use super::app::{App, GitHubFetchState, LinkPromptStage, LinkTarget, View};
use super::keymap::{Action, KeyStroke, Keymap};
use super::modal_keymap::{KeyContext, ModalAction, ModalKeymap};
use super::state::async_task::TaskKind;
use super::state::config_panel::{FieldKind, SettingField, SettingsTab};
use super::state::confirm::ConfirmButton;
use super::state::create_form::{Field, Mode};
const CANONICAL_TRIPLE: [Field; 3] = [Field::Type, Field::Issue, Field::Desc];
use super::state::pty_overlay::PtyKind;
use super::state::sidebar::SidebarMode;
use super::state::spinner::DOT_FRAMES;
use super::theme::Theme;
use super::wt_tree::{self, working_tree_category, WtCategory, WtNode, WT_DIR_OPEN_ICON};
use crate::bootstrap::{BootstrapReport, StepStatus};
use crate::command_log::CommandStatus;
use crate::config::ConfigSource;
use crate::github::{CiState, IssueState, LinkSource, PrState};
use crate::worktree::{self, BranchStatus, WorktreeInfo};
use ratatui::{
buffer::Buffer,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{
Block, BorderType, Borders, Cell, Clear, Padding, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState,
Table, Widget, Wrap,
},
Frame,
};
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Default)]
pub struct SidebarSections {
pub worktree: Vec<Line<'static>>,
pub working_tree: Vec<Line<'static>>,
pub working_tree_counts: WorkingTreeCounts,
pub recent_commits: Vec<Line<'static>>,
}
#[derive(Debug, Clone, Copy)]
pub enum LoaderWidgetState<'a> {
Running {
glyph: &'a str,
label: &'a str,
detail: Option<&'a str>,
},
Failed {
message: &'a str,
detail: Option<&'a str>,
},
}
#[derive(Debug, Clone, Copy)]
pub struct LoaderWidget<'a> {
state: LoaderWidgetState<'a>,
accent: Color,
text: Color,
muted: Color,
failed: Color,
alignment: Alignment,
}
impl<'a> LoaderWidget<'a> {
pub fn running(glyph: &'a str, label: &'a str, detail: Option<&'a str>, theme: &Theme) -> Self {
Self {
state: LoaderWidgetState::Running { glyph, label, detail },
accent: theme.accent,
text: theme.name,
muted: theme.muted,
failed: theme.prunable,
alignment: Alignment::Left,
}
}
pub fn failed(message: &'a str, detail: Option<&'a str>, theme: &Theme) -> Self {
Self {
state: LoaderWidgetState::Failed { message, detail },
accent: theme.accent,
text: theme.name,
muted: theme.muted,
failed: theme.prunable,
alignment: Alignment::Left,
}
}
pub fn alignment(mut self, alignment: Alignment) -> Self {
self.alignment = alignment;
self
}
fn line(self) -> Line<'static> {
let mut spans = match self.state {
LoaderWidgetState::Running { glyph, label, .. } => vec![
Span::styled(
format!("{glyph} "),
Style::default().fg(self.accent).add_modifier(Modifier::BOLD),
),
Span::styled(
label.to_string(),
Style::default().fg(self.text).add_modifier(Modifier::BOLD),
),
],
LoaderWidgetState::Failed { message, .. } => vec![
Span::styled("! ", Style::default().fg(self.failed).add_modifier(Modifier::BOLD)),
Span::styled(
message.to_string(),
Style::default().fg(self.failed).add_modifier(Modifier::BOLD),
),
],
};
let detail = match self.state {
LoaderWidgetState::Running { detail, .. } | LoaderWidgetState::Failed { detail, .. } => detail,
};
if let Some(detail) = detail {
spans.push(Span::styled(" — ", Style::default().fg(self.muted)));
spans.push(Span::styled(detail.to_string(), Style::default().fg(self.muted)));
}
Line::from(spans)
}
}
impl Widget for LoaderWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
Paragraph::new(self.line()).alignment(self.alignment).render(area, buf);
}
}
pub fn draw(f: &mut Frame, app: &mut App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0), Constraint::Length(1)])
.split(f.area());
draw_header(f, chunks[0], app);
draw_body(f, chunks[1], app);
draw_footer(f, chunks[2], app);
match app.view {
View::Help => draw_help(f, app),
View::Create => draw_create(f, app),
View::Confirm => draw_confirm(f, app),
View::Report => draw_report(f, app),
View::OpenMenu => draw_open_menu(f, app),
View::LinkPrompt => draw_link_prompt(f, app),
View::CommandPalette => draw_command_palette(f, app),
View::CommandLogs => draw_command_logs(f, app),
View::Config => draw_config_panel(f, app),
View::Pty => draw_pty_overlay(f, app),
View::ExecPicker => draw_exec_picker(f, app),
View::CleanReport => draw_clean_overlay(f, app),
View::Edit => draw_edit_worktree(f, app),
View::DetailOverlay => draw_detail_overlay(f, app),
View::List => {}
}
}
pub fn header_line(
repo_name: &str,
workdir_display: &str,
picker_mode: bool,
width: usize,
theme: &Theme,
) -> Line<'static> {
if width == 0 {
return Line::default();
}
let sanitize = |s: &str| -> String { s.chars().map(|c| if c.is_control() { ' ' } else { c }).collect() };
let repo = sanitize(repo_name);
let path = sanitize(workdir_display);
let version_style = chip_style(theme.accent);
let dir_badge_style = chip_style(theme.name);
let picker_style = chip_style(theme.dirty);
let path_style = Style::default().fg(theme.muted);
let version_text = format!(" gwm {} ", env!("CARGO_PKG_VERSION"));
let version_w = version_text.chars().count();
let dir_text = format!(" {} ", repo);
let dir_w = dir_text.chars().count();
if width < version_w {
return Line::from(Span::styled(trunc(&version_text, width), version_style));
}
let mut spans: Vec<Span<'static>> = Vec::new();
let mut used = 0usize;
let dir_budget = width.saturating_sub(version_w + 1);
if dir_w <= dir_budget {
spans.push(Span::styled(dir_text, dir_badge_style));
used += dir_w;
} else if dir_budget > 0 {
let clipped = trunc(&dir_text, dir_budget);
used += clipped.chars().count();
spans.push(Span::styled(clipped, dir_badge_style));
}
if picker_mode {
let picker_text = " picker ".to_string();
let need = 1 + picker_text.chars().count(); if used + need + version_w < width {
spans.push(Span::raw(" "));
spans.push(Span::styled(picker_text, picker_style));
used += need;
}
}
let path_gap = 2usize;
if used + path_gap + version_w < width {
let avail = width - used - path_gap - version_w;
let path_disp = trunc(&path, avail);
if !path_disp.is_empty() {
let w = path_disp.chars().count();
spans.push(Span::raw(" "));
spans.push(Span::styled(path_disp, path_style));
used += path_gap + w;
}
}
let pad = width.saturating_sub(used + version_w);
if pad > 0 {
spans.push(Span::raw(" ".repeat(pad)));
}
spans.push(Span::styled(version_text, version_style));
Line::from(spans)
}
fn draw_body(f: &mut Frame, area: Rect, app: &mut App) {
use super::state::sidebar::ResolvedSidebarLayout as Resolved;
let layout = app.sidebar.resolve_layout(area.width);
let (table_pct, sidebar_pct) = match layout.split_percentages() {
Some((t, s)) => (Constraint::Percentage(t), Constraint::Percentage(s)),
None => {
app.sidebar.max_scroll = 0;
app.sidebar.wt_max_scroll = 0;
draw_list(f, area, app);
return;
}
};
match layout {
Resolved::Hidden => unreachable!("Hidden returns None from split_percentages, handled above"),
Resolved::SideBySide { sidebar_left } => {
let split = Layout::default()
.direction(Direction::Horizontal)
.constraints(if sidebar_left {
[sidebar_pct, table_pct]
} else {
[table_pct, sidebar_pct]
})
.split(area);
let (list_area, sidebar_area) = if sidebar_left {
(split[1], split[0])
} else {
(split[0], split[1])
};
draw_list(f, list_area, app);
draw_sidebar(f, sidebar_area, app);
}
Resolved::Stacked => {
let split = Layout::default()
.direction(Direction::Vertical)
.constraints([table_pct, sidebar_pct])
.split(area);
draw_list(f, split[0], app);
draw_sidebar(f, split[1], app);
}
}
}
fn draw_header(f: &mut Frame, area: Rect, app: &App) {
let workdir = tilde_compress(&app.workdir.to_string_lossy());
let line = header_line(
&app.display_repo_name,
&workdir,
app.picker_mode,
area.width as usize,
&app.theme,
);
f.render_widget(Paragraph::new(line), area);
}
pub fn panel_border_color(focused: bool, theme: &super::theme::Theme) -> Color {
if focused {
theme.focus
} else {
theme.muted
}
}
pub fn worktrees_pane_title(
query: &str,
active: bool,
visible: usize,
total: usize,
filter_color: Color,
) -> Line<'static> {
let mut spans = vec![Span::raw(" [1] Worktrees ")];
if active || !query.is_empty() {
spans.push(Span::styled(
"/",
Style::default().fg(filter_color).add_modifier(Modifier::BOLD),
));
spans.push(Span::raw(query.to_string()));
if active {
spans.push(Span::styled(
"\u{2588}",
Style::default().fg(filter_color).add_modifier(Modifier::SLOW_BLINK),
));
}
spans.push(Span::raw(" "));
}
let counter = if query.is_empty() {
format!("({}) ", total)
} else {
format!("({}/{}) ", visible, total)
};
spans.push(Span::raw(counter));
Line::from(spans)
}
pub fn status_pane_title() -> &'static str {
" [2] Status "
}
pub fn pane_counter(selected: usize, visible: usize) -> Option<String> {
if visible == 0 {
None
} else {
Some(format!(" {} of {} ", selected, visible))
}
}
fn draw_list(f: &mut Frame, area: Rect, app: &mut App) {
let filtered: Vec<usize> = app.filtered_indices().to_vec();
let visible: Vec<&WorktreeInfo> = filtered.iter().filter_map(|&i| app.worktrees.get(i)).collect();
let theme = app.theme;
let is_workspace = app.is_workspace();
let repo_names: Vec<String> = if is_workspace {
filtered
.iter()
.map(|&raw| app.row_repo_name(raw).unwrap_or("?").to_string())
.collect()
} else {
Vec::new()
};
let repo_w = if is_workspace {
column_width(repo_names.iter().map(|s| s.as_str()), 6, 24)
} else {
0
};
let name_w = column_width(visible.iter().map(|w| w.name.as_str()), 18, 38);
let branch_w = column_width(visible.iter().map(|w| w.branch.as_deref().unwrap_or("-")), 18, 38);
let status_w: u16 = 16;
let mut header_cells = vec![Cell::from("")];
if is_workspace {
header_cells.push(Cell::from("REPO"));
}
let show_agent = app.any_agent_sessions();
header_cells.push(Cell::from("I/P"));
header_cells.push(Cell::from("NAME"));
header_cells.push(Cell::from("BRANCH"));
header_cells.push(Cell::from("STATUS"));
if show_agent {
header_cells.push(Cell::from("AGENT"));
}
header_cells.push(Cell::from("PATH"));
let header = Row::new(header_cells).style(Style::default().fg(theme.muted).add_modifier(Modifier::BOLD));
let now = std::time::SystemTime::now();
let agent_cells: Vec<Option<(&'static str, crate::agent_sessions::Freshness)>> = visible
.iter()
.map(|w| agent_cell_label(app.agents_for(w), now))
.collect();
let rows: Vec<Row> = visible
.iter()
.enumerate()
.map(|(vi, w)| {
let repo = is_workspace.then(|| (repo_names[vi].as_str(), repo_w));
let agent = show_agent.then_some(agent_cells[vi]);
build_row(w, repo, name_w, branch_w, status_w, agent, &theme)
})
.collect();
let mut widths = vec![Constraint::Length(4)];
if is_workspace {
widths.push(Constraint::Length(repo_w));
}
widths.extend([
Constraint::Length(3),
Constraint::Min(name_w),
Constraint::Min(branch_w),
Constraint::Length(status_w),
]);
if show_agent {
widths.push(Constraint::Length(8));
}
widths.push(Constraint::Fill(1));
let list_has_focus = !(app.sidebar.open && app.sidebar.focused);
let border_color = panel_border_color(list_has_focus, &app.theme);
let title = worktrees_pane_title(
app.filter.query(),
app.filter.active,
visible.len(),
app.worktrees.len(),
app.theme.dirty,
);
let selected_1based = app.list_state.selected().map(|i| i + 1).unwrap_or(0);
let counter = pane_counter(selected_1based, visible.len());
let mut block = Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(Style::default().fg(border_color));
if let Some(counter) = counter {
block = block.title_bottom(Line::from(counter).right_aligned());
}
let table = Table::new(rows, widths)
.header(header)
.column_spacing(1)
.block(block)
.row_highlight_style(Style::default().bg(theme.selection_bg).add_modifier(Modifier::BOLD))
.highlight_symbol("▶ ");
f.render_stateful_widget(table, area, &mut app.list_state);
}
fn draw_sidebar(f: &mut Frame, area: Rect, app: &mut App) {
let border_color = panel_border_color(app.sidebar.focused, &app.theme);
let theme = app.theme;
let active_mode = app.sidebar.mode;
let issue_pr_inner_width = area.width.saturating_sub(3) as usize;
let Some(w) = app.selected().cloned() else {
let issue_pr_lines = github_status_lines(app, issue_pr_inner_width);
let placeholder = [Line::from("(nothing selected)")];
let h = |lines: usize| (lines as u16).saturating_add(2);
let constraints = [
Constraint::Length(h(placeholder.len())),
Constraint::Length(h(issue_pr_lines.len())),
Constraint::Length(0),
Constraint::Length(0),
Constraint::Min(3),
];
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(area);
app.sidebar.max_scroll = 0;
app.sidebar.scroll = 0;
app.sidebar.wt_max_scroll = 0;
app.sidebar.wt_scroll = 0;
render_section(
f,
chunks[0],
status_pane_title(),
SectionBody::new(&placeholder),
border_color,
0,
None,
);
render_section(
f,
chunks[1],
issue_pr_pane_title(&app.keymap),
SectionBody::new(&issue_pr_lines),
border_color,
0,
None,
);
render_section(
f,
chunks[4],
recent_items_pane_title(active_mode, &app.keymap),
SectionBody::new(&[]),
border_color,
0,
None,
);
return;
};
let cache_is_current = matches!(
&app.sidebar.cache,
Some(((p, m), _)) if *p == w.path && *m == active_mode
);
let placeholder = if cache_is_current {
SidebarSections::default()
} else {
SidebarSections {
worktree: worktree_identity_lines(&w, None, &theme),
working_tree: match active_mode {
super::state::sidebar::SidebarMode::Commits => {
vec![Line::from(Span::styled("loading…", Style::default().fg(theme.muted)))]
}
super::state::sidebar::SidebarMode::Stashes => Vec::new(),
},
working_tree_counts: WorkingTreeCounts::default(),
recent_commits: vec![Line::from(Span::styled("loading…", Style::default().fg(theme.muted)))],
}
};
let prefix_lines = vec![sidebar_header_line(&w, app)];
let issue_pr_lines = github_status_lines(app, issue_pr_inner_width);
let agent_pins: &[String] = app
.agent_pins
.get(&crate::agent_sessions::path_display_key(&w.path))
.map(|v| v.as_slice())
.unwrap_or(&[]);
let agent_lines = agent_pane_lines(app.agents_for(&w), agent_pins, std::time::SystemTime::now(), &theme);
let (worktree_len, working_tree_len, working_tree_counts, commits_len) = {
let s = if cache_is_current {
app.sidebar.cache.as_ref().map(|(_, s)| s).unwrap_or(&placeholder)
} else {
&placeholder
};
(
s.worktree.len() + prefix_lines.len(),
s.working_tree.len(),
s.working_tree_counts,
s.recent_commits.len() as u16,
)
};
let h = |lines: usize| (lines as u16).saturating_add(2);
let fixed = h(worktree_len).saturating_add(h(issue_pr_lines.len()));
let (agents_height, working_tree_height, commits_height) = super::state::sidebar::split_section_heights(
area.height.saturating_sub(fixed),
agent_lines.len() as u16,
working_tree_len as u16,
commits_len,
);
let constraints = [
Constraint::Length(h(worktree_len)),
Constraint::Length(h(issue_pr_lines.len())),
Constraint::Length(agents_height),
Constraint::Length(working_tree_height),
Constraint::Length(commits_height),
];
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(area);
let commits_area = chunks[4];
let commits_visible = commits_area.height.saturating_sub(2);
app.sidebar.max_scroll = commits_len.saturating_sub(commits_visible);
if app.sidebar.scroll > app.sidebar.max_scroll {
app.sidebar.scroll = app.sidebar.max_scroll;
}
let scroll = app.sidebar.scroll;
let wt_visible = chunks[3].height.saturating_sub(2);
app.sidebar.wt_max_scroll = (working_tree_len as u16).saturating_sub(wt_visible);
if app.sidebar.wt_scroll > app.sidebar.wt_max_scroll {
app.sidebar.wt_scroll = app.sidebar.wt_max_scroll;
}
let wt_scroll = app.sidebar.wt_scroll;
let (panel_title, panel_footer) = match active_mode {
super::state::sidebar::SidebarMode::Commits => {
let title = recent_items_pane_title(active_mode, &app.keymap);
let footer = if commits_len == 0 {
None
} else {
let bottom = scroll.saturating_add(commits_visible).min(commits_len);
Some(format!(" {} of {} ", bottom, commits_len))
};
(title, footer)
}
super::state::sidebar::SidebarMode::Stashes => {
let title = recent_items_pane_title(active_mode, &app.keymap);
let footer = if commits_len == 0 {
None
} else {
Some(" Enter: copy stash@{N} to status ".to_string())
};
(title, footer)
}
};
let issue_pr_title = issue_pr_pane_title(&app.keymap);
let working_tree_title = working_tree_pane_title(&app.keymap);
let working_tree_footer = if working_tree_len == 0 {
None
} else {
working_tree_counts_footer(&working_tree_counts, &theme)
};
let sections = if cache_is_current {
app.sidebar.cache.as_ref().map(|(_, s)| s).unwrap_or(&placeholder)
} else {
&placeholder
};
render_section(
f,
chunks[0],
status_pane_title(),
SectionBody::with_prefix(&prefix_lines, §ions.worktree),
border_color,
0,
None,
);
render_section(
f,
chunks[1],
issue_pr_title,
SectionBody::new(&issue_pr_lines),
border_color,
0,
None,
);
if !agent_lines.is_empty() {
render_section(
f,
chunks[2],
agents_pane_title(&app.keymap),
SectionBody::new(&agent_lines),
border_color,
0,
None,
);
}
if !sections.working_tree.is_empty() {
render_section(
f,
chunks[3],
working_tree_title,
SectionBody::new(§ions.working_tree),
border_color,
wt_scroll,
working_tree_footer,
);
let inner = Rect {
x: chunks[3].x.saturating_add(1),
y: chunks[3].y.saturating_add(1),
width: chunks[3].width.saturating_sub(2),
height: chunks[3].height.saturating_sub(2),
};
if inner.height > 0 {
let _ = scrollable_body_area(f, inner, wt_scroll, working_tree_len, &theme);
}
}
render_section(
f,
commits_area,
panel_title,
SectionBody::new(§ions.recent_commits),
border_color,
scroll,
panel_footer.map(ratatui::text::Line::from),
);
}
struct SectionBody<'a> {
prefix: &'a [Line<'a>],
lines: &'a [Line<'a>],
}
impl<'a> SectionBody<'a> {
fn new(lines: &'a [Line<'a>]) -> Self {
Self { prefix: &[], lines }
}
fn with_prefix(prefix: &'a [Line<'a>], lines: &'a [Line<'a>]) -> Self {
Self { prefix, lines }
}
}
fn render_section(
f: &mut Frame,
area: Rect,
title: impl Into<ratatui::text::Line<'static>>,
body: SectionBody<'_>,
border_color: Color,
scroll: u16,
footer: Option<ratatui::text::Line<'static>>,
) {
let SectionBody { prefix, lines } = body;
let mut block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title(title.into())
.border_style(Style::default().fg(border_color));
if let Some(f) = footer {
block = block.title_bottom(f.right_aligned());
}
fn pad<'a>(l: &'a Line<'_>) -> Line<'a> {
let mut spans = Vec::with_capacity(l.spans.len() + 1);
spans.push(Span::raw(" "));
spans.extend(l.spans.iter().map(|s| Span::styled(s.content.as_ref(), s.style)));
Line::from(spans)
}
let padded: Vec<Line<'_>> = prefix.iter().chain(lines.iter()).map(pad).collect();
let paragraph = Paragraph::new(padded).block(block).scroll((scroll, 0));
f.render_widget(paragraph, area);
}
pub fn agents_pane_title(keymap: &Keymap) -> String {
format!(" Agents [{}] ", action_chord(keymap, Action::AgentSessions, "a"))
}
pub fn agent_pane_lines(
agents: Option<&crate::agent_sessions::WorktreeAgents>,
pinned: &[String],
now: std::time::SystemTime,
theme: &Theme,
) -> Vec<Line<'static>> {
const MAX_ROWS: usize = 3;
let Some(agents) = agents else {
return Vec::new();
};
let shown: Vec<&crate::agent_sessions::AgentSession> = agents
.sessions
.iter()
.filter(|s| pinned.iter().any(|p| p == &s.id))
.collect();
let mut lines: Vec<Line<'static>> = shown
.iter()
.take(MAX_ROWS)
.map(|s| {
let freshness = crate::agent_sessions::Freshness::classify(s.last_activity, s.ended, now);
let (word, color) = match freshness {
crate::agent_sessions::Freshness::Active => ("active", theme.clean),
crate::agent_sessions::Freshness::Idle => ("idle", theme.muted),
};
let ago = now
.duration_since(s.last_activity)
.map(worktree::format_relative_duration)
.unwrap_or_else(|_| "now".into());
let identity = s.name.as_deref().unwrap_or(&s.id);
Line::from(vec![
Span::styled(
s.kind.display().to_string(),
Style::default().fg(color).add_modifier(Modifier::BOLD),
),
Span::styled(format!(" · {word} · {ago} ago · "), Style::default().fg(theme.muted)),
Span::styled(identity.to_string(), Style::default().fg(theme.name)),
])
})
.collect();
let extra = shown.len().saturating_sub(MAX_ROWS);
if extra > 0 {
lines.push(Line::from(Span::styled(
format!("+{extra} more"),
Style::default().fg(theme.muted),
)));
}
lines
}
fn sidebar_header_line(w: &WorktreeInfo, app: &App) -> Line<'static> {
let (dot, dot_color) = sidebar_status_dot(app);
Line::from(vec![
Span::styled(dot, Style::default().fg(dot_color).add_modifier(Modifier::BOLD)),
Span::styled(w.name.clone(), worktree_name_style(&app.theme)),
])
}
fn sidebar_status_dot(app: &App) -> (&'static str, Color) {
if let GitHubFetchState::Loaded(pr) = app.pr_fetch_state() {
return ("● ", pr_badge_color(pr.state, &app.theme));
}
if let GitHubFetchState::Loaded(issue) = app.issue_fetch_state() {
return ("● ", issue_badge_color(issue.state, &app.theme));
}
let link = app.current_link();
if link.pr.is_some() || link.issue.is_some() {
return ("● ", Color::White);
}
("● ", app.theme.muted)
}
pub fn build_sidebar_sections(
w: &WorktreeInfo,
mode: super::state::sidebar::SidebarMode,
diff: Option<worktree::DiffLineStat>,
theme: &Theme,
) -> SidebarSections {
use super::state::sidebar::SidebarMode;
let body = match mode {
SidebarMode::Commits => recent_commits_lines(w, RECENT_COMMITS_LIMIT, theme),
SidebarMode::Stashes => stash_lines(w, STASHES_DISPLAY_LIMIT, theme),
};
let (working_tree, working_tree_counts) = match mode {
SidebarMode::Commits => working_tree_lines(w, theme),
SidebarMode::Stashes => (Vec::new(), WorkingTreeCounts::default()),
};
SidebarSections {
worktree: worktree_identity_lines(w, diff.as_ref(), theme),
working_tree,
working_tree_counts,
recent_commits: body,
}
}
pub fn build_sidebar_payload(
w: &WorktreeInfo,
mode: super::state::sidebar::SidebarMode,
trunks: &[String],
theme: &Theme,
) -> SidebarSections {
let diff = worktree::git_diff_stat_vs_base(&w.path, trunks).ok().flatten();
build_sidebar_sections(w, mode, diff, theme)
}
pub const STASHES_DISPLAY_LIMIT: usize = 10;
fn stash_lines(w: &WorktreeInfo, limit: usize, theme: &Theme) -> Vec<Line<'static>> {
match crate::worktree::git_stash_list(&w.path, limit) {
Ok(stashes) if stashes.is_empty() => {
vec![Line::from(Span::styled(
"(no stashes)",
Style::default().fg(theme.muted),
))]
}
Ok(stashes) => stashes
.into_iter()
.map(|s| {
Line::from(vec![
Span::styled(s.ref_name, Style::default().fg(theme.dirty)),
Span::raw(" "),
Span::raw(s.subject),
])
})
.collect(),
Err(e) => vec![Line::from(Span::styled(
format!("git stash list failed: {}", e),
Style::default().fg(theme.prunable),
))],
}
}
fn worktree_identity_lines(
w: &WorktreeInfo,
diff: Option<&worktree::DiffLineStat>,
theme: &Theme,
) -> Vec<Line<'static>> {
let mut out: Vec<Line<'static>> = Vec::with_capacity(5);
let label_w = "Created".chars().count();
let label_style = Style::default().fg(theme.muted);
let branch_color = branch_name_color(&w.status, theme);
let branch = w.branch.clone().unwrap_or_else(|| "-".into());
let mut spans = vec![
Span::styled(format!("{:<label_w$} ", "Branch", label_w = label_w), label_style),
Span::styled(branch, Style::default().fg(branch_color)),
];
if let Some(head) = w.head.as_deref() {
spans.push(Span::styled(" · ".to_string(), Style::default().fg(theme.muted)));
spans.push(Span::styled(short_oid(head), Style::default().fg(theme.dirty)));
}
out.push(Line::from(spans));
out.push(Line::from(vec![
Span::styled(format!("{:<label_w$} ", "Created", label_w = label_w), label_style),
Span::styled(branch_age_label(w), Style::default().fg(branch_age_color(w, theme))),
]));
if let Some(d) = diff {
if !d.is_empty() {
out.push(Line::from(vec![
Span::styled(format!("{:<label_w$} ", "Diff", label_w = label_w), label_style),
Span::styled(format!("+{}", d.insertions), Style::default().fg(theme.untracked)),
Span::raw(" "),
Span::styled(format!("-{}", d.deletions), Style::default().fg(theme.prunable)),
]));
}
}
let mut state_spans = vec![Span::styled(
format!("{:<label_w$} ", "State", label_w = label_w),
label_style,
)];
state_spans.extend(badges_line(w, theme).spans);
out.push(Line::from(state_spans));
out.push(Line::from(vec![
Span::styled(format!("{:<label_w$} ", "Path", label_w = label_w), label_style),
Span::styled(
tilde_compress(&w.path.display().to_string()),
Style::default().fg(theme.muted),
),
]));
out
}
fn branch_age_label(w: &WorktreeInfo) -> String {
w.age
.map(worktree::format_relative_duration)
.unwrap_or_else(|| "-".into())
}
fn branch_age_color(w: &WorktreeInfo, theme: &Theme) -> Color {
w.age.map(|age| freshness_color(age, theme)).unwrap_or(theme.muted)
}
fn badges_line(w: &WorktreeInfo, theme: &Theme) -> Line<'static> {
let mut spans: Vec<Span<'static>> = Vec::new();
let status_label = branch_status_label(&w.status);
let status_color = branch_status_color(&w.status, theme);
let is_diverged = w.status.has_upstream && (w.status.ahead > 0 || w.status.behind > 0);
let badge_text = if w.status.unknown {
format!("? {}", status_label)
} else if w.status.is_dirty {
format!("● {}", status_label)
} else if is_diverged {
status_label
} else {
format!("✓ {}", status_label)
};
spans.push(Span::styled(badge_text, Style::default().fg(status_color)));
let sep = || Span::styled(" ".to_string(), Style::default().fg(theme.muted));
if w.is_main {
spans.push(sep());
spans.push(Span::styled("★ main".to_string(), Style::default().fg(theme.main)));
}
if w.is_locked {
spans.push(sep());
spans.push(Span::styled("🔒 locked".to_string(), Style::default().fg(theme.locked)));
}
if w.is_prunable {
spans.push(sep());
spans.push(Span::styled(
"⚠ prunable".to_string(),
Style::default().fg(theme.prunable),
));
}
Line::from(spans)
}
fn working_tree_lines(w: &WorktreeInfo, theme: &Theme) -> (Vec<Line<'static>>, WorkingTreeCounts) {
match worktree::git_status_short(&w.path) {
Ok((s, _)) if s.trim().is_empty() => (
vec![Line::from(Span::styled(
"✓ clean".to_string(),
Style::default().fg(theme.clean),
))],
WorkingTreeCounts::default(),
),
Ok((s, scan_truncated)) => {
let counts = working_tree_status_counts(&s);
let records = wt_tree::parse_status_z(&s);
let (tree, overflow) = wt_tree::build_capped_tree(&records, wt_tree::WT_TREE_MAX_FILES);
let mut lines = working_tree_tree_lines(&tree, theme);
if overflow > 0 {
let label = if scan_truncated {
format!("… {}+ more", overflow)
} else {
format!("… {} more", overflow)
};
lines.push(Line::from(Span::styled(label, Style::default().fg(theme.muted))));
}
(lines, counts)
}
Err(e) => (
vec![Line::from(Span::styled(
format!("! {}", e),
Style::default().fg(theme.prunable),
))],
WorkingTreeCounts::default(),
),
}
}
fn working_tree_tree_lines(nodes: &[WtNode], theme: &Theme) -> Vec<Line<'static>> {
let mut out = Vec::new();
push_wt_nodes(&mut out, nodes, String::new(), theme);
out
}
fn push_wt_nodes(out: &mut Vec<Line<'static>>, nodes: &[WtNode], prefix: String, theme: &Theme) {
let last = nodes.len().saturating_sub(1);
for (i, node) in nodes.iter().enumerate() {
let is_last = i == last;
let connector = format!("{}{}", prefix, if is_last { "└─ " } else { "├─ " });
match node {
WtNode::Dir {
name,
children,
category,
} => {
let color = match category {
Some(c) => working_tree_category_color(*c, theme),
None => theme.accent,
};
out.push(Line::from(vec![
Span::styled(connector, Style::default().fg(theme.muted)),
Span::styled(
format!("{} {}", WT_DIR_OPEN_ICON, wt_tree::sanitize_name(name)),
Style::default().fg(color),
),
]));
let child_prefix = format!("{}{}", prefix, if is_last { " " } else { "│ " });
push_wt_nodes(out, children, child_prefix, theme);
}
WtNode::File {
name,
icon,
badge,
category,
} => {
let color = working_tree_category_color(*category, theme);
out.push(Line::from(vec![
Span::styled(connector, Style::default().fg(theme.muted)),
Span::styled(format!("{} ", badge), Style::default().fg(color)),
Span::styled(
format!("{} {}", icon, wt_tree::sanitize_name(name)),
Style::default().fg(color),
),
]));
}
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct WorkingTreeCounts {
pub created: usize,
pub modified: usize,
pub deleted: usize,
}
impl WorkingTreeCounts {
pub fn is_empty(&self) -> bool {
self.created == 0 && self.modified == 0 && self.deleted == 0
}
}
pub const WT_CREATED_ICON: &str = "\u{eadc}";
pub const WT_MODIFIED_ICON: &str = "\u{eadd}";
pub const WT_DELETED_ICON: &str = "\u{eade}";
fn working_tree_category_color(cat: WtCategory, theme: &Theme) -> Color {
match cat {
WtCategory::Created => theme.untracked,
WtCategory::Modified => theme.modified,
WtCategory::Deleted => theme.prunable,
}
}
pub fn working_tree_status_counts(status_z: &str) -> WorkingTreeCounts {
let mut c = WorkingTreeCounts::default();
for rec in wt_tree::parse_status_z(status_z) {
match working_tree_category(rec.x, rec.y) {
WtCategory::Created => c.created += 1,
WtCategory::Modified => c.modified += 1,
WtCategory::Deleted => c.deleted += 1,
}
}
c
}
pub fn working_tree_counts_footer(counts: &WorkingTreeCounts, theme: &Theme) -> Option<Line<'static>> {
if counts.is_empty() {
return None;
}
let mut spans: Vec<Span<'static>> = vec![Span::raw(" ")];
if counts.created > 0 {
spans.push(Span::styled(
format!("{} {} ", WT_CREATED_ICON, counts.created),
Style::default().fg(theme.untracked),
));
}
if counts.modified > 0 {
spans.push(Span::styled(
format!("{} {} ", WT_MODIFIED_ICON, counts.modified),
Style::default().fg(theme.modified),
));
}
if counts.deleted > 0 {
spans.push(Span::styled(
format!("{} {} ", WT_DELETED_ICON, counts.deleted),
Style::default().fg(theme.prunable),
));
}
Some(Line::from(spans))
}
pub fn working_tree_status_line(raw: &str, theme: &Theme) -> Line<'static> {
let mut indices = raw.char_indices();
let (x_at, x) = match indices.next() {
Some(c) => c,
None => return Line::from(raw.to_string()),
};
let (_y_at, y) = match indices.next() {
Some(c) => c,
None => return Line::from(raw.to_string()),
};
let (sep_at, sep) = match indices.next() {
Some(c) => c,
None => return Line::from(raw.to_string()),
};
let path_at = sep_at + sep.len_utf8();
let style = Style::default().fg(working_tree_category_color(working_tree_category(x, y), theme));
Line::from(vec![
Span::styled(raw[x_at..sep_at].to_string(), style),
Span::raw(raw[sep_at..path_at].to_string()),
Span::styled(raw[path_at..].to_string(), style),
])
}
pub const RECENT_COMMITS_LIMIT: usize = 300;
pub const COMMIT_HASH_DISPLAY_LEN: usize = 8;
pub fn recent_commits_lines(w: &WorktreeInfo, limit: usize, theme: &Theme) -> Vec<Line<'static>> {
match worktree::recent_commits_cached(w, limit) {
Ok(rows) if !rows.is_empty() => {
let graphs = super::commit_graph::render_commits(&rows, theme);
rows
.into_iter()
.zip(graphs)
.map(|(row, graph_spans)| commit_row_line(row, graph_spans, theme))
.collect()
}
Ok(_) => vec![Line::from(Span::styled(
"(no commits)".to_string(),
Style::default().fg(theme.muted),
))],
Err(e) => vec![Line::from(Span::styled(
format!("! {}", e),
Style::default().fg(theme.prunable),
))],
}
}
fn commit_row_line(row: worktree::CommitRow, graph: Vec<Span<'static>>, theme: &Theme) -> Line<'static> {
let mut short_hash = row.hash.to_string();
short_hash.truncate(COMMIT_HASH_DISPLAY_LEN);
let initials = author_initials(&row.author);
let mut spans: Vec<Span<'static>> = Vec::with_capacity(5 + graph.len());
spans.push(Span::styled(short_hash, Style::default().fg(theme.dirty)));
spans.push(Span::raw(" "));
spans.push(Span::styled(
format!("{:<2}", initials),
Style::default().fg(theme.accent).add_modifier(Modifier::BOLD),
));
spans.push(Span::raw(" "));
spans.extend(graph);
spans.push(Span::raw(" "));
spans.push(Span::raw(row.subject));
Line::from(spans)
}
pub fn author_initials(author: &str) -> String {
let trimmed = author.trim();
if trimmed.is_empty() {
return String::new();
}
let mut parts = trimmed.split_whitespace();
let first = parts.next().unwrap_or("");
match parts.next() {
Some(second) => {
let a: String = first.chars().take(1).collect();
let b: String = second.chars().take(1).collect();
format!("{}{}", a, b)
}
None => first.chars().take(2).collect(),
}
}
fn tilde_compress(path: &str) -> String {
if let Some(home) = dirs::home_dir() {
tilde_compress_with_home(path, &home)
} else {
path.to_string()
}
}
pub fn tilde_compress_with_home(path: &str, home: &std::path::Path) -> String {
let home_s = home.display().to_string();
if let Some(rest) = path.strip_prefix(&home_s) {
if rest.is_empty() || rest.starts_with('/') || rest.starts_with(std::path::MAIN_SEPARATOR) {
return format!("~{}", rest);
}
}
path.to_string()
}
fn short_oid(oid: &str) -> String {
oid.chars().take(7).collect()
}
fn branch_status_label(s: &BranchStatus) -> String {
if s.unknown {
return "unknown".into();
}
let mut parts: Vec<String> = Vec::new();
if s.is_dirty {
parts.push("dirty".into());
}
if s.has_upstream {
if s.ahead > 0 {
parts.push(format!("↑{}", s.ahead));
}
if s.behind > 0 {
parts.push(format!("↓{}", s.behind));
}
if !s.is_dirty && s.synced() {
parts.push("synced".into());
}
} else if !s.is_dirty {
parts.push("clean".into());
}
if parts.is_empty() {
"clean".into()
} else {
parts.join(" ")
}
}
pub fn branch_status_color(s: &BranchStatus, theme: &Theme) -> Color {
if s.unknown {
theme.muted
} else if s.is_dirty || s.behind > 0 {
theme.dirty
} else if s.ahead > 0 {
theme.accent
} else {
theme.clean
}
}
fn column_width<'a>(items: impl Iterator<Item = &'a str>, min: u16, max: u16) -> u16 {
let observed = items.map(|s| s.chars().count() as u16).max().unwrap_or(min);
observed.clamp(min, max)
}
pub fn worktree_name_style(theme: &Theme) -> Style {
Style::default().fg(theme.name).add_modifier(Modifier::BOLD)
}
pub fn worktree_path_style(theme: &Theme) -> Style {
Style::default().fg(theme.path)
}
pub fn chip_style(color: Color) -> Style {
Style::default()
.fg(color)
.add_modifier(Modifier::REVERSED | Modifier::BOLD)
}
pub fn hint_key_style(theme: &Theme) -> Style {
Style::default().fg(theme.accent).add_modifier(Modifier::BOLD)
}
pub fn hint_label_style(theme: &Theme) -> Style {
Style::default().fg(theme.muted)
}
pub fn palette_name_style(theme: &Theme) -> Style {
Style::default().fg(theme.name)
}
pub fn help_label_style(theme: &Theme) -> Style {
Style::default().fg(theme.name)
}
pub fn agent_cell_label(
agents: Option<&crate::agent_sessions::WorktreeAgents>,
now: std::time::SystemTime,
) -> Option<(&'static str, crate::agent_sessions::Freshness)> {
let top = agents?.top()?;
let freshness = crate::agent_sessions::Freshness::classify(top.last_activity, top.ended, now);
Some((top.kind.display(), freshness))
}
fn build_row(
w: &WorktreeInfo,
repo: Option<(&str, u16)>,
name_w: u16,
branch_w: u16,
status_w: u16,
agent: Option<Option<(&'static str, crate::agent_sessions::Freshness)>>,
theme: &Theme,
) -> Row<'static> {
let marker = table_marker(w, theme);
let branch_text = w.branch.clone().unwrap_or_else(|| "-".into());
let name_cell = Cell::from(trunc(&w.name, name_w as usize)).style(worktree_name_style(theme));
let branch_cell =
Cell::from(trunc(&branch_text, branch_w as usize)).style(Style::default().fg(branch_name_color(&w.status, theme)));
let status_cell = build_status_cell(w, status_w as usize, theme);
let age_label = w.age.map(format_relative_duration_str).unwrap_or_else(|| "-".into());
let age_cell = Cell::from(age_label).style(Style::default().fg(theme.muted));
let path_cell = Cell::from(w.path.to_string_lossy().to_string()).style(worktree_path_style(theme));
let mut cells = vec![age_cell];
if let Some((repo_name, repo_w)) = repo {
cells.push(
Cell::from(trunc(repo_name, repo_w as usize))
.style(Style::default().fg(theme.accent).add_modifier(Modifier::BOLD)),
);
}
cells.push(Cell::from(marker));
cells.push(name_cell);
cells.push(branch_cell);
cells.push(status_cell);
if let Some(agent) = agent {
cells.push(match agent {
Some((label, crate::agent_sessions::Freshness::Active)) => {
Cell::from(label).style(Style::default().fg(theme.clean).add_modifier(Modifier::BOLD))
}
Some((label, crate::agent_sessions::Freshness::Idle)) => {
Cell::from(label).style(Style::default().fg(theme.muted))
}
None => Cell::from(""),
});
}
cells.push(path_cell);
Row::new(cells)
}
fn format_relative_duration_str(d: std::time::Duration) -> String {
worktree::format_relative_duration(d)
}
fn build_status_cell(w: &WorktreeInfo, width: usize, theme: &Theme) -> Cell<'static> {
if w.is_prunable {
return Cell::from("prunable").style(Style::default().fg(theme.prunable).add_modifier(Modifier::BOLD));
}
if w.is_locked {
return Cell::from("locked").style(Style::default().fg(theme.locked));
}
let s = &w.status;
let (label, color) = format_status(s, width, theme);
Cell::from(label).style(Style::default().fg(color))
}
pub fn format_status(s: &BranchStatus, width: usize, theme: &Theme) -> (String, Color) {
if s.unknown {
return ("unknown".into(), theme.muted);
}
let mut parts: Vec<String> = Vec::new();
if s.is_dirty {
parts.push("● dirty".into());
}
if s.has_upstream {
if s.ahead > 0 {
parts.push(format!("↑{}", s.ahead));
}
if s.behind > 0 {
parts.push(format!("↓{}", s.behind));
}
if !s.is_dirty && s.synced() {
parts.push("✓ synced".into());
}
} else if !s.is_dirty {
parts.push("clean".into());
}
let joined = parts.join(" ");
let label = trunc(&joined, width.max(4));
(label, branch_status_color(s, theme))
}
#[derive(Debug, Clone, Copy)]
enum Hint {
Key(super::keymap::Action, &'static str),
Modal(ModalAction, &'static str),
Lit(&'static str, &'static str),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HintContext {
Worktrees,
Status,
Picker,
Create,
CreateFreeform,
Confirm,
OpenMenu,
LinkPrompt,
LinkInputNumber,
CommandPalette,
Report,
Help,
Pty,
ExecPicker,
Clean,
Rename,
RenameFreeform,
Detail,
CiChecks,
}
impl HintContext {
pub fn label(self) -> &'static str {
match self {
HintContext::Worktrees => "worktrees",
HintContext::Status => "status",
HintContext::Picker => "switch",
HintContext::Create | HintContext::CreateFreeform => "create",
HintContext::Confirm => "confirm",
HintContext::OpenMenu => "open",
HintContext::LinkPrompt => "link",
HintContext::LinkInputNumber => "link",
HintContext::CommandPalette => "command",
HintContext::Report => "report",
HintContext::Help => "help",
HintContext::Pty => "terminal",
HintContext::ExecPicker => "exec",
HintContext::Clean => "clean",
HintContext::Rename | HintContext::RenameFreeform => "rename",
HintContext::Detail => "agents",
HintContext::CiChecks => "checks",
}
}
fn hint_specs(self) -> &'static [Hint] {
use super::keymap::Action::*;
match self {
HintContext::Worktrees => &[
Hint::Key(Create, "new"),
Hint::Key(DeleteConfirm, "del"),
Hint::Key(Bootstrap, "boot"),
Hint::Key(TerminalFullscreen, "open"),
Hint::Key(LazyGitFullscreen, "git"),
Hint::Key(ExecOverlay, "exec"),
Hint::Key(AgentSessions, "agents"),
Hint::Key(ReviewFullscreen, "review"),
Hint::Key(YankPath, "yank"),
Hint::Key(Filter, "filter"),
Hint::Key(FocusStatus, "status"),
Hint::Key(CommandLogs, "logs"),
Hint::Key(ConfigPanel, "settings"),
Hint::Key(Help, "help"),
Hint::Key(Quit, "quit"),
],
HintContext::Status => &[
Hint::Key(Down, "scroll"),
Hint::Key(WtScrollDown, "wt scroll"),
Hint::Key(FetchGithub, "fetch"),
Hint::Key(EditWorktree, "ci checks"),
Hint::Key(ToggleSidebarMode, "mode"),
Hint::Key(CycleSidebarLayout, "layout"),
Hint::Key(FocusWorktrees, "worktrees"),
Hint::Key(Filter, "filter"),
Hint::Key(CommandLogs, "logs"),
Hint::Key(ConfigPanel, "settings"),
Hint::Key(Help, "help"),
Hint::Key(Quit, "quit"),
],
HintContext::Picker => &[
Hint::Lit("Enter", "select"),
Hint::Lit("Esc", "cancel"),
Hint::Key(TerminalFullscreen, "open"),
Hint::Key(LazyGitFullscreen, "git"),
Hint::Key(YankPath, "yank"),
Hint::Key(Filter, "filter"),
Hint::Key(Help, "help"),
Hint::Key(Quit, "quit"),
],
HintContext::Create => &[
Hint::Modal(ModalAction::CreateNextField, "field"),
Hint::Lit("↑/↓", "type"),
Hint::Modal(ModalAction::CreateToggleMode, "free-form"),
Hint::Modal(ModalAction::CreateSubmit, "submit"),
Hint::Modal(ModalAction::CreateCancel, "cancel"),
],
HintContext::CreateFreeform => &[
Hint::Modal(ModalAction::CreateToggleMode, "structured"),
Hint::Modal(ModalAction::CreateSubmit, "submit"),
Hint::Modal(ModalAction::CreateCancel, "cancel"),
],
HintContext::Confirm => &[
Hint::Modal(ModalAction::ConfirmConfirm, "confirm"),
Hint::Key(ToggleDeleteBranch, "branch"),
Hint::Lit("←/→", "move"),
Hint::Modal(ModalAction::ConfirmActivate, "activate"),
Hint::Modal(ModalAction::ConfirmCancel, "cancel"),
],
HintContext::OpenMenu => &[
Hint::Modal(ModalAction::OpenMenuIssue, "issue"),
Hint::Modal(ModalAction::OpenMenuPr, "pr"),
Hint::Key(FetchGithub, "fetch"),
Hint::Modal(ModalAction::OpenMenuClose, "close"),
],
HintContext::LinkPrompt => &[
Hint::Modal(ModalAction::LinkChoosePrev, "prev"),
Hint::Modal(ModalAction::LinkChooseNext, "next"),
Hint::Modal(ModalAction::LinkChooseIssue, "issue"),
Hint::Modal(ModalAction::LinkChoosePr, "pr"),
Hint::Modal(ModalAction::LinkChooseAccept, "link"),
Hint::Key(FetchGithub, "fetch"),
Hint::Modal(ModalAction::LinkChooseCancel, "cancel"),
],
HintContext::LinkInputNumber => &[
Hint::Lit("0-9", "number"),
Hint::Modal(ModalAction::LinkInputSubmit, "submit"),
Hint::Key(FetchGithub, "fetch"),
Hint::Modal(ModalAction::LinkInputCancel, "cancel"),
],
HintContext::CommandPalette => &[
Hint::Lit("↑/↓", "move"),
Hint::Modal(ModalAction::CommandPaletteAccept, "run"),
Hint::Modal(ModalAction::CommandPaletteClose, "cancel"),
],
HintContext::Report => &[Hint::Modal(ModalAction::ReportClose, "close")],
HintContext::Detail => &[
Hint::Lit("j/k", "select"),
Hint::Modal(ModalAction::DetailAttach, "attach"),
Hint::Modal(ModalAction::DetailDetach, "detach"),
Hint::Modal(ModalAction::DetailInput, "by id"),
Hint::Modal(ModalAction::DetailClose, "close"),
],
HintContext::CiChecks => &[
Hint::Lit("j/k", "select"),
Hint::Modal(ModalAction::CiChecksOpen, "open"),
Hint::Modal(ModalAction::CiChecksFilter, "filter"),
Hint::Modal(ModalAction::CiChecksRefresh, "refresh"),
Hint::Modal(ModalAction::CiChecksClose, "close"),
],
HintContext::Help => &[
Hint::Lit("j/k", "scroll"),
Hint::Lit("h/l", "pan"),
Hint::Modal(ModalAction::HelpClose, "close"),
],
HintContext::Pty => &[Hint::Lit("Esc", "close")],
HintContext::ExecPicker => &[
Hint::Lit("↑/↓", "pick"),
Hint::Modal(ModalAction::ExecPickerAccept, "run"),
Hint::Modal(ModalAction::ExecPickerCancel, "cancel"),
],
HintContext::Clean => &[
Hint::Lit("↑/↓", "profile"),
Hint::Modal(ModalAction::CleanConfirm, "reclaim"),
Hint::Modal(ModalAction::CleanCancel, "cancel"),
],
HintContext::Rename => &[
Hint::Modal(ModalAction::CreateToggleMode, "free-form"),
Hint::Modal(ModalAction::CreateNextField, "field"),
Hint::Lit("↑/↓", "type"),
Hint::Modal(ModalAction::CreateSubmit, "submit"),
Hint::Modal(ModalAction::CreateCancel, "cancel"),
],
HintContext::RenameFreeform => &[
Hint::Modal(ModalAction::CreateToggleMode, "structured"),
Hint::Modal(ModalAction::CreateSubmit, "submit"),
Hint::Modal(ModalAction::CreateCancel, "cancel"),
],
}
}
pub fn resolve(self, keymap: &super::keymap::Keymap, modal: &ModalKeymap) -> Vec<(String, String)> {
self.resolve_with_fields(keymap, modal, &CANONICAL_TRIPLE)
}
pub fn resolve_with_fields(
self,
keymap: &super::keymap::Keymap,
modal: &ModalKeymap,
fields: &[Field],
) -> Vec<(String, String)> {
let structured_form = matches!(self, HintContext::Create | HintContext::Rename);
self
.hint_specs()
.iter()
.filter(|h| {
if !structured_form {
return true;
}
match h {
Hint::Lit("↑/↓", "type") => fields.contains(&Field::Type),
Hint::Modal(ModalAction::CreateNextField, _) => fields.len() > 1,
_ => true,
}
})
.filter_map(|h| match h {
Hint::Key(action, label) => keymap
.primary_chord(*action)
.filter(|k| !self.key_shadowed_by_modal(k, modal) && !self.key_swallowed_by_typing(k))
.map(|k| (k, label.to_string())),
Hint::Modal(action, label) => modal.primary_key(*action).map(|k| (k, label.to_string())),
Hint::Lit(key, label) => Some((key.to_string(), label.to_string())),
})
.collect()
}
fn modal_context(self) -> Option<KeyContext> {
Some(match self {
HintContext::Create | HintContext::CreateFreeform | HintContext::Rename | HintContext::RenameFreeform => {
KeyContext::Create
}
HintContext::Confirm => KeyContext::Confirm,
HintContext::OpenMenu => KeyContext::OpenMenu,
HintContext::LinkPrompt => KeyContext::LinkChooseTarget,
HintContext::LinkInputNumber => KeyContext::LinkInputNumber,
HintContext::CommandPalette => KeyContext::CommandPalette,
HintContext::Report => KeyContext::Report,
HintContext::Help => KeyContext::Help,
HintContext::Detail => KeyContext::Detail,
HintContext::CiChecks => KeyContext::CiChecks,
HintContext::ExecPicker => KeyContext::ExecPicker,
HintContext::Clean => KeyContext::Clean,
HintContext::Worktrees | HintContext::Status | HintContext::Picker | HintContext::Pty => return None,
})
}
fn key_shadowed_by_modal(self, key: &str, modal: &ModalKeymap) -> bool {
match self.modal_context() {
Some(ctx) => modal
.bindings_for(ctx)
.iter()
.any(|b| b.keys.iter().any(|ks| ks.to_string() == key)),
None => false,
}
}
fn key_swallowed_by_typing(self, key: &str) -> bool {
match self.modal_context() {
Some(ctx) => KeyStroke::parse_chord(key)
.ok()
.and_then(|strokes| strokes.first().cloned())
.is_some_and(|ks| ctx.reserved_typing_stroke(&ks)),
None => false,
}
}
}
fn action_chord(keymap: &Keymap, action: Action, fallback: &str) -> String {
keymap.primary_chord(action).unwrap_or_else(|| fallback.to_string())
}
pub fn issue_pr_pane_title(keymap: &Keymap) -> String {
format!(" Issue / PR [{}] ", action_chord(keymap, Action::FetchGithub, "F"))
}
pub fn working_tree_pane_title(keymap: &Keymap) -> String {
format!(
" Working Tree [{}] ",
action_chord(keymap, Action::ReviewFullscreen, "R")
)
}
pub fn recent_items_pane_title(mode: SidebarMode, keymap: &Keymap) -> String {
match mode {
SidebarMode::Commits => format!(
" Recent Commits [{}] ",
action_chord(keymap, Action::LazyGitFullscreen, "l")
),
SidebarMode::Stashes => format!(" Stashes [{}] ", action_chord(keymap, Action::LazyGitFullscreen, "l")),
}
}
pub fn modal_hint_line(hints: &[(&str, &str)], theme: &Theme) -> Line<'static> {
let key_style = hint_key_style(theme);
let label_style = hint_label_style(theme);
let mut spans: Vec<Span<'static>> = Vec::new();
for (i, (key, label)) in hints.iter().enumerate() {
if i > 0 {
spans.push(Span::raw(" "));
}
spans.push(Span::styled((*key).to_string(), key_style));
spans.push(Span::styled(format!(" {}", label), label_style));
}
Line::from(spans).centered()
}
pub fn config_edit_footer_hints(modal: &ModalKeymap) -> Vec<(String, String)> {
[
(ModalAction::ConfigEditSubmit, "save"),
(ModalAction::ConfigEditCancel, "cancel"),
]
.into_iter()
.filter_map(|(action, label)| modal.primary_key(action).map(|k| (k, label.to_string())))
.collect()
}
pub fn config_nav_footer_hints(
modal: &ModalKeymap,
tab: SettingsTab,
selected_kind: Option<FieldKind>,
) -> Vec<(String, String)> {
let mut hints: Vec<(String, String)> = Vec::new();
if tab == SettingsTab::All {
hints.push(("j/k".to_string(), "scroll".to_string()));
} else {
let label = if tab == SettingsTab::Keys {
"rebind"
} else if selected_kind == Some(FieldKind::Choice) {
"cycle"
} else {
"edit"
};
if let Some(k) = modal.primary_key(ModalAction::ConfigActivate) {
hints.push((k, label.to_string()));
}
}
for (action, label) in [
(ModalAction::ConfigNextTab, "section"),
(ModalAction::ConfigToggleLayer, "layer"),
(ModalAction::ConfigClose, "close"),
] {
if let Some(k) = modal.primary_key(action) {
hints.push((k, label.to_string()));
}
}
hints
}
pub fn config_capture_footer_hints(modal: &ModalKeymap, single_only: bool) -> Vec<(String, String)> {
let mut hints: Vec<(String, String)> = Vec::new();
if single_only {
hints.push(("any key".to_string(), "bind".to_string()));
} else {
if let Some(k) = modal.primary_key(ModalAction::ConfigEditSubmit) {
hints.push((k, "save".to_string()));
}
hints.push(("Backspace".to_string(), "delete".to_string()));
}
if let Some(k) = modal.primary_key(ModalAction::ConfigEditCancel) {
hints.push((k, "cancel".to_string()));
}
hints
}
pub fn command_logs_footer_hints(modal: &ModalKeymap) -> Vec<(String, String)> {
let mut hints: Vec<(String, String)> = vec![
("j/k".to_string(), "scroll".to_string()),
("g/G".to_string(), "top/bottom".to_string()),
];
for (action, label) in [
(ModalAction::CommandLogsCopy, "copy"),
(ModalAction::CommandLogsClose, "close"),
] {
if let Some(k) = modal.primary_key(action) {
hints.push((k, label.to_string()));
}
}
hints
}
pub fn modal_hint_for_context(ctx: HintContext, keymap: &Keymap, modal: &ModalKeymap, theme: &Theme) -> Line<'static> {
modal_hint_for_context_with_fields(ctx, keymap, modal, theme, &CANONICAL_TRIPLE)
}
pub fn modal_hint_for_context_with_fields(
ctx: HintContext,
keymap: &Keymap,
modal: &ModalKeymap,
theme: &Theme,
fields: &[Field],
) -> Line<'static> {
let resolved = ctx.resolve_with_fields(keymap, modal, fields);
let hints: Vec<(&str, &str)> = resolved.iter().map(|(k, l)| (k.as_str(), l.as_str())).collect();
modal_hint_line(&hints, theme)
}
fn push_modal_hint(
lines: &mut Vec<Line<'static>>,
ctx: HintContext,
keymap: &Keymap,
modal: &ModalKeymap,
theme: &Theme,
) {
lines.push(Line::from(String::new()));
lines.push(modal_hint_for_context(ctx, keymap, modal, theme));
}
pub fn footer_line(hints: &[(&str, &str)], status: &str, width: usize, theme: &Theme) -> Line<'static> {
let key_style = hint_key_style(theme);
let label_style = hint_label_style(theme);
let status_style = Style::default().fg(theme.dirty);
if width == 0 {
return Line::default();
}
let status: String = status.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
let status_text = format!("[{}]", status);
let status_w = status_text.chars().count();
if width <= status_w {
return Line::from(Span::styled(trunc(&status_text, width), status_style));
}
let hint_budget = (width - status_w - 1).saturating_sub(1);
let mut spans: Vec<Span<'static>> = Vec::new();
let mut used = 0usize; let mut truncated = false;
for (i, (key, label)) in hints.iter().enumerate() {
let sep = if i > 0 { 2 } else { 0 }; let badge_w = key.chars().count() + 1 + label.chars().count();
if used + sep + badge_w > hint_budget {
truncated = true;
break;
}
if sep > 0 {
spans.push(Span::raw(" ".repeat(sep)));
used += sep;
}
spans.push(Span::styled((*key).to_string(), key_style));
spans.push(Span::styled(format!(" {}", label), label_style));
used += badge_w;
}
if truncated {
if used > 0 {
spans.push(Span::raw(" "));
used += 1;
}
spans.push(Span::styled("…", label_style));
used += 1;
}
let pad = width.saturating_sub(used + status_w);
if pad > 0 {
spans.push(Span::raw(" ".repeat(pad)));
}
spans.push(Span::styled(status_text, status_style));
Line::from(spans)
}
pub fn status_line(
context: &str,
hints: &[(&str, &str)],
status: &str,
spinner: Option<&str>,
width: usize,
theme: &Theme,
) -> Line<'static> {
let context_style = chip_style(theme.focus);
let key_style = hint_key_style(theme);
let label_style = hint_label_style(theme);
let status_style = Style::default().fg(theme.dirty);
let spinner_style = Style::default().fg(theme.accent).add_modifier(Modifier::BOLD);
if width == 0 {
return Line::default();
}
let status: String = status.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
let status_text = format!("[{}]", status);
let status_w = status_text.chars().count();
if width <= status_w {
return Line::from(Span::styled(trunc(&status_text, width), status_style));
}
let avail = width - status_w; let mut spans: Vec<Span<'static>> = Vec::new();
let mut used = 0usize;
let ctx_chip = format!(" {} ", context);
let ctx_w = ctx_chip.chars().count();
if ctx_w <= avail {
spans.push(Span::styled(ctx_chip, context_style));
used += ctx_w;
}
if let Some(glyph) = spinner {
let padded = format!(" {} ", glyph);
let gw = padded.chars().count();
if used + gw <= avail {
spans.push(Span::styled(padded, spinner_style));
used += gw;
}
}
let hint_budget = avail.saturating_sub(used).saturating_sub(1);
let mut truncated = false;
let mut hint_used = 0usize;
for (i, (key, label)) in hints.iter().enumerate() {
let sep = if i > 0 { 2 } else { usize::from(used > 0) };
let badge_w = key.chars().count() + 1 + label.chars().count();
if hint_used + sep + badge_w > hint_budget {
truncated = true;
break;
}
if sep > 0 {
spans.push(Span::raw(" ".repeat(sep)));
hint_used += sep;
}
spans.push(Span::styled((*key).to_string(), key_style));
spans.push(Span::styled(format!(" {}", label), label_style));
hint_used += badge_w;
}
used += hint_used;
if truncated {
if used > 0 {
spans.push(Span::raw(" "));
used += 1;
}
spans.push(Span::styled("…", label_style));
used += 1;
}
let pad = width.saturating_sub(used + status_w);
if pad > 0 {
spans.push(Span::raw(" ".repeat(pad)));
}
spans.push(Span::styled(status_text, status_style));
Line::from(spans)
}
fn draw_footer(f: &mut Frame, area: Rect, app: &App) {
let ctx = app.hint_context();
let spinner = if app.is_github_loading() || app.is_task_loading() {
Some(app.spinner.glyph(DOT_FRAMES))
} else {
None
};
let resolved = ctx.resolve_with_fields(&app.keymap, &app.modal_keymap, app.create_form.fields());
let hints: Vec<(&str, &str)> = resolved.iter().map(|(k, l)| (k.as_str(), l.as_str())).collect();
let line = status_line(
ctx.label(),
&hints,
&app.status,
spinner,
area.width as usize,
&app.theme,
);
f.render_widget(Paragraph::new(line), area);
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HelpRow {
Title(String),
Subtitle(String),
Section(String),
Blank,
Entry { keys: String, label: String },
}
pub fn help_rows(km: &super::keymap::Keymap, modal: &ModalKeymap, ctx: HintContext) -> Vec<HelpRow> {
use super::keymap::Action;
let picker_mode = matches!(ctx, HintContext::Picker);
let bindings = km.list();
let keys_for = |action: Action| -> String {
bindings
.iter()
.find(|b| b.action == action)
.map(|b| {
b.chords
.iter()
.map(|c| c.iter().map(|k| k.to_string()).collect::<Vec<_>>().join(" "))
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default()
};
let entry = |action: Action, label: &str| -> HelpRow {
HelpRow::Entry {
keys: keys_for(action),
label: label.to_string(),
}
};
let fixed = |keys: &str, label: &str| -> HelpRow {
HelpRow::Entry {
keys: keys.to_string(),
label: label.to_string(),
}
};
let modal_entry = |action: ModalAction, label: &str| -> HelpRow {
HelpRow::Entry {
keys: modal.keys_display(action),
label: label.to_string(),
}
};
let mut rows: Vec<HelpRow> = vec![
HelpRow::Title("Keybindings".to_string()),
HelpRow::Subtitle(ctx.label().to_string()),
HelpRow::Blank,
HelpRow::Section("Global".to_string()),
HelpRow::Blank,
entry(Action::Quit, "quit (Esc also quits when filter is clear)"),
fixed("Ctrl-C", "quit (hard-coded escape hatch)"),
HelpRow::Blank,
HelpRow::Section("List View".to_string()),
HelpRow::Blank,
entry(Action::Down, "next (scrolls sidebar when focused)"),
entry(Action::Up, "prev (scrolls sidebar when focused)"),
entry(Action::WtScrollDown, "scroll the Working Tree pane down (status focus)"),
entry(Action::WtScrollUp, "scroll the Working Tree pane up (status focus)"),
entry(Action::Top, "jump to first worktree"),
entry(Action::Bottom, "jump to last worktree"),
];
if picker_mode {
rows.push(fixed("enter", "select highlighted worktree (prints path on exit)"));
} else {
rows.push(entry(Action::Create, "new worktree"));
rows.push(entry(Action::DeleteConfirm, "delete selected"));
rows.push(entry(Action::Bootstrap, "bootstrap selected"));
}
rows.push(entry(
Action::TerminalFullscreen,
"open per [tui.open] — shell / editor / finder",
));
rows.push(entry(Action::TerminalPty, "open native $SHELL in embedded PTY overlay"));
rows.push(entry(Action::OpenDocs, "open the gwm documentation in the browser"));
rows.push(entry(Action::YankPath, "yank selected worktree path to clipboard"));
rows.push(entry(Action::YankBranchName, "yank selected branch name to clipboard"));
rows.push(entry(
Action::YankWorktreeName,
"yank selected worktree name to clipboard",
));
rows.push(entry(Action::LazyGitFullscreen, "launch lazygit fullscreen"));
rows.push(entry(Action::LazyGitPty, "open lazygit in embedded PTY overlay"));
rows.push(entry(Action::ToggleSidebar, "toggle git preview sidebar"));
rows.push(entry(
Action::ToggleSidebarMode,
"cycle sidebar mode (commits / stashes)",
));
rows.push(entry(
Action::CycleSidebarLayout,
"cycle sidebar layout (auto / side-by-side / stacked)",
));
rows.push(entry(
Action::ToggleSidebarPosition,
"toggle sidebar position (left / right)",
));
rows.push(entry(Action::FocusSwap, "swap focus between worktree list and sidebar"));
rows.push(entry(Action::FocusWorktrees, "focus the worktrees pane"));
rows.push(entry(Action::FocusStatus, "focus the status pane (opens it if hidden)"));
rows.push(entry(Action::CommandLogs, "show the command logs overlay"));
rows.push(entry(Action::ConfigPanel, "show the resolved configuration panel"));
if !picker_mode {
rows.push(entry(
Action::ExecOverlay,
"pick an [exec.profiles] profile and run it in a PTY",
));
rows.push(entry(
Action::CleanOverlay,
"preview and reclaim build artifacts (with confirm)",
));
rows.push(entry(
Action::AgentSessions,
"show the agent sessions attached to this worktree",
));
rows.push(entry(
Action::CiChecks,
"list the linked PR's CI checks (also `c` with status focus)",
));
}
rows.push(entry(
Action::Filter,
"open fuzzy filter bar (enter: sticky, esc: clear)",
));
rows.push(entry(Action::Refresh, "refresh worktree list"));
if !picker_mode {
rows.push(entry(Action::Sync, "sync selected worktree onto its upstream (rebase)"));
rows.push(entry(Action::Pull, "pull selected worktree's branch from upstream"));
rows.push(entry(Action::Push, "push selected worktree's branch to remote"));
rows.push(entry(Action::EditWorktree, "rename the selected worktree's branch"));
rows.push(entry(
Action::ExitToWorktree,
"quit TUI and print selected path to stdout",
));
rows.push(entry(Action::MuxPane, "open selected worktree in new mux pane/tab"));
rows.push(entry(Action::Macro1, "run [tui.macro1] command"));
rows.push(entry(Action::Macro2, "run [tui.macro2] command"));
rows.push(entry(Action::FetchGithub, "refresh GitHub issue/PR status via `gh`"));
rows.push(entry(Action::ReviewFullscreen, "run [review] launcher fullscreen"));
rows.push(entry(
Action::ReviewPty,
"run [review] launcher in embedded PTY overlay",
));
rows.push(entry(Action::ToggleDeleteBranch, "toggle 'delete branch on remove'"));
rows.push(fixed("enter", "show path in status bar"));
rows.push(HelpRow::Blank);
rows.push(HelpRow::Section("Issue / PR".to_string()));
rows.push(HelpRow::Blank);
let open_picks: Vec<String> = [
(ModalAction::OpenMenuIssue, "issue"),
(ModalAction::OpenMenuPr, "pull request"),
]
.into_iter()
.filter_map(|(a, l)| modal.primary_key(a).map(|k| format!("{k}={l}")))
.collect();
let open_desc = if open_picks.is_empty() {
"open menu".to_string()
} else {
format!("open menu — {}", open_picks.join(" · "))
};
rows.push(entry(Action::BrowseLinks, &open_desc));
let key = |a: ModalAction| modal.primary_key(a);
let nav: Vec<String> = [ModalAction::LinkChooseNext, ModalAction::LinkChoosePrev]
.into_iter()
.filter_map(key)
.collect();
let picks: Vec<String> = [ModalAction::LinkChooseIssue, ModalAction::LinkChoosePr]
.into_iter()
.filter_map(key)
.collect();
let mut parts: Vec<String> = Vec::new();
match (nav.is_empty(), key(ModalAction::LinkChooseAccept)) {
(false, Some(a)) => parts.push(format!("{} + {a}", nav.join("/"))),
(false, None) => parts.push(nav.join("/")),
(true, Some(a)) => parts.push(a),
(true, None) => {}
}
if !picks.is_empty() {
parts.push(format!("or {}", picks.join("/")));
}
parts.push("then digits".to_string());
rows.push(entry(
Action::LinkPrompt,
&format!("link prompt — {}", parts.join(", ")),
));
}
rows.push(entry(Action::Help, "this help"));
if !picker_mode {
rows.push(entry(Action::CommandPalette, "open the command palette"));
}
if !picker_mode {
rows.extend([
HelpRow::Blank,
HelpRow::Section("Create Form".to_string()),
HelpRow::Blank,
modal_entry(ModalAction::CreatePrevType, "previous branch type"),
modal_entry(ModalAction::CreateNextType, "next branch type"),
modal_entry(ModalAction::CreateNextField, "next field"),
modal_entry(ModalAction::CreatePrevField, "previous field"),
modal_entry(ModalAction::CreateSubmit, "submit (on the last field) / next field"),
modal_entry(
ModalAction::CreateToggleMode,
"toggle structured fields ↔ free-form name",
),
modal_entry(ModalAction::CreateCancel, "cancel"),
fixed(
"0-9",
"type into the issue field, where the patterns ask for one (digits only)",
),
fixed("any char", "type into the focused text field"),
fixed("Backspace", "delete the last character"),
HelpRow::Blank,
HelpRow::Section("Delete Worktree".to_string()),
HelpRow::Blank,
modal_entry(ModalAction::ConfirmFocusConfirm, "focus the Confirm button"),
modal_entry(ModalAction::ConfirmFocusCancel, "focus the Cancel button"),
modal_entry(ModalAction::ConfirmToggleFocus, "toggle the focused button"),
modal_entry(
ModalAction::ConfirmActivate,
"activate the focused button (defaults to Cancel)",
),
modal_entry(ModalAction::ConfirmConfirm, "confirm"),
modal_entry(ModalAction::ConfirmCancel, "cancel"),
]);
rows.extend([
HelpRow::Blank,
HelpRow::Section("Browse Links".to_string()),
HelpRow::Blank,
modal_entry(ModalAction::OpenMenuToggle, "toggle issue / pull request"),
modal_entry(
ModalAction::OpenMenuAccept,
"open the highlighted target in the browser",
),
modal_entry(ModalAction::OpenMenuIssue, "open the linked issue directly"),
modal_entry(ModalAction::OpenMenuPr, "open the linked pull request directly"),
modal_entry(ModalAction::OpenMenuClose, "close"),
HelpRow::Blank,
HelpRow::Section("Link Prompt".to_string()),
HelpRow::Blank,
modal_entry(ModalAction::LinkChooseNext, "next target (issue / PR)"),
modal_entry(ModalAction::LinkChoosePrev, "previous target"),
modal_entry(ModalAction::LinkChooseIssue, "pick issue directly"),
modal_entry(ModalAction::LinkChoosePr, "pick pull request directly"),
modal_entry(ModalAction::LinkChooseAccept, "accept the highlighted target"),
modal_entry(ModalAction::LinkChooseCancel, "cancel"),
fixed("0-9", "type the issue / PR number"),
fixed("Backspace", "erase the last digit"),
modal_entry(ModalAction::LinkInputSubmit, "submit the typed number"),
modal_entry(ModalAction::LinkInputCancel, "cancel the number input"),
HelpRow::Blank,
HelpRow::Section("Exec Profiles".to_string()),
HelpRow::Blank,
modal_entry(ModalAction::ExecPickerNext, "next profile"),
modal_entry(ModalAction::ExecPickerPrev, "previous profile"),
modal_entry(ModalAction::ExecPickerAccept, "run the profile in a PTY overlay"),
modal_entry(ModalAction::ExecPickerCancel, "cancel"),
HelpRow::Blank,
HelpRow::Section("Clean Reclaim".to_string()),
HelpRow::Blank,
modal_entry(ModalAction::CleanNext, "next profile"),
modal_entry(ModalAction::CleanPrev, "previous profile"),
modal_entry(ModalAction::CleanConfirm, "reclaim (starts the safety countdown)"),
modal_entry(ModalAction::CleanCancel, "cancel"),
HelpRow::Blank,
HelpRow::Section("Agent Sessions".to_string()),
HelpRow::Blank,
modal_entry(ModalAction::DetailSelectNext, "next session"),
modal_entry(ModalAction::DetailSelectPrev, "previous session"),
modal_entry(ModalAction::DetailAttach, "attach to the selected session"),
modal_entry(ModalAction::DetailDetach, "detach the selected session"),
modal_entry(ModalAction::DetailInput, "attach by id (palette-style prompt)"),
fixed("any char", "attach prompt: type to filter the session ids"),
fixed("Backspace", "attach prompt: delete the last character"),
fixed("Up/Down", "attach prompt: move the highlight"),
fixed("enter", "attach prompt: attach the highlighted session"),
fixed("Esc", "attach prompt: back to the list"),
modal_entry(ModalAction::DetailClose, "close"),
HelpRow::Blank,
HelpRow::Section("CI Checks".to_string()),
HelpRow::Blank,
modal_entry(ModalAction::CiChecksNext, "next check"),
modal_entry(ModalAction::CiChecksPrev, "previous check"),
modal_entry(ModalAction::CiChecksOpen, "open the check's details URL in the browser"),
modal_entry(ModalAction::CiChecksFilter, "filter the checks by name"),
modal_entry(ModalAction::CiChecksRefresh, "re-fetch the PR and refresh the rows"),
fixed("any char", "filter: type to narrow the checks"),
fixed("Backspace", "filter: delete the last character"),
fixed("Up/Down", "filter: move the highlight"),
fixed("enter", "filter: open the highlighted check's URL"),
fixed("Esc", "filter: back to the list"),
modal_entry(ModalAction::CiChecksClose, "close"),
HelpRow::Blank,
HelpRow::Section("Bootstrap Report".to_string()),
HelpRow::Blank,
modal_entry(ModalAction::ReportClose, "close"),
]);
}
if !picker_mode {
rows.extend([
HelpRow::Blank,
HelpRow::Section("Command Palette".to_string()),
HelpRow::Blank,
modal_entry(ModalAction::CommandPaletteNext, "next command"),
modal_entry(ModalAction::CommandPalettePrev, "previous command"),
modal_entry(ModalAction::CommandPaletteAccept, "run the highlighted command"),
fixed("a-z 0-9 _ -", "fuzzy-filter the commands (lowercase input only)"),
fixed("Backspace", "delete the last filter character"),
modal_entry(ModalAction::CommandPaletteClose, "close"),
HelpRow::Blank,
HelpRow::Section("Command Logs".to_string()),
HelpRow::Blank,
modal_entry(ModalAction::CommandLogsScrollDown, "scroll down"),
modal_entry(ModalAction::CommandLogsScrollUp, "scroll up"),
modal_entry(ModalAction::CommandLogsScrollLeft, "pan left"),
modal_entry(ModalAction::CommandLogsScrollRight, "pan right"),
modal_entry(ModalAction::CommandLogsScrollTop, "jump to the top"),
modal_entry(ModalAction::CommandLogsScrollBottom, "jump to the bottom"),
modal_entry(
ModalAction::CommandLogsCopy,
"copy the full transcript to the clipboard",
),
modal_entry(ModalAction::CommandLogsClose, "close"),
HelpRow::Blank,
HelpRow::Section("Settings".to_string()),
HelpRow::Blank,
modal_entry(ModalAction::ConfigNextTab, "next tab"),
modal_entry(ModalAction::ConfigPrevTab, "previous tab"),
modal_entry(ModalAction::ConfigToggleLayer, "toggle the Project / Global layer"),
modal_entry(ModalAction::ConfigSelectNext, "next setting (All tab: scroll down)"),
modal_entry(ModalAction::ConfigSelectPrev, "previous setting (All tab: scroll up)"),
modal_entry(
ModalAction::ConfigActivate,
"toggle / edit the selected setting (Keys tab: start a key capture — a modal verb commits on its first stroke)",
),
modal_entry(ModalAction::ConfigScrollLeft, "pan left (All tab)"),
modal_entry(ModalAction::ConfigScrollRight, "pan right (All tab)"),
modal_entry(ModalAction::ConfigScrollTop, "jump to the top (All tab)"),
modal_entry(ModalAction::ConfigScrollBottom, "jump to the bottom (All tab)"),
modal_entry(
ModalAction::ConfigEditSubmit,
"commit the edited value / the captured global chord",
),
modal_entry(ModalAction::ConfigEditCancel, "cancel the edit / the key capture"),
fixed(
"any char",
"type the value — free text for text fields, digits for numeric ones",
),
fixed(
"Backspace",
"erase the last character / drop the last stroke of a global capture",
),
fixed("enter", "capture: commit the global chord (reserved, despite rebinds)"),
fixed("Esc", "capture: cancel (reserved, despite rebinds)"),
modal_entry(ModalAction::ConfigClose, "close"),
HelpRow::Blank,
HelpRow::Section("PTY Overlay".to_string()),
HelpRow::Blank,
fixed(
"Esc",
"close the overlay — other keys pass through (any key but Ctrl-C closes a finished exec run)",
),
]);
rows.extend([
HelpRow::Blank,
HelpRow::Section("Help Overlay".to_string()),
HelpRow::Blank,
modal_entry(ModalAction::HelpScrollDown, "scroll down"),
modal_entry(ModalAction::HelpScrollUp, "scroll up"),
modal_entry(ModalAction::HelpScrollLeft, "pan left"),
modal_entry(ModalAction::HelpScrollRight, "pan right"),
modal_entry(ModalAction::HelpScrollTop, "jump to the top"),
modal_entry(ModalAction::HelpScrollBottom, "jump to the bottom"),
modal_entry(ModalAction::HelpClose, "close"),
]);
}
rows
}
pub fn help_lines(km: &super::keymap::Keymap, modal: &ModalKeymap, picker_mode: bool) -> Vec<String> {
let ctx = if picker_mode {
HintContext::Picker
} else {
HintContext::Worktrees
};
help_rows(km, modal, ctx)
.into_iter()
.map(|row| match row {
HelpRow::Title(s) | HelpRow::Subtitle(s) | HelpRow::Section(s) => s,
HelpRow::Blank => String::new(),
HelpRow::Entry { keys, label } => {
let keys = if keys.is_empty() { "(unbound)".to_string() } else { keys };
format!(" {:<13} {}", keys, label)
}
})
.collect()
}
pub fn badge_group_width(keys: &str) -> usize {
if keys.is_empty() || keys == "(unbound)" {
return "(unbound)".chars().count();
}
let chords: Vec<&str> = keys.split(", ").collect();
let glyphs: usize = chords.iter().map(|c| c.chars().count()).sum();
glyphs + chords.len().saturating_sub(1)
}
pub fn help_entry_line(keys: &str, label: &str, max_group_w: usize, theme: &Theme) -> Line<'static> {
let key_style = hint_key_style(theme);
let muted_style = Style::default().fg(theme.muted);
let mut spans: Vec<Span<'static>> = vec![Span::raw(" ")];
if keys.is_empty() || keys == "(unbound)" {
spans.push(Span::styled("(unbound)", muted_style));
} else {
for (i, chord) in keys.split(", ").enumerate() {
if i > 0 {
spans.push(Span::raw(" "));
}
spans.push(Span::styled(chord.to_string(), key_style));
}
}
let pad = max_group_w.saturating_sub(badge_group_width(keys)) + 1;
spans.push(Span::raw(" ".repeat(pad)));
spans.push(Span::styled(label.to_string(), help_label_style(theme)));
Line::from(spans)
}
fn draw_help(f: &mut Frame, app: &mut App) {
let area = centered(60, 60, f.area());
let rows = help_rows(&app.keymap, &app.modal_keymap, app.pane_hint_context());
let accent = app.theme.accent;
let heading_style = Style::default().fg(accent).add_modifier(Modifier::BOLD);
let subtitle_style = Style::default().fg(app.theme.branch).add_modifier(Modifier::ITALIC);
let max_group_w = rows
.iter()
.filter_map(|r| match r {
HelpRow::Entry { keys, .. } => Some(badge_group_width(keys)),
_ => None,
})
.max()
.unwrap_or(0);
let mut header_lines: Vec<Line<'static>> = Vec::new();
let mut body_lines: Vec<Line<'static>> = Vec::new();
for row in rows {
match row {
HelpRow::Title(t) => header_lines.push(Line::from(Span::styled(t, heading_style)).centered()),
HelpRow::Subtitle(t) => header_lines.push(Line::from(Span::styled(t, subtitle_style)).centered()),
HelpRow::Section(t) => body_lines.push(Line::from(Span::styled(
t,
help_section_style(help_body_section_color(&app.theme)),
))),
HelpRow::Blank => body_lines.push(Line::from(String::new())),
HelpRow::Entry { keys, label } => {
body_lines.push(help_entry_line(&keys, &label, max_group_w, &app.theme));
}
}
}
let block = overlay_block(accent);
let inner_area = block.inner(area);
f.render_widget(Clear, area);
f.render_widget(block, area);
let header_h = header_lines.len() as u16;
let [header_area, body_area, footer_area] =
Layout::vertical([Constraint::Length(header_h), Constraint::Min(1), Constraint::Length(1)]).areas(inner_area);
f.render_widget(Paragraph::new(header_lines), header_area);
let body_viewport = body_area.height as usize;
app.help_max_scroll = (body_lines.len().saturating_sub(body_viewport)) as u16;
app.help_scroll = app.help_scroll.min(app.help_max_scroll);
let scroll = app.help_scroll;
let text_area = scrollable_body_area(f, body_area, scroll, body_lines.len(), &app.theme);
let content_width = body_lines.iter().map(Line::width).max().unwrap_or(0);
app.help_max_x_scroll = content_width.saturating_sub(text_area.width as usize) as u16;
app.help_x_scroll = app.help_x_scroll.min(app.help_max_x_scroll);
let x_scroll = app.help_x_scroll;
f.render_widget(Paragraph::new(body_lines).scroll((scroll, x_scroll)), text_area);
f.render_widget(
modal_hint_for_context(HintContext::Help, &app.keymap, &app.modal_keymap, &app.theme),
footer_area,
);
}
fn draw_command_logs(f: &mut Frame, app: &mut App) {
let area = centered(90, 85, f.area());
let accent = app.theme.accent;
let muted = app.theme.muted;
let ok_color = app.theme.clean;
let err_color = app.theme.prunable;
let label_style = help_label_style(&app.theme);
let muted_style = Style::default().fg(muted);
let heading_style = Style::default().fg(accent).add_modifier(Modifier::BOLD);
let block = overlay_block(accent);
let inner = block.inner(area);
f.render_widget(Clear, area);
f.render_widget(block, area);
let [header_area, body_area, footer_area] =
Layout::vertical([Constraint::Length(1), Constraint::Min(1), Constraint::Length(1)]).areas(inner);
f.render_widget(
Paragraph::new(Line::from(Span::styled("Command Logs", heading_style)).centered()),
header_area,
);
let rule = "-".repeat(body_area.width as usize);
let mut lines: Vec<Line<'static>> = Vec::new();
if app.command_logs.entries.is_empty() {
lines.push(Line::from(Span::styled("No commands run yet.", muted_style)));
} else {
for (i, entry) in app.command_logs.entries.iter().rev().enumerate() {
if i > 0 {
lines.push(Line::from(String::new()));
lines.push(Line::from(Span::styled(rule.clone(), muted_style)));
lines.push(Line::from(String::new()));
}
lines.push(Line::from(vec![
Span::styled("$ ", Style::default().fg(accent).add_modifier(Modifier::BOLD)),
Span::styled(entry.command.clone(), label_style),
]));
let (color, detail) = match &entry.status {
CommandStatus::Exited(Some(0)) => (ok_color, format!("→ exit 0 ({} ms)", entry.duration.as_millis())),
CommandStatus::Exited(Some(code)) => (
err_color,
format!("→ exit {} ({} ms)", code, entry.duration.as_millis()),
),
CommandStatus::Exited(None) => (err_color, format!("→ terminated ({} ms)", entry.duration.as_millis())),
CommandStatus::Spawn => (err_color, "✗ failed to spawn".to_string()),
};
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(detail, Style::default().fg(color)),
]));
if !entry.output.is_empty() {
const MAX_OUTPUT_LINES: usize = 6;
let out: Vec<&str> = entry.output.lines().collect();
let start = out.len().saturating_sub(MAX_OUTPUT_LINES);
if start > 0 {
lines.push(Line::from(Span::styled(
format!(" … {} earlier line(s)", start),
muted_style,
)));
}
for l in &out[start..] {
lines.push(Line::from(Span::styled(format!(" {}", l), muted_style)));
}
}
}
}
let body_viewport = body_area.height as usize;
app.command_logs.max_scroll = (lines.len().saturating_sub(body_viewport)) as u16;
app.command_logs.scroll = app.command_logs.scroll.min(app.command_logs.max_scroll);
let scroll = app.command_logs.scroll;
let text_area = scrollable_body_area(f, body_area, scroll, lines.len(), &app.theme);
let content_w = lines.iter().map(Line::width).max().unwrap_or(0);
app.command_logs.max_x_scroll = content_w.saturating_sub(text_area.width as usize) as u16;
app.command_logs.x_scroll = app.command_logs.x_scroll.min(app.command_logs.max_x_scroll);
let x_scroll = app.command_logs.x_scroll;
f.render_widget(Paragraph::new(lines).scroll((scroll, x_scroll)), text_area);
let footer_owned = command_logs_footer_hints(&app.modal_keymap);
let footer_hints: Vec<(&str, &str)> = footer_owned.iter().map(|(k, l)| (k.as_str(), l.as_str())).collect();
f.render_widget(modal_hint_line(&footer_hints, &app.theme), footer_area);
}
fn scrollable_body_area(f: &mut Frame, area: Rect, offset: u16, content_len: usize, theme: &Theme) -> Rect {
let viewport = area.height as usize;
if content_len <= viewport || area.width < 2 {
return area;
}
let max_scroll = content_len - viewport;
let mut state = ScrollbarState::new(max_scroll + 1)
.position(offset as usize)
.viewport_content_length(viewport);
let bar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None)
.thumb_style(Style::default().fg(theme.accent))
.track_style(Style::default().fg(theme.muted));
f.render_stateful_widget(bar, area, &mut state);
Rect {
width: area.width.saturating_sub(1),
..area
}
}
fn settings_all_lines(app: &App) -> Vec<Line<'static>> {
let accent = app.theme.accent;
let muted = app.theme.muted;
let label_style = help_label_style(&app.theme);
let muted_style = Style::default().fg(muted);
let mut lines: Vec<Line<'static>> = Vec::new();
if app.config_panel.rows.is_empty() {
lines.push(Line::from(Span::styled("No configuration resolved.", muted_style)));
return lines;
}
let mut current_section: Option<String> = None;
for row in &app.config_panel.rows {
let section = row.key.split(['.', '[']).next().unwrap_or("").to_string();
if current_section.as_deref() != Some(section.as_str()) {
if current_section.is_some() {
lines.push(Line::from(String::new()));
}
lines.push(Line::from(Span::styled(
format!("[{section}]"),
help_section_style(accent),
)));
current_section = Some(section);
}
let src_color = match row.source {
ConfigSource::Repo => app.theme.clean,
ConfigSource::User => app.theme.branch,
ConfigSource::Default => muted,
};
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(format!("{:<7}", row.source.label()), Style::default().fg(src_color)),
Span::raw(" "),
Span::styled(row.key.clone(), label_style),
Span::styled(" = ", muted_style),
Span::styled(row.value.clone(), Style::default().fg(Color::White)),
]));
}
lines
}
fn settings_fields_lines(app: &App, fields: &[SettingField]) -> Vec<Line<'static>> {
let accent = app.theme.accent;
let muted = app.theme.muted;
let label_style = help_label_style(&app.theme);
let muted_style = Style::default().fg(muted);
let panel = &app.config_panel;
let mut lines: Vec<Line<'static>> = Vec::new();
for (i, field) in fields.iter().enumerate() {
let selected = i == panel.selected;
let editing = selected && panel.editing.is_some();
let value = if editing {
format!("{}_", panel.editing.as_deref().unwrap_or(""))
} else {
field.current(&app.config)
};
let marker = if selected { "›" } else { " " };
let marker_style = Style::default().fg(accent).add_modifier(Modifier::BOLD);
let value_style = if selected {
Style::default().fg(accent).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let mut spans = vec![
Span::styled(format!(" {marker} "), marker_style),
Span::styled(format!("{:<24}", field.label()), label_style),
Span::styled(value, value_style),
];
if selected && panel.layer.source() == ConfigSource::User && panel.field_source(*field) == Some(ConfigSource::Repo)
{
spans.push(Span::styled(" — set in .gwm.toml; switch to Project", muted_style));
}
lines.push(Line::from(spans));
}
lines
}
fn settings_keys_lines(app: &App) -> (Vec<Line<'static>>, Option<usize>) {
let accent = app.theme.accent;
let muted = app.theme.muted;
let label_style = help_label_style(&app.theme);
let muted_style = Style::default().fg(muted);
let panel = &app.config_panel;
let mut lines: Vec<Line<'static>> = Vec::new();
let mut selected_line: Option<usize> = None;
if panel.key_rows.is_empty() {
lines.push(Line::from(Span::styled("No bindings resolved.", muted_style)));
return (lines, None);
}
let mut current_scope: Option<String> = None;
for (i, row) in panel.key_rows.iter().enumerate() {
if current_scope.as_deref() != Some(row.scope.as_str()) {
if current_scope.is_some() {
lines.push(Line::from(String::new()));
}
lines.push(Line::from(Span::styled(
format!("[{}]", row.scope),
help_section_style(accent),
)));
current_scope = Some(row.scope.clone());
}
let selected = i == panel.selected;
if selected {
selected_line = Some(lines.len());
}
let capturing = selected && panel.capture.is_some();
let marker = if selected { "›" } else { " " };
let marker_style = Style::default().fg(accent).add_modifier(Modifier::BOLD);
let src_color = match row.source {
ConfigSource::Repo => app.theme.clean,
ConfigSource::User => app.theme.branch,
ConfigSource::Default => muted,
};
let key_span = if capturing {
let pending = panel
.capture
.as_ref()
.map(|c| c.pending.iter().map(|s| s.to_string()).collect::<Vec<_>>().join(" "))
.unwrap_or_default();
Span::styled(
format!("[ {pending}_ ]"),
Style::default().fg(accent).add_modifier(Modifier::BOLD),
)
} else {
let shown = if row.keys.is_empty() {
"(unbound)".to_string()
} else {
row.keys.clone()
};
let style = if row.keys.is_empty() {
muted_style
} else if selected {
Style::default().fg(accent).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
Span::styled(shown, style)
};
lines.push(Line::from(vec![
Span::styled(format!(" {marker} "), marker_style),
Span::styled(format!("{:<7}", row.source.label()), Style::default().fg(src_color)),
Span::raw(" "),
Span::styled(format!("{:<24}", row.label), label_style),
key_span,
]));
}
(lines, selected_line)
}
fn draw_config_panel(f: &mut Frame, app: &mut App) {
let area = centered(60, 60, f.area());
let accent = app.theme.accent;
let muted = app.theme.muted;
let muted_style = Style::default().fg(muted);
let heading_style = Style::default().fg(accent).add_modifier(Modifier::BOLD);
let subtitle_style = Style::default().fg(app.theme.branch).add_modifier(Modifier::ITALIC);
let tab = app.config_panel.tab;
let editing = app.config_panel.editing.is_some();
let selected_kind = app.config_panel.selected_field().map(SettingField::kind);
let title = Line::from(Span::styled("Settings", heading_style)).centered();
let subtitle = Line::from(Span::styled(app.config_panel.layer.label(), subtitle_style)).centered();
let mut tab_spans: Vec<Span<'static>> = vec![Span::raw(" ")];
for (i, t) in SettingsTab::ALL.iter().enumerate() {
if i > 0 {
tab_spans.push(Span::raw(" "));
}
let style = if *t == tab { chip_style(accent) } else { muted_style };
tab_spans.push(Span::styled(format!(" {} ", t.label()), style));
}
let header_lines = vec![title, subtitle, Line::from(String::new()), Line::from(tab_spans)];
let mut selected_line: Option<usize> = None;
let body_lines = match tab {
SettingsTab::All => settings_all_lines(app),
SettingsTab::Keys => {
let (lines, sel) = settings_keys_lines(app);
selected_line = sel;
lines
}
other => {
let fields = other.fields();
if !fields.is_empty() {
selected_line = Some(app.config_panel.selected.min(fields.len().saturating_sub(1)));
}
settings_fields_lines(app, fields)
}
};
let capture_single = app.config_panel.capture.as_ref().map(|c| c.single_only);
let footer_owned = if let Some(single) = capture_single {
config_capture_footer_hints(&app.modal_keymap, single)
} else if editing {
config_edit_footer_hints(&app.modal_keymap)
} else {
config_nav_footer_hints(&app.modal_keymap, tab, selected_kind)
};
let footer_hints: Vec<(&str, &str)> = footer_owned.iter().map(|(k, l)| (k.as_str(), l.as_str())).collect();
let block = overlay_block(accent);
let inner = block.inner(area);
f.render_widget(Clear, area);
f.render_widget(block, area);
let header_h = header_lines.len() as u16;
let [header_area, body_area, footer_area] =
Layout::vertical([Constraint::Length(header_h), Constraint::Min(1), Constraint::Length(1)]).areas(inner);
f.render_widget(Paragraph::new(header_lines), header_area);
let body_viewport = body_area.height as usize;
app.config_panel.max_scroll = (body_lines.len().saturating_sub(body_viewport)) as u16;
if let Some(sel) = selected_line {
let scroll = app.config_panel.scroll as usize;
if sel < scroll {
app.config_panel.scroll = sel as u16;
} else if body_viewport > 0 && sel >= scroll + body_viewport {
app.config_panel.scroll = (sel + 1 - body_viewport) as u16;
}
}
app.config_panel.scroll = app.config_panel.scroll.min(app.config_panel.max_scroll);
let scroll = app.config_panel.scroll;
let text_area = scrollable_body_area(f, body_area, scroll, body_lines.len(), &app.theme);
let content_w = body_lines.iter().map(Line::width).max().unwrap_or(0);
app.config_panel.max_x_scroll = content_w.saturating_sub(text_area.width as usize) as u16;
app.config_panel.x_scroll = app.config_panel.x_scroll.min(app.config_panel.max_x_scroll);
let x_scroll = app.config_panel.x_scroll;
f.render_widget(Paragraph::new(body_lines).scroll((scroll, x_scroll)), text_area);
f.render_widget(modal_hint_line(&footer_hints, &app.theme), footer_area);
}
fn rename_pr_warning(app: &App, new_branch: &str, old_branch: &str) -> Option<String> {
if new_branch == old_branch {
return None;
}
let w = app.selected()?;
if !matches!(w.pr_state.or(w.link.pr_state)?, PrState::Open | PrState::Draft) {
return None;
}
Some(match w.link.pr {
Some(number) => format!("⚠ renaming the branch closes PR #{}", number),
None => "⚠ renaming the branch closes its open pull request".into(),
})
}
fn pattern_preview(app: &App, type_str: &str) -> (String, String) {
if app.create_form.mode == Mode::Freeform {
let name = crate::naming::WorktreeName::Freeform(app.create_form.name.clone());
return (
name
.branch_name(&app.config.worktree, &app.repo_name)
.unwrap_or_default(),
name
.worktree_dirname(&app.config.worktree, &app.repo_name)
.unwrap_or_default(),
);
}
let expand = |pattern: &str| {
crate::config::expand_placeholders(
pattern,
&app.repo_name,
Some(type_str),
Some(&app.create_form.issue),
Some(&app.create_form.desc),
None,
)
.unwrap_or_default()
};
(
expand(&app.config.worktree.branch_pattern),
expand(&app.config.worktree.path_pattern),
)
}
fn form_field_lines(app: &App, type_str: &str, type_desc: &str, value_w: usize, label_w: usize) -> Vec<Line<'static>> {
let accent = app.theme.accent;
let muted = app.theme.muted;
let surface = app.theme.selection_bg;
let label = |s: &str| format!("{:<label_w$}", s);
let mut lines: Vec<Line<'static>> = Vec::new();
for field in app.create_form.fields() {
if !lines.is_empty() {
lines.push(Line::from(String::new()));
}
lines.push(match field {
Field::Type => type_selector_line(
&label("Type"),
type_str,
type_desc,
app.create_form.field == Field::Type,
accent,
muted,
),
Field::Issue => field_input_line(
&label("Issue"),
&app.create_form.issue,
app.create_form.field == Field::Issue,
value_w,
accent,
muted,
surface,
),
Field::Desc => field_input_line(
&label("Desc"),
&app.create_form.desc,
app.create_form.field == Field::Desc,
value_w,
accent,
muted,
surface,
),
Field::Name => continue,
});
}
lines
}
fn draw_create(f: &mut Frame, app: &App) {
let accent = app.theme.accent;
let muted = app.theme.muted;
let clean = app.theme.clean;
let surface = app.theme.selection_bg;
let (type_str, type_desc) = app
.branch_types
.get(app.create_form.type_index)
.map(|t| (t.name.as_str(), t.description.as_str()))
.unwrap_or(("", "(no branch types configured)"));
let block = overlay_block(clean);
let term = f.area();
let outer = centered_box(70, 72, 1, term);
let inner_w = block.inner(outer).width as usize;
let label_w = 5usize;
let gutter = 2 + label_w + 2;
let value_w = inner_w.saturating_sub(gutter);
let label = |s: &str| format!("{:<label_w$}", s);
let freeform = app.create_form.mode == Mode::Freeform;
let (branch_raw, dir_raw) = if freeform {
(app.create_form.name.clone(), app.create_form.name.replace('/', "-"))
} else {
pattern_preview(app, type_str)
};
let branch = ellipsize_middle(&branch_raw, inner_w.saturating_sub(" Branch : ".len()));
let dirname = ellipsize_middle(&dir_raw, inner_w.saturating_sub(" Dir : ".len()));
let mut lines = overlay_title_lines(
if freeform {
"New Worktree — free-form"
} else {
"New Worktree"
},
clean,
);
lines.push(Line::from(vec![
Span::raw(" Branch : "),
Span::styled(branch, Style::default().fg(app.theme.branch)),
]));
lines.push(Line::from(vec![
Span::raw(" Dir : "),
Span::styled(dirname, Style::default().fg(app.theme.dirty)),
]));
lines.push(Line::from(String::new()));
if freeform {
lines.push(field_input_line(
&label("Name"),
&app.create_form.name,
app.create_form.field == Field::Name,
value_w,
accent,
muted,
surface,
));
} else {
lines.extend(form_field_lines(app, type_str, type_desc, value_w, label_w));
}
let height = lines.len() as u16 + 4 + 2 + 2 ;
let area = centered_box(70, 72, height, term);
let inner = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), ])
.split(block.inner(area));
f.render_widget(Clear, area);
f.render_widget(block, area);
f.render_widget(Paragraph::new(lines), inner[0]);
if app.is_create_worktree_loading() {
f.render_widget(
LoaderWidget::running(
app.spinner.glyph(DOT_FRAMES),
TaskKind::CreateWorktree.loading_label(),
None,
&app.theme,
)
.alignment(Alignment::Center),
inner[1],
);
} else if let Some(error) = app.create_failure.as_deref() {
f.render_widget(
LoaderWidget::failed("create failed", Some(error), &app.theme).alignment(Alignment::Center),
inner[1],
);
}
if !app.is_create_worktree_loading() {
f.render_widget(
Paragraph::new(create_buttons_line(accent, muted)).alignment(Alignment::Center),
inner[2],
);
f.render_widget(
Paragraph::new(modal_hint_for_context_with_fields(
app.create_hint_context(),
&app.keymap,
&app.modal_keymap,
&app.theme,
app.create_form.fields(),
)),
inner[4],
);
}
}
pub fn create_buttons_line(accent: Color, muted: Color) -> Line<'static> {
primary_cancel_buttons_line(" Create ", accent, muted)
}
pub fn rename_buttons_line(accent: Color, muted: Color) -> Line<'static> {
primary_cancel_buttons_line(" Rename ", accent, muted)
}
fn primary_cancel_buttons_line(primary_label: &'static str, accent: Color, muted: Color) -> Line<'static> {
let primary = chip_style(accent);
let idle = Style::default().fg(muted).add_modifier(Modifier::BOLD);
Line::from(vec![
Span::styled(primary_label, primary),
Span::raw(" "),
Span::styled(" Cancel ", idle),
])
}
pub fn type_selector_line(
label: &str,
name: &str,
desc: &str,
focused: bool,
accent: Color,
muted: Color,
) -> Line<'static> {
let arrow_style = if focused {
Style::default().fg(accent).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(muted)
};
let name_style = if focused {
chip_style(accent)
} else {
Style::default().fg(Color::White)
};
Line::from(vec![
Span::raw(" "),
Span::styled(label.to_string(), Style::default().fg(muted)),
Span::raw(" "),
Span::styled("‹ ", arrow_style),
Span::styled(format!(" {name} "), name_style),
Span::styled(" ›", arrow_style),
Span::raw(" "),
Span::styled(desc.to_string(), Style::default().fg(muted)),
])
}
pub fn field_input_line(
label: &str,
value: &str,
focused: bool,
value_width: usize,
accent: Color,
muted: Color,
surface: Color,
) -> Line<'static> {
let cursor = if focused { "_" } else { "" };
let mut field = format!(" {value}{cursor}");
let len = field.chars().count();
if len < value_width {
field.push_str(&" ".repeat(value_width - len));
}
let field_style = if focused {
Style::default().fg(Color::Black).bg(accent)
} else {
Style::default().fg(Color::White).bg(surface)
};
Line::from(vec![
Span::raw(" "),
Span::styled(label.to_string(), Style::default().fg(muted)),
Span::raw(" "),
Span::styled(field, field_style),
])
}
pub fn link_target_line(key: &str, label: &str, selected: bool, accent: Color, muted: Color) -> Line<'static> {
const BUTTON_WIDTH: usize = 17; let button = format!(" {key} {label} ");
let button = format!("{button:<BUTTON_WIDTH$}");
if selected {
let chip = chip_style(accent);
return Line::from(vec![Span::raw(" "), Span::styled(button, chip)]);
}
let idle = Style::default().fg(muted);
Line::from(vec![Span::raw(" "), Span::styled(button, idle)])
}
pub fn link_prompt_modal_width(term_width: u16) -> u16 {
let width = if term_width <= 80 {
term_width.saturating_mul(80) / 100
} else {
term_width.saturating_mul(60) / 100
};
width.min(72).min(term_width)
}
pub fn overlay_modal_width(term_width: u16) -> u16 {
let pct = if term_width <= 80 { 90 } else { 62 };
(term_width.saturating_mul(pct) / 100).clamp(48, 88).min(term_width)
}
pub fn help_section_style(section: Color) -> Style {
Style::default().fg(section).add_modifier(Modifier::BOLD)
}
pub fn confirm_detail_line(
label: &str,
value: impl Into<String>,
label_width: usize,
label_color: Color,
value_style: Style,
) -> Line<'static> {
Line::from(vec![
Span::styled(
format!("{label:<label_width$} ", label_width = label_width),
Style::default().fg(label_color),
),
Span::styled(value.into(), value_style),
])
}
pub fn delete_worktree_title() -> &'static str {
"Delete Worktree"
}
pub fn confirm_delete_branch_line(
enabled: bool,
key: &str,
label_width: usize,
accent: Color,
muted: Color,
) -> Line<'static> {
let key_style = chip_style(accent);
let value_style = chip_style(if enabled { accent } else { muted });
Line::from(vec![
Span::styled(
format!("{:<label_width$} ", "Delete Branch", label_width = label_width),
Style::default().fg(muted),
),
Span::styled(format!(" {key} "), key_style),
Span::raw(" "),
Span::styled(format!(" {enabled} "), value_style),
])
}
pub fn help_body_section_color(theme: &Theme) -> Color {
theme.locked
}
pub fn link_target_keys(ctx: HintContext, modal: &ModalKeymap) -> (String, String) {
let (issue, pr) = match ctx {
HintContext::OpenMenu => (ModalAction::OpenMenuIssue, ModalAction::OpenMenuPr),
_ => (ModalAction::LinkChooseIssue, ModalAction::LinkChoosePr),
};
(
modal.primary_key(issue).unwrap_or_default(),
modal.primary_key(pr).unwrap_or_default(),
)
}
pub fn link_open_modal_lines(app: &App, title: &str, selected: Option<LinkTarget>) -> Vec<Line<'static>> {
let accent = app.theme.accent;
let muted = app.theme.muted;
let ctx = if title == "Link" {
HintContext::LinkPrompt
} else {
HintContext::OpenMenu
};
let (issue_key, pr_key) = link_target_keys(ctx, &app.modal_keymap);
let mut lines = overlay_title_lines(title, accent);
lines.extend(github_status_lines(app, 56));
lines.push(Line::from(""));
lines.push(link_target_line(&issue_key, "Issue", selected == Some(LinkTarget::Issue), accent, muted).centered());
lines.push(link_target_line(&pr_key, "Pull Request", selected == Some(LinkTarget::Pr), accent, muted).centered());
push_modal_hint(&mut lines, ctx, &app.keymap, &app.modal_keymap, &app.theme);
lines
}
fn draw_confirm(f: &mut Frame, app: &App) {
let muted = app.theme.muted;
let danger = app.theme.prunable;
let block = overlay_block(danger);
let Some(w) = app.selected() else {
let mut lines = overlay_title_lines(delete_worktree_title(), danger);
lines.push(Line::from("nothing selected").centered());
let height = lines.len() as u16 + 2 + 2 ;
let area = centered_h(40, height, f.area());
f.render_widget(Clear, area);
f.render_widget(Paragraph::new(lines).block(block), area);
return;
};
let term = f.area();
let outer_w = term.width.saturating_mul(62) / 100;
let text_w = outer_w.saturating_sub(6) as usize;
let label_w = "Delete Branch".chars().count();
let value_w = text_w.saturating_sub(label_w + 2).max(1);
let name = ellipsize_middle(&w.name, value_w);
let path = ellipsize_middle(&tilde_compress(&w.path.display().to_string()), value_w);
let mut content: Vec<Line> = overlay_title_lines(delete_worktree_title(), danger);
content.push(confirm_detail_line(
"Worktree",
name,
label_w,
muted,
Style::default().fg(app.theme.dirty).add_modifier(Modifier::BOLD),
));
content.push(confirm_detail_line(
"Path",
path,
label_w,
muted,
Style::default().fg(muted),
));
if let Some(b) = &w.branch {
let branch = ellipsize_middle(b, value_w);
content.push(confirm_detail_line(
"Branch",
branch,
label_w,
muted,
Style::default().fg(app.theme.branch),
));
}
content.push(Line::from(""));
content.push(confirm_delete_branch_line(
app.delete_branch_on_remove,
&action_chord(&app.keymap, Action::ToggleDeleteBranch, "D"),
label_w,
app.theme.accent,
muted,
));
let height = content.len() as u16 + 4 + 2 + 2 ;
let area = centered_h(62, height, term);
f.render_widget(Clear, area);
let inner = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), ])
.split(block.inner(area));
f.render_widget(block, area);
f.render_widget(Paragraph::new(content).wrap(Wrap { trim: false }), inner[0]);
if app.is_delete_worktree_loading() {
f.render_widget(
LoaderWidget::running(
app.spinner.glyph(DOT_FRAMES),
TaskKind::DeleteWorktree.loading_label(),
None,
&app.theme,
)
.alignment(Alignment::Center),
inner[1],
);
} else if let Some(error) = app.delete_failure.as_deref() {
f.render_widget(
LoaderWidget::failed("delete failed", Some(error), &app.theme).alignment(Alignment::Center),
inner[1],
);
} else if app.confirm_is_countdown_mode() && app.confirm.is_armed() {
let now = Instant::now();
let mut spans = vec![Span::styled(
format!("{} ", app.spinner.glyph(DOT_FRAMES)),
Style::default().fg(danger).add_modifier(Modifier::BOLD),
)];
spans.extend(countdown_bar(
app.confirm_countdown_progress(now),
app.confirm_countdown_remaining_secs(now),
danger,
app.theme.dirty,
muted,
));
f.render_widget(Paragraph::new(Line::from(spans)).alignment(Alignment::Center), inner[1]);
}
if !app.is_delete_worktree_loading() {
f.render_widget(
Paragraph::new(confirm_buttons_line(
app.confirm.focused_button(),
app.theme.accent,
muted,
))
.alignment(Alignment::Center),
inner[2],
);
f.render_widget(
Paragraph::new(modal_hint_for_context(
HintContext::Confirm,
&app.keymap,
&app.modal_keymap,
&app.theme,
)),
inner[4],
);
}
}
pub fn confirm_buttons_line(focus: ConfirmButton, accent: Color, muted: Color) -> Line<'static> {
let focused = chip_style(accent);
let idle = Style::default().fg(muted).add_modifier(Modifier::BOLD);
let (confirm_style, cancel_style) = match focus {
ConfirmButton::Confirm => (focused, idle),
ConfirmButton::Cancel => (idle, focused),
};
Line::from(vec![
Span::styled(" Confirm ", confirm_style),
Span::raw(" "),
Span::styled(" Cancel ", cancel_style),
])
}
fn countdown_bar<'a>(
progress: f64,
remaining_secs: u64,
filled_color: Color,
secs_color: Color,
frame_color: Color,
) -> Vec<Span<'a>> {
const CELLS: usize = 10;
let filled = filled_cells_for_progress(progress, CELLS);
let bar: String = std::iter::repeat_n('█', filled)
.chain(std::iter::repeat_n('░', CELLS - filled))
.collect();
vec![
Span::styled(" [", Style::default().fg(frame_color)),
Span::styled(bar, Style::default().fg(filled_color).add_modifier(Modifier::BOLD)),
Span::styled("] ", Style::default().fg(frame_color)),
Span::styled(
format!("{remaining_secs}s"),
Style::default().fg(secs_color).add_modifier(Modifier::BOLD),
),
]
}
pub fn filled_cells_for_progress(progress: f64, cells: usize) -> usize {
if progress >= 1.0 {
return cells;
}
if progress <= 0.0 || cells == 0 {
return 0;
}
let raw = (progress * cells as f64).floor() as usize;
raw.min(cells.saturating_sub(1))
}
pub fn bootstrap_report_lines(report: Option<&BootstrapReport>, theme: &Theme) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
if let Some(report) = report {
for step in &report.steps {
let sigil = step.status.sigil();
let color = match step.status {
StepStatus::Ok => theme.clean,
StepStatus::Skipped => theme.muted,
StepStatus::Warning => theme.dirty,
StepStatus::Failed => theme.prunable,
};
lines.push(Line::from(vec![
Span::styled(
format!(" {} ", sigil),
Style::default().fg(color).add_modifier(Modifier::BOLD),
),
Span::styled(step.label.clone(), Style::default().fg(theme.name)),
]));
for detail_line in step.detail.lines() {
lines.push(Line::from(Span::styled(
format!(" {}", detail_line),
Style::default().fg(theme.muted),
)));
}
}
} else {
lines.push(Line::from("(no report)"));
}
lines
}
fn draw_report(f: &mut Frame, app: &App) {
let accent = app.theme.accent;
let logs = bootstrap_report_lines(app.report.as_ref(), &app.theme);
let term = f.area();
let logs_height = (logs.len() as u16 + 2).max(3);
let height = (2 + logs_height + 2 + 2 + 2)
.min(term.height.saturating_mul(80) / 100);
let area = centered_h(80, height, term);
let block = overlay_block(accent);
let inner = block.inner(area);
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Min(3), Constraint::Length(1), Constraint::Length(1), ])
.split(inner);
f.render_widget(Clear, area);
f.render_widget(block, area);
f.render_widget(
Paragraph::new(
Line::from(Span::styled(
"Bootstrap Report",
Style::default().fg(accent).add_modifier(Modifier::BOLD),
))
.centered(),
),
layout[0],
);
render_section(f, layout[2], " Logs ", SectionBody::new(&logs), accent, 0, None);
f.render_widget(
Paragraph::new(modal_hint_for_context(
HintContext::Report,
&app.keymap,
&app.modal_keymap,
&app.theme,
)),
layout[4],
);
}
fn draw_pty_overlay(f: &mut Frame, app: &mut App) {
let term = f.area();
let area = centered(90, 90, term);
f.render_widget(Clear, area);
let title = match app.pty_overlay.as_ref().map(|p| (p.kind, p.finished)) {
Some((PtyKind::LazyGit, _)) => " LazyGit ",
Some((PtyKind::Terminal, _)) => " Terminal ",
Some((PtyKind::Review, _)) => " Review ",
Some((PtyKind::Exec, false)) => " Exec ",
Some((PtyKind::Exec, true)) => " Exec · done — press any key ",
None => " Overlay ",
};
let block = overlay_block(app.theme.accent)
.title(title)
.title_alignment(ratatui::layout::Alignment::Center);
let inner = block.inner(area);
f.render_widget(block, area);
if let Some(pty) = app.pty_overlay.as_ref() {
let pseudo_terminal = tui_term::widget::PseudoTerminal::new(pty.parser.screen());
f.render_widget(pseudo_terminal, inner);
}
}
fn centered(pct_x: u16, pct_y: u16, area: Rect) -> Rect {
let v = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - pct_y) / 2),
Constraint::Percentage(pct_y),
Constraint::Percentage((100 - pct_y) / 2),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - pct_x) / 2),
Constraint::Percentage(pct_x),
Constraint::Percentage((100 - pct_x) / 2),
])
.split(v[1])[1]
}
pub fn centered_abs(width: u16, height: u16, area: Rect) -> Rect {
let width = width.min(area.width);
let height = height.min(area.height);
let x = area.x + area.width.saturating_sub(width) / 2;
let y = area.y + area.height.saturating_sub(height) / 2;
Rect { x, y, width, height }
}
fn centered_h(width_pct: u16, height: u16, area: Rect) -> Rect {
let width = area.width.saturating_mul(width_pct) / 100;
centered_abs(width, height, area)
}
fn centered_box(width_pct: u16, max_width: u16, height: u16, area: Rect) -> Rect {
let height = height.min(area.height);
let width = (area.width.saturating_mul(width_pct) / 100)
.min(max_width)
.min(area.width);
let x = area.x + area.width.saturating_sub(width) / 2;
let y = area.y + area.height.saturating_sub(height) / 2;
Rect { x, y, width, height }
}
fn overlay_block(color: Color) -> Block<'static> {
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.padding(Padding::symmetric(2, 1))
.border_style(Style::default().fg(color))
}
fn overlay_title_lines(title: &str, color: Color) -> Vec<Line<'static>> {
vec![
Line::from(Span::styled(
title.to_string(),
Style::default().fg(color).add_modifier(Modifier::BOLD),
))
.centered(),
Line::from(String::new()),
]
}
pub fn ellipsize_middle(s: &str, max: usize) -> String {
let count = s.chars().count();
if count <= max {
return s.to_string();
}
if max <= 1 {
return "…".to_string();
}
let keep = max - 1; let head = keep.div_ceil(2);
let tail = keep - head;
let head_str: String = s.chars().take(head).collect();
let tail_str: String = s.chars().skip(count - tail).collect();
format!("{head_str}…{tail_str}")
}
fn trunc(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
out.push('…');
out
}
}
fn draw_open_menu(f: &mut Frame, app: &App) {
let accent = app.theme.accent;
let lines = link_open_modal_lines(app, "Open in Browser", Some(app.open_menu_selected));
let height = lines.len() as u16 + 2 + 2 ;
let term = f.area();
let width = link_prompt_modal_width(term.width);
let area = centered_abs(width, height, term);
f.render_widget(Clear, area);
f.render_widget(Paragraph::new(lines).block(overlay_block(accent)), area);
}
fn draw_link_prompt(f: &mut Frame, app: &App) {
let accent = app.theme.accent;
let lines = match app.link_prompt_stage() {
LinkPromptStage::ChooseTarget => {
let selected = app.link_prompt_selected();
link_open_modal_lines(app, "Link", Some(selected))
}
LinkPromptStage::InputNumber => {
let label = match app.link_prompt_target() {
Some(super::app::LinkTarget::Issue) => "issue #",
Some(super::app::LinkTarget::Pr) => "PR #",
None => "#",
};
let mut lines = overlay_title_lines(
&format!("type the {} number", label.trim_end_matches('#').trim()),
accent,
);
lines.push(Line::from(format!(" {}{}_", label, app.link_prompt_number_input())));
push_modal_hint(
&mut lines,
HintContext::LinkInputNumber,
&app.keymap,
&app.modal_keymap,
&app.theme,
);
lines
}
};
let height = lines.len() as u16 + 2 + 2 ;
let term = f.area();
let width = link_prompt_modal_width(term.width);
let area = centered_abs(width, height, term);
f.render_widget(Clear, area);
f.render_widget(Paragraph::new(lines).block(overlay_block(accent)), area);
}
pub fn reclaim_size_color(bytes: u64, theme: &Theme) -> Color {
const MIB: u64 = 1024 * 1024;
if bytes >= 500 * MIB {
theme.prunable
} else if bytes >= 50 * MIB {
theme.dirty
} else {
theme.clean
}
}
pub fn clean_dir_icon(rel: &str) -> &'static str {
match rel.trim_start_matches('.').to_ascii_lowercase().as_str() {
"node_modules" => "\u{e718}", "target" => wt_tree::WT_RUST_ICON, "vendor" => "\u{e73d}", "venv" | "__pycache__" | "pytest_cache" | "mypy_cache" | "tox" => "\u{e73c}", "dist" | "build" | "out" | "output" | "bin" => "\u{f487}", "cache" | "turbo" | "parcel-cache" => "\u{f187}", "nuxt" | "next" | "svelte-kit" | "astro" | "vite" => "\u{e74e}", "coverage" => "\u{f201}", _ => wt_tree::WT_DIR_ICON, }
}
pub fn picker_window(len: usize, selected: usize, max_visible: usize) -> (usize, usize) {
if max_visible == 0 || len <= max_visible {
return (0, len);
}
let half = max_visible / 2;
let start = selected.saturating_sub(half).min(len - max_visible);
(start, start + max_visible)
}
fn picker_lines(
labels: &[&str],
selected: usize,
max_visible: usize,
inner: usize,
theme: &Theme,
) -> Vec<Line<'static>> {
let mut out = Vec::new();
if labels.is_empty() {
return out;
}
let textw = inner.saturating_sub(3);
let (start, end) = picker_window(labels.len(), selected, max_visible);
if start > 0 {
out.push(
Line::from(Span::styled(
format!("↑ {start} more"),
Style::default().fg(theme.muted),
))
.centered(),
);
}
for (i, label) in labels.iter().enumerate().take(end).skip(start) {
let marker = if i == selected { "▸" } else { " " };
let txt = format!(" {marker} {:<textw$}", ellipsize_middle(label, textw));
let style = if i == selected {
Style::default()
.fg(theme.accent)
.bg(theme.selection_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.muted)
};
out.push(Line::from(Span::styled(txt, style)));
}
if end < labels.len() {
out.push(
Line::from(Span::styled(
format!("↓ {} more", labels.len() - end),
Style::default().fg(theme.muted),
))
.centered(),
);
}
out
}
fn draw_exec_picker(f: &mut Frame, app: &App) {
let accent = app.theme.accent;
let term = f.area();
let width = overlay_modal_width(term.width);
let inner = width.saturating_sub(6) as usize; let mut lines = overlay_title_lines("Run an exec profile", accent);
let max_visible = (term.height as usize).saturating_sub(8).max(3);
let labels: Vec<&str> = app.exec_picker.profiles().iter().map(String::as_str).collect();
lines.extend(picker_lines(
&labels,
app.exec_picker.selected_index(),
max_visible,
inner,
&app.theme,
));
push_modal_hint(
&mut lines,
HintContext::ExecPicker,
&app.keymap,
&app.modal_keymap,
&app.theme,
);
let height = lines.len() as u16 + 2 + 2 ;
let area = centered_abs(width, height, term);
f.render_widget(Clear, area);
f.render_widget(Paragraph::new(lines).block(overlay_block(accent)), area);
}
fn draw_detail_overlay(f: &mut Frame, app: &App) {
use crate::tui::state::detail_overlay::{DetailMode, DetailRole};
let accent = app.theme.accent;
let term = f.area();
let width = overlay_modal_width(term.width);
let inner = width.saturating_sub(6) as usize; let ov = &app.detail_overlay;
if ov.mode == DetailMode::Input && ov.kind == crate::tui::state::detail_overlay::DetailKind::CiChecks {
let matches = app.ci_input_matches();
let list_h = (term.height as usize).saturating_sub(12).clamp(3, 10);
let (start, end) = picker_window(matches.len(), ov.input_selected, list_h);
let mut lines = overlay_title_lines("Filter CI checks", accent);
lines.push(Line::from(vec![
Span::styled("filter: ", Style::default().fg(app.theme.muted)),
Span::styled(
ov.input.clone(),
Style::default().fg(app.theme.name).add_modifier(Modifier::BOLD),
),
Span::styled("▏", Style::default().fg(accent)),
]));
lines.push(Line::from(String::new()));
if matches.is_empty() {
lines.push(Line::from(Span::styled(
"no matching check",
Style::default().fg(app.theme.muted),
)));
}
for (i, row_idx) in matches.iter().enumerate().take(end).skip(start) {
let Some(row) = ov.rows.get(*row_idx) else { continue };
let label_color = match row.role {
DetailRole::Success => app.theme.clean,
DetailRole::Failure => app.theme.prunable,
DetailRole::Running => app.theme.dirty,
_ => app.theme.name,
};
let text = format!("{} {}", row.label, row.value);
let pad = inner.saturating_sub(text.chars().count());
let mut style = Style::default().fg(label_color);
if i == ov.input_selected {
style = style.bg(app.theme.selection_bg).add_modifier(Modifier::BOLD);
}
lines.push(Line::from(Span::styled(format!("{}{}", text, " ".repeat(pad)), style)));
}
for _ in matches.len().min(end).saturating_sub(start)..list_h {
lines.push(Line::from(String::new()));
}
let height = (2 + list_h + 2) as u16 + 2 + 2 ;
let area = centered_abs(width, height, term);
f.render_widget(Clear, area);
f.render_widget(Paragraph::new(lines).block(overlay_block(accent)), area);
let list_rect = Rect {
x: area.x + 1,
y: area.y + 6,
width: area.width.saturating_sub(2),
height: list_h as u16,
}
.intersection(area);
if list_rect.height > 0 {
let _ = scrollable_body_area(f, list_rect, start as u16, matches.len(), &app.theme);
}
return;
}
if ov.mode == DetailMode::Input {
let candidates = app.agent_input_candidates();
let list_h = (term.height as usize).saturating_sub(12).clamp(3, 10);
let (start, end) = picker_window(candidates.len(), ov.input_selected, list_h);
let now = std::time::SystemTime::now();
let mut lines = overlay_title_lines("Attach a session", accent);
lines.push(Line::from(vec![
Span::styled("id: ", Style::default().fg(app.theme.muted)),
Span::styled(
ov.input.clone(),
Style::default().fg(app.theme.name).add_modifier(Modifier::BOLD),
),
Span::styled("▏", Style::default().fg(accent)),
]));
lines.push(Line::from(String::new()));
if candidates.is_empty() {
lines.push(Line::from(Span::styled(
"no matching session",
Style::default().fg(app.theme.muted),
)));
}
for (i, sess) in candidates.iter().enumerate().take(end).skip(start) {
let freshness = crate::agent_sessions::Freshness::classify(sess.last_activity, sess.ended, now);
let color = match freshness {
crate::agent_sessions::Freshness::Active => app.theme.clean,
crate::agent_sessions::Freshness::Idle => app.theme.muted,
};
let identity = sess.name.as_deref().unwrap_or(&sess.id);
let text = format!("{:<9} {}", sess.kind.display(), identity);
let pad = inner.saturating_sub(text.chars().count());
let mut style = Style::default().fg(color);
let mut pad_style = Style::default();
if i == ov.input_selected {
style = style.bg(app.theme.selection_bg).add_modifier(Modifier::BOLD);
pad_style = pad_style.bg(app.theme.selection_bg);
}
lines.push(Line::from(vec![
Span::styled(text, style),
Span::styled(" ".repeat(pad), pad_style),
]));
}
let shown = if candidates.is_empty() { 1 } else { end - start };
for _ in shown..list_h {
lines.push(Line::from(String::new()));
}
lines.push(Line::from(String::new()));
lines.push(modal_hint_line(
&[
("type", "filter"),
("↑/↓", "pick"),
("Enter", "attach"),
("Esc", "back"),
],
&app.theme,
));
let height = lines.len() as u16 + 2 + 2 ;
let area = centered_abs(width, height, term);
f.render_widget(Clear, area);
f.render_widget(Paragraph::new(lines).block(overlay_block(accent)), area);
let list_rect = Rect {
x: area.x + 1,
y: area.y + 2 + 2 + 2,
width: area.width.saturating_sub(2),
height: list_h as u16,
}
.intersection(area);
if list_rect.height > 0 {
let _ = scrollable_body_area(f, list_rect, start as u16, candidates.len(), &app.theme);
}
return;
}
let total = ov.rows.len();
let max_visible = (term.height as usize).saturating_sub(10).max(3);
let visible = total.min(max_visible);
let (start, end) = picker_window(total, ov.selected, visible);
let label_w = ov.rows.iter().map(|r| r.label.chars().count()).max().unwrap_or(0);
let mut lines = overlay_title_lines(&ov.title, accent);
for (i, row) in ov.rows.iter().enumerate().take(end).skip(start) {
let (label_color, value_color, value_bold) = match row.role {
DetailRole::Active => (app.theme.clean, app.theme.clean, true),
DetailRole::Muted => (app.theme.muted, app.theme.muted, false),
DetailRole::Normal => (app.theme.name, app.theme.name, false),
DetailRole::Success => (app.theme.clean, app.theme.name, false),
DetailRole::Failure => (app.theme.prunable, app.theme.name, false),
DetailRole::Running => (app.theme.dirty, app.theme.name, false),
};
let mut extra: String = row.extra.as_deref().unwrap_or("").to_string();
let mut extra_cols = extra.chars().count();
if extra_cols > 0 {
let reserve = row.value.chars().count().min(12);
let extra_budget = inner.saturating_sub(label_w + 2 + reserve + 2);
if extra_cols > extra_budget {
if extra_budget == 0 {
extra.clear();
} else {
let tail: String = extra.chars().skip(extra_cols - (extra_budget - 1)).collect();
extra = format!("…{tail}");
}
extra_cols = extra.chars().count();
}
}
let value_budget = inner.saturating_sub(label_w + 2 + if extra_cols > 0 { extra_cols + 2 } else { 0 });
let value: String = if row.value.chars().count() > value_budget {
let mut v: String = row.value.chars().take(value_budget.saturating_sub(1)).collect();
v.push('…');
v
} else {
row.value.clone()
};
let text_cols = label_w + 2 + value.chars().count();
let pad = inner.saturating_sub(text_cols + extra_cols);
let mut label_style = Style::default().fg(label_color);
let mut value_style = Style::default().fg(value_color);
if value_bold {
value_style = value_style.add_modifier(Modifier::BOLD);
}
let mut pad_style = Style::default();
let mut extra_style = Style::default().fg(app.theme.muted);
if i == ov.selected {
label_style = label_style.bg(app.theme.selection_bg).add_modifier(Modifier::BOLD);
value_style = value_style.bg(app.theme.selection_bg);
pad_style = pad_style.bg(app.theme.selection_bg);
extra_style = extra_style.bg(app.theme.selection_bg);
}
lines.push(Line::from(vec![
Span::styled(format!("{:label_w$} ", row.label), label_style),
Span::styled(value, value_style),
Span::styled(" ".repeat(pad), pad_style),
Span::styled(extra, extra_style),
]));
}
let hint_ctx = if ov.kind == crate::tui::state::detail_overlay::DetailKind::CiChecks {
HintContext::CiChecks
} else {
HintContext::Detail
};
push_modal_hint(&mut lines, hint_ctx, &app.keymap, &app.modal_keymap, &app.theme);
let height = (2 + visible + 2) as u16 + 2 + 2 ;
let area = centered_abs(width, height, term);
f.render_widget(Clear, area);
f.render_widget(Paragraph::new(lines).block(overlay_block(accent)), area);
let rows_rect = Rect {
x: area.x + 1,
y: area.y + 2 + 2,
width: area.width.saturating_sub(2),
height: visible as u16,
}
.intersection(area);
if rows_rect.height > 0 {
let _ = scrollable_body_area(f, rows_rect, start as u16, total, &app.theme);
}
}
fn draw_clean_overlay(f: &mut Frame, app: &App) {
let accent = app.theme.accent;
let muted = app.theme.muted;
let danger = app.theme.prunable;
let armed = app.clean_overlay.confirm.is_armed();
let border = if armed { danger } else { accent };
let term = f.area();
let width = overlay_modal_width(term.width);
let inner = width.saturating_sub(6) as usize;
let mut lines = overlay_title_lines("Reclaim build artifacts", border);
if app.clean_overlay.has_profiles() {
let labels = app.clean_overlay.choice_labels();
let max_visible = (term.height as usize).saturating_sub(14).max(3);
lines.extend(picker_lines(
&labels,
app.clean_overlay.selected_index(),
max_visible,
inner,
&app.theme,
));
lines.push(Line::from(""));
}
match app.clean_overlay.reclaim() {
Some(reclaim) if !reclaim.artifacts.is_empty() => {
let namew = inner.saturating_sub(15).max(5);
let row = |icon: &str, left: &str, left_style: Style, bytes: u64, size_style: Style| -> Line<'static> {
Line::from(vec![
Span::styled(format!(" {icon} "), Style::default().fg(accent)),
Span::styled(format!("{:<namew$}", ellipsize_middle(left, namew)), left_style),
Span::styled(format!("{:>10} ", crate::clean::human_size(bytes)), size_style),
])
};
let max_rows = (term.height as usize).saturating_sub(14).max(3);
let shown = reclaim.artifacts.len().min(max_rows);
for a in reclaim.artifacts.iter().take(shown) {
lines.push(row(
clean_dir_icon(&a.rel),
&a.rel,
Style::default().fg(muted),
a.bytes,
Style::default().fg(reclaim_size_color(a.bytes, &app.theme)),
));
}
if reclaim.artifacts.len() > shown {
lines.push(
Line::from(Span::styled(
format!("… {} more", reclaim.artifacts.len() - shown),
Style::default().fg(muted),
))
.centered(),
);
}
lines.push(row(
"\u{f03a}",
"total",
Style::default().fg(accent).add_modifier(Modifier::BOLD),
reclaim.total_bytes,
Style::default()
.fg(reclaim_size_color(reclaim.total_bytes, &app.theme))
.add_modifier(Modifier::BOLD),
));
}
_ => {
lines.push(
Line::from("nothing to reclaim")
.style(Style::default().fg(muted))
.centered(),
);
}
}
for rel in app.clean_overlay.skipped() {
lines.push(
Line::from(format!("skipped {rel} — not git-ignored / holds tracked files"))
.style(Style::default().fg(muted))
.centered(),
);
}
if armed {
lines.push(Line::from(""));
lines.push(
Line::from("⚠ armed — confirm again or cancel to abort")
.style(Style::default().fg(danger).add_modifier(Modifier::BOLD))
.centered(),
);
}
push_modal_hint(
&mut lines,
HintContext::Clean,
&app.keymap,
&app.modal_keymap,
&app.theme,
);
let height = lines.len() as u16 + 2 + 2 ;
let area = centered_abs(width, height, term);
f.render_widget(Clear, area);
f.render_widget(Paragraph::new(lines).block(overlay_block(border)), area);
}
fn draw_edit_worktree(f: &mut Frame, app: &App) {
let accent = app.theme.accent;
let muted = app.theme.muted;
let clean = app.theme.clean;
let surface = app.theme.selection_bg;
let (type_str, type_desc) = app
.branch_types
.get(app.create_form.type_index)
.map(|t| (t.name.as_str(), t.description.as_str()))
.unwrap_or(("", "(no branch types configured)"));
let block = overlay_block(clean);
let term = f.area();
let outer = centered_box(70, 72, 1, term);
let inner_w = block.inner(outer).width as usize;
let label_w = 5usize;
let gutter = 2 + label_w + 2;
let value_w = inner_w.saturating_sub(gutter);
let label = |s: &str| format!("{:<label_w$}", s);
let old_branch = app
.edit_original_branch
.as_deref()
.or_else(|| app.selected().and_then(|w| w.branch.as_deref()))
.unwrap_or("(none)");
let old_display = ellipsize_middle(old_branch, inner_w.saturating_sub(" From : ".len()));
let (branch_raw, dir_raw) = pattern_preview(app, type_str);
let branch = ellipsize_middle(&branch_raw, inner_w.saturating_sub(" Branch : ".len()));
let dirname = ellipsize_middle(&dir_raw, inner_w.saturating_sub(" Dir : ".len()));
let freeform = app.create_form.mode == Mode::Freeform;
let mut lines = overlay_title_lines("Rename Worktree", clean);
lines.push(Line::from(vec![
Span::raw(" From : "),
Span::styled(old_display, Style::default().fg(muted)),
]));
lines.push(Line::from(String::new()));
lines.push(Line::from(vec![
Span::raw(" Branch : "),
Span::styled(branch, Style::default().fg(app.theme.branch)),
]));
lines.push(Line::from(vec![
Span::raw(" Dir : "),
Span::styled(dirname, Style::default().fg(app.theme.dirty)),
]));
if let Some(warning) = rename_pr_warning(app, &branch_raw, old_branch) {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
ellipsize_middle(&warning, inner_w.saturating_sub(2)),
Style::default().fg(app.theme.prunable),
),
]));
}
lines.push(Line::from(String::new()));
if freeform {
lines.push(field_input_line(
&label("Name"),
&app.create_form.name,
app.create_form.field == Field::Name,
value_w,
accent,
muted,
surface,
));
} else {
lines.extend(form_field_lines(app, type_str, type_desc, value_w, label_w));
}
let height = lines.len() as u16 + 4 + 2 + 2 ;
let area = centered_box(70, 72, height, term);
let inner = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), ])
.split(block.inner(area));
f.render_widget(Clear, area);
f.render_widget(block, area);
f.render_widget(Paragraph::new(lines), inner[0]);
if app.is_edit_worktree_loading() {
f.render_widget(
LoaderWidget::running(
app.spinner.glyph(DOT_FRAMES),
TaskKind::EditWorktree.loading_label(),
None,
&app.theme,
)
.alignment(Alignment::Center),
inner[1],
);
} else if let Some(error) = app.edit_failure.as_deref() {
f.render_widget(
LoaderWidget::failed("rename failed", Some(error), &app.theme).alignment(Alignment::Center),
inner[1],
);
}
if !app.is_edit_worktree_loading() {
f.render_widget(
Paragraph::new(rename_buttons_line(accent, muted)).alignment(Alignment::Center),
inner[2],
);
f.render_widget(
Paragraph::new(modal_hint_for_context_with_fields(
app.rename_hint_context(),
&app.keymap,
&app.modal_keymap,
&app.theme,
app.create_form.fields(),
)),
inner[4],
);
}
}
fn draw_command_palette(f: &mut Frame, app: &App) {
let area = centered(60, 50, f.area());
f.render_widget(Clear, area);
let accent = app.theme.accent;
let outer = overlay_block(accent);
let inner = outer.inner(area);
f.render_widget(outer, area);
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(3), Constraint::Length(1), Constraint::Length(1), ])
.split(inner);
f.render_widget(
Paragraph::new(
Line::from(Span::styled(
"Command Palette",
Style::default().fg(accent).add_modifier(Modifier::BOLD),
))
.centered(),
),
layout[0],
);
let label = ":";
let gutter = 2 + label.chars().count() + 2; let value_w = (inner.width as usize).saturating_sub(gutter);
f.render_widget(
Paragraph::new(field_input_line(
label,
app.palette.buffer(),
true,
value_w,
accent,
app.theme.muted,
app.theme.selection_bg,
)),
layout[2],
);
let entries = app.palette.matches();
let highlight = app.palette.highlight();
let mut lines: Vec<Line<'_>> = entries
.iter()
.enumerate()
.map(|(i, entry)| {
let prefix = if i == highlight { "▶ " } else { " " };
let name_style = if i == highlight {
Style::default().fg(accent).add_modifier(Modifier::BOLD)
} else {
palette_name_style(&app.theme)
};
Line::from(vec![
Span::raw(prefix),
Span::styled(format!("{:<22}", entry.name), name_style),
Span::raw(" "),
Span::styled(entry.description, Style::default().fg(app.theme.muted)),
])
})
.collect();
if lines.is_empty() {
lines.push(Line::from(Span::styled(
" (no matching command — backspace to broaden)",
Style::default().fg(app.theme.prunable),
)));
}
f.render_widget(Paragraph::new(lines), layout[4]);
f.render_widget(
Paragraph::new(modal_hint_for_context(
HintContext::CommandPalette,
&app.keymap,
&app.modal_keymap,
&app.theme,
)),
layout[6],
);
}
pub fn github_status_lines(app: &App, max_width: usize) -> Vec<Line<'static>> {
let link = app.current_link();
let mut lines: Vec<Line<'static>> = Vec::new();
if link.issue.is_none() && link.pr.is_none() {
let chord = action_chord(&app.keymap, Action::LinkPrompt, "i");
lines.push(Line::from(Span::styled(
trunc(&format!("no link · press {chord} to link"), max_width),
Style::default().fg(app.theme.muted),
)));
return lines;
}
if let Some(n) = link.issue {
let spinner = app.spinner.glyph(DOT_FRAMES);
lines.push(issue_summary_line_with_spinner(
n,
link.issue_source,
app.issue_fetch_state(),
PersistedSummary {
title: link.issue_title.as_deref(),
state: link.issue_state,
},
max_width,
&app.theme,
Some(spinner),
));
}
if let Some(n) = link.pr {
let spinner = app.spinner.glyph(DOT_FRAMES);
let ci_key = if app.picker_mode {
None
} else if app.sidebar.open && app.sidebar.focused {
app
.keymap
.primary_chord(Action::EditWorktree)
.or_else(|| app.keymap.primary_chord(Action::CiChecks))
} else {
app.keymap.primary_chord(Action::CiChecks)
};
lines.push(pr_summary_line_with_spinner(
n,
link.pr_source,
app.pr_fetch_state(),
PersistedSummary {
title: link.pr_title.as_deref(),
state: link.pr_state,
},
max_width,
&app.theme,
Some(spinner),
ci_key.as_deref(),
));
}
lines
}
pub const ISSUE_ICON: &str = "\u{f41b}";
pub const PR_ICON: &str = "\u{f407}";
pub const CI_PASSING_ICON: &str = "\u{f42e}";
pub const CI_FAILING_ICON: &str = "\u{f467}";
pub const CI_RUNNING_ICON: &str = "\u{f46a}";
pub const CI_UNKNOWN_ICON: &str = "\u{f059}";
fn source_chip(s: LinkSource, theme: &Theme) -> Option<(&'static str, Color)> {
match s {
LinkSource::BranchName => Some(("auto", theme.muted)),
LinkSource::Detected => Some(("detected", theme.accent)),
LinkSource::Explicit | LinkSource::None => None,
}
}
fn flatten_if_overflow(spans: &mut Vec<Span<'static>>, max_width: usize) {
let w: usize = spans.iter().map(|s| s.content.chars().count()).sum();
if w > max_width {
let raw: String = spans.iter().map(|s| s.content.as_ref()).collect();
*spans = vec![Span::raw(trunc(&raw, max_width))];
}
}
pub fn issue_summary_line(
n: u64,
src: LinkSource,
state: &GitHubFetchState<crate::github::IssueStatus>,
max_width: usize,
theme: &Theme,
) -> Line<'static> {
issue_summary_line_with_spinner(n, src, state, PersistedSummary::none(), max_width, theme, None)
}
#[derive(Clone, Copy)]
struct PersistedSummary<'a, S> {
title: Option<&'a str>,
state: Option<S>,
}
impl<S> PersistedSummary<'_, S> {
fn none() -> Self {
Self {
title: None,
state: None,
}
}
}
enum SummaryState<'a> {
Idle,
CachedTitle {
title: &'a str,
},
CachedStatus {
badge: &'a str,
badge_color: Color,
trailing: String,
trailing_color: Option<Color>,
title: &'a str,
},
Loading,
Loaded {
badge: &'a str,
badge_color: Color,
trailing: String,
trailing_color: Option<Color>,
title: &'a str,
},
Error(&'a str),
}
fn trailing_span(trailing: String, color: Option<Color>) -> Span<'static> {
match color {
Some(c) => Span::styled(trailing, Style::default().fg(c)),
None => Span::raw(trailing),
}
}
fn summary_line(
icon: &str,
head: String,
source: LinkSource,
state: SummaryState,
max_width: usize,
theme: &Theme,
spinner: Option<&str>,
) -> Line<'static> {
let icon_seg = format!("{} ", icon); let chip = source_chip(source, theme);
let source_seg_w = chip.map(|(l, _)| 1 + l.chars().count() + 2).unwrap_or(0);
let prefix_w = icon_seg.chars().count() + head.chars().count() + source_seg_w;
let icon_color = match &state {
SummaryState::CachedStatus { badge_color, .. } | SummaryState::Loaded { badge_color, .. } => *badge_color,
SummaryState::Idle | SummaryState::CachedTitle { .. } | SummaryState::Loading | SummaryState::Error(_) => {
theme.muted
}
};
let build_prefix = |head_bold: bool| -> Vec<Span<'static>> {
let head_style = if head_bold {
Style::default().fg(theme.name).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.name)
};
let mut spans = vec![
Span::styled(icon_seg.clone(), Style::default().fg(icon_color)),
Span::styled(head.clone(), head_style),
];
if let Some((label, color)) = chip {
spans.push(Span::raw(" "));
spans.push(Span::styled(format!(" {} ", label), chip_style(color)));
}
spans
};
match state {
SummaryState::Idle => {
let mut spans = build_prefix(false);
flatten_if_overflow(&mut spans, max_width);
Line::from(spans)
}
SummaryState::CachedTitle { title } => {
let fixed = prefix_w + 1;
let budget = max_width.saturating_sub(fixed);
let mut spans = build_prefix(false);
spans.push(Span::raw(" "));
spans.push(Span::raw(trunc(title, budget)));
flatten_if_overflow(&mut spans, max_width);
Line::from(spans)
}
SummaryState::CachedStatus {
badge,
badge_color,
trailing,
trailing_color,
title,
} => {
let badge_seg_w = 1 + badge.chars().count() + 2;
let fixed = prefix_w + badge_seg_w + trailing.chars().count() + 1;
if fixed >= max_width {
let mut spans = build_prefix(true);
spans.push(Span::raw(" "));
spans.push(Span::raw(format!(" {} ", badge)));
spans.push(trailing_span(trailing, trailing_color));
flatten_if_overflow(&mut spans, max_width);
return Line::from(spans);
}
let budget = max_width - fixed;
let mut spans = build_prefix(true);
spans.push(Span::raw(" "));
spans.push(Span::styled(format!(" {} ", badge), chip_style(badge_color)));
spans.push(trailing_span(trailing, trailing_color));
spans.push(Span::raw(" "));
spans.push(Span::raw(trunc(title, budget)));
Line::from(spans)
}
SummaryState::Loading => {
let glyph = spinner.unwrap_or("…");
let mut spans = build_prefix(false);
spans.push(Span::raw(format!(" {} loading", glyph)));
flatten_if_overflow(&mut spans, max_width);
Line::from(spans)
}
SummaryState::Loaded {
badge,
badge_color,
trailing,
trailing_color,
title,
} => {
let badge_seg_w = 1 + badge.chars().count() + 2;
let fixed = prefix_w + badge_seg_w + trailing.chars().count() + 1;
if fixed >= max_width {
let mut spans = build_prefix(true);
spans.push(Span::raw(" "));
spans.push(Span::raw(format!(" {} ", badge)));
spans.push(trailing_span(trailing, trailing_color));
flatten_if_overflow(&mut spans, max_width);
return Line::from(spans);
}
let budget = max_width - fixed;
let mut spans = build_prefix(true);
spans.push(Span::raw(" "));
spans.push(Span::styled(format!(" {} ", badge), chip_style(badge_color)));
spans.push(trailing_span(trailing, trailing_color));
spans.push(Span::raw(" "));
spans.push(Span::raw(trunc(title, budget)));
Line::from(spans)
}
SummaryState::Error(e) => {
let fixed = prefix_w + 2; let budget = max_width.saturating_sub(fixed);
let mut spans = build_prefix(false);
spans.push(Span::raw(" "));
spans.push(Span::styled(
format!("!{}", trunc(e, budget)),
Style::default().fg(theme.prunable),
));
flatten_if_overflow(&mut spans, max_width);
Line::from(spans)
}
}
}
fn issue_summary_line_with_spinner(
n: u64,
src: LinkSource,
state: &GitHubFetchState<crate::github::IssueStatus>,
persisted: PersistedSummary<'_, IssueState>,
max_width: usize,
theme: &Theme,
spinner: Option<&str>,
) -> Line<'static> {
let head = format!("Issue #{}", n);
let resolved = match state {
GitHubFetchState::Idle => match persisted.state {
Some(state) => {
let badge = match state {
IssueState::Open => "open",
IssueState::Closed => "closed",
};
SummaryState::CachedStatus {
badge,
badge_color: issue_badge_color(state, theme),
trailing: String::new(),
trailing_color: None,
title: persisted.title.unwrap_or(""),
}
}
None => persisted
.title
.map(|title| SummaryState::CachedTitle { title })
.unwrap_or(SummaryState::Idle),
},
GitHubFetchState::Loading => match persisted.state {
Some(state) => {
let badge = match state {
IssueState::Open => "open",
IssueState::Closed => "closed",
};
SummaryState::CachedStatus {
badge,
badge_color: issue_badge_color(state, theme),
trailing: format!(" · {} loading", spinner.unwrap_or("…")),
trailing_color: None,
title: persisted.title.unwrap_or(""),
}
}
None => SummaryState::Loading,
},
GitHubFetchState::Loaded(s) => {
let badge = match s.state {
IssueState::Open => "open",
IssueState::Closed => "closed",
};
SummaryState::Loaded {
badge,
badge_color: issue_badge_color(s.state, theme),
trailing: String::new(),
trailing_color: None,
title: &s.title,
}
}
GitHubFetchState::Error(e) => SummaryState::Error(e),
};
summary_line(ISSUE_ICON, head, src, resolved, max_width, theme, spinner)
}
pub fn pr_summary_line(
n: u64,
src: LinkSource,
state: &GitHubFetchState<crate::github::PrStatus>,
max_width: usize,
theme: &Theme,
ci_hint: Option<&str>,
) -> Line<'static> {
pr_summary_line_with_spinner(n, src, state, PersistedSummary::none(), max_width, theme, None, ci_hint)
}
#[allow(clippy::too_many_arguments)] fn pr_summary_line_with_spinner(
n: u64,
src: LinkSource,
state: &GitHubFetchState<crate::github::PrStatus>,
persisted: PersistedSummary<'_, PrState>,
max_width: usize,
theme: &Theme,
spinner: Option<&str>,
ci_hint: Option<&str>,
) -> Line<'static> {
let head = format!("PR #{}", n);
let resolved = match state {
GitHubFetchState::Idle => match persisted.state {
Some(state) => {
let badge = match state {
PrState::Open => "open",
PrState::Draft => "draft",
PrState::Closed => "closed",
PrState::Merged => "merged",
};
SummaryState::CachedStatus {
badge,
badge_color: pr_badge_color(state, theme),
trailing: String::new(),
trailing_color: None,
title: persisted.title.unwrap_or(""),
}
}
None => persisted
.title
.map(|title| SummaryState::CachedTitle { title })
.unwrap_or(SummaryState::Idle),
},
GitHubFetchState::Loading => match persisted.state {
Some(state) => {
let badge = match state {
PrState::Open => "open",
PrState::Draft => "draft",
PrState::Closed => "closed",
PrState::Merged => "merged",
};
SummaryState::CachedStatus {
badge,
badge_color: pr_badge_color(state, theme),
trailing: format!(" · {} loading", spinner.unwrap_or("…")),
trailing_color: None,
title: persisted.title.unwrap_or(""),
}
}
None => SummaryState::Loading,
},
GitHubFetchState::Loaded(s) => {
let badge = match s.state {
PrState::Open => "open",
PrState::Draft => "draft",
PrState::Closed => "closed",
PrState::Merged => "merged",
};
let (trailing, trailing_color) = match ci_indicator(s.ci, s.checks_passed, s.checks_total, theme) {
Some((text, color)) => (
match ci_hint {
Some(key) => format!("{text} [{key}]"),
None => text,
},
Some(color),
),
None => (String::new(), None),
};
SummaryState::Loaded {
badge,
badge_color: pr_badge_color(s.state, theme),
trailing,
trailing_color,
title: &s.title,
}
}
GitHubFetchState::Error(e) => SummaryState::Error(e),
};
summary_line(PR_ICON, head, src, resolved, max_width, theme, spinner)
}
pub fn branch_name_color(s: &BranchStatus, theme: &Theme) -> Color {
if s.unknown {
return theme.muted;
}
if s.is_dirty {
return theme.prunable;
}
if s.ahead > 0 || s.behind > 0 {
return theme.dirty;
}
if !s.has_upstream {
return theme.locked;
}
theme.branch
}
pub fn freshness_color(age: Duration, theme: &Theme) -> Color {
const WEEK: u64 = 7 * 86_400;
const MONTH: u64 = 30 * 86_400;
let s = age.as_secs();
if s < WEEK {
theme.clean
} else if s < MONTH {
theme.dirty
} else {
theme.muted
}
}
pub fn pr_badge_color(state: PrState, theme: &Theme) -> Color {
match state {
PrState::Open => theme.clean,
PrState::Draft => theme.muted,
PrState::Merged => theme.locked,
PrState::Closed => theme.prunable,
}
}
pub fn ci_indicator(ci: CiState, passed: u32, total: u32, theme: &Theme) -> Option<(String, Color)> {
let (icon, label, color) = match ci {
CiState::None => return None,
CiState::Passing => (CI_PASSING_ICON, "passing", theme.clean),
CiState::Failing => (CI_FAILING_ICON, "failing", theme.prunable),
CiState::Running => (CI_RUNNING_ICON, "running", theme.dirty),
};
Some((format!(" {} CI {} {}/{}", icon, label, passed, total), color))
}
pub fn issue_badge_color(state: IssueState, theme: &Theme) -> Color {
match state {
IssueState::Open => theme.clean,
IssueState::Closed => theme.locked,
}
}
pub fn table_marker(w: &WorktreeInfo, theme: &Theme) -> Line<'static> {
if w.is_main {
return Line::from(Span::styled("★", Style::default().fg(theme.main)));
}
let issue_color = match (w.link.issue, w.issue_state) {
(Some(_), Some(state)) => issue_badge_color(state, theme),
(Some(_), None) => theme.clean,
(None, _) => theme.name,
};
let pr_color = match (w.link.pr, w.pr_state) {
(Some(_), Some(state)) => pr_badge_color(state, theme),
(Some(_), None) => theme.locked,
(None, _) => theme.name,
};
Line::from(vec![
Span::styled(
if w.link.issue.is_some() { "●" } else { "-" },
Style::default().fg(issue_color),
),
Span::styled("/", Style::default().fg(theme.muted)),
Span::styled(
if w.link.pr.is_some() { "●" } else { "-" },
Style::default().fg(pr_color),
),
])
}