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