From 9e96eec964e5de97f7fffe7836879516379e58dd Mon Sep 17 00:00:00 2001 From: oiupoyt Date: Sat, 26 Sep 2026 16:03:46 +0530 Subject: [PATCH] feat(tui): add sessions, double-tap esc to quit, tool execution, and minimal UI symbols --- src/lib.rs | 4 + src/persona.rs | 15 ++++ src/session.rs | 169 ++++++++++++++++++++++++++++++++++++ src/tool.rs | 167 ++++++++++++++++++++++++++++++++++++ src/tui/app.rs | 132 ++++++++++++++++++++++++++--- src/tui/mod.rs | 213 +++++++++++++++++++++++++++++++++++++++++++--- src/tui/ui.rs | 226 +++++++++++++++++++++++++++++++++++-------------- 7 files changed, 842 insertions(+), 84 deletions(-) create mode 100644 src/session.rs create mode 100644 src/tool.rs diff --git a/src/lib.rs b/src/lib.rs index cd2a1b7..00b629a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,8 @@ pub mod persona; pub mod pipeline; pub mod registry; pub mod router; +pub mod session; +pub mod tool; pub use budget::{BudgetExceeded, BudgetStatus, BudgetTracker, CreditTier}; pub use client::{ClientError, ModelClient, OpenRouterClient}; @@ -15,3 +17,5 @@ pub use persona::{DispatchResult, ModelAttempt, ModelCandidate, Persona, Persona pub use pipeline::{PipelineConfig, PipelineEvent, PipelineExecutor, PipelineStep}; pub use registry::{ModelInfo, ModelPricing, ModelRegistry}; pub use router::{KeywordRouter, LlmRouter, RouteDecision, RouteStage, Router}; +pub use session::{Session, SessionMessage, SessionSummary}; +pub use tool::{ToolEngine, ToolOutput}; diff --git a/src/persona.rs b/src/persona.rs index ea3deb2..9f9977b 100644 --- a/src/persona.rs +++ b/src/persona.rs @@ -102,6 +102,21 @@ impl PersonaRegistry { Ok(eligible) } } + + /// Returns a new PersonaRegistry with tool execution prompts injected into persona system prompts + pub fn with_tool_capabilities(&self) -> Self { + let tool_prompt = crate::tool::ToolEngine::system_tool_prompt(); + let personas = self + .personas + .iter() + .map(|p| { + let mut cp = p.clone(); + cp.system_prompt.push_str(tool_prompt); + cp + }) + .collect(); + Self { personas } + } } #[cfg(test)] diff --git a/src/session.rs b/src/session.rs new file mode 100644 index 0000000..3751d9d --- /dev/null +++ b/src/session.rs @@ -0,0 +1,169 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::io; +use std::path::Path; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionMessage { + pub sender: String, + pub content: String, + pub model: Option, + pub timestamp: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Session { + pub id: String, + pub title: String, + pub persona: String, + pub created_at: String, + pub updated_at: String, + pub messages: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionSummary { + pub id: String, + pub title: String, + pub persona: String, + pub updated_at: String, + pub message_count: usize, +} + +impl Session { + pub fn new(persona: &str) -> Self { + let now: DateTime = Utc::now(); + let timestamp_str = now.format("%Y%m%d-%H%M%S").to_string(); + let id = format!("session-{timestamp_str}"); + + Self { + id, + title: "New Session".to_string(), + persona: persona.to_string(), + created_at: now.to_rfc3339(), + updated_at: now.to_rfc3339(), + messages: Vec::new(), + } + } + + pub fn add_message(&mut self, sender: &str, content: &str, model: Option<&str>) { + let now: DateTime = Utc::now(); + self.updated_at = now.to_rfc3339(); + + // Update session title from first user prompt if still default + if self.title == "New Session" && sender.eq_ignore_ascii_case("you") { + let truncated: String = content.chars().take(40).collect(); + self.title = truncated.trim().to_string(); + } + + self.messages.push(SessionMessage { + sender: sender.to_string(), + content: content.to_string(), + model: model.map(|m| m.to_string()), + timestamp: now.to_rfc3339(), + }); + } + + pub fn save(&self, base_dir: &Path) -> Result<(), io::Error> { + let sessions_dir = base_dir.join("sessions"); + fs::create_dir_all(&sessions_dir)?; + + let file_path = sessions_dir.join(format!("{}.json", self.id)); + let json = serde_json::to_string_pretty(self) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + fs::write(file_path, json)?; + + // Also save latest symlink/copy for convenient continuation + let latest_path = sessions_dir.join("latest.json"); + let _ = fs::write( + latest_path, + serde_json::to_string_pretty(self).unwrap_or_default(), + ); + + Ok(()) + } + + pub fn load(base_dir: &Path, id: &str) -> Result { + let sessions_dir = base_dir.join("sessions"); + let file_path = if id == "latest" { + sessions_dir.join("latest.json") + } else { + sessions_dir.join(format!("{id}.json")) + }; + + let content = fs::read_to_string(file_path)?; + let session: Session = serde_json::from_str(&content) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + Ok(session) + } + + pub fn list(base_dir: &Path) -> Result, io::Error> { + let sessions_dir = base_dir.join("sessions"); + if !sessions_dir.exists() { + return Ok(Vec::new()); + } + + let mut list = Vec::new(); + for entry in fs::read_dir(sessions_dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("json") { + let filename = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or_default(); + if filename == "latest" { + continue; + } + if let Ok(content) = fs::read_to_string(&path) { + if let Ok(session) = serde_json::from_str::(&content) { + list.push(SessionSummary { + id: session.id, + title: session.title, + persona: session.persona, + updated_at: session.updated_at, + message_count: session.messages.len(), + }); + } + } + } + } + + list.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + Ok(list) + } + + pub fn delete(base_dir: &Path, id: &str) -> Result<(), io::Error> { + let file_path = base_dir.join("sessions").join(format!("{id}.json")); + if file_path.exists() { + fs::remove_file(file_path)?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn test_session_lifecycle_save_and_load() { + let dir = tempdir().unwrap(); + let mut session = Session::new("coder"); + session.add_message("you", "Write an LRU cache", None); + session.add_message("coder", "Here is the code", Some("qwen")); + + assert_eq!(session.title, "Write an LRU cache"); + session.save(dir.path()).expect("save succeeds"); + + let loaded = Session::load(dir.path(), &session.id).expect("load succeeds"); + assert_eq!(loaded.messages.len(), 2); + assert_eq!(loaded.persona, "coder"); + + let summaries = Session::list(dir.path()).expect("list succeeds"); + assert_eq!(summaries.len(), 1); + assert_eq!(summaries[0].id, session.id); + } +} diff --git a/src/tool.rs b/src/tool.rs new file mode 100644 index 0000000..88fb483 --- /dev/null +++ b/src/tool.rs @@ -0,0 +1,167 @@ +use serde::{Deserialize, Serialize}; +use std::path::Path; +use std::time::Duration; +use tokio::process::Command; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolOutput { + pub command: String, + pub exit_code: i32, + pub stdout: String, + pub stderr: String, + pub duration_ms: u64, +} + +impl ToolOutput { + pub fn is_success(&self) -> bool { + self.exit_code == 0 + } + + pub fn format_for_model(&self) -> String { + let mut out = format!( + "[Command: `{}` exit code: {}]\n", + self.command, self.exit_code + ); + if !self.stdout.is_empty() { + out.push_str("stdout:\n"); + out.push_str(&self.stdout); + if !self.stdout.ends_with('\n') { + out.push('\n'); + } + } + if !self.stderr.is_empty() { + out.push_str("stderr:\n"); + out.push_str(&self.stderr); + if !self.stderr.ends_with('\n') { + out.push('\n'); + } + } + if self.stdout.is_empty() && self.stderr.is_empty() { + out.push_str("(No output produced)\n"); + } + out + } +} + +pub struct ToolEngine; + +impl ToolEngine { + /// Executes a bash command locally with timeout and output capture + pub async fn execute_bash( + command: &str, + cwd: Option<&Path>, + timeout_duration: Duration, + ) -> Result { + let start = std::time::Instant::now(); + + let mut cmd = Command::new("bash"); + cmd.arg("-c").arg(command); + + if let Some(dir) = cwd { + cmd.current_dir(dir); + } + + let child = cmd.output(); + + match tokio::time::timeout(timeout_duration, child).await { + Ok(Ok(output)) => { + let duration_ms = start.elapsed().as_millis() as u64; + let exit_code = output.status.code().unwrap_or(-1); + + let stdout_raw = String::from_utf8_lossy(&output.stdout); + let stderr_raw = String::from_utf8_lossy(&output.stderr); + + // Truncate to maximum 32KB to protect token context + let stdout = truncate_output(&stdout_raw, 32 * 1024); + let stderr = truncate_output(&stderr_raw, 16 * 1024); + + Ok(ToolOutput { + command: command.to_string(), + exit_code, + stdout, + stderr, + duration_ms, + }) + } + Ok(Err(e)) => Err(format!("Process spawn error: {e}")), + Err(_) => Err(format!( + "Command timed out after {}s", + timeout_duration.as_secs() + )), + } + } + + /// Extracts any command execution requests formatted as: + /// `cmd`, `cmd`, or markdown ```bash blocks tagged for execution + pub fn extract_command_calls(text: &str) -> Vec { + let mut commands = Vec::new(); + + // 1. Check for ... tags + let mut rest = text; + while let Some(start_idx) = rest.find("") { + let after_start = &rest[start_idx + 5..]; + if let Some(end_idx) = after_start.find("") { + let cmd = after_start[..end_idx].trim(); + if !cmd.is_empty() { + commands.push(cmd.to_string()); + } + rest = &after_start[end_idx + 6..]; + } else { + break; + } + } + + // 2. Check for ... tags + let mut rest_bash = text; + while let Some(start_idx) = rest_bash.find("") { + let after_start = &rest_bash[start_idx + 6..]; + if let Some(end_idx) = after_start.find("") { + let cmd = after_start[..end_idx].trim(); + if !cmd.is_empty() && !commands.contains(&cmd.to_string()) { + commands.push(cmd.to_string()); + } + rest_bash = &after_start[end_idx + 7..]; + } else { + break; + } + } + + commands + } + + /// Tool instructions appended to persona system prompts + pub fn system_tool_prompt() -> &'static str { + "\n\n[TOOL CAPABILITY: LOCAL BASH]\nYou can execute local shell commands on the user's system when needed (e.g. running tests, reading files, inspecting directories, running git/cargo/python/npm). To run a command, output it enclosed in ... tags, for example:\ncargo check\nor\ngit status\nThe command output will be executed and returned to you." + } +} + +fn truncate_output(s: &str, max_bytes: usize) -> String { + if s.len() <= max_bytes { + s.to_string() + } else { + let truncated = &s[..max_bytes]; + format!("{truncated}\n...[output truncated by BrokeCrew]") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_command_calls() { + let text = "Let me check the repository status:\ngit status\nand also list files:\nls -la"; + let calls = ToolEngine::extract_command_calls(text); + assert_eq!(calls, vec!["git status", "ls -la"]); + } + + #[tokio::test] + async fn test_execute_echo_command() { + let res = + ToolEngine::execute_bash("echo 'hello from brokecrew'", None, Duration::from_secs(5)) + .await + .expect("execution succeeds"); + assert_eq!(res.exit_code, 0); + assert!(res.stdout.contains("hello from brokecrew")); + } +} diff --git a/src/tui/app.rs b/src/tui/app.rs index 27a0536..3fdc02a 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -1,5 +1,8 @@ use brokecrew::persona::Tier; use brokecrew::pipeline::PipelineConfig; +use brokecrew::session::{Session, SessionSummary}; +use std::path::PathBuf; +use std::time::Instant; #[derive(Debug, Clone, PartialEq, Eq)] pub enum MessageSender { @@ -16,6 +19,10 @@ pub enum MessageSender { model: String, tier: Tier, }, + Tool { + command: String, + exit_code: i32, + }, System, } @@ -31,6 +38,7 @@ pub enum TuiMode { Normal, PersonaSelect, PipelineSelect, + SessionSelect, } #[derive(Debug, Clone)] @@ -61,18 +69,18 @@ pub struct WorkingsState { impl Default for WorkingsState { fn default() -> Self { Self { - active_persona: "Auto-Route".to_string(), + active_persona: "auto".to_string(), active_pipeline: None, route_stage: None, route_details: None, candidate_attempts: Vec::new(), events: vec![ format!( - "{} ⚡ BrokeCrew AI Team online", + "{} brokecrew online", chrono::Local::now().format("%H:%M:%S") ), format!( - "{} ℹ Catalog & budget initialized", + "{} catalog and budget initialized", chrono::Local::now().format("%H:%M:%S") ), ], @@ -91,6 +99,9 @@ pub struct AppState { pub scroll_offset: u16, pub auto_scroll: bool, + // Esc confirmation + pub esc_pressed_at: Option, + // Personas & selection pub persona_items: Vec, pub selected_persona_idx: usize, @@ -100,7 +111,13 @@ pub struct AppState { pub selected_pipeline_idx: usize, pub active_pipeline_for_prompt: Option, - // Team & Routing Workings telemetry + // Sessions & persistence + pub active_session: Session, + pub session_summaries: Vec, + pub selected_session_idx: usize, + pub config_dir: PathBuf, + + // Workings telemetry pub workings: WorkingsState, // Rate limits & budget @@ -121,12 +138,13 @@ impl AppState { pipelines: Vec, minute_limit: usize, daily_limit: usize, + config_dir: PathBuf, ) -> Self { let mut persona_items = Vec::new(); // Item 0 is always the smart Auto-Router persona_items.push(PersonaItem { - name: "Auto-Route".to_string(), - description: "Stage A keyword + Stage B LLM classifier".to_string(), + name: "auto".to_string(), + description: "keyword filter + LLM classifier".to_string(), }); for (name, desc) in raw_personas { persona_items.push(PersonaItem { @@ -135,10 +153,13 @@ impl AppState { }); } + let session = Session::new("auto"); + let session_summaries = Session::list(&config_dir).unwrap_or_default(); + Self { messages: vec![ChatMessage { sender: MessageSender::System, - content: "⚡ BrokeCrew AI Team initialized. Press [Tab] to switch persona, [Ctrl+P] for pipelines, [Ctrl+L] to clear.".to_string(), + content: "brokecrew initialized. [Tab] persona | [Ctrl+P] pipeline | [Ctrl+S] sessions | [Ctrl+L] clear".to_string(), fell_back: false, }], input: String::new(), @@ -146,14 +167,19 @@ impl AppState { mode: TuiMode::Normal, is_generating: false, spinner_tick: 0, - status_message: "Ready".to_string(), + status_message: "ready".to_string(), scroll_offset: 0, auto_scroll: true, + esc_pressed_at: None, persona_items, selected_persona_idx: 0, pipelines, selected_pipeline_idx: 0, active_pipeline_for_prompt: None, + active_session: session, + session_summaries, + selected_session_idx: 0, + config_dir, workings: WorkingsState::default(), minute_requests: 0, minute_limit, @@ -167,11 +193,11 @@ impl AppState { pub fn current_persona_name(&self) -> &str { if self.selected_persona_idx == 0 { - "Auto-Route" + "auto" } else if let Some(item) = self.persona_items.get(self.selected_persona_idx) { &item.name } else { - "Auto-Route" + "auto" } } @@ -210,6 +236,7 @@ impl AppState { } pub fn add_user_message(&mut self, text: String) { + self.active_session.add_message("you", &text, None); self.messages.push(ChatMessage { sender: MessageSender::User, content: text, @@ -259,6 +286,19 @@ impl AppState { } } + pub fn add_tool_result_message(&mut self, command: String, exit_code: i32, output: String) { + self.active_session + .add_message("tool", &format!("$ {}\n{}", command, output), None); + self.messages.push(ChatMessage { + sender: MessageSender::Tool { command, exit_code }, + content: output, + fell_back: false, + }); + if self.auto_scroll { + self.scroll_offset = 0; + } + } + pub fn append_to_current_message(&mut self, chunk: &str) { if let Some(msg) = self.messages.last_mut() { msg.content.push_str(chunk); @@ -279,19 +319,84 @@ impl AppState { }); } + pub fn save_current_session(&mut self) { + self.active_session.messages.clear(); + // Sync active_session messages + for msg in &self.messages { + let role = match &msg.sender { + MessageSender::User => "you", + MessageSender::Persona { name, .. } => name.as_str(), + MessageSender::PipelineStep { persona, .. } => persona.as_str(), + MessageSender::Tool { .. } => "tool", + MessageSender::System => "system", + }; + let model = match &msg.sender { + MessageSender::Persona { model, .. } => Some(model.as_str()), + MessageSender::PipelineStep { model, .. } => Some(model.as_str()), + _ => None, + }; + self.active_session.add_message(role, &msg.content, model); + } + + let _ = self.active_session.save(&self.config_dir); + self.session_summaries = Session::list(&self.config_dir).unwrap_or_default(); + } + + pub fn load_session_by_id(&mut self, session_id: &str) -> Result<(), String> { + let loaded = Session::load(&self.config_dir, session_id).map_err(|e| e.to_string())?; + self.messages.clear(); + + for m in &loaded.messages { + let sender = match m.sender.to_lowercase().as_str() { + "you" | "user" => MessageSender::User, + "system" => MessageSender::System, + "tool" => MessageSender::Tool { + command: "exec".to_string(), + exit_code: 0, + }, + other => MessageSender::Persona { + name: other.to_string(), + model: m.model.clone().unwrap_or_else(|| "unknown".to_string()), + tier: Tier::Free, + }, + }; + self.messages.push(ChatMessage { + sender, + content: m.content.clone(), + fell_back: false, + }); + } + + self.active_session = loaded; + self.scroll_offset = 0; + self.log_working_event(&format!("[session] loaded {}", session_id)); + Ok(()) + } + + pub fn start_new_session(&mut self) { + let persona = self.current_persona_name().to_string(); + self.active_session = Session::new(&persona); + self.messages.clear(); + self.add_system_message("Started fresh session.".to_string()); + self.scroll_offset = 0; + self.log_working_event("[session] started fresh session"); + } + pub fn clear_chat(&mut self) { self.messages.clear(); self.add_system_message("Chat history cleared.".to_string()); self.scroll_offset = 0; - self.log_working_event("Chat history cleared"); + self.log_working_event("[chat] history cleared"); } pub fn insert_char(&mut self, c: char) { + self.esc_pressed_at = None; self.input.insert(self.cursor_pos, c); self.cursor_pos += c.len_utf8(); } pub fn delete_char_before_cursor(&mut self) { + self.esc_pressed_at = None; if self.cursor_pos > 0 { let mut prev = self.cursor_pos - 1; while !self.input.is_char_boundary(prev) && prev > 0 { @@ -303,12 +408,14 @@ impl AppState { } pub fn delete_char_under_cursor(&mut self) { + self.esc_pressed_at = None; if self.cursor_pos < self.input.len() { self.input.remove(self.cursor_pos); } } pub fn move_cursor_left(&mut self) { + self.esc_pressed_at = None; if self.cursor_pos > 0 { let mut prev = self.cursor_pos - 1; while !self.input.is_char_boundary(prev) && prev > 0 { @@ -319,6 +426,7 @@ impl AppState { } pub fn move_cursor_right(&mut self) { + self.esc_pressed_at = None; if self.cursor_pos < self.input.len() { let mut next = self.cursor_pos + 1; while !self.input.is_char_boundary(next) && next < self.input.len() { @@ -329,10 +437,12 @@ impl AppState { } pub fn move_cursor_start(&mut self) { + self.esc_pressed_at = None; self.cursor_pos = 0; } pub fn move_cursor_end(&mut self) { + self.esc_pressed_at = None; self.cursor_pos = self.input.len(); } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 269da6f..23d2c8f 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -9,6 +9,8 @@ use brokecrew::persona::Tier; use brokecrew::pipeline::{PipelineEvent, PipelineExecutor}; use brokecrew::registry::ModelRegistry; use brokecrew::router::{RouteStage, Router}; +use brokecrew::session::Session; +use brokecrew::tool::ToolEngine; use crossterm::{ event::{Event, EventStream, KeyCode, KeyEvent, KeyModifiers}, execute, @@ -46,6 +48,14 @@ pub enum WorkerEvent { model: String, error: String, }, + ToolExecutionStarted { + command: String, + }, + ToolExecutionCompleted { + command: String, + exit_code: i32, + output: String, + }, StepStarted { step_index: usize, total_steps: usize, @@ -112,6 +122,7 @@ pub async fn run_tui( config.pipelines.clone(), minute_limit, daily_limit, + config.config_dir.clone(), ); app.allow_stealth = allow_stealth || config.allow_stealth; app.escalate = escalate; @@ -238,6 +249,20 @@ fn handle_worker_event(app: &mut AppState, budget_tracker: &mut BudgetTracker, e app.log_working_event(&format!("[fallback] failed {}: {}", model, error)); } + WorkerEvent::ToolExecutionStarted { command } => { + app.status_message = format!("Running tool: {command}"); + app.log_working_event(&format!("[tool:start] $ {command}")); + } + + WorkerEvent::ToolExecutionCompleted { + command, + exit_code, + output, + } => { + app.add_tool_result_message(command.clone(), exit_code, output); + app.log_working_event(&format!("[tool:done] $ {command} -> exit {exit_code}")); + } + WorkerEvent::StepStarted { step_index, total_steps, @@ -313,7 +338,7 @@ fn handle_worker_event(app: &mut AppState, budget_tracker: &mut BudgetTracker, e } if let Some(err) = error { - app.add_system_message(format!("❌ Error: {err}")); + app.add_system_message(format!("! error: {err}")); app.log_working_event(&format!("[error] {err}")); } else { app.log_working_event(&format!("[complete] response finished ({model_used})")); @@ -325,6 +350,9 @@ fn handle_worker_event(app: &mut AppState, budget_tracker: &mut BudgetTracker, e for _ in 0..paid_calls_made { let _ = budget_tracker.record_call(true); } + + // Auto-save session state + app.save_current_session(); } } } @@ -389,14 +417,58 @@ fn handle_key_event( _ => {} }, + TuiMode::SessionSelect => match key.code { + KeyCode::Esc => { + app.mode = TuiMode::Normal; + } + KeyCode::Up => { + app.selected_session_idx = app.selected_session_idx.saturating_sub(1); + } + KeyCode::Down => { + if app.selected_session_idx + 1 < app.session_summaries.len() { + app.selected_session_idx += 1; + } + } + KeyCode::Enter => { + if let Some(summary) = app.session_summaries.get(app.selected_session_idx).cloned() + { + let _ = app.load_session_by_id(&summary.id); + } + app.mode = TuiMode::Normal; + } + KeyCode::Char('d') => { + if let Some(summary) = app.session_summaries.get(app.selected_session_idx).cloned() + { + let _ = Session::delete(&app.config_dir, &summary.id); + app.session_summaries = Session::list(&app.config_dir).unwrap_or_default(); + if app.selected_session_idx >= app.session_summaries.len() { + app.selected_session_idx = app.session_summaries.len().saturating_sub(1); + } + app.log_working_event(&format!("[session] deleted {}", summary.id)); + } + } + _ => {} + }, + TuiMode::Normal => { // Mode switching and shortcuts if key.modifiers.contains(KeyModifiers::CONTROL) { + app.esc_pressed_at = None; match key.code { KeyCode::Char('p') => { app.mode = TuiMode::PipelineSelect; return false; } + KeyCode::Char('s') => { + app.session_summaries = Session::list(&app.config_dir).unwrap_or_default(); + app.selected_session_idx = 0; + app.mode = TuiMode::SessionSelect; + return false; + } + KeyCode::Char('n') => { + app.start_new_session(); + return false; + } KeyCode::Char('l') => { app.clear_chat(); return false; @@ -407,8 +479,24 @@ fn handle_key_event( match key.code { KeyCode::Esc => { - return true; + let now = std::time::Instant::now(); + if let Some(prev) = app.esc_pressed_at { + if now.duration_since(prev) <= Duration::from_millis(2000) { + return true; + } + } + app.esc_pressed_at = Some(now); + app.status_message = "Press Esc again to quit".to_string(); + app.log_working_event("[esc] press Esc again to quit"); + return false; } + _ => { + app.esc_pressed_at = None; + } + } + + match key.code { + KeyCode::Esc => unreachable!(), KeyCode::Tab => { app.mode = TuiMode::PersonaSelect; } @@ -693,9 +781,11 @@ fn spawn_dispatch_task( } }); + let persona_registry_tools = persona_registry.with_tool_capabilities(); + let res = PipelineExecutor::dispatch_persona_with_events( &target_persona, - &persona_registry, + &persona_registry_tools, &model_registry, client.as_ref(), &prompt, @@ -711,15 +801,116 @@ fn spawn_dispatch_task( match res { Ok(disp) => { - let _ = worker_tx - .send(WorkerEvent::Finished { - fell_back: disp.fell_back, - model_used: disp.model_used, - error: None, - calls_made: disp.attempts.len() + 1, - paid_calls_made: if disp.tier_used == Tier::Paid { 1 } else { 0 }, - }) + let commands = ToolEngine::extract_command_calls(&disp.reply); + if !commands.is_empty() { + let mut tool_results_summary = String::new(); + for cmd in commands { + let _ = worker_tx + .send(WorkerEvent::ToolExecutionStarted { + command: cmd.clone(), + }) + .await; + + let (exit_code, formatted) = + match ToolEngine::execute_bash(&cmd, None, Duration::from_secs(30)) + .await + { + Ok(out) => (out.exit_code, out.format_for_model()), + Err(e) => (-1, format!("[Command: `{cmd}` failed: {e}]\n")), + }; + + let _ = worker_tx + .send(WorkerEvent::ToolExecutionCompleted { + command: cmd, + exit_code, + output: formatted.clone(), + }) + .await; + + tool_results_summary.push_str(&formatted); + tool_results_summary.push('\n'); + } + + let followup_prompt = format!( + "{prompt}\n\n[Tool Output]:\n{tool_results_summary}\nPlease analyze the tool results above and provide your response to the user." + ); + + let (chunk_tx2, mut chunk_rx2) = mpsc::channel::(100); + let w_tx3 = worker_tx.clone(); + let target_clone2 = target_persona.clone(); + let chunk_forwarder2 = tokio::spawn(async move { + while let Some(chunk) = chunk_rx2.recv().await { + let _ = w_tx3 + .send(WorkerEvent::Chunk { + persona: target_clone2.clone(), + model: "streaming".to_string(), + tier: Tier::Free, + delta: chunk, + }) + .await; + } + }); + + let followup_res = PipelineExecutor::dispatch_persona_with_events( + &target_persona, + &persona_registry_tools, + &model_registry, + client.as_ref(), + &followup_prompt, + allow_stealth, + escalate, + Some(chunk_tx2), + None, + ) .await; + + let _ = chunk_forwarder2.await; + + match followup_res { + Ok(f_disp) => { + let _ = worker_tx + .send(WorkerEvent::Finished { + fell_back: disp.fell_back || f_disp.fell_back, + model_used: f_disp.model_used, + error: None, + calls_made: disp.attempts.len() + f_disp.attempts.len() + 2, + paid_calls_made: if disp.tier_used == Tier::Paid + || f_disp.tier_used == Tier::Paid + { + 1 + } else { + 0 + }, + }) + .await; + } + Err(e) => { + let _ = worker_tx + .send(WorkerEvent::Finished { + fell_back: disp.fell_back, + model_used: disp.model_used, + error: Some(format!("Tool synthesis failed: {e}")), + calls_made: disp.attempts.len() + 2, + paid_calls_made: if disp.tier_used == Tier::Paid { + 1 + } else { + 0 + }, + }) + .await; + } + } + } else { + let _ = worker_tx + .send(WorkerEvent::Finished { + fell_back: disp.fell_back, + model_used: disp.model_used, + error: None, + calls_made: disp.attempts.len() + 1, + paid_calls_made: if disp.tier_used == Tier::Paid { 1 } else { 0 }, + }) + .await; + } } Err(e) => { let _ = worker_tx diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 66f083b..325be1f 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -27,40 +27,48 @@ pub fn render(frame: &mut Frame, app: &mut AppState) { match app.mode { TuiMode::PersonaSelect => render_persona_popup(frame, app), TuiMode::PipelineSelect => render_pipeline_popup(frame, app), + TuiMode::SessionSelect => render_session_popup(frame, app), TuiMode::Normal => {} } } fn render_header(frame: &mut Frame, app: &AppState, area: Rect) { let active_str = if app.is_auto_route() { - "Auto-Route".to_string() + "auto".to_string() } else { app.current_persona_name().to_string() }; let pipe_tag = if let Some(ref p) = app.active_pipeline_for_prompt { - Span::styled( - format!(" [pipeline: {p}]"), - Style::default().fg(Color::Yellow), - ) + Span::styled(format!(" [pipe: {p}]"), Style::default().fg(Color::Yellow)) } else { Span::raw("") }; - let status_dot = if app.is_generating { - let spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + let status_indicator = if app.is_generating { + let spinner_frames = ["-", "\\", "|", "/"]; let sp = spinner_frames[app.spinner_tick % spinner_frames.len()]; Span::styled( format!(" {sp} thinking..."), Style::default().fg(Color::Yellow), ) + } else if app.esc_pressed_at.is_some() { + Span::styled( + " [esc again to quit]", + Style::default().fg(Color::LightRed).bold(), + ) } else { - Span::styled(" ● ready", Style::default().fg(Color::Green)) + Span::styled(" * ready", Style::default().fg(Color::Green)) }; + let session_badge = Span::styled( + format!(" [{}]", truncate_str(&app.active_session.title, 20)), + Style::default().fg(Color::DarkGray), + ); + let left_spans = vec![ Span::styled( - "⚡ brokecrew", + ":: brokecrew", Style::default() .fg(Color::Cyan) .add_modifier(Modifier::BOLD), @@ -73,8 +81,9 @@ fn render_header(frame: &mut Frame, app: &AppState, area: Rect) { .add_modifier(Modifier::BOLD), ), pipe_tag, + session_badge, Span::raw(" "), - status_dot, + status_indicator, ]; let min_color = if app.minute_requests >= 18 { @@ -95,24 +104,24 @@ fn render_header(frame: &mut Frame, app: &AppState, area: Rect) { let right_spans = vec![ Span::styled( - format!("⏱ {}/{}m", app.minute_requests, app.minute_limit), + format!("1m: {}/{}", app.minute_requests, app.minute_limit), Style::default().fg(min_color), ), Span::raw(" "), Span::styled( - format!("📅 {}/{}d", app.daily_requests, app.daily_limit), + format!("24h: {}/{}", app.daily_requests, app.daily_limit), Style::default().fg(day_color), ), Span::raw(" "), Span::styled( - format!("📦 {} models", app.models_cached), + format!("models: {}", app.models_cached), Style::default().fg(Color::DarkGray), ), ]; let header_layout = Layout::default() .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(55), Constraint::Percentage(45)]) + .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) .split(Rect { x: area.x, y: area.y, @@ -126,7 +135,7 @@ fn render_header(frame: &mut Frame, app: &AppState, area: Rect) { header_layout[1], ); - // Subtle single-line horizontal divider + // Subtle divider let divider = Line::from(Span::styled( "─".repeat(area.width as usize), Style::default().fg(Color::DarkGray), @@ -184,7 +193,7 @@ fn render_minimal_messages(frame: &mut Frame, app: &AppState, area: Rect) { match &msg.sender { MessageSender::User => { lines.push(Line::from(vec![Span::styled( - "❯ you", + "> you", Style::default() .fg(Color::Cyan) .add_modifier(Modifier::BOLD), @@ -199,14 +208,14 @@ fn render_minimal_messages(frame: &mut Frame, app: &AppState, area: Rect) { } MessageSender::Persona { name, model, tier } => { - let (color, icon) = get_persona_style(name); + let color = get_persona_color(name); let tier_str = match tier { Tier::Free => "free", Tier::Paid => "paid", }; let mut header_spans = vec![ Span::styled( - format!("{icon} {name}"), + format!("* {name}"), Style::default().fg(color).add_modifier(Modifier::BOLD), ), Span::raw(" "), @@ -235,14 +244,14 @@ fn render_minimal_messages(frame: &mut Frame, app: &AppState, area: Rect) { model, tier, } => { - let (color, icon) = get_persona_style(persona); + let color = get_persona_color(persona); let tier_str = match tier { Tier::Free => "free", Tier::Paid => "paid", }; let mut header_spans = vec![ Span::styled( - format!("{icon} step {}/{}: {persona}", step_index + 1, total_steps), + format!("* step {}/{}: {persona}", step_index + 1, total_steps), Style::default().fg(color).add_modifier(Modifier::BOLD), ), Span::raw(" "), @@ -264,9 +273,33 @@ fn render_minimal_messages(frame: &mut Frame, app: &AppState, area: Rect) { lines.push(Line::from("")); } + MessageSender::Tool { command, exit_code } => { + let code_color = if *exit_code == 0 { + Color::Green + } else { + Color::Red + }; + lines.push(Line::from(vec![ + Span::styled("$ ", Style::default().fg(Color::Yellow).bold()), + Span::styled(command.clone(), Style::default().fg(Color::LightYellow)), + Span::raw(" "), + Span::styled( + format!("[exit {}]", exit_code), + Style::default().fg(code_color), + ), + ])); + for text_line in msg.content.lines() { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(text_line.to_string(), Style::default().fg(Color::LightCyan)), + ])); + } + lines.push(Line::from("")); + } + MessageSender::System => { lines.push(Line::from(vec![ - Span::styled("● ", Style::default().fg(Color::DarkGray)), + Span::styled(". ", Style::default().fg(Color::DarkGray)), Span::styled( msg.content.clone(), Style::default().fg(Color::DarkGray).italic(), @@ -322,7 +355,7 @@ fn render_clean_markdown_body(content: &str, lines: &mut Vec) { } fn render_minimal_input(frame: &mut Frame, app: &AppState, area: Rect) { - let prompt_prefix = "❯ "; + let prompt_prefix = "> "; let para = Paragraph::new(Line::from(vec![ Span::styled(prompt_prefix, Style::default().fg(Color::Cyan).bold()), @@ -347,7 +380,7 @@ fn render_workings_pane(frame: &mut Frame, app: &AppState, area: Rect) { .border_type(BorderType::Rounded) .border_style(Style::default().fg(Color::DarkGray)) .title(Span::styled( - " ⚙ Team & Routing Workings ", + " [workings] ", Style::default().fg(Color::Cyan).bold(), )); @@ -358,29 +391,29 @@ fn render_workings_pane(frame: &mut Frame, app: &AppState, area: Rect) { // Section 1: Active Target & Route lines.push(Line::from(vec![Span::styled( - "ROUTING & PERSONA", + "ROUTING", Style::default().fg(Color::DarkGray).bold(), )])); let target_persona = if app.is_auto_route() { - "Auto-Route (Stage A/B)".to_string() + "auto (stage a/b)".to_string() } else { app.current_persona_name().to_string() }; lines.push(Line::from(vec![ - Span::raw(" Active: "), + Span::raw(" target: "), Span::styled(target_persona, Style::default().fg(Color::Green).bold()), ])); if let Some(ref stage) = app.workings.route_stage { lines.push(Line::from(vec![ - Span::raw(" Stage: "), + Span::raw(" stage: "), Span::styled(stage.clone(), Style::default().fg(Color::Yellow)), ])); } if let Some(ref detail) = app.workings.route_details { lines.push(Line::from(vec![ - Span::raw(" Detail: "), + Span::raw(" detail: "), Span::styled(detail.clone(), Style::default().fg(Color::Gray)), ])); } @@ -388,7 +421,7 @@ fn render_workings_pane(frame: &mut Frame, app: &AppState, area: Rect) { // Section 2: Candidate Cascade lines.push(Line::from(vec![Span::styled( - "CANDIDATE CASCADE", + "CASCADE", Style::default().fg(Color::DarkGray).bold(), )])); @@ -396,18 +429,18 @@ fn render_workings_pane(frame: &mut Frame, app: &AppState, area: Rect) { lines.push(Line::from(vec![ Span::raw(" "), Span::styled( - "Idle (evaluates on prompt)", + "idle (evaluates on prompt)", Style::default().fg(Color::DarkGray).italic(), ), ])); } else { for (idx, att) in app.workings.candidate_attempts.iter().enumerate() { let symbol = if att.is_success { - Span::styled("✔ ", Style::default().fg(Color::Green).bold()) + Span::styled("+ ", Style::default().fg(Color::Green).bold()) } else if att.is_error { - Span::styled("✖ ", Style::default().fg(Color::Red).bold()) + Span::styled("- ", Style::default().fg(Color::Red).bold()) } else { - Span::styled("● ", Style::default().fg(Color::Yellow)) + Span::styled(". ", Style::default().fg(Color::Yellow)) }; let tier_str = match att.tier { @@ -446,11 +479,11 @@ fn render_workings_pane(frame: &mut Frame, app: &AppState, area: Rect) { // Section 3: Team Pipeline if let Some(ref p_name) = app.workings.active_pipeline { lines.push(Line::from(vec![Span::styled( - "TEAM PIPELINE", + "PIPELINE", Style::default().fg(Color::DarkGray).bold(), )])); lines.push(Line::from(vec![ - Span::raw(" Running: "), + Span::raw(" active: "), Span::styled(p_name, Style::default().fg(Color::Yellow).bold()), ])); lines.push(Line::from("")); @@ -458,7 +491,7 @@ fn render_workings_pane(frame: &mut Frame, app: &AppState, area: Rect) { // Section 4: Budget & Rate Limits lines.push(Line::from(vec![Span::styled( - "BUDGET STATUS", + "BUDGET", Style::default().fg(Color::DarkGray).bold(), )])); lines.push(Line::from(vec![ @@ -470,7 +503,7 @@ fn render_workings_pane(frame: &mut Frame, app: &AppState, area: Rect) { Span::raw(" reqs"), ])); lines.push(Line::from(vec![ - Span::raw(" Daily limit:"), + Span::raw(" 24h limit: "), Span::styled( format!("{}/{}", app.daily_requests, app.daily_limit), Style::default().fg(Color::White).bold(), @@ -503,17 +536,21 @@ fn render_workings_pane(frame: &mut Frame, app: &AppState, area: Rect) { fn render_footer(frame: &mut Frame, _app: &AppState, area: Rect) { let help_spans = vec![ Span::styled("[Enter]", Style::default().fg(Color::White).bold()), - Span::styled(" Send ", Style::default().fg(Color::DarkGray)), + Span::styled(" send ", Style::default().fg(Color::DarkGray)), Span::styled("[Tab]", Style::default().fg(Color::White).bold()), - Span::styled(" Persona ", Style::default().fg(Color::DarkGray)), + Span::styled(" persona ", Style::default().fg(Color::DarkGray)), Span::styled("[Ctrl+P]", Style::default().fg(Color::White).bold()), - Span::styled(" Pipeline ", Style::default().fg(Color::DarkGray)), + Span::styled(" pipeline ", Style::default().fg(Color::DarkGray)), + Span::styled("[Ctrl+S]", Style::default().fg(Color::White).bold()), + Span::styled(" sessions ", Style::default().fg(Color::DarkGray)), + Span::styled("[Ctrl+N]", Style::default().fg(Color::White).bold()), + Span::styled(" new ", Style::default().fg(Color::DarkGray)), Span::styled("[Ctrl+L]", Style::default().fg(Color::White).bold()), - Span::styled(" Clear ", Style::default().fg(Color::DarkGray)), + Span::styled(" clear ", Style::default().fg(Color::DarkGray)), Span::styled("[PgUp/Dn]", Style::default().fg(Color::White).bold()), - Span::styled(" Scroll ", Style::default().fg(Color::DarkGray)), + Span::styled(" scroll ", Style::default().fg(Color::DarkGray)), Span::styled("[Esc]", Style::default().fg(Color::White).bold()), - Span::styled(" Quit", Style::default().fg(Color::DarkGray)), + Span::styled(" quit", Style::default().fg(Color::DarkGray)), ]; let para = Paragraph::new(Line::from(help_spans)).alignment(Alignment::Left); @@ -530,15 +567,11 @@ fn render_persona_popup(frame: &mut Frame, app: &AppState) { .enumerate() .map(|(idx, item)| { let is_selected = idx == app.selected_persona_idx; - let (icon, prefix) = if idx == 0 { - ("🤖", if is_selected { "❯ " } else { " " }) - } else { - ("⚡", if is_selected { "❯ " } else { " " }) - }; + let prefix = if is_selected { "> " } else { " " }; let line = Line::from(vec![ Span::styled( - format!("{prefix}{icon} {:<12} ", item.name), + format!("{prefix}{:<12} ", item.name), if is_selected { Style::default() .fg(Color::Cyan) @@ -567,7 +600,7 @@ fn render_persona_popup(frame: &mut Frame, app: &AppState) { .border_type(BorderType::Rounded) .border_style(Style::default().fg(Color::Cyan)) .title(Span::styled( - " Select Persona [↑/↓ Navigate, Enter Select, Esc Cancel] ", + " select persona [up/down, enter select, esc cancel] ", Style::default() .fg(Color::Cyan) .add_modifier(Modifier::BOLD), @@ -582,21 +615,21 @@ fn render_pipeline_popup(frame: &mut Frame, app: &AppState) { frame.render_widget(Clear, area); let items: Vec = if app.pipelines.is_empty() { - vec![ListItem::new("No pipelines configured in personas.toml")] + vec![ListItem::new("no pipelines configured in personas.toml")] } else { app.pipelines .iter() .enumerate() .map(|(idx, pipe)| { let is_selected = idx == app.selected_pipeline_idx; - let prefix = if is_selected { "❯ " } else { " " }; + let prefix = if is_selected { "> " } else { " " }; let steps: Vec = pipe.steps.iter().map(|s| s.persona.clone()).collect(); - let steps_str = steps.join(" ➔ "); + let steps_str = steps.join(" -> "); let line = Line::from(vec![ Span::styled( - format!("{prefix}⚡ {:<18} ", pipe.name), + format!("{prefix}{:<18} ", pipe.name), if is_selected { Style::default() .fg(Color::Yellow) @@ -626,7 +659,7 @@ fn render_pipeline_popup(frame: &mut Frame, app: &AppState) { .border_type(BorderType::Rounded) .border_style(Style::default().fg(Color::Yellow)) .title(Span::styled( - " Select Pipeline to Run [↑/↓ Navigate, Enter Select, Esc Cancel] ", + " select pipeline [up/down, enter run, esc cancel] ", Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD), @@ -636,14 +669,83 @@ fn render_pipeline_popup(frame: &mut Frame, app: &AppState) { frame.render_widget(list, area); } -fn get_persona_style(name: &str) -> (Color, &'static str) { +fn render_session_popup(frame: &mut Frame, app: &AppState) { + let area = centered_rect(65, 55, frame.area()); + frame.render_widget(Clear, area); + + let items: Vec = if app.session_summaries.is_empty() { + vec![ListItem::new("no saved sessions yet")] + } else { + app.session_summaries + .iter() + .enumerate() + .map(|(idx, s)| { + let is_selected = idx == app.selected_session_idx; + let prefix = if is_selected { "> " } else { " " }; + + let line = Line::from(vec![ + Span::styled( + format!("{prefix}{:<24} ", truncate_str(&s.title, 24)), + if is_selected { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }, + ), + Span::styled( + format!( + "[{}] ({} msgs) - {}", + s.persona, + s.message_count, + truncate_str(&s.updated_at, 19) + ), + Style::default().fg(Color::DarkGray), + ), + ]); + + if is_selected { + ListItem::new(line).style(Style::default().bg(Color::Rgb(30, 30, 45))) + } else { + ListItem::new(line) + } + }) + .collect() + }; + + let list = List::new(items).block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(Color::Cyan)) + .title(Span::styled( + " saved sessions [up/down, enter resume, esc cancel] ", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )), + ); + + frame.render_widget(list, area); +} + +fn get_persona_color(name: &str) -> Color { match name.to_lowercase().as_str() { - "coder" => (Color::LightGreen, "⚡"), - "reviewer" => (Color::LightYellow, "🔍"), - "architect" => (Color::LightBlue, "🏛️"), - "researcher" => (Color::LightMagenta, "🔬"), - "summarizer" => (Color::LightCyan, "📝"), - _ => (Color::White, "🤖"), + "coder" => Color::LightGreen, + "reviewer" => Color::LightYellow, + "architect" => Color::LightBlue, + "researcher" => Color::LightMagenta, + "summarizer" => Color::LightCyan, + _ => Color::White, + } +} + +fn truncate_str(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_string() + } else { + format!("{}...", &s[..max.saturating_sub(3)]) } }