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