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 - tools, file edits, builds\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         /vein-reset       - (Vein) Wipe the RAG index; rebuilds automatically on next turn\n\
1200           /clear            - (UI) Clear dialogue display only\n\
1201         /gemma-native [auto|on|off|status] - (Model) Auto/force/disable Gemma 4 native formatting\n\
1202         /runtime-refresh  - (Model) Re-read LM Studio model + CTX now\n\
1203         /undo             - (Ghost) Revert last file change\n\
1204         /diff             - (Git) Show session changes (--stat)\n\
1205         /lsp              - (Logic) Start Language Servers (semantic intelligence)\n\
1206         /swarm <text>     - (Swarm) Spawn parallel workers on a directive\n\
1207         /worktree <cmd>   - (Isolated) Manage git worktrees (list|add|remove|prune)\n\
1208         /think            - (Brain) Enable deep reasoning mode\n\
1209         /no_think         - (Speed) Disable reasoning (3-5x faster responses)\n\
1210         /voice            - (TTS) List all available voices\n\
1211         /voice N          - (TTS) Select voice by number\n\
1212         /read <text>      - (TTS) Speak text aloud directly, bypassing the model. ESC to stop.\n\
1213         /attach <path>    - (Docs) Attach a PDF/markdown/txt file for next message (PDF best-effort)\n\
1214         /attach-pick      - (Docs) Open a file picker and attach a document\n\
1215         /image <path>     - (Vision) Attach an image for the next message\n\
1216         /image-pick       - (Vision) Open a file picker and attach an image\n\
1217         /detach           - (Context) Drop pending document/image attachments\n\
1218         /copy             - (Debug) Copy exact session transcript (includes help/system output)\n\
1219         /copy-last        - (Debug) Copy the latest Hematite reply only\n\
1220         /copy-clean       - (Debug) Copy chat transcript without help/debug boilerplate\n\
1221         /copy2            - (Debug) Copy SPECULAR log to clipboard (reasoning + events)\n\
1222         \nHotkeys:\n\
1223         Ctrl+B - Toggle Brief Mode (minimal output)\n\
1224         Ctrl+P - Toggle Professional Mode (strip personality)\n\
1225         Ctrl+O - Open document picker for next-turn context\n\
1226         Ctrl+I - Open image picker for next-turn vision context\n\
1227         Ctrl+Y - Toggle Approvals Off (bypass safety approvals)\n\
1228         Ctrl+S - Quick Swarm (hardcoded bootstrap)\n\
1229         Ctrl+Z - Undo last edit\n\
1230         Ctrl+Q/C - Quit session\n\
1231         ESC    - Silence current playback\n\
1232         \nStatus Legend:\n\
1233         LM    - LM Studio runtime health (`LIVE`, `RECV`, `WARN`, `CEIL`, `STALE`, `BOOT`)\n\
1234         VN    - Vein RAG status (`SEM`=semantic active, `FTS`=BM25 only, `--`=not indexed)\n\
1235         BUD   - Total prompt-budget pressure against the live context window\n\
1236         CMP   - History compaction pressure against Hematite's adaptive threshold\n\
1237         ERR   - Session error count (runtime, tool, or SPECULAR failures)\n\
1238         CTX   - Live context window currently reported by LM Studio\n\
1239         VOICE - Local speech output state\n\
1240         \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. If a PDF fails, export it to text/markdown or attach page images instead.\n\
1241         ",
1242    );
1243}
1244
1245#[allow(dead_code)]
1246fn show_help_message_legacy(app: &mut App) {
1247    app.push_message("System",
1248        "Hematite Commands:\n\
1249         /chat             — (Mode) Conversation mode — clean chat, no tool noise\n\
1250         /agent            — (Mode) Full coding harness — tools, file edits, builds\n\
1251         /reroll           — (Soul) Hatch a new companion mid-session\n\
1252         /auto             — (Flow) Let Hematite choose the narrowest effective workflow\n\
1253         /ask [prompt]     — (Flow) Read-only analysis mode; optional inline prompt\n\
1254         /code [prompt]    — (Flow) Explicit implementation mode; optional inline prompt\n\
1255           /architect [prompt] — (Flow) Plan-first mode; optional inline prompt\n\
1256           /read-only [prompt] — (Flow) Hard read-only mode; optional inline prompt\n\
1257           /new              — (Reset) Fresh task context; clear chat, pins, and task files\n\
1258           /forget           — (Wipe) Hard forget; purge saved memory and Vein index too\n\
1259           /vein-inspect     — (Vein) Inspect indexed memory, hot files, and active room bias\n\
1260           /workspace-profile — (Profile) Show the auto-generated workspace profile\n\
1261           /version          — (Build) Show the running Hematite version\n\
1262           /vein-reset       — (Vein) Wipe the RAG index; rebuilds automatically on next turn\n\
1263           /clear            — (UI) Clear dialogue display only\n\
1264         /gemma-native [auto|on|off|status] — (Model) Auto/force/disable Gemma 4 native formatting\n\
1265         /runtime-refresh  — (Model) Re-read LM Studio model + CTX now\n\
1266         /undo             — (Ghost) Revert last file change\n\
1267         /diff             — (Git) Show session changes (--stat)\n\
1268         /lsp              — (Logic) Start Language Servers (semantic intelligence)\n\
1269         /swarm <text>     — (Swarm) Spawn parallel workers on a directive\n\
1270         /worktree <cmd>   — (Isolated) Manage git worktrees (list|add|remove|prune)\n\
1271         /think            — (Brain) Enable deep reasoning mode\n\
1272         /no_think         — (Speed) Disable reasoning (3-5x faster responses)\n\
1273         /voice            — (TTS) List all available voices\n\
1274         /voice N          — (TTS) Select voice by number\n\
1275         /read <text>      — (TTS) Speak text aloud directly, bypassing the model. ESC to stop.\n\
1276         /attach <path>    — (Docs) Attach a PDF/markdown/txt file for next message\n\
1277         /attach-pick      — (Docs) Open a file picker and attach a document\n\
1278         /image <path>     — (Vision) Attach an image for the next message\n\
1279         /image-pick       — (Vision) Open a file picker and attach an image\n\
1280         /detach           — (Context) Drop pending document/image attachments\n\
1281         /copy             — (Debug) Copy session transcript to clipboard\n\
1282         /copy2            — (Debug) Copy SPECULAR log to clipboard (reasoning + events)\n\
1283         \nHotkeys:\n\
1284         Ctrl+B — Toggle Brief Mode (minimal output)\n\
1285         Ctrl+P — Toggle Professional Mode (strip personality)\n\
1286         Ctrl+O — Open document picker for next-turn context\n\
1287         Ctrl+I — Open image picker for next-turn vision context\n\
1288         Ctrl+Y — Toggle Approvals Off (bypass safety approvals)\n\
1289         Ctrl+S — Quick Swarm (hardcoded bootstrap)\n\
1290         Ctrl+Z — Undo last edit\n\
1291         Ctrl+Q/C — Quit session\n\
1292         ESC    — Silence current playback\n\
1293         \nStatus Legend:\n\
1294         LM    — LM Studio runtime health (`LIVE`, `RECV`, `WARN`, `CEIL`, `STALE`, `BOOT`)\n\
1295         VN    — Vein RAG status (`SEM`=semantic active, `FTS`=BM25 only, `--`=not indexed)\n\
1296         BUD   — Total prompt-budget pressure against the live context window\n\
1297         CMP   — History compaction pressure against Hematite's adaptive threshold\n\
1298         ERR   — Session error count (runtime, tool, or SPECULAR failures)\n\
1299         CTX   — Live context window currently reported by LM Studio\n\
1300         VOICE — Local speech output state\n\
1301         \nAssistant: Semantic Pathing (LSP), Vision Pass, Web Research, Swarm Synthesis"
1302    );
1303    app.push_message(
1304        "System",
1305        "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. If a PDF fails, export it to text/markdown or attach page images instead.",
1306    );
1307}
1308
1309fn trigger_input_action(app: &mut App, action: InputAction) {
1310    match action {
1311        InputAction::Stop => request_stop(app),
1312        InputAction::PickDocument => match pick_attachment_path(AttachmentPickerKind::Document) {
1313            Ok(Some(path)) => attach_document_from_path(app, &path),
1314            Ok(None) => app.push_message("System", "Document picker cancelled."),
1315            Err(e) => app.push_message("System", &e),
1316        },
1317        InputAction::PickImage => match pick_attachment_path(AttachmentPickerKind::Image) {
1318            Ok(Some(path)) => attach_image_from_path(app, &path),
1319            Ok(None) => app.push_message("System", "Image picker cancelled."),
1320            Err(e) => app.push_message("System", &e),
1321        },
1322        InputAction::Detach => {
1323            app.clear_pending_attachments();
1324            app.push_message(
1325                "System",
1326                "Cleared pending document/image attachments for the next turn.",
1327            );
1328        }
1329        InputAction::New => {
1330            if !app.agent_running {
1331                reset_visible_session_state(app);
1332                app.push_message("You", "/new");
1333                app.agent_running = true;
1334                let _ = app.user_input_tx.try_send(UserTurn::text("/new"));
1335            }
1336        }
1337        InputAction::Forget => {
1338            if !app.agent_running {
1339                app.cancel_token
1340                    .store(true, std::sync::atomic::Ordering::SeqCst);
1341                reset_visible_session_state(app);
1342                app.push_message("You", "/forget");
1343                app.agent_running = true;
1344                app.cancel_token
1345                    .store(false, std::sync::atomic::Ordering::SeqCst);
1346                let _ = app.user_input_tx.try_send(UserTurn::text("/forget"));
1347            }
1348        }
1349        InputAction::Help => show_help_message(app),
1350    }
1351}
1352
1353fn pick_attachment_path(kind: AttachmentPickerKind) -> Result<Option<String>, String> {
1354    #[cfg(target_os = "windows")]
1355    {
1356        let (title, filter) = match kind {
1357            AttachmentPickerKind::Document => (
1358                "Attach document for the next Hematite turn",
1359                "Documents|*.pdf;*.md;*.markdown;*.txt;*.rst|All Files|*.*",
1360            ),
1361            AttachmentPickerKind::Image => (
1362                "Attach image for the next Hematite turn",
1363                "Images|*.png;*.jpg;*.jpeg;*.gif;*.webp|All Files|*.*",
1364            ),
1365        };
1366        let script = format!(
1367            "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 }}"
1368        );
1369        let output = std::process::Command::new("powershell")
1370            .args(["-NoProfile", "-STA", "-Command", &script])
1371            .output()
1372            .map_err(|e| format!("File picker failed: {}", e))?;
1373        if !output.status.success() {
1374            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1375            return Err(if stderr.is_empty() {
1376                "File picker did not complete successfully.".to_string()
1377            } else {
1378                format!("File picker failed: {}", stderr)
1379            });
1380        }
1381        let selected = String::from_utf8_lossy(&output.stdout).trim().to_string();
1382        if selected.is_empty() {
1383            Ok(None)
1384        } else {
1385            Ok(Some(selected))
1386        }
1387    }
1388    #[cfg(target_os = "macos")]
1389    {
1390        let prompt = match kind {
1391            AttachmentPickerKind::Document => "Choose a document for the next Hematite turn",
1392            AttachmentPickerKind::Image => "Choose an image for the next Hematite turn",
1393        };
1394        let script = format!("POSIX path of (choose file with prompt \"{}\")", prompt);
1395        let output = std::process::Command::new("osascript")
1396            .args(["-e", &script])
1397            .output()
1398            .map_err(|e| format!("File picker failed: {}", e))?;
1399        if output.status.success() {
1400            let selected = String::from_utf8_lossy(&output.stdout).trim().to_string();
1401            if selected.is_empty() {
1402                Ok(None)
1403            } else {
1404                Ok(Some(selected))
1405            }
1406        } else {
1407            Ok(None)
1408        }
1409    }
1410    #[cfg(all(unix, not(target_os = "macos")))]
1411    {
1412        let title = match kind {
1413            AttachmentPickerKind::Document => "Attach document for the next Hematite turn",
1414            AttachmentPickerKind::Image => "Attach image for the next Hematite turn",
1415        };
1416        let output = std::process::Command::new("zenity")
1417            .args(["--file-selection", "--title", title])
1418            .output()
1419            .map_err(|e| format!("File picker failed: {}", e))?;
1420        if output.status.success() {
1421            let selected = String::from_utf8_lossy(&output.stdout).trim().to_string();
1422            if selected.is_empty() {
1423                Ok(None)
1424            } else {
1425                Ok(Some(selected))
1426            }
1427        } else {
1428            Ok(None)
1429        }
1430    }
1431}
1432
1433pub async fn run_app<B: Backend>(
1434    terminal: &mut Terminal<B>,
1435    mut specular_rx: Receiver<SpecularEvent>,
1436    mut agent_rx: Receiver<crate::agent::inference::InferenceEvent>,
1437    user_input_tx: tokio::sync::mpsc::Sender<UserTurn>,
1438    mut swarm_rx: Receiver<SwarmMessage>,
1439    swarm_tx: tokio::sync::mpsc::Sender<SwarmMessage>,
1440    swarm_coordinator: Arc<crate::agent::swarm::SwarmCoordinator>,
1441    last_interaction: Arc<Mutex<Instant>>,
1442    cockpit: crate::CliCockpit,
1443    soul: crate::ui::hatch::RustySoul,
1444    professional: bool,
1445    gpu_state: Arc<GpuState>,
1446    git_state: Arc<crate::agent::git_monitor::GitState>,
1447    cancel_token: Arc<std::sync::atomic::AtomicBool>,
1448    voice_manager: Arc<crate::ui::voice::VoiceManager>,
1449) -> Result<(), Box<dyn std::error::Error>> {
1450    let mut app = App {
1451        messages: Vec::new(),
1452        messages_raw: Vec::new(),
1453        specular_logs: Vec::new(),
1454        brief_mode: cockpit.brief,
1455        tick_count: 0,
1456        stats: RustyStats {
1457            debugging: 0,
1458            wisdom: soul.wisdom,
1459            patience: 100.0,
1460            chaos: soul.chaos,
1461            snark: soul.snark,
1462        },
1463        yolo_mode: cockpit.yolo,
1464        awaiting_approval: None,
1465        active_workers: HashMap::new(),
1466        worker_labels: HashMap::new(),
1467        active_review: None,
1468        input: String::new(),
1469        input_history: Vec::new(),
1470        history_idx: None,
1471        thinking: false,
1472        agent_running: false,
1473        current_thought: String::new(),
1474        professional,
1475        last_reasoning: String::new(),
1476        active_context: default_active_context(),
1477        manual_scroll_offset: None,
1478        user_input_tx,
1479        specular_scroll: 0,
1480        specular_auto_scroll: true,
1481        gpu_state,
1482        git_state,
1483        last_input_time: Instant::now(),
1484        cancel_token,
1485        total_tokens: 0,
1486        current_session_cost: 0.0,
1487        model_id: "detecting...".to_string(),
1488        context_length: 0,
1489        prompt_pressure_percent: 0,
1490        prompt_estimated_input_tokens: 0,
1491        prompt_reserved_output_tokens: 0,
1492        prompt_estimated_total_tokens: 0,
1493        compaction_percent: 0,
1494        compaction_estimated_tokens: 0,
1495        compaction_threshold_tokens: 0,
1496        compaction_warned_level: 0,
1497        last_runtime_profile_time: Instant::now(),
1498        vein_file_count: 0,
1499        vein_embedded_count: 0,
1500        vein_docs_only: false,
1501        provider_state: ProviderRuntimeState::Booting,
1502        last_provider_summary: String::new(),
1503        mcp_state: McpRuntimeState::Unconfigured,
1504        last_mcp_summary: String::new(),
1505        last_operator_checkpoint_state: OperatorCheckpointState::Idle,
1506        last_operator_checkpoint_summary: String::new(),
1507        last_recovery_recipe_summary: String::new(),
1508        think_mode: None,
1509        workflow_mode: "AUTO".into(),
1510        autocomplete_suggestions: Vec::new(),
1511        selected_suggestion: 0,
1512        show_autocomplete: false,
1513        autocomplete_filter: String::new(),
1514        current_objective: "Awaiting objective...".into(),
1515        voice_manager,
1516        voice_loading: false,
1517        voice_loading_progress: 0.0,
1518        hardware_guard_enabled: true,
1519        session_start: std::time::SystemTime::now(),
1520        soul_name: soul.species.clone(),
1521        attached_context: None,
1522        attached_image: None,
1523        hovered_input_action: None,
1524    };
1525
1526    // Initial placeholder — streaming will overwrite this with hardware diagnostics
1527    app.push_message("Hematite", "Initialising Engine & Hardware...");
1528
1529    // ── Splash Screen ─────────────────────────────────────────────────────────
1530    // Blocking splash — user must press Enter to proceed.
1531    if !cockpit.no_splash {
1532        draw_splash(terminal)?;
1533        loop {
1534            if let Ok(Event::Key(key)) = event::read() {
1535                if key.kind == event::KeyEventKind::Press
1536                    && matches!(key.code, KeyCode::Enter | KeyCode::Char(' '))
1537                {
1538                    break;
1539                }
1540            }
1541        }
1542    }
1543
1544    let mut event_stream = EventStream::new();
1545    let mut ticker = tokio::time::interval(std::time::Duration::from_millis(100));
1546
1547    loop {
1548        // ── Hardware Watchdog ──
1549        let vram_ratio = app.gpu_state.ratio();
1550        if app.hardware_guard_enabled && vram_ratio > 0.95 && !app.brief_mode {
1551            app.brief_mode = true;
1552            app.push_message(
1553                "System",
1554                "🚨 HARDWARE GUARD: VRAM > 95%. Brief Mode auto-enabled to prevent crash.",
1555            );
1556        }
1557
1558        terminal.draw(|f| ui(f, &app))?;
1559
1560        tokio::select! {
1561            _ = ticker.tick() => {
1562                // Increment voice loading progress (estimated 50s total load)
1563                if app.voice_loading && app.voice_loading_progress < 0.98 {
1564                    app.voice_loading_progress += 0.002;
1565                }
1566
1567                let workers = app.active_workers.len() as u64;
1568                let advance = if workers > 0 { workers * 4 + 1 } else { 1 };
1569                // Scale advance to match new 100ms tick (formerly 500ms)
1570                // We keep animations consistent by only advancing tick_count every 5 ticks or scaling.
1571                // Let's just increment every tick but use a larger modulo in animations.
1572                app.tick_count = app.tick_count.wrapping_add(advance);
1573                app.update_objective();
1574            }
1575
1576            // ── Keyboard / mouse input ────────────────────────────────────────
1577            maybe_event = event_stream.next() => {
1578                match maybe_event {
1579                    Some(Ok(Event::Mouse(mouse))) => {
1580                        use crossterm::event::{MouseButton, MouseEventKind};
1581                        let (width, height) = match terminal.size() {
1582                            Ok(s) => (s.width, s.height),
1583                            Err(_) => (80, 24),
1584                        };
1585                        let is_right_side = mouse.column as f64 > width as f64 * 0.65;
1586                        let input_rect = input_rect_for_size(
1587                            Rect { x: 0, y: 0, width, height },
1588                            app.input.len(),
1589                        );
1590                        let title_area = input_title_area(input_rect);
1591
1592                        match mouse.kind {
1593                            MouseEventKind::Moved => {
1594                                let hovered = if mouse.row == title_area.y
1595                                    && mouse.column >= title_area.x
1596                                    && mouse.column < title_area.x + title_area.width
1597                                {
1598                                    input_action_hitboxes(&app, title_area)
1599                                        .into_iter()
1600                                        .find_map(|(action, start, end)| {
1601                                            (mouse.column >= start && mouse.column <= end)
1602                                                .then_some(action)
1603                                        })
1604                                } else {
1605                                    None
1606                                };
1607                                app.hovered_input_action = hovered;
1608                            }
1609                            MouseEventKind::Down(MouseButton::Left) => {
1610                                if mouse.row == title_area.y
1611                                    && mouse.column >= title_area.x
1612                                    && mouse.column < title_area.x + title_area.width
1613                                {
1614                                    for (action, start, end) in input_action_hitboxes(&app, title_area) {
1615                                        if mouse.column >= start && mouse.column <= end {
1616                                            app.hovered_input_action = Some(action);
1617                                            trigger_input_action(&mut app, action);
1618                                            break;
1619                                        }
1620                                    }
1621                                } else {
1622                                    app.hovered_input_action = None;
1623                                }
1624                            }
1625                            MouseEventKind::ScrollUp => {
1626                                if is_right_side {
1627                                    // User scrolled up — disable auto-scroll so they can read.
1628                                    app.specular_auto_scroll = false;
1629                                    app.specular_scroll = app.specular_scroll.saturating_sub(3);
1630                                } else {
1631                                    let cur = app.manual_scroll_offset.unwrap_or(0);
1632                                    app.manual_scroll_offset = Some(cur.saturating_add(3));
1633                                }
1634                            }
1635                            MouseEventKind::ScrollDown => {
1636                                if is_right_side {
1637                                    app.specular_auto_scroll = false;
1638                                    app.specular_scroll = app.specular_scroll.saturating_add(3);
1639                                } else if let Some(cur) = app.manual_scroll_offset {
1640                                    app.manual_scroll_offset = if cur <= 3 { None } else { Some(cur - 3) };
1641                                }
1642                            }
1643                            _ => {}
1644                        }
1645                    }
1646                    Some(Ok(Event::Key(key))) => {
1647                        if key.kind != event::KeyEventKind::Press { continue; }
1648
1649                        // Update idle tracker for DeepReflect.
1650                        { *last_interaction.lock().unwrap() = Instant::now(); }
1651
1652                        // ── Tier-2 Swarm diff review modal (exclusive lock) ───
1653                        if let Some(review) = app.active_review.take() {
1654                            match key.code {
1655                                KeyCode::Char('y') | KeyCode::Char('Y') => {
1656                                    let _ = review.tx.send(ReviewResponse::Accept);
1657                                    app.push_message("System", &format!("Worker {} diff accepted.", review.worker_id));
1658                                }
1659                                KeyCode::Char('n') | KeyCode::Char('N') => {
1660                                    let _ = review.tx.send(ReviewResponse::Reject);
1661                                    app.push_message("System", "Diff rejected.");
1662                                }
1663                                KeyCode::Char('r') | KeyCode::Char('R') => {
1664                                    let _ = review.tx.send(ReviewResponse::Retry);
1665                                    app.push_message("System", "Retrying synthesis…");
1666                                }
1667                                _ => { app.active_review = Some(review); }
1668                            }
1669                            continue;
1670                        }
1671
1672                        // ── High-risk approval modal (exclusive lock) ─────────
1673                        if let Some(mut approval) = app.awaiting_approval.take() {
1674                            // Scroll keys — adjust offset and put approval back.
1675                            let scroll_handled = if approval.diff.is_some() {
1676                                let diff_lines = approval.diff.as_ref().map(|d| d.lines().count()).unwrap_or(0) as u16;
1677                                match key.code {
1678                                    KeyCode::Down | KeyCode::Char('j') => {
1679                                        approval.diff_scroll = approval.diff_scroll.saturating_add(1).min(diff_lines.saturating_sub(1));
1680                                        true
1681                                    }
1682                                    KeyCode::Up | KeyCode::Char('k') => {
1683                                        approval.diff_scroll = approval.diff_scroll.saturating_sub(1);
1684                                        true
1685                                    }
1686                                    KeyCode::PageDown => {
1687                                        approval.diff_scroll = approval.diff_scroll.saturating_add(10).min(diff_lines.saturating_sub(1));
1688                                        true
1689                                    }
1690                                    KeyCode::PageUp => {
1691                                        approval.diff_scroll = approval.diff_scroll.saturating_sub(10);
1692                                        true
1693                                    }
1694                                    _ => false,
1695                                }
1696                            } else {
1697                                false
1698                            };
1699                            if scroll_handled {
1700                                app.awaiting_approval = Some(approval);
1701                                continue;
1702                            }
1703                            match key.code {
1704                                KeyCode::Char('y') | KeyCode::Char('Y') => {
1705                                    if let Some(ref diff) = approval.diff {
1706                                        let added = diff.lines().filter(|l| l.starts_with("+ ")).count();
1707                                        let removed = diff.lines().filter(|l| l.starts_with("- ")).count();
1708                                        app.push_message("System", &format!(
1709                                            "Applied: {} +{} -{}", approval.display, added, removed
1710                                        ));
1711                                    } else {
1712                                        app.push_message("System", &format!("Approved: {}", approval.display));
1713                                    }
1714                                    let _ = approval.responder.send(true);
1715                                }
1716                                KeyCode::Char('n') | KeyCode::Char('N') => {
1717                                    if approval.diff.is_some() {
1718                                        app.push_message("System", "Edit skipped.");
1719                                    } else {
1720                                        app.push_message("System", "Declined.");
1721                                    }
1722                                    let _ = approval.responder.send(false);
1723                                }
1724                                _ => { app.awaiting_approval = Some(approval); }
1725                            }
1726                            continue;
1727                        }
1728
1729                        // ── Normal key bindings ───────────────────────────────
1730                        match key.code {
1731                            KeyCode::Char('q') | KeyCode::Char('c')
1732                                if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
1733                                    app.write_session_report();
1734                                    app.copy_transcript_to_clipboard();
1735                                    break;
1736                                }
1737
1738                            KeyCode::Esc => {
1739                                request_stop(&mut app);
1740                            }
1741
1742                            KeyCode::Char('b') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
1743                                app.brief_mode = !app.brief_mode;
1744                                // If the user manually toggles, silence the hardware guard for this session.
1745                                app.hardware_guard_enabled = false;
1746                                app.push_message("System", &format!("Hardware Guard {}: {}", if app.brief_mode { "ENFORCED" } else { "SILENCED" }, if app.brief_mode { "ON" } else { "OFF" }));
1747                            }
1748                            KeyCode::Char('p') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
1749                                app.professional = !app.professional;
1750                                app.push_message("System", &format!("Professional Harness: {}", if app.professional { "ACTIVE" } else { "DISABLED" }));
1751                            }
1752                            KeyCode::Char('y') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
1753                                app.yolo_mode = !app.yolo_mode;
1754                                app.push_message("System", &format!("Approvals Off: {}", if app.yolo_mode { "ON — all tools auto-approved" } else { "OFF" }));
1755                            }
1756                            KeyCode::Char('t') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
1757                                if !app.voice_manager.is_available() {
1758                                    app.push_message("System", "Voice is not available in this build. Use a packaged release for baked-in voice.");
1759                                } else {
1760                                    let enabled = app.voice_manager.toggle();
1761                                    app.push_message("System", &format!("Voice of Hematite: {}", if enabled { "VIBRANT" } else { "SILENCED" }));
1762                                }
1763                            }
1764                            KeyCode::Char('o') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
1765                                match pick_attachment_path(AttachmentPickerKind::Document) {
1766                                    Ok(Some(path)) => attach_document_from_path(&mut app, &path),
1767                                    Ok(None) => app.push_message("System", "Document picker cancelled."),
1768                                    Err(e) => app.push_message("System", &e),
1769                                }
1770                            }
1771                            KeyCode::Char('i') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
1772                                match pick_attachment_path(AttachmentPickerKind::Image) {
1773                                    Ok(Some(path)) => attach_image_from_path(&mut app, &path),
1774                                    Ok(None) => app.push_message("System", "Image picker cancelled."),
1775                                    Err(e) => app.push_message("System", &e),
1776                                }
1777                            }
1778                            KeyCode::Char('s') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
1779                                app.push_message("Hematite", "Swarm engaged.");
1780                                let swarm_tx_c = swarm_tx.clone();
1781                                let coord_c = swarm_coordinator.clone();
1782                                // Hardware-aware swarm: Limit workers if GPU is busy.
1783                                let max_workers = if app.gpu_state.ratio() > 0.70 { 1 } else { 3 };
1784                                if max_workers < 3 {
1785                                    app.push_message("System", "Hardware Guard: Limiting swarm to 1 worker due to GPU load.");
1786                                }
1787
1788                                app.agent_running = true;
1789                                tokio::spawn(async move {
1790                                    let payload = r#"<worker_task id="1" target="src/ui/tui.rs">Implement Swarm Layout</worker_task>
1791<worker_task id="2" target="src/agent/swarm.rs">Build Scratchpad constraints</worker_task>
1792<worker_task id="3" target="docs">Update Readme</worker_task>"#;
1793                                    let tasks = crate::agent::parser::parse_master_spec(payload);
1794                                    let _ = coord_c.dispatch_swarm(tasks, swarm_tx_c, max_workers).await;
1795                                });
1796                            }
1797                            KeyCode::Char('z') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
1798                                match crate::tools::file_ops::pop_ghost_ledger() {
1799                                    Ok(msg) => {
1800                                        app.specular_logs.push(format!("GHOST: {}", msg));
1801                                        trim_vec(&mut app.specular_logs, 7);
1802                                        app.push_message("System", &msg);
1803                                    }
1804                                    Err(e) => {
1805                                        app.push_message("System", &format!("Undo failed: {}", e));
1806                                    }
1807                                }
1808                            }
1809                            KeyCode::Up => {
1810                                if app.show_autocomplete && !app.autocomplete_suggestions.is_empty() {
1811                                    app.selected_suggestion = app.selected_suggestion.saturating_sub(1);
1812                                } else if app.manual_scroll_offset.is_some() {
1813                                    // Protect history: Use Up as a scroll fallback if already scrolling.
1814                                    let cur = app.manual_scroll_offset.unwrap();
1815                                    app.manual_scroll_offset = Some(cur.saturating_add(3));
1816                                } else if !app.input_history.is_empty() {
1817                                    // Only cycle history if we are at the bottom of the chat.
1818                                    let new_idx = match app.history_idx {
1819                                        None => app.input_history.len() - 1,
1820                                        Some(i) => i.saturating_sub(1),
1821                                    };
1822                                    app.history_idx = Some(new_idx);
1823                                    app.input = app.input_history[new_idx].clone();
1824                                }
1825                            }
1826                            KeyCode::Down => {
1827                                if app.show_autocomplete && !app.autocomplete_suggestions.is_empty() {
1828                                    app.selected_suggestion = (app.selected_suggestion + 1).min(app.autocomplete_suggestions.len().saturating_sub(1));
1829                                } else if let Some(off) = app.manual_scroll_offset {
1830                                    if off <= 3 { app.manual_scroll_offset = None; }
1831                                    else { app.manual_scroll_offset = Some(off.saturating_sub(3)); }
1832                                } else if let Some(i) = app.history_idx {
1833                                    if i + 1 < app.input_history.len() {
1834                                        app.history_idx = Some(i + 1);
1835                                        app.input = app.input_history[i + 1].clone();
1836                                    } else {
1837                                        app.history_idx = None;
1838                                        app.input.clear();
1839                                    }
1840                                }
1841                            }
1842                            KeyCode::PageUp => {
1843                                let cur = app.manual_scroll_offset.unwrap_or(0);
1844                                app.manual_scroll_offset = Some(cur.saturating_add(10));
1845                            }
1846                            KeyCode::PageDown => {
1847                                if let Some(off) = app.manual_scroll_offset {
1848                                    if off <= 10 { app.manual_scroll_offset = None; }
1849                                    else { app.manual_scroll_offset = Some(off.saturating_sub(10)); }
1850                                }
1851                            }
1852                            KeyCode::Tab => {
1853                                if app.show_autocomplete && !app.autocomplete_suggestions.is_empty() {
1854                                    let selected = &app.autocomplete_suggestions[app.selected_suggestion];
1855                                    if let Some(pos) = app.input.rfind('@') {
1856                                        app.input.truncate(pos + 1);
1857                                        app.input.push_str(selected);
1858                                        app.show_autocomplete = false;
1859                                    }
1860                                }
1861                            }
1862                            KeyCode::Char(c) => {
1863                                app.history_idx = None; // typing cancels history nav
1864                                app.input.push(c);
1865                                app.last_input_time = Instant::now();
1866
1867                                if c == '@' {
1868                                    app.show_autocomplete = true;
1869                                    app.autocomplete_filter.clear();
1870                                    app.selected_suggestion = 0;
1871                                    app.update_autocomplete();
1872                                } else if app.show_autocomplete {
1873                                    app.autocomplete_filter.push(c);
1874                                    app.update_autocomplete();
1875                                }
1876                            }
1877                            KeyCode::Backspace => {
1878                                app.input.pop();
1879                                if app.show_autocomplete {
1880                                    if app.input.ends_with('@') || !app.input.contains('@') {
1881                                        app.show_autocomplete = false;
1882                                        app.autocomplete_filter.clear();
1883                                    } else {
1884                                        app.autocomplete_filter.pop();
1885                                        app.update_autocomplete();
1886                                    }
1887                                }
1888                            }
1889                            KeyCode::Enter => {
1890                                if app.show_autocomplete && !app.autocomplete_suggestions.is_empty() {
1891                                    let selected = &app.autocomplete_suggestions[app.selected_suggestion];
1892                                    if let Some(pos) = app.input.rfind('@') {
1893                                        app.input.truncate(pos + 1);
1894                                        app.input.push_str(selected);
1895                                        app.show_autocomplete = false;
1896                                        continue;
1897                                    }
1898                                }
1899
1900                                if !app.input.is_empty() && !app.agent_running {
1901                                    // PASTE GUARD: If a newline arrives within 50ms of a character,
1902                                    // it's almost certainly part of a paste stream. Convert to space.
1903                                    if Instant::now().duration_since(app.last_input_time) < std::time::Duration::from_millis(50) {
1904                                        app.input.push(' ');
1905                                        app.last_input_time = Instant::now();
1906                                        continue;
1907                                    }
1908
1909                                    let input_text = app.input.drain(..).collect::<String>();
1910
1911                                    // ── Slash Command Processor ──────────────────────────
1912                                    if input_text.starts_with('/') {
1913                                        let parts: Vec<&str> = input_text.trim().split_whitespace().collect();
1914                                        let cmd = parts[0].to_lowercase();
1915                                        match cmd.as_str() {
1916                                            "/undo" => {
1917                                                match crate::tools::file_ops::pop_ghost_ledger() {
1918                                                    Ok(msg) => {
1919                                                        app.specular_logs.push(format!("GHOST: {}", msg));
1920                                                        trim_vec(&mut app.specular_logs, 7);
1921                                                        app.push_message("System", &msg);
1922                                                    }
1923                                                    Err(e) => {
1924                                                        app.push_message("System", &format!("Undo failed: {}", e));
1925                                                    }
1926                                                }
1927                                                app.history_idx = None;
1928                                                continue;
1929                                            }
1930                                            "/clear" => {
1931                                                reset_visible_session_state(&mut app);
1932                                                app.push_message("System", "Dialogue buffer cleared.");
1933                                                app.history_idx = None;
1934                                                continue;
1935                                            }
1936                                            "/diff" => {
1937                                                app.push_message("System", "Fetching session diff...");
1938                                                let ws = crate::tools::file_ops::workspace_root();
1939                                                if crate::agent::git::is_git_repo(&ws) {
1940                                                    let output = std::process::Command::new("git")
1941                                                        .args(["diff", "--stat"])
1942                                                        .current_dir(ws)
1943                                                        .output();
1944                                                    if let Ok(out) = output {
1945                                                        let stat = String::from_utf8_lossy(&out.stdout).to_string();
1946                                                        app.push_message("System", if stat.is_empty() { "No changes detected." } else { &stat });
1947                                                    }
1948                                                } else {
1949                                                    app.push_message("System", "Not a git repository. Diff limited.");
1950                                                }
1951                                                app.history_idx = None;
1952                                                continue;
1953                                            }
1954                                            "/vein-reset" => {
1955                                                app.vein_file_count = 0;
1956                                                app.vein_embedded_count = 0;
1957                                                app.push_message("You", "/vein-reset");
1958                                                app.agent_running = true;
1959                                                let _ = app.user_input_tx.try_send(UserTurn::text("/vein-reset"));
1960                                                app.history_idx = None;
1961                                                continue;
1962                                            }
1963                                            "/vein-inspect" => {
1964                                                app.push_message("You", "/vein-inspect");
1965                                                app.agent_running = true;
1966                                                let _ = app.user_input_tx.try_send(UserTurn::text("/vein-inspect"));
1967                                                app.history_idx = None;
1968                                                continue;
1969                                            }
1970                                            "/workspace-profile" => {
1971                                                app.push_message("You", "/workspace-profile");
1972                                                app.agent_running = true;
1973                                                let _ = app.user_input_tx.try_send(UserTurn::text("/workspace-profile"));
1974                                                app.history_idx = None;
1975                                                continue;
1976                                            }
1977                                            "/copy" => {
1978                                                app.copy_transcript_to_clipboard();
1979                                                app.push_message("System", "Exact session transcript copied to clipboard (includes help/system output).");
1980                                                app.history_idx = None;
1981                                                continue;
1982                                            }
1983                                            "/copy-last" => {
1984                                                if app.copy_last_reply_to_clipboard() {
1985                                                    app.push_message("System", "Latest Hematite reply copied to clipboard.");
1986                                                } else {
1987                                                    app.push_message("System", "No Hematite reply is available to copy yet.");
1988                                                }
1989                                                app.history_idx = None;
1990                                                continue;
1991                                            }
1992                                            "/copy-clean" => {
1993                                                app.copy_clean_transcript_to_clipboard();
1994                                                app.push_message("System", "Clean chat transcript copied to clipboard (skips help/debug boilerplate).");
1995                                                app.history_idx = None;
1996                                                continue;
1997                                            }
1998                                            "/copy2" => {
1999                                                app.copy_specular_to_clipboard();
2000                                                app.push_message("System", "SPECULAR log copied to clipboard (reasoning + events).");
2001                                                app.history_idx = None;
2002                                                continue;
2003                                            }
2004                                            "/voice" => {
2005                                                use crate::ui::voice::VOICE_LIST;
2006                                                if let Some(arg) = parts.get(1) {
2007                                                    // /voice N — select by number
2008                                                    if let Ok(n) = arg.parse::<usize>() {
2009                                                        let idx = n.saturating_sub(1);
2010                                                        if let Some(&(id, label)) = VOICE_LIST.get(idx) {
2011                                                            app.voice_manager.set_voice(id);
2012                                                            let _ = crate::agent::config::set_voice(id);
2013                                                            app.push_message("System", &format!("Voice set to {} — {}", id, label));
2014                                                        } else {
2015                                                            app.push_message("System", &format!("Invalid voice number. Use /voice to list voices (1–{}).", VOICE_LIST.len()));
2016                                                        }
2017                                                    } else {
2018                                                        // /voice af_bella — select by name
2019                                                        if let Some(&(id, label)) = VOICE_LIST.iter().find(|&&(id, _)| id == *arg) {
2020                                                            app.voice_manager.set_voice(id);
2021                                                            let _ = crate::agent::config::set_voice(id);
2022                                                            app.push_message("System", &format!("Voice set to {} — {}", id, label));
2023                                                        } else {
2024                                                            app.push_message("System", &format!("Unknown voice '{}'. Use /voice to list voices.", arg));
2025                                                        }
2026                                                    }
2027                                                } else {
2028                                                    // /voice — list all voices
2029                                                    let current = app.voice_manager.current_voice_id();
2030                                                    let mut list = format!("Available voices (current: {}):\n", current);
2031                                                    for (i, &(id, label)) in VOICE_LIST.iter().enumerate() {
2032                                                        let marker = if id == current.as_str() { " ◀" } else { "" };
2033                                                        list.push_str(&format!("  {:>2}. {}{}\n", i + 1, label, marker));
2034                                                    }
2035                                                    list.push_str("\nUse /voice N or /voice <id> to select.");
2036                                                    app.push_message("System", &list);
2037                                                }
2038                                                app.history_idx = None;
2039                                                continue;
2040                                            }
2041                                            "/read" => {
2042                                                let text = parts[1..].join(" ");
2043                                                if text.is_empty() {
2044                                                    app.push_message("System", "Usage: /read <text to speak>");
2045                                                } else if !app.voice_manager.is_available() {
2046                                                    app.push_message("System", "Voice is not available in this build. Use a packaged release for baked-in voice.");
2047                                                } else if !app.voice_manager.is_enabled() {
2048                                                    app.push_message("System", "Voice is off. Press Ctrl+T to enable, then /read again.");
2049                                                } else {
2050                                                    app.push_message("System", &format!("Reading {} words aloud. ESC to stop.", text.split_whitespace().count()));
2051                                                    app.voice_manager.speak(text.clone());
2052                                                }
2053                                                app.history_idx = None;
2054                                                continue;
2055                                            }
2056                                            "/new" => {
2057                                                reset_visible_session_state(&mut app);
2058                                                app.push_message("You", "/new");
2059                                                app.agent_running = true;
2060                                                app.clear_pending_attachments();
2061                                                let _ = app.user_input_tx.try_send(UserTurn::text("/new"));
2062                                                app.history_idx = None;
2063                                                continue;
2064                                            }
2065                                            "/forget" => {
2066                                                // Cancel any running turn so /forget isn't queued behind retries.
2067                                                app.cancel_token.store(true, std::sync::atomic::Ordering::SeqCst);
2068                                                reset_visible_session_state(&mut app);
2069                                                app.push_message("You", "/forget");
2070                                                app.agent_running = true;
2071                                                app.cancel_token.store(false, std::sync::atomic::Ordering::SeqCst);
2072                                                app.clear_pending_attachments();
2073                                                let _ = app.user_input_tx.try_send(UserTurn::text("/forget"));
2074                                                app.history_idx = None;
2075                                                continue;
2076                                            }
2077                                            "/gemma-native" => {
2078                                                let sub = parts.get(1).copied().unwrap_or("status").to_ascii_lowercase();
2079                                                let gemma_detected = crate::agent::inference::is_gemma4_model_name(&app.model_id);
2080                                                match sub.as_str() {
2081                                                    "auto" => {
2082                                                        match crate::agent::config::set_gemma_native_mode("auto") {
2083                                                            Ok(_) => {
2084                                                                if gemma_detected {
2085                                                                    app.push_message("System", "Gemma Native Formatting: AUTO. Gemma 4 will use native formatting automatically on the next turn.");
2086                                                                } else {
2087                                                                    app.push_message("System", "Gemma Native Formatting: AUTO in settings. It will activate automatically when a Gemma 4 model is loaded.");
2088                                                                }
2089                                                            }
2090                                                            Err(e) => app.push_message("System", &format!("Failed to update settings: {}", e)),
2091                                                        }
2092                                                    }
2093                                                    "on" => {
2094                                                        match crate::agent::config::set_gemma_native_mode("on") {
2095                                                            Ok(_) => {
2096                                                                if gemma_detected {
2097                                                                    app.push_message("System", "Gemma Native Formatting: ON (forced). It will apply on the next turn.");
2098                                                                } else {
2099                                                                    app.push_message("System", "Gemma Native Formatting: ON (forced) in settings. It will activate only when a Gemma 4 model is loaded.");
2100                                                                }
2101                                                            }
2102                                                            Err(e) => app.push_message("System", &format!("Failed to update settings: {}", e)),
2103                                                        }
2104                                                    }
2105                                                    "off" => {
2106                                                        match crate::agent::config::set_gemma_native_mode("off") {
2107                                                            Ok(_) => app.push_message("System", "Gemma Native Formatting: OFF."),
2108                                                            Err(e) => app.push_message("System", &format!("Failed to update settings: {}", e)),
2109                                                        }
2110                                                    }
2111                                                    _ => {
2112                                                        let config = crate::agent::config::load_config();
2113                                                        let mode = crate::agent::config::gemma_native_mode_label(&config, &app.model_id);
2114                                                        let enabled = match mode {
2115                                                            "on" => "ON (forced)",
2116                                                            "auto" => "ON (auto)",
2117                                                            "off" => "OFF",
2118                                                            _ => "INACTIVE",
2119                                                        };
2120                                                        let model_note = if gemma_detected {
2121                                                            "Gemma 4 detected."
2122                                                        } else {
2123                                                            "Current model is not Gemma 4."
2124                                                        };
2125                                                        app.push_message(
2126                                                            "System",
2127                                                            &format!(
2128                                                                "Gemma Native Formatting: {}. {} Usage: /gemma-native auto | on | off | status",
2129                                                                enabled, model_note
2130                                                            ),
2131                                                        );
2132                                                    }
2133                                                }
2134                                                app.history_idx = None;
2135                                                continue;
2136                                            }
2137                                            "/chat" => {
2138                                                app.workflow_mode = "CHAT".into();
2139                                                app.push_message("System", "Chat mode — natural conversation, no agent scaffolding. Use /agent to switch back.");
2140                                                app.history_idx = None;
2141                                                let _ = app.user_input_tx.try_send(UserTurn::text("/chat"));
2142                                                continue;
2143                                            }
2144                                            "/reroll" => {
2145                                                app.history_idx = None;
2146                                                let _ = app.user_input_tx.try_send(UserTurn::text("/reroll"));
2147                                                continue;
2148                                            }
2149                                            "/agent" => {
2150                                                app.workflow_mode = "AUTO".into();
2151                                                app.push_message("System", "Agent mode — full coding harness active. Use /chat for clean conversation.");
2152                                                app.history_idx = None;
2153                                                let _ = app.user_input_tx.try_send(UserTurn::text("/agent"));
2154                                                continue;
2155                                            }
2156                                            "/ask" | "/code" | "/architect" | "/read-only" | "/auto" => {
2157                                                let label = match cmd.as_str() {
2158                                                    "/ask" => "ASK",
2159                                                    "/code" => "CODE",
2160                                                    "/architect" => "ARCHITECT",
2161                                                    "/read-only" => "READ-ONLY",
2162                                                    _ => "AUTO",
2163                                                };
2164                                                app.workflow_mode = label.to_string();
2165                                                let outbound = input_text.trim().to_string();
2166                                                app.push_message("You", &outbound);
2167                                                app.agent_running = true;
2168                                                let _ = app.user_input_tx.try_send(UserTurn::text(outbound));
2169                                                app.history_idx = None;
2170                                                continue;
2171                                            }
2172                                            "/worktree" => {
2173                                                let sub = parts.get(1).copied().unwrap_or("");
2174                                                match sub {
2175                                                    "list" => {
2176                                                        app.push_message("You", "/worktree list");
2177                                                        app.agent_running = true;
2178                                                        let _ = app.user_input_tx.try_send(UserTurn::text(
2179                                                            "Call git_worktree with action=list"
2180                                                        ));
2181                                                    }
2182                                                    "add" => {
2183                                                        let wt_path = parts.get(2).copied().unwrap_or("");
2184                                                        let wt_branch = parts.get(3).copied().unwrap_or("");
2185                                                        if wt_path.is_empty() {
2186                                                            app.push_message("System", "Usage: /worktree add <path> [branch]");
2187                                                        } else {
2188                                                            app.push_message("You", &format!("/worktree add {wt_path}"));
2189                                                            app.agent_running = true;
2190                                                            let directive = if wt_branch.is_empty() {
2191                                                                format!("Call git_worktree with action=add path={wt_path}")
2192                                                            } else {
2193                                                                format!("Call git_worktree with action=add path={wt_path} branch={wt_branch}")
2194                                                            };
2195                                                            let _ = app.user_input_tx.try_send(UserTurn::text(directive));
2196                                                        }
2197                                                    }
2198                                                    "remove" => {
2199                                                        let wt_path = parts.get(2).copied().unwrap_or("");
2200                                                        if wt_path.is_empty() {
2201                                                            app.push_message("System", "Usage: /worktree remove <path>");
2202                                                        } else {
2203                                                            app.push_message("You", &format!("/worktree remove {wt_path}"));
2204                                                            app.agent_running = true;
2205                                                            let _ = app.user_input_tx.try_send(UserTurn::text(
2206                                                                format!("Call git_worktree with action=remove path={wt_path}")
2207                                                            ));
2208                                                        }
2209                                                    }
2210                                                    "prune" => {
2211                                                        app.push_message("You", "/worktree prune");
2212                                                        app.agent_running = true;
2213                                                        let _ = app.user_input_tx.try_send(UserTurn::text(
2214                                                            "Call git_worktree with action=prune"
2215                                                        ));
2216                                                    }
2217                                                    _ => {
2218                                                        app.push_message("System",
2219                                                            "Usage: /worktree list | add <path> [branch] | remove <path> | prune");
2220                                                    }
2221                                                }
2222                                                app.history_idx = None;
2223                                                continue;
2224                                            }
2225                                            "/think" => {
2226                                                app.think_mode = Some(true);
2227                                                app.push_message("You", "/think");
2228                                                app.agent_running = true;
2229                                                let _ = app.user_input_tx.try_send(UserTurn::text("/think"));
2230                                                app.history_idx = None;
2231                                                continue;
2232                                            }
2233                                            "/no_think" => {
2234                                                app.think_mode = Some(false);
2235                                                app.push_message("You", "/no_think");
2236                                                app.agent_running = true;
2237                                                let _ = app.user_input_tx.try_send(UserTurn::text("/no_think"));
2238                                                app.history_idx = None;
2239                                                continue;
2240                                            }
2241                                            "/lsp" => {
2242                                                app.push_message("You", "/lsp");
2243                                                app.agent_running = true;
2244                                                let _ = app.user_input_tx.try_send(UserTurn::text("/lsp"));
2245                                                app.history_idx = None;
2246                                                continue;
2247                                            }
2248                                            "/runtime-refresh" => {
2249                                                app.push_message("You", "/runtime-refresh");
2250                                                app.agent_running = true;
2251                                                let _ = app.user_input_tx.try_send(UserTurn::text("/runtime-refresh"));
2252                                                app.history_idx = None;
2253                                                continue;
2254                                            }
2255                                            "/help" => {
2256                                                show_help_message(&mut app);
2257                                                app.history_idx = None;
2258                                                continue;
2259                                            }
2260                                            "/help-legacy-unused" => {
2261                                                app.push_message("System",
2262                                                    "Hematite Commands:\n\
2263                                                     /chat             — (Mode) Conversation mode — clean chat, no tool noise\n\
2264                                                     /agent            — (Mode) Full coding harness — tools, file edits, builds\n\
2265                                                     /reroll           — (Soul) Hatch a new companion mid-session\n\
2266                                                     /auto             — (Flow) Let Hematite choose the narrowest effective workflow\n\
2267                                                     /ask [prompt]     — (Flow) Read-only analysis mode; optional inline prompt\n\
2268                                                     /code [prompt]    — (Flow) Explicit implementation mode; optional inline prompt\n\
2269                                                       /architect [prompt] — (Flow) Plan-first mode; optional inline prompt\n\
2270                                                       /read-only [prompt] — (Flow) Hard read-only mode; optional inline prompt\n\
2271                                                       /new              — (Reset) Fresh task context; clear chat, pins, and task files\n\
2272                                                       /forget           — (Wipe) Hard forget; purge saved memory and Vein index too\n\
2273                                                       /vein-inspect     — (Vein) Inspect indexed memory, hot files, and active room bias\n\
2274                                                       /workspace-profile — (Profile) Show the auto-generated workspace profile\n\
2275                                                       /version          — (Build) Show the running Hematite version\n\
2276                                                       /vein-reset       — (Vein) Wipe the RAG index; rebuilds automatically on next turn\n\
2277                                                       /clear            — (UI) Clear dialogue display only\n\
2278                                                     /gemma-native [auto|on|off|status] — (Model) Auto/force/disable Gemma 4 native formatting\n\
2279                                                     /runtime-refresh  — (Model) Re-read LM Studio model + CTX now\n\
2280                                                     /undo             — (Ghost) Revert last file change\n\
2281                                                     /diff             — (Git) Show session changes (--stat)\n\
2282                                                     /lsp              — (Logic) Start Language Servers (semantic intelligence)\n\
2283                                                     /swarm <text>     — (Swarm) Spawn parallel workers on a directive\n\
2284                                                     /worktree <cmd>   — (Isolated) Manage git worktrees (list|add|remove|prune)\n\
2285                                                     /think            — (Brain) Enable deep reasoning mode\n\
2286                                                     /no_think         — (Speed) Disable reasoning (3-5x faster responses)\n\
2287                                                     /voice            — (TTS) List all available voices\n\
2288                                                     /voice N          — (TTS) Select voice by number\n\
2289                                                     /attach <path>    — (Docs) Attach a PDF/markdown/txt file for next message\n\
2290                                                     /attach-pick      — (Docs) Open a file picker and attach a document\n\
2291                                                     /image <path>     — (Vision) Attach an image for the next message\n\
2292                                                     /image-pick       — (Vision) Open a file picker and attach an image\n\
2293                                                     /detach           — (Context) Drop pending document/image attachments\n\
2294                                                     /copy             — (Debug) Copy session transcript to clipboard\n\
2295                                                     /copy2            — (Debug) Copy SPECULAR log to clipboard (reasoning + events)\n\
2296                                                     \nHotkeys:\n\
2297                                                     Ctrl+B — Toggle Brief Mode (minimal output)\n\
2298                                                     Ctrl+P — Toggle Professional Mode (strip personality)\n\
2299                                                     Ctrl+O — Open document picker for next-turn context\n\
2300                                                     Ctrl+I — Open image picker for next-turn vision context\n\
2301                                                     Ctrl+Y — Toggle Approvals Off (bypass safety approvals)\n\
2302                                                     Ctrl+S — Quick Swarm (hardcoded bootstrap)\n\
2303                                                     Ctrl+Z — Undo last edit\n\
2304                                                     Ctrl+Q/C — Quit session\n\
2305                                                     ESC    — Silence current playback\n\
2306                                                     \nStatus Legend:\n\
2307                                                     LM    — LM Studio runtime health (`LIVE`, `RECV`, `WARN`, `CEIL`, `STALE`, `BOOT`)\n\
2308                                                     VN    — Vein RAG status (`SEM`=semantic active, `FTS`=BM25 only, `--`=not indexed)\n\
2309                                                     BUD   — Total prompt-budget pressure against the live context window\n\
2310                                                     CMP   — History compaction pressure against Hematite's adaptive threshold\n\
2311                                                     ERR   — Session error count (runtime, tool, or SPECULAR failures)\n\
2312                                                     CTX   — Live context window currently reported by LM Studio\n\
2313                                                     VOICE — Local speech output state\n\
2314                                                     \nAssistant: Semantic Pathing (LSP), Vision Pass, Web Research, Swarm Synthesis"
2315                                                );
2316                                                app.history_idx = None;
2317                                                continue;
2318                                            }
2319                                            "/swarm" => {
2320                                                let directive = parts[1..].join(" ");
2321                                                if directive.is_empty() {
2322                                                    app.push_message("System", "Usage: /swarm <directive>");
2323                                                } else {
2324                                                    app.active_workers.clear(); // Fresh architecture for a fresh command
2325                                                    app.push_message("Hematite", &format!("Swarm analyzing: '{}'", directive));
2326                                                    let swarm_tx_c = swarm_tx.clone();
2327                                                    let coord_c = swarm_coordinator.clone();
2328                                                    let max_workers = if app.gpu_state.ratio() > 0.75 { 1 } else { 3 };
2329                                                    app.agent_running = true;
2330                                                    tokio::spawn(async move {
2331                                                        let payload = format!(r#"<worker_task id="1" target="src">Research {}</worker_task>
2332<worker_task id="2" target="src">Implement {}</worker_task>
2333<worker_task id="3" target="docs">Document {}</worker_task>"#, directive, directive, directive);
2334                                                        let tasks = crate::agent::parser::parse_master_spec(&payload);
2335                                                        let _ = coord_c.dispatch_swarm(tasks, swarm_tx_c, max_workers).await;
2336                                                    });
2337                                                }
2338                                                app.history_idx = None;
2339                                                continue;
2340                                            }
2341                                            "/version" => {
2342                                                app.push_message(
2343                                                    "System",
2344                                                    &crate::hematite_version_report(),
2345                                                );
2346                                                app.history_idx = None;
2347                                                continue;
2348                                            }
2349                                            "/detach" => {
2350                                                app.clear_pending_attachments();
2351                                                app.push_message("System", "Cleared pending document/image attachments for the next turn.");
2352                                                app.history_idx = None;
2353                                                continue;
2354                                            }
2355                                            "/attach" => {
2356                                                let file_path = parts[1..].join(" ").trim().to_string();
2357                                                if file_path.is_empty() {
2358                                                    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.");
2359                                                    app.history_idx = None;
2360                                                    continue;
2361                                                }
2362                                                if file_path.is_empty() {
2363                                                    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.");
2364                                                } else {
2365                                                    let p = std::path::Path::new(&file_path);
2366                                                    match crate::memory::vein::extract_document_text(p) {
2367                                                        Ok(text) => {
2368                                                            let name = p.file_name()
2369                                                                .and_then(|n| n.to_str())
2370                                                                .unwrap_or(&file_path)
2371                                                                .to_string();
2372                                                            let preview_len = text.len().min(200);
2373                                                            app.push_message("System", &format!(
2374                                                                "Attached: {} ({} chars) — will be injected as context on your next message.\nPreview: {}...",
2375                                                                name, text.len(), &text[..preview_len]
2376                                                            ));
2377                                                            app.attached_context = Some((name, text));
2378                                                        }
2379                                                        Err(e) => {
2380                                                            app.push_message("System", &format!("Attach failed: {}", e));
2381                                                        }
2382                                                    }
2383                                                }
2384                                                app.history_idx = None;
2385                                                continue;
2386                                            }
2387                                            "/attach-pick" => {
2388                                                match pick_attachment_path(AttachmentPickerKind::Document) {
2389                                                    Ok(Some(path)) => attach_document_from_path(&mut app, &path),
2390                                                    Ok(None) => app.push_message("System", "Document picker cancelled."),
2391                                                    Err(e) => app.push_message("System", &e),
2392                                                }
2393                                                app.history_idx = None;
2394                                                continue;
2395                                            }
2396                                            "/image" => {
2397                                                let file_path = parts[1..].join(" ").trim().to_string();
2398                                                if file_path.is_empty() {
2399                                                    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.");
2400                                                } else {
2401                                                    attach_image_from_path(&mut app, &file_path);
2402                                                }
2403                                                app.history_idx = None;
2404                                                continue;
2405                                            }
2406                                            "/image-pick" => {
2407                                                match pick_attachment_path(AttachmentPickerKind::Image) {
2408                                                    Ok(Some(path)) => attach_image_from_path(&mut app, &path),
2409                                                    Ok(None) => app.push_message("System", "Image picker cancelled."),
2410                                                    Err(e) => app.push_message("System", &e),
2411                                                }
2412                                                app.history_idx = None;
2413                                                continue;
2414                                            }
2415                                            _ => {
2416                                                app.push_message("System", &format!("Unknown command: {}", cmd));
2417                                                app.history_idx = None;
2418                                                continue;
2419                                            }
2420                                        }
2421                                    }
2422
2423                                    // Save to history (avoid consecutive duplicates).
2424                                    if app.input_history.last().map(|s| s.as_str()) != Some(&input_text) {
2425                                        app.input_history.push(input_text.clone());
2426                                        if app.input_history.len() > 50 {
2427                                            app.input_history.remove(0);
2428                                        }
2429                                    }
2430                                    app.history_idx = None;
2431                                    app.push_message("You", &input_text);
2432                                    app.agent_running = true;
2433                                    app.cancel_token.store(false, std::sync::atomic::Ordering::SeqCst);
2434                                    app.last_reasoning.clear();
2435                                    app.manual_scroll_offset = None;
2436                                    app.specular_auto_scroll = true;
2437                                    let tx = app.user_input_tx.clone();
2438                                    let outbound = UserTurn {
2439                                        text: input_text,
2440                                        attached_document: app.attached_context.take().map(|(name, content)| {
2441                                            AttachedDocument { name, content }
2442                                        }),
2443                                        attached_image: app.attached_image.take(),
2444                                    };
2445                                    tokio::spawn(async move {
2446                                        let _ = tx.send(outbound).await;
2447                                    });
2448                                }
2449                            }
2450                            _ => {}
2451                        }
2452                    }
2453                    Some(Ok(Event::Paste(content))) => {
2454                        if !try_attach_from_paste(&mut app, &content) {
2455                            // Normalize pasted newlines into spaces so we don't accidentally submit
2456                            // multiple lines or break the single-line input logic.
2457                            let normalized = content.replace("\r\n", " ").replace('\n', " ");
2458                            app.input.push_str(&normalized);
2459                            app.last_input_time = Instant::now();
2460                        }
2461                    }
2462                    _ => {}
2463                }
2464            }
2465
2466            // ── Specular proactive watcher ────────────────────────────────────
2467            Some(specular_evt) = specular_rx.recv() => {
2468                match specular_evt {
2469                    SpecularEvent::SyntaxError { path, details } => {
2470                        app.record_error();
2471                        app.specular_logs.push(format!("ERROR: {:?}", path));
2472                        trim_vec(&mut app.specular_logs, 20);
2473
2474                        // Only proactively suggest a fix if the user isn't actively typing.
2475                        let user_idle = {
2476                            let lock = last_interaction.lock().unwrap();
2477                            lock.elapsed() > std::time::Duration::from_secs(3)
2478                        };
2479                        if user_idle && !app.agent_running {
2480                            app.agent_running = true;
2481                            let tx = app.user_input_tx.clone();
2482                            let diag = details.clone();
2483                            tokio::spawn(async move {
2484                                let msg = format!(
2485                                    "<specular-build-fail>\n{}\n</specular-build-fail>\n\
2486                                     Fix the compiler error above.",
2487                                    diag
2488                                );
2489                                let _ = tx.send(UserTurn::text(msg)).await;
2490                            });
2491                        }
2492                    }
2493                    SpecularEvent::FileChanged(path) => {
2494                        app.stats.wisdom += 1;
2495                        app.stats.patience = (app.stats.patience - 0.5).max(0.0);
2496                        if app.stats.patience < 50.0 && !app.brief_mode {
2497                            app.brief_mode = true;
2498                            app.push_message("System", "Context saturation high — Brief Mode auto-enabled.");
2499                        }
2500                        let path_str = path.to_string_lossy().to_string();
2501                        app.specular_logs.push(format!("INDEX: {}", path_str));
2502                        app.push_context_file(path_str, "Active".into());
2503                        trim_vec(&mut app.specular_logs, 20);
2504                    }
2505                }
2506            }
2507
2508            // ── Inference / agent events ──────────────────────────────────────
2509            Some(event) = agent_rx.recv() => {
2510                use crate::agent::inference::InferenceEvent;
2511                match event {
2512                    InferenceEvent::Thought(content) => {
2513                        app.thinking = true;
2514                        app.current_thought.push_str(&content);
2515                    }
2516                    InferenceEvent::VoiceStatus(msg) => {
2517                        app.push_message("System", &msg);
2518                    }
2519                    InferenceEvent::Token(ref token) | InferenceEvent::MutedToken(ref token) => {
2520                        let is_muted = matches!(event, InferenceEvent::MutedToken(_));
2521                        app.thinking = false;
2522                        if app.messages_raw.last().map(|(s, _)| s.as_str()) != Some("Hematite") {
2523                            app.push_message("Hematite", "");
2524                        }
2525                        app.update_last_message(token);
2526                        app.manual_scroll_offset = None;
2527
2528                        // ONLY speak if not muted
2529                        if !is_muted && app.voice_manager.is_enabled() && !app.cancel_token.load(std::sync::atomic::Ordering::SeqCst) {
2530                            app.voice_manager.speak(token.clone());
2531                        }
2532                    }
2533                    InferenceEvent::ToolCallStart { name, args, .. } => {
2534                        // In chat mode, suppress tool noise from the main chat surface.
2535                        if app.workflow_mode != "CHAT" {
2536                            let display = format!("( )  {} {}", name, args);
2537                            app.push_message("Tool", &display);
2538                        }
2539                        // Always track in active context regardless of mode
2540                        app.active_context.push(ContextFile {
2541                            path: name.clone(),
2542                            size: 0,
2543                            status: "Running".into()
2544                        });
2545                        trim_vec_context(&mut app.active_context, 8);
2546                        app.manual_scroll_offset = None;
2547                    }
2548                    InferenceEvent::ToolCallResult { id: _, name, output, is_error } => {
2549                        let icon = if is_error { "[x]" } else { "[v]" };
2550                        if is_error {
2551                            app.record_error();
2552                        }
2553                        // In chat mode, suppress tool results from main chat.
2554                        // Errors still show so the user knows something went wrong.
2555                        let preview = first_n_chars(&output, 100);
2556                        if app.workflow_mode != "CHAT" {
2557                            app.push_message("Tool", &format!("{}  {} → {}", icon, name, preview));
2558                        } else if is_error {
2559                            app.push_message("System", &format!("Tool error: {}", preview));
2560                        }
2561
2562                        // If it was a read or write, we can extract the path from the app.active_context "Running" entries
2563                        // but it's simpler to just let Specular handle the indexing or update here if we had the path.
2564
2565                        // Remove "Running" tools from context list
2566                        app.active_context.retain(|f| f.path != name || f.status != "Running");
2567                        app.manual_scroll_offset = None;
2568                    }
2569                    InferenceEvent::ApprovalRequired { id: _, name, display, diff, responder } => {
2570                        let is_diff = diff.is_some();
2571                        app.awaiting_approval = Some(PendingApproval {
2572                            display: display.clone(),
2573                            tool_name: name,
2574                            diff,
2575                            diff_scroll: 0,
2576                            responder,
2577                        });
2578                        if is_diff {
2579                            app.push_message("System", "[~]  Diff preview — [Y] Apply  [N] Skip");
2580                        } else {
2581                            app.push_message("System", "[!]  Approval required (Press [Y] Approve or [N] Decline)");
2582                            app.push_message("System", &format!("Command: {}", display));
2583                        }
2584                    }
2585                    InferenceEvent::UsageUpdate(usage) => {
2586                        app.total_tokens = usage.total_tokens;
2587                        // Calculate discounted cost for this turn.
2588                        let turn_cost = crate::agent::pricing::calculate_cost(&usage, &app.model_id);
2589                        app.current_session_cost += turn_cost;
2590                    }
2591                    InferenceEvent::Done => {
2592                        app.thinking = false;
2593                        app.agent_running = false;
2594                        if app.voice_manager.is_enabled() {
2595                            app.voice_manager.flush();
2596                        }
2597                        if !app.current_thought.is_empty() {
2598                            app.last_reasoning = app.current_thought.clone();
2599                        }
2600                        app.current_thought.clear();
2601                        app.specular_auto_scroll = true;
2602                        // Clear single-agent task bars on completion
2603                        app.active_workers.remove("AGENT");
2604                        app.worker_labels.remove("AGENT");
2605                    }
2606                    InferenceEvent::Error(e) => {
2607                        app.record_error();
2608                        app.thinking = false;
2609                        app.agent_running = false;
2610                        if app.voice_manager.is_enabled() {
2611                            app.voice_manager.flush();
2612                        }
2613                        app.push_message("System", &format!("Error: {e}"));
2614                    }
2615                    InferenceEvent::ProviderStatus { state, summary } => {
2616                        app.provider_state = state;
2617                        if !summary.trim().is_empty() && app.last_provider_summary != summary {
2618                            app.specular_logs.push(format!("PROVIDER: {}", summary));
2619                            trim_vec(&mut app.specular_logs, 20);
2620                            app.last_provider_summary = summary;
2621                        }
2622                    }
2623                    InferenceEvent::McpStatus { state, summary } => {
2624                        app.mcp_state = state;
2625                        if !summary.trim().is_empty() && app.last_mcp_summary != summary {
2626                            app.specular_logs.push(format!("MCP: {}", summary));
2627                            trim_vec(&mut app.specular_logs, 20);
2628                            app.last_mcp_summary = summary;
2629                        }
2630                    }
2631                    InferenceEvent::OperatorCheckpoint { state, summary } => {
2632                        app.last_operator_checkpoint_state = state;
2633                        if state == OperatorCheckpointState::Idle {
2634                            app.last_operator_checkpoint_summary.clear();
2635                        } else if !summary.trim().is_empty()
2636                            && app.last_operator_checkpoint_summary != summary
2637                        {
2638                            app.specular_logs.push(format!(
2639                                "STATE: {} - {}",
2640                                state.label(),
2641                                summary
2642                            ));
2643                            trim_vec(&mut app.specular_logs, 20);
2644                            app.last_operator_checkpoint_summary = summary;
2645                        }
2646                    }
2647                    InferenceEvent::RecoveryRecipe { summary } => {
2648                        if !summary.trim().is_empty()
2649                            && app.last_recovery_recipe_summary != summary
2650                        {
2651                            app.specular_logs.push(format!("RECOVERY: {}", summary));
2652                            trim_vec(&mut app.specular_logs, 20);
2653                            app.last_recovery_recipe_summary = summary;
2654                        }
2655                    }
2656                    InferenceEvent::CompactionPressure {
2657                        estimated_tokens,
2658                        threshold_tokens,
2659                        percent,
2660                    } => {
2661                        app.compaction_estimated_tokens = estimated_tokens;
2662                        app.compaction_threshold_tokens = threshold_tokens;
2663                        app.compaction_percent = percent;
2664                        // Fire a one-shot warning when crossing 70% or 90%.
2665                        // Reset warned_level to 0 when pressure drops back below 60%
2666                        // so warnings re-fire if context fills up again after a /new.
2667                        if percent < 60 {
2668                            app.compaction_warned_level = 0;
2669                        } else if percent >= 90 && app.compaction_warned_level < 90 {
2670                            app.compaction_warned_level = 90;
2671                            app.push_message(
2672                                "System",
2673                                "Context is 90% full. Use /new to reset history (project memory is preserved) or /forget to wipe everything.",
2674                            );
2675                        } else if percent >= 70 && app.compaction_warned_level < 70 {
2676                            app.compaction_warned_level = 70;
2677                            app.push_message(
2678                                "System",
2679                                &format!("Context at {}% — approaching the compaction threshold. Consider /new soon to keep responses sharp.", percent),
2680                            );
2681                        }
2682                    }
2683                    InferenceEvent::PromptPressure {
2684                        estimated_input_tokens,
2685                        reserved_output_tokens,
2686                        estimated_total_tokens,
2687                        context_length: _,
2688                        percent,
2689                    } => {
2690                        app.prompt_estimated_input_tokens = estimated_input_tokens;
2691                        app.prompt_reserved_output_tokens = reserved_output_tokens;
2692                        app.prompt_estimated_total_tokens = estimated_total_tokens;
2693                        app.prompt_pressure_percent = percent;
2694                    }
2695                    InferenceEvent::TaskProgress { id, label, progress } => {
2696                        let nid = normalize_id(&id);
2697                        app.active_workers.insert(nid.clone(), progress);
2698                        app.worker_labels.insert(nid, label);
2699                    }
2700                    InferenceEvent::RuntimeProfile { model_id, context_length } => {
2701                        let was_no_model = app.model_id == "no model loaded";
2702                        let now_no_model = model_id == "no model loaded";
2703                        let changed = app.model_id != "detecting..."
2704                            && (app.model_id != model_id || app.context_length != context_length);
2705                        app.model_id = model_id.clone();
2706                        app.context_length = context_length;
2707                        app.last_runtime_profile_time = Instant::now();
2708                        if app.provider_state == ProviderRuntimeState::Booting {
2709                            app.provider_state = ProviderRuntimeState::Live;
2710                        }
2711                        if now_no_model && !was_no_model {
2712                            app.push_message(
2713                                "System",
2714                                "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.",
2715                            );
2716                        } else if changed && !now_no_model {
2717                            app.push_message(
2718                                "System",
2719                                &format!(
2720                                    "Runtime profile refreshed: Model {} | CTX {}",
2721                                    model_id, context_length
2722                                ),
2723                            );
2724                        }
2725                    }
2726                    InferenceEvent::EmbedProfile { model_id } => {
2727                        match model_id {
2728                            Some(id) => app.push_message(
2729                                "System",
2730                                &format!("Embed model loaded: {} (semantic search ready)", id),
2731                            ),
2732                            None => app.push_message(
2733                                "System",
2734                                "Embed model unloaded. Semantic search inactive.",
2735                            ),
2736                        }
2737                    }
2738                    InferenceEvent::VeinStatus { file_count, embedded_count, docs_only } => {
2739                        app.vein_file_count = file_count;
2740                        app.vein_embedded_count = embedded_count;
2741                        app.vein_docs_only = docs_only;
2742                    }
2743                    InferenceEvent::VeinContext { paths } => {
2744                        // Replace the default placeholder entries with what the
2745                        // Vein actually surfaced for this turn.
2746                        app.active_context.retain(|f| f.status == "Running");
2747                        for path in paths {
2748                            let root = crate::tools::file_ops::workspace_root();
2749                            let size = std::fs::metadata(root.join(&path))
2750                                .map(|m| m.len())
2751                                .unwrap_or(0);
2752                            if !app.active_context.iter().any(|f| f.path == path) {
2753                                app.active_context.push(ContextFile {
2754                                    path,
2755                                    size,
2756                                    status: "Vein".to_string(),
2757                                });
2758                            }
2759                        }
2760                        trim_vec_context(&mut app.active_context, 8);
2761                    }
2762                    InferenceEvent::SoulReroll { species, rarity, shiny, .. } => {
2763                        let shiny_tag = if shiny { " 🌟 SHINY" } else { "" };
2764                        app.soul_name = species.clone();
2765                        app.push_message(
2766                            "System",
2767                            &format!("[{}{}] {} has awakened.", rarity, shiny_tag, species),
2768                        );
2769                    }
2770                }
2771            }
2772
2773            // ── Swarm messages ────────────────────────────────────────────────
2774            Some(msg) = swarm_rx.recv() => {
2775                match msg {
2776                    SwarmMessage::Progress(worker_id, progress) => {
2777                        let nid = normalize_id(&worker_id);
2778                        app.active_workers.insert(nid.clone(), progress);
2779                        match progress {
2780                            102 => app.push_message("System", &format!("Worker {} architecture verified and applied.", nid)),
2781                            101 => { /* Handled by 102 terminal message */ },
2782                            100 => app.push_message("Hematite", &format!("Worker {} complete. Standing by for review...", nid)),
2783                            _ => {}
2784                        }
2785                    }
2786                    SwarmMessage::ReviewRequest { worker_id, file_path, before, after, tx } => {
2787                        app.push_message("Hematite", &format!("Worker {} conflict — review required.", worker_id));
2788                        app.active_review = Some(ActiveReview {
2789                            worker_id,
2790                            file_path: file_path.to_string_lossy().to_string(),
2791                            before,
2792                            after,
2793                            tx,
2794                        });
2795                    }
2796                    SwarmMessage::Done => {
2797                        app.agent_running = false;
2798                        // Workers now persist in SPECULAR until a new command is issued
2799                        app.push_message("System", "──────────────────────────────────────────────────────────");
2800                        app.push_message("System", " TASK COMPLETE: Swarm Synthesis Finalized ");
2801                        app.push_message("System", "──────────────────────────────────────────────────────────");
2802                    }
2803                }
2804            }
2805        }
2806    }
2807    Ok(())
2808}
2809
2810// ── Render ────────────────────────────────────────────────────────────────────
2811
2812fn ui(f: &mut ratatui::Frame, app: &App) {
2813    let size = f.size();
2814    if size.width < 60 || size.height < 10 {
2815        // Render a minimal wait message or just clear if area is too collapsed
2816        f.render_widget(Clear, size);
2817        return;
2818    }
2819
2820    let input_height = compute_input_height(f.size().width, app.input.len());
2821
2822    let chunks = Layout::default()
2823        .direction(Direction::Vertical)
2824        .constraints([
2825            Constraint::Min(0),
2826            Constraint::Length(input_height),
2827            Constraint::Length(3),
2828        ])
2829        .split(f.size());
2830
2831    let top = Layout::default()
2832        .direction(Direction::Horizontal)
2833        .constraints([Constraint::Fill(1), Constraint::Length(45)]) // Fixed width sidebar prevents bleed
2834        .split(chunks[0]);
2835
2836    // ── Box 1: Dialogue ───────────────────────────────────────────────────────
2837    let mut core_lines = app.messages.clone();
2838
2839    // Show agent-running indicator as last line when active.
2840    if app.agent_running {
2841        let dots = ".".repeat((app.tick_count % 4) as usize + 1);
2842        core_lines.push(Line::from(Span::styled(
2843            format!(" Hematite is thinking{}", dots),
2844            Style::default()
2845                .fg(Color::Magenta)
2846                .add_modifier(Modifier::DIM),
2847        )));
2848    }
2849
2850    let (heart_color, core_icon) = if app.agent_running || !app.active_workers.is_empty() {
2851        let (r_base, g_base, b_base) = if !app.active_workers.is_empty() {
2852            (0, 200, 200) // Cyan pulse for swarm
2853        } else {
2854            (200, 0, 200) // Magenta pulse for thinking
2855        };
2856
2857        let pulse = (app.tick_count % 50) as f64 / 50.0;
2858        let factor = (pulse * std::f64::consts::PI).sin().abs();
2859        let r = (r_base as f64 * factor) as u8;
2860        let g = (g_base as f64 * factor) as u8;
2861        let b = (b_base as f64 * factor) as u8;
2862
2863        (Color::Rgb(r.max(60), g.max(60), b.max(60)), "•")
2864    } else {
2865        (Color::Rgb(80, 80, 80), "•") // Standby
2866    };
2867
2868    let live_objective = if app.current_objective != "Idle" {
2869        app.current_objective.clone()
2870    } else if !app.active_workers.is_empty() {
2871        "Swarm active".to_string()
2872    } else if app.thinking {
2873        "Reasoning".to_string()
2874    } else if app.agent_running {
2875        "Working".to_string()
2876    } else {
2877        "Idle".to_string()
2878    };
2879
2880    let objective_text = if live_objective.len() > 30 {
2881        format!("{}...", &live_objective[..27])
2882    } else {
2883        live_objective
2884    };
2885
2886    let core_title = if app.professional {
2887        Line::from(vec![
2888            Span::styled(format!(" {} ", core_icon), Style::default().fg(heart_color)),
2889            Span::styled("HEMATITE ", Style::default().add_modifier(Modifier::BOLD)),
2890            Span::styled(
2891                format!(" TASK: {} ", objective_text),
2892                Style::default()
2893                    .fg(Color::Yellow)
2894                    .add_modifier(Modifier::ITALIC),
2895            ),
2896        ])
2897    } else {
2898        Line::from(format!(" TASK: {} ", objective_text))
2899    };
2900
2901    let core_para = Paragraph::new(core_lines.clone())
2902        .block(
2903            Block::default()
2904                .title(core_title)
2905                .borders(Borders::ALL)
2906                .border_style(Style::default().fg(Color::DarkGray)),
2907        )
2908        .wrap(Wrap { trim: true });
2909
2910    // Enhanced Scroll calculation.
2911    let avail_h = top[0].height.saturating_sub(2);
2912    // Borders (2) + Scrollbar (1) + explicit Padding (1) = 4.
2913    let inner_w = top[0].width.saturating_sub(4).max(1);
2914
2915    let mut total_lines: u16 = 0;
2916    for line in &core_lines {
2917        let line_w = line.width() as u16;
2918        if line_w == 0 {
2919            total_lines += 1;
2920        } else {
2921            // TUI SCROLL FIX:
2922            // Exact calculation: how many times does line_w fit into inner_w?
2923            // This matches Paragraph's internal Wrap logic closely.
2924            let wrapped = (line_w + inner_w - 1) / inner_w;
2925            total_lines += wrapped;
2926        }
2927    }
2928
2929    let max_scroll = total_lines.saturating_sub(avail_h);
2930    let scroll = if let Some(off) = app.manual_scroll_offset {
2931        max_scroll.saturating_sub(off)
2932    } else {
2933        max_scroll
2934    };
2935
2936    // Clear the outer chunk and the inner dialogue area to prevent ghosting from previous frames or background renders.
2937    f.render_widget(Clear, top[0]);
2938
2939    // Create a sub-area for the dialogue with horizontal padding.
2940    let chat_area = Rect::new(
2941        top[0].x + 1,
2942        top[0].y,
2943        top[0].width.saturating_sub(2).max(1),
2944        top[0].height,
2945    );
2946    f.render_widget(Clear, chat_area);
2947    f.render_widget(core_para.scroll((scroll, 0)), chat_area);
2948
2949    // Scrollbar: content_length = max_scroll+1 so position==max_scroll puts the
2950    // thumb flush at the bottom (position == content_length - 1).
2951    let mut scrollbar_state =
2952        ScrollbarState::new(max_scroll as usize + 1).position(scroll as usize);
2953    f.render_stateful_widget(
2954        Scrollbar::default()
2955            .orientation(ScrollbarOrientation::VerticalRight)
2956            .begin_symbol(Some("↑"))
2957            .end_symbol(Some("↓")),
2958        top[0],
2959        &mut scrollbar_state,
2960    );
2961
2962    // ── Box 2: Side panel ─────────────────────────────────────────────────────
2963    let side = Layout::default()
2964        .direction(Direction::Vertical)
2965        .constraints([
2966            Constraint::Length(8), // CONTEXT
2967            Constraint::Min(0),    // SPECULAR
2968        ])
2969        .split(top[1]);
2970
2971    // Pane 1: Context (Nervous focus)
2972    let context_source = if app.active_context.is_empty() {
2973        default_active_context()
2974    } else {
2975        app.active_context.clone()
2976    };
2977    let mut context_display = context_source
2978        .iter()
2979        .map(|f| {
2980            let (icon, color) = match f.status.as_str() {
2981                "Running" => ("⚙️", Color::Cyan),
2982                "Dirty" => ("📝", Color::Yellow),
2983                _ => ("📄", Color::Gray),
2984            };
2985            // Simple heuristic for "Tokens" (size / 4)
2986            let tokens = f.size / 4;
2987            ListItem::new(Line::from(vec![
2988                Span::styled(format!(" {} ", icon), Style::default().fg(color)),
2989                Span::styled(f.path.clone(), Style::default().fg(Color::White)),
2990                Span::styled(
2991                    format!(" {}t ", tokens),
2992                    Style::default().fg(Color::DarkGray),
2993                ),
2994            ]))
2995        })
2996        .collect::<Vec<ListItem>>();
2997
2998    if context_display.is_empty() {
2999        context_display = vec![ListItem::new(" (No active files)")];
3000    }
3001
3002    let ctx_block = Block::default()
3003        .title(" ACTIVE CONTEXT ")
3004        .borders(Borders::ALL)
3005        .border_style(Style::default().fg(Color::DarkGray));
3006
3007    f.render_widget(Clear, side[0]);
3008    f.render_widget(List::new(context_display).block(ctx_block), side[0]);
3009
3010    // Optional: Add a Gauge for total context if tokens were tracked accurately.
3011    // For now, let's just make the CONTEXT pane look high-density.
3012
3013    // ── SPECULAR panel (Pane 2) ────────────────────────────────────────────────
3014    let v_title = if app.thinking || app.agent_running {
3015        format!(" SPECULAR [working] ")
3016    } else {
3017        " SPECULAR [Watching] ".to_string()
3018    };
3019
3020    f.render_widget(Clear, side[1]);
3021
3022    let mut v_lines: Vec<Line<'static>> = Vec::new();
3023
3024    // Section: live thought (bounded to last 300 chars to avoid wall-of-text)
3025    if app.thinking || app.agent_running {
3026        let dots = ".".repeat((app.tick_count % 4) as usize + 1);
3027        let label = if app.thinking { "REASONING" } else { "WORKING" };
3028        v_lines.push(Line::from(vec![Span::styled(
3029            format!("[ {}{} ]", label, dots),
3030            Style::default()
3031                .fg(Color::Green)
3032                .add_modifier(Modifier::BOLD),
3033        )]));
3034        // Show last 300 chars of current thought, split by line.
3035        let preview = if app.current_thought.chars().count() > 300 {
3036            app.current_thought
3037                .chars()
3038                .rev()
3039                .take(300)
3040                .collect::<Vec<_>>()
3041                .into_iter()
3042                .rev()
3043                .collect::<String>()
3044        } else {
3045            app.current_thought.clone()
3046        };
3047        for raw in preview.lines() {
3048            let raw = raw.trim();
3049            if !raw.is_empty() {
3050                v_lines.extend(render_markdown_line(raw));
3051            }
3052        }
3053        v_lines.push(Line::raw(""));
3054    }
3055
3056    // Section: worker progress bars
3057    if !app.active_workers.is_empty() {
3058        v_lines.push(Line::from(vec![Span::styled(
3059            "── Task Progress ──",
3060            Style::default()
3061                .fg(Color::White)
3062                .add_modifier(Modifier::DIM),
3063        )]));
3064
3065        let mut sorted_ids: Vec<_> = app.active_workers.keys().cloned().collect();
3066        sorted_ids.sort();
3067
3068        for id in sorted_ids {
3069            let prog = app.active_workers[&id];
3070            let custom_label = app.worker_labels.get(&id).cloned();
3071
3072            let (label, color) = match prog {
3073                101..=102 => ("VERIFIED", Color::Green),
3074                100 if !app.agent_running && id != "AGENT" => ("SKIPPED ", Color::DarkGray),
3075                100 => ("REVIEW  ", Color::Magenta),
3076                _ => ("WORKING ", Color::Yellow),
3077            };
3078
3079            let display_label = custom_label.unwrap_or_else(|| label.to_string());
3080            let filled = (prog.min(100) / 10) as usize;
3081            let bar = "▓".repeat(filled) + &"░".repeat(10 - filled);
3082
3083            let id_prefix = if id == "AGENT" {
3084                "Agent: ".to_string()
3085            } else {
3086                format!("W{}: ", id)
3087            };
3088
3089            v_lines.push(Line::from(vec![
3090                Span::styled(id_prefix, Style::default().fg(Color::Gray)),
3091                Span::styled(bar, Style::default().fg(color)),
3092                Span::styled(
3093                    format!(" {} ", display_label),
3094                    Style::default().fg(color).add_modifier(Modifier::BOLD),
3095                ),
3096                Span::styled(
3097                    format!("{}%", prog.min(100)),
3098                    Style::default().fg(Color::DarkGray),
3099                ),
3100            ]));
3101        }
3102        v_lines.push(Line::raw(""));
3103    }
3104
3105    // Section: last completed turn's reasoning
3106    if !app.last_reasoning.is_empty() {
3107        v_lines.push(Line::from(vec![Span::styled(
3108            "── Logic Trace ──",
3109            Style::default()
3110                .fg(Color::White)
3111                .add_modifier(Modifier::DIM),
3112        )]));
3113        for raw in app.last_reasoning.lines() {
3114            v_lines.extend(render_markdown_line(raw));
3115        }
3116        v_lines.push(Line::raw(""));
3117    }
3118
3119    // Section: specular event log
3120    if !app.specular_logs.is_empty() {
3121        v_lines.push(Line::from(vec![Span::styled(
3122            "── Events ──",
3123            Style::default()
3124                .fg(Color::White)
3125                .add_modifier(Modifier::DIM),
3126        )]));
3127        for log in &app.specular_logs {
3128            let (icon, color) = if log.starts_with("ERROR") {
3129                ("X ", Color::Red)
3130            } else if log.starts_with("INDEX") {
3131                ("I ", Color::Cyan)
3132            } else if log.starts_with("GHOST") {
3133                ("< ", Color::Magenta)
3134            } else {
3135                ("- ", Color::Gray)
3136            };
3137            v_lines.push(Line::from(vec![
3138                Span::styled(icon, Style::default().fg(color)),
3139                Span::styled(
3140                    log.to_string(),
3141                    Style::default()
3142                        .fg(Color::White)
3143                        .add_modifier(Modifier::DIM),
3144                ),
3145            ]));
3146        }
3147    }
3148
3149    let v_total = v_lines.len() as u16;
3150    let v_avail = side[1].height.saturating_sub(2);
3151    let v_max_scroll = v_total.saturating_sub(v_avail);
3152    // If auto-scroll is active, always show the bottom. Otherwise respect the
3153    // user's manual position (clamped so we never scroll past the content end).
3154    let v_scroll = if app.specular_auto_scroll {
3155        v_max_scroll
3156    } else {
3157        app.specular_scroll.min(v_max_scroll)
3158    };
3159
3160    let specular_para = Paragraph::new(v_lines)
3161        .wrap(Wrap { trim: true })
3162        .scroll((v_scroll, 0))
3163        .block(Block::default().title(v_title).borders(Borders::ALL));
3164
3165    f.render_widget(specular_para, side[1]);
3166
3167    // Scrollbar for SPECULAR
3168    let mut v_scrollbar_state =
3169        ScrollbarState::new(v_max_scroll as usize + 1).position(v_scroll as usize);
3170    f.render_stateful_widget(
3171        Scrollbar::default()
3172            .orientation(ScrollbarOrientation::VerticalRight)
3173            .begin_symbol(None)
3174            .end_symbol(None),
3175        side[1],
3176        &mut v_scrollbar_state,
3177    );
3178
3179    // ── Box 3: Status bar ─────────────────────────────────────────────────────
3180    let frame = app.tick_count % 3;
3181    let spark = match frame {
3182        0 => "✧",
3183        1 => "✦",
3184        _ => "✨",
3185    };
3186    let vigil = if app.brief_mode {
3187        "VIGIL:[ON]"
3188    } else {
3189        "VIGIL:[off]"
3190    };
3191    let yolo = if app.yolo_mode {
3192        " | APPROVALS: OFF"
3193    } else {
3194        ""
3195    };
3196
3197    let bar_constraints = if app.professional {
3198        vec![
3199            Constraint::Min(0),     // MODE
3200            Constraint::Length(22), // LM + VN badge
3201            Constraint::Length(12), // BUD
3202            Constraint::Length(12), // CMP
3203            Constraint::Length(16), // REMOTE
3204            Constraint::Length(28), // TOKENS
3205            Constraint::Length(28), // VRAM
3206        ]
3207    } else {
3208        vec![
3209            Constraint::Length(12), // NAME
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    };
3219    let bar_chunks = Layout::default()
3220        .direction(Direction::Horizontal)
3221        .constraints(bar_constraints)
3222        .split(chunks[2]);
3223
3224    let char_count: usize = app.messages_raw.iter().map(|(_, c)| c.len()).sum();
3225    let est_tokens = char_count / 3;
3226    let current_tokens = if app.total_tokens > 0 {
3227        app.total_tokens
3228    } else {
3229        est_tokens
3230    };
3231    let usage_text = format!(
3232        "TOKENS: {:0>5} | TOTAL: ${:.4}",
3233        current_tokens, app.current_session_cost
3234    );
3235    let runtime_age = app.last_runtime_profile_time.elapsed();
3236    let (lm_label, lm_color) = if app.model_id == "no model loaded" {
3237        ("LM:NONE", Color::Red)
3238    } else if app.model_id == "detecting..." || app.context_length == 0 {
3239        ("LM:BOOT", Color::DarkGray)
3240    } else if app.provider_state == ProviderRuntimeState::Recovering {
3241        ("LM:RECV", Color::Cyan)
3242    } else if matches!(
3243        app.provider_state,
3244        ProviderRuntimeState::Degraded | ProviderRuntimeState::EmptyResponse
3245    ) {
3246        ("LM:WARN", Color::Red)
3247    } else if app.provider_state == ProviderRuntimeState::ContextWindow {
3248        ("LM:CEIL", Color::Yellow)
3249    } else if runtime_age > std::time::Duration::from_secs(12) {
3250        ("LM:STALE", Color::Yellow)
3251    } else {
3252        ("LM:LIVE", Color::Green)
3253    };
3254    let compaction_percent = app.compaction_percent.min(100);
3255    let compaction_label = if app.compaction_threshold_tokens == 0 {
3256        " CMP:  0%".to_string()
3257    } else {
3258        format!(" CMP:{:>3}%", compaction_percent)
3259    };
3260    let compaction_color = if app.compaction_threshold_tokens == 0 {
3261        Color::DarkGray
3262    } else if compaction_percent >= 85 {
3263        Color::Red
3264    } else if compaction_percent >= 60 {
3265        Color::Yellow
3266    } else {
3267        Color::Green
3268    };
3269    let prompt_percent = app.prompt_pressure_percent.min(100);
3270    let prompt_label = if app.prompt_estimated_total_tokens == 0 {
3271        " BUD:  0%".to_string()
3272    } else {
3273        format!(" BUD:{:>3}%", prompt_percent)
3274    };
3275    let prompt_color = if app.prompt_estimated_total_tokens == 0 {
3276        Color::DarkGray
3277    } else if prompt_percent >= 85 {
3278        Color::Red
3279    } else if prompt_percent >= 60 {
3280        Color::Yellow
3281    } else {
3282        Color::Green
3283    };
3284
3285    let think_badge = match app.think_mode {
3286        Some(true) => " [THINK]",
3287        Some(false) => " [FAST]",
3288        None => "",
3289    };
3290
3291    let (vein_label, vein_color) = if app.vein_docs_only {
3292        let color = if app.vein_embedded_count > 0 {
3293            Color::Green
3294        } else if app.vein_file_count > 0 {
3295            Color::Yellow
3296        } else {
3297            Color::DarkGray
3298        };
3299        ("VN:DOC", color)
3300    } else if app.vein_file_count == 0 {
3301        ("VN:--", Color::DarkGray)
3302    } else if app.vein_embedded_count > 0 {
3303        ("VN:SEM", Color::Green)
3304    } else {
3305        ("VN:FTS", Color::Yellow)
3306    };
3307
3308    let (status_idx, lm_idx, bud_idx, cmp_idx, remote_idx, tokens_idx, vram_idx) =
3309        if app.professional {
3310            (0usize, 1usize, 2usize, 3usize, 4usize, 5usize, 6usize)
3311        } else {
3312            (1usize, 2usize, 3usize, 4usize, 5usize, 6usize, 7usize)
3313        };
3314
3315    if app.professional {
3316        f.render_widget(Clear, bar_chunks[status_idx]);
3317
3318        let voice_badge = if app.voice_manager.is_enabled() {
3319            " | VOICE:ON"
3320        } else {
3321            ""
3322        };
3323        f.render_widget(
3324            Paragraph::new(format!(
3325                " MODE:PRO | FLOW:{}{} | CTX:{} | ERR:{}{}{}",
3326                app.workflow_mode,
3327                yolo,
3328                app.context_length,
3329                app.stats.debugging,
3330                think_badge,
3331                voice_badge
3332            ))
3333            .block(Block::default().borders(Borders::ALL)),
3334            bar_chunks[status_idx],
3335        );
3336    } else {
3337        f.render_widget(Clear, bar_chunks[0]);
3338        f.render_widget(
3339            Paragraph::new(format!(" {} {}", spark, app.soul_name))
3340                .block(Block::default().borders(Borders::ALL)),
3341            bar_chunks[0],
3342        );
3343        f.render_widget(Clear, bar_chunks[status_idx]);
3344        f.render_widget(
3345            Paragraph::new(format!("{}{}", vigil, think_badge))
3346                .block(Block::default().borders(Borders::ALL).fg(Color::Yellow)),
3347            bar_chunks[status_idx],
3348        );
3349    }
3350
3351    // ── Remote status indicator ──────────────────────────────────────────────
3352    let git_status = app.git_state.status();
3353    let git_label = app.git_state.label();
3354    let git_color = match git_status {
3355        crate::agent::git_monitor::GitRemoteStatus::Connected => Color::Green,
3356        crate::agent::git_monitor::GitRemoteStatus::NoRemote => Color::Yellow,
3357        crate::agent::git_monitor::GitRemoteStatus::Behind
3358        | crate::agent::git_monitor::GitRemoteStatus::Ahead => Color::Magenta,
3359        crate::agent::git_monitor::GitRemoteStatus::Diverged
3360        | crate::agent::git_monitor::GitRemoteStatus::Error => Color::Red,
3361        _ => Color::DarkGray,
3362    };
3363
3364    f.render_widget(Clear, bar_chunks[lm_idx]);
3365    f.render_widget(
3366        Paragraph::new(ratatui::text::Line::from(vec![
3367            ratatui::text::Span::styled(format!(" {}", lm_label), Style::default().fg(lm_color)),
3368            ratatui::text::Span::raw(" | "),
3369            ratatui::text::Span::styled(vein_label, Style::default().fg(vein_color)),
3370        ]))
3371        .block(
3372            Block::default()
3373                .borders(Borders::ALL)
3374                .border_style(Style::default().fg(lm_color)),
3375        ),
3376        bar_chunks[lm_idx],
3377    );
3378
3379    f.render_widget(Clear, bar_chunks[bud_idx]);
3380    f.render_widget(
3381        Paragraph::new(prompt_label)
3382            .block(
3383                Block::default()
3384                    .borders(Borders::ALL)
3385                    .border_style(Style::default().fg(prompt_color)),
3386            )
3387            .fg(prompt_color),
3388        bar_chunks[bud_idx],
3389    );
3390
3391    f.render_widget(Clear, bar_chunks[cmp_idx]);
3392    f.render_widget(
3393        Paragraph::new(compaction_label)
3394            .block(
3395                Block::default()
3396                    .borders(Borders::ALL)
3397                    .border_style(Style::default().fg(compaction_color)),
3398            )
3399            .fg(compaction_color),
3400        bar_chunks[cmp_idx],
3401    );
3402
3403    f.render_widget(Clear, bar_chunks[remote_idx]);
3404    f.render_widget(
3405        Paragraph::new(format!(" REMOTE: {}", git_label))
3406            .block(
3407                Block::default()
3408                    .borders(Borders::ALL)
3409                    .border_style(Style::default().fg(git_color)),
3410            )
3411            .fg(git_color),
3412        bar_chunks[remote_idx],
3413    );
3414
3415    let usage_color = Color::Rgb(215, 125, 40);
3416    f.render_widget(Clear, bar_chunks[tokens_idx]);
3417    f.render_widget(
3418        Paragraph::new(usage_text)
3419            .block(Block::default().borders(Borders::ALL).fg(usage_color))
3420            .fg(usage_color),
3421        bar_chunks[tokens_idx],
3422    );
3423
3424    // ── VRAM gauge (live from nvidia-smi poller) ─────────────────────────────
3425    let vram_ratio = app.gpu_state.ratio();
3426    let vram_label = app.gpu_state.label();
3427    let gpu_name = app.gpu_state.gpu_name();
3428
3429    let gauge_color = if vram_ratio > 0.85 {
3430        Color::Red
3431    } else if vram_ratio > 0.60 {
3432        Color::Yellow
3433    } else {
3434        Color::Cyan
3435    };
3436    f.render_widget(Clear, bar_chunks[vram_idx]);
3437    f.render_widget(
3438        Gauge::default()
3439            .block(
3440                Block::default()
3441                    .borders(Borders::ALL)
3442                    .title(format!(" {} ", gpu_name)),
3443            )
3444            .gauge_style(Style::default().fg(gauge_color))
3445            .ratio(vram_ratio)
3446            .label(format!("  {}  ", vram_label)), // Added extra padding for visual excellence
3447        bar_chunks[vram_idx],
3448    );
3449
3450    // ── Box 4: Input ──────────────────────────────────────────────────────────
3451    let input_style = if app.agent_running {
3452        Style::default().fg(Color::DarkGray)
3453    } else {
3454        Style::default().fg(Color::Rgb(120, 70, 50))
3455    };
3456    let input_rect = chunks[1];
3457    let title_area = input_title_area(input_rect);
3458    let input_hint = render_input_title(app, title_area);
3459    let input_block = Block::default()
3460        .title(input_hint)
3461        .borders(Borders::ALL)
3462        .border_style(input_style)
3463        .style(Style::default().bg(Color::Rgb(40, 25, 15))); // Deeper soil rich background
3464
3465    let inner_area = input_block.inner(input_rect);
3466    f.render_widget(Clear, input_rect);
3467    f.render_widget(input_block, input_rect);
3468
3469    f.render_widget(
3470        Paragraph::new(app.input.as_str()).wrap(Wrap { trim: true }),
3471        inner_area,
3472    );
3473
3474    // Hardware Cursor (Managed by terminal emulator for smooth asynchronous blink)
3475    // Hardware Cursor (Managed by terminal emulator for smooth asynchronous blink)
3476    // Always call set_cursor during standard operation to "park" the cursor safely in the input box,
3477    // preventing it from jittering to (0,0) (the top-left title) during modal reviews.
3478    if !app.agent_running && inner_area.height > 0 {
3479        let text_w = app.input.len() as u16;
3480        let max_w = inner_area.width.saturating_sub(1);
3481        let cursor_x = inner_area.x + text_w.min(max_w);
3482        f.set_cursor(cursor_x, inner_area.y);
3483    }
3484
3485    // ── High-risk approval modal ───────────────────────────────────────────────
3486    if let Some(approval) = &app.awaiting_approval {
3487        let is_diff_preview = approval.diff.is_some();
3488
3489        // Taller modal for diff preview so more lines are visible.
3490        let modal_h = if is_diff_preview { 70 } else { 50 };
3491        let area = centered_rect(80, modal_h, f.size());
3492        f.render_widget(Clear, area);
3493
3494        let chunks = Layout::default()
3495            .direction(Direction::Vertical)
3496            .constraints([
3497                Constraint::Length(4), // Header: Title + Instructions
3498                Constraint::Min(0),    // Body: Tool + diff/command
3499            ])
3500            .split(area);
3501
3502        // ── Modal Header ─────────────────────────────────────────────────────
3503        let (title_str, title_color) = if is_diff_preview {
3504            (" DIFF PREVIEW — REVIEW BEFORE APPLYING ", Color::Yellow)
3505        } else {
3506            (" HIGH-RISK OPERATION REQUESTED ", Color::Red)
3507        };
3508        let header_text = vec![
3509            Line::from(Span::styled(
3510                title_str,
3511                Style::default()
3512                    .fg(title_color)
3513                    .add_modifier(Modifier::BOLD),
3514            )),
3515            Line::from(Span::styled(
3516                if is_diff_preview {
3517                    "  [↑↓/jk/PgUp/PgDn] Scroll   [Y] Apply   [N] Skip "
3518                } else {
3519                    "  [Y] Approve     [N] Decline "
3520                },
3521                Style::default()
3522                    .fg(Color::Green)
3523                    .add_modifier(Modifier::BOLD),
3524            )),
3525        ];
3526        f.render_widget(
3527            Paragraph::new(header_text)
3528                .block(
3529                    Block::default()
3530                        .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
3531                        .border_style(Style::default().fg(title_color)),
3532                )
3533                .alignment(ratatui::layout::Alignment::Center),
3534            chunks[0],
3535        );
3536
3537        // ── Modal Body ───────────────────────────────────────────────────────
3538        let border_color = if is_diff_preview {
3539            Color::Yellow
3540        } else {
3541            Color::Red
3542        };
3543        if let Some(diff_text) = &approval.diff {
3544            // Render colored diff lines
3545            let added = diff_text.lines().filter(|l| l.starts_with("+ ")).count();
3546            let removed = diff_text.lines().filter(|l| l.starts_with("- ")).count();
3547            let mut body_lines: Vec<Line> = vec![
3548                Line::from(Span::styled(
3549                    format!(" {}", approval.display),
3550                    Style::default().fg(Color::Cyan),
3551                )),
3552                Line::from(vec![
3553                    Span::styled(
3554                        format!(" +{}", added),
3555                        Style::default()
3556                            .fg(Color::Green)
3557                            .add_modifier(Modifier::BOLD),
3558                    ),
3559                    Span::styled(
3560                        format!(" -{}", removed),
3561                        Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
3562                    ),
3563                ]),
3564                Line::from(Span::raw("")),
3565            ];
3566            for raw_line in diff_text.lines() {
3567                let styled = if raw_line.starts_with("+ ") {
3568                    Line::from(Span::styled(
3569                        format!(" {}", raw_line),
3570                        Style::default().fg(Color::Green),
3571                    ))
3572                } else if raw_line.starts_with("- ") {
3573                    Line::from(Span::styled(
3574                        format!(" {}", raw_line),
3575                        Style::default().fg(Color::Red),
3576                    ))
3577                } else if raw_line.starts_with("---") || raw_line.starts_with("@@ ") {
3578                    Line::from(Span::styled(
3579                        format!(" {}", raw_line),
3580                        Style::default()
3581                            .fg(Color::DarkGray)
3582                            .add_modifier(Modifier::BOLD),
3583                    ))
3584                } else {
3585                    Line::from(Span::raw(format!(" {}", raw_line)))
3586                };
3587                body_lines.push(styled);
3588            }
3589            f.render_widget(
3590                Paragraph::new(body_lines)
3591                    .block(
3592                        Block::default()
3593                            .borders(Borders::BOTTOM | Borders::LEFT | Borders::RIGHT)
3594                            .border_style(Style::default().fg(border_color)),
3595                    )
3596                    .scroll((approval.diff_scroll, 0)),
3597                chunks[1],
3598            );
3599        } else {
3600            let body_text = vec![
3601                Line::from(Span::raw(format!(" Tool: {}", approval.tool_name))),
3602                Line::from(Span::styled(
3603                    format!(" ❯ {}", approval.display),
3604                    Style::default().fg(Color::Cyan),
3605                )),
3606            ];
3607            f.render_widget(
3608                Paragraph::new(body_text)
3609                    .block(
3610                        Block::default()
3611                            .borders(Borders::BOTTOM | Borders::LEFT | Borders::RIGHT)
3612                            .border_style(Style::default().fg(border_color)),
3613                    )
3614                    .wrap(Wrap { trim: true }),
3615                chunks[1],
3616            );
3617        }
3618    }
3619
3620    // ── Swarm diff review modal ────────────────────────────────────────────────
3621    if let Some(review) = &app.active_review {
3622        draw_diff_review(f, review);
3623    }
3624
3625    // ── Autocomplete Hatch (Floating Popup) ──────────────────────────────────
3626    if app.show_autocomplete && !app.autocomplete_suggestions.is_empty() {
3627        let area = Rect {
3628            x: chunks[1].x + 2,
3629            y: chunks[1]
3630                .y
3631                .saturating_sub(app.autocomplete_suggestions.len() as u16 + 2),
3632            width: chunks[1].width.saturating_sub(4),
3633            height: app.autocomplete_suggestions.len() as u16 + 2,
3634        };
3635        f.render_widget(Clear, area);
3636
3637        let items: Vec<ListItem> = app
3638            .autocomplete_suggestions
3639            .iter()
3640            .enumerate()
3641            .map(|(i, s)| {
3642                let style = if i == app.selected_suggestion {
3643                    Style::default()
3644                        .fg(Color::Black)
3645                        .bg(Color::Cyan)
3646                        .add_modifier(Modifier::BOLD)
3647                } else {
3648                    Style::default().fg(Color::Gray)
3649                };
3650                ListItem::new(format!(" 📄 {}", s)).style(style)
3651            })
3652            .collect();
3653
3654        let hatch = List::new(items).block(
3655            Block::default()
3656                .borders(Borders::ALL)
3657                .border_style(Style::default().fg(Color::Cyan))
3658                .title(format!(
3659                    " @ RESOLVER (Matching: {}) ",
3660                    app.autocomplete_filter
3661                )),
3662        );
3663        f.render_widget(hatch, area);
3664
3665        // Optional "More matches..." indicator
3666        if app.autocomplete_suggestions.len() >= 15 {
3667            let more_area = Rect {
3668                x: area.x + 2,
3669                y: area.y + area.height - 1,
3670                width: 20,
3671                height: 1,
3672            };
3673            f.render_widget(
3674                Paragraph::new("... (type to narrow) ").style(Style::default().fg(Color::DarkGray)),
3675                more_area,
3676            );
3677        }
3678    }
3679}
3680
3681// ── Helpers ───────────────────────────────────────────────────────────────────
3682
3683fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
3684    let vert = Layout::default()
3685        .direction(Direction::Vertical)
3686        .constraints([
3687            Constraint::Percentage((100 - percent_y) / 2),
3688            Constraint::Percentage(percent_y),
3689            Constraint::Percentage((100 - percent_y) / 2),
3690        ])
3691        .split(r);
3692    Layout::default()
3693        .direction(Direction::Horizontal)
3694        .constraints([
3695            Constraint::Percentage((100 - percent_x) / 2),
3696            Constraint::Percentage(percent_x),
3697            Constraint::Percentage((100 - percent_x) / 2),
3698        ])
3699        .split(vert[1])[1]
3700}
3701
3702fn strip_ghost_prefix(s: &str) -> &str {
3703    for prefix in &[
3704        "Hematite: ",
3705        "HEMATITE: ",
3706        "Assistant: ",
3707        "assistant: ",
3708        "Okay, ",
3709        "Hmm, ",
3710        "Wait, ",
3711        "Alright, ",
3712        "Got it, ",
3713        "Certainly, ",
3714        "Sure, ",
3715        "Understood, ",
3716    ] {
3717        if s.to_lowercase().starts_with(&prefix.to_lowercase()) {
3718            return &s[prefix.len()..];
3719        }
3720    }
3721    s
3722}
3723
3724fn first_n_chars(s: &str, n: usize) -> String {
3725    let mut result = String::new();
3726    let mut count = 0;
3727    for c in s.chars() {
3728        if count >= n {
3729            result.push('…');
3730            break;
3731        }
3732        if c == '\n' || c == '\r' {
3733            result.push(' ');
3734        } else if !c.is_control() {
3735            result.push(c);
3736        }
3737        count += 1;
3738    }
3739    result
3740}
3741
3742fn trim_vec_context(v: &mut Vec<ContextFile>, max: usize) {
3743    while v.len() > max {
3744        v.remove(0);
3745    }
3746}
3747
3748fn trim_vec(v: &mut Vec<String>, max: usize) {
3749    while v.len() > max {
3750        v.remove(0);
3751    }
3752}
3753
3754/// Minimal markdown → ratatui spans for the SPECULAR panel.
3755/// Handles: # headers, **bold**, `code`, - bullet, > blockquote, plain text.
3756fn render_markdown_line(raw: &str) -> Vec<Line<'static>> {
3757    // 1. Strip ANSI and control noise first to verify content.
3758    let cleaned_ansi = strip_ansi(raw);
3759    let trimmed = cleaned_ansi.trim();
3760    if trimmed.is_empty() {
3761        return vec![Line::raw("")];
3762    }
3763
3764    // 2. Strip thought tags.
3765    let cleaned_owned = trimmed
3766        .replace("<thought>", "")
3767        .replace("</thought>", "")
3768        .replace("<think>", "")
3769        .replace("</think>", "");
3770    let trimmed = cleaned_owned.trim();
3771    if trimmed.is_empty() {
3772        return vec![];
3773    }
3774
3775    // # Heading (all levels → bold white)
3776    for (prefix, indent) in &[("### ", "  "), ("## ", " "), ("# ", "")] {
3777        if let Some(rest) = trimmed.strip_prefix(prefix) {
3778            return vec![Line::from(vec![Span::styled(
3779                format!("{}{}", indent, rest),
3780                Style::default()
3781                    .fg(Color::White)
3782                    .add_modifier(Modifier::BOLD),
3783            )])];
3784        }
3785    }
3786
3787    // > blockquote
3788    if let Some(rest) = trimmed
3789        .strip_prefix("> ")
3790        .or_else(|| trimmed.strip_prefix(">"))
3791    {
3792        return vec![Line::from(vec![
3793            Span::styled("| ", Style::default().fg(Color::DarkGray)),
3794            Span::styled(
3795                rest.to_string(),
3796                Style::default()
3797                    .fg(Color::White)
3798                    .add_modifier(Modifier::DIM),
3799            ),
3800        ])];
3801    }
3802
3803    // - / * bullet
3804    if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
3805        let rest = &trimmed[2..];
3806        let mut spans = vec![Span::styled("* ", Style::default().fg(Color::Gray))];
3807        spans.extend(inline_markdown(rest));
3808        return vec![Line::from(spans)];
3809    }
3810
3811    // Plain line with possible inline markdown
3812    let spans = inline_markdown(trimmed);
3813    vec![Line::from(spans)]
3814}
3815
3816/// Inline markdown for The Core chat window (brighter palette than SPECULAR).
3817fn inline_markdown_core(text: &str) -> Vec<Span<'static>> {
3818    let mut spans = Vec::new();
3819    let mut remaining = text;
3820
3821    while !remaining.is_empty() {
3822        if let Some(start) = remaining.find("**") {
3823            let before = &remaining[..start];
3824            if !before.is_empty() {
3825                spans.push(Span::raw(before.to_string()));
3826            }
3827            let after_open = &remaining[start + 2..];
3828            if let Some(end) = after_open.find("**") {
3829                spans.push(Span::styled(
3830                    after_open[..end].to_string(),
3831                    Style::default()
3832                        .fg(Color::White)
3833                        .add_modifier(Modifier::BOLD),
3834                ));
3835                remaining = &after_open[end + 2..];
3836                continue;
3837            }
3838        }
3839        if let Some(start) = remaining.find('`') {
3840            let before = &remaining[..start];
3841            if !before.is_empty() {
3842                spans.push(Span::raw(before.to_string()));
3843            }
3844            let after_open = &remaining[start + 1..];
3845            if let Some(end) = after_open.find('`') {
3846                spans.push(Span::styled(
3847                    after_open[..end].to_string(),
3848                    Style::default().fg(Color::Yellow),
3849                ));
3850                remaining = &after_open[end + 1..];
3851                continue;
3852            }
3853        }
3854        spans.push(Span::raw(remaining.to_string()));
3855        break;
3856    }
3857    spans
3858}
3859
3860/// Parse inline `**bold**` and `` `code` `` — shared by SPECULAR and Core renderers.
3861fn inline_markdown(text: &str) -> Vec<Span<'static>> {
3862    let mut spans = Vec::new();
3863    let mut remaining = text;
3864
3865    while !remaining.is_empty() {
3866        if let Some(start) = remaining.find("**") {
3867            let before = &remaining[..start];
3868            if !before.is_empty() {
3869                spans.push(Span::raw(before.to_string()));
3870            }
3871            let after_open = &remaining[start + 2..];
3872            if let Some(end) = after_open.find("**") {
3873                spans.push(Span::styled(
3874                    after_open[..end].to_string(),
3875                    Style::default()
3876                        .fg(Color::White)
3877                        .add_modifier(Modifier::BOLD),
3878                ));
3879                remaining = &after_open[end + 2..];
3880                continue;
3881            }
3882        }
3883        if let Some(start) = remaining.find('`') {
3884            let before = &remaining[..start];
3885            if !before.is_empty() {
3886                spans.push(Span::raw(before.to_string()));
3887            }
3888            let after_open = &remaining[start + 1..];
3889            if let Some(end) = after_open.find('`') {
3890                spans.push(Span::styled(
3891                    after_open[..end].to_string(),
3892                    Style::default().fg(Color::Yellow),
3893                ));
3894                remaining = &after_open[end + 1..];
3895                continue;
3896            }
3897        }
3898        spans.push(Span::raw(remaining.to_string()));
3899        break;
3900    }
3901    spans
3902}
3903
3904// ── Splash Screen ─────────────────────────────────────────────────────────────
3905
3906fn draw_splash<B: Backend>(terminal: &mut Terminal<B>) -> Result<(), Box<dyn std::error::Error>> {
3907    let rust_color = Color::Rgb(180, 90, 50);
3908
3909    let logo_lines = vec![
3910        "██╗  ██╗███████╗███╗   ███╗ █████╗ ████████╗██╗████████╗███████╗",
3911        "██║  ██║██╔════╝████╗ ████║██╔══██╗╚══██╔══╝██║╚══██╔══╝██╔════╝",
3912        "███████║█████╗  ██╔████╔██║███████║   ██║   ██║   ██║   █████╗  ",
3913        "██╔══██║██╔══╝  ██║╚██╔╝██║██╔══██║   ██║   ██║   ██║   ██╔══╝  ",
3914        "██║  ██║███████╗██║ ╚═╝ ██║██║  ██║   ██║   ██║   ██║   ███████╗",
3915        "╚═╝  ╚═╝╚══════╝╚═╝     ╚═╝╚═╝  ╚═╝   ╚═╝   ╚═╝   ╚═╝   ╚══════╝",
3916    ];
3917
3918    let version = env!("CARGO_PKG_VERSION");
3919
3920    terminal.draw(|f| {
3921        let area = f.size();
3922
3923        // Clear with a dark background
3924        f.render_widget(
3925            Block::default().style(Style::default().bg(Color::Black)),
3926            area,
3927        );
3928
3929        // Total content height: logo(6) + spacer(1) + version(1) + tagline(1) + author(1) + spacer(2) + prompt(1) = 13
3930        let content_height: u16 = 13;
3931        let top_pad = area.height.saturating_sub(content_height) / 2;
3932
3933        let mut lines: Vec<Line<'static>> = Vec::new();
3934
3935        // Top padding
3936        for _ in 0..top_pad {
3937            lines.push(Line::raw(""));
3938        }
3939
3940        // Logo lines — centered horizontally
3941        for logo_line in &logo_lines {
3942            lines.push(Line::from(Span::styled(
3943                logo_line.to_string(),
3944                Style::default().fg(rust_color).add_modifier(Modifier::BOLD),
3945            )));
3946        }
3947
3948        // Spacer
3949        lines.push(Line::raw(""));
3950
3951        // Version
3952        lines.push(Line::from(vec![Span::styled(
3953            format!("v{}", version),
3954            Style::default().fg(Color::DarkGray),
3955        )]));
3956
3957        // Tagline
3958        lines.push(Line::from(vec![Span::styled(
3959            "Local AI coding harness",
3960            Style::default()
3961                .fg(Color::DarkGray)
3962                .add_modifier(Modifier::DIM),
3963        )]));
3964
3965        // Developer credit
3966        lines.push(Line::from(vec![Span::styled(
3967            "Developed by Ocean Bennett",
3968            Style::default().fg(Color::Gray).add_modifier(Modifier::DIM),
3969        )]));
3970
3971        // Spacer
3972        lines.push(Line::raw(""));
3973        lines.push(Line::raw(""));
3974
3975        // Prompt
3976        lines.push(Line::from(vec![
3977            Span::styled("[ ", Style::default().fg(rust_color)),
3978            Span::styled(
3979                "Press ENTER to start",
3980                Style::default()
3981                    .fg(Color::White)
3982                    .add_modifier(Modifier::BOLD),
3983            ),
3984            Span::styled(" ]", Style::default().fg(rust_color)),
3985        ]));
3986
3987        let splash = Paragraph::new(lines).alignment(ratatui::layout::Alignment::Center);
3988
3989        f.render_widget(splash, area);
3990    })?;
3991
3992    Ok(())
3993}
3994
3995fn normalize_id(id: &str) -> String {
3996    id.trim().to_uppercase()
3997}
3998
3999fn filter_tui_noise(text: &str) -> String {
4000    // 1. First Pass: Strip ANSI escape codes that cause "shattering" in layout.
4001    let cleaned = strip_ansi(text);
4002
4003    // 2. Second Pass: Filter heuristic noise.
4004    let mut lines = Vec::new();
4005    for line in cleaned.lines() {
4006        // Strip multi-line "LF replaced by CRLF" noise frequently emitted by git/shell on Windows.
4007        if CRLF_REGEX.is_match(line) {
4008            continue;
4009        }
4010        // Strip git checkout/file update noise if it's too repetitive.
4011        if line.contains("Updating files:") && line.contains("%") {
4012            continue;
4013        }
4014        // Strip random terminal control characters that might have escaped.
4015        let sanitized: String = line
4016            .chars()
4017            .filter(|c| !c.is_control() || *c == '\t')
4018            .collect();
4019        if sanitized.trim().is_empty() && !line.trim().is_empty() {
4020            continue;
4021        }
4022
4023        lines.push(normalize_tui_text(&sanitized));
4024    }
4025    lines.join("\n").trim().to_string()
4026}
4027
4028fn normalize_tui_text(text: &str) -> String {
4029    let mut normalized = text
4030        .replace("ΓÇö", "-")
4031        .replace("ΓÇô", "-")
4032        .replace("…", "...")
4033        .replace("✅", "[OK]")
4034        .replace("🛠️", "")
4035        .replace("—", "-")
4036        .replace("–", "-")
4037        .replace("…", "...")
4038        .replace("•", "*")
4039        .replace("✅", "[OK]")
4040        .replace("🚨", "[!]");
4041
4042    normalized = normalized
4043        .chars()
4044        .map(|c| match c {
4045            '\u{00A0}' => ' ',
4046            '\u{2018}' | '\u{2019}' => '\'',
4047            '\u{201C}' | '\u{201D}' => '"',
4048            c if c.is_ascii() || c == '\n' || c == '\t' => c,
4049            _ => ' ',
4050        })
4051        .collect();
4052
4053    let mut compacted = String::with_capacity(normalized.len());
4054    let mut prev_space = false;
4055    for ch in normalized.chars() {
4056        if ch == ' ' {
4057            if !prev_space {
4058                compacted.push(ch);
4059            }
4060            prev_space = true;
4061        } else {
4062            compacted.push(ch);
4063            prev_space = false;
4064        }
4065    }
4066
4067    compacted.trim().to_string()
4068}