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