use std::collections::VecDeque;
use std::io::{self, Write as _};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use crossterm::event::{
self, DisableBracketedPaste, EnableBracketedPaste, EnableMouseCapture, DisableMouseCapture,
Event, KeyCode, KeyEvent, KeyModifiers, MouseEventKind,
};
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use crossterm::ExecutableCommand;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use ratatui::Terminal;
use pulldown_cmark::{
Event as MdEvent, HeadingLevel, Options as MdOptions, Parser as MdParser, Tag, TagEnd,
};
use commands::slash_command_specs;
use runtime::AssistantEvent;
const BG: Color = Color::Rgb(13, 17, 33);
const FG: Color = Color::Rgb(220, 220, 220);
const DIM: Color = Color::Rgb(80, 80, 80);
const GREY: Color = Color::Rgb(145, 145, 145);
const GREEN: Color = Color::Rgb(0, 220, 120);
const CYAN: Color = Color::Rgb(0, 200, 255);
const ORANGE: Color = Color::Rgb(255, 140, 50); const USER_BOX_BG: Color = Color::Reset;
const STATUS_BG: Color = Color::Reset;
const BRANCH_BG: Color = Color::Reset; const POPUP_BG: Color = Color::Rgb(20, 26, 48);
const POPUP_MATCH: Color = Color::Rgb(0, 180, 100);
const POPUP_SEL_BG: Color = Color::Rgb(42, 42, 42);
const CODE_FG: Color = Color::Rgb(100, 210, 255); const CODE_BG: Color = Color::Reset; const CODE_BAR: Color = Color::Rgb(0, 100, 160); const CHAT_BORDER: Color = Color::Rgb(0, 65, 75); const INPUT_BORDER: Color = Color::Rgb(0, 175, 160); const ERROR_FG: Color = Color::Rgb(230, 80, 50); const POPUP_WINDOW: usize = 16; const CATEGORY_FG: Color = Color::Rgb(60, 60, 60);
fn get_pulse_style(elapsed: f32, is_active: bool) -> Style {
if !is_active {
return Style::default().fg(GREY);
}
let period = 2.0 / 1.5; let t = (elapsed % period) / period;
let intensity = ((t * std::f32::consts::PI).sin()).powf(2.0);
let r = (80.0 + (0.0 - 80.0) * intensity) as u8;
let g = (80.0 + (200.0 - 80.0) * intensity) as u8;
let b = (80.0 + (255.0 - 80.0) * intensity) as u8;
Style::default()
.fg(Color::Rgb(r, g, b))
.add_modifier(Modifier::BOLD)
}
const TIPS: &[&str] = &[
"Use /compress to free context space mid-session",
"Use /model to switch providers without losing session history",
"Use /permissions danger-full-access for unrestricted shell access",
"PageUp / PageDown to scroll through conversation history",
"Type /help to see all available slash commands",
"Press esc to interrupt a running turn at any time",
"Use /session list to see and switch between saved sessions",
"Use /init to scaffold an ALBERT.md for project context",
"Use /memory to view the agent's persistent long-term memory",
"Use /commit to have AI draft a perfect git commit message",
"Use /diff to review current changes before committing",
"Use /tdd to enter a test-driven development loop",
"Use /bughunter to scan the codebase for potential issues",
"Use /refactor to improve the structure of the current file",
"Ctrl+L scrolls the conversation back to the bottom",
"Ctrl+Space toggles voice recording (STT) for hands-free input",
"Shift+Enter adds a newline to your message",
"Tab completes slash commands and opens sub-menus",
"Use /aside to take temporary notes during a deep session",
"Use /export to save the current conversation to a file",
"Use /checkpoint to save a snapshot of the current workspace",
];
const PERM_MODES: &[(&str, &str)] = &[
("read-only", "no writes · no shell"),
("workspace-write", "files only · no shell"),
("danger-full-access", "unrestricted · full shell"),
];
const MODEL_ENTRIES: &[(&str, &str, &str)] = &[
("gemini-2.5-pro", "Google", "Most capable Gemini"),
("gemini-2.5-flash", "Google", "Fast & capable — recommended"),
("gemini-2.5-flash-lite", "Google", "Lightest Gemini"),
("claude-opus-4-7", "Anthropic", "Most capable Claude"),
("claude-sonnet-4-6", "Anthropic", "Best balance"),
("claude-haiku-4-5-20251001", "Anthropic", "Fastest Claude"),
("gpt-4o", "OpenAI", "GPT-4o flagship"),
("gpt-4o-mini", "OpenAI", "Efficient GPT-4o"),
("gpt-5", "OpenAI", "GPT-5 frontier"),
("o3", "OpenAI", "Full o3 reasoning"),
("o3-mini", "OpenAI", "o3 reasoning — efficient"),
("grok-3", "xAI", "Grok 3 flagship"),
("grok-3-mini", "xAI", "Efficient Grok"),
("llama-3.3-70b-versatile", "Groq", "Llama 3.3 70B — ultra-fast LPU"),
("llama-3.1-8b-instant", "Groq", "Llama 3.1 8B — fastest/cheapest"),
("gemma2-9b-it", "Groq", "Gemma2 9B on Groq"),
("mistral-large-latest", "Mistral", "Mistral Large 2"),
("mistral-small-latest", "Mistral", "Mistral Small — fast"),
("codestral-latest", "Mistral", "Code specialist"),
("pixtral-large-latest", "Mistral", "Pixtral multimodal"),
("deepseek-chat", "DeepSeek", "DeepSeek V3 flagship"),
("deepseek-reasoner", "DeepSeek", "DeepSeek R1 chain-of-thought"),
("openai/gpt-4o", "OpenRouter", "GPT-4o via OpenRouter"),
("anthropic/claude-sonnet-4-6", "OpenRouter", "Claude Sonnet 4.6"),
("google/gemini-2.5-flash", "OpenRouter", "Gemini Flash"),
("x-ai/grok-3-mini", "OpenRouter", "Grok 3 Mini"),
("sonar-pro", "Perplexity", "Search-grounded Pro"),
("sonar", "Perplexity", "Search-grounded Fast"),
("command-r-plus", "Cohere", "Command R+ RAG flagship"),
("command-r", "Cohere", "Command R — efficient"),
("llama3.3-70b", "Cerebras", "Llama 3.3 70B on WSE"),
("qwen-max", "Qwen", "Qwen Max flagship"),
("qwq-32b", "Qwen", "QwQ 32B chain-of-thought"),
("nvidia/llama-3.1-nemotron-70b-instruct", "NVIDIA NIM", "Nemotron 70B"),
("meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo","Together", "Llama 3.1 70B Turbo"),
("llama3.2", "Ollama", "Llama 3.2 local"),
("phi4", "Ollama", "Phi-4 local"),
("qwen2.5-coder:14b", "Ollama", "Qwen2.5 Coder local"),
("local-model", "LM Studio", "Active LM Studio model"),
];
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TaskStatus {
Pending,
Running,
Done,
Failed,
}
#[derive(Clone, Debug)]
pub struct Task {
pub id: String,
pub label: String,
pub status: TaskStatus,
}
#[derive(Clone, Debug)]
pub enum ExecBlock {
UserMessage(String),
ToolUse { name: String, args: String, active: bool },
Plan { tasks: Vec<Task>, frozen: bool },
ToolOutput { lines: Vec<String>, total: usize, active: bool },
AgentText(String, bool), SystemMsg(String),
WorkedFor(u64),
}
#[derive(Clone, Debug)]
pub struct TuiState {
pub exec_log: VecDeque<ExecBlock>,
pub input: String,
pub cursor: usize,
pub tokens_in: u32,
pub tokens_out: u32,
pub model: String,
pub cwd: String,
pub permission_mode: String,
pub session_start: Instant,
pub turn_start: Option<Instant>,
pub working: bool,
pub scroll: u16,
pub popup_selected: usize,
pub is_recording: bool,
pub auth_flow: Option<String>,
pub paste_line_count: Option<usize>,
pub help_open: bool,
pub help_scroll: u16,
pub typewriter_buffer: String,
pub current_assistant_block_index: Option<usize>,
pub input_history: Vec<String>,
pub history_idx: Option<usize>,
pub input_saved: String,
pub tool_calls: usize,
pub tool_success: usize,
pub tool_failure: usize,
pub agent_active_ms: u64,
pub api_time_ms: u64,
pub tool_time_ms: u64,
pub session_id: String,
pub quit_confirm: bool,
pub trusted: bool,
pub is_prompting: Arc<AtomicBool>,
}
impl Default for TuiState {
fn default() -> Self {
Self {
exec_log: VecDeque::new(),
input: String::new(),
cursor: 0,
tokens_in: 0,
tokens_out: 0,
model: String::new(),
cwd: String::new(),
permission_mode: String::new(),
session_start: Instant::now(),
turn_start: None,
working: false,
scroll: 0,
popup_selected: 0,
is_recording: false,
auth_flow: None,
paste_line_count: None,
help_open: false,
help_scroll: 0,
typewriter_buffer: String::new(),
current_assistant_block_index: None,
input_history: Vec::new(),
history_idx: None,
input_saved: String::new(),
tool_calls: 0,
tool_success: 0,
tool_failure: 0,
agent_active_ms: 0,
api_time_ms: 0,
tool_time_ms: 0,
session_id: String::new(),
quit_confirm: false,
trusted: false,
is_prompting: Arc::new(AtomicBool::new(false)),
}
}
}
impl TuiState {
pub fn new(model: String, cwd: String, permission_mode: String, session_id: String) -> Self {
Self { model, cwd, permission_mode, session_id, ..Default::default() }
}
pub fn push_exec(&mut self, block: ExecBlock) {
if matches!(&block, ExecBlock::UserMessage(_)) {
self.seal_last_assistant_block();
self.current_assistant_block_index = None;
self.typewriter_buffer.clear(); }
if matches!(&block, ExecBlock::ToolUse { .. }) {
self.deactivate_all_tools();
}
if let ExecBlock::ToolUse { ref name, .. } = block {
if name == "SendUserMessage" || name == "Brief" {
return;
}
}
if let ExecBlock::SystemMsg(ref msg) = block {
if let Some(ExecBlock::SystemMsg(ref last)) = self.exec_log.back() {
if last == msg {
return;
}
}
}
self.exec_log.push_back(block);
if matches!(self.exec_log.back(), Some(ExecBlock::AgentText(..))) {
self.current_assistant_block_index = Some(self.exec_log.len() - 1);
} else {
self.current_assistant_block_index = None;
}
while self.exec_log.len() > 120 {
self.exec_log.pop_front();
if let Some(ref mut idx) = self.current_assistant_block_index {
if *idx == 0 {
self.current_assistant_block_index = None;
} else {
*idx -= 1;
}
}
}
}
pub fn seal_last_assistant_block(&mut self) {
for block in self.exec_log.iter_mut() {
if let ExecBlock::Plan { frozen, .. } = block {
*frozen = true;
}
}
if let Some(idx) = self.current_assistant_block_index {
if let Some(ExecBlock::AgentText(_, interrupted)) = self.exec_log.get_mut(idx) {
*interrupted = true;
}
}
}
pub fn deactivate_all_tools(&mut self) {
for block in self.exec_log.iter_mut() {
match block {
ExecBlock::ToolUse { active, .. } => *active = false,
ExecBlock::ToolOutput { active, .. } => *active = false,
_ => {}
}
}
}
pub fn input_insert(&mut self, ch: char) {
self.paste_line_count = None;
let pos = self
.input
.char_indices()
.nth(self.cursor)
.map(|(i, _)| i)
.unwrap_or(self.input.len());
self.input.insert(pos, ch);
self.cursor += 1;
}
pub fn input_backspace(&mut self) {
self.paste_line_count = None;
if self.cursor > 0 {
let pos = self
.input
.char_indices()
.nth(self.cursor - 1)
.map(|(i, _)| i)
.unwrap();
self.input.remove(pos);
self.cursor -= 1;
}
}
pub fn input_delete(&mut self) {
self.paste_line_count = None;
let len = self.input.chars().count();
if self.cursor < len {
let pos = self
.input
.char_indices()
.nth(self.cursor)
.map(|(i, _)| i)
.unwrap();
self.input.remove(pos);
}
}
pub fn input_take(&mut self) -> String {
self.cursor = 0;
self.paste_line_count = None;
std::mem::take(&mut self.input)
}
pub fn history_push(&mut self, text: &str) {
let text = text.trim().to_string();
if text.is_empty() { return; }
if self.input_history.last().map(|s| s == &text).unwrap_or(false) { return; }
self.input_history.push(text);
if self.input_history.len() > 200 { self.input_history.remove(0); }
self.history_idx = None;
}
pub fn history_prev(&mut self) {
if self.input_history.is_empty() { return; }
match self.history_idx {
None => {
self.input_saved = self.input.clone();
let idx = self.input_history.len() - 1;
self.history_idx = Some(idx);
self.input = self.input_history[idx].clone();
self.cursor = self.input.chars().count();
}
Some(0) => {}
Some(idx) => {
let new = idx - 1;
self.history_idx = Some(new);
self.input = self.input_history[new].clone();
self.cursor = self.input.chars().count();
}
}
}
pub fn history_next(&mut self) {
match self.history_idx {
None => {}
Some(idx) if idx + 1 >= self.input_history.len() => {
self.history_idx = None;
self.input = std::mem::take(&mut self.input_saved);
self.cursor = self.input.chars().count();
}
Some(idx) => {
let new = idx + 1;
self.history_idx = Some(new);
self.input = self.input_history[new].clone();
self.cursor = self.input.chars().count();
}
}
}
}
fn word_left(input: &str, cursor: usize) -> usize {
if cursor == 0 { return 0; }
let chars: Vec<char> = input.chars().collect();
let mut pos = cursor - 1;
while pos > 0 && chars[pos].is_whitespace() { pos -= 1; }
while pos > 0 && !chars[pos - 1].is_whitespace() { pos -= 1; }
pos
}
fn word_right(input: &str, cursor: usize) -> usize {
let chars: Vec<char> = input.chars().collect();
let len = chars.len();
if cursor >= len { return len; }
let mut pos = cursor;
while pos < len && !chars[pos].is_whitespace() { pos += 1; }
while pos < len && chars[pos].is_whitespace() { pos += 1; }
pos
}
pub enum TuiEvent {
Key(KeyEvent),
AgentEvent(AssistantEvent),
Tick,
Suspend { ack: std::sync::mpsc::SyncSender<()> },
Resume,
Quit,
QuitWithReport,
VoiceText(String),
VoiceError(String),
PasteText(String),
ScrollUp,
ScrollDown,
}
#[derive(Clone)]
struct PopupItem {
display: String,
complete: String,
desc: String,
is_header: bool,
}
impl PopupItem {
fn cmd(display: &str, complete: &str, desc: &str) -> Self {
Self { display: display.to_string(), complete: complete.to_string(), desc: desc.to_string(), is_header: false }
}
fn header(label: &str) -> Self {
Self { display: label.to_string(), complete: String::new(), desc: String::new(), is_header: true }
}
}
const CMD_GROUPS: &[(&str, &[&str])] = &[
("CONFIG", &["model", "permissions", "auth"]),
("SESSION", &["status", "compact", "compress", "clear", "cost", "export", "session", "resume"]),
("GIT", &["commit", "pr", "issue", "diff"]),
("AGENT", &["plan", "loop", "tdd", "verify", "code-review", "build-fix", "bughunter", "ultraplan", "refactor"]),
("WORKSPACE", &["init", "memory", "config", "docs", "learn", "checkpoint", "aside", "teleport", "debug-tool-call"]),
("INFO", &["help", "version"]),
];
fn is_drilldown(complete: &str) -> bool {
matches!(complete, "/model" | "/permissions" | "/auth")
}
const AUTH_PROVIDERS: &[(&str, &str)] = &[
("anthropic", "Claude opus-4-7 · sonnet-4-6 · haiku-4-5"),
("openai", "GPT-4o · GPT-4o-mini · o3 · o3-mini"),
("google", "Gemini 2.5 Pro · Flash · Flash-Lite"),
("xai", "Grok 3 · Grok 3-mini"),
("groq", "Llama 3.3 70B · 8B — ultra-fast LPU"),
("mistral", "Mistral Large · Small · Codestral"),
("deepseek", "DeepSeek V3 · R1 chain-of-thought"),
("openrouter", "100+ models via unified API"),
("perplexity", "Sonar Pro · Sonar — search-grounded"),
("cohere", "Command R+ · Command R — RAG"),
("cerebras", "Llama 3.3 70B on WSE accelerator"),
("together", "Open source models at scale"),
("fireworks", "Fast inference — Llama, Mistral, …"),
("novita", "Cost-efficient open model hosting"),
("ollama", "Local models — no API key required"),
];
const AGENT_GROUPS: &[(&str, &[(&str, &str)])] = &[
("AGENTS", &[
("plan", "Break down complex tasks into steps"),
("loop", "Autonomous execution autopilot"),
("tdd", "Strict Test-Driven Development"),
("verify", "Full workspace health check"),
("debug", "Deep root-cause analysis"),
("fix", "Autonomous bug resolution"),
("review", "Deep security & logic review"),
]),
("RULES", &[
("strict", "Enforce maximum safety and types"),
("fast", "Prioritize speed and brevity"),
("debug", "Verbose logging and tool traces"),
]),
];
fn popup_items(input: &str) -> Vec<PopupItem> {
if input.starts_with('@') {
let partial = &input[1..];
let mut items = Vec::new();
for (label, agents) in AGENT_GROUPS {
let matches: Vec<PopupItem> = agents.iter()
.filter(|(name, _)| partial.is_empty() || name.starts_with(partial))
.map(|(name, desc)| PopupItem::cmd(
&format!("@{name}"),
&format!("/{name}"), desc,
))
.collect();
if !matches.is_empty() {
items.push(PopupItem::header(label));
items.extend(matches);
}
}
return items;
}
if !input.starts_with('/') {
return vec![];
}
if input.starts_with("/permissions") {
let partial = input.strip_prefix("/permissions").unwrap_or("").trim();
return PERM_MODES
.iter()
.filter(|(mode, _)| partial.is_empty() || mode.starts_with(partial))
.map(|(mode, desc)| PopupItem::cmd(
&format!("permissions {mode}"),
&format!("/permissions {mode}"),
desc,
))
.collect();
}
if input.starts_with("/model") {
let partial = input.strip_prefix("/model").unwrap_or("").trim();
let mut items: Vec<PopupItem> = Vec::new();
let mut cur_provider = "";
for (id, provider, desc) in MODEL_ENTRIES {
if !partial.is_empty() && !id.contains(partial) && !provider.to_lowercase().contains(partial) {
continue;
}
if *provider != cur_provider {
items.push(PopupItem::header(provider));
cur_provider = provider;
}
items.push(PopupItem::cmd(
id,
&format!("/model {id}"),
desc,
));
}
return items;
}
if input.starts_with("/auth") {
let partial = input.strip_prefix("/auth").unwrap_or("").trim();
return AUTH_PROVIDERS
.iter()
.filter(|(p, _)| partial.is_empty() || p.starts_with(partial))
.map(|(provider, desc)| PopupItem::cmd(
&format!("auth {provider}"),
&format!("/auth {provider}"),
desc,
))
.collect();
}
let prefix = &input[1..];
if prefix.is_empty() {
let specs = slash_command_specs();
let mut items: Vec<PopupItem> = Vec::new();
for (label, names) in CMD_GROUPS {
let group_items: Vec<PopupItem> = names
.iter()
.filter_map(|&n| specs.iter().find(|s| s.name == n))
.map(|s| {
let hint = if is_drilldown(&format!("/{}", s.name)) {
" ›".to_string()
} else {
s.argument_hint.map(|h| format!(" {h}")).unwrap_or_default()
};
PopupItem::cmd(
&format!("{}{hint}", s.name),
&format!("/{}", s.name),
s.summary,
)
})
.collect();
if !group_items.is_empty() {
items.push(PopupItem::header(label));
items.extend(group_items);
}
}
return items;
}
slash_command_specs()
.iter()
.filter(|s| s.name.starts_with(prefix))
.map(|s| {
let hint = if is_drilldown(&format!("/{}", s.name)) {
" ›".to_string()
} else {
s.argument_hint.map(|h| format!(" {h}")).unwrap_or_default()
};
PopupItem::cmd(
&format!("{}{hint}", s.name),
&format!("/{}", s.name),
s.summary,
)
})
.collect()
}
pub fn tool_input_preview(input: &str) -> String {
const MAX: usize = 90;
if let Ok(val) = serde_json::from_str::<serde_json::Value>(input) {
let priority = [
"command", "path", "file_path", "pattern", "query",
"url", "prompt", "text", "content",
];
for key in &priority {
if let Some(s) = val.get(key).and_then(|v| v.as_str()) {
let s = s.trim();
if !s.is_empty() {
return truncate(s, MAX);
}
}
}
if let Some(obj) = val.as_object() {
for (_, v) in obj {
if let Some(s) = v.as_str() {
let s = s.trim();
if !s.is_empty() {
return truncate(s, MAX);
}
}
}
}
}
truncate(input.trim(), MAX)
}
fn truncate(s: &str, max_chars: usize) -> String {
let count = s.chars().count();
if count <= max_chars {
return s.to_string();
}
let end = s.char_indices().nth(max_chars).map(|(i, _)| i).unwrap_or(s.len());
format!("{}…", &s[..end])
}
fn generate_repo_map(root: &std::path::Path, max_depth: usize) -> String {
use walkdir::WalkDir;
let mut map = String::new();
let root_str = root.file_name().and_then(|n| n.to_str()).unwrap_or(".");
map.push_str(&format!("{}/\n", root_str));
fn is_noise(name: &str) -> bool {
let n = name.to_lowercase();
let noise_dirs = ["target", "node_modules", "dist", "build", "out", "debug", "release", ".git", ".cache", ".next", ".cargo", "vendor"];
if noise_dirs.iter().any(|&d| n == d) { return true; }
if name.len() > 20 && name.chars().all(|c| c.is_alphanumeric()) { return true; }
false
}
let mut it = WalkDir::new(root)
.min_depth(1)
.max_depth(max_depth)
.sort_by(|a, b| {
let a_is_dir = a.file_type().is_dir();
let b_is_dir = b.file_type().is_dir();
if a_is_dir != b_is_dir {
b_is_dir.cmp(&a_is_dir)
} else {
a.file_name().cmp(b.file_name())
}
})
.into_iter()
.filter_entry(|e| !is_noise(e.file_name().to_str().unwrap_or("")))
.peekable();
let mut count_at_depth = std::collections::HashMap::new();
while let Some(Ok(entry)) = it.next() {
let depth = entry.depth();
let name = entry.file_name().to_string_lossy();
let is_dir = entry.file_type().is_dir();
let count = count_at_depth.entry(depth).or_insert(0);
*count += 1;
if !is_dir && *count > 5 {
let mut more = 0;
while let Some(Ok(peek)) = it.peek() {
if peek.depth() == depth {
more += 1;
it.next();
} else {
break;
}
}
let mut indent = String::new();
for _ in 1..depth { indent.push_str("│ "); }
if more > 0 {
map.push_str(&format!("└── ... and {} more items\n", more));
}
continue;
}
let mut indent = String::new();
for _ in 1..depth {
indent.push_str("│ ");
}
let connector = "├── ";
map.push_str(&format!("{}{}{}\n", indent, connector, name));
}
map
}
fn markdown_to_lines(text: &str, prefix: Option<Span<'static>>, width: u16) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
let mut spans: Vec<Span<'static>> = Vec::new();
let mut bold = false;
let mut italic = false;
let mut in_code_block = false;
let mut in_heading = false;
let mut heading_color = FG;
let mut list_depth: usize = 0;
let mut item_needs_bullet = false;
let prefix_w = prefix.as_ref().map(|p| p.content.chars().count()).unwrap_or(0) as u16;
let available_w = width.saturating_sub(prefix_w + 1).max(10);
let opts = MdOptions::ENABLE_STRIKETHROUGH;
let parser = MdParser::new_ext(text, opts);
let mut flush_to_lines = |spans: &mut Vec<Span<'static>>, lines: &mut Vec<Line<'static>>| {
if spans.is_empty() { return; }
let mut full_text = String::new();
for s in spans.iter() { full_text.push_str(&s.content); }
let mut current_pos = 0;
let chars: Vec<char> = full_text.chars().collect();
while current_pos < chars.len() {
let end = (current_pos + available_w as usize).min(chars.len());
let mut wrap_at = end;
if end < chars.len() {
for i in (current_pos..end).rev() {
if chars[i].is_whitespace() {
wrap_at = i + 1;
break;
}
}
}
let chunk: String = chars[current_pos..wrap_at].iter().collect();
let mut row = Vec::new();
if let Some(p) = &prefix { row.push(p.clone()); }
row.push(Span::styled(chunk, Style::default().fg(FG))); lines.push(Line::from(row));
current_pos = wrap_at;
while current_pos < chars.len() && chars[current_pos].is_whitespace() && chars[current_pos] != '\n' {
current_pos += 1;
}
}
spans.clear();
};
for event in parser {
match event {
MdEvent::Start(Tag::Heading { level, .. }) => {
in_heading = true;
heading_color = match level {
HeadingLevel::H1 => GREEN,
HeadingLevel::H2 => CYAN,
_ => FG,
};
}
MdEvent::End(TagEnd::Heading(_)) => {
flush_to_lines(&mut spans, &mut lines);
in_heading = false;
}
MdEvent::Start(Tag::Strong) => bold = true,
MdEvent::End(TagEnd::Strong) => bold = false,
MdEvent::Start(Tag::Emphasis) => italic = true,
MdEvent::End(TagEnd::Emphasis) => italic = false,
MdEvent::Start(Tag::CodeBlock(_)) => in_code_block = true,
MdEvent::End(TagEnd::CodeBlock) => {
flush_to_lines(&mut spans, &mut lines);
lines.push(if let Some(p) = &prefix { Line::from(vec![p.clone()]) } else { Line::default() });
in_code_block = false;
}
MdEvent::Start(Tag::List(_)) => list_depth += 1,
MdEvent::End(TagEnd::List(_)) => list_depth = list_depth.saturating_sub(1),
MdEvent::Start(Tag::Item) => item_needs_bullet = true,
MdEvent::End(TagEnd::Item) => flush_to_lines(&mut spans, &mut lines),
MdEvent::Start(Tag::Paragraph) => {}
MdEvent::End(TagEnd::Paragraph) => {
flush_to_lines(&mut spans, &mut lines);
lines.push(if let Some(p) = &prefix { Line::from(vec![p.clone()]) } else { Line::default() });
}
MdEvent::Text(t) => {
if item_needs_bullet {
item_needs_bullet = false;
let indent = " ".repeat(list_depth); spans.push(Span::styled(format!("{indent}• "), Style::default().fg(DIM)));
}
if in_code_block {
for line in t.lines() {
let mut row = Vec::new();
if let Some(p) = &prefix { row.push(p.clone()); }
row.push(Span::styled("▌", Style::default().fg(CODE_BAR).bg(CODE_BG)));
row.push(Span::styled(format!(" {line}"), Style::default().fg(CODE_FG).bg(CODE_BG)));
lines.push(Line::from(row));
}
} else {
let mut style = Style::default().fg(FG);
if in_heading { style = style.fg(heading_color).add_modifier(Modifier::BOLD); }
else {
if bold { style = style.add_modifier(Modifier::BOLD); }
if italic { style = style.add_modifier(Modifier::ITALIC); }
}
spans.push(Span::styled(t.to_string(), style));
}
}
MdEvent::Code(c) => {
spans.push(Span::styled(format!("`{c}`"), Style::default().fg(CODE_FG)));
}
MdEvent::SoftBreak => { spans.push(Span::styled(" ".to_string(), Style::default().fg(FG))); }
MdEvent::HardBreak => flush_to_lines(&mut spans, &mut lines),
MdEvent::Rule => {
flush_to_lines(&mut spans, &mut lines);
let mut row = Vec::new();
if let Some(p) = &prefix { row.push(p.clone()); }
row.push(Span::styled("─".repeat(available_w as usize), Style::default().fg(DIM)));
lines.push(Line::from(row));
}
_ => {}
}
}
flush_to_lines(&mut spans, &mut lines);
lines
}
pub fn render(f: &mut ratatui::Frame, state: &TuiState) {
let area = f.area();
let items = if state.auth_flow.is_some() { vec![] } else { popup_items(&state.input) };
let n_items = items.len();
let popup_h = if n_items == 0 { 0u16 } else { (n_items.min(POPUP_WINDOW) + 1) as u16 };
let input_h = {
let badge_approx = git_branch_cached()
.map(|b| b.chars().count() as u16 + 2)
.unwrap_or(0);
let w = area.width.saturating_sub(2 + badge_approx).max(10) as usize;
let p_len = 3; let n = if state.paste_line_count.is_some() { 1 } else { state.input.chars().count() };
let text_rows = if n == 0 || state.paste_line_count.is_some() {
1
} else if n <= w - p_len {
1
} else {
let rem = n - (w - p_len);
1 + (rem + w - 1) / w
};
(text_rows as u16 + 2).min(12) };
let mut constraints = vec![
Constraint::Min(3),
Constraint::Length(1), Constraint::Length(input_h), ];
if popup_h > 0 { constraints.push(Constraint::Length(popup_h)); }
constraints.push(Constraint::Length(1)); constraints.push(Constraint::Length(1));
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(area);
let mut idx = 0usize;
render_content(f, layout[idx], state);
idx += 1;
render_status(f, layout[idx], state);
idx += 1;
render_input(f, layout[idx], state);
idx += 1;
if popup_h > 0 {
let sel = state.popup_selected.min(n_items.saturating_sub(1));
render_popup(f, layout[idx], &items, sel);
idx += 1;
}
render_tips(f, layout[idx], state);
idx += 1;
render_footer(f, layout[idx], state);
if state.help_open {
render_help_overlay(f, area, state.help_scroll);
}
}
fn is_last_in_turn(it: &std::iter::Peekable<std::collections::vec_deque::Iter<'_, ExecBlock>>) -> bool {
let mut peek_it = it.clone();
while let Some(next) = peek_it.next() {
match next {
ExecBlock::UserMessage(_) => return true,
ExecBlock::ToolUse { .. } | ExecBlock::Plan { .. } | ExecBlock::ToolOutput { .. } | ExecBlock::AgentText(..) => return false,
ExecBlock::WorkedFor(_) | ExecBlock::SystemMsg(_) => continue,
}
}
true
}
fn build_exec_lines(state: &TuiState, _width: u16) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
let mut it = state.exec_log.iter().peekable();
let mut in_assistant_turn = false;
let spine_str = "│ ";
let spine = Span::styled(spine_str, Style::default().fg(Color::Rgb(25, 45, 45)));
let seal = Span::styled("└─", Style::default().fg(Color::Rgb(25, 45, 45)));
while let Some(block) = it.next() {
let is_last = is_last_in_turn(&it);
match block {
ExecBlock::UserMessage(msg) => {
lines.push(Line::default());
lines.push(Line::from(vec![
Span::styled(
"≻ ",
Style::default().fg(CYAN).add_modifier(Modifier::BOLD),
),
Span::styled(msg.clone(), Style::default().fg(FG).bg(USER_BOX_BG)),
]));
in_assistant_turn = false;
}
ExecBlock::ToolUse { name, args, active } => {
if !in_assistant_turn {
lines.push(Line::default());
lines.push(Line::from(vec![
Span::styled("albert", Style::default().fg(Color::Rgb(0, 170, 120)).add_modifier(Modifier::BOLD)),
Span::styled(" ─────────────────────", Style::default().fg(Color::Rgb(25, 45, 45))),
]));
in_assistant_turn = true;
}
let elapsed = state.session_start.elapsed().as_secs_f32();
let dot_style = get_pulse_style(elapsed, *active);
let (name_col, args_col) = if *active { (FG, CYAN) } else { (GREY, GREY) };
let verb = if name.contains("write") { "Wrote" }
else if name.contains("read") { "Read" }
else if name.contains("grep") || name.contains("search") { "Searched" }
else if name.contains("glob") || name.contains("scan") { "Scanned" }
else if name.contains("bash") || name.contains("execute") { "Ran" }
else if name.contains("plan") { "Planned" }
else if name.contains("fetch") { "Fetched" }
else { "Used" };
let has_collapsed_output = matches!(it.peek(), Some(ExecBlock::ToolOutput { active: false, .. }));
let hook = if is_last && !has_collapsed_output { "└─" } else { "├─" };
let full_line = format!("{verb} {name}{}{}",
if args.is_empty() { "" } else { &format!(" {args}") },
if has_collapsed_output { " [Output Collapsed]" } else { "" }
);
let chars: Vec<char> = full_line.chars().collect();
let indent_len = 7;
let usable_w = _width.saturating_sub(indent_len as u16 + 1).max(10) as usize;
if chars.len() <= usable_w {
let mut spans = vec![
spine.clone(),
Span::styled(hook, Style::default().fg(Color::Rgb(25, 45, 45))),
Span::styled(" ● ", dot_style),
Span::styled(format!("{verb} {name}"), Style::default().fg(name_col).add_modifier(Modifier::BOLD)),
];
if !args.is_empty() {
spans.push(Span::styled(format!(" {args}"), Style::default().fg(args_col)));
}
if has_collapsed_output {
spans.push(Span::styled(" [Output Collapsed]", Style::default().fg(DIM).add_modifier(Modifier::ITALIC)));
}
lines.push(Line::from(spans));
} else {
let first_chunk: String = chars[..usable_w].iter().collect();
lines.push(Line::from(vec![
spine.clone(),
Span::styled(hook, Style::default().fg(Color::Rgb(25, 45, 45))),
Span::styled(" ● ", dot_style),
Span::styled(first_chunk, Style::default().fg(name_col).add_modifier(Modifier::BOLD)),
]));
let mut start = usable_w;
while start < chars.len() {
let end = (start + usable_w).min(chars.len());
let chunk: String = chars[start..end].iter().collect();
lines.push(Line::from(vec![
spine.clone(),
Span::styled(" ", Style::default()), Span::styled(chunk, Style::default().fg(args_col)),
]));
start = end;
}
}
}
ExecBlock::Plan { tasks, frozen } => {
if !in_assistant_turn {
lines.push(Line::default());
lines.push(Line::from(vec![
Span::styled("albert", Style::default().fg(Color::Rgb(0, 170, 120)).add_modifier(Modifier::BOLD)),
Span::styled(" ─────────────────────", Style::default().fg(Color::Rgb(25, 45, 45))),
]));
in_assistant_turn = true;
}
let elapsed = state.session_start.elapsed().as_secs_f32();
for (i, task) in tasks.iter().enumerate() {
let is_final_task = is_last && i == tasks.len() - 1;
let hook = if is_final_task { "└─" } else { "├─" };
let (icon, style) = match task.status {
TaskStatus::Pending => (" [ ] ", Style::default().fg(GREY)),
TaskStatus::Running => {
if *frozen { (" [●] ", Style::default().fg(GREEN)) }
else { (" [●] ", get_pulse_style(elapsed, true)) }
}
TaskStatus::Done => (" [✔] ", Style::default().fg(GREEN).add_modifier(Modifier::BOLD)),
TaskStatus::Failed => (" [✘] ", Style::default().fg(ERROR_FG).add_modifier(Modifier::BOLD)),
};
lines.push(Line::from(vec![
spine.clone(),
Span::styled(hook, Style::default().fg(Color::Rgb(25, 45, 45))),
Span::styled(icon, style),
Span::styled(task.label.clone(), Style::default().fg(FG)),
]));
}
}
ExecBlock::ToolOutput { lines: out, total, active } => {
if *active {
for (i, line) in out.iter().enumerate() {
let connector = if i == 0 { "└─" } else { " " };
let lower = line.to_ascii_lowercase();
let is_err = lower.contains("error") || lower.contains("not found") || lower.contains("failed:");
let line_col = if is_err { ERROR_FG } else { Color::Rgb(110, 120, 120) };
lines.push(Line::from(vec![
spine.clone(), Span::styled(connector, Style::default().fg(Color::Rgb(25, 45, 45))),
Span::styled(" ", Style::default()),
Span::styled(line.clone(), Style::default().fg(line_col)),
]));
}
if *total > out.len() {
lines.push(Line::from(vec![
spine.clone(),
Span::styled(" ", Style::default()),
Span::styled(format!("… +{} lines", total - out.len()), Style::default().fg(DIM).add_modifier(Modifier::ITALIC)),
]));
}
if is_last {
lines.push(Line::from(vec![seal.clone()]));
}
} else {
if is_last {
lines.push(Line::from(vec![seal.clone()]));
}
}
}
ExecBlock::AgentText(text, interrupted) => {
if !in_assistant_turn {
lines.push(Line::default());
lines.push(Line::from(vec![
Span::styled("albert", Style::default().fg(Color::Rgb(0, 170, 120)).add_modifier(Modifier::BOLD)),
Span::styled(" ─────────────────────", Style::default().fg(Color::Rgb(25, 45, 45))),
]));
in_assistant_turn = true;
}
let text = text.trim();
let md_lines = markdown_to_lines(text, Some(spine.clone()), _width);
lines.extend(md_lines);
if is_last || *interrupted {
lines.push(Line::from(vec![seal.clone()]));
}
}
ExecBlock::WorkedFor(_) => {}
ExecBlock::SystemMsg(msg) => {
let mut it_msg = msg.lines().peekable();
let first_line = it_msg.peek().cloned().unwrap_or_default();
let is_banner = first_line == "[BANNER]";
let is_treemap = first_line == "[TREEMAP]";
if is_banner {
let has_user_msgs = state.exec_log.iter().any(|b| matches!(b, ExecBlock::UserMessage(_)));
if has_user_msgs { continue; }
}
lines.push(Line::default());
in_assistant_turn = false;
if is_banner || is_treemap {
it_msg.next(); let logo_colors = [
Color::Rgb(0, 255, 255), Color::Rgb(0, 220, 255),
Color::Rgb(0, 190, 255),
Color::Rgb(0, 160, 255),
Color::Rgb(0, 130, 255),
Color::Rgb(0, 100, 255),
];
let banner_lines: Vec<String> = it_msg.map(|s| s.to_string()).collect();
let footer_line = banner_lines.iter().position(|l| l.contains("Did you know?")).unwrap_or(banner_lines.len());
let mut padded_logo = Vec::new();
let mut max_logo_chars: usize = 0;
if is_banner {
let logo_count = 6.min(banner_lines.len()).min(footer_line);
for i in 0..logo_count {
let row = banner_lines[i].chars().take(54).collect::<String>().trim_end().to_string();
let c = row.chars().count();
if c > max_logo_chars { max_logo_chars = c; }
padded_logo.push(row);
}
for row in &mut padded_logo {
let needed = max_logo_chars.saturating_sub(row.chars().count());
row.push_str(&" ".repeat(needed));
}
}
let mut max_visual_w = 51; for (i, line) in banner_lines.iter().enumerate() {
if i >= footer_line { break; }
let text = if is_banner && i < padded_logo.len() {
padded_logo[i].clone()
} else if is_banner {
line.chars().take(54).collect::<String>().trim_end().to_string()
} else {
line.trim_end().to_string()
};
let w = console::measure_text_width(&text);
if w > max_visual_w { max_visual_w = w; }
}
let target_w = max_visual_w + 2;
lines.push(Line::from(vec![
Span::styled(format!(" ┌{}┐", "─".repeat(target_w + 2)), Style::default().fg(CHAT_BORDER))
]));
for (i, line) in banner_lines.iter().enumerate() {
if i >= footer_line { break; }
let mut row_spans = Vec::new();
row_spans.push(Span::styled(" │ ", Style::default().fg(CHAT_BORDER)));
let mut current_row_visual_w;
if is_treemap {
let text = line.trim_end();
current_row_visual_w = console::measure_text_width(text);
row_spans.push(Span::styled(text.to_string(), Style::default().fg(GREEN)));
} else {
let content_text = if i < padded_logo.len() {
padded_logo[i].clone()
} else {
line.chars().take(54).collect::<String>().trim_end().to_string()
};
if i < padded_logo.len() { let col = logo_colors.get(i).cloned().unwrap_or(GREY);
row_spans.push(Span::styled(content_text.clone(), Style::default().fg(col).add_modifier(Modifier::BOLD)));
current_row_visual_w = console::measure_text_width(&content_text);
} else if i >= padded_logo.len() && i < footer_line { let text = content_text.trim(); current_row_visual_w = 0;
if text.starts_with("Welcome Back,") {
row_spans.push(Span::styled("Welcome Back, ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)));
let user_part = text.chars().skip(13).collect::<String>();
let user_with_bang = format!("{}!", user_part.trim());
current_row_visual_w += 14 + console::measure_text_width(&user_with_bang);
row_spans.push(Span::styled(user_with_bang, Style::default().fg(CYAN).add_modifier(Modifier::BOLD)));
} else if text.starts_with("Model") || text.starts_with("Mode") || text.starts_with("Session") {
let parts: Vec<&str> = text.splitn(2, ' ').collect();
row_spans.push(Span::styled(format!("{:<8}", parts[0]), Style::default().fg(GREY)));
current_row_visual_w += 8;
if parts.len() > 1 {
let val = parts[1].trim();
current_row_visual_w += console::measure_text_width(val);
row_spans.push(Span::styled(val.to_string(), Style::default().fg(GREY).add_modifier(Modifier::BOLD)));
}
} else {
current_row_visual_w += console::measure_text_width(text);
row_spans.push(Span::styled(text.to_string(), Style::default().fg(GREY)));
}
} else {
current_row_visual_w = console::measure_text_width(&content_text);
row_spans.push(Span::raw(content_text));
}
}
let padding_needed = target_w.saturating_sub(current_row_visual_w);
row_spans.push(Span::raw(" ".repeat(padding_needed)));
row_spans.push(Span::styled(" │", Style::default().fg(CHAT_BORDER)));
lines.push(Line::from(row_spans));
}
lines.push(Line::from(vec![
Span::styled(format!(" └{}┘", "─".repeat(target_w + 2)), Style::default().fg(CHAT_BORDER))
]));
if footer_line < banner_lines.len() {
lines.push(Line::from(vec![
Span::styled(format!(" {}", banner_lines[footer_line].trim()), Style::default().fg(GREY).add_modifier(Modifier::ITALIC))
]));
}
} else {
for line in msg.lines() {
let mut spans = Vec::new();
spans.push(Span::styled("* ", Style::default().fg(DIM)));
spans.push(Span::styled(line.to_string(), Style::default().fg(GREY)));
lines.push(Line::from(spans));
}
}
}
}
}
lines
}
fn render_content(f: &mut ratatui::Frame, area: Rect, state: &TuiState) {
let scroll_indicator = if state.scroll > 0 {
format!(" ↑ {} ctrl+l → bottom ", state.scroll)
} else {
String::new()
};
let title_line = if scroll_indicator.is_empty() {
Line::from(vec![
Span::styled(" albert ", Style::default().fg(CHAT_BORDER).add_modifier(Modifier::BOLD))
])
} else {
Line::from(vec![
Span::styled(" albert ", Style::default().fg(CHAT_BORDER).add_modifier(Modifier::BOLD)),
Span::styled(scroll_indicator, Style::default().fg(GREY)),
])
};
let block = Block::default()
.title(title_line)
.borders(Borders::ALL)
.border_style(Style::default().fg(CHAT_BORDER))
.style(Style::default().bg(BG));
let inner = block.inner(area);
let lines = build_exec_lines(state, inner.width);
let w = inner.width.max(1) as usize;
let total_wrapped: usize = lines
.iter()
.map(|line| {
let chars: usize = line.spans.iter().map(|s| s.content.chars().count()).sum();
if chars == 0 { 1 } else { (chars + w - 1) / w }
})
.sum();
let visible = inner.height as usize;
let total_wrapped_count = total_wrapped;
let max_scroll = total_wrapped_count.saturating_sub(visible);
let scroll_row = max_scroll.saturating_sub(state.scroll as usize).min(u16::MAX as usize) as u16;
let para = Paragraph::new(Text::from(lines))
.style(Style::default().bg(BG).fg(FG))
.wrap(Wrap { trim: false })
.scroll((scroll_row, 0))
.block(block);
f.render_widget(para, area);
}
fn render_popup(f: &mut ratatui::Frame, area: Rect, items: &[PopupItem], selected: usize) {
let total = items.len();
let win_size = total.min(POPUP_WINDOW);
let win_start = selected
.saturating_sub(win_size / 2)
.min(total.saturating_sub(win_size));
let win_end = (win_start + win_size).min(total);
let selectable_total = items.iter().filter(|i| !i.is_header).count();
let selectable_idx = items[..selected.min(total.saturating_sub(1))]
.iter()
.filter(|i| !i.is_header)
.count();
let sel_item = items.get(selected.min(total.saturating_sub(1)));
let sel_is_drilldown = sel_item
.map(|it| !it.is_header && is_drilldown(&it.complete))
.unwrap_or(false);
let mut lines: Vec<Line<'static>> = Vec::new();
for (abs_i, item) in items[win_start..win_end].iter().enumerate() {
let i = win_start + abs_i;
if item.is_header {
let label = format!(" {} ", item.display);
lines.push(Line::from(Span::styled(label, Style::default().fg(CATEGORY_FG).bg(POPUP_BG))));
} else {
let is_sel = i == selected;
let bg = if is_sel { POPUP_SEL_BG } else { POPUP_BG };
let name_col = if is_sel { GREEN } else { POPUP_MATCH };
let desc_col = if is_sel { FG } else { GREY };
let drilldown_hint = if is_sel && is_drilldown(&item.complete) {
Span::styled(" ›", Style::default().fg(CYAN).bg(bg).add_modifier(Modifier::BOLD))
} else {
Span::styled("", Style::default().bg(bg))
};
lines.push(Line::from(vec![
Span::styled(" ", Style::default().bg(bg)),
Span::styled(
format!("/{}", item.display),
Style::default().fg(name_col).bg(bg).add_modifier(Modifier::BOLD),
),
drilldown_hint,
Span::styled(" ", Style::default().bg(bg)),
Span::styled(item.desc.clone(), Style::default().fg(desc_col).bg(bg)),
]));
}
}
let action_hint = if sel_is_drilldown { "enter → open · " } else { "enter → select · " };
let nav = if selectable_total > 0 {
format!(
" ({}/{}) ↑↓ navigate · {action_hint}tab → complete · esc dismiss",
selectable_idx + 1,
selectable_total,
)
} else {
" ↑↓ navigate · esc dismiss".to_string()
};
lines.push(Line::from(Span::styled(nav, Style::default().fg(DIM).bg(POPUP_BG))));
let para = Paragraph::new(Text::from(lines)).style(Style::default().bg(POPUP_BG));
f.render_widget(para, area);
}
fn render_help_overlay(f: &mut ratatui::Frame, area: Rect, scroll: u16) {
use ratatui::widgets::Clear;
let overlay = Rect {
x: area.x + 2,
y: area.y + 1,
width: area.width.saturating_sub(4),
height: area.height.saturating_sub(2),
};
f.render_widget(Clear, overlay);
const SECTION: Color = Color::Rgb(0, 200, 120);
const CMD_C: Color = Color::Rgb(0, 200, 255);
const HINT_C: Color = Color::Rgb(120, 120, 120);
let mut lines: Vec<Line<'static>> = Vec::new();
let h = |s: &'static str| Line::from(Span::styled(s, Style::default().fg(SECTION).add_modifier(Modifier::BOLD)));
let c = |cmd: &'static str, desc: &'static str| Line::from(vec![
Span::styled(format!(" {cmd:<22}"), Style::default().fg(CMD_C).add_modifier(Modifier::BOLD)),
Span::styled(desc, Style::default().fg(FG)),
]);
let hint_line = |s: &'static str| Line::from(Span::styled(format!(" {s}"), Style::default().fg(HINT_C)));
let blank = || Line::from("");
lines.push(blank());
lines.push(h(" MODELS & PROVIDERS"));
lines.push(c("/model <id>", "switch model — opens picker when blank"));
lines.push(c("/auth <provider>", "set API key for a provider"));
lines.push(c("/auth browser", "OAuth browser login (Google / GitHub)"));
lines.push(hint_line("Providers: anthropic · openai · google · xai · groq · mistral · deepseek"));
lines.push(hint_line(" together · openrouter · perplexity · cohere · cerebras · qwen"));
lines.push(hint_line(" nvidia · fireworks · deepinfra · novita · sambanova · ollama"));
lines.push(blank());
lines.push(h(" TIPS & INTERACTION"));
lines.push(hint_line("Selection: Click & Drag to select and copy text."));
lines.push(hint_line("Scrolling: Mouse wheel or Shift+Up/Down arrows to scroll chat history."));
lines.push(hint_line("Override: Hold SHIFT to force native terminal selection/scrolling."));
lines.push(blank());
lines.push(h(" SESSION"));
lines.push(c("/compact", "summarise old context to free tokens"));
lines.push(c("/compress", "aggressive compression — strip tool outputs"));
lines.push(c("/status", "show token usage and session info"));
lines.push(c("/cost", "show estimated API cost for this session"));
lines.push(c("/clear", "wipe conversation history"));
lines.push(c("/export", "save conversation to markdown file"));
lines.push(c("/session", "list saved sessions"));
lines.push(c("/resume <id>", "restore a previous session"));
lines.push(blank());
lines.push(h(" AGENT MODES"));
lines.push(c("/plan <task>", "decompose task into numbered steps"));
lines.push(c("/loop <mission>", "autonomous mode — runs until MISSION COMPLETE"));
lines.push(c("/tdd <spec>", "test-driven development loop"));
lines.push(c("/code-review", "review staged diff"));
lines.push(c("/bughunter", "scan codebase for bugs"));
lines.push(c("/refactor", "refactor current file"));
lines.push(blank());
lines.push(h(" WORKSPACE & GIT"));
lines.push(c("/commit", "commit staged changes with AI message"));
lines.push(c("/pr", "create pull request"));
lines.push(c("/diff", "show current git diff"));
lines.push(c("/init", "scaffold ALBERT.md in current directory"));
lines.push(c("/memory", "view/edit persistent memory"));
lines.push(blank());
lines.push(h(" PERMISSIONS"));
lines.push(c("/permissions", "show current permission mode — picker when blank"));
lines.push(hint_line("Modes: read-only · workspace-write · danger-full-access"));
lines.push(blank());
lines.push(h(" KEYBOARD"));
lines.push(hint_line("Enter send message"));
lines.push(hint_line("Tab autocomplete command from popup"));
lines.push(hint_line("↑ ↓ navigate input history"));
lines.push(hint_line("PageUp/Dn scroll conversation (or Shift + ↑/↓)"));
lines.push(hint_line("MouseWheel scroll conversation"));
lines.push(hint_line("Shift+Click select text / right-click (native)"));
lines.push(hint_line("Esc interrupt · dismiss popup · close this overlay"));
lines.push(hint_line("Ctrl+Space toggle voice recording (whisper STT)"));
lines.push(hint_line("Ctrl+V paste from clipboard"));
lines.push(hint_line("Ctrl+C quit"));
lines.push(blank());
lines.push(Line::from(Span::styled(" Esc to close", Style::default().fg(DIM))));
let total = lines.len() as u16;
let visible = overlay.height.saturating_sub(2);
let max_scroll = total.saturating_sub(visible);
let scroll = scroll.min(max_scroll);
let block = Block::default()
.title(" Albert — Command Reference ")
.title_style(Style::default().fg(GREEN).add_modifier(Modifier::BOLD))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Rgb(50, 50, 50)))
.style(Style::default().bg(Color::Rgb(8, 8, 8)));
let para = Paragraph::new(Text::from(lines))
.block(block)
.scroll((scroll, 0));
f.render_widget(para, overlay);
}
fn render_input(f: &mut ratatui::Frame, area: Rect, state: &TuiState) {
let border_col = if state.auth_flow.is_some() {
ORANGE } else {
INPUT_BORDER
};
let input_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_col))
.style(Style::default().bg(USER_BOX_BG));
f.render_widget(input_block.clone(), area);
let inner = input_block.inner(area);
let branch = git_branch_cached();
let badge_text = branch.as_deref().map(|b| format!(" {b} ")).unwrap_or_default();
let badge_w = badge_text.chars().count() as u16;
let h_layout = if badge_w > 0 && inner.width > badge_w + 4 {
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(4), Constraint::Length(badge_w)])
.split(inner)
} else {
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(1)])
.split(inner)
};
let text_area = h_layout[0];
let para = if let Some(n) = state.paste_line_count {
Paragraph::new(Line::from(vec![
Span::styled(" ≻ ", Style::default().fg(CYAN).add_modifier(Modifier::BOLD)),
Span::styled(
format!("[Pasted Text: {} lines]", n),
Style::default()
.fg(Color::Rgb(198, 120, 221))
.bg(Color::Rgb(40, 44, 52)),
),
Span::styled(" Esc to clear", Style::default().fg(DIM).add_modifier(Modifier::ITALIC)),
]))
} else if let Some(ref provider) = state.auth_flow {
if state.input.is_empty() {
Paragraph::new(Line::from(vec![
Span::styled(" 🔑 ", Style::default().fg(ORANGE).add_modifier(Modifier::BOLD)),
Span::styled(
format!("API key for {provider}:"),
Style::default().fg(ORANGE),
),
Span::styled(" (press Enter to save)", Style::default().fg(DIM)),
]))
} else {
let masked: String = "*".repeat(state.input.chars().count());
Paragraph::new(Line::from(vec![
Span::styled(" 🔑 ", Style::default().fg(ORANGE).add_modifier(Modifier::BOLD)),
Span::styled(masked, Style::default().fg(ORANGE)),
]))
}
} else if state.input.is_empty() {
Paragraph::new(Line::from(vec![
Span::styled(" ≻ ", Style::default().fg(CYAN).add_modifier(Modifier::BOLD)),
Span::styled("Type your message or @path/to/file", Style::default().fg(DIM)),
]))
} else {
let (prompt_txt, prompt_col) = if state.history_idx.is_some() {
(" ↑ ", ORANGE)
} else {
(" ≻ ", CYAN)
};
let w = text_area.width as usize;
let p_len = 3;
let chars: Vec<char> = state.input.chars().collect();
let mut lines = Vec::new();
if chars.len() <= w.saturating_sub(p_len) {
lines.push(Line::from(vec![
Span::styled(prompt_txt, Style::default().fg(prompt_col).add_modifier(Modifier::BOLD)),
Span::styled(state.input.clone(), Style::default().fg(FG)),
]));
} else {
lines.push(Line::from(vec![
Span::styled(prompt_txt, Style::default().fg(prompt_col).add_modifier(Modifier::BOLD)),
Span::styled(chars[0..w.saturating_sub(p_len)].iter().collect::<String>(), Style::default().fg(FG)),
]));
let mut start = w.saturating_sub(p_len);
while start < chars.len() {
let end = (start + w).min(chars.len());
lines.push(Line::from(vec![
Span::styled(chars[start..end].iter().collect::<String>(), Style::default().fg(FG)),
]));
start = end;
}
}
Paragraph::new(lines)
};
f.render_widget(para.style(Style::default().bg(USER_BOX_BG)), text_area);
{
const PREFIX: u16 = 3;
let (cx, cy) = if state.paste_line_count.is_some() {
(text_area.x + PREFIX + 1, text_area.y)
} else {
let w = text_area.width as usize;
let p = PREFIX as usize;
let (visual_row, visual_col) = if state.cursor < w.saturating_sub(p) {
(0, state.cursor + p)
} else {
let rem = state.cursor - (w.saturating_sub(p));
(1 + rem / w, rem % w)
};
let cx = (text_area.x + visual_col as u16).min(text_area.x + text_area.width.saturating_sub(1));
let cy = (text_area.y + visual_row as u16).min(text_area.y + text_area.height.saturating_sub(1));
(cx, cy)
};
f.set_cursor_position((cx, cy));
}
if h_layout.len() == 2 {
f.render_widget(
Paragraph::new(Line::from(Span::styled(
badge_text,
Style::default().fg(GREEN).bg(BRANCH_BG).add_modifier(Modifier::BOLD),
)))
.style(Style::default().bg(BRANCH_BG)),
h_layout[1],
);
}
}
pub fn render_report_card(f: &mut ratatui::Frame, state: &TuiState) {
let area = f.area();
let w = 76;
let h = 20; let x = (area.width.saturating_sub(w)) / 2;
let y = (area.height.saturating_sub(h)) / 2;
let popup_area = Rect::new(x, y, w.min(area.width), h.min(area.height));
let mut lines = Vec::new();
lines.push(Line::from(vec![
Span::styled(" 𒀭 Agent powering down. Goodbye!", Style::default().fg(GREEN).add_modifier(Modifier::BOLD)),
]));
lines.push(Line::default());
let label_style = Style::default().fg(GREY);
let value_style = Style::default().fg(CYAN).add_modifier(Modifier::BOLD);
lines.push(Line::from(Span::styled(" Interaction Summary", Style::default().add_modifier(Modifier::BOLD))));
lines.push(Line::from(vec![
Span::styled(" Session ID: ", label_style),
Span::styled(&state.session_id, value_style),
]));
let success_rate = if state.tool_calls > 0 {
(state.tool_success as f32 / state.tool_calls as f32) * 100.0
} else {
0.0
};
lines.push(Line::from(vec![
Span::styled(" Tool Calls: ", label_style),
Span::styled(format!("{} ( ✓ {} ✗ {} )", state.tool_calls, state.tool_success, state.tool_failure), value_style),
]));
lines.push(Line::from(vec![
Span::styled(" Success Rate: ", label_style),
Span::styled(format!("{:.1}%", success_rate), value_style),
]));
lines.push(Line::default());
lines.push(Line::from(Span::styled(" Resources", Style::default().add_modifier(Modifier::BOLD))));
lines.push(Line::from(vec![
Span::styled(" Total Tokens: ", label_style),
Span::styled(format!("{} in · {} out", fmt_tokens(state.tokens_in), fmt_tokens(state.tokens_out)), value_style),
]));
lines.push(Line::default());
lines.push(Line::from(Span::styled(" Performance", Style::default().add_modifier(Modifier::BOLD))));
let wall_secs = state.session_start.elapsed().as_secs();
let wall_time = if wall_secs >= 60 { format!("{}m {}s", wall_secs / 60, wall_secs % 60) } else { format!("{wall_secs}s") };
let active_secs = state.agent_active_ms / 1000;
let active_time = if active_secs >= 60 { format!("{}m {}s", active_secs / 60, active_secs % 60) } else { format!("{active_secs}s") };
lines.push(Line::from(vec![
Span::styled(" Wall Time: ", label_style),
Span::styled(wall_time, value_style),
]));
lines.push(Line::from(vec![
Span::styled(" Agent Active: ", label_style),
Span::styled(active_time, value_style),
]));
let api_pct = if state.agent_active_ms > 0 { (state.api_time_ms as f32 / state.agent_active_ms as f32) * 100.0 } else { 0.0 };
let tool_pct = if state.agent_active_ms > 0 { (state.tool_time_ms as f32 / state.agent_active_ms as f32) * 100.0 } else { 0.0 };
lines.push(Line::from(vec![
Span::styled(" » API Time: ", label_style),
Span::styled(format!("{}s ({:.1}%)", state.api_time_ms / 1000, api_pct), value_style),
]));
lines.push(Line::from(vec![
Span::styled(" » Tool Time: ", label_style),
Span::styled(format!("{}s ({:.1}%)", state.tool_time_ms / 1000, tool_pct), value_style),
]));
lines.push(Line::default());
lines.push(Line::from(vec![
Span::styled(" To resume this session: ", label_style),
Span::styled(format!("albert --resume {}", state.session_id), Style::default().fg(GREEN)),
]));
lines.push(Line::default());
lines.push(Line::from(vec![
Span::styled(" ( Press any key to exit )", Style::default().fg(DIM).add_modifier(Modifier::ITALIC)),
]));
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(CHAT_BORDER))
.style(Style::default().bg(BG));
let para = Paragraph::new(lines)
.block(block);
f.render_widget(para, popup_area);
}
fn current_activity(state: &TuiState) -> String {
let elapsed = state.session_start.elapsed().as_secs_f32();
let tick = (elapsed * 10.0) as usize; let phrase_idx = (tick / 30) % 10;
let thinking_phrases = [
"Computing causal vectors...",
"Navigating ternary matrices...",
"Weighing ontological states...",
"Resolving logic branches...",
"Distilling intent...",
"Evaluating outcome probabilities...",
"Synthesizing cognitive shards...",
"Mapping architecture dependencies...",
"Optimizing heuristic paths...",
"Synchronizing neural weights...",
];
let reading_phrases = [
"Ingesting context...",
"Parsing structural data...",
"Scanning workspace geometry...",
"Resolving symbol references...",
"Analyzing byte streams...",
"Absorbing local state...",
"Decoding manifest layers...",
"Tracing source origins...",
"Indexing project memory...",
"Querying filesystem truth...",
];
let writing_phrases = [
"Compiling output...",
"Forging response...",
"Committing logic to buffer...",
"Assembling content blocks...",
"Refining prose...",
"Emitting signal...",
"Hardening implementation...",
"Polishing syntax...",
"Projecting thought into text...",
"Finalizing assistant state...",
];
for block in state.exec_log.iter().rev() {
if let ExecBlock::ToolUse { name, active, .. } = block {
if *active {
let n = name.as_str();
if n.contains("read") {
return reading_phrases[phrase_idx].to_string();
} else if n.contains("write") || n.contains("edit") {
return writing_phrases[phrase_idx].to_string();
} else if n.contains("bash") || n.contains("execute") {
return format!("Running {}...", n);
} else if n.contains("grep") || n.contains("search") {
return format!("Searching {}...", n);
} else if n.contains("glob") || n.contains("scan") {
return format!("Scanning {}...", n);
} else if n.contains("web") || n.contains("fetch") {
return "Fetching...".to_string();
} else if n.contains("plan") {
return "Planning...".to_string();
} else {
return "On it...".to_string();
};
}
}
}
thinking_phrases[phrase_idx].to_string()
}
const MIC_RED: Color = Color::Rgb(255, 60, 60);
fn render_status(f: &mut ratatui::Frame, area: Rect, state: &TuiState) {
let line = if !state.trusted {
Line::from(vec![
Span::styled(" ⚠ ", Style::default().fg(ORANGE).add_modifier(Modifier::BOLD)),
Span::styled("Untrusted Folder", Style::default().fg(ORANGE)),
Span::styled(" tools will require manual approval", Style::default().fg(DIM)),
])
} else if state.is_recording {
Line::from(vec![
Span::styled(" 𒀭 ", Style::default().fg(MIC_RED).add_modifier(Modifier::BOLD)),
Span::styled("Recording…", Style::default().fg(MIC_RED)),
Span::styled(" ctrl+space to stop & transcribe", Style::default().fg(GREY)),
])
} else if state.is_prompting.load(Ordering::Relaxed) {
Line::from(vec![
Span::styled(" 𒀭 ", Style::default().fg(ORANGE).add_modifier(Modifier::BOLD)),
Span::styled("Waiting for you…", Style::default().fg(ORANGE)),
Span::styled(" check main terminal for approval prompt", Style::default().fg(GREY)),
])
} else if state.working {
let elapsed_ms = state.turn_start.map(|t| t.elapsed().as_millis()).unwrap_or(0);
let secs = elapsed_ms / 1000;
let timer = if secs >= 60 {
format!("{}m {}s", secs / 60, secs % 60)
} else {
format!("{secs}s")
};
let tok_str = if state.tokens_out > 0 {
format!(" · ↓ {} tokens", fmt_tokens(state.tokens_out))
} else {
String::new()
};
let activity = current_activity(state);
let elapsed = state.session_start.elapsed().as_secs_f32();
let pulse_style = get_pulse_style(elapsed, true);
Line::from(vec![
Span::styled(" 𒀭 ", pulse_style),
Span::styled(activity, pulse_style),
Span::styled(format!(" ({timer}{tok_str})"), Style::default().fg(GREY)),
])
} else {
let last_worked = state.exec_log.iter().rev().find_map(|b| {
if let ExecBlock::WorkedFor(s) = b { Some(*s) } else { None }
});
let worked_part = last_worked.map(|secs| {
let dur = if secs >= 60 {
format!("{}m {}s", secs / 60, secs % 60)
} else {
format!("{secs}s")
};
format!(" · Worked for {dur} ")
}).unwrap_or_else(|| " · ".to_string());
Line::from(vec![
Span::styled(" 𒀭 ", Style::default().fg(DIM).add_modifier(Modifier::BOLD)),
Span::styled("Idle", Style::default().fg(DIM)),
Span::styled(worked_part, Style::default().fg(DIM)),
Span::styled("type / for commands", Style::default().fg(DIM)),
])
};
f.render_widget(Paragraph::new(line).style(Style::default().bg(STATUS_BG)), area);
}
fn render_tips(f: &mut ratatui::Frame, area: Rect, state: &TuiState) {
let secs = state.session_start.elapsed().as_secs();
let tip_idx = (secs / 8) as usize % TIPS.len();
let tip = TIPS[tip_idx];
let line = Line::from(vec![
Span::styled("⎿ ", Style::default().fg(DIM)),
Span::styled("Tip: ", Style::default().fg(DIM)),
Span::styled(tip, Style::default().fg(GREY)),
]);
f.render_widget(Paragraph::new(line).style(Style::default().bg(BG)), area);
}
fn render_footer(f: &mut ratatui::Frame, area: Rect, state: &TuiState) {
let line = if state.working {
Line::from(vec![
Span::styled(" ▶▶ ", Style::default().fg(CYAN).add_modifier(Modifier::BOLD)),
Span::styled("esc to interrupt", Style::default().fg(CYAN)),
Span::styled(
" · ctrl+c to quit",
Style::default().fg(Color::Rgb(60, 60, 60)),
),
])
} else {
let dir = std::path::Path::new(&state.cwd)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(&state.cwd);
let perm = if state.permission_mode.is_empty() {
String::new()
} else {
format!(" · {}", state.permission_mode)
};
let base = format!(" {} · {}{}", state.model, dir, perm);
let base_style = Style::default().fg(Color::Rgb(50, 50, 50));
let tok_style = Style::default().fg(Color::Rgb(40, 40, 40));
if state.tokens_in > 0 {
let tok_str = format!(
" · {}↑ {}↓",
fmt_tokens(state.tokens_in),
fmt_tokens(state.tokens_out),
);
Line::from(vec![
Span::styled(base, base_style),
Span::styled(tok_str, tok_style),
])
} else {
Line::from(Span::styled(base, base_style))
}
};
f.render_widget(
Paragraph::new(line).style(Style::default().bg(BG)),
area,
);
}
fn fmt_tokens(n: u32) -> String {
if n >= 1_000_000 {
format!("{:.1}M", n as f64 / 1_000_000.0)
} else if n >= 1_000 {
format!("{:.1}K", n as f64 / 1_000.0)
} else {
n.to_string()
}
}
fn git_branch_cached() -> Option<String> {
use std::sync::OnceLock;
use std::time::SystemTime;
static CACHE: OnceLock<std::sync::Mutex<(Option<String>, SystemTime)>> = OnceLock::new();
let cache = CACHE.get_or_init(|| std::sync::Mutex::new((None, SystemTime::UNIX_EPOCH)));
let mut guard = cache.lock().ok()?;
let (ref mut branch, ref mut updated) = *guard;
let age = SystemTime::now().duration_since(*updated).unwrap_or_default();
if age.as_secs() > 10 {
if let Ok(out) = std::process::Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.output()
{
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !s.is_empty() && s != "HEAD" {
*branch = Some(s);
}
}
*updated = SystemTime::now();
}
branch.clone()
}
async fn transcribe(wav_path: &str) -> Result<String, String> {
if let Ok(out) = std::process::Command::new("whisper")
.args([wav_path, "--model", "tiny", "--language", "en",
"--output_format", "txt", "--output_dir", "/tmp", "--fp16", "False"])
.output()
{
if out.status.success() {
let txt_path = "/tmp/albert-voice.txt";
if let Ok(text) = std::fs::read_to_string(txt_path) {
let _ = std::fs::remove_file(txt_path);
let t = text.trim().to_string();
if !t.is_empty() { return Ok(t); }
}
let stdout = String::from_utf8_lossy(&out.stdout);
let t = stdout.trim().to_string();
if !t.is_empty() { return Ok(t); }
}
}
if let Ok(key) = std::env::var("OPENAI_API_KEY") {
if !key.is_empty() {
let wav = std::fs::read(wav_path).map_err(|e| e.to_string())?;
return transcribe_openai(wav, &key).await;
}
}
Err("voice: no STT available — install whisper: pip install openai-whisper".to_string())
}
async fn transcribe_openai(wav: Vec<u8>, api_key: &str) -> Result<String, String> {
let client = reqwest::Client::new();
let part = reqwest::multipart::Part::bytes(wav)
.file_name("audio.wav")
.mime_str("audio/wav")
.map_err(|e| e.to_string())?;
let form = reqwest::multipart::Form::new()
.part("file", part)
.text("model", "whisper-1");
let resp = client
.post("https://api.openai.com/v1/audio/transcriptions")
.header("Authorization", format!("Bearer {api_key}"))
.multipart(form)
.send()
.await
.map_err(|e| e.to_string())?;
let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
json.get("text")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| format!("unexpected response: {json}"))
}
pub struct TuiApp {
pub state: Arc<Mutex<TuiState>>,
pub event_tx: tokio::sync::mpsc::UnboundedSender<TuiEvent>,
event_rx: tokio::sync::mpsc::UnboundedReceiver<TuiEvent>,
submit_tx: std::sync::mpsc::Sender<String>,
key_paused: Arc<AtomicBool>,
pub cancel_flag: Arc<AtomicBool>,
voice_process: Arc<std::sync::Mutex<Option<std::process::Child>>>,
}
impl TuiApp {
pub fn new(model: String, cwd: String, permission_mode: String, session_id: String) -> (Self, std::sync::mpsc::Receiver<String>) {
let (event_tx, event_rx) = tokio::sync::mpsc::unbounded_channel();
let (submit_tx, submit_rx) = std::sync::mpsc::channel();
let app = Self {
state: Arc::new(Mutex::new(TuiState::new(model, cwd, permission_mode, session_id))),
event_tx,
event_rx,
submit_tx,
key_paused: Arc::new(AtomicBool::new(false)),
cancel_flag: Arc::new(AtomicBool::new(false)),
voice_process: Arc::new(std::sync::Mutex::new(None)),
};
(app, submit_rx)
}
pub fn run(self) {
if let Err(e) = self.run_inner() {
eprintln!("tui: {e}");
}
}
fn run_inner(mut self) -> Result<(), Box<dyn std::error::Error>> {
enable_raw_mode()?;
io::stdout().execute(EnterAlternateScreen)?;
io::stdout().execute(EnableBracketedPaste)?;
io::stdout().execute(EnableMouseCapture)?;
let backend = CrosstermBackend::new(io::stdout());
let mut terminal = Terminal::new(backend)?;
terminal.clear()?;
let cancel_flag = Arc::clone(&self.cancel_flag);
let ktx = self.event_tx.clone();
let key_paused = Arc::clone(&self.key_paused);
std::thread::spawn(move || loop {
if key_paused.load(Ordering::Relaxed) {
std::thread::sleep(Duration::from_millis(30));
continue;
}
if event::poll(Duration::from_millis(50)).unwrap_or(false) {
match event::read() {
Ok(Event::Key(k)) => { let _ = ktx.send(TuiEvent::Key(k)); }
Ok(Event::Paste(text)) => { let _ = ktx.send(TuiEvent::PasteText(text)); }
Ok(Event::Resize(_, _)) => { let _ = ktx.send(TuiEvent::Tick); }
Ok(Event::Mouse(me)) => {
match me.kind {
MouseEventKind::ScrollUp => { let _ = ktx.send(TuiEvent::ScrollUp); }
MouseEventKind::ScrollDown => { let _ = ktx.send(TuiEvent::ScrollDown); }
_ => {}
}
}
_ => {}
}
}
});
let ttx = self.event_tx.clone();
std::thread::spawn(move || loop {
std::thread::sleep(Duration::from_millis(100));
let _ = ttx.send(TuiEvent::Tick);
});
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
rt.block_on(async {
let mut last_draw = Instant::now();
const DRAW_INTERVAL: Duration = Duration::from_millis(33);
loop {
if last_draw.elapsed() >= DRAW_INTERVAL {
{
let state = self.state.lock().unwrap();
terminal.draw(|f| render(f, &state))?;
}
last_draw = Instant::now();
}
let wait = DRAW_INTERVAL.saturating_sub(last_draw.elapsed());
let ev = match tokio::time::timeout(wait, self.event_rx.recv()).await {
Ok(ev) => ev,
Err(_) => continue, };
match ev {
Some(TuiEvent::Key(key)) => {
let mut quit_event: Option<TuiEvent> = None;
let mut submit_text: Option<String> = None;
let mut voice_toggle: Option<bool> = None;
{
let mut state = self.state.lock().unwrap();
let items = popup_items(&state.input);
let has_popup = !items.is_empty();
match (key.code, key.modifiers) {
(KeyCode::Char('c'), KeyModifiers::CONTROL)
| (KeyCode::Char('q'), KeyModifiers::CONTROL) => {
if state.quit_confirm {
quit_event = Some(TuiEvent::QuitWithReport);
} else {
state.quit_confirm = true;
state.push_exec(ExecBlock::SystemMsg("Press Ctrl+C again to exit session".to_string()));
}
}
(KeyCode::Char(' '), KeyModifiers::CONTROL) => {
state.is_recording = !state.is_recording;
voice_toggle = Some(state.is_recording);
}
(KeyCode::Char('v'), KeyModifiers::CONTROL) => {
if let Ok(mut board) = arboard::Clipboard::new() {
if let Ok(text) = board.get_text() {
let line_count = text.lines().count();
if line_count > 1 || text.chars().count() > 2000 {
state.input = text;
state.cursor = 0;
state.paste_line_count = Some(line_count);
} else {
for ch in text.chars() {
if ch == '\n' || ch == '\r' {
state.input_insert(' ');
} else {
state.input_insert(ch);
}
}
}
}
}
}
(KeyCode::Esc, _) => {
if state.working {
cancel_flag.store(true, Ordering::Relaxed);
} else if state.help_open {
state.help_open = false;
state.help_scroll = 0;
} else if state.paste_line_count.is_some() {
state.input.clear();
state.cursor = 0;
state.paste_line_count = None;
} else if has_popup {
state.input.clear();
state.cursor = 0;
state.popup_selected = 0;
} else {
state.scroll = 0;
}
}
(KeyCode::Up, _) | (KeyCode::PageUp, _) if state.help_open => {
state.help_scroll = state.help_scroll.saturating_sub(3);
}
(KeyCode::Down, _) | (KeyCode::PageDown, _) if state.help_open => {
state.help_scroll = state.help_scroll.saturating_add(3);
}
(KeyCode::Up, KeyModifiers::NONE)
| (KeyCode::Left, KeyModifiers::NONE)
if has_popup =>
{
let mut idx = state.popup_selected.saturating_sub(1);
while idx > 0 && items.get(idx).map(|i| i.is_header).unwrap_or(false) {
idx = idx.saturating_sub(1);
}
if !items.get(idx).map(|i| i.is_header).unwrap_or(true) {
state.popup_selected = idx;
}
}
(KeyCode::Down, KeyModifiers::NONE)
| (KeyCode::Right, KeyModifiers::NONE)
if has_popup =>
{
let max = items.len().saturating_sub(1);
let mut idx = (state.popup_selected + 1).min(max);
while idx < max && items.get(idx).map(|i| i.is_header).unwrap_or(false) {
idx = (idx + 1).min(max);
}
if !items.get(idx).map(|i| i.is_header).unwrap_or(true) {
state.popup_selected = idx;
}
}
(KeyCode::Up, KeyModifiers::NONE) => {
state.history_prev();
}
(KeyCode::Down, KeyModifiers::NONE) => {
state.history_next();
}
(KeyCode::PageUp, _) | (KeyCode::Up, KeyModifiers::SHIFT) => {
state.scroll = state.scroll.saturating_add(10);
}
(KeyCode::PageDown, _) | (KeyCode::Down, KeyModifiers::SHIFT) => {
state.scroll = state.scroll.saturating_sub(10);
}
(KeyCode::Tab, _) if has_popup => {
let sel = state.popup_selected.min(items.len().saturating_sub(1));
if !items[sel].is_header {
let complete = items[sel].complete.clone();
let already_has_space = complete.ends_with(' ');
state.input = if already_has_space {
complete
} else {
format!("{complete} ")
};
state.cursor = state.input.chars().count();
let new_items = popup_items(&state.input);
state.popup_selected = new_items.iter()
.position(|i| !i.is_header)
.unwrap_or(0);
}
}
(KeyCode::Enter, KeyModifiers::NONE) if has_popup => {
let sel = state.popup_selected.min(items.len().saturating_sub(1));
if !items[sel].is_header {
let complete = items[sel].complete.clone();
if is_drilldown(&complete) {
state.input = format!("{complete} ");
state.cursor = state.input.chars().count();
let new_items = popup_items(&state.input);
state.popup_selected = new_items.iter()
.position(|i| !i.is_header)
.unwrap_or(0);
} else {
state.input = complete;
state.cursor = state.input.chars().count();
state.popup_selected = 0;
let text = state.input_take();
if text.trim() == "/treemap" {
let cwd = std::path::PathBuf::from(&state.cwd);
let map = generate_repo_map(&cwd, 2);
state.push_exec(ExecBlock::SystemMsg(format!("[TREEMAP]\n{}", map)));
} else {
submit_text = Some(text);
}
}
}
}
(KeyCode::Enter, KeyModifiers::NONE) => {
let text = state.input_take();
if !text.trim().is_empty() {
if text.trim() == "/treemap" {
let cwd = std::path::PathBuf::from(&state.cwd);
let map = generate_repo_map(&cwd, 2);
state.push_exec(ExecBlock::SystemMsg(format!("[TREEMAP]\n{}", map)));
} else {
submit_text = Some(text);
}
}
}
(KeyCode::Char(c), m)
if m == KeyModifiers::NONE || m == KeyModifiers::SHIFT =>
{
state.quit_confirm = false;
state.history_idx = None; state.input_insert(c);
let new_items = popup_items(&state.input);
state.popup_selected = new_items.iter()
.position(|i| !i.is_header)
.unwrap_or(0);
}
(KeyCode::Backspace, _) => {
state.quit_confirm = false;
state.input_backspace();
let new_items = popup_items(&state.input);
state.popup_selected = new_items.iter()
.position(|i| !i.is_header)
.unwrap_or(0);
}
(KeyCode::Delete, _) => {
state.quit_confirm = false;
state.input_delete();
}
(KeyCode::Char('a'), KeyModifiers::CONTROL) => {
state.quit_confirm = false;
state.cursor = 0;
}
(KeyCode::Char('e'), KeyModifiers::CONTROL) => {
state.quit_confirm = false;
state.cursor = state.input.chars().count();
}
(KeyCode::Char('k'), KeyModifiers::CONTROL) => {
state.quit_confirm = false;
let pos = state.input.char_indices()
.nth(state.cursor)
.map(|(i, _)| i)
.unwrap_or(state.input.len());
state.input.truncate(pos);
}
(KeyCode::Char('u'), KeyModifiers::CONTROL) => {
state.quit_confirm = false;
let pos = state.input.char_indices()
.nth(state.cursor)
.map(|(i, _)| i)
.unwrap_or(state.input.len());
state.input.drain(..pos);
state.cursor = 0;
}
(KeyCode::Char('w'), KeyModifiers::CONTROL) => {
state.quit_confirm = false;
let new_cur = word_left(&state.input, state.cursor);
let start = state.input.char_indices()
.nth(new_cur).map(|(i, _)| i).unwrap_or(0);
let end = state.input.char_indices()
.nth(state.cursor).map(|(i, _)| i)
.unwrap_or(state.input.len());
state.input.drain(start..end);
state.cursor = new_cur;
}
(KeyCode::Char('l'), KeyModifiers::CONTROL) => {
state.quit_confirm = false;
state.scroll = 0;
}
(KeyCode::Left, KeyModifiers::CONTROL) => {
state.quit_confirm = false;
state.cursor = word_left(&state.input, state.cursor);
}
(KeyCode::Right, KeyModifiers::CONTROL) => {
state.quit_confirm = false;
state.cursor = word_right(&state.input, state.cursor);
}
(KeyCode::Left, _) => {
state.quit_confirm = false;
if state.cursor > 0 { state.cursor -= 1; }
}
(KeyCode::Right, _) => {
state.quit_confirm = false;
if state.cursor < state.input.chars().count() {
state.cursor += 1;
}
}
(KeyCode::Home, _) => {
state.quit_confirm = false;
state.cursor = 0;
}
(KeyCode::End, _) => {
state.quit_confirm = false;
state.cursor = state.input.chars().count();
}
_ => {}
}
}
if let Some(ev) = quit_event {
let _ = self.event_tx.send(ev);
}
if let Some(text) = submit_text {
let trimmed = text.trim();
if trimmed == "/help" || trimmed == "/?" {
self.state.lock().unwrap().help_open = true;
} else {
self.state.lock().unwrap().history_push(&text);
let _ = self.submit_tx.send(text);
}
}
match voice_toggle {
Some(true) => {
let _ = std::fs::remove_file("/tmp/albert-voice.wav");
match std::process::Command::new("arecord")
.args(["-q", "-r", "16000", "-c", "1", "-f", "S16_LE",
"/tmp/albert-voice.wav"])
.spawn()
{
Ok(child) => {
*self.voice_process.lock().unwrap() = Some(child);
}
Err(_) => {
let _ = self.event_tx.send(TuiEvent::VoiceError(
"voice: arecord not found (install alsa-utils)".to_string(),
));
self.state.lock().unwrap().is_recording = false;
}
}
}
Some(false) => {
if let Some(mut child) = self.voice_process.lock().unwrap().take() {
let _ = child.kill();
let _ = child.wait();
}
let tx = self.event_tx.clone();
tokio::spawn(async move {
const WAV: &str = "/tmp/albert-voice.wav";
let size = std::fs::metadata(WAV).map(|m| m.len()).unwrap_or(0);
if size > 44 {
match transcribe(WAV).await {
Ok(text) => { let _ = tx.send(TuiEvent::VoiceText(text)); }
Err(e) => { let _ = tx.send(TuiEvent::VoiceError(e)); }
}
} else {
let _ = tx.send(TuiEvent::VoiceError(
"voice: no audio captured".to_string(),
));
}
});
}
None => {}
}
}
Some(TuiEvent::AgentEvent(ev)) => {
let mut state = self.state.lock().unwrap();
match ev {
AssistantEvent::TextDelta(delta) => {
if delta.trim().is_empty() && !delta.contains('\n') {
} else {
state.typewriter_buffer.push_str(&delta);
}
}
AssistantEvent::ToolUse { name, input, .. } => {
let preview = tool_input_preview(&input);
state.push_exec(ExecBlock::ToolUse {
name,
args: preview,
active: true,
});
}
AssistantEvent::TaskStarted { id, label } => {
let mut found = false;
if let Some(ExecBlock::Plan { tasks, frozen: false }) = state.exec_log.back_mut() {
if let Some(task) = tasks.iter_mut().find(|t| t.id == id) {
task.status = TaskStatus::Running;
found = true;
} else {
tasks.push(Task { id: id.clone(), label: label.clone(), status: TaskStatus::Running });
found = true;
}
}
if !found {
state.push_exec(ExecBlock::Plan {
tasks: vec![Task { id, label, status: TaskStatus::Running }],
frozen: false,
});
}
}
AssistantEvent::TaskCompleted { id, success } => {
if let Some(ExecBlock::Plan { tasks, frozen: false }) = state.exec_log.back_mut() {
if let Some(task) = tasks.iter_mut().find(|t| t.id == id) {
task.status = if success { TaskStatus::Done } else { TaskStatus::Failed };
}
}
}
AssistantEvent::Usage(usage) => {
state.tokens_in = state.tokens_in.max(usage.input_tokens);
state.tokens_out += usage.output_tokens;
}
AssistantEvent::MessageStop => {
state.current_assistant_block_index = None;
if let Some(ExecBlock::Plan { tasks, frozen }) = state.exec_log.back_mut() {
*frozen = true;
for task in tasks.iter_mut() {
if task.status == TaskStatus::Running {
task.status = TaskStatus::Done;
}
}
}
}
}
}
Some(TuiEvent::Suspend { ack }) => {
self.key_paused.store(true, Ordering::Relaxed);
io::stdout().execute(DisableMouseCapture).ok();
io::stdout().execute(DisableBracketedPaste).ok();
disable_raw_mode().ok();
io::stdout().execute(LeaveAlternateScreen).ok();
io::stdout().flush().ok();
let _ = ack.send(());
loop {
match self.event_rx.recv().await {
Some(TuiEvent::Resume) => break,
Some(TuiEvent::Quit) | Some(TuiEvent::QuitWithReport) | None => {
self.key_paused.store(false, Ordering::Relaxed);
return Ok::<(), Box<dyn std::error::Error>>(());
}
_ => {}
}
}
enable_raw_mode().ok();
io::stdout().execute(EnableBracketedPaste).ok();
io::stdout().execute(EnableMouseCapture).ok();
io::stdout().execute(EnterAlternateScreen).ok();
terminal.clear().ok();
self.key_paused.store(false, Ordering::Relaxed);
}
Some(TuiEvent::VoiceText(text)) => {
let mut state = self.state.lock().unwrap();
for ch in text.trim().chars() {
state.input_insert(ch);
}
}
Some(TuiEvent::VoiceError(msg)) => {
let mut state = self.state.lock().unwrap();
state.push_exec(ExecBlock::SystemMsg(msg));
}
Some(TuiEvent::PasteText(text)) => {
let mut state = self.state.lock().unwrap();
let line_count = text.lines().count();
if line_count > 1 || text.chars().count() > 2000 {
state.input = text;
state.cursor = 0; state.paste_line_count = Some(line_count);
} else {
state.paste_line_count = None;
for ch in text.chars() {
if ch == '\n' || ch == '\r' {
state.input_insert(' ');
} else {
state.input_insert(ch);
}
}
}
}
Some(TuiEvent::ScrollUp) => {
let mut state = self.state.lock().unwrap();
state.scroll = state.scroll.saturating_add(5);
}
Some(TuiEvent::ScrollDown) => {
let mut state = self.state.lock().unwrap();
state.scroll = state.scroll.saturating_sub(5);
}
Some(TuiEvent::Tick) | Some(TuiEvent::Resume) => {
let mut state = self.state.lock().unwrap();
if !state.typewriter_buffer.is_empty() {
let buf_len = state.typewriter_buffer.chars().count();
let n = if buf_len > 50 { 25 } else if buf_len > 20 { 15 } else { 5 };
let n = n.min(buf_len);
let chars: String = state.typewriter_buffer.chars().take(n).collect();
state.typewriter_buffer = state.typewriter_buffer.chars().skip(n).collect();
let mut appended = false;
if let Some(idx) = state.current_assistant_block_index {
if let Some(ExecBlock::AgentText(ref mut s, _)) = state.exec_log.get_mut(idx) {
s.push_str(&chars);
appended = true;
}
}
if !appended {
state.push_exec(ExecBlock::AgentText(chars, false));
}
}
}
Some(TuiEvent::Quit) => {
break;
}
Some(TuiEvent::QuitWithReport) => {
{
let state = self.state.lock().unwrap();
terminal.draw(|f| render_report_card(f, &state))?;
}
while self.event_rx.try_recv().is_ok() {}
loop {
match self.event_rx.recv().await {
Some(TuiEvent::Key(_)) | Some(TuiEvent::Quit) | None => break,
_ => {}
}
}
break;
}
None => break,
}
}
Ok::<(), Box<dyn std::error::Error>>(())
})?;
io::stdout().execute(DisableMouseCapture).ok();
io::stdout().execute(DisableBracketedPaste).ok();
disable_raw_mode().ok();
io::stdout().execute(LeaveAlternateScreen).ok();
Ok(())
}
}