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