Skip to main content

gwm/tui/
ui.rs

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