1use crate::llm::{LlmClient, StreamEvent};
6use crate::mission::TuiEvent;
7use crate::model_config::ModelConfig;
8use crate::model_picker::{self, AvailableModel, ModelPickerState, PickerAction};
9use crate::snake::SnakeGame;
10use crate::space::SpaceGame;
11use anyhow::Result;
12use crossterm::{
13 event::{self, Event, KeyCode, KeyModifiers},
14 execute,
15 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
16};
17use ratatui::{
18 backend::CrosstermBackend,
19 layout::{Constraint, Direction, Layout},
20 style::{Color, Modifier, Style},
21 text::{Line, Span},
22 widgets::{Block, Borders, Paragraph, Tabs, Wrap},
23 Terminal,
24};
25use std::io;
26use tokio::sync::mpsc;
27
28#[derive(Clone, Copy, PartialEq)]
29enum Tab {
30 Chat,
31 Queue,
32 Models,
33 Code,
34 Hw,
35 Log,
36}
37
38impl Tab {
39 fn titles() -> Vec<&'static str> {
40 vec![
41 "Chat [1]",
42 "Queue [2]",
43 "Models [3]",
44 "Code [4]",
45 "HW [5]",
46 "Log [6]",
47 ]
48 }
49 fn index(&self) -> usize {
50 match self {
51 Tab::Chat => 0,
52 Tab::Queue => 1,
53 Tab::Models => 2,
54 Tab::Code => 3,
55 Tab::Hw => 4,
56 Tab::Log => 5,
57 }
58 }
59 fn next(&self) -> Self {
60 match self {
61 Tab::Chat => Tab::Queue,
62 Tab::Queue => Tab::Models,
63 Tab::Models => Tab::Code,
64 Tab::Code => Tab::Hw,
65 Tab::Hw => Tab::Log,
66 Tab::Log => Tab::Chat,
67 }
68 }
69}
70
71struct LogEntry {
73 level: String,
74 message: String,
75 timestamp: String,
76}
77
78struct QueueItem {
80 stage: String,
81 step: String,
82 model: String,
83 status: String, }
85
86struct ThinkingEntry {
88 model: String,
89 content: String,
90 is_active: bool,
91}
92
93struct App {
94 current_tab: Tab,
95 input: String,
96 input_cursor: usize,
97 chat_messages: Vec<(String, String)>,
98 stream_buffer: String,
100 stream_rx: Option<mpsc::Receiver<StreamEvent>>,
101 is_generating: bool,
102 code_content: String,
104 code_model: String,
105 code_streaming: bool,
106 code_history: Vec<String>,
107 code_display_len: usize, code_scroll: u16,
110 code_auto_scroll: bool,
111 code_total_lines: u16,
112 queue_items: Vec<QueueItem>,
114 log_entries: Vec<LogEntry>,
116 log_scroll: u16,
118 log_auto_scroll: bool,
119 log_total_lines: u16,
120 thinking_buffer: Vec<ThinkingEntry>,
122 hw_lines: Vec<String>,
124 hw_cpu_pct: f32,
125 hw_ram_used_gb: f64,
126 hw_ram_total_gb: f64,
127 hw_vram_gb: f64,
128 snake_game: Option<SnakeGame>,
130 space_game: Option<SpaceGame>,
131 picker_state: Option<ModelPickerState>,
133 model_config: ModelConfig,
134 chat_scroll: u16,
136 chat_auto_scroll: bool,
137 chat_total_lines: u16,
138 cto_agent: Option<crate::cto::CtoAgent>,
140 mission_event_rx: mpsc::UnboundedReceiver<TuiEvent>,
142 mission_event_tx: mpsc::UnboundedSender<TuiEvent>,
143 status_line: String,
145 total_cost: f64,
146 mission_running: bool,
147 should_quit: bool,
148}
149
150impl App {
151 fn new() -> Self {
152 let (mission_event_tx, mission_event_rx) = mpsc::unbounded_channel();
153 let now = chrono::Local::now().format("%H:%M:%S").to_string();
154 Self {
155 current_tab: Tab::Chat,
156 input: String::new(),
157 input_cursor: 0,
158 chat_messages: vec![(
159 "system".into(),
160 format!(
161 "BattleCommand Forge v{} — Type a message or /help",
162 env!("CARGO_PKG_VERSION")
163 ),
164 )],
165 stream_buffer: String::new(),
166 stream_rx: None,
167 is_generating: false,
168 code_content: String::new(),
169 code_model: String::new(),
170 code_streaming: false,
171 code_history: Vec::new(),
172 code_display_len: 0,
173 code_scroll: 0,
174 code_auto_scroll: true,
175 code_total_lines: 0,
176 queue_items: Vec::new(),
177 log_entries: vec![LogEntry {
178 level: "info".into(),
179 message: "TUI started".into(),
180 timestamp: now,
181 }],
182 log_scroll: 0,
183 log_auto_scroll: true,
184 log_total_lines: 0,
185 thinking_buffer: Vec::new(),
186 hw_lines: vec!["Loading hardware metrics...".into()],
187 hw_cpu_pct: 0.0,
188 hw_ram_used_gb: 0.0,
189 hw_ram_total_gb: 0.0,
190 hw_vram_gb: 0.0,
191 snake_game: None,
192 space_game: None,
193 picker_state: None,
194 model_config: ModelConfig::resolve(
195 crate::model_config::Preset::Premium,
196 ".",
197 None,
198 None,
199 None,
200 None,
201 ),
202 chat_scroll: 0,
203 chat_auto_scroll: true,
204 chat_total_lines: 0,
205 cto_agent: None,
206 mission_event_rx,
207 mission_event_tx,
208 status_line: "READY".into(),
209 total_cost: 0.0,
210 mission_running: false,
211 should_quit: false,
212 }
213 }
214
215 fn log(&mut self, level: &str, message: impl Into<String>) {
216 self.log_entries.push(LogEntry {
217 level: level.into(),
218 message: message.into(),
219 timestamp: chrono::Local::now().format("%H:%M:%S").to_string(),
220 });
221 }
222}
223
224pub async fn run_tui() -> Result<()> {
225 enable_raw_mode()?;
226 let mut stdout = io::stdout();
227 execute!(stdout, EnterAlternateScreen)?;
228 let backend = CrosstermBackend::new(stdout);
229 let mut terminal = Terminal::new(backend)?;
230 let mut app = App::new();
231
232 let metrics = crate::hardware::collect_metrics().await;
234 app.hw_lines = crate::hardware::render_for_tui(&metrics);
235 app.hw_cpu_pct = metrics.cpu_usage_total;
236 app.hw_ram_used_gb = metrics.mem_used_gb;
237 app.hw_ram_total_gb = metrics.mem_total_gb;
238 app.hw_vram_gb = metrics.ollama_vram_total_gb;
239
240 let mut hw_tick = 0u32;
242
243 loop {
244 if let Some(ref snake) = app.snake_game {
246 terminal.draw(|f| {
247 snake.draw(f, f.area());
248 })?;
249 } else if let Some(ref space) = app.space_game {
250 terminal.draw(|f| {
251 space.draw(f, f.area());
252 })?;
253 } else {
254 let picker_ref = &app.picker_state;
255 terminal.draw(|f| {
256 let chunks = Layout::default()
257 .direction(Direction::Vertical)
258 .constraints([
259 Constraint::Length(3), Constraint::Min(0), Constraint::Length(1), Constraint::Length(3), ])
264 .split(f.area());
265
266 let title = if app.hw_ram_total_gb > 0.0 {
268 format!(
269 " BattleCommand Forge | CPU:{:.0}% RAM:{:.0}/{:.0}G VRAM:{:.0}G ",
270 app.hw_cpu_pct,
271 app.hw_ram_used_gb,
272 app.hw_ram_total_gb,
273 app.hw_vram_gb.abs()
274 )
275 } else {
276 " BattleCommand Forge ".to_string()
277 };
278 let titles: Vec<Line> = Tab::titles()
279 .iter()
280 .map(|t| Line::from(Span::raw(*t)))
281 .collect();
282 let tabs = Tabs::new(titles)
283 .block(Block::default().borders(Borders::ALL).title(title))
284 .select(app.current_tab.index())
285 .style(Style::default().fg(Color::White))
286 .highlight_style(
287 Style::default()
288 .fg(Color::Yellow)
289 .add_modifier(Modifier::BOLD),
290 );
291 f.render_widget(tabs, chunks[0]);
292
293 let visible_height = chunks[1].height.saturating_sub(2); let content_width = chunks[1].width.saturating_sub(2) as usize;
296 match app.current_tab {
297 Tab::Chat => {
298 let cto_model = app.model_config.cto.model.clone();
299 let (para, total) = render_chat(
300 &app.chat_messages,
301 &app.stream_buffer,
302 app.is_generating,
303 app.chat_scroll,
304 app.chat_auto_scroll,
305 visible_height,
306 content_width,
307 &cto_model,
308 );
309 app.chat_total_lines = total;
310 f.render_widget(para, chunks[1]);
311 }
312 Tab::Queue => f.render_widget(render_queue(&app.queue_items), chunks[1]),
313 Tab::Models => f.render_widget(render_models(), chunks[1]),
314 Tab::Code => {
315 let (para, total) = render_code(
316 &app.code_content,
317 &app.code_model,
318 app.code_streaming,
319 &app.code_history,
320 app.code_display_len,
321 app.code_scroll,
322 app.code_auto_scroll,
323 visible_height,
324 content_width,
325 );
326 app.code_total_lines = total;
327 f.render_widget(para, chunks[1]);
328 }
329 Tab::Hw => f.render_widget(render_hw(&app.hw_lines), chunks[1]),
330 Tab::Log => {
331 let (para, total) = render_log(
332 &app.log_entries,
333 &app.thinking_buffer,
334 app.log_scroll,
335 app.log_auto_scroll,
336 visible_height,
337 content_width,
338 );
339 app.log_total_lines = total;
340 f.render_widget(para, chunks[1]);
341 }
342 }
343
344 let completed = app
346 .queue_items
347 .iter()
348 .filter(|i| i.status.starts_with("done"))
349 .count();
350 let total_tasks = app.queue_items.len();
351 f.render_widget(
352 render_status_bar(
353 &app.status_line,
354 completed,
355 total_tasks,
356 app.total_cost,
357 app.hw_vram_gb,
358 ),
359 chunks[2],
360 );
361
362 let input_text = if app.current_tab == Tab::Chat {
364 if app.is_generating {
365 " Generating...".to_string()
366 } else {
367 let (before, after) =
368 app.input.split_at(app.input_cursor.min(app.input.len()));
369 format!(" > {}|{}", before, after)
370 }
371 } else {
372 " 1-6=tabs | Tab=cycle | PgUp/PgDn=scroll".into()
373 };
374 let input_bar = Paragraph::new(input_text)
375 .style(Style::default().fg(Color::Cyan))
376 .block(Block::default().borders(Borders::ALL).title(" Input "));
377 f.render_widget(input_bar, chunks[3]);
378
379 if let Some(ref picker) = picker_ref {
381 model_picker::draw_model_picker(f, picker);
382 }
383 })?;
384 }
385
386 {
388 let mut deferred_logs: Vec<(&str, String)> = Vec::new();
389 if let Some(ref mut rx) = app.stream_rx {
390 while let Ok(evt) = rx.try_recv() {
391 match evt {
392 StreamEvent::Token(t) => {
393 app.stream_buffer.push_str(&t);
394 }
395 StreamEvent::Done(full) => {
396 app.chat_messages.push(("cto".into(), full.clone()));
397 let code_blocks = extract_code_blocks(&full);
398 if !code_blocks.is_empty() {
399 app.code_content = code_blocks;
400 app.code_model = "CTO".into();
401 app.code_display_len = 0; app.code_history.push(app.code_content.clone());
403 }
404 app.stream_buffer.clear();
405 app.code_streaming = false;
406 app.is_generating = false;
407 app.status_line = "READY".into();
408 deferred_logs.push(("info", "Response complete".into()));
409 }
410 StreamEvent::Error(e) => {
411 app.chat_messages.push(("error".into(), e));
412 app.stream_buffer.clear();
413 app.is_generating = false;
414 app.code_streaming = false;
415 app.status_line = "READY".into();
416 }
417 StreamEvent::ToolCallStart { name, args } => {
418 let display = if args.len() > 80 {
419 format!("{:.80}...", args)
420 } else {
421 args
422 };
423 app.chat_messages
424 .push(("tool".into(), format!("[{}] {}", name, display)));
425 }
426 StreamEvent::ToolCallResult { name, result } => {
427 let display = if result.len() > 200 {
428 format!("{:.200}...", result)
429 } else {
430 result
431 };
432 app.chat_messages
433 .push(("tool_result".into(), format!("[{}] {}", name, display)));
434 }
435 StreamEvent::AgentReturn(agent) => {
436 app.cto_agent = Some(*agent);
437 deferred_logs.push(("info", "CTO agent returned".into()));
438 }
439 }
440 }
441 }
442 for (level, msg) in deferred_logs {
443 app.log(level, msg);
444 }
445 }
446
447 while let Ok(evt) = app.mission_event_rx.try_recv() {
449 match evt {
450 TuiEvent::Log { level, message } => {
451 app.log(&level, &message);
452 }
453 TuiEvent::StageStarted { stage, step, model } => {
454 app.status_line = format!("Stage: {} [{}]", stage, model);
455 if let Some(item) = app.queue_items.iter_mut().find(|i| i.stage == stage) {
456 item.status = "running".into();
457 item.model = model;
458 } else {
459 app.queue_items.push(QueueItem {
460 stage,
461 step,
462 model,
463 status: "running".into(),
464 });
465 }
466 }
467 TuiEvent::StageCompleted { stage, status } => {
468 if let Some(item) = app.queue_items.iter_mut().find(|i| i.stage == stage) {
469 item.status = format!("done: {}", status);
470 }
471 }
472 TuiEvent::CodeChunk {
473 content,
474 model,
475 done: _,
476 } => {
477 if app.code_content.is_empty() || !content.starts_with(&app.code_content) {
479 app.code_display_len = 0;
480 }
481 app.code_content = content;
482 app.code_model = model;
483 }
484 TuiEvent::MissionCompleted { score, output_dir } => {
485 app.mission_running = false;
486 app.status_line = format!("MISSION COMPLETE — Score: {:.1}/10", score);
487 app.chat_messages.push((
488 "system".into(),
489 format!("Mission complete! Score: {:.1}/10 — {}", score, output_dir),
490 ));
491 app.log("info", format!("Mission complete: {:.1}/10", score));
492 if !app.code_content.is_empty() {
493 app.code_history.push(app.code_content.clone());
494 }
495 }
496 TuiEvent::MissionFailed { error } => {
497 app.mission_running = false;
498 app.status_line = "MISSION FAILED".into();
499 app.chat_messages
500 .push(("error".into(), format!("Mission failed: {}", error)));
501 app.log("error", format!("Mission failed: {}", error));
502 }
503 TuiEvent::CostUpdate { total_usd } => {
504 app.total_cost = total_usd;
505 }
506 TuiEvent::ThinkingChunk {
507 model,
508 content,
509 done,
510 } => {
511 if done {
512 if let Some(last) = app.thinking_buffer.last_mut() {
513 last.is_active = false;
514 }
515 } else if let Some(last) = app.thinking_buffer.last_mut() {
516 if last.is_active && last.model == model {
517 last.content.push_str(&content);
518 } else {
519 app.thinking_buffer.push(ThinkingEntry {
520 model,
521 content,
522 is_active: true,
523 });
524 }
525 } else {
526 app.thinking_buffer.push(ThinkingEntry {
527 model,
528 content,
529 is_active: true,
530 });
531 }
532 }
533 }
534 }
535
536 hw_tick += 1;
538 if hw_tick.is_multiple_of(80) {
539 let metrics = crate::hardware::collect_metrics().await;
540 app.hw_cpu_pct = metrics.cpu_usage_total;
541 app.hw_ram_used_gb = metrics.mem_used_gb;
542 app.hw_ram_total_gb = metrics.mem_total_gb;
543 app.hw_vram_gb = metrics.ollama_vram_total_gb;
544 if app.current_tab == Tab::Hw {
545 app.hw_lines = crate::hardware::render_for_tui(&metrics);
546 }
547 }
548
549 if !app.code_content.is_empty() && app.code_display_len < app.code_content.len() {
551 let remaining = app.code_content.len().saturating_sub(app.code_display_len);
552 let advance = 12usize.min(remaining);
553 let target = app.code_display_len + advance;
554 let safe = if target >= app.code_content.len() {
556 app.code_content.len()
557 } else {
558 let mut pos = target;
559 while pos < app.code_content.len() && !app.code_content.is_char_boundary(pos) {
560 pos += 1;
561 }
562 pos
563 };
564 app.code_display_len = safe;
565 }
566
567 if let Some(ref mut snake) = app.snake_game {
569 snake.tick();
570 }
571 if let Some(ref mut space) = app.space_game {
572 space.tick();
573 }
574
575 if event::poll(std::time::Duration::from_millis(50))? {
576 if let Event::Key(key) = event::read()? {
577 if app.snake_game.is_some() {
579 let exit = app.snake_game.as_mut().unwrap().handle_input(key.code);
580 if exit {
581 app.snake_game = None;
582 }
583 continue;
584 }
585 if app.space_game.is_some() {
586 let exit = app.space_game.as_mut().unwrap().handle_input(key.code);
587 if exit {
588 app.space_game = None;
589 }
590 continue;
591 }
592
593 if app.picker_state.is_some() {
595 match key.code {
596 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
597 app.picker_state = None;
598 }
599 _ => {
600 if let Some(ref mut picker) = app.picker_state {
601 match model_picker::handle_picker_input(picker, key.code) {
602 PickerAction::Confirm(config) => {
603 let toml = picker.to_toml();
604 app.picker_state = None;
605 app.model_config = config;
606 let _ = std::fs::create_dir_all(".battlecommand");
608 match std::fs::write(".battlecommand/models.toml", &toml) {
609 Ok(_) => {
610 app.chat_messages.push(("system".into(), "Model config saved to .battlecommand/models.toml".into()));
611 app.log("info", "Model config saved");
612 }
613 Err(e) => {
614 app.chat_messages.push((
615 "error".into(),
616 format!("Failed to save: {}", e),
617 ));
618 }
619 }
620 app.model_config.print_summary();
621 }
622 PickerAction::Cancel => {
623 app.picker_state = None;
624 app.chat_messages.push((
625 "system".into(),
626 "Model setup cancelled — keeping current config."
627 .into(),
628 ));
629 }
630 PickerAction::Continue => {}
631 }
632 }
633 }
634 }
635 continue;
636 }
637
638 if app.current_tab == Tab::Chat {
639 match key.code {
640 KeyCode::Enter if !app.input.is_empty() && !app.is_generating => {
641 let msg = app.input.clone();
642 app.input.clear();
643 app.input_cursor = 0;
644 app.chat_auto_scroll = true;
645 app.chat_scroll = 0;
646
647 if msg == "/snake" {
649 app.snake_game = Some(SnakeGame::new());
650 continue;
651 } else if msg == "/space" {
652 app.space_game = Some(SpaceGame::new());
653 continue;
654 } else if msg == "/status" {
655 let ws = crate::workspace::list_workspaces().unwrap_or_default();
656 app.chat_messages.push((
657 "system".into(),
658 format!("Workspaces: {} | Modules: 30", ws.len()),
659 ));
660 continue;
661 } else if msg == "/models" {
662 app.current_tab = Tab::Models;
663 continue;
664 } else if msg == "/hw" {
665 app.current_tab = Tab::Hw;
666 continue;
667 } else if msg == "/settings" {
668 match crate::models::list_ollama_models().await {
669 Ok(models) => {
670 let available = to_available_models(&models);
671 if available.is_empty() {
672 app.chat_messages.push((
673 "system".into(),
674 "No models available. Is Ollama running?".into(),
675 ));
676 } else {
677 app.picker_state = Some(ModelPickerState::new(
678 available,
679 &app.model_config,
680 ));
681 }
682 }
683 Err(e) => app
684 .chat_messages
685 .push(("error".into(), format!("Failed: {}", e))),
686 }
687 continue;
688 } else if msg == "/clear" {
689 app.chat_messages.clear();
690 app.chat_messages
691 .push(("system".into(), "Chat cleared.".into()));
692 app.thinking_buffer.clear();
693 if let Some(ref mut agent) = app.cto_agent {
694 agent.clear_history();
695 agent.save_history().ok();
696 }
697 continue;
698 } else if msg == "/compress" {
699 if let Some(ref mut agent) = app.cto_agent {
700 agent.compact_history();
701 agent.save_history().ok();
702 app.chat_messages.push((
703 "system".into(),
704 format!(
705 "History compacted to {} messages",
706 agent.history_len()
707 ),
708 ));
709 } else {
710 app.chat_messages
711 .push(("system".into(), "No active CTO session.".into()));
712 }
713 continue;
714 } else if msg.starts_with("/mission ") {
715 if app.mission_running {
717 app.chat_messages.push((
718 "system".into(),
719 "A mission is already running. Wait for it to complete."
720 .into(),
721 ));
722 continue;
723 }
724 let prompt = msg.strip_prefix("/mission ").unwrap_or("").trim();
725 if !prompt.is_empty() {
726 app.mission_running = true;
727 app.status_line = "MISSION RUNNING...".into();
728 app.chat_messages.push((
729 "system".into(),
730 format!("Mission launched: {}", prompt),
731 ));
732 app.queue_items.clear();
733 app.code_content.clear();
734 app.code_display_len = 0;
735 let config = app.model_config.clone();
736 let p = prompt.to_string();
737 let etx = app.mission_event_tx.clone();
738 tokio::spawn(async move {
739 let mut runner = crate::mission::MissionRunner::new(config);
740 runner.auto_mode = true;
741 runner.event_tx = Some(etx.clone());
742 if let Err(e) = runner.run(&p).await {
743 let _ = etx.send(TuiEvent::MissionFailed {
744 error: e.to_string(),
745 });
746 }
747 });
748 } else {
749 app.chat_messages
750 .push(("system".into(), "Usage: /mission <prompt>".into()));
751 }
752 continue;
753 } else if msg.starts_with("/verify") {
754 let arg = msg.strip_prefix("/verify").unwrap_or("").trim();
755 let path = if arg.is_empty() {
756 let mut best: Option<std::path::PathBuf> = None;
758 if let Ok(entries) = std::fs::read_dir("output") {
759 for entry in entries.flatten() {
760 let p = entry.path();
761 if p.is_dir()
762 && best.as_ref().is_none_or(|b| {
763 p.metadata()
764 .and_then(|m| m.modified())
765 .unwrap_or(
766 std::time::SystemTime::UNIX_EPOCH,
767 )
768 > b.metadata()
769 .and_then(|m| m.modified())
770 .unwrap_or(
771 std::time::SystemTime::UNIX_EPOCH,
772 )
773 })
774 {
775 best = Some(p);
776 }
777 }
778 }
779 best
780 } else {
781 Some(std::path::PathBuf::from(arg))
782 };
783 match path {
784 Some(dir) if dir.exists() => {
785 app.chat_messages.push((
786 "system".into(),
787 format!("Verifying {}...", dir.display()),
788 ));
789 match crate::verifier::verify_project(&dir, "python") {
790 Ok(report) => {
791 app.chat_messages.push(("system".into(), format!(
792 "Score: {:.1}/10 | Tests: {} passed, {} failed | Files: {}",
793 report.avg_score, report.tests_passed, report.tests_failed,
794 report.file_reports.len()
795 )));
796 if !report.test_errors.is_empty() {
797 let errors: String = report
798 .test_errors
799 .iter()
800 .take(5)
801 .map(|e| format!(" {}", e))
802 .collect::<Vec<_>>()
803 .join("\n");
804 app.chat_messages.push((
805 "error".into(),
806 format!("Errors:\n{}", errors),
807 ));
808 }
809 }
810 Err(e) => app.chat_messages.push((
811 "error".into(),
812 format!("Verify failed: {}", e),
813 )),
814 }
815 }
816 Some(dir) => app.chat_messages.push((
817 "error".into(),
818 format!("Not found: {}", dir.display()),
819 )),
820 None => app.chat_messages.push((
821 "system".into(),
822 "No output directory found. Usage: /verify [path]".into(),
823 )),
824 }
825 continue;
826 } else if msg.starts_with("/report") {
827 let arg = msg.strip_prefix("/report").unwrap_or("").trim();
828 if arg == "list" || arg.is_empty() {
829 match crate::report::list_reports() {
830 Ok(reports) if reports.is_empty() => {
831 app.chat_messages.push((
832 "system".into(),
833 "No reports yet. Run a mission first.".into(),
834 ));
835 }
836 Ok(reports) => {
837 app.chat_messages.push((
838 "system".into(),
839 format!("{} reports:", reports.len()),
840 ));
841 for r in reports.iter().rev().take(10) {
842 app.chat_messages.push((
843 "system".into(),
844 format!(" {}", r.display()),
845 ));
846 }
847 }
848 Err(e) => app
849 .chat_messages
850 .push(("error".into(), format!("Failed: {}", e))),
851 }
852 } else {
853 let report_path = if arg == "show" {
855 std::path::PathBuf::from(
856 ".battlecommand/reports/latest.json",
857 )
858 } else {
859 let p = arg.strip_prefix("show ").unwrap_or(arg);
860 std::path::PathBuf::from(p)
861 };
862 if !report_path.exists() {
863 app.chat_messages.push((
864 "error".into(),
865 format!("Report not found: {}", report_path.display()),
866 ));
867 } else {
868 match crate::report::load_report(&report_path) {
869 Ok(report) => {
870 app.chat_messages.push(("system".into(), format!(
871 "Mission: {} | Score: {:.1} | Rounds: {} | {}",
872 report.mission.prompt.chars().take(50).collect::<String>(),
873 report.result.best_score,
874 report.result.total_rounds,
875 if report.result.quality_gate_passed { "SHIPPED" } else { "NOT SHIPPED" }
876 )));
877 if let Some(best) =
878 report.rounds.iter().max_by(|a, b| {
879 a.final_score
880 .partial_cmp(&b.final_score)
881 .unwrap_or(std::cmp::Ordering::Equal)
882 })
883 {
884 let s = &best.critique.scores;
885 app.chat_messages.push(("system".into(), format!(
886 "Critique: DEV={:.1} ARCH={:.1} TEST={:.1} SEC={:.1} DOCS={:.1}",
887 s.dev, s.arch, s.test, s.sec, s.docs
888 )));
889 }
890 }
891 Err(e) => app
892 .chat_messages
893 .push(("error".into(), format!("Failed: {}", e))),
894 }
895 }
896 }
897 continue;
898 } else if msg.starts_with("/audit") {
899 let arg = msg.strip_prefix("/audit").unwrap_or("").trim();
900 let limit: usize = arg.parse().unwrap_or(10);
901 match crate::enterprise::read_audit_log(limit) {
902 Ok(entries) if entries.is_empty() => {
903 app.chat_messages
904 .push(("system".into(), "No audit entries.".into()));
905 }
906 Ok(entries) => {
907 app.chat_messages.push((
908 "system".into(),
909 format!("Last {} audit entries:", entries.len()),
910 ));
911 for e in &entries {
912 app.chat_messages.push((
913 "system".into(),
914 format!(
915 "[{}] {} {} — {}",
916 e.timestamp, e.actor, e.action, e.resource
917 ),
918 ));
919 }
920 }
921 Err(e) => app
922 .chat_messages
923 .push(("error".into(), format!("Failed: {}", e))),
924 }
925 continue;
926 } else if msg.starts_with("/preset") {
927 let arg = msg.strip_prefix("/preset").unwrap_or("").trim();
928 match arg {
929 "fast" | "balanced" | "premium" => {
930 let preset_enum = arg
931 .parse::<crate::model_config::Preset>()
932 .unwrap_or(crate::model_config::Preset::Premium);
933 app.model_config =
934 crate::model_config::ModelConfig::resolve(
935 preset_enum,
936 ".",
937 None,
938 None,
939 None,
940 None,
941 );
942 app.chat_messages.push((
943 "system".into(),
944 format!("Switched to {} preset", arg),
945 ));
946 app.chat_messages.push((
947 "system".into(),
948 format!(
949 " Architect: {} | Coder: {} | CTO: {}",
950 app.model_config.architect.model,
951 app.model_config.coder.model,
952 app.model_config.cto.model,
953 ),
954 ));
955 app.log("info", format!("Preset: {}", arg));
956 }
957 _ => {
958 app.chat_messages.push((
959 "system".into(),
960 "Usage: /preset <fast|balanced|premium>".into(),
961 ));
962 }
963 }
964 continue;
965 } else if msg == "/cost" {
966 match crate::enterprise::total_cost() {
967 Ok(cost) => {
968 app.chat_messages.push((
969 "system".into(),
970 format!("Total API cost: ${:.4}", cost),
971 ));
972 }
973 Err(e) => app
974 .chat_messages
975 .push(("error".into(), format!("Failed: {}", e))),
976 }
977 continue;
978 } else if msg == "/help" {
979 app.chat_messages
980 .push(("system".into(), "── Commands ──".into()));
981 app.chat_messages.push((
982 "system".into(),
983 "/mission <prompt> — Launch a mission".into(),
984 ));
985 app.chat_messages.push((
986 "system".into(),
987 "/verify [path] — Run verifier (default: latest output)"
988 .into(),
989 ));
990 app.chat_messages.push((
991 "system".into(),
992 "/report [list|show] — View pipeline reports".into(),
993 ));
994 app.chat_messages.push((
995 "system".into(),
996 "/audit [n] — Show audit log (default: 10)".into(),
997 ));
998 app.chat_messages.push((
999 "system".into(),
1000 "/preset <name> — Switch preset (fast/balanced/premium)"
1001 .into(),
1002 ));
1003 app.chat_messages.push((
1004 "system".into(),
1005 "/cost — Show total API cost".into(),
1006 ));
1007 app.chat_messages.push((
1008 "system".into(),
1009 "/settings — Model picker".into(),
1010 ));
1011 app.chat_messages.push((
1012 "system".into(),
1013 "/clear — Clear chat + CTO history".into(),
1014 ));
1015 app.chat_messages.push((
1016 "system".into(),
1017 "/compress — Compact CTO history".into(),
1018 ));
1019 app.chat_messages.push((
1020 "system".into(),
1021 "/models /hw /status — Switch tabs / info".into(),
1022 ));
1023 app.chat_messages.push((
1024 "system".into(),
1025 "Or type any message to chat with CTO".into(),
1026 ));
1027 continue;
1028 }
1029
1030 app.chat_messages.push(("you".into(), msg.clone()));
1032 app.log(
1033 "info",
1034 format!("Prompt: {}", &msg.chars().take(50).collect::<String>()),
1035 );
1036
1037 if app.cto_agent.is_none() {
1039 let cto_model = &app.model_config.cto.model;
1040 let llm = LlmClient::with_limits(
1041 cto_model,
1042 app.model_config.cto.context_size(),
1043 app.model_config.cto.max_predict(),
1044 );
1045 let mut agent = crate::cto::CtoAgent::new(llm);
1046 agent.set_model_config(app.model_config.clone());
1047 agent.set_tui_event_tx(app.mission_event_tx.clone());
1048 agent.load_history().ok();
1049 app.cto_agent = Some(agent);
1050 app.log(
1051 "info",
1052 format!(
1053 "CTO agent initialized ({})",
1054 app.model_config.cto.model
1055 ),
1056 );
1057 }
1058
1059 let (tx, rx) = mpsc::channel(512);
1060 app.stream_rx = Some(rx);
1061 app.is_generating = true;
1062 app.stream_buffer.clear();
1063 app.status_line =
1064 format!("CTO STREAMING [{}]...", app.model_config.cto.model);
1065
1066 let mut agent = app.cto_agent.take().unwrap();
1068 let tx_clone = tx.clone();
1069 tokio::spawn(async move {
1070 agent.set_event_tx(tx_clone.clone());
1071 match agent.chat(&msg).await {
1072 Ok(response) => {
1073 let _ = tx_clone.send(StreamEvent::Done(response)).await;
1074 }
1075 Err(e) => {
1076 let _ =
1077 tx_clone.send(StreamEvent::Error(e.to_string())).await;
1078 }
1079 }
1080 let _ = tx_clone
1081 .send(StreamEvent::AgentReturn(Box::new(agent)))
1082 .await;
1083 });
1084 }
1085 KeyCode::Backspace if app.input_cursor > 0 => {
1087 app.input.remove(app.input_cursor - 1);
1088 app.input_cursor -= 1;
1089 }
1090 KeyCode::Delete if app.input_cursor < app.input.len() => {
1091 app.input.remove(app.input_cursor);
1092 }
1093 KeyCode::Left => {
1094 app.input_cursor = app.input_cursor.saturating_sub(1);
1095 }
1096 KeyCode::Right if app.input_cursor < app.input.len() => {
1097 app.input_cursor += 1;
1098 }
1099 KeyCode::Home => {
1100 if app.input.is_empty() {
1101 app.chat_auto_scroll = false;
1102 app.chat_scroll = app.chat_total_lines;
1103 } else {
1104 app.input_cursor = 0;
1105 }
1106 }
1107 KeyCode::End => {
1108 if app.input.is_empty() {
1109 app.chat_scroll = 0;
1110 app.chat_auto_scroll = true;
1111 } else {
1112 app.input_cursor = app.input.len();
1113 }
1114 }
1115 KeyCode::PageUp => {
1117 app.chat_auto_scroll = false;
1118 app.chat_scroll = app.chat_scroll.saturating_add(20);
1119 }
1120 KeyCode::PageDown => {
1121 if app.chat_scroll >= 20 {
1122 app.chat_scroll -= 20;
1123 } else {
1124 app.chat_scroll = 0;
1125 app.chat_auto_scroll = true;
1126 }
1127 }
1128 KeyCode::Up if app.input.is_empty() => {
1129 app.chat_auto_scroll = false;
1130 app.chat_scroll = app.chat_scroll.saturating_add(3);
1131 }
1132 KeyCode::Down if app.input.is_empty() => {
1133 if app.chat_scroll >= 3 {
1134 app.chat_scroll -= 3;
1135 } else {
1136 app.chat_scroll = 0;
1137 app.chat_auto_scroll = true;
1138 }
1139 }
1140 KeyCode::Esc => {
1141 if app.input.is_empty() {
1142 app.should_quit = true;
1143 } else {
1144 app.input.clear();
1145 app.input_cursor = 0;
1146 }
1147 }
1148 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1149 app.should_quit = true;
1150 }
1151 KeyCode::Char(c) => {
1152 if app.input.is_empty() && matches!(c, '1'..='6') {
1153 match c {
1154 '1' => app.current_tab = Tab::Chat,
1155 '2' => app.current_tab = Tab::Queue,
1156 '3' => app.current_tab = Tab::Models,
1157 '4' => app.current_tab = Tab::Code,
1158 '5' => app.current_tab = Tab::Hw,
1159 '6' => app.current_tab = Tab::Log,
1160 _ => {}
1161 }
1162 } else {
1163 app.input.insert(app.input_cursor, c);
1164 app.input_cursor += 1;
1165 }
1166 }
1167 KeyCode::Tab => {
1168 app.current_tab = app.current_tab.next();
1169 }
1170 _ => {}
1171 }
1172 } else {
1173 match key.code {
1175 KeyCode::PageUp => match app.current_tab {
1176 Tab::Code => {
1177 app.code_auto_scroll = false;
1178 app.code_scroll = app.code_scroll.saturating_add(20);
1179 }
1180 Tab::Log => {
1181 app.log_auto_scroll = false;
1182 app.log_scroll = app.log_scroll.saturating_add(20);
1183 }
1184 _ => {}
1185 },
1186 KeyCode::PageDown => match app.current_tab {
1187 Tab::Code => {
1188 if app.code_scroll >= 20 {
1189 app.code_scroll -= 20;
1190 } else {
1191 app.code_scroll = 0;
1192 app.code_auto_scroll = true;
1193 }
1194 }
1195 Tab::Log => {
1196 if app.log_scroll >= 20 {
1197 app.log_scroll -= 20;
1198 } else {
1199 app.log_scroll = 0;
1200 app.log_auto_scroll = true;
1201 }
1202 }
1203 _ => {}
1204 },
1205 KeyCode::Up => match app.current_tab {
1206 Tab::Code => {
1207 app.code_auto_scroll = false;
1208 app.code_scroll = app.code_scroll.saturating_add(3);
1209 }
1210 Tab::Log => {
1211 app.log_auto_scroll = false;
1212 app.log_scroll = app.log_scroll.saturating_add(3);
1213 }
1214 _ => {}
1215 },
1216 KeyCode::Down => match app.current_tab {
1217 Tab::Code => {
1218 if app.code_scroll >= 3 {
1219 app.code_scroll -= 3;
1220 } else {
1221 app.code_scroll = 0;
1222 app.code_auto_scroll = true;
1223 }
1224 }
1225 Tab::Log => {
1226 if app.log_scroll >= 3 {
1227 app.log_scroll -= 3;
1228 } else {
1229 app.log_scroll = 0;
1230 app.log_auto_scroll = true;
1231 }
1232 }
1233 _ => {}
1234 },
1235 KeyCode::Home => match app.current_tab {
1236 Tab::Code => {
1237 app.code_auto_scroll = false;
1238 app.code_scroll = app.code_total_lines;
1239 }
1240 Tab::Log => {
1241 app.log_auto_scroll = false;
1242 app.log_scroll = app.log_total_lines;
1243 }
1244 _ => {}
1245 },
1246 KeyCode::End => match app.current_tab {
1247 Tab::Code => {
1248 app.code_scroll = 0;
1249 app.code_auto_scroll = true;
1250 }
1251 Tab::Log => {
1252 app.log_scroll = 0;
1253 app.log_auto_scroll = true;
1254 }
1255 _ => {}
1256 },
1257 KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true,
1258 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1259 app.should_quit = true
1260 }
1261 KeyCode::Char('1') => app.current_tab = Tab::Chat,
1262 KeyCode::Char('2') => app.current_tab = Tab::Queue,
1263 KeyCode::Char('3') => app.current_tab = Tab::Models,
1264 KeyCode::Char('4') => app.current_tab = Tab::Code,
1265 KeyCode::Char('5') => app.current_tab = Tab::Hw,
1266 KeyCode::Char('6') => app.current_tab = Tab::Log,
1267 KeyCode::Tab => app.current_tab = app.current_tab.next(),
1268 _ => {}
1269 }
1270 }
1271 }
1272 }
1273
1274 if app.should_quit {
1275 break;
1276 }
1277 }
1278
1279 disable_raw_mode()?;
1280 execute!(io::stdout(), LeaveAlternateScreen)?;
1281 Ok(())
1282}
1283
1284fn wrapped_line_count(text: &str, width: usize) -> u16 {
1287 if width == 0 {
1288 return 1;
1289 }
1290 let len = text.len();
1291 if len <= width {
1292 1
1293 } else {
1294 len.div_ceil(width) as u16
1295 }
1296}
1297
1298fn render_chat<'a>(
1299 messages: &[(String, String)],
1300 stream: &str,
1301 generating: bool,
1302 scroll_offset: u16,
1303 auto_scroll: bool,
1304 visible_height: u16,
1305 content_width: usize,
1306 cto_model: &str,
1307) -> (Paragraph<'a>, u16) {
1308 let mut lines: Vec<Line> = Vec::new();
1309 let mut visual_total: u16 = 0;
1310 for (role, content) in messages {
1311 let (prefix, style, display_content) = match role.as_str() {
1312 "you" => {
1313 let display = if content.len() > 80 {
1315 format!(
1316 "{}... [{} chars]",
1317 &content[..77.min(content.len())],
1318 content.len()
1319 )
1320 } else {
1321 content.clone()
1322 };
1323 (
1324 "[YOU] ".to_string(),
1325 Style::default()
1326 .fg(Color::Green)
1327 .add_modifier(Modifier::BOLD),
1328 display,
1329 )
1330 }
1331 "cto" => (
1332 format!("[{}] ", cto_model),
1333 Style::default()
1334 .fg(Color::Cyan)
1335 .add_modifier(Modifier::BOLD),
1336 content.clone(),
1337 ),
1338 "error" => (
1339 "[ERR] ".to_string(),
1340 Style::default().fg(Color::Red),
1341 content.clone(),
1342 ),
1343 "tool" => (
1344 "[TOOL] ".to_string(),
1345 Style::default().fg(Color::Magenta),
1346 content.clone(),
1347 ),
1348 "tool_result" => (
1349 " ".to_string(),
1350 Style::default().fg(Color::DarkGray),
1351 content.clone(),
1352 ),
1353 _ => (
1354 "[SYS] ".to_string(),
1355 Style::default().fg(Color::DarkGray),
1356 content.clone(),
1357 ),
1358 };
1359 for line in display_content.lines() {
1360 let text = format!(" {}{}", prefix, line);
1361 visual_total += wrapped_line_count(&text, content_width);
1362 lines.push(Line::from(Span::styled(text, style)));
1363 }
1364 }
1365 if !stream.is_empty() {
1366 let stream_lines: Vec<&str> = stream.lines().rev().take(5).collect();
1367 for line in stream_lines.into_iter().rev() {
1368 let text = format!(" [{} ...] {}", cto_model, line);
1369 visual_total += wrapped_line_count(&text, content_width);
1370 lines.push(Line::from(Span::styled(
1371 text,
1372 Style::default().fg(Color::Yellow),
1373 )));
1374 }
1375 }
1376 if generating && stream.is_empty() {
1377 visual_total += 1;
1378 lines.push(Line::from(Span::styled(
1379 " Thinking...",
1380 Style::default().fg(Color::Yellow),
1381 )));
1382 }
1383
1384 let total = visual_total;
1385 let scroll = if auto_scroll {
1386 total.saturating_sub(visible_height)
1387 } else {
1388 let max_scroll = total.saturating_sub(visible_height);
1389 max_scroll.saturating_sub(scroll_offset.min(max_scroll))
1390 };
1391
1392 let title = if auto_scroll {
1393 " Chat [LIVE] ".to_string()
1394 } else {
1395 format!(" Chat [{}/{}] ", total.saturating_sub(scroll), total)
1396 };
1397
1398 let para = Paragraph::new(lines)
1399 .block(Block::default().borders(Borders::ALL).title(title))
1400 .wrap(Wrap { trim: false })
1401 .scroll((scroll, 0));
1402 (para, total)
1403}
1404
1405fn render_queue<'a>(items: &'a [QueueItem]) -> Paragraph<'a> {
1406 let mut lines: Vec<Line> = Vec::new();
1407 lines.push(Line::from(""));
1408
1409 if items.is_empty() {
1410 lines.push(Line::from(Span::styled(
1411 " No active missions.",
1412 Style::default().fg(Color::DarkGray),
1413 )));
1414 lines.push(Line::from(Span::styled(
1415 " Use /mission <prompt> or chat with CTO to launch.",
1416 Style::default().fg(Color::DarkGray),
1417 )));
1418 } else {
1419 lines.push(Line::from(vec![
1421 Span::styled(
1422 " Stage ",
1423 Style::default()
1424 .fg(Color::White)
1425 .add_modifier(Modifier::BOLD),
1426 ),
1427 Span::styled(
1428 "Step ",
1429 Style::default()
1430 .fg(Color::White)
1431 .add_modifier(Modifier::BOLD),
1432 ),
1433 Span::styled(
1434 "Model ",
1435 Style::default()
1436 .fg(Color::White)
1437 .add_modifier(Modifier::BOLD),
1438 ),
1439 Span::styled(
1440 "Status",
1441 Style::default()
1442 .fg(Color::White)
1443 .add_modifier(Modifier::BOLD),
1444 ),
1445 ]));
1446 lines.push(Line::from(Span::styled(
1447 " ─────────────────────────────────────────────────────────────────",
1448 Style::default().fg(Color::DarkGray),
1449 )));
1450
1451 for item in items {
1452 let status_color = if item.status == "running" {
1453 Color::Yellow
1454 } else if item.status.starts_with("done") {
1455 Color::Green
1456 } else {
1457 Color::Red
1458 };
1459 let status_marker = if item.status == "running" {
1460 ">>>"
1461 } else {
1462 " "
1463 };
1464 lines.push(Line::from(vec![
1465 Span::styled(
1466 format!(" [{}] ", item.stage),
1467 Style::default().fg(Color::Cyan),
1468 ),
1469 Span::styled(
1470 format!("{:<15}", item.step),
1471 Style::default().fg(Color::White),
1472 ),
1473 Span::styled(
1474 format!("{:<25}", truncate_display(&item.model, 24)),
1475 Style::default().fg(Color::DarkGray),
1476 ),
1477 Span::styled(status_marker, Style::default().fg(status_color)),
1478 Span::styled(&item.status, Style::default().fg(status_color)),
1479 ]));
1480 }
1481 }
1482
1483 Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(" Queue "))
1484}
1485
1486fn truncate_display(s: &str, max: usize) -> String {
1487 if s.len() <= max {
1488 s.to_string()
1489 } else {
1490 format!("{}...", &s[..max.saturating_sub(3)])
1491 }
1492}
1493
1494fn render_models<'a>() -> Paragraph<'a> {
1495 Paragraph::new(vec![
1496 Line::from(""),
1497 Line::from(Span::styled(
1498 " Model Configuration",
1499 Style::default()
1500 .fg(Color::Magenta)
1501 .add_modifier(Modifier::BOLD),
1502 )),
1503 Line::from(""),
1504 Line::from(Span::styled(
1505 " [premium] qwen3-coder:30b-a3b-q8_0",
1506 Style::default().fg(Color::Green),
1507 )),
1508 Line::from(Span::styled(
1509 " [balanced] qwen2.5-coder:32b",
1510 Style::default().fg(Color::Yellow),
1511 )),
1512 Line::from(Span::styled(
1513 " [fast] qwen2.5-coder:7b",
1514 Style::default().fg(Color::Red),
1515 )),
1516 Line::from(""),
1517 Line::from(" Claude API: set ANTHROPIC_API_KEY for Sonnet"),
1518 Line::from(""),
1519 Line::from(" CLI: battlecommand-forge models list|benchmark|presets"),
1520 ])
1521 .block(Block::default().borders(Borders::ALL).title(" Models "))
1522}
1523
1524fn render_code<'a>(
1525 content: &str,
1526 model: &str,
1527 streaming: bool,
1528 history: &[String],
1529 display_len: usize,
1530 scroll_offset: u16,
1531 auto_scroll: bool,
1532 visible_height: u16,
1533 content_width: usize,
1534) -> (Paragraph<'a>, u16) {
1535 let mut lines: Vec<Line> = Vec::new();
1536 let mut visual_total: u16 = 0;
1537
1538 if content.is_empty() && history.is_empty() {
1539 lines.push(Line::from(""));
1540 lines.push(Line::from(Span::styled(
1541 " No code being generated.",
1542 Style::default().fg(Color::DarkGray),
1543 )));
1544 lines.push(Line::from(Span::styled(
1545 " Start a mission or chat with the CTO.",
1546 Style::default().fg(Color::DarkGray),
1547 )));
1548 visual_total = 3;
1549 } else {
1550 let full_content = if content.is_empty() && !history.is_empty() {
1551 history.last().unwrap().as_str()
1552 } else {
1553 content
1554 };
1555
1556 let typewriter_active = display_len < full_content.len() && !full_content.is_empty();
1558 let display = if typewriter_active {
1559 let mut safe = display_len;
1561 while safe < full_content.len() && !full_content.is_char_boundary(safe) {
1562 safe += 1;
1563 }
1564 &full_content[..safe]
1565 } else {
1566 full_content
1567 };
1568
1569 for line in display.lines() {
1570 let text = format!(" {}", line);
1571 visual_total += wrapped_line_count(&text, content_width);
1572 lines.push(Line::from(Span::styled(
1573 text,
1574 Style::default().fg(Color::Green),
1575 )));
1576 }
1577
1578 if typewriter_active || streaming {
1580 visual_total += 1;
1581 lines.push(Line::from(Span::styled(
1582 " \u{2588}",
1583 Style::default()
1584 .fg(Color::Green)
1585 .add_modifier(Modifier::SLOW_BLINK),
1586 )));
1587 }
1588 }
1589
1590 let total = visual_total;
1591 let scroll = if auto_scroll {
1592 total.saturating_sub(visible_height)
1593 } else {
1594 let max_scroll = total.saturating_sub(visible_height);
1595 max_scroll.saturating_sub(scroll_offset.min(max_scroll))
1596 };
1597
1598 let title = if streaming {
1599 format!(" Code [{}|streaming] ", model)
1600 } else if display_len < content.len() && !content.is_empty() {
1601 format!(" Code [{}|typewriter] ", model)
1602 } else if auto_scroll {
1603 if model.is_empty() {
1604 " Code ".to_string()
1605 } else {
1606 format!(" Code [{}] ", model)
1607 }
1608 } else {
1609 format!(" Code [{}/{}] ", total.saturating_sub(scroll), total)
1610 };
1611
1612 let para = Paragraph::new(lines)
1613 .block(
1614 Block::default()
1615 .borders(Borders::ALL)
1616 .title(title)
1617 .style(Style::default().bg(Color::Black)),
1618 )
1619 .wrap(Wrap { trim: false })
1620 .scroll((scroll, 0));
1621 (para, total)
1622}
1623
1624fn render_hw<'a>(hw_lines: &[String]) -> Paragraph<'a> {
1625 let mut lines = vec![Line::from("")];
1626 lines.push(Line::from(Span::styled(
1627 " Hardware Monitor",
1628 Style::default()
1629 .fg(Color::Yellow)
1630 .add_modifier(Modifier::BOLD),
1631 )));
1632 lines.push(Line::from(""));
1633 for line in hw_lines {
1634 let style = if line
1636 .contains("\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}")
1637 {
1638 Style::default().fg(Color::Red)
1639 } else if line.contains("\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}") {
1640 Style::default().fg(Color::Yellow)
1641 } else {
1642 Style::default().fg(Color::Green)
1643 };
1644 lines.push(Line::from(Span::styled(format!(" {}", line), style)));
1645 }
1646 Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(" Hardware "))
1647}
1648
1649fn render_log<'a>(
1650 entries: &[LogEntry],
1651 thinking: &[ThinkingEntry],
1652 scroll_offset: u16,
1653 auto_scroll: bool,
1654 visible_height: u16,
1655 content_width: usize,
1656) -> (Paragraph<'a>, u16) {
1657 let mut lines: Vec<Line> = Vec::new();
1658 let mut visual_total: u16 = 0;
1659
1660 if let Some(last) = thinking.last() {
1662 let status = if last.is_active {
1663 Line::from(Span::styled(
1664 format!(" [{}] thinking...", last.model),
1665 Style::default().fg(Color::Yellow),
1666 ))
1667 } else {
1668 Line::from(Span::styled(
1669 format!(" [{}] done", last.model),
1670 Style::default().fg(Color::DarkGray),
1671 ))
1672 };
1673 visual_total += 1;
1674 lines.push(status);
1675
1676 for line in last
1678 .content
1679 .lines()
1680 .rev()
1681 .take(5)
1682 .collect::<Vec<_>>()
1683 .into_iter()
1684 .rev()
1685 {
1686 let text = format!(" {}", line);
1687 visual_total += wrapped_line_count(&text, content_width);
1688 lines.push(Line::from(Span::styled(
1689 text,
1690 Style::default().fg(Color::DarkGray),
1691 )));
1692 }
1693
1694 let sep = " ─────────────────────────────────────────";
1695 visual_total += 1;
1696 lines.push(Line::from(Span::styled(
1697 sep,
1698 Style::default().fg(Color::DarkGray),
1699 )));
1700 }
1701
1702 for e in entries
1704 .iter()
1705 .rev()
1706 .take(100)
1707 .collect::<Vec<_>>()
1708 .into_iter()
1709 .rev()
1710 {
1711 let level_color = match e.level.as_str() {
1712 "error" => Color::Red,
1713 "warn" => Color::Yellow,
1714 "info" => Color::Green,
1715 "debug" => Color::DarkGray,
1716 _ => Color::White,
1717 };
1718 let text = format!(
1719 " [{}] {:5} {}",
1720 e.timestamp,
1721 e.level.to_uppercase(),
1722 e.message
1723 );
1724 visual_total += wrapped_line_count(&text, content_width);
1725 lines.push(Line::from(vec![
1726 Span::styled(
1727 format!(" [{}] ", e.timestamp),
1728 Style::default().fg(Color::DarkGray),
1729 ),
1730 Span::styled(
1731 format!("{:5} ", e.level.to_uppercase()),
1732 Style::default().fg(level_color),
1733 ),
1734 Span::styled(e.message.clone(), Style::default().fg(Color::White)),
1735 ]));
1736 }
1737
1738 let total = visual_total;
1739 let scroll = if auto_scroll {
1740 total.saturating_sub(visible_height)
1741 } else {
1742 let max_scroll = total.saturating_sub(visible_height);
1743 max_scroll.saturating_sub(scroll_offset.min(max_scroll))
1744 };
1745
1746 let title = if auto_scroll {
1747 format!(" Log ({} entries) [LIVE] ", entries.len())
1748 } else {
1749 format!(
1750 " Log ({} entries) [{}/{}] ",
1751 entries.len(),
1752 total.saturating_sub(scroll),
1753 total
1754 )
1755 };
1756
1757 let para = Paragraph::new(lines)
1758 .block(Block::default().borders(Borders::ALL).title(title))
1759 .wrap(Wrap { trim: false })
1760 .scroll((scroll, 0));
1761 (para, total)
1762}
1763
1764fn render_status_bar<'a>(
1766 status: &str,
1767 completed: usize,
1768 total_tasks: usize,
1769 cost: f64,
1770 vram: f64,
1771) -> Paragraph<'a> {
1772 let mut spans: Vec<Span> = vec![
1773 Span::styled(
1774 " FORGE ",
1775 Style::default()
1776 .fg(Color::Black)
1777 .bg(Color::Red)
1778 .add_modifier(Modifier::BOLD),
1779 ),
1780 Span::raw(" "),
1781 Span::styled(status.to_string(), Style::default().fg(Color::Yellow)),
1782 ];
1783
1784 if total_tasks > 0 {
1785 spans.push(Span::styled(
1786 format!(" [{}/{}]", completed, total_tasks),
1787 Style::default()
1788 .fg(Color::Cyan)
1789 .add_modifier(Modifier::BOLD),
1790 ));
1791 }
1792
1793 spans.push(Span::raw(" | "));
1794 spans.push(Span::styled(
1795 format!("Cost: ${:.4}", cost),
1796 Style::default().fg(Color::Green),
1797 ));
1798
1799 if vram.abs() > 0.01 {
1800 spans.push(Span::raw(" | "));
1801 let vram_color = if vram > 40.0 {
1802 Color::Red
1803 } else if vram > 20.0 {
1804 Color::Yellow
1805 } else {
1806 Color::Green
1807 };
1808 spans.push(Span::styled(
1809 format!("VRAM {:.0}G", vram.abs()),
1810 Style::default().fg(vram_color).add_modifier(Modifier::BOLD),
1811 ));
1812 }
1813
1814 spans.push(Span::raw(" | "));
1815 spans.push(Span::styled(
1816 "Tab | Ctrl+C quit",
1817 Style::default().fg(Color::DarkGray),
1818 ));
1819
1820 Paragraph::new(Line::from(spans))
1821}
1822
1823fn extract_code_blocks(text: &str) -> String {
1825 let mut blocks = Vec::new();
1826 let mut in_block = false;
1827 let mut current = String::new();
1828
1829 for line in text.lines() {
1830 if line.trim_start().starts_with("```") {
1831 if in_block {
1832 blocks.push(current.clone());
1833 current.clear();
1834 in_block = false;
1835 } else {
1836 in_block = true;
1837 }
1838 } else if in_block {
1839 current.push_str(line);
1840 current.push('\n');
1841 }
1842 }
1843 blocks.join("\n---\n\n")
1844}
1845
1846fn to_available_models(models: &[crate::models::ModelInfo]) -> Vec<AvailableModel> {
1848 let mut available: Vec<AvailableModel> = models
1849 .iter()
1850 .map(|m| {
1851 let size_gb = m.size.trim_end_matches(" GB").parse::<f64>().unwrap_or(0.0);
1852 AvailableModel {
1853 name: m.name.clone(),
1854 size_gb,
1855 provider: crate::model_config::ModelProvider::Local,
1856 }
1857 })
1858 .collect();
1859 available.sort_by(|a, b| {
1861 b.size_gb
1862 .partial_cmp(&a.size_gb)
1863 .unwrap_or(std::cmp::Ordering::Equal)
1864 });
1865
1866 for &(model_id, _label) in model_picker::CLAUDE_MODELS {
1868 available.push(AvailableModel {
1869 name: model_id.to_string(),
1870 size_gb: 0.0,
1871 provider: crate::model_config::ModelProvider::Cloud,
1872 });
1873 }
1874
1875 available
1876}