diff --git a/app/Cargo.toml b/app/Cargo.toml index 49ade1e660d..957d9b2e6cb 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -1053,6 +1053,7 @@ git_credential_refresh = [] prompt_cache_expiry_warning = [] osc_hyperlinks = [] ctrl_c_cancels_third_party_harness = [] +shell_widget_handoff = [] [package.metadata.bundle.bin.warp-oss] category = "public.app-category.developer-tools" diff --git a/app/assets/bundled/bootstrap/bash_body.sh b/app/assets/bundled/bootstrap/bash_body.sh index 11219ed836d..00017808b7d 100644 --- a/app/assets/bundled/bootstrap/bash_body.sh +++ b/app/assets/bundled/bootstrap/bash_body.sh @@ -970,6 +970,41 @@ if [ -z "$WARP_BOOTSTRAPPED" ]; then READLINE_LINE="" } + # Runs the shell's ctrl-r history widget as a foreground command. + warp_run_external_ctrl_r_widget () { + local result="" + case "$_WARP_EXTERNAL_CTRL_R_WIDGET" in + __fzf_history__) + result="$(__fzf_history__)" + ;; + __atuin_history) + # Bypass atuin's own bash key-binding machinery entirely and invoke the underlying + # `atuin search` command directly, exactly as atuin's own integration does + # (__atuin_search_cmd's non-tmux branch). + result="$(ATUIN_SHELL=bash atuin search -i 3>&1 1>&2 2>&3 3>&-)" + # If the user has atuin's enter_accept config on, Enter both selects and runs the + # command, signaled by this prefix; we only ever want the selection, never to run + # it, so strip the prefix in both cases (see atuin's __atuin_history for the same + # check). + result="${result#__atuin_accept__:}" + ;; + esac + local warp_escaped_selection="$(warp_escape_json "$result")" + warp_send_json_message "{ \"hook\": \"ExternalShellWidgetSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"session_id\": $WARP_SESSION_ID } }" + } + + # Runs the shell's own ctrl-t file-search widget as a foreground command. + warp_run_external_ctrl_t_widget () { + local result="" + case "$_WARP_EXTERNAL_CTRL_T_WIDGET" in + fzf-file-widget) + result="$(__fzf_select__)" + ;; + esac + local warp_escaped_selection="$(warp_escape_json "$result")" + warp_send_json_message "{ \"hook\": \"ExternalShellWidgetSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"session_id\": $WARP_SESSION_ID } }" + } + # Check whether the prompt-related variables have OSC prompt marker sequences, # and if not, wrap them with the appropriate markers so that we can direct the # prompt bytes to the appropriate grids. @@ -1407,13 +1442,13 @@ esac # rcfiles. USER_HISTCONTROL="$HISTCONTROL" - # Add a pattern to ignore in-band commands in shell history, while preserving the user's + # Add patterns to ignore in-band commands in shell history, while preserving the user's # HISTIGNORE value which may been set in an RC file sourced above. It is important to # ensure that this happens _after_ the user's RC files have been sourced. if [[ ! -z $HISTIGNORE ]]; then - HISTIGNORE="*warp_run_generator_command*:$HISTIGNORE" + HISTIGNORE="*warp_run_generator_command*:*warp_run_external_ctrl_r_widget*:*warp_run_external_ctrl_t_widget*:$HISTIGNORE" else - HISTIGNORE="*warp_run_generator_command*" + HISTIGNORE="*warp_run_generator_command*:*warp_run_external_ctrl_r_widget*:*warp_run_external_ctrl_t_widget*" fi # If the user has PROMPT_COMMAND set in their bootstrap scripts, @@ -1528,6 +1563,38 @@ esac shell_plugins=() + # Detect whether ctrl-r has been rebound to fzf's or atuin's bash history widget, so Warp + # can hand ctrl-r off to it. + _WARP_EXTERNAL_CTRL_R_WIDGET="" + if [ "$WARP_IN_MSYS2" = false ]; then + warp_ctrl_r_binding="$(bind -X 2>/dev/null | command -p sed -n 's/^"\\C-r"[ :] *"\(.*\)"$/\1/p')" + case "$warp_ctrl_r_binding" in + __fzf_history__|__atuin_history) + _WARP_EXTERNAL_CTRL_R_WIDGET="$warp_ctrl_r_binding" + shell_plugins+=(external_ctrl_r_history) + ;; + esac + # atuin >= 18.10 binds ctrl-r through the indirect dispatcher above rather than a plain + # `bind -x`. Instead use atuin's own init-time flag ($__atuin_bind_ctrl_r) plus + # __atuin_history being defined. + if [ -z "$_WARP_EXTERNAL_CTRL_R_WIDGET" ] && [ "$__atuin_bind_ctrl_r" = true ] && + declare -F __atuin_history >/dev/null; then + _WARP_EXTERNAL_CTRL_R_WIDGET="__atuin_history" + shell_plugins+=(external_ctrl_r_history) + fi + + _WARP_EXTERNAL_CTRL_T_WIDGET="" + warp_ctrl_t_binding="$(bind -X 2>/dev/null | command -p sed -n 's/^"\\C-t"[ :] *"\(.*\)"$/\1/p')" + case "$warp_ctrl_t_binding" in + fzf-file-widget) + if declare -F __fzf_select__ >/dev/null; then + _WARP_EXTERNAL_CTRL_T_WIDGET="$warp_ctrl_t_binding" + shell_plugins+=(external_ctrl_t_file) + fi + ;; + esac + fi + function warp_bootstrapped () { local aliases="`alias`" local env_var_names="`compgen -e`" diff --git a/app/assets/bundled/bootstrap/fish.sh b/app/assets/bundled/bootstrap/fish.sh index 79ca2d78320..853b83e0381 100644 --- a/app/assets/bundled/bootstrap/fish.sh +++ b/app/assets/bundled/bootstrap/fish.sh @@ -551,6 +551,114 @@ function warp_escape_json string join \n $argv | command sed -E 's/(["\\\\])/\\\\\\1/g; s/'\b'/\\\\b/g; s/'\t'/\\\\t/g; s/'\f'/\\\\f/g; s/'\r'/\\\\r/g; $!s/$/\\\\n/' | command tr -d '\n' end +# Reports the widget `^R` is bound to, if the user has rebound it away from fish's own +# history search. Returns non-zero when `^R` is still on a fish default. +function warp_external_ctrl_r_widget + # fish >= 4.0 renamed key specifications, so `bind` echoes back `ctrl-r` where earlier + # versions echo `\cr`. + set -l widget "" + for binding in (bind \cr 2>/dev/null) + if string match --quiet -- 'bind --preset *' "$binding" + continue + end + # Strip the leading `bind [-M ] `, leaving just the widget/command. + set widget (string replace --regex -- '^bind (-M \S+ +)?\S+ +' '' "$binding") + end + test -n "$widget"; or return 1 + echo "$widget" +end + +# Reports the widget `^T` is bound to, if the user has rebound it away from fish's default (no +# binding at all). Returns non-zero when `^T` has no non-preset binding. +function warp_external_ctrl_t_widget + set -l widget "" + for binding in (bind \ct 2>/dev/null) + if string match --quiet -- 'bind --preset *' "$binding" + continue + end + set widget (string replace --regex -- '^bind (-M \S+ +)?\S+ +' '' "$binding") + end + test -n "$widget"; or return 1 + echo "$widget" +end + +# Runs the shell's own ctrl-r history tool as a foreground command. +function warp_run_external_ctrl_r_widget + set -l result "" + switch "$_WARP_EXTERNAL_CTRL_R_WIDGET" + case 'fzf-history-widget' + test -z "$fish_private_mode"; and builtin history merge + fzf-history-widget + set result (commandline | string collect) + commandline -r '' + case '_atuin_search' + # atuin writes its TUI to stdout and the selection to fd 3, so the two are swapped here to + # leave the UI on the terminal and capture only the selection. + set -l output (ATUIN_SHELL_FISH=t ATUIN_LOG=error atuin search -i 3>&1 1>&2 2>&3 | string collect) + # atuin prefixes the selection with __atuin_accept__: when `enter_accept` is on and the + # user pressed enter. Warp always inserts without executing, so the prefix is dropped. + set result (string replace "__atuin_accept__:" "" -- "$output" | string collect) + end + set -l warp_escaped_selection (warp_escape_json "$result") + warp_send_json_message "{ \"hook\": \"ExternalShellWidgetSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"session_id\": $WARP_SESSION_ID } }" +end + +function warp_ctrl_t_widget_result + test "$argv[1]" = "$argv[2]"; or string collect -- "$argv[2]" +end + +# Runs fzf directly against a find-style command as a foreground command. +function warp_run_external_ctrl_t_widget + set -l result "" + switch "$_WARP_EXTERNAL_CTRL_T_WIDGET" + case 'fzf-file-widget' + set -l warp_ctrl_t_parts (string split -m 1 -- ':' "$argv[1]") + set -l char_cursor $warp_ctrl_t_parts[1] + set -l original_line (warp_hex_decode_string $warp_ctrl_t_parts[2] | string collect --no-trim-newlines --allow-empty) + commandline -r -- $original_line + commandline -C -- $char_cursor + fzf-file-widget + set -l cl_readback (commandline | string collect) + set result (warp_ctrl_t_widget_result "$original_line" "$cl_readback") + commandline -r '' + end + set -l warp_escaped_selection (warp_escape_json "$result") + warp_send_json_message "{ \"hook\": \"ExternalShellWidgetSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"session_id\": $WARP_SESSION_ID } }" +end + +# Exclude the ctrl-r/ctrl-t external handoff helpers (see warp_run_external_ctrl_r_widget/ +# warp_run_external_ctrl_t_widget above) from the user's history. +# +# fish only supports a single fish_should_add_to_history function (unlike zsh's array of +# zshaddhistory hooks or bash's PROMPT_COMMAND-style stacking), so compose with any +# user-defined one -- e.g. from a plugin sourced in config.fish before this bootstrap script +# runs -- rather than clobbering it, following the same backup pattern warp_update_prompt_vars +# uses for fish_prompt. +# +# warp_original_fish_should_add_to_history must exist and be safe to call *before* we install our +# own wrapper below. This bootstrap script can run more than once in the same fish process (a +# shell reload, or a nested fish subshell), and a user or plugin can define or replace +# fish_should_add_to_history at any point, including between two of our sourcings -- so on every +# run, re-derive the backup from whatever fish_should_add_to_history currently is, unless that's +# already our own wrapper from a previous run (identified by the warp_run_external_ctrl_r_widget +# sentinel in its body), in which case the existing backup -- the last real hook we captured, or +# the accept-everything default if none ever existed -- is left alone. Backing up our own wrapper +# as if it were the original would make every history check call itself. +if functions -q fish_should_add_to_history + and not functions fish_should_add_to_history | string match --quiet -- '*warp_run_external_ctrl_r_widget*' + functions -q warp_original_fish_should_add_to_history; and functions -e warp_original_fish_should_add_to_history + functions -c fish_should_add_to_history warp_original_fish_should_add_to_history +else if not functions -q warp_original_fish_should_add_to_history + function warp_original_fish_should_add_to_history + return 0 + end +end +function fish_should_add_to_history + string match --quiet -- '*warp_run_external_ctrl_r_widget*' $argv[1]; and return 1 + string match --quiet -- '*warp_run_external_ctrl_t_widget*' $argv[1]; and return 1 + warp_original_fish_should_add_to_history $argv +end + function warp_bootstrapped set -l histfile_directory set histfile_directory "$XDG_DATA_HOME" @@ -564,6 +672,26 @@ function warp_bootstrapped set vi_mode_enabled "1" end + set -l shell_plugins + set -g _WARP_EXTERNAL_CTRL_R_WIDGET "" + set -l warp_ctrl_r_widget (warp_external_ctrl_r_widget) + switch "$warp_ctrl_r_widget" + case 'fzf-history-widget' '_atuin_search' + set -g _WARP_EXTERNAL_CTRL_R_WIDGET "$warp_ctrl_r_widget" + set -a shell_plugins external_ctrl_r_history + end + + set -g _WARP_EXTERNAL_CTRL_T_WIDGET "" + set -l warp_ctrl_t_widget (warp_external_ctrl_t_widget) + switch "$warp_ctrl_t_widget" + case 'fzf-file-widget' + if functions -q fzf-file-widget + set -g _WARP_EXTERNAL_CTRL_T_WIDGET "$warp_ctrl_t_widget" + set -a shell_plugins external_ctrl_t_file + end + end + set -l escaped_shell_plugins (warp_escape_json $shell_plugins) + set -l kernel_name (uname) if test -n "$kernel_name" if [ "$kernel_name" = "Darwin" ] @@ -593,7 +721,7 @@ function warp_bootstrapped # part of its builtins (e.g. "for", "while", etc.). set -l escaped_editor (warp_escape_json "$EDITOR") set -l escaped_shell_path (warp_escape_json (status fish-path)) - set -l escaped_json "{\"hook\": \"Bootstrapped\", \"value\": {\"histfile\": \"$escaped_histfile\", \"session_id\": $WARP_SESSION_ID, \"shell\": \"fish\", \"home_dir\": \"$HOME\", \"path\": \"$PATH\", \"editor\": \"$escaped_editor\", \"abbreviations\": \"$escaped_abbr\", \"aliases\": \"$escaped_aliases\", \"function_names\": \"$function_names\", \"env_var_names\": \"$env_var_names\", \"builtins\": \"$escaped_builtins\", \"keywords\": \"\", \"shell_version\": \"$FISH_VERSION\", \"vi_mode_enabled\": \"$vi_mode_enabled\", \"os_category\": \"$os_category\", \"linux_distribution\": \"$linux_distribution\", \"wsl_name\": \"$WSL_DISTRO_NAME\", \"shell_path\": \"$escaped_shell_path\"}}" + set -l escaped_json "{\"hook\": \"Bootstrapped\", \"value\": {\"histfile\": \"$escaped_histfile\", \"session_id\": $WARP_SESSION_ID, \"shell\": \"fish\", \"home_dir\": \"$HOME\", \"path\": \"$PATH\", \"editor\": \"$escaped_editor\", \"abbreviations\": \"$escaped_abbr\", \"aliases\": \"$escaped_aliases\", \"function_names\": \"$function_names\", \"env_var_names\": \"$env_var_names\", \"builtins\": \"$escaped_builtins\", \"keywords\": \"\", \"shell_version\": \"$FISH_VERSION\", \"shell_plugins\": \"$escaped_shell_plugins\", \"vi_mode_enabled\": \"$vi_mode_enabled\", \"os_category\": \"$os_category\", \"linux_distribution\": \"$linux_distribution\", \"wsl_name\": \"$WSL_DISTRO_NAME\", \"shell_path\": \"$escaped_shell_path\"}}" warp_send_json_message $escaped_json end diff --git a/app/assets/bundled/bootstrap/zsh_body.sh b/app/assets/bundled/bootstrap/zsh_body.sh index 536004a1d99..42bc2bbcb22 100644 --- a/app/assets/bundled/bootstrap/zsh_body.sh +++ b/app/assets/bundled/bootstrap/zsh_body.sh @@ -710,6 +710,48 @@ if [[ -z $WARP_BOOTSTRAPPED ]]; then } zle -N warp_report_input + # Runs the shell's own ctrl-r history widget as a foreground command. + function warp_run_external_ctrl_r_widget () { + local result="" + case "$_WARP_EXTERNAL_CTRL_R_WIDGET" in + fzf-history-widget) + result="$(fc -rl 1 \ + | command -p awk '{ cmd=$0; sub(/^[ \t]*[0-9]+\**[ \t]+/, "", cmd); if (!seen[cmd]++) print cmd }' \ + | fzf --scheme=history --tiebreak=index +m)" + ;; + atuin-search|atuin-search-viins|atuin-search-vicmd|_atuin_search_widget) + # atuin writes its TUI to stdout; under plain command substitution that's a pipe, and + # its cursor-position query (\x1b[6n) has nothing to answer it, so it bails during + # startup. Swap stdout/stderr through fd 3, matching atuin's own zsh integration + # (_atuin_search -> __atuin_search_cmd), so the TUI reaches the tty while the + # selection is still captured via command substitution. + result="$(ATUIN_SHELL=zsh atuin search -i 3>&1 1>&2 2>&3 3>&-)" + # If the user has atuin's enter_accept config on, Enter both selects and runs the + # command, signaled by this prefix; we only ever want the selection, never to run it, + # so strip the prefix in both cases (see atuin's _atuin_search for the same check). + result="${result#__atuin_accept__:}" + ;; + esac + local warp_escaped_selection="$(warp_escape_json "$result")" + warp_send_json_message "{ \"hook\": \"ExternalShellWidgetSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"session_id\": $WARP_SESSION_ID } }" + } + + # Runs the shell's own ctrl-t file-search widget as a foreground command. + function warp_run_external_ctrl_t_widget () { + local result="" + case "$_WARP_EXTERNAL_CTRL_T_WIDGET" in + fzf-file-widget) + if (( $+functions[__fzf_select] )); then + result="$(__fzf_select)" + else # fzf < 0.48 + result="$(__fsel)" + fi + ;; + esac + local warp_escaped_selection="$(warp_escape_json "$result")" + warp_send_json_message "{ \"hook\": \"ExternalShellWidgetSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"session_id\": $WARP_SESSION_ID } }" + } + function clear() { warp_send_json_message "{\"hook\": \"Clear\", \"value\": {\"session_id\": $WARP_SESSION_ID}}" } @@ -1279,7 +1321,8 @@ esac # See https://zsh.sourceforge.io/Doc/Release/Functions.html for more context # on the zshaddhistory hook. _warp_zshaddhistory() { - _is_warp_generator_command "$1" + _is_warp_generator_command "$1" && [[ "$1" != *"warp_run_external_ctrl_r_widget"* ]] && \ + [[ "$1" != *"warp_run_external_ctrl_t_widget"* ]] } # Register this zshaddhistory hook after the user's RC files have been sourced, @@ -1353,6 +1396,34 @@ esac shell_plugins+=(vi) fi + # Detect whether ctrl-r has been rebound to fzf's or atuin's history widget. + _WARP_EXTERNAL_CTRL_R_WIDGET="" + warp_ctrl_r_binding="$(bindkey -M main '^R' 2>/dev/null)" + if [[ "$warp_ctrl_r_binding" == '"^R" '* ]]; then + warp_ctrl_r_widget="${warp_ctrl_r_binding#\"^R\" }" + case "$warp_ctrl_r_widget" in + fzf-history-widget|atuin-search|atuin-search-viins|atuin-search-vicmd|_atuin_search_widget) + _WARP_EXTERNAL_CTRL_R_WIDGET="$warp_ctrl_r_widget" + shell_plugins+=(external_ctrl_r_history) + ;; + esac + fi + + # Detect whether ctrl-t has been rebound to fzf's file-search widget. + _WARP_EXTERNAL_CTRL_T_WIDGET="" + warp_ctrl_t_binding="$(bindkey -M main '^T' 2>/dev/null)" + if [[ "$warp_ctrl_t_binding" == '"^T" '* ]]; then + warp_ctrl_t_widget="${warp_ctrl_t_binding#\"^T\" }" + case "$warp_ctrl_t_widget" in + fzf-file-widget) + if (( $+functions[__fzf_select] )) || (( $+functions[__fsel] )); then + _WARP_EXTERNAL_CTRL_T_WIDGET="$warp_ctrl_t_widget" + shell_plugins+=(external_ctrl_t_file) + fi + ;; + esac + fi + if kernel_name="$(uname)"; then if [[ "$kernel_name" == "Darwin" ]]; then os_category="MacOS" diff --git a/app/src/features.rs b/app/src/features.rs index 753eb7c0198..abd3322f132 100644 --- a/app/src/features.rs +++ b/app/src/features.rs @@ -525,6 +525,8 @@ fn enabled_features() -> HashSet { FeatureFlag::TerminalLifecycleRecovery, #[cfg(feature = "ctrl_c_cancels_third_party_harness")] FeatureFlag::CtrlCCancelsThirdPartyHarness, + #[cfg(feature = "shell_widget_handoff")] + FeatureFlag::ShellWidgetHandoff, ]); flags diff --git a/app/src/terminal/event.rs b/app/src/terminal/event.rs index 48d6277046d..4e2ab4f7aed 100644 --- a/app/src/terminal/event.rs +++ b/app/src/terminal/event.rs @@ -9,7 +9,7 @@ pub use warp_terminal::event::{ExecutedExecutorCommandEvent, ParseGeneratorOutpu use warp_util::lazy::Lazy; use super::history::HistoryEntry; -use super::model::ansi::FinishUpdateValue; +use super::model::ansi::{ExternalShellWidgetSelectionValue, FinishUpdateValue}; use super::model::block::BlockId; use super::model::lifecycle::LifecycleRecoveryRecord; use super::model::session::{SessionId, SessionInfo}; @@ -128,6 +128,7 @@ pub enum Event { /// Emitted when the assisted auto-update has completed and we're ready to /// relaunch the app. FinishUpdate(FinishUpdateValue), + ExternalShellWidgetSelection(ExternalShellWidgetSelectionValue), TextSelectionChanged, ShellSpawned(ShellType), ImageReceived { @@ -475,6 +476,13 @@ impl Debug for Event { ) } Event::FinishUpdate(data) => write!(f, "FinishUpdate({})", data.update_id), + Event::ExternalShellWidgetSelection(data) => { + write!( + f, + "ExternalShellWidgetSelection(buffer_len: {})", + data.buffer.len() + ) + } Event::TextSelectionChanged => write!(f, "TextSelectionChanged"), Event::ShellSpawned(shell_type) => write!(f, "ShellSpawned({shell_type:?})"), Event::ImageReceived { image_id, .. } => { diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index 1fd7cbeb756..0e792c2932d 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -1876,6 +1876,46 @@ pub struct Input { /// we snapshot the current input contents here so we can restore them after the command /// completes and the buffer would normally be cleared. input_contents_before_prompt_chip_command: Option, + + pending_shell_widget_handoff: Option, +} + +/// How a completed shell-widget handoff lands its selection. Fish's ctrl-t widget already performs +/// token-aware replacement and reports the whole line; bash/zsh ctrl-t report a path fragment to +/// splice at the cursor. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ShellWidgetApplyMode { + Splice, + Replace, +} + +struct PendingShellWidgetHandoff { + session_id: SessionId, + original_buffer: String, + selection: Option, + block_id: BlockId, + apply_mode: ShellWidgetApplyMode, + cursor_offset: Option, +} + +impl PendingShellWidgetHandoff { + fn maybe_apply_selection(&mut self, session_id: SessionId, selection: &str) { + if self.session_id != session_id { + return; + } + if !selection.is_empty() { + self.selection = Some(selection.to_string()); + } + } + + fn restore_text(&self) -> &str { + match (self.apply_mode, &self.selection) { + (ShellWidgetApplyMode::Replace, Some(selection)) => selection, + (ShellWidgetApplyMode::Replace, None) | (ShellWidgetApplyMode::Splice, _) => { + &self.original_buffer + } + } + } } struct AmbientAgentViewState { @@ -2185,6 +2225,14 @@ pub fn init(app: &mut AppContext) { ) .with_context_predicate(id!("Input")) .with_key_binding("tab"), + EditableBinding::new( + "workspace:trigger_external_ctrl_t_file_search", + "External File Search", + WorkspaceAction::TriggerExternalCtrlTFileSearch, + ) + .with_enabled(|| FeatureFlag::ShellWidgetHandoff.is_enabled()) + .with_context_predicate(id!("Input") & !id!("VoltronActive") & !id!("LongRunningCommand")) + .with_key_binding("ctrl-t"), ]); if let Some(custom_action) = workflows::CategoriesView::custom_action() { @@ -4129,6 +4177,7 @@ impl Input { cloud_mode_composer_slash_command_data_source, ephemeral_message_model, input_contents_before_prompt_chip_command: None, + pending_shell_widget_handoff: None, }; #[cfg(feature = "local_fs")] @@ -6508,8 +6557,12 @@ impl Input { let command = render_prompt_chip_shell_command(command, shell_type); // Snapshot the current input so we can restore it after the command completes. let current_input = self.buffer_text(ctx); - if self.try_execute_command_from_source(&command, CommandExecutionSource::User, ctx) - { + if self.try_execute_command_from_source( + &command, + CommandExecutionSource::User, + true, + ctx, + ) { self.cancel_active_conversation(ctx, CancellationReason::UserCommandExecuted); if !current_input.is_empty() { self.input_contents_before_prompt_chip_command = Some(current_input); @@ -7580,6 +7633,7 @@ impl Input { ai_metadata: None, preserve_input, }, + true, ctx, ) } @@ -7621,6 +7675,61 @@ impl Input { self.try_execute_command_with_options(command, false, ctx) } + /// Applies `selection` only if `session_id` matches the in-flight handoff. + pub fn set_external_shell_widget_selection(&mut self, session_id: SessionId, selection: &str) { + let Some(handoff) = self.pending_shell_widget_handoff.as_mut() else { + return; + }; + handoff.maybe_apply_selection(session_id, selection); + } + + /// Runs `helper_command` (a bootstrap-installed shell function), snapshotting the current + /// buffer so it can be restored once the command's block completes. Returns `true` if the + /// command was started. + pub fn trigger_external_shell_widget_handoff( + &mut self, + helper_command: &str, + apply_mode: ShellWidgetApplyMode, + capture_cursor: bool, + ctx: &mut ViewContext, + ) -> bool { + let Some(session_id) = self.active_block_session_id() else { + return false; + }; + let original_buffer = self.buffer_text(ctx); + let cursor_offset = capture_cursor.then(|| { + self.editor + .as_ref(ctx) + .end_byte_index_of_last_selection(ctx) + }); + let block_id = self.model.lock().block_list().active_block_id().clone(); + // Prefixed with a leading space, the "ignorespace" convention. + let mut command = format!(" {helper_command}"); + if let Some(cursor_offset) = cursor_offset + && apply_mode == ShellWidgetApplyMode::Replace + { + let char_cursor = original_buffer[..cursor_offset.as_usize()].chars().count(); + command.push_str(&format!(" {char_cursor}:{}", hex::encode(&original_buffer))); + } + let started = self.try_execute_command_from_source( + &command, + CommandExecutionSource::User, + false, /* should_add_command_to_history */ + ctx, + ); + if started { + self.pending_shell_widget_handoff = Some(PendingShellWidgetHandoff { + session_id, + original_buffer, + selection: None, + block_id, + apply_mode, + cursor_offset, + }); + } + started + } + fn try_execute_command_with_options( &mut self, command: &str, @@ -7664,10 +7773,11 @@ impl Input { self.try_execute_command_from_source( command, CommandExecutionSource::QueuedCommand, + true, ctx, ) } else { - self.try_execute_command_from_source(command, CommandExecutionSource::User, ctx) + self.try_execute_command_from_source(command, CommandExecutionSource::User, true, ctx) } } @@ -7711,6 +7821,7 @@ impl Input { &mut self, command: &str, source: CommandExecutionSource, + should_add_command_to_history: bool, ctx: &mut ViewContext, ) -> bool { if let CanExecuteCommand::No(reason) = self.can_execute_command(ctx) { @@ -7868,7 +7979,12 @@ impl Input { }); } - self.start_block_and_write_command_to_pty(command, source, ctx); + self.start_block_and_write_command_to_pty( + command, + source, + should_add_command_to_history, + ctx, + ); did_execute = true; } else { // We don't want to submit the command if precmd has not @@ -15475,8 +15591,24 @@ impl Input { && !cloud_setup_pre_first_exchange && !self.has_queued_command_in_flight(ctx); let latest_block_id = self.model.lock().block_list().active_block_id().clone(); - let input_contents_before_prompt_chip_command = - self.input_contents_before_prompt_chip_command.take(); + // Prefer a prompt-chip restore (e.g. `cd`) over a shell-widget handoff restore. + let completed_handoff = self + .pending_shell_widget_handoff + .take_if(|handoff| handoff.block_id == block_completed_event.block_id); + if let Some(handoff) = &completed_handoff { + self.model + .lock() + .block_list_mut() + .hide_block(&handoff.block_id); + } + let pending_input_restore = self + .input_contents_before_prompt_chip_command + .take() + .or_else(|| { + completed_handoff + .as_ref() + .map(|handoff| handoff.restore_text().to_string()) + }); if should_clear_buffer { // We want to reinitialize the buffer whenever a command is completed so that @@ -15487,11 +15619,38 @@ impl Input { .update(ctx, |editor, ctx| editor.reinitialize_buffer(None, ctx)); self.latest_buffer_operations = Vec::new(); - // If we have a pending input restore (from a prompt chip command like cd), - // restore the input contents instead of leaving the buffer empty. - if let Some(restore_text) = input_contents_before_prompt_chip_command { + // If we have a pending input restore (from a prompt chip command like cd, or + // a ctrl-r/ctrl-t external handoff), restore the input contents instead of + // leaving the buffer empty. + if let Some(restore_text) = pending_input_restore { self.editor.update(ctx, |editor, ctx| { editor.set_buffer_text(&restore_text, ctx); + if let Some(handoff) = &completed_handoff { + match ( + handoff.apply_mode, + &handoff.selection, + handoff.cursor_offset, + ) { + ( + ShellWidgetApplyMode::Splice, + Some(insertion), + Some(cursor_offset), + ) => editor.select_and_replace( + insertion, + [cursor_offset..cursor_offset], + PlainTextEditorViewAction::InsertSelectedText, + ctx, + ), + (_, None, Some(cursor_offset)) => editor + .select_ranges_by_byte_offset( + [cursor_offset..cursor_offset], + ctx, + ), + (ShellWidgetApplyMode::Replace, Some(_), _) + | (ShellWidgetApplyMode::Splice, Some(_), None) + | (_, None, None) => {} + } + } }); self.is_editor_empty_on_last_edit = false; } else { @@ -15650,6 +15809,7 @@ impl Input { &mut self, command: &str, source: CommandExecutionSource, + should_add_command_to_history: bool, ctx: &mut ViewContext, ) { start_trace!("command_execution:start"); @@ -15725,7 +15885,7 @@ impl Input { workflow_id, session_id, workflow_command, - should_add_command_to_history: true, + should_add_command_to_history, source, }))); end_trace!(); diff --git a/app/src/terminal/input_tests.rs b/app/src/terminal/input_tests.rs index 05f18f67598..2267464b582 100644 --- a/app/src/terminal/input_tests.rs +++ b/app/src/terminal/input_tests.rs @@ -116,6 +116,57 @@ use crate::{ ReferralThemeStatus, experiments, }; +fn pending_ctrl_r_handoff() -> PendingShellWidgetHandoff { + PendingShellWidgetHandoff { + session_id: SessionId::from(1), + original_buffer: "draft".to_string(), + selection: None, + block_id: BlockId::new(), + apply_mode: ShellWidgetApplyMode::Replace, + cursor_offset: None, + } +} + +fn pending_ctrl_t_handoff() -> PendingShellWidgetHandoff { + PendingShellWidgetHandoff { + session_id: SessionId::from(1), + original_buffer: "echo ".to_string(), + selection: None, + block_id: BlockId::new(), + apply_mode: ShellWidgetApplyMode::Splice, + cursor_offset: Some(ByteOffset::from(5)), + } +} + +#[test] +fn matching_shell_widget_handoff_selection_is_applied() { + let mut handoff = pending_ctrl_r_handoff(); + handoff.maybe_apply_selection(SessionId::from(1), "echo selected"); + assert_eq!(handoff.restore_text(), "echo selected"); + + let mut handoff = pending_ctrl_t_handoff(); + handoff.maybe_apply_selection(SessionId::from(1), "selected/file.txt"); + assert_eq!(handoff.selection, Some("selected/file.txt".to_string())); +} + +#[test] +fn unsolicited_or_stale_shell_widget_handoff_selection_is_ignored() { + let mut handoff = pending_ctrl_r_handoff(); + handoff.maybe_apply_selection(SessionId::from(2), "echo selected"); + assert_eq!(handoff.restore_text(), "draft"); +} + +#[test] +fn empty_shell_widget_handoff_selection_keeps_original_buffer() { + let mut handoff = pending_ctrl_r_handoff(); + handoff.maybe_apply_selection(SessionId::from(1), ""); + assert_eq!(handoff.restore_text(), "draft"); + + let mut handoff = pending_ctrl_t_handoff(); + handoff.maybe_apply_selection(SessionId::from(1), ""); + assert_eq!(handoff.selection, None); +} + #[test] fn renders_git_checkout_prompt_chip_command_as_single_shell_argument() { let command = PromptChipShellCommand::GitCheckout { @@ -1835,6 +1886,341 @@ fn queued_command_completion_preserves_draft() { }); } +fn user_block_completed_for_test(command: &str) -> BlockType { + BlockType::User(UserBlockCompleted::new_for_test( + BlockIndex::zero(), + Arc::new(SerializedBlock::new_for_test( + command.as_bytes().to_vec(), + vec![], + )), + command.to_owned(), + command.to_owned(), + String::new(), + String::new(), + false, + None, + 0, + 0, + )) +} + +async fn complete_ctrl_t_handoff( + app: &mut App, + apply_mode: ShellWidgetApplyMode, + original_buffer: &str, + cursor_offset: usize, + insertion: Option<&str>, +) -> (String, ByteOffset) { + let terminal = add_window_with_bootstrapped_terminal(app, None, None).await; + let input = terminal.read(app, |view, _| view.input().clone()); + let block_id = BlockId::new(); + input.update(app, |input, ctx| { + input.pending_shell_widget_handoff = Some(PendingShellWidgetHandoff { + session_id: SessionId::from(1), + original_buffer: original_buffer.to_string(), + selection: insertion.map(str::to_string), + block_id: block_id.clone(), + apply_mode, + cursor_offset: Some(ByteOffset::from(cursor_offset)), + }); + input.deferred_remote_operations.latest_block_id = BlockId::new(); + input.handle_block_completed_event( + BlockCompletedEvent { + block_type: user_block_completed_for_test(original_buffer), + num_secrets_obfuscated: 0, + block_index: BlockIndex::zero(), + block_id, + session_id: None, + restored_block_was_local: None, + }, + ctx, + ); + }); + input.read(app, |input, ctx| { + ( + input.buffer_text(ctx), + input + .editor() + .as_ref(ctx) + .end_byte_index_of_last_selection(ctx), + ) + }) +} + +async fn complete_ctrl_r_handoff( + app: &mut App, + original_buffer: &str, + selection: Option<&str>, +) -> String { + let terminal = add_window_with_bootstrapped_terminal(app, None, None).await; + let input = terminal.read(app, |view, _| view.input().clone()); + let block_id = BlockId::new(); + input.update(app, |input, ctx| { + input.pending_shell_widget_handoff = Some(PendingShellWidgetHandoff { + session_id: SessionId::from(1), + original_buffer: original_buffer.to_string(), + selection: selection.map(str::to_string), + block_id: block_id.clone(), + apply_mode: ShellWidgetApplyMode::Replace, + cursor_offset: None, + }); + input.deferred_remote_operations.latest_block_id = BlockId::new(); + input.handle_block_completed_event( + BlockCompletedEvent { + block_type: user_block_completed_for_test(original_buffer), + num_secrets_obfuscated: 0, + block_index: BlockIndex::zero(), + block_id, + session_id: None, + restored_block_was_local: None, + }, + ctx, + ); + }); + input.read(app, |input, ctx| input.buffer_text(ctx)) +} + +#[test] +fn ctrl_r_handoff_replace_lands_selection() { + App::test((), |mut app| async move { + initialize_app(&mut app); + let buffer = complete_ctrl_r_handoff(&mut app, "draft", Some("echo selected")).await; + assert_eq!(buffer, "echo selected"); + }); +} + +#[test] +fn ctrl_r_handoff_cancel_restores_draft() { + App::test((), |mut app| async move { + initialize_app(&mut app); + let buffer = complete_ctrl_r_handoff(&mut app, "draft", None).await; + assert_eq!(buffer, "draft"); + }); +} + +#[test] +fn ctrl_t_handoff_splices_selection_in_middle_of_line() { + App::test((), |mut app| async move { + initialize_app(&mut app); + let (buffer, cursor) = complete_ctrl_t_handoff( + &mut app, + ShellWidgetApplyMode::Splice, + "echo START END", + 11, + Some("FILE.txt "), + ) + .await; + assert_eq!(buffer, "echo START FILE.txt END"); + assert_eq!(cursor, ByteOffset::from("echo START FILE.txt ".len())); + }); +} + +#[test] +fn ctrl_t_handoff_splices_selection_at_end_of_line() { + App::test((), |mut app| async move { + initialize_app(&mut app); + let (buffer, cursor) = complete_ctrl_t_handoff( + &mut app, + ShellWidgetApplyMode::Splice, + "echo ", + 5, + Some("FILE.txt"), + ) + .await; + assert_eq!(buffer, "echo FILE.txt"); + assert_eq!(cursor, ByteOffset::from("echo FILE.txt".len())); + }); +} + +#[test] +fn ctrl_t_handoff_splices_selection_into_empty_buffer() { + App::test((), |mut app| async move { + initialize_app(&mut app); + let (buffer, cursor) = complete_ctrl_t_handoff( + &mut app, + ShellWidgetApplyMode::Splice, + "", + 0, + Some("FILE.txt"), + ) + .await; + assert_eq!(buffer, "FILE.txt"); + assert_eq!(cursor, ByteOffset::from("FILE.txt".len())); + }); +} + +#[test] +fn ctrl_t_handoff_splices_selection_after_multi_byte_character() { + App::test((), |mut app| async move { + initialize_app(&mut app); + let original = "caf\u{e9} "; + let cursor_offset = original.len(); + let (buffer, cursor) = complete_ctrl_t_handoff( + &mut app, + ShellWidgetApplyMode::Splice, + original, + cursor_offset, + Some("dest.txt"), + ) + .await; + assert_eq!(buffer, "caf\u{e9} dest.txt"); + assert_eq!(cursor, ByteOffset::from("caf\u{e9} dest.txt".len())); + }); +} + +#[test] +fn ctrl_t_handoff_cancel_restores_cursor_to_original_offset_mid_line() { + App::test((), |mut app| async move { + initialize_app(&mut app); + for apply_mode in [ShellWidgetApplyMode::Splice, ShellWidgetApplyMode::Replace] { + let (buffer, cursor) = + complete_ctrl_t_handoff(&mut app, apply_mode, "echo START END", 11, None).await; + assert_eq!( + buffer, "echo START END", + "{apply_mode:?}: cancelling must leave the original text untouched" + ); + assert_eq!( + cursor, + ByteOffset::from(11), + "{apply_mode:?}: cancelling must restore the cursor to where ctrl-t was pressed, \ + not the end of the buffer" + ); + } + }); +} + +#[test] +fn ctrl_t_handoff_cancel_restores_cursor_captured_by_a_real_trigger() { + App::test((), |mut app| async move { + initialize_app(&mut app); + let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; + let input = terminal.read(&app, |view, _| view.input().clone()); + + input.update(&mut app, |input, ctx| { + input.user_insert("echo START MIDDLE", ctx); + input.editor().update(ctx, |editor, ctx| { + editor.select_ranges_by_byte_offset( + [ByteOffset::from(11)..ByteOffset::from(11)], + ctx, + ); + }); + }); + + let started = input.update(&mut app, |input, ctx| { + input.trigger_external_shell_widget_handoff( + "warp_run_external_ctrl_t_widget", + ShellWidgetApplyMode::Splice, + true, + ctx, + ) + }); + assert!(started, "the handoff command should have started"); + + let block_id = terminal.read(&app, |terminal, _| { + terminal.model.lock().block_list().active_block_id().clone() + }); + + input.update(&mut app, |input, _ctx| { + input.deferred_remote_operations.latest_block_id = BlockId::new(); + }); + + input.update(&mut app, |input, ctx| { + input.handle_block_completed_event( + BlockCompletedEvent { + block_type: user_block_completed_for_test(" warp_run_external_ctrl_t_widget"), + num_secrets_obfuscated: 0, + block_index: BlockIndex::zero(), + block_id, + session_id: None, + restored_block_was_local: None, + }, + ctx, + ); + }); + + input.read(&app, |input, ctx| { + assert_eq!(input.buffer_text(ctx), "echo START MIDDLE"); + assert_eq!( + input + .editor() + .as_ref(ctx) + .end_byte_index_of_last_selection(ctx), + ByteOffset::from(11), + "cancelling a handoff whose cursor was captured by a real trigger must restore \ + the cursor to where ctrl-t was pressed, not the end of the buffer" + ); + }); + }); +} + +#[test] +fn ctrl_t_handoff_replace_mode_lands_selection_wholesale() { + App::test((), |mut app| async move { + initialize_app(&mut app); + let (buffer, cursor) = complete_ctrl_t_handoff( + &mut app, + ShellWidgetApplyMode::Replace, + "vim src/ END", + 8, + Some("vim src/nested.rs "), + ) + .await; + assert_eq!(buffer, "vim src/nested.rs "); + assert_eq!(cursor, ByteOffset::from("vim src/nested.rs ".len())); + }); +} + +#[test] +fn ctrl_t_apply_mode_forks_between_splice_and_replace_for_the_same_draft() { + App::test((), |mut app| async move { + initialize_app(&mut app); + + let (splice_buffer, splice_cursor) = complete_ctrl_t_handoff( + &mut app, + ShellWidgetApplyMode::Splice, + "vim src/ END", + 8, + Some("nested.rs "), + ) + .await; + assert_eq!(splice_buffer, "vim src/nested.rs END"); + assert_eq!(splice_cursor, ByteOffset::from("vim src/nested.rs ".len())); + + let (replace_buffer, replace_cursor) = complete_ctrl_t_handoff( + &mut app, + ShellWidgetApplyMode::Replace, + "vim src/ END", + 8, + Some("vim src/nested.rs END"), + ) + .await; + assert_eq!(replace_buffer, "vim src/nested.rs END"); + assert_eq!( + replace_cursor, + ByteOffset::from("vim src/nested.rs END".len()) + ); + }); +} + +#[test] +fn ctrl_t_binding_is_ineligible_when_shell_widget_handoff_flag_is_disabled() { + App::test((), |mut app| async move { + initialize_app(&mut app); + + assert!( + !FeatureFlag::ShellWidgetHandoff.is_enabled(), + "this test assumes the flag defaults to disabled in the test harness" + ); + app.read(|ctx| { + assert!( + ctx.get_binding_by_name("workspace:trigger_external_ctrl_t_file_search") + .is_none(), + "the ctrl-t binding must be ineligible while ShellWidgetHandoff is disabled" + ); + }); + }); +} + /// Verifies deleting a queued row does not overwrite an existing draft. #[test] fn row_deleted_event_preserves_existing_draft() { diff --git a/app/src/terminal/model/blocks.rs b/app/src/terminal/model/blocks.rs index 89949714117..a0255cde803 100644 --- a/app/src/terminal/model/blocks.rs +++ b/app/src/terminal/model/blocks.rs @@ -1420,6 +1420,18 @@ impl BlockList { } } + pub fn hide_block(&mut self, block_id: &BlockId) { + if let Some(block) = self.mut_block_from_id(block_id) { + block.hide(); + } else { + return; + } + self.update_blocks_and_sumtree(None, None, |_| {}, |_| {}); + + // Force a re-draw since the blocklist has changed. + self.event_proxy.send_wakeup_event(); + } + pub fn is_executing_oz_environment_startup_commands(&self) -> bool { self.is_executing_oz_environment_startup_commands } diff --git a/app/src/terminal/model/terminal_model.rs b/app/src/terminal/model/terminal_model.rs index 5bda5f22c1e..6223d306303 100644 --- a/app/src/terminal/model/terminal_model.rs +++ b/app/src/terminal/model/terminal_model.rs @@ -65,9 +65,10 @@ use crate::terminal::event_listener::ChannelEventListener; pub use crate::terminal::history::HistoryEntry; use crate::terminal::model::ansi; use crate::terminal::model::ansi::{ - ClearValue, CommandFinishedValue, CompletionMetadata, ExitShellValue, Handler, InitShellValue, - InitSubshellValue, PreInteractiveSSHSessionValue, PrecmdValue, PreexecValue, PromptMetadata, - SSHValue, SourcedRcFileForWarpValue, + ClearValue, CommandFinishedValue, CompletionMetadata, ExitShellValue, + ExternalShellWidgetSelectionValue, Handler, InitShellValue, InitSubshellValue, + PreInteractiveSSHSessionValue, PrecmdValue, PreexecValue, PromptMetadata, SSHValue, + SourcedRcFileForWarpValue, }; use crate::terminal::model::bootstrap::BootstrapStage; use crate::terminal::model::completions::{ShellCompletion, ShellCompletionUpdate}; @@ -3201,6 +3202,11 @@ impl ansi::Handler for TerminalModel { delegate!(self.input_buffer(data)); } + fn external_shell_widget_selection(&mut self, data: ExternalShellWidgetSelectionValue) { + self.event_proxy + .send_app_event(Event::ExternalShellWidgetSelection(data)); + } + fn init_subshell(&mut self, data: InitSubshellValue) { match ShellType::from_name(data.shell.as_str()) { Some(shell_type) => { diff --git a/app/src/terminal/model_events.rs b/app/src/terminal/model_events.rs index d3899218f37..4c73d536fe8 100644 --- a/app/src/terminal/model_events.rs +++ b/app/src/terminal/model_events.rs @@ -5,7 +5,7 @@ use warpui::{Entity, ModelContext, ModelHandle, SingletonEntity}; use super::event::{BootstrappedEvent, SshLoginStatus}; use super::model::ansi; -use super::model::ansi::FinishUpdateValue; +use super::model::ansi::{ExternalShellWidgetSelectionValue, FinishUpdateValue}; use super::model::block::BlockId; use super::model::completions::ShellCompletion; use super::model::lifecycle::LifecycleTelemetryEvent; @@ -263,6 +263,9 @@ impl ModelEventDispatcher { Event::HonorPS1OutOfSync => ModelEvent::HonorPS1OutOfSync, Event::Typeahead => ModelEvent::Typeahead, Event::FinishUpdate(data) => ModelEvent::FinishUpdate(data), + Event::ExternalShellWidgetSelection(data) => { + ModelEvent::ExternalShellWidgetSelection(data) + } Event::TextSelectionChanged => ModelEvent::SelectedTextChanged, Event::ShellSpawned(shell_type) => ModelEvent::ShellSpawned(shell_type), Event::ImageReceived { @@ -447,6 +450,7 @@ pub enum ModelEvent { /// inaccessible to views/models. Handler(AnsiHandlerEvent), FinishUpdate(FinishUpdateValue), + ExternalShellWidgetSelection(ExternalShellWidgetSelectionValue), SelectedTextChanged, ShellSpawned(ShellType), CompletionsFinished(Vec, Option), diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index bc676ad9757..ce450b5d9ff 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -418,7 +418,7 @@ use crate::terminal::input::inline_menu::InlineMenuPositioner; use crate::terminal::input::slash_commands::fork_button_action; use crate::terminal::input::{ CommandExecutionSource, InputAction, InputEmptyStateChangeReason, InputState, MenuPositioning, - MenuPositioningProvider, + MenuPositioningProvider, ShellWidgetApplyMode, }; use crate::terminal::keys::TerminalKeybindings; use crate::terminal::ligature_settings::{LigatureSettings, should_use_ligature_rendering}; @@ -716,6 +716,28 @@ pub const DEFAULT_ASK_AI_AUTOSUGGESTION_TEXT: &str = "What happened here?"; const WARP_MD_PATH: &str = "WARP.md"; +/// `shell_plugins` tag reported by bootstrap when the shell's `^R` binding has been rebound away +/// from its default reverse-history-search widget (e.g. by fzf or atuin). Must match the tag +/// name used in `app/assets/bundled/bootstrap/zsh_body.sh`. +const EXTERNAL_CTRL_R_HISTORY_PLUGIN_TAG: &str = "external_ctrl_r_history"; + +/// `shell_plugins` tag reported by bootstrap when the shell's `^T` binding has been rebound away +/// from its default line-editor binding to an external file-search widget (e.g. fzf). Independent +/// of [`EXTERNAL_CTRL_R_HISTORY_PLUGIN_TAG`] -- a shell can have either, both, or neither, since +/// each binding is detected and reported on its own. Must match the tag name used in +/// `app/assets/bundled/bootstrap/zsh_body.sh`. +const EXTERNAL_CTRL_T_FILE_PLUGIN_TAG: &str = "external_ctrl_t_file"; + +/// Name of the bootstrap-installed shell function invoked to hand ctrl-r off to the shell's +/// own external history widget. Must match the function name defined in +/// `app/assets/bundled/bootstrap/zsh_body.sh`. +const EXTERNAL_CTRL_R_HELPER_COMMAND: &str = "warp_run_external_ctrl_r_widget"; + +/// Name of the bootstrap-installed shell function invoked to hand ctrl-t off to the shell's own +/// external file-search widget. Must match the function name defined in +/// `app/assets/bundled/bootstrap/zsh_body.sh`. +const EXTERNAL_CTRL_T_HELPER_COMMAND: &str = "warp_run_external_ctrl_t_widget"; + pub const LONG_RUNNING_AGENT_REQUESTED_COMMAND_CONTEXT_KEY: &str = "LongRunningRequestedCommand"; pub const LONG_RUNNING_AGENT_REQUESTED_COMMAND_USER_TOOK_OVER_CONTEXT_KEY: &str = "LongRunningRequestedUserTookOverCommand"; @@ -9194,6 +9216,99 @@ impl TerminalView { && !model.is_read_only() } + /// If ctrl-r was pressed at an idle prompt on a session whose shell has rebound `^R` away + /// from its default reverse-history-search widget (reported via the + /// [`EXTERNAL_CTRL_R_HISTORY_PLUGIN_TAG`] shell plugin tag, e.g. by fzf or atuin), hands the + /// keypress off to that widget instead of opening Warp's own command search. + /// + /// Returns `true` if the handoff was triggered, in which case the caller should not open + /// Warp's command search. + pub fn maybe_trigger_external_ctrl_r_history_search( + &mut self, + ctx: &mut ViewContext, + ) -> bool { + if !FeatureFlag::ShellWidgetHandoff.is_enabled() || self.is_long_running() { + return false; + } + let Some(session_id) = self.active_block_session_id() else { + return false; + }; + let has_external_ctrl_r_widget = + self.sessions + .as_ref(ctx) + .get(session_id) + .is_some_and(|session| { + session + .shell() + .plugins() + .contains(EXTERNAL_CTRL_R_HISTORY_PLUGIN_TAG) + }); + if !has_external_ctrl_r_widget || self.model.lock().is_alt_screen_active() { + return false; + } + + self.input.update(ctx, |input, ctx| { + input.trigger_external_shell_widget_handoff( + EXTERNAL_CTRL_R_HELPER_COMMAND, + ShellWidgetApplyMode::Replace, + false, /* capture_cursor */ + ctx, + ) + }) + } + + /// If ctrl-t was pressed at an idle prompt on a session whose shell has rebound `^T` to an + /// external file-search widget (reported via the [`EXTERNAL_CTRL_T_FILE_PLUGIN_TAG`] shell + /// plugin tag, e.g. by fzf), hands the keypress off to that widget. Mirrors + /// [`Self::maybe_trigger_external_ctrl_r_history_search`], but lands the selection either by + /// inserting it into the input editor at the cursor position or by replacing the whole + /// buffer, depending on the session's shell; see [`Input::trigger_external_shell_widget_handoff`] + /// and [`ShellWidgetApplyMode`]. + /// + /// Returns `true` if the handoff was triggered, in which case the caller should not pass + /// ctrl-t through to the pty or handle it any other way. + pub fn maybe_trigger_external_ctrl_t_file_search( + &mut self, + ctx: &mut ViewContext, + ) -> bool { + if !FeatureFlag::ShellWidgetHandoff.is_enabled() || self.is_long_running() { + return false; + } + let Some(session_id) = self.active_block_session_id() else { + return false; + }; + let Some(session) = self.sessions.as_ref(ctx).get(session_id) else { + return false; + }; + if !session + .shell() + .plugins() + .contains(EXTERNAL_CTRL_T_FILE_PLUGIN_TAG) + || self.model.lock().is_alt_screen_active() + { + return false; + } + // fish invokes the user's real `fzf-file-widget` directly, which already performs its + // own token-aware replacement and so returns the whole new line; bash/zsh's helper + // instead searches independently of the draft and reports a plain path to splice in at + // the cursor. See `ShellWidgetApplyMode` and the fish/bash/zsh helper implementations. + let apply_mode = match session.shell().shell_type() { + ShellType::Fish => ShellWidgetApplyMode::Replace, + ShellType::Bash | ShellType::Zsh | ShellType::PowerShell => { + ShellWidgetApplyMode::Splice + } + }; + + self.input.update(ctx, |input, ctx| { + input.trigger_external_shell_widget_handoff( + EXTERNAL_CTRL_T_HELPER_COMMAND, + apply_mode, + true, /* capture_cursor */ + ctx, + ) + }) + } + /// Returns `true` when an interactive SSH command has been detected at /// preexec and the SSH block is still running (long-running). Used by /// the workspace to derive `PendingRemoteSession` without storing @@ -9480,7 +9595,7 @@ impl TerminalView { /// Also calls logic to emit a sync event. Returns whether the bytes were /// actually forwarded to the PTY: `false` when the active block is under /// agent control, in which case nothing is written. - fn write_user_bytes_to_pty>>( + pub(crate) fn write_user_bytes_to_pty>>( &mut self, data: B, ctx: &mut ViewContext, @@ -12879,6 +12994,15 @@ impl TerminalView { log::warn!("Got a FinishUpdate event with non-matching update id!"); } } + ModelEvent::ExternalShellWidgetSelection(data) => { + if FeatureFlag::ShellWidgetHandoff.is_enabled() + && let Some(session_id) = data.session_id.map(SessionId::from) + { + self.input.update(ctx, |input, _ctx| { + input.set_external_shell_widget_selection(session_id, &data.buffer); + }); + } + } ModelEvent::SelectedTextChanged => { ctx.emit(Event::SelectedTextChanged); } diff --git a/app/src/terminal/view/tab_metadata.rs b/app/src/terminal/view/tab_metadata.rs index 3ce0f752a84..77c1db8c59e 100644 --- a/app/src/terminal/view/tab_metadata.rs +++ b/app/src/terminal/view/tab_metadata.rs @@ -48,6 +48,7 @@ impl TerminalView { if block.finished() && !block.is_background() && !block.is_static() + && !block.is_hidden() && !block.is_in_band_command_block() && (block.bootstrap_stage().is_done() || block.is_restored()) { diff --git a/app/src/workspace/action.rs b/app/src/workspace/action.rs index ea0d9b75747..5c0fa344582 100644 --- a/app/src/workspace/action.rs +++ b/app/src/workspace/action.rs @@ -142,25 +142,14 @@ pub enum WorkspaceAction { RenamePane(PaneViewLocator), ResetPaneName(PaneViewLocator), RenameActiveTab, - /// Renames the focused pane in the active tab. Mirrors `RenameActiveTab` - /// so the action is reachable from the binding registry / Command Palette - /// (see #9351). The context-menu path keeps using `RenamePane(locator)`. RenameActivePane, SetActiveTabName(String), CycleActiveTabColor, - /// Sets the manual color override for the active tab. - /// - /// - `Color(_)` — apply that color. - /// - `Cleared` — explicitly clear (suppresses any directory default). - /// - `Unset` — remove the manual override (lets the directory default apply, if any). SetActiveTabColor(SelectedTabColor), ToggleTabRightClickMenu { tab_index: usize, anchor: TabContextMenuAnchor, }, - /// Toggles the multi-tab selection right-click menu. - /// Dispatched by the UI when the right-clicked tab is part of a multi-tab - /// selection (cmd-click or shift-click). ToggleTabSelectionRightClickMenu { tab_index: usize, anchor: TabContextMenuAnchor, @@ -188,9 +177,6 @@ pub enum WorkspaceAction { ToggleTabGroupCollapsed(TabGroupId), /// Opens an inline editor over the given group's header for renaming. RenameTabGroup(TabGroupId), - /// Cancels any active rename (tab, pane, or group) without committing the - /// new name. Dispatched when clicking on the vtab panel background while a - /// rename editor is open. CancelActiveRename, /// Creates a new tab group containing the tab at the given index. NewTabGroupFromTab(usize), @@ -211,8 +197,7 @@ pub enum WorkspaceAction { ToggleTabMultiSelection { locator: PaneViewLocator, }, - /// Clears the tab multi-selection. Dispatched from the UI when the user takes - /// an action that should cancel any active selections. + /// Clears the tab multi-selection. ClearTabMultiSelection, /// Creates a new tab group from the current tab multi-selection. NewTabGroupFromSelectedTabs, @@ -340,8 +325,6 @@ pub enum WorkspaceAction { color: AnsiColorIdentifier, tab_index: usize, }, - /// Toggles the color for a tab group. Clears the color if it was already - /// set to `color`; otherwise applies `color` as the uniform group color. ToggleTabGroupColor { color: AnsiColorIdentifier, group_id: TabGroupId, @@ -355,6 +338,7 @@ pub enum WorkspaceAction { ClickedAIAssistantIcon, ToggleKeybindingsPage, ShowCommandSearch(CommandSearchOptions), + TriggerExternalCtrlTFileSearch, CreatePersonalNotebook, ImportToPersonalDrive, ImportToTeamDrive, @@ -391,8 +375,7 @@ pub enum WorkspaceAction { ToggleLeftPanel, /// Toggles directly to the Warp Drive tab of the left panel in Code Mode V2 ToggleWarpDrive, - /// Unconditionally opens Warp Drive. This is used in the case of user lifecycle - /// events like new user onboarding or when the user joins a team. + /// Unconditionally opens Warp Drive. OpenWarpDrive, /// Toggles the right panel. This happens as an explicit action from the user. ToggleRightPanel, @@ -1072,6 +1055,7 @@ impl WorkspaceAction { | OpenPromptSuggestionsUnavailableModal | ToggleKeybindingsPage | ShowCommandSearch(_) + | TriggerExternalCtrlTFileSearch | ToggleMouseReporting | ToggleScrollReporting | ToggleFocusReporting diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index 7d3b3cfafeb..62335d0b8f0 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -403,6 +403,7 @@ use crate::terminal::ligature_settings::should_use_ligature_rendering; #[cfg(feature = "local_tty")] use crate::terminal::local_tty::docker_sandbox::resolve_sbx_path_from_user_shell; use crate::terminal::model::blockgrid::BlockGrid; +use crate::terminal::model::escape_sequences::C0; #[cfg(feature = "local_fs")] use crate::terminal::model::session::Session; use crate::terminal::model::session::SessionId; @@ -17296,6 +17297,15 @@ impl Workspace { return; } + if query_filter.is_none() + && let Some(terminal_view_handle) = self.active_session_view(ctx) + && terminal_view_handle.update(ctx, |terminal_view, ctx| { + terminal_view.maybe_trigger_external_ctrl_r_history_search(ctx) + }) + { + return; + } + // Close all overlays including chip menus before opening command search self.close_all_overlays(ctx); @@ -17373,6 +17383,21 @@ impl Workspace { } } + /// If the active session's shell has rebound ctrl-t to an external file-search widget + /// (e.g. fzf), hands the keypress off to it. + fn trigger_external_ctrl_t_file_search(&mut self, ctx: &mut ViewContext) { + if self.is_readonly_shared_session_active(ctx) { + return; + } + if let Some(terminal_view_handle) = self.active_session_view(ctx) { + terminal_view_handle.update(ctx, |terminal_view, ctx| { + if !terminal_view.maybe_trigger_external_ctrl_t_file_search(ctx) { + terminal_view.write_user_bytes_to_pty(vec![C0::DC4], ctx); + } + }); + } + } + fn get_active_input_view_handle(&self, app: &AppContext) -> Option> { app.view(self.active_tab_pane_group()) .active_session_view(app) @@ -24419,6 +24444,7 @@ impl TypedActionView for Workspace { filter, init_content, }) => self.show_command_search(*filter, init_content, ctx), + TriggerExternalCtrlTFileSearch => self.trigger_external_ctrl_t_file_search(ctx), ImportToPersonalDrive => { if let Some(personal_drive) = UserWorkspaces::as_ref(ctx).personal_drive(ctx) { self.open_import_modal(personal_drive, &None, ctx); diff --git a/app/src/workspace/view_tests.rs b/app/src/workspace/view_tests.rs index 9bfbf366831..3522f311f7f 100644 --- a/app/src/workspace/view_tests.rs +++ b/app/src/workspace/view_tests.rs @@ -1860,6 +1860,41 @@ fn test_workspace_sessions_retrieves_tabs() { }); } +#[test] +fn ctrl_t_action_forwards_to_pty_when_no_external_widget_detected() { + App::test((), |mut app| async move { + initialize_app(&mut app); + let _flag = FeatureFlag::ShellWidgetHandoff.override_enabled(true); + let workspace = mock_workspace(&mut app); + + let terminal_view = workspace.update(&mut app, |workspace, ctx| { + workspace + .active_session_view(ctx) + .expect("mock_workspace should have an active session") + }); + + let pty_writes: std::rc::Rc>>> = Default::default(); + let writes = pty_writes.clone(); + app.update(|ctx| { + ctx.subscribe_to_view(&terminal_view, move |_, event, _| { + if let crate::terminal::view::Event::WriteBytesToPty { bytes } = event { + writes.borrow_mut().push(bytes.to_vec()); + } + }); + }); + + workspace.update(&mut app, |workspace, ctx| { + workspace.handle_action(&WorkspaceAction::TriggerExternalCtrlTFileSearch, ctx); + }); + + assert_eq!( + *pty_writes.borrow(), + vec![vec![0x14]], + "ctrl-t must be forwarded to the pty as a plain keystroke when no external widget is detected" + ); + }); +} + #[test] fn test_workspace_sessions_retrieves_panes() { App::test((), |mut app| async move { diff --git a/crates/warp_features/src/lib.rs b/crates/warp_features/src/lib.rs index 7ff3bfa9284..18dcde7ac97 100644 --- a/crates/warp_features/src/lib.rs +++ b/crates/warp_features/src/lib.rs @@ -977,6 +977,9 @@ pub enum FeatureFlag { /// signaled or torn down. CtrlCCancelsThirdPartyHarness, + /// Uses fzf or atuin for history search instead of Warp's command search. + ShellWidgetHandoff, + /// Attaches process-tree liveness signals to long-running command /// snapshots, giving the agent evidence that a silent command is still /// doing work before it decides to cancel. @@ -1062,7 +1065,10 @@ pub const DOGFOOD_FLAGS: &[FeatureFlag] = &[ /// Features enabled for feature preview build users (e.g.: Friends of Warp). /// All PREVIEW_FLAGS are also automatically added to dogfood builds (WarpDev). -pub const PREVIEW_FLAGS: &[FeatureFlag] = &[FeatureFlag::NativeShellCompletions]; +pub const PREVIEW_FLAGS: &[FeatureFlag] = &[ + FeatureFlag::NativeShellCompletions, + FeatureFlag::ShellWidgetHandoff, +]; /// Features enabled for all release builds (i.e.: everything but WarpLocal). /// NOTE: if you are promoting a feature from Preview to launch, you'll likely diff --git a/crates/warp_terminal/src/model/ansi/dcs_hooks.rs b/crates/warp_terminal/src/model/ansi/dcs_hooks.rs index 9d18b257acf..cd0e2b102d4 100644 --- a/crates/warp_terminal/src/model/ansi/dcs_hooks.rs +++ b/crates/warp_terminal/src/model/ansi/dcs_hooks.rs @@ -69,6 +69,9 @@ pub(super) enum DProtoHook { InputBuffer { value: InputBufferValue, }, + ExternalShellWidgetSelection { + value: ExternalShellWidgetSelectionValue, + }, Clear { value: ClearValue, }, @@ -96,6 +99,7 @@ const DPROTO_HOOK_VARIANTS: &[&str] = &[ "SSH", "InitShell", "InputBuffer", + "ExternalShellWidgetSelection", "Clear", "InitSubshell", "SourcedRcFileForWarp", @@ -149,6 +153,9 @@ impl<'de> Deserialize<'de> for DProtoHook { "InputBuffer" => DProtoHook::InputBuffer { value: parse_hook_value::<_, D::Error>(raw.value)?, }, + "ExternalShellWidgetSelection" => DProtoHook::ExternalShellWidgetSelection { + value: parse_hook_value::<_, D::Error>(raw.value)?, + }, "Clear" => DProtoHook::Clear { value: parse_hook_value::<_, D::Error>(raw.value)?, }, @@ -185,6 +192,7 @@ impl DProtoHook { DProtoHook::SSH { .. } => "SSH", DProtoHook::InitShell { .. } => "InitShell", DProtoHook::InputBuffer { .. } => "InputBuffer", + DProtoHook::ExternalShellWidgetSelection { .. } => "ExternalShellWidgetSelection", DProtoHook::Clear { .. } => "Clear", DProtoHook::InitSubshell { .. } => "InitSubshell", DProtoHook::SourcedRcFileForWarp { .. } => "SourcedRcFileForWarp", @@ -204,6 +212,9 @@ impl DProtoHook { DProtoHook::CommandFinished { value } => value.session_id.map(SessionId::from), DProtoHook::Bootstrapped { value } => value.session_id.map(SessionId::from), DProtoHook::InputBuffer { value } => value.session_id.map(SessionId::from), + DProtoHook::ExternalShellWidgetSelection { value } => { + value.session_id.map(SessionId::from) + } DProtoHook::Clear { value } => value.session_id.map(SessionId::from), DProtoHook::FinishUpdate { value } => value.session_id.map(SessionId::from), DProtoHook::PreInteractiveSSHSession { value } => value.session_id.map(SessionId::from), @@ -225,6 +236,7 @@ impl DProtoHook { | DProtoHook::SSH { .. } | DProtoHook::InitShell { .. } | DProtoHook::InputBuffer { .. } + | DProtoHook::ExternalShellWidgetSelection { .. } | DProtoHook::Clear { .. } | DProtoHook::InitSubshell { .. } | DProtoHook::FinishUpdate { .. } @@ -985,6 +997,24 @@ pub struct InputBufferValue { pub session_id: HookSessionId, } +/// Selection reported by an external shell widget (ctrl-r history or ctrl-t file search). +/// Empty `buffer` means the user cancelled. +#[derive(Default, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct ExternalShellWidgetSelectionValue { + pub buffer: String, + #[serde(default)] + pub session_id: HookSessionId, +} + +impl std::fmt::Debug for ExternalShellWidgetSelectionValue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ExternalShellWidgetSelectionValue") + .field("buffer", &"") + .field("session_id", &self.session_id) + .finish() + } +} + /// Received from the pty when the terminal screen should be cleared (e.g. via /// the `clear` command or ctrl-l). #[derive(Debug, Default, Deserialize, Serialize)] diff --git a/crates/warp_terminal/src/model/ansi/dcs_hooks_tests.rs b/crates/warp_terminal/src/model/ansi/dcs_hooks_tests.rs index f34f6fc28e6..d6351c3ea3d 100644 --- a/crates/warp_terminal/src/model/ansi/dcs_hooks_tests.rs +++ b/crates/warp_terminal/src/model/ansi/dcs_hooks_tests.rs @@ -170,6 +170,10 @@ fn every_hook_tag_dispatches_to_the_matching_variant() { serde_json::json!({"session_id": 1, "shell": "zsh"}), ), ("InputBuffer", serde_json::json!({"buffer": "echo hi"})), + ( + "ExternalShellWidgetSelection", + serde_json::json!({"buffer": "echo hi"}), + ), ("Clear", serde_json::json!({})), ( "InitSubshell", diff --git a/crates/warp_terminal/src/model/ansi/handler.rs b/crates/warp_terminal/src/model/ansi/handler.rs index 9ea1cb3efd5..231af3dd903 100644 --- a/crates/warp_terminal/src/model/ansi/handler.rs +++ b/crates/warp_terminal/src/model/ansi/handler.rs @@ -306,6 +306,8 @@ pub trait Handler { /// input buffer (the reporting is itself triggered by Warp). fn input_buffer(&mut self, _data: InputBufferValue) {} + fn external_shell_widget_selection(&mut self, _data: ExternalShellWidgetSelectionValue) {} + /// Callback emitted during the initialization process for subshells with where the shell type /// is initiall not known. fn init_subshell(&mut self, _data: InitSubshellValue) {} diff --git a/crates/warp_terminal/src/model/ansi/mod.rs b/crates/warp_terminal/src/model/ansi/mod.rs index e6bab9ad373..4f279bf2969 100644 --- a/crates/warp_terminal/src/model/ansi/mod.rs +++ b/crates/warp_terminal/src/model/ansi/mod.rs @@ -604,6 +604,9 @@ impl<'a, H: Handler + 'a, W: io::Write> Performer<'a, H, W> { Ok(DProtoHook::SSH { value }) => self.handler.ssh(value), Ok(DProtoHook::InitShell { value }) => self.handler.init_shell(value), Ok(DProtoHook::InputBuffer { value }) => self.handler.input_buffer(value), + Ok(DProtoHook::ExternalShellWidgetSelection { value }) => { + self.handler.external_shell_widget_selection(value) + } Ok(DProtoHook::Clear { value }) => self.handler.clear(value), Ok(DProtoHook::InitSubshell { value }) => self.handler.init_subshell(value), Ok(DProtoHook::SourcedRcFileForWarp { .. }) => { diff --git a/crates/warp_terminal/src/model/ansi/mod_tests.rs b/crates/warp_terminal/src/model/ansi/mod_tests.rs index 4e0846d50d9..94d79ce5de5 100644 --- a/crates/warp_terminal/src/model/ansi/mod_tests.rs +++ b/crates/warp_terminal/src/model/ansi/mod_tests.rs @@ -237,6 +237,11 @@ impl Handler for MockHandler { .push(DProtoHook::InputBuffer { value: data }) } + fn external_shell_widget_selection(&mut self, data: super::ExternalShellWidgetSelectionValue) { + self.d_proto_hooks + .push(DProtoHook::ExternalShellWidgetSelection { value: data }) + } + fn init_subshell(&mut self, data: InitSubshellValue) { self.d_proto_hooks .push(DProtoHook::InitSubshell { value: data }) @@ -975,6 +980,53 @@ fn parse_dcs_input_buffer() { } } +#[test] +fn parse_dcs_external_shell_widget_selection() { + let bytes = hex_encoded_dcs_string( + r#"{ + "hook": "ExternalShellWidgetSelection", + "value": { + "buffer": "echo selected", + "session_id": 167303092612201 + } + }"#, + ); + + let (_, handler) = parse_bytes(&bytes); + + assert_eq!(handler.d_proto_hooks.len(), 1); + match handler.d_proto_hooks.first().unwrap() { + DProtoHook::ExternalShellWidgetSelection { value } => assert_eq!( + *value, + ExternalShellWidgetSelectionValue { + buffer: "echo selected".to_string(), + session_id: Some(167303092612201), + } + ), + _ => panic!("incorrect dcs value"), + } +} + +#[test] +fn parse_dcs_external_shell_widget_selection_with_unregistered_session_is_rejected() { + let bytes = hex_encoded_dcs_string( + r#"{ + "hook": "ExternalShellWidgetSelection", + "value": { + "buffer": "echo selected", + "session_id": 999999999999999 + } + }"#, + ); + + let (_, handler) = parse_bytes(&bytes); + + assert!( + handler.d_proto_hooks.is_empty(), + "a selection for an unregistered session_id must be rejected, not dispatched" + ); +} + #[test] fn parse_sourced_rc_file_hook() { let rc_file_hook = r#"{"hook": "SourcedRcFileForWarp", "value": { "shell": "zsh" }}"#;