Skip to main content

hematite/ui/
tui.rs

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