mod app;
#[doc(hidden)]
pub mod commit_graph;
pub mod keymap;
pub mod modal_keymap;
pub mod palette;
pub mod state;
pub mod theme;
mod ui;
pub mod wt_tree;
use crate::error::Result;
use crate::tui::keymap::Action;
use crate::tui::modal_keymap::{KeyContext, ModalAction};
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use std::io;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
pub use app::{
read_pins_from_sources, App, CreateKey, ExecPickerKey, LauncherPlan, LinkPromptKey, LinkPromptStage, LinkTarget,
OpenTarget, RepoMeta, View, WorkspaceState,
};
pub use state::async_task::{CreateWorktreeResult, TaskKind, TaskMsg, TaskRunner};
pub use state::clean_overlay::CleanOverlay;
pub use state::command_logs::CommandLogs;
pub use state::config_panel::{
build_key_rows, ConfigPanel, FieldKind, KeyCapture, KeyRow, KeyTarget, SettingField, SettingsLayer, SettingsTab,
};
pub use state::confirm::{ConfirmButton, ConfirmKeyAction, ConfirmModal, CountdownTickOutcome};
pub use state::create_form::{CreateForm, Field};
pub use state::exec_picker::ExecPicker;
pub use state::filter::FilterState;
pub use state::github_fetch::{FetchKey, GitHubFetch, GitHubFetchState};
pub use state::link_prompt::LinkPrompt;
pub use state::pty_overlay::{key_to_bytes, PtyKind, PtyOverlay};
pub use state::sidebar::SidebarState;
pub fn clipboard_candidates() -> Vec<(&'static str, Vec<&'static str>)> {
if cfg!(target_os = "macos") {
vec![("pbcopy", vec![])]
} else if cfg!(target_os = "windows") {
vec![("clip", vec![])]
} else {
vec![
("wl-copy", vec![]),
("xclip", vec!["-selection", "clipboard"]),
("xsel", vec!["--clipboard", "--input"]),
]
}
}
pub use ui::{
agent_cell_label, agent_pane_lines, agents_pane_title, author_initials, badge_group_width, bootstrap_report_lines,
branch_name_color, branch_status_color, build_sidebar_payload, build_sidebar_sections, centered_abs, chip_style,
ci_indicator, clean_dir_icon, command_logs_footer_hints, config_capture_footer_hints, config_edit_footer_hints,
config_nav_footer_hints, confirm_buttons_line, confirm_delete_branch_line, confirm_detail_line, create_buttons_line,
delete_worktree_title, ellipsize_middle, field_input_line, filled_cells_for_progress, footer_line, format_status,
freshness_color, github_status_lines, header_line, help_body_section_color, help_entry_line, help_label_style,
help_lines, help_rows, help_section_style, hint_key_style, hint_label_style, issue_badge_color, issue_pr_pane_title,
issue_summary_line, link_open_modal_lines, link_prompt_modal_width, link_target_keys, link_target_line,
modal_hint_for_context, modal_hint_for_context_with_fields, modal_hint_line, overlay_modal_width, palette_name_style,
pane_counter, panel_border_color, picker_window, pr_badge_color, pr_summary_line, recent_commits_lines,
recent_items_pane_title, reclaim_size_color, rename_buttons_line, status_line, status_pane_title, table_marker,
tilde_compress_with_home, type_selector_line, working_tree_counts_footer, working_tree_pane_title,
working_tree_status_counts, working_tree_status_line, worktree_name_style, worktree_path_style, worktrees_pane_title,
HelpRow, HintContext, SidebarSections, WorkingTreeCounts, COMMIT_HASH_DISPLAY_LEN, ISSUE_ICON, PR_ICON,
RECENT_COMMITS_LIMIT, WT_CREATED_ICON, WT_DELETED_ICON, WT_MODIFIED_ICON,
};
#[doc(hidden)]
pub use ui::draw;
pub fn run(trust_mode: crate::trust::TrustMode) -> Result<()> {
let app = App::new()?.with_trust_mode(trust_mode);
let mut terminal = enter_terminal()?;
let result = run_app(&mut terminal, app);
leave_terminal(&mut terminal)?;
if let Some(path) = result? {
println!("{}", path.display());
}
Ok(())
}
pub fn run_workspace(root: &Path, trust_mode: crate::trust::TrustMode) -> Result<()> {
let app =
App::new_workspace_at_layered(root, crate::config::global_config_path().as_deref())?.with_trust_mode(trust_mode);
let mut terminal = enter_terminal()?;
let result = run_app(&mut terminal, app);
leave_terminal(&mut terminal)?;
if let Some(path) = result? {
println!("{}", path.display());
}
Ok(())
}
pub fn run_picker() -> Result<Option<PathBuf>> {
let app = App::new_picker_at(None)?;
let mut terminal = enter_terminal()?;
let result = run_app(&mut terminal, app);
leave_terminal(&mut terminal)?;
result
}
fn enter_terminal() -> Result<Terminal<CrosstermBackend<io::Stderr>>> {
enable_raw_mode()?;
let mut stderr = io::stderr();
execute!(stderr, EnterAlternateScreen, EnableMouseCapture)?;
Ok(Terminal::new(CrosstermBackend::new(stderr))?)
}
fn leave_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stderr>>) -> Result<()> {
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
terminal.show_cursor()?;
Ok(())
}
fn confirm_fire(app: &mut App) {
if app.is_delete_worktree_loading() {
app.status = TaskKind::DeleteWorktree.loading_label().into();
return;
}
match app.confirm_press_y(Instant::now()) {
ConfirmKeyAction::FireNow => {
if let Err(e) = app.confirm_delete() {
app.status = format!("delete failed: {}", e);
}
}
ConfirmKeyAction::Armed | ConfirmKeyAction::Disarmed => {}
}
}
fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stderr>>, mut app: App) -> Result<Option<PathBuf>> {
loop {
let now = Instant::now();
app.drain_task_results();
app.tick_ci_overlay_durations();
if app.is_github_loading() || app.is_task_loading() {
app.spinner.tick();
}
if app.should_quit {
if app.can_quit_now() {
break;
}
app.defer_quit_for_mutating_task();
}
if app.view == View::CommandLogs {
app.command_logs.sync();
}
if !app.destructive_overlay_open() {
app.maybe_auto_refresh(now);
}
if app.view == View::Pty {
let status = app.pty_overlay.as_mut().map(|p| {
p.poll_bytes();
(p.kind, p.is_alive())
});
match status {
Some((PtyKind::Exec, false)) => {
if let Some(p) = app.pty_overlay.as_mut() {
p.mark_finished();
}
}
Some((_, false)) | None => app.close_pty_overlay(),
Some((_, true)) => {}
}
}
if !app.destructive_overlay_open() {
app.sync_active_repo();
app.maybe_refresh_sidebar();
app.maybe_refresh_agent_sessions();
}
terminal.draw(|f| ui::draw(f, &mut app))?;
if app.view == View::Confirm {
if app.confirm.is_armed() {
app.spinner.tick();
}
match app.tick_confirm_countdown(now) {
CountdownTickOutcome::ReadyToFire => {
if let Err(e) = app.confirm_delete() {
app.status = format!("delete failed: {}", e);
}
}
CountdownTickOutcome::Pending | CountdownTickOutcome::NotArmed => {}
}
}
if app.view == View::CleanReport {
if app.clean_overlay.confirm.is_armed() {
app.spinner.tick();
}
match app.tick_clean_countdown(now) {
CountdownTickOutcome::ReadyToFire => app.clean_overlay_delete(),
CountdownTickOutcome::Pending | CountdownTickOutcome::NotArmed => {}
}
}
let poll_ms = if app.view == View::Pty || app.tasks.is_loading(TaskKind::Sidebar) {
50
} else {
200
};
if !event::poll(Duration::from_millis(poll_ms))? {
continue;
}
let ev = event::read()?;
if let Event::Resize(cols, rows) = ev {
if app.view == View::Pty {
if let Some(ref mut pty) = app.pty_overlay {
let inner_cols = ((cols as u32 * 90 / 100) as u16).saturating_sub(6).max(10);
let inner_rows = ((rows as u32 * 90 / 100) as u16).saturating_sub(4).max(5);
pty.resize(inner_cols, inner_rows);
}
}
terminal.clear()?;
continue;
}
let Event::Key(key) = ev else { continue };
if key.kind != KeyEventKind::Press {
continue;
}
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
if app.view == View::Pty {
if let Some(ref mut pty) = app.pty_overlay {
let _ = pty.write_key(key);
}
continue;
}
app.should_quit = true;
if app.can_quit_now() {
break;
}
app.defer_quit_for_mutating_task();
continue;
}
if app.should_quit {
continue;
}
match app.view {
View::List if app.filter.active => match key.code {
KeyCode::Esc => {
if app.picker_mode {
app.picker_cancel();
} else {
app.exit_filter_cancel();
}
}
KeyCode::Enter => {
app.exit_filter_keep();
if app.picker_mode {
app.picker_confirm();
}
}
KeyCode::Backspace => app.filter_pop_char(),
KeyCode::Char(c) => app.filter_push_char(c),
_ => {}
},
View::List => {
if key.code == KeyCode::Esc {
app.cancel_pending_motion();
if !app.filter.query().is_empty() {
app.exit_filter_cancel();
} else {
app.should_quit = true;
}
} else if key.code == KeyCode::Enter {
app.cancel_pending_motion();
if app.picker_mode {
app.picker_confirm();
} else {
app.copy_path_to_status();
}
} else if let Some(action) = app.dispatch_key(key) {
if matches!(action, Action::Quit) {
app.should_quit = true;
} else {
let action = app.resolve_contextual_action(action);
run_action(terminal, &mut app, action)?;
}
}
}
View::Help => match app.resolve_modal(KeyContext::Help, key) {
Some(ModalAction::HelpClose) => app.view = View::List,
Some(ModalAction::HelpScrollDown) => app.help_scroll_down(),
Some(ModalAction::HelpScrollUp) => app.help_scroll_up(),
Some(ModalAction::HelpScrollRight) => app.help_scroll_right(),
Some(ModalAction::HelpScrollLeft) => app.help_scroll_left(),
Some(ModalAction::HelpScrollTop) => app.help_scroll = 0,
Some(ModalAction::HelpScrollBottom) => app.help_scroll = app.help_max_scroll,
_ => {}
},
View::CommandLogs => match app.resolve_modal(KeyContext::CommandLogs, key) {
Some(ModalAction::CommandLogsClose) => app.view = View::List,
Some(ModalAction::CommandLogsCopy) => copy_command_logs_to_clipboard(&mut app),
Some(ModalAction::CommandLogsScrollDown) => app.command_logs.scroll_down(),
Some(ModalAction::CommandLogsScrollUp) => app.command_logs.scroll_up(),
Some(ModalAction::CommandLogsScrollRight) => app.command_logs.scroll_right(),
Some(ModalAction::CommandLogsScrollLeft) => app.command_logs.scroll_left(),
Some(ModalAction::CommandLogsScrollTop) => app.command_logs.scroll_to_top(),
Some(ModalAction::CommandLogsScrollBottom) => app.command_logs.scroll_to_bottom(),
_ if app.key_matches_action(key, Action::CommandLogs) => app.view = View::List,
_ => {}
},
View::Config if app.config_panel.capture.is_some() => app.handle_capture_key(key),
View::Config if app.config_panel.editing.is_some() => app.handle_settings_edit_key(key),
View::Config => {
let on_all = app.config_panel.tab == SettingsTab::All;
match app.resolve_modal(KeyContext::Config, key) {
Some(ModalAction::ConfigClose) => app.view = View::List,
Some(ModalAction::ConfigNextTab) => app.config_panel.next_tab(),
Some(ModalAction::ConfigPrevTab) => app.config_panel.prev_tab(),
Some(ModalAction::ConfigToggleLayer) => app.config_panel.toggle_layer(),
Some(ModalAction::ConfigActivate) => {
if app.config_panel.tab == SettingsTab::Keys {
app.config_panel.begin_capture();
} else {
app.activate_selected_setting();
}
}
Some(ModalAction::ConfigSelectNext) => {
if on_all {
app.config_panel.scroll_down();
} else {
app.config_panel.select_next();
}
}
Some(ModalAction::ConfigSelectPrev) => {
if on_all {
app.config_panel.scroll_up();
} else {
app.config_panel.select_prev();
}
}
Some(ModalAction::ConfigScrollRight) if on_all => app.config_panel.scroll_right(),
Some(ModalAction::ConfigScrollLeft) if on_all => app.config_panel.scroll_left(),
Some(ModalAction::ConfigScrollTop) if on_all => app.config_panel.scroll_to_top(),
Some(ModalAction::ConfigScrollBottom) if on_all => app.config_panel.scroll_to_bottom(),
_ if app.key_matches_action(key, Action::ConfigPanel) => app.view = View::List,
_ => {}
}
}
View::Create if app.is_create_worktree_loading() => {}
View::Create => match app.handle_create_key(key) {
CreateKey::Submit => {
if let Err(e) = app.submit_create() {
app.status = format!("error: {}", e);
}
}
CreateKey::Cancel => app.view = View::List,
CreateKey::Handled => {}
},
View::Confirm if app.is_delete_worktree_loading() => {}
View::Confirm => match app.resolve_modal(KeyContext::Confirm, key) {
Some(ModalAction::ConfirmConfirm) => confirm_fire(&mut app),
Some(ModalAction::ConfirmActivate) => match app.confirm.focused_button() {
ConfirmButton::Confirm => confirm_fire(&mut app),
ConfirmButton::Cancel => app.confirm_dismiss(),
},
Some(ModalAction::ConfirmCancel) => app.confirm_dismiss(),
Some(ModalAction::ConfirmFocusConfirm) => app.confirm.focus_confirm(),
Some(ModalAction::ConfirmFocusCancel) => app.confirm.focus_cancel(),
Some(ModalAction::ConfirmToggleFocus) => app.confirm.toggle_focus(),
_ if app.key_matches_action(key, Action::ToggleDeleteBranch) => app.toggle_delete_branch(),
_ => {}
},
View::Report => {
if let Some(ModalAction::ReportClose) = app.resolve_modal(KeyContext::Report, key) {
app.view = View::List;
app.refresh()?;
}
}
View::OpenMenu => match app.resolve_modal(KeyContext::OpenMenu, key) {
Some(ModalAction::OpenMenuClose) => app.exit_open_menu(),
Some(ModalAction::OpenMenuToggle) => app.open_menu_toggle_selection(),
Some(ModalAction::OpenMenuAccept) => {
if let Some(url) = app.open_menu_pick(app.open_menu_selected) {
open_url(&url, &mut app);
}
}
Some(ModalAction::OpenMenuIssue) => {
if let Some(url) = app.open_menu_pick(LinkTarget::Issue) {
open_url(&url, &mut app);
}
}
Some(ModalAction::OpenMenuPr) => {
if let Some(url) = app.open_menu_pick(LinkTarget::Pr) {
open_url(&url, &mut app);
}
}
_ if app.key_matches_action(key, Action::FetchGithub) => app.refresh_github_status(),
_ => {}
},
View::LinkPrompt => match app.handle_link_prompt_key(key) {
LinkPromptKey::Submit => {
if let Err(e) = app.link_prompt_submit() {
app.status = format!("link failed: {}", e);
}
}
LinkPromptKey::Refresh => app.refresh_github_status(),
LinkPromptKey::Cancel => app.link_prompt_cancel(),
LinkPromptKey::Handled => {}
},
View::Pty => {
let exec_finished = app.pty_overlay.as_ref().is_some_and(|p| p.finished);
if key.code == KeyCode::Esc || exec_finished {
app.close_pty_overlay();
} else if let Some(ref mut pty) = app.pty_overlay {
let _ = pty.write_key(key);
}
}
View::ExecPicker => match app.handle_exec_picker_key(key) {
ExecPickerKey::Submit => {
if let Some((argv, cwd)) = app.exec_picker_resolve() {
let sz = terminal.size().unwrap_or_default();
let inner_cols = ((sz.width as u32 * 90 / 100) as u16).saturating_sub(6).max(20);
let inner_rows = ((sz.height as u32 * 90 / 100) as u16).saturating_sub(4).max(5);
let argv_refs: Vec<&str> = argv.iter().map(String::as_str).collect();
match PtyOverlay::spawn(PtyKind::Exec, &argv_refs, &cwd, inner_cols, inner_rows) {
Ok(pty) => app.open_pty_overlay(pty),
Err(e) => {
app.status = format!("exec overlay failed: {}", e);
app.close_exec_picker();
}
}
} else {
app.close_exec_picker();
}
}
ExecPickerKey::Cancel => app.close_exec_picker(),
ExecPickerKey::Handled => {}
},
View::DetailOverlay if app.detail_overlay.mode == crate::tui::state::detail_overlay::DetailMode::Input => {
let ci = app.detail_overlay.kind == crate::tui::state::detail_overlay::DetailKind::CiChecks;
match key.code {
KeyCode::Esc if ci => app.ci_input_cancel(),
KeyCode::Esc => app.agent_input_cancel(),
KeyCode::Enter if ci => match app.ci_input_selected_url() {
Some(url) => open_url(&url, &mut app),
None if app.detail_overlay.mode == crate::tui::state::detail_overlay::DetailMode::List => {
app.status = "this check exposes no details URL".into()
}
None => {}
},
KeyCode::Enter => app.agent_input_submit(),
KeyCode::Backspace if ci => app.ci_input_pop(),
KeyCode::Backspace => app.agent_input_pop(),
KeyCode::Down if ci => app.ci_input_next(),
KeyCode::Down => app.agent_input_next(),
KeyCode::Up if ci => app.ci_input_prev(),
KeyCode::Up => app.agent_input_prev(),
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
if ci {
app.ci_input_push(c)
} else {
app.agent_input_push(c)
}
}
_ => {}
}
}
View::DetailOverlay if app.detail_overlay.kind == crate::tui::state::detail_overlay::DetailKind::CiChecks => {
match app.resolve_modal(KeyContext::CiChecks, key) {
Some(ModalAction::CiChecksClose) => app.close_detail_overlay(),
Some(ModalAction::CiChecksNext) => app.detail_overlay.select_next(),
Some(ModalAction::CiChecksPrev) => app.detail_overlay.select_prev(),
Some(ModalAction::CiChecksOpen) => match app.ci_selected_url() {
Some(url) => open_url(&url, &mut app),
None => app.status = "this check exposes no details URL".into(),
},
Some(ModalAction::CiChecksFilter) => app.ci_input_open(),
Some(ModalAction::CiChecksRefresh) => app.ci_checks_refresh(),
_ => {}
}
}
View::DetailOverlay => match app.resolve_modal(KeyContext::Detail, key) {
Some(ModalAction::DetailClose) => app.close_detail_overlay(),
Some(ModalAction::DetailSelectNext) => app.detail_overlay.select_next(),
Some(ModalAction::DetailSelectPrev) => app.detail_overlay.select_prev(),
Some(ModalAction::DetailAttach) => app.attach_selected_agent(),
Some(ModalAction::DetailDetach) => app.detach_selected_agent(),
Some(ModalAction::DetailInput) => app.open_agent_input(),
_ => {}
},
View::CleanReport => match app.resolve_modal(KeyContext::Clean, key) {
Some(ModalAction::CleanCancel) => app.close_clean_overlay(),
Some(ModalAction::CleanConfirm) => {
if app.clean_confirm_press(now) == ConfirmKeyAction::FireNow {
app.clean_overlay_delete();
}
}
Some(ModalAction::CleanNext) => app.clean_overlay_next(),
Some(ModalAction::CleanPrev) => app.clean_overlay_prev(),
_ => {}
},
View::Edit if app.is_edit_worktree_loading() => {}
View::Edit => match app.handle_create_key(key) {
CreateKey::Submit => {
if let Err(e) = app.submit_edit_worktree() {
app.status = format!("rename failed: {}", e);
}
}
CreateKey::Cancel => app.cancel_edit_worktree(),
CreateKey::Handled => {}
},
View::CommandPalette => {
if !app.palette_input_key(key) {
match app.resolve_modal(KeyContext::CommandPalette, key) {
Some(ModalAction::CommandPaletteClose) => app.close_command_palette(),
Some(ModalAction::CommandPaletteAccept) => {
if let Some(action) = app.accept_command_palette() {
run_palette_action(terminal, &mut app, action)?;
}
}
Some(ModalAction::CommandPalettePrev) => app.palette_cycle_up(),
Some(ModalAction::CommandPaletteNext) => app.palette_cycle_down(),
_ => app.palette_unresolved_fallback(key),
}
}
}
}
if app.picker_should_exit {
break;
}
if app.should_quit {
if app.can_quit_now() {
break;
}
app.defer_quit_for_mutating_task();
}
}
Ok(app.should_exit_to.or(app.picker_result))
}
fn run_action(terminal: &mut Terminal<CrosstermBackend<io::Stderr>>, app: &mut App, action: Action) -> Result<()> {
if app.workspace_active_stale && action.is_repo_mutating() {
app.status = "workspace: selected repo is unavailable (moved/deleted?) — press r to refresh".into();
return Ok(());
}
match action {
Action::Quit => app.should_quit = true,
Action::Down => app.next(),
Action::Up => app.prev(),
Action::Top => app.first(),
Action::Bottom => app.last(),
Action::WtScrollDown => app.wt_scroll_down(),
Action::WtScrollUp => app.wt_scroll_up(),
Action::ToggleSidebar => app.toggle_sidebar(),
Action::ToggleSidebarMode => app.cycle_sidebar_mode(),
Action::CycleSidebarLayout => app.cycle_sidebar_layout(),
Action::ToggleSidebarPosition => app.toggle_sidebar_position(),
Action::FocusSwap => app.toggle_focus(),
Action::FocusWorktrees => app.focus_worktrees(),
Action::FocusStatus => app.focus_status(),
Action::Filter => app.enter_filter(),
Action::Refresh => app.request_refresh(),
Action::Help => app.enter_help(),
Action::YankPath => yank_selected_path_to_clipboard(app),
Action::YankBranchName => yank_selected_branch_to_clipboard(app),
Action::YankWorktreeName => yank_selected_worktree_name_to_clipboard(app),
Action::TerminalFullscreen => match app.resolve_open_target() {
None => app.status = "nothing selected".into(),
Some(OpenTarget::Finder { .. }) => app.open_selected_in_finder(),
Some(OpenTarget::Shell { path, command }) => run_subshell(terminal, &command, &[], Some(&path), app, "shell")?,
Some(OpenTarget::Editor { path, command }) => {
let path_str = path.display().to_string();
run_subshell(terminal, &command, &[&path_str], None, app, "editor")?
}
},
Action::TerminalPty => {
let cwd = app.selected().map(|wt| wt.path.clone());
match cwd {
None => app.status = "nothing selected".into(),
Some(path) => {
#[cfg(windows)]
let shell = std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".into());
#[cfg(not(windows))]
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into());
let sz = terminal.size().unwrap_or_default();
let inner_cols = ((sz.width as u32 * 90 / 100) as u16).saturating_sub(6).max(20);
let inner_rows = ((sz.height as u32 * 90 / 100) as u16).saturating_sub(4).max(5);
match PtyOverlay::spawn(PtyKind::Terminal, &[shell.as_str()], &path, inner_cols, inner_rows) {
Ok(pty) => app.open_pty_overlay(pty),
Err(e) => app.status = format!("terminal overlay failed: {}", e),
}
}
}
}
Action::LazyGitFullscreen => {
if let Some(plan) = app.prepare_git_tui() {
run_launcher(terminal, plan, app)?;
}
}
Action::LazyGitPty => {
if let Some(plan) = app.prepare_git_tui() {
let sz = terminal.size().unwrap_or_default();
let inner_cols = ((sz.width as u32 * 90 / 100) as u16).saturating_sub(6).max(20);
let inner_rows = ((sz.height as u32 * 90 / 100) as u16).saturating_sub(4).max(5);
let argv: Vec<String> = plan.expanded.argv.clone();
let argv_refs: Vec<&str> = argv.iter().map(String::as_str).collect();
match PtyOverlay::spawn(PtyKind::LazyGit, &argv_refs, &plan.cwd, inner_cols, inner_rows) {
Ok(pty) => app.open_pty_overlay(pty),
Err(e) => app.status = format!("lazygit overlay failed: {}", e),
}
}
}
Action::ReviewPty if !app.picker_mode => {
if let Some(mut plan) = app.prepare_review() {
let sz = terminal.size().unwrap_or_default();
let inner_cols = ((sz.width as u32 * 90 / 100) as u16).saturating_sub(6).max(20);
let inner_rows = ((sz.height as u32 * 90 / 100) as u16).saturating_sub(4).max(5);
let argv: Vec<String> = plan.expanded.argv.clone();
let argv_refs: Vec<&str> = argv.iter().map(String::as_str).collect();
match PtyOverlay::spawn(PtyKind::Review, &argv_refs, &plan.cwd, inner_cols, inner_rows) {
Ok(mut pty) => {
pty.diff_file = plan.expanded.diff_file.take();
app.open_pty_overlay(pty);
}
Err(e) => app.status = format!("review overlay failed: {}", e),
}
}
}
Action::Create if !app.picker_mode => app.enter_create(),
Action::DeleteConfirm if !app.picker_mode => app.enter_confirm_delete(),
Action::Bootstrap if !app.picker_mode => app.bootstrap_selected(),
Action::Sync if !app.picker_mode => app.request_sync(),
Action::Pull if !app.picker_mode => app.request_pull(),
Action::Push if !app.picker_mode => app.request_push(),
Action::EditWorktree if !app.picker_mode => app.enter_edit_worktree(),
Action::CiChecks if !app.picker_mode => app.enter_ci_checks(),
Action::ExitToWorktree => app.exit_to_worktree(),
Action::MuxPane if !app.picker_mode => app.open_in_mux_pane(),
Action::Macro1 if !app.picker_mode => run_macro(terminal, app, 1)?,
Action::Macro2 if !app.picker_mode => run_macro(terminal, app, 2)?,
Action::ToggleDeleteBranch if !app.picker_mode => app.toggle_delete_branch(),
Action::BrowseLinks if !app.picker_mode => app.enter_open_menu(),
Action::OpenDocs => open_url(DOCS_URL, app),
Action::LinkPrompt if !app.picker_mode => app.enter_link_prompt(),
Action::FetchGithub if !app.picker_mode => app.refresh_github_status(),
Action::ReviewFullscreen if !app.picker_mode => {
if let Some(plan) = app.prepare_review() {
run_launcher(terminal, plan, app)?;
}
}
Action::CommandPalette => app.open_command_palette(),
Action::CommandLogs => app.enter_command_logs(),
Action::ConfigPanel => app.enter_config_panel(),
Action::ExecOverlay if !app.picker_mode => app.enter_exec_picker(),
Action::CleanOverlay if !app.picker_mode => app.enter_clean_overlay(),
Action::AgentSessions if !app.picker_mode => app.open_agent_overlay(),
_ => {}
}
Ok(())
}
fn run_palette_action(
terminal: &mut Terminal<CrosstermBackend<io::Stderr>>,
app: &mut App,
action: Action,
) -> Result<()> {
run_action(terminal, app, action)
}
pub fn wants_child_stdout_on_tty(stdout_is_terminal: bool) -> bool {
!stdout_is_terminal
}
fn route_fullscreen_child_stdout(command: &mut std::process::Command) {
use std::io::IsTerminal;
if !wants_child_stdout_on_tty(std::io::stdout().is_terminal()) {
return;
}
#[cfg(unix)]
if let Ok(tty) = std::fs::OpenOptions::new().write(true).open("/dev/tty") {
command.stdout(std::process::Stdio::from(tty));
}
#[cfg(not(unix))]
let _ = command;
}
fn run_launcher(
terminal: &mut Terminal<CrosstermBackend<io::Stderr>>,
plan: app::LauncherPlan,
app: &mut App,
) -> Result<()> {
use std::process::{Command, Stdio};
let argv = plan.expanded.argv.clone();
let Some((bin, rest)) = argv.split_first() else {
app.status = "launcher template produced an empty argv".into();
return Ok(());
};
if which::which(bin).is_err() {
app.status = format!(
"`{}` not on $PATH — install it or change [review]/[git_tui] in .gwm.toml",
bin
);
return Ok(());
}
if plan.fullscreen {
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
terminal.show_cursor()?;
let mut cmd = Command::new(bin);
cmd.args(rest).current_dir(&plan.cwd);
route_fullscreen_child_stdout(&mut cmd);
let spawn = cmd.status();
enable_raw_mode()?;
execute!(terminal.backend_mut(), EnterAlternateScreen, EnableMouseCapture)?;
terminal.clear()?;
match spawn {
Ok(s) if s.success() => app.status = format!("{} exited ok", bin),
Ok(s) => app.status = format!("{} exited with code {:?}", bin, s.code()),
Err(e) => app.status = format!("failed to launch {}: {}", bin, e),
}
} else {
let out = Command::new(bin)
.args(rest)
.current_dir(&plan.cwd)
.stdout(Stdio::null())
.stderr(Stdio::piped())
.output();
match out {
Ok(o) if o.status.success() => app.status = format!("{} done", bin),
Ok(o) => {
let first = String::from_utf8_lossy(&o.stderr)
.lines()
.next()
.unwrap_or_default()
.trim()
.to_string();
app.status = if first.is_empty() {
format!("{} exited with code {:?}", bin, o.status.code())
} else {
format!("{}: {}", bin, first)
};
}
Err(e) => app.status = format!("failed to launch {}: {}", bin, e),
}
}
drop(plan);
Ok(())
}
fn run_subshell(
terminal: &mut Terminal<CrosstermBackend<io::Stderr>>,
cmd: &str,
args: &[&str],
cwd: Option<&std::path::Path>,
app: &mut App,
label: &str,
) -> Result<()> {
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
terminal.show_cursor()?;
let mut command = std::process::Command::new(cmd);
command.args(args);
if let Some(dir) = cwd {
command.current_dir(dir);
}
route_fullscreen_child_stdout(&mut command);
let spawn = command.status();
enable_raw_mode()?;
execute!(terminal.backend_mut(), EnterAlternateScreen, EnableMouseCapture)?;
terminal.clear()?;
match spawn {
Ok(s) if s.success() => app.status = format!("{} exited ok ({})", label, cmd),
Ok(s) => app.status = format!("{} exited with code {:?}", label, s.code()),
Err(e) => app.status = format!("failed to launch {} ({}): {}", label, cmd, e),
}
Ok(())
}
fn yank_selected_path_to_clipboard(app: &mut App) {
let Some(path) = app.yank_selected_path() else {
app.status = "nothing selected".into();
return;
};
let text = path.display().to_string();
copy_text_to_clipboard(app, &text, "yanked path");
}
fn yank_selected_branch_to_clipboard(app: &mut App) {
let Some(branch) = app.yank_selected_branch() else {
app.status = "nothing selected or no branch (detached HEAD)".into();
return;
};
copy_text_to_clipboard(app, &branch, "yanked branch name");
}
fn yank_selected_worktree_name_to_clipboard(app: &mut App) {
let Some(name) = app.yank_selected_worktree_name() else {
app.status = "nothing selected".into();
return;
};
copy_text_to_clipboard(app, &name, "yanked worktree name");
}
fn run_macro(terminal: &mut Terminal<CrosstermBackend<io::Stderr>>, app: &mut App, n: u8) -> Result<()> {
use crate::config::MacroOpenMode;
let cfg = if n == 1 {
app.config.tui.macro1.clone()
} else {
app.config.tui.macro2.clone()
};
let Some(macro_cfg) = cfg else {
app.status = format!("macro{} not configured — add [tui.macro{}] to .gwm.toml", n, n);
return Ok(());
};
use crate::multiplexer::{build_tmux_command, build_zellij_command, detect_tmux, detect_zellij, SpawnMode};
let Some(path) = app.selected().map(|w| w.path.clone()) else {
app.status = format!("macro{}: nothing selected", n);
return Ok(());
};
#[cfg(windows)]
let shell = std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".into());
#[cfg(not(windows))]
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into());
let shell_flag = if cfg!(windows) { "/C" } else { "-c" };
let mux_cmd = if matches!(macro_cfg.open_in, MacroOpenMode::MuxPane) {
let label = format!("macro{}", n);
if detect_tmux(std::env::var("TMUX").ok()) {
Some(build_tmux_command(&label, &path, SpawnMode::Split))
} else if detect_zellij(std::env::var("ZELLIJ").ok()) {
Some(build_zellij_command(&label, &path, SpawnMode::Split))
} else {
app.status = format!("macro{}: no multiplexer — falling back to PTY overlay", n);
None
}
} else {
None
};
if let Some(cmd) = mux_cmd {
let bin = cmd[0].as_str();
let mut full_cmd: Vec<&str> = cmd[1..].iter().map(String::as_str).collect();
if bin == "zellij" {
full_cmd.push("--");
full_cmd.push(shell.as_str());
full_cmd.push(shell_flag);
full_cmd.push(macro_cfg.command.as_str());
} else {
full_cmd.push(macro_cfg.command.as_str());
}
match std::process::Command::new(bin).args(&full_cmd).spawn() {
Ok(_) => app.status = format!("macro{} opened in mux pane", n),
Err(e) => app.status = format!("macro{} mux failed: {}", n, e),
}
} else {
let sz = terminal.size().unwrap_or_default();
let inner_cols = ((sz.width as u32 * 90 / 100) as u16).saturating_sub(6).max(20);
let inner_rows = ((sz.height as u32 * 90 / 100) as u16).saturating_sub(4).max(5);
let argv = [shell.as_str(), shell_flag, macro_cfg.command.as_str()];
match PtyOverlay::spawn(PtyKind::Terminal, &argv, &path, inner_cols, inner_rows) {
Ok(pty) => app.open_pty_overlay(pty),
Err(e) => app.status = format!("macro{} overlay failed: {}", n, e),
}
}
Ok(())
}
fn copy_command_logs_to_clipboard(app: &mut App) {
let text = app.command_logs_transcript();
if text.is_empty() {
app.status = "no commands to copy".into();
return;
}
copy_text_to_clipboard(app, &text, "copied command logs");
}
fn copy_text_to_clipboard(app: &mut App, text: &str, success: &str) {
use crate::clipboard::{plan_clipboard_write, ClipboardPlan};
use std::io::Write;
let plan = plan_clipboard_write(
text,
app.config.tui.clipboard,
std::env::var_os("SSH_TTY").is_some() || std::env::var_os("SSH_CONNECTION").is_some(),
crate::multiplexer::detect_tmux(std::env::var("TMUX").ok()),
std::env::var_os("STY").is_some(),
);
match plan {
ClipboardPlan::Osc52(bytes) => {
let mut err = std::io::stderr();
match err.write_all(&bytes).and_then(|_| err.flush()) {
Ok(()) => app.status = format!("{} (osc52)", success),
Err(e) => app.status = format!("osc52 write failed: {}", e),
}
return;
}
ClipboardPlan::TooLarge { bytes } => {
app.status = format!(
"too large for osc52 ({} KiB > {} KiB) — set [tui] clipboard = \"tools\"",
bytes.div_ceil(1024),
crate::clipboard::MAX_OSC52_BYTES / 1024
);
return;
}
ClipboardPlan::Tools => {}
}
for (cmd, args) in clipboard_candidates() {
if which::which(cmd).is_err() {
continue;
}
let child = std::process::Command::new(cmd)
.args(&args)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
match child {
Ok(mut c) => {
if let Some(mut stdin) = c.stdin.take() {
let _ = stdin.write_all(text.as_bytes());
}
match c.wait() {
Ok(s) if s.success() => {
app.status = format!("{} ({})", success, cmd);
return;
}
Ok(s) => {
app.status = format!("{} exited with code {:?}", cmd, s.code());
return;
}
Err(e) => {
app.status = format!("{} wait failed: {}", cmd, e);
return;
}
}
}
Err(e) => {
app.status = format!("failed to spawn {}: {}", cmd, e);
return;
}
}
}
app.status = "y: no clipboard tool found (install pbcopy / wl-copy / xclip / xsel / clip)".into();
}
pub const DOCS_URL: &str = concat!(env!("CARGO_PKG_REPOSITORY"), "/tree/main/docs");
fn open_url(url: &str, app: &mut App) {
let opener = if cfg!(target_os = "macos") {
"open"
} else if cfg!(target_os = "windows") {
"explorer"
} else {
"xdg-open"
};
match std::process::Command::new(opener).arg(url).spawn() {
Ok(_) => app.status = format!("opened {}", url),
Err(e) => app.status = format!("failed to open {}: {}", url, e),
}
}