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