Skip to main content

hematite/ui/
tui.rs

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