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 stop_requested: bool,
156    pub current_thought: String,
157    pub professional: bool,
158    pub last_reasoning: String,
159    pub active_context: Vec<ContextFile>,
160    pub manual_scroll_offset: Option<u16>,
161    /// Channel to send user messages to the agent task.
162    pub user_input_tx: tokio::sync::mpsc::Sender<UserTurn>,
163    pub specular_scroll: u16,
164    /// When true the SPECULAR panel snaps to the bottom every frame.
165    /// Set false when the user manually scrolls up; reset true on new turn / Done.
166    pub specular_auto_scroll: bool,
167    /// Shared GPU VRAM state (polled in background).
168    pub gpu_state: Arc<GpuState>,
169    /// Shared Git remote state (polled in background).
170    pub git_state: Arc<crate::agent::git_monitor::GitState>,
171    /// Track the last time a character or paste arrived to detect "fast streams" (pasting).
172    pub last_input_time: std::time::Instant,
173    pub cancel_token: Arc<std::sync::atomic::AtomicBool>,
174    pub total_tokens: usize,
175    pub current_session_cost: f64,
176    pub model_id: String,
177    pub context_length: usize,
178    prompt_pressure_percent: u8,
179    prompt_estimated_input_tokens: usize,
180    prompt_reserved_output_tokens: usize,
181    prompt_estimated_total_tokens: usize,
182    compaction_percent: u8,
183    compaction_estimated_tokens: usize,
184    compaction_threshold_tokens: usize,
185    /// Tracks the highest threshold crossed for compaction warnings (70, 90).
186    /// Prevents re-firing the same warning every update tick.
187    compaction_warned_level: u8,
188    last_runtime_profile_time: Instant,
189    vein_file_count: usize,
190    vein_embedded_count: usize,
191    vein_docs_only: bool,
192    provider_state: ProviderRuntimeState,
193    last_provider_summary: String,
194    mcp_state: McpRuntimeState,
195    last_mcp_summary: String,
196    last_operator_checkpoint_state: OperatorCheckpointState,
197    last_operator_checkpoint_summary: String,
198    last_recovery_recipe_summary: String,
199    /// Mirrors ConversationManager::think_mode for status bar display.
200    /// None = auto, Some(true) = /think, Some(false) = /no_think.
201    pub think_mode: Option<bool>,
202    /// Sticky user-facing workflow mode.
203    pub workflow_mode: String,
204    /// [Autocomplete Hatch] List of matching project files.
205    pub autocomplete_suggestions: Vec<String>,
206    /// [Autocomplete Hatch] Index of the currently highlighted suggestion.
207    pub selected_suggestion: usize,
208    /// [Autocomplete Hatch] Whether the suggestions popup is visible.
209    pub show_autocomplete: bool,
210    /// [Autocomplete Hatch] The search fragment after the '@' symbol.
211    pub autocomplete_filter: String,
212    /// [Strategist] The currently active task from TASK.md.
213    pub current_objective: String,
214    /// [Voice of Hematite] Local TTS manager.
215    pub voice_manager: Arc<crate::ui::voice::VoiceManager>,
216    pub voice_loading: bool,
217    pub voice_loading_progress: f64,
218    /// [Autocomplete Hatch] True if the current scan is rooted in a sovereign folder.
219    pub autocomplete_alias_active: bool,
220    /// If false, the VRAM watchdog is silenced.
221    pub hardware_guard_enabled: bool,
222    /// Wall-clock time when this session started (for report timestamp).
223    pub session_start: std::time::SystemTime,
224    /// The current Rusty companion's species name — shown in the footer.
225    pub soul_name: String,
226    /// File attached via /attach — injected as context prefix on the next turn, then cleared.
227    pub attached_context: Option<(String, String)>,
228    pub attached_image: Option<AttachedImage>,
229    hovered_input_action: Option<InputAction>,
230    pub teleported_from: Option<String>,
231    /// Numbered directory list from the last /ls call — used by /ls <N> to teleport.
232    pub nav_list: Vec<std::path::PathBuf>,
233    /// When true, all ApprovalRequired events are auto-approved for the rest of the session.
234    /// Activated by pressing [A] ("Accept All") on any approval dialog.
235    pub auto_approve_session: bool,
236}
237
238impl App {
239    pub fn reset_active_context(&mut self) {
240        self.active_context = default_active_context();
241    }
242
243    pub fn record_error(&mut self) {
244        self.stats.debugging = self.stats.debugging.saturating_add(1);
245    }
246
247    pub fn reset_error_count(&mut self) {
248        self.stats.debugging = 0;
249    }
250
251    pub fn reset_runtime_status_memory(&mut self) {
252        self.last_provider_summary.clear();
253        self.last_mcp_summary.clear();
254        self.last_operator_checkpoint_summary.clear();
255        self.last_operator_checkpoint_state = OperatorCheckpointState::Idle;
256        self.last_recovery_recipe_summary.clear();
257    }
258
259    pub fn clear_pending_attachments(&mut self) {
260        self.attached_context = None;
261        self.attached_image = None;
262    }
263
264    pub fn push_message(&mut self, speaker: &str, content: &str) {
265        let filtered = filter_tui_noise(content);
266        if filtered.is_empty() && !content.is_empty() {
267            return;
268        } // Completely suppressed noise
269
270        self.messages_raw.push((speaker.to_string(), filtered));
271        // Cap raw history to prevent UI lag.
272        if self.messages_raw.len() > 500 {
273            self.messages_raw.remove(0);
274        }
275        self.rebuild_formatted_messages();
276        // Cap visual history.
277        if self.messages.len() > 8192 {
278            let to_drain = self.messages.len() - 8192;
279            self.messages.drain(0..to_drain);
280        }
281    }
282
283    pub fn update_last_message(&mut self, token: &str) {
284        if let Some(last_raw) = self.messages_raw.last_mut() {
285            if last_raw.0 == "Hematite" {
286                last_raw.1.push_str(token);
287                // Explicitly treat the last assistant message as "dirty" and repaint
288                // so the TUI can reliably snap to the newest Hematite message.
289                self.rebuild_formatted_messages();
290            }
291        }
292    }
293
294    fn rebuild_formatted_messages(&mut self) {
295        self.messages.clear();
296        let total = self.messages_raw.len();
297        for (i, (speaker, content)) in self.messages_raw.iter().enumerate() {
298            let is_last = i == total - 1;
299            let formatted = self.format_message(speaker, content, is_last);
300            self.messages.extend(formatted);
301            // Add a single blank line between messages for breathing room.
302            // Never add this to the very last message so it remains flush with the bottom.
303            if !is_last {
304                self.messages.push(Line::raw(""));
305            }
306        }
307    }
308
309    fn format_message(&self, speaker: &str, content: &str, _is_last: bool) -> Vec<Line<'static>> {
310        let mut lines = Vec::new();
311        // Hematite = rust iron-oxide brown; You = green; Tool = cyan
312        let rust = Color::Rgb(180, 90, 50);
313        let style = match speaker {
314            "You" => Style::default()
315                .fg(Color::Green)
316                .add_modifier(Modifier::BOLD),
317            "Hematite" => Style::default().fg(rust).add_modifier(Modifier::BOLD),
318            "Tool" => Style::default().fg(Color::Cyan),
319            _ => Style::default().fg(Color::DarkGray),
320        };
321
322        // Aggressive trim to avoid leading/trailing blank rows.
323        let cleaned = crate::agent::inference::strip_think_blocks(content)
324            .trim()
325            .to_string();
326        let cleaned = strip_ghost_prefix(&cleaned);
327
328        let mut is_first = true;
329        for raw_line in cleaned.lines() {
330            // SPACING FIX:
331            // If we have a sequence of blank lines, don't label them with "  ".
332            // Only add labels to lines that have content OR are the very first line of the message.
333            if !is_first && raw_line.trim().is_empty() {
334                lines.push(Line::raw(""));
335                continue;
336            }
337
338            let label = if is_first {
339                format!("{}: ", speaker)
340            } else {
341                "  ".to_string()
342            };
343
344            // System messages with "+N -N" stat tokens get inline green/red coloring.
345            if speaker == "System" && (raw_line.contains(" +") || raw_line.contains(" -")) {
346                let mut spans: Vec<Span<'static>> =
347                    vec![Span::raw(" "), Span::styled(label, style)];
348                // Tokenise on whitespace, colouring +digits green, -digits red,
349                // and file paths (containing '/' or '.') bright white.
350                for token in raw_line.split_whitespace() {
351                    let is_add = token.starts_with('+')
352                        && token.len() > 1
353                        && token[1..].chars().all(|c| c.is_ascii_digit());
354                    let is_rem = token.starts_with('-')
355                        && token.len() > 1
356                        && token[1..].chars().all(|c| c.is_ascii_digit());
357                    let is_path =
358                        (token.contains('/') || token.contains('\\') || token.contains('.'))
359                            && !token.starts_with('+')
360                            && !token.starts_with('-')
361                            && !token.ends_with(':');
362                    let span = if is_add {
363                        Span::styled(
364                            format!("{} ", token),
365                            Style::default()
366                                .fg(Color::Green)
367                                .add_modifier(Modifier::BOLD),
368                        )
369                    } else if is_rem {
370                        Span::styled(
371                            format!("{} ", token),
372                            Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
373                        )
374                    } else if is_path {
375                        Span::styled(
376                            format!("{} ", token),
377                            Style::default()
378                                .fg(Color::White)
379                                .add_modifier(Modifier::BOLD),
380                        )
381                    } else {
382                        Span::raw(format!("{} ", token))
383                    };
384                    spans.push(span);
385                }
386                lines.push(Line::from(spans));
387                is_first = false;
388                continue;
389            }
390
391            if speaker == "Tool"
392                && (raw_line.starts_with("-")
393                    || raw_line.starts_with("+")
394                    || raw_line.starts_with("@@"))
395            {
396                let line_style = if raw_line.starts_with("-") {
397                    Style::default().fg(Color::Red)
398                } else if raw_line.starts_with("+") {
399                    Style::default().fg(Color::Green)
400                } else {
401                    Style::default()
402                        .fg(Color::Yellow)
403                        .add_modifier(Modifier::DIM)
404                };
405                lines.push(Line::from(vec![
406                    Span::raw("    "), // Deeper indent for diffs
407                    Span::styled(raw_line.to_string(), line_style),
408                ]));
409            } else {
410                let mut spans = vec![Span::raw(" "), Span::styled(label, style)];
411                // Render inline markdown for Hematite responses; plain text for others.
412                // Code fence lines (``` or ```rust etc.) are rendered as plain dim text
413                // rather than passed through inline_markdown_core, which would misparse
414                // the backticks as inline code spans and garble the layout.
415                if speaker == "Hematite" {
416                    if raw_line.trim_start().starts_with("```") {
417                        spans.push(Span::styled(
418                            raw_line.to_string(),
419                            Style::default().fg(Color::DarkGray),
420                        ));
421                    } else {
422                        spans.extend(inline_markdown_core(raw_line));
423                    }
424                } else {
425                    spans.push(Span::raw(raw_line.to_string()));
426                }
427                lines.push(Line::from(spans));
428            }
429            is_first = false;
430        }
431
432        lines
433    }
434
435    /// [Intelli-Hematite] Live scan of the workspace to populate autocomplete.
436    /// Excludes common noisy directories like target, node_modules, .git.
437    pub fn update_autocomplete(&mut self) {
438        self.autocomplete_alias_active = false;
439        let (scan_root, query) = if let Some(pos) = self.input.rfind('@') {
440            let fragment = &self.input[pos + 1..];
441            let upper = fragment.to_uppercase();
442
443            // ── Path Alias Scan ──────────────────────────────────────────────
444            // If the fragment starts with a known shortcut, jump the scan root.
445            let mut resolved_root = crate::tools::file_ops::workspace_root();
446            let mut final_query = fragment;
447
448            let tokens = [
449                "DESKTOP",
450                "DOWNLOADS",
451                "DOCUMENTS",
452                "PICTURES",
453                "VIDEOS",
454                "MUSIC",
455                "HOME",
456            ];
457            for token in tokens {
458                if upper.starts_with(token) {
459                    let candidate =
460                        crate::tools::file_ops::resolve_candidate(&format!("@{}", token));
461                    if candidate.exists() {
462                        resolved_root = candidate;
463                        self.autocomplete_alias_active = true;
464                        // Strip the token from the query so we match files inside the target
465                        if let Some(slash_pos) = fragment.find('/') {
466                            final_query = &fragment[slash_pos + 1..];
467                        } else {
468                            final_query = ""; // Just browsing the token root
469                        }
470                        break;
471                    }
472                }
473            }
474            (resolved_root, final_query.to_lowercase())
475        } else {
476            (crate::tools::file_ops::workspace_root(), "".to_string())
477        };
478
479        self.autocomplete_filter = query.clone();
480        let mut matches = Vec::new();
481        let mut total_found = 0;
482
483        // ── Noise Suppression List ───────────────────────────────────────────
484        let noise = [
485            "node_modules",
486            "target",
487            ".git",
488            ".next",
489            ".venv",
490            "venv",
491            "env",
492            "bin",
493            "obj",
494            "dist",
495            "vendor",
496            "__pycache__",
497            "AppData",
498            "Local",
499            "Roaming",
500            "Application Data",
501        ];
502
503        for entry in WalkDir::new(&scan_root)
504            .max_depth(4) // Prevent deep system dives
505            .into_iter()
506            .filter_entry(|e| {
507                let name = e.file_name().to_string_lossy();
508                !name.starts_with('.') && !noise.iter().any(|&n| name.eq_ignore_ascii_case(n))
509            })
510            .flatten()
511        {
512            let is_file = entry.file_type().is_file();
513            let is_dir = entry.file_type().is_dir();
514
515            if (is_file || is_dir) && entry.path() != scan_root {
516                let path = entry
517                    .path()
518                    .strip_prefix(&scan_root)
519                    .unwrap_or(entry.path());
520                let mut path_str = path.to_string_lossy().to_string();
521
522                if is_dir {
523                    path_str.push('/');
524                }
525
526                if path_str.to_lowercase().contains(&query) || query.is_empty() {
527                    total_found += 1;
528                    if matches.len() < 15 {
529                        matches.push(path_str);
530                    }
531                }
532            }
533            if total_found > 60 {
534                break;
535            } // Tighter safety cap
536        }
537
538        // Prioritize: Directories and source files (.rs, .md) at the top
539        matches.sort_by(|a, b| {
540            let a_is_dir = a.ends_with('/');
541            let b_is_dir = b.ends_with('/');
542
543            let a_ext = a.split('.').last().unwrap_or("");
544            let b_ext = b.split('.').last().unwrap_or("");
545            let a_is_src = a_ext == "rs" || a_ext == "md";
546            let b_is_src = b_ext == "rs" || b_ext == "md";
547
548            let a_score = if a_is_dir {
549                2
550            } else if a_is_src {
551                1
552            } else {
553                0
554            };
555            let b_score = if b_is_dir {
556                2
557            } else if b_is_src {
558                1
559            } else {
560                0
561            };
562
563            b_score.cmp(&a_score)
564        });
565
566        self.autocomplete_suggestions = matches;
567        self.selected_suggestion = self
568            .selected_suggestion
569            .min(self.autocomplete_suggestions.len().saturating_sub(1));
570    }
571
572    /// [Intelli-Hematite] Applies an autocomplete selection back to the input bar.
573    /// Implements Smart Splicing to handle path aliases (@DESKTOP/) vs global scans.
574    pub fn apply_autocomplete_selection(&mut self, selection: &str) {
575        if let Some(pos) = self.input.rfind('@') {
576            if self.autocomplete_alias_active {
577                // Splicing for @ALIAS/path
578                // Truncate to the last slash AFTER the @ if it exists
579                let after_at = &self.input[pos + 1..];
580                if let Some(slash_pos) = after_at.rfind('/') {
581                    self.input.truncate(pos + 1 + slash_pos + 1);
582                } else {
583                    // No slash yet, truncate to @ + 1
584                    self.input.truncate(pos + 1);
585                }
586            } else {
587                // Splicing for global scan: replace the @ entirely
588                self.input.truncate(pos);
589            }
590            self.input.push_str(selection);
591            self.show_autocomplete = false;
592        }
593    }
594
595    /// [Intelli-Hematite] Update the context strategy deck with real file data.
596    pub fn push_context_file(&mut self, path: String, status: String) {
597        self.active_context.retain(|f| f.path != path);
598
599        let root = crate::tools::file_ops::workspace_root();
600        let full_path = root.join(&path);
601        let size = std::fs::metadata(full_path).map(|m| m.len()).unwrap_or(0);
602
603        self.active_context.push(ContextFile { path, size, status });
604
605        if self.active_context.len() > 10 {
606            self.active_context.remove(0);
607        }
608    }
609
610    /// [Task Analyzer] Parse TASK.md to find the current active goal.
611    pub fn update_objective(&mut self) {
612        let hdir = crate::tools::file_ops::hematite_dir();
613        let plan_path = hdir.join("PLAN.md");
614        if plan_path.exists() {
615            if let Some(plan) = crate::tools::plan::load_plan_handoff() {
616                if plan.has_signal() && !plan.goal.trim().is_empty() {
617                    self.current_objective = plan.summary_line();
618                    return;
619                }
620            }
621        }
622        let path = hdir.join("TASK.md");
623        if let Ok(content) = std::fs::read_to_string(path) {
624            for line in content.lines() {
625                let trimmed = line.trim();
626                // Match "- [ ]" or "- [/]"
627                if (trimmed.starts_with("- [ ]") || trimmed.starts_with("- [/]"))
628                    && trimmed.len() > 6
629                {
630                    self.current_objective = trimmed[6..].trim().to_string();
631                    return;
632                }
633            }
634        }
635        self.current_objective = "Idle".into();
636    }
637
638    /// [Auto-Diagnostic] Copy full session transcript to clipboard.
639    pub fn copy_specular_to_clipboard(&self) {
640        let mut out = String::from("=== SPECULAR LOG ===\n\n");
641
642        if !self.last_reasoning.is_empty() {
643            out.push_str("--- Last Reasoning Block ---\n");
644            out.push_str(&self.last_reasoning);
645            out.push_str("\n\n");
646        }
647
648        if !self.current_thought.is_empty() {
649            out.push_str("--- In-Progress Reasoning ---\n");
650            out.push_str(&self.current_thought);
651            out.push_str("\n\n");
652        }
653
654        if !self.specular_logs.is_empty() {
655            out.push_str("--- Specular Events ---\n");
656            for entry in &self.specular_logs {
657                out.push_str(entry);
658                out.push('\n');
659            }
660            out.push('\n');
661        }
662
663        out.push_str(&format!(
664            "Tokens: {} | Cost: ${:.4}\n",
665            self.total_tokens, self.current_session_cost
666        ));
667
668        let mut child = std::process::Command::new("clip.exe")
669            .stdin(std::process::Stdio::piped())
670            .spawn()
671            .expect("Failed to spawn clip.exe");
672        if let Some(mut stdin) = child.stdin.take() {
673            use std::io::Write;
674            let _ = stdin.write_all(out.as_bytes());
675        }
676        let _ = child.wait();
677    }
678
679    pub fn write_session_report(&self) {
680        let report_dir = crate::tools::file_ops::hematite_dir().join("reports");
681        if std::fs::create_dir_all(&report_dir).is_err() {
682            return;
683        }
684
685        // Timestamp from session_start
686        let start_secs = self
687            .session_start
688            .duration_since(std::time::UNIX_EPOCH)
689            .unwrap_or_default()
690            .as_secs();
691
692        // Simple epoch → YYYY-MM-DD_HH-MM-SS (UTC)
693        let secs_in_day = start_secs % 86400;
694        let days = start_secs / 86400;
695        let years_approx = (days * 4 + 2) / 1461;
696        let year = 1970 + years_approx;
697        let day_of_year = days - (years_approx * 365 + years_approx / 4);
698        let month = (day_of_year / 30 + 1).min(12);
699        let day = (day_of_year % 30 + 1).min(31);
700        let hh = secs_in_day / 3600;
701        let mm = (secs_in_day % 3600) / 60;
702        let ss = secs_in_day % 60;
703        let timestamp = format!(
704            "{:04}-{:02}-{:02}_{:02}-{:02}-{:02}",
705            year, month, day, hh, mm, ss
706        );
707
708        let duration_secs = std::time::SystemTime::now()
709            .duration_since(self.session_start)
710            .unwrap_or_default()
711            .as_secs();
712
713        let report_path = report_dir.join(format!("session_{}.json", timestamp));
714
715        let turns: Vec<serde_json::Value> = self
716            .messages_raw
717            .iter()
718            .map(|(speaker, text)| serde_json::json!({ "speaker": speaker, "text": text }))
719            .collect();
720
721        let report = serde_json::json!({
722            "session_start": timestamp,
723            "duration_secs": duration_secs,
724            "model": self.model_id,
725            "context_length": self.context_length,
726            "total_tokens": self.total_tokens,
727            "estimated_cost_usd": self.current_session_cost,
728            "turn_count": turns.len(),
729            "transcript": turns,
730        });
731
732        if let Ok(json) = serde_json::to_string_pretty(&report) {
733            let _ = std::fs::write(&report_path, json);
734        }
735    }
736
737    fn transcript_snapshot_for_copy(&self) -> (Vec<(String, String)>, bool) {
738        if !self.agent_running {
739            return (self.messages_raw.clone(), false);
740        }
741
742        if let Some(last_user_idx) = self
743            .messages_raw
744            .iter()
745            .rposition(|(speaker, _)| speaker == "You")
746        {
747            (
748                self.messages_raw[..=last_user_idx].to_vec(),
749                last_user_idx + 1 < self.messages_raw.len(),
750            )
751        } else {
752            (Vec::new(), !self.messages_raw.is_empty())
753        }
754    }
755
756    pub fn copy_transcript_to_clipboard(&self) {
757        let (snapshot, omitted_inflight) = self.transcript_snapshot_for_copy();
758        let mut history = snapshot
759            .iter()
760            .filter(|(speaker, content)| !should_skip_transcript_copy_entry(speaker, content))
761            .map(|m| format!("[{}] {}\n", m.0, m.1))
762            .collect::<String>();
763
764        if omitted_inflight {
765            history.push_str(
766                "[System] Current turn is still in progress; in-flight Hematite output was omitted from this clipboard snapshot.\n",
767            );
768        }
769
770        history.push_str("\nSession Stats\n");
771        history.push_str(&format!("Tokens: {}\n", self.total_tokens));
772        history.push_str(&format!("Cost: ${:.4}\n", self.current_session_cost));
773
774        copy_text_to_clipboard(&history);
775    }
776
777    pub fn copy_clean_transcript_to_clipboard(&self) {
778        let (snapshot, omitted_inflight) = self.transcript_snapshot_for_copy();
779        let mut history = snapshot
780            .iter()
781            .filter(|(speaker, content)| !should_skip_transcript_copy_entry(speaker, content))
782            .map(|m| format!("[{}] {}\n", m.0, m.1))
783            .collect::<String>();
784
785        if omitted_inflight {
786            history.push_str(
787                "[System] Current turn is still in progress; in-flight Hematite output was omitted from this clipboard snapshot.\n",
788            );
789        }
790
791        history.push_str("\nSession Stats\n");
792        history.push_str(&format!("Tokens: {}\n", self.total_tokens));
793        history.push_str(&format!("Cost: ${:.4}\n", self.current_session_cost));
794
795        copy_text_to_clipboard(&history);
796    }
797
798    pub fn copy_last_reply_to_clipboard(&self) -> bool {
799        if let Some((speaker, content)) = self
800            .messages_raw
801            .iter()
802            .rev()
803            .find(|(speaker, content)| is_copyable_hematite_reply(speaker, content))
804        {
805            let cleaned = cleaned_copyable_reply_text(content);
806            let payload = format!("[{}] {}", speaker, cleaned);
807            copy_text_to_clipboard(&payload);
808            true
809        } else {
810            false
811        }
812    }
813}
814
815fn copy_text_to_clipboard(text: &str) {
816    if copy_text_to_clipboard_powershell(text) {
817        return;
818    }
819
820    // Fallback: Windows clip.exe is fast and dependency-free, but some
821    // terminal/clipboard paths can mangle non-ASCII punctuation.
822    let mut child = std::process::Command::new("clip.exe")
823        .stdin(std::process::Stdio::piped())
824        .spawn()
825        .expect("Failed to spawn clip.exe");
826
827    if let Some(mut stdin) = child.stdin.take() {
828        use std::io::Write;
829        let _ = stdin.write_all(text.as_bytes());
830    }
831    let _ = child.wait();
832}
833
834/// Capture the pixel rect of the current console window via a synchronous PowerShell call.
835/// Returns (x, y, width, height) in screen pixels.
836#[cfg(windows)]
837fn get_console_pixel_rect() -> Option<(i32, i32, i32, i32)> {
838    let script = concat!(
839        "Add-Type -TypeDefinition '",
840        "using System;using System.Runtime.InteropServices;",
841        "public class WG{",
842        "[DllImport(\"kernel32\")]public static extern IntPtr GetConsoleWindow();",
843        "[DllImport(\"user32\")]public static extern bool GetWindowRect(IntPtr h,out RECT r);",
844        "[StructLayout(LayoutKind.Sequential)]public struct RECT{public int L,T,R,B;}}",
845        "';",
846        "$h=[WG]::GetConsoleWindow();$r=New-Object WG+RECT;",
847        "[WG]::GetWindowRect($h,[ref]$r)|Out-Null;",
848        "Write-Output \"$($r.L) $($r.T) $($r.R-$r.L) $($r.B-$r.T)\""
849    );
850    let out = std::process::Command::new("powershell.exe")
851        .args(["-NoProfile", "-NonInteractive", "-Command", script])
852        .output()
853        .ok()?;
854    let s = String::from_utf8_lossy(&out.stdout);
855    let parts: Vec<i32> = s
856        .split_whitespace()
857        .filter_map(|v| v.trim().parse().ok())
858        .collect();
859    if parts.len() >= 4 {
860        Some((parts[0], parts[1], parts[2], parts[3]))
861    } else {
862        None
863    }
864}
865
866/// Find the shell/tab process that should be closed after teleporting away from
867/// the current session. In Windows Terminal we want the tab shell, not the
868/// terminal host process itself.
869#[cfg(windows)]
870fn get_console_close_target_pid_sync() -> Option<u32> {
871    let pid = std::process::id();
872    let script = format!(
873        r#"
874$current = [uint32]{pid}
875$seen = New-Object 'System.Collections.Generic.HashSet[uint32]'
876$shell_pattern = '^(cmd|powershell|pwsh|bash|sh|wsl|ubuntu|debian|kali|arch)$'
877$skip_pattern = '^(WindowsTerminal|wt|OpenConsole|conhost)$'
878$fallback = $null
879$found = $false
880while ($current -gt 0 -and $seen.Add($current)) {{
881    $proc = Get-CimInstance Win32_Process -Filter "ProcessId=$current" -ErrorAction SilentlyContinue
882    if (-not $proc) {{ break }}
883    $parent = [uint32]$proc.ParentProcessId
884    if ($parent -le 0) {{ break }}
885    $parent_proc = Get-Process -Id $parent -ErrorAction SilentlyContinue
886    if ($parent_proc) {{
887        $name = $parent_proc.ProcessName
888        if ($name -match $shell_pattern) {{
889            $found = $true
890            Write-Output $parent
891            break
892        }}
893        if (-not $fallback -and $name -notmatch $skip_pattern) {{
894            $fallback = $parent
895        }}
896    }}
897    $current = $parent
898}}
899if (-not $found -and $fallback) {{ Write-Output $fallback }}
900"#
901    );
902    let out = std::process::Command::new("powershell.exe")
903        .args(["-NoProfile", "-NonInteractive", "-Command", &script])
904        .output()
905        .ok()?;
906    String::from_utf8_lossy(&out.stdout).trim().parse().ok()
907}
908
909/// Spawns a new detached terminal window pre-navigated to `path`, running Hematite.
910/// - Writes a temp .bat file to avoid quoting issues with paths containing spaces
911/// - Matches the current window's pixel size and position
912/// - Skips the splash screen in the new session (`--no-splash`)
913/// - Closes the originating shell/tab after Hematite exits without killing the
914///   whole Windows Terminal host
915#[cfg(windows)]
916fn spawn_dive_in_terminal(path: &str) {
917    let pid = std::process::id();
918    let current_dir = std::env::current_dir()
919        .map(|p| p.to_string_lossy().to_string())
920        .unwrap_or_default();
921
922    let close_target_pid = get_console_close_target_pid_sync().unwrap_or(0);
923    let (px, py, pw, ph) = get_console_pixel_rect().unwrap_or((50, 50, 1100, 750));
924
925    let bat_path = std::env::temp_dir().join("hematite_teleport.bat");
926    let bat_content = format!(
927        "@echo off\r\ncd /d \"{p}\"\r\nhematite --no-splash --teleported-from \"{o}\"\r\n",
928        p = path.replace('"', ""),
929        o = current_dir.replace('"', ""),
930    );
931    if std::fs::write(&bat_path, bat_content).is_err() {
932        return;
933    }
934    let bat_str = bat_path.to_string_lossy().to_string();
935    let bat_ps = bat_str.replace('\'', "''");
936
937    let script = format!(
938        r#"
939Add-Type -TypeDefinition @'
940using System; using System.Runtime.InteropServices;
941public class WM {{ [DllImport("user32")] public static extern bool MoveWindow(IntPtr h,int x,int y,int w,int ht,bool b); }}
942'@
943$proc = Start-Process cmd.exe -ArgumentList @('/k', '"{bat}"') -PassThru
944$deadline = (Get-Date).AddSeconds(8)
945while ((Get-Date) -lt $deadline -and $proc.MainWindowHandle -eq [IntPtr]::Zero) {{ Start-Sleep -Milliseconds 100 }}
946if ($proc.MainWindowHandle -ne [IntPtr]::Zero) {{
947    [WM]::MoveWindow($proc.MainWindowHandle, {px}, {py}, {pw}, {ph}, $true) | Out-Null
948}}
949Wait-Process -Id {pid} -ErrorAction SilentlyContinue
950if ({close_pid} -gt 0) {{
951    Stop-Process -Id {close_pid} -Force -ErrorAction SilentlyContinue
952}}
953"#,
954        bat = bat_ps,
955        px = px,
956        py = py,
957        pw = pw,
958        ph = ph,
959        pid = pid,
960        close_pid = close_target_pid,
961    );
962
963    let _ = std::process::Command::new("powershell.exe")
964        .args([
965            "-NoProfile",
966            "-NonInteractive",
967            "-WindowStyle",
968            "Hidden",
969            "-Command",
970            &script,
971        ])
972        .spawn();
973}
974
975#[cfg(not(windows))]
976fn spawn_dive_in_terminal(_path: &str) {}
977
978fn copy_text_to_clipboard_powershell(text: &str) -> bool {
979    let temp_path = std::env::temp_dir().join(format!(
980        "hematite-clipboard-{}-{}.txt",
981        std::process::id(),
982        std::time::SystemTime::now()
983            .duration_since(std::time::UNIX_EPOCH)
984            .map(|d| d.as_millis())
985            .unwrap_or_default()
986    ));
987
988    if std::fs::write(&temp_path, text.as_bytes()).is_err() {
989        return false;
990    }
991
992    let escaped_path = temp_path.display().to_string().replace('\'', "''");
993    let script = format!(
994        "$t = Get-Content -LiteralPath '{}' -Raw -Encoding UTF8; Set-Clipboard -Value $t",
995        escaped_path
996    );
997
998    let status = std::process::Command::new("powershell.exe")
999        .args(["-NoProfile", "-NonInteractive", "-Command", &script])
1000        .status();
1001
1002    let _ = std::fs::remove_file(&temp_path);
1003
1004    matches!(status, Ok(code) if code.success())
1005}
1006
1007fn is_immediate_local_command(input: &str) -> bool {
1008    matches!(
1009        input.trim().to_ascii_lowercase().as_str(),
1010        "/copy" | "/copy-last" | "/copy-clean" | "/copy2"
1011    )
1012}
1013
1014fn should_skip_transcript_copy_entry(speaker: &str, content: &str) -> bool {
1015    if speaker != "System" {
1016        return false;
1017    }
1018
1019    content.starts_with("Hematite Commands:\n")
1020        || content.starts_with("Document note: `/attach`")
1021        || content == "Chat transcript copied to clipboard."
1022        || content == "Exact session transcript copied to clipboard (includes help/system output)."
1023        || content == "Clean chat transcript copied to clipboard (skips help/debug boilerplate)."
1024        || content == "Latest Hematite reply copied to clipboard."
1025        || content == "SPECULAR log copied to clipboard (reasoning + events)."
1026        || content == "Cancellation requested. Logs copied to clipboard."
1027}
1028
1029fn is_copyable_hematite_reply(speaker: &str, content: &str) -> bool {
1030    if speaker != "Hematite" {
1031        return false;
1032    }
1033
1034    let trimmed = content.trim();
1035    if trimmed.is_empty() {
1036        return false;
1037    }
1038
1039    if trimmed == "Initialising Engine & Hardware..."
1040        || trimmed == "Swarm engaged."
1041        || trimmed.starts_with("Hematite v")
1042        || trimmed.starts_with("Swarm analyzing: '")
1043        || trimmed.ends_with("Standing by for review...")
1044        || trimmed.ends_with("conflict - review required.")
1045        || trimmed.ends_with("conflict — review required.")
1046    {
1047        return false;
1048    }
1049
1050    true
1051}
1052
1053fn cleaned_copyable_reply_text(content: &str) -> String {
1054    let cleaned = content
1055        .replace("<thought>", "")
1056        .replace("</thought>", "")
1057        .replace("<think>", "")
1058        .replace("</think>", "");
1059    strip_ghost_prefix(cleaned.trim()).trim().to_string()
1060}
1061
1062// ── run_app ───────────────────────────────────────────────────────────────────
1063
1064#[derive(Clone, Copy, PartialEq, Eq)]
1065enum InputAction {
1066    Stop,
1067    PickDocument,
1068    PickImage,
1069    Detach,
1070    New,
1071    Forget,
1072    Help,
1073}
1074
1075struct InputActionVisual {
1076    action: InputAction,
1077    label: String,
1078    style: Style,
1079}
1080
1081#[derive(Clone, Copy)]
1082enum AttachmentPickerKind {
1083    Document,
1084    Image,
1085}
1086
1087fn attach_document_from_path(app: &mut App, file_path: &str) {
1088    let p = std::path::Path::new(file_path);
1089    match crate::memory::vein::extract_document_text(p) {
1090        Ok(text) => {
1091            let name = p
1092                .file_name()
1093                .and_then(|n| n.to_str())
1094                .unwrap_or(file_path)
1095                .to_string();
1096            let preview_len = text.len().min(200);
1097            // Rough token estimate: ~4 chars per token.
1098            let estimated_tokens = text.len() / 4;
1099            let ctx = app.context_length.max(1);
1100            let budget_pct = (estimated_tokens * 100) / ctx;
1101            let budget_note = if budget_pct >= 75 {
1102                format!(
1103                    "\nWarning: this document is ~{} tokens (~{}% of your {}k context). \
1104                     Very little room left for conversation. Consider /attach on a shorter excerpt.",
1105                    estimated_tokens, budget_pct, ctx / 1000
1106                )
1107            } else if budget_pct >= 40 {
1108                format!(
1109                    "\nNote: this document is ~{} tokens (~{}% of your {}k context).",
1110                    estimated_tokens,
1111                    budget_pct,
1112                    ctx / 1000
1113                )
1114            } else {
1115                String::new()
1116            };
1117            app.push_message(
1118                "System",
1119                &format!(
1120                    "Attached document: {} ({} chars) for the next message.\nPreview: {}...{}",
1121                    name,
1122                    text.len(),
1123                    &text[..preview_len],
1124                    budget_note,
1125                ),
1126            );
1127            app.attached_context = Some((name, text));
1128        }
1129        Err(e) => {
1130            app.push_message("System", &format!("Attach failed: {}", e));
1131        }
1132    }
1133}
1134
1135fn attach_image_from_path(app: &mut App, file_path: &str) {
1136    let p = std::path::Path::new(file_path);
1137    match crate::tools::vision::encode_image_as_data_url(p) {
1138        Ok(_) => {
1139            let name = p
1140                .file_name()
1141                .and_then(|n| n.to_str())
1142                .unwrap_or(file_path)
1143                .to_string();
1144            app.push_message(
1145                "System",
1146                &format!("Attached image: {} for the next message.", name),
1147            );
1148            app.attached_image = Some(AttachedImage {
1149                name,
1150                path: file_path.to_string(),
1151            });
1152        }
1153        Err(e) => {
1154            app.push_message("System", &format!("Image attach failed: {}", e));
1155        }
1156    }
1157}
1158
1159fn is_document_path(path: &std::path::Path) -> bool {
1160    matches!(
1161        path.extension()
1162            .and_then(|e| e.to_str())
1163            .unwrap_or("")
1164            .to_ascii_lowercase()
1165            .as_str(),
1166        "pdf" | "md" | "markdown" | "txt" | "rst"
1167    )
1168}
1169
1170fn is_image_path(path: &std::path::Path) -> bool {
1171    matches!(
1172        path.extension()
1173            .and_then(|e| e.to_str())
1174            .unwrap_or("")
1175            .to_ascii_lowercase()
1176            .as_str(),
1177        "png" | "jpg" | "jpeg" | "gif" | "webp"
1178    )
1179}
1180
1181fn extract_pasted_path_candidates(content: &str) -> Vec<String> {
1182    let mut out = Vec::new();
1183    let trimmed = content.trim();
1184    if trimmed.is_empty() {
1185        return out;
1186    }
1187
1188    let mut in_quotes = false;
1189    let mut current = String::new();
1190    for ch in trimmed.chars() {
1191        if ch == '"' {
1192            if in_quotes && !current.trim().is_empty() {
1193                out.push(current.trim().to_string());
1194                current.clear();
1195            }
1196            in_quotes = !in_quotes;
1197            continue;
1198        }
1199        if in_quotes {
1200            current.push(ch);
1201        }
1202    }
1203    if !out.is_empty() {
1204        return out;
1205    }
1206
1207    for line in trimmed.lines() {
1208        let candidate = line.trim().trim_matches('"').trim();
1209        if !candidate.is_empty() {
1210            out.push(candidate.to_string());
1211        }
1212    }
1213
1214    if out.is_empty() {
1215        out.push(trimmed.trim_matches('"').to_string());
1216    }
1217    out
1218}
1219
1220fn try_attach_from_paste(app: &mut App, content: &str) -> bool {
1221    let mut attached_doc = false;
1222    let mut attached_image = false;
1223    let mut ignored_supported = 0usize;
1224
1225    for raw in extract_pasted_path_candidates(content) {
1226        let path = std::path::Path::new(&raw);
1227        if !path.exists() {
1228            continue;
1229        }
1230        if is_image_path(path) {
1231            if attached_image || app.attached_image.is_some() {
1232                ignored_supported += 1;
1233            } else {
1234                attach_image_from_path(app, &raw);
1235                attached_image = true;
1236            }
1237        } else if is_document_path(path) {
1238            if attached_doc || app.attached_context.is_some() {
1239                ignored_supported += 1;
1240            } else {
1241                attach_document_from_path(app, &raw);
1242                attached_doc = true;
1243            }
1244        }
1245    }
1246
1247    if ignored_supported > 0 {
1248        app.push_message(
1249            "System",
1250            &format!(
1251                "Ignored {} extra dropped file(s). Hematite currently keeps one pending document and one pending image.",
1252                ignored_supported
1253            ),
1254        );
1255    }
1256
1257    attached_doc || attached_image
1258}
1259
1260fn compute_input_height(total_width: u16, input_len: usize) -> u16 {
1261    let width = total_width.max(1) as usize;
1262    let approx_input_w = (width * 65 / 100).saturating_sub(4).max(1);
1263    let needed_lines = (input_len / approx_input_w) as u16 + 3;
1264    needed_lines.clamp(3, 10)
1265}
1266
1267fn input_rect_for_size(size: Rect, input_len: usize) -> Rect {
1268    let input_height = compute_input_height(size.width, input_len);
1269    Layout::default()
1270        .direction(Direction::Vertical)
1271        .constraints([
1272            Constraint::Min(0),
1273            Constraint::Length(input_height),
1274            Constraint::Length(3),
1275        ])
1276        .split(size)[1]
1277}
1278
1279fn input_title_area(input_rect: Rect) -> Rect {
1280    Rect {
1281        x: input_rect.x.saturating_add(1),
1282        y: input_rect.y,
1283        width: input_rect.width.saturating_sub(2),
1284        height: 1,
1285    }
1286}
1287
1288fn build_input_actions(app: &App) -> Vec<InputActionVisual> {
1289    let doc_label = if app.attached_context.is_some() {
1290        "Files*"
1291    } else {
1292        "Files"
1293    };
1294    let image_label = if app.attached_image.is_some() {
1295        "Image*"
1296    } else {
1297        "Image"
1298    };
1299    let detach_style = if app.attached_context.is_some() || app.attached_image.is_some() {
1300        Style::default()
1301            .fg(Color::Yellow)
1302            .add_modifier(Modifier::BOLD)
1303    } else {
1304        Style::default().fg(Color::DarkGray)
1305    };
1306
1307    let mut actions = Vec::new();
1308    if app.agent_running {
1309        actions.push(InputActionVisual {
1310            action: InputAction::Stop,
1311            label: "Stop Esc".to_string(),
1312            style: Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
1313        });
1314    } else {
1315        actions.push(InputActionVisual {
1316            action: InputAction::New,
1317            label: "New".to_string(),
1318            style: Style::default()
1319                .fg(Color::Green)
1320                .add_modifier(Modifier::BOLD),
1321        });
1322        actions.push(InputActionVisual {
1323            action: InputAction::Forget,
1324            label: "Forget".to_string(),
1325            style: Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
1326        });
1327    }
1328
1329    actions.push(InputActionVisual {
1330        action: InputAction::PickDocument,
1331        label: format!("{} ^O", doc_label),
1332        style: Style::default()
1333            .fg(Color::Cyan)
1334            .add_modifier(Modifier::BOLD),
1335    });
1336    actions.push(InputActionVisual {
1337        action: InputAction::PickImage,
1338        label: format!("{} ^I", image_label),
1339        style: Style::default()
1340            .fg(Color::Magenta)
1341            .add_modifier(Modifier::BOLD),
1342    });
1343    actions.push(InputActionVisual {
1344        action: InputAction::Detach,
1345        label: "Detach".to_string(),
1346        style: detach_style,
1347    });
1348    actions.push(InputActionVisual {
1349        action: InputAction::Help,
1350        label: "Help".to_string(),
1351        style: Style::default()
1352            .fg(Color::Blue)
1353            .add_modifier(Modifier::BOLD),
1354    });
1355    actions
1356}
1357
1358fn visible_input_actions(app: &App, max_width: u16) -> Vec<InputActionVisual> {
1359    let mut used = 0u16;
1360    let mut visible = Vec::new();
1361    for action in build_input_actions(app) {
1362        let chip_width = action.label.chars().count() as u16 + 2;
1363        let gap = if visible.is_empty() { 0 } else { 1 };
1364        if used + gap + chip_width > max_width {
1365            break;
1366        }
1367        used += gap + chip_width;
1368        visible.push(action);
1369    }
1370    visible
1371}
1372
1373fn input_status_text(app: &App) -> String {
1374    let voice_status = if app.voice_manager.is_enabled() {
1375        "ON"
1376    } else {
1377        "OFF"
1378    };
1379    let approvals_status = if app.yolo_mode { "OFF" } else { "ON" };
1380    let doc_status = if app.attached_context.is_some() {
1381        "DOC"
1382    } else {
1383        "--"
1384    };
1385    let image_status = if app.attached_image.is_some() {
1386        "IMG"
1387    } else {
1388        "--"
1389    };
1390    if app.agent_running {
1391        format!(
1392            "pending:{}:{} | voice:{}",
1393            doc_status, image_status, voice_status
1394        )
1395    } else {
1396        format!(
1397            "pending:{}:{} | voice:{} | appr:{} | Len:{}",
1398            doc_status,
1399            image_status,
1400            voice_status,
1401            approvals_status,
1402            app.input.len()
1403        )
1404    }
1405}
1406
1407fn visible_input_actions_for_title(app: &App, title_area: Rect) -> Vec<InputActionVisual> {
1408    let reserved = input_status_text(app).chars().count() as u16 + 3;
1409    let max_width = title_area.width.saturating_sub(reserved);
1410    visible_input_actions(app, max_width)
1411}
1412
1413fn input_action_hitboxes(app: &App, title_area: Rect) -> Vec<(InputAction, u16, u16)> {
1414    let mut x = title_area.x;
1415    let mut out = Vec::new();
1416    for action in visible_input_actions_for_title(app, title_area) {
1417        let chip_width = action.label.chars().count() as u16 + 2;
1418        out.push((action.action, x, x + chip_width.saturating_sub(1)));
1419        x = x.saturating_add(chip_width + 1);
1420    }
1421    out
1422}
1423
1424fn render_input_title(app: &App, title_area: Rect) -> Line<'static> {
1425    let mut spans = Vec::new();
1426    let actions = visible_input_actions_for_title(app, title_area);
1427    for (idx, action) in actions.into_iter().enumerate() {
1428        if idx > 0 {
1429            spans.push(Span::raw(" "));
1430        }
1431        let style = if app.hovered_input_action == Some(action.action) {
1432            action
1433                .style
1434                .bg(Color::Rgb(85, 48, 26))
1435                .add_modifier(Modifier::REVERSED)
1436        } else {
1437            action.style
1438        };
1439        spans.push(Span::styled(format!("[{}]", action.label), style));
1440    }
1441    let status = input_status_text(app);
1442    if !spans.is_empty() {
1443        spans.push(Span::raw(" | "));
1444    }
1445    spans.push(Span::styled(status, Style::default().fg(Color::DarkGray)));
1446    Line::from(spans)
1447}
1448
1449fn reset_visible_session_state(app: &mut App) {
1450    app.messages.clear();
1451    app.messages_raw.clear();
1452    app.last_reasoning.clear();
1453    app.current_thought.clear();
1454    app.specular_logs.clear();
1455    app.reset_error_count();
1456    app.reset_runtime_status_memory();
1457    app.reset_active_context();
1458    app.clear_pending_attachments();
1459    app.current_objective = "Idle".into();
1460}
1461
1462fn request_stop(app: &mut App) {
1463    app.voice_manager.stop();
1464    if app.stop_requested {
1465        return;
1466    }
1467    app.stop_requested = true;
1468    app.cancel_token
1469        .store(true, std::sync::atomic::Ordering::SeqCst);
1470    if app.thinking || app.agent_running {
1471        app.write_session_report();
1472        app.copy_transcript_to_clipboard();
1473        app.push_message(
1474            "System",
1475            "Cancellation requested. Logs copied to clipboard.",
1476        );
1477    }
1478}
1479
1480fn show_help_message(app: &mut App) {
1481    app.push_message(
1482        "System",
1483        "Hematite Commands:\n\
1484         /chat             - (Mode) Conversation mode - clean chat, no tool noise\n\
1485         /agent            - (Mode) Full coding harness + workstation mode - tools, file edits, builds, inspection\n\
1486         /reroll           - (Soul) Hatch a new companion mid-session\n\
1487         /auto             - (Flow) Let Hematite choose the narrowest effective workflow\n\
1488         /rules [view|edit]- (Meta) View status or edit local/shared project guidelines\n\
1489         /ask [prompt]     - (Flow) Read-only analysis mode; optional inline prompt\n\
1490         /code [prompt]    - (Flow) Explicit implementation mode; optional inline prompt\n\
1491         /architect [prompt] - (Flow) Plan-first mode; optional inline prompt\n\
1492         /implement-plan   - (Flow) Execute the saved architect handoff in /code\n\
1493         /read-only [prompt] - (Flow) Hard read-only mode; optional inline prompt\n\
1494         /teach [prompt]   - (Flow) Teacher mode; inspect machine then walk you through any admin task step-by-step\n\
1495         /new              - (Reset) Fresh task context; clear chat, pins, and task files\n\
1496         /forget           - (Wipe) Hard forget; purge saved memory and Vein index too\n\
1497         /cd <path>        - (Nav) Teleport to another directory and close this session; supports bare tokens like downloads, desktop, docs, home, temp, and ~\n\
1498         /ls [path|N]      - (Nav) List common locations or subdirectories; /ls <N> teleports to the numbered entry\n\
1499         /vein-inspect     - (Vein) Inspect indexed memory, hot files, and active room bias\n\
1500         /workspace-profile - (Profile) Show the auto-generated workspace profile\n\
1501         /rules            - (Rules) View behavioral guidelines (.hematite/rules.md)\n\
1502         /version          - (Build) Show the running Hematite version\n\
1503         /about            - (Info) Show author, repo, and product info\n\
1504         /vein-reset       - (Vein) Wipe the RAG index; rebuilds automatically on next turn\n\
1505         /clear            - (UI) Clear dialogue display only\n\
1506         /health           - (Diag) Run a synthesized plain-English system health report\n\
1507         /explain <text>   - (Help) Paste an error to get a non-technical breakdown\n\
1508         /gemma-native [auto|on|off|status] - (Model) Auto/force/disable Gemma 4 native formatting\n\
1509         /runtime-refresh  - (Model) Re-read LM Studio model + CTX now\n\
1510         /undo             - (Ghost) Revert last file change\n\
1511         /diff             - (Git) Show session changes (--stat)\n\
1512         /lsp              - (Logic) Start Language Servers (semantic intelligence)\n\
1513         /swarm <text>     - (Swarm) Spawn parallel workers on a directive\n\
1514         /worktree <cmd>   - (Isolated) Manage git worktrees (list|add|remove|prune)\n\
1515         /think            - (Brain) Enable deep reasoning mode\n\
1516         /no_think         - (Speed) Disable reasoning (3-5x faster responses)\n\
1517         /voice            - (TTS) List all available voices\n\
1518         /voice N          - (TTS) Select voice by number\n\
1519         /read <text>      - (TTS) Speak text aloud directly, bypassing the model. ESC to stop.\n\
1520         /explain <text>   - (Plain English) Paste any error or output; Hematite explains it in plain English.\n\
1521         /health           - (SysAdmin) Run a full system health report (disk, RAM, tools, recent errors).\n\
1522         /attach <path>    - (Docs) Attach a PDF/markdown/txt file for next message (PDF best-effort)\n\
1523         /attach-pick      - (Docs) Open a file picker and attach a document\n\
1524         /image <path>     - (Vision) Attach an image for the next message\n\
1525         /image-pick       - (Vision) Open a file picker and attach an image\n\
1526         /detach           - (Context) Drop pending document/image attachments\n\
1527         /copy             - (Debug) Copy exact session transcript (includes help/system output)\n\
1528         /copy-last        - (Debug) Copy the latest Hematite reply only\n\
1529         /copy-clean       - (Debug) Copy chat transcript without help/debug boilerplate\n\
1530         /copy2            - (Debug) Copy SPECULAR log to clipboard (reasoning + events)\n\
1531         \nHotkeys:\n\
1532         Ctrl+B - Toggle Brief Mode (minimal output)\n\
1533         Ctrl+P - Toggle Professional Mode (strip personality)\n\
1534         Ctrl+O - Open document picker for next-turn context\n\
1535         Ctrl+I - Open image picker for next-turn vision context\n\
1536         Ctrl+Y - Toggle Approvals Off (bypass safety approvals)\n\
1537         Ctrl+S - Quick Swarm (hardcoded bootstrap)\n\
1538         Ctrl+Z - Undo last edit\n\
1539         Ctrl+Q/C - Quit session\n\
1540         ESC    - Silence current playback\n\
1541         \nStatus Legend:\n\
1542         LM    - LM Studio runtime health (`LIVE`, `RECV`, `WARN`, `CEIL`, `STALE`, `BOOT`)\n\
1543         VN    - Vein RAG status (`SEM`=semantic active, `FTS`=BM25 only, `--`=not indexed)\n\
1544         BUD   - Total prompt-budget pressure against the live context window\n\
1545         CMP   - History compaction pressure against Hematite's adaptive threshold\n\
1546         ERR   - Session error count (runtime, tool, or SPECULAR failures)\n\
1547         CTX   - Live context window currently reported by LM Studio\n\
1548         VOICE - Local speech output state\n\
1549         \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\
1550         ",
1551    );
1552}
1553
1554#[allow(dead_code)]
1555fn show_help_message_legacy(app: &mut App) {
1556    app.push_message("System",
1557        "Hematite Commands:\n\
1558         /chat             — (Mode) Conversation mode — clean chat, no tool noise\n\
1559         /agent            — (Mode) Full coding harness + workstation mode — tools, file edits, builds, inspection\n\
1560         /reroll           — (Soul) Hatch a new companion mid-session\n\
1561         /auto             — (Flow) Let Hematite choose the narrowest effective workflow\n\
1562         /ask [prompt]     — (Flow) Read-only analysis mode; optional inline prompt\n\
1563         /code [prompt]    — (Flow) Explicit implementation mode; optional inline prompt\n\
1564         /architect [prompt] — (Flow) Plan-first mode; optional inline prompt\n\
1565         /implement-plan   — (Flow) Execute the saved architect handoff in /code\n\
1566         /read-only [prompt] — (Flow) Hard read-only mode; optional inline prompt\n\
1567         /teach [prompt]   — (Flow) Teacher mode; inspect machine then walk you through any admin task step-by-step\n\
1568           /new              — (Reset) Fresh task context; clear chat, pins, and task files\n\
1569           /forget           — (Wipe) Hard forget; purge saved memory and Vein index too\n\
1570           /vein-inspect     — (Vein) Inspect indexed memory, hot files, and active room bias\n\
1571           /workspace-profile — (Profile) Show the auto-generated workspace profile\n\
1572           /rules            — (Rules) View behavioral guidelines (.hematite/rules.md)\n\
1573           /version          — (Build) Show the running Hematite version\n\
1574           /about            — (Info) Show author, repo, and product info\n\
1575           /vein-reset       — (Vein) Wipe the RAG index; rebuilds automatically on next turn\n\
1576           /clear            — (UI) Clear dialogue display only\n\
1577         /health           — (Diag) Run a synthesized plain-English system health report\n\
1578         /explain <text>   — (Help) Paste an error to get a non-technical breakdown\n\
1579         /gemma-native [auto|on|off|status] — (Model) Auto/force/disable Gemma 4 native formatting\n\
1580         /runtime-refresh  — (Model) Re-read LM Studio model + CTX now\n\
1581         /undo             — (Ghost) Revert last file change\n\
1582         /diff             — (Git) Show session changes (--stat)\n\
1583         /lsp              — (Logic) Start Language Servers (semantic intelligence)\n\
1584         /swarm <text>     — (Swarm) Spawn parallel workers on a directive\n\
1585         /worktree <cmd>   — (Isolated) Manage git worktrees (list|add|remove|prune)\n\
1586         /think            — (Brain) Enable deep reasoning mode\n\
1587         /no_think         — (Speed) Disable reasoning (3-5x faster responses)\n\
1588         /voice            — (TTS) List all available voices\n\
1589         /voice N          — (TTS) Select voice by number\n\
1590         /read <text>      — (TTS) Speak text aloud directly, bypassing the model. ESC to stop.\n\
1591         /explain <text>   — (Plain English) Paste any error or output; Hematite explains it in plain English.\n\
1592         /health           — (SysAdmin) Run a full system health report (disk, RAM, tools, recent errors).\n\
1593         /attach <path>    — (Docs) Attach a PDF/markdown/txt file for next message\n\
1594         /attach-pick      — (Docs) Open a file picker and attach a document\n\
1595         /image <path>     — (Vision) Attach an image for the next message\n\
1596         /image-pick       — (Vision) Open a file picker and attach an image\n\
1597         /detach           — (Context) Drop pending document/image attachments\n\
1598         /copy             — (Debug) Copy session transcript to clipboard\n\
1599         /copy2            — (Debug) Copy SPECULAR log to clipboard (reasoning + events)\n\
1600         \nHotkeys:\n\
1601         Ctrl+B — Toggle Brief Mode (minimal output)\n\
1602         Ctrl+P — Toggle Professional Mode (strip personality)\n\
1603         Ctrl+O — Open document picker for next-turn context\n\
1604         Ctrl+I — Open image picker for next-turn vision context\n\
1605         Ctrl+Y — Toggle Approvals Off (bypass safety approvals)\n\
1606         Ctrl+S — Quick Swarm (hardcoded bootstrap)\n\
1607         Ctrl+Z — Undo last edit\n\
1608         Ctrl+Q/C — Quit session\n\
1609         ESC    — Silence current playback\n\
1610         \nStatus Legend:\n\
1611         LM    — LM Studio runtime health (`LIVE`, `RECV`, `WARN`, `CEIL`, `STALE`, `BOOT`)\n\
1612         VN    — Vein RAG status (`SEM`=semantic active, `FTS`=BM25 only, `--`=not indexed)\n\
1613         BUD   — Total prompt-budget pressure against the live context window\n\
1614         CMP   — History compaction pressure against Hematite's adaptive threshold\n\
1615         ERR   — Session error count (runtime, tool, or SPECULAR failures)\n\
1616         CTX   — Live context window currently reported by LM Studio\n\
1617         VOICE — Local speech output state\n\
1618         \nAssistant: Semantic Pathing (LSP), Vision Pass, Web Research, Swarm Synthesis"
1619    );
1620    app.push_message(
1621        "System",
1622        "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.",
1623    );
1624}
1625
1626fn trigger_input_action(app: &mut App, action: InputAction) {
1627    match action {
1628        InputAction::Stop => request_stop(app),
1629        InputAction::PickDocument => match pick_attachment_path(AttachmentPickerKind::Document) {
1630            Ok(Some(path)) => attach_document_from_path(app, &path),
1631            Ok(None) => app.push_message("System", "Document picker cancelled."),
1632            Err(e) => app.push_message("System", &e),
1633        },
1634        InputAction::PickImage => match pick_attachment_path(AttachmentPickerKind::Image) {
1635            Ok(Some(path)) => attach_image_from_path(app, &path),
1636            Ok(None) => app.push_message("System", "Image picker cancelled."),
1637            Err(e) => app.push_message("System", &e),
1638        },
1639        InputAction::Detach => {
1640            app.clear_pending_attachments();
1641            app.push_message(
1642                "System",
1643                "Cleared pending document/image attachments for the next turn.",
1644            );
1645        }
1646        InputAction::New => {
1647            if !app.agent_running {
1648                reset_visible_session_state(app);
1649                app.push_message("You", "/new");
1650                app.agent_running = true;
1651                let _ = app.user_input_tx.try_send(UserTurn::text("/new"));
1652            }
1653        }
1654        InputAction::Forget => {
1655            if !app.agent_running {
1656                app.cancel_token
1657                    .store(true, std::sync::atomic::Ordering::SeqCst);
1658                reset_visible_session_state(app);
1659                app.push_message("You", "/forget");
1660                app.agent_running = true;
1661                app.cancel_token
1662                    .store(false, std::sync::atomic::Ordering::SeqCst);
1663                let _ = app.user_input_tx.try_send(UserTurn::text("/forget"));
1664            }
1665        }
1666        InputAction::Help => show_help_message(app),
1667    }
1668}
1669
1670fn pick_attachment_path(kind: AttachmentPickerKind) -> Result<Option<String>, String> {
1671    #[cfg(target_os = "windows")]
1672    {
1673        let (title, filter) = match kind {
1674            AttachmentPickerKind::Document => (
1675                "Attach document for the next Hematite turn",
1676                "Documents|*.pdf;*.md;*.markdown;*.txt;*.rst|All Files|*.*",
1677            ),
1678            AttachmentPickerKind::Image => (
1679                "Attach image for the next Hematite turn",
1680                "Images|*.png;*.jpg;*.jpeg;*.gif;*.webp|All Files|*.*",
1681            ),
1682        };
1683        let script = format!(
1684            "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 }}"
1685        );
1686        let output = std::process::Command::new("powershell")
1687            .args(["-NoProfile", "-STA", "-Command", &script])
1688            .output()
1689            .map_err(|e| format!("File picker failed: {}", e))?;
1690        if !output.status.success() {
1691            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1692            return Err(if stderr.is_empty() {
1693                "File picker did not complete successfully.".to_string()
1694            } else {
1695                format!("File picker failed: {}", stderr)
1696            });
1697        }
1698        let selected = String::from_utf8_lossy(&output.stdout).trim().to_string();
1699        if selected.is_empty() {
1700            Ok(None)
1701        } else {
1702            Ok(Some(selected))
1703        }
1704    }
1705    #[cfg(target_os = "macos")]
1706    {
1707        let prompt = match kind {
1708            AttachmentPickerKind::Document => "Choose a document for the next Hematite turn",
1709            AttachmentPickerKind::Image => "Choose an image for the next Hematite turn",
1710        };
1711        let script = format!("POSIX path of (choose file with prompt \"{}\")", prompt);
1712        let output = std::process::Command::new("osascript")
1713            .args(["-e", &script])
1714            .output()
1715            .map_err(|e| format!("File picker failed: {}", e))?;
1716        if output.status.success() {
1717            let selected = String::from_utf8_lossy(&output.stdout).trim().to_string();
1718            if selected.is_empty() {
1719                Ok(None)
1720            } else {
1721                Ok(Some(selected))
1722            }
1723        } else {
1724            Ok(None)
1725        }
1726    }
1727    #[cfg(all(unix, not(target_os = "macos")))]
1728    {
1729        let title = match kind {
1730            AttachmentPickerKind::Document => "Attach document for the next Hematite turn",
1731            AttachmentPickerKind::Image => "Attach image for the next Hematite turn",
1732        };
1733        let output = std::process::Command::new("zenity")
1734            .args(["--file-selection", "--title", title])
1735            .output()
1736            .map_err(|e| format!("File picker failed: {}", e))?;
1737        if output.status.success() {
1738            let selected = String::from_utf8_lossy(&output.stdout).trim().to_string();
1739            if selected.is_empty() {
1740                Ok(None)
1741            } else {
1742                Ok(Some(selected))
1743            }
1744        } else {
1745            Ok(None)
1746        }
1747    }
1748}
1749
1750pub async fn run_app<B: Backend>(
1751    terminal: &mut Terminal<B>,
1752    mut specular_rx: Receiver<SpecularEvent>,
1753    mut agent_rx: Receiver<crate::agent::inference::InferenceEvent>,
1754    user_input_tx: tokio::sync::mpsc::Sender<UserTurn>,
1755    mut swarm_rx: Receiver<SwarmMessage>,
1756    swarm_tx: tokio::sync::mpsc::Sender<SwarmMessage>,
1757    swarm_coordinator: Arc<crate::agent::swarm::SwarmCoordinator>,
1758    last_interaction: Arc<Mutex<Instant>>,
1759    cockpit: crate::CliCockpit,
1760    soul: crate::ui::hatch::RustySoul,
1761    professional: bool,
1762    gpu_state: Arc<GpuState>,
1763    git_state: Arc<crate::agent::git_monitor::GitState>,
1764    cancel_token: Arc<std::sync::atomic::AtomicBool>,
1765    voice_manager: Arc<crate::ui::voice::VoiceManager>,
1766) -> Result<(), Box<dyn std::error::Error>> {
1767    let mut app = App {
1768        messages: Vec::new(),
1769        messages_raw: Vec::new(),
1770        specular_logs: Vec::new(),
1771        brief_mode: cockpit.brief,
1772        tick_count: 0,
1773        stats: RustyStats {
1774            debugging: 0,
1775            wisdom: soul.wisdom,
1776            patience: 100.0,
1777            chaos: soul.chaos,
1778            snark: soul.snark,
1779        },
1780        yolo_mode: cockpit.yolo,
1781        awaiting_approval: None,
1782        active_workers: HashMap::new(),
1783        worker_labels: HashMap::new(),
1784        active_review: None,
1785        input: String::new(),
1786        input_history: Vec::new(),
1787        history_idx: None,
1788        thinking: false,
1789        agent_running: false,
1790        stop_requested: false,
1791        current_thought: String::new(),
1792        professional,
1793        last_reasoning: String::new(),
1794        active_context: default_active_context(),
1795        manual_scroll_offset: None,
1796        user_input_tx,
1797        specular_scroll: 0,
1798        specular_auto_scroll: true,
1799        gpu_state,
1800        git_state,
1801        last_input_time: Instant::now(),
1802        cancel_token,
1803        total_tokens: 0,
1804        current_session_cost: 0.0,
1805        model_id: "detecting...".to_string(),
1806        context_length: 0,
1807        prompt_pressure_percent: 0,
1808        prompt_estimated_input_tokens: 0,
1809        prompt_reserved_output_tokens: 0,
1810        prompt_estimated_total_tokens: 0,
1811        compaction_percent: 0,
1812        compaction_estimated_tokens: 0,
1813        compaction_threshold_tokens: 0,
1814        compaction_warned_level: 0,
1815        last_runtime_profile_time: Instant::now(),
1816        vein_file_count: 0,
1817        vein_embedded_count: 0,
1818        vein_docs_only: false,
1819        provider_state: ProviderRuntimeState::Booting,
1820        last_provider_summary: String::new(),
1821        mcp_state: McpRuntimeState::Unconfigured,
1822        last_mcp_summary: String::new(),
1823        last_operator_checkpoint_state: OperatorCheckpointState::Idle,
1824        last_operator_checkpoint_summary: String::new(),
1825        last_recovery_recipe_summary: String::new(),
1826        think_mode: None,
1827        workflow_mode: "AUTO".into(),
1828        autocomplete_suggestions: Vec::new(),
1829        selected_suggestion: 0,
1830        show_autocomplete: false,
1831        autocomplete_filter: String::new(),
1832        current_objective: "Awaiting objective...".into(),
1833        voice_manager,
1834        voice_loading: false,
1835        voice_loading_progress: 1.0, // Pre-baked weights ready
1836        autocomplete_alias_active: false,
1837        hardware_guard_enabled: true,
1838        session_start: std::time::SystemTime::now(),
1839        soul_name: soul.species.clone(),
1840        attached_context: None,
1841        attached_image: None,
1842        hovered_input_action: None,
1843        teleported_from: cockpit.teleported_from.clone(),
1844        nav_list: Vec::new(),
1845        auto_approve_session: false,
1846    };
1847
1848    // Initial placeholder — streaming will overwrite this with hardware diagnostics
1849    app.push_message("Hematite", "Initialising Engine & Hardware...");
1850
1851    if let Some(origin) = &app.teleported_from {
1852        app.push_message(
1853            "System",
1854            &format!(
1855                "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?",
1856                origin
1857            ),
1858        );
1859    }
1860
1861    // ── Splash Screen ─────────────────────────────────────────────────────────
1862    // Blocking splash — user must press Enter to proceed.
1863    if !cockpit.no_splash {
1864        draw_splash(terminal)?;
1865        loop {
1866            if let Ok(Event::Key(key)) = event::read() {
1867                if key.kind == event::KeyEventKind::Press
1868                    && matches!(key.code, KeyCode::Enter | KeyCode::Char(' '))
1869                {
1870                    break;
1871                }
1872            }
1873        }
1874    }
1875
1876    if app.teleported_from.is_some()
1877        && crate::tools::plan::consume_teleport_resume_marker()
1878        && crate::tools::plan::load_plan_handoff().is_some()
1879    {
1880        app.workflow_mode = "CODE".into();
1881        app.thinking = true;
1882        app.agent_running = true;
1883        app.push_message(
1884            "System",
1885            "Teleport handoff detected in this project. Resuming from `.hematite/PLAN.md` automatically.",
1886        );
1887        app.push_message("You", "/implement-plan");
1888        let _ = app
1889            .user_input_tx
1890            .try_send(UserTurn::text("/implement-plan"));
1891    }
1892
1893    let mut event_stream = EventStream::new();
1894    let mut ticker = tokio::time::interval(std::time::Duration::from_millis(100));
1895
1896    loop {
1897        // ── Hardware Watchdog ──
1898        let vram_ratio = app.gpu_state.ratio();
1899        if app.hardware_guard_enabled && vram_ratio > 0.95 && !app.brief_mode {
1900            app.brief_mode = true;
1901            app.push_message(
1902                "System",
1903                "🚨 HARDWARE GUARD: VRAM > 95%. Brief Mode auto-enabled to prevent crash.",
1904            );
1905        }
1906
1907        terminal.draw(|f| ui(f, &app))?;
1908
1909        tokio::select! {
1910            _ = ticker.tick() => {
1911                // Increment voice loading progress (estimated 50s total load)
1912                if app.voice_loading && app.voice_loading_progress < 0.98 {
1913                    app.voice_loading_progress += 0.002;
1914                }
1915
1916                let workers = app.active_workers.len() as u64;
1917                let advance = if workers > 0 { workers * 4 + 1 } else { 1 };
1918                // Scale advance to match new 100ms tick (formerly 500ms)
1919                // We keep animations consistent by only advancing tick_count every 5 ticks or scaling.
1920                // Let's just increment every tick but use a larger modulo in animations.
1921                app.tick_count = app.tick_count.wrapping_add(advance);
1922                app.update_objective();
1923            }
1924
1925            // ── Keyboard / mouse input ────────────────────────────────────────
1926            maybe_event = event_stream.next() => {
1927                match maybe_event {
1928                    Some(Ok(Event::Mouse(mouse))) => {
1929                        use crossterm::event::{MouseButton, MouseEventKind};
1930                        let (width, height) = match terminal.size() {
1931                            Ok(s) => (s.width, s.height),
1932                            Err(_) => (80, 24),
1933                        };
1934                        let is_right_side = mouse.column as f64 > width as f64 * 0.65;
1935                        let input_rect = input_rect_for_size(
1936                            Rect { x: 0, y: 0, width, height },
1937                            app.input.len(),
1938                        );
1939                        let title_area = input_title_area(input_rect);
1940
1941                        match mouse.kind {
1942                            MouseEventKind::Moved => {
1943                                let hovered = if mouse.row == title_area.y
1944                                    && mouse.column >= title_area.x
1945                                    && mouse.column < title_area.x + title_area.width
1946                                {
1947                                    input_action_hitboxes(&app, title_area)
1948                                        .into_iter()
1949                                        .find_map(|(action, start, end)| {
1950                                            (mouse.column >= start && mouse.column <= end)
1951                                                .then_some(action)
1952                                        })
1953                                } else {
1954                                    None
1955                                };
1956                                app.hovered_input_action = hovered;
1957                            }
1958                            MouseEventKind::Down(MouseButton::Left) => {
1959                                if mouse.row == title_area.y
1960                                    && mouse.column >= title_area.x
1961                                    && mouse.column < title_area.x + title_area.width
1962                                {
1963                                    for (action, start, end) in input_action_hitboxes(&app, title_area) {
1964                                        if mouse.column >= start && mouse.column <= end {
1965                                            app.hovered_input_action = Some(action);
1966                                            trigger_input_action(&mut app, action);
1967                                            break;
1968                                        }
1969                                    }
1970                                } else {
1971                                    app.hovered_input_action = None;
1972
1973                                    // Check Autocomplete Click
1974                                    if app.show_autocomplete && !app.autocomplete_suggestions.is_empty() {
1975                                        // The popup is rendered at chunks[1].y - (suggestions + 2)
1976                                        // Calculation must match ui() rendering logic exactly
1977                                        let items_len = app.autocomplete_suggestions.len();
1978                                        let popup_h = (items_len as u16 + 2).min(17); // 15 + borders
1979                                        let popup_y = input_rect.y.saturating_sub(popup_h);
1980                                        let popup_x = input_rect.x + 2;
1981                                        let popup_w = input_rect.width.saturating_sub(4);
1982
1983                                        if mouse.row >= popup_y && mouse.row < popup_y + popup_h
1984                                            && mouse.column >= popup_x && mouse.column < popup_x + popup_w
1985                                        {
1986                                            // Clicked inside popup
1987                                            let mouse_relative_y = mouse.row.saturating_sub(popup_y + 1);
1988                                            if mouse_relative_y < items_len as u16 {
1989                                                let clicked_idx = mouse_relative_y as usize;
1990                                                let selected = &app.autocomplete_suggestions[clicked_idx].clone();
1991                                                app.apply_autocomplete_selection(selected);
1992                                            }
1993                                            continue; // Event handled
1994                                        }
1995                                    }
1996                                }
1997                            }
1998                            MouseEventKind::ScrollUp => {
1999                                if is_right_side {
2000                                    // User scrolled up — disable auto-scroll so they can read.
2001                                    app.specular_auto_scroll = false;
2002                                    app.specular_scroll = app.specular_scroll.saturating_sub(3);
2003                                } else {
2004                                    let cur = app.manual_scroll_offset.unwrap_or(0);
2005                                    app.manual_scroll_offset = Some(cur.saturating_add(3));
2006                                }
2007                            }
2008                            MouseEventKind::ScrollDown => {
2009                                if is_right_side {
2010                                    app.specular_auto_scroll = false;
2011                                    app.specular_scroll = app.specular_scroll.saturating_add(3);
2012                                } else if let Some(cur) = app.manual_scroll_offset {
2013                                    app.manual_scroll_offset = if cur <= 3 { None } else { Some(cur - 3) };
2014                                }
2015                            }
2016                            _ => {}
2017                        }
2018                    }
2019                    Some(Ok(Event::Key(key))) => {
2020                        if key.kind != event::KeyEventKind::Press { continue; }
2021
2022                        // Update idle tracker for DeepReflect.
2023                        { *last_interaction.lock().unwrap() = Instant::now(); }
2024
2025                        // ── Tier-2 Swarm diff review modal (exclusive lock) ───
2026                        if let Some(review) = app.active_review.take() {
2027                            match key.code {
2028                                KeyCode::Char('y') | KeyCode::Char('Y') => {
2029                                    let _ = review.tx.send(ReviewResponse::Accept);
2030                                    app.push_message("System", &format!("Worker {} diff accepted.", review.worker_id));
2031                                }
2032                                KeyCode::Char('n') | KeyCode::Char('N') => {
2033                                    let _ = review.tx.send(ReviewResponse::Reject);
2034                                    app.push_message("System", "Diff rejected.");
2035                                }
2036                                KeyCode::Char('r') | KeyCode::Char('R') => {
2037                                    let _ = review.tx.send(ReviewResponse::Retry);
2038                                    app.push_message("System", "Retrying synthesis…");
2039                                }
2040                                _ => { app.active_review = Some(review); }
2041                            }
2042                            continue;
2043                        }
2044
2045                        // ── High-risk approval modal (exclusive lock) ─────────
2046                        if let Some(mut approval) = app.awaiting_approval.take() {
2047                            // Scroll keys — adjust offset and put approval back.
2048                            let scroll_handled = if approval.diff.is_some() {
2049                                let diff_lines = approval.diff.as_ref().map(|d| d.lines().count()).unwrap_or(0) as u16;
2050                                match key.code {
2051                                    KeyCode::Down | KeyCode::Char('j') => {
2052                                        approval.diff_scroll = approval.diff_scroll.saturating_add(1).min(diff_lines.saturating_sub(1));
2053                                        true
2054                                    }
2055                                    KeyCode::Up | KeyCode::Char('k') => {
2056                                        approval.diff_scroll = approval.diff_scroll.saturating_sub(1);
2057                                        true
2058                                    }
2059                                    KeyCode::PageDown => {
2060                                        approval.diff_scroll = approval.diff_scroll.saturating_add(10).min(diff_lines.saturating_sub(1));
2061                                        true
2062                                    }
2063                                    KeyCode::PageUp => {
2064                                        approval.diff_scroll = approval.diff_scroll.saturating_sub(10);
2065                                        true
2066                                    }
2067                                    _ => false,
2068                                }
2069                            } else {
2070                                false
2071                            };
2072                            if scroll_handled {
2073                                app.awaiting_approval = Some(approval);
2074                                continue;
2075                            }
2076                            match key.code {
2077                                KeyCode::Char('y') | KeyCode::Char('Y') => {
2078                                    if let Some(ref diff) = approval.diff {
2079                                        let added = diff.lines().filter(|l| l.starts_with("+ ")).count();
2080                                        let removed = diff.lines().filter(|l| l.starts_with("- ")).count();
2081                                        app.push_message("System", &format!(
2082                                            "Applied: {} +{} -{}", approval.display, added, removed
2083                                        ));
2084                                    } else {
2085                                        app.push_message("System", &format!("Approved: {}", approval.display));
2086                                    }
2087                                    let _ = approval.responder.send(true);
2088                                }
2089                                KeyCode::Char('a') | KeyCode::Char('A') => {
2090                                    app.auto_approve_session = true;
2091                                    if let Some(ref diff) = approval.diff {
2092                                        let added = diff.lines().filter(|l| l.starts_with("+ ")).count();
2093                                        let removed = diff.lines().filter(|l| l.starts_with("- ")).count();
2094                                        app.push_message("System", &format!(
2095                                            "Applied: {} +{} -{}", approval.display, added, removed
2096                                        ));
2097                                    } else {
2098                                        app.push_message("System", &format!("Approved: {}", approval.display));
2099                                    }
2100                                    app.push_message("System", "🔓 FULL AUTONOMY — All mutations auto-approved for this session.");
2101                                    let _ = approval.responder.send(true);
2102                                }
2103                                KeyCode::Char('n') | KeyCode::Char('N') => {
2104                                    if approval.diff.is_some() {
2105                                        app.push_message("System", "Edit skipped.");
2106                                    } else {
2107                                        app.push_message("System", "Declined.");
2108                                    }
2109                                    let _ = approval.responder.send(false);
2110                                }
2111                                _ => { app.awaiting_approval = Some(approval); }
2112                            }
2113                            continue;
2114                        }
2115
2116                        // ── Normal key bindings ───────────────────────────────
2117                        match key.code {
2118                            KeyCode::Char('q') | KeyCode::Char('c')
2119                                if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
2120                                    app.write_session_report();
2121                                    app.copy_transcript_to_clipboard();
2122                                    break;
2123                                }
2124
2125                            KeyCode::Esc => {
2126                                request_stop(&mut app);
2127                            }
2128
2129                            KeyCode::Char('b') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
2130                                app.brief_mode = !app.brief_mode;
2131                                // If the user manually toggles, silence the hardware guard for this session.
2132                                app.hardware_guard_enabled = false;
2133                                app.push_message("System", &format!("Hardware Guard {}: {}", if app.brief_mode { "ENFORCED" } else { "SILENCED" }, if app.brief_mode { "ON" } else { "OFF" }));
2134                            }
2135                            KeyCode::Char('p') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
2136                                app.professional = !app.professional;
2137                                app.push_message("System", &format!("Professional Harness: {}", if app.professional { "ACTIVE" } else { "DISABLED" }));
2138                            }
2139                            KeyCode::Char('y') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
2140                                app.yolo_mode = !app.yolo_mode;
2141                                app.push_message("System", &format!("Approvals Off: {}", if app.yolo_mode { "ON — all tools auto-approved" } else { "OFF" }));
2142                            }
2143                            KeyCode::Char('t') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
2144                                if !app.voice_manager.is_available() {
2145                                    app.push_message("System", "Voice is not available in this build. Use a packaged release for baked-in voice.");
2146                                } else {
2147                                    let enabled = app.voice_manager.toggle();
2148                                    app.push_message("System", &format!("Voice of Hematite: {}", if enabled { "VIBRANT" } else { "SILENCED" }));
2149                                }
2150                            }
2151                            KeyCode::Char('o') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
2152                                match pick_attachment_path(AttachmentPickerKind::Document) {
2153                                    Ok(Some(path)) => attach_document_from_path(&mut app, &path),
2154                                    Ok(None) => app.push_message("System", "Document picker cancelled."),
2155                                    Err(e) => app.push_message("System", &e),
2156                                }
2157                            }
2158                            KeyCode::Char('i') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
2159                                match pick_attachment_path(AttachmentPickerKind::Image) {
2160                                    Ok(Some(path)) => attach_image_from_path(&mut app, &path),
2161                                    Ok(None) => app.push_message("System", "Image picker cancelled."),
2162                                    Err(e) => app.push_message("System", &e),
2163                                }
2164                            }
2165                            KeyCode::Char('s') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
2166                                app.push_message("Hematite", "Swarm engaged.");
2167                                let swarm_tx_c = swarm_tx.clone();
2168                                let coord_c = swarm_coordinator.clone();
2169                                // Hardware-aware swarm: Limit workers if GPU is busy.
2170                                let max_workers = if app.gpu_state.ratio() > 0.70 { 1 } else { 3 };
2171                                if max_workers < 3 {
2172                                    app.push_message("System", "Hardware Guard: Limiting swarm to 1 worker due to GPU load.");
2173                                }
2174
2175                                app.agent_running = true;
2176                                tokio::spawn(async move {
2177                                    let payload = r#"<worker_task id="1" target="src/ui/tui.rs">Implement Swarm Layout</worker_task>
2178<worker_task id="2" target="src/agent/swarm.rs">Build Scratchpad constraints</worker_task>
2179<worker_task id="3" target="docs">Update Readme</worker_task>"#;
2180                                    let tasks = crate::agent::parser::parse_master_spec(payload);
2181                                    let _ = coord_c.dispatch_swarm(tasks, swarm_tx_c, max_workers).await;
2182                                });
2183                            }
2184                            KeyCode::Char('z') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
2185                                match crate::tools::file_ops::pop_ghost_ledger() {
2186                                    Ok(msg) => {
2187                                        app.specular_logs.push(format!("GHOST: {}", msg));
2188                                        trim_vec(&mut app.specular_logs, 7);
2189                                        app.push_message("System", &msg);
2190                                    }
2191                                    Err(e) => {
2192                                        app.push_message("System", &format!("Undo failed: {}", e));
2193                                    }
2194                                }
2195                            }
2196                            KeyCode::Up => {
2197                                if app.show_autocomplete && !app.autocomplete_suggestions.is_empty() {
2198                                    app.selected_suggestion = app.selected_suggestion.saturating_sub(1);
2199                                } else if app.manual_scroll_offset.is_some() {
2200                                    // Protect history: Use Up as a scroll fallback if already scrolling.
2201                                    let cur = app.manual_scroll_offset.unwrap();
2202                                    app.manual_scroll_offset = Some(cur.saturating_add(3));
2203                                } else if !app.input_history.is_empty() {
2204                                    // Only cycle history if we are at the bottom of the chat.
2205                                    let new_idx = match app.history_idx {
2206                                        None => app.input_history.len() - 1,
2207                                        Some(i) => i.saturating_sub(1),
2208                                    };
2209                                    app.history_idx = Some(new_idx);
2210                                    app.input = app.input_history[new_idx].clone();
2211                                }
2212                            }
2213                            KeyCode::Down => {
2214                                if app.show_autocomplete && !app.autocomplete_suggestions.is_empty() {
2215                                    app.selected_suggestion = (app.selected_suggestion + 1).min(app.autocomplete_suggestions.len().saturating_sub(1));
2216                                } else if let Some(off) = app.manual_scroll_offset {
2217                                    if off <= 3 { app.manual_scroll_offset = None; }
2218                                    else { app.manual_scroll_offset = Some(off.saturating_sub(3)); }
2219                                } else if let Some(i) = app.history_idx {
2220                                    if i + 1 < app.input_history.len() {
2221                                        app.history_idx = Some(i + 1);
2222                                        app.input = app.input_history[i + 1].clone();
2223                                    } else {
2224                                        app.history_idx = None;
2225                                        app.input.clear();
2226                                    }
2227                                }
2228                            }
2229                            KeyCode::PageUp => {
2230                                let cur = app.manual_scroll_offset.unwrap_or(0);
2231                                app.manual_scroll_offset = Some(cur.saturating_add(10));
2232                            }
2233                            KeyCode::PageDown => {
2234                                if let Some(off) = app.manual_scroll_offset {
2235                                    if off <= 10 { app.manual_scroll_offset = None; }
2236                                    else { app.manual_scroll_offset = Some(off.saturating_sub(10)); }
2237                                }
2238                            }
2239                            KeyCode::Tab => {
2240                                if app.show_autocomplete && !app.autocomplete_suggestions.is_empty() {
2241                                    let selected = app.autocomplete_suggestions[app.selected_suggestion].clone();
2242                                    app.apply_autocomplete_selection(&selected);
2243                                }
2244                            }
2245                            KeyCode::Char(c) => {
2246                                app.history_idx = None; // typing cancels history nav
2247                                app.input.push(c);
2248                                app.last_input_time = Instant::now();
2249
2250                                if c == '@' {
2251                                    app.show_autocomplete = true;
2252                                    app.autocomplete_filter.clear();
2253                                    app.selected_suggestion = 0;
2254                                    app.update_autocomplete();
2255                                } else if app.show_autocomplete {
2256                                    app.autocomplete_filter.push(c);
2257                                    app.update_autocomplete();
2258                                }
2259                            }
2260                            KeyCode::Backspace => {
2261                                app.input.pop();
2262                                if app.show_autocomplete {
2263                                    if app.input.ends_with('@') || !app.input.contains('@') {
2264                                        app.show_autocomplete = false;
2265                                        app.autocomplete_filter.clear();
2266                                    } else {
2267                                        app.autocomplete_filter.pop();
2268                                        app.update_autocomplete();
2269                                    }
2270                                }
2271                            }
2272                            KeyCode::Enter => {
2273                                if app.show_autocomplete && !app.autocomplete_suggestions.is_empty() {
2274                                    let selected = app.autocomplete_suggestions[app.selected_suggestion].clone();
2275                                    app.apply_autocomplete_selection(&selected);
2276                                    continue;
2277                                }
2278
2279                                if !app.input.is_empty()
2280                                    && (!app.agent_running
2281                                        || is_immediate_local_command(&app.input))
2282                                {
2283                                    // PASTE GUARD: If a newline arrives within 50ms of a character,
2284                                    // it's almost certainly part of a paste stream. Convert to space.
2285                                    if Instant::now().duration_since(app.last_input_time) < std::time::Duration::from_millis(50) {
2286                                        app.input.push(' ');
2287                                        app.last_input_time = Instant::now();
2288                                        continue;
2289                                    }
2290
2291                                    let input_text = app.input.drain(..).collect::<String>();
2292
2293                                    // ── Slash Command Processor ──────────────────────────
2294                                    if input_text.starts_with('/') {
2295                                        let parts: Vec<&str> = input_text.trim().split_whitespace().collect();
2296                                        let cmd = parts[0].to_lowercase();
2297                                        match cmd.as_str() {
2298                                            "/undo" => {
2299                                                match crate::tools::file_ops::pop_ghost_ledger() {
2300                                                    Ok(msg) => {
2301                                                        app.specular_logs.push(format!("GHOST: {}", msg));
2302                                                        trim_vec(&mut app.specular_logs, 7);
2303                                                        app.push_message("System", &msg);
2304                                                    }
2305                                                    Err(e) => {
2306                                                        app.push_message("System", &format!("Undo failed: {}", e));
2307                                                    }
2308                                                }
2309                                                app.history_idx = None;
2310                                                continue;
2311                                            }
2312                                            "/clear" => {
2313                                                reset_visible_session_state(&mut app);
2314                                                app.push_message("System", "Dialogue buffer cleared.");
2315                                                app.history_idx = None;
2316                                                continue;
2317                                            }
2318                                            "/cd" => {
2319                                                if parts.len() < 2 {
2320                                                    app.push_message("System", "Usage: /cd <path>  — teleport to any directory. Supports bare tokens like downloads, desktop, docs, pictures, videos, music, home, temp, bare ~, @TOKENS, .., and absolute paths.");
2321                                                    app.history_idx = None;
2322                                                    continue;
2323                                                }
2324                                                let raw = parts[1..].join(" ");
2325                                                let target = crate::tools::file_ops::resolve_candidate(&raw);
2326                                                if !target.exists() {
2327                                                    app.push_message("System", &format!("Directory not found: {}", target.display()));
2328                                                    app.history_idx = None;
2329                                                    continue;
2330                                                }
2331                                                if !target.is_dir() {
2332                                                    app.push_message("System", &format!("Not a directory: {}", target.display()));
2333                                                    app.history_idx = None;
2334                                                    continue;
2335                                                }
2336                                                let target_str = target.to_string_lossy().to_string();
2337                                                app.push_message("You", &format!("/cd {}", raw));
2338                                                app.push_message("System", &format!("Teleporting to {}...", target_str));
2339                                                app.push_message("System", "Launching new session. This terminal will close.");
2340                                                spawn_dive_in_terminal(&target_str);
2341                                                app.write_session_report();
2342                                                app.copy_transcript_to_clipboard();
2343                                                break;
2344                                            }
2345                                            "/ls" => {
2346                                                let base: std::path::PathBuf = if parts.len() >= 2 {
2347                                                    // /ls <path> or /ls <N>
2348                                                    let arg = parts[1..].join(" ");
2349                                                    if let Ok(n) = arg.trim().parse::<usize>() {
2350                                                        // /ls <N> — teleport to nav_list entry N
2351                                                        if n == 0 || n > app.nav_list.len() {
2352                                                            app.push_message("System", &format!("No entry {}. Run /ls first to see the list.", n));
2353                                                            app.history_idx = None;
2354                                                            continue;
2355                                                        }
2356                                                        let target = app.nav_list[n - 1].clone();
2357                                                        let target_str = target.to_string_lossy().to_string();
2358                                                        app.push_message("You", &format!("/ls {}", n));
2359                                                        app.push_message("System", &format!("Teleporting to {}...", target_str));
2360                                                        app.push_message("System", "Launching new session. This terminal will close.");
2361                                                        spawn_dive_in_terminal(&target_str);
2362                                                        app.write_session_report();
2363                                                        app.copy_transcript_to_clipboard();
2364                                                        break;
2365                                                    } else {
2366                                                        crate::tools::file_ops::resolve_candidate(&arg)
2367                                                    }
2368                                                } else {
2369                                                    std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
2370                                                };
2371
2372                                                // Build numbered nav list
2373                                                let mut entries: Vec<std::path::PathBuf> = Vec::new();
2374                                                let mut output = String::new();
2375
2376                                                // Common locations (only when listing current/no-arg)
2377                                                let listing_base = parts.len() < 2;
2378                                                if listing_base {
2379                                                    let common: Vec<(&str, Option<std::path::PathBuf>)> = vec![
2380                                                        ("Desktop", dirs::desktop_dir()),
2381                                                        ("Downloads", dirs::download_dir()),
2382                                                        ("Documents", dirs::document_dir()),
2383                                                        ("Pictures", dirs::picture_dir()),
2384                                                        ("Home", dirs::home_dir()),
2385                                                    ];
2386                                                    let valid: Vec<_> = common.into_iter().filter_map(|(label, p)| p.map(|pb| (label, pb))).collect();
2387                                                    if !valid.is_empty() {
2388                                                        output.push_str("Common locations:\n");
2389                                                        for (label, pb) in &valid {
2390                                                            entries.push(pb.clone());
2391                                                            output.push_str(&format!("  {:>2}.  {:<12}  {}\n", entries.len(), label, pb.display()));
2392                                                        }
2393                                                    }
2394                                                }
2395
2396                                                // Subdirectories of base path
2397                                                let cwd_label = if listing_base {
2398                                                    std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
2399                                                } else {
2400                                                    base.clone()
2401                                                };
2402                                                if let Ok(read) = std::fs::read_dir(&cwd_label) {
2403                                                    let mut dirs_found: Vec<std::path::PathBuf> = read
2404                                                        .filter_map(|e| e.ok())
2405                                                        .filter(|e| e.path().is_dir())
2406                                                        .map(|e| e.path())
2407                                                        .collect();
2408                                                    dirs_found.sort();
2409                                                    if !dirs_found.is_empty() {
2410                                                        output.push_str(&format!("\n{}:\n", cwd_label.display()));
2411                                                        for pb in &dirs_found {
2412                                                            entries.push(pb.clone());
2413                                                            let name = pb.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
2414                                                            output.push_str(&format!("  {:>2}.  {}\n", entries.len(), name));
2415                                                        }
2416                                                    }
2417                                                }
2418
2419                                                if entries.is_empty() {
2420                                                    app.push_message("System", "No directories found.");
2421                                                } else {
2422                                                    output.push_str("\nType /ls <N> to teleport to that directory.");
2423                                                    app.nav_list = entries;
2424                                                    app.push_message("System", &output);
2425                                                }
2426                                                app.history_idx = None;
2427                                                continue;
2428                                            }
2429                                            "/diff" => {
2430                                                app.push_message("System", "Fetching session diff...");
2431                                                let ws = crate::tools::file_ops::workspace_root();
2432                                                if crate::agent::git::is_git_repo(&ws) {
2433                                                    let output = std::process::Command::new("git")
2434                                                        .args(["diff", "--stat"])
2435                                                        .current_dir(ws)
2436                                                        .output();
2437                                                    if let Ok(out) = output {
2438                                                        let stat = String::from_utf8_lossy(&out.stdout).to_string();
2439                                                        app.push_message("System", if stat.is_empty() { "No changes detected." } else { &stat });
2440                                                    }
2441                                                } else {
2442                                                    app.push_message("System", "Not a git repository. Diff limited.");
2443                                                }
2444                                                app.history_idx = None;
2445                                                continue;
2446                                            }
2447                                            "/vein-reset" => {
2448                                                app.vein_file_count = 0;
2449                                                app.vein_embedded_count = 0;
2450                                                app.push_message("You", "/vein-reset");
2451                                                app.agent_running = true;
2452                                                let _ = app.user_input_tx.try_send(UserTurn::text("/vein-reset"));
2453                                                app.history_idx = None;
2454                                                continue;
2455                                            }
2456                                            "/vein-inspect" => {
2457                                                app.push_message("You", "/vein-inspect");
2458                                                app.agent_running = true;
2459                                                let _ = app.user_input_tx.try_send(UserTurn::text("/vein-inspect"));
2460                                                app.history_idx = None;
2461                                                continue;
2462                                            }
2463                                            "/workspace-profile" => {
2464                                                app.push_message("You", "/workspace-profile");
2465                                                app.agent_running = true;
2466                                                let _ = app.user_input_tx.try_send(UserTurn::text("/workspace-profile"));
2467                                                app.history_idx = None;
2468                                                continue;
2469                                            }
2470                                            "/copy" => {
2471                                                app.copy_transcript_to_clipboard();
2472                                                app.push_message("System", "Exact session transcript copied to clipboard (includes help/system output).");
2473                                                app.history_idx = None;
2474                                                continue;
2475                                            }
2476                                            "/copy-last" => {
2477                                                if app.copy_last_reply_to_clipboard() {
2478                                                    app.push_message("System", "Latest Hematite reply copied to clipboard.");
2479                                                } else {
2480                                                    app.push_message("System", "No Hematite reply is available to copy yet.");
2481                                                }
2482                                                app.history_idx = None;
2483                                                continue;
2484                                            }
2485                                            "/copy-clean" => {
2486                                                app.copy_clean_transcript_to_clipboard();
2487                                                app.push_message("System", "Clean chat transcript copied to clipboard (skips help/debug boilerplate).");
2488                                                app.history_idx = None;
2489                                                continue;
2490                                            }
2491                                            "/copy2" => {
2492                                                app.copy_specular_to_clipboard();
2493                                                app.push_message("System", "SPECULAR log copied to clipboard (reasoning + events).");
2494                                                app.history_idx = None;
2495                                                continue;
2496                                            }
2497                                            "/voice" => {
2498                                                use crate::ui::voice::VOICE_LIST;
2499                                                if let Some(arg) = parts.get(1) {
2500                                                    // /voice N — select by number
2501                                                    if let Ok(n) = arg.parse::<usize>() {
2502                                                        let idx = n.saturating_sub(1);
2503                                                        if let Some(&(id, label)) = VOICE_LIST.get(idx) {
2504                                                            app.voice_manager.set_voice(id);
2505                                                            let _ = crate::agent::config::set_voice(id);
2506                                                            app.push_message("System", &format!("Voice set to {} — {}", id, label));
2507                                                        } else {
2508                                                            app.push_message("System", &format!("Invalid voice number. Use /voice to list voices (1–{}).", VOICE_LIST.len()));
2509                                                        }
2510                                                    } else {
2511                                                        // /voice af_bella — select by name
2512                                                        if let Some(&(id, label)) = VOICE_LIST.iter().find(|&&(id, _)| id == *arg) {
2513                                                            app.voice_manager.set_voice(id);
2514                                                            let _ = crate::agent::config::set_voice(id);
2515                                                            app.push_message("System", &format!("Voice set to {} — {}", id, label));
2516                                                        } else {
2517                                                            app.push_message("System", &format!("Unknown voice '{}'. Use /voice to list voices.", arg));
2518                                                        }
2519                                                    }
2520                                                } else {
2521                                                    // /voice — list all voices
2522                                                    let current = app.voice_manager.current_voice_id();
2523                                                    let mut list = format!("Available voices (current: {}):\n", current);
2524                                                    for (i, &(id, label)) in VOICE_LIST.iter().enumerate() {
2525                                                        let marker = if id == current.as_str() { " ◀" } else { "" };
2526                                                        list.push_str(&format!("  {:>2}. {}{}\n", i + 1, label, marker));
2527                                                    }
2528                                                    list.push_str("\nUse /voice N or /voice <id> to select.");
2529                                                    app.push_message("System", &list);
2530                                                }
2531                                                app.history_idx = None;
2532                                                continue;
2533                                            }
2534                                            "/read" => {
2535                                                let text = parts[1..].join(" ");
2536                                                if text.is_empty() {
2537                                                    app.push_message("System", "Usage: /read <text to speak>");
2538                                                } else if !app.voice_manager.is_available() {
2539                                                    app.push_message("System", "Voice is not available in this build. Use a packaged release for baked-in voice.");
2540                                                } else if !app.voice_manager.is_enabled() {
2541                                                    app.push_message("System", "Voice is off. Press Ctrl+T to enable, then /read again.");
2542                                                } else {
2543                                                    app.push_message("System", &format!("Reading {} words aloud. ESC to stop.", text.split_whitespace().count()));
2544                                                    app.voice_manager.speak(text.clone());
2545                                                }
2546                                                app.history_idx = None;
2547                                                continue;
2548                                            }
2549                                            "/new" => {
2550                                                reset_visible_session_state(&mut app);
2551                                                app.push_message("You", "/new");
2552                                                app.agent_running = true;
2553                                                app.clear_pending_attachments();
2554                                                let _ = app.user_input_tx.try_send(UserTurn::text("/new"));
2555                                                app.history_idx = None;
2556                                                continue;
2557                                            }
2558                                            "/forget" => {
2559                                                // Cancel any running turn so /forget isn't queued behind retries.
2560                                                app.cancel_token.store(true, std::sync::atomic::Ordering::SeqCst);
2561                                                reset_visible_session_state(&mut app);
2562                                                app.push_message("You", "/forget");
2563                                                app.agent_running = true;
2564                                                app.cancel_token.store(false, std::sync::atomic::Ordering::SeqCst);
2565                                                app.clear_pending_attachments();
2566                                                let _ = app.user_input_tx.try_send(UserTurn::text("/forget"));
2567                                                app.history_idx = None;
2568                                                continue;
2569                                            }
2570                                            "/gemma-native" => {
2571                                                let sub = parts.get(1).copied().unwrap_or("status").to_ascii_lowercase();
2572                                                let gemma_detected = crate::agent::inference::is_hematite_native_model(&app.model_id);
2573                                                match sub.as_str() {
2574                                                    "auto" => {
2575                                                        match crate::agent::config::set_gemma_native_mode("auto") {
2576                                                            Ok(_) => {
2577                                                                if gemma_detected {
2578                                                                    app.push_message("System", "Gemma Native Formatting: AUTO. Gemma 4 will use native formatting automatically on the next turn.");
2579                                                                } else {
2580                                                                    app.push_message("System", "Gemma Native Formatting: AUTO in settings. It will activate automatically when a Gemma 4 model is loaded.");
2581                                                                }
2582                                                            }
2583                                                            Err(e) => app.push_message("System", &format!("Failed to update settings: {}", e)),
2584                                                        }
2585                                                    }
2586                                                    "on" => {
2587                                                        match crate::agent::config::set_gemma_native_mode("on") {
2588                                                            Ok(_) => {
2589                                                                if gemma_detected {
2590                                                                    app.push_message("System", "Gemma Native Formatting: ON (forced). It will apply on the next turn.");
2591                                                                } else {
2592                                                                    app.push_message("System", "Gemma Native Formatting: ON (forced) in settings. It will activate only when a Gemma 4 model is loaded.");
2593                                                                }
2594                                                            }
2595                                                            Err(e) => app.push_message("System", &format!("Failed to update settings: {}", e)),
2596                                                        }
2597                                                    }
2598                                                    "off" => {
2599                                                        match crate::agent::config::set_gemma_native_mode("off") {
2600                                                            Ok(_) => app.push_message("System", "Gemma Native Formatting: OFF."),
2601                                                            Err(e) => app.push_message("System", &format!("Failed to update settings: {}", e)),
2602                                                        }
2603                                                    }
2604                                                    _ => {
2605                                                        let config = crate::agent::config::load_config();
2606                                                        let mode = crate::agent::config::gemma_native_mode_label(&config, &app.model_id);
2607                                                        let enabled = match mode {
2608                                                            "on" => "ON (forced)",
2609                                                            "auto" => "ON (auto)",
2610                                                            "off" => "OFF",
2611                                                            _ => "INACTIVE",
2612                                                        };
2613                                                        let model_note = if gemma_detected {
2614                                                            "Gemma 4 detected."
2615                                                        } else {
2616                                                            "Current model is not Gemma 4."
2617                                                        };
2618                                                        app.push_message(
2619                                                            "System",
2620                                                            &format!(
2621                                                                "Gemma Native Formatting: {}. {} Usage: /gemma-native auto | on | off | status",
2622                                                                enabled, model_note
2623                                                            ),
2624                                                        );
2625                                                    }
2626                                                }
2627                                                app.history_idx = None;
2628                                                continue;
2629                                            }
2630                                            "/chat" => {
2631                                                app.workflow_mode = "CHAT".into();
2632                                                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.");
2633                                                app.history_idx = None;
2634                                                let _ = app.user_input_tx.try_send(UserTurn::text("/chat"));
2635                                                continue;
2636                                            }
2637                                            "/reroll" => {
2638                                                app.history_idx = None;
2639                                                let _ = app.user_input_tx.try_send(UserTurn::text("/reroll"));
2640                                                continue;
2641                                            }
2642                                            "/agent" => {
2643                                                app.workflow_mode = "AUTO".into();
2644                                                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.");
2645                                                app.history_idx = None;
2646                                                let _ = app.user_input_tx.try_send(UserTurn::text("/agent"));
2647                                                continue;
2648                                            }
2649                                            "/implement-plan" => {
2650                                                app.workflow_mode = "CODE".into();
2651                                                app.push_message("You", "/implement-plan");
2652                                                app.agent_running = true;
2653                                                let _ = app.user_input_tx.try_send(UserTurn::text("/implement-plan"));
2654                                                app.history_idx = None;
2655                                                continue;
2656                                            }
2657                                            "/ask" | "/code" | "/architect" | "/read-only" | "/auto" | "/teach" => {
2658                                                let label = match cmd.as_str() {
2659                                                    "/ask" => "ASK",
2660                                                    "/code" => "CODE",
2661                                                    "/architect" => "ARCHITECT",
2662                                                    "/read-only" => "READ-ONLY",
2663                                                    "/teach" => "TEACH",
2664                                                    _ => "AUTO",
2665                                                };
2666                                                app.workflow_mode = label.to_string();
2667                                                let outbound = input_text.trim().to_string();
2668                                                app.push_message("You", &outbound);
2669                                                app.agent_running = true;
2670                                                let _ = app.user_input_tx.try_send(UserTurn::text(outbound));
2671                                                app.history_idx = None;
2672                                                continue;
2673                                            }
2674                                            "/worktree" => {
2675                                                let sub = parts.get(1).copied().unwrap_or("");
2676                                                match sub {
2677                                                    "list" => {
2678                                                        app.push_message("You", "/worktree list");
2679                                                        app.agent_running = true;
2680                                                        let _ = app.user_input_tx.try_send(UserTurn::text(
2681                                                            "Call git_worktree with action=list"
2682                                                        ));
2683                                                    }
2684                                                    "add" => {
2685                                                        let wt_path = parts.get(2).copied().unwrap_or("");
2686                                                        let wt_branch = parts.get(3).copied().unwrap_or("");
2687                                                        if wt_path.is_empty() {
2688                                                            app.push_message("System", "Usage: /worktree add <path> [branch]");
2689                                                        } else {
2690                                                            app.push_message("You", &format!("/worktree add {wt_path}"));
2691                                                            app.agent_running = true;
2692                                                            let directive = if wt_branch.is_empty() {
2693                                                                format!("Call git_worktree with action=add path={wt_path}")
2694                                                            } else {
2695                                                                format!("Call git_worktree with action=add path={wt_path} branch={wt_branch}")
2696                                                            };
2697                                                            let _ = app.user_input_tx.try_send(UserTurn::text(directive));
2698                                                        }
2699                                                    }
2700                                                    "remove" => {
2701                                                        let wt_path = parts.get(2).copied().unwrap_or("");
2702                                                        if wt_path.is_empty() {
2703                                                            app.push_message("System", "Usage: /worktree remove <path>");
2704                                                        } else {
2705                                                            app.push_message("You", &format!("/worktree remove {wt_path}"));
2706                                                            app.agent_running = true;
2707                                                            let _ = app.user_input_tx.try_send(UserTurn::text(
2708                                                                format!("Call git_worktree with action=remove path={wt_path}")
2709                                                            ));
2710                                                        }
2711                                                    }
2712                                                    "prune" => {
2713                                                        app.push_message("You", "/worktree prune");
2714                                                        app.agent_running = true;
2715                                                        let _ = app.user_input_tx.try_send(UserTurn::text(
2716                                                            "Call git_worktree with action=prune"
2717                                                        ));
2718                                                    }
2719                                                    _ => {
2720                                                        app.push_message("System",
2721                                                            "Usage: /worktree list | add <path> [branch] | remove <path> | prune");
2722                                                    }
2723                                                }
2724                                                app.history_idx = None;
2725                                                continue;
2726                                            }
2727                                            "/think" => {
2728                                                app.think_mode = Some(true);
2729                                                app.push_message("You", "/think");
2730                                                app.agent_running = true;
2731                                                let _ = app.user_input_tx.try_send(UserTurn::text("/think"));
2732                                                app.history_idx = None;
2733                                                continue;
2734                                            }
2735                                            "/no_think" => {
2736                                                app.think_mode = Some(false);
2737                                                app.push_message("You", "/no_think");
2738                                                app.agent_running = true;
2739                                                let _ = app.user_input_tx.try_send(UserTurn::text("/no_think"));
2740                                                app.history_idx = None;
2741                                                continue;
2742                                            }
2743                                            "/lsp" => {
2744                                                app.push_message("You", "/lsp");
2745                                                app.agent_running = true;
2746                                                let _ = app.user_input_tx.try_send(UserTurn::text("/lsp"));
2747                                                app.history_idx = None;
2748                                                continue;
2749                                            }
2750                                            "/runtime-refresh" => {
2751                                                app.push_message("You", "/runtime-refresh");
2752                                                app.agent_running = true;
2753                                                let _ = app.user_input_tx.try_send(UserTurn::text("/runtime-refresh"));
2754                                                app.history_idx = None;
2755                                                continue;
2756                                            }
2757                                            "/rules" => {
2758                                                let sub = parts.get(1).copied().unwrap_or("status").to_ascii_lowercase();
2759                                                let ws_root = crate::tools::file_ops::workspace_root();
2760
2761                                                match sub.as_str() {
2762                                                    "view" => {
2763                                                        let mut combined = String::new();
2764                                                        let candidates = [
2765                                                            "CLAUDE.md",
2766                                                            ".claude.md",
2767                                                            "CLAUDE.local.md",
2768                                                            "HEMATITE.md",
2769                                                            ".hematite/rules.md",
2770                                                            ".hematite/rules.local.md",
2771                                                        ];
2772                                                        for cand in candidates {
2773                                                            let p = ws_root.join(cand);
2774                                                            if p.exists() {
2775                                                                if let Ok(c) = std::fs::read_to_string(&p) {
2776                                                                    combined.push_str(&format!("--- [{}] ---\n", cand));
2777                                                                    combined.push_str(&c);
2778                                                                    combined.push_str("\n\n");
2779                                                                }
2780                                                            }
2781                                                        }
2782                                                        if combined.is_empty() {
2783                                                            app.push_message("System", "No rule files found (CLAUDE.md, .hematite/rules.md, etc.).");
2784                                                        } else {
2785                                                            app.push_message("System", &format!("Current behavioral rules being injected:\n\n{}", combined));
2786                                                        }
2787                                                    }
2788                                                    "edit" => {
2789                                                        let which = parts.get(2).copied().unwrap_or("local").to_ascii_lowercase();
2790                                                        let target_file = if which == "shared" { "rules.md" } else { "rules.local.md" };
2791                                                        let target_path = crate::tools::file_ops::hematite_dir().join(target_file);
2792
2793                                                        if !target_path.exists() {
2794                                                            if let Some(parent) = target_path.parent() {
2795                                                                let _ = std::fs::create_dir_all(parent);
2796                                                            }
2797                                                            let header = if which == "shared" { "# Project Rules (Shared)" } else { "# Local Guidelines (Private)" };
2798                                                            let _ = std::fs::write(&target_path, format!("{}\n\nAdd behavioral guidelines here for the agent to follow in this workspace.\n", header));
2799                                                        }
2800
2801                                                        match crate::tools::file_ops::open_in_system_editor(&target_path) {
2802                                                            Ok(_) => app.push_message("System", &format!("Opening {} in system editor...", target_path.display())),
2803                                                            Err(e) => app.push_message("System", &format!("Failed to open editor: {}", e)),
2804                                                        }
2805                                                    }
2806                                                    _ => {
2807                                                        let mut status = "Behavioral Guidelines:\n".to_string();
2808                                                        let candidates = [
2809                                                            "CLAUDE.md",
2810                                                            ".claude.md",
2811                                                            "CLAUDE.local.md",
2812                                                            "HEMATITE.md",
2813                                                            ".hematite/rules.md",
2814                                                            ".hematite/rules.local.md",
2815                                                        ];
2816                                                        for cand in candidates {
2817                                                              let p = ws_root.join(cand);
2818                                                              let icon = if p.exists() { "[v]" } else { "[ ]" };
2819                                                              let label = if cand.contains(".local") || cand.ends_with(".local.md") { "(local override)" } else { "(shared asset)" };
2820                                                              status.push_str(&format!("  {} {:<25} {}\n", icon, cand, label));
2821                                                        }
2822                                                        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");
2823                                                        app.push_message("System", &status);
2824                                                    }
2825                                                }
2826                                                app.history_idx = None;
2827                                                continue;
2828                                            }
2829                                            "/help" => {
2830                                                show_help_message(&mut app);
2831                                                app.history_idx = None;
2832                                                continue;
2833                                            }
2834                                            "/help-legacy-unused" => {
2835                                                app.push_message("System",
2836                                                    "Hematite Commands:\n\
2837                                                     /chat             — (Mode) Conversation mode — clean chat, no tool noise\n\
2838                                                     /agent            — (Mode) Full coding harness + workstation mode — tools, file edits, builds, inspection\n\
2839                                                     /reroll           — (Soul) Hatch a new companion mid-session\n\
2840                                                     /auto             — (Flow) Let Hematite choose the narrowest effective workflow\n\
2841                                                     /ask [prompt]     — (Flow) Read-only analysis mode; optional inline prompt\n\
2842                                                     /code [prompt]    — (Flow) Explicit implementation mode; optional inline prompt\n\
2843                                                     /architect [prompt] — (Flow) Plan-first mode; optional inline prompt\n\
2844                                                     /implement-plan   — (Flow) Execute the saved architect handoff in /code\n\
2845                                                     /read-only [prompt] — (Flow) Hard read-only mode; optional inline prompt\n\
2846                                                     /teach [prompt]   — (Flow) Teacher mode; inspect machine then walk you through any admin task step-by-step\n\
2847                                                       /new              — (Reset) Fresh task context; clear chat, pins, and task files\n\
2848                                                       /forget           — (Wipe) Hard forget; purge saved memory and Vein index too\n\
2849                                                       /vein-inspect     — (Vein) Inspect indexed memory, hot files, and active room bias\n\
2850                                                       /workspace-profile — (Profile) Show the auto-generated workspace profile\n\
2851                                                       /rules            — (Rules) View behavioral guidelines (.hematite/rules.md)\n\
2852                                                       /version          — (Build) Show the running Hematite version\n\
2853                                                       /about            — (Info) Show author, repo, and product info\n\
2854                                                       /vein-reset       — (Vein) Wipe the RAG index; rebuilds automatically on next turn\n\
2855                                                       /clear            — (UI) Clear dialogue display only\n\
2856                                                     /gemma-native [auto|on|off|status] — (Model) Auto/force/disable Gemma 4 native formatting\n\
2857                                                     /runtime-refresh  — (Model) Re-read LM Studio model + CTX now\n\
2858                                                     /undo             — (Ghost) Revert last file change\n\
2859                                                     /diff             — (Git) Show session changes (--stat)\n\
2860                                                     /lsp              — (Logic) Start Language Servers (semantic intelligence)\n\
2861                                                     /swarm <text>     — (Swarm) Spawn parallel workers on a directive\n\
2862                                                     /worktree <cmd>   — (Isolated) Manage git worktrees (list|add|remove|prune)\n\
2863                                                     /think            — (Brain) Enable deep reasoning mode\n\
2864                                                     /no_think         — (Speed) Disable reasoning (3-5x faster responses)\n\
2865                                                     /voice            — (TTS) List all available voices\n\
2866                                                     /voice N          — (TTS) Select voice by number\n\
2867                                                     /attach <path>    — (Docs) Attach a PDF/markdown/txt file for next message\n\
2868                                                     /attach-pick      — (Docs) Open a file picker and attach a document\n\
2869                                                     /image <path>     — (Vision) Attach an image for the next message\n\
2870                                                     /image-pick       — (Vision) Open a file picker and attach an image\n\
2871                                                     /detach           — (Context) Drop pending document/image attachments\n\
2872                                                     /copy             — (Debug) Copy session transcript to clipboard\n\
2873                                                     /copy2            — (Debug) Copy SPECULAR log to clipboard (reasoning + events)\n\
2874                                                     \nHotkeys:\n\
2875                                                     Ctrl+B — Toggle Brief Mode (minimal output)\n\
2876                                                     Ctrl+P — Toggle Professional Mode (strip personality)\n\
2877                                                     Ctrl+O — Open document picker for next-turn context\n\
2878                                                     Ctrl+I — Open image picker for next-turn vision context\n\
2879                                                     Ctrl+Y — Toggle Approvals Off (bypass safety approvals)\n\
2880                                                     Ctrl+S — Quick Swarm (hardcoded bootstrap)\n\
2881                                                     Ctrl+Z — Undo last edit\n\
2882                                                     Ctrl+Q/C — Quit session\n\
2883                                                     ESC    — Silence current playback\n\
2884                                                     \nStatus Legend:\n\
2885                                                     LM    — LM Studio runtime health (`LIVE`, `RECV`, `WARN`, `CEIL`, `STALE`, `BOOT`)\n\
2886                                                     VN    — Vein RAG status (`SEM`=semantic active, `FTS`=BM25 only, `--`=not indexed)\n\
2887                                                     BUD   — Total prompt-budget pressure against the live context window\n\
2888                                                     CMP   — History compaction pressure against Hematite's adaptive threshold\n\
2889                                                     ERR   — Session error count (runtime, tool, or SPECULAR failures)\n\
2890                                                     CTX   — Live context window currently reported by LM Studio\n\
2891                                                     VOICE — Local speech output state\n\
2892                                                     \nAssistant: Semantic Pathing (LSP), Vision Pass, Web Research, Swarm Synthesis"
2893                                                );
2894                                                app.history_idx = None;
2895                                                continue;
2896                                            }
2897                                            "/swarm" => {
2898                                                let directive = parts[1..].join(" ");
2899                                                if directive.is_empty() {
2900                                                    app.push_message("System", "Usage: /swarm <directive>");
2901                                                } else {
2902                                                    app.active_workers.clear(); // Fresh architecture for a fresh command
2903                                                    app.push_message("Hematite", &format!("Swarm analyzing: '{}'", directive));
2904                                                    let swarm_tx_c = swarm_tx.clone();
2905                                                    let coord_c = swarm_coordinator.clone();
2906                                                    let max_workers = if app.gpu_state.ratio() > 0.75 { 1 } else { 3 };
2907                                                    app.agent_running = true;
2908                                                    tokio::spawn(async move {
2909                                                        let payload = format!(r#"<worker_task id="1" target="src">Research {}</worker_task>
2910<worker_task id="2" target="src">Implement {}</worker_task>
2911<worker_task id="3" target="docs">Document {}</worker_task>"#, directive, directive, directive);
2912                                                        let tasks = crate::agent::parser::parse_master_spec(&payload);
2913                                                        let _ = coord_c.dispatch_swarm(tasks, swarm_tx_c, max_workers).await;
2914                                                    });
2915                                                }
2916                                                app.history_idx = None;
2917                                                continue;
2918                                            }
2919                                            "/version" => {
2920                                                app.push_message(
2921                                                    "System",
2922                                                    &crate::hematite_version_report(),
2923                                                );
2924                                                app.history_idx = None;
2925                                                continue;
2926                                            }
2927                                            "/about" => {
2928                                                app.push_message(
2929                                                    "System",
2930                                                    &crate::hematite_about_report(),
2931                                                );
2932                                                app.history_idx = None;
2933                                                continue;
2934                                            }
2935                                            "/explain" => {
2936                                                let error_text = parts[1..].join(" ");
2937                                                if error_text.trim().is_empty() {
2938                                                    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.");
2939                                                } else {
2940                                                    let framed = format!(
2941                                                        "The user pasted the following error or message and needs a plain-English explanation. \
2942                                                         Explain what this means, why it happened, and what to do about it. \
2943                                                         Use simple, non-technical language. Avoid jargon. \
2944                                                         Structure your response as:\n\
2945                                                         1. What happened (one sentence)\n\
2946                                                         2. Why it happened\n\
2947                                                         3. How to fix it (step by step)\n\
2948                                                         4. How to prevent it next time (optional, if relevant)\n\n\
2949                                                         Error/message to explain:\n```\n{}\n```",
2950                                                        error_text
2951                                                    );
2952                                                    app.push_message("You", &format!("/explain {}", error_text));
2953                                                    app.agent_running = true;
2954                                                    let _ = app.user_input_tx.try_send(UserTurn::text(framed));
2955                                                }
2956                                                app.history_idx = None;
2957                                                continue;
2958                                            }
2959                                            "/health" => {
2960                                                app.push_message("You", "/health");
2961                                                app.agent_running = true;
2962                                                let _ = app.user_input_tx.try_send(UserTurn::text(
2963                                                    "Run inspect_host with topic=health_report. \
2964                                                     After getting the report, summarize it in plain English for a non-technical user. \
2965                                                     Use the tier labels (Needs fixing / Worth watching / Looking good) and \
2966                                                     give specific, actionable next steps for any items that need attention."
2967                                                ));
2968                                                app.history_idx = None;
2969                                                continue;
2970                                            }
2971                                            "/detach" => {
2972                                                app.clear_pending_attachments();
2973                                                app.push_message("System", "Cleared pending document/image attachments for the next turn.");
2974                                                app.history_idx = None;
2975                                                continue;
2976                                            }
2977                                            "/attach" => {
2978                                                let file_path = parts[1..].join(" ").trim().to_string();
2979                                                if file_path.is_empty() {
2980                                                    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.");
2981                                                    app.history_idx = None;
2982                                                    continue;
2983                                                }
2984                                                if file_path.is_empty() {
2985                                                    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.");
2986                                                } else {
2987                                                    let p = std::path::Path::new(&file_path);
2988                                                    match crate::memory::vein::extract_document_text(p) {
2989                                                        Ok(text) => {
2990                                                            let name = p.file_name()
2991                                                                .and_then(|n| n.to_str())
2992                                                                .unwrap_or(&file_path)
2993                                                                .to_string();
2994                                                            let preview_len = text.len().min(200);
2995                                                            app.push_message("System", &format!(
2996                                                                "Attached: {} ({} chars) — will be injected as context on your next message.\nPreview: {}...",
2997                                                                name, text.len(), &text[..preview_len]
2998                                                            ));
2999                                                            app.attached_context = Some((name, text));
3000                                                        }
3001                                                        Err(e) => {
3002                                                            app.push_message("System", &format!("Attach failed: {}", e));
3003                                                        }
3004                                                    }
3005                                                }
3006                                                app.history_idx = None;
3007                                                continue;
3008                                            }
3009                                            "/attach-pick" => {
3010                                                match pick_attachment_path(AttachmentPickerKind::Document) {
3011                                                    Ok(Some(path)) => attach_document_from_path(&mut app, &path),
3012                                                    Ok(None) => app.push_message("System", "Document picker cancelled."),
3013                                                    Err(e) => app.push_message("System", &e),
3014                                                }
3015                                                app.history_idx = None;
3016                                                continue;
3017                                            }
3018                                            "/image" => {
3019                                                let file_path = parts[1..].join(" ").trim().to_string();
3020                                                if file_path.is_empty() {
3021                                                    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.");
3022                                                } else {
3023                                                    attach_image_from_path(&mut app, &file_path);
3024                                                }
3025                                                app.history_idx = None;
3026                                                continue;
3027                                            }
3028                                            "/image-pick" => {
3029                                                match pick_attachment_path(AttachmentPickerKind::Image) {
3030                                                    Ok(Some(path)) => attach_image_from_path(&mut app, &path),
3031                                                    Ok(None) => app.push_message("System", "Image picker cancelled."),
3032                                                    Err(e) => app.push_message("System", &e),
3033                                                }
3034                                                app.history_idx = None;
3035                                                continue;
3036                                            }
3037                                            _ => {
3038                                                app.push_message("System", &format!("Unknown command: {}", cmd));
3039                                                app.history_idx = None;
3040                                                continue;
3041                                            }
3042                                        }
3043                                    }
3044
3045                                    // Save to history (avoid consecutive duplicates).
3046                                    if app.input_history.last().map(|s| s.as_str()) != Some(&input_text) {
3047                                        app.input_history.push(input_text.clone());
3048                                        if app.input_history.len() > 50 {
3049                                            app.input_history.remove(0);
3050                                        }
3051                                    }
3052                                    app.history_idx = None;
3053                                    app.push_message("You", &input_text);
3054                                    app.agent_running = true;
3055                                    app.stop_requested = false;
3056                                    app.cancel_token.store(false, std::sync::atomic::Ordering::SeqCst);
3057                                    app.last_reasoning.clear();
3058                                    app.manual_scroll_offset = None;
3059                                    app.specular_auto_scroll = true;
3060                                    let tx = app.user_input_tx.clone();
3061                                    let outbound = UserTurn {
3062                                        text: input_text,
3063                                        attached_document: app.attached_context.take().map(|(name, content)| {
3064                                            AttachedDocument { name, content }
3065                                        }),
3066                                        attached_image: app.attached_image.take(),
3067                                    };
3068                                    tokio::spawn(async move {
3069                                        let _ = tx.send(outbound).await;
3070                                    });
3071                                }
3072                            }
3073                            _ => {}
3074                        }
3075                    }
3076                    Some(Ok(Event::Paste(content))) => {
3077                        if !try_attach_from_paste(&mut app, &content) {
3078                            // Normalize pasted newlines into spaces so we don't accidentally submit
3079                            // multiple lines or break the single-line input logic.
3080                            let normalized = content.replace("\r\n", " ").replace('\n', " ");
3081                            app.input.push_str(&normalized);
3082                            app.last_input_time = Instant::now();
3083                        }
3084                    }
3085                    _ => {}
3086                }
3087            }
3088
3089            // ── Specular proactive watcher ────────────────────────────────────
3090            Some(specular_evt) = specular_rx.recv() => {
3091                match specular_evt {
3092                    SpecularEvent::SyntaxError { path, details } => {
3093                        app.record_error();
3094                        app.specular_logs.push(format!("ERROR: {:?}", path));
3095                        trim_vec(&mut app.specular_logs, 20);
3096
3097                        // Only proactively suggest a fix if the user isn't actively typing.
3098                        let user_idle = {
3099                            let lock = last_interaction.lock().unwrap();
3100                            lock.elapsed() > std::time::Duration::from_secs(3)
3101                        };
3102                        if user_idle && !app.agent_running {
3103                            app.agent_running = true;
3104                            let tx = app.user_input_tx.clone();
3105                            let diag = details.clone();
3106                            tokio::spawn(async move {
3107                                let msg = format!(
3108                                    "<specular-build-fail>\n{}\n</specular-build-fail>\n\
3109                                     Fix the compiler error above.",
3110                                    diag
3111                                );
3112                                let _ = tx.send(UserTurn::text(msg)).await;
3113                            });
3114                        }
3115                    }
3116                    SpecularEvent::FileChanged(path) => {
3117                        app.stats.wisdom += 1;
3118                        app.stats.patience = (app.stats.patience - 0.5).max(0.0);
3119                        if app.stats.patience < 50.0 && !app.brief_mode {
3120                            app.brief_mode = true;
3121                            app.push_message("System", "Context saturation high — Brief Mode auto-enabled.");
3122                        }
3123                        let path_str = path.to_string_lossy().to_string();
3124                        app.specular_logs.push(format!("INDEX: {}", path_str));
3125                        app.push_context_file(path_str, "Active".into());
3126                        trim_vec(&mut app.specular_logs, 20);
3127                    }
3128                }
3129            }
3130
3131            // ── Inference / agent events ──────────────────────────────────────
3132            Some(event) = agent_rx.recv() => {
3133                use crate::agent::inference::InferenceEvent;
3134                match event {
3135                    InferenceEvent::Thought(content) => {
3136                        if app.stop_requested {
3137                            continue;
3138                        }
3139                        app.thinking = true;
3140                        app.current_thought.push_str(&content);
3141                    }
3142                    InferenceEvent::VoiceStatus(msg) => {
3143                        if app.stop_requested {
3144                            continue;
3145                        }
3146                        app.push_message("System", &msg);
3147                    }
3148                    InferenceEvent::Token(ref token) | InferenceEvent::MutedToken(ref token) => {
3149                        if app.stop_requested {
3150                            continue;
3151                        }
3152                        let is_muted = matches!(event, InferenceEvent::MutedToken(_));
3153                        app.thinking = false;
3154                        if app.messages_raw.last().map(|(s, _)| s.as_str()) != Some("Hematite") {
3155                            app.push_message("Hematite", "");
3156                        }
3157                        app.update_last_message(token);
3158                        app.manual_scroll_offset = None;
3159
3160                        // ONLY speak if not muted
3161                        if !is_muted && app.voice_manager.is_enabled() && !app.cancel_token.load(std::sync::atomic::Ordering::SeqCst) {
3162                            app.voice_manager.speak(token.clone());
3163                        }
3164                    }
3165                    InferenceEvent::ToolCallStart { name, args, .. } => {
3166                        if app.stop_requested {
3167                            continue;
3168                        }
3169                        // In chat mode, suppress tool noise from the main chat surface.
3170                        if app.workflow_mode != "CHAT" {
3171                            let display = format!("( )  {} {}", name, args);
3172                            app.push_message("Tool", &display);
3173                        }
3174                        // Always track in active context regardless of mode
3175                        app.active_context.push(ContextFile {
3176                            path: name.clone(),
3177                            size: 0,
3178                            status: "Running".into()
3179                        });
3180                        trim_vec_context(&mut app.active_context, 8);
3181                        app.manual_scroll_offset = None;
3182                    }
3183                    InferenceEvent::ToolCallResult { id: _, name, output, is_error } => {
3184                        if app.stop_requested {
3185                            continue;
3186                        }
3187                        let icon = if is_error { "[x]" } else { "[v]" };
3188                        if is_error {
3189                            app.record_error();
3190                        }
3191                        // In chat mode, suppress tool results from main chat.
3192                        // Errors still show so the user knows something went wrong.
3193                        let preview = first_n_chars(&output, 100);
3194                        if app.workflow_mode != "CHAT" {
3195                            app.push_message("Tool", &format!("{}  {} → {}", icon, name, preview));
3196                        } else if is_error {
3197                            app.push_message("System", &format!("Tool error: {}", preview));
3198                        }
3199
3200                        // If it was a read or write, we can extract the path from the app.active_context "Running" entries
3201                        // but it's simpler to just let Specular handle the indexing or update here if we had the path.
3202
3203                        // Remove "Running" tools from context list
3204                        app.active_context.retain(|f| f.path != name || f.status != "Running");
3205                        app.manual_scroll_offset = None;
3206                    }
3207                    InferenceEvent::ApprovalRequired { id: _, name, display, diff, mutation_label, responder } => {
3208                        if app.stop_requested {
3209                            let _ = responder.send(false);
3210                            continue;
3211                        }
3212                        // Session-level auto-approve: skip dialog entirely.
3213                        if app.auto_approve_session {
3214                            if let Some(ref diff) = diff {
3215                                let added = diff.lines().filter(|l| l.starts_with("+ ")).count();
3216                                let removed = diff.lines().filter(|l| l.starts_with("- ")).count();
3217                                app.push_message("System", &format!(
3218                                    "Auto-approved: {} +{} -{}", display, added, removed
3219                                ));
3220                            } else {
3221                                app.push_message("System", &format!("Auto-approved: {}", display));
3222                            }
3223                            let _ = responder.send(true);
3224                            continue;
3225                        }
3226                        let is_diff = diff.is_some();
3227                        app.awaiting_approval = Some(PendingApproval {
3228                            display: display.clone(),
3229                            tool_name: name,
3230                            diff,
3231                            diff_scroll: 0,
3232                            mutation_label,
3233                            responder,
3234                        });
3235                        if is_diff {
3236                            app.push_message("System", "[~]  Diff preview — [Y] Apply  [N] Skip  [A] Accept All");
3237                        } else {
3238                            app.push_message("System", "[!]  Approval required — [Y] Approve  [N] Decline  [A] Accept All");
3239                            app.push_message("System", &format!("Command: {}", display));
3240                        }
3241                    }
3242                    InferenceEvent::UsageUpdate(usage) => {
3243                        app.total_tokens = usage.total_tokens;
3244                        // Calculate discounted cost for this turn.
3245                        let turn_cost = crate::agent::pricing::calculate_cost(&usage, &app.model_id);
3246                        app.current_session_cost += turn_cost;
3247                    }
3248                    InferenceEvent::Done => {
3249                        app.thinking = false;
3250                        app.agent_running = false;
3251                        app.stop_requested = false;
3252                        if app.voice_manager.is_enabled() {
3253                            app.voice_manager.flush();
3254                        }
3255                        if !app.current_thought.is_empty() {
3256                            app.last_reasoning = app.current_thought.clone();
3257                        }
3258                        app.current_thought.clear();
3259                        // Force one last repaint of the visible chat buffer in case the
3260                        // final streamed token chunk did not trigger the lightweight
3261                        // reformat heuristic in update_last_message().
3262                        app.rebuild_formatted_messages();
3263                        app.manual_scroll_offset = None;
3264                        app.specular_auto_scroll = true;
3265                        // Clear single-agent task bars on completion
3266                        app.active_workers.remove("AGENT");
3267                        app.worker_labels.remove("AGENT");
3268                    }
3269                    InferenceEvent::CopyDiveInCommand(path) => {
3270                        let command = format!("cd \"{}\" && hematite", path.replace('\\', "/"));
3271                        copy_text_to_clipboard(&command);
3272                        spawn_dive_in_terminal(&path);
3273                        app.push_message("System", &format!("Teleportation initiated: New terminal launched at {}", path));
3274                        app.push_message("System", "Teleportation complete. Closing original session to maintain workstation hygiene...");
3275
3276                        // Self-Destruct Sequence: Graceful exit matching Ctrl+Q behavior
3277                        app.write_session_report();
3278                        app.copy_transcript_to_clipboard();
3279                        break;
3280                    }
3281                    InferenceEvent::ChainImplementPlan => {
3282                        app.push_message("You", "/implement-plan (Autonomous Handoff)");
3283                        app.manual_scroll_offset = None;
3284                    }
3285                    InferenceEvent::Error(e) => {
3286                        app.record_error();
3287                        app.thinking = false;
3288                        app.agent_running = false;
3289                        if app.voice_manager.is_enabled() {
3290                            app.voice_manager.flush();
3291                        }
3292                        app.push_message("System", &format!("Error: {e}"));
3293                    }
3294                    InferenceEvent::ProviderStatus { state, summary } => {
3295                        app.provider_state = state;
3296                        if !summary.trim().is_empty() && app.last_provider_summary != summary {
3297                            app.specular_logs.push(format!("PROVIDER: {}", summary));
3298                            trim_vec(&mut app.specular_logs, 20);
3299                            app.last_provider_summary = summary;
3300                        }
3301                    }
3302                    InferenceEvent::McpStatus { state, summary } => {
3303                        app.mcp_state = state;
3304                        if !summary.trim().is_empty() && app.last_mcp_summary != summary {
3305                            app.specular_logs.push(format!("MCP: {}", summary));
3306                            trim_vec(&mut app.specular_logs, 20);
3307                            app.last_mcp_summary = summary;
3308                        }
3309                    }
3310                    InferenceEvent::OperatorCheckpoint { state, summary } => {
3311                        app.last_operator_checkpoint_state = state;
3312                        if state == OperatorCheckpointState::Idle {
3313                            app.last_operator_checkpoint_summary.clear();
3314                        } else if !summary.trim().is_empty()
3315                            && app.last_operator_checkpoint_summary != summary
3316                        {
3317                            app.specular_logs.push(format!(
3318                                "STATE: {} - {}",
3319                                state.label(),
3320                                summary
3321                            ));
3322                            trim_vec(&mut app.specular_logs, 20);
3323                            app.last_operator_checkpoint_summary = summary;
3324                        }
3325                    }
3326                    InferenceEvent::RecoveryRecipe { summary } => {
3327                        if !summary.trim().is_empty()
3328                            && app.last_recovery_recipe_summary != summary
3329                        {
3330                            app.specular_logs.push(format!("RECOVERY: {}", summary));
3331                            trim_vec(&mut app.specular_logs, 20);
3332                            app.last_recovery_recipe_summary = summary;
3333                        }
3334                    }
3335                    InferenceEvent::CompactionPressure {
3336                        estimated_tokens,
3337                        threshold_tokens,
3338                        percent,
3339                    } => {
3340                        app.compaction_estimated_tokens = estimated_tokens;
3341                        app.compaction_threshold_tokens = threshold_tokens;
3342                        app.compaction_percent = percent;
3343                        // Fire a one-shot warning when crossing 70% or 90%.
3344                        // Reset warned_level to 0 when pressure drops back below 60%
3345                        // so warnings re-fire if context fills up again after a /new.
3346                        if percent < 60 {
3347                            app.compaction_warned_level = 0;
3348                        } else if percent >= 90 && app.compaction_warned_level < 90 {
3349                            app.compaction_warned_level = 90;
3350                            app.push_message(
3351                                "System",
3352                                "Context is 90% full. Use /new to reset history (project memory is preserved) or /forget to wipe everything.",
3353                            );
3354                        } else if percent >= 70 && app.compaction_warned_level < 70 {
3355                            app.compaction_warned_level = 70;
3356                            app.push_message(
3357                                "System",
3358                                &format!("Context at {}% — approaching the compaction threshold. Consider /new soon to keep responses sharp.", percent),
3359                            );
3360                        }
3361                    }
3362                    InferenceEvent::PromptPressure {
3363                        estimated_input_tokens,
3364                        reserved_output_tokens,
3365                        estimated_total_tokens,
3366                        context_length: _,
3367                        percent,
3368                    } => {
3369                        app.prompt_estimated_input_tokens = estimated_input_tokens;
3370                        app.prompt_reserved_output_tokens = reserved_output_tokens;
3371                        app.prompt_estimated_total_tokens = estimated_total_tokens;
3372                        app.prompt_pressure_percent = percent;
3373                    }
3374                    InferenceEvent::TaskProgress { id, label, progress } => {
3375                        let nid = normalize_id(&id);
3376                        app.active_workers.insert(nid.clone(), progress);
3377                        app.worker_labels.insert(nid, label);
3378                    }
3379                    InferenceEvent::RuntimeProfile { model_id, context_length } => {
3380                        let was_no_model = app.model_id == "no model loaded";
3381                        let now_no_model = model_id == "no model loaded";
3382                        let changed = app.model_id != "detecting..."
3383                            && (app.model_id != model_id || app.context_length != context_length);
3384                        app.model_id = model_id.clone();
3385                        app.context_length = context_length;
3386                        app.last_runtime_profile_time = Instant::now();
3387                        if app.provider_state == ProviderRuntimeState::Booting {
3388                            app.provider_state = ProviderRuntimeState::Live;
3389                        }
3390                        if now_no_model && !was_no_model {
3391                            app.push_message(
3392                                "System",
3393                                "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.",
3394                            );
3395                        } else if changed && !now_no_model {
3396                            app.push_message(
3397                                "System",
3398                                &format!(
3399                                    "Runtime profile refreshed: Model {} | CTX {}",
3400                                    model_id, context_length
3401                                ),
3402                            );
3403                        }
3404                    }
3405                    InferenceEvent::EmbedProfile { model_id } => {
3406                        match model_id {
3407                            Some(id) => app.push_message(
3408                                "System",
3409                                &format!("Embed model loaded: {} (semantic search ready)", id),
3410                            ),
3411                            None => app.push_message(
3412                                "System",
3413                                "Embed model unloaded. Semantic search inactive.",
3414                            ),
3415                        }
3416                    }
3417                    InferenceEvent::VeinStatus { file_count, embedded_count, docs_only } => {
3418                        app.vein_file_count = file_count;
3419                        app.vein_embedded_count = embedded_count;
3420                        app.vein_docs_only = docs_only;
3421                    }
3422                    InferenceEvent::VeinContext { paths } => {
3423                        // Replace the default placeholder entries with what the
3424                        // Vein actually surfaced for this turn.
3425                        app.active_context.retain(|f| f.status == "Running");
3426                        for path in paths {
3427                            let root = crate::tools::file_ops::workspace_root();
3428                            let size = std::fs::metadata(root.join(&path))
3429                                .map(|m| m.len())
3430                                .unwrap_or(0);
3431                            if !app.active_context.iter().any(|f| f.path == path) {
3432                                app.active_context.push(ContextFile {
3433                                    path,
3434                                    size,
3435                                    status: "Vein".to_string(),
3436                                });
3437                            }
3438                        }
3439                        trim_vec_context(&mut app.active_context, 8);
3440                    }
3441                    InferenceEvent::SoulReroll { species, rarity, shiny, .. } => {
3442                        let shiny_tag = if shiny { " 🌟 SHINY" } else { "" };
3443                        app.soul_name = species.clone();
3444                        app.push_message(
3445                            "System",
3446                            &format!("[{}{}] {} has awakened.", rarity, shiny_tag, species),
3447                        );
3448                    }
3449                    InferenceEvent::ShellLine(line) => {
3450                        // Stream shell output into the SPECULAR side panel as it
3451                        // arrives so the operator sees live progress.
3452                        app.current_thought.push_str(&line);
3453                        app.current_thought.push('\n');
3454                    }
3455                }
3456            }
3457
3458            // ── Swarm messages ────────────────────────────────────────────────
3459            Some(msg) = swarm_rx.recv() => {
3460                match msg {
3461                    SwarmMessage::Progress(worker_id, progress) => {
3462                        let nid = normalize_id(&worker_id);
3463                        app.active_workers.insert(nid.clone(), progress);
3464                        match progress {
3465                            102 => app.push_message("System", &format!("Worker {} architecture verified and applied.", nid)),
3466                            101 => { /* Handled by 102 terminal message */ },
3467                            100 => app.push_message("Hematite", &format!("Worker {} complete. Standing by for review...", nid)),
3468                            _ => {}
3469                        }
3470                    }
3471                    SwarmMessage::ReviewRequest { worker_id, file_path, before, after, tx } => {
3472                        app.push_message("Hematite", &format!("Worker {} conflict — review required.", worker_id));
3473                        app.active_review = Some(ActiveReview {
3474                            worker_id,
3475                            file_path: file_path.to_string_lossy().to_string(),
3476                            before,
3477                            after,
3478                            tx,
3479                        });
3480                    }
3481                    SwarmMessage::Done => {
3482                        app.agent_running = false;
3483                        // Workers now persist in SPECULAR until a new command is issued
3484                        app.push_message("System", "──────────────────────────────────────────────────────────");
3485                        app.push_message("System", " TASK COMPLETE: Swarm Synthesis Finalized ");
3486                        app.push_message("System", "──────────────────────────────────────────────────────────");
3487                    }
3488                }
3489            }
3490        }
3491    }
3492    Ok(())
3493}
3494
3495// ── Render ────────────────────────────────────────────────────────────────────
3496
3497fn ui(f: &mut ratatui::Frame, app: &App) {
3498    let size = f.size();
3499    if size.width < 60 || size.height < 10 {
3500        // Render a minimal wait message or just clear if area is too collapsed
3501        f.render_widget(Clear, size);
3502        return;
3503    }
3504
3505    let input_height = compute_input_height(f.size().width, app.input.len());
3506
3507    let chunks = Layout::default()
3508        .direction(Direction::Vertical)
3509        .constraints([
3510            Constraint::Min(0),
3511            Constraint::Length(input_height),
3512            Constraint::Length(3),
3513        ])
3514        .split(f.size());
3515
3516    let top = Layout::default()
3517        .direction(Direction::Horizontal)
3518        .constraints([Constraint::Fill(1), Constraint::Length(45)]) // Fixed width sidebar prevents bleed
3519        .split(chunks[0]);
3520
3521    // ── Box 1: Dialogue ───────────────────────────────────────────────────────
3522    let mut core_lines = app.messages.clone();
3523
3524    // Show agent-running indicator as last line when active.
3525    if app.agent_running {
3526        let dots = ".".repeat((app.tick_count % 4) as usize + 1);
3527        core_lines.push(Line::from(Span::styled(
3528            format!(" Hematite is thinking{}", dots),
3529            Style::default()
3530                .fg(Color::Magenta)
3531                .add_modifier(Modifier::DIM),
3532        )));
3533    }
3534
3535    let (heart_color, core_icon) = if app.agent_running || !app.active_workers.is_empty() {
3536        let (r_base, g_base, b_base) = if !app.active_workers.is_empty() {
3537            (0, 200, 200) // Cyan pulse for swarm
3538        } else {
3539            (200, 0, 200) // Magenta pulse for thinking
3540        };
3541
3542        let pulse = (app.tick_count % 50) as f64 / 50.0;
3543        let factor = (pulse * std::f64::consts::PI).sin().abs();
3544        let r = (r_base as f64 * factor) as u8;
3545        let g = (g_base as f64 * factor) as u8;
3546        let b = (b_base as f64 * factor) as u8;
3547
3548        (Color::Rgb(r.max(60), g.max(60), b.max(60)), "•")
3549    } else {
3550        (Color::Rgb(80, 80, 80), "•") // Standby
3551    };
3552
3553    let live_objective = if app.current_objective != "Idle" {
3554        app.current_objective.clone()
3555    } else if !app.active_workers.is_empty() {
3556        "Swarm active".to_string()
3557    } else if app.thinking {
3558        "Reasoning".to_string()
3559    } else if app.agent_running {
3560        "Working".to_string()
3561    } else {
3562        "Idle".to_string()
3563    };
3564
3565    let objective_text = if live_objective.len() > 30 {
3566        format!("{}...", &live_objective[..27])
3567    } else {
3568        live_objective
3569    };
3570
3571    let core_title = if app.professional {
3572        Line::from(vec![
3573            Span::styled(format!(" {} ", core_icon), Style::default().fg(heart_color)),
3574            Span::styled("HEMATITE ", Style::default().add_modifier(Modifier::BOLD)),
3575            Span::styled(
3576                format!(" TASK: {} ", objective_text),
3577                Style::default()
3578                    .fg(Color::Yellow)
3579                    .add_modifier(Modifier::ITALIC),
3580            ),
3581        ])
3582    } else {
3583        Line::from(format!(" TASK: {} ", objective_text))
3584    };
3585
3586    let core_para = Paragraph::new(core_lines.clone())
3587        .block(
3588            Block::default()
3589                .title(core_title)
3590                .borders(Borders::ALL)
3591                .border_style(Style::default().fg(Color::DarkGray)),
3592        )
3593        .wrap(Wrap { trim: true });
3594
3595    // Enhanced Scroll calculation.
3596    let avail_h = top[0].height.saturating_sub(2);
3597    // Borders (2) + Scrollbar (1) + explicit Padding (1) = 4.
3598    let inner_w = top[0].width.saturating_sub(4).max(1);
3599
3600    let mut total_lines: u16 = 0;
3601    for line in &core_lines {
3602        let line_w = line.width() as u16;
3603        if line_w == 0 {
3604            total_lines += 1;
3605        } else {
3606            // TUI SCROLL FIX:
3607            // Exact calculation: how many times does line_w fit into inner_w?
3608            // This matches Paragraph's internal Wrap logic closely.
3609            let wrapped = (line_w + inner_w - 1) / inner_w;
3610            total_lines += wrapped;
3611        }
3612    }
3613
3614    let max_scroll = total_lines.saturating_sub(avail_h);
3615    let scroll = if let Some(off) = app.manual_scroll_offset {
3616        max_scroll.saturating_sub(off)
3617    } else {
3618        max_scroll
3619    };
3620
3621    // Clear the outer chunk and the inner dialogue area to prevent ghosting from previous frames or background renders.
3622    f.render_widget(Clear, top[0]);
3623
3624    // Create a sub-area for the dialogue with horizontal padding.
3625    let chat_area = Rect::new(
3626        top[0].x + 1,
3627        top[0].y,
3628        top[0].width.saturating_sub(2).max(1),
3629        top[0].height,
3630    );
3631    f.render_widget(Clear, chat_area);
3632    f.render_widget(core_para.scroll((scroll, 0)), chat_area);
3633
3634    // Scrollbar: content_length = max_scroll+1 so position==max_scroll puts the
3635    // thumb flush at the bottom (position == content_length - 1).
3636    let mut scrollbar_state =
3637        ScrollbarState::new(max_scroll as usize + 1).position(scroll as usize);
3638    f.render_stateful_widget(
3639        Scrollbar::default()
3640            .orientation(ScrollbarOrientation::VerticalRight)
3641            .begin_symbol(Some("↑"))
3642            .end_symbol(Some("↓")),
3643        top[0],
3644        &mut scrollbar_state,
3645    );
3646
3647    // ── Box 2: Side panel ─────────────────────────────────────────────────────
3648    let side = Layout::default()
3649        .direction(Direction::Vertical)
3650        .constraints([
3651            Constraint::Length(8), // CONTEXT
3652            Constraint::Min(0),    // SPECULAR
3653        ])
3654        .split(top[1]);
3655
3656    // Pane 1: Context (Nervous focus)
3657    let context_source = if app.active_context.is_empty() {
3658        default_active_context()
3659    } else {
3660        app.active_context.clone()
3661    };
3662    let mut context_display = context_source
3663        .iter()
3664        .map(|f| {
3665            let (icon, color) = match f.status.as_str() {
3666                "Running" => ("⚙️", Color::Cyan),
3667                "Dirty" => ("📝", Color::Yellow),
3668                _ => ("📄", Color::Gray),
3669            };
3670            // Simple heuristic for "Tokens" (size / 4)
3671            let tokens = f.size / 4;
3672            ListItem::new(Line::from(vec![
3673                Span::styled(format!(" {} ", icon), Style::default().fg(color)),
3674                Span::styled(f.path.clone(), Style::default().fg(Color::White)),
3675                Span::styled(
3676                    format!(" {}t ", tokens),
3677                    Style::default().fg(Color::DarkGray),
3678                ),
3679            ]))
3680        })
3681        .collect::<Vec<ListItem>>();
3682
3683    if context_display.is_empty() {
3684        context_display = vec![ListItem::new(" (No active files)")];
3685    }
3686
3687    let ctx_block = Block::default()
3688        .title(" ACTIVE CONTEXT ")
3689        .borders(Borders::ALL)
3690        .border_style(Style::default().fg(Color::DarkGray));
3691
3692    f.render_widget(Clear, side[0]);
3693    f.render_widget(List::new(context_display).block(ctx_block), side[0]);
3694
3695    // Optional: Add a Gauge for total context if tokens were tracked accurately.
3696    // For now, let's just make the CONTEXT pane look high-density.
3697
3698    // ── SPECULAR panel (Pane 2) ────────────────────────────────────────────────
3699    let v_title = if app.thinking || app.agent_running {
3700        format!(" SPECULAR [working] ")
3701    } else {
3702        " SPECULAR [Watching] ".to_string()
3703    };
3704
3705    f.render_widget(Clear, side[1]);
3706
3707    let mut v_lines: Vec<Line<'static>> = Vec::new();
3708
3709    // Section: live thought (bounded to last 300 chars to avoid wall-of-text)
3710    if app.thinking || app.agent_running {
3711        let dots = ".".repeat((app.tick_count % 4) as usize + 1);
3712        let label = if app.thinking { "REASONING" } else { "WORKING" };
3713        v_lines.push(Line::from(vec![Span::styled(
3714            format!("[ {}{} ]", label, dots),
3715            Style::default()
3716                .fg(Color::Green)
3717                .add_modifier(Modifier::BOLD),
3718        )]));
3719        // Show last 300 chars of current thought, split by line.
3720        let preview = if app.current_thought.chars().count() > 300 {
3721            app.current_thought
3722                .chars()
3723                .rev()
3724                .take(300)
3725                .collect::<Vec<_>>()
3726                .into_iter()
3727                .rev()
3728                .collect::<String>()
3729        } else {
3730            app.current_thought.clone()
3731        };
3732        for raw in preview.lines() {
3733            let raw = raw.trim();
3734            if !raw.is_empty() {
3735                v_lines.extend(render_markdown_line(raw));
3736            }
3737        }
3738        v_lines.push(Line::raw(""));
3739    }
3740
3741    // Section: worker progress bars
3742    if !app.active_workers.is_empty() {
3743        v_lines.push(Line::from(vec![Span::styled(
3744            "── Task Progress ──",
3745            Style::default()
3746                .fg(Color::White)
3747                .add_modifier(Modifier::DIM),
3748        )]));
3749
3750        let mut sorted_ids: Vec<_> = app.active_workers.keys().cloned().collect();
3751        sorted_ids.sort();
3752
3753        for id in sorted_ids {
3754            let prog = app.active_workers[&id];
3755            let custom_label = app.worker_labels.get(&id).cloned();
3756
3757            let (label, color) = match prog {
3758                101..=102 => ("VERIFIED", Color::Green),
3759                100 if !app.agent_running && id != "AGENT" => ("SKIPPED ", Color::DarkGray),
3760                100 => ("REVIEW  ", Color::Magenta),
3761                _ => ("WORKING ", Color::Yellow),
3762            };
3763
3764            let display_label = custom_label.unwrap_or_else(|| label.to_string());
3765            let filled = (prog.min(100) / 10) as usize;
3766            let bar = "▓".repeat(filled) + &"░".repeat(10 - filled);
3767
3768            let id_prefix = if id == "AGENT" {
3769                "Agent: ".to_string()
3770            } else {
3771                format!("W{}: ", id)
3772            };
3773
3774            v_lines.push(Line::from(vec![
3775                Span::styled(id_prefix, Style::default().fg(Color::Gray)),
3776                Span::styled(bar, Style::default().fg(color)),
3777                Span::styled(
3778                    format!(" {} ", display_label),
3779                    Style::default().fg(color).add_modifier(Modifier::BOLD),
3780                ),
3781                Span::styled(
3782                    format!("{}%", prog.min(100)),
3783                    Style::default().fg(Color::DarkGray),
3784                ),
3785            ]));
3786        }
3787        v_lines.push(Line::raw(""));
3788    }
3789
3790    // Section: last completed turn's reasoning
3791    if !app.last_reasoning.is_empty() {
3792        v_lines.push(Line::from(vec![Span::styled(
3793            "── Logic Trace ──",
3794            Style::default()
3795                .fg(Color::White)
3796                .add_modifier(Modifier::DIM),
3797        )]));
3798        for raw in app.last_reasoning.lines() {
3799            v_lines.extend(render_markdown_line(raw));
3800        }
3801        v_lines.push(Line::raw(""));
3802    }
3803
3804    // Section: specular event log
3805    if !app.specular_logs.is_empty() {
3806        v_lines.push(Line::from(vec![Span::styled(
3807            "── Events ──",
3808            Style::default()
3809                .fg(Color::White)
3810                .add_modifier(Modifier::DIM),
3811        )]));
3812        for log in &app.specular_logs {
3813            let (icon, color) = if log.starts_with("ERROR") {
3814                ("X ", Color::Red)
3815            } else if log.starts_with("INDEX") {
3816                ("I ", Color::Cyan)
3817            } else if log.starts_with("GHOST") {
3818                ("< ", Color::Magenta)
3819            } else {
3820                ("- ", Color::Gray)
3821            };
3822            v_lines.push(Line::from(vec![
3823                Span::styled(icon, Style::default().fg(color)),
3824                Span::styled(
3825                    log.to_string(),
3826                    Style::default()
3827                        .fg(Color::White)
3828                        .add_modifier(Modifier::DIM),
3829                ),
3830            ]));
3831        }
3832    }
3833
3834    let v_total = v_lines.len() as u16;
3835    let v_avail = side[1].height.saturating_sub(2);
3836    let v_max_scroll = v_total.saturating_sub(v_avail);
3837    // If auto-scroll is active, always show the bottom. Otherwise respect the
3838    // user's manual position (clamped so we never scroll past the content end).
3839    let v_scroll = if app.specular_auto_scroll {
3840        v_max_scroll
3841    } else {
3842        app.specular_scroll.min(v_max_scroll)
3843    };
3844
3845    let specular_para = Paragraph::new(v_lines)
3846        .wrap(Wrap { trim: true })
3847        .scroll((v_scroll, 0))
3848        .block(Block::default().title(v_title).borders(Borders::ALL));
3849
3850    f.render_widget(specular_para, side[1]);
3851
3852    // Scrollbar for SPECULAR
3853    let mut v_scrollbar_state =
3854        ScrollbarState::new(v_max_scroll as usize + 1).position(v_scroll as usize);
3855    f.render_stateful_widget(
3856        Scrollbar::default()
3857            .orientation(ScrollbarOrientation::VerticalRight)
3858            .begin_symbol(None)
3859            .end_symbol(None),
3860        side[1],
3861        &mut v_scrollbar_state,
3862    );
3863
3864    // ── Box 3: Status bar ─────────────────────────────────────────────────────
3865    let frame = app.tick_count % 3;
3866    let spark = match frame {
3867        0 => "✧",
3868        1 => "✦",
3869        _ => "✨",
3870    };
3871    let vigil = if app.brief_mode {
3872        "VIGIL:[ON]"
3873    } else {
3874        "VIGIL:[off]"
3875    };
3876    let yolo = if app.yolo_mode {
3877        " | APPROVALS: OFF"
3878    } else {
3879        ""
3880    };
3881
3882    let bar_constraints = if app.professional {
3883        vec![
3884            Constraint::Min(0),     // MODE
3885            Constraint::Length(22), // LM + VN badge
3886            Constraint::Length(12), // BUD
3887            Constraint::Length(12), // CMP
3888            Constraint::Length(16), // REMOTE
3889            Constraint::Length(28), // TOKENS
3890            Constraint::Length(28), // VRAM
3891        ]
3892    } else {
3893        vec![
3894            Constraint::Length(12), // NAME
3895            Constraint::Min(0),     // MODE
3896            Constraint::Length(22), // LM + VN badge
3897            Constraint::Length(12), // BUD
3898            Constraint::Length(12), // CMP
3899            Constraint::Length(16), // REMOTE
3900            Constraint::Length(28), // TOKENS
3901            Constraint::Length(28), // VRAM
3902        ]
3903    };
3904    let bar_chunks = Layout::default()
3905        .direction(Direction::Horizontal)
3906        .constraints(bar_constraints)
3907        .split(chunks[2]);
3908
3909    let char_count: usize = app.messages_raw.iter().map(|(_, c)| c.len()).sum();
3910    let est_tokens = char_count / 3;
3911    let current_tokens = if app.total_tokens > 0 {
3912        app.total_tokens
3913    } else {
3914        est_tokens
3915    };
3916    let usage_text = format!(
3917        "TOKENS: {:0>5} | TOTAL: ${:.4}",
3918        current_tokens, app.current_session_cost
3919    );
3920    let runtime_age = app.last_runtime_profile_time.elapsed();
3921    let (lm_label, lm_color) = if app.model_id == "no model loaded" {
3922        ("LM:NONE", Color::Red)
3923    } else if app.model_id == "detecting..." || app.context_length == 0 {
3924        ("LM:BOOT", Color::DarkGray)
3925    } else if app.provider_state == ProviderRuntimeState::Recovering {
3926        ("LM:RECV", Color::Cyan)
3927    } else if matches!(
3928        app.provider_state,
3929        ProviderRuntimeState::Degraded | ProviderRuntimeState::EmptyResponse
3930    ) {
3931        ("LM:WARN", Color::Red)
3932    } else if app.provider_state == ProviderRuntimeState::ContextWindow {
3933        ("LM:CEIL", Color::Yellow)
3934    } else if runtime_age > std::time::Duration::from_secs(12) {
3935        ("LM:STALE", Color::Yellow)
3936    } else {
3937        ("LM:LIVE", Color::Green)
3938    };
3939    let compaction_percent = app.compaction_percent.min(100);
3940    let compaction_label = if app.compaction_threshold_tokens == 0 {
3941        " CMP:  0%".to_string()
3942    } else {
3943        format!(" CMP:{:>3}%", compaction_percent)
3944    };
3945    let compaction_color = if app.compaction_threshold_tokens == 0 {
3946        Color::DarkGray
3947    } else if compaction_percent >= 85 {
3948        Color::Red
3949    } else if compaction_percent >= 60 {
3950        Color::Yellow
3951    } else {
3952        Color::Green
3953    };
3954    let prompt_percent = app.prompt_pressure_percent.min(100);
3955    let prompt_label = if app.prompt_estimated_total_tokens == 0 {
3956        " BUD:  0%".to_string()
3957    } else {
3958        format!(" BUD:{:>3}%", prompt_percent)
3959    };
3960    let prompt_color = if app.prompt_estimated_total_tokens == 0 {
3961        Color::DarkGray
3962    } else if prompt_percent >= 85 {
3963        Color::Red
3964    } else if prompt_percent >= 60 {
3965        Color::Yellow
3966    } else {
3967        Color::Green
3968    };
3969
3970    let think_badge = match app.think_mode {
3971        Some(true) => " [THINK]",
3972        Some(false) => " [FAST]",
3973        None => "",
3974    };
3975
3976    let (vein_label, vein_color) = if app.vein_docs_only {
3977        let color = if app.vein_embedded_count > 0 {
3978            Color::Green
3979        } else if app.vein_file_count > 0 {
3980            Color::Yellow
3981        } else {
3982            Color::DarkGray
3983        };
3984        ("VN:DOC", color)
3985    } else if app.vein_file_count == 0 {
3986        ("VN:--", Color::DarkGray)
3987    } else if app.vein_embedded_count > 0 {
3988        ("VN:SEM", Color::Green)
3989    } else {
3990        ("VN:FTS", Color::Yellow)
3991    };
3992
3993    let (status_idx, lm_idx, bud_idx, cmp_idx, remote_idx, tokens_idx, vram_idx) =
3994        if app.professional {
3995            (0usize, 1usize, 2usize, 3usize, 4usize, 5usize, 6usize)
3996        } else {
3997            (1usize, 2usize, 3usize, 4usize, 5usize, 6usize, 7usize)
3998        };
3999
4000    if app.professional {
4001        f.render_widget(Clear, bar_chunks[status_idx]);
4002
4003        let voice_badge = if app.voice_manager.is_enabled() {
4004            " | VOICE:ON"
4005        } else {
4006            ""
4007        };
4008        f.render_widget(
4009            Paragraph::new(format!(
4010                " MODE:PRO | FLOW:{}{} | CTX:{} | ERR:{}{}{}",
4011                app.workflow_mode,
4012                yolo,
4013                app.context_length,
4014                app.stats.debugging,
4015                think_badge,
4016                voice_badge
4017            ))
4018            .block(Block::default().borders(Borders::ALL)),
4019            bar_chunks[status_idx],
4020        );
4021    } else {
4022        f.render_widget(Clear, bar_chunks[0]);
4023        f.render_widget(
4024            Paragraph::new(format!(" {} {}", spark, app.soul_name))
4025                .block(Block::default().borders(Borders::ALL)),
4026            bar_chunks[0],
4027        );
4028        f.render_widget(Clear, bar_chunks[status_idx]);
4029        f.render_widget(
4030            Paragraph::new(format!("{}{}", vigil, think_badge))
4031                .block(Block::default().borders(Borders::ALL).fg(Color::Yellow)),
4032            bar_chunks[status_idx],
4033        );
4034    }
4035
4036    // ── Remote status indicator ──────────────────────────────────────────────
4037    let git_status = app.git_state.status();
4038    let git_label = app.git_state.label();
4039    let git_color = match git_status {
4040        crate::agent::git_monitor::GitRemoteStatus::Connected => Color::Green,
4041        crate::agent::git_monitor::GitRemoteStatus::NoRemote => Color::Yellow,
4042        crate::agent::git_monitor::GitRemoteStatus::Behind
4043        | crate::agent::git_monitor::GitRemoteStatus::Ahead => Color::Magenta,
4044        crate::agent::git_monitor::GitRemoteStatus::Diverged
4045        | crate::agent::git_monitor::GitRemoteStatus::Error => Color::Red,
4046        _ => Color::DarkGray,
4047    };
4048
4049    f.render_widget(Clear, bar_chunks[lm_idx]);
4050    f.render_widget(
4051        Paragraph::new(ratatui::text::Line::from(vec![
4052            ratatui::text::Span::styled(format!(" {}", lm_label), Style::default().fg(lm_color)),
4053            ratatui::text::Span::raw(" | "),
4054            ratatui::text::Span::styled(vein_label, Style::default().fg(vein_color)),
4055        ]))
4056        .block(
4057            Block::default()
4058                .borders(Borders::ALL)
4059                .border_style(Style::default().fg(lm_color)),
4060        ),
4061        bar_chunks[lm_idx],
4062    );
4063
4064    f.render_widget(Clear, bar_chunks[bud_idx]);
4065    f.render_widget(
4066        Paragraph::new(prompt_label)
4067            .block(
4068                Block::default()
4069                    .borders(Borders::ALL)
4070                    .border_style(Style::default().fg(prompt_color)),
4071            )
4072            .fg(prompt_color),
4073        bar_chunks[bud_idx],
4074    );
4075
4076    f.render_widget(Clear, bar_chunks[cmp_idx]);
4077    f.render_widget(
4078        Paragraph::new(compaction_label)
4079            .block(
4080                Block::default()
4081                    .borders(Borders::ALL)
4082                    .border_style(Style::default().fg(compaction_color)),
4083            )
4084            .fg(compaction_color),
4085        bar_chunks[cmp_idx],
4086    );
4087
4088    f.render_widget(Clear, bar_chunks[remote_idx]);
4089    f.render_widget(
4090        Paragraph::new(format!(" REMOTE: {}", git_label))
4091            .block(
4092                Block::default()
4093                    .borders(Borders::ALL)
4094                    .border_style(Style::default().fg(git_color)),
4095            )
4096            .fg(git_color),
4097        bar_chunks[remote_idx],
4098    );
4099
4100    let usage_color = Color::Rgb(215, 125, 40);
4101    f.render_widget(Clear, bar_chunks[tokens_idx]);
4102    f.render_widget(
4103        Paragraph::new(usage_text)
4104            .block(Block::default().borders(Borders::ALL).fg(usage_color))
4105            .fg(usage_color),
4106        bar_chunks[tokens_idx],
4107    );
4108
4109    // ── VRAM gauge (live from nvidia-smi poller) ─────────────────────────────
4110    let vram_ratio = app.gpu_state.ratio();
4111    let vram_label = app.gpu_state.label();
4112    let gpu_name = app.gpu_state.gpu_name();
4113
4114    let gauge_color = if vram_ratio > 0.85 {
4115        Color::Red
4116    } else if vram_ratio > 0.60 {
4117        Color::Yellow
4118    } else {
4119        Color::Cyan
4120    };
4121    f.render_widget(Clear, bar_chunks[vram_idx]);
4122    f.render_widget(
4123        Gauge::default()
4124            .block(
4125                Block::default()
4126                    .borders(Borders::ALL)
4127                    .title(format!(" {} ", gpu_name)),
4128            )
4129            .gauge_style(Style::default().fg(gauge_color))
4130            .ratio(vram_ratio)
4131            .label(format!("  {}  ", vram_label)), // Added extra padding for visual excellence
4132        bar_chunks[vram_idx],
4133    );
4134
4135    // ── Box 4: Input ──────────────────────────────────────────────────────────
4136    let input_style = if app.agent_running {
4137        Style::default().fg(Color::DarkGray)
4138    } else {
4139        Style::default().fg(Color::Rgb(120, 70, 50))
4140    };
4141    let input_rect = chunks[1];
4142    let title_area = input_title_area(input_rect);
4143    let input_hint = render_input_title(app, title_area);
4144    let input_block = Block::default()
4145        .title(input_hint)
4146        .borders(Borders::ALL)
4147        .border_style(input_style)
4148        .style(Style::default().bg(Color::Rgb(40, 25, 15))); // Deeper soil rich background
4149
4150    let inner_area = input_block.inner(input_rect);
4151    f.render_widget(Clear, input_rect);
4152    f.render_widget(input_block, input_rect);
4153
4154    f.render_widget(
4155        Paragraph::new(app.input.as_str()).wrap(Wrap { trim: true }),
4156        inner_area,
4157    );
4158
4159    // Hardware Cursor (Managed by terminal emulator for smooth asynchronous blink)
4160    // Hardware Cursor (Managed by terminal emulator for smooth asynchronous blink)
4161    // Always call set_cursor during standard operation to "park" the cursor safely in the input box,
4162    // preventing it from jittering to (0,0) (the top-left title) during modal reviews.
4163    if !app.agent_running && inner_area.height > 0 {
4164        let text_w = app.input.len() as u16;
4165        let max_w = inner_area.width.saturating_sub(1);
4166        let cursor_x = inner_area.x + text_w.min(max_w);
4167        f.set_cursor(cursor_x, inner_area.y);
4168    }
4169
4170    // ── High-risk approval modal ───────────────────────────────────────────────
4171    if let Some(approval) = &app.awaiting_approval {
4172        let is_diff_preview = approval.diff.is_some();
4173
4174        // Taller modal for diff preview so more lines are visible.
4175        let modal_h = if is_diff_preview { 70 } else { 50 };
4176        let area = centered_rect(80, modal_h, f.size());
4177        f.render_widget(Clear, area);
4178
4179        let chunks = Layout::default()
4180            .direction(Direction::Vertical)
4181            .constraints([
4182                Constraint::Length(4), // Header: Title + Instructions
4183                Constraint::Min(0),    // Body: Tool + diff/command
4184            ])
4185            .split(area);
4186
4187        // ── Modal Header ─────────────────────────────────────────────────────
4188        let (title_str, title_color) = if let Some(_) = &approval.mutation_label {
4189            (" MUTATION REQUESTED — AUTHORISE THE WORKFLOW ", Color::Cyan)
4190        } else if is_diff_preview {
4191            (" DIFF PREVIEW — REVIEW BEFORE APPLYING ", Color::Yellow)
4192        } else {
4193            (" HIGH-RISK OPERATION REQUESTED ", Color::Red)
4194        };
4195        let header_text = vec![
4196            Line::from(Span::styled(
4197                title_str,
4198                Style::default()
4199                    .fg(title_color)
4200                    .add_modifier(Modifier::BOLD),
4201            )),
4202            if is_diff_preview {
4203                Line::from(Span::styled(
4204                    "  [↑↓/jk/PgUp/PgDn] Scroll   [Y] Apply   [N] Skip   [A] Accept All ",
4205                    Style::default()
4206                        .fg(Color::Green)
4207                        .add_modifier(Modifier::BOLD),
4208                ))
4209            } else {
4210                Line::from(vec![
4211                    Span::styled(
4212                        "  [Y] Approve  ",
4213                        Style::default()
4214                            .fg(Color::Green)
4215                            .add_modifier(Modifier::BOLD),
4216                    ),
4217                    Span::styled(
4218                        "  [N] Decline  ",
4219                        Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
4220                    ),
4221                    Span::styled(
4222                        "  [A] Accept All ",
4223                        Style::default()
4224                            .fg(Color::Magenta)
4225                            .add_modifier(Modifier::BOLD),
4226                    ),
4227                ])
4228            },
4229        ];
4230        f.render_widget(
4231            Paragraph::new(header_text)
4232                .block(
4233                    Block::default()
4234                        .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
4235                        .border_style(Style::default().fg(title_color)),
4236                )
4237                .alignment(ratatui::layout::Alignment::Center),
4238            chunks[0],
4239        );
4240
4241        // ── Modal Body ───────────────────────────────────────────────────────
4242        let border_color = if let Some(_) = &approval.mutation_label {
4243            Color::Cyan
4244        } else if is_diff_preview {
4245            Color::Yellow
4246        } else {
4247            Color::Red
4248        };
4249        if let Some(diff_text) = &approval.diff {
4250            // Render colored diff lines
4251            let added = diff_text.lines().filter(|l| l.starts_with("+ ")).count();
4252            let removed = diff_text.lines().filter(|l| l.starts_with("- ")).count();
4253            let mut body_lines: Vec<Line> = vec![
4254                Line::from(Span::styled(
4255                    if let Some(label) = &approval.mutation_label {
4256                        format!(" INTENT: {}", label)
4257                    } else {
4258                        format!(" {}", approval.display)
4259                    },
4260                    Style::default()
4261                        .fg(Color::Cyan)
4262                        .add_modifier(Modifier::BOLD),
4263                )),
4264                Line::from(vec![
4265                    Span::styled(
4266                        format!(" +{}", added),
4267                        Style::default()
4268                            .fg(Color::Green)
4269                            .add_modifier(Modifier::BOLD),
4270                    ),
4271                    Span::styled(
4272                        format!(" -{}", removed),
4273                        Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
4274                    ),
4275                ]),
4276                Line::from(Span::raw("")),
4277            ];
4278            for raw_line in diff_text.lines() {
4279                let styled = if raw_line.starts_with("+ ") {
4280                    Line::from(Span::styled(
4281                        format!(" {}", raw_line),
4282                        Style::default().fg(Color::Green),
4283                    ))
4284                } else if raw_line.starts_with("- ") {
4285                    Line::from(Span::styled(
4286                        format!(" {}", raw_line),
4287                        Style::default().fg(Color::Red),
4288                    ))
4289                } else if raw_line.starts_with("---") || raw_line.starts_with("@@ ") {
4290                    Line::from(Span::styled(
4291                        format!(" {}", raw_line),
4292                        Style::default()
4293                            .fg(Color::DarkGray)
4294                            .add_modifier(Modifier::BOLD),
4295                    ))
4296                } else {
4297                    Line::from(Span::raw(format!(" {}", raw_line)))
4298                };
4299                body_lines.push(styled);
4300            }
4301            f.render_widget(
4302                Paragraph::new(body_lines)
4303                    .block(
4304                        Block::default()
4305                            .borders(Borders::BOTTOM | Borders::LEFT | Borders::RIGHT)
4306                            .border_style(Style::default().fg(border_color)),
4307                    )
4308                    .scroll((approval.diff_scroll, 0)),
4309                chunks[1],
4310            );
4311        } else {
4312            let body_text = vec![
4313                Line::from(Span::raw("")),
4314                Line::from(Span::styled(
4315                    if let Some(label) = &approval.mutation_label {
4316                        format!(" INTENT: {}", label)
4317                    } else {
4318                        format!(" ACTION: {}", approval.display)
4319                    },
4320                    Style::default()
4321                        .fg(Color::Cyan)
4322                        .add_modifier(Modifier::BOLD),
4323                )),
4324                Line::from(Span::raw("")),
4325                Line::from(Span::styled(
4326                    format!("  Tool: {}", approval.tool_name),
4327                    Style::default().fg(Color::DarkGray),
4328                )),
4329            ];
4330            if approval.mutation_label.is_some() {
4331                // For mutations, show the original display (e.g. path) as extra info
4332            }
4333            f.render_widget(
4334                Paragraph::new(body_text)
4335                    .block(
4336                        Block::default()
4337                            .borders(Borders::BOTTOM | Borders::LEFT | Borders::RIGHT)
4338                            .border_style(Style::default().fg(border_color)),
4339                    )
4340                    .alignment(ratatui::layout::Alignment::Center),
4341                chunks[1],
4342            );
4343        }
4344    }
4345
4346    // ── Swarm diff review modal ────────────────────────────────────────────────
4347    if let Some(review) = &app.active_review {
4348        draw_diff_review(f, review);
4349    }
4350
4351    // ── Autocomplete Hatch (Floating Popup) ──────────────────────────────────
4352    if app.show_autocomplete && !app.autocomplete_suggestions.is_empty() {
4353        let area = Rect {
4354            x: chunks[1].x + 2,
4355            y: chunks[1]
4356                .y
4357                .saturating_sub(app.autocomplete_suggestions.len() as u16 + 2),
4358            width: chunks[1].width.saturating_sub(4),
4359            height: app.autocomplete_suggestions.len() as u16 + 2,
4360        };
4361        f.render_widget(Clear, area);
4362
4363        let items: Vec<ListItem> = app
4364            .autocomplete_suggestions
4365            .iter()
4366            .enumerate()
4367            .map(|(i, s)| {
4368                let style = if i == app.selected_suggestion {
4369                    Style::default()
4370                        .fg(Color::Black)
4371                        .bg(Color::Cyan)
4372                        .add_modifier(Modifier::BOLD)
4373                } else {
4374                    Style::default().fg(Color::Gray)
4375                };
4376                ListItem::new(format!(" 📄 {}", s)).style(style)
4377            })
4378            .collect();
4379
4380        let hatch = List::new(items).block(
4381            Block::default()
4382                .borders(Borders::ALL)
4383                .border_style(Style::default().fg(Color::Cyan))
4384                .title(format!(
4385                    " @ RESOLVER (Matching: {}) ",
4386                    app.autocomplete_filter
4387                )),
4388        );
4389        f.render_widget(hatch, area);
4390
4391        // Optional "More matches..." indicator
4392        if app.autocomplete_suggestions.len() >= 15 {
4393            let more_area = Rect {
4394                x: area.x + 2,
4395                y: area.y + area.height - 1,
4396                width: 20,
4397                height: 1,
4398            };
4399            f.render_widget(
4400                Paragraph::new("... (type to narrow) ").style(Style::default().fg(Color::DarkGray)),
4401                more_area,
4402            );
4403        }
4404    }
4405}
4406
4407// ── Helpers ───────────────────────────────────────────────────────────────────
4408
4409fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
4410    let vert = Layout::default()
4411        .direction(Direction::Vertical)
4412        .constraints([
4413            Constraint::Percentage((100 - percent_y) / 2),
4414            Constraint::Percentage(percent_y),
4415            Constraint::Percentage((100 - percent_y) / 2),
4416        ])
4417        .split(r);
4418    Layout::default()
4419        .direction(Direction::Horizontal)
4420        .constraints([
4421            Constraint::Percentage((100 - percent_x) / 2),
4422            Constraint::Percentage(percent_x),
4423            Constraint::Percentage((100 - percent_x) / 2),
4424        ])
4425        .split(vert[1])[1]
4426}
4427
4428fn strip_ghost_prefix(s: &str) -> &str {
4429    for prefix in &[
4430        "Hematite: ",
4431        "HEMATITE: ",
4432        "Assistant: ",
4433        "assistant: ",
4434        "Okay, ",
4435        "Hmm, ",
4436        "Wait, ",
4437        "Alright, ",
4438        "Got it, ",
4439        "Certainly, ",
4440        "Sure, ",
4441        "Understood, ",
4442    ] {
4443        if s.to_lowercase().starts_with(&prefix.to_lowercase()) {
4444            return &s[prefix.len()..];
4445        }
4446    }
4447    s
4448}
4449
4450fn first_n_chars(s: &str, n: usize) -> String {
4451    let mut result = String::new();
4452    let mut count = 0;
4453    for c in s.chars() {
4454        if count >= n {
4455            result.push('…');
4456            break;
4457        }
4458        if c == '\n' || c == '\r' {
4459            result.push(' ');
4460        } else if !c.is_control() {
4461            result.push(c);
4462        }
4463        count += 1;
4464    }
4465    result
4466}
4467
4468fn trim_vec_context(v: &mut Vec<ContextFile>, max: usize) {
4469    while v.len() > max {
4470        v.remove(0);
4471    }
4472}
4473
4474fn trim_vec(v: &mut Vec<String>, max: usize) {
4475    while v.len() > max {
4476        v.remove(0);
4477    }
4478}
4479
4480/// Minimal markdown → ratatui spans for the SPECULAR panel.
4481/// Handles: # headers, **bold**, `code`, - bullet, > blockquote, plain text.
4482fn render_markdown_line(raw: &str) -> Vec<Line<'static>> {
4483    // 1. Strip ANSI and control noise first to verify content.
4484    let cleaned_ansi = strip_ansi(raw);
4485    let trimmed = cleaned_ansi.trim();
4486    if trimmed.is_empty() {
4487        return vec![Line::raw("")];
4488    }
4489
4490    // 2. Strip thought tags.
4491    let cleaned_owned = trimmed
4492        .replace("<thought>", "")
4493        .replace("</thought>", "")
4494        .replace("<think>", "")
4495        .replace("</think>", "");
4496    let trimmed = cleaned_owned.trim();
4497    if trimmed.is_empty() {
4498        return vec![];
4499    }
4500
4501    // # Heading (all levels → bold white)
4502    for (prefix, indent) in &[("### ", "  "), ("## ", " "), ("# ", "")] {
4503        if let Some(rest) = trimmed.strip_prefix(prefix) {
4504            return vec![Line::from(vec![Span::styled(
4505                format!("{}{}", indent, rest),
4506                Style::default()
4507                    .fg(Color::White)
4508                    .add_modifier(Modifier::BOLD),
4509            )])];
4510        }
4511    }
4512
4513    // > blockquote
4514    if let Some(rest) = trimmed
4515        .strip_prefix("> ")
4516        .or_else(|| trimmed.strip_prefix(">"))
4517    {
4518        return vec![Line::from(vec![
4519            Span::styled("| ", Style::default().fg(Color::DarkGray)),
4520            Span::styled(
4521                rest.to_string(),
4522                Style::default()
4523                    .fg(Color::White)
4524                    .add_modifier(Modifier::DIM),
4525            ),
4526        ])];
4527    }
4528
4529    // - / * bullet
4530    if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
4531        let rest = &trimmed[2..];
4532        let mut spans = vec![Span::styled("* ", Style::default().fg(Color::Gray))];
4533        spans.extend(inline_markdown(rest));
4534        return vec![Line::from(spans)];
4535    }
4536
4537    // Plain line with possible inline markdown
4538    let spans = inline_markdown(trimmed);
4539    vec![Line::from(spans)]
4540}
4541
4542/// Inline markdown for The Core chat window (brighter palette than SPECULAR).
4543fn inline_markdown_core(text: &str) -> Vec<Span<'static>> {
4544    let mut spans = Vec::new();
4545    let mut remaining = text;
4546
4547    while !remaining.is_empty() {
4548        if let Some(start) = remaining.find("**") {
4549            let before = &remaining[..start];
4550            if !before.is_empty() {
4551                spans.push(Span::raw(before.to_string()));
4552            }
4553            let after_open = &remaining[start + 2..];
4554            if let Some(end) = after_open.find("**") {
4555                spans.push(Span::styled(
4556                    after_open[..end].to_string(),
4557                    Style::default()
4558                        .fg(Color::White)
4559                        .add_modifier(Modifier::BOLD),
4560                ));
4561                remaining = &after_open[end + 2..];
4562                continue;
4563            }
4564        }
4565        if let Some(start) = remaining.find('`') {
4566            let before = &remaining[..start];
4567            if !before.is_empty() {
4568                spans.push(Span::raw(before.to_string()));
4569            }
4570            let after_open = &remaining[start + 1..];
4571            if let Some(end) = after_open.find('`') {
4572                spans.push(Span::styled(
4573                    after_open[..end].to_string(),
4574                    Style::default().fg(Color::Yellow),
4575                ));
4576                remaining = &after_open[end + 1..];
4577                continue;
4578            }
4579        }
4580        spans.push(Span::raw(remaining.to_string()));
4581        break;
4582    }
4583    spans
4584}
4585
4586/// Parse inline `**bold**` and `` `code` `` — shared by SPECULAR and Core renderers.
4587fn inline_markdown(text: &str) -> Vec<Span<'static>> {
4588    let mut spans = Vec::new();
4589    let mut remaining = text;
4590
4591    while !remaining.is_empty() {
4592        if let Some(start) = remaining.find("**") {
4593            let before = &remaining[..start];
4594            if !before.is_empty() {
4595                spans.push(Span::raw(before.to_string()));
4596            }
4597            let after_open = &remaining[start + 2..];
4598            if let Some(end) = after_open.find("**") {
4599                spans.push(Span::styled(
4600                    after_open[..end].to_string(),
4601                    Style::default()
4602                        .fg(Color::White)
4603                        .add_modifier(Modifier::BOLD),
4604                ));
4605                remaining = &after_open[end + 2..];
4606                continue;
4607            }
4608        }
4609        if let Some(start) = remaining.find('`') {
4610            let before = &remaining[..start];
4611            if !before.is_empty() {
4612                spans.push(Span::raw(before.to_string()));
4613            }
4614            let after_open = &remaining[start + 1..];
4615            if let Some(end) = after_open.find('`') {
4616                spans.push(Span::styled(
4617                    after_open[..end].to_string(),
4618                    Style::default().fg(Color::Yellow),
4619                ));
4620                remaining = &after_open[end + 1..];
4621                continue;
4622            }
4623        }
4624        spans.push(Span::raw(remaining.to_string()));
4625        break;
4626    }
4627    spans
4628}
4629
4630// ── Splash Screen ─────────────────────────────────────────────────────────────
4631
4632fn draw_splash<B: Backend>(terminal: &mut Terminal<B>) -> Result<(), Box<dyn std::error::Error>> {
4633    let rust_color = Color::Rgb(180, 90, 50);
4634
4635    let logo_lines = vec![
4636        "██╗  ██╗███████╗███╗   ███╗ █████╗ ████████╗██╗████████╗███████╗",
4637        "██║  ██║██╔════╝████╗ ████║██╔══██╗╚══██╔══╝██║╚══██╔══╝██╔════╝",
4638        "███████║█████╗  ██╔████╔██║███████║   ██║   ██║   ██║   █████╗  ",
4639        "██╔══██║██╔══╝  ██║╚██╔╝██║██╔══██║   ██║   ██║   ██║   ██╔══╝  ",
4640        "██║  ██║███████╗██║ ╚═╝ ██║██║  ██║   ██║   ██║   ██║   ███████╗",
4641        "╚═╝  ╚═╝╚══════╝╚═╝     ╚═╝╚═╝  ╚═╝   ╚═╝   ╚═╝   ╚═╝   ╚══════╝",
4642    ];
4643
4644    let version = env!("CARGO_PKG_VERSION");
4645
4646    terminal.draw(|f| {
4647        let area = f.size();
4648
4649        // Clear with a dark background
4650        f.render_widget(
4651            Block::default().style(Style::default().bg(Color::Black)),
4652            area,
4653        );
4654
4655        // Total content height: logo(6) + spacer(1) + version(1) + tagline(1) + author(1) + spacer(2) + prompt(1) = 13
4656        let content_height: u16 = 13;
4657        let top_pad = area.height.saturating_sub(content_height) / 2;
4658
4659        let mut lines: Vec<Line<'static>> = Vec::new();
4660
4661        // Top padding
4662        for _ in 0..top_pad {
4663            lines.push(Line::raw(""));
4664        }
4665
4666        // Logo lines — centered horizontally
4667        for logo_line in &logo_lines {
4668            lines.push(Line::from(Span::styled(
4669                logo_line.to_string(),
4670                Style::default().fg(rust_color).add_modifier(Modifier::BOLD),
4671            )));
4672        }
4673
4674        // Spacer
4675        lines.push(Line::raw(""));
4676
4677        // Version
4678        lines.push(Line::from(vec![Span::styled(
4679            format!("v{}", version),
4680            Style::default().fg(Color::DarkGray),
4681        )]));
4682
4683        // Tagline
4684        lines.push(Line::from(vec![Span::styled(
4685            "Local AI coding harness + workstation assistant",
4686            Style::default()
4687                .fg(Color::DarkGray)
4688                .add_modifier(Modifier::DIM),
4689        )]));
4690
4691        // Developer credit
4692        lines.push(Line::from(vec![Span::styled(
4693            "Developed by Ocean Bennett",
4694            Style::default().fg(Color::Gray).add_modifier(Modifier::DIM),
4695        )]));
4696
4697        // Spacer
4698        lines.push(Line::raw(""));
4699        lines.push(Line::raw(""));
4700
4701        // Prompt
4702        lines.push(Line::from(vec![
4703            Span::styled("[ ", Style::default().fg(rust_color)),
4704            Span::styled(
4705                "Press ENTER to start",
4706                Style::default()
4707                    .fg(Color::White)
4708                    .add_modifier(Modifier::BOLD),
4709            ),
4710            Span::styled(" ]", Style::default().fg(rust_color)),
4711        ]));
4712
4713        let splash = Paragraph::new(lines).alignment(ratatui::layout::Alignment::Center);
4714
4715        f.render_widget(splash, area);
4716    })?;
4717
4718    Ok(())
4719}
4720
4721fn normalize_id(id: &str) -> String {
4722    id.trim().to_uppercase()
4723}
4724
4725fn filter_tui_noise(text: &str) -> String {
4726    // 1. First Pass: Strip ANSI escape codes that cause "shattering" in layout.
4727    let cleaned = strip_ansi(text);
4728
4729    // 2. Second Pass: Filter heuristic noise.
4730    let mut lines = Vec::new();
4731    for line in cleaned.lines() {
4732        // Strip multi-line "LF replaced by CRLF" noise frequently emitted by git/shell on Windows.
4733        if CRLF_REGEX.is_match(line) {
4734            continue;
4735        }
4736        // Strip git checkout/file update noise if it's too repetitive.
4737        if line.contains("Updating files:") && line.contains("%") {
4738            continue;
4739        }
4740        // Strip random terminal control characters that might have escaped.
4741        let sanitized: String = line
4742            .chars()
4743            .filter(|c| !c.is_control() || *c == '\t')
4744            .collect();
4745        if sanitized.trim().is_empty() && !line.trim().is_empty() {
4746            continue;
4747        }
4748
4749        lines.push(normalize_tui_text(&sanitized));
4750    }
4751    lines.join("\n").trim().to_string()
4752}
4753
4754fn normalize_tui_text(text: &str) -> String {
4755    let mut normalized = text
4756        .replace("ΓÇö", "-")
4757        .replace("ΓÇô", "-")
4758        .replace("…", "...")
4759        .replace("✅", "[OK]")
4760        .replace("🛠️", "")
4761        .replace("—", "-")
4762        .replace("–", "-")
4763        .replace("…", "...")
4764        .replace("•", "*")
4765        .replace("✅", "[OK]")
4766        .replace("🚨", "[!]");
4767
4768    normalized = normalized
4769        .chars()
4770        .map(|c| match c {
4771            '\u{00A0}' => ' ',
4772            '\u{2018}' | '\u{2019}' => '\'',
4773            '\u{201C}' | '\u{201D}' => '"',
4774            c if c.is_ascii() || c == '\n' || c == '\t' => c,
4775            _ => ' ',
4776        })
4777        .collect();
4778
4779    let mut compacted = String::with_capacity(normalized.len());
4780    let mut prev_space = false;
4781    for ch in normalized.chars() {
4782        if ch == ' ' {
4783            if !prev_space {
4784                compacted.push(ch);
4785            }
4786            prev_space = true;
4787        } else {
4788            compacted.push(ch);
4789            prev_space = false;
4790        }
4791    }
4792
4793    compacted.trim().to_string()
4794}