use ratatui::{ layout::{Alignment, Rect}, style::{Modifier, Style}, text::{Line, Span}, widgets::{Clear, Paragraph}, Frame, }; use super::sidebar::{ agent_panel_entries, agent_panel_entries_from, grouped_child_display_label, next_entry_is_indented_workspace, workspace_list_entries_expanded, AgentPanelEntry, WorkspaceListEntry, }; use super::status::{agent_icon, state_dot}; use super::text::{display_width_u16, truncate_end}; use crate::app::state::{Palette, ToastKind, ToastNotification}; use crate::app::AppState; use crate::detect::AgentState; use crate::layout::PaneId; use crate::terminal::TerminalRuntimeRegistry; const SWITCH_BUTTON_WIDTH: u16 = 10; #[derive(Debug, Clone, Copy, Default)] pub(crate) struct MobileHeaderHitAreas { pub menu: Rect, } #[derive(Debug, Clone, Copy, Default)] pub(crate) struct MobileSwitcherAreas { pub close: Rect, pub viewport: Rect, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum MobileSwitcherTarget { NewWorkspace, Workspace(usize), NewTab, Tab(usize), Agent { ws_idx: usize, tab_idx: usize, pane_id: PaneId, }, Menu(usize), } pub(crate) fn is_mobile_width(area: Rect, threshold: u16) -> bool { area.width > 0 && area.width <= threshold } pub(crate) fn compute_mobile_header_hit_areas(_app: &AppState, area: Rect) -> MobileHeaderHitAreas { if area.width == 0 || area.height == 0 { return MobileHeaderHitAreas::default(); } let width = SWITCH_BUTTON_WIDTH.min(area.width); let switch = Rect::new( area.x + area.width.saturating_sub(width), area.y, width, area.height, ); MobileHeaderHitAreas { menu: switch } } pub(crate) fn mobile_switcher_areas(app: &AppState) -> MobileSwitcherAreas { let screen = mobile_screen_rect(app); if screen.width == 0 || screen.height <= 2 { return MobileSwitcherAreas::default(); } let header_h = screen.height.min(2); let close_w = 10u16.min(screen.width); let close = Rect::new( screen.x + screen.width.saturating_sub(close_w), screen.y, close_w, header_h, ); let viewport = Rect::new( screen.x, screen.y + header_h + 1, screen.width, screen.height.saturating_sub(header_h + 1), ); MobileSwitcherAreas { close, viewport } } pub(crate) fn mobile_switcher_max_scroll_for_height(app: &AppState, viewport_height: u16) -> usize { mobile_switcher_content_height(app).saturating_sub(viewport_height as usize) } /// Doc-row height of the agents section (title + two rows per agent), or 0 when /// there are no agents so we don't show an empty header. The switcher leads with /// agents, so every section below it is offset by this. fn mobile_agents_block_height(app: &AppState) -> usize { let count = agent_panel_entries(app).len(); if count == 0 { 0 } else { 1 + count * 2 } } pub(crate) fn mobile_switcher_workspace_doc_range( app: &AppState, idx: usize, ) -> std::ops::Range { // Spaces render in grouped order, so a workspace's row position is its index // in the entry list, not its raw array index. let pos = workspace_list_entries_expanded(app) .iter() .position(|WorkspaceListEntry::Workspace { ws_idx, .. }| *ws_idx == idx) .unwrap_or(idx); // spaces sit after the agents block, then a title + "new workspace" row. let start = mobile_agents_block_height(app) + 2 + pos * 2; start..start + 2 } pub(crate) fn mobile_switcher_max_scroll(app: &AppState) -> usize { mobile_switcher_max_scroll_for_height(app, mobile_switcher_areas(app).viewport.height) } pub(crate) fn mobile_switcher_target_at( app: &AppState, col: u16, row: u16, ) -> Option { let areas = mobile_switcher_areas(app); let content = inset_for_left_scrollbar(areas.viewport); if !rect_contains(content, col, row) { return None; } let doc_row = app .mobile_switcher_scroll .saturating_add(row.saturating_sub(areas.viewport.y) as usize); let mut cursor = 0usize; // Agents lead the switcher: the primary job is switching between running // agents. Spaces/tabs/create actions follow for navigation and management. // The section is omitted entirely when there are no agents. let agents = agent_panel_entries(app); if !agents.is_empty() { cursor += 1; // agents title let agents_end = cursor + agents.len() * 2; if doc_row >= cursor && doc_row < agents_end { let idx = (doc_row - cursor) / 2; return agents.get(idx).map(|entry| MobileSwitcherTarget::Agent { ws_idx: entry.ws_idx, tab_idx: entry.tab_idx, pane_id: entry.pane_id, }); } cursor = agents_end; } cursor += 1; // spaces title if doc_row == cursor { return Some(MobileSwitcherTarget::NewWorkspace); } cursor += 1; // Spaces render in grouped (worktree-tree) order, which differs from raw // array order, so map the clicked row to the entry's real workspace index. let space_entries = workspace_list_entries_expanded(app); let spaces_end = cursor + space_entries.len() * 2; if doc_row >= cursor && doc_row < spaces_end { let entry_idx = (doc_row - cursor) / 2; return space_entries.get(entry_idx).map( |WorkspaceListEntry::Workspace { ws_idx, .. }| MobileSwitcherTarget::Workspace(*ws_idx), ); } cursor = spaces_end; if let Some(ws) = app.active.and_then(|idx| app.workspaces.get(idx)) { cursor += 1; // tabs title if doc_row == cursor { return Some(MobileSwitcherTarget::NewTab); } cursor += 1; let tabs_end = cursor + ws.tabs.len(); if doc_row >= cursor && doc_row < tabs_end { return Some(MobileSwitcherTarget::Tab(doc_row - cursor)); } cursor = tabs_end; } cursor += 1; // menu title let menu_idx = doc_row.checked_sub(cursor)?; (menu_idx < app.global_menu_labels().len()).then_some(MobileSwitcherTarget::Menu(menu_idx)) } pub(crate) fn render_mobile_header( app: &AppState, terminal_runtimes: &TerminalRuntimeRegistry, frame: &mut Frame, area: Rect, ) { if area.width == 0 || area.height == 0 { return; } let p = &app.palette; fill_rect(frame, area, Style::default().bg(p.panel_bg)); let switch = app.view.mobile_menu_hit_area; let status_w = switch.x.saturating_sub(area.x).saturating_sub(1); let status = Rect::new(area.x, area.y, status_w, area.height); render_header_status(app, terminal_runtimes, frame, status); render_switch_button(app, frame, switch); } pub(crate) fn mobile_toast_banner_rect(area: Rect, offset_for_warning: bool) -> Rect { if area.width == 0 || area.height == 0 { return Rect::default(); } let y = area.y + area .height .saturating_sub(1 + if offset_for_warning { 1 } else { 0 }); Rect::new(area.x, y, area.width, 1) } pub(crate) fn render_mobile_toast_banner( frame: &mut Frame, area: Rect, toast: &ToastNotification, offset_for_warning: bool, p: &Palette, ) { if area.width == 0 || area.height == 0 { return; } let dot_color = match toast.kind { ToastKind::NeedsAttention => p.red, ToastKind::Finished => p.blue, ToastKind::UpdateInstalled => p.accent, }; let banner = mobile_toast_banner_rect(area, offset_for_warning); let bg = p.surface0; frame.render_widget(Clear, banner); fill_rect(frame, banner, Style::default().bg(bg)); frame.render_widget( Paragraph::new(Line::from(vec![ Span::styled(" ", Style::default().bg(bg)), Span::styled("●", Style::default().fg(dot_color).bg(bg)), Span::styled(" ", Style::default().bg(bg)), Span::styled( mobile_toast_title(toast), Style::default() .fg(p.text) .bg(bg) .add_modifier(Modifier::BOLD), ), Span::styled(" · ", Style::default().fg(p.overlay0).bg(bg)), Span::styled(&toast.context, Style::default().fg(p.overlay0).bg(bg)), ])), banner, ); } pub(crate) fn render_mobile_panel( app: &AppState, terminal_runtimes: &TerminalRuntimeRegistry, frame: &mut Frame, area: Rect, ) { if area.width == 0 || area.height == 0 { return; } let p = &app.palette; frame.render_widget(Clear, area); fill_rect(frame, area, Style::default().bg(p.panel_bg)); let areas = mobile_switcher_areas(app); frame.render_widget( Paragraph::new(" switch").style( Style::default() .fg(p.text) .bg(p.panel_bg) .add_modifier(Modifier::BOLD), ), Rect::new(area.x, area.y, areas.close.x.saturating_sub(area.x), 1), ); render_close_button(app, frame, areas.close); if area.height > areas.close.height { draw_horizontal_rule( frame, Rect::new(area.x, area.y + areas.close.height, area.width, 1), p, ); } render_mobile_switcher_content(app, terminal_runtimes, frame, areas.viewport); } fn render_header_status( app: &AppState, terminal_runtimes: &TerminalRuntimeRegistry, frame: &mut Frame, area: Rect, ) { if area.width == 0 || area.height == 0 { return; } let p = &app.palette; let Some(ws) = app.active.and_then(|idx| app.workspaces.get(idx)) else { frame.render_widget(Paragraph::new(" no workspace"), area); return; }; let (state, seen) = ws.aggregate_state(&app.terminals); let (dot, dot_style) = if matches!(state, AgentState::Working) { ( super::spinner_frame(app.spinner_tick), Style::default().fg(p.yellow), ) } else { state_dot(state, seen, p) }; let tab_label = mobile_tab_status(ws); let row1 = Rect::new(area.x, area.y, area.width, 1); let tab_w = display_width_u16(&tab_label) .saturating_add(1) .min(area.width); let name_w = area.width.saturating_sub(tab_w); frame.render_widget( Paragraph::new(Line::from(vec![ Span::raw(" "), Span::styled(dot, dot_style.bg(p.panel_bg)), Span::raw(" "), Span::styled( truncate_end( &ws.display_name_from(&app.terminals, terminal_runtimes), name_w.saturating_sub(4) as usize, ), Style::default() .fg(p.text) .bg(p.panel_bg) .add_modifier(Modifier::BOLD), ), ])), Rect::new(row1.x, row1.y, name_w, 1), ); frame.render_widget( Paragraph::new(tab_label) .style(Style::default().fg(p.overlay1).bg(p.panel_bg)) .alignment(Alignment::Right), Rect::new(row1.x + name_w, row1.y, tab_w, 1), ); if area.height > 1 { frame.render_widget( Paragraph::new(agent_summary_line(app, p, area.width)), Rect::new(area.x, area.y + 1, area.width, 1), ); } } fn mobile_tab_status(ws: &crate::workspace::Workspace) -> String { let tab_label = ws .tab_display_name(ws.active_tab) .unwrap_or_else(|| (ws.active_tab + 1).to_string()); if ws.tabs.len() <= 1 { format!("tab {tab_label}") } else { format!("tab {tab_label} · {}/{}", ws.active_tab + 1, ws.tabs.len()) } } fn render_switch_button(app: &AppState, frame: &mut Frame, area: Rect) { if area.width == 0 || area.height == 0 { return; } let p = &app.palette; fill_rect(frame, area, Style::default().bg(p.surface0)); for y in area.y..area.y + area.height { frame.buffer_mut()[(area.x, y)] .set_symbol("│") .set_style(Style::default().fg(p.surface_dim).bg(p.surface0)); } let label_y = if area.height > 1 { area.y + 1 } else { area.y }; frame.render_widget( Paragraph::new("switch") .style( Style::default() .fg(p.text) .bg(p.surface0) .add_modifier(Modifier::BOLD), ) .alignment(Alignment::Center), Rect::new(area.x + 1, label_y, area.width.saturating_sub(1), 1), ); // Attention badge: a blocked agent anywhere makes the button itself read as // "tap me" without the user reading the summary row. if global_agent_counts(app).blocked > 0 { let bx = area.x + area.width.saturating_sub(1); frame.buffer_mut()[(bx, area.y)] .set_symbol("●") .set_style(Style::default().fg(p.red).bg(p.surface0)); } } fn render_close_button(app: &AppState, frame: &mut Frame, area: Rect) { if area.width == 0 || area.height == 0 { return; } let p = &app.palette; fill_rect(frame, area, Style::default().bg(p.surface0)); for y in area.y..area.y + area.height { frame.buffer_mut()[(area.x, y)] .set_symbol("│") .set_style(Style::default().fg(p.surface_dim).bg(p.surface0)); } frame.render_widget( Paragraph::new("close") .style( Style::default() .fg(p.overlay1) .bg(p.surface0) .add_modifier(Modifier::BOLD), ) .alignment(Alignment::Center), Rect::new(area.x + 1, area.y, area.width.saturating_sub(1), 1), ); if area.height > 1 { frame.render_widget( Paragraph::new("×") .style( Style::default() .fg(p.text) .bg(p.surface0) .add_modifier(Modifier::BOLD), ) .alignment(Alignment::Center), Rect::new(area.x + 1, area.y + 1, area.width.saturating_sub(1), 1), ); } } fn mobile_switcher_content_height(app: &AppState) -> usize { // Derive spaces height from the same entry list the render/hit-test use so // the three never disagree. let spaces_h = 2 + workspace_list_entries_expanded(app).len() * 2; let tabs_h = app .active .and_then(|idx| app.workspaces.get(idx)) .map(|ws| 2 + ws.tabs.len()) .unwrap_or(0); let agents_h = mobile_agents_block_height(app); let menu_h = 1 + app.global_menu_labels().len(); spaces_h + tabs_h + agents_h + menu_h } fn render_mobile_switcher_content( app: &AppState, terminal_runtimes: &TerminalRuntimeRegistry, frame: &mut Frame, viewport: Rect, ) { if viewport.width == 0 || viewport.height == 0 { return; } let p = &app.palette; let total_height = mobile_switcher_content_height(app); render_left_scrollbar( frame, viewport, total_height, viewport.height as usize, app.mobile_switcher_scroll, p, ); let content = inset_for_left_scrollbar(viewport); if content == Rect::default() { return; } let mut doc_y = 0usize; let entries = agent_panel_entries_from(app, terminal_runtimes); if !entries.is_empty() { let focused_agent = app.active.and_then(|ws_idx| { let ws = app.workspaces.get(ws_idx)?; ws.focused_pane_id() .map(|pane_id| (ws_idx, ws.active_tab, pane_id)) }); render_section_title_at( frame, viewport, content, doc_y, app.mobile_switcher_scroll, "agents", p, ); doc_y += 1; for entry in &entries { let active = focused_agent.is_some_and(|(ws_idx, tab_idx, pane_id)| { entry.ws_idx == ws_idx && entry.tab_idx == tab_idx && entry.pane_id == pane_id }); let bg = mobile_item_bg(false, active, p); let (icon, icon_style) = agent_icon(entry.state, entry.seen, app.spinner_tick, p); let title = Line::from(vec![ Span::styled(" ", Style::default().bg(bg)), Span::styled(icon, icon_style.bg(bg)), Span::styled(" ", Style::default().bg(bg)), Span::styled( truncate_end( &entry.primary_label, content.width.saturating_sub(5) as usize, ), Style::default() .fg(p.text) .bg(bg) .add_modifier(Modifier::BOLD), ), ]); let detail = mobile_agent_detail(entry); render_two_line_item( frame, viewport, content, doc_y, app.mobile_switcher_scroll, bg, title, truncate_end(&detail, content.width as usize), p.overlay0, ); doc_y += 2; } } render_section_title_at( frame, viewport, content, doc_y, app.mobile_switcher_scroll, "spaces", p, ); doc_y += 1; render_action_row_at( frame, viewport, content, doc_y, app.mobile_switcher_scroll, "+ new workspace", p, ); doc_y += 1; let space_entries = workspace_list_entries_expanded(app); for (entry_idx, WorkspaceListEntry::Workspace { ws_idx, indented }) in space_entries.iter().enumerate() { let Some(ws) = app.workspaces.get(*ws_idx) else { continue; }; let active = Some(*ws_idx) == app.active; let selected = *ws_idx == app.selected; let bg = mobile_item_bg(selected, active, p); let (state, seen) = ws.aggregate_state(&app.terminals); let (dot, dot_style) = state_dot(state, seen, p); let mut title_spans = vec![Span::styled(" ", Style::default().bg(bg))]; // Worktrees of the same space render as branches off their parent, so a // child gets an L/T connector on its name row and a matching vertical // continuation on its detail row. let detail_prefix = if *indented { let last_child = !next_entry_is_indented_workspace(&space_entries, entry_idx); title_spans.push(Span::styled( if last_child { "└─ " } else { "├─ " }, Style::default().fg(p.overlay0).bg(bg), )); if last_child { " " } else { " │ " } } else { " " }; title_spans.push(Span::styled(dot, dot_style.bg(bg))); title_spans.push(Span::styled(" ", Style::default().bg(bg))); let raw_label = ws.display_name_from(&app.terminals, terminal_runtimes); let name = if *indented { grouped_child_display_label( &raw_label, ws.branch().as_deref(), ws.custom_name.is_some(), ) } else { raw_label }; let name_budget = content.width.saturating_sub(if *indented { 8 } else { 5 }) as usize; title_spans.push(Span::styled( truncate_end(&name, name_budget), Style::default() .fg(p.text) .bg(bg) .add_modifier(Modifier::BOLD), )); let detail = format!( "{detail_prefix}{} · {}", ws.branch().unwrap_or_else(|| "shell".into()), mobile_tab_status(ws) ); render_two_line_item( frame, viewport, content, doc_y, app.mobile_switcher_scroll, bg, Line::from(title_spans), truncate_end(&detail, content.width as usize), p.overlay0, ); doc_y += 2; } if let Some(ws) = app.active.and_then(|idx| app.workspaces.get(idx)) { render_section_title_at( frame, viewport, content, doc_y, app.mobile_switcher_scroll, "tabs", p, ); doc_y += 1; render_action_row_at( frame, viewport, content, doc_y, app.mobile_switcher_scroll, "+ new tab", p, ); doc_y += 1; for (idx, tab) in ws.tabs.iter().enumerate() { let active = idx == ws.active_tab; let bg = mobile_item_bg(false, active, p); let display_name = ws .tab_display_name(idx) .unwrap_or_else(|| (idx + 1).to_string()); let label = if tab.is_auto_named() { format!("tab {display_name}") } else { format!("{} · {display_name}", idx + 1) }; let title = Line::from(vec![ Span::styled(" ", Style::default().bg(bg)), Span::styled( truncate_end(&label, content.width.saturating_sub(3) as usize), Style::default() .fg(p.text) .bg(bg) .add_modifier(Modifier::BOLD), ), ]); render_one_line_item( frame, viewport, content, doc_y, app.mobile_switcher_scroll, bg, title, ); doc_y += 1; } } render_section_title_at( frame, viewport, content, doc_y, app.mobile_switcher_scroll, "menu", p, ); doc_y += 1; for label in app.global_menu_labels() { if let Some(y) = visible_y(viewport, app.mobile_switcher_scroll, doc_y) { frame.render_widget( Paragraph::new(format!(" {label}")) .style(Style::default().fg(p.overlay1).bg(p.panel_bg)), Rect::new(content.x, y, content.width, 1), ); } doc_y += 1; } } fn mobile_agent_detail(entry: &AgentPanelEntry) -> String { let mut parts = Vec::new(); if let Some(tab_label) = entry.primary_tab_label.as_deref() { parts.push(tab_label.to_string()); } let status = entry .state_labels .get(super::sidebar::agent_panel_status_key( entry.state, entry.seen, )) .cloned() .unwrap_or_else(|| super::status::state_label(entry.state, entry.seen).to_string()); parts.push(status); if let Some(agent_label) = entry.agent_label.as_deref() { parts.push(agent_label.to_string()); } if let Some(custom_status) = entry.custom_status.as_deref() { parts.push(custom_status.to_string()); } format!(" {}", parts.join(" · ")) } fn render_section_title_at( frame: &mut Frame, viewport: Rect, content: Rect, doc_y: usize, scroll: usize, title: &str, p: &Palette, ) { let Some(y) = visible_y(viewport, scroll, doc_y) else { return; }; render_section_title( frame, Rect::new(content.x, y, content.width.saturating_sub(1), 1), title, p, ); } fn render_action_row_at( frame: &mut Frame, viewport: Rect, content: Rect, doc_y: usize, scroll: usize, label: &str, p: &Palette, ) { let Some(y) = visible_y(viewport, scroll, doc_y) else { return; }; render_action_row(frame, Rect::new(content.x, y, content.width, 1), label, p); } fn render_one_line_item( frame: &mut Frame, viewport: Rect, content: Rect, doc_y: usize, scroll: usize, bg: ratatui::style::Color, title: Line<'_>, ) { fill_visible_doc_rect( frame, viewport, content, doc_y, 1, Style::default().bg(bg), scroll, ); if let Some(y) = visible_y(viewport, scroll, doc_y) { frame.render_widget( Paragraph::new(title), Rect::new(content.x, y, content.width, 1), ); } } fn render_two_line_item( frame: &mut Frame, viewport: Rect, content: Rect, doc_y: usize, scroll: usize, bg: ratatui::style::Color, title: Line<'_>, detail: String, detail_fg: ratatui::style::Color, ) { fill_visible_doc_rect( frame, viewport, content, doc_y, 2, Style::default().bg(bg), scroll, ); if let Some(y) = visible_y(viewport, scroll, doc_y) { frame.render_widget( Paragraph::new(title), Rect::new(content.x, y, content.width, 1), ); } if let Some(y) = visible_y(viewport, scroll, doc_y + 1) { frame.render_widget( Paragraph::new(detail).style(Style::default().fg(detail_fg).bg(bg)), Rect::new(content.x, y, content.width, 1), ); } } fn visible_y(viewport: Rect, scroll: usize, doc_y: usize) -> Option { let offset = doc_y.checked_sub(scroll)?; (offset < viewport.height as usize).then_some(viewport.y + offset as u16) } fn fill_visible_doc_rect( frame: &mut Frame, viewport: Rect, content: Rect, doc_y: usize, height: usize, style: Style, scroll: usize, ) { for offset in 0..height { if let Some(y) = visible_y(viewport, scroll, doc_y + offset) { fill_rect(frame, Rect::new(content.x, y, content.width, 1), style); } } } fn mobile_item_bg(selected: bool, active: bool, p: &Palette) -> ratatui::style::Color { if selected { p.surface0 } else if active { p.surface_dim } else { p.panel_bg } } fn inset_for_left_scrollbar(area: Rect) -> Rect { if area.width <= 1 { return Rect::default(); } Rect::new(area.x + 1, area.y, area.width - 1, area.height) } fn render_left_scrollbar( frame: &mut Frame, area: Rect, total_rows: usize, visible_rows: usize, scroll: usize, p: &Palette, ) { if area.width == 0 || area.height == 0 || visible_rows == 0 || total_rows <= visible_rows { return; } let track = Rect::new(area.x, area.y, 1, area.height); let max_scroll = total_rows.saturating_sub(visible_rows); let thumb_len = ((track.height as usize * visible_rows).div_ceil(total_rows)) .max(1) .min(track.height as usize) as u16; let travel = track.height.saturating_sub(thumb_len); let thumb_top = track.y + ((travel as usize * scroll.min(max_scroll)) / max_scroll) as u16; for y in track.y..track.y + track.height { let is_thumb = y >= thumb_top && y < thumb_top + thumb_len; frame.buffer_mut()[(track.x, y)] .set_symbol(if is_thumb { "▌" } else { "│" }) .set_style( Style::default() .fg(if is_thumb { p.accent } else { p.surface_dim }) .bg(p.panel_bg), ); } } fn render_section_title(frame: &mut Frame, area: Rect, title: &str, p: &Palette) { frame.render_widget( Paragraph::new(format!(" {title} ")).style( Style::default() .fg(p.overlay1) .bg(p.panel_bg) .add_modifier(Modifier::BOLD | Modifier::UNDERLINED), ), Rect::new(area.x, area.y, area.width, 1), ); } fn render_action_row(frame: &mut Frame, area: Rect, label: &str, p: &Palette) { if area.width == 0 || area.height == 0 { return; } frame.render_widget( Paragraph::new(format!(" {label}")).style( Style::default() .fg(p.accent) .bg(p.panel_bg) .add_modifier(Modifier::BOLD), ), area, ); } fn rect_contains(rect: Rect, col: u16, row: u16) -> bool { rect.width > 0 && rect.height > 0 && col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height } fn mobile_screen_rect(app: &AppState) -> Rect { let header = app.view.mobile_header_rect; let terminal = app.view.terminal_area; let x = header.x.min(terminal.x); let y = header.y.min(terminal.y); let right = (header.x + header.width).max(terminal.x + terminal.width); let bottom = (header.y + header.height).max(terminal.y + terminal.height); Rect::new(x, y, right.saturating_sub(x), bottom.saturating_sub(y)) } /// Agent state counts across every workspace. The mobile header is global on /// purpose: while you stare at one terminal, a blocked agent anywhere should /// still surface. #[derive(Debug, Default, Clone, Copy)] struct GlobalAgentCounts { blocked: usize, done: usize, working: usize, idle: usize, } impl GlobalAgentCounts { fn total(&self) -> usize { self.blocked + self.done + self.working + self.idle } fn any_pending(&self) -> bool { self.blocked > 0 || self.done > 0 || self.working > 0 } } fn global_agent_counts(app: &AppState) -> GlobalAgentCounts { let mut counts = GlobalAgentCounts::default(); for entry in agent_panel_entries(app) { match (entry.state, entry.seen) { (AgentState::Blocked, _) => counts.blocked += 1, (AgentState::Idle, false) => counts.done += 1, (AgentState::Working, _) => counts.working += 1, (AgentState::Idle, true) => counts.idle += 1, (AgentState::Unknown, _) => {} } } counts } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SummaryTone { Blocked, Done, Working, Idle, Muted, } /// Ordered, non-zero breakdown for the header roll-up: attention states lead /// (blocked → done → working → idle). Pure so it can be unit-tested. fn agent_summary_segments(counts: GlobalAgentCounts) -> Vec<(String, SummaryTone)> { if counts.total() == 0 { return vec![("no agents".to_string(), SummaryTone::Muted)]; } if !counts.any_pending() { return vec![("all idle".to_string(), SummaryTone::Muted)]; } let mut segments = Vec::new(); if counts.blocked > 0 { segments.push(( format!("◉ {} blocked", counts.blocked), SummaryTone::Blocked, )); } if counts.done > 0 { segments.push((format!("● {} done", counts.done), SummaryTone::Done)); } if counts.working > 0 { segments.push((format!("{} working", counts.working), SummaryTone::Working)); } if counts.idle > 0 { segments.push((format!("{} idle", counts.idle), SummaryTone::Idle)); } segments } /// Greedily keep the most-urgent segments that fit `max_width` (counting the /// leading space and " · " separators) and report whether any were dropped. /// Segments are ordered by urgency, so the dropped tail is always the least /// important state. fn fit_summary_segments( segments: Vec<(String, SummaryTone)>, max_width: usize, ) -> (Vec<(String, SummaryTone)>, bool) { let mut shown = Vec::new(); let mut used = 1usize; // leading space for (idx, segment) in segments.iter().enumerate() { let sep = if idx > 0 { 3 } else { 0 }; // " · " let seg_w = segment.0.chars().count(); if used + sep + seg_w > max_width { break; } used += sep + seg_w; shown.push(segment.clone()); } let truncated = shown.len() < segments.len(); (shown, truncated) } fn agent_summary_line(app: &AppState, p: &Palette, max_width: u16) -> Line<'static> { let segments = agent_summary_segments(global_agent_counts(app)); let (shown, truncated) = fit_summary_segments(segments, max_width as usize); let mut spans = vec![Span::styled(" ", Style::default().bg(p.panel_bg))]; let mut used = 1usize; for (idx, (text, tone)) in shown.into_iter().enumerate() { if idx > 0 { spans.push(Span::styled( " · ", Style::default().fg(p.overlay0).bg(p.panel_bg), )); used += 3; } // Only the leading (most urgent) segment keeps its state color; the // rest stay dim so the urgent count is the loud thing. let style = if idx == 0 { let color = match tone { SummaryTone::Blocked => p.red, SummaryTone::Done => p.blue, SummaryTone::Working => p.yellow, SummaryTone::Idle | SummaryTone::Muted => p.overlay1, }; let style = Style::default().fg(color).bg(p.panel_bg); if tone == SummaryTone::Muted { style } else { style.add_modifier(Modifier::BOLD) } } else { Style::default().fg(p.overlay1).bg(p.panel_bg) }; used += text.chars().count(); spans.push(Span::styled(text, style)); } if truncated && used + 2 <= max_width as usize { spans.push(Span::styled( " …", Style::default().fg(p.overlay0).bg(p.panel_bg), )); } Line::from(spans) } fn mobile_toast_title(toast: &ToastNotification) -> String { match toast.kind { ToastKind::NeedsAttention => toast .title .strip_suffix(" needs attention") .map(|agent| format!("{agent} waiting")) .unwrap_or_else(|| toast.title.clone()), ToastKind::Finished => toast .title .strip_suffix(" finished") .map(|agent| format!("{agent} done")) .unwrap_or_else(|| toast.title.clone()), ToastKind::UpdateInstalled => "update ready".to_string(), } } fn fill_rect(frame: &mut Frame, area: Rect, style: Style) { if area.width == 0 || area.height == 0 { return; } let buf = frame.buffer_mut(); for y in area.y..area.y + area.height { for x in area.x..area.x + area.width { buf[(x, y)].set_symbol(" "); buf[(x, y)].set_style(style); } } } fn draw_horizontal_rule(frame: &mut Frame, area: Rect, p: &Palette) { if area.width == 0 || area.height == 0 { return; } let buf = frame.buffer_mut(); for x in area.x..area.x + area.width { buf[(x, area.y)] .set_symbol("─") .set_style(Style::default().fg(p.surface_dim).bg(p.panel_bg)); } } #[cfg(test)] mod tests { use super::*; fn agent_entry(primary_tab_label: Option<&str>, agent_label: Option<&str>) -> AgentPanelEntry { AgentPanelEntry { ws_idx: 0, tab_idx: 0, pane_id: PaneId::from_raw(1), primary_label: "herdr".into(), primary_tab_label: primary_tab_label.map(str::to_string), agent_label: agent_label.map(str::to_string), state: AgentState::Idle, seen: true, last_agent_state_change_seq: None, custom_status: None, state_labels: std::collections::HashMap::new(), } } #[test] fn agent_summary_leads_with_attention_states_in_priority_order() { let counts = GlobalAgentCounts { blocked: 2, done: 1, working: 2, idle: 1, }; let segments = agent_summary_segments(counts); let labels: Vec<&str> = segments.iter().map(|(text, _)| text.as_str()).collect(); assert_eq!( labels, vec!["◉ 2 blocked", "● 1 done", "2 working", "1 idle"] ); assert_eq!(segments[0].1, SummaryTone::Blocked); } #[test] fn agent_summary_hides_empty_categories() { let counts = GlobalAgentCounts { done: 1, working: 2, ..Default::default() }; let labels: Vec = agent_summary_segments(counts) .into_iter() .map(|(text, _)| text) .collect(); assert_eq!( labels, vec!["● 1 done".to_string(), "2 working".to_string()] ); } #[test] fn agent_summary_collapses_to_all_idle_without_attention() { let counts = GlobalAgentCounts { idle: 3, ..Default::default() }; assert_eq!( agent_summary_segments(counts), vec![("all idle".to_string(), SummaryTone::Muted)] ); } #[test] fn agent_summary_drops_least_urgent_segments_when_narrow() { let counts = GlobalAgentCounts { blocked: 2, done: 1, working: 2, idle: 1, }; let (shown, truncated) = fit_summary_segments(agent_summary_segments(counts), 24); let labels: Vec<&str> = shown.iter().map(|(text, _)| text.as_str()).collect(); assert_eq!(labels, vec!["◉ 2 blocked", "● 1 done"]); assert!(truncated); } #[test] fn agent_summary_keeps_all_segments_when_wide_enough() { let counts = GlobalAgentCounts { blocked: 2, done: 1, working: 2, idle: 1, }; let (shown, truncated) = fit_summary_segments(agent_summary_segments(counts), 60); assert_eq!(shown.len(), 4); assert!(!truncated); } #[test] fn agent_summary_reports_no_agents_when_empty() { assert_eq!( agent_summary_segments(GlobalAgentCounts::default()), vec![("no agents".to_string(), SummaryTone::Muted)] ); } #[test] fn switcher_leads_with_agents_and_shifts_spaces_below() { let mut app = crate::app::state::AppState::test_new(); let mut workspace = crate::workspace::Workspace::test_new("agents-first"); workspace.test_add_tab(None); // two tabs -> two agent panes app.workspaces = vec![workspace]; app.ensure_test_terminals(); for terminal in app.terminals.values_mut() { terminal.agent_name = Some("pi".to_string()); terminal.state = AgentState::Working; } app.active = Some(0); app.selected = 0; app.view.mobile_header_rect = Rect::new(0, 0, 40, 2); app.view.terminal_area = Rect::new(0, 2, 40, 18); assert_eq!(agent_panel_entries(&app).len(), 2); // agents title (1) + 2 agents * 2 rows = 5, then spaces title + "new // workspace" (2) before the first workspace ribbon at doc row 7. assert_eq!(mobile_switcher_workspace_doc_range(&app, 0).start, 7); let viewport = mobile_switcher_areas(&app).viewport; let agent_hit = mobile_switcher_target_at(&app, viewport.x + 2, viewport.y + 1); assert!(matches!( agent_hit, Some(MobileSwitcherTarget::Agent { .. }) )); let workspace_hit = mobile_switcher_target_at(&app, viewport.x + 2, viewport.y + 7); assert_eq!(workspace_hit, Some(MobileSwitcherTarget::Workspace(0))); } fn worktree_workspace(name: &str, key: &str, linked: bool) -> crate::workspace::Workspace { let mut ws = crate::workspace::Workspace::test_new(name); ws.worktree_space = Some(crate::workspace::WorktreeSpaceMembership { key: key.into(), label: "herdr".into(), repo_root: std::path::PathBuf::from("/repo/herdr"), checkout_path: std::path::PathBuf::from(format!("/repo/{name}")), is_linked_worktree: linked, }); ws } #[test] fn switcher_spaces_follow_grouped_worktree_order() { let mut app = crate::app::state::AppState::test_new(); app.workspaces = vec![ worktree_workspace("main", "repo-key", false), crate::workspace::Workspace::test_new("other"), worktree_workspace("feature", "repo-key", true), ]; app.active = Some(0); app.selected = 0; app.view.mobile_header_rect = Rect::new(0, 0, 40, 2); app.view.terminal_area = Rect::new(0, 2, 40, 18); // Grouped order pulls the worktree (idx 2) up under its parent (idx 0), // ahead of the unrelated "other" workspace (idx 1): rows are main, // feature, other. assert_eq!(mobile_switcher_workspace_doc_range(&app, 2).start, 4); assert_eq!(mobile_switcher_workspace_doc_range(&app, 1).start, 6); let viewport = mobile_switcher_areas(&app).viewport; // The second space row on screen is the worktree, not workspaces[1]. let hit = mobile_switcher_target_at(&app, viewport.x + 2, viewport.y + 4); assert_eq!(hit, Some(MobileSwitcherTarget::Workspace(2))); // Mobile ignores collapse: even with the space folded on desktop, the // worktree child still renders in the same position. app.collapsed_space_keys.insert("repo-key".to_string()); assert_eq!(mobile_switcher_workspace_doc_range(&app, 2).start, 4); let hit = mobile_switcher_target_at(&app, viewport.x + 2, viewport.y + 4); assert_eq!(hit, Some(MobileSwitcherTarget::Workspace(2))); } #[test] fn switcher_without_agents_keeps_spaces_first() { let mut app = crate::app::state::AppState::test_new(); app.workspaces = vec![crate::workspace::Workspace::test_new("shell-only")]; app.active = Some(0); app.selected = 0; // No attached terminals -> no agents -> no agents header, spaces lead. assert_eq!(agent_panel_entries(&app).len(), 0); assert_eq!(mobile_switcher_workspace_doc_range(&app, 0).start, 2); } #[test] fn mobile_agent_detail_includes_tab_context_when_available() { let entry = agent_entry(Some("mobile-state"), Some("pi")); assert_eq!(mobile_agent_detail(&entry), " mobile-state · idle · pi"); } #[test] fn mobile_agent_detail_keeps_existing_compact_detail_without_tab_context() { let entry = agent_entry(None, Some("pi")); assert_eq!(mobile_agent_detail(&entry), " idle · pi"); } #[test] fn mobile_tab_status_uses_compact_tab_label_and_position() { let mut workspace = crate::workspace::Workspace::test_new("mobile-tabs"); let removed_tab = workspace.test_add_tab(None); workspace.test_add_tab(None); assert!(workspace.close_tab(removed_tab)); workspace.active_tab = 1; assert_eq!(mobile_tab_status(&workspace), "tab 2 · 2/2"); } #[test] fn mobile_switcher_uses_compact_tab_label_for_auto_tab_labels() { let mut app = crate::app::state::AppState::test_new(); let mut workspace = crate::workspace::Workspace::test_new("mobile-tabs"); let removed_tab = workspace.test_add_tab(None); workspace.test_add_tab(None); assert!(workspace.close_tab(removed_tab)); app.workspaces = vec![workspace]; app.ensure_test_terminals(); app.active = Some(0); app.selected = 0; app.view.mobile_header_rect = Rect::new(0, 0, 40, 2); app.view.terminal_area = Rect::new(0, 2, 40, 18); let backend = ratatui::backend::TestBackend::new(40, 20); let mut terminal = ratatui::Terminal::new(backend).unwrap(); terminal .draw(|frame| { render_mobile_panel( &app, &TerminalRuntimeRegistry::new(), frame, Rect::new(0, 0, 40, 20), ) }) .unwrap(); let row = (0..40) .map(|x| terminal.backend().buffer()[(x, 10)].symbol()) .collect::(); assert!(row.contains("tab 2"), "mobile tab row: {row:?}"); assert!(!row.contains("tab 3"), "mobile tab row: {row:?}"); } #[cfg(unix)] #[tokio::test] async fn mobile_header_uses_live_root_runtime_cwd_for_workspace_label() { let unique = format!( "herdr-mobile-header-runtime-cwd-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() ); let root = std::env::temp_dir().join(unique); let stale_cwd = root.join("issue-264-nix-support"); let live_cwd = root.join("herdr"); std::fs::create_dir_all(stale_cwd.join(".git")).unwrap(); std::fs::create_dir_all(live_cwd.join(".git")).unwrap(); let mut app = crate::app::state::AppState::test_new(); let mut workspace = crate::workspace::Workspace::test_new("stale-name"); workspace.custom_name = None; workspace.identity_cwd = stale_cwd.clone(); let pane = workspace.tabs[0].root_pane; app.workspaces = vec![workspace]; app.ensure_test_terminals(); let terminal_id = app.workspaces[0].tabs[0].panes[&pane] .attached_terminal_id .clone(); app.terminals.get_mut(&terminal_id).unwrap().cwd = stale_cwd; app.active = Some(0); app.selected = 0; app.view.mobile_menu_hit_area = Rect::new(30, 0, 10, 2); let (events, _) = tokio::sync::mpsc::channel(4); let runtime = crate::terminal::TerminalRuntime::spawn( pane, 24, 80, live_cwd.clone(), 0, crate::terminal_theme::TerminalTheme::default(), crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin), &crate::pane::PaneLaunchEnv::default(), events, std::sync::Arc::new(tokio::sync::Notify::new()), std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), ) .unwrap(); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); while runtime.cwd() != Some(live_cwd.clone()) && std::time::Instant::now() < deadline { tokio::time::sleep(std::time::Duration::from_millis(10)).await; } let mut runtime_registry = TerminalRuntimeRegistry::new(); runtime_registry.insert(terminal_id, runtime); let backend = ratatui::backend::TestBackend::new(40, 2); let mut terminal = ratatui::Terminal::new(backend).unwrap(); terminal .draw(|frame| { render_mobile_header(&app, &runtime_registry, frame, Rect::new(0, 0, 40, 2)) }) .unwrap(); let row = (0..40) .map(|x| terminal.backend().buffer()[(x, 0)].symbol()) .collect::(); for (_, runtime) in runtime_registry.drain() { runtime.shutdown(); } let _ = std::fs::remove_dir_all(root); assert!(row.contains("herdr"), "header row: {row:?}"); assert!( !row.contains("issue-264-nix-support"), "header row: {row:?}" ); } }