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