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::truncation::safe_head;
7use crate::agent::utils::{strip_ansi, CRLF_REGEX};
8use crate::ui::gpu_monitor::GpuState;
9use crossterm::event::{self, Event, EventStream, KeyCode};
10use futures::StreamExt;
11use ratatui::{
12    backend::Backend,
13    layout::{Alignment, Constraint, Direction, Layout, Rect},
14    style::{Color, Modifier, Style},
15    text::{Line, Span},
16    widgets::{
17        Block, Borders, Clear, List, ListItem, Paragraph, Scrollbar, ScrollbarOrientation,
18        ScrollbarState, Wrap,
19    },
20    Terminal,
21};
22use std::fmt::Write as _;
23use std::sync::{Arc, Mutex};
24use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
25use tokio::sync::mpsc::Receiver;
26use walkdir::WalkDir;
27
28fn provider_badge_prefix(provider_name: &str) -> &'static str {
29    match provider_name {
30        "LM Studio" => "LM",
31        "Ollama" => "OL",
32        _ => "AI",
33    }
34}
35
36fn provider_state_label(state: ProviderRuntimeState) -> &'static str {
37    match state {
38        ProviderRuntimeState::Booting => "booting",
39        ProviderRuntimeState::Live => "live",
40        ProviderRuntimeState::Degraded => "degraded",
41        ProviderRuntimeState::Recovering => "recovering",
42        ProviderRuntimeState::EmptyResponse => "empty_response",
43        ProviderRuntimeState::ContextWindow => "context_window",
44    }
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48enum RuntimeIssueKind {
49    Healthy,
50    Booting,
51    Recovering,
52    NoModel,
53    Connectivity,
54    EmptyResponse,
55    ContextCeiling,
56}
57
58fn classify_runtime_issue(
59    provider_state: ProviderRuntimeState,
60    model_id: &str,
61    context_length: usize,
62    provider_summary: &str,
63) -> RuntimeIssueKind {
64    if provider_state == ProviderRuntimeState::ContextWindow {
65        return RuntimeIssueKind::ContextCeiling;
66    }
67    if model_id.trim() == "no model loaded" {
68        return RuntimeIssueKind::NoModel;
69    }
70    if provider_state == ProviderRuntimeState::EmptyResponse {
71        return RuntimeIssueKind::EmptyResponse;
72    }
73    if provider_state == ProviderRuntimeState::Recovering {
74        return RuntimeIssueKind::Recovering;
75    }
76    if provider_state == ProviderRuntimeState::Booting
77        || model_id.trim().is_empty()
78        || model_id.trim() == "detecting..."
79        || context_length == 0
80    {
81        return RuntimeIssueKind::Booting;
82    }
83    if provider_state == ProviderRuntimeState::Degraded {
84        let lower = provider_summary.to_ascii_lowercase();
85        if lower.contains("empty reply") || lower.contains("empty response") {
86            return RuntimeIssueKind::EmptyResponse;
87        }
88        if lower.contains("context ceiling") || lower.contains("context window") {
89            return RuntimeIssueKind::ContextCeiling;
90        }
91        return RuntimeIssueKind::Connectivity;
92    }
93    RuntimeIssueKind::Healthy
94}
95
96fn runtime_issue_kind(app: &App) -> RuntimeIssueKind {
97    classify_runtime_issue(
98        app.provider_state,
99        &app.model_id,
100        app.context_length,
101        &app.last_provider_summary,
102    )
103}
104
105fn runtime_issue_label(issue: RuntimeIssueKind) -> &'static str {
106    match issue {
107        RuntimeIssueKind::Healthy => "healthy",
108        RuntimeIssueKind::Booting => "booting",
109        RuntimeIssueKind::Recovering => "recovering",
110        RuntimeIssueKind::NoModel => "no_model",
111        RuntimeIssueKind::Connectivity => "connectivity",
112        RuntimeIssueKind::EmptyResponse => "empty_response",
113        RuntimeIssueKind::ContextCeiling => "context_ceiling",
114    }
115}
116
117fn runtime_issue_badge(issue: RuntimeIssueKind) -> (&'static str, Color) {
118    match issue {
119        RuntimeIssueKind::Healthy => ("OK", Color::Green),
120        RuntimeIssueKind::Booting => ("WAIT", Color::DarkGray),
121        RuntimeIssueKind::Recovering => ("RECV", Color::Cyan),
122        RuntimeIssueKind::NoModel => ("MOD", Color::Red),
123        RuntimeIssueKind::Connectivity => ("NET", Color::Red),
124        RuntimeIssueKind::EmptyResponse => ("EMP", Color::Red),
125        RuntimeIssueKind::ContextCeiling => ("CTX", Color::Yellow),
126    }
127}
128
129fn mcp_state_label(state: McpRuntimeState) -> &'static str {
130    match state {
131        McpRuntimeState::Unconfigured => "unconfigured",
132        McpRuntimeState::Healthy => "healthy",
133        McpRuntimeState::Degraded => "degraded",
134        McpRuntimeState::Failed => "failed",
135    }
136}
137
138fn runtime_configured_endpoint() -> String {
139    let config = crate::agent::config::load_config();
140    config
141        .api_url
142        .clone()
143        .unwrap_or_else(|| crate::agent::config::DEFAULT_LM_STUDIO_API_URL.to_string())
144}
145
146fn runtime_session_provider(app: &App) -> String {
147    if app.provider_name.trim().is_empty() {
148        "detecting".to_string()
149    } else {
150        app.provider_name.clone()
151    }
152}
153
154fn runtime_session_endpoint(app: &App, configured_endpoint: &str) -> String {
155    if app.provider_endpoint.trim().is_empty() {
156        configured_endpoint.to_string()
157    } else {
158        app.provider_endpoint.clone()
159    }
160}
161
162async fn format_provider_summary(app: &App) -> String {
163    let config = crate::agent::config::load_config();
164    let active_provider = runtime_session_provider(app);
165    let active_endpoint = runtime_session_endpoint(
166        app,
167        &config.api_url.clone().unwrap_or_else(|| {
168            crate::agent::config::default_api_url_for_provider(&active_provider).to_string()
169        }),
170    );
171    let saved = config
172        .api_url
173        .as_ref()
174        .map(|url| {
175            format!(
176                "{} ({})",
177                crate::agent::config::provider_label_for_api_url(url),
178                url
179            )
180        })
181        .unwrap_or_else(|| {
182            format!(
183                "default LM Studio ({})",
184                crate::agent::config::DEFAULT_LM_STUDIO_API_URL
185            )
186        });
187    let alternative = crate::runtime::detect_alternative_provider(&active_provider)
188        .await
189        .map(|(name, url)| format!("Reachable alternative: {} ({})", name, url))
190        .unwrap_or_else(|| "Reachable alternative: none detected".to_string());
191    format!(
192        "Active provider: {} | Session endpoint: {}\nSaved preference: {}\n{}\n\nUse /provider lmstudio, /provider ollama, /provider clear, or /provider <url>.\nProvider changes apply to new sessions; restart Hematite to switch this one.",
193        active_provider, active_endpoint, saved, alternative
194    )
195}
196
197fn runtime_fix_path(app: &App) -> String {
198    let session_provider = runtime_session_provider(app);
199    match runtime_issue_kind(app) {
200        RuntimeIssueKind::NoModel => {
201            if session_provider == "Ollama" {
202                format!(
203                    "Shortest fix: pull or run a chat model in Ollama, then keep `api_url` on `{}`. Hematite cannot safely auto-load that model for you here.",
204                    crate::agent::config::DEFAULT_OLLAMA_API_URL
205                )
206            } else {
207                format!(
208                    "Shortest fix: load a coding model in LM Studio and keep the local server on `{}`. Hematite cannot safely auto-load that model for you here.",
209                    crate::agent::config::DEFAULT_LM_STUDIO_API_URL
210                )
211            }
212        }
213        RuntimeIssueKind::ContextCeiling => {
214            format!(
215                "Shortest fix: narrow the request, let Hematite compact if needed, and run `/runtime fix` to refresh and re-check the active provider (`{}`).",
216                session_provider
217            )
218        }
219        RuntimeIssueKind::Connectivity | RuntimeIssueKind::Recovering => {
220            format!(
221                "Shortest fix: run `/runtime fix` to refresh and re-check the active provider (`{}`). If needed after that, use `/runtime provider <name>` and restart Hematite.",
222                session_provider
223            )
224        }
225        RuntimeIssueKind::EmptyResponse => {
226            "Shortest fix: run `/runtime fix` to refresh the active runtime, then retry once with a narrower grounded request if the provider keeps answering empty.".to_string()
227        }
228        RuntimeIssueKind::Booting => {
229            format!(
230                "Shortest fix: wait for the active provider (`{}`) to stabilize, then run `/runtime fix` or `/runtime refresh` if detection stays stale.",
231                session_provider
232            )
233        }
234        RuntimeIssueKind::Healthy => {
235            if app.embed_model_id.is_none() {
236                "Shortest fix: optional only — load a preferred embedding model if you want semantic file search."
237                    .to_string()
238            } else {
239                "Shortest fix: none — runtime is healthy.".to_string()
240            }
241        }
242    }
243}
244
245async fn format_runtime_summary(app: &App) -> String {
246    let config = crate::agent::config::load_config();
247    let configured_endpoint = runtime_configured_endpoint();
248    let configured_provider =
249        crate::agent::config::provider_label_for_api_url(&configured_endpoint);
250    let session_provider = runtime_session_provider(app);
251    let session_endpoint = runtime_session_endpoint(app, &configured_endpoint);
252    let issue = runtime_issue_kind(app);
253    let coding_model = if app.model_id.trim().is_empty() {
254        "detecting...".to_string()
255    } else {
256        app.model_id.clone()
257    };
258    let embed_status = match app.embed_model_id.as_deref() {
259        Some(id) => format!("loaded ({})", id),
260        None => "not loaded".to_string(),
261    };
262    let semantic_status = if app.embed_model_id.is_some() || app.vein_embedded_count > 0 {
263        "ready"
264    } else {
265        "inactive"
266    };
267    let preferred_coding = crate::agent::config::preferred_coding_model(&config)
268        .unwrap_or_else(|| "none saved".to_string());
269    let preferred_embed = config
270        .embed_model
271        .clone()
272        .unwrap_or_else(|| "none saved".to_string());
273    let alternative = crate::runtime::detect_alternative_provider(&session_provider).await;
274    let alternative_line = alternative
275        .as_ref()
276        .map(|(name, url)| format!("Reachable alternative: {} ({})", name, url))
277        .unwrap_or_else(|| "Reachable alternative: none detected".to_string());
278    let provider_controls = if session_provider == "Ollama" {
279        "Provider controls: Ollama coding+embed load/unload is available here; `--ctx` maps to Ollama `num_ctx` for coding models."
280    } else {
281        "Provider controls: LM Studio coding+embed load/unload is available here; `--ctx` maps to LM Studio context length."
282    };
283    format!(
284        "Configured provider: {} ({})\nSession provider: {} ({})\nProvider state: {}\nPrimary issue: {}\nCoding model: {}\nPreferred coding model: {}\nCTX: {}\nEmbedding model: {}\nPreferred embed model: {}\nSemantic search: {} | embedded chunks: {}\nMCP: {}\n{}\n{}\n{}\n\nTry: /runtime explain, /runtime fix, /model status, /model list loaded",
285        configured_provider,
286        configured_endpoint,
287        session_provider,
288        session_endpoint,
289        provider_state_label(app.provider_state),
290        runtime_issue_label(issue),
291        coding_model,
292        preferred_coding,
293        app.context_length,
294        embed_status,
295        preferred_embed,
296        semantic_status,
297        app.vein_embedded_count,
298        mcp_state_label(app.mcp_state),
299        alternative_line,
300        provider_controls,
301        runtime_fix_path(app)
302    )
303}
304
305async fn format_runtime_explanation(app: &App) -> String {
306    let session_provider = runtime_session_provider(app);
307    let issue = runtime_issue_kind(app);
308    let coding_model = if app.model_id.trim().is_empty() {
309        "detecting...".to_string()
310    } else {
311        app.model_id.clone()
312    };
313    let semantic = if app.embed_model_id.is_some() || app.vein_embedded_count > 0 {
314        "semantic search is ready"
315    } else {
316        "semantic search is inactive"
317    };
318    let state_line = match app.provider_state {
319        ProviderRuntimeState::Live => format!(
320            "{} is live, Hematite sees model `{}`, and {}.",
321            session_provider, coding_model, semantic
322        ),
323        ProviderRuntimeState::Booting => format!(
324            "{} is still booting or being detected. Hematite has not stabilized the runtime view yet.",
325            session_provider
326        ),
327        ProviderRuntimeState::Recovering => format!(
328            "{} hit a runtime problem recently and Hematite is still trying to recover cleanly.",
329            session_provider
330        ),
331        ProviderRuntimeState::Degraded => format!(
332            "{} is reachable but degraded, so responses may fail or stall until the runtime is stable again.",
333            session_provider
334        ),
335        ProviderRuntimeState::EmptyResponse => format!(
336            "{} answered without useful content, which usually means the runtime needs attention even if the endpoint is still up.",
337            session_provider
338        ),
339        ProviderRuntimeState::ContextWindow => format!(
340            "{} hit its active context ceiling, so the problem is prompt budget rather than basic connectivity.",
341            session_provider
342        ),
343    };
344    let model_line = if coding_model == "no model loaded" {
345        "No coding model is loaded right now, so Hematite cannot do real model work until one is available.".to_string()
346    } else {
347        format!("The current coding model is `{}`.", coding_model)
348    };
349    let alternative = crate::runtime::detect_alternative_provider(&session_provider)
350        .await
351        .map(|(name, url)| format!("A reachable alternative exists: {} ({}).", name, url))
352        .unwrap_or_else(|| "No other reachable local runtime is currently detected.".to_string());
353    format!(
354        "Primary issue: {}\n{}\n{}\n{}\n{}",
355        runtime_issue_label(issue),
356        state_line,
357        model_line,
358        alternative,
359        runtime_fix_path(app)
360    )
361}
362
363async fn handle_runtime_fix(app: &mut App) {
364    let session_provider = runtime_session_provider(app);
365    let issue = runtime_issue_kind(app);
366    let alternative = crate::runtime::detect_alternative_provider(&session_provider).await;
367
368    if issue == RuntimeIssueKind::NoModel {
369        let mut message = runtime_fix_path(app);
370        if let Some((name, url)) = alternative {
371            let _ = write!(message,
372                "\nReachable alternative: {} ({}). Hematite will not switch providers silently; use `/runtime provider {}` and restart if you want that runtime instead.",
373                name,
374                url,
375                name.to_ascii_lowercase()
376            );
377        }
378        app.push_message("System", &message);
379        app.history_idx = None;
380        return;
381    }
382
383    if matches!(
384        issue,
385        RuntimeIssueKind::Booting
386            | RuntimeIssueKind::Recovering
387            | RuntimeIssueKind::Connectivity
388            | RuntimeIssueKind::EmptyResponse
389            | RuntimeIssueKind::ContextCeiling
390    ) {
391        let _ = app
392            .user_input_tx
393            .try_send(UserTurn::text("/runtime-refresh"));
394        app.push_message("You", "/runtime fix");
395        app.provider_state = ProviderRuntimeState::Recovering;
396        app.agent_running = true;
397
398        let mut message = format!(
399            "Running the shortest safe fix now: refreshing the {} runtime profile and re-checking the active model/context window.",
400            session_provider
401        );
402        if let Some((name, url)) = alternative {
403            let _ = write!(message,
404                "\nReachable alternative: {} ({}). Hematite will stay on the current provider unless you explicitly switch with `/runtime provider {}` and restart.",
405                name,
406                url,
407                name.to_ascii_lowercase()
408            );
409        }
410        app.push_message("System", &message);
411        if issue == RuntimeIssueKind::EmptyResponse {
412            if let Some(fallback) =
413                build_runtime_fix_grounded_fallback(&app.recent_grounded_results)
414            {
415                app.push_message(
416                    "System",
417                    "The last turn already produced grounded tool output, so Hematite is surfacing a bounded fallback while the runtime refresh completes.",
418                );
419                app.push_message("Hematite", &fallback);
420            } else {
421                app.push_message(
422                    "System",
423                    "Runtime refresh requested successfully. The failed turn has no safe grounded fallback cached, so retry the turn once the runtime settles.",
424                );
425            }
426        }
427        app.history_idx = None;
428        return;
429    }
430
431    if issue == RuntimeIssueKind::Healthy && app.embed_model_id.is_none() {
432        app.push_message(
433            "System",
434            "Runtime is already healthy. The only missing piece is optional semantic search; load your preferred embedding model if you want embedding-backed file retrieval.",
435        );
436        app.history_idx = None;
437        return;
438    }
439
440    app.push_message(
441        "System",
442        "Runtime is already healthy. `/runtime fix` has nothing safe to change right now.",
443    );
444    app.history_idx = None;
445}
446
447async fn handle_provider_command(app: &mut App, arg_text: String) {
448    if arg_text.is_empty() || arg_text.eq_ignore_ascii_case("status") {
449        app.push_message("System", &format_provider_summary(app).await);
450        app.history_idx = None;
451        return;
452    }
453
454    let lower = arg_text.to_ascii_lowercase();
455    let result = match lower.as_str() {
456        "lmstudio" | "lm" => {
457            crate::agent::config::set_api_url_override(Some(
458                crate::agent::config::DEFAULT_LM_STUDIO_API_URL,
459            ))
460            .map(|_| {
461                format!(
462                    "Saved provider preference: LM Studio ({}) in `.hematite/settings.json`.\nRestart Hematite to switch this session.",
463                    crate::agent::config::DEFAULT_LM_STUDIO_API_URL
464                )
465            })
466        }
467        "ollama" | "ol" => {
468            crate::agent::config::set_api_url_override(Some(
469                crate::agent::config::DEFAULT_OLLAMA_API_URL,
470            ))
471            .map(|_| {
472                format!(
473                    "Saved provider preference: Ollama ({}) in `.hematite/settings.json`.\nRestart Hematite to switch this session.",
474                    crate::agent::config::DEFAULT_OLLAMA_API_URL
475                )
476            })
477        }
478        "clear" | "default" => crate::agent::config::set_api_url_override(None).map(|_| {
479            format!(
480                "Cleared the saved provider override. New sessions will fall back to LM Studio ({}) unless `--url` overrides it.\nRestart Hematite to switch this session.",
481                crate::agent::config::DEFAULT_LM_STUDIO_API_URL
482            )
483        }),
484        _ if lower.starts_with("http://") || lower.starts_with("https://") => {
485            crate::agent::config::set_api_url_override(Some(&arg_text)).map(|_| {
486                format!(
487                    "Saved provider endpoint override: {} ({}) in `.hematite/settings.json`.\nRestart Hematite to switch this session.",
488                    crate::agent::config::provider_label_for_api_url(&arg_text),
489                    arg_text
490                )
491            })
492        }
493        _ => Err("Usage: /provider [status|lmstudio|ollama|clear|http://host:port/v1]".to_string()),
494    };
495
496    match result {
497        Ok(message) => app.push_message("System", &message),
498        Err(error) => app.push_message("System", &error),
499    }
500    app.history_idx = None;
501}
502
503// ── Approval modal state ──────────────────────────────────────────────────────
504
505/// Holds a pending high-risk tool approval request.
506/// The agent loop is blocked on `responder` until the user presses Y or N.
507pub struct PendingApproval {
508    pub display: String,
509    pub tool_name: String,
510    /// Pre-formatted diff from `compute_*_diff`.  Lines starting with "- " are
511    /// removals (red), "+ " are additions (green), "---" / "@@ " are headers.
512    pub diff: Option<String>,
513    /// Current scroll offset for the diff body (lines scrolled down).
514    pub diff_scroll: u16,
515    pub mutation_label: Option<String>,
516    pub responder: tokio::sync::oneshot::Sender<bool>,
517}
518
519// ── App state ─────────────────────────────────────────────────────────────────
520
521pub struct RustyStats {
522    pub debugging: u32,
523    pub wisdom: u16,
524    pub patience: f32,
525    pub chaos: u8,
526    pub snark: u8,
527}
528
529use std::collections::HashMap;
530
531#[derive(Clone)]
532pub struct ContextFile {
533    pub path: String,
534    pub size: u64,
535    pub status: String,
536}
537
538fn default_active_context() -> Vec<ContextFile> {
539    let root = crate::tools::file_ops::workspace_root();
540
541    // Detect the actual project entrypoints generically rather than
542    // hardcoding Hematite's own file layout. Priority order: first match wins
543    // for the "primary" slot, then the project manifest, then source root.
544    let entrypoint_candidates = [
545        "src/main.rs",
546        "src/lib.rs",
547        "src/index.ts",
548        "src/index.js",
549        "src/main.ts",
550        "src/main.js",
551        "src/main.py",
552        "main.py",
553        "main.go",
554        "index.js",
555        "index.ts",
556        "app.py",
557        "app.rs",
558    ];
559    let manifest_candidates = [
560        "Cargo.toml",
561        "package.json",
562        "go.mod",
563        "pyproject.toml",
564        "setup.py",
565        "composer.json",
566        "pom.xml",
567        "build.gradle",
568    ];
569
570    let mut files = Vec::with_capacity(5);
571
572    // Primary entrypoint
573    for path in &entrypoint_candidates {
574        let joined = root.join(path);
575        if joined.exists() {
576            let size = std::fs::metadata(&joined).map(|m| m.len()).unwrap_or(0);
577            files.push(ContextFile {
578                path: path.to_string(),
579                size,
580                status: "Active".to_string(),
581            });
582            break;
583        }
584    }
585
586    // Project manifest
587    for path in &manifest_candidates {
588        let joined = root.join(path);
589        if joined.exists() {
590            let size = std::fs::metadata(&joined).map(|m| m.len()).unwrap_or(0);
591            files.push(ContextFile {
592                path: path.to_string(),
593                size,
594                status: "Active".to_string(),
595            });
596            break;
597        }
598    }
599
600    // Source root watcher
601    let src = root.join("src");
602    if src.exists() {
603        let size = std::fs::metadata(&src).map(|m| m.len()).unwrap_or(0);
604        files.push(ContextFile {
605            path: "./src".to_string(),
606            size,
607            status: "Watching".to_string(),
608        });
609    }
610
611    files
612}
613
614#[derive(Clone, Copy, Debug, PartialEq, Eq)]
615enum SidebarMode {
616    Hidden,
617    Compact,
618    Full,
619}
620
621fn sidebar_has_live_activity(app: &App) -> bool {
622    app.agent_running
623        || !app.active_workers.is_empty()
624        || app.active_review.is_some()
625        || app.awaiting_approval.is_some()
626}
627
628fn select_sidebar_mode(width: u16, brief_mode: bool, live_activity: bool) -> SidebarMode {
629    if brief_mode || width < 100 {
630        SidebarMode::Hidden
631    } else if live_activity && width >= 145 {
632        SidebarMode::Full
633    } else {
634        SidebarMode::Compact
635    }
636}
637
638fn sidebar_mode(app: &App, width: u16) -> SidebarMode {
639    select_sidebar_mode(width, app.brief_mode, sidebar_has_live_activity(app))
640}
641
642fn build_compact_sidebar_lines(app: &App) -> Vec<Line<'static>> {
643    let mut lines = Vec::with_capacity(16);
644    let issue = runtime_issue_label(runtime_issue_kind(app));
645    let provider = if app.provider_name.trim().is_empty() {
646        "detecting".to_string()
647    } else {
648        app.provider_name.clone()
649    };
650    let model = if app.model_id.trim().is_empty() {
651        "detecting...".to_string()
652    } else {
653        app.model_id.clone()
654    };
655
656    lines.push(Line::from(vec![
657        Span::styled(" Runtime ", Style::default().fg(Color::Gray)),
658        Span::styled(
659            format!("{} / {}", provider, issue),
660            Style::default().fg(Color::White),
661        ),
662    ]));
663    lines.push(Line::from(vec![
664        Span::styled(" Model   ", Style::default().fg(Color::Gray)),
665        Span::styled(model, Style::default().fg(Color::White)),
666    ]));
667    lines.push(Line::from(vec![
668        Span::styled(" Flow    ", Style::default().fg(Color::Gray)),
669        Span::styled(
670            format!("{} | CTX {}", app.workflow_mode, app.context_length),
671            Style::default().fg(Color::White),
672        ),
673    ]));
674
675    let context_source = if app.active_context.is_empty() {
676        default_active_context()
677    } else {
678        app.active_context.clone()
679    };
680    if !context_source.is_empty() {
681        lines.push(Line::raw(""));
682        lines.push(Line::from(Span::styled(
683            "Files",
684            Style::default()
685                .fg(Color::White)
686                .add_modifier(Modifier::DIM),
687        )));
688        for file in context_source.iter().take(3) {
689            lines.push(Line::from(vec![
690                Span::styled("· ", Style::default().fg(Color::DarkGray)),
691                Span::styled(file.path.clone(), Style::default().fg(Color::White)),
692            ]));
693        }
694    }
695
696    let mut recent_events: Vec<String> = Vec::with_capacity(5);
697    if sidebar_has_live_activity(app) {
698        let label = if app.thinking { "Reasoning" } else { "Working" };
699        let dots = ".".repeat((app.tick_count % 4) as usize + 1);
700        recent_events.push(format!("{label}{dots}"));
701    }
702    recent_events.extend(app.specular_logs.iter().rev().take(4).cloned());
703    if !recent_events.is_empty() {
704        lines.push(Line::raw(""));
705        lines.push(Line::from(Span::styled(
706            "Signals",
707            Style::default()
708                .fg(Color::White)
709                .add_modifier(Modifier::DIM),
710        )));
711        for event in recent_events.into_iter().take(4) {
712            lines.push(Line::from(vec![
713                Span::styled("· ", Style::default().fg(Color::DarkGray)),
714                Span::styled(event, Style::default().fg(Color::Gray)),
715            ]));
716        }
717    }
718
719    lines
720}
721
722fn sidebar_signal_rows(app: &App) -> Vec<(String, Color)> {
723    let mut rows = Vec::with_capacity(4);
724    if !app.last_operator_checkpoint_summary.trim().is_empty() {
725        rows.push((
726            format!(
727                "STATE: {}",
728                first_n_chars(&app.last_operator_checkpoint_summary, 96)
729            ),
730            Color::Yellow,
731        ));
732    }
733    if !app.last_recovery_recipe_summary.trim().is_empty() {
734        rows.push((
735            format!(
736                "RECOVERY: {}",
737                first_n_chars(&app.last_recovery_recipe_summary, 96)
738            ),
739            Color::Cyan,
740        ));
741    }
742    if !app.last_provider_summary.trim().is_empty() {
743        rows.push((
744            format!(
745                "PROVIDER: {}",
746                first_n_chars(&app.last_provider_summary, 96)
747            ),
748            Color::Gray,
749        ));
750    }
751    if !app.last_mcp_summary.trim().is_empty() {
752        rows.push((
753            format!("MCP: {}", first_n_chars(&app.last_mcp_summary, 96)),
754            Color::Gray,
755        ));
756    }
757    rows
758}
759
760pub struct App {
761    pub messages: Vec<Line<'static>>,
762    pub messages_raw: Vec<(String, String)>, // Keep raw for reference or re-formatting if needed
763    pub specular_logs: Vec<String>,
764    pub brief_mode: bool,
765    pub tick_count: u64,
766    pub stats: RustyStats,
767    pub yolo_mode: bool,
768    /// Blocked waiting for user approval of a risky tool call.
769    pub awaiting_approval: Option<PendingApproval>,
770    pub active_workers: HashMap<String, u8>,
771    pub worker_labels: HashMap<String, String>,
772    pub active_review: Option<ActiveReview>,
773    pub input: String,
774    pub input_history: Vec<String>,
775    pub history_idx: Option<usize>,
776    pub thinking: bool,
777    pub agent_running: bool,
778    pub stop_requested: bool,
779    pub current_thought: String,
780    pub professional: bool,
781    pub last_reasoning: String,
782    pub active_context: Vec<ContextFile>,
783    pub manual_scroll_offset: Option<u16>,
784    /// Channel to send user messages to the agent task.
785    pub user_input_tx: tokio::sync::mpsc::Sender<UserTurn>,
786    pub specular_scroll: u16,
787    /// When true the SPECULAR panel snaps to the bottom every frame.
788    /// Set false when the user manually scrolls up; reset true on new turn / Done.
789    pub specular_auto_scroll: bool,
790    /// Shared GPU VRAM state (polled in background).
791    pub gpu_state: Arc<GpuState>,
792    /// Shared Git remote state (polled in background).
793    pub git_state: Arc<crate::agent::git_monitor::GitState>,
794    /// Track the last time a character or paste arrived to detect "fast streams" (pasting).
795    pub last_input_time: std::time::Instant,
796    pub cancel_token: Arc<std::sync::atomic::AtomicBool>,
797    pub total_tokens: usize,
798    pub current_session_cost: f64,
799    pub model_id: String,
800    pub context_length: usize,
801    prompt_pressure_percent: u8,
802    prompt_estimated_input_tokens: usize,
803    prompt_reserved_output_tokens: usize,
804    prompt_estimated_total_tokens: usize,
805    compaction_percent: u8,
806    compaction_estimated_tokens: usize,
807    compaction_threshold_tokens: usize,
808    /// Tracks the highest threshold crossed for compaction warnings (70, 90).
809    /// Prevents re-firing the same warning every update tick.
810    compaction_warned_level: u8,
811    last_runtime_profile_time: Instant,
812    vein_file_count: usize,
813    vein_embedded_count: usize,
814    vein_docs_only: bool,
815    provider_name: String,
816    provider_endpoint: String,
817    embed_model_id: Option<String>,
818    provider_state: ProviderRuntimeState,
819    last_provider_summary: String,
820    mcp_state: McpRuntimeState,
821    last_mcp_summary: String,
822    last_operator_checkpoint_state: OperatorCheckpointState,
823    last_operator_checkpoint_summary: String,
824    last_recovery_recipe_summary: String,
825    /// Mirrors ConversationManager::think_mode for status bar display.
826    /// None = auto, Some(true) = /think, Some(false) = /no_think.
827    pub think_mode: Option<bool>,
828    /// Sticky user-facing workflow mode.
829    pub workflow_mode: String,
830    /// [Autocomplete Hatch] List of matching project files.
831    pub autocomplete_suggestions: Vec<String>,
832    /// [Autocomplete Hatch] Index of the currently highlighted suggestion.
833    pub selected_suggestion: usize,
834    /// [Autocomplete Hatch] Whether the suggestions popup is visible.
835    pub show_autocomplete: bool,
836    /// [Autocomplete Hatch] The search fragment after the '@' symbol.
837    pub autocomplete_filter: String,
838    /// [Strategist] The currently active task from TASK.md.
839    pub current_objective: String,
840    /// [Voice of Hematite] Local TTS manager.
841    pub voice_manager: Arc<crate::ui::voice::VoiceManager>,
842    pub voice_loading: bool,
843    pub voice_loading_progress: f64,
844    /// [Autocomplete Hatch] True if the current scan is rooted in a sovereign folder.
845    pub autocomplete_alias_active: bool,
846    /// If false, the VRAM watchdog is silenced.
847    pub hardware_guard_enabled: bool,
848    /// Wall-clock time when this session started (for report timestamp).
849    pub session_start: std::time::SystemTime,
850    /// The current Rusty companion's species name — shown in the footer.
851    pub soul_name: String,
852    /// File attached via /attach — injected as context prefix on the next turn, then cleared.
853    pub attached_context: Option<(String, String)>,
854    pub attached_image: Option<AttachedImage>,
855    hovered_input_action: Option<InputAction>,
856    pub teleported_from: Option<String>,
857    /// Numbered directory list from the last /ls call — used by /ls <N> to teleport.
858    pub nav_list: Vec<std::path::PathBuf>,
859    /// When true, all ApprovalRequired events are auto-approved for the rest of the session.
860    /// Activated by pressing [A] ("Accept All") on any approval dialog.
861    pub auto_approve_session: bool,
862    /// Track when the current agentic task started for elapsed time rendering.
863    pub task_start_time: Option<std::time::Instant>,
864    /// Track live tool start times so timeline cards can show honest elapsed chips.
865    pub tool_started_at: HashMap<String, std::time::Instant>,
866    /// Successful grounded research/docs outputs from the current turn, used for
867    /// bounded fallback recovery when the model returns empty content.
868    pub recent_grounded_results: Vec<(String, String)>,
869}
870
871impl App {
872    pub fn reset_active_context(&mut self) {
873        self.active_context = default_active_context();
874    }
875
876    pub fn record_error(&mut self) {
877        self.stats.debugging = self.stats.debugging.saturating_add(1);
878    }
879
880    pub fn reset_error_count(&mut self) {
881        self.stats.debugging = 0;
882    }
883
884    pub fn reset_runtime_status_memory(&mut self) {
885        self.last_provider_summary.clear();
886        self.last_mcp_summary.clear();
887        self.last_operator_checkpoint_summary.clear();
888        self.last_operator_checkpoint_state = OperatorCheckpointState::Idle;
889        self.last_recovery_recipe_summary.clear();
890        self.embed_model_id = None;
891    }
892
893    pub fn clear_pending_attachments(&mut self) {
894        self.attached_context = None;
895        self.attached_image = None;
896    }
897
898    pub fn clear_grounded_recovery_cache(&mut self) {
899        self.recent_grounded_results.clear();
900    }
901
902    pub fn push_message(&mut self, speaker: &str, content: &str) {
903        let filtered = filter_tui_noise(content);
904        if filtered.is_empty() && !content.is_empty() {
905            return;
906        } // Completely suppressed noise
907
908        self.messages_raw.push((speaker.to_string(), filtered));
909        // Cap raw history to prevent UI lag.
910        if self.messages_raw.len() > 500 {
911            self.messages_raw.remove(0);
912        }
913        self.rebuild_formatted_messages();
914        // Cap visual history.
915        if self.messages.len() > 8192 {
916            let to_drain = self.messages.len() - 8192;
917            self.messages.drain(0..to_drain);
918        }
919    }
920
921    pub fn update_last_message(&mut self, token: &str) {
922        if let Some(last_raw) = self.messages_raw.last_mut() {
923            if last_raw.0 == "Hematite" {
924                last_raw.1.push_str(token);
925                // Explicitly treat the last assistant message as "dirty" and repaint
926                // so the TUI can reliably snap to the newest Hematite message.
927                self.rebuild_formatted_messages();
928            }
929        }
930    }
931
932    fn sync_task_start_time(&mut self) {
933        self.task_start_time = synced_task_start_time(self.agent_running, self.task_start_time);
934    }
935
936    fn rebuild_formatted_messages(&mut self) {
937        self.messages.clear();
938        let total = self.messages_raw.len();
939        for (i, (speaker, content)) in self.messages_raw.iter().enumerate() {
940            let is_last = i == total - 1;
941            let formatted = self.format_message(speaker, content, is_last);
942            self.messages.extend(formatted);
943            // Add a single blank line between messages for breathing room.
944            // Never add this to the very last message so it remains flush with the bottom.
945            if !is_last {
946                self.messages.push(Line::raw(""));
947            }
948        }
949    }
950
951    fn header_spans(&self, speaker: &str, is_last: bool) -> Vec<Span<'static>> {
952        let graphite = Color::Rgb(95, 95, 95);
953        let steel = Color::Rgb(110, 110, 110);
954        let ice = Color::Rgb(145, 205, 255);
955        let slate = Color::Rgb(42, 42, 42);
956        let pulse_on = self.tick_count.is_multiple_of(2);
957
958        match speaker {
959            "You" => vec![
960                Span::styled(" [", Style::default().fg(Color::DarkGray)),
961                Span::styled(
962                    "YOU",
963                    Style::default()
964                        .fg(Color::Black)
965                        .bg(Color::Green)
966                        .add_modifier(Modifier::BOLD),
967                ),
968                Span::styled("] ", Style::default().fg(Color::DarkGray)),
969            ],
970            "Hematite" => {
971                let live_label = if is_last && (self.agent_running || self.thinking) {
972                    if pulse_on {
973                        "LIVE"
974                    } else {
975                        "FLOW"
976                    }
977                } else {
978                    "HEMATITE"
979                };
980                vec![
981                    Span::styled(" [", Style::default().fg(Color::DarkGray)),
982                    Span::styled(
983                        live_label,
984                        Style::default()
985                            .fg(if is_last { ice } else { steel })
986                            .bg(slate)
987                            .add_modifier(Modifier::BOLD),
988                    ),
989                    Span::styled("] ", Style::default().fg(Color::DarkGray)),
990                ]
991            }
992            "System" => vec![
993                Span::styled(" [", Style::default().fg(Color::DarkGray)),
994                Span::styled(
995                    "SYSTEM",
996                    Style::default()
997                        .fg(graphite)
998                        .bg(Color::Rgb(28, 28, 28))
999                        .add_modifier(Modifier::BOLD),
1000                ),
1001                Span::styled("] ", Style::default().fg(Color::DarkGray)),
1002            ],
1003            "Tool" => vec![
1004                Span::styled(" [", Style::default().fg(Color::DarkGray)),
1005                Span::styled(
1006                    "TOOLS",
1007                    Style::default()
1008                        .fg(Color::Cyan)
1009                        .bg(Color::Rgb(28, 34, 38))
1010                        .add_modifier(Modifier::BOLD),
1011                ),
1012                Span::styled("] ", Style::default().fg(Color::DarkGray)),
1013            ],
1014            _ => vec![Span::styled(
1015                format!("[{}] ", speaker),
1016                Style::default().fg(graphite).add_modifier(Modifier::BOLD),
1017            )],
1018        }
1019    }
1020
1021    fn tool_timeline_header(&self, label: &str, color: Color) -> Line<'static> {
1022        Line::from(vec![
1023            Span::styled("  o", Style::default().fg(Color::DarkGray)),
1024            Span::styled("----", Style::default().fg(Color::Rgb(52, 52, 52))),
1025            Span::styled(
1026                format!(" {} ", label),
1027                Style::default()
1028                    .fg(color)
1029                    .bg(Color::Rgb(28, 28, 28))
1030                    .add_modifier(Modifier::BOLD),
1031            ),
1032        ])
1033    }
1034
1035    fn tool_timeline_header_with_meta(
1036        &self,
1037        label: &str,
1038        color: Color,
1039        elapsed: Option<&str>,
1040    ) -> Line<'static> {
1041        let mut spans = vec![
1042            Span::styled("  o", Style::default().fg(Color::DarkGray)),
1043            Span::styled("----", Style::default().fg(Color::Rgb(52, 52, 52))),
1044            Span::styled(
1045                format!(" {} ", label),
1046                Style::default()
1047                    .fg(color)
1048                    .bg(Color::Rgb(28, 28, 28))
1049                    .add_modifier(Modifier::BOLD),
1050            ),
1051        ];
1052        if let Some(elapsed) = elapsed.filter(|elapsed| !elapsed.trim().is_empty()) {
1053            spans.push(Span::raw(" "));
1054            spans.push(Span::styled(
1055                format!(" {} ", elapsed),
1056                Style::default()
1057                    .fg(Color::Rgb(210, 210, 210))
1058                    .bg(Color::Rgb(36, 36, 36))
1059                    .add_modifier(Modifier::BOLD),
1060            ));
1061        }
1062        Line::from(spans)
1063    }
1064
1065    fn format_message(&self, speaker: &str, content: &str, is_last: bool) -> Vec<Line<'static>> {
1066        let mut lines = Vec::new();
1067        let cleaned_str = crate::agent::inference::strip_think_blocks(content);
1068        let trimmed = cleaned_str.trim();
1069        let cleaned = String::from(strip_ghost_prefix(trimmed));
1070
1071        let mut is_first = true;
1072        let mut in_code_block = false;
1073
1074        for raw_line in cleaned.lines() {
1075            let owned_line = String::from(raw_line);
1076            if !is_first && raw_line.trim().is_empty() {
1077                lines.push(Line::raw(""));
1078                continue;
1079            }
1080
1081            if raw_line.trim_start().starts_with("```") {
1082                in_code_block = !in_code_block;
1083                let lang = raw_line
1084                    .trim_start()
1085                    .strip_prefix("```")
1086                    .unwrap_or("")
1087                    .trim();
1088
1089                let (border, label) = if in_code_block {
1090                    (
1091                        " ┌── ",
1092                        format!(" {} ", if lang.is_empty() { "code" } else { lang }),
1093                    )
1094                } else {
1095                    (" └──", String::new())
1096                };
1097
1098                lines.push(Line::from(vec![
1099                    Span::styled(
1100                        border,
1101                        Style::default()
1102                            .fg(Color::DarkGray)
1103                            .add_modifier(Modifier::DIM),
1104                    ),
1105                    Span::styled(
1106                        label,
1107                        Style::default()
1108                            .fg(Color::Cyan)
1109                            .bg(Color::Rgb(40, 40, 40))
1110                            .add_modifier(Modifier::BOLD),
1111                    ),
1112                ]));
1113                is_first = false;
1114                continue;
1115            }
1116
1117            if in_code_block {
1118                lines.push(Line::from(vec![
1119                    Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
1120                    Span::styled(owned_line, Style::default().fg(Color::Rgb(200, 200, 160))),
1121                ]));
1122                is_first = false;
1123                continue;
1124            }
1125
1126            if speaker == "System" && (raw_line.contains(" +") || raw_line.contains(" -")) {
1127                let mut spans: Vec<Span<'static>> = if is_first {
1128                    self.header_spans(speaker, is_last)
1129                } else {
1130                    vec![Span::raw("   ")]
1131                };
1132                for token in raw_line.split_whitespace() {
1133                    let is_add = token.starts_with('+')
1134                        && token.len() > 1
1135                        && token[1..].chars().all(|c| c.is_ascii_digit());
1136                    let is_rem = token.starts_with('-')
1137                        && token.len() > 1
1138                        && token[1..].chars().all(|c| c.is_ascii_digit());
1139                    let is_path =
1140                        (token.contains('/') || token.contains('\\') || token.contains('.'))
1141                            && !token.starts_with('+')
1142                            && !token.starts_with('-')
1143                            && !token.ends_with(':');
1144                    let span = if is_add {
1145                        Span::styled(
1146                            format!("{} ", token),
1147                            Style::default()
1148                                .fg(Color::Green)
1149                                .add_modifier(Modifier::BOLD),
1150                        )
1151                    } else if is_rem {
1152                        Span::styled(
1153                            format!("{} ", token),
1154                            Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
1155                        )
1156                    } else if is_path {
1157                        Span::styled(
1158                            format!("{} ", token),
1159                            Style::default()
1160                                .fg(Color::White)
1161                                .add_modifier(Modifier::BOLD),
1162                        )
1163                    } else {
1164                        Span::raw(format!("{} ", token))
1165                    };
1166                    spans.push(span);
1167                }
1168                lines.push(Line::from(spans));
1169                is_first = false;
1170                continue;
1171            }
1172
1173            if speaker == "Tool"
1174                && (raw_line.starts_with("-")
1175                    || raw_line.starts_with("+")
1176                    || raw_line.starts_with("@@"))
1177            {
1178                let (line_style, gutter_style, sign) = if raw_line.starts_with("-") {
1179                    (
1180                        Style::default()
1181                            .fg(Color::Rgb(255, 200, 200))
1182                            .bg(Color::Rgb(60, 20, 20)),
1183                        Style::default().fg(Color::Red).bg(Color::Rgb(40, 15, 15)),
1184                        "-",
1185                    )
1186                } else if raw_line.starts_with("+") {
1187                    (
1188                        Style::default()
1189                            .fg(Color::Rgb(200, 255, 200))
1190                            .bg(Color::Rgb(20, 50, 30)),
1191                        Style::default().fg(Color::Green).bg(Color::Rgb(15, 30, 20)),
1192                        "+",
1193                    )
1194                } else {
1195                    (
1196                        Style::default().fg(Color::Cyan).add_modifier(Modifier::DIM),
1197                        Style::default().fg(Color::DarkGray),
1198                        "⋮",
1199                    )
1200                };
1201
1202                let content = if raw_line.starts_with("@@") {
1203                    owned_line
1204                } else {
1205                    String::from(&raw_line[1..])
1206                };
1207
1208                lines.push(Line::from(vec![
1209                    Span::styled(format!("  {} ", sign), gutter_style),
1210                    Span::styled(content, line_style),
1211                ]));
1212                is_first = false;
1213                continue;
1214            }
1215            if speaker == "Tool" {
1216                let border_style = Style::default().fg(Color::Rgb(60, 60, 60));
1217
1218                if raw_line.starts_with("( )") {
1219                    lines.push(self.tool_timeline_header("REQUEST", Color::Cyan));
1220                    lines.push(Line::from(vec![
1221                        Span::styled("  | ", border_style),
1222                        Span::styled(
1223                            String::from(&raw_line[4..]),
1224                            Style::default().fg(Color::Rgb(155, 220, 255)),
1225                        ),
1226                    ]));
1227                } else if raw_line.starts_with("[v]") || raw_line.starts_with("[x]") {
1228                    let is_success = raw_line.starts_with("[v]");
1229                    let (status, color) = if is_success {
1230                        ("SUCCESS", Color::Green)
1231                    } else {
1232                        ("FAILED", Color::Red)
1233                    };
1234
1235                    let payload = raw_line[4..].trim();
1236                    let (summary, preview) = if let Some((left, right)) = payload.split_once(" → ")
1237                    {
1238                        (left.trim(), Some(right))
1239                    } else {
1240                        (payload, None)
1241                    };
1242                    let (summary, elapsed) = extract_tool_elapsed_chip(summary);
1243
1244                    lines.push(self.tool_timeline_header_with_meta(
1245                        status,
1246                        color,
1247                        elapsed.as_deref(),
1248                    ));
1249                    let mut detail_spans = vec![
1250                        Span::styled("  | ", border_style),
1251                        Span::styled(
1252                            summary,
1253                            Style::default().fg(if is_success {
1254                                Color::Rgb(145, 215, 145)
1255                            } else {
1256                                Color::Rgb(255, 175, 175)
1257                            }),
1258                        ),
1259                    ];
1260                    if let Some(preview) = preview {
1261                        detail_spans
1262                            .push(Span::styled(" → ", Style::default().fg(Color::DarkGray)));
1263                        detail_spans.push(Span::styled(
1264                            preview.to_string(),
1265                            Style::default().fg(Color::DarkGray),
1266                        ));
1267                    }
1268                    lines.push(Line::from(detail_spans));
1269                } else if raw_line.starts_with("┌──") {
1270                    lines.push(Line::from(vec![
1271                        Span::styled(" ┌──", border_style),
1272                        Span::styled(
1273                            String::from(&raw_line[3..]),
1274                            Style::default()
1275                                .fg(Color::Cyan)
1276                                .add_modifier(Modifier::BOLD),
1277                        ),
1278                    ]));
1279                } else if raw_line.starts_with("└─") {
1280                    let status_color = if raw_line.contains("SUCCESS") {
1281                        Color::Green
1282                    } else {
1283                        Color::Red
1284                    };
1285                    lines.push(Line::from(vec![
1286                        Span::styled(" └─", border_style),
1287                        Span::styled(
1288                            String::from(&raw_line[3..]),
1289                            Style::default()
1290                                .fg(status_color)
1291                                .add_modifier(Modifier::BOLD),
1292                        ),
1293                    ]));
1294                } else if raw_line.starts_with("│") {
1295                    lines.push(Line::from(vec![
1296                        Span::styled(" │", border_style),
1297                        Span::styled(
1298                            String::from(&raw_line[1..]),
1299                            Style::default().fg(Color::DarkGray),
1300                        ),
1301                    ]));
1302                } else {
1303                    lines.push(Line::from(vec![
1304                        Span::styled(" │ ", border_style),
1305                        Span::styled(owned_line, Style::default().fg(Color::DarkGray)),
1306                    ]));
1307                }
1308                is_first = false;
1309                continue;
1310            }
1311
1312            let mut spans = if is_first {
1313                self.header_spans(speaker, is_last)
1314            } else {
1315                vec![Span::raw("   ")]
1316            };
1317
1318            if speaker == "Hematite" {
1319                if is_first {
1320                    spans.push(Span::styled(" ", Style::default().fg(Color::DarkGray)));
1321                }
1322                spans.extend(inline_markdown_core(raw_line));
1323            } else {
1324                spans.push(Span::raw(owned_line));
1325            }
1326            lines.push(Line::from(spans));
1327            is_first = false;
1328        }
1329
1330        lines
1331    }
1332
1333    /// [Intelli-Hematite] Live scan of the workspace to populate autocomplete.
1334    /// Excludes common noisy directories like target, node_modules, .git.
1335    pub fn update_autocomplete(&mut self) {
1336        self.autocomplete_alias_active = false;
1337        let (scan_root, query) = if let Some(pos) = self.input.rfind('@') {
1338            let fragment = &self.input[pos + 1..];
1339            let upper = fragment.to_uppercase();
1340
1341            // ── Path Alias Scan ──────────────────────────────────────────────
1342            // If the fragment starts with a known shortcut, jump the scan root.
1343            let mut resolved_root = crate::tools::file_ops::workspace_root();
1344            let mut final_query = fragment;
1345
1346            let tokens = [
1347                "DESKTOP",
1348                "DOWNLOADS",
1349                "DOCUMENTS",
1350                "PICTURES",
1351                "VIDEOS",
1352                "MUSIC",
1353                "HOME",
1354            ];
1355            for token in tokens {
1356                if upper.starts_with(token) {
1357                    let candidate =
1358                        crate::tools::file_ops::resolve_candidate(&format!("@{}", token));
1359                    if candidate.exists() {
1360                        resolved_root = candidate;
1361                        self.autocomplete_alias_active = true;
1362                        // Strip the token from the query so we match files inside the target
1363                        if let Some(slash_pos) = fragment.find('/') {
1364                            final_query = &fragment[slash_pos + 1..];
1365                        } else {
1366                            final_query = ""; // Just browsing the token root
1367                        }
1368                        break;
1369                    }
1370                }
1371            }
1372            (resolved_root, final_query.to_lowercase())
1373        } else {
1374            (crate::tools::file_ops::workspace_root(), "".to_string())
1375        };
1376
1377        self.autocomplete_filter = query.clone();
1378        let mut matches = Vec::new();
1379        let mut total_found = 0;
1380
1381        // ── Noise Suppression List ───────────────────────────────────────────
1382        let noise = [
1383            "node_modules",
1384            "target",
1385            ".git",
1386            ".next",
1387            ".venv",
1388            "venv",
1389            "env",
1390            "bin",
1391            "obj",
1392            "dist",
1393            "vendor",
1394            "__pycache__",
1395            "AppData",
1396            "Local",
1397            "Roaming",
1398            "Application Data",
1399        ];
1400
1401        for entry in WalkDir::new(&scan_root)
1402            .max_depth(4) // Prevent deep system dives
1403            .into_iter()
1404            .filter_entry(|e| {
1405                let name = e.file_name().to_string_lossy();
1406                !name.starts_with('.') && !noise.iter().any(|&n| name.eq_ignore_ascii_case(n))
1407            })
1408            .flatten()
1409        {
1410            let is_file = entry.file_type().is_file();
1411            let is_dir = entry.file_type().is_dir();
1412
1413            if (is_file || is_dir) && entry.path() != scan_root {
1414                let path = entry
1415                    .path()
1416                    .strip_prefix(&scan_root)
1417                    .unwrap_or(entry.path());
1418                let mut path_str = path.to_string_lossy().to_string();
1419
1420                if is_dir {
1421                    path_str.push('/');
1422                }
1423
1424                if path_str.to_lowercase().contains(&query) || query.is_empty() {
1425                    total_found += 1;
1426                    if matches.len() < 15 {
1427                        matches.push(path_str);
1428                    }
1429                }
1430            }
1431            if total_found > 60 {
1432                break;
1433            } // Tighter safety cap
1434        }
1435
1436        // Prioritize: Directories and source files (.rs, .md) at the top
1437        matches.sort_by(|a, b| {
1438            let a_is_dir = a.ends_with('/');
1439            let b_is_dir = b.ends_with('/');
1440
1441            let a_ext = a.split('.').next_back().unwrap_or("");
1442            let b_ext = b.split('.').next_back().unwrap_or("");
1443            let a_is_src = a_ext == "rs" || a_ext == "md";
1444            let b_is_src = b_ext == "rs" || b_ext == "md";
1445
1446            let a_score = if a_is_dir {
1447                2
1448            } else if a_is_src {
1449                1
1450            } else {
1451                0
1452            };
1453            let b_score = if b_is_dir {
1454                2
1455            } else if b_is_src {
1456                1
1457            } else {
1458                0
1459            };
1460
1461            b_score.cmp(&a_score)
1462        });
1463
1464        self.autocomplete_suggestions = matches;
1465        self.selected_suggestion = self
1466            .selected_suggestion
1467            .min(self.autocomplete_suggestions.len().saturating_sub(1));
1468    }
1469
1470    /// [Intelli-Hematite] Applies an autocomplete selection back to the input bar.
1471    /// Implements Smart Splicing to handle path aliases (@DESKTOP/) vs global scans.
1472    pub fn apply_autocomplete_selection(&mut self, selection: &str) {
1473        if let Some(pos) = self.input.rfind('@') {
1474            if self.autocomplete_alias_active {
1475                // Splicing for @ALIAS/path
1476                // Truncate to the last slash AFTER the @ if it exists
1477                let after_at = &self.input[pos + 1..];
1478                if let Some(slash_pos) = after_at.rfind('/') {
1479                    self.input.truncate(pos + 1 + slash_pos + 1);
1480                } else {
1481                    // No slash yet, truncate to @ + 1
1482                    self.input.truncate(pos + 1);
1483                }
1484            } else {
1485                // Splicing for global scan: replace the @ entirely
1486                self.input.truncate(pos);
1487            }
1488            self.input.push_str(selection);
1489            self.show_autocomplete = false;
1490        }
1491    }
1492
1493    /// [Intelli-Hematite] Update the context strategy deck with real file data.
1494    pub fn push_context_file(&mut self, path: String, status: String) {
1495        self.active_context.retain(|f| f.path != path);
1496
1497        let root = crate::tools::file_ops::workspace_root();
1498        let full_path = root.join(&path);
1499        let size = std::fs::metadata(full_path).map(|m| m.len()).unwrap_or(0);
1500
1501        self.active_context.push(ContextFile { path, size, status });
1502
1503        if self.active_context.len() > 10 {
1504            self.active_context.remove(0);
1505        }
1506    }
1507
1508    /// [Task Analyzer] Parse TASK.md to find the current active goal.
1509    pub fn update_objective(&mut self) {
1510        let hdir = crate::tools::file_ops::hematite_dir();
1511        let plan_path = hdir.join("PLAN.md");
1512        if plan_path.exists() {
1513            if let Some(plan) = crate::tools::plan::load_plan_handoff() {
1514                if plan.has_signal() && !plan.goal.trim().is_empty() {
1515                    self.current_objective = plan.summary_line();
1516                    return;
1517                }
1518            }
1519        }
1520        let path = hdir.join("TASK.md");
1521        if let Ok(content) = std::fs::read_to_string(path) {
1522            for line in content.lines() {
1523                let trimmed = line.trim();
1524                // Match "- [ ]" or "- [/]"
1525                if (trimmed.starts_with("- [ ]") || trimmed.starts_with("- [/]"))
1526                    && trimmed.len() > 6
1527                {
1528                    self.current_objective = trimmed[6..].trim().to_string();
1529                    return;
1530                }
1531            }
1532        }
1533        self.current_objective = "Idle".into();
1534    }
1535
1536    /// [Auto-Diagnostic] Copy full session transcript to clipboard.
1537    pub fn copy_specular_to_clipboard(&self) {
1538        let mut out = String::from("=== SPECULAR LOG ===\n\n");
1539
1540        if !self.last_reasoning.is_empty() {
1541            out.push_str("--- Last Reasoning Block ---\n");
1542            out.push_str(&self.last_reasoning);
1543            out.push_str("\n\n");
1544        }
1545
1546        if !self.current_thought.is_empty() {
1547            out.push_str("--- In-Progress Reasoning ---\n");
1548            out.push_str(&self.current_thought);
1549            out.push_str("\n\n");
1550        }
1551
1552        if !self.specular_logs.is_empty() {
1553            out.push_str("--- Specular Events ---\n");
1554            for entry in &self.specular_logs {
1555                out.push_str(entry);
1556                out.push('\n');
1557            }
1558            out.push('\n');
1559        }
1560
1561        let _ = writeln!(
1562            out,
1563            "Tokens: {} | Cost: ${:.4}",
1564            self.total_tokens, self.current_session_cost
1565        );
1566
1567        let clip = system32_exe("clip.exe");
1568        let mut child = std::process::Command::new(&clip)
1569            .stdin(std::process::Stdio::piped())
1570            .spawn()
1571            .expect("Failed to spawn clip.exe");
1572        if let Some(mut stdin) = child.stdin.take() {
1573            use std::io::Write;
1574            let _ = stdin.write_all(out.as_bytes());
1575        }
1576        let _ = child.wait();
1577    }
1578
1579    pub fn write_session_report(&self) {
1580        let report_dir = crate::tools::file_ops::hematite_dir().join("reports");
1581        if std::fs::create_dir_all(&report_dir).is_err() {
1582            return;
1583        }
1584
1585        // Timestamp from session_start
1586        let start_secs = self
1587            .session_start
1588            .duration_since(std::time::UNIX_EPOCH)
1589            .unwrap_or_default()
1590            .as_secs();
1591
1592        // Simple epoch → YYYY-MM-DD_HH-MM-SS (UTC)
1593        let secs_in_day = start_secs % 86400;
1594        let days = start_secs / 86400;
1595        let years_approx = (days * 4 + 2) / 1461;
1596        let year = 1970 + years_approx;
1597        let day_of_year = days - (years_approx * 365 + years_approx / 4);
1598        let month = (day_of_year / 30 + 1).min(12);
1599        let day = (day_of_year % 30 + 1).min(31);
1600        let hh = secs_in_day / 3600;
1601        let mm = (secs_in_day % 3600) / 60;
1602        let ss = secs_in_day % 60;
1603        let timestamp = format!(
1604            "{:04}-{:02}-{:02}_{:02}-{:02}-{:02}",
1605            year, month, day, hh, mm, ss
1606        );
1607
1608        let duration_secs = std::time::SystemTime::now()
1609            .duration_since(self.session_start)
1610            .unwrap_or_default()
1611            .as_secs();
1612
1613        let report_path = report_dir.join(format!("session_{}.json", timestamp));
1614
1615        let turns: Vec<serde_json::Value> = self
1616            .messages_raw
1617            .iter()
1618            .map(|(speaker, text)| serde_json::json!({ "speaker": speaker, "text": text }))
1619            .collect();
1620
1621        let report = serde_json::json!({
1622            "session_start": timestamp,
1623            "duration_secs": duration_secs,
1624            "model": self.model_id,
1625            "context_length": self.context_length,
1626            "total_tokens": self.total_tokens,
1627            "estimated_cost_usd": self.current_session_cost,
1628            "turn_count": turns.len(),
1629            "transcript": turns,
1630        });
1631
1632        if let Ok(json) = serde_json::to_string_pretty(&report) {
1633            let _ = std::fs::write(&report_path, json);
1634        }
1635    }
1636
1637    fn transcript_snapshot_for_copy(&self) -> (Vec<(String, String)>, bool) {
1638        if !self.agent_running {
1639            return (self.messages_raw.clone(), false);
1640        }
1641
1642        if let Some(last_user_idx) = self
1643            .messages_raw
1644            .iter()
1645            .rposition(|(speaker, _)| speaker == "You")
1646        {
1647            (
1648                self.messages_raw[..=last_user_idx].to_vec(),
1649                last_user_idx + 1 < self.messages_raw.len(),
1650            )
1651        } else {
1652            (Vec::new(), !self.messages_raw.is_empty())
1653        }
1654    }
1655
1656    pub fn copy_transcript_to_clipboard(&self) {
1657        let (snapshot, omitted_inflight) = self.transcript_snapshot_for_copy();
1658        let mut history = snapshot
1659            .iter()
1660            .filter(|(speaker, content)| !should_skip_transcript_copy_entry(speaker, content))
1661            .map(|m| format!("[{}] {}\n", m.0, m.1))
1662            .collect::<String>();
1663
1664        if omitted_inflight {
1665            history.push_str(
1666                "[System] Current turn is still in progress; in-flight Hematite output was omitted from this clipboard snapshot.\n",
1667            );
1668        }
1669
1670        history.push_str("\nSession Stats\n");
1671        let _ = writeln!(history, "Tokens: {}", self.total_tokens);
1672        let _ = writeln!(history, "Cost: ${:.4}", self.current_session_cost);
1673
1674        copy_text_to_clipboard(&history);
1675    }
1676
1677    pub fn copy_clean_transcript_to_clipboard(&self) {
1678        let (snapshot, omitted_inflight) = self.transcript_snapshot_for_copy();
1679        let mut history = snapshot
1680            .iter()
1681            .filter(|(speaker, content)| !should_skip_transcript_copy_entry(speaker, content))
1682            .map(|m| format!("[{}] {}\n", m.0, m.1))
1683            .collect::<String>();
1684
1685        if omitted_inflight {
1686            history.push_str(
1687                "[System] Current turn is still in progress; in-flight Hematite output was omitted from this clipboard snapshot.\n",
1688            );
1689        }
1690
1691        history.push_str("\nSession Stats\n");
1692        let _ = writeln!(history, "Tokens: {}", self.total_tokens);
1693        let _ = writeln!(history, "Cost: ${:.4}", self.current_session_cost);
1694
1695        copy_text_to_clipboard(&history);
1696    }
1697
1698    pub fn copy_last_reply_to_clipboard(&self) -> bool {
1699        if let Some((speaker, content)) = self
1700            .messages_raw
1701            .iter()
1702            .rev()
1703            .find(|(speaker, content)| is_copyable_hematite_reply(speaker, content))
1704        {
1705            let cleaned = cleaned_copyable_reply_text(content);
1706            let payload = format!("[{}] {}", speaker, cleaned);
1707            copy_text_to_clipboard(&payload);
1708            true
1709        } else {
1710            false
1711        }
1712    }
1713}
1714
1715fn should_accept_autocomplete_on_enter(alias_active: bool, filter: &str) -> bool {
1716    if alias_active && filter.trim().is_empty() {
1717        return false;
1718    }
1719    true
1720}
1721
1722/// Resolve an absolute path to a System32 binary using %SystemRoot%.
1723/// Avoids relying on PATH resolution for known system binaries.
1724fn system32_exe(name: &str) -> String {
1725    let root = std::env::var("SystemRoot").unwrap_or_else(|_| "C:\\Windows".to_string());
1726    format!("{root}\\System32\\{name}")
1727}
1728
1729fn copy_text_to_clipboard(text: &str) {
1730    if copy_text_to_clipboard_powershell(text) {
1731        return;
1732    }
1733
1734    // Fallback: Windows clip.exe is fast and dependency-free, but some
1735    // terminal/clipboard paths can mangle non-ASCII punctuation.
1736    let clip = system32_exe("clip.exe");
1737    let mut child = std::process::Command::new(&clip)
1738        .stdin(std::process::Stdio::piped())
1739        .spawn()
1740        .expect("Failed to spawn clip.exe");
1741
1742    if let Some(mut stdin) = child.stdin.take() {
1743        use std::io::Write;
1744        let _ = stdin.write_all(text.as_bytes());
1745    }
1746    let _ = child.wait();
1747}
1748
1749fn synced_task_start_time(
1750    active: bool,
1751    current: Option<std::time::Instant>,
1752) -> Option<std::time::Instant> {
1753    match (active, current) {
1754        (true, None) => Some(std::time::Instant::now()),
1755        (false, Some(_)) => None,
1756        (_, existing) => existing,
1757    }
1758}
1759
1760fn scroll_specular_up(app: &mut App, amount: u16) {
1761    app.specular_auto_scroll = false;
1762    app.specular_scroll = app.specular_scroll.saturating_sub(amount);
1763}
1764
1765fn scroll_specular_down(app: &mut App, amount: u16) {
1766    app.specular_auto_scroll = false;
1767    app.specular_scroll = app.specular_scroll.saturating_add(amount);
1768}
1769
1770fn follow_live_specular(app: &mut App) {
1771    app.specular_auto_scroll = true;
1772    app.specular_scroll = 0;
1773}
1774
1775fn format_tool_elapsed(elapsed: std::time::Duration) -> String {
1776    if elapsed.as_millis() < 1_000 {
1777        format!("{}ms", elapsed.as_millis())
1778    } else {
1779        format!("{:.1}s", elapsed.as_secs_f64())
1780    }
1781}
1782
1783fn extract_tool_elapsed_chip(summary: &str) -> (String, Option<String>) {
1784    let trimmed = summary.trim();
1785    if let Some((head, tail)) = trimmed.rsplit_once(" [") {
1786        if let Some(elapsed) = tail.strip_suffix(']') {
1787            if !elapsed.is_empty()
1788                && elapsed
1789                    .chars()
1790                    .all(|ch| ch.is_ascii_digit() || ch == '.' || ch == 'm' || ch == 's')
1791            {
1792                return (head.trim().to_string(), Some(elapsed.to_string()));
1793            }
1794        }
1795    }
1796    (trimmed.to_string(), None)
1797}
1798
1799fn should_capture_grounded_tool_output(name: &str, is_error: bool) -> bool {
1800    !is_error && matches!(name, "research_web" | "fetch_docs")
1801}
1802
1803fn looks_like_markup_payload(result: &str) -> bool {
1804    let lower = result
1805        .chars()
1806        .take(256)
1807        .collect::<String>()
1808        .to_ascii_lowercase();
1809    lower.contains("<!doctype")
1810        || lower.contains("<html")
1811        || lower.contains("<body")
1812        || lower.contains("<meta ")
1813}
1814
1815fn build_runtime_fix_grounded_fallback(results: &[(String, String)]) -> Option<String> {
1816    if results.is_empty() {
1817        return None;
1818    }
1819
1820    let mut sections = Vec::with_capacity(results.len());
1821
1822    for (name, result) in results.iter().filter(|(name, _)| name == "research_web") {
1823        sections.push(format!(
1824            "[{}]\n{}",
1825            name,
1826            first_n_chars(result, 1800).trim()
1827        ));
1828    }
1829
1830    if sections.is_empty() {
1831        for (name, result) in results
1832            .iter()
1833            .filter(|(name, result)| name == "fetch_docs" && !looks_like_markup_payload(result))
1834        {
1835            sections.push(format!(
1836                "[{}]\n{}",
1837                name,
1838                first_n_chars(result, 1600).trim()
1839            ));
1840        }
1841    }
1842
1843    if sections.is_empty() {
1844        if let Some((name, result)) = results.last() {
1845            sections.push(format!(
1846                "[{}]\n{}",
1847                name,
1848                first_n_chars(result, 1200).trim()
1849            ));
1850        }
1851    }
1852
1853    if sections.is_empty() {
1854        None
1855    } else {
1856        Some(format!(
1857            "The model returned empty content after grounded tool work. Hematite is surfacing the latest verified tool output directly.\n\n{}",
1858            sections.join("\n\n")
1859        ))
1860    }
1861}
1862
1863#[cfg(test)]
1864mod tests {
1865    use super::{
1866        build_runtime_fix_grounded_fallback, classify_runtime_issue, extract_tool_elapsed_chip,
1867        format_tool_elapsed, make_animated_sparkline_gauge, provider_badge_prefix,
1868        select_fitting_variant, select_sidebar_mode, should_accept_autocomplete_on_enter,
1869        synced_task_start_time, RuntimeIssueKind, SidebarMode,
1870    };
1871    use crate::agent::inference::ProviderRuntimeState;
1872
1873    #[test]
1874    fn tool_elapsed_chip_extracts_cleanly_from_summary() {
1875        assert_eq!(
1876            extract_tool_elapsed_chip("research_web [842ms]"),
1877            ("research_web".to_string(), Some("842ms".to_string()))
1878        );
1879        assert_eq!(
1880            extract_tool_elapsed_chip("read_file"),
1881            ("read_file".to_string(), None)
1882        );
1883    }
1884
1885    #[test]
1886    fn tool_elapsed_formats_compact_runtime_durations() {
1887        assert_eq!(
1888            format_tool_elapsed(std::time::Duration::from_millis(842)),
1889            "842ms"
1890        );
1891        assert_eq!(
1892            format_tool_elapsed(std::time::Duration::from_millis(1520)),
1893            "1.5s"
1894        );
1895    }
1896
1897    #[test]
1898    fn enter_submits_bare_alias_root_instead_of_selecting_first_child() {
1899        assert!(!should_accept_autocomplete_on_enter(true, ""));
1900        assert!(!should_accept_autocomplete_on_enter(true, "   "));
1901    }
1902
1903    #[test]
1904    fn enter_still_accepts_narrowed_alias_matches() {
1905        assert!(should_accept_autocomplete_on_enter(true, "web"));
1906        assert!(should_accept_autocomplete_on_enter(false, ""));
1907    }
1908
1909    #[test]
1910    fn provider_badge_prefix_tracks_runtime_provider() {
1911        assert_eq!(provider_badge_prefix("LM Studio"), "LM");
1912        assert_eq!(provider_badge_prefix("Ollama"), "OL");
1913        assert_eq!(provider_badge_prefix("Other"), "AI");
1914    }
1915
1916    #[test]
1917    fn runtime_issue_prefers_no_model_over_live_state() {
1918        assert_eq!(
1919            classify_runtime_issue(ProviderRuntimeState::Live, "no model loaded", 32000, ""),
1920            RuntimeIssueKind::NoModel
1921        );
1922    }
1923
1924    #[test]
1925    fn runtime_issue_distinguishes_context_ceiling() {
1926        assert_eq!(
1927            classify_runtime_issue(
1928                ProviderRuntimeState::ContextWindow,
1929                "qwen/qwen3.5-9b",
1930                32000,
1931                "LM context ceiling hit."
1932            ),
1933            RuntimeIssueKind::ContextCeiling
1934        );
1935    }
1936
1937    #[test]
1938    fn runtime_issue_maps_generic_degraded_state_to_connectivity_signal() {
1939        assert_eq!(
1940            classify_runtime_issue(
1941                ProviderRuntimeState::Degraded,
1942                "qwen/qwen3.5-9b",
1943                32000,
1944                "LM Studio degraded and did not recover cleanly; operator action is now required."
1945            ),
1946            RuntimeIssueKind::Connectivity
1947        );
1948    }
1949
1950    #[test]
1951    fn sidebar_mode_hides_in_brief_or_narrow_layouts() {
1952        assert_eq!(select_sidebar_mode(99, false, true), SidebarMode::Hidden);
1953        assert_eq!(select_sidebar_mode(160, true, true), SidebarMode::Hidden);
1954    }
1955
1956    #[test]
1957    fn sidebar_mode_only_uses_full_chrome_for_live_wide_sessions() {
1958        assert_eq!(select_sidebar_mode(130, false, false), SidebarMode::Compact);
1959        assert_eq!(select_sidebar_mode(130, false, true), SidebarMode::Compact);
1960        assert_eq!(select_sidebar_mode(160, false, true), SidebarMode::Full);
1961    }
1962
1963    #[test]
1964    fn task_timer_starts_when_activity_begins() {
1965        assert!(synced_task_start_time(true, None).is_some());
1966    }
1967
1968    #[test]
1969    fn task_timer_clears_when_activity_ends() {
1970        assert!(synced_task_start_time(false, Some(std::time::Instant::now())).is_none());
1971    }
1972
1973    #[test]
1974    fn fitting_variant_picks_longest_string_that_fits() {
1975        let variants = vec![
1976            "this variant is too wide".to_string(),
1977            "fits nicely".to_string(),
1978            "tiny".to_string(),
1979        ];
1980        assert_eq!(select_fitting_variant(&variants, 12), "fits nicely");
1981        assert_eq!(select_fitting_variant(&variants, 4), "tiny");
1982    }
1983
1984    #[test]
1985    fn animated_gauge_preserves_requested_width() {
1986        let gauge = make_animated_sparkline_gauge(0.42, 12, 7);
1987        assert_eq!(gauge.chars().count(), 12);
1988        assert!(gauge.contains('█') || gauge.contains('▓') || gauge.contains('▒'));
1989    }
1990    #[test]
1991    fn runtime_fix_grounded_fallback_prefers_search_results_over_html_fetch() {
1992        let fallback = build_runtime_fix_grounded_fallback(&[
1993            (
1994                "fetch_docs".to_string(),
1995                "<!doctype html><html><body>raw page shell</body></html>".to_string(),
1996            ),
1997            (
1998                "research_web".to_string(),
1999                "Search results for: uefn toolbelt\n1. GitHub repo\n2. Epic forum thread"
2000                    .to_string(),
2001            ),
2002        ])
2003        .expect("fallback");
2004
2005        assert!(fallback.contains("Search results for: uefn toolbelt"));
2006        assert!(!fallback.contains("<!doctype html>"));
2007    }
2008
2009    #[test]
2010    fn runtime_fix_grounded_fallback_returns_none_without_grounded_results() {
2011        assert!(build_runtime_fix_grounded_fallback(&[]).is_none());
2012    }
2013}
2014
2015/// Capture the pixel rect of the current console window via a synchronous PowerShell call.
2016/// Returns (x, y, width, height) in screen pixels.
2017#[cfg(windows)]
2018fn get_console_pixel_rect() -> Option<(i32, i32, i32, i32)> {
2019    let script = concat!(
2020        "Add-Type -TypeDefinition '",
2021        "using System;using System.Runtime.InteropServices;",
2022        "public class WG{",
2023        "[DllImport(\"kernel32\")]public static extern IntPtr GetConsoleWindow();",
2024        "[DllImport(\"user32\")]public static extern bool GetWindowRect(IntPtr h,out RECT r);",
2025        "[StructLayout(LayoutKind.Sequential)]public struct RECT{public int L,T,R,B;}}",
2026        "';",
2027        "$h=[WG]::GetConsoleWindow();$r=New-Object WG+RECT;",
2028        "[WG]::GetWindowRect($h,[ref]$r)|Out-Null;",
2029        "Write-Output \"$($r.L) $($r.T) $($r.R-$r.L) $($r.B-$r.T)\""
2030    );
2031    let out = std::process::Command::new("powershell.exe")
2032        .args(["-NoProfile", "-NonInteractive", "-Command", script])
2033        .output()
2034        .ok()?;
2035    let s = String::from_utf8_lossy(&out.stdout);
2036    let parts: Vec<i32> = s
2037        .split_whitespace()
2038        .filter_map(|v| v.trim().parse().ok())
2039        .collect();
2040    if parts.len() >= 4 {
2041        Some((parts[0], parts[1], parts[2], parts[3]))
2042    } else {
2043        None
2044    }
2045}
2046
2047/// Find the shell/tab process that should be closed after teleporting away from
2048/// the current session. In Windows Terminal we want the tab shell, not the
2049/// terminal host process itself.
2050#[cfg(windows)]
2051fn get_console_close_target_pid_sync() -> Option<u32> {
2052    let pid = std::process::id();
2053    let script = format!(
2054        r#"
2055$current = [uint32]{pid}
2056$seen = New-Object 'System.Collections.Generic.HashSet[uint32]'
2057$shell_pattern = '^(cmd|powershell|pwsh|bash|sh|wsl|ubuntu|debian|kali|arch)$'
2058$skip_pattern = '^(WindowsTerminal|wt|OpenConsole|conhost)$'
2059$fallback = $null
2060$found = $false
2061while ($current -gt 0 -and $seen.Add($current)) {{
2062    $proc = Get-CimInstance Win32_Process -Filter "ProcessId=$current" -ErrorAction SilentlyContinue
2063    if (-not $proc) {{ break }}
2064    $parent = [uint32]$proc.ParentProcessId
2065    if ($parent -le 0) {{ break }}
2066    $parent_proc = Get-Process -Id $parent -ErrorAction SilentlyContinue
2067    if ($parent_proc) {{
2068        $name = $parent_proc.ProcessName
2069        if ($name -match $shell_pattern) {{
2070            $found = $true
2071            Write-Output $parent
2072            break
2073        }}
2074        if (-not $fallback -and $name -notmatch $skip_pattern) {{
2075            $fallback = $parent
2076        }}
2077    }}
2078    $current = $parent
2079}}
2080if (-not $found -and $fallback) {{ Write-Output $fallback }}
2081"#
2082    );
2083    let out = std::process::Command::new("powershell.exe")
2084        .args(["-NoProfile", "-NonInteractive", "-Command", &script])
2085        .output()
2086        .ok()?;
2087    String::from_utf8_lossy(&out.stdout).trim().parse().ok()
2088}
2089
2090/// Spawns a new detached terminal window pre-navigated to `path`, running Hematite.
2091/// - Writes a temp .bat file to avoid quoting issues with paths containing spaces
2092/// - Matches the current window's pixel size and position
2093/// - Skips the splash screen in the new session (`--no-splash`)
2094/// - Closes the originating shell/tab after Hematite exits without killing the
2095///   whole Windows Terminal host
2096#[cfg(windows)]
2097fn spawn_dive_in_terminal(path: &str) {
2098    let pid = std::process::id();
2099    let current_dir = std::env::current_dir()
2100        .map(|p| p.to_string_lossy().to_string())
2101        .unwrap_or_default();
2102
2103    let close_target_pid = get_console_close_target_pid_sync().unwrap_or(0);
2104    let (px, py, pw, ph) = get_console_pixel_rect().unwrap_or((50, 50, 1100, 750));
2105
2106    let bat_path = std::env::temp_dir().join("hematite_teleport.bat");
2107    let bat_content = format!(
2108        "@echo off\r\ncd /d \"{p}\"\r\nhematite --no-splash --teleported-from \"{o}\"\r\n",
2109        p = path.replace('"', ""),
2110        o = current_dir.replace('"', ""),
2111    );
2112    if std::fs::write(&bat_path, bat_content).is_err() {
2113        return;
2114    }
2115    let bat_str = bat_path.to_string_lossy().to_string();
2116    let bat_ps = bat_str.replace('\'', "''");
2117
2118    let script = format!(
2119        r#"
2120Add-Type -TypeDefinition @'
2121using System; using System.Runtime.InteropServices;
2122public class WM {{ [DllImport("user32")] public static extern bool MoveWindow(IntPtr h,int x,int y,int w,int ht,bool b); }}
2123'@
2124$proc = Start-Process cmd.exe -ArgumentList @('/k', '"{bat}"') -PassThru
2125$deadline = (Get-Date).AddSeconds(8)
2126while ((Get-Date) -lt $deadline -and $proc.MainWindowHandle -eq [IntPtr]::Zero) {{ Start-Sleep -Milliseconds 100 }}
2127if ($proc.MainWindowHandle -ne [IntPtr]::Zero) {{
2128    [WM]::MoveWindow($proc.MainWindowHandle, {px}, {py}, {pw}, {ph}, $true) | Out-Null
2129}}
2130Wait-Process -Id {pid} -ErrorAction SilentlyContinue
2131if ({close_pid} -gt 0) {{
2132    Stop-Process -Id {close_pid} -Force -ErrorAction SilentlyContinue
2133}}
2134"#,
2135        bat = bat_ps,
2136        px = px,
2137        py = py,
2138        pw = pw,
2139        ph = ph,
2140        pid = pid,
2141        close_pid = close_target_pid,
2142    );
2143
2144    let _ = std::process::Command::new("powershell.exe")
2145        .args([
2146            "-NoProfile",
2147            "-NonInteractive",
2148            "-WindowStyle",
2149            "Hidden",
2150            "-Command",
2151            &script,
2152        ])
2153        .spawn();
2154}
2155
2156#[cfg(not(windows))]
2157fn spawn_dive_in_terminal(_path: &str) {}
2158
2159fn copy_text_to_clipboard_powershell(text: &str) -> bool {
2160    let temp_path = std::env::temp_dir().join(format!(
2161        "hematite-clipboard-{}-{}.txt",
2162        std::process::id(),
2163        std::time::SystemTime::now()
2164            .duration_since(std::time::UNIX_EPOCH)
2165            .map(|d| d.as_millis())
2166            .unwrap_or_default()
2167    ));
2168
2169    if std::fs::write(&temp_path, text.as_bytes()).is_err() {
2170        return false;
2171    }
2172
2173    let escaped_path = temp_path.display().to_string().replace('\'', "''");
2174    let script = format!(
2175        "$t = Get-Content -LiteralPath '{}' -Raw -Encoding UTF8; Set-Clipboard -Value $t",
2176        escaped_path
2177    );
2178
2179    let status = std::process::Command::new("powershell.exe")
2180        .args(["-NoProfile", "-NonInteractive", "-Command", &script])
2181        .status();
2182
2183    let _ = std::fs::remove_file(&temp_path);
2184
2185    matches!(status, Ok(code) if code.success())
2186}
2187
2188fn is_immediate_local_command(input: &str) -> bool {
2189    matches!(
2190        input.trim().to_ascii_lowercase().as_str(),
2191        "/copy" | "/copy-last" | "/copy-clean" | "/copy2"
2192    )
2193}
2194
2195fn should_skip_transcript_copy_entry(speaker: &str, content: &str) -> bool {
2196    if speaker != "System" {
2197        return false;
2198    }
2199
2200    content.starts_with("Hematite Commands:\n")
2201        || content.starts_with("Document note: `/attach`")
2202        || content == "Chat transcript copied to clipboard."
2203        || content == "Exact session transcript copied to clipboard (includes help/system output)."
2204        || content == "Clean chat transcript copied to clipboard (skips help/debug boilerplate)."
2205        || content == "Latest Hematite reply copied to clipboard."
2206        || content == "SPECULAR log copied to clipboard (reasoning + events)."
2207        || content == "Cancellation requested. Logs copied to clipboard."
2208}
2209
2210fn is_copyable_hematite_reply(speaker: &str, content: &str) -> bool {
2211    if speaker != "Hematite" {
2212        return false;
2213    }
2214
2215    let trimmed = content.trim();
2216    if trimmed.is_empty() {
2217        return false;
2218    }
2219
2220    if trimmed == "Initialising Engine & Hardware..."
2221        || trimmed == "Swarm engaged."
2222        || trimmed.starts_with("Hematite v")
2223        || trimmed.starts_with("Swarm analyzing: '")
2224        || trimmed.ends_with("Standing by for review...")
2225        || trimmed.ends_with("conflict - review required.")
2226        || trimmed.ends_with("conflict — review required.")
2227    {
2228        return false;
2229    }
2230
2231    true
2232}
2233
2234fn cleaned_copyable_reply_text(content: &str) -> String {
2235    let cleaned = content
2236        .replace("<thought>", "")
2237        .replace("</thought>", "")
2238        .replace("<think>", "")
2239        .replace("</think>", "");
2240    strip_ghost_prefix(cleaned.trim()).trim().to_string()
2241}
2242
2243// ── run_app ───────────────────────────────────────────────────────────────────
2244
2245#[derive(Clone, Copy, PartialEq, Eq)]
2246enum InputAction {
2247    Stop,
2248    PickDocument,
2249    PickImage,
2250    Detach,
2251    New,
2252    Forget,
2253    Help,
2254}
2255
2256#[derive(Clone)]
2257struct InputActionVisual {
2258    action: InputAction,
2259    label: String,
2260    style: Style,
2261}
2262
2263#[derive(Clone, Copy)]
2264enum AttachmentPickerKind {
2265    Document,
2266    Image,
2267}
2268
2269fn attach_document_from_path(app: &mut App, file_path: &str) {
2270    let p = std::path::Path::new(file_path);
2271    match crate::memory::vein::extract_document_text(p) {
2272        Ok(text) => {
2273            let name = p
2274                .file_name()
2275                .and_then(|n| n.to_str())
2276                .unwrap_or(file_path)
2277                .to_string();
2278            let preview_len = text.len().min(200);
2279            // Rough token estimate: ~4 chars per token.
2280            let estimated_tokens = text.len() / 4;
2281            let ctx = app.context_length.max(1);
2282            let budget_pct = (estimated_tokens * 100) / ctx;
2283            let budget_note = if budget_pct >= 75 {
2284                format!(
2285                    "\nWarning: this document is ~{} tokens (~{}% of your {}k context). \
2286                     Very little room left for conversation. Consider /attach on a shorter excerpt.",
2287                    estimated_tokens, budget_pct, ctx / 1000
2288                )
2289            } else if budget_pct >= 40 {
2290                format!(
2291                    "\nNote: this document is ~{} tokens (~{}% of your {}k context).",
2292                    estimated_tokens,
2293                    budget_pct,
2294                    ctx / 1000
2295                )
2296            } else {
2297                String::new()
2298            };
2299            app.push_message(
2300                "System",
2301                &format!(
2302                    "Attached document: {} ({} chars) for the next message.\nPreview: {}...{}",
2303                    name,
2304                    text.len(),
2305                    &text[..preview_len],
2306                    budget_note,
2307                ),
2308            );
2309            app.attached_context = Some((name, text));
2310        }
2311        Err(e) => {
2312            app.push_message("System", &format!("Attach failed: {}", e));
2313        }
2314    }
2315}
2316
2317fn attach_image_from_path(app: &mut App, file_path: &str) {
2318    let p = std::path::Path::new(file_path);
2319    match crate::tools::vision::encode_image_as_data_url(p) {
2320        Ok(_) => {
2321            let name = p
2322                .file_name()
2323                .and_then(|n| n.to_str())
2324                .unwrap_or(file_path)
2325                .to_string();
2326            app.push_message(
2327                "System",
2328                &format!("Attached image: {} for the next message.", name),
2329            );
2330            app.attached_image = Some(AttachedImage {
2331                name,
2332                path: file_path.to_string(),
2333            });
2334        }
2335        Err(e) => {
2336            app.push_message("System", &format!("Image attach failed: {}", e));
2337        }
2338    }
2339}
2340
2341fn is_document_path(path: &std::path::Path) -> bool {
2342    matches!(
2343        path.extension()
2344            .and_then(|e| e.to_str())
2345            .unwrap_or("")
2346            .to_ascii_lowercase()
2347            .as_str(),
2348        "pdf" | "md" | "markdown" | "txt" | "rst"
2349    )
2350}
2351
2352fn is_image_path(path: &std::path::Path) -> bool {
2353    matches!(
2354        path.extension()
2355            .and_then(|e| e.to_str())
2356            .unwrap_or("")
2357            .to_ascii_lowercase()
2358            .as_str(),
2359        "png" | "jpg" | "jpeg" | "gif" | "webp"
2360    )
2361}
2362
2363fn extract_pasted_path_candidates(content: &str) -> Vec<String> {
2364    let mut out = Vec::new();
2365    let trimmed = content.trim();
2366    if trimmed.is_empty() {
2367        return out;
2368    }
2369
2370    let mut in_quotes = false;
2371    let mut current = String::new();
2372    for ch in trimmed.chars() {
2373        if ch == '"' {
2374            if in_quotes && !current.trim().is_empty() {
2375                out.push(current.trim().to_string());
2376                current.clear();
2377            }
2378            in_quotes = !in_quotes;
2379            continue;
2380        }
2381        if in_quotes {
2382            current.push(ch);
2383        }
2384    }
2385    if !out.is_empty() {
2386        return out;
2387    }
2388
2389    for line in trimmed.lines() {
2390        let candidate = line.trim().trim_matches('"').trim();
2391        if !candidate.is_empty() {
2392            out.push(candidate.to_string());
2393        }
2394    }
2395
2396    if out.is_empty() {
2397        out.push(trimmed.trim_matches('"').to_string());
2398    }
2399    out
2400}
2401
2402fn try_attach_from_paste(app: &mut App, content: &str) -> bool {
2403    let mut attached_doc = false;
2404    let mut attached_image = false;
2405    let mut ignored_supported = 0usize;
2406
2407    for raw in extract_pasted_path_candidates(content) {
2408        let path = std::path::Path::new(&raw);
2409        if !path.exists() {
2410            continue;
2411        }
2412        if is_image_path(path) {
2413            if attached_image || app.attached_image.is_some() {
2414                ignored_supported += 1;
2415            } else {
2416                attach_image_from_path(app, &raw);
2417                attached_image = true;
2418            }
2419        } else if is_document_path(path) {
2420            if attached_doc || app.attached_context.is_some() {
2421                ignored_supported += 1;
2422            } else {
2423                attach_document_from_path(app, &raw);
2424                attached_doc = true;
2425            }
2426        }
2427    }
2428
2429    if ignored_supported > 0 {
2430        app.push_message(
2431            "System",
2432            &format!(
2433                "Ignored {} extra dropped file(s). Hematite currently keeps one pending document and one pending image.",
2434                ignored_supported
2435            ),
2436        );
2437    }
2438
2439    attached_doc || attached_image
2440}
2441
2442fn compute_input_height(total_width: u16, input_len: usize) -> u16 {
2443    let width = total_width.max(1) as usize;
2444    let approx_input_w = (width * 65 / 100).saturating_sub(4).max(1);
2445    let needed_lines = (input_len / approx_input_w) as u16 + 3;
2446    needed_lines.clamp(3, 10)
2447}
2448
2449fn input_rect_for_size(size: Rect, input_len: usize) -> Rect {
2450    let input_height = compute_input_height(size.width, input_len);
2451    Layout::default()
2452        .direction(Direction::Vertical)
2453        .constraints([
2454            Constraint::Min(0),
2455            Constraint::Length(input_height),
2456            Constraint::Length(5), // Synced with 2-tier ui() for surgical mouse alignment
2457        ])
2458        .split(size)[1]
2459}
2460
2461fn input_title_area(input_rect: Rect) -> Rect {
2462    Rect {
2463        x: input_rect.x.saturating_add(1),
2464        y: input_rect.y,
2465        width: input_rect.width.saturating_sub(2),
2466        height: 1,
2467    }
2468}
2469
2470fn build_input_actions(app: &App) -> Vec<InputActionVisual> {
2471    let doc_label = if app.attached_context.is_some() {
2472        "Files*"
2473    } else {
2474        "Files"
2475    };
2476    let image_label = if app.attached_image.is_some() {
2477        "Image*"
2478    } else {
2479        "Image"
2480    };
2481    let detach_style = if app.attached_context.is_some() || app.attached_image.is_some() {
2482        Style::default()
2483            .fg(Color::Yellow)
2484            .add_modifier(Modifier::BOLD)
2485    } else {
2486        Style::default().fg(Color::DarkGray)
2487    };
2488
2489    let mut actions = Vec::with_capacity(6);
2490    if app.agent_running {
2491        actions.push(InputActionVisual {
2492            action: InputAction::Stop,
2493            label: "Stop Esc".to_string(),
2494            style: Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
2495        });
2496    } else {
2497        actions.push(InputActionVisual {
2498            action: InputAction::New,
2499            label: "New".to_string(),
2500            style: Style::default()
2501                .fg(Color::Green)
2502                .add_modifier(Modifier::BOLD),
2503        });
2504        actions.push(InputActionVisual {
2505            action: InputAction::Forget,
2506            label: "Forget".to_string(),
2507            style: Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
2508        });
2509    }
2510
2511    actions.push(InputActionVisual {
2512        action: InputAction::PickDocument,
2513        label: format!("{} ^O", doc_label),
2514        style: Style::default()
2515            .fg(Color::Cyan)
2516            .add_modifier(Modifier::BOLD),
2517    });
2518    actions.push(InputActionVisual {
2519        action: InputAction::PickImage,
2520        label: format!("{} ^I", image_label),
2521        style: Style::default()
2522            .fg(Color::Magenta)
2523            .add_modifier(Modifier::BOLD),
2524    });
2525    actions.push(InputActionVisual {
2526        action: InputAction::Detach,
2527        label: "Detach".to_string(),
2528        style: detach_style,
2529    });
2530    actions.push(InputActionVisual {
2531        action: InputAction::Help,
2532        label: "Help".to_string(),
2533        style: Style::default()
2534            .fg(Color::Blue)
2535            .add_modifier(Modifier::BOLD),
2536    });
2537    actions
2538}
2539
2540fn visible_input_actions(app: &App, max_width: u16) -> Vec<InputActionVisual> {
2541    let mut used = 0u16;
2542    let mut visible = Vec::with_capacity(6);
2543    for action in build_input_actions(app) {
2544        let chip_width = action.label.chars().count() as u16 + 2;
2545        let gap = if visible.is_empty() { 0 } else { 1 };
2546        if used + gap + chip_width > max_width {
2547            break;
2548        }
2549        used += gap + chip_width;
2550        visible.push(action);
2551    }
2552    visible
2553}
2554
2555fn input_status_variants(app: &App) -> Vec<String> {
2556    let voice_status = if app.voice_manager.is_enabled() {
2557        "ON"
2558    } else {
2559        "OFF"
2560    };
2561    let approvals_status = if app.yolo_mode { "OFF" } else { "ON" };
2562    let issue = runtime_issue_badge(runtime_issue_kind(app)).0;
2563    let flow = app.workflow_mode.to_uppercase();
2564    let attach_status = if app.attached_context.is_some() && app.attached_image.is_some() {
2565        "ATTACH:DOC+IMG"
2566    } else if app.attached_context.is_some() {
2567        "ATTACH:DOC"
2568    } else if app.attached_image.is_some() {
2569        "ATTACH:IMG"
2570    } else {
2571        "ATTACH:--"
2572    };
2573    if app.agent_running {
2574        vec![
2575            format!(
2576                "WORKING · ESC stops · FLOW:{} · RT:{} · VOICE:{}",
2577                flow, issue, voice_status
2578            ),
2579            format!("WORKING · RT:{} · VOICE:{}", issue, voice_status),
2580            format!("RT:{} · VOICE:{}", issue, voice_status),
2581            format!("RT:{}", issue),
2582        ]
2583    } else if app.input.trim().is_empty() {
2584        vec![
2585            format!(
2586                "READY · FLOW:{} · RT:{} · VOICE:{} · APPR:{}",
2587                flow, issue, voice_status, approvals_status
2588            ),
2589            format!("READY · FLOW:{} · RT:{}", flow, issue),
2590            format!("FLOW:{} · RT:{}", flow, issue),
2591            format!("RT:{}", issue),
2592        ]
2593    } else {
2594        let draft_len = app.input.len();
2595        vec![
2596            format!(
2597                "DRAFT:{} · FLOW:{} · RT:{} · {}",
2598                draft_len, flow, issue, attach_status
2599            ),
2600            format!("DRAFT:{} · RT:{} · {}", draft_len, issue, attach_status),
2601            format!("LEN:{} · RT:{}", draft_len, issue),
2602            format!("RT:{}", issue),
2603        ]
2604    }
2605}
2606
2607fn make_sparkline_gauge(ratio: f64, width: usize) -> String {
2608    let filled = (ratio * width as f64).round() as usize;
2609    let mut s = String::with_capacity(width);
2610    for i in 0..width {
2611        if i < filled {
2612            s.push('▓');
2613        } else {
2614            s.push('░');
2615        }
2616    }
2617    s
2618}
2619
2620fn make_animated_sparkline_gauge(ratio: f64, width: usize, tick_count: u64) -> String {
2621    let filled = (ratio.clamp(0.0, 1.0) * width as f64).round() as usize;
2622    let shimmer_idx = if filled > 0 {
2623        (tick_count as usize / 2) % filled.max(1)
2624    } else {
2625        0
2626    };
2627    let mut chars: Vec<char> = make_sparkline_gauge(ratio, width).chars().collect();
2628    for (i, ch) in chars.iter_mut().enumerate() {
2629        if i < filled {
2630            *ch = if i == shimmer_idx { '█' } else { '▓' };
2631        } else if i == filled && filled < width && ratio > 0.0 {
2632            *ch = '▒';
2633        } else {
2634            *ch = '░';
2635        }
2636    }
2637    chars.into_iter().collect()
2638}
2639
2640fn select_fitting_variant(variants: &[String], width: u16) -> String {
2641    let max_width = width as usize;
2642    for variant in variants {
2643        if variant.chars().count() <= max_width {
2644            return variant.clone();
2645        }
2646    }
2647    variants.last().cloned().unwrap_or_default()
2648}
2649
2650fn idle_footer_variants(app: &App) -> Vec<String> {
2651    let issue = runtime_issue_badge(runtime_issue_kind(app)).0;
2652    if issue != "OK" {
2653        return vec![
2654            format!(" /runtime fix • /runtime explain • RT:{} ", issue),
2655            format!(" /runtime fix • RT:{} ", issue),
2656            format!(" RT:{} ", issue),
2657        ];
2658    }
2659
2660    let phase = (app.tick_count / 18) % 3;
2661    match phase {
2662        0 => vec![
2663            " [↑/↓] scroll • /help hints • /runtime status ".to_string(),
2664            " [↑/↓] scroll • /help hints ".to_string(),
2665            " /help ".to_string(),
2666        ],
2667        1 => vec![
2668            " /ask analyze • /architect plan • /code implement ".to_string(),
2669            " /ask • /architect • /code ".to_string(),
2670            " /code ".to_string(),
2671        ],
2672        _ => vec![
2673            " /provider status • /runtime refresh • /ls desktop ".to_string(),
2674            " /provider • /runtime refresh ".to_string(),
2675            " /runtime ".to_string(),
2676        ],
2677    }
2678}
2679
2680fn running_footer_variants(app: &App, elapsed: &str, last_log: &str) -> Vec<String> {
2681    let worker_count = app.active_workers.len();
2682    let primary_caption = if worker_count > 0 {
2683        format!("{} workers • {}", worker_count, last_log)
2684    } else {
2685        last_log.to_string()
2686    };
2687    vec![
2688        primary_caption,
2689        last_log.to_string(),
2690        format!("{} • working", elapsed.trim()),
2691        "working".to_string(),
2692    ]
2693}
2694
2695fn select_input_title_layout(app: &App, title_width: u16) -> (Vec<InputActionVisual>, String) {
2696    let action_total = build_input_actions(app).len();
2697    let mut best_actions = visible_input_actions(app, title_width);
2698    let mut best_status = String::new();
2699    for status in input_status_variants(app) {
2700        let reserved = status.chars().count() as u16 + 3;
2701        let actions = visible_input_actions(app, title_width.saturating_sub(reserved));
2702        let replace = actions.len() > best_actions.len()
2703            || (actions.len() == best_actions.len() && status.len() > best_status.len());
2704        if replace {
2705            best_actions = actions.clone();
2706            best_status = status.clone();
2707        }
2708        if actions.len() == action_total {
2709            return (actions, status);
2710        }
2711    }
2712    (best_actions, best_status)
2713}
2714
2715fn input_action_hitboxes(app: &App, title_area: Rect) -> Vec<(InputAction, u16, u16)> {
2716    let mut x = title_area.x;
2717    let mut out = Vec::with_capacity(6);
2718    let (actions, _) = select_input_title_layout(app, title_area.width);
2719    for action in actions {
2720        let chip_width = action.label.chars().count() as u16 + 2; // " " + label + " "
2721        out.push((action.action, x, x + chip_width.saturating_sub(1)));
2722        x = x.saturating_add(chip_width + 1);
2723    }
2724    out
2725}
2726
2727fn render_input_title<'a>(app: &'a App, area: Rect) -> Line<'a> {
2728    let mut spans = Vec::with_capacity(8);
2729    let (actions, status) = select_input_title_layout(app, area.width);
2730    for action in actions {
2731        let is_hovered = app.hovered_input_action == Some(action.action);
2732        let style = if is_hovered {
2733            Style::default()
2734                .bg(action.style.fg.unwrap_or(Color::Gray))
2735                .fg(Color::Black)
2736                .add_modifier(Modifier::BOLD)
2737        } else {
2738            action.style
2739        };
2740        spans.push(Span::styled(format!(" {} ", action.label), style));
2741        spans.push(Span::raw(" "));
2742    }
2743
2744    if !status.is_empty() {
2745        spans.push(Span::raw(" "));
2746        spans.push(Span::styled(status, Style::default().fg(Color::DarkGray)));
2747    }
2748    Line::from(spans)
2749}
2750
2751fn reset_visible_session_state(app: &mut App) {
2752    app.messages.clear();
2753    app.messages_raw.clear();
2754    app.last_reasoning.clear();
2755    app.current_thought.clear();
2756    app.specular_logs.clear();
2757    app.reset_error_count();
2758    app.reset_runtime_status_memory();
2759    app.reset_active_context();
2760    app.tool_started_at.clear();
2761    app.clear_grounded_recovery_cache();
2762    app.clear_pending_attachments();
2763    app.current_objective = "Idle".into();
2764}
2765
2766fn request_stop(app: &mut App) {
2767    app.voice_manager.stop();
2768    if app.stop_requested {
2769        return;
2770    }
2771    app.stop_requested = true;
2772    app.cancel_token
2773        .store(true, std::sync::atomic::Ordering::SeqCst);
2774    if app.thinking || app.agent_running {
2775        app.write_session_report();
2776        app.copy_transcript_to_clipboard();
2777        app.push_message(
2778            "System",
2779            "Cancellation requested. Logs copied to clipboard.",
2780        );
2781    }
2782}
2783
2784fn show_help_message(app: &mut App) {
2785    app.push_message(
2786        "System",
2787        "Hematite Command Inventory\n\n\
2788         [IT & Remediation Tools] (0-Model Logic)\n\
2789         /triage [preset] - Run IT triage logic (health, security, connectivity, identity, updates)\n\
2790         /health          - Alias for /triage (deterministic health report)\n\
2791         /fix <issue>     - Generate a targeted fix plan for a specific issue\n\
2792         /inspect <topic> - Run a specific host inspection topic (e.g., /inspect connectivity)\n\
2793         /diagnose        - Run staged health triage with agent handoff\n\
2794         /export [fmt]    - Generate and save a full diagnostic report (md|html|json)\n\
2795         /explain <text>  - Paste an error to get a non-technical breakdown\n\n\
2796         [Agent Workflow Modes]\n\
2797         /chat            - Conversation mode (no tool noise)\n\
2798         /agent           - Full coding harness + workstation mode (tools active)\n\
2799         /auto            - Let Hematite choose the narrowest effective workflow\n\
2800         /ask, /code      - Sticky Analysis or Implementation modes\n\
2801         /architect       - Plan-first mode (inspect and approach before edit)\n\
2802         /teach           - Guided walkthrough mode (no-execute)\n\n\
2803         [Context & Memory Management]\n\
2804         /new             - Fresh task context (clear chat/pins/task files)\n\
2805         /forget          - Hard forget (purge chat + saved memory + Vein index)\n\
2806         /clear           - Clear dialogue display only\n\
2807         /attach, /image  - Attach document or image for next message\n\
2808         /detach          - Drop pending attachments\n\
2809         /vein-inspect    - Inspect RAG memory and active room bias\n\n\
2810         [System & Runtime]\n\
2811         /runtime [fix]   - Show or fix live provider/model/embed status\n\
2812         /model, /embed   - List, load, unload, or prefer specific models\n\
2813         /lsp             - Start Language Servers (semantic intelligence)\n\
2814         /think, /no_think - Toggle deep reasoning mode (reasoning is 3-5x slower)\n\
2815         /undo            - Revert last file change\n\
2816         /version, /about - Show build and product info\n\n\
2817         [Navigation & Filesystem]\n\
2818         /cd <path>       - Teleport to another directory\n\
2819         /ls [path]       - List locations or subdirectories\n\n\
2820         Hotkeys: Ctrl+B (Brief), Ctrl+P (Professional), Ctrl+Y (Auto-approve), Ctrl+Z (Undo), Ctrl+C (Quit), ESC (Silence)"
2821    );
2822}
2823
2824#[allow(dead_code)]
2825fn show_help_message_legacy(app: &mut App) {
2826    app.push_message("System",
2827        "Hematite Commands:\n\
2828         /chat             — (Mode) Conversation mode — clean chat, no tool noise\n\
2829         /agent            — (Mode) Full coding harness + workstation mode — tools, file edits, builds, inspection\n\
2830         /reroll           — (Soul) Hatch a new companion mid-session\n\
2831         /auto             — (Flow) Let Hematite choose the narrowest effective workflow\n\
2832         /ask [prompt]     — (Flow) Read-only analysis mode; optional inline prompt\n\
2833         /code [prompt]    — (Flow) Explicit implementation mode; optional inline prompt\n\
2834         /architect [prompt] — (Flow) Plan-first mode; optional inline prompt\n\
2835         /implement-plan   — (Flow) Execute the saved architect handoff in /code\n\
2836         /read-only [prompt] — (Flow) Hard read-only mode; optional inline prompt\n\
2837         /teach [prompt]   — (Flow) Teacher mode; inspect machine then walk you through any admin task step-by-step\n\
2838           /new              — (Reset) Fresh task context; clear chat, pins, and task files\n\
2839           /forget           — (Wipe) Hard forget; purge saved memory and Vein index too\n\
2840           /vein-inspect     — (Vein) Inspect indexed memory, hot files, and active room bias\n\
2841           /workspace-profile — (Profile) Show the auto-generated workspace profile\n\
2842           /rules            — (Rules) View project guidance (CLAUDE.md, SKILLS.md, .hematite/rules.md)\n\
2843           /version          — (Build) Show the running Hematite version\n\
2844           /about            — (Info) Show author, repo, and product info\n\
2845           /vein-reset       — (Vein) Wipe the RAG index; rebuilds automatically on next turn\n\
2846           /clear            — (UI) Clear dialogue display only\n\
2847         /health           — (Diag) Run a synthesized plain-English system health report\n\
2848         /explain <text>   — (Help) Paste an error to get a non-technical breakdown\n\
2849         /gemma-native [auto|on|off|status] — (Model) Auto/force/disable Gemma 4 native formatting\n\
2850         /provider [status|lmstudio|ollama|clear|URL] — (Model) Show or save the active provider endpoint preference\n\
2851         /runtime          — (Model) Show the live runtime/provider/model/embed status and shortest fix path\n\
2852         /runtime fix      — (Model) Run the shortest safe runtime recovery step now\n\
2853         /runtime-refresh  — (Model) Re-read active provider model + CTX now\n\
2854         /model [status|list [available|loaded]|load <id> [--ctx N]|unload [id|current|all]|prefer <id>|clear] — (Model) Inspect, list, load, unload, or save the preferred coding model (`--ctx` uses LM Studio context length or Ollama `num_ctx`)\n\
2855         /embed [status|load <id>|unload [id|current]|prefer <id>|clear] — (Model) Inspect, load, unload, or save the preferred embed model\n\
2856         /undo             — (Ghost) Revert last file change\n\
2857         /diff             — (Git) Show session changes (--stat)\n\
2858         /lsp              — (Logic) Start Language Servers (semantic intelligence)\n\
2859         /swarm <text>     — (Swarm) Spawn parallel workers on a directive\n\
2860         /worktree <cmd>   — (Isolated) Manage git worktrees (list|add|remove|prune)\n\
2861         /think            — (Brain) Enable deep reasoning mode\n\
2862         /no_think         — (Speed) Disable reasoning (3-5x faster responses)\n\
2863         /voice            — (TTS) List all available voices\n\
2864         /voice N          — (TTS) Select voice by number\n\
2865         /read <text>      — (TTS) Speak text aloud directly, bypassing the model. ESC to stop.\n\
2866         /explain <text>   — (Plain English) Paste any error or output; Hematite explains it in plain English.\n\
2867         /health           — (SysAdmin) Run a full system health report (disk, RAM, tools, recent errors).\n\
2868         /attach <path>    — (Docs) Attach a PDF/markdown/txt file for next message\n\
2869         /attach-pick      — (Docs) Open a file picker and attach a document\n\
2870         /image <path>     — (Vision) Attach an image for the next message\n\
2871         /image-pick       — (Vision) Open a file picker and attach an image\n\
2872         /detach           — (Context) Drop pending document/image attachments\n\
2873         /copy             — (Debug) Copy session transcript to clipboard\n\
2874         /copy2            — (Debug) Copy the full SPECULAR rail to clipboard (reasoning + events)\n\
2875         \nHotkeys:\n\
2876         Ctrl+B — Toggle Brief Mode (minimal output; collapses side chrome)\n\
2877         Alt+↑/↓ — Scroll the SPECULAR rail by 3 lines\n\
2878         Alt+PgUp/PgDn — Scroll the SPECULAR rail by 10 lines\n\
2879         Alt+End — Snap SPECULAR back to live follow mode\n\
2880         Ctrl+P — Toggle Professional Mode (strip personality)\n\
2881         Ctrl+O — Open document picker for next-turn context\n\
2882         Ctrl+I — Open image picker for next-turn vision context\n\
2883         Ctrl+Y — Toggle Approvals Off (bypass safety approvals)\n\
2884         Ctrl+S — Quick Swarm (hardcoded bootstrap)\n\
2885         Ctrl+Z — Undo last edit\n\
2886         Ctrl+Q/C — Quit session\n\
2887         ESC    — Silence current playback\n\
2888         \nStatus Legend:\n\
2889         LM/OL — Provider runtime health (`LIVE`, `RECV`, `WARN`, `CEIL`, `STALE`, `BOOT`)\n\
2890         RT    — Primary runtime issue (`OK`, `MOD`, `NET`, `EMP`, `CTX`, `WAIT`)\n\
2891         VN    — Vein RAG status (`SEM`=semantic active, `FTS`=BM25 only, `--`=not indexed)\n\
2892         BUD   — Total prompt-budget pressure against the live context window\n\
2893         CMP   — History compaction pressure against Hematite's adaptive threshold\n\
2894         ERR   — Session error count (runtime, tool, or SPECULAR failures)\n\
2895         CTX   — Live context window currently reported by the provider\n\
2896         VOICE — Local speech output state\n\
2897         \nAssistant: Semantic Pathing (LSP), Vision Pass, Web Research, Swarm Synthesis"
2898    );
2899    app.push_message(
2900        "System",
2901        "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.",
2902    );
2903}
2904
2905fn trigger_input_action(app: &mut App, action: InputAction) {
2906    match action {
2907        InputAction::Stop => request_stop(app),
2908        InputAction::PickDocument => match pick_attachment_path(AttachmentPickerKind::Document) {
2909            Ok(Some(path)) => attach_document_from_path(app, &path),
2910            Ok(None) => app.push_message("System", "Document picker cancelled."),
2911            Err(e) => app.push_message("System", &e),
2912        },
2913        InputAction::PickImage => match pick_attachment_path(AttachmentPickerKind::Image) {
2914            Ok(Some(path)) => attach_image_from_path(app, &path),
2915            Ok(None) => app.push_message("System", "Image picker cancelled."),
2916            Err(e) => app.push_message("System", &e),
2917        },
2918        InputAction::Detach => {
2919            app.clear_pending_attachments();
2920            app.push_message(
2921                "System",
2922                "Cleared pending document/image attachments for the next turn.",
2923            );
2924        }
2925        InputAction::New => {
2926            if !app.agent_running {
2927                reset_visible_session_state(app);
2928                app.push_message("You", "/new");
2929                app.agent_running = true;
2930                let _ = app.user_input_tx.try_send(UserTurn::text("/new"));
2931            }
2932        }
2933        InputAction::Forget => {
2934            if !app.agent_running {
2935                app.cancel_token
2936                    .store(true, std::sync::atomic::Ordering::SeqCst);
2937                reset_visible_session_state(app);
2938                app.push_message("You", "/forget");
2939                app.agent_running = true;
2940                app.cancel_token
2941                    .store(false, std::sync::atomic::Ordering::SeqCst);
2942                let _ = app.user_input_tx.try_send(UserTurn::text("/forget"));
2943            }
2944        }
2945        InputAction::Help => show_help_message(app),
2946    }
2947}
2948
2949fn pick_attachment_path(kind: AttachmentPickerKind) -> Result<Option<String>, String> {
2950    #[cfg(target_os = "windows")]
2951    {
2952        let (title, filter) = match kind {
2953            AttachmentPickerKind::Document => (
2954                "Attach document for the next Hematite turn",
2955                "Documents|*.pdf;*.md;*.markdown;*.txt;*.rst|All Files|*.*",
2956            ),
2957            AttachmentPickerKind::Image => (
2958                "Attach image for the next Hematite turn",
2959                "Images|*.png;*.jpg;*.jpeg;*.gif;*.webp|All Files|*.*",
2960            ),
2961        };
2962        let script = format!(
2963            "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 }}"
2964        );
2965        let output = std::process::Command::new("powershell")
2966            .args(["-NoProfile", "-STA", "-Command", &script])
2967            .output()
2968            .map_err(|e| format!("File picker failed: {}", e))?;
2969        if !output.status.success() {
2970            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
2971            return Err(if stderr.is_empty() {
2972                "File picker did not complete successfully.".to_string()
2973            } else {
2974                format!("File picker failed: {}", stderr)
2975            });
2976        }
2977        let selected = String::from_utf8_lossy(&output.stdout).trim().to_string();
2978        if selected.is_empty() {
2979            Ok(None)
2980        } else {
2981            Ok(Some(selected))
2982        }
2983    }
2984    #[cfg(target_os = "macos")]
2985    {
2986        let prompt = match kind {
2987            AttachmentPickerKind::Document => "Choose a document for the next Hematite turn",
2988            AttachmentPickerKind::Image => "Choose an image for the next Hematite turn",
2989        };
2990        let script = format!("POSIX path of (choose file with prompt \"{}\")", prompt);
2991        let output = std::process::Command::new("osascript")
2992            .args(["-e", &script])
2993            .output()
2994            .map_err(|e| format!("File picker failed: {}", e))?;
2995        if output.status.success() {
2996            let selected = String::from_utf8_lossy(&output.stdout).trim().to_string();
2997            if selected.is_empty() {
2998                Ok(None)
2999            } else {
3000                Ok(Some(selected))
3001            }
3002        } else {
3003            Ok(None)
3004        }
3005    }
3006    #[cfg(all(unix, not(target_os = "macos")))]
3007    {
3008        let title = match kind {
3009            AttachmentPickerKind::Document => "Attach document for the next Hematite turn",
3010            AttachmentPickerKind::Image => "Attach image for the next Hematite turn",
3011        };
3012        let output = std::process::Command::new("zenity")
3013            .args(["--file-selection", "--title", title])
3014            .output()
3015            .map_err(|e| format!("File picker failed: {}", e))?;
3016        if output.status.success() {
3017            let selected = String::from_utf8_lossy(&output.stdout).trim().to_string();
3018            if selected.is_empty() {
3019                Ok(None)
3020            } else {
3021                Ok(Some(selected))
3022            }
3023        } else {
3024            Ok(None)
3025        }
3026    }
3027}
3028
3029#[allow(clippy::too_many_arguments)]
3030pub async fn run_app<B: Backend>(
3031    terminal: &mut Terminal<B>,
3032    mut specular_rx: Receiver<SpecularEvent>,
3033    mut agent_rx: Receiver<crate::agent::inference::InferenceEvent>,
3034    user_input_tx: tokio::sync::mpsc::Sender<UserTurn>,
3035    mut swarm_rx: Receiver<SwarmMessage>,
3036    swarm_tx: tokio::sync::mpsc::Sender<SwarmMessage>,
3037    swarm_coordinator: Arc<crate::agent::swarm::SwarmCoordinator>,
3038    last_interaction: Arc<Mutex<Instant>>,
3039    cockpit: crate::CliCockpit,
3040    soul: crate::ui::hatch::RustySoul,
3041    professional: bool,
3042    gpu_state: Arc<GpuState>,
3043    git_state: Arc<crate::agent::git_monitor::GitState>,
3044    cancel_token: Arc<std::sync::atomic::AtomicBool>,
3045    voice_manager: Arc<crate::ui::voice::VoiceManager>,
3046) -> Result<(), Box<dyn std::error::Error>> {
3047    let mut app = App {
3048        messages: Vec::new(),
3049        messages_raw: Vec::new(),
3050        specular_logs: Vec::new(),
3051        brief_mode: cockpit.brief,
3052        tick_count: 0,
3053        stats: RustyStats {
3054            debugging: 0,
3055            wisdom: soul.wisdom,
3056            patience: 100.0,
3057            chaos: soul.chaos,
3058            snark: soul.snark,
3059        },
3060        yolo_mode: cockpit.yolo,
3061        awaiting_approval: None,
3062        active_workers: HashMap::new(),
3063        worker_labels: HashMap::new(),
3064        active_review: None,
3065        input: String::new(),
3066        input_history: Vec::new(),
3067        history_idx: None,
3068        thinking: false,
3069        agent_running: false,
3070        stop_requested: false,
3071        current_thought: String::new(),
3072        professional,
3073        last_reasoning: String::new(),
3074        active_context: default_active_context(),
3075        manual_scroll_offset: None,
3076        user_input_tx,
3077        specular_scroll: 0,
3078        specular_auto_scroll: true,
3079        gpu_state,
3080        git_state,
3081        last_input_time: Instant::now(),
3082        cancel_token,
3083        total_tokens: 0,
3084        current_session_cost: 0.0,
3085        model_id: "detecting...".to_string(),
3086        context_length: 0,
3087        prompt_pressure_percent: 0,
3088        prompt_estimated_input_tokens: 0,
3089        prompt_reserved_output_tokens: 0,
3090        prompt_estimated_total_tokens: 0,
3091        compaction_percent: 0,
3092        compaction_estimated_tokens: 0,
3093        compaction_threshold_tokens: 0,
3094        compaction_warned_level: 0,
3095        last_runtime_profile_time: Instant::now(),
3096        vein_file_count: 0,
3097        vein_embedded_count: 0,
3098        vein_docs_only: false,
3099        provider_name: "detecting".to_string(),
3100        provider_endpoint: String::new(),
3101        embed_model_id: None,
3102        provider_state: ProviderRuntimeState::Booting,
3103        last_provider_summary: String::new(),
3104        mcp_state: McpRuntimeState::Unconfigured,
3105        last_mcp_summary: String::new(),
3106        last_operator_checkpoint_state: OperatorCheckpointState::Idle,
3107        last_operator_checkpoint_summary: String::new(),
3108        last_recovery_recipe_summary: String::new(),
3109        think_mode: None,
3110        workflow_mode: "AUTO".into(),
3111        autocomplete_suggestions: Vec::new(),
3112        selected_suggestion: 0,
3113        show_autocomplete: false,
3114        autocomplete_filter: String::new(),
3115        current_objective: "Awaiting objective...".into(),
3116        voice_manager,
3117        voice_loading: false,
3118        voice_loading_progress: 1.0, // Pre-baked weights ready
3119        autocomplete_alias_active: false,
3120        hardware_guard_enabled: true,
3121        session_start: std::time::SystemTime::now(),
3122        soul_name: soul.species.clone(),
3123        attached_context: None,
3124        attached_image: None,
3125        hovered_input_action: None,
3126        teleported_from: cockpit.teleported_from.clone(),
3127        nav_list: Vec::new(),
3128        auto_approve_session: false,
3129        task_start_time: None,
3130        tool_started_at: HashMap::new(),
3131        recent_grounded_results: Vec::new(),
3132    };
3133
3134    // Initial placeholder — streaming will overwrite this with hardware diagnostics
3135    app.push_message("Hematite", "Initialising Engine & Hardware...");
3136
3137    if let Some(origin) = &app.teleported_from {
3138        app.push_message(
3139            "System",
3140            &format!(
3141                "Teleportation complete. You've arrived from {}. Hematite has launched this fresh session to ensure your original terminal remains clean and your context is grounded in this target workspace. What's our next move?",
3142                origin
3143            ),
3144        );
3145    }
3146
3147    // ── Splash Screen ─────────────────────────────────────────────────────────
3148    // Animated splash — redraw every 350ms until Enter or Space.
3149    if !cockpit.no_splash {
3150        loop {
3151            draw_splash(terminal)?;
3152
3153            if event::poll(Duration::from_millis(350))? {
3154                if let Event::Key(key) = event::read()? {
3155                    if key.kind == event::KeyEventKind::Press
3156                        && matches!(key.code, KeyCode::Enter | KeyCode::Char(' '))
3157                    {
3158                        break;
3159                    }
3160                }
3161            }
3162        }
3163    }
3164
3165    if app.teleported_from.is_some()
3166        && crate::tools::plan::consume_teleport_resume_marker()
3167        && crate::tools::plan::load_plan_handoff().is_some()
3168    {
3169        app.workflow_mode = "CODE".into();
3170        app.thinking = true;
3171        app.agent_running = true;
3172        app.push_message(
3173            "System",
3174            "Teleport handoff detected in this project. Resuming from `.hematite/PLAN.md` automatically.",
3175        );
3176        app.push_message("You", "/implement-plan");
3177        let _ = app
3178            .user_input_tx
3179            .try_send(UserTurn::text("/implement-plan"));
3180    }
3181
3182    let mut event_stream = EventStream::new();
3183    let mut ticker = tokio::time::interval(std::time::Duration::from_millis(100));
3184
3185    loop {
3186        // ── Hardware Watchdog ──
3187        let vram_ratio = app.gpu_state.ratio();
3188        if app.hardware_guard_enabled && vram_ratio > 0.95 && !app.brief_mode {
3189            app.brief_mode = true;
3190            app.push_message(
3191                "System",
3192                "🚨 HARDWARE GUARD: VRAM > 95%. Brief Mode auto-enabled to prevent crash.",
3193            );
3194        }
3195
3196        app.sync_task_start_time();
3197        terminal.draw(|f| ui(f, &app))?;
3198
3199        tokio::select! {
3200            _ = ticker.tick() => {
3201                // Increment voice loading progress (estimated 50s total load)
3202                if app.voice_loading && app.voice_loading_progress < 0.98 {
3203                    app.voice_loading_progress += 0.002;
3204                }
3205
3206                let workers = app.active_workers.len() as u64;
3207                let advance = if workers > 0 { workers * 4 + 1 } else { 1 };
3208                // Scale advance to match new 100ms tick (formerly 500ms)
3209                // We keep animations consistent by only advancing tick_count every 5 ticks or scaling.
3210                // Let's just increment every tick but use a larger modulo in animations.
3211                app.tick_count = app.tick_count.wrapping_add(advance);
3212                app.update_objective();
3213            }
3214
3215            // ── Keyboard / mouse input ────────────────────────────────────────
3216            maybe_event = event_stream.next() => {
3217                match maybe_event {
3218                    Some(Ok(Event::Mouse(mouse))) => {
3219                        use crossterm::event::{MouseButton, MouseEventKind};
3220                        let (width, height) = match terminal.size() {
3221                            Ok(s) => (s.width, s.height),
3222                            Err(_) => (80, 24),
3223                        };
3224                        let is_right_side = mouse.column as f64 > width as f64 * 0.65;
3225                        let input_rect = input_rect_for_size(
3226                            Rect { x: 0, y: 0, width, height },
3227                            app.input.len(),
3228                        );
3229                        let title_area = input_title_area(input_rect);
3230
3231                        match mouse.kind {
3232                            MouseEventKind::Moved => {
3233                                let hovered = if mouse.row == title_area.y
3234                                    && mouse.column >= title_area.x
3235                                    && mouse.column < title_area.x + title_area.width
3236                                {
3237                                    input_action_hitboxes(&app, title_area)
3238                                        .into_iter()
3239                                        .find_map(|(action, start, end)| {
3240                                            (mouse.column >= start && mouse.column <= end)
3241                                                .then_some(action)
3242                                        })
3243                                } else {
3244                                    None
3245                                };
3246                                app.hovered_input_action = hovered;
3247                            }
3248                            MouseEventKind::Down(MouseButton::Left) => {
3249                                if mouse.row == title_area.y
3250                                    && mouse.column >= title_area.x
3251                                    && mouse.column < title_area.x + title_area.width
3252                                {
3253                                    for (action, start, end) in input_action_hitboxes(&app, title_area) {
3254                                        if mouse.column >= start && mouse.column <= end {
3255                                            app.hovered_input_action = Some(action);
3256                                            trigger_input_action(&mut app, action);
3257                                            break;
3258                                        }
3259                                    }
3260                                } else {
3261                                    app.hovered_input_action = None;
3262
3263                                    // Check Autocomplete Click
3264                                    if app.show_autocomplete && !app.autocomplete_suggestions.is_empty() {
3265                                        // The popup is rendered at chunks[1].y - (suggestions + 2)
3266                                        // Calculation must match ui() rendering logic exactly
3267                                        let items_len = app.autocomplete_suggestions.len();
3268                                        let popup_h = (items_len as u16 + 2).min(17); // 15 + borders
3269                                        let popup_y = input_rect.y.saturating_sub(popup_h);
3270                                        let popup_x = input_rect.x + 2;
3271                                        let popup_w = input_rect.width.saturating_sub(4);
3272
3273                                        if mouse.row >= popup_y && mouse.row < popup_y + popup_h
3274                                            && mouse.column >= popup_x && mouse.column < popup_x + popup_w
3275                                        {
3276                                            // Clicked inside popup
3277                                            let mouse_relative_y = mouse.row.saturating_sub(popup_y + 1);
3278                                            if mouse_relative_y < items_len as u16 {
3279                                                let clicked_idx = mouse_relative_y as usize;
3280                                                let selected = &app.autocomplete_suggestions[clicked_idx].clone();
3281                                                app.apply_autocomplete_selection(selected);
3282                                            }
3283                                            continue; // Event handled
3284                                        }
3285                                    }
3286                                }
3287                            }
3288                            MouseEventKind::ScrollUp => {
3289                                if is_right_side {
3290                                    // User scrolled up — disable auto-scroll so they can read.
3291                                    scroll_specular_up(&mut app, 3);
3292                                } else {
3293                                    let cur = app.manual_scroll_offset.unwrap_or(0);
3294                                    app.manual_scroll_offset = Some(cur.saturating_add(3));
3295                                }
3296                            }
3297                            MouseEventKind::ScrollDown => {
3298                                if is_right_side {
3299                                    scroll_specular_down(&mut app, 3);
3300                                } else if let Some(cur) = app.manual_scroll_offset {
3301                                    app.manual_scroll_offset = if cur <= 3 { None } else { Some(cur - 3) };
3302                                }
3303                            }
3304                            _ => {}
3305                        }
3306                    }
3307                    Some(Ok(Event::Key(key))) => {
3308                        if key.kind != event::KeyEventKind::Press { continue; }
3309
3310                        // Update idle tracker for DeepReflect.
3311                        { *last_interaction.lock().unwrap() = Instant::now(); }
3312
3313                        // ── Tier-2 Swarm diff review modal (exclusive lock) ───
3314                        if let Some(review) = app.active_review.take() {
3315                            match key.code {
3316                                KeyCode::Char('y') | KeyCode::Char('Y') => {
3317                                    let _ = review.tx.send(ReviewResponse::Accept);
3318                                    app.push_message("System", &format!("Worker {} diff accepted.", review.worker_id));
3319                                }
3320                                KeyCode::Char('n') | KeyCode::Char('N') => {
3321                                    let _ = review.tx.send(ReviewResponse::Reject);
3322                                    app.push_message("System", "Diff rejected.");
3323                                }
3324                                KeyCode::Char('r') | KeyCode::Char('R') => {
3325                                    let _ = review.tx.send(ReviewResponse::Retry);
3326                                    app.push_message("System", "Retrying synthesis…");
3327                                }
3328                                _ => { app.active_review = Some(review); }
3329                            }
3330                            continue;
3331                        }
3332
3333                        // ── High-risk approval modal (exclusive lock) ─────────
3334                        if let Some(mut approval) = app.awaiting_approval.take() {
3335                            // Scroll keys — adjust offset and put approval back.
3336                            let scroll_handled = if approval.diff.is_some() {
3337                                let diff_lines = approval.diff.as_ref().map(|d| d.lines().count()).unwrap_or(0) as u16;
3338                                match key.code {
3339                                    KeyCode::Down | KeyCode::Char('j') => {
3340                                        approval.diff_scroll = approval.diff_scroll.saturating_add(1).min(diff_lines.saturating_sub(1));
3341                                        true
3342                                    }
3343                                    KeyCode::Up | KeyCode::Char('k') => {
3344                                        approval.diff_scroll = approval.diff_scroll.saturating_sub(1);
3345                                        true
3346                                    }
3347                                    KeyCode::PageDown => {
3348                                        approval.diff_scroll = approval.diff_scroll.saturating_add(10).min(diff_lines.saturating_sub(1));
3349                                        true
3350                                    }
3351                                    KeyCode::PageUp => {
3352                                        approval.diff_scroll = approval.diff_scroll.saturating_sub(10);
3353                                        true
3354                                    }
3355                                    _ => false,
3356                                }
3357                            } else {
3358                                false
3359                            };
3360                            if scroll_handled {
3361                                app.awaiting_approval = Some(approval);
3362                                continue;
3363                            }
3364                            match key.code {
3365                                KeyCode::Char('y') | KeyCode::Char('Y') => {
3366                                    if let Some(ref diff) = approval.diff {
3367                                        let added = diff.lines().filter(|l| l.starts_with("+ ")).count();
3368                                        let removed = diff.lines().filter(|l| l.starts_with("- ")).count();
3369                                        app.push_message("System", &format!(
3370                                            "Applied: {} +{} -{}", approval.display, added, removed
3371                                        ));
3372                                    } else {
3373                                        app.push_message("System", &format!("Approved: {}", approval.display));
3374                                    }
3375                                    let _ = approval.responder.send(true);
3376                                }
3377                                KeyCode::Char('a') | KeyCode::Char('A') => {
3378                                    app.auto_approve_session = true;
3379                                    if let Some(ref diff) = approval.diff {
3380                                        let added = diff.lines().filter(|l| l.starts_with("+ ")).count();
3381                                        let removed = diff.lines().filter(|l| l.starts_with("- ")).count();
3382                                        app.push_message("System", &format!(
3383                                            "Applied: {} +{} -{}", approval.display, added, removed
3384                                        ));
3385                                    } else {
3386                                        app.push_message("System", &format!("Approved: {}", approval.display));
3387                                    }
3388                                    app.push_message("System", "🔓 FULL AUTONOMY — All mutations auto-approved for this session.");
3389                                    let _ = approval.responder.send(true);
3390                                }
3391                                KeyCode::Char('n') | KeyCode::Char('N') => {
3392                                    if approval.diff.is_some() {
3393                                        app.push_message("System", "Edit skipped.");
3394                                    } else {
3395                                        app.push_message("System", "Declined.");
3396                                    }
3397                                    let _ = approval.responder.send(false);
3398                                }
3399                                _ => { app.awaiting_approval = Some(approval); }
3400                            }
3401                            continue;
3402                        }
3403
3404                        // ── Normal key bindings ───────────────────────────────
3405                        match key.code {
3406                            KeyCode::Char('q') | KeyCode::Char('c')
3407                                if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
3408                                    app.write_session_report();
3409                                    app.copy_transcript_to_clipboard();
3410                                    break;
3411                                }
3412
3413                            KeyCode::Esc => {
3414                                request_stop(&mut app);
3415                            }
3416
3417                            KeyCode::Char('b') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
3418                                app.brief_mode = !app.brief_mode;
3419                                // If the user manually toggles, silence the hardware guard for this session.
3420                                app.hardware_guard_enabled = false;
3421                                app.push_message("System", &format!("Hardware Guard {}: {}", if app.brief_mode { "ENFORCED" } else { "SILENCED" }, if app.brief_mode { "ON" } else { "OFF" }));
3422                            }
3423                            KeyCode::Char('p') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
3424                                app.professional = !app.professional;
3425                                app.push_message("System", &format!("Professional Harness: {}", if app.professional { "ACTIVE" } else { "DISABLED" }));
3426                            }
3427                            KeyCode::Char('y') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
3428                                app.yolo_mode = !app.yolo_mode;
3429                                app.push_message("System", &format!("Approvals Off: {}", if app.yolo_mode { "ON — all tools auto-approved" } else { "OFF" }));
3430                            }
3431                            KeyCode::Char('t') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
3432                                if !app.voice_manager.is_available() {
3433                                    app.push_message("System", "Voice is not available in this build. Use a packaged release for baked-in voice.");
3434                                } else {
3435                                    let enabled = app.voice_manager.toggle();
3436                                    app.push_message("System", &format!("Voice of Hematite: {}", if enabled { "VIBRANT" } else { "SILENCED" }));
3437                                }
3438                            }
3439                            KeyCode::Char('o') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
3440                                match pick_attachment_path(AttachmentPickerKind::Document) {
3441                                    Ok(Some(path)) => attach_document_from_path(&mut app, &path),
3442                                    Ok(None) => app.push_message("System", "Document picker cancelled."),
3443                                    Err(e) => app.push_message("System", &e),
3444                                }
3445                            }
3446                            KeyCode::Char('i') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
3447                                match pick_attachment_path(AttachmentPickerKind::Image) {
3448                                    Ok(Some(path)) => attach_image_from_path(&mut app, &path),
3449                                    Ok(None) => app.push_message("System", "Image picker cancelled."),
3450                                    Err(e) => app.push_message("System", &e),
3451                                }
3452                            }
3453                            KeyCode::Char('s') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
3454                                app.push_message("Hematite", "Swarm engaged.");
3455                                let swarm_tx_c = swarm_tx.clone();
3456                                let coord_c = swarm_coordinator.clone();
3457                                // Hardware-aware swarm: Limit workers if GPU is busy.
3458                                let max_workers = if app.gpu_state.ratio() > 0.70 { 1 } else { 3 };
3459                                if max_workers < 3 {
3460                                    app.push_message("System", "Hardware Guard: Limiting swarm to 1 worker due to GPU load.");
3461                                }
3462
3463                                app.agent_running = true;
3464                                tokio::spawn(async move {
3465                                    let payload = r#"<worker_task id="1" target="src/ui/tui.rs">Implement Swarm Layout</worker_task>
3466<worker_task id="2" target="src/agent/swarm.rs">Build Scratchpad constraints</worker_task>
3467<worker_task id="3" target="docs">Update Readme</worker_task>"#;
3468                                    let tasks = crate::agent::parser::parse_master_spec(payload);
3469                                    let _ = coord_c.dispatch_swarm(tasks, swarm_tx_c, max_workers).await;
3470                                });
3471                            }
3472                            KeyCode::Char('z') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
3473                                match crate::tools::file_ops::pop_ghost_ledger() {
3474                                    Ok(msg) => {
3475                                        app.specular_logs.push(format!("GHOST: {}", msg));
3476                                        trim_vec(&mut app.specular_logs, 7);
3477                                        app.push_message("System", &msg);
3478                                    }
3479                                    Err(e) => {
3480                                        app.push_message("System", &format!("Undo failed: {}", e));
3481                                    }
3482                                }
3483                            }
3484                            KeyCode::Up
3485                                if key.modifiers.contains(event::KeyModifiers::ALT) =>
3486                            {
3487                                scroll_specular_up(&mut app, 3);
3488                            }
3489                            KeyCode::Down
3490                                if key.modifiers.contains(event::KeyModifiers::ALT) =>
3491                            {
3492                                scroll_specular_down(&mut app, 3);
3493                            }
3494                            KeyCode::PageUp
3495                                if key.modifiers.contains(event::KeyModifiers::ALT) =>
3496                            {
3497                                scroll_specular_up(&mut app, 10);
3498                            }
3499                            KeyCode::PageDown
3500                                if key.modifiers.contains(event::KeyModifiers::ALT) =>
3501                            {
3502                                scroll_specular_down(&mut app, 10);
3503                            }
3504                            KeyCode::End
3505                                if key.modifiers.contains(event::KeyModifiers::ALT) =>
3506                            {
3507                                follow_live_specular(&mut app);
3508                                app.push_message(
3509                                    "System",
3510                                    "SPECULAR snapped back to live follow mode.",
3511                                );
3512                            }
3513                            KeyCode::Up => {
3514                                if app.show_autocomplete && !app.autocomplete_suggestions.is_empty() {
3515                                    app.selected_suggestion = app.selected_suggestion.saturating_sub(1);
3516                                } else if app.manual_scroll_offset.is_some() {
3517                                    // Protect history: Use Up as a scroll fallback if already scrolling.
3518                                    let cur = app.manual_scroll_offset.unwrap();
3519                                    app.manual_scroll_offset = Some(cur.saturating_add(3));
3520                                } else if !app.input_history.is_empty() {
3521                                    // Only cycle history if we are at the bottom of the chat.
3522                                    let new_idx = match app.history_idx {
3523                                        None => app.input_history.len() - 1,
3524                                        Some(i) => i.saturating_sub(1),
3525                                    };
3526                                    app.history_idx = Some(new_idx);
3527                                    app.input = app.input_history[new_idx].clone();
3528                                }
3529                            }
3530                            KeyCode::Down => {
3531                                if app.show_autocomplete && !app.autocomplete_suggestions.is_empty() {
3532                                    app.selected_suggestion = (app.selected_suggestion + 1).min(app.autocomplete_suggestions.len().saturating_sub(1));
3533                                } else if let Some(off) = app.manual_scroll_offset {
3534                                    if off <= 3 { app.manual_scroll_offset = None; }
3535                                    else { app.manual_scroll_offset = Some(off.saturating_sub(3)); }
3536                                } else if let Some(i) = app.history_idx {
3537                                    if i + 1 < app.input_history.len() {
3538                                        app.history_idx = Some(i + 1);
3539                                        app.input = app.input_history[i + 1].clone();
3540                                    } else {
3541                                        app.history_idx = None;
3542                                        app.input.clear();
3543                                    }
3544                                }
3545                            }
3546                            KeyCode::PageUp => {
3547                                let cur = app.manual_scroll_offset.unwrap_or(0);
3548                                app.manual_scroll_offset = Some(cur.saturating_add(10));
3549                            }
3550                            KeyCode::PageDown => {
3551                                if let Some(off) = app.manual_scroll_offset {
3552                                    if off <= 10 { app.manual_scroll_offset = None; }
3553                                    else { app.manual_scroll_offset = Some(off.saturating_sub(10)); }
3554                                }
3555                            }
3556                            KeyCode::Tab
3557                                if app.show_autocomplete
3558                                    && !app.autocomplete_suggestions.is_empty() =>
3559                            {
3560                                let selected = app.autocomplete_suggestions[app.selected_suggestion].clone();
3561                                app.apply_autocomplete_selection(&selected);
3562                            }
3563                            KeyCode::Char(c) => {
3564                                app.history_idx = None; // typing cancels history nav
3565                                app.input.push(c);
3566                                app.last_input_time = Instant::now();
3567
3568                                if c == '@' {
3569                                    app.show_autocomplete = true;
3570                                    app.autocomplete_filter.clear();
3571                                    app.selected_suggestion = 0;
3572                                    app.update_autocomplete();
3573                                } else if app.show_autocomplete {
3574                                    app.autocomplete_filter.push(c);
3575                                    app.update_autocomplete();
3576                                }
3577                            }
3578                            KeyCode::Backspace => {
3579                                app.input.pop();
3580                                if app.show_autocomplete {
3581                                    if app.input.ends_with('@') || !app.input.contains('@') {
3582                                        app.show_autocomplete = false;
3583                                        app.autocomplete_filter.clear();
3584                                    } else {
3585                                        app.autocomplete_filter.pop();
3586                                        app.update_autocomplete();
3587                                    }
3588                                }
3589                            }
3590                            KeyCode::Enter => {
3591                                if app.show_autocomplete
3592                                    && !app.autocomplete_suggestions.is_empty()
3593                                    && should_accept_autocomplete_on_enter(
3594                                        app.autocomplete_alias_active,
3595                                        &app.autocomplete_filter,
3596                                    )
3597                                {
3598                                    let selected = app.autocomplete_suggestions[app.selected_suggestion].clone();
3599                                    app.apply_autocomplete_selection(&selected);
3600                                    continue;
3601                                }
3602
3603                                if !app.input.is_empty()
3604                                    && (!app.agent_running
3605                                        || is_immediate_local_command(&app.input))
3606                                {
3607                                    // PASTE GUARD: If a newline arrives within 50ms of a character,
3608                                    // it's almost certainly part of a paste stream. Convert to space.
3609                                    if Instant::now().duration_since(app.last_input_time) < std::time::Duration::from_millis(50) {
3610                                        app.input.push(' ');
3611                                        app.last_input_time = Instant::now();
3612                                        continue;
3613                                    }
3614
3615                                    let input_text = app.input.drain(..).collect::<String>();
3616
3617                                    // ── Slash Command Processor ──────────────────────────
3618                                    if input_text.starts_with('/') {
3619                                        let parts: Vec<&str> = input_text.split_whitespace().collect();
3620                                        let cmd = parts[0].to_lowercase();
3621                                        match cmd.as_str() {
3622                                            "/undo" => {
3623                                                match crate::tools::file_ops::pop_ghost_ledger() {
3624                                                    Ok(msg) => {
3625                                                        app.specular_logs.push(format!("GHOST: {}", msg));
3626                                                        trim_vec(&mut app.specular_logs, 7);
3627                                                        app.push_message("System", &msg);
3628                                                    }
3629                                                    Err(e) => {
3630                                                        app.push_message("System", &format!("Undo failed: {}", e));
3631                                                    }
3632                                                }
3633                                                app.history_idx = None;
3634                                                continue;
3635                                            }
3636                                            "/clear" => {
3637                                                reset_visible_session_state(&mut app);
3638                                                app.push_message("System", "Dialogue buffer cleared.");
3639                                                app.history_idx = None;
3640                                                continue;
3641                                            }
3642                                            "/cd" => {
3643                                                if parts.len() < 2 {
3644                                                    app.push_message("System", "Usage: /cd <path>  — teleport to any directory. Supports bare tokens like downloads, desktop, docs, pictures, videos, music, home, temp, bare ~, aliases like @DESKTOP/project, plus .. and absolute paths. Tip: run /ls desktop first if you want a numbered picker.");
3645                                                    app.history_idx = None;
3646                                                    continue;
3647                                                }
3648                                                let raw = parts[1..].join(" ");
3649                                                let target = crate::tools::file_ops::resolve_candidate(&raw);
3650                                                if !target.exists() {
3651                                                    app.push_message("System", &format!("Directory not found: {}", target.display()));
3652                                                    app.history_idx = None;
3653                                                    continue;
3654                                                }
3655                                                if !target.is_dir() {
3656                                                    app.push_message("System", &format!("Not a directory: {}", target.display()));
3657                                                    app.history_idx = None;
3658                                                    continue;
3659                                                }
3660                                                let target_str = target.to_string_lossy().to_string();
3661                                                app.push_message("You", &format!("/cd {}", raw));
3662                                                app.push_message("System", &format!("Teleporting to {}...", target_str));
3663                                                app.push_message("System", "Launching new session. This terminal will close.");
3664                                                spawn_dive_in_terminal(&target_str);
3665                                                app.write_session_report();
3666                                                app.copy_transcript_to_clipboard();
3667                                                break;
3668                                            }
3669                                            "/ls" => {
3670                                                let base: std::path::PathBuf = if parts.len() >= 2 {
3671                                                    // /ls <path> or /ls <N>
3672                                                    let arg = parts[1..].join(" ");
3673                                                    if let Ok(n) = arg.trim().parse::<usize>() {
3674                                                        // /ls <N> — teleport to nav_list entry N
3675                                                        if n == 0 || n > app.nav_list.len() {
3676                                                            app.push_message("System", &format!("No entry {}. Run /ls first to see the list.", n));
3677                                                            app.history_idx = None;
3678                                                            continue;
3679                                                        }
3680                                                        let target = app.nav_list[n - 1].clone();
3681                                                        let target_str = target.to_string_lossy().to_string();
3682                                                        app.push_message("You", &format!("/ls {}", n));
3683                                                        app.push_message("System", &format!("Teleporting to {}...", target_str));
3684                                                        app.push_message("System", "Launching new session. This terminal will close.");
3685                                                        spawn_dive_in_terminal(&target_str);
3686                                                        app.write_session_report();
3687                                                        app.copy_transcript_to_clipboard();
3688                                                        break;
3689                                                    } else {
3690                                                        crate::tools::file_ops::resolve_candidate(&arg)
3691                                                    }
3692                                                } else {
3693                                                    std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
3694                                                };
3695
3696                                                // Build numbered nav list
3697                                                let mut entries: Vec<std::path::PathBuf> = Vec::new();
3698                                                let mut output = String::with_capacity(1024);
3699
3700                                                // Common locations (only when listing current/no-arg)
3701                                                let listing_base = parts.len() < 2;
3702                                                if listing_base {
3703                                                    let common: Vec<(&str, Option<std::path::PathBuf>)> = vec![
3704                                                        ("Desktop", dirs::desktop_dir()),
3705                                                        ("Downloads", dirs::download_dir()),
3706                                                        ("Documents", dirs::document_dir()),
3707                                                        ("Pictures", dirs::picture_dir()),
3708                                                        ("Home", dirs::home_dir()),
3709                                                    ];
3710                                                    let valid: Vec<_> = common.into_iter().filter_map(|(label, p)| p.map(|pb| (label, pb))).collect();
3711                                                    if !valid.is_empty() {
3712                                                        output.push_str("Common locations:\n");
3713                                                        for (label, pb) in &valid {
3714                                                            entries.push(pb.clone());
3715                                                            let _ = writeln!(output, "  {:>2}.  {:<12}  {}", entries.len(), label, pb.display());
3716                                                        }
3717                                                    }
3718                                                }
3719
3720                                                // Subdirectories of base path
3721                                                let cwd_label = if listing_base {
3722                                                    std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
3723                                                } else {
3724                                                    base.clone()
3725                                                };
3726                                                if let Ok(read) = std::fs::read_dir(&cwd_label) {
3727                                                    let mut dirs_found: Vec<std::path::PathBuf> = read
3728                                                        .filter_map(|e| e.ok())
3729                                                        .filter(|e| e.path().is_dir())
3730                                                        .map(|e| e.path())
3731                                                        .collect();
3732                                                    dirs_found.sort_unstable();
3733                                                    if !dirs_found.is_empty() {
3734                                                        let _ = write!(output, "\n{}:\n", cwd_label.display());
3735                                                        for pb in &dirs_found {
3736                                                            entries.push(pb.clone());
3737                                                            let name = pb.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
3738                                                            let _ = writeln!(output, "  {:>2}.  {}", entries.len(), name);
3739                                                        }
3740                                                    }
3741                                                }
3742
3743                                                if entries.is_empty() {
3744                                                    app.push_message("System", "No directories found.");
3745                                                } else {
3746                                                    output.push_str("\nType /ls <N> to teleport to that directory.");
3747                                                    app.nav_list = entries;
3748                                                    app.push_message("System", &output);
3749                                                }
3750                                                app.history_idx = None;
3751                                                continue;
3752                                            }
3753                                            "/diff" => {
3754                                                app.push_message("System", "Fetching session diff...");
3755                                                let ws = crate::tools::file_ops::workspace_root();
3756                                                if crate::agent::git::is_git_repo(&ws) {
3757                                                    let output = std::process::Command::new("git")
3758                                                        .args(["diff", "--stat"])
3759                                                        .current_dir(ws)
3760                                                        .output();
3761                                                    if let Ok(out) = output {
3762                                                        let stat = String::from_utf8_lossy(&out.stdout).into_owned();
3763                                                        app.push_message("System", if stat.is_empty() { "No changes detected." } else { &stat });
3764                                                    }
3765                                                } else {
3766                                                    app.push_message("System", "Not a git repository. Diff limited.");
3767                                                }
3768                                                app.history_idx = None;
3769                                                continue;
3770                                            }
3771                                            "/vein-reset" => {
3772                                                app.vein_file_count = 0;
3773                                                app.vein_embedded_count = 0;
3774                                                app.push_message("You", "/vein-reset");
3775                                                app.agent_running = true;
3776                                                let _ = app.user_input_tx.try_send(UserTurn::text("/vein-reset"));
3777                                                app.history_idx = None;
3778                                                continue;
3779                                            }
3780                                            "/vein-inspect" => {
3781                                                app.push_message("You", "/vein-inspect");
3782                                                app.agent_running = true;
3783                                                let _ = app.user_input_tx.try_send(UserTurn::text("/vein-inspect"));
3784                                                app.history_idx = None;
3785                                                continue;
3786                                            }
3787                                            "/workspace-profile" => {
3788                                                app.push_message("You", "/workspace-profile");
3789                                                app.agent_running = true;
3790                                                let _ = app.user_input_tx.try_send(UserTurn::text("/workspace-profile"));
3791                                                app.history_idx = None;
3792                                                continue;
3793                                            }
3794                                            "/copy" => {
3795                                                app.copy_transcript_to_clipboard();
3796                                                app.push_message("System", "Exact session transcript copied to clipboard (includes help/system output).");
3797                                                app.history_idx = None;
3798                                                continue;
3799                                            }
3800                                            "/copy-last" => {
3801                                                if app.copy_last_reply_to_clipboard() {
3802                                                    app.push_message("System", "Latest Hematite reply copied to clipboard.");
3803                                                } else {
3804                                                    app.push_message("System", "No Hematite reply is available to copy yet.");
3805                                                }
3806                                                app.history_idx = None;
3807                                                continue;
3808                                            }
3809                                            "/copy-clean" => {
3810                                                app.copy_clean_transcript_to_clipboard();
3811                                                app.push_message("System", "Clean chat transcript copied to clipboard (skips help/debug boilerplate).");
3812                                                app.history_idx = None;
3813                                                continue;
3814                                            }
3815                                            "/copy2" => {
3816                                                app.copy_specular_to_clipboard();
3817                                                app.push_message("System", "SPECULAR log copied to clipboard (reasoning + events).");
3818                                                app.history_idx = None;
3819                                                continue;
3820                                            }
3821                                            "/voice" => {
3822                                                use crate::ui::voice::VOICE_LIST;
3823                                                if let Some(arg) = parts.get(1) {
3824                                                    // /voice N — select by number
3825                                                    if let Ok(n) = arg.parse::<usize>() {
3826                                                        let idx = n.saturating_sub(1);
3827                                                        if let Some(&(id, label)) = VOICE_LIST.get(idx) {
3828                                                            app.voice_manager.set_voice(id);
3829                                                            let _ = crate::agent::config::set_voice(id);
3830                                                            app.push_message("System", &format!("Voice set to {} — {}", id, label));
3831                                                        } else {
3832                                                            app.push_message("System", &format!("Invalid voice number. Use /voice to list voices (1–{}).", VOICE_LIST.len()));
3833                                                        }
3834                                                    } else {
3835                                                        // /voice af_bella — select by name
3836                                                        if let Some(&(id, label)) = VOICE_LIST.iter().find(|&&(id, _)| id == *arg) {
3837                                                            app.voice_manager.set_voice(id);
3838                                                            let _ = crate::agent::config::set_voice(id);
3839                                                            app.push_message("System", &format!("Voice set to {} — {}", id, label));
3840                                                        } else {
3841                                                            app.push_message("System", &format!("Unknown voice '{}'. Use /voice to list voices.", arg));
3842                                                        }
3843                                                    }
3844                                                } else {
3845                                                    // /voice — list all voices
3846                                                    let current = app.voice_manager.current_voice_id();
3847                                                    let mut list = format!("Available voices (current: {}):\n", current);
3848                                                    for (i, &(id, label)) in VOICE_LIST.iter().enumerate() {
3849                                                        let marker = if id == current.as_str() { " ◀" } else { "" };
3850                                                        let _ = writeln!(list, "  {:>2}. {}{}", i + 1, label, marker);
3851                                                    }
3852                                                    list.push_str("\nUse /voice N or /voice <id> to select.");
3853                                                    app.push_message("System", &list);
3854                                                }
3855                                                app.history_idx = None;
3856                                                continue;
3857                                            }
3858                                            "/read" => {
3859                                                let text = parts[1..].join(" ");
3860                                                if text.is_empty() {
3861                                                    app.push_message("System", "Usage: /read <text to speak>");
3862                                                } else if !app.voice_manager.is_available() {
3863                                                    app.push_message("System", "Voice is not available in this build. Use a packaged release for baked-in voice.");
3864                                                } else if !app.voice_manager.is_enabled() {
3865                                                    app.push_message("System", "Voice is off. Press Ctrl+T to enable, then /read again.");
3866                                                } else {
3867                                                    app.push_message("System", &format!("Reading {} words aloud. ESC to stop.", text.split_whitespace().count()));
3868                                                    app.voice_manager.speak(text.clone());
3869                                                }
3870                                                app.history_idx = None;
3871                                                continue;
3872                                            }
3873                                            "/new" => {
3874                                                reset_visible_session_state(&mut app);
3875                                                app.push_message("You", "/new");
3876                                                app.agent_running = true;
3877                                                app.clear_pending_attachments();
3878                                                let _ = app.user_input_tx.try_send(UserTurn::text("/new"));
3879                                                app.history_idx = None;
3880                                                continue;
3881                                            }
3882                                            "/forget" => {
3883                                                // Cancel any running turn so /forget isn't queued behind retries.
3884                                                app.cancel_token.store(true, std::sync::atomic::Ordering::SeqCst);
3885                                                reset_visible_session_state(&mut app);
3886                                                app.push_message("You", "/forget");
3887                                                app.agent_running = true;
3888                                                app.cancel_token.store(false, std::sync::atomic::Ordering::SeqCst);
3889                                                app.clear_pending_attachments();
3890                                                let _ = app.user_input_tx.try_send(UserTurn::text("/forget"));
3891                                                app.history_idx = None;
3892                                                continue;
3893                                            }
3894                                            "/gemma-native" => {
3895                                                let sub = parts.get(1).copied().unwrap_or("status").to_ascii_lowercase();
3896                                                let gemma_detected = crate::agent::inference::is_hematite_native_model(&app.model_id);
3897                                                match sub.as_str() {
3898                                                    "auto" => {
3899                                                        match crate::agent::config::set_gemma_native_mode("auto") {
3900                                                            Ok(_) => {
3901                                                                if gemma_detected {
3902                                                                    app.push_message("System", "Gemma Native Formatting: AUTO. Gemma 4 will use native formatting automatically on the next turn.");
3903                                                                } else {
3904                                                                    app.push_message("System", "Gemma Native Formatting: AUTO in settings. It will activate automatically when a Gemma 4 model is loaded.");
3905                                                                }
3906                                                            }
3907                                                            Err(e) => app.push_message("System", &format!("Failed to update settings: {}", e)),
3908                                                        }
3909                                                    }
3910                                                    "on" => {
3911                                                        match crate::agent::config::set_gemma_native_mode("on") {
3912                                                            Ok(_) => {
3913                                                                if gemma_detected {
3914                                                                    app.push_message("System", "Gemma Native Formatting: ON (forced). It will apply on the next turn.");
3915                                                                } else {
3916                                                                    app.push_message("System", "Gemma Native Formatting: ON (forced) in settings. It will activate only when a Gemma 4 model is loaded.");
3917                                                                }
3918                                                            }
3919                                                            Err(e) => app.push_message("System", &format!("Failed to update settings: {}", e)),
3920                                                        }
3921                                                    }
3922                                                    "off" => {
3923                                                        match crate::agent::config::set_gemma_native_mode("off") {
3924                                                            Ok(_) => app.push_message("System", "Gemma Native Formatting: OFF."),
3925                                                            Err(e) => app.push_message("System", &format!("Failed to update settings: {}", e)),
3926                                                        }
3927                                                    }
3928                                                    _ => {
3929                                                        let config = crate::agent::config::load_config();
3930                                                        let mode = crate::agent::config::gemma_native_mode_label(&config, &app.model_id);
3931                                                        let enabled = match mode {
3932                                                            "on" => "ON (forced)",
3933                                                            "auto" => "ON (auto)",
3934                                                            "off" => "OFF",
3935                                                            _ => "INACTIVE",
3936                                                        };
3937                                                        let model_note = if gemma_detected {
3938                                                            "Gemma 4 detected."
3939                                                        } else {
3940                                                            "Current model is not Gemma 4."
3941                                                        };
3942                                                        app.push_message(
3943                                                            "System",
3944                                                            &format!(
3945                                                                "Gemma Native Formatting: {}. {} Usage: /gemma-native auto | on | off | status",
3946                                                                enabled, model_note
3947                                                            ),
3948                                                        );
3949                                                    }
3950                                                }
3951                                                app.history_idx = None;
3952                                                continue;
3953                                            }
3954                                            "/chat" => {
3955                                                app.workflow_mode = "CHAT".into();
3956                                                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.");
3957                                                app.history_idx = None;
3958                                                let _ = app.user_input_tx.try_send(UserTurn::text("/chat"));
3959                                                continue;
3960                                            }
3961                                            "/reroll" => {
3962                                                app.history_idx = None;
3963                                                let _ = app.user_input_tx.try_send(UserTurn::text("/reroll"));
3964                                                continue;
3965                                            }
3966                                            "/agent" => {
3967                                                app.workflow_mode = "AUTO".into();
3968                                                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.");
3969                                                app.history_idx = None;
3970                                                let _ = app.user_input_tx.try_send(UserTurn::text("/agent"));
3971                                                continue;
3972                                            }
3973                                            "/implement-plan" => {
3974                                                app.workflow_mode = "CODE".into();
3975                                                app.push_message("You", "/implement-plan");
3976                                                app.agent_running = true;
3977                                                let _ = app.user_input_tx.try_send(UserTurn::text("/implement-plan"));
3978                                                app.history_idx = None;
3979                                                continue;
3980                                            }
3981                                            "/ask" | "/code" | "/architect" | "/read-only" | "/auto" | "/teach" => {
3982                                                let label = match cmd.as_str() {
3983                                                    "/ask" => "ASK",
3984                                                    "/code" => "CODE",
3985                                                    "/architect" => "ARCHITECT",
3986                                                    "/read-only" => "READ-ONLY",
3987                                                    "/teach" => "TEACH",
3988                                                    _ => "AUTO",
3989                                                };
3990                                                app.workflow_mode = label.to_string();
3991                                                let outbound = input_text.trim().to_string();
3992                                                app.push_message("You", &outbound);
3993                                                app.agent_running = true;
3994                                                let _ = app.user_input_tx.try_send(UserTurn::text(outbound));
3995                                                app.history_idx = None;
3996                                                continue;
3997                                            }
3998                                            "/worktree" => {
3999                                                let sub = parts.get(1).copied().unwrap_or("");
4000                                                match sub {
4001                                                    "list" => {
4002                                                        app.push_message("You", "/worktree list");
4003                                                        app.agent_running = true;
4004                                                        let _ = app.user_input_tx.try_send(UserTurn::text(
4005                                                            "Call git_worktree with action=list"
4006                                                        ));
4007                                                    }
4008                                                    "add" => {
4009                                                        let wt_path = parts.get(2).copied().unwrap_or("");
4010                                                        let wt_branch = parts.get(3).copied().unwrap_or("");
4011                                                        if wt_path.is_empty() {
4012                                                            app.push_message("System", "Usage: /worktree add <path> [branch]");
4013                                                        } else {
4014                                                            app.push_message("You", &format!("/worktree add {wt_path}"));
4015                                                            app.agent_running = true;
4016                                                            let directive = if wt_branch.is_empty() {
4017                                                                format!("Call git_worktree with action=add path={wt_path}")
4018                                                            } else {
4019                                                                format!("Call git_worktree with action=add path={wt_path} branch={wt_branch}")
4020                                                            };
4021                                                            let _ = app.user_input_tx.try_send(UserTurn::text(directive));
4022                                                        }
4023                                                    }
4024                                                    "remove" => {
4025                                                        let wt_path = parts.get(2).copied().unwrap_or("");
4026                                                        if wt_path.is_empty() {
4027                                                            app.push_message("System", "Usage: /worktree remove <path>");
4028                                                        } else {
4029                                                            app.push_message("You", &format!("/worktree remove {wt_path}"));
4030                                                            app.agent_running = true;
4031                                                            let _ = app.user_input_tx.try_send(UserTurn::text(
4032                                                                format!("Call git_worktree with action=remove path={wt_path}")
4033                                                            ));
4034                                                        }
4035                                                    }
4036                                                    "prune" => {
4037                                                        app.push_message("You", "/worktree prune");
4038                                                        app.agent_running = true;
4039                                                        let _ = app.user_input_tx.try_send(UserTurn::text(
4040                                                            "Call git_worktree with action=prune"
4041                                                        ));
4042                                                    }
4043                                                    _ => {
4044                                                        app.push_message("System",
4045                                                            "Usage: /worktree list | add <path> [branch] | remove <path> | prune");
4046                                                    }
4047                                                }
4048                                                app.history_idx = None;
4049                                                continue;
4050                                            }
4051                                            "/think" => {
4052                                                app.think_mode = Some(true);
4053                                                app.push_message("You", "/think");
4054                                                app.agent_running = true;
4055                                                let _ = app.user_input_tx.try_send(UserTurn::text("/think"));
4056                                                app.history_idx = None;
4057                                                continue;
4058                                            }
4059                                            "/no_think" => {
4060                                                app.think_mode = Some(false);
4061                                                app.push_message("You", "/no_think");
4062                                                app.agent_running = true;
4063                                                let _ = app.user_input_tx.try_send(UserTurn::text("/no_think"));
4064                                                app.history_idx = None;
4065                                                continue;
4066                                            }
4067                                            "/lsp" => {
4068                                                app.push_message("You", "/lsp");
4069                                                app.agent_running = true;
4070                                                let _ = app.user_input_tx.try_send(UserTurn::text("/lsp"));
4071                                                app.history_idx = None;
4072                                                continue;
4073                                            }
4074                                            "/runtime-refresh" => {
4075                                                app.push_message("You", "/runtime-refresh");
4076                                                app.agent_running = true;
4077                                                let _ = app.user_input_tx.try_send(UserTurn::text("/runtime-refresh"));
4078                                                app.history_idx = None;
4079                                                continue;
4080                                            }
4081                                            "/rules" => {
4082                                                let sub = parts.get(1).copied().unwrap_or("status").to_ascii_lowercase();
4083                                                let ws_root = crate::tools::file_ops::workspace_root();
4084
4085                                                match sub.as_str() {
4086                                                    "view" => {
4087                                                        let mut combined = String::with_capacity(
4088                                                            crate::agent::instructions::PROJECT_GUIDANCE_FILES.len() * 512,
4089                                                        );
4090                                                        for cand in crate::agent::instructions::PROJECT_GUIDANCE_FILES {
4091                                                            let p = crate::agent::instructions::resolve_guidance_path(&ws_root, cand);
4092                                                            if p.exists() {
4093                                                                if let Ok(c) = std::fs::read_to_string(&p) {
4094                                                                    let _ = writeln!(combined, "--- [{}] ---", cand);
4095                                                                    combined.push_str(&c);
4096                                                                    combined.push_str("\n\n");
4097                                                                }
4098                                                            }
4099                                                        }
4100                                                        if combined.is_empty() {
4101                                                            app.push_message("System", "No project guidance files found (CLAUDE.md, SKILLS.md, .hematite/rules.md, etc.).");
4102                                                        } else {
4103                                                            app.push_message("System", &format!("Current project guidance being injected:\n\n{}", combined));
4104                                                        }
4105                                                    }
4106                                                    "edit" => {
4107                                                        let which = parts.get(2).copied().unwrap_or("local").to_ascii_lowercase();
4108                                                        let target_file = if which == "shared" { "rules.md" } else { "rules.local.md" };
4109                                                        let target_path = crate::tools::file_ops::hematite_dir().join(target_file);
4110
4111                                                        if !target_path.exists() {
4112                                                            if let Some(parent) = target_path.parent() {
4113                                                                let _ = std::fs::create_dir_all(parent);
4114                                                            }
4115                                                            let header = if which == "shared" { "# Project Rules (Shared)" } else { "# Local Guidelines (Private)" };
4116                                                            let _ = std::fs::write(&target_path, format!("{}\n\nAdd behavioral guidelines here for the agent to follow in this workspace.\n", header));
4117                                                        }
4118
4119                                                        match crate::tools::file_ops::open_in_system_editor(&target_path) {
4120                                                            Ok(_) => app.push_message("System", &format!("Opening {} in system editor...", target_path.display())),
4121                                                            Err(e) => app.push_message("System", &format!("Failed to open editor: {}", e)),
4122                                                        }
4123                                                    }
4124                                                    _ => {
4125                                                        let mut status = "Project Guidance:\n".to_string();
4126                                                        for cand in crate::agent::instructions::PROJECT_GUIDANCE_FILES {
4127                                                              let p = crate::agent::instructions::resolve_guidance_path(&ws_root, cand);
4128                                                              let icon = if p.exists() { "[v]" } else { "[ ]" };
4129                                                              let label = crate::agent::instructions::guidance_status_label(cand);
4130                                                              let _ = writeln!(status, "  {} {:<25} {}", icon, cand, label);
4131                                                        }
4132                                                        status.push_str("\nUsage:\n  /rules view        - View combined guidance\n  /rules edit        - Edit personal local rules (ignored by git)\n  /rules edit shared - Edit project-wide shared rules");
4133                                                        app.push_message("System", &status);
4134                                                    }
4135                                                }
4136                                                app.history_idx = None;
4137                                                continue;
4138                                            }
4139                                            "/skills" => {
4140                                                let workspace_root = crate::tools::file_ops::workspace_root();
4141                                                let config = crate::agent::config::load_config();
4142                                                let discovery = crate::agent::instructions::discover_agent_skills(
4143                                                    &workspace_root,
4144                                                    &config.trust,
4145                                                );
4146                                                let report =
4147                                                    crate::agent::instructions::render_skills_report(&discovery);
4148                                                app.push_message("System", &report);
4149                                                app.history_idx = None;
4150                                                continue;
4151                                            }
4152                                            "/help" => {
4153                                                show_help_message(&mut app);
4154                                                app.history_idx = None;
4155                                                continue;
4156                                            }
4157                                            "/help-legacy-unused" => {
4158                                                app.push_message("System",
4159                                                    "Hematite Commands:\n\
4160                                                     /chat             — (Mode) Conversation mode — clean chat, no tool noise\n\
4161                                                     /agent            — (Mode) Full coding harness + workstation mode — tools, file edits, builds, inspection\n\
4162                                                     /reroll           — (Soul) Hatch a new companion mid-session\n\
4163                                                     /auto             — (Flow) Let Hematite choose the narrowest effective workflow\n\
4164                                                     /ask [prompt]     — (Flow) Read-only analysis mode; optional inline prompt\n\
4165                                                     /code [prompt]    — (Flow) Explicit implementation mode; optional inline prompt\n\
4166                                                     /architect [prompt] — (Flow) Plan-first mode; optional inline prompt\n\
4167                                                     /implement-plan   — (Flow) Execute the saved architect handoff in /code\n\
4168                                                     /read-only [prompt] — (Flow) Hard read-only mode; optional inline prompt\n\
4169                                                     /teach [prompt]   — (Flow) Teacher mode; inspect machine then walk you through any admin task step-by-step\n\
4170                                                       /new              — (Reset) Fresh task context; clear chat, pins, and task files\n\
4171                                                       /forget           — (Wipe) Hard forget; purge saved memory and Vein index too\n\
4172                                                       /vein-inspect     — (Vein) Inspect indexed memory, hot files, and active room bias\n\
4173                                                       /workspace-profile — (Profile) Show the auto-generated workspace profile\n\
4174                                                       /rules            — (Rules) View project guidance (CLAUDE.md, SKILLS.md, .hematite/rules.md)\n\
4175                                                       /version          — (Build) Show the running Hematite version\n\
4176                                                       /about            — (Info) Show author, repo, and product info\n\
4177                                                       /vein-reset       — (Vein) Wipe the RAG index; rebuilds automatically on next turn\n\
4178                                                       /clear            — (UI) Clear dialogue display only\n\
4179                                                     /gemma-native [auto|on|off|status] — (Model) Auto/force/disable Gemma 4 native formatting\n\
4180                                                     /provider [status|lmstudio|ollama|clear|URL] — (Model) Show or save the active provider endpoint preference\n\
4181                                                     /runtime          — (Model) Show the live runtime/provider/model/embed status and shortest fix path\n\
4182                                                     /runtime fix      — (Model) Run the shortest safe runtime recovery step now\n\
4183                                                     /runtime-refresh  — (Model) Re-read active provider model + CTX now\n\
4184                                                     /model [status|list [available|loaded]|load <id> [--ctx N]|unload [id|current|all]|prefer <id>|clear] — (Model) Inspect, list, load, unload, or save the preferred coding model (`--ctx` uses LM Studio context length or Ollama `num_ctx`)\n\
4185                                                     /embed [status|load <id>|unload [id|current]|prefer <id>|clear] — (Model) Inspect, load, unload, or save the preferred embed model\n\
4186                                                     /undo             — (Ghost) Revert last file change\n\
4187                                                     /diff             — (Git) Show session changes (--stat)\n\
4188                                                     /lsp              — (Logic) Start Language Servers (semantic intelligence)\n\
4189                                                     /swarm <text>     — (Swarm) Spawn parallel workers on a directive\n\
4190                                                     /worktree <cmd>   — (Isolated) Manage git worktrees (list|add|remove|prune)\n\
4191                                                     /think            — (Brain) Enable deep reasoning mode\n\
4192                                                     /no_think         — (Speed) Disable reasoning (3-5x faster responses)\n\
4193                                                     /voice            — (TTS) List all available voices\n\
4194                                                     /voice N          — (TTS) Select voice by number\n\
4195                                                     /attach <path>    — (Docs) Attach a PDF/markdown/txt file for next message\n\
4196                                                     /attach-pick      — (Docs) Open a file picker and attach a document\n\
4197                                                     /image <path>     — (Vision) Attach an image for the next message\n\
4198                                                     /image-pick       — (Vision) Open a file picker and attach an image\n\
4199                                                     /detach           — (Context) Drop pending document/image attachments\n\
4200                                                     /copy             — (Debug) Copy session transcript to clipboard\n\
4201                                                     /copy2            — (Debug) Copy SPECULAR log to clipboard (reasoning + events)\n\
4202                                                     \nHotkeys:\n\
4203                                                     Ctrl+B — Toggle Brief Mode (minimal output; collapses side chrome)\n\
4204                                                     Ctrl+P — Toggle Professional Mode (strip personality)\n\
4205                                                     Ctrl+O — Open document picker for next-turn context\n\
4206                                                     Ctrl+I — Open image picker for next-turn vision context\n\
4207                                                     Ctrl+Y — Toggle Approvals Off (bypass safety approvals)\n\
4208                                                     Ctrl+S — Quick Swarm (hardcoded bootstrap)\n\
4209                                                     Ctrl+Z — Undo last edit\n\
4210                                                     Ctrl+Q/C — Quit session\n\
4211                                                     ESC    — Silence current playback\n\
4212                                                     \nStatus Legend:\n\
4213                                                     LM    — LM Studio runtime health (`LIVE`, `RECV`, `WARN`, `CEIL`, `STALE`, `BOOT`)\n\
4214                                                     VN    — Vein RAG status (`SEM`=semantic active, `FTS`=BM25 only, `--`=not indexed)\n\
4215                                                     BUD   — Total prompt-budget pressure against the live context window\n\
4216                                                     CMP   — History compaction pressure against Hematite's adaptive threshold\n\
4217                                                     ERR   — Session error count (runtime, tool, or SPECULAR failures)\n\
4218                                                     CTX   — Live context window currently reported by LM Studio\n\
4219                                                     VOICE — Local speech output state\n\
4220                                                     \nAssistant: Semantic Pathing (LSP), Vision Pass, Web Research, Swarm Synthesis"
4221                                                );
4222                                                app.history_idx = None;
4223                                                continue;
4224                                            }
4225                                            "/swarm" => {
4226                                                let directive = parts[1..].join(" ");
4227                                                if directive.is_empty() {
4228                                                    app.push_message("System", "Usage: /swarm <directive>");
4229                                                } else {
4230                                                    app.active_workers.clear(); // Fresh architecture for a fresh command
4231                                                    app.push_message("Hematite", &format!("Swarm analyzing: '{}'", directive));
4232                                                    let swarm_tx_c = swarm_tx.clone();
4233                                                    let coord_c = swarm_coordinator.clone();
4234                                                    let max_workers = if app.gpu_state.ratio() > 0.75 { 1 } else { 3 };
4235                                                    app.agent_running = true;
4236                                                    tokio::spawn(async move {
4237                                                        let payload = format!(r#"<worker_task id="1" target="src">Research {}</worker_task>
4238<worker_task id="2" target="src">Implement {}</worker_task>
4239<worker_task id="3" target="docs">Document {}</worker_task>"#, directive, directive, directive);
4240                                                        let tasks = crate::agent::parser::parse_master_spec(&payload);
4241                                                        let _ = coord_c.dispatch_swarm(tasks, swarm_tx_c, max_workers).await;
4242                                                    });
4243                                                }
4244                                                app.history_idx = None;
4245                                                continue;
4246                                            }
4247                                            "/provider" => {
4248                                                let arg_text = parts[1..].join(" ").trim().to_string();
4249                                                handle_provider_command(&mut app, arg_text).await;
4250                                                continue;
4251                                            }
4252                                            "/runtime" => {
4253                                                let arg_text = parts[1..].join(" ").trim().to_string();
4254                                                let lower = arg_text.to_ascii_lowercase();
4255                                                match lower.as_str() {
4256                                                    "" | "status" => {
4257                                                        app.push_message(
4258                                                            "System",
4259                                                            &format_runtime_summary(&app).await,
4260                                                        );
4261                                                    }
4262                                                    "explain" => {
4263                                                        app.push_message(
4264                                                            "System",
4265                                                            &format_runtime_explanation(&app).await,
4266                                                        );
4267                                                    }
4268                                                    "refresh" => {
4269                                                        let _ = app
4270                                                            .user_input_tx
4271                                                            .try_send(UserTurn::text(
4272                                                                "/runtime-refresh",
4273                                                            ));
4274                                                        app.push_message("You", "/runtime refresh");
4275                                                        app.agent_running = true;
4276                                                    }
4277                                                    "fix" => {
4278                                                        handle_runtime_fix(&mut app).await;
4279                                                    }
4280                                                    _ if lower.starts_with("provider") => {
4281                                                        let provider_arg =
4282                                                            arg_text["provider".len()..].trim().to_string();
4283                                                        if provider_arg.is_empty() {
4284                                                            app.push_message(
4285                                                                "System",
4286                                                                "Usage: /runtime provider [status|lmstudio|ollama|clear|http://host:port/v1]",
4287                                                            );
4288                                                        } else {
4289                                                            handle_provider_command(&mut app, provider_arg)
4290                                                                .await;
4291                                                        }
4292                                                    }
4293                                                    _ => {
4294                                                        app.push_message(
4295                                                            "System",
4296                                                            "Usage: /runtime [status|explain|fix|refresh|provider ...]",
4297                                                        );
4298                                                    }
4299                                                }
4300                                                app.history_idx = None;
4301                                                continue;
4302                                            }
4303                                            "/model" | "/embed" => {
4304                                                let outbound = input_text.clone();
4305                                                app.push_message("You", &outbound);
4306                                                app.agent_running = true;
4307                                                app.stop_requested = false;
4308                                                app.cancel_token.store(
4309                                                    false,
4310                                                    std::sync::atomic::Ordering::SeqCst,
4311                                                );
4312                                                app.last_reasoning.clear();
4313                                                app.manual_scroll_offset = None;
4314                                                app.specular_auto_scroll = true;
4315                                                let _ = app
4316                                                    .user_input_tx
4317                                                    .try_send(UserTurn::text(outbound));
4318                                                app.history_idx = None;
4319                                                continue;
4320                                            }
4321                                            "/version" => {
4322                                                app.push_message(
4323                                                    "System",
4324                                                    &crate::hematite_version_report(),
4325                                                );
4326                                                app.history_idx = None;
4327                                                continue;
4328                                            }
4329                                            "/about" => {
4330                                                app.push_message(
4331                                                    "System",
4332                                                    &crate::hematite_about_report(),
4333                                                );
4334                                                app.history_idx = None;
4335                                                continue;
4336                                            }
4337                                            "/explain" => {
4338                                                let error_text = parts[1..].join(" ");
4339                                                if error_text.trim().is_empty() {
4340                                                    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.");
4341                                                } else {
4342                                                    let framed = format!(
4343                                                        "The user pasted the following error or message and needs a plain-English explanation. \
4344                                                         Explain what this means, why it happened, and what to do about it. \
4345                                                         Use simple, non-technical language. Avoid jargon. \
4346                                                         Structure your response as:\n\
4347                                                         1. What happened (one sentence)\n\
4348                                                         2. Why it happened\n\
4349                                                         3. How to fix it (step by step)\n\
4350                                                         4. How to prevent it next time (optional, if relevant)\n\n\
4351                                                         Error/message to explain:\n```\n{}\n```",
4352                                                        error_text
4353                                                    );
4354                                                    app.push_message("You", &format!("/explain {}", error_text));
4355                                                    app.agent_running = true;
4356                                                    let _ = app.user_input_tx.try_send(UserTurn::text(framed));
4357                                                }
4358                                                app.history_idx = None;
4359                                                continue;
4360                                            }
4361                                            "/health" | "/triage" | "/fix" | "/inspect" => {
4362                                                app.push_message("You", &input_text);
4363                                                app.agent_running = true;
4364                                                let _ = app.user_input_tx.try_send(UserTurn::text(input_text.clone()));
4365                                                app.history_idx = None;
4366                                                continue;
4367                                            }
4368                                            "/diagnose" => {
4369                                                app.push_message("You", "/diagnose");
4370                                                app.push_message("System", "Running health triage...");
4371                                                let health_args = serde_json::json!({"topic": "health_report"});
4372                                                let health_output = crate::tools::host_inspect::inspect_host(&health_args)
4373                                                    .await
4374                                                    .unwrap_or_else(|e| format!("Error: {}", e));
4375                                                let follow_ups = crate::agent::diagnose::triage_follow_up_topics(&health_output);
4376                                                let n = follow_ups.len();
4377                                                if n > 0 {
4378                                                    app.push_message("System", &format!(
4379                                                        "Triage complete — {} area(s) flagged. Handing off to agent for deep investigation...",
4380                                                        n
4381                                                    ));
4382                                                } else {
4383                                                    app.push_message("System", "Triage complete — machine looks healthy. Confirming with agent...");
4384                                                }
4385                                                let instruction = crate::agent::diagnose::build_diagnose_instruction(
4386                                                    &health_output,
4387                                                    &follow_ups,
4388                                                );
4389                                                app.agent_running = true;
4390                                                let _ = app.user_input_tx.try_send(UserTurn::text(instruction));
4391                                                app.history_idx = None;
4392                                                continue;
4393                                            }
4394                                            "/export" => {
4395                                                let fmt = parts.get(1).copied().unwrap_or("md").to_ascii_lowercase();
4396                                                let label = match fmt.as_str() {
4397                                                    "json" => "JSON",
4398                                                    "html" => "HTML",
4399                                                    _ => "Markdown",
4400                                                };
4401                                                app.push_message("System", &format!(
4402                                                    "Generating diagnostic report ({}) — scanning 6 topics...", label
4403                                                ));
4404                                                let path = match fmt.as_str() {
4405                                                    "json" => {
4406                                                        let (_, p) = crate::agent::report_export::save_report_json().await;
4407                                                        p
4408                                                    }
4409                                                    "html" => {
4410                                                        let (_, p) = crate::agent::report_export::save_report_html().await;
4411                                                        p
4412                                                    }
4413                                                    _ => {
4414                                                        let (_, p) = crate::agent::report_export::save_report_markdown().await;
4415                                                        p
4416                                                    }
4417                                                };
4418                                                let path_str = path.display().to_string();
4419                                                copy_text_to_clipboard(&path_str);
4420                                                app.push_message("System", &format!(
4421                                                    "Report saved: {}\n(Path copied to clipboard — open in browser or share with your team)",
4422                                                    path_str
4423                                                ));
4424                                                app.history_idx = None;
4425                                                continue;
4426                                            }
4427                                            "/save-html" => {
4428                                                let title = parts[1..].join(" ");
4429                                                // Find the last Hematite response in raw message history
4430                                                let last_response = app.messages_raw.iter().rev()
4431                                                    .find(|(speaker, _)| speaker == "Hematite")
4432                                                    .map(|(_, content)| content.clone());
4433                                                match last_response {
4434                                                    None => {
4435                                                        app.push_message("System", "No Hematite response found in this session to save.");
4436                                                    }
4437                                                    Some(body) => {
4438                                                        let (_, path) = crate::agent::report_export::save_research_html(&title, &body);
4439                                                        let path_str = path.display().to_string();
4440                                                        copy_text_to_clipboard(&path_str);
4441                                                        app.push_message("System", &format!(
4442                                                            "Saved: {}\n(Path copied to clipboard)",
4443                                                            path_str
4444                                                        ));
4445                                                        #[cfg(target_os = "windows")]
4446                                                        { let s = path.to_string_lossy().into_owned(); let _ = std::process::Command::new("cmd").args(["/c", "start", "", &s]).spawn(); }
4447                                                        #[cfg(not(target_os = "windows"))]
4448                                                        { let opener = if cfg!(target_os = "macos") { "open" } else { "xdg-open" }; let _ = std::process::Command::new(opener).arg(&path).spawn(); }
4449                                                    }
4450                                                }
4451                                                app.history_idx = None;
4452                                                continue;
4453                                            }
4454                                            "/detach" => {
4455                                                app.clear_pending_attachments();
4456                                                app.push_message("System", "Cleared pending document/image attachments for the next turn.");
4457                                                app.history_idx = None;
4458                                                continue;
4459                                            }
4460                                            "/attach" => {
4461                                                let file_path = parts[1..].join(" ").trim().to_string();
4462                                                if file_path.is_empty() {
4463                                                    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.");
4464                                                    app.history_idx = None;
4465                                                    continue;
4466                                                }
4467                                                if file_path.is_empty() {
4468                                                    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.");
4469                                                } else {
4470                                                    let p = std::path::Path::new(&file_path);
4471                                                    match crate::memory::vein::extract_document_text(p) {
4472                                                        Ok(text) => {
4473                                                            let name = p.file_name()
4474                                                                .and_then(|n| n.to_str())
4475                                                                .unwrap_or(&file_path)
4476                                                                .to_string();
4477                                                            let preview_len = text.len().min(200);
4478                                                            app.push_message("System", &format!(
4479                                                                "Attached: {} ({} chars) — will be injected as context on your next message.\nPreview: {}...",
4480                                                                name, text.len(), &text[..preview_len]
4481                                                            ));
4482                                                            app.attached_context = Some((name, text));
4483                                                        }
4484                                                        Err(e) => {
4485                                                            app.push_message("System", &format!("Attach failed: {}", e));
4486                                                        }
4487                                                    }
4488                                                }
4489                                                app.history_idx = None;
4490                                                continue;
4491                                            }
4492                                            "/attach-pick" => {
4493                                                match pick_attachment_path(AttachmentPickerKind::Document) {
4494                                                    Ok(Some(path)) => attach_document_from_path(&mut app, &path),
4495                                                    Ok(None) => app.push_message("System", "Document picker cancelled."),
4496                                                    Err(e) => app.push_message("System", &e),
4497                                                }
4498                                                app.history_idx = None;
4499                                                continue;
4500                                            }
4501                                            "/image" => {
4502                                                let file_path = parts[1..].join(" ").trim().to_string();
4503                                                if file_path.is_empty() {
4504                                                    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.");
4505                                                } else {
4506                                                    attach_image_from_path(&mut app, &file_path);
4507                                                }
4508                                                app.history_idx = None;
4509                                                continue;
4510                                            }
4511                                            "/image-pick" => {
4512                                                match pick_attachment_path(AttachmentPickerKind::Image) {
4513                                                    Ok(Some(path)) => attach_image_from_path(&mut app, &path),
4514                                                    Ok(None) => app.push_message("System", "Image picker cancelled."),
4515                                                    Err(e) => app.push_message("System", &e),
4516                                                }
4517                                                app.history_idx = None;
4518                                                continue;
4519                                            }
4520                                            _ => {
4521                                                app.push_message("System", &format!("Unknown command: {}", cmd));
4522                                                app.history_idx = None;
4523                                                continue;
4524                                            }
4525                                        }
4526                                    }
4527
4528                                    // Save to history (avoid consecutive duplicates).
4529                                    if app.input_history.last().map(|s| s.as_str()) != Some(&input_text) {
4530                                        app.input_history.push(input_text.clone());
4531                                        if app.input_history.len() > 50 {
4532                                            app.input_history.remove(0);
4533                                        }
4534                                    }
4535                                    app.history_idx = None;
4536                                    app.clear_grounded_recovery_cache();
4537                                    app.push_message("You", &input_text);
4538                                    app.agent_running = true;
4539                                    app.stop_requested = false;
4540                                    app.cancel_token.store(false, std::sync::atomic::Ordering::SeqCst);
4541                                    app.last_reasoning.clear();
4542                                    app.manual_scroll_offset = None;
4543                                    app.specular_auto_scroll = true;
4544                                    let tx = app.user_input_tx.clone();
4545                                    let outbound = UserTurn {
4546                                        text: input_text,
4547                                        attached_document: app.attached_context.take().map(|(name, content)| {
4548                                            AttachedDocument { name, content }
4549                                        }),
4550                                        attached_image: app.attached_image.take(),
4551                                    };
4552                                    tokio::spawn(async move {
4553                                        let _ = tx.send(outbound).await;
4554                                    });
4555                                }
4556                            }
4557                            _ => {}
4558                        }
4559                    }
4560                    Some(Ok(Event::Paste(content)))
4561                        if !try_attach_from_paste(&mut app, &content) =>
4562                    {
4563                        // Normalize pasted newlines into spaces so we don't accidentally submit
4564                        // multiple lines or break the single-line input logic.
4565                        let normalized = content.replace("\r\n", " ").replace('\n', " ");
4566                        app.input.push_str(&normalized);
4567                        app.last_input_time = Instant::now();
4568                    }
4569                    _ => {}
4570                }
4571            }
4572
4573            // ── Specular proactive watcher ────────────────────────────────────
4574            Some(specular_evt) = specular_rx.recv() => {
4575                match specular_evt {
4576                    SpecularEvent::SyntaxError { path, details } => {
4577                        app.record_error();
4578                        app.specular_logs.push(format!("ERROR: {:?}", path));
4579                        trim_vec(&mut app.specular_logs, 20);
4580
4581                        // Only proactively suggest a fix if the user isn't actively typing.
4582                        let user_idle = {
4583                            let lock = last_interaction.lock().unwrap();
4584                            lock.elapsed() > std::time::Duration::from_secs(3)
4585                        };
4586                        if user_idle && !app.agent_running {
4587                            app.agent_running = true;
4588                            let tx = app.user_input_tx.clone();
4589                            let diag = details.clone();
4590                            tokio::spawn(async move {
4591                                let msg = format!(
4592                                    "<specular-build-fail>\n{}\n</specular-build-fail>\n\
4593                                     Fix the compiler error above.",
4594                                    diag
4595                                );
4596                                let _ = tx.send(UserTurn::text(msg)).await;
4597                            });
4598                        }
4599                    }
4600                    SpecularEvent::FileChanged(path) => {
4601                        app.stats.wisdom += 1;
4602                        app.stats.patience = (app.stats.patience - 0.5).max(0.0);
4603                        if app.stats.patience < 50.0 && !app.brief_mode {
4604                            app.brief_mode = true;
4605                            app.push_message("System", "Context saturation high — Brief Mode auto-enabled.");
4606                        }
4607                        let path_str = path.to_string_lossy().to_string();
4608                        app.specular_logs.push(format!("INDEX: {}", path_str));
4609                        app.push_context_file(path_str, "Active".into());
4610                        trim_vec(&mut app.specular_logs, 20);
4611                    }
4612                }
4613            }
4614
4615            // ── Inference / agent events ──────────────────────────────────────
4616            Some(event) = agent_rx.recv() => {
4617                use crate::agent::inference::InferenceEvent;
4618                match event {
4619                    InferenceEvent::Thought(content) => {
4620                        if app.stop_requested {
4621                            continue;
4622                        }
4623                        app.thinking = true;
4624                        app.current_thought.push_str(&content);
4625                    }
4626                    InferenceEvent::VoiceStatus(msg) => {
4627                        if app.stop_requested {
4628                            continue;
4629                        }
4630                        app.push_message("System", &msg);
4631                    }
4632                    InferenceEvent::Token(ref token) | InferenceEvent::MutedToken(ref token) => {
4633                        if app.stop_requested {
4634                            continue;
4635                        }
4636                        let is_muted = matches!(event, InferenceEvent::MutedToken(_));
4637                        app.thinking = false;
4638                        if app.messages_raw.last().map(|(s, _)| s.as_str()) != Some("Hematite") {
4639                            app.push_message("Hematite", "");
4640                        }
4641                        app.update_last_message(token);
4642                        app.manual_scroll_offset = None;
4643
4644                        // ONLY speak if not muted
4645                        if !is_muted && app.voice_manager.is_enabled() && !app.cancel_token.load(std::sync::atomic::Ordering::SeqCst) {
4646                            app.voice_manager.speak(token.clone());
4647                        }
4648                    }
4649                    InferenceEvent::ToolCallStart { id, name, args } => {
4650                        if app.stop_requested {
4651                            continue;
4652                        }
4653                        app.tool_started_at.insert(id, Instant::now());
4654                        // In chat mode, suppress tool noise from the main chat surface.
4655                        if app.workflow_mode != "CHAT" {
4656                            let display = format!("( )  {} {}", name, args);
4657                            app.push_message("Tool", &display);
4658                        }
4659                        // Always track in active context regardless of mode
4660                        app.active_context.push(ContextFile {
4661                            path: name.clone(),
4662                            size: 0,
4663                            status: "Running".into()
4664                        });
4665                        trim_vec_context(&mut app.active_context, 8);
4666                        app.manual_scroll_offset = None;
4667                    }
4668                    InferenceEvent::ToolCallResult { id, name, result, is_error } => {
4669                        if app.stop_requested {
4670                            continue;
4671                        }
4672                        if should_capture_grounded_tool_output(&name, is_error) {
4673                            app.recent_grounded_results.push((name.clone(), result.clone()));
4674                            if app.recent_grounded_results.len() > 4 {
4675                                app.recent_grounded_results.remove(0);
4676                            }
4677                        }
4678                        let icon = if is_error { "[x]" } else { "[v]" };
4679                        let elapsed_chip = app
4680                            .tool_started_at
4681                            .remove(&id)
4682                            .map(|started| format_tool_elapsed(started.elapsed()));
4683                        if is_error {
4684                            app.record_error();
4685                        }
4686                        // In chat mode, suppress tool results from main chat.
4687                        // Errors still show so the user knows something went wrong.
4688                        let preview = first_n_chars(&result, 100);
4689                        if app.workflow_mode != "CHAT" {
4690                            let display = if let Some(elapsed) = elapsed_chip.as_deref() {
4691                                format!("{}  {} [{}] ? {}", icon, name, elapsed, preview)
4692                            } else {
4693                                format!("{}  {} ? {}", icon, name, preview)
4694                            };
4695                            app.push_message("Tool", &display);
4696                        } else if is_error {
4697                            app.push_message("System", &format!("Tool error: {}", preview));
4698                        }
4699
4700                        // If it was a read or write, we can extract the path from the app.active_context "Running" entries
4701                        // but it's simpler to just let Specular handle the indexing or update here if we had the path.
4702
4703                        // Remove "Running" tools from context list
4704                        app.active_context.retain(|f| f.path != name || f.status != "Running");
4705                        app.manual_scroll_offset = None;
4706                    }
4707                    InferenceEvent::ApprovalRequired { id: _, name, display, diff, mutation_label, responder } => {
4708                        if app.stop_requested {
4709                            let _ = responder.send(false);
4710                            continue;
4711                        }
4712                        // Session-level auto-approve: skip dialog entirely.
4713                        if app.auto_approve_session {
4714                            if let Some(ref diff) = diff {
4715                                let added = diff.lines().filter(|l| l.starts_with("+ ")).count();
4716                                let removed = diff.lines().filter(|l| l.starts_with("- ")).count();
4717                                app.push_message("System", &format!(
4718                                    "Auto-approved: {} +{} -{}", display, added, removed
4719                                ));
4720                            } else {
4721                                app.push_message("System", &format!("Auto-approved: {}", display));
4722                            }
4723                            let _ = responder.send(true);
4724                            continue;
4725                        }
4726                        let is_diff = diff.is_some();
4727                        app.awaiting_approval = Some(PendingApproval {
4728                            display: display.clone(),
4729                            tool_name: name,
4730                            diff,
4731                            diff_scroll: 0,
4732                            mutation_label,
4733                            responder,
4734                        });
4735                        if is_diff {
4736                            app.push_message("System", "[~]  Diff preview — [Y] Apply  [N] Skip  [A] Accept All");
4737                        } else {
4738                            app.push_message("System", "[!]  Approval required — [Y] Approve  [N] Decline  [A] Accept All");
4739                            app.push_message("System", &format!("Command: {}", display));
4740                        }
4741                    }
4742                    InferenceEvent::TurnTiming { context_prep_ms, inference_ms, execution_ms } => {
4743                        app.specular_logs.push(format!(
4744                            "PROFILE: Prep {}ms | Eval {}ms | Exec {}ms",
4745                            context_prep_ms, inference_ms, execution_ms
4746                        ));
4747                        trim_vec(&mut app.specular_logs, 20);
4748                    }
4749                    InferenceEvent::UsageUpdate(usage) => {
4750                        app.total_tokens = usage.total_tokens;
4751                        // Calculate discounted cost for this turn.
4752                        let turn_cost = crate::agent::pricing::calculate_cost(&usage, &app.model_id);
4753                        app.current_session_cost += turn_cost;
4754                    }
4755                    InferenceEvent::Done => {
4756                        app.thinking = false;
4757                        app.agent_running = false;
4758                        app.stop_requested = false;
4759                        app.task_start_time = None;
4760                        if app.voice_manager.is_enabled() {
4761                            app.voice_manager.flush();
4762                        }
4763                        if !app.current_thought.is_empty() {
4764                            app.last_reasoning = app.current_thought.clone();
4765                        }
4766                        app.current_thought.clear();
4767                        // Force one last repaint of the visible chat buffer in case the
4768                        // final streamed token chunk did not trigger the lightweight
4769                        // reformat heuristic in update_last_message().
4770                        app.rebuild_formatted_messages();
4771                        app.manual_scroll_offset = None;
4772                        app.specular_auto_scroll = true;
4773                        // Clear single-agent task bars on completion
4774                        app.active_workers.remove("AGENT");
4775                        app.worker_labels.remove("AGENT");
4776                    }
4777                    InferenceEvent::CopyDiveInCommand(path) => {
4778                        let command = format!("cd \"{}\" && hematite", path.replace('\\', "/"));
4779                        copy_text_to_clipboard(&command);
4780                        spawn_dive_in_terminal(&path);
4781                        app.push_message("System", &format!("Teleportation initiated: New terminal launched at {}", path));
4782                        app.push_message("System", "Teleportation complete. Closing original session to maintain workstation hygiene...");
4783
4784                        // Self-Destruct Sequence: Graceful exit matching Ctrl+Q behavior
4785                        app.write_session_report();
4786                        app.copy_transcript_to_clipboard();
4787                        break;
4788                    }
4789                    InferenceEvent::ChainImplementPlan => {
4790                        app.push_message("You", "/implement-plan (Autonomous Handoff)");
4791                        app.manual_scroll_offset = None;
4792                    }
4793                    InferenceEvent::Error(e) => {
4794                        app.record_error();
4795                        app.thinking = false;
4796                        app.agent_running = false;
4797                        app.task_start_time = None;
4798                        if app.voice_manager.is_enabled() {
4799                            app.voice_manager.flush();
4800                        }
4801                        app.push_message("System", &format!("Error: {e}"));
4802                    }
4803                    InferenceEvent::ProviderStatus { state, summary } => {
4804                        app.provider_state = state;
4805                        if !summary.trim().is_empty() && app.last_provider_summary != summary {
4806                            app.specular_logs.push(format!("PROVIDER: {}", summary));
4807                            trim_vec(&mut app.specular_logs, 20);
4808                            app.last_provider_summary = summary;
4809                        }
4810                    }
4811                    InferenceEvent::McpStatus { state, summary } => {
4812                        app.mcp_state = state;
4813                        if !summary.trim().is_empty() && app.last_mcp_summary != summary {
4814                            app.specular_logs.push(format!("MCP: {}", summary));
4815                            trim_vec(&mut app.specular_logs, 20);
4816                            app.last_mcp_summary = summary;
4817                        }
4818                    }
4819                    InferenceEvent::OperatorCheckpoint { state, summary } => {
4820                        app.last_operator_checkpoint_state = state;
4821                        if state == OperatorCheckpointState::Idle {
4822                            app.last_operator_checkpoint_summary.clear();
4823                        } else if !summary.trim().is_empty()
4824                            && app.last_operator_checkpoint_summary != summary
4825                        {
4826                            app.specular_logs.push(format!(
4827                                "STATE: {} - {}",
4828                                state.label(),
4829                                summary
4830                            ));
4831                            trim_vec(&mut app.specular_logs, 20);
4832                            app.last_operator_checkpoint_summary = summary;
4833                        }
4834                    }
4835                    InferenceEvent::RecoveryRecipe { summary } => {
4836                        if !summary.trim().is_empty()
4837                            && app.last_recovery_recipe_summary != summary
4838                        {
4839                            app.specular_logs.push(format!("RECOVERY: {}", summary));
4840                            trim_vec(&mut app.specular_logs, 20);
4841                            app.last_recovery_recipe_summary = summary;
4842                        }
4843                    }
4844                    InferenceEvent::CompactionPressure {
4845                        estimated_tokens,
4846                        threshold_tokens,
4847                        percent,
4848                    } => {
4849                        app.compaction_estimated_tokens = estimated_tokens;
4850                        app.compaction_threshold_tokens = threshold_tokens;
4851                        app.compaction_percent = percent;
4852                        // Fire a one-shot warning when crossing 70% or 90%.
4853                        // Reset warned_level to 0 when pressure drops back below 60%
4854                        // so warnings re-fire if context fills up again after a /new.
4855                        if percent < 60 {
4856                            app.compaction_warned_level = 0;
4857                        } else if percent >= 90 && app.compaction_warned_level < 90 {
4858                            app.compaction_warned_level = 90;
4859                            app.push_message(
4860                                "System",
4861                                "Context is 90% full. Run /compact to summarize history in place, /new to reset (preserves project memory), or /forget to wipe everything.",
4862                            );
4863                        } else if percent >= 70 && app.compaction_warned_level < 70 {
4864                            app.compaction_warned_level = 70;
4865                            app.push_message(
4866                                "System",
4867                                &format!("Context at {}% — approaching compaction threshold. Run /compact to summarize history and free space.", percent),
4868                            );
4869                        }
4870                    }
4871                    InferenceEvent::PromptPressure {
4872                        estimated_input_tokens,
4873                        reserved_output_tokens,
4874                        estimated_total_tokens,
4875                        context_length: _,
4876                        percent,
4877                    } => {
4878                        app.prompt_estimated_input_tokens = estimated_input_tokens;
4879                        app.prompt_reserved_output_tokens = reserved_output_tokens;
4880                        app.prompt_estimated_total_tokens = estimated_total_tokens;
4881                        app.prompt_pressure_percent = percent;
4882                    }
4883                    InferenceEvent::TaskProgress { id, label, progress } => {
4884                        let nid = normalize_id(&id);
4885                        app.active_workers.insert(nid.clone(), progress);
4886                        app.worker_labels.insert(nid, label);
4887                    }
4888                    InferenceEvent::RuntimeProfile {
4889                        provider_name,
4890                        endpoint,
4891                        model_id,
4892                        context_length,
4893                    } => {
4894                        let was_no_model = app.model_id == "no model loaded";
4895                        let now_no_model = model_id == "no model loaded";
4896                        let changed = app.model_id != "detecting..."
4897                            && (app.model_id != model_id || app.context_length != context_length);
4898                        let provider_changed = app.provider_name != provider_name;
4899                        app.provider_name = provider_name.clone();
4900                        app.provider_endpoint = endpoint.clone();
4901                        app.model_id = model_id.clone();
4902                        app.context_length = context_length;
4903                        app.last_runtime_profile_time = Instant::now();
4904                        if app.provider_state == ProviderRuntimeState::Booting {
4905                            app.provider_state = ProviderRuntimeState::Live;
4906                        }
4907                        if now_no_model && !was_no_model {
4908                            let mut guidance = if provider_name == "Ollama" {
4909                                "No coding model is currently available from Ollama. Pull or load a chat model in Ollama, then keep `api_url` pointed at `http://localhost:11434/v1`. If you also want semantic search, set `/embed prefer <id>` to an Ollama embedding model.".to_string()
4910                            } else {
4911                                "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 an embedding model for semantic search.".to_string()
4912                            };
4913                            if let Some((alt_name, alt_url)) =
4914                                crate::runtime::detect_alternative_provider(&provider_name).await
4915                            {
4916                                let _ = write!(guidance,
4917                                    " Reachable alternative detected: {} ({}). Use `/provider {}` and restart Hematite if you want to switch.",
4918                                    alt_name,
4919                                    alt_url,
4920                                    alt_name.to_ascii_lowercase().replace(' ', "")
4921                                );
4922                            }
4923                            app.push_message("System", &guidance);
4924                        } else if provider_changed && !now_no_model {
4925                            app.push_message(
4926                                "System",
4927                                &format!(
4928                                    "Provider detected: {} | Model {} | CTX {}",
4929                                    provider_name, model_id, context_length
4930                                ),
4931                            );
4932                        } else if changed && !now_no_model {
4933                            app.push_message(
4934                                "System",
4935                                &format!(
4936                                    "Runtime profile refreshed: {} | Model {} | CTX {}",
4937                                    provider_name, model_id, context_length
4938                                ),
4939                            );
4940                        }
4941                    }
4942                    InferenceEvent::EmbedProfile { model_id } => {
4943                        let changed = app.embed_model_id != model_id;
4944                        app.embed_model_id = model_id.clone();
4945                        if changed {
4946                            match model_id {
4947                                Some(id) => app.push_message(
4948                                    "System",
4949                                    &format!("Embed model loaded: {} (semantic search ready)", id),
4950                                ),
4951                                None => app.push_message(
4952                                    "System",
4953                                    "Embed model unloaded. Semantic search inactive.",
4954                                ),
4955                            }
4956                        }
4957                    }
4958                    InferenceEvent::VeinStatus { file_count, embedded_count, docs_only } => {
4959                        app.vein_file_count = file_count;
4960                        app.vein_embedded_count = embedded_count;
4961                        app.vein_docs_only = docs_only;
4962                    }
4963                    InferenceEvent::VeinContext { paths } => {
4964                        // Replace the default placeholder entries with what the
4965                        // Vein actually surfaced for this turn.
4966                        app.active_context.retain(|f| f.status == "Running");
4967                        for path in paths {
4968                            let root = crate::tools::file_ops::workspace_root();
4969                            let size = std::fs::metadata(root.join(&path))
4970                                .map(|m| m.len())
4971                                .unwrap_or(0);
4972                            if !app.active_context.iter().any(|f| f.path == path) {
4973                                app.active_context.push(ContextFile {
4974                                    path,
4975                                    size,
4976                                    status: "Vein".to_string(),
4977                                });
4978                            }
4979                        }
4980                        trim_vec_context(&mut app.active_context, 8);
4981                    }
4982                    InferenceEvent::SoulReroll { species, rarity, shiny, .. } => {
4983                        let shiny_tag = if shiny { " 🌟 SHINY" } else { "" };
4984                        app.soul_name = species.clone();
4985                        app.push_message(
4986                            "System",
4987                            &format!("[{}{}] {} has awakened.", rarity, shiny_tag, species),
4988                        );
4989                    }
4990                    InferenceEvent::ShellLine(line) => {
4991                        // Stream shell output into the SPECULAR side panel as it
4992                        // arrives so the operator sees live progress.
4993                        app.current_thought.push_str(&line);
4994                        app.current_thought.push('\n');
4995                    }
4996                    InferenceEvent::TurnBudget(budget) => {
4997                        // Route the per-turn context budget ledger into SPECULAR.
4998                        app.current_thought.push_str(&budget.render());
4999                        app.current_thought.push('\n');
5000                    }
5001                }
5002            }
5003
5004            // ── Swarm messages ────────────────────────────────────────────────
5005            Some(msg) = swarm_rx.recv() => {
5006                match msg {
5007                    SwarmMessage::Progress(worker_id, progress) => {
5008                        let nid = normalize_id(&worker_id);
5009                        app.active_workers.insert(nid.clone(), progress);
5010                        match progress {
5011                            102 => app.push_message("System", &format!("Worker {} architecture verified and applied.", nid)),
5012                            101 => { /* Handled by 102 terminal message */ },
5013                            100 => app.push_message("Hematite", &format!("Worker {} complete. Standing by for review...", nid)),
5014                            _ => {}
5015                        }
5016                    }
5017                    SwarmMessage::ReviewRequest { worker_id, file_path, before, after, tx } => {
5018                        app.push_message("Hematite", &format!("Worker {} conflict — review required.", worker_id));
5019                        app.active_review = Some(ActiveReview {
5020                            worker_id,
5021                            file_path: file_path.to_string_lossy().to_string(),
5022                            before,
5023                            after,
5024                            tx,
5025                        });
5026                    }
5027                    SwarmMessage::Done => {
5028                        app.agent_running = false;
5029                        // Workers now persist in SPECULAR until a new command is issued
5030                        app.push_message("System", "──────────────────────────────────────────────────────────");
5031                        app.push_message("System", " TASK COMPLETE: Swarm Synthesis Finalized ");
5032                        app.push_message("System", "──────────────────────────────────────────────────────────");
5033                    }
5034                }
5035            }
5036        }
5037    }
5038    Ok(())
5039}
5040
5041// ── Render ────────────────────────────────────────────────────────────────────
5042
5043fn ui(f: &mut ratatui::Frame, app: &App) {
5044    let size = f.area();
5045    if size.width < 60 || size.height < 10 {
5046        // Render a minimal wait message or just clear if area is too collapsed
5047        f.render_widget(Clear, size);
5048        return;
5049    }
5050
5051    let input_height = compute_input_height(f.area().width, app.input.len());
5052
5053    let chunks = Layout::default()
5054        .direction(Direction::Vertical)
5055        .constraints([
5056            Constraint::Min(0),
5057            Constraint::Length(input_height),
5058            Constraint::Length(5), // Expanded to accommodate Multi-Tier Liquid Telemetry
5059        ])
5060        .split(f.area());
5061
5062    let sidebar_mode = sidebar_mode(app, size.width);
5063    let sidebar_width = match sidebar_mode {
5064        SidebarMode::Hidden => 0,
5065        SidebarMode::Compact => 32,
5066        SidebarMode::Full => 45,
5067    };
5068    let top = Layout::default()
5069        .direction(Direction::Horizontal)
5070        .constraints([Constraint::Fill(1), Constraint::Length(sidebar_width)])
5071        .split(chunks[0]);
5072
5073    // ── Box 1: Dialogue ───────────────────────────────────────────────────────
5074    let mut core_lines = app.messages.clone();
5075
5076    // Show agent-running indicator as last line when active.
5077    if app.agent_running {
5078        let dots = ".".repeat((app.tick_count % 4) as usize + 1);
5079        let verb = if app.thinking { "thinking" } else { "working" };
5080        core_lines.push(Line::from(Span::styled(
5081            format!(" Hematite is {}{}", verb, dots),
5082            Style::default()
5083                .fg(Color::Magenta)
5084                .add_modifier(Modifier::DIM),
5085        )));
5086    }
5087
5088    let (heart_color, core_icon) = if app.agent_running || !app.active_workers.is_empty() {
5089        let (r_base, g_base, b_base) = if !app.active_workers.is_empty() {
5090            (0, 200, 200) // Cyan pulse for swarm
5091        } else {
5092            (200, 0, 200) // Magenta pulse for thinking
5093        };
5094
5095        let pulse = (app.tick_count % 50) as f64 / 50.0;
5096        let factor = (pulse * std::f64::consts::PI).sin().abs();
5097        let r = (r_base as f64 * factor) as u8;
5098        let g = (g_base as f64 * factor) as u8;
5099        let b = (b_base as f64 * factor) as u8;
5100
5101        (Color::Rgb(r.max(60), g.max(60), b.max(60)), "•")
5102    } else {
5103        (Color::Rgb(80, 80, 80), "•") // Standby
5104    };
5105
5106    // Use a context-appropriate label so "TASK:" never appears next to a transient
5107    // state like "Reasoning" or "Working".  The prefix changes with the actual state.
5108    let has_real_task = !app.current_objective.is_empty()
5109        && app.current_objective != "Idle"
5110        && app.current_objective != "Awaiting objective...";
5111
5112    let (title_prefix, title_body, title_color): (&str, String, Color) = if has_real_task {
5113        let body = if app.current_objective.len() > 30 {
5114            format!("{}...", safe_head(&app.current_objective, 27))
5115        } else {
5116            app.current_objective.clone()
5117        };
5118        ("TASK", body, Color::Yellow)
5119    } else if !app.active_workers.is_empty() {
5120        ("SWARM", "Parallel agents active".into(), Color::Cyan)
5121    } else if app.thinking {
5122        ("THINKING", String::new(), Color::Magenta)
5123    } else if app.agent_running {
5124        ("WORKING", String::new(), Color::Green)
5125    } else {
5126        ("READY", String::new(), Color::DarkGray)
5127    };
5128
5129    let title_text = if title_body.is_empty() {
5130        format!(" {} ", title_prefix)
5131    } else {
5132        format!(" {}: {} ", title_prefix, title_body)
5133    };
5134
5135    let core_title = if app.professional {
5136        Line::from(vec![
5137            Span::styled(format!(" {} ", core_icon), Style::default().fg(heart_color)),
5138            Span::styled("HEMATITE ", Style::default().add_modifier(Modifier::BOLD)),
5139            Span::styled(
5140                title_text,
5141                Style::default()
5142                    .fg(title_color)
5143                    .add_modifier(Modifier::ITALIC),
5144            ),
5145        ])
5146    } else {
5147        Line::from(vec![
5148            Span::styled(format!(" {} ", core_icon), Style::default().fg(heart_color)),
5149            Span::styled(title_text, Style::default().fg(title_color)),
5150        ])
5151    };
5152
5153    // Enhanced Scroll calculation — done before Paragraph construction so we can
5154    // move core_lines into Paragraph::new() instead of cloning it a second time.
5155    let avail_h = top[0].height.saturating_sub(2);
5156    // Borders (2) + Scrollbar (1) + explicit Padding (1) = 4.
5157    let inner_w = top[0].width.saturating_sub(4).max(1);
5158
5159    let mut total_lines: u16 = 0;
5160    for line in &core_lines {
5161        let line_w = line.width() as u16;
5162        if line_w == 0 {
5163            total_lines += 1;
5164        } else {
5165            // TUI SCROLL FIX:
5166            // Exact calculation: how many times does line_w fit into inner_w?
5167            // This matches Paragraph's internal Wrap logic closely.
5168            let wrapped = line_w.div_ceil(inner_w);
5169            total_lines += wrapped;
5170        }
5171    }
5172
5173    let max_scroll = total_lines.saturating_sub(avail_h);
5174    let scroll = if let Some(off) = app.manual_scroll_offset {
5175        max_scroll.saturating_sub(off)
5176    } else {
5177        max_scroll
5178    };
5179
5180    let core_para = Paragraph::new(core_lines)
5181        .block(
5182            Block::default()
5183                .title(core_title)
5184                .borders(Borders::ALL)
5185                .border_style(Style::default().fg(Color::DarkGray)),
5186        )
5187        .wrap(Wrap { trim: true });
5188
5189    // Clear the outer chunk and the inner dialogue area to prevent ghosting from previous frames or background renders.
5190    f.render_widget(Clear, top[0]);
5191
5192    // Create a sub-area for the dialogue with horizontal padding.
5193    let chat_area = Rect::new(
5194        top[0].x + 1,
5195        top[0].y,
5196        top[0].width.saturating_sub(2).max(1),
5197        top[0].height,
5198    );
5199    f.render_widget(Clear, chat_area);
5200    f.render_widget(core_para.scroll((scroll, 0)), chat_area);
5201
5202    // Scrollbar: content_length = max_scroll+1 so position==max_scroll puts the
5203    // thumb flush at the bottom (position == content_length - 1).
5204    let mut scrollbar_state =
5205        ScrollbarState::new(max_scroll as usize + 1).position(scroll as usize);
5206    f.render_stateful_widget(
5207        Scrollbar::default()
5208            .orientation(ScrollbarOrientation::VerticalRight)
5209            .begin_symbol(Some("↑"))
5210            .end_symbol(Some("↓")),
5211        top[0],
5212        &mut scrollbar_state,
5213    );
5214
5215    // ── Box 2: Side panel ─────────────────────────────────────────────────────
5216    if sidebar_mode == SidebarMode::Compact && top[1].width > 0 {
5217        let compact_title = if sidebar_has_live_activity(app) {
5218            " SIGNALS "
5219        } else {
5220            " SESSION "
5221        };
5222        let compact_para = Paragraph::new(build_compact_sidebar_lines(app))
5223            .wrap(Wrap { trim: true })
5224            .block(
5225                Block::default()
5226                    .title(compact_title)
5227                    .borders(Borders::ALL)
5228                    .border_style(Style::default().fg(Color::DarkGray)),
5229            );
5230        f.render_widget(Clear, top[1]);
5231        f.render_widget(compact_para, top[1]);
5232    } else if sidebar_mode == SidebarMode::Full && top[1].width > 0 {
5233        let side = Layout::default()
5234            .direction(Direction::Vertical)
5235            .constraints([
5236                Constraint::Length(8), // CONTEXT
5237                Constraint::Min(0),    // SPECULAR
5238            ])
5239            .split(top[1]);
5240
5241        // Pane 1: Context (Nervous focus)
5242        let context_source = if app.active_context.is_empty() {
5243            default_active_context()
5244        } else {
5245            app.active_context.clone()
5246        };
5247        let mut context_display = context_source
5248            .iter()
5249            .map(|f| {
5250                let (icon, color) = match f.status.as_str() {
5251                    "Running" => ("⚙️", Color::Cyan),
5252                    "Dirty" => ("📝", Color::Yellow),
5253                    _ => ("📄", Color::Gray),
5254                };
5255                // Simple heuristic for "Tokens" (size / 4)
5256                let tokens = f.size / 4;
5257                ListItem::new(Line::from(vec![
5258                    Span::styled(format!(" {} ", icon), Style::default().fg(color)),
5259                    Span::styled(f.path.clone(), Style::default().fg(Color::White)),
5260                    Span::styled(
5261                        format!(" {}t ", tokens),
5262                        Style::default().fg(Color::DarkGray),
5263                    ),
5264                ]))
5265            })
5266            .collect::<Vec<ListItem>>();
5267
5268        if context_display.is_empty() {
5269            context_display = vec![ListItem::new(" (No active files)")];
5270        }
5271
5272        let ctx_title = if sidebar_has_live_activity(app) {
5273            " LIVE CONTEXT "
5274        } else {
5275            " SESSION CONTEXT "
5276        };
5277
5278        let ctx_block = Block::default()
5279            .title(ctx_title)
5280            .borders(Borders::ALL)
5281            .border_style(Style::default().fg(Color::DarkGray));
5282
5283        f.render_widget(Clear, side[0]);
5284        f.render_widget(List::new(context_display).block(ctx_block), side[0]);
5285
5286        // Optional: Add a Gauge for total context if tokens were tracked accurately.
5287        // For now, let's just make the CONTEXT pane look high-density.
5288
5289        // ── SPECULAR panel (Pane 2) ────────────────────────────────────────────────
5290        let v_title = if app.thinking || app.agent_running {
5291            " HEMATITE SIGNALS [live] ".to_string()
5292        } else {
5293            " HEMATITE SIGNALS [watching] ".to_string()
5294        };
5295
5296        f.render_widget(Clear, side[1]);
5297
5298        let mut v_lines: Vec<Line<'static>> = Vec::with_capacity(32);
5299
5300        // Section: live thought (bounded to last 300 chars to avoid wall-of-text)
5301        if app.thinking || app.agent_running {
5302            let dots = ".".repeat((app.tick_count % 4) as usize + 1);
5303            let label = if app.thinking { "REASONING" } else { "WORKING" };
5304            v_lines.push(Line::from(vec![Span::styled(
5305                format!("[ {}{} ]", label, dots),
5306                Style::default()
5307                    .fg(Color::Green)
5308                    .add_modifier(Modifier::BOLD),
5309            )]));
5310            // Show last 300 chars of current thought, split by line.
5311            let preview = {
5312                let thought = &app.current_thought;
5313                let char_count = thought.chars().count();
5314                if char_count > 300 {
5315                    thought.chars().skip(char_count - 300).collect::<String>()
5316                } else {
5317                    thought.clone()
5318                }
5319            };
5320            for raw in preview.lines() {
5321                let raw = raw.trim();
5322                if !raw.is_empty() {
5323                    v_lines.extend(render_markdown_line(raw));
5324                }
5325            }
5326            v_lines.push(Line::raw(""));
5327        } else {
5328            v_lines.push(Line::from(vec![
5329                Span::styled("• ", Style::default().fg(Color::DarkGray)),
5330                Span::styled(
5331                    "Waiting for the next turn. Runtime, MCP, and index signals stay visible here.",
5332                    Style::default().fg(Color::Gray),
5333                ),
5334            ]));
5335            v_lines.push(Line::raw(""));
5336        }
5337
5338        let signal_rows = sidebar_signal_rows(app);
5339        if !signal_rows.is_empty() {
5340            let section_title = if app.thinking || app.agent_running {
5341                "-- Operator Signals --"
5342            } else {
5343                "-- Session Snapshot --"
5344            };
5345            v_lines.push(Line::from(vec![Span::styled(
5346                section_title,
5347                Style::default()
5348                    .fg(Color::White)
5349                    .add_modifier(Modifier::DIM),
5350            )]));
5351            for (row, color) in signal_rows
5352                .iter()
5353                .take(if app.thinking || app.agent_running {
5354                    4
5355                } else {
5356                    3
5357                })
5358            {
5359                v_lines.push(Line::from(vec![
5360                    Span::styled("- ", Style::default().fg(Color::DarkGray)),
5361                    Span::styled(row.clone(), Style::default().fg(*color)),
5362                ]));
5363            }
5364            v_lines.push(Line::raw(""));
5365        }
5366
5367        // Section: worker progress bars
5368        if !app.active_workers.is_empty() {
5369            v_lines.push(Line::from(vec![Span::styled(
5370                "── Task Progress ──",
5371                Style::default()
5372                    .fg(Color::White)
5373                    .add_modifier(Modifier::DIM),
5374            )]));
5375
5376            let mut sorted_ids: Vec<_> = app.active_workers.keys().cloned().collect();
5377            sorted_ids.sort_unstable();
5378
5379            for id in sorted_ids {
5380                let prog = app.active_workers[&id];
5381                let custom_label = app.worker_labels.get(&id).cloned();
5382
5383                let (label, color) = match prog {
5384                    101..=102 => ("VERIFIED", Color::Green),
5385                    100 if !app.agent_running && id != "AGENT" => ("SKIPPED ", Color::DarkGray),
5386                    100 => ("REVIEW  ", Color::Magenta),
5387                    _ => ("WORKING ", Color::Yellow),
5388                };
5389
5390                let display_label = custom_label.unwrap_or_else(|| label.to_string());
5391                let filled = (prog.min(100) / 10) as usize;
5392                let bar = "▓".repeat(filled) + &"░".repeat(10 - filled);
5393
5394                let id_prefix = if id == "AGENT" {
5395                    "Agent: ".to_string()
5396                } else {
5397                    format!("W{}: ", id)
5398                };
5399
5400                v_lines.push(Line::from(vec![
5401                    Span::styled(id_prefix, Style::default().fg(Color::Gray)),
5402                    Span::styled(bar, Style::default().fg(color)),
5403                    Span::styled(
5404                        format!(" {} ", display_label),
5405                        Style::default().fg(color).add_modifier(Modifier::BOLD),
5406                    ),
5407                    Span::styled(
5408                        format!("{}%", prog.min(100)),
5409                        Style::default().fg(Color::DarkGray),
5410                    ),
5411                ]));
5412            }
5413            v_lines.push(Line::raw(""));
5414        }
5415
5416        // Section: last completed turn's reasoning
5417        if (app.thinking || app.agent_running) && !app.last_reasoning.is_empty() {
5418            v_lines.push(Line::from(vec![Span::styled(
5419                "── Logic Trace ──",
5420                Style::default()
5421                    .fg(Color::White)
5422                    .add_modifier(Modifier::DIM),
5423            )]));
5424            for raw in app.last_reasoning.lines() {
5425                v_lines.extend(render_markdown_line(raw));
5426            }
5427            v_lines.push(Line::raw(""));
5428        }
5429
5430        // Section: specular event log
5431        if !app.specular_logs.is_empty() {
5432            v_lines.push(Line::from(vec![Span::styled(
5433                if app.thinking || app.agent_running {
5434                    "── Live Events ──"
5435                } else {
5436                    "── Recent Events ──"
5437                },
5438                Style::default()
5439                    .fg(Color::White)
5440                    .add_modifier(Modifier::DIM),
5441            )]));
5442            let recent_logs: Vec<String> = if app.thinking || app.agent_running {
5443                app.specular_logs.iter().rev().take(8).cloned().collect()
5444            } else {
5445                app.specular_logs.iter().rev().take(5).cloned().collect()
5446            };
5447            for log in recent_logs.into_iter().rev() {
5448                let (icon, color) = if log.starts_with("ERROR") {
5449                    ("X ", Color::Red)
5450                } else if log.starts_with("INDEX") {
5451                    ("I ", Color::Cyan)
5452                } else if log.starts_with("GHOST") {
5453                    ("< ", Color::Magenta)
5454                } else {
5455                    ("- ", Color::Gray)
5456                };
5457                v_lines.push(Line::from(vec![
5458                    Span::styled(icon, Style::default().fg(color)),
5459                    Span::styled(
5460                        log,
5461                        Style::default()
5462                            .fg(Color::White)
5463                            .add_modifier(Modifier::DIM),
5464                    ),
5465                ]));
5466            }
5467        }
5468
5469        let v_total = v_lines.len() as u16;
5470        let v_avail = side[1].height.saturating_sub(2);
5471        let v_max_scroll = v_total.saturating_sub(v_avail);
5472        // If auto-scroll is active, always show the bottom. Otherwise respect the
5473        // user's manual position (clamped so we never scroll past the content end).
5474        let v_scroll = if app.specular_auto_scroll {
5475            v_max_scroll
5476        } else {
5477            app.specular_scroll.min(v_max_scroll)
5478        };
5479
5480        let specular_para = Paragraph::new(v_lines)
5481            .wrap(Wrap { trim: true })
5482            .scroll((v_scroll, 0))
5483            .block(Block::default().title(v_title).borders(Borders::ALL));
5484
5485        f.render_widget(specular_para, side[1]);
5486
5487        // Scrollbar for SPECULAR
5488        let mut v_scrollbar_state =
5489            ScrollbarState::new(v_max_scroll as usize + 1).position(v_scroll as usize);
5490        f.render_stateful_widget(
5491            Scrollbar::default()
5492                .orientation(ScrollbarOrientation::VerticalRight)
5493                .begin_symbol(None)
5494                .end_symbol(None),
5495            side[1],
5496            &mut v_scrollbar_state,
5497        );
5498    }
5499
5500    // ── Box 3: Status bar ─────────────────────────────────────────────────────
5501    let vigil_badge = if app.brief_mode { " VIGIL" } else { "" };
5502    let yolo_badge = if app.yolo_mode { " YOLO" } else { "" };
5503
5504    let bar_constraints = vec![Constraint::Fill(1)];
5505    let bar_chunks = Layout::default()
5506        .direction(Direction::Horizontal)
5507        .constraints(bar_constraints)
5508        .split(chunks[2]);
5509
5510    let footer_row = {
5511        let footer_row_width = bar_chunks[0].width.saturating_sub(6);
5512        if app.agent_running {
5513            let elapsed = if let Some(start) = app.task_start_time {
5514                format!(" {:0>2}s ", start.elapsed().as_secs())
5515            } else {
5516                String::new()
5517            };
5518            let last_log = app
5519                .specular_logs
5520                .last()
5521                .map(|s| s.as_str())
5522                .unwrap_or("...");
5523            let spinner = match app.tick_count % 8 {
5524                0 => "⠋",
5525                1 => "⠙",
5526                2 => "⠹",
5527                3 => "⠸",
5528                4 => "⠼",
5529                5 => "⠴",
5530                6 => "⠦",
5531                _ => "⠧",
5532            };
5533            let footer_caption = select_fitting_variant(
5534                &running_footer_variants(app, &elapsed, last_log),
5535                footer_row_width,
5536            );
5537
5538            Line::from(vec![
5539                Span::styled(
5540                    format!(" {} ", spinner),
5541                    Style::default()
5542                        .fg(Color::Cyan)
5543                        .add_modifier(Modifier::BOLD),
5544                ),
5545                Span::styled(
5546                    elapsed,
5547                    Style::default()
5548                        .bg(Color::Rgb(40, 40, 40))
5549                        .fg(Color::White)
5550                        .add_modifier(Modifier::BOLD),
5551                ),
5552                Span::styled(
5553                    format!(" ⬢ {}", footer_caption),
5554                    Style::default().fg(Color::DarkGray),
5555                ),
5556            ])
5557        } else {
5558            let idle_hint = select_fitting_variant(&idle_footer_variants(app), footer_row_width);
5559            Line::from(vec![
5560                Span::styled(" ⬢ ", Style::default().fg(Color::Rgb(40, 40, 40))),
5561                Span::styled(
5562                    idle_hint,
5563                    Style::default()
5564                        .fg(Color::DarkGray)
5565                        .add_modifier(Modifier::DIM),
5566                ),
5567            ])
5568        }
5569    };
5570
5571    let runtime_age = app.last_runtime_profile_time.elapsed();
5572    let provider_prefix = provider_badge_prefix(&app.provider_name);
5573    let issue = runtime_issue_kind(app);
5574    let (issue_code, issue_color) = runtime_issue_badge(issue);
5575    let (lm_label, lm_color) = if issue == RuntimeIssueKind::NoModel {
5576        (format!("{provider_prefix}:NONE"), Color::Red)
5577    } else if issue == RuntimeIssueKind::Booting {
5578        (format!("{provider_prefix}:BOOT"), Color::DarkGray)
5579    } else if issue == RuntimeIssueKind::Recovering {
5580        (format!("{provider_prefix}:RECV"), Color::Cyan)
5581    } else if matches!(
5582        issue,
5583        RuntimeIssueKind::Connectivity | RuntimeIssueKind::EmptyResponse
5584    ) {
5585        (format!("{provider_prefix}:WARN"), Color::Red)
5586    } else if issue == RuntimeIssueKind::ContextCeiling {
5587        (format!("{provider_prefix}:CEIL"), Color::Yellow)
5588    } else if runtime_age > std::time::Duration::from_secs(120) {
5589        (format!("{provider_prefix}:STALE"), Color::Yellow)
5590    } else {
5591        (format!("{provider_prefix}:LIVE"), Color::Green)
5592    };
5593    let compaction_percent = app.compaction_percent.min(100);
5594    let compaction_label = if app.compaction_threshold_tokens == 0 {
5595        " CMP:  0%".to_string()
5596    } else {
5597        format!(" CMP:{:>3}%", compaction_percent)
5598    };
5599    let compaction_color = if app.compaction_threshold_tokens == 0 {
5600        Color::DarkGray
5601    } else if compaction_percent >= 85 {
5602        Color::Red
5603    } else if compaction_percent >= 60 {
5604        Color::Yellow
5605    } else {
5606        Color::Green
5607    };
5608    let prompt_percent = app.prompt_pressure_percent.min(100);
5609    let prompt_label = if app.prompt_estimated_total_tokens == 0 {
5610        " BUD:  0%".to_string()
5611    } else {
5612        format!(" BUD:{:>3}%", prompt_percent)
5613    };
5614    let prompt_color = if app.prompt_estimated_total_tokens == 0 {
5615        Color::DarkGray
5616    } else if prompt_percent >= 85 {
5617        Color::Red
5618    } else if prompt_percent >= 60 {
5619        Color::Yellow
5620    } else {
5621        Color::Green
5622    };
5623
5624    let think_badge = match app.think_mode {
5625        Some(true) => " [THINK]",
5626        Some(false) => " [FAST]",
5627        None => "",
5628    };
5629
5630    // ── VRAM gauge (live from nvidia-smi poller) ─────────────────────────────
5631    let vram_ratio = app.gpu_state.ratio();
5632    let vram_label = app.gpu_state.label();
5633    let gpu_name = app.gpu_state.gpu_name();
5634
5635    let (vein_label, vein_color) = if app.vein_docs_only {
5636        let color = if app.vein_embedded_count > 0 {
5637            Color::Green
5638        } else if app.vein_file_count > 0 {
5639            Color::Yellow
5640        } else {
5641            Color::DarkGray
5642        };
5643        ("VN:DOC", color)
5644    } else if app.vein_file_count == 0 {
5645        ("VN:--", Color::DarkGray)
5646    } else if app.vein_embedded_count > 0 {
5647        ("VN:SEM", Color::Green)
5648    } else {
5649        ("VN:FTS", Color::Yellow)
5650    };
5651
5652    let char_count: usize = app.messages_raw.iter().map(|(_, c)| c.len()).sum();
5653    let est_tokens = char_count / 3;
5654    let current_tokens = if app.total_tokens > 0 {
5655        app.total_tokens
5656    } else {
5657        est_tokens
5658    };
5659    let session_usage_text = format!(
5660        " TOKENS: {:0>5} | TOTAL: ${:.2} ",
5661        current_tokens, app.current_session_cost
5662    );
5663
5664    // ── Single Liquid Status Bar ──────────────────────────────────────────
5665    f.render_widget(Clear, bar_chunks[0]);
5666
5667    let usage_color = Color::Rgb(100, 100, 100);
5668    let ai_line = vec![
5669        Span::styled(
5670            format!(" {} ", lm_label),
5671            Style::default().fg(lm_color).add_modifier(Modifier::BOLD),
5672        ),
5673        Span::styled("║ ", Style::default().fg(Color::Rgb(60, 60, 60))),
5674        Span::styled(format!("{} ", vein_label), Style::default().fg(vein_color)),
5675        Span::styled("│ ", Style::default().fg(Color::Rgb(40, 40, 40))),
5676        Span::styled(format!("{} ", issue_code), Style::default().fg(issue_color)),
5677        Span::styled("│ ", Style::default().fg(Color::Rgb(40, 40, 40))),
5678        Span::styled(
5679            format!("CTX:{} ", app.context_length),
5680            Style::default().fg(Color::DarkGray),
5681        ),
5682        Span::styled("│ ", Style::default().fg(Color::Rgb(40, 40, 40))),
5683        Span::styled(
5684            format!("REMOTE:{} ", app.git_state.label()),
5685            Style::default().fg(Color::DarkGray),
5686        ),
5687        Span::styled("│ ", Style::default().fg(Color::Rgb(40, 40, 40))),
5688        Span::styled(prompt_label, Style::default().fg(prompt_color)),
5689        Span::styled(" ", Style::default().fg(Color::Rgb(40, 40, 40))),
5690        Span::styled(compaction_label, Style::default().fg(compaction_color)),
5691        Span::styled(
5692            format!("{} ", think_badge),
5693            Style::default().fg(Color::Cyan),
5694        ),
5695        Span::styled(
5696            vigil_badge.to_string(),
5697            Style::default()
5698                .fg(Color::Yellow)
5699                .add_modifier(Modifier::BOLD),
5700        ),
5701        Span::styled(
5702            yolo_badge.to_string(),
5703            Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
5704        ),
5705        Span::styled(" │ ", Style::default().fg(Color::Rgb(40, 40, 40))),
5706        Span::styled(session_usage_text, Style::default().fg(usage_color)),
5707    ];
5708
5709    let hardware_line = vec![
5710        Span::styled("   ⬢ ", Style::default().fg(Color::Rgb(60, 60, 60))), // Gray tint
5711        Span::styled(
5712            format!("{} ", gpu_name),
5713            Style::default()
5714                .fg(Color::Rgb(200, 200, 200))
5715                .add_modifier(Modifier::BOLD),
5716        ),
5717        Span::styled("║ ", Style::default().fg(Color::Rgb(60, 60, 60))),
5718        Span::styled(
5719            format!(
5720                "[{}] ",
5721                make_animated_sparkline_gauge(vram_ratio, 12, app.tick_count)
5722            ),
5723            Style::default().fg(Color::Cyan),
5724        ),
5725        Span::styled(
5726            format!("{}% ", (vram_ratio * 100.0) as u8),
5727            Style::default().fg(Color::Cyan),
5728        ),
5729        Span::styled(
5730            format!("({})", vram_label),
5731            Style::default()
5732                .fg(Color::DarkGray)
5733                .add_modifier(Modifier::DIM),
5734        ),
5735    ];
5736
5737    f.render_widget(
5738        Paragraph::new(vec![
5739            Line::from(ai_line),
5740            Line::from(hardware_line),
5741            footer_row,
5742        ])
5743        .block(
5744            Block::default()
5745                .borders(Borders::ALL)
5746                .border_style(Style::default().fg(Color::Rgb(60, 60, 60))),
5747        ),
5748        bar_chunks[0],
5749    );
5750
5751    // ── Box 4: Input ──────────────────────────────────────────────────────────
5752    let input_border_color = if app.agent_running {
5753        Color::Rgb(60, 60, 60)
5754    } else {
5755        Color::Rgb(100, 100, 100) // High-focus gray glow
5756    };
5757    let input_rect = chunks[1];
5758    let title_area = input_title_area(input_rect);
5759    let input_hint = render_input_title(app, title_area);
5760    let input_block = Block::default()
5761        .title(input_hint)
5762        .borders(Borders::ALL)
5763        .border_style(Style::default().fg(input_border_color))
5764        .style(Style::default().bg(Color::Rgb(25, 25, 25))); // Obsidian Dark Gray
5765
5766    let inner_area = input_block.inner(input_rect);
5767    f.render_widget(Clear, input_rect);
5768    f.render_widget(input_block, input_rect);
5769
5770    f.render_widget(
5771        Paragraph::new(app.input.as_str()).wrap(Wrap { trim: true }),
5772        inner_area,
5773    );
5774
5775    // Hardware Cursor (Managed by terminal emulator for smooth asynchronous blink)
5776    // Hardware Cursor (Managed by terminal emulator for smooth asynchronous blink)
5777    // Always call set_cursor during standard operation to "park" the cursor safely in the input box,
5778    // preventing it from jittering to (0,0) (the top-left title) during modal reviews.
5779    if !app.agent_running && inner_area.height > 0 {
5780        let text_w = app.input.len() as u16;
5781        let max_w = inner_area.width.saturating_sub(1);
5782        let cursor_x = inner_area.x + text_w.min(max_w);
5783        f.set_cursor_position((cursor_x, inner_area.y));
5784    }
5785
5786    // ── High-risk approval modal ───────────────────────────────────────────────
5787    if let Some(approval) = &app.awaiting_approval {
5788        let is_diff_preview = approval.diff.is_some();
5789
5790        // Taller modal for diff preview so more lines are visible.
5791        let modal_h = if is_diff_preview { 70 } else { 50 };
5792        let area = centered_rect(80, modal_h, f.area());
5793        f.render_widget(Clear, area);
5794
5795        let chunks = Layout::default()
5796            .direction(Direction::Vertical)
5797            .constraints([
5798                Constraint::Length(4), // Header: Title + Instructions
5799                Constraint::Min(0),    // Body: Tool + diff/command
5800            ])
5801            .split(area);
5802
5803        // ── Modal Header ─────────────────────────────────────────────────────
5804        let (title_str, title_color) = if approval.mutation_label.is_some() {
5805            (" MUTATION REQUESTED — AUTHORISE THE WORKFLOW ", Color::Cyan)
5806        } else if is_diff_preview {
5807            (" DIFF PREVIEW — REVIEW BEFORE APPLYING ", Color::Yellow)
5808        } else {
5809            (" HIGH-RISK OPERATION REQUESTED ", Color::Red)
5810        };
5811        let header_text = vec![
5812            Line::from(Span::styled(
5813                title_str,
5814                Style::default()
5815                    .fg(title_color)
5816                    .add_modifier(Modifier::BOLD),
5817            )),
5818            if is_diff_preview {
5819                Line::from(Span::styled(
5820                    "  [↑↓/jk/PgUp/PgDn] Scroll   [Y] Apply   [N] Skip   [A] Accept All ",
5821                    Style::default()
5822                        .fg(Color::Green)
5823                        .add_modifier(Modifier::BOLD),
5824                ))
5825            } else {
5826                Line::from(vec![
5827                    Span::styled(
5828                        "  [Y] Approve  ",
5829                        Style::default()
5830                            .fg(Color::Green)
5831                            .add_modifier(Modifier::BOLD),
5832                    ),
5833                    Span::styled(
5834                        "  [N] Decline  ",
5835                        Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
5836                    ),
5837                    Span::styled(
5838                        "  [A] Accept All ",
5839                        Style::default()
5840                            .fg(Color::Magenta)
5841                            .add_modifier(Modifier::BOLD),
5842                    ),
5843                ])
5844            },
5845        ];
5846        f.render_widget(
5847            Paragraph::new(header_text)
5848                .block(
5849                    Block::default()
5850                        .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
5851                        .border_style(Style::default().fg(title_color)),
5852                )
5853                .alignment(ratatui::layout::Alignment::Center),
5854            chunks[0],
5855        );
5856
5857        // ── Modal Body ───────────────────────────────────────────────────────
5858        let border_color = if approval.mutation_label.is_some() {
5859            Color::Cyan
5860        } else if is_diff_preview {
5861            Color::Yellow
5862        } else {
5863            Color::Red
5864        };
5865        if let Some(diff_text) = &approval.diff {
5866            // Render colored diff lines
5867            let added = diff_text.lines().filter(|l| l.starts_with("+ ")).count();
5868            let removed = diff_text.lines().filter(|l| l.starts_with("- ")).count();
5869            let mut body_lines: Vec<Line> = vec![
5870                Line::from(Span::styled(
5871                    if let Some(label) = &approval.mutation_label {
5872                        format!(" INTENT: {}", label)
5873                    } else {
5874                        format!(" {}", approval.display)
5875                    },
5876                    Style::default()
5877                        .fg(Color::Cyan)
5878                        .add_modifier(Modifier::BOLD),
5879                )),
5880                Line::from(vec![
5881                    Span::styled(
5882                        format!(" +{}", added),
5883                        Style::default()
5884                            .fg(Color::Green)
5885                            .add_modifier(Modifier::BOLD),
5886                    ),
5887                    Span::styled(
5888                        format!(" -{}", removed),
5889                        Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
5890                    ),
5891                ]),
5892                Line::from(Span::raw("")),
5893            ];
5894            for raw_line in diff_text.lines() {
5895                let styled = if raw_line.starts_with("+ ") {
5896                    Line::from(Span::styled(
5897                        format!(" {}", raw_line),
5898                        Style::default().fg(Color::Green),
5899                    ))
5900                } else if raw_line.starts_with("- ") {
5901                    Line::from(Span::styled(
5902                        format!(" {}", raw_line),
5903                        Style::default().fg(Color::Red),
5904                    ))
5905                } else if raw_line.starts_with("---") || raw_line.starts_with("@@ ") {
5906                    Line::from(Span::styled(
5907                        format!(" {}", raw_line),
5908                        Style::default()
5909                            .fg(Color::DarkGray)
5910                            .add_modifier(Modifier::BOLD),
5911                    ))
5912                } else {
5913                    Line::from(Span::raw(format!(" {}", raw_line)))
5914                };
5915                body_lines.push(styled);
5916            }
5917            f.render_widget(
5918                Paragraph::new(body_lines)
5919                    .block(
5920                        Block::default()
5921                            .borders(Borders::BOTTOM | Borders::LEFT | Borders::RIGHT)
5922                            .border_style(Style::default().fg(border_color)),
5923                    )
5924                    .scroll((approval.diff_scroll, 0)),
5925                chunks[1],
5926            );
5927        } else {
5928            let body_text = vec![
5929                Line::from(Span::raw("")),
5930                Line::from(Span::styled(
5931                    if let Some(label) = &approval.mutation_label {
5932                        format!(" INTENT: {}", label)
5933                    } else {
5934                        format!(" ACTION: {}", approval.display)
5935                    },
5936                    Style::default()
5937                        .fg(Color::Cyan)
5938                        .add_modifier(Modifier::BOLD),
5939                )),
5940                Line::from(Span::raw("")),
5941                Line::from(Span::styled(
5942                    format!("  Tool: {}", approval.tool_name),
5943                    Style::default().fg(Color::DarkGray),
5944                )),
5945            ];
5946            if approval.mutation_label.is_some() {
5947                // For mutations, show the original display (e.g. path) as extra info
5948            }
5949            f.render_widget(
5950                Paragraph::new(body_text)
5951                    .block(
5952                        Block::default()
5953                            .borders(Borders::BOTTOM | Borders::LEFT | Borders::RIGHT)
5954                            .border_style(Style::default().fg(border_color)),
5955                    )
5956                    .alignment(ratatui::layout::Alignment::Center),
5957                chunks[1],
5958            );
5959        }
5960    }
5961
5962    // ── Swarm diff review modal ────────────────────────────────────────────────
5963    if let Some(review) = &app.active_review {
5964        draw_diff_review(f, review);
5965    }
5966
5967    // ── Autocomplete Hatch (Floating Popup) ──────────────────────────────────
5968    if app.show_autocomplete && !app.autocomplete_suggestions.is_empty() {
5969        let area = Rect {
5970            x: chunks[1].x + 2,
5971            y: chunks[1]
5972                .y
5973                .saturating_sub(app.autocomplete_suggestions.len() as u16 + 2),
5974            width: chunks[1].width.saturating_sub(4),
5975            height: app.autocomplete_suggestions.len() as u16 + 2,
5976        };
5977        f.render_widget(Clear, area);
5978
5979        let items: Vec<ListItem> = app
5980            .autocomplete_suggestions
5981            .iter()
5982            .enumerate()
5983            .map(|(i, s)| {
5984                let style = if i == app.selected_suggestion {
5985                    Style::default()
5986                        .fg(Color::Black)
5987                        .bg(Color::Cyan)
5988                        .add_modifier(Modifier::BOLD)
5989                } else {
5990                    Style::default().fg(Color::Gray)
5991                };
5992                ListItem::new(format!(" 📄 {}", s)).style(style)
5993            })
5994            .collect();
5995
5996        let hatch = List::new(items).block(
5997            Block::default()
5998                .borders(Borders::ALL)
5999                .border_style(Style::default().fg(Color::Cyan))
6000                .title(format!(
6001                    " @ RESOLVER (Matching: {}) ",
6002                    app.autocomplete_filter
6003                )),
6004        );
6005        f.render_widget(hatch, area);
6006
6007        // Optional "More matches..." indicator
6008        if app.autocomplete_suggestions.len() >= 15 {
6009            let more_area = Rect {
6010                x: area.x + 2,
6011                y: area.y + area.height - 1,
6012                width: 20,
6013                height: 1,
6014            };
6015            f.render_widget(
6016                Paragraph::new("... (type to narrow) ").style(Style::default().fg(Color::DarkGray)),
6017                more_area,
6018            );
6019        }
6020    }
6021}
6022
6023// ── Helpers ───────────────────────────────────────────────────────────────────
6024
6025fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
6026    let vert = Layout::default()
6027        .direction(Direction::Vertical)
6028        .constraints([
6029            Constraint::Percentage((100 - percent_y) / 2),
6030            Constraint::Percentage(percent_y),
6031            Constraint::Percentage((100 - percent_y) / 2),
6032        ])
6033        .split(r);
6034    Layout::default()
6035        .direction(Direction::Horizontal)
6036        .constraints([
6037            Constraint::Percentage((100 - percent_x) / 2),
6038            Constraint::Percentage(percent_x),
6039            Constraint::Percentage((100 - percent_x) / 2),
6040        ])
6041        .split(vert[1])[1]
6042}
6043
6044fn strip_ghost_prefix(s: &str) -> &str {
6045    for prefix in &[
6046        "Hematite: ",
6047        "HEMATITE: ",
6048        "Assistant: ",
6049        "assistant: ",
6050        "Okay, ",
6051        "Hmm, ",
6052        "Wait, ",
6053        "Alright, ",
6054        "Got it, ",
6055        "Certainly, ",
6056        "Sure, ",
6057        "Understood, ",
6058    ] {
6059        if s.to_lowercase().starts_with(&prefix.to_lowercase()) {
6060            return &s[prefix.len()..];
6061        }
6062    }
6063    s
6064}
6065
6066fn first_n_chars(s: &str, n: usize) -> String {
6067    let mut result = String::with_capacity(n.min(s.len()));
6068    for (count, c) in s.chars().enumerate() {
6069        if count >= n {
6070            result.push('…');
6071            break;
6072        }
6073        if c == '\n' || c == '\r' {
6074            result.push(' ');
6075        } else if !c.is_control() {
6076            result.push(c);
6077        }
6078    }
6079    result
6080}
6081
6082fn trim_vec_context(v: &mut Vec<ContextFile>, max: usize) {
6083    while v.len() > max {
6084        v.remove(0);
6085    }
6086}
6087
6088fn trim_vec(v: &mut Vec<String>, max: usize) {
6089    while v.len() > max {
6090        v.remove(0);
6091    }
6092}
6093
6094/// Minimal markdown → ratatui spans for the SPECULAR panel.
6095/// Handles: # headers, **bold**, `code`, - bullet, > blockquote, plain text.
6096fn render_markdown_line(raw: &str) -> Vec<Line<'static>> {
6097    // 1. Strip ANSI and control noise first to verify content.
6098    let cleaned_ansi = strip_ansi(raw);
6099    let trimmed = cleaned_ansi.trim();
6100    if trimmed.is_empty() {
6101        return vec![Line::raw("")];
6102    }
6103
6104    // 2. Strip thought tags.
6105    let cleaned_owned = trimmed
6106        .replace("<thought>", "")
6107        .replace("</thought>", "")
6108        .replace("<think>", "")
6109        .replace("</think>", "");
6110    let trimmed = cleaned_owned.trim();
6111    if trimmed.is_empty() {
6112        return vec![];
6113    }
6114
6115    // # Heading (all levels → bold white)
6116    for (prefix, indent) in &[("### ", "  "), ("## ", " "), ("# ", "")] {
6117        if let Some(rest) = trimmed.strip_prefix(prefix) {
6118            return vec![Line::from(vec![Span::styled(
6119                format!("{}{}", indent, rest),
6120                Style::default()
6121                    .fg(Color::White)
6122                    .add_modifier(Modifier::BOLD),
6123            )])];
6124        }
6125    }
6126
6127    // > blockquote
6128    if let Some(rest) = trimmed
6129        .strip_prefix("> ")
6130        .or_else(|| trimmed.strip_prefix(">"))
6131    {
6132        return vec![Line::from(vec![
6133            Span::styled("| ", Style::default().fg(Color::DarkGray)),
6134            Span::styled(
6135                rest.to_string(),
6136                Style::default()
6137                    .fg(Color::White)
6138                    .add_modifier(Modifier::DIM),
6139            ),
6140        ])];
6141    }
6142
6143    // - / * bullet
6144    if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
6145        let rest = &trimmed[2..];
6146        let mut spans = vec![Span::styled("* ", Style::default().fg(Color::Gray))];
6147        spans.extend(inline_markdown(rest));
6148        return vec![Line::from(spans)];
6149    }
6150
6151    // Plain line with possible inline markdown
6152    let spans = inline_markdown(trimmed);
6153    vec![Line::from(spans)]
6154}
6155
6156/// Inline markdown for The Core chat window (brighter palette than SPECULAR).
6157fn inline_markdown_core(text: &str) -> Vec<Span<'static>> {
6158    let mut spans = Vec::with_capacity(4);
6159    let mut remaining = text;
6160
6161    while !remaining.is_empty() {
6162        if let Some(start) = remaining.find("**") {
6163            let before = &remaining[..start];
6164            if !before.is_empty() {
6165                spans.push(Span::raw(before.to_string()));
6166            }
6167            let after_open = &remaining[start + 2..];
6168            if let Some(end) = after_open.find("**") {
6169                spans.push(Span::styled(
6170                    after_open[..end].to_string(),
6171                    Style::default()
6172                        .fg(Color::White)
6173                        .add_modifier(Modifier::BOLD),
6174                ));
6175                remaining = &after_open[end + 2..];
6176                continue;
6177            }
6178        }
6179        if let Some(start) = remaining.find('`') {
6180            let before = &remaining[..start];
6181            if !before.is_empty() {
6182                spans.push(Span::raw(before.to_string()));
6183            }
6184            let after_open = &remaining[start + 1..];
6185            if let Some(end) = after_open.find('`') {
6186                spans.push(Span::styled(
6187                    after_open[..end].to_string(),
6188                    Style::default().fg(Color::Yellow),
6189                ));
6190                remaining = &after_open[end + 1..];
6191                continue;
6192            }
6193        }
6194        spans.push(Span::raw(remaining.to_string()));
6195        break;
6196    }
6197    spans
6198}
6199
6200/// Parse inline `**bold**` and `` `code` `` — shared by SPECULAR and Core renderers.
6201fn inline_markdown(text: &str) -> Vec<Span<'static>> {
6202    let mut spans = Vec::with_capacity(4);
6203    let mut remaining = text;
6204
6205    while !remaining.is_empty() {
6206        if let Some(start) = remaining.find("**") {
6207            let before = &remaining[..start];
6208            if !before.is_empty() {
6209                spans.push(Span::raw(before.to_string()));
6210            }
6211            let after_open = &remaining[start + 2..];
6212            if let Some(end) = after_open.find("**") {
6213                spans.push(Span::styled(
6214                    after_open[..end].to_string(),
6215                    Style::default()
6216                        .fg(Color::White)
6217                        .add_modifier(Modifier::BOLD),
6218                ));
6219                remaining = &after_open[end + 2..];
6220                continue;
6221            }
6222        }
6223        if let Some(start) = remaining.find('`') {
6224            let before = &remaining[..start];
6225            if !before.is_empty() {
6226                spans.push(Span::raw(before.to_string()));
6227            }
6228            let after_open = &remaining[start + 1..];
6229            if let Some(end) = after_open.find('`') {
6230                spans.push(Span::styled(
6231                    after_open[..end].to_string(),
6232                    Style::default().fg(Color::Yellow),
6233                ));
6234                remaining = &after_open[end + 1..];
6235                continue;
6236            }
6237        }
6238        spans.push(Span::raw(remaining.to_string()));
6239        break;
6240    }
6241    spans
6242}
6243
6244// ── Splash Screen ─────────────────────────────────────────────────────────────
6245
6246fn make_starfield(width: u16, rows: u16, seed: u64, tick: u64) -> Vec<String> {
6247    let mut lines = Vec::with_capacity(rows as usize);
6248
6249    for y in 0..rows {
6250        let mut line = String::with_capacity(width as usize);
6251
6252        for x in 0..width {
6253            let n = (x as u64).wrapping_mul(73_856_093)
6254                ^ (y as u64).wrapping_mul(19_349_663)
6255                ^ seed
6256                ^ tick.wrapping_mul(83_492_791);
6257
6258            let ch = match n % 97 {
6259                0 => '*',
6260                1 | 2 => '.',
6261                3 => '+',
6262                _ => ' ',
6263            };
6264
6265            line.push(ch);
6266        }
6267
6268        lines.push(line);
6269    }
6270
6271    lines
6272}
6273
6274// ── Splash Screen ─────────────────────────────────────────────────────────────
6275
6276fn draw_splash<B: Backend>(terminal: &mut Terminal<B>) -> Result<(), Box<dyn std::error::Error>> {
6277    let logo_color = Color::Rgb(118, 118, 124);
6278    let star_color = Color::White;
6279    let sub_logo_color = Color::DarkGray;
6280    let tagline_color = Color::Gray;
6281    let author_color = Color::DarkGray;
6282
6283    let wide_logo = vec![
6284        "██╗  ██╗███████╗███╗   ███╗ █████╗ ████████╗██╗████████╗███████╗",
6285        "██║  ██║██╔════╝████╗ ████║██╔══██╗╚══██╔══╝██║╚══██╔══╝██╔════╝",
6286        "███████║█████╗  ██╔████╔██║███████║   ██║   ██║   ██║   █████╗  ",
6287        "██╔══██║██╔══╝  ██║╚██╔╝██║██╔══██║   ██║   ██║   ██║   ██╔══╝  ",
6288        "██║  ██║███████╗██║ ╚═╝ ██║██║  ██║   ██║   ██║   ██║   ███████╗",
6289        "╚═╝  ╚═╝╚══════╝╚═╝     ╚═╝╚═╝  ╚═╝   ╚═╝   ╚═╝   ╚═╝   ╚══════╝",
6290    ];
6291
6292    let version = env!("CARGO_PKG_VERSION");
6293
6294    terminal.draw(|f| {
6295        let area = f.area();
6296
6297        f.render_widget(
6298            Block::default().style(Style::default().bg(Color::Black)),
6299            area,
6300        );
6301
6302        let now = SystemTime::now()
6303            .duration_since(UNIX_EPOCH)
6304            .unwrap_or_default();
6305        let tick = (now.as_millis() / 350) as u64;
6306
6307        let top_stars = make_starfield(area.width, 3, 0xA11CE, tick);
6308        let bottom_stars = make_starfield(area.width, 2, 0xBADC0DE, tick + 17);
6309
6310        // total content:
6311        // top_stars(3)
6312        // logo(6)
6313        // sub_logo(1)
6314        // spacer(1)
6315        // version(1)
6316        // tagline(1)
6317        // author(1)
6318        // spacer(1)
6319        // bottom_stars(2)
6320        // spacer(1)
6321        // prompt(1)
6322        let content_height: u16 = 19;
6323        let top_pad = area.height.saturating_sub(content_height) / 2;
6324
6325        let mut lines: Vec<Line<'static>> =
6326            Vec::with_capacity((top_pad + content_height) as usize + 4);
6327
6328        for _ in 0..top_pad {
6329            lines.push(Line::raw(""));
6330        }
6331
6332        // Top starfield
6333        for line in top_stars {
6334            lines.push(Line::from(Span::styled(
6335                line,
6336                Style::default()
6337                    .fg(star_color)
6338                    .add_modifier(Modifier::BOLD)
6339                    .add_modifier(Modifier::DIM),
6340            )));
6341        }
6342
6343        // Main logo
6344        for line in &wide_logo {
6345            lines.push(Line::from(Span::styled(
6346                (*line).to_string(),
6347                Style::default().fg(logo_color).add_modifier(Modifier::BOLD),
6348            )));
6349        }
6350
6351        // Sub-logo
6352        lines.push(Line::from(Span::styled(
6353            "                                   -- cli --".to_string(),
6354            Style::default()
6355                .fg(sub_logo_color)
6356                .add_modifier(Modifier::DIM),
6357        )));
6358
6359        lines.push(Line::raw(""));
6360
6361        // Version
6362        lines.push(Line::from(Span::styled(
6363            format!("v{}", version),
6364            Style::default().fg(sub_logo_color),
6365        )));
6366
6367        // Tagline
6368        lines.push(Line::from(Span::styled(
6369            "Local AI coding harness + workstation assistant".to_string(),
6370            Style::default().fg(tagline_color),
6371        )));
6372
6373        // Author
6374        lines.push(Line::from(Span::styled(
6375            "developed by Ocean Bennett".to_string(),
6376            Style::default()
6377                .fg(author_color)
6378                .add_modifier(Modifier::DIM),
6379        )));
6380
6381        lines.push(Line::raw(""));
6382
6383        // Bottom starfield
6384        for line in bottom_stars {
6385            lines.push(Line::from(Span::styled(
6386                line,
6387                Style::default()
6388                    .fg(star_color)
6389                    .add_modifier(Modifier::BOLD)
6390                    .add_modifier(Modifier::DIM),
6391            )));
6392        }
6393
6394        lines.push(Line::raw(""));
6395
6396        // Prompt
6397        lines.push(Line::from(vec![
6398            Span::styled("[ ", Style::default().fg(logo_color)),
6399            Span::styled(
6400                "PRESS ENTER TO START",
6401                Style::default()
6402                    .fg(Color::White)
6403                    .add_modifier(Modifier::BOLD),
6404            ),
6405            Span::styled(" ]", Style::default().fg(logo_color)),
6406        ]));
6407
6408        let splash = Paragraph::new(lines).alignment(Alignment::Center);
6409        f.render_widget(splash, area);
6410    })?;
6411
6412    Ok(())
6413}
6414
6415fn normalize_id(id: &str) -> String {
6416    id.trim().to_uppercase()
6417}
6418
6419fn filter_tui_noise(text: &str) -> String {
6420    // 1. First Pass: Strip ANSI escape codes that cause "shattering" in layout.
6421    let cleaned = strip_ansi(text);
6422
6423    // 2. Second Pass: Filter heuristic noise.
6424    let mut lines = Vec::with_capacity(cleaned.matches('\n').count() + 1);
6425    for line in cleaned.lines() {
6426        // Strip multi-line "LF replaced by CRLF" noise frequently emitted by git/shell on Windows.
6427        if CRLF_REGEX.is_match(line) {
6428            continue;
6429        }
6430        // Strip git checkout/file update noise if it's too repetitive.
6431        if line.contains("Updating files:") && line.contains("%") {
6432            continue;
6433        }
6434        // Strip random terminal control characters that might have escaped.
6435        let mut sanitized = String::with_capacity(line.len());
6436        for c in line.chars() {
6437            if !c.is_control() || c == '\t' {
6438                sanitized.push(c);
6439            }
6440        }
6441        if sanitized.trim().is_empty() && !line.trim().is_empty() {
6442            continue;
6443        }
6444
6445        lines.push(normalize_tui_text(&sanitized));
6446    }
6447    lines.join("\n").trim().to_string()
6448}
6449
6450fn normalize_tui_text(text: &str) -> String {
6451    let mut normalized = text
6452        .replace("ΓÇö", "-")
6453        .replace("ΓÇô", "-")
6454        .replace("…", "...")
6455        .replace("✅", "[OK]")
6456        .replace("🛠️", "")
6457        .replace("—", "-")
6458        .replace("–", "-")
6459        .replace("…", "...")
6460        .replace("•", "*")
6461        .replace("✅", "[OK]")
6462        .replace("🚨", "[!]");
6463
6464    normalized = normalized
6465        .chars()
6466        .map(|c| match c {
6467            '\u{00A0}' => ' ',
6468            '\u{2018}' | '\u{2019}' => '\'',
6469            '\u{201C}' | '\u{201D}' => '"',
6470            c if c.is_ascii() || c == '\n' || c == '\t' => c,
6471            _ => ' ',
6472        })
6473        .collect();
6474
6475    let mut compacted = String::with_capacity(normalized.len());
6476    let mut prev_space = false;
6477    for ch in normalized.chars() {
6478        if ch == ' ' {
6479            if !prev_space {
6480                compacted.push(ch);
6481            }
6482            prev_space = true;
6483        } else {
6484            compacted.push(ch);
6485            prev_space = false;
6486        }
6487    }
6488
6489    compacted.trim().to_string()
6490}