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 Commands:\n\
2776         /chat             - (Mode) Conversation mode - clean chat, no tool noise\n\
2777         /agent            - (Mode) Full coding harness + workstation mode - tools, file edits, builds, inspection\n\
2778         /reroll           - (Soul) Hatch a new companion mid-session\n\
2779         /auto             - (Flow) Let Hematite choose the narrowest effective workflow\n\
2780         /rules [view|edit]- (Meta) View status or edit local/shared project guidelines\n\
2781         /skills           - (Meta) View discovered Agent Skills (`SKILL.md` catalogs)\n\
2782         /ask [prompt]     - (Flow) Read-only analysis mode; optional inline prompt\n\
2783         /code [prompt]    - (Flow) Explicit implementation mode; optional inline prompt\n\
2784         /architect [prompt] - (Flow) Plan-first mode; optional inline prompt\n\
2785         /implement-plan   - (Flow) Execute the saved architect handoff in /code\n\
2786         /read-only [prompt] - (Flow) Hard read-only mode; optional inline prompt\n\
2787         /teach [prompt]   - (Flow) Teacher mode; inspect machine then walk you through any admin task step-by-step\n\
2788         /new              - (Reset) Fresh task context; clear chat, pins, and task files\n\
2789         /forget           - (Wipe) Hard forget; purge saved memory and Vein index too\n\
2790         /cd <path>        - (Nav) Teleport to another directory and close this session; supports bare tokens like downloads, desktop, docs, home, temp, and ~, plus aliases like @DESKTOP/project\n\
2791         /ls [path|N]      - (Nav) List common locations or subdirectories; use /ls desktop, then /ls <N> to teleport to a numbered entry\n\
2792         /vein-inspect     - (Vein) Inspect indexed memory, hot files, and active room bias\n\
2793         /workspace-profile - (Profile) Show the auto-generated workspace profile\n\
2794         /rules            - (Rules) View project guidance (CLAUDE.md, SKILLS.md, .hematite/rules.md)\n\
2795         /skills           - (Skills) View directory-based Agent Skills\n\
2796         /version          - (Build) Show the running Hematite version\n\
2797         /about            - (Info) Show author, repo, and product info\n\
2798         /vein-reset       - (Vein) Wipe the RAG index; rebuilds automatically on next turn\n\
2799         /clear            - (UI) Clear dialogue display only\n\
2800         /health           - (Diag) Run a synthesized plain-English system health report\n\
2801         /explain <text>   - (Help) Paste an error to get a non-technical breakdown\n\
2802         /gemma-native [auto|on|off|status] - (Model) Auto/force/disable Gemma 4 native formatting\n\
2803         /provider [status|lmstudio|ollama|clear|URL] - (Model) Show or save the active provider endpoint preference\n\
2804         /runtime          - (Model) Show the live runtime/provider/model/embed status and shortest fix path\n\
2805         /runtime fix      - (Model) Run the shortest safe runtime recovery step now\n\
2806         /runtime-refresh  - (Model) Re-read active provider model + CTX now\n\
2807         /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\
2808         /embed [status|load <id>|unload [id|current]|prefer <id>|clear] - (Model) Inspect, load, unload, or save the preferred embed model\n\
2809         /undo             - (Ghost) Revert last file change\n\
2810         /diff             - (Git) Show session changes (--stat)\n\
2811         /lsp              - (Logic) Start Language Servers (semantic intelligence)\n\
2812         /swarm <text>     - (Swarm) Spawn parallel workers on a directive\n\
2813         /worktree <cmd>   - (Isolated) Manage git worktrees (list|add|remove|prune)\n\
2814         /think            - (Brain) Enable deep reasoning mode\n\
2815         /no_think         - (Speed) Disable reasoning (3-5x faster responses)\n\
2816         /voice            - (TTS) List all available voices\n\
2817         /voice N          - (TTS) Select voice by number\n\
2818         /read <text>      - (TTS) Speak text aloud directly, bypassing the model. ESC to stop.\n\
2819         /explain <text>   - (Plain English) Paste any error or output; Hematite explains it in plain English.\n\
2820         /health           - (SysAdmin) Run a full system health report (disk, RAM, tools, recent errors).\n\
2821         /attach <path>    - (Docs) Attach a PDF/markdown/txt file for next message (PDF best-effort)\n\
2822         /attach-pick      - (Docs) Open a file picker and attach a document\n\
2823         /image <path>     - (Vision) Attach an image for the next message\n\
2824         /image-pick       - (Vision) Open a file picker and attach an image\n\
2825         /detach           - (Context) Drop pending document/image attachments\n\
2826         /copy             - (Debug) Copy exact session transcript (includes help/system output)\n\
2827         /copy-last        - (Debug) Copy the latest Hematite reply only\n\
2828         /copy-clean       - (Debug) Copy chat transcript without help/debug boilerplate\n\
2829         /copy2            - (Debug) Copy the full SPECULAR rail to clipboard (reasoning + events)\n\
2830         \nHotkeys:\n\
2831         Ctrl+B - Toggle Brief Mode (minimal output; collapses side chrome)\n\
2832         Alt+↑/↓ - Scroll the SPECULAR rail by 3 lines\n\
2833         Alt+PgUp/PgDn - Scroll the SPECULAR rail by 10 lines\n\
2834         Alt+End - Snap SPECULAR back to live follow mode\n\
2835         Ctrl+P - Toggle Professional Mode (strip personality)\n\
2836         Ctrl+O - Open document picker for next-turn context\n\
2837         Ctrl+I - Open image picker for next-turn vision context\n\
2838         Ctrl+Y - Toggle Approvals Off (bypass safety approvals)\n\
2839         Ctrl+S - Quick Swarm (hardcoded bootstrap)\n\
2840         Ctrl+Z - Undo last edit\n\
2841         Ctrl+Q/C - Quit session\n\
2842         ESC    - Silence current playback\n\
2843         \nStatus Legend:\n\
2844         LM/OL - Provider runtime health (`LIVE`, `RECV`, `WARN`, `CEIL`, `STALE`, `BOOT`)\n\
2845         RT    - Primary runtime issue (`OK`, `MOD`, `NET`, `EMP`, `CTX`, `WAIT`)\n\
2846         VN    - Vein RAG status (`SEM`=semantic active, `FTS`=BM25 only, `--`=not indexed)\n\
2847         BUD   - Total prompt-budget pressure against the live context window\n\
2848         CMP   - History compaction pressure against Hematite's adaptive threshold\n\
2849         ERR   - Session error count (runtime, tool, or SPECULAR failures)\n\
2850         CTX   - Live context window currently reported by the provider\n\
2851         VOICE - Local speech output state\n\
2852         \nDocument note: `/attach` supports PDF/markdown/txt, but PDF parsing is best-effort by design so Hematite can stay a lightweight single-binary local coding harness and workstation assistant. If a PDF fails, export it to text/markdown or attach page images instead.\n\
2853         ",
2854    );
2855}
2856
2857#[allow(dead_code)]
2858fn show_help_message_legacy(app: &mut App) {
2859    app.push_message("System",
2860        "Hematite Commands:\n\
2861         /chat             — (Mode) Conversation mode — clean chat, no tool noise\n\
2862         /agent            — (Mode) Full coding harness + workstation mode — tools, file edits, builds, inspection\n\
2863         /reroll           — (Soul) Hatch a new companion mid-session\n\
2864         /auto             — (Flow) Let Hematite choose the narrowest effective workflow\n\
2865         /ask [prompt]     — (Flow) Read-only analysis mode; optional inline prompt\n\
2866         /code [prompt]    — (Flow) Explicit implementation mode; optional inline prompt\n\
2867         /architect [prompt] — (Flow) Plan-first mode; optional inline prompt\n\
2868         /implement-plan   — (Flow) Execute the saved architect handoff in /code\n\
2869         /read-only [prompt] — (Flow) Hard read-only mode; optional inline prompt\n\
2870         /teach [prompt]   — (Flow) Teacher mode; inspect machine then walk you through any admin task step-by-step\n\
2871           /new              — (Reset) Fresh task context; clear chat, pins, and task files\n\
2872           /forget           — (Wipe) Hard forget; purge saved memory and Vein index too\n\
2873           /vein-inspect     — (Vein) Inspect indexed memory, hot files, and active room bias\n\
2874           /workspace-profile — (Profile) Show the auto-generated workspace profile\n\
2875           /rules            — (Rules) View project guidance (CLAUDE.md, SKILLS.md, .hematite/rules.md)\n\
2876           /version          — (Build) Show the running Hematite version\n\
2877           /about            — (Info) Show author, repo, and product info\n\
2878           /vein-reset       — (Vein) Wipe the RAG index; rebuilds automatically on next turn\n\
2879           /clear            — (UI) Clear dialogue display only\n\
2880         /health           — (Diag) Run a synthesized plain-English system health report\n\
2881         /explain <text>   — (Help) Paste an error to get a non-technical breakdown\n\
2882         /gemma-native [auto|on|off|status] — (Model) Auto/force/disable Gemma 4 native formatting\n\
2883         /provider [status|lmstudio|ollama|clear|URL] — (Model) Show or save the active provider endpoint preference\n\
2884         /runtime          — (Model) Show the live runtime/provider/model/embed status and shortest fix path\n\
2885         /runtime fix      — (Model) Run the shortest safe runtime recovery step now\n\
2886         /runtime-refresh  — (Model) Re-read active provider model + CTX now\n\
2887         /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\
2888         /embed [status|load <id>|unload [id|current]|prefer <id>|clear] — (Model) Inspect, load, unload, or save the preferred embed model\n\
2889         /undo             — (Ghost) Revert last file change\n\
2890         /diff             — (Git) Show session changes (--stat)\n\
2891         /lsp              — (Logic) Start Language Servers (semantic intelligence)\n\
2892         /swarm <text>     — (Swarm) Spawn parallel workers on a directive\n\
2893         /worktree <cmd>   — (Isolated) Manage git worktrees (list|add|remove|prune)\n\
2894         /think            — (Brain) Enable deep reasoning mode\n\
2895         /no_think         — (Speed) Disable reasoning (3-5x faster responses)\n\
2896         /voice            — (TTS) List all available voices\n\
2897         /voice N          — (TTS) Select voice by number\n\
2898         /read <text>      — (TTS) Speak text aloud directly, bypassing the model. ESC to stop.\n\
2899         /explain <text>   — (Plain English) Paste any error or output; Hematite explains it in plain English.\n\
2900         /health           — (SysAdmin) Run a full system health report (disk, RAM, tools, recent errors).\n\
2901         /attach <path>    — (Docs) Attach a PDF/markdown/txt file for next message\n\
2902         /attach-pick      — (Docs) Open a file picker and attach a document\n\
2903         /image <path>     — (Vision) Attach an image for the next message\n\
2904         /image-pick       — (Vision) Open a file picker and attach an image\n\
2905         /detach           — (Context) Drop pending document/image attachments\n\
2906         /copy             — (Debug) Copy session transcript to clipboard\n\
2907         /copy2            — (Debug) Copy the full SPECULAR rail to clipboard (reasoning + events)\n\
2908         \nHotkeys:\n\
2909         Ctrl+B — Toggle Brief Mode (minimal output; collapses side chrome)\n\
2910         Alt+↑/↓ — Scroll the SPECULAR rail by 3 lines\n\
2911         Alt+PgUp/PgDn — Scroll the SPECULAR rail by 10 lines\n\
2912         Alt+End — Snap SPECULAR back to live follow mode\n\
2913         Ctrl+P — Toggle Professional Mode (strip personality)\n\
2914         Ctrl+O — Open document picker for next-turn context\n\
2915         Ctrl+I — Open image picker for next-turn vision context\n\
2916         Ctrl+Y — Toggle Approvals Off (bypass safety approvals)\n\
2917         Ctrl+S — Quick Swarm (hardcoded bootstrap)\n\
2918         Ctrl+Z — Undo last edit\n\
2919         Ctrl+Q/C — Quit session\n\
2920         ESC    — Silence current playback\n\
2921         \nStatus Legend:\n\
2922         LM/OL — Provider runtime health (`LIVE`, `RECV`, `WARN`, `CEIL`, `STALE`, `BOOT`)\n\
2923         RT    — Primary runtime issue (`OK`, `MOD`, `NET`, `EMP`, `CTX`, `WAIT`)\n\
2924         VN    — Vein RAG status (`SEM`=semantic active, `FTS`=BM25 only, `--`=not indexed)\n\
2925         BUD   — Total prompt-budget pressure against the live context window\n\
2926         CMP   — History compaction pressure against Hematite's adaptive threshold\n\
2927         ERR   — Session error count (runtime, tool, or SPECULAR failures)\n\
2928         CTX   — Live context window currently reported by the provider\n\
2929         VOICE — Local speech output state\n\
2930         \nAssistant: Semantic Pathing (LSP), Vision Pass, Web Research, Swarm Synthesis"
2931    );
2932    app.push_message(
2933        "System",
2934        "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.",
2935    );
2936}
2937
2938fn trigger_input_action(app: &mut App, action: InputAction) {
2939    match action {
2940        InputAction::Stop => request_stop(app),
2941        InputAction::PickDocument => match pick_attachment_path(AttachmentPickerKind::Document) {
2942            Ok(Some(path)) => attach_document_from_path(app, &path),
2943            Ok(None) => app.push_message("System", "Document picker cancelled."),
2944            Err(e) => app.push_message("System", &e),
2945        },
2946        InputAction::PickImage => match pick_attachment_path(AttachmentPickerKind::Image) {
2947            Ok(Some(path)) => attach_image_from_path(app, &path),
2948            Ok(None) => app.push_message("System", "Image picker cancelled."),
2949            Err(e) => app.push_message("System", &e),
2950        },
2951        InputAction::Detach => {
2952            app.clear_pending_attachments();
2953            app.push_message(
2954                "System",
2955                "Cleared pending document/image attachments for the next turn.",
2956            );
2957        }
2958        InputAction::New => {
2959            if !app.agent_running {
2960                reset_visible_session_state(app);
2961                app.push_message("You", "/new");
2962                app.agent_running = true;
2963                let _ = app.user_input_tx.try_send(UserTurn::text("/new"));
2964            }
2965        }
2966        InputAction::Forget => {
2967            if !app.agent_running {
2968                app.cancel_token
2969                    .store(true, std::sync::atomic::Ordering::SeqCst);
2970                reset_visible_session_state(app);
2971                app.push_message("You", "/forget");
2972                app.agent_running = true;
2973                app.cancel_token
2974                    .store(false, std::sync::atomic::Ordering::SeqCst);
2975                let _ = app.user_input_tx.try_send(UserTurn::text("/forget"));
2976            }
2977        }
2978        InputAction::Help => show_help_message(app),
2979    }
2980}
2981
2982fn pick_attachment_path(kind: AttachmentPickerKind) -> Result<Option<String>, String> {
2983    #[cfg(target_os = "windows")]
2984    {
2985        let (title, filter) = match kind {
2986            AttachmentPickerKind::Document => (
2987                "Attach document for the next Hematite turn",
2988                "Documents|*.pdf;*.md;*.markdown;*.txt;*.rst|All Files|*.*",
2989            ),
2990            AttachmentPickerKind::Image => (
2991                "Attach image for the next Hematite turn",
2992                "Images|*.png;*.jpg;*.jpeg;*.gif;*.webp|All Files|*.*",
2993            ),
2994        };
2995        let script = format!(
2996            "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 }}"
2997        );
2998        let output = std::process::Command::new("powershell")
2999            .args(["-NoProfile", "-STA", "-Command", &script])
3000            .output()
3001            .map_err(|e| format!("File picker failed: {}", e))?;
3002        if !output.status.success() {
3003            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
3004            return Err(if stderr.is_empty() {
3005                "File picker did not complete successfully.".to_string()
3006            } else {
3007                format!("File picker failed: {}", stderr)
3008            });
3009        }
3010        let selected = String::from_utf8_lossy(&output.stdout).trim().to_string();
3011        if selected.is_empty() {
3012            Ok(None)
3013        } else {
3014            Ok(Some(selected))
3015        }
3016    }
3017    #[cfg(target_os = "macos")]
3018    {
3019        let prompt = match kind {
3020            AttachmentPickerKind::Document => "Choose a document for the next Hematite turn",
3021            AttachmentPickerKind::Image => "Choose an image for the next Hematite turn",
3022        };
3023        let script = format!("POSIX path of (choose file with prompt \"{}\")", prompt);
3024        let output = std::process::Command::new("osascript")
3025            .args(["-e", &script])
3026            .output()
3027            .map_err(|e| format!("File picker failed: {}", e))?;
3028        if output.status.success() {
3029            let selected = String::from_utf8_lossy(&output.stdout).trim().to_string();
3030            if selected.is_empty() {
3031                Ok(None)
3032            } else {
3033                Ok(Some(selected))
3034            }
3035        } else {
3036            Ok(None)
3037        }
3038    }
3039    #[cfg(all(unix, not(target_os = "macos")))]
3040    {
3041        let title = match kind {
3042            AttachmentPickerKind::Document => "Attach document for the next Hematite turn",
3043            AttachmentPickerKind::Image => "Attach image for the next Hematite turn",
3044        };
3045        let output = std::process::Command::new("zenity")
3046            .args(["--file-selection", "--title", title])
3047            .output()
3048            .map_err(|e| format!("File picker failed: {}", e))?;
3049        if output.status.success() {
3050            let selected = String::from_utf8_lossy(&output.stdout).trim().to_string();
3051            if selected.is_empty() {
3052                Ok(None)
3053            } else {
3054                Ok(Some(selected))
3055            }
3056        } else {
3057            Ok(None)
3058        }
3059    }
3060}
3061
3062pub async fn run_app<B: Backend>(
3063    terminal: &mut Terminal<B>,
3064    mut specular_rx: Receiver<SpecularEvent>,
3065    mut agent_rx: Receiver<crate::agent::inference::InferenceEvent>,
3066    user_input_tx: tokio::sync::mpsc::Sender<UserTurn>,
3067    mut swarm_rx: Receiver<SwarmMessage>,
3068    swarm_tx: tokio::sync::mpsc::Sender<SwarmMessage>,
3069    swarm_coordinator: Arc<crate::agent::swarm::SwarmCoordinator>,
3070    last_interaction: Arc<Mutex<Instant>>,
3071    cockpit: crate::CliCockpit,
3072    soul: crate::ui::hatch::RustySoul,
3073    professional: bool,
3074    gpu_state: Arc<GpuState>,
3075    git_state: Arc<crate::agent::git_monitor::GitState>,
3076    cancel_token: Arc<std::sync::atomic::AtomicBool>,
3077    voice_manager: Arc<crate::ui::voice::VoiceManager>,
3078) -> Result<(), Box<dyn std::error::Error>> {
3079    let mut app = App {
3080        messages: Vec::new(),
3081        messages_raw: Vec::new(),
3082        specular_logs: Vec::new(),
3083        brief_mode: cockpit.brief,
3084        tick_count: 0,
3085        stats: RustyStats {
3086            debugging: 0,
3087            wisdom: soul.wisdom,
3088            patience: 100.0,
3089            chaos: soul.chaos,
3090            snark: soul.snark,
3091        },
3092        yolo_mode: cockpit.yolo,
3093        awaiting_approval: None,
3094        active_workers: HashMap::new(),
3095        worker_labels: HashMap::new(),
3096        active_review: None,
3097        input: String::new(),
3098        input_history: Vec::new(),
3099        history_idx: None,
3100        thinking: false,
3101        agent_running: false,
3102        stop_requested: false,
3103        current_thought: String::new(),
3104        professional,
3105        last_reasoning: String::new(),
3106        active_context: default_active_context(),
3107        manual_scroll_offset: None,
3108        user_input_tx,
3109        specular_scroll: 0,
3110        specular_auto_scroll: true,
3111        gpu_state,
3112        git_state,
3113        last_input_time: Instant::now(),
3114        cancel_token,
3115        total_tokens: 0,
3116        current_session_cost: 0.0,
3117        model_id: "detecting...".to_string(),
3118        context_length: 0,
3119        prompt_pressure_percent: 0,
3120        prompt_estimated_input_tokens: 0,
3121        prompt_reserved_output_tokens: 0,
3122        prompt_estimated_total_tokens: 0,
3123        compaction_percent: 0,
3124        compaction_estimated_tokens: 0,
3125        compaction_threshold_tokens: 0,
3126        compaction_warned_level: 0,
3127        last_runtime_profile_time: Instant::now(),
3128        vein_file_count: 0,
3129        vein_embedded_count: 0,
3130        vein_docs_only: false,
3131        provider_name: "detecting".to_string(),
3132        provider_endpoint: String::new(),
3133        embed_model_id: None,
3134        provider_state: ProviderRuntimeState::Booting,
3135        last_provider_summary: String::new(),
3136        mcp_state: McpRuntimeState::Unconfigured,
3137        last_mcp_summary: String::new(),
3138        last_operator_checkpoint_state: OperatorCheckpointState::Idle,
3139        last_operator_checkpoint_summary: String::new(),
3140        last_recovery_recipe_summary: String::new(),
3141        think_mode: None,
3142        workflow_mode: "AUTO".into(),
3143        autocomplete_suggestions: Vec::new(),
3144        selected_suggestion: 0,
3145        show_autocomplete: false,
3146        autocomplete_filter: String::new(),
3147        current_objective: "Awaiting objective...".into(),
3148        voice_manager,
3149        voice_loading: false,
3150        voice_loading_progress: 1.0, // Pre-baked weights ready
3151        autocomplete_alias_active: false,
3152        hardware_guard_enabled: true,
3153        session_start: std::time::SystemTime::now(),
3154        soul_name: soul.species.clone(),
3155        attached_context: None,
3156        attached_image: None,
3157        hovered_input_action: None,
3158        teleported_from: cockpit.teleported_from.clone(),
3159        nav_list: Vec::new(),
3160        auto_approve_session: false,
3161        task_start_time: None,
3162        tool_started_at: HashMap::new(),
3163        recent_grounded_results: Vec::new(),
3164    };
3165
3166    // Initial placeholder — streaming will overwrite this with hardware diagnostics
3167    app.push_message("Hematite", "Initialising Engine & Hardware...");
3168
3169    if let Some(origin) = &app.teleported_from {
3170        app.push_message(
3171            "System",
3172            &format!(
3173                "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?",
3174                origin
3175            ),
3176        );
3177    }
3178
3179    // ── Splash Screen ─────────────────────────────────────────────────────────
3180    // Animated splash — redraw every 350ms until Enter or Space.
3181    if !cockpit.no_splash {
3182        loop {
3183            draw_splash(terminal)?;
3184
3185            if event::poll(Duration::from_millis(350))? {
3186                if let Event::Key(key) = event::read()? {
3187                    if key.kind == event::KeyEventKind::Press
3188                        && matches!(key.code, KeyCode::Enter | KeyCode::Char(' '))
3189                    {
3190                        break;
3191                    }
3192                }
3193            }
3194        }
3195    }
3196
3197    if app.teleported_from.is_some()
3198        && crate::tools::plan::consume_teleport_resume_marker()
3199        && crate::tools::plan::load_plan_handoff().is_some()
3200    {
3201        app.workflow_mode = "CODE".into();
3202        app.thinking = true;
3203        app.agent_running = true;
3204        app.push_message(
3205            "System",
3206            "Teleport handoff detected in this project. Resuming from `.hematite/PLAN.md` automatically.",
3207        );
3208        app.push_message("You", "/implement-plan");
3209        let _ = app
3210            .user_input_tx
3211            .try_send(UserTurn::text("/implement-plan"));
3212    }
3213
3214    let mut event_stream = EventStream::new();
3215    let mut ticker = tokio::time::interval(std::time::Duration::from_millis(100));
3216
3217    loop {
3218        // ── Hardware Watchdog ──
3219        let vram_ratio = app.gpu_state.ratio();
3220        if app.hardware_guard_enabled && vram_ratio > 0.95 && !app.brief_mode {
3221            app.brief_mode = true;
3222            app.push_message(
3223                "System",
3224                "🚨 HARDWARE GUARD: VRAM > 95%. Brief Mode auto-enabled to prevent crash.",
3225            );
3226        }
3227
3228        app.sync_task_start_time();
3229        terminal.draw(|f| ui(f, &app))?;
3230
3231        tokio::select! {
3232            _ = ticker.tick() => {
3233                // Increment voice loading progress (estimated 50s total load)
3234                if app.voice_loading && app.voice_loading_progress < 0.98 {
3235                    app.voice_loading_progress += 0.002;
3236                }
3237
3238                let workers = app.active_workers.len() as u64;
3239                let advance = if workers > 0 { workers * 4 + 1 } else { 1 };
3240                // Scale advance to match new 100ms tick (formerly 500ms)
3241                // We keep animations consistent by only advancing tick_count every 5 ticks or scaling.
3242                // Let's just increment every tick but use a larger modulo in animations.
3243                app.tick_count = app.tick_count.wrapping_add(advance);
3244                app.update_objective();
3245            }
3246
3247            // ── Keyboard / mouse input ────────────────────────────────────────
3248            maybe_event = event_stream.next() => {
3249                match maybe_event {
3250                    Some(Ok(Event::Mouse(mouse))) => {
3251                        use crossterm::event::{MouseButton, MouseEventKind};
3252                        let (width, height) = match terminal.size() {
3253                            Ok(s) => (s.width, s.height),
3254                            Err(_) => (80, 24),
3255                        };
3256                        let is_right_side = mouse.column as f64 > width as f64 * 0.65;
3257                        let input_rect = input_rect_for_size(
3258                            Rect { x: 0, y: 0, width, height },
3259                            app.input.len(),
3260                        );
3261                        let title_area = input_title_area(input_rect);
3262
3263                        match mouse.kind {
3264                            MouseEventKind::Moved => {
3265                                let hovered = if mouse.row == title_area.y
3266                                    && mouse.column >= title_area.x
3267                                    && mouse.column < title_area.x + title_area.width
3268                                {
3269                                    input_action_hitboxes(&app, title_area)
3270                                        .into_iter()
3271                                        .find_map(|(action, start, end)| {
3272                                            (mouse.column >= start && mouse.column <= end)
3273                                                .then_some(action)
3274                                        })
3275                                } else {
3276                                    None
3277                                };
3278                                app.hovered_input_action = hovered;
3279                            }
3280                            MouseEventKind::Down(MouseButton::Left) => {
3281                                if mouse.row == title_area.y
3282                                    && mouse.column >= title_area.x
3283                                    && mouse.column < title_area.x + title_area.width
3284                                {
3285                                    for (action, start, end) in input_action_hitboxes(&app, title_area) {
3286                                        if mouse.column >= start && mouse.column <= end {
3287                                            app.hovered_input_action = Some(action);
3288                                            trigger_input_action(&mut app, action);
3289                                            break;
3290                                        }
3291                                    }
3292                                } else {
3293                                    app.hovered_input_action = None;
3294
3295                                    // Check Autocomplete Click
3296                                    if app.show_autocomplete && !app.autocomplete_suggestions.is_empty() {
3297                                        // The popup is rendered at chunks[1].y - (suggestions + 2)
3298                                        // Calculation must match ui() rendering logic exactly
3299                                        let items_len = app.autocomplete_suggestions.len();
3300                                        let popup_h = (items_len as u16 + 2).min(17); // 15 + borders
3301                                        let popup_y = input_rect.y.saturating_sub(popup_h);
3302                                        let popup_x = input_rect.x + 2;
3303                                        let popup_w = input_rect.width.saturating_sub(4);
3304
3305                                        if mouse.row >= popup_y && mouse.row < popup_y + popup_h
3306                                            && mouse.column >= popup_x && mouse.column < popup_x + popup_w
3307                                        {
3308                                            // Clicked inside popup
3309                                            let mouse_relative_y = mouse.row.saturating_sub(popup_y + 1);
3310                                            if mouse_relative_y < items_len as u16 {
3311                                                let clicked_idx = mouse_relative_y as usize;
3312                                                let selected = &app.autocomplete_suggestions[clicked_idx].clone();
3313                                                app.apply_autocomplete_selection(selected);
3314                                            }
3315                                            continue; // Event handled
3316                                        }
3317                                    }
3318                                }
3319                            }
3320                            MouseEventKind::ScrollUp => {
3321                                if is_right_side {
3322                                    // User scrolled up — disable auto-scroll so they can read.
3323                                    scroll_specular_up(&mut app, 3);
3324                                } else {
3325                                    let cur = app.manual_scroll_offset.unwrap_or(0);
3326                                    app.manual_scroll_offset = Some(cur.saturating_add(3));
3327                                }
3328                            }
3329                            MouseEventKind::ScrollDown => {
3330                                if is_right_side {
3331                                    scroll_specular_down(&mut app, 3);
3332                                } else if let Some(cur) = app.manual_scroll_offset {
3333                                    app.manual_scroll_offset = if cur <= 3 { None } else { Some(cur - 3) };
3334                                }
3335                            }
3336                            _ => {}
3337                        }
3338                    }
3339                    Some(Ok(Event::Key(key))) => {
3340                        if key.kind != event::KeyEventKind::Press { continue; }
3341
3342                        // Update idle tracker for DeepReflect.
3343                        { *last_interaction.lock().unwrap() = Instant::now(); }
3344
3345                        // ── Tier-2 Swarm diff review modal (exclusive lock) ───
3346                        if let Some(review) = app.active_review.take() {
3347                            match key.code {
3348                                KeyCode::Char('y') | KeyCode::Char('Y') => {
3349                                    let _ = review.tx.send(ReviewResponse::Accept);
3350                                    app.push_message("System", &format!("Worker {} diff accepted.", review.worker_id));
3351                                }
3352                                KeyCode::Char('n') | KeyCode::Char('N') => {
3353                                    let _ = review.tx.send(ReviewResponse::Reject);
3354                                    app.push_message("System", "Diff rejected.");
3355                                }
3356                                KeyCode::Char('r') | KeyCode::Char('R') => {
3357                                    let _ = review.tx.send(ReviewResponse::Retry);
3358                                    app.push_message("System", "Retrying synthesis…");
3359                                }
3360                                _ => { app.active_review = Some(review); }
3361                            }
3362                            continue;
3363                        }
3364
3365                        // ── High-risk approval modal (exclusive lock) ─────────
3366                        if let Some(mut approval) = app.awaiting_approval.take() {
3367                            // Scroll keys — adjust offset and put approval back.
3368                            let scroll_handled = if approval.diff.is_some() {
3369                                let diff_lines = approval.diff.as_ref().map(|d| d.lines().count()).unwrap_or(0) as u16;
3370                                match key.code {
3371                                    KeyCode::Down | KeyCode::Char('j') => {
3372                                        approval.diff_scroll = approval.diff_scroll.saturating_add(1).min(diff_lines.saturating_sub(1));
3373                                        true
3374                                    }
3375                                    KeyCode::Up | KeyCode::Char('k') => {
3376                                        approval.diff_scroll = approval.diff_scroll.saturating_sub(1);
3377                                        true
3378                                    }
3379                                    KeyCode::PageDown => {
3380                                        approval.diff_scroll = approval.diff_scroll.saturating_add(10).min(diff_lines.saturating_sub(1));
3381                                        true
3382                                    }
3383                                    KeyCode::PageUp => {
3384                                        approval.diff_scroll = approval.diff_scroll.saturating_sub(10);
3385                                        true
3386                                    }
3387                                    _ => false,
3388                                }
3389                            } else {
3390                                false
3391                            };
3392                            if scroll_handled {
3393                                app.awaiting_approval = Some(approval);
3394                                continue;
3395                            }
3396                            match key.code {
3397                                KeyCode::Char('y') | KeyCode::Char('Y') => {
3398                                    if let Some(ref diff) = approval.diff {
3399                                        let added = diff.lines().filter(|l| l.starts_with("+ ")).count();
3400                                        let removed = diff.lines().filter(|l| l.starts_with("- ")).count();
3401                                        app.push_message("System", &format!(
3402                                            "Applied: {} +{} -{}", approval.display, added, removed
3403                                        ));
3404                                    } else {
3405                                        app.push_message("System", &format!("Approved: {}", approval.display));
3406                                    }
3407                                    let _ = approval.responder.send(true);
3408                                }
3409                                KeyCode::Char('a') | KeyCode::Char('A') => {
3410                                    app.auto_approve_session = true;
3411                                    if let Some(ref diff) = approval.diff {
3412                                        let added = diff.lines().filter(|l| l.starts_with("+ ")).count();
3413                                        let removed = diff.lines().filter(|l| l.starts_with("- ")).count();
3414                                        app.push_message("System", &format!(
3415                                            "Applied: {} +{} -{}", approval.display, added, removed
3416                                        ));
3417                                    } else {
3418                                        app.push_message("System", &format!("Approved: {}", approval.display));
3419                                    }
3420                                    app.push_message("System", "🔓 FULL AUTONOMY — All mutations auto-approved for this session.");
3421                                    let _ = approval.responder.send(true);
3422                                }
3423                                KeyCode::Char('n') | KeyCode::Char('N') => {
3424                                    if approval.diff.is_some() {
3425                                        app.push_message("System", "Edit skipped.");
3426                                    } else {
3427                                        app.push_message("System", "Declined.");
3428                                    }
3429                                    let _ = approval.responder.send(false);
3430                                }
3431                                _ => { app.awaiting_approval = Some(approval); }
3432                            }
3433                            continue;
3434                        }
3435
3436                        // ── Normal key bindings ───────────────────────────────
3437                        match key.code {
3438                            KeyCode::Char('q') | KeyCode::Char('c')
3439                                if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
3440                                    app.write_session_report();
3441                                    app.copy_transcript_to_clipboard();
3442                                    break;
3443                                }
3444
3445                            KeyCode::Esc => {
3446                                request_stop(&mut app);
3447                            }
3448
3449                            KeyCode::Char('b') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
3450                                app.brief_mode = !app.brief_mode;
3451                                // If the user manually toggles, silence the hardware guard for this session.
3452                                app.hardware_guard_enabled = false;
3453                                app.push_message("System", &format!("Hardware Guard {}: {}", if app.brief_mode { "ENFORCED" } else { "SILENCED" }, if app.brief_mode { "ON" } else { "OFF" }));
3454                            }
3455                            KeyCode::Char('p') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
3456                                app.professional = !app.professional;
3457                                app.push_message("System", &format!("Professional Harness: {}", if app.professional { "ACTIVE" } else { "DISABLED" }));
3458                            }
3459                            KeyCode::Char('y') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
3460                                app.yolo_mode = !app.yolo_mode;
3461                                app.push_message("System", &format!("Approvals Off: {}", if app.yolo_mode { "ON — all tools auto-approved" } else { "OFF" }));
3462                            }
3463                            KeyCode::Char('t') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
3464                                if !app.voice_manager.is_available() {
3465                                    app.push_message("System", "Voice is not available in this build. Use a packaged release for baked-in voice.");
3466                                } else {
3467                                    let enabled = app.voice_manager.toggle();
3468                                    app.push_message("System", &format!("Voice of Hematite: {}", if enabled { "VIBRANT" } else { "SILENCED" }));
3469                                }
3470                            }
3471                            KeyCode::Char('o') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
3472                                match pick_attachment_path(AttachmentPickerKind::Document) {
3473                                    Ok(Some(path)) => attach_document_from_path(&mut app, &path),
3474                                    Ok(None) => app.push_message("System", "Document picker cancelled."),
3475                                    Err(e) => app.push_message("System", &e),
3476                                }
3477                            }
3478                            KeyCode::Char('i') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
3479                                match pick_attachment_path(AttachmentPickerKind::Image) {
3480                                    Ok(Some(path)) => attach_image_from_path(&mut app, &path),
3481                                    Ok(None) => app.push_message("System", "Image picker cancelled."),
3482                                    Err(e) => app.push_message("System", &e),
3483                                }
3484                            }
3485                            KeyCode::Char('s') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
3486                                app.push_message("Hematite", "Swarm engaged.");
3487                                let swarm_tx_c = swarm_tx.clone();
3488                                let coord_c = swarm_coordinator.clone();
3489                                // Hardware-aware swarm: Limit workers if GPU is busy.
3490                                let max_workers = if app.gpu_state.ratio() > 0.70 { 1 } else { 3 };
3491                                if max_workers < 3 {
3492                                    app.push_message("System", "Hardware Guard: Limiting swarm to 1 worker due to GPU load.");
3493                                }
3494
3495                                app.agent_running = true;
3496                                tokio::spawn(async move {
3497                                    let payload = r#"<worker_task id="1" target="src/ui/tui.rs">Implement Swarm Layout</worker_task>
3498<worker_task id="2" target="src/agent/swarm.rs">Build Scratchpad constraints</worker_task>
3499<worker_task id="3" target="docs">Update Readme</worker_task>"#;
3500                                    let tasks = crate::agent::parser::parse_master_spec(payload);
3501                                    let _ = coord_c.dispatch_swarm(tasks, swarm_tx_c, max_workers).await;
3502                                });
3503                            }
3504                            KeyCode::Char('z') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
3505                                match crate::tools::file_ops::pop_ghost_ledger() {
3506                                    Ok(msg) => {
3507                                        app.specular_logs.push(format!("GHOST: {}", msg));
3508                                        trim_vec(&mut app.specular_logs, 7);
3509                                        app.push_message("System", &msg);
3510                                    }
3511                                    Err(e) => {
3512                                        app.push_message("System", &format!("Undo failed: {}", e));
3513                                    }
3514                                }
3515                            }
3516                            KeyCode::Up
3517                                if key.modifiers.contains(event::KeyModifiers::ALT) =>
3518                            {
3519                                scroll_specular_up(&mut app, 3);
3520                            }
3521                            KeyCode::Down
3522                                if key.modifiers.contains(event::KeyModifiers::ALT) =>
3523                            {
3524                                scroll_specular_down(&mut app, 3);
3525                            }
3526                            KeyCode::PageUp
3527                                if key.modifiers.contains(event::KeyModifiers::ALT) =>
3528                            {
3529                                scroll_specular_up(&mut app, 10);
3530                            }
3531                            KeyCode::PageDown
3532                                if key.modifiers.contains(event::KeyModifiers::ALT) =>
3533                            {
3534                                scroll_specular_down(&mut app, 10);
3535                            }
3536                            KeyCode::End
3537                                if key.modifiers.contains(event::KeyModifiers::ALT) =>
3538                            {
3539                                follow_live_specular(&mut app);
3540                                app.push_message(
3541                                    "System",
3542                                    "SPECULAR snapped back to live follow mode.",
3543                                );
3544                            }
3545                            KeyCode::Up => {
3546                                if app.show_autocomplete && !app.autocomplete_suggestions.is_empty() {
3547                                    app.selected_suggestion = app.selected_suggestion.saturating_sub(1);
3548                                } else if app.manual_scroll_offset.is_some() {
3549                                    // Protect history: Use Up as a scroll fallback if already scrolling.
3550                                    let cur = app.manual_scroll_offset.unwrap();
3551                                    app.manual_scroll_offset = Some(cur.saturating_add(3));
3552                                } else if !app.input_history.is_empty() {
3553                                    // Only cycle history if we are at the bottom of the chat.
3554                                    let new_idx = match app.history_idx {
3555                                        None => app.input_history.len() - 1,
3556                                        Some(i) => i.saturating_sub(1),
3557                                    };
3558                                    app.history_idx = Some(new_idx);
3559                                    app.input = app.input_history[new_idx].clone();
3560                                }
3561                            }
3562                            KeyCode::Down => {
3563                                if app.show_autocomplete && !app.autocomplete_suggestions.is_empty() {
3564                                    app.selected_suggestion = (app.selected_suggestion + 1).min(app.autocomplete_suggestions.len().saturating_sub(1));
3565                                } else if let Some(off) = app.manual_scroll_offset {
3566                                    if off <= 3 { app.manual_scroll_offset = None; }
3567                                    else { app.manual_scroll_offset = Some(off.saturating_sub(3)); }
3568                                } else if let Some(i) = app.history_idx {
3569                                    if i + 1 < app.input_history.len() {
3570                                        app.history_idx = Some(i + 1);
3571                                        app.input = app.input_history[i + 1].clone();
3572                                    } else {
3573                                        app.history_idx = None;
3574                                        app.input.clear();
3575                                    }
3576                                }
3577                            }
3578                            KeyCode::PageUp => {
3579                                let cur = app.manual_scroll_offset.unwrap_or(0);
3580                                app.manual_scroll_offset = Some(cur.saturating_add(10));
3581                            }
3582                            KeyCode::PageDown => {
3583                                if let Some(off) = app.manual_scroll_offset {
3584                                    if off <= 10 { app.manual_scroll_offset = None; }
3585                                    else { app.manual_scroll_offset = Some(off.saturating_sub(10)); }
3586                                }
3587                            }
3588                            KeyCode::Tab => {
3589                                if app.show_autocomplete && !app.autocomplete_suggestions.is_empty() {
3590                                    let selected = app.autocomplete_suggestions[app.selected_suggestion].clone();
3591                                    app.apply_autocomplete_selection(&selected);
3592                                }
3593                            }
3594                            KeyCode::Char(c) => {
3595                                app.history_idx = None; // typing cancels history nav
3596                                app.input.push(c);
3597                                app.last_input_time = Instant::now();
3598
3599                                if c == '@' {
3600                                    app.show_autocomplete = true;
3601                                    app.autocomplete_filter.clear();
3602                                    app.selected_suggestion = 0;
3603                                    app.update_autocomplete();
3604                                } else if app.show_autocomplete {
3605                                    app.autocomplete_filter.push(c);
3606                                    app.update_autocomplete();
3607                                }
3608                            }
3609                            KeyCode::Backspace => {
3610                                app.input.pop();
3611                                if app.show_autocomplete {
3612                                    if app.input.ends_with('@') || !app.input.contains('@') {
3613                                        app.show_autocomplete = false;
3614                                        app.autocomplete_filter.clear();
3615                                    } else {
3616                                        app.autocomplete_filter.pop();
3617                                        app.update_autocomplete();
3618                                    }
3619                                }
3620                            }
3621                            KeyCode::Enter => {
3622                                if app.show_autocomplete
3623                                    && !app.autocomplete_suggestions.is_empty()
3624                                    && should_accept_autocomplete_on_enter(
3625                                        app.autocomplete_alias_active,
3626                                        &app.autocomplete_filter,
3627                                    )
3628                                {
3629                                    let selected = app.autocomplete_suggestions[app.selected_suggestion].clone();
3630                                    app.apply_autocomplete_selection(&selected);
3631                                    continue;
3632                                }
3633
3634                                if !app.input.is_empty()
3635                                    && (!app.agent_running
3636                                        || is_immediate_local_command(&app.input))
3637                                {
3638                                    // PASTE GUARD: If a newline arrives within 50ms of a character,
3639                                    // it's almost certainly part of a paste stream. Convert to space.
3640                                    if Instant::now().duration_since(app.last_input_time) < std::time::Duration::from_millis(50) {
3641                                        app.input.push(' ');
3642                                        app.last_input_time = Instant::now();
3643                                        continue;
3644                                    }
3645
3646                                    let input_text = app.input.drain(..).collect::<String>();
3647
3648                                    // ── Slash Command Processor ──────────────────────────
3649                                    if input_text.starts_with('/') {
3650                                        let parts: Vec<&str> = input_text.trim().split_whitespace().collect();
3651                                        let cmd = parts[0].to_lowercase();
3652                                        match cmd.as_str() {
3653                                            "/undo" => {
3654                                                match crate::tools::file_ops::pop_ghost_ledger() {
3655                                                    Ok(msg) => {
3656                                                        app.specular_logs.push(format!("GHOST: {}", msg));
3657                                                        trim_vec(&mut app.specular_logs, 7);
3658                                                        app.push_message("System", &msg);
3659                                                    }
3660                                                    Err(e) => {
3661                                                        app.push_message("System", &format!("Undo failed: {}", e));
3662                                                    }
3663                                                }
3664                                                app.history_idx = None;
3665                                                continue;
3666                                            }
3667                                            "/clear" => {
3668                                                reset_visible_session_state(&mut app);
3669                                                app.push_message("System", "Dialogue buffer cleared.");
3670                                                app.history_idx = None;
3671                                                continue;
3672                                            }
3673                                            "/cd" => {
3674                                                if parts.len() < 2 {
3675                                                    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.");
3676                                                    app.history_idx = None;
3677                                                    continue;
3678                                                }
3679                                                let raw = parts[1..].join(" ");
3680                                                let target = crate::tools::file_ops::resolve_candidate(&raw);
3681                                                if !target.exists() {
3682                                                    app.push_message("System", &format!("Directory not found: {}", target.display()));
3683                                                    app.history_idx = None;
3684                                                    continue;
3685                                                }
3686                                                if !target.is_dir() {
3687                                                    app.push_message("System", &format!("Not a directory: {}", target.display()));
3688                                                    app.history_idx = None;
3689                                                    continue;
3690                                                }
3691                                                let target_str = target.to_string_lossy().to_string();
3692                                                app.push_message("You", &format!("/cd {}", raw));
3693                                                app.push_message("System", &format!("Teleporting to {}...", target_str));
3694                                                app.push_message("System", "Launching new session. This terminal will close.");
3695                                                spawn_dive_in_terminal(&target_str);
3696                                                app.write_session_report();
3697                                                app.copy_transcript_to_clipboard();
3698                                                break;
3699                                            }
3700                                            "/ls" => {
3701                                                let base: std::path::PathBuf = if parts.len() >= 2 {
3702                                                    // /ls <path> or /ls <N>
3703                                                    let arg = parts[1..].join(" ");
3704                                                    if let Ok(n) = arg.trim().parse::<usize>() {
3705                                                        // /ls <N> — teleport to nav_list entry N
3706                                                        if n == 0 || n > app.nav_list.len() {
3707                                                            app.push_message("System", &format!("No entry {}. Run /ls first to see the list.", n));
3708                                                            app.history_idx = None;
3709                                                            continue;
3710                                                        }
3711                                                        let target = app.nav_list[n - 1].clone();
3712                                                        let target_str = target.to_string_lossy().to_string();
3713                                                        app.push_message("You", &format!("/ls {}", n));
3714                                                        app.push_message("System", &format!("Teleporting to {}...", target_str));
3715                                                        app.push_message("System", "Launching new session. This terminal will close.");
3716                                                        spawn_dive_in_terminal(&target_str);
3717                                                        app.write_session_report();
3718                                                        app.copy_transcript_to_clipboard();
3719                                                        break;
3720                                                    } else {
3721                                                        crate::tools::file_ops::resolve_candidate(&arg)
3722                                                    }
3723                                                } else {
3724                                                    std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
3725                                                };
3726
3727                                                // Build numbered nav list
3728                                                let mut entries: Vec<std::path::PathBuf> = Vec::new();
3729                                                let mut output = String::new();
3730
3731                                                // Common locations (only when listing current/no-arg)
3732                                                let listing_base = parts.len() < 2;
3733                                                if listing_base {
3734                                                    let common: Vec<(&str, Option<std::path::PathBuf>)> = vec![
3735                                                        ("Desktop", dirs::desktop_dir()),
3736                                                        ("Downloads", dirs::download_dir()),
3737                                                        ("Documents", dirs::document_dir()),
3738                                                        ("Pictures", dirs::picture_dir()),
3739                                                        ("Home", dirs::home_dir()),
3740                                                    ];
3741                                                    let valid: Vec<_> = common.into_iter().filter_map(|(label, p)| p.map(|pb| (label, pb))).collect();
3742                                                    if !valid.is_empty() {
3743                                                        output.push_str("Common locations:\n");
3744                                                        for (label, pb) in &valid {
3745                                                            entries.push(pb.clone());
3746                                                            output.push_str(&format!("  {:>2}.  {:<12}  {}\n", entries.len(), label, pb.display()));
3747                                                        }
3748                                                    }
3749                                                }
3750
3751                                                // Subdirectories of base path
3752                                                let cwd_label = if listing_base {
3753                                                    std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
3754                                                } else {
3755                                                    base.clone()
3756                                                };
3757                                                if let Ok(read) = std::fs::read_dir(&cwd_label) {
3758                                                    let mut dirs_found: Vec<std::path::PathBuf> = read
3759                                                        .filter_map(|e| e.ok())
3760                                                        .filter(|e| e.path().is_dir())
3761                                                        .map(|e| e.path())
3762                                                        .collect();
3763                                                    dirs_found.sort();
3764                                                    if !dirs_found.is_empty() {
3765                                                        output.push_str(&format!("\n{}:\n", cwd_label.display()));
3766                                                        for pb in &dirs_found {
3767                                                            entries.push(pb.clone());
3768                                                            let name = pb.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
3769                                                            output.push_str(&format!("  {:>2}.  {}\n", entries.len(), name));
3770                                                        }
3771                                                    }
3772                                                }
3773
3774                                                if entries.is_empty() {
3775                                                    app.push_message("System", "No directories found.");
3776                                                } else {
3777                                                    output.push_str("\nType /ls <N> to teleport to that directory.");
3778                                                    app.nav_list = entries;
3779                                                    app.push_message("System", &output);
3780                                                }
3781                                                app.history_idx = None;
3782                                                continue;
3783                                            }
3784                                            "/diff" => {
3785                                                app.push_message("System", "Fetching session diff...");
3786                                                let ws = crate::tools::file_ops::workspace_root();
3787                                                if crate::agent::git::is_git_repo(&ws) {
3788                                                    let output = std::process::Command::new("git")
3789                                                        .args(["diff", "--stat"])
3790                                                        .current_dir(ws)
3791                                                        .output();
3792                                                    if let Ok(out) = output {
3793                                                        let stat = String::from_utf8_lossy(&out.stdout).to_string();
3794                                                        app.push_message("System", if stat.is_empty() { "No changes detected." } else { &stat });
3795                                                    }
3796                                                } else {
3797                                                    app.push_message("System", "Not a git repository. Diff limited.");
3798                                                }
3799                                                app.history_idx = None;
3800                                                continue;
3801                                            }
3802                                            "/vein-reset" => {
3803                                                app.vein_file_count = 0;
3804                                                app.vein_embedded_count = 0;
3805                                                app.push_message("You", "/vein-reset");
3806                                                app.agent_running = true;
3807                                                let _ = app.user_input_tx.try_send(UserTurn::text("/vein-reset"));
3808                                                app.history_idx = None;
3809                                                continue;
3810                                            }
3811                                            "/vein-inspect" => {
3812                                                app.push_message("You", "/vein-inspect");
3813                                                app.agent_running = true;
3814                                                let _ = app.user_input_tx.try_send(UserTurn::text("/vein-inspect"));
3815                                                app.history_idx = None;
3816                                                continue;
3817                                            }
3818                                            "/workspace-profile" => {
3819                                                app.push_message("You", "/workspace-profile");
3820                                                app.agent_running = true;
3821                                                let _ = app.user_input_tx.try_send(UserTurn::text("/workspace-profile"));
3822                                                app.history_idx = None;
3823                                                continue;
3824                                            }
3825                                            "/copy" => {
3826                                                app.copy_transcript_to_clipboard();
3827                                                app.push_message("System", "Exact session transcript copied to clipboard (includes help/system output).");
3828                                                app.history_idx = None;
3829                                                continue;
3830                                            }
3831                                            "/copy-last" => {
3832                                                if app.copy_last_reply_to_clipboard() {
3833                                                    app.push_message("System", "Latest Hematite reply copied to clipboard.");
3834                                                } else {
3835                                                    app.push_message("System", "No Hematite reply is available to copy yet.");
3836                                                }
3837                                                app.history_idx = None;
3838                                                continue;
3839                                            }
3840                                            "/copy-clean" => {
3841                                                app.copy_clean_transcript_to_clipboard();
3842                                                app.push_message("System", "Clean chat transcript copied to clipboard (skips help/debug boilerplate).");
3843                                                app.history_idx = None;
3844                                                continue;
3845                                            }
3846                                            "/copy2" => {
3847                                                app.copy_specular_to_clipboard();
3848                                                app.push_message("System", "SPECULAR log copied to clipboard (reasoning + events).");
3849                                                app.history_idx = None;
3850                                                continue;
3851                                            }
3852                                            "/voice" => {
3853                                                use crate::ui::voice::VOICE_LIST;
3854                                                if let Some(arg) = parts.get(1) {
3855                                                    // /voice N — select by number
3856                                                    if let Ok(n) = arg.parse::<usize>() {
3857                                                        let idx = n.saturating_sub(1);
3858                                                        if let Some(&(id, label)) = VOICE_LIST.get(idx) {
3859                                                            app.voice_manager.set_voice(id);
3860                                                            let _ = crate::agent::config::set_voice(id);
3861                                                            app.push_message("System", &format!("Voice set to {} — {}", id, label));
3862                                                        } else {
3863                                                            app.push_message("System", &format!("Invalid voice number. Use /voice to list voices (1–{}).", VOICE_LIST.len()));
3864                                                        }
3865                                                    } else {
3866                                                        // /voice af_bella — select by name
3867                                                        if let Some(&(id, label)) = VOICE_LIST.iter().find(|&&(id, _)| id == *arg) {
3868                                                            app.voice_manager.set_voice(id);
3869                                                            let _ = crate::agent::config::set_voice(id);
3870                                                            app.push_message("System", &format!("Voice set to {} — {}", id, label));
3871                                                        } else {
3872                                                            app.push_message("System", &format!("Unknown voice '{}'. Use /voice to list voices.", arg));
3873                                                        }
3874                                                    }
3875                                                } else {
3876                                                    // /voice — list all voices
3877                                                    let current = app.voice_manager.current_voice_id();
3878                                                    let mut list = format!("Available voices (current: {}):\n", current);
3879                                                    for (i, &(id, label)) in VOICE_LIST.iter().enumerate() {
3880                                                        let marker = if id == current.as_str() { " ◀" } else { "" };
3881                                                        list.push_str(&format!("  {:>2}. {}{}\n", i + 1, label, marker));
3882                                                    }
3883                                                    list.push_str("\nUse /voice N or /voice <id> to select.");
3884                                                    app.push_message("System", &list);
3885                                                }
3886                                                app.history_idx = None;
3887                                                continue;
3888                                            }
3889                                            "/read" => {
3890                                                let text = parts[1..].join(" ");
3891                                                if text.is_empty() {
3892                                                    app.push_message("System", "Usage: /read <text to speak>");
3893                                                } else if !app.voice_manager.is_available() {
3894                                                    app.push_message("System", "Voice is not available in this build. Use a packaged release for baked-in voice.");
3895                                                } else if !app.voice_manager.is_enabled() {
3896                                                    app.push_message("System", "Voice is off. Press Ctrl+T to enable, then /read again.");
3897                                                } else {
3898                                                    app.push_message("System", &format!("Reading {} words aloud. ESC to stop.", text.split_whitespace().count()));
3899                                                    app.voice_manager.speak(text.clone());
3900                                                }
3901                                                app.history_idx = None;
3902                                                continue;
3903                                            }
3904                                            "/new" => {
3905                                                reset_visible_session_state(&mut app);
3906                                                app.push_message("You", "/new");
3907                                                app.agent_running = true;
3908                                                app.clear_pending_attachments();
3909                                                let _ = app.user_input_tx.try_send(UserTurn::text("/new"));
3910                                                app.history_idx = None;
3911                                                continue;
3912                                            }
3913                                            "/forget" => {
3914                                                // Cancel any running turn so /forget isn't queued behind retries.
3915                                                app.cancel_token.store(true, std::sync::atomic::Ordering::SeqCst);
3916                                                reset_visible_session_state(&mut app);
3917                                                app.push_message("You", "/forget");
3918                                                app.agent_running = true;
3919                                                app.cancel_token.store(false, std::sync::atomic::Ordering::SeqCst);
3920                                                app.clear_pending_attachments();
3921                                                let _ = app.user_input_tx.try_send(UserTurn::text("/forget"));
3922                                                app.history_idx = None;
3923                                                continue;
3924                                            }
3925                                            "/gemma-native" => {
3926                                                let sub = parts.get(1).copied().unwrap_or("status").to_ascii_lowercase();
3927                                                let gemma_detected = crate::agent::inference::is_hematite_native_model(&app.model_id);
3928                                                match sub.as_str() {
3929                                                    "auto" => {
3930                                                        match crate::agent::config::set_gemma_native_mode("auto") {
3931                                                            Ok(_) => {
3932                                                                if gemma_detected {
3933                                                                    app.push_message("System", "Gemma Native Formatting: AUTO. Gemma 4 will use native formatting automatically on the next turn.");
3934                                                                } else {
3935                                                                    app.push_message("System", "Gemma Native Formatting: AUTO in settings. It will activate automatically when a Gemma 4 model is loaded.");
3936                                                                }
3937                                                            }
3938                                                            Err(e) => app.push_message("System", &format!("Failed to update settings: {}", e)),
3939                                                        }
3940                                                    }
3941                                                    "on" => {
3942                                                        match crate::agent::config::set_gemma_native_mode("on") {
3943                                                            Ok(_) => {
3944                                                                if gemma_detected {
3945                                                                    app.push_message("System", "Gemma Native Formatting: ON (forced). It will apply on the next turn.");
3946                                                                } else {
3947                                                                    app.push_message("System", "Gemma Native Formatting: ON (forced) in settings. It will activate only when a Gemma 4 model is loaded.");
3948                                                                }
3949                                                            }
3950                                                            Err(e) => app.push_message("System", &format!("Failed to update settings: {}", e)),
3951                                                        }
3952                                                    }
3953                                                    "off" => {
3954                                                        match crate::agent::config::set_gemma_native_mode("off") {
3955                                                            Ok(_) => app.push_message("System", "Gemma Native Formatting: OFF."),
3956                                                            Err(e) => app.push_message("System", &format!("Failed to update settings: {}", e)),
3957                                                        }
3958                                                    }
3959                                                    _ => {
3960                                                        let config = crate::agent::config::load_config();
3961                                                        let mode = crate::agent::config::gemma_native_mode_label(&config, &app.model_id);
3962                                                        let enabled = match mode {
3963                                                            "on" => "ON (forced)",
3964                                                            "auto" => "ON (auto)",
3965                                                            "off" => "OFF",
3966                                                            _ => "INACTIVE",
3967                                                        };
3968                                                        let model_note = if gemma_detected {
3969                                                            "Gemma 4 detected."
3970                                                        } else {
3971                                                            "Current model is not Gemma 4."
3972                                                        };
3973                                                        app.push_message(
3974                                                            "System",
3975                                                            &format!(
3976                                                                "Gemma Native Formatting: {}. {} Usage: /gemma-native auto | on | off | status",
3977                                                                enabled, model_note
3978                                                            ),
3979                                                        );
3980                                                    }
3981                                                }
3982                                                app.history_idx = None;
3983                                                continue;
3984                                            }
3985                                            "/chat" => {
3986                                                app.workflow_mode = "CHAT".into();
3987                                                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.");
3988                                                app.history_idx = None;
3989                                                let _ = app.user_input_tx.try_send(UserTurn::text("/chat"));
3990                                                continue;
3991                                            }
3992                                            "/reroll" => {
3993                                                app.history_idx = None;
3994                                                let _ = app.user_input_tx.try_send(UserTurn::text("/reroll"));
3995                                                continue;
3996                                            }
3997                                            "/agent" => {
3998                                                app.workflow_mode = "AUTO".into();
3999                                                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.");
4000                                                app.history_idx = None;
4001                                                let _ = app.user_input_tx.try_send(UserTurn::text("/agent"));
4002                                                continue;
4003                                            }
4004                                            "/implement-plan" => {
4005                                                app.workflow_mode = "CODE".into();
4006                                                app.push_message("You", "/implement-plan");
4007                                                app.agent_running = true;
4008                                                let _ = app.user_input_tx.try_send(UserTurn::text("/implement-plan"));
4009                                                app.history_idx = None;
4010                                                continue;
4011                                            }
4012                                            "/ask" | "/code" | "/architect" | "/read-only" | "/auto" | "/teach" => {
4013                                                let label = match cmd.as_str() {
4014                                                    "/ask" => "ASK",
4015                                                    "/code" => "CODE",
4016                                                    "/architect" => "ARCHITECT",
4017                                                    "/read-only" => "READ-ONLY",
4018                                                    "/teach" => "TEACH",
4019                                                    _ => "AUTO",
4020                                                };
4021                                                app.workflow_mode = label.to_string();
4022                                                let outbound = input_text.trim().to_string();
4023                                                app.push_message("You", &outbound);
4024                                                app.agent_running = true;
4025                                                let _ = app.user_input_tx.try_send(UserTurn::text(outbound));
4026                                                app.history_idx = None;
4027                                                continue;
4028                                            }
4029                                            "/worktree" => {
4030                                                let sub = parts.get(1).copied().unwrap_or("");
4031                                                match sub {
4032                                                    "list" => {
4033                                                        app.push_message("You", "/worktree list");
4034                                                        app.agent_running = true;
4035                                                        let _ = app.user_input_tx.try_send(UserTurn::text(
4036                                                            "Call git_worktree with action=list"
4037                                                        ));
4038                                                    }
4039                                                    "add" => {
4040                                                        let wt_path = parts.get(2).copied().unwrap_or("");
4041                                                        let wt_branch = parts.get(3).copied().unwrap_or("");
4042                                                        if wt_path.is_empty() {
4043                                                            app.push_message("System", "Usage: /worktree add <path> [branch]");
4044                                                        } else {
4045                                                            app.push_message("You", &format!("/worktree add {wt_path}"));
4046                                                            app.agent_running = true;
4047                                                            let directive = if wt_branch.is_empty() {
4048                                                                format!("Call git_worktree with action=add path={wt_path}")
4049                                                            } else {
4050                                                                format!("Call git_worktree with action=add path={wt_path} branch={wt_branch}")
4051                                                            };
4052                                                            let _ = app.user_input_tx.try_send(UserTurn::text(directive));
4053                                                        }
4054                                                    }
4055                                                    "remove" => {
4056                                                        let wt_path = parts.get(2).copied().unwrap_or("");
4057                                                        if wt_path.is_empty() {
4058                                                            app.push_message("System", "Usage: /worktree remove <path>");
4059                                                        } else {
4060                                                            app.push_message("You", &format!("/worktree remove {wt_path}"));
4061                                                            app.agent_running = true;
4062                                                            let _ = app.user_input_tx.try_send(UserTurn::text(
4063                                                                format!("Call git_worktree with action=remove path={wt_path}")
4064                                                            ));
4065                                                        }
4066                                                    }
4067                                                    "prune" => {
4068                                                        app.push_message("You", "/worktree prune");
4069                                                        app.agent_running = true;
4070                                                        let _ = app.user_input_tx.try_send(UserTurn::text(
4071                                                            "Call git_worktree with action=prune"
4072                                                        ));
4073                                                    }
4074                                                    _ => {
4075                                                        app.push_message("System",
4076                                                            "Usage: /worktree list | add <path> [branch] | remove <path> | prune");
4077                                                    }
4078                                                }
4079                                                app.history_idx = None;
4080                                                continue;
4081                                            }
4082                                            "/think" => {
4083                                                app.think_mode = Some(true);
4084                                                app.push_message("You", "/think");
4085                                                app.agent_running = true;
4086                                                let _ = app.user_input_tx.try_send(UserTurn::text("/think"));
4087                                                app.history_idx = None;
4088                                                continue;
4089                                            }
4090                                            "/no_think" => {
4091                                                app.think_mode = Some(false);
4092                                                app.push_message("You", "/no_think");
4093                                                app.agent_running = true;
4094                                                let _ = app.user_input_tx.try_send(UserTurn::text("/no_think"));
4095                                                app.history_idx = None;
4096                                                continue;
4097                                            }
4098                                            "/lsp" => {
4099                                                app.push_message("You", "/lsp");
4100                                                app.agent_running = true;
4101                                                let _ = app.user_input_tx.try_send(UserTurn::text("/lsp"));
4102                                                app.history_idx = None;
4103                                                continue;
4104                                            }
4105                                            "/runtime-refresh" => {
4106                                                app.push_message("You", "/runtime-refresh");
4107                                                app.agent_running = true;
4108                                                let _ = app.user_input_tx.try_send(UserTurn::text("/runtime-refresh"));
4109                                                app.history_idx = None;
4110                                                continue;
4111                                            }
4112                                            "/rules" => {
4113                                                let sub = parts.get(1).copied().unwrap_or("status").to_ascii_lowercase();
4114                                                let ws_root = crate::tools::file_ops::workspace_root();
4115
4116                                                match sub.as_str() {
4117                                                    "view" => {
4118                                                        let mut combined = String::new();
4119                                                        for cand in crate::agent::instructions::PROJECT_GUIDANCE_FILES {
4120                                                            let p = crate::agent::instructions::resolve_guidance_path(&ws_root, cand);
4121                                                            if p.exists() {
4122                                                                if let Ok(c) = std::fs::read_to_string(&p) {
4123                                                                    combined.push_str(&format!("--- [{}] ---\n", cand));
4124                                                                    combined.push_str(&c);
4125                                                                    combined.push_str("\n\n");
4126                                                                }
4127                                                            }
4128                                                        }
4129                                                        if combined.is_empty() {
4130                                                            app.push_message("System", "No project guidance files found (CLAUDE.md, SKILLS.md, .hematite/rules.md, etc.).");
4131                                                        } else {
4132                                                            app.push_message("System", &format!("Current project guidance being injected:\n\n{}", combined));
4133                                                        }
4134                                                    }
4135                                                    "edit" => {
4136                                                        let which = parts.get(2).copied().unwrap_or("local").to_ascii_lowercase();
4137                                                        let target_file = if which == "shared" { "rules.md" } else { "rules.local.md" };
4138                                                        let target_path = crate::tools::file_ops::hematite_dir().join(target_file);
4139
4140                                                        if !target_path.exists() {
4141                                                            if let Some(parent) = target_path.parent() {
4142                                                                let _ = std::fs::create_dir_all(parent);
4143                                                            }
4144                                                            let header = if which == "shared" { "# Project Rules (Shared)" } else { "# Local Guidelines (Private)" };
4145                                                            let _ = std::fs::write(&target_path, format!("{}\n\nAdd behavioral guidelines here for the agent to follow in this workspace.\n", header));
4146                                                        }
4147
4148                                                        match crate::tools::file_ops::open_in_system_editor(&target_path) {
4149                                                            Ok(_) => app.push_message("System", &format!("Opening {} in system editor...", target_path.display())),
4150                                                            Err(e) => app.push_message("System", &format!("Failed to open editor: {}", e)),
4151                                                        }
4152                                                    }
4153                                                    _ => {
4154                                                        let mut status = "Project Guidance:\n".to_string();
4155                                                        for cand in crate::agent::instructions::PROJECT_GUIDANCE_FILES {
4156                                                              let p = crate::agent::instructions::resolve_guidance_path(&ws_root, cand);
4157                                                              let icon = if p.exists() { "[v]" } else { "[ ]" };
4158                                                              let label = crate::agent::instructions::guidance_status_label(cand);
4159                                                              status.push_str(&format!("  {} {:<25} {}\n", icon, cand, label));
4160                                                        }
4161                                                        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");
4162                                                        app.push_message("System", &status);
4163                                                    }
4164                                                }
4165                                                app.history_idx = None;
4166                                                continue;
4167                                            }
4168                                            "/skills" => {
4169                                                let workspace_root = crate::tools::file_ops::workspace_root();
4170                                                let config = crate::agent::config::load_config();
4171                                                let discovery = crate::agent::instructions::discover_agent_skills(
4172                                                    &workspace_root,
4173                                                    &config.trust,
4174                                                );
4175                                                let report =
4176                                                    crate::agent::instructions::render_skills_report(&discovery);
4177                                                app.push_message("System", &report);
4178                                                app.history_idx = None;
4179                                                continue;
4180                                            }
4181                                            "/help" => {
4182                                                show_help_message(&mut app);
4183                                                app.history_idx = None;
4184                                                continue;
4185                                            }
4186                                            "/help-legacy-unused" => {
4187                                                app.push_message("System",
4188                                                    "Hematite Commands:\n\
4189                                                     /chat             — (Mode) Conversation mode — clean chat, no tool noise\n\
4190                                                     /agent            — (Mode) Full coding harness + workstation mode — tools, file edits, builds, inspection\n\
4191                                                     /reroll           — (Soul) Hatch a new companion mid-session\n\
4192                                                     /auto             — (Flow) Let Hematite choose the narrowest effective workflow\n\
4193                                                     /ask [prompt]     — (Flow) Read-only analysis mode; optional inline prompt\n\
4194                                                     /code [prompt]    — (Flow) Explicit implementation mode; optional inline prompt\n\
4195                                                     /architect [prompt] — (Flow) Plan-first mode; optional inline prompt\n\
4196                                                     /implement-plan   — (Flow) Execute the saved architect handoff in /code\n\
4197                                                     /read-only [prompt] — (Flow) Hard read-only mode; optional inline prompt\n\
4198                                                     /teach [prompt]   — (Flow) Teacher mode; inspect machine then walk you through any admin task step-by-step\n\
4199                                                       /new              — (Reset) Fresh task context; clear chat, pins, and task files\n\
4200                                                       /forget           — (Wipe) Hard forget; purge saved memory and Vein index too\n\
4201                                                       /vein-inspect     — (Vein) Inspect indexed memory, hot files, and active room bias\n\
4202                                                       /workspace-profile — (Profile) Show the auto-generated workspace profile\n\
4203                                                       /rules            — (Rules) View project guidance (CLAUDE.md, SKILLS.md, .hematite/rules.md)\n\
4204                                                       /version          — (Build) Show the running Hematite version\n\
4205                                                       /about            — (Info) Show author, repo, and product info\n\
4206                                                       /vein-reset       — (Vein) Wipe the RAG index; rebuilds automatically on next turn\n\
4207                                                       /clear            — (UI) Clear dialogue display only\n\
4208                                                     /gemma-native [auto|on|off|status] — (Model) Auto/force/disable Gemma 4 native formatting\n\
4209                                                     /provider [status|lmstudio|ollama|clear|URL] — (Model) Show or save the active provider endpoint preference\n\
4210                                                     /runtime          — (Model) Show the live runtime/provider/model/embed status and shortest fix path\n\
4211                                                     /runtime fix      — (Model) Run the shortest safe runtime recovery step now\n\
4212                                                     /runtime-refresh  — (Model) Re-read active provider model + CTX now\n\
4213                                                     /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\
4214                                                     /embed [status|load <id>|unload [id|current]|prefer <id>|clear] — (Model) Inspect, load, unload, or save the preferred embed model\n\
4215                                                     /undo             — (Ghost) Revert last file change\n\
4216                                                     /diff             — (Git) Show session changes (--stat)\n\
4217                                                     /lsp              — (Logic) Start Language Servers (semantic intelligence)\n\
4218                                                     /swarm <text>     — (Swarm) Spawn parallel workers on a directive\n\
4219                                                     /worktree <cmd>   — (Isolated) Manage git worktrees (list|add|remove|prune)\n\
4220                                                     /think            — (Brain) Enable deep reasoning mode\n\
4221                                                     /no_think         — (Speed) Disable reasoning (3-5x faster responses)\n\
4222                                                     /voice            — (TTS) List all available voices\n\
4223                                                     /voice N          — (TTS) Select voice by number\n\
4224                                                     /attach <path>    — (Docs) Attach a PDF/markdown/txt file for next message\n\
4225                                                     /attach-pick      — (Docs) Open a file picker and attach a document\n\
4226                                                     /image <path>     — (Vision) Attach an image for the next message\n\
4227                                                     /image-pick       — (Vision) Open a file picker and attach an image\n\
4228                                                     /detach           — (Context) Drop pending document/image attachments\n\
4229                                                     /copy             — (Debug) Copy session transcript to clipboard\n\
4230                                                     /copy2            — (Debug) Copy SPECULAR log to clipboard (reasoning + events)\n\
4231                                                     \nHotkeys:\n\
4232                                                     Ctrl+B — Toggle Brief Mode (minimal output; collapses side chrome)\n\
4233                                                     Ctrl+P — Toggle Professional Mode (strip personality)\n\
4234                                                     Ctrl+O — Open document picker for next-turn context\n\
4235                                                     Ctrl+I — Open image picker for next-turn vision context\n\
4236                                                     Ctrl+Y — Toggle Approvals Off (bypass safety approvals)\n\
4237                                                     Ctrl+S — Quick Swarm (hardcoded bootstrap)\n\
4238                                                     Ctrl+Z — Undo last edit\n\
4239                                                     Ctrl+Q/C — Quit session\n\
4240                                                     ESC    — Silence current playback\n\
4241                                                     \nStatus Legend:\n\
4242                                                     LM    — LM Studio runtime health (`LIVE`, `RECV`, `WARN`, `CEIL`, `STALE`, `BOOT`)\n\
4243                                                     VN    — Vein RAG status (`SEM`=semantic active, `FTS`=BM25 only, `--`=not indexed)\n\
4244                                                     BUD   — Total prompt-budget pressure against the live context window\n\
4245                                                     CMP   — History compaction pressure against Hematite's adaptive threshold\n\
4246                                                     ERR   — Session error count (runtime, tool, or SPECULAR failures)\n\
4247                                                     CTX   — Live context window currently reported by LM Studio\n\
4248                                                     VOICE — Local speech output state\n\
4249                                                     \nAssistant: Semantic Pathing (LSP), Vision Pass, Web Research, Swarm Synthesis"
4250                                                );
4251                                                app.history_idx = None;
4252                                                continue;
4253                                            }
4254                                            "/swarm" => {
4255                                                let directive = parts[1..].join(" ");
4256                                                if directive.is_empty() {
4257                                                    app.push_message("System", "Usage: /swarm <directive>");
4258                                                } else {
4259                                                    app.active_workers.clear(); // Fresh architecture for a fresh command
4260                                                    app.push_message("Hematite", &format!("Swarm analyzing: '{}'", directive));
4261                                                    let swarm_tx_c = swarm_tx.clone();
4262                                                    let coord_c = swarm_coordinator.clone();
4263                                                    let max_workers = if app.gpu_state.ratio() > 0.75 { 1 } else { 3 };
4264                                                    app.agent_running = true;
4265                                                    tokio::spawn(async move {
4266                                                        let payload = format!(r#"<worker_task id="1" target="src">Research {}</worker_task>
4267<worker_task id="2" target="src">Implement {}</worker_task>
4268<worker_task id="3" target="docs">Document {}</worker_task>"#, directive, directive, directive);
4269                                                        let tasks = crate::agent::parser::parse_master_spec(&payload);
4270                                                        let _ = coord_c.dispatch_swarm(tasks, swarm_tx_c, max_workers).await;
4271                                                    });
4272                                                }
4273                                                app.history_idx = None;
4274                                                continue;
4275                                            }
4276                                            "/provider" => {
4277                                                let arg_text = parts[1..].join(" ").trim().to_string();
4278                                                handle_provider_command(&mut app, arg_text).await;
4279                                                continue;
4280                                            }
4281                                            "/runtime" => {
4282                                                let arg_text = parts[1..].join(" ").trim().to_string();
4283                                                let lower = arg_text.to_ascii_lowercase();
4284                                                match lower.as_str() {
4285                                                    "" | "status" => {
4286                                                        app.push_message(
4287                                                            "System",
4288                                                            &format_runtime_summary(&app).await,
4289                                                        );
4290                                                    }
4291                                                    "explain" => {
4292                                                        app.push_message(
4293                                                            "System",
4294                                                            &format_runtime_explanation(&app).await,
4295                                                        );
4296                                                    }
4297                                                    "refresh" => {
4298                                                        let _ = app
4299                                                            .user_input_tx
4300                                                            .try_send(UserTurn::text(
4301                                                                "/runtime-refresh",
4302                                                            ));
4303                                                        app.push_message("You", "/runtime refresh");
4304                                                        app.agent_running = true;
4305                                                    }
4306                                                    "fix" => {
4307                                                        handle_runtime_fix(&mut app).await;
4308                                                    }
4309                                                    _ if lower.starts_with("provider") => {
4310                                                        let provider_arg =
4311                                                            arg_text["provider".len()..].trim().to_string();
4312                                                        if provider_arg.is_empty() {
4313                                                            app.push_message(
4314                                                                "System",
4315                                                                "Usage: /runtime provider [status|lmstudio|ollama|clear|http://host:port/v1]",
4316                                                            );
4317                                                        } else {
4318                                                            handle_provider_command(&mut app, provider_arg)
4319                                                                .await;
4320                                                        }
4321                                                    }
4322                                                    _ => {
4323                                                        app.push_message(
4324                                                            "System",
4325                                                            "Usage: /runtime [status|explain|fix|refresh|provider ...]",
4326                                                        );
4327                                                    }
4328                                                }
4329                                                app.history_idx = None;
4330                                                continue;
4331                                            }
4332                                            "/model" | "/embed" => {
4333                                                let outbound = input_text.clone();
4334                                                app.push_message("You", &outbound);
4335                                                app.agent_running = true;
4336                                                app.stop_requested = false;
4337                                                app.cancel_token.store(
4338                                                    false,
4339                                                    std::sync::atomic::Ordering::SeqCst,
4340                                                );
4341                                                app.last_reasoning.clear();
4342                                                app.manual_scroll_offset = None;
4343                                                app.specular_auto_scroll = true;
4344                                                let _ = app
4345                                                    .user_input_tx
4346                                                    .try_send(UserTurn::text(outbound));
4347                                                app.history_idx = None;
4348                                                continue;
4349                                            }
4350                                            "/version" => {
4351                                                app.push_message(
4352                                                    "System",
4353                                                    &crate::hematite_version_report(),
4354                                                );
4355                                                app.history_idx = None;
4356                                                continue;
4357                                            }
4358                                            "/about" => {
4359                                                app.push_message(
4360                                                    "System",
4361                                                    &crate::hematite_about_report(),
4362                                                );
4363                                                app.history_idx = None;
4364                                                continue;
4365                                            }
4366                                            "/explain" => {
4367                                                let error_text = parts[1..].join(" ");
4368                                                if error_text.trim().is_empty() {
4369                                                    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.");
4370                                                } else {
4371                                                    let framed = format!(
4372                                                        "The user pasted the following error or message and needs a plain-English explanation. \
4373                                                         Explain what this means, why it happened, and what to do about it. \
4374                                                         Use simple, non-technical language. Avoid jargon. \
4375                                                         Structure your response as:\n\
4376                                                         1. What happened (one sentence)\n\
4377                                                         2. Why it happened\n\
4378                                                         3. How to fix it (step by step)\n\
4379                                                         4. How to prevent it next time (optional, if relevant)\n\n\
4380                                                         Error/message to explain:\n```\n{}\n```",
4381                                                        error_text
4382                                                    );
4383                                                    app.push_message("You", &format!("/explain {}", error_text));
4384                                                    app.agent_running = true;
4385                                                    let _ = app.user_input_tx.try_send(UserTurn::text(framed));
4386                                                }
4387                                                app.history_idx = None;
4388                                                continue;
4389                                            }
4390                                            "/health" => {
4391                                                app.push_message("You", "/health");
4392                                                app.agent_running = true;
4393                                                let _ = app.user_input_tx.try_send(UserTurn::text(
4394                                                    "Run inspect_host with topic=health_report. \
4395                                                     After getting the report, summarize it in plain English for a non-technical user. \
4396                                                     Use the tier labels (Needs fixing / Worth watching / Looking good) and \
4397                                                     give specific, actionable next steps for any items that need attention."
4398                                                ));
4399                                                app.history_idx = None;
4400                                                continue;
4401                                            }
4402                                            "/diagnose" => {
4403                                                app.push_message("You", "/diagnose");
4404                                                app.push_message("System", "Running health triage...");
4405                                                let health_args = serde_json::json!({"topic": "health_report"});
4406                                                let health_output = crate::tools::host_inspect::inspect_host(&health_args)
4407                                                    .await
4408                                                    .unwrap_or_else(|e| format!("Error: {}", e));
4409                                                let follow_ups = crate::agent::diagnose::triage_follow_up_topics(&health_output);
4410                                                let n = follow_ups.len();
4411                                                if n > 0 {
4412                                                    app.push_message("System", &format!(
4413                                                        "Triage complete — {} area(s) flagged. Handing off to agent for deep investigation...",
4414                                                        n
4415                                                    ));
4416                                                } else {
4417                                                    app.push_message("System", "Triage complete — machine looks healthy. Confirming with agent...");
4418                                                }
4419                                                let instruction = crate::agent::diagnose::build_diagnose_instruction(
4420                                                    &health_output,
4421                                                    &follow_ups,
4422                                                );
4423                                                app.agent_running = true;
4424                                                let _ = app.user_input_tx.try_send(UserTurn::text(instruction));
4425                                                app.history_idx = None;
4426                                                continue;
4427                                            }
4428                                            "/export" => {
4429                                                let fmt = parts.get(1).copied().unwrap_or("md").to_ascii_lowercase();
4430                                                let label = match fmt.as_str() {
4431                                                    "json" => "JSON",
4432                                                    "html" => "HTML",
4433                                                    _ => "Markdown",
4434                                                };
4435                                                app.push_message("System", &format!(
4436                                                    "Generating diagnostic report ({}) — scanning 6 topics...", label
4437                                                ));
4438                                                let path = match fmt.as_str() {
4439                                                    "json" => {
4440                                                        let (_, p) = crate::agent::report_export::save_report_json().await;
4441                                                        p
4442                                                    }
4443                                                    "html" => {
4444                                                        let (_, p) = crate::agent::report_export::save_report_html().await;
4445                                                        p
4446                                                    }
4447                                                    _ => {
4448                                                        let (_, p) = crate::agent::report_export::save_report_markdown().await;
4449                                                        p
4450                                                    }
4451                                                };
4452                                                let path_str = path.display().to_string();
4453                                                copy_text_to_clipboard(&path_str);
4454                                                app.push_message("System", &format!(
4455                                                    "Report saved: {}\n(Path copied to clipboard — open in browser or share with your team)",
4456                                                    path_str
4457                                                ));
4458                                                app.history_idx = None;
4459                                                continue;
4460                                            }
4461                                            "/save-html" => {
4462                                                let title = parts[1..].join(" ");
4463                                                // Find the last Hematite response in raw message history
4464                                                let last_response = app.messages_raw.iter().rev()
4465                                                    .find(|(speaker, _)| speaker == "Hematite")
4466                                                    .map(|(_, content)| content.clone());
4467                                                match last_response {
4468                                                    None => {
4469                                                        app.push_message("System", "No Hematite response found in this session to save.");
4470                                                    }
4471                                                    Some(body) => {
4472                                                        let (_, path) = crate::agent::report_export::save_research_html(&title, &body);
4473                                                        let path_str = path.display().to_string();
4474                                                        copy_text_to_clipboard(&path_str);
4475                                                        app.push_message("System", &format!(
4476                                                            "Saved: {}\n(Path copied to clipboard)",
4477                                                            path_str
4478                                                        ));
4479                                                        #[cfg(target_os = "windows")]
4480                                                        { let s = path.to_string_lossy().into_owned(); let _ = std::process::Command::new("cmd").args(["/c", "start", "", &s]).spawn(); }
4481                                                        #[cfg(not(target_os = "windows"))]
4482                                                        { let opener = if cfg!(target_os = "macos") { "open" } else { "xdg-open" }; let _ = std::process::Command::new(opener).arg(&path).spawn(); }
4483                                                    }
4484                                                }
4485                                                app.history_idx = None;
4486                                                continue;
4487                                            }
4488                                            "/detach" => {
4489                                                app.clear_pending_attachments();
4490                                                app.push_message("System", "Cleared pending document/image attachments for the next turn.");
4491                                                app.history_idx = None;
4492                                                continue;
4493                                            }
4494                                            "/attach" => {
4495                                                let file_path = parts[1..].join(" ").trim().to_string();
4496                                                if file_path.is_empty() {
4497                                                    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.");
4498                                                    app.history_idx = None;
4499                                                    continue;
4500                                                }
4501                                                if file_path.is_empty() {
4502                                                    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.");
4503                                                } else {
4504                                                    let p = std::path::Path::new(&file_path);
4505                                                    match crate::memory::vein::extract_document_text(p) {
4506                                                        Ok(text) => {
4507                                                            let name = p.file_name()
4508                                                                .and_then(|n| n.to_str())
4509                                                                .unwrap_or(&file_path)
4510                                                                .to_string();
4511                                                            let preview_len = text.len().min(200);
4512                                                            app.push_message("System", &format!(
4513                                                                "Attached: {} ({} chars) — will be injected as context on your next message.\nPreview: {}...",
4514                                                                name, text.len(), &text[..preview_len]
4515                                                            ));
4516                                                            app.attached_context = Some((name, text));
4517                                                        }
4518                                                        Err(e) => {
4519                                                            app.push_message("System", &format!("Attach failed: {}", e));
4520                                                        }
4521                                                    }
4522                                                }
4523                                                app.history_idx = None;
4524                                                continue;
4525                                            }
4526                                            "/attach-pick" => {
4527                                                match pick_attachment_path(AttachmentPickerKind::Document) {
4528                                                    Ok(Some(path)) => attach_document_from_path(&mut app, &path),
4529                                                    Ok(None) => app.push_message("System", "Document picker cancelled."),
4530                                                    Err(e) => app.push_message("System", &e),
4531                                                }
4532                                                app.history_idx = None;
4533                                                continue;
4534                                            }
4535                                            "/image" => {
4536                                                let file_path = parts[1..].join(" ").trim().to_string();
4537                                                if file_path.is_empty() {
4538                                                    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.");
4539                                                } else {
4540                                                    attach_image_from_path(&mut app, &file_path);
4541                                                }
4542                                                app.history_idx = None;
4543                                                continue;
4544                                            }
4545                                            "/image-pick" => {
4546                                                match pick_attachment_path(AttachmentPickerKind::Image) {
4547                                                    Ok(Some(path)) => attach_image_from_path(&mut app, &path),
4548                                                    Ok(None) => app.push_message("System", "Image picker cancelled."),
4549                                                    Err(e) => app.push_message("System", &e),
4550                                                }
4551                                                app.history_idx = None;
4552                                                continue;
4553                                            }
4554                                            _ => {
4555                                                app.push_message("System", &format!("Unknown command: {}", cmd));
4556                                                app.history_idx = None;
4557                                                continue;
4558                                            }
4559                                        }
4560                                    }
4561
4562                                    // Save to history (avoid consecutive duplicates).
4563                                    if app.input_history.last().map(|s| s.as_str()) != Some(&input_text) {
4564                                        app.input_history.push(input_text.clone());
4565                                        if app.input_history.len() > 50 {
4566                                            app.input_history.remove(0);
4567                                        }
4568                                    }
4569                                    app.history_idx = None;
4570                                    app.clear_grounded_recovery_cache();
4571                                    app.push_message("You", &input_text);
4572                                    app.agent_running = true;
4573                                    app.stop_requested = false;
4574                                    app.cancel_token.store(false, std::sync::atomic::Ordering::SeqCst);
4575                                    app.last_reasoning.clear();
4576                                    app.manual_scroll_offset = None;
4577                                    app.specular_auto_scroll = true;
4578                                    let tx = app.user_input_tx.clone();
4579                                    let outbound = UserTurn {
4580                                        text: input_text,
4581                                        attached_document: app.attached_context.take().map(|(name, content)| {
4582                                            AttachedDocument { name, content }
4583                                        }),
4584                                        attached_image: app.attached_image.take(),
4585                                    };
4586                                    tokio::spawn(async move {
4587                                        let _ = tx.send(outbound).await;
4588                                    });
4589                                }
4590                            }
4591                            _ => {}
4592                        }
4593                    }
4594                    Some(Ok(Event::Paste(content))) => {
4595                        if !try_attach_from_paste(&mut app, &content) {
4596                            // Normalize pasted newlines into spaces so we don't accidentally submit
4597                            // multiple lines or break the single-line input logic.
4598                            let normalized = content.replace("\r\n", " ").replace('\n', " ");
4599                            app.input.push_str(&normalized);
4600                            app.last_input_time = Instant::now();
4601                        }
4602                    }
4603                    _ => {}
4604                }
4605            }
4606
4607            // ── Specular proactive watcher ────────────────────────────────────
4608            Some(specular_evt) = specular_rx.recv() => {
4609                match specular_evt {
4610                    SpecularEvent::SyntaxError { path, details } => {
4611                        app.record_error();
4612                        app.specular_logs.push(format!("ERROR: {:?}", path));
4613                        trim_vec(&mut app.specular_logs, 20);
4614
4615                        // Only proactively suggest a fix if the user isn't actively typing.
4616                        let user_idle = {
4617                            let lock = last_interaction.lock().unwrap();
4618                            lock.elapsed() > std::time::Duration::from_secs(3)
4619                        };
4620                        if user_idle && !app.agent_running {
4621                            app.agent_running = true;
4622                            let tx = app.user_input_tx.clone();
4623                            let diag = details.clone();
4624                            tokio::spawn(async move {
4625                                let msg = format!(
4626                                    "<specular-build-fail>\n{}\n</specular-build-fail>\n\
4627                                     Fix the compiler error above.",
4628                                    diag
4629                                );
4630                                let _ = tx.send(UserTurn::text(msg)).await;
4631                            });
4632                        }
4633                    }
4634                    SpecularEvent::FileChanged(path) => {
4635                        app.stats.wisdom += 1;
4636                        app.stats.patience = (app.stats.patience - 0.5).max(0.0);
4637                        if app.stats.patience < 50.0 && !app.brief_mode {
4638                            app.brief_mode = true;
4639                            app.push_message("System", "Context saturation high — Brief Mode auto-enabled.");
4640                        }
4641                        let path_str = path.to_string_lossy().to_string();
4642                        app.specular_logs.push(format!("INDEX: {}", path_str));
4643                        app.push_context_file(path_str, "Active".into());
4644                        trim_vec(&mut app.specular_logs, 20);
4645                    }
4646                }
4647            }
4648
4649            // ── Inference / agent events ──────────────────────────────────────
4650            Some(event) = agent_rx.recv() => {
4651                use crate::agent::inference::InferenceEvent;
4652                match event {
4653                    InferenceEvent::Thought(content) => {
4654                        if app.stop_requested {
4655                            continue;
4656                        }
4657                        app.thinking = true;
4658                        app.current_thought.push_str(&content);
4659                    }
4660                    InferenceEvent::VoiceStatus(msg) => {
4661                        if app.stop_requested {
4662                            continue;
4663                        }
4664                        app.push_message("System", &msg);
4665                    }
4666                    InferenceEvent::Token(ref token) | InferenceEvent::MutedToken(ref token) => {
4667                        if app.stop_requested {
4668                            continue;
4669                        }
4670                        let is_muted = matches!(event, InferenceEvent::MutedToken(_));
4671                        app.thinking = false;
4672                        if app.messages_raw.last().map(|(s, _)| s.as_str()) != Some("Hematite") {
4673                            app.push_message("Hematite", "");
4674                        }
4675                        app.update_last_message(token);
4676                        app.manual_scroll_offset = None;
4677
4678                        // ONLY speak if not muted
4679                        if !is_muted && app.voice_manager.is_enabled() && !app.cancel_token.load(std::sync::atomic::Ordering::SeqCst) {
4680                            app.voice_manager.speak(token.clone());
4681                        }
4682                    }
4683                    InferenceEvent::ToolCallStart { id, name, args } => {
4684                        if app.stop_requested {
4685                            continue;
4686                        }
4687                        app.tool_started_at.insert(id, Instant::now());
4688                        // In chat mode, suppress tool noise from the main chat surface.
4689                        if app.workflow_mode != "CHAT" {
4690                            let display = format!("( )  {} {}", name, args);
4691                            app.push_message("Tool", &display);
4692                        }
4693                        // Always track in active context regardless of mode
4694                        app.active_context.push(ContextFile {
4695                            path: name.clone(),
4696                            size: 0,
4697                            status: "Running".into()
4698                        });
4699                        trim_vec_context(&mut app.active_context, 8);
4700                        app.manual_scroll_offset = None;
4701                    }
4702                    InferenceEvent::ToolCallResult { id, name, result, is_error } => {
4703                        if app.stop_requested {
4704                            continue;
4705                        }
4706                        if should_capture_grounded_tool_output(&name, is_error) {
4707                            app.recent_grounded_results.push((name.clone(), result.clone()));
4708                            if app.recent_grounded_results.len() > 4 {
4709                                app.recent_grounded_results.remove(0);
4710                            }
4711                        }
4712                        let icon = if is_error { "[x]" } else { "[v]" };
4713                        let elapsed_chip = app
4714                            .tool_started_at
4715                            .remove(&id)
4716                            .map(|started| format_tool_elapsed(started.elapsed()));
4717                        if is_error {
4718                            app.record_error();
4719                        }
4720                        // In chat mode, suppress tool results from main chat.
4721                        // Errors still show so the user knows something went wrong.
4722                        let preview = first_n_chars(&result, 100);
4723                        if app.workflow_mode != "CHAT" {
4724                            let display = if let Some(elapsed) = elapsed_chip.as_deref() {
4725                                format!("{}  {} [{}] ? {}", icon, name, elapsed, preview)
4726                            } else {
4727                                format!("{}  {} ? {}", icon, name, preview)
4728                            };
4729                            app.push_message("Tool", &display);
4730                        } else if is_error {
4731                            app.push_message("System", &format!("Tool error: {}", preview));
4732                        }
4733
4734                        // If it was a read or write, we can extract the path from the app.active_context "Running" entries
4735                        // but it's simpler to just let Specular handle the indexing or update here if we had the path.
4736
4737                        // Remove "Running" tools from context list
4738                        app.active_context.retain(|f| f.path != name || f.status != "Running");
4739                        app.manual_scroll_offset = None;
4740                    }
4741                    InferenceEvent::ApprovalRequired { id: _, name, display, diff, mutation_label, responder } => {
4742                        if app.stop_requested {
4743                            let _ = responder.send(false);
4744                            continue;
4745                        }
4746                        // Session-level auto-approve: skip dialog entirely.
4747                        if app.auto_approve_session {
4748                            if let Some(ref diff) = diff {
4749                                let added = diff.lines().filter(|l| l.starts_with("+ ")).count();
4750                                let removed = diff.lines().filter(|l| l.starts_with("- ")).count();
4751                                app.push_message("System", &format!(
4752                                    "Auto-approved: {} +{} -{}", display, added, removed
4753                                ));
4754                            } else {
4755                                app.push_message("System", &format!("Auto-approved: {}", display));
4756                            }
4757                            let _ = responder.send(true);
4758                            continue;
4759                        }
4760                        let is_diff = diff.is_some();
4761                        app.awaiting_approval = Some(PendingApproval {
4762                            display: display.clone(),
4763                            tool_name: name,
4764                            diff,
4765                            diff_scroll: 0,
4766                            mutation_label,
4767                            responder,
4768                        });
4769                        if is_diff {
4770                            app.push_message("System", "[~]  Diff preview — [Y] Apply  [N] Skip  [A] Accept All");
4771                        } else {
4772                            app.push_message("System", "[!]  Approval required — [Y] Approve  [N] Decline  [A] Accept All");
4773                            app.push_message("System", &format!("Command: {}", display));
4774                        }
4775                    }
4776                    InferenceEvent::TurnTiming { context_prep_ms, inference_ms, execution_ms } => {
4777                        app.specular_logs.push(format!(
4778                            "PROFILE: Prep {}ms | Eval {}ms | Exec {}ms",
4779                            context_prep_ms, inference_ms, execution_ms
4780                        ));
4781                        trim_vec(&mut app.specular_logs, 20);
4782                    }
4783                    InferenceEvent::UsageUpdate(usage) => {
4784                        app.total_tokens = usage.total_tokens;
4785                        // Calculate discounted cost for this turn.
4786                        let turn_cost = crate::agent::pricing::calculate_cost(&usage, &app.model_id);
4787                        app.current_session_cost += turn_cost;
4788                    }
4789                    InferenceEvent::Done => {
4790                        app.thinking = false;
4791                        app.agent_running = false;
4792                        app.stop_requested = false;
4793                        app.task_start_time = None;
4794                        if app.voice_manager.is_enabled() {
4795                            app.voice_manager.flush();
4796                        }
4797                        if !app.current_thought.is_empty() {
4798                            app.last_reasoning = app.current_thought.clone();
4799                        }
4800                        app.current_thought.clear();
4801                        // Force one last repaint of the visible chat buffer in case the
4802                        // final streamed token chunk did not trigger the lightweight
4803                        // reformat heuristic in update_last_message().
4804                        app.rebuild_formatted_messages();
4805                        app.manual_scroll_offset = None;
4806                        app.specular_auto_scroll = true;
4807                        // Clear single-agent task bars on completion
4808                        app.active_workers.remove("AGENT");
4809                        app.worker_labels.remove("AGENT");
4810                    }
4811                    InferenceEvent::CopyDiveInCommand(path) => {
4812                        let command = format!("cd \"{}\" && hematite", path.replace('\\', "/"));
4813                        copy_text_to_clipboard(&command);
4814                        spawn_dive_in_terminal(&path);
4815                        app.push_message("System", &format!("Teleportation initiated: New terminal launched at {}", path));
4816                        app.push_message("System", "Teleportation complete. Closing original session to maintain workstation hygiene...");
4817
4818                        // Self-Destruct Sequence: Graceful exit matching Ctrl+Q behavior
4819                        app.write_session_report();
4820                        app.copy_transcript_to_clipboard();
4821                        break;
4822                    }
4823                    InferenceEvent::ChainImplementPlan => {
4824                        app.push_message("You", "/implement-plan (Autonomous Handoff)");
4825                        app.manual_scroll_offset = None;
4826                    }
4827                    InferenceEvent::Error(e) => {
4828                        app.record_error();
4829                        app.thinking = false;
4830                        app.agent_running = false;
4831                        app.task_start_time = None;
4832                        if app.voice_manager.is_enabled() {
4833                            app.voice_manager.flush();
4834                        }
4835                        app.push_message("System", &format!("Error: {e}"));
4836                    }
4837                    InferenceEvent::ProviderStatus { state, summary } => {
4838                        app.provider_state = state;
4839                        if !summary.trim().is_empty() && app.last_provider_summary != summary {
4840                            app.specular_logs.push(format!("PROVIDER: {}", summary));
4841                            trim_vec(&mut app.specular_logs, 20);
4842                            app.last_provider_summary = summary;
4843                        }
4844                    }
4845                    InferenceEvent::McpStatus { state, summary } => {
4846                        app.mcp_state = state;
4847                        if !summary.trim().is_empty() && app.last_mcp_summary != summary {
4848                            app.specular_logs.push(format!("MCP: {}", summary));
4849                            trim_vec(&mut app.specular_logs, 20);
4850                            app.last_mcp_summary = summary;
4851                        }
4852                    }
4853                    InferenceEvent::OperatorCheckpoint { state, summary } => {
4854                        app.last_operator_checkpoint_state = state;
4855                        if state == OperatorCheckpointState::Idle {
4856                            app.last_operator_checkpoint_summary.clear();
4857                        } else if !summary.trim().is_empty()
4858                            && app.last_operator_checkpoint_summary != summary
4859                        {
4860                            app.specular_logs.push(format!(
4861                                "STATE: {} - {}",
4862                                state.label(),
4863                                summary
4864                            ));
4865                            trim_vec(&mut app.specular_logs, 20);
4866                            app.last_operator_checkpoint_summary = summary;
4867                        }
4868                    }
4869                    InferenceEvent::RecoveryRecipe { summary } => {
4870                        if !summary.trim().is_empty()
4871                            && app.last_recovery_recipe_summary != summary
4872                        {
4873                            app.specular_logs.push(format!("RECOVERY: {}", summary));
4874                            trim_vec(&mut app.specular_logs, 20);
4875                            app.last_recovery_recipe_summary = summary;
4876                        }
4877                    }
4878                    InferenceEvent::CompactionPressure {
4879                        estimated_tokens,
4880                        threshold_tokens,
4881                        percent,
4882                    } => {
4883                        app.compaction_estimated_tokens = estimated_tokens;
4884                        app.compaction_threshold_tokens = threshold_tokens;
4885                        app.compaction_percent = percent;
4886                        // Fire a one-shot warning when crossing 70% or 90%.
4887                        // Reset warned_level to 0 when pressure drops back below 60%
4888                        // so warnings re-fire if context fills up again after a /new.
4889                        if percent < 60 {
4890                            app.compaction_warned_level = 0;
4891                        } else if percent >= 90 && app.compaction_warned_level < 90 {
4892                            app.compaction_warned_level = 90;
4893                            app.push_message(
4894                                "System",
4895                                "Context is 90% full. Run /compact to summarize history in place, /new to reset (preserves project memory), or /forget to wipe everything.",
4896                            );
4897                        } else if percent >= 70 && app.compaction_warned_level < 70 {
4898                            app.compaction_warned_level = 70;
4899                            app.push_message(
4900                                "System",
4901                                &format!("Context at {}% — approaching compaction threshold. Run /compact to summarize history and free space.", percent),
4902                            );
4903                        }
4904                    }
4905                    InferenceEvent::PromptPressure {
4906                        estimated_input_tokens,
4907                        reserved_output_tokens,
4908                        estimated_total_tokens,
4909                        context_length: _,
4910                        percent,
4911                    } => {
4912                        app.prompt_estimated_input_tokens = estimated_input_tokens;
4913                        app.prompt_reserved_output_tokens = reserved_output_tokens;
4914                        app.prompt_estimated_total_tokens = estimated_total_tokens;
4915                        app.prompt_pressure_percent = percent;
4916                    }
4917                    InferenceEvent::TaskProgress { id, label, progress } => {
4918                        let nid = normalize_id(&id);
4919                        app.active_workers.insert(nid.clone(), progress);
4920                        app.worker_labels.insert(nid, label);
4921                    }
4922                    InferenceEvent::RuntimeProfile {
4923                        provider_name,
4924                        endpoint,
4925                        model_id,
4926                        context_length,
4927                    } => {
4928                        let was_no_model = app.model_id == "no model loaded";
4929                        let now_no_model = model_id == "no model loaded";
4930                        let changed = app.model_id != "detecting..."
4931                            && (app.model_id != model_id || app.context_length != context_length);
4932                        let provider_changed = app.provider_name != provider_name;
4933                        app.provider_name = provider_name.clone();
4934                        app.provider_endpoint = endpoint.clone();
4935                        app.model_id = model_id.clone();
4936                        app.context_length = context_length;
4937                        app.last_runtime_profile_time = Instant::now();
4938                        if app.provider_state == ProviderRuntimeState::Booting {
4939                            app.provider_state = ProviderRuntimeState::Live;
4940                        }
4941                        if now_no_model && !was_no_model {
4942                            let mut guidance = if provider_name == "Ollama" {
4943                                "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()
4944                            } else {
4945                                "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()
4946                            };
4947                            if let Some((alt_name, alt_url)) =
4948                                crate::runtime::detect_alternative_provider(&provider_name).await
4949                            {
4950                                guidance.push_str(&format!(
4951                                    " Reachable alternative detected: {} ({}). Use `/provider {}` and restart Hematite if you want to switch.",
4952                                    alt_name,
4953                                    alt_url,
4954                                    alt_name.to_ascii_lowercase().replace(' ', "")
4955                                ));
4956                            }
4957                            app.push_message("System", &guidance);
4958                        } else if provider_changed && !now_no_model {
4959                            app.push_message(
4960                                "System",
4961                                &format!(
4962                                    "Provider detected: {} | Model {} | CTX {}",
4963                                    provider_name, model_id, context_length
4964                                ),
4965                            );
4966                        } else if changed && !now_no_model {
4967                            app.push_message(
4968                                "System",
4969                                &format!(
4970                                    "Runtime profile refreshed: {} | Model {} | CTX {}",
4971                                    provider_name, model_id, context_length
4972                                ),
4973                            );
4974                        }
4975                    }
4976                    InferenceEvent::EmbedProfile { model_id } => {
4977                        let changed = app.embed_model_id != model_id;
4978                        app.embed_model_id = model_id.clone();
4979                        if changed {
4980                            match model_id {
4981                                Some(id) => app.push_message(
4982                                    "System",
4983                                    &format!("Embed model loaded: {} (semantic search ready)", id),
4984                                ),
4985                                None => app.push_message(
4986                                    "System",
4987                                    "Embed model unloaded. Semantic search inactive.",
4988                                ),
4989                            }
4990                        }
4991                    }
4992                    InferenceEvent::VeinStatus { file_count, embedded_count, docs_only } => {
4993                        app.vein_file_count = file_count;
4994                        app.vein_embedded_count = embedded_count;
4995                        app.vein_docs_only = docs_only;
4996                    }
4997                    InferenceEvent::VeinContext { paths } => {
4998                        // Replace the default placeholder entries with what the
4999                        // Vein actually surfaced for this turn.
5000                        app.active_context.retain(|f| f.status == "Running");
5001                        for path in paths {
5002                            let root = crate::tools::file_ops::workspace_root();
5003                            let size = std::fs::metadata(root.join(&path))
5004                                .map(|m| m.len())
5005                                .unwrap_or(0);
5006                            if !app.active_context.iter().any(|f| f.path == path) {
5007                                app.active_context.push(ContextFile {
5008                                    path,
5009                                    size,
5010                                    status: "Vein".to_string(),
5011                                });
5012                            }
5013                        }
5014                        trim_vec_context(&mut app.active_context, 8);
5015                    }
5016                    InferenceEvent::SoulReroll { species, rarity, shiny, .. } => {
5017                        let shiny_tag = if shiny { " 🌟 SHINY" } else { "" };
5018                        app.soul_name = species.clone();
5019                        app.push_message(
5020                            "System",
5021                            &format!("[{}{}] {} has awakened.", rarity, shiny_tag, species),
5022                        );
5023                    }
5024                    InferenceEvent::ShellLine(line) => {
5025                        // Stream shell output into the SPECULAR side panel as it
5026                        // arrives so the operator sees live progress.
5027                        app.current_thought.push_str(&line);
5028                        app.current_thought.push('\n');
5029                    }
5030                    InferenceEvent::TurnBudget(budget) => {
5031                        // Route the per-turn context budget ledger into SPECULAR.
5032                        app.current_thought.push_str(&budget.render());
5033                        app.current_thought.push('\n');
5034                    }
5035                }
5036            }
5037
5038            // ── Swarm messages ────────────────────────────────────────────────
5039            Some(msg) = swarm_rx.recv() => {
5040                match msg {
5041                    SwarmMessage::Progress(worker_id, progress) => {
5042                        let nid = normalize_id(&worker_id);
5043                        app.active_workers.insert(nid.clone(), progress);
5044                        match progress {
5045                            102 => app.push_message("System", &format!("Worker {} architecture verified and applied.", nid)),
5046                            101 => { /* Handled by 102 terminal message */ },
5047                            100 => app.push_message("Hematite", &format!("Worker {} complete. Standing by for review...", nid)),
5048                            _ => {}
5049                        }
5050                    }
5051                    SwarmMessage::ReviewRequest { worker_id, file_path, before, after, tx } => {
5052                        app.push_message("Hematite", &format!("Worker {} conflict — review required.", worker_id));
5053                        app.active_review = Some(ActiveReview {
5054                            worker_id,
5055                            file_path: file_path.to_string_lossy().to_string(),
5056                            before,
5057                            after,
5058                            tx,
5059                        });
5060                    }
5061                    SwarmMessage::Done => {
5062                        app.agent_running = false;
5063                        // Workers now persist in SPECULAR until a new command is issued
5064                        app.push_message("System", "──────────────────────────────────────────────────────────");
5065                        app.push_message("System", " TASK COMPLETE: Swarm Synthesis Finalized ");
5066                        app.push_message("System", "──────────────────────────────────────────────────────────");
5067                    }
5068                }
5069            }
5070        }
5071    }
5072    Ok(())
5073}
5074
5075// ── Render ────────────────────────────────────────────────────────────────────
5076
5077fn ui(f: &mut ratatui::Frame, app: &App) {
5078    let size = f.size();
5079    if size.width < 60 || size.height < 10 {
5080        // Render a minimal wait message or just clear if area is too collapsed
5081        f.render_widget(Clear, size);
5082        return;
5083    }
5084
5085    let input_height = compute_input_height(f.size().width, app.input.len());
5086
5087    let chunks = Layout::default()
5088        .direction(Direction::Vertical)
5089        .constraints([
5090            Constraint::Min(0),
5091            Constraint::Length(input_height),
5092            Constraint::Length(5), // Expanded to accommodate Multi-Tier Liquid Telemetry
5093        ])
5094        .split(f.size());
5095
5096    let sidebar_mode = sidebar_mode(app, size.width);
5097    let sidebar_width = match sidebar_mode {
5098        SidebarMode::Hidden => 0,
5099        SidebarMode::Compact => 32,
5100        SidebarMode::Full => 45,
5101    };
5102    let top = Layout::default()
5103        .direction(Direction::Horizontal)
5104        .constraints([Constraint::Fill(1), Constraint::Length(sidebar_width)])
5105        .split(chunks[0]);
5106
5107    // ── Box 1: Dialogue ───────────────────────────────────────────────────────
5108    let mut core_lines = app.messages.clone();
5109
5110    // Show agent-running indicator as last line when active.
5111    if app.agent_running {
5112        let dots = ".".repeat((app.tick_count % 4) as usize + 1);
5113        core_lines.push(Line::from(Span::styled(
5114            format!(" Hematite is thinking{}", dots),
5115            Style::default()
5116                .fg(Color::Magenta)
5117                .add_modifier(Modifier::DIM),
5118        )));
5119    }
5120
5121    let (heart_color, core_icon) = if app.agent_running || !app.active_workers.is_empty() {
5122        let (r_base, g_base, b_base) = if !app.active_workers.is_empty() {
5123            (0, 200, 200) // Cyan pulse for swarm
5124        } else {
5125            (200, 0, 200) // Magenta pulse for thinking
5126        };
5127
5128        let pulse = (app.tick_count % 50) as f64 / 50.0;
5129        let factor = (pulse * std::f64::consts::PI).sin().abs();
5130        let r = (r_base as f64 * factor) as u8;
5131        let g = (g_base as f64 * factor) as u8;
5132        let b = (b_base as f64 * factor) as u8;
5133
5134        (Color::Rgb(r.max(60), g.max(60), b.max(60)), "•")
5135    } else {
5136        (Color::Rgb(80, 80, 80), "•") // Standby
5137    };
5138
5139    let live_objective = if app.current_objective != "Idle" {
5140        app.current_objective.clone()
5141    } else if !app.active_workers.is_empty() {
5142        "Swarm active".to_string()
5143    } else if app.thinking {
5144        "Reasoning".to_string()
5145    } else if app.agent_running {
5146        "Working".to_string()
5147    } else {
5148        "Idle".to_string()
5149    };
5150
5151    let objective_text = if live_objective.len() > 30 {
5152        format!("{}...", &live_objective[..27])
5153    } else {
5154        live_objective
5155    };
5156
5157    let core_title = if app.professional {
5158        Line::from(vec![
5159            Span::styled(format!(" {} ", core_icon), Style::default().fg(heart_color)),
5160            Span::styled("HEMATITE ", Style::default().add_modifier(Modifier::BOLD)),
5161            Span::styled(
5162                format!(" TASK: {} ", objective_text),
5163                Style::default()
5164                    .fg(Color::Yellow)
5165                    .add_modifier(Modifier::ITALIC),
5166            ),
5167        ])
5168    } else {
5169        Line::from(format!(" TASK: {} ", objective_text))
5170    };
5171
5172    let core_para = Paragraph::new(core_lines.clone())
5173        .block(
5174            Block::default()
5175                .title(core_title)
5176                .borders(Borders::ALL)
5177                .border_style(Style::default().fg(Color::DarkGray)),
5178        )
5179        .wrap(Wrap { trim: true });
5180
5181    // Enhanced Scroll calculation.
5182    let avail_h = top[0].height.saturating_sub(2);
5183    // Borders (2) + Scrollbar (1) + explicit Padding (1) = 4.
5184    let inner_w = top[0].width.saturating_sub(4).max(1);
5185
5186    let mut total_lines: u16 = 0;
5187    for line in &core_lines {
5188        let line_w = line.width() as u16;
5189        if line_w == 0 {
5190            total_lines += 1;
5191        } else {
5192            // TUI SCROLL FIX:
5193            // Exact calculation: how many times does line_w fit into inner_w?
5194            // This matches Paragraph's internal Wrap logic closely.
5195            let wrapped = (line_w + inner_w - 1) / inner_w;
5196            total_lines += wrapped;
5197        }
5198    }
5199
5200    let max_scroll = total_lines.saturating_sub(avail_h);
5201    let scroll = if let Some(off) = app.manual_scroll_offset {
5202        max_scroll.saturating_sub(off)
5203    } else {
5204        max_scroll
5205    };
5206
5207    // Clear the outer chunk and the inner dialogue area to prevent ghosting from previous frames or background renders.
5208    f.render_widget(Clear, top[0]);
5209
5210    // Create a sub-area for the dialogue with horizontal padding.
5211    let chat_area = Rect::new(
5212        top[0].x + 1,
5213        top[0].y,
5214        top[0].width.saturating_sub(2).max(1),
5215        top[0].height,
5216    );
5217    f.render_widget(Clear, chat_area);
5218    f.render_widget(core_para.scroll((scroll, 0)), chat_area);
5219
5220    // Scrollbar: content_length = max_scroll+1 so position==max_scroll puts the
5221    // thumb flush at the bottom (position == content_length - 1).
5222    let mut scrollbar_state =
5223        ScrollbarState::new(max_scroll as usize + 1).position(scroll as usize);
5224    f.render_stateful_widget(
5225        Scrollbar::default()
5226            .orientation(ScrollbarOrientation::VerticalRight)
5227            .begin_symbol(Some("↑"))
5228            .end_symbol(Some("↓")),
5229        top[0],
5230        &mut scrollbar_state,
5231    );
5232
5233    // ── Box 2: Side panel ─────────────────────────────────────────────────────
5234    if sidebar_mode == SidebarMode::Compact && top[1].width > 0 {
5235        let compact_title = if sidebar_has_live_activity(app) {
5236            " SIGNALS "
5237        } else {
5238            " SESSION "
5239        };
5240        let compact_para = Paragraph::new(build_compact_sidebar_lines(app))
5241            .wrap(Wrap { trim: true })
5242            .block(
5243                Block::default()
5244                    .title(compact_title)
5245                    .borders(Borders::ALL)
5246                    .border_style(Style::default().fg(Color::DarkGray)),
5247            );
5248        f.render_widget(Clear, top[1]);
5249        f.render_widget(compact_para, top[1]);
5250    } else if sidebar_mode == SidebarMode::Full && top[1].width > 0 {
5251        let side = Layout::default()
5252            .direction(Direction::Vertical)
5253            .constraints([
5254                Constraint::Length(8), // CONTEXT
5255                Constraint::Min(0),    // SPECULAR
5256            ])
5257            .split(top[1]);
5258
5259        // Pane 1: Context (Nervous focus)
5260        let context_source = if app.active_context.is_empty() {
5261            default_active_context()
5262        } else {
5263            app.active_context.clone()
5264        };
5265        let mut context_display = context_source
5266            .iter()
5267            .map(|f| {
5268                let (icon, color) = match f.status.as_str() {
5269                    "Running" => ("⚙️", Color::Cyan),
5270                    "Dirty" => ("📝", Color::Yellow),
5271                    _ => ("📄", Color::Gray),
5272                };
5273                // Simple heuristic for "Tokens" (size / 4)
5274                let tokens = f.size / 4;
5275                ListItem::new(Line::from(vec![
5276                    Span::styled(format!(" {} ", icon), Style::default().fg(color)),
5277                    Span::styled(f.path.clone(), Style::default().fg(Color::White)),
5278                    Span::styled(
5279                        format!(" {}t ", tokens),
5280                        Style::default().fg(Color::DarkGray),
5281                    ),
5282                ]))
5283            })
5284            .collect::<Vec<ListItem>>();
5285
5286        if context_display.is_empty() {
5287            context_display = vec![ListItem::new(" (No active files)")];
5288        }
5289
5290        let ctx_title = if sidebar_has_live_activity(app) {
5291            " LIVE CONTEXT "
5292        } else {
5293            " SESSION CONTEXT "
5294        };
5295
5296        let ctx_block = Block::default()
5297            .title(ctx_title)
5298            .borders(Borders::ALL)
5299            .border_style(Style::default().fg(Color::DarkGray));
5300
5301        f.render_widget(Clear, side[0]);
5302        f.render_widget(List::new(context_display).block(ctx_block), side[0]);
5303
5304        // Optional: Add a Gauge for total context if tokens were tracked accurately.
5305        // For now, let's just make the CONTEXT pane look high-density.
5306
5307        // ── SPECULAR panel (Pane 2) ────────────────────────────────────────────────
5308        let v_title = if app.thinking || app.agent_running {
5309            " HEMATITE SIGNALS [live] ".to_string()
5310        } else {
5311            " HEMATITE SIGNALS [watching] ".to_string()
5312        };
5313
5314        f.render_widget(Clear, side[1]);
5315
5316        let mut v_lines: Vec<Line<'static>> = Vec::new();
5317
5318        // Section: live thought (bounded to last 300 chars to avoid wall-of-text)
5319        if app.thinking || app.agent_running {
5320            let dots = ".".repeat((app.tick_count % 4) as usize + 1);
5321            let label = if app.thinking { "REASONING" } else { "WORKING" };
5322            v_lines.push(Line::from(vec![Span::styled(
5323                format!("[ {}{} ]", label, dots),
5324                Style::default()
5325                    .fg(Color::Green)
5326                    .add_modifier(Modifier::BOLD),
5327            )]));
5328            // Show last 300 chars of current thought, split by line.
5329            let preview = if app.current_thought.chars().count() > 300 {
5330                app.current_thought
5331                    .chars()
5332                    .rev()
5333                    .take(300)
5334                    .collect::<Vec<_>>()
5335                    .into_iter()
5336                    .rev()
5337                    .collect::<String>()
5338            } else {
5339                app.current_thought.clone()
5340            };
5341            for raw in preview.lines() {
5342                let raw = raw.trim();
5343                if !raw.is_empty() {
5344                    v_lines.extend(render_markdown_line(raw));
5345                }
5346            }
5347            v_lines.push(Line::raw(""));
5348        } else {
5349            v_lines.push(Line::from(vec![
5350                Span::styled("• ", Style::default().fg(Color::DarkGray)),
5351                Span::styled(
5352                    "Waiting for the next turn. Runtime, MCP, and index signals stay visible here.",
5353                    Style::default().fg(Color::Gray),
5354                ),
5355            ]));
5356            v_lines.push(Line::raw(""));
5357        }
5358
5359        let signal_rows = sidebar_signal_rows(app);
5360        if !signal_rows.is_empty() {
5361            let section_title = if app.thinking || app.agent_running {
5362                "-- Operator Signals --"
5363            } else {
5364                "-- Session Snapshot --"
5365            };
5366            v_lines.push(Line::from(vec![Span::styled(
5367                section_title,
5368                Style::default()
5369                    .fg(Color::White)
5370                    .add_modifier(Modifier::DIM),
5371            )]));
5372            for (row, color) in signal_rows
5373                .iter()
5374                .take(if app.thinking || app.agent_running {
5375                    4
5376                } else {
5377                    3
5378                })
5379            {
5380                v_lines.push(Line::from(vec![
5381                    Span::styled("- ", Style::default().fg(Color::DarkGray)),
5382                    Span::styled(row.clone(), Style::default().fg(*color)),
5383                ]));
5384            }
5385            v_lines.push(Line::raw(""));
5386        }
5387
5388        // Section: worker progress bars
5389        if !app.active_workers.is_empty() {
5390            v_lines.push(Line::from(vec![Span::styled(
5391                "── Task Progress ──",
5392                Style::default()
5393                    .fg(Color::White)
5394                    .add_modifier(Modifier::DIM),
5395            )]));
5396
5397            let mut sorted_ids: Vec<_> = app.active_workers.keys().cloned().collect();
5398            sorted_ids.sort();
5399
5400            for id in sorted_ids {
5401                let prog = app.active_workers[&id];
5402                let custom_label = app.worker_labels.get(&id).cloned();
5403
5404                let (label, color) = match prog {
5405                    101..=102 => ("VERIFIED", Color::Green),
5406                    100 if !app.agent_running && id != "AGENT" => ("SKIPPED ", Color::DarkGray),
5407                    100 => ("REVIEW  ", Color::Magenta),
5408                    _ => ("WORKING ", Color::Yellow),
5409                };
5410
5411                let display_label = custom_label.unwrap_or_else(|| label.to_string());
5412                let filled = (prog.min(100) / 10) as usize;
5413                let bar = "▓".repeat(filled) + &"░".repeat(10 - filled);
5414
5415                let id_prefix = if id == "AGENT" {
5416                    "Agent: ".to_string()
5417                } else {
5418                    format!("W{}: ", id)
5419                };
5420
5421                v_lines.push(Line::from(vec![
5422                    Span::styled(id_prefix, Style::default().fg(Color::Gray)),
5423                    Span::styled(bar, Style::default().fg(color)),
5424                    Span::styled(
5425                        format!(" {} ", display_label),
5426                        Style::default().fg(color).add_modifier(Modifier::BOLD),
5427                    ),
5428                    Span::styled(
5429                        format!("{}%", prog.min(100)),
5430                        Style::default().fg(Color::DarkGray),
5431                    ),
5432                ]));
5433            }
5434            v_lines.push(Line::raw(""));
5435        }
5436
5437        // Section: last completed turn's reasoning
5438        if (app.thinking || app.agent_running) && !app.last_reasoning.is_empty() {
5439            v_lines.push(Line::from(vec![Span::styled(
5440                "── Logic Trace ──",
5441                Style::default()
5442                    .fg(Color::White)
5443                    .add_modifier(Modifier::DIM),
5444            )]));
5445            for raw in app.last_reasoning.lines() {
5446                v_lines.extend(render_markdown_line(raw));
5447            }
5448            v_lines.push(Line::raw(""));
5449        }
5450
5451        // Section: specular event log
5452        if !app.specular_logs.is_empty() {
5453            v_lines.push(Line::from(vec![Span::styled(
5454                if app.thinking || app.agent_running {
5455                    "── Live Events ──"
5456                } else {
5457                    "── Recent Events ──"
5458                },
5459                Style::default()
5460                    .fg(Color::White)
5461                    .add_modifier(Modifier::DIM),
5462            )]));
5463            let recent_logs: Vec<String> = if app.thinking || app.agent_running {
5464                app.specular_logs.iter().rev().take(8).cloned().collect()
5465            } else {
5466                app.specular_logs.iter().rev().take(5).cloned().collect()
5467            };
5468            for log in recent_logs.into_iter().rev() {
5469                let (icon, color) = if log.starts_with("ERROR") {
5470                    ("X ", Color::Red)
5471                } else if log.starts_with("INDEX") {
5472                    ("I ", Color::Cyan)
5473                } else if log.starts_with("GHOST") {
5474                    ("< ", Color::Magenta)
5475                } else {
5476                    ("- ", Color::Gray)
5477                };
5478                v_lines.push(Line::from(vec![
5479                    Span::styled(icon, Style::default().fg(color)),
5480                    Span::styled(
5481                        log,
5482                        Style::default()
5483                            .fg(Color::White)
5484                            .add_modifier(Modifier::DIM),
5485                    ),
5486                ]));
5487            }
5488        }
5489
5490        let v_total = v_lines.len() as u16;
5491        let v_avail = side[1].height.saturating_sub(2);
5492        let v_max_scroll = v_total.saturating_sub(v_avail);
5493        // If auto-scroll is active, always show the bottom. Otherwise respect the
5494        // user's manual position (clamped so we never scroll past the content end).
5495        let v_scroll = if app.specular_auto_scroll {
5496            v_max_scroll
5497        } else {
5498            app.specular_scroll.min(v_max_scroll)
5499        };
5500
5501        let specular_para = Paragraph::new(v_lines)
5502            .wrap(Wrap { trim: true })
5503            .scroll((v_scroll, 0))
5504            .block(Block::default().title(v_title).borders(Borders::ALL));
5505
5506        f.render_widget(specular_para, side[1]);
5507
5508        // Scrollbar for SPECULAR
5509        let mut v_scrollbar_state =
5510            ScrollbarState::new(v_max_scroll as usize + 1).position(v_scroll as usize);
5511        f.render_stateful_widget(
5512            Scrollbar::default()
5513                .orientation(ScrollbarOrientation::VerticalRight)
5514                .begin_symbol(None)
5515                .end_symbol(None),
5516            side[1],
5517            &mut v_scrollbar_state,
5518        );
5519    }
5520
5521    // ── Box 3: Status bar ─────────────────────────────────────────────────────
5522    let frame = app.tick_count % 3;
5523    let _spark = match frame {
5524        0 => "✧",
5525        1 => "✦",
5526        _ => "✨",
5527    };
5528    let _vigil = if app.brief_mode {
5529        "VIGIL:[ON]"
5530    } else {
5531        "VIGIL:[off]"
5532    };
5533    let _yolo = if app.yolo_mode {
5534        " | APPROVALS: OFF"
5535    } else {
5536        ""
5537    };
5538
5539    let bar_constraints = vec![Constraint::Fill(1)];
5540    let bar_chunks = Layout::default()
5541        .direction(Direction::Horizontal)
5542        .constraints(bar_constraints)
5543        .split(chunks[2]);
5544
5545    // ── Box 4: Logic Activity Row (Alive cues) ───────────────────────────────
5546    // We render this in the bottom-most area if active.
5547    let _footer_row_legacy = if app.agent_running {
5548        let elapsed = if let Some(start) = app.task_start_time {
5549            format!(" {:0>2}s ", start.elapsed().as_secs())
5550        } else {
5551            String::new()
5552        };
5553        let last_log = app
5554            .specular_logs
5555            .last()
5556            .map(|s| s.as_str())
5557            .unwrap_or("...");
5558        let spinner = match app.tick_count % 8 {
5559            0 => "⠋",
5560            1 => "⠙",
5561            2 => "⠹",
5562            3 => "⠸",
5563            4 => "⠼",
5564            5 => "⠴",
5565            6 => "⠦",
5566            _ => "⠧",
5567        };
5568
5569        Line::from(vec![
5570            Span::styled(
5571                format!(" {} ", spinner),
5572                Style::default()
5573                    .fg(Color::Cyan)
5574                    .add_modifier(Modifier::BOLD),
5575            ),
5576            Span::styled(
5577                elapsed,
5578                Style::default()
5579                    .bg(Color::Rgb(40, 40, 40))
5580                    .fg(Color::White)
5581                    .add_modifier(Modifier::BOLD),
5582            ),
5583            Span::styled(
5584                format!(" ⬢ {}", last_log),
5585                Style::default().fg(Color::DarkGray),
5586            ),
5587        ])
5588    } else {
5589        Line::from(vec![
5590            Span::styled(" ⬢ ", Style::default().fg(Color::Rgb(40, 40, 40))),
5591            Span::styled(
5592                " [↑/↓] scroll ",
5593                Style::default()
5594                    .fg(Color::DarkGray)
5595                    .add_modifier(Modifier::DIM),
5596            ),
5597            Span::styled(" | ", Style::default().fg(Color::Rgb(30, 30, 30))),
5598            Span::styled(
5599                " /help hints ",
5600                Style::default()
5601                    .fg(Color::DarkGray)
5602                    .add_modifier(Modifier::DIM),
5603            ),
5604        ])
5605    };
5606
5607    let footer_row = {
5608        let footer_row_width = bar_chunks[0].width.saturating_sub(6);
5609        if app.agent_running {
5610            let elapsed = if let Some(start) = app.task_start_time {
5611                format!(" {:0>2}s ", start.elapsed().as_secs())
5612            } else {
5613                String::new()
5614            };
5615            let last_log = app
5616                .specular_logs
5617                .last()
5618                .map(|s| s.as_str())
5619                .unwrap_or("...");
5620            let spinner = match app.tick_count % 8 {
5621                0 => "⠋",
5622                1 => "⠙",
5623                2 => "⠹",
5624                3 => "⠸",
5625                4 => "⠼",
5626                5 => "⠴",
5627                6 => "⠦",
5628                _ => "⠧",
5629            };
5630            let footer_caption = select_fitting_variant(
5631                &running_footer_variants(app, &elapsed, last_log),
5632                footer_row_width,
5633            );
5634
5635            Line::from(vec![
5636                Span::styled(
5637                    format!(" {} ", spinner),
5638                    Style::default()
5639                        .fg(Color::Cyan)
5640                        .add_modifier(Modifier::BOLD),
5641                ),
5642                Span::styled(
5643                    elapsed,
5644                    Style::default()
5645                        .bg(Color::Rgb(40, 40, 40))
5646                        .fg(Color::White)
5647                        .add_modifier(Modifier::BOLD),
5648                ),
5649                Span::styled(
5650                    format!(" ⬢ {}", footer_caption),
5651                    Style::default().fg(Color::DarkGray),
5652                ),
5653            ])
5654        } else {
5655            let idle_hint = select_fitting_variant(&idle_footer_variants(app), footer_row_width);
5656            Line::from(vec![
5657                Span::styled(" ⬢ ", Style::default().fg(Color::Rgb(40, 40, 40))),
5658                Span::styled(
5659                    idle_hint,
5660                    Style::default()
5661                        .fg(Color::DarkGray)
5662                        .add_modifier(Modifier::DIM),
5663                ),
5664            ])
5665        }
5666    };
5667
5668    let runtime_age = app.last_runtime_profile_time.elapsed();
5669    let provider_prefix = provider_badge_prefix(&app.provider_name);
5670    let issue = runtime_issue_kind(app);
5671    let (issue_code, issue_color) = runtime_issue_badge(issue);
5672    let (lm_label, lm_color) = if issue == RuntimeIssueKind::NoModel {
5673        (format!("{provider_prefix}:NONE"), Color::Red)
5674    } else if issue == RuntimeIssueKind::Booting {
5675        (format!("{provider_prefix}:BOOT"), Color::DarkGray)
5676    } else if issue == RuntimeIssueKind::Recovering {
5677        (format!("{provider_prefix}:RECV"), Color::Cyan)
5678    } else if matches!(
5679        issue,
5680        RuntimeIssueKind::Connectivity | RuntimeIssueKind::EmptyResponse
5681    ) {
5682        (format!("{provider_prefix}:WARN"), Color::Red)
5683    } else if issue == RuntimeIssueKind::ContextCeiling {
5684        (format!("{provider_prefix}:CEIL"), Color::Yellow)
5685    } else if runtime_age > std::time::Duration::from_secs(12) {
5686        (format!("{provider_prefix}:STALE"), Color::Yellow)
5687    } else {
5688        (format!("{provider_prefix}:LIVE"), Color::Green)
5689    };
5690    let compaction_percent = app.compaction_percent.min(100);
5691    let _compaction_label = if app.compaction_threshold_tokens == 0 {
5692        " CMP:  0%".to_string()
5693    } else {
5694        format!(" CMP:{:>3}%", compaction_percent)
5695    };
5696    let _compaction_color = if app.compaction_threshold_tokens == 0 {
5697        Color::DarkGray
5698    } else if compaction_percent >= 85 {
5699        Color::Red
5700    } else if compaction_percent >= 60 {
5701        Color::Yellow
5702    } else {
5703        Color::Green
5704    };
5705    let prompt_percent = app.prompt_pressure_percent.min(100);
5706    let _prompt_label = if app.prompt_estimated_total_tokens == 0 {
5707        " BUD:  0%".to_string()
5708    } else {
5709        format!(" BUD:{:>3}%", prompt_percent)
5710    };
5711    let _prompt_color = if app.prompt_estimated_total_tokens == 0 {
5712        Color::DarkGray
5713    } else if prompt_percent >= 85 {
5714        Color::Red
5715    } else if prompt_percent >= 60 {
5716        Color::Yellow
5717    } else {
5718        Color::Green
5719    };
5720
5721    let _think_badge = match app.think_mode {
5722        Some(true) => " [THINK]",
5723        Some(false) => " [FAST]",
5724        None => "",
5725    };
5726
5727    // ── VRAM gauge (live from nvidia-smi poller) ─────────────────────────────
5728    let vram_ratio = app.gpu_state.ratio();
5729    let vram_label = app.gpu_state.label();
5730    let gpu_name = app.gpu_state.gpu_name();
5731
5732    let (vein_label, vein_color) = if app.vein_docs_only {
5733        let color = if app.vein_embedded_count > 0 {
5734            Color::Green
5735        } else if app.vein_file_count > 0 {
5736            Color::Yellow
5737        } else {
5738            Color::DarkGray
5739        };
5740        ("VN:DOC", color)
5741    } else if app.vein_file_count == 0 {
5742        ("VN:--", Color::DarkGray)
5743    } else if app.vein_embedded_count > 0 {
5744        ("VN:SEM", Color::Green)
5745    } else {
5746        ("VN:FTS", Color::Yellow)
5747    };
5748
5749    let char_count: usize = app.messages_raw.iter().map(|(_, c)| c.len()).sum();
5750    let est_tokens = char_count / 3;
5751    let current_tokens = if app.total_tokens > 0 {
5752        app.total_tokens
5753    } else {
5754        est_tokens
5755    };
5756    let session_usage_text = format!(
5757        " TOKENS: {:0>5} | TOTAL: ${:.2} ",
5758        current_tokens, app.current_session_cost
5759    );
5760
5761    // ── Single Liquid Status Bar ──────────────────────────────────────────
5762    f.render_widget(Clear, bar_chunks[0]);
5763
5764    let usage_color = Color::Rgb(100, 100, 100);
5765    let ai_line = vec![
5766        Span::styled(
5767            format!(" {} ", lm_label),
5768            Style::default().fg(lm_color).add_modifier(Modifier::BOLD),
5769        ),
5770        Span::styled("║ ", Style::default().fg(Color::Rgb(60, 60, 60))),
5771        Span::styled(format!("{} ", vein_label), Style::default().fg(vein_color)),
5772        Span::styled("│ ", Style::default().fg(Color::Rgb(40, 40, 40))),
5773        Span::styled(format!("{} ", issue_code), Style::default().fg(issue_color)),
5774        Span::styled("│ ", Style::default().fg(Color::Rgb(40, 40, 40))),
5775        Span::styled(
5776            format!("CTX:{} ", app.context_length),
5777            Style::default().fg(Color::DarkGray),
5778        ),
5779        Span::styled("│ ", Style::default().fg(Color::Rgb(40, 40, 40))),
5780        Span::styled(
5781            format!("REMOTE:{} ", app.git_state.label()),
5782            Style::default().fg(Color::DarkGray),
5783        ),
5784        Span::styled("│ ", Style::default().fg(Color::Rgb(40, 40, 40))),
5785        Span::styled(
5786            format!("BUD:{:>3}% CMP:{:>3}% ", prompt_percent, compaction_percent),
5787            Style::default().fg(Color::DarkGray),
5788        ),
5789        Span::styled("│ ", Style::default().fg(Color::Rgb(40, 40, 40))),
5790        Span::styled(session_usage_text, Style::default().fg(usage_color)),
5791    ];
5792
5793    let hardware_line = vec![
5794        Span::styled("   ⬢ ", Style::default().fg(Color::Rgb(60, 60, 60))), // Gray tint
5795        Span::styled(
5796            format!("{} ", gpu_name),
5797            Style::default()
5798                .fg(Color::Rgb(200, 200, 200))
5799                .add_modifier(Modifier::BOLD),
5800        ),
5801        Span::styled("║ ", Style::default().fg(Color::Rgb(60, 60, 60))),
5802        Span::styled(
5803            format!(
5804                "[{}] ",
5805                make_animated_sparkline_gauge(vram_ratio, 12, app.tick_count)
5806            ),
5807            Style::default().fg(Color::Cyan),
5808        ),
5809        Span::styled(
5810            format!("{}% ", (vram_ratio * 100.0) as u8),
5811            Style::default().fg(Color::Cyan),
5812        ),
5813        Span::styled(
5814            format!("({})", vram_label),
5815            Style::default()
5816                .fg(Color::DarkGray)
5817                .add_modifier(Modifier::DIM),
5818        ),
5819    ];
5820
5821    f.render_widget(
5822        Paragraph::new(vec![
5823            Line::from(ai_line),
5824            Line::from(hardware_line),
5825            footer_row,
5826        ])
5827        .block(
5828            Block::default()
5829                .borders(Borders::ALL)
5830                .border_style(Style::default().fg(Color::Rgb(60, 60, 60))),
5831        ),
5832        bar_chunks[0],
5833    );
5834
5835    // ── Box 4: Input ──────────────────────────────────────────────────────────
5836    let input_border_color = if app.agent_running {
5837        Color::Rgb(60, 60, 60)
5838    } else {
5839        Color::Rgb(100, 100, 100) // High-focus gray glow
5840    };
5841    let input_rect = chunks[1];
5842    let title_area = input_title_area(input_rect);
5843    let input_hint = render_input_title(app, title_area);
5844    let input_block = Block::default()
5845        .title(input_hint)
5846        .borders(Borders::ALL)
5847        .border_style(Style::default().fg(input_border_color))
5848        .style(Style::default().bg(Color::Rgb(25, 25, 25))); // Obsidian Dark Gray
5849
5850    let inner_area = input_block.inner(input_rect);
5851    f.render_widget(Clear, input_rect);
5852    f.render_widget(input_block, input_rect);
5853
5854    f.render_widget(
5855        Paragraph::new(app.input.as_str()).wrap(Wrap { trim: true }),
5856        inner_area,
5857    );
5858
5859    // Hardware Cursor (Managed by terminal emulator for smooth asynchronous blink)
5860    // Hardware Cursor (Managed by terminal emulator for smooth asynchronous blink)
5861    // Always call set_cursor during standard operation to "park" the cursor safely in the input box,
5862    // preventing it from jittering to (0,0) (the top-left title) during modal reviews.
5863    if !app.agent_running && inner_area.height > 0 {
5864        let text_w = app.input.len() as u16;
5865        let max_w = inner_area.width.saturating_sub(1);
5866        let cursor_x = inner_area.x + text_w.min(max_w);
5867        f.set_cursor(cursor_x, inner_area.y);
5868    }
5869
5870    // ── High-risk approval modal ───────────────────────────────────────────────
5871    if let Some(approval) = &app.awaiting_approval {
5872        let is_diff_preview = approval.diff.is_some();
5873
5874        // Taller modal for diff preview so more lines are visible.
5875        let modal_h = if is_diff_preview { 70 } else { 50 };
5876        let area = centered_rect(80, modal_h, f.size());
5877        f.render_widget(Clear, area);
5878
5879        let chunks = Layout::default()
5880            .direction(Direction::Vertical)
5881            .constraints([
5882                Constraint::Length(4), // Header: Title + Instructions
5883                Constraint::Min(0),    // Body: Tool + diff/command
5884            ])
5885            .split(area);
5886
5887        // ── Modal Header ─────────────────────────────────────────────────────
5888        let (title_str, title_color) = if let Some(_) = &approval.mutation_label {
5889            (" MUTATION REQUESTED — AUTHORISE THE WORKFLOW ", Color::Cyan)
5890        } else if is_diff_preview {
5891            (" DIFF PREVIEW — REVIEW BEFORE APPLYING ", Color::Yellow)
5892        } else {
5893            (" HIGH-RISK OPERATION REQUESTED ", Color::Red)
5894        };
5895        let header_text = vec![
5896            Line::from(Span::styled(
5897                title_str,
5898                Style::default()
5899                    .fg(title_color)
5900                    .add_modifier(Modifier::BOLD),
5901            )),
5902            if is_diff_preview {
5903                Line::from(Span::styled(
5904                    "  [↑↓/jk/PgUp/PgDn] Scroll   [Y] Apply   [N] Skip   [A] Accept All ",
5905                    Style::default()
5906                        .fg(Color::Green)
5907                        .add_modifier(Modifier::BOLD),
5908                ))
5909            } else {
5910                Line::from(vec![
5911                    Span::styled(
5912                        "  [Y] Approve  ",
5913                        Style::default()
5914                            .fg(Color::Green)
5915                            .add_modifier(Modifier::BOLD),
5916                    ),
5917                    Span::styled(
5918                        "  [N] Decline  ",
5919                        Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
5920                    ),
5921                    Span::styled(
5922                        "  [A] Accept All ",
5923                        Style::default()
5924                            .fg(Color::Magenta)
5925                            .add_modifier(Modifier::BOLD),
5926                    ),
5927                ])
5928            },
5929        ];
5930        f.render_widget(
5931            Paragraph::new(header_text)
5932                .block(
5933                    Block::default()
5934                        .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
5935                        .border_style(Style::default().fg(title_color)),
5936                )
5937                .alignment(ratatui::layout::Alignment::Center),
5938            chunks[0],
5939        );
5940
5941        // ── Modal Body ───────────────────────────────────────────────────────
5942        let border_color = if let Some(_) = &approval.mutation_label {
5943            Color::Cyan
5944        } else if is_diff_preview {
5945            Color::Yellow
5946        } else {
5947            Color::Red
5948        };
5949        if let Some(diff_text) = &approval.diff {
5950            // Render colored diff lines
5951            let added = diff_text.lines().filter(|l| l.starts_with("+ ")).count();
5952            let removed = diff_text.lines().filter(|l| l.starts_with("- ")).count();
5953            let mut body_lines: Vec<Line> = vec![
5954                Line::from(Span::styled(
5955                    if let Some(label) = &approval.mutation_label {
5956                        format!(" INTENT: {}", label)
5957                    } else {
5958                        format!(" {}", approval.display)
5959                    },
5960                    Style::default()
5961                        .fg(Color::Cyan)
5962                        .add_modifier(Modifier::BOLD),
5963                )),
5964                Line::from(vec![
5965                    Span::styled(
5966                        format!(" +{}", added),
5967                        Style::default()
5968                            .fg(Color::Green)
5969                            .add_modifier(Modifier::BOLD),
5970                    ),
5971                    Span::styled(
5972                        format!(" -{}", removed),
5973                        Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
5974                    ),
5975                ]),
5976                Line::from(Span::raw("")),
5977            ];
5978            for raw_line in diff_text.lines() {
5979                let styled = if raw_line.starts_with("+ ") {
5980                    Line::from(Span::styled(
5981                        format!(" {}", raw_line),
5982                        Style::default().fg(Color::Green),
5983                    ))
5984                } else if raw_line.starts_with("- ") {
5985                    Line::from(Span::styled(
5986                        format!(" {}", raw_line),
5987                        Style::default().fg(Color::Red),
5988                    ))
5989                } else if raw_line.starts_with("---") || raw_line.starts_with("@@ ") {
5990                    Line::from(Span::styled(
5991                        format!(" {}", raw_line),
5992                        Style::default()
5993                            .fg(Color::DarkGray)
5994                            .add_modifier(Modifier::BOLD),
5995                    ))
5996                } else {
5997                    Line::from(Span::raw(format!(" {}", raw_line)))
5998                };
5999                body_lines.push(styled);
6000            }
6001            f.render_widget(
6002                Paragraph::new(body_lines)
6003                    .block(
6004                        Block::default()
6005                            .borders(Borders::BOTTOM | Borders::LEFT | Borders::RIGHT)
6006                            .border_style(Style::default().fg(border_color)),
6007                    )
6008                    .scroll((approval.diff_scroll, 0)),
6009                chunks[1],
6010            );
6011        } else {
6012            let body_text = vec![
6013                Line::from(Span::raw("")),
6014                Line::from(Span::styled(
6015                    if let Some(label) = &approval.mutation_label {
6016                        format!(" INTENT: {}", label)
6017                    } else {
6018                        format!(" ACTION: {}", approval.display)
6019                    },
6020                    Style::default()
6021                        .fg(Color::Cyan)
6022                        .add_modifier(Modifier::BOLD),
6023                )),
6024                Line::from(Span::raw("")),
6025                Line::from(Span::styled(
6026                    format!("  Tool: {}", approval.tool_name),
6027                    Style::default().fg(Color::DarkGray),
6028                )),
6029            ];
6030            if approval.mutation_label.is_some() {
6031                // For mutations, show the original display (e.g. path) as extra info
6032            }
6033            f.render_widget(
6034                Paragraph::new(body_text)
6035                    .block(
6036                        Block::default()
6037                            .borders(Borders::BOTTOM | Borders::LEFT | Borders::RIGHT)
6038                            .border_style(Style::default().fg(border_color)),
6039                    )
6040                    .alignment(ratatui::layout::Alignment::Center),
6041                chunks[1],
6042            );
6043        }
6044    }
6045
6046    // ── Swarm diff review modal ────────────────────────────────────────────────
6047    if let Some(review) = &app.active_review {
6048        draw_diff_review(f, review);
6049    }
6050
6051    // ── Autocomplete Hatch (Floating Popup) ──────────────────────────────────
6052    if app.show_autocomplete && !app.autocomplete_suggestions.is_empty() {
6053        let area = Rect {
6054            x: chunks[1].x + 2,
6055            y: chunks[1]
6056                .y
6057                .saturating_sub(app.autocomplete_suggestions.len() as u16 + 2),
6058            width: chunks[1].width.saturating_sub(4),
6059            height: app.autocomplete_suggestions.len() as u16 + 2,
6060        };
6061        f.render_widget(Clear, area);
6062
6063        let items: Vec<ListItem> = app
6064            .autocomplete_suggestions
6065            .iter()
6066            .enumerate()
6067            .map(|(i, s)| {
6068                let style = if i == app.selected_suggestion {
6069                    Style::default()
6070                        .fg(Color::Black)
6071                        .bg(Color::Cyan)
6072                        .add_modifier(Modifier::BOLD)
6073                } else {
6074                    Style::default().fg(Color::Gray)
6075                };
6076                ListItem::new(format!(" 📄 {}", s)).style(style)
6077            })
6078            .collect();
6079
6080        let hatch = List::new(items).block(
6081            Block::default()
6082                .borders(Borders::ALL)
6083                .border_style(Style::default().fg(Color::Cyan))
6084                .title(format!(
6085                    " @ RESOLVER (Matching: {}) ",
6086                    app.autocomplete_filter
6087                )),
6088        );
6089        f.render_widget(hatch, area);
6090
6091        // Optional "More matches..." indicator
6092        if app.autocomplete_suggestions.len() >= 15 {
6093            let more_area = Rect {
6094                x: area.x + 2,
6095                y: area.y + area.height - 1,
6096                width: 20,
6097                height: 1,
6098            };
6099            f.render_widget(
6100                Paragraph::new("... (type to narrow) ").style(Style::default().fg(Color::DarkGray)),
6101                more_area,
6102            );
6103        }
6104    }
6105}
6106
6107// ── Helpers ───────────────────────────────────────────────────────────────────
6108
6109fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
6110    let vert = Layout::default()
6111        .direction(Direction::Vertical)
6112        .constraints([
6113            Constraint::Percentage((100 - percent_y) / 2),
6114            Constraint::Percentage(percent_y),
6115            Constraint::Percentage((100 - percent_y) / 2),
6116        ])
6117        .split(r);
6118    Layout::default()
6119        .direction(Direction::Horizontal)
6120        .constraints([
6121            Constraint::Percentage((100 - percent_x) / 2),
6122            Constraint::Percentage(percent_x),
6123            Constraint::Percentage((100 - percent_x) / 2),
6124        ])
6125        .split(vert[1])[1]
6126}
6127
6128fn strip_ghost_prefix(s: &str) -> &str {
6129    for prefix in &[
6130        "Hematite: ",
6131        "HEMATITE: ",
6132        "Assistant: ",
6133        "assistant: ",
6134        "Okay, ",
6135        "Hmm, ",
6136        "Wait, ",
6137        "Alright, ",
6138        "Got it, ",
6139        "Certainly, ",
6140        "Sure, ",
6141        "Understood, ",
6142    ] {
6143        if s.to_lowercase().starts_with(&prefix.to_lowercase()) {
6144            return &s[prefix.len()..];
6145        }
6146    }
6147    s
6148}
6149
6150fn first_n_chars(s: &str, n: usize) -> String {
6151    let mut result = String::new();
6152    let mut count = 0;
6153    for c in s.chars() {
6154        if count >= n {
6155            result.push('…');
6156            break;
6157        }
6158        if c == '\n' || c == '\r' {
6159            result.push(' ');
6160        } else if !c.is_control() {
6161            result.push(c);
6162        }
6163        count += 1;
6164    }
6165    result
6166}
6167
6168fn trim_vec_context(v: &mut Vec<ContextFile>, max: usize) {
6169    while v.len() > max {
6170        v.remove(0);
6171    }
6172}
6173
6174fn trim_vec(v: &mut Vec<String>, max: usize) {
6175    while v.len() > max {
6176        v.remove(0);
6177    }
6178}
6179
6180/// Minimal markdown → ratatui spans for the SPECULAR panel.
6181/// Handles: # headers, **bold**, `code`, - bullet, > blockquote, plain text.
6182fn render_markdown_line(raw: &str) -> Vec<Line<'static>> {
6183    // 1. Strip ANSI and control noise first to verify content.
6184    let cleaned_ansi = strip_ansi(raw);
6185    let trimmed = cleaned_ansi.trim();
6186    if trimmed.is_empty() {
6187        return vec![Line::raw("")];
6188    }
6189
6190    // 2. Strip thought tags.
6191    let cleaned_owned = trimmed
6192        .replace("<thought>", "")
6193        .replace("</thought>", "")
6194        .replace("<think>", "")
6195        .replace("</think>", "");
6196    let trimmed = cleaned_owned.trim();
6197    if trimmed.is_empty() {
6198        return vec![];
6199    }
6200
6201    // # Heading (all levels → bold white)
6202    for (prefix, indent) in &[("### ", "  "), ("## ", " "), ("# ", "")] {
6203        if let Some(rest) = trimmed.strip_prefix(prefix) {
6204            return vec![Line::from(vec![Span::styled(
6205                format!("{}{}", indent, rest),
6206                Style::default()
6207                    .fg(Color::White)
6208                    .add_modifier(Modifier::BOLD),
6209            )])];
6210        }
6211    }
6212
6213    // > blockquote
6214    if let Some(rest) = trimmed
6215        .strip_prefix("> ")
6216        .or_else(|| trimmed.strip_prefix(">"))
6217    {
6218        return vec![Line::from(vec![
6219            Span::styled("| ", Style::default().fg(Color::DarkGray)),
6220            Span::styled(
6221                rest.to_string(),
6222                Style::default()
6223                    .fg(Color::White)
6224                    .add_modifier(Modifier::DIM),
6225            ),
6226        ])];
6227    }
6228
6229    // - / * bullet
6230    if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
6231        let rest = &trimmed[2..];
6232        let mut spans = vec![Span::styled("* ", Style::default().fg(Color::Gray))];
6233        spans.extend(inline_markdown(rest));
6234        return vec![Line::from(spans)];
6235    }
6236
6237    // Plain line with possible inline markdown
6238    let spans = inline_markdown(trimmed);
6239    vec![Line::from(spans)]
6240}
6241
6242/// Inline markdown for The Core chat window (brighter palette than SPECULAR).
6243fn inline_markdown_core(text: &str) -> Vec<Span<'static>> {
6244    let mut spans = Vec::new();
6245    let mut remaining = text;
6246
6247    while !remaining.is_empty() {
6248        if let Some(start) = remaining.find("**") {
6249            let before = &remaining[..start];
6250            if !before.is_empty() {
6251                spans.push(Span::raw(before.to_string()));
6252            }
6253            let after_open = &remaining[start + 2..];
6254            if let Some(end) = after_open.find("**") {
6255                spans.push(Span::styled(
6256                    after_open[..end].to_string(),
6257                    Style::default()
6258                        .fg(Color::White)
6259                        .add_modifier(Modifier::BOLD),
6260                ));
6261                remaining = &after_open[end + 2..];
6262                continue;
6263            }
6264        }
6265        if let Some(start) = remaining.find('`') {
6266            let before = &remaining[..start];
6267            if !before.is_empty() {
6268                spans.push(Span::raw(before.to_string()));
6269            }
6270            let after_open = &remaining[start + 1..];
6271            if let Some(end) = after_open.find('`') {
6272                spans.push(Span::styled(
6273                    after_open[..end].to_string(),
6274                    Style::default().fg(Color::Yellow),
6275                ));
6276                remaining = &after_open[end + 1..];
6277                continue;
6278            }
6279        }
6280        spans.push(Span::raw(remaining.to_string()));
6281        break;
6282    }
6283    spans
6284}
6285
6286/// Parse inline `**bold**` and `` `code` `` — shared by SPECULAR and Core renderers.
6287fn inline_markdown(text: &str) -> Vec<Span<'static>> {
6288    let mut spans = Vec::new();
6289    let mut remaining = text;
6290
6291    while !remaining.is_empty() {
6292        if let Some(start) = remaining.find("**") {
6293            let before = &remaining[..start];
6294            if !before.is_empty() {
6295                spans.push(Span::raw(before.to_string()));
6296            }
6297            let after_open = &remaining[start + 2..];
6298            if let Some(end) = after_open.find("**") {
6299                spans.push(Span::styled(
6300                    after_open[..end].to_string(),
6301                    Style::default()
6302                        .fg(Color::White)
6303                        .add_modifier(Modifier::BOLD),
6304                ));
6305                remaining = &after_open[end + 2..];
6306                continue;
6307            }
6308        }
6309        if let Some(start) = remaining.find('`') {
6310            let before = &remaining[..start];
6311            if !before.is_empty() {
6312                spans.push(Span::raw(before.to_string()));
6313            }
6314            let after_open = &remaining[start + 1..];
6315            if let Some(end) = after_open.find('`') {
6316                spans.push(Span::styled(
6317                    after_open[..end].to_string(),
6318                    Style::default().fg(Color::Yellow),
6319                ));
6320                remaining = &after_open[end + 1..];
6321                continue;
6322            }
6323        }
6324        spans.push(Span::raw(remaining.to_string()));
6325        break;
6326    }
6327    spans
6328}
6329
6330// ── Splash Screen ─────────────────────────────────────────────────────────────
6331
6332fn make_starfield(width: u16, rows: u16, seed: u64, tick: u64) -> Vec<String> {
6333    let mut lines = Vec::with_capacity(rows as usize);
6334
6335    for y in 0..rows {
6336        let mut line = String::with_capacity(width as usize);
6337
6338        for x in 0..width {
6339            let n = (x as u64).wrapping_mul(73_856_093)
6340                ^ (y as u64).wrapping_mul(19_349_663)
6341                ^ seed
6342                ^ tick.wrapping_mul(83_492_791);
6343
6344            let ch = match n % 97 {
6345                0 => '*',
6346                1 | 2 => '.',
6347                3 => '+',
6348                _ => ' ',
6349            };
6350
6351            line.push(ch);
6352        }
6353
6354        lines.push(line);
6355    }
6356
6357    lines
6358}
6359
6360// ── Splash Screen ─────────────────────────────────────────────────────────────
6361
6362fn draw_splash<B: Backend>(terminal: &mut Terminal<B>) -> Result<(), Box<dyn std::error::Error>> {
6363    let logo_color = Color::Rgb(118, 118, 124);
6364    let star_color = Color::White;
6365    let sub_logo_color = Color::DarkGray;
6366    let tagline_color = Color::Gray;
6367    let author_color = Color::DarkGray;
6368
6369    let wide_logo = vec![
6370        "██╗  ██╗███████╗███╗   ███╗ █████╗ ████████╗██╗████████╗███████╗",
6371        "██║  ██║██╔════╝████╗ ████║██╔══██╗╚══██╔══╝██║╚══██╔══╝██╔════╝",
6372        "███████║█████╗  ██╔████╔██║███████║   ██║   ██║   ██║   █████╗  ",
6373        "██╔══██║██╔══╝  ██║╚██╔╝██║██╔══██║   ██║   ██║   ██║   ██╔══╝  ",
6374        "██║  ██║███████╗██║ ╚═╝ ██║██║  ██║   ██║   ██║   ██║   ███████╗",
6375        "╚═╝  ╚═╝╚══════╝╚═╝     ╚═╝╚═╝  ╚═╝   ╚═╝   ╚═╝   ╚═╝   ╚══════╝",
6376    ];
6377
6378    let version = env!("CARGO_PKG_VERSION");
6379
6380    terminal.draw(|f| {
6381        let area = f.size();
6382
6383        f.render_widget(
6384            Block::default().style(Style::default().bg(Color::Black)),
6385            area,
6386        );
6387
6388        let now = SystemTime::now()
6389            .duration_since(UNIX_EPOCH)
6390            .unwrap_or_default();
6391        let tick = (now.as_millis() / 350) as u64;
6392
6393        let top_stars = make_starfield(area.width, 3, 0xA11CE, tick);
6394        let bottom_stars = make_starfield(area.width, 2, 0xBADC0DE, tick + 17);
6395
6396        // total content:
6397        // top_stars(3)
6398        // logo(6)
6399        // sub_logo(1)
6400        // spacer(1)
6401        // version(1)
6402        // tagline(1)
6403        // author(1)
6404        // spacer(1)
6405        // bottom_stars(2)
6406        // spacer(1)
6407        // prompt(1)
6408        let content_height: u16 = 19;
6409        let top_pad = area.height.saturating_sub(content_height) / 2;
6410
6411        let mut lines: Vec<Line<'static>> = Vec::new();
6412
6413        for _ in 0..top_pad {
6414            lines.push(Line::raw(""));
6415        }
6416
6417        // Top starfield
6418        for line in top_stars {
6419            lines.push(Line::from(Span::styled(
6420                line,
6421                Style::default()
6422                    .fg(star_color)
6423                    .add_modifier(Modifier::BOLD)
6424                    .add_modifier(Modifier::DIM),
6425            )));
6426        }
6427
6428        // Main logo
6429        for line in &wide_logo {
6430            lines.push(Line::from(Span::styled(
6431                (*line).to_string(),
6432                Style::default().fg(logo_color).add_modifier(Modifier::BOLD),
6433            )));
6434        }
6435
6436        // Sub-logo
6437        lines.push(Line::from(Span::styled(
6438            "                                   -- cli --".to_string(),
6439            Style::default()
6440                .fg(sub_logo_color)
6441                .add_modifier(Modifier::DIM),
6442        )));
6443
6444        lines.push(Line::raw(""));
6445
6446        // Version
6447        lines.push(Line::from(Span::styled(
6448            format!("v{}", version),
6449            Style::default().fg(sub_logo_color),
6450        )));
6451
6452        // Tagline
6453        lines.push(Line::from(Span::styled(
6454            "Local AI coding harness + workstation assistant".to_string(),
6455            Style::default().fg(tagline_color),
6456        )));
6457
6458        // Author
6459        lines.push(Line::from(Span::styled(
6460            "developed by Ocean Bennett".to_string(),
6461            Style::default()
6462                .fg(author_color)
6463                .add_modifier(Modifier::DIM),
6464        )));
6465
6466        lines.push(Line::raw(""));
6467
6468        // Bottom starfield
6469        for line in bottom_stars {
6470            lines.push(Line::from(Span::styled(
6471                line,
6472                Style::default()
6473                    .fg(star_color)
6474                    .add_modifier(Modifier::BOLD)
6475                    .add_modifier(Modifier::DIM),
6476            )));
6477        }
6478
6479        lines.push(Line::raw(""));
6480
6481        // Prompt
6482        lines.push(Line::from(vec![
6483            Span::styled("[ ", Style::default().fg(logo_color)),
6484            Span::styled(
6485                "PRESS ENTER TO START",
6486                Style::default()
6487                    .fg(Color::White)
6488                    .add_modifier(Modifier::BOLD),
6489            ),
6490            Span::styled(" ]", Style::default().fg(logo_color)),
6491        ]));
6492
6493        let splash = Paragraph::new(lines).alignment(Alignment::Center);
6494        f.render_widget(splash, area);
6495    })?;
6496
6497    Ok(())
6498}
6499
6500fn normalize_id(id: &str) -> String {
6501    id.trim().to_uppercase()
6502}
6503
6504fn filter_tui_noise(text: &str) -> String {
6505    // 1. First Pass: Strip ANSI escape codes that cause "shattering" in layout.
6506    let cleaned = strip_ansi(text);
6507
6508    // 2. Second Pass: Filter heuristic noise.
6509    let mut lines = Vec::new();
6510    for line in cleaned.lines() {
6511        // Strip multi-line "LF replaced by CRLF" noise frequently emitted by git/shell on Windows.
6512        if CRLF_REGEX.is_match(line) {
6513            continue;
6514        }
6515        // Strip git checkout/file update noise if it's too repetitive.
6516        if line.contains("Updating files:") && line.contains("%") {
6517            continue;
6518        }
6519        // Strip random terminal control characters that might have escaped.
6520        let sanitized: String = line
6521            .chars()
6522            .filter(|c| !c.is_control() || *c == '\t')
6523            .collect();
6524        if sanitized.trim().is_empty() && !line.trim().is_empty() {
6525            continue;
6526        }
6527
6528        lines.push(normalize_tui_text(&sanitized));
6529    }
6530    lines.join("\n").trim().to_string()
6531}
6532
6533fn normalize_tui_text(text: &str) -> String {
6534    let mut normalized = text
6535        .replace("ΓÇö", "-")
6536        .replace("ΓÇô", "-")
6537        .replace("…", "...")
6538        .replace("✅", "[OK]")
6539        .replace("🛠️", "")
6540        .replace("—", "-")
6541        .replace("–", "-")
6542        .replace("…", "...")
6543        .replace("•", "*")
6544        .replace("✅", "[OK]")
6545        .replace("🚨", "[!]");
6546
6547    normalized = normalized
6548        .chars()
6549        .map(|c| match c {
6550            '\u{00A0}' => ' ',
6551            '\u{2018}' | '\u{2019}' => '\'',
6552            '\u{201C}' | '\u{201D}' => '"',
6553            c if c.is_ascii() || c == '\n' || c == '\t' => c,
6554            _ => ' ',
6555        })
6556        .collect();
6557
6558    let mut compacted = String::with_capacity(normalized.len());
6559    let mut prev_space = false;
6560    for ch in normalized.chars() {
6561        if ch == ' ' {
6562            if !prev_space {
6563                compacted.push(ch);
6564            }
6565            prev_space = true;
6566        } else {
6567            compacted.push(ch);
6568            prev_space = false;
6569        }
6570    }
6571
6572    compacted.trim().to_string()
6573}