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::truncation::safe_head;
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::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{
Block, Borders, Clear, List, ListItem, Paragraph, Scrollbar, ScrollbarOrientation,
ScrollbarState, Wrap,
},
Terminal,
};
use std::fmt::Write as _;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tokio::sync::mpsc::Receiver;
use walkdir::WalkDir;
fn provider_badge_prefix(provider_name: &str) -> &'static str {
match provider_name {
"LM Studio" => "LM",
"Ollama" => "OL",
_ => "AI",
}
}
fn provider_state_label(state: ProviderRuntimeState) -> &'static str {
match state {
ProviderRuntimeState::Booting => "booting",
ProviderRuntimeState::Live => "live",
ProviderRuntimeState::Degraded => "degraded",
ProviderRuntimeState::Recovering => "recovering",
ProviderRuntimeState::EmptyResponse => "empty_response",
ProviderRuntimeState::ContextWindow => "context_window",
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RuntimeIssueKind {
Healthy,
Booting,
Recovering,
NoModel,
Connectivity,
EmptyResponse,
ContextCeiling,
}
fn classify_runtime_issue(
provider_state: ProviderRuntimeState,
model_id: &str,
context_length: usize,
provider_summary: &str,
) -> RuntimeIssueKind {
if provider_state == ProviderRuntimeState::ContextWindow {
return RuntimeIssueKind::ContextCeiling;
}
if model_id.trim() == "no model loaded" {
return RuntimeIssueKind::NoModel;
}
if provider_state == ProviderRuntimeState::EmptyResponse {
return RuntimeIssueKind::EmptyResponse;
}
if provider_state == ProviderRuntimeState::Recovering {
return RuntimeIssueKind::Recovering;
}
if provider_state == ProviderRuntimeState::Booting
|| model_id.trim().is_empty()
|| model_id.trim() == "detecting..."
|| context_length == 0
{
return RuntimeIssueKind::Booting;
}
if provider_state == ProviderRuntimeState::Degraded {
let lower = provider_summary.to_ascii_lowercase();
if lower.contains("empty reply") || lower.contains("empty response") {
return RuntimeIssueKind::EmptyResponse;
}
if lower.contains("context ceiling") || lower.contains("context window") {
return RuntimeIssueKind::ContextCeiling;
}
return RuntimeIssueKind::Connectivity;
}
RuntimeIssueKind::Healthy
}
fn runtime_issue_kind(app: &App) -> RuntimeIssueKind {
classify_runtime_issue(
app.provider_state,
&app.model_id,
app.context_length,
&app.last_provider_summary,
)
}
fn runtime_issue_label(issue: RuntimeIssueKind) -> &'static str {
match issue {
RuntimeIssueKind::Healthy => "healthy",
RuntimeIssueKind::Booting => "booting",
RuntimeIssueKind::Recovering => "recovering",
RuntimeIssueKind::NoModel => "no_model",
RuntimeIssueKind::Connectivity => "connectivity",
RuntimeIssueKind::EmptyResponse => "empty_response",
RuntimeIssueKind::ContextCeiling => "context_ceiling",
}
}
fn runtime_issue_badge(issue: RuntimeIssueKind) -> (&'static str, Color) {
match issue {
RuntimeIssueKind::Healthy => ("OK", Color::Green),
RuntimeIssueKind::Booting => ("WAIT", Color::DarkGray),
RuntimeIssueKind::Recovering => ("RECV", Color::Cyan),
RuntimeIssueKind::NoModel => ("MOD", Color::Red),
RuntimeIssueKind::Connectivity => ("NET", Color::Red),
RuntimeIssueKind::EmptyResponse => ("EMP", Color::Red),
RuntimeIssueKind::ContextCeiling => ("CTX", Color::Yellow),
}
}
fn mcp_state_label(state: McpRuntimeState) -> &'static str {
match state {
McpRuntimeState::Unconfigured => "unconfigured",
McpRuntimeState::Healthy => "healthy",
McpRuntimeState::Degraded => "degraded",
McpRuntimeState::Failed => "failed",
}
}
fn runtime_configured_endpoint() -> String {
let config = crate::agent::config::load_config();
config
.api_url
.clone()
.unwrap_or_else(|| crate::agent::config::DEFAULT_LM_STUDIO_API_URL.to_string())
}
fn runtime_session_provider(app: &App) -> String {
if app.provider_name.trim().is_empty() {
"detecting".to_string()
} else {
app.provider_name.clone()
}
}
fn runtime_session_endpoint(app: &App, configured_endpoint: &str) -> String {
if app.provider_endpoint.trim().is_empty() {
configured_endpoint.to_string()
} else {
app.provider_endpoint.clone()
}
}
async fn format_provider_summary(app: &App) -> String {
let config = crate::agent::config::load_config();
let active_provider = runtime_session_provider(app);
let active_endpoint = runtime_session_endpoint(
app,
&config.api_url.clone().unwrap_or_else(|| {
crate::agent::config::default_api_url_for_provider(&active_provider).to_string()
}),
);
let saved = config
.api_url
.as_ref()
.map(|url| {
format!(
"{} ({})",
crate::agent::config::provider_label_for_api_url(url),
url
)
})
.unwrap_or_else(|| {
format!(
"default LM Studio ({})",
crate::agent::config::DEFAULT_LM_STUDIO_API_URL
)
});
let alternative = crate::runtime::detect_alternative_provider(&active_provider)
.await
.map(|(name, url)| format!("Reachable alternative: {} ({})", name, url))
.unwrap_or_else(|| "Reachable alternative: none detected".to_string());
format!(
"Active provider: {} | Session endpoint: {}\nSaved preference: {}\n{}\n\nUse /provider lmstudio, /provider ollama, /provider clear, or /provider <url>.\nProvider changes apply to new sessions; restart Hematite to switch this one.",
active_provider, active_endpoint, saved, alternative
)
}
fn runtime_fix_path(app: &App) -> String {
let session_provider = runtime_session_provider(app);
match runtime_issue_kind(app) {
RuntimeIssueKind::NoModel => {
if session_provider == "Ollama" {
format!(
"Shortest fix: pull or run a chat model in Ollama, then keep `api_url` on `{}`. Hematite cannot safely auto-load that model for you here.",
crate::agent::config::DEFAULT_OLLAMA_API_URL
)
} else {
format!(
"Shortest fix: load a coding model in LM Studio and keep the local server on `{}`. Hematite cannot safely auto-load that model for you here.",
crate::agent::config::DEFAULT_LM_STUDIO_API_URL
)
}
}
RuntimeIssueKind::ContextCeiling => {
format!(
"Shortest fix: narrow the request, let Hematite compact if needed, and run `/runtime fix` to refresh and re-check the active provider (`{}`).",
session_provider
)
}
RuntimeIssueKind::Connectivity | RuntimeIssueKind::Recovering => {
format!(
"Shortest fix: run `/runtime fix` to refresh and re-check the active provider (`{}`). If needed after that, use `/runtime provider <name>` and restart Hematite.",
session_provider
)
}
RuntimeIssueKind::EmptyResponse => {
"Shortest fix: run `/runtime fix` to refresh the active runtime, then retry once with a narrower grounded request if the provider keeps answering empty.".to_string()
}
RuntimeIssueKind::Booting => {
format!(
"Shortest fix: wait for the active provider (`{}`) to stabilize, then run `/runtime fix` or `/runtime refresh` if detection stays stale.",
session_provider
)
}
RuntimeIssueKind::Healthy => {
if app.embed_model_id.is_none() {
"Shortest fix: optional only — load a preferred embedding model if you want semantic file search."
.to_string()
} else {
"Shortest fix: none — runtime is healthy.".to_string()
}
}
}
}
async fn format_runtime_summary(app: &App) -> String {
let config = crate::agent::config::load_config();
let configured_endpoint = runtime_configured_endpoint();
let configured_provider =
crate::agent::config::provider_label_for_api_url(&configured_endpoint);
let session_provider = runtime_session_provider(app);
let session_endpoint = runtime_session_endpoint(app, &configured_endpoint);
let issue = runtime_issue_kind(app);
let coding_model = if app.model_id.trim().is_empty() {
"detecting...".to_string()
} else {
app.model_id.clone()
};
let embed_status = match app.embed_model_id.as_deref() {
Some(id) => format!("loaded ({})", id),
None => "not loaded".to_string(),
};
let semantic_status = if app.embed_model_id.is_some() || app.vein_embedded_count > 0 {
"ready"
} else {
"inactive"
};
let preferred_coding = crate::agent::config::preferred_coding_model(&config)
.unwrap_or_else(|| "none saved".to_string());
let preferred_embed = config
.embed_model
.clone()
.unwrap_or_else(|| "none saved".to_string());
let alternative = crate::runtime::detect_alternative_provider(&session_provider).await;
let alternative_line = alternative
.as_ref()
.map(|(name, url)| format!("Reachable alternative: {} ({})", name, url))
.unwrap_or_else(|| "Reachable alternative: none detected".to_string());
let provider_controls = if session_provider == "Ollama" {
"Provider controls: Ollama coding+embed load/unload is available here; `--ctx` maps to Ollama `num_ctx` for coding models."
} else {
"Provider controls: LM Studio coding+embed load/unload is available here; `--ctx` maps to LM Studio context length."
};
format!(
"Configured provider: {} ({})\nSession provider: {} ({})\nProvider state: {}\nPrimary issue: {}\nCoding model: {}\nPreferred coding model: {}\nCTX: {}\nEmbedding model: {}\nPreferred embed model: {}\nSemantic search: {} | embedded chunks: {}\nMCP: {}\n{}\n{}\n{}\n\nTry: /runtime explain, /runtime fix, /model status, /model list loaded",
configured_provider,
configured_endpoint,
session_provider,
session_endpoint,
provider_state_label(app.provider_state),
runtime_issue_label(issue),
coding_model,
preferred_coding,
app.context_length,
embed_status,
preferred_embed,
semantic_status,
app.vein_embedded_count,
mcp_state_label(app.mcp_state),
alternative_line,
provider_controls,
runtime_fix_path(app)
)
}
async fn format_runtime_explanation(app: &App) -> String {
let session_provider = runtime_session_provider(app);
let issue = runtime_issue_kind(app);
let coding_model = if app.model_id.trim().is_empty() {
"detecting...".to_string()
} else {
app.model_id.clone()
};
let semantic = if app.embed_model_id.is_some() || app.vein_embedded_count > 0 {
"semantic search is ready"
} else {
"semantic search is inactive"
};
let state_line = match app.provider_state {
ProviderRuntimeState::Live => format!(
"{} is live, Hematite sees model `{}`, and {}.",
session_provider, coding_model, semantic
),
ProviderRuntimeState::Booting => format!(
"{} is still booting or being detected. Hematite has not stabilized the runtime view yet.",
session_provider
),
ProviderRuntimeState::Recovering => format!(
"{} hit a runtime problem recently and Hematite is still trying to recover cleanly.",
session_provider
),
ProviderRuntimeState::Degraded => format!(
"{} is reachable but degraded, so responses may fail or stall until the runtime is stable again.",
session_provider
),
ProviderRuntimeState::EmptyResponse => format!(
"{} answered without useful content, which usually means the runtime needs attention even if the endpoint is still up.",
session_provider
),
ProviderRuntimeState::ContextWindow => format!(
"{} hit its active context ceiling, so the problem is prompt budget rather than basic connectivity.",
session_provider
),
};
let model_line = if coding_model == "no model loaded" {
"No coding model is loaded right now, so Hematite cannot do real model work until one is available.".to_string()
} else {
format!("The current coding model is `{}`.", coding_model)
};
let alternative = crate::runtime::detect_alternative_provider(&session_provider)
.await
.map(|(name, url)| format!("A reachable alternative exists: {} ({}).", name, url))
.unwrap_or_else(|| "No other reachable local runtime is currently detected.".to_string());
format!(
"Primary issue: {}\n{}\n{}\n{}\n{}",
runtime_issue_label(issue),
state_line,
model_line,
alternative,
runtime_fix_path(app)
)
}
async fn handle_runtime_fix(app: &mut App) {
let session_provider = runtime_session_provider(app);
let issue = runtime_issue_kind(app);
let alternative = crate::runtime::detect_alternative_provider(&session_provider).await;
if issue == RuntimeIssueKind::NoModel {
let mut message = runtime_fix_path(app);
if let Some((name, url)) = alternative {
let _ = write!(message,
"\nReachable alternative: {} ({}). Hematite will not switch providers silently; use `/runtime provider {}` and restart if you want that runtime instead.",
name,
url,
name.to_ascii_lowercase()
);
}
app.push_message("System", &message);
app.history_idx = None;
return;
}
if matches!(
issue,
RuntimeIssueKind::Booting
| RuntimeIssueKind::Recovering
| RuntimeIssueKind::Connectivity
| RuntimeIssueKind::EmptyResponse
| RuntimeIssueKind::ContextCeiling
) {
let _ = app
.user_input_tx
.try_send(UserTurn::text("/runtime-refresh"));
app.push_message("You", "/runtime fix");
app.provider_state = ProviderRuntimeState::Recovering;
app.agent_running = true;
let mut message = format!(
"Running the shortest safe fix now: refreshing the {} runtime profile and re-checking the active model/context window.",
session_provider
);
if let Some((name, url)) = alternative {
let _ = write!(message,
"\nReachable alternative: {} ({}). Hematite will stay on the current provider unless you explicitly switch with `/runtime provider {}` and restart.",
name,
url,
name.to_ascii_lowercase()
);
}
app.push_message("System", &message);
if issue == RuntimeIssueKind::EmptyResponse {
if let Some(fallback) =
build_runtime_fix_grounded_fallback(&app.recent_grounded_results)
{
app.push_message(
"System",
"The last turn already produced grounded tool output, so Hematite is surfacing a bounded fallback while the runtime refresh completes.",
);
app.push_message("Hematite", &fallback);
} else {
app.push_message(
"System",
"Runtime refresh requested successfully. The failed turn has no safe grounded fallback cached, so retry the turn once the runtime settles.",
);
}
}
app.history_idx = None;
return;
}
if issue == RuntimeIssueKind::Healthy && app.embed_model_id.is_none() {
app.push_message(
"System",
"Runtime is already healthy. The only missing piece is optional semantic search; load your preferred embedding model if you want embedding-backed file retrieval.",
);
app.history_idx = None;
return;
}
app.push_message(
"System",
"Runtime is already healthy. `/runtime fix` has nothing safe to change right now.",
);
app.history_idx = None;
}
async fn handle_provider_command(app: &mut App, arg_text: String) {
if arg_text.is_empty() || arg_text.eq_ignore_ascii_case("status") {
app.push_message("System", &format_provider_summary(app).await);
app.history_idx = None;
return;
}
let lower = arg_text.to_ascii_lowercase();
let result = match lower.as_str() {
"lmstudio" | "lm" => {
crate::agent::config::set_api_url_override(Some(
crate::agent::config::DEFAULT_LM_STUDIO_API_URL,
))
.map(|_| {
format!(
"Saved provider preference: LM Studio ({}) in `.hematite/settings.json`.\nRestart Hematite to switch this session.",
crate::agent::config::DEFAULT_LM_STUDIO_API_URL
)
})
}
"ollama" | "ol" => {
crate::agent::config::set_api_url_override(Some(
crate::agent::config::DEFAULT_OLLAMA_API_URL,
))
.map(|_| {
format!(
"Saved provider preference: Ollama ({}) in `.hematite/settings.json`.\nRestart Hematite to switch this session.",
crate::agent::config::DEFAULT_OLLAMA_API_URL
)
})
}
"clear" | "default" => crate::agent::config::set_api_url_override(None).map(|_| {
format!(
"Cleared the saved provider override. New sessions will fall back to LM Studio ({}) unless `--url` overrides it.\nRestart Hematite to switch this session.",
crate::agent::config::DEFAULT_LM_STUDIO_API_URL
)
}),
_ if lower.starts_with("http://") || lower.starts_with("https://") => {
crate::agent::config::set_api_url_override(Some(&arg_text)).map(|_| {
format!(
"Saved provider endpoint override: {} ({}) in `.hematite/settings.json`.\nRestart Hematite to switch this session.",
crate::agent::config::provider_label_for_api_url(&arg_text),
arg_text
)
})
}
_ => Err("Usage: /provider [status|lmstudio|ollama|clear|http://host:port/v1]".to_string()),
};
match result {
Ok(message) => app.push_message("System", &message),
Err(error) => app.push_message("System", &error),
}
app.history_idx = None;
}
pub struct PendingApproval {
pub display: String,
pub tool_name: String,
pub diff: Option<String>,
pub diff_scroll: u16,
pub mutation_label: Option<String>,
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::with_capacity(5);
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
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SidebarMode {
Hidden,
Compact,
Full,
}
fn sidebar_has_live_activity(app: &App) -> bool {
app.agent_running
|| !app.active_workers.is_empty()
|| app.active_review.is_some()
|| app.awaiting_approval.is_some()
}
fn select_sidebar_mode(width: u16, brief_mode: bool, live_activity: bool) -> SidebarMode {
if brief_mode || width < 100 {
SidebarMode::Hidden
} else if live_activity && width >= 145 {
SidebarMode::Full
} else {
SidebarMode::Compact
}
}
fn sidebar_mode(app: &App, width: u16) -> SidebarMode {
select_sidebar_mode(width, app.brief_mode, sidebar_has_live_activity(app))
}
fn build_compact_sidebar_lines(app: &App) -> Vec<Line<'static>> {
let mut lines = Vec::with_capacity(16);
let issue = runtime_issue_label(runtime_issue_kind(app));
let provider = if app.provider_name.trim().is_empty() {
"detecting".to_string()
} else {
app.provider_name.clone()
};
let model = if app.model_id.trim().is_empty() {
"detecting...".to_string()
} else {
app.model_id.clone()
};
lines.push(Line::from(vec![
Span::styled(" Runtime ", Style::default().fg(Color::Gray)),
Span::styled(
format!("{} / {}", provider, issue),
Style::default().fg(Color::White),
),
]));
lines.push(Line::from(vec![
Span::styled(" Model ", Style::default().fg(Color::Gray)),
Span::styled(model, Style::default().fg(Color::White)),
]));
lines.push(Line::from(vec![
Span::styled(" Flow ", Style::default().fg(Color::Gray)),
Span::styled(
format!("{} | CTX {}", app.workflow_mode, app.context_length),
Style::default().fg(Color::White),
),
]));
let context_source = if app.active_context.is_empty() {
default_active_context()
} else {
app.active_context.clone()
};
if !context_source.is_empty() {
lines.push(Line::raw(""));
lines.push(Line::from(Span::styled(
"Files",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::DIM),
)));
for file in context_source.iter().take(3) {
lines.push(Line::from(vec![
Span::styled("· ", Style::default().fg(Color::DarkGray)),
Span::styled(file.path.clone(), Style::default().fg(Color::White)),
]));
}
}
let mut recent_events: Vec<String> = Vec::with_capacity(5);
if sidebar_has_live_activity(app) {
let label = if app.thinking { "Reasoning" } else { "Working" };
let dots = ".".repeat((app.tick_count % 4) as usize + 1);
recent_events.push(format!("{label}{dots}"));
}
recent_events.extend(app.specular_logs.iter().rev().take(4).cloned());
if !recent_events.is_empty() {
lines.push(Line::raw(""));
lines.push(Line::from(Span::styled(
"Signals",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::DIM),
)));
for event in recent_events.into_iter().take(4) {
lines.push(Line::from(vec![
Span::styled("· ", Style::default().fg(Color::DarkGray)),
Span::styled(event, Style::default().fg(Color::Gray)),
]));
}
}
lines
}
fn sidebar_signal_rows(app: &App) -> Vec<(String, Color)> {
let mut rows = Vec::with_capacity(4);
if !app.last_operator_checkpoint_summary.trim().is_empty() {
rows.push((
format!(
"STATE: {}",
first_n_chars(&app.last_operator_checkpoint_summary, 96)
),
Color::Yellow,
));
}
if !app.last_recovery_recipe_summary.trim().is_empty() {
rows.push((
format!(
"RECOVERY: {}",
first_n_chars(&app.last_recovery_recipe_summary, 96)
),
Color::Cyan,
));
}
if !app.last_provider_summary.trim().is_empty() {
rows.push((
format!(
"PROVIDER: {}",
first_n_chars(&app.last_provider_summary, 96)
),
Color::Gray,
));
}
if !app.last_mcp_summary.trim().is_empty() {
rows.push((
format!("MCP: {}", first_n_chars(&app.last_mcp_summary, 96)),
Color::Gray,
));
}
rows
}
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 stop_requested: 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_name: String,
provider_endpoint: String,
embed_model_id: Option<String>,
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 autocomplete_alias_active: bool,
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>,
pub teleported_from: Option<String>,
pub nav_list: Vec<std::path::PathBuf>,
pub auto_approve_session: bool,
pub task_start_time: Option<std::time::Instant>,
pub tool_started_at: HashMap<String, std::time::Instant>,
pub recent_grounded_results: Vec<(String, String)>,
}
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();
self.embed_model_id = None;
}
pub fn clear_pending_attachments(&mut self) {
self.attached_context = None;
self.attached_image = None;
}
pub fn clear_grounded_recovery_cache(&mut self) {
self.recent_grounded_results.clear();
}
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() > 500 {
self.messages_raw.remove(0);
}
self.rebuild_formatted_messages();
if self.messages.len() > 8192 {
let to_drain = self.messages.len() - 8192;
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);
self.rebuild_formatted_messages();
}
}
}
fn sync_task_start_time(&mut self) {
self.task_start_time = synced_task_start_time(self.agent_running, self.task_start_time);
}
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 header_spans(&self, speaker: &str, is_last: bool) -> Vec<Span<'static>> {
let graphite = Color::Rgb(95, 95, 95);
let steel = Color::Rgb(110, 110, 110);
let ice = Color::Rgb(145, 205, 255);
let slate = Color::Rgb(42, 42, 42);
let pulse_on = self.tick_count.is_multiple_of(2);
match speaker {
"You" => vec![
Span::styled(" [", Style::default().fg(Color::DarkGray)),
Span::styled(
"YOU",
Style::default()
.fg(Color::Black)
.bg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::styled("] ", Style::default().fg(Color::DarkGray)),
],
"Hematite" => {
let live_label = if is_last && (self.agent_running || self.thinking) {
if pulse_on {
"LIVE"
} else {
"FLOW"
}
} else {
"HEMATITE"
};
vec![
Span::styled(" [", Style::default().fg(Color::DarkGray)),
Span::styled(
live_label,
Style::default()
.fg(if is_last { ice } else { steel })
.bg(slate)
.add_modifier(Modifier::BOLD),
),
Span::styled("] ", Style::default().fg(Color::DarkGray)),
]
}
"System" => vec![
Span::styled(" [", Style::default().fg(Color::DarkGray)),
Span::styled(
"SYSTEM",
Style::default()
.fg(graphite)
.bg(Color::Rgb(28, 28, 28))
.add_modifier(Modifier::BOLD),
),
Span::styled("] ", Style::default().fg(Color::DarkGray)),
],
"Tool" => vec![
Span::styled(" [", Style::default().fg(Color::DarkGray)),
Span::styled(
"TOOLS",
Style::default()
.fg(Color::Cyan)
.bg(Color::Rgb(28, 34, 38))
.add_modifier(Modifier::BOLD),
),
Span::styled("] ", Style::default().fg(Color::DarkGray)),
],
_ => vec![Span::styled(
format!("[{}] ", speaker),
Style::default().fg(graphite).add_modifier(Modifier::BOLD),
)],
}
}
fn tool_timeline_header(&self, label: &str, color: Color) -> Line<'static> {
Line::from(vec![
Span::styled(" o", Style::default().fg(Color::DarkGray)),
Span::styled("----", Style::default().fg(Color::Rgb(52, 52, 52))),
Span::styled(
format!(" {} ", label),
Style::default()
.fg(color)
.bg(Color::Rgb(28, 28, 28))
.add_modifier(Modifier::BOLD),
),
])
}
fn tool_timeline_header_with_meta(
&self,
label: &str,
color: Color,
elapsed: Option<&str>,
) -> Line<'static> {
let mut spans = vec![
Span::styled(" o", Style::default().fg(Color::DarkGray)),
Span::styled("----", Style::default().fg(Color::Rgb(52, 52, 52))),
Span::styled(
format!(" {} ", label),
Style::default()
.fg(color)
.bg(Color::Rgb(28, 28, 28))
.add_modifier(Modifier::BOLD),
),
];
if let Some(elapsed) = elapsed.filter(|elapsed| !elapsed.trim().is_empty()) {
spans.push(Span::raw(" "));
spans.push(Span::styled(
format!(" {} ", elapsed),
Style::default()
.fg(Color::Rgb(210, 210, 210))
.bg(Color::Rgb(36, 36, 36))
.add_modifier(Modifier::BOLD),
));
}
Line::from(spans)
}
fn format_message(&self, speaker: &str, content: &str, is_last: bool) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let cleaned_str = crate::agent::inference::strip_think_blocks(content);
let trimmed = cleaned_str.trim();
let cleaned = String::from(strip_ghost_prefix(trimmed));
let mut is_first = true;
let mut in_code_block = false;
for raw_line in cleaned.lines() {
let owned_line = String::from(raw_line);
if !is_first && raw_line.trim().is_empty() {
lines.push(Line::raw(""));
continue;
}
if raw_line.trim_start().starts_with("```") {
in_code_block = !in_code_block;
let lang = raw_line
.trim_start()
.strip_prefix("```")
.unwrap_or("")
.trim();
let (border, label) = if in_code_block {
(
" ┌── ",
format!(" {} ", if lang.is_empty() { "code" } else { lang }),
)
} else {
(" └──", String::new())
};
lines.push(Line::from(vec![
Span::styled(
border,
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::DIM),
),
Span::styled(
label,
Style::default()
.fg(Color::Cyan)
.bg(Color::Rgb(40, 40, 40))
.add_modifier(Modifier::BOLD),
),
]));
is_first = false;
continue;
}
if in_code_block {
lines.push(Line::from(vec![
Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
Span::styled(owned_line, Style::default().fg(Color::Rgb(200, 200, 160))),
]));
is_first = false;
continue;
}
if speaker == "System" && (raw_line.contains(" +") || raw_line.contains(" -")) {
let mut spans: Vec<Span<'static>> = if is_first {
self.header_spans(speaker, is_last)
} else {
vec![Span::raw(" ")]
};
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, gutter_style, sign) = if raw_line.starts_with("-") {
(
Style::default()
.fg(Color::Rgb(255, 200, 200))
.bg(Color::Rgb(60, 20, 20)),
Style::default().fg(Color::Red).bg(Color::Rgb(40, 15, 15)),
"-",
)
} else if raw_line.starts_with("+") {
(
Style::default()
.fg(Color::Rgb(200, 255, 200))
.bg(Color::Rgb(20, 50, 30)),
Style::default().fg(Color::Green).bg(Color::Rgb(15, 30, 20)),
"+",
)
} else {
(
Style::default().fg(Color::Cyan).add_modifier(Modifier::DIM),
Style::default().fg(Color::DarkGray),
"⋮",
)
};
let content = if raw_line.starts_with("@@") {
owned_line
} else {
String::from(&raw_line[1..])
};
lines.push(Line::from(vec![
Span::styled(format!(" {} ", sign), gutter_style),
Span::styled(content, line_style),
]));
is_first = false;
continue;
}
if speaker == "Tool" {
let border_style = Style::default().fg(Color::Rgb(60, 60, 60));
if raw_line.starts_with("( )") {
lines.push(self.tool_timeline_header("REQUEST", Color::Cyan));
lines.push(Line::from(vec![
Span::styled(" | ", border_style),
Span::styled(
String::from(&raw_line[4..]),
Style::default().fg(Color::Rgb(155, 220, 255)),
),
]));
} else if raw_line.starts_with("[v]") || raw_line.starts_with("[x]") {
let is_success = raw_line.starts_with("[v]");
let (status, color) = if is_success {
("SUCCESS", Color::Green)
} else {
("FAILED", Color::Red)
};
let payload = raw_line[4..].trim();
let (summary, preview) = if let Some((left, right)) = payload.split_once(" → ")
{
(left.trim(), Some(right))
} else {
(payload, None)
};
let (summary, elapsed) = extract_tool_elapsed_chip(summary);
lines.push(self.tool_timeline_header_with_meta(
status,
color,
elapsed.as_deref(),
));
let mut detail_spans = vec![
Span::styled(" | ", border_style),
Span::styled(
summary,
Style::default().fg(if is_success {
Color::Rgb(145, 215, 145)
} else {
Color::Rgb(255, 175, 175)
}),
),
];
if let Some(preview) = preview {
detail_spans
.push(Span::styled(" → ", Style::default().fg(Color::DarkGray)));
detail_spans.push(Span::styled(
preview.to_string(),
Style::default().fg(Color::DarkGray),
));
}
lines.push(Line::from(detail_spans));
} else if raw_line.starts_with("┌──") {
lines.push(Line::from(vec![
Span::styled(" ┌──", border_style),
Span::styled(
String::from(&raw_line[3..]),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
]));
} else if raw_line.starts_with("└─") {
let status_color = if raw_line.contains("SUCCESS") {
Color::Green
} else {
Color::Red
};
lines.push(Line::from(vec![
Span::styled(" └─", border_style),
Span::styled(
String::from(&raw_line[3..]),
Style::default()
.fg(status_color)
.add_modifier(Modifier::BOLD),
),
]));
} else if raw_line.starts_with("│") {
lines.push(Line::from(vec![
Span::styled(" │", border_style),
Span::styled(
String::from(&raw_line[1..]),
Style::default().fg(Color::DarkGray),
),
]));
} else {
lines.push(Line::from(vec![
Span::styled(" │ ", border_style),
Span::styled(owned_line, Style::default().fg(Color::DarkGray)),
]));
}
is_first = false;
continue;
}
let mut spans = if is_first {
self.header_spans(speaker, is_last)
} else {
vec![Span::raw(" ")]
};
if speaker == "Hematite" {
if is_first {
spans.push(Span::styled(" ", Style::default().fg(Color::DarkGray)));
}
spans.extend(inline_markdown_core(raw_line));
} else {
spans.push(Span::raw(owned_line));
}
lines.push(Line::from(spans));
is_first = false;
}
lines
}
pub fn update_autocomplete(&mut self) {
self.autocomplete_alias_active = false;
let (scan_root, query) = if let Some(pos) = self.input.rfind('@') {
let fragment = &self.input[pos + 1..];
let upper = fragment.to_uppercase();
let mut resolved_root = crate::tools::file_ops::workspace_root();
let mut final_query = fragment;
let tokens = [
"DESKTOP",
"DOWNLOADS",
"DOCUMENTS",
"PICTURES",
"VIDEOS",
"MUSIC",
"HOME",
];
for token in tokens {
if upper.starts_with(token) {
let candidate =
crate::tools::file_ops::resolve_candidate(&format!("@{}", token));
if candidate.exists() {
resolved_root = candidate;
self.autocomplete_alias_active = true;
if let Some(slash_pos) = fragment.find('/') {
final_query = &fragment[slash_pos + 1..];
} else {
final_query = ""; }
break;
}
}
}
(resolved_root, final_query.to_lowercase())
} else {
(crate::tools::file_ops::workspace_root(), "".to_string())
};
self.autocomplete_filter = query.clone();
let mut matches = Vec::new();
let mut total_found = 0;
let noise = [
"node_modules",
"target",
".git",
".next",
".venv",
"venv",
"env",
"bin",
"obj",
"dist",
"vendor",
"__pycache__",
"AppData",
"Local",
"Roaming",
"Application Data",
];
for entry in WalkDir::new(&scan_root)
.max_depth(4) .into_iter()
.filter_entry(|e| {
let name = e.file_name().to_string_lossy();
!name.starts_with('.') && !noise.iter().any(|&n| name.eq_ignore_ascii_case(n))
})
.flatten()
{
let is_file = entry.file_type().is_file();
let is_dir = entry.file_type().is_dir();
if (is_file || is_dir) && entry.path() != scan_root {
let path = entry
.path()
.strip_prefix(&scan_root)
.unwrap_or(entry.path());
let mut path_str = path.to_string_lossy().to_string();
if is_dir {
path_str.push('/');
}
if path_str.to_lowercase().contains(&query) || query.is_empty() {
total_found += 1;
if matches.len() < 15 {
matches.push(path_str);
}
}
}
if total_found > 60 {
break;
} }
matches.sort_by(|a, b| {
let a_is_dir = a.ends_with('/');
let b_is_dir = b.ends_with('/');
let a_ext = a.split('.').next_back().unwrap_or("");
let b_ext = b.split('.').next_back().unwrap_or("");
let a_is_src = a_ext == "rs" || a_ext == "md";
let b_is_src = b_ext == "rs" || b_ext == "md";
let a_score = if a_is_dir {
2
} else if a_is_src {
1
} else {
0
};
let b_score = if b_is_dir {
2
} else if b_is_src {
1
} else {
0
};
b_score.cmp(&a_score)
});
self.autocomplete_suggestions = matches;
self.selected_suggestion = self
.selected_suggestion
.min(self.autocomplete_suggestions.len().saturating_sub(1));
}
pub fn apply_autocomplete_selection(&mut self, selection: &str) {
if let Some(pos) = self.input.rfind('@') {
if self.autocomplete_alias_active {
let after_at = &self.input[pos + 1..];
if let Some(slash_pos) = after_at.rfind('/') {
self.input.truncate(pos + 1 + slash_pos + 1);
} else {
self.input.truncate(pos + 1);
}
} else {
self.input.truncate(pos);
}
self.input.push_str(selection);
self.show_autocomplete = false;
}
}
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 hdir = crate::tools::file_ops::hematite_dir();
let plan_path = hdir.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 = hdir.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');
}
let _ = writeln!(
out,
"Tokens: {} | Cost: ${:.4}",
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 = crate::tools::file_ops::hematite_dir().join("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);
}
}
fn transcript_snapshot_for_copy(&self) -> (Vec<(String, String)>, bool) {
if !self.agent_running {
return (self.messages_raw.clone(), false);
}
if let Some(last_user_idx) = self
.messages_raw
.iter()
.rposition(|(speaker, _)| speaker == "You")
{
(
self.messages_raw[..=last_user_idx].to_vec(),
last_user_idx + 1 < self.messages_raw.len(),
)
} else {
(Vec::new(), !self.messages_raw.is_empty())
}
}
pub fn copy_transcript_to_clipboard(&self) {
let (snapshot, omitted_inflight) = self.transcript_snapshot_for_copy();
let mut history = snapshot
.iter()
.filter(|(speaker, content)| !should_skip_transcript_copy_entry(speaker, content))
.map(|m| format!("[{}] {}\n", m.0, m.1))
.collect::<String>();
if omitted_inflight {
history.push_str(
"[System] Current turn is still in progress; in-flight Hematite output was omitted from this clipboard snapshot.\n",
);
}
history.push_str("\nSession Stats\n");
let _ = writeln!(history, "Tokens: {}", self.total_tokens);
let _ = writeln!(history, "Cost: ${:.4}", self.current_session_cost);
copy_text_to_clipboard(&history);
}
pub fn copy_clean_transcript_to_clipboard(&self) {
let (snapshot, omitted_inflight) = self.transcript_snapshot_for_copy();
let mut history = snapshot
.iter()
.filter(|(speaker, content)| !should_skip_transcript_copy_entry(speaker, content))
.map(|m| format!("[{}] {}\n", m.0, m.1))
.collect::<String>();
if omitted_inflight {
history.push_str(
"[System] Current turn is still in progress; in-flight Hematite output was omitted from this clipboard snapshot.\n",
);
}
history.push_str("\nSession Stats\n");
let _ = writeln!(history, "Tokens: {}", self.total_tokens);
let _ = writeln!(history, "Cost: ${:.4}", 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 should_accept_autocomplete_on_enter(alias_active: bool, filter: &str) -> bool {
if alias_active && filter.trim().is_empty() {
return false;
}
true
}
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 synced_task_start_time(
active: bool,
current: Option<std::time::Instant>,
) -> Option<std::time::Instant> {
match (active, current) {
(true, None) => Some(std::time::Instant::now()),
(false, Some(_)) => None,
(_, existing) => existing,
}
}
fn scroll_specular_up(app: &mut App, amount: u16) {
app.specular_auto_scroll = false;
app.specular_scroll = app.specular_scroll.saturating_sub(amount);
}
fn scroll_specular_down(app: &mut App, amount: u16) {
app.specular_auto_scroll = false;
app.specular_scroll = app.specular_scroll.saturating_add(amount);
}
fn follow_live_specular(app: &mut App) {
app.specular_auto_scroll = true;
app.specular_scroll = 0;
}
fn format_tool_elapsed(elapsed: std::time::Duration) -> String {
if elapsed.as_millis() < 1_000 {
format!("{}ms", elapsed.as_millis())
} else {
format!("{:.1}s", elapsed.as_secs_f64())
}
}
fn extract_tool_elapsed_chip(summary: &str) -> (String, Option<String>) {
let trimmed = summary.trim();
if let Some((head, tail)) = trimmed.rsplit_once(" [") {
if let Some(elapsed) = tail.strip_suffix(']') {
if !elapsed.is_empty()
&& elapsed
.chars()
.all(|ch| ch.is_ascii_digit() || ch == '.' || ch == 'm' || ch == 's')
{
return (head.trim().to_string(), Some(elapsed.to_string()));
}
}
}
(trimmed.to_string(), None)
}
fn should_capture_grounded_tool_output(name: &str, is_error: bool) -> bool {
!is_error && matches!(name, "research_web" | "fetch_docs")
}
fn looks_like_markup_payload(result: &str) -> bool {
let lower = result
.chars()
.take(256)
.collect::<String>()
.to_ascii_lowercase();
lower.contains("<!doctype")
|| lower.contains("<html")
|| lower.contains("<body")
|| lower.contains("<meta ")
}
fn build_runtime_fix_grounded_fallback(results: &[(String, String)]) -> Option<String> {
if results.is_empty() {
return None;
}
let mut sections = Vec::with_capacity(results.len());
for (name, result) in results.iter().filter(|(name, _)| name == "research_web") {
sections.push(format!(
"[{}]\n{}",
name,
first_n_chars(result, 1800).trim()
));
}
if sections.is_empty() {
for (name, result) in results
.iter()
.filter(|(name, result)| name == "fetch_docs" && !looks_like_markup_payload(result))
{
sections.push(format!(
"[{}]\n{}",
name,
first_n_chars(result, 1600).trim()
));
}
}
if sections.is_empty() {
if let Some((name, result)) = results.last() {
sections.push(format!(
"[{}]\n{}",
name,
first_n_chars(result, 1200).trim()
));
}
}
if sections.is_empty() {
None
} else {
Some(format!(
"The model returned empty content after grounded tool work. Hematite is surfacing the latest verified tool output directly.\n\n{}",
sections.join("\n\n")
))
}
}
#[cfg(test)]
mod tests {
use super::{
build_runtime_fix_grounded_fallback, classify_runtime_issue, extract_tool_elapsed_chip,
format_tool_elapsed, make_animated_sparkline_gauge, provider_badge_prefix,
select_fitting_variant, select_sidebar_mode, should_accept_autocomplete_on_enter,
synced_task_start_time, RuntimeIssueKind, SidebarMode,
};
use crate::agent::inference::ProviderRuntimeState;
#[test]
fn tool_elapsed_chip_extracts_cleanly_from_summary() {
assert_eq!(
extract_tool_elapsed_chip("research_web [842ms]"),
("research_web".to_string(), Some("842ms".to_string()))
);
assert_eq!(
extract_tool_elapsed_chip("read_file"),
("read_file".to_string(), None)
);
}
#[test]
fn tool_elapsed_formats_compact_runtime_durations() {
assert_eq!(
format_tool_elapsed(std::time::Duration::from_millis(842)),
"842ms"
);
assert_eq!(
format_tool_elapsed(std::time::Duration::from_millis(1520)),
"1.5s"
);
}
#[test]
fn enter_submits_bare_alias_root_instead_of_selecting_first_child() {
assert!(!should_accept_autocomplete_on_enter(true, ""));
assert!(!should_accept_autocomplete_on_enter(true, " "));
}
#[test]
fn enter_still_accepts_narrowed_alias_matches() {
assert!(should_accept_autocomplete_on_enter(true, "web"));
assert!(should_accept_autocomplete_on_enter(false, ""));
}
#[test]
fn provider_badge_prefix_tracks_runtime_provider() {
assert_eq!(provider_badge_prefix("LM Studio"), "LM");
assert_eq!(provider_badge_prefix("Ollama"), "OL");
assert_eq!(provider_badge_prefix("Other"), "AI");
}
#[test]
fn runtime_issue_prefers_no_model_over_live_state() {
assert_eq!(
classify_runtime_issue(ProviderRuntimeState::Live, "no model loaded", 32000, ""),
RuntimeIssueKind::NoModel
);
}
#[test]
fn runtime_issue_distinguishes_context_ceiling() {
assert_eq!(
classify_runtime_issue(
ProviderRuntimeState::ContextWindow,
"qwen/qwen3.5-9b",
32000,
"LM context ceiling hit."
),
RuntimeIssueKind::ContextCeiling
);
}
#[test]
fn runtime_issue_maps_generic_degraded_state_to_connectivity_signal() {
assert_eq!(
classify_runtime_issue(
ProviderRuntimeState::Degraded,
"qwen/qwen3.5-9b",
32000,
"LM Studio degraded and did not recover cleanly; operator action is now required."
),
RuntimeIssueKind::Connectivity
);
}
#[test]
fn sidebar_mode_hides_in_brief_or_narrow_layouts() {
assert_eq!(select_sidebar_mode(99, false, true), SidebarMode::Hidden);
assert_eq!(select_sidebar_mode(160, true, true), SidebarMode::Hidden);
}
#[test]
fn sidebar_mode_only_uses_full_chrome_for_live_wide_sessions() {
assert_eq!(select_sidebar_mode(130, false, false), SidebarMode::Compact);
assert_eq!(select_sidebar_mode(130, false, true), SidebarMode::Compact);
assert_eq!(select_sidebar_mode(160, false, true), SidebarMode::Full);
}
#[test]
fn task_timer_starts_when_activity_begins() {
assert!(synced_task_start_time(true, None).is_some());
}
#[test]
fn task_timer_clears_when_activity_ends() {
assert!(synced_task_start_time(false, Some(std::time::Instant::now())).is_none());
}
#[test]
fn fitting_variant_picks_longest_string_that_fits() {
let variants = vec![
"this variant is too wide".to_string(),
"fits nicely".to_string(),
"tiny".to_string(),
];
assert_eq!(select_fitting_variant(&variants, 12), "fits nicely");
assert_eq!(select_fitting_variant(&variants, 4), "tiny");
}
#[test]
fn animated_gauge_preserves_requested_width() {
let gauge = make_animated_sparkline_gauge(0.42, 12, 7);
assert_eq!(gauge.chars().count(), 12);
assert!(gauge.contains('█') || gauge.contains('▓') || gauge.contains('▒'));
}
#[test]
fn runtime_fix_grounded_fallback_prefers_search_results_over_html_fetch() {
let fallback = build_runtime_fix_grounded_fallback(&[
(
"fetch_docs".to_string(),
"<!doctype html><html><body>raw page shell</body></html>".to_string(),
),
(
"research_web".to_string(),
"Search results for: uefn toolbelt\n1. GitHub repo\n2. Epic forum thread"
.to_string(),
),
])
.expect("fallback");
assert!(fallback.contains("Search results for: uefn toolbelt"));
assert!(!fallback.contains("<!doctype html>"));
}
#[test]
fn runtime_fix_grounded_fallback_returns_none_without_grounded_results() {
assert!(build_runtime_fix_grounded_fallback(&[]).is_none());
}
}
#[cfg(windows)]
fn get_console_pixel_rect() -> Option<(i32, i32, i32, i32)> {
let script = concat!(
"Add-Type -TypeDefinition '",
"using System;using System.Runtime.InteropServices;",
"public class WG{",
"[DllImport(\"kernel32\")]public static extern IntPtr GetConsoleWindow();",
"[DllImport(\"user32\")]public static extern bool GetWindowRect(IntPtr h,out RECT r);",
"[StructLayout(LayoutKind.Sequential)]public struct RECT{public int L,T,R,B;}}",
"';",
"$h=[WG]::GetConsoleWindow();$r=New-Object WG+RECT;",
"[WG]::GetWindowRect($h,[ref]$r)|Out-Null;",
"Write-Output \"$($r.L) $($r.T) $($r.R-$r.L) $($r.B-$r.T)\""
);
let out = std::process::Command::new("powershell.exe")
.args(["-NoProfile", "-NonInteractive", "-Command", script])
.output()
.ok()?;
let s = String::from_utf8_lossy(&out.stdout);
let parts: Vec<i32> = s
.split_whitespace()
.filter_map(|v| v.trim().parse().ok())
.collect();
if parts.len() >= 4 {
Some((parts[0], parts[1], parts[2], parts[3]))
} else {
None
}
}
#[cfg(windows)]
fn get_console_close_target_pid_sync() -> Option<u32> {
let pid = std::process::id();
let script = format!(
r#"
$current = [uint32]{pid}
$seen = New-Object 'System.Collections.Generic.HashSet[uint32]'
$shell_pattern = '^(cmd|powershell|pwsh|bash|sh|wsl|ubuntu|debian|kali|arch)$'
$skip_pattern = '^(WindowsTerminal|wt|OpenConsole|conhost)$'
$fallback = $null
$found = $false
while ($current -gt 0 -and $seen.Add($current)) {{
$proc = Get-CimInstance Win32_Process -Filter "ProcessId=$current" -ErrorAction SilentlyContinue
if (-not $proc) {{ break }}
$parent = [uint32]$proc.ParentProcessId
if ($parent -le 0) {{ break }}
$parent_proc = Get-Process -Id $parent -ErrorAction SilentlyContinue
if ($parent_proc) {{
$name = $parent_proc.ProcessName
if ($name -match $shell_pattern) {{
$found = $true
Write-Output $parent
break
}}
if (-not $fallback -and $name -notmatch $skip_pattern) {{
$fallback = $parent
}}
}}
$current = $parent
}}
if (-not $found -and $fallback) {{ Write-Output $fallback }}
"#
);
let out = std::process::Command::new("powershell.exe")
.args(["-NoProfile", "-NonInteractive", "-Command", &script])
.output()
.ok()?;
String::from_utf8_lossy(&out.stdout).trim().parse().ok()
}
#[cfg(windows)]
fn spawn_dive_in_terminal(path: &str) {
let pid = std::process::id();
let current_dir = std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let close_target_pid = get_console_close_target_pid_sync().unwrap_or(0);
let (px, py, pw, ph) = get_console_pixel_rect().unwrap_or((50, 50, 1100, 750));
let bat_path = std::env::temp_dir().join("hematite_teleport.bat");
let bat_content = format!(
"@echo off\r\ncd /d \"{p}\"\r\nhematite --no-splash --teleported-from \"{o}\"\r\n",
p = path.replace('"', ""),
o = current_dir.replace('"', ""),
);
if std::fs::write(&bat_path, bat_content).is_err() {
return;
}
let bat_str = bat_path.to_string_lossy().to_string();
let bat_ps = bat_str.replace('\'', "''");
let script = format!(
r#"
Add-Type -TypeDefinition @'
using System; using System.Runtime.InteropServices;
public class WM {{ [DllImport("user32")] public static extern bool MoveWindow(IntPtr h,int x,int y,int w,int ht,bool b); }}
'@
$proc = Start-Process cmd.exe -ArgumentList @('/k', '"{bat}"') -PassThru
$deadline = (Get-Date).AddSeconds(8)
while ((Get-Date) -lt $deadline -and $proc.MainWindowHandle -eq [IntPtr]::Zero) {{ Start-Sleep -Milliseconds 100 }}
if ($proc.MainWindowHandle -ne [IntPtr]::Zero) {{
[WM]::MoveWindow($proc.MainWindowHandle, {px}, {py}, {pw}, {ph}, $true) | Out-Null
}}
Wait-Process -Id {pid} -ErrorAction SilentlyContinue
if ({close_pid} -gt 0) {{
Stop-Process -Id {close_pid} -Force -ErrorAction SilentlyContinue
}}
"#,
bat = bat_ps,
px = px,
py = py,
pw = pw,
ph = ph,
pid = pid,
close_pid = close_target_pid,
);
let _ = std::process::Command::new("powershell.exe")
.args([
"-NoProfile",
"-NonInteractive",
"-WindowStyle",
"Hidden",
"-Command",
&script,
])
.spawn();
}
#[cfg(not(windows))]
fn spawn_dive_in_terminal(_path: &str) {}
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 is_immediate_local_command(input: &str) -> bool {
matches!(
input.trim().to_ascii_lowercase().as_str(),
"/copy" | "/copy-last" | "/copy-clean" | "/copy2"
)
}
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 == "Exact session transcript copied to clipboard (includes help/system output)."
|| content == "Clean chat transcript copied to clipboard (skips help/debug boilerplate)."
|| content == "Latest Hematite reply 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,
}
#[derive(Clone)]
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(5), ])
.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::with_capacity(6);
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::with_capacity(6);
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_variants(app: &App) -> Vec<String> {
let voice_status = if app.voice_manager.is_enabled() {
"ON"
} else {
"OFF"
};
let approvals_status = if app.yolo_mode { "OFF" } else { "ON" };
let issue = runtime_issue_badge(runtime_issue_kind(app)).0;
let flow = app.workflow_mode.to_uppercase();
let attach_status = if app.attached_context.is_some() && app.attached_image.is_some() {
"ATTACH:DOC+IMG"
} else if app.attached_context.is_some() {
"ATTACH:DOC"
} else if app.attached_image.is_some() {
"ATTACH:IMG"
} else {
"ATTACH:--"
};
if app.agent_running {
vec![
format!(
"WORKING · ESC stops · FLOW:{} · RT:{} · VOICE:{}",
flow, issue, voice_status
),
format!("WORKING · RT:{} · VOICE:{}", issue, voice_status),
format!("RT:{} · VOICE:{}", issue, voice_status),
format!("RT:{}", issue),
]
} else if app.input.trim().is_empty() {
vec![
format!(
"READY · FLOW:{} · RT:{} · VOICE:{} · APPR:{}",
flow, issue, voice_status, approvals_status
),
format!("READY · FLOW:{} · RT:{}", flow, issue),
format!("FLOW:{} · RT:{}", flow, issue),
format!("RT:{}", issue),
]
} else {
let draft_len = app.input.len();
vec![
format!(
"DRAFT:{} · FLOW:{} · RT:{} · {}",
draft_len, flow, issue, attach_status
),
format!("DRAFT:{} · RT:{} · {}", draft_len, issue, attach_status),
format!("LEN:{} · RT:{}", draft_len, issue),
format!("RT:{}", issue),
]
}
}
fn make_sparkline_gauge(ratio: f64, width: usize) -> String {
let filled = (ratio * width as f64).round() as usize;
let mut s = String::with_capacity(width);
for i in 0..width {
if i < filled {
s.push('▓');
} else {
s.push('░');
}
}
s
}
fn make_animated_sparkline_gauge(ratio: f64, width: usize, tick_count: u64) -> String {
let filled = (ratio.clamp(0.0, 1.0) * width as f64).round() as usize;
let shimmer_idx = if filled > 0 {
(tick_count as usize / 2) % filled.max(1)
} else {
0
};
let mut chars: Vec<char> = make_sparkline_gauge(ratio, width).chars().collect();
for (i, ch) in chars.iter_mut().enumerate() {
if i < filled {
*ch = if i == shimmer_idx { '█' } else { '▓' };
} else if i == filled && filled < width && ratio > 0.0 {
*ch = '▒';
} else {
*ch = '░';
}
}
chars.into_iter().collect()
}
fn select_fitting_variant(variants: &[String], width: u16) -> String {
let max_width = width as usize;
for variant in variants {
if variant.chars().count() <= max_width {
return variant.clone();
}
}
variants.last().cloned().unwrap_or_default()
}
fn idle_footer_variants(app: &App) -> Vec<String> {
let issue = runtime_issue_badge(runtime_issue_kind(app)).0;
if issue != "OK" {
return vec![
format!(" /runtime fix • /runtime explain • RT:{} ", issue),
format!(" /runtime fix • RT:{} ", issue),
format!(" RT:{} ", issue),
];
}
let phase = (app.tick_count / 18) % 3;
match phase {
0 => vec![
" [↑/↓] scroll • /help hints • /runtime status ".to_string(),
" [↑/↓] scroll • /help hints ".to_string(),
" /help ".to_string(),
],
1 => vec![
" /ask analyze • /architect plan • /code implement ".to_string(),
" /ask • /architect • /code ".to_string(),
" /code ".to_string(),
],
_ => vec![
" /provider status • /runtime refresh • /ls desktop ".to_string(),
" /provider • /runtime refresh ".to_string(),
" /runtime ".to_string(),
],
}
}
fn running_footer_variants(app: &App, elapsed: &str, last_log: &str) -> Vec<String> {
let worker_count = app.active_workers.len();
let primary_caption = if worker_count > 0 {
format!("{} workers • {}", worker_count, last_log)
} else {
last_log.to_string()
};
vec![
primary_caption,
last_log.to_string(),
format!("{} • working", elapsed.trim()),
"working".to_string(),
]
}
fn select_input_title_layout(app: &App, title_width: u16) -> (Vec<InputActionVisual>, String) {
let action_total = build_input_actions(app).len();
let mut best_actions = visible_input_actions(app, title_width);
let mut best_status = String::new();
for status in input_status_variants(app) {
let reserved = status.chars().count() as u16 + 3;
let actions = visible_input_actions(app, title_width.saturating_sub(reserved));
let replace = actions.len() > best_actions.len()
|| (actions.len() == best_actions.len() && status.len() > best_status.len());
if replace {
best_actions = actions.clone();
best_status = status.clone();
}
if actions.len() == action_total {
return (actions, status);
}
}
(best_actions, best_status)
}
fn input_action_hitboxes(app: &App, title_area: Rect) -> Vec<(InputAction, u16, u16)> {
let mut x = title_area.x;
let mut out = Vec::with_capacity(6);
let (actions, _) = select_input_title_layout(app, title_area.width);
for action in actions {
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<'a>(app: &'a App, area: Rect) -> Line<'a> {
let mut spans = Vec::with_capacity(8);
let (actions, status) = select_input_title_layout(app, area.width);
for action in actions {
let is_hovered = app.hovered_input_action == Some(action.action);
let style = if is_hovered {
Style::default()
.bg(action.style.fg.unwrap_or(Color::Gray))
.fg(Color::Black)
.add_modifier(Modifier::BOLD)
} else {
action.style
};
spans.push(Span::styled(format!(" {} ", action.label), style));
spans.push(Span::raw(" "));
}
if !status.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.tool_started_at.clear();
app.clear_grounded_recovery_cache();
app.clear_pending_attachments();
app.current_objective = "Idle".into();
}
fn request_stop(app: &mut App) {
app.voice_manager.stop();
if app.stop_requested {
return;
}
app.stop_requested = true;
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 Command Inventory\n\n\
[IT & Remediation Tools] (0-Model Logic)\n\
/triage [preset] - Run IT triage logic (health, security, connectivity, identity, updates)\n\
/health - Alias for /triage (deterministic health report)\n\
/fix <issue> - Generate a targeted fix plan for a specific issue\n\
/inspect <topic> - Run a specific host inspection topic (e.g., /inspect connectivity)\n\
/diagnose - Run staged health triage with agent handoff\n\
/export [fmt] - Generate and save a full diagnostic report (md|html|json)\n\
/explain <text> - Paste an error to get a non-technical breakdown\n\n\
[Agent Workflow Modes]\n\
/chat - Conversation mode (no tool noise)\n\
/agent - Full coding harness + workstation mode (tools active)\n\
/auto - Let Hematite choose the narrowest effective workflow\n\
/ask, /code - Sticky Analysis or Implementation modes\n\
/architect - Plan-first mode (inspect and approach before edit)\n\
/teach - Guided walkthrough mode (no-execute)\n\n\
[Context & Memory Management]\n\
/new - Fresh task context (clear chat/pins/task files)\n\
/forget - Hard forget (purge chat + saved memory + Vein index)\n\
/clear - Clear dialogue display only\n\
/attach, /image - Attach document or image for next message\n\
/detach - Drop pending attachments\n\
/vein-inspect - Inspect RAG memory and active room bias\n\n\
[System & Runtime]\n\
/runtime [fix] - Show or fix live provider/model/embed status\n\
/model, /embed - List, load, unload, or prefer specific models\n\
/lsp - Start Language Servers (semantic intelligence)\n\
/think, /no_think - Toggle deep reasoning mode (reasoning is 3-5x slower)\n\
/undo - Revert last file change\n\
/version, /about - Show build and product info\n\n\
[Navigation & Filesystem]\n\
/cd <path> - Teleport to another directory\n\
/ls [path] - List locations or subdirectories\n\n\
Hotkeys: Ctrl+B (Brief), Ctrl+P (Professional), Ctrl+Y (Auto-approve), Ctrl+Z (Undo), Ctrl+C (Quit), ESC (Silence)"
);
}
#[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\
/rules — (Rules) View project guidance (CLAUDE.md, SKILLS.md, .hematite/rules.md)\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\
/provider [status|lmstudio|ollama|clear|URL] — (Model) Show or save the active provider endpoint preference\n\
/runtime — (Model) Show the live runtime/provider/model/embed status and shortest fix path\n\
/runtime fix — (Model) Run the shortest safe runtime recovery step now\n\
/runtime-refresh — (Model) Re-read active provider model + CTX now\n\
/model [status|list [available|loaded]|load <id> [--ctx N]|unload [id|current|all]|prefer <id>|clear] — (Model) Inspect, list, load, unload, or save the preferred coding model (`--ctx` uses LM Studio context length or Ollama `num_ctx`)\n\
/embed [status|load <id>|unload [id|current]|prefer <id>|clear] — (Model) Inspect, load, unload, or save the preferred embed model\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 the full SPECULAR rail to clipboard (reasoning + events)\n\
\nHotkeys:\n\
Ctrl+B — Toggle Brief Mode (minimal output; collapses side chrome)\n\
Alt+↑/↓ — Scroll the SPECULAR rail by 3 lines\n\
Alt+PgUp/PgDn — Scroll the SPECULAR rail by 10 lines\n\
Alt+End — Snap SPECULAR back to live follow mode\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/OL — Provider runtime health (`LIVE`, `RECV`, `WARN`, `CEIL`, `STALE`, `BOOT`)\n\
RT — Primary runtime issue (`OK`, `MOD`, `NET`, `EMP`, `CTX`, `WAIT`)\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 the provider\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)
}
}
}
#[allow(clippy::too_many_arguments)]
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,
stop_requested: 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_name: "detecting".to_string(),
provider_endpoint: String::new(),
embed_model_id: None,
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: 1.0, autocomplete_alias_active: false,
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,
teleported_from: cockpit.teleported_from.clone(),
nav_list: Vec::new(),
auto_approve_session: false,
task_start_time: None,
tool_started_at: HashMap::new(),
recent_grounded_results: Vec::new(),
};
app.push_message("Hematite", "Initialising Engine & Hardware...");
if let Some(origin) = &app.teleported_from {
app.push_message(
"System",
&format!(
"Teleportation complete. You've arrived from {}. Hematite has launched this fresh session to ensure your original terminal remains clean and your context is grounded in this target workspace. What's our next move?",
origin
),
);
}
if !cockpit.no_splash {
loop {
draw_splash(terminal)?;
if event::poll(Duration::from_millis(350))? {
if let Event::Key(key) = event::read()? {
if key.kind == event::KeyEventKind::Press
&& matches!(key.code, KeyCode::Enter | KeyCode::Char(' '))
{
break;
}
}
}
}
}
if app.teleported_from.is_some()
&& crate::tools::plan::consume_teleport_resume_marker()
&& crate::tools::plan::load_plan_handoff().is_some()
{
app.workflow_mode = "CODE".into();
app.thinking = true;
app.agent_running = true;
app.push_message(
"System",
"Teleport handoff detected in this project. Resuming from `.hematite/PLAN.md` automatically.",
);
app.push_message("You", "/implement-plan");
let _ = app
.user_input_tx
.try_send(UserTurn::text("/implement-plan"));
}
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.",
);
}
app.sync_task_start_time();
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;
if app.show_autocomplete && !app.autocomplete_suggestions.is_empty() {
let items_len = app.autocomplete_suggestions.len();
let popup_h = (items_len as u16 + 2).min(17); let popup_y = input_rect.y.saturating_sub(popup_h);
let popup_x = input_rect.x + 2;
let popup_w = input_rect.width.saturating_sub(4);
if mouse.row >= popup_y && mouse.row < popup_y + popup_h
&& mouse.column >= popup_x && mouse.column < popup_x + popup_w
{
let mouse_relative_y = mouse.row.saturating_sub(popup_y + 1);
if mouse_relative_y < items_len as u16 {
let clicked_idx = mouse_relative_y as usize;
let selected = &app.autocomplete_suggestions[clicked_idx].clone();
app.apply_autocomplete_selection(selected);
}
continue; }
}
}
}
MouseEventKind::ScrollUp => {
if is_right_side {
scroll_specular_up(&mut app, 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 {
scroll_specular_down(&mut app, 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('a') | KeyCode::Char('A') => {
app.auto_approve_session = true;
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));
}
app.push_message("System", "🔓 FULL AUTONOMY — All mutations auto-approved for this session.");
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 key.modifiers.contains(event::KeyModifiers::ALT) =>
{
scroll_specular_up(&mut app, 3);
}
KeyCode::Down
if key.modifiers.contains(event::KeyModifiers::ALT) =>
{
scroll_specular_down(&mut app, 3);
}
KeyCode::PageUp
if key.modifiers.contains(event::KeyModifiers::ALT) =>
{
scroll_specular_up(&mut app, 10);
}
KeyCode::PageDown
if key.modifiers.contains(event::KeyModifiers::ALT) =>
{
scroll_specular_down(&mut app, 10);
}
KeyCode::End
if key.modifiers.contains(event::KeyModifiers::ALT) =>
{
follow_live_specular(&mut app);
app.push_message(
"System",
"SPECULAR snapped back to live follow mode.",
);
}
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].clone();
app.apply_autocomplete_selection(&selected);
}
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()
&& should_accept_autocomplete_on_enter(
app.autocomplete_alias_active,
&app.autocomplete_filter,
)
{
let selected = app.autocomplete_suggestions[app.selected_suggestion].clone();
app.apply_autocomplete_selection(&selected);
continue;
}
if !app.input.is_empty()
&& (!app.agent_running
|| is_immediate_local_command(&app.input))
{
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.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;
}
"/cd" => {
if parts.len() < 2 {
app.push_message("System", "Usage: /cd <path> — teleport to any directory. Supports bare tokens like downloads, desktop, docs, pictures, videos, music, home, temp, bare ~, aliases like @DESKTOP/project, plus .. and absolute paths. Tip: run /ls desktop first if you want a numbered picker.");
app.history_idx = None;
continue;
}
let raw = parts[1..].join(" ");
let target = crate::tools::file_ops::resolve_candidate(&raw);
if !target.exists() {
app.push_message("System", &format!("Directory not found: {}", target.display()));
app.history_idx = None;
continue;
}
if !target.is_dir() {
app.push_message("System", &format!("Not a directory: {}", target.display()));
app.history_idx = None;
continue;
}
let target_str = target.to_string_lossy().to_string();
app.push_message("You", &format!("/cd {}", raw));
app.push_message("System", &format!("Teleporting to {}...", target_str));
app.push_message("System", "Launching new session. This terminal will close.");
spawn_dive_in_terminal(&target_str);
app.write_session_report();
app.copy_transcript_to_clipboard();
break;
}
"/ls" => {
let base: std::path::PathBuf = if parts.len() >= 2 {
let arg = parts[1..].join(" ");
if let Ok(n) = arg.trim().parse::<usize>() {
if n == 0 || n > app.nav_list.len() {
app.push_message("System", &format!("No entry {}. Run /ls first to see the list.", n));
app.history_idx = None;
continue;
}
let target = app.nav_list[n - 1].clone();
let target_str = target.to_string_lossy().to_string();
app.push_message("You", &format!("/ls {}", n));
app.push_message("System", &format!("Teleporting to {}...", target_str));
app.push_message("System", "Launching new session. This terminal will close.");
spawn_dive_in_terminal(&target_str);
app.write_session_report();
app.copy_transcript_to_clipboard();
break;
} else {
crate::tools::file_ops::resolve_candidate(&arg)
}
} else {
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
};
let mut entries: Vec<std::path::PathBuf> = Vec::new();
let mut output = String::with_capacity(1024);
let listing_base = parts.len() < 2;
if listing_base {
let common: Vec<(&str, Option<std::path::PathBuf>)> = vec![
("Desktop", dirs::desktop_dir()),
("Downloads", dirs::download_dir()),
("Documents", dirs::document_dir()),
("Pictures", dirs::picture_dir()),
("Home", dirs::home_dir()),
];
let valid: Vec<_> = common.into_iter().filter_map(|(label, p)| p.map(|pb| (label, pb))).collect();
if !valid.is_empty() {
output.push_str("Common locations:\n");
for (label, pb) in &valid {
entries.push(pb.clone());
let _ = writeln!(output, " {:>2}. {:<12} {}", entries.len(), label, pb.display());
}
}
}
let cwd_label = if listing_base {
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
} else {
base.clone()
};
if let Ok(read) = std::fs::read_dir(&cwd_label) {
let mut dirs_found: Vec<std::path::PathBuf> = read
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.map(|e| e.path())
.collect();
dirs_found.sort_unstable();
if !dirs_found.is_empty() {
let _ = write!(output, "\n{}:\n", cwd_label.display());
for pb in &dirs_found {
entries.push(pb.clone());
let name = pb.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let _ = writeln!(output, " {:>2}. {}", entries.len(), name);
}
}
}
if entries.is_empty() {
app.push_message("System", "No directories found.");
} else {
output.push_str("\nType /ls <N> to teleport to that directory.");
app.nav_list = entries;
app.push_message("System", &output);
}
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).into_owned();
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 { "" };
let _ = writeln!(list, " {:>2}. {}{}", 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_hematite_native_model(&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;
}
"/rules" => {
let sub = parts.get(1).copied().unwrap_or("status").to_ascii_lowercase();
let ws_root = crate::tools::file_ops::workspace_root();
match sub.as_str() {
"view" => {
let mut combined = String::with_capacity(
crate::agent::instructions::PROJECT_GUIDANCE_FILES.len() * 512,
);
for cand in crate::agent::instructions::PROJECT_GUIDANCE_FILES {
let p = crate::agent::instructions::resolve_guidance_path(&ws_root, cand);
if p.exists() {
if let Ok(c) = std::fs::read_to_string(&p) {
let _ = writeln!(combined, "--- [{}] ---", cand);
combined.push_str(&c);
combined.push_str("\n\n");
}
}
}
if combined.is_empty() {
app.push_message("System", "No project guidance files found (CLAUDE.md, SKILLS.md, .hematite/rules.md, etc.).");
} else {
app.push_message("System", &format!("Current project guidance being injected:\n\n{}", combined));
}
}
"edit" => {
let which = parts.get(2).copied().unwrap_or("local").to_ascii_lowercase();
let target_file = if which == "shared" { "rules.md" } else { "rules.local.md" };
let target_path = crate::tools::file_ops::hematite_dir().join(target_file);
if !target_path.exists() {
if let Some(parent) = target_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let header = if which == "shared" { "# Project Rules (Shared)" } else { "# Local Guidelines (Private)" };
let _ = std::fs::write(&target_path, format!("{}\n\nAdd behavioral guidelines here for the agent to follow in this workspace.\n", header));
}
match crate::tools::file_ops::open_in_system_editor(&target_path) {
Ok(_) => app.push_message("System", &format!("Opening {} in system editor...", target_path.display())),
Err(e) => app.push_message("System", &format!("Failed to open editor: {}", e)),
}
}
_ => {
let mut status = "Project Guidance:\n".to_string();
for cand in crate::agent::instructions::PROJECT_GUIDANCE_FILES {
let p = crate::agent::instructions::resolve_guidance_path(&ws_root, cand);
let icon = if p.exists() { "[v]" } else { "[ ]" };
let label = crate::agent::instructions::guidance_status_label(cand);
let _ = writeln!(status, " {} {:<25} {}", icon, cand, label);
}
status.push_str("\nUsage:\n /rules view - View combined guidance\n /rules edit - Edit personal local rules (ignored by git)\n /rules edit shared - Edit project-wide shared rules");
app.push_message("System", &status);
}
}
app.history_idx = None;
continue;
}
"/skills" => {
let workspace_root = crate::tools::file_ops::workspace_root();
let config = crate::agent::config::load_config();
let discovery = crate::agent::instructions::discover_agent_skills(
&workspace_root,
&config.trust,
);
let report =
crate::agent::instructions::render_skills_report(&discovery);
app.push_message("System", &report);
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\
/rules — (Rules) View project guidance (CLAUDE.md, SKILLS.md, .hematite/rules.md)\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\
/provider [status|lmstudio|ollama|clear|URL] — (Model) Show or save the active provider endpoint preference\n\
/runtime — (Model) Show the live runtime/provider/model/embed status and shortest fix path\n\
/runtime fix — (Model) Run the shortest safe runtime recovery step now\n\
/runtime-refresh — (Model) Re-read active provider model + CTX now\n\
/model [status|list [available|loaded]|load <id> [--ctx N]|unload [id|current|all]|prefer <id>|clear] — (Model) Inspect, list, load, unload, or save the preferred coding model (`--ctx` uses LM Studio context length or Ollama `num_ctx`)\n\
/embed [status|load <id>|unload [id|current]|prefer <id>|clear] — (Model) Inspect, load, unload, or save the preferred embed model\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; collapses side chrome)\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;
}
"/provider" => {
let arg_text = parts[1..].join(" ").trim().to_string();
handle_provider_command(&mut app, arg_text).await;
continue;
}
"/runtime" => {
let arg_text = parts[1..].join(" ").trim().to_string();
let lower = arg_text.to_ascii_lowercase();
match lower.as_str() {
"" | "status" => {
app.push_message(
"System",
&format_runtime_summary(&app).await,
);
}
"explain" => {
app.push_message(
"System",
&format_runtime_explanation(&app).await,
);
}
"refresh" => {
let _ = app
.user_input_tx
.try_send(UserTurn::text(
"/runtime-refresh",
));
app.push_message("You", "/runtime refresh");
app.agent_running = true;
}
"fix" => {
handle_runtime_fix(&mut app).await;
}
_ if lower.starts_with("provider") => {
let provider_arg =
arg_text["provider".len()..].trim().to_string();
if provider_arg.is_empty() {
app.push_message(
"System",
"Usage: /runtime provider [status|lmstudio|ollama|clear|http://host:port/v1]",
);
} else {
handle_provider_command(&mut app, provider_arg)
.await;
}
}
_ => {
app.push_message(
"System",
"Usage: /runtime [status|explain|fix|refresh|provider ...]",
);
}
}
app.history_idx = None;
continue;
}
"/model" | "/embed" => {
let outbound = input_text.clone();
app.push_message("You", &outbound);
app.agent_running = true;
app.stop_requested = false;
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 _ = app
.user_input_tx
.try_send(UserTurn::text(outbound));
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" | "/triage" | "/fix" | "/inspect" => {
app.push_message("You", &input_text);
app.agent_running = true;
let _ = app.user_input_tx.try_send(UserTurn::text(input_text.clone()));
app.history_idx = None;
continue;
}
"/diagnose" => {
app.push_message("You", "/diagnose");
app.push_message("System", "Running health triage...");
let health_args = serde_json::json!({"topic": "health_report"});
let health_output = crate::tools::host_inspect::inspect_host(&health_args)
.await
.unwrap_or_else(|e| format!("Error: {}", e));
let follow_ups = crate::agent::diagnose::triage_follow_up_topics(&health_output);
let n = follow_ups.len();
if n > 0 {
app.push_message("System", &format!(
"Triage complete — {} area(s) flagged. Handing off to agent for deep investigation...",
n
));
} else {
app.push_message("System", "Triage complete — machine looks healthy. Confirming with agent...");
}
let instruction = crate::agent::diagnose::build_diagnose_instruction(
&health_output,
&follow_ups,
);
app.agent_running = true;
let _ = app.user_input_tx.try_send(UserTurn::text(instruction));
app.history_idx = None;
continue;
}
"/export" => {
let fmt = parts.get(1).copied().unwrap_or("md").to_ascii_lowercase();
let label = match fmt.as_str() {
"json" => "JSON",
"html" => "HTML",
_ => "Markdown",
};
app.push_message("System", &format!(
"Generating diagnostic report ({}) — scanning 6 topics...", label
));
let path = match fmt.as_str() {
"json" => {
let (_, p) = crate::agent::report_export::save_report_json().await;
p
}
"html" => {
let (_, p) = crate::agent::report_export::save_report_html().await;
p
}
_ => {
let (_, p) = crate::agent::report_export::save_report_markdown().await;
p
}
};
let path_str = path.display().to_string();
copy_text_to_clipboard(&path_str);
app.push_message("System", &format!(
"Report saved: {}\n(Path copied to clipboard — open in browser or share with your team)",
path_str
));
app.history_idx = None;
continue;
}
"/save-html" => {
let title = parts[1..].join(" ");
let last_response = app.messages_raw.iter().rev()
.find(|(speaker, _)| speaker == "Hematite")
.map(|(_, content)| content.clone());
match last_response {
None => {
app.push_message("System", "No Hematite response found in this session to save.");
}
Some(body) => {
let (_, path) = crate::agent::report_export::save_research_html(&title, &body);
let path_str = path.display().to_string();
copy_text_to_clipboard(&path_str);
app.push_message("System", &format!(
"Saved: {}\n(Path copied to clipboard)",
path_str
));
#[cfg(target_os = "windows")]
{ let s = path.to_string_lossy().into_owned(); let _ = std::process::Command::new("cmd").args(["/c", "start", "", &s]).spawn(); }
#[cfg(not(target_os = "windows"))]
{ let opener = if cfg!(target_os = "macos") { "open" } else { "xdg-open" }; let _ = std::process::Command::new(opener).arg(&path).spawn(); }
}
}
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.clear_grounded_recovery_cache();
app.push_message("You", &input_text);
app.agent_running = true;
app.stop_requested = false;
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) => {
if app.stop_requested {
continue;
}
app.thinking = true;
app.current_thought.push_str(&content);
}
InferenceEvent::VoiceStatus(msg) => {
if app.stop_requested {
continue;
}
app.push_message("System", &msg);
}
InferenceEvent::Token(ref token) | InferenceEvent::MutedToken(ref token) => {
if app.stop_requested {
continue;
}
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 { id, name, args } => {
if app.stop_requested {
continue;
}
app.tool_started_at.insert(id, Instant::now());
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, result, is_error } => {
if app.stop_requested {
continue;
}
if should_capture_grounded_tool_output(&name, is_error) {
app.recent_grounded_results.push((name.clone(), result.clone()));
if app.recent_grounded_results.len() > 4 {
app.recent_grounded_results.remove(0);
}
}
let icon = if is_error { "[x]" } else { "[v]" };
let elapsed_chip = app
.tool_started_at
.remove(&id)
.map(|started| format_tool_elapsed(started.elapsed()));
if is_error {
app.record_error();
}
let preview = first_n_chars(&result, 100);
if app.workflow_mode != "CHAT" {
let display = if let Some(elapsed) = elapsed_chip.as_deref() {
format!("{} {} [{}] ? {}", icon, name, elapsed, preview)
} else {
format!("{} {} ? {}", icon, name, preview)
};
app.push_message("Tool", &display);
} 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, mutation_label, responder } => {
if app.stop_requested {
let _ = responder.send(false);
continue;
}
if app.auto_approve_session {
if let Some(ref diff) = 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!(
"Auto-approved: {} +{} -{}", display, added, removed
));
} else {
app.push_message("System", &format!("Auto-approved: {}", display));
}
let _ = responder.send(true);
continue;
}
let is_diff = diff.is_some();
app.awaiting_approval = Some(PendingApproval {
display: display.clone(),
tool_name: name,
diff,
diff_scroll: 0,
mutation_label,
responder,
});
if is_diff {
app.push_message("System", "[~] Diff preview — [Y] Apply [N] Skip [A] Accept All");
} else {
app.push_message("System", "[!] Approval required — [Y] Approve [N] Decline [A] Accept All");
app.push_message("System", &format!("Command: {}", display));
}
}
InferenceEvent::TurnTiming { context_prep_ms, inference_ms, execution_ms } => {
app.specular_logs.push(format!(
"PROFILE: Prep {}ms | Eval {}ms | Exec {}ms",
context_prep_ms, inference_ms, execution_ms
));
trim_vec(&mut app.specular_logs, 20);
}
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;
app.stop_requested = false;
app.task_start_time = None;
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.rebuild_formatted_messages();
app.manual_scroll_offset = None;
app.specular_auto_scroll = true;
app.active_workers.remove("AGENT");
app.worker_labels.remove("AGENT");
}
InferenceEvent::CopyDiveInCommand(path) => {
let command = format!("cd \"{}\" && hematite", path.replace('\\', "/"));
copy_text_to_clipboard(&command);
spawn_dive_in_terminal(&path);
app.push_message("System", &format!("Teleportation initiated: New terminal launched at {}", path));
app.push_message("System", "Teleportation complete. Closing original session to maintain workstation hygiene...");
app.write_session_report();
app.copy_transcript_to_clipboard();
break;
}
InferenceEvent::ChainImplementPlan => {
app.push_message("You", "/implement-plan (Autonomous Handoff)");
app.manual_scroll_offset = None;
}
InferenceEvent::Error(e) => {
app.record_error();
app.thinking = false;
app.agent_running = false;
app.task_start_time = None;
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. Run /compact to summarize history in place, /new to reset (preserves project memory), 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 compaction threshold. Run /compact to summarize history and free space.", 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 {
provider_name,
endpoint,
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);
let provider_changed = app.provider_name != provider_name;
app.provider_name = provider_name.clone();
app.provider_endpoint = endpoint.clone();
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 {
let mut guidance = if provider_name == "Ollama" {
"No coding model is currently available from Ollama. Pull or load a chat model in Ollama, then keep `api_url` pointed at `http://localhost:11434/v1`. If you also want semantic search, set `/embed prefer <id>` to an Ollama embedding model.".to_string()
} else {
"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 an embedding model for semantic search.".to_string()
};
if let Some((alt_name, alt_url)) =
crate::runtime::detect_alternative_provider(&provider_name).await
{
let _ = write!(guidance,
" Reachable alternative detected: {} ({}). Use `/provider {}` and restart Hematite if you want to switch.",
alt_name,
alt_url,
alt_name.to_ascii_lowercase().replace(' ', "")
);
}
app.push_message("System", &guidance);
} else if provider_changed && !now_no_model {
app.push_message(
"System",
&format!(
"Provider detected: {} | Model {} | CTX {}",
provider_name, model_id, context_length
),
);
} else if changed && !now_no_model {
app.push_message(
"System",
&format!(
"Runtime profile refreshed: {} | Model {} | CTX {}",
provider_name, model_id, context_length
),
);
}
}
InferenceEvent::EmbedProfile { model_id } => {
let changed = app.embed_model_id != model_id;
app.embed_model_id = model_id.clone();
if changed {
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');
}
InferenceEvent::TurnBudget(budget) => {
app.current_thought.push_str(&budget.render());
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.area();
if size.width < 60 || size.height < 10 {
f.render_widget(Clear, size);
return;
}
let input_height = compute_input_height(f.area().width, app.input.len());
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0),
Constraint::Length(input_height),
Constraint::Length(5), ])
.split(f.area());
let sidebar_mode = sidebar_mode(app, size.width);
let sidebar_width = match sidebar_mode {
SidebarMode::Hidden => 0,
SidebarMode::Compact => 32,
SidebarMode::Full => 45,
};
let top = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Fill(1), Constraint::Length(sidebar_width)])
.split(chunks[0]);
let mut core_lines = app.messages.clone();
if app.agent_running {
let dots = ".".repeat((app.tick_count % 4) as usize + 1);
let verb = if app.thinking { "thinking" } else { "working" };
core_lines.push(Line::from(Span::styled(
format!(" Hematite is {}{}", verb, 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 has_real_task = !app.current_objective.is_empty()
&& app.current_objective != "Idle"
&& app.current_objective != "Awaiting objective...";
let (title_prefix, title_body, title_color): (&str, String, Color) = if has_real_task {
let body = if app.current_objective.len() > 30 {
format!("{}...", safe_head(&app.current_objective, 27))
} else {
app.current_objective.clone()
};
("TASK", body, Color::Yellow)
} else if !app.active_workers.is_empty() {
("SWARM", "Parallel agents active".into(), Color::Cyan)
} else if app.thinking {
("THINKING", String::new(), Color::Magenta)
} else if app.agent_running {
("WORKING", String::new(), Color::Green)
} else {
("READY", String::new(), Color::DarkGray)
};
let title_text = if title_body.is_empty() {
format!(" {} ", title_prefix)
} else {
format!(" {}: {} ", title_prefix, title_body)
};
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(
title_text,
Style::default()
.fg(title_color)
.add_modifier(Modifier::ITALIC),
),
])
} else {
Line::from(vec![
Span::styled(format!(" {} ", core_icon), Style::default().fg(heart_color)),
Span::styled(title_text, Style::default().fg(title_color)),
])
};
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.div_ceil(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
};
let core_para = Paragraph::new(core_lines)
.block(
Block::default()
.title(core_title)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray)),
)
.wrap(Wrap { trim: true });
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,
);
if sidebar_mode == SidebarMode::Compact && top[1].width > 0 {
let compact_title = if sidebar_has_live_activity(app) {
" SIGNALS "
} else {
" SESSION "
};
let compact_para = Paragraph::new(build_compact_sidebar_lines(app))
.wrap(Wrap { trim: true })
.block(
Block::default()
.title(compact_title)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray)),
);
f.render_widget(Clear, top[1]);
f.render_widget(compact_para, top[1]);
} else if sidebar_mode == SidebarMode::Full && top[1].width > 0 {
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_title = if sidebar_has_live_activity(app) {
" LIVE CONTEXT "
} else {
" SESSION CONTEXT "
};
let ctx_block = Block::default()
.title(ctx_title)
.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 {
" HEMATITE SIGNALS [live] ".to_string()
} else {
" HEMATITE SIGNALS [watching] ".to_string()
};
f.render_widget(Clear, side[1]);
let mut v_lines: Vec<Line<'static>> = Vec::with_capacity(32);
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 = {
let thought = &app.current_thought;
let char_count = thought.chars().count();
if char_count > 300 {
thought.chars().skip(char_count - 300).collect::<String>()
} else {
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(""));
} else {
v_lines.push(Line::from(vec![
Span::styled("• ", Style::default().fg(Color::DarkGray)),
Span::styled(
"Waiting for the next turn. Runtime, MCP, and index signals stay visible here.",
Style::default().fg(Color::Gray),
),
]));
v_lines.push(Line::raw(""));
}
let signal_rows = sidebar_signal_rows(app);
if !signal_rows.is_empty() {
let section_title = if app.thinking || app.agent_running {
"-- Operator Signals --"
} else {
"-- Session Snapshot --"
};
v_lines.push(Line::from(vec![Span::styled(
section_title,
Style::default()
.fg(Color::White)
.add_modifier(Modifier::DIM),
)]));
for (row, color) in signal_rows
.iter()
.take(if app.thinking || app.agent_running {
4
} else {
3
})
{
v_lines.push(Line::from(vec![
Span::styled("- ", Style::default().fg(Color::DarkGray)),
Span::styled(row.clone(), Style::default().fg(*color)),
]));
}
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_unstable();
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.thinking || app.agent_running) && !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(
if app.thinking || app.agent_running {
"── Live Events ──"
} else {
"── Recent Events ──"
},
Style::default()
.fg(Color::White)
.add_modifier(Modifier::DIM),
)]));
let recent_logs: Vec<String> = if app.thinking || app.agent_running {
app.specular_logs.iter().rev().take(8).cloned().collect()
} else {
app.specular_logs.iter().rev().take(5).cloned().collect()
};
for log in recent_logs.into_iter().rev() {
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,
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 vigil_badge = if app.brief_mode { " VIGIL" } else { "" };
let yolo_badge = if app.yolo_mode { " YOLO" } else { "" };
let bar_constraints = vec![Constraint::Fill(1)];
let bar_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints(bar_constraints)
.split(chunks[2]);
let footer_row = {
let footer_row_width = bar_chunks[0].width.saturating_sub(6);
if app.agent_running {
let elapsed = if let Some(start) = app.task_start_time {
format!(" {:0>2}s ", start.elapsed().as_secs())
} else {
String::new()
};
let last_log = app
.specular_logs
.last()
.map(|s| s.as_str())
.unwrap_or("...");
let spinner = match app.tick_count % 8 {
0 => "⠋",
1 => "⠙",
2 => "⠹",
3 => "⠸",
4 => "⠼",
5 => "⠴",
6 => "⠦",
_ => "⠧",
};
let footer_caption = select_fitting_variant(
&running_footer_variants(app, &elapsed, last_log),
footer_row_width,
);
Line::from(vec![
Span::styled(
format!(" {} ", spinner),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::styled(
elapsed,
Style::default()
.bg(Color::Rgb(40, 40, 40))
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" ⬢ {}", footer_caption),
Style::default().fg(Color::DarkGray),
),
])
} else {
let idle_hint = select_fitting_variant(&idle_footer_variants(app), footer_row_width);
Line::from(vec![
Span::styled(" ⬢ ", Style::default().fg(Color::Rgb(40, 40, 40))),
Span::styled(
idle_hint,
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::DIM),
),
])
}
};
let runtime_age = app.last_runtime_profile_time.elapsed();
let provider_prefix = provider_badge_prefix(&app.provider_name);
let issue = runtime_issue_kind(app);
let (issue_code, issue_color) = runtime_issue_badge(issue);
let (lm_label, lm_color) = if issue == RuntimeIssueKind::NoModel {
(format!("{provider_prefix}:NONE"), Color::Red)
} else if issue == RuntimeIssueKind::Booting {
(format!("{provider_prefix}:BOOT"), Color::DarkGray)
} else if issue == RuntimeIssueKind::Recovering {
(format!("{provider_prefix}:RECV"), Color::Cyan)
} else if matches!(
issue,
RuntimeIssueKind::Connectivity | RuntimeIssueKind::EmptyResponse
) {
(format!("{provider_prefix}:WARN"), Color::Red)
} else if issue == RuntimeIssueKind::ContextCeiling {
(format!("{provider_prefix}:CEIL"), Color::Yellow)
} else if runtime_age > std::time::Duration::from_secs(120) {
(format!("{provider_prefix}:STALE"), Color::Yellow)
} else {
(format!("{provider_prefix}: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 vram_ratio = app.gpu_state.ratio();
let vram_label = app.gpu_state.label();
let gpu_name = app.gpu_state.gpu_name();
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 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 session_usage_text = format!(
" TOKENS: {:0>5} | TOTAL: ${:.2} ",
current_tokens, app.current_session_cost
);
f.render_widget(Clear, bar_chunks[0]);
let usage_color = Color::Rgb(100, 100, 100);
let ai_line = vec![
Span::styled(
format!(" {} ", lm_label),
Style::default().fg(lm_color).add_modifier(Modifier::BOLD),
),
Span::styled("║ ", Style::default().fg(Color::Rgb(60, 60, 60))),
Span::styled(format!("{} ", vein_label), Style::default().fg(vein_color)),
Span::styled("│ ", Style::default().fg(Color::Rgb(40, 40, 40))),
Span::styled(format!("{} ", issue_code), Style::default().fg(issue_color)),
Span::styled("│ ", Style::default().fg(Color::Rgb(40, 40, 40))),
Span::styled(
format!("CTX:{} ", app.context_length),
Style::default().fg(Color::DarkGray),
),
Span::styled("│ ", Style::default().fg(Color::Rgb(40, 40, 40))),
Span::styled(
format!("REMOTE:{} ", app.git_state.label()),
Style::default().fg(Color::DarkGray),
),
Span::styled("│ ", Style::default().fg(Color::Rgb(40, 40, 40))),
Span::styled(prompt_label, Style::default().fg(prompt_color)),
Span::styled(" ", Style::default().fg(Color::Rgb(40, 40, 40))),
Span::styled(compaction_label, Style::default().fg(compaction_color)),
Span::styled(
format!("{} ", think_badge),
Style::default().fg(Color::Cyan),
),
Span::styled(
vigil_badge.to_string(),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(
yolo_badge.to_string(),
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::styled(" │ ", Style::default().fg(Color::Rgb(40, 40, 40))),
Span::styled(session_usage_text, Style::default().fg(usage_color)),
];
let hardware_line = vec![
Span::styled(" ⬢ ", Style::default().fg(Color::Rgb(60, 60, 60))), Span::styled(
format!("{} ", gpu_name),
Style::default()
.fg(Color::Rgb(200, 200, 200))
.add_modifier(Modifier::BOLD),
),
Span::styled("║ ", Style::default().fg(Color::Rgb(60, 60, 60))),
Span::styled(
format!(
"[{}] ",
make_animated_sparkline_gauge(vram_ratio, 12, app.tick_count)
),
Style::default().fg(Color::Cyan),
),
Span::styled(
format!("{}% ", (vram_ratio * 100.0) as u8),
Style::default().fg(Color::Cyan),
),
Span::styled(
format!("({})", vram_label),
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::DIM),
),
];
f.render_widget(
Paragraph::new(vec![
Line::from(ai_line),
Line::from(hardware_line),
footer_row,
])
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Rgb(60, 60, 60))),
),
bar_chunks[0],
);
let input_border_color = if app.agent_running {
Color::Rgb(60, 60, 60)
} else {
Color::Rgb(100, 100, 100) };
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(Style::default().fg(input_border_color))
.style(Style::default().bg(Color::Rgb(25, 25, 25)));
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_position((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.area());
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 approval.mutation_label.is_some() {
(" MUTATION REQUESTED — AUTHORISE THE WORKFLOW ", Color::Cyan)
} else 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),
)),
if is_diff_preview {
Line::from(Span::styled(
" [↑↓/jk/PgUp/PgDn] Scroll [Y] Apply [N] Skip [A] Accept All ",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
))
} else {
Line::from(vec![
Span::styled(
" [Y] Approve ",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::styled(
" [N] Decline ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::styled(
" [A] Accept All ",
Style::default()
.fg(Color::Magenta)
.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 approval.mutation_label.is_some() {
Color::Cyan
} else 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(
if let Some(label) = &approval.mutation_label {
format!(" INTENT: {}", label)
} else {
format!(" {}", approval.display)
},
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)),
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("")),
Line::from(Span::styled(
if let Some(label) = &approval.mutation_label {
format!(" INTENT: {}", label)
} else {
format!(" ACTION: {}", approval.display)
},
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::raw("")),
Line::from(Span::styled(
format!(" Tool: {}", approval.tool_name),
Style::default().fg(Color::DarkGray),
)),
];
if approval.mutation_label.is_some() {
}
f.render_widget(
Paragraph::new(body_text)
.block(
Block::default()
.borders(Borders::BOTTOM | Borders::LEFT | Borders::RIGHT)
.border_style(Style::default().fg(border_color)),
)
.alignment(ratatui::layout::Alignment::Center),
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::with_capacity(n.min(s.len()));
for (count, c) in s.chars().enumerate() {
if count >= n {
result.push('…');
break;
}
if c == '\n' || c == '\r' {
result.push(' ');
} else if !c.is_control() {
result.push(c);
}
}
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::with_capacity(4);
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::with_capacity(4);
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 make_starfield(width: u16, rows: u16, seed: u64, tick: u64) -> Vec<String> {
let mut lines = Vec::with_capacity(rows as usize);
for y in 0..rows {
let mut line = String::with_capacity(width as usize);
for x in 0..width {
let n = (x as u64).wrapping_mul(73_856_093)
^ (y as u64).wrapping_mul(19_349_663)
^ seed
^ tick.wrapping_mul(83_492_791);
let ch = match n % 97 {
0 => '*',
1 | 2 => '.',
3 => '+',
_ => ' ',
};
line.push(ch);
}
lines.push(line);
}
lines
}
fn draw_splash<B: Backend>(terminal: &mut Terminal<B>) -> Result<(), Box<dyn std::error::Error>> {
let logo_color = Color::Rgb(118, 118, 124);
let star_color = Color::White;
let sub_logo_color = Color::DarkGray;
let tagline_color = Color::Gray;
let author_color = Color::DarkGray;
let wide_logo = vec![
"██╗ ██╗███████╗███╗ ███╗ █████╗ ████████╗██╗████████╗███████╗",
"██║ ██║██╔════╝████╗ ████║██╔══██╗╚══██╔══╝██║╚══██╔══╝██╔════╝",
"███████║█████╗ ██╔████╔██║███████║ ██║ ██║ ██║ █████╗ ",
"██╔══██║██╔══╝ ██║╚██╔╝██║██╔══██║ ██║ ██║ ██║ ██╔══╝ ",
"██║ ██║███████╗██║ ╚═╝ ██║██║ ██║ ██║ ██║ ██║ ███████╗",
"╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝",
];
let version = env!("CARGO_PKG_VERSION");
terminal.draw(|f| {
let area = f.area();
f.render_widget(
Block::default().style(Style::default().bg(Color::Black)),
area,
);
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
let tick = (now.as_millis() / 350) as u64;
let top_stars = make_starfield(area.width, 3, 0xA11CE, tick);
let bottom_stars = make_starfield(area.width, 2, 0xBADC0DE, tick + 17);
let content_height: u16 = 19;
let top_pad = area.height.saturating_sub(content_height) / 2;
let mut lines: Vec<Line<'static>> =
Vec::with_capacity((top_pad + content_height) as usize + 4);
for _ in 0..top_pad {
lines.push(Line::raw(""));
}
for line in top_stars {
lines.push(Line::from(Span::styled(
line,
Style::default()
.fg(star_color)
.add_modifier(Modifier::BOLD)
.add_modifier(Modifier::DIM),
)));
}
for line in &wide_logo {
lines.push(Line::from(Span::styled(
(*line).to_string(),
Style::default().fg(logo_color).add_modifier(Modifier::BOLD),
)));
}
lines.push(Line::from(Span::styled(
" -- cli --".to_string(),
Style::default()
.fg(sub_logo_color)
.add_modifier(Modifier::DIM),
)));
lines.push(Line::raw(""));
lines.push(Line::from(Span::styled(
format!("v{}", version),
Style::default().fg(sub_logo_color),
)));
lines.push(Line::from(Span::styled(
"Local AI coding harness + workstation assistant".to_string(),
Style::default().fg(tagline_color),
)));
lines.push(Line::from(Span::styled(
"developed by Ocean Bennett".to_string(),
Style::default()
.fg(author_color)
.add_modifier(Modifier::DIM),
)));
lines.push(Line::raw(""));
for line in bottom_stars {
lines.push(Line::from(Span::styled(
line,
Style::default()
.fg(star_color)
.add_modifier(Modifier::BOLD)
.add_modifier(Modifier::DIM),
)));
}
lines.push(Line::raw(""));
lines.push(Line::from(vec![
Span::styled("[ ", Style::default().fg(logo_color)),
Span::styled(
"PRESS ENTER TO START",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
Span::styled(" ]", Style::default().fg(logo_color)),
]));
let splash = Paragraph::new(lines).alignment(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::with_capacity(cleaned.matches('\n').count() + 1);
for line in cleaned.lines() {
if CRLF_REGEX.is_match(line) {
continue;
}
if line.contains("Updating files:") && line.contains("%") {
continue;
}
let mut sanitized = String::with_capacity(line.len());
for c in line.chars() {
if !c.is_control() || c == '\t' {
sanitized.push(c);
}
}
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()
}