1use super::modal_review::{draw_diff_review, ActiveReview};
2use crate::agent::conversation::{AttachedDocument, AttachedImage, UserTurn};
3use crate::agent::inference::{McpRuntimeState, OperatorCheckpointState, ProviderRuntimeState};
4use crate::agent::specular::SpecularEvent;
5use crate::agent::swarm::{ReviewResponse, SwarmMessage};
6use crate::agent::utils::{strip_ansi, CRLF_REGEX};
7use crate::ui::gpu_monitor::GpuState;
8use crossterm::event::{self, Event, EventStream, KeyCode};
9use futures::StreamExt;
10use ratatui::{
11 backend::Backend,
12 layout::{Constraint, Direction, Layout, Rect},
13 style::{Color, Modifier, Style, Stylize},
14 text::{Line, Span},
15 widgets::{
16 Block, Borders, Clear, Gauge, List, ListItem, Paragraph, Scrollbar, ScrollbarOrientation,
17 ScrollbarState, Wrap,
18 },
19 Terminal,
20};
21use std::sync::{Arc, Mutex};
22use std::time::Instant;
23use tokio::sync::mpsc::Receiver;
24use walkdir::WalkDir;
25
26pub struct PendingApproval {
31 pub display: String,
32 pub tool_name: String,
33 pub diff: Option<String>,
36 pub diff_scroll: u16,
38 pub mutation_label: Option<String>,
39 pub responder: tokio::sync::oneshot::Sender<bool>,
40}
41
42pub struct RustyStats {
45 pub debugging: u32,
46 pub wisdom: u16,
47 pub patience: f32,
48 pub chaos: u8,
49 pub snark: u8,
50}
51
52use std::collections::HashMap;
53
54#[derive(Clone)]
55pub struct ContextFile {
56 pub path: String,
57 pub size: u64,
58 pub status: String,
59}
60
61fn default_active_context() -> Vec<ContextFile> {
62 let root = crate::tools::file_ops::workspace_root();
63
64 let entrypoint_candidates = [
68 "src/main.rs",
69 "src/lib.rs",
70 "src/index.ts",
71 "src/index.js",
72 "src/main.ts",
73 "src/main.js",
74 "src/main.py",
75 "main.py",
76 "main.go",
77 "index.js",
78 "index.ts",
79 "app.py",
80 "app.rs",
81 ];
82 let manifest_candidates = [
83 "Cargo.toml",
84 "package.json",
85 "go.mod",
86 "pyproject.toml",
87 "setup.py",
88 "composer.json",
89 "pom.xml",
90 "build.gradle",
91 ];
92
93 let mut files = Vec::new();
94
95 for path in &entrypoint_candidates {
97 let joined = root.join(path);
98 if joined.exists() {
99 let size = std::fs::metadata(&joined).map(|m| m.len()).unwrap_or(0);
100 files.push(ContextFile {
101 path: path.to_string(),
102 size,
103 status: "Active".to_string(),
104 });
105 break;
106 }
107 }
108
109 for path in &manifest_candidates {
111 let joined = root.join(path);
112 if joined.exists() {
113 let size = std::fs::metadata(&joined).map(|m| m.len()).unwrap_or(0);
114 files.push(ContextFile {
115 path: path.to_string(),
116 size,
117 status: "Active".to_string(),
118 });
119 break;
120 }
121 }
122
123 let src = root.join("src");
125 if src.exists() {
126 let size = std::fs::metadata(&src).map(|m| m.len()).unwrap_or(0);
127 files.push(ContextFile {
128 path: "./src".to_string(),
129 size,
130 status: "Watching".to_string(),
131 });
132 }
133
134 files
135}
136
137pub struct App {
138 pub messages: Vec<Line<'static>>,
139 pub messages_raw: Vec<(String, String)>, pub specular_logs: Vec<String>,
141 pub brief_mode: bool,
142 pub tick_count: u64,
143 pub stats: RustyStats,
144 pub yolo_mode: bool,
145 pub awaiting_approval: Option<PendingApproval>,
147 pub active_workers: HashMap<String, u8>,
148 pub worker_labels: HashMap<String, String>,
149 pub active_review: Option<ActiveReview>,
150 pub input: String,
151 pub input_history: Vec<String>,
152 pub history_idx: Option<usize>,
153 pub thinking: bool,
154 pub agent_running: bool,
155 pub current_thought: String,
156 pub professional: bool,
157 pub last_reasoning: String,
158 pub active_context: Vec<ContextFile>,
159 pub manual_scroll_offset: Option<u16>,
160 pub user_input_tx: tokio::sync::mpsc::Sender<UserTurn>,
162 pub specular_scroll: u16,
163 pub specular_auto_scroll: bool,
166 pub gpu_state: Arc<GpuState>,
168 pub git_state: Arc<crate::agent::git_monitor::GitState>,
170 pub last_input_time: std::time::Instant,
172 pub cancel_token: Arc<std::sync::atomic::AtomicBool>,
173 pub total_tokens: usize,
174 pub current_session_cost: f64,
175 pub model_id: String,
176 pub context_length: usize,
177 prompt_pressure_percent: u8,
178 prompt_estimated_input_tokens: usize,
179 prompt_reserved_output_tokens: usize,
180 prompt_estimated_total_tokens: usize,
181 compaction_percent: u8,
182 compaction_estimated_tokens: usize,
183 compaction_threshold_tokens: usize,
184 compaction_warned_level: u8,
187 last_runtime_profile_time: Instant,
188 vein_file_count: usize,
189 vein_embedded_count: usize,
190 vein_docs_only: bool,
191 provider_state: ProviderRuntimeState,
192 last_provider_summary: String,
193 mcp_state: McpRuntimeState,
194 last_mcp_summary: String,
195 last_operator_checkpoint_state: OperatorCheckpointState,
196 last_operator_checkpoint_summary: String,
197 last_recovery_recipe_summary: String,
198 pub think_mode: Option<bool>,
201 pub workflow_mode: String,
203 pub autocomplete_suggestions: Vec<String>,
205 pub selected_suggestion: usize,
207 pub show_autocomplete: bool,
209 pub autocomplete_filter: String,
211 pub current_objective: String,
213 pub voice_manager: Arc<crate::ui::voice::VoiceManager>,
215 pub voice_loading: bool,
216 pub voice_loading_progress: f64,
217 pub hardware_guard_enabled: bool,
219 pub session_start: std::time::SystemTime,
221 pub soul_name: String,
223 pub attached_context: Option<(String, String)>,
225 pub attached_image: Option<AttachedImage>,
226 hovered_input_action: Option<InputAction>,
227 pub teleported_from: Option<String>,
228 pub nav_list: Vec<std::path::PathBuf>,
230}
231
232impl App {
233 pub fn reset_active_context(&mut self) {
234 self.active_context = default_active_context();
235 }
236
237 pub fn record_error(&mut self) {
238 self.stats.debugging = self.stats.debugging.saturating_add(1);
239 }
240
241 pub fn reset_error_count(&mut self) {
242 self.stats.debugging = 0;
243 }
244
245 pub fn reset_runtime_status_memory(&mut self) {
246 self.last_provider_summary.clear();
247 self.last_mcp_summary.clear();
248 self.last_operator_checkpoint_summary.clear();
249 self.last_operator_checkpoint_state = OperatorCheckpointState::Idle;
250 self.last_recovery_recipe_summary.clear();
251 }
252
253 pub fn clear_pending_attachments(&mut self) {
254 self.attached_context = None;
255 self.attached_image = None;
256 }
257
258 pub fn push_message(&mut self, speaker: &str, content: &str) {
259 let filtered = filter_tui_noise(content);
260 if filtered.is_empty() && !content.is_empty() {
261 return;
262 } self.messages_raw.push((speaker.to_string(), filtered));
265 if self.messages_raw.len() > 100 {
267 self.messages_raw.remove(0);
268 }
269 self.rebuild_formatted_messages();
270 if self.messages.len() > 250 {
272 let to_drain = self.messages.len() - 250;
273 self.messages.drain(0..to_drain);
274 }
275 }
276
277 pub fn update_last_message(&mut self, token: &str) {
278 if let Some(last_raw) = self.messages_raw.last_mut() {
279 if last_raw.0 == "Hematite" {
280 last_raw.1.push_str(token);
281 if token.contains(' ')
284 || token.contains('\n')
285 || token.contains('.')
286 || token.len() > 5
287 {
288 self.rebuild_formatted_messages();
289 }
290 }
291 }
292 }
293
294 fn rebuild_formatted_messages(&mut self) {
295 self.messages.clear();
296 let total = self.messages_raw.len();
297 for (i, (speaker, content)) in self.messages_raw.iter().enumerate() {
298 let is_last = i == total - 1;
299 let formatted = self.format_message(speaker, content, is_last);
300 self.messages.extend(formatted);
301 if !is_last {
304 self.messages.push(Line::raw(""));
305 }
306 }
307 }
308
309 fn format_message(&self, speaker: &str, content: &str, _is_last: bool) -> Vec<Line<'static>> {
310 let mut lines = Vec::new();
311 let rust = Color::Rgb(180, 90, 50);
313 let style = match speaker {
314 "You" => Style::default()
315 .fg(Color::Green)
316 .add_modifier(Modifier::BOLD),
317 "Hematite" => Style::default().fg(rust).add_modifier(Modifier::BOLD),
318 "Tool" => Style::default().fg(Color::Cyan),
319 _ => Style::default().fg(Color::DarkGray),
320 };
321
322 let cleaned = crate::agent::inference::strip_think_blocks(content)
324 .trim()
325 .to_string();
326 let cleaned = strip_ghost_prefix(&cleaned);
327
328 let mut is_first = true;
329 for raw_line in cleaned.lines() {
330 if !is_first && raw_line.trim().is_empty() {
334 lines.push(Line::raw(""));
335 continue;
336 }
337
338 let label = if is_first {
339 format!("{}: ", speaker)
340 } else {
341 " ".to_string()
342 };
343
344 if speaker == "System" && (raw_line.contains(" +") || raw_line.contains(" -")) {
346 let mut spans: Vec<Span<'static>> =
347 vec![Span::raw(" "), Span::styled(label, style)];
348 for token in raw_line.split_whitespace() {
351 let is_add = token.starts_with('+')
352 && token.len() > 1
353 && token[1..].chars().all(|c| c.is_ascii_digit());
354 let is_rem = token.starts_with('-')
355 && token.len() > 1
356 && token[1..].chars().all(|c| c.is_ascii_digit());
357 let is_path =
358 (token.contains('/') || token.contains('\\') || token.contains('.'))
359 && !token.starts_with('+')
360 && !token.starts_with('-')
361 && !token.ends_with(':');
362 let span = if is_add {
363 Span::styled(
364 format!("{} ", token),
365 Style::default()
366 .fg(Color::Green)
367 .add_modifier(Modifier::BOLD),
368 )
369 } else if is_rem {
370 Span::styled(
371 format!("{} ", token),
372 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
373 )
374 } else if is_path {
375 Span::styled(
376 format!("{} ", token),
377 Style::default()
378 .fg(Color::White)
379 .add_modifier(Modifier::BOLD),
380 )
381 } else {
382 Span::raw(format!("{} ", token))
383 };
384 spans.push(span);
385 }
386 lines.push(Line::from(spans));
387 is_first = false;
388 continue;
389 }
390
391 if speaker == "Tool"
392 && (raw_line.starts_with("-")
393 || raw_line.starts_with("+")
394 || raw_line.starts_with("@@"))
395 {
396 let line_style = if raw_line.starts_with("-") {
397 Style::default().fg(Color::Red)
398 } else if raw_line.starts_with("+") {
399 Style::default().fg(Color::Green)
400 } else {
401 Style::default()
402 .fg(Color::Yellow)
403 .add_modifier(Modifier::DIM)
404 };
405 lines.push(Line::from(vec![
406 Span::raw(" "), Span::styled(raw_line.to_string(), line_style),
408 ]));
409 } else {
410 let mut spans = vec![Span::raw(" "), Span::styled(label, style)];
411 if speaker == "Hematite" {
416 if raw_line.trim_start().starts_with("```") {
417 spans.push(Span::styled(
418 raw_line.to_string(),
419 Style::default().fg(Color::DarkGray),
420 ));
421 } else {
422 spans.extend(inline_markdown_core(raw_line));
423 }
424 } else {
425 spans.push(Span::raw(raw_line.to_string()));
426 }
427 lines.push(Line::from(spans));
428 }
429 is_first = false;
430 }
431
432 lines
433 }
434
435 pub fn update_autocomplete(&mut self) {
438 let root = crate::tools::file_ops::workspace_root();
439 let query = if let Some(pos) = self.input.rfind('@') {
441 &self.input[pos + 1..]
442 } else {
443 ""
444 }
445 .to_lowercase();
446
447 self.autocomplete_filter = query.clone();
448
449 let mut matches = Vec::new();
450 let mut total_found = 0;
451
452 for entry in WalkDir::new(&root)
453 .into_iter()
454 .filter_entry(|e| {
455 let name = e.file_name().to_string_lossy();
456 !name.starts_with('.') && name != "target" && name != "node_modules"
457 })
458 .flatten()
459 {
460 if entry.file_type().is_file() {
461 let path = entry.path().strip_prefix(&root).unwrap_or(entry.path());
462 let path_str = path.to_string_lossy().to_string();
463 if path_str.to_lowercase().contains(&query) {
464 total_found += 1;
465 if matches.len() < 15 {
466 matches.push(path_str);
468 }
469 }
470 }
471 if total_found > 100 {
472 break;
473 } }
475
476 matches.sort_by(|a, b| {
478 let a_ext = a.split('.').last().unwrap_or("");
479 let b_ext = b.split('.').last().unwrap_or("");
480 let a_is_src = a_ext == "rs" || a_ext == "md";
481 let b_is_src = b_ext == "rs" || b_ext == "md";
482 b_is_src.cmp(&a_is_src)
483 });
484
485 self.autocomplete_suggestions = matches;
486 self.selected_suggestion = self
487 .selected_suggestion
488 .min(self.autocomplete_suggestions.len().saturating_sub(1));
489 }
490
491 pub fn push_context_file(&mut self, path: String, status: String) {
493 self.active_context.retain(|f| f.path != path);
494
495 let root = crate::tools::file_ops::workspace_root();
496 let full_path = root.join(&path);
497 let size = std::fs::metadata(full_path).map(|m| m.len()).unwrap_or(0);
498
499 self.active_context.push(ContextFile { path, size, status });
500
501 if self.active_context.len() > 10 {
502 self.active_context.remove(0);
503 }
504 }
505
506 pub fn update_objective(&mut self) {
508 let hdir = crate::tools::file_ops::hematite_dir();
509 let plan_path = hdir.join("PLAN.md");
510 if plan_path.exists() {
511 if let Some(plan) = crate::tools::plan::load_plan_handoff() {
512 if plan.has_signal() && !plan.goal.trim().is_empty() {
513 self.current_objective = plan.summary_line();
514 return;
515 }
516 }
517 }
518 let path = hdir.join("TASK.md");
519 if let Ok(content) = std::fs::read_to_string(path) {
520 for line in content.lines() {
521 let trimmed = line.trim();
522 if (trimmed.starts_with("- [ ]") || trimmed.starts_with("- [/]"))
524 && trimmed.len() > 6
525 {
526 self.current_objective = trimmed[6..].trim().to_string();
527 return;
528 }
529 }
530 }
531 self.current_objective = "Idle".into();
532 }
533
534 pub fn copy_specular_to_clipboard(&self) {
536 let mut out = String::from("=== SPECULAR LOG ===\n\n");
537
538 if !self.last_reasoning.is_empty() {
539 out.push_str("--- Last Reasoning Block ---\n");
540 out.push_str(&self.last_reasoning);
541 out.push_str("\n\n");
542 }
543
544 if !self.current_thought.is_empty() {
545 out.push_str("--- In-Progress Reasoning ---\n");
546 out.push_str(&self.current_thought);
547 out.push_str("\n\n");
548 }
549
550 if !self.specular_logs.is_empty() {
551 out.push_str("--- Specular Events ---\n");
552 for entry in &self.specular_logs {
553 out.push_str(entry);
554 out.push('\n');
555 }
556 out.push('\n');
557 }
558
559 out.push_str(&format!(
560 "Tokens: {} | Cost: ${:.4}\n",
561 self.total_tokens, self.current_session_cost
562 ));
563
564 let mut child = std::process::Command::new("clip.exe")
565 .stdin(std::process::Stdio::piped())
566 .spawn()
567 .expect("Failed to spawn clip.exe");
568 if let Some(mut stdin) = child.stdin.take() {
569 use std::io::Write;
570 let _ = stdin.write_all(out.as_bytes());
571 }
572 let _ = child.wait();
573 }
574
575 pub fn write_session_report(&self) {
576 let report_dir = crate::tools::file_ops::hematite_dir().join("reports");
577 if std::fs::create_dir_all(&report_dir).is_err() {
578 return;
579 }
580
581 let start_secs = self
583 .session_start
584 .duration_since(std::time::UNIX_EPOCH)
585 .unwrap_or_default()
586 .as_secs();
587
588 let secs_in_day = start_secs % 86400;
590 let days = start_secs / 86400;
591 let years_approx = (days * 4 + 2) / 1461;
592 let year = 1970 + years_approx;
593 let day_of_year = days - (years_approx * 365 + years_approx / 4);
594 let month = (day_of_year / 30 + 1).min(12);
595 let day = (day_of_year % 30 + 1).min(31);
596 let hh = secs_in_day / 3600;
597 let mm = (secs_in_day % 3600) / 60;
598 let ss = secs_in_day % 60;
599 let timestamp = format!(
600 "{:04}-{:02}-{:02}_{:02}-{:02}-{:02}",
601 year, month, day, hh, mm, ss
602 );
603
604 let duration_secs = std::time::SystemTime::now()
605 .duration_since(self.session_start)
606 .unwrap_or_default()
607 .as_secs();
608
609 let report_path = report_dir.join(format!("session_{}.json", timestamp));
610
611 let turns: Vec<serde_json::Value> = self
612 .messages_raw
613 .iter()
614 .map(|(speaker, text)| serde_json::json!({ "speaker": speaker, "text": text }))
615 .collect();
616
617 let report = serde_json::json!({
618 "session_start": timestamp,
619 "duration_secs": duration_secs,
620 "model": self.model_id,
621 "context_length": self.context_length,
622 "total_tokens": self.total_tokens,
623 "estimated_cost_usd": self.current_session_cost,
624 "turn_count": turns.len(),
625 "transcript": turns,
626 });
627
628 if let Ok(json) = serde_json::to_string_pretty(&report) {
629 let _ = std::fs::write(&report_path, json);
630 }
631 }
632
633 pub fn copy_transcript_to_clipboard(&self) {
634 let mut history = self
635 .messages_raw
636 .iter()
637 .map(|m| format!("[{}] {}\n", m.0, m.1))
638 .collect::<String>();
639
640 history.push_str("\nSession Stats\n");
641 history.push_str(&format!("Tokens: {}\n", self.total_tokens));
642 history.push_str(&format!("Cost: ${:.4}\n", self.current_session_cost));
643
644 copy_text_to_clipboard(&history);
645 }
646
647 pub fn copy_clean_transcript_to_clipboard(&self) {
648 let mut history = self
649 .messages_raw
650 .iter()
651 .filter(|(speaker, content)| !should_skip_transcript_copy_entry(speaker, content))
652 .map(|m| format!("[{}] {}\n", m.0, m.1))
653 .collect::<String>();
654
655 history.push_str("\nSession Stats\n");
656 history.push_str(&format!("Tokens: {}\n", self.total_tokens));
657 history.push_str(&format!("Cost: ${:.4}\n", self.current_session_cost));
658
659 copy_text_to_clipboard(&history);
660 }
661
662 pub fn copy_last_reply_to_clipboard(&self) -> bool {
663 if let Some((speaker, content)) = self
664 .messages_raw
665 .iter()
666 .rev()
667 .find(|(speaker, content)| is_copyable_hematite_reply(speaker, content))
668 {
669 let cleaned = cleaned_copyable_reply_text(content);
670 let payload = format!("[{}] {}", speaker, cleaned);
671 copy_text_to_clipboard(&payload);
672 true
673 } else {
674 false
675 }
676 }
677}
678
679fn copy_text_to_clipboard(text: &str) {
680 if copy_text_to_clipboard_powershell(text) {
681 return;
682 }
683
684 let mut child = std::process::Command::new("clip.exe")
687 .stdin(std::process::Stdio::piped())
688 .spawn()
689 .expect("Failed to spawn clip.exe");
690
691 if let Some(mut stdin) = child.stdin.take() {
692 use std::io::Write;
693 let _ = stdin.write_all(text.as_bytes());
694 }
695 let _ = child.wait();
696}
697
698#[cfg(windows)]
701fn get_console_pixel_rect() -> Option<(i32, i32, i32, i32)> {
702 let script = concat!(
703 "Add-Type -TypeDefinition '",
704 "using System;using System.Runtime.InteropServices;",
705 "public class WG{",
706 "[DllImport(\"kernel32\")]public static extern IntPtr GetConsoleWindow();",
707 "[DllImport(\"user32\")]public static extern bool GetWindowRect(IntPtr h,out RECT r);",
708 "[StructLayout(LayoutKind.Sequential)]public struct RECT{public int L,T,R,B;}}",
709 "';",
710 "$h=[WG]::GetConsoleWindow();$r=New-Object WG+RECT;",
711 "[WG]::GetWindowRect($h,[ref]$r)|Out-Null;",
712 "Write-Output \"$($r.L) $($r.T) $($r.R-$r.L) $($r.B-$r.T)\""
713 );
714 let out = std::process::Command::new("powershell.exe")
715 .args(["-NoProfile", "-NonInteractive", "-Command", script])
716 .output()
717 .ok()?;
718 let s = String::from_utf8_lossy(&out.stdout);
719 let parts: Vec<i32> = s
720 .split_whitespace()
721 .filter_map(|v| v.trim().parse().ok())
722 .collect();
723 if parts.len() >= 4 {
724 Some((parts[0], parts[1], parts[2], parts[3]))
725 } else {
726 None
727 }
728}
729
730#[cfg(windows)]
734fn get_console_close_target_pid_sync() -> Option<u32> {
735 let pid = std::process::id();
736 let script = format!(
737 r#"
738$current = [uint32]{pid}
739$seen = New-Object 'System.Collections.Generic.HashSet[uint32]'
740$shell_pattern = '^(cmd|powershell|pwsh|bash|sh|wsl|ubuntu|debian|kali|arch)$'
741$skip_pattern = '^(WindowsTerminal|wt|OpenConsole|conhost)$'
742$fallback = $null
743$found = $false
744while ($current -gt 0 -and $seen.Add($current)) {{
745 $proc = Get-CimInstance Win32_Process -Filter "ProcessId=$current" -ErrorAction SilentlyContinue
746 if (-not $proc) {{ break }}
747 $parent = [uint32]$proc.ParentProcessId
748 if ($parent -le 0) {{ break }}
749 $parent_proc = Get-Process -Id $parent -ErrorAction SilentlyContinue
750 if ($parent_proc) {{
751 $name = $parent_proc.ProcessName
752 if ($name -match $shell_pattern) {{
753 $found = $true
754 Write-Output $parent
755 break
756 }}
757 if (-not $fallback -and $name -notmatch $skip_pattern) {{
758 $fallback = $parent
759 }}
760 }}
761 $current = $parent
762}}
763if (-not $found -and $fallback) {{ Write-Output $fallback }}
764"#
765 );
766 let out = std::process::Command::new("powershell.exe")
767 .args(["-NoProfile", "-NonInteractive", "-Command", &script])
768 .output()
769 .ok()?;
770 String::from_utf8_lossy(&out.stdout).trim().parse().ok()
771}
772
773#[cfg(windows)]
780fn spawn_dive_in_terminal(path: &str) {
781 let pid = std::process::id();
782 let current_dir = std::env::current_dir()
783 .map(|p| p.to_string_lossy().to_string())
784 .unwrap_or_default();
785
786 let close_target_pid = get_console_close_target_pid_sync().unwrap_or(0);
787 let (px, py, pw, ph) = get_console_pixel_rect().unwrap_or((50, 50, 1100, 750));
788
789 let bat_path = std::env::temp_dir().join("hematite_teleport.bat");
790 let bat_content = format!(
791 "@echo off\r\ncd /d \"{p}\"\r\nhematite --no-splash --teleported-from \"{o}\"\r\n",
792 p = path.replace('"', ""),
793 o = current_dir.replace('"', ""),
794 );
795 if std::fs::write(&bat_path, bat_content).is_err() {
796 return;
797 }
798 let bat_str = bat_path.to_string_lossy().to_string();
799 let bat_ps = bat_str.replace('\'', "''");
800
801 let script = format!(
802 r#"
803Add-Type -TypeDefinition @'
804using System; using System.Runtime.InteropServices;
805public class WM {{ [DllImport("user32")] public static extern bool MoveWindow(IntPtr h,int x,int y,int w,int ht,bool b); }}
806'@
807$proc = Start-Process cmd.exe -ArgumentList @('/k', '"{bat}"') -PassThru
808$deadline = (Get-Date).AddSeconds(8)
809while ((Get-Date) -lt $deadline -and $proc.MainWindowHandle -eq [IntPtr]::Zero) {{ Start-Sleep -Milliseconds 100 }}
810if ($proc.MainWindowHandle -ne [IntPtr]::Zero) {{
811 [WM]::MoveWindow($proc.MainWindowHandle, {px}, {py}, {pw}, {ph}, $true) | Out-Null
812}}
813Wait-Process -Id {pid} -ErrorAction SilentlyContinue
814if ({close_pid} -gt 0) {{
815 Stop-Process -Id {close_pid} -Force -ErrorAction SilentlyContinue
816}}
817"#,
818 bat = bat_ps,
819 px = px,
820 py = py,
821 pw = pw,
822 ph = ph,
823 pid = pid,
824 close_pid = close_target_pid,
825 );
826
827 let _ = std::process::Command::new("powershell.exe")
828 .args([
829 "-NoProfile",
830 "-NonInteractive",
831 "-WindowStyle",
832 "Hidden",
833 "-Command",
834 &script,
835 ])
836 .spawn();
837}
838
839#[cfg(not(windows))]
840fn spawn_dive_in_terminal(_path: &str) {}
841
842fn copy_text_to_clipboard_powershell(text: &str) -> bool {
843 let temp_path = std::env::temp_dir().join(format!(
844 "hematite-clipboard-{}-{}.txt",
845 std::process::id(),
846 std::time::SystemTime::now()
847 .duration_since(std::time::UNIX_EPOCH)
848 .map(|d| d.as_millis())
849 .unwrap_or_default()
850 ));
851
852 if std::fs::write(&temp_path, text.as_bytes()).is_err() {
853 return false;
854 }
855
856 let escaped_path = temp_path.display().to_string().replace('\'', "''");
857 let script = format!(
858 "$t = Get-Content -LiteralPath '{}' -Raw -Encoding UTF8; Set-Clipboard -Value $t",
859 escaped_path
860 );
861
862 let status = std::process::Command::new("powershell.exe")
863 .args(["-NoProfile", "-NonInteractive", "-Command", &script])
864 .status();
865
866 let _ = std::fs::remove_file(&temp_path);
867
868 matches!(status, Ok(code) if code.success())
869}
870
871fn should_skip_transcript_copy_entry(speaker: &str, content: &str) -> bool {
872 if speaker != "System" {
873 return false;
874 }
875
876 content.starts_with("Hematite Commands:\n")
877 || content.starts_with("Document note: `/attach`")
878 || content == "Chat transcript copied to clipboard."
879 || content == "SPECULAR log copied to clipboard (reasoning + events)."
880 || content == "Cancellation requested. Logs copied to clipboard."
881}
882
883fn is_copyable_hematite_reply(speaker: &str, content: &str) -> bool {
884 if speaker != "Hematite" {
885 return false;
886 }
887
888 let trimmed = content.trim();
889 if trimmed.is_empty() {
890 return false;
891 }
892
893 if trimmed == "Initialising Engine & Hardware..."
894 || trimmed == "Swarm engaged."
895 || trimmed.starts_with("Hematite v")
896 || trimmed.starts_with("Swarm analyzing: '")
897 || trimmed.ends_with("Standing by for review...")
898 || trimmed.ends_with("conflict - review required.")
899 || trimmed.ends_with("conflict — review required.")
900 {
901 return false;
902 }
903
904 true
905}
906
907fn cleaned_copyable_reply_text(content: &str) -> String {
908 let cleaned = content
909 .replace("<thought>", "")
910 .replace("</thought>", "")
911 .replace("<think>", "")
912 .replace("</think>", "");
913 strip_ghost_prefix(cleaned.trim()).trim().to_string()
914}
915
916#[derive(Clone, Copy, PartialEq, Eq)]
919enum InputAction {
920 Stop,
921 PickDocument,
922 PickImage,
923 Detach,
924 New,
925 Forget,
926 Help,
927}
928
929struct InputActionVisual {
930 action: InputAction,
931 label: String,
932 style: Style,
933}
934
935#[derive(Clone, Copy)]
936enum AttachmentPickerKind {
937 Document,
938 Image,
939}
940
941fn attach_document_from_path(app: &mut App, file_path: &str) {
942 let p = std::path::Path::new(file_path);
943 match crate::memory::vein::extract_document_text(p) {
944 Ok(text) => {
945 let name = p
946 .file_name()
947 .and_then(|n| n.to_str())
948 .unwrap_or(file_path)
949 .to_string();
950 let preview_len = text.len().min(200);
951 let estimated_tokens = text.len() / 4;
953 let ctx = app.context_length.max(1);
954 let budget_pct = (estimated_tokens * 100) / ctx;
955 let budget_note = if budget_pct >= 75 {
956 format!(
957 "\nWarning: this document is ~{} tokens (~{}% of your {}k context). \
958 Very little room left for conversation. Consider /attach on a shorter excerpt.",
959 estimated_tokens, budget_pct, ctx / 1000
960 )
961 } else if budget_pct >= 40 {
962 format!(
963 "\nNote: this document is ~{} tokens (~{}% of your {}k context).",
964 estimated_tokens,
965 budget_pct,
966 ctx / 1000
967 )
968 } else {
969 String::new()
970 };
971 app.push_message(
972 "System",
973 &format!(
974 "Attached document: {} ({} chars) for the next message.\nPreview: {}...{}",
975 name,
976 text.len(),
977 &text[..preview_len],
978 budget_note,
979 ),
980 );
981 app.attached_context = Some((name, text));
982 }
983 Err(e) => {
984 app.push_message("System", &format!("Attach failed: {}", e));
985 }
986 }
987}
988
989fn attach_image_from_path(app: &mut App, file_path: &str) {
990 let p = std::path::Path::new(file_path);
991 match crate::tools::vision::encode_image_as_data_url(p) {
992 Ok(_) => {
993 let name = p
994 .file_name()
995 .and_then(|n| n.to_str())
996 .unwrap_or(file_path)
997 .to_string();
998 app.push_message(
999 "System",
1000 &format!("Attached image: {} for the next message.", name),
1001 );
1002 app.attached_image = Some(AttachedImage {
1003 name,
1004 path: file_path.to_string(),
1005 });
1006 }
1007 Err(e) => {
1008 app.push_message("System", &format!("Image attach failed: {}", e));
1009 }
1010 }
1011}
1012
1013fn is_document_path(path: &std::path::Path) -> bool {
1014 matches!(
1015 path.extension()
1016 .and_then(|e| e.to_str())
1017 .unwrap_or("")
1018 .to_ascii_lowercase()
1019 .as_str(),
1020 "pdf" | "md" | "markdown" | "txt" | "rst"
1021 )
1022}
1023
1024fn is_image_path(path: &std::path::Path) -> bool {
1025 matches!(
1026 path.extension()
1027 .and_then(|e| e.to_str())
1028 .unwrap_or("")
1029 .to_ascii_lowercase()
1030 .as_str(),
1031 "png" | "jpg" | "jpeg" | "gif" | "webp"
1032 )
1033}
1034
1035fn extract_pasted_path_candidates(content: &str) -> Vec<String> {
1036 let mut out = Vec::new();
1037 let trimmed = content.trim();
1038 if trimmed.is_empty() {
1039 return out;
1040 }
1041
1042 let mut in_quotes = false;
1043 let mut current = String::new();
1044 for ch in trimmed.chars() {
1045 if ch == '"' {
1046 if in_quotes && !current.trim().is_empty() {
1047 out.push(current.trim().to_string());
1048 current.clear();
1049 }
1050 in_quotes = !in_quotes;
1051 continue;
1052 }
1053 if in_quotes {
1054 current.push(ch);
1055 }
1056 }
1057 if !out.is_empty() {
1058 return out;
1059 }
1060
1061 for line in trimmed.lines() {
1062 let candidate = line.trim().trim_matches('"').trim();
1063 if !candidate.is_empty() {
1064 out.push(candidate.to_string());
1065 }
1066 }
1067
1068 if out.is_empty() {
1069 out.push(trimmed.trim_matches('"').to_string());
1070 }
1071 out
1072}
1073
1074fn try_attach_from_paste(app: &mut App, content: &str) -> bool {
1075 let mut attached_doc = false;
1076 let mut attached_image = false;
1077 let mut ignored_supported = 0usize;
1078
1079 for raw in extract_pasted_path_candidates(content) {
1080 let path = std::path::Path::new(&raw);
1081 if !path.exists() {
1082 continue;
1083 }
1084 if is_image_path(path) {
1085 if attached_image || app.attached_image.is_some() {
1086 ignored_supported += 1;
1087 } else {
1088 attach_image_from_path(app, &raw);
1089 attached_image = true;
1090 }
1091 } else if is_document_path(path) {
1092 if attached_doc || app.attached_context.is_some() {
1093 ignored_supported += 1;
1094 } else {
1095 attach_document_from_path(app, &raw);
1096 attached_doc = true;
1097 }
1098 }
1099 }
1100
1101 if ignored_supported > 0 {
1102 app.push_message(
1103 "System",
1104 &format!(
1105 "Ignored {} extra dropped file(s). Hematite currently keeps one pending document and one pending image.",
1106 ignored_supported
1107 ),
1108 );
1109 }
1110
1111 attached_doc || attached_image
1112}
1113
1114fn compute_input_height(total_width: u16, input_len: usize) -> u16 {
1115 let width = total_width.max(1) as usize;
1116 let approx_input_w = (width * 65 / 100).saturating_sub(4).max(1);
1117 let needed_lines = (input_len / approx_input_w) as u16 + 3;
1118 needed_lines.clamp(3, 10)
1119}
1120
1121fn input_rect_for_size(size: Rect, input_len: usize) -> Rect {
1122 let input_height = compute_input_height(size.width, input_len);
1123 Layout::default()
1124 .direction(Direction::Vertical)
1125 .constraints([
1126 Constraint::Min(0),
1127 Constraint::Length(input_height),
1128 Constraint::Length(3),
1129 ])
1130 .split(size)[1]
1131}
1132
1133fn input_title_area(input_rect: Rect) -> Rect {
1134 Rect {
1135 x: input_rect.x.saturating_add(1),
1136 y: input_rect.y,
1137 width: input_rect.width.saturating_sub(2),
1138 height: 1,
1139 }
1140}
1141
1142fn build_input_actions(app: &App) -> Vec<InputActionVisual> {
1143 let doc_label = if app.attached_context.is_some() {
1144 "Files*"
1145 } else {
1146 "Files"
1147 };
1148 let image_label = if app.attached_image.is_some() {
1149 "Image*"
1150 } else {
1151 "Image"
1152 };
1153 let detach_style = if app.attached_context.is_some() || app.attached_image.is_some() {
1154 Style::default()
1155 .fg(Color::Yellow)
1156 .add_modifier(Modifier::BOLD)
1157 } else {
1158 Style::default().fg(Color::DarkGray)
1159 };
1160
1161 let mut actions = Vec::new();
1162 if app.agent_running {
1163 actions.push(InputActionVisual {
1164 action: InputAction::Stop,
1165 label: "Stop Esc".to_string(),
1166 style: Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
1167 });
1168 } else {
1169 actions.push(InputActionVisual {
1170 action: InputAction::New,
1171 label: "New".to_string(),
1172 style: Style::default()
1173 .fg(Color::Green)
1174 .add_modifier(Modifier::BOLD),
1175 });
1176 actions.push(InputActionVisual {
1177 action: InputAction::Forget,
1178 label: "Forget".to_string(),
1179 style: Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
1180 });
1181 }
1182
1183 actions.push(InputActionVisual {
1184 action: InputAction::PickDocument,
1185 label: format!("{} ^O", doc_label),
1186 style: Style::default()
1187 .fg(Color::Cyan)
1188 .add_modifier(Modifier::BOLD),
1189 });
1190 actions.push(InputActionVisual {
1191 action: InputAction::PickImage,
1192 label: format!("{} ^I", image_label),
1193 style: Style::default()
1194 .fg(Color::Magenta)
1195 .add_modifier(Modifier::BOLD),
1196 });
1197 actions.push(InputActionVisual {
1198 action: InputAction::Detach,
1199 label: "Detach".to_string(),
1200 style: detach_style,
1201 });
1202 actions.push(InputActionVisual {
1203 action: InputAction::Help,
1204 label: "Help".to_string(),
1205 style: Style::default()
1206 .fg(Color::Blue)
1207 .add_modifier(Modifier::BOLD),
1208 });
1209 actions
1210}
1211
1212fn visible_input_actions(app: &App, max_width: u16) -> Vec<InputActionVisual> {
1213 let mut used = 0u16;
1214 let mut visible = Vec::new();
1215 for action in build_input_actions(app) {
1216 let chip_width = action.label.chars().count() as u16 + 2;
1217 let gap = if visible.is_empty() { 0 } else { 1 };
1218 if used + gap + chip_width > max_width {
1219 break;
1220 }
1221 used += gap + chip_width;
1222 visible.push(action);
1223 }
1224 visible
1225}
1226
1227fn input_status_text(app: &App) -> String {
1228 let voice_status = if app.voice_manager.is_enabled() {
1229 "ON"
1230 } else {
1231 "OFF"
1232 };
1233 let approvals_status = if app.yolo_mode { "OFF" } else { "ON" };
1234 let doc_status = if app.attached_context.is_some() {
1235 "DOC"
1236 } else {
1237 "--"
1238 };
1239 let image_status = if app.attached_image.is_some() {
1240 "IMG"
1241 } else {
1242 "--"
1243 };
1244 if app.agent_running {
1245 format!(
1246 "pending:{}:{} | voice:{}",
1247 doc_status, image_status, voice_status
1248 )
1249 } else {
1250 format!(
1251 "pending:{}:{} | voice:{} | appr:{} | Len:{}",
1252 doc_status,
1253 image_status,
1254 voice_status,
1255 approvals_status,
1256 app.input.len()
1257 )
1258 }
1259}
1260
1261fn visible_input_actions_for_title(app: &App, title_area: Rect) -> Vec<InputActionVisual> {
1262 let reserved = input_status_text(app).chars().count() as u16 + 3;
1263 let max_width = title_area.width.saturating_sub(reserved);
1264 visible_input_actions(app, max_width)
1265}
1266
1267fn input_action_hitboxes(app: &App, title_area: Rect) -> Vec<(InputAction, u16, u16)> {
1268 let mut x = title_area.x;
1269 let mut out = Vec::new();
1270 for action in visible_input_actions_for_title(app, title_area) {
1271 let chip_width = action.label.chars().count() as u16 + 2;
1272 out.push((action.action, x, x + chip_width.saturating_sub(1)));
1273 x = x.saturating_add(chip_width + 1);
1274 }
1275 out
1276}
1277
1278fn render_input_title(app: &App, title_area: Rect) -> Line<'static> {
1279 let mut spans = Vec::new();
1280 let actions = visible_input_actions_for_title(app, title_area);
1281 for (idx, action) in actions.into_iter().enumerate() {
1282 if idx > 0 {
1283 spans.push(Span::raw(" "));
1284 }
1285 let style = if app.hovered_input_action == Some(action.action) {
1286 action
1287 .style
1288 .bg(Color::Rgb(85, 48, 26))
1289 .add_modifier(Modifier::REVERSED)
1290 } else {
1291 action.style
1292 };
1293 spans.push(Span::styled(format!("[{}]", action.label), style));
1294 }
1295 let status = input_status_text(app);
1296 if !spans.is_empty() {
1297 spans.push(Span::raw(" | "));
1298 }
1299 spans.push(Span::styled(status, Style::default().fg(Color::DarkGray)));
1300 Line::from(spans)
1301}
1302
1303fn reset_visible_session_state(app: &mut App) {
1304 app.messages.clear();
1305 app.messages_raw.clear();
1306 app.last_reasoning.clear();
1307 app.current_thought.clear();
1308 app.specular_logs.clear();
1309 app.reset_error_count();
1310 app.reset_runtime_status_memory();
1311 app.reset_active_context();
1312 app.clear_pending_attachments();
1313 app.current_objective = "Idle".into();
1314}
1315
1316fn request_stop(app: &mut App) {
1317 app.voice_manager.stop();
1318 app.cancel_token
1319 .store(true, std::sync::atomic::Ordering::SeqCst);
1320 if app.thinking || app.agent_running {
1321 app.write_session_report();
1322 app.copy_transcript_to_clipboard();
1323 app.push_message(
1324 "System",
1325 "Cancellation requested. Logs copied to clipboard.",
1326 );
1327 }
1328}
1329
1330fn show_help_message(app: &mut App) {
1331 app.push_message(
1332 "System",
1333 "Hematite Commands:\n\
1334 /chat - (Mode) Conversation mode - clean chat, no tool noise\n\
1335 /agent - (Mode) Full coding harness + workstation mode - tools, file edits, builds, inspection\n\
1336 /reroll - (Soul) Hatch a new companion mid-session\n\
1337 /auto - (Flow) Let Hematite choose the narrowest effective workflow\n\
1338 /rules [view|edit]- (Meta) View status or edit local/shared project guidelines\n\
1339 /ask [prompt] - (Flow) Read-only analysis mode; optional inline prompt\n\
1340 /code [prompt] - (Flow) Explicit implementation mode; optional inline prompt\n\
1341 /architect [prompt] - (Flow) Plan-first mode; optional inline prompt\n\
1342 /implement-plan - (Flow) Execute the saved architect handoff in /code\n\
1343 /read-only [prompt] - (Flow) Hard read-only mode; optional inline prompt\n\
1344 /teach [prompt] - (Flow) Teacher mode; inspect machine then walk you through any admin task step-by-step\n\
1345 /new - (Reset) Fresh task context; clear chat, pins, and task files\n\
1346 /forget - (Wipe) Hard forget; purge saved memory and Vein index too\n\
1347 /cd <path> - (Nav) Teleport to another directory and close this session; supports bare tokens like downloads, desktop, docs, home, temp, and ~\n\
1348 /ls [path|N] - (Nav) List common locations or subdirectories; /ls <N> teleports to the numbered entry\n\
1349 /vein-inspect - (Vein) Inspect indexed memory, hot files, and active room bias\n\
1350 /workspace-profile - (Profile) Show the auto-generated workspace profile\n\
1351 /rules - (Rules) View behavioral guidelines (.hematite/rules.md)\n\
1352 /version - (Build) Show the running Hematite version\n\
1353 /about - (Info) Show author, repo, and product info\n\
1354 /vein-reset - (Vein) Wipe the RAG index; rebuilds automatically on next turn\n\
1355 /clear - (UI) Clear dialogue display only\n\
1356 /health - (Diag) Run a synthesized plain-English system health report\n\
1357 /explain <text> - (Help) Paste an error to get a non-technical breakdown\n\
1358 /gemma-native [auto|on|off|status] - (Model) Auto/force/disable Gemma 4 native formatting\n\
1359 /runtime-refresh - (Model) Re-read LM Studio model + CTX now\n\
1360 /undo - (Ghost) Revert last file change\n\
1361 /diff - (Git) Show session changes (--stat)\n\
1362 /lsp - (Logic) Start Language Servers (semantic intelligence)\n\
1363 /swarm <text> - (Swarm) Spawn parallel workers on a directive\n\
1364 /worktree <cmd> - (Isolated) Manage git worktrees (list|add|remove|prune)\n\
1365 /think - (Brain) Enable deep reasoning mode\n\
1366 /no_think - (Speed) Disable reasoning (3-5x faster responses)\n\
1367 /voice - (TTS) List all available voices\n\
1368 /voice N - (TTS) Select voice by number\n\
1369 /read <text> - (TTS) Speak text aloud directly, bypassing the model. ESC to stop.\n\
1370 /explain <text> - (Plain English) Paste any error or output; Hematite explains it in plain English.\n\
1371 /health - (SysAdmin) Run a full system health report (disk, RAM, tools, recent errors).\n\
1372 /attach <path> - (Docs) Attach a PDF/markdown/txt file for next message (PDF best-effort)\n\
1373 /attach-pick - (Docs) Open a file picker and attach a document\n\
1374 /image <path> - (Vision) Attach an image for the next message\n\
1375 /image-pick - (Vision) Open a file picker and attach an image\n\
1376 /detach - (Context) Drop pending document/image attachments\n\
1377 /copy - (Debug) Copy exact session transcript (includes help/system output)\n\
1378 /copy-last - (Debug) Copy the latest Hematite reply only\n\
1379 /copy-clean - (Debug) Copy chat transcript without help/debug boilerplate\n\
1380 /copy2 - (Debug) Copy SPECULAR log to clipboard (reasoning + events)\n\
1381 \nHotkeys:\n\
1382 Ctrl+B - Toggle Brief Mode (minimal output)\n\
1383 Ctrl+P - Toggle Professional Mode (strip personality)\n\
1384 Ctrl+O - Open document picker for next-turn context\n\
1385 Ctrl+I - Open image picker for next-turn vision context\n\
1386 Ctrl+Y - Toggle Approvals Off (bypass safety approvals)\n\
1387 Ctrl+S - Quick Swarm (hardcoded bootstrap)\n\
1388 Ctrl+Z - Undo last edit\n\
1389 Ctrl+Q/C - Quit session\n\
1390 ESC - Silence current playback\n\
1391 \nStatus Legend:\n\
1392 LM - LM Studio runtime health (`LIVE`, `RECV`, `WARN`, `CEIL`, `STALE`, `BOOT`)\n\
1393 VN - Vein RAG status (`SEM`=semantic active, `FTS`=BM25 only, `--`=not indexed)\n\
1394 BUD - Total prompt-budget pressure against the live context window\n\
1395 CMP - History compaction pressure against Hematite's adaptive threshold\n\
1396 ERR - Session error count (runtime, tool, or SPECULAR failures)\n\
1397 CTX - Live context window currently reported by LM Studio\n\
1398 VOICE - Local speech output state\n\
1399 \nDocument note: `/attach` supports PDF/markdown/txt, but PDF parsing is best-effort by design so Hematite can stay a lightweight single-binary local coding harness and workstation assistant. If a PDF fails, export it to text/markdown or attach page images instead.\n\
1400 ",
1401 );
1402}
1403
1404#[allow(dead_code)]
1405fn show_help_message_legacy(app: &mut App) {
1406 app.push_message("System",
1407 "Hematite Commands:\n\
1408 /chat — (Mode) Conversation mode — clean chat, no tool noise\n\
1409 /agent — (Mode) Full coding harness + workstation mode — tools, file edits, builds, inspection\n\
1410 /reroll — (Soul) Hatch a new companion mid-session\n\
1411 /auto — (Flow) Let Hematite choose the narrowest effective workflow\n\
1412 /ask [prompt] — (Flow) Read-only analysis mode; optional inline prompt\n\
1413 /code [prompt] — (Flow) Explicit implementation mode; optional inline prompt\n\
1414 /architect [prompt] — (Flow) Plan-first mode; optional inline prompt\n\
1415 /implement-plan — (Flow) Execute the saved architect handoff in /code\n\
1416 /read-only [prompt] — (Flow) Hard read-only mode; optional inline prompt\n\
1417 /teach [prompt] — (Flow) Teacher mode; inspect machine then walk you through any admin task step-by-step\n\
1418 /new — (Reset) Fresh task context; clear chat, pins, and task files\n\
1419 /forget — (Wipe) Hard forget; purge saved memory and Vein index too\n\
1420 /vein-inspect — (Vein) Inspect indexed memory, hot files, and active room bias\n\
1421 /workspace-profile — (Profile) Show the auto-generated workspace profile\n\
1422 /rules — (Rules) View behavioral guidelines (.hematite/rules.md)\n\
1423 /version — (Build) Show the running Hematite version\n\
1424 /about — (Info) Show author, repo, and product info\n\
1425 /vein-reset — (Vein) Wipe the RAG index; rebuilds automatically on next turn\n\
1426 /clear — (UI) Clear dialogue display only\n\
1427 /health — (Diag) Run a synthesized plain-English system health report\n\
1428 /explain <text> — (Help) Paste an error to get a non-technical breakdown\n\
1429 /gemma-native [auto|on|off|status] — (Model) Auto/force/disable Gemma 4 native formatting\n\
1430 /runtime-refresh — (Model) Re-read LM Studio model + CTX now\n\
1431 /undo — (Ghost) Revert last file change\n\
1432 /diff — (Git) Show session changes (--stat)\n\
1433 /lsp — (Logic) Start Language Servers (semantic intelligence)\n\
1434 /swarm <text> — (Swarm) Spawn parallel workers on a directive\n\
1435 /worktree <cmd> — (Isolated) Manage git worktrees (list|add|remove|prune)\n\
1436 /think — (Brain) Enable deep reasoning mode\n\
1437 /no_think — (Speed) Disable reasoning (3-5x faster responses)\n\
1438 /voice — (TTS) List all available voices\n\
1439 /voice N — (TTS) Select voice by number\n\
1440 /read <text> — (TTS) Speak text aloud directly, bypassing the model. ESC to stop.\n\
1441 /explain <text> — (Plain English) Paste any error or output; Hematite explains it in plain English.\n\
1442 /health — (SysAdmin) Run a full system health report (disk, RAM, tools, recent errors).\n\
1443 /attach <path> — (Docs) Attach a PDF/markdown/txt file for next message\n\
1444 /attach-pick — (Docs) Open a file picker and attach a document\n\
1445 /image <path> — (Vision) Attach an image for the next message\n\
1446 /image-pick — (Vision) Open a file picker and attach an image\n\
1447 /detach — (Context) Drop pending document/image attachments\n\
1448 /copy — (Debug) Copy session transcript to clipboard\n\
1449 /copy2 — (Debug) Copy SPECULAR log to clipboard (reasoning + events)\n\
1450 \nHotkeys:\n\
1451 Ctrl+B — Toggle Brief Mode (minimal output)\n\
1452 Ctrl+P — Toggle Professional Mode (strip personality)\n\
1453 Ctrl+O — Open document picker for next-turn context\n\
1454 Ctrl+I — Open image picker for next-turn vision context\n\
1455 Ctrl+Y — Toggle Approvals Off (bypass safety approvals)\n\
1456 Ctrl+S — Quick Swarm (hardcoded bootstrap)\n\
1457 Ctrl+Z — Undo last edit\n\
1458 Ctrl+Q/C — Quit session\n\
1459 ESC — Silence current playback\n\
1460 \nStatus Legend:\n\
1461 LM — LM Studio runtime health (`LIVE`, `RECV`, `WARN`, `CEIL`, `STALE`, `BOOT`)\n\
1462 VN — Vein RAG status (`SEM`=semantic active, `FTS`=BM25 only, `--`=not indexed)\n\
1463 BUD — Total prompt-budget pressure against the live context window\n\
1464 CMP — History compaction pressure against Hematite's adaptive threshold\n\
1465 ERR — Session error count (runtime, tool, or SPECULAR failures)\n\
1466 CTX — Live context window currently reported by LM Studio\n\
1467 VOICE — Local speech output state\n\
1468 \nAssistant: Semantic Pathing (LSP), Vision Pass, Web Research, Swarm Synthesis"
1469 );
1470 app.push_message(
1471 "System",
1472 "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.",
1473 );
1474}
1475
1476fn trigger_input_action(app: &mut App, action: InputAction) {
1477 match action {
1478 InputAction::Stop => request_stop(app),
1479 InputAction::PickDocument => match pick_attachment_path(AttachmentPickerKind::Document) {
1480 Ok(Some(path)) => attach_document_from_path(app, &path),
1481 Ok(None) => app.push_message("System", "Document picker cancelled."),
1482 Err(e) => app.push_message("System", &e),
1483 },
1484 InputAction::PickImage => match pick_attachment_path(AttachmentPickerKind::Image) {
1485 Ok(Some(path)) => attach_image_from_path(app, &path),
1486 Ok(None) => app.push_message("System", "Image picker cancelled."),
1487 Err(e) => app.push_message("System", &e),
1488 },
1489 InputAction::Detach => {
1490 app.clear_pending_attachments();
1491 app.push_message(
1492 "System",
1493 "Cleared pending document/image attachments for the next turn.",
1494 );
1495 }
1496 InputAction::New => {
1497 if !app.agent_running {
1498 reset_visible_session_state(app);
1499 app.push_message("You", "/new");
1500 app.agent_running = true;
1501 let _ = app.user_input_tx.try_send(UserTurn::text("/new"));
1502 }
1503 }
1504 InputAction::Forget => {
1505 if !app.agent_running {
1506 app.cancel_token
1507 .store(true, std::sync::atomic::Ordering::SeqCst);
1508 reset_visible_session_state(app);
1509 app.push_message("You", "/forget");
1510 app.agent_running = true;
1511 app.cancel_token
1512 .store(false, std::sync::atomic::Ordering::SeqCst);
1513 let _ = app.user_input_tx.try_send(UserTurn::text("/forget"));
1514 }
1515 }
1516 InputAction::Help => show_help_message(app),
1517 }
1518}
1519
1520fn pick_attachment_path(kind: AttachmentPickerKind) -> Result<Option<String>, String> {
1521 #[cfg(target_os = "windows")]
1522 {
1523 let (title, filter) = match kind {
1524 AttachmentPickerKind::Document => (
1525 "Attach document for the next Hematite turn",
1526 "Documents|*.pdf;*.md;*.markdown;*.txt;*.rst|All Files|*.*",
1527 ),
1528 AttachmentPickerKind::Image => (
1529 "Attach image for the next Hematite turn",
1530 "Images|*.png;*.jpg;*.jpeg;*.gif;*.webp|All Files|*.*",
1531 ),
1532 };
1533 let script = format!(
1534 "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 }}"
1535 );
1536 let output = std::process::Command::new("powershell")
1537 .args(["-NoProfile", "-STA", "-Command", &script])
1538 .output()
1539 .map_err(|e| format!("File picker failed: {}", e))?;
1540 if !output.status.success() {
1541 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1542 return Err(if stderr.is_empty() {
1543 "File picker did not complete successfully.".to_string()
1544 } else {
1545 format!("File picker failed: {}", stderr)
1546 });
1547 }
1548 let selected = String::from_utf8_lossy(&output.stdout).trim().to_string();
1549 if selected.is_empty() {
1550 Ok(None)
1551 } else {
1552 Ok(Some(selected))
1553 }
1554 }
1555 #[cfg(target_os = "macos")]
1556 {
1557 let prompt = match kind {
1558 AttachmentPickerKind::Document => "Choose a document for the next Hematite turn",
1559 AttachmentPickerKind::Image => "Choose an image for the next Hematite turn",
1560 };
1561 let script = format!("POSIX path of (choose file with prompt \"{}\")", prompt);
1562 let output = std::process::Command::new("osascript")
1563 .args(["-e", &script])
1564 .output()
1565 .map_err(|e| format!("File picker failed: {}", e))?;
1566 if output.status.success() {
1567 let selected = String::from_utf8_lossy(&output.stdout).trim().to_string();
1568 if selected.is_empty() {
1569 Ok(None)
1570 } else {
1571 Ok(Some(selected))
1572 }
1573 } else {
1574 Ok(None)
1575 }
1576 }
1577 #[cfg(all(unix, not(target_os = "macos")))]
1578 {
1579 let title = match kind {
1580 AttachmentPickerKind::Document => "Attach document for the next Hematite turn",
1581 AttachmentPickerKind::Image => "Attach image for the next Hematite turn",
1582 };
1583 let output = std::process::Command::new("zenity")
1584 .args(["--file-selection", "--title", title])
1585 .output()
1586 .map_err(|e| format!("File picker failed: {}", e))?;
1587 if output.status.success() {
1588 let selected = String::from_utf8_lossy(&output.stdout).trim().to_string();
1589 if selected.is_empty() {
1590 Ok(None)
1591 } else {
1592 Ok(Some(selected))
1593 }
1594 } else {
1595 Ok(None)
1596 }
1597 }
1598}
1599
1600pub async fn run_app<B: Backend>(
1601 terminal: &mut Terminal<B>,
1602 mut specular_rx: Receiver<SpecularEvent>,
1603 mut agent_rx: Receiver<crate::agent::inference::InferenceEvent>,
1604 user_input_tx: tokio::sync::mpsc::Sender<UserTurn>,
1605 mut swarm_rx: Receiver<SwarmMessage>,
1606 swarm_tx: tokio::sync::mpsc::Sender<SwarmMessage>,
1607 swarm_coordinator: Arc<crate::agent::swarm::SwarmCoordinator>,
1608 last_interaction: Arc<Mutex<Instant>>,
1609 cockpit: crate::CliCockpit,
1610 soul: crate::ui::hatch::RustySoul,
1611 professional: bool,
1612 gpu_state: Arc<GpuState>,
1613 git_state: Arc<crate::agent::git_monitor::GitState>,
1614 cancel_token: Arc<std::sync::atomic::AtomicBool>,
1615 voice_manager: Arc<crate::ui::voice::VoiceManager>,
1616) -> Result<(), Box<dyn std::error::Error>> {
1617 let mut app = App {
1618 messages: Vec::new(),
1619 messages_raw: Vec::new(),
1620 specular_logs: Vec::new(),
1621 brief_mode: cockpit.brief,
1622 tick_count: 0,
1623 stats: RustyStats {
1624 debugging: 0,
1625 wisdom: soul.wisdom,
1626 patience: 100.0,
1627 chaos: soul.chaos,
1628 snark: soul.snark,
1629 },
1630 yolo_mode: cockpit.yolo,
1631 awaiting_approval: None,
1632 active_workers: HashMap::new(),
1633 worker_labels: HashMap::new(),
1634 active_review: None,
1635 input: String::new(),
1636 input_history: Vec::new(),
1637 history_idx: None,
1638 thinking: false,
1639 agent_running: false,
1640 current_thought: String::new(),
1641 professional,
1642 last_reasoning: String::new(),
1643 active_context: default_active_context(),
1644 manual_scroll_offset: None,
1645 user_input_tx,
1646 specular_scroll: 0,
1647 specular_auto_scroll: true,
1648 gpu_state,
1649 git_state,
1650 last_input_time: Instant::now(),
1651 cancel_token,
1652 total_tokens: 0,
1653 current_session_cost: 0.0,
1654 model_id: "detecting...".to_string(),
1655 context_length: 0,
1656 prompt_pressure_percent: 0,
1657 prompt_estimated_input_tokens: 0,
1658 prompt_reserved_output_tokens: 0,
1659 prompt_estimated_total_tokens: 0,
1660 compaction_percent: 0,
1661 compaction_estimated_tokens: 0,
1662 compaction_threshold_tokens: 0,
1663 compaction_warned_level: 0,
1664 last_runtime_profile_time: Instant::now(),
1665 vein_file_count: 0,
1666 vein_embedded_count: 0,
1667 vein_docs_only: false,
1668 provider_state: ProviderRuntimeState::Booting,
1669 last_provider_summary: String::new(),
1670 mcp_state: McpRuntimeState::Unconfigured,
1671 last_mcp_summary: String::new(),
1672 last_operator_checkpoint_state: OperatorCheckpointState::Idle,
1673 last_operator_checkpoint_summary: String::new(),
1674 last_recovery_recipe_summary: String::new(),
1675 think_mode: None,
1676 workflow_mode: "AUTO".into(),
1677 autocomplete_suggestions: Vec::new(),
1678 selected_suggestion: 0,
1679 show_autocomplete: false,
1680 autocomplete_filter: String::new(),
1681 current_objective: "Awaiting objective...".into(),
1682 voice_manager,
1683 voice_loading: false,
1684 voice_loading_progress: 0.0,
1685 hardware_guard_enabled: true,
1686 session_start: std::time::SystemTime::now(),
1687 soul_name: soul.species.clone(),
1688 attached_context: None,
1689 attached_image: None,
1690 hovered_input_action: None,
1691 teleported_from: cockpit.teleported_from.clone(),
1692 nav_list: Vec::new(),
1693 };
1694
1695 app.push_message("Hematite", "Initialising Engine & Hardware...");
1697
1698 if let Some(origin) = &app.teleported_from {
1699 app.push_message(
1700 "System",
1701 &format!(
1702 "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?",
1703 origin
1704 ),
1705 );
1706 }
1707
1708 if !cockpit.no_splash {
1711 draw_splash(terminal)?;
1712 loop {
1713 if let Ok(Event::Key(key)) = event::read() {
1714 if key.kind == event::KeyEventKind::Press
1715 && matches!(key.code, KeyCode::Enter | KeyCode::Char(' '))
1716 {
1717 break;
1718 }
1719 }
1720 }
1721 }
1722
1723 let mut event_stream = EventStream::new();
1724 let mut ticker = tokio::time::interval(std::time::Duration::from_millis(100));
1725
1726 loop {
1727 let vram_ratio = app.gpu_state.ratio();
1729 if app.hardware_guard_enabled && vram_ratio > 0.95 && !app.brief_mode {
1730 app.brief_mode = true;
1731 app.push_message(
1732 "System",
1733 "🚨 HARDWARE GUARD: VRAM > 95%. Brief Mode auto-enabled to prevent crash.",
1734 );
1735 }
1736
1737 terminal.draw(|f| ui(f, &app))?;
1738
1739 tokio::select! {
1740 _ = ticker.tick() => {
1741 if app.voice_loading && app.voice_loading_progress < 0.98 {
1743 app.voice_loading_progress += 0.002;
1744 }
1745
1746 let workers = app.active_workers.len() as u64;
1747 let advance = if workers > 0 { workers * 4 + 1 } else { 1 };
1748 app.tick_count = app.tick_count.wrapping_add(advance);
1752 app.update_objective();
1753 }
1754
1755 maybe_event = event_stream.next() => {
1757 match maybe_event {
1758 Some(Ok(Event::Mouse(mouse))) => {
1759 use crossterm::event::{MouseButton, MouseEventKind};
1760 let (width, height) = match terminal.size() {
1761 Ok(s) => (s.width, s.height),
1762 Err(_) => (80, 24),
1763 };
1764 let is_right_side = mouse.column as f64 > width as f64 * 0.65;
1765 let input_rect = input_rect_for_size(
1766 Rect { x: 0, y: 0, width, height },
1767 app.input.len(),
1768 );
1769 let title_area = input_title_area(input_rect);
1770
1771 match mouse.kind {
1772 MouseEventKind::Moved => {
1773 let hovered = if mouse.row == title_area.y
1774 && mouse.column >= title_area.x
1775 && mouse.column < title_area.x + title_area.width
1776 {
1777 input_action_hitboxes(&app, title_area)
1778 .into_iter()
1779 .find_map(|(action, start, end)| {
1780 (mouse.column >= start && mouse.column <= end)
1781 .then_some(action)
1782 })
1783 } else {
1784 None
1785 };
1786 app.hovered_input_action = hovered;
1787 }
1788 MouseEventKind::Down(MouseButton::Left) => {
1789 if mouse.row == title_area.y
1790 && mouse.column >= title_area.x
1791 && mouse.column < title_area.x + title_area.width
1792 {
1793 for (action, start, end) in input_action_hitboxes(&app, title_area) {
1794 if mouse.column >= start && mouse.column <= end {
1795 app.hovered_input_action = Some(action);
1796 trigger_input_action(&mut app, action);
1797 break;
1798 }
1799 }
1800 } else {
1801 app.hovered_input_action = None;
1802 }
1803 }
1804 MouseEventKind::ScrollUp => {
1805 if is_right_side {
1806 app.specular_auto_scroll = false;
1808 app.specular_scroll = app.specular_scroll.saturating_sub(3);
1809 } else {
1810 let cur = app.manual_scroll_offset.unwrap_or(0);
1811 app.manual_scroll_offset = Some(cur.saturating_add(3));
1812 }
1813 }
1814 MouseEventKind::ScrollDown => {
1815 if is_right_side {
1816 app.specular_auto_scroll = false;
1817 app.specular_scroll = app.specular_scroll.saturating_add(3);
1818 } else if let Some(cur) = app.manual_scroll_offset {
1819 app.manual_scroll_offset = if cur <= 3 { None } else { Some(cur - 3) };
1820 }
1821 }
1822 _ => {}
1823 }
1824 }
1825 Some(Ok(Event::Key(key))) => {
1826 if key.kind != event::KeyEventKind::Press { continue; }
1827
1828 { *last_interaction.lock().unwrap() = Instant::now(); }
1830
1831 if let Some(review) = app.active_review.take() {
1833 match key.code {
1834 KeyCode::Char('y') | KeyCode::Char('Y') => {
1835 let _ = review.tx.send(ReviewResponse::Accept);
1836 app.push_message("System", &format!("Worker {} diff accepted.", review.worker_id));
1837 }
1838 KeyCode::Char('n') | KeyCode::Char('N') => {
1839 let _ = review.tx.send(ReviewResponse::Reject);
1840 app.push_message("System", "Diff rejected.");
1841 }
1842 KeyCode::Char('r') | KeyCode::Char('R') => {
1843 let _ = review.tx.send(ReviewResponse::Retry);
1844 app.push_message("System", "Retrying synthesis…");
1845 }
1846 _ => { app.active_review = Some(review); }
1847 }
1848 continue;
1849 }
1850
1851 if let Some(mut approval) = app.awaiting_approval.take() {
1853 let scroll_handled = if approval.diff.is_some() {
1855 let diff_lines = approval.diff.as_ref().map(|d| d.lines().count()).unwrap_or(0) as u16;
1856 match key.code {
1857 KeyCode::Down | KeyCode::Char('j') => {
1858 approval.diff_scroll = approval.diff_scroll.saturating_add(1).min(diff_lines.saturating_sub(1));
1859 true
1860 }
1861 KeyCode::Up | KeyCode::Char('k') => {
1862 approval.diff_scroll = approval.diff_scroll.saturating_sub(1);
1863 true
1864 }
1865 KeyCode::PageDown => {
1866 approval.diff_scroll = approval.diff_scroll.saturating_add(10).min(diff_lines.saturating_sub(1));
1867 true
1868 }
1869 KeyCode::PageUp => {
1870 approval.diff_scroll = approval.diff_scroll.saturating_sub(10);
1871 true
1872 }
1873 _ => false,
1874 }
1875 } else {
1876 false
1877 };
1878 if scroll_handled {
1879 app.awaiting_approval = Some(approval);
1880 continue;
1881 }
1882 match key.code {
1883 KeyCode::Char('y') | KeyCode::Char('Y') => {
1884 if let Some(ref diff) = approval.diff {
1885 let added = diff.lines().filter(|l| l.starts_with("+ ")).count();
1886 let removed = diff.lines().filter(|l| l.starts_with("- ")).count();
1887 app.push_message("System", &format!(
1888 "Applied: {} +{} -{}", approval.display, added, removed
1889 ));
1890 } else {
1891 app.push_message("System", &format!("Approved: {}", approval.display));
1892 }
1893 let _ = approval.responder.send(true);
1894 }
1895 KeyCode::Char('n') | KeyCode::Char('N') => {
1896 if approval.diff.is_some() {
1897 app.push_message("System", "Edit skipped.");
1898 } else {
1899 app.push_message("System", "Declined.");
1900 }
1901 let _ = approval.responder.send(false);
1902 }
1903 _ => { app.awaiting_approval = Some(approval); }
1904 }
1905 continue;
1906 }
1907
1908 match key.code {
1910 KeyCode::Char('q') | KeyCode::Char('c')
1911 if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
1912 app.write_session_report();
1913 app.copy_transcript_to_clipboard();
1914 break;
1915 }
1916
1917 KeyCode::Esc => {
1918 request_stop(&mut app);
1919 }
1920
1921 KeyCode::Char('b') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
1922 app.brief_mode = !app.brief_mode;
1923 app.hardware_guard_enabled = false;
1925 app.push_message("System", &format!("Hardware Guard {}: {}", if app.brief_mode { "ENFORCED" } else { "SILENCED" }, if app.brief_mode { "ON" } else { "OFF" }));
1926 }
1927 KeyCode::Char('p') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
1928 app.professional = !app.professional;
1929 app.push_message("System", &format!("Professional Harness: {}", if app.professional { "ACTIVE" } else { "DISABLED" }));
1930 }
1931 KeyCode::Char('y') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
1932 app.yolo_mode = !app.yolo_mode;
1933 app.push_message("System", &format!("Approvals Off: {}", if app.yolo_mode { "ON — all tools auto-approved" } else { "OFF" }));
1934 }
1935 KeyCode::Char('t') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
1936 if !app.voice_manager.is_available() {
1937 app.push_message("System", "Voice is not available in this build. Use a packaged release for baked-in voice.");
1938 } else {
1939 let enabled = app.voice_manager.toggle();
1940 app.push_message("System", &format!("Voice of Hematite: {}", if enabled { "VIBRANT" } else { "SILENCED" }));
1941 }
1942 }
1943 KeyCode::Char('o') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
1944 match pick_attachment_path(AttachmentPickerKind::Document) {
1945 Ok(Some(path)) => attach_document_from_path(&mut app, &path),
1946 Ok(None) => app.push_message("System", "Document picker cancelled."),
1947 Err(e) => app.push_message("System", &e),
1948 }
1949 }
1950 KeyCode::Char('i') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
1951 match pick_attachment_path(AttachmentPickerKind::Image) {
1952 Ok(Some(path)) => attach_image_from_path(&mut app, &path),
1953 Ok(None) => app.push_message("System", "Image picker cancelled."),
1954 Err(e) => app.push_message("System", &e),
1955 }
1956 }
1957 KeyCode::Char('s') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
1958 app.push_message("Hematite", "Swarm engaged.");
1959 let swarm_tx_c = swarm_tx.clone();
1960 let coord_c = swarm_coordinator.clone();
1961 let max_workers = if app.gpu_state.ratio() > 0.70 { 1 } else { 3 };
1963 if max_workers < 3 {
1964 app.push_message("System", "Hardware Guard: Limiting swarm to 1 worker due to GPU load.");
1965 }
1966
1967 app.agent_running = true;
1968 tokio::spawn(async move {
1969 let payload = r#"<worker_task id="1" target="src/ui/tui.rs">Implement Swarm Layout</worker_task>
1970<worker_task id="2" target="src/agent/swarm.rs">Build Scratchpad constraints</worker_task>
1971<worker_task id="3" target="docs">Update Readme</worker_task>"#;
1972 let tasks = crate::agent::parser::parse_master_spec(payload);
1973 let _ = coord_c.dispatch_swarm(tasks, swarm_tx_c, max_workers).await;
1974 });
1975 }
1976 KeyCode::Char('z') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
1977 match crate::tools::file_ops::pop_ghost_ledger() {
1978 Ok(msg) => {
1979 app.specular_logs.push(format!("GHOST: {}", msg));
1980 trim_vec(&mut app.specular_logs, 7);
1981 app.push_message("System", &msg);
1982 }
1983 Err(e) => {
1984 app.push_message("System", &format!("Undo failed: {}", e));
1985 }
1986 }
1987 }
1988 KeyCode::Up => {
1989 if app.show_autocomplete && !app.autocomplete_suggestions.is_empty() {
1990 app.selected_suggestion = app.selected_suggestion.saturating_sub(1);
1991 } else if app.manual_scroll_offset.is_some() {
1992 let cur = app.manual_scroll_offset.unwrap();
1994 app.manual_scroll_offset = Some(cur.saturating_add(3));
1995 } else if !app.input_history.is_empty() {
1996 let new_idx = match app.history_idx {
1998 None => app.input_history.len() - 1,
1999 Some(i) => i.saturating_sub(1),
2000 };
2001 app.history_idx = Some(new_idx);
2002 app.input = app.input_history[new_idx].clone();
2003 }
2004 }
2005 KeyCode::Down => {
2006 if app.show_autocomplete && !app.autocomplete_suggestions.is_empty() {
2007 app.selected_suggestion = (app.selected_suggestion + 1).min(app.autocomplete_suggestions.len().saturating_sub(1));
2008 } else if let Some(off) = app.manual_scroll_offset {
2009 if off <= 3 { app.manual_scroll_offset = None; }
2010 else { app.manual_scroll_offset = Some(off.saturating_sub(3)); }
2011 } else if let Some(i) = app.history_idx {
2012 if i + 1 < app.input_history.len() {
2013 app.history_idx = Some(i + 1);
2014 app.input = app.input_history[i + 1].clone();
2015 } else {
2016 app.history_idx = None;
2017 app.input.clear();
2018 }
2019 }
2020 }
2021 KeyCode::PageUp => {
2022 let cur = app.manual_scroll_offset.unwrap_or(0);
2023 app.manual_scroll_offset = Some(cur.saturating_add(10));
2024 }
2025 KeyCode::PageDown => {
2026 if let Some(off) = app.manual_scroll_offset {
2027 if off <= 10 { app.manual_scroll_offset = None; }
2028 else { app.manual_scroll_offset = Some(off.saturating_sub(10)); }
2029 }
2030 }
2031 KeyCode::Tab => {
2032 if app.show_autocomplete && !app.autocomplete_suggestions.is_empty() {
2033 let selected = &app.autocomplete_suggestions[app.selected_suggestion];
2034 if let Some(pos) = app.input.rfind('@') {
2035 app.input.truncate(pos + 1);
2036 app.input.push_str(selected);
2037 app.show_autocomplete = false;
2038 }
2039 }
2040 }
2041 KeyCode::Char(c) => {
2042 app.history_idx = None; app.input.push(c);
2044 app.last_input_time = Instant::now();
2045
2046 if c == '@' {
2047 app.show_autocomplete = true;
2048 app.autocomplete_filter.clear();
2049 app.selected_suggestion = 0;
2050 app.update_autocomplete();
2051 } else if app.show_autocomplete {
2052 app.autocomplete_filter.push(c);
2053 app.update_autocomplete();
2054 }
2055 }
2056 KeyCode::Backspace => {
2057 app.input.pop();
2058 if app.show_autocomplete {
2059 if app.input.ends_with('@') || !app.input.contains('@') {
2060 app.show_autocomplete = false;
2061 app.autocomplete_filter.clear();
2062 } else {
2063 app.autocomplete_filter.pop();
2064 app.update_autocomplete();
2065 }
2066 }
2067 }
2068 KeyCode::Enter => {
2069 if app.show_autocomplete && !app.autocomplete_suggestions.is_empty() {
2070 let selected = &app.autocomplete_suggestions[app.selected_suggestion];
2071 if let Some(pos) = app.input.rfind('@') {
2072 app.input.truncate(pos + 1);
2073 app.input.push_str(selected);
2074 app.show_autocomplete = false;
2075 continue;
2076 }
2077 }
2078
2079 if !app.input.is_empty() && !app.agent_running {
2080 if Instant::now().duration_since(app.last_input_time) < std::time::Duration::from_millis(50) {
2083 app.input.push(' ');
2084 app.last_input_time = Instant::now();
2085 continue;
2086 }
2087
2088 let input_text = app.input.drain(..).collect::<String>();
2089
2090 if input_text.starts_with('/') {
2092 let parts: Vec<&str> = input_text.trim().split_whitespace().collect();
2093 let cmd = parts[0].to_lowercase();
2094 match cmd.as_str() {
2095 "/undo" => {
2096 match crate::tools::file_ops::pop_ghost_ledger() {
2097 Ok(msg) => {
2098 app.specular_logs.push(format!("GHOST: {}", msg));
2099 trim_vec(&mut app.specular_logs, 7);
2100 app.push_message("System", &msg);
2101 }
2102 Err(e) => {
2103 app.push_message("System", &format!("Undo failed: {}", e));
2104 }
2105 }
2106 app.history_idx = None;
2107 continue;
2108 }
2109 "/clear" => {
2110 reset_visible_session_state(&mut app);
2111 app.push_message("System", "Dialogue buffer cleared.");
2112 app.history_idx = None;
2113 continue;
2114 }
2115 "/cd" => {
2116 if parts.len() < 2 {
2117 app.push_message("System", "Usage: /cd <path> — teleport to any directory. Supports bare tokens like downloads, desktop, docs, pictures, videos, music, home, temp, bare ~, @TOKENS, .., and absolute paths.");
2118 app.history_idx = None;
2119 continue;
2120 }
2121 let raw = parts[1..].join(" ");
2122 let target = crate::tools::file_ops::resolve_candidate(&raw);
2123 if !target.exists() {
2124 app.push_message("System", &format!("Directory not found: {}", target.display()));
2125 app.history_idx = None;
2126 continue;
2127 }
2128 if !target.is_dir() {
2129 app.push_message("System", &format!("Not a directory: {}", target.display()));
2130 app.history_idx = None;
2131 continue;
2132 }
2133 let target_str = target.to_string_lossy().to_string();
2134 app.push_message("You", &format!("/cd {}", raw));
2135 app.push_message("System", &format!("Teleporting to {}...", target_str));
2136 app.push_message("System", "Launching new session. This terminal will close.");
2137 spawn_dive_in_terminal(&target_str);
2138 app.write_session_report();
2139 app.copy_transcript_to_clipboard();
2140 break;
2141 }
2142 "/ls" => {
2143 let base: std::path::PathBuf = if parts.len() >= 2 {
2144 let arg = parts[1..].join(" ");
2146 if let Ok(n) = arg.trim().parse::<usize>() {
2147 if n == 0 || n > app.nav_list.len() {
2149 app.push_message("System", &format!("No entry {}. Run /ls first to see the list.", n));
2150 app.history_idx = None;
2151 continue;
2152 }
2153 let target = app.nav_list[n - 1].clone();
2154 let target_str = target.to_string_lossy().to_string();
2155 app.push_message("You", &format!("/ls {}", n));
2156 app.push_message("System", &format!("Teleporting to {}...", target_str));
2157 app.push_message("System", "Launching new session. This terminal will close.");
2158 spawn_dive_in_terminal(&target_str);
2159 app.write_session_report();
2160 app.copy_transcript_to_clipboard();
2161 break;
2162 } else {
2163 crate::tools::file_ops::resolve_candidate(&arg)
2164 }
2165 } else {
2166 std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
2167 };
2168
2169 let mut entries: Vec<std::path::PathBuf> = Vec::new();
2171 let mut output = String::new();
2172
2173 let listing_base = parts.len() < 2;
2175 if listing_base {
2176 let common: Vec<(&str, Option<std::path::PathBuf>)> = vec![
2177 ("Desktop", dirs::desktop_dir()),
2178 ("Downloads", dirs::download_dir()),
2179 ("Documents", dirs::document_dir()),
2180 ("Pictures", dirs::picture_dir()),
2181 ("Home", dirs::home_dir()),
2182 ];
2183 let valid: Vec<_> = common.into_iter().filter_map(|(label, p)| p.map(|pb| (label, pb))).collect();
2184 if !valid.is_empty() {
2185 output.push_str("Common locations:\n");
2186 for (label, pb) in &valid {
2187 entries.push(pb.clone());
2188 output.push_str(&format!(" {:>2}. {:<12} {}\n", entries.len(), label, pb.display()));
2189 }
2190 }
2191 }
2192
2193 let cwd_label = if listing_base {
2195 std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
2196 } else {
2197 base.clone()
2198 };
2199 if let Ok(read) = std::fs::read_dir(&cwd_label) {
2200 let mut dirs_found: Vec<std::path::PathBuf> = read
2201 .filter_map(|e| e.ok())
2202 .filter(|e| e.path().is_dir())
2203 .map(|e| e.path())
2204 .collect();
2205 dirs_found.sort();
2206 if !dirs_found.is_empty() {
2207 output.push_str(&format!("\n{}:\n", cwd_label.display()));
2208 for pb in &dirs_found {
2209 entries.push(pb.clone());
2210 let name = pb.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
2211 output.push_str(&format!(" {:>2}. {}\n", entries.len(), name));
2212 }
2213 }
2214 }
2215
2216 if entries.is_empty() {
2217 app.push_message("System", "No directories found.");
2218 } else {
2219 output.push_str("\nType /ls <N> to teleport to that directory.");
2220 app.nav_list = entries;
2221 app.push_message("System", &output);
2222 }
2223 app.history_idx = None;
2224 continue;
2225 }
2226 "/diff" => {
2227 app.push_message("System", "Fetching session diff...");
2228 let ws = crate::tools::file_ops::workspace_root();
2229 if crate::agent::git::is_git_repo(&ws) {
2230 let output = std::process::Command::new("git")
2231 .args(["diff", "--stat"])
2232 .current_dir(ws)
2233 .output();
2234 if let Ok(out) = output {
2235 let stat = String::from_utf8_lossy(&out.stdout).to_string();
2236 app.push_message("System", if stat.is_empty() { "No changes detected." } else { &stat });
2237 }
2238 } else {
2239 app.push_message("System", "Not a git repository. Diff limited.");
2240 }
2241 app.history_idx = None;
2242 continue;
2243 }
2244 "/vein-reset" => {
2245 app.vein_file_count = 0;
2246 app.vein_embedded_count = 0;
2247 app.push_message("You", "/vein-reset");
2248 app.agent_running = true;
2249 let _ = app.user_input_tx.try_send(UserTurn::text("/vein-reset"));
2250 app.history_idx = None;
2251 continue;
2252 }
2253 "/vein-inspect" => {
2254 app.push_message("You", "/vein-inspect");
2255 app.agent_running = true;
2256 let _ = app.user_input_tx.try_send(UserTurn::text("/vein-inspect"));
2257 app.history_idx = None;
2258 continue;
2259 }
2260 "/workspace-profile" => {
2261 app.push_message("You", "/workspace-profile");
2262 app.agent_running = true;
2263 let _ = app.user_input_tx.try_send(UserTurn::text("/workspace-profile"));
2264 app.history_idx = None;
2265 continue;
2266 }
2267 "/copy" => {
2268 app.copy_transcript_to_clipboard();
2269 app.push_message("System", "Exact session transcript copied to clipboard (includes help/system output).");
2270 app.history_idx = None;
2271 continue;
2272 }
2273 "/copy-last" => {
2274 if app.copy_last_reply_to_clipboard() {
2275 app.push_message("System", "Latest Hematite reply copied to clipboard.");
2276 } else {
2277 app.push_message("System", "No Hematite reply is available to copy yet.");
2278 }
2279 app.history_idx = None;
2280 continue;
2281 }
2282 "/copy-clean" => {
2283 app.copy_clean_transcript_to_clipboard();
2284 app.push_message("System", "Clean chat transcript copied to clipboard (skips help/debug boilerplate).");
2285 app.history_idx = None;
2286 continue;
2287 }
2288 "/copy2" => {
2289 app.copy_specular_to_clipboard();
2290 app.push_message("System", "SPECULAR log copied to clipboard (reasoning + events).");
2291 app.history_idx = None;
2292 continue;
2293 }
2294 "/voice" => {
2295 use crate::ui::voice::VOICE_LIST;
2296 if let Some(arg) = parts.get(1) {
2297 if let Ok(n) = arg.parse::<usize>() {
2299 let idx = n.saturating_sub(1);
2300 if let Some(&(id, label)) = VOICE_LIST.get(idx) {
2301 app.voice_manager.set_voice(id);
2302 let _ = crate::agent::config::set_voice(id);
2303 app.push_message("System", &format!("Voice set to {} — {}", id, label));
2304 } else {
2305 app.push_message("System", &format!("Invalid voice number. Use /voice to list voices (1–{}).", VOICE_LIST.len()));
2306 }
2307 } else {
2308 if let Some(&(id, label)) = VOICE_LIST.iter().find(|&&(id, _)| id == *arg) {
2310 app.voice_manager.set_voice(id);
2311 let _ = crate::agent::config::set_voice(id);
2312 app.push_message("System", &format!("Voice set to {} — {}", id, label));
2313 } else {
2314 app.push_message("System", &format!("Unknown voice '{}'. Use /voice to list voices.", arg));
2315 }
2316 }
2317 } else {
2318 let current = app.voice_manager.current_voice_id();
2320 let mut list = format!("Available voices (current: {}):\n", current);
2321 for (i, &(id, label)) in VOICE_LIST.iter().enumerate() {
2322 let marker = if id == current.as_str() { " ◀" } else { "" };
2323 list.push_str(&format!(" {:>2}. {}{}\n", i + 1, label, marker));
2324 }
2325 list.push_str("\nUse /voice N or /voice <id> to select.");
2326 app.push_message("System", &list);
2327 }
2328 app.history_idx = None;
2329 continue;
2330 }
2331 "/read" => {
2332 let text = parts[1..].join(" ");
2333 if text.is_empty() {
2334 app.push_message("System", "Usage: /read <text to speak>");
2335 } else if !app.voice_manager.is_available() {
2336 app.push_message("System", "Voice is not available in this build. Use a packaged release for baked-in voice.");
2337 } else if !app.voice_manager.is_enabled() {
2338 app.push_message("System", "Voice is off. Press Ctrl+T to enable, then /read again.");
2339 } else {
2340 app.push_message("System", &format!("Reading {} words aloud. ESC to stop.", text.split_whitespace().count()));
2341 app.voice_manager.speak(text.clone());
2342 }
2343 app.history_idx = None;
2344 continue;
2345 }
2346 "/new" => {
2347 reset_visible_session_state(&mut app);
2348 app.push_message("You", "/new");
2349 app.agent_running = true;
2350 app.clear_pending_attachments();
2351 let _ = app.user_input_tx.try_send(UserTurn::text("/new"));
2352 app.history_idx = None;
2353 continue;
2354 }
2355 "/forget" => {
2356 app.cancel_token.store(true, std::sync::atomic::Ordering::SeqCst);
2358 reset_visible_session_state(&mut app);
2359 app.push_message("You", "/forget");
2360 app.agent_running = true;
2361 app.cancel_token.store(false, std::sync::atomic::Ordering::SeqCst);
2362 app.clear_pending_attachments();
2363 let _ = app.user_input_tx.try_send(UserTurn::text("/forget"));
2364 app.history_idx = None;
2365 continue;
2366 }
2367 "/gemma-native" => {
2368 let sub = parts.get(1).copied().unwrap_or("status").to_ascii_lowercase();
2369 let gemma_detected = crate::agent::inference::is_gemma4_model_name(&app.model_id);
2370 match sub.as_str() {
2371 "auto" => {
2372 match crate::agent::config::set_gemma_native_mode("auto") {
2373 Ok(_) => {
2374 if gemma_detected {
2375 app.push_message("System", "Gemma Native Formatting: AUTO. Gemma 4 will use native formatting automatically on the next turn.");
2376 } else {
2377 app.push_message("System", "Gemma Native Formatting: AUTO in settings. It will activate automatically when a Gemma 4 model is loaded.");
2378 }
2379 }
2380 Err(e) => app.push_message("System", &format!("Failed to update settings: {}", e)),
2381 }
2382 }
2383 "on" => {
2384 match crate::agent::config::set_gemma_native_mode("on") {
2385 Ok(_) => {
2386 if gemma_detected {
2387 app.push_message("System", "Gemma Native Formatting: ON (forced). It will apply on the next turn.");
2388 } else {
2389 app.push_message("System", "Gemma Native Formatting: ON (forced) in settings. It will activate only when a Gemma 4 model is loaded.");
2390 }
2391 }
2392 Err(e) => app.push_message("System", &format!("Failed to update settings: {}", e)),
2393 }
2394 }
2395 "off" => {
2396 match crate::agent::config::set_gemma_native_mode("off") {
2397 Ok(_) => app.push_message("System", "Gemma Native Formatting: OFF."),
2398 Err(e) => app.push_message("System", &format!("Failed to update settings: {}", e)),
2399 }
2400 }
2401 _ => {
2402 let config = crate::agent::config::load_config();
2403 let mode = crate::agent::config::gemma_native_mode_label(&config, &app.model_id);
2404 let enabled = match mode {
2405 "on" => "ON (forced)",
2406 "auto" => "ON (auto)",
2407 "off" => "OFF",
2408 _ => "INACTIVE",
2409 };
2410 let model_note = if gemma_detected {
2411 "Gemma 4 detected."
2412 } else {
2413 "Current model is not Gemma 4."
2414 };
2415 app.push_message(
2416 "System",
2417 &format!(
2418 "Gemma Native Formatting: {}. {} Usage: /gemma-native auto | on | off | status",
2419 enabled, model_note
2420 ),
2421 );
2422 }
2423 }
2424 app.history_idx = None;
2425 continue;
2426 }
2427 "/chat" => {
2428 app.workflow_mode = "CHAT".into();
2429 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.");
2430 app.history_idx = None;
2431 let _ = app.user_input_tx.try_send(UserTurn::text("/chat"));
2432 continue;
2433 }
2434 "/reroll" => {
2435 app.history_idx = None;
2436 let _ = app.user_input_tx.try_send(UserTurn::text("/reroll"));
2437 continue;
2438 }
2439 "/agent" => {
2440 app.workflow_mode = "AUTO".into();
2441 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.");
2442 app.history_idx = None;
2443 let _ = app.user_input_tx.try_send(UserTurn::text("/agent"));
2444 continue;
2445 }
2446 "/implement-plan" => {
2447 app.workflow_mode = "CODE".into();
2448 app.push_message("You", "/implement-plan");
2449 app.agent_running = true;
2450 let _ = app.user_input_tx.try_send(UserTurn::text("/implement-plan"));
2451 app.history_idx = None;
2452 continue;
2453 }
2454 "/ask" | "/code" | "/architect" | "/read-only" | "/auto" | "/teach" => {
2455 let label = match cmd.as_str() {
2456 "/ask" => "ASK",
2457 "/code" => "CODE",
2458 "/architect" => "ARCHITECT",
2459 "/read-only" => "READ-ONLY",
2460 "/teach" => "TEACH",
2461 _ => "AUTO",
2462 };
2463 app.workflow_mode = label.to_string();
2464 let outbound = input_text.trim().to_string();
2465 app.push_message("You", &outbound);
2466 app.agent_running = true;
2467 let _ = app.user_input_tx.try_send(UserTurn::text(outbound));
2468 app.history_idx = None;
2469 continue;
2470 }
2471 "/worktree" => {
2472 let sub = parts.get(1).copied().unwrap_or("");
2473 match sub {
2474 "list" => {
2475 app.push_message("You", "/worktree list");
2476 app.agent_running = true;
2477 let _ = app.user_input_tx.try_send(UserTurn::text(
2478 "Call git_worktree with action=list"
2479 ));
2480 }
2481 "add" => {
2482 let wt_path = parts.get(2).copied().unwrap_or("");
2483 let wt_branch = parts.get(3).copied().unwrap_or("");
2484 if wt_path.is_empty() {
2485 app.push_message("System", "Usage: /worktree add <path> [branch]");
2486 } else {
2487 app.push_message("You", &format!("/worktree add {wt_path}"));
2488 app.agent_running = true;
2489 let directive = if wt_branch.is_empty() {
2490 format!("Call git_worktree with action=add path={wt_path}")
2491 } else {
2492 format!("Call git_worktree with action=add path={wt_path} branch={wt_branch}")
2493 };
2494 let _ = app.user_input_tx.try_send(UserTurn::text(directive));
2495 }
2496 }
2497 "remove" => {
2498 let wt_path = parts.get(2).copied().unwrap_or("");
2499 if wt_path.is_empty() {
2500 app.push_message("System", "Usage: /worktree remove <path>");
2501 } else {
2502 app.push_message("You", &format!("/worktree remove {wt_path}"));
2503 app.agent_running = true;
2504 let _ = app.user_input_tx.try_send(UserTurn::text(
2505 format!("Call git_worktree with action=remove path={wt_path}")
2506 ));
2507 }
2508 }
2509 "prune" => {
2510 app.push_message("You", "/worktree prune");
2511 app.agent_running = true;
2512 let _ = app.user_input_tx.try_send(UserTurn::text(
2513 "Call git_worktree with action=prune"
2514 ));
2515 }
2516 _ => {
2517 app.push_message("System",
2518 "Usage: /worktree list | add <path> [branch] | remove <path> | prune");
2519 }
2520 }
2521 app.history_idx = None;
2522 continue;
2523 }
2524 "/think" => {
2525 app.think_mode = Some(true);
2526 app.push_message("You", "/think");
2527 app.agent_running = true;
2528 let _ = app.user_input_tx.try_send(UserTurn::text("/think"));
2529 app.history_idx = None;
2530 continue;
2531 }
2532 "/no_think" => {
2533 app.think_mode = Some(false);
2534 app.push_message("You", "/no_think");
2535 app.agent_running = true;
2536 let _ = app.user_input_tx.try_send(UserTurn::text("/no_think"));
2537 app.history_idx = None;
2538 continue;
2539 }
2540 "/lsp" => {
2541 app.push_message("You", "/lsp");
2542 app.agent_running = true;
2543 let _ = app.user_input_tx.try_send(UserTurn::text("/lsp"));
2544 app.history_idx = None;
2545 continue;
2546 }
2547 "/runtime-refresh" => {
2548 app.push_message("You", "/runtime-refresh");
2549 app.agent_running = true;
2550 let _ = app.user_input_tx.try_send(UserTurn::text("/runtime-refresh"));
2551 app.history_idx = None;
2552 continue;
2553 }
2554 "/rules" => {
2555 let sub = parts.get(1).copied().unwrap_or("status").to_ascii_lowercase();
2556 let ws_root = crate::tools::file_ops::workspace_root();
2557
2558 match sub.as_str() {
2559 "view" => {
2560 let mut combined = String::new();
2561 let candidates = [
2562 "CLAUDE.md",
2563 ".claude.md",
2564 "CLAUDE.local.md",
2565 "HEMATITE.md",
2566 ".hematite/rules.md",
2567 ".hematite/rules.local.md",
2568 ];
2569 for cand in candidates {
2570 let p = ws_root.join(cand);
2571 if p.exists() {
2572 if let Ok(c) = std::fs::read_to_string(&p) {
2573 combined.push_str(&format!("--- [{}] ---\n", cand));
2574 combined.push_str(&c);
2575 combined.push_str("\n\n");
2576 }
2577 }
2578 }
2579 if combined.is_empty() {
2580 app.push_message("System", "No rule files found (CLAUDE.md, .hematite/rules.md, etc.).");
2581 } else {
2582 app.push_message("System", &format!("Current behavioral rules being injected:\n\n{}", combined));
2583 }
2584 }
2585 "edit" => {
2586 let which = parts.get(2).copied().unwrap_or("local").to_ascii_lowercase();
2587 let target_file = if which == "shared" { "rules.md" } else { "rules.local.md" };
2588 let target_path = crate::tools::file_ops::hematite_dir().join(target_file);
2589
2590 if !target_path.exists() {
2591 if let Some(parent) = target_path.parent() {
2592 let _ = std::fs::create_dir_all(parent);
2593 }
2594 let header = if which == "shared" { "# Project Rules (Shared)" } else { "# Local Guidelines (Private)" };
2595 let _ = std::fs::write(&target_path, format!("{}\n\nAdd behavioral guidelines here for the agent to follow in this workspace.\n", header));
2596 }
2597
2598 match crate::tools::file_ops::open_in_system_editor(&target_path) {
2599 Ok(_) => app.push_message("System", &format!("Opening {} in system editor...", target_path.display())),
2600 Err(e) => app.push_message("System", &format!("Failed to open editor: {}", e)),
2601 }
2602 }
2603 _ => {
2604 let mut status = "Behavioral Guidelines:\n".to_string();
2605 let candidates = [
2606 "CLAUDE.md",
2607 ".claude.md",
2608 "CLAUDE.local.md",
2609 "HEMATITE.md",
2610 ".hematite/rules.md",
2611 ".hematite/rules.local.md",
2612 ];
2613 for cand in candidates {
2614 let p = ws_root.join(cand);
2615 let icon = if p.exists() { "[v]" } else { "[ ]" };
2616 let label = if cand.contains(".local") || cand.ends_with(".local.md") { "(local override)" } else { "(shared asset)" };
2617 status.push_str(&format!(" {} {:<25} {}\n", icon, cand, label));
2618 }
2619 status.push_str("\nUsage:\n /rules view - View combined rules\n /rules edit - Edit personal local rules (ignored by git)\n /rules edit shared - Edit project-wide shared rules");
2620 app.push_message("System", &status);
2621 }
2622 }
2623 app.history_idx = None;
2624 continue;
2625 }
2626 "/help" => {
2627 show_help_message(&mut app);
2628 app.history_idx = None;
2629 continue;
2630 }
2631 "/help-legacy-unused" => {
2632 app.push_message("System",
2633 "Hematite Commands:\n\
2634 /chat — (Mode) Conversation mode — clean chat, no tool noise\n\
2635 /agent — (Mode) Full coding harness + workstation mode — tools, file edits, builds, inspection\n\
2636 /reroll — (Soul) Hatch a new companion mid-session\n\
2637 /auto — (Flow) Let Hematite choose the narrowest effective workflow\n\
2638 /ask [prompt] — (Flow) Read-only analysis mode; optional inline prompt\n\
2639 /code [prompt] — (Flow) Explicit implementation mode; optional inline prompt\n\
2640 /architect [prompt] — (Flow) Plan-first mode; optional inline prompt\n\
2641 /implement-plan — (Flow) Execute the saved architect handoff in /code\n\
2642 /read-only [prompt] — (Flow) Hard read-only mode; optional inline prompt\n\
2643 /teach [prompt] — (Flow) Teacher mode; inspect machine then walk you through any admin task step-by-step\n\
2644 /new — (Reset) Fresh task context; clear chat, pins, and task files\n\
2645 /forget — (Wipe) Hard forget; purge saved memory and Vein index too\n\
2646 /vein-inspect — (Vein) Inspect indexed memory, hot files, and active room bias\n\
2647 /workspace-profile — (Profile) Show the auto-generated workspace profile\n\
2648 /rules — (Rules) View behavioral guidelines (.hematite/rules.md)\n\
2649 /version — (Build) Show the running Hematite version\n\
2650 /about — (Info) Show author, repo, and product info\n\
2651 /vein-reset — (Vein) Wipe the RAG index; rebuilds automatically on next turn\n\
2652 /clear — (UI) Clear dialogue display only\n\
2653 /gemma-native [auto|on|off|status] — (Model) Auto/force/disable Gemma 4 native formatting\n\
2654 /runtime-refresh — (Model) Re-read LM Studio model + CTX now\n\
2655 /undo — (Ghost) Revert last file change\n\
2656 /diff — (Git) Show session changes (--stat)\n\
2657 /lsp — (Logic) Start Language Servers (semantic intelligence)\n\
2658 /swarm <text> — (Swarm) Spawn parallel workers on a directive\n\
2659 /worktree <cmd> — (Isolated) Manage git worktrees (list|add|remove|prune)\n\
2660 /think — (Brain) Enable deep reasoning mode\n\
2661 /no_think — (Speed) Disable reasoning (3-5x faster responses)\n\
2662 /voice — (TTS) List all available voices\n\
2663 /voice N — (TTS) Select voice by number\n\
2664 /attach <path> — (Docs) Attach a PDF/markdown/txt file for next message\n\
2665 /attach-pick — (Docs) Open a file picker and attach a document\n\
2666 /image <path> — (Vision) Attach an image for the next message\n\
2667 /image-pick — (Vision) Open a file picker and attach an image\n\
2668 /detach — (Context) Drop pending document/image attachments\n\
2669 /copy — (Debug) Copy session transcript to clipboard\n\
2670 /copy2 — (Debug) Copy SPECULAR log to clipboard (reasoning + events)\n\
2671 \nHotkeys:\n\
2672 Ctrl+B — Toggle Brief Mode (minimal output)\n\
2673 Ctrl+P — Toggle Professional Mode (strip personality)\n\
2674 Ctrl+O — Open document picker for next-turn context\n\
2675 Ctrl+I — Open image picker for next-turn vision context\n\
2676 Ctrl+Y — Toggle Approvals Off (bypass safety approvals)\n\
2677 Ctrl+S — Quick Swarm (hardcoded bootstrap)\n\
2678 Ctrl+Z — Undo last edit\n\
2679 Ctrl+Q/C — Quit session\n\
2680 ESC — Silence current playback\n\
2681 \nStatus Legend:\n\
2682 LM — LM Studio runtime health (`LIVE`, `RECV`, `WARN`, `CEIL`, `STALE`, `BOOT`)\n\
2683 VN — Vein RAG status (`SEM`=semantic active, `FTS`=BM25 only, `--`=not indexed)\n\
2684 BUD — Total prompt-budget pressure against the live context window\n\
2685 CMP — History compaction pressure against Hematite's adaptive threshold\n\
2686 ERR — Session error count (runtime, tool, or SPECULAR failures)\n\
2687 CTX — Live context window currently reported by LM Studio\n\
2688 VOICE — Local speech output state\n\
2689 \nAssistant: Semantic Pathing (LSP), Vision Pass, Web Research, Swarm Synthesis"
2690 );
2691 app.history_idx = None;
2692 continue;
2693 }
2694 "/swarm" => {
2695 let directive = parts[1..].join(" ");
2696 if directive.is_empty() {
2697 app.push_message("System", "Usage: /swarm <directive>");
2698 } else {
2699 app.active_workers.clear(); app.push_message("Hematite", &format!("Swarm analyzing: '{}'", directive));
2701 let swarm_tx_c = swarm_tx.clone();
2702 let coord_c = swarm_coordinator.clone();
2703 let max_workers = if app.gpu_state.ratio() > 0.75 { 1 } else { 3 };
2704 app.agent_running = true;
2705 tokio::spawn(async move {
2706 let payload = format!(r#"<worker_task id="1" target="src">Research {}</worker_task>
2707<worker_task id="2" target="src">Implement {}</worker_task>
2708<worker_task id="3" target="docs">Document {}</worker_task>"#, directive, directive, directive);
2709 let tasks = crate::agent::parser::parse_master_spec(&payload);
2710 let _ = coord_c.dispatch_swarm(tasks, swarm_tx_c, max_workers).await;
2711 });
2712 }
2713 app.history_idx = None;
2714 continue;
2715 }
2716 "/version" => {
2717 app.push_message(
2718 "System",
2719 &crate::hematite_version_report(),
2720 );
2721 app.history_idx = None;
2722 continue;
2723 }
2724 "/about" => {
2725 app.push_message(
2726 "System",
2727 &crate::hematite_about_report(),
2728 );
2729 app.history_idx = None;
2730 continue;
2731 }
2732 "/explain" => {
2733 let error_text = parts[1..].join(" ");
2734 if error_text.trim().is_empty() {
2735 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.");
2736 } else {
2737 let framed = format!(
2738 "The user pasted the following error or message and needs a plain-English explanation. \
2739 Explain what this means, why it happened, and what to do about it. \
2740 Use simple, non-technical language. Avoid jargon. \
2741 Structure your response as:\n\
2742 1. What happened (one sentence)\n\
2743 2. Why it happened\n\
2744 3. How to fix it (step by step)\n\
2745 4. How to prevent it next time (optional, if relevant)\n\n\
2746 Error/message to explain:\n```\n{}\n```",
2747 error_text
2748 );
2749 app.push_message("You", &format!("/explain {}", error_text));
2750 app.agent_running = true;
2751 let _ = app.user_input_tx.try_send(UserTurn::text(framed));
2752 }
2753 app.history_idx = None;
2754 continue;
2755 }
2756 "/health" => {
2757 app.push_message("You", "/health");
2758 app.agent_running = true;
2759 let _ = app.user_input_tx.try_send(UserTurn::text(
2760 "Run inspect_host with topic=health_report. \
2761 After getting the report, summarize it in plain English for a non-technical user. \
2762 Use the tier labels (Needs fixing / Worth watching / Looking good) and \
2763 give specific, actionable next steps for any items that need attention."
2764 ));
2765 app.history_idx = None;
2766 continue;
2767 }
2768 "/detach" => {
2769 app.clear_pending_attachments();
2770 app.push_message("System", "Cleared pending document/image attachments for the next turn.");
2771 app.history_idx = None;
2772 continue;
2773 }
2774 "/attach" => {
2775 let file_path = parts[1..].join(" ").trim().to_string();
2776 if file_path.is_empty() {
2777 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.");
2778 app.history_idx = None;
2779 continue;
2780 }
2781 if file_path.is_empty() {
2782 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.");
2783 } else {
2784 let p = std::path::Path::new(&file_path);
2785 match crate::memory::vein::extract_document_text(p) {
2786 Ok(text) => {
2787 let name = p.file_name()
2788 .and_then(|n| n.to_str())
2789 .unwrap_or(&file_path)
2790 .to_string();
2791 let preview_len = text.len().min(200);
2792 app.push_message("System", &format!(
2793 "Attached: {} ({} chars) — will be injected as context on your next message.\nPreview: {}...",
2794 name, text.len(), &text[..preview_len]
2795 ));
2796 app.attached_context = Some((name, text));
2797 }
2798 Err(e) => {
2799 app.push_message("System", &format!("Attach failed: {}", e));
2800 }
2801 }
2802 }
2803 app.history_idx = None;
2804 continue;
2805 }
2806 "/attach-pick" => {
2807 match pick_attachment_path(AttachmentPickerKind::Document) {
2808 Ok(Some(path)) => attach_document_from_path(&mut app, &path),
2809 Ok(None) => app.push_message("System", "Document picker cancelled."),
2810 Err(e) => app.push_message("System", &e),
2811 }
2812 app.history_idx = None;
2813 continue;
2814 }
2815 "/image" => {
2816 let file_path = parts[1..].join(" ").trim().to_string();
2817 if file_path.is_empty() {
2818 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.");
2819 } else {
2820 attach_image_from_path(&mut app, &file_path);
2821 }
2822 app.history_idx = None;
2823 continue;
2824 }
2825 "/image-pick" => {
2826 match pick_attachment_path(AttachmentPickerKind::Image) {
2827 Ok(Some(path)) => attach_image_from_path(&mut app, &path),
2828 Ok(None) => app.push_message("System", "Image picker cancelled."),
2829 Err(e) => app.push_message("System", &e),
2830 }
2831 app.history_idx = None;
2832 continue;
2833 }
2834 _ => {
2835 app.push_message("System", &format!("Unknown command: {}", cmd));
2836 app.history_idx = None;
2837 continue;
2838 }
2839 }
2840 }
2841
2842 if app.input_history.last().map(|s| s.as_str()) != Some(&input_text) {
2844 app.input_history.push(input_text.clone());
2845 if app.input_history.len() > 50 {
2846 app.input_history.remove(0);
2847 }
2848 }
2849 app.history_idx = None;
2850 app.push_message("You", &input_text);
2851 app.agent_running = true;
2852 app.cancel_token.store(false, std::sync::atomic::Ordering::SeqCst);
2853 app.last_reasoning.clear();
2854 app.manual_scroll_offset = None;
2855 app.specular_auto_scroll = true;
2856 let tx = app.user_input_tx.clone();
2857 let outbound = UserTurn {
2858 text: input_text,
2859 attached_document: app.attached_context.take().map(|(name, content)| {
2860 AttachedDocument { name, content }
2861 }),
2862 attached_image: app.attached_image.take(),
2863 };
2864 tokio::spawn(async move {
2865 let _ = tx.send(outbound).await;
2866 });
2867 }
2868 }
2869 _ => {}
2870 }
2871 }
2872 Some(Ok(Event::Paste(content))) => {
2873 if !try_attach_from_paste(&mut app, &content) {
2874 let normalized = content.replace("\r\n", " ").replace('\n', " ");
2877 app.input.push_str(&normalized);
2878 app.last_input_time = Instant::now();
2879 }
2880 }
2881 _ => {}
2882 }
2883 }
2884
2885 Some(specular_evt) = specular_rx.recv() => {
2887 match specular_evt {
2888 SpecularEvent::SyntaxError { path, details } => {
2889 app.record_error();
2890 app.specular_logs.push(format!("ERROR: {:?}", path));
2891 trim_vec(&mut app.specular_logs, 20);
2892
2893 let user_idle = {
2895 let lock = last_interaction.lock().unwrap();
2896 lock.elapsed() > std::time::Duration::from_secs(3)
2897 };
2898 if user_idle && !app.agent_running {
2899 app.agent_running = true;
2900 let tx = app.user_input_tx.clone();
2901 let diag = details.clone();
2902 tokio::spawn(async move {
2903 let msg = format!(
2904 "<specular-build-fail>\n{}\n</specular-build-fail>\n\
2905 Fix the compiler error above.",
2906 diag
2907 );
2908 let _ = tx.send(UserTurn::text(msg)).await;
2909 });
2910 }
2911 }
2912 SpecularEvent::FileChanged(path) => {
2913 app.stats.wisdom += 1;
2914 app.stats.patience = (app.stats.patience - 0.5).max(0.0);
2915 if app.stats.patience < 50.0 && !app.brief_mode {
2916 app.brief_mode = true;
2917 app.push_message("System", "Context saturation high — Brief Mode auto-enabled.");
2918 }
2919 let path_str = path.to_string_lossy().to_string();
2920 app.specular_logs.push(format!("INDEX: {}", path_str));
2921 app.push_context_file(path_str, "Active".into());
2922 trim_vec(&mut app.specular_logs, 20);
2923 }
2924 }
2925 }
2926
2927 Some(event) = agent_rx.recv() => {
2929 use crate::agent::inference::InferenceEvent;
2930 match event {
2931 InferenceEvent::Thought(content) => {
2932 app.thinking = true;
2933 app.current_thought.push_str(&content);
2934 }
2935 InferenceEvent::VoiceStatus(msg) => {
2936 app.push_message("System", &msg);
2937 }
2938 InferenceEvent::Token(ref token) | InferenceEvent::MutedToken(ref token) => {
2939 let is_muted = matches!(event, InferenceEvent::MutedToken(_));
2940 app.thinking = false;
2941 if app.messages_raw.last().map(|(s, _)| s.as_str()) != Some("Hematite") {
2942 app.push_message("Hematite", "");
2943 }
2944 app.update_last_message(token);
2945 app.manual_scroll_offset = None;
2946
2947 if !is_muted && app.voice_manager.is_enabled() && !app.cancel_token.load(std::sync::atomic::Ordering::SeqCst) {
2949 app.voice_manager.speak(token.clone());
2950 }
2951 }
2952 InferenceEvent::ToolCallStart { name, args, .. } => {
2953 if app.workflow_mode != "CHAT" {
2955 let display = format!("( ) {} {}", name, args);
2956 app.push_message("Tool", &display);
2957 }
2958 app.active_context.push(ContextFile {
2960 path: name.clone(),
2961 size: 0,
2962 status: "Running".into()
2963 });
2964 trim_vec_context(&mut app.active_context, 8);
2965 app.manual_scroll_offset = None;
2966 }
2967 InferenceEvent::ToolCallResult { id: _, name, output, is_error } => {
2968 let icon = if is_error { "[x]" } else { "[v]" };
2969 if is_error {
2970 app.record_error();
2971 }
2972 let preview = first_n_chars(&output, 100);
2975 if app.workflow_mode != "CHAT" {
2976 app.push_message("Tool", &format!("{} {} → {}", icon, name, preview));
2977 } else if is_error {
2978 app.push_message("System", &format!("Tool error: {}", preview));
2979 }
2980
2981 app.active_context.retain(|f| f.path != name || f.status != "Running");
2986 app.manual_scroll_offset = None;
2987 }
2988 InferenceEvent::ApprovalRequired { id: _, name, display, diff, mutation_label, responder } => {
2989 let is_diff = diff.is_some();
2990 app.awaiting_approval = Some(PendingApproval {
2991 display: display.clone(),
2992 tool_name: name,
2993 diff,
2994 diff_scroll: 0,
2995 mutation_label,
2996 responder,
2997 });
2998 if is_diff {
2999 app.push_message("System", "[~] Diff preview — [Y] Apply [N] Skip");
3000 } else {
3001 app.push_message("System", "[!] Approval required (Press [Y] Approve or [N] Decline)");
3002 app.push_message("System", &format!("Command: {}", display));
3003 }
3004 }
3005 InferenceEvent::UsageUpdate(usage) => {
3006 app.total_tokens = usage.total_tokens;
3007 let turn_cost = crate::agent::pricing::calculate_cost(&usage, &app.model_id);
3009 app.current_session_cost += turn_cost;
3010 }
3011 InferenceEvent::Done => {
3012 app.thinking = false;
3013 app.agent_running = false;
3014 if app.voice_manager.is_enabled() {
3015 app.voice_manager.flush();
3016 }
3017 if !app.current_thought.is_empty() {
3018 app.last_reasoning = app.current_thought.clone();
3019 }
3020 app.current_thought.clear();
3021 app.specular_auto_scroll = true;
3022 app.active_workers.remove("AGENT");
3024 app.worker_labels.remove("AGENT");
3025 }
3026 InferenceEvent::CopyDiveInCommand(path) => {
3027 let command = format!("cd \"{}\" && hematite", path.replace('\\', "/"));
3028 copy_text_to_clipboard(&command);
3029 spawn_dive_in_terminal(&path);
3030 app.push_message("System", &format!("Teleportation initiated: New terminal launched at {}", path));
3031 app.push_message("System", "Teleportation complete. Closing original session to maintain workstation hygiene...");
3032
3033 app.write_session_report();
3035 app.copy_transcript_to_clipboard();
3036 break;
3037 }
3038 InferenceEvent::Error(e) => {
3039 app.record_error();
3040 app.thinking = false;
3041 app.agent_running = false;
3042 if app.voice_manager.is_enabled() {
3043 app.voice_manager.flush();
3044 }
3045 app.push_message("System", &format!("Error: {e}"));
3046 }
3047 InferenceEvent::ProviderStatus { state, summary } => {
3048 app.provider_state = state;
3049 if !summary.trim().is_empty() && app.last_provider_summary != summary {
3050 app.specular_logs.push(format!("PROVIDER: {}", summary));
3051 trim_vec(&mut app.specular_logs, 20);
3052 app.last_provider_summary = summary;
3053 }
3054 }
3055 InferenceEvent::McpStatus { state, summary } => {
3056 app.mcp_state = state;
3057 if !summary.trim().is_empty() && app.last_mcp_summary != summary {
3058 app.specular_logs.push(format!("MCP: {}", summary));
3059 trim_vec(&mut app.specular_logs, 20);
3060 app.last_mcp_summary = summary;
3061 }
3062 }
3063 InferenceEvent::OperatorCheckpoint { state, summary } => {
3064 app.last_operator_checkpoint_state = state;
3065 if state == OperatorCheckpointState::Idle {
3066 app.last_operator_checkpoint_summary.clear();
3067 } else if !summary.trim().is_empty()
3068 && app.last_operator_checkpoint_summary != summary
3069 {
3070 app.specular_logs.push(format!(
3071 "STATE: {} - {}",
3072 state.label(),
3073 summary
3074 ));
3075 trim_vec(&mut app.specular_logs, 20);
3076 app.last_operator_checkpoint_summary = summary;
3077 }
3078 }
3079 InferenceEvent::RecoveryRecipe { summary } => {
3080 if !summary.trim().is_empty()
3081 && app.last_recovery_recipe_summary != summary
3082 {
3083 app.specular_logs.push(format!("RECOVERY: {}", summary));
3084 trim_vec(&mut app.specular_logs, 20);
3085 app.last_recovery_recipe_summary = summary;
3086 }
3087 }
3088 InferenceEvent::CompactionPressure {
3089 estimated_tokens,
3090 threshold_tokens,
3091 percent,
3092 } => {
3093 app.compaction_estimated_tokens = estimated_tokens;
3094 app.compaction_threshold_tokens = threshold_tokens;
3095 app.compaction_percent = percent;
3096 if percent < 60 {
3100 app.compaction_warned_level = 0;
3101 } else if percent >= 90 && app.compaction_warned_level < 90 {
3102 app.compaction_warned_level = 90;
3103 app.push_message(
3104 "System",
3105 "Context is 90% full. Use /new to reset history (project memory is preserved) or /forget to wipe everything.",
3106 );
3107 } else if percent >= 70 && app.compaction_warned_level < 70 {
3108 app.compaction_warned_level = 70;
3109 app.push_message(
3110 "System",
3111 &format!("Context at {}% — approaching the compaction threshold. Consider /new soon to keep responses sharp.", percent),
3112 );
3113 }
3114 }
3115 InferenceEvent::PromptPressure {
3116 estimated_input_tokens,
3117 reserved_output_tokens,
3118 estimated_total_tokens,
3119 context_length: _,
3120 percent,
3121 } => {
3122 app.prompt_estimated_input_tokens = estimated_input_tokens;
3123 app.prompt_reserved_output_tokens = reserved_output_tokens;
3124 app.prompt_estimated_total_tokens = estimated_total_tokens;
3125 app.prompt_pressure_percent = percent;
3126 }
3127 InferenceEvent::TaskProgress { id, label, progress } => {
3128 let nid = normalize_id(&id);
3129 app.active_workers.insert(nid.clone(), progress);
3130 app.worker_labels.insert(nid, label);
3131 }
3132 InferenceEvent::RuntimeProfile { model_id, context_length } => {
3133 let was_no_model = app.model_id == "no model loaded";
3134 let now_no_model = model_id == "no model loaded";
3135 let changed = app.model_id != "detecting..."
3136 && (app.model_id != model_id || app.context_length != context_length);
3137 app.model_id = model_id.clone();
3138 app.context_length = context_length;
3139 app.last_runtime_profile_time = Instant::now();
3140 if app.provider_state == ProviderRuntimeState::Booting {
3141 app.provider_state = ProviderRuntimeState::Live;
3142 }
3143 if now_no_model && !was_no_model {
3144 app.push_message(
3145 "System",
3146 "No coding model loaded. Load a model in LM Studio (e.g. Qwen/Qwen3.5-9B Q4_K_M) and start the server on port 1234. Optionally also load nomic-embed-text-v2 for semantic search.",
3147 );
3148 } else if changed && !now_no_model {
3149 app.push_message(
3150 "System",
3151 &format!(
3152 "Runtime profile refreshed: Model {} | CTX {}",
3153 model_id, context_length
3154 ),
3155 );
3156 }
3157 }
3158 InferenceEvent::EmbedProfile { model_id } => {
3159 match model_id {
3160 Some(id) => app.push_message(
3161 "System",
3162 &format!("Embed model loaded: {} (semantic search ready)", id),
3163 ),
3164 None => app.push_message(
3165 "System",
3166 "Embed model unloaded. Semantic search inactive.",
3167 ),
3168 }
3169 }
3170 InferenceEvent::VeinStatus { file_count, embedded_count, docs_only } => {
3171 app.vein_file_count = file_count;
3172 app.vein_embedded_count = embedded_count;
3173 app.vein_docs_only = docs_only;
3174 }
3175 InferenceEvent::VeinContext { paths } => {
3176 app.active_context.retain(|f| f.status == "Running");
3179 for path in paths {
3180 let root = crate::tools::file_ops::workspace_root();
3181 let size = std::fs::metadata(root.join(&path))
3182 .map(|m| m.len())
3183 .unwrap_or(0);
3184 if !app.active_context.iter().any(|f| f.path == path) {
3185 app.active_context.push(ContextFile {
3186 path,
3187 size,
3188 status: "Vein".to_string(),
3189 });
3190 }
3191 }
3192 trim_vec_context(&mut app.active_context, 8);
3193 }
3194 InferenceEvent::SoulReroll { species, rarity, shiny, .. } => {
3195 let shiny_tag = if shiny { " 🌟 SHINY" } else { "" };
3196 app.soul_name = species.clone();
3197 app.push_message(
3198 "System",
3199 &format!("[{}{}] {} has awakened.", rarity, shiny_tag, species),
3200 );
3201 }
3202 InferenceEvent::ShellLine(line) => {
3203 app.current_thought.push_str(&line);
3206 app.current_thought.push('\n');
3207 }
3208 }
3209 }
3210
3211 Some(msg) = swarm_rx.recv() => {
3213 match msg {
3214 SwarmMessage::Progress(worker_id, progress) => {
3215 let nid = normalize_id(&worker_id);
3216 app.active_workers.insert(nid.clone(), progress);
3217 match progress {
3218 102 => app.push_message("System", &format!("Worker {} architecture verified and applied.", nid)),
3219 101 => { },
3220 100 => app.push_message("Hematite", &format!("Worker {} complete. Standing by for review...", nid)),
3221 _ => {}
3222 }
3223 }
3224 SwarmMessage::ReviewRequest { worker_id, file_path, before, after, tx } => {
3225 app.push_message("Hematite", &format!("Worker {} conflict — review required.", worker_id));
3226 app.active_review = Some(ActiveReview {
3227 worker_id,
3228 file_path: file_path.to_string_lossy().to_string(),
3229 before,
3230 after,
3231 tx,
3232 });
3233 }
3234 SwarmMessage::Done => {
3235 app.agent_running = false;
3236 app.push_message("System", "──────────────────────────────────────────────────────────");
3238 app.push_message("System", " TASK COMPLETE: Swarm Synthesis Finalized ");
3239 app.push_message("System", "──────────────────────────────────────────────────────────");
3240 }
3241 }
3242 }
3243 }
3244 }
3245 Ok(())
3246}
3247
3248fn ui(f: &mut ratatui::Frame, app: &App) {
3251 let size = f.size();
3252 if size.width < 60 || size.height < 10 {
3253 f.render_widget(Clear, size);
3255 return;
3256 }
3257
3258 let input_height = compute_input_height(f.size().width, app.input.len());
3259
3260 let chunks = Layout::default()
3261 .direction(Direction::Vertical)
3262 .constraints([
3263 Constraint::Min(0),
3264 Constraint::Length(input_height),
3265 Constraint::Length(3),
3266 ])
3267 .split(f.size());
3268
3269 let top = Layout::default()
3270 .direction(Direction::Horizontal)
3271 .constraints([Constraint::Fill(1), Constraint::Length(45)]) .split(chunks[0]);
3273
3274 let mut core_lines = app.messages.clone();
3276
3277 if app.agent_running {
3279 let dots = ".".repeat((app.tick_count % 4) as usize + 1);
3280 core_lines.push(Line::from(Span::styled(
3281 format!(" Hematite is thinking{}", dots),
3282 Style::default()
3283 .fg(Color::Magenta)
3284 .add_modifier(Modifier::DIM),
3285 )));
3286 }
3287
3288 let (heart_color, core_icon) = if app.agent_running || !app.active_workers.is_empty() {
3289 let (r_base, g_base, b_base) = if !app.active_workers.is_empty() {
3290 (0, 200, 200) } else {
3292 (200, 0, 200) };
3294
3295 let pulse = (app.tick_count % 50) as f64 / 50.0;
3296 let factor = (pulse * std::f64::consts::PI).sin().abs();
3297 let r = (r_base as f64 * factor) as u8;
3298 let g = (g_base as f64 * factor) as u8;
3299 let b = (b_base as f64 * factor) as u8;
3300
3301 (Color::Rgb(r.max(60), g.max(60), b.max(60)), "•")
3302 } else {
3303 (Color::Rgb(80, 80, 80), "•") };
3305
3306 let live_objective = if app.current_objective != "Idle" {
3307 app.current_objective.clone()
3308 } else if !app.active_workers.is_empty() {
3309 "Swarm active".to_string()
3310 } else if app.thinking {
3311 "Reasoning".to_string()
3312 } else if app.agent_running {
3313 "Working".to_string()
3314 } else {
3315 "Idle".to_string()
3316 };
3317
3318 let objective_text = if live_objective.len() > 30 {
3319 format!("{}...", &live_objective[..27])
3320 } else {
3321 live_objective
3322 };
3323
3324 let core_title = if app.professional {
3325 Line::from(vec![
3326 Span::styled(format!(" {} ", core_icon), Style::default().fg(heart_color)),
3327 Span::styled("HEMATITE ", Style::default().add_modifier(Modifier::BOLD)),
3328 Span::styled(
3329 format!(" TASK: {} ", objective_text),
3330 Style::default()
3331 .fg(Color::Yellow)
3332 .add_modifier(Modifier::ITALIC),
3333 ),
3334 ])
3335 } else {
3336 Line::from(format!(" TASK: {} ", objective_text))
3337 };
3338
3339 let core_para = Paragraph::new(core_lines.clone())
3340 .block(
3341 Block::default()
3342 .title(core_title)
3343 .borders(Borders::ALL)
3344 .border_style(Style::default().fg(Color::DarkGray)),
3345 )
3346 .wrap(Wrap { trim: true });
3347
3348 let avail_h = top[0].height.saturating_sub(2);
3350 let inner_w = top[0].width.saturating_sub(4).max(1);
3352
3353 let mut total_lines: u16 = 0;
3354 for line in &core_lines {
3355 let line_w = line.width() as u16;
3356 if line_w == 0 {
3357 total_lines += 1;
3358 } else {
3359 let wrapped = (line_w + inner_w - 1) / inner_w;
3363 total_lines += wrapped;
3364 }
3365 }
3366
3367 let max_scroll = total_lines.saturating_sub(avail_h);
3368 let scroll = if let Some(off) = app.manual_scroll_offset {
3369 max_scroll.saturating_sub(off)
3370 } else {
3371 max_scroll
3372 };
3373
3374 f.render_widget(Clear, top[0]);
3376
3377 let chat_area = Rect::new(
3379 top[0].x + 1,
3380 top[0].y,
3381 top[0].width.saturating_sub(2).max(1),
3382 top[0].height,
3383 );
3384 f.render_widget(Clear, chat_area);
3385 f.render_widget(core_para.scroll((scroll, 0)), chat_area);
3386
3387 let mut scrollbar_state =
3390 ScrollbarState::new(max_scroll as usize + 1).position(scroll as usize);
3391 f.render_stateful_widget(
3392 Scrollbar::default()
3393 .orientation(ScrollbarOrientation::VerticalRight)
3394 .begin_symbol(Some("↑"))
3395 .end_symbol(Some("↓")),
3396 top[0],
3397 &mut scrollbar_state,
3398 );
3399
3400 let side = Layout::default()
3402 .direction(Direction::Vertical)
3403 .constraints([
3404 Constraint::Length(8), Constraint::Min(0), ])
3407 .split(top[1]);
3408
3409 let context_source = if app.active_context.is_empty() {
3411 default_active_context()
3412 } else {
3413 app.active_context.clone()
3414 };
3415 let mut context_display = context_source
3416 .iter()
3417 .map(|f| {
3418 let (icon, color) = match f.status.as_str() {
3419 "Running" => ("⚙️", Color::Cyan),
3420 "Dirty" => ("📝", Color::Yellow),
3421 _ => ("📄", Color::Gray),
3422 };
3423 let tokens = f.size / 4;
3425 ListItem::new(Line::from(vec![
3426 Span::styled(format!(" {} ", icon), Style::default().fg(color)),
3427 Span::styled(f.path.clone(), Style::default().fg(Color::White)),
3428 Span::styled(
3429 format!(" {}t ", tokens),
3430 Style::default().fg(Color::DarkGray),
3431 ),
3432 ]))
3433 })
3434 .collect::<Vec<ListItem>>();
3435
3436 if context_display.is_empty() {
3437 context_display = vec![ListItem::new(" (No active files)")];
3438 }
3439
3440 let ctx_block = Block::default()
3441 .title(" ACTIVE CONTEXT ")
3442 .borders(Borders::ALL)
3443 .border_style(Style::default().fg(Color::DarkGray));
3444
3445 f.render_widget(Clear, side[0]);
3446 f.render_widget(List::new(context_display).block(ctx_block), side[0]);
3447
3448 let v_title = if app.thinking || app.agent_running {
3453 format!(" SPECULAR [working] ")
3454 } else {
3455 " SPECULAR [Watching] ".to_string()
3456 };
3457
3458 f.render_widget(Clear, side[1]);
3459
3460 let mut v_lines: Vec<Line<'static>> = Vec::new();
3461
3462 if app.thinking || app.agent_running {
3464 let dots = ".".repeat((app.tick_count % 4) as usize + 1);
3465 let label = if app.thinking { "REASONING" } else { "WORKING" };
3466 v_lines.push(Line::from(vec![Span::styled(
3467 format!("[ {}{} ]", label, dots),
3468 Style::default()
3469 .fg(Color::Green)
3470 .add_modifier(Modifier::BOLD),
3471 )]));
3472 let preview = if app.current_thought.chars().count() > 300 {
3474 app.current_thought
3475 .chars()
3476 .rev()
3477 .take(300)
3478 .collect::<Vec<_>>()
3479 .into_iter()
3480 .rev()
3481 .collect::<String>()
3482 } else {
3483 app.current_thought.clone()
3484 };
3485 for raw in preview.lines() {
3486 let raw = raw.trim();
3487 if !raw.is_empty() {
3488 v_lines.extend(render_markdown_line(raw));
3489 }
3490 }
3491 v_lines.push(Line::raw(""));
3492 }
3493
3494 if !app.active_workers.is_empty() {
3496 v_lines.push(Line::from(vec![Span::styled(
3497 "── Task Progress ──",
3498 Style::default()
3499 .fg(Color::White)
3500 .add_modifier(Modifier::DIM),
3501 )]));
3502
3503 let mut sorted_ids: Vec<_> = app.active_workers.keys().cloned().collect();
3504 sorted_ids.sort();
3505
3506 for id in sorted_ids {
3507 let prog = app.active_workers[&id];
3508 let custom_label = app.worker_labels.get(&id).cloned();
3509
3510 let (label, color) = match prog {
3511 101..=102 => ("VERIFIED", Color::Green),
3512 100 if !app.agent_running && id != "AGENT" => ("SKIPPED ", Color::DarkGray),
3513 100 => ("REVIEW ", Color::Magenta),
3514 _ => ("WORKING ", Color::Yellow),
3515 };
3516
3517 let display_label = custom_label.unwrap_or_else(|| label.to_string());
3518 let filled = (prog.min(100) / 10) as usize;
3519 let bar = "▓".repeat(filled) + &"░".repeat(10 - filled);
3520
3521 let id_prefix = if id == "AGENT" {
3522 "Agent: ".to_string()
3523 } else {
3524 format!("W{}: ", id)
3525 };
3526
3527 v_lines.push(Line::from(vec![
3528 Span::styled(id_prefix, Style::default().fg(Color::Gray)),
3529 Span::styled(bar, Style::default().fg(color)),
3530 Span::styled(
3531 format!(" {} ", display_label),
3532 Style::default().fg(color).add_modifier(Modifier::BOLD),
3533 ),
3534 Span::styled(
3535 format!("{}%", prog.min(100)),
3536 Style::default().fg(Color::DarkGray),
3537 ),
3538 ]));
3539 }
3540 v_lines.push(Line::raw(""));
3541 }
3542
3543 if !app.last_reasoning.is_empty() {
3545 v_lines.push(Line::from(vec![Span::styled(
3546 "── Logic Trace ──",
3547 Style::default()
3548 .fg(Color::White)
3549 .add_modifier(Modifier::DIM),
3550 )]));
3551 for raw in app.last_reasoning.lines() {
3552 v_lines.extend(render_markdown_line(raw));
3553 }
3554 v_lines.push(Line::raw(""));
3555 }
3556
3557 if !app.specular_logs.is_empty() {
3559 v_lines.push(Line::from(vec![Span::styled(
3560 "── Events ──",
3561 Style::default()
3562 .fg(Color::White)
3563 .add_modifier(Modifier::DIM),
3564 )]));
3565 for log in &app.specular_logs {
3566 let (icon, color) = if log.starts_with("ERROR") {
3567 ("X ", Color::Red)
3568 } else if log.starts_with("INDEX") {
3569 ("I ", Color::Cyan)
3570 } else if log.starts_with("GHOST") {
3571 ("< ", Color::Magenta)
3572 } else {
3573 ("- ", Color::Gray)
3574 };
3575 v_lines.push(Line::from(vec![
3576 Span::styled(icon, Style::default().fg(color)),
3577 Span::styled(
3578 log.to_string(),
3579 Style::default()
3580 .fg(Color::White)
3581 .add_modifier(Modifier::DIM),
3582 ),
3583 ]));
3584 }
3585 }
3586
3587 let v_total = v_lines.len() as u16;
3588 let v_avail = side[1].height.saturating_sub(2);
3589 let v_max_scroll = v_total.saturating_sub(v_avail);
3590 let v_scroll = if app.specular_auto_scroll {
3593 v_max_scroll
3594 } else {
3595 app.specular_scroll.min(v_max_scroll)
3596 };
3597
3598 let specular_para = Paragraph::new(v_lines)
3599 .wrap(Wrap { trim: true })
3600 .scroll((v_scroll, 0))
3601 .block(Block::default().title(v_title).borders(Borders::ALL));
3602
3603 f.render_widget(specular_para, side[1]);
3604
3605 let mut v_scrollbar_state =
3607 ScrollbarState::new(v_max_scroll as usize + 1).position(v_scroll as usize);
3608 f.render_stateful_widget(
3609 Scrollbar::default()
3610 .orientation(ScrollbarOrientation::VerticalRight)
3611 .begin_symbol(None)
3612 .end_symbol(None),
3613 side[1],
3614 &mut v_scrollbar_state,
3615 );
3616
3617 let frame = app.tick_count % 3;
3619 let spark = match frame {
3620 0 => "✧",
3621 1 => "✦",
3622 _ => "✨",
3623 };
3624 let vigil = if app.brief_mode {
3625 "VIGIL:[ON]"
3626 } else {
3627 "VIGIL:[off]"
3628 };
3629 let yolo = if app.yolo_mode {
3630 " | APPROVALS: OFF"
3631 } else {
3632 ""
3633 };
3634
3635 let bar_constraints = if app.professional {
3636 vec![
3637 Constraint::Min(0), Constraint::Length(22), Constraint::Length(12), Constraint::Length(12), Constraint::Length(16), Constraint::Length(28), Constraint::Length(28), ]
3645 } else {
3646 vec![
3647 Constraint::Length(12), Constraint::Min(0), Constraint::Length(22), Constraint::Length(12), Constraint::Length(12), Constraint::Length(16), Constraint::Length(28), Constraint::Length(28), ]
3656 };
3657 let bar_chunks = Layout::default()
3658 .direction(Direction::Horizontal)
3659 .constraints(bar_constraints)
3660 .split(chunks[2]);
3661
3662 let char_count: usize = app.messages_raw.iter().map(|(_, c)| c.len()).sum();
3663 let est_tokens = char_count / 3;
3664 let current_tokens = if app.total_tokens > 0 {
3665 app.total_tokens
3666 } else {
3667 est_tokens
3668 };
3669 let usage_text = format!(
3670 "TOKENS: {:0>5} | TOTAL: ${:.4}",
3671 current_tokens, app.current_session_cost
3672 );
3673 let runtime_age = app.last_runtime_profile_time.elapsed();
3674 let (lm_label, lm_color) = if app.model_id == "no model loaded" {
3675 ("LM:NONE", Color::Red)
3676 } else if app.model_id == "detecting..." || app.context_length == 0 {
3677 ("LM:BOOT", Color::DarkGray)
3678 } else if app.provider_state == ProviderRuntimeState::Recovering {
3679 ("LM:RECV", Color::Cyan)
3680 } else if matches!(
3681 app.provider_state,
3682 ProviderRuntimeState::Degraded | ProviderRuntimeState::EmptyResponse
3683 ) {
3684 ("LM:WARN", Color::Red)
3685 } else if app.provider_state == ProviderRuntimeState::ContextWindow {
3686 ("LM:CEIL", Color::Yellow)
3687 } else if runtime_age > std::time::Duration::from_secs(12) {
3688 ("LM:STALE", Color::Yellow)
3689 } else {
3690 ("LM:LIVE", Color::Green)
3691 };
3692 let compaction_percent = app.compaction_percent.min(100);
3693 let compaction_label = if app.compaction_threshold_tokens == 0 {
3694 " CMP: 0%".to_string()
3695 } else {
3696 format!(" CMP:{:>3}%", compaction_percent)
3697 };
3698 let compaction_color = if app.compaction_threshold_tokens == 0 {
3699 Color::DarkGray
3700 } else if compaction_percent >= 85 {
3701 Color::Red
3702 } else if compaction_percent >= 60 {
3703 Color::Yellow
3704 } else {
3705 Color::Green
3706 };
3707 let prompt_percent = app.prompt_pressure_percent.min(100);
3708 let prompt_label = if app.prompt_estimated_total_tokens == 0 {
3709 " BUD: 0%".to_string()
3710 } else {
3711 format!(" BUD:{:>3}%", prompt_percent)
3712 };
3713 let prompt_color = if app.prompt_estimated_total_tokens == 0 {
3714 Color::DarkGray
3715 } else if prompt_percent >= 85 {
3716 Color::Red
3717 } else if prompt_percent >= 60 {
3718 Color::Yellow
3719 } else {
3720 Color::Green
3721 };
3722
3723 let think_badge = match app.think_mode {
3724 Some(true) => " [THINK]",
3725 Some(false) => " [FAST]",
3726 None => "",
3727 };
3728
3729 let (vein_label, vein_color) = if app.vein_docs_only {
3730 let color = if app.vein_embedded_count > 0 {
3731 Color::Green
3732 } else if app.vein_file_count > 0 {
3733 Color::Yellow
3734 } else {
3735 Color::DarkGray
3736 };
3737 ("VN:DOC", color)
3738 } else if app.vein_file_count == 0 {
3739 ("VN:--", Color::DarkGray)
3740 } else if app.vein_embedded_count > 0 {
3741 ("VN:SEM", Color::Green)
3742 } else {
3743 ("VN:FTS", Color::Yellow)
3744 };
3745
3746 let (status_idx, lm_idx, bud_idx, cmp_idx, remote_idx, tokens_idx, vram_idx) =
3747 if app.professional {
3748 (0usize, 1usize, 2usize, 3usize, 4usize, 5usize, 6usize)
3749 } else {
3750 (1usize, 2usize, 3usize, 4usize, 5usize, 6usize, 7usize)
3751 };
3752
3753 if app.professional {
3754 f.render_widget(Clear, bar_chunks[status_idx]);
3755
3756 let voice_badge = if app.voice_manager.is_enabled() {
3757 " | VOICE:ON"
3758 } else {
3759 ""
3760 };
3761 f.render_widget(
3762 Paragraph::new(format!(
3763 " MODE:PRO | FLOW:{}{} | CTX:{} | ERR:{}{}{}",
3764 app.workflow_mode,
3765 yolo,
3766 app.context_length,
3767 app.stats.debugging,
3768 think_badge,
3769 voice_badge
3770 ))
3771 .block(Block::default().borders(Borders::ALL)),
3772 bar_chunks[status_idx],
3773 );
3774 } else {
3775 f.render_widget(Clear, bar_chunks[0]);
3776 f.render_widget(
3777 Paragraph::new(format!(" {} {}", spark, app.soul_name))
3778 .block(Block::default().borders(Borders::ALL)),
3779 bar_chunks[0],
3780 );
3781 f.render_widget(Clear, bar_chunks[status_idx]);
3782 f.render_widget(
3783 Paragraph::new(format!("{}{}", vigil, think_badge))
3784 .block(Block::default().borders(Borders::ALL).fg(Color::Yellow)),
3785 bar_chunks[status_idx],
3786 );
3787 }
3788
3789 let git_status = app.git_state.status();
3791 let git_label = app.git_state.label();
3792 let git_color = match git_status {
3793 crate::agent::git_monitor::GitRemoteStatus::Connected => Color::Green,
3794 crate::agent::git_monitor::GitRemoteStatus::NoRemote => Color::Yellow,
3795 crate::agent::git_monitor::GitRemoteStatus::Behind
3796 | crate::agent::git_monitor::GitRemoteStatus::Ahead => Color::Magenta,
3797 crate::agent::git_monitor::GitRemoteStatus::Diverged
3798 | crate::agent::git_monitor::GitRemoteStatus::Error => Color::Red,
3799 _ => Color::DarkGray,
3800 };
3801
3802 f.render_widget(Clear, bar_chunks[lm_idx]);
3803 f.render_widget(
3804 Paragraph::new(ratatui::text::Line::from(vec![
3805 ratatui::text::Span::styled(format!(" {}", lm_label), Style::default().fg(lm_color)),
3806 ratatui::text::Span::raw(" | "),
3807 ratatui::text::Span::styled(vein_label, Style::default().fg(vein_color)),
3808 ]))
3809 .block(
3810 Block::default()
3811 .borders(Borders::ALL)
3812 .border_style(Style::default().fg(lm_color)),
3813 ),
3814 bar_chunks[lm_idx],
3815 );
3816
3817 f.render_widget(Clear, bar_chunks[bud_idx]);
3818 f.render_widget(
3819 Paragraph::new(prompt_label)
3820 .block(
3821 Block::default()
3822 .borders(Borders::ALL)
3823 .border_style(Style::default().fg(prompt_color)),
3824 )
3825 .fg(prompt_color),
3826 bar_chunks[bud_idx],
3827 );
3828
3829 f.render_widget(Clear, bar_chunks[cmp_idx]);
3830 f.render_widget(
3831 Paragraph::new(compaction_label)
3832 .block(
3833 Block::default()
3834 .borders(Borders::ALL)
3835 .border_style(Style::default().fg(compaction_color)),
3836 )
3837 .fg(compaction_color),
3838 bar_chunks[cmp_idx],
3839 );
3840
3841 f.render_widget(Clear, bar_chunks[remote_idx]);
3842 f.render_widget(
3843 Paragraph::new(format!(" REMOTE: {}", git_label))
3844 .block(
3845 Block::default()
3846 .borders(Borders::ALL)
3847 .border_style(Style::default().fg(git_color)),
3848 )
3849 .fg(git_color),
3850 bar_chunks[remote_idx],
3851 );
3852
3853 let usage_color = Color::Rgb(215, 125, 40);
3854 f.render_widget(Clear, bar_chunks[tokens_idx]);
3855 f.render_widget(
3856 Paragraph::new(usage_text)
3857 .block(Block::default().borders(Borders::ALL).fg(usage_color))
3858 .fg(usage_color),
3859 bar_chunks[tokens_idx],
3860 );
3861
3862 let vram_ratio = app.gpu_state.ratio();
3864 let vram_label = app.gpu_state.label();
3865 let gpu_name = app.gpu_state.gpu_name();
3866
3867 let gauge_color = if vram_ratio > 0.85 {
3868 Color::Red
3869 } else if vram_ratio > 0.60 {
3870 Color::Yellow
3871 } else {
3872 Color::Cyan
3873 };
3874 f.render_widget(Clear, bar_chunks[vram_idx]);
3875 f.render_widget(
3876 Gauge::default()
3877 .block(
3878 Block::default()
3879 .borders(Borders::ALL)
3880 .title(format!(" {} ", gpu_name)),
3881 )
3882 .gauge_style(Style::default().fg(gauge_color))
3883 .ratio(vram_ratio)
3884 .label(format!(" {} ", vram_label)), bar_chunks[vram_idx],
3886 );
3887
3888 let input_style = if app.agent_running {
3890 Style::default().fg(Color::DarkGray)
3891 } else {
3892 Style::default().fg(Color::Rgb(120, 70, 50))
3893 };
3894 let input_rect = chunks[1];
3895 let title_area = input_title_area(input_rect);
3896 let input_hint = render_input_title(app, title_area);
3897 let input_block = Block::default()
3898 .title(input_hint)
3899 .borders(Borders::ALL)
3900 .border_style(input_style)
3901 .style(Style::default().bg(Color::Rgb(40, 25, 15))); let inner_area = input_block.inner(input_rect);
3904 f.render_widget(Clear, input_rect);
3905 f.render_widget(input_block, input_rect);
3906
3907 f.render_widget(
3908 Paragraph::new(app.input.as_str()).wrap(Wrap { trim: true }),
3909 inner_area,
3910 );
3911
3912 if !app.agent_running && inner_area.height > 0 {
3917 let text_w = app.input.len() as u16;
3918 let max_w = inner_area.width.saturating_sub(1);
3919 let cursor_x = inner_area.x + text_w.min(max_w);
3920 f.set_cursor(cursor_x, inner_area.y);
3921 }
3922
3923 if let Some(approval) = &app.awaiting_approval {
3925 let is_diff_preview = approval.diff.is_some();
3926
3927 let modal_h = if is_diff_preview { 70 } else { 50 };
3929 let area = centered_rect(80, modal_h, f.size());
3930 f.render_widget(Clear, area);
3931
3932 let chunks = Layout::default()
3933 .direction(Direction::Vertical)
3934 .constraints([
3935 Constraint::Length(4), Constraint::Min(0), ])
3938 .split(area);
3939
3940 let (title_str, title_color) = if let Some(_) = &approval.mutation_label {
3942 (" MUTATION REQUESTED — AUTHORISE THE WORKFLOW ", Color::Cyan)
3943 } else if is_diff_preview {
3944 (" DIFF PREVIEW — REVIEW BEFORE APPLYING ", Color::Yellow)
3945 } else {
3946 (" HIGH-RISK OPERATION REQUESTED ", Color::Red)
3947 };
3948 let header_text = vec![
3949 Line::from(Span::styled(
3950 title_str,
3951 Style::default()
3952 .fg(title_color)
3953 .add_modifier(Modifier::BOLD),
3954 )),
3955 Line::from(Span::styled(
3956 if is_diff_preview {
3957 " [↑↓/jk/PgUp/PgDn] Scroll [Y] Apply [N] Skip "
3958 } else {
3959 " [Y] Approve [N] Decline "
3960 },
3961 Style::default()
3962 .fg(Color::Green)
3963 .add_modifier(Modifier::BOLD),
3964 )),
3965 ];
3966 f.render_widget(
3967 Paragraph::new(header_text)
3968 .block(
3969 Block::default()
3970 .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
3971 .border_style(Style::default().fg(title_color)),
3972 )
3973 .alignment(ratatui::layout::Alignment::Center),
3974 chunks[0],
3975 );
3976
3977 let border_color = if let Some(_) = &approval.mutation_label {
3979 Color::Cyan
3980 } else if is_diff_preview {
3981 Color::Yellow
3982 } else {
3983 Color::Red
3984 };
3985 if let Some(diff_text) = &approval.diff {
3986 let added = diff_text.lines().filter(|l| l.starts_with("+ ")).count();
3988 let removed = diff_text.lines().filter(|l| l.starts_with("- ")).count();
3989 let mut body_lines: Vec<Line> = vec![
3990 Line::from(Span::styled(
3991 if let Some(label) = &approval.mutation_label {
3992 format!(" INTENT: {}", label)
3993 } else {
3994 format!(" {}", approval.display)
3995 },
3996 Style::default()
3997 .fg(Color::Cyan)
3998 .add_modifier(Modifier::BOLD),
3999 )),
4000 Line::from(vec![
4001 Span::styled(
4002 format!(" +{}", added),
4003 Style::default()
4004 .fg(Color::Green)
4005 .add_modifier(Modifier::BOLD),
4006 ),
4007 Span::styled(
4008 format!(" -{}", removed),
4009 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
4010 ),
4011 ]),
4012 Line::from(Span::raw("")),
4013 ];
4014 for raw_line in diff_text.lines() {
4015 let styled = if raw_line.starts_with("+ ") {
4016 Line::from(Span::styled(
4017 format!(" {}", raw_line),
4018 Style::default().fg(Color::Green),
4019 ))
4020 } else if raw_line.starts_with("- ") {
4021 Line::from(Span::styled(
4022 format!(" {}", raw_line),
4023 Style::default().fg(Color::Red),
4024 ))
4025 } else if raw_line.starts_with("---") || raw_line.starts_with("@@ ") {
4026 Line::from(Span::styled(
4027 format!(" {}", raw_line),
4028 Style::default()
4029 .fg(Color::DarkGray)
4030 .add_modifier(Modifier::BOLD),
4031 ))
4032 } else {
4033 Line::from(Span::raw(format!(" {}", raw_line)))
4034 };
4035 body_lines.push(styled);
4036 }
4037 f.render_widget(
4038 Paragraph::new(body_lines)
4039 .block(
4040 Block::default()
4041 .borders(Borders::BOTTOM | Borders::LEFT | Borders::RIGHT)
4042 .border_style(Style::default().fg(border_color)),
4043 )
4044 .scroll((approval.diff_scroll, 0)),
4045 chunks[1],
4046 );
4047 } else {
4048 let body_text = vec![
4049 Line::from(Span::raw("")),
4050 Line::from(Span::styled(
4051 if let Some(label) = &approval.mutation_label {
4052 format!(" INTENT: {}", label)
4053 } else {
4054 format!(" ACTION: {}", approval.display)
4055 },
4056 Style::default()
4057 .fg(Color::Cyan)
4058 .add_modifier(Modifier::BOLD),
4059 )),
4060 Line::from(Span::raw("")),
4061 Line::from(Span::styled(
4062 format!(" Tool: {}", approval.tool_name),
4063 Style::default().fg(Color::DarkGray),
4064 )),
4065 ];
4066 if approval.mutation_label.is_some() {
4067 }
4069 f.render_widget(
4070 Paragraph::new(body_text)
4071 .block(
4072 Block::default()
4073 .borders(Borders::BOTTOM | Borders::LEFT | Borders::RIGHT)
4074 .border_style(Style::default().fg(border_color)),
4075 )
4076 .alignment(ratatui::layout::Alignment::Center),
4077 chunks[1],
4078 );
4079 }
4080 }
4081
4082 if let Some(review) = &app.active_review {
4084 draw_diff_review(f, review);
4085 }
4086
4087 if app.show_autocomplete && !app.autocomplete_suggestions.is_empty() {
4089 let area = Rect {
4090 x: chunks[1].x + 2,
4091 y: chunks[1]
4092 .y
4093 .saturating_sub(app.autocomplete_suggestions.len() as u16 + 2),
4094 width: chunks[1].width.saturating_sub(4),
4095 height: app.autocomplete_suggestions.len() as u16 + 2,
4096 };
4097 f.render_widget(Clear, area);
4098
4099 let items: Vec<ListItem> = app
4100 .autocomplete_suggestions
4101 .iter()
4102 .enumerate()
4103 .map(|(i, s)| {
4104 let style = if i == app.selected_suggestion {
4105 Style::default()
4106 .fg(Color::Black)
4107 .bg(Color::Cyan)
4108 .add_modifier(Modifier::BOLD)
4109 } else {
4110 Style::default().fg(Color::Gray)
4111 };
4112 ListItem::new(format!(" 📄 {}", s)).style(style)
4113 })
4114 .collect();
4115
4116 let hatch = List::new(items).block(
4117 Block::default()
4118 .borders(Borders::ALL)
4119 .border_style(Style::default().fg(Color::Cyan))
4120 .title(format!(
4121 " @ RESOLVER (Matching: {}) ",
4122 app.autocomplete_filter
4123 )),
4124 );
4125 f.render_widget(hatch, area);
4126
4127 if app.autocomplete_suggestions.len() >= 15 {
4129 let more_area = Rect {
4130 x: area.x + 2,
4131 y: area.y + area.height - 1,
4132 width: 20,
4133 height: 1,
4134 };
4135 f.render_widget(
4136 Paragraph::new("... (type to narrow) ").style(Style::default().fg(Color::DarkGray)),
4137 more_area,
4138 );
4139 }
4140 }
4141}
4142
4143fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
4146 let vert = Layout::default()
4147 .direction(Direction::Vertical)
4148 .constraints([
4149 Constraint::Percentage((100 - percent_y) / 2),
4150 Constraint::Percentage(percent_y),
4151 Constraint::Percentage((100 - percent_y) / 2),
4152 ])
4153 .split(r);
4154 Layout::default()
4155 .direction(Direction::Horizontal)
4156 .constraints([
4157 Constraint::Percentage((100 - percent_x) / 2),
4158 Constraint::Percentage(percent_x),
4159 Constraint::Percentage((100 - percent_x) / 2),
4160 ])
4161 .split(vert[1])[1]
4162}
4163
4164fn strip_ghost_prefix(s: &str) -> &str {
4165 for prefix in &[
4166 "Hematite: ",
4167 "HEMATITE: ",
4168 "Assistant: ",
4169 "assistant: ",
4170 "Okay, ",
4171 "Hmm, ",
4172 "Wait, ",
4173 "Alright, ",
4174 "Got it, ",
4175 "Certainly, ",
4176 "Sure, ",
4177 "Understood, ",
4178 ] {
4179 if s.to_lowercase().starts_with(&prefix.to_lowercase()) {
4180 return &s[prefix.len()..];
4181 }
4182 }
4183 s
4184}
4185
4186fn first_n_chars(s: &str, n: usize) -> String {
4187 let mut result = String::new();
4188 let mut count = 0;
4189 for c in s.chars() {
4190 if count >= n {
4191 result.push('…');
4192 break;
4193 }
4194 if c == '\n' || c == '\r' {
4195 result.push(' ');
4196 } else if !c.is_control() {
4197 result.push(c);
4198 }
4199 count += 1;
4200 }
4201 result
4202}
4203
4204fn trim_vec_context(v: &mut Vec<ContextFile>, max: usize) {
4205 while v.len() > max {
4206 v.remove(0);
4207 }
4208}
4209
4210fn trim_vec(v: &mut Vec<String>, max: usize) {
4211 while v.len() > max {
4212 v.remove(0);
4213 }
4214}
4215
4216fn render_markdown_line(raw: &str) -> Vec<Line<'static>> {
4219 let cleaned_ansi = strip_ansi(raw);
4221 let trimmed = cleaned_ansi.trim();
4222 if trimmed.is_empty() {
4223 return vec![Line::raw("")];
4224 }
4225
4226 let cleaned_owned = trimmed
4228 .replace("<thought>", "")
4229 .replace("</thought>", "")
4230 .replace("<think>", "")
4231 .replace("</think>", "");
4232 let trimmed = cleaned_owned.trim();
4233 if trimmed.is_empty() {
4234 return vec![];
4235 }
4236
4237 for (prefix, indent) in &[("### ", " "), ("## ", " "), ("# ", "")] {
4239 if let Some(rest) = trimmed.strip_prefix(prefix) {
4240 return vec![Line::from(vec![Span::styled(
4241 format!("{}{}", indent, rest),
4242 Style::default()
4243 .fg(Color::White)
4244 .add_modifier(Modifier::BOLD),
4245 )])];
4246 }
4247 }
4248
4249 if let Some(rest) = trimmed
4251 .strip_prefix("> ")
4252 .or_else(|| trimmed.strip_prefix(">"))
4253 {
4254 return vec![Line::from(vec![
4255 Span::styled("| ", Style::default().fg(Color::DarkGray)),
4256 Span::styled(
4257 rest.to_string(),
4258 Style::default()
4259 .fg(Color::White)
4260 .add_modifier(Modifier::DIM),
4261 ),
4262 ])];
4263 }
4264
4265 if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
4267 let rest = &trimmed[2..];
4268 let mut spans = vec![Span::styled("* ", Style::default().fg(Color::Gray))];
4269 spans.extend(inline_markdown(rest));
4270 return vec![Line::from(spans)];
4271 }
4272
4273 let spans = inline_markdown(trimmed);
4275 vec![Line::from(spans)]
4276}
4277
4278fn inline_markdown_core(text: &str) -> Vec<Span<'static>> {
4280 let mut spans = Vec::new();
4281 let mut remaining = text;
4282
4283 while !remaining.is_empty() {
4284 if let Some(start) = remaining.find("**") {
4285 let before = &remaining[..start];
4286 if !before.is_empty() {
4287 spans.push(Span::raw(before.to_string()));
4288 }
4289 let after_open = &remaining[start + 2..];
4290 if let Some(end) = after_open.find("**") {
4291 spans.push(Span::styled(
4292 after_open[..end].to_string(),
4293 Style::default()
4294 .fg(Color::White)
4295 .add_modifier(Modifier::BOLD),
4296 ));
4297 remaining = &after_open[end + 2..];
4298 continue;
4299 }
4300 }
4301 if let Some(start) = remaining.find('`') {
4302 let before = &remaining[..start];
4303 if !before.is_empty() {
4304 spans.push(Span::raw(before.to_string()));
4305 }
4306 let after_open = &remaining[start + 1..];
4307 if let Some(end) = after_open.find('`') {
4308 spans.push(Span::styled(
4309 after_open[..end].to_string(),
4310 Style::default().fg(Color::Yellow),
4311 ));
4312 remaining = &after_open[end + 1..];
4313 continue;
4314 }
4315 }
4316 spans.push(Span::raw(remaining.to_string()));
4317 break;
4318 }
4319 spans
4320}
4321
4322fn inline_markdown(text: &str) -> Vec<Span<'static>> {
4324 let mut spans = Vec::new();
4325 let mut remaining = text;
4326
4327 while !remaining.is_empty() {
4328 if let Some(start) = remaining.find("**") {
4329 let before = &remaining[..start];
4330 if !before.is_empty() {
4331 spans.push(Span::raw(before.to_string()));
4332 }
4333 let after_open = &remaining[start + 2..];
4334 if let Some(end) = after_open.find("**") {
4335 spans.push(Span::styled(
4336 after_open[..end].to_string(),
4337 Style::default()
4338 .fg(Color::White)
4339 .add_modifier(Modifier::BOLD),
4340 ));
4341 remaining = &after_open[end + 2..];
4342 continue;
4343 }
4344 }
4345 if let Some(start) = remaining.find('`') {
4346 let before = &remaining[..start];
4347 if !before.is_empty() {
4348 spans.push(Span::raw(before.to_string()));
4349 }
4350 let after_open = &remaining[start + 1..];
4351 if let Some(end) = after_open.find('`') {
4352 spans.push(Span::styled(
4353 after_open[..end].to_string(),
4354 Style::default().fg(Color::Yellow),
4355 ));
4356 remaining = &after_open[end + 1..];
4357 continue;
4358 }
4359 }
4360 spans.push(Span::raw(remaining.to_string()));
4361 break;
4362 }
4363 spans
4364}
4365
4366fn draw_splash<B: Backend>(terminal: &mut Terminal<B>) -> Result<(), Box<dyn std::error::Error>> {
4369 let rust_color = Color::Rgb(180, 90, 50);
4370
4371 let logo_lines = vec![
4372 "██╗ ██╗███████╗███╗ ███╗ █████╗ ████████╗██╗████████╗███████╗",
4373 "██║ ██║██╔════╝████╗ ████║██╔══██╗╚══██╔══╝██║╚══██╔══╝██╔════╝",
4374 "███████║█████╗ ██╔████╔██║███████║ ██║ ██║ ██║ █████╗ ",
4375 "██╔══██║██╔══╝ ██║╚██╔╝██║██╔══██║ ██║ ██║ ██║ ██╔══╝ ",
4376 "██║ ██║███████╗██║ ╚═╝ ██║██║ ██║ ██║ ██║ ██║ ███████╗",
4377 "╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝",
4378 ];
4379
4380 let version = env!("CARGO_PKG_VERSION");
4381
4382 terminal.draw(|f| {
4383 let area = f.size();
4384
4385 f.render_widget(
4387 Block::default().style(Style::default().bg(Color::Black)),
4388 area,
4389 );
4390
4391 let content_height: u16 = 13;
4393 let top_pad = area.height.saturating_sub(content_height) / 2;
4394
4395 let mut lines: Vec<Line<'static>> = Vec::new();
4396
4397 for _ in 0..top_pad {
4399 lines.push(Line::raw(""));
4400 }
4401
4402 for logo_line in &logo_lines {
4404 lines.push(Line::from(Span::styled(
4405 logo_line.to_string(),
4406 Style::default().fg(rust_color).add_modifier(Modifier::BOLD),
4407 )));
4408 }
4409
4410 lines.push(Line::raw(""));
4412
4413 lines.push(Line::from(vec![Span::styled(
4415 format!("v{}", version),
4416 Style::default().fg(Color::DarkGray),
4417 )]));
4418
4419 lines.push(Line::from(vec![Span::styled(
4421 "Local AI coding harness + workstation assistant",
4422 Style::default()
4423 .fg(Color::DarkGray)
4424 .add_modifier(Modifier::DIM),
4425 )]));
4426
4427 lines.push(Line::from(vec![Span::styled(
4429 "Developed by Ocean Bennett",
4430 Style::default().fg(Color::Gray).add_modifier(Modifier::DIM),
4431 )]));
4432
4433 lines.push(Line::raw(""));
4435 lines.push(Line::raw(""));
4436
4437 lines.push(Line::from(vec![
4439 Span::styled("[ ", Style::default().fg(rust_color)),
4440 Span::styled(
4441 "Press ENTER to start",
4442 Style::default()
4443 .fg(Color::White)
4444 .add_modifier(Modifier::BOLD),
4445 ),
4446 Span::styled(" ]", Style::default().fg(rust_color)),
4447 ]));
4448
4449 let splash = Paragraph::new(lines).alignment(ratatui::layout::Alignment::Center);
4450
4451 f.render_widget(splash, area);
4452 })?;
4453
4454 Ok(())
4455}
4456
4457fn normalize_id(id: &str) -> String {
4458 id.trim().to_uppercase()
4459}
4460
4461fn filter_tui_noise(text: &str) -> String {
4462 let cleaned = strip_ansi(text);
4464
4465 let mut lines = Vec::new();
4467 for line in cleaned.lines() {
4468 if CRLF_REGEX.is_match(line) {
4470 continue;
4471 }
4472 if line.contains("Updating files:") && line.contains("%") {
4474 continue;
4475 }
4476 let sanitized: String = line
4478 .chars()
4479 .filter(|c| !c.is_control() || *c == '\t')
4480 .collect();
4481 if sanitized.trim().is_empty() && !line.trim().is_empty() {
4482 continue;
4483 }
4484
4485 lines.push(normalize_tui_text(&sanitized));
4486 }
4487 lines.join("\n").trim().to_string()
4488}
4489
4490fn normalize_tui_text(text: &str) -> String {
4491 let mut normalized = text
4492 .replace("ΓÇö", "-")
4493 .replace("ΓÇô", "-")
4494 .replace("…", "...")
4495 .replace("✅", "[OK]")
4496 .replace("🛠️", "")
4497 .replace("—", "-")
4498 .replace("–", "-")
4499 .replace("…", "...")
4500 .replace("•", "*")
4501 .replace("✅", "[OK]")
4502 .replace("🚨", "[!]");
4503
4504 normalized = normalized
4505 .chars()
4506 .map(|c| match c {
4507 '\u{00A0}' => ' ',
4508 '\u{2018}' | '\u{2019}' => '\'',
4509 '\u{201C}' | '\u{201D}' => '"',
4510 c if c.is_ascii() || c == '\n' || c == '\t' => c,
4511 _ => ' ',
4512 })
4513 .collect();
4514
4515 let mut compacted = String::with_capacity(normalized.len());
4516 let mut prev_space = false;
4517 for ch in normalized.chars() {
4518 if ch == ' ' {
4519 if !prev_space {
4520 compacted.push(ch);
4521 }
4522 prev_space = true;
4523 } else {
4524 compacted.push(ch);
4525 prev_space = false;
4526 }
4527 }
4528
4529 compacted.trim().to_string()
4530}