From af1a61a46828962f3a56488accc915a4e7a8a707 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Fri, 26 Dec 2025 19:31:48 +0100 Subject: [PATCH] feat(ui): Integrate TUI with Engine Loop and Status Bar Prompt --- crates/app/cli/src/main.rs | 26 ++--- crates/app/ui/src/app.rs | 123 ++++++++++++++++++--- crates/app/ui/src/components/status_bar.rs | 22 +++- crates/app/ui/src/events.rs | 2 + crates/app/ui/src/lib.rs | 6 +- crates/core/agent/src/lib.rs | 2 + crates/core/agent/src/messages.rs | 51 +++++++++ crates/core/agent/src/state.rs | 67 +++++++++++ 8 files changed, 269 insertions(+), 30 deletions(-) create mode 100644 crates/core/agent/src/messages.rs create mode 100644 crates/core/agent/src/state.rs diff --git a/crates/app/cli/src/main.rs b/crates/app/cli/src/main.rs index 868ac8e..5e548b7 100644 --- a/crates/app/cli/src/main.rs +++ b/crates/app/cli/src/main.rs @@ -1,9 +1,6 @@ mod commands; -mod messages; mod engine; -mod state; mod agent_manager; -mod tool_registry; use clap::{Parser, ValueEnum}; use color_eyre::eyre::{Result, eyre}; @@ -19,6 +16,8 @@ use serde::Serialize; use std::io::Write; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; +use agent_core::messages::{Message, UserAction, AgentResponse}; +use agent_core::state::{AppState, AppMode}; pub use commands::{BuiltinCommands, CommandResult}; @@ -563,7 +562,7 @@ async fn main() -> Result<()> { c } else if let Some(plugin_path) = app_context.plugin_manager.all_commands().get(&command_name) { // Found in plugins - tools_fs::read_file(&plugin_path.to_string_lossy())? + tools_fs::read_file(&plugin_path.to_string_lossy())? } else { return Err(eyre!( "Slash command '{}' not found in .owlen/commands/ or plugins", @@ -762,19 +761,18 @@ async fn main() -> Result<()> { let opts = ChatOptions::new(&model); // Initialize async engine infrastructure - let (tx, rx) = tokio::sync::mpsc::channel::(100); - // Keep tx for future use (will be passed to UI/REPL) - let _tx = tx.clone(); + let (tx_engine, rx_engine) = tokio::sync::mpsc::channel::(100); + let (tx_ui, rx_ui) = tokio::sync::mpsc::channel::(100); // Create shared state - let state = Arc::new(tokio::sync::Mutex::new(crate::state::AppState::new())); + let state = Arc::new(tokio::sync::Mutex::new(AppState::new())); // Spawn the Engine Loop let client_clone = client.clone(); - let tx_clone = tx.clone(); let state_clone = state.clone(); + let tx_ui_engine = tx_ui.clone(); tokio::spawn(async move { - engine::run_engine_loop(rx, tx_clone, client_clone, state_clone).await; + engine::run_engine_loop(rx_engine, tx_ui_engine, client_clone, state_clone).await; }); // Check if interactive mode (no prompt provided) @@ -789,9 +787,7 @@ async fn main() -> Result<()> { let _token_refresher = auth_manager.clone().start_background_refresh(); // Launch TUI with multi-provider support - // Note: For now, TUI doesn't use plugin manager directly - // In the future, we'll integrate plugin commands into TUI - return ui::run_with_providers(auth_manager, perms, settings).await; + return ui::run_with_providers(auth_manager, perms, settings, tx_engine, state, rx_ui).await; } // Legacy text-based REPL @@ -1083,7 +1079,7 @@ async fn main() -> Result<()> { session_id, messages: vec![ serde_json::json!({"role": "user", "content": prompt}), - serde_json::json!({"role": "assistant", "content": response}), + serde_json::json!({"role": "assistant", "content": response}) ], stats: Stats { total_tokens: estimated_tokens, @@ -1136,4 +1132,4 @@ async fn main() -> Result<()> { } Ok(()) -} +} \ No newline at end of file diff --git a/crates/app/ui/src/app.rs b/crates/app/ui/src/app.rs index 7baa468..f3f10d5 100644 --- a/crates/app/ui/src/app.rs +++ b/crates/app/ui/src/app.rs @@ -48,6 +48,9 @@ enum ProviderMode { Multi(ProviderManager), } +use agent_core::messages::{Message, UserAction, AgentResponse}; +use agent_core::state::{AppState as EngineState}; + pub struct TuiApp { // UI components chat_panel: ChatPanel, @@ -76,6 +79,11 @@ pub struct TuiApp { #[allow(dead_code)] settings: config_agent::Settings, + // Engine integration + engine_tx: Option>, + shared_state: Option>>, + engine_rx: Option>, + // Runtime state running: bool, waiting_for_llm: bool, @@ -85,6 +93,17 @@ pub struct TuiApp { } impl TuiApp { + /// Set the engine components + pub fn set_engine( + &mut self, + tx: mpsc::Sender, + state: Arc>, + rx: mpsc::Receiver, + ) { + self.engine_tx = Some(tx); + self.shared_state = Some(state); + self.engine_rx = Some(rx); + } /// Create a new TUI app with a single provider (legacy mode) pub fn new( client: Arc, @@ -122,6 +141,9 @@ impl TuiApp { perms, ctx: ToolContext::new(), settings, + engine_tx: None, + shared_state: None, + engine_rx: None, running: true, waiting_for_llm: false, pending_tool: None, @@ -171,6 +193,9 @@ impl TuiApp { perms, ctx: ToolContext::new(), settings, + engine_tx: None, + shared_state: None, + engine_rx: None, running: true, waiting_for_llm: false, pending_tool: None, @@ -431,6 +456,16 @@ Commands: /help, /model , /clear, /theme } }); + // Spawn engine event listener + if let Some(mut rx) = self.engine_rx.take() { + let tx_clone = event_tx.clone(); + tokio::spawn(async move { + while let Some(msg) = rx.recv().await { + let _ = tx_clone.send(AppEvent::EngineMessage(msg)); + } + }); + } + // Show first-run welcome message if this is the first time if config_agent::is_first_run() { self.chat_panel.add_message(ChatMessage::System( @@ -553,6 +588,9 @@ Commands: /help, /model , /clear, /theme if let Some(tx) = self.permission_tx.take() { let _ = tx.send(true); } + if let Some(tx) = &self.engine_tx { + let _ = tx.send(Message::UserAction(UserAction::PermissionResult(true))).await; + } } PermissionOption::AlwaysAllow => { // Add rule to permission manager @@ -569,6 +607,9 @@ Commands: /help, /model , /clear, /theme if let Some(tx) = self.permission_tx.take() { let _ = tx.send(true); } + if let Some(tx) = &self.engine_tx { + let _ = tx.send(Message::UserAction(UserAction::PermissionResult(true))).await; + } } PermissionOption::Deny => { self.chat_panel.add_message(ChatMessage::System( @@ -577,6 +618,9 @@ Commands: /help, /model , /clear, /theme if let Some(tx) = self.permission_tx.take() { let _ = tx.send(false); } + if let Some(tx) = &self.engine_tx { + let _ = tx.send(Message::UserAction(UserAction::PermissionResult(false))).await; + } } PermissionOption::Explain => { // Show explanation @@ -602,6 +646,8 @@ Commands: /help, /model , /clear, /theme } self.permission_popup = None; self.pending_tool = None; + self.status_bar.set_state(crate::components::AppState::Idle); + self.status_bar.set_pending_permission(None); } return Ok(()); } @@ -717,6 +763,51 @@ Commands: /help, /model , /clear, /theme AppEvent::ToggleTodo => { self.todo_panel.toggle(); } + AppEvent::EngineMessage(msg) => { + match msg { + Message::AgentResponse(res) => { + match res { + AgentResponse::Token(t) => { + // Map to LlmChunk + if !self.waiting_for_llm { + self.waiting_for_llm = true; + self.chat_panel.set_streaming(true); + } + self.chat_panel.append_to_assistant(&t); + } + AgentResponse::Complete => { + self.waiting_for_llm = false; + self.chat_panel.set_streaming(false); + // TODO: Get full response from state or accumulate here + } + AgentResponse::Error(e) => { + self.waiting_for_llm = false; + self.chat_panel.set_streaming(false); + self.chat_panel.add_message(ChatMessage::System(format!("Error: {}", e))); + } + AgentResponse::PermissionRequest { tool, context } => { + self.permission_popup = Some(PermissionPopup::new(tool.clone(), context, self.theme.clone())); + self.status_bar.set_state(crate::components::AppState::WaitingPermission); + self.status_bar.set_pending_permission(Some(tool)); + } + AgentResponse::ToolCall { name, args } => { + self.chat_panel.add_message(ChatMessage::ToolCall { name, args }); + } + } + } + Message::System(sys) => { + match sys { + agent_core::messages::SystemNotification::Warning(w) => { + self.chat_panel.add_message(ChatMessage::System(format!("Warning: {}", w))); + } + agent_core::messages::SystemNotification::StateUpdate(_s) => { + // Handle state updates if needed + } + } + } + _ => {} + } + } AppEvent::SwitchProvider(provider_type) => { let _ = self.switch_provider(provider_type); } @@ -918,21 +1009,27 @@ Commands: /help, /model , /clear, /theme self.chat_panel.set_streaming(true); let _ = event_tx.send(AppEvent::StreamStart); - // Spawn streaming in background task - let opts = self.opts.clone(); - let tx = event_tx.clone(); - let message_owned = message.clone(); + // Send to engine + if let Some(tx) = &self.engine_tx { + let _ = tx.send(Message::UserAction(UserAction::Input(message.clone()))).await; + } else { + // Fallback to legacy background stream if no engine + // Spawn streaming in background task + let opts = self.opts.clone(); + let tx = event_tx.clone(); + let message_owned = message.clone(); - tokio::spawn(async move { - match Self::run_background_stream(client, opts, message_owned, tx.clone()).await { - Ok(response) => { - let _ = tx.send(AppEvent::StreamEnd { response }); + tokio::spawn(async move { + match Self::run_background_stream(client, opts, message_owned, tx.clone()).await { + Ok(response) => { + let _ = tx.send(AppEvent::StreamEnd { response }); + } + Err(e) => { + let _ = tx.send(AppEvent::StreamError(e.to_string())); + } } - Err(e) => { - let _ = tx.send(AppEvent::StreamError(e.to_string())); - } - } - }); + }); + } Ok(()) } diff --git a/crates/app/ui/src/components/status_bar.rs b/crates/app/ui/src/components/status_bar.rs index c903085..5810c3b 100644 --- a/crates/app/ui/src/components/status_bar.rs +++ b/crates/app/ui/src/components/status_bar.rs @@ -8,7 +8,7 @@ use agent_core::SessionStats; use permissions::Mode; use ratatui::{ layout::Rect, - style::Style, + style::{Style, Stylize}, text::{Line, Span}, widgets::Paragraph, Frame, @@ -45,6 +45,7 @@ pub struct StatusBar { estimated_cost: f64, planning_mode: bool, theme: Theme, + pending_permission: Option, } impl StatusBar { @@ -60,9 +61,15 @@ impl StatusBar { estimated_cost: 0.0, planning_mode: false, theme, + pending_permission: None, } } + /// Set pending permission tool name + pub fn set_pending_permission(&mut self, tool: Option) { + self.pending_permission = tool; + } + /// Set the active provider pub fn set_provider(&mut self, provider: Provider) { self.provider = provider; @@ -120,6 +127,19 @@ impl StatusBar { let sep = self.theme.symbols.vertical_separator; let sep_style = Style::default().fg(self.theme.palette.border); + // If waiting for permission, show prompt instead of normal status + if let (AppState::WaitingPermission, Some(tool)) = (self.state, &self.pending_permission) { + let prompt_spans = vec![ + Span::styled(" ALLOW TOOL: ", self.theme.status_dim), + Span::styled(tool, Style::default().fg(self.theme.palette.warning).bold()), + Span::styled("? (y/n/a/e) ", self.theme.status_dim), + ]; + let line = Line::from(prompt_spans); + let paragraph = Paragraph::new(line); + frame.render_widget(paragraph, area); + return; + } + // Permission mode let mode_str = if self.planning_mode { "PLAN" diff --git a/crates/app/ui/src/events.rs b/crates/app/ui/src/events.rs index 0ff1799..3bc3616 100644 --- a/crates/app/ui/src/events.rs +++ b/crates/app/ui/src/events.rs @@ -36,6 +36,8 @@ pub enum AppEvent { ScrollDown, /// Toggle the todo panel ToggleTodo, + /// Message from the background engine + EngineMessage(agent_core::messages::Message), /// Switch to a specific provider SwitchProvider(ProviderType), /// Cycle to the next provider (Tab key) diff --git a/crates/app/ui/src/lib.rs b/crates/app/ui/src/lib.rs index 1da1e70..1f5073a 100644 --- a/crates/app/ui/src/lib.rs +++ b/crates/app/ui/src/lib.rs @@ -33,13 +33,17 @@ pub async fn run( app.run().await } -/// Run the TUI application with multi-provider support +/// Run the TUI application with multi-provider support and engine integration pub async fn run_with_providers( auth_manager: Arc, perms: permissions::PermissionManager, settings: config_agent::Settings, + engine_tx: tokio::sync::mpsc::Sender, + shared_state: Arc>, + engine_rx: tokio::sync::mpsc::Receiver, ) -> Result<()> { let provider_manager = ProviderManager::new(auth_manager, settings.clone()); let mut app = TuiApp::with_provider_manager(provider_manager, perms, settings)?; + app.set_engine(engine_tx, shared_state, engine_rx); app.run().await } diff --git a/crates/core/agent/src/lib.rs b/crates/core/agent/src/lib.rs index a2d9ce4..ef20d29 100644 --- a/crates/core/agent/src/lib.rs +++ b/crates/core/agent/src/lib.rs @@ -7,6 +7,8 @@ pub mod session; pub mod system_prompt; pub mod git; pub mod compact; +pub mod messages; +pub mod state; use color_eyre::eyre::{Result, eyre}; use futures_util::StreamExt; diff --git a/crates/core/agent/src/messages.rs b/crates/core/agent/src/messages.rs new file mode 100644 index 0000000..718cee8 --- /dev/null +++ b/crates/core/agent/src/messages.rs @@ -0,0 +1,51 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Message { + UserAction(UserAction), + AgentResponse(AgentResponse), + System(SystemNotification), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum UserAction { + Input(String), + Command(String), + PermissionResult(bool), + Exit, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum AgentResponse { + Token(String), + ToolCall { name: String, args: String }, + PermissionRequest { tool: String, context: Option }, + Complete, + Error(String), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum SystemNotification { + StateUpdate(String), + Warning(String), +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::sync::mpsc; + + #[tokio::test] + async fn test_message_channel() { + let (tx, mut rx) = mpsc::channel(32); + + let msg = Message::UserAction(UserAction::Input("Hello".to_string())); + tx.send(msg).await.unwrap(); + + let received = rx.recv().await.unwrap(); + match received { + Message::UserAction(UserAction::Input(s)) => assert_eq!(s, "Hello"), + _ => panic!("Wrong message type"), + } + } +} diff --git a/crates/core/agent/src/state.rs b/crates/core/agent/src/state.rs new file mode 100644 index 0000000..678a45b --- /dev/null +++ b/crates/core/agent/src/state.rs @@ -0,0 +1,67 @@ +use std::sync::Arc; +use tokio::sync::{Mutex, Notify}; +use llm_core::ChatMessage; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum AppMode { + Normal, + Plan, + AcceptAll, +} + +impl Default for AppMode { + fn default() -> Self { + Self::Normal + } +} + +/// Shared application state +#[derive(Debug, Default)] +pub struct AppState { + pub messages: Vec, + pub running: bool, + pub mode: AppMode, + pub last_permission_result: Option, + pub permission_notify: Arc, +} + +impl AppState { + pub fn new() -> Self { + Self { + messages: Vec::new(), + running: true, + mode: AppMode::Normal, + last_permission_result: None, + permission_notify: Arc::new(Notify::new()), + } + } + + pub fn add_message(&mut self, message: ChatMessage) { + self.messages.push(message); + } + + pub fn set_permission_result(&mut self, result: bool) { + self.last_permission_result = Some(result); + self.permission_notify.notify_one(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_app_state_sharing() { + let state = Arc::new(Mutex::new(AppState::new())); + + let state_clone = state.clone(); + tokio::spawn(async move { + let mut guard = state_clone.lock().await; + guard.add_message(ChatMessage::user("Test")); + }).await.unwrap(); + + let guard = state.lock().await; + assert_eq!(guard.messages.len(), 1); + } +}