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 - tools, file edits, builds\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 /vein-reset - (Vein) Wipe the RAG index; rebuilds automatically on next turn\n\
1200 /clear - (UI) Clear dialogue display only\n\
1201 /gemma-native [auto|on|off|status] - (Model) Auto/force/disable Gemma 4 native formatting\n\
1202 /runtime-refresh - (Model) Re-read LM Studio model + CTX now\n\
1203 /undo - (Ghost) Revert last file change\n\
1204 /diff - (Git) Show session changes (--stat)\n\
1205 /lsp - (Logic) Start Language Servers (semantic intelligence)\n\
1206 /swarm <text> - (Swarm) Spawn parallel workers on a directive\n\
1207 /worktree <cmd> - (Isolated) Manage git worktrees (list|add|remove|prune)\n\
1208 /think - (Brain) Enable deep reasoning mode\n\
1209 /no_think - (Speed) Disable reasoning (3-5x faster responses)\n\
1210 /voice - (TTS) List all available voices\n\
1211 /voice N - (TTS) Select voice by number\n\
1212 /read <text> - (TTS) Speak text aloud directly, bypassing the model. ESC to stop.\n\
1213 /attach <path> - (Docs) Attach a PDF/markdown/txt file for next message (PDF best-effort)\n\
1214 /attach-pick - (Docs) Open a file picker and attach a document\n\
1215 /image <path> - (Vision) Attach an image for the next message\n\
1216 /image-pick - (Vision) Open a file picker and attach an image\n\
1217 /detach - (Context) Drop pending document/image attachments\n\
1218 /copy - (Debug) Copy exact session transcript (includes help/system output)\n\
1219 /copy-last - (Debug) Copy the latest Hematite reply only\n\
1220 /copy-clean - (Debug) Copy chat transcript without help/debug boilerplate\n\
1221 /copy2 - (Debug) Copy SPECULAR log to clipboard (reasoning + events)\n\
1222 \nHotkeys:\n\
1223 Ctrl+B - Toggle Brief Mode (minimal output)\n\
1224 Ctrl+P - Toggle Professional Mode (strip personality)\n\
1225 Ctrl+O - Open document picker for next-turn context\n\
1226 Ctrl+I - Open image picker for next-turn vision context\n\
1227 Ctrl+Y - Toggle Approvals Off (bypass safety approvals)\n\
1228 Ctrl+S - Quick Swarm (hardcoded bootstrap)\n\
1229 Ctrl+Z - Undo last edit\n\
1230 Ctrl+Q/C - Quit session\n\
1231 ESC - Silence current playback\n\
1232 \nStatus Legend:\n\
1233 LM - LM Studio runtime health (`LIVE`, `RECV`, `WARN`, `CEIL`, `STALE`, `BOOT`)\n\
1234 VN - Vein RAG status (`SEM`=semantic active, `FTS`=BM25 only, `--`=not indexed)\n\
1235 BUD - Total prompt-budget pressure against the live context window\n\
1236 CMP - History compaction pressure against Hematite's adaptive threshold\n\
1237 ERR - Session error count (runtime, tool, or SPECULAR failures)\n\
1238 CTX - Live context window currently reported by LM Studio\n\
1239 VOICE - Local speech output state\n\
1240 \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. If a PDF fails, export it to text/markdown or attach page images instead.\n\
1241 ",
1242 );
1243}
1244
1245#[allow(dead_code)]
1246fn show_help_message_legacy(app: &mut App) {
1247 app.push_message("System",
1248 "Hematite Commands:\n\
1249 /chat — (Mode) Conversation mode — clean chat, no tool noise\n\
1250 /agent — (Mode) Full coding harness — tools, file edits, builds\n\
1251 /reroll — (Soul) Hatch a new companion mid-session\n\
1252 /auto — (Flow) Let Hematite choose the narrowest effective workflow\n\
1253 /ask [prompt] — (Flow) Read-only analysis mode; optional inline prompt\n\
1254 /code [prompt] — (Flow) Explicit implementation mode; optional inline prompt\n\
1255 /architect [prompt] — (Flow) Plan-first mode; optional inline prompt\n\
1256 /read-only [prompt] — (Flow) Hard read-only mode; optional inline prompt\n\
1257 /new — (Reset) Fresh task context; clear chat, pins, and task files\n\
1258 /forget — (Wipe) Hard forget; purge saved memory and Vein index too\n\
1259 /vein-inspect — (Vein) Inspect indexed memory, hot files, and active room bias\n\
1260 /workspace-profile — (Profile) Show the auto-generated workspace profile\n\
1261 /version — (Build) Show the running Hematite version\n\
1262 /vein-reset — (Vein) Wipe the RAG index; rebuilds automatically on next turn\n\
1263 /clear — (UI) Clear dialogue display only\n\
1264 /gemma-native [auto|on|off|status] — (Model) Auto/force/disable Gemma 4 native formatting\n\
1265 /runtime-refresh — (Model) Re-read LM Studio model + CTX now\n\
1266 /undo — (Ghost) Revert last file change\n\
1267 /diff — (Git) Show session changes (--stat)\n\
1268 /lsp — (Logic) Start Language Servers (semantic intelligence)\n\
1269 /swarm <text> — (Swarm) Spawn parallel workers on a directive\n\
1270 /worktree <cmd> — (Isolated) Manage git worktrees (list|add|remove|prune)\n\
1271 /think — (Brain) Enable deep reasoning mode\n\
1272 /no_think — (Speed) Disable reasoning (3-5x faster responses)\n\
1273 /voice — (TTS) List all available voices\n\
1274 /voice N — (TTS) Select voice by number\n\
1275 /read <text> — (TTS) Speak text aloud directly, bypassing the model. ESC to stop.\n\
1276 /attach <path> — (Docs) Attach a PDF/markdown/txt file for next message\n\
1277 /attach-pick — (Docs) Open a file picker and attach a document\n\
1278 /image <path> — (Vision) Attach an image for the next message\n\
1279 /image-pick — (Vision) Open a file picker and attach an image\n\
1280 /detach — (Context) Drop pending document/image attachments\n\
1281 /copy — (Debug) Copy session transcript to clipboard\n\
1282 /copy2 — (Debug) Copy SPECULAR log to clipboard (reasoning + events)\n\
1283 \nHotkeys:\n\
1284 Ctrl+B — Toggle Brief Mode (minimal output)\n\
1285 Ctrl+P — Toggle Professional Mode (strip personality)\n\
1286 Ctrl+O — Open document picker for next-turn context\n\
1287 Ctrl+I — Open image picker for next-turn vision context\n\
1288 Ctrl+Y — Toggle Approvals Off (bypass safety approvals)\n\
1289 Ctrl+S — Quick Swarm (hardcoded bootstrap)\n\
1290 Ctrl+Z — Undo last edit\n\
1291 Ctrl+Q/C — Quit session\n\
1292 ESC — Silence current playback\n\
1293 \nStatus Legend:\n\
1294 LM — LM Studio runtime health (`LIVE`, `RECV`, `WARN`, `CEIL`, `STALE`, `BOOT`)\n\
1295 VN — Vein RAG status (`SEM`=semantic active, `FTS`=BM25 only, `--`=not indexed)\n\
1296 BUD — Total prompt-budget pressure against the live context window\n\
1297 CMP — History compaction pressure against Hematite's adaptive threshold\n\
1298 ERR — Session error count (runtime, tool, or SPECULAR failures)\n\
1299 CTX — Live context window currently reported by LM Studio\n\
1300 VOICE — Local speech output state\n\
1301 \nAssistant: Semantic Pathing (LSP), Vision Pass, Web Research, Swarm Synthesis"
1302 );
1303 app.push_message(
1304 "System",
1305 "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. If a PDF fails, export it to text/markdown or attach page images instead.",
1306 );
1307}
1308
1309fn trigger_input_action(app: &mut App, action: InputAction) {
1310 match action {
1311 InputAction::Stop => request_stop(app),
1312 InputAction::PickDocument => match pick_attachment_path(AttachmentPickerKind::Document) {
1313 Ok(Some(path)) => attach_document_from_path(app, &path),
1314 Ok(None) => app.push_message("System", "Document picker cancelled."),
1315 Err(e) => app.push_message("System", &e),
1316 },
1317 InputAction::PickImage => match pick_attachment_path(AttachmentPickerKind::Image) {
1318 Ok(Some(path)) => attach_image_from_path(app, &path),
1319 Ok(None) => app.push_message("System", "Image picker cancelled."),
1320 Err(e) => app.push_message("System", &e),
1321 },
1322 InputAction::Detach => {
1323 app.clear_pending_attachments();
1324 app.push_message(
1325 "System",
1326 "Cleared pending document/image attachments for the next turn.",
1327 );
1328 }
1329 InputAction::New => {
1330 if !app.agent_running {
1331 reset_visible_session_state(app);
1332 app.push_message("You", "/new");
1333 app.agent_running = true;
1334 let _ = app.user_input_tx.try_send(UserTurn::text("/new"));
1335 }
1336 }
1337 InputAction::Forget => {
1338 if !app.agent_running {
1339 app.cancel_token
1340 .store(true, std::sync::atomic::Ordering::SeqCst);
1341 reset_visible_session_state(app);
1342 app.push_message("You", "/forget");
1343 app.agent_running = true;
1344 app.cancel_token
1345 .store(false, std::sync::atomic::Ordering::SeqCst);
1346 let _ = app.user_input_tx.try_send(UserTurn::text("/forget"));
1347 }
1348 }
1349 InputAction::Help => show_help_message(app),
1350 }
1351}
1352
1353fn pick_attachment_path(kind: AttachmentPickerKind) -> Result<Option<String>, String> {
1354 #[cfg(target_os = "windows")]
1355 {
1356 let (title, filter) = match kind {
1357 AttachmentPickerKind::Document => (
1358 "Attach document for the next Hematite turn",
1359 "Documents|*.pdf;*.md;*.markdown;*.txt;*.rst|All Files|*.*",
1360 ),
1361 AttachmentPickerKind::Image => (
1362 "Attach image for the next Hematite turn",
1363 "Images|*.png;*.jpg;*.jpeg;*.gif;*.webp|All Files|*.*",
1364 ),
1365 };
1366 let script = format!(
1367 "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 }}"
1368 );
1369 let output = std::process::Command::new("powershell")
1370 .args(["-NoProfile", "-STA", "-Command", &script])
1371 .output()
1372 .map_err(|e| format!("File picker failed: {}", e))?;
1373 if !output.status.success() {
1374 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1375 return Err(if stderr.is_empty() {
1376 "File picker did not complete successfully.".to_string()
1377 } else {
1378 format!("File picker failed: {}", stderr)
1379 });
1380 }
1381 let selected = String::from_utf8_lossy(&output.stdout).trim().to_string();
1382 if selected.is_empty() {
1383 Ok(None)
1384 } else {
1385 Ok(Some(selected))
1386 }
1387 }
1388 #[cfg(target_os = "macos")]
1389 {
1390 let prompt = match kind {
1391 AttachmentPickerKind::Document => "Choose a document for the next Hematite turn",
1392 AttachmentPickerKind::Image => "Choose an image for the next Hematite turn",
1393 };
1394 let script = format!("POSIX path of (choose file with prompt \"{}\")", prompt);
1395 let output = std::process::Command::new("osascript")
1396 .args(["-e", &script])
1397 .output()
1398 .map_err(|e| format!("File picker failed: {}", e))?;
1399 if output.status.success() {
1400 let selected = String::from_utf8_lossy(&output.stdout).trim().to_string();
1401 if selected.is_empty() {
1402 Ok(None)
1403 } else {
1404 Ok(Some(selected))
1405 }
1406 } else {
1407 Ok(None)
1408 }
1409 }
1410 #[cfg(all(unix, not(target_os = "macos")))]
1411 {
1412 let title = match kind {
1413 AttachmentPickerKind::Document => "Attach document for the next Hematite turn",
1414 AttachmentPickerKind::Image => "Attach image for the next Hematite turn",
1415 };
1416 let output = std::process::Command::new("zenity")
1417 .args(["--file-selection", "--title", title])
1418 .output()
1419 .map_err(|e| format!("File picker failed: {}", e))?;
1420 if output.status.success() {
1421 let selected = String::from_utf8_lossy(&output.stdout).trim().to_string();
1422 if selected.is_empty() {
1423 Ok(None)
1424 } else {
1425 Ok(Some(selected))
1426 }
1427 } else {
1428 Ok(None)
1429 }
1430 }
1431}
1432
1433pub async fn run_app<B: Backend>(
1434 terminal: &mut Terminal<B>,
1435 mut specular_rx: Receiver<SpecularEvent>,
1436 mut agent_rx: Receiver<crate::agent::inference::InferenceEvent>,
1437 user_input_tx: tokio::sync::mpsc::Sender<UserTurn>,
1438 mut swarm_rx: Receiver<SwarmMessage>,
1439 swarm_tx: tokio::sync::mpsc::Sender<SwarmMessage>,
1440 swarm_coordinator: Arc<crate::agent::swarm::SwarmCoordinator>,
1441 last_interaction: Arc<Mutex<Instant>>,
1442 cockpit: crate::CliCockpit,
1443 soul: crate::ui::hatch::RustySoul,
1444 professional: bool,
1445 gpu_state: Arc<GpuState>,
1446 git_state: Arc<crate::agent::git_monitor::GitState>,
1447 cancel_token: Arc<std::sync::atomic::AtomicBool>,
1448 voice_manager: Arc<crate::ui::voice::VoiceManager>,
1449) -> Result<(), Box<dyn std::error::Error>> {
1450 let mut app = App {
1451 messages: Vec::new(),
1452 messages_raw: Vec::new(),
1453 specular_logs: Vec::new(),
1454 brief_mode: cockpit.brief,
1455 tick_count: 0,
1456 stats: RustyStats {
1457 debugging: 0,
1458 wisdom: soul.wisdom,
1459 patience: 100.0,
1460 chaos: soul.chaos,
1461 snark: soul.snark,
1462 },
1463 yolo_mode: cockpit.yolo,
1464 awaiting_approval: None,
1465 active_workers: HashMap::new(),
1466 worker_labels: HashMap::new(),
1467 active_review: None,
1468 input: String::new(),
1469 input_history: Vec::new(),
1470 history_idx: None,
1471 thinking: false,
1472 agent_running: false,
1473 current_thought: String::new(),
1474 professional,
1475 last_reasoning: String::new(),
1476 active_context: default_active_context(),
1477 manual_scroll_offset: None,
1478 user_input_tx,
1479 specular_scroll: 0,
1480 specular_auto_scroll: true,
1481 gpu_state,
1482 git_state,
1483 last_input_time: Instant::now(),
1484 cancel_token,
1485 total_tokens: 0,
1486 current_session_cost: 0.0,
1487 model_id: "detecting...".to_string(),
1488 context_length: 0,
1489 prompt_pressure_percent: 0,
1490 prompt_estimated_input_tokens: 0,
1491 prompt_reserved_output_tokens: 0,
1492 prompt_estimated_total_tokens: 0,
1493 compaction_percent: 0,
1494 compaction_estimated_tokens: 0,
1495 compaction_threshold_tokens: 0,
1496 compaction_warned_level: 0,
1497 last_runtime_profile_time: Instant::now(),
1498 vein_file_count: 0,
1499 vein_embedded_count: 0,
1500 vein_docs_only: false,
1501 provider_state: ProviderRuntimeState::Booting,
1502 last_provider_summary: String::new(),
1503 mcp_state: McpRuntimeState::Unconfigured,
1504 last_mcp_summary: String::new(),
1505 last_operator_checkpoint_state: OperatorCheckpointState::Idle,
1506 last_operator_checkpoint_summary: String::new(),
1507 last_recovery_recipe_summary: String::new(),
1508 think_mode: None,
1509 workflow_mode: "AUTO".into(),
1510 autocomplete_suggestions: Vec::new(),
1511 selected_suggestion: 0,
1512 show_autocomplete: false,
1513 autocomplete_filter: String::new(),
1514 current_objective: "Awaiting objective...".into(),
1515 voice_manager,
1516 voice_loading: false,
1517 voice_loading_progress: 0.0,
1518 hardware_guard_enabled: true,
1519 session_start: std::time::SystemTime::now(),
1520 soul_name: soul.species.clone(),
1521 attached_context: None,
1522 attached_image: None,
1523 hovered_input_action: None,
1524 };
1525
1526 app.push_message("Hematite", "Initialising Engine & Hardware...");
1528
1529 if !cockpit.no_splash {
1532 draw_splash(terminal)?;
1533 loop {
1534 if let Ok(Event::Key(key)) = event::read() {
1535 if key.kind == event::KeyEventKind::Press
1536 && matches!(key.code, KeyCode::Enter | KeyCode::Char(' '))
1537 {
1538 break;
1539 }
1540 }
1541 }
1542 }
1543
1544 let mut event_stream = EventStream::new();
1545 let mut ticker = tokio::time::interval(std::time::Duration::from_millis(100));
1546
1547 loop {
1548 let vram_ratio = app.gpu_state.ratio();
1550 if app.hardware_guard_enabled && vram_ratio > 0.95 && !app.brief_mode {
1551 app.brief_mode = true;
1552 app.push_message(
1553 "System",
1554 "🚨 HARDWARE GUARD: VRAM > 95%. Brief Mode auto-enabled to prevent crash.",
1555 );
1556 }
1557
1558 terminal.draw(|f| ui(f, &app))?;
1559
1560 tokio::select! {
1561 _ = ticker.tick() => {
1562 if app.voice_loading && app.voice_loading_progress < 0.98 {
1564 app.voice_loading_progress += 0.002;
1565 }
1566
1567 let workers = app.active_workers.len() as u64;
1568 let advance = if workers > 0 { workers * 4 + 1 } else { 1 };
1569 app.tick_count = app.tick_count.wrapping_add(advance);
1573 app.update_objective();
1574 }
1575
1576 maybe_event = event_stream.next() => {
1578 match maybe_event {
1579 Some(Ok(Event::Mouse(mouse))) => {
1580 use crossterm::event::{MouseButton, MouseEventKind};
1581 let (width, height) = match terminal.size() {
1582 Ok(s) => (s.width, s.height),
1583 Err(_) => (80, 24),
1584 };
1585 let is_right_side = mouse.column as f64 > width as f64 * 0.65;
1586 let input_rect = input_rect_for_size(
1587 Rect { x: 0, y: 0, width, height },
1588 app.input.len(),
1589 );
1590 let title_area = input_title_area(input_rect);
1591
1592 match mouse.kind {
1593 MouseEventKind::Moved => {
1594 let hovered = if mouse.row == title_area.y
1595 && mouse.column >= title_area.x
1596 && mouse.column < title_area.x + title_area.width
1597 {
1598 input_action_hitboxes(&app, title_area)
1599 .into_iter()
1600 .find_map(|(action, start, end)| {
1601 (mouse.column >= start && mouse.column <= end)
1602 .then_some(action)
1603 })
1604 } else {
1605 None
1606 };
1607 app.hovered_input_action = hovered;
1608 }
1609 MouseEventKind::Down(MouseButton::Left) => {
1610 if mouse.row == title_area.y
1611 && mouse.column >= title_area.x
1612 && mouse.column < title_area.x + title_area.width
1613 {
1614 for (action, start, end) in input_action_hitboxes(&app, title_area) {
1615 if mouse.column >= start && mouse.column <= end {
1616 app.hovered_input_action = Some(action);
1617 trigger_input_action(&mut app, action);
1618 break;
1619 }
1620 }
1621 } else {
1622 app.hovered_input_action = None;
1623 }
1624 }
1625 MouseEventKind::ScrollUp => {
1626 if is_right_side {
1627 app.specular_auto_scroll = false;
1629 app.specular_scroll = app.specular_scroll.saturating_sub(3);
1630 } else {
1631 let cur = app.manual_scroll_offset.unwrap_or(0);
1632 app.manual_scroll_offset = Some(cur.saturating_add(3));
1633 }
1634 }
1635 MouseEventKind::ScrollDown => {
1636 if is_right_side {
1637 app.specular_auto_scroll = false;
1638 app.specular_scroll = app.specular_scroll.saturating_add(3);
1639 } else if let Some(cur) = app.manual_scroll_offset {
1640 app.manual_scroll_offset = if cur <= 3 { None } else { Some(cur - 3) };
1641 }
1642 }
1643 _ => {}
1644 }
1645 }
1646 Some(Ok(Event::Key(key))) => {
1647 if key.kind != event::KeyEventKind::Press { continue; }
1648
1649 { *last_interaction.lock().unwrap() = Instant::now(); }
1651
1652 if let Some(review) = app.active_review.take() {
1654 match key.code {
1655 KeyCode::Char('y') | KeyCode::Char('Y') => {
1656 let _ = review.tx.send(ReviewResponse::Accept);
1657 app.push_message("System", &format!("Worker {} diff accepted.", review.worker_id));
1658 }
1659 KeyCode::Char('n') | KeyCode::Char('N') => {
1660 let _ = review.tx.send(ReviewResponse::Reject);
1661 app.push_message("System", "Diff rejected.");
1662 }
1663 KeyCode::Char('r') | KeyCode::Char('R') => {
1664 let _ = review.tx.send(ReviewResponse::Retry);
1665 app.push_message("System", "Retrying synthesis…");
1666 }
1667 _ => { app.active_review = Some(review); }
1668 }
1669 continue;
1670 }
1671
1672 if let Some(mut approval) = app.awaiting_approval.take() {
1674 let scroll_handled = if approval.diff.is_some() {
1676 let diff_lines = approval.diff.as_ref().map(|d| d.lines().count()).unwrap_or(0) as u16;
1677 match key.code {
1678 KeyCode::Down | KeyCode::Char('j') => {
1679 approval.diff_scroll = approval.diff_scroll.saturating_add(1).min(diff_lines.saturating_sub(1));
1680 true
1681 }
1682 KeyCode::Up | KeyCode::Char('k') => {
1683 approval.diff_scroll = approval.diff_scroll.saturating_sub(1);
1684 true
1685 }
1686 KeyCode::PageDown => {
1687 approval.diff_scroll = approval.diff_scroll.saturating_add(10).min(diff_lines.saturating_sub(1));
1688 true
1689 }
1690 KeyCode::PageUp => {
1691 approval.diff_scroll = approval.diff_scroll.saturating_sub(10);
1692 true
1693 }
1694 _ => false,
1695 }
1696 } else {
1697 false
1698 };
1699 if scroll_handled {
1700 app.awaiting_approval = Some(approval);
1701 continue;
1702 }
1703 match key.code {
1704 KeyCode::Char('y') | KeyCode::Char('Y') => {
1705 if let Some(ref diff) = approval.diff {
1706 let added = diff.lines().filter(|l| l.starts_with("+ ")).count();
1707 let removed = diff.lines().filter(|l| l.starts_with("- ")).count();
1708 app.push_message("System", &format!(
1709 "Applied: {} +{} -{}", approval.display, added, removed
1710 ));
1711 } else {
1712 app.push_message("System", &format!("Approved: {}", approval.display));
1713 }
1714 let _ = approval.responder.send(true);
1715 }
1716 KeyCode::Char('n') | KeyCode::Char('N') => {
1717 if approval.diff.is_some() {
1718 app.push_message("System", "Edit skipped.");
1719 } else {
1720 app.push_message("System", "Declined.");
1721 }
1722 let _ = approval.responder.send(false);
1723 }
1724 _ => { app.awaiting_approval = Some(approval); }
1725 }
1726 continue;
1727 }
1728
1729 match key.code {
1731 KeyCode::Char('q') | KeyCode::Char('c')
1732 if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
1733 app.write_session_report();
1734 app.copy_transcript_to_clipboard();
1735 break;
1736 }
1737
1738 KeyCode::Esc => {
1739 request_stop(&mut app);
1740 }
1741
1742 KeyCode::Char('b') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
1743 app.brief_mode = !app.brief_mode;
1744 app.hardware_guard_enabled = false;
1746 app.push_message("System", &format!("Hardware Guard {}: {}", if app.brief_mode { "ENFORCED" } else { "SILENCED" }, if app.brief_mode { "ON" } else { "OFF" }));
1747 }
1748 KeyCode::Char('p') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
1749 app.professional = !app.professional;
1750 app.push_message("System", &format!("Professional Harness: {}", if app.professional { "ACTIVE" } else { "DISABLED" }));
1751 }
1752 KeyCode::Char('y') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
1753 app.yolo_mode = !app.yolo_mode;
1754 app.push_message("System", &format!("Approvals Off: {}", if app.yolo_mode { "ON — all tools auto-approved" } else { "OFF" }));
1755 }
1756 KeyCode::Char('t') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
1757 if !app.voice_manager.is_available() {
1758 app.push_message("System", "Voice is not available in this build. Use a packaged release for baked-in voice.");
1759 } else {
1760 let enabled = app.voice_manager.toggle();
1761 app.push_message("System", &format!("Voice of Hematite: {}", if enabled { "VIBRANT" } else { "SILENCED" }));
1762 }
1763 }
1764 KeyCode::Char('o') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
1765 match pick_attachment_path(AttachmentPickerKind::Document) {
1766 Ok(Some(path)) => attach_document_from_path(&mut app, &path),
1767 Ok(None) => app.push_message("System", "Document picker cancelled."),
1768 Err(e) => app.push_message("System", &e),
1769 }
1770 }
1771 KeyCode::Char('i') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
1772 match pick_attachment_path(AttachmentPickerKind::Image) {
1773 Ok(Some(path)) => attach_image_from_path(&mut app, &path),
1774 Ok(None) => app.push_message("System", "Image picker cancelled."),
1775 Err(e) => app.push_message("System", &e),
1776 }
1777 }
1778 KeyCode::Char('s') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
1779 app.push_message("Hematite", "Swarm engaged.");
1780 let swarm_tx_c = swarm_tx.clone();
1781 let coord_c = swarm_coordinator.clone();
1782 let max_workers = if app.gpu_state.ratio() > 0.70 { 1 } else { 3 };
1784 if max_workers < 3 {
1785 app.push_message("System", "Hardware Guard: Limiting swarm to 1 worker due to GPU load.");
1786 }
1787
1788 app.agent_running = true;
1789 tokio::spawn(async move {
1790 let payload = r#"<worker_task id="1" target="src/ui/tui.rs">Implement Swarm Layout</worker_task>
1791<worker_task id="2" target="src/agent/swarm.rs">Build Scratchpad constraints</worker_task>
1792<worker_task id="3" target="docs">Update Readme</worker_task>"#;
1793 let tasks = crate::agent::parser::parse_master_spec(payload);
1794 let _ = coord_c.dispatch_swarm(tasks, swarm_tx_c, max_workers).await;
1795 });
1796 }
1797 KeyCode::Char('z') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
1798 match crate::tools::file_ops::pop_ghost_ledger() {
1799 Ok(msg) => {
1800 app.specular_logs.push(format!("GHOST: {}", msg));
1801 trim_vec(&mut app.specular_logs, 7);
1802 app.push_message("System", &msg);
1803 }
1804 Err(e) => {
1805 app.push_message("System", &format!("Undo failed: {}", e));
1806 }
1807 }
1808 }
1809 KeyCode::Up => {
1810 if app.show_autocomplete && !app.autocomplete_suggestions.is_empty() {
1811 app.selected_suggestion = app.selected_suggestion.saturating_sub(1);
1812 } else if app.manual_scroll_offset.is_some() {
1813 let cur = app.manual_scroll_offset.unwrap();
1815 app.manual_scroll_offset = Some(cur.saturating_add(3));
1816 } else if !app.input_history.is_empty() {
1817 let new_idx = match app.history_idx {
1819 None => app.input_history.len() - 1,
1820 Some(i) => i.saturating_sub(1),
1821 };
1822 app.history_idx = Some(new_idx);
1823 app.input = app.input_history[new_idx].clone();
1824 }
1825 }
1826 KeyCode::Down => {
1827 if app.show_autocomplete && !app.autocomplete_suggestions.is_empty() {
1828 app.selected_suggestion = (app.selected_suggestion + 1).min(app.autocomplete_suggestions.len().saturating_sub(1));
1829 } else if let Some(off) = app.manual_scroll_offset {
1830 if off <= 3 { app.manual_scroll_offset = None; }
1831 else { app.manual_scroll_offset = Some(off.saturating_sub(3)); }
1832 } else if let Some(i) = app.history_idx {
1833 if i + 1 < app.input_history.len() {
1834 app.history_idx = Some(i + 1);
1835 app.input = app.input_history[i + 1].clone();
1836 } else {
1837 app.history_idx = None;
1838 app.input.clear();
1839 }
1840 }
1841 }
1842 KeyCode::PageUp => {
1843 let cur = app.manual_scroll_offset.unwrap_or(0);
1844 app.manual_scroll_offset = Some(cur.saturating_add(10));
1845 }
1846 KeyCode::PageDown => {
1847 if let Some(off) = app.manual_scroll_offset {
1848 if off <= 10 { app.manual_scroll_offset = None; }
1849 else { app.manual_scroll_offset = Some(off.saturating_sub(10)); }
1850 }
1851 }
1852 KeyCode::Tab => {
1853 if app.show_autocomplete && !app.autocomplete_suggestions.is_empty() {
1854 let selected = &app.autocomplete_suggestions[app.selected_suggestion];
1855 if let Some(pos) = app.input.rfind('@') {
1856 app.input.truncate(pos + 1);
1857 app.input.push_str(selected);
1858 app.show_autocomplete = false;
1859 }
1860 }
1861 }
1862 KeyCode::Char(c) => {
1863 app.history_idx = None; app.input.push(c);
1865 app.last_input_time = Instant::now();
1866
1867 if c == '@' {
1868 app.show_autocomplete = true;
1869 app.autocomplete_filter.clear();
1870 app.selected_suggestion = 0;
1871 app.update_autocomplete();
1872 } else if app.show_autocomplete {
1873 app.autocomplete_filter.push(c);
1874 app.update_autocomplete();
1875 }
1876 }
1877 KeyCode::Backspace => {
1878 app.input.pop();
1879 if app.show_autocomplete {
1880 if app.input.ends_with('@') || !app.input.contains('@') {
1881 app.show_autocomplete = false;
1882 app.autocomplete_filter.clear();
1883 } else {
1884 app.autocomplete_filter.pop();
1885 app.update_autocomplete();
1886 }
1887 }
1888 }
1889 KeyCode::Enter => {
1890 if app.show_autocomplete && !app.autocomplete_suggestions.is_empty() {
1891 let selected = &app.autocomplete_suggestions[app.selected_suggestion];
1892 if let Some(pos) = app.input.rfind('@') {
1893 app.input.truncate(pos + 1);
1894 app.input.push_str(selected);
1895 app.show_autocomplete = false;
1896 continue;
1897 }
1898 }
1899
1900 if !app.input.is_empty() && !app.agent_running {
1901 if Instant::now().duration_since(app.last_input_time) < std::time::Duration::from_millis(50) {
1904 app.input.push(' ');
1905 app.last_input_time = Instant::now();
1906 continue;
1907 }
1908
1909 let input_text = app.input.drain(..).collect::<String>();
1910
1911 if input_text.starts_with('/') {
1913 let parts: Vec<&str> = input_text.trim().split_whitespace().collect();
1914 let cmd = parts[0].to_lowercase();
1915 match cmd.as_str() {
1916 "/undo" => {
1917 match crate::tools::file_ops::pop_ghost_ledger() {
1918 Ok(msg) => {
1919 app.specular_logs.push(format!("GHOST: {}", msg));
1920 trim_vec(&mut app.specular_logs, 7);
1921 app.push_message("System", &msg);
1922 }
1923 Err(e) => {
1924 app.push_message("System", &format!("Undo failed: {}", e));
1925 }
1926 }
1927 app.history_idx = None;
1928 continue;
1929 }
1930 "/clear" => {
1931 reset_visible_session_state(&mut app);
1932 app.push_message("System", "Dialogue buffer cleared.");
1933 app.history_idx = None;
1934 continue;
1935 }
1936 "/diff" => {
1937 app.push_message("System", "Fetching session diff...");
1938 let ws = crate::tools::file_ops::workspace_root();
1939 if crate::agent::git::is_git_repo(&ws) {
1940 let output = std::process::Command::new("git")
1941 .args(["diff", "--stat"])
1942 .current_dir(ws)
1943 .output();
1944 if let Ok(out) = output {
1945 let stat = String::from_utf8_lossy(&out.stdout).to_string();
1946 app.push_message("System", if stat.is_empty() { "No changes detected." } else { &stat });
1947 }
1948 } else {
1949 app.push_message("System", "Not a git repository. Diff limited.");
1950 }
1951 app.history_idx = None;
1952 continue;
1953 }
1954 "/vein-reset" => {
1955 app.vein_file_count = 0;
1956 app.vein_embedded_count = 0;
1957 app.push_message("You", "/vein-reset");
1958 app.agent_running = true;
1959 let _ = app.user_input_tx.try_send(UserTurn::text("/vein-reset"));
1960 app.history_idx = None;
1961 continue;
1962 }
1963 "/vein-inspect" => {
1964 app.push_message("You", "/vein-inspect");
1965 app.agent_running = true;
1966 let _ = app.user_input_tx.try_send(UserTurn::text("/vein-inspect"));
1967 app.history_idx = None;
1968 continue;
1969 }
1970 "/workspace-profile" => {
1971 app.push_message("You", "/workspace-profile");
1972 app.agent_running = true;
1973 let _ = app.user_input_tx.try_send(UserTurn::text("/workspace-profile"));
1974 app.history_idx = None;
1975 continue;
1976 }
1977 "/copy" => {
1978 app.copy_transcript_to_clipboard();
1979 app.push_message("System", "Exact session transcript copied to clipboard (includes help/system output).");
1980 app.history_idx = None;
1981 continue;
1982 }
1983 "/copy-last" => {
1984 if app.copy_last_reply_to_clipboard() {
1985 app.push_message("System", "Latest Hematite reply copied to clipboard.");
1986 } else {
1987 app.push_message("System", "No Hematite reply is available to copy yet.");
1988 }
1989 app.history_idx = None;
1990 continue;
1991 }
1992 "/copy-clean" => {
1993 app.copy_clean_transcript_to_clipboard();
1994 app.push_message("System", "Clean chat transcript copied to clipboard (skips help/debug boilerplate).");
1995 app.history_idx = None;
1996 continue;
1997 }
1998 "/copy2" => {
1999 app.copy_specular_to_clipboard();
2000 app.push_message("System", "SPECULAR log copied to clipboard (reasoning + events).");
2001 app.history_idx = None;
2002 continue;
2003 }
2004 "/voice" => {
2005 use crate::ui::voice::VOICE_LIST;
2006 if let Some(arg) = parts.get(1) {
2007 if let Ok(n) = arg.parse::<usize>() {
2009 let idx = n.saturating_sub(1);
2010 if let Some(&(id, label)) = VOICE_LIST.get(idx) {
2011 app.voice_manager.set_voice(id);
2012 let _ = crate::agent::config::set_voice(id);
2013 app.push_message("System", &format!("Voice set to {} — {}", id, label));
2014 } else {
2015 app.push_message("System", &format!("Invalid voice number. Use /voice to list voices (1–{}).", VOICE_LIST.len()));
2016 }
2017 } else {
2018 if let Some(&(id, label)) = VOICE_LIST.iter().find(|&&(id, _)| id == *arg) {
2020 app.voice_manager.set_voice(id);
2021 let _ = crate::agent::config::set_voice(id);
2022 app.push_message("System", &format!("Voice set to {} — {}", id, label));
2023 } else {
2024 app.push_message("System", &format!("Unknown voice '{}'. Use /voice to list voices.", arg));
2025 }
2026 }
2027 } else {
2028 let current = app.voice_manager.current_voice_id();
2030 let mut list = format!("Available voices (current: {}):\n", current);
2031 for (i, &(id, label)) in VOICE_LIST.iter().enumerate() {
2032 let marker = if id == current.as_str() { " ◀" } else { "" };
2033 list.push_str(&format!(" {:>2}. {}{}\n", i + 1, label, marker));
2034 }
2035 list.push_str("\nUse /voice N or /voice <id> to select.");
2036 app.push_message("System", &list);
2037 }
2038 app.history_idx = None;
2039 continue;
2040 }
2041 "/read" => {
2042 let text = parts[1..].join(" ");
2043 if text.is_empty() {
2044 app.push_message("System", "Usage: /read <text to speak>");
2045 } else if !app.voice_manager.is_available() {
2046 app.push_message("System", "Voice is not available in this build. Use a packaged release for baked-in voice.");
2047 } else if !app.voice_manager.is_enabled() {
2048 app.push_message("System", "Voice is off. Press Ctrl+T to enable, then /read again.");
2049 } else {
2050 app.push_message("System", &format!("Reading {} words aloud. ESC to stop.", text.split_whitespace().count()));
2051 app.voice_manager.speak(text.clone());
2052 }
2053 app.history_idx = None;
2054 continue;
2055 }
2056 "/new" => {
2057 reset_visible_session_state(&mut app);
2058 app.push_message("You", "/new");
2059 app.agent_running = true;
2060 app.clear_pending_attachments();
2061 let _ = app.user_input_tx.try_send(UserTurn::text("/new"));
2062 app.history_idx = None;
2063 continue;
2064 }
2065 "/forget" => {
2066 app.cancel_token.store(true, std::sync::atomic::Ordering::SeqCst);
2068 reset_visible_session_state(&mut app);
2069 app.push_message("You", "/forget");
2070 app.agent_running = true;
2071 app.cancel_token.store(false, std::sync::atomic::Ordering::SeqCst);
2072 app.clear_pending_attachments();
2073 let _ = app.user_input_tx.try_send(UserTurn::text("/forget"));
2074 app.history_idx = None;
2075 continue;
2076 }
2077 "/gemma-native" => {
2078 let sub = parts.get(1).copied().unwrap_or("status").to_ascii_lowercase();
2079 let gemma_detected = crate::agent::inference::is_gemma4_model_name(&app.model_id);
2080 match sub.as_str() {
2081 "auto" => {
2082 match crate::agent::config::set_gemma_native_mode("auto") {
2083 Ok(_) => {
2084 if gemma_detected {
2085 app.push_message("System", "Gemma Native Formatting: AUTO. Gemma 4 will use native formatting automatically on the next turn.");
2086 } else {
2087 app.push_message("System", "Gemma Native Formatting: AUTO in settings. It will activate automatically when a Gemma 4 model is loaded.");
2088 }
2089 }
2090 Err(e) => app.push_message("System", &format!("Failed to update settings: {}", e)),
2091 }
2092 }
2093 "on" => {
2094 match crate::agent::config::set_gemma_native_mode("on") {
2095 Ok(_) => {
2096 if gemma_detected {
2097 app.push_message("System", "Gemma Native Formatting: ON (forced). It will apply on the next turn.");
2098 } else {
2099 app.push_message("System", "Gemma Native Formatting: ON (forced) in settings. It will activate only when a Gemma 4 model is loaded.");
2100 }
2101 }
2102 Err(e) => app.push_message("System", &format!("Failed to update settings: {}", e)),
2103 }
2104 }
2105 "off" => {
2106 match crate::agent::config::set_gemma_native_mode("off") {
2107 Ok(_) => app.push_message("System", "Gemma Native Formatting: OFF."),
2108 Err(e) => app.push_message("System", &format!("Failed to update settings: {}", e)),
2109 }
2110 }
2111 _ => {
2112 let config = crate::agent::config::load_config();
2113 let mode = crate::agent::config::gemma_native_mode_label(&config, &app.model_id);
2114 let enabled = match mode {
2115 "on" => "ON (forced)",
2116 "auto" => "ON (auto)",
2117 "off" => "OFF",
2118 _ => "INACTIVE",
2119 };
2120 let model_note = if gemma_detected {
2121 "Gemma 4 detected."
2122 } else {
2123 "Current model is not Gemma 4."
2124 };
2125 app.push_message(
2126 "System",
2127 &format!(
2128 "Gemma Native Formatting: {}. {} Usage: /gemma-native auto | on | off | status",
2129 enabled, model_note
2130 ),
2131 );
2132 }
2133 }
2134 app.history_idx = None;
2135 continue;
2136 }
2137 "/chat" => {
2138 app.workflow_mode = "CHAT".into();
2139 app.push_message("System", "Chat mode — natural conversation, no agent scaffolding. Use /agent to switch back.");
2140 app.history_idx = None;
2141 let _ = app.user_input_tx.try_send(UserTurn::text("/chat"));
2142 continue;
2143 }
2144 "/reroll" => {
2145 app.history_idx = None;
2146 let _ = app.user_input_tx.try_send(UserTurn::text("/reroll"));
2147 continue;
2148 }
2149 "/agent" => {
2150 app.workflow_mode = "AUTO".into();
2151 app.push_message("System", "Agent mode — full coding harness active. Use /chat for clean conversation.");
2152 app.history_idx = None;
2153 let _ = app.user_input_tx.try_send(UserTurn::text("/agent"));
2154 continue;
2155 }
2156 "/ask" | "/code" | "/architect" | "/read-only" | "/auto" => {
2157 let label = match cmd.as_str() {
2158 "/ask" => "ASK",
2159 "/code" => "CODE",
2160 "/architect" => "ARCHITECT",
2161 "/read-only" => "READ-ONLY",
2162 _ => "AUTO",
2163 };
2164 app.workflow_mode = label.to_string();
2165 let outbound = input_text.trim().to_string();
2166 app.push_message("You", &outbound);
2167 app.agent_running = true;
2168 let _ = app.user_input_tx.try_send(UserTurn::text(outbound));
2169 app.history_idx = None;
2170 continue;
2171 }
2172 "/worktree" => {
2173 let sub = parts.get(1).copied().unwrap_or("");
2174 match sub {
2175 "list" => {
2176 app.push_message("You", "/worktree list");
2177 app.agent_running = true;
2178 let _ = app.user_input_tx.try_send(UserTurn::text(
2179 "Call git_worktree with action=list"
2180 ));
2181 }
2182 "add" => {
2183 let wt_path = parts.get(2).copied().unwrap_or("");
2184 let wt_branch = parts.get(3).copied().unwrap_or("");
2185 if wt_path.is_empty() {
2186 app.push_message("System", "Usage: /worktree add <path> [branch]");
2187 } else {
2188 app.push_message("You", &format!("/worktree add {wt_path}"));
2189 app.agent_running = true;
2190 let directive = if wt_branch.is_empty() {
2191 format!("Call git_worktree with action=add path={wt_path}")
2192 } else {
2193 format!("Call git_worktree with action=add path={wt_path} branch={wt_branch}")
2194 };
2195 let _ = app.user_input_tx.try_send(UserTurn::text(directive));
2196 }
2197 }
2198 "remove" => {
2199 let wt_path = parts.get(2).copied().unwrap_or("");
2200 if wt_path.is_empty() {
2201 app.push_message("System", "Usage: /worktree remove <path>");
2202 } else {
2203 app.push_message("You", &format!("/worktree remove {wt_path}"));
2204 app.agent_running = true;
2205 let _ = app.user_input_tx.try_send(UserTurn::text(
2206 format!("Call git_worktree with action=remove path={wt_path}")
2207 ));
2208 }
2209 }
2210 "prune" => {
2211 app.push_message("You", "/worktree prune");
2212 app.agent_running = true;
2213 let _ = app.user_input_tx.try_send(UserTurn::text(
2214 "Call git_worktree with action=prune"
2215 ));
2216 }
2217 _ => {
2218 app.push_message("System",
2219 "Usage: /worktree list | add <path> [branch] | remove <path> | prune");
2220 }
2221 }
2222 app.history_idx = None;
2223 continue;
2224 }
2225 "/think" => {
2226 app.think_mode = Some(true);
2227 app.push_message("You", "/think");
2228 app.agent_running = true;
2229 let _ = app.user_input_tx.try_send(UserTurn::text("/think"));
2230 app.history_idx = None;
2231 continue;
2232 }
2233 "/no_think" => {
2234 app.think_mode = Some(false);
2235 app.push_message("You", "/no_think");
2236 app.agent_running = true;
2237 let _ = app.user_input_tx.try_send(UserTurn::text("/no_think"));
2238 app.history_idx = None;
2239 continue;
2240 }
2241 "/lsp" => {
2242 app.push_message("You", "/lsp");
2243 app.agent_running = true;
2244 let _ = app.user_input_tx.try_send(UserTurn::text("/lsp"));
2245 app.history_idx = None;
2246 continue;
2247 }
2248 "/runtime-refresh" => {
2249 app.push_message("You", "/runtime-refresh");
2250 app.agent_running = true;
2251 let _ = app.user_input_tx.try_send(UserTurn::text("/runtime-refresh"));
2252 app.history_idx = None;
2253 continue;
2254 }
2255 "/help" => {
2256 show_help_message(&mut app);
2257 app.history_idx = None;
2258 continue;
2259 }
2260 "/help-legacy-unused" => {
2261 app.push_message("System",
2262 "Hematite Commands:\n\
2263 /chat — (Mode) Conversation mode — clean chat, no tool noise\n\
2264 /agent — (Mode) Full coding harness — tools, file edits, builds\n\
2265 /reroll — (Soul) Hatch a new companion mid-session\n\
2266 /auto — (Flow) Let Hematite choose the narrowest effective workflow\n\
2267 /ask [prompt] — (Flow) Read-only analysis mode; optional inline prompt\n\
2268 /code [prompt] — (Flow) Explicit implementation mode; optional inline prompt\n\
2269 /architect [prompt] — (Flow) Plan-first mode; optional inline prompt\n\
2270 /read-only [prompt] — (Flow) Hard read-only mode; optional inline prompt\n\
2271 /new — (Reset) Fresh task context; clear chat, pins, and task files\n\
2272 /forget — (Wipe) Hard forget; purge saved memory and Vein index too\n\
2273 /vein-inspect — (Vein) Inspect indexed memory, hot files, and active room bias\n\
2274 /workspace-profile — (Profile) Show the auto-generated workspace profile\n\
2275 /version — (Build) Show the running Hematite version\n\
2276 /vein-reset — (Vein) Wipe the RAG index; rebuilds automatically on next turn\n\
2277 /clear — (UI) Clear dialogue display only\n\
2278 /gemma-native [auto|on|off|status] — (Model) Auto/force/disable Gemma 4 native formatting\n\
2279 /runtime-refresh — (Model) Re-read LM Studio model + CTX now\n\
2280 /undo — (Ghost) Revert last file change\n\
2281 /diff — (Git) Show session changes (--stat)\n\
2282 /lsp — (Logic) Start Language Servers (semantic intelligence)\n\
2283 /swarm <text> — (Swarm) Spawn parallel workers on a directive\n\
2284 /worktree <cmd> — (Isolated) Manage git worktrees (list|add|remove|prune)\n\
2285 /think — (Brain) Enable deep reasoning mode\n\
2286 /no_think — (Speed) Disable reasoning (3-5x faster responses)\n\
2287 /voice — (TTS) List all available voices\n\
2288 /voice N — (TTS) Select voice by number\n\
2289 /attach <path> — (Docs) Attach a PDF/markdown/txt file for next message\n\
2290 /attach-pick — (Docs) Open a file picker and attach a document\n\
2291 /image <path> — (Vision) Attach an image for the next message\n\
2292 /image-pick — (Vision) Open a file picker and attach an image\n\
2293 /detach — (Context) Drop pending document/image attachments\n\
2294 /copy — (Debug) Copy session transcript to clipboard\n\
2295 /copy2 — (Debug) Copy SPECULAR log to clipboard (reasoning + events)\n\
2296 \nHotkeys:\n\
2297 Ctrl+B — Toggle Brief Mode (minimal output)\n\
2298 Ctrl+P — Toggle Professional Mode (strip personality)\n\
2299 Ctrl+O — Open document picker for next-turn context\n\
2300 Ctrl+I — Open image picker for next-turn vision context\n\
2301 Ctrl+Y — Toggle Approvals Off (bypass safety approvals)\n\
2302 Ctrl+S — Quick Swarm (hardcoded bootstrap)\n\
2303 Ctrl+Z — Undo last edit\n\
2304 Ctrl+Q/C — Quit session\n\
2305 ESC — Silence current playback\n\
2306 \nStatus Legend:\n\
2307 LM — LM Studio runtime health (`LIVE`, `RECV`, `WARN`, `CEIL`, `STALE`, `BOOT`)\n\
2308 VN — Vein RAG status (`SEM`=semantic active, `FTS`=BM25 only, `--`=not indexed)\n\
2309 BUD — Total prompt-budget pressure against the live context window\n\
2310 CMP — History compaction pressure against Hematite's adaptive threshold\n\
2311 ERR — Session error count (runtime, tool, or SPECULAR failures)\n\
2312 CTX — Live context window currently reported by LM Studio\n\
2313 VOICE — Local speech output state\n\
2314 \nAssistant: Semantic Pathing (LSP), Vision Pass, Web Research, Swarm Synthesis"
2315 );
2316 app.history_idx = None;
2317 continue;
2318 }
2319 "/swarm" => {
2320 let directive = parts[1..].join(" ");
2321 if directive.is_empty() {
2322 app.push_message("System", "Usage: /swarm <directive>");
2323 } else {
2324 app.active_workers.clear(); app.push_message("Hematite", &format!("Swarm analyzing: '{}'", directive));
2326 let swarm_tx_c = swarm_tx.clone();
2327 let coord_c = swarm_coordinator.clone();
2328 let max_workers = if app.gpu_state.ratio() > 0.75 { 1 } else { 3 };
2329 app.agent_running = true;
2330 tokio::spawn(async move {
2331 let payload = format!(r#"<worker_task id="1" target="src">Research {}</worker_task>
2332<worker_task id="2" target="src">Implement {}</worker_task>
2333<worker_task id="3" target="docs">Document {}</worker_task>"#, directive, directive, directive);
2334 let tasks = crate::agent::parser::parse_master_spec(&payload);
2335 let _ = coord_c.dispatch_swarm(tasks, swarm_tx_c, max_workers).await;
2336 });
2337 }
2338 app.history_idx = None;
2339 continue;
2340 }
2341 "/version" => {
2342 app.push_message(
2343 "System",
2344 &crate::hematite_version_report(),
2345 );
2346 app.history_idx = None;
2347 continue;
2348 }
2349 "/detach" => {
2350 app.clear_pending_attachments();
2351 app.push_message("System", "Cleared pending document/image attachments for the next turn.");
2352 app.history_idx = None;
2353 continue;
2354 }
2355 "/attach" => {
2356 let file_path = parts[1..].join(" ").trim().to_string();
2357 if file_path.is_empty() {
2358 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.");
2359 app.history_idx = None;
2360 continue;
2361 }
2362 if file_path.is_empty() {
2363 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.");
2364 } else {
2365 let p = std::path::Path::new(&file_path);
2366 match crate::memory::vein::extract_document_text(p) {
2367 Ok(text) => {
2368 let name = p.file_name()
2369 .and_then(|n| n.to_str())
2370 .unwrap_or(&file_path)
2371 .to_string();
2372 let preview_len = text.len().min(200);
2373 app.push_message("System", &format!(
2374 "Attached: {} ({} chars) — will be injected as context on your next message.\nPreview: {}...",
2375 name, text.len(), &text[..preview_len]
2376 ));
2377 app.attached_context = Some((name, text));
2378 }
2379 Err(e) => {
2380 app.push_message("System", &format!("Attach failed: {}", e));
2381 }
2382 }
2383 }
2384 app.history_idx = None;
2385 continue;
2386 }
2387 "/attach-pick" => {
2388 match pick_attachment_path(AttachmentPickerKind::Document) {
2389 Ok(Some(path)) => attach_document_from_path(&mut app, &path),
2390 Ok(None) => app.push_message("System", "Document picker cancelled."),
2391 Err(e) => app.push_message("System", &e),
2392 }
2393 app.history_idx = None;
2394 continue;
2395 }
2396 "/image" => {
2397 let file_path = parts[1..].join(" ").trim().to_string();
2398 if file_path.is_empty() {
2399 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.");
2400 } else {
2401 attach_image_from_path(&mut app, &file_path);
2402 }
2403 app.history_idx = None;
2404 continue;
2405 }
2406 "/image-pick" => {
2407 match pick_attachment_path(AttachmentPickerKind::Image) {
2408 Ok(Some(path)) => attach_image_from_path(&mut app, &path),
2409 Ok(None) => app.push_message("System", "Image picker cancelled."),
2410 Err(e) => app.push_message("System", &e),
2411 }
2412 app.history_idx = None;
2413 continue;
2414 }
2415 _ => {
2416 app.push_message("System", &format!("Unknown command: {}", cmd));
2417 app.history_idx = None;
2418 continue;
2419 }
2420 }
2421 }
2422
2423 if app.input_history.last().map(|s| s.as_str()) != Some(&input_text) {
2425 app.input_history.push(input_text.clone());
2426 if app.input_history.len() > 50 {
2427 app.input_history.remove(0);
2428 }
2429 }
2430 app.history_idx = None;
2431 app.push_message("You", &input_text);
2432 app.agent_running = true;
2433 app.cancel_token.store(false, std::sync::atomic::Ordering::SeqCst);
2434 app.last_reasoning.clear();
2435 app.manual_scroll_offset = None;
2436 app.specular_auto_scroll = true;
2437 let tx = app.user_input_tx.clone();
2438 let outbound = UserTurn {
2439 text: input_text,
2440 attached_document: app.attached_context.take().map(|(name, content)| {
2441 AttachedDocument { name, content }
2442 }),
2443 attached_image: app.attached_image.take(),
2444 };
2445 tokio::spawn(async move {
2446 let _ = tx.send(outbound).await;
2447 });
2448 }
2449 }
2450 _ => {}
2451 }
2452 }
2453 Some(Ok(Event::Paste(content))) => {
2454 if !try_attach_from_paste(&mut app, &content) {
2455 let normalized = content.replace("\r\n", " ").replace('\n', " ");
2458 app.input.push_str(&normalized);
2459 app.last_input_time = Instant::now();
2460 }
2461 }
2462 _ => {}
2463 }
2464 }
2465
2466 Some(specular_evt) = specular_rx.recv() => {
2468 match specular_evt {
2469 SpecularEvent::SyntaxError { path, details } => {
2470 app.record_error();
2471 app.specular_logs.push(format!("ERROR: {:?}", path));
2472 trim_vec(&mut app.specular_logs, 20);
2473
2474 let user_idle = {
2476 let lock = last_interaction.lock().unwrap();
2477 lock.elapsed() > std::time::Duration::from_secs(3)
2478 };
2479 if user_idle && !app.agent_running {
2480 app.agent_running = true;
2481 let tx = app.user_input_tx.clone();
2482 let diag = details.clone();
2483 tokio::spawn(async move {
2484 let msg = format!(
2485 "<specular-build-fail>\n{}\n</specular-build-fail>\n\
2486 Fix the compiler error above.",
2487 diag
2488 );
2489 let _ = tx.send(UserTurn::text(msg)).await;
2490 });
2491 }
2492 }
2493 SpecularEvent::FileChanged(path) => {
2494 app.stats.wisdom += 1;
2495 app.stats.patience = (app.stats.patience - 0.5).max(0.0);
2496 if app.stats.patience < 50.0 && !app.brief_mode {
2497 app.brief_mode = true;
2498 app.push_message("System", "Context saturation high — Brief Mode auto-enabled.");
2499 }
2500 let path_str = path.to_string_lossy().to_string();
2501 app.specular_logs.push(format!("INDEX: {}", path_str));
2502 app.push_context_file(path_str, "Active".into());
2503 trim_vec(&mut app.specular_logs, 20);
2504 }
2505 }
2506 }
2507
2508 Some(event) = agent_rx.recv() => {
2510 use crate::agent::inference::InferenceEvent;
2511 match event {
2512 InferenceEvent::Thought(content) => {
2513 app.thinking = true;
2514 app.current_thought.push_str(&content);
2515 }
2516 InferenceEvent::VoiceStatus(msg) => {
2517 app.push_message("System", &msg);
2518 }
2519 InferenceEvent::Token(ref token) | InferenceEvent::MutedToken(ref token) => {
2520 let is_muted = matches!(event, InferenceEvent::MutedToken(_));
2521 app.thinking = false;
2522 if app.messages_raw.last().map(|(s, _)| s.as_str()) != Some("Hematite") {
2523 app.push_message("Hematite", "");
2524 }
2525 app.update_last_message(token);
2526 app.manual_scroll_offset = None;
2527
2528 if !is_muted && app.voice_manager.is_enabled() && !app.cancel_token.load(std::sync::atomic::Ordering::SeqCst) {
2530 app.voice_manager.speak(token.clone());
2531 }
2532 }
2533 InferenceEvent::ToolCallStart { name, args, .. } => {
2534 if app.workflow_mode != "CHAT" {
2536 let display = format!("( ) {} {}", name, args);
2537 app.push_message("Tool", &display);
2538 }
2539 app.active_context.push(ContextFile {
2541 path: name.clone(),
2542 size: 0,
2543 status: "Running".into()
2544 });
2545 trim_vec_context(&mut app.active_context, 8);
2546 app.manual_scroll_offset = None;
2547 }
2548 InferenceEvent::ToolCallResult { id: _, name, output, is_error } => {
2549 let icon = if is_error { "[x]" } else { "[v]" };
2550 if is_error {
2551 app.record_error();
2552 }
2553 let preview = first_n_chars(&output, 100);
2556 if app.workflow_mode != "CHAT" {
2557 app.push_message("Tool", &format!("{} {} → {}", icon, name, preview));
2558 } else if is_error {
2559 app.push_message("System", &format!("Tool error: {}", preview));
2560 }
2561
2562 app.active_context.retain(|f| f.path != name || f.status != "Running");
2567 app.manual_scroll_offset = None;
2568 }
2569 InferenceEvent::ApprovalRequired { id: _, name, display, diff, responder } => {
2570 let is_diff = diff.is_some();
2571 app.awaiting_approval = Some(PendingApproval {
2572 display: display.clone(),
2573 tool_name: name,
2574 diff,
2575 diff_scroll: 0,
2576 responder,
2577 });
2578 if is_diff {
2579 app.push_message("System", "[~] Diff preview — [Y] Apply [N] Skip");
2580 } else {
2581 app.push_message("System", "[!] Approval required (Press [Y] Approve or [N] Decline)");
2582 app.push_message("System", &format!("Command: {}", display));
2583 }
2584 }
2585 InferenceEvent::UsageUpdate(usage) => {
2586 app.total_tokens = usage.total_tokens;
2587 let turn_cost = crate::agent::pricing::calculate_cost(&usage, &app.model_id);
2589 app.current_session_cost += turn_cost;
2590 }
2591 InferenceEvent::Done => {
2592 app.thinking = false;
2593 app.agent_running = false;
2594 if app.voice_manager.is_enabled() {
2595 app.voice_manager.flush();
2596 }
2597 if !app.current_thought.is_empty() {
2598 app.last_reasoning = app.current_thought.clone();
2599 }
2600 app.current_thought.clear();
2601 app.specular_auto_scroll = true;
2602 app.active_workers.remove("AGENT");
2604 app.worker_labels.remove("AGENT");
2605 }
2606 InferenceEvent::Error(e) => {
2607 app.record_error();
2608 app.thinking = false;
2609 app.agent_running = false;
2610 if app.voice_manager.is_enabled() {
2611 app.voice_manager.flush();
2612 }
2613 app.push_message("System", &format!("Error: {e}"));
2614 }
2615 InferenceEvent::ProviderStatus { state, summary } => {
2616 app.provider_state = state;
2617 if !summary.trim().is_empty() && app.last_provider_summary != summary {
2618 app.specular_logs.push(format!("PROVIDER: {}", summary));
2619 trim_vec(&mut app.specular_logs, 20);
2620 app.last_provider_summary = summary;
2621 }
2622 }
2623 InferenceEvent::McpStatus { state, summary } => {
2624 app.mcp_state = state;
2625 if !summary.trim().is_empty() && app.last_mcp_summary != summary {
2626 app.specular_logs.push(format!("MCP: {}", summary));
2627 trim_vec(&mut app.specular_logs, 20);
2628 app.last_mcp_summary = summary;
2629 }
2630 }
2631 InferenceEvent::OperatorCheckpoint { state, summary } => {
2632 app.last_operator_checkpoint_state = state;
2633 if state == OperatorCheckpointState::Idle {
2634 app.last_operator_checkpoint_summary.clear();
2635 } else if !summary.trim().is_empty()
2636 && app.last_operator_checkpoint_summary != summary
2637 {
2638 app.specular_logs.push(format!(
2639 "STATE: {} - {}",
2640 state.label(),
2641 summary
2642 ));
2643 trim_vec(&mut app.specular_logs, 20);
2644 app.last_operator_checkpoint_summary = summary;
2645 }
2646 }
2647 InferenceEvent::RecoveryRecipe { summary } => {
2648 if !summary.trim().is_empty()
2649 && app.last_recovery_recipe_summary != summary
2650 {
2651 app.specular_logs.push(format!("RECOVERY: {}", summary));
2652 trim_vec(&mut app.specular_logs, 20);
2653 app.last_recovery_recipe_summary = summary;
2654 }
2655 }
2656 InferenceEvent::CompactionPressure {
2657 estimated_tokens,
2658 threshold_tokens,
2659 percent,
2660 } => {
2661 app.compaction_estimated_tokens = estimated_tokens;
2662 app.compaction_threshold_tokens = threshold_tokens;
2663 app.compaction_percent = percent;
2664 if percent < 60 {
2668 app.compaction_warned_level = 0;
2669 } else if percent >= 90 && app.compaction_warned_level < 90 {
2670 app.compaction_warned_level = 90;
2671 app.push_message(
2672 "System",
2673 "Context is 90% full. Use /new to reset history (project memory is preserved) or /forget to wipe everything.",
2674 );
2675 } else if percent >= 70 && app.compaction_warned_level < 70 {
2676 app.compaction_warned_level = 70;
2677 app.push_message(
2678 "System",
2679 &format!("Context at {}% — approaching the compaction threshold. Consider /new soon to keep responses sharp.", percent),
2680 );
2681 }
2682 }
2683 InferenceEvent::PromptPressure {
2684 estimated_input_tokens,
2685 reserved_output_tokens,
2686 estimated_total_tokens,
2687 context_length: _,
2688 percent,
2689 } => {
2690 app.prompt_estimated_input_tokens = estimated_input_tokens;
2691 app.prompt_reserved_output_tokens = reserved_output_tokens;
2692 app.prompt_estimated_total_tokens = estimated_total_tokens;
2693 app.prompt_pressure_percent = percent;
2694 }
2695 InferenceEvent::TaskProgress { id, label, progress } => {
2696 let nid = normalize_id(&id);
2697 app.active_workers.insert(nid.clone(), progress);
2698 app.worker_labels.insert(nid, label);
2699 }
2700 InferenceEvent::RuntimeProfile { model_id, context_length } => {
2701 let was_no_model = app.model_id == "no model loaded";
2702 let now_no_model = model_id == "no model loaded";
2703 let changed = app.model_id != "detecting..."
2704 && (app.model_id != model_id || app.context_length != context_length);
2705 app.model_id = model_id.clone();
2706 app.context_length = context_length;
2707 app.last_runtime_profile_time = Instant::now();
2708 if app.provider_state == ProviderRuntimeState::Booting {
2709 app.provider_state = ProviderRuntimeState::Live;
2710 }
2711 if now_no_model && !was_no_model {
2712 app.push_message(
2713 "System",
2714 "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.",
2715 );
2716 } else if changed && !now_no_model {
2717 app.push_message(
2718 "System",
2719 &format!(
2720 "Runtime profile refreshed: Model {} | CTX {}",
2721 model_id, context_length
2722 ),
2723 );
2724 }
2725 }
2726 InferenceEvent::EmbedProfile { model_id } => {
2727 match model_id {
2728 Some(id) => app.push_message(
2729 "System",
2730 &format!("Embed model loaded: {} (semantic search ready)", id),
2731 ),
2732 None => app.push_message(
2733 "System",
2734 "Embed model unloaded. Semantic search inactive.",
2735 ),
2736 }
2737 }
2738 InferenceEvent::VeinStatus { file_count, embedded_count, docs_only } => {
2739 app.vein_file_count = file_count;
2740 app.vein_embedded_count = embedded_count;
2741 app.vein_docs_only = docs_only;
2742 }
2743 InferenceEvent::VeinContext { paths } => {
2744 app.active_context.retain(|f| f.status == "Running");
2747 for path in paths {
2748 let root = crate::tools::file_ops::workspace_root();
2749 let size = std::fs::metadata(root.join(&path))
2750 .map(|m| m.len())
2751 .unwrap_or(0);
2752 if !app.active_context.iter().any(|f| f.path == path) {
2753 app.active_context.push(ContextFile {
2754 path,
2755 size,
2756 status: "Vein".to_string(),
2757 });
2758 }
2759 }
2760 trim_vec_context(&mut app.active_context, 8);
2761 }
2762 InferenceEvent::SoulReroll { species, rarity, shiny, .. } => {
2763 let shiny_tag = if shiny { " 🌟 SHINY" } else { "" };
2764 app.soul_name = species.clone();
2765 app.push_message(
2766 "System",
2767 &format!("[{}{}] {} has awakened.", rarity, shiny_tag, species),
2768 );
2769 }
2770 }
2771 }
2772
2773 Some(msg) = swarm_rx.recv() => {
2775 match msg {
2776 SwarmMessage::Progress(worker_id, progress) => {
2777 let nid = normalize_id(&worker_id);
2778 app.active_workers.insert(nid.clone(), progress);
2779 match progress {
2780 102 => app.push_message("System", &format!("Worker {} architecture verified and applied.", nid)),
2781 101 => { },
2782 100 => app.push_message("Hematite", &format!("Worker {} complete. Standing by for review...", nid)),
2783 _ => {}
2784 }
2785 }
2786 SwarmMessage::ReviewRequest { worker_id, file_path, before, after, tx } => {
2787 app.push_message("Hematite", &format!("Worker {} conflict — review required.", worker_id));
2788 app.active_review = Some(ActiveReview {
2789 worker_id,
2790 file_path: file_path.to_string_lossy().to_string(),
2791 before,
2792 after,
2793 tx,
2794 });
2795 }
2796 SwarmMessage::Done => {
2797 app.agent_running = false;
2798 app.push_message("System", "──────────────────────────────────────────────────────────");
2800 app.push_message("System", " TASK COMPLETE: Swarm Synthesis Finalized ");
2801 app.push_message("System", "──────────────────────────────────────────────────────────");
2802 }
2803 }
2804 }
2805 }
2806 }
2807 Ok(())
2808}
2809
2810fn ui(f: &mut ratatui::Frame, app: &App) {
2813 let size = f.size();
2814 if size.width < 60 || size.height < 10 {
2815 f.render_widget(Clear, size);
2817 return;
2818 }
2819
2820 let input_height = compute_input_height(f.size().width, app.input.len());
2821
2822 let chunks = Layout::default()
2823 .direction(Direction::Vertical)
2824 .constraints([
2825 Constraint::Min(0),
2826 Constraint::Length(input_height),
2827 Constraint::Length(3),
2828 ])
2829 .split(f.size());
2830
2831 let top = Layout::default()
2832 .direction(Direction::Horizontal)
2833 .constraints([Constraint::Fill(1), Constraint::Length(45)]) .split(chunks[0]);
2835
2836 let mut core_lines = app.messages.clone();
2838
2839 if app.agent_running {
2841 let dots = ".".repeat((app.tick_count % 4) as usize + 1);
2842 core_lines.push(Line::from(Span::styled(
2843 format!(" Hematite is thinking{}", dots),
2844 Style::default()
2845 .fg(Color::Magenta)
2846 .add_modifier(Modifier::DIM),
2847 )));
2848 }
2849
2850 let (heart_color, core_icon) = if app.agent_running || !app.active_workers.is_empty() {
2851 let (r_base, g_base, b_base) = if !app.active_workers.is_empty() {
2852 (0, 200, 200) } else {
2854 (200, 0, 200) };
2856
2857 let pulse = (app.tick_count % 50) as f64 / 50.0;
2858 let factor = (pulse * std::f64::consts::PI).sin().abs();
2859 let r = (r_base as f64 * factor) as u8;
2860 let g = (g_base as f64 * factor) as u8;
2861 let b = (b_base as f64 * factor) as u8;
2862
2863 (Color::Rgb(r.max(60), g.max(60), b.max(60)), "•")
2864 } else {
2865 (Color::Rgb(80, 80, 80), "•") };
2867
2868 let live_objective = if app.current_objective != "Idle" {
2869 app.current_objective.clone()
2870 } else if !app.active_workers.is_empty() {
2871 "Swarm active".to_string()
2872 } else if app.thinking {
2873 "Reasoning".to_string()
2874 } else if app.agent_running {
2875 "Working".to_string()
2876 } else {
2877 "Idle".to_string()
2878 };
2879
2880 let objective_text = if live_objective.len() > 30 {
2881 format!("{}...", &live_objective[..27])
2882 } else {
2883 live_objective
2884 };
2885
2886 let core_title = if app.professional {
2887 Line::from(vec![
2888 Span::styled(format!(" {} ", core_icon), Style::default().fg(heart_color)),
2889 Span::styled("HEMATITE ", Style::default().add_modifier(Modifier::BOLD)),
2890 Span::styled(
2891 format!(" TASK: {} ", objective_text),
2892 Style::default()
2893 .fg(Color::Yellow)
2894 .add_modifier(Modifier::ITALIC),
2895 ),
2896 ])
2897 } else {
2898 Line::from(format!(" TASK: {} ", objective_text))
2899 };
2900
2901 let core_para = Paragraph::new(core_lines.clone())
2902 .block(
2903 Block::default()
2904 .title(core_title)
2905 .borders(Borders::ALL)
2906 .border_style(Style::default().fg(Color::DarkGray)),
2907 )
2908 .wrap(Wrap { trim: true });
2909
2910 let avail_h = top[0].height.saturating_sub(2);
2912 let inner_w = top[0].width.saturating_sub(4).max(1);
2914
2915 let mut total_lines: u16 = 0;
2916 for line in &core_lines {
2917 let line_w = line.width() as u16;
2918 if line_w == 0 {
2919 total_lines += 1;
2920 } else {
2921 let wrapped = (line_w + inner_w - 1) / inner_w;
2925 total_lines += wrapped;
2926 }
2927 }
2928
2929 let max_scroll = total_lines.saturating_sub(avail_h);
2930 let scroll = if let Some(off) = app.manual_scroll_offset {
2931 max_scroll.saturating_sub(off)
2932 } else {
2933 max_scroll
2934 };
2935
2936 f.render_widget(Clear, top[0]);
2938
2939 let chat_area = Rect::new(
2941 top[0].x + 1,
2942 top[0].y,
2943 top[0].width.saturating_sub(2).max(1),
2944 top[0].height,
2945 );
2946 f.render_widget(Clear, chat_area);
2947 f.render_widget(core_para.scroll((scroll, 0)), chat_area);
2948
2949 let mut scrollbar_state =
2952 ScrollbarState::new(max_scroll as usize + 1).position(scroll as usize);
2953 f.render_stateful_widget(
2954 Scrollbar::default()
2955 .orientation(ScrollbarOrientation::VerticalRight)
2956 .begin_symbol(Some("↑"))
2957 .end_symbol(Some("↓")),
2958 top[0],
2959 &mut scrollbar_state,
2960 );
2961
2962 let side = Layout::default()
2964 .direction(Direction::Vertical)
2965 .constraints([
2966 Constraint::Length(8), Constraint::Min(0), ])
2969 .split(top[1]);
2970
2971 let context_source = if app.active_context.is_empty() {
2973 default_active_context()
2974 } else {
2975 app.active_context.clone()
2976 };
2977 let mut context_display = context_source
2978 .iter()
2979 .map(|f| {
2980 let (icon, color) = match f.status.as_str() {
2981 "Running" => ("⚙️", Color::Cyan),
2982 "Dirty" => ("📝", Color::Yellow),
2983 _ => ("📄", Color::Gray),
2984 };
2985 let tokens = f.size / 4;
2987 ListItem::new(Line::from(vec![
2988 Span::styled(format!(" {} ", icon), Style::default().fg(color)),
2989 Span::styled(f.path.clone(), Style::default().fg(Color::White)),
2990 Span::styled(
2991 format!(" {}t ", tokens),
2992 Style::default().fg(Color::DarkGray),
2993 ),
2994 ]))
2995 })
2996 .collect::<Vec<ListItem>>();
2997
2998 if context_display.is_empty() {
2999 context_display = vec![ListItem::new(" (No active files)")];
3000 }
3001
3002 let ctx_block = Block::default()
3003 .title(" ACTIVE CONTEXT ")
3004 .borders(Borders::ALL)
3005 .border_style(Style::default().fg(Color::DarkGray));
3006
3007 f.render_widget(Clear, side[0]);
3008 f.render_widget(List::new(context_display).block(ctx_block), side[0]);
3009
3010 let v_title = if app.thinking || app.agent_running {
3015 format!(" SPECULAR [working] ")
3016 } else {
3017 " SPECULAR [Watching] ".to_string()
3018 };
3019
3020 f.render_widget(Clear, side[1]);
3021
3022 let mut v_lines: Vec<Line<'static>> = Vec::new();
3023
3024 if app.thinking || app.agent_running {
3026 let dots = ".".repeat((app.tick_count % 4) as usize + 1);
3027 let label = if app.thinking { "REASONING" } else { "WORKING" };
3028 v_lines.push(Line::from(vec![Span::styled(
3029 format!("[ {}{} ]", label, dots),
3030 Style::default()
3031 .fg(Color::Green)
3032 .add_modifier(Modifier::BOLD),
3033 )]));
3034 let preview = if app.current_thought.chars().count() > 300 {
3036 app.current_thought
3037 .chars()
3038 .rev()
3039 .take(300)
3040 .collect::<Vec<_>>()
3041 .into_iter()
3042 .rev()
3043 .collect::<String>()
3044 } else {
3045 app.current_thought.clone()
3046 };
3047 for raw in preview.lines() {
3048 let raw = raw.trim();
3049 if !raw.is_empty() {
3050 v_lines.extend(render_markdown_line(raw));
3051 }
3052 }
3053 v_lines.push(Line::raw(""));
3054 }
3055
3056 if !app.active_workers.is_empty() {
3058 v_lines.push(Line::from(vec![Span::styled(
3059 "── Task Progress ──",
3060 Style::default()
3061 .fg(Color::White)
3062 .add_modifier(Modifier::DIM),
3063 )]));
3064
3065 let mut sorted_ids: Vec<_> = app.active_workers.keys().cloned().collect();
3066 sorted_ids.sort();
3067
3068 for id in sorted_ids {
3069 let prog = app.active_workers[&id];
3070 let custom_label = app.worker_labels.get(&id).cloned();
3071
3072 let (label, color) = match prog {
3073 101..=102 => ("VERIFIED", Color::Green),
3074 100 if !app.agent_running && id != "AGENT" => ("SKIPPED ", Color::DarkGray),
3075 100 => ("REVIEW ", Color::Magenta),
3076 _ => ("WORKING ", Color::Yellow),
3077 };
3078
3079 let display_label = custom_label.unwrap_or_else(|| label.to_string());
3080 let filled = (prog.min(100) / 10) as usize;
3081 let bar = "▓".repeat(filled) + &"░".repeat(10 - filled);
3082
3083 let id_prefix = if id == "AGENT" {
3084 "Agent: ".to_string()
3085 } else {
3086 format!("W{}: ", id)
3087 };
3088
3089 v_lines.push(Line::from(vec![
3090 Span::styled(id_prefix, Style::default().fg(Color::Gray)),
3091 Span::styled(bar, Style::default().fg(color)),
3092 Span::styled(
3093 format!(" {} ", display_label),
3094 Style::default().fg(color).add_modifier(Modifier::BOLD),
3095 ),
3096 Span::styled(
3097 format!("{}%", prog.min(100)),
3098 Style::default().fg(Color::DarkGray),
3099 ),
3100 ]));
3101 }
3102 v_lines.push(Line::raw(""));
3103 }
3104
3105 if !app.last_reasoning.is_empty() {
3107 v_lines.push(Line::from(vec![Span::styled(
3108 "── Logic Trace ──",
3109 Style::default()
3110 .fg(Color::White)
3111 .add_modifier(Modifier::DIM),
3112 )]));
3113 for raw in app.last_reasoning.lines() {
3114 v_lines.extend(render_markdown_line(raw));
3115 }
3116 v_lines.push(Line::raw(""));
3117 }
3118
3119 if !app.specular_logs.is_empty() {
3121 v_lines.push(Line::from(vec![Span::styled(
3122 "── Events ──",
3123 Style::default()
3124 .fg(Color::White)
3125 .add_modifier(Modifier::DIM),
3126 )]));
3127 for log in &app.specular_logs {
3128 let (icon, color) = if log.starts_with("ERROR") {
3129 ("X ", Color::Red)
3130 } else if log.starts_with("INDEX") {
3131 ("I ", Color::Cyan)
3132 } else if log.starts_with("GHOST") {
3133 ("< ", Color::Magenta)
3134 } else {
3135 ("- ", Color::Gray)
3136 };
3137 v_lines.push(Line::from(vec![
3138 Span::styled(icon, Style::default().fg(color)),
3139 Span::styled(
3140 log.to_string(),
3141 Style::default()
3142 .fg(Color::White)
3143 .add_modifier(Modifier::DIM),
3144 ),
3145 ]));
3146 }
3147 }
3148
3149 let v_total = v_lines.len() as u16;
3150 let v_avail = side[1].height.saturating_sub(2);
3151 let v_max_scroll = v_total.saturating_sub(v_avail);
3152 let v_scroll = if app.specular_auto_scroll {
3155 v_max_scroll
3156 } else {
3157 app.specular_scroll.min(v_max_scroll)
3158 };
3159
3160 let specular_para = Paragraph::new(v_lines)
3161 .wrap(Wrap { trim: true })
3162 .scroll((v_scroll, 0))
3163 .block(Block::default().title(v_title).borders(Borders::ALL));
3164
3165 f.render_widget(specular_para, side[1]);
3166
3167 let mut v_scrollbar_state =
3169 ScrollbarState::new(v_max_scroll as usize + 1).position(v_scroll as usize);
3170 f.render_stateful_widget(
3171 Scrollbar::default()
3172 .orientation(ScrollbarOrientation::VerticalRight)
3173 .begin_symbol(None)
3174 .end_symbol(None),
3175 side[1],
3176 &mut v_scrollbar_state,
3177 );
3178
3179 let frame = app.tick_count % 3;
3181 let spark = match frame {
3182 0 => "✧",
3183 1 => "✦",
3184 _ => "✨",
3185 };
3186 let vigil = if app.brief_mode {
3187 "VIGIL:[ON]"
3188 } else {
3189 "VIGIL:[off]"
3190 };
3191 let yolo = if app.yolo_mode {
3192 " | APPROVALS: OFF"
3193 } else {
3194 ""
3195 };
3196
3197 let bar_constraints = if app.professional {
3198 vec![
3199 Constraint::Min(0), Constraint::Length(22), Constraint::Length(12), Constraint::Length(12), Constraint::Length(16), Constraint::Length(28), Constraint::Length(28), ]
3207 } else {
3208 vec![
3209 Constraint::Length(12), Constraint::Min(0), Constraint::Length(22), Constraint::Length(12), Constraint::Length(12), Constraint::Length(16), Constraint::Length(28), Constraint::Length(28), ]
3218 };
3219 let bar_chunks = Layout::default()
3220 .direction(Direction::Horizontal)
3221 .constraints(bar_constraints)
3222 .split(chunks[2]);
3223
3224 let char_count: usize = app.messages_raw.iter().map(|(_, c)| c.len()).sum();
3225 let est_tokens = char_count / 3;
3226 let current_tokens = if app.total_tokens > 0 {
3227 app.total_tokens
3228 } else {
3229 est_tokens
3230 };
3231 let usage_text = format!(
3232 "TOKENS: {:0>5} | TOTAL: ${:.4}",
3233 current_tokens, app.current_session_cost
3234 );
3235 let runtime_age = app.last_runtime_profile_time.elapsed();
3236 let (lm_label, lm_color) = if app.model_id == "no model loaded" {
3237 ("LM:NONE", Color::Red)
3238 } else if app.model_id == "detecting..." || app.context_length == 0 {
3239 ("LM:BOOT", Color::DarkGray)
3240 } else if app.provider_state == ProviderRuntimeState::Recovering {
3241 ("LM:RECV", Color::Cyan)
3242 } else if matches!(
3243 app.provider_state,
3244 ProviderRuntimeState::Degraded | ProviderRuntimeState::EmptyResponse
3245 ) {
3246 ("LM:WARN", Color::Red)
3247 } else if app.provider_state == ProviderRuntimeState::ContextWindow {
3248 ("LM:CEIL", Color::Yellow)
3249 } else if runtime_age > std::time::Duration::from_secs(12) {
3250 ("LM:STALE", Color::Yellow)
3251 } else {
3252 ("LM:LIVE", Color::Green)
3253 };
3254 let compaction_percent = app.compaction_percent.min(100);
3255 let compaction_label = if app.compaction_threshold_tokens == 0 {
3256 " CMP: 0%".to_string()
3257 } else {
3258 format!(" CMP:{:>3}%", compaction_percent)
3259 };
3260 let compaction_color = if app.compaction_threshold_tokens == 0 {
3261 Color::DarkGray
3262 } else if compaction_percent >= 85 {
3263 Color::Red
3264 } else if compaction_percent >= 60 {
3265 Color::Yellow
3266 } else {
3267 Color::Green
3268 };
3269 let prompt_percent = app.prompt_pressure_percent.min(100);
3270 let prompt_label = if app.prompt_estimated_total_tokens == 0 {
3271 " BUD: 0%".to_string()
3272 } else {
3273 format!(" BUD:{:>3}%", prompt_percent)
3274 };
3275 let prompt_color = if app.prompt_estimated_total_tokens == 0 {
3276 Color::DarkGray
3277 } else if prompt_percent >= 85 {
3278 Color::Red
3279 } else if prompt_percent >= 60 {
3280 Color::Yellow
3281 } else {
3282 Color::Green
3283 };
3284
3285 let think_badge = match app.think_mode {
3286 Some(true) => " [THINK]",
3287 Some(false) => " [FAST]",
3288 None => "",
3289 };
3290
3291 let (vein_label, vein_color) = if app.vein_docs_only {
3292 let color = if app.vein_embedded_count > 0 {
3293 Color::Green
3294 } else if app.vein_file_count > 0 {
3295 Color::Yellow
3296 } else {
3297 Color::DarkGray
3298 };
3299 ("VN:DOC", color)
3300 } else if app.vein_file_count == 0 {
3301 ("VN:--", Color::DarkGray)
3302 } else if app.vein_embedded_count > 0 {
3303 ("VN:SEM", Color::Green)
3304 } else {
3305 ("VN:FTS", Color::Yellow)
3306 };
3307
3308 let (status_idx, lm_idx, bud_idx, cmp_idx, remote_idx, tokens_idx, vram_idx) =
3309 if app.professional {
3310 (0usize, 1usize, 2usize, 3usize, 4usize, 5usize, 6usize)
3311 } else {
3312 (1usize, 2usize, 3usize, 4usize, 5usize, 6usize, 7usize)
3313 };
3314
3315 if app.professional {
3316 f.render_widget(Clear, bar_chunks[status_idx]);
3317
3318 let voice_badge = if app.voice_manager.is_enabled() {
3319 " | VOICE:ON"
3320 } else {
3321 ""
3322 };
3323 f.render_widget(
3324 Paragraph::new(format!(
3325 " MODE:PRO | FLOW:{}{} | CTX:{} | ERR:{}{}{}",
3326 app.workflow_mode,
3327 yolo,
3328 app.context_length,
3329 app.stats.debugging,
3330 think_badge,
3331 voice_badge
3332 ))
3333 .block(Block::default().borders(Borders::ALL)),
3334 bar_chunks[status_idx],
3335 );
3336 } else {
3337 f.render_widget(Clear, bar_chunks[0]);
3338 f.render_widget(
3339 Paragraph::new(format!(" {} {}", spark, app.soul_name))
3340 .block(Block::default().borders(Borders::ALL)),
3341 bar_chunks[0],
3342 );
3343 f.render_widget(Clear, bar_chunks[status_idx]);
3344 f.render_widget(
3345 Paragraph::new(format!("{}{}", vigil, think_badge))
3346 .block(Block::default().borders(Borders::ALL).fg(Color::Yellow)),
3347 bar_chunks[status_idx],
3348 );
3349 }
3350
3351 let git_status = app.git_state.status();
3353 let git_label = app.git_state.label();
3354 let git_color = match git_status {
3355 crate::agent::git_monitor::GitRemoteStatus::Connected => Color::Green,
3356 crate::agent::git_monitor::GitRemoteStatus::NoRemote => Color::Yellow,
3357 crate::agent::git_monitor::GitRemoteStatus::Behind
3358 | crate::agent::git_monitor::GitRemoteStatus::Ahead => Color::Magenta,
3359 crate::agent::git_monitor::GitRemoteStatus::Diverged
3360 | crate::agent::git_monitor::GitRemoteStatus::Error => Color::Red,
3361 _ => Color::DarkGray,
3362 };
3363
3364 f.render_widget(Clear, bar_chunks[lm_idx]);
3365 f.render_widget(
3366 Paragraph::new(ratatui::text::Line::from(vec![
3367 ratatui::text::Span::styled(format!(" {}", lm_label), Style::default().fg(lm_color)),
3368 ratatui::text::Span::raw(" | "),
3369 ratatui::text::Span::styled(vein_label, Style::default().fg(vein_color)),
3370 ]))
3371 .block(
3372 Block::default()
3373 .borders(Borders::ALL)
3374 .border_style(Style::default().fg(lm_color)),
3375 ),
3376 bar_chunks[lm_idx],
3377 );
3378
3379 f.render_widget(Clear, bar_chunks[bud_idx]);
3380 f.render_widget(
3381 Paragraph::new(prompt_label)
3382 .block(
3383 Block::default()
3384 .borders(Borders::ALL)
3385 .border_style(Style::default().fg(prompt_color)),
3386 )
3387 .fg(prompt_color),
3388 bar_chunks[bud_idx],
3389 );
3390
3391 f.render_widget(Clear, bar_chunks[cmp_idx]);
3392 f.render_widget(
3393 Paragraph::new(compaction_label)
3394 .block(
3395 Block::default()
3396 .borders(Borders::ALL)
3397 .border_style(Style::default().fg(compaction_color)),
3398 )
3399 .fg(compaction_color),
3400 bar_chunks[cmp_idx],
3401 );
3402
3403 f.render_widget(Clear, bar_chunks[remote_idx]);
3404 f.render_widget(
3405 Paragraph::new(format!(" REMOTE: {}", git_label))
3406 .block(
3407 Block::default()
3408 .borders(Borders::ALL)
3409 .border_style(Style::default().fg(git_color)),
3410 )
3411 .fg(git_color),
3412 bar_chunks[remote_idx],
3413 );
3414
3415 let usage_color = Color::Rgb(215, 125, 40);
3416 f.render_widget(Clear, bar_chunks[tokens_idx]);
3417 f.render_widget(
3418 Paragraph::new(usage_text)
3419 .block(Block::default().borders(Borders::ALL).fg(usage_color))
3420 .fg(usage_color),
3421 bar_chunks[tokens_idx],
3422 );
3423
3424 let vram_ratio = app.gpu_state.ratio();
3426 let vram_label = app.gpu_state.label();
3427 let gpu_name = app.gpu_state.gpu_name();
3428
3429 let gauge_color = if vram_ratio > 0.85 {
3430 Color::Red
3431 } else if vram_ratio > 0.60 {
3432 Color::Yellow
3433 } else {
3434 Color::Cyan
3435 };
3436 f.render_widget(Clear, bar_chunks[vram_idx]);
3437 f.render_widget(
3438 Gauge::default()
3439 .block(
3440 Block::default()
3441 .borders(Borders::ALL)
3442 .title(format!(" {} ", gpu_name)),
3443 )
3444 .gauge_style(Style::default().fg(gauge_color))
3445 .ratio(vram_ratio)
3446 .label(format!(" {} ", vram_label)), bar_chunks[vram_idx],
3448 );
3449
3450 let input_style = if app.agent_running {
3452 Style::default().fg(Color::DarkGray)
3453 } else {
3454 Style::default().fg(Color::Rgb(120, 70, 50))
3455 };
3456 let input_rect = chunks[1];
3457 let title_area = input_title_area(input_rect);
3458 let input_hint = render_input_title(app, title_area);
3459 let input_block = Block::default()
3460 .title(input_hint)
3461 .borders(Borders::ALL)
3462 .border_style(input_style)
3463 .style(Style::default().bg(Color::Rgb(40, 25, 15))); let inner_area = input_block.inner(input_rect);
3466 f.render_widget(Clear, input_rect);
3467 f.render_widget(input_block, input_rect);
3468
3469 f.render_widget(
3470 Paragraph::new(app.input.as_str()).wrap(Wrap { trim: true }),
3471 inner_area,
3472 );
3473
3474 if !app.agent_running && inner_area.height > 0 {
3479 let text_w = app.input.len() as u16;
3480 let max_w = inner_area.width.saturating_sub(1);
3481 let cursor_x = inner_area.x + text_w.min(max_w);
3482 f.set_cursor(cursor_x, inner_area.y);
3483 }
3484
3485 if let Some(approval) = &app.awaiting_approval {
3487 let is_diff_preview = approval.diff.is_some();
3488
3489 let modal_h = if is_diff_preview { 70 } else { 50 };
3491 let area = centered_rect(80, modal_h, f.size());
3492 f.render_widget(Clear, area);
3493
3494 let chunks = Layout::default()
3495 .direction(Direction::Vertical)
3496 .constraints([
3497 Constraint::Length(4), Constraint::Min(0), ])
3500 .split(area);
3501
3502 let (title_str, title_color) = if is_diff_preview {
3504 (" DIFF PREVIEW — REVIEW BEFORE APPLYING ", Color::Yellow)
3505 } else {
3506 (" HIGH-RISK OPERATION REQUESTED ", Color::Red)
3507 };
3508 let header_text = vec![
3509 Line::from(Span::styled(
3510 title_str,
3511 Style::default()
3512 .fg(title_color)
3513 .add_modifier(Modifier::BOLD),
3514 )),
3515 Line::from(Span::styled(
3516 if is_diff_preview {
3517 " [↑↓/jk/PgUp/PgDn] Scroll [Y] Apply [N] Skip "
3518 } else {
3519 " [Y] Approve [N] Decline "
3520 },
3521 Style::default()
3522 .fg(Color::Green)
3523 .add_modifier(Modifier::BOLD),
3524 )),
3525 ];
3526 f.render_widget(
3527 Paragraph::new(header_text)
3528 .block(
3529 Block::default()
3530 .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
3531 .border_style(Style::default().fg(title_color)),
3532 )
3533 .alignment(ratatui::layout::Alignment::Center),
3534 chunks[0],
3535 );
3536
3537 let border_color = if is_diff_preview {
3539 Color::Yellow
3540 } else {
3541 Color::Red
3542 };
3543 if let Some(diff_text) = &approval.diff {
3544 let added = diff_text.lines().filter(|l| l.starts_with("+ ")).count();
3546 let removed = diff_text.lines().filter(|l| l.starts_with("- ")).count();
3547 let mut body_lines: Vec<Line> = vec![
3548 Line::from(Span::styled(
3549 format!(" {}", approval.display),
3550 Style::default().fg(Color::Cyan),
3551 )),
3552 Line::from(vec![
3553 Span::styled(
3554 format!(" +{}", added),
3555 Style::default()
3556 .fg(Color::Green)
3557 .add_modifier(Modifier::BOLD),
3558 ),
3559 Span::styled(
3560 format!(" -{}", removed),
3561 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
3562 ),
3563 ]),
3564 Line::from(Span::raw("")),
3565 ];
3566 for raw_line in diff_text.lines() {
3567 let styled = if raw_line.starts_with("+ ") {
3568 Line::from(Span::styled(
3569 format!(" {}", raw_line),
3570 Style::default().fg(Color::Green),
3571 ))
3572 } else if raw_line.starts_with("- ") {
3573 Line::from(Span::styled(
3574 format!(" {}", raw_line),
3575 Style::default().fg(Color::Red),
3576 ))
3577 } else if raw_line.starts_with("---") || raw_line.starts_with("@@ ") {
3578 Line::from(Span::styled(
3579 format!(" {}", raw_line),
3580 Style::default()
3581 .fg(Color::DarkGray)
3582 .add_modifier(Modifier::BOLD),
3583 ))
3584 } else {
3585 Line::from(Span::raw(format!(" {}", raw_line)))
3586 };
3587 body_lines.push(styled);
3588 }
3589 f.render_widget(
3590 Paragraph::new(body_lines)
3591 .block(
3592 Block::default()
3593 .borders(Borders::BOTTOM | Borders::LEFT | Borders::RIGHT)
3594 .border_style(Style::default().fg(border_color)),
3595 )
3596 .scroll((approval.diff_scroll, 0)),
3597 chunks[1],
3598 );
3599 } else {
3600 let body_text = vec![
3601 Line::from(Span::raw(format!(" Tool: {}", approval.tool_name))),
3602 Line::from(Span::styled(
3603 format!(" ❯ {}", approval.display),
3604 Style::default().fg(Color::Cyan),
3605 )),
3606 ];
3607 f.render_widget(
3608 Paragraph::new(body_text)
3609 .block(
3610 Block::default()
3611 .borders(Borders::BOTTOM | Borders::LEFT | Borders::RIGHT)
3612 .border_style(Style::default().fg(border_color)),
3613 )
3614 .wrap(Wrap { trim: true }),
3615 chunks[1],
3616 );
3617 }
3618 }
3619
3620 if let Some(review) = &app.active_review {
3622 draw_diff_review(f, review);
3623 }
3624
3625 if app.show_autocomplete && !app.autocomplete_suggestions.is_empty() {
3627 let area = Rect {
3628 x: chunks[1].x + 2,
3629 y: chunks[1]
3630 .y
3631 .saturating_sub(app.autocomplete_suggestions.len() as u16 + 2),
3632 width: chunks[1].width.saturating_sub(4),
3633 height: app.autocomplete_suggestions.len() as u16 + 2,
3634 };
3635 f.render_widget(Clear, area);
3636
3637 let items: Vec<ListItem> = app
3638 .autocomplete_suggestions
3639 .iter()
3640 .enumerate()
3641 .map(|(i, s)| {
3642 let style = if i == app.selected_suggestion {
3643 Style::default()
3644 .fg(Color::Black)
3645 .bg(Color::Cyan)
3646 .add_modifier(Modifier::BOLD)
3647 } else {
3648 Style::default().fg(Color::Gray)
3649 };
3650 ListItem::new(format!(" 📄 {}", s)).style(style)
3651 })
3652 .collect();
3653
3654 let hatch = List::new(items).block(
3655 Block::default()
3656 .borders(Borders::ALL)
3657 .border_style(Style::default().fg(Color::Cyan))
3658 .title(format!(
3659 " @ RESOLVER (Matching: {}) ",
3660 app.autocomplete_filter
3661 )),
3662 );
3663 f.render_widget(hatch, area);
3664
3665 if app.autocomplete_suggestions.len() >= 15 {
3667 let more_area = Rect {
3668 x: area.x + 2,
3669 y: area.y + area.height - 1,
3670 width: 20,
3671 height: 1,
3672 };
3673 f.render_widget(
3674 Paragraph::new("... (type to narrow) ").style(Style::default().fg(Color::DarkGray)),
3675 more_area,
3676 );
3677 }
3678 }
3679}
3680
3681fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
3684 let vert = Layout::default()
3685 .direction(Direction::Vertical)
3686 .constraints([
3687 Constraint::Percentage((100 - percent_y) / 2),
3688 Constraint::Percentage(percent_y),
3689 Constraint::Percentage((100 - percent_y) / 2),
3690 ])
3691 .split(r);
3692 Layout::default()
3693 .direction(Direction::Horizontal)
3694 .constraints([
3695 Constraint::Percentage((100 - percent_x) / 2),
3696 Constraint::Percentage(percent_x),
3697 Constraint::Percentage((100 - percent_x) / 2),
3698 ])
3699 .split(vert[1])[1]
3700}
3701
3702fn strip_ghost_prefix(s: &str) -> &str {
3703 for prefix in &[
3704 "Hematite: ",
3705 "HEMATITE: ",
3706 "Assistant: ",
3707 "assistant: ",
3708 "Okay, ",
3709 "Hmm, ",
3710 "Wait, ",
3711 "Alright, ",
3712 "Got it, ",
3713 "Certainly, ",
3714 "Sure, ",
3715 "Understood, ",
3716 ] {
3717 if s.to_lowercase().starts_with(&prefix.to_lowercase()) {
3718 return &s[prefix.len()..];
3719 }
3720 }
3721 s
3722}
3723
3724fn first_n_chars(s: &str, n: usize) -> String {
3725 let mut result = String::new();
3726 let mut count = 0;
3727 for c in s.chars() {
3728 if count >= n {
3729 result.push('…');
3730 break;
3731 }
3732 if c == '\n' || c == '\r' {
3733 result.push(' ');
3734 } else if !c.is_control() {
3735 result.push(c);
3736 }
3737 count += 1;
3738 }
3739 result
3740}
3741
3742fn trim_vec_context(v: &mut Vec<ContextFile>, max: usize) {
3743 while v.len() > max {
3744 v.remove(0);
3745 }
3746}
3747
3748fn trim_vec(v: &mut Vec<String>, max: usize) {
3749 while v.len() > max {
3750 v.remove(0);
3751 }
3752}
3753
3754fn render_markdown_line(raw: &str) -> Vec<Line<'static>> {
3757 let cleaned_ansi = strip_ansi(raw);
3759 let trimmed = cleaned_ansi.trim();
3760 if trimmed.is_empty() {
3761 return vec![Line::raw("")];
3762 }
3763
3764 let cleaned_owned = trimmed
3766 .replace("<thought>", "")
3767 .replace("</thought>", "")
3768 .replace("<think>", "")
3769 .replace("</think>", "");
3770 let trimmed = cleaned_owned.trim();
3771 if trimmed.is_empty() {
3772 return vec![];
3773 }
3774
3775 for (prefix, indent) in &[("### ", " "), ("## ", " "), ("# ", "")] {
3777 if let Some(rest) = trimmed.strip_prefix(prefix) {
3778 return vec![Line::from(vec![Span::styled(
3779 format!("{}{}", indent, rest),
3780 Style::default()
3781 .fg(Color::White)
3782 .add_modifier(Modifier::BOLD),
3783 )])];
3784 }
3785 }
3786
3787 if let Some(rest) = trimmed
3789 .strip_prefix("> ")
3790 .or_else(|| trimmed.strip_prefix(">"))
3791 {
3792 return vec![Line::from(vec![
3793 Span::styled("| ", Style::default().fg(Color::DarkGray)),
3794 Span::styled(
3795 rest.to_string(),
3796 Style::default()
3797 .fg(Color::White)
3798 .add_modifier(Modifier::DIM),
3799 ),
3800 ])];
3801 }
3802
3803 if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
3805 let rest = &trimmed[2..];
3806 let mut spans = vec![Span::styled("* ", Style::default().fg(Color::Gray))];
3807 spans.extend(inline_markdown(rest));
3808 return vec![Line::from(spans)];
3809 }
3810
3811 let spans = inline_markdown(trimmed);
3813 vec![Line::from(spans)]
3814}
3815
3816fn inline_markdown_core(text: &str) -> Vec<Span<'static>> {
3818 let mut spans = Vec::new();
3819 let mut remaining = text;
3820
3821 while !remaining.is_empty() {
3822 if let Some(start) = remaining.find("**") {
3823 let before = &remaining[..start];
3824 if !before.is_empty() {
3825 spans.push(Span::raw(before.to_string()));
3826 }
3827 let after_open = &remaining[start + 2..];
3828 if let Some(end) = after_open.find("**") {
3829 spans.push(Span::styled(
3830 after_open[..end].to_string(),
3831 Style::default()
3832 .fg(Color::White)
3833 .add_modifier(Modifier::BOLD),
3834 ));
3835 remaining = &after_open[end + 2..];
3836 continue;
3837 }
3838 }
3839 if let Some(start) = remaining.find('`') {
3840 let before = &remaining[..start];
3841 if !before.is_empty() {
3842 spans.push(Span::raw(before.to_string()));
3843 }
3844 let after_open = &remaining[start + 1..];
3845 if let Some(end) = after_open.find('`') {
3846 spans.push(Span::styled(
3847 after_open[..end].to_string(),
3848 Style::default().fg(Color::Yellow),
3849 ));
3850 remaining = &after_open[end + 1..];
3851 continue;
3852 }
3853 }
3854 spans.push(Span::raw(remaining.to_string()));
3855 break;
3856 }
3857 spans
3858}
3859
3860fn inline_markdown(text: &str) -> Vec<Span<'static>> {
3862 let mut spans = Vec::new();
3863 let mut remaining = text;
3864
3865 while !remaining.is_empty() {
3866 if let Some(start) = remaining.find("**") {
3867 let before = &remaining[..start];
3868 if !before.is_empty() {
3869 spans.push(Span::raw(before.to_string()));
3870 }
3871 let after_open = &remaining[start + 2..];
3872 if let Some(end) = after_open.find("**") {
3873 spans.push(Span::styled(
3874 after_open[..end].to_string(),
3875 Style::default()
3876 .fg(Color::White)
3877 .add_modifier(Modifier::BOLD),
3878 ));
3879 remaining = &after_open[end + 2..];
3880 continue;
3881 }
3882 }
3883 if let Some(start) = remaining.find('`') {
3884 let before = &remaining[..start];
3885 if !before.is_empty() {
3886 spans.push(Span::raw(before.to_string()));
3887 }
3888 let after_open = &remaining[start + 1..];
3889 if let Some(end) = after_open.find('`') {
3890 spans.push(Span::styled(
3891 after_open[..end].to_string(),
3892 Style::default().fg(Color::Yellow),
3893 ));
3894 remaining = &after_open[end + 1..];
3895 continue;
3896 }
3897 }
3898 spans.push(Span::raw(remaining.to_string()));
3899 break;
3900 }
3901 spans
3902}
3903
3904fn draw_splash<B: Backend>(terminal: &mut Terminal<B>) -> Result<(), Box<dyn std::error::Error>> {
3907 let rust_color = Color::Rgb(180, 90, 50);
3908
3909 let logo_lines = vec![
3910 "██╗ ██╗███████╗███╗ ███╗ █████╗ ████████╗██╗████████╗███████╗",
3911 "██║ ██║██╔════╝████╗ ████║██╔══██╗╚══██╔══╝██║╚══██╔══╝██╔════╝",
3912 "███████║█████╗ ██╔████╔██║███████║ ██║ ██║ ██║ █████╗ ",
3913 "██╔══██║██╔══╝ ██║╚██╔╝██║██╔══██║ ██║ ██║ ██║ ██╔══╝ ",
3914 "██║ ██║███████╗██║ ╚═╝ ██║██║ ██║ ██║ ██║ ██║ ███████╗",
3915 "╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝",
3916 ];
3917
3918 let version = env!("CARGO_PKG_VERSION");
3919
3920 terminal.draw(|f| {
3921 let area = f.size();
3922
3923 f.render_widget(
3925 Block::default().style(Style::default().bg(Color::Black)),
3926 area,
3927 );
3928
3929 let content_height: u16 = 13;
3931 let top_pad = area.height.saturating_sub(content_height) / 2;
3932
3933 let mut lines: Vec<Line<'static>> = Vec::new();
3934
3935 for _ in 0..top_pad {
3937 lines.push(Line::raw(""));
3938 }
3939
3940 for logo_line in &logo_lines {
3942 lines.push(Line::from(Span::styled(
3943 logo_line.to_string(),
3944 Style::default().fg(rust_color).add_modifier(Modifier::BOLD),
3945 )));
3946 }
3947
3948 lines.push(Line::raw(""));
3950
3951 lines.push(Line::from(vec![Span::styled(
3953 format!("v{}", version),
3954 Style::default().fg(Color::DarkGray),
3955 )]));
3956
3957 lines.push(Line::from(vec![Span::styled(
3959 "Local AI coding harness",
3960 Style::default()
3961 .fg(Color::DarkGray)
3962 .add_modifier(Modifier::DIM),
3963 )]));
3964
3965 lines.push(Line::from(vec![Span::styled(
3967 "Developed by Ocean Bennett",
3968 Style::default().fg(Color::Gray).add_modifier(Modifier::DIM),
3969 )]));
3970
3971 lines.push(Line::raw(""));
3973 lines.push(Line::raw(""));
3974
3975 lines.push(Line::from(vec![
3977 Span::styled("[ ", Style::default().fg(rust_color)),
3978 Span::styled(
3979 "Press ENTER to start",
3980 Style::default()
3981 .fg(Color::White)
3982 .add_modifier(Modifier::BOLD),
3983 ),
3984 Span::styled(" ]", Style::default().fg(rust_color)),
3985 ]));
3986
3987 let splash = Paragraph::new(lines).alignment(ratatui::layout::Alignment::Center);
3988
3989 f.render_widget(splash, area);
3990 })?;
3991
3992 Ok(())
3993}
3994
3995fn normalize_id(id: &str) -> String {
3996 id.trim().to_uppercase()
3997}
3998
3999fn filter_tui_noise(text: &str) -> String {
4000 let cleaned = strip_ansi(text);
4002
4003 let mut lines = Vec::new();
4005 for line in cleaned.lines() {
4006 if CRLF_REGEX.is_match(line) {
4008 continue;
4009 }
4010 if line.contains("Updating files:") && line.contains("%") {
4012 continue;
4013 }
4014 let sanitized: String = line
4016 .chars()
4017 .filter(|c| !c.is_control() || *c == '\t')
4018 .collect();
4019 if sanitized.trim().is_empty() && !line.trim().is_empty() {
4020 continue;
4021 }
4022
4023 lines.push(normalize_tui_text(&sanitized));
4024 }
4025 lines.join("\n").trim().to_string()
4026}
4027
4028fn normalize_tui_text(text: &str) -> String {
4029 let mut normalized = text
4030 .replace("ΓÇö", "-")
4031 .replace("ΓÇô", "-")
4032 .replace("…", "...")
4033 .replace("✅", "[OK]")
4034 .replace("🛠️", "")
4035 .replace("—", "-")
4036 .replace("–", "-")
4037 .replace("…", "...")
4038 .replace("•", "*")
4039 .replace("✅", "[OK]")
4040 .replace("🚨", "[!]");
4041
4042 normalized = normalized
4043 .chars()
4044 .map(|c| match c {
4045 '\u{00A0}' => ' ',
4046 '\u{2018}' | '\u{2019}' => '\'',
4047 '\u{201C}' | '\u{201D}' => '"',
4048 c if c.is_ascii() || c == '\n' || c == '\t' => c,
4049 _ => ' ',
4050 })
4051 .collect();
4052
4053 let mut compacted = String::with_capacity(normalized.len());
4054 let mut prev_space = false;
4055 for ch in normalized.chars() {
4056 if ch == ' ' {
4057 if !prev_space {
4058 compacted.push(ch);
4059 }
4060 prev_space = true;
4061 } else {
4062 compacted.push(ch);
4063 prev_space = false;
4064 }
4065 }
4066
4067 compacted.trim().to_string()
4068}