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