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