Skip to main content

battlecommand_forge/
tui.rs

1//! Terminal UI — 6 tabs: Chat, Queue, Models, Code, HW, Log.
2//! Full snake easter egg, live streaming, hardware monitoring, code viewer.
3//! Status bar with live cost, VRAM, task counter. Typewriter code animation.
4
5use 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
71/// Structured log entry for the Log tab.
72struct LogEntry {
73    level: String,
74    message: String,
75    timestamp: String,
76}
77
78/// Queue item for the Queue tab.
79struct QueueItem {
80    stage: String,
81    step: String,
82    model: String,
83    status: String, // "running", "completed", "failed"
84}
85
86/// Thinking entry for reasoning visualization in Log tab.
87struct 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    // Streaming
99    stream_buffer: String,
100    stream_rx: Option<mpsc::Receiver<StreamEvent>>,
101    is_generating: bool,
102    // Code tab
103    code_content: String,
104    code_model: String,
105    code_streaming: bool,
106    code_history: Vec<String>,
107    code_display_len: usize, // Typewriter: chars revealed so far
108    // Code scroll
109    code_scroll: u16,
110    code_auto_scroll: bool,
111    code_total_lines: u16,
112    // Queue tab
113    queue_items: Vec<QueueItem>,
114    // Log tab (structured)
115    log_entries: Vec<LogEntry>,
116    // Log scroll
117    log_scroll: u16,
118    log_auto_scroll: bool,
119    log_total_lines: u16,
120    // Thinking buffer
121    thinking_buffer: Vec<ThinkingEntry>,
122    // HW
123    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    // Easter eggs
129    snake_game: Option<SnakeGame>,
130    space_game: Option<SpaceGame>,
131    // Model picker
132    picker_state: Option<ModelPickerState>,
133    model_config: ModelConfig,
134    // Chat scrolling
135    chat_scroll: u16,
136    chat_auto_scroll: bool,
137    chat_total_lines: u16,
138    // CTO agent (persists across messages)
139    cto_agent: Option<crate::cto::CtoAgent>,
140    // Mission event channel
141    mission_event_rx: mpsc::UnboundedReceiver<TuiEvent>,
142    mission_event_tx: mpsc::UnboundedSender<TuiEvent>,
143    // Status bar
144    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    // Initial HW poll
233    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    // HW refresh counter
241    let mut hw_tick = 0u32;
242
243    loop {
244        // Easter egg overlays take priority
245        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), // Tab bar
260                        Constraint::Min(0),    // Content
261                        Constraint::Length(1), // Status bar
262                        Constraint::Length(3), // Input
263                    ])
264                    .split(f.area());
265
266                // Tab bar with HW summary in title
267                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                // Content area
294                let visible_height = chunks[1].height.saturating_sub(2); // minus borders
295                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                // Status bar
345                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                // Input bar
363                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                // Picker overlay (drawn on top)
380                if let Some(ref picker) = picker_ref {
381                    model_picker::draw_model_picker(f, picker);
382                }
383            })?;
384        }
385
386        // Drain streaming tokens
387        {
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; // Reset typewriter for new code
402                                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        // Drain mission events
448        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                    // Reset typewriter if content is brand new (not appended)
478                    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 refresh every ~4 seconds (80 ticks * 50ms) — always poll for status bar
537        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        // Typewriter tick — advance 12 chars per frame (~240 chars/sec)
550        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            // Safe char boundary
555            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        // Easter egg ticks
568        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                // Easter egg intercepts
578                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                // Picker intercept
594                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                                        // Save to .battlecommand/models.toml
607                                        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                            // ── Slash commands ──
648                            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                                // Duplicate mission guard
716                                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                                    // Find most recent output directory
757                                    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                                    // /report show [path]
854                                    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                            // ── Regular chat → CTO agent ──
1031                            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                            // Initialize CTO agent on first use
1038                            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                            // Take agent, spawn async task, return via channel
1067                            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                        // ── Input cursor movement ──
1086                        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                        // ── Scrolling ──
1116                        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                    // Non-Chat tabs: scroll handling for Code + Log
1174                    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
1284// ── Renderers ──
1285
1286fn 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                // Truncate long user messages (v2 parity)
1314                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        // Header
1420        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        // Typewriter: only show first display_len chars
1557        let typewriter_active = display_len < full_content.len() && !full_content.is_empty();
1558        let display = if typewriter_active {
1559            // Safe char boundary
1560            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        // Blinking cursor during typewriter or streaming
1579        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        // Color code based on content
1635        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    // Thinking section (top) — show last thinking entry
1661    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        // Show last few lines of thinking content (dimmed)
1677        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    // Log entries (bottom)
1703    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
1764/// Status bar: FORGE badge | status | [completed/total] | Cost | VRAM | help
1765fn 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
1823/// Extract code blocks (``` fenced) from a CTO response.
1824fn 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
1846/// Convert Ollama model list to AvailableModel list for picker (includes Claude cloud models).
1847fn 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    // Sort by size descending (bigger models first)
1860    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    // Append Claude cloud models
1867    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}