//! Screen 1: Agent Studio view (Dual-Deck: Stream & Web Preview + Telemetry HUD). //! //! All content is derived from real API data: //! - Web preview URL comes from `launch_servers` (real `/sessions/{id}/launch`). //! - Tool execution cards come from `presentation_items` (real SSE). //! - Git diff inspector shows real diff data from presentation items. //! - Telemetry gauges show real `/health` values. //! - Sandbox name comes from real health report. use ratatui::buffer::Buffer; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::Modifier; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph, Widget}; use crate::app::{DeckFocus, PresItem}; use crate::telemetry::TelemetryState; use crate::theme::{Symbols, Theme}; pub struct StudioView<'a> { pub telemetry: &'a TelemetryState, pub theme: &'a Theme, pub is_running: bool, pub deck_focus: DeckFocus, pub card_expanded: bool, pub presentation_items: &'a [PresItem], pub launch_url: Option<&'a str>, pub sandbox_name: &'a str, pub cwd: &'a str, } impl<'a> Widget for StudioView<'a> { fn render(self, area: Rect, buf: &mut Buffer) { let main_decks = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) .split(area); let left_border = if self.deck_focus == DeckFocus::Left { self.theme.style_border_focus() } else { self.theme.style_border() }; let right_border = if self.deck_focus == DeckFocus::Right { self.theme.style_border_focus() } else { self.theme.style_border() }; // ------------------------------------------------------------- // LEFT DECK: Stream, Previews & Tool Runs // ------------------------------------------------------------- let doc_height = if self.card_expanded { 11 } else { 7 }; let left_chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(9), Constraint::Length(doc_height), Constraint::Min(6), ]) .split(main_decks[0]); // 1. Live Webpage Preview Card — real URL from launch_servers let preview_url = self.launch_url.unwrap_or("(no server running)"); let prev_block = Block::default() .borders(Borders::ALL) .border_style(left_border) .title(format!( " {} [PREVIEW] {preview_url} {} ", Symbols::BULLET, Symbols::EXTERNAL_LINK )) .title_style(self.theme.style_accent().add_modifier(Modifier::BOLD)); let prev_inner = prev_block.inner(left_chunks[0]); prev_block.render(left_chunks[0], buf); // Preview button if prev_inner.height >= 2 && prev_inner.width >= 10 { let btn_area = Rect { x: prev_inner.x + 2, y: prev_inner.y + prev_inner.height.saturating_sub(1), width: 32.min(prev_inner.width.saturating_sub(4)), height: 1, }; let btn_span = Span::styled( " [ [PREVIEW] in Browser (o) ] ", self.theme.style_tab_active(), ); Paragraph::new(Line::from(vec![btn_span])).render(btn_area, buf); } // 2. Markdown Document Card — real presentation items let mut doc_lines: Vec = Vec::new(); // Show first text-like presentation item as the document card content let doc_item = self .presentation_items .iter() .find(|i| i.kind == "text" || i.kind == "markdown" || i.kind == "document"); if let Some(item) = doc_item { doc_lines.push(Line::from(vec![ Span::styled(item.kind.clone(), self.theme.style_card()), Span::styled(format!(" {}", item.title), self.theme.style_accent()), ])); doc_lines.push(Line::from(vec![Span::styled( item.status.clone(), self.theme.style_card().add_modifier(Modifier::DIM), )])); } else { doc_lines.push(Line::from(vec![Span::styled( "Waiting for presentation events…", self.theme.style_card().add_modifier(Modifier::DIM), )])); } if self.card_expanded { doc_lines.push(Line::from(vec![ Span::styled(format!(" {} ", Symbols::CHECK), self.theme.style_ok()), Span::styled( format!("Sandbox: {} ", self.sandbox_name), self.theme.style_ok(), ), ])); doc_lines.push(Line::from(vec![ Span::styled(format!(" {} ", Symbols::BULLET), self.theme.style_accent()), Span::styled( format!("Workspace: {} ", self.cwd), self.theme.style_card().add_modifier(Modifier::DIM), ), ])); } let doc_caret = if self.card_expanded { Symbols::CARET_EXPANDED } else { Symbols::CARET_COLLAPSED }; let doc_block = Block::default() .borders(Borders::ALL) .border_style(left_border) .title(format!(" {} Markdown Document Card [Space] ", doc_caret)) .title_style(self.theme.style_card()); Paragraph::new(doc_lines) .block(doc_block) .render(left_chunks[1], buf); // 3. Tool Execution Card — real presentation items with 'tool' kind let tool_items: Vec<&PresItem> = self .presentation_items .iter() .filter(|i| i.kind == "tool" || i.kind == "bash" || i.kind == "tool_call") .collect(); let tool_lines: Vec = if tool_items.is_empty() { vec![Line::from(vec![Span::styled( "Waiting for tool execution events…", self.theme.style_card().add_modifier(Modifier::DIM), )])] } else { let mut lines = Vec::new(); for item in tool_items.iter().take(5) { let icon = if item.status == "running" || item.status == "active" { Symbols::STATUS_ACTIVE } else if item.status == "error" || item.status == "failed" { Symbols::CROSS } else { Symbols::CHECK }; let status_style = if item.status == "running" || item.status == "active" { self.theme.style_ok() } else if item.status == "error" || item.status == "failed" { self.theme.style_danger() } else { self.theme.style_ok() }; lines.push(Line::from(vec![ Span::styled(format!(" {} ", icon), status_style), Span::styled(item.title.clone(), self.theme.style_card()), Span::styled(format!(" [{}] ", item.status), status_style), ])); } lines }; let spinner = Symbols::SPINNER_FRAMES[self.telemetry.tick_count % Symbols::SPINNER_FRAMES.len()]; let (title, title_style) = if self.is_running { ( format!( " {} Agent Stream [{} RUNNING] ", Symbols::CARET_EXPANDED, spinner ), self.theme.style_accent(), ) } else { ( format!(" {} Tool Execution History ", Symbols::CARET_EXPANDED), self.theme.style_ok(), ) }; let tool_block = Block::default() .borders(Borders::ALL) .border_style(self.theme.style_border_focus()) .title(title) .title_style(title_style); Paragraph::new(tool_lines) .block(tool_block) .render(left_chunks[2], buf); // ------------------------------------------------------------- // RIGHT DECK: Telemetry HUD // ------------------------------------------------------------- let hud_chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(6), Constraint::Length(4), Constraint::Min(8), ]) .split(main_decks[1]); // 1. System Metrics & Swarm Radar let gauge_chunks = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) .split(hud_chunks[0]); // CPU & RSS Gauges (real values from health when available) let gauge_lines = vec![ Line::from(vec![ Span::styled("CPU UTIL: ", self.theme.style_card()), Span::styled( format!("{:.1}% ", self.telemetry.cpu_percent), self.theme.style_accent().add_modifier(Modifier::BOLD), ), Span::styled("[■■■■□□□□]", self.theme.style_accent()), ]), Line::from(vec![ Span::styled("MEMORY RSS:", self.theme.style_card()), Span::styled( format!("{:.1}MB ", self.telemetry.rss_mb), self.theme.style_ok().add_modifier(Modifier::BOLD), ), Span::styled("[■■■■■■□□]", self.theme.style_ok()), ]), Line::from(vec![ Span::styled("SANDBOX: ", self.theme.style_card()), Span::styled( self.sandbox_name, if self.sandbox_name == "none" { self.theme.style_danger() } else { self.theme.style_ok() }, ), ]), ]; let gauge_block = Block::default() .borders(Borders::ALL) .border_style(right_border) .title(" System Metrics [Tab] ") .title_style(self.theme.style_card()); Paragraph::new(gauge_lines) .block(gauge_block) .render(gauge_chunks[0], buf); // Swarm Radar (real worker count from SSE) let radar_lines = vec![ Line::from(vec![ Span::styled( format!(" {} ACTIVE: ", Symbols::STATUS_ACTIVE), self.theme.style_accent(), ), Span::styled( format!("{} agents", self.telemetry.active_workers), self.theme.style_card().add_modifier(Modifier::BOLD), ), ]), Line::from(vec![ Span::styled( format!(" {} PENDING: ", Symbols::STATUS_IDLE), self.theme.style_card(), ), Span::styled( format!("{} tasks", self.telemetry.pending_tasks), self.theme.style_card(), ), ]), Line::from(vec![ Span::styled(" RADAR: ", self.theme.style_card()), Span::styled( format!("SWEEP {:.0}°", self.telemetry.radar_angle_deg), self.theme.style_ok(), ), ]), ]; let radar_block = Block::default() .borders(Borders::ALL) .border_style(right_border) .title(" Agent Swarm Radar ") .title_style(self.theme.style_card()); Paragraph::new(radar_lines) .block(radar_block) .render(gauge_chunks[1], buf); // 2. Token Velocity Waveform (real samples from TurnEnd events) let mut wave_str = String::new(); for val in &self.telemetry.token_rate_history { let symbol = if *val > 150 { "▲" } else if *val > 100 { "∿" } else { "~" }; wave_str.push_str(symbol); } let max_rate = self.telemetry.max_rate.max(1); let rate_label = if wave_str.is_empty() { "no data".to_string() } else { format!("{max_rate} tok/s ") }; let wave_lines = vec![Line::from(vec![ Span::styled("RATE: ", self.theme.style_card()), Span::styled( rate_label, self.theme.style_accent().add_modifier(Modifier::BOLD), ), Span::styled(wave_str, self.theme.style_accent()), ])]; let wave_block = Block::default() .borders(Borders::ALL) .border_style(right_border) .title(" Token Burn-Rate ") .title_style(self.theme.style_card()); Paragraph::new(wave_lines) .block(wave_block) .render(hud_chunks[1], buf); // 3. Multi-File Diff Inspector — real diff data from presentation items let diff_items: Vec<&PresItem> = self .presentation_items .iter() .filter(|i| i.kind == "diff" || i.kind == "file_diff") .collect(); let diff_lines: Vec = if diff_items.is_empty() { vec![Line::from(vec![Span::styled( "Waiting for diff data…", self.theme.style_card().add_modifier(Modifier::DIM), )])] } else { let mut lines = Vec::new(); for item in diff_items.iter().take(5) { let status_icon = if item.status.contains("ok") || item.status.contains("done") { Symbols::CHECK } else { Symbols::STATUS_ACTIVE }; lines.push(Line::from(vec![ Span::styled( item.title.clone(), self.theme.style_card().add_modifier(Modifier::BOLD), ), Span::styled(format!(" [{}] ", item.status), self.theme.style_ok()), Span::styled(format!(" {} ", status_icon), self.theme.style_ok()), ])); // Show the summary from the status field lines.push(Line::from(vec![Span::styled( item.status.clone(), self.theme.style_card(), )])); } lines }; let diff_block = Block::default() .borders(Borders::ALL) .border_style(right_border) .title(" Multi-File Diff Inspector [Ctrl+K] ") .title_style(self.theme.style_card()); Paragraph::new(diff_lines) .block(diff_block) .render(hud_chunks[2], buf); } }