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