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}