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