Skip to main content

hematite/ui/
tui.rs

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