Skip to main content

gwm/tui/
ui.rs

1use super::app::{App, GitHubFetchState, LinkPromptStage, LinkTarget, View};
2use super::keymap::{Action, KeyStroke, Keymap};
3use super::modal_keymap::{KeyContext, ModalAction, ModalKeymap};
4use super::state::async_task::TaskKind;
5use super::state::config_panel::{FieldKind, SettingField, SettingsTab};
6use super::state::confirm::ConfirmButton;
7use super::state::create_form::{Field, Mode};
8
9/// The field set of the canonical `<type>/#<issue>-<desc>` triple, used as the
10/// default by the hint helpers that have no form in reach (issue #418). Every
11/// row those helpers can drop is present in it, so the default is "advertise
12/// everything", which is what they did before the field set became dynamic.
13const CANONICAL_TRIPLE: [Field; 3] = [Field::Type, Field::Issue, Field::Desc];
14use super::state::pty_overlay::PtyKind;
15use super::state::sidebar::SidebarMode;
16use super::state::spinner::DOT_FRAMES;
17use super::theme::Theme;
18use super::wt_tree::{self, working_tree_category, WtCategory, WtNode, WT_DIR_OPEN_ICON};
19use crate::bootstrap::{BootstrapReport, StepStatus};
20use crate::command_log::CommandStatus;
21use crate::config::ConfigSource;
22use crate::github::{CiState, IssueState, LinkSource, PrState};
23use crate::worktree::{self, BranchStatus, WorktreeInfo};
24use ratatui::{
25  buffer::Buffer,
26  layout::{Alignment, Constraint, Direction, Layout, Rect},
27  style::{Color, Modifier, Style},
28  text::{Line, Span},
29  widgets::{
30    Block, BorderType, Borders, Cell, Clear, Padding, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState,
31    Table, Widget, Wrap,
32  },
33  Frame,
34};
35use std::time::{Duration, Instant};
36
37/// Per-section content of the worktree details sidebar. Rendered by
38/// [`draw_sidebar`] into separate rounded-border blocks (no outer
39/// `Details` frame, so each section reads as an independent card).
40///
41/// The Issue / PR section is intentionally absent here: it depends on
42/// live `App` fetch state and is built per-frame via
43/// [`github_status_lines`], not cached on the worktree.
44#[derive(Debug, Clone, Default)]
45pub struct SidebarSections {
46  /// Compact identity block: name (bold), `branch · head`, badges
47  /// (`✓ synced` / `● dirty` / `↑N` / `↓M` plus optional `★ main`,
48  /// `🔒 locked`, `⚠ prunable`), tilde-compressed path.
49  pub worktree: Vec<Line<'static>>,
50  /// `git status --short` lines, or `✓ clean`, or a load error.
51  pub working_tree: Vec<Line<'static>>,
52  /// Per-category counts of changed files (issue #287): created / modified
53  /// / deleted, driving the colour-coded nerdfont footer of the Working
54  /// Tree pane.
55  pub working_tree_counts: WorkingTreeCounts,
56  /// Up to 10 oneline commits, or an empty / error notice.
57  pub recent_commits: Vec<Line<'static>>,
58}
59
60/// Reusable one-line loader for dedicated panel/modal areas (issue #257).
61#[derive(Debug, Clone, Copy)]
62pub enum LoaderWidgetState<'a> {
63  Running {
64    glyph: &'a str,
65    label: &'a str,
66    detail: Option<&'a str>,
67  },
68  Failed {
69    message: &'a str,
70    detail: Option<&'a str>,
71  },
72}
73
74#[derive(Debug, Clone, Copy)]
75pub struct LoaderWidget<'a> {
76  state: LoaderWidgetState<'a>,
77  accent: Color,
78  text: Color,
79  muted: Color,
80  failed: Color,
81  alignment: Alignment,
82}
83
84impl<'a> LoaderWidget<'a> {
85  pub fn running(glyph: &'a str, label: &'a str, detail: Option<&'a str>, theme: &Theme) -> Self {
86    Self {
87      state: LoaderWidgetState::Running { glyph, label, detail },
88      accent: theme.accent,
89      text: theme.name,
90      muted: theme.muted,
91      failed: theme.prunable,
92      alignment: Alignment::Left,
93    }
94  }
95
96  pub fn failed(message: &'a str, detail: Option<&'a str>, theme: &Theme) -> Self {
97    Self {
98      state: LoaderWidgetState::Failed { message, detail },
99      accent: theme.accent,
100      text: theme.name,
101      muted: theme.muted,
102      failed: theme.prunable,
103      alignment: Alignment::Left,
104    }
105  }
106
107  pub fn alignment(mut self, alignment: Alignment) -> Self {
108    self.alignment = alignment;
109    self
110  }
111
112  fn line(self) -> Line<'static> {
113    let mut spans = match self.state {
114      LoaderWidgetState::Running { glyph, label, .. } => vec![
115        Span::styled(
116          format!("{glyph} "),
117          Style::default().fg(self.accent).add_modifier(Modifier::BOLD),
118        ),
119        Span::styled(
120          label.to_string(),
121          Style::default().fg(self.text).add_modifier(Modifier::BOLD),
122        ),
123      ],
124      LoaderWidgetState::Failed { message, .. } => vec![
125        Span::styled("! ", Style::default().fg(self.failed).add_modifier(Modifier::BOLD)),
126        Span::styled(
127          message.to_string(),
128          Style::default().fg(self.failed).add_modifier(Modifier::BOLD),
129        ),
130      ],
131    };
132
133    let detail = match self.state {
134      LoaderWidgetState::Running { detail, .. } | LoaderWidgetState::Failed { detail, .. } => detail,
135    };
136    if let Some(detail) = detail {
137      spans.push(Span::styled(" — ", Style::default().fg(self.muted)));
138      spans.push(Span::styled(detail.to_string(), Style::default().fg(self.muted)));
139    }
140    Line::from(spans)
141  }
142}
143
144impl Widget for LoaderWidget<'_> {
145  fn render(self, area: Rect, buf: &mut Buffer) {
146    Paragraph::new(self.line()).alignment(self.alignment).render(area, buf);
147  }
148}
149
150pub fn draw(f: &mut Frame, app: &mut App) {
151  // Header and footer are single borderless rows (#185); the body fills the
152  // rest. The fuzzy filter no longer claims its own row — it renders inside
153  // the worktrees pane title (#262), so the layout is a stable header / body /
154  // footer split whether or not a filter is active.
155  let chunks = Layout::default()
156    .direction(Direction::Vertical)
157    .constraints([Constraint::Length(1), Constraint::Min(0), Constraint::Length(1)])
158    .split(f.area());
159
160  draw_header(f, chunks[0], app);
161  draw_body(f, chunks[1], app);
162  draw_footer(f, chunks[2], app);
163
164  match app.view {
165    View::Help => draw_help(f, app),
166    View::Create => draw_create(f, app),
167    View::Confirm => draw_confirm(f, app),
168    View::Report => draw_report(f, app),
169    View::OpenMenu => draw_open_menu(f, app),
170    View::LinkPrompt => draw_link_prompt(f, app),
171    View::CommandPalette => draw_command_palette(f, app),
172    View::CommandLogs => draw_command_logs(f, app),
173    View::Config => draw_config_panel(f, app),
174    View::Pty => draw_pty_overlay(f, app),
175    // #325: exec profile picker renders as a small centred modal.
176    View::ExecPicker => draw_exec_picker(f, app),
177    // #325: clean reclaim report renders as a centred modal.
178    View::CleanReport => draw_clean_overlay(f, app),
179    // #290: branch-rename inline modal renders over the list.
180    View::Edit => draw_edit_worktree(f, app),
181    // #408: generic detail overlay (agent sessions) as a centred modal.
182    View::DetailOverlay => draw_detail_overlay(f, app),
183    View::List => {}
184  }
185}
186
187/// Styled, width-driven header builder (issue #185). Replaces the flat
188/// header-title string in the rendered TUI with a clear visual hierarchy
189/// that mirrors the #180 footer's chip language:
190///
191/// - **Current directory** — a leading reverse-video badge.
192/// - **Working directory** — dimmed (`DarkGray`), stable after the badge and
193///   dropped/truncated under width pressure.
194/// - **`picker`** — an accent-distinct (yellow) chip flagging a `gwm switch`
195///   picker session.
196/// - **Version** — a right-pinned reverse-video badge (` gwm <version> `)
197///   painted on `accent`. The version still comes from `CARGO_PKG_VERSION`,
198///   so `gwm --version` parity is preserved.
199///
200/// Priority when the terminal is narrow: the version chip survives (clipped
201/// only if it alone exceeds `width`), then the current-dir badge, then the
202/// picker chip, and the path is sacrificed first. Pure and measured with
203/// `chars().count()` so the contract is pinned by `tests/tui_header_tests.rs`
204/// without a ratatui backend; control chars are collapsed to spaces so a
205/// pathological path can never split the single row.
206pub fn header_line(
207  repo_name: &str,
208  workdir_display: &str,
209  picker_mode: bool,
210  width: usize,
211  theme: &Theme,
212) -> Line<'static> {
213  // A zero-width row can hold nothing — return an empty line rather than let
214  // `trunc` floor a 1-column `…` into existence.
215  if width == 0 {
216    return Line::default();
217  }
218
219  let sanitize = |s: &str| -> String { s.chars().map(|c| if c.is_control() { ' ' } else { c }).collect() };
220  let repo = sanitize(repo_name);
221  let path = sanitize(workdir_display);
222
223  let version_style = chip_style(theme.accent);
224  let dir_badge_style = chip_style(theme.name);
225  // Picker chip uses the `dirty` role (not the accent) so the mode warning
226  // reads as distinct from the always-present version chip — pre-theme this
227  // was a hard-coded `Color::Yellow`.
228  let picker_style = chip_style(theme.dirty);
229  let path_style = Style::default().fg(theme.muted);
230
231  let version_text = format!(" gwm {} ", env!("CARGO_PKG_VERSION"));
232  let version_w = version_text.chars().count();
233  let dir_text = format!(" {} ", repo);
234  let dir_w = dir_text.chars().count();
235
236  // Priority floor: if even the right-pinned version chip cannot fit, show it
237  // clipped alone — never an empty header.
238  if width < version_w {
239    return Line::from(Span::styled(trunc(&version_text, width), version_style));
240  }
241
242  let mut spans: Vec<Span<'static>> = Vec::new();
243  let mut used = 0usize;
244
245  // Current-directory badge first. If the row is too narrow for the full
246  // badge plus the pinned version, trim the badge rather than move the path
247  // or version around.
248  let dir_budget = width.saturating_sub(version_w + 1);
249  if dir_w <= dir_budget {
250    spans.push(Span::styled(dir_text, dir_badge_style));
251    used += dir_w;
252  } else if dir_budget > 0 {
253    let clipped = trunc(&dir_text, dir_budget);
254    used += clipped.chars().count();
255    spans.push(Span::styled(clipped, dir_badge_style));
256  }
257
258  // Picker chip — mode-safety indicator, kept right after the current-dir
259  // badge when there is room.
260  if picker_mode {
261    let picker_text = " picker ".to_string();
262    let need = 1 + picker_text.chars().count(); // leading space + chip
263    if used + need + version_w < width {
264      spans.push(Span::raw(" "));
265      spans.push(Span::styled(picker_text, picker_style));
266      used += need;
267    }
268  }
269
270  // Path — dimmed secondary context. It stays immediately after the current
271  // directory badge and is dropped/truncated under pressure; the version chip
272  // remains pinned at the end of the row.
273  let path_gap = 2usize;
274  if used + path_gap + version_w < width {
275    let avail = width - used - path_gap - version_w;
276    let path_disp = trunc(&path, avail);
277    if !path_disp.is_empty() {
278      let w = path_disp.chars().count();
279      spans.push(Span::raw("  "));
280      spans.push(Span::styled(path_disp, path_style));
281      used += path_gap + w;
282    }
283  }
284
285  let pad = width.saturating_sub(used + version_w);
286  if pad > 0 {
287    spans.push(Span::raw(" ".repeat(pad)));
288  }
289  spans.push(Span::styled(version_text, version_style));
290
291  Line::from(spans)
292}
293
294/// Lay out the worktree table and the optional preview sidebar for the
295/// body region. The layout (hidden / side-by-side / stacked) and the
296/// left-or-right side are decided by the pure
297/// [`SidebarState::resolve_layout`](super::state::sidebar::SidebarState::resolve_layout),
298/// so this function only translates that decision into ratatui splits
299/// (issue #188). The table/sidebar ratio is per-axis (issue #217): 55/45
300/// side-by-side, 42/58 stacked — see
301/// [`ResolvedSidebarLayout::split_percentages`](super::state::sidebar::ResolvedSidebarLayout::split_percentages).
302fn draw_body(f: &mut Frame, area: Rect, app: &mut App) {
303  use super::state::sidebar::ResolvedSidebarLayout as Resolved;
304
305  let layout = app.sidebar.resolve_layout(area.width);
306  let (table_pct, sidebar_pct) = match layout.split_percentages() {
307    Some((t, s)) => (Constraint::Percentage(t), Constraint::Percentage(s)),
308    None => {
309      // Sidebar not rendered → no scrollable surface → no max scroll to track.
310      app.sidebar.max_scroll = 0;
311      app.sidebar.wt_max_scroll = 0;
312      draw_list(f, area, app);
313      return;
314    }
315  };
316
317  match layout {
318    Resolved::Hidden => unreachable!("Hidden returns None from split_percentages, handled above"),
319    Resolved::SideBySide { sidebar_left } => {
320      let split = Layout::default()
321        .direction(Direction::Horizontal)
322        .constraints(if sidebar_left {
323          [sidebar_pct, table_pct]
324        } else {
325          [table_pct, sidebar_pct]
326        })
327        .split(area);
328      let (list_area, sidebar_area) = if sidebar_left {
329        (split[1], split[0])
330      } else {
331        (split[0], split[1])
332      };
333      draw_list(f, list_area, app);
334      draw_sidebar(f, sidebar_area, app);
335    }
336    Resolved::Stacked => {
337      // Table on top, sidebar below — the default layout (issue #217) and the
338      // narrow-terminal fallback. The left/right position does not apply to a
339      // vertical stack.
340      let split = Layout::default()
341        .direction(Direction::Vertical)
342        .constraints([table_pct, sidebar_pct])
343        .split(area);
344      draw_list(f, split[0], app);
345      draw_sidebar(f, split[1], app);
346    }
347  }
348}
349
350fn draw_header(f: &mut Frame, area: Rect, app: &App) {
351  // Tilde-compress the workdir so `$HOME`-rooted paths read as `~/…` — same
352  // treatment as the sidebar identity block. The styled, width-driven layout
353  // (version chip, bold repo, dimmed path, optional picker chip) lives in
354  // `header_line` (issue #185) so it can be pinned without a ratatui backend.
355  let workdir = tilde_compress(&app.workdir.to_string_lossy());
356  // Borderless single row (#185): the builder gets the full area width and the
357  // line renders flush, mirroring the footer. No `Wrap` — `header_line`
358  // guarantees one visual line clipped to `width`.
359  let line = header_line(
360    // The workspace label, which is what the user is looking at; `repo_name` is
361    // the naming name and can be the same string in a workspace of one (#480).
362    &app.display_repo_name,
363    &workdir,
364    app.picker_mode,
365    area.width as usize,
366    &app.theme,
367  );
368  f.render_widget(Paragraph::new(line), area);
369}
370
371/// Border colour for a focus-swappable panel (worktree list ↔ sidebar,
372/// toggled with `Tab`): the theme `focus` role when the panel holds focus,
373/// else a muted `DarkGray`. Extracted as a pure fn so the focus→theme wiring
374/// is pinned by `tests/tui_app_tests.rs` without a ratatui backend — and so a
375/// regression hardcoding a colour (the pre-#185 `Color::Cyan`) is caught.
376pub fn panel_border_color(focused: bool, theme: &super::theme::Theme) -> Color {
377  if focused {
378    theme.focus
379  } else {
380    theme.muted
381  }
382}
383
384/// Title for the worktree pane block (issue #217; carries the inline fuzzy
385/// filter since #262). Always leads with the `[1]` focus mnemonic (the pane
386/// is focusable with the `1` key). When a filter is live — the user is typing
387/// (`active`) or a sticky query remains — the title embeds the `/query`
388/// prompt (in `filter_color`), a block cursor while `active`, and the
389/// `(visible/total)` ratio so the user sees how much the filter narrowed the
390/// list. With no filter it shows just the `(total)` count. This replaces the
391/// standalone filter bar row (#262): the filter now reads in the pane border,
392/// attached to the list it narrows. Pure + width-free so the copy + the
393/// prompt colour are pinned by `tests/tui_ui_helpers_tests.rs` without a
394/// ratatui backend.
395pub fn worktrees_pane_title(
396  query: &str,
397  active: bool,
398  visible: usize,
399  total: usize,
400  filter_color: Color,
401) -> Line<'static> {
402  let mut spans = vec![Span::raw(" [1] Worktrees ")];
403  // Live filter (typing or sticky): show the `/query` prompt + optional
404  // cursor, mirroring the Vim-style bar the title replaced.
405  if active || !query.is_empty() {
406    spans.push(Span::styled(
407      "/",
408      Style::default().fg(filter_color).add_modifier(Modifier::BOLD),
409    ));
410    spans.push(Span::raw(query.to_string()));
411    if active {
412      spans.push(Span::styled(
413        "\u{2588}",
414        Style::default().fg(filter_color).add_modifier(Modifier::SLOW_BLINK),
415      ));
416    }
417    spans.push(Span::raw(" "));
418  }
419  // Counter: the visible/total ratio only once a query actually narrows the
420  // list; an empty query (even while the bar is open) matches all, so the
421  // plain `(total)` form reads cleaner.
422  let counter = if query.is_empty() {
423    format!("({}) ", total)
424  } else {
425    format!("({}/{}) ", visible, total)
426  };
427  spans.push(Span::raw(counter));
428  Line::from(spans)
429}
430
431/// Title for the head section of the status (sidebar) pane (issue #217).
432/// Carries the `[2]` focus mnemonic (focusable with the `2` key), mirroring
433/// [`worktrees_pane_title`]'s `[1]`. The sidebar is a stack of sub-sections;
434/// this labels the first one so the pane reads as `[2] Status` without
435/// nesting an extra bordered frame.
436pub fn status_pane_title() -> &'static str {
437  " [2] Status "
438}
439
440/// Bottom-right `selected of visible` counter for a pane footer (issue
441/// #217), lazygit-style. `selected` is the 1-based cursor position;
442/// `visible` is the count of rows currently on screen. Returns `None` when
443/// the pane is empty so the footer disappears instead of rendering ` 0 of 0 `
444/// — mirroring the Recent Commits section, which also drops its counter when
445/// there is nothing to scroll.
446pub fn pane_counter(selected: usize, visible: usize) -> Option<String> {
447  if visible == 0 {
448    None
449  } else {
450    Some(format!(" {} of {} ", selected, visible))
451  }
452}
453
454fn draw_list(f: &mut Frame, area: Rect, app: &mut App) {
455  // Filter-aware: the visible rows are the filtered subset (issue #21). When
456  // there is no active filter, this is the identity over `app.worktrees`.
457  // Borrow scoping: `filtered_indices` returns `&[usize]` rooted in
458  // `&mut app.filter`, which conflicts with the immutable `app.worktrees`
459  // read on the next line. Materialise the indices into an owned `Vec`
460  // so the mutable borrow ends. The expensive path (nucleo pass) stays
461  // memoised on `FilterState`; this per-frame clone is just a Vec<usize>
462  // of length ≤ worktrees.len().
463  let filtered: Vec<usize> = app.filtered_indices().to_vec();
464  let visible: Vec<&WorktreeInfo> = filtered.iter().filter_map(|&i| app.worktrees.get(i)).collect();
465  // `Theme` is `Copy`; snapshot it so the row/header builders below can
466  // read roles without conflicting with the mutable `app.list_state`
467  // borrow handed to `render_stateful_widget`.
468  let theme = app.theme;
469
470  // Workspace mode (issue #36): a leading REPO column naming each row's repo.
471  // Names are resolved per visible row up front so the immutable `app` reads
472  // don't clash with the mutable `list_state` borrow at render time.
473  let is_workspace = app.is_workspace();
474  let repo_names: Vec<String> = if is_workspace {
475    filtered
476      .iter()
477      .map(|&raw| app.row_repo_name(raw).unwrap_or("?").to_string())
478      .collect()
479  } else {
480    Vec::new()
481  };
482  let repo_w = if is_workspace {
483    column_width(repo_names.iter().map(|s| s.as_str()), 6, 24)
484  } else {
485    0
486  };
487
488  // Dynamic column widths derived from the visible subset so columns fit the
489  // rows actually on screen. The path column is always last and absorbs the
490  // remaining width.
491  let name_w = column_width(visible.iter().map(|w| w.name.as_str()), 18, 38);
492  let branch_w = column_width(visible.iter().map(|w| w.branch.as_deref().unwrap_or("-")), 18, 38);
493  let status_w: u16 = 16;
494
495  // Header cells, with an optional REPO column after the (caption-less) age
496  // column in workspace mode.
497  let mut header_cells = vec![Cell::from("")];
498  if is_workspace {
499    header_cells.push(Cell::from("REPO"));
500  }
501  // AGENT column only when at least one session is detected (Codex review
502  // round D): a no-agent setup keeps the exact pre-#408 table instead of an
503  // empty fixed column squeezing NAME/BRANCH/PATH on narrow terminals.
504  let show_agent = app.any_agent_sessions();
505  header_cells.push(Cell::from("I/P"));
506  header_cells.push(Cell::from("NAME"));
507  header_cells.push(Cell::from("BRANCH"));
508  header_cells.push(Cell::from("STATUS"));
509  if show_agent {
510    header_cells.push(Cell::from("AGENT"));
511  }
512  header_cells.push(Cell::from("PATH"));
513  let header = Row::new(header_cells).style(Style::default().fg(theme.muted).add_modifier(Modifier::BOLD));
514
515  // Agent cells resolved up front (issue #408) for the same borrow reason as
516  // `repo_names`: `agents_for` reads `app` immutably, `list_state` is borrowed
517  // mutably at render time. Pure snapshot lookups — no I/O on the render path.
518  let now = std::time::SystemTime::now();
519  let agent_cells: Vec<Option<(&'static str, crate::agent_sessions::Freshness)>> = visible
520    .iter()
521    .map(|w| agent_cell_label(app.agents_for(w), now))
522    .collect();
523
524  let rows: Vec<Row> = visible
525    .iter()
526    .enumerate()
527    .map(|(vi, w)| {
528      let repo = is_workspace.then(|| (repo_names[vi].as_str(), repo_w));
529      let agent = show_agent.then_some(agent_cells[vi]);
530      build_row(w, repo, name_w, branch_w, status_w, agent, &theme)
531    })
532    .collect();
533
534  // ratatui's Layout solver squeezes the FIRST `Length` column to
535  // satisfy the others when terminal width is tight. We want the age
536  // column rock-stable at 4 cells (the cost of losing the unit
537  // letter to truncation — "22h" → "22" — is worse than name/branch
538  // shrinking by a char or two). Strategy:
539  //   - `Length(4)` for age, `Length(3)` for marker (`●/●`, `●/-`, etc.),
540  //     `Length(16)` for status: hard-fixed lengths the solver must honour.
541  //   - `Min(name_w)` / `Min(branch_w)`: these absorb the pressure
542  //     when the terminal is narrow (they shrink down to 8) and grow
543  //     to the original clamped width (or more) when there's room.
544  //   - `Fill(1)` for path: takes whatever's left, vanishes last.
545  // Verified by standalone probe down to 40-cell terminals: col 0
546  // stays at 4 cells across every size.
547  let mut widths = vec![Constraint::Length(4)];
548  if is_workspace {
549    // REPO column sits between age and the I/P marker; a hard length so the
550    // solver doesn't starve it on narrow terminals.
551    widths.push(Constraint::Length(repo_w));
552  }
553  widths.extend([
554    Constraint::Length(3),
555    Constraint::Min(name_w),
556    Constraint::Min(branch_w),
557    Constraint::Length(status_w),
558  ]);
559  if show_agent {
560    // AGENT (issue #408): hard length sized to the longest agent name
561    // ("opencode"), so the solver never starves it into ambiguity.
562    widths.push(Constraint::Length(8));
563  }
564  widths.push(Constraint::Fill(1));
565
566  let list_has_focus = !(app.sidebar.open && app.sidebar.focused);
567  let border_color = panel_border_color(list_has_focus, &app.theme);
568
569  let title = worktrees_pane_title(
570    app.filter.query(),
571    app.filter.active,
572    visible.len(),
573    app.worktrees.len(),
574    app.theme.dirty,
575  );
576
577  // Bottom-right `selected of visible` counter (issue #217), mirroring the
578  // Recent Commits footer. `list_state.selected()` is 0-based; render it
579  // 1-based. Blank when nothing is visible so the footer disappears.
580  let selected_1based = app.list_state.selected().map(|i| i + 1).unwrap_or(0);
581  let counter = pane_counter(selected_1based, visible.len());
582
583  let mut block = Block::default()
584    .borders(Borders::ALL)
585    .title(title)
586    .border_style(Style::default().fg(border_color));
587  if let Some(counter) = counter {
588    block = block.title_bottom(Line::from(counter).right_aligned());
589  }
590
591  let table = Table::new(rows, widths)
592    .header(header)
593    .column_spacing(1)
594    .block(block)
595    .row_highlight_style(Style::default().bg(theme.selection_bg).add_modifier(Modifier::BOLD))
596    .highlight_symbol("▶ ");
597
598  f.render_stateful_widget(table, area, &mut app.list_state);
599}
600
601/// Details panel for the selected worktree — structured info, recent commits,
602/// working-tree status, and a commands cheat-sheet (lazyssh-style layout).
603///
604/// Content is cached on `App` keyed by the selected worktree's path so the
605/// underlying `git log` / `git status` only run when the selection changes
606/// or `refresh()` invalidates the cache.
607fn draw_sidebar(f: &mut Frame, area: Rect, app: &mut App) {
608  let border_color = panel_border_color(app.sidebar.focused, &app.theme);
609  // `Theme` is `Copy`; snapshot it so the cached section builder can read
610  // roles while `app.sidebar.cache` is mutably borrowed below.
611  let theme = app.theme;
612
613  // Resolve (or populate) the cached worktree sections for the current
614  // selection. Issue / PR block is rebuilt every frame (its fetch state
615  // moves independently of the worktree info). The leading `●` status
616  // dot line on the Worktree section is also rebuilt fresh each frame
617  // (issue #73) so it tracks live PR / issue fetches without
618  // invalidating the expensive git-preview cache underneath.
619  // Cache key carries the active mode (issue #34) so toggling between
620  // commits / stashes re-shells the right git command instead of
621  // serving the previous mode's pre-rendered lines.
622  let active_mode = app.sidebar.mode;
623
624  // Inner width = block area − 2 border columns − 1 leading-padding column
625  // (applied by `render_section`). Summary lines trim their variable parts
626  // (title / error blob) so the total visible width fits — without this,
627  // long PR titles would either overflow the block right border or be
628  // wrapped onto a second visual row that the `Constraint::Length` below
629  // never budgeted for, breaking the layout.
630  let issue_pr_inner_width = area.width.saturating_sub(3) as usize;
631
632  let Some(w) = app.selected().cloned() else {
633    // Nothing selected: render the placeholder and bail. No cache to read,
634    // so the borrow gymnastics below don't apply.
635    let issue_pr_lines = github_status_lines(app, issue_pr_inner_width);
636    let placeholder = [Line::from("(nothing selected)")];
637    let h = |lines: usize| (lines as u16).saturating_add(2);
638    let constraints = [
639      Constraint::Length(h(placeholder.len())),
640      Constraint::Length(h(issue_pr_lines.len())),
641      Constraint::Length(0),
642      Constraint::Length(0),
643      Constraint::Min(3),
644    ];
645    let chunks = Layout::default()
646      .direction(Direction::Vertical)
647      .constraints(constraints)
648      .split(area);
649    app.sidebar.max_scroll = 0;
650    app.sidebar.scroll = 0;
651    app.sidebar.wt_max_scroll = 0;
652    app.sidebar.wt_scroll = 0;
653    render_section(
654      f,
655      chunks[0],
656      status_pane_title(),
657      SectionBody::new(&placeholder),
658      border_color,
659      0,
660      None,
661    );
662    render_section(
663      f,
664      chunks[1],
665      issue_pr_pane_title(&app.keymap),
666      SectionBody::new(&issue_pr_lines),
667      border_color,
668      0,
669      None,
670    );
671    render_section(
672      f,
673      chunks[4],
674      recent_items_pane_title(active_mode, &app.keymap),
675      SectionBody::new(&[]),
676      border_color,
677      0,
678      None,
679    );
680    return;
681  };
682
683  // Issue #343: the render path NEVER shells out. The git-backed sections
684  // (`git_diff_stat_vs_base` + `git status` + `git log` / `git stash list`)
685  // are rebuilt off-thread by the async sidebar worker (`App::
686  // maybe_refresh_sidebar` → `TaskKind::Sidebar`), which stores the payload in
687  // `app.sidebar.cache` keyed by `(path, mode)`. Here we only READ it, and
688  // only when it was built for the *current* selection + mode — a stale-key or
689  // cold cache renders a muted "loading…" placeholder while the worker catches
690  // up. The identity card (branch / head / path / badges) comes straight from
691  // `w`, so it shows instantly on navigation; only the diff figure and the
692  // status / commits blocks wait on the worker. The live `● <name>` header and
693  // the Issue / PR block are still built per-frame below (no subprocess).
694  // Whether the cached payload is authoritative for the current selection.
695  // The sections to render are resolved from this at each read site (lengths
696  // below, render pass further down) as a direct match so the immutable cache
697  // borrow stays scoped and never overlaps the `app.sidebar.max_scroll` /
698  // `scroll` writes in between — the pre-#343 borrow discipline, unchanged.
699  let cache_is_current = matches!(
700    &app.sidebar.cache,
701    Some(((p, m), _)) if *p == w.path && *m == active_mode
702  );
703
704  // The loading placeholder, built ONLY when the cache is stale — on the warm
705  // path it stays an empty `default()` (no per-frame identity-line allocation
706  // in what is, after all, a render-perf change). The identity card renders
707  // straight from `w` (no subprocess), so it shows instantly on navigation;
708  // the status / commits blocks read "loading…" until the worker's payload
709  // lands.
710  let placeholder = if cache_is_current {
711    SidebarSections::default()
712  } else {
713    SidebarSections {
714      worktree: worktree_identity_lines(&w, None, &theme),
715      working_tree: match active_mode {
716        super::state::sidebar::SidebarMode::Commits => {
717          vec![Line::from(Span::styled("loading…", Style::default().fg(theme.muted)))]
718        }
719        super::state::sidebar::SidebarMode::Stashes => Vec::new(),
720      },
721      working_tree_counts: WorkingTreeCounts::default(),
722      recent_commits: vec![Line::from(Span::styled("loading…", Style::default().fg(theme.muted)))],
723    }
724  };
725
726  // The live header line and the per-frame Issue / PR block are built BEFORE
727  // the long cache borrow so they don't overlap it. The header is the only
728  // line that is rebuilt fresh each frame (issue #73) — it's prefixed onto
729  // the cached worktree section at render time instead of being spliced into
730  // a cloned vec.
731  let prefix_lines = vec![sidebar_header_line(&w, app)];
732  let issue_pr_lines = github_status_lines(app, issue_pr_inner_width);
733  // Agents pane body (issue #408): per-frame pure snapshot + pins lookup
734  // (no config I/O — `app.agent_pins` is refreshed off-render), its
735  // bordered block collapses to zero height when nothing is pinned.
736  let agent_pins: &[String] = app
737    .agent_pins
738    .get(&crate::agent_sessions::path_display_key(&w.path))
739    .map(|v| v.as_slice())
740    .unwrap_or(&[]);
741  let agent_lines = agent_pane_lines(app.agents_for(&w), agent_pins, std::time::SystemTime::now(), &theme);
742
743  // Read the resolved section lengths via a short immutable borrow so the
744  // layout solver and scroll clamp can run before the render borrow. The
745  // worktree section gains the live prefix rows (header + optional agent
746  // summary).
747  let (worktree_len, working_tree_len, working_tree_counts, commits_len) = {
748    let s = if cache_is_current {
749      app.sidebar.cache.as_ref().map(|(_, s)| s).unwrap_or(&placeholder)
750    } else {
751      &placeholder
752    };
753    (
754      s.worktree.len() + prefix_lines.len(),
755      s.working_tree.len(),
756      s.working_tree_counts,
757      s.recent_commits.len() as u16,
758    )
759  };
760
761  // Per-section block height = content rows + 2 border lines. Fixed
762  // for the small sections (worktree / issue-PR); the three variable
763  // sections — Agents / Working Tree / Recent Commits — share the rest
764  // through the responsive solver (issue #438): natural heights while
765  // everything fits (commits absorbs the slack, as the old `Min(3)`
766  // did), a 5-line floor per visible section plus proportional sharing
767  // on overflow. Empty sections keep their collapse behaviour (issue
768  // #34: Working Tree is empty in `Stashes` mode; #408: Agents hidden
769  // with no session).
770  let h = |lines: usize| (lines as u16).saturating_add(2);
771  let fixed = h(worktree_len).saturating_add(h(issue_pr_lines.len()));
772  let (agents_height, working_tree_height, commits_height) = super::state::sidebar::split_section_heights(
773    area.height.saturating_sub(fixed),
774    agent_lines.len() as u16,
775    working_tree_len as u16,
776    commits_len,
777  );
778  let constraints = [
779    Constraint::Length(h(worktree_len)),
780    Constraint::Length(h(issue_pr_lines.len())),
781    Constraint::Length(agents_height),
782    Constraint::Length(working_tree_height),
783    Constraint::Length(commits_height),
784  ];
785  let chunks = Layout::default()
786    .direction(Direction::Vertical)
787    .constraints(constraints)
788    .split(area);
789
790  // Recent Commits is the only scrollable section. Clamp the scroll
791  // offset to its visible area so `j` / `k` can't scroll past the end.
792  // Done before the render borrow so no mutable `app` access overlaps it.
793  let commits_area = chunks[4];
794  let commits_visible = commits_area.height.saturating_sub(2);
795  app.sidebar.max_scroll = commits_len.saturating_sub(commits_visible);
796  if app.sidebar.scroll > app.sidebar.max_scroll {
797    app.sidebar.scroll = app.sidebar.max_scroll;
798  }
799  let scroll = app.sidebar.scroll;
800
801  // Working Tree scroll (issue #437). The section asks for
802  // `Length(content + 2)` but the layout solver may hand it less when
803  // the sidebar column is shorter than the sum of its sections — the
804  // exact case where entries used to be unreachable. Republish the max
805  // against the clamped viewport, same contract as Recent Commits.
806  let wt_visible = chunks[3].height.saturating_sub(2);
807  app.sidebar.wt_max_scroll = (working_tree_len as u16).saturating_sub(wt_visible);
808  if app.sidebar.wt_scroll > app.sidebar.wt_max_scroll {
809    app.sidebar.wt_scroll = app.sidebar.wt_max_scroll;
810  }
811  let wt_scroll = app.sidebar.wt_scroll;
812
813  // Issue #34: surface the active mode in the bottom-scrollable
814  // panel title. The footer keeps the `i of N` counter; the bottom
815  // hint switches to "Enter: copy stash@{N}" in stashes mode.
816  let (panel_title, panel_footer) = match active_mode {
817    super::state::sidebar::SidebarMode::Commits => {
818      let title = recent_items_pane_title(active_mode, &app.keymap);
819      let footer = if commits_len == 0 {
820        None
821      } else {
822        let bottom = scroll.saturating_add(commits_visible).min(commits_len);
823        Some(format!(" {} of {} ", bottom, commits_len))
824      };
825      (title, footer)
826    }
827    super::state::sidebar::SidebarMode::Stashes => {
828      let title = recent_items_pane_title(active_mode, &app.keymap);
829      // The "Enter on stash …" hint from the issue is the operative
830      // affordance in this mode — it's worth more than the i/N
831      // counter because the user needs to know they can paste the
832      // ref name.
833      let footer = if commits_len == 0 {
834        None
835      } else {
836        Some(" Enter: copy stash@{N} to status ".to_string())
837      };
838      (title, footer)
839    }
840  };
841  let issue_pr_title = issue_pr_pane_title(&app.keymap);
842  let working_tree_title = working_tree_pane_title(&app.keymap);
843  // Working Tree footer (issue #287): colour-coded created / modified /
844  // deleted counts. `None` in stashes mode (no section) and on a clean tree
845  // (all-zero counts → `working_tree_counts_footer` returns `None`), so the
846  // footer disappears instead of showing a bare ` 0 `.
847  let working_tree_footer = if working_tree_len == 0 {
848    None
849  } else {
850    working_tree_counts_footer(&working_tree_counts, &theme)
851  };
852
853  // The render borrow: sections are read by reference and never cloned (issue
854  // #238). On a cache hit this copies zero commit text — the up-to-300 `git
855  // log` lines stay put in `app.sidebar.cache`; `render_section` only rebuilds
856  // the thin padded `Vec<Span>` per visible row, borrowing the span content.
857  // `app` is only read immutably from here on (all mutation already happened
858  // above), so this long borrow is conflict-free. Resolved from the same
859  // `cache_is_current` decision as the length block: the cached payload when it
860  // is authoritative for the current selection, else the loading placeholder
861  // (issue #343) — never `unwrap()`, keeping the render path panic-free per the
862  // house rules.
863  let sections = if cache_is_current {
864    app.sidebar.cache.as_ref().map(|(_, s)| s).unwrap_or(&placeholder)
865  } else {
866    &placeholder
867  };
868  render_section(
869    f,
870    chunks[0],
871    status_pane_title(),
872    SectionBody::with_prefix(&prefix_lines, &sections.worktree),
873    border_color,
874    0,
875    None,
876  );
877  render_section(
878    f,
879    chunks[1],
880    issue_pr_title,
881    SectionBody::new(&issue_pr_lines),
882    border_color,
883    0,
884    None,
885  );
886  if !agent_lines.is_empty() {
887    render_section(
888      f,
889      chunks[2],
890      agents_pane_title(&app.keymap),
891      SectionBody::new(&agent_lines),
892      border_color,
893      0,
894      None,
895    );
896  }
897  if !sections.working_tree.is_empty() {
898    render_section(
899      f,
900      chunks[3],
901      working_tree_title,
902      SectionBody::new(&sections.working_tree),
903      border_color,
904      wt_scroll,
905      working_tree_footer,
906    );
907    // Scrollbar over the inner right column when the tree overflows the
908    // viewport the responsive split granted (user feedback on PR #454) —
909    // the scroll existed (#437) but nothing showed where the viewport
910    // sat. Same herdr-style helper as the overflowing modals; no-op when
911    // everything fits.
912    let inner = Rect {
913      x: chunks[3].x.saturating_add(1),
914      y: chunks[3].y.saturating_add(1),
915      width: chunks[3].width.saturating_sub(2),
916      height: chunks[3].height.saturating_sub(2),
917    };
918    if inner.height > 0 {
919      let _ = scrollable_body_area(f, inner, wt_scroll, working_tree_len, &theme);
920    }
921  }
922  render_section(
923    f,
924    commits_area,
925    panel_title,
926    SectionBody::new(&sections.recent_commits),
927    border_color,
928    scroll,
929    panel_footer.map(ratatui::text::Line::from),
930  );
931}
932
933/// Borrowed content for one [`render_section`] block (issue #238).
934///
935/// `lines` are rendered straight out of their owner — for the sidebar that
936/// is `app.sidebar.cache`, so a warm-cache frame copies none of the up-to-300
937/// commit `Line`s (each holding owned `String` spans) that the previous code
938/// deep-cloned every frame just to dodge a borrow conflict. `prefix` carries
939/// the single live line (the `● <name>` header) that must lead the worktree
940/// section; it's rebuilt fresh per frame anyway, so passing it separately
941/// costs nothing and keeps the cached `worktree` vec immutable.
942struct SectionBody<'a> {
943  prefix: &'a [Line<'a>],
944  lines: &'a [Line<'a>],
945}
946
947impl<'a> SectionBody<'a> {
948  /// Section body with no leading live line (Issue / PR, Working Tree,
949  /// Recent Commits, and the `(nothing selected)` placeholder).
950  fn new(lines: &'a [Line<'a>]) -> Self {
951    Self { prefix: &[], lines }
952  }
953
954  /// Section body whose first rows are per-frame live lines — the worktree
955  /// identity block, led by the `● <name>` status-dot header (and, since
956  /// issue #408, an optional agent summary line).
957  fn with_prefix(prefix: &'a [Line<'a>], lines: &'a [Line<'a>]) -> Self {
958    Self { prefix, lines }
959  }
960}
961
962fn render_section(
963  f: &mut Frame,
964  area: Rect,
965  // Title is `impl Into<Line<'static>>` so static-literal call
966  // sites (` Worktree ` / ` Issue / PR ` / ` Working Tree `) pass
967  // through to ratatui zero-copy (a `&'static str` becomes a
968  // `Line<'static>` borrowing the slice), while the dynamic
969  // mode-aware title for the bottom panel (` Recent Commits —
970  // commits ` / ` Stashes — stashes `) moves in as an owned
971  // `String`. Pre-review the signature was `impl Into<String>`,
972  // which copied every static literal on every render frame.
973  title: impl Into<ratatui::text::Line<'static>>,
974  body: SectionBody<'_>,
975  border_color: Color,
976  scroll: u16,
977  footer: Option<ratatui::text::Line<'static>>,
978) {
979  let SectionBody { prefix, lines } = body;
980  let mut block = Block::default()
981    .borders(Borders::ALL)
982    .border_type(BorderType::Rounded)
983    .title(title.into())
984    .border_style(Style::default().fg(border_color));
985  if let Some(f) = footer {
986    block = block.title_bottom(f.right_aligned());
987  }
988  // Pad content with one leading space per line for breathing room against
989  // the left border. Each padded line BORROWS its span content from the
990  // source line (`Span::styled(&str, style)` yields a `Cow::Borrowed`, zero
991  // allocation) so a warm cache hit copies no commit text — only the thin
992  // per-row `Vec<Span>` is rebuilt, which the old code did anyway.
993  fn pad<'a>(l: &'a Line<'_>) -> Line<'a> {
994    let mut spans = Vec::with_capacity(l.spans.len() + 1);
995    spans.push(Span::raw(" "));
996    spans.extend(l.spans.iter().map(|s| Span::styled(s.content.as_ref(), s.style)));
997    Line::from(spans)
998  }
999  let padded: Vec<Line<'_>> = prefix.iter().chain(lines.iter()).map(pad).collect();
1000  // No `Wrap`: every section now relies on ratatui's view-level hard-clip,
1001  // matching lazygit's commits panel and ensuring 1 logical row = 1 visual
1002  // row (so the layout's `Constraint::Length` always matches what we draw).
1003  let paragraph = Paragraph::new(padded).block(block).scroll((scroll, 0));
1004  f.render_widget(paragraph, area);
1005}
1006
1007/// Lazygit-style header line: `● <name>` where the dot's colour tracks
1008/// the linked PR / issue state. Rendered fresh every frame (not cached)
1009/// so the dot reflects the live fetch result without invalidating the
1010/// expensive git preview cache underneath.
1011/// Title of the Agents sidebar pane (issue #408, user feedback 2026-07-22):
1012/// advertises the overlay key like `Issue / PR [F]` does its fetch key.
1013pub fn agents_pane_title(keymap: &Keymap) -> String {
1014  format!(" Agents [{}] ", action_chord(keymap, Action::AgentSessions, "a"))
1015}
1016
1017/// Per-frame body of the Agents sidebar pane: one line per **pinned**
1018/// session (user feedback 2026-07-22 — the pane is the deliberate view;
1019/// the full detected list lives in the `a` overlay), capped at three —
1020/// agent kind coloured by freshness, a human-readable recency, and the
1021/// session name (full id when unnamed). Empty when nothing is pinned, so
1022/// the bordered block collapses like the Working Tree pane does in stashes
1023/// mode. Pure — pinned by `tests/tui_app_tests.rs::agent_pane`.
1024pub fn agent_pane_lines(
1025  agents: Option<&crate::agent_sessions::WorktreeAgents>,
1026  pinned: &[String],
1027  now: std::time::SystemTime,
1028  theme: &Theme,
1029) -> Vec<Line<'static>> {
1030  const MAX_ROWS: usize = 3;
1031  let Some(agents) = agents else {
1032    return Vec::new();
1033  };
1034  let shown: Vec<&crate::agent_sessions::AgentSession> = agents
1035    .sessions
1036    .iter()
1037    .filter(|s| pinned.iter().any(|p| p == &s.id))
1038    .collect();
1039  let mut lines: Vec<Line<'static>> = shown
1040    .iter()
1041    .take(MAX_ROWS)
1042    .map(|s| {
1043      let freshness = crate::agent_sessions::Freshness::classify(s.last_activity, s.ended, now);
1044      let (word, color) = match freshness {
1045        crate::agent_sessions::Freshness::Active => ("active", theme.clean),
1046        crate::agent_sessions::Freshness::Idle => ("idle", theme.muted),
1047      };
1048      let ago = now
1049        .duration_since(s.last_activity)
1050        .map(worktree::format_relative_duration)
1051        .unwrap_or_else(|_| "now".into());
1052      let identity = s.name.as_deref().unwrap_or(&s.id);
1053      Line::from(vec![
1054        Span::styled(
1055          s.kind.display().to_string(),
1056          Style::default().fg(color).add_modifier(Modifier::BOLD),
1057        ),
1058        Span::styled(format!(" · {word} · {ago} ago · "), Style::default().fg(theme.muted)),
1059        Span::styled(identity.to_string(), Style::default().fg(theme.name)),
1060      ])
1061    })
1062    .collect();
1063  let extra = shown.len().saturating_sub(MAX_ROWS);
1064  if extra > 0 {
1065    lines.push(Line::from(Span::styled(
1066      format!("+{extra} more"),
1067      Style::default().fg(theme.muted),
1068    )));
1069  }
1070  lines
1071}
1072
1073fn sidebar_header_line(w: &WorktreeInfo, app: &App) -> Line<'static> {
1074  let (dot, dot_color) = sidebar_status_dot(app);
1075  Line::from(vec![
1076    Span::styled(dot, Style::default().fg(dot_color).add_modifier(Modifier::BOLD)),
1077    Span::styled(w.name.clone(), worktree_name_style(&app.theme)),
1078  ])
1079}
1080
1081/// Resolve the leading status dot for the sidebar header. PR state wins
1082/// over issue state (a worktree most often tracks a PR); falls back to a
1083/// neutral darkgray dot when the worktree has no link at all so the
1084/// alignment stays consistent across rows.
1085fn sidebar_status_dot(app: &App) -> (&'static str, Color) {
1086  if let GitHubFetchState::Loaded(pr) = app.pr_fetch_state() {
1087    return ("● ", pr_badge_color(pr.state, &app.theme));
1088  }
1089  if let GitHubFetchState::Loaded(issue) = app.issue_fetch_state() {
1090    return ("● ", issue_badge_color(issue.state, &app.theme));
1091  }
1092  let link = app.current_link();
1093  if link.pr.is_some() || link.issue.is_some() {
1094    // Link exists but not fetched yet — neutral white so the user sees
1095    // there's *something* to refresh with `F`. White carries no theme
1096    // role (it is "not yet known", not a status), so it stays white.
1097    return ("● ", Color::White);
1098  }
1099  ("● ", app.theme.muted)
1100}
1101
1102/// Build the per-section content of the details sidebar for one worktree.
1103///
1104/// The Commands cheat-sheet block is intentionally not produced here — it
1105/// duplicated the `?` help overlay and consumed ~15 vertical lines for no
1106/// new information. Press `?` for the full key map.
1107///
1108/// The `●` status-dot header is intentionally NOT in `worktree` here either —
1109/// it's rebuilt fresh by `draw_sidebar` on every frame so the dot tracks
1110/// live PR / issue fetch state without invalidating this cached payload.
1111pub fn build_sidebar_sections(
1112  w: &WorktreeInfo,
1113  mode: super::state::sidebar::SidebarMode,
1114  diff: Option<worktree::DiffLineStat>,
1115  theme: &Theme,
1116) -> SidebarSections {
1117  use super::state::sidebar::SidebarMode;
1118  let body = match mode {
1119    // Pre-#34 behaviour. The `Working Tree` section is unconditionally
1120    // rendered alongside; both come from `git log` / `git status` and
1121    // share a single cache invalidation cycle.
1122    SidebarMode::Commits => recent_commits_lines(w, RECENT_COMMITS_LIMIT, theme),
1123    // Stashes view (issue #34). `working_tree` is left empty: the
1124    // user's current dirty state has nothing to do with the stashed
1125    // contents they're auditing, so a separate `git status` block
1126    // alongside would only distract. A per-stash file summary
1127    // (`+/-` counts via `git diff-tree --numstat`) is on the
1128    // follow-up list; v1 ships `<ref>  <subject>` only.
1129    SidebarMode::Stashes => stash_lines(w, STASHES_DISPLAY_LIMIT, theme),
1130  };
1131  let (working_tree, working_tree_counts) = match mode {
1132    SidebarMode::Commits => working_tree_lines(w, theme),
1133    SidebarMode::Stashes => (Vec::new(), WorkingTreeCounts::default()),
1134  };
1135  SidebarSections {
1136    worktree: worktree_identity_lines(w, diff.as_ref(), theme),
1137    working_tree,
1138    working_tree_counts,
1139    recent_commits: body,
1140  }
1141}
1142
1143/// Build the full sidebar payload for one worktree — the diff-vs-base stat
1144/// plus [`build_sidebar_sections`] — as a single owned, `Send` value (issue
1145/// #343). This is the unit of work the async sidebar worker runs off-thread:
1146/// every git subprocess the sidebar needs (`git_diff_stat_vs_base`,
1147/// `git status --porcelain -z`, `git log`, `git stash list`) fires here, on a
1148/// worker, so `terminal.draw` never shells out. The result is stored into
1149/// `SidebarState::cache` by `App::drain_task_results`. `trunks` is the active
1150/// repo's `doctor.trunks` (the base `gwm pr` targets) captured at spawn.
1151pub fn build_sidebar_payload(
1152  w: &WorktreeInfo,
1153  mode: super::state::sidebar::SidebarMode,
1154  trunks: &[String],
1155  theme: &Theme,
1156) -> SidebarSections {
1157  let diff = worktree::git_diff_stat_vs_base(&w.path, trunks).ok().flatten();
1158  build_sidebar_sections(w, mode, diff, theme)
1159}
1160
1161/// Number of stash entries shown in `SidebarMode::Stashes`. Set to
1162/// match `RECENT_COMMITS_LIMIT` so the panel stays a comparable height
1163/// across modes. Stashes beyond this are still listed by
1164/// `git stash list` — the limit only governs the in-panel preview.
1165pub const STASHES_DISPLAY_LIMIT: usize = 10;
1166
1167/// Render `git stash list` output (issue #34) into ratatui lines for
1168/// the stashes mode of the sidebar. One stash per row, formatted as
1169/// `<ref>  <subject>` with the ref in yellow (to mimic git's own
1170/// colourisation). When the worktree has no stashes the renderer
1171/// shows a single muted "(no stashes)" line so the panel never reads
1172/// as broken on a fresh worktree.
1173fn stash_lines(w: &WorktreeInfo, limit: usize, theme: &Theme) -> Vec<Line<'static>> {
1174  match crate::worktree::git_stash_list(&w.path, limit) {
1175    Ok(stashes) if stashes.is_empty() => {
1176      vec![Line::from(Span::styled(
1177        "(no stashes)",
1178        Style::default().fg(theme.muted),
1179      ))]
1180    }
1181    Ok(stashes) => stashes
1182      .into_iter()
1183      .map(|s| {
1184        Line::from(vec![
1185          Span::styled(s.ref_name, Style::default().fg(theme.dirty)),
1186          Span::raw("  "),
1187          Span::raw(s.subject),
1188        ])
1189      })
1190      .collect(),
1191    Err(e) => vec![Line::from(Span::styled(
1192      format!("git stash list failed: {}", e),
1193      Style::default().fg(theme.prunable),
1194    ))],
1195  }
1196}
1197
1198/// Compact identity card for the Worktree block — `branch · head`,
1199/// `Created: <age>`, status + flag badges, tilde-compressed path. The
1200/// `●` status dot + bold name line is prepended live by `draw_sidebar`,
1201/// not cached here, so the dot can track GitHub fetch state without
1202/// invalidating the git-preview cache. Skips badges whose flags are
1203/// false to avoid visual noise.
1204fn worktree_identity_lines(
1205  w: &WorktreeInfo,
1206  diff: Option<&worktree::DiffLineStat>,
1207  theme: &Theme,
1208) -> Vec<Line<'static>> {
1209  let mut out: Vec<Line<'static>> = Vec::with_capacity(5);
1210  let label_w = "Created".chars().count();
1211  let label_style = Style::default().fg(theme.muted);
1212
1213  // Line 1 — "Branch  <branch> · <short head>". Branch colour follows the
1214  // lazygit scheme (PR #73): worst-state wins (dirty → red,
1215  // ahead/behind → yellow, unpublished → magenta, synced → green,
1216  // unknown → dark gray) so the most actionable signal stays at eye
1217  // level.
1218  let branch_color = branch_name_color(&w.status, theme);
1219  let branch = w.branch.clone().unwrap_or_else(|| "-".into());
1220  let mut spans = vec![
1221    Span::styled(format!("{:<label_w$}  ", "Branch", label_w = label_w), label_style),
1222    Span::styled(branch, Style::default().fg(branch_color)),
1223  ];
1224  if let Some(head) = w.head.as_deref() {
1225    spans.push(Span::styled("  ·  ".to_string(), Style::default().fg(theme.muted)));
1226    spans.push(Span::styled(short_oid(head), Style::default().fg(theme.dirty)));
1227  }
1228  out.push(Line::from(spans));
1229
1230  // Line 2 — "Created  <age>" (compact relative duration, colour-coded
1231  // by freshness — PR #73). Skipped when the branch has no measurable
1232  // age (trunk, detached HEAD, or repo open failure).
1233  out.push(Line::from(vec![
1234    Span::styled(format!("{:<label_w$}  ", "Created", label_w = label_w), label_style),
1235    Span::styled(branch_age_label(w), Style::default().fg(branch_age_color(w, theme))),
1236  ]));
1237
1238  // Line 3 (issue #287) — "Diff  +<ins> -<del>" of the branch versus its
1239  // base trunk (three-dot merge-base diff, matching `gwm pr`'s base).
1240  // Insertions paint green (`untracked` role), deletions red (`prunable`
1241  // role). Skipped when there's no base, HEAD is the trunk, or the branch
1242  // has no committed diff yet — `diff` arrives `None` / empty in those
1243  // cases so the card stays compact.
1244  if let Some(d) = diff {
1245    if !d.is_empty() {
1246      out.push(Line::from(vec![
1247        Span::styled(format!("{:<label_w$}  ", "Diff", label_w = label_w), label_style),
1248        Span::styled(format!("+{}", d.insertions), Style::default().fg(theme.untracked)),
1249        Span::raw(" "),
1250        Span::styled(format!("-{}", d.deletions), Style::default().fg(theme.prunable)),
1251      ]));
1252    }
1253  }
1254
1255  // Line 4 — "State  <badges>" with optional flag badges. Only renders the badges
1256  // that are *true* / *interesting*; the false cases stay invisible.
1257  let mut state_spans = vec![Span::styled(
1258    format!("{:<label_w$}  ", "State", label_w = label_w),
1259    label_style,
1260  )];
1261  state_spans.extend(badges_line(w, theme).spans);
1262  out.push(Line::from(state_spans));
1263
1264  // Line 4 — "Path  <path>", tilde-compressed for compactness.
1265  out.push(Line::from(vec![
1266    Span::styled(format!("{:<label_w$}  ", "Path", label_w = label_w), label_style),
1267    Span::styled(
1268      tilde_compress(&w.path.display().to_string()),
1269      Style::default().fg(theme.muted),
1270    ),
1271  ]));
1272
1273  out
1274}
1275
1276/// Render the "Created" line value: compact relative duration (`2d`,
1277/// `3w`, `1M`, …) read from the pre-computed `WorktreeInfo.age` field,
1278/// or `"-"` when the branch has no measurable age (trunk, detached HEAD,
1279/// repo open failure). Issue #103: previously this opened a fresh
1280/// `git2::Repository` per row per frame; the libgit2 work now happens
1281/// once at `worktree::list()` time.
1282fn branch_age_label(w: &WorktreeInfo) -> String {
1283  w.age
1284    .map(worktree::format_relative_duration)
1285    .unwrap_or_else(|| "-".into())
1286}
1287
1288fn branch_age_color(w: &WorktreeInfo, theme: &Theme) -> Color {
1289  w.age.map(|age| freshness_color(age, theme)).unwrap_or(theme.muted)
1290}
1291
1292fn badges_line(w: &WorktreeInfo, theme: &Theme) -> Line<'static> {
1293  let mut spans: Vec<Span<'static>> = Vec::new();
1294  // Status sigil:
1295  //   `?`     — unknown
1296  //   `●`     — dirty (working tree or index)
1297  //   `✓`     — synced / clean (no divergence)
1298  //   (none)  — ahead / behind / both — the label already carries `↑N` /
1299  //             `↓M` / `↑N ↓M`. Prefixing `✓` here would lie about
1300  //             divergence (raised by PR #70 Copilot review).
1301  let status_label = branch_status_label(&w.status);
1302  let status_color = branch_status_color(&w.status, theme);
1303  let is_diverged = w.status.has_upstream && (w.status.ahead > 0 || w.status.behind > 0);
1304  let badge_text = if w.status.unknown {
1305    format!("? {}", status_label)
1306  } else if w.status.is_dirty {
1307    format!("● {}", status_label)
1308  } else if is_diverged {
1309    status_label
1310  } else {
1311    format!("✓ {}", status_label)
1312  };
1313  spans.push(Span::styled(badge_text, Style::default().fg(status_color)));
1314
1315  let sep = || Span::styled("  ".to_string(), Style::default().fg(theme.muted));
1316  if w.is_main {
1317    spans.push(sep());
1318    spans.push(Span::styled("★ main".to_string(), Style::default().fg(theme.main)));
1319  }
1320  if w.is_locked {
1321    spans.push(sep());
1322    spans.push(Span::styled("🔒 locked".to_string(), Style::default().fg(theme.locked)));
1323  }
1324  if w.is_prunable {
1325    spans.push(sep());
1326    spans.push(Span::styled(
1327      "⚠ prunable".to_string(),
1328      Style::default().fg(theme.prunable),
1329    ));
1330  }
1331  Line::from(spans)
1332}
1333
1334fn working_tree_lines(w: &WorktreeInfo, theme: &Theme) -> (Vec<Line<'static>>, WorkingTreeCounts) {
1335  match worktree::git_status_short(&w.path) {
1336    Ok((s, _)) if s.trim().is_empty() => (
1337      vec![Line::from(Span::styled(
1338        "✓ clean".to_string(),
1339        Style::default().fg(theme.clean),
1340      ))],
1341      WorkingTreeCounts::default(),
1342    ),
1343    Ok((s, scan_truncated)) => {
1344      let counts = working_tree_status_counts(&s);
1345      let records = wt_tree::parse_status_z(&s);
1346      // Cap the explorer for a pathological untracked-dir explosion (issue
1347      // #300): build at most WT_TREE_MAX_FILES leaves and surface the
1348      // remainder as a single muted `… N more` row, so the non-scrollable
1349      // section can't be sized from tens of thousands of files.
1350      let (tree, overflow) = wt_tree::build_capped_tree(&records, wt_tree::WT_TREE_MAX_FILES);
1351      let mut lines = working_tree_tree_lines(&tree, theme);
1352      if overflow > 0 {
1353        // After a scan truncation the real remainder is unknown (git was
1354        // killed at the cap), so `overflow` is only a lower bound — render
1355        // `… N+ more` rather than claiming an exact count.
1356        let label = if scan_truncated {
1357          format!("… {}+ more", overflow)
1358        } else {
1359          format!("… {} more", overflow)
1360        };
1361        lines.push(Line::from(Span::styled(label, Style::default().fg(theme.muted))));
1362      }
1363      (lines, counts)
1364    }
1365    Err(e) => (
1366      vec![Line::from(Span::styled(
1367        format!("! {}", e),
1368        Style::default().fg(theme.prunable),
1369      ))],
1370      WorkingTreeCounts::default(),
1371    ),
1372  }
1373}
1374
1375/// Render the Working Tree file-explorer model (issue #300) into styled
1376/// sidebar rows.
1377///
1378/// - **Connector lines**: each row is prefixed with box-drawing branches
1379///   (`├─ ` / `└─ ` with `│  ` / `   ` carried down from ancestors) in the
1380///   muted role, so the hierarchy reads like `tree(1)`.
1381/// - **Directory colour is retroactive**: a folder is painted by the
1382///   aggregate git category of its subtree — only-modified → yellow,
1383///   only-new → green, only-deleted → red, mixed (or none) → neutral
1384///   `accent`.
1385/// - **Files** carry a category-coloured status badge + a nerd-font
1386///   file-type icon + the leaf name, painted in the file's change-category
1387///   colour so a row's colour matches the footer count it belongs to (the
1388///   #287 invariant, preserved).
1389/// - An **extra space** follows each nerd-font glyph: most glyphs render
1390///   double-width but occupy a single terminal cell, so the pad keeps the
1391///   following text from being clipped.
1392fn working_tree_tree_lines(nodes: &[WtNode], theme: &Theme) -> Vec<Line<'static>> {
1393  let mut out = Vec::new();
1394  push_wt_nodes(&mut out, nodes, String::new(), theme);
1395  out
1396}
1397
1398/// Depth-first walk used by [`working_tree_tree_lines`]. `prefix` is the
1399/// accumulated ancestor connector string; each child appends `├─ `/`└─ `
1400/// for its own row and `│  `/`   ` for its descendants.
1401fn push_wt_nodes(out: &mut Vec<Line<'static>>, nodes: &[WtNode], prefix: String, theme: &Theme) {
1402  let last = nodes.len().saturating_sub(1);
1403  for (i, node) in nodes.iter().enumerate() {
1404    let is_last = i == last;
1405    let connector = format!("{}{}", prefix, if is_last { "└─ " } else { "├─ " });
1406    match node {
1407      WtNode::Dir {
1408        name,
1409        children,
1410        category,
1411      } => {
1412        let color = match category {
1413          Some(c) => working_tree_category_color(*c, theme),
1414          None => theme.accent,
1415        };
1416        out.push(Line::from(vec![
1417          Span::styled(connector, Style::default().fg(theme.muted)),
1418          Span::styled(
1419            format!("{}  {}", WT_DIR_OPEN_ICON, wt_tree::sanitize_name(name)),
1420            Style::default().fg(color),
1421          ),
1422        ]));
1423        let child_prefix = format!("{}{}", prefix, if is_last { "   " } else { "│  " });
1424        push_wt_nodes(out, children, child_prefix, theme);
1425      }
1426      WtNode::File {
1427        name,
1428        icon,
1429        badge,
1430        category,
1431      } => {
1432        let color = working_tree_category_color(*category, theme);
1433        out.push(Line::from(vec![
1434          Span::styled(connector, Style::default().fg(theme.muted)),
1435          Span::styled(format!("{} ", badge), Style::default().fg(color)),
1436          Span::styled(
1437            format!("{}  {}", icon, wt_tree::sanitize_name(name)),
1438            Style::default().fg(color),
1439          ),
1440        ]));
1441      }
1442    }
1443  }
1444}
1445
1446/// Per-category counts of changed files in the Working Tree pane (issue
1447/// #287), derived from `git status --short`. Each tracked / untracked file
1448/// is counted once, into the single category that dominates its porcelain
1449/// `XY` status pair.
1450#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1451pub struct WorkingTreeCounts {
1452  /// Untracked or added files (`??`, or `A` in either column).
1453  pub created: usize,
1454  /// Files changed in place (`M`, `R`, `C`, `T`, `U`, …).
1455  pub modified: usize,
1456  /// Files removed (`D` in either column).
1457  pub deleted: usize,
1458}
1459
1460impl WorkingTreeCounts {
1461  /// True when no file falls in any category — a clean (or empty) status,
1462  /// so the Working Tree footer renders nothing rather than a bare ` 0 `.
1463  pub fn is_empty(&self) -> bool {
1464    self.created == 0 && self.modified == 0 && self.deleted == 0
1465  }
1466}
1467
1468/// Nerdfont codicon glyphs for the Working Tree footer counts (issue #287):
1469/// `diff-added` / `diff-modified` / `diff-removed`, the purpose-built file-
1470/// status trio.
1471pub const WT_CREATED_ICON: &str = "\u{eadc}";
1472pub const WT_MODIFIED_ICON: &str = "\u{eadd}";
1473pub const WT_DELETED_ICON: &str = "\u{eade}";
1474
1475/// Theme colour for a change category (issue #287): created → `untracked`
1476/// (green), modified → `modified` (yellow), deleted → `prunable` (red).
1477fn working_tree_category_color(cat: WtCategory, theme: &Theme) -> Color {
1478  match cat {
1479    WtCategory::Created => theme.untracked,
1480    WtCategory::Modified => theme.modified,
1481    WtCategory::Deleted => theme.prunable,
1482  }
1483}
1484
1485/// Tally `git status --porcelain -z` output into per-category
1486/// [`WorkingTreeCounts`] (issue #287) via [`working_tree_category`]. Shares
1487/// the NUL-delimited parser ([`wt_tree::parse_status_z`]) with the file
1488/// tree, so a rename counts once (its source token is dropped) and the
1489/// footer total always matches the number of rows the tree renders.
1490pub fn working_tree_status_counts(status_z: &str) -> WorkingTreeCounts {
1491  let mut c = WorkingTreeCounts::default();
1492  for rec in wt_tree::parse_status_z(status_z) {
1493    match working_tree_category(rec.x, rec.y) {
1494      WtCategory::Created => c.created += 1,
1495      WtCategory::Modified => c.modified += 1,
1496      WtCategory::Deleted => c.deleted += 1,
1497    }
1498  }
1499  c
1500}
1501
1502/// Build the Working Tree pane footer (issue #287): per-category file
1503/// counts as colour-coded nerdfont segments — created (green / `untracked`
1504/// role), modified (yellow / `modified` role), deleted (red / `prunable`
1505/// role). Each segment renders only when its count is non-zero; an all-zero
1506/// (clean) tally yields `None` so the footer disappears entirely instead of
1507/// showing a bare ` 0 `.
1508pub fn working_tree_counts_footer(counts: &WorkingTreeCounts, theme: &Theme) -> Option<Line<'static>> {
1509  if counts.is_empty() {
1510    return None;
1511  }
1512  let mut spans: Vec<Span<'static>> = vec![Span::raw(" ")];
1513  if counts.created > 0 {
1514    spans.push(Span::styled(
1515      format!("{} {} ", WT_CREATED_ICON, counts.created),
1516      Style::default().fg(theme.untracked),
1517    ));
1518  }
1519  if counts.modified > 0 {
1520    spans.push(Span::styled(
1521      format!("{} {} ", WT_MODIFIED_ICON, counts.modified),
1522      Style::default().fg(theme.modified),
1523    ));
1524  }
1525  if counts.deleted > 0 {
1526    spans.push(Span::styled(
1527      format!("{} {} ", WT_DELETED_ICON, counts.deleted),
1528      Style::default().fg(theme.prunable),
1529    ));
1530  }
1531  Some(Line::from(spans))
1532}
1533
1534/// Colourise one `git status --short` porcelain line (issue #179, recoloured
1535/// in #287).
1536///
1537/// The short format is `XY<space>PATH`. The whole row — both status columns
1538/// and the file name — is painted by the file's single change category, so
1539/// a row's colour always equals the Working-Tree footer segment it's
1540/// counted in:
1541///
1542/// - created (`??` / `A`) → green (`untracked` role),
1543/// - modified (`M`, `R`, `C`, `T`, `U`, …) → yellow (`modified` role),
1544/// - deleted (`D`) → red (`prunable` role).
1545///
1546/// Precedence created > deleted > modified mirrors
1547/// [`working_tree_status_counts`] via the shared [`working_tree_category`].
1548/// The pre-#287 staged-vs-worktree (cyan `X` column) distinction is dropped
1549/// in favour of this add/modify/delete scheme. The separator space is left
1550/// unstyled; the rendered text is byte-for-byte identical to the input.
1551pub fn working_tree_status_line(raw: &str, theme: &Theme) -> Line<'static> {
1552  // Porcelain short output is always `XY<space>PATH` with ASCII status
1553  // codes, but the helper is `pub` — a non-git caller could pass arbitrary
1554  // input. Split on char boundaries (not byte offsets) so a multi-byte
1555  // leading codepoint can never slice mid-character and panic. Anything
1556  // shorter than the two status columns + separator is rendered verbatim.
1557  let mut indices = raw.char_indices();
1558  let (x_at, x) = match indices.next() {
1559    Some(c) => c,
1560    None => return Line::from(raw.to_string()),
1561  };
1562  let (_y_at, y) = match indices.next() {
1563    Some(c) => c,
1564    None => return Line::from(raw.to_string()),
1565  };
1566  let (sep_at, sep) = match indices.next() {
1567    Some(c) => c,
1568    None => return Line::from(raw.to_string()),
1569  };
1570  // Byte offset where the path begins (just past the separator char).
1571  let path_at = sep_at + sep.len_utf8();
1572
1573  // One colour for the whole row, from the file's change category — so the
1574  // row and the footer count agree (issue #287).
1575  let style = Style::default().fg(working_tree_category_color(working_tree_category(x, y), theme));
1576
1577  Line::from(vec![
1578    Span::styled(raw[x_at..sep_at].to_string(), style),
1579    Span::raw(raw[sep_at..path_at].to_string()),
1580    Span::styled(raw[path_at..].to_string(), style),
1581  ])
1582}
1583
1584/// Default number of commits pulled into the Recent Commits block — chosen
1585/// to match lazygit's initial `git log -300` window so the panel stays
1586/// dense on tall terminals without paginating.
1587pub const RECENT_COMMITS_LIMIT: usize = 300;
1588
1589/// Number of hex chars rendered for each commit's SHA in the sidebar.
1590/// Matches lazygit's `Gui.CommitHashLength` default of 8.
1591pub const COMMIT_HASH_DISPLAY_LEN: usize = 8;
1592
1593/// Produce the styled rows of the Recent Commits sidebar block for a
1594/// worktree, limited to `limit` entries. Each `Line` mirrors lazygit's
1595/// per-row format:
1596///
1597/// ```text
1598/// <8-char hash>  <author initials>  <graph>  <subject>
1599/// ```
1600///
1601/// where `<graph>` is the per-row output of the topology renderer in
1602/// [`super::commit_graph`] — a sequence of `2 * (max_pos + 1)` cells
1603/// drawing `○` / `◎` nodes plus the `│ ─ ╮ ╭ ╯ ╰ …` connectors that
1604/// link consecutive commits across branch / merge boundaries. The
1605/// graph width is deterministic on the commit list — independent of
1606/// terminal width — so the cache stays valid across resizes.
1607///
1608/// The subject is **not** truncated here — the renderer relies on
1609/// ratatui's view-level hard-clip (no `Wrap`) to match lazygit's gocui
1610/// behaviour: one commit per visual line, overflow cut at the right
1611/// edge without `…`.
1612pub fn recent_commits_lines(w: &WorktreeInfo, limit: usize, theme: &Theme) -> Vec<Line<'static>> {
1613  match worktree::recent_commits_cached(w, limit) {
1614    Ok(rows) if !rows.is_empty() => {
1615      let graphs = super::commit_graph::render_commits(&rows, theme);
1616      rows
1617        .into_iter()
1618        .zip(graphs)
1619        .map(|(row, graph_spans)| commit_row_line(row, graph_spans, theme))
1620        .collect()
1621    }
1622    Ok(_) => vec![Line::from(Span::styled(
1623      "(no commits)".to_string(),
1624      Style::default().fg(theme.muted),
1625    ))],
1626    Err(e) => vec![Line::from(Span::styled(
1627      format!("! {}", e),
1628      Style::default().fg(theme.prunable),
1629    ))],
1630  }
1631}
1632
1633fn commit_row_line(row: worktree::CommitRow, graph: Vec<Span<'static>>, theme: &Theme) -> Line<'static> {
1634  let mut short_hash = row.hash.to_string();
1635  short_hash.truncate(COMMIT_HASH_DISPLAY_LEN);
1636  let initials = author_initials(&row.author);
1637  let mut spans: Vec<Span<'static>> = Vec::with_capacity(5 + graph.len());
1638  spans.push(Span::styled(short_hash, Style::default().fg(theme.dirty)));
1639  spans.push(Span::raw("  "));
1640  spans.push(Span::styled(
1641    format!("{:<2}", initials),
1642    Style::default().fg(theme.accent).add_modifier(Modifier::BOLD),
1643  ));
1644  spans.push(Span::raw("  "));
1645  spans.extend(graph);
1646  spans.push(Span::raw(" "));
1647  spans.push(Span::raw(row.subject));
1648  Line::from(spans)
1649}
1650
1651/// Derive lazygit-style author initials from a full name. Closely
1652/// mirrors `getInitials` in lazygit's
1653/// `pkg/gui/presentation/authors/authors.go`:
1654///
1655/// - Empty / whitespace-only → empty.
1656/// - Single word → first 2 Unicode scalar values of that word.
1657/// - ≥ 2 words → first scalar of split[0] + first scalar of split[1].
1658///
1659/// "Kylian Bardini" → `KB`. "Linus" → `Li`. "🦀 Crab" → `🦀C`.
1660/// Capped at 2 visible characters (`CommitAuthorShortLength` in
1661/// lazygit).
1662///
1663/// **Divergence from lazygit** (PR #72 review, Copilot): lazygit uses
1664/// `uniseg.FirstGraphemeClusterInString` and keeps multi-scalar
1665/// grapheme clusters intact (e.g. regional-indicator flags like
1666/// "🇫🇷"). gwm slices on Unicode scalar values via `str::chars()`,
1667/// so the French flag is split into its two regional indicators and
1668/// only the first survives. We accept this divergence intentionally
1669/// — pulling in `unicode-segmentation` for a near-zero-impact author
1670/// renderer would inflate the dependency tree without user-visible
1671/// benefit on the typical "FirstName LastName" pattern.
1672pub fn author_initials(author: &str) -> String {
1673  let trimmed = author.trim();
1674  if trimmed.is_empty() {
1675    return String::new();
1676  }
1677  let mut parts = trimmed.split_whitespace();
1678  let first = parts.next().unwrap_or("");
1679  match parts.next() {
1680    Some(second) => {
1681      let a: String = first.chars().take(1).collect();
1682      let b: String = second.chars().take(1).collect();
1683      format!("{}{}", a, b)
1684    }
1685    None => first.chars().take(2).collect(),
1686  }
1687}
1688
1689/// Replace the user's home prefix with `~` so paths render compactly in
1690/// the narrow sidebar. Falls back to the raw path if `$HOME` is unset or
1691/// the path doesn't live under it.
1692fn tilde_compress(path: &str) -> String {
1693  if let Some(home) = dirs::home_dir() {
1694    tilde_compress_with_home(path, &home)
1695  } else {
1696    path.to_string()
1697  }
1698}
1699
1700/// Pure variant of [`tilde_compress`] that takes the home directory
1701/// explicitly. Exposed for tests — the production `tilde_compress`
1702/// wrapper just looks up `dirs::home_dir()` and delegates.
1703///
1704/// Enforces a path-separator boundary at the end of the home prefix so
1705/// `/home/al` does not slice into `/home/alice/repo` and produce
1706/// `~ice/repo` (raised by PR #70 Copilot review).
1707pub fn tilde_compress_with_home(path: &str, home: &std::path::Path) -> String {
1708  let home_s = home.display().to_string();
1709  if let Some(rest) = path.strip_prefix(&home_s) {
1710    // Accept exact-home (`rest.is_empty()`) and home-followed-by-separator
1711    // matches. Reject prefix matches that bleed into a longer dir name.
1712    if rest.is_empty() || rest.starts_with('/') || rest.starts_with(std::path::MAIN_SEPARATOR) {
1713      return format!("~{}", rest);
1714    }
1715  }
1716  path.to_string()
1717}
1718
1719fn short_oid(oid: &str) -> String {
1720  oid.chars().take(7).collect()
1721}
1722
1723fn branch_status_label(s: &BranchStatus) -> String {
1724  if s.unknown {
1725    return "unknown".into();
1726  }
1727  let mut parts: Vec<String> = Vec::new();
1728  if s.is_dirty {
1729    parts.push("dirty".into());
1730  }
1731  if s.has_upstream {
1732    if s.ahead > 0 {
1733      parts.push(format!("↑{}", s.ahead));
1734    }
1735    if s.behind > 0 {
1736      parts.push(format!("↓{}", s.behind));
1737    }
1738    if !s.is_dirty && s.synced() {
1739      parts.push("synced".into());
1740    }
1741  } else if !s.is_dirty {
1742    parts.push("clean".into());
1743  }
1744  if parts.is_empty() {
1745    "clean".into()
1746  } else {
1747    parts.join(" ")
1748  }
1749}
1750
1751/// Worst-status accent colour for a [`BranchStatus`]: `unknown` → `muted`,
1752/// `dirty`/`behind` → `dirty`, `ahead`-only → `accent`, else `clean`. The
1753/// single source of truth shared by the sidebar status badge (`badges_line`)
1754/// and the table status cell ([`format_status`], issue #241) — each builds its
1755/// own label/sigils, but the colour is derived here once. Exported so the
1756/// dedup is pinned by `tests/tui_theme_audit_tests.rs` (both call sites are
1757/// private render code).
1758pub fn branch_status_color(s: &BranchStatus, theme: &Theme) -> Color {
1759  if s.unknown {
1760    theme.muted
1761  } else if s.is_dirty || s.behind > 0 {
1762    theme.dirty
1763  } else if s.ahead > 0 {
1764    theme.accent
1765  } else {
1766    theme.clean
1767  }
1768}
1769
1770/// Constraint-friendly column width based on observed content, clamped to [min, max].
1771fn column_width<'a>(items: impl Iterator<Item = &'a str>, min: u16, max: u16) -> u16 {
1772  let observed = items.map(|s| s.chars().count() as u16).max().unwrap_or(min);
1773  observed.clamp(min, max)
1774}
1775
1776/// Style for the worktree *name* — the row's primary identity text in
1777/// the table and the sidebar header. Uses the `name` role (default
1778/// `White`, issue #210), rendered bold so the name anchors each row.
1779/// Extracted so the role wiring is unit-testable (`build_row` /
1780/// `sidebar_header_line` are private render code).
1781pub fn worktree_name_style(theme: &Theme) -> Style {
1782  Style::default().fg(theme.name).add_modifier(Modifier::BOLD)
1783}
1784
1785/// Style for the table's worktree *path* column. Uses the `path` role
1786/// (default `Gray`, issue #210) — a structural mid-grey distinct from
1787/// `muted` (`DarkGray`). Extracted alongside [`worktree_name_style`]
1788/// for the same testability reason.
1789pub fn worktree_path_style(theme: &Theme) -> Style {
1790  Style::default().fg(theme.path)
1791}
1792
1793/// The shared "chip" style: a reverse-video, bold badge painted on `color`
1794/// (issue #240). This is the single source of truth for the `` key `` /
1795/// button / badge treatment that recurs across the header, footer,
1796/// statusbar, help overlay and modal buttons — `REVERSED` paints `color`
1797/// as the chip's background, `BOLD` keeps the glyph legible against it.
1798/// Extracted so the ~14 inline `fg(c).add_modifier(REVERSED | BOLD)`
1799/// repetitions resolve through one definition; sites that add a `bg` or
1800/// extra modifiers keep their bespoke style.
1801pub fn chip_style(color: Color) -> Style {
1802  Style::default()
1803    .fg(color)
1804    .add_modifier(Modifier::REVERSED | Modifier::BOLD)
1805}
1806
1807/// The hint *bind* style (issue #279): the accent-coloured, **bold** key
1808/// glyph that leads every statusbar / modal hint. This replaces the
1809/// pre-#279 reverse-video [`chip_style`] badge with a flat herdr-style
1810/// "accent bind + space + muted action" treatment — no box around the key.
1811/// Action *buttons* (Create / confirm / type selector) and the statusbar
1812/// context anchor keep [`chip_style`]; only the which-key hints are flat.
1813pub fn hint_key_style(theme: &Theme) -> Style {
1814  Style::default().fg(theme.accent).add_modifier(Modifier::BOLD)
1815}
1816
1817/// The hint *action* style (issue #279): the muted description trailing a
1818/// [`hint_key_style`] bind. Routed through the `muted` role so a theme
1819/// override recolours it with the rest of the dim chrome.
1820pub fn hint_label_style(theme: &Theme) -> Style {
1821  Style::default().fg(theme.muted)
1822}
1823
1824/// Style for a *non-highlighted* command name in the command palette
1825/// (issue #210 follow-up). Routes through the `name` role (default
1826/// `White`) so a `[theme]` override / light preset recolours it, instead
1827/// of the pre-#240 hard-coded `Color::White` that bypassed the theme.
1828/// Extracted so the route is pinned by `tests/tui_theme_audit_tests.rs`
1829/// (`draw_command_palette` is private Frame render code).
1830pub fn palette_name_style(theme: &Theme) -> Style {
1831  Style::default().fg(theme.name)
1832}
1833
1834/// Style for a Keybindings-overlay entry *label* (the action description
1835/// trailing each key chip). Routes through the `name` role (default
1836/// `White`) for the same reason as [`palette_name_style`]: the pre-#240
1837/// literal `Color::White` ignored a `[theme]` override. Pinned by
1838/// `tests/tui_theme_audit_tests.rs`.
1839pub fn help_label_style(theme: &Theme) -> Style {
1840  Style::default().fg(theme.name)
1841}
1842
1843/// Label + freshness for a worktree's AGENT cell (issue #408). `None` — for
1844/// a missing snapshot (startup) or a session-less worktree — renders an empty
1845/// cell, indistinguishable from today (spec US1 scenario 5). Pure so the
1846/// contract is pinned ratatui-free by `tests/tui_app_tests.rs`.
1847pub fn agent_cell_label(
1848  agents: Option<&crate::agent_sessions::WorktreeAgents>,
1849  now: std::time::SystemTime,
1850) -> Option<(&'static str, crate::agent_sessions::Freshness)> {
1851  let top = agents?.top()?;
1852  let freshness = crate::agent_sessions::Freshness::classify(top.last_activity, top.ended, now);
1853  Some((top.kind.display(), freshness))
1854}
1855
1856/// Build one worktree table row. In workspace mode (issue #36) `repo` is
1857/// `Some((name, width))` and a leading `REPO` cell is inserted after the age
1858/// column, painted in the `accent` role; in single-repo mode it is `None` and
1859/// the row keeps its historical shape.
1860fn build_row(
1861  w: &WorktreeInfo,
1862  repo: Option<(&str, u16)>,
1863  name_w: u16,
1864  branch_w: u16,
1865  status_w: u16,
1866  // Outer `Option` = is the AGENT column shown at all (round D:
1867  // conditional on any detected session); inner = this row's top agent.
1868  agent: Option<Option<(&'static str, crate::agent_sessions::Freshness)>>,
1869  theme: &Theme,
1870) -> Row<'static> {
1871  let marker = table_marker(w, theme);
1872  let branch_text = w.branch.clone().unwrap_or_else(|| "-".into());
1873
1874  // The worktree name is the row's primary identity text. It paints with
1875  // the `name` role (issue #210; default `White`, bold) so a `[theme]`
1876  // override / preset can recolour it.
1877  let name_cell = Cell::from(trunc(&w.name, name_w as usize)).style(worktree_name_style(theme));
1878
1879  // Issue #73: branch column tracks the worst-state colour so the
1880  // colour-coded signal is visible without expanding the sidebar.
1881  let branch_cell =
1882    Cell::from(trunc(&branch_text, branch_w as usize)).style(Style::default().fg(branch_name_color(&w.status, theme)));
1883
1884  let status_cell = build_status_cell(w, status_w as usize, theme);
1885
1886  // PR #74 follow-up: surface branch age right in the table so it stays
1887  // visible when the sidebar is hidden (<120 cols or `v` collapsed).
1888  // Issue #103: `w.age` is now pre-computed at `worktree::list()` time,
1889  // so the table render path is pure field access — no libgit2 handle is
1890  // opened per row per frame. Colour stays uniform Gray — the saturated
1891  // freshness palette (green/yellow/darkgray) reads as noise next to the
1892  // more important BRANCH-status colour, so we keep it muted in the table
1893  // and let the sidebar's `Created:` row carry the colour-coded signal.
1894  let age_label = w.age.map(format_relative_duration_str).unwrap_or_else(|| "-".into());
1895  let age_cell = Cell::from(age_label).style(Style::default().fg(theme.muted));
1896
1897  // The path column paints with the `path` role (issue #210; default
1898  // `Gray`) — a structural mid-grey distinct from `muted`/`DarkGray`.
1899  // Not width-constrained, so it does not pass through `trunc`'s funnel and
1900  // has to say so itself: a path carries the worktree directory name, which is
1901  // as unvetted as the branch (issue #506).
1902  let path_cell =
1903    Cell::from(crate::naming::sanitise_for_terminal(&w.path.to_string_lossy())).style(worktree_path_style(theme));
1904
1905  let mut cells = vec![age_cell];
1906  if let Some((repo_name, repo_w)) = repo {
1907    cells.push(
1908      Cell::from(trunc(repo_name, repo_w as usize))
1909        .style(Style::default().fg(theme.accent).add_modifier(Modifier::BOLD)),
1910    );
1911  }
1912  cells.push(Cell::from(marker));
1913  cells.push(name_cell);
1914  cells.push(branch_cell);
1915  cells.push(status_cell);
1916  // AGENT (issue #408): the most recently active session's agent, coloured by
1917  // freshness — `clean` (active) vs `muted` (idle). Sessionless row in a
1918  // shown column → empty cell; column hidden → no cell at all (round D).
1919  if let Some(agent) = agent {
1920    cells.push(match agent {
1921      Some((label, crate::agent_sessions::Freshness::Active)) => {
1922        Cell::from(label).style(Style::default().fg(theme.clean).add_modifier(Modifier::BOLD))
1923      }
1924      Some((label, crate::agent_sessions::Freshness::Idle)) => {
1925        Cell::from(label).style(Style::default().fg(theme.muted))
1926      }
1927      None => Cell::from(""),
1928    });
1929  }
1930  cells.push(path_cell);
1931  Row::new(cells)
1932}
1933
1934/// Owned-String wrapper around `worktree::format_relative_duration` so
1935/// the table-row builder can hand a `Cell::from` an owned value without
1936/// re-allocating downstream. Centralised here purely to keep `build_row`
1937/// readable.
1938fn format_relative_duration_str(d: std::time::Duration) -> String {
1939  worktree::format_relative_duration(d)
1940}
1941
1942fn build_status_cell(w: &WorktreeInfo, width: usize, theme: &Theme) -> Cell<'static> {
1943  // Priority: prunable > locked > dirty/sync info.
1944  if w.is_prunable {
1945    return Cell::from("prunable").style(Style::default().fg(theme.prunable).add_modifier(Modifier::BOLD));
1946  }
1947  if w.is_locked {
1948    return Cell::from("locked").style(Style::default().fg(theme.locked));
1949  }
1950
1951  let s = &w.status;
1952  let (label, color) = format_status(s, width, theme);
1953  Cell::from(label).style(Style::default().fg(color))
1954}
1955
1956/// Pick a compact label + accent colour for a `BranchStatus`. The colour is
1957/// derived through the shared [`branch_status_color`] so the table cell and
1958/// the sidebar status agree (issue #241); the label/sigil logic stays
1959/// table-specific (the sidebar builds its own badge in `badges_line`).
1960/// Exported so the colour route is pinned by `tests/tui_theme_audit_tests.rs`.
1961pub fn format_status(s: &BranchStatus, width: usize, theme: &Theme) -> (String, Color) {
1962  if s.unknown {
1963    return ("unknown".into(), theme.muted);
1964  }
1965
1966  let mut parts: Vec<String> = Vec::new();
1967  if s.is_dirty {
1968    parts.push("● dirty".into());
1969  }
1970  if s.has_upstream {
1971    if s.ahead > 0 {
1972      parts.push(format!("↑{}", s.ahead));
1973    }
1974    if s.behind > 0 {
1975      parts.push(format!("↓{}", s.behind));
1976    }
1977    if !s.is_dirty && s.synced() {
1978      parts.push("✓ synced".into());
1979    }
1980  } else if !s.is_dirty {
1981    parts.push("clean".into());
1982  }
1983
1984  let joined = parts.join(" ");
1985  let label = trunc(&joined, width.max(4));
1986
1987  // Worst-status colour, shared with the sidebar (issue #241). `unknown` was
1988  // already handled by the early return above, so reaching `branch_status_color`
1989  // here is byte-identical to the former inline `dirty/behind → ahead → clean`
1990  // chain while keeping a single source of truth.
1991  (label, branch_status_color(s, theme))
1992}
1993
1994/// One statusbar hint specification (issue #217). Either a rebindable
1995/// keymap [`Action`](super::keymap::Action) whose key is resolved live from
1996/// the keymap, or a fixed literal for keys that are hard-coded contextual
1997/// escape hatches (Esc / Enter / digits inside a modal) and so cannot be
1998/// rebound.
1999#[derive(Debug, Clone, Copy)]
2000enum Hint {
2001  /// Resolve the displayed key from the global keymap (honours `[tui.keys]`).
2002  Key(super::keymap::Action, &'static str),
2003  /// Resolve the displayed key from the contextual modal keymap (honours
2004  /// `[tui.keys.modal.<context>]`, issue #219). Used for modal verbs whose hint
2005  /// is a single rebindable key (cancel / submit / confirm / issue / pr).
2006  Modal(ModalAction, &'static str),
2007  /// A fixed key + label for a non-rebindable keystroke or a multi-key
2008  /// movement pair (`↑/↓`, `j/k`) that no single resolved key captures.
2009  Lit(&'static str, &'static str),
2010}
2011
2012/// Which pane / mode / overlay the TUI is in — the single source the help
2013/// overlay subtitle and the contextual statusbar both read (issue #217).
2014/// Keeping them on one enum means the discoverable hints (`?`) and the
2015/// always-on statusbar chips can never advertise a different verb set for
2016/// the same context. An open modal takes priority over the pane focus (see
2017/// [`App::hint_context`](super::app::App::hint_context)).
2018#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2019pub enum HintContext {
2020  /// Worktree table focused — the default list-view context.
2021  Worktrees,
2022  /// Status (sidebar) pane focused — `j` / `k` scroll the preview.
2023  Status,
2024  /// `gwm switch` picker — mutating verbs are inert, Enter/Esc pick/cancel.
2025  Picker,
2026  /// Create-worktree form modal, structured `<type> <issue> <desc>` mode.
2027  Create,
2028  /// Create-worktree form modal, free-form mode (issue #416). A separate
2029  /// context because the two modes present different inputs: free-form has
2030  /// one field and no type selector, so the `field` / `type` hints would
2031  /// name verbs that do nothing there.
2032  CreateFreeform,
2033  /// Confirm-delete modal.
2034  Confirm,
2035  /// Open issue/PR URL menu.
2036  OpenMenu,
2037  /// Issue/PR link prompt, stage 1 — choose issue vs PR.
2038  LinkPrompt,
2039  /// Issue/PR link prompt, stage 2 — typing the number (#219): submit /
2040  /// cancel resolve from `[tui.keys.modal.link.input_number]`, not the choose
2041  /// stage's keys.
2042  LinkInputNumber,
2043  /// Command palette overlay.
2044  CommandPalette,
2045  /// Bootstrap report overlay.
2046  Report,
2047  /// Keybindings help overlay.
2048  Help,
2049  /// PTY overlay (embedded lazygit / terminal). All keys pass through to the
2050  /// child process; Esc is the only gwm-level escape hatch.
2051  Pty,
2052  /// Exec profile picker overlay (issue #325): j/k pick, Enter runs, Esc
2053  /// cancels.
2054  ExecPicker,
2055  /// Clean reclaim overlay (issue #325): j/k pick a profile, confirm
2056  /// reclaims (safety countdown), Esc cancels.
2057  Clean,
2058  /// Branch-rename modal (`View::Edit`, #290), structured triple.
2059  Rename,
2060  /// Branch-rename modal in free-form mode (issue #479). A separate context
2061  /// for the same reason `CreateFreeform` is one: the two modes present
2062  /// different inputs, so advertising a type selector free-form does not
2063  /// render would name a key that does nothing.
2064  RenameFreeform,
2065  /// Generic detail overlay (issue #408): scroll / close. Agent sessions
2066  /// today, the rich PR/Issue view tomorrow.
2067  Detail,
2068  /// CI checks overlay (issue #436): the detail-overlay shell on the linked
2069  /// PR's per-check rollup — j/k select, Enter opens the details URL,
2070  /// f filters, Esc closes.
2071  CiChecks,
2072}
2073
2074impl HintContext {
2075  /// Short label rendered into the statusbar context chip and the help
2076  /// overlay subtitle.
2077  pub fn label(self) -> &'static str {
2078    match self {
2079      HintContext::Worktrees => "worktrees",
2080      HintContext::Status => "status",
2081      HintContext::Picker => "switch",
2082      HintContext::Create | HintContext::CreateFreeform => "create",
2083      HintContext::Confirm => "confirm",
2084      HintContext::OpenMenu => "open",
2085      HintContext::LinkPrompt => "link",
2086      HintContext::LinkInputNumber => "link",
2087      HintContext::CommandPalette => "command",
2088      HintContext::Report => "report",
2089      HintContext::Help => "help",
2090      HintContext::Pty => "terminal",
2091      HintContext::ExecPicker => "exec",
2092      HintContext::Clean => "clean",
2093      HintContext::Rename | HintContext::RenameFreeform => "rename",
2094      HintContext::Detail => "agents",
2095      HintContext::CiChecks => "checks",
2096    }
2097  }
2098
2099  /// Static hint specs for this context. List-view contexts use rebindable
2100  /// [`Hint::Key`] verbs (resolved against the global keymap); modal /
2101  /// overlay contexts use [`Hint::Modal`] for their single-key rebindable
2102  /// verbs (resolved against the contextual keymap, issue #219) and
2103  /// [`Hint::Lit`] only for movement pairs (`↑/↓`, `j/k`) and the genuinely
2104  /// hard-coded escape hatches (the PTY overlay's `Esc`). All resolved live
2105  /// by [`Self::resolve`].
2106  fn hint_specs(self) -> &'static [Hint] {
2107    use super::keymap::Action::*;
2108    match self {
2109      // Grouped by family, most-used verb of each first; the order doubles as
2110      // the right-to-left truncation priority (#290 footer reorg).
2111      HintContext::Worktrees => &[
2112        // Worktree lifecycle.
2113        Hint::Key(Create, "new"),
2114        Hint::Key(DeleteConfirm, "del"),
2115        Hint::Key(Bootstrap, "boot"),
2116        // Act on the selected worktree. #453 re-audit: exec and agent
2117        // sessions joined the family; clean / mux / macros stay
2118        // overlay-only — the footer is a teaser, `?` is the manual.
2119        Hint::Key(TerminalFullscreen, "open"),
2120        Hint::Key(LazyGitFullscreen, "git"),
2121        Hint::Key(ExecOverlay, "exec"),
2122        Hint::Key(AgentSessions, "agents"),
2123        Hint::Key(ReviewFullscreen, "review"),
2124        Hint::Key(YankPath, "yank"),
2125        // Find / navigate panes.
2126        Hint::Key(Filter, "filter"),
2127        Hint::Key(FocusStatus, "status"),
2128        Hint::Key(CommandLogs, "logs"),
2129        Hint::Key(ConfigPanel, "settings"),
2130        // Global.
2131        Hint::Key(Help, "help"),
2132        Hint::Key(Quit, "quit"),
2133      ],
2134      HintContext::Status => &[
2135        // Read the status pane.
2136        Hint::Key(Down, "scroll"),
2137        Hint::Key(WtScrollDown, "wt scroll"),
2138        Hint::Key(FetchGithub, "fetch"),
2139        // #436: `c` routes to the CI checks overlay in this context.
2140        Hint::Key(EditWorktree, "ci checks"),
2141        // Sidebar mode / layout.
2142        Hint::Key(ToggleSidebarMode, "mode"),
2143        Hint::Key(CycleSidebarLayout, "layout"),
2144        // Navigate panes.
2145        Hint::Key(FocusWorktrees, "worktrees"),
2146        Hint::Key(Filter, "filter"),
2147        Hint::Key(CommandLogs, "logs"),
2148        Hint::Key(ConfigPanel, "settings"),
2149        // Global.
2150        Hint::Key(Help, "help"),
2151        Hint::Key(Quit, "quit"),
2152      ],
2153      HintContext::Picker => &[
2154        // Pick / dismiss.
2155        Hint::Lit("Enter", "select"),
2156        Hint::Lit("Esc", "cancel"),
2157        // Act on the highlighted worktree.
2158        Hint::Key(TerminalFullscreen, "open"),
2159        Hint::Key(LazyGitFullscreen, "git"),
2160        Hint::Key(YankPath, "yank"),
2161        // Find / global.
2162        Hint::Key(Filter, "filter"),
2163        Hint::Key(Help, "help"),
2164        Hint::Key(Quit, "quit"),
2165      ],
2166      // #219: single-key modal verbs use Hint::Modal so a rebind shows
2167      // through; multi-key movement pairs (↑/↓, j/k, ←/→) stay literal
2168      // because no single resolved key captures them.
2169      HintContext::Create => &[
2170        Hint::Modal(ModalAction::CreateNextField, "field"),
2171        Hint::Lit("↑/↓", "type"),
2172        Hint::Modal(ModalAction::CreateToggleMode, "free-form"),
2173        Hint::Modal(ModalAction::CreateSubmit, "submit"),
2174        Hint::Modal(ModalAction::CreateCancel, "cancel"),
2175      ],
2176      // Free-form has one field and no type selector, so `field` and `type`
2177      // are dropped rather than shown inert — the same reason `draw_create`
2178      // stops rendering those rows. `toggle_mode` leads the row: it is the
2179      // only way back, and unlike the create form's other verbs it is not
2180      // guessable from the visible inputs.
2181      HintContext::CreateFreeform => &[
2182        Hint::Modal(ModalAction::CreateToggleMode, "structured"),
2183        Hint::Modal(ModalAction::CreateSubmit, "submit"),
2184        Hint::Modal(ModalAction::CreateCancel, "cancel"),
2185      ],
2186      HintContext::Confirm => &[
2187        Hint::Modal(ModalAction::ConfirmConfirm, "confirm"),
2188        Hint::Key(ToggleDeleteBranch, "branch"),
2189        Hint::Lit("←/→", "move"),
2190        Hint::Modal(ModalAction::ConfirmActivate, "activate"),
2191        Hint::Modal(ModalAction::ConfirmCancel, "cancel"),
2192      ],
2193      HintContext::OpenMenu => &[
2194        Hint::Modal(ModalAction::OpenMenuIssue, "issue"),
2195        Hint::Modal(ModalAction::OpenMenuPr, "pr"),
2196        Hint::Key(FetchGithub, "fetch"),
2197        Hint::Modal(ModalAction::OpenMenuClose, "close"),
2198      ],
2199      HintContext::LinkPrompt => &[
2200        Hint::Modal(ModalAction::LinkChoosePrev, "prev"),
2201        Hint::Modal(ModalAction::LinkChooseNext, "next"),
2202        Hint::Modal(ModalAction::LinkChooseIssue, "issue"),
2203        Hint::Modal(ModalAction::LinkChoosePr, "pr"),
2204        Hint::Modal(ModalAction::LinkChooseAccept, "link"),
2205        Hint::Key(FetchGithub, "fetch"),
2206        Hint::Modal(ModalAction::LinkChooseCancel, "cancel"),
2207      ],
2208      // #219: while typing the number, submit / cancel come from the
2209      // input-number context — not the choose-target keys above.
2210      HintContext::LinkInputNumber => &[
2211        Hint::Lit("0-9", "number"),
2212        Hint::Modal(ModalAction::LinkInputSubmit, "submit"),
2213        Hint::Key(FetchGithub, "fetch"),
2214        Hint::Modal(ModalAction::LinkInputCancel, "cancel"),
2215      ],
2216      HintContext::CommandPalette => &[
2217        Hint::Lit("↑/↓", "move"),
2218        Hint::Modal(ModalAction::CommandPaletteAccept, "run"),
2219        Hint::Modal(ModalAction::CommandPaletteClose, "cancel"),
2220      ],
2221      // #219: `close` is a single rebindable verb, so it resolves through the
2222      // modal keymap; the scroll/pan pairs stay literal (no single resolved
2223      // key captures `j/k` / `h/l`, matching the Create/Confirm convention).
2224      HintContext::Report => &[Hint::Modal(ModalAction::ReportClose, "close")],
2225      // #408: detail overlay — selection pair stays literal like Help's;
2226      // attach / detach / close resolve through the modal keymap so a
2227      // rebind shows through.
2228      HintContext::Detail => &[
2229        Hint::Lit("j/k", "select"),
2230        Hint::Modal(ModalAction::DetailAttach, "attach"),
2231        Hint::Modal(ModalAction::DetailDetach, "detach"),
2232        Hint::Modal(ModalAction::DetailInput, "by id"),
2233        Hint::Modal(ModalAction::DetailClose, "close"),
2234      ],
2235      HintContext::CiChecks => &[
2236        Hint::Lit("j/k", "select"),
2237        Hint::Modal(ModalAction::CiChecksOpen, "open"),
2238        Hint::Modal(ModalAction::CiChecksFilter, "filter"),
2239        Hint::Modal(ModalAction::CiChecksRefresh, "refresh"),
2240        Hint::Modal(ModalAction::CiChecksClose, "close"),
2241      ],
2242      HintContext::Help => &[
2243        Hint::Lit("j/k", "scroll"),
2244        Hint::Lit("h/l", "pan"),
2245        Hint::Modal(ModalAction::HelpClose, "close"),
2246      ],
2247      HintContext::Pty => &[Hint::Lit("Esc", "close")],
2248      // #325: pick a profile then run it in a PTY. The j/k movement pair
2249      // stays literal (no single resolved key captures it), matching the
2250      // palette / create convention.
2251      HintContext::ExecPicker => &[
2252        Hint::Lit("↑/↓", "pick"),
2253        Hint::Modal(ModalAction::ExecPickerAccept, "run"),
2254        Hint::Modal(ModalAction::ExecPickerCancel, "cancel"),
2255      ],
2256      // #325: the profile picker pair stays literal; confirm / cancel are
2257      // rebindable modal verbs (the safety countdown reuses the delete
2258      // confirm's `y` / Enter convention).
2259      HintContext::Clean => &[
2260        Hint::Lit("↑/↓", "profile"),
2261        Hint::Modal(ModalAction::CleanConfirm, "reclaim"),
2262        Hint::Modal(ModalAction::CleanCancel, "cancel"),
2263      ],
2264      // Rename reuses the create-form input handler, hence the `create`
2265      // context's verbs (#290 / #219).
2266      HintContext::Rename => &[
2267        Hint::Modal(ModalAction::CreateToggleMode, "free-form"),
2268        Hint::Modal(ModalAction::CreateNextField, "field"),
2269        Hint::Lit("↑/↓", "type"),
2270        Hint::Modal(ModalAction::CreateSubmit, "submit"),
2271        Hint::Modal(ModalAction::CreateCancel, "cancel"),
2272      ],
2273      // Free-form has one field and no type selector, so `field` and `type`
2274      // are left out rather than advertised inert (issue #479).
2275      HintContext::RenameFreeform => &[
2276        Hint::Modal(ModalAction::CreateToggleMode, "structured"),
2277        Hint::Modal(ModalAction::CreateSubmit, "submit"),
2278        Hint::Modal(ModalAction::CreateCancel, "cancel"),
2279      ],
2280    }
2281  }
2282
2283  /// Resolve this context's hints to `(key, label)` pairs for the statusbar,
2284  /// reading the live keymap so rebindable verbs show the user's actual
2285  /// binding (issue #217 review) — the same `primary_chord` source the help
2286  /// overlay and the Issue/PR prompt use. An unbound action is dropped from
2287  /// the row rather than advertised with a phantom key.
2288  pub fn resolve(self, keymap: &super::keymap::Keymap, modal: &ModalKeymap) -> Vec<(String, String)> {
2289    self.resolve_with_fields(keymap, modal, &CANONICAL_TRIPLE)
2290  }
2291
2292  /// As [`Self::resolve`], but told which fields the create / rename form
2293  /// actually presents (issue #418), so the structured rows stop advertising
2294  /// verbs that do nothing.
2295  ///
2296  /// Two of them go inert once the field set follows the repo's patterns, and
2297  /// this codebase's rule is to never name a key that does nothing (the reason
2298  /// free-form drops the same two rows since #416):
2299  ///
2300  /// - `↑/↓ type` when no pattern carries `{type}`. The selector is not
2301  ///   rendered and `handle_create_key` gates the cycling verbs on
2302  ///   `Field::Type`, so the arrows are a no-op.
2303  /// - `field` when the pattern presents one field. `next_field` rotates
2304  ///   within a one-element list, so Tab does nothing.
2305  ///
2306  /// `fields` is ignored outside the structured create / rename contexts:
2307  /// free-form drops both rows already, and no other context carries them.
2308  pub fn resolve_with_fields(
2309    self,
2310    keymap: &super::keymap::Keymap,
2311    modal: &ModalKeymap,
2312    fields: &[Field],
2313  ) -> Vec<(String, String)> {
2314    let structured_form = matches!(self, HintContext::Create | HintContext::Rename);
2315    self
2316      .hint_specs()
2317      .iter()
2318      .filter(|h| {
2319        if !structured_form {
2320          return true;
2321        }
2322        match h {
2323          Hint::Lit("↑/↓", "type") => fields.contains(&Field::Type),
2324          Hint::Modal(ModalAction::CreateNextField, _) => fields.len() > 1,
2325          _ => true,
2326        }
2327      })
2328      .filter_map(|h| match h {
2329        // #219: a global verb whose key is claimed by a modal binding in the
2330        // active context is resolved as that modal verb first — the event loop
2331        // never reaches the global action. Drop the hint rather than advertise
2332        // a duplicate key for an unreachable action. Same for a key the
2333        // context's reserved typing consumes (Codex review #456, iteration
2334        // 13): `fetch_github` rebound to a digit never fires while typing the
2335        // link number.
2336        Hint::Key(action, label) => keymap
2337          .primary_chord(*action)
2338          .filter(|k| !self.key_shadowed_by_modal(k, modal) && !self.key_swallowed_by_typing(k))
2339          .map(|k| (k, label.to_string())),
2340        Hint::Modal(action, label) => modal.primary_key(*action).map(|k| (k, label.to_string())),
2341        Hint::Lit(key, label) => Some((key.to_string(), label.to_string())),
2342      })
2343      .collect()
2344  }
2345
2346  /// The modal [`KeyContext`] this hint context renders, when it is a modal /
2347  /// overlay surface (the global panes have none). Used to detect a global
2348  /// hint key shadowed by a modal binding in the same context.
2349  fn modal_context(self) -> Option<KeyContext> {
2350    Some(match self {
2351      HintContext::Create | HintContext::CreateFreeform | HintContext::Rename | HintContext::RenameFreeform => {
2352        KeyContext::Create
2353      }
2354      HintContext::Confirm => KeyContext::Confirm,
2355      HintContext::OpenMenu => KeyContext::OpenMenu,
2356      HintContext::LinkPrompt => KeyContext::LinkChooseTarget,
2357      HintContext::LinkInputNumber => KeyContext::LinkInputNumber,
2358      HintContext::CommandPalette => KeyContext::CommandPalette,
2359      HintContext::Report => KeyContext::Report,
2360      HintContext::Help => KeyContext::Help,
2361      HintContext::Detail => KeyContext::Detail,
2362      HintContext::CiChecks => KeyContext::CiChecks,
2363      HintContext::ExecPicker => KeyContext::ExecPicker,
2364      HintContext::Clean => KeyContext::Clean,
2365      HintContext::Worktrees | HintContext::Status | HintContext::Picker | HintContext::Pty => return None,
2366    })
2367  }
2368
2369  /// `true` when `key` is bound to a modal verb in this context — i.e. the
2370  /// modal keymap intercepts it before any global action with the same key.
2371  fn key_shadowed_by_modal(self, key: &str, modal: &ModalKeymap) -> bool {
2372    match self.modal_context() {
2373      Some(ctx) => modal
2374        .bindings_for(ctx)
2375        .iter()
2376        .any(|b| b.keys.iter().any(|ks| ks.to_string() == key)),
2377      None => false,
2378    }
2379  }
2380
2381  /// `true` when this context's reserved typing consumes `key` before any
2382  /// resolution — a global fallback on it (e.g. `fetch_github` rebound to a
2383  /// digit at the link number stage) can never fire, so its hint is dead.
2384  /// A multi-stroke chord dies with its opening stroke: the typing route
2385  /// eats it before the pending-chord machinery sees it.
2386  fn key_swallowed_by_typing(self, key: &str) -> bool {
2387    match self.modal_context() {
2388      Some(ctx) => KeyStroke::parse_chord(key)
2389        .ok()
2390        .and_then(|strokes| strokes.first().cloned())
2391        .is_some_and(|ks| ctx.reserved_typing_stroke(&ks)),
2392      None => false,
2393    }
2394  }
2395}
2396
2397fn action_chord(keymap: &Keymap, action: Action, fallback: &str) -> String {
2398  keymap.primary_chord(action).unwrap_or_else(|| fallback.to_string())
2399}
2400
2401pub fn issue_pr_pane_title(keymap: &Keymap) -> String {
2402  format!(" Issue / PR [{}] ", action_chord(keymap, Action::FetchGithub, "F"))
2403}
2404
2405pub fn working_tree_pane_title(keymap: &Keymap) -> String {
2406  format!(
2407    " Working Tree [{}] ",
2408    action_chord(keymap, Action::ReviewFullscreen, "R")
2409  )
2410}
2411
2412pub fn recent_items_pane_title(mode: SidebarMode, keymap: &Keymap) -> String {
2413  match mode {
2414    SidebarMode::Commits => format!(
2415      " Recent Commits [{}] ",
2416      action_chord(keymap, Action::LazyGitFullscreen, "l")
2417    ),
2418    SidebarMode::Stashes => format!(" Stashes [{}] ", action_chord(keymap, Action::LazyGitFullscreen, "l")),
2419  }
2420}
2421
2422pub fn modal_hint_line(hints: &[(&str, &str)], theme: &Theme) -> Line<'static> {
2423  let key_style = hint_key_style(theme);
2424  let label_style = hint_label_style(theme);
2425  let mut spans: Vec<Span<'static>> = Vec::new();
2426  for (i, (key, label)) in hints.iter().enumerate() {
2427    if i > 0 {
2428      // Two spaces between hint pairs keep `key action` groups visually
2429      // distinct now that the badge box is gone (issue #279).
2430      spans.push(Span::raw("  "));
2431    }
2432    spans.push(Span::styled((*key).to_string(), key_style));
2433    spans.push(Span::styled(format!(" {}", label), label_style));
2434  }
2435  Line::from(spans).centered()
2436}
2437
2438/// Settings-panel footer hints shown while a field is being edited (#219
2439/// review): `save` / `cancel` resolve from the `ConfigEdit*` modal bindings so
2440/// a rebind of `[tui.keys.modal.config.edit]` shows through instead of the literal
2441/// `Enter` / `Esc`. An unbound verb is dropped rather than advertised with a
2442/// phantom key, mirroring the statusbar's `HintContext::resolve`.
2443pub fn config_edit_footer_hints(modal: &ModalKeymap) -> Vec<(String, String)> {
2444  [
2445    (ModalAction::ConfigEditSubmit, "save"),
2446    (ModalAction::ConfigEditCancel, "cancel"),
2447  ]
2448  .into_iter()
2449  .filter_map(|(action, label)| modal.primary_key(action).map(|k| (k, label.to_string())))
2450  .collect()
2451}
2452
2453/// Settings-panel footer hints in *navigation* mode (#219 review): the
2454/// single-key verbs (`section` / `layer` / `close`, plus `cycle` / `edit` via
2455/// `activate`) resolve from the `Config*` modal bindings so a rebind of
2456/// `[tui.keys.modal.config]` shows through. The `j/k` scroll pair stays literal —
2457/// no single resolved key captures a movement pair (same rule as Help). The
2458/// leading verb depends on the active tab / field kind, mirroring the historic
2459/// hard-coded branches.
2460pub fn config_nav_footer_hints(
2461  modal: &ModalKeymap,
2462  tab: SettingsTab,
2463  selected_kind: Option<FieldKind>,
2464) -> Vec<(String, String)> {
2465  let mut hints: Vec<(String, String)> = Vec::new();
2466  if tab == SettingsTab::All {
2467    hints.push(("j/k".to_string(), "scroll".to_string()));
2468  } else {
2469    let label = if tab == SettingsTab::Keys {
2470      "rebind"
2471    } else if selected_kind == Some(FieldKind::Choice) {
2472      "cycle"
2473    } else {
2474      "edit"
2475    };
2476    if let Some(k) = modal.primary_key(ModalAction::ConfigActivate) {
2477      hints.push((k, label.to_string()));
2478    }
2479  }
2480  for (action, label) in [
2481    (ModalAction::ConfigNextTab, "section"),
2482    (ModalAction::ConfigToggleLayer, "layer"),
2483    (ModalAction::ConfigClose, "close"),
2484  ] {
2485    if let Some(k) = modal.primary_key(action) {
2486      hints.push((k, label.to_string()));
2487    }
2488  }
2489  hints
2490}
2491
2492/// Settings-panel footer hints while a live keystroke capture is armed on the
2493/// Keys tab (issue #294). `cancel` (and, for a multi-stroke global chord,
2494/// `save`) resolve from the `ConfigEdit*` modal bindings so a rebind of
2495/// `[tui.keys.modal.config.edit]` shows through. A single-stroke modal capture
2496/// auto-commits on the first key, so it advertises that instead of a `save`
2497/// verb; the multi-stroke global path adds the literal `Backspace` deletes-last
2498/// affordance (no modal verb binds it).
2499pub fn config_capture_footer_hints(modal: &ModalKeymap, single_only: bool) -> Vec<(String, String)> {
2500  let mut hints: Vec<(String, String)> = Vec::new();
2501  if single_only {
2502    hints.push(("any key".to_string(), "bind".to_string()));
2503  } else {
2504    if let Some(k) = modal.primary_key(ModalAction::ConfigEditSubmit) {
2505      hints.push((k, "save".to_string()));
2506    }
2507    hints.push(("Backspace".to_string(), "delete".to_string()));
2508  }
2509  if let Some(k) = modal.primary_key(ModalAction::ConfigEditCancel) {
2510    hints.push((k, "cancel".to_string()));
2511  }
2512  hints
2513}
2514
2515/// Command Logs overlay footer hints (#219 review): `copy` / `close` resolve
2516/// from the `CommandLogs*` modal bindings so a rebind of
2517/// `[tui.keys.modal.command_logs]` shows through; the scroll / top-bottom movement
2518/// pairs stay literal (no single resolved key captures `j/k` / `g/G`).
2519pub fn command_logs_footer_hints(modal: &ModalKeymap) -> Vec<(String, String)> {
2520  let mut hints: Vec<(String, String)> = vec![
2521    ("j/k".to_string(), "scroll".to_string()),
2522    ("g/G".to_string(), "top/bottom".to_string()),
2523  ];
2524  for (action, label) in [
2525    (ModalAction::CommandLogsCopy, "copy"),
2526    (ModalAction::CommandLogsClose, "close"),
2527  ] {
2528    if let Some(k) = modal.primary_key(action) {
2529      hints.push((k, label.to_string()));
2530    }
2531  }
2532  hints
2533}
2534
2535pub fn modal_hint_for_context(ctx: HintContext, keymap: &Keymap, modal: &ModalKeymap, theme: &Theme) -> Line<'static> {
2536  modal_hint_for_context_with_fields(ctx, keymap, modal, theme, &CANONICAL_TRIPLE)
2537}
2538
2539/// As [`modal_hint_for_context`], for the two footers whose form knows which
2540/// fields it presents (issue #418). Every other modal keeps the plain call.
2541pub fn modal_hint_for_context_with_fields(
2542  ctx: HintContext,
2543  keymap: &Keymap,
2544  modal: &ModalKeymap,
2545  theme: &Theme,
2546  fields: &[Field],
2547) -> Line<'static> {
2548  let resolved = ctx.resolve_with_fields(keymap, modal, fields);
2549  let hints: Vec<(&str, &str)> = resolved.iter().map(|(k, l)| (k.as_str(), l.as_str())).collect();
2550  modal_hint_line(&hints, theme)
2551}
2552
2553fn push_modal_hint(
2554  lines: &mut Vec<Line<'static>>,
2555  ctx: HintContext,
2556  keymap: &Keymap,
2557  modal: &ModalKeymap,
2558  theme: &Theme,
2559) {
2560  lines.push(Line::from(String::new()));
2561  lines.push(modal_hint_for_context(ctx, keymap, modal, theme));
2562}
2563
2564/// Build the single-line statusline (issue #180).
2565///
2566/// Layout, left-to-right:
2567///
2568/// ```text
2569///  n  new  d  del  …                                   [<status>]
2570/// ```
2571///
2572/// Each hint renders as a reverse-video badge chip (` key ` painted with the
2573/// theme `accent` as background via `REVERSED`) followed by a dim label. The
2574/// status message (the action log) is pinned flush-right and has **absolute
2575/// priority**: when `width` is too small for every hint, the hint list is cut
2576/// short with an `…` marker, but the status is always kept — clipped only if
2577/// it alone exceeds `width`. There is no wrapping: the caller renders this
2578/// without `Wrap`, so the row is hard-clipped at the terminal edge.
2579///
2580/// Pure and width-driven so the contract is pinned by
2581/// `tests/tui_footer_tests.rs` without spinning up a ratatui backend. Widths
2582/// are measured with `chars().count()` to match the rest of `ui.rs` (keys,
2583/// labels and the bracketed status are ASCII / single-width in practice).
2584pub fn footer_line(hints: &[(&str, &str)], status: &str, width: usize, theme: &Theme) -> Line<'static> {
2585  let key_style = hint_key_style(theme);
2586  let label_style = hint_label_style(theme);
2587  let status_style = Style::default().fg(theme.dirty);
2588
2589  // A zero-width row can hold nothing — return an empty line rather than let
2590  // the `trunc()` floor below emit a 1-column `…`.
2591  if width == 0 {
2592    return Line::default();
2593  }
2594
2595  // Action logs are sometimes error strings carrying embedded newlines /
2596  // tabs. `Wrap` is disabled, but a raw `\n` would still split the row in
2597  // two, so collapse every control char to a single space first — the footer
2598  // must stay one visual line.
2599  let status: String = status.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
2600  let status_text = format!("[{}]", status);
2601  let status_w = status_text.chars().count();
2602
2603  // Priority floor: if even the status cannot fit, show a clipped status
2604  // alone — never a hint at the log's expense.
2605  if width <= status_w {
2606    return Line::from(Span::styled(trunc(&status_text, width), status_style));
2607  }
2608
2609  // Budget for the hint badges: the width left after the right-pinned status,
2610  // minus one column reserved for the `…` truncation marker. The gap between
2611  // the hints and the status is best-effort — it shows up as left-over
2612  // padding only when at least one badge fits; in the tight band just above
2613  // `status_w` there may be room for neither a badge nor a gap.
2614  let hint_budget = (width - status_w - 1).saturating_sub(1);
2615
2616  let mut spans: Vec<Span<'static>> = Vec::new();
2617  let mut used = 0usize; // display columns consumed by hint groups so far
2618  let mut truncated = false;
2619  for (i, (key, label)) in hints.iter().enumerate() {
2620    let sep = if i > 0 { 2 } else { 0 }; // two spaces between hint groups (#279)
2621                                         // flat bind `key` + ` label` (label + 1 leading space)
2622    let badge_w = key.chars().count() + 1 + label.chars().count();
2623    if used + sep + badge_w > hint_budget {
2624      truncated = true;
2625      break;
2626    }
2627    if sep > 0 {
2628      spans.push(Span::raw(" ".repeat(sep)));
2629      used += sep;
2630    }
2631    spans.push(Span::styled((*key).to_string(), key_style));
2632    spans.push(Span::styled(format!(" {}", label), label_style));
2633    used += badge_w;
2634  }
2635
2636  if truncated {
2637    if used > 0 {
2638      spans.push(Span::raw(" "));
2639      used += 1;
2640    }
2641    spans.push(Span::styled("…", label_style));
2642    used += 1;
2643  }
2644
2645  // Pad so the status sits flush right (priority: the log is at the end).
2646  let pad = width.saturating_sub(used + status_w);
2647  if pad > 0 {
2648    spans.push(Span::raw(" ".repeat(pad)));
2649  }
2650  spans.push(Span::styled(status_text, status_style));
2651  Line::from(spans)
2652}
2653
2654/// Contextual statusbar (issue #217) — a superset of [`footer_line`] that
2655/// leads with a context chip and an optional loading spinner. Layout,
2656/// left-to-right:
2657///
2658/// ```text
2659///  worktrees  ⠋  n  new  d  del  …                       [<status>]
2660/// ```
2661///
2662/// Priority when space is tight, from most to least protected: the status
2663/// log (right, clipped only if it alone overflows), the context chip and
2664/// spinner (left, the load-bearing "where am I / am I busy" signals), then
2665/// the hints (truncated with `…`). Pure + width-driven so the contract is
2666/// pinned by `tests/tui_footer_tests.rs`; `footer_line` is kept intact for
2667/// its own callers and tests.
2668pub fn status_line(
2669  context: &str,
2670  hints: &[(&str, &str)],
2671  status: &str,
2672  spinner: Option<&str>,
2673  width: usize,
2674  theme: &Theme,
2675) -> Line<'static> {
2676  let context_style = chip_style(theme.focus);
2677  let key_style = hint_key_style(theme);
2678  let label_style = hint_label_style(theme);
2679  let status_style = Style::default().fg(theme.dirty);
2680  let spinner_style = Style::default().fg(theme.accent).add_modifier(Modifier::BOLD);
2681
2682  if width == 0 {
2683    return Line::default();
2684  }
2685
2686  let status: String = status.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
2687  let status_text = format!("[{}]", status);
2688  let status_w = status_text.chars().count();
2689
2690  // Priority floor: if even the status cannot fit, show a clipped status
2691  // alone — never a chip or hint at the log's expense.
2692  if width <= status_w {
2693    return Line::from(Span::styled(trunc(&status_text, width), status_style));
2694  }
2695
2696  let avail = width - status_w; // columns to the left of the right-pinned status
2697  let mut spans: Vec<Span<'static>> = Vec::new();
2698  let mut used = 0usize;
2699
2700  // Context chip — load-bearing, kept whenever it fits at all.
2701  let ctx_chip = format!(" {} ", context);
2702  let ctx_w = ctx_chip.chars().count();
2703  if ctx_w <= avail {
2704    spans.push(Span::styled(ctx_chip, context_style));
2705    used += ctx_w;
2706  }
2707
2708  // Loading spinner — optional, rendered right after the chip when present
2709  // and there is room.
2710  if let Some(glyph) = spinner {
2711    let padded = format!(" {} ", glyph);
2712    let gw = padded.chars().count();
2713    if used + gw <= avail {
2714      spans.push(Span::styled(padded, spinner_style));
2715      used += gw;
2716    }
2717  }
2718
2719  // Hint badges fill whatever is left, minus one column for the `…` marker.
2720  let hint_budget = avail.saturating_sub(used).saturating_sub(1);
2721  let mut truncated = false;
2722  let mut hint_used = 0usize;
2723  for (i, (key, label)) in hints.iter().enumerate() {
2724    // Two spaces between hint groups (#279); a single space after the left
2725    // cluster (context chip / spinner) before the first hint.
2726    let sep = if i > 0 { 2 } else { usize::from(used > 0) };
2727    let badge_w = key.chars().count() + 1 + label.chars().count();
2728    if hint_used + sep + badge_w > hint_budget {
2729      truncated = true;
2730      break;
2731    }
2732    if sep > 0 {
2733      spans.push(Span::raw(" ".repeat(sep)));
2734      hint_used += sep;
2735    }
2736    spans.push(Span::styled((*key).to_string(), key_style));
2737    spans.push(Span::styled(format!(" {}", label), label_style));
2738    hint_used += badge_w;
2739  }
2740  used += hint_used;
2741  if truncated {
2742    if used > 0 {
2743      spans.push(Span::raw(" "));
2744      used += 1;
2745    }
2746    spans.push(Span::styled("…", label_style));
2747    used += 1;
2748  }
2749
2750  let pad = width.saturating_sub(used + status_w);
2751  if pad > 0 {
2752    spans.push(Span::raw(" ".repeat(pad)));
2753  }
2754  spans.push(Span::styled(status_text, status_style));
2755  Line::from(spans)
2756}
2757
2758fn draw_footer(f: &mut Frame, area: Rect, app: &App) {
2759  let ctx = app.hint_context();
2760  // Spinner shows while any async op is inflight: a GitHub fetch (issue
2761  // #217) or a generic background task such as the off-thread worktree
2762  // refresh (issue #231). Both render through the same statusbar spinner +
2763  // per-op label (carried on `app.status`) so "loading" reads consistently
2764  // across every async site. The frame advances at the poll cadence.
2765  let spinner = if app.is_github_loading() || app.is_task_loading() {
2766    Some(app.spinner.glyph(DOT_FRAMES))
2767  } else {
2768    None
2769  };
2770  // Resolve the rebindable hint keys against the live keymap (issue #217
2771  // review) so a user override shows through, then borrow into the slice
2772  // `status_line` expects.
2773  // Same field set the two footers use (#418), so the bar behind a modal and
2774  // the modal's own footer cannot advertise different verbs.
2775  let resolved = ctx.resolve_with_fields(&app.keymap, &app.modal_keymap, app.create_form.fields());
2776  let hints: Vec<(&str, &str)> = resolved.iter().map(|(k, l)| (k.as_str(), l.as_str())).collect();
2777  let line = status_line(
2778    ctx.label(),
2779    &hints,
2780    &app.status,
2781    spinner,
2782    area.width as usize,
2783    &app.theme,
2784  );
2785  // No `Wrap`: the statusbar is a single hard-clipped row (issue #180).
2786  f.render_widget(Paragraph::new(line), area);
2787}
2788
2789/// A single logical row of the help overlay (#187).
2790///
2791/// Decouples *what* the overlay documents from *how* it is painted.
2792/// [`help_rows`] produces this structured form so [`draw_help`] can
2793/// render coloured section headers and key *badges* (the same chip
2794/// style as the bottom statusline), while [`help_lines`] flattens it
2795/// back to the legacy `  {keys:<13} {label}` strings the chord tests
2796/// in `tests/tui_chord_tests.rs` pin.
2797#[derive(Debug, Clone, PartialEq, Eq)]
2798pub enum HelpRow {
2799  /// Overlay title — always the first row.
2800  Title(String),
2801  /// Context subtitle under the title (`worktrees` / `status` / `switch`),
2802  /// issue #217. Reflects the focused pane / mode when `?` was opened.
2803  Subtitle(String),
2804  /// Section header (`global`, `list view`, `issue / PR (#67)`, …).
2805  Section(String),
2806  /// Blank spacer row.
2807  Blank,
2808  /// A documented binding: the resolved chord(s) and the human label.
2809  /// `keys` is empty only for an unbound action; the flattening in
2810  /// [`help_lines`] renders that as `(unbound)`.
2811  Entry { keys: String, label: String },
2812}
2813
2814/// Structured builder for the help overlay (issue #87 logic,
2815/// restructured into rows in #187).
2816///
2817/// Reads every list-view binding from the resolved `Keymap` so user
2818/// overrides under `[tui.keys]` show through verbatim — a user who
2819/// rebinds `down = ["Ctrl+n"]` sees `Ctrl+n` next to "next" instead
2820/// of the historical `j / ↓`. Rows that document non-rebindable
2821/// surfaces (Ctrl-C escape hatch, contextual Esc / Enter, create-
2822/// form keys, confirm-delete keys) carry a fixed key string.
2823///
2824/// Exposed as `pub` (and re-exported through `tui::help_rows`) so the
2825/// renderer and the state-machine tests share one source of truth.
2826///
2827/// `ctx` (issue #217) drives the title's context subtitle and whether the
2828/// picker-only / non-picker sections render. `HintContext::Picker` is the
2829/// `gwm switch` overlay; `Worktrees` / `Status` are the two list-view panes
2830/// (same body, the subtitle just names the focused pane).
2831pub fn help_rows(km: &super::keymap::Keymap, modal: &ModalKeymap, ctx: HintContext) -> Vec<HelpRow> {
2832  use super::keymap::Action;
2833
2834  let picker_mode = matches!(ctx, HintContext::Picker);
2835
2836  // Snapshot the keymap once. The pre-#87-review version called
2837  // `km.list()` inside `keys_for`, which cloned the entire bindings
2838  // vector for every help row — measurable churn for the ~20 rows
2839  // the overlay renders. Single clone here, indexed by action below.
2840  let bindings = km.list();
2841
2842  // Format every chord bound to `action` as a comma-separated list
2843  // (`"j, Down"` or `"g g"` or `""` for unbound). The width 13 is
2844  // wide enough for `Ctrl+Shift+Tab` while keeping the help overlay
2845  // narrow enough for an 80-column terminal.
2846  let keys_for = |action: Action| -> String {
2847    bindings
2848      .iter()
2849      .find(|b| b.action == action)
2850      .map(|b| {
2851        b.chords
2852          .iter()
2853          .map(|c| c.iter().map(|k| k.to_string()).collect::<Vec<_>>().join(" "))
2854          .collect::<Vec<_>>()
2855          .join(", ")
2856      })
2857      .unwrap_or_default()
2858  };
2859  // A rebindable entry: keys resolved from the keymap.
2860  let entry = |action: Action, label: &str| -> HelpRow {
2861    HelpRow::Entry {
2862      keys: keys_for(action),
2863      label: label.to_string(),
2864    }
2865  };
2866  // A fixed entry: a non-rebindable surface documented with a literal
2867  // key string (Ctrl-C, contextual Enter, the picker's hard-coded Enter).
2868  let fixed = |keys: &str, label: &str| -> HelpRow {
2869    HelpRow::Entry {
2870      keys: keys.to_string(),
2871      label: label.to_string(),
2872    }
2873  };
2874  // A rebindable modal entry: keys resolved from the contextual keymap
2875  // (issue #219) so the create-form / delete-confirm rows track
2876  // `[tui.keys.modal.<context>]` overrides instead of a frozen literal.
2877  // No display-side filtering is needed for the always-typing contexts:
2878  // a binding their reserved input would swallow is refused at config
2879  // time (`ModalKeymap::apply_override`, Codex review #456), so every
2880  // advertised chord is reachable by construction.
2881  let modal_entry = |action: ModalAction, label: &str| -> HelpRow {
2882    HelpRow::Entry {
2883      keys: modal.keys_display(action),
2884      label: label.to_string(),
2885    }
2886  };
2887
2888  let mut rows: Vec<HelpRow> = vec![
2889    HelpRow::Title("Keybindings".to_string()),
2890    HelpRow::Subtitle(ctx.label().to_string()),
2891    HelpRow::Blank,
2892    HelpRow::Section("Global".to_string()),
2893    HelpRow::Blank,
2894    entry(Action::Quit, "quit (Esc also quits when filter is clear)"),
2895    fixed("Ctrl-C", "quit (hard-coded escape hatch)"),
2896    HelpRow::Blank,
2897    HelpRow::Section("List View".to_string()),
2898    HelpRow::Blank,
2899    entry(Action::Down, "next (scrolls sidebar when focused)"),
2900    entry(Action::Up, "prev (scrolls sidebar when focused)"),
2901    entry(Action::WtScrollDown, "scroll the Working Tree pane down (status focus)"),
2902    entry(Action::WtScrollUp, "scroll the Working Tree pane up (status focus)"),
2903    entry(Action::Top, "jump to first worktree"),
2904    entry(Action::Bottom, "jump to last worktree"),
2905  ];
2906  if picker_mode {
2907    rows.push(fixed("enter", "select highlighted worktree (prints path on exit)"));
2908  } else {
2909    rows.push(entry(Action::Create, "new worktree"));
2910    rows.push(entry(Action::DeleteConfirm, "delete selected"));
2911    rows.push(entry(Action::Bootstrap, "bootstrap selected"));
2912  }
2913  rows.push(entry(
2914    Action::TerminalFullscreen,
2915    "open per [tui.open] — shell / editor / finder",
2916  ));
2917  rows.push(entry(Action::TerminalPty, "open native $SHELL in embedded PTY overlay"));
2918  rows.push(entry(Action::OpenDocs, "open the gwm documentation in the browser"));
2919  rows.push(entry(Action::YankPath, "yank selected worktree path to clipboard"));
2920  rows.push(entry(Action::YankBranchName, "yank selected branch name to clipboard"));
2921  rows.push(entry(
2922    Action::YankWorktreeName,
2923    "yank selected worktree name to clipboard",
2924  ));
2925  rows.push(entry(Action::LazyGitFullscreen, "launch lazygit fullscreen"));
2926  rows.push(entry(Action::LazyGitPty, "open lazygit in embedded PTY overlay"));
2927  rows.push(entry(Action::ToggleSidebar, "toggle git preview sidebar"));
2928  rows.push(entry(
2929    Action::ToggleSidebarMode,
2930    "cycle sidebar mode (commits / stashes)",
2931  ));
2932  rows.push(entry(
2933    Action::CycleSidebarLayout,
2934    "cycle sidebar layout (auto / side-by-side / stacked)",
2935  ));
2936  rows.push(entry(
2937    Action::ToggleSidebarPosition,
2938    "toggle sidebar position (left / right)",
2939  ));
2940  rows.push(entry(Action::FocusSwap, "swap focus between worktree list and sidebar"));
2941  rows.push(entry(Action::FocusWorktrees, "focus the worktrees pane"));
2942  rows.push(entry(Action::FocusStatus, "focus the status pane (opens it if hidden)"));
2943  rows.push(entry(Action::CommandLogs, "show the command logs overlay"));
2944  rows.push(entry(Action::ConfigPanel, "show the resolved configuration panel"));
2945  // #334 review: the exec / clean overlays are picker-gated (`run_action`
2946  // no-ops them in `gwm switch`), so only advertise them outside picker mode.
2947  if !picker_mode {
2948    rows.push(entry(
2949      Action::ExecOverlay,
2950      "pick an [exec.profiles] profile and run it in a PTY",
2951    ));
2952    rows.push(entry(
2953      Action::CleanOverlay,
2954      "preview and reclaim build artifacts (with confirm)",
2955    ));
2956    rows.push(entry(
2957      Action::AgentSessions,
2958      "show the agent sessions attached to this worktree",
2959    ));
2960    rows.push(entry(
2961      Action::CiChecks,
2962      "list the linked PR's CI checks (also `c` with status focus)",
2963    ));
2964  }
2965  rows.push(entry(
2966    Action::Filter,
2967    "open fuzzy filter bar (enter: sticky, esc: clear)",
2968  ));
2969  rows.push(entry(Action::Refresh, "refresh worktree list"));
2970  if !picker_mode {
2971    rows.push(entry(Action::Sync, "sync selected worktree onto its upstream (rebase)"));
2972    rows.push(entry(Action::Pull, "pull selected worktree's branch from upstream"));
2973    rows.push(entry(Action::Push, "push selected worktree's branch to remote"));
2974    rows.push(entry(Action::EditWorktree, "rename the selected worktree's branch"));
2975    rows.push(entry(
2976      Action::ExitToWorktree,
2977      "quit TUI and print selected path to stdout",
2978    ));
2979    rows.push(entry(Action::MuxPane, "open selected worktree in new mux pane/tab"));
2980    rows.push(entry(Action::Macro1, "run [tui.macro1] command"));
2981    rows.push(entry(Action::Macro2, "run [tui.macro2] command"));
2982    rows.push(entry(Action::FetchGithub, "refresh GitHub issue/PR status via `gh`"));
2983    rows.push(entry(Action::ReviewFullscreen, "run [review] launcher fullscreen"));
2984    rows.push(entry(
2985      Action::ReviewPty,
2986      "run [review] launcher in embedded PTY overlay",
2987    ));
2988    rows.push(entry(Action::ToggleDeleteBranch, "toggle 'delete branch on remove'"));
2989    rows.push(fixed("enter", "show path in status bar"));
2990    rows.push(HelpRow::Blank);
2991    rows.push(HelpRow::Section("Issue / PR".to_string()));
2992    rows.push(HelpRow::Blank);
2993    // #219 review: the direct-pick keys named in these descriptions are the
2994    // OpenMenu / LinkChooseTarget modal verbs — resolve them so a rebind shows
2995    // through, and DROP any verb the user explicitly unbound rather than
2996    // advertise a phantom literal (matching every other modal hint).
2997    let open_picks: Vec<String> = [
2998      (ModalAction::OpenMenuIssue, "issue"),
2999      (ModalAction::OpenMenuPr, "pull request"),
3000    ]
3001    .into_iter()
3002    .filter_map(|(a, l)| modal.primary_key(a).map(|k| format!("{k}={l}")))
3003    .collect();
3004    let open_desc = if open_picks.is_empty() {
3005      "open menu".to_string()
3006    } else {
3007      format!("open menu — {}", open_picks.join(" · "))
3008    };
3009    rows.push(entry(Action::BrowseLinks, &open_desc));
3010
3011    let key = |a: ModalAction| modal.primary_key(a);
3012    let nav: Vec<String> = [ModalAction::LinkChooseNext, ModalAction::LinkChoosePrev]
3013      .into_iter()
3014      .filter_map(key)
3015      .collect();
3016    let picks: Vec<String> = [ModalAction::LinkChooseIssue, ModalAction::LinkChoosePr]
3017      .into_iter()
3018      .filter_map(key)
3019      .collect();
3020    let mut parts: Vec<String> = Vec::new();
3021    match (nav.is_empty(), key(ModalAction::LinkChooseAccept)) {
3022      (false, Some(a)) => parts.push(format!("{} + {a}", nav.join("/"))),
3023      (false, None) => parts.push(nav.join("/")),
3024      (true, Some(a)) => parts.push(a),
3025      (true, None) => {}
3026    }
3027    if !picks.is_empty() {
3028      parts.push(format!("or {}", picks.join("/")));
3029    }
3030    parts.push("then digits".to_string());
3031    rows.push(entry(
3032      Action::LinkPrompt,
3033      &format!("link prompt — {}", parts.join(", ")),
3034    ));
3035  }
3036  rows.push(entry(Action::Help, "this help"));
3037  if !picker_mode {
3038    rows.push(entry(Action::CommandPalette, "open the command palette"));
3039  }
3040  if !picker_mode {
3041    rows.extend([
3042      HelpRow::Blank,
3043      HelpRow::Section("Create Form".to_string()),
3044      HelpRow::Blank,
3045      modal_entry(ModalAction::CreatePrevType, "previous branch type"),
3046      modal_entry(ModalAction::CreateNextType, "next branch type"),
3047      modal_entry(ModalAction::CreateNextField, "next field"),
3048      modal_entry(ModalAction::CreatePrevField, "previous field"),
3049      modal_entry(ModalAction::CreateSubmit, "submit (on the last field) / next field"),
3050      modal_entry(
3051        ModalAction::CreateToggleMode,
3052        "toggle structured fields ↔ free-form name",
3053      ),
3054      modal_entry(ModalAction::CreateCancel, "cancel"),
3055      fixed(
3056        "0-9",
3057        "type into the issue field, where the patterns ask for one (digits only)",
3058      ),
3059      fixed("any char", "type into the focused text field"),
3060      fixed("Backspace", "delete the last character"),
3061      HelpRow::Blank,
3062      HelpRow::Section("Delete Worktree".to_string()),
3063      HelpRow::Blank,
3064      modal_entry(ModalAction::ConfirmFocusConfirm, "focus the Confirm button"),
3065      modal_entry(ModalAction::ConfirmFocusCancel, "focus the Cancel button"),
3066      modal_entry(ModalAction::ConfirmToggleFocus, "toggle the focused button"),
3067      modal_entry(
3068        ModalAction::ConfirmActivate,
3069        "activate the focused button (defaults to Cancel)",
3070      ),
3071      modal_entry(ModalAction::ConfirmConfirm, "confirm"),
3072      modal_entry(ModalAction::ConfirmCancel, "cancel"),
3073    ]);
3074    // #453: one section per modal context, in workflow order, every verb
3075    // resolved live against the modal keymap so rebinds show through (and
3076    // an explicitly unbound verb renders `(unbound)` like every other
3077    // entry). Completeness pinned per section by
3078    // `help_overlay_documents_every_modal_action_in_its_section`. Only the
3079    // sections whose actions `run_action` picker-gates live in this block
3080    // (Codex review #456): the palette, the Command Logs / Settings
3081    // overlays and the PTY stay reachable from `gwm switch` and render
3082    // below for every context.
3083    rows.extend([
3084      HelpRow::Blank,
3085      HelpRow::Section("Browse Links".to_string()),
3086      HelpRow::Blank,
3087      modal_entry(ModalAction::OpenMenuToggle, "toggle issue / pull request"),
3088      modal_entry(
3089        ModalAction::OpenMenuAccept,
3090        "open the highlighted target in the browser",
3091      ),
3092      modal_entry(ModalAction::OpenMenuIssue, "open the linked issue directly"),
3093      modal_entry(ModalAction::OpenMenuPr, "open the linked pull request directly"),
3094      modal_entry(ModalAction::OpenMenuClose, "close"),
3095      HelpRow::Blank,
3096      HelpRow::Section("Link Prompt".to_string()),
3097      HelpRow::Blank,
3098      modal_entry(ModalAction::LinkChooseNext, "next target (issue / PR)"),
3099      modal_entry(ModalAction::LinkChoosePrev, "previous target"),
3100      modal_entry(ModalAction::LinkChooseIssue, "pick issue directly"),
3101      modal_entry(ModalAction::LinkChoosePr, "pick pull request directly"),
3102      modal_entry(ModalAction::LinkChooseAccept, "accept the highlighted target"),
3103      modal_entry(ModalAction::LinkChooseCancel, "cancel"),
3104      fixed("0-9", "type the issue / PR number"),
3105      fixed("Backspace", "erase the last digit"),
3106      modal_entry(ModalAction::LinkInputSubmit, "submit the typed number"),
3107      modal_entry(ModalAction::LinkInputCancel, "cancel the number input"),
3108      HelpRow::Blank,
3109      HelpRow::Section("Exec Profiles".to_string()),
3110      HelpRow::Blank,
3111      modal_entry(ModalAction::ExecPickerNext, "next profile"),
3112      modal_entry(ModalAction::ExecPickerPrev, "previous profile"),
3113      modal_entry(ModalAction::ExecPickerAccept, "run the profile in a PTY overlay"),
3114      modal_entry(ModalAction::ExecPickerCancel, "cancel"),
3115      HelpRow::Blank,
3116      HelpRow::Section("Clean Reclaim".to_string()),
3117      HelpRow::Blank,
3118      modal_entry(ModalAction::CleanNext, "next profile"),
3119      modal_entry(ModalAction::CleanPrev, "previous profile"),
3120      modal_entry(ModalAction::CleanConfirm, "reclaim (starts the safety countdown)"),
3121      modal_entry(ModalAction::CleanCancel, "cancel"),
3122      HelpRow::Blank,
3123      HelpRow::Section("Agent Sessions".to_string()),
3124      HelpRow::Blank,
3125      modal_entry(ModalAction::DetailSelectNext, "next session"),
3126      modal_entry(ModalAction::DetailSelectPrev, "previous session"),
3127      modal_entry(ModalAction::DetailAttach, "attach to the selected session"),
3128      modal_entry(ModalAction::DetailDetach, "detach the selected session"),
3129      modal_entry(ModalAction::DetailInput, "attach by id (palette-style prompt)"),
3130      fixed("any char", "attach prompt: type to filter the session ids"),
3131      fixed("Backspace", "attach prompt: delete the last character"),
3132      fixed("Up/Down", "attach prompt: move the highlight"),
3133      fixed("enter", "attach prompt: attach the highlighted session"),
3134      fixed("Esc", "attach prompt: back to the list"),
3135      modal_entry(ModalAction::DetailClose, "close"),
3136      HelpRow::Blank,
3137      HelpRow::Section("CI Checks".to_string()),
3138      HelpRow::Blank,
3139      modal_entry(ModalAction::CiChecksNext, "next check"),
3140      modal_entry(ModalAction::CiChecksPrev, "previous check"),
3141      modal_entry(ModalAction::CiChecksOpen, "open the check's details URL in the browser"),
3142      modal_entry(ModalAction::CiChecksFilter, "filter the checks by name"),
3143      modal_entry(ModalAction::CiChecksRefresh, "re-fetch the PR and refresh the rows"),
3144      fixed("any char", "filter: type to narrow the checks"),
3145      fixed("Backspace", "filter: delete the last character"),
3146      fixed("Up/Down", "filter: move the highlight"),
3147      fixed("enter", "filter: open the highlighted check's URL"),
3148      fixed("Esc", "filter: back to the list"),
3149      modal_entry(ModalAction::CiChecksClose, "close"),
3150      HelpRow::Blank,
3151      HelpRow::Section("Bootstrap Report".to_string()),
3152      HelpRow::Blank,
3153      modal_entry(ModalAction::ReportClose, "close"),
3154    ]);
3155  }
3156  // In a real `gwm switch` the filter bar is ALWAYS active — its only
3157  // exits confirm (Enter) or cancel (Esc) the pick — so no overlay is
3158  // reachable there, whatever `run_action` would allow: every printable
3159  // key (`?`, `:`, `3`, `4`, `o`) types into the filter instead (Codex
3160  // review #456, iteration 8). The modal sections all stay non-picker.
3161  if !picker_mode {
3162    rows.extend([
3163      HelpRow::Blank,
3164      HelpRow::Section("Command Palette".to_string()),
3165      HelpRow::Blank,
3166      modal_entry(ModalAction::CommandPaletteNext, "next command"),
3167      modal_entry(ModalAction::CommandPalettePrev, "previous command"),
3168      modal_entry(ModalAction::CommandPaletteAccept, "run the highlighted command"),
3169      fixed("a-z 0-9 _ -", "fuzzy-filter the commands (lowercase input only)"),
3170      fixed("Backspace", "delete the last filter character"),
3171      modal_entry(ModalAction::CommandPaletteClose, "close"),
3172      HelpRow::Blank,
3173      HelpRow::Section("Command Logs".to_string()),
3174      HelpRow::Blank,
3175      modal_entry(ModalAction::CommandLogsScrollDown, "scroll down"),
3176      modal_entry(ModalAction::CommandLogsScrollUp, "scroll up"),
3177      modal_entry(ModalAction::CommandLogsScrollLeft, "pan left"),
3178      modal_entry(ModalAction::CommandLogsScrollRight, "pan right"),
3179      modal_entry(ModalAction::CommandLogsScrollTop, "jump to the top"),
3180      modal_entry(ModalAction::CommandLogsScrollBottom, "jump to the bottom"),
3181      modal_entry(
3182        ModalAction::CommandLogsCopy,
3183        "copy the full transcript to the clipboard",
3184      ),
3185      modal_entry(ModalAction::CommandLogsClose, "close"),
3186      HelpRow::Blank,
3187      HelpRow::Section("Settings".to_string()),
3188      HelpRow::Blank,
3189      modal_entry(ModalAction::ConfigNextTab, "next tab"),
3190      modal_entry(ModalAction::ConfigPrevTab, "previous tab"),
3191      modal_entry(ModalAction::ConfigToggleLayer, "toggle the Project / Global layer"),
3192      modal_entry(ModalAction::ConfigSelectNext, "next setting (All tab: scroll down)"),
3193      modal_entry(ModalAction::ConfigSelectPrev, "previous setting (All tab: scroll up)"),
3194      modal_entry(
3195        ModalAction::ConfigActivate,
3196        "toggle / edit the selected setting (Keys tab: start a key capture — a modal verb commits on its first stroke)",
3197      ),
3198      modal_entry(ModalAction::ConfigScrollLeft, "pan left (All tab)"),
3199      modal_entry(ModalAction::ConfigScrollRight, "pan right (All tab)"),
3200      modal_entry(ModalAction::ConfigScrollTop, "jump to the top (All tab)"),
3201      modal_entry(ModalAction::ConfigScrollBottom, "jump to the bottom (All tab)"),
3202      modal_entry(
3203        ModalAction::ConfigEditSubmit,
3204        "commit the edited value / the captured global chord",
3205      ),
3206      modal_entry(ModalAction::ConfigEditCancel, "cancel the edit / the key capture"),
3207      fixed(
3208        "any char",
3209        "type the value — free text for text fields, digits for numeric ones",
3210      ),
3211      fixed(
3212        "Backspace",
3213        "erase the last character / drop the last stroke of a global capture",
3214      ),
3215      fixed("enter", "capture: commit the global chord (reserved, despite rebinds)"),
3216      fixed("Esc", "capture: cancel (reserved, despite rebinds)"),
3217      modal_entry(ModalAction::ConfigClose, "close"),
3218      HelpRow::Blank,
3219      HelpRow::Section("PTY Overlay".to_string()),
3220      HelpRow::Blank,
3221      fixed(
3222        "Esc",
3223        "close the overlay — other keys pass through (any key but Ctrl-C closes a finished exec run)",
3224      ),
3225    ]);
3226    rows.extend([
3227      HelpRow::Blank,
3228      HelpRow::Section("Help Overlay".to_string()),
3229      HelpRow::Blank,
3230      modal_entry(ModalAction::HelpScrollDown, "scroll down"),
3231      modal_entry(ModalAction::HelpScrollUp, "scroll up"),
3232      modal_entry(ModalAction::HelpScrollLeft, "pan left"),
3233      modal_entry(ModalAction::HelpScrollRight, "pan right"),
3234      modal_entry(ModalAction::HelpScrollTop, "jump to the top"),
3235      modal_entry(ModalAction::HelpScrollBottom, "jump to the bottom"),
3236      modal_entry(ModalAction::HelpClose, "close"),
3237    ]);
3238  }
3239  rows
3240}
3241
3242/// Flatten [`help_rows`] back into the legacy `Vec<String>` overlay body
3243/// (issue #87). Kept as the stable, terminal-free contract that
3244/// `tests/tui_chord_tests.rs` asserts against: every entry renders as
3245/// `  {keys:<13} {label}`, sections / title as their bare text, blanks
3246/// as empty strings. The width 13 is wide enough for `Ctrl+Shift+Tab`.
3247pub fn help_lines(km: &super::keymap::Keymap, modal: &ModalKeymap, picker_mode: bool) -> Vec<String> {
3248  // The bool signature is kept for `gwm tui keys` and the chord tests; map
3249  // it to the context enum (issue #217). The list-view help body is the same
3250  // for either pane, so `Worktrees` stands in for the non-picker case.
3251  let ctx = if picker_mode {
3252    HintContext::Picker
3253  } else {
3254    HintContext::Worktrees
3255  };
3256  help_rows(km, modal, ctx)
3257    .into_iter()
3258    .map(|row| match row {
3259      HelpRow::Title(s) | HelpRow::Subtitle(s) | HelpRow::Section(s) => s,
3260      HelpRow::Blank => String::new(),
3261      HelpRow::Entry { keys, label } => {
3262        let keys = if keys.is_empty() { "(unbound)".to_string() } else { keys };
3263        format!("  {:<13} {}", keys, label)
3264      }
3265    })
3266    .collect()
3267}
3268
3269/// Display width of a help row's key *badges* once split into one badge
3270/// per chord (#187 review). Each badge renders as ` chord ` (chord + 2
3271/// pad cells); badges are separated by a single space. `(unbound)` /
3272/// empty render as one muted badge. Used to right-pad the badge column
3273/// so the labels line up regardless of how many chords a row binds.
3274pub fn badge_group_width(keys: &str) -> usize {
3275  if keys.is_empty() || keys == "(unbound)" {
3276    return "(unbound)".chars().count();
3277  }
3278  let chords: Vec<&str> = keys.split(", ").collect();
3279  // Flat accent-bold glyphs now (issue #279), no `` key `` padding box: a
3280  // group is the sum of bare chord widths plus one space between adjacent
3281  // chords.
3282  let glyphs: usize = chords.iter().map(|c| c.chars().count()).sum();
3283  glyphs + chords.len().saturating_sub(1)
3284}
3285
3286/// One documented-binding row for the Keybindings overlay (issue #279):
3287/// the chord(s) as flat accent-bold glyphs (no reverse-video badge),
3288/// padded to `max_group_w` so every label lines up in one column, then the
3289/// human label. An unbound action reads as a muted `(unbound)` placeholder.
3290/// Extracted as a pure builder so the de-badged treatment is pinned by
3291/// `tests/tui_ui_helpers_tests.rs` without a ratatui backend.
3292pub fn help_entry_line(keys: &str, label: &str, max_group_w: usize, theme: &Theme) -> Line<'static> {
3293  let key_style = hint_key_style(theme);
3294  let muted_style = Style::default().fg(theme.muted);
3295  let mut spans: Vec<Span<'static>> = vec![Span::raw("  ")];
3296  if keys.is_empty() || keys == "(unbound)" {
3297    spans.push(Span::styled("(unbound)", muted_style));
3298  } else {
3299    for (i, chord) in keys.split(", ").enumerate() {
3300      if i > 0 {
3301        spans.push(Span::raw(" "));
3302      }
3303      spans.push(Span::styled(chord.to_string(), key_style));
3304    }
3305  }
3306  let pad = max_group_w.saturating_sub(badge_group_width(keys)) + 1;
3307  spans.push(Span::raw(" ".repeat(pad)));
3308  spans.push(Span::styled(label.to_string(), help_label_style(theme)));
3309  Line::from(spans)
3310}
3311
3312fn draw_help(f: &mut Frame, app: &mut App) {
3313  let area = centered(60, 60, f.area());
3314  // Use the underlying pane context, not the view-priority `hint_context`
3315  // (which would be `Help` while this overlay is up) — `?` documents the
3316  // pane you opened it from, and the picker gating depends on it.
3317  let rows = help_rows(&app.keymap, &app.modal_keymap, app.pane_hint_context());
3318
3319  // Theme-driven colours so the overlay tracks `[theme]` like the rest
3320  // of the TUI (pre-#187 it was hard-coded `Cyan` + plain text).
3321  let accent = app.theme.accent;
3322
3323  let heading_style = Style::default().fg(accent).add_modifier(Modifier::BOLD);
3324  // Subtitle reads in a distinct accent hue (the theme's branch colour) +
3325  // italic, so the context name is clearly a different colour from both the
3326  // bold title and the muted key labels (issue #217 follow-up).
3327  let subtitle_style = Style::default().fg(app.theme.branch).add_modifier(Modifier::ITALIC);
3328
3329  // Align every label to the same column: pad each chord *group* out to the
3330  // widest one so the descriptions line up under one another.
3331  let max_group_w = rows
3332    .iter()
3333    .filter_map(|r| match r {
3334      HelpRow::Entry { keys, .. } => Some(badge_group_width(keys)),
3335      _ => None,
3336    })
3337    .max()
3338    .unwrap_or(0);
3339
3340  // Issue #279: split the overlay into a FIXED header (title + subtitle), a
3341  // SCROLLABLE body (sections + entries), and a FIXED footer hint. Pre-#279
3342  // the whole content scrolled in one `Paragraph`, so the title and the
3343  // close hint rolled off the top/bottom as soon as the body outgrew the
3344  // modal. Title/subtitle are the leading rows; everything else is body.
3345  let mut header_lines: Vec<Line<'static>> = Vec::new();
3346  let mut body_lines: Vec<Line<'static>> = Vec::new();
3347  for row in rows {
3348    match row {
3349      // Title + subtitle are centred (issue #217) and pinned in the header.
3350      HelpRow::Title(t) => header_lines.push(Line::from(Span::styled(t, heading_style)).centered()),
3351      HelpRow::Subtitle(t) => header_lines.push(Line::from(Span::styled(t, subtitle_style)).centered()),
3352      // Section headers stay left-aligned so they anchor their groups
3353      // lazygit-style.
3354      HelpRow::Section(t) => body_lines.push(Line::from(Span::styled(
3355        t,
3356        help_section_style(help_body_section_color(&app.theme)),
3357      ))),
3358      HelpRow::Blank => body_lines.push(Line::from(String::new())),
3359      HelpRow::Entry { keys, label } => {
3360        body_lines.push(help_entry_line(&keys, &label, max_group_w, &app.theme));
3361      }
3362    }
3363  }
3364
3365  let block = overlay_block(accent);
3366  let inner_area = block.inner(area);
3367  f.render_widget(Clear, area);
3368  f.render_widget(block, area);
3369
3370  // header (fixed) | body (scrollable) | footer hint (fixed). The header is
3371  // exactly as tall as its line count; the footer is one row; the body
3372  // takes the rest.
3373  let header_h = header_lines.len() as u16;
3374  let [header_area, body_area, footer_area] =
3375    Layout::vertical([Constraint::Length(header_h), Constraint::Min(1), Constraint::Length(1)]).areas(inner_area);
3376
3377  f.render_widget(Paragraph::new(header_lines), header_area);
3378
3379  // Publish the scroll bounds against the BODY viewport only (issue #279) —
3380  // not the whole inner height — so the clamp matches what actually scrolls
3381  // and the last body rows stay reachable.
3382  let body_viewport = body_area.height as usize;
3383  app.help_max_scroll = (body_lines.len().saturating_sub(body_viewport)) as u16;
3384  app.help_scroll = app.help_scroll.min(app.help_max_scroll);
3385  let scroll = app.help_scroll;
3386  // Reserve the scrollbar column FIRST, then bound the horizontal pan against
3387  // the reduced text width so the final cell stays reachable (review P3).
3388  let text_area = scrollable_body_area(f, body_area, scroll, body_lines.len(), &app.theme);
3389  let content_width = body_lines.iter().map(Line::width).max().unwrap_or(0);
3390  app.help_max_x_scroll = content_width.saturating_sub(text_area.width as usize) as u16;
3391  app.help_x_scroll = app.help_x_scroll.min(app.help_max_x_scroll);
3392  let x_scroll = app.help_x_scroll;
3393  f.render_widget(Paragraph::new(body_lines).scroll((scroll, x_scroll)), text_area);
3394  f.render_widget(
3395    modal_hint_for_context(HintContext::Help, &app.keymap, &app.modal_keymap, &app.theme),
3396    footer_area,
3397  );
3398}
3399
3400/// Render the Command Logs overlay (issue #226): a ~90% fullscreen modal
3401/// over the dimmed list showing the lazygit-style transcript of the
3402/// external commands gwm ran, newest-first. Scrolls like the help overlay —
3403/// the renderer republishes `command_logs.max_scroll` / `max_x_scroll`
3404/// against the live viewport so `App`'s scroll cursor can never run past
3405/// the content. Colours track `[theme]` roles (`clean` ok / `prunable`
3406/// fail / `muted` output) so a theme override applies here too.
3407fn draw_command_logs(f: &mut Frame, app: &mut App) {
3408  let area = centered(90, 85, f.area());
3409  let accent = app.theme.accent;
3410  let muted = app.theme.muted;
3411  let ok_color = app.theme.clean;
3412  let err_color = app.theme.prunable;
3413  let label_style = help_label_style(&app.theme);
3414  let muted_style = Style::default().fg(muted);
3415  let heading_style = Style::default().fg(accent).add_modifier(Modifier::BOLD);
3416
3417  // Fixed header (title) / scrollable body / fixed footer hint (issue #279) —
3418  // the title and the close hint stay pinned while the transcript scrolls.
3419  let block = overlay_block(accent);
3420  let inner = block.inner(area);
3421  f.render_widget(Clear, area);
3422  f.render_widget(block, area);
3423
3424  let [header_area, body_area, footer_area] =
3425    Layout::vertical([Constraint::Length(1), Constraint::Min(1), Constraint::Length(1)]).areas(inner);
3426
3427  f.render_widget(
3428    Paragraph::new(Line::from(Span::styled("Command Logs", heading_style)).centered()),
3429    header_area,
3430  );
3431
3432  // A full-width `-` rule, padded by a blank line above and below, separates
3433  // adjacent log entries (issue #279 follow-up).
3434  let rule = "-".repeat(body_area.width as usize);
3435  let mut lines: Vec<Line<'static>> = Vec::new();
3436
3437  if app.command_logs.entries.is_empty() {
3438    lines.push(Line::from(Span::styled("No commands run yet.", muted_style)));
3439  } else {
3440    // Newest-first: the most recent command is what the user opened the
3441    // overlay to see, so it sits at the top without scrolling.
3442    for (i, entry) in app.command_logs.entries.iter().rev().enumerate() {
3443      if i > 0 {
3444        lines.push(Line::from(String::new()));
3445        lines.push(Line::from(Span::styled(rule.clone(), muted_style)));
3446        lines.push(Line::from(String::new()));
3447      }
3448      // The resolved argv, prefixed lazygit-style with `$`.
3449      lines.push(Line::from(vec![
3450        Span::styled("$ ", Style::default().fg(accent).add_modifier(Modifier::BOLD)),
3451        Span::styled(entry.command.clone(), label_style),
3452      ]));
3453      // Outcome line, coloured by exit status.
3454      let (color, detail) = match &entry.status {
3455        CommandStatus::Exited(Some(0)) => (ok_color, format!("→ exit 0 ({} ms)", entry.duration.as_millis())),
3456        CommandStatus::Exited(Some(code)) => (
3457          err_color,
3458          format!("→ exit {} ({} ms)", code, entry.duration.as_millis()),
3459        ),
3460        CommandStatus::Exited(None) => (err_color, format!("→ terminated ({} ms)", entry.duration.as_millis())),
3461        CommandStatus::Spawn => (err_color, "✗ failed to spawn".to_string()),
3462      };
3463      lines.push(Line::from(vec![
3464        Span::raw("  "),
3465        Span::styled(detail, Style::default().fg(color)),
3466      ]));
3467      // Captured output, tail-capped so one chatty command cannot dominate
3468      // the transcript (the tail is where errors surface).
3469      if !entry.output.is_empty() {
3470        const MAX_OUTPUT_LINES: usize = 6;
3471        let out: Vec<&str> = entry.output.lines().collect();
3472        let start = out.len().saturating_sub(MAX_OUTPUT_LINES);
3473        if start > 0 {
3474          lines.push(Line::from(Span::styled(
3475            format!("    … {} earlier line(s)", start),
3476            muted_style,
3477          )));
3478        }
3479        for l in &out[start..] {
3480          lines.push(Line::from(Span::styled(format!("    {}", l), muted_style)));
3481        }
3482      }
3483    }
3484  }
3485
3486  // Publish the scroll bounds against the BODY viewport only (issue #279).
3487  let body_viewport = body_area.height as usize;
3488  app.command_logs.max_scroll = (lines.len().saturating_sub(body_viewport)) as u16;
3489  app.command_logs.scroll = app.command_logs.scroll.min(app.command_logs.max_scroll);
3490  let scroll = app.command_logs.scroll;
3491  // Reserve the scrollbar column first, then bound the pan (review P3).
3492  let text_area = scrollable_body_area(f, body_area, scroll, lines.len(), &app.theme);
3493  let content_w = lines.iter().map(Line::width).max().unwrap_or(0);
3494  app.command_logs.max_x_scroll = content_w.saturating_sub(text_area.width as usize) as u16;
3495  app.command_logs.x_scroll = app.command_logs.x_scroll.min(app.command_logs.max_x_scroll);
3496  let x_scroll = app.command_logs.x_scroll;
3497  f.render_widget(Paragraph::new(lines).scroll((scroll, x_scroll)), text_area);
3498  // #219 review: copy / close resolve from the command_logs modal bindings so
3499  // a rebind shows through; the scroll / top-bottom pairs stay literal.
3500  let footer_owned = command_logs_footer_hints(&app.modal_keymap);
3501  let footer_hints: Vec<(&str, &str)> = footer_owned.iter().map(|(k, l)| (k.as_str(), l.as_str())).collect();
3502  f.render_widget(modal_hint_line(&footer_hints, &app.theme), footer_area);
3503}
3504
3505/// Render a vertical scrollbar on the right edge of `area` when the content
3506/// overflows the viewport (issue #279, herdr-style), and return the text
3507/// area shrunk by one column to make room. When everything fits, the area
3508/// is returned unchanged and no scrollbar is drawn. The thumb tracks the
3509/// theme `accent`; the track reads `muted`.
3510fn scrollable_body_area(f: &mut Frame, area: Rect, offset: u16, content_len: usize, theme: &Theme) -> Rect {
3511  let viewport = area.height as usize;
3512  if content_len <= viewport || area.width < 2 {
3513    return area;
3514  }
3515  // ratatui maps the thumb over `content_length - 1`, but our scroll offset
3516  // is clamped to `content_len - viewport` (the last page stays full). Pass
3517  // `content_length = max_scroll + 1` with the real viewport length so the
3518  // thumb size stays proportional AND reaches the bottom at full scroll
3519  // (issue #279 follow-up: the thumb used to top out early).
3520  let max_scroll = content_len - viewport;
3521  let mut state = ScrollbarState::new(max_scroll + 1)
3522    .position(offset as usize)
3523    .viewport_content_length(viewport);
3524  let bar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
3525    .begin_symbol(None)
3526    .end_symbol(None)
3527    .thumb_style(Style::default().fg(theme.accent))
3528    .track_style(Style::default().fg(theme.muted));
3529  f.render_stateful_widget(bar, area, &mut state);
3530  Rect {
3531    width: area.width.saturating_sub(1),
3532    ..area
3533  }
3534}
3535
3536/// Build the read-only `All`-tab body: the resolved config grouped by
3537/// top-level section with a colour-coded source column (repo / user /
3538/// default). The pre-#279 Configuration view, now one tab of the Settings
3539/// overlay.
3540fn settings_all_lines(app: &App) -> Vec<Line<'static>> {
3541  let accent = app.theme.accent;
3542  let muted = app.theme.muted;
3543  let label_style = help_label_style(&app.theme);
3544  let muted_style = Style::default().fg(muted);
3545  let mut lines: Vec<Line<'static>> = Vec::new();
3546
3547  if app.config_panel.rows.is_empty() {
3548    lines.push(Line::from(Span::styled("No configuration resolved.", muted_style)));
3549    return lines;
3550  }
3551  let mut current_section: Option<String> = None;
3552  for row in &app.config_panel.rows {
3553    let section = row.key.split(['.', '[']).next().unwrap_or("").to_string();
3554    if current_section.as_deref() != Some(section.as_str()) {
3555      if current_section.is_some() {
3556        lines.push(Line::from(String::new()));
3557      }
3558      lines.push(Line::from(Span::styled(
3559        format!("[{section}]"),
3560        help_section_style(accent),
3561      )));
3562      current_section = Some(section);
3563    }
3564    let src_color = match row.source {
3565      ConfigSource::Repo => app.theme.clean,
3566      ConfigSource::User => app.theme.branch,
3567      ConfigSource::Default => muted,
3568    };
3569    lines.push(Line::from(vec![
3570      Span::raw("  "),
3571      Span::styled(format!("{:<7}", row.source.label()), Style::default().fg(src_color)),
3572      Span::raw("  "),
3573      Span::styled(row.key.clone(), label_style),
3574      Span::styled(" = ", muted_style),
3575      Span::styled(row.value.clone(), Style::default().fg(Color::White)),
3576    ]));
3577  }
3578  lines
3579}
3580
3581/// Build an editable-tab body: one row per [`SettingField`], the selected
3582/// row marked and its value in the accent. The `Uint` field under edit
3583/// shows its live buffer with a cursor; a field whose effective value is
3584/// shadowed by a higher-precedence layer carries an inline guidance note
3585/// (issue #279 — honours "edit both layers" without a silent dead edit).
3586fn settings_fields_lines(app: &App, fields: &[SettingField]) -> Vec<Line<'static>> {
3587  let accent = app.theme.accent;
3588  let muted = app.theme.muted;
3589  let label_style = help_label_style(&app.theme);
3590  let muted_style = Style::default().fg(muted);
3591  let panel = &app.config_panel;
3592  let mut lines: Vec<Line<'static>> = Vec::new();
3593
3594  for (i, field) in fields.iter().enumerate() {
3595    let selected = i == panel.selected;
3596    let editing = selected && panel.editing.is_some();
3597    let value = if editing {
3598      format!("{}_", panel.editing.as_deref().unwrap_or(""))
3599    } else {
3600      field.current(&app.config)
3601    };
3602    let marker = if selected { "›" } else { " " };
3603    let marker_style = Style::default().fg(accent).add_modifier(Modifier::BOLD);
3604    let value_style = if selected {
3605      Style::default().fg(accent).add_modifier(Modifier::BOLD)
3606    } else {
3607      Style::default().fg(Color::White)
3608    };
3609    let mut spans = vec![
3610      Span::styled(format!(" {marker} "), marker_style),
3611      Span::styled(format!("{:<24}", field.label()), label_style),
3612      Span::styled(value, value_style),
3613    ];
3614    // Shadow guidance: editing the Global layer for a field the repo
3615    // overrides won't change the effective value (repo wins). Surface it
3616    // rather than silently no-op or hard-disable the field.
3617    if selected && panel.layer.source() == ConfigSource::User && panel.field_source(*field) == Some(ConfigSource::Repo)
3618    {
3619      spans.push(Span::styled("  — set in .gwm.toml; switch to Project", muted_style));
3620    }
3621    lines.push(Line::from(spans));
3622  }
3623  lines
3624}
3625
3626/// Build the Keys-tab body (issue #294): the rebindable bindings grouped by
3627/// scope (`[global]`, `[modal.<context>]`), each row showing its source badge,
3628/// label and current key(s). The selected row is marked; while a live capture
3629/// is armed its key column becomes a `[ … ]` input echoing the captured
3630/// strokes. Returns the line index of the selected row so the caller can keep
3631/// it in view (this body is far taller than the viewport). Mirrors
3632/// [`settings_all_lines`]'s section grouping + source colours.
3633fn settings_keys_lines(app: &App) -> (Vec<Line<'static>>, Option<usize>) {
3634  let accent = app.theme.accent;
3635  let muted = app.theme.muted;
3636  let label_style = help_label_style(&app.theme);
3637  let muted_style = Style::default().fg(muted);
3638  let panel = &app.config_panel;
3639  let mut lines: Vec<Line<'static>> = Vec::new();
3640  let mut selected_line: Option<usize> = None;
3641
3642  if panel.key_rows.is_empty() {
3643    lines.push(Line::from(Span::styled("No bindings resolved.", muted_style)));
3644    return (lines, None);
3645  }
3646
3647  let mut current_scope: Option<String> = None;
3648  for (i, row) in panel.key_rows.iter().enumerate() {
3649    if current_scope.as_deref() != Some(row.scope.as_str()) {
3650      if current_scope.is_some() {
3651        lines.push(Line::from(String::new()));
3652      }
3653      lines.push(Line::from(Span::styled(
3654        format!("[{}]", row.scope),
3655        help_section_style(accent),
3656      )));
3657      current_scope = Some(row.scope.clone());
3658    }
3659
3660    let selected = i == panel.selected;
3661    if selected {
3662      selected_line = Some(lines.len());
3663    }
3664    let capturing = selected && panel.capture.is_some();
3665    let marker = if selected { "›" } else { " " };
3666    let marker_style = Style::default().fg(accent).add_modifier(Modifier::BOLD);
3667    let src_color = match row.source {
3668      ConfigSource::Repo => app.theme.clean,
3669      ConfigSource::User => app.theme.branch,
3670      ConfigSource::Default => muted,
3671    };
3672
3673    let key_span = if capturing {
3674      let pending = panel
3675        .capture
3676        .as_ref()
3677        .map(|c| c.pending.iter().map(|s| s.to_string()).collect::<Vec<_>>().join(" "))
3678        .unwrap_or_default();
3679      Span::styled(
3680        format!("[ {pending}_ ]"),
3681        Style::default().fg(accent).add_modifier(Modifier::BOLD),
3682      )
3683    } else {
3684      let shown = if row.keys.is_empty() {
3685        "(unbound)".to_string()
3686      } else {
3687        row.keys.clone()
3688      };
3689      let style = if row.keys.is_empty() {
3690        muted_style
3691      } else if selected {
3692        Style::default().fg(accent).add_modifier(Modifier::BOLD)
3693      } else {
3694        Style::default().fg(Color::White)
3695      };
3696      Span::styled(shown, style)
3697    };
3698
3699    lines.push(Line::from(vec![
3700      Span::styled(format!(" {marker} "), marker_style),
3701      Span::styled(format!("{:<7}", row.source.label()), Style::default().fg(src_color)),
3702      Span::raw(" "),
3703      Span::styled(format!("{:<24}", row.label), label_style),
3704      key_span,
3705    ]));
3706  }
3707  (lines, selected_line)
3708}
3709
3710/// Render the Settings overlay (issue #232; editable in #279): same modal
3711/// size as the Keybindings overlay, with a fixed header (title + the edit
3712/// layer as a subtitle + category tabs), a scrollable body (the active
3713/// tab's fields, or the read-only resolved config on the `All` tab) with a
3714/// herdr-style scrollbar, and a fixed footer hint. The renderer republishes
3715/// `config_panel.max_scroll` against the live body viewport.
3716fn draw_config_panel(f: &mut Frame, app: &mut App) {
3717  let area = centered(60, 60, f.area());
3718  let accent = app.theme.accent;
3719  let muted = app.theme.muted;
3720  let muted_style = Style::default().fg(muted);
3721  let heading_style = Style::default().fg(accent).add_modifier(Modifier::BOLD);
3722  // Subtitle reads in the branch hue + italic, mirroring the Keybindings
3723  // overlay's context subtitle.
3724  let subtitle_style = Style::default().fg(app.theme.branch).add_modifier(Modifier::ITALIC);
3725
3726  let tab = app.config_panel.tab;
3727  let editing = app.config_panel.editing.is_some();
3728  let selected_kind = app.config_panel.selected_field().map(SettingField::kind);
3729
3730  // Header: title + the active edit layer as a subtitle + a blank spacer +
3731  // the tab strip (all fixed). The layer-switch key lives in the footer
3732  // hints, so the subtitle stays a plain context label.
3733  let title = Line::from(Span::styled("Settings", heading_style)).centered();
3734  let subtitle = Line::from(Span::styled(app.config_panel.layer.label(), subtitle_style)).centered();
3735  let mut tab_spans: Vec<Span<'static>> = vec![Span::raw(" ")];
3736  for (i, t) in SettingsTab::ALL.iter().enumerate() {
3737    if i > 0 {
3738      tab_spans.push(Span::raw("  "));
3739    }
3740    let style = if *t == tab { chip_style(accent) } else { muted_style };
3741    tab_spans.push(Span::styled(format!(" {} ", t.label()), style));
3742  }
3743  let header_lines = vec![title, subtitle, Line::from(String::new()), Line::from(tab_spans)];
3744
3745  // Body depends on the active tab. Every tab with a selection reports the line
3746  // index of the selected row so the renderer can scroll it into view.
3747  //
3748  // This used to be Keys-only, on the reasoning that "the field tabs are short
3749  // enough to never need this". That was wrong on any short terminal: the modal
3750  // is 60% of the height, so 24 lines leave ~6 body rows, and the TUI tab has
3751  // had more fields than that since well before #367 added an 8th. The result
3752  // was a selection that walked off screen — the user cycling or editing a row
3753  // they cannot see (Codex review #368 P2).
3754  //
3755  // `settings_fields_lines` renders exactly one line per field, so the field
3756  // index *is* the line index.
3757  let mut selected_line: Option<usize> = None;
3758  let body_lines = match tab {
3759    SettingsTab::All => settings_all_lines(app),
3760    SettingsTab::Keys => {
3761      let (lines, sel) = settings_keys_lines(app);
3762      selected_line = sel;
3763      lines
3764    }
3765    other => {
3766      let fields = other.fields();
3767      if !fields.is_empty() {
3768        selected_line = Some(app.config_panel.selected.min(fields.len().saturating_sub(1)));
3769      }
3770      settings_fields_lines(app, fields)
3771    }
3772  };
3773
3774  // Footer hints — flat accent-bind + muted-action (issue #279), dynamic to
3775  // the current tab / edit / capture mode. The edit, capture and nav rows
3776  // resolve their single-key verbs from the Config* modal bindings (#219
3777  // review) so a rebind of `[tui.keys.modal.config(.edit)]` shows through
3778  // instead of literal keys.
3779  let capture_single = app.config_panel.capture.as_ref().map(|c| c.single_only);
3780  let footer_owned = if let Some(single) = capture_single {
3781    config_capture_footer_hints(&app.modal_keymap, single)
3782  } else if editing {
3783    config_edit_footer_hints(&app.modal_keymap)
3784  } else {
3785    config_nav_footer_hints(&app.modal_keymap, tab, selected_kind)
3786  };
3787  let footer_hints: Vec<(&str, &str)> = footer_owned.iter().map(|(k, l)| (k.as_str(), l.as_str())).collect();
3788
3789  let block = overlay_block(accent);
3790  let inner = block.inner(area);
3791  f.render_widget(Clear, area);
3792  f.render_widget(block, area);
3793
3794  let header_h = header_lines.len() as u16;
3795  let [header_area, body_area, footer_area] =
3796    Layout::vertical([Constraint::Length(header_h), Constraint::Min(1), Constraint::Length(1)]).areas(inner);
3797
3798  f.render_widget(Paragraph::new(header_lines), header_area);
3799
3800  // Publish scroll bounds against the BODY viewport only (issue #279).
3801  let body_viewport = body_area.height as usize;
3802  app.config_panel.max_scroll = (body_lines.len().saturating_sub(body_viewport)) as u16;
3803  // Follow the selected row so it stays on screen as the selection moves —
3804  // every tab that has a selection, not just Keys (see the note above).
3805  if let Some(sel) = selected_line {
3806    let scroll = app.config_panel.scroll as usize;
3807    if sel < scroll {
3808      app.config_panel.scroll = sel as u16;
3809    } else if body_viewport > 0 && sel >= scroll + body_viewport {
3810      app.config_panel.scroll = (sel + 1 - body_viewport) as u16;
3811    }
3812  }
3813  app.config_panel.scroll = app.config_panel.scroll.min(app.config_panel.max_scroll);
3814  let scroll = app.config_panel.scroll;
3815  // Reserve the scrollbar column first, then bound the pan (review P3).
3816  let text_area = scrollable_body_area(f, body_area, scroll, body_lines.len(), &app.theme);
3817  let content_w = body_lines.iter().map(Line::width).max().unwrap_or(0);
3818  app.config_panel.max_x_scroll = content_w.saturating_sub(text_area.width as usize) as u16;
3819  app.config_panel.x_scroll = app.config_panel.x_scroll.min(app.config_panel.max_x_scroll);
3820  let x_scroll = app.config_panel.x_scroll;
3821  f.render_widget(Paragraph::new(body_lines).scroll((scroll, x_scroll)), text_area);
3822  f.render_widget(modal_hint_line(&footer_hints, &app.theme), footer_area);
3823}
3824
3825/// The branch and directory a structured form would produce, expanded from
3826/// **this repo's own** `branch_pattern` / `path_pattern`.
3827///
3828/// Issue #417: those patterns are what `gwm create` and the rename actually
3829/// write, so a live preview has to come from them. Both modals hardcoded the
3830/// default `<type>/#<issue>-<desc>` shape instead, and under a custom pattern
3831/// they promised names the repo would never create. The rename case was the
3832/// loud one: with `feat/#{issue}-{desc}`, picking `docs` in the type selector
3833/// previewed `docs/#42-x` while submitting wrote `feat/#42-x`, because the
3834/// pattern has no `{type}` to write into.
3835///
3836/// The form's fields are expanded exactly as they stand, mid-typing and all,
3837/// so this never validates and never refuses: an empty issue expands to
3838/// nothing, which is what a preview should show. An expansion that cannot be
3839/// resolved at all yields an empty string rather than a stale or invented one.
3840/// Issue #481. Whether submitting this rename would close an open pull request,
3841/// as a message ready to render, or `None` when nothing is at risk.
3842///
3843/// The remote half of a rename is `git push --atomic origin :<old> <new>:<new>`,
3844/// a delete followed by a create, and GitHub closes a pull request whose head
3845/// branch is renamed. gwm cannot route around that. GitHub's own rename
3846/// endpoint retargets a pull request whose *base* is the renamed branch and
3847/// closes one whose *head* it is, and a worktree branch is always the head of
3848/// its own pull request, so both paths end in the same place. GitLab has no
3849/// rename operation at all, only create-then-delete. Saying so before the push
3850/// is the whole of what is available.
3851///
3852/// Keyed on the **branch** rather than on the rename: an edit that only moves
3853/// the directory returns from `worktree::rename_worktree` before touching a
3854/// single ref, so it is never at risk, and warning there would train the user
3855/// to ignore the line. Recomputed per frame, so it appears the moment the form
3856/// would write a different branch and goes away when the user reverts.
3857///
3858/// A merged or closed pull request has nothing left to lose, and an unfetched
3859/// state says nothing either way: this reports what gwm knows, and claims
3860/// nothing about what it has not looked up.
3861fn rename_pr_warning(app: &App, new_branch: &str, old_branch: &str) -> Option<String> {
3862  if new_branch == old_branch {
3863    return None;
3864  }
3865  let w = app.selected()?;
3866  if !matches!(w.pr_state.or(w.link.pr_state)?, PrState::Open | PrState::Draft) {
3867    return None;
3868  }
3869  Some(match w.link.pr {
3870    Some(number) => format!("⚠ renaming the branch closes PR #{}", number),
3871    None => "⚠ renaming the branch closes its open pull request".into(),
3872  })
3873}
3874
3875/// The `(branch, directory)` pair the form would produce, for the live preview.
3876///
3877/// Both modes, because both are reachable from the create form (#416) and now
3878/// from the rename modal too (#479) — and a preview that handled only one of
3879/// them would show a branch the submit is not going to write. That defect
3880/// shipped once already, with the preview hardcoding `<type>/#<issue>-<desc>`
3881/// while the submit expanded the repo's patterns; free-form is the same trap
3882/// one mode over, since there nothing is expanded at all.
3883///
3884/// Deliberately **unvalidated**, unlike [`App::edit_target`]: this runs on every
3885/// keystroke and has to show what a half-typed form is heading towards. So the
3886/// free-form arm builds `WorktreeName::Freeform` directly rather than through
3887/// `WorktreeName::freeform`, which would refuse an incomplete name and blank the
3888/// preview mid-word. The flattening still comes from `worktree_dirname`, so the
3889/// preview and the submit cannot drift on it.
3890fn pattern_preview(app: &App, type_str: &str) -> (String, String) {
3891  if app.create_form.mode == Mode::Freeform {
3892    let name = crate::naming::WorktreeName::Freeform(app.create_form.name.clone());
3893    return (
3894      name
3895        .branch_name(&app.config.worktree, &app.repo_name)
3896        .unwrap_or_default(),
3897      name
3898        .worktree_dirname(&app.config.worktree, &app.repo_name)
3899        .unwrap_or_default(),
3900    );
3901  }
3902  let expand = |pattern: &str| {
3903    crate::config::expand_placeholders(
3904      pattern,
3905      &app.repo_name,
3906      Some(type_str),
3907      Some(&app.create_form.issue),
3908      Some(&app.create_form.desc),
3909      None,
3910    )
3911    .unwrap_or_default()
3912  };
3913  (
3914    expand(&app.config.worktree.branch_pattern),
3915    expand(&app.config.worktree.path_pattern),
3916  )
3917}
3918
3919/// One row per field the form presents, in the order the patterns write them
3920/// (issue #418), blank-line separated.
3921///
3922/// Both the create overlay and the rename modal draw from this, so they cannot
3923/// come to disagree about which inputs exist — they used to hardcode the same
3924/// `Type` / `Issue` / `Desc` triple twice, which is two places to forget a
3925/// pattern that carries only some of them.
3926///
3927/// Visual order is focus order by construction, which is the property that
3928/// makes a custom pattern legible: `{desc}-{issue}` reads top-to-bottom in the
3929/// same order it writes left-to-right. That does move the type selector below
3930/// the preview, where the old layout kept it above.
3931fn form_field_lines(app: &App, type_str: &str, type_desc: &str, value_w: usize, label_w: usize) -> Vec<Line<'static>> {
3932  let accent = app.theme.accent;
3933  let muted = app.theme.muted;
3934  let surface = app.theme.selection_bg;
3935  let label = |s: &str| format!("{:<label_w$}", s);
3936
3937  let mut lines: Vec<Line<'static>> = Vec::new();
3938  for field in app.create_form.fields() {
3939    if !lines.is_empty() {
3940      lines.push(Line::from(String::new()));
3941    }
3942    lines.push(match field {
3943      Field::Type => type_selector_line(
3944        &label("Type"),
3945        type_str,
3946        type_desc,
3947        app.create_form.field == Field::Type,
3948        accent,
3949        muted,
3950      ),
3951      Field::Issue => field_input_line(
3952        &label("Issue"),
3953        &app.create_form.issue,
3954        app.create_form.field == Field::Issue,
3955        value_w,
3956        accent,
3957        muted,
3958        surface,
3959      ),
3960      Field::Desc => field_input_line(
3961        &label("Desc"),
3962        &app.create_form.desc,
3963        app.create_form.field == Field::Desc,
3964        value_w,
3965        accent,
3966        muted,
3967        surface,
3968      ),
3969      // `Name` belongs to free-form mode, which never reaches this list.
3970      Field::Name => continue,
3971    });
3972  }
3973  lines
3974}
3975
3976fn draw_create(f: &mut Frame, app: &App) {
3977  let accent = app.theme.accent;
3978  let muted = app.theme.muted;
3979  let clean = app.theme.clean;
3980  let surface = app.theme.selection_bg;
3981
3982  let (type_str, type_desc) = app
3983    .branch_types
3984    .get(app.create_form.type_index)
3985    .map(|t| (t.name.as_str(), t.description.as_str()))
3986    .unwrap_or(("", "(no branch types configured)"));
3987
3988  let block = overlay_block(clean);
3989  let term = f.area();
3990  let outer = centered_box(70, 72, 1, term);
3991  let inner_w = block.inner(outer).width as usize;
3992
3993  // Width of the background-filled value field: the inner width minus the
3994  // `  label  ` gutter (2 indent + label column + 2 gap).
3995  let label_w = 5usize;
3996  let gutter = 2 + label_w + 2;
3997  let value_w = inner_w.saturating_sub(gutter);
3998
3999  let label = |s: &str| format!("{:<label_w$}", s);
4000  let freeform = app.create_form.mode == Mode::Freeform;
4001  // Issue #416: a free-form name IS the branch, and the directory is that
4002  // name with `/` flattened — mirroring `WorktreeName::worktree_dirname`.
4003  let (branch_raw, dir_raw) = if freeform {
4004    (app.create_form.name.clone(), app.create_form.name.replace('/', "-"))
4005  } else {
4006    pattern_preview(app, type_str)
4007  };
4008  let branch = ellipsize_middle(&branch_raw, inner_w.saturating_sub("  Branch : ".len()));
4009  let dirname = ellipsize_middle(&dir_raw, inner_w.saturating_sub("  Dir    : ".len()));
4010
4011  let mut lines = overlay_title_lines(
4012    if freeform {
4013      "New Worktree — free-form"
4014    } else {
4015      "New Worktree"
4016    },
4017    clean,
4018  );
4019  // The live preview first, then the editable fields — the preview sits above
4020  // the inputs so the resulting names stay in view while typing (issue #217
4021  // follow-up). Which inputs those are comes from the patterns (#418), so a
4022  // repo whose convention writes no issue number is not shown a field for one.
4023  lines.push(Line::from(vec![
4024    Span::raw("  Branch : "),
4025    Span::styled(branch, Style::default().fg(app.theme.branch)),
4026  ]));
4027  lines.push(Line::from(vec![
4028    Span::raw("  Dir    : "),
4029    Span::styled(dirname, Style::default().fg(app.theme.dirty)),
4030  ]));
4031  lines.push(Line::from(String::new()));
4032  if freeform {
4033    lines.push(field_input_line(
4034      &label("Name"),
4035      &app.create_form.name,
4036      app.create_form.field == Field::Name,
4037      value_w,
4038      accent,
4039      muted,
4040      surface,
4041    ));
4042  } else {
4043    lines.extend(form_field_lines(app, type_str, type_desc, value_w, label_w));
4044  }
4045
4046  let height = lines.len() as u16 + 4 + 2 /* border */ + 2 /* vertical padding */;
4047  let area = centered_box(70, 72, height, term);
4048  let inner = Layout::default()
4049    .direction(Direction::Vertical)
4050    .constraints([
4051      Constraint::Min(1),    // title + form fields
4052      Constraint::Length(1), // loader / failure
4053      Constraint::Length(1), // buttons
4054      Constraint::Length(1), // hint gap
4055      Constraint::Length(1), // hint
4056    ])
4057    .split(block.inner(area));
4058
4059  f.render_widget(Clear, area);
4060  f.render_widget(block, area);
4061  f.render_widget(Paragraph::new(lines), inner[0]);
4062
4063  if app.is_create_worktree_loading() {
4064    f.render_widget(
4065      LoaderWidget::running(
4066        app.spinner.glyph(DOT_FRAMES),
4067        TaskKind::CreateWorktree.loading_label(),
4068        None,
4069        &app.theme,
4070      )
4071      .alignment(Alignment::Center),
4072      inner[1],
4073    );
4074  } else if let Some(error) = app.create_failure.as_deref() {
4075    f.render_widget(
4076      LoaderWidget::failed("create failed", Some(error), &app.theme).alignment(Alignment::Center),
4077      inner[1],
4078    );
4079  }
4080
4081  if !app.is_create_worktree_loading() {
4082    f.render_widget(
4083      Paragraph::new(create_buttons_line(accent, muted)).alignment(Alignment::Center),
4084      inner[2],
4085    );
4086    f.render_widget(
4087      Paragraph::new(modal_hint_for_context_with_fields(
4088        app.create_hint_context(),
4089        &app.keymap,
4090        &app.modal_keymap,
4091        &app.theme,
4092        app.create_form.fields(),
4093      )),
4094      inner[4],
4095    );
4096  }
4097}
4098
4099/// The create overlay's ` Create ` / ` Cancel ` button row (issue #217).
4100/// Mirrors [`confirm_buttons_line`]'s flat coloured chips, but — the create
4101/// action being non-destructive — primes `Create` as the reversed-accent
4102/// chip rather than defaulting focus to Cancel. Pure so the chip contract
4103/// is pinned by `tests/tui_ui_helpers_tests.rs`.
4104pub fn create_buttons_line(accent: Color, muted: Color) -> Line<'static> {
4105  primary_cancel_buttons_line(" Create ", accent, muted)
4106}
4107
4108/// Button row for the rename (`c`) modal: a reversed-accent `Rename` chip
4109/// beside a muted `Cancel`. Mirrors [`create_buttons_line`] but labels the
4110/// primary action "Rename" so the modal's button matches its title and the
4111/// Enter action (Codex review on PR #292, P3).
4112pub fn rename_buttons_line(accent: Color, muted: Color) -> Line<'static> {
4113  primary_cancel_buttons_line(" Rename ", accent, muted)
4114}
4115
4116fn primary_cancel_buttons_line(primary_label: &'static str, accent: Color, muted: Color) -> Line<'static> {
4117  let primary = chip_style(accent);
4118  let idle = Style::default().fg(muted).add_modifier(Modifier::BOLD);
4119  Line::from(vec![
4120    Span::styled(primary_label, primary),
4121    Span::raw("   "),
4122    Span::styled(" Cancel ", idle),
4123  ])
4124}
4125
4126/// A horizontal `‹ name ›` branch-type selector row for the create overlay
4127/// (issue #217 — replaces the bordered up/down box). `label` leads the row
4128/// muted; the arrows + selected name read in the accent when focused, and
4129/// the type's description trails muted. Pure for
4130/// `tests/tui_ui_helpers_tests.rs`.
4131pub fn type_selector_line(
4132  label: &str,
4133  name: &str,
4134  desc: &str,
4135  focused: bool,
4136  accent: Color,
4137  muted: Color,
4138) -> Line<'static> {
4139  let arrow_style = if focused {
4140    Style::default().fg(accent).add_modifier(Modifier::BOLD)
4141  } else {
4142    Style::default().fg(muted)
4143  };
4144  // Focused, the selected value reads as a reversed-accent chip (the same
4145  // badge style as the buttons) so it stands out as an editable control;
4146  // idle it is plain white text between muted arrows.
4147  let name_style = if focused {
4148    chip_style(accent)
4149  } else {
4150    Style::default().fg(Color::White)
4151  };
4152  Line::from(vec![
4153    Span::raw("  "),
4154    Span::styled(label.to_string(), Style::default().fg(muted)),
4155    Span::raw("  "),
4156    Span::styled("‹ ", arrow_style),
4157    Span::styled(format!(" {name} "), name_style),
4158    Span::styled(" ›", arrow_style),
4159    Span::raw("  "),
4160    Span::styled(desc.to_string(), Style::default().fg(muted)),
4161  ])
4162}
4163
4164/// A single-row labelled input with a background surface for the create
4165/// overlay (issue #217 — replaces the 3-row bordered field). `label` leads
4166/// muted; the value sits in a `value_width`-wide background-filled field so
4167/// it reads as one input row. The focused field brightens to the accent
4168/// background and shows a `_` cursor. Pure for
4169/// `tests/tui_ui_helpers_tests.rs`.
4170pub fn field_input_line(
4171  label: &str,
4172  value: &str,
4173  focused: bool,
4174  value_width: usize,
4175  accent: Color,
4176  muted: Color,
4177  surface: Color,
4178) -> Line<'static> {
4179  let cursor = if focused { "_" } else { "" };
4180  let mut field = format!(" {value}{cursor}");
4181  let len = field.chars().count();
4182  if len < value_width {
4183    field.push_str(&" ".repeat(value_width - len));
4184  }
4185  let field_style = if focused {
4186    Style::default().fg(Color::Black).bg(accent)
4187  } else {
4188    Style::default().fg(Color::White).bg(surface)
4189  };
4190  Line::from(vec![
4191    Span::raw("  "),
4192    Span::styled(label.to_string(), Style::default().fg(muted)),
4193    Span::raw("  "),
4194    Span::styled(field, field_style),
4195  ])
4196}
4197
4198/// A single selectable row of the link prompt's `ChooseTarget` picker
4199/// (issue #217, polished in #220): the selected row uses the same
4200/// reversed-bold accent chip treatment as modal buttons; idle rows stay
4201/// muted. `key` is the direct-pick shortcut (`i` / `p`). Pure so the
4202/// highlight contract is pinned by `tests/tui_ui_helpers_tests.rs`.
4203pub fn link_target_line(key: &str, label: &str, selected: bool, accent: Color, muted: Color) -> Line<'static> {
4204  const BUTTON_WIDTH: usize = 17; // " p  Pull Request "
4205  let button = format!(" {key}  {label} ");
4206  let button = format!("{button:<BUTTON_WIDTH$}");
4207  if selected {
4208    let chip = chip_style(accent);
4209    return Line::from(vec![Span::raw("  "), Span::styled(button, chip)]);
4210  }
4211
4212  let idle = Style::default().fg(muted);
4213  Line::from(vec![Span::raw("  "), Span::styled(button, idle)])
4214}
4215
4216/// Modal width for the Link prompt. Pure so the visual budget remains pinned
4217/// without a terminal renderer in `tests/tui_ui_helpers_tests.rs`.
4218pub fn link_prompt_modal_width(term_width: u16) -> u16 {
4219  let width = if term_width <= 80 {
4220    term_width.saturating_mul(80) / 100
4221  } else {
4222    term_width.saturating_mul(60) / 100
4223  };
4224  width.min(72).min(term_width)
4225}
4226
4227/// Modal width for the exec / clean overlays (issue #334 polish). A bit wider
4228/// than the link-prompt modal so the full-width clean report (icon + dir name
4229/// pinned left, size pinned right) uses the horizontal space — but capped so
4230/// the name↔size gap never stretches absurdly on an ultra-wide terminal.
4231/// ~62 % of the width (90 % when ≤ 80 cols), clamped to `[48, 88]`.
4232pub fn overlay_modal_width(term_width: u16) -> u16 {
4233  let pct = if term_width <= 80 { 90 } else { 62 };
4234  (term_width.saturating_mul(pct) / 100).clamp(48, 88).min(term_width)
4235}
4236
4237/// Section-heading style for the Keybindings overlay body. Kept pure so the
4238/// title/body colour split is pinned outside the ratatui renderer.
4239pub fn help_section_style(section: Color) -> Style {
4240  Style::default().fg(section).add_modifier(Modifier::BOLD)
4241}
4242
4243/// One aligned detail row for destructive confirmation summaries.
4244pub fn confirm_detail_line(
4245  label: &str,
4246  value: impl Into<String>,
4247  label_width: usize,
4248  label_color: Color,
4249  value_style: Style,
4250) -> Line<'static> {
4251  Line::from(vec![
4252    Span::styled(
4253      format!("{label:<label_width$}  ", label_width = label_width),
4254      Style::default().fg(label_color),
4255    ),
4256    Span::styled(value.into(), value_style),
4257  ])
4258}
4259
4260pub fn delete_worktree_title() -> &'static str {
4261  "Delete Worktree"
4262}
4263
4264pub fn confirm_delete_branch_line(
4265  enabled: bool,
4266  key: &str,
4267  label_width: usize,
4268  accent: Color,
4269  muted: Color,
4270) -> Line<'static> {
4271  let key_style = chip_style(accent);
4272  let value_style = chip_style(if enabled { accent } else { muted });
4273  Line::from(vec![
4274    Span::styled(
4275      format!("{:<label_width$}  ", "Delete Branch", label_width = label_width),
4276      Style::default().fg(muted),
4277    ),
4278    Span::styled(format!(" {key} "), key_style),
4279    Span::raw("  "),
4280    Span::styled(format!(" {enabled} "), value_style),
4281  ])
4282}
4283
4284pub fn help_body_section_color(theme: &Theme) -> Color {
4285  theme.locked
4286}
4287
4288/// Direct-pick keys (`issue`, `pr`) for the link / open-menu target chips,
4289/// resolved from the active context's modal bindings (#219 review) so a
4290/// rebind of `[tui.keys.modal.link.choose_target]` / `[tui.keys.modal.open_menu]` shows
4291/// through instead of the literal `i` / `p`. An unbound verb yields an empty
4292/// string — the chip then renders label-only rather than a phantom key.
4293pub fn link_target_keys(ctx: HintContext, modal: &ModalKeymap) -> (String, String) {
4294  let (issue, pr) = match ctx {
4295    HintContext::OpenMenu => (ModalAction::OpenMenuIssue, ModalAction::OpenMenuPr),
4296    _ => (ModalAction::LinkChooseIssue, ModalAction::LinkChoosePr),
4297  };
4298  (
4299    modal.primary_key(issue).unwrap_or_default(),
4300    modal.primary_key(pr).unwrap_or_default(),
4301  )
4302}
4303
4304pub fn link_open_modal_lines(app: &App, title: &str, selected: Option<LinkTarget>) -> Vec<Line<'static>> {
4305  let accent = app.theme.accent;
4306  let muted = app.theme.muted;
4307  let ctx = if title == "Link" {
4308    HintContext::LinkPrompt
4309  } else {
4310    HintContext::OpenMenu
4311  };
4312  // #219: the direct-pick chips track the active context's issue/pr bindings
4313  // (like the footer below) so a rebind shows through instead of `i` / `p`.
4314  let (issue_key, pr_key) = link_target_keys(ctx, &app.modal_keymap);
4315  let mut lines = overlay_title_lines(title, accent);
4316  lines.extend(github_status_lines(app, 56));
4317  lines.push(Line::from(""));
4318  lines.push(link_target_line(&issue_key, "Issue", selected == Some(LinkTarget::Issue), accent, muted).centered());
4319  lines.push(link_target_line(&pr_key, "Pull Request", selected == Some(LinkTarget::Pr), accent, muted).centered());
4320  push_modal_hint(&mut lines, ctx, &app.keymap, &app.modal_keymap, &app.theme);
4321  lines
4322}
4323
4324fn draw_confirm(f: &mut Frame, app: &App) {
4325  let muted = app.theme.muted;
4326  // The destructive modal reads in the theme's "danger" colour (the
4327  // same role the prunable `⚠` badge uses), so it tracks `[theme]`
4328  // instead of the pre-#187 hard-coded `Red`.
4329  let danger = app.theme.prunable;
4330
4331  let block = overlay_block(danger);
4332
4333  let Some(w) = app.selected() else {
4334    let mut lines = overlay_title_lines(delete_worktree_title(), danger);
4335    lines.push(Line::from("nothing selected").centered());
4336    let height = lines.len() as u16 + 2 /* border */ + 2 /* padding */;
4337    let area = centered_h(40, height, f.area());
4338    f.render_widget(Clear, area);
4339    f.render_widget(Paragraph::new(lines).block(block), area);
4340    return;
4341  };
4342
4343  // Width first (a fixed % of the terminal) so a long path / name can be
4344  // middle-ellipsized to one line instead of wrapping mid-path (#187
4345  // review). `text_w` is the room inside the border + padding.
4346  let term = f.area();
4347  let outer_w = term.width.saturating_mul(62) / 100;
4348  let text_w = outer_w.saturating_sub(6) as usize;
4349  let label_w = "Delete Branch".chars().count();
4350  let value_w = text_w.saturating_sub(label_w + 2).max(1);
4351
4352  let name = ellipsize_middle(&w.name, value_w);
4353  let path = ellipsize_middle(&tilde_compress(&w.path.display().to_string()), value_w);
4354
4355  // Title stays centred; details use an aligned label/value grid so the
4356  // destructive target is easier to scan (#220 visual follow-up).
4357  let mut content: Vec<Line> = overlay_title_lines(delete_worktree_title(), danger);
4358  content.push(confirm_detail_line(
4359    "Worktree",
4360    name,
4361    label_w,
4362    muted,
4363    Style::default().fg(app.theme.dirty).add_modifier(Modifier::BOLD),
4364  ));
4365  content.push(confirm_detail_line(
4366    "Path",
4367    path,
4368    label_w,
4369    muted,
4370    Style::default().fg(muted),
4371  ));
4372  if let Some(b) = &w.branch {
4373    let branch = ellipsize_middle(b, value_w);
4374    content.push(confirm_detail_line(
4375      "Branch",
4376      branch,
4377      label_w,
4378      muted,
4379      Style::default().fg(app.theme.branch),
4380    ));
4381  }
4382  content.push(Line::from(""));
4383  content.push(confirm_delete_branch_line(
4384    app.delete_branch_on_remove,
4385    // Derive the live chord (Codex review on PR #292): ToggleDeleteBranch is
4386    // `D` since #290, not the pre-#290 `p`, and tracks `[tui.keys]` overrides.
4387    &action_chord(&app.keymap, Action::ToggleDeleteBranch, "D"),
4388    label_w,
4389    app.theme.accent,
4390    muted,
4391  ));
4392
4393  // Size the modal to its content: the title + description rows plus the
4394  // fixed rows (loader / buttons / hint gap / hint), the rounded border and the
4395  // shared interior padding — no more fixed 44%-tall box that dwarfed its
4396  // few lines (#187 review).
4397  let height = content.len() as u16 + 4 + 2 /* border */ + 2 /* padding */;
4398  let area = centered_h(62, height, term);
4399  f.render_widget(Clear, area);
4400
4401  // Five stacked regions inside the padded frame: the title + description,
4402  // a loader/countdown row, the button row, a gap, and a statusbar-style hint. The
4403  // loader row stays reserved (Length 1) even when idle so the buttons
4404  // never jump as the countdown arms. Split `block.inner` so the shared
4405  // padding owns the breathing room (issue #217).
4406  let inner = Layout::default()
4407    .direction(Direction::Vertical)
4408    .constraints([
4409      Constraint::Min(1),    // title + description
4410      Constraint::Length(1), // loader / countdown
4411      Constraint::Length(1), // buttons
4412      Constraint::Length(1), // hint gap
4413      Constraint::Length(1), // hint
4414    ])
4415    .split(block.inner(area));
4416  f.render_widget(block, area);
4417
4418  f.render_widget(Paragraph::new(content).wrap(Wrap { trim: false }), inner[0]);
4419
4420  // --- loader + countdown ---
4421  if app.is_delete_worktree_loading() {
4422    f.render_widget(
4423      LoaderWidget::running(
4424        app.spinner.glyph(DOT_FRAMES),
4425        TaskKind::DeleteWorktree.loading_label(),
4426        None,
4427        &app.theme,
4428      )
4429      .alignment(Alignment::Center),
4430      inner[1],
4431    );
4432  } else if let Some(error) = app.delete_failure.as_deref() {
4433    f.render_widget(
4434      LoaderWidget::failed("delete failed", Some(error), &app.theme).alignment(Alignment::Center),
4435      inner[1],
4436    );
4437  } else if app.confirm_is_countdown_mode() && app.confirm.is_armed() {
4438    let now = Instant::now();
4439    let mut spans = vec![Span::styled(
4440      format!("{} ", app.spinner.glyph(DOT_FRAMES)),
4441      Style::default().fg(danger).add_modifier(Modifier::BOLD),
4442    )];
4443    spans.extend(countdown_bar(
4444      app.confirm_countdown_progress(now),
4445      app.confirm_countdown_remaining_secs(now),
4446      danger,
4447      app.theme.dirty,
4448      muted,
4449    ));
4450    f.render_widget(Paragraph::new(Line::from(spans)).alignment(Alignment::Center), inner[1]);
4451  }
4452
4453  // --- buttons (focused one highlighted) ---
4454  if !app.is_delete_worktree_loading() {
4455    f.render_widget(
4456      Paragraph::new(confirm_buttons_line(
4457        app.confirm.focused_button(),
4458        app.theme.accent,
4459        muted,
4460      ))
4461      .alignment(Alignment::Center),
4462      inner[2],
4463    );
4464
4465    f.render_widget(
4466      Paragraph::new(modal_hint_for_context(
4467        HintContext::Confirm,
4468        &app.keymap,
4469        &app.modal_keymap,
4470        &app.theme,
4471      )),
4472      inner[4],
4473    );
4474  }
4475}
4476
4477/// The ` Confirm ` ` Cancel ` button row (#187, restyled in #217). The
4478/// buttons are flat coloured chips — no square brackets: the focused one
4479/// gets the reversed-bold accent chip (the same badge style as the bottom
4480/// statusline and the help overlay), the idle one reads muted-bold. Focus
4481/// defaults to Cancel, so the destructive button is never the one a stray
4482/// `Enter` lands on. Pure so the chip contract is pinned by
4483/// `tests/tui_ui_helpers_tests.rs`.
4484pub fn confirm_buttons_line(focus: ConfirmButton, accent: Color, muted: Color) -> Line<'static> {
4485  let focused = chip_style(accent);
4486  let idle = Style::default().fg(muted).add_modifier(Modifier::BOLD);
4487  let (confirm_style, cancel_style) = match focus {
4488    ConfirmButton::Confirm => (focused, idle),
4489    ConfirmButton::Cancel => (idle, focused),
4490  };
4491  Line::from(vec![
4492    Span::styled(" Confirm ", confirm_style),
4493    Span::raw("   "),
4494    Span::styled(" Cancel ", cancel_style),
4495  ])
4496}
4497
4498/// Build the `[████░░] Ns` countdown line, themed by the caller (#187
4499/// review: was hard-coding `Red` / `Yellow` / `DarkGray`, which clashed
4500/// with non-default themes). Width is fixed at 10 cells so the bar reads
4501/// the same regardless of modal size. The control hint (`n` / `Esc` to
4502/// cancel) lives in the modal's hint row, not here, so the controls have
4503/// a single source of truth.
4504fn countdown_bar<'a>(
4505  progress: f64,
4506  remaining_secs: u64,
4507  filled_color: Color,
4508  secs_color: Color,
4509  frame_color: Color,
4510) -> Vec<Span<'a>> {
4511  const CELLS: usize = 10;
4512  let filled = filled_cells_for_progress(progress, CELLS);
4513  let bar: String = std::iter::repeat_n('█', filled)
4514    .chain(std::iter::repeat_n('░', CELLS - filled))
4515    .collect();
4516  vec![
4517    Span::styled("  [", Style::default().fg(frame_color)),
4518    Span::styled(bar, Style::default().fg(filled_color).add_modifier(Modifier::BOLD)),
4519    Span::styled("] ", Style::default().fg(frame_color)),
4520    Span::styled(
4521      format!("{remaining_secs}s"),
4522      Style::default().fg(secs_color).add_modifier(Modifier::BOLD),
4523    ),
4524  ]
4525}
4526
4527/// Compute the number of filled cells for a countdown progress bar.
4528///
4529/// Contract pinned by Copilot review on PR #66:
4530/// - Returns `0` when `progress <= 0.0`.
4531/// - Returns `cells` only when `progress >= 1.0`. For any
4532///   `progress in (0.0, 1.0)`, the result is strictly less than
4533///   `cells` — the last cell stays empty so the visual "bar full"
4534///   moment lines up with the actual delete firing (not 50ms before).
4535/// - Clamps to `cells` for `progress > 1.0` (handles float drift on
4536///   an overshooting tick).
4537///
4538/// Uses `floor` rather than `round` so a progress of `0.95` paints 9
4539/// cells, not 10 — the previous `round()` behaviour painted a full bar
4540/// before the destructive action actually fired.
4541pub fn filled_cells_for_progress(progress: f64, cells: usize) -> usize {
4542  if progress >= 1.0 {
4543    return cells;
4544  }
4545  if progress <= 0.0 || cells == 0 {
4546    return 0;
4547  }
4548  let raw = (progress * cells as f64).floor() as usize;
4549  // Reserve the last cell for the progress >= 1.0 moment.
4550  raw.min(cells.saturating_sub(1))
4551}
4552
4553pub fn bootstrap_report_lines(report: Option<&BootstrapReport>, theme: &Theme) -> Vec<Line<'static>> {
4554  let mut lines: Vec<Line<'static>> = Vec::new();
4555  if let Some(report) = report {
4556    for step in &report.steps {
4557      let sigil = step.status.sigil();
4558      let color = match step.status {
4559        StepStatus::Ok => theme.clean,
4560        StepStatus::Skipped => theme.muted,
4561        StepStatus::Warning => theme.dirty,
4562        StepStatus::Failed => theme.prunable,
4563      };
4564      lines.push(Line::from(vec![
4565        Span::styled(
4566          format!(" {} ", sigil),
4567          Style::default().fg(color).add_modifier(Modifier::BOLD),
4568        ),
4569        Span::styled(step.label.clone(), Style::default().fg(theme.name)),
4570      ]));
4571      for detail_line in step.detail.lines() {
4572        lines.push(Line::from(Span::styled(
4573          format!("      {}", detail_line),
4574          Style::default().fg(theme.muted),
4575        )));
4576      }
4577    }
4578  } else {
4579    lines.push(Line::from("(no report)"));
4580  }
4581  lines
4582}
4583
4584fn draw_report(f: &mut Frame, app: &App) {
4585  let accent = app.theme.accent;
4586  let logs = bootstrap_report_lines(app.report.as_ref(), &app.theme);
4587
4588  // Size to the report length (+ border + padding), capped at 80% of the
4589  // screen so a long report stays on-screen rather than a fixed 80%-tall
4590  // box (#187).
4591  let term = f.area();
4592  let logs_height = (logs.len() as u16 + 2/* nested border */).max(3);
4593  let height = (2 /* title */ + logs_height + 2 /* gap + hint */ + 2 /* border */ + 2/* padding */)
4594    .min(term.height.saturating_mul(80) / 100);
4595  let area = centered_h(80, height, term);
4596  let block = overlay_block(accent);
4597  let inner = block.inner(area);
4598  let layout = Layout::default()
4599    .direction(Direction::Vertical)
4600    .constraints([
4601      Constraint::Length(1), // title
4602      Constraint::Length(1), // title gap
4603      Constraint::Min(3),    // logs pane
4604      Constraint::Length(1), // hint gap
4605      Constraint::Length(1), // hint
4606    ])
4607    .split(inner);
4608  f.render_widget(Clear, area);
4609  f.render_widget(block, area);
4610  f.render_widget(
4611    Paragraph::new(
4612      Line::from(Span::styled(
4613        "Bootstrap Report",
4614        Style::default().fg(accent).add_modifier(Modifier::BOLD),
4615      ))
4616      .centered(),
4617    ),
4618    layout[0],
4619  );
4620  render_section(f, layout[2], " Logs ", SectionBody::new(&logs), accent, 0, None);
4621  f.render_widget(
4622    Paragraph::new(modal_hint_for_context(
4623      HintContext::Report,
4624      &app.keymap,
4625      &app.modal_keymap,
4626      &app.theme,
4627    )),
4628    layout[4],
4629  );
4630}
4631
4632// ── PTY overlay (issue #35) ────────────────────────────────────────────────
4633
4634/// Render the embedded PTY overlay (lazygit or native terminal). The overlay
4635/// occupies ~90 % × 90 % of the terminal, centred and drawn over the list
4636/// view. The rendered PTY content fills the entire inner area of the block
4637/// so the child process gets as much screen real-estate as possible.
4638fn draw_pty_overlay(f: &mut Frame, app: &mut App) {
4639  let term = f.area();
4640  let area = centered(90, 90, term);
4641
4642  f.render_widget(Clear, area);
4643
4644  let title = match app.pty_overlay.as_ref().map(|p| (p.kind, p.finished)) {
4645    Some((PtyKind::LazyGit, _)) => " LazyGit ",
4646    Some((PtyKind::Terminal, _)) => " Terminal ",
4647    Some((PtyKind::Review, _)) => " Review ",
4648    Some((PtyKind::Exec, false)) => " Exec ",
4649    // #325: once the one-shot command exits, the title invites dismissal.
4650    Some((PtyKind::Exec, true)) => " Exec · done — press any key ",
4651    None => " Overlay ",
4652  };
4653  let block = overlay_block(app.theme.accent)
4654    .title(title)
4655    .title_alignment(ratatui::layout::Alignment::Center);
4656  let inner = block.inner(area);
4657  f.render_widget(block, area);
4658
4659  if let Some(pty) = app.pty_overlay.as_ref() {
4660    let pseudo_terminal = tui_term::widget::PseudoTerminal::new(pty.parser.screen());
4661    f.render_widget(pseudo_terminal, inner);
4662  }
4663}
4664
4665fn centered(pct_x: u16, pct_y: u16, area: Rect) -> Rect {
4666  let v = Layout::default()
4667    .direction(Direction::Vertical)
4668    .constraints([
4669      Constraint::Percentage((100 - pct_y) / 2),
4670      Constraint::Percentage(pct_y),
4671      Constraint::Percentage((100 - pct_y) / 2),
4672    ])
4673    .split(area);
4674  Layout::default()
4675    .direction(Direction::Horizontal)
4676    .constraints([
4677      Constraint::Percentage((100 - pct_x) / 2),
4678      Constraint::Percentage(pct_x),
4679      Constraint::Percentage((100 - pct_x) / 2),
4680    ])
4681    .split(v[1])[1]
4682}
4683
4684/// Center a box of an **absolute** `width`/`height` (in cells) inside `area`,
4685/// clamping each dimension to the area so an oversized modal cannot overflow
4686/// the frame. Shared by the open-menu and link-prompt modals (issue #243) and
4687/// the percentage-based [`centered_h`], unifying the three centering paths.
4688pub fn centered_abs(width: u16, height: u16, area: Rect) -> Rect {
4689  let width = width.min(area.width);
4690  let height = height.min(area.height);
4691  let x = area.x + area.width.saturating_sub(width) / 2;
4692  let y = area.y + area.height.saturating_sub(height) / 2;
4693  Rect { x, y, width, height }
4694}
4695
4696/// Centre a box of `width_pct`% width and a fixed `height` (rows) in
4697/// `area`. Unlike [`centered`], the height is absolute so an overlay can
4698/// size itself to its content rather than a fixed percentage of the
4699/// screen (#187 — the confirm modal was far taller than its few lines).
4700/// Delegates the centering arithmetic to [`centered_abs`].
4701fn centered_h(width_pct: u16, height: u16, area: Rect) -> Rect {
4702  let width = area.width.saturating_mul(width_pct) / 100;
4703  centered_abs(width, height, area)
4704}
4705
4706/// Like [`centered_h`] but also caps the width at `max_width` columns so a
4707/// form modal does not stretch edge-to-edge on a wide terminal (issue #217
4708/// — the create overlay's input surfaces spanned the whole screen).
4709fn centered_box(width_pct: u16, max_width: u16, height: u16, area: Rect) -> Rect {
4710  let height = height.min(area.height);
4711  let width = (area.width.saturating_mul(width_pct) / 100)
4712    .min(max_width)
4713    .min(area.width);
4714  let x = area.x + area.width.saturating_sub(width) / 2;
4715  let y = area.y + area.height.saturating_sub(height) / 2;
4716  Rect { x, y, width, height }
4717}
4718
4719/// A modal overlay frame: a rounded border in `color` with interior
4720/// padding on every side. Shared by every overlay (#187) so the confirm /
4721/// help / create / report / open / link / palette modals read consistently.
4722/// The title is *not* embedded in the border any more (issue #217): it
4723/// lives inside the frame as its own centred line via [`overlay_title_lines`]
4724/// so the border stays clean and no content hugs the edge. The padding
4725/// (2 cols horizontal, 1 row vertical) is the breathing room callers must
4726/// account for when sizing — inner height shrinks by 2 rows, inner width by
4727/// 4 cols, on top of the 2-cell border.
4728fn overlay_block(color: Color) -> Block<'static> {
4729  Block::default()
4730    .borders(Borders::ALL)
4731    .border_type(BorderType::Rounded)
4732    .padding(Padding::symmetric(2, 1))
4733    .border_style(Style::default().fg(color))
4734}
4735
4736/// The detached modal title: a centred bold line in `color` followed by a
4737/// blank spacer row, prepended to a modal's content so the title sits
4738/// inside the rounded frame rather than embedded in the top border
4739/// (issue #217). Returns two lines, so callers sizing to content add 2.
4740fn overlay_title_lines(title: &str, color: Color) -> Vec<Line<'static>> {
4741  vec![
4742    Line::from(Span::styled(
4743      title.to_string(),
4744      Style::default().fg(color).add_modifier(Modifier::BOLD),
4745    ))
4746    .centered(),
4747    Line::from(String::new()),
4748  ]
4749}
4750
4751/// Middle-ellipsize `s` to at most `max` display columns, keeping the
4752/// head and tail so a long path keeps both its root and the worktree
4753/// name (e.g. `~/Projects/…/feat-187-modal`). Returns `s` unchanged when
4754/// it already fits, and a lone `…` when `max` is too small to keep
4755/// anything either side. Counts by `char`, not byte, so multi-byte path
4756/// segments are not sliced mid-codepoint.
4757pub fn ellipsize_middle(s: &str, max: usize) -> String {
4758  let count = s.chars().count();
4759  if count <= max {
4760    return s.to_string();
4761  }
4762  if max <= 1 {
4763    return "…".to_string();
4764  }
4765  let keep = max - 1; // reserve one column for the ellipsis
4766  let head = keep.div_ceil(2);
4767  let tail = keep - head;
4768  let head_str: String = s.chars().take(head).collect();
4769  let tail_str: String = s.chars().skip(count - tail).collect();
4770  format!("{head_str}…{tail_str}")
4771}
4772
4773/// Clip `s` to `max` columns, neutralising what must never reach the terminal
4774/// first (issue #506).
4775///
4776/// The sanitisation is here rather than at each call site because this is the
4777/// one funnel every width-constrained cell already goes through, table rows
4778/// included, so a cell added later inherits it instead of having to remember
4779/// it. It is a no-op on ordinary text.
4780///
4781/// Measured, ratatui 0.30 drops zero-width control bytes on every render path,
4782/// but `List` and `Table` keep the `Bidi_Control` characters, which is exactly
4783/// where a branch name lands: git's ref rules refuse the ASCII controls and
4784/// `~^:?*[`, not the Unicode format characters, so a fetched ref can carry one
4785/// and a row can then read in an order the ref is not stored in.
4786///
4787/// It runs **before** the width count, so what is measured is what is drawn: a
4788/// replacement is one char for one char, but a `Bidi_Control` character can
4789/// measure zero columns where `?` measures one.
4790fn trunc(s: &str, max: usize) -> String {
4791  let s = crate::naming::sanitise_for_terminal(s);
4792  if s.chars().count() <= max {
4793    s
4794  } else {
4795    let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
4796    out.push('…');
4797    out
4798  }
4799}
4800
4801// ---- Issue/PR linking (issue #67) ---------------------------------------
4802
4803fn draw_open_menu(f: &mut Frame, app: &App) {
4804  let accent = app.theme.accent;
4805  let lines = link_open_modal_lines(app, "Open in Browser", Some(app.open_menu_selected));
4806  let height = lines.len() as u16 + 2 /* border */ + 2 /* padding */;
4807  let term = f.area();
4808  let width = link_prompt_modal_width(term.width);
4809  let area = centered_abs(width, height, term);
4810  f.render_widget(Clear, area);
4811  f.render_widget(Paragraph::new(lines).block(overlay_block(accent)), area);
4812}
4813
4814fn draw_link_prompt(f: &mut Frame, app: &App) {
4815  let accent = app.theme.accent;
4816  let lines = match app.link_prompt_stage() {
4817    LinkPromptStage::ChooseTarget => {
4818      // A vertical selectable list (#217): j/k move the highlight, Enter
4819      // links the highlighted row, i/p stay direct picks. The highlighted
4820      // row reads in the accent.
4821      let selected = app.link_prompt_selected();
4822      link_open_modal_lines(app, "Link", Some(selected))
4823    }
4824    LinkPromptStage::InputNumber => {
4825      let label = match app.link_prompt_target() {
4826        Some(super::app::LinkTarget::Issue) => "issue #",
4827        Some(super::app::LinkTarget::Pr) => "PR #",
4828        None => "#",
4829      };
4830      let mut lines = overlay_title_lines(
4831        &format!("type the {} number", label.trim_end_matches('#').trim()),
4832        accent,
4833      );
4834      lines.push(Line::from(format!("  {}{}_", label, app.link_prompt_number_input())));
4835      push_modal_hint(
4836        &mut lines,
4837        HintContext::LinkInputNumber,
4838        &app.keymap,
4839        &app.modal_keymap,
4840        &app.theme,
4841      );
4842      lines
4843    }
4844  };
4845  let height = lines.len() as u16 + 2 /* border */ + 2 /* padding */;
4846  let term = f.area();
4847  let width = link_prompt_modal_width(term.width);
4848  let area = centered_abs(width, height, term);
4849  f.render_widget(Clear, area);
4850  f.render_widget(Paragraph::new(lines).block(overlay_block(accent)), area);
4851}
4852
4853/// Magnitude heatmap for a reclaimable size (issue #325 overlay polish):
4854/// green (small) → yellow (medium) → red (large) so a big reclaim stands out
4855/// at a glance. Thresholds tuned for build artifacts (50 MiB / 500 MiB).
4856pub fn reclaim_size_color(bytes: u64, theme: &Theme) -> Color {
4857  const MIB: u64 = 1024 * 1024;
4858  if bytes >= 500 * MIB {
4859    theme.prunable
4860  } else if bytes >= 50 * MIB {
4861    theme.dirty
4862  } else {
4863    theme.clean
4864  }
4865}
4866
4867/// A nerd-font glyph matched to a reclaimable directory name (issue #334
4868/// polish) — the ecosystem the artifact belongs to (`node_modules` → node,
4869/// `target` → Rust, `vendor` → PHP, `.venv` → Python, `dist`/`build` →
4870/// package, `.cache` → archive…), falling back to a generic folder. Leading
4871/// dots are stripped so `.venv` / `.nuxt` match like `venv` / `nuxt`.
4872pub fn clean_dir_icon(rel: &str) -> &'static str {
4873  match rel.trim_start_matches('.').to_ascii_lowercase().as_str() {
4874    "node_modules" => "\u{e718}",      // nf-dev-nodejs
4875    "target" => wt_tree::WT_RUST_ICON, // nf-dev-rust
4876    "vendor" => "\u{e73d}",            // nf-dev-php
4877    "venv" | "__pycache__" | "pytest_cache" | "mypy_cache" | "tox" => "\u{e73c}", // nf-dev-python
4878    "dist" | "build" | "out" | "output" | "bin" => "\u{f487}", // nf-oct-package
4879    "cache" | "turbo" | "parcel-cache" => "\u{f187}", // nf-fa-archive
4880    "nuxt" | "next" | "svelte-kit" | "astro" | "vite" => "\u{e74e}", // nf-dev-javascript
4881    "coverage" => "\u{f201}",          // nf-fa-line_chart
4882    _ => wt_tree::WT_DIR_ICON,         // generic folder
4883  }
4884}
4885
4886/// The visible `[start, end)` slice of a `len`-item picker when at most
4887/// `max_visible` rows fit, keeping `selected` in view (centred while
4888/// scrolling). Returns the whole list when it fits (issue #325 polish).
4889pub fn picker_window(len: usize, selected: usize, max_visible: usize) -> (usize, usize) {
4890  if max_visible == 0 || len <= max_visible {
4891    return (0, len);
4892  }
4893  let half = max_visible / 2;
4894  let start = selected.saturating_sub(half).min(len - max_visible);
4895  (start, start + max_visible)
4896}
4897
4898/// Build the full-width, scrollable rows for an overlay profile picker (issue
4899/// #334 polish). Each row spans the modal's `inner` width — left-aligned so
4900/// the labels start at the same column and the selection highlight reads as a
4901/// full-width bar — and the visible window follows `selected` with
4902/// `↑ / ↓ N more` markers (centred) when the list overflows `max_visible`.
4903fn picker_lines(
4904  labels: &[&str],
4905  selected: usize,
4906  max_visible: usize,
4907  inner: usize,
4908  theme: &Theme,
4909) -> Vec<Line<'static>> {
4910  let mut out = Vec::new();
4911  if labels.is_empty() {
4912    return out;
4913  }
4914  // Width available for the label text after the ` ▸ ` marker gutter.
4915  let textw = inner.saturating_sub(3);
4916  let (start, end) = picker_window(labels.len(), selected, max_visible);
4917  if start > 0 {
4918    out.push(
4919      Line::from(Span::styled(
4920        format!("↑ {start} more"),
4921        Style::default().fg(theme.muted),
4922      ))
4923      .centered(),
4924    );
4925  }
4926  for (i, label) in labels.iter().enumerate().take(end).skip(start) {
4927    let marker = if i == selected { "▸" } else { " " };
4928    // Pad to the full inner width so the selection bar fills the whole row.
4929    let txt = format!(" {marker} {:<textw$}", ellipsize_middle(label, textw));
4930    let style = if i == selected {
4931      Style::default()
4932        .fg(theme.accent)
4933        .bg(theme.selection_bg)
4934        .add_modifier(Modifier::BOLD)
4935    } else {
4936      Style::default().fg(theme.muted)
4937    };
4938    out.push(Line::from(Span::styled(txt, style)));
4939  }
4940  if end < labels.len() {
4941    out.push(
4942      Line::from(Span::styled(
4943        format!("↓ {} more", labels.len() - end),
4944        Style::default().fg(theme.muted),
4945      ))
4946      .centered(),
4947    );
4948  }
4949  out
4950}
4951
4952/// Render the exec profile picker overlay (issue #325). A small centred
4953/// modal listing the `[exec.profiles.*]` names; the highlighted row reads in
4954/// the accent (with a selection bar) and a `▸` marker, the rest muted. The
4955/// list is aligned, same-width, and scrolls to keep the highlight in view.
4956/// `Enter` resolves the highlight and the run loop spawns it in a PTY overlay.
4957fn draw_exec_picker(f: &mut Frame, app: &App) {
4958  let accent = app.theme.accent;
4959  let term = f.area();
4960  let width = overlay_modal_width(term.width);
4961  let inner = width.saturating_sub(6) as usize; // inside borders (1) + overlay_block padding (2) each side
4962  let mut lines = overlay_title_lines("Run an exec profile", accent);
4963  // Leave room for the title + hint + borders; the picker scrolls past that.
4964  let max_visible = (term.height as usize).saturating_sub(8).max(3);
4965  let labels: Vec<&str> = app.exec_picker.profiles().iter().map(String::as_str).collect();
4966  lines.extend(picker_lines(
4967    &labels,
4968    app.exec_picker.selected_index(),
4969    max_visible,
4970    inner,
4971    &app.theme,
4972  ));
4973  push_modal_hint(
4974    &mut lines,
4975    HintContext::ExecPicker,
4976    &app.keymap,
4977    &app.modal_keymap,
4978    &app.theme,
4979  );
4980  let height = lines.len() as u16 + 2 /* border */ + 2 /* padding */;
4981  let area = centered_abs(width, height, term);
4982  f.render_widget(Clear, area);
4983  f.render_widget(Paragraph::new(lines).block(overlay_block(accent)), area);
4984}
4985
4986/// Render the generic detail overlay (issue #408). A centred modal listing
4987/// `(label, value)` rows with theme-mapped roles — agent sessions today, the
4988/// rich PR/Issue view tomorrow. Content is prebuilt state
4989/// ([`crate::tui::state::detail_overlay::DetailOverlay`]); this function
4990/// only paints it, so the render path stays pure.
4991fn draw_detail_overlay(f: &mut Frame, app: &App) {
4992  use crate::tui::state::detail_overlay::{DetailMode, DetailRole};
4993  let accent = app.theme.accent;
4994  let term = f.area();
4995  let width = overlay_modal_width(term.width);
4996  let inner = width.saturating_sub(6) as usize; // borders (1) + padding (2) each side
4997  let ov = &app.detail_overlay;
4998
4999  // CI checks filter (issue #436): palette-style query over the overlay's
5000  // own rows — the highlight follows the filtered set, Enter opens the
5001  // highlighted check's details URL. Same fixed-height frame contract as
5002  // the attach prompt below (#445: typing must not resize the window).
5003  if ov.mode == DetailMode::Input && ov.kind == crate::tui::state::detail_overlay::DetailKind::CiChecks {
5004    let matches = app.ci_input_matches();
5005    let list_h = (term.height as usize).saturating_sub(12).clamp(3, 10);
5006    let (start, end) = picker_window(matches.len(), ov.input_selected, list_h);
5007
5008    let mut lines = overlay_title_lines("Filter CI checks", accent);
5009    lines.push(Line::from(vec![
5010      Span::styled("filter: ", Style::default().fg(app.theme.muted)),
5011      Span::styled(
5012        ov.input.clone(),
5013        Style::default().fg(app.theme.name).add_modifier(Modifier::BOLD),
5014      ),
5015      Span::styled("▏", Style::default().fg(accent)),
5016    ]));
5017    lines.push(Line::from(String::new()));
5018    if matches.is_empty() {
5019      lines.push(Line::from(Span::styled(
5020        "no matching check",
5021        Style::default().fg(app.theme.muted),
5022      )));
5023    }
5024    for (i, row_idx) in matches.iter().enumerate().take(end).skip(start) {
5025      let Some(row) = ov.rows.get(*row_idx) else { continue };
5026      let label_color = match row.role {
5027        DetailRole::Success => app.theme.clean,
5028        DetailRole::Failure => app.theme.prunable,
5029        DetailRole::Running => app.theme.dirty,
5030        _ => app.theme.name,
5031      };
5032      let text = format!("{}  {}", row.label, row.value);
5033      let pad = inner.saturating_sub(text.chars().count());
5034      let mut style = Style::default().fg(label_color);
5035      if i == ov.input_selected {
5036        style = style.bg(app.theme.selection_bg).add_modifier(Modifier::BOLD);
5037      }
5038      lines.push(Line::from(Span::styled(format!("{}{}", text, " ".repeat(pad)), style)));
5039    }
5040    for _ in matches.len().min(end).saturating_sub(start)..list_h {
5041      lines.push(Line::from(String::new()));
5042    }
5043    let height = (2 + list_h + 2) as u16 + 2 /* border */ + 2 /* padding */;
5044    let area = centered_abs(width, height, term);
5045    f.render_widget(Clear, area);
5046    f.render_widget(Paragraph::new(lines).block(overlay_block(accent)), area);
5047    // Scrollbar over the LISTING sub-area (Codex review #455): the rows
5048    // start after border (1) + padding (1) + two title lines + the query
5049    // line + its blank spacer = y + 6 — anchoring at + 4 overlapped the
5050    // query and stopped short of the last rows.
5051    let list_rect = Rect {
5052      x: area.x + 1,
5053      y: area.y + 6,
5054      width: area.width.saturating_sub(2),
5055      height: list_h as u16,
5056    }
5057    .intersection(area);
5058    if list_rect.height > 0 {
5059      let _ = scrollable_body_area(f, list_rect, start as u16, matches.len(), &app.theme);
5060    }
5061    return;
5062  }
5063
5064  // Attach-by-id prompt (user feedback 2026-07-22): palette-style query
5065  // over every detected session; Enter pins the highlighted candidate.
5066  if ov.mode == DetailMode::Input {
5067    let candidates = app.agent_input_candidates();
5068    // FIXED listing height (issue #445): the window is sized by the
5069    // terminal alone, never by the filtered candidate count — typing must
5070    // not resize the frame. Short lists blank-pad the remaining rows.
5071    // Capped at 10 rows: a full-terminal prompt reads as a takeover, not
5072    // a palette (user feedback 2026-07-23); the scrollbar covers the rest.
5073    let list_h = (term.height as usize).saturating_sub(12).clamp(3, 10);
5074    let (start, end) = picker_window(candidates.len(), ov.input_selected, list_h);
5075    let now = std::time::SystemTime::now();
5076
5077    let mut lines = overlay_title_lines("Attach a session", accent);
5078    lines.push(Line::from(vec![
5079      Span::styled("id: ", Style::default().fg(app.theme.muted)),
5080      Span::styled(
5081        ov.input.clone(),
5082        Style::default().fg(app.theme.name).add_modifier(Modifier::BOLD),
5083      ),
5084      Span::styled("▏", Style::default().fg(accent)),
5085    ]));
5086    lines.push(Line::from(String::new()));
5087    if candidates.is_empty() {
5088      lines.push(Line::from(Span::styled(
5089        "no matching session",
5090        Style::default().fg(app.theme.muted),
5091      )));
5092    }
5093    for (i, sess) in candidates.iter().enumerate().take(end).skip(start) {
5094      let freshness = crate::agent_sessions::Freshness::classify(sess.last_activity, sess.ended, now);
5095      let color = match freshness {
5096        crate::agent_sessions::Freshness::Active => app.theme.clean,
5097        crate::agent_sessions::Freshness::Idle => app.theme.muted,
5098      };
5099      let identity = sess.name.as_deref().unwrap_or(&sess.id);
5100      let text = format!("{:<9} {}", sess.kind.display(), identity);
5101      let pad = inner.saturating_sub(text.chars().count());
5102      let mut style = Style::default().fg(color);
5103      let mut pad_style = Style::default();
5104      if i == ov.input_selected {
5105        style = style.bg(app.theme.selection_bg).add_modifier(Modifier::BOLD);
5106        pad_style = pad_style.bg(app.theme.selection_bg);
5107      }
5108      lines.push(Line::from(vec![
5109        Span::styled(text, style),
5110        Span::styled(" ".repeat(pad), pad_style),
5111      ]));
5112    }
5113    // Blank-pad up to the fixed window so the frame height is constant
5114    // whatever the filter matched (the empty-state line counts as one row).
5115    let shown = if candidates.is_empty() { 1 } else { end - start };
5116    for _ in shown..list_h {
5117      lines.push(Line::from(String::new()));
5118    }
5119    lines.push(Line::from(String::new()));
5120    lines.push(modal_hint_line(
5121      &[
5122        ("type", "filter"),
5123        ("↑/↓", "pick"),
5124        ("Enter", "attach"),
5125        ("Esc", "back"),
5126      ],
5127      &app.theme,
5128    ));
5129    let height = lines.len() as u16 + 2 /* border */ + 2 /* padding */;
5130    let area = centered_abs(width, height, term);
5131    f.render_widget(Clear, area);
5132    f.render_widget(Paragraph::new(lines).block(overlay_block(accent)), area);
5133    // Scrollbar over the listing sub-area when the candidates overflow the
5134    // fixed window — same affordance as the detail mode below (issue #445).
5135    // Intersected with the modal's real area: on a tiny terminal
5136    // `centered_abs` clamps the frame, and an un-clipped rect would render
5137    // past the ratatui buffer and panic (Codex review #445).
5138    let list_rect = Rect {
5139      x: area.x + 1,
5140      y: area.y + 2 /* border + padding */ + 2 /* title */ + 2, /* id line + blank */
5141      width: area.width.saturating_sub(2),
5142      height: list_h as u16,
5143    }
5144    .intersection(area);
5145    if list_rect.height > 0 {
5146      let _ = scrollable_body_area(f, list_rect, start as u16, candidates.len(), &app.theme);
5147    }
5148    return;
5149  }
5150  let total = ov.rows.len();
5151  // The modal height is derived from the VISIBLE row count, which is
5152  // constant while navigating — scrolling must never resize the frame
5153  // (user feedback 2026-07-22). The window follows the selection.
5154  let max_visible = (term.height as usize).saturating_sub(10).max(3);
5155  let visible = total.min(max_visible);
5156  let (start, end) = picker_window(total, ov.selected, visible);
5157
5158  let label_w = ov.rows.iter().map(|r| r.label.chars().count()).max().unwrap_or(0);
5159  let mut lines = overlay_title_lines(&ov.title, accent);
5160  for (i, row) in ov.rows.iter().enumerate().take(end).skip(start) {
5161    let (label_color, value_color, value_bold) = match row.role {
5162      DetailRole::Active => (app.theme.clean, app.theme.clean, true),
5163      DetailRole::Muted => (app.theme.muted, app.theme.muted, false),
5164      DetailRole::Normal => (app.theme.name, app.theme.name, false),
5165      // #436: per-check CI outcomes, same theme roles as `ci_indicator`.
5166      DetailRole::Success => (app.theme.clean, app.theme.name, false),
5167      DetailRole::Failure => (app.theme.prunable, app.theme.name, false),
5168      DetailRole::Running => (app.theme.dirty, app.theme.name, false),
5169    };
5170    // Selection paints a full-width bar (picker convention): pad the row
5171    // out to the modal's inner width so the highlight reads as one block.
5172    // The optional `extra` detail (#436: workflow · duration) sits right-
5173    // aligned inside that bar, rendered muted. Its width is RESERVED
5174    // (Codex review #455): a long check name truncates with an ellipsis
5175    // instead of pushing the detail column past the clipping edge.
5176    let mut extra: String = row.extra.as_deref().unwrap_or("").to_string();
5177    let mut extra_cols = extra.chars().count();
5178    // The detail column is bounded too (Codex review #455): on a narrow
5179    // modal an oversized workflow name would run past the clipping edge
5180    // and ratatui cuts its RIGHT end — the duration, the very info the
5181    // column carries. Truncate from the workflow side instead (leading
5182    // ellipsis, the tail survives), reserving the value a dozen columns
5183    // or its full width when shorter.
5184    if extra_cols > 0 {
5185      let reserve = row.value.chars().count().min(12);
5186      let extra_budget = inner.saturating_sub(label_w + 2 + reserve + 2);
5187      if extra_cols > extra_budget {
5188        if extra_budget == 0 {
5189          extra.clear();
5190        } else {
5191          let tail: String = extra.chars().skip(extra_cols - (extra_budget - 1)).collect();
5192          extra = format!("…{tail}");
5193        }
5194        extra_cols = extra.chars().count();
5195      }
5196    }
5197    let value_budget = inner.saturating_sub(label_w + 2 + if extra_cols > 0 { extra_cols + 2 } else { 0 });
5198    let value: String = if row.value.chars().count() > value_budget {
5199      let mut v: String = row.value.chars().take(value_budget.saturating_sub(1)).collect();
5200      v.push('…');
5201      v
5202    } else {
5203      row.value.clone()
5204    };
5205    let text_cols = label_w + 2 + value.chars().count();
5206    let pad = inner.saturating_sub(text_cols + extra_cols);
5207    let mut label_style = Style::default().fg(label_color);
5208    let mut value_style = Style::default().fg(value_color);
5209    if value_bold {
5210      value_style = value_style.add_modifier(Modifier::BOLD);
5211    }
5212    let mut pad_style = Style::default();
5213    let mut extra_style = Style::default().fg(app.theme.muted);
5214    if i == ov.selected {
5215      label_style = label_style.bg(app.theme.selection_bg).add_modifier(Modifier::BOLD);
5216      value_style = value_style.bg(app.theme.selection_bg);
5217      pad_style = pad_style.bg(app.theme.selection_bg);
5218      extra_style = extra_style.bg(app.theme.selection_bg);
5219    }
5220    lines.push(Line::from(vec![
5221      Span::styled(format!("{:label_w$}  ", row.label), label_style),
5222      Span::styled(value, value_style),
5223      Span::styled(" ".repeat(pad), pad_style),
5224      Span::styled(extra, extra_style),
5225    ]));
5226  }
5227  // #436 validation feedback: the CI checks consumer advertises ITS verbs,
5228  // not the agents' attach / detach — the hint context follows the kind.
5229  let hint_ctx = if ov.kind == crate::tui::state::detail_overlay::DetailKind::CiChecks {
5230    HintContext::CiChecks
5231  } else {
5232    HintContext::Detail
5233  };
5234  push_modal_hint(&mut lines, hint_ctx, &app.keymap, &app.modal_keymap, &app.theme);
5235  let height = (2 + visible + 2) as u16 + 2 /* border */ + 2 /* padding */;
5236  let area = centered_abs(width, height, term);
5237  f.render_widget(Clear, area);
5238  f.render_widget(Paragraph::new(lines).block(overlay_block(accent)), area);
5239  // Scrollbar over the rows sub-area (right padding column) when the list
5240  // overflows — the missing affordance from the feedback.
5241  // Intersected with the modal's real area for the same tiny-terminal
5242  // clamp as the attach prompt above (Codex review #445).
5243  let rows_rect = Rect {
5244    x: area.x + 1,
5245    y: area.y + 2 /* border + padding */ + 2, /* title lines */
5246    width: area.width.saturating_sub(2),
5247    height: visible as u16,
5248  }
5249  .intersection(area);
5250  if rows_rect.height > 0 {
5251    let _ = scrollable_body_area(f, rows_rect, start as u16, total, &app.theme);
5252  }
5253}
5254
5255/// Render the clean reclaim overlay (issue #325). A centred modal showing
5256/// the gated reclaim report for the selected worktree (per-artifact sizes +
5257/// total), the `[clean.profiles.*]` picker when configured, the gate-
5258/// preserved names, and a danger-coloured armed indicator while the safety
5259/// countdown runs. The live countdown progresses on the status bar; the
5260/// border switches to the danger colour once armed.
5261fn draw_clean_overlay(f: &mut Frame, app: &App) {
5262  let accent = app.theme.accent;
5263  let muted = app.theme.muted;
5264  let danger = app.theme.prunable;
5265  let armed = app.clean_overlay.confirm.is_armed();
5266  let border = if armed { danger } else { accent };
5267  let term = f.area();
5268  let width = overlay_modal_width(term.width);
5269  let inner = width.saturating_sub(6) as usize; // inside borders (1) + overlay_block padding (2) each side
5270
5271  let mut lines = overlay_title_lines("Reclaim build artifacts", border);
5272
5273  // Profile picker — the `(default)` choice plus any `[clean.profiles]`.
5274  // Full-width, scrollable; only rendered when named profiles exist.
5275  if app.clean_overlay.has_profiles() {
5276    let labels = app.clean_overlay.choice_labels();
5277    let max_visible = (term.height as usize).saturating_sub(14).max(3);
5278    lines.extend(picker_lines(
5279      &labels,
5280      app.clean_overlay.selected_index(),
5281      max_visible,
5282      inner,
5283      &app.theme,
5284    ));
5285    lines.push(Line::from(""));
5286  }
5287
5288  // The gated reclaim report — only the git-ignored, untracked artifacts.
5289  // Each row fills the modal's inner width: a matched nerd-font icon (#334) +
5290  // dir name pinned left, the heatmap-coloured size pinned to the right edge,
5291  // so the columns use the whole box. Capped to the modal height with a
5292  // `… N more` overflow marker.
5293  match app.clean_overlay.reclaim() {
5294    Some(reclaim) if !reclaim.artifacts.is_empty() => {
5295      // Name column = inner width minus the ` icon  ` gutter (4) and the
5296      // `<size> ` tail (11), so the size lands flush on the right edge.
5297      let namew = inner.saturating_sub(15).max(5);
5298      let row = |icon: &str, left: &str, left_style: Style, bytes: u64, size_style: Style| -> Line<'static> {
5299        Line::from(vec![
5300          Span::styled(format!(" {icon}  "), Style::default().fg(accent)),
5301          Span::styled(format!("{:<namew$}", ellipsize_middle(left, namew)), left_style),
5302          Span::styled(format!("{:>10} ", crate::clean::human_size(bytes)), size_style),
5303        ])
5304      };
5305      let max_rows = (term.height as usize).saturating_sub(14).max(3);
5306      let shown = reclaim.artifacts.len().min(max_rows);
5307      for a in reclaim.artifacts.iter().take(shown) {
5308        lines.push(row(
5309          clean_dir_icon(&a.rel),
5310          &a.rel,
5311          Style::default().fg(muted),
5312          a.bytes,
5313          Style::default().fg(reclaim_size_color(a.bytes, &app.theme)),
5314        ));
5315      }
5316      if reclaim.artifacts.len() > shown {
5317        lines.push(
5318          Line::from(Span::styled(
5319            format!("… {} more", reclaim.artifacts.len() - shown),
5320            Style::default().fg(muted),
5321          ))
5322          .centered(),
5323        );
5324      }
5325      // The total row uses an aggregate (sigma) glyph in the icon column.
5326      lines.push(row(
5327        "\u{f03a}",
5328        "total",
5329        Style::default().fg(accent).add_modifier(Modifier::BOLD),
5330        reclaim.total_bytes,
5331        Style::default()
5332          .fg(reclaim_size_color(reclaim.total_bytes, &app.theme))
5333          .add_modifier(Modifier::BOLD),
5334      ));
5335    }
5336    _ => {
5337      lines.push(
5338        Line::from("nothing to reclaim")
5339          .style(Style::default().fg(muted))
5340          .centered(),
5341      );
5342    }
5343  }
5344
5345  // Gate-preserved names — explain why a visible `target/` was not counted.
5346  for rel in app.clean_overlay.skipped() {
5347    lines.push(
5348      Line::from(format!("skipped {rel} — not git-ignored / holds tracked files"))
5349        .style(Style::default().fg(muted))
5350        .centered(),
5351    );
5352  }
5353
5354  // Danger-coloured armed indicator; the live countdown shows on the status
5355  // bar (set by `clean_confirm_press`), so the render stays time-free.
5356  if armed {
5357    lines.push(Line::from(""));
5358    lines.push(
5359      Line::from("⚠ armed — confirm again or cancel to abort")
5360        .style(Style::default().fg(danger).add_modifier(Modifier::BOLD))
5361        .centered(),
5362    );
5363  }
5364
5365  push_modal_hint(
5366    &mut lines,
5367    HintContext::Clean,
5368    &app.keymap,
5369    &app.modal_keymap,
5370    &app.theme,
5371  );
5372  let height = lines.len() as u16 + 2 /* border */ + 2 /* padding */;
5373  let area = centered_abs(width, height, term);
5374  f.render_widget(Clear, area);
5375  f.render_widget(Paragraph::new(lines).block(overlay_block(border)), area);
5376}
5377
5378/// Render the command palette overlay (issue #32).
5379///
5380/// Layout: a centered modal sized at 60% × 50% of the frame
5381/// (matches the `centered(60, 50, …)` call below). Matches list
5382/// occupies the top of the inner area, input bar is pinned to the
5383/// bottom row. The highlight follows the user's cycle key
5384/// (`Up` / `Down` / `Tab`); `Enter` fires the highlighted entry,
5385/// Worktree-rename modal (#290). Mirrors [`draw_create`] — it reuses the
5386/// same Create form state (Type / Issue / Desc) pre-filled from the current
5387/// branch — plus a `From :` line showing the original branch, an async
5388/// "renaming…" loader, and an inline failure surfaced from
5389/// `App::edit_failure`. State lives on `App::create_form` +
5390/// `App::edit_original_branch`.
5391fn draw_edit_worktree(f: &mut Frame, app: &App) {
5392  let accent = app.theme.accent;
5393  let muted = app.theme.muted;
5394  let clean = app.theme.clean;
5395  let surface = app.theme.selection_bg;
5396
5397  let (type_str, type_desc) = app
5398    .branch_types
5399    .get(app.create_form.type_index)
5400    .map(|t| (t.name.as_str(), t.description.as_str()))
5401    .unwrap_or(("", "(no branch types configured)"));
5402
5403  let block = overlay_block(clean);
5404  let term = f.area();
5405  let outer = centered_box(70, 72, 1, term);
5406  let inner_w = block.inner(outer).width as usize;
5407  let label_w = 5usize;
5408  let gutter = 2 + label_w + 2;
5409  let value_w = inner_w.saturating_sub(gutter);
5410
5411  let label = |s: &str| format!("{:<label_w$}", s);
5412  let old_branch = app
5413    .edit_original_branch
5414    .as_deref()
5415    .or_else(|| app.selected().and_then(|w| w.branch.as_deref()))
5416    .unwrap_or("(none)");
5417  let old_display = ellipsize_middle(old_branch, inner_w.saturating_sub("  From   : ".len()));
5418  let (branch_raw, dir_raw) = pattern_preview(app, type_str);
5419  let branch = ellipsize_middle(&branch_raw, inner_w.saturating_sub("  Branch : ".len()));
5420  let dirname = ellipsize_middle(&dir_raw, inner_w.saturating_sub("  Dir    : ".len()));
5421
5422  let freeform = app.create_form.mode == Mode::Freeform;
5423  let mut lines = overlay_title_lines("Rename Worktree", clean);
5424  lines.push(Line::from(vec![
5425    Span::raw("  From   : "),
5426    Span::styled(old_display, Style::default().fg(muted)),
5427  ]));
5428  lines.push(Line::from(String::new()));
5429  lines.push(Line::from(vec![
5430    Span::raw("  Branch : "),
5431    Span::styled(branch, Style::default().fg(app.theme.branch)),
5432  ]));
5433  lines.push(Line::from(vec![
5434    Span::raw("  Dir    : "),
5435    Span::styled(dirname, Style::default().fg(app.theme.dirty)),
5436  ]));
5437  if let Some(warning) = rename_pr_warning(app, &branch_raw, old_branch) {
5438    lines.push(Line::from(vec![
5439      Span::raw("  "),
5440      Span::styled(
5441        ellipsize_middle(&warning, inner_w.saturating_sub(2)),
5442        Style::default().fg(app.theme.prunable),
5443      ),
5444    ]));
5445  }
5446  lines.push(Line::from(String::new()));
5447  // Issue #479: free-form has one field and no type selector, so the rows the
5448  // structured triple needs are not rendered rather than rendered inert — the
5449  // same shape `draw_create` uses, and the reason #474 suppressed the toggle
5450  // here in the first place was that these rows did not exist. Which rows the
5451  // structured side needs comes from the patterns (#418).
5452  if freeform {
5453    lines.push(field_input_line(
5454      &label("Name"),
5455      &app.create_form.name,
5456      app.create_form.field == Field::Name,
5457      value_w,
5458      accent,
5459      muted,
5460      surface,
5461    ));
5462  } else {
5463    lines.extend(form_field_lines(app, type_str, type_desc, value_w, label_w));
5464  }
5465
5466  let height = lines.len() as u16 + 4 + 2 /* border */ + 2 /* vertical padding */;
5467  let area = centered_box(70, 72, height, term);
5468  let inner = Layout::default()
5469    .direction(Direction::Vertical)
5470    .constraints([
5471      Constraint::Min(1),    // title + form fields
5472      Constraint::Length(1), // loader / failure
5473      Constraint::Length(1), // buttons
5474      Constraint::Length(1), // hint gap
5475      Constraint::Length(1), // hint
5476    ])
5477    .split(block.inner(area));
5478
5479  f.render_widget(Clear, area);
5480  f.render_widget(block, area);
5481  f.render_widget(Paragraph::new(lines), inner[0]);
5482
5483  if app.is_edit_worktree_loading() {
5484    f.render_widget(
5485      LoaderWidget::running(
5486        app.spinner.glyph(DOT_FRAMES),
5487        TaskKind::EditWorktree.loading_label(),
5488        None,
5489        &app.theme,
5490      )
5491      .alignment(Alignment::Center),
5492      inner[1],
5493    );
5494  } else if let Some(error) = app.edit_failure.as_deref() {
5495    f.render_widget(
5496      LoaderWidget::failed("rename failed", Some(error), &app.theme).alignment(Alignment::Center),
5497      inner[1],
5498    );
5499  }
5500
5501  if !app.is_edit_worktree_loading() {
5502    f.render_widget(
5503      Paragraph::new(rename_buttons_line(accent, muted)).alignment(Alignment::Center),
5504      inner[2],
5505    );
5506    f.render_widget(
5507      Paragraph::new(modal_hint_for_context_with_fields(
5508        // One source with the statusbar (`App::rename_hint_context`), so the
5509        // footer and the bar behind it cannot disagree about the mode.
5510        app.rename_hint_context(),
5511        &app.keymap,
5512        &app.modal_keymap,
5513        &app.theme,
5514        app.create_form.fields(),
5515      )),
5516      inner[4],
5517    );
5518  }
5519}
5520
5521fn draw_command_palette(f: &mut Frame, app: &App) {
5522  let area = centered(60, 50, f.area());
5523  f.render_widget(Clear, area);
5524
5525  let accent = app.theme.accent;
5526  let outer = overlay_block(accent);
5527  let inner = outer.inner(area);
5528  f.render_widget(outer, area);
5529
5530  // Input-first layout (issue #262): a detached centred title, a blank
5531  // spacer, the `:` input field (background-filled, mirroring the New
5532  // Worktree modal's `field_input_line`), a spacer, the matches list (flex),
5533  // a hint gap, and the statusbar-style hint. The input moved to the top so
5534  // the modal reads input-then-results like the create form.
5535  let layout = Layout::default()
5536    .direction(Direction::Vertical)
5537    .constraints([
5538      Constraint::Length(1), // title
5539      Constraint::Length(1), // spacer
5540      Constraint::Length(1), // input field
5541      Constraint::Length(1), // spacer
5542      Constraint::Min(3),    // matches
5543      Constraint::Length(1), // hint gap
5544      Constraint::Length(1), // hint
5545    ])
5546    .split(inner);
5547
5548  f.render_widget(
5549    Paragraph::new(
5550      Line::from(Span::styled(
5551        "Command Palette",
5552        Style::default().fg(accent).add_modifier(Modifier::BOLD),
5553      ))
5554      .centered(),
5555    ),
5556    layout[0],
5557  );
5558
5559  // The `:` input field, styled like the create modal's fields: a `:` label
5560  // then a background-filled value box. The palette input is always focused
5561  // (the user is typing into it), so it carries the accent fill + cursor.
5562  let label = ":";
5563  let gutter = 2 + label.chars().count() + 2; // field_input_line's `  label  ` gutter
5564  let value_w = (inner.width as usize).saturating_sub(gutter);
5565  f.render_widget(
5566    Paragraph::new(field_input_line(
5567      label,
5568      app.palette.buffer(),
5569      true,
5570      value_w,
5571      accent,
5572      app.theme.muted,
5573      app.theme.selection_bg,
5574    )),
5575    layout[2],
5576  );
5577
5578  let entries = app.palette.matches();
5579  let highlight = app.palette.highlight();
5580  let mut lines: Vec<Line<'_>> = entries
5581    .iter()
5582    .enumerate()
5583    .map(|(i, entry)| {
5584      let prefix = if i == highlight { "▶ " } else { "  " };
5585      let name_style = if i == highlight {
5586        Style::default().fg(accent).add_modifier(Modifier::BOLD)
5587      } else {
5588        palette_name_style(&app.theme)
5589      };
5590      Line::from(vec![
5591        Span::raw(prefix),
5592        Span::styled(format!("{:<22}", entry.name), name_style),
5593        Span::raw("  "),
5594        Span::styled(entry.description, Style::default().fg(app.theme.muted)),
5595      ])
5596    })
5597    .collect();
5598  if lines.is_empty() {
5599    lines.push(Line::from(Span::styled(
5600      "  (no matching command — backspace to broaden)",
5601      Style::default().fg(app.theme.prunable),
5602    )));
5603  }
5604  f.render_widget(Paragraph::new(lines), layout[4]);
5605  f.render_widget(
5606    Paragraph::new(modal_hint_for_context(
5607      HintContext::CommandPalette,
5608      &app.keymap,
5609      &app.modal_keymap,
5610      &app.theme,
5611    )),
5612    layout[6],
5613  );
5614}
5615
5616/// Body of the Issue / PR sidebar block. The block title (`" Issue / PR "`)
5617/// is supplied by [`draw_sidebar`] via the surrounding `Block`, so this
5618/// function only returns the content rows. `max_width` is the inner
5619/// width of the Issue / PR block (chunk width minus 2 borders and the
5620/// 1-char left padding applied by [`render_section`]); summary lines
5621/// trim their variable parts so total visible width ≤ `max_width`.
5622pub fn github_status_lines(app: &App, max_width: usize) -> Vec<Line<'static>> {
5623  let link = app.current_link();
5624  let mut lines: Vec<Line<'static>> = Vec::new();
5625
5626  if link.issue.is_none() && link.pr.is_none() {
5627    // Derive LinkPrompt's chord from the live keymap so the hint tracks the
5628    // binding (and any `[tui.keys]` override) instead of drifting — the `L`
5629    // hardcoded pre-#290 now belongs to LazyGitFullscreen.
5630    let chord = action_chord(&app.keymap, Action::LinkPrompt, "i");
5631    lines.push(Line::from(Span::styled(
5632      trunc(&format!("no link · press {chord} to link"), max_width),
5633      Style::default().fg(app.theme.muted),
5634    )));
5635    return lines;
5636  }
5637
5638  if let Some(n) = link.issue {
5639    let spinner = app.spinner.glyph(DOT_FRAMES);
5640    lines.push(issue_summary_line_with_spinner(
5641      n,
5642      link.issue_source,
5643      app.issue_fetch_state(),
5644      PersistedSummary {
5645        title: link.issue_title.as_deref(),
5646        state: link.issue_state,
5647      },
5648      max_width,
5649      &app.theme,
5650      Some(spinner),
5651    ));
5652  }
5653  if let Some(n) = link.pr {
5654    let spinner = app.spinner.glyph(DOT_FRAMES);
5655    // #436: advertise the key that opens the CI checks overlay right after
5656    // the indicator, resolved live so a rebind shows through. The key is
5657    // context-accurate (Codex review #455): the contextual `c`
5658    // (EditWorktree's chord) only while the status pane holds the focus —
5659    // in the worktrees context that key opens the rename modal, so the
5660    // global `ci_checks` binding is advertised instead. An unbound
5661    // EditWorktree falls back to the global binding (still live in that
5662    // context); only when both are unbound does the suffix disappear. In
5663    // picker mode (`gwm switch`) run_action drops Action::CiChecks —
5664    // printable keys feed the filter — so no key is advertised at all.
5665    let ci_key = if app.picker_mode {
5666      None
5667    } else if app.sidebar.open && app.sidebar.focused {
5668      app
5669        .keymap
5670        .primary_chord(Action::EditWorktree)
5671        .or_else(|| app.keymap.primary_chord(Action::CiChecks))
5672    } else {
5673      app.keymap.primary_chord(Action::CiChecks)
5674    };
5675    lines.push(pr_summary_line_with_spinner(
5676      n,
5677      link.pr_source,
5678      app.pr_fetch_state(),
5679      PersistedSummary {
5680        title: link.pr_title.as_deref(),
5681        state: link.pr_state,
5682      },
5683      max_width,
5684      &app.theme,
5685      Some(spinner),
5686      ci_key.as_deref(),
5687    ));
5688  }
5689  lines
5690}
5691
5692/// Nerdfont glyph leading the pane's Issue line (issue #283):
5693/// `nf-oct-issue_opened`.
5694pub const ISSUE_ICON: &str = "\u{f41b}";
5695/// Nerdfont glyph leading the pane's PR line (issue #283):
5696/// `nf-oct-git_pull_request`.
5697pub const PR_ICON: &str = "\u{f407}";
5698
5699/// Nerdfont glyphs for the per-PR CI indicator (issue #299):
5700/// `nf-oct-check` (passing), `nf-oct-x` (failing), `nf-oct-sync` (running).
5701pub const CI_PASSING_ICON: &str = "\u{f42e}";
5702pub const CI_FAILING_ICON: &str = "\u{f467}";
5703pub const CI_RUNNING_ICON: &str = "\u{f46a}";
5704/// A check whose state this build does not recognise (issue #419) — most
5705/// likely a GitLab pipeline status added upstream. Rendered distinctly so
5706/// it never passes for green, and never for a running check with a clock.
5707pub const CI_UNKNOWN_ICON: &str = "\u{f059}";
5708
5709/// The pane's source chip (issue #283): `auto` for a branch-name inference,
5710/// `detected` for a live GitHub match. Explicit / none carry no chip — the
5711/// number already speaks for an explicit link. Rendered version-badge style
5712/// (reverse-video [`chip_style`]); `auto` stays muted, `detected` takes the
5713/// accent so a freshly-found PR draws the eye.
5714fn source_chip(s: LinkSource, theme: &Theme) -> Option<(&'static str, Color)> {
5715  match s {
5716    LinkSource::BranchName => Some(("auto", theme.muted)),
5717    LinkSource::Detected => Some(("detected", theme.accent)),
5718    LinkSource::Explicit | LinkSource::None => None,
5719  }
5720}
5721
5722/// Collapse a multi-span line into a single truncated plain span when the
5723/// styled spans together overflow `max_width`. Used by the pane's narrow
5724/// fallback so the icon + chips never push the line past the block border
5725/// (issue #283). Width is counted in display columns (`chars().count()`),
5726/// matching the budget arithmetic in [`summary_line`].
5727fn flatten_if_overflow(spans: &mut Vec<Span<'static>>, max_width: usize) {
5728  let w: usize = spans.iter().map(|s| s.content.chars().count()).sum();
5729  if w > max_width {
5730    let raw: String = spans.iter().map(|s| s.content.as_ref()).collect();
5731    *spans = vec![Span::raw(trunc(&raw, max_width))];
5732  }
5733}
5734
5735/// Render the Loaded / Idle / Loading / Error variants for an issue link
5736/// row in the sidebar. `max_width` is the number of columns the line is
5737/// allowed to occupy (sidebar inner width minus padding); the variable
5738/// part (title or error blob) is trimmed so the total visible width
5739/// stays ≤ `max_width`. Fixed elements (head, badge) are preserved.
5740pub fn issue_summary_line(
5741  n: u64,
5742  src: LinkSource,
5743  state: &GitHubFetchState<crate::github::IssueStatus>,
5744  max_width: usize,
5745  theme: &Theme,
5746) -> Line<'static> {
5747  issue_summary_line_with_spinner(n, src, state, PersistedSummary::none(), max_width, theme, None)
5748}
5749
5750#[derive(Clone, Copy)]
5751struct PersistedSummary<'a, S> {
5752  title: Option<&'a str>,
5753  state: Option<S>,
5754}
5755
5756impl<S> PersistedSummary<'_, S> {
5757  fn none() -> Self {
5758    Self {
5759      title: None,
5760      state: None,
5761    }
5762  }
5763}
5764
5765/// Resolved render inputs for a GitHub summary line, after the caller has
5766/// collapsed the issue/PR-specific `match` into the shared shape. The
5767/// `Loaded` arm carries the already-picked badge label + colour and an
5768/// optional `trailing` segment (issue: empty; PR: ` · checks N/M`) placed
5769/// between the closing `]` and the final space+title.
5770enum SummaryState<'a> {
5771  Idle,
5772  CachedTitle {
5773    title: &'a str,
5774  },
5775  CachedStatus {
5776    badge: &'a str,
5777    badge_color: Color,
5778    trailing: String,
5779    /// Colour for the `trailing` segment, e.g. the CI indicator
5780    /// (issue #299). `None` paints it with the default foreground.
5781    trailing_color: Option<Color>,
5782    title: &'a str,
5783  },
5784  Loading,
5785  Loaded {
5786    badge: &'a str,
5787    badge_color: Color,
5788    trailing: String,
5789    /// See [`SummaryState::CachedStatus::trailing_color`].
5790    trailing_color: Option<Color>,
5791    title: &'a str,
5792  },
5793  Error(&'a str),
5794}
5795
5796/// Shared renderer behind [`issue_summary_line`] and [`pr_summary_line`]
5797/// (issue #283). Both twins pass their leading nerdfont `icon`, their `head`
5798/// identity ("Issue #…" / "PR    #…"), the link `source`, and — for `Loaded`
5799/// — a state `badge` + optional `trailing` segment. Every line leads with
5800/// `<icon> <head>`, then an optional version-badge-style source chip
5801/// (`auto` / `detected`), then the state-specific tail.
5802///
5803/// `trailing` keeps the two twins identical past the badge: issue passes ""
5804/// → renders ` badge  title`; PR passes ` · checks 1/2` → renders
5805/// ` badge · checks 1/2 title`. Widths are counted in display columns (the
5806/// `·` is U+00B7: 1 column) so the budget arithmetic holds.
5807/// Render a `trailing` segment, styling it with `color` when present
5808/// (the CI indicator, issue #299) and falling back to the default
5809/// foreground otherwise (the legacy uncoloured `· checks N/M`).
5810fn trailing_span(trailing: String, color: Option<Color>) -> Span<'static> {
5811  match color {
5812    Some(c) => Span::styled(trailing, Style::default().fg(c)),
5813    None => Span::raw(trailing),
5814  }
5815}
5816
5817fn summary_line(
5818  icon: &str,
5819  head: String,
5820  source: LinkSource,
5821  state: SummaryState,
5822  max_width: usize,
5823  theme: &Theme,
5824  spinner: Option<&str>,
5825) -> Line<'static> {
5826  // `<icon> <head>` plus an optional source chip are common to every state.
5827  // `prefix_w` tracks the visible width so the variable tail (title / error
5828  // blob) can be trimmed to fit `max_width`.
5829  let icon_seg = format!("{}  ", icon); // glyph + two trailing gaps
5830  let chip = source_chip(source, theme);
5831  // Source chip segment = " " + " <label> " (a leading gap + the padded chip).
5832  let source_seg_w = chip.map(|(l, _)| 1 + l.chars().count() + 2).unwrap_or(0);
5833  let prefix_w = icon_seg.chars().count() + head.chars().count() + source_seg_w;
5834
5835  // The `head` carries no status signal, only identity — it paints with the
5836  // `name` role (issue #210); the icon mirrors loaded state colour and falls
5837  // back to muted while no fresh status exists.
5838  let icon_color = match &state {
5839    SummaryState::CachedStatus { badge_color, .. } | SummaryState::Loaded { badge_color, .. } => *badge_color,
5840    SummaryState::Idle | SummaryState::CachedTitle { .. } | SummaryState::Loading | SummaryState::Error(_) => {
5841      theme.muted
5842    }
5843  };
5844  let build_prefix = |head_bold: bool| -> Vec<Span<'static>> {
5845    let head_style = if head_bold {
5846      Style::default().fg(theme.name).add_modifier(Modifier::BOLD)
5847    } else {
5848      Style::default().fg(theme.name)
5849    };
5850    let mut spans = vec![
5851      Span::styled(icon_seg.clone(), Style::default().fg(icon_color)),
5852      Span::styled(head.clone(), head_style),
5853    ];
5854    if let Some((label, color)) = chip {
5855      spans.push(Span::raw(" "));
5856      spans.push(Span::styled(format!(" {} ", label), chip_style(color)));
5857    }
5858    spans
5859  };
5860
5861  match state {
5862    SummaryState::Idle => {
5863      let mut spans = build_prefix(false);
5864      flatten_if_overflow(&mut spans, max_width);
5865      Line::from(spans)
5866    }
5867    SummaryState::CachedTitle { title } => {
5868      let fixed = prefix_w + 1;
5869      let budget = max_width.saturating_sub(fixed);
5870      let mut spans = build_prefix(false);
5871      spans.push(Span::raw(" "));
5872      spans.push(Span::raw(trunc(title, budget)));
5873      flatten_if_overflow(&mut spans, max_width);
5874      Line::from(spans)
5875    }
5876    SummaryState::CachedStatus {
5877      badge,
5878      badge_color,
5879      trailing,
5880      trailing_color,
5881      title,
5882    } => {
5883      let badge_seg_w = 1 + badge.chars().count() + 2;
5884      let fixed = prefix_w + badge_seg_w + trailing.chars().count() + 1;
5885      if fixed >= max_width {
5886        let mut spans = build_prefix(true);
5887        spans.push(Span::raw(" "));
5888        spans.push(Span::raw(format!(" {} ", badge)));
5889        spans.push(trailing_span(trailing, trailing_color));
5890        flatten_if_overflow(&mut spans, max_width);
5891        return Line::from(spans);
5892      }
5893      let budget = max_width - fixed;
5894      let mut spans = build_prefix(true);
5895      spans.push(Span::raw(" "));
5896      spans.push(Span::styled(format!(" {} ", badge), chip_style(badge_color)));
5897      spans.push(trailing_span(trailing, trailing_color));
5898      spans.push(Span::raw(" "));
5899      spans.push(Span::raw(trunc(title, budget)));
5900      Line::from(spans)
5901    }
5902    SummaryState::Loading => {
5903      let glyph = spinner.unwrap_or("…");
5904      let mut spans = build_prefix(false);
5905      spans.push(Span::raw(format!(" {} loading", glyph)));
5906      flatten_if_overflow(&mut spans, max_width);
5907      Line::from(spans)
5908    }
5909    SummaryState::Loaded {
5910      badge,
5911      badge_color,
5912      trailing,
5913      trailing_color,
5914      title,
5915    } => {
5916      // Tail fixed cost past the prefix = " " + " <badge> " + trailing + " ".
5917      let badge_seg_w = 1 + badge.chars().count() + 2;
5918      let fixed = prefix_w + badge_seg_w + trailing.chars().count() + 1;
5919      if fixed >= max_width {
5920        // Very narrow pane: keep the prefix + badge, drop the title, and
5921        // flatten to fit rather than overflow the block border.
5922        let mut spans = build_prefix(true);
5923        spans.push(Span::raw(" "));
5924        spans.push(Span::raw(format!(" {} ", badge)));
5925        spans.push(trailing_span(trailing, trailing_color));
5926        flatten_if_overflow(&mut spans, max_width);
5927        return Line::from(spans);
5928      }
5929      let budget = max_width - fixed;
5930      let mut spans = build_prefix(true);
5931      spans.push(Span::raw(" "));
5932      spans.push(Span::styled(format!(" {} ", badge), chip_style(badge_color)));
5933      spans.push(trailing_span(trailing, trailing_color));
5934      spans.push(Span::raw(" "));
5935      spans.push(Span::raw(trunc(title, budget)));
5936      Line::from(spans)
5937    }
5938    SummaryState::Error(e) => {
5939      let fixed = prefix_w + 2; // " " + "!"
5940      let budget = max_width.saturating_sub(fixed);
5941      let mut spans = build_prefix(false);
5942      spans.push(Span::raw(" "));
5943      spans.push(Span::styled(
5944        format!("!{}", trunc(e, budget)),
5945        Style::default().fg(theme.prunable),
5946      ));
5947      flatten_if_overflow(&mut spans, max_width);
5948      Line::from(spans)
5949    }
5950  }
5951}
5952
5953fn issue_summary_line_with_spinner(
5954  n: u64,
5955  src: LinkSource,
5956  state: &GitHubFetchState<crate::github::IssueStatus>,
5957  persisted: PersistedSummary<'_, IssueState>,
5958  max_width: usize,
5959  theme: &Theme,
5960  spinner: Option<&str>,
5961) -> Line<'static> {
5962  let head = format!("Issue #{}", n);
5963  let resolved = match state {
5964    GitHubFetchState::Idle => match persisted.state {
5965      Some(state) => {
5966        let badge = match state {
5967          IssueState::Open => "open",
5968          IssueState::Closed => "closed",
5969        };
5970        SummaryState::CachedStatus {
5971          badge,
5972          badge_color: issue_badge_color(state, theme),
5973          trailing: String::new(),
5974          trailing_color: None,
5975          title: persisted.title.unwrap_or(""),
5976        }
5977      }
5978      None => persisted
5979        .title
5980        .map(|title| SummaryState::CachedTitle { title })
5981        .unwrap_or(SummaryState::Idle),
5982    },
5983    GitHubFetchState::Loading => match persisted.state {
5984      Some(state) => {
5985        let badge = match state {
5986          IssueState::Open => "open",
5987          IssueState::Closed => "closed",
5988        };
5989        SummaryState::CachedStatus {
5990          badge,
5991          badge_color: issue_badge_color(state, theme),
5992          trailing: format!(" · {} loading", spinner.unwrap_or("…")),
5993          trailing_color: None,
5994          title: persisted.title.unwrap_or(""),
5995        }
5996      }
5997      None => SummaryState::Loading,
5998    },
5999    GitHubFetchState::Loaded(s) => {
6000      // Mirror `issue_badge_color` exactly so the summary line and the
6001      // sidebar header dot never disagree for the same issue: closed maps
6002      // to `locked` ("moved on"), not `prunable` ("alarming"). Pre-#170
6003      // this site hard-coded `Color::Red` while the dot used `Magenta` —
6004      // a latent inconsistency the audit closes (Copilot review #209).
6005      let badge = match s.state {
6006        IssueState::Open => "open",
6007        IssueState::Closed => "closed",
6008      };
6009      SummaryState::Loaded {
6010        badge,
6011        badge_color: issue_badge_color(s.state, theme),
6012        trailing: String::new(),
6013        trailing_color: None,
6014        title: &s.title,
6015      }
6016    }
6017    GitHubFetchState::Error(e) => SummaryState::Error(e),
6018  };
6019  summary_line(ISSUE_ICON, head, src, resolved, max_width, theme, spinner)
6020}
6021
6022/// Render the Loaded / Idle / Loading / Error variants for a PR link
6023/// row in the sidebar. See [`issue_summary_line`] for the `max_width`
6024/// contract — same idea, with a coloured CI indicator ([`ci_indicator`],
6025/// issue #299) squeezed in between badge and title when the rollup is
6026/// non-empty.
6027pub fn pr_summary_line(
6028  n: u64,
6029  src: LinkSource,
6030  state: &GitHubFetchState<crate::github::PrStatus>,
6031  max_width: usize,
6032  theme: &Theme,
6033  ci_hint: Option<&str>,
6034) -> Line<'static> {
6035  pr_summary_line_with_spinner(n, src, state, PersistedSummary::none(), max_width, theme, None, ci_hint)
6036}
6037
6038#[allow(clippy::too_many_arguments)] // one arg past the limit; splitting a builder for a single call site is worse
6039fn pr_summary_line_with_spinner(
6040  n: u64,
6041  src: LinkSource,
6042  state: &GitHubFetchState<crate::github::PrStatus>,
6043  persisted: PersistedSummary<'_, PrState>,
6044  max_width: usize,
6045  theme: &Theme,
6046  spinner: Option<&str>,
6047  ci_hint: Option<&str>,
6048) -> Line<'static> {
6049  let head = format!("PR    #{}", n);
6050  let resolved = match state {
6051    GitHubFetchState::Idle => match persisted.state {
6052      Some(state) => {
6053        let badge = match state {
6054          PrState::Open => "open",
6055          PrState::Draft => "draft",
6056          PrState::Closed => "closed",
6057          PrState::Merged => "merged",
6058        };
6059        SummaryState::CachedStatus {
6060          badge,
6061          badge_color: pr_badge_color(state, theme),
6062          trailing: String::new(),
6063          trailing_color: None,
6064          title: persisted.title.unwrap_or(""),
6065        }
6066      }
6067      None => persisted
6068        .title
6069        .map(|title| SummaryState::CachedTitle { title })
6070        .unwrap_or(SummaryState::Idle),
6071    },
6072    GitHubFetchState::Loading => match persisted.state {
6073      Some(state) => {
6074        let badge = match state {
6075          PrState::Open => "open",
6076          PrState::Draft => "draft",
6077          PrState::Closed => "closed",
6078          PrState::Merged => "merged",
6079        };
6080        SummaryState::CachedStatus {
6081          badge,
6082          badge_color: pr_badge_color(state, theme),
6083          trailing: format!(" · {} loading", spinner.unwrap_or("…")),
6084          trailing_color: None,
6085          title: persisted.title.unwrap_or(""),
6086        }
6087      }
6088      None => SummaryState::Loading,
6089    },
6090    GitHubFetchState::Loaded(s) => {
6091      // Route the badge colour through `pr_badge_color` (mirroring how the
6092      // issue side calls `issue_badge_color`) so the summary line and the
6093      // sidebar header dot never disagree for the same PR. Only the label
6094      // stays inline. Pre-#239 this site duplicated the colour map (Copilot
6095      // review #209).
6096      let badge = match s.state {
6097        PrState::Open => "open",
6098        PrState::Draft => "draft",
6099        PrState::Closed => "closed",
6100        PrState::Merged => "merged",
6101      };
6102      // Issue #299: surface the derived CI state (icon + label + N/M, coloured)
6103      // instead of the bare ` · checks N/M`, so pass / fail / running reads at a
6104      // glance. `ci_indicator` returns `None` when the PR has no checks.
6105      // #436 validation feedback: the indicator ends with the resolved key
6106      // that opens the CI checks overlay, mirroring the pane titles' `[F]`.
6107      let (trailing, trailing_color) = match ci_indicator(s.ci, s.checks_passed, s.checks_total, theme) {
6108        Some((text, color)) => (
6109          match ci_hint {
6110            Some(key) => format!("{text} [{key}]"),
6111            None => text,
6112          },
6113          Some(color),
6114        ),
6115        None => (String::new(), None),
6116      };
6117      SummaryState::Loaded {
6118        badge,
6119        badge_color: pr_badge_color(s.state, theme),
6120        trailing,
6121        trailing_color,
6122        title: &s.title,
6123      }
6124    }
6125    GitHubFetchState::Error(e) => SummaryState::Error(e),
6126  };
6127  summary_line(PR_ICON, head, src, resolved, max_width, theme, spinner)
6128}
6129
6130// ---- Issue #73: lazygit-style colour helpers -------------------------------
6131// Pure functions exposed at the crate boundary so the table-driven tests
6132// in `tests/tui_app_tests.rs` can pin the visual contract without spinning
6133// up a real terminal. Anything that takes `BranchStatus` / `PrState` /
6134// `IssueState` / a `Duration` and returns a `Color` belongs here.
6135
6136/// Pick a colour for a branch name based on its `BranchStatus`. Worst
6137/// signal wins so the most actionable state stays visible at a glance.
6138/// Priority (top down): `unknown` → `dirty` → `ahead/behind` → no
6139/// upstream → synced/clean. Mirrors lazygit's branches view scheme
6140/// (`pkg/gui/presentation/branches.go::getBranchDisplayStrings`) with
6141/// one local addition: `dirty` lands on red because for a worktree
6142/// manager the most actionable "do something" signal is uncommitted
6143/// work.
6144pub fn branch_name_color(s: &BranchStatus, theme: &Theme) -> Color {
6145  if s.unknown {
6146    return theme.muted;
6147  }
6148  if s.is_dirty {
6149    return theme.prunable;
6150  }
6151  if s.ahead > 0 || s.behind > 0 {
6152    return theme.dirty;
6153  }
6154  if !s.has_upstream {
6155    // Lazygit's `?` marker — branch never pushed yet. Distinct from
6156    // synced so the user knows whether they need to run `git push`.
6157    return theme.locked;
6158  }
6159  theme.branch
6160}
6161
6162/// Map a branch age to a freshness colour: green < 7d, yellow < 30d,
6163/// darkgray otherwise. Cutoffs are wide on purpose — a 6-day branch
6164/// is "fresh", a 4-week one is "ageing", a 5-week one is "stale" —
6165/// so the colour shift registers as signal rather than noise.
6166pub fn freshness_color(age: Duration, theme: &Theme) -> Color {
6167  const WEEK: u64 = 7 * 86_400;
6168  const MONTH: u64 = 30 * 86_400;
6169  let s = age.as_secs();
6170  if s < WEEK {
6171    theme.clean
6172  } else if s < MONTH {
6173    theme.dirty
6174  } else {
6175    theme.muted
6176  }
6177}
6178
6179/// Pick a colour for the PR-status dot rendered in the sidebar header.
6180/// Ports the lazygit `WithPrColor` palette (open=green, draft=gray,
6181/// merged=magenta, closed=red) but uses 16-colour names instead of
6182/// hex RGB so the badge respects the user's terminal theme.
6183pub fn pr_badge_color(state: PrState, theme: &Theme) -> Color {
6184  match state {
6185    PrState::Open => theme.clean,
6186    PrState::Draft => theme.muted,
6187    PrState::Merged => theme.locked,
6188    PrState::Closed => theme.prunable,
6189  }
6190}
6191
6192/// Build the CI indicator segment for a loaded PR (issue #299): a nerd-font
6193/// glyph + short label + `passed/total` count, plus the theme colour it
6194/// paints with. Returns `None` for [`CiState::None`] so a PR with no checks
6195/// renders nothing. Colours reuse the status-dot roles already used elsewhere
6196/// in the sidebar: passing → `clean` (green), failing → `prunable` (red),
6197/// running → `dirty` (yellow). The leading space keeps it flush against the
6198/// preceding badge, mirroring the old ` · checks N/M` trailing.
6199pub fn ci_indicator(ci: CiState, passed: u32, total: u32, theme: &Theme) -> Option<(String, Color)> {
6200  let (icon, label, color) = match ci {
6201    CiState::None => return None,
6202    CiState::Passing => (CI_PASSING_ICON, "passing", theme.clean),
6203    CiState::Failing => (CI_FAILING_ICON, "failing", theme.prunable),
6204    CiState::Running => (CI_RUNNING_ICON, "running", theme.dirty),
6205  };
6206  Some((format!(" {} CI {} {}/{}", icon, label, passed, total), color))
6207}
6208
6209/// Same idea as [`pr_badge_color`] but for a linked issue. Closed maps
6210/// to magenta (treated as "moved on") rather than red so a routinely
6211/// resolved issue doesn't read as alarming.
6212pub fn issue_badge_color(state: IssueState, theme: &Theme) -> Color {
6213  match state {
6214    IssueState::Open => theme.clean,
6215    IssueState::Closed => theme.locked,
6216  }
6217}
6218
6219/// Build the table's first-column marker (issue #283). The main worktree
6220/// keeps its single `★` (painted with the `main` role, preserving the
6221/// pre-#73 convention). Every other row renders two Issue/PR slots:
6222///
6223/// - left = **Issue** — `●` with the loaded issue-state colour when known,
6224///   `●` in `clean` green when only a link is known, else `-` in white.
6225/// - right = **PR** — `●` with the loaded PR-state colour when known, `●`
6226///   in `locked` violet when only a link is known, else `-` in white.
6227/// - a `muted` `/` separates them.
6228///
6229/// The table is normally the no-fetch read path. Once GitHub status has been
6230/// fetched for linked rows, their snapshots carry loaded states so
6231/// the Issue/PR pastilles can mirror open/closed/draft/merged without a
6232/// per-frame `gh` call. A detected PR shows here on every row only because it
6233/// is persisted to `gwm-pr-detected` (#283) and read back by
6234/// [`crate::github::read_link`].
6235pub fn table_marker(w: &WorktreeInfo, theme: &Theme) -> Line<'static> {
6236  if w.is_main {
6237    return Line::from(Span::styled("★", Style::default().fg(theme.main)));
6238  }
6239  // An empty slot stays `name`-white so "no link" reads as a neutral
6240  // placeholder rather than borrowing a status colour that would claim the
6241  // row. A linked slot takes its accent unless a live loaded state exists.
6242  let issue_color = match (w.link.issue, w.issue_state) {
6243    (Some(_), Some(state)) => issue_badge_color(state, theme),
6244    (Some(_), None) => theme.clean,
6245    (None, _) => theme.name,
6246  };
6247  let pr_color = match (w.link.pr, w.pr_state) {
6248    (Some(_), Some(state)) => pr_badge_color(state, theme),
6249    (Some(_), None) => theme.locked,
6250    (None, _) => theme.name,
6251  };
6252  Line::from(vec![
6253    Span::styled(
6254      if w.link.issue.is_some() { "●" } else { "-" },
6255      Style::default().fg(issue_color),
6256    ),
6257    Span::styled("/", Style::default().fg(theme.muted)),
6258    Span::styled(
6259      if w.link.pr.is_some() { "●" } else { "-" },
6260      Style::default().fg(pr_color),
6261    ),
6262  ])
6263}