feat(tui): add sessions, double-tap esc to quit, tool execution, and minimal UI symbols
This commit is contained in:
parent
bfea14d01b
commit
9e96eec964
7 changed files with 842 additions and 84 deletions
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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
169
src/session.rs
Normal 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
167
src/tool.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
132
src/tui/app.rs
132
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<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();
|
||||
}
|
||||
|
||||
|
|
|
|||
213
src/tui/mod.rs
213
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::<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
|
||||
|
|
|
|||
226
src/tui/ui.rs
226
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<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)])
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue