use super::modal_review::{draw_diff_review, ActiveReview};
use crate::agent::conversation::{AttachedDocument, AttachedImage, UserTurn};
use crate::agent::inference::{McpRuntimeState, OperatorCheckpointState, ProviderRuntimeState};
use crate::agent::specular::SpecularEvent;
use crate::agent::swarm::{ReviewResponse, SwarmMessage};
use crate::agent::utils::{strip_ansi, CRLF_REGEX};
use crate::ui::gpu_monitor::GpuState;
use crossterm::event::{self, Event, EventStream, KeyCode};
use futures::StreamExt;
use ratatui::{
backend::Backend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style, Stylize},
text::{Line, Span},
widgets::{
Block, Borders, Clear, Gauge, List, ListItem, Paragraph, Scrollbar, ScrollbarOrientation,
ScrollbarState, Wrap,
},
Terminal,
};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use tokio::sync::mpsc::Receiver;
use walkdir::WalkDir;
pub struct PendingApproval {
pub display: String,
pub tool_name: String,
pub diff: Option<String>,
pub diff_scroll: u16,
pub responder: tokio::sync::oneshot::Sender<bool>,
}
pub struct RustyStats {
pub debugging: u32,
pub wisdom: u16,
pub patience: f32,
pub chaos: u8,
pub snark: u8,
}
use std::collections::HashMap;
#[derive(Clone)]
pub struct ContextFile {
pub path: String,
pub size: u64,
pub status: String,
}
fn default_active_context() -> Vec<ContextFile> {
let root = crate::tools::file_ops::workspace_root();
let entrypoint_candidates = [
"src/main.rs",
"src/lib.rs",
"src/index.ts",
"src/index.js",
"src/main.ts",
"src/main.js",
"src/main.py",
"main.py",
"main.go",
"index.js",
"index.ts",
"app.py",
"app.rs",
];
let manifest_candidates = [
"Cargo.toml",
"package.json",
"go.mod",
"pyproject.toml",
"setup.py",
"composer.json",
"pom.xml",
"build.gradle",
];
let mut files = Vec::new();
for path in &entrypoint_candidates {
let joined = root.join(path);
if joined.exists() {
let size = std::fs::metadata(&joined).map(|m| m.len()).unwrap_or(0);
files.push(ContextFile {
path: path.to_string(),
size,
status: "Active".to_string(),
});
break;
}
}
for path in &manifest_candidates {
let joined = root.join(path);
if joined.exists() {
let size = std::fs::metadata(&joined).map(|m| m.len()).unwrap_or(0);
files.push(ContextFile {
path: path.to_string(),
size,
status: "Active".to_string(),
});
break;
}
}
let src = root.join("src");
if src.exists() {
let size = std::fs::metadata(&src).map(|m| m.len()).unwrap_or(0);
files.push(ContextFile {
path: "./src".to_string(),
size,
status: "Watching".to_string(),
});
}
files
}
pub struct App {
pub messages: Vec<Line<'static>>,
pub messages_raw: Vec<(String, String)>, pub specular_logs: Vec<String>,
pub brief_mode: bool,
pub tick_count: u64,
pub stats: RustyStats,
pub yolo_mode: bool,
pub awaiting_approval: Option<PendingApproval>,
pub active_workers: HashMap<String, u8>,
pub worker_labels: HashMap<String, String>,
pub active_review: Option<ActiveReview>,
pub input: String,
pub input_history: Vec<String>,
pub history_idx: Option<usize>,
pub thinking: bool,
pub agent_running: bool,
pub current_thought: String,
pub professional: bool,
pub last_reasoning: String,
pub active_context: Vec<ContextFile>,
pub manual_scroll_offset: Option<u16>,
pub user_input_tx: tokio::sync::mpsc::Sender<UserTurn>,
pub specular_scroll: u16,
pub specular_auto_scroll: bool,
pub gpu_state: Arc<GpuState>,
pub git_state: Arc<crate::agent::git_monitor::GitState>,
pub last_input_time: std::time::Instant,
pub cancel_token: Arc<std::sync::atomic::AtomicBool>,
pub total_tokens: usize,
pub current_session_cost: f64,
pub model_id: String,
pub context_length: usize,
prompt_pressure_percent: u8,
prompt_estimated_input_tokens: usize,
prompt_reserved_output_tokens: usize,
prompt_estimated_total_tokens: usize,
compaction_percent: u8,
compaction_estimated_tokens: usize,
compaction_threshold_tokens: usize,
compaction_warned_level: u8,
last_runtime_profile_time: Instant,
vein_file_count: usize,
vein_embedded_count: usize,
vein_docs_only: bool,
provider_state: ProviderRuntimeState,
last_provider_summary: String,
mcp_state: McpRuntimeState,
last_mcp_summary: String,
last_operator_checkpoint_state: OperatorCheckpointState,
last_operator_checkpoint_summary: String,
last_recovery_recipe_summary: String,
pub think_mode: Option<bool>,
pub workflow_mode: String,
pub autocomplete_suggestions: Vec<String>,
pub selected_suggestion: usize,
pub show_autocomplete: bool,
pub autocomplete_filter: String,
pub current_objective: String,
pub voice_manager: Arc<crate::ui::voice::VoiceManager>,
pub voice_loading: bool,
pub voice_loading_progress: f64,
pub hardware_guard_enabled: bool,
pub session_start: std::time::SystemTime,
pub soul_name: String,
pub attached_context: Option<(String, String)>,
pub attached_image: Option<AttachedImage>,
hovered_input_action: Option<InputAction>,
}
impl App {
pub fn reset_active_context(&mut self) {
self.active_context = default_active_context();
}
pub fn record_error(&mut self) {
self.stats.debugging = self.stats.debugging.saturating_add(1);
}
pub fn reset_error_count(&mut self) {
self.stats.debugging = 0;
}
pub fn reset_runtime_status_memory(&mut self) {
self.last_provider_summary.clear();
self.last_mcp_summary.clear();
self.last_operator_checkpoint_summary.clear();
self.last_operator_checkpoint_state = OperatorCheckpointState::Idle;
self.last_recovery_recipe_summary.clear();
}
pub fn clear_pending_attachments(&mut self) {
self.attached_context = None;
self.attached_image = None;
}
pub fn push_message(&mut self, speaker: &str, content: &str) {
let filtered = filter_tui_noise(content);
if filtered.is_empty() && !content.is_empty() {
return;
}
self.messages_raw.push((speaker.to_string(), filtered));
if self.messages_raw.len() > 100 {
self.messages_raw.remove(0);
}
self.rebuild_formatted_messages();
if self.messages.len() > 250 {
let to_drain = self.messages.len() - 250;
self.messages.drain(0..to_drain);
}
}
pub fn update_last_message(&mut self, token: &str) {
if let Some(last_raw) = self.messages_raw.last_mut() {
if last_raw.0 == "Hematite" {
last_raw.1.push_str(token);
if token.contains(' ')
|| token.contains('\n')
|| token.contains('.')
|| token.len() > 5
{
self.rebuild_formatted_messages();
}
}
}
}
fn rebuild_formatted_messages(&mut self) {
self.messages.clear();
let total = self.messages_raw.len();
for (i, (speaker, content)) in self.messages_raw.iter().enumerate() {
let is_last = i == total - 1;
let formatted = self.format_message(speaker, content, is_last);
self.messages.extend(formatted);
if !is_last {
self.messages.push(Line::raw(""));
}
}
}
fn format_message(&self, speaker: &str, content: &str, _is_last: bool) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let rust = Color::Rgb(180, 90, 50);
let style = match speaker {
"You" => Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
"Hematite" => Style::default().fg(rust).add_modifier(Modifier::BOLD),
"Tool" => Style::default().fg(Color::Cyan),
_ => Style::default().fg(Color::DarkGray),
};
let cleaned = crate::agent::inference::strip_think_blocks(content)
.trim()
.to_string();
let cleaned = strip_ghost_prefix(&cleaned);
let mut is_first = true;
for raw_line in cleaned.lines() {
if !is_first && raw_line.trim().is_empty() {
lines.push(Line::raw(""));
continue;
}
let label = if is_first {
format!("{}: ", speaker)
} else {
" ".to_string()
};
if speaker == "System" && (raw_line.contains(" +") || raw_line.contains(" -")) {
let mut spans: Vec<Span<'static>> =
vec![Span::raw(" "), Span::styled(label, style)];
for token in raw_line.split_whitespace() {
let is_add = token.starts_with('+')
&& token.len() > 1
&& token[1..].chars().all(|c| c.is_ascii_digit());
let is_rem = token.starts_with('-')
&& token.len() > 1
&& token[1..].chars().all(|c| c.is_ascii_digit());
let is_path =
(token.contains('/') || token.contains('\\') || token.contains('.'))
&& !token.starts_with('+')
&& !token.starts_with('-')
&& !token.ends_with(':');
let span = if is_add {
Span::styled(
format!("{} ", token),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
)
} else if is_rem {
Span::styled(
format!("{} ", token),
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)
} else if is_path {
Span::styled(
format!("{} ", token),
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)
} else {
Span::raw(format!("{} ", token))
};
spans.push(span);
}
lines.push(Line::from(spans));
is_first = false;
continue;
}
if speaker == "Tool"
&& (raw_line.starts_with("-")
|| raw_line.starts_with("+")
|| raw_line.starts_with("@@"))
{
let line_style = if raw_line.starts_with("-") {
Style::default().fg(Color::Red)
} else if raw_line.starts_with("+") {
Style::default().fg(Color::Green)
} else {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::DIM)
};
lines.push(Line::from(vec![
Span::raw(" "), Span::styled(raw_line.to_string(), line_style),
]));
} else {
let mut spans = vec![Span::raw(" "), Span::styled(label, style)];
if speaker == "Hematite" {
if raw_line.trim_start().starts_with("```") {
spans.push(Span::styled(
raw_line.to_string(),
Style::default().fg(Color::DarkGray),
));
} else {
spans.extend(inline_markdown_core(raw_line));
}
} else {
spans.push(Span::raw(raw_line.to_string()));
}
lines.push(Line::from(spans));
}
is_first = false;
}
lines
}
pub fn update_autocomplete(&mut self) {
let root = crate::tools::file_ops::workspace_root();
let query = if let Some(pos) = self.input.rfind('@') {
&self.input[pos + 1..]
} else {
""
}
.to_lowercase();
self.autocomplete_filter = query.clone();
let mut matches = Vec::new();
let mut total_found = 0;
for entry in WalkDir::new(&root)
.into_iter()
.filter_entry(|e| {
let name = e.file_name().to_string_lossy();
!name.starts_with('.') && name != "target" && name != "node_modules"
})
.flatten()
{
if entry.file_type().is_file() {
let path = entry.path().strip_prefix(&root).unwrap_or(entry.path());
let path_str = path.to_string_lossy().to_string();
if path_str.to_lowercase().contains(&query) {
total_found += 1;
if matches.len() < 15 {
matches.push(path_str);
}
}
}
if total_found > 100 {
break;
} }
matches.sort_by(|a, b| {
let a_ext = a.split('.').last().unwrap_or("");
let b_ext = b.split('.').last().unwrap_or("");
let a_is_src = a_ext == "rs" || a_ext == "md";
let b_is_src = b_ext == "rs" || b_ext == "md";
b_is_src.cmp(&a_is_src)
});
self.autocomplete_suggestions = matches;
self.selected_suggestion = self
.selected_suggestion
.min(self.autocomplete_suggestions.len().saturating_sub(1));
}
pub fn push_context_file(&mut self, path: String, status: String) {
self.active_context.retain(|f| f.path != path);
let root = crate::tools::file_ops::workspace_root();
let full_path = root.join(&path);
let size = std::fs::metadata(full_path).map(|m| m.len()).unwrap_or(0);
self.active_context.push(ContextFile { path, size, status });
if self.active_context.len() > 10 {
self.active_context.remove(0);
}
}
pub fn update_objective(&mut self) {
let root = crate::tools::file_ops::workspace_root();
let plan_path = root.join(".hematite").join("PLAN.md");
if plan_path.exists() {
if let Some(plan) = crate::tools::plan::load_plan_handoff() {
if plan.has_signal() && !plan.goal.trim().is_empty() {
self.current_objective = plan.summary_line();
return;
}
}
}
let path = root.join(".hematite").join("TASK.md");
if let Ok(content) = std::fs::read_to_string(path) {
for line in content.lines() {
let trimmed = line.trim();
if (trimmed.starts_with("- [ ]") || trimmed.starts_with("- [/]"))
&& trimmed.len() > 6
{
self.current_objective = trimmed[6..].trim().to_string();
return;
}
}
}
self.current_objective = "Idle".into();
}
pub fn copy_specular_to_clipboard(&self) {
let mut out = String::from("=== SPECULAR LOG ===\n\n");
if !self.last_reasoning.is_empty() {
out.push_str("--- Last Reasoning Block ---\n");
out.push_str(&self.last_reasoning);
out.push_str("\n\n");
}
if !self.current_thought.is_empty() {
out.push_str("--- In-Progress Reasoning ---\n");
out.push_str(&self.current_thought);
out.push_str("\n\n");
}
if !self.specular_logs.is_empty() {
out.push_str("--- Specular Events ---\n");
for entry in &self.specular_logs {
out.push_str(entry);
out.push('\n');
}
out.push('\n');
}
out.push_str(&format!(
"Tokens: {} | Cost: ${:.4}\n",
self.total_tokens, self.current_session_cost
));
let mut child = std::process::Command::new("clip.exe")
.stdin(std::process::Stdio::piped())
.spawn()
.expect("Failed to spawn clip.exe");
if let Some(mut stdin) = child.stdin.take() {
use std::io::Write;
let _ = stdin.write_all(out.as_bytes());
}
let _ = child.wait();
}
pub fn write_session_report(&self) {
let report_dir = std::path::PathBuf::from(".hematite/reports");
if std::fs::create_dir_all(&report_dir).is_err() {
return;
}
let start_secs = self
.session_start
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let secs_in_day = start_secs % 86400;
let days = start_secs / 86400;
let years_approx = (days * 4 + 2) / 1461;
let year = 1970 + years_approx;
let day_of_year = days - (years_approx * 365 + years_approx / 4);
let month = (day_of_year / 30 + 1).min(12);
let day = (day_of_year % 30 + 1).min(31);
let hh = secs_in_day / 3600;
let mm = (secs_in_day % 3600) / 60;
let ss = secs_in_day % 60;
let timestamp = format!(
"{:04}-{:02}-{:02}_{:02}-{:02}-{:02}",
year, month, day, hh, mm, ss
);
let duration_secs = std::time::SystemTime::now()
.duration_since(self.session_start)
.unwrap_or_default()
.as_secs();
let report_path = report_dir.join(format!("session_{}.json", timestamp));
let turns: Vec<serde_json::Value> = self
.messages_raw
.iter()
.map(|(speaker, text)| serde_json::json!({ "speaker": speaker, "text": text }))
.collect();
let report = serde_json::json!({
"session_start": timestamp,
"duration_secs": duration_secs,
"model": self.model_id,
"context_length": self.context_length,
"total_tokens": self.total_tokens,
"estimated_cost_usd": self.current_session_cost,
"turn_count": turns.len(),
"transcript": turns,
});
if let Ok(json) = serde_json::to_string_pretty(&report) {
let _ = std::fs::write(&report_path, json);
}
}
pub fn copy_transcript_to_clipboard(&self) {
let mut history = self
.messages_raw
.iter()
.map(|m| format!("[{}] {}\n", m.0, m.1))
.collect::<String>();
history.push_str("\nSession Stats\n");
history.push_str(&format!("Tokens: {}\n", self.total_tokens));
history.push_str(&format!("Cost: ${:.4}\n", self.current_session_cost));
copy_text_to_clipboard(&history);
}
pub fn copy_clean_transcript_to_clipboard(&self) {
let mut history = self
.messages_raw
.iter()
.filter(|(speaker, content)| !should_skip_transcript_copy_entry(speaker, content))
.map(|m| format!("[{}] {}\n", m.0, m.1))
.collect::<String>();
history.push_str("\nSession Stats\n");
history.push_str(&format!("Tokens: {}\n", self.total_tokens));
history.push_str(&format!("Cost: ${:.4}\n", self.current_session_cost));
copy_text_to_clipboard(&history);
}
pub fn copy_last_reply_to_clipboard(&self) -> bool {
if let Some((speaker, content)) = self
.messages_raw
.iter()
.rev()
.find(|(speaker, content)| is_copyable_hematite_reply(speaker, content))
{
let cleaned = cleaned_copyable_reply_text(content);
let payload = format!("[{}] {}", speaker, cleaned);
copy_text_to_clipboard(&payload);
true
} else {
false
}
}
}
fn copy_text_to_clipboard(text: &str) {
if copy_text_to_clipboard_powershell(text) {
return;
}
let mut child = std::process::Command::new("clip.exe")
.stdin(std::process::Stdio::piped())
.spawn()
.expect("Failed to spawn clip.exe");
if let Some(mut stdin) = child.stdin.take() {
use std::io::Write;
let _ = stdin.write_all(text.as_bytes());
}
let _ = child.wait();
}
fn copy_text_to_clipboard_powershell(text: &str) -> bool {
let temp_path = std::env::temp_dir().join(format!(
"hematite-clipboard-{}-{}.txt",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or_default()
));
if std::fs::write(&temp_path, text.as_bytes()).is_err() {
return false;
}
let escaped_path = temp_path.display().to_string().replace('\'', "''");
let script = format!(
"$t = Get-Content -LiteralPath '{}' -Raw -Encoding UTF8; Set-Clipboard -Value $t",
escaped_path
);
let status = std::process::Command::new("powershell.exe")
.args(["-NoProfile", "-NonInteractive", "-Command", &script])
.status();
let _ = std::fs::remove_file(&temp_path);
matches!(status, Ok(code) if code.success())
}
fn should_skip_transcript_copy_entry(speaker: &str, content: &str) -> bool {
if speaker != "System" {
return false;
}
content.starts_with("Hematite Commands:\n")
|| content.starts_with("Document note: `/attach`")
|| content == "Chat transcript copied to clipboard."
|| content == "SPECULAR log copied to clipboard (reasoning + events)."
|| content == "Cancellation requested. Logs copied to clipboard."
}
fn is_copyable_hematite_reply(speaker: &str, content: &str) -> bool {
if speaker != "Hematite" {
return false;
}
let trimmed = content.trim();
if trimmed.is_empty() {
return false;
}
if trimmed == "Initialising Engine & Hardware..."
|| trimmed == "Swarm engaged."
|| trimmed.starts_with("Hematite v")
|| trimmed.starts_with("Swarm analyzing: '")
|| trimmed.ends_with("Standing by for review...")
|| trimmed.ends_with("conflict - review required.")
|| trimmed.ends_with("conflict — review required.")
{
return false;
}
true
}
fn cleaned_copyable_reply_text(content: &str) -> String {
let cleaned = content
.replace("<thought>", "")
.replace("</thought>", "")
.replace("<think>", "")
.replace("</think>", "");
strip_ghost_prefix(cleaned.trim()).trim().to_string()
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum InputAction {
Stop,
PickDocument,
PickImage,
Detach,
New,
Forget,
Help,
}
struct InputActionVisual {
action: InputAction,
label: String,
style: Style,
}
#[derive(Clone, Copy)]
enum AttachmentPickerKind {
Document,
Image,
}
fn attach_document_from_path(app: &mut App, file_path: &str) {
let p = std::path::Path::new(file_path);
match crate::memory::vein::extract_document_text(p) {
Ok(text) => {
let name = p
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(file_path)
.to_string();
let preview_len = text.len().min(200);
let estimated_tokens = text.len() / 4;
let ctx = app.context_length.max(1);
let budget_pct = (estimated_tokens * 100) / ctx;
let budget_note = if budget_pct >= 75 {
format!(
"\nWarning: this document is ~{} tokens (~{}% of your {}k context). \
Very little room left for conversation. Consider /attach on a shorter excerpt.",
estimated_tokens, budget_pct, ctx / 1000
)
} else if budget_pct >= 40 {
format!(
"\nNote: this document is ~{} tokens (~{}% of your {}k context).",
estimated_tokens,
budget_pct,
ctx / 1000
)
} else {
String::new()
};
app.push_message(
"System",
&format!(
"Attached document: {} ({} chars) for the next message.\nPreview: {}...{}",
name,
text.len(),
&text[..preview_len],
budget_note,
),
);
app.attached_context = Some((name, text));
}
Err(e) => {
app.push_message("System", &format!("Attach failed: {}", e));
}
}
}
fn attach_image_from_path(app: &mut App, file_path: &str) {
let p = std::path::Path::new(file_path);
match crate::tools::vision::encode_image_as_data_url(p) {
Ok(_) => {
let name = p
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(file_path)
.to_string();
app.push_message(
"System",
&format!("Attached image: {} for the next message.", name),
);
app.attached_image = Some(AttachedImage {
name,
path: file_path.to_string(),
});
}
Err(e) => {
app.push_message("System", &format!("Image attach failed: {}", e));
}
}
}
fn is_document_path(path: &std::path::Path) -> bool {
matches!(
path.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase()
.as_str(),
"pdf" | "md" | "markdown" | "txt" | "rst"
)
}
fn is_image_path(path: &std::path::Path) -> bool {
matches!(
path.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase()
.as_str(),
"png" | "jpg" | "jpeg" | "gif" | "webp"
)
}
fn extract_pasted_path_candidates(content: &str) -> Vec<String> {
let mut out = Vec::new();
let trimmed = content.trim();
if trimmed.is_empty() {
return out;
}
let mut in_quotes = false;
let mut current = String::new();
for ch in trimmed.chars() {
if ch == '"' {
if in_quotes && !current.trim().is_empty() {
out.push(current.trim().to_string());
current.clear();
}
in_quotes = !in_quotes;
continue;
}
if in_quotes {
current.push(ch);
}
}
if !out.is_empty() {
return out;
}
for line in trimmed.lines() {
let candidate = line.trim().trim_matches('"').trim();
if !candidate.is_empty() {
out.push(candidate.to_string());
}
}
if out.is_empty() {
out.push(trimmed.trim_matches('"').to_string());
}
out
}
fn try_attach_from_paste(app: &mut App, content: &str) -> bool {
let mut attached_doc = false;
let mut attached_image = false;
let mut ignored_supported = 0usize;
for raw in extract_pasted_path_candidates(content) {
let path = std::path::Path::new(&raw);
if !path.exists() {
continue;
}
if is_image_path(path) {
if attached_image || app.attached_image.is_some() {
ignored_supported += 1;
} else {
attach_image_from_path(app, &raw);
attached_image = true;
}
} else if is_document_path(path) {
if attached_doc || app.attached_context.is_some() {
ignored_supported += 1;
} else {
attach_document_from_path(app, &raw);
attached_doc = true;
}
}
}
if ignored_supported > 0 {
app.push_message(
"System",
&format!(
"Ignored {} extra dropped file(s). Hematite currently keeps one pending document and one pending image.",
ignored_supported
),
);
}
attached_doc || attached_image
}
fn compute_input_height(total_width: u16, input_len: usize) -> u16 {
let width = total_width.max(1) as usize;
let approx_input_w = (width * 65 / 100).saturating_sub(4).max(1);
let needed_lines = (input_len / approx_input_w) as u16 + 3;
needed_lines.clamp(3, 10)
}
fn input_rect_for_size(size: Rect, input_len: usize) -> Rect {
let input_height = compute_input_height(size.width, input_len);
Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0),
Constraint::Length(input_height),
Constraint::Length(3),
])
.split(size)[1]
}
fn input_title_area(input_rect: Rect) -> Rect {
Rect {
x: input_rect.x.saturating_add(1),
y: input_rect.y,
width: input_rect.width.saturating_sub(2),
height: 1,
}
}
fn build_input_actions(app: &App) -> Vec<InputActionVisual> {
let doc_label = if app.attached_context.is_some() {
"Files*"
} else {
"Files"
};
let image_label = if app.attached_image.is_some() {
"Image*"
} else {
"Image"
};
let detach_style = if app.attached_context.is_some() || app.attached_image.is_some() {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
let mut actions = Vec::new();
if app.agent_running {
actions.push(InputActionVisual {
action: InputAction::Stop,
label: "Stop Esc".to_string(),
style: Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
});
} else {
actions.push(InputActionVisual {
action: InputAction::New,
label: "New".to_string(),
style: Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
});
actions.push(InputActionVisual {
action: InputAction::Forget,
label: "Forget".to_string(),
style: Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
});
}
actions.push(InputActionVisual {
action: InputAction::PickDocument,
label: format!("{} ^O", doc_label),
style: Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
});
actions.push(InputActionVisual {
action: InputAction::PickImage,
label: format!("{} ^I", image_label),
style: Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
});
actions.push(InputActionVisual {
action: InputAction::Detach,
label: "Detach".to_string(),
style: detach_style,
});
actions.push(InputActionVisual {
action: InputAction::Help,
label: "Help".to_string(),
style: Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD),
});
actions
}
fn visible_input_actions(app: &App, max_width: u16) -> Vec<InputActionVisual> {
let mut used = 0u16;
let mut visible = Vec::new();
for action in build_input_actions(app) {
let chip_width = action.label.chars().count() as u16 + 2;
let gap = if visible.is_empty() { 0 } else { 1 };
if used + gap + chip_width > max_width {
break;
}
used += gap + chip_width;
visible.push(action);
}
visible
}
fn input_status_text(app: &App) -> String {
let voice_status = if app.voice_manager.is_enabled() {
"ON"
} else {
"OFF"
};
let approvals_status = if app.yolo_mode { "OFF" } else { "ON" };
let doc_status = if app.attached_context.is_some() {
"DOC"
} else {
"--"
};
let image_status = if app.attached_image.is_some() {
"IMG"
} else {
"--"
};
if app.agent_running {
format!(
"pending:{}:{} | voice:{}",
doc_status, image_status, voice_status
)
} else {
format!(
"pending:{}:{} | voice:{} | appr:{} | Len:{}",
doc_status,
image_status,
voice_status,
approvals_status,
app.input.len()
)
}
}
fn visible_input_actions_for_title(app: &App, title_area: Rect) -> Vec<InputActionVisual> {
let reserved = input_status_text(app).chars().count() as u16 + 3;
let max_width = title_area.width.saturating_sub(reserved);
visible_input_actions(app, max_width)
}
fn input_action_hitboxes(app: &App, title_area: Rect) -> Vec<(InputAction, u16, u16)> {
let mut x = title_area.x;
let mut out = Vec::new();
for action in visible_input_actions_for_title(app, title_area) {
let chip_width = action.label.chars().count() as u16 + 2;
out.push((action.action, x, x + chip_width.saturating_sub(1)));
x = x.saturating_add(chip_width + 1);
}
out
}
fn render_input_title(app: &App, title_area: Rect) -> Line<'static> {
let mut spans = Vec::new();
let actions = visible_input_actions_for_title(app, title_area);
for (idx, action) in actions.into_iter().enumerate() {
if idx > 0 {
spans.push(Span::raw(" "));
}
let style = if app.hovered_input_action == Some(action.action) {
action
.style
.bg(Color::Rgb(85, 48, 26))
.add_modifier(Modifier::REVERSED)
} else {
action.style
};
spans.push(Span::styled(format!("[{}]", action.label), style));
}
let status = input_status_text(app);
if !spans.is_empty() {
spans.push(Span::raw(" | "));
}
spans.push(Span::styled(status, Style::default().fg(Color::DarkGray)));
Line::from(spans)
}
fn reset_visible_session_state(app: &mut App) {
app.messages.clear();
app.messages_raw.clear();
app.last_reasoning.clear();
app.current_thought.clear();
app.specular_logs.clear();
app.reset_error_count();
app.reset_runtime_status_memory();
app.reset_active_context();
app.clear_pending_attachments();
app.current_objective = "Idle".into();
}
fn request_stop(app: &mut App) {
app.voice_manager.stop();
app.cancel_token
.store(true, std::sync::atomic::Ordering::SeqCst);
if app.thinking || app.agent_running {
app.write_session_report();
app.copy_transcript_to_clipboard();
app.push_message(
"System",
"Cancellation requested. Logs copied to clipboard.",
);
}
}
fn show_help_message(app: &mut App) {
app.push_message(
"System",
"Hematite Commands:\n\
/chat - (Mode) Conversation mode - clean chat, no tool noise\n\
/agent - (Mode) Full coding harness + workstation mode - tools, file edits, builds, inspection\n\
/reroll - (Soul) Hatch a new companion mid-session\n\
/auto - (Flow) Let Hematite choose the narrowest effective workflow\n\
/ask [prompt] - (Flow) Read-only analysis mode; optional inline prompt\n\
/code [prompt] - (Flow) Explicit implementation mode; optional inline prompt\n\
/architect [prompt] - (Flow) Plan-first mode; optional inline prompt\n\
/implement-plan - (Flow) Execute the saved architect handoff in /code\n\
/read-only [prompt] - (Flow) Hard read-only mode; optional inline prompt\n\
/teach [prompt] - (Flow) Teacher mode; inspect machine then walk you through any admin task step-by-step\n\
/new - (Reset) Fresh task context; clear chat, pins, and task files\n\
/forget - (Wipe) Hard forget; purge saved memory and Vein index too\n\
/vein-inspect - (Vein) Inspect indexed memory, hot files, and active room bias\n\
/workspace-profile - (Profile) Show the auto-generated workspace profile\n\
/version - (Build) Show the running Hematite version\n\
/about - (Info) Show author, repo, and product info\n\
/vein-reset - (Vein) Wipe the RAG index; rebuilds automatically on next turn\n\
/clear - (UI) Clear dialogue display only\n\
/health - (Diag) Run a synthesized plain-English system health report\n\
/explain <text> - (Help) Paste an error to get a non-technical breakdown\n\
/gemma-native [auto|on|off|status] - (Model) Auto/force/disable Gemma 4 native formatting\n\
/runtime-refresh - (Model) Re-read LM Studio model + CTX now\n\
/undo - (Ghost) Revert last file change\n\
/diff - (Git) Show session changes (--stat)\n\
/lsp - (Logic) Start Language Servers (semantic intelligence)\n\
/swarm <text> - (Swarm) Spawn parallel workers on a directive\n\
/worktree <cmd> - (Isolated) Manage git worktrees (list|add|remove|prune)\n\
/think - (Brain) Enable deep reasoning mode\n\
/no_think - (Speed) Disable reasoning (3-5x faster responses)\n\
/voice - (TTS) List all available voices\n\
/voice N - (TTS) Select voice by number\n\
/read <text> - (TTS) Speak text aloud directly, bypassing the model. ESC to stop.\n\
/explain <text> - (Plain English) Paste any error or output; Hematite explains it in plain English.\n\
/health - (SysAdmin) Run a full system health report (disk, RAM, tools, recent errors).\n\
/attach <path> - (Docs) Attach a PDF/markdown/txt file for next message (PDF best-effort)\n\
/attach-pick - (Docs) Open a file picker and attach a document\n\
/image <path> - (Vision) Attach an image for the next message\n\
/image-pick - (Vision) Open a file picker and attach an image\n\
/detach - (Context) Drop pending document/image attachments\n\
/copy - (Debug) Copy exact session transcript (includes help/system output)\n\
/copy-last - (Debug) Copy the latest Hematite reply only\n\
/copy-clean - (Debug) Copy chat transcript without help/debug boilerplate\n\
/copy2 - (Debug) Copy SPECULAR log to clipboard (reasoning + events)\n\
\nHotkeys:\n\
Ctrl+B - Toggle Brief Mode (minimal output)\n\
Ctrl+P - Toggle Professional Mode (strip personality)\n\
Ctrl+O - Open document picker for next-turn context\n\
Ctrl+I - Open image picker for next-turn vision context\n\
Ctrl+Y - Toggle Approvals Off (bypass safety approvals)\n\
Ctrl+S - Quick Swarm (hardcoded bootstrap)\n\
Ctrl+Z - Undo last edit\n\
Ctrl+Q/C - Quit session\n\
ESC - Silence current playback\n\
\nStatus Legend:\n\
LM - LM Studio runtime health (`LIVE`, `RECV`, `WARN`, `CEIL`, `STALE`, `BOOT`)\n\
VN - Vein RAG status (`SEM`=semantic active, `FTS`=BM25 only, `--`=not indexed)\n\
BUD - Total prompt-budget pressure against the live context window\n\
CMP - History compaction pressure against Hematite's adaptive threshold\n\
ERR - Session error count (runtime, tool, or SPECULAR failures)\n\
CTX - Live context window currently reported by LM Studio\n\
VOICE - Local speech output state\n\
\nDocument note: `/attach` supports PDF/markdown/txt, but PDF parsing is best-effort by design so Hematite can stay a lightweight single-binary local coding harness and workstation assistant. If a PDF fails, export it to text/markdown or attach page images instead.\n\
",
);
}
#[allow(dead_code)]
fn show_help_message_legacy(app: &mut App) {
app.push_message("System",
"Hematite Commands:\n\
/chat — (Mode) Conversation mode — clean chat, no tool noise\n\
/agent — (Mode) Full coding harness + workstation mode — tools, file edits, builds, inspection\n\
/reroll — (Soul) Hatch a new companion mid-session\n\
/auto — (Flow) Let Hematite choose the narrowest effective workflow\n\
/ask [prompt] — (Flow) Read-only analysis mode; optional inline prompt\n\
/code [prompt] — (Flow) Explicit implementation mode; optional inline prompt\n\
/architect [prompt] — (Flow) Plan-first mode; optional inline prompt\n\
/implement-plan — (Flow) Execute the saved architect handoff in /code\n\
/read-only [prompt] — (Flow) Hard read-only mode; optional inline prompt\n\
/teach [prompt] — (Flow) Teacher mode; inspect machine then walk you through any admin task step-by-step\n\
/new — (Reset) Fresh task context; clear chat, pins, and task files\n\
/forget — (Wipe) Hard forget; purge saved memory and Vein index too\n\
/vein-inspect — (Vein) Inspect indexed memory, hot files, and active room bias\n\
/workspace-profile — (Profile) Show the auto-generated workspace profile\n\
/version — (Build) Show the running Hematite version\n\
/about — (Info) Show author, repo, and product info\n\
/vein-reset — (Vein) Wipe the RAG index; rebuilds automatically on next turn\n\
/clear — (UI) Clear dialogue display only\n\
/health — (Diag) Run a synthesized plain-English system health report\n\
/explain <text> — (Help) Paste an error to get a non-technical breakdown\n\
/gemma-native [auto|on|off|status] — (Model) Auto/force/disable Gemma 4 native formatting\n\
/runtime-refresh — (Model) Re-read LM Studio model + CTX now\n\
/undo — (Ghost) Revert last file change\n\
/diff — (Git) Show session changes (--stat)\n\
/lsp — (Logic) Start Language Servers (semantic intelligence)\n\
/swarm <text> — (Swarm) Spawn parallel workers on a directive\n\
/worktree <cmd> — (Isolated) Manage git worktrees (list|add|remove|prune)\n\
/think — (Brain) Enable deep reasoning mode\n\
/no_think — (Speed) Disable reasoning (3-5x faster responses)\n\
/voice — (TTS) List all available voices\n\
/voice N — (TTS) Select voice by number\n\
/read <text> — (TTS) Speak text aloud directly, bypassing the model. ESC to stop.\n\
/explain <text> — (Plain English) Paste any error or output; Hematite explains it in plain English.\n\
/health — (SysAdmin) Run a full system health report (disk, RAM, tools, recent errors).\n\
/attach <path> — (Docs) Attach a PDF/markdown/txt file for next message\n\
/attach-pick — (Docs) Open a file picker and attach a document\n\
/image <path> — (Vision) Attach an image for the next message\n\
/image-pick — (Vision) Open a file picker and attach an image\n\
/detach — (Context) Drop pending document/image attachments\n\
/copy — (Debug) Copy session transcript to clipboard\n\
/copy2 — (Debug) Copy SPECULAR log to clipboard (reasoning + events)\n\
\nHotkeys:\n\
Ctrl+B — Toggle Brief Mode (minimal output)\n\
Ctrl+P — Toggle Professional Mode (strip personality)\n\
Ctrl+O — Open document picker for next-turn context\n\
Ctrl+I — Open image picker for next-turn vision context\n\
Ctrl+Y — Toggle Approvals Off (bypass safety approvals)\n\
Ctrl+S — Quick Swarm (hardcoded bootstrap)\n\
Ctrl+Z — Undo last edit\n\
Ctrl+Q/C — Quit session\n\
ESC — Silence current playback\n\
\nStatus Legend:\n\
LM — LM Studio runtime health (`LIVE`, `RECV`, `WARN`, `CEIL`, `STALE`, `BOOT`)\n\
VN — Vein RAG status (`SEM`=semantic active, `FTS`=BM25 only, `--`=not indexed)\n\
BUD — Total prompt-budget pressure against the live context window\n\
CMP — History compaction pressure against Hematite's adaptive threshold\n\
ERR — Session error count (runtime, tool, or SPECULAR failures)\n\
CTX — Live context window currently reported by LM Studio\n\
VOICE — Local speech output state\n\
\nAssistant: Semantic Pathing (LSP), Vision Pass, Web Research, Swarm Synthesis"
);
app.push_message(
"System",
"Document note: `/attach` supports PDF/markdown/txt, but PDF parsing is best-effort by design so Hematite can stay a lightweight single-binary local coding harness and workstation assistant. If a PDF fails, export it to text/markdown or attach page images instead.",
);
}
fn trigger_input_action(app: &mut App, action: InputAction) {
match action {
InputAction::Stop => request_stop(app),
InputAction::PickDocument => match pick_attachment_path(AttachmentPickerKind::Document) {
Ok(Some(path)) => attach_document_from_path(app, &path),
Ok(None) => app.push_message("System", "Document picker cancelled."),
Err(e) => app.push_message("System", &e),
},
InputAction::PickImage => match pick_attachment_path(AttachmentPickerKind::Image) {
Ok(Some(path)) => attach_image_from_path(app, &path),
Ok(None) => app.push_message("System", "Image picker cancelled."),
Err(e) => app.push_message("System", &e),
},
InputAction::Detach => {
app.clear_pending_attachments();
app.push_message(
"System",
"Cleared pending document/image attachments for the next turn.",
);
}
InputAction::New => {
if !app.agent_running {
reset_visible_session_state(app);
app.push_message("You", "/new");
app.agent_running = true;
let _ = app.user_input_tx.try_send(UserTurn::text("/new"));
}
}
InputAction::Forget => {
if !app.agent_running {
app.cancel_token
.store(true, std::sync::atomic::Ordering::SeqCst);
reset_visible_session_state(app);
app.push_message("You", "/forget");
app.agent_running = true;
app.cancel_token
.store(false, std::sync::atomic::Ordering::SeqCst);
let _ = app.user_input_tx.try_send(UserTurn::text("/forget"));
}
}
InputAction::Help => show_help_message(app),
}
}
fn pick_attachment_path(kind: AttachmentPickerKind) -> Result<Option<String>, String> {
#[cfg(target_os = "windows")]
{
let (title, filter) = match kind {
AttachmentPickerKind::Document => (
"Attach document for the next Hematite turn",
"Documents|*.pdf;*.md;*.markdown;*.txt;*.rst|All Files|*.*",
),
AttachmentPickerKind::Image => (
"Attach image for the next Hematite turn",
"Images|*.png;*.jpg;*.jpeg;*.gif;*.webp|All Files|*.*",
),
};
let script = format!(
"Add-Type -AssemblyName System.Windows.Forms\n$dialog = New-Object System.Windows.Forms.OpenFileDialog\n$dialog.Title = '{title}'\n$dialog.Filter = '{filter}'\n$dialog.Multiselect = $false\nif ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {{ Write-Output $dialog.FileName }}"
);
let output = std::process::Command::new("powershell")
.args(["-NoProfile", "-STA", "-Command", &script])
.output()
.map_err(|e| format!("File picker failed: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(if stderr.is_empty() {
"File picker did not complete successfully.".to_string()
} else {
format!("File picker failed: {}", stderr)
});
}
let selected = String::from_utf8_lossy(&output.stdout).trim().to_string();
if selected.is_empty() {
Ok(None)
} else {
Ok(Some(selected))
}
}
#[cfg(target_os = "macos")]
{
let prompt = match kind {
AttachmentPickerKind::Document => "Choose a document for the next Hematite turn",
AttachmentPickerKind::Image => "Choose an image for the next Hematite turn",
};
let script = format!("POSIX path of (choose file with prompt \"{}\")", prompt);
let output = std::process::Command::new("osascript")
.args(["-e", &script])
.output()
.map_err(|e| format!("File picker failed: {}", e))?;
if output.status.success() {
let selected = String::from_utf8_lossy(&output.stdout).trim().to_string();
if selected.is_empty() {
Ok(None)
} else {
Ok(Some(selected))
}
} else {
Ok(None)
}
}
#[cfg(all(unix, not(target_os = "macos")))]
{
let title = match kind {
AttachmentPickerKind::Document => "Attach document for the next Hematite turn",
AttachmentPickerKind::Image => "Attach image for the next Hematite turn",
};
let output = std::process::Command::new("zenity")
.args(["--file-selection", "--title", title])
.output()
.map_err(|e| format!("File picker failed: {}", e))?;
if output.status.success() {
let selected = String::from_utf8_lossy(&output.stdout).trim().to_string();
if selected.is_empty() {
Ok(None)
} else {
Ok(Some(selected))
}
} else {
Ok(None)
}
}
}
pub async fn run_app<B: Backend>(
terminal: &mut Terminal<B>,
mut specular_rx: Receiver<SpecularEvent>,
mut agent_rx: Receiver<crate::agent::inference::InferenceEvent>,
user_input_tx: tokio::sync::mpsc::Sender<UserTurn>,
mut swarm_rx: Receiver<SwarmMessage>,
swarm_tx: tokio::sync::mpsc::Sender<SwarmMessage>,
swarm_coordinator: Arc<crate::agent::swarm::SwarmCoordinator>,
last_interaction: Arc<Mutex<Instant>>,
cockpit: crate::CliCockpit,
soul: crate::ui::hatch::RustySoul,
professional: bool,
gpu_state: Arc<GpuState>,
git_state: Arc<crate::agent::git_monitor::GitState>,
cancel_token: Arc<std::sync::atomic::AtomicBool>,
voice_manager: Arc<crate::ui::voice::VoiceManager>,
) -> Result<(), Box<dyn std::error::Error>> {
let mut app = App {
messages: Vec::new(),
messages_raw: Vec::new(),
specular_logs: Vec::new(),
brief_mode: cockpit.brief,
tick_count: 0,
stats: RustyStats {
debugging: 0,
wisdom: soul.wisdom,
patience: 100.0,
chaos: soul.chaos,
snark: soul.snark,
},
yolo_mode: cockpit.yolo,
awaiting_approval: None,
active_workers: HashMap::new(),
worker_labels: HashMap::new(),
active_review: None,
input: String::new(),
input_history: Vec::new(),
history_idx: None,
thinking: false,
agent_running: false,
current_thought: String::new(),
professional,
last_reasoning: String::new(),
active_context: default_active_context(),
manual_scroll_offset: None,
user_input_tx,
specular_scroll: 0,
specular_auto_scroll: true,
gpu_state,
git_state,
last_input_time: Instant::now(),
cancel_token,
total_tokens: 0,
current_session_cost: 0.0,
model_id: "detecting...".to_string(),
context_length: 0,
prompt_pressure_percent: 0,
prompt_estimated_input_tokens: 0,
prompt_reserved_output_tokens: 0,
prompt_estimated_total_tokens: 0,
compaction_percent: 0,
compaction_estimated_tokens: 0,
compaction_threshold_tokens: 0,
compaction_warned_level: 0,
last_runtime_profile_time: Instant::now(),
vein_file_count: 0,
vein_embedded_count: 0,
vein_docs_only: false,
provider_state: ProviderRuntimeState::Booting,
last_provider_summary: String::new(),
mcp_state: McpRuntimeState::Unconfigured,
last_mcp_summary: String::new(),
last_operator_checkpoint_state: OperatorCheckpointState::Idle,
last_operator_checkpoint_summary: String::new(),
last_recovery_recipe_summary: String::new(),
think_mode: None,
workflow_mode: "AUTO".into(),
autocomplete_suggestions: Vec::new(),
selected_suggestion: 0,
show_autocomplete: false,
autocomplete_filter: String::new(),
current_objective: "Awaiting objective...".into(),
voice_manager,
voice_loading: false,
voice_loading_progress: 0.0,
hardware_guard_enabled: true,
session_start: std::time::SystemTime::now(),
soul_name: soul.species.clone(),
attached_context: None,
attached_image: None,
hovered_input_action: None,
};
app.push_message("Hematite", "Initialising Engine & Hardware...");
if !cockpit.no_splash {
draw_splash(terminal)?;
loop {
if let Ok(Event::Key(key)) = event::read() {
if key.kind == event::KeyEventKind::Press
&& matches!(key.code, KeyCode::Enter | KeyCode::Char(' '))
{
break;
}
}
}
}
let mut event_stream = EventStream::new();
let mut ticker = tokio::time::interval(std::time::Duration::from_millis(100));
loop {
let vram_ratio = app.gpu_state.ratio();
if app.hardware_guard_enabled && vram_ratio > 0.95 && !app.brief_mode {
app.brief_mode = true;
app.push_message(
"System",
"🚨 HARDWARE GUARD: VRAM > 95%. Brief Mode auto-enabled to prevent crash.",
);
}
terminal.draw(|f| ui(f, &app))?;
tokio::select! {
_ = ticker.tick() => {
if app.voice_loading && app.voice_loading_progress < 0.98 {
app.voice_loading_progress += 0.002;
}
let workers = app.active_workers.len() as u64;
let advance = if workers > 0 { workers * 4 + 1 } else { 1 };
app.tick_count = app.tick_count.wrapping_add(advance);
app.update_objective();
}
maybe_event = event_stream.next() => {
match maybe_event {
Some(Ok(Event::Mouse(mouse))) => {
use crossterm::event::{MouseButton, MouseEventKind};
let (width, height) = match terminal.size() {
Ok(s) => (s.width, s.height),
Err(_) => (80, 24),
};
let is_right_side = mouse.column as f64 > width as f64 * 0.65;
let input_rect = input_rect_for_size(
Rect { x: 0, y: 0, width, height },
app.input.len(),
);
let title_area = input_title_area(input_rect);
match mouse.kind {
MouseEventKind::Moved => {
let hovered = if mouse.row == title_area.y
&& mouse.column >= title_area.x
&& mouse.column < title_area.x + title_area.width
{
input_action_hitboxes(&app, title_area)
.into_iter()
.find_map(|(action, start, end)| {
(mouse.column >= start && mouse.column <= end)
.then_some(action)
})
} else {
None
};
app.hovered_input_action = hovered;
}
MouseEventKind::Down(MouseButton::Left) => {
if mouse.row == title_area.y
&& mouse.column >= title_area.x
&& mouse.column < title_area.x + title_area.width
{
for (action, start, end) in input_action_hitboxes(&app, title_area) {
if mouse.column >= start && mouse.column <= end {
app.hovered_input_action = Some(action);
trigger_input_action(&mut app, action);
break;
}
}
} else {
app.hovered_input_action = None;
}
}
MouseEventKind::ScrollUp => {
if is_right_side {
app.specular_auto_scroll = false;
app.specular_scroll = app.specular_scroll.saturating_sub(3);
} else {
let cur = app.manual_scroll_offset.unwrap_or(0);
app.manual_scroll_offset = Some(cur.saturating_add(3));
}
}
MouseEventKind::ScrollDown => {
if is_right_side {
app.specular_auto_scroll = false;
app.specular_scroll = app.specular_scroll.saturating_add(3);
} else if let Some(cur) = app.manual_scroll_offset {
app.manual_scroll_offset = if cur <= 3 { None } else { Some(cur - 3) };
}
}
_ => {}
}
}
Some(Ok(Event::Key(key))) => {
if key.kind != event::KeyEventKind::Press { continue; }
{ *last_interaction.lock().unwrap() = Instant::now(); }
if let Some(review) = app.active_review.take() {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
let _ = review.tx.send(ReviewResponse::Accept);
app.push_message("System", &format!("Worker {} diff accepted.", review.worker_id));
}
KeyCode::Char('n') | KeyCode::Char('N') => {
let _ = review.tx.send(ReviewResponse::Reject);
app.push_message("System", "Diff rejected.");
}
KeyCode::Char('r') | KeyCode::Char('R') => {
let _ = review.tx.send(ReviewResponse::Retry);
app.push_message("System", "Retrying synthesis…");
}
_ => { app.active_review = Some(review); }
}
continue;
}
if let Some(mut approval) = app.awaiting_approval.take() {
let scroll_handled = if approval.diff.is_some() {
let diff_lines = approval.diff.as_ref().map(|d| d.lines().count()).unwrap_or(0) as u16;
match key.code {
KeyCode::Down | KeyCode::Char('j') => {
approval.diff_scroll = approval.diff_scroll.saturating_add(1).min(diff_lines.saturating_sub(1));
true
}
KeyCode::Up | KeyCode::Char('k') => {
approval.diff_scroll = approval.diff_scroll.saturating_sub(1);
true
}
KeyCode::PageDown => {
approval.diff_scroll = approval.diff_scroll.saturating_add(10).min(diff_lines.saturating_sub(1));
true
}
KeyCode::PageUp => {
approval.diff_scroll = approval.diff_scroll.saturating_sub(10);
true
}
_ => false,
}
} else {
false
};
if scroll_handled {
app.awaiting_approval = Some(approval);
continue;
}
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
if let Some(ref diff) = approval.diff {
let added = diff.lines().filter(|l| l.starts_with("+ ")).count();
let removed = diff.lines().filter(|l| l.starts_with("- ")).count();
app.push_message("System", &format!(
"Applied: {} +{} -{}", approval.display, added, removed
));
} else {
app.push_message("System", &format!("Approved: {}", approval.display));
}
let _ = approval.responder.send(true);
}
KeyCode::Char('n') | KeyCode::Char('N') => {
if approval.diff.is_some() {
app.push_message("System", "Edit skipped.");
} else {
app.push_message("System", "Declined.");
}
let _ = approval.responder.send(false);
}
_ => { app.awaiting_approval = Some(approval); }
}
continue;
}
match key.code {
KeyCode::Char('q') | KeyCode::Char('c')
if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
app.write_session_report();
app.copy_transcript_to_clipboard();
break;
}
KeyCode::Esc => {
request_stop(&mut app);
}
KeyCode::Char('b') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
app.brief_mode = !app.brief_mode;
app.hardware_guard_enabled = false;
app.push_message("System", &format!("Hardware Guard {}: {}", if app.brief_mode { "ENFORCED" } else { "SILENCED" }, if app.brief_mode { "ON" } else { "OFF" }));
}
KeyCode::Char('p') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
app.professional = !app.professional;
app.push_message("System", &format!("Professional Harness: {}", if app.professional { "ACTIVE" } else { "DISABLED" }));
}
KeyCode::Char('y') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
app.yolo_mode = !app.yolo_mode;
app.push_message("System", &format!("Approvals Off: {}", if app.yolo_mode { "ON — all tools auto-approved" } else { "OFF" }));
}
KeyCode::Char('t') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
if !app.voice_manager.is_available() {
app.push_message("System", "Voice is not available in this build. Use a packaged release for baked-in voice.");
} else {
let enabled = app.voice_manager.toggle();
app.push_message("System", &format!("Voice of Hematite: {}", if enabled { "VIBRANT" } else { "SILENCED" }));
}
}
KeyCode::Char('o') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
match pick_attachment_path(AttachmentPickerKind::Document) {
Ok(Some(path)) => attach_document_from_path(&mut app, &path),
Ok(None) => app.push_message("System", "Document picker cancelled."),
Err(e) => app.push_message("System", &e),
}
}
KeyCode::Char('i') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
match pick_attachment_path(AttachmentPickerKind::Image) {
Ok(Some(path)) => attach_image_from_path(&mut app, &path),
Ok(None) => app.push_message("System", "Image picker cancelled."),
Err(e) => app.push_message("System", &e),
}
}
KeyCode::Char('s') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
app.push_message("Hematite", "Swarm engaged.");
let swarm_tx_c = swarm_tx.clone();
let coord_c = swarm_coordinator.clone();
let max_workers = if app.gpu_state.ratio() > 0.70 { 1 } else { 3 };
if max_workers < 3 {
app.push_message("System", "Hardware Guard: Limiting swarm to 1 worker due to GPU load.");
}
app.agent_running = true;
tokio::spawn(async move {
let payload = r#"<worker_task id="1" target="src/ui/tui.rs">Implement Swarm Layout</worker_task>
<worker_task id="2" target="src/agent/swarm.rs">Build Scratchpad constraints</worker_task>
<worker_task id="3" target="docs">Update Readme</worker_task>"#;
let tasks = crate::agent::parser::parse_master_spec(payload);
let _ = coord_c.dispatch_swarm(tasks, swarm_tx_c, max_workers).await;
});
}
KeyCode::Char('z') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
match crate::tools::file_ops::pop_ghost_ledger() {
Ok(msg) => {
app.specular_logs.push(format!("GHOST: {}", msg));
trim_vec(&mut app.specular_logs, 7);
app.push_message("System", &msg);
}
Err(e) => {
app.push_message("System", &format!("Undo failed: {}", e));
}
}
}
KeyCode::Up => {
if app.show_autocomplete && !app.autocomplete_suggestions.is_empty() {
app.selected_suggestion = app.selected_suggestion.saturating_sub(1);
} else if app.manual_scroll_offset.is_some() {
let cur = app.manual_scroll_offset.unwrap();
app.manual_scroll_offset = Some(cur.saturating_add(3));
} else if !app.input_history.is_empty() {
let new_idx = match app.history_idx {
None => app.input_history.len() - 1,
Some(i) => i.saturating_sub(1),
};
app.history_idx = Some(new_idx);
app.input = app.input_history[new_idx].clone();
}
}
KeyCode::Down => {
if app.show_autocomplete && !app.autocomplete_suggestions.is_empty() {
app.selected_suggestion = (app.selected_suggestion + 1).min(app.autocomplete_suggestions.len().saturating_sub(1));
} else if let Some(off) = app.manual_scroll_offset {
if off <= 3 { app.manual_scroll_offset = None; }
else { app.manual_scroll_offset = Some(off.saturating_sub(3)); }
} else if let Some(i) = app.history_idx {
if i + 1 < app.input_history.len() {
app.history_idx = Some(i + 1);
app.input = app.input_history[i + 1].clone();
} else {
app.history_idx = None;
app.input.clear();
}
}
}
KeyCode::PageUp => {
let cur = app.manual_scroll_offset.unwrap_or(0);
app.manual_scroll_offset = Some(cur.saturating_add(10));
}
KeyCode::PageDown => {
if let Some(off) = app.manual_scroll_offset {
if off <= 10 { app.manual_scroll_offset = None; }
else { app.manual_scroll_offset = Some(off.saturating_sub(10)); }
}
}
KeyCode::Tab => {
if app.show_autocomplete && !app.autocomplete_suggestions.is_empty() {
let selected = &app.autocomplete_suggestions[app.selected_suggestion];
if let Some(pos) = app.input.rfind('@') {
app.input.truncate(pos + 1);
app.input.push_str(selected);
app.show_autocomplete = false;
}
}
}
KeyCode::Char(c) => {
app.history_idx = None; app.input.push(c);
app.last_input_time = Instant::now();
if c == '@' {
app.show_autocomplete = true;
app.autocomplete_filter.clear();
app.selected_suggestion = 0;
app.update_autocomplete();
} else if app.show_autocomplete {
app.autocomplete_filter.push(c);
app.update_autocomplete();
}
}
KeyCode::Backspace => {
app.input.pop();
if app.show_autocomplete {
if app.input.ends_with('@') || !app.input.contains('@') {
app.show_autocomplete = false;
app.autocomplete_filter.clear();
} else {
app.autocomplete_filter.pop();
app.update_autocomplete();
}
}
}
KeyCode::Enter => {
if app.show_autocomplete && !app.autocomplete_suggestions.is_empty() {
let selected = &app.autocomplete_suggestions[app.selected_suggestion];
if let Some(pos) = app.input.rfind('@') {
app.input.truncate(pos + 1);
app.input.push_str(selected);
app.show_autocomplete = false;
continue;
}
}
if !app.input.is_empty() && !app.agent_running {
if Instant::now().duration_since(app.last_input_time) < std::time::Duration::from_millis(50) {
app.input.push(' ');
app.last_input_time = Instant::now();
continue;
}
let input_text = app.input.drain(..).collect::<String>();
if input_text.starts_with('/') {
let parts: Vec<&str> = input_text.trim().split_whitespace().collect();
let cmd = parts[0].to_lowercase();
match cmd.as_str() {
"/undo" => {
match crate::tools::file_ops::pop_ghost_ledger() {
Ok(msg) => {
app.specular_logs.push(format!("GHOST: {}", msg));
trim_vec(&mut app.specular_logs, 7);
app.push_message("System", &msg);
}
Err(e) => {
app.push_message("System", &format!("Undo failed: {}", e));
}
}
app.history_idx = None;
continue;
}
"/clear" => {
reset_visible_session_state(&mut app);
app.push_message("System", "Dialogue buffer cleared.");
app.history_idx = None;
continue;
}
"/diff" => {
app.push_message("System", "Fetching session diff...");
let ws = crate::tools::file_ops::workspace_root();
if crate::agent::git::is_git_repo(&ws) {
let output = std::process::Command::new("git")
.args(["diff", "--stat"])
.current_dir(ws)
.output();
if let Ok(out) = output {
let stat = String::from_utf8_lossy(&out.stdout).to_string();
app.push_message("System", if stat.is_empty() { "No changes detected." } else { &stat });
}
} else {
app.push_message("System", "Not a git repository. Diff limited.");
}
app.history_idx = None;
continue;
}
"/vein-reset" => {
app.vein_file_count = 0;
app.vein_embedded_count = 0;
app.push_message("You", "/vein-reset");
app.agent_running = true;
let _ = app.user_input_tx.try_send(UserTurn::text("/vein-reset"));
app.history_idx = None;
continue;
}
"/vein-inspect" => {
app.push_message("You", "/vein-inspect");
app.agent_running = true;
let _ = app.user_input_tx.try_send(UserTurn::text("/vein-inspect"));
app.history_idx = None;
continue;
}
"/workspace-profile" => {
app.push_message("You", "/workspace-profile");
app.agent_running = true;
let _ = app.user_input_tx.try_send(UserTurn::text("/workspace-profile"));
app.history_idx = None;
continue;
}
"/copy" => {
app.copy_transcript_to_clipboard();
app.push_message("System", "Exact session transcript copied to clipboard (includes help/system output).");
app.history_idx = None;
continue;
}
"/copy-last" => {
if app.copy_last_reply_to_clipboard() {
app.push_message("System", "Latest Hematite reply copied to clipboard.");
} else {
app.push_message("System", "No Hematite reply is available to copy yet.");
}
app.history_idx = None;
continue;
}
"/copy-clean" => {
app.copy_clean_transcript_to_clipboard();
app.push_message("System", "Clean chat transcript copied to clipboard (skips help/debug boilerplate).");
app.history_idx = None;
continue;
}
"/copy2" => {
app.copy_specular_to_clipboard();
app.push_message("System", "SPECULAR log copied to clipboard (reasoning + events).");
app.history_idx = None;
continue;
}
"/voice" => {
use crate::ui::voice::VOICE_LIST;
if let Some(arg) = parts.get(1) {
if let Ok(n) = arg.parse::<usize>() {
let idx = n.saturating_sub(1);
if let Some(&(id, label)) = VOICE_LIST.get(idx) {
app.voice_manager.set_voice(id);
let _ = crate::agent::config::set_voice(id);
app.push_message("System", &format!("Voice set to {} — {}", id, label));
} else {
app.push_message("System", &format!("Invalid voice number. Use /voice to list voices (1–{}).", VOICE_LIST.len()));
}
} else {
if let Some(&(id, label)) = VOICE_LIST.iter().find(|&&(id, _)| id == *arg) {
app.voice_manager.set_voice(id);
let _ = crate::agent::config::set_voice(id);
app.push_message("System", &format!("Voice set to {} — {}", id, label));
} else {
app.push_message("System", &format!("Unknown voice '{}'. Use /voice to list voices.", arg));
}
}
} else {
let current = app.voice_manager.current_voice_id();
let mut list = format!("Available voices (current: {}):\n", current);
for (i, &(id, label)) in VOICE_LIST.iter().enumerate() {
let marker = if id == current.as_str() { " ◀" } else { "" };
list.push_str(&format!(" {:>2}. {}{}\n", i + 1, label, marker));
}
list.push_str("\nUse /voice N or /voice <id> to select.");
app.push_message("System", &list);
}
app.history_idx = None;
continue;
}
"/read" => {
let text = parts[1..].join(" ");
if text.is_empty() {
app.push_message("System", "Usage: /read <text to speak>");
} else if !app.voice_manager.is_available() {
app.push_message("System", "Voice is not available in this build. Use a packaged release for baked-in voice.");
} else if !app.voice_manager.is_enabled() {
app.push_message("System", "Voice is off. Press Ctrl+T to enable, then /read again.");
} else {
app.push_message("System", &format!("Reading {} words aloud. ESC to stop.", text.split_whitespace().count()));
app.voice_manager.speak(text.clone());
}
app.history_idx = None;
continue;
}
"/new" => {
reset_visible_session_state(&mut app);
app.push_message("You", "/new");
app.agent_running = true;
app.clear_pending_attachments();
let _ = app.user_input_tx.try_send(UserTurn::text("/new"));
app.history_idx = None;
continue;
}
"/forget" => {
app.cancel_token.store(true, std::sync::atomic::Ordering::SeqCst);
reset_visible_session_state(&mut app);
app.push_message("You", "/forget");
app.agent_running = true;
app.cancel_token.store(false, std::sync::atomic::Ordering::SeqCst);
app.clear_pending_attachments();
let _ = app.user_input_tx.try_send(UserTurn::text("/forget"));
app.history_idx = None;
continue;
}
"/gemma-native" => {
let sub = parts.get(1).copied().unwrap_or("status").to_ascii_lowercase();
let gemma_detected = crate::agent::inference::is_gemma4_model_name(&app.model_id);
match sub.as_str() {
"auto" => {
match crate::agent::config::set_gemma_native_mode("auto") {
Ok(_) => {
if gemma_detected {
app.push_message("System", "Gemma Native Formatting: AUTO. Gemma 4 will use native formatting automatically on the next turn.");
} else {
app.push_message("System", "Gemma Native Formatting: AUTO in settings. It will activate automatically when a Gemma 4 model is loaded.");
}
}
Err(e) => app.push_message("System", &format!("Failed to update settings: {}", e)),
}
}
"on" => {
match crate::agent::config::set_gemma_native_mode("on") {
Ok(_) => {
if gemma_detected {
app.push_message("System", "Gemma Native Formatting: ON (forced). It will apply on the next turn.");
} else {
app.push_message("System", "Gemma Native Formatting: ON (forced) in settings. It will activate only when a Gemma 4 model is loaded.");
}
}
Err(e) => app.push_message("System", &format!("Failed to update settings: {}", e)),
}
}
"off" => {
match crate::agent::config::set_gemma_native_mode("off") {
Ok(_) => app.push_message("System", "Gemma Native Formatting: OFF."),
Err(e) => app.push_message("System", &format!("Failed to update settings: {}", e)),
}
}
_ => {
let config = crate::agent::config::load_config();
let mode = crate::agent::config::gemma_native_mode_label(&config, &app.model_id);
let enabled = match mode {
"on" => "ON (forced)",
"auto" => "ON (auto)",
"off" => "OFF",
_ => "INACTIVE",
};
let model_note = if gemma_detected {
"Gemma 4 detected."
} else {
"Current model is not Gemma 4."
};
app.push_message(
"System",
&format!(
"Gemma Native Formatting: {}. {} Usage: /gemma-native auto | on | off | status",
enabled, model_note
),
);
}
}
app.history_idx = None;
continue;
}
"/chat" => {
app.workflow_mode = "CHAT".into();
app.push_message("System", "Chat mode — natural conversation, no agent scaffolding. Use /agent to return to the full harness, or /ask, /architect, or /code to jump straight into a narrower workflow.");
app.history_idx = None;
let _ = app.user_input_tx.try_send(UserTurn::text("/chat"));
continue;
}
"/reroll" => {
app.history_idx = None;
let _ = app.user_input_tx.try_send(UserTurn::text("/reroll"));
continue;
}
"/agent" => {
app.workflow_mode = "AUTO".into();
app.push_message("System", "Agent mode — full coding harness and workstation assistant active. Use /auto for normal behavior, /ask for read-only analysis, /architect for plan-first work, /code for implementation, or /chat for clean conversation.");
app.history_idx = None;
let _ = app.user_input_tx.try_send(UserTurn::text("/agent"));
continue;
}
"/implement-plan" => {
app.workflow_mode = "CODE".into();
app.push_message("You", "/implement-plan");
app.agent_running = true;
let _ = app.user_input_tx.try_send(UserTurn::text("/implement-plan"));
app.history_idx = None;
continue;
}
"/ask" | "/code" | "/architect" | "/read-only" | "/auto" | "/teach" => {
let label = match cmd.as_str() {
"/ask" => "ASK",
"/code" => "CODE",
"/architect" => "ARCHITECT",
"/read-only" => "READ-ONLY",
"/teach" => "TEACH",
_ => "AUTO",
};
app.workflow_mode = label.to_string();
let outbound = input_text.trim().to_string();
app.push_message("You", &outbound);
app.agent_running = true;
let _ = app.user_input_tx.try_send(UserTurn::text(outbound));
app.history_idx = None;
continue;
}
"/worktree" => {
let sub = parts.get(1).copied().unwrap_or("");
match sub {
"list" => {
app.push_message("You", "/worktree list");
app.agent_running = true;
let _ = app.user_input_tx.try_send(UserTurn::text(
"Call git_worktree with action=list"
));
}
"add" => {
let wt_path = parts.get(2).copied().unwrap_or("");
let wt_branch = parts.get(3).copied().unwrap_or("");
if wt_path.is_empty() {
app.push_message("System", "Usage: /worktree add <path> [branch]");
} else {
app.push_message("You", &format!("/worktree add {wt_path}"));
app.agent_running = true;
let directive = if wt_branch.is_empty() {
format!("Call git_worktree with action=add path={wt_path}")
} else {
format!("Call git_worktree with action=add path={wt_path} branch={wt_branch}")
};
let _ = app.user_input_tx.try_send(UserTurn::text(directive));
}
}
"remove" => {
let wt_path = parts.get(2).copied().unwrap_or("");
if wt_path.is_empty() {
app.push_message("System", "Usage: /worktree remove <path>");
} else {
app.push_message("You", &format!("/worktree remove {wt_path}"));
app.agent_running = true;
let _ = app.user_input_tx.try_send(UserTurn::text(
format!("Call git_worktree with action=remove path={wt_path}")
));
}
}
"prune" => {
app.push_message("You", "/worktree prune");
app.agent_running = true;
let _ = app.user_input_tx.try_send(UserTurn::text(
"Call git_worktree with action=prune"
));
}
_ => {
app.push_message("System",
"Usage: /worktree list | add <path> [branch] | remove <path> | prune");
}
}
app.history_idx = None;
continue;
}
"/think" => {
app.think_mode = Some(true);
app.push_message("You", "/think");
app.agent_running = true;
let _ = app.user_input_tx.try_send(UserTurn::text("/think"));
app.history_idx = None;
continue;
}
"/no_think" => {
app.think_mode = Some(false);
app.push_message("You", "/no_think");
app.agent_running = true;
let _ = app.user_input_tx.try_send(UserTurn::text("/no_think"));
app.history_idx = None;
continue;
}
"/lsp" => {
app.push_message("You", "/lsp");
app.agent_running = true;
let _ = app.user_input_tx.try_send(UserTurn::text("/lsp"));
app.history_idx = None;
continue;
}
"/runtime-refresh" => {
app.push_message("You", "/runtime-refresh");
app.agent_running = true;
let _ = app.user_input_tx.try_send(UserTurn::text("/runtime-refresh"));
app.history_idx = None;
continue;
}
"/help" => {
show_help_message(&mut app);
app.history_idx = None;
continue;
}
"/help-legacy-unused" => {
app.push_message("System",
"Hematite Commands:\n\
/chat — (Mode) Conversation mode — clean chat, no tool noise\n\
/agent — (Mode) Full coding harness + workstation mode — tools, file edits, builds, inspection\n\
/reroll — (Soul) Hatch a new companion mid-session\n\
/auto — (Flow) Let Hematite choose the narrowest effective workflow\n\
/ask [prompt] — (Flow) Read-only analysis mode; optional inline prompt\n\
/code [prompt] — (Flow) Explicit implementation mode; optional inline prompt\n\
/architect [prompt] — (Flow) Plan-first mode; optional inline prompt\n\
/implement-plan — (Flow) Execute the saved architect handoff in /code\n\
/read-only [prompt] — (Flow) Hard read-only mode; optional inline prompt\n\
/teach [prompt] — (Flow) Teacher mode; inspect machine then walk you through any admin task step-by-step\n\
/new — (Reset) Fresh task context; clear chat, pins, and task files\n\
/forget — (Wipe) Hard forget; purge saved memory and Vein index too\n\
/vein-inspect — (Vein) Inspect indexed memory, hot files, and active room bias\n\
/workspace-profile — (Profile) Show the auto-generated workspace profile\n\
/version — (Build) Show the running Hematite version\n\
/about — (Info) Show author, repo, and product info\n\
/vein-reset — (Vein) Wipe the RAG index; rebuilds automatically on next turn\n\
/clear — (UI) Clear dialogue display only\n\
/gemma-native [auto|on|off|status] — (Model) Auto/force/disable Gemma 4 native formatting\n\
/runtime-refresh — (Model) Re-read LM Studio model + CTX now\n\
/undo — (Ghost) Revert last file change\n\
/diff — (Git) Show session changes (--stat)\n\
/lsp — (Logic) Start Language Servers (semantic intelligence)\n\
/swarm <text> — (Swarm) Spawn parallel workers on a directive\n\
/worktree <cmd> — (Isolated) Manage git worktrees (list|add|remove|prune)\n\
/think — (Brain) Enable deep reasoning mode\n\
/no_think — (Speed) Disable reasoning (3-5x faster responses)\n\
/voice — (TTS) List all available voices\n\
/voice N — (TTS) Select voice by number\n\
/attach <path> — (Docs) Attach a PDF/markdown/txt file for next message\n\
/attach-pick — (Docs) Open a file picker and attach a document\n\
/image <path> — (Vision) Attach an image for the next message\n\
/image-pick — (Vision) Open a file picker and attach an image\n\
/detach — (Context) Drop pending document/image attachments\n\
/copy — (Debug) Copy session transcript to clipboard\n\
/copy2 — (Debug) Copy SPECULAR log to clipboard (reasoning + events)\n\
\nHotkeys:\n\
Ctrl+B — Toggle Brief Mode (minimal output)\n\
Ctrl+P — Toggle Professional Mode (strip personality)\n\
Ctrl+O — Open document picker for next-turn context\n\
Ctrl+I — Open image picker for next-turn vision context\n\
Ctrl+Y — Toggle Approvals Off (bypass safety approvals)\n\
Ctrl+S — Quick Swarm (hardcoded bootstrap)\n\
Ctrl+Z — Undo last edit\n\
Ctrl+Q/C — Quit session\n\
ESC — Silence current playback\n\
\nStatus Legend:\n\
LM — LM Studio runtime health (`LIVE`, `RECV`, `WARN`, `CEIL`, `STALE`, `BOOT`)\n\
VN — Vein RAG status (`SEM`=semantic active, `FTS`=BM25 only, `--`=not indexed)\n\
BUD — Total prompt-budget pressure against the live context window\n\
CMP — History compaction pressure against Hematite's adaptive threshold\n\
ERR — Session error count (runtime, tool, or SPECULAR failures)\n\
CTX — Live context window currently reported by LM Studio\n\
VOICE — Local speech output state\n\
\nAssistant: Semantic Pathing (LSP), Vision Pass, Web Research, Swarm Synthesis"
);
app.history_idx = None;
continue;
}
"/swarm" => {
let directive = parts[1..].join(" ");
if directive.is_empty() {
app.push_message("System", "Usage: /swarm <directive>");
} else {
app.active_workers.clear(); app.push_message("Hematite", &format!("Swarm analyzing: '{}'", directive));
let swarm_tx_c = swarm_tx.clone();
let coord_c = swarm_coordinator.clone();
let max_workers = if app.gpu_state.ratio() > 0.75 { 1 } else { 3 };
app.agent_running = true;
tokio::spawn(async move {
let payload = format!(r#"<worker_task id="1" target="src">Research {}</worker_task>
<worker_task id="2" target="src">Implement {}</worker_task>
<worker_task id="3" target="docs">Document {}</worker_task>"#, directive, directive, directive);
let tasks = crate::agent::parser::parse_master_spec(&payload);
let _ = coord_c.dispatch_swarm(tasks, swarm_tx_c, max_workers).await;
});
}
app.history_idx = None;
continue;
}
"/version" => {
app.push_message(
"System",
&crate::hematite_version_report(),
);
app.history_idx = None;
continue;
}
"/about" => {
app.push_message(
"System",
&crate::hematite_about_report(),
);
app.history_idx = None;
continue;
}
"/explain" => {
let error_text = parts[1..].join(" ");
if error_text.trim().is_empty() {
app.push_message("System", "Usage: /explain <error message or text>\n\nPaste any error, warning, or confusing message and Hematite will explain it in plain English — what it means, why it happened, and what to do about it.");
} else {
let framed = format!(
"The user pasted the following error or message and needs a plain-English explanation. \
Explain what this means, why it happened, and what to do about it. \
Use simple, non-technical language. Avoid jargon. \
Structure your response as:\n\
1. What happened (one sentence)\n\
2. Why it happened\n\
3. How to fix it (step by step)\n\
4. How to prevent it next time (optional, if relevant)\n\n\
Error/message to explain:\n```\n{}\n```",
error_text
);
app.push_message("You", &format!("/explain {}", error_text));
app.agent_running = true;
let _ = app.user_input_tx.try_send(UserTurn::text(framed));
}
app.history_idx = None;
continue;
}
"/health" => {
app.push_message("You", "/health");
app.agent_running = true;
let _ = app.user_input_tx.try_send(UserTurn::text(
"Run inspect_host with topic=health_report. \
After getting the report, summarize it in plain English for a non-technical user. \
Use the tier labels (Needs fixing / Worth watching / Looking good) and \
give specific, actionable next steps for any items that need attention."
));
app.history_idx = None;
continue;
}
"/detach" => {
app.clear_pending_attachments();
app.push_message("System", "Cleared pending document/image attachments for the next turn.");
app.history_idx = None;
continue;
}
"/attach" => {
let file_path = parts[1..].join(" ").trim().to_string();
if file_path.is_empty() {
app.push_message("System", "Usage: /attach <path> - attach a file (PDF, markdown, txt) as context for the next message.\nPDF parsing is best-effort for single-binary portability; scanned/image-only or oddly encoded PDFs may fail.\nUse /attach-pick for a file dialog. Drop reference docs in .hematite/docs/ to have them indexed permanently.");
app.history_idx = None;
continue;
}
if file_path.is_empty() {
app.push_message("System", "Usage: /attach <path> — attach a file (PDF, markdown, txt) as context for the next message.\nUse /attach-pick for a file dialog. Drop reference docs in .hematite/docs/ to have them indexed permanently.");
} else {
let p = std::path::Path::new(&file_path);
match crate::memory::vein::extract_document_text(p) {
Ok(text) => {
let name = p.file_name()
.and_then(|n| n.to_str())
.unwrap_or(&file_path)
.to_string();
let preview_len = text.len().min(200);
app.push_message("System", &format!(
"Attached: {} ({} chars) — will be injected as context on your next message.\nPreview: {}...",
name, text.len(), &text[..preview_len]
));
app.attached_context = Some((name, text));
}
Err(e) => {
app.push_message("System", &format!("Attach failed: {}", e));
}
}
}
app.history_idx = None;
continue;
}
"/attach-pick" => {
match pick_attachment_path(AttachmentPickerKind::Document) {
Ok(Some(path)) => attach_document_from_path(&mut app, &path),
Ok(None) => app.push_message("System", "Document picker cancelled."),
Err(e) => app.push_message("System", &e),
}
app.history_idx = None;
continue;
}
"/image" => {
let file_path = parts[1..].join(" ").trim().to_string();
if file_path.is_empty() {
app.push_message("System", "Usage: /image <path> - attach an image (PNG/JPG/GIF/WebP) for the next message.\nUse /image-pick for a file dialog.");
} else {
attach_image_from_path(&mut app, &file_path);
}
app.history_idx = None;
continue;
}
"/image-pick" => {
match pick_attachment_path(AttachmentPickerKind::Image) {
Ok(Some(path)) => attach_image_from_path(&mut app, &path),
Ok(None) => app.push_message("System", "Image picker cancelled."),
Err(e) => app.push_message("System", &e),
}
app.history_idx = None;
continue;
}
_ => {
app.push_message("System", &format!("Unknown command: {}", cmd));
app.history_idx = None;
continue;
}
}
}
if app.input_history.last().map(|s| s.as_str()) != Some(&input_text) {
app.input_history.push(input_text.clone());
if app.input_history.len() > 50 {
app.input_history.remove(0);
}
}
app.history_idx = None;
app.push_message("You", &input_text);
app.agent_running = true;
app.cancel_token.store(false, std::sync::atomic::Ordering::SeqCst);
app.last_reasoning.clear();
app.manual_scroll_offset = None;
app.specular_auto_scroll = true;
let tx = app.user_input_tx.clone();
let outbound = UserTurn {
text: input_text,
attached_document: app.attached_context.take().map(|(name, content)| {
AttachedDocument { name, content }
}),
attached_image: app.attached_image.take(),
};
tokio::spawn(async move {
let _ = tx.send(outbound).await;
});
}
}
_ => {}
}
}
Some(Ok(Event::Paste(content))) => {
if !try_attach_from_paste(&mut app, &content) {
let normalized = content.replace("\r\n", " ").replace('\n', " ");
app.input.push_str(&normalized);
app.last_input_time = Instant::now();
}
}
_ => {}
}
}
Some(specular_evt) = specular_rx.recv() => {
match specular_evt {
SpecularEvent::SyntaxError { path, details } => {
app.record_error();
app.specular_logs.push(format!("ERROR: {:?}", path));
trim_vec(&mut app.specular_logs, 20);
let user_idle = {
let lock = last_interaction.lock().unwrap();
lock.elapsed() > std::time::Duration::from_secs(3)
};
if user_idle && !app.agent_running {
app.agent_running = true;
let tx = app.user_input_tx.clone();
let diag = details.clone();
tokio::spawn(async move {
let msg = format!(
"<specular-build-fail>\n{}\n</specular-build-fail>\n\
Fix the compiler error above.",
diag
);
let _ = tx.send(UserTurn::text(msg)).await;
});
}
}
SpecularEvent::FileChanged(path) => {
app.stats.wisdom += 1;
app.stats.patience = (app.stats.patience - 0.5).max(0.0);
if app.stats.patience < 50.0 && !app.brief_mode {
app.brief_mode = true;
app.push_message("System", "Context saturation high — Brief Mode auto-enabled.");
}
let path_str = path.to_string_lossy().to_string();
app.specular_logs.push(format!("INDEX: {}", path_str));
app.push_context_file(path_str, "Active".into());
trim_vec(&mut app.specular_logs, 20);
}
}
}
Some(event) = agent_rx.recv() => {
use crate::agent::inference::InferenceEvent;
match event {
InferenceEvent::Thought(content) => {
app.thinking = true;
app.current_thought.push_str(&content);
}
InferenceEvent::VoiceStatus(msg) => {
app.push_message("System", &msg);
}
InferenceEvent::Token(ref token) | InferenceEvent::MutedToken(ref token) => {
let is_muted = matches!(event, InferenceEvent::MutedToken(_));
app.thinking = false;
if app.messages_raw.last().map(|(s, _)| s.as_str()) != Some("Hematite") {
app.push_message("Hematite", "");
}
app.update_last_message(token);
app.manual_scroll_offset = None;
if !is_muted && app.voice_manager.is_enabled() && !app.cancel_token.load(std::sync::atomic::Ordering::SeqCst) {
app.voice_manager.speak(token.clone());
}
}
InferenceEvent::ToolCallStart { name, args, .. } => {
if app.workflow_mode != "CHAT" {
let display = format!("( ) {} {}", name, args);
app.push_message("Tool", &display);
}
app.active_context.push(ContextFile {
path: name.clone(),
size: 0,
status: "Running".into()
});
trim_vec_context(&mut app.active_context, 8);
app.manual_scroll_offset = None;
}
InferenceEvent::ToolCallResult { id: _, name, output, is_error } => {
let icon = if is_error { "[x]" } else { "[v]" };
if is_error {
app.record_error();
}
let preview = first_n_chars(&output, 100);
if app.workflow_mode != "CHAT" {
app.push_message("Tool", &format!("{} {} → {}", icon, name, preview));
} else if is_error {
app.push_message("System", &format!("Tool error: {}", preview));
}
app.active_context.retain(|f| f.path != name || f.status != "Running");
app.manual_scroll_offset = None;
}
InferenceEvent::ApprovalRequired { id: _, name, display, diff, responder } => {
let is_diff = diff.is_some();
app.awaiting_approval = Some(PendingApproval {
display: display.clone(),
tool_name: name,
diff,
diff_scroll: 0,
responder,
});
if is_diff {
app.push_message("System", "[~] Diff preview — [Y] Apply [N] Skip");
} else {
app.push_message("System", "[!] Approval required (Press [Y] Approve or [N] Decline)");
app.push_message("System", &format!("Command: {}", display));
}
}
InferenceEvent::UsageUpdate(usage) => {
app.total_tokens = usage.total_tokens;
let turn_cost = crate::agent::pricing::calculate_cost(&usage, &app.model_id);
app.current_session_cost += turn_cost;
}
InferenceEvent::Done => {
app.thinking = false;
app.agent_running = false;
if app.voice_manager.is_enabled() {
app.voice_manager.flush();
}
if !app.current_thought.is_empty() {
app.last_reasoning = app.current_thought.clone();
}
app.current_thought.clear();
app.specular_auto_scroll = true;
app.active_workers.remove("AGENT");
app.worker_labels.remove("AGENT");
}
InferenceEvent::Error(e) => {
app.record_error();
app.thinking = false;
app.agent_running = false;
if app.voice_manager.is_enabled() {
app.voice_manager.flush();
}
app.push_message("System", &format!("Error: {e}"));
}
InferenceEvent::ProviderStatus { state, summary } => {
app.provider_state = state;
if !summary.trim().is_empty() && app.last_provider_summary != summary {
app.specular_logs.push(format!("PROVIDER: {}", summary));
trim_vec(&mut app.specular_logs, 20);
app.last_provider_summary = summary;
}
}
InferenceEvent::McpStatus { state, summary } => {
app.mcp_state = state;
if !summary.trim().is_empty() && app.last_mcp_summary != summary {
app.specular_logs.push(format!("MCP: {}", summary));
trim_vec(&mut app.specular_logs, 20);
app.last_mcp_summary = summary;
}
}
InferenceEvent::OperatorCheckpoint { state, summary } => {
app.last_operator_checkpoint_state = state;
if state == OperatorCheckpointState::Idle {
app.last_operator_checkpoint_summary.clear();
} else if !summary.trim().is_empty()
&& app.last_operator_checkpoint_summary != summary
{
app.specular_logs.push(format!(
"STATE: {} - {}",
state.label(),
summary
));
trim_vec(&mut app.specular_logs, 20);
app.last_operator_checkpoint_summary = summary;
}
}
InferenceEvent::RecoveryRecipe { summary } => {
if !summary.trim().is_empty()
&& app.last_recovery_recipe_summary != summary
{
app.specular_logs.push(format!("RECOVERY: {}", summary));
trim_vec(&mut app.specular_logs, 20);
app.last_recovery_recipe_summary = summary;
}
}
InferenceEvent::CompactionPressure {
estimated_tokens,
threshold_tokens,
percent,
} => {
app.compaction_estimated_tokens = estimated_tokens;
app.compaction_threshold_tokens = threshold_tokens;
app.compaction_percent = percent;
if percent < 60 {
app.compaction_warned_level = 0;
} else if percent >= 90 && app.compaction_warned_level < 90 {
app.compaction_warned_level = 90;
app.push_message(
"System",
"Context is 90% full. Use /new to reset history (project memory is preserved) or /forget to wipe everything.",
);
} else if percent >= 70 && app.compaction_warned_level < 70 {
app.compaction_warned_level = 70;
app.push_message(
"System",
&format!("Context at {}% — approaching the compaction threshold. Consider /new soon to keep responses sharp.", percent),
);
}
}
InferenceEvent::PromptPressure {
estimated_input_tokens,
reserved_output_tokens,
estimated_total_tokens,
context_length: _,
percent,
} => {
app.prompt_estimated_input_tokens = estimated_input_tokens;
app.prompt_reserved_output_tokens = reserved_output_tokens;
app.prompt_estimated_total_tokens = estimated_total_tokens;
app.prompt_pressure_percent = percent;
}
InferenceEvent::TaskProgress { id, label, progress } => {
let nid = normalize_id(&id);
app.active_workers.insert(nid.clone(), progress);
app.worker_labels.insert(nid, label);
}
InferenceEvent::RuntimeProfile { model_id, context_length } => {
let was_no_model = app.model_id == "no model loaded";
let now_no_model = model_id == "no model loaded";
let changed = app.model_id != "detecting..."
&& (app.model_id != model_id || app.context_length != context_length);
app.model_id = model_id.clone();
app.context_length = context_length;
app.last_runtime_profile_time = Instant::now();
if app.provider_state == ProviderRuntimeState::Booting {
app.provider_state = ProviderRuntimeState::Live;
}
if now_no_model && !was_no_model {
app.push_message(
"System",
"No coding model loaded. Load a model in LM Studio (e.g. Qwen/Qwen3.5-9B Q4_K_M) and start the server on port 1234. Optionally also load nomic-embed-text-v2 for semantic search.",
);
} else if changed && !now_no_model {
app.push_message(
"System",
&format!(
"Runtime profile refreshed: Model {} | CTX {}",
model_id, context_length
),
);
}
}
InferenceEvent::EmbedProfile { model_id } => {
match model_id {
Some(id) => app.push_message(
"System",
&format!("Embed model loaded: {} (semantic search ready)", id),
),
None => app.push_message(
"System",
"Embed model unloaded. Semantic search inactive.",
),
}
}
InferenceEvent::VeinStatus { file_count, embedded_count, docs_only } => {
app.vein_file_count = file_count;
app.vein_embedded_count = embedded_count;
app.vein_docs_only = docs_only;
}
InferenceEvent::VeinContext { paths } => {
app.active_context.retain(|f| f.status == "Running");
for path in paths {
let root = crate::tools::file_ops::workspace_root();
let size = std::fs::metadata(root.join(&path))
.map(|m| m.len())
.unwrap_or(0);
if !app.active_context.iter().any(|f| f.path == path) {
app.active_context.push(ContextFile {
path,
size,
status: "Vein".to_string(),
});
}
}
trim_vec_context(&mut app.active_context, 8);
}
InferenceEvent::SoulReroll { species, rarity, shiny, .. } => {
let shiny_tag = if shiny { " 🌟 SHINY" } else { "" };
app.soul_name = species.clone();
app.push_message(
"System",
&format!("[{}{}] {} has awakened.", rarity, shiny_tag, species),
);
}
InferenceEvent::ShellLine(line) => {
app.current_thought.push_str(&line);
app.current_thought.push('\n');
}
}
}
Some(msg) = swarm_rx.recv() => {
match msg {
SwarmMessage::Progress(worker_id, progress) => {
let nid = normalize_id(&worker_id);
app.active_workers.insert(nid.clone(), progress);
match progress {
102 => app.push_message("System", &format!("Worker {} architecture verified and applied.", nid)),
101 => { },
100 => app.push_message("Hematite", &format!("Worker {} complete. Standing by for review...", nid)),
_ => {}
}
}
SwarmMessage::ReviewRequest { worker_id, file_path, before, after, tx } => {
app.push_message("Hematite", &format!("Worker {} conflict — review required.", worker_id));
app.active_review = Some(ActiveReview {
worker_id,
file_path: file_path.to_string_lossy().to_string(),
before,
after,
tx,
});
}
SwarmMessage::Done => {
app.agent_running = false;
app.push_message("System", "──────────────────────────────────────────────────────────");
app.push_message("System", " TASK COMPLETE: Swarm Synthesis Finalized ");
app.push_message("System", "──────────────────────────────────────────────────────────");
}
}
}
}
}
Ok(())
}
fn ui(f: &mut ratatui::Frame, app: &App) {
let size = f.size();
if size.width < 60 || size.height < 10 {
f.render_widget(Clear, size);
return;
}
let input_height = compute_input_height(f.size().width, app.input.len());
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0),
Constraint::Length(input_height),
Constraint::Length(3),
])
.split(f.size());
let top = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Fill(1), Constraint::Length(45)]) .split(chunks[0]);
let mut core_lines = app.messages.clone();
if app.agent_running {
let dots = ".".repeat((app.tick_count % 4) as usize + 1);
core_lines.push(Line::from(Span::styled(
format!(" Hematite is thinking{}", dots),
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::DIM),
)));
}
let (heart_color, core_icon) = if app.agent_running || !app.active_workers.is_empty() {
let (r_base, g_base, b_base) = if !app.active_workers.is_empty() {
(0, 200, 200) } else {
(200, 0, 200) };
let pulse = (app.tick_count % 50) as f64 / 50.0;
let factor = (pulse * std::f64::consts::PI).sin().abs();
let r = (r_base as f64 * factor) as u8;
let g = (g_base as f64 * factor) as u8;
let b = (b_base as f64 * factor) as u8;
(Color::Rgb(r.max(60), g.max(60), b.max(60)), "•")
} else {
(Color::Rgb(80, 80, 80), "•") };
let live_objective = if app.current_objective != "Idle" {
app.current_objective.clone()
} else if !app.active_workers.is_empty() {
"Swarm active".to_string()
} else if app.thinking {
"Reasoning".to_string()
} else if app.agent_running {
"Working".to_string()
} else {
"Idle".to_string()
};
let objective_text = if live_objective.len() > 30 {
format!("{}...", &live_objective[..27])
} else {
live_objective
};
let core_title = if app.professional {
Line::from(vec![
Span::styled(format!(" {} ", core_icon), Style::default().fg(heart_color)),
Span::styled("HEMATITE ", Style::default().add_modifier(Modifier::BOLD)),
Span::styled(
format!(" TASK: {} ", objective_text),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::ITALIC),
),
])
} else {
Line::from(format!(" TASK: {} ", objective_text))
};
let core_para = Paragraph::new(core_lines.clone())
.block(
Block::default()
.title(core_title)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray)),
)
.wrap(Wrap { trim: true });
let avail_h = top[0].height.saturating_sub(2);
let inner_w = top[0].width.saturating_sub(4).max(1);
let mut total_lines: u16 = 0;
for line in &core_lines {
let line_w = line.width() as u16;
if line_w == 0 {
total_lines += 1;
} else {
let wrapped = (line_w + inner_w - 1) / inner_w;
total_lines += wrapped;
}
}
let max_scroll = total_lines.saturating_sub(avail_h);
let scroll = if let Some(off) = app.manual_scroll_offset {
max_scroll.saturating_sub(off)
} else {
max_scroll
};
f.render_widget(Clear, top[0]);
let chat_area = Rect::new(
top[0].x + 1,
top[0].y,
top[0].width.saturating_sub(2).max(1),
top[0].height,
);
f.render_widget(Clear, chat_area);
f.render_widget(core_para.scroll((scroll, 0)), chat_area);
let mut scrollbar_state =
ScrollbarState::new(max_scroll as usize + 1).position(scroll as usize);
f.render_stateful_widget(
Scrollbar::default()
.orientation(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("↑"))
.end_symbol(Some("↓")),
top[0],
&mut scrollbar_state,
);
let side = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(8), Constraint::Min(0), ])
.split(top[1]);
let context_source = if app.active_context.is_empty() {
default_active_context()
} else {
app.active_context.clone()
};
let mut context_display = context_source
.iter()
.map(|f| {
let (icon, color) = match f.status.as_str() {
"Running" => ("⚙️", Color::Cyan),
"Dirty" => ("📝", Color::Yellow),
_ => ("📄", Color::Gray),
};
let tokens = f.size / 4;
ListItem::new(Line::from(vec![
Span::styled(format!(" {} ", icon), Style::default().fg(color)),
Span::styled(f.path.clone(), Style::default().fg(Color::White)),
Span::styled(
format!(" {}t ", tokens),
Style::default().fg(Color::DarkGray),
),
]))
})
.collect::<Vec<ListItem>>();
if context_display.is_empty() {
context_display = vec![ListItem::new(" (No active files)")];
}
let ctx_block = Block::default()
.title(" ACTIVE CONTEXT ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray));
f.render_widget(Clear, side[0]);
f.render_widget(List::new(context_display).block(ctx_block), side[0]);
let v_title = if app.thinking || app.agent_running {
format!(" SPECULAR [working] ")
} else {
" SPECULAR [Watching] ".to_string()
};
f.render_widget(Clear, side[1]);
let mut v_lines: Vec<Line<'static>> = Vec::new();
if app.thinking || app.agent_running {
let dots = ".".repeat((app.tick_count % 4) as usize + 1);
let label = if app.thinking { "REASONING" } else { "WORKING" };
v_lines.push(Line::from(vec![Span::styled(
format!("[ {}{} ]", label, dots),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
)]));
let preview = if app.current_thought.chars().count() > 300 {
app.current_thought
.chars()
.rev()
.take(300)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<String>()
} else {
app.current_thought.clone()
};
for raw in preview.lines() {
let raw = raw.trim();
if !raw.is_empty() {
v_lines.extend(render_markdown_line(raw));
}
}
v_lines.push(Line::raw(""));
}
if !app.active_workers.is_empty() {
v_lines.push(Line::from(vec![Span::styled(
"── Task Progress ──",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::DIM),
)]));
let mut sorted_ids: Vec<_> = app.active_workers.keys().cloned().collect();
sorted_ids.sort();
for id in sorted_ids {
let prog = app.active_workers[&id];
let custom_label = app.worker_labels.get(&id).cloned();
let (label, color) = match prog {
101..=102 => ("VERIFIED", Color::Green),
100 if !app.agent_running && id != "AGENT" => ("SKIPPED ", Color::DarkGray),
100 => ("REVIEW ", Color::Magenta),
_ => ("WORKING ", Color::Yellow),
};
let display_label = custom_label.unwrap_or_else(|| label.to_string());
let filled = (prog.min(100) / 10) as usize;
let bar = "▓".repeat(filled) + &"░".repeat(10 - filled);
let id_prefix = if id == "AGENT" {
"Agent: ".to_string()
} else {
format!("W{}: ", id)
};
v_lines.push(Line::from(vec![
Span::styled(id_prefix, Style::default().fg(Color::Gray)),
Span::styled(bar, Style::default().fg(color)),
Span::styled(
format!(" {} ", display_label),
Style::default().fg(color).add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{}%", prog.min(100)),
Style::default().fg(Color::DarkGray),
),
]));
}
v_lines.push(Line::raw(""));
}
if !app.last_reasoning.is_empty() {
v_lines.push(Line::from(vec![Span::styled(
"── Logic Trace ──",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::DIM),
)]));
for raw in app.last_reasoning.lines() {
v_lines.extend(render_markdown_line(raw));
}
v_lines.push(Line::raw(""));
}
if !app.specular_logs.is_empty() {
v_lines.push(Line::from(vec![Span::styled(
"── Events ──",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::DIM),
)]));
for log in &app.specular_logs {
let (icon, color) = if log.starts_with("ERROR") {
("X ", Color::Red)
} else if log.starts_with("INDEX") {
("I ", Color::Cyan)
} else if log.starts_with("GHOST") {
("< ", Color::Magenta)
} else {
("- ", Color::Gray)
};
v_lines.push(Line::from(vec![
Span::styled(icon, Style::default().fg(color)),
Span::styled(
log.to_string(),
Style::default()
.fg(Color::White)
.add_modifier(Modifier::DIM),
),
]));
}
}
let v_total = v_lines.len() as u16;
let v_avail = side[1].height.saturating_sub(2);
let v_max_scroll = v_total.saturating_sub(v_avail);
let v_scroll = if app.specular_auto_scroll {
v_max_scroll
} else {
app.specular_scroll.min(v_max_scroll)
};
let specular_para = Paragraph::new(v_lines)
.wrap(Wrap { trim: true })
.scroll((v_scroll, 0))
.block(Block::default().title(v_title).borders(Borders::ALL));
f.render_widget(specular_para, side[1]);
let mut v_scrollbar_state =
ScrollbarState::new(v_max_scroll as usize + 1).position(v_scroll as usize);
f.render_stateful_widget(
Scrollbar::default()
.orientation(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None),
side[1],
&mut v_scrollbar_state,
);
let frame = app.tick_count % 3;
let spark = match frame {
0 => "✧",
1 => "✦",
_ => "✨",
};
let vigil = if app.brief_mode {
"VIGIL:[ON]"
} else {
"VIGIL:[off]"
};
let yolo = if app.yolo_mode {
" | APPROVALS: OFF"
} else {
""
};
let bar_constraints = if app.professional {
vec![
Constraint::Min(0), Constraint::Length(22), Constraint::Length(12), Constraint::Length(12), Constraint::Length(16), Constraint::Length(28), Constraint::Length(28), ]
} else {
vec![
Constraint::Length(12), Constraint::Min(0), Constraint::Length(22), Constraint::Length(12), Constraint::Length(12), Constraint::Length(16), Constraint::Length(28), Constraint::Length(28), ]
};
let bar_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints(bar_constraints)
.split(chunks[2]);
let char_count: usize = app.messages_raw.iter().map(|(_, c)| c.len()).sum();
let est_tokens = char_count / 3;
let current_tokens = if app.total_tokens > 0 {
app.total_tokens
} else {
est_tokens
};
let usage_text = format!(
"TOKENS: {:0>5} | TOTAL: ${:.4}",
current_tokens, app.current_session_cost
);
let runtime_age = app.last_runtime_profile_time.elapsed();
let (lm_label, lm_color) = if app.model_id == "no model loaded" {
("LM:NONE", Color::Red)
} else if app.model_id == "detecting..." || app.context_length == 0 {
("LM:BOOT", Color::DarkGray)
} else if app.provider_state == ProviderRuntimeState::Recovering {
("LM:RECV", Color::Cyan)
} else if matches!(
app.provider_state,
ProviderRuntimeState::Degraded | ProviderRuntimeState::EmptyResponse
) {
("LM:WARN", Color::Red)
} else if app.provider_state == ProviderRuntimeState::ContextWindow {
("LM:CEIL", Color::Yellow)
} else if runtime_age > std::time::Duration::from_secs(12) {
("LM:STALE", Color::Yellow)
} else {
("LM:LIVE", Color::Green)
};
let compaction_percent = app.compaction_percent.min(100);
let compaction_label = if app.compaction_threshold_tokens == 0 {
" CMP: 0%".to_string()
} else {
format!(" CMP:{:>3}%", compaction_percent)
};
let compaction_color = if app.compaction_threshold_tokens == 0 {
Color::DarkGray
} else if compaction_percent >= 85 {
Color::Red
} else if compaction_percent >= 60 {
Color::Yellow
} else {
Color::Green
};
let prompt_percent = app.prompt_pressure_percent.min(100);
let prompt_label = if app.prompt_estimated_total_tokens == 0 {
" BUD: 0%".to_string()
} else {
format!(" BUD:{:>3}%", prompt_percent)
};
let prompt_color = if app.prompt_estimated_total_tokens == 0 {
Color::DarkGray
} else if prompt_percent >= 85 {
Color::Red
} else if prompt_percent >= 60 {
Color::Yellow
} else {
Color::Green
};
let think_badge = match app.think_mode {
Some(true) => " [THINK]",
Some(false) => " [FAST]",
None => "",
};
let (vein_label, vein_color) = if app.vein_docs_only {
let color = if app.vein_embedded_count > 0 {
Color::Green
} else if app.vein_file_count > 0 {
Color::Yellow
} else {
Color::DarkGray
};
("VN:DOC", color)
} else if app.vein_file_count == 0 {
("VN:--", Color::DarkGray)
} else if app.vein_embedded_count > 0 {
("VN:SEM", Color::Green)
} else {
("VN:FTS", Color::Yellow)
};
let (status_idx, lm_idx, bud_idx, cmp_idx, remote_idx, tokens_idx, vram_idx) =
if app.professional {
(0usize, 1usize, 2usize, 3usize, 4usize, 5usize, 6usize)
} else {
(1usize, 2usize, 3usize, 4usize, 5usize, 6usize, 7usize)
};
if app.professional {
f.render_widget(Clear, bar_chunks[status_idx]);
let voice_badge = if app.voice_manager.is_enabled() {
" | VOICE:ON"
} else {
""
};
f.render_widget(
Paragraph::new(format!(
" MODE:PRO | FLOW:{}{} | CTX:{} | ERR:{}{}{}",
app.workflow_mode,
yolo,
app.context_length,
app.stats.debugging,
think_badge,
voice_badge
))
.block(Block::default().borders(Borders::ALL)),
bar_chunks[status_idx],
);
} else {
f.render_widget(Clear, bar_chunks[0]);
f.render_widget(
Paragraph::new(format!(" {} {}", spark, app.soul_name))
.block(Block::default().borders(Borders::ALL)),
bar_chunks[0],
);
f.render_widget(Clear, bar_chunks[status_idx]);
f.render_widget(
Paragraph::new(format!("{}{}", vigil, think_badge))
.block(Block::default().borders(Borders::ALL).fg(Color::Yellow)),
bar_chunks[status_idx],
);
}
let git_status = app.git_state.status();
let git_label = app.git_state.label();
let git_color = match git_status {
crate::agent::git_monitor::GitRemoteStatus::Connected => Color::Green,
crate::agent::git_monitor::GitRemoteStatus::NoRemote => Color::Yellow,
crate::agent::git_monitor::GitRemoteStatus::Behind
| crate::agent::git_monitor::GitRemoteStatus::Ahead => Color::Magenta,
crate::agent::git_monitor::GitRemoteStatus::Diverged
| crate::agent::git_monitor::GitRemoteStatus::Error => Color::Red,
_ => Color::DarkGray,
};
f.render_widget(Clear, bar_chunks[lm_idx]);
f.render_widget(
Paragraph::new(ratatui::text::Line::from(vec![
ratatui::text::Span::styled(format!(" {}", lm_label), Style::default().fg(lm_color)),
ratatui::text::Span::raw(" | "),
ratatui::text::Span::styled(vein_label, Style::default().fg(vein_color)),
]))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(lm_color)),
),
bar_chunks[lm_idx],
);
f.render_widget(Clear, bar_chunks[bud_idx]);
f.render_widget(
Paragraph::new(prompt_label)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(prompt_color)),
)
.fg(prompt_color),
bar_chunks[bud_idx],
);
f.render_widget(Clear, bar_chunks[cmp_idx]);
f.render_widget(
Paragraph::new(compaction_label)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(compaction_color)),
)
.fg(compaction_color),
bar_chunks[cmp_idx],
);
f.render_widget(Clear, bar_chunks[remote_idx]);
f.render_widget(
Paragraph::new(format!(" REMOTE: {}", git_label))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(git_color)),
)
.fg(git_color),
bar_chunks[remote_idx],
);
let usage_color = Color::Rgb(215, 125, 40);
f.render_widget(Clear, bar_chunks[tokens_idx]);
f.render_widget(
Paragraph::new(usage_text)
.block(Block::default().borders(Borders::ALL).fg(usage_color))
.fg(usage_color),
bar_chunks[tokens_idx],
);
let vram_ratio = app.gpu_state.ratio();
let vram_label = app.gpu_state.label();
let gpu_name = app.gpu_state.gpu_name();
let gauge_color = if vram_ratio > 0.85 {
Color::Red
} else if vram_ratio > 0.60 {
Color::Yellow
} else {
Color::Cyan
};
f.render_widget(Clear, bar_chunks[vram_idx]);
f.render_widget(
Gauge::default()
.block(
Block::default()
.borders(Borders::ALL)
.title(format!(" {} ", gpu_name)),
)
.gauge_style(Style::default().fg(gauge_color))
.ratio(vram_ratio)
.label(format!(" {} ", vram_label)), bar_chunks[vram_idx],
);
let input_style = if app.agent_running {
Style::default().fg(Color::DarkGray)
} else {
Style::default().fg(Color::Rgb(120, 70, 50))
};
let input_rect = chunks[1];
let title_area = input_title_area(input_rect);
let input_hint = render_input_title(app, title_area);
let input_block = Block::default()
.title(input_hint)
.borders(Borders::ALL)
.border_style(input_style)
.style(Style::default().bg(Color::Rgb(40, 25, 15)));
let inner_area = input_block.inner(input_rect);
f.render_widget(Clear, input_rect);
f.render_widget(input_block, input_rect);
f.render_widget(
Paragraph::new(app.input.as_str()).wrap(Wrap { trim: true }),
inner_area,
);
if !app.agent_running && inner_area.height > 0 {
let text_w = app.input.len() as u16;
let max_w = inner_area.width.saturating_sub(1);
let cursor_x = inner_area.x + text_w.min(max_w);
f.set_cursor(cursor_x, inner_area.y);
}
if let Some(approval) = &app.awaiting_approval {
let is_diff_preview = approval.diff.is_some();
let modal_h = if is_diff_preview { 70 } else { 50 };
let area = centered_rect(80, modal_h, f.size());
f.render_widget(Clear, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(4), Constraint::Min(0), ])
.split(area);
let (title_str, title_color) = if is_diff_preview {
(" DIFF PREVIEW — REVIEW BEFORE APPLYING ", Color::Yellow)
} else {
(" HIGH-RISK OPERATION REQUESTED ", Color::Red)
};
let header_text = vec![
Line::from(Span::styled(
title_str,
Style::default()
.fg(title_color)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
if is_diff_preview {
" [↑↓/jk/PgUp/PgDn] Scroll [Y] Apply [N] Skip "
} else {
" [Y] Approve [N] Decline "
},
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
)),
];
f.render_widget(
Paragraph::new(header_text)
.block(
Block::default()
.borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
.border_style(Style::default().fg(title_color)),
)
.alignment(ratatui::layout::Alignment::Center),
chunks[0],
);
let border_color = if is_diff_preview {
Color::Yellow
} else {
Color::Red
};
if let Some(diff_text) = &approval.diff {
let added = diff_text.lines().filter(|l| l.starts_with("+ ")).count();
let removed = diff_text.lines().filter(|l| l.starts_with("- ")).count();
let mut body_lines: Vec<Line> = vec![
Line::from(Span::styled(
format!(" {}", approval.display),
Style::default().fg(Color::Cyan),
)),
Line::from(vec![
Span::styled(
format!(" +{}", added),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" -{}", removed),
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
]),
Line::from(Span::raw("")),
];
for raw_line in diff_text.lines() {
let styled = if raw_line.starts_with("+ ") {
Line::from(Span::styled(
format!(" {}", raw_line),
Style::default().fg(Color::Green),
))
} else if raw_line.starts_with("- ") {
Line::from(Span::styled(
format!(" {}", raw_line),
Style::default().fg(Color::Red),
))
} else if raw_line.starts_with("---") || raw_line.starts_with("@@ ") {
Line::from(Span::styled(
format!(" {}", raw_line),
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
))
} else {
Line::from(Span::raw(format!(" {}", raw_line)))
};
body_lines.push(styled);
}
f.render_widget(
Paragraph::new(body_lines)
.block(
Block::default()
.borders(Borders::BOTTOM | Borders::LEFT | Borders::RIGHT)
.border_style(Style::default().fg(border_color)),
)
.scroll((approval.diff_scroll, 0)),
chunks[1],
);
} else {
let body_text = vec![
Line::from(Span::raw(format!(" Tool: {}", approval.tool_name))),
Line::from(Span::styled(
format!(" ❯ {}", approval.display),
Style::default().fg(Color::Cyan),
)),
];
f.render_widget(
Paragraph::new(body_text)
.block(
Block::default()
.borders(Borders::BOTTOM | Borders::LEFT | Borders::RIGHT)
.border_style(Style::default().fg(border_color)),
)
.wrap(Wrap { trim: true }),
chunks[1],
);
}
}
if let Some(review) = &app.active_review {
draw_diff_review(f, review);
}
if app.show_autocomplete && !app.autocomplete_suggestions.is_empty() {
let area = Rect {
x: chunks[1].x + 2,
y: chunks[1]
.y
.saturating_sub(app.autocomplete_suggestions.len() as u16 + 2),
width: chunks[1].width.saturating_sub(4),
height: app.autocomplete_suggestions.len() as u16 + 2,
};
f.render_widget(Clear, area);
let items: Vec<ListItem> = app
.autocomplete_suggestions
.iter()
.enumerate()
.map(|(i, s)| {
let style = if i == app.selected_suggestion {
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Gray)
};
ListItem::new(format!(" 📄 {}", s)).style(style)
})
.collect();
let hatch = List::new(items).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.title(format!(
" @ RESOLVER (Matching: {}) ",
app.autocomplete_filter
)),
);
f.render_widget(hatch, area);
if app.autocomplete_suggestions.len() >= 15 {
let more_area = Rect {
x: area.x + 2,
y: area.y + area.height - 1,
width: 20,
height: 1,
};
f.render_widget(
Paragraph::new("... (type to narrow) ").style(Style::default().fg(Color::DarkGray)),
more_area,
);
}
}
}
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let vert = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(vert[1])[1]
}
fn strip_ghost_prefix(s: &str) -> &str {
for prefix in &[
"Hematite: ",
"HEMATITE: ",
"Assistant: ",
"assistant: ",
"Okay, ",
"Hmm, ",
"Wait, ",
"Alright, ",
"Got it, ",
"Certainly, ",
"Sure, ",
"Understood, ",
] {
if s.to_lowercase().starts_with(&prefix.to_lowercase()) {
return &s[prefix.len()..];
}
}
s
}
fn first_n_chars(s: &str, n: usize) -> String {
let mut result = String::new();
let mut count = 0;
for c in s.chars() {
if count >= n {
result.push('…');
break;
}
if c == '\n' || c == '\r' {
result.push(' ');
} else if !c.is_control() {
result.push(c);
}
count += 1;
}
result
}
fn trim_vec_context(v: &mut Vec<ContextFile>, max: usize) {
while v.len() > max {
v.remove(0);
}
}
fn trim_vec(v: &mut Vec<String>, max: usize) {
while v.len() > max {
v.remove(0);
}
}
fn render_markdown_line(raw: &str) -> Vec<Line<'static>> {
let cleaned_ansi = strip_ansi(raw);
let trimmed = cleaned_ansi.trim();
if trimmed.is_empty() {
return vec![Line::raw("")];
}
let cleaned_owned = trimmed
.replace("<thought>", "")
.replace("</thought>", "")
.replace("<think>", "")
.replace("</think>", "");
let trimmed = cleaned_owned.trim();
if trimmed.is_empty() {
return vec![];
}
for (prefix, indent) in &[("### ", " "), ("## ", " "), ("# ", "")] {
if let Some(rest) = trimmed.strip_prefix(prefix) {
return vec![Line::from(vec![Span::styled(
format!("{}{}", indent, rest),
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)])];
}
}
if let Some(rest) = trimmed
.strip_prefix("> ")
.or_else(|| trimmed.strip_prefix(">"))
{
return vec![Line::from(vec![
Span::styled("| ", Style::default().fg(Color::DarkGray)),
Span::styled(
rest.to_string(),
Style::default()
.fg(Color::White)
.add_modifier(Modifier::DIM),
),
])];
}
if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
let rest = &trimmed[2..];
let mut spans = vec![Span::styled("* ", Style::default().fg(Color::Gray))];
spans.extend(inline_markdown(rest));
return vec![Line::from(spans)];
}
let spans = inline_markdown(trimmed);
vec![Line::from(spans)]
}
fn inline_markdown_core(text: &str) -> Vec<Span<'static>> {
let mut spans = Vec::new();
let mut remaining = text;
while !remaining.is_empty() {
if let Some(start) = remaining.find("**") {
let before = &remaining[..start];
if !before.is_empty() {
spans.push(Span::raw(before.to_string()));
}
let after_open = &remaining[start + 2..];
if let Some(end) = after_open.find("**") {
spans.push(Span::styled(
after_open[..end].to_string(),
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
));
remaining = &after_open[end + 2..];
continue;
}
}
if let Some(start) = remaining.find('`') {
let before = &remaining[..start];
if !before.is_empty() {
spans.push(Span::raw(before.to_string()));
}
let after_open = &remaining[start + 1..];
if let Some(end) = after_open.find('`') {
spans.push(Span::styled(
after_open[..end].to_string(),
Style::default().fg(Color::Yellow),
));
remaining = &after_open[end + 1..];
continue;
}
}
spans.push(Span::raw(remaining.to_string()));
break;
}
spans
}
fn inline_markdown(text: &str) -> Vec<Span<'static>> {
let mut spans = Vec::new();
let mut remaining = text;
while !remaining.is_empty() {
if let Some(start) = remaining.find("**") {
let before = &remaining[..start];
if !before.is_empty() {
spans.push(Span::raw(before.to_string()));
}
let after_open = &remaining[start + 2..];
if let Some(end) = after_open.find("**") {
spans.push(Span::styled(
after_open[..end].to_string(),
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
));
remaining = &after_open[end + 2..];
continue;
}
}
if let Some(start) = remaining.find('`') {
let before = &remaining[..start];
if !before.is_empty() {
spans.push(Span::raw(before.to_string()));
}
let after_open = &remaining[start + 1..];
if let Some(end) = after_open.find('`') {
spans.push(Span::styled(
after_open[..end].to_string(),
Style::default().fg(Color::Yellow),
));
remaining = &after_open[end + 1..];
continue;
}
}
spans.push(Span::raw(remaining.to_string()));
break;
}
spans
}
fn draw_splash<B: Backend>(terminal: &mut Terminal<B>) -> Result<(), Box<dyn std::error::Error>> {
let rust_color = Color::Rgb(180, 90, 50);
let logo_lines = vec![
"██╗ ██╗███████╗███╗ ███╗ █████╗ ████████╗██╗████████╗███████╗",
"██║ ██║██╔════╝████╗ ████║██╔══██╗╚══██╔══╝██║╚══██╔══╝██╔════╝",
"███████║█████╗ ██╔████╔██║███████║ ██║ ██║ ██║ █████╗ ",
"██╔══██║██╔══╝ ██║╚██╔╝██║██╔══██║ ██║ ██║ ██║ ██╔══╝ ",
"██║ ██║███████╗██║ ╚═╝ ██║██║ ██║ ██║ ██║ ██║ ███████╗",
"╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝",
];
let version = env!("CARGO_PKG_VERSION");
terminal.draw(|f| {
let area = f.size();
f.render_widget(
Block::default().style(Style::default().bg(Color::Black)),
area,
);
let content_height: u16 = 13;
let top_pad = area.height.saturating_sub(content_height) / 2;
let mut lines: Vec<Line<'static>> = Vec::new();
for _ in 0..top_pad {
lines.push(Line::raw(""));
}
for logo_line in &logo_lines {
lines.push(Line::from(Span::styled(
logo_line.to_string(),
Style::default().fg(rust_color).add_modifier(Modifier::BOLD),
)));
}
lines.push(Line::raw(""));
lines.push(Line::from(vec![Span::styled(
format!("v{}", version),
Style::default().fg(Color::DarkGray),
)]));
lines.push(Line::from(vec![Span::styled(
"Local AI coding harness + workstation assistant",
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::DIM),
)]));
lines.push(Line::from(vec![Span::styled(
"Developed by Ocean Bennett",
Style::default().fg(Color::Gray).add_modifier(Modifier::DIM),
)]));
lines.push(Line::raw(""));
lines.push(Line::raw(""));
lines.push(Line::from(vec![
Span::styled("[ ", Style::default().fg(rust_color)),
Span::styled(
"Press ENTER to start",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
Span::styled(" ]", Style::default().fg(rust_color)),
]));
let splash = Paragraph::new(lines).alignment(ratatui::layout::Alignment::Center);
f.render_widget(splash, area);
})?;
Ok(())
}
fn normalize_id(id: &str) -> String {
id.trim().to_uppercase()
}
fn filter_tui_noise(text: &str) -> String {
let cleaned = strip_ansi(text);
let mut lines = Vec::new();
for line in cleaned.lines() {
if CRLF_REGEX.is_match(line) {
continue;
}
if line.contains("Updating files:") && line.contains("%") {
continue;
}
let sanitized: String = line
.chars()
.filter(|c| !c.is_control() || *c == '\t')
.collect();
if sanitized.trim().is_empty() && !line.trim().is_empty() {
continue;
}
lines.push(normalize_tui_text(&sanitized));
}
lines.join("\n").trim().to_string()
}
fn normalize_tui_text(text: &str) -> String {
let mut normalized = text
.replace("ΓÇö", "-")
.replace("ΓÇô", "-")
.replace("…", "...")
.replace("✅", "[OK]")
.replace("🛠️", "")
.replace("—", "-")
.replace("–", "-")
.replace("…", "...")
.replace("•", "*")
.replace("✅", "[OK]")
.replace("🚨", "[!]");
normalized = normalized
.chars()
.map(|c| match c {
'\u{00A0}' => ' ',
'\u{2018}' | '\u{2019}' => '\'',
'\u{201C}' | '\u{201D}' => '"',
c if c.is_ascii() || c == '\n' || c == '\t' => c,
_ => ' ',
})
.collect();
let mut compacted = String::with_capacity(normalized.len());
let mut prev_space = false;
for ch in normalized.chars() {
if ch == ' ' {
if !prev_space {
compacted.push(ch);
}
prev_space = true;
} else {
compacted.push(ch);
prev_space = false;
}
}
compacted.trim().to_string()
}