feat(ui): Integrate TUI with Engine Loop and Status Bar Prompt

This commit is contained in:
2025-12-26 19:31:48 +01:00
parent f610ed312c
commit af1a61a468
8 changed files with 269 additions and 30 deletions

View File

@@ -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::<messages::Message>(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::<Message>(100);
let (tx_ui, rx_ui) = tokio::sync::mpsc::channel::<Message>(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(())
}
}

View File

@@ -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<mpsc::Sender<Message>>,
shared_state: Option<Arc<tokio::sync::Mutex<EngineState>>>,
engine_rx: Option<mpsc::Receiver<Message>>,
// 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<Message>,
state: Arc<tokio::sync::Mutex<EngineState>>,
rx: mpsc::Receiver<Message>,
) {
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<dyn LlmProvider>,
@@ -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 <name>, /clear, /theme <name>
}
});
// 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 <name>, /clear, /theme <name>
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 <name>, /clear, /theme <name>
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 <name>, /clear, /theme <name>
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 <name>, /clear, /theme <name>
}
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 <name>, /clear, /theme <name>
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 <name>, /clear, /theme <name>
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(())
}

View File

@@ -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<String>,
}
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<String>) {
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"

View File

@@ -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)

View File

@@ -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<AuthManager>,
perms: permissions::PermissionManager,
settings: config_agent::Settings,
engine_tx: tokio::sync::mpsc::Sender<agent_core::messages::Message>,
shared_state: Arc<tokio::sync::Mutex<agent_core::state::AppState>>,
engine_rx: tokio::sync::mpsc::Receiver<agent_core::messages::Message>,
) -> 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
}

View File

@@ -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;

View File

@@ -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<String> },
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"),
}
}
}

View File

@@ -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<ChatMessage>,
pub running: bool,
pub mode: AppMode,
pub last_permission_result: Option<bool>,
pub permission_notify: Arc<Notify>,
}
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);
}
}