feat(tui): add sessions, double-tap esc to quit, tool execution, and minimal UI symbols

This commit is contained in:
katR 2026-09-26 16:03:46 +05:30
parent bfea14d01b
commit 9e96eec964
7 changed files with 842 additions and 84 deletions

View file

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

View file

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

169
src/session.rs Normal file
View file

@ -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<String>,
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<SessionMessage>,
}
#[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> = 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> = 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<Self, io::Error> {
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<Vec<SessionSummary>, 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::<Session>(&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);
}
}

167
src/tool.rs Normal file
View file

@ -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<ToolOutput, String> {
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:
/// `<run>cmd</run>`, `<bash>cmd</bash>`, or markdown ```bash blocks tagged for execution
pub fn extract_command_calls(text: &str) -> Vec<String> {
let mut commands = Vec::new();
// 1. Check for <run>...</run> tags
let mut rest = text;
while let Some(start_idx) = rest.find("<run>") {
let after_start = &rest[start_idx + 5..];
if let Some(end_idx) = after_start.find("</run>") {
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 <bash>...</bash> tags
let mut rest_bash = text;
while let Some(start_idx) = rest_bash.find("<bash>") {
let after_start = &rest_bash[start_idx + 6..];
if let Some(end_idx) = after_start.find("</bash>") {
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 <run>...</run> tags, for example:\n<run>cargo check</run>\nor\n<run>git status</run>\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:\n<run>git status</run>\nand also list files:\n<bash>ls -la</bash>";
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"));
}
}

View file

@ -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<Instant>,
// Personas & selection
pub persona_items: Vec<PersonaItem>,
pub selected_persona_idx: usize,
@ -100,7 +111,13 @@ pub struct AppState {
pub selected_pipeline_idx: usize,
pub active_pipeline_for_prompt: Option<String>,
// Team & Routing Workings telemetry
// Sessions & persistence
pub active_session: Session,
pub session_summaries: Vec<SessionSummary>,
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<PipelineConfig>,
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();
}

View file

@ -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::<String>(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

View file

@ -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<Line>) {
}
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<ListItem> = 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<String> = 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<ListItem> = 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)])
}
}