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