Skip to main content

gwm/tui/
mod.rs

1mod app;
2/// Commit-graph topology renderer, ported from lazygit. **Not part of the
3/// public SemVer surface** — exposed only so the integration tests under
4/// `tests/` can pin the algorithm. Use `gwm::tui::recent_commits_lines`
5/// (re-exported below) for the stable entry point that callers should
6/// actually depend on.
7#[doc(hidden)]
8pub mod commit_graph;
9pub mod keymap;
10pub mod modal_keymap;
11pub mod palette;
12pub mod state;
13pub mod theme;
14mod ui;
15pub mod wt_tree;
16
17use crate::error::Result;
18use crate::tui::keymap::Action;
19use crate::tui::modal_keymap::{KeyContext, ModalAction};
20use crossterm::{
21  event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers},
22  execute,
23  terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
24};
25use ratatui::{backend::CrosstermBackend, Terminal};
26use std::io;
27use std::path::{Path, PathBuf};
28use std::time::{Duration, Instant};
29
30pub use app::{
31  read_pins_from_sources, App, CreateKey, ExecPickerKey, LauncherPlan, LinkPromptKey, LinkPromptStage, LinkTarget,
32  OpenTarget, RepoMeta, View, WorkspaceState,
33};
34pub use state::async_task::{CreateWorktreeResult, TaskKind, TaskMsg, TaskRunner};
35pub use state::clean_overlay::CleanOverlay;
36pub use state::command_logs::CommandLogs;
37pub use state::config_panel::{
38  build_key_rows, ConfigPanel, FieldKind, KeyCapture, KeyRow, KeyTarget, SettingField, SettingsLayer, SettingsTab,
39};
40pub use state::confirm::{ConfirmButton, ConfirmKeyAction, ConfirmModal, CountdownTickOutcome};
41pub use state::create_form::{CreateForm, Field};
42pub use state::exec_picker::ExecPicker;
43pub use state::filter::FilterState;
44pub use state::github_fetch::{FetchKey, GitHubFetch, GitHubFetchState};
45pub use state::link_prompt::LinkPrompt;
46pub use state::pty_overlay::{key_to_bytes, PtyKind, PtyOverlay};
47pub use state::sidebar::SidebarState;
48
49/// Ordered list of clipboard tools to try for the host OS (issue #73).
50/// First entry that resolves on `$PATH` wins. Returned in the
51/// platform's preferred order — `pbcopy` first on macOS, `wl-copy`
52/// then `xclip` then `xsel` on Linux, `clip.exe` on Windows. Exposed
53/// from the crate root so the tests in `tui_app_tests.rs` can pin the
54/// non-empty contract without spawning anything.
55pub fn clipboard_candidates() -> Vec<(&'static str, Vec<&'static str>)> {
56  if cfg!(target_os = "macos") {
57    vec![("pbcopy", vec![])]
58  } else if cfg!(target_os = "windows") {
59    vec![("clip", vec![])]
60  } else {
61    vec![
62      ("wl-copy", vec![]),
63      ("xclip", vec!["-selection", "clipboard"]),
64      ("xsel", vec!["--clipboard", "--input"]),
65    ]
66  }
67}
68pub use ui::{
69  agent_cell_label, agent_pane_lines, agents_pane_title, author_initials, badge_group_width, bootstrap_report_lines,
70  branch_name_color, branch_status_color, build_sidebar_payload, build_sidebar_sections, centered_abs, chip_style,
71  ci_indicator, clean_dir_icon, command_logs_footer_hints, config_capture_footer_hints, config_edit_footer_hints,
72  config_nav_footer_hints, confirm_buttons_line, confirm_delete_branch_line, confirm_detail_line, create_buttons_line,
73  delete_worktree_title, ellipsize_middle, field_input_line, filled_cells_for_progress, footer_line, format_status,
74  freshness_color, github_status_lines, header_line, help_body_section_color, help_entry_line, help_label_style,
75  help_lines, help_rows, help_section_style, hint_key_style, hint_label_style, issue_badge_color, issue_pr_pane_title,
76  issue_summary_line, link_open_modal_lines, link_prompt_modal_width, link_target_keys, link_target_line,
77  modal_hint_for_context, modal_hint_for_context_with_fields, modal_hint_line, overlay_modal_width, palette_name_style,
78  pane_counter, panel_border_color, picker_window, pr_badge_color, pr_summary_line, recent_commits_lines,
79  recent_items_pane_title, reclaim_size_color, rename_buttons_line, status_line, status_pane_title, table_marker,
80  tilde_compress_with_home, type_selector_line, working_tree_counts_footer, working_tree_pane_title,
81  working_tree_status_counts, working_tree_status_line, worktree_name_style, worktree_path_style, worktrees_pane_title,
82  HelpRow, HintContext, SidebarSections, WorkingTreeCounts, COMMIT_HASH_DISPLAY_LEN, ISSUE_ICON, PR_ICON,
83  RECENT_COMMITS_LIMIT, WT_CREATED_ICON, WT_DELETED_ICON, WT_MODIFIED_ICON,
84};
85
86/// The single TUI render entry point. **Not part of the public SemVer
87/// surface** — exposed only so the modal render net in `tests/` (issue
88/// #235) can drive each overlay through the same `draw` path the event
89/// loop uses, pinning modal layout against future `ui.rs` refactors. The
90/// per-modal `draw_*` helpers stay private; this mirrors the
91/// `#[doc(hidden)] pub mod commit_graph` convention above.
92#[doc(hidden)]
93pub use ui::draw;
94
95pub fn run(trust_mode: crate::trust::TrustMode) -> Result<()> {
96  // Construct the App BEFORE touching the terminal: if discovery / config
97  // load fails (e.g. not inside a git repo), the user's terminal stays in
98  // its pristine cooked state. Addresses Copilot's PR #53 review — the
99  // previous order left raw mode + alt-screen on when `App::new()?`
100  // bubbled up.
101  //
102  // `trust_mode` is threaded down so the TUI's bootstrap call sites
103  // (`submit_create`, `bootstrap_selected`) take the same TOFU
104  // decision as `gwm create` / `gwm bootstrap` — closes the bypass
105  // flagged in PR #113 review (issue #95).
106  let app = App::new()?.with_trust_mode(trust_mode);
107  let mut terminal = enter_terminal()?;
108  let result = run_app(&mut terminal, app);
109  leave_terminal(&mut terminal)?;
110  // #290: ExitToWorktree prints the selected path to stdout so a shell
111  // wrapper (`cd "$(gwm)"`) can change directory.
112  if let Some(path) = result? {
113    println!("{}", path.display());
114  }
115  Ok(())
116}
117
118/// Workspace-mode entry point (issue #36): open the TUI over every git repo
119/// one level below `root`. Same teardown-safety contract as [`run`] — App
120/// construction (discovery + per-repo config load) happens before the
121/// terminal is touched, so a failure (no repos, bad config) leaves the
122/// terminal cooked.
123pub fn run_workspace(root: &Path, trust_mode: crate::trust::TrustMode) -> Result<()> {
124  let app =
125    App::new_workspace_at_layered(root, crate::config::global_config_path().as_deref())?.with_trust_mode(trust_mode);
126  let mut terminal = enter_terminal()?;
127  let result = run_app(&mut terminal, app);
128  leave_terminal(&mut terminal)?;
129  if let Some(path) = result? {
130    println!("{}", path.display());
131  }
132  Ok(())
133}
134
135/// `gwm switch` entry point: open the same TUI in picker mode and return
136/// the user's pick (Some(path) on Enter, None on Esc / Ctrl-C / q).
137///
138/// Drives the terminal setup separately from `run` so the alternate screen
139/// is always torn down before the caller prints the chosen path on stdout.
140pub fn run_picker() -> Result<Option<PathBuf>> {
141  // Same teardown-safety pattern as `run`: any error from
142  // `App::new_picker_at` (repo discovery, config load) bubbles up with the
143  // terminal still in cooked mode.
144  let app = App::new_picker_at(None)?;
145  let mut terminal = enter_terminal()?;
146  let result = run_app(&mut terminal, app);
147  leave_terminal(&mut terminal)?;
148  result
149}
150
151/// Enable raw mode + alternate screen + mouse capture and hand back a
152/// configured `Terminal`. Centralised so `run` and `run_picker` cannot
153/// drift on the setup recipe.
154fn enter_terminal() -> Result<Terminal<CrosstermBackend<io::Stderr>>> {
155  enable_raw_mode()?;
156  // Render the TUI to STDERR, not stdout: `exit_to_worktree` (#290) prints the
157  // selected path to stdout for the `cd "$(gwm)"` shell wrapper, so stdout must
158  // stay free of alt-screen / ANSI frames (the fzf/skim pattern). stderr is the
159  // tty in an interactive session, so the UI still draws (Codex review #292).
160  let mut stderr = io::stderr();
161  execute!(stderr, EnterAlternateScreen, EnableMouseCapture)?;
162  Ok(Terminal::new(CrosstermBackend::new(stderr))?)
163}
164
165/// Inverse of `enter_terminal`. Always called from the same scope as
166/// `enter_terminal` so the order of teardown matches the order of setup.
167fn leave_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stderr>>) -> Result<()> {
168  disable_raw_mode()?;
169  execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
170  terminal.show_cursor()?;
171  Ok(())
172}
173
174/// Confirm-side of the delete modal: arm/fire the destructive action.
175/// Shared by the `y` shortcut and the `Enter`-on-`[ Confirm ]` path so
176/// the arm-then-fire countdown semantics stay identical (#187). In
177/// countdown mode the first call arms (the loop ticks the bar), a second
178/// disarms; in classic mode it fires immediately.
179fn confirm_fire(app: &mut App) {
180  if app.is_delete_worktree_loading() {
181    app.status = TaskKind::DeleteWorktree.loading_label().into();
182    return;
183  }
184  match app.confirm_press_y(Instant::now()) {
185    ConfirmKeyAction::FireNow => {
186      if let Err(e) = app.confirm_delete() {
187        app.status = format!("delete failed: {}", e);
188      }
189    }
190    // Armed / Disarmed update the status line; the loop keeps the modal
191    // open and lets the countdown tick (or wait for another y / Esc).
192    ConfirmKeyAction::Armed | ConfirmKeyAction::Disarmed => {}
193  }
194}
195
196fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stderr>>, mut app: App) -> Result<Option<PathBuf>> {
197  loop {
198    let now = Instant::now();
199    // Generic off-thread tasks (issue #231; GitHub fetch folded in by #255):
200    // apply any worker results that landed since the last tick — the
201    // off-thread worktree refresh and the `gh issue/pr view` fetches all
202    // report over this one channel now. Drained before the draw so the frame
203    // reflects the freshly-applied results, and the loader animates below
204    // while any of them is still in flight (200ms poll cadence).
205    app.drain_task_results();
206    // Advance the elapsed duration of any Running check while the CI
207    // overlay is up (Codex review #455) — same 200ms cadence, no-op
208    // otherwise.
209    app.tick_ci_overlay_durations();
210    if app.is_github_loading() || app.is_task_loading() {
211      app.spinner.tick();
212    }
213
214    if app.should_quit {
215      if app.can_quit_now() {
216        break;
217      }
218      app.defer_quit_for_mutating_task();
219    }
220
221    // Keep the Command Logs overlay live (issue #226): re-snapshot the
222    // global log each tick while it is open so a command that finishes
223    // off-thread (e.g. the GitHub fetch) appears without reopening. The
224    // scroll cursor is preserved; the renderer re-clamps it.
225    if app.view == View::CommandLogs {
226      app.command_logs.sync();
227    }
228    // #325: don't auto-refresh while a destructive overlay (exec picker /
229    // clean report) is open — a re-list would drift the live selection /
230    // active repo out from under the target the overlay captured at open.
231    if !app.destructive_overlay_open() {
232      app.maybe_auto_refresh(now);
233    }
234
235    // Issue #35: drain PTY output and detect process death before drawing.
236    // `poll_bytes` feeds pending reader-thread bytes into the vt100 parser
237    // so the next frame reflects the freshest output. If the process has
238    // already exited, close the overlay so the list view is rendered instead.
239    if app.view == View::Pty {
240      let status = app.pty_overlay.as_mut().map(|p| {
241        p.poll_bytes();
242        (p.kind, p.is_alive())
243      });
244      match status {
245        // #325: a one-shot exec command exits the instant it finishes — keep
246        // its final output on screen and let any key dismiss it, instead of
247        // the lazygit / shell behaviour of closing the overlay on child death.
248        // `mark_finished` also reaps the process group now (so a backgrounded
249        // descendant is cleaned in the safe window, not after the linger).
250        Some((PtyKind::Exec, false)) => {
251          if let Some(p) = app.pty_overlay.as_mut() {
252            p.mark_finished();
253          }
254        }
255        // Interactive overlays (lazygit / shell / review) close on child exit.
256        Some((_, false)) | None => app.close_pty_overlay(),
257        Some((_, true)) => {}
258      }
259    }
260
261    // Issue #36: in workspace mode keep the active repo aligned with the
262    // selected worktree's repo before drawing (so the sidebar preview reads
263    // the right repo) and before the next key fires an action against it. A
264    // no-op in single-repo mode and when the selection hasn't crossed repos.
265    // #325: suspended while a destructive overlay is open so the active repo
266    // (and its config) can't swap under the captured exec/clean target.
267    if !app.destructive_overlay_open() {
268      app.sync_active_repo();
269      // Issue #343: keep the details sidebar's git preview off the render path.
270      // Runs after `sync_active_repo` so the active repo's `doctor.trunks` are
271      // correct when a workspace-mode rebuild spawns. A no-op when the cache is
272      // already current for the selection; otherwise it spawns one coalesced
273      // worker (the render draws the placeholder until it lands).
274      app.maybe_refresh_sidebar();
275      // Agent-session detection (issue #408): same off-thread + coalesce
276      // discipline as the sidebar — a no-op while the snapshot is fresh.
277      app.maybe_refresh_agent_sessions();
278    }
279
280    terminal.draw(|f| ui::draw(f, &mut app))?;
281
282    // Tick the confirm-overlay safety countdown (issue #30) before
283    // polling for input. Driving it from the poll cadence keeps the UI
284    // smooth (the 200ms poll already drives the redraw); doing it after
285    // the keypress branch would skip a tick whenever a poll-timeout
286    // doesn't fire a key event, stretching a 3s countdown by the
287    // input-handling latency of every armed iteration.
288    if app.view == View::Confirm {
289      // Advance the loader animation while the safety countdown is
290      // armed (#187). The 200ms poll re-enters this block every tick,
291      // so the spinner animates at the poll cadence; when idle (no
292      // countdown) the frame stays put.
293      if app.confirm.is_armed() {
294        app.spinner.tick();
295      }
296      match app.tick_confirm_countdown(now) {
297        CountdownTickOutcome::ReadyToFire => {
298          if let Err(e) = app.confirm_delete() {
299            app.status = format!("delete failed: {}", e);
300          }
301        }
302        CountdownTickOutcome::Pending | CountdownTickOutcome::NotArmed => {}
303      }
304    }
305
306    // #325: drive the clean overlay's safety countdown off the same poll
307    // cadence as the delete confirm above, so an armed reclaim auto-fires
308    // after the configured delay even when no key event arrives.
309    if app.view == View::CleanReport {
310      if app.clean_overlay.confirm.is_armed() {
311        app.spinner.tick();
312      }
313      match app.tick_clean_countdown(now) {
314        CountdownTickOutcome::ReadyToFire => app.clean_overlay_delete(),
315        CountdownTickOutcome::Pending | CountdownTickOutcome::NotArmed => {}
316      }
317    }
318
319    // Issue #35: tighten the poll cadence while the PTY is open so typed
320    // characters and arrow keys feel responsive (< 50 ms round-trip vs.
321    // the normal 200 ms status-refresh interval).
322    // Issue #343: also tighten it while a sidebar rebuild is in flight so the
323    // "loading…" placeholder swaps to the real preview within ~50 ms instead of
324    // up to a full 200 ms poll after a fast `j` / `k` on a large repo. Scoped
325    // to the `Sidebar` slot on purpose — a multi-second `sync` / `push` / list
326    // refresh doesn't need the loop spinning at 20 fps for its whole duration.
327    let poll_ms = if app.view == View::Pty || app.tasks.is_loading(TaskKind::Sidebar) {
328      50
329    } else {
330      200
331    };
332    if !event::poll(Duration::from_millis(poll_ms))? {
333      continue;
334    }
335    let ev = event::read()?;
336    // Issue #35: resize the PTY when the host terminal is resized so the
337    // child program (lazygit, shell) sees the updated dimensions.
338    if let Event::Resize(cols, rows) = ev {
339      if app.view == View::Pty {
340        if let Some(ref mut pty) = app.pty_overlay {
341          // 90% × 90% overlay minus overlay_block overhead (6 cols, 4 rows).
342          let inner_cols = ((cols as u32 * 90 / 100) as u16).saturating_sub(6).max(10);
343          let inner_rows = ((rows as u32 * 90 / 100) as u16).saturating_sub(4).max(5);
344          pty.resize(inner_cols, inner_rows);
345        }
346      }
347      terminal.clear()?;
348      continue;
349    }
350    let Event::Key(key) = ev else { continue };
351    if key.kind != KeyEventKind::Press {
352      continue;
353    }
354
355    // Global keys
356    if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
357      // Inside the PTY overlay, Ctrl+C must reach the child process (interrupt
358      // a running command) rather than quit gwm. Forward the byte and continue.
359      if app.view == View::Pty {
360        if let Some(ref mut pty) = app.pty_overlay {
361          let _ = pty.write_key(key);
362        }
363        continue;
364      }
365      app.should_quit = true;
366      if app.can_quit_now() {
367        break;
368      }
369      app.defer_quit_for_mutating_task();
370      continue;
371    }
372    if app.should_quit {
373      continue;
374    }
375
376    match app.view {
377      // When the inline filter bar is open, capture every key as filter input
378      // so the user can type a query containing `q`, `?`, `/`, etc. The only
379      // ways out are Enter (sticky filter) or Esc (clear filter).
380      View::List if app.filter.active => match key.code {
381        KeyCode::Esc => {
382          // Picker contract (footer `esc:cancel`): Esc inside the filter
383          // bar quits the picker, it doesn't merely clear the filter.
384          // Regular TUI keeps the two-step Esc (clear → quit) so a typo'd
385          // filter doesn't accidentally close the long-lived session.
386          if app.picker_mode {
387            app.picker_cancel();
388          } else {
389            app.exit_filter_cancel();
390          }
391        }
392        KeyCode::Enter => {
393          // In picker mode (`gwm switch`), Enter doubles as "stop typing the
394          // filter AND commit the highlighted pick". Exiting the filter bar
395          // first lets `selected()` resolve against the narrowed set.
396          app.exit_filter_keep();
397          if app.picker_mode {
398            app.picker_confirm();
399          }
400        }
401        KeyCode::Backspace => app.filter_pop_char(),
402        KeyCode::Char(c) => app.filter_push_char(c),
403        _ => {}
404      },
405      View::List => {
406        // Esc and Enter stay hard-coded because their semantics are
407        // *contextual* (filter state, picker mode, sticky filter) —
408        // folding them into the user-rebindable keymap would require
409        // a modal grammar the config language cannot express. Their
410        // pre-#87 behaviour is preserved verbatim; both also drop
411        // any in-flight chord so a stray `g` followed by Esc never
412        // leaks state into the next view.
413        if key.code == KeyCode::Esc {
414          app.cancel_pending_motion();
415          if !app.filter.query().is_empty() {
416            app.exit_filter_cancel();
417          } else {
418            app.should_quit = true;
419          }
420        } else if key.code == KeyCode::Enter {
421          app.cancel_pending_motion();
422          if app.picker_mode {
423            app.picker_confirm();
424          } else {
425            app.copy_path_to_status();
426          }
427        } else if let Some(action) = app.dispatch_key(key) {
428          // Issue #87: the View::List binding table is driven by the
429          // resolved keymap. Routed through `run_action` so the
430          // palette overlay (issue #32) and the key path stay
431          // observationally identical: both call the same dispatch.
432          if matches!(action, Action::Quit) {
433            app.should_quit = true;
434          } else {
435            // #436: the key path applies the contextual pre-resolution
436            // (c → CI checks while the status pane is focused); the
437            // palette path deliberately does not — its entries dispatch
438            // by name (Codex review #455).
439            let action = app.resolve_contextual_action(action);
440            run_action(terminal, &mut app, action)?;
441          }
442        }
443      }
444      // #219: keys resolved through the `help` modal context. Scroll the
445      // Keybindings overlay when it outgrows the modal (#217).
446      View::Help => match app.resolve_modal(KeyContext::Help, key) {
447        Some(ModalAction::HelpClose) => app.view = View::List,
448        Some(ModalAction::HelpScrollDown) => app.help_scroll_down(),
449        Some(ModalAction::HelpScrollUp) => app.help_scroll_up(),
450        Some(ModalAction::HelpScrollRight) => app.help_scroll_right(),
451        Some(ModalAction::HelpScrollLeft) => app.help_scroll_left(),
452        Some(ModalAction::HelpScrollTop) => app.help_scroll = 0,
453        Some(ModalAction::HelpScrollBottom) => app.help_scroll = app.help_max_scroll,
454        _ => {}
455      },
456      // Command Logs overlay (issue #226). Scrolls like the help overlay;
457      // closes on Esc / `q` or the bound `command_logs` key (default `3`)
458      // so the open key toggles it shut even when rebound.
459      // #219: keys resolved through the `command_logs` modal context. The
460      // bound global `command_logs` key still toggles the overlay shut.
461      View::CommandLogs => match app.resolve_modal(KeyContext::CommandLogs, key) {
462        Some(ModalAction::CommandLogsClose) => app.view = View::List,
463        // `y` copies the whole transcript to the clipboard (issue #279).
464        Some(ModalAction::CommandLogsCopy) => copy_command_logs_to_clipboard(&mut app),
465        Some(ModalAction::CommandLogsScrollDown) => app.command_logs.scroll_down(),
466        Some(ModalAction::CommandLogsScrollUp) => app.command_logs.scroll_up(),
467        Some(ModalAction::CommandLogsScrollRight) => app.command_logs.scroll_right(),
468        Some(ModalAction::CommandLogsScrollLeft) => app.command_logs.scroll_left(),
469        Some(ModalAction::CommandLogsScrollTop) => app.command_logs.scroll_to_top(),
470        Some(ModalAction::CommandLogsScrollBottom) => app.command_logs.scroll_to_bottom(),
471        _ if app.key_matches_action(key, Action::CommandLogs) => app.view = View::List,
472        _ => {}
473      },
474      // Settings panel (issue #232; editable in #279). While a numeric input
475      // is armed, keystrokes route to the edit buffer and only Enter / Esc
476      // escape — so `q` / `j` / Tab while typing a countdown never quit or
477      // navigate. Otherwise: Tab/BackTab switch category tabs, `L` flips the
478      // edit layer, Up/Down select fields (or scroll on the read-only `All`
479      // tab), Space/Enter cycle a choice or open the numeric input, and
480      // Esc / `q` / the bound `config_panel` key (default `4`) close.
481      // #219: edit sub-mode keys resolve through the `config.edit` context;
482      // anything else is literal input into the numeric edit buffer.
483      // Keys tab live capture (issue #294). While a capture is armed every
484      // keystroke is recorded into the binding rather than navigating. The
485      // logic lives in a testable `App` method (mirrors `handle_create_key` /
486      // `handle_link_prompt_key`): `cancel` (def Esc) aborts, `submit` (def
487      // Enter) commits a multi-stroke global chord, Backspace drops its last
488      // stroke, a single-stroke modal verb auto-commits on the first key. Esc /
489      // Enter / Backspace stay reserved controls and can't be assigned via
490      // capture — hand-edit `.gwm.toml` for those (same hard-coded escape-hatch
491      // trade-off as the rest of the keymap).
492      View::Config if app.config_panel.capture.is_some() => app.handle_capture_key(key),
493      // Typing routes before the modal context here too (Codex review
494      // #456) — see `App::settings_edit_input_key` (no-op unless an edit
495      // is live, so plain Config navigation falls through).
496      // The whole route lives in a testable App method (Codex review
497      // #456): reserved typing, then the modal resolution, then the
498      // AltGr reinjection of unresolved printables.
499      View::Config if app.config_panel.editing.is_some() => app.handle_settings_edit_key(key),
500      // #219: nav keys resolve through the `config` context. Select vs scroll
501      // and the horizontal pan / jump verbs stay gated on the read-only `All`
502      // tab exactly as before; the bound global `config_panel` key still
503      // toggles the overlay shut.
504      View::Config => {
505        let on_all = app.config_panel.tab == SettingsTab::All;
506        match app.resolve_modal(KeyContext::Config, key) {
507          Some(ModalAction::ConfigClose) => app.view = View::List,
508          Some(ModalAction::ConfigNextTab) => app.config_panel.next_tab(),
509          Some(ModalAction::ConfigPrevTab) => app.config_panel.prev_tab(),
510          Some(ModalAction::ConfigToggleLayer) => app.config_panel.toggle_layer(),
511          // On the Keys tab `activate` arms a live keystroke capture for the
512          // selected binding (issue #294); elsewhere it cycles a choice or
513          // opens the numeric/text edit buffer.
514          Some(ModalAction::ConfigActivate) => {
515            if app.config_panel.tab == SettingsTab::Keys {
516              app.config_panel.begin_capture();
517            } else {
518              app.activate_selected_setting();
519            }
520          }
521          Some(ModalAction::ConfigSelectNext) => {
522            if on_all {
523              app.config_panel.scroll_down();
524            } else {
525              app.config_panel.select_next();
526            }
527          }
528          Some(ModalAction::ConfigSelectPrev) => {
529            if on_all {
530              app.config_panel.scroll_up();
531            } else {
532              app.config_panel.select_prev();
533            }
534          }
535          Some(ModalAction::ConfigScrollRight) if on_all => app.config_panel.scroll_right(),
536          Some(ModalAction::ConfigScrollLeft) if on_all => app.config_panel.scroll_left(),
537          Some(ModalAction::ConfigScrollTop) if on_all => app.config_panel.scroll_to_top(),
538          Some(ModalAction::ConfigScrollBottom) if on_all => app.config_panel.scroll_to_bottom(),
539          _ if app.key_matches_action(key, Action::ConfigPanel) => app.view = View::List,
540          _ => {}
541        }
542      }
543      // Create-overlay keys live in a testable `App` method (issue #217);
544      // the loop only owns the two side effects (submit / close). While the
545      // async create worker is in flight (#276), keep the modal locked so a
546      // second submit/cancel does not race the mutating operation.
547      View::Create if app.is_create_worktree_loading() => {}
548      View::Create => match app.handle_create_key(key) {
549        CreateKey::Submit => {
550          if let Err(e) = app.submit_create() {
551            app.status = format!("error: {}", e);
552          }
553        }
554        CreateKey::Cancel => app.view = View::List,
555        CreateKey::Handled => {}
556      },
557      View::Confirm if app.is_delete_worktree_loading() => {}
558      // #219: keys resolve through the `confirm` context. `confirm` (def `y`)
559      // fires regardless of focus (unchanged muscle memory); `activate` (def
560      // Enter) acts on the *focused* button — focus defaults to Cancel (#187),
561      // so a stray Enter on a freshly-opened modal cancels rather than
562      // deletes. The bound global `delete_branch` key still toggles the
563      // branch-deletion checkbox. Focus nav (#187): `focus_confirm` (←/h),
564      // `focus_cancel` (→/l), `toggle_focus` (Tab).
565      View::Confirm => match app.resolve_modal(KeyContext::Confirm, key) {
566        Some(ModalAction::ConfirmConfirm) => confirm_fire(&mut app),
567        Some(ModalAction::ConfirmActivate) => match app.confirm.focused_button() {
568          ConfirmButton::Confirm => confirm_fire(&mut app),
569          ConfirmButton::Cancel => app.confirm_dismiss(),
570        },
571        Some(ModalAction::ConfirmCancel) => app.confirm_dismiss(),
572        Some(ModalAction::ConfirmFocusConfirm) => app.confirm.focus_confirm(),
573        Some(ModalAction::ConfirmFocusCancel) => app.confirm.focus_cancel(),
574        Some(ModalAction::ConfirmToggleFocus) => app.confirm.toggle_focus(),
575        _ if app.key_matches_action(key, Action::ToggleDeleteBranch) => app.toggle_delete_branch(),
576        _ => {}
577      },
578      // #219: the bootstrap-report overlay closes (and refreshes) on the
579      // `report` context's `close` verb (def Esc / q / Enter).
580      View::Report => {
581        if let Some(ModalAction::ReportClose) = app.resolve_modal(KeyContext::Report, key) {
582          app.view = View::List;
583          app.refresh()?;
584        }
585      }
586      // #219: keys resolve through the `open_menu` context. The bound global
587      // `fetch_github` key still refreshes the GitHub status in place.
588      View::OpenMenu => match app.resolve_modal(KeyContext::OpenMenu, key) {
589        Some(ModalAction::OpenMenuClose) => app.exit_open_menu(),
590        Some(ModalAction::OpenMenuToggle) => app.open_menu_toggle_selection(),
591        Some(ModalAction::OpenMenuAccept) => {
592          if let Some(url) = app.open_menu_pick(app.open_menu_selected) {
593            open_url(&url, &mut app);
594          }
595        }
596        Some(ModalAction::OpenMenuIssue) => {
597          if let Some(url) = app.open_menu_pick(LinkTarget::Issue) {
598            open_url(&url, &mut app);
599          }
600        }
601        Some(ModalAction::OpenMenuPr) => {
602          if let Some(url) = app.open_menu_pick(LinkTarget::Pr) {
603            open_url(&url, &mut app);
604          }
605        }
606        _ if app.key_matches_action(key, Action::FetchGithub) => app.refresh_github_status(),
607        _ => {}
608      },
609      // Link-prompt keys live in a testable `App` method (issue #217); the
610      // loop only owns the two side effects (submit shell-out / close).
611      View::LinkPrompt => match app.handle_link_prompt_key(key) {
612        LinkPromptKey::Submit => {
613          if let Err(e) = app.link_prompt_submit() {
614            app.status = format!("link failed: {}", e);
615          }
616        }
617        LinkPromptKey::Refresh => app.refresh_github_status(),
618        LinkPromptKey::Cancel => app.link_prompt_cancel(),
619        LinkPromptKey::Handled => {}
620      },
621      // Issue #35: PTY overlay. All keys are forwarded to the child process
622      // via `write_key` — lazygit and the shell consume them directly. `Esc`
623      // is the only key gwm intercepts: it kills the child and closes the
624      // overlay so the user can exit even if the program does not respond to
625      // `q`. Process death (natural exit via lazygit's `q`) is detected by
626      // the pre-draw `is_alive()` check above and also closes the overlay.
627      //
628      // #219: this `Esc` stays hard-coded by design — it is an *emergency*
629      // detach, and routing it through a rebindable context would silently
630      // steal a keystroke from the child program. See the `modal_keymap`
631      // module note ("What stays hard-coded").
632      View::Pty => {
633        // #325: once a one-shot exec command has finished, the overlay is
634        // just showing its final output — there is no live child to receive
635        // input, so any key dismisses it. Otherwise `Esc` is the emergency
636        // detach and every other key passes through to the child.
637        let exec_finished = app.pty_overlay.as_ref().is_some_and(|p| p.finished);
638        if key.code == KeyCode::Esc || exec_finished {
639          app.close_pty_overlay();
640        } else if let Some(ref mut pty) = app.pty_overlay {
641          let _ = pty.write_key(key);
642        }
643      }
644      // #325: exec profile picker. The testable handler owns the highlight;
645      // `Submit` resolves the profile to an argv and spawns it in a PTY
646      // overlay rooted at the selected worktree (mirrors `LazyGitPty`).
647      View::ExecPicker => match app.handle_exec_picker_key(key) {
648        ExecPickerKey::Submit => {
649          if let Some((argv, cwd)) = app.exec_picker_resolve() {
650            let sz = terminal.size().unwrap_or_default();
651            let inner_cols = ((sz.width as u32 * 90 / 100) as u16).saturating_sub(6).max(20);
652            let inner_rows = ((sz.height as u32 * 90 / 100) as u16).saturating_sub(4).max(5);
653            let argv_refs: Vec<&str> = argv.iter().map(String::as_str).collect();
654            match PtyOverlay::spawn(PtyKind::Exec, &argv_refs, &cwd, inner_cols, inner_rows) {
655              Ok(pty) => app.open_pty_overlay(pty),
656              Err(e) => {
657                app.status = format!("exec overlay failed: {}", e);
658                app.close_exec_picker();
659              }
660            }
661          } else {
662            // Resolve failed (status already set) — close back to the list.
663            app.close_exec_picker();
664          }
665        }
666        ExecPickerKey::Cancel => app.close_exec_picker(),
667        ExecPickerKey::Handled => {}
668      },
669      // #325: clean reclaim overlay. Mirrors the delete-confirm routing —
670      // `confirm` arms / fires the safety countdown, `cancel` aborts, j/k
671      // cycle the `[clean.profiles]` picker (re-scanning each time). The
672      // countdown auto-fire is driven by the tick block above.
673      // Detail overlay (issue #408): j/k move the selection, `a` pins the
674      // selected session, `d` unpins, `i` opens the attach-by-id prompt
675      // (user feedback 2026-07-22). While the prompt is active, keys are
676      // captured as query input (palette convention): printable chars type,
677      // Backspace pops, arrows move the candidate highlight, Enter
678      // attaches, Esc falls back to the list.
679      // Issue #436: the same shell serves two consumers — route the input
680      // prompt AND the list verbs by `detail_overlay.kind` (agents attach
681      // by id; CI checks filter their own rows and open URLs).
682      View::DetailOverlay if app.detail_overlay.mode == crate::tui::state::detail_overlay::DetailMode::Input => {
683        let ci = app.detail_overlay.kind == crate::tui::state::detail_overlay::DetailKind::CiChecks;
684        match key.code {
685          KeyCode::Esc if ci => app.ci_input_cancel(),
686          KeyCode::Esc => app.agent_input_cancel(),
687          KeyCode::Enter if ci => match app.ci_input_selected_url() {
688            Some(url) => open_url(&url, &mut app),
689            // The method flips back to List only when a row WAS picked —
690            // report the missing URL like the List-mode Enter does (Codex
691            // review #455). A query with no match keeps the filter open.
692            None if app.detail_overlay.mode == crate::tui::state::detail_overlay::DetailMode::List => {
693              app.status = "this check exposes no details URL".into()
694            }
695            None => {}
696          },
697          KeyCode::Enter => app.agent_input_submit(),
698          KeyCode::Backspace if ci => app.ci_input_pop(),
699          KeyCode::Backspace => app.agent_input_pop(),
700          KeyCode::Down if ci => app.ci_input_next(),
701          KeyCode::Down => app.agent_input_next(),
702          KeyCode::Up if ci => app.ci_input_prev(),
703          KeyCode::Up => app.agent_input_prev(),
704          KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
705            if ci {
706              app.ci_input_push(c)
707            } else {
708              app.agent_input_push(c)
709            }
710          }
711          _ => {}
712        }
713      }
714      View::DetailOverlay if app.detail_overlay.kind == crate::tui::state::detail_overlay::DetailKind::CiChecks => {
715        match app.resolve_modal(KeyContext::CiChecks, key) {
716          Some(ModalAction::CiChecksClose) => app.close_detail_overlay(),
717          Some(ModalAction::CiChecksNext) => app.detail_overlay.select_next(),
718          Some(ModalAction::CiChecksPrev) => app.detail_overlay.select_prev(),
719          Some(ModalAction::CiChecksOpen) => match app.ci_selected_url() {
720            Some(url) => open_url(&url, &mut app),
721            None => app.status = "this check exposes no details URL".into(),
722          },
723          Some(ModalAction::CiChecksFilter) => app.ci_input_open(),
724          // Validation feedback on PR #455: `f` re-fetches the PR from
725          // inside the overlay; the landing refreshes the rows in place.
726          Some(ModalAction::CiChecksRefresh) => app.ci_checks_refresh(),
727          _ => {}
728        }
729      }
730      View::DetailOverlay => match app.resolve_modal(KeyContext::Detail, key) {
731        Some(ModalAction::DetailClose) => app.close_detail_overlay(),
732        Some(ModalAction::DetailSelectNext) => app.detail_overlay.select_next(),
733        Some(ModalAction::DetailSelectPrev) => app.detail_overlay.select_prev(),
734        Some(ModalAction::DetailAttach) => app.attach_selected_agent(),
735        Some(ModalAction::DetailDetach) => app.detach_selected_agent(),
736        Some(ModalAction::DetailInput) => app.open_agent_input(),
737        _ => {}
738      },
739      View::CleanReport => match app.resolve_modal(KeyContext::Clean, key) {
740        Some(ModalAction::CleanCancel) => app.close_clean_overlay(),
741        Some(ModalAction::CleanConfirm) => {
742          if app.clean_confirm_press(now) == ConfirmKeyAction::FireNow {
743            app.clean_overlay_delete();
744          }
745        }
746        Some(ModalAction::CleanNext) => app.clean_overlay_next(),
747        Some(ModalAction::CleanPrev) => app.clean_overlay_prev(),
748        _ => {}
749      },
750      // #290: worktree-rename modal. Reuses the Create form input handler
751      // (Type / Issue / Desc), but routes submit to the rename worker. Input
752      // is swallowed while the async rename is in flight, mirroring create.
753      View::Edit if app.is_edit_worktree_loading() => {}
754      View::Edit => match app.handle_create_key(key) {
755        CreateKey::Submit => {
756          if let Err(e) = app.submit_edit_worktree() {
757            app.status = format!("rename failed: {}", e);
758          }
759        }
760        CreateKey::Cancel => app.cancel_edit_worktree(),
761        CreateKey::Handled => {}
762      },
763      // Issue #32: command palette overlay. Palette entry names
764      // are restricted to `[a-z0-9_-]` (see
765      // `tests/palette_tests.rs::registry_names_are_unique_and_lowercase_words`),
766      // so only those characters can usefully reach the buffer —
767      // any other typed character would just shrink the match set
768      // to empty. The accepted-character set is enforced explicitly
769      // here so a stray `:` (the palette's own trigger) doesn't
770      // self-append, and so future overlays (themes / fuzzy
771      // search) that share the input bar don't inherit a "swallow
772      // everything" contract by accident. Esc / Enter / arrows /
773      // Tab still exit or navigate; Backspace edits.
774      // #219: close / accept / prev / next resolve through the `palette`
775      // context; every other key is literal input into the fuzzy buffer.
776      // Typing routes before the modal context (Codex review #456) — see
777      // `App::palette_input_key` for the reserved-typing contract (a
778      // testable method, per the repo's TDD rule for event-loop routes).
779      // Typing routes before the modal context (Codex review #456) — see
780      // `App::palette_input_key` for the reserved-typing contract (a
781      // testable method, per the repo's TDD rule for event-loop routes).
782      // Plain `if`, not a match guard: guards cannot borrow mutably.
783      // Typing routes before the modal context (Codex review #456) — see
784      // `App::palette_input_key` for the reserved-typing contract (a
785      // testable method; an `if` in the arm body because match guards
786      // cannot borrow mutably). The charset / swallow rules for plain
787      // characters live in that method now; only Ctrl-modified keys and
788      // non-character keys reach the modal resolution.
789      View::CommandPalette => {
790        if !app.palette_input_key(key) {
791          match app.resolve_modal(KeyContext::CommandPalette, key) {
792            Some(ModalAction::CommandPaletteClose) => app.close_command_palette(),
793            Some(ModalAction::CommandPaletteAccept) => {
794              if let Some(action) = app.accept_command_palette() {
795                run_palette_action(terminal, &mut app, action)?;
796              }
797            }
798            Some(ModalAction::CommandPalettePrev) => app.palette_cycle_up(),
799            Some(ModalAction::CommandPaletteNext) => app.palette_cycle_down(),
800            // Unresolved keys fall back to typing (AltGr / modified
801            // Backspace parity — Codex #456); testable App method.
802            _ => app.palette_unresolved_fallback(key),
803          }
804        }
805      }
806    }
807
808    // Picker contract (Copilot PR #53): only break when the App has
809    // explicitly signalled exit — set by `picker_confirm` (only if a
810    // worktree was actually selected) and `picker_cancel`. Replaces the
811    // unconditional `break` after Enter that turned an empty-match
812    // Enter into a surprise exit-1.
813    if app.picker_should_exit {
814      break;
815    }
816    // Issue #32/#267: every quit path raises this flag, then the loop
817    // exits only once in-flight mutating workers have reported back. Read-
818    // only workers may be abandoned immediately.
819    if app.should_quit {
820      if app.can_quit_now() {
821        break;
822      }
823      app.defer_quit_for_mutating_task();
824    }
825  }
826  // #290: ExitToWorktree stores the path; normal quit leaves it None.
827  Ok(app.should_exit_to.or(app.picker_result))
828}
829
830/// Dispatch a [`LauncherPlan`] from [`App::prepare_git_tui`] /
831/// [`App::prepare_review`]. When `fullscreen=true` the TUI is
832/// suspended (raw mode off, alt-screen left) for the call and restored
833/// on exit — same recipe as the previous hardcoded `lazygit` flow.
834///
835/// **Non-fullscreen launchers also run synchronously**: gwm stays in
836/// the alt-screen, `Command::output()` waits for the child to exit,
837/// then the first line of its stderr lands on the status bar. The
838/// TUI is therefore unresponsive until the tool returns — fine for
839/// print-only AI reviewers (`claude --print`, `gh pr view --web`)
840/// that terminate quickly, but a long-running tool will visibly
841/// block. Pick `fullscreen = true` (proper suspend/resume) for
842/// anything that's not a quick one-shot. Caught by Copilot's review
843/// on PR #76; the previous docstring claimed "run in the background"
844/// which `output()` does not.
845///
846/// Apply a resolved `Action` (issue #87 dispatch) to `App`.
847///
848/// Centralised so the keystroke path (`View::List` → `dispatch_key`)
849/// and the command-palette path (issue #32: `View::CommandPalette` →
850/// `accept_command_palette`) fire identical side effects. Without
851/// this single funnel the two surfaces would inevitably drift: a
852/// future feature wired into one would silently miss the other.
853///
854/// `Action::Quit` raises `app.should_quit` so the event loop can
855/// honour it from any caller and defer the actual exit while a mutating
856/// worker is still in flight. The loop checks the flag at the top and
857/// bottom of every iteration.
858fn run_action(terminal: &mut Terminal<CrosstermBackend<io::Stderr>>, app: &mut App, action: Action) -> Result<()> {
859  // Issue #304: in workspace mode, block repo-mutating actions while the
860  // selected row's repo could not be activated (moved/deleted/corrupt since
861  // listing) — `app.repo`/`workdir`/`config` still point at the previously
862  // active repo, so the write would hit the wrong repository. Navigation and
863  // refresh stay live so the user can recover (a refresh drops the dead repo).
864  if app.workspace_active_stale && action.is_repo_mutating() {
865    app.status = "workspace: selected repo is unavailable (moved/deleted?) — press r to refresh".into();
866    return Ok(());
867  }
868
869  match action {
870    // Issue #32/#267: signal quit via `app.should_quit` so palette
871    // and keymap paths share the same graceful-shutdown gate.
872    Action::Quit => app.should_quit = true,
873    Action::Down => app.next(),
874    Action::Up => app.prev(),
875    Action::Top => app.first(),
876    Action::Bottom => app.last(),
877    // Issue #437: Working Tree pane scroll — no-ops unless the status
878    // pane holds the focus (gate lives on the `App` methods).
879    Action::WtScrollDown => app.wt_scroll_down(),
880    Action::WtScrollUp => app.wt_scroll_up(),
881    Action::ToggleSidebar => app.toggle_sidebar(),
882    // Issue #34: cycle the sidebar preview between commits and
883    // stashes. Lands here as the merge resolution between #166
884    // (which added the action) and #167 (which extracted run_action).
885    Action::ToggleSidebarMode => app.cycle_sidebar_mode(),
886    // Issue #188: responsive sidebar layout — cycle orientation and
887    // flip the side-by-side position.
888    Action::CycleSidebarLayout => app.cycle_sidebar_layout(),
889    Action::ToggleSidebarPosition => app.toggle_sidebar_position(),
890    Action::FocusSwap => app.toggle_focus(),
891    Action::FocusWorktrees => app.focus_worktrees(),
892    Action::FocusStatus => app.focus_status(),
893    Action::Filter => app.enter_filter(),
894    // Issue #231: the user-initiated refresh runs off-thread so a large
895    // repo / slow filesystem no longer freezes the TUI. A failed re-list
896    // now surfaces on the status bar instead of tearing down the loop.
897    Action::Refresh => app.request_refresh(),
898    Action::Help => app.enter_help(),
899    // #290: `Y` yanks the worktree path (was `y` before #290).
900    Action::YankPath => yank_selected_path_to_clipboard(app),
901    // #290: `y` yanks the branch name.
902    Action::YankBranchName => yank_selected_branch_to_clipboard(app),
903    // #290: `w` yanks the worktree slug/name.
904    Action::YankWorktreeName => yank_selected_worktree_name_to_clipboard(app),
905    // #290: TerminalFullscreen replaces Open — open the shell/editor/finder
906    // target fullscreen (honours [tui.open] config, same as the old `o`).
907    Action::TerminalFullscreen => match app.resolve_open_target() {
908      None => app.status = "nothing selected".into(),
909      Some(OpenTarget::Finder { .. }) => app.open_selected_in_finder(),
910      Some(OpenTarget::Shell { path, command }) => run_subshell(terminal, &command, &[], Some(&path), app, "shell")?,
911      Some(OpenTarget::Editor { path, command }) => {
912        let path_str = path.display().to_string();
913        run_subshell(terminal, &command, &[&path_str], None, app, "editor")?
914      }
915    },
916    // #290: TerminalPty replaces OpenTerminalOverlay — open a native $SHELL
917    // in an embedded PTY overlay rooted at the selected worktree's path.
918    Action::TerminalPty => {
919      let cwd = app.selected().map(|wt| wt.path.clone());
920      match cwd {
921        None => app.status = "nothing selected".into(),
922        Some(path) => {
923          #[cfg(windows)]
924          let shell = std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".into());
925          #[cfg(not(windows))]
926          let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into());
927          let sz = terminal.size().unwrap_or_default();
928          let inner_cols = ((sz.width as u32 * 90 / 100) as u16).saturating_sub(6).max(20);
929          let inner_rows = ((sz.height as u32 * 90 / 100) as u16).saturating_sub(4).max(5);
930          match PtyOverlay::spawn(PtyKind::Terminal, &[shell.as_str()], &path, inner_cols, inner_rows) {
931            Ok(pty) => app.open_pty_overlay(pty),
932            Err(e) => app.status = format!("terminal overlay failed: {}", e),
933          }
934        }
935      }
936    }
937    // #290: LazyGitFullscreen replaces GitTui.
938    Action::LazyGitFullscreen => {
939      if let Some(plan) = app.prepare_git_tui() {
940        run_launcher(terminal, plan, app)?;
941      }
942    }
943    // #290: LazyGitPty replaces GitTuiOverlay — open lazygit in an embedded
944    // PTY overlay sized to 90% × 90% of the terminal.
945    Action::LazyGitPty => {
946      if let Some(plan) = app.prepare_git_tui() {
947        let sz = terminal.size().unwrap_or_default();
948        let inner_cols = ((sz.width as u32 * 90 / 100) as u16).saturating_sub(6).max(20);
949        let inner_rows = ((sz.height as u32 * 90 / 100) as u16).saturating_sub(4).max(5);
950        let argv: Vec<String> = plan.expanded.argv.clone();
951        let argv_refs: Vec<&str> = argv.iter().map(String::as_str).collect();
952        match PtyOverlay::spawn(PtyKind::LazyGit, &argv_refs, &plan.cwd, inner_cols, inner_rows) {
953          Ok(pty) => app.open_pty_overlay(pty),
954          Err(e) => app.status = format!("lazygit overlay failed: {}", e),
955        }
956      }
957    }
958    // #290: ReviewPty replaces ReviewOverlay — open the review tool in an
959    // embedded PTY overlay. Picker-gated: branch-specific, meaningless in
960    // `gwm switch`.
961    Action::ReviewPty if !app.picker_mode => {
962      if let Some(mut plan) = app.prepare_review() {
963        let sz = terminal.size().unwrap_or_default();
964        let inner_cols = ((sz.width as u32 * 90 / 100) as u16).saturating_sub(6).max(20);
965        let inner_rows = ((sz.height as u32 * 90 / 100) as u16).saturating_sub(4).max(5);
966        let argv: Vec<String> = plan.expanded.argv.clone();
967        let argv_refs: Vec<&str> = argv.iter().map(String::as_str).collect();
968        match PtyOverlay::spawn(PtyKind::Review, &argv_refs, &plan.cwd, inner_cols, inner_rows) {
969          Ok(mut pty) => {
970            pty.diff_file = plan.expanded.diff_file.take();
971            app.open_pty_overlay(pty);
972          }
973          Err(e) => app.status = format!("review overlay failed: {}", e),
974        }
975      }
976    }
977    Action::Create if !app.picker_mode => app.enter_create(),
978    Action::DeleteConfirm if !app.picker_mode => app.enter_confirm_delete(),
979    Action::Bootstrap if !app.picker_mode => app.bootstrap_selected(),
980    // Issue #258: `gwm sync` of the selected worktree, off-thread.
981    Action::Sync if !app.picker_mode => app.request_sync(),
982    // #290: `p` pulls, `P` pushes, both off-thread.
983    Action::Pull if !app.picker_mode => app.request_pull(),
984    Action::Push if !app.picker_mode => app.request_push(),
985    // #290: `c` opens the branch-rename modal.
986    Action::EditWorktree if !app.picker_mode => app.enter_edit_worktree(),
987    Action::CiChecks if !app.picker_mode => app.enter_ci_checks(),
988    // #290: `e` exits TUI and prints selected path to stdout.
989    Action::ExitToWorktree => app.exit_to_worktree(),
990    // #290: `t` opens the selected worktree in a new mux pane/tab.
991    Action::MuxPane if !app.picker_mode => app.open_in_mux_pane(),
992    // #290: `h`/`H` fire user macros from [tui.macro1]/[tui.macro2].
993    Action::Macro1 if !app.picker_mode => run_macro(terminal, app, 1)?,
994    Action::Macro2 if !app.picker_mode => run_macro(terminal, app, 2)?,
995    Action::ToggleDeleteBranch if !app.picker_mode => app.toggle_delete_branch(),
996    // #290: BrowseLinks replaces OpenMenu.
997    Action::BrowseLinks if !app.picker_mode => app.enter_open_menu(),
998    // Not picker-gated — `gwm switch` can open docs too.
999    Action::OpenDocs => open_url(DOCS_URL, app),
1000    Action::LinkPrompt if !app.picker_mode => app.enter_link_prompt(),
1001    Action::FetchGithub if !app.picker_mode => app.refresh_github_status(),
1002    // #290: ReviewFullscreen replaces Review.
1003    Action::ReviewFullscreen if !app.picker_mode => {
1004      if let Some(plan) = app.prepare_review() {
1005        run_launcher(terminal, plan, app)?;
1006      }
1007    }
1008    // Issue #32: pressing `:` (or any user-rebound key for
1009    // `Action::CommandPalette`) opens the palette overlay. Inside
1010    // the palette, the user can type `:command-palette` to reopen
1011    // it — harmless, but explicitly handled here so the palette →
1012    // CommandPalette → run_action loop terminates cleanly (the
1013    // overlay just stays open).
1014    Action::CommandPalette => app.open_command_palette(),
1015    // Issue #226: `3` opens the Command Logs overlay. Not picker-gated —
1016    // it is a read-only transcript, harmless inside `gwm switch`, and
1017    // mirrors Help / the palette which also open from any List state.
1018    Action::CommandLogs => app.enter_command_logs(),
1019    // Issue #232: `4` opens the Configuration panel. Like the Command Logs
1020    // overlay it is read-only and not picker-gated — harmless inside
1021    // `gwm switch`, opening from any List state.
1022    Action::ConfigPanel => app.enter_config_panel(),
1023    // Issue #325: `x` opens the exec profile picker. Picker-gated —
1024    // running a profile in a PTY is a focus-mode action, meaningless in
1025    // the stripped-down `gwm switch` picker.
1026    Action::ExecOverlay if !app.picker_mode => app.enter_exec_picker(),
1027    // Issue #325: `X` opens the clean reclaim overlay. Picker-gated — it
1028    // deletes from the selected worktree, a focus-mode action.
1029    Action::CleanOverlay if !app.picker_mode => app.enter_clean_overlay(),
1030    // Issue #408: `a` opens the agent-session detail overlay. Read-only, but
1031    // picker-gated like the other overlays — the stripped-down `gwm switch`
1032    // picker advertises pick/cancel only.
1033    Action::AgentSessions if !app.picker_mode => app.open_agent_overlay(),
1034    // Picker-mode-gated actions fall through to no-op when the
1035    // guard fails (i.e. the user pressed them inside `gwm switch`).
1036    // Same fallthrough catches future actions not yet wired into
1037    // the List view.
1038    _ => {}
1039  }
1040  Ok(())
1041}
1042
1043/// Dispatch an action accepted from the command palette (issue #32).
1044/// Thin wrapper around [`run_action`] so the call site in
1045/// `View::CommandPalette` reads symmetrically with the keystroke
1046/// path. Distinct name keeps stack traces meaningful — if a feature
1047/// fires only from the palette and breaks, the frame name names it.
1048fn run_palette_action(
1049  terminal: &mut Terminal<CrosstermBackend<io::Stderr>>,
1050  app: &mut App,
1051  action: Action,
1052) -> Result<()> {
1053  run_action(terminal, app, action)
1054}
1055
1056/// `LauncherPlan` is consumed by-value so the `{diff}` tempfile it
1057/// carries lives at least until the child process has been waited on.
1058/// Errors are never propagated — the user pressed a key in the TUI,
1059/// and surfacing failures via the status bar is the documented
1060/// contract (see [`Self::run_lazygit`] in the pre-issue-#75 codebase).
1061/// Whether a fullscreen child's stdout must be re-routed to the controlling
1062/// terminal. True exactly when gwm's own stdout is *not* a tty — i.e. it is
1063/// the command-substitution pipe of a `cd "$(gwm)"` wrapper reading the
1064/// exit-to-worktree path (#290). Inheriting that pipe would send the child's
1065/// TUI frames / ANSI into the captured path (Codex review on PR #292). Pure
1066/// so the policy is unit-testable without a real pipe.
1067pub fn wants_child_stdout_on_tty(stdout_is_terminal: bool) -> bool {
1068  !stdout_is_terminal
1069}
1070
1071/// Point `command`'s stdout at `/dev/tty` when gwm's stdout is captured, so a
1072/// fullscreen child never writes into the `cd "$(gwm)"` pipe. No-op when
1073/// stdout is already a tty, on non-unix, or when `/dev/tty` can't be opened
1074/// (then inherit and accept the captured-pipe risk rather than fail the
1075/// launch).
1076fn route_fullscreen_child_stdout(command: &mut std::process::Command) {
1077  use std::io::IsTerminal;
1078  if !wants_child_stdout_on_tty(std::io::stdout().is_terminal()) {
1079    return;
1080  }
1081  #[cfg(unix)]
1082  if let Ok(tty) = std::fs::OpenOptions::new().write(true).open("/dev/tty") {
1083    command.stdout(std::process::Stdio::from(tty));
1084  }
1085  // No `/dev/tty` equivalent here, so the child inherits stdout. Bind the
1086  // param to silence the unused-variable error under `-D warnings` on the
1087  // non-unix build (CI windows-latest caught this).
1088  #[cfg(not(unix))]
1089  let _ = command;
1090}
1091
1092fn run_launcher(
1093  terminal: &mut Terminal<CrosstermBackend<io::Stderr>>,
1094  plan: app::LauncherPlan,
1095  app: &mut App,
1096) -> Result<()> {
1097  use std::process::{Command, Stdio};
1098
1099  let argv = plan.expanded.argv.clone();
1100  let Some((bin, rest)) = argv.split_first() else {
1101    app.status = "launcher template produced an empty argv".into();
1102    return Ok(());
1103  };
1104
1105  // Probe `$PATH` before paying the suspend/restore tax. Missing
1106  // binaries get a clean status-bar error instead of a flicker.
1107  if which::which(bin).is_err() {
1108    app.status = format!(
1109      "`{}` not on $PATH — install it or change [review]/[git_tui] in .gwm.toml",
1110      bin
1111    );
1112    return Ok(());
1113  }
1114
1115  if plan.fullscreen {
1116    disable_raw_mode()?;
1117    execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
1118    terminal.show_cursor()?;
1119
1120    let mut cmd = Command::new(bin);
1121    cmd.args(rest).current_dir(&plan.cwd);
1122    route_fullscreen_child_stdout(&mut cmd);
1123    let spawn = cmd.status();
1124
1125    enable_raw_mode()?;
1126    execute!(terminal.backend_mut(), EnterAlternateScreen, EnableMouseCapture)?;
1127    terminal.clear()?;
1128
1129    match spawn {
1130      Ok(s) if s.success() => app.status = format!("{} exited ok", bin),
1131      Ok(s) => app.status = format!("{} exited with code {:?}", bin, s.code()),
1132      Err(e) => app.status = format!("failed to launch {}: {}", bin, e),
1133    }
1134  } else {
1135    // Non-TUI tool: capture stderr so its first line can land in the
1136    // status bar without taking over the screen. stdout is dropped on
1137    // the floor — printing it would crash through ratatui's frame.
1138    let out = Command::new(bin)
1139      .args(rest)
1140      .current_dir(&plan.cwd)
1141      .stdout(Stdio::null())
1142      .stderr(Stdio::piped())
1143      .output();
1144    match out {
1145      Ok(o) if o.status.success() => app.status = format!("{} done", bin),
1146      Ok(o) => {
1147        let first = String::from_utf8_lossy(&o.stderr)
1148          .lines()
1149          .next()
1150          .unwrap_or_default()
1151          .trim()
1152          .to_string();
1153        app.status = if first.is_empty() {
1154          format!("{} exited with code {:?}", bin, o.status.code())
1155        } else {
1156          format!("{}: {}", bin, first)
1157        };
1158      }
1159      Err(e) => app.status = format!("failed to launch {}: {}", bin, e),
1160    }
1161  }
1162  // `plan.expanded.diff_file` drops here, unlinking the tempfile if any.
1163  drop(plan);
1164  Ok(())
1165}
1166
1167/// Suspend the TUI, spawn `cmd args...` (optionally with `cwd`), wait for
1168/// its exit, then restore the TUI. Used by the `o: open` dispatch when the
1169/// resolved [`OpenTarget`] is `Shell` or `Editor`. The lifecycle is
1170/// identical to [`run_lazygit`] so the user can't observe a difference
1171/// between pressing `l` (lazygit) and pressing `o` with `mode = "shell"`.
1172///
1173/// `label` is the noun used in status-bar messages (`"shell"`, `"editor"`).
1174fn run_subshell(
1175  terminal: &mut Terminal<CrosstermBackend<io::Stderr>>,
1176  cmd: &str,
1177  args: &[&str],
1178  cwd: Option<&std::path::Path>,
1179  app: &mut App,
1180  label: &str,
1181) -> Result<()> {
1182  disable_raw_mode()?;
1183  execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
1184  terminal.show_cursor()?;
1185
1186  let mut command = std::process::Command::new(cmd);
1187  command.args(args);
1188  if let Some(dir) = cwd {
1189    command.current_dir(dir);
1190  }
1191  route_fullscreen_child_stdout(&mut command);
1192  let spawn = command.status();
1193
1194  // Always restore the TUI, even if the child failed to spawn or exited non-zero.
1195  enable_raw_mode()?;
1196  execute!(terminal.backend_mut(), EnterAlternateScreen, EnableMouseCapture)?;
1197  terminal.clear()?;
1198
1199  match spawn {
1200    Ok(s) if s.success() => app.status = format!("{} exited ok ({})", label, cmd),
1201    Ok(s) => app.status = format!("{} exited with code {:?}", label, s.code()),
1202    Err(e) => app.status = format!("failed to launch {} ({}): {}", label, cmd, e),
1203  }
1204  Ok(())
1205}
1206
1207/// Push the selected worktree's path into the system clipboard via
1208/// [`clipboard_candidates`]. Walks the candidates in order, uses the
1209/// first one whose binary is on `$PATH`, and feeds the path through
1210/// its stdin. Failures and "no tool found" both surface in the status
1211/// bar — no propagation, the TUI must never die on a clipboard miss.
1212fn yank_selected_path_to_clipboard(app: &mut App) {
1213  let Some(path) = app.yank_selected_path() else {
1214    app.status = "nothing selected".into();
1215    return;
1216  };
1217  let text = path.display().to_string();
1218  copy_text_to_clipboard(app, &text, "yanked path");
1219}
1220
1221fn yank_selected_branch_to_clipboard(app: &mut App) {
1222  let Some(branch) = app.yank_selected_branch() else {
1223    app.status = "nothing selected or no branch (detached HEAD)".into();
1224    return;
1225  };
1226  copy_text_to_clipboard(app, &branch, "yanked branch name");
1227}
1228
1229fn yank_selected_worktree_name_to_clipboard(app: &mut App) {
1230  let Some(name) = app.yank_selected_worktree_name() else {
1231    app.status = "nothing selected".into();
1232    return;
1233  };
1234  copy_text_to_clipboard(app, &name, "yanked worktree name");
1235}
1236
1237/// Fire a user macro (#290). `n` is 1 for `Macro1`/`h`, 2 for `Macro2`/`H`.
1238/// Reads `[tui.macro1]` / `[tui.macro2]` from config; no-ops when absent.
1239fn run_macro(terminal: &mut Terminal<CrosstermBackend<io::Stderr>>, app: &mut App, n: u8) -> Result<()> {
1240  use crate::config::MacroOpenMode;
1241  let cfg = if n == 1 {
1242    app.config.tui.macro1.clone()
1243  } else {
1244    app.config.tui.macro2.clone()
1245  };
1246  let Some(macro_cfg) = cfg else {
1247    app.status = format!("macro{} not configured — add [tui.macro{}] to .gwm.toml", n, n);
1248    return Ok(());
1249  };
1250  use crate::multiplexer::{build_tmux_command, build_zellij_command, detect_tmux, detect_zellij, SpawnMode};
1251  // Macros run in the selected worktree. With nothing selected (e.g. a filter
1252  // with no matches), refuse rather than silently running in the main repo —
1253  // a destructive command must not hit the wrong tree (Codex review on #292).
1254  let Some(path) = app.selected().map(|w| w.path.clone()) else {
1255    app.status = format!("macro{}: nothing selected", n);
1256    return Ok(());
1257  };
1258
1259  #[cfg(windows)]
1260  let shell = std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".into());
1261  #[cfg(not(windows))]
1262  let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into());
1263  let shell_flag = if cfg!(windows) { "/C" } else { "-c" };
1264
1265  // Resolve the mux command up front so a `mux_pane` macro can fall back to the
1266  // PTY overlay when no multiplexer is active (the documented behaviour — Codex
1267  // review on PR #292), rather than no-oping.
1268  let mux_cmd = if matches!(macro_cfg.open_in, MacroOpenMode::MuxPane) {
1269    let label = format!("macro{}", n);
1270    if detect_tmux(std::env::var("TMUX").ok()) {
1271      Some(build_tmux_command(&label, &path, SpawnMode::Split))
1272    } else if detect_zellij(std::env::var("ZELLIJ").ok()) {
1273      Some(build_zellij_command(&label, &path, SpawnMode::Split))
1274    } else {
1275      app.status = format!("macro{}: no multiplexer — falling back to PTY overlay", n);
1276      None
1277    }
1278  } else {
1279    None
1280  };
1281
1282  if let Some(cmd) = mux_cmd {
1283    let bin = cmd[0].as_str();
1284    let mut full_cmd: Vec<&str> = cmd[1..].iter().map(String::as_str).collect();
1285    if bin == "zellij" {
1286      // `zellij action new-pane` runs the trailing argv DIRECTLY, not via a
1287      // shell, so a command with spaces/shell syntax must be wrapped in
1288      // `-- <shell> -c <cmd>` (Codex review on PR #292).
1289      full_cmd.push("--");
1290      full_cmd.push(shell.as_str());
1291      full_cmd.push(shell_flag);
1292      full_cmd.push(macro_cfg.command.as_str());
1293    } else {
1294      // tmux takes the command as a SINGLE shell-command operand and hands it
1295      // to the shell itself, so we pass it as one trailing argument rather than
1296      // pre-splitting into `sh -c <cmd>`.
1297      full_cmd.push(macro_cfg.command.as_str());
1298    }
1299    match std::process::Command::new(bin).args(&full_cmd).spawn() {
1300      Ok(_) => app.status = format!("macro{} opened in mux pane", n),
1301      Err(e) => app.status = format!("macro{} mux failed: {}", n, e),
1302    }
1303  } else {
1304    // PTY overlay: the explicit `pty` mode, or the `mux_pane` fallback above.
1305    let sz = terminal.size().unwrap_or_default();
1306    let inner_cols = ((sz.width as u32 * 90 / 100) as u16).saturating_sub(6).max(20);
1307    let inner_rows = ((sz.height as u32 * 90 / 100) as u16).saturating_sub(4).max(5);
1308    let argv = [shell.as_str(), shell_flag, macro_cfg.command.as_str()];
1309    match PtyOverlay::spawn(PtyKind::Terminal, &argv, &path, inner_cols, inner_rows) {
1310      Ok(pty) => app.open_pty_overlay(pty),
1311      Err(e) => app.status = format!("macro{} overlay failed: {}", n, e),
1312    }
1313  }
1314  Ok(())
1315}
1316
1317/// Copy the Command Logs transcript to the clipboard (issue #279, `y`).
1318/// Builds the plain-text transcript from owned state, then hands it to the
1319/// shared clipboard helper. Empty transcript → a status note, no spawn.
1320fn copy_command_logs_to_clipboard(app: &mut App) {
1321  let text = app.command_logs_transcript();
1322  if text.is_empty() {
1323    app.status = "no commands to copy".into();
1324    return;
1325  }
1326  copy_text_to_clipboard(app, &text, "copied command logs");
1327}
1328
1329/// Put `text` on the clipboard, honouring `[tui] clipboard` (issue #367).
1330///
1331/// The single chokepoint for every yank action, so routing lives here rather
1332/// than in each caller. [`crate::clipboard::plan_clipboard_write`] makes the
1333/// decision (it is pure and unit-tested); this function only performs it.
1334///
1335/// `success` is the status-bar label on a clean copy — suffixed with the path
1336/// that actually ran (`(osc52)` / `(pbcopy)`). That suffix is load-bearing:
1337/// OSC52 is never acknowledged by the terminal, so when a paste comes back
1338/// empty the status line is the only clue about which route was taken.
1339fn copy_text_to_clipboard(app: &mut App, text: &str, success: &str) {
1340  use crate::clipboard::{plan_clipboard_write, ClipboardPlan};
1341  use std::io::Write;
1342
1343  let plan = plan_clipboard_write(
1344    text,
1345    app.config.tui.clipboard,
1346    // `$SSH_TTY` covers an interactive login; `$SSH_CONNECTION` also covers
1347    // the cases where no tty was allocated.
1348    std::env::var_os("SSH_TTY").is_some() || std::env::var_os("SSH_CONNECTION").is_some(),
1349    crate::multiplexer::detect_tmux(std::env::var("TMUX").ok()),
1350    std::env::var_os("STY").is_some(),
1351  );
1352  match plan {
1353    ClipboardPlan::Osc52(bytes) => {
1354      // The TUI renders to stderr, so the sequence goes to the same fd. It is
1355      // an escape sequence, not cells, so ratatui's next draw won't erase it —
1356      // but it must be flushed, or it sits in the buffer until the next frame.
1357      let mut err = std::io::stderr();
1358      match err.write_all(&bytes).and_then(|_| err.flush()) {
1359        Ok(()) => app.status = format!("{} (osc52)", success),
1360        Err(e) => app.status = format!("osc52 write failed: {}", e),
1361      }
1362      return;
1363    }
1364    ClipboardPlan::TooLarge { bytes } => {
1365      // Refuse rather than emit a sequence the terminal will truncate into
1366      // corrupt paste content. Round the reported size *up*: truncating
1367      // division renders 65_537 bytes as "64 KiB > 64 KiB", which reads as a
1368      // bug in the check rather than as a reason for the refusal.
1369      app.status = format!(
1370        "too large for osc52 ({} KiB > {} KiB) — set [tui] clipboard = \"tools\"",
1371        bytes.div_ceil(1024),
1372        crate::clipboard::MAX_OSC52_BYTES / 1024
1373      );
1374      return;
1375    }
1376    ClipboardPlan::Tools => {}
1377  }
1378
1379  for (cmd, args) in clipboard_candidates() {
1380    if which::which(cmd).is_err() {
1381      continue;
1382    }
1383    let child = std::process::Command::new(cmd)
1384      .args(&args)
1385      .stdin(std::process::Stdio::piped())
1386      .stdout(std::process::Stdio::null())
1387      .stderr(std::process::Stdio::null())
1388      .spawn();
1389    match child {
1390      Ok(mut c) => {
1391        if let Some(mut stdin) = c.stdin.take() {
1392          let _ = stdin.write_all(text.as_bytes());
1393        }
1394        match c.wait() {
1395          Ok(s) if s.success() => {
1396            app.status = format!("{} ({})", success, cmd);
1397            return;
1398          }
1399          Ok(s) => {
1400            app.status = format!("{} exited with code {:?}", cmd, s.code());
1401            return;
1402          }
1403          Err(e) => {
1404            app.status = format!("{} wait failed: {}", cmd, e);
1405            return;
1406          }
1407        }
1408      }
1409      Err(e) => {
1410        // Tool was resolvable on PATH but spawning failed — surface and stop;
1411        // trying the next candidate would mask the real error.
1412        app.status = format!("failed to spawn {}: {}", cmd, e);
1413        return;
1414      }
1415    }
1416  }
1417  app.status = "y: no clipboard tool found (install pbcopy / wl-copy / xclip / xsel / clip)".into();
1418}
1419
1420/// Canonical documentation URL opened by the `.` key (issue #233).
1421///
1422/// Derived from the crate's `repository` (Cargo.toml) so a fork points at
1423/// its own docs without a patch — there is no standalone docs site
1424/// deployed yet, so the MVP target is the docs tree on the repo's default
1425/// branch. A `[docs]` config override is a possible follow-up.
1426pub const DOCS_URL: &str = concat!(env!("CARGO_PKG_REPOSITORY"), "/tree/main/docs");
1427
1428/// Spawn the OS opener for `url` (used by the OpenMenu key handler and the
1429/// `.` open-docs key, issue #233).
1430/// Failures land in the status bar — we never propagate up.
1431fn open_url(url: &str, app: &mut App) {
1432  let opener = if cfg!(target_os = "macos") {
1433    "open"
1434  } else if cfg!(target_os = "windows") {
1435    "explorer"
1436  } else {
1437    "xdg-open"
1438  };
1439  match std::process::Command::new(opener).arg(url).spawn() {
1440    Ok(_) => app.status = format!("opened {}", url),
1441    Err(e) => app.status = format!("failed to open {}: {}", url, e),
1442  }
1443}