feat(ui): Integrate TUI with Engine Loop and Status Bar Prompt
This commit is contained in:
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
51
crates/core/agent/src/messages.rs
Normal file
51
crates/core/agent/src/messages.rs
Normal 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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
67
crates/core/agent/src/state.rs
Normal file
67
crates/core/agent/src/state.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user