Skip to main content

hematite/ui/
tui.rs

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