Skip to main content

hematite/ui/
tui.rs

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
26// ── Approval modal state ──────────────────────────────────────────────────────
27
28/// Holds a pending high-risk tool approval request.
29/// The agent loop is blocked on `responder` until the user presses Y or N.
30pub struct PendingApproval {
31    pub display: String,
32    pub tool_name: String,
33    /// Pre-formatted diff from `compute_*_diff`.  Lines starting with "- " are
34    /// removals (red), "+ " are additions (green), "---" / "@@ " are headers.
35    pub diff: Option<String>,
36    /// Current scroll offset for the diff body (lines scrolled down).
37    pub diff_scroll: u16,
38    pub mutation_label: Option<String>,
39    pub responder: tokio::sync::oneshot::Sender<bool>,
40}
41
42// ── App state ─────────────────────────────────────────────────────────────────
43
44pub 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    // Detect the actual project entrypoints generically rather than
65    // hardcoding Hematite's own file layout. Priority order: first match wins
66    // for the "primary" slot, then the project manifest, then source root.
67    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    // Primary entrypoint
96    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    // Project manifest
110    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    // Source root watcher
124    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)>, // Keep raw for reference or re-formatting if needed
140    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    /// Blocked waiting for user approval of a risky tool call.
146    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    /// Channel to send user messages to the agent task.
161    pub user_input_tx: tokio::sync::mpsc::Sender<UserTurn>,
162    pub specular_scroll: u16,
163    /// When true the SPECULAR panel snaps to the bottom every frame.
164    /// Set false when the user manually scrolls up; reset true on new turn / Done.
165    pub specular_auto_scroll: bool,
166    /// Shared GPU VRAM state (polled in background).
167    pub gpu_state: Arc<GpuState>,
168    /// Shared Git remote state (polled in background).
169    pub git_state: Arc<crate::agent::git_monitor::GitState>,
170    /// Track the last time a character or paste arrived to detect "fast streams" (pasting).
171    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    /// Tracks the highest threshold crossed for compaction warnings (70, 90).
185    /// Prevents re-firing the same warning every update tick.
186    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    /// Mirrors ConversationManager::think_mode for status bar display.
199    /// None = auto, Some(true) = /think, Some(false) = /no_think.
200    pub think_mode: Option<bool>,
201    /// Sticky user-facing workflow mode.
202    pub workflow_mode: String,
203    /// [Autocomplete Hatch] List of matching project files.
204    pub autocomplete_suggestions: Vec<String>,
205    /// [Autocomplete Hatch] Index of the currently highlighted suggestion.
206    pub selected_suggestion: usize,
207    /// [Autocomplete Hatch] Whether the suggestions popup is visible.
208    pub show_autocomplete: bool,
209    /// [Autocomplete Hatch] The search fragment after the '@' symbol.
210    pub autocomplete_filter: String,
211    /// [Strategist] The currently active task from TASK.md.
212    pub current_objective: String,
213    /// [Voice of Hematite] Local TTS manager.
214    pub voice_manager: Arc<crate::ui::voice::VoiceManager>,
215    pub voice_loading: bool,
216    pub voice_loading_progress: f64,
217    /// If false, the VRAM watchdog is silenced.
218    pub hardware_guard_enabled: bool,
219    /// Wall-clock time when this session started (for report timestamp).
220    pub session_start: std::time::SystemTime,
221    /// The current Rusty companion's species name — shown in the footer.
222    pub soul_name: String,
223    /// File attached via /attach — injected as context prefix on the next turn, then cleared.
224    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        } // Completely suppressed noise
261
262        self.messages_raw.push((speaker.to_string(), filtered));
263        // Cap raw history to prevent UI lag.
264        if self.messages_raw.len() > 100 {
265            self.messages_raw.remove(0);
266        }
267        self.rebuild_formatted_messages();
268        // Cap visual history.
269        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                // Optimization: Only rebuild formatting on whitespace/newline or if specifically requested.
280                // This prevents "shattering" the TUI during high-speed token streams.
281                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            // Add a single blank line between messages for breathing room.
300            // Never add this to the very last message so it remains flush with the bottom.
301            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        // Hematite = rust iron-oxide brown; You = green; Tool = cyan
310        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        // Aggressive trim to avoid leading/trailing blank rows.
321        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            // SPACING FIX:
329            // If we have a sequence of blank lines, don't label them with "  ".
330            // Only add labels to lines that have content OR are the very first line of the message.
331            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            // System messages with "+N -N" stat tokens get inline green/red coloring.
343            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                // Tokenise on whitespace, colouring +digits green, -digits red,
347                // and file paths (containing '/' or '.') bright white.
348                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("    "), // Deeper indent for diffs
405                    Span::styled(raw_line.to_string(), line_style),
406                ]));
407            } else {
408                let mut spans = vec![Span::raw(" "), Span::styled(label, style)];
409                // Render inline markdown for Hematite responses; plain text for others.
410                // Code fence lines (``` or ```rust etc.) are rendered as plain dim text
411                // rather than passed through inline_markdown_core, which would misparse
412                // the backticks as inline code spans and garble the layout.
413                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    /// [Intelli-Hematite] Live scan of the workspace to populate autocomplete.
434    /// Excludes common noisy directories like target, node_modules, .git.
435    pub fn update_autocomplete(&mut self) {
436        let root = crate::tools::file_ops::workspace_root();
437        // Extract the fragment after the last '@'
438        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                        // Show up to 15 at once
465                        matches.push(path_str);
466                    }
467                }
468            }
469            if total_found > 100 {
470                break;
471            } // Safety cap for massive repos
472        }
473
474        // Prioritize: Move .rs and .md files to the top if they match
475        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    /// [Intelli-Hematite] Update the context strategy deck with real file data.
490    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    /// [Task Analyzer] Parse TASK.md to find the current active goal.
505    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                // Match "- [ ]" or "- [/]"
521                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    /// [Auto-Diagnostic] Copy full session transcript to clipboard.
533    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        // Timestamp from session_start
580        let start_secs = self
581            .session_start
582            .duration_since(std::time::UNIX_EPOCH)
583            .unwrap_or_default()
584            .as_secs();
585
586        // Simple epoch → YYYY-MM-DD_HH-MM-SS (UTC)
587        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    // Fallback: Windows clip.exe is fast and dependency-free, but some
683    // terminal/clipboard paths can mangle non-ASCII punctuation.
684    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
696/// Spawns a new detached terminal window pre-loaded with the dive-in command.
697/// On Windows, uses PowerShell's Start-Process to ensure the new window is detached.
698fn 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// ── run_app ───────────────────────────────────────────────────────────────────
793
794#[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            // Rough token estimate: ~4 chars per token.
828            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    // Initial placeholder — streaming will overwrite this with hardware diagnostics
1569    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    // ── Splash Screen ─────────────────────────────────────────────────────────
1582    // Blocking splash — user must press Enter to proceed.
1583    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        // ── Hardware Watchdog ──
1601        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                // Increment voice loading progress (estimated 50s total load)
1615                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                // Scale advance to match new 100ms tick (formerly 500ms)
1622                // We keep animations consistent by only advancing tick_count every 5 ticks or scaling.
1623                // Let's just increment every tick but use a larger modulo in animations.
1624                app.tick_count = app.tick_count.wrapping_add(advance);
1625                app.update_objective();
1626            }
1627
1628            // ── Keyboard / mouse input ────────────────────────────────────────
1629            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                                    // User scrolled up — disable auto-scroll so they can read.
1680                                    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                        // Update idle tracker for DeepReflect.
1702                        { *last_interaction.lock().unwrap() = Instant::now(); }
1703
1704                        // ── Tier-2 Swarm diff review modal (exclusive lock) ───
1705                        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                        // ── High-risk approval modal (exclusive lock) ─────────
1725                        if let Some(mut approval) = app.awaiting_approval.take() {
1726                            // Scroll keys — adjust offset and put approval back.
1727                            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                        // ── Normal key bindings ───────────────────────────────
1782                        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                                // If the user manually toggles, silence the hardware guard for this session.
1797                                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                                // Hardware-aware swarm: Limit workers if GPU is busy.
1835                                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                                    // Protect history: Use Up as a scroll fallback if already scrolling.
1866                                    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                                    // Only cycle history if we are at the bottom of the chat.
1870                                    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; // typing cancels history nav
1916                                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                                    // PASTE GUARD: If a newline arrives within 50ms of a character,
1954                                    // it's almost certainly part of a paste stream. Convert to space.
1955                                    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                                    // ── Slash Command Processor ──────────────────────────
1964                                    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                                                    // /voice N — select by number
2060                                                    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                                                        // /voice af_bella — select by name
2071                                                        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                                                    // /voice — list all voices
2081                                                    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                                                // Cancel any running turn so /forget isn't queued behind retries.
2119                                                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(); // Fresh architecture for a fresh command
2462                                                    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                                    // Save to history (avoid consecutive duplicates).
2605                                    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                            // Normalize pasted newlines into spaces so we don't accidentally submit
2637                            // multiple lines or break the single-line input logic.
2638                            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            // ── Specular proactive watcher ────────────────────────────────────
2648            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                        // Only proactively suggest a fix if the user isn't actively typing.
2656                        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            // ── Inference / agent events ──────────────────────────────────────
2690            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                        // ONLY speak if not muted
2710                        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                        // In chat mode, suppress tool noise from the main chat surface.
2716                        if app.workflow_mode != "CHAT" {
2717                            let display = format!("( )  {} {}", name, args);
2718                            app.push_message("Tool", &display);
2719                        }
2720                        // Always track in active context regardless of mode
2721                        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                        // In chat mode, suppress tool results from main chat.
2735                        // Errors still show so the user knows something went wrong.
2736                        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                        // If it was a read or write, we can extract the path from the app.active_context "Running" entries
2744                        // but it's simpler to just let Specular handle the indexing or update here if we had the path.
2745
2746                        // Remove "Running" tools from context list
2747                        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                        // Calculate discounted cost for this turn.
2770                        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                        // Clear single-agent task bars on completion
2785                        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                        // Self-Destruct Sequence: Graceful exit matching Ctrl+Q behavior
2796                        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                        // Fire a one-shot warning when crossing 70% or 90%.
2859                        // Reset warned_level to 0 when pressure drops back below 60%
2860                        // so warnings re-fire if context fills up again after a /new.
2861                        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                        // Replace the default placeholder entries with what the
2939                        // Vein actually surfaced for this turn.
2940                        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                        // Stream shell output into the SPECULAR side panel as it
2966                        // arrives so the operator sees live progress.
2967                        app.current_thought.push_str(&line);
2968                        app.current_thought.push('\n');
2969                    }
2970                }
2971            }
2972
2973            // ── Swarm messages ────────────────────────────────────────────────
2974            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 => { /* Handled by 102 terminal message */ },
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                        // Workers now persist in SPECULAR until a new command is issued
2999                        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
3010// ── Render ────────────────────────────────────────────────────────────────────
3011
3012fn ui(f: &mut ratatui::Frame, app: &App) {
3013    let size = f.size();
3014    if size.width < 60 || size.height < 10 {
3015        // Render a minimal wait message or just clear if area is too collapsed
3016        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)]) // Fixed width sidebar prevents bleed
3034        .split(chunks[0]);
3035
3036    // ── Box 1: Dialogue ───────────────────────────────────────────────────────
3037    let mut core_lines = app.messages.clone();
3038
3039    // Show agent-running indicator as last line when active.
3040    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) // Cyan pulse for swarm
3053        } else {
3054            (200, 0, 200) // Magenta pulse for thinking
3055        };
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), "•") // Standby
3066    };
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    // Enhanced Scroll calculation.
3111    let avail_h = top[0].height.saturating_sub(2);
3112    // Borders (2) + Scrollbar (1) + explicit Padding (1) = 4.
3113    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            // TUI SCROLL FIX:
3122            // Exact calculation: how many times does line_w fit into inner_w?
3123            // This matches Paragraph's internal Wrap logic closely.
3124            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    // Clear the outer chunk and the inner dialogue area to prevent ghosting from previous frames or background renders.
3137    f.render_widget(Clear, top[0]);
3138
3139    // Create a sub-area for the dialogue with horizontal padding.
3140    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    // Scrollbar: content_length = max_scroll+1 so position==max_scroll puts the
3150    // thumb flush at the bottom (position == content_length - 1).
3151    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    // ── Box 2: Side panel ─────────────────────────────────────────────────────
3163    let side = Layout::default()
3164        .direction(Direction::Vertical)
3165        .constraints([
3166            Constraint::Length(8), // CONTEXT
3167            Constraint::Min(0),    // SPECULAR
3168        ])
3169        .split(top[1]);
3170
3171    // Pane 1: Context (Nervous focus)
3172    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            // Simple heuristic for "Tokens" (size / 4)
3186            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    // Optional: Add a Gauge for total context if tokens were tracked accurately.
3211    // For now, let's just make the CONTEXT pane look high-density.
3212
3213    // ── SPECULAR panel (Pane 2) ────────────────────────────────────────────────
3214    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    // Section: live thought (bounded to last 300 chars to avoid wall-of-text)
3225    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        // Show last 300 chars of current thought, split by line.
3235        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    // Section: worker progress bars
3257    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    // Section: last completed turn's reasoning
3306    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    // Section: specular event log
3320    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    // If auto-scroll is active, always show the bottom. Otherwise respect the
3353    // user's manual position (clamped so we never scroll past the content end).
3354    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    // Scrollbar for SPECULAR
3368    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    // ── Box 3: Status bar ─────────────────────────────────────────────────────
3380    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),     // MODE
3400            Constraint::Length(22), // LM + VN badge
3401            Constraint::Length(12), // BUD
3402            Constraint::Length(12), // CMP
3403            Constraint::Length(16), // REMOTE
3404            Constraint::Length(28), // TOKENS
3405            Constraint::Length(28), // VRAM
3406        ]
3407    } else {
3408        vec![
3409            Constraint::Length(12), // NAME
3410            Constraint::Min(0),     // MODE
3411            Constraint::Length(22), // LM + VN badge
3412            Constraint::Length(12), // BUD
3413            Constraint::Length(12), // CMP
3414            Constraint::Length(16), // REMOTE
3415            Constraint::Length(28), // TOKENS
3416            Constraint::Length(28), // VRAM
3417        ]
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    // ── Remote status indicator ──────────────────────────────────────────────
3552    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    // ── VRAM gauge (live from nvidia-smi poller) ─────────────────────────────
3625    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)), // Added extra padding for visual excellence
3647        bar_chunks[vram_idx],
3648    );
3649
3650    // ── Box 4: Input ──────────────────────────────────────────────────────────
3651    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))); // Deeper soil rich background
3664
3665    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    // Hardware Cursor (Managed by terminal emulator for smooth asynchronous blink)
3675    // Hardware Cursor (Managed by terminal emulator for smooth asynchronous blink)
3676    // Always call set_cursor during standard operation to "park" the cursor safely in the input box,
3677    // preventing it from jittering to (0,0) (the top-left title) during modal reviews.
3678    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    // ── High-risk approval modal ───────────────────────────────────────────────
3686    if let Some(approval) = &app.awaiting_approval {
3687        let is_diff_preview = approval.diff.is_some();
3688
3689        // Taller modal for diff preview so more lines are visible.
3690        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), // Header: Title + Instructions
3698                Constraint::Min(0),    // Body: Tool + diff/command
3699            ])
3700            .split(area);
3701
3702        // ── Modal Header ─────────────────────────────────────────────────────
3703        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        // ── Modal Body ───────────────────────────────────────────────────────
3740        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            // Render colored diff lines
3749            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                // For mutations, show the original display (e.g. path) as extra info
3830            }
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    // ── Swarm diff review modal ────────────────────────────────────────────────
3845    if let Some(review) = &app.active_review {
3846        draw_diff_review(f, review);
3847    }
3848
3849    // ── Autocomplete Hatch (Floating Popup) ──────────────────────────────────
3850    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        // Optional "More matches..." indicator
3890        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
3905// ── Helpers ───────────────────────────────────────────────────────────────────
3906
3907fn 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
3978/// Minimal markdown → ratatui spans for the SPECULAR panel.
3979/// Handles: # headers, **bold**, `code`, - bullet, > blockquote, plain text.
3980fn render_markdown_line(raw: &str) -> Vec<Line<'static>> {
3981    // 1. Strip ANSI and control noise first to verify content.
3982    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    // 2. Strip thought tags.
3989    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    // # Heading (all levels → bold white)
4000    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    // > blockquote
4012    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    // - / * bullet
4028    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    // Plain line with possible inline markdown
4036    let spans = inline_markdown(trimmed);
4037    vec![Line::from(spans)]
4038}
4039
4040/// Inline markdown for The Core chat window (brighter palette than SPECULAR).
4041fn 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
4084/// Parse inline `**bold**` and `` `code` `` — shared by SPECULAR and Core renderers.
4085fn 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
4128// ── Splash Screen ─────────────────────────────────────────────────────────────
4129
4130fn 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        // Clear with a dark background
4148        f.render_widget(
4149            Block::default().style(Style::default().bg(Color::Black)),
4150            area,
4151        );
4152
4153        // Total content height: logo(6) + spacer(1) + version(1) + tagline(1) + author(1) + spacer(2) + prompt(1) = 13
4154        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        // Top padding
4160        for _ in 0..top_pad {
4161            lines.push(Line::raw(""));
4162        }
4163
4164        // Logo lines — centered horizontally
4165        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        // Spacer
4173        lines.push(Line::raw(""));
4174
4175        // Version
4176        lines.push(Line::from(vec![Span::styled(
4177            format!("v{}", version),
4178            Style::default().fg(Color::DarkGray),
4179        )]));
4180
4181        // Tagline
4182        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        // Developer credit
4190        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        // Spacer
4196        lines.push(Line::raw(""));
4197        lines.push(Line::raw(""));
4198
4199        // Prompt
4200        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    // 1. First Pass: Strip ANSI escape codes that cause "shattering" in layout.
4225    let cleaned = strip_ansi(text);
4226
4227    // 2. Second Pass: Filter heuristic noise.
4228    let mut lines = Vec::new();
4229    for line in cleaned.lines() {
4230        // Strip multi-line "LF replaced by CRLF" noise frequently emitted by git/shell on Windows.
4231        if CRLF_REGEX.is_match(line) {
4232            continue;
4233        }
4234        // Strip git checkout/file update noise if it's too repetitive.
4235        if line.contains("Updating files:") && line.contains("%") {
4236            continue;
4237        }
4238        // Strip random terminal control characters that might have escaped.
4239        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}