diff options
Diffstat (limited to 'src/ui/mobile.rs')
| -rw-r--r-- | src/ui/mobile.rs | 1626 |
1 files changed, 0 insertions, 1626 deletions
diff --git a/src/ui/mobile.rs b/src/ui/mobile.rs deleted file mode 100644 index bdec6551..00000000 --- a/src/ui/mobile.rs +++ /dev/null @@ -1,1626 +0,0 @@ -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::{state_icon, state_icon_symbol}; -use super::text::{display_width_u16, truncate_end}; -use crate::app::state::{Palette, ToastKind, ToastNotification}; -use crate::app::AppState; -use crate::config::StatusIndicatorStyle; -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. An active query keeps its title and an -/// empty-state row visible even when no agents match. -fn mobile_agents_block_height(app: &AppState) -> usize { - let count = agent_panel_entries(app).len(); - if count == 0 { - usize::from(app.agent_view_override.is_some()) * 2 - } else { - 1 + count * 2 - } -} - -pub(crate) fn mobile_switcher_workspace_doc_range( - app: &AppState, - idx: usize, -) -> std::ops::Range<usize> { - // 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<MobileSwitcherTarget> { - let areas = mobile_switcher_areas(app); - let content = inset_for_left_scrollbar(areas.viewport); - if !rect_contains(content, col, row) { - return None; - } - - let scroll = app - .mobile_switcher_scroll - .min(mobile_switcher_max_scroll_for_height( - app, - areas.viewport.height, - )); - let doc_row = 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. - let agents = agent_panel_entries(app); - if !agents.is_empty() || app.agent_view_override.is_some() { - cursor += 1; // agents title - if agents.is_empty() { - cursor += 1; // active-query empty state - } else { - 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) = state_icon(state, seen, app.status_indicators, 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); - let (symbol, style) = state_icon(AgentState::Blocked, true, app.status_indicators, p); - frame.buffer_mut()[(bx, area.y)] - .set_symbol(symbol) - .set_style(style.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() || app.agent_view_override.is_some() { - 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)) - }); - let title = app - .agent_view_override - .as_ref() - .map(|view| format!("agents · {}", view.label.as_deref().unwrap_or("filtered"))) - .unwrap_or_else(|| "agents".to_string()); - render_section_title_at( - frame, - viewport, - content, - doc_y, - app.mobile_switcher_scroll, - &title, - p, - ); - doc_y += 1; - if entries.is_empty() { - render_one_line_item( - frame, - viewport, - content, - doc_y, - app.mobile_switcher_scroll, - ratatui::style::Color::Reset, - Line::from(Span::styled( - " no matching agents", - Style::default().fg(p.overlay0).add_modifier(Modifier::DIM), - )), - ); - 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) = state_icon(entry.state, entry.seen, app.status_indicators, 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_icon(state, seen, app.status_indicators, 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()); - } - 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<u16> { - 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 crate::ui::all_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, - indicator_style: StatusIndicatorStyle, -) -> 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(( - agent_summary_text( - indicator_style, - AgentState::Blocked, - true, - Some("◉"), - counts.blocked, - "blocked", - ), - SummaryTone::Blocked, - )); - } - if counts.done > 0 { - segments.push(( - agent_summary_text( - indicator_style, - AgentState::Idle, - false, - Some("●"), - counts.done, - "done", - ), - SummaryTone::Done, - )); - } - if counts.working > 0 { - segments.push(( - agent_summary_text( - indicator_style, - AgentState::Working, - true, - None, - counts.working, - "working", - ), - SummaryTone::Working, - )); - } - if counts.idle > 0 { - segments.push(( - agent_summary_text( - indicator_style, - AgentState::Idle, - true, - None, - counts.idle, - "idle", - ), - SummaryTone::Idle, - )); - } - segments -} - -fn agent_summary_text( - indicator_style: StatusIndicatorStyle, - state: AgentState, - seen: bool, - dot_style_symbol: Option<&str>, - count: usize, - label: &str, -) -> String { - let symbol = match indicator_style { - StatusIndicatorStyle::Dots => dot_style_symbol, - StatusIndicatorStyle::Symbols => Some(state_icon_symbol(state, seen, indicator_style)), - }; - match symbol { - Some(symbol) => format!("{symbol} {count} {label}"), - None => format!("{count} {label}"), - } -} - -/// 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), app.status_indicators); - 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), - pane_label: None, - terminal_title: None, - terminal_title_stripped: None, - agent_label: agent_label.map(str::to_string), - agent_kind_label: agent_label.map(str::to_string), - agent: agent_label.and_then(crate::detect::parse_agent_label), - state: AgentState::Idle, - seen: true, - last_agent_state_change_seq: None, - state_labels: std::collections::HashMap::new(), - tokens: std::collections::HashMap::new(), - } - } - - #[test] - fn global_agent_counts_ignore_active_agent_view_filter() { - let mut app = AppState::test_new(); - app.workspaces = vec![ - crate::workspace::Workspace::test_new("blocked"), - crate::workspace::Workspace::test_new("working"), - ]; - app.ensure_test_terminals(); - for (ws_idx, state) in [(0, AgentState::Blocked), (1, AgentState::Working)] { - let pane_id = app.workspaces[ws_idx].tabs[0].root_pane; - let terminal_id = app.workspaces[ws_idx].tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - let terminal = app.terminals.get_mut(&terminal_id).unwrap(); - terminal.detected_agent = Some(crate::detect::Agent::Claude); - terminal.state = state; - } - app.agent_view_override = Some(crate::api::schema::AgentViewSetParams { - source: "example.views".to_string(), - label: None, - filter: Some(crate::api::schema::AgentViewFilter::Eq { - field: crate::api::schema::AgentViewField::Builtin( - crate::api::schema::AgentViewBuiltinField::Status, - ), - value: crate::api::schema::AgentViewValue::String("working".to_string()), - }), - sort: Vec::new(), - }); - - let counts = global_agent_counts(&app); - assert_eq!(counts.blocked, 1); - assert_eq!(counts.working, 1); - } - - #[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, StatusIndicatorStyle::Dots); - 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 distinct_agent_summary_uses_configured_symbols_for_every_state() { - let counts = GlobalAgentCounts { - blocked: 2, - done: 1, - working: 2, - idle: 1, - }; - let labels: Vec<String> = agent_summary_segments(counts, StatusIndicatorStyle::Symbols) - .into_iter() - .map(|(text, _)| text) - .collect(); - assert_eq!( - labels, - ["× 2 blocked", "✓ 1 done", "◐ 2 working", "○ 1 idle"] - ); - } - - #[test] - fn distinct_status_style_updates_mobile_blocked_badge() { - let mut app = crate::app::state::AppState::test_new(); - app.workspaces = vec![crate::workspace::Workspace::test_new("blocked")]; - app.ensure_test_terminals(); - app.status_indicators = StatusIndicatorStyle::Symbols; - let pane_id = app.workspaces[0].tabs[0].root_pane; - let terminal_id = app.workspaces[0].tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); - let terminal_state = app.terminals.get_mut(&terminal_id).unwrap(); - terminal_state.detected_agent = Some(crate::detect::Agent::Claude); - terminal_state.state = AgentState::Blocked; - - let area = Rect::new(0, 0, 12, 2); - let mut terminal = - ratatui::Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height)) - .unwrap(); - terminal - .draw(|frame| render_switch_button(&app, frame, area)) - .unwrap(); - - assert_eq!( - terminal.backend().buffer()[(area.width - 1, 0)].symbol(), - "×" - ); - } - - #[test] - fn agent_summary_hides_empty_categories() { - let counts = GlobalAgentCounts { - done: 1, - working: 2, - ..Default::default() - }; - let labels: Vec<String> = agent_summary_segments(counts, StatusIndicatorStyle::Dots) - .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, StatusIndicatorStyle::Dots), - 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, StatusIndicatorStyle::Dots), - 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, StatusIndicatorStyle::Dots), - 60, - ); - assert_eq!(shown.len(), 4); - assert!(!truncated); - } - - #[test] - fn agent_summary_reports_no_agents_when_empty() { - assert_eq!( - agent_summary_segments(GlobalAgentCounts::default(), StatusIndicatorStyle::Dots,), - 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; - app.mobile_switcher_scroll = 100; - 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::<String>(); - - 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(), - None, - 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(crate::render_signal::RenderSignal::new()), - ) - .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::<String>(); - - 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:?}" - ); - } -} |
