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