Skip to main content

gwm/tui/
app.rs

1use super::keymap::{Action, ChordResolution, KeyStroke, Keymap};
2use super::modal_keymap::{KeyContext, ModalAction, ModalKeymap};
3use super::palette::PaletteState;
4use super::state::async_task::{CreateWorktreeResult, EditWorktreeResult, TaskKind, TaskMsg, TaskRunner};
5use super::state::clean_overlay::CleanOverlay;
6use super::state::command_logs::CommandLogs;
7use super::state::config_panel::{ConfigPanel, FieldKind, KeyTarget, SettingField, SettingsLayer};
8use super::state::confirm::{ConfirmKeyAction, ConfirmModal, CountdownTickOutcome};
9use super::state::create_form::{CreateForm, Field, Mode};
10use super::state::exec_picker::ExecPicker;
11use super::state::filter::{fuzzy_match_indices, FilterState};
12use super::state::github_fetch::{FetchKey, GitHubFetch};
13use super::state::link_prompt::LinkPrompt;
14use super::state::pty_overlay::PtyOverlay;
15use super::state::sidebar::SidebarState;
16use super::state::spinner::Spinner;
17use super::theme::Theme;
18use crate::bootstrap::{self, BootstrapCtx, BootstrapReport, StepStatus};
19use crate::config::BranchType;
20use crate::config::{CleanConfig, Config, ExecConfig, TuiOpenConfig, TuiOpenMode};
21use crate::error::{GwmError, Result};
22use crate::github::{self, BranchLink, IssueState, IssueStatus, PrStatus};
23use crate::launcher::{self, ExpandedCommand, LauncherContext};
24use crate::naming::{BranchSpec, WorktreeName};
25use crate::worktree::{self, WorktreeInfo};
26use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
27use git2::Repository;
28use ratatui::widgets::TableState;
29use std::collections::{BTreeSet, HashMap};
30use std::path::{Path, PathBuf};
31use std::sync::mpsc;
32use std::time::{Duration, Instant};
33
34// Re-export the GitHub fetch state enum at its historical path
35// (`tui::app::GitHubFetchState`) so callers that imported it from
36// `tui::app` (or via `tui::GitHubFetchState` before the new
37// `state::github_fetch` re-export landed) keep compiling. The owning
38// module is now `tui::state::github_fetch` — see #128.
39pub use super::state::github_fetch::GitHubFetchState;
40
41/// Spawnable launcher plan handed to the event loop by
42/// [`App::prepare_git_tui`] / [`App::prepare_review`]. Carries the
43/// expanded argv, the cwd to set on the child, and the `fullscreen`
44/// toggle that decides whether gwm suspends its own TUI for the call.
45///
46/// The `diff_file` inside `expanded` (when set) is kept alive for the
47/// lifetime of the plan, so a `{diff}` tempfile survives until the
48/// spawned reviewer has had a chance to consume it.
49#[derive(Debug)]
50pub struct LauncherPlan {
51  pub expanded: ExpandedCommand,
52  pub cwd: std::path::PathBuf,
53  pub fullscreen: bool,
54  /// Resolved base ref, when the launcher cares about it (review).
55  /// `None` for the git_tui launcher. Surfaced so the status bar /
56  /// caller can mention which ref was used.
57  pub base: Option<String>,
58}
59
60#[derive(Debug, PartialEq, Eq, Clone, Copy)]
61pub enum View {
62  List,
63  Create,
64  Confirm,
65  Report,
66  Help,
67  /// Compact menu to pick which GitHub URL to open (issue / pr).
68  OpenMenu,
69  /// Two-stage prompt: pick the link kind, then enter the number.
70  LinkPrompt,
71  /// Command palette (issue #32). A bottom overlay where the user
72  /// types an action by name (`:create`, `:bootstrap`, …). State
73  /// lives on [`App::palette`]; orchestrator methods are
74  /// `open_command_palette` / `palette_push_char` / `palette_pop_char`
75  /// / `palette_cycle_*` / `accept_command_palette` /
76  /// `close_command_palette`.
77  CommandPalette,
78  /// Command Logs overlay (issue #226). A ~90% fullscreen modal over a
79  /// dimmed list showing the lazygit-style transcript of the external
80  /// commands gwm ran. Opened on `3`, scrolled like the help overlay;
81  /// state lives on [`App::command_logs`].
82  CommandLogs,
83  /// Configuration panel (issue #232). A ~90% fullscreen modal over a
84  /// dimmed list showing the resolved `.gwm.toml` (user-level global
85  /// deep-merged under the repo file) with a per-row source column
86  /// (repo / user / default). Opened on `4`, scrolled like the help
87  /// overlay; state lives on [`App::config_panel`].
88  Config,
89  /// Embedded PTY overlay (issue #35). A ~90% fullscreen modal that renders
90  /// a live PTY session (lazygit on `l`, native terminal on `o`) over the
91  /// worktree list. All keys are forwarded to the child process; `Esc`
92  /// kills the child and returns to the list. State lives on
93  /// [`App::pty_overlay`].
94  Pty,
95  /// Exec profile picker overlay (issue #325). A small centred modal that
96  /// lists the `[exec.profiles.*]` names; `Enter` resolves the highlight
97  /// to an argv and the run loop spawns it in a PTY overlay
98  /// ([`PtyKind::Exec`]) rooted at the selected worktree. State lives on
99  /// [`App::exec_picker`]; keys resolve through
100  /// [`crate::tui::modal_keymap::KeyContext::ExecPicker`].
101  ExecPicker,
102  /// Clean reclaim overlay (issue #325). A centred modal showing the gated
103  /// `clean::scan_worktree_safe` report for the selected worktree, an
104  /// optional `[clean.profiles.*]` picker, and a safety countdown; the run
105  /// loop fires `clean::delete_reclaim` when the countdown elapses. State
106  /// lives on [`App::clean_overlay`]; keys resolve through
107  /// [`crate::tui::modal_keymap::KeyContext::Clean`].
108  CleanReport,
109  /// Worktree-rename modal (#290). Reuses the Create form (Type / Issue /
110  /// Desc) pre-filled by parsing the current branch; submitting renames the
111  /// local + remote branch and moves the worktree directory. State lives on
112  /// [`App::create_form`] plus [`App::edit_original_branch`] /
113  /// [`App::edit_original_path`].
114  Edit,
115  /// Generic detail overlay (issue #408). A centred row-list modal — its
116  /// first consumer is the agent-session view (`a` on the worktree list);
117  /// the content contract is deliberately generic so the planned rich
118  /// PR/Issue view reuses it. State lives on [`App::detail_overlay`]; keys
119  /// resolve through [`crate::tui::modal_keymap::KeyContext::Detail`].
120  DetailOverlay,
121}
122
123/// What the run loop must do after [`App::handle_exec_picker_key`]
124/// processes a key in the exec picker overlay (issue #325). Mirrors
125/// [`CreateKey`] / [`LinkPromptKey`]: the testable handler owns the
126/// highlight movement, the loop owns the two side effects (resolve the
127/// argv + spawn the PTY overlay, or close back to the list).
128#[derive(Debug, PartialEq, Eq, Clone, Copy)]
129pub enum ExecPickerKey {
130  /// The key moved the highlight (or was ignored); stay in the picker.
131  Handled,
132  /// `Enter` — the loop should resolve the highlighted profile and spawn
133  /// the PTY overlay.
134  Submit,
135  /// `Esc` — the loop should close the picker back to the list.
136  Cancel,
137}
138
139/// What the run loop must do after [`App::handle_create_key`] processes a
140/// key in the create overlay (issue #217). Keeps the side effects
141/// (worktree creation, view transition) in the loop while the form
142/// mutations stay in the testable handler.
143#[derive(Debug, PartialEq, Eq, Clone, Copy)]
144pub enum CreateKey {
145  /// The key mutated form state (or was ignored); stay in the overlay.
146  Handled,
147  /// `Enter` on the description field — the loop should run `submit_create`.
148  Submit,
149  /// `Esc` — the loop should close the overlay back to the list.
150  Cancel,
151}
152
153/// What the run loop must do after [`App::handle_link_prompt_key`] processes
154/// a key in the link prompt (issue #217). Mirrors [`CreateKey`]: the testable
155/// handler owns the picker / digit-buffer mutations, the loop owns the two
156/// side effects (the `github::link_*` shell-out, the view transition).
157#[derive(Debug, PartialEq, Eq, Clone, Copy)]
158pub enum LinkPromptKey {
159  /// The key moved the highlight, committed a target, or edited the number
160  /// buffer (or was ignored); stay in the prompt.
161  Handled,
162  /// `Enter` on the number field — the loop should run `link_prompt_submit`.
163  Submit,
164  /// The resolved `fetch_github` key — the loop should refresh status.
165  Refresh,
166  /// `Esc` — the loop should close the prompt back to the list.
167  Cancel,
168}
169
170/// Target of an open / link action. Canonical definition lives in
171/// `crate::cli::LinkTarget` (it carries the `clap::ValueEnum` derive
172/// for the CLI surface); the TUI re-exports the same type so a value
173/// crossing the cli/tui boundary doesn't need a manual conversion
174/// (issue #106).
175pub use crate::cli::LinkTarget;
176
177/// Dispatch target for the `o` key (issue #73). Resolved by
178/// [`App::resolve_open_target`] from the current selection + the
179/// `[tui.open]` config so the event loop can hand off to the right
180/// runner (shell suspend, editor suspend, OS file manager) without
181/// re-reading the config or `$SHELL` / `$EDITOR` itself.
182#[derive(Debug, PartialEq, Eq, Clone)]
183pub enum OpenTarget {
184  /// Spawn `command` with `cwd = path`. Caller suspends the TUI and
185  /// restores it on the child's exit (same lifecycle as `l: lazygit`).
186  Shell { path: PathBuf, command: String },
187  /// Spawn `command <path>` and wait. Same suspend/restore lifecycle
188  /// as `Shell`.
189  Editor { path: PathBuf, command: String },
190  /// Hand off to the OS opener (`open` / `xdg-open` / `explorer`).
191  /// Doesn't suspend the TUI — the opener detaches.
192  Finder { path: PathBuf },
193}
194
195/// Stage of the two-step link prompt. Re-export from the extracted
196/// `LinkPrompt` sub-struct (issue #126) so the existing public surface
197/// (`gwm::tui::LinkPromptStage`) keeps compiling without callers
198/// learning the new module path.
199pub use super::state::link_prompt::LinkPromptStage;
200
201/// One repo's session-stable metadata in workspace mode (issue #36). The live
202/// `git2::Repository` is *not* stored here (it isn't `Send`/`Clone` and would
203/// duplicate `App.repo`); it is re-opened from `workdir` when this repo
204/// becomes the active one. `config` is cloned into `App.config` on activation
205/// so per-row actions (`create`, bootstrap, hooks) read the right repo's
206/// `.gwm.toml` — matching the issue's "each row inherits its own repo's
207/// config" contract. Keymap/theme stay session-level (resolved once from the
208/// first repo), the same "resolved once, relaunch to change" contract as
209/// single-repo mode.
210#[derive(Debug, Clone)]
211pub struct RepoMeta {
212  pub name: String,
213  pub workdir: PathBuf,
214  pub config: Config,
215}
216
217/// Pins per worktree path, read from each row's owning repo (its branch
218/// config). Runs in the detection worker on the periodic path (round P) and
219/// synchronously on user-action paths; repos are opened at most once per
220/// distinct workdir. Pub: the state tests pin the owning-repo contract
221/// through it without spawning the worker thread.
222pub fn read_pins_from_sources(
223  sources: &[(String, String, PathBuf)],
224) -> std::collections::BTreeMap<String, Vec<String>> {
225  let mut repos: std::collections::BTreeMap<&PathBuf, Option<Repository>> = std::collections::BTreeMap::new();
226  let mut out = std::collections::BTreeMap::new();
227  for (path, branch, repo_dir) in sources {
228    let repo = repos.entry(repo_dir).or_insert_with(|| Repository::open(repo_dir).ok());
229    let Some(repo) = repo.as_ref() else {
230      continue;
231    };
232    let pins = crate::github::agent_pins(repo, branch).unwrap_or_default();
233    if !pins.is_empty() {
234      out.insert(path.clone(), pins);
235    }
236  }
237  out
238}
239
240/// Workspace-mode state (issue #36). `None` in single-repo mode (the default).
241/// The *active* repo lives in `App`'s core fields (`repo`/`repo_name`/
242/// `workdir`/`config`); this holds everything needed to swap a different repo
243/// into those fields as the selection moves between repos.
244#[derive(Debug, Clone)]
245pub struct WorkspaceState {
246  /// The root `--workspace` pointed at.
247  pub root: PathBuf,
248  /// Session-stable repo metadata, in discovery (alphabetical) order.
249  pub repos: Vec<RepoMeta>,
250  /// The owning repo index for each `App.worktrees[i]` row, parallel to that
251  /// vec. Rebuilt by every workspace refresh so it never drifts.
252  pub row_repo: Vec<usize>,
253  /// Index into `repos` of the currently active repo (mirrors `App.repo*`).
254  pub active: usize,
255}
256
257pub struct App {
258  pub repo: Repository,
259  /// The name `{repo}` expands to, in `branch_pattern`, `path_pattern` and
260  /// `[worktree].base` alike: the repo directory's basename
261  /// ([`worktree::repo_name`]), which is also the name every `gwm create` from
262  /// the CLI uses and the one the parser side reads back
263  /// ([`crate::naming::BranchParser::for_repo`], `github::read_link`,
264  /// `lifecycle`).
265  ///
266  /// Issue #480: deliberately **not** the workspace display label. That label
267  /// is derived from the workspace's current membership (a second `api` becomes
268  /// `api-2`, #304), so it changes when a sibling repo moves while the branches
269  /// already written keep saying `api-2`. A name persisted in git cannot depend
270  /// on what else happens to sit next to it on disk.
271  pub repo_name: String,
272  /// The name shown to the user for the active repo: the workspace display
273  /// label when there is one, otherwise identical to [`Self::repo_name`]. Only
274  /// the header reads it; nothing that writes a branch or a path does.
275  pub display_repo_name: String,
276  pub workdir: PathBuf,
277  pub config: Config,
278  /// Workspace-mode state (issue #36); `None` in single-repo mode.
279  pub workspace: Option<WorkspaceState>,
280  /// Set when the selected row's repo could not be activated in workspace mode
281  /// (moved / deleted / corrupt since listing). While true, `repo`/`workdir`/
282  /// `config` still point at the previously active repo, so repo-mutating
283  /// actions are blocked to avoid a wrong-target write (#304). Always `false`
284  /// in single-repo mode and once a selection activates cleanly.
285  pub workspace_active_stale: bool,
286  pub worktrees: Vec<WorktreeInfo>,
287  pub list_state: TableState,
288  pub view: View,
289  pub status: String,
290  pub delete_branch_on_remove: bool,
291  pub open_menu_selected: LinkTarget,
292
293  // Create form state
294  /// Create-worktree overlay state (extracted per #123). Holds field
295  /// focus, type index, and the issue/slug input buffers.
296  pub create_form: CreateForm,
297  /// Last asynchronous create failure shown inside the Create modal.
298  pub create_failure: Option<String>,
299  /// Branch types displayed in the create-form picker. Resolved once at
300  /// startup from [`Config::resolved_branch_types`] so the picker
301  /// honours any `[[branch_types]]` override in `.gwm.toml` without
302  /// re-reading the file on every key event.
303  pub branch_types: Vec<BranchType>,
304
305  // Bootstrap report
306  pub report: Option<BootstrapReport>,
307
308  /// Keybindings (help) overlay scroll offset, in rows. Reset to 0 every
309  /// time the overlay opens; clamped to `help_max_scroll` (#217).
310  pub help_scroll: u16,
311  /// Keybindings (help) overlay horizontal scroll offset, in columns (#222).
312  pub help_x_scroll: u16,
313  /// Maximum help scroll offset, republished by [`super::ui::draw_help`]
314  /// each frame as `content_rows.saturating_sub(viewport_rows)` so the
315  /// offset can never scroll past the last line into the void.
316  pub help_max_scroll: u16,
317  /// Maximum horizontal help scroll offset, republished by the renderer.
318  pub help_max_x_scroll: u16,
319
320  /// Sidebar (git preview) panel state (extracted per #127). Owns the
321  /// visibility / focus flags, the scroll offset + max bound, and the
322  /// cached pre-rendered sections keyed by the selected worktree's
323  /// path. The cache prevents re-shelling `git log` / `git status` on
324  /// every TUI redraw — they only run when the selection actually
325  /// changes (via [`SidebarState::on_navigation`]) or on explicit
326  /// refresh ([`SidebarState::invalidate`]). The renderer publishes
327  /// `sidebar.max_scroll` every frame against the actual rendered
328  /// Recent Commits height; [`SidebarState::scroll_down`] clamps
329  /// against it.
330  pub sidebar: SidebarState,
331
332  /// Last completed agent-session snapshot, keyed by worktree path string
333  /// (issue #408). `None` until the first detection lands — the table then
334  /// renders without agent cells, no placeholder noise. Replaced atomically
335  /// by [`Self::apply_agent_snapshot`]; the render path only reads it.
336  pub agent_snapshot: Option<std::collections::BTreeMap<String, crate::agent_sessions::WorktreeAgents>>,
337  /// When the current snapshot was taken — drives the periodic re-detection
338  /// in [`Self::maybe_refresh_agent_sessions`] so freshness colours do not
339  /// fossilise at their startup value.
340  pub agent_snapshot_at: Option<std::time::Instant>,
341  /// Every session the last detection saw, matched or not — the candidate
342  /// pool of the overlay's attach-by-id prompt (user feedback 2026-07-22).
343  pub agent_all_sessions: Vec<crate::agent_sessions::AgentSession>,
344  /// Pinned session ids per worktree path — the sidebar Agents pane shows
345  /// ONLY these (user feedback 2026-07-22), and the render path must not
346  /// read git config, so the map is refreshed off-render (each detection
347  /// cycle + immediately after attach/detach). Empty in workspace mode
348  /// (same single-repo ceiling as the pins themselves).
349  pub agent_pins: std::collections::BTreeMap<String, Vec<String>>,
350  /// A full pool scan was requested while a detection run was in flight —
351  /// it chains after that run lands instead of walking the store
352  /// concurrently (Codex review round R).
353  agent_pool_wanted: bool,
354  /// A pin changed while a detection run was in flight — the re-scan (and
355  /// the pins refresh) chains after that run lands instead of racing a
356  /// second walk against it (Codex review round U).
357  agent_redetect_wanted: bool,
358
359  // Vim motion buffer: armed by first `g`, completed by the second.
360  // **Kept for backward compatibility** with pre-#87 tests that read
361  // it directly. Now a *mirror* of [`Self::pending_chord`] —
362  // [`Self::dispatch_key`] keeps the two synchronised via
363  // [`Self::sync_legacy_pending`]. New code should consume
364  // [`Self::pending_chord_is_empty`] instead.
365  pub pending_g: bool,
366
367  /// Generic pending-keys buffer for the configurable keymap
368  /// (issue #87). Empty most of the time; populated with the
369  /// strokes seen so far whenever the user is partway through a
370  /// chord that is a prefix of a bound binding (e.g. after the
371  /// first `g` of the default `g g → Top`).
372  pub pending_chord: Vec<KeyStroke>,
373
374  /// Resolved keymap for this TUI session. Built from
375  /// [`Config::tui.keys`] at construction time and never mutated
376  /// thereafter — the user has to relaunch gwm to pick up a config
377  /// change, mirroring how every other knob in `[tui]` behaves.
378  pub keymap: Keymap,
379
380  /// Resolved contextual keymap for modals / overlays (issue #219).
381  /// Built from the `[tui.keys.modal.<context>]` sub-tables at construction
382  /// time alongside [`Self::keymap`]; consulted by the modal routing in
383  /// `src/tui/mod.rs` to turn a keystroke into a typed [`ModalAction`].
384  pub modal_keymap: ModalKeymap,
385
386  /// Resolved colour theme for this TUI session (issue #33). Built
387  /// from `[theme]` in `.gwm.toml` at construction time. Threaded
388  /// through `draw_*` calls so user overrides reach every visual
389  /// signal. Same hot-reload-on-relaunch contract as the keymap.
390  pub theme: Theme,
391
392  // Inline fuzzy filter on the worktree list (issue #21, extracted per
393  // #124 with memoisation closing #104). The sub-struct owns the buffer
394  // (`query`), the typing-bar flag (`active`), and a cached indices vec
395  // so the 3–5 `tui/ui.rs` call sites per render frame don't each rerun
396  // the `nucleo_matcher` pass. `App::refresh` calls
397  // `self.filter.invalidate()` to drop the cache when `worktrees`
398  // changes; a worktrees-length mismatch auto-invalidates too.
399  pub filter: FilterState,
400
401  // Picker mode (issue #22): `gwm switch` runs the TUI as a stripped-down
402  // picker. Create / delete / bootstrap keys are inert; Enter records the
403  // highlighted worktree path into `picker_result` and the event loop quits
404  // so the CLI caller can print the path on stdout for `cd "$(gwm switch)"`.
405  pub picker_mode: bool,
406  pub picker_result: Option<PathBuf>,
407  /// Event-loop exit signal for picker mode. Driven by `picker_confirm`
408  /// (only when a worktree is actually selected) and `picker_cancel` (Esc
409  /// from inside the filter bar, where a blanket `break` would clash with
410  /// the regular TUI's clear-filter behaviour). Keeps the loop running on
411  /// Enter-with-no-match so the user can back-space and refine the filter
412  /// instead of being kicked out with exit code 1.
413  pub picker_should_exit: bool,
414
415  /// Event-loop exit signal for `Action::Quit` fired from a path
416  /// that cannot itself `break` the loop (issue #32: the command
417  /// palette routes accepted actions through `run_action`, which
418  /// returns `Result<()>` and has no `break` channel). Set by
419  /// `run_action` when it sees `Action::Quit`; checked at the top
420  /// of every event-loop iteration alongside `picker_should_exit`.
421  pub should_quit: bool,
422
423  /// Safety countdown state for the confirm overlay (issue #30, extracted
424  /// per #125). Holds the timer anchor and exposes the pure state-machine
425  /// API; this `App` keeps the side-effecting wrappers below that compose
426  /// the status messages and call `worktree::remove`.
427  pub confirm: ConfirmModal,
428
429  /// Last delete-worktree failure shown inside the confirm modal (issue
430  /// #257). Kept on `App`, not `ConfirmModal`, because it is the outcome of
431  /// the async worktree deletion side effect rather than countdown state.
432  pub delete_failure: Option<String>,
433
434  /// Animated loader for overlays (issue #187). Advanced by the event
435  /// loop's 200ms poll tick while the confirm countdown is armed and
436  /// read by the renderer; pure state lives in
437  /// [`super::state::spinner::Spinner`].
438  pub spinner: Spinner,
439
440  // ---- Issue/PR linking (issue #67) -------------------------------------
441  /// GitHub fetch state slice — owns the cached link for the currently
442  /// selected worktree's branch, the repo slug parsed from `origin`,
443  /// and the per-target `gh issue view` / `gh pr view` fetch state
444  /// (extracted per #128, part 6/6 of the `App` god-struct
445  /// decomposition #102). The orchestrator methods below
446  /// (`refresh_link`, `refresh_github_status`,
447  /// `apply_issue_fetch_result`, `apply_pr_fetch_result`) are thin
448  /// wrappers that compose the status-bar copy + drive the actual
449  /// `gh` shell-outs; the pure state machine lives on
450  /// `GitHubFetch`.
451  pub github: GitHubFetch,
452  /// Two-stage issue/PR link prompt state (extracted per #126). Owns
453  /// the stage + target + digit buffer; the orchestrator wraps the
454  /// transitions to update the status bar and shell out to
455  /// `github::link_{issue,pr}` on submit.
456  link_prompt: LinkPrompt,
457
458  /// Command palette overlay state (issue #32). Opened by
459  /// `Action::CommandPalette` (default `:` binding). The pure state
460  /// machine — buffer, fuzzy-matched candidates, highlight cursor —
461  /// lives on `PaletteState`; this `App` owns the view transition
462  /// and routes the accepted `Action` back through the normal
463  /// dispatcher so palette and keymap fire identical side effects.
464  pub palette: PaletteState,
465
466  /// TOFU trust mode for this TUI session (issue #95). Resolved at
467  /// the CLI entrypoint from `--allow-bootstrap` / `--deny-bootstrap`
468  /// / `GWM_ALLOW_BOOTSTRAP=1` and threaded down via `tui::run(mode)`.
469  /// Used by `check_trust_for_bootstrap` to gate `submit_create` and
470  /// `bootstrap_selected` — same security policy as the CLI, no
471  /// bypass via the TUI. Default `Prompt` (preserves the safe
472  /// default when callers construct `App` directly, e.g. tests that
473  /// don't care about the gate).
474  pub trust_mode: crate::trust::TrustMode,
475
476  /// Generic off-thread task spine (issue #231; GitHub fetch folded in by
477  /// #255): coalescing + per-key generation late-drop for slow one-shot
478  /// ops — the worktree list refresh and the `gh issue/pr view` fetches.
479  /// Public for the same reason `github` is — the state-machine tests
480  /// claim a generation directly without spawning an OS thread.
481  pub tasks: TaskRunner,
482  /// Last point at which the periodic TUI worktree refresh was armed.
483  /// Tests set this directly to simulate elapsed time without sleeping.
484  pub last_auto_refresh_at: Instant,
485  /// Sender cloned into each background task worker (issue #231; carries the
486  /// GitHub fetch results too since #255).
487  task_tx: mpsc::Sender<TaskMsg>,
488  /// Receiver drained by [`Self::drain_task_results`] each event-loop tick.
489  /// A worker whose `App` has dropped simply fails its `send` and is ignored.
490  task_rx: mpsc::Receiver<TaskMsg>,
491
492  /// Command Logs overlay state (issue #226): the scroll cursor plus an
493  /// owned snapshot of the [`crate::command_log`] global, so the modal
494  /// renders off `App` state rather than locking the global mid-frame.
495  pub command_logs: CommandLogs,
496
497  /// Configuration panel overlay state (issue #232): the scroll cursor
498  /// plus the resolved-row snapshot, filled by [`Self::enter_config_panel`].
499  pub config_panel: ConfigPanel,
500
501  /// The user-level global config path this `App` was constructed with
502  /// (issue #232). Stored so [`Self::enter_config_panel`] resolves the
503  /// panel's source attribution against the *same* layers the running
504  /// config was loaded from — `None` in tests / sandboxed runs with no
505  /// global file, matching [`Config::load_layered`]'s injection point.
506  global_path: Option<PathBuf>,
507
508  /// Live PTY overlay state (issue #35). `Some` while a lazygit or native
509  /// terminal PTY session is open; `None` at all other times.
510  /// Managed by [`Self::open_pty_overlay`] / [`Self::close_pty_overlay`].
511  pub pty_overlay: Option<PtyOverlay>,
512
513  /// Exec profile picker overlay state (issue #325). Populated by
514  /// [`Self::enter_exec_picker`] from `[exec.profiles.*]`; on `Enter` the
515  /// run loop resolves the highlight to an argv and spawns a PTY overlay
516  /// ([`PtyKind::Exec`]) in the selected worktree's directory.
517  pub exec_picker: ExecPicker,
518
519  /// The `[exec]` config captured when the exec picker opened (issue #325).
520  /// In workspace mode `sync_active_repo` can swap `self.config` to another
521  /// repo while the overlay is open, so `Enter` resolves the argv against
522  /// this snapshot — the active repo's `[exec]` at open time — not the live
523  /// config (Codex #333 review).
524  exec_picker_cfg: ExecConfig,
525
526  /// Clean overlay state (issue #325). Holds the gated reclaim scan of the
527  /// selected worktree, the `[clean.profiles.*]` picker, and a dedicated
528  /// safety countdown. Filled by [`Self::enter_clean_overlay`]; the run loop
529  /// fires [`crate::clean::delete_reclaim`] when the countdown elapses.
530  pub clean_overlay: CleanOverlay,
531
532  /// The `[clean]` config captured when the clean overlay opened (issue
533  /// #325) — every re-scan and the delete resolve their dir-set against this
534  /// snapshot, not the live `self.config.clean`, which a workspace
535  /// auto-refresh could swap to another repo's (Codex #333 review).
536  clean_overlay_cfg: CleanConfig,
537
538  /// The safety-countdown duration (seconds) captured when the clean overlay
539  /// opened (issue #325). Pinned alongside [`Self::clean_overlay_cfg`] so a
540  /// workspace config swap can't shorten — or clear to `0` — the delay
541  /// before an armed reclaim fires (Codex #333 review).
542  clean_overlay_countdown_secs: u32,
543
544  /// Generic detail overlay content (issue #408) — filled by
545  /// [`Self::open_agent_overlay`] while [`View::DetailOverlay`] is up.
546  pub detail_overlay: crate::tui::state::detail_overlay::DetailOverlay,
547
548  /// The worktree the open detail overlay was built for — `(path, branch)`
549  /// captured at open so attach/detach pin against it even if an
550  /// auto-refresh drifts the live selection (clean-overlay pattern).
551  detail_overlay_target: Option<(PathBuf, Option<String>)>,
552
553  /// CI-consumer counterpart of `detail_overlay_target` (Codex review
554  /// #455): the `(remote slug, PR number)` the open CI checks overlay was
555  /// built for, captured by [`Self::enter_ci_checks`]. Any link mutation
556  /// that disagrees — the PR changed, disappeared, or (workspace mode)
557  /// the slug moved to another repo whose PR happens to share the
558  /// number — closes the overlay up front via
559  /// [`Self::close_ci_overlay_if_link_disagrees`]; otherwise the stale
560  /// checks stay up through the new fetch, and forever if it fails, with
561  /// `Enter` opening an old PR's check URL.
562  detail_overlay_pr: Option<(Option<String>, u64)>,
563
564  /// The `PrCheck`s the open CI overlay renders (Codex review #455): the
565  /// duration tick used to read them back from the PR fetch cache, so an
566  /// invalidation while the overlay was up — a workspace `refresh_link`
567  /// with no bulk refetch, a failed manual refresh — silently killed the
568  /// clock of a Running check. The overlay owns its checks instead;
569  /// populated at open and on every landing, cleared on close.
570  ci_overlay_checks: Vec<github::PrCheck>,
571
572  /// Set by `Action::ExitToWorktree` (#290): the path the main loop
573  /// should print to stdout just before quitting so the shell wrapper
574  /// (`cd "$(gwm)"`) can change directory. `None` → plain quit.
575  pub should_exit_to: Option<PathBuf>,
576
577  /// The selected worktree's branch name captured when the rename modal
578  /// (`View::Edit`, #290) opens — the `<old>` in `git branch -m <old> <new>`.
579  /// `None` while the modal is closed.
580  pub edit_original_branch: Option<String>,
581
582  /// The selected worktree's on-disk path captured when the rename modal
583  /// opens — the source for `git worktree move <old_path> <new_path>`.
584  pub edit_original_path: Option<PathBuf>,
585
586  /// Last rename failure, surfaced inside the Edit modal (mirrors
587  /// [`Self::create_failure`]) so the user can correct and retry without
588  /// losing the form. Cleared when the modal reopens.
589  pub edit_failure: Option<String>,
590}
591
592impl App {
593  pub fn new() -> Result<Self> {
594    Self::new_at(None)
595  }
596
597  pub fn new_at(start: Option<&Path>) -> Result<Self> {
598    Self::new_at_layered(start, crate::config::global_config_path().as_deref())
599  }
600
601  /// Injectable variant of [`Self::new_at`] (issue #194): `global_path`
602  /// is the user-level global config layered under the repo's `.gwm.toml`
603  /// (`None` = repo-only, no environment read). Tests pass `None` so `App`
604  /// construction never depends on the runner's real
605  /// `~/.config/gwm/config.toml`. `new_at` delegates with the real
606  /// `global_config_path()`, so runtime behaviour is unchanged.
607  pub fn new_at_layered(start: Option<&Path>, global_path: Option<&Path>) -> Result<Self> {
608    let repo = worktree::discover_repo(start)?;
609    let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?.to_path_buf();
610    let repo_name = worktree::repo_name(&repo);
611    let config = Config::load_layered(&workdir, global_path)?;
612    let branch_types = config.resolved_branch_types().types;
613    // Resolve the keymap once at construction. Config::load_for_repo
614    // already validated the overrides, so this should not surface a
615    // fresh error — but we re-`?` it rather than `.expect()` so a
616    // future hot-reload path could exercise the same call.
617    let keymap = config.tui.keys.resolved_keymap()?;
618    // Issue #219: resolve the contextual modal keymap once, same lifecycle
619    // as the global keymap above. Pre-validated by `Config::load_for_repo`.
620    let modal_keymap = config.tui.keys.resolved_modal_keymap()?;
621    // Issue #33: resolve the colour theme once at construction.
622    // Validated by `Config::load_for_repo` already, so this can
623    // only surface a fresh error if the loader pre-validation is
624    // bypassed (e.g. a future hot-reload path) — `?` is still the
625    // right propagation policy.
626    let theme = config.theme.resolve()?;
627    let worktrees = worktree::list(&repo)?;
628    let mut state = TableState::default();
629    if !worktrees.is_empty() {
630      state.select(Some(0));
631    }
632    let (task_tx, task_rx) = mpsc::channel();
633    let mut out = Self {
634      repo,
635      display_repo_name: repo_name.clone(),
636      repo_name,
637      workdir,
638      config,
639      workspace: None,
640      workspace_active_stale: false,
641      worktrees,
642      list_state: state,
643      view: View::List,
644      status: String::from("press ? for help"),
645      delete_branch_on_remove: false,
646      open_menu_selected: LinkTarget::Issue,
647      create_form: CreateForm::new(),
648      create_failure: None,
649      branch_types,
650      report: None,
651      help_scroll: 0,
652      help_x_scroll: 0,
653      help_max_scroll: 0,
654      help_max_x_scroll: 0,
655      sidebar: SidebarState::new(),
656      agent_snapshot: None,
657      agent_snapshot_at: None,
658      agent_all_sessions: Vec::new(),
659      agent_pins: std::collections::BTreeMap::new(),
660      agent_pool_wanted: false,
661      agent_redetect_wanted: false,
662      pending_g: false,
663      pending_chord: Vec::new(),
664      keymap,
665      modal_keymap,
666      theme,
667      filter: FilterState::new(),
668      picker_mode: false,
669      picker_result: None,
670      picker_should_exit: false,
671      should_quit: false,
672      confirm: ConfirmModal::new(),
673      delete_failure: None,
674      spinner: Spinner::new(),
675      github: GitHubFetch::new(),
676      link_prompt: LinkPrompt::new(),
677      palette: PaletteState::new(),
678      trust_mode: crate::trust::TrustMode::Prompt,
679      tasks: TaskRunner::new(),
680      last_auto_refresh_at: Instant::now(),
681      task_tx,
682      task_rx,
683      command_logs: CommandLogs::new(),
684      config_panel: ConfigPanel::new(),
685      global_path: global_path.map(Path::to_path_buf),
686      pty_overlay: None,
687      exec_picker: ExecPicker::new(),
688      exec_picker_cfg: ExecConfig::default(),
689      clean_overlay: CleanOverlay::new(),
690      clean_overlay_cfg: CleanConfig::default(),
691      clean_overlay_countdown_secs: 0,
692      detail_overlay: crate::tui::state::detail_overlay::DetailOverlay::default(),
693      detail_overlay_target: None,
694      detail_overlay_pr: None,
695      ci_overlay_checks: Vec::new(),
696      should_exit_to: None,
697      edit_original_branch: None,
698      edit_original_path: None,
699      edit_failure: None,
700    };
701    out.apply_sidebar_config();
702    out.apply_create_form_fields();
703    out.refresh_link();
704    let spawned = out.refresh_linked_github_statuses_for_worktrees();
705    if spawned > 0 {
706      out.status = String::from("fetching GitHub status…");
707    }
708    Ok(out)
709  }
710
711  /// Workspace-mode constructor (issue #36): open the TUI over every git repo
712  /// one level below `root`, merging their worktree listings into one
713  /// repo-tagged table. Anchors the session on the first repo (alphabetical)
714  /// for keymap/theme resolution and the event-loop channels, then swaps the
715  /// merged list and per-row repo map in. Errors with [`GwmError::EmptyWorkspace`]
716  /// when no repo sits directly under `root`.
717  pub fn new_workspace_at_layered(root: &Path, global_path: Option<&Path>) -> Result<Self> {
718    let ws = crate::workspace::discover(root)?;
719    if ws.is_empty() {
720      return Err(GwmError::EmptyWorkspace {
721        root: root.display().to_string(),
722      });
723    }
724
725    // Load each repo's `.gwm.toml` once — session-stable metadata swapped into
726    // the active slot on navigation.
727    let mut repos: Vec<RepoMeta> = Vec::with_capacity(ws.repos.len());
728    for r in &ws.repos {
729      let config = Config::load_layered(&r.path, global_path)?;
730      repos.push(RepoMeta {
731        name: r.name.clone(),
732        workdir: r.path.clone(),
733        config,
734      });
735    }
736
737    // Anchor the session on the first repo: this resolves the keymap, theme,
738    // branch types, and sets up the task channels exactly as single-repo mode.
739    let mut app = Self::new_at_layered(Some(&repos[0].workdir), global_path)?;
740
741    // Replace the single-repo list with the merged, repo-tagged one. Map each
742    // row to its repo by the repo's *workdir path*, not its display name —
743    // names can collide (a linked worktree resolving to an owner outside the
744    // root, symlinks), and a name-keyed map would then point rows at the wrong
745    // repo handle/config (Codex review #303 round-2 P2).
746    let path_to_idx: HashMap<&Path, usize> = repos
747      .iter()
748      .enumerate()
749      .map(|(i, m)| (m.workdir.as_path(), i))
750      .collect();
751    let rows = crate::workspace::merge_worktrees(&ws)?;
752    let mut worktrees = Vec::with_capacity(rows.len());
753    let mut row_repo = Vec::with_capacity(rows.len());
754    for row in &rows {
755      let idx = path_to_idx.get(row.repo_path.as_path()).copied().unwrap_or(0);
756      worktrees.push(row.info.clone());
757      row_repo.push(idx);
758    }
759
760    let repo_count = repos.len();
761    let wt_count = worktrees.len();
762    app.worktrees = worktrees;
763    app.workspace = Some(WorkspaceState {
764      root: root.to_path_buf(),
765      repos,
766      row_repo,
767      active: 0,
768    });
769    app.filter.invalidate();
770    app.list_state.select(if wt_count == 0 { None } else { Some(0) });
771    // Resolve the initially-selected row's GitHub link/slug against its own
772    // repo (the anchor). Workspace mode fetches GitHub state per-selection, not
773    // in one cross-repo bulk pass — see `refresh_linked_github_statuses_for_worktrees`.
774    app.refresh_link();
775    app.status = format!(
776      "workspace {} — {} repo(s), {} worktree(s) · press ? for help",
777      root.display(),
778      repo_count,
779      wt_count
780    );
781    Ok(app)
782  }
783
784  /// True when the TUI is in workspace mode (issue #36).
785  pub fn is_workspace(&self) -> bool {
786    self.workspace.is_some()
787  }
788
789  /// Display name of the repo owning raw worktree row `raw_index` (the index
790  /// into [`Self::worktrees`], not the filtered view). `None` in single-repo
791  /// mode or for an out-of-range index. Drives the TUI `REPO` column.
792  pub fn row_repo_name(&self, raw_index: usize) -> Option<&str> {
793    let ws = self.workspace.as_ref()?;
794    let idx = *ws.row_repo.get(raw_index)?;
795    ws.repos.get(idx).map(|m| m.name.as_str())
796  }
797
798  /// Raw `worktrees` index of the current selection, hopping through the fuzzy
799  /// filter map (the selection indexes the filtered view, not the raw vec).
800  fn selected_raw_index(&self) -> Option<usize> {
801    let i = self.list_state.selected()?;
802    let filtered = self.filter.snapshot_indices(&self.worktrees, fuzzy_match_indices);
803    filtered.get(i).copied()
804  }
805
806  /// Align the active repo (`repo`/`repo_name`/`workdir`/`config`) with the
807  /// selected worktree's repo (issue #36). A no-op in single-repo mode and
808  /// when the selection still belongs to the active repo, so the event loop
809  /// can call it every frame cheaply. On the repo actually changing it
810  /// re-opens the `git2::Repository` from the target workdir and invalidates
811  /// the sidebar preview; an open failure keeps the current repo and reports
812  /// on the status bar rather than panicking mid-render.
813  /// Apply the `[tui]` sidebar knobs from the live config onto the sidebar
814  /// state. Called from every point where `self.config` becomes authoritative:
815  /// construction, the Settings-panel reload, and the workspace repo swap
816  /// (`sync_active_repo`). Kept as one call rather than open-coded assignments
817  /// so a future knob can't be wired into two of the three and silently drift —
818  /// which is precisely how the repo-swap path came to ignore
819  /// `sidebar_position` (Codex review #366 P2).
820  fn apply_sidebar_config(&mut self) {
821    self.sidebar.position = self.config.tui.sidebar_position;
822    self.sidebar.orientation = self.config.tui.sidebar_orientation;
823  }
824
825  /// Point the create form at the fields the active repo's patterns ask for
826  /// (issue #418). Same three call sites and same reasoning as
827  /// [`Self::apply_sidebar_config`]: one call rather than open-coded
828  /// assignments, so the repo swap cannot come to ignore it.
829  ///
830  /// The union covers `base` as well as the two patterns, because
831  /// [`crate::naming::BranchSpec::worktree_path`] expands the triple in it too:
832  /// a segment only `base` carries still names a directory on disk.
833  /// Public for the same reason the rest of the lib surface is (#342, the
834  /// internal test seam): state-machine tests set `config.worktree.*` directly
835  /// rather than round-tripping a `.gwm.toml`, and that shortcut has to be able
836  /// to re-derive what construction derives.
837  pub fn apply_create_form_fields(&mut self) {
838    self.create_form.set_fields(super::state::create_form::fields_for(&[
839      &self.config.worktree.branch_pattern,
840      &self.config.worktree.path_pattern,
841      &self.config.worktree.base,
842    ]));
843  }
844
845  /// The name of the field Enter submits from, for the status line (#418).
846  /// Read from the same `last_field` the key handler gates on, so the hint and
847  /// the behaviour cannot disagree.
848  fn submit_field_label(&self) -> &'static str {
849    match self.create_form.last_field() {
850      Field::Type => "type",
851      Field::Issue => "issue",
852      Field::Name => "name",
853      Field::Desc => "desc",
854    }
855  }
856
857  /// The verbs the structured form's status line advertises (#418), naming the
858  /// real submit field and dropping the field-switching half when there is
859  /// nothing to switch between. Same rule the hint row follows, and the same
860  /// reason: `next_field` rotates within a one-element list, so telling the
861  /// user to press Tab names a key that does nothing.
862  fn structured_form_instruction(&self) -> String {
863    match self.create_form.fields().len() {
864      // A pattern set with no editable token at all presents no field, so
865      // there is none to name — `last_field` falls back to `Type`, which the
866      // renderer is not drawing (Codex review on PR #492, fifth pass).
867      0 => "enter: submit".into(),
868      1 => format!("enter on {}: submit", self.submit_field_label()),
869      _ => format!(
870        "tab/shift-tab: switch field — enter on {}: submit",
871        self.submit_field_label()
872      ),
873    }
874  }
875
876  /// How the free-form status line names the mode it toggles back to (#418):
877  /// the fields that mode actually presents, rather than the canonical triple.
878  /// On a `{type}/{desc}` repo "back to type/issue/desc" promised an Issue
879  /// field the structured form hides.
880  fn structured_mode_label(&self) -> String {
881    let names: Vec<&str> = self
882      .create_form
883      .fields()
884      .iter()
885      .map(|f| match f {
886        Field::Type => "type",
887        Field::Issue => "issue",
888        Field::Desc => "desc",
889        Field::Name => "name",
890      })
891      .collect();
892    if names.is_empty() {
893      "the structured form".into()
894    } else {
895      names.join("/")
896    }
897  }
898
899  /// The segments the active repo's patterns ask the user to supply — what
900  /// [`crate::naming::BranchSpec::new_with_required`] validates against, so the
901  /// form never refuses a submission over a value the patterns discard (#418).
902  fn required_segments(&self) -> Vec<&'static str> {
903    crate::naming::editable_segments(&[
904      &self.config.worktree.branch_pattern,
905      &self.config.worktree.path_pattern,
906      &self.config.worktree.base,
907    ])
908  }
909
910  /// Mark the workspace selection stale AND close the open CI checks
911  /// overlay (Codex review #455): its rows belong to the previously
912  /// active repo, so every verb — `Enter` opening a check URL included —
913  /// would act on the wrong repo. One funnel so a new stale site cannot
914  /// forget the close.
915  fn mark_workspace_stale(&mut self) {
916    self.workspace_active_stale = true;
917    if self.view == View::DetailOverlay
918      && self.detail_overlay.kind == crate::tui::state::detail_overlay::DetailKind::CiChecks
919    {
920      self.close_detail_overlay();
921    }
922  }
923
924  pub fn sync_active_repo(&mut self) {
925    let Some(ws) = self.workspace.as_ref() else {
926      return;
927    };
928    let Some(raw) = self.selected_raw_index() else {
929      // No visible/selected row (e.g. the filter hides everything): there is no
930      // active repo the selection points at, so writes must not fall through to
931      // the previously active repo — mark stale to block them (#304). Reached
932      // only in workspace mode (the `ws` guard above returns in single-repo).
933      self.mark_workspace_stale();
934      return;
935    };
936    let Some(&target) = ws.row_repo.get(raw) else {
937      self.mark_workspace_stale();
938      return;
939    };
940    if target == ws.active {
941      // The selection is on the live, already-activated repo — clear any stale
942      // flag left over from a previous unreachable selection.
943      self.workspace_active_stale = false;
944      return;
945    }
946    let Some(meta) = ws.repos.get(target).cloned() else {
947      return;
948    };
949    match Repository::open(&meta.workdir) {
950      Ok(repo) => {
951        // Issue #480: the naming name comes from the freshly-opened repo's own
952        // directory, never from the workspace label — see `App::repo_name`.
953        self.repo_name = worktree::repo_name(&repo);
954        self.repo = repo;
955        self.display_repo_name = meta.name;
956        self.workdir = meta.workdir;
957        self.config = meta.config;
958        self.workspace_active_stale = false;
959        // The branch types drive the create form; re-resolve them from the
960        // newly-active repo's config so a per-repo `[[branch_types]]` override
961        // applies to the row being acted on (Codex review #303 P2).
962        self.branch_types = self.config.resolved_branch_types().types;
963        // Same reasoning for the form's field set (#418): the swap replaced
964        // `self.config` wholesale, so the newly-active repo's patterns decide
965        // which fields the form presents.
966        self.apply_create_form_fields();
967        // Same reasoning for the sidebar layout: the swap replaced `self.config`
968        // wholesale, so a per-repo `[tui]` sidebar override would otherwise be
969        // ignored until a reload (Codex review #366 P2).
970        self.apply_sidebar_config();
971        if let Some(ws) = self.workspace.as_mut() {
972          ws.active = target;
973        }
974        self.invalidate_sidebar_cache();
975        // Re-resolve the GitHub link + slug against the now-active repo so the
976        // Issue/PR panel and the `F` refresh act on the selected row's own
977        // repo, not the previously-active one (Codex review #303 P2). The
978        // per-repo nav hook (`on_navigation`) ran `refresh_link` *before* this
979        // swap, while `self.repo` still pointed at the old repo.
980        self.refresh_link();
981      }
982      Err(e) => {
983        // Keep the previously active repo live but mark the selection stale so
984        // repo-mutating actions are blocked until the user moves to a
985        // reachable row (or a refresh drops the dead repo) — #304.
986        self.mark_workspace_stale();
987        self.status = format!(
988          "workspace: repo '{}' is unavailable ({}) — press r to refresh",
989          meta.name, e
990        );
991      }
992    }
993  }
994
995  /// Set the active config, keeping the workspace cache coherent. In
996  /// workspace mode the per-repo `RepoMeta.config` is the source of truth that
997  /// `sync_active_repo` restores on activation, so a settings/keymap reload
998  /// that only updated `self.config` would be reverted the next time the user
999  /// navigated away and back (Codex review #303 P3). Write the reloaded config
1000  /// through to the active repo's cached meta too.
1001  fn set_active_config(&mut self, cfg: Config) {
1002    self.config = cfg;
1003    if let Some(ws) = self.workspace.as_mut() {
1004      if let Some(meta) = ws.repos.get_mut(ws.active) {
1005        meta.config = self.config.clone();
1006      }
1007    }
1008  }
1009
1010  /// Reload every workspace repo's cached config from disk (issue #36). Called
1011  /// after a Global-layer settings edit, which changes the deep-merged config
1012  /// for *all* repos — without this, navigating to a non-active repo would
1013  /// restore the config it was loaded with at startup, reverting the edit for
1014  /// that repo until relaunch (Codex review #303 P2). The active repo's live
1015  /// `self.config` is already current (set by `set_active_config`); this
1016  /// re-syncs its cached meta too, so it stays the single source of truth.
1017  fn reload_workspace_repo_configs(&mut self) {
1018    let Some(ws) = self.workspace.as_ref() else {
1019      return;
1020    };
1021    let global = self.global_path.clone();
1022    let targets: Vec<(usize, PathBuf)> = ws
1023      .repos
1024      .iter()
1025      .enumerate()
1026      .map(|(i, m)| (i, m.workdir.clone()))
1027      .collect();
1028    for (i, workdir) in targets {
1029      if let Ok(cfg) = Config::load_layered(&workdir, global.as_deref()) {
1030        if let Some(ws) = self.workspace.as_mut() {
1031          if let Some(meta) = ws.repos.get_mut(i) {
1032            meta.config = cfg;
1033          }
1034        }
1035      }
1036    }
1037  }
1038
1039  /// Per-row mask of whether each `worktrees` row belongs to the currently
1040  /// active repo. `None` in single-repo mode (every row qualifies). Issue/PR
1041  /// numbers are only unique *within* a repo, so the number-keyed GitHub state
1042  /// stamping must be scoped to the active repo's rows in workspace mode —
1043  /// otherwise a fetch for repo A's `#1` would stamp (and persist to the wrong
1044  /// repo) every other repo's `#1` row (Codex review #303 P2).
1045  fn active_repo_row_mask(&self) -> Option<Vec<bool>> {
1046    let ws = self.workspace.as_ref()?;
1047    Some(ws.row_repo.iter().map(|&r| r == ws.active).collect())
1048  }
1049
1050  /// Re-list every repo in the workspace and rebuild the merged table +
1051  /// row→repo map (issue #36). The single-repo async refresh would clobber the
1052  /// merged list with one repo's worktrees, so workspace refresh runs
1053  /// synchronously across all repos instead. Repos are fixed for the session
1054  /// (a new repo under the root needs a relaunch, matching the config "resolved
1055  /// once" contract), so this re-lists the stored metas rather than re-walking
1056  /// the root.
1057  fn refresh_workspace(&mut self) {
1058    let targets = self.workspace_refresh_targets();
1059    let rows = Self::list_workspace(&targets);
1060    self.apply_workspace_worktrees(rows);
1061  }
1062
1063  /// The `(repo_index, workdir)` targets a workspace re-list walks — every
1064  /// repo's stored `workdir` (repos are fixed for the session). Owned `Send`
1065  /// data, so the async worker ([`Self::spawn_refresh_workspace`], issue #343)
1066  /// can move it across the thread boundary; the synchronous path uses it too.
1067  fn workspace_refresh_targets(&self) -> Vec<(usize, PathBuf)> {
1068    self
1069      .workspace
1070      .as_ref()
1071      .map(|ws| {
1072        ws.repos
1073          .iter()
1074          .enumerate()
1075          .map(|(i, m)| (i, m.workdir.clone()))
1076          .collect()
1077      })
1078      .unwrap_or_default()
1079  }
1080
1081  /// Open + list every workspace target into merged `(worktree, repo_index)`
1082  /// rows (issue #343 / #36). A static fn taking owned targets so it runs
1083  /// unchanged on the async worker thread or the synchronous path. Per-repo
1084  /// open / list errors are swallowed — a broken repo drops its rows, the rest
1085  /// still list — matching the pre-#343 synchronous behaviour.
1086  fn list_workspace(targets: &[(usize, PathBuf)]) -> Vec<(WorktreeInfo, usize)> {
1087    let mut rows = Vec::new();
1088    for (idx, workdir) in targets {
1089      if let Ok(repo) = Repository::open(workdir) {
1090        if let Ok(trees) = worktree::list(&repo) {
1091          for t in trees {
1092            rows.push((t, *idx));
1093          }
1094        }
1095      }
1096    }
1097    rows
1098  }
1099
1100  /// Apply merged workspace rows: rebuild the row→repo map, swap in the merged
1101  /// worktree list, and re-align the active repo (issue #343 / #36). Shared by
1102  /// the synchronous [`Self::refresh_workspace`] and the async
1103  /// `RefreshWorkspace` drain so the two can never drift.
1104  fn apply_workspace_worktrees(&mut self, rows: Vec<(WorktreeInfo, usize)>) {
1105    let mut worktrees = Vec::with_capacity(rows.len());
1106    let mut row_repo = Vec::with_capacity(rows.len());
1107    for (t, idx) in rows {
1108      worktrees.push(t);
1109      row_repo.push(idx);
1110    }
1111    if let Some(ws) = self.workspace.as_mut() {
1112      ws.row_repo = row_repo;
1113    }
1114    self.apply_refreshed_worktrees(worktrees);
1115    // The selection may now land on a different repo's row — re-align the
1116    // active repo. `sync_active_repo` only refreshes the link when the repo
1117    // actually changes, so re-resolve the selected row's link/slug here too
1118    // (the bulk prefetch is a no-op in workspace mode).
1119    self.sync_active_repo();
1120    self.refresh_link();
1121  }
1122
1123  /// Builder-style setter for `trust_mode`. The TUI entrypoint
1124  /// (`tui::run`) calls this after construction to thread through
1125  /// the CLI flags / env resolution; tests can use it directly to
1126  /// exercise each variant of the gate.
1127  pub fn with_trust_mode(mut self, mode: crate::trust::TrustMode) -> Self {
1128    self.trust_mode = mode;
1129    self
1130  }
1131
1132  /// Silent TOFU gate for the TUI's bootstrap call sites
1133  /// (`submit_create`, `bootstrap_selected`). Returns:
1134  ///
1135  /// * `Ok(None)` — caller is cleared to invoke `bootstrap::run`.
1136  /// * `Ok(Some(msg))` — caller MUST NOT run bootstrap; show `msg`
1137  ///   to the user (e.g. assign to `self.status`). Untrusted
1138  ///   configs and `TrustMode::Deny` both land here — the TUI
1139  ///   alternate-screen can't host a stdin prompt today, so we
1140  ///   refuse with a hint pointing the user at the CLI gate
1141  ///   (`gwm bootstrap` from another terminal).
1142  /// * `Err(e)` — ledger I/O / config read error propagated verbatim.
1143  pub fn check_trust_for_bootstrap(&self) -> Result<Option<String>> {
1144    use crate::trust::{self, TrustOutcome};
1145
1146    let origin = trust::origin_key_for_repo(&self.repo, &self.workdir);
1147
1148    match trust::evaluate(&self.workdir, &origin, self.trust_mode)? {
1149      TrustOutcome::Proceed => Ok(None),
1150      TrustOutcome::Refuse { message } => Ok(Some(message)),
1151      TrustOutcome::Prompt { cfg_path, sha, .. } => {
1152        let short_sha: String = sha.chars().take(12).collect();
1153        Ok(Some(format!(
1154          ".gwm.toml at {} not in trust ledger (hash {}) — \
1155           run `gwm bootstrap` from a CLI in another terminal to approve, \
1156           or relaunch with GWM_ALLOW_BOOTSTRAP=1 / --allow-bootstrap",
1157          cfg_path.display(),
1158          short_sha
1159        )))
1160      }
1161    }
1162  }
1163
1164  /// Constructor for `gwm switch`: same App, but picker mode is on and the
1165  /// fuzzy filter bar is open from the first frame so the user can start
1166  /// narrowing right away. Everything else (worktree list, sidebar, vim
1167  /// motions) behaves identically; only the event-loop interpretation of
1168  /// Enter / n / d / b changes.
1169  pub fn new_picker_at(start: Option<&Path>) -> Result<Self> {
1170    Self::new_picker_at_layered(start, crate::config::global_config_path().as_deref())
1171  }
1172
1173  /// Injectable variant of [`Self::new_picker_at`] (issue #196): mirrors
1174  /// [`Self::new_at_layered`] so picker-mode tests never read the runner's
1175  /// real `~/.config/gwm/config.toml`. `new_picker_at` delegates with the
1176  /// real `global_config_path()`.
1177  pub fn new_picker_at_layered(start: Option<&Path>, global_path: Option<&Path>) -> Result<Self> {
1178    let mut app = Self::new_at_layered(start, global_path)?;
1179    app.picker_mode = true;
1180    app.filter.open();
1181    app.status = "switch picker — type to filter · enter selects · esc cancels".into();
1182    Ok(app)
1183  }
1184
1185  /// Synchronous worktree list refresh. Kept for internal post-mutation
1186  /// callers (create / delete / report-close) that need the list fresh
1187  /// *before* the next render; the user-initiated `f` / `r` key path goes
1188  /// through the off-thread [`Self::request_refresh`] instead (issue
1189  /// #231). Both converge on [`Self::apply_refreshed_worktrees`] so the
1190  /// two paths can never drift on the post-list bookkeeping.
1191  pub fn refresh(&mut self) -> Result<()> {
1192    // A synchronous re-list (create / delete / report-close) produces
1193    // authoritative fresh state, so any older async refresh still in flight
1194    // is by definition stale — bump its generation so `drain_task_results`
1195    // drops the late result instead of clobbering this post-mutation list
1196    // with a pre-mutation snapshot (issue #231, the #138 race class). A
1197    // harmless counter bump when no task is running. Lives here and not in
1198    // `apply_refreshed_worktrees` so the async drain, which shares that
1199    // tail, does not re-invalidate the run it just applied.
1200    self.tasks.invalidate(TaskKind::RefreshWorktrees);
1201    // Same for an in-flight async workspace re-list (issue #343): this
1202    // synchronous path produces authoritative post-mutation state, so drop the
1203    // stale run's generation. (The in-flight *sidebar* rebuild is dropped by
1204    // `apply_refreshed_worktrees`, the tail every refresh path shares.)
1205    self.tasks.invalidate(TaskKind::RefreshWorkspace);
1206    if self.is_workspace() {
1207      // Workspace mode re-lists every repo, not just the active one (#36).
1208      self.refresh_workspace();
1209      return Ok(());
1210    }
1211    let worktrees = worktree::list(&self.repo)?;
1212    self.apply_refreshed_worktrees(worktrees);
1213    Ok(())
1214  }
1215
1216  /// Swap in a freshly-listed worktree vec and run the bookkeeping every
1217  /// refresh path shares: drop the cached fuzzy-match indices (they point
1218  /// at the previous vec — a length change auto-invalidates, but a
1219  /// same-length list with different contents would not, so the explicit
1220  /// flush is the safe play), re-clamp the selection (which re-resolves
1221  /// the link cache), refresh every Issue/PR status linked by the listed
1222  /// rows, invalidate the sidebar preview, and report the count. Called by
1223  /// the synchronous [`Self::refresh`] and by the off-thread drain in
1224  /// [`Self::drain_task_results`].
1225  fn apply_refreshed_worktrees(&mut self, mut worktrees: Vec<WorktreeInfo>) {
1226    let old_keys: std::collections::BTreeSet<(PathBuf, Option<String>)> = self
1227      .worktrees
1228      .iter()
1229      .map(|w| (w.path.clone(), w.branch.clone()))
1230      .collect();
1231    // The carry-over preserves this session's in-memory fetched issue/PR state
1232    // across a re-list, keyed by number. In workspace mode that key collides
1233    // across repos (two repos can both own `#1`), so skip it: the freshly
1234    // listed rows already carry each repo's own *persisted* state from
1235    // `read_link`, which is per-repo-correct (Codex review #303 P2).
1236    if !self.is_workspace() {
1237      let issue_states: HashMap<u64, IssueState> = self
1238        .worktrees
1239        .iter()
1240        .filter_map(|w| Some((w.link.issue?, w.issue_state?)))
1241        .collect();
1242      let pr_states = self
1243        .worktrees
1244        .iter()
1245        .filter_map(|w| Some((w.link.pr?, w.pr_state?)))
1246        .collect::<HashMap<_, _>>();
1247
1248      for w in &mut worktrees {
1249        if let Some(issue) = w.link.issue {
1250          if let Some(state) = issue_states.get(&issue).copied() {
1251            w.issue_state = Some(state);
1252          }
1253        }
1254        if let Some(pr) = w.link.pr {
1255          if let Some(state) = pr_states.get(&pr).copied() {
1256            w.pr_state = Some(state);
1257          }
1258        }
1259      }
1260    }
1261
1262    self.worktrees = worktrees;
1263    self.filter.invalidate();
1264    self.clamp_selection_to_filter();
1265    let spawned = self.refresh_linked_github_statuses_for_worktrees();
1266    self.invalidate_sidebar_cache();
1267    // The re-list re-read git state, so any in-flight sidebar rebuild is now
1268    // reading *pre-refresh* data — bump its generation so a late result is
1269    // dropped by the drain instead of stored under the current key and rendered
1270    // as fresh until the next navigation (issue #343). Lives here, in the tail
1271    // every refresh path shares, so the OFF-thread drains (`RefreshWorktrees` /
1272    // `RefreshWorkspace`) get it too, not just the synchronous `refresh`.
1273    self.tasks.invalidate(TaskKind::Sidebar);
1274    // Agent staleness is keyed to the SET of (path, branch) pairs, not to
1275    // the refresh itself (Codex review rounds P + Q): invalidating
1276    // unconditionally freed the in-flight slot while its scan thread kept
1277    // running, so an auto-refresh faster than the scan piled up concurrent
1278    // scans whose results were each dropped as stale — no snapshot ever
1279    // landed. The branch is part of the key because pins live in BRANCH
1280    // config: a same-path checkout that switched branch must drop the old
1281    // branch's pins instead of showing them for up to 30 s. A same-keys
1282    // refresh keeps the in-flight run (the 30 s TTL owns freshness).
1283    let new_keys: std::collections::BTreeSet<(PathBuf, Option<String>)> = self
1284      .worktrees
1285      .iter()
1286      .map(|w| (w.path.clone(), w.branch.clone()))
1287      .collect();
1288    if old_keys != new_keys {
1289      self.tasks.invalidate(TaskKind::AgentSessions);
1290      self.agent_snapshot_at = None;
1291    }
1292    self.status = if spawned > 0 {
1293      format!(
1294        "refreshed — {} worktree(s); fetching GitHub status…",
1295        self.worktrees.len()
1296      )
1297    } else {
1298      format!("refreshed — {} worktree(s)", self.worktrees.len())
1299    };
1300  }
1301
1302  /// Off-thread worktree list refresh for the `f` / `r` key (issue #231):
1303  /// spawn a worker that re-lists the worktrees and posts the result back
1304  /// to the event loop, so a large repo / slow filesystem no longer
1305  /// freezes the TUI. Coalesces onto an in-flight run (a second press
1306  /// while loading is a no-op) and seeds the loader label + spinner. The
1307  /// result is applied by [`Self::drain_task_results`].
1308  pub fn request_refresh(&mut self) {
1309    if self.is_workspace() {
1310      // Workspace mode re-lists every repo off-thread on its own slot (issue
1311      // #343): the single-repo worker can't be reused (it would clobber the
1312      // merged list with one repo's worktrees, #36), so route through
1313      // `RefreshWorkspace` instead of the pre-#343 synchronous `refresh()`.
1314      let Some(generation) = self.tasks.request(TaskKind::RefreshWorkspace) else {
1315        return;
1316      };
1317      self.spinner.reset();
1318      self.status = TaskKind::RefreshWorkspace.loading_label().into();
1319      self.spawn_refresh_workspace(generation);
1320      return;
1321    }
1322    let Some(generation) = self.tasks.request(TaskKind::RefreshWorktrees) else {
1323      // A refresh is already in flight — coalesce onto it.
1324      return;
1325    };
1326    // Start the loader from a deterministic frame and surface the label.
1327    self.spinner.reset();
1328    self.status = TaskKind::RefreshWorktrees.loading_label().into();
1329    self.spawn_refresh(generation);
1330  }
1331
1332  /// Periodic worktree-list refresh for the TUI event loop. Returns `true`
1333  /// only when a new async refresh task was actually started. `0` disables
1334  /// the feature, and an in-flight refresh coalesces so the renderer is never
1335  /// blocked by repeated relist attempts.
1336  pub fn maybe_auto_refresh(&mut self, now: Instant) -> bool {
1337    let secs = self.config.tui.auto_refresh_secs;
1338    if secs == 0 {
1339      return false;
1340    }
1341    if now.saturating_duration_since(self.last_auto_refresh_at) < Duration::from_secs(secs) {
1342      return false;
1343    }
1344    self.last_auto_refresh_at = now;
1345    if self.is_workspace() {
1346      // Off-thread merged refresh in workspace mode (issue #343 / #36): the
1347      // per-repo `Repository::open` + `worktree::list` loop no longer freezes
1348      // the event loop on a many-repo workspace. Coalesces onto an in-flight
1349      // run so a slow relist never stacks.
1350      let Some(generation) = self.tasks.request(TaskKind::RefreshWorkspace) else {
1351        return false;
1352      };
1353      self.spinner.reset();
1354      self.status = "auto-refreshing worktrees…".into();
1355      self.spawn_refresh_workspace(generation);
1356      return true;
1357    }
1358    let Some(generation) = self.tasks.request(TaskKind::RefreshWorktrees) else {
1359      return false;
1360    };
1361    self.spinner.reset();
1362    self.status = "auto-refreshing worktrees…".into();
1363    self.spawn_refresh(generation);
1364    true
1365  }
1366
1367  /// Spawn one background worktree-list worker tagged with `generation`
1368  /// (issue #231). A thin shell, mirroring [`Self::spawn_github_fetch`]:
1369  /// it owns only the off-thread dispatch + send, no state logic (the
1370  /// coalescing / late-drop contract lives in [`TaskRunner`], tested in
1371  /// `tui_state_async_task_tests.rs`). `git2::Repository` is not `Send`,
1372  /// so the worker opens its *own* repo from the owned `workdir` path
1373  /// rather than borrowing `self.repo` — the same "only owned `Send` data
1374  /// crosses the boundary" discipline as the GitHub worker. A `send`
1375  /// failure (the `App`/receiver dropped) is ignored.
1376  fn spawn_refresh(&self, generation: u64) {
1377    let tx = self.task_tx.clone();
1378    let workdir = self.workdir.clone();
1379    std::thread::spawn(move || {
1380      let result = worktree::discover_repo(Some(&workdir))
1381        .and_then(|repo| worktree::list(&repo))
1382        .map_err(|e| e.to_string());
1383      let _ = tx.send(TaskMsg::RefreshWorktrees(generation, result));
1384    });
1385  }
1386
1387  /// Spawn one background workspace re-list worker tagged with `generation`
1388  /// (issue #343 / #36). Mirrors [`Self::spawn_refresh`] for workspace mode:
1389  /// the owned `(repo_index, workdir)` targets are the only data crossing the
1390  /// boundary, and [`Self::list_workspace`] opens each repo + lists off-thread.
1391  /// The drain applies the merged rows via [`Self::apply_workspace_worktrees`].
1392  fn spawn_refresh_workspace(&self, generation: u64) {
1393    let tx = self.task_tx.clone();
1394    let targets = self.workspace_refresh_targets();
1395    std::thread::spawn(move || {
1396      let rows = Self::list_workspace(&targets);
1397      let _ = tx.send(TaskMsg::RefreshWorkspace(generation, rows));
1398    });
1399  }
1400
1401  /// Keep the details sidebar's git-backed preview off the render path (issue
1402  /// #343). Called once per event-loop tick (after `sync_active_repo`, so the
1403  /// active repo's `doctor.trunks` are correct in workspace mode). When the
1404  /// cached payload was NOT built for the current selection + mode — a cold
1405  /// cache, a navigation (`on_navigation` nulled it), a mode toggle, or a
1406  /// post-mutation `invalidate` — this spawns one worker to rebuild it, keyed
1407  /// to the *currently selected* worktree.
1408  ///
1409  /// Pure navigation deliberately does NOT [`TaskRunner::invalidate`] the
1410  /// `Sidebar` slot, so a held `j` coalesces onto the single in-flight worker
1411  /// instead of spawning a thread per row: the render shows the placeholder
1412  /// while scrolling and the settled selection is fetched once the burst ends.
1413  /// That coalescing IS the debounce — no timer needed. A worker whose
1414  /// selection has since moved stores a payload the render key-check ignores;
1415  /// the next tick requests the settled one.
1416  pub fn maybe_refresh_sidebar(&mut self) {
1417    // A hidden sidebar is not drawn (`draw_body` skips `draw_sidebar`), so
1418    // rebuilding its preview would run git work for an invisible panel —
1419    // restoring the pre-#343 behaviour where hiding the sidebar (`v`) did no
1420    // preview work at all. Opening it (`v`) re-arms the fetch on the next tick.
1421    if !self.sidebar.open {
1422      return;
1423    }
1424    let Some(w) = self.selected().cloned() else {
1425      return;
1426    };
1427    let mode = self.sidebar.mode;
1428    // Already authoritative for this selection + mode → nothing to rebuild.
1429    if matches!(&self.sidebar.cache, Some(((p, m), _)) if *p == w.path && *m == mode) {
1430      return;
1431    }
1432    let Some(generation) = self.tasks.request(TaskKind::Sidebar) else {
1433      // A rebuild is already in flight — coalesce onto it (the debounce).
1434      return;
1435    };
1436    let trunks = self.config.doctor.trunks.clone();
1437    let theme = self.theme;
1438    self.spawn_sidebar(generation, w, mode, trunks, theme);
1439  }
1440
1441  /// Spawn one background sidebar-rebuild worker tagged with `generation`
1442  /// (issue #343). Mirrors [`Self::spawn_refresh`]: only owned `Send` data
1443  /// crosses the boundary (the [`WorktreeInfo`], mode, the active repo's
1444  /// `trunks`, and the `Copy` [`Theme`]), and the worker runs
1445  /// [`crate::tui::ui::build_sidebar_payload`], which fires every sidebar git
1446  /// subprocess off-thread. A `send` failure (the `App`/receiver dropped) is
1447  /// ignored.
1448  fn spawn_sidebar(
1449    &self,
1450    generation: u64,
1451    w: WorktreeInfo,
1452    mode: crate::tui::state::sidebar::SidebarMode,
1453    trunks: Vec<String>,
1454    theme: crate::tui::theme::Theme,
1455  ) {
1456    let tx = self.task_tx.clone();
1457    std::thread::spawn(move || {
1458      let path = w.path.clone();
1459      let sections = crate::tui::ui::build_sidebar_payload(&w, mode, &trunks, &theme);
1460      let _ = tx.send(TaskMsg::Sidebar(generation, path, mode, sections));
1461    });
1462  }
1463
1464  /// Keep agent-session detection off the render path (issue #408). Called
1465  /// once per event-loop tick, next to [`Self::maybe_refresh_sidebar`]: a
1466  /// cold snapshot (startup, or nulled by a refresh) or one older than the
1467  /// re-detection period spawns one worker; a tick that finds a run already
1468  /// in flight coalesces onto it — same no-timer debounce as the sidebar.
1469  pub fn maybe_refresh_agent_sessions(&mut self) {
1470    const REDETECT_PERIOD: std::time::Duration = std::time::Duration::from_secs(30);
1471    let fresh = self.agent_snapshot_at.is_some_and(|at| at.elapsed() < REDETECT_PERIOD);
1472    if fresh {
1473      return;
1474    }
1475    let Some(generation) = self.tasks.request(TaskKind::AgentSessions) else {
1476      return; // detection already in flight — coalesce
1477    };
1478    let rows: Vec<(String, PathBuf)> = self
1479      .worktrees
1480      .iter()
1481      .map(|w| (crate::agent_sessions::path_display_key(&w.path), w.path.clone()))
1482      .collect();
1483    // Pin reads are branch-config I/O — in workspace mode one repo open
1484    // per row. That happens in the WORKER, not here (Codex review round P:
1485    // the event loop must not touch the disk on the periodic path); the
1486    // main thread only assembles (path, branch, owning repo dir) triples,
1487    // resolved via `row_repo` so each row reads its own repo (round I).
1488    let pin_sources = self.agent_pin_sources();
1489    let tx = self.task_tx.clone();
1490    std::thread::spawn(move || {
1491      let pins_map = read_pins_from_sources(&pin_sources);
1492      let pins: Vec<(String, String)> = pins_map
1493        .iter()
1494        .flat_map(|(path, sids)| sids.iter().map(move |sid| (path.clone(), sid.clone())))
1495        .collect();
1496      // Summary-only: the matched-per-worktree scan, NOT the full
1497      // foreign-dir sweep — that one is linear in the whole artefact
1498      // history and runs only when the attach prompt opens (round Q).
1499      let map = match crate::agent_sessions::agents_home() {
1500        Some(home) => crate::agent_sessions::detect_all(&home, &rows, &pins, std::time::SystemTime::now()),
1501        None => Default::default(), // no home: detection degrades to empty (FR-009)
1502      };
1503      let _ = tx.send(TaskMsg::AgentSessions(generation, map, None, pins_map));
1504    });
1505  }
1506
1507  /// Spawn the FULL detection — foreign-dir sweep included — to feed the
1508  /// attach prompt's candidate pool. Prompt-open only (round Q): the sweep
1509  /// costs a bounded read of every recent foreign artefact and must not
1510  /// ride the 30 s periodic tick. Drops a coalescing in-flight periodic
1511  /// run: this result supersedes it anyway.
1512  fn refresh_agent_pool(&mut self) {
1513    // A run in flight keeps walking the store even after `invalidate`
1514    // frees its slot — starting the full scan NOW would double the I/O.
1515    // Queue it instead; `apply_agent_snapshot` chains it on landing
1516    // (round R).
1517    if self.tasks.is_loading(TaskKind::AgentSessions) {
1518      self.agent_pool_wanted = true;
1519      return;
1520    }
1521    let Some(generation) = self.tasks.request(TaskKind::AgentSessions) else {
1522      return;
1523    };
1524    let rows: Vec<(String, PathBuf)> = self
1525      .worktrees
1526      .iter()
1527      .map(|w| (crate::agent_sessions::path_display_key(&w.path), w.path.clone()))
1528      .collect();
1529    let pin_sources = self.agent_pin_sources();
1530    let tx = self.task_tx.clone();
1531    std::thread::spawn(move || {
1532      let pins_map = read_pins_from_sources(&pin_sources);
1533      let pins: Vec<(String, String)> = pins_map
1534        .iter()
1535        .flat_map(|(path, sids)| sids.iter().map(move |sid| (path.clone(), sid.clone())))
1536        .collect();
1537      let (map, all) = match crate::agent_sessions::agents_home() {
1538        Some(home) => crate::agent_sessions::detect_with_sessions(&home, &rows, &pins, std::time::SystemTime::now()),
1539        None => Default::default(), // no home: detection degrades to empty (FR-009)
1540      };
1541      let _ = tx.send(TaskMsg::AgentSessions(generation, map, Some(all), pins_map));
1542    });
1543  }
1544
1545  /// The (worktree path, branch, owning repo workdir) triples the detection
1546  /// worker reads pins from — assembled here without touching the disk. In
1547  /// workspace mode the owner comes from the `row_repo` mapping; in
1548  /// single-repo mode every row belongs to the active repo.
1549  pub fn agent_pin_sources(&self) -> Vec<(String, String, PathBuf)> {
1550    self
1551      .worktrees
1552      .iter()
1553      .enumerate()
1554      .filter_map(|(i, w)| {
1555        let branch = crate::github::pinnable_branch(w.branch.as_deref())?;
1556        let repo_dir = if let Some(ws) = &self.workspace {
1557          ws.repos.get(*ws.row_repo.get(i)?)?.workdir.clone()
1558        } else {
1559          self.workdir.clone()
1560        };
1561        Some((
1562          crate::agent_sessions::path_display_key(&w.path),
1563          branch.to_string(),
1564          repo_dir,
1565        ))
1566      })
1567      .collect()
1568  }
1569
1570  /// Store a completed detection snapshot if its generation is still
1571  /// authoritative (issue #408). Returns `true` when applied; a late result
1572  /// superseded by [`TaskRunner::invalidate`] is dropped and the previous
1573  /// snapshot survives. Extracted from the drain so the state contract is
1574  /// pinned ratatui-free by `tests/tui_app_tests.rs`.
1575  pub fn apply_agent_snapshot(
1576    &mut self,
1577    generation: u64,
1578    map: std::collections::BTreeMap<String, crate::agent_sessions::WorktreeAgents>,
1579    all: Option<Vec<crate::agent_sessions::AgentSession>>,
1580    pins: std::collections::BTreeMap<String, Vec<String>>,
1581  ) -> bool {
1582    if !self.tasks.complete(TaskKind::AgentSessions, generation) {
1583      return false;
1584    }
1585    self.agent_snapshot = Some(map);
1586    self.agent_snapshot_at = Some(std::time::Instant::now());
1587    // `None` = summary-only run: the previous pool survives so an open
1588    // attach prompt keeps its candidates (round Q).
1589    let landed_pool = all.is_some();
1590    if let Some(all) = all {
1591      self.agent_all_sessions = all;
1592    }
1593    // A pool scan queued while this run was in flight chains now that the
1594    // slot is free (round R); a landing that already carried the pool
1595    // satisfies the request outright, and a prompt closed in the meantime
1596    // abandons it — nobody would consume the sweep (round T).
1597    if self.agent_pool_wanted {
1598      self.agent_pool_wanted = false;
1599      let prompt_open = self.view == View::DetailOverlay
1600        && self.detail_overlay.mode == crate::tui::state::detail_overlay::DetailMode::Input;
1601      if !landed_pool && prompt_open {
1602        self.refresh_agent_pool();
1603      }
1604    }
1605    // The worker read the pins from each row's owning repo (round P);
1606    // store them before the overlay rebuild below reads the map — UNLESS
1607    // a pin changed while this run was in flight: its map predates the
1608    // change, so the fresh event-path read stands and a re-detection is
1609    // chained by clearing the snapshot timestamp (round U).
1610    if self.agent_redetect_wanted {
1611      self.agent_redetect_wanted = false;
1612      self.agent_snapshot_at = None;
1613    } else {
1614      self.agent_pins = pins;
1615    }
1616    // A landing detection refreshes the open overlay in place (user
1617    // feedback: attach/detach used to leave stale rows until reopened).
1618    // Gated on the AGENTS consumer (Codex review #455): a stale target
1619    // left by an interrupted agents overlay must never rebuild the CI
1620    // checks rows into session rows under an unchanged CiChecks kind.
1621    if self.view == View::DetailOverlay
1622      && self.detail_overlay.kind == crate::tui::state::detail_overlay::DetailKind::Agents
1623    {
1624      if let Some((path, _)) = self.detail_overlay_target.clone() {
1625        if let Some(w) = self.worktrees.iter().find(|w| w.path == path).cloned() {
1626          let rows = self.build_agent_rows(&w);
1627          self.detail_overlay.set_rows(rows);
1628        }
1629      }
1630    }
1631    true
1632  }
1633
1634  /// The agent sessions matched to `w`, if a snapshot has landed and holds
1635  /// any (issue #408). Pure lookup — the render path's only entry point.
1636  pub fn agents_for(&self, w: &crate::worktree::WorktreeInfo) -> Option<&crate::agent_sessions::WorktreeAgents> {
1637    self
1638      .agent_snapshot
1639      .as_ref()
1640      .and_then(|map| map.get(&crate::agent_sessions::path_display_key(&w.path)))
1641  }
1642
1643  /// Any session in the landed snapshot at all? Drives the table's AGENT
1644  /// column visibility (Codex review round D): with no agent tooling the
1645  /// table must stay visually pre-#408, not carry an empty 8-cell column
1646  /// squeezing NAME/BRANCH/PATH on narrow terminals. Keyed to the whole
1647  /// snapshot — not the visible rows — so filtering/scrolling never makes
1648  /// the column flicker.
1649  pub fn any_agent_sessions(&self) -> bool {
1650    self
1651      .agent_snapshot
1652      .as_ref()
1653      .is_some_and(|map| map.values().any(|a| !a.sessions.is_empty()))
1654  }
1655
1656  /// Off-thread `gwm sync` of the selected worktree for the `S` key (issue
1657  /// #258): fetch + rebase its branch onto upstream on a worker thread, so a
1658  /// slow network fetch / rebase does not freeze the event loop. Coalesces
1659  /// onto an in-flight sync (a second `S` while one runs is a no-op, so two
1660  /// rebases never race). The outcome is applied by
1661  /// [`Self::drain_task_results`], which reports it and refreshes the list so
1662  /// the new ahead/behind state shows. Default strategy is rebase (the repo
1663  /// convention); a `--merge` variant is deferred (see #258).
1664  pub fn request_sync(&mut self) {
1665    let Some((path, name)) = self.selected().map(|w| (w.path.clone(), w.name.clone())) else {
1666      self.status = "no worktree selected to sync".into();
1667      return;
1668    };
1669    if self.tasks.has_mutating_task_in_flight() && !self.tasks.is_loading(TaskKind::Sync) {
1670      self.status = self.busy_mutation_status("syncing");
1671      return;
1672    }
1673    let Some(generation) = self.tasks.request(TaskKind::Sync) else {
1674      // A sync is already in flight — coalesce onto it.
1675      return;
1676    };
1677    self.spinner.reset();
1678    self.status = TaskKind::Sync.loading_label().into();
1679    self.spawn_sync(generation, path, name);
1680  }
1681
1682  /// Spawn one background `gwm sync` worker tagged with `generation` (issue
1683  /// #258). Mirrors [`Self::spawn_refresh`]: it moves only owned `Send` data
1684  /// (the worktree `path` + `name`) across the boundary and runs the existing
1685  /// [`crate::sync::sync`] logic, which discovers its own repo from `path` and
1686  /// shells out to `git` for fetch/rebase. A `send` failure (the `App`/receiver
1687  /// dropped) is ignored.
1688  fn spawn_sync(&self, generation: u64, path: PathBuf, name: String) {
1689    let tx = self.task_tx.clone();
1690    std::thread::spawn(move || {
1691      let result = crate::sync::sync(&path, crate::sync::SyncStrategy::Rebase).map_err(|e| e.to_string());
1692      let _ = tx.send(TaskMsg::Sync(generation, name, result));
1693    });
1694  }
1695
1696  /// Apply every background task result that has arrived since the last
1697  /// call (issue #231; GitHub fetch results folded in by #255), draining
1698  /// the channel without blocking. Each result goes through
1699  /// [`TaskRunner::complete`], so a result whose per-key generation was
1700  /// bumped mid-flight is dropped (#138 guard, generalised) — this is what
1701  /// makes a stale GitHub worker lose to a fresh one in the retry race.
1702  ///
1703  /// A failed refresh surfaces on the status bar and leaves the list
1704  /// intact — what used to be a fatal `refresh()?` that tore down the
1705  /// event loop is now a graceful message. A GitHub result is stamped into
1706  /// the per-key cache via `complete_{issue,pr}` (pure writes now that the
1707  /// drop decision lives on the spine); once nothing GitHub-side is left
1708  /// loading, the aggregate outcome is re-reported on the status bar — the
1709  /// same end state `drain_github_results` produced pre-#255. Returns `true`
1710  /// if at least one result was applied, so the loop can force a redraw.
1711  pub fn drain_task_results(&mut self) -> bool {
1712    let mut applied = false;
1713    let mut github_applied = false;
1714    let mut refresh_applied = false;
1715    while let Ok(msg) = self.task_rx.try_recv() {
1716      match msg {
1717        TaskMsg::CreateWorktree(generation, result) => {
1718          if !self.tasks.complete(TaskKind::CreateWorktree, generation) {
1719            // Late result — a newer run (or an invalidate) superseded it.
1720            continue;
1721          }
1722          match result {
1723            Ok(result) => {
1724              self.create_failure = None;
1725              self.report = Some(result.report);
1726              self.view = View::Report;
1727              let refresh_result = self.refresh();
1728              self.status = match refresh_result {
1729                Ok(()) => format!("created {} @ {}", result.branch, result.created.display()),
1730                Err(e) => format!(
1731                  "created {} @ {}; refresh failed: {}",
1732                  result.branch,
1733                  result.created.display(),
1734                  e
1735                ),
1736              };
1737            }
1738            Err(e) => {
1739              self.create_failure = Some(e.clone());
1740              self.view = View::Create;
1741              self.status = format!("create failed: {}", e);
1742            }
1743          }
1744          applied = true;
1745          // Create owns the status line this tick.
1746          refresh_applied = true;
1747        }
1748        TaskMsg::RefreshWorktrees(generation, result) => {
1749          if !self.tasks.complete(TaskKind::RefreshWorktrees, generation) {
1750            // Late result — a newer run (or an invalidate) superseded it.
1751            continue;
1752          }
1753          match result {
1754            Ok(worktrees) => self.apply_refreshed_worktrees(worktrees),
1755            Err(e) => self.status = format!("refresh failed: {}", e),
1756          }
1757          applied = true;
1758          refresh_applied = true;
1759        }
1760        TaskMsg::RefreshWorkspace(generation, rows) => {
1761          if !self.tasks.complete(TaskKind::RefreshWorkspace, generation) {
1762            // Late result — a newer run (or a synchronous `refresh`) superseded it.
1763            continue;
1764          }
1765          self.apply_workspace_worktrees(rows);
1766          applied = true;
1767          refresh_applied = true;
1768        }
1769        TaskMsg::GithubIssue(generation, number, result) => {
1770          // Generation guard: a stale worker whose slot was bumped by an
1771          // intervening invalidate/re-request is dropped here, before it can
1772          // stamp the cache (the Codex-flagged race, fixed by the spine).
1773          if !self.tasks.complete(TaskKind::GithubIssue(number), generation) {
1774            continue;
1775          }
1776          if let Ok(status) = &result {
1777            self.persist_loaded_issue_title(status);
1778          }
1779          self.github.complete_issue(number, result);
1780          applied = true;
1781          github_applied = true;
1782        }
1783        TaskMsg::GithubPr(generation, number, result) => {
1784          if !self.tasks.complete(TaskKind::GithubPr(number), generation) {
1785            continue;
1786          }
1787          if let Ok(status) = &result {
1788            self.persist_loaded_pr_title(status);
1789            if self.refresh_ci_overlay_on_pr_landing(status) {
1790              // The overlay-close message owns the status line this tick.
1791              refresh_applied = true;
1792            }
1793          }
1794          self.github.complete_pr(number, result);
1795          applied = true;
1796          github_applied = true;
1797        }
1798        TaskMsg::Sync(generation, name, result) => {
1799          if !self.tasks.complete(TaskKind::Sync, generation) {
1800            // Late result — a newer sync (or an invalidate) superseded it.
1801            continue;
1802          }
1803          match result {
1804            Ok(report) => {
1805              // Re-list so the new ahead/behind state shows (this also bumps
1806              // the refresh generation — the #138 race guard). The worker
1807              // mutated refs in a subprocess, but a libgit2 read re-reads them
1808              // from disk, so the synchronous `self.refresh()` (`self.repo`)
1809              // sees the rebased state — verified end-to-end by the
1810              // ahead/behind assertion in `sync_tests::
1811              // tui_sync_action_relists_to_the_rebased_state_from_disk`.
1812              // `refresh` sets its own "refreshed — N" status, so overwrite it
1813              // with the sync outcome afterwards — the user pressed `S`, the
1814              // sync result is what they want to read.
1815              let _ = self.refresh();
1816              self.status = crate::cli::format_sync_report(&name, &report).trim_end().to_string();
1817            }
1818            Err(e) => self.status = format!("sync failed: {}", e),
1819          }
1820          applied = true;
1821          // The sync owns the status line this tick — keep the post-loop
1822          // GitHub report from overwriting it (same guard the refresh uses).
1823          refresh_applied = true;
1824        }
1825        TaskMsg::Bootstrap(generation, result) => {
1826          if !self.tasks.complete(TaskKind::Bootstrap, generation) {
1827            // Late result — a newer run (or an invalidate) superseded it, so
1828            // it must not flip the view to a stale report.
1829            continue;
1830          }
1831          match result {
1832            Ok(report) => {
1833              // Same outcome as the old synchronous path (issue #256): show
1834              // the report and surface whether any step failed.
1835              let any_failed = report.steps.iter().any(|s| s.status == StepStatus::Failed);
1836              self.report = Some(report);
1837              self.view = View::Report;
1838              self.status = if any_failed {
1839                "bootstrap had failures".into()
1840              } else {
1841                "bootstrap ok".into()
1842              };
1843            }
1844            Err(e) => self.status = format!("bootstrap error: {}", e),
1845          }
1846          applied = true;
1847          // The bootstrap owns the status line (and the view) this tick — keep
1848          // the post-loop GitHub report from overwriting it (same guard the
1849          // refresh / sync arms use).
1850          refresh_applied = true;
1851        }
1852        TaskMsg::DeleteWorktree(generation, name, label, result) => {
1853          if !self.tasks.complete(TaskKind::DeleteWorktree, generation) {
1854            // Late result — a newer run (or an invalidate) superseded it.
1855            continue;
1856          }
1857          match result {
1858            Ok(()) => {
1859              self.delete_failure = None;
1860              self.view = View::List;
1861              self.confirm.reset();
1862              let refresh_result = self.refresh();
1863              self.status = match refresh_result {
1864                Ok(()) => format!("removed {} ({})", name, label),
1865                Err(e) => format!("removed {} ({}); refresh failed: {}", name, label, e),
1866              };
1867            }
1868            Err(e) => {
1869              self.delete_failure = Some(e.clone());
1870              self.view = View::Confirm;
1871              self.status = format!("delete failed: {}", e);
1872            }
1873          }
1874          applied = true;
1875          // Delete owns the status line this tick.
1876          refresh_applied = true;
1877        }
1878        TaskMsg::Pull(generation, name, result) => {
1879          if !self.tasks.complete(TaskKind::Pull, generation) {
1880            continue;
1881          }
1882          // Refresh on both arms: a failed pull can still mutate the tree (a
1883          // merge/rebase conflict leaves it dirty / mid-rebase), so the table
1884          // must not keep showing the pre-pull clean state (Codex review #292).
1885          let _ = self.refresh();
1886          match result {
1887            Ok(msg) => self.status = format!("pulled {}: {}", name, msg),
1888            Err(e) => self.status = format!("pull failed: {}", e),
1889          }
1890          applied = true;
1891          refresh_applied = true;
1892        }
1893        TaskMsg::Push(generation, name, result) => {
1894          if !self.tasks.complete(TaskKind::Push, generation) {
1895            continue;
1896          }
1897          match result {
1898            Ok(msg) => {
1899              // Pushing updates the remote-tracking ref + ahead/behind, so
1900              // refresh the table before overwriting the status, mirroring
1901              // the pull/sync path (Codex review on PR #292).
1902              let _ = self.refresh();
1903              self.status = format!("pushed {}: {}", name, msg);
1904            }
1905            Err(e) => self.status = format!("push failed: {}", e),
1906          }
1907          applied = true;
1908          refresh_applied = true;
1909        }
1910        TaskMsg::EditWorktree(generation, result) => {
1911          if !self.tasks.complete(TaskKind::EditWorktree, generation) {
1912            continue;
1913          }
1914          match result {
1915            Ok(res) => {
1916              let _ = self.refresh();
1917              self.status = if res.remote_renamed {
1918                format!("renamed to {} (local + remote)", res.new_branch)
1919              } else {
1920                format!("renamed to {} (local only)", res.new_branch)
1921              };
1922              // Re-select the renamed worktree by its new path so the cursor
1923              // stays on the row the user just edited (mapped through the
1924              // filter — Codex review on PR #292).
1925              self.reselect_by_path(&res.new_path);
1926              self.edit_original_branch = None;
1927              self.edit_original_path = None;
1928              self.edit_failure = None;
1929              self.create_form.reset();
1930              self.view = View::List;
1931            }
1932            // Keep the modal open so the user can fix the form and retry, and
1933            // replace the "renaming worktree…" loading status so the bar no
1934            // longer reads as in-progress (Codex review on PR #292, P3).
1935            Err(e) => {
1936              self.status = format!("rename failed: {}", e);
1937              self.edit_failure = Some(e);
1938            }
1939          }
1940          applied = true;
1941          refresh_applied = true;
1942        }
1943        TaskMsg::Sidebar(generation, path, mode, sections) => {
1944          // Late result — the selection moved and `refresh` bumped the slot's
1945          // generation (a mutation invalidated a pre-mutation rebuild), so this
1946          // payload is stale. Drop it; the next tick requests the current one.
1947          if !self.tasks.complete(TaskKind::Sidebar, generation) {
1948            continue;
1949          }
1950          // Store keyed by the worktree + mode it was built for. If the
1951          // selection has since moved this key won't match the current one, so
1952          // the render shows the placeholder and `maybe_refresh_sidebar` fetches
1953          // the settled selection next tick — no stale worktree's git preview
1954          // is ever shown under the live header.
1955          self.sidebar.cache = Some(((path, mode), sections));
1956          applied = true;
1957        }
1958        TaskMsg::AgentSessions(generation, map, all, pins) => {
1959          // Late-drop + store live in `apply_agent_snapshot` so the state
1960          // contract is pinned ratatui-free (issue #408). Deliberately does
1961          // NOT set `applied`: agent detection reads no git state, so there
1962          // is nothing for the post-drain refresh bookkeeping to do.
1963          self.apply_agent_snapshot(generation, map, all, pins);
1964        }
1965      }
1966    }
1967    // Once nothing GitHub-side is left loading, swap the "fetching…"
1968    // placeholder for the real outcome (refreshed / partial failure /
1969    // failure) — only when a GitHub result actually applied, so a dropped
1970    // stale result never overwrites the current status (issue #217 review P2).
1971    //
1972    // Skip it when a worktree refresh also landed this tick: pre-#255 the
1973    // event loop drained the GitHub channel *before* the task channel, so a
1974    // simultaneous completion left `apply_refreshed_worktrees`' "refreshed —
1975    // N" message standing last. The `!refresh_applied` guard preserves that
1976    // ordering now that both drain in one pass.
1977    if github_applied && !refresh_applied && !self.is_github_loading() {
1978      self.report_github_refresh_status();
1979    }
1980    applied
1981  }
1982
1983  /// `true` while any background task is in flight (issue #231) — drives
1984  /// the statusbar spinner alongside [`Self::is_github_loading`].
1985  pub fn is_task_loading(&self) -> bool {
1986    self.tasks.is_any_loading()
1987  }
1988
1989  /// `true` while the create-worktree worker is in flight (issue #276).
1990  pub fn is_create_worktree_loading(&self) -> bool {
1991    self.tasks.is_loading(TaskKind::CreateWorktree)
1992  }
1993
1994  /// `true` while the delete-worktree worker is in flight (issue #257).
1995  pub fn is_delete_worktree_loading(&self) -> bool {
1996    self.tasks.is_loading(TaskKind::DeleteWorktree)
1997  }
1998
1999  /// `true` when a requested quit can safely leave the event loop now.
2000  /// Mutating spine workers keep running until their result is drained so
2001  /// `sync` / `bootstrap` / delete-worktree are not abandoned mid-operation.
2002  pub fn can_quit_now(&self) -> bool {
2003    !self.should_quit || !self.tasks.has_mutating_task_in_flight()
2004  }
2005
2006  /// Surface why a requested quit is being held. The event loop keeps
2007  /// ticking/draining while this status is visible.
2008  pub fn defer_quit_for_mutating_task(&mut self) {
2009    if let Some(label) = self.tasks.mutating_loading_label() {
2010      self.status = format!("finishing {} before quit…", label.trim_end_matches('…'));
2011    } else {
2012      self.status = "finishing task before quit…".into();
2013    }
2014  }
2015
2016  /// A clone of the task channel sender background workers report over
2017  /// (issue #231; GitHub fetch workers too since #255). Exposed so the
2018  /// async-apply path ([`Self::drain_task_results`]) can be driven
2019  /// deterministically in tests — inject a [`TaskMsg`] exactly as a worker
2020  /// would, then drain — without spawning an OS thread or a real `gh`.
2021  pub fn task_result_sender(&self) -> mpsc::Sender<TaskMsg> {
2022    self.task_tx.clone()
2023  }
2024
2025  /// Drop the cached sidebar content. Call on any change that may have altered
2026  /// what the sidebar shows: worktree list refresh, filter narrowing, etc.
2027  /// Pure delegate over [`SidebarState::invalidate`]; navigation-driven
2028  /// invalidation goes through [`Self::on_navigation`] which also resets
2029  /// the scroll offset.
2030  pub fn invalidate_sidebar_cache(&mut self) {
2031    self.sidebar.invalidate();
2032  }
2033
2034  /// Selection-change reaction: drop the sidebar's scroll back to the
2035  /// top, invalidate its cached preview, and resolve the link cache
2036  /// against the freshly selected worktree. Collapses the verbatim
2037  /// `sidebar.scroll = 0; invalidate_sidebar_cache(); refresh_link();`
2038  /// triple that was repeated across `next`, `prev`, `first`, `last`
2039  /// pre-extraction (issue #127, part of #102). The first two pieces
2040  /// live on [`SidebarState::on_navigation`]; the link refresh is
2041  /// orchestrator-shaped (it touches `self.link` / `self.link_slug` /
2042  /// `self.issue_state` / `self.pr_state` via [`Self::refresh_link`])
2043  /// so it stays here. Every navigation entry point now goes through
2044  /// this single call so the triple cannot drift back into duplicated
2045  /// literals.
2046  pub fn on_navigation(&mut self) {
2047    self.sidebar.on_navigation();
2048    self.refresh_link();
2049  }
2050
2051  pub fn next(&mut self) {
2052    // Route navigation to the sidebar when it's focused; otherwise move the list.
2053    if self.sidebar.open && self.sidebar.focused {
2054      self.sidebar_scroll_down();
2055      return;
2056    }
2057    let len = self.filtered_indices().len();
2058    if len == 0 {
2059      return;
2060    }
2061    let i = match self.list_state.selected() {
2062      Some(i) => (i + 1) % len,
2063      None => 0,
2064    };
2065    self.list_state.select(Some(i));
2066    self.on_navigation();
2067  }
2068
2069  pub fn prev(&mut self) {
2070    if self.sidebar.open && self.sidebar.focused {
2071      self.sidebar_scroll_up();
2072      return;
2073    }
2074    let len = self.filtered_indices().len();
2075    if len == 0 {
2076      return;
2077    }
2078    let i = match self.list_state.selected() {
2079      Some(0) | None => len - 1,
2080      Some(i) => i - 1,
2081    };
2082    self.list_state.select(Some(i));
2083    self.on_navigation();
2084  }
2085
2086  // ---- Vim-style motions / list jumps -------------------------------------
2087
2088  pub fn first(&mut self) {
2089    let len = self.filtered_indices().len();
2090    if len > 0 {
2091      self.list_state.select(Some(0));
2092      self.on_navigation();
2093    }
2094  }
2095
2096  pub fn last(&mut self) {
2097    let len = self.filtered_indices().len();
2098    if len > 0 {
2099      self.list_state.select(Some(len - 1));
2100      self.on_navigation();
2101    }
2102  }
2103
2104  /// Drive the two-keystroke `gg` motion. First press arms it, second jumps to top.
2105  ///
2106  /// **Compatibility shim** — kept so the existing tests in
2107  /// `tests/tui_app_tests.rs::handle_g_motion_tracks_pending_then_jumps_to_first`
2108  /// and the not-yet-migrated event-loop branch keep working
2109  /// verbatim. The implementation routes through
2110  /// [`Self::dispatch_key`] so the legacy and generic paths cannot
2111  /// drift on the chord semantics.
2112  pub fn handle_g(&mut self) {
2113    let ev = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty());
2114    if let Some(Action::Top) = self.dispatch_key(ev) {
2115      self.first();
2116    }
2117  }
2118
2119  /// Drop any in-flight chord prefix. Called by the legacy event-loop
2120  /// branch on any non-`g` keystroke (pre-#87 contract). New call
2121  /// sites that route through [`Self::dispatch_key`] don't need it —
2122  /// `dispatch_key` already clears the buffer on `NoMatch`.
2123  pub fn cancel_pending_motion(&mut self) {
2124    self.pending_chord.clear();
2125    self.sync_legacy_pending_flag();
2126  }
2127
2128  /// True iff no chord prefix is currently armed. Surface for tests
2129  /// and for the help / status-bar code that may want to show a
2130  /// "waiting for next key" hint once chord support is wired up.
2131  pub fn pending_chord_is_empty(&self) -> bool {
2132    self.pending_chord.is_empty()
2133  }
2134
2135  /// Drive a raw `KeyEvent` through the keymap.
2136  ///
2137  /// Returns `Some(action)` when the buffer (current pending chord +
2138  /// this stroke) matches a binding — caller fires the action and the
2139  /// buffer is left cleared. Returns `None` when the buffer is now a
2140  /// strict prefix of a longer binding (caller waits for the next
2141  /// keystroke) **or** when the stroke matches nothing at all
2142  /// (caller drops it).
2143  ///
2144  /// Vim-style fallback: if appending the stroke to a non-empty
2145  /// buffer produces a `NoMatch`, the buffer is cleared and the
2146  /// stroke is re-tried on its own. This mirrors the historical
2147  /// `g j` behaviour where the stray `g` is forgotten and `j`
2148  /// still navigates down.
2149  pub fn dispatch_key(&mut self, key: KeyEvent) -> Option<Action> {
2150    let stroke = KeyStroke::from_event(&key);
2151    let mut tentative = self.pending_chord.clone();
2152    tentative.push(stroke.clone());
2153
2154    let outcome = match self.keymap.lookup(&tentative) {
2155      ChordResolution::Matched(action) => {
2156        self.pending_chord.clear();
2157        Some(action)
2158      }
2159      ChordResolution::PendingPrefix => {
2160        self.pending_chord = tentative;
2161        None
2162      }
2163      ChordResolution::NoMatch if self.pending_chord.is_empty() => {
2164        // Single stroke, no binding. Nothing to retry.
2165        None
2166      }
2167      ChordResolution::NoMatch => {
2168        // Mismatched continuation. Drop the in-flight prefix and
2169        // retry the new stroke on its own so the user's keypress
2170        // is not silently swallowed when it has a single-key
2171        // binding (the `g j` case).
2172        self.pending_chord.clear();
2173        let single = vec![stroke];
2174        match self.keymap.lookup(&single) {
2175          ChordResolution::Matched(action) => Some(action),
2176          ChordResolution::PendingPrefix => {
2177            self.pending_chord = single;
2178            None
2179          }
2180          ChordResolution::NoMatch => None,
2181        }
2182      }
2183    };
2184
2185    self.sync_legacy_pending_flag();
2186    outcome
2187  }
2188
2189  pub fn key_matches_action(&self, key: KeyEvent, action: Action) -> bool {
2190    matches!(
2191      self.keymap.lookup(&[KeyStroke::from_event(&key)]),
2192      ChordResolution::Matched(found) if found == action
2193    )
2194  }
2195
2196  /// Resolve a keystroke against the contextual modal keymap (issue #219).
2197  /// Returns the [`ModalAction`] bound to `key` in `ctx`, or `None` when
2198  /// nothing in that context binds it — the modal routing then applies its
2199  /// text-input / default fallback (digits, free-text, sub-state guards).
2200  pub fn resolve_modal(&self, ctx: KeyContext, key: KeyEvent) -> Option<ModalAction> {
2201    self.modal_keymap.resolve(ctx, &KeyStroke::from_event(&key))
2202  }
2203
2204  /// Mirror the new `pending_chord` buffer into the legacy
2205  /// `pending_g` boolean so pre-#87 tests that read it as a field
2206  /// stay green. Removed when those tests migrate to
2207  /// [`Self::pending_chord_is_empty`].
2208  fn sync_legacy_pending_flag(&mut self) {
2209    let g = KeyStroke::new(KeyCode::Char('g'), KeyModifiers::empty());
2210    self.pending_g = self.pending_chord.len() == 1 && self.pending_chord[0] == g;
2211  }
2212
2213  // ---- Command palette (issue #32) ----------------------------------------
2214
2215  /// Open the command palette overlay. Transitions the active view
2216  /// to `View::CommandPalette` and arms the pure state machine on
2217  /// `self.palette` with a fresh empty buffer. Status bar shows a
2218  /// short hint so the user knows what to type.
2219  /// Typing route of the palette, resolved BEFORE the modal context
2220  /// (Codex review #456): during text input the printable keys and
2221  /// Backspace are reserved for typing — a rebind like
2222  /// `palette.close = ["x"]` must not close the palette mid-word. Returns
2223  /// `true` when the key was consumed as input; charset keys type, other
2224  /// printable characters are swallowed (no palette entry could match
2225  /// them), Ctrl-modified keys fall through to the modal resolution.
2226  pub fn palette_input_key(&mut self, key: KeyEvent) -> bool {
2227    use crossterm::event::KeyModifiers as Mods;
2228    // Ctrl/Alt-modified strokes are bindable and must stay reachable
2229    // (Codex review #456) — only unmodified legitimate input is reserved.
2230    if key.modifiers.intersects(Mods::CONTROL | Mods::ALT) {
2231      return false;
2232    }
2233    // Kitty-style terminals report an uppercase as Char + SHIFT (the case
2234    // KeyStroke::from_event normalises): that is an uppercase letter, not
2235    // the palette's lowercase input — a binding on "X" must stay
2236    // reachable on every terminal encoding (Codex review #456, it. 10).
2237    if key.modifiers.contains(Mods::SHIFT) && matches!(key.code, KeyCode::Char(c) if c.is_ascii_alphabetic()) {
2238      return false;
2239    }
2240    if key.code == KeyCode::Backspace {
2241      self.palette_pop_char();
2242      return true;
2243    }
2244    match key.code {
2245      KeyCode::Char(c) if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-' => {
2246        self.palette_push_char(c);
2247        true
2248      }
2249      // Characters outside the filter charset fall through to the modal
2250      // resolution — a rebind like `close = ["?"]` stays honoured (#293
2251      // contract); unresolved ones are dropped by the dispatch fallback.
2252      _ => false,
2253    }
2254  }
2255
2256  /// Fallback after an EMPTY modal resolution in the palette (Codex
2257  /// review #456, iterations 10/14): an unresolved modified charset
2258  /// printable is still typing (AltGr/Option characters arrive as
2259  /// Char + ALT on some keyboards), and an unresolved modified Backspace
2260  /// still erases (parity with the pre-#456 routing).
2261  pub fn palette_unresolved_fallback(&mut self, key: KeyEvent) {
2262    match key.code {
2263      KeyCode::Char(c) if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-' => {
2264        self.palette_push_char(c);
2265      }
2266      KeyCode::Backspace => self.palette_pop_char(),
2267      _ => {}
2268    }
2269  }
2270
2271  /// Typing route of the Settings value editor — same reserved-typing
2272  /// contract as [`Self::palette_input_key`] (Codex review #456). A no-op
2273  /// (`false`) when no edit is live.
2274  pub fn settings_edit_input_key(&mut self, key: KeyEvent) -> bool {
2275    use crossterm::event::KeyModifiers as Mods;
2276    if self.config_panel.editing.is_none() {
2277      return false;
2278    }
2279    if key.modifiers.intersects(Mods::CONTROL | Mods::ALT) {
2280      return false;
2281    }
2282    if key.code == KeyCode::Backspace {
2283      self.config_panel.pop_edit_char();
2284      return true;
2285    }
2286    match key.code {
2287      // A character the field refuses (a non-digit on a numeric field) is
2288      // NOT typing — it falls through so a rebound verb on it still fires
2289      // (Codex review #456, iteration 11).
2290      KeyCode::Char(c) => self.config_panel.push_edit_char(c),
2291      _ => false,
2292    }
2293  }
2294
2295  /// The whole Settings-editor key route (Codex review #456, iteration
2296  /// 10) — one testable method: reserved typing first, then the modal
2297  /// resolution, and an unresolved printable is REINJECTED as typing
2298  /// (AltGr/Option characters arrive Char + ALT on some keyboards; they
2299  /// are not reserved so a bound Alt+x stays reachable, but when nothing
2300  /// resolves they are what the user typed).
2301  pub fn handle_settings_edit_key(&mut self, key: KeyEvent) {
2302    if self.settings_edit_input_key(key) {
2303      return;
2304    }
2305    match self.resolve_modal(KeyContext::ConfigEdit, key) {
2306      Some(ModalAction::ConfigEditSubmit) => self.commit_settings_edit(),
2307      Some(ModalAction::ConfigEditCancel) => self.config_panel.cancel_edit(),
2308      _ => match key.code {
2309        // Best-effort reinjection; a character the field refuses (an
2310        // AltGr symbol on a numeric field) is simply dropped.
2311        KeyCode::Char(c) => {
2312          let _ = self.config_panel.push_edit_char(c);
2313        }
2314        // An UNBOUND Alt/Ctrl+Backspace still erases (iteration 14 —
2315        // parity with the pre-#456 routing, which erased on every
2316        // KeyCode::Backspace).
2317        KeyCode::Backspace => self.config_panel.pop_edit_char(),
2318        _ => {}
2319      },
2320    }
2321  }
2322
2323  pub fn open_command_palette(&mut self) {
2324    self.palette.open();
2325    self.view = View::CommandPalette;
2326    self.status = "command palette — type, Enter to run, Esc to cancel".into();
2327  }
2328
2329  /// Close the palette without firing anything. Called on `Esc` from
2330  /// inside the overlay. Returns the view to `View::List` and drops
2331  /// the buffer.
2332  pub fn close_command_palette(&mut self) {
2333    self.palette.close();
2334    self.view = View::List;
2335    self.status = "palette cancelled".into();
2336  }
2337
2338  /// Append a character to the palette input buffer. The pure state
2339  /// machine re-runs its fuzzy match and resets the highlight to 0.
2340  pub fn palette_push_char(&mut self, c: char) {
2341    self.palette.push_char(c);
2342  }
2343
2344  /// Remove the trailing character from the palette input buffer.
2345  pub fn palette_pop_char(&mut self) {
2346    self.palette.pop_char();
2347  }
2348
2349  /// Move the palette highlight one row down (wraps at the end).
2350  pub fn palette_cycle_down(&mut self) {
2351    self.palette.cycle_highlight_down();
2352  }
2353
2354  /// Move the palette highlight one row up (wraps at the start).
2355  pub fn palette_cycle_up(&mut self) {
2356    self.palette.cycle_highlight_up();
2357  }
2358
2359  /// Accept the highlighted entry. Returns the resolved `Action` and
2360  /// drops the palette overlay; the caller (event loop) routes the
2361  /// action through the same dispatcher branch as a keystroke so
2362  /// palette + key fire identical side effects.
2363  ///
2364  /// When the input buffer matches nothing the palette stays open
2365  /// and `None` is returned — the user can backspace and retry
2366  /// without losing context.
2367  pub fn accept_command_palette(&mut self) -> Option<Action> {
2368    let action = self.palette.accept()?;
2369    self.view = View::List;
2370    self.status = format!("palette: {}", action.slug());
2371    Some(action)
2372  }
2373
2374  // ---- Sidebar ------------------------------------------------------------
2375
2376  pub fn toggle_sidebar(&mut self) {
2377    self.sidebar.toggle_open();
2378    self.status = if self.sidebar.open {
2379      "sidebar shown".into()
2380    } else {
2381      "sidebar hidden".into()
2382    };
2383  }
2384
2385  /// Cycle the sidebar preview mode between Commits and Stashes
2386  /// (issue #34). Drives the pure-state cycle on `SidebarState`
2387  /// plus the status-bar copy: orchestrator-shaped because the
2388  /// status bar is owned by `App`, not by the sub-struct.
2389  pub fn cycle_sidebar_mode(&mut self) {
2390    self.sidebar.cycle_mode();
2391    self.status = format!("sidebar: {}", self.sidebar.mode.label());
2392  }
2393
2394  /// Cycle the sidebar orientation `auto → side-by-side → stacked`
2395  /// (issue #188). Orchestrator-shaped for the status-bar copy, like
2396  /// [`Self::cycle_sidebar_mode`].
2397  pub fn cycle_sidebar_layout(&mut self) {
2398    self.sidebar.cycle_orientation();
2399    self.status = format!("sidebar layout: {}", self.sidebar.orientation.label());
2400  }
2401
2402  /// Flip the side-by-side sidebar position left ↔ right (issue #188).
2403  pub fn toggle_sidebar_position(&mut self) {
2404    self.sidebar.toggle_position();
2405    self.status = format!("sidebar position: {}", self.sidebar.position.label());
2406  }
2407
2408  pub fn toggle_focus(&mut self) {
2409    self.sidebar.toggle_focus();
2410  }
2411
2412  /// Direct-focus the worktree table (issue #217, `1`). Orchestrator-shaped
2413  /// for the status-bar copy, like the sidebar toggles.
2414  pub fn focus_worktrees(&mut self) {
2415    self.sidebar.focus_table();
2416    self.status = "focus: worktrees".into();
2417  }
2418
2419  /// Direct-focus the status (sidebar) pane (issue #217, `2`). Opens the
2420  /// sidebar if needed and moves focus onto it.
2421  pub fn focus_status(&mut self) {
2422    self.sidebar.focus_panel();
2423    self.status = "focus: status".into();
2424  }
2425
2426  /// The live UI context driving the statusbar chip + help subtitle (issue
2427  /// #217). An open modal / overlay wins over the pane focus (issue #217
2428  /// review P2): when the create form is up, the statusbar must advertise
2429  /// the form's keys, not the worktrees pane's `n new` — pressing `n` there
2430  /// types text. Only `View::List` falls through to the pane context
2431  /// (`Picker` in `gwm switch`, `Status` when the sidebar holds focus, else
2432  /// `Worktrees`).
2433  pub fn hint_context(&self) -> super::ui::HintContext {
2434    use super::ui::HintContext;
2435    match self.view {
2436      View::Create => self.create_hint_context(),
2437      View::Confirm => HintContext::Confirm,
2438      View::OpenMenu => HintContext::OpenMenu,
2439      // #219: the two link-prompt stages advertise different keys — the
2440      // choose-target picker vs the number-input submit/cancel — so the
2441      // statusbar tracks whichever stage is live.
2442      View::LinkPrompt => {
2443        if self.link_prompt_stage() == crate::tui::state::link_prompt::LinkPromptStage::InputNumber {
2444          HintContext::LinkInputNumber
2445        } else {
2446          HintContext::LinkPrompt
2447        }
2448      }
2449      View::CommandPalette => HintContext::CommandPalette,
2450      View::Report => HintContext::Report,
2451      View::Help => HintContext::Help,
2452      // The Command Logs overlay (issue #226) is a ~90% fullscreen modal;
2453      // the statusbar behind it shows the underlying pane's context, as the
2454      // List view does.
2455      View::CommandLogs => self.pane_hint_context(),
2456      // The Configuration panel (issue #232) is likewise a ~90% fullscreen
2457      // modal; the statusbar behind it keeps the underlying pane context.
2458      View::Config => self.pane_hint_context(),
2459      View::Pty => super::ui::HintContext::Pty,
2460      View::ExecPicker => HintContext::ExecPicker,
2461      View::CleanReport => HintContext::Clean,
2462      View::Edit => self.rename_hint_context(),
2463      // Issue #408: the detail overlay advertises its close/scroll keys.
2464      View::DetailOverlay => {
2465        if self.detail_overlay.kind == crate::tui::state::detail_overlay::DetailKind::CiChecks {
2466          HintContext::CiChecks
2467        } else {
2468          HintContext::Detail
2469        }
2470      }
2471      View::List => self.pane_hint_context(),
2472    }
2473  }
2474
2475  /// Which hint row the create overlay advertises (issue #416). The two
2476  /// modes present different inputs, so they advertise different verbs:
2477  /// free-form has one field and no type selector, and `toggle_mode` is the
2478  /// only way between them — a verb a user cannot guess from the visible
2479  /// inputs, unlike Tab or the arrows. Both the statusbar and the overlay's
2480  /// own footer read this, so the two can never disagree.
2481  pub fn create_hint_context(&self) -> super::ui::HintContext {
2482    use super::ui::HintContext;
2483    match self.create_form.mode {
2484      Mode::Freeform => HintContext::CreateFreeform,
2485      Mode::Structured => HintContext::Create,
2486    }
2487  }
2488
2489  /// Which hint row the rename overlay advertises (issue #479). Same shape and
2490  /// same reason as [`Self::create_hint_context`]: free-form has one field and
2491  /// no type selector, so advertising `field` and `type` there would name keys
2492  /// that do nothing. Both the statusbar and the modal's own footer read this,
2493  /// so the two can never disagree — which they did when only the footer knew
2494  /// about the mode (Codex review on PR #485).
2495  pub fn rename_hint_context(&self) -> super::ui::HintContext {
2496    use super::ui::HintContext;
2497    match self.create_form.mode {
2498      Mode::Freeform => HintContext::RenameFreeform,
2499      Mode::Structured => HintContext::Rename,
2500    }
2501  }
2502
2503  /// The underlying list-view pane context (issue #217), ignoring any open
2504  /// overlay. Drives the help overlay's subtitle + picker-section gating:
2505  /// `?` documents the keys for the pane you were on, so it must NOT collapse
2506  /// to the `Help` context that [`Self::hint_context`] returns while the
2507  /// overlay is up.
2508  pub fn pane_hint_context(&self) -> super::ui::HintContext {
2509    use super::ui::HintContext;
2510    if self.picker_mode {
2511      HintContext::Picker
2512    } else if self.sidebar.open && self.sidebar.focused {
2513      HintContext::Status
2514    } else {
2515      HintContext::Worktrees
2516    }
2517  }
2518
2519  /// `true` while a GitHub issue / PR fetch for the current link is inflight
2520  /// (issue #217) — drives the statusbar loading spinner.
2521  pub fn is_github_loading(&self) -> bool {
2522    matches!(self.issue_fetch_state(), GitHubFetchState::Loading)
2523      || matches!(self.pr_fetch_state(), GitHubFetchState::Loading)
2524  }
2525
2526  pub fn sidebar_scroll_down(&mut self) {
2527    self.sidebar.scroll_down();
2528  }
2529
2530  pub fn sidebar_scroll_up(&mut self) {
2531    self.sidebar.scroll_up();
2532  }
2533
2534  /// Scroll the Working Tree pane down (issue #437, `J`). Gated on the
2535  /// status pane holding the navigation focus — the same condition that
2536  /// routes `j` / `k` to the sidebar in [`Self::next`] / [`Self::prev`] —
2537  /// so the keys stay inert (and reusable) in the worktrees context.
2538  pub fn wt_scroll_down(&mut self) {
2539    if self.sidebar.open && self.sidebar.focused {
2540      self.sidebar.wt_scroll_down();
2541    }
2542  }
2543
2544  /// Scroll the Working Tree pane up (issue #437, `K`). Same focus gate
2545  /// as [`Self::wt_scroll_down`].
2546  pub fn wt_scroll_up(&mut self) {
2547    if self.sidebar.open && self.sidebar.focused {
2548      self.sidebar.wt_scroll_up();
2549    }
2550  }
2551
2552  /// Open the Keybindings (help) overlay from the top (#217). Resetting
2553  /// the scroll offset here keeps re-opens predictable.
2554  pub fn enter_help(&mut self) {
2555    self.view = View::Help;
2556    self.help_scroll = 0;
2557    self.help_x_scroll = 0;
2558  }
2559
2560  /// Open the Command Logs overlay (issue #226). Snapshots the global
2561  /// command log into owned state and resets the scroll cursor so a
2562  /// previously-scrolled session starts fresh at the top. The renderer
2563  /// republishes `max_scroll` against the live viewport.
2564  pub fn enter_command_logs(&mut self) {
2565    self.command_logs.sync();
2566    self.command_logs.reset();
2567    self.view = View::CommandLogs;
2568  }
2569
2570  /// Open the Configuration panel (issue #232). Resolves the effective
2571  /// config — the user-level global deep-merged under the repo `.gwm.toml`,
2572  /// with per-row source attribution — into owned state, then resets the
2573  /// scroll cursor so a re-open starts fresh at the top. The reads are
2574  /// cheap local TOML parses; on failure the panel still opens (empty)
2575  /// with the error on the statusbar rather than refusing to open.
2576  pub fn enter_config_panel(&mut self) {
2577    match crate::config::resolved_rows(&self.workdir, self.global_path.as_deref()) {
2578      Ok(rows) => self.config_panel.rows = rows,
2579      Err(e) => {
2580        self.config_panel.rows = Vec::new();
2581        self.status = format!("error: {}", e);
2582      }
2583    }
2584    self.refresh_key_rows();
2585    self.config_panel.reset();
2586    self.view = View::Config;
2587  }
2588
2589  /// Rebuild the Keys-tab rows (issue #294) from the live keymaps, attributing
2590  /// each binding's source via the resolved-row snapshot (the same layer
2591  /// attribution the `All` tab shows). Called on panel open and after a
2592  /// successful rebind so the displayed key(s) + badge track the edit.
2593  fn refresh_key_rows(&mut self) {
2594    let rows = self.config_panel.rows.clone();
2595    let key_rows = super::state::config_panel::build_key_rows(&self.keymap, &self.modal_keymap, |key| {
2596      rows
2597        .iter()
2598        .find(|r| r.key == key)
2599        .map(|r| r.source)
2600        .unwrap_or(crate::config::ConfigSource::Default)
2601    });
2602    self.config_panel.key_rows = key_rows;
2603  }
2604
2605  /// Feed a raw key event into the in-progress Keys-tab capture (issue #294),
2606  /// normalising it to a [`KeyStroke`] first. No-op when no capture is armed.
2607  pub fn push_key_capture(&mut self, key: KeyEvent) {
2608    self.config_panel.capture_push(KeyStroke::from_event(&key));
2609  }
2610
2611  /// Drive a key through an armed Keys-tab capture (issue #294). The event loop
2612  /// owns no logic — it just routes here when a capture is armed, mirroring
2613  /// `handle_create_key` / `handle_link_prompt_key`. Controls (resolved through
2614  /// the `config.edit` context so a rebind shows through):
2615  ///
2616  /// - `cancel` (def Esc) aborts the capture;
2617  /// - `submit` (def Enter) commits a **multi-stroke global chord**;
2618  /// - `Backspace` drops the last stroke of a global chord;
2619  /// - any other key is captured — a **single-stroke modal** verb auto-commits
2620  ///   on the first one, a global chord accumulates until `submit`.
2621  ///
2622  /// `Esc` / `Enter` / `Backspace` stay reserved controls in **both** modes and
2623  /// are never themselves captured (a modal verb can't be bound to them via the
2624  /// UI — hand-edit `.gwm.toml`), matching the documented capture controls and
2625  /// the hard-coded escape-hatch policy.
2626  pub fn handle_capture_key(&mut self, key: KeyEvent) {
2627    let single = self
2628      .config_panel
2629      .capture
2630      .as_ref()
2631      .map(|c| c.single_only)
2632      .unwrap_or(false);
2633    // Reserved capture controls. The *physical* Esc / Enter / Backspace are
2634    // always controls (never captured) regardless of any `config.edit` rebind,
2635    // so a custom `submit = ["Ctrl+s"]` can't make Enter assignable (Codex #297
2636    // review). The resolved `config.edit` verbs are honoured *in addition*, so a
2637    // custom key also cancels / commits.
2638    let resolved = self.resolve_modal(KeyContext::ConfigEdit, key);
2639    let is_cancel = key.code == KeyCode::Esc || resolved == Some(ModalAction::ConfigEditCancel);
2640    let is_submit = key.code == KeyCode::Enter || resolved == Some(ModalAction::ConfigEditSubmit);
2641    if is_cancel {
2642      self.config_panel.cancel_capture();
2643    } else if is_submit {
2644      // Enter commits an accumulated global chord; a reserved control (ignored)
2645      // for a single-stroke modal capture.
2646      if !single {
2647        self.commit_key_capture();
2648      }
2649    } else if key.code == KeyCode::Backspace {
2650      // Backspace edits a global chord; reserved (ignored) for a modal capture.
2651      if !single {
2652        self.config_panel.capture_pop();
2653      }
2654    } else {
2655      self.push_key_capture(key);
2656      if single {
2657        self.commit_key_capture();
2658      }
2659    }
2660  }
2661
2662  /// Commit the in-progress Keys-tab capture (issue #294): write the captured
2663  /// chord as a TOML array to the selected target's `[tui.keys]` /
2664  /// `[tui.keys.modal.<context>]` key in the active layer, then reload the
2665  /// config + both keymaps so the rebind is live immediately. An empty capture
2666  /// writes `[]` (unbind). Validation (conflict / prefix-collision) happens in
2667  /// the writer's validate-before-write gate; on failure the file and the live
2668  /// keymaps are left untouched and the error is surfaced on the statusbar.
2669  pub fn commit_key_capture(&mut self) {
2670    let Some(cap) = self.config_panel.take_capture() else {
2671      return;
2672    };
2673    let target = match self.config_panel.key_rows.get(cap.row) {
2674      Some(row) => row.target,
2675      None => return,
2676    };
2677    let config_key = target.config_key();
2678    let items = cap.as_config_items();
2679
2680    // A Project-layer write targets `self.workdir/.gwm.toml`. In workspace mode
2681    // with a stale selection that path is the *previously* active repo, so
2682    // refuse rather than rebind keys in the wrong repo (#304).
2683    if self.workspace_active_stale && self.config_panel.layer == SettingsLayer::Project {
2684      self.status = "workspace: selected repo is unavailable — can't edit its project keymap".into();
2685      return;
2686    }
2687    let path = match self.config_panel.layer {
2688      SettingsLayer::Project => self.workdir.join(crate::config::CONFIG_FILE),
2689      SettingsLayer::Global => match self.global_path.clone() {
2690        Some(p) => p,
2691        None => {
2692          self.status = "keys: no global config path (set $XDG_CONFIG_HOME or $HOME)".into();
2693          return;
2694        }
2695      },
2696    };
2697
2698    // Snapshot the target file first: `set_array_at` only validates the file
2699    // it writes, not the layered merge, so a rebind that is valid in this file
2700    // alone but collides with the *other* layer once merged (e.g. a prefix
2701    // collision the global layer reveals) would slip past and brick the
2702    // config for the next launch. Keep the prior bytes so we can roll back
2703    // (Codex #297 review P2).
2704    let prior = std::fs::read(&path).ok();
2705
2706    if let Err(e) = crate::config_cli::set_array_at(&path, &config_key, &items) {
2707      // `write_and_validate` writes the edit *before* erroring when the file
2708      // was already invalid on its own (the recovery path for #281 — here the
2709      // target value can be shadowed by another layer so the app still
2710      // loaded). Roll back so a rebind reported as failed never persists or
2711      // takes effect on the next launch (Codex #297 review P2).
2712      Self::restore_file(&path, prior);
2713      self.status = format!("keys: {}", e);
2714      return;
2715    }
2716
2717    // Strip any pre-#290 alias of this action from the same file: a legacy
2718    // config that still carries e.g. `tui.keys.open_menu` would, on reload,
2719    // re-apply the alias after the canonical `browse_links` in the sorted
2720    // override walk and silently shadow the new binding (Codex #297 review).
2721    // Best-effort: the canonical key is already written, so a cleanup error
2722    // is surfaced but does not abort the rebind.
2723    for alias_key in target.compat_alias_keys() {
2724      if let Err(e) = crate::config_cli::unset_at(&path, &alias_key) {
2725        self.status = format!("keys: {}", e);
2726      }
2727    }
2728
2729    // Reload the merged config and rebuild both keymaps so the new binding
2730    // fires without a restart.
2731    match Config::load_layered(&self.workdir, self.global_path.as_deref()) {
2732      Ok(cfg) => self.set_active_config(cfg),
2733      Err(e) => {
2734        // The single-file write validated but the layered merge is invalid —
2735        // roll the file back to its prior state so the config is never left
2736        // broken on disk, and keep the previous live keymaps.
2737        Self::restore_file(&path, prior);
2738        self.status = format!("keys: rebind rejected — would break the merged config: {}", e);
2739        return;
2740      }
2741    }
2742    match self.config.tui.keys.resolved_keymap() {
2743      Ok(km) => self.keymap = km,
2744      Err(e) => {
2745        self.status = format!("keys: {}", e);
2746        return;
2747      }
2748    }
2749    match self.config.tui.keys.resolved_modal_keymap() {
2750      Ok(mk) => self.modal_keymap = mk,
2751      Err(e) => {
2752        self.status = format!("keys: {}", e);
2753        return;
2754      }
2755    }
2756    if let Ok(rows) = crate::config::resolved_rows(&self.workdir, self.global_path.as_deref()) {
2757      self.config_panel.rows = rows;
2758    }
2759    self.refresh_key_rows();
2760
2761    let desc = if items.is_empty() {
2762      "unbound".to_string()
2763    } else {
2764      items.join(" ")
2765    };
2766    let mut status = format!("set {} = {} ({})", config_key, desc, self.config_panel.layer.label());
2767    // Verify the capture actually took effect in the *merged* keymap: a
2768    // higher-precedence layer, or a pre-#290 alias still declared in another
2769    // layer (which we deliberately don't edit), can shadow the write so the new
2770    // key never fires — or, for an unbind, keeps the action bound — even though
2771    // it persisted. Warn instead of reporting a clean success (Codex #297
2772    // review).
2773    if !self.capture_took_effect(target, &cap.pending) {
2774      status.push_str(" — shadowed (a higher layer or legacy alias still binds it)");
2775    }
2776    self.status = status;
2777  }
2778
2779  /// Restore a config file to a snapshot taken before a rebind write: rewrite
2780  /// the prior bytes, or remove the file if it did not exist before. Used to
2781  /// roll back a failed / merge-invalid Keys-tab write (issue #294).
2782  fn restore_file(path: &std::path::Path, prior: Option<Vec<u8>>) {
2783    match prior {
2784      Some(bytes) => {
2785        let _ = std::fs::write(path, bytes);
2786      }
2787      None => {
2788        let _ = std::fs::remove_file(path);
2789      }
2790    }
2791  }
2792
2793  /// Whether the just-committed capture is the *effective* state in the live
2794  /// (merged) keymap, i.e. not shadowed by another layer / a lingering legacy
2795  /// alias. For a rebind (`strokes` non-empty) the captured chord must resolve
2796  /// to the target's action; for an unbind (`strokes` empty) the action must
2797  /// have no remaining binding. Issue #294 (Codex #297 review).
2798  fn capture_took_effect(&self, target: KeyTarget, strokes: &[KeyStroke]) -> bool {
2799    match target {
2800      KeyTarget::Global(action) => {
2801        if strokes.is_empty() {
2802          self.keymap.keys_display(action).is_empty()
2803        } else {
2804          matches!(self.keymap.lookup(strokes), ChordResolution::Matched(a) if a == action)
2805        }
2806      }
2807      KeyTarget::Modal(action) => {
2808        if strokes.is_empty() {
2809          self.modal_keymap.keys_display(action).is_empty()
2810        } else {
2811          strokes
2812            .first()
2813            .map(|s| self.modal_keymap.resolve(action.context(), s) == Some(action))
2814            .unwrap_or(false)
2815        }
2816      }
2817    }
2818  }
2819
2820  // ── PTY overlay (issue #35) ────────────────────────────────────────────
2821
2822  /// Open the PTY overlay: store `pty` and switch to [`View::Pty`].
2823  pub fn open_pty_overlay(&mut self, pty: super::state::pty_overlay::PtyOverlay) {
2824    self.pty_overlay = Some(pty);
2825    self.view = View::Pty;
2826  }
2827
2828  /// Close the PTY overlay: kill the child process, drop the state, and
2829  /// return to [`View::List`]. Safe to call when no overlay is open.
2830  pub fn close_pty_overlay(&mut self) {
2831    if let Some(ref mut pty) = self.pty_overlay {
2832      pty.kill();
2833    }
2834    self.pty_overlay = None;
2835    if self.view == View::Pty {
2836      self.view = View::List;
2837    }
2838  }
2839
2840  // ── Exec picker overlay (issue #325) ───────────────────────────────────
2841
2842  /// `true` while a destructive overlay — the exec picker or the clean
2843  /// report — is open (issue #325). The run loop suspends `maybe_auto_refresh`
2844  /// and `sync_active_repo` while one is up, so the worktree list (and thus
2845  /// the live selection / active repo) cannot reshuffle under an armed reclaim
2846  /// or a pending exec run. This closes the drift class at its source (Codex
2847  /// #333 review); the per-overlay open-time snapshots stay as defence in
2848  /// depth against an already-in-flight refresh landing its result.
2849  pub fn destructive_overlay_open(&self) -> bool {
2850    matches!(self.view, View::ExecPicker | View::CleanReport)
2851  }
2852
2853  /// Open the exec profile picker (issue #325). Populates it from
2854  /// `[exec.profiles.*]` and switches to [`View::ExecPicker`]. Refuses
2855  /// (status-bar message, no transition) when nothing is selected or no
2856  /// exec profiles are configured — there is nothing to pick.
2857  pub fn enter_exec_picker(&mut self) {
2858    let Some(cwd) = self.selected().map(|wt| wt.path.clone()) else {
2859      self.status = "nothing selected".into();
2860      return;
2861    };
2862    let names: Vec<String> = self.config.exec.profiles.keys().cloned().collect();
2863    if names.is_empty() {
2864      self.status = "no [exec.profiles] configured — add one to .gwm.toml".into();
2865      return;
2866    }
2867    // Capture the target worktree path AND the active repo's `[exec]` config
2868    // now: an auto-refresh can drift the live selection (and, in workspace
2869    // mode, the active repo) while the picker is open, so `Enter` must run in
2870    // *this* worktree against *this* config — not whatever is live later
2871    // (Codex #333 review).
2872    self.exec_picker_cfg = self.config.exec.clone();
2873    self.exec_picker.open(names, cwd);
2874    self.view = View::ExecPicker;
2875  }
2876
2877  /// Handle a key inside the exec picker overlay (issue #325). The
2878  /// testable handler owns the highlight movement; the run loop owns the
2879  /// two side effects (resolve + spawn, or close). Keys resolve through
2880  /// [`KeyContext::ExecPicker`] so they honour `[tui.keys.modal.exec]`.
2881  pub fn handle_exec_picker_key(&mut self, key: KeyEvent) -> ExecPickerKey {
2882    match self.resolve_modal(KeyContext::ExecPicker, key) {
2883      Some(ModalAction::ExecPickerCancel) => ExecPickerKey::Cancel,
2884      Some(ModalAction::ExecPickerAccept) => ExecPickerKey::Submit,
2885      Some(ModalAction::ExecPickerNext) => {
2886        self.exec_picker.next();
2887        ExecPickerKey::Handled
2888      }
2889      Some(ModalAction::ExecPickerPrev) => {
2890        self.exec_picker.prev();
2891        ExecPickerKey::Handled
2892      }
2893      _ => ExecPickerKey::Handled,
2894    }
2895  }
2896
2897  /// Resolve the highlighted exec profile to an `(argv, cwd)` pair for the
2898  /// run loop to spawn in a PTY overlay (issue #325). `None` (with a
2899  /// status-bar message) when nothing is selected or the profile fails to
2900  /// resolve — e.g. an empty `command` array. The argv is the frozen
2901  /// `[exec.profiles.<name>].command` verbatim (no shell), matching the
2902  /// 1.0 exec contract; the run loop spawns `argv[0]` directly.
2903  pub fn exec_picker_resolve(&mut self) -> Option<(Vec<String>, PathBuf)> {
2904    let profile = self.exec_picker.selected_profile()?.to_string();
2905    // Resolve against the worktree captured when the picker opened, NOT the
2906    // live selection (which an auto-refresh may have drifted) — #333 review.
2907    let Some(cwd) = self.exec_picker.cwd().map(Path::to_path_buf) else {
2908      self.status = "nothing selected".into();
2909      return None;
2910    };
2911    // Resolve against the `[exec]` config captured at open, not the live one.
2912    match crate::exec::resolve_exec_command(Some(&profile), &[], &self.exec_picker_cfg) {
2913      Ok(mut argv) => {
2914        // Pin a worktree-relative executable (`./run.sh`, `scripts/build`) to
2915        // the captured worktree, exactly like the CLI exec path — otherwise
2916        // `argv[0]` would resolve against gwm's own cwd (Codex #333 review).
2917        // A bare command (`cargo`) or an absolute path is returned unchanged
2918        // (PATH lookup / as-is).
2919        if let Some(first) = argv.first_mut() {
2920          *first = crate::exec::resolve_program(&cwd, first).to_string_lossy().into_owned();
2921        }
2922        Some((argv, cwd))
2923      }
2924      Err(e) => {
2925        self.status = format!("exec profile {profile:?}: {e}");
2926        None
2927      }
2928    }
2929  }
2930
2931  /// Close the exec picker without running anything (issue #325). Returns
2932  /// to [`View::List`].
2933  pub fn close_exec_picker(&mut self) {
2934    if self.view == View::ExecPicker {
2935      self.view = View::List;
2936    }
2937  }
2938
2939  // ── Clean overlay (issue #325) ─────────────────────────────────────────
2940
2941  /// Open the clean overlay (issue #325). Populates the `[clean.profiles]`
2942  /// picker, scans the selected worktree through the safety gate
2943  /// ([`crate::clean::scan_worktree_safe`]), and switches to
2944  /// [`View::CleanReport`]. Refuses (status-bar message, no transition) when
2945  /// nothing is selected. A scan that finds nothing safe still opens — the
2946  /// report says so.
2947  /// Open the agent-session detail overlay for the selected worktree
2948  /// (issue #408, `a`). Rows come from the pure
2949  /// [`crate::tui::state::detail_overlay::agent_detail_rows`] mapping over
2950  /// the last completed snapshot — a session-less worktree opens with an
2951  /// explicit "no agent session found" row, never blank.
2952  pub fn open_agent_overlay(&mut self) {
2953    let Some(sel) = self.selected().cloned() else {
2954      self.status = "nothing selected".into();
2955      return;
2956    };
2957    // Capture the target now (clean-overlay pattern, Codex #333): an
2958    // auto-refresh can drift the live selection while the overlay is open,
2959    // and attach/detach must pin against THIS worktree's branch.
2960    self.detail_overlay_target = Some((
2961      sel.path.clone(),
2962      crate::github::pinnable_branch(sel.branch.as_deref()).map(str::to_string),
2963    ));
2964    let rows = self.build_agent_rows(&sel);
2965    self.detail_overlay.open(
2966      crate::tui::state::detail_overlay::DetailKind::Agents,
2967      "Agent Sessions".into(),
2968      rows,
2969    );
2970    self.view = View::DetailOverlay;
2971  }
2972
2973  /// Open the CI checks overlay (issue #436): one row per classified
2974  /// `statusCheckRollup` entry of the linked PR, in rollup order. With no
2975  /// linked PR or an empty rollup the overlay would be a bordered void —
2976  /// explain on the status bar instead.
2977  pub fn enter_ci_checks(&mut self) {
2978    // Workspace mode: a failed `Repository::open` for the selected row
2979    // leaves `github.link` and its cache on the previously active repo —
2980    // opening now would show (and `Enter` would browse) the OLD repo's
2981    // checks. Refuse, the same contract as the project-layer keymap
2982    // editor (#304 / Codex review #455).
2983    if self.workspace_active_stale {
2984      self.status = "workspace: selected repo is unavailable — can't open its CI checks".into();
2985      return;
2986    }
2987    let checks = match self.pr_fetch_state() {
2988      GitHubFetchState::Loaded(pr) if !pr.checks.is_empty() => pr.checks.clone(),
2989      _ => {
2990        // Resolve the active fetch binding instead of hard-coding `F`
2991        // (Codex review #455); an unbound action drops the parenthetical.
2992        self.status = match self.keymap.primary_chord(Action::FetchGithub) {
2993          Some(key) => format!("no CI checks to show — link a PR and fetch ({key}) first"),
2994          None => "no CI checks to show — link a PR and fetch first".into(),
2995        };
2996        return;
2997      }
2998    };
2999    let rows = crate::tui::state::detail_overlay::ci_check_rows(&checks, std::time::SystemTime::now());
3000    // Drop any stale agents target (an interrupted agents overlay leaves
3001    // one behind) — it belongs to the agents consumer only (Codex #455).
3002    self.detail_overlay_target = None;
3003    // Pin the overlay to the PR it renders, so a link mutation that
3004    // disagrees can close it (Codex review #455). The checks themselves
3005    // are kept too — the duration tick's cache-independent source.
3006    self.detail_overlay_pr = self.github.link.pr.map(|n| (self.github.link_slug.clone(), n));
3007    self.ci_overlay_checks = checks;
3008    self.detail_overlay.open(
3009      crate::tui::state::detail_overlay::DetailKind::CiChecks,
3010      "CI Checks".into(),
3011      rows,
3012    );
3013    self.view = View::DetailOverlay;
3014  }
3015
3016  /// Contextual KEY routing (issue #436) — same mechanism that turns
3017  /// `j` / `k` into sidebar scroll: while the status pane holds the
3018  /// focus, the `c` keystroke (EditWorktree) opens the CI checks
3019  /// overlay instead of the rename modal. Applied by the event loop on
3020  /// the **key path only** (Codex review #455): the command palette
3021  /// dispatches actions by their NAME, so its `edit-worktree` entry
3022  /// must stay a rename in every context (a dedicated `ci-checks`
3023  /// entry already exists there). Pure, so the contract is pinned
3024  /// without an event loop.
3025  pub fn resolve_contextual_action(&self, action: Action) -> Action {
3026    if action == Action::EditWorktree && self.sidebar.open && self.sidebar.focused {
3027      Action::CiChecks
3028    } else {
3029      action
3030    }
3031  }
3032
3033  // ---- CI checks overlay `f` filter (issue #436) ---------------------------
3034  // Same shell machinery as the agent attach prompt right above (mode +
3035  // input buffer + candidate cursor), filtering the overlay's own rows.
3036
3037  /// `f` inside the CI checks overlay — re-fetch the PR; the landing
3038  /// refreshes the rows in place (`refresh_ci_overlay_on_pr_landing`).
3039  /// The modal dispatch bypasses run_action's workspace guard, so it is
3040  /// re-applied here (Codex review #455, P1): a selection gone stale
3041  /// AFTER the overlay opened must not fetch — and persist PR metadata —
3042  /// through the previously active repo's slug and handle. The overlay
3043  /// closes, since its rows belong to that previous repo anyway.
3044  pub fn ci_checks_refresh(&mut self) {
3045    if self.workspace_active_stale {
3046      self.close_detail_overlay();
3047      self.status = "workspace: selected repo is unavailable — CI checks closed".into();
3048      return;
3049    }
3050    self.refresh_github_status();
3051  }
3052
3053  /// Poll-cadence tick (Codex review #455): a Running check's `extra`
3054  /// column carries an elapsed duration formatted when the rows were
3055  /// built, which otherwise freezes until the next `f`. Rebuild the rows
3056  /// from the cached PR state while at least one check is still running —
3057  /// pure in-memory formatting, no I/O — and stay a no-op once every
3058  /// check is terminal so idle frames do no churn. `set_rows` keeps the
3059  /// selection and the filter cursor clamped.
3060  pub fn tick_ci_overlay_durations(&mut self) {
3061    if self.view != View::DetailOverlay
3062      || self.detail_overlay.kind != crate::tui::state::detail_overlay::DetailKind::CiChecks
3063    {
3064      return;
3065    }
3066    // The overlay's OWN checks, not the fetch cache (Codex review #455):
3067    // an invalidation while the overlay is up — a workspace refresh_link
3068    // with no bulk refetch, a failed manual refresh — would empty the
3069    // cache and silently kill the clock of a still-Running check.
3070    if !self
3071      .ci_overlay_checks
3072      .iter()
3073      .any(|c| matches!(c.outcome, github::CheckOutcome::Running))
3074    {
3075      return;
3076    }
3077    let rows = crate::tui::state::detail_overlay::ci_check_rows(&self.ci_overlay_checks, std::time::SystemTime::now());
3078    self.detail_overlay.set_rows(rows);
3079  }
3080
3081  pub fn ci_input_open(&mut self) {
3082    self.detail_overlay.mode = crate::tui::state::detail_overlay::DetailMode::Input;
3083    self.detail_overlay.input.clear();
3084    self.detail_overlay.input_selected = 0;
3085  }
3086
3087  pub fn ci_input_push(&mut self, c: char) {
3088    self.detail_overlay.input.push(c);
3089    self.detail_overlay.input_selected = 0;
3090  }
3091
3092  pub fn ci_input_pop(&mut self) {
3093    self.detail_overlay.input.pop();
3094    self.detail_overlay.input_selected = 0;
3095  }
3096
3097  /// Indices of the rows matching the live query, in row order.
3098  pub fn ci_input_matches(&self) -> Vec<usize> {
3099    crate::tui::state::detail_overlay::filter_rows(&self.detail_overlay.rows, &self.detail_overlay.input)
3100  }
3101
3102  pub fn ci_input_next(&mut self) {
3103    let len = self.ci_input_matches().len();
3104    self.detail_overlay.input_selected = (self.detail_overlay.input_selected + 1).min(len.saturating_sub(1));
3105  }
3106
3107  pub fn ci_input_prev(&mut self) {
3108    self.detail_overlay.input_selected = self.detail_overlay.input_selected.saturating_sub(1);
3109  }
3110
3111  pub fn ci_input_cancel(&mut self) {
3112    self.detail_overlay.mode = crate::tui::state::detail_overlay::DetailMode::List;
3113    self.detail_overlay.input.clear();
3114  }
3115
3116  /// The details URL of the highlighted filtered row (Enter inside the
3117  /// filter). Pure so the event loop owns the actual browser spawn; also
3118  /// re-anchors the List selection on the picked row and leaves the
3119  /// filter, so Esc-free flows land where the user expects.
3120  pub fn ci_input_selected_url(&mut self) -> Option<String> {
3121    let matches = self.ci_input_matches();
3122    let row_idx = matches.get(self.detail_overlay.input_selected).copied()?;
3123    self.detail_overlay.selected = row_idx;
3124    self.ci_input_cancel();
3125    self.detail_overlay.rows.get(row_idx).and_then(|r| r.meta.clone())
3126  }
3127
3128  /// The details URL of the selected row in List mode (Enter). `None` when
3129  /// the check carries no URL — the caller reports on the status bar.
3130  pub fn ci_selected_url(&self) -> Option<String> {
3131    self
3132      .detail_overlay
3133      .rows
3134      .get(self.detail_overlay.selected)
3135      .and_then(|r| r.meta.clone())
3136  }
3137
3138  /// Rows for the captured worktree: sessions from the snapshot, the manual
3139  /// pins marked (issue #408 US4 + user feedback 2026-07-22 — multi-pin).
3140  fn build_agent_rows(&self, w: &crate::worktree::WorktreeInfo) -> Vec<crate::tui::state::detail_overlay::DetailRow> {
3141    // Pins come from the per-path map — built per OWNING repo, so a
3142    // workspace active-repo swap under the open overlay cannot yield
3143    // absent or wrong markers (round N), and the snapshot-landing rebuild
3144    // does no branch-config I/O on the event loop (round P): the map is
3145    // refreshed by the landing itself and by every attach/detach.
3146    let pinned = self
3147      .agent_pins
3148      .get(&crate::agent_sessions::path_display_key(&w.path))
3149      .cloned()
3150      .unwrap_or_default();
3151    crate::tui::state::detail_overlay::agent_detail_rows(self.agents_for(w), &pinned, std::time::SystemTime::now())
3152  }
3153
3154  /// Fresh pins per worktree path from branch config — the synchronous
3155  /// read for USER-ACTION paths (attach/detach refresh); the periodic
3156  /// detection reads the same sources in its worker instead (round P).
3157  /// Each row reads from its OWNING repo via [`Self::agent_pin_sources`]
3158  /// (rounds A + I: a same-named branch elsewhere cannot leak its pins).
3159  fn read_agent_pins(&self) -> std::collections::BTreeMap<String, Vec<String>> {
3160    read_pins_from_sources(&self.agent_pin_sources())
3161  }
3162
3163  /// The current pinnable branch of the worktree at `path`, freshly read
3164  /// from the listed rows (which every refresh re-lists) — never the
3165  /// branch captured when an overlay opened (Codex review round U).
3166  fn current_branch_of(&self, path: &Path) -> Option<String> {
3167    let w = self.worktrees.iter().find(|w| w.path == path)?;
3168    crate::github::pinnable_branch(w.branch.as_deref()).map(str::to_string)
3169  }
3170
3171  /// Pin the selected overlay row's session to the overlay's target worktree
3172  /// (`a` inside the modal). Auto-detection stays the default; the pin only
3173  /// adds (issue #408 US4).
3174  pub fn attach_selected_agent(&mut self) {
3175    let Some(sid) = self.detail_overlay.selected_meta().map(str::to_string) else {
3176      // Only the "no agent session found" placeholder carries no id: with
3177      // nothing to select, `a` falls through to the attach-by-id prompt
3178      // instead of dead-ending (user feedback 2026-07-22).
3179      self.open_agent_input();
3180      return;
3181    };
3182    self.attach_agent_by_id(&sid);
3183  }
3184
3185  /// Pin `sid` to the overlay's target worktree — shared by the row action
3186  /// and the attach-by-id prompt. Returns `true` when the pin was written.
3187  fn attach_agent_by_id(&mut self, sid: &str) -> bool {
3188    if self.is_workspace() {
3189      // Pins are single-repo (same ceiling as the CLI surfaces): in
3190      // workspace mode `sync_active_repo` may swap `self.repo` under the
3191      // open overlay, which would write the pin into the wrong repo's
3192      // config (Codex review round B).
3193      self.status = "agent pins are per-repo — not available in workspace mode".into();
3194      return false;
3195    }
3196    let Some((path, _)) = self.detail_overlay_target.clone() else {
3197      self.status = "cannot pin: no worktree captured".into();
3198      return false;
3199    };
3200    // The CURRENT branch, not the one captured at overlay open: a branch
3201    // flipped externally while the overlay stayed open would otherwise
3202    // receive the pin under `branch.<old>.` (Codex review round U).
3203    let Some(branch) = self.current_branch_of(&path) else {
3204      self.status = "cannot pin: worktree has no branch (detached HEAD)".into();
3205      return false;
3206    };
3207    if let Err(e) = crate::github::add_agent_pin(&self.repo, &branch, sid) {
3208      self.status = format!("pin failed: {e}");
3209      return false;
3210    }
3211    self.status = format!("pinned {sid}");
3212    self.refresh_agent_overlay_rows(&path);
3213    true
3214  }
3215
3216  /// Enter the attach-by-id prompt (`i` in the overlay): palette-style
3217  /// query over EVERY detected session — a session matched to no worktree
3218  /// is exactly the one worth pinning manually.
3219  pub fn open_agent_input(&mut self) {
3220    self.detail_overlay.mode = crate::tui::state::detail_overlay::DetailMode::Input;
3221    self.detail_overlay.input.clear();
3222    self.detail_overlay.input_selected = 0;
3223    // The candidate pool needs the full sweep — refreshed on open, not on
3224    // the periodic tick (round Q); until it lands the prompt filters the
3225    // last landed pool.
3226    self.refresh_agent_pool();
3227  }
3228
3229  pub fn agent_input_push(&mut self, c: char) {
3230    self.detail_overlay.input.push(c);
3231    self.detail_overlay.input_selected = 0;
3232  }
3233
3234  pub fn agent_input_pop(&mut self) {
3235    self.detail_overlay.input.pop();
3236    self.detail_overlay.input_selected = 0;
3237  }
3238
3239  pub fn agent_input_next(&mut self) {
3240    let len = self.agent_input_candidates().len();
3241    self.detail_overlay.input_selected = (self.detail_overlay.input_selected + 1).min(len.saturating_sub(1));
3242  }
3243
3244  pub fn agent_input_prev(&mut self) {
3245    self.detail_overlay.input_selected = self.detail_overlay.input_selected.saturating_sub(1);
3246  }
3247
3248  pub fn agent_input_cancel(&mut self) {
3249    self.detail_overlay.mode = crate::tui::state::detail_overlay::DetailMode::List;
3250    self.detail_overlay.input.clear();
3251  }
3252
3253  /// The prompt's filtered candidate pool (owned clones — the borrow of
3254  /// `agent_all_sessions` must not outlive `&mut self` call sites).
3255  pub fn agent_input_candidates(&self) -> Vec<crate::agent_sessions::AgentSession> {
3256    crate::tui::state::detail_overlay::filter_sessions(&self.agent_all_sessions, &self.detail_overlay.input)
3257      .into_iter()
3258      .cloned()
3259      .collect()
3260  }
3261
3262  /// Attach the highlighted candidate (or the literal query when nothing
3263  /// matches a known session — validated before persisting). Unknown id
3264  /// keeps the prompt open for correction.
3265  pub fn agent_input_submit(&mut self) {
3266    let candidates = self.agent_input_candidates();
3267    let sid = candidates
3268      .get(self.detail_overlay.input_selected)
3269      .map(|s| s.id.clone())
3270      .unwrap_or_else(|| self.detail_overlay.input.trim().to_string());
3271    let known = candidates.iter().any(|s| s.id == sid);
3272    if sid.is_empty() || !known {
3273      self.status = format!("no agent session matching '{sid}' — run gwm agents for ids");
3274      return;
3275    }
3276    if self.attach_agent_by_id(&sid) {
3277      self.detail_overlay.mode = crate::tui::state::detail_overlay::DetailMode::List;
3278      self.detail_overlay.input.clear();
3279    }
3280  }
3281
3282  /// Unpin the SELECTED session (`d` inside the modal). Pins are
3283  /// multi-valued (user feedback 2026-07-22): only the highlighted
3284  /// session's pin is removed, the others stay.
3285  pub fn detach_selected_agent(&mut self) {
3286    if self.is_workspace() {
3287      self.status = "agent pins are per-repo — not available in workspace mode".into();
3288      return;
3289    }
3290    let Some(sid) = self.detail_overlay.selected_meta().map(str::to_string) else {
3291      self.status = "no session selected to unpin".into();
3292      return;
3293    };
3294    let Some((path, _)) = self.detail_overlay_target.clone() else {
3295      self.status = "cannot detach: no worktree captured".into();
3296      return;
3297    };
3298    // Same round-U rule as attach: unpin from the CURRENT branch.
3299    let Some(branch) = self.current_branch_of(&path) else {
3300      self.status = "cannot detach: worktree has no branch (detached HEAD)".into();
3301      return;
3302    };
3303    match crate::github::remove_agent_pin(&self.repo, &branch, &sid) {
3304      Ok(true) => self.status = format!("unpinned {sid}"),
3305      Ok(false) => {
3306        self.status = "session is not pinned".into();
3307        return;
3308      }
3309      Err(e) => {
3310        self.status = format!("detach failed: {e}");
3311        return;
3312      }
3313    }
3314    self.refresh_agent_overlay_rows(&path);
3315  }
3316
3317  /// Rebuild the open overlay's rows after a pin change, refresh the
3318  /// render-side pins copy (the Agents pane shows pinned-only), and push
3319  /// the new pin state to every other surface (snapshot re-detection).
3320  fn refresh_agent_overlay_rows(&mut self, path: &Path) {
3321    // Map first: `build_agent_rows` reads the [`Self::agent_pins`] copy
3322    // (round P), so the fresh read must land before the rows rebuild.
3323    self.agent_pins = self.read_agent_pins();
3324    if let Some(w) = self.worktrees.iter().find(|w| w.path == path).cloned() {
3325      let rows = self.build_agent_rows(&w);
3326      self.detail_overlay.set_rows(rows);
3327    }
3328    if self.tasks.is_loading(TaskKind::AgentSessions) {
3329      // The in-flight thread keeps walking the store even if its slot is
3330      // dropped — invalidating here raced a second scan against it
3331      // (round U, same hazard as rounds P/R). Let it land and chain the
3332      // re-detection; its pre-change pins are skipped on landing.
3333      self.agent_redetect_wanted = true;
3334    } else {
3335      self.tasks.invalidate(TaskKind::AgentSessions);
3336      self.agent_snapshot_at = None;
3337    }
3338  }
3339
3340  /// Close the detail overlay back to the list, leaving list state as it was.
3341  pub fn close_detail_overlay(&mut self) {
3342    self.detail_overlay_target = None;
3343    self.detail_overlay_pr = None;
3344    self.ci_overlay_checks.clear();
3345    self.view = View::List;
3346  }
3347
3348  pub fn enter_clean_overlay(&mut self) {
3349    let Some(sel) = self.selected() else {
3350      self.status = "nothing selected".into();
3351      return;
3352    };
3353    // Capture the target worktree AND the active repo's `[clean]` config now:
3354    // an auto-refresh can drift the live selection (and, in workspace mode,
3355    // the active repo) while the overlay is open / armed, so every re-scan
3356    // and the delete must pin to *this* worktree against *this* config
3357    // (Codex #333 review).
3358    let name = sel.name.clone();
3359    let path = sel.path.clone();
3360    self.clean_overlay_cfg = self.config.clean.clone();
3361    self.clean_overlay_countdown_secs = self.config.tui.effective_confirm_countdown_secs();
3362    let names: Vec<String> = self.clean_overlay_cfg.profiles.keys().cloned().collect();
3363    self.clean_overlay.open(names, name, path);
3364    if let Err(e) = self.clean_overlay_rescan() {
3365      self.status = format!("clean: {e}");
3366      return;
3367    }
3368    self.view = View::CleanReport;
3369  }
3370
3371  /// Re-resolve the highlighted profile's dirs and re-scan the *captured*
3372  /// target worktree (not the live selection), storing the gated snapshot.
3373  /// Surfaces a profile-resolution error (e.g. an invalid `[clean.profiles]`
3374  /// dir) to the caller.
3375  fn clean_overlay_rescan(&mut self) -> Result<()> {
3376    let Some((name, path)) = self
3377      .clean_overlay
3378      .target()
3379      .map(|(n, p)| (n.to_string(), p.to_path_buf()))
3380    else {
3381      return Ok(());
3382    };
3383    let profile = self.clean_overlay.selected_profile().map(str::to_string);
3384    let dirs = crate::clean::resolve_clean_dirs(profile.as_deref(), &self.clean_overlay_cfg)?;
3385    let (reclaim, skipped) = crate::clean::scan_worktree_safe(&name, &path, &dirs);
3386    self.clean_overlay.set_scan(reclaim, skipped);
3387    Ok(())
3388  }
3389
3390  /// Cycle the clean profile picker forward and re-scan, but ONLY when the
3391  /// highlight actually moved (issue #325 / Codex #333). A no-op move (only
3392  /// the `(default)` choice) must not re-scan — that would reset the
3393  /// `ConfirmModal` and silently disarm a pending reclaim while the status
3394  /// bar still reads `armed`.
3395  pub fn clean_overlay_next(&mut self) {
3396    if self.clean_overlay.select_next() {
3397      if let Err(e) = self.clean_overlay_rescan() {
3398        self.status = format!("clean: {e}");
3399      }
3400    }
3401  }
3402
3403  /// Cycle the clean profile picker backward and re-scan, only when the
3404  /// highlight actually moved (issue #325 / Codex #333).
3405  pub fn clean_overlay_prev(&mut self) {
3406    if self.clean_overlay.select_prev() {
3407      if let Err(e) = self.clean_overlay_rescan() {
3408        self.status = format!("clean: {e}");
3409      }
3410    }
3411  }
3412
3413  /// Total duration of the clean safety countdown. Unlike the delete-confirm
3414  /// modal, clean has no `delete_branch_on_remove` gate — it reads
3415  /// `[tui] confirm_countdown_secs` directly. `Duration::ZERO` ⇒ classic
3416  /// single-keystroke confirm.
3417  pub fn clean_countdown_total(&self) -> Duration {
3418    // The value captured at open (Codex #333) — never the live config, which a
3419    // workspace refresh could swap (e.g. to `0`, erasing the safety delay).
3420    Duration::from_secs(u64::from(self.clean_overlay_countdown_secs))
3421  }
3422
3423  /// Handle the clean confirm key. Arms / disarms / fires the countdown via
3424  /// the dedicated [`CleanOverlay`] modal. Nothing-to-reclaim is a no-op
3425  /// guard so the user cannot arm a delete that would free zero bytes.
3426  pub fn clean_confirm_press(&mut self, now: Instant) -> ConfirmKeyAction {
3427    if self.clean_overlay.is_empty_scan() {
3428      self.status = "nothing to reclaim".into();
3429      return ConfirmKeyAction::Disarmed;
3430    }
3431    let total = self.clean_countdown_total();
3432    let action = self.clean_overlay.confirm.press_y(now, total);
3433    match action {
3434      ConfirmKeyAction::Armed => {
3435        self.status = format!(
3436          "armed — reclaiming {} in {}s",
3437          crate::clean::human_size(self.clean_overlay.total_bytes()),
3438          total.as_secs()
3439        );
3440      }
3441      ConfirmKeyAction::Disarmed => self.status = "clean cancelled".into(),
3442      ConfirmKeyAction::FireNow => {}
3443    }
3444    action
3445  }
3446
3447  /// Tick the clean safety countdown. Called from the event loop on every
3448  /// poll-timeout iteration while the overlay is open.
3449  pub fn tick_clean_countdown(&mut self, now: Instant) -> CountdownTickOutcome {
3450    self.clean_overlay.confirm.tick(now, self.clean_countdown_total())
3451  }
3452
3453  /// Clean countdown progress in `[0.0, 1.0]` for the UI gauge.
3454  pub fn clean_countdown_progress(&self, now: Instant) -> f64 {
3455    self.clean_overlay.confirm.progress(now, self.clean_countdown_total())
3456  }
3457
3458  /// Seconds remaining (rounded up) on the clean countdown, for the UI label.
3459  pub fn clean_countdown_remaining_secs(&self, now: Instant) -> u64 {
3460    self
3461      .clean_overlay
3462      .confirm
3463      .remaining_secs(now, self.clean_countdown_total())
3464  }
3465
3466  /// Delete the gated reclaim of the current clean snapshot (issue #325) and
3467  /// return to the list. The snapshot was already filtered to the
3468  /// git-ignored, untracked artifacts by [`crate::clean::scan_worktree_safe`],
3469  /// so this only removes what the CLI `gwm clean --yes` would. Reports the
3470  /// freed size (or the failure) on the status bar.
3471  pub fn clean_overlay_delete(&mut self) {
3472    // Re-scan + re-gate IMMEDIATELY before deleting rather than trusting the
3473    // snapshot shown in the overlay (Codex #333 review). That snapshot can be
3474    // seconds old — the safety countdown, or just the overlay sitting open —
3475    // and a directory may have turned unsafe meanwhile (e.g. `git add -f
3476    // target/file` under an ignored `target/`). Deleting a freshly gated
3477    // reclaim closes that TOCTOU window, matching the CLI's scan-then-delete.
3478    // Pin to the CAPTURED target worktree, not the live selection (an
3479    // auto-refresh may have drifted it while the countdown ran) — #333.
3480    let Some((name, path)) = self
3481      .clean_overlay
3482      .target()
3483      .map(|(n, p)| (n.to_string(), p.to_path_buf()))
3484    else {
3485      self.close_clean_overlay();
3486      return;
3487    };
3488    let profile = self.clean_overlay.selected_profile().map(str::to_string);
3489    let dirs = match crate::clean::resolve_clean_dirs(profile.as_deref(), &self.clean_overlay_cfg) {
3490      Ok(d) => d,
3491      Err(e) => {
3492        self.status = format!("clean: {e}");
3493        self.close_clean_overlay();
3494        return;
3495      }
3496    };
3497    let (reclaim, _skipped) = crate::clean::scan_worktree_safe(&name, &path, &dirs);
3498    if reclaim.artifacts.is_empty() {
3499      self.status = "nothing to reclaim".into();
3500      self.close_clean_overlay();
3501      return;
3502    }
3503    match crate::clean::delete_reclaim(&reclaim) {
3504      Ok(freed) => {
3505        self.status = format!("reclaimed {} from {}", crate::clean::human_size(freed), reclaim.name);
3506      }
3507      Err(e) => self.status = format!("clean failed: {e}"),
3508    }
3509    self.close_clean_overlay();
3510  }
3511
3512  /// Close the clean overlay, disarming the countdown, and return to
3513  /// [`View::List`] (issue #325).
3514  pub fn close_clean_overlay(&mut self) {
3515    self.clean_overlay.confirm.dismiss();
3516    if self.view == View::CleanReport {
3517      self.view = View::List;
3518    }
3519  }
3520
3521  /// Activate the selected Settings field (issue #279): cycle a choice field
3522  /// to its next value (writing + applying live), or arm the numeric input
3523  /// buffer for a `Uint` field. No-op on the read-only `All` tab.
3524  pub fn activate_selected_setting(&mut self) {
3525    let Some(field) = self.config_panel.selected_field() else {
3526      return;
3527    };
3528    match field.kind() {
3529      FieldKind::Choice => {
3530        if let Some(next) = field.next_choice(&self.config) {
3531          self.apply_setting(field, &next);
3532        }
3533      }
3534      FieldKind::Uint | FieldKind::Text => {
3535        let current = field.current(&self.config);
3536        self.config_panel.begin_edit(&current);
3537      }
3538    }
3539  }
3540
3541  /// Commit the in-progress numeric edit (issue #279): write the buffered
3542  /// value to the selected field and apply it live. Clearing the buffer
3543  /// reads as `0` (see [`ConfigPanel::take_edit`]).
3544  pub fn commit_settings_edit(&mut self) {
3545    let Some(field) = self.config_panel.selected_field() else {
3546      self.config_panel.cancel_edit();
3547      return;
3548    };
3549    if let Some(value) = self.config_panel.take_edit() {
3550      // A cleared numeric input is a valid zero; a cleared text input is a
3551      // legitimate empty / unset value.
3552      let value = if field.kind() == FieldKind::Uint && value.is_empty() {
3553        "0".to_string()
3554      } else {
3555        value
3556      };
3557      self.apply_setting(field, &value);
3558    }
3559  }
3560
3561  /// Persist `field = value` into the active layer's TOML file and apply the
3562  /// change live (issue #279). The write targets the per-project `.gwm.toml`
3563  /// or the user-global `config.toml` per the panel's layer selector; on
3564  /// success the config is reloaded, the theme re-resolved, the sidebar
3565  /// position re-seeded and the resolved-rows snapshot refreshed so the
3566  /// `All` tab and the source attribution track the edit. Every fallible
3567  /// step routes its error to the status line — no `unwrap` on this path.
3568  pub fn apply_setting(&mut self, field: SettingField, value: &str) {
3569    // A Project-layer write targets `self.workdir/.gwm.toml`. In workspace mode
3570    // with a stale selection that path is the *previously* active repo, so
3571    // refuse rather than write settings into the wrong repo (#304). Global-layer
3572    // edits are repo-independent and stay allowed.
3573    if self.workspace_active_stale && self.config_panel.layer == SettingsLayer::Project {
3574      self.status = "workspace: selected repo is unavailable — can't edit its project config".into();
3575      return;
3576    }
3577    let path = match self.config_panel.layer {
3578      SettingsLayer::Project => self.workdir.join(crate::config::CONFIG_FILE),
3579      SettingsLayer::Global => match self.global_path.clone() {
3580        Some(p) => p,
3581        None => {
3582          self.status = "settings: no global config path (set $XDG_CONFIG_HOME or $HOME)".into();
3583          return;
3584        }
3585      },
3586    };
3587
3588    // Numeric fields write a TOML integer; choices and free text write a
3589    // TOML string, so a value like `123` / `true` in a shell command or
3590    // worktree pattern is preserved as text rather than coerced (review P2).
3591    let write = match field.kind() {
3592      FieldKind::Uint => crate::config_cli::set_value_at(&path, field.key_path(), value),
3593      FieldKind::Choice | FieldKind::Text => crate::config_cli::set_string_at(&path, field.key_path(), value),
3594    };
3595    if let Err(e) = write {
3596      self.status = format!("settings: {}", e);
3597      return;
3598    }
3599
3600    // Reload the merged config so every live read (open mode, confirm
3601    // countdown) and the re-seeded state below reflect the edit.
3602    match Config::load_layered(&self.workdir, self.global_path.as_deref()) {
3603      Ok(cfg) => self.set_active_config(cfg),
3604      Err(e) => {
3605        self.status = format!("settings saved, but reload failed: {}", e);
3606        return;
3607      }
3608    }
3609    // A Global-layer edit changes config for *every* repo, not just the active
3610    // one — refresh each cached `RepoMeta.config` so navigating to another repo
3611    // doesn't restore the pre-edit global value (Codex review #303 P2). A
3612    // Project-layer edit only touched the active repo's `.gwm.toml`, already
3613    // handled by `set_active_config`.
3614    if self.config_panel.layer == SettingsLayer::Global {
3615      self.reload_workspace_repo_configs();
3616    }
3617    match self.config.theme.resolve() {
3618      Ok(theme) => self.theme = theme,
3619      Err(e) => self.status = format!("theme: {}", e),
3620    }
3621    self.apply_sidebar_config();
3622    // A Settings edit can rewrite the patterns themselves, and the field set is
3623    // derived from them (#418) — refresh it here too, or the form keeps asking
3624    // for a token the pattern no longer carries until the next launch.
3625    self.apply_create_form_fields();
3626    if let Ok(rows) = crate::config::resolved_rows(&self.workdir, self.global_path.as_deref()) {
3627      self.config_panel.rows = rows;
3628    }
3629
3630    let mut status = format!(
3631      "set {} = {} ({})",
3632      field.key_path(),
3633      value,
3634      self.config_panel.layer.label()
3635    );
3636    // Surface a shadowed edit: writing global for a key the repo overrides
3637    // leaves the effective value unchanged (repo wins).
3638    if self.config_panel.layer == SettingsLayer::Global
3639      && self.config_panel.field_source(field) == Some(crate::config::ConfigSource::Repo)
3640    {
3641      status.push_str(" — shadowed by .gwm.toml");
3642    }
3643    self.status = status;
3644  }
3645
3646  /// Render the Command Logs transcript as plain text for the clipboard
3647  /// (issue #279, `y`): newest-first, mirroring the overlay's layout
3648  /// (`$ argv`, the outcome line, then the full captured output — not the
3649  /// tail-capped view), entries separated by a blank line. Pure + owned so
3650  /// the format is unit-testable without a clipboard. Empty when no commands
3651  /// have run.
3652  pub fn command_logs_transcript(&self) -> String {
3653    use crate::command_log::CommandStatus;
3654    let mut out = String::new();
3655    for entry in self.command_logs.entries.iter().rev() {
3656      out.push_str(&format!("$ {}\n", entry.command));
3657      let detail = match &entry.status {
3658        CommandStatus::Exited(Some(0)) => format!("→ exit 0 ({} ms)", entry.duration.as_millis()),
3659        CommandStatus::Exited(Some(code)) => format!("→ exit {} ({} ms)", code, entry.duration.as_millis()),
3660        CommandStatus::Exited(None) => format!("→ terminated ({} ms)", entry.duration.as_millis()),
3661        CommandStatus::Spawn => "✗ failed to spawn".to_string(),
3662      };
3663      out.push_str(&format!("  {}\n", detail));
3664      for line in entry.output.lines() {
3665        out.push_str(&format!("    {}\n", line));
3666      }
3667      out.push('\n');
3668    }
3669    out.trim_end().to_string()
3670  }
3671
3672  /// Scroll the help overlay down one row, clamped to the renderer-published
3673  /// `help_max_scroll` so it never scrolls past the last line.
3674  pub fn help_scroll_down(&mut self) {
3675    self.help_scroll = (self.help_scroll + 1).min(self.help_max_scroll);
3676  }
3677
3678  /// Scroll the help overlay up one row, clamped at the top.
3679  pub fn help_scroll_up(&mut self) {
3680    self.help_scroll = self.help_scroll.saturating_sub(1);
3681  }
3682
3683  pub fn help_scroll_right(&mut self) {
3684    self.help_x_scroll = (self.help_x_scroll + 1).min(self.help_max_x_scroll);
3685  }
3686
3687  pub fn help_scroll_left(&mut self) {
3688    self.help_x_scroll = self.help_x_scroll.saturating_sub(1);
3689  }
3690
3691  /// Path to launch lazygit on, or `None` if nothing selected or lazygit is missing.
3692  /// The caller drives the actual TUI suspension/restoration around the spawn.
3693  ///
3694  /// Retained for callers that still want the legacy "lazygit only"
3695  /// path; new code should go through [`Self::prepare_git_tui`], which
3696  /// honours the configurable `[git_tui]` block (issue #75).
3697  pub fn launch_lazygit(&mut self) -> Option<PathBuf> {
3698    let path = self.selected()?.path.clone();
3699    if which::which("lazygit").is_err() {
3700      self.status = "lazygit not found in PATH".into();
3701      return None;
3702    }
3703    Some(path)
3704  }
3705
3706  // ---- Configurable launchers (issue #75) ---------------------------------
3707
3708  /// Build the [`LauncherPlan`] for the `l` keybinding. Reads
3709  /// `[git_tui]` from `.gwm.toml` (default `lazygit -p {path}`
3710  /// fullscreen=true) and expands the `{path}` placeholder against
3711  /// the selected worktree. Returns `None` (and sets a status hint)
3712  /// when nothing is selected or the template is malformed.
3713  pub fn prepare_git_tui(&mut self) -> Option<LauncherPlan> {
3714    let Some(wt) = self.selected().cloned() else {
3715      self.status = "nothing selected".into();
3716      return None;
3717    };
3718    let resolved = self.config.git_tui.resolved();
3719    let ctx = LauncherContext {
3720      worktree_path: &wt.path,
3721      base: None,
3722      head: None,
3723      repo_workdir: Some(&self.workdir),
3724    };
3725    match launcher::expand_command(&resolved.command, &ctx) {
3726      Ok(expanded) => Some(LauncherPlan {
3727        expanded,
3728        cwd: wt.path,
3729        fullscreen: resolved.fullscreen,
3730        base: None,
3731      }),
3732      Err(e) => {
3733        self.status = format!("git_tui template error: {}", e);
3734        None
3735      }
3736    }
3737  }
3738
3739  /// Build the [`LauncherPlan`] for the `R` keybinding. Implements the
3740  /// full review contract from issue #75:
3741  ///
3742  /// 1. `[review]` must resolve to a concrete launcher (`command`
3743  ///    set, or `tool = "<preset>"` matched).
3744  /// 2. The selected worktree must carry a branch name.
3745  /// 3. The review base is resolved via the documented chain (upstream
3746  ///    → `gwm-base` → `[review].default_base` → `"dev"` → `"main"`).
3747  /// 4. When `skip_when_no_changes` is on (default), a zero
3748  ///    `git rev-list --count {base}..HEAD` short-circuits with a
3749  ///    status-bar hint naming the base.
3750  /// 5. The template is expanded; `{diff}` lazily materialises a
3751  ///    tempfile so unused placeholders never spawn `git diff`.
3752  pub fn prepare_review(&mut self) -> Option<LauncherPlan> {
3753    let resolved = match self.config.review.resolved() {
3754      Some(r) => r,
3755      None => {
3756        self.status = "review tool not configured — set [review] in .gwm.toml".into();
3757        return None;
3758      }
3759    };
3760    let Some(wt) = self.selected().cloned() else {
3761      self.status = "nothing selected".into();
3762      return None;
3763    };
3764    let Some(head) = wt.branch.clone() else {
3765      self.status = "selected worktree has no branch — cannot review".into();
3766      return None;
3767    };
3768
3769    let base = launcher::resolve_review_base(&self.repo, &head, self.config.review.default_base.as_deref());
3770
3771    if self.config.review.skip_when_no_changes {
3772      let n = launcher::count_commits_ahead(&wt.path, &base, "HEAD");
3773      if n == 0 {
3774        self.status = format!("no changes to review (already at {})", base);
3775        return None;
3776      }
3777    }
3778
3779    let ctx = LauncherContext {
3780      worktree_path: &wt.path,
3781      base: Some(&base),
3782      head: Some(&head),
3783      repo_workdir: Some(&self.workdir),
3784    };
3785    match launcher::expand_command(&resolved.command, &ctx) {
3786      Ok(expanded) => {
3787        if self.config.review.has_shadowed_tool() {
3788          self.status = format!("review: command overrides tool — running {}", base);
3789        } else {
3790          self.status = format!("review: {} vs {}", head, base);
3791        }
3792        Some(LauncherPlan {
3793          expanded,
3794          cwd: wt.path,
3795          fullscreen: resolved.fullscreen,
3796          base: Some(base),
3797        })
3798      }
3799      Err(e) => {
3800        self.status = format!("review template error: {}", e);
3801        None
3802      }
3803    }
3804  }
3805
3806  pub fn selected(&self) -> Option<&WorktreeInfo> {
3807    // The visible list is the filtered subset, so the table state's index is
3808    // into `filtered_indices()`, not the raw `worktrees` vec. Resolving the
3809    // selection means hopping through the filter map.
3810    //
3811    // `selected` keeps its `&self` signature so callers holding a
3812    // shared borrow (e.g. `ui.rs` render path, `copy_path_to_status`)
3813    // don't have to upgrade. `snapshot_indices` reads the cache when
3814    // it's warm (which the per-frame render path guarantees, since
3815    // the table renderer calls `filtered_indices` first) and falls
3816    // back to a fresh compute when it isn't.
3817    let i = self.list_state.selected()?;
3818    let filtered = self.filter.snapshot_indices(&self.worktrees, fuzzy_match_indices);
3819    let original = *filtered.get(i)?;
3820    self.worktrees.get(original)
3821  }
3822
3823  pub fn copy_path_to_status(&mut self) {
3824    if let Some(w) = self.selected() {
3825      self.status = format!("path: {}", w.path.display());
3826    }
3827  }
3828
3829  /// Reveal the selected worktree's directory in the OS file manager.
3830  /// macOS: `open`, Linux: `xdg-open`, Windows: `explorer`. Used by
3831  /// `resolve_open_target` when the config picks `mode = "finder"`,
3832  /// and by the event loop directly to spawn the opener.
3833  pub fn open_selected_in_finder(&mut self) {
3834    let path = match self.selected() {
3835      Some(w) => w.path.clone(),
3836      None => {
3837        self.status = "nothing selected".into();
3838        return;
3839      }
3840    };
3841    let opener = if cfg!(target_os = "macos") {
3842      "open"
3843    } else if cfg!(target_os = "windows") {
3844      "explorer"
3845    } else {
3846      "xdg-open"
3847    };
3848    match std::process::Command::new(opener).arg(&path).spawn() {
3849      Ok(_) => self.status = format!("opened {} in {}", path.display(), opener),
3850      Err(e) => self.status = format!("failed to open {}: {}", path.display(), e),
3851    }
3852  }
3853
3854  /// Return the path that the `Y: yank-path` key should push into the
3855  /// system clipboard, or `None` when nothing is selected. Pure — the
3856  /// shell-out is handled by the event loop.
3857  pub fn yank_selected_path(&self) -> Option<PathBuf> {
3858    self.selected().map(|w| w.path.clone())
3859  }
3860
3861  /// Return the branch name for the `y: yank-branch-name` key (#290).
3862  pub fn yank_selected_branch(&self) -> Option<String> {
3863    self.selected()?.branch.clone()
3864  }
3865
3866  /// Return the worktree slug/name for the `w: yank-worktree-name` key (#290).
3867  pub fn yank_selected_worktree_name(&self) -> Option<String> {
3868    self.selected().map(|w| w.name.clone())
3869  }
3870
3871  /// Signal the event loop to print the selected worktree path to stdout
3872  /// before quitting (`e: exit-to-worktree`, #290). The loop checks
3873  /// `should_exit_to` after `can_quit_now` to emit the path.
3874  pub fn exit_to_worktree(&mut self) {
3875    let Some(path) = self.selected().map(|w| w.path.clone()) else {
3876      self.status = "no worktree selected".into();
3877      return;
3878    };
3879    self.should_exit_to = Some(path);
3880    self.should_quit = true;
3881  }
3882
3883  /// Request an off-thread `git pull` of the selected worktree's branch
3884  /// (#290). Coalesces if a pull is already in flight, and refuses to start
3885  /// while a *different* mutating task (sync / bootstrap / push / rename /
3886  /// create / delete) runs in the same worktree (Codex review on PR #292).
3887  pub fn request_pull(&mut self) {
3888    let Some((path, name)) = self.selected().map(|w| (w.path.clone(), w.name.clone())) else {
3889      self.status = "no worktree selected".into();
3890      return;
3891    };
3892    if self.tasks.has_mutating_task_in_flight() && !self.tasks.is_loading(TaskKind::Pull) {
3893      self.status = self.busy_mutation_status("pulling");
3894      return;
3895    }
3896    let Some(generation) = self.tasks.request(TaskKind::Pull) else {
3897      return;
3898    };
3899    self.spinner.reset();
3900    self.status = TaskKind::Pull.loading_label().into();
3901    self.spawn_pull(generation, path, name);
3902  }
3903
3904  /// Status line shown when a mutating verb is pressed while another mutating
3905  /// task is in flight. `action` is the gerund of the blocked verb
3906  /// (e.g. "pulling", "pushing").
3907  fn busy_mutation_status(&self, action: &str) -> String {
3908    match self.tasks.mutating_loading_label() {
3909      Some(label) => format!("finish {} before {}", label.trim_end_matches('…'), action),
3910      None => format!("finish current task before {}", action),
3911    }
3912  }
3913
3914  fn spawn_pull(&self, generation: u64, path: PathBuf, name: String) {
3915    let tx = self.task_tx.clone();
3916    std::thread::spawn(move || {
3917      let mut cmd = std::process::Command::new("git");
3918      cmd.args(["pull"]).current_dir(&path);
3919      // Route through the command-log chokepoint so `git pull` lands in the
3920      // Command Logs modal (#290) — a user-triggered mutating op the user
3921      // expects to find in the transcript.
3922      let result = crate::command_log::run_logged(&mut cmd, "git pull".to_string())
3923        .map_err(|e| e.to_string())
3924        .and_then(|out| {
3925          if out.status.success() {
3926            Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
3927          } else {
3928            Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
3929          }
3930        });
3931      let _ = tx.send(TaskMsg::Pull(generation, name, result));
3932    });
3933  }
3934
3935  /// Request an off-thread `git push` of the selected worktree's branch
3936  /// (#290). Coalesces if a push is already in flight, and refuses to start
3937  /// while a *different* mutating task runs in the same worktree (Codex review
3938  /// on PR #292).
3939  pub fn request_push(&mut self) {
3940    let Some((path, name)) = self.selected().map(|w| (w.path.clone(), w.name.clone())) else {
3941      self.status = "no worktree selected".into();
3942      return;
3943    };
3944    if self.tasks.has_mutating_task_in_flight() && !self.tasks.is_loading(TaskKind::Push) {
3945      self.status = self.busy_mutation_status("pushing");
3946      return;
3947    }
3948    let Some(generation) = self.tasks.request(TaskKind::Push) else {
3949      return;
3950    };
3951    self.spinner.reset();
3952    self.status = TaskKind::Push.loading_label().into();
3953    self.spawn_push(generation, path, name);
3954  }
3955
3956  fn spawn_push(&self, generation: u64, path: PathBuf, name: String) {
3957    let tx = self.task_tx.clone();
3958    std::thread::spawn(move || {
3959      let mut cmd = std::process::Command::new("git");
3960      cmd.args(["push"]).current_dir(&path);
3961      // Route through the command-log chokepoint so `git push` lands in the
3962      // Command Logs modal (#290). git writes its progress to stderr, so the
3963      // status line still reads stderr on success.
3964      let result = crate::command_log::run_logged(&mut cmd, "git push".to_string())
3965        .map_err(|e| e.to_string())
3966        .and_then(|out| {
3967          if out.status.success() {
3968            Ok(String::from_utf8_lossy(&out.stderr).trim().to_string())
3969          } else {
3970            Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
3971          }
3972        });
3973      let _ = tx.send(TaskMsg::Push(generation, name, result));
3974    });
3975  }
3976
3977  /// Open the rename modal for the selected worktree (`c`, #290). Reuses the
3978  /// Create form (Type / Issue / Desc) pre-filled by parsing the current
3979  /// branch name, so renaming is symmetric with creating. A branch that does
3980  /// not match the `<type>/#<issue>-<desc>` pattern can't be decomposed into
3981  /// the form, so the modal refuses to open and explains why.
3982  pub fn enter_edit_worktree(&mut self) {
3983    // Issue #479 replaces an accidental protection with a deliberate one. The
3984    // main checkout's branch is normally unparseable (`main`, `dev`), so the
3985    // refusal below used to turn this modal away from it — for the wrong
3986    // reason, but with the right result. Free-form mode parses nothing, so that
3987    // side effect is gone: state the guard, the same shape
3988    // `enter_confirm_delete` already uses. Renaming the main worktree means
3989    // renaming the repo's default branch, and `git worktree move` cannot move
3990    // the main checkout anyway.
3991    if self.selected().is_some_and(|w| w.is_main) {
3992      self.status = "cannot rename the main worktree".into();
3993      return;
3994    }
3995    let Some((branch, path)) = self
3996      .selected()
3997      .and_then(|w| w.branch.clone().map(|b| (b, w.path.clone())))
3998    else {
3999      self.status = "no branch to rename (detached HEAD or nothing selected)".into();
4000      return;
4001    };
4002    // Issue #417: the form rebuilds the triple this repo's own
4003    // `worktree.branch_pattern` writes, so the branch is read back with it.
4004    // `self.repo_name`, not the workdir basename: in a workspace, two repos
4005    // sharing a basename are disambiguated for display (#304) and every
4006    // formatter call in this flow expands `{repo}` with that name. Parser and
4007    // formatter agreeing is the whole of #417, so they read the same name.
4008    //
4009    // Issue #478: the directory is read too. A segment `branch_pattern`
4010    // freezes is not in the branch, and `path_pattern` may still carry what
4011    // `gwm create` was given — rebuilding from the branch alone renamed the
4012    // directory's own components on every edit.
4013    let Some(spec) = crate::naming::worktree_spec(
4014      &self.config,
4015      &self.repo_name,
4016      &branch,
4017      path.file_name().and_then(|name| name.to_str()),
4018    ) else {
4019      // Issue #416 refused here: a name the user chose on purpose has none of
4020      // the `<type>/#<issue>-<desc>` triple the form rebuilds, so the form said
4021      // it did not apply. Issue #479 supplies the missing mode instead — the
4022      // form now collects a free-form name too, which is exactly the shape this
4023      // worktree already has, so there is nothing left to turn away.
4024      //
4025      // Prefilled with the current branch verbatim: the common edit is a small
4026      // one (`spike-redis` becoming `spike-valkey`), and it is also the only
4027      // non-guess available, since a free-form name carries no segments.
4028      //
4029      // Issue #417 deliberately did NOT fold "type not configured" into this
4030      // arm: `{type}` stays `[a-z]+`, so `zzz/#7-thing` still parses and the
4031      // type-index lookup below refuses it with the precise reason.
4032      self.create_form.reset();
4033      self.create_form.mode = Mode::Freeform;
4034      self.create_form.name = branch.clone();
4035      self.create_form.field = Field::Name;
4036      self.edit_original_branch = Some(branch);
4037      self.edit_original_path = Some(path);
4038      self.edit_failure = None;
4039      self.view = View::Edit;
4040      return;
4041    };
4042    // **The form must not open on a segment whose current value it cannot show.**
4043    //
4044    // Issue #418 rescoped the guard that stands here rather than removing it,
4045    // and both earlier readings of it were wrong in the same direction. The
4046    // original (Codex review on PR #476) refused whenever a segment came back
4047    // empty, which took the form away from a repo whose patterns simply do not
4048    // carry that segment: nothing writes it, nothing asks for it, its absence
4049    // is not a dead end. Deleting the guard outright then went too far the
4050    // other way (Codex review on PR #492, second pass): `worktree_spec` reads
4051    // the branch and the directory name and never parses `base`, so a segment
4052    // carried only by `base` comes back empty **while its value sits on disk**.
4053    // Under `base = ".../wt/{type}"`, a worktree at `.../wt/fix/my-desc` would
4054    // have opened with the selector defaulted to the first configured type, and
4055    // submitting without touching it would have moved the worktree to
4056    // `.../wt/feat/`. "Not recovered" is not "not there".
4057    //
4058    // So the rule is neither "empty" nor "absent from the patterns" but the
4059    // conjunction: a segment some pattern **writes** that the parse did **not**
4060    // recover. There the form would show a default it did not read and the
4061    // submit would write it over the real one.
4062    let required = self.required_segments();
4063    if let Some(missing) = required.iter().find(|segment| match **segment {
4064      "type" => spec.type_.is_empty(),
4065      "issue" => spec.issue.is_empty(),
4066      _ => spec.desc.is_empty(),
4067    }) {
4068      self.status = format!(
4069        "'{}' carries {{{}}} only where this form cannot read it back (check worktree.base), so renaming would overwrite it — rename with git, or write {{{}}} into worktree.branch_pattern or worktree.path_pattern",
4070        crate::naming::sanitise_for_terminal(&branch),
4071        missing,
4072        missing
4073      );
4074      return;
4075    }
4076    // Refuse rather than silently preselect type index 0: a branch whose
4077    // parsed type isn't configured (config change, manual branch) would
4078    // otherwise be renamed to the first configured type on Enter (Codex
4079    // review on PR #292). Only reachable when some pattern carries `{type}`,
4080    // the guard above having taken the unrecovered case; where none does, the
4081    // value is discarded by every expansion and index 0 is inert.
4082    let type_index = match self.branch_types.iter().position(|t| t.name == spec.type_) {
4083      Some(index) => index,
4084      None if !required.contains(&"type") => 0,
4085      None => {
4086        self.status = format!("branch type '{}' is not configured; can't rename here", spec.type_);
4087        return;
4088      }
4089    };
4090    self.create_form.reset();
4091    self.create_form.type_index = type_index;
4092    self.create_form.issue = spec.issue;
4093    self.create_form.desc = spec.desc;
4094    // The last field in pattern order: the usual rename edits the trailing
4095    // description, and naming `Field::Desc` here focused an input the renderer
4096    // does not draw on a pattern without one (#418).
4097    self.create_form.field = self.create_form.last_field();
4098    self.edit_original_branch = Some(branch);
4099    self.edit_original_path = Some(path);
4100    self.edit_failure = None;
4101    self.view = View::Edit;
4102  }
4103
4104  /// The [`WorktreeName`] the form currently describes, in whichever mode it is
4105  /// in (#479). The rename target is built the same way the create target is,
4106  /// so the four conversions of issue #479 are two code paths, not four:
4107  /// `WorktreeName` already knows how each shape becomes a branch and a
4108  /// directory.
4109  fn worktree_name_from_form(&self) -> std::result::Result<WorktreeName, String> {
4110    match self.create_form.mode {
4111      Mode::Freeform => WorktreeName::freeform(&self.create_form.name).map_err(|e| e.to_string()),
4112      Mode::Structured => {
4113        let type_ = self
4114          .branch_types
4115          .get(self.create_form.type_index)
4116          .map(|t| t.name.clone())
4117          .unwrap_or_default();
4118        // Validated only against the segments the patterns actually carry
4119        // (#418): a `{type}/{desc}` repo writes no issue number anywhere, so
4120        // refusing an empty one refused a form the user had filled completely.
4121        BranchSpec::new_with_required(
4122          type_,
4123          self.create_form.issue.clone(),
4124          self.create_form.desc.clone(),
4125          &self.branch_types,
4126          &self.required_segments(),
4127        )
4128        .map(WorktreeName::Structured)
4129        .map_err(|e| e.to_string())
4130      }
4131    }
4132  }
4133
4134  /// What the rename would write: `(branch, directory name)`.
4135  ///
4136  /// Public because the renderer needs it. The preview and the submit have to
4137  /// derive from **one** place or they drift, and drifting is not hypothetical:
4138  /// both live previews used to expand a hardcoded `<type>/#<issue>-<desc>`
4139  /// while the submit expanded the repo's real patterns, so the modal showed a
4140  /// branch it was not going to write (found by hand on #476). Free-form mode
4141  /// is the same trap one mode over, since there the branch is the name and no
4142  /// pattern is expanded at all.
4143  ///
4144  /// `Err` carries the reason the form cannot compose a target yet — an
4145  /// incomplete triple, a refused free-form name, or a `base` that uses a
4146  /// placeholder a free-form name has no value for.
4147  pub fn edit_target(&self) -> std::result::Result<(String, String), String> {
4148    let name = self.worktree_name_from_form()?;
4149    let branch = name
4150      .branch_name(&self.config.worktree, &self.repo_name)
4151      .map_err(|e| e.to_string())?;
4152    let dirname = name
4153      .worktree_dirname(&self.config.worktree, &self.repo_name)
4154      .map_err(|e| e.to_string())?;
4155    Ok((branch, dirname))
4156  }
4157
4158  /// Seed the mode `toggle_mode` is about to switch *into*, for the rename
4159  /// modal (#479). Called before the flip, so `self.create_form.mode` is still
4160  /// the mode being left.
4161  ///
4162  /// Only ever fills an **empty** buffer: #416 keeps both modes' buffers side
4163  /// by side precisely so a round trip loses nothing, and overwriting what the
4164  /// user already typed would defeat that.
4165  ///
4166  /// Create seeds nothing — there is no worktree to seed from.
4167  ///
4168  /// Leaving structured, the free-form name is seeded with the current branch
4169  /// verbatim: it is the only non-guess available, and the common edit is a
4170  /// small one.
4171  ///
4172  /// Leaving free-form, the description is seeded with `kebab` of the name and
4173  /// the issue is left empty, because a free-form name carries no issue number
4174  /// and inventing one would be a guess. `kebab`'s output is by construction
4175  /// either `DESC_RE`-valid or empty, so this seed can never dead-end the form
4176  /// on a value it would then refuse to submit.
4177  ///
4178  /// Truncated to `MAX_DESC_LEN` (Codex review on PR #485): a free-form name
4179  /// may run to `MAX_DIR_COMPONENT_BYTES`, `push_char` is the only other place
4180  /// that bound is applied, and `BranchSpec` has no length check to catch the
4181  /// overflow downstream — so seeding unbounded wrote a description no
4182  /// keystroke could have produced, and the cap is what keeps
4183  /// `<type>/#<issue>-<desc>` inside git's ref limit.
4184  ///
4185  /// The **type** is not seeded and is not left unset either: it stays on
4186  /// whatever the selector shows, which is the first configured type on a form
4187  /// that was just opened. That is the create form's own contract, the value is
4188  /// on screen in the selector, and the preview spells out the branch it
4189  /// produces — so a promotion into the pattern is stated rather than silent.
4190  fn seed_toggled_mode(&mut self) {
4191    if self.view != View::Edit {
4192      return;
4193    }
4194    match self.create_form.mode {
4195      Mode::Structured if self.create_form.name.is_empty() => {
4196        if let Some(branch) = self.edit_original_branch.clone() {
4197          self.create_form.name = branch;
4198        }
4199      }
4200      Mode::Freeform if self.create_form.desc.is_empty() => {
4201        self.create_form.desc = crate::naming::kebab(&self.create_form.name)
4202          .chars()
4203          .take(crate::tui::state::create_form::MAX_DESC_LEN)
4204          .collect();
4205      }
4206      _ => {}
4207    }
4208  }
4209
4210  /// `true` while the async rename worker is in flight (#290). The run loop
4211  /// swallows input in `View::Edit` while this holds, mirroring create.
4212  pub fn is_edit_worktree_loading(&self) -> bool {
4213    self.tasks.is_loading(TaskKind::EditWorktree)
4214  }
4215
4216  /// Cancel the rename modal (`Esc`): drop the captured original branch/path
4217  /// and return to the list without touching git.
4218  pub fn cancel_edit_worktree(&mut self) {
4219    self.edit_original_branch = None;
4220    self.edit_original_path = None;
4221    self.edit_failure = None;
4222    self.create_form.reset();
4223    self.view = View::List;
4224  }
4225
4226  /// Whether this submit changes a segment **nothing writes**, setting
4227  /// `edit_failure` with the reason when it does (#417, Codex review on
4228  /// PR #476). Structured mode only: see the call site for why free-form has
4229  /// no segments to freeze.
4230  ///
4231  /// Returns its verdict rather than leaving the caller to read `edit_failure`
4232  /// back (Codex review on PR #485). That field survives a failed submit, so
4233  /// reading it as "did the guard refuse" also caught every *earlier* failure
4234  /// and stopped a submit the user had just corrected, wedging the form shut
4235  /// until it was closed and reopened.
4236  fn refuse_unwritable_segment_change(&mut self, type_: &str) -> bool {
4237    // Issue #417 / Codex review on PR #476: a segment **nothing writes** is not
4238    // editable here, because there is nowhere to put the new value. The submit
4239    // would rebuild the same branch at the same path and close the form having
4240    // changed nothing, so say no instead.
4241    //
4242    // The question is the *formatter's*, not the parser's — asking the parser
4243    // is what made the first two versions of this guard wrong. It is not "can a
4244    // new value be read back", it is "will a new value be written anywhere", and
4245    // `expand_placeholders` writes a token wherever it appears. So the test is
4246    // whether any of the three patterns it expands carries that token:
4247    //
4248    // - `branch_pattern`, the obvious one;
4249    // - `path_pattern` (Kylian, validating by hand): `feat/#{issue}-{desc}` will
4250    //   say `feat` whatever the form holds, so under that config the *directory*
4251    //   is where this worktree's type lives, and refusing meant a worktree
4252    //   created as `fix` could never become `docs`;
4253    // - `[worktree].base` (Codex review, tenth pass): `worktree_path` feeds it
4254    //   the triple too, so a `base` of `.../{type}` sorts worktrees into
4255    //   per-type directories and changing the type moves the worktree between
4256    //   them.
4257    //
4258    // In the last two cases the branch does not change at all, which
4259    // `rename_worktree` handles as a path-only edit — it skips every ref
4260    // mutation, local and remote — and the preview states it on screen by
4261    // showing the branch unchanged.
4262    //
4263    // Scoped to a segment the user actually changed, so `feat/#{issue}-{desc}`,
4264    // whose rename worked before #417, keeps renaming its issue and description.
4265    //
4266    // Compared against what the form was **opened with**, not against the
4267    // pattern's literal (#478): that value may have come from the worktree's
4268    // directory rather than from the pattern. And against the *form's* fields
4269    // rather than the `BranchSpec`, because a frozen description need not be
4270    // canonical — `DESC_RE` accepts `fixed-`, and `kebab` would strip that
4271    // trailing dash on the way into the spec, making every submit look like a
4272    // change and locking the form shut.
4273    // The same predicate `editable_segments` computes for the form's field set
4274    // (#418), asked once rather than open-coded twice: this guard and the field
4275    // set have to agree on "does any pattern write this", and two spellings of
4276    // one question are two things to keep in step.
4277    //
4278    // ⚠️ I wrote here that this made the guard unreachable in structured mode,
4279    // reasoning that the form presents no field for a segment nothing writes so
4280    // its value cannot differ. Wrong, and the sixth review pass found it: the
4281    // buffer behind a hidden field keeps its default, which is not the parsed
4282    // value, so the comparison below fired on a change nobody made. The skip
4283    // added there is what actually makes the two agree.
4284    let written = self.required_segments();
4285    let writes = |segment: &str| written.contains(&segment);
4286    let opened_with = self.edit_original_branch.as_deref().and_then(|branch| {
4287      crate::naming::worktree_spec(
4288        &self.config,
4289        &self.repo_name,
4290        branch,
4291        self
4292          .edit_original_path
4293          .as_ref()
4294          .and_then(|path| path.file_name())
4295          .and_then(|name| name.to_str()),
4296      )
4297    });
4298    if let Some(opened_with) = opened_with.as_ref() {
4299      for segment in ["type", "issue", "desc"] {
4300        if writes(segment) {
4301          continue;
4302        }
4303        // A segment the form does not present has no value to defend (Codex
4304        // review on PR #492, sixth pass, and it disproves the note two commits
4305        // back claiming this guard had become unreachable in structured mode).
4306        // The buffers behind a hidden field keep their defaults — `type_index`
4307        // points at the first configured type — so comparing them against what
4308        // the branch parsed reads as a change the user never made, and refuses
4309        // every structured rename on a pattern set that omits `{type}`. The
4310        // field is not on screen, so nothing clears it either.
4311        if !self.create_form.fields().contains(&match segment {
4312          "type" => Field::Type,
4313          "issue" => Field::Issue,
4314          _ => Field::Desc,
4315        }) {
4316          continue;
4317        }
4318        let (submitted, was) = match segment {
4319          "type" => (type_, opened_with.type_.as_str()),
4320          "issue" => (self.create_form.issue.as_str(), opened_with.issue.as_str()),
4321          _ => (self.create_form.desc.as_str(), opened_with.desc.as_str()),
4322        };
4323        if submitted != was {
4324          // No value in the message, on purpose: `LoaderWidget` renders one
4325          // unwrapped line, so a message whose length follows a user-supplied
4326          // value clips at an arbitrary point. This one is a fixed 36
4327          // characters whatever the branch holds, and the value it would have
4328          // quoted is on screen anyway, in the `From :` row above (found
4329          // validating by hand).
4330          let _ = was;
4331          self.edit_failure = Some(format!("branch_pattern has no {{{}}} to write", segment));
4332          return true;
4333        }
4334      }
4335    }
4336    false
4337  }
4338
4339  /// Submit the rename from the `View::Edit` modal (#290). Composes the new
4340  /// branch name + worktree path from the form, then spawns an off-thread
4341  /// worker that renames the local branch (`git branch -m`), the remote
4342  /// branch when it exists (`git push origin :<old> <new>:<new>` + re-track),
4343  /// and moves the worktree directory (`git worktree move`). A no-op rename
4344  /// (nothing changed) just closes the modal.
4345  pub fn submit_edit_worktree(&mut self) -> Result<()> {
4346    let type_ = self
4347      .branch_types
4348      .get(self.create_form.type_index)
4349      .map(|t| t.name.clone())
4350      .unwrap_or_default();
4351    // Issue #479: the frozen-segment guard below is about segments, and a
4352    // free-form name has none — the branch is the name, no pattern is expanded,
4353    // so nothing can be frozen out of reach. Skipping it is stated here rather
4354    // than left to fall out of `opened_with` being `None` for an unparseable
4355    // branch, which is how it would happen by accident.
4356    //
4357    // Converting a free-form worktree *into* the pattern therefore bypasses the
4358    // guard, and that is right: the guard refuses to change a value the form was
4359    // opened with when nothing can write it, and a worktree opened free-form was
4360    // opened with no such value to contradict.
4361    if self.create_form.mode == Mode::Structured && self.refuse_unwritable_segment_change(&type_) {
4362      return Ok(());
4363    }
4364    // Issue #479: composed through `WorktreeName`, the same seam
4365    // `submit_create` uses, so free-form and structured targets are built by
4366    // one mechanism and the preview can show exactly what this will write.
4367    let name = match self.worktree_name_from_form() {
4368      Ok(n) => n,
4369      Err(e) => {
4370        self.edit_failure = Some(e);
4371        return Ok(());
4372      }
4373    };
4374    // Reported in the form rather than returned as `Err`: these are user- and
4375    // config-driven refusals (an incomplete triple, a name git will not take, a
4376    // `base` a free-form name has no value for), and an `Err` out of here tears
4377    // down the alternate screen instead of telling the user what to fix.
4378    let (new_branch, new_name) = match self.edit_target() {
4379      Ok(target) => target,
4380      Err(e) => {
4381        self.edit_failure = Some(e);
4382        return Ok(());
4383      }
4384    };
4385    let new_path = match name.worktree_path(&self.config.worktree, &self.repo_name, &self.workdir) {
4386      Ok(p) => p,
4387      Err(e) => {
4388        self.edit_failure = Some(e.to_string());
4389        return Ok(());
4390      }
4391    };
4392
4393    let Some(old_branch) = self.edit_original_branch.clone() else {
4394      self.cancel_edit_worktree();
4395      return Ok(());
4396    };
4397    let Some(old_path) = self.edit_original_path.clone() else {
4398      self.cancel_edit_worktree();
4399      return Ok(());
4400    };
4401
4402    // Nothing changed — close without shelling out to git.
4403    if new_branch == old_branch && new_path == old_path {
4404      self.status = "no change".into();
4405      self.cancel_edit_worktree();
4406      return Ok(());
4407    }
4408
4409    if self.tasks.has_mutating_task_in_flight() {
4410      if let Some(label) = self.tasks.mutating_loading_label() {
4411        self.status = format!("finish {} before renaming", label.trim_end_matches('…'));
4412      } else {
4413        self.status = "finish current task before renaming".into();
4414      }
4415      return Ok(());
4416    }
4417    let Some(generation) = self.tasks.request(TaskKind::EditWorktree) else {
4418      return Ok(());
4419    };
4420    self.edit_failure = None;
4421    self.spinner.reset();
4422    self.status = TaskKind::EditWorktree.loading_label().into();
4423    self.spawn_edit_worktree(
4424      generation,
4425      old_branch,
4426      old_path,
4427      new_branch,
4428      new_path,
4429      new_name,
4430      self.workdir.clone(),
4431    );
4432    Ok(())
4433  }
4434
4435  // The rename worker takes each piece of the edit as an owned, `Send`
4436  // parameter because only owned data may cross the `thread::spawn` boundary
4437  // (`self` / `git2::Repository` are not `Send`) — the same flat signature the
4438  // other `spawn_*` workers use. Bundling them into a struct would just add an
4439  // indirection between the call site and the move-closure for no gain, so the
4440  // arg count is deliberate.
4441  #[allow(clippy::too_many_arguments)]
4442  fn spawn_edit_worktree(
4443    &self,
4444    generation: u64,
4445    old_branch: String,
4446    old_path: PathBuf,
4447    new_branch: String,
4448    new_path: PathBuf,
4449    new_name: String,
4450    workdir: PathBuf,
4451  ) {
4452    let tx = self.task_tx.clone();
4453    std::thread::spawn(move || {
4454      let result = crate::worktree::rename_worktree(&workdir, &old_path, &old_branch, &new_path, &new_branch)
4455        .map(|remote_renamed| EditWorktreeResult {
4456          new_branch,
4457          new_path,
4458          new_name,
4459          remote_renamed,
4460        })
4461        .map_err(|e| e.to_string());
4462      let _ = tx.send(TaskMsg::EditWorktree(generation, result));
4463    });
4464  }
4465
4466  /// Open the selected worktree in a new multiplexer pane/tab (`t`, #290).
4467  /// Detects tmux / zellij at runtime via environment variables; prints a
4468  /// status message when no supported multiplexer is active.
4469  pub fn open_in_mux_pane(&mut self) {
4470    use crate::multiplexer::{build_tmux_command, build_zellij_command, detect_tmux, detect_zellij, SpawnMode};
4471    let Some(w) = self.selected() else {
4472      self.status = "no worktree selected".into();
4473      return;
4474    };
4475    let path = w.path.clone();
4476    let name = w.name.clone();
4477    // `mux_pane` promises a pane, so split the current pane (tmux
4478    // `split-window` / zellij `new-pane`) rather than opening a new
4479    // window/tab (Codex review on PR #292).
4480    let cmd = if detect_tmux(std::env::var("TMUX").ok()) {
4481      build_tmux_command(&name, &path, SpawnMode::Split)
4482    } else if detect_zellij(std::env::var("ZELLIJ").ok()) {
4483      build_zellij_command(&name, &path, SpawnMode::Split)
4484    } else {
4485      self.status = "no multiplexer detected ($TMUX / $ZELLIJ not set)".into();
4486      return;
4487    };
4488    let bin = cmd[0].as_str();
4489    match std::process::Command::new(bin).args(&cmd[1..]).spawn() {
4490      Ok(_) => self.status = format!("opened {} in new pane", name),
4491      Err(e) => self.status = format!("mux-pane failed: {}", e),
4492    }
4493  }
4494
4495  /// Resolve what the `o` key should do for the currently selected
4496  /// worktree. Returns `None` when nothing is selected (the event loop
4497  /// surfaces a status message in that case). The exact command is
4498  /// resolved once here (config override > env var > hardcoded fallback)
4499  /// so the event loop never has to reason about precedence.
4500  pub fn resolve_open_target(&self) -> Option<OpenTarget> {
4501    let path = self.selected()?.path.clone();
4502    Some(match self.config.tui.open.mode {
4503      TuiOpenMode::Shell => OpenTarget::Shell {
4504        path,
4505        command: resolve_shell_command(&self.config.tui.open),
4506      },
4507      TuiOpenMode::Editor => OpenTarget::Editor {
4508        path,
4509        command: resolve_editor_command(&self.config.tui.open),
4510      },
4511      TuiOpenMode::Finder => OpenTarget::Finder { path },
4512    })
4513  }
4514
4515  pub fn toggle_delete_branch(&mut self) {
4516    self.delete_branch_on_remove = !self.delete_branch_on_remove;
4517    self.status = format!("delete branch on remove: {}", self.delete_branch_on_remove);
4518  }
4519
4520  // ---- Create flow ---------------------------------------------------------
4521
4522  pub fn enter_create(&mut self) {
4523    self.view = View::Create;
4524    self.create_form.reset();
4525    self.create_failure = None;
4526    // Open focused on the first field the user types into rather than the
4527    // cycle-only Type field (#217 UX): the first keypress then edits text
4528    // instead of being a silent no-op on Type. The type keeps its `reset()`
4529    // default and stays reachable via Shift-Tab / the field rotation.
4530    //
4531    // Asked of the form rather than named here (#418): on a pattern that
4532    // writes no issue number, `Field::Issue` focused an input the renderer
4533    // does not draw, so the first keypress went nowhere at all.
4534    self.create_form.field = self.create_form.entry_field();
4535    self.status = format!("{} — esc: cancel", self.structured_form_instruction());
4536  }
4537
4538  pub fn create_next_field(&mut self) {
4539    self.create_form.next_field();
4540  }
4541
4542  pub fn create_prev_field(&mut self) {
4543    self.create_form.prev_field();
4544  }
4545
4546  pub fn create_next_type(&mut self) {
4547    self.create_form.next_type(self.branch_types.len());
4548  }
4549
4550  pub fn create_prev_type(&mut self) {
4551    self.create_form.prev_type(self.branch_types.len());
4552  }
4553
4554  pub fn create_push_char(&mut self, c: char) {
4555    self.create_form.push_char(c);
4556  }
4557
4558  pub fn create_pop_char(&mut self) {
4559    self.create_form.pop_char();
4560  }
4561
4562  /// Handle one key in the create overlay and report what the run loop must
4563  /// do next. Extracted from the inline `View::Create` match (issue #217)
4564  /// so the input path — typing, type cycling, submit/cancel — is
4565  /// unit-testable rather than only reachable through a live terminal.
4566  ///
4567  /// `h` / `l` mirror the `←` / `→` horizontal type selector, but **only**
4568  /// when the Type field is focused; on a text field they are literal input
4569  /// so the letters are never swallowed.
4570  pub fn handle_create_key(&mut self, key: KeyEvent) -> CreateKey {
4571    if self.is_create_worktree_loading() {
4572      return CreateKey::Handled;
4573    }
4574    let on_type = self.create_form.field == Field::Type;
4575    // Typing is RESERVED on the text fields (Codex review #456): a modal
4576    // rebind like `cancel = ["Backspace"]` (or `= ["q"]`) resolved before
4577    // the typing fallback, so it stole the eraser / typed characters.
4578    // Printable keys and Backspace route to the field first — the palette
4579    // convention; modal verbs act through non-printable keys (Ctrl-
4580    // modified chars still fall through to the modal resolution).
4581    if !on_type
4582      && !key
4583        .modifiers
4584        .intersects(crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT)
4585    {
4586      match key.code {
4587        KeyCode::Backspace => {
4588          self.create_pop_char();
4589          return CreateKey::Handled;
4590        }
4591        // Only the field's LEGITIMATE input is reserved: free text on
4592        // description, digits on issue. A non-digit on the issue field
4593        // still reaches the modal resolution (a printable rebind stays
4594        // honoured, the #293 contract), with the digits-only status hint
4595        // as the unresolved fallback below.
4596        KeyCode::Char(c) if self.create_form.field != Field::Issue || c.is_ascii_digit() => {
4597          self.create_push_char(c);
4598          return CreateKey::Handled;
4599        }
4600        _ => {}
4601      }
4602    }
4603    // #219: verbs resolve through the `create` context. The type-cycling
4604    // verbs (`prev_type` / `next_type`, def arrows + h/l) only fire on the
4605    // Type field; on a text field their keys fall through to literal input
4606    // so `h` / `l` are never swallowed while typing a description.
4607    match self.resolve_modal(KeyContext::Create, key) {
4608      Some(ModalAction::CreateCancel) => return CreateKey::Cancel,
4609      Some(ModalAction::CreateNextField) => self.create_next_field(),
4610      Some(ModalAction::CreatePrevField) => self.create_prev_field(),
4611      // Both modal views that collect a name: create (#416) and rename (#479).
4612      // #474 scoped this to create alone because `draw_edit_worktree` did not
4613      // render the `Name` field and `submit_edit_worktree` did not read it, so
4614      // toggling there sent keystrokes into an invisible buffer; #479 supplies
4615      // both halves, so the verb is no longer suppressed.
4616      // Swallowed outside those modals — an explicit empty arm, not a
4617      // guard on the arm below, because a failing guard would fall through
4618      // to the literal-input fallback and type `t` into the description.
4619      Some(ModalAction::CreateToggleMode) if !matches!(self.view, View::Create | View::Edit) => {}
4620      Some(ModalAction::CreateToggleMode) => {
4621        self.seed_toggled_mode();
4622        self.create_form.toggle_mode();
4623        // Name the LIVE binding, and drop the clause when the verb is
4624        // unbound — same contract as the confirm countdown above: never
4625        // advertise a key that does nothing (Codex review on PR #474).
4626        let back = self
4627          .modal_keymap
4628          .primary_key(ModalAction::CreateToggleMode)
4629          .map(|k| format!(" — {k}: "))
4630          .unwrap_or_default();
4631        self.status = match self.create_form.mode {
4632          Mode::Freeform if back.is_empty() => "free-form: name the worktree anything git accepts".into(),
4633          Mode::Freeform => format!(
4634            "free-form: name the worktree anything git accepts{back}back to {}",
4635            self.structured_mode_label()
4636          ),
4637          // The submit field is named from the patterns, not hardcoded to
4638          // `desc` (#418): a pattern without one told the user to press enter
4639          // on a field the form does not present.
4640          Mode::Structured if back.is_empty() => self.structured_form_instruction(),
4641          Mode::Structured => format!("{}{back}free-form", self.structured_form_instruction()),
4642        };
4643      }
4644      Some(ModalAction::CreateSubmit) => {
4645        // The submit field is the last one of the active mode: `Name` in
4646        // free-form (its only field), and in structured mode the last field
4647        // the *patterns* present (#418) rather than `Desc` by name. Naming
4648        // `Desc` made `{type}/#{issue}` a form that could not be submitted at
4649        // all, since Enter then only ever rotated.
4650        let submit_field = match self.create_form.mode {
4651          Mode::Freeform => Field::Name,
4652          Mode::Structured => self.create_form.last_field(),
4653        };
4654        if self.create_form.field == submit_field {
4655          return CreateKey::Submit;
4656        }
4657        self.create_next_field();
4658      }
4659      Some(ModalAction::CreatePrevType) if on_type => self.create_prev_type(),
4660      Some(ModalAction::CreateNextType) if on_type => self.create_next_type(),
4661      _ => match key.code {
4662        KeyCode::Char(c) if self.create_form.field == Field::Issue && !c.is_ascii_digit() => {
4663          self.status = "issue accepts digits only".into();
4664        }
4665        KeyCode::Char(c) if !on_type => self.create_push_char(c),
4666        KeyCode::Backspace if !on_type => self.create_pop_char(),
4667        _ => {}
4668      },
4669    }
4670    CreateKey::Handled
4671  }
4672
4673  pub fn submit_create(&mut self) -> Result<()> {
4674    // Issue #416: free-form validation lands here rather than per keystroke,
4675    // so the user can type through an intermediate state. A rejected name
4676    // keeps the form open with the reason in the status bar — same shape as
4677    // the trust refusal below, and for the same reason (an `Err` would tear
4678    // down the alternate screen).
4679    //
4680    // Composed through the same `worktree_name_from_form` the rename uses
4681    // (#418). It used to build its own `BranchSpec` here, so relaxing
4682    // validation for the patterns' actual segments in one composer left the
4683    // other still demanding a value it would discard — two composers of one
4684    // value drift, and this is the second time on this form (the previews did
4685    // it in #476).
4686    let wt_name = match self.worktree_name_from_form() {
4687      Ok(n) => n,
4688      Err(e) => {
4689        self.status = e;
4690        return Ok(());
4691      }
4692    };
4693    let branch = wt_name.branch_name(&self.config.worktree, &self.repo_name)?;
4694    let dirname = wt_name.worktree_dirname(&self.config.worktree, &self.repo_name)?;
4695    let target = wt_name.worktree_path(&self.config.worktree, &self.repo_name, &self.workdir)?;
4696
4697    // Gate the bootstrap RCE primitive on the TOFU ledger BEFORE
4698    // creating the worktree on disk (issue #95). A refusal here
4699    // leaves the user's disk state untouched — no orphaned
4700    // worktree to clean up. Mirrors `cmd_create` in src/cli.rs.
4701    if let Some(msg) = self.check_trust_for_bootstrap()? {
4702      self.status = msg;
4703      // Stay in the create form so the user can retry after
4704      // approving the config via the CLI gate. Returning Ok here
4705      // (rather than Err) keeps the event loop alive — an Err
4706      // would print to stderr and tear down the alternate screen.
4707      return Ok(());
4708    }
4709
4710    if self.tasks.has_mutating_task_in_flight() {
4711      if let Some(label) = self.tasks.mutating_loading_label() {
4712        self.status = format!("finish {} before creating worktree", label.trim_end_matches('…'));
4713      } else {
4714        self.status = "finish current task before creating worktree".into();
4715      }
4716      return Ok(());
4717    }
4718    let Some(generation) = self.tasks.request(TaskKind::CreateWorktree) else {
4719      return Ok(());
4720    };
4721    self.create_failure = None;
4722    self.spinner.reset();
4723    self.status = TaskKind::CreateWorktree.loading_label().into();
4724    self.spawn_create_worktree(
4725      generation,
4726      dirname,
4727      target,
4728      branch,
4729      self.workdir.clone(),
4730      self.config.clone(),
4731    );
4732    Ok(())
4733  }
4734
4735  fn spawn_create_worktree(
4736    &self,
4737    generation: u64,
4738    dirname: String,
4739    target: PathBuf,
4740    branch: String,
4741    workdir: PathBuf,
4742    config: Config,
4743  ) {
4744    let tx = self.task_tx.clone();
4745    std::thread::spawn(move || {
4746      let result = (|| -> Result<CreateWorktreeResult> {
4747        let repo = worktree::discover_repo(Some(&workdir))?;
4748        let created = worktree::add(&repo, &dirname, &target, &branch, false)?;
4749        let ctx = BootstrapCtx {
4750          main_repo: &workdir,
4751          worktree: &created,
4752          config: &config,
4753        };
4754        let report = bootstrap::run(&ctx)?;
4755        Ok(CreateWorktreeResult {
4756          branch,
4757          created,
4758          report,
4759        })
4760      })()
4761      .map_err(|e| e.to_string());
4762      let _ = tx.send(TaskMsg::CreateWorktree(generation, result));
4763    });
4764  }
4765
4766  // ---- Delete flow ---------------------------------------------------------
4767
4768  pub fn enter_confirm_delete(&mut self) {
4769    let Some(sel) = self.selected() else {
4770      self.status = "nothing selected".into();
4771      return;
4772    };
4773    if sel.is_main {
4774      self.status = "cannot remove the main worktree".into();
4775      return;
4776    }
4777    self.view = View::Confirm;
4778    self.confirm.reset();
4779    self.delete_failure = None;
4780    // Start the loader animation from a deterministic frame each time
4781    // the modal opens (#187).
4782    self.spinner.reset();
4783  }
4784
4785  pub fn confirm_delete(&mut self) -> Result<()> {
4786    // `worktree::remove` resolves by the internal git id, which can diverge
4787    // from the display name after a rename (#290), so pass `id` here.
4788    let (id, label) = match self.selected() {
4789      Some(s) => (s.id.clone(), s.path.display().to_string()),
4790      None => return Ok(()),
4791    };
4792    if self.is_delete_worktree_loading() {
4793      return Ok(());
4794    }
4795    if self.tasks.has_mutating_task_in_flight() {
4796      if let Some(label) = self.tasks.mutating_loading_label() {
4797        self.status = format!("finish {} before deleting worktree", label.trim_end_matches('…'));
4798      } else {
4799        self.status = "finish current task before deleting worktree".into();
4800      }
4801      return Ok(());
4802    }
4803    let Some(generation) = self.tasks.request(TaskKind::DeleteWorktree) else {
4804      return Ok(());
4805    };
4806    let delete_branch = self.delete_branch_on_remove;
4807    self.delete_failure = None;
4808    self.confirm.dismiss();
4809    self.spinner.reset();
4810    self.status = TaskKind::DeleteWorktree.loading_label().into();
4811    self.spawn_delete_worktree(generation, id, label, delete_branch);
4812    Ok(())
4813  }
4814
4815  fn spawn_delete_worktree(&self, generation: u64, id: String, label: String, delete_branch: bool) {
4816    let tx = self.task_tx.clone();
4817    let workdir = self.workdir.clone();
4818    std::thread::spawn(move || {
4819      let result = worktree::discover_repo(Some(&workdir))
4820        .and_then(|repo| worktree::remove(&repo, &id, delete_branch))
4821        .map_err(|e| e.to_string());
4822      let _ = tx.send(TaskMsg::DeleteWorktree(generation, id, label, result));
4823    });
4824  }
4825
4826  // ---- Confirm-overlay safety countdown (issue #30, extracted per #125) ---
4827  //
4828  // The countdown only applies when `delete_branch_on_remove` is ON AND the
4829  // configured `confirm_countdown_secs` is non-zero. The pure state lives
4830  // on `self.confirm` (see `src/tui/state/confirm.rs`); the wrappers below
4831  // own the side effects (status messages, view transitions).
4832
4833  /// Total duration of the safety countdown for the current modal state.
4834  /// `Duration::ZERO` means "no countdown — classic modal".
4835  pub fn confirm_countdown_total(&self) -> Duration {
4836    if self.delete_branch_on_remove {
4837      Duration::from_secs(u64::from(self.config.tui.effective_confirm_countdown_secs()))
4838    } else {
4839      Duration::ZERO
4840    }
4841  }
4842
4843  /// True when the confirm overlay should render the countdown variant
4844  /// (progress bar + footer "y arm / y again to cancel"). False for the
4845  /// classic single-keystroke confirm.
4846  pub fn confirm_is_countdown_mode(&self) -> bool {
4847    self.confirm_countdown_total() > Duration::ZERO
4848  }
4849
4850  /// Handle a `y` / Enter press inside the confirm overlay. Delegates to
4851  /// `ConfirmModal::press_y` and composes the status-bar message based on
4852  /// the returned action.
4853  pub fn confirm_press_y(&mut self, now: Instant) -> ConfirmKeyAction {
4854    let total = self.confirm_countdown_total();
4855    let action = self.confirm.press_y(now, total);
4856    match action {
4857      ConfirmKeyAction::FireNow => {}
4858      ConfirmKeyAction::Disarmed => {
4859        let secs = total.as_secs();
4860        // #219 review: name the live confirm key, and drop the clause entirely
4861        // when it is unbound — never advertise a key that no longer re-arms.
4862        self.status = match self.modal_keymap.primary_key(ModalAction::ConfirmConfirm) {
4863          Some(c) => format!("countdown cancelled — press {c} to re-arm ({secs}s safety delay)"),
4864          None => format!("countdown cancelled ({secs}s safety delay)"),
4865        };
4866      }
4867      ConfirmKeyAction::Armed => {
4868        let secs = total.as_secs();
4869        // #219 review: name the live confirm / cancel keys (rebindable via
4870        // `[tui.keys.modal.confirm]`), dropping either clause when its verb is
4871        // unbound rather than advertising a phantom key while the timer runs.
4872        let confirm = self.modal_keymap.primary_key(ModalAction::ConfirmConfirm);
4873        let cancel = self.modal_keymap.primary_key(ModalAction::ConfirmCancel);
4874        let tail = match (confirm, cancel) {
4875          (Some(c), Some(x)) => format!(" · press {c} again or {x} to cancel"),
4876          (Some(c), None) => format!(" · press {c} again to disarm"),
4877          (None, Some(x)) => format!(" · press {x} to cancel"),
4878          (None, None) => String::new(),
4879        };
4880        self.status = format!("armed — auto-fires in {secs}s{tail}");
4881      }
4882    }
4883    action
4884  }
4885
4886  /// Handle the dismissal keys (`n` / `Esc`) inside the confirm overlay.
4887  /// Always disarms the countdown and returns to the list.
4888  pub fn confirm_dismiss(&mut self) {
4889    if self.is_delete_worktree_loading() {
4890      self.status = TaskKind::DeleteWorktree.loading_label().into();
4891      return;
4892    }
4893    self.confirm.dismiss();
4894    self.delete_failure = None;
4895    self.view = View::List;
4896  }
4897
4898  /// Tick the countdown forward. Called from the event loop on every
4899  /// poll-timeout iteration (every 200ms).
4900  pub fn tick_confirm_countdown(&mut self, now: Instant) -> CountdownTickOutcome {
4901    self.confirm.tick(now, self.confirm_countdown_total())
4902  }
4903
4904  /// Countdown progress in `[0.0, 1.0]`. `0.0` when not armed, `1.0` once
4905  /// elapsed. Used by the UI to draw the gauge.
4906  pub fn confirm_countdown_progress(&self, now: Instant) -> f64 {
4907    self.confirm.progress(now, self.confirm_countdown_total())
4908  }
4909
4910  /// Seconds remaining (rounded up to the next whole second) for the UI
4911  /// label. `0` when not armed or when the countdown has elapsed.
4912  pub fn confirm_countdown_remaining_secs(&self, now: Instant) -> u64 {
4913    self.confirm.remaining_secs(now, self.confirm_countdown_total())
4914  }
4915
4916  // ---- Fuzzy filter (issue #21) -------------------------------------------
4917
4918  /// Open the inline filter bar. The existing query is preserved so the user
4919  /// can refine an already-sticky filter; `Esc` is the way to start fresh.
4920  /// Disarms any pending `gg` motion so `/g` doesn't half-trigger it.
4921  ///
4922  /// Forces focus back onto the list: opening `/` is an intent to narrow the
4923  /// list, and the post-`Enter` contract is "navigation returns to the
4924  /// table". Leaving the sidebar focused would make `j` / `k` scroll it
4925  /// instead of walking the filtered worktrees after the filter sticks.
4926  pub fn enter_filter(&mut self) {
4927    self.filter.open();
4928    self.sidebar.focused = false;
4929    self.cancel_pending_motion();
4930    self.status = "/ filter — type to narrow · enter confirms · esc clears".into();
4931  }
4932
4933  /// Close the filter bar but keep the query: `Enter` confirms the current
4934  /// match set and returns the cursor to list navigation.
4935  pub fn exit_filter_keep(&mut self) {
4936    self.filter.close_keep();
4937    self.status = if self.filter.query().is_empty() {
4938      "press ? for help".into()
4939    } else {
4940      format!("filter sticky: {}", self.filter.query())
4941    };
4942  }
4943
4944  /// Close the filter bar and clear the query: `Esc` returns to the full list.
4945  pub fn exit_filter_cancel(&mut self) {
4946    let had_query = !self.filter.query().is_empty();
4947    self.filter.close_cancel();
4948    self.clamp_selection_to_filter();
4949    self.invalidate_sidebar_cache();
4950    self.status = if had_query {
4951      "filter cleared".into()
4952    } else {
4953      "press ? for help".into()
4954    };
4955  }
4956
4957  pub fn filter_push_char(&mut self, c: char) {
4958    self.filter.push_char(c);
4959    self.clamp_selection_to_filter();
4960    self.invalidate_sidebar_cache();
4961  }
4962
4963  pub fn filter_pop_char(&mut self) {
4964    let before = self.filter.query().len();
4965    self.filter.pop_char();
4966    if self.filter.query().len() != before {
4967      self.clamp_selection_to_filter();
4968      self.invalidate_sidebar_cache();
4969    }
4970  }
4971
4972  /// Indices into `self.worktrees`, in display order:
4973  /// - empty query: identity (every worktree in source order).
4974  /// - non-empty: only worktrees whose name matches the query via
4975  ///   `nucleo_matcher`, ranked by descending score (nucleo intrinsically
4976  ///   ranks exact/substring/prefix matches above subsequence matches).
4977  ///
4978  /// Score ties are broken by original index so output is stable.
4979  ///
4980  /// Memoised on `FilterState` since #124 / #104: the per-frame render
4981  /// path calls this 3–5× (table height, visible rows, title hint,
4982  /// footer counter, selection resolver), but the result only changes
4983  /// when the query OR the worktrees vec changes. The cache holds the
4984  /// previous result and the worktrees length it was computed against;
4985  /// any buffer mutation (`push_char` / `pop_char` / `set_query` /
4986  /// `clear`), an explicit `filter.invalidate()`, or a length change
4987  /// invalidates it. `App::refresh` calls `invalidate` after replacing
4988  /// `worktrees` so a same-length-different-contents refresh is also
4989  /// caught.
4990  pub fn filtered_indices(&mut self) -> &[usize] {
4991    self.filter.filtered_indices(&self.worktrees, fuzzy_match_indices)
4992  }
4993
4994  /// Reposition the selection so it stays inside the current filtered subset.
4995  /// Called whenever the filter mutates (`/`-mode typing, `Esc`-clear) or the
4996  /// worktree list itself changes (`refresh`). Also re-resolves the issue/PR
4997  /// link cache so the right-panel block tracks the new selection — PR #68
4998  /// Copilot review caught that selection changes were leaving the cache
4999  /// pointing at the previously selected worktree.
5000  fn clamp_selection_to_filter(&mut self) {
5001    let len = self.filtered_indices().len();
5002    if len == 0 {
5003      self.list_state.select(None);
5004      self.refresh_link();
5005      return;
5006    }
5007    match self.list_state.selected() {
5008      Some(i) if i >= len => self.list_state.select(Some(len - 1)),
5009      Some(_) => {}
5010      None => self.list_state.select(Some(0)),
5011    }
5012    self.refresh_link();
5013  }
5014
5015  /// Move the cursor onto the worktree at `path`, mapping its raw index in
5016  /// `self.worktrees` to its slot in the *filtered* list — `list_state`
5017  /// indexes `filtered_indices()`, not the raw vec, so selecting a raw index
5018  /// under an active filter lands on the wrong visible row or none (Codex
5019  /// review on PR #292). A no-op when the path is filtered out.
5020  /// The chord that opens the issue/PR link prompt (`i` by default since
5021  /// #290), resolved from the live keymap so "press X to link" status hints
5022  /// track the binding and any `[tui.keys]` override (Codex review on PR
5023  /// #292, P3).
5024  fn link_prompt_chord(&self) -> String {
5025    self
5026      .keymap
5027      .primary_chord(Action::LinkPrompt)
5028      .unwrap_or_else(|| "i".into())
5029  }
5030
5031  pub fn reselect_by_path(&mut self, path: &Path) {
5032    let Some(raw) = self.worktrees.iter().position(|w| w.path == path) else {
5033      return;
5034    };
5035    let pos = self.filtered_indices().iter().position(|&idx| idx == raw);
5036    if let Some(pos) = pos {
5037      self.list_state.select(Some(pos));
5038    }
5039  }
5040
5041  // ---- Bootstrap flow ------------------------------------------------------
5042
5043  // ---- Picker mode (issue #22) --------------------------------------------
5044
5045  /// Commit the highlighted worktree as the picker's result. The event loop
5046  /// breaks once `picker_should_exit` flips so `run_picker` can surface the
5047  /// path to the CLI caller, which prints it on stdout for `cd "$(gwm
5048  /// switch)"`.
5049  ///
5050  /// Outside picker mode the call is inert. When picker mode is on but
5051  /// nothing is selected (e.g. the filter narrowed the list to zero
5052  /// matches), the loop stays open and a status hint asks the user to
5053  /// refine — addresses Copilot's PR #53 review: Enter on an empty match
5054  /// set used to break with `None`, which read as "cancel" instead of
5055  /// "nothing to pick".
5056  pub fn picker_confirm(&mut self) {
5057    if !self.picker_mode {
5058      return;
5059    }
5060    match self.selected() {
5061      Some(w) => {
5062        self.picker_result = Some(w.path.clone());
5063        self.picker_should_exit = true;
5064      }
5065      None => {
5066        self.status = "no worktree selected — adjust the filter and try again".into();
5067      }
5068    }
5069  }
5070
5071  /// Esc-equivalent for picker mode: leave without recording a path. The
5072  /// regular TUI uses Esc to clear an active filter, which conflicts with
5073  /// the picker footer's `esc:cancel` contract; this method exists so the
5074  /// event loop can route Esc-during-filter to a clean picker cancel.
5075  pub fn picker_cancel(&mut self) {
5076    if !self.picker_mode {
5077      return;
5078    }
5079    self.picker_should_exit = true;
5080  }
5081
5082  pub fn bootstrap_selected(&mut self) {
5083    let path = match self.selected() {
5084      Some(s) => s.path.clone(),
5085      None => {
5086        self.status = "nothing selected".into();
5087        return;
5088      }
5089    };
5090
5091    // Same TOFU gate as `submit_create` — pressing `b` to re-run
5092    // bootstrap on an existing worktree is just as much an RCE
5093    // primitive as creating a new one. Issue #95.
5094    match self.check_trust_for_bootstrap() {
5095      Ok(None) => {}
5096      Ok(Some(msg)) => {
5097        self.status = msg;
5098        return;
5099      }
5100      Err(e) => {
5101        self.status = format!("trust gate error: {}", e);
5102        return;
5103      }
5104    }
5105
5106    // Run off-thread on the async-task spine (issue #256): `bootstrap::run`
5107    // (file copies, guards, command hooks) used to block the event loop. The
5108    // TOFU gate above stays synchronous on the main thread; only the run
5109    // itself moves to a worker, with the `View::Report` transition deferred
5110    // to `drain_task_results`. A second `b` press while one is in flight
5111    // coalesces (no `Some(generation)`), so two bootstraps never race.
5112    if self.tasks.has_mutating_task_in_flight() && !self.tasks.is_loading(TaskKind::Bootstrap) {
5113      self.status = self.busy_mutation_status("bootstrapping");
5114      return;
5115    }
5116    let Some(generation) = self.tasks.request(TaskKind::Bootstrap) else {
5117      return;
5118    };
5119    self.spinner.reset();
5120    self.status = TaskKind::Bootstrap.loading_label().into();
5121    self.spawn_bootstrap(generation, self.workdir.clone(), path, self.config.clone());
5122  }
5123
5124  /// Spawn the off-thread bootstrap worker (issue #256). Only owned, `Send`
5125  /// data crosses the thread boundary — the `main_repo` / `worktree` paths
5126  /// and a clone of the resolved `Config` — so the worker rebuilds its own
5127  /// `BootstrapCtx` rather than borrowing `self`. The result is posted back
5128  /// over the task channel for `drain_task_results` to apply.
5129  fn spawn_bootstrap(&self, generation: u64, main_repo: PathBuf, worktree: PathBuf, config: Config) {
5130    let tx = self.task_tx.clone();
5131    std::thread::spawn(move || {
5132      let ctx = BootstrapCtx {
5133        main_repo: &main_repo,
5134        worktree: &worktree,
5135        config: &config,
5136      };
5137      let result = bootstrap::run(&ctx).map_err(|e| e.to_string());
5138      let _ = tx.send(TaskMsg::Bootstrap(generation, result));
5139    });
5140  }
5141
5142  // ---- Issue/PR linking (issue #67) -------------------------------------
5143
5144  /// Re-read the link for the currently selected worktree's branch. Also
5145  /// re-resolves the repo slug from the origin remote, and resets any
5146  /// previously cached GitHub fetch state since it would refer to a
5147  /// different (issue, pr) tuple now. Delegates to
5148  /// [`GitHubFetch::refresh_link`] for the pure state mutation; the
5149  /// branch resolution still lives here because it depends on
5150  /// `App`'s `selected()` + `repo.head()` fallback.
5151  pub fn refresh_link(&mut self) {
5152    let branch = self.selected_branch_name();
5153    self.github.refresh_link(&self.repo, branch.as_deref(), &self.config);
5154    // Navigation invariant (issue #255): the cache clear above must be paired
5155    // with a spine generation-bump so any in-flight `gh` worker for the
5156    // previous worktree's link is dropped instead of stamping the now-active
5157    // worktree's cache. `refresh_link` no longer holds the old issue/PR
5158    // numbers, so invalidate by predicate.
5159    self.tasks.invalidate_matching(TaskKind::is_github);
5160    // Every link mutation funnels through here or through the
5161    // `refresh_github_status` re-probe — both revalidate the CI overlay's
5162    // pinned identity (Codex review #455): an auto-refresh relist can move
5163    // the selection (the current worktree disappeared) while the overlay
5164    // is up, and its checks must not survive their PR.
5165    self.close_ci_overlay_if_link_disagrees();
5166  }
5167
5168  /// Close the open CI checks overlay when the link no longer matches the
5169  /// `(slug, PR)` it was built for — see `detail_overlay_pr`.
5170  fn close_ci_overlay_if_link_disagrees(&mut self) {
5171    if self.view != View::DetailOverlay
5172      || self.detail_overlay.kind != crate::tui::state::detail_overlay::DetailKind::CiChecks
5173    {
5174      return;
5175    }
5176    let current = self.github.link.pr.map(|n| (self.github.link_slug.clone(), n));
5177    if current != self.detail_overlay_pr {
5178      self.close_detail_overlay();
5179    }
5180  }
5181
5182  fn selected_branch_name(&self) -> Option<String> {
5183    self.selected().and_then(|w| w.branch.clone()).or_else(|| {
5184      self
5185        .repo
5186        .head()
5187        .ok()
5188        .and_then(|h| h.shorthand().ok().map(|s| s.to_string()))
5189    })
5190  }
5191
5192  pub fn current_link(&self) -> &BranchLink {
5193    &self.github.link
5194  }
5195
5196  /// Mirror the live resolved `github.link` onto the selected worktree's
5197  /// snapshot (issue #283 / Codex review #284). The table renders the PR/
5198  /// issue pastilles from `self.worktrees[*].link`, captured at list time,
5199  /// so a freshly persisted auto-detection would otherwise stay invisible on
5200  /// the selected row until a full relist. Resolves the selection through
5201  /// the same filter map as [`Self::selected`].
5202  fn sync_selected_link_into_table(&mut self) {
5203    let Some(i) = self.list_state.selected() else {
5204      return;
5205    };
5206    let filtered = self.filter.snapshot_indices(&self.worktrees, fuzzy_match_indices);
5207    let Some(&original) = filtered.get(i) else {
5208      return;
5209    };
5210    let link = self.github.link.clone();
5211    if let Some(w) = self.worktrees.get_mut(original) {
5212      if w.link.issue != link.issue {
5213        w.issue_state = None;
5214      }
5215      if w.link.pr != link.pr {
5216        w.pr_state = None;
5217      }
5218      w.link = link;
5219    }
5220  }
5221
5222  fn sync_issue_status_into_table(&mut self, status: &IssueStatus) {
5223    if self.github.link.issue == Some(status.number) {
5224      self.github.link.issue_title = Some(status.title.clone());
5225      self.github.link.issue_state = Some(status.state);
5226      if let Some(branch) = self.selected_branch_name() {
5227        let _ = github::persist_issue_title(&self.repo, &branch, &status.title);
5228        let _ = github::persist_issue_state(&self.repo, &branch, status.state);
5229      }
5230    }
5231    // In workspace mode the fetch was for the active repo's selected issue, so
5232    // only stamp/persist rows belonging to that repo — a number-only match
5233    // would otherwise carry repo A's state onto repo B's same-numbered row and
5234    // persist it through the wrong repo handle (Codex review #303 P2).
5235    let mask = self.active_repo_row_mask();
5236    for (i, w) in self.worktrees.iter_mut().enumerate() {
5237      if mask.as_ref().is_some_and(|m| !m[i]) {
5238        continue;
5239      }
5240      if w.link.issue != Some(status.number) {
5241        continue;
5242      }
5243      w.issue_state = Some(status.state);
5244      w.link.issue_title = Some(status.title.clone());
5245      w.link.issue_state = Some(status.state);
5246      if let Some(branch) = w.branch.as_deref() {
5247        let _ = github::persist_issue_title(&self.repo, branch, &status.title);
5248        let _ = github::persist_issue_state(&self.repo, branch, status.state);
5249      }
5250    }
5251  }
5252
5253  fn sync_pr_status_into_table(&mut self, status: &PrStatus) {
5254    if self.github.link.pr == Some(status.number) {
5255      self.github.link.pr_title = Some(status.title.clone());
5256      self.github.link.pr_state = Some(status.state);
5257      if let Some(branch) = self.selected_branch_name() {
5258        let _ = match self.github.link.pr_source {
5259          github::LinkSource::Detected => github::persist_detected_pr_title(&self.repo, &branch, &status.title)
5260            .and_then(|()| github::persist_detected_pr_state(&self.repo, &branch, status.state)),
5261          github::LinkSource::Explicit => github::persist_pr_title(&self.repo, &branch, &status.title)
5262            .and_then(|()| github::persist_pr_state(&self.repo, &branch, status.state)),
5263          github::LinkSource::BranchName | github::LinkSource::None => Ok(()),
5264        };
5265      }
5266    }
5267    // Scope to the active repo's rows in workspace mode — see the matching
5268    // note in `sync_issue_status_into_table` (Codex review #303 P2).
5269    let mask = self.active_repo_row_mask();
5270    for (i, w) in self.worktrees.iter_mut().enumerate() {
5271      if mask.as_ref().is_some_and(|m| !m[i]) {
5272        continue;
5273      }
5274      if w.link.pr != Some(status.number) {
5275        continue;
5276      }
5277      w.pr_state = Some(status.state);
5278      w.link.pr_title = Some(status.title.clone());
5279      w.link.pr_state = Some(status.state);
5280      if let Some(branch) = w.branch.as_deref() {
5281        let _ = match w.link.pr_source {
5282          github::LinkSource::Detected => github::persist_detected_pr_title(&self.repo, branch, &status.title)
5283            .and_then(|()| github::persist_detected_pr_state(&self.repo, branch, status.state)),
5284          github::LinkSource::Explicit => github::persist_pr_title(&self.repo, branch, &status.title)
5285            .and_then(|()| github::persist_pr_state(&self.repo, branch, status.state)),
5286          github::LinkSource::BranchName | github::LinkSource::None => Ok(()),
5287        };
5288      }
5289    }
5290  }
5291
5292  pub fn current_slug(&self) -> Option<&str> {
5293    self.github.link_slug.as_deref()
5294  }
5295
5296  /// Read the cached issue fetch state for the *currently-linked*
5297  /// issue. Returns `&GitHubFetchState::Idle` when no issue is linked
5298  /// (or when the linked issue has never been fetched) — the cache is
5299  /// per-number (post-#138), so reading "the" state means resolving
5300  /// via `self.github.link.issue` first.
5301  pub fn issue_fetch_state(&self) -> &GitHubFetchState<IssueStatus> {
5302    match self.github.link.issue {
5303      Some(n) => self.github.issue_fetch_state(n),
5304      None => &GitHubFetchState::Idle,
5305    }
5306  }
5307
5308  /// PR-side counterpart to [`Self::issue_fetch_state`].
5309  pub fn pr_fetch_state(&self) -> &GitHubFetchState<PrStatus> {
5310    match self.github.link.pr {
5311      Some(n) => self.github.pr_fetch_state(n),
5312      None => &GitHubFetchState::Idle,
5313    }
5314  }
5315
5316  /// Kick off the issue/PR fetch. Called from the event loop when the
5317  /// user presses `F` (refresh GitHub status). Each `gh issue view` /
5318  /// `gh pr view` shell-out runs **off-thread** on the shared async-task
5319  /// spine (issue #255, migrated from #217's dedicated channel): the `App`
5320  /// checks the per-key cache, claims a generation from
5321  /// [`TaskRunner::request`], marks the cache `Loading`, and spawns a
5322  /// worker tagged with that generation. The worker reports a
5323  /// `TaskMsg::Github{Issue,Pr}` back; [`Self::drain_task_results`] applies
5324  /// it only if the generation is still authoritative — so a stale worker
5325  /// from a previous fetch loses the retry race to a fresh one.
5326  ///
5327  /// The PR auto-detection (`gh pr list`, issue #181) stays synchronous:
5328  /// it mutates `link` which the very next render needs, and it is a single
5329  /// cheap call rather than the two `view` shell-outs the spinner is for.
5330  ///
5331  /// This call path is the explicit user-initiated refresh, so it flushes
5332  /// the cache + drops any in-flight worker via [`Self::invalidate_github`]
5333  /// first — the user just asked for fresh data, a cache short-circuit here
5334  /// would be a bug.
5335  pub fn refresh_github_status(&mut self) {
5336    let slug = self.github.link_slug.clone();
5337
5338    // Re-resolve a non-explicit PR live on `F` (issue #181/#283): only an
5339    // explicit `gwm link --pr` pins the PR; a branch-name / none / persisted-
5340    // detected (#283) PR is re-probed so a number that changed since the last
5341    // detection is refreshed. The in-memory detection is dropped *only* once
5342    // we have a fresh successful result (the `Ok` arm), so a refresh that
5343    // cannot probe — no origin slug, no resolvable branch, or a failed `gh`
5344    // call — keeps the persisted detection visible instead of blanking the
5345    // pane/table (Codex review #284). `apply_detected_pr` only fills an empty
5346    // slot, hence the clear-then-apply to replace a stale detection.
5347    if self.github.link.pr_source != github::LinkSource::Explicit {
5348      if let (Some(forge), Some(branch)) = (self.github.forge.clone(), self.selected_branch_name()) {
5349        if let Ok(detected) = forge.find_pr_for_branch(&branch) {
5350          self.github.clear_detected_pr();
5351          self.github.apply_detected_pr(detected);
5352          // Persist the detection (issue #283) so the no-fetch table read
5353          // path colours the PR pastille on every row, not just the selected
5354          // one. Only a successful probe is authoritative: store a hit, clear
5355          // the key on a proven `Ok(None)`. Best-effort write — a git-config
5356          // failure must not break the refresh, so the result is discarded.
5357          let _ = match detected {
5358            Some(n) => github::persist_detected_pr(&self.repo, &branch, n),
5359            None => github::clear_persisted_detected_pr(&self.repo, &branch),
5360          };
5361        }
5362        // On a `gh` failure (Err) nothing was cleared, so the link keeps
5363        // whatever `read_link` resolved (possibly a persisted detection).
5364        //
5365        // Mirror the resolved link onto the selected row's snapshot so the
5366        // table pastille reflects the detection immediately, without waiting
5367        // for a separate relist (Codex review #284). The table renders from
5368        // `self.worktrees[*].link`, not the live `github.link`.
5369        self.sync_selected_link_into_table();
5370      }
5371    }
5372
5373    // The re-probe can CHANGE the PR identity — a persisted detection
5374    // coming back None, or re-detecting a different number (#61 → #62).
5375    // The flow below owns the status line ("nothing linked" / "fetching…").
5376    self.close_ci_overlay_if_link_disagrees();
5377
5378    if self.github.link.issue.is_none() && self.github.link.pr.is_none() {
5379      self.status = format!(
5380        "nothing linked — press {} to link an issue or PR",
5381        self.link_prompt_chord()
5382      );
5383      return;
5384    }
5385    let Some(slug) = slug else {
5386      self.status = "no GitHub remote — cannot fetch status".into();
5387      return;
5388    };
5389    // Explicit user-initiated refresh: flush the cache (so the cold-cache
5390    // branch fires instead of a hit) and drop any in-flight worker on the
5391    // spine, so previously-loaded keys re-fetch.
5392    self.invalidate_github();
5393    let mut spawned = 0u32;
5394    if let Some(n) = self.github.link.issue {
5395      if self.spawn_github_issue(n, &slug) {
5396        spawned += 1;
5397      }
5398    }
5399    if let Some(n) = self.github.link.pr {
5400      if self.spawn_github_pr(n, &slug) {
5401        spawned += 1;
5402      }
5403    }
5404    if spawned > 0 {
5405      // Loading state is live; the spinner animates until `drain` applies
5406      // the results and re-reports the outcome.
5407      self.spinner.reset();
5408      self.status = "fetching GitHub status…".into();
5409    } else {
5410      // Nothing actually spawned (all keys already terminal in cache) —
5411      // report the current outcome immediately.
5412      self.report_github_refresh_status();
5413    }
5414  }
5415
5416  fn refresh_linked_github_statuses_for_worktrees(&mut self) -> u32 {
5417    // Workspace mode (#36): this bulk prefetch resolves every merged row's
5418    // issue/PR against a single repo's slug (`self.github.link_slug`), which
5419    // mis-attributes numbers across child repos with different remotes (Codex
5420    // review #303 P2). In workspace mode GitHub state is fetched per-selection
5421    // instead — `sync_active_repo`/`on_navigation` call `refresh_link`, which
5422    // re-resolves the slug from the selected row's own repo. So skip the bulk
5423    // cross-repo prefetch here.
5424    if self.is_workspace() {
5425      return 0;
5426    }
5427    let Some(slug) = self.github.link_slug.clone() else {
5428      return 0;
5429    };
5430    let issues = self
5431      .worktrees
5432      .iter()
5433      .filter_map(|w| w.link.issue)
5434      .collect::<BTreeSet<_>>()
5435      .into_iter()
5436      .collect::<Vec<_>>();
5437    let prs = self
5438      .worktrees
5439      .iter()
5440      .filter_map(|w| w.link.pr)
5441      .collect::<BTreeSet<_>>()
5442      .into_iter()
5443      .collect::<Vec<_>>();
5444    if issues.is_empty() && prs.is_empty() {
5445      return 0;
5446    }
5447
5448    self.invalidate_github();
5449    let mut spawned = 0u32;
5450    for n in issues {
5451      if self.spawn_github_issue(n, &slug) {
5452        spawned += 1;
5453      }
5454    }
5455    for n in prs {
5456      if self.spawn_github_pr(n, &slug) {
5457        spawned += 1;
5458      }
5459    }
5460    if spawned > 0 {
5461      self.spinner.reset();
5462    }
5463    spawned
5464  }
5465
5466  /// Flush the GitHub result cache **and** drop any in-flight GitHub worker
5467  /// on the spine (issue #255). The navigation invariant: the cache clear
5468  /// and the spine generation-bump must always move together, or a stale
5469  /// worker's late result could outlive the cache flush. Routed through one
5470  /// helper so the pairing can't desync — `refresh_github_status` and
5471  /// (via the predicate) `refresh_link` are the only callers.
5472  fn invalidate_github(&mut self) {
5473    self.github.invalidate();
5474    self.tasks.invalidate_matching(TaskKind::is_github);
5475  }
5476
5477  /// Claim a spine generation for `Issue(n)` and spawn its `gh issue view`
5478  /// worker (issue #255), returning `true` when a worker was actually
5479  /// started. A terminal cache hit (the explicit refresh flushed the cache
5480  /// first, so this only fires on a redundant call) or a coalesced spine
5481  /// slot (a worker for this key is already in flight) returns `false`
5482  /// without spawning a second subprocess.
5483  fn spawn_github_issue(&mut self, n: u64, slug: &str) -> bool {
5484    let key = FetchKey::Issue(n);
5485    if self.github.is_cached(key) {
5486      return false;
5487    }
5488    let Some(generation) = self.tasks.request(TaskKind::GithubIssue(n)) else {
5489      return false;
5490    };
5491    self.github.mark_loading(key);
5492    self.spawn_github_fetch(key, slug.to_string(), generation);
5493    true
5494  }
5495
5496  /// PR-side counterpart to [`Self::spawn_github_issue`] (issue #255).
5497  fn spawn_github_pr(&mut self, n: u64, slug: &str) -> bool {
5498    let key = FetchKey::Pr(n);
5499    if self.github.is_cached(key) {
5500      return false;
5501    }
5502    let Some(generation) = self.tasks.request(TaskKind::GithubPr(n)) else {
5503      return false;
5504    };
5505    self.github.mark_loading(key);
5506    self.spawn_github_fetch(key, slug.to_string(), generation);
5507    true
5508  }
5509
5510  /// Spawn one background `gh` shell-out for `key` tagged with `generation`
5511  /// and wire its result back over the shared task channel (issue #255,
5512  /// migrated from #217's dedicated channel). Deliberately a thin shell: it
5513  /// owns only the off-thread dispatch + send, no state logic — the
5514  /// coalescing / late-drop contract lives on the [`TaskRunner`] spine. A
5515  /// `send` failure (the `App`/receiver was dropped) is ignored: there is
5516  /// no longer anyone to apply the result.
5517  fn spawn_github_fetch(&self, key: FetchKey, _slug: String, generation: u64) {
5518    let tx = self.task_tx.clone();
5519    // Clone the resolved forge on THIS (main) thread and hand it to the
5520    // worker. The backend captured `$GWM_GH` / `$GWM_GLAB` when it was
5521    // built (also on the main thread), so the worker never reads the
5522    // process environment concurrently with env-mutating code elsewhere —
5523    // the `env_lock` unsoundness it would otherwise reintroduce (#217).
5524    let Some(forge) = self.github.forge.clone() else {
5525      return;
5526    };
5527    std::thread::spawn(move || {
5528      let msg = match key {
5529        FetchKey::Issue(n) => TaskMsg::GithubIssue(generation, n, forge.fetch_issue(n).map_err(|e| e.to_string())),
5530        FetchKey::Pr(n) => TaskMsg::GithubPr(generation, n, forge.fetch_pr(n).map_err(|e| e.to_string())),
5531      };
5532      let _ = tx.send(msg);
5533    });
5534  }
5535
5536  /// Compute the post-refresh status line message based on the actual
5537  /// outcome of the issue / PR fetches. PR #68 Copilot review caught
5538  /// that always printing "refreshed" misled users when one of the
5539  /// fetches had failed.
5540  pub fn report_github_refresh_status(&mut self) {
5541    let issue_err = matches!(self.issue_fetch_state(), GitHubFetchState::Error(_));
5542    let pr_err = matches!(self.pr_fetch_state(), GitHubFetchState::Error(_));
5543    self.status = match (issue_err, pr_err) {
5544      (false, false) => "github status refreshed".into(),
5545      (true, false) => format!(
5546        "issue fetch failed: {}",
5547        self.issue_error_message().unwrap_or("?".into())
5548      ),
5549      (false, true) => format!("pr fetch failed: {}", self.pr_error_message().unwrap_or("?".into())),
5550      (true, true) => format!(
5551        "issue + pr fetch failed — issue: {} · pr: {}",
5552        self.issue_error_message().unwrap_or("?".into()),
5553        self.pr_error_message().unwrap_or("?".into())
5554      ),
5555    };
5556  }
5557
5558  fn issue_error_message(&self) -> Option<String> {
5559    match self.issue_fetch_state() {
5560      GitHubFetchState::Error(e) => Some(e.clone()),
5561      _ => None,
5562    }
5563  }
5564
5565  fn pr_error_message(&self) -> Option<String> {
5566    match self.pr_fetch_state() {
5567      GitHubFetchState::Error(e) => Some(e.clone()),
5568      _ => None,
5569    }
5570  }
5571
5572  pub fn apply_issue_fetch_result(&mut self, r: std::result::Result<IssueStatus, String>) {
5573    if let Ok(status) = &r {
5574      self.persist_loaded_issue_title(status);
5575    }
5576    self.github.apply_issue_result(r);
5577  }
5578
5579  pub fn apply_pr_fetch_result(&mut self, r: std::result::Result<PrStatus, String>) {
5580    if let Ok(status) = &r {
5581      self.persist_loaded_pr_title(status);
5582      self.refresh_ci_overlay_on_pr_landing(status);
5583    }
5584    self.github.apply_pr_result(r);
5585  }
5586
5587  /// Rebuild the open CI checks overlay from a landed PR fetch (validation
5588  /// feedback on PR #455, `f` = refresh inside the overlay) — same
5589  /// convention as the agents landing. Gated on the CI consumer AND on the
5590  /// linked PR: the worktree-wide bulk prefetch lands other PRs' results
5591  /// through the same drain arm, and those must not clobber the rows.
5592  /// `set_rows` clamps the selection to the new count. Called from both
5593  /// landing paths — the drain (`TaskMsg::GithubPr`, the real worker path)
5594  /// and the `apply_pr_fetch_result` test seam — so they cannot desync
5595  /// again (the first cut lived only in the seam, so the running TUI never
5596  /// refreshed the overlay).
5597  ///
5598  /// Returns `true` when the landing closed the overlay and claimed the
5599  /// status line (empty rollup) so the drain suppresses its end-of-drain
5600  /// `report_github_refresh_status` — which otherwise overwrote the close
5601  /// message with "github status refreshed" (Codex review #455); same
5602  /// guard the sync arm uses.
5603  fn refresh_ci_overlay_on_pr_landing(&mut self, status: &PrStatus) -> bool {
5604    if self.view != View::DetailOverlay
5605      || self.detail_overlay.kind != crate::tui::state::detail_overlay::DetailKind::CiChecks
5606      || self.github.link.pr != Some(status.number)
5607    {
5608      return false;
5609    }
5610    // An empty rollup (a fresh commit whose workflows have not started
5611    // yet) would blank the rows while leaving the overlay open — exactly
5612    // the empty overlay `enter_ci_checks` refuses to open (Codex review
5613    // #455). Close it and say why instead.
5614    if status.checks.is_empty() {
5615      self.close_detail_overlay();
5616      self.status = "no CI checks reported by the refreshed PR".into();
5617      return true;
5618    }
5619    let rows = crate::tui::state::detail_overlay::ci_check_rows(&status.checks, std::time::SystemTime::now());
5620    self.ci_overlay_checks = status.checks.clone();
5621    self.detail_overlay.set_rows(rows);
5622    false
5623  }
5624
5625  fn persist_loaded_issue_title(&mut self, status: &IssueStatus) {
5626    self.sync_issue_status_into_table(status);
5627  }
5628
5629  fn persist_loaded_pr_title(&mut self, status: &PrStatus) {
5630    self.sync_pr_status_into_table(status);
5631  }
5632
5633  // ---- Open menu ----------------------------------------------------------
5634
5635  pub fn enter_open_menu(&mut self) {
5636    // Re-resolve link + slug in case the user just linked something
5637    // (`gwm link …` from a parallel terminal) or moved the origin remote.
5638    //
5639    // Deliberately the non-invalidating variant: `refresh_link` clears
5640    // the fetch caches, and those hold the server-reported `web_url`
5641    // this menu is about to prefer over a locally built one. Clearing
5642    // here meant that URL was never once used (Codex review #458). Safe
5643    // because the caches are keyed by number, so a link that really
5644    // changed simply misses.
5645    let branch = self.selected_branch_name();
5646    if self.github.reread_link(&self.repo, branch.as_deref(), &self.config) {
5647      // The identity moved, so `reread_link` dropped the caches — the
5648      // navigation invariant says the spine generation moves with them,
5649      // or an in-flight worker for the previous instance repopulates
5650      // what was just cleared (issue #255, Codex review #458).
5651      self.tasks.invalidate_matching(TaskKind::is_github);
5652    }
5653    self.open_menu_selected = LinkTarget::Issue;
5654    self.view = View::OpenMenu;
5655  }
5656
5657  pub fn exit_open_menu(&mut self) {
5658    self.view = View::List;
5659  }
5660
5661  pub fn open_menu_toggle_selection(&mut self) {
5662    self.open_menu_selected = match self.open_menu_selected {
5663      LinkTarget::Issue => LinkTarget::Pr,
5664      LinkTarget::Pr => LinkTarget::Issue,
5665    };
5666  }
5667
5668  /// The issue's URL as the forge itself reported it, when a fetch has
5669  /// already landed (Codex review #458).
5670  ///
5671  /// For a guessed (SSH) origin the locally constructed URL is only
5672  /// `https://<ssh-host>/…`, which is wrong whenever the SSH hostname is
5673  /// not the web hostname or the web UI runs on HTTP / a non-standard
5674  /// port. The cached status carries the server's own `web_url`, so it is
5675  /// preferred whenever it is there — and unlike the CLI path this costs
5676  /// no request, which matters on the render thread.
5677  fn cached_issue_url(&self, number: u64) -> Option<String> {
5678    match self.github.issue_fetch_state(number) {
5679      GitHubFetchState::Loaded(s) if !s.url.is_empty() => Some(s.url.clone()),
5680      _ => None,
5681    }
5682  }
5683
5684  /// PR-side counterpart to [`Self::cached_issue_url`].
5685  fn cached_pr_url(&self, number: u64) -> Option<String> {
5686    match self.github.pr_fetch_state(number) {
5687      GitHubFetchState::Loaded(s) if !s.url.is_empty() => Some(s.url.clone()),
5688      _ => None,
5689    }
5690  }
5691
5692  /// Pick a target from the open menu. Returns the URL to open, or `None`
5693  /// when the link is missing (the status bar carries the explanation).
5694  pub fn open_menu_pick(&mut self, target: LinkTarget) -> Option<String> {
5695    self.view = View::List;
5696    let Some(forge) = self.github.forge.clone() else {
5697      self.status = "no forge remote — cannot build URL".into();
5698      return None;
5699    };
5700    // Whether the URL was built locally rather than read off the server.
5701    // On a guessed SSH origin the local build uses the SSH hostname, and
5702    // the cache is empty until a fetch lands — which on an unreachable
5703    // instance is never (Codex review #458). Opening a best guess still
5704    // beats a dead menu entry; saying so beats a silent wrong tab.
5705    let mut inferred = false;
5706    let url = match target {
5707      LinkTarget::Issue => match self.github.link.issue {
5708        Some(n) => self.cached_issue_url(n).unwrap_or_else(|| {
5709          inferred = true;
5710          forge.issue_url(n)
5711        }),
5712        None => {
5713          self.status = format!("no issue linked — press {} to link one", self.link_prompt_chord());
5714          return None;
5715        }
5716      },
5717      LinkTarget::Pr => match self.github.link.pr {
5718        Some(n) => self.cached_pr_url(n).unwrap_or_else(|| {
5719          inferred = true;
5720          forge.pr_url(n)
5721        }),
5722        None => {
5723          self.status = format!(
5724            "no {} linked — press {} to link one",
5725            forge.pr_noun(),
5726            self.link_prompt_chord()
5727          );
5728          return None;
5729        }
5730      },
5731    };
5732    if inferred && !forge.origin_is_authoritative() {
5733      self.status = format!("opening a guessed URL — the SSH origin names no web host: {url}");
5734    }
5735    Some(url)
5736  }
5737
5738  // ---- Link prompt --------------------------------------------------------
5739  //
5740  // Pure state lives in `self.link_prompt` (`tui::state::link_prompt`,
5741  // extracted per #126). The methods below are thin orchestrator
5742  // wrappers: they update `self.view` / `self.status` / drive the
5743  // `github::link_{issue,pr}` shell-out on submit, then delegate the
5744  // buffer / stage transitions to `LinkPrompt`.
5745
5746  pub fn enter_link_prompt(&mut self) {
5747    self.view = View::LinkPrompt;
5748    self.link_prompt.reset();
5749    self.status = "pick".into();
5750  }
5751
5752  /// Highlighted row in the `ChooseTarget` picker (for the renderer).
5753  pub fn link_prompt_selected(&self) -> LinkTarget {
5754    self.link_prompt.selected
5755  }
5756
5757  /// Testable key handler for the link prompt (issue #217), mirroring
5758  /// [`App::handle_create_key`]. The picker / digit-buffer mutations and
5759  /// the per-stage status copy stay here; the loop only acts on the
5760  /// returned [`LinkPromptKey`] for the two genuine side effects
5761  /// (submit shell-out, view transition).
5762  pub fn handle_link_prompt_key(&mut self, key: KeyEvent) -> LinkPromptKey {
5763    use crate::tui::state::link_prompt::LinkPromptStage;
5764    // #219: each stage is its own modal context. ChooseTarget is a vertical
5765    // two-row picker — `next` / `prev` both flip the highlight (a single
5766    // flip serves j/k/Up/Down alike), while `issue` / `pr` are direct picks.
5767    // InputNumber routes `submit` / `cancel` through the context and treats
5768    // everything else as digit input. The global `fetch_github` key is a
5769    // FALLBACK after the stage context, so a contextual binding on that key
5770    // (e.g. `submit = ["F"]`) wins over the fetch shortcut (#293 review).
5771    match self.link_prompt.stage {
5772      LinkPromptStage::ChooseTarget => match self.resolve_modal(KeyContext::LinkChooseTarget, key) {
5773        Some(ModalAction::LinkChooseCancel) => return LinkPromptKey::Cancel,
5774        Some(ModalAction::LinkChooseNext) | Some(ModalAction::LinkChoosePrev) => self.link_prompt.toggle_selection(),
5775        Some(ModalAction::LinkChooseIssue) => self.link_prompt_choose(LinkTarget::Issue),
5776        Some(ModalAction::LinkChoosePr) => self.link_prompt_choose(LinkTarget::Pr),
5777        Some(ModalAction::LinkChooseAccept) => {
5778          let target = self.link_prompt.selected;
5779          self.link_prompt_choose(target);
5780        }
5781        _ if self.key_matches_action(key, Action::FetchGithub) => return LinkPromptKey::Refresh,
5782        _ => {}
5783      },
5784      // Typing stays reserved here too (Codex review #456) — digits and
5785      // Backspace route to the number before the stage context so a
5786      // modal rebind cannot swallow them. Same contract as the create
5787      // form; Ctrl-modified chars still reach the modal resolution.
5788      LinkPromptStage::InputNumber
5789        if key.code == KeyCode::Backspace
5790          && !key
5791            .modifiers
5792            .intersects(crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT) =>
5793      {
5794        self.link_prompt_pop_char()
5795      }
5796      LinkPromptStage::InputNumber
5797        if matches!(key.code, KeyCode::Char(c) if c.is_ascii_digit())
5798          && !key
5799            .modifiers
5800            .intersects(crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT) =>
5801      {
5802        if let KeyCode::Char(c) = key.code {
5803          self.link_prompt_push_char(c);
5804        }
5805      }
5806      LinkPromptStage::InputNumber => match self.resolve_modal(KeyContext::LinkInputNumber, key) {
5807        Some(ModalAction::LinkInputCancel) => return LinkPromptKey::Cancel,
5808        Some(ModalAction::LinkInputSubmit) => return LinkPromptKey::Submit,
5809        _ if self.key_matches_action(key, Action::FetchGithub) => return LinkPromptKey::Refresh,
5810        _ => match key.code {
5811          KeyCode::Char(c) => self.link_prompt_push_char(c),
5812          KeyCode::Backspace => self.link_prompt_pop_char(),
5813          _ => {}
5814        },
5815      },
5816    }
5817    LinkPromptKey::Handled
5818  }
5819
5820  pub fn link_prompt_cancel(&mut self) {
5821    self.view = View::List;
5822    self.link_prompt.reset();
5823  }
5824
5825  pub fn link_prompt_stage(&self) -> LinkPromptStage {
5826    self.link_prompt.stage
5827  }
5828
5829  pub fn link_prompt_number_input(&self) -> &str {
5830    &self.link_prompt.number
5831  }
5832
5833  pub fn link_prompt_target(&self) -> Option<LinkTarget> {
5834    self.link_prompt.target
5835  }
5836
5837  pub fn link_prompt_choose(&mut self, target: LinkTarget) {
5838    self.link_prompt.commit_target(target);
5839    self.status = match target {
5840      LinkTarget::Issue | LinkTarget::Pr => "num".into(),
5841    };
5842  }
5843
5844  pub fn link_prompt_push_char(&mut self, c: char) {
5845    self.link_prompt.push_char(c);
5846  }
5847
5848  pub fn link_prompt_pop_char(&mut self) {
5849    self.link_prompt.pop_char();
5850  }
5851
5852  pub fn link_prompt_submit(&mut self) -> Result<()> {
5853    let Some(target) = self.link_prompt.target else {
5854      self.status = "no target chosen".into();
5855      return Ok(());
5856    };
5857    let n: u64 = self
5858      .link_prompt
5859      .number
5860      .parse()
5861      .map_err(|_| GwmError::Other("number is empty or invalid".into()))?;
5862    let branch = self
5863      .selected()
5864      .and_then(|w| w.branch.clone())
5865      .or_else(|| {
5866        self
5867          .repo
5868          .head()
5869          .ok()
5870          .and_then(|h| h.shorthand().ok().map(|s| s.to_string()))
5871      })
5872      .ok_or_else(|| GwmError::Other("no branch resolved for selected worktree".into()))?;
5873    match target {
5874      LinkTarget::Issue => github::link_issue(&self.repo, &branch, n)?,
5875      LinkTarget::Pr => github::link_pr(&self.repo, &branch, n)?,
5876    }
5877    self.status = match target {
5878      LinkTarget::Issue => format!("linked issue #{} to {}", n, branch),
5879      LinkTarget::Pr => format!("linked PR #{} to {}", n, branch),
5880    };
5881    self.view = View::List;
5882    self.link_prompt.reset();
5883    self.refresh_link();
5884    Ok(())
5885  }
5886}
5887
5888/// Resolve the shell command for `mode = "shell"`. Precedence:
5889/// `shell_cmd` in `.gwm.toml` → `$SHELL` env var → `/bin/sh`. The
5890/// hardcoded fallback exists for the (rare) case where neither is set —
5891/// the TUI's spawn-and-restore loop assumes a non-empty command string.
5892fn resolve_shell_command(cfg: &TuiOpenConfig) -> String {
5893  cfg
5894    .shell_cmd
5895    .clone()
5896    .or_else(|| std::env::var("SHELL").ok())
5897    .unwrap_or_else(|| "/bin/sh".into())
5898}
5899
5900/// Resolve the editor command for `mode = "editor"`. Precedence:
5901/// `editor_cmd` in `.gwm.toml` → `$EDITOR` env var → `vi` (POSIX
5902/// baseline). Mirrors `resolve_shell_command` so the two flows share
5903/// the same precedence story.
5904fn resolve_editor_command(cfg: &TuiOpenConfig) -> String {
5905  cfg
5906    .editor_cmd
5907    .clone()
5908    .or_else(|| std::env::var("EDITOR").ok())
5909    .unwrap_or_else(|| "vi".into())
5910}