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
503pub struct PendingApproval {
508 pub display: String,
509 pub tool_name: String,
510 pub diff: Option<String>,
513 pub diff_scroll: u16,
515 pub mutation_label: Option<String>,
516 pub responder: tokio::sync::oneshot::Sender<bool>,
517}
518
519pub 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 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 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 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 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)>, 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 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 pub user_input_tx: tokio::sync::mpsc::Sender<UserTurn>,
786 pub specular_scroll: u16,
787 pub specular_auto_scroll: bool,
790 pub gpu_state: Arc<GpuState>,
792 pub git_state: Arc<crate::agent::git_monitor::GitState>,
794 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 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 pub think_mode: Option<bool>,
828 pub workflow_mode: String,
830 pub autocomplete_suggestions: Vec<String>,
832 pub selected_suggestion: usize,
834 pub show_autocomplete: bool,
836 pub autocomplete_filter: String,
838 pub current_objective: String,
840 pub voice_manager: Arc<crate::ui::voice::VoiceManager>,
842 pub voice_loading: bool,
843 pub voice_loading_progress: f64,
844 pub autocomplete_alias_active: bool,
846 pub hardware_guard_enabled: bool,
848 pub session_start: std::time::SystemTime,
850 pub soul_name: String,
852 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 pub nav_list: Vec<std::path::PathBuf>,
859 pub auto_approve_session: bool,
862 pub task_start_time: Option<std::time::Instant>,
864 pub tool_started_at: HashMap<String, std::time::Instant>,
866 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 } self.messages_raw.push((speaker.to_string(), filtered));
909 if self.messages_raw.len() > 500 {
911 self.messages_raw.remove(0);
912 }
913 self.rebuild_formatted_messages();
914 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 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 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 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 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 if let Some(slash_pos) = fragment.find('/') {
1364 final_query = &fragment[slash_pos + 1..];
1365 } else {
1366 final_query = ""; }
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 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) .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 } }
1435
1436 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 pub fn apply_autocomplete_selection(&mut self, selection: &str) {
1473 if let Some(pos) = self.input.rfind('@') {
1474 if self.autocomplete_alias_active {
1475 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 self.input.truncate(pos + 1);
1483 }
1484 } else {
1485 self.input.truncate(pos);
1487 }
1488 self.input.push_str(selection);
1489 self.show_autocomplete = false;
1490 }
1491 }
1492
1493 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 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 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 pub fn copy_specular_to_clipboard(&self) {
1538 let mut out = String::from("=== SPECULAR LOG ===\n\n");
1539
1540 if !self.last_reasoning.is_empty() {
1541 out.push_str("--- Last Reasoning Block ---\n");
1542 out.push_str(&self.last_reasoning);
1543 out.push_str("\n\n");
1544 }
1545
1546 if !self.current_thought.is_empty() {
1547 out.push_str("--- In-Progress Reasoning ---\n");
1548 out.push_str(&self.current_thought);
1549 out.push_str("\n\n");
1550 }
1551
1552 if !self.specular_logs.is_empty() {
1553 out.push_str("--- Specular Events ---\n");
1554 for entry in &self.specular_logs {
1555 out.push_str(entry);
1556 out.push('\n');
1557 }
1558 out.push('\n');
1559 }
1560
1561 let _ = writeln!(
1562 out,
1563 "Tokens: {} | Cost: ${:.4}",
1564 self.total_tokens, self.current_session_cost
1565 );
1566
1567 let clip = system32_exe("clip.exe");
1568 let mut child = std::process::Command::new(&clip)
1569 .stdin(std::process::Stdio::piped())
1570 .spawn()
1571 .expect("Failed to spawn clip.exe");
1572 if let Some(mut stdin) = child.stdin.take() {
1573 use std::io::Write;
1574 let _ = stdin.write_all(out.as_bytes());
1575 }
1576 let _ = child.wait();
1577 }
1578
1579 pub fn write_session_report(&self) {
1580 let report_dir = crate::tools::file_ops::hematite_dir().join("reports");
1581 if std::fs::create_dir_all(&report_dir).is_err() {
1582 return;
1583 }
1584
1585 let start_secs = self
1587 .session_start
1588 .duration_since(std::time::UNIX_EPOCH)
1589 .unwrap_or_default()
1590 .as_secs();
1591
1592 let secs_in_day = start_secs % 86400;
1594 let days = start_secs / 86400;
1595 let years_approx = (days * 4 + 2) / 1461;
1596 let year = 1970 + years_approx;
1597 let day_of_year = days - (years_approx * 365 + years_approx / 4);
1598 let month = (day_of_year / 30 + 1).min(12);
1599 let day = (day_of_year % 30 + 1).min(31);
1600 let hh = secs_in_day / 3600;
1601 let mm = (secs_in_day % 3600) / 60;
1602 let ss = secs_in_day % 60;
1603 let timestamp = format!(
1604 "{:04}-{:02}-{:02}_{:02}-{:02}-{:02}",
1605 year, month, day, hh, mm, ss
1606 );
1607
1608 let duration_secs = std::time::SystemTime::now()
1609 .duration_since(self.session_start)
1610 .unwrap_or_default()
1611 .as_secs();
1612
1613 let report_path = report_dir.join(format!("session_{}.json", timestamp));
1614
1615 let turns: Vec<serde_json::Value> = self
1616 .messages_raw
1617 .iter()
1618 .map(|(speaker, text)| serde_json::json!({ "speaker": speaker, "text": text }))
1619 .collect();
1620
1621 let report = serde_json::json!({
1622 "session_start": timestamp,
1623 "duration_secs": duration_secs,
1624 "model": self.model_id,
1625 "context_length": self.context_length,
1626 "total_tokens": self.total_tokens,
1627 "estimated_cost_usd": self.current_session_cost,
1628 "turn_count": turns.len(),
1629 "transcript": turns,
1630 });
1631
1632 if let Ok(json) = serde_json::to_string_pretty(&report) {
1633 let _ = std::fs::write(&report_path, json);
1634 }
1635 }
1636
1637 fn transcript_snapshot_for_copy(&self) -> (Vec<(String, String)>, bool) {
1638 if !self.agent_running {
1639 return (self.messages_raw.clone(), false);
1640 }
1641
1642 if let Some(last_user_idx) = self
1643 .messages_raw
1644 .iter()
1645 .rposition(|(speaker, _)| speaker == "You")
1646 {
1647 (
1648 self.messages_raw[..=last_user_idx].to_vec(),
1649 last_user_idx + 1 < self.messages_raw.len(),
1650 )
1651 } else {
1652 (Vec::new(), !self.messages_raw.is_empty())
1653 }
1654 }
1655
1656 pub fn copy_transcript_to_clipboard(&self) {
1657 let (snapshot, omitted_inflight) = self.transcript_snapshot_for_copy();
1658 let mut history = snapshot
1659 .iter()
1660 .filter(|(speaker, content)| !should_skip_transcript_copy_entry(speaker, content))
1661 .map(|m| format!("[{}] {}\n", m.0, m.1))
1662 .collect::<String>();
1663
1664 if omitted_inflight {
1665 history.push_str(
1666 "[System] Current turn is still in progress; in-flight Hematite output was omitted from this clipboard snapshot.\n",
1667 );
1668 }
1669
1670 history.push_str("\nSession Stats\n");
1671 let _ = writeln!(history, "Tokens: {}", self.total_tokens);
1672 let _ = writeln!(history, "Cost: ${:.4}", self.current_session_cost);
1673
1674 copy_text_to_clipboard(&history);
1675 }
1676
1677 pub fn copy_clean_transcript_to_clipboard(&self) {
1678 let (snapshot, omitted_inflight) = self.transcript_snapshot_for_copy();
1679 let mut history = snapshot
1680 .iter()
1681 .filter(|(speaker, content)| !should_skip_transcript_copy_entry(speaker, content))
1682 .map(|m| format!("[{}] {}\n", m.0, m.1))
1683 .collect::<String>();
1684
1685 if omitted_inflight {
1686 history.push_str(
1687 "[System] Current turn is still in progress; in-flight Hematite output was omitted from this clipboard snapshot.\n",
1688 );
1689 }
1690
1691 history.push_str("\nSession Stats\n");
1692 let _ = writeln!(history, "Tokens: {}", self.total_tokens);
1693 let _ = writeln!(history, "Cost: ${:.4}", self.current_session_cost);
1694
1695 copy_text_to_clipboard(&history);
1696 }
1697
1698 pub fn copy_last_reply_to_clipboard(&self) -> bool {
1699 if let Some((speaker, content)) = self
1700 .messages_raw
1701 .iter()
1702 .rev()
1703 .find(|(speaker, content)| is_copyable_hematite_reply(speaker, content))
1704 {
1705 let cleaned = cleaned_copyable_reply_text(content);
1706 let payload = format!("[{}] {}", speaker, cleaned);
1707 copy_text_to_clipboard(&payload);
1708 true
1709 } else {
1710 false
1711 }
1712 }
1713}
1714
1715fn should_accept_autocomplete_on_enter(alias_active: bool, filter: &str) -> bool {
1716 if alias_active && filter.trim().is_empty() {
1717 return false;
1718 }
1719 true
1720}
1721
1722fn system32_exe(name: &str) -> String {
1725 let root = std::env::var("SystemRoot").unwrap_or_else(|_| "C:\\Windows".to_string());
1726 format!("{root}\\System32\\{name}")
1727}
1728
1729fn copy_text_to_clipboard(text: &str) {
1730 if copy_text_to_clipboard_powershell(text) {
1731 return;
1732 }
1733
1734 let clip = system32_exe("clip.exe");
1737 let mut child = std::process::Command::new(&clip)
1738 .stdin(std::process::Stdio::piped())
1739 .spawn()
1740 .expect("Failed to spawn clip.exe");
1741
1742 if let Some(mut stdin) = child.stdin.take() {
1743 use std::io::Write;
1744 let _ = stdin.write_all(text.as_bytes());
1745 }
1746 let _ = child.wait();
1747}
1748
1749fn synced_task_start_time(
1750 active: bool,
1751 current: Option<std::time::Instant>,
1752) -> Option<std::time::Instant> {
1753 match (active, current) {
1754 (true, None) => Some(std::time::Instant::now()),
1755 (false, Some(_)) => None,
1756 (_, existing) => existing,
1757 }
1758}
1759
1760fn scroll_specular_up(app: &mut App, amount: u16) {
1761 app.specular_auto_scroll = false;
1762 app.specular_scroll = app.specular_scroll.saturating_sub(amount);
1763}
1764
1765fn scroll_specular_down(app: &mut App, amount: u16) {
1766 app.specular_auto_scroll = false;
1767 app.specular_scroll = app.specular_scroll.saturating_add(amount);
1768}
1769
1770fn follow_live_specular(app: &mut App) {
1771 app.specular_auto_scroll = true;
1772 app.specular_scroll = 0;
1773}
1774
1775fn format_tool_elapsed(elapsed: std::time::Duration) -> String {
1776 if elapsed.as_millis() < 1_000 {
1777 format!("{}ms", elapsed.as_millis())
1778 } else {
1779 format!("{:.1}s", elapsed.as_secs_f64())
1780 }
1781}
1782
1783fn extract_tool_elapsed_chip(summary: &str) -> (String, Option<String>) {
1784 let trimmed = summary.trim();
1785 if let Some((head, tail)) = trimmed.rsplit_once(" [") {
1786 if let Some(elapsed) = tail.strip_suffix(']') {
1787 if !elapsed.is_empty()
1788 && elapsed
1789 .chars()
1790 .all(|ch| ch.is_ascii_digit() || ch == '.' || ch == 'm' || ch == 's')
1791 {
1792 return (head.trim().to_string(), Some(elapsed.to_string()));
1793 }
1794 }
1795 }
1796 (trimmed.to_string(), None)
1797}
1798
1799fn should_capture_grounded_tool_output(name: &str, is_error: bool) -> bool {
1800 !is_error && matches!(name, "research_web" | "fetch_docs")
1801}
1802
1803fn looks_like_markup_payload(result: &str) -> bool {
1804 let lower = result
1805 .chars()
1806 .take(256)
1807 .collect::<String>()
1808 .to_ascii_lowercase();
1809 lower.contains("<!doctype")
1810 || lower.contains("<html")
1811 || lower.contains("<body")
1812 || lower.contains("<meta ")
1813}
1814
1815fn build_runtime_fix_grounded_fallback(results: &[(String, String)]) -> Option<String> {
1816 if results.is_empty() {
1817 return None;
1818 }
1819
1820 let mut sections = Vec::with_capacity(results.len());
1821
1822 for (name, result) in results.iter().filter(|(name, _)| name == "research_web") {
1823 sections.push(format!(
1824 "[{}]\n{}",
1825 name,
1826 first_n_chars(result, 1800).trim()
1827 ));
1828 }
1829
1830 if sections.is_empty() {
1831 for (name, result) in results
1832 .iter()
1833 .filter(|(name, result)| name == "fetch_docs" && !looks_like_markup_payload(result))
1834 {
1835 sections.push(format!(
1836 "[{}]\n{}",
1837 name,
1838 first_n_chars(result, 1600).trim()
1839 ));
1840 }
1841 }
1842
1843 if sections.is_empty() {
1844 if let Some((name, result)) = results.last() {
1845 sections.push(format!(
1846 "[{}]\n{}",
1847 name,
1848 first_n_chars(result, 1200).trim()
1849 ));
1850 }
1851 }
1852
1853 if sections.is_empty() {
1854 None
1855 } else {
1856 Some(format!(
1857 "The model returned empty content after grounded tool work. Hematite is surfacing the latest verified tool output directly.\n\n{}",
1858 sections.join("\n\n")
1859 ))
1860 }
1861}
1862
1863#[cfg(test)]
1864mod tests {
1865 use super::{
1866 build_runtime_fix_grounded_fallback, classify_runtime_issue, extract_tool_elapsed_chip,
1867 format_tool_elapsed, make_animated_sparkline_gauge, provider_badge_prefix,
1868 select_fitting_variant, select_sidebar_mode, should_accept_autocomplete_on_enter,
1869 synced_task_start_time, RuntimeIssueKind, SidebarMode,
1870 };
1871 use crate::agent::inference::ProviderRuntimeState;
1872
1873 #[test]
1874 fn tool_elapsed_chip_extracts_cleanly_from_summary() {
1875 assert_eq!(
1876 extract_tool_elapsed_chip("research_web [842ms]"),
1877 ("research_web".to_string(), Some("842ms".to_string()))
1878 );
1879 assert_eq!(
1880 extract_tool_elapsed_chip("read_file"),
1881 ("read_file".to_string(), None)
1882 );
1883 }
1884
1885 #[test]
1886 fn tool_elapsed_formats_compact_runtime_durations() {
1887 assert_eq!(
1888 format_tool_elapsed(std::time::Duration::from_millis(842)),
1889 "842ms"
1890 );
1891 assert_eq!(
1892 format_tool_elapsed(std::time::Duration::from_millis(1520)),
1893 "1.5s"
1894 );
1895 }
1896
1897 #[test]
1898 fn enter_submits_bare_alias_root_instead_of_selecting_first_child() {
1899 assert!(!should_accept_autocomplete_on_enter(true, ""));
1900 assert!(!should_accept_autocomplete_on_enter(true, " "));
1901 }
1902
1903 #[test]
1904 fn enter_still_accepts_narrowed_alias_matches() {
1905 assert!(should_accept_autocomplete_on_enter(true, "web"));
1906 assert!(should_accept_autocomplete_on_enter(false, ""));
1907 }
1908
1909 #[test]
1910 fn provider_badge_prefix_tracks_runtime_provider() {
1911 assert_eq!(provider_badge_prefix("LM Studio"), "LM");
1912 assert_eq!(provider_badge_prefix("Ollama"), "OL");
1913 assert_eq!(provider_badge_prefix("Other"), "AI");
1914 }
1915
1916 #[test]
1917 fn runtime_issue_prefers_no_model_over_live_state() {
1918 assert_eq!(
1919 classify_runtime_issue(ProviderRuntimeState::Live, "no model loaded", 32000, ""),
1920 RuntimeIssueKind::NoModel
1921 );
1922 }
1923
1924 #[test]
1925 fn runtime_issue_distinguishes_context_ceiling() {
1926 assert_eq!(
1927 classify_runtime_issue(
1928 ProviderRuntimeState::ContextWindow,
1929 "qwen/qwen3.5-9b",
1930 32000,
1931 "LM context ceiling hit."
1932 ),
1933 RuntimeIssueKind::ContextCeiling
1934 );
1935 }
1936
1937 #[test]
1938 fn runtime_issue_maps_generic_degraded_state_to_connectivity_signal() {
1939 assert_eq!(
1940 classify_runtime_issue(
1941 ProviderRuntimeState::Degraded,
1942 "qwen/qwen3.5-9b",
1943 32000,
1944 "LM Studio degraded and did not recover cleanly; operator action is now required."
1945 ),
1946 RuntimeIssueKind::Connectivity
1947 );
1948 }
1949
1950 #[test]
1951 fn sidebar_mode_hides_in_brief_or_narrow_layouts() {
1952 assert_eq!(select_sidebar_mode(99, false, true), SidebarMode::Hidden);
1953 assert_eq!(select_sidebar_mode(160, true, true), SidebarMode::Hidden);
1954 }
1955
1956 #[test]
1957 fn sidebar_mode_only_uses_full_chrome_for_live_wide_sessions() {
1958 assert_eq!(select_sidebar_mode(130, false, false), SidebarMode::Compact);
1959 assert_eq!(select_sidebar_mode(130, false, true), SidebarMode::Compact);
1960 assert_eq!(select_sidebar_mode(160, false, true), SidebarMode::Full);
1961 }
1962
1963 #[test]
1964 fn task_timer_starts_when_activity_begins() {
1965 assert!(synced_task_start_time(true, None).is_some());
1966 }
1967
1968 #[test]
1969 fn task_timer_clears_when_activity_ends() {
1970 assert!(synced_task_start_time(false, Some(std::time::Instant::now())).is_none());
1971 }
1972
1973 #[test]
1974 fn fitting_variant_picks_longest_string_that_fits() {
1975 let variants = vec![
1976 "this variant is too wide".to_string(),
1977 "fits nicely".to_string(),
1978 "tiny".to_string(),
1979 ];
1980 assert_eq!(select_fitting_variant(&variants, 12), "fits nicely");
1981 assert_eq!(select_fitting_variant(&variants, 4), "tiny");
1982 }
1983
1984 #[test]
1985 fn animated_gauge_preserves_requested_width() {
1986 let gauge = make_animated_sparkline_gauge(0.42, 12, 7);
1987 assert_eq!(gauge.chars().count(), 12);
1988 assert!(gauge.contains('█') || gauge.contains('▓') || gauge.contains('▒'));
1989 }
1990 #[test]
1991 fn runtime_fix_grounded_fallback_prefers_search_results_over_html_fetch() {
1992 let fallback = build_runtime_fix_grounded_fallback(&[
1993 (
1994 "fetch_docs".to_string(),
1995 "<!doctype html><html><body>raw page shell</body></html>".to_string(),
1996 ),
1997 (
1998 "research_web".to_string(),
1999 "Search results for: uefn toolbelt\n1. GitHub repo\n2. Epic forum thread"
2000 .to_string(),
2001 ),
2002 ])
2003 .expect("fallback");
2004
2005 assert!(fallback.contains("Search results for: uefn toolbelt"));
2006 assert!(!fallback.contains("<!doctype html>"));
2007 }
2008
2009 #[test]
2010 fn runtime_fix_grounded_fallback_returns_none_without_grounded_results() {
2011 assert!(build_runtime_fix_grounded_fallback(&[]).is_none());
2012 }
2013}
2014
2015#[cfg(windows)]
2018fn get_console_pixel_rect() -> Option<(i32, i32, i32, i32)> {
2019 let script = concat!(
2020 "Add-Type -TypeDefinition '",
2021 "using System;using System.Runtime.InteropServices;",
2022 "public class WG{",
2023 "[DllImport(\"kernel32\")]public static extern IntPtr GetConsoleWindow();",
2024 "[DllImport(\"user32\")]public static extern bool GetWindowRect(IntPtr h,out RECT r);",
2025 "[StructLayout(LayoutKind.Sequential)]public struct RECT{public int L,T,R,B;}}",
2026 "';",
2027 "$h=[WG]::GetConsoleWindow();$r=New-Object WG+RECT;",
2028 "[WG]::GetWindowRect($h,[ref]$r)|Out-Null;",
2029 "Write-Output \"$($r.L) $($r.T) $($r.R-$r.L) $($r.B-$r.T)\""
2030 );
2031 let out = std::process::Command::new("powershell.exe")
2032 .args(["-NoProfile", "-NonInteractive", "-Command", script])
2033 .output()
2034 .ok()?;
2035 let s = String::from_utf8_lossy(&out.stdout);
2036 let parts: Vec<i32> = s
2037 .split_whitespace()
2038 .filter_map(|v| v.trim().parse().ok())
2039 .collect();
2040 if parts.len() >= 4 {
2041 Some((parts[0], parts[1], parts[2], parts[3]))
2042 } else {
2043 None
2044 }
2045}
2046
2047#[cfg(windows)]
2051fn get_console_close_target_pid_sync() -> Option<u32> {
2052 let pid = std::process::id();
2053 let script = format!(
2054 r#"
2055$current = [uint32]{pid}
2056$seen = New-Object 'System.Collections.Generic.HashSet[uint32]'
2057$shell_pattern = '^(cmd|powershell|pwsh|bash|sh|wsl|ubuntu|debian|kali|arch)$'
2058$skip_pattern = '^(WindowsTerminal|wt|OpenConsole|conhost)$'
2059$fallback = $null
2060$found = $false
2061while ($current -gt 0 -and $seen.Add($current)) {{
2062 $proc = Get-CimInstance Win32_Process -Filter "ProcessId=$current" -ErrorAction SilentlyContinue
2063 if (-not $proc) {{ break }}
2064 $parent = [uint32]$proc.ParentProcessId
2065 if ($parent -le 0) {{ break }}
2066 $parent_proc = Get-Process -Id $parent -ErrorAction SilentlyContinue
2067 if ($parent_proc) {{
2068 $name = $parent_proc.ProcessName
2069 if ($name -match $shell_pattern) {{
2070 $found = $true
2071 Write-Output $parent
2072 break
2073 }}
2074 if (-not $fallback -and $name -notmatch $skip_pattern) {{
2075 $fallback = $parent
2076 }}
2077 }}
2078 $current = $parent
2079}}
2080if (-not $found -and $fallback) {{ Write-Output $fallback }}
2081"#
2082 );
2083 let out = std::process::Command::new("powershell.exe")
2084 .args(["-NoProfile", "-NonInteractive", "-Command", &script])
2085 .output()
2086 .ok()?;
2087 String::from_utf8_lossy(&out.stdout).trim().parse().ok()
2088}
2089
2090#[cfg(windows)]
2097fn spawn_dive_in_terminal(path: &str) {
2098 let pid = std::process::id();
2099 let current_dir = std::env::current_dir()
2100 .map(|p| p.to_string_lossy().to_string())
2101 .unwrap_or_default();
2102
2103 let close_target_pid = get_console_close_target_pid_sync().unwrap_or(0);
2104 let (px, py, pw, ph) = get_console_pixel_rect().unwrap_or((50, 50, 1100, 750));
2105
2106 let bat_path = std::env::temp_dir().join("hematite_teleport.bat");
2107 let bat_content = format!(
2108 "@echo off\r\ncd /d \"{p}\"\r\nhematite --no-splash --teleported-from \"{o}\"\r\n",
2109 p = path.replace('"', ""),
2110 o = current_dir.replace('"', ""),
2111 );
2112 if std::fs::write(&bat_path, bat_content).is_err() {
2113 return;
2114 }
2115 let bat_str = bat_path.to_string_lossy().to_string();
2116 let bat_ps = bat_str.replace('\'', "''");
2117
2118 let script = format!(
2119 r#"
2120Add-Type -TypeDefinition @'
2121using System; using System.Runtime.InteropServices;
2122public class WM {{ [DllImport("user32")] public static extern bool MoveWindow(IntPtr h,int x,int y,int w,int ht,bool b); }}
2123'@
2124$proc = Start-Process cmd.exe -ArgumentList @('/k', '"{bat}"') -PassThru
2125$deadline = (Get-Date).AddSeconds(8)
2126while ((Get-Date) -lt $deadline -and $proc.MainWindowHandle -eq [IntPtr]::Zero) {{ Start-Sleep -Milliseconds 100 }}
2127if ($proc.MainWindowHandle -ne [IntPtr]::Zero) {{
2128 [WM]::MoveWindow($proc.MainWindowHandle, {px}, {py}, {pw}, {ph}, $true) | Out-Null
2129}}
2130Wait-Process -Id {pid} -ErrorAction SilentlyContinue
2131if ({close_pid} -gt 0) {{
2132 Stop-Process -Id {close_pid} -Force -ErrorAction SilentlyContinue
2133}}
2134"#,
2135 bat = bat_ps,
2136 px = px,
2137 py = py,
2138 pw = pw,
2139 ph = ph,
2140 pid = pid,
2141 close_pid = close_target_pid,
2142 );
2143
2144 let _ = std::process::Command::new("powershell.exe")
2145 .args([
2146 "-NoProfile",
2147 "-NonInteractive",
2148 "-WindowStyle",
2149 "Hidden",
2150 "-Command",
2151 &script,
2152 ])
2153 .spawn();
2154}
2155
2156#[cfg(not(windows))]
2157fn spawn_dive_in_terminal(_path: &str) {}
2158
2159fn copy_text_to_clipboard_powershell(text: &str) -> bool {
2160 let temp_path = std::env::temp_dir().join(format!(
2161 "hematite-clipboard-{}-{}.txt",
2162 std::process::id(),
2163 std::time::SystemTime::now()
2164 .duration_since(std::time::UNIX_EPOCH)
2165 .map(|d| d.as_millis())
2166 .unwrap_or_default()
2167 ));
2168
2169 if std::fs::write(&temp_path, text.as_bytes()).is_err() {
2170 return false;
2171 }
2172
2173 let escaped_path = temp_path.display().to_string().replace('\'', "''");
2174 let script = format!(
2175 "$t = Get-Content -LiteralPath '{}' -Raw -Encoding UTF8; Set-Clipboard -Value $t",
2176 escaped_path
2177 );
2178
2179 let status = std::process::Command::new("powershell.exe")
2180 .args(["-NoProfile", "-NonInteractive", "-Command", &script])
2181 .status();
2182
2183 let _ = std::fs::remove_file(&temp_path);
2184
2185 matches!(status, Ok(code) if code.success())
2186}
2187
2188fn is_immediate_local_command(input: &str) -> bool {
2189 matches!(
2190 input.trim().to_ascii_lowercase().as_str(),
2191 "/copy" | "/copy-last" | "/copy-clean" | "/copy2"
2192 )
2193}
2194
2195fn should_skip_transcript_copy_entry(speaker: &str, content: &str) -> bool {
2196 if speaker != "System" {
2197 return false;
2198 }
2199
2200 content.starts_with("Hematite Commands:\n")
2201 || content.starts_with("Document note: `/attach`")
2202 || content == "Chat transcript copied to clipboard."
2203 || content == "Exact session transcript copied to clipboard (includes help/system output)."
2204 || content == "Clean chat transcript copied to clipboard (skips help/debug boilerplate)."
2205 || content == "Latest Hematite reply copied to clipboard."
2206 || content == "SPECULAR log copied to clipboard (reasoning + events)."
2207 || content == "Cancellation requested. Logs copied to clipboard."
2208}
2209
2210fn is_copyable_hematite_reply(speaker: &str, content: &str) -> bool {
2211 if speaker != "Hematite" {
2212 return false;
2213 }
2214
2215 let trimmed = content.trim();
2216 if trimmed.is_empty() {
2217 return false;
2218 }
2219
2220 if trimmed == "Initialising Engine & Hardware..."
2221 || trimmed == "Swarm engaged."
2222 || trimmed.starts_with("Hematite v")
2223 || trimmed.starts_with("Swarm analyzing: '")
2224 || trimmed.ends_with("Standing by for review...")
2225 || trimmed.ends_with("conflict - review required.")
2226 || trimmed.ends_with("conflict — review required.")
2227 {
2228 return false;
2229 }
2230
2231 true
2232}
2233
2234fn cleaned_copyable_reply_text(content: &str) -> String {
2235 let cleaned = content
2236 .replace("<thought>", "")
2237 .replace("</thought>", "")
2238 .replace("<think>", "")
2239 .replace("</think>", "");
2240 strip_ghost_prefix(cleaned.trim()).trim().to_string()
2241}
2242
2243#[derive(Clone, Copy, PartialEq, Eq)]
2246enum InputAction {
2247 Stop,
2248 PickDocument,
2249 PickImage,
2250 Detach,
2251 New,
2252 Forget,
2253 Help,
2254}
2255
2256#[derive(Clone)]
2257struct InputActionVisual {
2258 action: InputAction,
2259 label: String,
2260 style: Style,
2261}
2262
2263#[derive(Clone, Copy)]
2264enum AttachmentPickerKind {
2265 Document,
2266 Image,
2267}
2268
2269fn attach_document_from_path(app: &mut App, file_path: &str) {
2270 let p = std::path::Path::new(file_path);
2271 match crate::memory::vein::extract_document_text(p) {
2272 Ok(text) => {
2273 let name = p
2274 .file_name()
2275 .and_then(|n| n.to_str())
2276 .unwrap_or(file_path)
2277 .to_string();
2278 let preview_len = text.len().min(200);
2279 let estimated_tokens = text.len() / 4;
2281 let ctx = app.context_length.max(1);
2282 let budget_pct = (estimated_tokens * 100) / ctx;
2283 let budget_note = if budget_pct >= 75 {
2284 format!(
2285 "\nWarning: this document is ~{} tokens (~{}% of your {}k context). \
2286 Very little room left for conversation. Consider /attach on a shorter excerpt.",
2287 estimated_tokens, budget_pct, ctx / 1000
2288 )
2289 } else if budget_pct >= 40 {
2290 format!(
2291 "\nNote: this document is ~{} tokens (~{}% of your {}k context).",
2292 estimated_tokens,
2293 budget_pct,
2294 ctx / 1000
2295 )
2296 } else {
2297 String::new()
2298 };
2299 app.push_message(
2300 "System",
2301 &format!(
2302 "Attached document: {} ({} chars) for the next message.\nPreview: {}...{}",
2303 name,
2304 text.len(),
2305 &text[..preview_len],
2306 budget_note,
2307 ),
2308 );
2309 app.attached_context = Some((name, text));
2310 }
2311 Err(e) => {
2312 app.push_message("System", &format!("Attach failed: {}", e));
2313 }
2314 }
2315}
2316
2317fn attach_image_from_path(app: &mut App, file_path: &str) {
2318 let p = std::path::Path::new(file_path);
2319 match crate::tools::vision::encode_image_as_data_url(p) {
2320 Ok(_) => {
2321 let name = p
2322 .file_name()
2323 .and_then(|n| n.to_str())
2324 .unwrap_or(file_path)
2325 .to_string();
2326 app.push_message(
2327 "System",
2328 &format!("Attached image: {} for the next message.", name),
2329 );
2330 app.attached_image = Some(AttachedImage {
2331 name,
2332 path: file_path.to_string(),
2333 });
2334 }
2335 Err(e) => {
2336 app.push_message("System", &format!("Image attach failed: {}", e));
2337 }
2338 }
2339}
2340
2341fn is_document_path(path: &std::path::Path) -> bool {
2342 matches!(
2343 path.extension()
2344 .and_then(|e| e.to_str())
2345 .unwrap_or("")
2346 .to_ascii_lowercase()
2347 .as_str(),
2348 "pdf" | "md" | "markdown" | "txt" | "rst"
2349 )
2350}
2351
2352fn is_image_path(path: &std::path::Path) -> bool {
2353 matches!(
2354 path.extension()
2355 .and_then(|e| e.to_str())
2356 .unwrap_or("")
2357 .to_ascii_lowercase()
2358 .as_str(),
2359 "png" | "jpg" | "jpeg" | "gif" | "webp"
2360 )
2361}
2362
2363fn extract_pasted_path_candidates(content: &str) -> Vec<String> {
2364 let mut out = Vec::new();
2365 let trimmed = content.trim();
2366 if trimmed.is_empty() {
2367 return out;
2368 }
2369
2370 let mut in_quotes = false;
2371 let mut current = String::new();
2372 for ch in trimmed.chars() {
2373 if ch == '"' {
2374 if in_quotes && !current.trim().is_empty() {
2375 out.push(current.trim().to_string());
2376 current.clear();
2377 }
2378 in_quotes = !in_quotes;
2379 continue;
2380 }
2381 if in_quotes {
2382 current.push(ch);
2383 }
2384 }
2385 if !out.is_empty() {
2386 return out;
2387 }
2388
2389 for line in trimmed.lines() {
2390 let candidate = line.trim().trim_matches('"').trim();
2391 if !candidate.is_empty() {
2392 out.push(candidate.to_string());
2393 }
2394 }
2395
2396 if out.is_empty() {
2397 out.push(trimmed.trim_matches('"').to_string());
2398 }
2399 out
2400}
2401
2402fn try_attach_from_paste(app: &mut App, content: &str) -> bool {
2403 let mut attached_doc = false;
2404 let mut attached_image = false;
2405 let mut ignored_supported = 0usize;
2406
2407 for raw in extract_pasted_path_candidates(content) {
2408 let path = std::path::Path::new(&raw);
2409 if !path.exists() {
2410 continue;
2411 }
2412 if is_image_path(path) {
2413 if attached_image || app.attached_image.is_some() {
2414 ignored_supported += 1;
2415 } else {
2416 attach_image_from_path(app, &raw);
2417 attached_image = true;
2418 }
2419 } else if is_document_path(path) {
2420 if attached_doc || app.attached_context.is_some() {
2421 ignored_supported += 1;
2422 } else {
2423 attach_document_from_path(app, &raw);
2424 attached_doc = true;
2425 }
2426 }
2427 }
2428
2429 if ignored_supported > 0 {
2430 app.push_message(
2431 "System",
2432 &format!(
2433 "Ignored {} extra dropped file(s). Hematite currently keeps one pending document and one pending image.",
2434 ignored_supported
2435 ),
2436 );
2437 }
2438
2439 attached_doc || attached_image
2440}
2441
2442fn compute_input_height(total_width: u16, input_len: usize) -> u16 {
2443 let width = total_width.max(1) as usize;
2444 let approx_input_w = (width * 65 / 100).saturating_sub(4).max(1);
2445 let needed_lines = (input_len / approx_input_w) as u16 + 3;
2446 needed_lines.clamp(3, 10)
2447}
2448
2449fn input_rect_for_size(size: Rect, input_len: usize) -> Rect {
2450 let input_height = compute_input_height(size.width, input_len);
2451 Layout::default()
2452 .direction(Direction::Vertical)
2453 .constraints([
2454 Constraint::Min(0),
2455 Constraint::Length(input_height),
2456 Constraint::Length(5), ])
2458 .split(size)[1]
2459}
2460
2461fn input_title_area(input_rect: Rect) -> Rect {
2462 Rect {
2463 x: input_rect.x.saturating_add(1),
2464 y: input_rect.y,
2465 width: input_rect.width.saturating_sub(2),
2466 height: 1,
2467 }
2468}
2469
2470fn build_input_actions(app: &App) -> Vec<InputActionVisual> {
2471 let doc_label = if app.attached_context.is_some() {
2472 "Files*"
2473 } else {
2474 "Files"
2475 };
2476 let image_label = if app.attached_image.is_some() {
2477 "Image*"
2478 } else {
2479 "Image"
2480 };
2481 let detach_style = if app.attached_context.is_some() || app.attached_image.is_some() {
2482 Style::default()
2483 .fg(Color::Yellow)
2484 .add_modifier(Modifier::BOLD)
2485 } else {
2486 Style::default().fg(Color::DarkGray)
2487 };
2488
2489 let mut actions = Vec::with_capacity(6);
2490 if app.agent_running {
2491 actions.push(InputActionVisual {
2492 action: InputAction::Stop,
2493 label: "Stop Esc".to_string(),
2494 style: Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
2495 });
2496 } else {
2497 actions.push(InputActionVisual {
2498 action: InputAction::New,
2499 label: "New".to_string(),
2500 style: Style::default()
2501 .fg(Color::Green)
2502 .add_modifier(Modifier::BOLD),
2503 });
2504 actions.push(InputActionVisual {
2505 action: InputAction::Forget,
2506 label: "Forget".to_string(),
2507 style: Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
2508 });
2509 }
2510
2511 actions.push(InputActionVisual {
2512 action: InputAction::PickDocument,
2513 label: format!("{} ^O", doc_label),
2514 style: Style::default()
2515 .fg(Color::Cyan)
2516 .add_modifier(Modifier::BOLD),
2517 });
2518 actions.push(InputActionVisual {
2519 action: InputAction::PickImage,
2520 label: format!("{} ^I", image_label),
2521 style: Style::default()
2522 .fg(Color::Magenta)
2523 .add_modifier(Modifier::BOLD),
2524 });
2525 actions.push(InputActionVisual {
2526 action: InputAction::Detach,
2527 label: "Detach".to_string(),
2528 style: detach_style,
2529 });
2530 actions.push(InputActionVisual {
2531 action: InputAction::Help,
2532 label: "Help".to_string(),
2533 style: Style::default()
2534 .fg(Color::Blue)
2535 .add_modifier(Modifier::BOLD),
2536 });
2537 actions
2538}
2539
2540fn visible_input_actions(app: &App, max_width: u16) -> Vec<InputActionVisual> {
2541 let mut used = 0u16;
2542 let mut visible = Vec::with_capacity(6);
2543 for action in build_input_actions(app) {
2544 let chip_width = action.label.chars().count() as u16 + 2;
2545 let gap = if visible.is_empty() { 0 } else { 1 };
2546 if used + gap + chip_width > max_width {
2547 break;
2548 }
2549 used += gap + chip_width;
2550 visible.push(action);
2551 }
2552 visible
2553}
2554
2555fn input_status_variants(app: &App) -> Vec<String> {
2556 let voice_status = if app.voice_manager.is_enabled() {
2557 "ON"
2558 } else {
2559 "OFF"
2560 };
2561 let approvals_status = if app.yolo_mode { "OFF" } else { "ON" };
2562 let issue = runtime_issue_badge(runtime_issue_kind(app)).0;
2563 let flow = app.workflow_mode.to_uppercase();
2564 let attach_status = if app.attached_context.is_some() && app.attached_image.is_some() {
2565 "ATTACH:DOC+IMG"
2566 } else if app.attached_context.is_some() {
2567 "ATTACH:DOC"
2568 } else if app.attached_image.is_some() {
2569 "ATTACH:IMG"
2570 } else {
2571 "ATTACH:--"
2572 };
2573 if app.agent_running {
2574 vec![
2575 format!(
2576 "WORKING · ESC stops · FLOW:{} · RT:{} · VOICE:{}",
2577 flow, issue, voice_status
2578 ),
2579 format!("WORKING · RT:{} · VOICE:{}", issue, voice_status),
2580 format!("RT:{} · VOICE:{}", issue, voice_status),
2581 format!("RT:{}", issue),
2582 ]
2583 } else if app.input.trim().is_empty() {
2584 vec![
2585 format!(
2586 "READY · FLOW:{} · RT:{} · VOICE:{} · APPR:{}",
2587 flow, issue, voice_status, approvals_status
2588 ),
2589 format!("READY · FLOW:{} · RT:{}", flow, issue),
2590 format!("FLOW:{} · RT:{}", flow, issue),
2591 format!("RT:{}", issue),
2592 ]
2593 } else {
2594 let draft_len = app.input.len();
2595 vec![
2596 format!(
2597 "DRAFT:{} · FLOW:{} · RT:{} · {}",
2598 draft_len, flow, issue, attach_status
2599 ),
2600 format!("DRAFT:{} · RT:{} · {}", draft_len, issue, attach_status),
2601 format!("LEN:{} · RT:{}", draft_len, issue),
2602 format!("RT:{}", issue),
2603 ]
2604 }
2605}
2606
2607fn make_sparkline_gauge(ratio: f64, width: usize) -> String {
2608 let filled = (ratio * width as f64).round() as usize;
2609 let mut s = String::with_capacity(width);
2610 for i in 0..width {
2611 if i < filled {
2612 s.push('▓');
2613 } else {
2614 s.push('░');
2615 }
2616 }
2617 s
2618}
2619
2620fn make_animated_sparkline_gauge(ratio: f64, width: usize, tick_count: u64) -> String {
2621 let filled = (ratio.clamp(0.0, 1.0) * width as f64).round() as usize;
2622 let shimmer_idx = if filled > 0 {
2623 (tick_count as usize / 2) % filled.max(1)
2624 } else {
2625 0
2626 };
2627 let mut chars: Vec<char> = make_sparkline_gauge(ratio, width).chars().collect();
2628 for (i, ch) in chars.iter_mut().enumerate() {
2629 if i < filled {
2630 *ch = if i == shimmer_idx { '█' } else { '▓' };
2631 } else if i == filled && filled < width && ratio > 0.0 {
2632 *ch = '▒';
2633 } else {
2634 *ch = '░';
2635 }
2636 }
2637 chars.into_iter().collect()
2638}
2639
2640fn select_fitting_variant(variants: &[String], width: u16) -> String {
2641 let max_width = width as usize;
2642 for variant in variants {
2643 if variant.chars().count() <= max_width {
2644 return variant.clone();
2645 }
2646 }
2647 variants.last().cloned().unwrap_or_default()
2648}
2649
2650fn idle_footer_variants(app: &App) -> Vec<String> {
2651 let issue = runtime_issue_badge(runtime_issue_kind(app)).0;
2652 if issue != "OK" {
2653 return vec![
2654 format!(" /runtime fix • /runtime explain • RT:{} ", issue),
2655 format!(" /runtime fix • RT:{} ", issue),
2656 format!(" RT:{} ", issue),
2657 ];
2658 }
2659
2660 let phase = (app.tick_count / 18) % 3;
2661 match phase {
2662 0 => vec![
2663 " [↑/↓] scroll • /help hints • /runtime status ".to_string(),
2664 " [↑/↓] scroll • /help hints ".to_string(),
2665 " /help ".to_string(),
2666 ],
2667 1 => vec![
2668 " /ask analyze • /architect plan • /code implement ".to_string(),
2669 " /ask • /architect • /code ".to_string(),
2670 " /code ".to_string(),
2671 ],
2672 _ => vec![
2673 " /provider status • /runtime refresh • /ls desktop ".to_string(),
2674 " /provider • /runtime refresh ".to_string(),
2675 " /runtime ".to_string(),
2676 ],
2677 }
2678}
2679
2680fn running_footer_variants(app: &App, elapsed: &str, last_log: &str) -> Vec<String> {
2681 let worker_count = app.active_workers.len();
2682 let primary_caption = if worker_count > 0 {
2683 format!("{} workers • {}", worker_count, last_log)
2684 } else {
2685 last_log.to_string()
2686 };
2687 vec![
2688 primary_caption,
2689 last_log.to_string(),
2690 format!("{} • working", elapsed.trim()),
2691 "working".to_string(),
2692 ]
2693}
2694
2695fn select_input_title_layout(app: &App, title_width: u16) -> (Vec<InputActionVisual>, String) {
2696 let action_total = build_input_actions(app).len();
2697 let mut best_actions = visible_input_actions(app, title_width);
2698 let mut best_status = String::new();
2699 for status in input_status_variants(app) {
2700 let reserved = status.chars().count() as u16 + 3;
2701 let actions = visible_input_actions(app, title_width.saturating_sub(reserved));
2702 let replace = actions.len() > best_actions.len()
2703 || (actions.len() == best_actions.len() && status.len() > best_status.len());
2704 if replace {
2705 best_actions = actions.clone();
2706 best_status = status.clone();
2707 }
2708 if actions.len() == action_total {
2709 return (actions, status);
2710 }
2711 }
2712 (best_actions, best_status)
2713}
2714
2715fn input_action_hitboxes(app: &App, title_area: Rect) -> Vec<(InputAction, u16, u16)> {
2716 let mut x = title_area.x;
2717 let mut out = Vec::with_capacity(6);
2718 let (actions, _) = select_input_title_layout(app, title_area.width);
2719 for action in actions {
2720 let chip_width = action.label.chars().count() as u16 + 2; out.push((action.action, x, x + chip_width.saturating_sub(1)));
2722 x = x.saturating_add(chip_width + 1);
2723 }
2724 out
2725}
2726
2727fn render_input_title<'a>(app: &'a App, area: Rect) -> Line<'a> {
2728 let mut spans = Vec::with_capacity(8);
2729 let (actions, status) = select_input_title_layout(app, area.width);
2730 for action in actions {
2731 let is_hovered = app.hovered_input_action == Some(action.action);
2732 let style = if is_hovered {
2733 Style::default()
2734 .bg(action.style.fg.unwrap_or(Color::Gray))
2735 .fg(Color::Black)
2736 .add_modifier(Modifier::BOLD)
2737 } else {
2738 action.style
2739 };
2740 spans.push(Span::styled(format!(" {} ", action.label), style));
2741 spans.push(Span::raw(" "));
2742 }
2743
2744 if !status.is_empty() {
2745 spans.push(Span::raw(" "));
2746 spans.push(Span::styled(status, Style::default().fg(Color::DarkGray)));
2747 }
2748 Line::from(spans)
2749}
2750
2751fn reset_visible_session_state(app: &mut App) {
2752 app.messages.clear();
2753 app.messages_raw.clear();
2754 app.last_reasoning.clear();
2755 app.current_thought.clear();
2756 app.specular_logs.clear();
2757 app.reset_error_count();
2758 app.reset_runtime_status_memory();
2759 app.reset_active_context();
2760 app.tool_started_at.clear();
2761 app.clear_grounded_recovery_cache();
2762 app.clear_pending_attachments();
2763 app.current_objective = "Idle".into();
2764}
2765
2766fn request_stop(app: &mut App) {
2767 app.voice_manager.stop();
2768 if app.stop_requested {
2769 return;
2770 }
2771 app.stop_requested = true;
2772 app.cancel_token
2773 .store(true, std::sync::atomic::Ordering::SeqCst);
2774 if app.thinking || app.agent_running {
2775 app.write_session_report();
2776 app.copy_transcript_to_clipboard();
2777 app.push_message(
2778 "System",
2779 "Cancellation requested. Logs copied to clipboard.",
2780 );
2781 }
2782}
2783
2784fn show_help_message(app: &mut App) {
2785 app.push_message(
2786 "System",
2787 "Hematite Command Inventory\n\n\
2788 [IT & Remediation Tools] (0-Model Logic)\n\
2789 /triage [preset] - Run IT triage logic (health, security, connectivity, identity, updates)\n\
2790 /health - Alias for /triage (deterministic health report)\n\
2791 /fix <issue> - Generate a targeted fix plan for a specific issue\n\
2792 /inspect <topic> - Run a specific host inspection topic (e.g., /inspect connectivity)\n\
2793 /diagnose - Run staged health triage with agent handoff\n\
2794 /export [fmt] - Generate and save a full diagnostic report (md|html|json)\n\
2795 /explain <text> - Paste an error to get a non-technical breakdown\n\n\
2796 [Agent Workflow Modes]\n\
2797 /chat - Conversation mode (no tool noise)\n\
2798 /agent - Full coding harness + workstation mode (tools active)\n\
2799 /auto - Let Hematite choose the narrowest effective workflow\n\
2800 /ask, /code - Sticky Analysis or Implementation modes\n\
2801 /architect - Plan-first mode (inspect and approach before edit)\n\
2802 /teach - Guided walkthrough mode (no-execute)\n\n\
2803 [Context & Memory Management]\n\
2804 /new - Fresh task context (clear chat/pins/task files)\n\
2805 /forget - Hard forget (purge chat + saved memory + Vein index)\n\
2806 /clear - Clear dialogue display only\n\
2807 /attach, /image - Attach document or image for next message\n\
2808 /detach - Drop pending attachments\n\
2809 /vein-inspect - Inspect RAG memory and active room bias\n\n\
2810 [System & Runtime]\n\
2811 /runtime [fix] - Show or fix live provider/model/embed status\n\
2812 /model, /embed - List, load, unload, or prefer specific models\n\
2813 /lsp - Start Language Servers (semantic intelligence)\n\
2814 /think, /no_think - Toggle deep reasoning mode (reasoning is 3-5x slower)\n\
2815 /undo - Revert last file change\n\
2816 /version, /about - Show build and product info\n\n\
2817 [Navigation & Filesystem]\n\
2818 /cd <path> - Teleport to another directory\n\
2819 /ls [path] - List locations or subdirectories\n\n\
2820 Hotkeys: Ctrl+B (Brief), Ctrl+P (Professional), Ctrl+Y (Auto-approve), Ctrl+Z (Undo), Ctrl+C (Quit), ESC (Silence)"
2821 );
2822}
2823
2824#[allow(dead_code)]
2825fn show_help_message_legacy(app: &mut App) {
2826 app.push_message("System",
2827 "Hematite Commands:\n\
2828 /chat — (Mode) Conversation mode — clean chat, no tool noise\n\
2829 /agent — (Mode) Full coding harness + workstation mode — tools, file edits, builds, inspection\n\
2830 /reroll — (Soul) Hatch a new companion mid-session\n\
2831 /auto — (Flow) Let Hematite choose the narrowest effective workflow\n\
2832 /ask [prompt] — (Flow) Read-only analysis mode; optional inline prompt\n\
2833 /code [prompt] — (Flow) Explicit implementation mode; optional inline prompt\n\
2834 /architect [prompt] — (Flow) Plan-first mode; optional inline prompt\n\
2835 /implement-plan — (Flow) Execute the saved architect handoff in /code\n\
2836 /read-only [prompt] — (Flow) Hard read-only mode; optional inline prompt\n\
2837 /teach [prompt] — (Flow) Teacher mode; inspect machine then walk you through any admin task step-by-step\n\
2838 /new — (Reset) Fresh task context; clear chat, pins, and task files\n\
2839 /forget — (Wipe) Hard forget; purge saved memory and Vein index too\n\
2840 /vein-inspect — (Vein) Inspect indexed memory, hot files, and active room bias\n\
2841 /workspace-profile — (Profile) Show the auto-generated workspace profile\n\
2842 /rules — (Rules) View project guidance (CLAUDE.md, SKILLS.md, .hematite/rules.md)\n\
2843 /version — (Build) Show the running Hematite version\n\
2844 /about — (Info) Show author, repo, and product info\n\
2845 /vein-reset — (Vein) Wipe the RAG index; rebuilds automatically on next turn\n\
2846 /clear — (UI) Clear dialogue display only\n\
2847 /health — (Diag) Run a synthesized plain-English system health report\n\
2848 /explain <text> — (Help) Paste an error to get a non-technical breakdown\n\
2849 /gemma-native [auto|on|off|status] — (Model) Auto/force/disable Gemma 4 native formatting\n\
2850 /provider [status|lmstudio|ollama|clear|URL] — (Model) Show or save the active provider endpoint preference\n\
2851 /runtime — (Model) Show the live runtime/provider/model/embed status and shortest fix path\n\
2852 /runtime fix — (Model) Run the shortest safe runtime recovery step now\n\
2853 /runtime-refresh — (Model) Re-read active provider model + CTX now\n\
2854 /model [status|list [available|loaded]|load <id> [--ctx N]|unload [id|current|all]|prefer <id>|clear] — (Model) Inspect, list, load, unload, or save the preferred coding model (`--ctx` uses LM Studio context length or Ollama `num_ctx`)\n\
2855 /embed [status|load <id>|unload [id|current]|prefer <id>|clear] — (Model) Inspect, load, unload, or save the preferred embed model\n\
2856 /undo — (Ghost) Revert last file change\n\
2857 /diff — (Git) Show session changes (--stat)\n\
2858 /lsp — (Logic) Start Language Servers (semantic intelligence)\n\
2859 /swarm <text> — (Swarm) Spawn parallel workers on a directive\n\
2860 /worktree <cmd> — (Isolated) Manage git worktrees (list|add|remove|prune)\n\
2861 /think — (Brain) Enable deep reasoning mode\n\
2862 /no_think — (Speed) Disable reasoning (3-5x faster responses)\n\
2863 /voice — (TTS) List all available voices\n\
2864 /voice N — (TTS) Select voice by number\n\
2865 /read <text> — (TTS) Speak text aloud directly, bypassing the model. ESC to stop.\n\
2866 /explain <text> — (Plain English) Paste any error or output; Hematite explains it in plain English.\n\
2867 /health — (SysAdmin) Run a full system health report (disk, RAM, tools, recent errors).\n\
2868 /attach <path> — (Docs) Attach a PDF/markdown/txt file for next message\n\
2869 /attach-pick — (Docs) Open a file picker and attach a document\n\
2870 /image <path> — (Vision) Attach an image for the next message\n\
2871 /image-pick — (Vision) Open a file picker and attach an image\n\
2872 /detach — (Context) Drop pending document/image attachments\n\
2873 /copy — (Debug) Copy session transcript to clipboard\n\
2874 /copy2 — (Debug) Copy the full SPECULAR rail to clipboard (reasoning + events)\n\
2875 \nHotkeys:\n\
2876 Ctrl+B — Toggle Brief Mode (minimal output; collapses side chrome)\n\
2877 Alt+↑/↓ — Scroll the SPECULAR rail by 3 lines\n\
2878 Alt+PgUp/PgDn — Scroll the SPECULAR rail by 10 lines\n\
2879 Alt+End — Snap SPECULAR back to live follow mode\n\
2880 Ctrl+P — Toggle Professional Mode (strip personality)\n\
2881 Ctrl+O — Open document picker for next-turn context\n\
2882 Ctrl+I — Open image picker for next-turn vision context\n\
2883 Ctrl+Y — Toggle Approvals Off (bypass safety approvals)\n\
2884 Ctrl+S — Quick Swarm (hardcoded bootstrap)\n\
2885 Ctrl+Z — Undo last edit\n\
2886 Ctrl+Q/C — Quit session\n\
2887 ESC — Silence current playback\n\
2888 \nStatus Legend:\n\
2889 LM/OL — Provider runtime health (`LIVE`, `RECV`, `WARN`, `CEIL`, `STALE`, `BOOT`)\n\
2890 RT — Primary runtime issue (`OK`, `MOD`, `NET`, `EMP`, `CTX`, `WAIT`)\n\
2891 VN — Vein RAG status (`SEM`=semantic active, `FTS`=BM25 only, `--`=not indexed)\n\
2892 BUD — Total prompt-budget pressure against the live context window\n\
2893 CMP — History compaction pressure against Hematite's adaptive threshold\n\
2894 ERR — Session error count (runtime, tool, or SPECULAR failures)\n\
2895 CTX — Live context window currently reported by the provider\n\
2896 VOICE — Local speech output state\n\
2897 \nAssistant: Semantic Pathing (LSP), Vision Pass, Web Research, Swarm Synthesis"
2898 );
2899 app.push_message(
2900 "System",
2901 "Document note: `/attach` supports PDF/markdown/txt, but PDF parsing is best-effort by design so Hematite can stay a lightweight single-binary local coding harness and workstation assistant. If a PDF fails, export it to text/markdown or attach page images instead.",
2902 );
2903}
2904
2905fn trigger_input_action(app: &mut App, action: InputAction) {
2906 match action {
2907 InputAction::Stop => request_stop(app),
2908 InputAction::PickDocument => match pick_attachment_path(AttachmentPickerKind::Document) {
2909 Ok(Some(path)) => attach_document_from_path(app, &path),
2910 Ok(None) => app.push_message("System", "Document picker cancelled."),
2911 Err(e) => app.push_message("System", &e),
2912 },
2913 InputAction::PickImage => match pick_attachment_path(AttachmentPickerKind::Image) {
2914 Ok(Some(path)) => attach_image_from_path(app, &path),
2915 Ok(None) => app.push_message("System", "Image picker cancelled."),
2916 Err(e) => app.push_message("System", &e),
2917 },
2918 InputAction::Detach => {
2919 app.clear_pending_attachments();
2920 app.push_message(
2921 "System",
2922 "Cleared pending document/image attachments for the next turn.",
2923 );
2924 }
2925 InputAction::New => {
2926 if !app.agent_running {
2927 reset_visible_session_state(app);
2928 app.push_message("You", "/new");
2929 app.agent_running = true;
2930 let _ = app.user_input_tx.try_send(UserTurn::text("/new"));
2931 }
2932 }
2933 InputAction::Forget => {
2934 if !app.agent_running {
2935 app.cancel_token
2936 .store(true, std::sync::atomic::Ordering::SeqCst);
2937 reset_visible_session_state(app);
2938 app.push_message("You", "/forget");
2939 app.agent_running = true;
2940 app.cancel_token
2941 .store(false, std::sync::atomic::Ordering::SeqCst);
2942 let _ = app.user_input_tx.try_send(UserTurn::text("/forget"));
2943 }
2944 }
2945 InputAction::Help => show_help_message(app),
2946 }
2947}
2948
2949fn pick_attachment_path(kind: AttachmentPickerKind) -> Result<Option<String>, String> {
2950 #[cfg(target_os = "windows")]
2951 {
2952 let (title, filter) = match kind {
2953 AttachmentPickerKind::Document => (
2954 "Attach document for the next Hematite turn",
2955 "Documents|*.pdf;*.md;*.markdown;*.txt;*.rst|All Files|*.*",
2956 ),
2957 AttachmentPickerKind::Image => (
2958 "Attach image for the next Hematite turn",
2959 "Images|*.png;*.jpg;*.jpeg;*.gif;*.webp|All Files|*.*",
2960 ),
2961 };
2962 let script = format!(
2963 "Add-Type -AssemblyName System.Windows.Forms\n$dialog = New-Object System.Windows.Forms.OpenFileDialog\n$dialog.Title = '{title}'\n$dialog.Filter = '{filter}'\n$dialog.Multiselect = $false\nif ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {{ Write-Output $dialog.FileName }}"
2964 );
2965 let output = std::process::Command::new("powershell")
2966 .args(["-NoProfile", "-STA", "-Command", &script])
2967 .output()
2968 .map_err(|e| format!("File picker failed: {}", e))?;
2969 if !output.status.success() {
2970 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
2971 return Err(if stderr.is_empty() {
2972 "File picker did not complete successfully.".to_string()
2973 } else {
2974 format!("File picker failed: {}", stderr)
2975 });
2976 }
2977 let selected = String::from_utf8_lossy(&output.stdout).trim().to_string();
2978 if selected.is_empty() {
2979 Ok(None)
2980 } else {
2981 Ok(Some(selected))
2982 }
2983 }
2984 #[cfg(target_os = "macos")]
2985 {
2986 let prompt = match kind {
2987 AttachmentPickerKind::Document => "Choose a document for the next Hematite turn",
2988 AttachmentPickerKind::Image => "Choose an image for the next Hematite turn",
2989 };
2990 let script = format!("POSIX path of (choose file with prompt \"{}\")", prompt);
2991 let output = std::process::Command::new("osascript")
2992 .args(["-e", &script])
2993 .output()
2994 .map_err(|e| format!("File picker failed: {}", e))?;
2995 if output.status.success() {
2996 let selected = String::from_utf8_lossy(&output.stdout).trim().to_string();
2997 if selected.is_empty() {
2998 Ok(None)
2999 } else {
3000 Ok(Some(selected))
3001 }
3002 } else {
3003 Ok(None)
3004 }
3005 }
3006 #[cfg(all(unix, not(target_os = "macos")))]
3007 {
3008 let title = match kind {
3009 AttachmentPickerKind::Document => "Attach document for the next Hematite turn",
3010 AttachmentPickerKind::Image => "Attach image for the next Hematite turn",
3011 };
3012 let output = std::process::Command::new("zenity")
3013 .args(["--file-selection", "--title", title])
3014 .output()
3015 .map_err(|e| format!("File picker failed: {}", e))?;
3016 if output.status.success() {
3017 let selected = String::from_utf8_lossy(&output.stdout).trim().to_string();
3018 if selected.is_empty() {
3019 Ok(None)
3020 } else {
3021 Ok(Some(selected))
3022 }
3023 } else {
3024 Ok(None)
3025 }
3026 }
3027}
3028
3029#[allow(clippy::too_many_arguments)]
3030pub async fn run_app<B: Backend>(
3031 terminal: &mut Terminal<B>,
3032 mut specular_rx: Receiver<SpecularEvent>,
3033 mut agent_rx: Receiver<crate::agent::inference::InferenceEvent>,
3034 user_input_tx: tokio::sync::mpsc::Sender<UserTurn>,
3035 mut swarm_rx: Receiver<SwarmMessage>,
3036 swarm_tx: tokio::sync::mpsc::Sender<SwarmMessage>,
3037 swarm_coordinator: Arc<crate::agent::swarm::SwarmCoordinator>,
3038 last_interaction: Arc<Mutex<Instant>>,
3039 cockpit: crate::CliCockpit,
3040 soul: crate::ui::hatch::RustySoul,
3041 professional: bool,
3042 gpu_state: Arc<GpuState>,
3043 git_state: Arc<crate::agent::git_monitor::GitState>,
3044 cancel_token: Arc<std::sync::atomic::AtomicBool>,
3045 voice_manager: Arc<crate::ui::voice::VoiceManager>,
3046) -> Result<(), Box<dyn std::error::Error>> {
3047 let mut app = App {
3048 messages: Vec::new(),
3049 messages_raw: Vec::new(),
3050 specular_logs: Vec::new(),
3051 brief_mode: cockpit.brief,
3052 tick_count: 0,
3053 stats: RustyStats {
3054 debugging: 0,
3055 wisdom: soul.wisdom,
3056 patience: 100.0,
3057 chaos: soul.chaos,
3058 snark: soul.snark,
3059 },
3060 yolo_mode: cockpit.yolo,
3061 awaiting_approval: None,
3062 active_workers: HashMap::new(),
3063 worker_labels: HashMap::new(),
3064 active_review: None,
3065 input: String::new(),
3066 input_history: Vec::new(),
3067 history_idx: None,
3068 thinking: false,
3069 agent_running: false,
3070 stop_requested: false,
3071 current_thought: String::new(),
3072 professional,
3073 last_reasoning: String::new(),
3074 active_context: default_active_context(),
3075 manual_scroll_offset: None,
3076 user_input_tx,
3077 specular_scroll: 0,
3078 specular_auto_scroll: true,
3079 gpu_state,
3080 git_state,
3081 last_input_time: Instant::now(),
3082 cancel_token,
3083 total_tokens: 0,
3084 current_session_cost: 0.0,
3085 model_id: "detecting...".to_string(),
3086 context_length: 0,
3087 prompt_pressure_percent: 0,
3088 prompt_estimated_input_tokens: 0,
3089 prompt_reserved_output_tokens: 0,
3090 prompt_estimated_total_tokens: 0,
3091 compaction_percent: 0,
3092 compaction_estimated_tokens: 0,
3093 compaction_threshold_tokens: 0,
3094 compaction_warned_level: 0,
3095 last_runtime_profile_time: Instant::now(),
3096 vein_file_count: 0,
3097 vein_embedded_count: 0,
3098 vein_docs_only: false,
3099 provider_name: "detecting".to_string(),
3100 provider_endpoint: String::new(),
3101 embed_model_id: None,
3102 provider_state: ProviderRuntimeState::Booting,
3103 last_provider_summary: String::new(),
3104 mcp_state: McpRuntimeState::Unconfigured,
3105 last_mcp_summary: String::new(),
3106 last_operator_checkpoint_state: OperatorCheckpointState::Idle,
3107 last_operator_checkpoint_summary: String::new(),
3108 last_recovery_recipe_summary: String::new(),
3109 think_mode: None,
3110 workflow_mode: "AUTO".into(),
3111 autocomplete_suggestions: Vec::new(),
3112 selected_suggestion: 0,
3113 show_autocomplete: false,
3114 autocomplete_filter: String::new(),
3115 current_objective: "Awaiting objective...".into(),
3116 voice_manager,
3117 voice_loading: false,
3118 voice_loading_progress: 1.0, autocomplete_alias_active: false,
3120 hardware_guard_enabled: true,
3121 session_start: std::time::SystemTime::now(),
3122 soul_name: soul.species.clone(),
3123 attached_context: None,
3124 attached_image: None,
3125 hovered_input_action: None,
3126 teleported_from: cockpit.teleported_from.clone(),
3127 nav_list: Vec::new(),
3128 auto_approve_session: false,
3129 task_start_time: None,
3130 tool_started_at: HashMap::new(),
3131 recent_grounded_results: Vec::new(),
3132 };
3133
3134 app.push_message("Hematite", "Initialising Engine & Hardware...");
3136
3137 if let Some(origin) = &app.teleported_from {
3138 app.push_message(
3139 "System",
3140 &format!(
3141 "Teleportation complete. You've arrived from {}. Hematite has launched this fresh session to ensure your original terminal remains clean and your context is grounded in this target workspace. What's our next move?",
3142 origin
3143 ),
3144 );
3145 }
3146
3147 if !cockpit.no_splash {
3150 loop {
3151 draw_splash(terminal)?;
3152
3153 if event::poll(Duration::from_millis(350))? {
3154 if let Event::Key(key) = event::read()? {
3155 if key.kind == event::KeyEventKind::Press
3156 && matches!(key.code, KeyCode::Enter | KeyCode::Char(' '))
3157 {
3158 break;
3159 }
3160 }
3161 }
3162 }
3163 }
3164
3165 if app.teleported_from.is_some()
3166 && crate::tools::plan::consume_teleport_resume_marker()
3167 && crate::tools::plan::load_plan_handoff().is_some()
3168 {
3169 app.workflow_mode = "CODE".into();
3170 app.thinking = true;
3171 app.agent_running = true;
3172 app.push_message(
3173 "System",
3174 "Teleport handoff detected in this project. Resuming from `.hematite/PLAN.md` automatically.",
3175 );
3176 app.push_message("You", "/implement-plan");
3177 let _ = app
3178 .user_input_tx
3179 .try_send(UserTurn::text("/implement-plan"));
3180 }
3181
3182 let mut event_stream = EventStream::new();
3183 let mut ticker = tokio::time::interval(std::time::Duration::from_millis(100));
3184
3185 loop {
3186 let vram_ratio = app.gpu_state.ratio();
3188 if app.hardware_guard_enabled && vram_ratio > 0.95 && !app.brief_mode {
3189 app.brief_mode = true;
3190 app.push_message(
3191 "System",
3192 "🚨 HARDWARE GUARD: VRAM > 95%. Brief Mode auto-enabled to prevent crash.",
3193 );
3194 }
3195
3196 app.sync_task_start_time();
3197 terminal.draw(|f| ui(f, &app))?;
3198
3199 tokio::select! {
3200 _ = ticker.tick() => {
3201 if app.voice_loading && app.voice_loading_progress < 0.98 {
3203 app.voice_loading_progress += 0.002;
3204 }
3205
3206 let workers = app.active_workers.len() as u64;
3207 let advance = if workers > 0 { workers * 4 + 1 } else { 1 };
3208 app.tick_count = app.tick_count.wrapping_add(advance);
3212 app.update_objective();
3213 }
3214
3215 maybe_event = event_stream.next() => {
3217 match maybe_event {
3218 Some(Ok(Event::Mouse(mouse))) => {
3219 use crossterm::event::{MouseButton, MouseEventKind};
3220 let (width, height) = match terminal.size() {
3221 Ok(s) => (s.width, s.height),
3222 Err(_) => (80, 24),
3223 };
3224 let is_right_side = mouse.column as f64 > width as f64 * 0.65;
3225 let input_rect = input_rect_for_size(
3226 Rect { x: 0, y: 0, width, height },
3227 app.input.len(),
3228 );
3229 let title_area = input_title_area(input_rect);
3230
3231 match mouse.kind {
3232 MouseEventKind::Moved => {
3233 let hovered = if mouse.row == title_area.y
3234 && mouse.column >= title_area.x
3235 && mouse.column < title_area.x + title_area.width
3236 {
3237 input_action_hitboxes(&app, title_area)
3238 .into_iter()
3239 .find_map(|(action, start, end)| {
3240 (mouse.column >= start && mouse.column <= end)
3241 .then_some(action)
3242 })
3243 } else {
3244 None
3245 };
3246 app.hovered_input_action = hovered;
3247 }
3248 MouseEventKind::Down(MouseButton::Left) => {
3249 if mouse.row == title_area.y
3250 && mouse.column >= title_area.x
3251 && mouse.column < title_area.x + title_area.width
3252 {
3253 for (action, start, end) in input_action_hitboxes(&app, title_area) {
3254 if mouse.column >= start && mouse.column <= end {
3255 app.hovered_input_action = Some(action);
3256 trigger_input_action(&mut app, action);
3257 break;
3258 }
3259 }
3260 } else {
3261 app.hovered_input_action = None;
3262
3263 if app.show_autocomplete && !app.autocomplete_suggestions.is_empty() {
3265 let items_len = app.autocomplete_suggestions.len();
3268 let popup_h = (items_len as u16 + 2).min(17); let popup_y = input_rect.y.saturating_sub(popup_h);
3270 let popup_x = input_rect.x + 2;
3271 let popup_w = input_rect.width.saturating_sub(4);
3272
3273 if mouse.row >= popup_y && mouse.row < popup_y + popup_h
3274 && mouse.column >= popup_x && mouse.column < popup_x + popup_w
3275 {
3276 let mouse_relative_y = mouse.row.saturating_sub(popup_y + 1);
3278 if mouse_relative_y < items_len as u16 {
3279 let clicked_idx = mouse_relative_y as usize;
3280 let selected = &app.autocomplete_suggestions[clicked_idx].clone();
3281 app.apply_autocomplete_selection(selected);
3282 }
3283 continue; }
3285 }
3286 }
3287 }
3288 MouseEventKind::ScrollUp => {
3289 if is_right_side {
3290 scroll_specular_up(&mut app, 3);
3292 } else {
3293 let cur = app.manual_scroll_offset.unwrap_or(0);
3294 app.manual_scroll_offset = Some(cur.saturating_add(3));
3295 }
3296 }
3297 MouseEventKind::ScrollDown => {
3298 if is_right_side {
3299 scroll_specular_down(&mut app, 3);
3300 } else if let Some(cur) = app.manual_scroll_offset {
3301 app.manual_scroll_offset = if cur <= 3 { None } else { Some(cur - 3) };
3302 }
3303 }
3304 _ => {}
3305 }
3306 }
3307 Some(Ok(Event::Key(key))) => {
3308 if key.kind != event::KeyEventKind::Press { continue; }
3309
3310 { *last_interaction.lock().unwrap() = Instant::now(); }
3312
3313 if let Some(review) = app.active_review.take() {
3315 match key.code {
3316 KeyCode::Char('y') | KeyCode::Char('Y') => {
3317 let _ = review.tx.send(ReviewResponse::Accept);
3318 app.push_message("System", &format!("Worker {} diff accepted.", review.worker_id));
3319 }
3320 KeyCode::Char('n') | KeyCode::Char('N') => {
3321 let _ = review.tx.send(ReviewResponse::Reject);
3322 app.push_message("System", "Diff rejected.");
3323 }
3324 KeyCode::Char('r') | KeyCode::Char('R') => {
3325 let _ = review.tx.send(ReviewResponse::Retry);
3326 app.push_message("System", "Retrying synthesis…");
3327 }
3328 _ => { app.active_review = Some(review); }
3329 }
3330 continue;
3331 }
3332
3333 if let Some(mut approval) = app.awaiting_approval.take() {
3335 let scroll_handled = if approval.diff.is_some() {
3337 let diff_lines = approval.diff.as_ref().map(|d| d.lines().count()).unwrap_or(0) as u16;
3338 match key.code {
3339 KeyCode::Down | KeyCode::Char('j') => {
3340 approval.diff_scroll = approval.diff_scroll.saturating_add(1).min(diff_lines.saturating_sub(1));
3341 true
3342 }
3343 KeyCode::Up | KeyCode::Char('k') => {
3344 approval.diff_scroll = approval.diff_scroll.saturating_sub(1);
3345 true
3346 }
3347 KeyCode::PageDown => {
3348 approval.diff_scroll = approval.diff_scroll.saturating_add(10).min(diff_lines.saturating_sub(1));
3349 true
3350 }
3351 KeyCode::PageUp => {
3352 approval.diff_scroll = approval.diff_scroll.saturating_sub(10);
3353 true
3354 }
3355 _ => false,
3356 }
3357 } else {
3358 false
3359 };
3360 if scroll_handled {
3361 app.awaiting_approval = Some(approval);
3362 continue;
3363 }
3364 match key.code {
3365 KeyCode::Char('y') | KeyCode::Char('Y') => {
3366 if let Some(ref diff) = approval.diff {
3367 let added = diff.lines().filter(|l| l.starts_with("+ ")).count();
3368 let removed = diff.lines().filter(|l| l.starts_with("- ")).count();
3369 app.push_message("System", &format!(
3370 "Applied: {} +{} -{}", approval.display, added, removed
3371 ));
3372 } else {
3373 app.push_message("System", &format!("Approved: {}", approval.display));
3374 }
3375 let _ = approval.responder.send(true);
3376 }
3377 KeyCode::Char('a') | KeyCode::Char('A') => {
3378 app.auto_approve_session = true;
3379 if let Some(ref diff) = approval.diff {
3380 let added = diff.lines().filter(|l| l.starts_with("+ ")).count();
3381 let removed = diff.lines().filter(|l| l.starts_with("- ")).count();
3382 app.push_message("System", &format!(
3383 "Applied: {} +{} -{}", approval.display, added, removed
3384 ));
3385 } else {
3386 app.push_message("System", &format!("Approved: {}", approval.display));
3387 }
3388 app.push_message("System", "🔓 FULL AUTONOMY — All mutations auto-approved for this session.");
3389 let _ = approval.responder.send(true);
3390 }
3391 KeyCode::Char('n') | KeyCode::Char('N') => {
3392 if approval.diff.is_some() {
3393 app.push_message("System", "Edit skipped.");
3394 } else {
3395 app.push_message("System", "Declined.");
3396 }
3397 let _ = approval.responder.send(false);
3398 }
3399 _ => { app.awaiting_approval = Some(approval); }
3400 }
3401 continue;
3402 }
3403
3404 match key.code {
3406 KeyCode::Char('q') | KeyCode::Char('c')
3407 if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
3408 app.write_session_report();
3409 app.copy_transcript_to_clipboard();
3410 break;
3411 }
3412
3413 KeyCode::Esc => {
3414 request_stop(&mut app);
3415 }
3416
3417 KeyCode::Char('b') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
3418 app.brief_mode = !app.brief_mode;
3419 app.hardware_guard_enabled = false;
3421 app.push_message("System", &format!("Hardware Guard {}: {}", if app.brief_mode { "ENFORCED" } else { "SILENCED" }, if app.brief_mode { "ON" } else { "OFF" }));
3422 }
3423 KeyCode::Char('p') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
3424 app.professional = !app.professional;
3425 app.push_message("System", &format!("Professional Harness: {}", if app.professional { "ACTIVE" } else { "DISABLED" }));
3426 }
3427 KeyCode::Char('y') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
3428 app.yolo_mode = !app.yolo_mode;
3429 app.push_message("System", &format!("Approvals Off: {}", if app.yolo_mode { "ON — all tools auto-approved" } else { "OFF" }));
3430 }
3431 KeyCode::Char('t') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
3432 if !app.voice_manager.is_available() {
3433 app.push_message("System", "Voice is not available in this build. Use a packaged release for baked-in voice.");
3434 } else {
3435 let enabled = app.voice_manager.toggle();
3436 app.push_message("System", &format!("Voice of Hematite: {}", if enabled { "VIBRANT" } else { "SILENCED" }));
3437 }
3438 }
3439 KeyCode::Char('o') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
3440 match pick_attachment_path(AttachmentPickerKind::Document) {
3441 Ok(Some(path)) => attach_document_from_path(&mut app, &path),
3442 Ok(None) => app.push_message("System", "Document picker cancelled."),
3443 Err(e) => app.push_message("System", &e),
3444 }
3445 }
3446 KeyCode::Char('i') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
3447 match pick_attachment_path(AttachmentPickerKind::Image) {
3448 Ok(Some(path)) => attach_image_from_path(&mut app, &path),
3449 Ok(None) => app.push_message("System", "Image picker cancelled."),
3450 Err(e) => app.push_message("System", &e),
3451 }
3452 }
3453 KeyCode::Char('s') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
3454 app.push_message("Hematite", "Swarm engaged.");
3455 let swarm_tx_c = swarm_tx.clone();
3456 let coord_c = swarm_coordinator.clone();
3457 let max_workers = if app.gpu_state.ratio() > 0.70 { 1 } else { 3 };
3459 if max_workers < 3 {
3460 app.push_message("System", "Hardware Guard: Limiting swarm to 1 worker due to GPU load.");
3461 }
3462
3463 app.agent_running = true;
3464 tokio::spawn(async move {
3465 let payload = r#"<worker_task id="1" target="src/ui/tui.rs">Implement Swarm Layout</worker_task>
3466<worker_task id="2" target="src/agent/swarm.rs">Build Scratchpad constraints</worker_task>
3467<worker_task id="3" target="docs">Update Readme</worker_task>"#;
3468 let tasks = crate::agent::parser::parse_master_spec(payload);
3469 let _ = coord_c.dispatch_swarm(tasks, swarm_tx_c, max_workers).await;
3470 });
3471 }
3472 KeyCode::Char('z') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
3473 match crate::tools::file_ops::pop_ghost_ledger() {
3474 Ok(msg) => {
3475 app.specular_logs.push(format!("GHOST: {}", msg));
3476 trim_vec(&mut app.specular_logs, 7);
3477 app.push_message("System", &msg);
3478 }
3479 Err(e) => {
3480 app.push_message("System", &format!("Undo failed: {}", e));
3481 }
3482 }
3483 }
3484 KeyCode::Up
3485 if key.modifiers.contains(event::KeyModifiers::ALT) =>
3486 {
3487 scroll_specular_up(&mut app, 3);
3488 }
3489 KeyCode::Down
3490 if key.modifiers.contains(event::KeyModifiers::ALT) =>
3491 {
3492 scroll_specular_down(&mut app, 3);
3493 }
3494 KeyCode::PageUp
3495 if key.modifiers.contains(event::KeyModifiers::ALT) =>
3496 {
3497 scroll_specular_up(&mut app, 10);
3498 }
3499 KeyCode::PageDown
3500 if key.modifiers.contains(event::KeyModifiers::ALT) =>
3501 {
3502 scroll_specular_down(&mut app, 10);
3503 }
3504 KeyCode::End
3505 if key.modifiers.contains(event::KeyModifiers::ALT) =>
3506 {
3507 follow_live_specular(&mut app);
3508 app.push_message(
3509 "System",
3510 "SPECULAR snapped back to live follow mode.",
3511 );
3512 }
3513 KeyCode::Up => {
3514 if app.show_autocomplete && !app.autocomplete_suggestions.is_empty() {
3515 app.selected_suggestion = app.selected_suggestion.saturating_sub(1);
3516 } else if app.manual_scroll_offset.is_some() {
3517 let cur = app.manual_scroll_offset.unwrap();
3519 app.manual_scroll_offset = Some(cur.saturating_add(3));
3520 } else if !app.input_history.is_empty() {
3521 let new_idx = match app.history_idx {
3523 None => app.input_history.len() - 1,
3524 Some(i) => i.saturating_sub(1),
3525 };
3526 app.history_idx = Some(new_idx);
3527 app.input = app.input_history[new_idx].clone();
3528 }
3529 }
3530 KeyCode::Down => {
3531 if app.show_autocomplete && !app.autocomplete_suggestions.is_empty() {
3532 app.selected_suggestion = (app.selected_suggestion + 1).min(app.autocomplete_suggestions.len().saturating_sub(1));
3533 } else if let Some(off) = app.manual_scroll_offset {
3534 if off <= 3 { app.manual_scroll_offset = None; }
3535 else { app.manual_scroll_offset = Some(off.saturating_sub(3)); }
3536 } else if let Some(i) = app.history_idx {
3537 if i + 1 < app.input_history.len() {
3538 app.history_idx = Some(i + 1);
3539 app.input = app.input_history[i + 1].clone();
3540 } else {
3541 app.history_idx = None;
3542 app.input.clear();
3543 }
3544 }
3545 }
3546 KeyCode::PageUp => {
3547 let cur = app.manual_scroll_offset.unwrap_or(0);
3548 app.manual_scroll_offset = Some(cur.saturating_add(10));
3549 }
3550 KeyCode::PageDown => {
3551 if let Some(off) = app.manual_scroll_offset {
3552 if off <= 10 { app.manual_scroll_offset = None; }
3553 else { app.manual_scroll_offset = Some(off.saturating_sub(10)); }
3554 }
3555 }
3556 KeyCode::Tab
3557 if app.show_autocomplete
3558 && !app.autocomplete_suggestions.is_empty() =>
3559 {
3560 let selected = app.autocomplete_suggestions[app.selected_suggestion].clone();
3561 app.apply_autocomplete_selection(&selected);
3562 }
3563 KeyCode::Char(c) => {
3564 app.history_idx = None; app.input.push(c);
3566 app.last_input_time = Instant::now();
3567
3568 if c == '@' {
3569 app.show_autocomplete = true;
3570 app.autocomplete_filter.clear();
3571 app.selected_suggestion = 0;
3572 app.update_autocomplete();
3573 } else if app.show_autocomplete {
3574 app.autocomplete_filter.push(c);
3575 app.update_autocomplete();
3576 }
3577 }
3578 KeyCode::Backspace => {
3579 app.input.pop();
3580 if app.show_autocomplete {
3581 if app.input.ends_with('@') || !app.input.contains('@') {
3582 app.show_autocomplete = false;
3583 app.autocomplete_filter.clear();
3584 } else {
3585 app.autocomplete_filter.pop();
3586 app.update_autocomplete();
3587 }
3588 }
3589 }
3590 KeyCode::Enter => {
3591 if app.show_autocomplete
3592 && !app.autocomplete_suggestions.is_empty()
3593 && should_accept_autocomplete_on_enter(
3594 app.autocomplete_alias_active,
3595 &app.autocomplete_filter,
3596 )
3597 {
3598 let selected = app.autocomplete_suggestions[app.selected_suggestion].clone();
3599 app.apply_autocomplete_selection(&selected);
3600 continue;
3601 }
3602
3603 if !app.input.is_empty()
3604 && (!app.agent_running
3605 || is_immediate_local_command(&app.input))
3606 {
3607 if Instant::now().duration_since(app.last_input_time) < std::time::Duration::from_millis(50) {
3610 app.input.push(' ');
3611 app.last_input_time = Instant::now();
3612 continue;
3613 }
3614
3615 let input_text = app.input.drain(..).collect::<String>();
3616
3617 if input_text.starts_with('/') {
3619 let parts: Vec<&str> = input_text.split_whitespace().collect();
3620 let cmd = parts[0].to_lowercase();
3621 match cmd.as_str() {
3622 "/undo" => {
3623 match crate::tools::file_ops::pop_ghost_ledger() {
3624 Ok(msg) => {
3625 app.specular_logs.push(format!("GHOST: {}", msg));
3626 trim_vec(&mut app.specular_logs, 7);
3627 app.push_message("System", &msg);
3628 }
3629 Err(e) => {
3630 app.push_message("System", &format!("Undo failed: {}", e));
3631 }
3632 }
3633 app.history_idx = None;
3634 continue;
3635 }
3636 "/clear" => {
3637 reset_visible_session_state(&mut app);
3638 app.push_message("System", "Dialogue buffer cleared.");
3639 app.history_idx = None;
3640 continue;
3641 }
3642 "/cd" => {
3643 if parts.len() < 2 {
3644 app.push_message("System", "Usage: /cd <path> — teleport to any directory. Supports bare tokens like downloads, desktop, docs, pictures, videos, music, home, temp, bare ~, aliases like @DESKTOP/project, plus .. and absolute paths. Tip: run /ls desktop first if you want a numbered picker.");
3645 app.history_idx = None;
3646 continue;
3647 }
3648 let raw = parts[1..].join(" ");
3649 let target = crate::tools::file_ops::resolve_candidate(&raw);
3650 if !target.exists() {
3651 app.push_message("System", &format!("Directory not found: {}", target.display()));
3652 app.history_idx = None;
3653 continue;
3654 }
3655 if !target.is_dir() {
3656 app.push_message("System", &format!("Not a directory: {}", target.display()));
3657 app.history_idx = None;
3658 continue;
3659 }
3660 let target_str = target.to_string_lossy().to_string();
3661 app.push_message("You", &format!("/cd {}", raw));
3662 app.push_message("System", &format!("Teleporting to {}...", target_str));
3663 app.push_message("System", "Launching new session. This terminal will close.");
3664 spawn_dive_in_terminal(&target_str);
3665 app.write_session_report();
3666 app.copy_transcript_to_clipboard();
3667 break;
3668 }
3669 "/ls" => {
3670 let base: std::path::PathBuf = if parts.len() >= 2 {
3671 let arg = parts[1..].join(" ");
3673 if let Ok(n) = arg.trim().parse::<usize>() {
3674 if n == 0 || n > app.nav_list.len() {
3676 app.push_message("System", &format!("No entry {}. Run /ls first to see the list.", n));
3677 app.history_idx = None;
3678 continue;
3679 }
3680 let target = app.nav_list[n - 1].clone();
3681 let target_str = target.to_string_lossy().to_string();
3682 app.push_message("You", &format!("/ls {}", n));
3683 app.push_message("System", &format!("Teleporting to {}...", target_str));
3684 app.push_message("System", "Launching new session. This terminal will close.");
3685 spawn_dive_in_terminal(&target_str);
3686 app.write_session_report();
3687 app.copy_transcript_to_clipboard();
3688 break;
3689 } else {
3690 crate::tools::file_ops::resolve_candidate(&arg)
3691 }
3692 } else {
3693 std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
3694 };
3695
3696 let mut entries: Vec<std::path::PathBuf> = Vec::new();
3698 let mut output = String::with_capacity(1024);
3699
3700 let listing_base = parts.len() < 2;
3702 if listing_base {
3703 let common: Vec<(&str, Option<std::path::PathBuf>)> = vec![
3704 ("Desktop", dirs::desktop_dir()),
3705 ("Downloads", dirs::download_dir()),
3706 ("Documents", dirs::document_dir()),
3707 ("Pictures", dirs::picture_dir()),
3708 ("Home", dirs::home_dir()),
3709 ];
3710 let valid: Vec<_> = common.into_iter().filter_map(|(label, p)| p.map(|pb| (label, pb))).collect();
3711 if !valid.is_empty() {
3712 output.push_str("Common locations:\n");
3713 for (label, pb) in &valid {
3714 entries.push(pb.clone());
3715 let _ = writeln!(output, " {:>2}. {:<12} {}", entries.len(), label, pb.display());
3716 }
3717 }
3718 }
3719
3720 let cwd_label = if listing_base {
3722 std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
3723 } else {
3724 base.clone()
3725 };
3726 if let Ok(read) = std::fs::read_dir(&cwd_label) {
3727 let mut dirs_found: Vec<std::path::PathBuf> = read
3728 .filter_map(|e| e.ok())
3729 .filter(|e| e.path().is_dir())
3730 .map(|e| e.path())
3731 .collect();
3732 dirs_found.sort_unstable();
3733 if !dirs_found.is_empty() {
3734 let _ = write!(output, "\n{}:\n", cwd_label.display());
3735 for pb in &dirs_found {
3736 entries.push(pb.clone());
3737 let name = pb.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
3738 let _ = writeln!(output, " {:>2}. {}", entries.len(), name);
3739 }
3740 }
3741 }
3742
3743 if entries.is_empty() {
3744 app.push_message("System", "No directories found.");
3745 } else {
3746 output.push_str("\nType /ls <N> to teleport to that directory.");
3747 app.nav_list = entries;
3748 app.push_message("System", &output);
3749 }
3750 app.history_idx = None;
3751 continue;
3752 }
3753 "/diff" => {
3754 app.push_message("System", "Fetching session diff...");
3755 let ws = crate::tools::file_ops::workspace_root();
3756 if crate::agent::git::is_git_repo(&ws) {
3757 let output = std::process::Command::new("git")
3758 .args(["diff", "--stat"])
3759 .current_dir(ws)
3760 .output();
3761 if let Ok(out) = output {
3762 let stat = String::from_utf8_lossy(&out.stdout).into_owned();
3763 app.push_message("System", if stat.is_empty() { "No changes detected." } else { &stat });
3764 }
3765 } else {
3766 app.push_message("System", "Not a git repository. Diff limited.");
3767 }
3768 app.history_idx = None;
3769 continue;
3770 }
3771 "/vein-reset" => {
3772 app.vein_file_count = 0;
3773 app.vein_embedded_count = 0;
3774 app.push_message("You", "/vein-reset");
3775 app.agent_running = true;
3776 let _ = app.user_input_tx.try_send(UserTurn::text("/vein-reset"));
3777 app.history_idx = None;
3778 continue;
3779 }
3780 "/vein-inspect" => {
3781 app.push_message("You", "/vein-inspect");
3782 app.agent_running = true;
3783 let _ = app.user_input_tx.try_send(UserTurn::text("/vein-inspect"));
3784 app.history_idx = None;
3785 continue;
3786 }
3787 "/workspace-profile" => {
3788 app.push_message("You", "/workspace-profile");
3789 app.agent_running = true;
3790 let _ = app.user_input_tx.try_send(UserTurn::text("/workspace-profile"));
3791 app.history_idx = None;
3792 continue;
3793 }
3794 "/copy" => {
3795 app.copy_transcript_to_clipboard();
3796 app.push_message("System", "Exact session transcript copied to clipboard (includes help/system output).");
3797 app.history_idx = None;
3798 continue;
3799 }
3800 "/copy-last" => {
3801 if app.copy_last_reply_to_clipboard() {
3802 app.push_message("System", "Latest Hematite reply copied to clipboard.");
3803 } else {
3804 app.push_message("System", "No Hematite reply is available to copy yet.");
3805 }
3806 app.history_idx = None;
3807 continue;
3808 }
3809 "/copy-clean" => {
3810 app.copy_clean_transcript_to_clipboard();
3811 app.push_message("System", "Clean chat transcript copied to clipboard (skips help/debug boilerplate).");
3812 app.history_idx = None;
3813 continue;
3814 }
3815 "/copy2" => {
3816 app.copy_specular_to_clipboard();
3817 app.push_message("System", "SPECULAR log copied to clipboard (reasoning + events).");
3818 app.history_idx = None;
3819 continue;
3820 }
3821 "/voice" => {
3822 use crate::ui::voice::VOICE_LIST;
3823 if let Some(arg) = parts.get(1) {
3824 if let Ok(n) = arg.parse::<usize>() {
3826 let idx = n.saturating_sub(1);
3827 if let Some(&(id, label)) = VOICE_LIST.get(idx) {
3828 app.voice_manager.set_voice(id);
3829 let _ = crate::agent::config::set_voice(id);
3830 app.push_message("System", &format!("Voice set to {} — {}", id, label));
3831 } else {
3832 app.push_message("System", &format!("Invalid voice number. Use /voice to list voices (1–{}).", VOICE_LIST.len()));
3833 }
3834 } else {
3835 if let Some(&(id, label)) = VOICE_LIST.iter().find(|&&(id, _)| id == *arg) {
3837 app.voice_manager.set_voice(id);
3838 let _ = crate::agent::config::set_voice(id);
3839 app.push_message("System", &format!("Voice set to {} — {}", id, label));
3840 } else {
3841 app.push_message("System", &format!("Unknown voice '{}'. Use /voice to list voices.", arg));
3842 }
3843 }
3844 } else {
3845 let current = app.voice_manager.current_voice_id();
3847 let mut list = format!("Available voices (current: {}):\n", current);
3848 for (i, &(id, label)) in VOICE_LIST.iter().enumerate() {
3849 let marker = if id == current.as_str() { " ◀" } else { "" };
3850 let _ = writeln!(list, " {:>2}. {}{}", i + 1, label, marker);
3851 }
3852 list.push_str("\nUse /voice N or /voice <id> to select.");
3853 app.push_message("System", &list);
3854 }
3855 app.history_idx = None;
3856 continue;
3857 }
3858 "/read" => {
3859 let text = parts[1..].join(" ");
3860 if text.is_empty() {
3861 app.push_message("System", "Usage: /read <text to speak>");
3862 } else if !app.voice_manager.is_available() {
3863 app.push_message("System", "Voice is not available in this build. Use a packaged release for baked-in voice.");
3864 } else if !app.voice_manager.is_enabled() {
3865 app.push_message("System", "Voice is off. Press Ctrl+T to enable, then /read again.");
3866 } else {
3867 app.push_message("System", &format!("Reading {} words aloud. ESC to stop.", text.split_whitespace().count()));
3868 app.voice_manager.speak(text.clone());
3869 }
3870 app.history_idx = None;
3871 continue;
3872 }
3873 "/new" => {
3874 reset_visible_session_state(&mut app);
3875 app.push_message("You", "/new");
3876 app.agent_running = true;
3877 app.clear_pending_attachments();
3878 let _ = app.user_input_tx.try_send(UserTurn::text("/new"));
3879 app.history_idx = None;
3880 continue;
3881 }
3882 "/forget" => {
3883 app.cancel_token.store(true, std::sync::atomic::Ordering::SeqCst);
3885 reset_visible_session_state(&mut app);
3886 app.push_message("You", "/forget");
3887 app.agent_running = true;
3888 app.cancel_token.store(false, std::sync::atomic::Ordering::SeqCst);
3889 app.clear_pending_attachments();
3890 let _ = app.user_input_tx.try_send(UserTurn::text("/forget"));
3891 app.history_idx = None;
3892 continue;
3893 }
3894 "/gemma-native" => {
3895 let sub = parts.get(1).copied().unwrap_or("status").to_ascii_lowercase();
3896 let gemma_detected = crate::agent::inference::is_hematite_native_model(&app.model_id);
3897 match sub.as_str() {
3898 "auto" => {
3899 match crate::agent::config::set_gemma_native_mode("auto") {
3900 Ok(_) => {
3901 if gemma_detected {
3902 app.push_message("System", "Gemma Native Formatting: AUTO. Gemma 4 will use native formatting automatically on the next turn.");
3903 } else {
3904 app.push_message("System", "Gemma Native Formatting: AUTO in settings. It will activate automatically when a Gemma 4 model is loaded.");
3905 }
3906 }
3907 Err(e) => app.push_message("System", &format!("Failed to update settings: {}", e)),
3908 }
3909 }
3910 "on" => {
3911 match crate::agent::config::set_gemma_native_mode("on") {
3912 Ok(_) => {
3913 if gemma_detected {
3914 app.push_message("System", "Gemma Native Formatting: ON (forced). It will apply on the next turn.");
3915 } else {
3916 app.push_message("System", "Gemma Native Formatting: ON (forced) in settings. It will activate only when a Gemma 4 model is loaded.");
3917 }
3918 }
3919 Err(e) => app.push_message("System", &format!("Failed to update settings: {}", e)),
3920 }
3921 }
3922 "off" => {
3923 match crate::agent::config::set_gemma_native_mode("off") {
3924 Ok(_) => app.push_message("System", "Gemma Native Formatting: OFF."),
3925 Err(e) => app.push_message("System", &format!("Failed to update settings: {}", e)),
3926 }
3927 }
3928 _ => {
3929 let config = crate::agent::config::load_config();
3930 let mode = crate::agent::config::gemma_native_mode_label(&config, &app.model_id);
3931 let enabled = match mode {
3932 "on" => "ON (forced)",
3933 "auto" => "ON (auto)",
3934 "off" => "OFF",
3935 _ => "INACTIVE",
3936 };
3937 let model_note = if gemma_detected {
3938 "Gemma 4 detected."
3939 } else {
3940 "Current model is not Gemma 4."
3941 };
3942 app.push_message(
3943 "System",
3944 &format!(
3945 "Gemma Native Formatting: {}. {} Usage: /gemma-native auto | on | off | status",
3946 enabled, model_note
3947 ),
3948 );
3949 }
3950 }
3951 app.history_idx = None;
3952 continue;
3953 }
3954 "/chat" => {
3955 app.workflow_mode = "CHAT".into();
3956 app.push_message("System", "Chat mode — natural conversation, no agent scaffolding. Use /agent to return to the full harness, or /ask, /architect, or /code to jump straight into a narrower workflow.");
3957 app.history_idx = None;
3958 let _ = app.user_input_tx.try_send(UserTurn::text("/chat"));
3959 continue;
3960 }
3961 "/reroll" => {
3962 app.history_idx = None;
3963 let _ = app.user_input_tx.try_send(UserTurn::text("/reroll"));
3964 continue;
3965 }
3966 "/agent" => {
3967 app.workflow_mode = "AUTO".into();
3968 app.push_message("System", "Agent mode — full coding harness and workstation assistant active. Use /auto for normal behavior, /ask for read-only analysis, /architect for plan-first work, /code for implementation, or /chat for clean conversation.");
3969 app.history_idx = None;
3970 let _ = app.user_input_tx.try_send(UserTurn::text("/agent"));
3971 continue;
3972 }
3973 "/implement-plan" => {
3974 app.workflow_mode = "CODE".into();
3975 app.push_message("You", "/implement-plan");
3976 app.agent_running = true;
3977 let _ = app.user_input_tx.try_send(UserTurn::text("/implement-plan"));
3978 app.history_idx = None;
3979 continue;
3980 }
3981 "/ask" | "/code" | "/architect" | "/read-only" | "/auto" | "/teach" => {
3982 let label = match cmd.as_str() {
3983 "/ask" => "ASK",
3984 "/code" => "CODE",
3985 "/architect" => "ARCHITECT",
3986 "/read-only" => "READ-ONLY",
3987 "/teach" => "TEACH",
3988 _ => "AUTO",
3989 };
3990 app.workflow_mode = label.to_string();
3991 let outbound = input_text.trim().to_string();
3992 app.push_message("You", &outbound);
3993 app.agent_running = true;
3994 let _ = app.user_input_tx.try_send(UserTurn::text(outbound));
3995 app.history_idx = None;
3996 continue;
3997 }
3998 "/worktree" => {
3999 let sub = parts.get(1).copied().unwrap_or("");
4000 match sub {
4001 "list" => {
4002 app.push_message("You", "/worktree list");
4003 app.agent_running = true;
4004 let _ = app.user_input_tx.try_send(UserTurn::text(
4005 "Call git_worktree with action=list"
4006 ));
4007 }
4008 "add" => {
4009 let wt_path = parts.get(2).copied().unwrap_or("");
4010 let wt_branch = parts.get(3).copied().unwrap_or("");
4011 if wt_path.is_empty() {
4012 app.push_message("System", "Usage: /worktree add <path> [branch]");
4013 } else {
4014 app.push_message("You", &format!("/worktree add {wt_path}"));
4015 app.agent_running = true;
4016 let directive = if wt_branch.is_empty() {
4017 format!("Call git_worktree with action=add path={wt_path}")
4018 } else {
4019 format!("Call git_worktree with action=add path={wt_path} branch={wt_branch}")
4020 };
4021 let _ = app.user_input_tx.try_send(UserTurn::text(directive));
4022 }
4023 }
4024 "remove" => {
4025 let wt_path = parts.get(2).copied().unwrap_or("");
4026 if wt_path.is_empty() {
4027 app.push_message("System", "Usage: /worktree remove <path>");
4028 } else {
4029 app.push_message("You", &format!("/worktree remove {wt_path}"));
4030 app.agent_running = true;
4031 let _ = app.user_input_tx.try_send(UserTurn::text(
4032 format!("Call git_worktree with action=remove path={wt_path}")
4033 ));
4034 }
4035 }
4036 "prune" => {
4037 app.push_message("You", "/worktree prune");
4038 app.agent_running = true;
4039 let _ = app.user_input_tx.try_send(UserTurn::text(
4040 "Call git_worktree with action=prune"
4041 ));
4042 }
4043 _ => {
4044 app.push_message("System",
4045 "Usage: /worktree list | add <path> [branch] | remove <path> | prune");
4046 }
4047 }
4048 app.history_idx = None;
4049 continue;
4050 }
4051 "/think" => {
4052 app.think_mode = Some(true);
4053 app.push_message("You", "/think");
4054 app.agent_running = true;
4055 let _ = app.user_input_tx.try_send(UserTurn::text("/think"));
4056 app.history_idx = None;
4057 continue;
4058 }
4059 "/no_think" => {
4060 app.think_mode = Some(false);
4061 app.push_message("You", "/no_think");
4062 app.agent_running = true;
4063 let _ = app.user_input_tx.try_send(UserTurn::text("/no_think"));
4064 app.history_idx = None;
4065 continue;
4066 }
4067 "/lsp" => {
4068 app.push_message("You", "/lsp");
4069 app.agent_running = true;
4070 let _ = app.user_input_tx.try_send(UserTurn::text("/lsp"));
4071 app.history_idx = None;
4072 continue;
4073 }
4074 "/runtime-refresh" => {
4075 app.push_message("You", "/runtime-refresh");
4076 app.agent_running = true;
4077 let _ = app.user_input_tx.try_send(UserTurn::text("/runtime-refresh"));
4078 app.history_idx = None;
4079 continue;
4080 }
4081 "/rules" => {
4082 let sub = parts.get(1).copied().unwrap_or("status").to_ascii_lowercase();
4083 let ws_root = crate::tools::file_ops::workspace_root();
4084
4085 match sub.as_str() {
4086 "view" => {
4087 let mut combined = String::with_capacity(
4088 crate::agent::instructions::PROJECT_GUIDANCE_FILES.len() * 512,
4089 );
4090 for cand in crate::agent::instructions::PROJECT_GUIDANCE_FILES {
4091 let p = crate::agent::instructions::resolve_guidance_path(&ws_root, cand);
4092 if p.exists() {
4093 if let Ok(c) = std::fs::read_to_string(&p) {
4094 let _ = writeln!(combined, "--- [{}] ---", cand);
4095 combined.push_str(&c);
4096 combined.push_str("\n\n");
4097 }
4098 }
4099 }
4100 if combined.is_empty() {
4101 app.push_message("System", "No project guidance files found (CLAUDE.md, SKILLS.md, .hematite/rules.md, etc.).");
4102 } else {
4103 app.push_message("System", &format!("Current project guidance being injected:\n\n{}", combined));
4104 }
4105 }
4106 "edit" => {
4107 let which = parts.get(2).copied().unwrap_or("local").to_ascii_lowercase();
4108 let target_file = if which == "shared" { "rules.md" } else { "rules.local.md" };
4109 let target_path = crate::tools::file_ops::hematite_dir().join(target_file);
4110
4111 if !target_path.exists() {
4112 if let Some(parent) = target_path.parent() {
4113 let _ = std::fs::create_dir_all(parent);
4114 }
4115 let header = if which == "shared" { "# Project Rules (Shared)" } else { "# Local Guidelines (Private)" };
4116 let _ = std::fs::write(&target_path, format!("{}\n\nAdd behavioral guidelines here for the agent to follow in this workspace.\n", header));
4117 }
4118
4119 match crate::tools::file_ops::open_in_system_editor(&target_path) {
4120 Ok(_) => app.push_message("System", &format!("Opening {} in system editor...", target_path.display())),
4121 Err(e) => app.push_message("System", &format!("Failed to open editor: {}", e)),
4122 }
4123 }
4124 _ => {
4125 let mut status = "Project Guidance:\n".to_string();
4126 for cand in crate::agent::instructions::PROJECT_GUIDANCE_FILES {
4127 let p = crate::agent::instructions::resolve_guidance_path(&ws_root, cand);
4128 let icon = if p.exists() { "[v]" } else { "[ ]" };
4129 let label = crate::agent::instructions::guidance_status_label(cand);
4130 let _ = writeln!(status, " {} {:<25} {}", icon, cand, label);
4131 }
4132 status.push_str("\nUsage:\n /rules view - View combined guidance\n /rules edit - Edit personal local rules (ignored by git)\n /rules edit shared - Edit project-wide shared rules");
4133 app.push_message("System", &status);
4134 }
4135 }
4136 app.history_idx = None;
4137 continue;
4138 }
4139 "/skills" => {
4140 let workspace_root = crate::tools::file_ops::workspace_root();
4141 let config = crate::agent::config::load_config();
4142 let discovery = crate::agent::instructions::discover_agent_skills(
4143 &workspace_root,
4144 &config.trust,
4145 );
4146 let report =
4147 crate::agent::instructions::render_skills_report(&discovery);
4148 app.push_message("System", &report);
4149 app.history_idx = None;
4150 continue;
4151 }
4152 "/help" => {
4153 show_help_message(&mut app);
4154 app.history_idx = None;
4155 continue;
4156 }
4157 "/help-legacy-unused" => {
4158 app.push_message("System",
4159 "Hematite Commands:\n\
4160 /chat — (Mode) Conversation mode — clean chat, no tool noise\n\
4161 /agent — (Mode) Full coding harness + workstation mode — tools, file edits, builds, inspection\n\
4162 /reroll — (Soul) Hatch a new companion mid-session\n\
4163 /auto — (Flow) Let Hematite choose the narrowest effective workflow\n\
4164 /ask [prompt] — (Flow) Read-only analysis mode; optional inline prompt\n\
4165 /code [prompt] — (Flow) Explicit implementation mode; optional inline prompt\n\
4166 /architect [prompt] — (Flow) Plan-first mode; optional inline prompt\n\
4167 /implement-plan — (Flow) Execute the saved architect handoff in /code\n\
4168 /read-only [prompt] — (Flow) Hard read-only mode; optional inline prompt\n\
4169 /teach [prompt] — (Flow) Teacher mode; inspect machine then walk you through any admin task step-by-step\n\
4170 /new — (Reset) Fresh task context; clear chat, pins, and task files\n\
4171 /forget — (Wipe) Hard forget; purge saved memory and Vein index too\n\
4172 /vein-inspect — (Vein) Inspect indexed memory, hot files, and active room bias\n\
4173 /workspace-profile — (Profile) Show the auto-generated workspace profile\n\
4174 /rules — (Rules) View project guidance (CLAUDE.md, SKILLS.md, .hematite/rules.md)\n\
4175 /version — (Build) Show the running Hematite version\n\
4176 /about — (Info) Show author, repo, and product info\n\
4177 /vein-reset — (Vein) Wipe the RAG index; rebuilds automatically on next turn\n\
4178 /clear — (UI) Clear dialogue display only\n\
4179 /gemma-native [auto|on|off|status] — (Model) Auto/force/disable Gemma 4 native formatting\n\
4180 /provider [status|lmstudio|ollama|clear|URL] — (Model) Show or save the active provider endpoint preference\n\
4181 /runtime — (Model) Show the live runtime/provider/model/embed status and shortest fix path\n\
4182 /runtime fix — (Model) Run the shortest safe runtime recovery step now\n\
4183 /runtime-refresh — (Model) Re-read active provider model + CTX now\n\
4184 /model [status|list [available|loaded]|load <id> [--ctx N]|unload [id|current|all]|prefer <id>|clear] — (Model) Inspect, list, load, unload, or save the preferred coding model (`--ctx` uses LM Studio context length or Ollama `num_ctx`)\n\
4185 /embed [status|load <id>|unload [id|current]|prefer <id>|clear] — (Model) Inspect, load, unload, or save the preferred embed model\n\
4186 /undo — (Ghost) Revert last file change\n\
4187 /diff — (Git) Show session changes (--stat)\n\
4188 /lsp — (Logic) Start Language Servers (semantic intelligence)\n\
4189 /swarm <text> — (Swarm) Spawn parallel workers on a directive\n\
4190 /worktree <cmd> — (Isolated) Manage git worktrees (list|add|remove|prune)\n\
4191 /think — (Brain) Enable deep reasoning mode\n\
4192 /no_think — (Speed) Disable reasoning (3-5x faster responses)\n\
4193 /voice — (TTS) List all available voices\n\
4194 /voice N — (TTS) Select voice by number\n\
4195 /attach <path> — (Docs) Attach a PDF/markdown/txt file for next message\n\
4196 /attach-pick — (Docs) Open a file picker and attach a document\n\
4197 /image <path> — (Vision) Attach an image for the next message\n\
4198 /image-pick — (Vision) Open a file picker and attach an image\n\
4199 /detach — (Context) Drop pending document/image attachments\n\
4200 /copy — (Debug) Copy session transcript to clipboard\n\
4201 /copy2 — (Debug) Copy SPECULAR log to clipboard (reasoning + events)\n\
4202 \nHotkeys:\n\
4203 Ctrl+B — Toggle Brief Mode (minimal output; collapses side chrome)\n\
4204 Ctrl+P — Toggle Professional Mode (strip personality)\n\
4205 Ctrl+O — Open document picker for next-turn context\n\
4206 Ctrl+I — Open image picker for next-turn vision context\n\
4207 Ctrl+Y — Toggle Approvals Off (bypass safety approvals)\n\
4208 Ctrl+S — Quick Swarm (hardcoded bootstrap)\n\
4209 Ctrl+Z — Undo last edit\n\
4210 Ctrl+Q/C — Quit session\n\
4211 ESC — Silence current playback\n\
4212 \nStatus Legend:\n\
4213 LM — LM Studio runtime health (`LIVE`, `RECV`, `WARN`, `CEIL`, `STALE`, `BOOT`)\n\
4214 VN — Vein RAG status (`SEM`=semantic active, `FTS`=BM25 only, `--`=not indexed)\n\
4215 BUD — Total prompt-budget pressure against the live context window\n\
4216 CMP — History compaction pressure against Hematite's adaptive threshold\n\
4217 ERR — Session error count (runtime, tool, or SPECULAR failures)\n\
4218 CTX — Live context window currently reported by LM Studio\n\
4219 VOICE — Local speech output state\n\
4220 \nAssistant: Semantic Pathing (LSP), Vision Pass, Web Research, Swarm Synthesis"
4221 );
4222 app.history_idx = None;
4223 continue;
4224 }
4225 "/swarm" => {
4226 let directive = parts[1..].join(" ");
4227 if directive.is_empty() {
4228 app.push_message("System", "Usage: /swarm <directive>");
4229 } else {
4230 app.active_workers.clear(); app.push_message("Hematite", &format!("Swarm analyzing: '{}'", directive));
4232 let swarm_tx_c = swarm_tx.clone();
4233 let coord_c = swarm_coordinator.clone();
4234 let max_workers = if app.gpu_state.ratio() > 0.75 { 1 } else { 3 };
4235 app.agent_running = true;
4236 tokio::spawn(async move {
4237 let payload = format!(r#"<worker_task id="1" target="src">Research {}</worker_task>
4238<worker_task id="2" target="src">Implement {}</worker_task>
4239<worker_task id="3" target="docs">Document {}</worker_task>"#, directive, directive, directive);
4240 let tasks = crate::agent::parser::parse_master_spec(&payload);
4241 let _ = coord_c.dispatch_swarm(tasks, swarm_tx_c, max_workers).await;
4242 });
4243 }
4244 app.history_idx = None;
4245 continue;
4246 }
4247 "/provider" => {
4248 let arg_text = parts[1..].join(" ").trim().to_string();
4249 handle_provider_command(&mut app, arg_text).await;
4250 continue;
4251 }
4252 "/runtime" => {
4253 let arg_text = parts[1..].join(" ").trim().to_string();
4254 let lower = arg_text.to_ascii_lowercase();
4255 match lower.as_str() {
4256 "" | "status" => {
4257 app.push_message(
4258 "System",
4259 &format_runtime_summary(&app).await,
4260 );
4261 }
4262 "explain" => {
4263 app.push_message(
4264 "System",
4265 &format_runtime_explanation(&app).await,
4266 );
4267 }
4268 "refresh" => {
4269 let _ = app
4270 .user_input_tx
4271 .try_send(UserTurn::text(
4272 "/runtime-refresh",
4273 ));
4274 app.push_message("You", "/runtime refresh");
4275 app.agent_running = true;
4276 }
4277 "fix" => {
4278 handle_runtime_fix(&mut app).await;
4279 }
4280 _ if lower.starts_with("provider") => {
4281 let provider_arg =
4282 arg_text["provider".len()..].trim().to_string();
4283 if provider_arg.is_empty() {
4284 app.push_message(
4285 "System",
4286 "Usage: /runtime provider [status|lmstudio|ollama|clear|http://host:port/v1]",
4287 );
4288 } else {
4289 handle_provider_command(&mut app, provider_arg)
4290 .await;
4291 }
4292 }
4293 _ => {
4294 app.push_message(
4295 "System",
4296 "Usage: /runtime [status|explain|fix|refresh|provider ...]",
4297 );
4298 }
4299 }
4300 app.history_idx = None;
4301 continue;
4302 }
4303 "/model" | "/embed" => {
4304 let outbound = input_text.clone();
4305 app.push_message("You", &outbound);
4306 app.agent_running = true;
4307 app.stop_requested = false;
4308 app.cancel_token.store(
4309 false,
4310 std::sync::atomic::Ordering::SeqCst,
4311 );
4312 app.last_reasoning.clear();
4313 app.manual_scroll_offset = None;
4314 app.specular_auto_scroll = true;
4315 let _ = app
4316 .user_input_tx
4317 .try_send(UserTurn::text(outbound));
4318 app.history_idx = None;
4319 continue;
4320 }
4321 "/version" => {
4322 app.push_message(
4323 "System",
4324 &crate::hematite_version_report(),
4325 );
4326 app.history_idx = None;
4327 continue;
4328 }
4329 "/about" => {
4330 app.push_message(
4331 "System",
4332 &crate::hematite_about_report(),
4333 );
4334 app.history_idx = None;
4335 continue;
4336 }
4337 "/explain" => {
4338 let error_text = parts[1..].join(" ");
4339 if error_text.trim().is_empty() {
4340 app.push_message("System", "Usage: /explain <error message or text>\n\nPaste any error, warning, or confusing message and Hematite will explain it in plain English — what it means, why it happened, and what to do about it.");
4341 } else {
4342 let framed = format!(
4343 "The user pasted the following error or message and needs a plain-English explanation. \
4344 Explain what this means, why it happened, and what to do about it. \
4345 Use simple, non-technical language. Avoid jargon. \
4346 Structure your response as:\n\
4347 1. What happened (one sentence)\n\
4348 2. Why it happened\n\
4349 3. How to fix it (step by step)\n\
4350 4. How to prevent it next time (optional, if relevant)\n\n\
4351 Error/message to explain:\n```\n{}\n```",
4352 error_text
4353 );
4354 app.push_message("You", &format!("/explain {}", error_text));
4355 app.agent_running = true;
4356 let _ = app.user_input_tx.try_send(UserTurn::text(framed));
4357 }
4358 app.history_idx = None;
4359 continue;
4360 }
4361 "/health" | "/triage" | "/fix" | "/inspect" => {
4362 app.push_message("You", &input_text);
4363 app.agent_running = true;
4364 let _ = app.user_input_tx.try_send(UserTurn::text(input_text.clone()));
4365 app.history_idx = None;
4366 continue;
4367 }
4368 "/diagnose" => {
4369 app.push_message("You", "/diagnose");
4370 app.push_message("System", "Running health triage...");
4371 let health_args = serde_json::json!({"topic": "health_report"});
4372 let health_output = crate::tools::host_inspect::inspect_host(&health_args)
4373 .await
4374 .unwrap_or_else(|e| format!("Error: {}", e));
4375 let follow_ups = crate::agent::diagnose::triage_follow_up_topics(&health_output);
4376 let n = follow_ups.len();
4377 if n > 0 {
4378 app.push_message("System", &format!(
4379 "Triage complete — {} area(s) flagged. Handing off to agent for deep investigation...",
4380 n
4381 ));
4382 } else {
4383 app.push_message("System", "Triage complete — machine looks healthy. Confirming with agent...");
4384 }
4385 let instruction = crate::agent::diagnose::build_diagnose_instruction(
4386 &health_output,
4387 &follow_ups,
4388 );
4389 app.agent_running = true;
4390 let _ = app.user_input_tx.try_send(UserTurn::text(instruction));
4391 app.history_idx = None;
4392 continue;
4393 }
4394 "/export" => {
4395 let fmt = parts.get(1).copied().unwrap_or("md").to_ascii_lowercase();
4396 let label = match fmt.as_str() {
4397 "json" => "JSON",
4398 "html" => "HTML",
4399 _ => "Markdown",
4400 };
4401 app.push_message("System", &format!(
4402 "Generating diagnostic report ({}) — scanning 6 topics...", label
4403 ));
4404 let path = match fmt.as_str() {
4405 "json" => {
4406 let (_, p) = crate::agent::report_export::save_report_json().await;
4407 p
4408 }
4409 "html" => {
4410 let (_, p) = crate::agent::report_export::save_report_html().await;
4411 p
4412 }
4413 _ => {
4414 let (_, p) = crate::agent::report_export::save_report_markdown().await;
4415 p
4416 }
4417 };
4418 let path_str = path.display().to_string();
4419 copy_text_to_clipboard(&path_str);
4420 app.push_message("System", &format!(
4421 "Report saved: {}\n(Path copied to clipboard — open in browser or share with your team)",
4422 path_str
4423 ));
4424 app.history_idx = None;
4425 continue;
4426 }
4427 "/save-html" => {
4428 let title = parts[1..].join(" ");
4429 let last_response = app.messages_raw.iter().rev()
4431 .find(|(speaker, _)| speaker == "Hematite")
4432 .map(|(_, content)| content.clone());
4433 match last_response {
4434 None => {
4435 app.push_message("System", "No Hematite response found in this session to save.");
4436 }
4437 Some(body) => {
4438 let (_, path) = crate::agent::report_export::save_research_html(&title, &body);
4439 let path_str = path.display().to_string();
4440 copy_text_to_clipboard(&path_str);
4441 app.push_message("System", &format!(
4442 "Saved: {}\n(Path copied to clipboard)",
4443 path_str
4444 ));
4445 #[cfg(target_os = "windows")]
4446 { let s = path.to_string_lossy().into_owned(); let _ = std::process::Command::new("cmd").args(["/c", "start", "", &s]).spawn(); }
4447 #[cfg(not(target_os = "windows"))]
4448 { let opener = if cfg!(target_os = "macos") { "open" } else { "xdg-open" }; let _ = std::process::Command::new(opener).arg(&path).spawn(); }
4449 }
4450 }
4451 app.history_idx = None;
4452 continue;
4453 }
4454 "/detach" => {
4455 app.clear_pending_attachments();
4456 app.push_message("System", "Cleared pending document/image attachments for the next turn.");
4457 app.history_idx = None;
4458 continue;
4459 }
4460 "/attach" => {
4461 let file_path = parts[1..].join(" ").trim().to_string();
4462 if file_path.is_empty() {
4463 app.push_message("System", "Usage: /attach <path> - attach a file (PDF, markdown, txt) as context for the next message.\nPDF parsing is best-effort for single-binary portability; scanned/image-only or oddly encoded PDFs may fail.\nUse /attach-pick for a file dialog. Drop reference docs in .hematite/docs/ to have them indexed permanently.");
4464 app.history_idx = None;
4465 continue;
4466 }
4467 if file_path.is_empty() {
4468 app.push_message("System", "Usage: /attach <path> — attach a file (PDF, markdown, txt) as context for the next message.\nUse /attach-pick for a file dialog. Drop reference docs in .hematite/docs/ to have them indexed permanently.");
4469 } else {
4470 let p = std::path::Path::new(&file_path);
4471 match crate::memory::vein::extract_document_text(p) {
4472 Ok(text) => {
4473 let name = p.file_name()
4474 .and_then(|n| n.to_str())
4475 .unwrap_or(&file_path)
4476 .to_string();
4477 let preview_len = text.len().min(200);
4478 app.push_message("System", &format!(
4479 "Attached: {} ({} chars) — will be injected as context on your next message.\nPreview: {}...",
4480 name, text.len(), &text[..preview_len]
4481 ));
4482 app.attached_context = Some((name, text));
4483 }
4484 Err(e) => {
4485 app.push_message("System", &format!("Attach failed: {}", e));
4486 }
4487 }
4488 }
4489 app.history_idx = None;
4490 continue;
4491 }
4492 "/attach-pick" => {
4493 match pick_attachment_path(AttachmentPickerKind::Document) {
4494 Ok(Some(path)) => attach_document_from_path(&mut app, &path),
4495 Ok(None) => app.push_message("System", "Document picker cancelled."),
4496 Err(e) => app.push_message("System", &e),
4497 }
4498 app.history_idx = None;
4499 continue;
4500 }
4501 "/image" => {
4502 let file_path = parts[1..].join(" ").trim().to_string();
4503 if file_path.is_empty() {
4504 app.push_message("System", "Usage: /image <path> - attach an image (PNG/JPG/GIF/WebP) for the next message.\nUse /image-pick for a file dialog.");
4505 } else {
4506 attach_image_from_path(&mut app, &file_path);
4507 }
4508 app.history_idx = None;
4509 continue;
4510 }
4511 "/image-pick" => {
4512 match pick_attachment_path(AttachmentPickerKind::Image) {
4513 Ok(Some(path)) => attach_image_from_path(&mut app, &path),
4514 Ok(None) => app.push_message("System", "Image picker cancelled."),
4515 Err(e) => app.push_message("System", &e),
4516 }
4517 app.history_idx = None;
4518 continue;
4519 }
4520 _ => {
4521 app.push_message("System", &format!("Unknown command: {}", cmd));
4522 app.history_idx = None;
4523 continue;
4524 }
4525 }
4526 }
4527
4528 if app.input_history.last().map(|s| s.as_str()) != Some(&input_text) {
4530 app.input_history.push(input_text.clone());
4531 if app.input_history.len() > 50 {
4532 app.input_history.remove(0);
4533 }
4534 }
4535 app.history_idx = None;
4536 app.clear_grounded_recovery_cache();
4537 app.push_message("You", &input_text);
4538 app.agent_running = true;
4539 app.stop_requested = false;
4540 app.cancel_token.store(false, std::sync::atomic::Ordering::SeqCst);
4541 app.last_reasoning.clear();
4542 app.manual_scroll_offset = None;
4543 app.specular_auto_scroll = true;
4544 let tx = app.user_input_tx.clone();
4545 let outbound = UserTurn {
4546 text: input_text,
4547 attached_document: app.attached_context.take().map(|(name, content)| {
4548 AttachedDocument { name, content }
4549 }),
4550 attached_image: app.attached_image.take(),
4551 };
4552 tokio::spawn(async move {
4553 let _ = tx.send(outbound).await;
4554 });
4555 }
4556 }
4557 _ => {}
4558 }
4559 }
4560 Some(Ok(Event::Paste(content)))
4561 if !try_attach_from_paste(&mut app, &content) =>
4562 {
4563 let normalized = content.replace("\r\n", " ").replace('\n', " ");
4566 app.input.push_str(&normalized);
4567 app.last_input_time = Instant::now();
4568 }
4569 _ => {}
4570 }
4571 }
4572
4573 Some(specular_evt) = specular_rx.recv() => {
4575 match specular_evt {
4576 SpecularEvent::SyntaxError { path, details } => {
4577 app.record_error();
4578 app.specular_logs.push(format!("ERROR: {:?}", path));
4579 trim_vec(&mut app.specular_logs, 20);
4580
4581 let user_idle = {
4583 let lock = last_interaction.lock().unwrap();
4584 lock.elapsed() > std::time::Duration::from_secs(3)
4585 };
4586 if user_idle && !app.agent_running {
4587 app.agent_running = true;
4588 let tx = app.user_input_tx.clone();
4589 let diag = details.clone();
4590 tokio::spawn(async move {
4591 let msg = format!(
4592 "<specular-build-fail>\n{}\n</specular-build-fail>\n\
4593 Fix the compiler error above.",
4594 diag
4595 );
4596 let _ = tx.send(UserTurn::text(msg)).await;
4597 });
4598 }
4599 }
4600 SpecularEvent::FileChanged(path) => {
4601 app.stats.wisdom += 1;
4602 app.stats.patience = (app.stats.patience - 0.5).max(0.0);
4603 if app.stats.patience < 50.0 && !app.brief_mode {
4604 app.brief_mode = true;
4605 app.push_message("System", "Context saturation high — Brief Mode auto-enabled.");
4606 }
4607 let path_str = path.to_string_lossy().to_string();
4608 app.specular_logs.push(format!("INDEX: {}", path_str));
4609 app.push_context_file(path_str, "Active".into());
4610 trim_vec(&mut app.specular_logs, 20);
4611 }
4612 }
4613 }
4614
4615 Some(event) = agent_rx.recv() => {
4617 use crate::agent::inference::InferenceEvent;
4618 match event {
4619 InferenceEvent::Thought(content) => {
4620 if app.stop_requested {
4621 continue;
4622 }
4623 app.thinking = true;
4624 app.current_thought.push_str(&content);
4625 }
4626 InferenceEvent::VoiceStatus(msg) => {
4627 if app.stop_requested {
4628 continue;
4629 }
4630 app.push_message("System", &msg);
4631 }
4632 InferenceEvent::Token(ref token) | InferenceEvent::MutedToken(ref token) => {
4633 if app.stop_requested {
4634 continue;
4635 }
4636 let is_muted = matches!(event, InferenceEvent::MutedToken(_));
4637 app.thinking = false;
4638 if app.messages_raw.last().map(|(s, _)| s.as_str()) != Some("Hematite") {
4639 app.push_message("Hematite", "");
4640 }
4641 app.update_last_message(token);
4642 app.manual_scroll_offset = None;
4643
4644 if !is_muted && app.voice_manager.is_enabled() && !app.cancel_token.load(std::sync::atomic::Ordering::SeqCst) {
4646 app.voice_manager.speak(token.clone());
4647 }
4648 }
4649 InferenceEvent::ToolCallStart { id, name, args } => {
4650 if app.stop_requested {
4651 continue;
4652 }
4653 app.tool_started_at.insert(id, Instant::now());
4654 if app.workflow_mode != "CHAT" {
4656 let display = format!("( ) {} {}", name, args);
4657 app.push_message("Tool", &display);
4658 }
4659 app.active_context.push(ContextFile {
4661 path: name.clone(),
4662 size: 0,
4663 status: "Running".into()
4664 });
4665 trim_vec_context(&mut app.active_context, 8);
4666 app.manual_scroll_offset = None;
4667 }
4668 InferenceEvent::ToolCallResult { id, name, result, is_error } => {
4669 if app.stop_requested {
4670 continue;
4671 }
4672 if should_capture_grounded_tool_output(&name, is_error) {
4673 app.recent_grounded_results.push((name.clone(), result.clone()));
4674 if app.recent_grounded_results.len() > 4 {
4675 app.recent_grounded_results.remove(0);
4676 }
4677 }
4678 let icon = if is_error { "[x]" } else { "[v]" };
4679 let elapsed_chip = app
4680 .tool_started_at
4681 .remove(&id)
4682 .map(|started| format_tool_elapsed(started.elapsed()));
4683 if is_error {
4684 app.record_error();
4685 }
4686 let preview = first_n_chars(&result, 100);
4689 if app.workflow_mode != "CHAT" {
4690 let display = if let Some(elapsed) = elapsed_chip.as_deref() {
4691 format!("{} {} [{}] ? {}", icon, name, elapsed, preview)
4692 } else {
4693 format!("{} {} ? {}", icon, name, preview)
4694 };
4695 app.push_message("Tool", &display);
4696 } else if is_error {
4697 app.push_message("System", &format!("Tool error: {}", preview));
4698 }
4699
4700 app.active_context.retain(|f| f.path != name || f.status != "Running");
4705 app.manual_scroll_offset = None;
4706 }
4707 InferenceEvent::ApprovalRequired { id: _, name, display, diff, mutation_label, responder } => {
4708 if app.stop_requested {
4709 let _ = responder.send(false);
4710 continue;
4711 }
4712 if app.auto_approve_session {
4714 if let Some(ref diff) = diff {
4715 let added = diff.lines().filter(|l| l.starts_with("+ ")).count();
4716 let removed = diff.lines().filter(|l| l.starts_with("- ")).count();
4717 app.push_message("System", &format!(
4718 "Auto-approved: {} +{} -{}", display, added, removed
4719 ));
4720 } else {
4721 app.push_message("System", &format!("Auto-approved: {}", display));
4722 }
4723 let _ = responder.send(true);
4724 continue;
4725 }
4726 let is_diff = diff.is_some();
4727 app.awaiting_approval = Some(PendingApproval {
4728 display: display.clone(),
4729 tool_name: name,
4730 diff,
4731 diff_scroll: 0,
4732 mutation_label,
4733 responder,
4734 });
4735 if is_diff {
4736 app.push_message("System", "[~] Diff preview — [Y] Apply [N] Skip [A] Accept All");
4737 } else {
4738 app.push_message("System", "[!] Approval required — [Y] Approve [N] Decline [A] Accept All");
4739 app.push_message("System", &format!("Command: {}", display));
4740 }
4741 }
4742 InferenceEvent::TurnTiming { context_prep_ms, inference_ms, execution_ms } => {
4743 app.specular_logs.push(format!(
4744 "PROFILE: Prep {}ms | Eval {}ms | Exec {}ms",
4745 context_prep_ms, inference_ms, execution_ms
4746 ));
4747 trim_vec(&mut app.specular_logs, 20);
4748 }
4749 InferenceEvent::UsageUpdate(usage) => {
4750 app.total_tokens = usage.total_tokens;
4751 let turn_cost = crate::agent::pricing::calculate_cost(&usage, &app.model_id);
4753 app.current_session_cost += turn_cost;
4754 }
4755 InferenceEvent::Done => {
4756 app.thinking = false;
4757 app.agent_running = false;
4758 app.stop_requested = false;
4759 app.task_start_time = None;
4760 if app.voice_manager.is_enabled() {
4761 app.voice_manager.flush();
4762 }
4763 if !app.current_thought.is_empty() {
4764 app.last_reasoning = app.current_thought.clone();
4765 }
4766 app.current_thought.clear();
4767 app.rebuild_formatted_messages();
4771 app.manual_scroll_offset = None;
4772 app.specular_auto_scroll = true;
4773 app.active_workers.remove("AGENT");
4775 app.worker_labels.remove("AGENT");
4776 }
4777 InferenceEvent::CopyDiveInCommand(path) => {
4778 let command = format!("cd \"{}\" && hematite", path.replace('\\', "/"));
4779 copy_text_to_clipboard(&command);
4780 spawn_dive_in_terminal(&path);
4781 app.push_message("System", &format!("Teleportation initiated: New terminal launched at {}", path));
4782 app.push_message("System", "Teleportation complete. Closing original session to maintain workstation hygiene...");
4783
4784 app.write_session_report();
4786 app.copy_transcript_to_clipboard();
4787 break;
4788 }
4789 InferenceEvent::ChainImplementPlan => {
4790 app.push_message("You", "/implement-plan (Autonomous Handoff)");
4791 app.manual_scroll_offset = None;
4792 }
4793 InferenceEvent::Error(e) => {
4794 app.record_error();
4795 app.thinking = false;
4796 app.agent_running = false;
4797 app.task_start_time = None;
4798 if app.voice_manager.is_enabled() {
4799 app.voice_manager.flush();
4800 }
4801 app.push_message("System", &format!("Error: {e}"));
4802 }
4803 InferenceEvent::ProviderStatus { state, summary } => {
4804 app.provider_state = state;
4805 if !summary.trim().is_empty() && app.last_provider_summary != summary {
4806 app.specular_logs.push(format!("PROVIDER: {}", summary));
4807 trim_vec(&mut app.specular_logs, 20);
4808 app.last_provider_summary = summary;
4809 }
4810 }
4811 InferenceEvent::McpStatus { state, summary } => {
4812 app.mcp_state = state;
4813 if !summary.trim().is_empty() && app.last_mcp_summary != summary {
4814 app.specular_logs.push(format!("MCP: {}", summary));
4815 trim_vec(&mut app.specular_logs, 20);
4816 app.last_mcp_summary = summary;
4817 }
4818 }
4819 InferenceEvent::OperatorCheckpoint { state, summary } => {
4820 app.last_operator_checkpoint_state = state;
4821 if state == OperatorCheckpointState::Idle {
4822 app.last_operator_checkpoint_summary.clear();
4823 } else if !summary.trim().is_empty()
4824 && app.last_operator_checkpoint_summary != summary
4825 {
4826 app.specular_logs.push(format!(
4827 "STATE: {} - {}",
4828 state.label(),
4829 summary
4830 ));
4831 trim_vec(&mut app.specular_logs, 20);
4832 app.last_operator_checkpoint_summary = summary;
4833 }
4834 }
4835 InferenceEvent::RecoveryRecipe { summary } => {
4836 if !summary.trim().is_empty()
4837 && app.last_recovery_recipe_summary != summary
4838 {
4839 app.specular_logs.push(format!("RECOVERY: {}", summary));
4840 trim_vec(&mut app.specular_logs, 20);
4841 app.last_recovery_recipe_summary = summary;
4842 }
4843 }
4844 InferenceEvent::CompactionPressure {
4845 estimated_tokens,
4846 threshold_tokens,
4847 percent,
4848 } => {
4849 app.compaction_estimated_tokens = estimated_tokens;
4850 app.compaction_threshold_tokens = threshold_tokens;
4851 app.compaction_percent = percent;
4852 if percent < 60 {
4856 app.compaction_warned_level = 0;
4857 } else if percent >= 90 && app.compaction_warned_level < 90 {
4858 app.compaction_warned_level = 90;
4859 app.push_message(
4860 "System",
4861 "Context is 90% full. Run /compact to summarize history in place, /new to reset (preserves project memory), or /forget to wipe everything.",
4862 );
4863 } else if percent >= 70 && app.compaction_warned_level < 70 {
4864 app.compaction_warned_level = 70;
4865 app.push_message(
4866 "System",
4867 &format!("Context at {}% — approaching compaction threshold. Run /compact to summarize history and free space.", percent),
4868 );
4869 }
4870 }
4871 InferenceEvent::PromptPressure {
4872 estimated_input_tokens,
4873 reserved_output_tokens,
4874 estimated_total_tokens,
4875 context_length: _,
4876 percent,
4877 } => {
4878 app.prompt_estimated_input_tokens = estimated_input_tokens;
4879 app.prompt_reserved_output_tokens = reserved_output_tokens;
4880 app.prompt_estimated_total_tokens = estimated_total_tokens;
4881 app.prompt_pressure_percent = percent;
4882 }
4883 InferenceEvent::TaskProgress { id, label, progress } => {
4884 let nid = normalize_id(&id);
4885 app.active_workers.insert(nid.clone(), progress);
4886 app.worker_labels.insert(nid, label);
4887 }
4888 InferenceEvent::RuntimeProfile {
4889 provider_name,
4890 endpoint,
4891 model_id,
4892 context_length,
4893 } => {
4894 let was_no_model = app.model_id == "no model loaded";
4895 let now_no_model = model_id == "no model loaded";
4896 let changed = app.model_id != "detecting..."
4897 && (app.model_id != model_id || app.context_length != context_length);
4898 let provider_changed = app.provider_name != provider_name;
4899 app.provider_name = provider_name.clone();
4900 app.provider_endpoint = endpoint.clone();
4901 app.model_id = model_id.clone();
4902 app.context_length = context_length;
4903 app.last_runtime_profile_time = Instant::now();
4904 if app.provider_state == ProviderRuntimeState::Booting {
4905 app.provider_state = ProviderRuntimeState::Live;
4906 }
4907 if now_no_model && !was_no_model {
4908 let mut guidance = if provider_name == "Ollama" {
4909 "No coding model is currently available from Ollama. Pull or load a chat model in Ollama, then keep `api_url` pointed at `http://localhost:11434/v1`. If you also want semantic search, set `/embed prefer <id>` to an Ollama embedding model.".to_string()
4910 } else {
4911 "No coding model loaded. Load a model in LM Studio (e.g. Qwen/Qwen3.5-9B Q4_K_M) and start the server on port 1234. Optionally also load an embedding model for semantic search.".to_string()
4912 };
4913 if let Some((alt_name, alt_url)) =
4914 crate::runtime::detect_alternative_provider(&provider_name).await
4915 {
4916 let _ = write!(guidance,
4917 " Reachable alternative detected: {} ({}). Use `/provider {}` and restart Hematite if you want to switch.",
4918 alt_name,
4919 alt_url,
4920 alt_name.to_ascii_lowercase().replace(' ', "")
4921 );
4922 }
4923 app.push_message("System", &guidance);
4924 } else if provider_changed && !now_no_model {
4925 app.push_message(
4926 "System",
4927 &format!(
4928 "Provider detected: {} | Model {} | CTX {}",
4929 provider_name, model_id, context_length
4930 ),
4931 );
4932 } else if changed && !now_no_model {
4933 app.push_message(
4934 "System",
4935 &format!(
4936 "Runtime profile refreshed: {} | Model {} | CTX {}",
4937 provider_name, model_id, context_length
4938 ),
4939 );
4940 }
4941 }
4942 InferenceEvent::EmbedProfile { model_id } => {
4943 let changed = app.embed_model_id != model_id;
4944 app.embed_model_id = model_id.clone();
4945 if changed {
4946 match model_id {
4947 Some(id) => app.push_message(
4948 "System",
4949 &format!("Embed model loaded: {} (semantic search ready)", id),
4950 ),
4951 None => app.push_message(
4952 "System",
4953 "Embed model unloaded. Semantic search inactive.",
4954 ),
4955 }
4956 }
4957 }
4958 InferenceEvent::VeinStatus { file_count, embedded_count, docs_only } => {
4959 app.vein_file_count = file_count;
4960 app.vein_embedded_count = embedded_count;
4961 app.vein_docs_only = docs_only;
4962 }
4963 InferenceEvent::VeinContext { paths } => {
4964 app.active_context.retain(|f| f.status == "Running");
4967 for path in paths {
4968 let root = crate::tools::file_ops::workspace_root();
4969 let size = std::fs::metadata(root.join(&path))
4970 .map(|m| m.len())
4971 .unwrap_or(0);
4972 if !app.active_context.iter().any(|f| f.path == path) {
4973 app.active_context.push(ContextFile {
4974 path,
4975 size,
4976 status: "Vein".to_string(),
4977 });
4978 }
4979 }
4980 trim_vec_context(&mut app.active_context, 8);
4981 }
4982 InferenceEvent::SoulReroll { species, rarity, shiny, .. } => {
4983 let shiny_tag = if shiny { " 🌟 SHINY" } else { "" };
4984 app.soul_name = species.clone();
4985 app.push_message(
4986 "System",
4987 &format!("[{}{}] {} has awakened.", rarity, shiny_tag, species),
4988 );
4989 }
4990 InferenceEvent::ShellLine(line) => {
4991 app.current_thought.push_str(&line);
4994 app.current_thought.push('\n');
4995 }
4996 InferenceEvent::TurnBudget(budget) => {
4997 app.current_thought.push_str(&budget.render());
4999 app.current_thought.push('\n');
5000 }
5001 }
5002 }
5003
5004 Some(msg) = swarm_rx.recv() => {
5006 match msg {
5007 SwarmMessage::Progress(worker_id, progress) => {
5008 let nid = normalize_id(&worker_id);
5009 app.active_workers.insert(nid.clone(), progress);
5010 match progress {
5011 102 => app.push_message("System", &format!("Worker {} architecture verified and applied.", nid)),
5012 101 => { },
5013 100 => app.push_message("Hematite", &format!("Worker {} complete. Standing by for review...", nid)),
5014 _ => {}
5015 }
5016 }
5017 SwarmMessage::ReviewRequest { worker_id, file_path, before, after, tx } => {
5018 app.push_message("Hematite", &format!("Worker {} conflict — review required.", worker_id));
5019 app.active_review = Some(ActiveReview {
5020 worker_id,
5021 file_path: file_path.to_string_lossy().to_string(),
5022 before,
5023 after,
5024 tx,
5025 });
5026 }
5027 SwarmMessage::Done => {
5028 app.agent_running = false;
5029 app.push_message("System", "──────────────────────────────────────────────────────────");
5031 app.push_message("System", " TASK COMPLETE: Swarm Synthesis Finalized ");
5032 app.push_message("System", "──────────────────────────────────────────────────────────");
5033 }
5034 }
5035 }
5036 }
5037 }
5038 Ok(())
5039}
5040
5041fn ui(f: &mut ratatui::Frame, app: &App) {
5044 let size = f.area();
5045 if size.width < 60 || size.height < 10 {
5046 f.render_widget(Clear, size);
5048 return;
5049 }
5050
5051 let input_height = compute_input_height(f.area().width, app.input.len());
5052
5053 let chunks = Layout::default()
5054 .direction(Direction::Vertical)
5055 .constraints([
5056 Constraint::Min(0),
5057 Constraint::Length(input_height),
5058 Constraint::Length(5), ])
5060 .split(f.area());
5061
5062 let sidebar_mode = sidebar_mode(app, size.width);
5063 let sidebar_width = match sidebar_mode {
5064 SidebarMode::Hidden => 0,
5065 SidebarMode::Compact => 32,
5066 SidebarMode::Full => 45,
5067 };
5068 let top = Layout::default()
5069 .direction(Direction::Horizontal)
5070 .constraints([Constraint::Fill(1), Constraint::Length(sidebar_width)])
5071 .split(chunks[0]);
5072
5073 let mut core_lines = app.messages.clone();
5075
5076 if app.agent_running {
5078 let dots = ".".repeat((app.tick_count % 4) as usize + 1);
5079 let verb = if app.thinking { "thinking" } else { "working" };
5080 core_lines.push(Line::from(Span::styled(
5081 format!(" Hematite is {}{}", verb, dots),
5082 Style::default()
5083 .fg(Color::Magenta)
5084 .add_modifier(Modifier::DIM),
5085 )));
5086 }
5087
5088 let (heart_color, core_icon) = if app.agent_running || !app.active_workers.is_empty() {
5089 let (r_base, g_base, b_base) = if !app.active_workers.is_empty() {
5090 (0, 200, 200) } else {
5092 (200, 0, 200) };
5094
5095 let pulse = (app.tick_count % 50) as f64 / 50.0;
5096 let factor = (pulse * std::f64::consts::PI).sin().abs();
5097 let r = (r_base as f64 * factor) as u8;
5098 let g = (g_base as f64 * factor) as u8;
5099 let b = (b_base as f64 * factor) as u8;
5100
5101 (Color::Rgb(r.max(60), g.max(60), b.max(60)), "•")
5102 } else {
5103 (Color::Rgb(80, 80, 80), "•") };
5105
5106 let has_real_task = !app.current_objective.is_empty()
5109 && app.current_objective != "Idle"
5110 && app.current_objective != "Awaiting objective...";
5111
5112 let (title_prefix, title_body, title_color): (&str, String, Color) = if has_real_task {
5113 let body = if app.current_objective.len() > 30 {
5114 format!("{}...", safe_head(&app.current_objective, 27))
5115 } else {
5116 app.current_objective.clone()
5117 };
5118 ("TASK", body, Color::Yellow)
5119 } else if !app.active_workers.is_empty() {
5120 ("SWARM", "Parallel agents active".into(), Color::Cyan)
5121 } else if app.thinking {
5122 ("THINKING", String::new(), Color::Magenta)
5123 } else if app.agent_running {
5124 ("WORKING", String::new(), Color::Green)
5125 } else {
5126 ("READY", String::new(), Color::DarkGray)
5127 };
5128
5129 let title_text = if title_body.is_empty() {
5130 format!(" {} ", title_prefix)
5131 } else {
5132 format!(" {}: {} ", title_prefix, title_body)
5133 };
5134
5135 let core_title = if app.professional {
5136 Line::from(vec![
5137 Span::styled(format!(" {} ", core_icon), Style::default().fg(heart_color)),
5138 Span::styled("HEMATITE ", Style::default().add_modifier(Modifier::BOLD)),
5139 Span::styled(
5140 title_text,
5141 Style::default()
5142 .fg(title_color)
5143 .add_modifier(Modifier::ITALIC),
5144 ),
5145 ])
5146 } else {
5147 Line::from(vec![
5148 Span::styled(format!(" {} ", core_icon), Style::default().fg(heart_color)),
5149 Span::styled(title_text, Style::default().fg(title_color)),
5150 ])
5151 };
5152
5153 let avail_h = top[0].height.saturating_sub(2);
5156 let inner_w = top[0].width.saturating_sub(4).max(1);
5158
5159 let mut total_lines: u16 = 0;
5160 for line in &core_lines {
5161 let line_w = line.width() as u16;
5162 if line_w == 0 {
5163 total_lines += 1;
5164 } else {
5165 let wrapped = line_w.div_ceil(inner_w);
5169 total_lines += wrapped;
5170 }
5171 }
5172
5173 let max_scroll = total_lines.saturating_sub(avail_h);
5174 let scroll = if let Some(off) = app.manual_scroll_offset {
5175 max_scroll.saturating_sub(off)
5176 } else {
5177 max_scroll
5178 };
5179
5180 let core_para = Paragraph::new(core_lines)
5181 .block(
5182 Block::default()
5183 .title(core_title)
5184 .borders(Borders::ALL)
5185 .border_style(Style::default().fg(Color::DarkGray)),
5186 )
5187 .wrap(Wrap { trim: true });
5188
5189 f.render_widget(Clear, top[0]);
5191
5192 let chat_area = Rect::new(
5194 top[0].x + 1,
5195 top[0].y,
5196 top[0].width.saturating_sub(2).max(1),
5197 top[0].height,
5198 );
5199 f.render_widget(Clear, chat_area);
5200 f.render_widget(core_para.scroll((scroll, 0)), chat_area);
5201
5202 let mut scrollbar_state =
5205 ScrollbarState::new(max_scroll as usize + 1).position(scroll as usize);
5206 f.render_stateful_widget(
5207 Scrollbar::default()
5208 .orientation(ScrollbarOrientation::VerticalRight)
5209 .begin_symbol(Some("↑"))
5210 .end_symbol(Some("↓")),
5211 top[0],
5212 &mut scrollbar_state,
5213 );
5214
5215 if sidebar_mode == SidebarMode::Compact && top[1].width > 0 {
5217 let compact_title = if sidebar_has_live_activity(app) {
5218 " SIGNALS "
5219 } else {
5220 " SESSION "
5221 };
5222 let compact_para = Paragraph::new(build_compact_sidebar_lines(app))
5223 .wrap(Wrap { trim: true })
5224 .block(
5225 Block::default()
5226 .title(compact_title)
5227 .borders(Borders::ALL)
5228 .border_style(Style::default().fg(Color::DarkGray)),
5229 );
5230 f.render_widget(Clear, top[1]);
5231 f.render_widget(compact_para, top[1]);
5232 } else if sidebar_mode == SidebarMode::Full && top[1].width > 0 {
5233 let side = Layout::default()
5234 .direction(Direction::Vertical)
5235 .constraints([
5236 Constraint::Length(8), Constraint::Min(0), ])
5239 .split(top[1]);
5240
5241 let context_source = if app.active_context.is_empty() {
5243 default_active_context()
5244 } else {
5245 app.active_context.clone()
5246 };
5247 let mut context_display = context_source
5248 .iter()
5249 .map(|f| {
5250 let (icon, color) = match f.status.as_str() {
5251 "Running" => ("⚙️", Color::Cyan),
5252 "Dirty" => ("📝", Color::Yellow),
5253 _ => ("📄", Color::Gray),
5254 };
5255 let tokens = f.size / 4;
5257 ListItem::new(Line::from(vec![
5258 Span::styled(format!(" {} ", icon), Style::default().fg(color)),
5259 Span::styled(f.path.clone(), Style::default().fg(Color::White)),
5260 Span::styled(
5261 format!(" {}t ", tokens),
5262 Style::default().fg(Color::DarkGray),
5263 ),
5264 ]))
5265 })
5266 .collect::<Vec<ListItem>>();
5267
5268 if context_display.is_empty() {
5269 context_display = vec![ListItem::new(" (No active files)")];
5270 }
5271
5272 let ctx_title = if sidebar_has_live_activity(app) {
5273 " LIVE CONTEXT "
5274 } else {
5275 " SESSION CONTEXT "
5276 };
5277
5278 let ctx_block = Block::default()
5279 .title(ctx_title)
5280 .borders(Borders::ALL)
5281 .border_style(Style::default().fg(Color::DarkGray));
5282
5283 f.render_widget(Clear, side[0]);
5284 f.render_widget(List::new(context_display).block(ctx_block), side[0]);
5285
5286 let v_title = if app.thinking || app.agent_running {
5291 " HEMATITE SIGNALS [live] ".to_string()
5292 } else {
5293 " HEMATITE SIGNALS [watching] ".to_string()
5294 };
5295
5296 f.render_widget(Clear, side[1]);
5297
5298 let mut v_lines: Vec<Line<'static>> = Vec::with_capacity(32);
5299
5300 if app.thinking || app.agent_running {
5302 let dots = ".".repeat((app.tick_count % 4) as usize + 1);
5303 let label = if app.thinking { "REASONING" } else { "WORKING" };
5304 v_lines.push(Line::from(vec![Span::styled(
5305 format!("[ {}{} ]", label, dots),
5306 Style::default()
5307 .fg(Color::Green)
5308 .add_modifier(Modifier::BOLD),
5309 )]));
5310 let preview = {
5312 let thought = &app.current_thought;
5313 let char_count = thought.chars().count();
5314 if char_count > 300 {
5315 thought.chars().skip(char_count - 300).collect::<String>()
5316 } else {
5317 thought.clone()
5318 }
5319 };
5320 for raw in preview.lines() {
5321 let raw = raw.trim();
5322 if !raw.is_empty() {
5323 v_lines.extend(render_markdown_line(raw));
5324 }
5325 }
5326 v_lines.push(Line::raw(""));
5327 } else {
5328 v_lines.push(Line::from(vec![
5329 Span::styled("• ", Style::default().fg(Color::DarkGray)),
5330 Span::styled(
5331 "Waiting for the next turn. Runtime, MCP, and index signals stay visible here.",
5332 Style::default().fg(Color::Gray),
5333 ),
5334 ]));
5335 v_lines.push(Line::raw(""));
5336 }
5337
5338 let signal_rows = sidebar_signal_rows(app);
5339 if !signal_rows.is_empty() {
5340 let section_title = if app.thinking || app.agent_running {
5341 "-- Operator Signals --"
5342 } else {
5343 "-- Session Snapshot --"
5344 };
5345 v_lines.push(Line::from(vec![Span::styled(
5346 section_title,
5347 Style::default()
5348 .fg(Color::White)
5349 .add_modifier(Modifier::DIM),
5350 )]));
5351 for (row, color) in signal_rows
5352 .iter()
5353 .take(if app.thinking || app.agent_running {
5354 4
5355 } else {
5356 3
5357 })
5358 {
5359 v_lines.push(Line::from(vec![
5360 Span::styled("- ", Style::default().fg(Color::DarkGray)),
5361 Span::styled(row.clone(), Style::default().fg(*color)),
5362 ]));
5363 }
5364 v_lines.push(Line::raw(""));
5365 }
5366
5367 if !app.active_workers.is_empty() {
5369 v_lines.push(Line::from(vec![Span::styled(
5370 "── Task Progress ──",
5371 Style::default()
5372 .fg(Color::White)
5373 .add_modifier(Modifier::DIM),
5374 )]));
5375
5376 let mut sorted_ids: Vec<_> = app.active_workers.keys().cloned().collect();
5377 sorted_ids.sort_unstable();
5378
5379 for id in sorted_ids {
5380 let prog = app.active_workers[&id];
5381 let custom_label = app.worker_labels.get(&id).cloned();
5382
5383 let (label, color) = match prog {
5384 101..=102 => ("VERIFIED", Color::Green),
5385 100 if !app.agent_running && id != "AGENT" => ("SKIPPED ", Color::DarkGray),
5386 100 => ("REVIEW ", Color::Magenta),
5387 _ => ("WORKING ", Color::Yellow),
5388 };
5389
5390 let display_label = custom_label.unwrap_or_else(|| label.to_string());
5391 let filled = (prog.min(100) / 10) as usize;
5392 let bar = "▓".repeat(filled) + &"░".repeat(10 - filled);
5393
5394 let id_prefix = if id == "AGENT" {
5395 "Agent: ".to_string()
5396 } else {
5397 format!("W{}: ", id)
5398 };
5399
5400 v_lines.push(Line::from(vec![
5401 Span::styled(id_prefix, Style::default().fg(Color::Gray)),
5402 Span::styled(bar, Style::default().fg(color)),
5403 Span::styled(
5404 format!(" {} ", display_label),
5405 Style::default().fg(color).add_modifier(Modifier::BOLD),
5406 ),
5407 Span::styled(
5408 format!("{}%", prog.min(100)),
5409 Style::default().fg(Color::DarkGray),
5410 ),
5411 ]));
5412 }
5413 v_lines.push(Line::raw(""));
5414 }
5415
5416 if (app.thinking || app.agent_running) && !app.last_reasoning.is_empty() {
5418 v_lines.push(Line::from(vec![Span::styled(
5419 "── Logic Trace ──",
5420 Style::default()
5421 .fg(Color::White)
5422 .add_modifier(Modifier::DIM),
5423 )]));
5424 for raw in app.last_reasoning.lines() {
5425 v_lines.extend(render_markdown_line(raw));
5426 }
5427 v_lines.push(Line::raw(""));
5428 }
5429
5430 if !app.specular_logs.is_empty() {
5432 v_lines.push(Line::from(vec![Span::styled(
5433 if app.thinking || app.agent_running {
5434 "── Live Events ──"
5435 } else {
5436 "── Recent Events ──"
5437 },
5438 Style::default()
5439 .fg(Color::White)
5440 .add_modifier(Modifier::DIM),
5441 )]));
5442 let recent_logs: Vec<String> = if app.thinking || app.agent_running {
5443 app.specular_logs.iter().rev().take(8).cloned().collect()
5444 } else {
5445 app.specular_logs.iter().rev().take(5).cloned().collect()
5446 };
5447 for log in recent_logs.into_iter().rev() {
5448 let (icon, color) = if log.starts_with("ERROR") {
5449 ("X ", Color::Red)
5450 } else if log.starts_with("INDEX") {
5451 ("I ", Color::Cyan)
5452 } else if log.starts_with("GHOST") {
5453 ("< ", Color::Magenta)
5454 } else {
5455 ("- ", Color::Gray)
5456 };
5457 v_lines.push(Line::from(vec![
5458 Span::styled(icon, Style::default().fg(color)),
5459 Span::styled(
5460 log,
5461 Style::default()
5462 .fg(Color::White)
5463 .add_modifier(Modifier::DIM),
5464 ),
5465 ]));
5466 }
5467 }
5468
5469 let v_total = v_lines.len() as u16;
5470 let v_avail = side[1].height.saturating_sub(2);
5471 let v_max_scroll = v_total.saturating_sub(v_avail);
5472 let v_scroll = if app.specular_auto_scroll {
5475 v_max_scroll
5476 } else {
5477 app.specular_scroll.min(v_max_scroll)
5478 };
5479
5480 let specular_para = Paragraph::new(v_lines)
5481 .wrap(Wrap { trim: true })
5482 .scroll((v_scroll, 0))
5483 .block(Block::default().title(v_title).borders(Borders::ALL));
5484
5485 f.render_widget(specular_para, side[1]);
5486
5487 let mut v_scrollbar_state =
5489 ScrollbarState::new(v_max_scroll as usize + 1).position(v_scroll as usize);
5490 f.render_stateful_widget(
5491 Scrollbar::default()
5492 .orientation(ScrollbarOrientation::VerticalRight)
5493 .begin_symbol(None)
5494 .end_symbol(None),
5495 side[1],
5496 &mut v_scrollbar_state,
5497 );
5498 }
5499
5500 let vigil_badge = if app.brief_mode { " VIGIL" } else { "" };
5502 let yolo_badge = if app.yolo_mode { " YOLO" } else { "" };
5503
5504 let bar_constraints = vec![Constraint::Fill(1)];
5505 let bar_chunks = Layout::default()
5506 .direction(Direction::Horizontal)
5507 .constraints(bar_constraints)
5508 .split(chunks[2]);
5509
5510 let footer_row = {
5511 let footer_row_width = bar_chunks[0].width.saturating_sub(6);
5512 if app.agent_running {
5513 let elapsed = if let Some(start) = app.task_start_time {
5514 format!(" {:0>2}s ", start.elapsed().as_secs())
5515 } else {
5516 String::new()
5517 };
5518 let last_log = app
5519 .specular_logs
5520 .last()
5521 .map(|s| s.as_str())
5522 .unwrap_or("...");
5523 let spinner = match app.tick_count % 8 {
5524 0 => "⠋",
5525 1 => "⠙",
5526 2 => "⠹",
5527 3 => "⠸",
5528 4 => "⠼",
5529 5 => "⠴",
5530 6 => "⠦",
5531 _ => "⠧",
5532 };
5533 let footer_caption = select_fitting_variant(
5534 &running_footer_variants(app, &elapsed, last_log),
5535 footer_row_width,
5536 );
5537
5538 Line::from(vec![
5539 Span::styled(
5540 format!(" {} ", spinner),
5541 Style::default()
5542 .fg(Color::Cyan)
5543 .add_modifier(Modifier::BOLD),
5544 ),
5545 Span::styled(
5546 elapsed,
5547 Style::default()
5548 .bg(Color::Rgb(40, 40, 40))
5549 .fg(Color::White)
5550 .add_modifier(Modifier::BOLD),
5551 ),
5552 Span::styled(
5553 format!(" ⬢ {}", footer_caption),
5554 Style::default().fg(Color::DarkGray),
5555 ),
5556 ])
5557 } else {
5558 let idle_hint = select_fitting_variant(&idle_footer_variants(app), footer_row_width);
5559 Line::from(vec![
5560 Span::styled(" ⬢ ", Style::default().fg(Color::Rgb(40, 40, 40))),
5561 Span::styled(
5562 idle_hint,
5563 Style::default()
5564 .fg(Color::DarkGray)
5565 .add_modifier(Modifier::DIM),
5566 ),
5567 ])
5568 }
5569 };
5570
5571 let runtime_age = app.last_runtime_profile_time.elapsed();
5572 let provider_prefix = provider_badge_prefix(&app.provider_name);
5573 let issue = runtime_issue_kind(app);
5574 let (issue_code, issue_color) = runtime_issue_badge(issue);
5575 let (lm_label, lm_color) = if issue == RuntimeIssueKind::NoModel {
5576 (format!("{provider_prefix}:NONE"), Color::Red)
5577 } else if issue == RuntimeIssueKind::Booting {
5578 (format!("{provider_prefix}:BOOT"), Color::DarkGray)
5579 } else if issue == RuntimeIssueKind::Recovering {
5580 (format!("{provider_prefix}:RECV"), Color::Cyan)
5581 } else if matches!(
5582 issue,
5583 RuntimeIssueKind::Connectivity | RuntimeIssueKind::EmptyResponse
5584 ) {
5585 (format!("{provider_prefix}:WARN"), Color::Red)
5586 } else if issue == RuntimeIssueKind::ContextCeiling {
5587 (format!("{provider_prefix}:CEIL"), Color::Yellow)
5588 } else if runtime_age > std::time::Duration::from_secs(120) {
5589 (format!("{provider_prefix}:STALE"), Color::Yellow)
5590 } else {
5591 (format!("{provider_prefix}:LIVE"), Color::Green)
5592 };
5593 let compaction_percent = app.compaction_percent.min(100);
5594 let compaction_label = if app.compaction_threshold_tokens == 0 {
5595 " CMP: 0%".to_string()
5596 } else {
5597 format!(" CMP:{:>3}%", compaction_percent)
5598 };
5599 let compaction_color = if app.compaction_threshold_tokens == 0 {
5600 Color::DarkGray
5601 } else if compaction_percent >= 85 {
5602 Color::Red
5603 } else if compaction_percent >= 60 {
5604 Color::Yellow
5605 } else {
5606 Color::Green
5607 };
5608 let prompt_percent = app.prompt_pressure_percent.min(100);
5609 let prompt_label = if app.prompt_estimated_total_tokens == 0 {
5610 " BUD: 0%".to_string()
5611 } else {
5612 format!(" BUD:{:>3}%", prompt_percent)
5613 };
5614 let prompt_color = if app.prompt_estimated_total_tokens == 0 {
5615 Color::DarkGray
5616 } else if prompt_percent >= 85 {
5617 Color::Red
5618 } else if prompt_percent >= 60 {
5619 Color::Yellow
5620 } else {
5621 Color::Green
5622 };
5623
5624 let think_badge = match app.think_mode {
5625 Some(true) => " [THINK]",
5626 Some(false) => " [FAST]",
5627 None => "",
5628 };
5629
5630 let vram_ratio = app.gpu_state.ratio();
5632 let vram_label = app.gpu_state.label();
5633 let gpu_name = app.gpu_state.gpu_name();
5634
5635 let (vein_label, vein_color) = if app.vein_docs_only {
5636 let color = if app.vein_embedded_count > 0 {
5637 Color::Green
5638 } else if app.vein_file_count > 0 {
5639 Color::Yellow
5640 } else {
5641 Color::DarkGray
5642 };
5643 ("VN:DOC", color)
5644 } else if app.vein_file_count == 0 {
5645 ("VN:--", Color::DarkGray)
5646 } else if app.vein_embedded_count > 0 {
5647 ("VN:SEM", Color::Green)
5648 } else {
5649 ("VN:FTS", Color::Yellow)
5650 };
5651
5652 let char_count: usize = app.messages_raw.iter().map(|(_, c)| c.len()).sum();
5653 let est_tokens = char_count / 3;
5654 let current_tokens = if app.total_tokens > 0 {
5655 app.total_tokens
5656 } else {
5657 est_tokens
5658 };
5659 let session_usage_text = format!(
5660 " TOKENS: {:0>5} | TOTAL: ${:.2} ",
5661 current_tokens, app.current_session_cost
5662 );
5663
5664 f.render_widget(Clear, bar_chunks[0]);
5666
5667 let usage_color = Color::Rgb(100, 100, 100);
5668 let ai_line = vec![
5669 Span::styled(
5670 format!(" {} ", lm_label),
5671 Style::default().fg(lm_color).add_modifier(Modifier::BOLD),
5672 ),
5673 Span::styled("║ ", Style::default().fg(Color::Rgb(60, 60, 60))),
5674 Span::styled(format!("{} ", vein_label), Style::default().fg(vein_color)),
5675 Span::styled("│ ", Style::default().fg(Color::Rgb(40, 40, 40))),
5676 Span::styled(format!("{} ", issue_code), Style::default().fg(issue_color)),
5677 Span::styled("│ ", Style::default().fg(Color::Rgb(40, 40, 40))),
5678 Span::styled(
5679 format!("CTX:{} ", app.context_length),
5680 Style::default().fg(Color::DarkGray),
5681 ),
5682 Span::styled("│ ", Style::default().fg(Color::Rgb(40, 40, 40))),
5683 Span::styled(
5684 format!("REMOTE:{} ", app.git_state.label()),
5685 Style::default().fg(Color::DarkGray),
5686 ),
5687 Span::styled("│ ", Style::default().fg(Color::Rgb(40, 40, 40))),
5688 Span::styled(prompt_label, Style::default().fg(prompt_color)),
5689 Span::styled(" ", Style::default().fg(Color::Rgb(40, 40, 40))),
5690 Span::styled(compaction_label, Style::default().fg(compaction_color)),
5691 Span::styled(
5692 format!("{} ", think_badge),
5693 Style::default().fg(Color::Cyan),
5694 ),
5695 Span::styled(
5696 vigil_badge.to_string(),
5697 Style::default()
5698 .fg(Color::Yellow)
5699 .add_modifier(Modifier::BOLD),
5700 ),
5701 Span::styled(
5702 yolo_badge.to_string(),
5703 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
5704 ),
5705 Span::styled(" │ ", Style::default().fg(Color::Rgb(40, 40, 40))),
5706 Span::styled(session_usage_text, Style::default().fg(usage_color)),
5707 ];
5708
5709 let hardware_line = vec![
5710 Span::styled(" ⬢ ", Style::default().fg(Color::Rgb(60, 60, 60))), Span::styled(
5712 format!("{} ", gpu_name),
5713 Style::default()
5714 .fg(Color::Rgb(200, 200, 200))
5715 .add_modifier(Modifier::BOLD),
5716 ),
5717 Span::styled("║ ", Style::default().fg(Color::Rgb(60, 60, 60))),
5718 Span::styled(
5719 format!(
5720 "[{}] ",
5721 make_animated_sparkline_gauge(vram_ratio, 12, app.tick_count)
5722 ),
5723 Style::default().fg(Color::Cyan),
5724 ),
5725 Span::styled(
5726 format!("{}% ", (vram_ratio * 100.0) as u8),
5727 Style::default().fg(Color::Cyan),
5728 ),
5729 Span::styled(
5730 format!("({})", vram_label),
5731 Style::default()
5732 .fg(Color::DarkGray)
5733 .add_modifier(Modifier::DIM),
5734 ),
5735 ];
5736
5737 f.render_widget(
5738 Paragraph::new(vec![
5739 Line::from(ai_line),
5740 Line::from(hardware_line),
5741 footer_row,
5742 ])
5743 .block(
5744 Block::default()
5745 .borders(Borders::ALL)
5746 .border_style(Style::default().fg(Color::Rgb(60, 60, 60))),
5747 ),
5748 bar_chunks[0],
5749 );
5750
5751 let input_border_color = if app.agent_running {
5753 Color::Rgb(60, 60, 60)
5754 } else {
5755 Color::Rgb(100, 100, 100) };
5757 let input_rect = chunks[1];
5758 let title_area = input_title_area(input_rect);
5759 let input_hint = render_input_title(app, title_area);
5760 let input_block = Block::default()
5761 .title(input_hint)
5762 .borders(Borders::ALL)
5763 .border_style(Style::default().fg(input_border_color))
5764 .style(Style::default().bg(Color::Rgb(25, 25, 25))); let inner_area = input_block.inner(input_rect);
5767 f.render_widget(Clear, input_rect);
5768 f.render_widget(input_block, input_rect);
5769
5770 f.render_widget(
5771 Paragraph::new(app.input.as_str()).wrap(Wrap { trim: true }),
5772 inner_area,
5773 );
5774
5775 if !app.agent_running && inner_area.height > 0 {
5780 let text_w = app.input.len() as u16;
5781 let max_w = inner_area.width.saturating_sub(1);
5782 let cursor_x = inner_area.x + text_w.min(max_w);
5783 f.set_cursor_position((cursor_x, inner_area.y));
5784 }
5785
5786 if let Some(approval) = &app.awaiting_approval {
5788 let is_diff_preview = approval.diff.is_some();
5789
5790 let modal_h = if is_diff_preview { 70 } else { 50 };
5792 let area = centered_rect(80, modal_h, f.area());
5793 f.render_widget(Clear, area);
5794
5795 let chunks = Layout::default()
5796 .direction(Direction::Vertical)
5797 .constraints([
5798 Constraint::Length(4), Constraint::Min(0), ])
5801 .split(area);
5802
5803 let (title_str, title_color) = if approval.mutation_label.is_some() {
5805 (" MUTATION REQUESTED — AUTHORISE THE WORKFLOW ", Color::Cyan)
5806 } else if is_diff_preview {
5807 (" DIFF PREVIEW — REVIEW BEFORE APPLYING ", Color::Yellow)
5808 } else {
5809 (" HIGH-RISK OPERATION REQUESTED ", Color::Red)
5810 };
5811 let header_text = vec![
5812 Line::from(Span::styled(
5813 title_str,
5814 Style::default()
5815 .fg(title_color)
5816 .add_modifier(Modifier::BOLD),
5817 )),
5818 if is_diff_preview {
5819 Line::from(Span::styled(
5820 " [↑↓/jk/PgUp/PgDn] Scroll [Y] Apply [N] Skip [A] Accept All ",
5821 Style::default()
5822 .fg(Color::Green)
5823 .add_modifier(Modifier::BOLD),
5824 ))
5825 } else {
5826 Line::from(vec![
5827 Span::styled(
5828 " [Y] Approve ",
5829 Style::default()
5830 .fg(Color::Green)
5831 .add_modifier(Modifier::BOLD),
5832 ),
5833 Span::styled(
5834 " [N] Decline ",
5835 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
5836 ),
5837 Span::styled(
5838 " [A] Accept All ",
5839 Style::default()
5840 .fg(Color::Magenta)
5841 .add_modifier(Modifier::BOLD),
5842 ),
5843 ])
5844 },
5845 ];
5846 f.render_widget(
5847 Paragraph::new(header_text)
5848 .block(
5849 Block::default()
5850 .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
5851 .border_style(Style::default().fg(title_color)),
5852 )
5853 .alignment(ratatui::layout::Alignment::Center),
5854 chunks[0],
5855 );
5856
5857 let border_color = if approval.mutation_label.is_some() {
5859 Color::Cyan
5860 } else if is_diff_preview {
5861 Color::Yellow
5862 } else {
5863 Color::Red
5864 };
5865 if let Some(diff_text) = &approval.diff {
5866 let added = diff_text.lines().filter(|l| l.starts_with("+ ")).count();
5868 let removed = diff_text.lines().filter(|l| l.starts_with("- ")).count();
5869 let mut body_lines: Vec<Line> = vec![
5870 Line::from(Span::styled(
5871 if let Some(label) = &approval.mutation_label {
5872 format!(" INTENT: {}", label)
5873 } else {
5874 format!(" {}", approval.display)
5875 },
5876 Style::default()
5877 .fg(Color::Cyan)
5878 .add_modifier(Modifier::BOLD),
5879 )),
5880 Line::from(vec![
5881 Span::styled(
5882 format!(" +{}", added),
5883 Style::default()
5884 .fg(Color::Green)
5885 .add_modifier(Modifier::BOLD),
5886 ),
5887 Span::styled(
5888 format!(" -{}", removed),
5889 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
5890 ),
5891 ]),
5892 Line::from(Span::raw("")),
5893 ];
5894 for raw_line in diff_text.lines() {
5895 let styled = if raw_line.starts_with("+ ") {
5896 Line::from(Span::styled(
5897 format!(" {}", raw_line),
5898 Style::default().fg(Color::Green),
5899 ))
5900 } else if raw_line.starts_with("- ") {
5901 Line::from(Span::styled(
5902 format!(" {}", raw_line),
5903 Style::default().fg(Color::Red),
5904 ))
5905 } else if raw_line.starts_with("---") || raw_line.starts_with("@@ ") {
5906 Line::from(Span::styled(
5907 format!(" {}", raw_line),
5908 Style::default()
5909 .fg(Color::DarkGray)
5910 .add_modifier(Modifier::BOLD),
5911 ))
5912 } else {
5913 Line::from(Span::raw(format!(" {}", raw_line)))
5914 };
5915 body_lines.push(styled);
5916 }
5917 f.render_widget(
5918 Paragraph::new(body_lines)
5919 .block(
5920 Block::default()
5921 .borders(Borders::BOTTOM | Borders::LEFT | Borders::RIGHT)
5922 .border_style(Style::default().fg(border_color)),
5923 )
5924 .scroll((approval.diff_scroll, 0)),
5925 chunks[1],
5926 );
5927 } else {
5928 let body_text = vec![
5929 Line::from(Span::raw("")),
5930 Line::from(Span::styled(
5931 if let Some(label) = &approval.mutation_label {
5932 format!(" INTENT: {}", label)
5933 } else {
5934 format!(" ACTION: {}", approval.display)
5935 },
5936 Style::default()
5937 .fg(Color::Cyan)
5938 .add_modifier(Modifier::BOLD),
5939 )),
5940 Line::from(Span::raw("")),
5941 Line::from(Span::styled(
5942 format!(" Tool: {}", approval.tool_name),
5943 Style::default().fg(Color::DarkGray),
5944 )),
5945 ];
5946 if approval.mutation_label.is_some() {
5947 }
5949 f.render_widget(
5950 Paragraph::new(body_text)
5951 .block(
5952 Block::default()
5953 .borders(Borders::BOTTOM | Borders::LEFT | Borders::RIGHT)
5954 .border_style(Style::default().fg(border_color)),
5955 )
5956 .alignment(ratatui::layout::Alignment::Center),
5957 chunks[1],
5958 );
5959 }
5960 }
5961
5962 if let Some(review) = &app.active_review {
5964 draw_diff_review(f, review);
5965 }
5966
5967 if app.show_autocomplete && !app.autocomplete_suggestions.is_empty() {
5969 let area = Rect {
5970 x: chunks[1].x + 2,
5971 y: chunks[1]
5972 .y
5973 .saturating_sub(app.autocomplete_suggestions.len() as u16 + 2),
5974 width: chunks[1].width.saturating_sub(4),
5975 height: app.autocomplete_suggestions.len() as u16 + 2,
5976 };
5977 f.render_widget(Clear, area);
5978
5979 let items: Vec<ListItem> = app
5980 .autocomplete_suggestions
5981 .iter()
5982 .enumerate()
5983 .map(|(i, s)| {
5984 let style = if i == app.selected_suggestion {
5985 Style::default()
5986 .fg(Color::Black)
5987 .bg(Color::Cyan)
5988 .add_modifier(Modifier::BOLD)
5989 } else {
5990 Style::default().fg(Color::Gray)
5991 };
5992 ListItem::new(format!(" 📄 {}", s)).style(style)
5993 })
5994 .collect();
5995
5996 let hatch = List::new(items).block(
5997 Block::default()
5998 .borders(Borders::ALL)
5999 .border_style(Style::default().fg(Color::Cyan))
6000 .title(format!(
6001 " @ RESOLVER (Matching: {}) ",
6002 app.autocomplete_filter
6003 )),
6004 );
6005 f.render_widget(hatch, area);
6006
6007 if app.autocomplete_suggestions.len() >= 15 {
6009 let more_area = Rect {
6010 x: area.x + 2,
6011 y: area.y + area.height - 1,
6012 width: 20,
6013 height: 1,
6014 };
6015 f.render_widget(
6016 Paragraph::new("... (type to narrow) ").style(Style::default().fg(Color::DarkGray)),
6017 more_area,
6018 );
6019 }
6020 }
6021}
6022
6023fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
6026 let vert = Layout::default()
6027 .direction(Direction::Vertical)
6028 .constraints([
6029 Constraint::Percentage((100 - percent_y) / 2),
6030 Constraint::Percentage(percent_y),
6031 Constraint::Percentage((100 - percent_y) / 2),
6032 ])
6033 .split(r);
6034 Layout::default()
6035 .direction(Direction::Horizontal)
6036 .constraints([
6037 Constraint::Percentage((100 - percent_x) / 2),
6038 Constraint::Percentage(percent_x),
6039 Constraint::Percentage((100 - percent_x) / 2),
6040 ])
6041 .split(vert[1])[1]
6042}
6043
6044fn strip_ghost_prefix(s: &str) -> &str {
6045 for prefix in &[
6046 "Hematite: ",
6047 "HEMATITE: ",
6048 "Assistant: ",
6049 "assistant: ",
6050 "Okay, ",
6051 "Hmm, ",
6052 "Wait, ",
6053 "Alright, ",
6054 "Got it, ",
6055 "Certainly, ",
6056 "Sure, ",
6057 "Understood, ",
6058 ] {
6059 if s.to_lowercase().starts_with(&prefix.to_lowercase()) {
6060 return &s[prefix.len()..];
6061 }
6062 }
6063 s
6064}
6065
6066fn first_n_chars(s: &str, n: usize) -> String {
6067 let mut result = String::with_capacity(n.min(s.len()));
6068 for (count, c) in s.chars().enumerate() {
6069 if count >= n {
6070 result.push('…');
6071 break;
6072 }
6073 if c == '\n' || c == '\r' {
6074 result.push(' ');
6075 } else if !c.is_control() {
6076 result.push(c);
6077 }
6078 }
6079 result
6080}
6081
6082fn trim_vec_context(v: &mut Vec<ContextFile>, max: usize) {
6083 while v.len() > max {
6084 v.remove(0);
6085 }
6086}
6087
6088fn trim_vec(v: &mut Vec<String>, max: usize) {
6089 while v.len() > max {
6090 v.remove(0);
6091 }
6092}
6093
6094fn render_markdown_line(raw: &str) -> Vec<Line<'static>> {
6097 let cleaned_ansi = strip_ansi(raw);
6099 let trimmed = cleaned_ansi.trim();
6100 if trimmed.is_empty() {
6101 return vec![Line::raw("")];
6102 }
6103
6104 let cleaned_owned = trimmed
6106 .replace("<thought>", "")
6107 .replace("</thought>", "")
6108 .replace("<think>", "")
6109 .replace("</think>", "");
6110 let trimmed = cleaned_owned.trim();
6111 if trimmed.is_empty() {
6112 return vec![];
6113 }
6114
6115 for (prefix, indent) in &[("### ", " "), ("## ", " "), ("# ", "")] {
6117 if let Some(rest) = trimmed.strip_prefix(prefix) {
6118 return vec![Line::from(vec![Span::styled(
6119 format!("{}{}", indent, rest),
6120 Style::default()
6121 .fg(Color::White)
6122 .add_modifier(Modifier::BOLD),
6123 )])];
6124 }
6125 }
6126
6127 if let Some(rest) = trimmed
6129 .strip_prefix("> ")
6130 .or_else(|| trimmed.strip_prefix(">"))
6131 {
6132 return vec![Line::from(vec![
6133 Span::styled("| ", Style::default().fg(Color::DarkGray)),
6134 Span::styled(
6135 rest.to_string(),
6136 Style::default()
6137 .fg(Color::White)
6138 .add_modifier(Modifier::DIM),
6139 ),
6140 ])];
6141 }
6142
6143 if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
6145 let rest = &trimmed[2..];
6146 let mut spans = vec![Span::styled("* ", Style::default().fg(Color::Gray))];
6147 spans.extend(inline_markdown(rest));
6148 return vec![Line::from(spans)];
6149 }
6150
6151 let spans = inline_markdown(trimmed);
6153 vec![Line::from(spans)]
6154}
6155
6156fn inline_markdown_core(text: &str) -> Vec<Span<'static>> {
6158 let mut spans = Vec::with_capacity(4);
6159 let mut remaining = text;
6160
6161 while !remaining.is_empty() {
6162 if let Some(start) = remaining.find("**") {
6163 let before = &remaining[..start];
6164 if !before.is_empty() {
6165 spans.push(Span::raw(before.to_string()));
6166 }
6167 let after_open = &remaining[start + 2..];
6168 if let Some(end) = after_open.find("**") {
6169 spans.push(Span::styled(
6170 after_open[..end].to_string(),
6171 Style::default()
6172 .fg(Color::White)
6173 .add_modifier(Modifier::BOLD),
6174 ));
6175 remaining = &after_open[end + 2..];
6176 continue;
6177 }
6178 }
6179 if let Some(start) = remaining.find('`') {
6180 let before = &remaining[..start];
6181 if !before.is_empty() {
6182 spans.push(Span::raw(before.to_string()));
6183 }
6184 let after_open = &remaining[start + 1..];
6185 if let Some(end) = after_open.find('`') {
6186 spans.push(Span::styled(
6187 after_open[..end].to_string(),
6188 Style::default().fg(Color::Yellow),
6189 ));
6190 remaining = &after_open[end + 1..];
6191 continue;
6192 }
6193 }
6194 spans.push(Span::raw(remaining.to_string()));
6195 break;
6196 }
6197 spans
6198}
6199
6200fn inline_markdown(text: &str) -> Vec<Span<'static>> {
6202 let mut spans = Vec::with_capacity(4);
6203 let mut remaining = text;
6204
6205 while !remaining.is_empty() {
6206 if let Some(start) = remaining.find("**") {
6207 let before = &remaining[..start];
6208 if !before.is_empty() {
6209 spans.push(Span::raw(before.to_string()));
6210 }
6211 let after_open = &remaining[start + 2..];
6212 if let Some(end) = after_open.find("**") {
6213 spans.push(Span::styled(
6214 after_open[..end].to_string(),
6215 Style::default()
6216 .fg(Color::White)
6217 .add_modifier(Modifier::BOLD),
6218 ));
6219 remaining = &after_open[end + 2..];
6220 continue;
6221 }
6222 }
6223 if let Some(start) = remaining.find('`') {
6224 let before = &remaining[..start];
6225 if !before.is_empty() {
6226 spans.push(Span::raw(before.to_string()));
6227 }
6228 let after_open = &remaining[start + 1..];
6229 if let Some(end) = after_open.find('`') {
6230 spans.push(Span::styled(
6231 after_open[..end].to_string(),
6232 Style::default().fg(Color::Yellow),
6233 ));
6234 remaining = &after_open[end + 1..];
6235 continue;
6236 }
6237 }
6238 spans.push(Span::raw(remaining.to_string()));
6239 break;
6240 }
6241 spans
6242}
6243
6244fn make_starfield(width: u16, rows: u16, seed: u64, tick: u64) -> Vec<String> {
6247 let mut lines = Vec::with_capacity(rows as usize);
6248
6249 for y in 0..rows {
6250 let mut line = String::with_capacity(width as usize);
6251
6252 for x in 0..width {
6253 let n = (x as u64).wrapping_mul(73_856_093)
6254 ^ (y as u64).wrapping_mul(19_349_663)
6255 ^ seed
6256 ^ tick.wrapping_mul(83_492_791);
6257
6258 let ch = match n % 97 {
6259 0 => '*',
6260 1 | 2 => '.',
6261 3 => '+',
6262 _ => ' ',
6263 };
6264
6265 line.push(ch);
6266 }
6267
6268 lines.push(line);
6269 }
6270
6271 lines
6272}
6273
6274fn draw_splash<B: Backend>(terminal: &mut Terminal<B>) -> Result<(), Box<dyn std::error::Error>> {
6277 let logo_color = Color::Rgb(118, 118, 124);
6278 let star_color = Color::White;
6279 let sub_logo_color = Color::DarkGray;
6280 let tagline_color = Color::Gray;
6281 let author_color = Color::DarkGray;
6282
6283 let wide_logo = vec![
6284 "██╗ ██╗███████╗███╗ ███╗ █████╗ ████████╗██╗████████╗███████╗",
6285 "██║ ██║██╔════╝████╗ ████║██╔══██╗╚══██╔══╝██║╚══██╔══╝██╔════╝",
6286 "███████║█████╗ ██╔████╔██║███████║ ██║ ██║ ██║ █████╗ ",
6287 "██╔══██║██╔══╝ ██║╚██╔╝██║██╔══██║ ██║ ██║ ██║ ██╔══╝ ",
6288 "██║ ██║███████╗██║ ╚═╝ ██║██║ ██║ ██║ ██║ ██║ ███████╗",
6289 "╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝",
6290 ];
6291
6292 let version = env!("CARGO_PKG_VERSION");
6293
6294 terminal.draw(|f| {
6295 let area = f.area();
6296
6297 f.render_widget(
6298 Block::default().style(Style::default().bg(Color::Black)),
6299 area,
6300 );
6301
6302 let now = SystemTime::now()
6303 .duration_since(UNIX_EPOCH)
6304 .unwrap_or_default();
6305 let tick = (now.as_millis() / 350) as u64;
6306
6307 let top_stars = make_starfield(area.width, 3, 0xA11CE, tick);
6308 let bottom_stars = make_starfield(area.width, 2, 0xBADC0DE, tick + 17);
6309
6310 let content_height: u16 = 19;
6323 let top_pad = area.height.saturating_sub(content_height) / 2;
6324
6325 let mut lines: Vec<Line<'static>> =
6326 Vec::with_capacity((top_pad + content_height) as usize + 4);
6327
6328 for _ in 0..top_pad {
6329 lines.push(Line::raw(""));
6330 }
6331
6332 for line in top_stars {
6334 lines.push(Line::from(Span::styled(
6335 line,
6336 Style::default()
6337 .fg(star_color)
6338 .add_modifier(Modifier::BOLD)
6339 .add_modifier(Modifier::DIM),
6340 )));
6341 }
6342
6343 for line in &wide_logo {
6345 lines.push(Line::from(Span::styled(
6346 (*line).to_string(),
6347 Style::default().fg(logo_color).add_modifier(Modifier::BOLD),
6348 )));
6349 }
6350
6351 lines.push(Line::from(Span::styled(
6353 " -- cli --".to_string(),
6354 Style::default()
6355 .fg(sub_logo_color)
6356 .add_modifier(Modifier::DIM),
6357 )));
6358
6359 lines.push(Line::raw(""));
6360
6361 lines.push(Line::from(Span::styled(
6363 format!("v{}", version),
6364 Style::default().fg(sub_logo_color),
6365 )));
6366
6367 lines.push(Line::from(Span::styled(
6369 "Local AI coding harness + workstation assistant".to_string(),
6370 Style::default().fg(tagline_color),
6371 )));
6372
6373 lines.push(Line::from(Span::styled(
6375 "developed by Ocean Bennett".to_string(),
6376 Style::default()
6377 .fg(author_color)
6378 .add_modifier(Modifier::DIM),
6379 )));
6380
6381 lines.push(Line::raw(""));
6382
6383 for line in bottom_stars {
6385 lines.push(Line::from(Span::styled(
6386 line,
6387 Style::default()
6388 .fg(star_color)
6389 .add_modifier(Modifier::BOLD)
6390 .add_modifier(Modifier::DIM),
6391 )));
6392 }
6393
6394 lines.push(Line::raw(""));
6395
6396 lines.push(Line::from(vec![
6398 Span::styled("[ ", Style::default().fg(logo_color)),
6399 Span::styled(
6400 "PRESS ENTER TO START",
6401 Style::default()
6402 .fg(Color::White)
6403 .add_modifier(Modifier::BOLD),
6404 ),
6405 Span::styled(" ]", Style::default().fg(logo_color)),
6406 ]));
6407
6408 let splash = Paragraph::new(lines).alignment(Alignment::Center);
6409 f.render_widget(splash, area);
6410 })?;
6411
6412 Ok(())
6413}
6414
6415fn normalize_id(id: &str) -> String {
6416 id.trim().to_uppercase()
6417}
6418
6419fn filter_tui_noise(text: &str) -> String {
6420 let cleaned = strip_ansi(text);
6422
6423 let mut lines = Vec::with_capacity(cleaned.matches('\n').count() + 1);
6425 for line in cleaned.lines() {
6426 if CRLF_REGEX.is_match(line) {
6428 continue;
6429 }
6430 if line.contains("Updating files:") && line.contains("%") {
6432 continue;
6433 }
6434 let mut sanitized = String::with_capacity(line.len());
6436 for c in line.chars() {
6437 if !c.is_control() || c == '\t' {
6438 sanitized.push(c);
6439 }
6440 }
6441 if sanitized.trim().is_empty() && !line.trim().is_empty() {
6442 continue;
6443 }
6444
6445 lines.push(normalize_tui_text(&sanitized));
6446 }
6447 lines.join("\n").trim().to_string()
6448}
6449
6450fn normalize_tui_text(text: &str) -> String {
6451 let mut normalized = text
6452 .replace("ΓÇö", "-")
6453 .replace("ΓÇô", "-")
6454 .replace("…", "...")
6455 .replace("✅", "[OK]")
6456 .replace("🛠️", "")
6457 .replace("—", "-")
6458 .replace("–", "-")
6459 .replace("…", "...")
6460 .replace("•", "*")
6461 .replace("✅", "[OK]")
6462 .replace("🚨", "[!]");
6463
6464 normalized = normalized
6465 .chars()
6466 .map(|c| match c {
6467 '\u{00A0}' => ' ',
6468 '\u{2018}' | '\u{2019}' => '\'',
6469 '\u{201C}' | '\u{201D}' => '"',
6470 c if c.is_ascii() || c == '\n' || c == '\t' => c,
6471 _ => ' ',
6472 })
6473 .collect();
6474
6475 let mut compacted = String::with_capacity(normalized.len());
6476 let mut prev_space = false;
6477 for ch in normalized.chars() {
6478 if ch == ' ' {
6479 if !prev_space {
6480 compacted.push(ch);
6481 }
6482 prev_space = true;
6483 } else {
6484 compacted.push(ch);
6485 prev_space = false;
6486 }
6487 }
6488
6489 compacted.trim().to_string()
6490}