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