diff --git a/app/Cargo.toml b/app/Cargo.toml index 0fb19de08d3..dbbed29fcd5 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -1050,6 +1050,7 @@ git_credential_refresh = [] prompt_cache_expiry_warning = [] osc_hyperlinks = [] ctrl_c_cancels_third_party_harness = [] +raw_keypress_ctrl_r_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 b90ecd351ee..ff435155d5b 100644 --- a/app/assets/bundled/bootstrap/bash_body.sh +++ b/app/assets/bundled/bootstrap/bash_body.sh @@ -1357,6 +1357,148 @@ esac shell_plugins=() + # Prototype (alternative to the foreground-command handoff in PR #15513): if the user has + # rebound ctrl-r to a `bind -x` function in one or more of bash's readline keymaps (as fzf + # and atuin both do), install a wrapper on the private key sequence Alt-] in that same keymap + # that re-invokes that keymap's own binding from inside a genuine readline key-binding + # context, giving it access to READLINE_LINE. Warp writes that sequence to the pty instead of + # a bare ^R when this session reports the `external_ctrl_r_raw_keypress` tag below. Not + # supported under MSYS2, where ctrl-r always falls through to Warp's own command search. + # + # Bash has no equivalent of zsh's `zle-keymap-select` hook, so Warp cannot tell which keymap + # is active when ctrl-r is pressed and `shell_plugins` cannot vary per keymap -- it is one + # session-wide flag. The tag is therefore only set once every relevant keymap has safely + # claimed Alt-] (with either a real wrapper or an empty-completion fallback); if any keymap + # already has a real binding there, we leave that keymap alone and withhold the tag entirely, + # since sending the private sequence into an occupied keymap would invoke the user's unrelated + # binding instead. An unqualified `bind -x` (no `-m`) only installs into whichever keymap is + # current at that moment, so each keymap below is bound explicitly with `-m`. + # + # A readline macro (installed by fzf on bash < 4) or a plain readline function name cannot be + # re-invoked this way, so those configurations are left undetected and fall back to Warp's own + # command search on ctrl-r. + __warp_classify_raw_keypress_ctrl_r_binding() { # keymap + local line + line=$(bind -m "$1" -X 2>/dev/null | command -p grep -F '"\C-r"' | command -p head -1) + [[ -n $line ]] || return 1 + line=${line#*: } + line=${line#\"} + printf '%s' "${line%\"}" + } + + # Returns success if Alt-] is not already bound to anything (function or exec) in the given + # keymap, i.e. it's safe for us to claim it there. + __warp_raw_keypress_ctrl_r_keyseq_free() { # keymap + ! { bind -m "$1" -p 2>/dev/null; bind -m "$1" -X 2>/dev/null; } | command -p grep -qF '"\e]"' + } + + # Reports the outcome of a raw-keypress ctrl-r handoff back to Warp. `token` is the decimal + # handoff id Warp pasted into the line buffer to start this handoff (see + # `raw_keypress_ctrl_r_handoff_payload` in view.rs); Warp echoes it back to the pending + # handoff it started, rejecting the report if they don't match. `selection` is the command + # text the user selected, or empty if they cancelled. + __warp_report_raw_keypress_ctrl_r_selection() { # token, selection + local escaped_token="$(warp_escape_json "$1")" + local escaped_selection="$(warp_escape_json "$2")" + warp_send_json_message "{ \"hook\": \"ExternalCtrlRRawKeypressSelection\", \"value\": { \"buffer\": \"$escaped_selection\", \"token\": \"$escaped_token\", \"session_id\": $WARP_SESSION_ID } }" + } + + # Reports that the real ctrl-r widget is actually about to run for this handoff. Sent + # before invoking it, so Warp has positive proof the wrapper is alive and can stop + # bounding the handoff by a fixed inactivity timeout -- unlike inferring liveness from pty + # output, which this token's own bracketed-paste echo would otherwise produce even when + # nothing is bound to the private key sequence at all. + __warp_report_raw_keypress_ctrl_r_started() { # token + local escaped_token="$(warp_escape_json "$1")" + warp_send_json_message "{ \"hook\": \"ExternalCtrlRRawKeypressStarted\", \"value\": { \"token\": \"$escaped_token\", \"session_id\": $WARP_SESSION_ID } }" + } + + # Fallback binding target for a keymap with no real ctrl-r widget to re-invoke: the pasted + # token is sitting alone in the line buffer (nothing else runs), so report it immediately + # with an empty selection. + __warp_report_raw_keypress_ctrl_r_selection_immediate() { + local token="$READLINE_LINE" + READLINE_LINE='' + READLINE_POINT=0 + __warp_report_raw_keypress_ctrl_r_selection "$token" '' + } + + # READLINE_LINE/READLINE_POINT are live globals for the duration of a `bind -x` binding, so + # the eval'd widget's assignments are visible here and readline picks them up when we return. + # A distinct function per keymap (rather than one shared function) is required because each + # keymap can capture a different original binding (e.g. atuin's emacs vs. vi-insert widgets). + # + # The token is captured and the buffer cleared *before* the real widget runs: the buffer + # holds the pasted token at that point, and after the widget runs it holds the selection + # instead. + __warp_run_raw_keypress_ctrl_r_widget_emacs() { + local token="$READLINE_LINE" + READLINE_LINE='' + READLINE_POINT=0 + __warp_report_raw_keypress_ctrl_r_started "$token" + eval "$__warp_raw_keypress_ctrl_r_orig_emacs" + local selection="$READLINE_LINE" + READLINE_LINE='' + READLINE_POINT=0 + __warp_report_raw_keypress_ctrl_r_selection "$token" "$selection" + } + __warp_run_raw_keypress_ctrl_r_widget_vi_insert() { + local token="$READLINE_LINE" + READLINE_LINE='' + READLINE_POINT=0 + __warp_report_raw_keypress_ctrl_r_started "$token" + eval "$__warp_raw_keypress_ctrl_r_orig_vi_insert" + local selection="$READLINE_LINE" + READLINE_LINE='' + READLINE_POINT=0 + __warp_report_raw_keypress_ctrl_r_selection "$token" "$selection" + } + __warp_run_raw_keypress_ctrl_r_widget_vi_command() { + local token="$READLINE_LINE" + READLINE_LINE='' + READLINE_POINT=0 + __warp_report_raw_keypress_ctrl_r_started "$token" + eval "$__warp_raw_keypress_ctrl_r_orig_vi_command" + local selection="$READLINE_LINE" + READLINE_LINE='' + READLINE_POINT=0 + __warp_report_raw_keypress_ctrl_r_selection "$token" "$selection" + } + + if [ "$WARP_IN_MSYS2" = false ]; then + __warp_raw_keypress_ctrl_r_all_keymaps_safe=1 + if __warp_raw_keypress_ctrl_r_keyseq_free emacs; then + if __warp_raw_keypress_ctrl_r_orig_emacs=$(__warp_classify_raw_keypress_ctrl_r_binding emacs); then + bind -m emacs -x '"\e]": __warp_run_raw_keypress_ctrl_r_widget_emacs' + else + bind -m emacs -x '"\e]": __warp_report_raw_keypress_ctrl_r_selection_immediate' + fi + else + __warp_raw_keypress_ctrl_r_all_keymaps_safe=0 + fi + if __warp_raw_keypress_ctrl_r_keyseq_free vi-insert; then + if __warp_raw_keypress_ctrl_r_orig_vi_insert=$(__warp_classify_raw_keypress_ctrl_r_binding vi-insert); then + bind -m vi-insert -x '"\e]": __warp_run_raw_keypress_ctrl_r_widget_vi_insert' + else + bind -m vi-insert -x '"\e]": __warp_report_raw_keypress_ctrl_r_selection_immediate' + fi + else + __warp_raw_keypress_ctrl_r_all_keymaps_safe=0 + fi + if __warp_raw_keypress_ctrl_r_keyseq_free vi-command; then + if __warp_raw_keypress_ctrl_r_orig_vi_command=$(__warp_classify_raw_keypress_ctrl_r_binding vi-command); then + bind -m vi-command -x '"\e]": __warp_run_raw_keypress_ctrl_r_widget_vi_command' + else + bind -m vi-command -x '"\e]": __warp_report_raw_keypress_ctrl_r_selection_immediate' + fi + else + __warp_raw_keypress_ctrl_r_all_keymaps_safe=0 + fi + if [ "$__warp_raw_keypress_ctrl_r_all_keymaps_safe" = 1 ]; then + shell_plugins+=(external_ctrl_r_raw_keypress) + fi + fi + function warp_bootstrapped () { local aliases="`alias`" local env_var_names="`compgen -e`" @@ -1388,8 +1530,13 @@ esac shell_plugins+=("starship") fi + # Join into a newline-separated list (one tag per line, matching zsh's `print -l --`) + # before escaping -- "$shell_plugins" alone would only expand to the array's first + # element. + local shell_plugins_list="$(printf '%s\n' "${shell_plugins[@]}")" + if [ "$WARP_IN_MSYS2" = false ]; then - local escaped_shell_plugins=$(warp_escape_json "$shell_plugins") + local escaped_shell_plugins=$(warp_escape_json "$shell_plugins_list") local escaped_path="$(warp_escape_json "$PATH")" local escaped_shell_options=$(warp_escape_json "$shell_options") fi @@ -1412,7 +1559,7 @@ esac warp_send_hook_kv_pair_escaped "function_names" "$function_names" warp_send_hook_kv_pair_escaped "builtins" "$builtins" warp_send_hook_kv_pair_escaped "keywords" "$keywords" - warp_send_hook_kv_pair "shell_plugins" "$shell_plugins" + warp_send_hook_kv_pair_escaped "shell_plugins" "$shell_plugins_list" warp_send_hook_kv_pair "shell_version" "$BASH_VERSION" warp_send_hook_kv_pair "shell_options" "$shell_options" warp_send_hook_kv_pair "rcfiles_start_time" "$rcfiles_start_time" @@ -1427,7 +1574,7 @@ esac local escaped_editor="$(warp_escape_json "$EDITOR")" local escaped_shell_path="$(warp_escape_json "$BASH")" local escaped_cdpath="$(warp_escape_json "$CDPATH")" - local escaped_json="{\"hook\": \"Bootstrapped\", \"value\": {\"histfile\": \"$escaped_histfile\", \"session_id\": $WARP_SESSION_ID, \"shell\": \"bash\", \"home_dir\": \"$HOME\", \"user\":\"$_user\", \"host\":\"$_hostname\", \"path\": \"$escaped_path\", \"cdpath\": \"$escaped_cdpath\", \"editor\": \"$escaped_editor\", \"env_var_names\": \"$escaped_env_var_names\", \"abbreviations\": \"$escaped_abbrs\", \"aliases\": \"$escaped_aliases\", \"function_names\": \"$escaped_function_names\", \"builtins\": \"$escaped_builtins\", \"keywords\": \"$escaped_keywords\", \"shell_version\": \"$BASH_VERSION\", \"shell_options\": \"$escaped_shell_options\", \"rcfiles_start_time\": \"$rcfiles_start_time\", \"rcfiles_end_time\": \"$rcfiles_end_time\", \"vi_mode_enabled\": \"$vi_mode_enabled\", \"os_category\": \"$os_category\", \"linux_distribution\": \"$linux_distribution\", \"wsl_name\": \"$WSL_DISTRO_NAME\", \"shell_path\": \"$escaped_shell_path\"}}" + local escaped_json="{\"hook\": \"Bootstrapped\", \"value\": {\"histfile\": \"$escaped_histfile\", \"session_id\": $WARP_SESSION_ID, \"shell\": \"bash\", \"home_dir\": \"$HOME\", \"user\":\"$_user\", \"host\":\"$_hostname\", \"path\": \"$escaped_path\", \"cdpath\": \"$escaped_cdpath\", \"editor\": \"$escaped_editor\", \"env_var_names\": \"$escaped_env_var_names\", \"abbreviations\": \"$escaped_abbrs\", \"aliases\": \"$escaped_aliases\", \"function_names\": \"$escaped_function_names\", \"builtins\": \"$escaped_builtins\", \"keywords\": \"$escaped_keywords\", \"shell_version\": \"$BASH_VERSION\", \"shell_options\": \"$escaped_shell_options\", \"rcfiles_start_time\": \"$rcfiles_start_time\", \"rcfiles_end_time\": \"$rcfiles_end_time\", \"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" fi } diff --git a/app/assets/bundled/bootstrap/fish.sh b/app/assets/bundled/bootstrap/fish.sh index 4d36d36a7e6..d245bceee0a 100644 --- a/app/assets/bundled/bootstrap/fish.sh +++ b/app/assets/bundled/bootstrap/fish.sh @@ -504,6 +504,133 @@ 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 +# Tags for shell configurations Warp needs to know about, matching the `shell_plugins` list bash +# and zsh already report. +set -g shell_plugins + +# Prototype (alternative to the foreground-command handoff in PR #15513): if the user has rebound +# ctrl-r away from fish's own history search in one or more of fish's bind modes ("default", and +# "insert" once `fish_vi_key_bindings` is loaded), install a wrapper in that same mode on the +# private key sequence Alt-] that re-invokes that mode's own binding from inside a real `bind` +# context. Both fzf's and atuin's fish widgets write their result back with the `commandline` +# builtin, which fails with status 1 outside an interactive-editing context, so invoking the +# user's binding from this wrapper is what makes fish work at all. Warp writes that sequence to +# the pty instead of a bare ^R when this session reports the `external_ctrl_r_raw_keypress` tag +# below. +# +# Warp cannot tell which mode is active when ctrl-r is pressed, so `shell_plugins` cannot vary +# per mode -- it is one session-wide flag. The tag is therefore only set once every relevant mode +# has safely claimed Alt-] (with either a real wrapper or an empty-completion fallback); if any +# mode already has a real binding there, we leave that mode alone and withhold the tag entirely, +# since sending the private sequence into an occupied mode would invoke the user's unrelated +# binding instead. +# +# `bind` lists fish's own defaults with a `--preset` flag, so any binding without it is one the +# user (or a plugin like fzf or atuin) installed. +function __warp_raw_keypress_ctrl_r_widget # mode + set -l widget "" + for binding in (bind -M $argv[1] \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 + +# Returns success if Alt-] is not already bound to anything in the given mode, i.e. it's safe +# for us to claim it there. +function __warp_raw_keypress_ctrl_r_keyseq_free # mode + not bind -M $argv[1] \x1b\x5d >/dev/null 2>&1 +end + +# Reports the outcome of a raw-keypress ctrl-r handoff back to Warp. `$argv[1]` is the decimal +# handoff id Warp pasted into the line buffer to start this handoff (see +# `raw_keypress_ctrl_r_handoff_payload` in view.rs); Warp echoes it back to the pending handoff +# it started, rejecting the report if they don't match. `$argv[2]` is the command text the user +# selected, or empty if they cancelled. +function __warp_report_raw_keypress_ctrl_r_selection # token selection + set -l escaped_token (warp_escape_json "$argv[1]") + set -l escaped_selection (warp_escape_json "$argv[2]") + warp_send_json_message "{ \"hook\": \"ExternalCtrlRRawKeypressSelection\", \"value\": { \"buffer\": \"$escaped_selection\", \"token\": \"$escaped_token\", \"session_id\": $WARP_SESSION_ID } }" +end + +# Reports that the real ctrl-r widget is actually about to run for this handoff. Sent before +# invoking it, so Warp has positive proof the wrapper is alive and can stop bounding the +# handoff by a fixed inactivity timeout -- unlike inferring liveness from pty output, which +# this token's own bracketed-paste echo would otherwise produce even when nothing is bound to +# the private key sequence at all. +function __warp_report_raw_keypress_ctrl_r_started # token + set -l escaped_token (warp_escape_json "$argv[1]") + warp_send_json_message "{ \"hook\": \"ExternalCtrlRRawKeypressStarted\", \"value\": { \"token\": \"$escaped_token\", \"session_id\": $WARP_SESSION_ID } }" +end + +# Fallback binding target for a mode with no real ctrl-r widget to re-invoke: the pasted token +# is sitting alone in the command line (nothing else runs), so report it immediately with an +# empty selection. +function __warp_report_raw_keypress_ctrl_r_selection_immediate + set -l token (commandline) + # Warp owns the command from here; the shell's own buffer must not keep a copy, or the + # next Enter would submit it twice. + commandline '' + commandline -f repaint + __warp_report_raw_keypress_ctrl_r_selection "$token" '' +end + +set -g __warp_raw_keypress_ctrl_r_all_modes_safe 1 + +if __warp_raw_keypress_ctrl_r_keyseq_free default + set -g __warp_raw_keypress_orig_ctrl_r_default (__warp_raw_keypress_ctrl_r_widget default) + if test -n "$__warp_raw_keypress_orig_ctrl_r_default" + # The token is captured and the command line cleared *before* the real widget runs: it + # holds the pasted token at that point, and after the widget runs it holds the selection + # instead. + function __warp_run_raw_keypress_ctrl_r_widget_default + set -l token (commandline) + commandline '' + __warp_report_raw_keypress_ctrl_r_started "$token" + eval $__warp_raw_keypress_orig_ctrl_r_default + set -l selection (commandline) + commandline '' + commandline -f repaint + __warp_report_raw_keypress_ctrl_r_selection "$token" "$selection" + end + bind -M default \x1b\x5d __warp_run_raw_keypress_ctrl_r_widget_default + else + bind -M default \x1b\x5d __warp_report_raw_keypress_ctrl_r_selection_immediate + end +else + set -g __warp_raw_keypress_ctrl_r_all_modes_safe 0 +end + +# The "insert" mode only exists once `fish_vi_key_bindings` has been loaded. +if bind -M insert > /dev/null 2>&1; and __warp_raw_keypress_ctrl_r_keyseq_free insert + set -g __warp_raw_keypress_orig_ctrl_r_insert (__warp_raw_keypress_ctrl_r_widget insert) + if test -n "$__warp_raw_keypress_orig_ctrl_r_insert" + function __warp_run_raw_keypress_ctrl_r_widget_insert + set -l token (commandline) + commandline '' + __warp_report_raw_keypress_ctrl_r_started "$token" + eval $__warp_raw_keypress_orig_ctrl_r_insert + set -l selection (commandline) + commandline '' + commandline -f repaint + __warp_report_raw_keypress_ctrl_r_selection "$token" "$selection" + end + bind -M insert \x1b\x5d __warp_run_raw_keypress_ctrl_r_widget_insert + else + bind -M insert \x1b\x5d __warp_report_raw_keypress_ctrl_r_selection_immediate + end +else if bind -M insert > /dev/null 2>&1 + set -g __warp_raw_keypress_ctrl_r_all_modes_safe 0 +end + +if test "$__warp_raw_keypress_ctrl_r_all_modes_safe" = 1 + set -a shell_plugins external_ctrl_r_raw_keypress +end + function warp_bootstrapped set -l histfile_directory set histfile_directory "$XDG_DATA_HOME" @@ -546,7 +673,8 @@ 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_shell_plugins (warp_escape_json $shell_plugins) + 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/tests/raw_keypress_ctrl_r_matrix.py b/app/assets/bundled/bootstrap/tests/raw_keypress_ctrl_r_matrix.py new file mode 100755 index 00000000000..9cd63750117 --- /dev/null +++ b/app/assets/bundled/bootstrap/tests/raw_keypress_ctrl_r_matrix.py @@ -0,0 +1,756 @@ +#!/usr/bin/env python3 +"""Shell-side test matrix for the raw-keypress ctrl-r handoff prototype +(CORE-3807, an alternative to PR #15513's foreground-command handoff). + +Drives real, unmodified bash/zsh/fish interactively on a PTY and sources the +actual bundled bootstrap scripts (with `#include` expanded exactly as they +are when Warp injects them into a real session), then exercises the parts of +the wrapper-widget installation and completion-token protocol that a plain +unit test can't reach because they depend on real shell keybinding state: + + - keymap/mode classification: a real third-party ctrl-r widget must be + recognized (installing a re-invoking wrapper), while a shell builtin + default must not (installing the empty-completion fallback instead). + This is what regressed for zsh's stock `redisplay` (viins) and `redo` + (vicmd) defaults before `$widgets[...]` classification replaced a + hardcoded name list. + - the occupied-Alt-] case: a pre-existing user binding on the private key + sequence must be left untouched -- no wrapper, no fallback -- and the + session-wide capability tag must be withheld entirely, since a single + tag can't tell Warp which keymap/mode is actually active. + - the fallback path: a keymap/mode with no real widget to re-invoke must + report the pasted token back immediately, with an empty selection. + - the full completion-token round trip through a real wrapper: the token + Warp pastes must be echoed back unchanged, and the reported selection + must be exactly what the wrapped widget produced, with the started hook + observed first. + +Requires: bash, zsh, fish, and python3 on PATH. No third-party dependencies. + +Usage: python3 raw_keypress_ctrl_r_matrix.py +Exits 0 if every case passes, 1 otherwise (with a PASS/FAIL line per case). +""" +import json +import os +import pty +import re +import select +import shutil +import sys +import tempfile +import time +from pathlib import Path + +BOOTSTRAP_DIR = Path(__file__).resolve().parent.parent + +BRACKETED_PASTE_PREFIX = b"\x1b[200~" +BRACKETED_PASTE_SUFFIX = b"\x1b[201~" +# Mirrors RAW_KEYPRESS_CTRL_R_HANDOFF_KEYSEQ in app/src/terminal/view.rs (Alt-]). +HANDOFF_KEYSEQ = b"\x1b]" +PLUGIN_TAG = "external_ctrl_r_raw_keypress" +HOOK_NAME = "ExternalCtrlRRawKeypressSelection" +STARTED_HOOK_NAME = "ExternalCtrlRRawKeypressStarted" +BOOTSTRAPPED_HOOK_NAME = "Bootstrapped" +ALL_HOOK_NAMES = {HOOK_NAME, STARTED_HOOK_NAME, BOOTSTRAPPED_HOOK_NAME} + +DCS_HOOK_RE = re.compile(rb"\x1bP\$d([0-9a-fA-F]+)\x1b\\") + +results = [] + + +def record(name, ok, detail=""): + results.append((name, ok, detail)) + status = "PASS" if ok else "FAIL" + line = f" {status} {name}" + if detail and not ok: + line += f" -- {detail}" + print(line) + + +def assemble_bootstrap(shell): + """Returns the fully-interpolated bootstrap script for `shell`, i.e. the + outer .sh with its `#include bundled/bootstrap/_body.sh` + directive replaced by the real body script's contents. Fish has no + separate body file.""" + if shell == "fish": + return (BOOTSTRAP_DIR / "fish.sh").read_text() + outer = (BOOTSTRAP_DIR / f"{shell}.sh").read_text() + body = (BOOTSTRAP_DIR / f"{shell}_body.sh").read_text() + lines = [] + for line in outer.split("\n"): + if line.strip().startswith("#include "): + lines.append(body) + else: + lines.append(line) + return "\n".join(lines) + + +class PtySession: + """A real interactive shell running on its own PTY.""" + + def __init__(self, argv, env): + self.pid, self.fd = pty.fork() + if self.pid == 0: + os.execvpe(argv[0], argv, env) + os._exit(1) + self.buf = b"" + + def drain(self, seconds): + end = time.time() + seconds + while time.time() < end: + r, _, _ = select.select([self.fd], [], [], max(0, end - time.time())) + if not r: + continue + try: + chunk = os.read(self.fd, 65536) + except OSError: + return + if not chunk: + return + self.buf += chunk + + def send(self, data, wait=1.0): + if isinstance(data, str): + data = data.encode() + os.write(self.fd, data) + self.drain(wait) + + def all_hooks(self): + """Every recognized hook seen so far, as (name, value) tuples in the order + they appear in the pty stream.""" + out = [] + for m in DCS_HOOK_RE.finditer(self.buf): + try: + raw = bytes.fromhex(m.group(1).decode()) + payload = json.loads(raw) + except (ValueError, UnicodeDecodeError): + continue + name = payload.get("hook") + if name in ALL_HOOK_NAMES: + out.append((name, payload.get("value"))) + return out + + def hooks(self): + """Every ExternalCtrlRRawKeypressSelection hook payload seen so far, decoded.""" + return [v for name, v in self.all_hooks() if name == HOOK_NAME] + + def started_hooks(self): + """Every ExternalCtrlRRawKeypressStarted hook payload seen so far, decoded.""" + return [v for name, v in self.all_hooks() if name == STARTED_HOOK_NAME] + + def bootstrapped_shell_plugins(self): + """The `shell_plugins` tags from the session's Bootstrapped hook, as a list.""" + for name, v in self.all_hooks(): + if name == BOOTSTRAPPED_HOOK_NAME: + return [tag for tag in (v or {}).get("shell_plugins", "").split("\n") if tag] + return [] + + def close(self): + try: + os.kill(self.pid, 9) + except ProcessLookupError: + pass + + +def send_handoff(session, token, wait=1.5): + """Writes the exact bytes TerminalView::raw_keypress_ctrl_r_handoff_payload + writes to the pty: the token wrapped in bracketed-paste markers, followed + by the private key sequence.""" + session.send( + BRACKETED_PASTE_PREFIX + token.encode() + BRACKETED_PASTE_SUFFIX + HANDOFF_KEYSEQ, + wait=wait, + ) + + +def check_started_then_selection(session, token, label): + """Asserts the started hook for `token` was observed exactly once, before the + selection hook for the same token.""" + seq = [name for name, v in session.all_hooks() if (v or {}).get("token") == token] + ok = seq == [STARTED_HOOK_NAME, HOOK_NAME] + record( + f"{label}: started hook observed before selection (token={token})", + ok, + detail=str(seq) if not ok else "", + ) + + +def check_no_started_hook(session, label): + """Asserts the fallback (immediate-report) path never emits a started hook.""" + record(f"{label}: fallback path does not emit a started hook", len(session.started_hooks()) == 0) + + +def check_plugin_tag(session, expected_present, label): + """Asserts whether the session-wide capability tag is present in the Bootstrapped hook.""" + tags = session.bootstrapped_shell_plugins() + present = PLUGIN_TAG in tags + ok = present == expected_present + verb = "advertised" if expected_present else "withheld" + record(f"{label}: capability tag {verb}", ok, detail=str(tags) if not ok else "") + + +def check_no_hooks_for_token(session, token, label): + """Asserts no ExternalCtrlRRawKeypress* hook was ever emitted for `token` -- e.g. because the + private key sequence invoked an unrelated pre-existing user binding instead of our wrapper or + fallback in the active keymap.""" + hooks_for_token = [name for name, v in session.all_hooks() if (v or {}).get("token") == token] + record( + f"{label}: occupied keymap ignores the handoff sequence (token={token})", + len(hooks_for_token) == 0, + detail=str(hooks_for_token) if hooks_for_token else "", + ) + + +def base_env(home, session_id): + env = dict(os.environ) + env["HOME"] = str(home) + env["WARP_SESSION_ID"] = str(session_id) + env["WARP_IS_LOCAL_SHELL_SESSION"] = "1" + env["WARP_USING_WINDOWS_CON_PTY"] = "false" + env["WARP_HONOR_PS1"] = "0" + env["TERM"] = "xterm-256color" + return env + + +def write_bootstrap_file(tmpdir, shell): + path = Path(tmpdir) / f"real_{shell}_bootstrap.sh" + path.write_text(assemble_bootstrap(shell)) + return path + + +# --------------------------------------------------------------------------- +# bash +# --------------------------------------------------------------------------- + + +def run_bash_case(name, tmpdir, rc_extra, check): + home = Path(tmpdir) / f"bash-home-{name}" + home.mkdir(parents=True, exist_ok=True) + (home / ".bashrc").write_text(f""" +export PATH={os.environ.get('PATH', '')} +PS1='BASHPROMPT> ' +{rc_extra} +""") + bootstrap_path = write_bootstrap_file(tmpdir, "bash") + env = base_env(home, 100000) + env["WARP_IN_MSYS2"] = "false" + # Production Warp starts bash with a minimal --rcfile (never the user's own + # ~/.bashrc) and injects the bootstrap script separately; using --rcfile here + # to load our synthetic rc (simulating a shell where fzf/atuin/etc. have + # already installed a ctrl-r binding) reproduces that same starting state. + session = PtySession(["/usr/bin/bash", "--rcfile", str(home / ".bashrc"), "-i"], env) + try: + session.drain(1.0) + session.send(f"source {bootstrap_path}\n", wait=1.5) + check(session) + finally: + session.close() + + +def test_bash(): + with tempfile.TemporaryDirectory() as tmpdir: + # A real third-party widget (as fzf/atuin install via `bind -x`) in the emacs + # keymap must be classified and wrapped, and the full token/selection round + # trip through it must work. + def check_real_widget(session): + # A typed command is echoed into session.buf as you type it, whether or not it goes + # on to execute -- so "... && echo MARKER" followed by checking MARKER in session.buf + # can never fail (the marker text is already there from being typed). Redirecting the + # actual listing to a file and checking the file's content is immune to that, matching + # the pattern the occupied-keymap checks already use below. + session.send( + "bind -m emacs -X > /tmp/_bash_real_widget_emacs.txt 2>&1\n", + wait=1.0, + ) + emacs_binds = Path("/tmp/_bash_real_widget_emacs.txt").read_text() + wrapped = "__warp_run_raw_keypress_ctrl_r_widget_emacs" in emacs_binds + record( + "bash: real widget classified and wrapped (emacs)", + wrapped, + detail=f"emacs binds: {emacs_binds!r}" if not wrapped else "", + ) + + send_handoff(session, "111") + hooks = session.hooks() + ok = len(hooks) == 1 and hooks[0].get("token") == "111" and hooks[0].get( + "buffer" + ) == "echo real-widget-selection" + record( + "bash: real widget round trip (emacs)", + ok, + detail=str(hooks) if not ok else "", + ) + check_started_then_selection(session, "111", "bash") + check_plugin_tag(session, True, "bash") + + run_bash_case( + "real-widget", + tmpdir, + 'my_ctrl_r_widget() { READLINE_LINE="echo real-widget-selection"; READLINE_POINT=${#READLINE_LINE}; }\n' + 'bind -m emacs -x \'"\\C-r": my_ctrl_r_widget\'\n', + check_real_widget, + ) + + # vi-command has no `bind -x` ctrl-r binding by default (bash's own + # reverse-search-history there isn't a `-x` binding), so classification must + # fail and the fallback must be installed, reporting the token immediately + # with an empty selection. + def check_fallback(session): + session.send( + "bind -m vi-command -X > /tmp/_bash_fallback_vicmd.txt 2>&1\n", + wait=1.0, + ) + vicmd_binds = Path("/tmp/_bash_fallback_vicmd.txt").read_text() + wrapped = '"\\e]"' in vicmd_binds + record( + "bash: vi-command keyseq claimed (fallback installed)", + wrapped, + detail=f"vi-command binds: {vicmd_binds!r}" if not wrapped else "", + ) + + session.send("set -o vi\n", wait=0.5) + send_handoff(session, "222") + hooks = session.hooks() + ok = len(hooks) == 1 and hooks[0].get("token") == "222" and hooks[0].get("buffer") == "" + record( + "bash: fallback path reports token with empty selection (vi-command)", + ok, + detail=str(hooks) if not ok else "", + ) + check_no_started_hook(session, "bash") + check_plugin_tag(session, True, "bash") + + run_bash_case("fallback", tmpdir, "", check_fallback) + + # A pre-existing user binding on Alt-] in vi-insert must be left untouched. + def check_occupied(session): + session.send( + "bind -m vi-insert -X > /tmp/_bash_occ_vi_insert.txt 2>&1\n" + "bind -m emacs -X > /tmp/_bash_occ_emacs.txt 2>&1\n", + wait=1.0, + ) + vi_insert = Path("/tmp/_bash_occ_vi_insert.txt").read_text() + emacs = Path("/tmp/_bash_occ_emacs.txt").read_text() + preserved = "my_custom_alt_bracket" in vi_insert + not_wrapped = ( + "__warp_run_raw_keypress_ctrl_r_widget" not in vi_insert + and "__warp_report_raw_keypress_ctrl_r_selection" not in vi_insert + ) + record( + "bash: occupied Alt-] left untouched (vi-insert)", + preserved and not_wrapped, + detail=f"vi-insert binds: {vi_insert!r}" if not (preserved and not_wrapped) else "", + ) + # An unrelated keymap (emacs) must still get the fallback/wrapper. + claimed = "__warp_run_raw_keypress_ctrl_r_widget" in emacs or ( + "__warp_report_raw_keypress_ctrl_r_selection" in emacs + ) + record("bash: unaffected keymap still claims Alt-] (emacs)", claimed) + + # Since one keymap is occupied, the session-wide capability tag must be withheld + # entirely -- otherwise Warp could send the private sequence into vi-insert while it's + # active, invoking the user's unrelated binding there instead of a ctrl-r wrapper. + check_plugin_tag(session, False, "bash") + + # Actually attempt a handoff while vi-insert (the occupied keymap) is active: the + # private sequence must reach the user's own binding, not report anything to Warp. + session.send("set -o vi\n", wait=0.5) + send_handoff(session, "777") + check_no_hooks_for_token(session, "777", "bash") + + run_bash_case( + "occupied", + tmpdir, + 'my_custom_alt_bracket() { READLINE_LINE="CUSTOM"; }\n' + 'bind -m vi-insert -x \'"\\e]": my_custom_alt_bracket\'\n', + check_occupied, + ) + + +# --------------------------------------------------------------------------- +# zsh +# --------------------------------------------------------------------------- + + +def run_zsh_case(name, tmpdir, rc_extra, check): + home = Path(tmpdir) / f"zsh-home-{name}" + home.mkdir(parents=True, exist_ok=True) + (home / ".zshrc").write_text(f""" +export PATH={os.environ.get('PATH', '')} +PROMPT='ZSHPROMPT> ' +HISTFILE=$HOME/.zsh_history +{rc_extra} +""") + bootstrap_path = write_bootstrap_file(tmpdir, "zsh") + env = base_env(home, 200000) + env["ZDOTDIR"] = str(home) + session = PtySession(["/usr/bin/zsh", "-i"], env) + try: + session.drain(1.2) + session.send(f"source {bootstrap_path}\n", wait=1.5) + check(session) + finally: + session.close() + + +def test_zsh(): + with tempfile.TemporaryDirectory() as tmpdir: + # A widget genuinely registered via `zle -N` (exactly how fzf/atuin/etc. + # install themselves) must be classified as real and wrapped. + def check_real_widget(session): + # File-redirected rather than checked against session.buf -- see the bash equivalent + # above for why. + session.send( + "bindkey -M emacs '\\e]' > /tmp/_zsh_real_widget_emacs.txt 2>&1\n", + wait=1.0, + ) + emacs_binds = Path("/tmp/_zsh_real_widget_emacs.txt").read_text() + wrapped = "__warp_run_raw_keypress_ctrl_r_widget_emacs" in emacs_binds + record( + "zsh: user-registered widget classified and wrapped (emacs)", + wrapped, + detail=f"emacs binds: {emacs_binds!r}" if not wrapped else "", + ) + + send_handoff(session, "333") + hooks = session.hooks() + ok = len(hooks) == 1 and hooks[0].get("token") == "333" and hooks[0].get( + "buffer" + ) == "echo real-widget-selection" + record( + "zsh: real widget round trip (emacs)", + ok, + detail=str(hooks) if not ok else "", + ) + check_started_then_selection(session, "333", "zsh") + check_plugin_tag(session, True, "zsh") + + run_zsh_case( + "real-widget", + tmpdir, + "my_ctrl_r_widget() { BUFFER='echo real-widget-selection'; CURSOR=${#BUFFER}; }\n" + "zle -N my_ctrl_r_widget\n" + "bindkey -M emacs '^R' my_ctrl_r_widget\n", + check_real_widget, + ) + + # Regression coverage for the fix under test: zsh's own stock ctrl-r + # defaults -- `redisplay` in viins, `redo` in vicmd -- are builtins, not + # `zle -N`-registered widgets, and must not be classified as a real + # history tool to hand off to (they used to be, before classification + # switched from a hardcoded name list to `$widgets[...]`). + def check_builtin_defaults_rejected(session): + session.send( + "print -r -- \"widgets[redisplay]=$widgets[redisplay]\"\n" + "print -r -- \"widgets[redo]=$widgets[redo]\"\n", + wait=0.5, + ) + builtin_classification = b"widgets[redisplay]=builtin" in session.buf and ( + b"widgets[redo]=builtin" in session.buf + ) + record( + "zsh: stock ctrl-r defaults classify as builtin, not user widgets", + builtin_classification, + ) + + session.send( + "bindkey -M viins '\\e]' > /tmp/_zsh_viins_default.txt 2>&1\n", + wait=1.0, + ) + viins_binds = Path("/tmp/_zsh_viins_default.txt").read_text() + not_wrapped = "__warp_run_raw_keypress_ctrl_r_widget_viins" not in viins_binds + record( + "zsh: viins default (redisplay) does not get a real wrapper", + not_wrapped, + detail=f"viins binds: {viins_binds!r}" if not not_wrapped else "", + ) + + send_handoff(session, "444") + hooks = session.hooks() + ok = len(hooks) == 1 and hooks[0].get("token") == "444" and hooks[0].get("buffer") == "" + record( + "zsh: fallback path reports token with empty selection (viins default)", + ok, + detail=str(hooks) if not ok else "", + ) + check_no_started_hook(session, "zsh") + check_plugin_tag(session, True, "zsh") + + run_zsh_case("builtin-defaults", tmpdir, "", check_builtin_defaults_rejected) + + # A pre-existing user binding on Alt-] in emacs must be left untouched. + def check_occupied(session): + session.send( + "bindkey -M emacs '\\e]' > /tmp/_zsh_occ_emacs.txt 2>&1\n" + "bindkey -M viins '\\e]' > /tmp/_zsh_occ_viins.txt 2>&1\n", + wait=1.0, + ) + emacs = Path("/tmp/_zsh_occ_emacs.txt").read_text() + viins = Path("/tmp/_zsh_occ_viins.txt").read_text() + preserved = "my_custom_alt_bracket" in emacs + record( + "zsh: occupied Alt-] left untouched (emacs)", + preserved, + detail=f"emacs binding: {emacs!r}" if not preserved else "", + ) + claimed = ( + "__warp_run_raw_keypress_ctrl_r_widget_viins" in viins + or "__warp_report_raw_keypress_ctrl_r_selection_immediate" in viins + ) + record("zsh: unaffected keymap still claims Alt-] (viins)", claimed) + + # Since one keymap is occupied, the session-wide capability tag must be withheld + # entirely -- otherwise Warp could send the private sequence into emacs while it's + # active, invoking the user's unrelated binding there instead of a ctrl-r wrapper. + check_plugin_tag(session, False, "zsh") + + # Actually attempt a handoff while emacs (the occupied keymap, and zsh's default) is + # active: the private sequence must reach the user's own binding, not report anything + # to Warp. + send_handoff(session, "888") + check_no_hooks_for_token(session, "888", "zsh") + + run_zsh_case( + "occupied", + tmpdir, + "my_custom_alt_bracket() { BUFFER='CUSTOM' }\n" + "zle -N my_custom_alt_bracket\n" + "bindkey -M emacs '\\e]' my_custom_alt_bracket\n", + check_occupied, + ) + + +# --------------------------------------------------------------------------- +# fish +# --------------------------------------------------------------------------- + + +def run_fish_case(name, tmpdir, config_extra, check): + home = Path(tmpdir) / f"fish-home-{name}" + (home / ".config" / "fish").mkdir(parents=True, exist_ok=True) + (home / ".local" / "share" / "fish").mkdir(parents=True, exist_ok=True) + (home / ".config" / "fish" / "config.fish").write_text(f""" +set -gx PATH {os.environ.get('PATH', '')} +function fish_prompt; echo -n 'FISHPROMPT> '; end +function fish_greeting; end +{config_extra} +""") + bootstrap_path = write_bootstrap_file(tmpdir, "fish") + env = base_env(home, 300000) + session = PtySession(["/usr/bin/fish", "-i"], env) + try: + session.drain(1.5) + session.send(f"source {bootstrap_path}\n", wait=1.5) + check(session) + finally: + session.close() + + +def test_fish(): + with tempfile.TemporaryDirectory() as tmpdir: + # A real user-installed ctrl-r binding (not a `bind --preset` default) in + # default mode must be classified and wrapped. + def check_real_widget(session): + # File-redirected rather than checked against session.buf -- see the bash equivalent + # above for why. + session.send( + "bind -M default \\x1b\\x5d > /tmp/_fish_real_widget_default.txt 2>&1\n", + wait=1.0, + ) + default_binds = Path("/tmp/_fish_real_widget_default.txt").read_text() + wrapped = "__warp_run_raw_keypress_ctrl_r_widget_default" in default_binds + record( + "fish: user-installed widget classified and wrapped (default)", + wrapped, + detail=f"default binds: {default_binds!r}" if not wrapped else "", + ) + + send_handoff(session, "555") + hooks = session.hooks() + ok = len(hooks) == 1 and hooks[0].get("token") == "555" and hooks[0].get( + "buffer" + ) == "echo real-widget-selection" + record( + "fish: real widget round trip (default)", + ok, + detail=str(hooks) if not ok else "", + ) + check_started_then_selection(session, "555", "fish") + check_plugin_tag(session, True, "fish") + + run_fish_case( + "real-widget", + tmpdir, + "function my_ctrl_r_widget\n" + " commandline -r 'echo real-widget-selection'\n" + "end\n" + "bind \\cr my_ctrl_r_widget\n", + check_real_widget, + ) + + # With no rebinding, default mode's ctrl-r is fish's own preset history + # search, which must not be classified as a real tool to hand off to. + def check_fallback(session): + session.send( + "bind -M default \\x1b\\x5d > /tmp/_fish_fallback_default.txt 2>&1\n", + wait=1.0, + ) + default_binds = Path("/tmp/_fish_fallback_default.txt").read_text() + not_wrapped = "__warp_run_raw_keypress_ctrl_r_widget_default" not in default_binds + record( + "fish: preset ctrl-r default does not get a real wrapper", + not_wrapped, + detail=f"default binds: {default_binds!r}" if not not_wrapped else "", + ) + + send_handoff(session, "666") + hooks = session.hooks() + ok = len(hooks) == 1 and hooks[0].get("token") == "666" and hooks[0].get("buffer") == "" + record( + "fish: fallback path reports token with empty selection (default)", + ok, + detail=str(hooks) if not ok else "", + ) + check_no_started_hook(session, "fish") + check_plugin_tag(session, True, "fish") + + run_fish_case("fallback", tmpdir, "", check_fallback) + + # A pre-existing user binding on Alt-] in default mode must be untouched. + def check_occupied(session): + session.send( + "bind -M default \\x1b\\x5d > /tmp/_fish_occ_default.txt 2>&1\n", + wait=1.0, + ) + default_binds = Path("/tmp/_fish_occ_default.txt").read_text() + preserved = "my_custom_alt_bracket" in default_binds + record( + "fish: occupied Alt-] left untouched (default)", + preserved, + detail=f"default binds: {default_binds!r}" if not preserved else "", + ) + + # Since default mode is occupied, the session-wide capability tag must be withheld + # entirely -- otherwise Warp could send the private sequence into default mode while + # it's active, invoking the user's unrelated binding there instead of a ctrl-r wrapper. + check_plugin_tag(session, False, "fish") + + # Actually attempt a handoff in default mode (occupied, and fish's starting mode): the + # private sequence must reach the user's own binding, not report anything to Warp. + send_handoff(session, "999") + check_no_hooks_for_token(session, "999", "fish") + + run_fish_case( + "occupied", + tmpdir, + "function my_custom_alt_bracket\n" + " commandline -r 'CUSTOM'\n" + "end\n" + "bind \\x1b\\x5d my_custom_alt_bracket\n", + check_occupied, + ) + + # A pre-existing user binding on Alt-] in vi insert mode must also be left + # untouched, and must also withhold the session-wide tag. + def check_occupied_insert(session): + session.send( + "bind -M insert \\x1b\\x5d > /tmp/_fish_occ_insert.txt 2>&1\n", + wait=1.0, + ) + insert_binds = Path("/tmp/_fish_occ_insert.txt").read_text() + preserved = "my_custom_alt_bracket" in insert_binds + record( + "fish: occupied Alt-] left untouched (vi insert)", + preserved, + detail=f"insert binds: {insert_binds!r}" if not preserved else "", + ) + + # default mode is left free here, so it must still have claimed Alt-] despite insert + # being occupied -- that is what makes this the *partial* collision the tag gate exists + # for, rather than a case where every mode happens to be occupied. Read from a file + # rather than session.buf, for the reason given in the bash equivalent above. + session.send( + "bind -M default \\x1b\\x5d > /tmp/_fish_occ_insert_default.txt 2>&1\n", + wait=1.0, + ) + default_binds = Path("/tmp/_fish_occ_insert_default.txt").read_text() + claimed = ( + "__warp_run_raw_keypress_ctrl_r_widget_default" in default_binds + or "__warp_report_raw_keypress_ctrl_r_selection_immediate" in default_binds + ) + record( + "fish: unoccupied mode still claims Alt-] (default)", + claimed, + detail=f"default binds: {default_binds!r}" if not claimed else "", + ) + + check_plugin_tag(session, False, "fish (vi insert occupied)") + + # Drive a real handoff in the free mode: the fallback must still report the token, + # proving default mode keeps working rather than merely looking bound. + send_handoff(session, "2020") + hooks = session.hooks() + default_hooks = [h for h in hooks if h.get("token") == "2020"] + ok = len(default_hooks) == 1 and default_hooks[0].get("buffer") == "" + record( + "fish: unoccupied mode (default) still completes a handoff while insert is occupied", + ok, + detail=str(default_hooks) if not ok else "", + ) + + # ESC enters default mode, then 'i' switches to insert for the handoff. + session.send("\x1b", wait=0.3) + session.send("i", wait=0.3) + send_handoff(session, "1010") + check_no_hooks_for_token(session, "1010", "fish (vi insert occupied)") + + run_fish_case( + "occupied-insert", + tmpdir, + "function my_custom_alt_bracket\n" + " commandline -r 'CUSTOM'\n" + "end\n" + "fish_vi_key_bindings\n" + "bind -M insert \\x1b\\x5d my_custom_alt_bracket\n", + check_occupied_insert, + ) + + +def test_zsh_static_checks(): + # A live PTY run only exercises whatever zsh is installed here, which won't reproduce + # this: on zsh <5.1 (e.g. 5.0.2, Ubuntu 14.04's stock build -- still the SSH test VM's + # actual version), an unquoted `local x=$(cmd)` word-splits `cmd`'s output before `local` + # sees it. bindkey's "unbound key" output is two words (`"^[]" undefined-key`), and + # `local` then tries to declare a second local named `undefined-key` -- not a valid + # identifier -- aborting the rest of bootstrap on every zsh session, since Alt-] starts + # out unbound. Guard statically against the unquoted form regressing, since no zsh + # available in this matrix reproduces the failure at runtime. + body = (BOOTSTRAP_DIR / "zsh_body.sh").read_text() + quoted = 'local result="$(bindkey -M "$1" \'\\e]\' 2>/dev/null)"' in body + record( + "zsh: Alt-] free-check command substitution stays quoted (old-zsh word-split guard)", + quoted, + ) + + +def main(): + for shell in ("bash", "zsh", "fish"): + if not shutil.which(shell): + print(f"SKIP: {shell} not found on PATH") + return 1 + + print("== bash ==") + test_bash() + print("== zsh ==") + test_zsh() + test_zsh_static_checks() + print("== fish ==") + test_fish() + + passed = sum(1 for _, ok, _ in results if ok) + failed = len(results) - passed + print(f"\npassed={passed} failed={failed}") + return 0 if failed == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/app/assets/bundled/bootstrap/tests/run-matrix.sh b/app/assets/bundled/bootstrap/tests/run-matrix.sh new file mode 100755 index 00000000000..e9e8760b5db --- /dev/null +++ b/app/assets/bundled/bootstrap/tests/run-matrix.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Runs the raw-keypress ctrl-r handoff shell-side test matrix (CORE-3807). +# See raw_keypress_ctrl_r_matrix.py for what it covers. +set -uo pipefail +HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +exec python3 "$HERE/raw_keypress_ctrl_r_matrix.py" "$@" diff --git a/app/assets/bundled/bootstrap/zsh_body.sh b/app/assets/bundled/bootstrap/zsh_body.sh index 75406b2cef3..4530115eb07 100644 --- a/app/assets/bundled/bootstrap/zsh_body.sh +++ b/app/assets/bundled/bootstrap/zsh_body.sh @@ -1316,6 +1316,157 @@ esac shell_plugins+=(vi) fi + # Prototype (alternative to the foreground-command handoff in PR #15513): whatever ^R resolves + # to in each of zsh's three keymaps (emacs, viins, vicmd) after the user's rc has run -- + # `bindkey -M '^R'` prints `"^R" widget-name` -- install a wrapper in that same keymap + # on the private key sequence Alt-] that re-invokes that keymap's widget via `zle`, giving it a + # genuine zle context (zle builtins like `vi-fetch-history` only work when a widget is actually + # bound to a key and invoked through zle, not when called as a plain function). Warp writes + # that sequence to the pty instead of a bare ^R when this session reports the + # `external_ctrl_r_raw_keypress` tag below. + # + # Warp cannot tell which keymap is active when ctrl-r is pressed, so `shell_plugins` cannot + # vary per keymap -- it is one session-wide flag. The tag is therefore only set once every + # keymap has safely claimed Alt-] (with either a real wrapper or an empty-completion fallback); + # if any keymap already has a real binding there, we leave that keymap alone and withhold the + # tag entirely, since sending the private sequence into an occupied keymap would invoke the + # user's unrelated binding instead. + # + # `$widgets[]` is zsh's own classification of every widget: `builtin` for anything zsh + # ships (including its own ^R defaults, e.g. `redisplay` in viins and `redo` in vicmd), and + # `user:` only for a widget actually registered via `zle -N`, which is exactly how + # fzf, atuin, and every other third-party ctrl-r tool install themselves. Requiring that + # classification, rather than naming individual zsh defaults, correctly rejects all of zsh's + # own built-in ^R bindings. + __warp_classify_raw_keypress_ctrl_r_binding() { # keymap + local widget=${${(z)$(bindkey -M "$1" '^R' 2>/dev/null)}[2]} + [[ "${widgets[$widget]:-}" == user:* ]] || return 1 + printf '%s' "$widget" + } + + # Returns success if Alt-] is not already bound to anything in the given keymap, i.e. it's + # safe for us to claim it there. An unbound key still prints a line (ending in + # "undefined-key"), not empty output, so we must check for that explicitly rather than + # treating any output as "bound". + # The command substitution must stay quoted: on zsh <5.1 (e.g. 5.0.2, Ubuntu 14.04's stock + # build), `local var=$(cmd)` -- unlike plain `var=$(cmd)` -- word-splits the substituted value + # and declares each resulting word, so bindkey's own `"^[]" undefined-key` output makes `local` + # attempt to declare `undefined-key`. That is not a valid identifier, so the whole sourced + # script aborts before it reaches `warp_bootstrapped`. + __warp_raw_keypress_ctrl_r_keyseq_free() { # keymap + local result="$(bindkey -M "$1" '\e]' 2>/dev/null)" + [[ -z "$result" || "$result" == *undefined-key* ]] + } + + # Reports the outcome of a raw-keypress ctrl-r handoff back to Warp. `token` is the decimal + # handoff id Warp pasted into the line buffer to start this handoff (see + # `raw_keypress_ctrl_r_handoff_payload` in view.rs); Warp echoes it back to the pending + # handoff it started, rejecting the report if they don't match. `selection` is the command + # text the user selected, or empty if they cancelled. + __warp_report_raw_keypress_ctrl_r_selection() { # token, selection + local escaped_token="$(warp_escape_json "$1")" + local escaped_selection="$(warp_escape_json "$2")" + warp_send_json_message "{ \"hook\": \"ExternalCtrlRRawKeypressSelection\", \"value\": { \"buffer\": \"$escaped_selection\", \"token\": \"$escaped_token\", \"session_id\": $WARP_SESSION_ID } }" + } + + # Reports that the real ctrl-r widget is actually about to run for this handoff. Sent before + # invoking it, so Warp has positive proof the wrapper is alive and can stop bounding the + # handoff by a fixed inactivity timeout -- unlike inferring liveness from pty output, which + # this token's own bracketed-paste echo would otherwise produce even when nothing is bound to + # the private key sequence at all. + __warp_report_raw_keypress_ctrl_r_started() { # token + local escaped_token="$(warp_escape_json "$1")" + warp_send_json_message "{ \"hook\": \"ExternalCtrlRRawKeypressStarted\", \"value\": { \"token\": \"$escaped_token\", \"session_id\": $WARP_SESSION_ID } }" + } + + # Fallback binding target for a keymap with no real ctrl-r widget to re-invoke: the pasted + # token is sitting alone in BUFFER (nothing else runs), so report it immediately with an + # empty selection. + __warp_report_raw_keypress_ctrl_r_selection_immediate() { + local token="$BUFFER" + # Warp owns the command from here; the shell's own buffer must not keep a copy, or the + # next Enter would submit it twice. + BUFFER='' + CURSOR=0 + zle reset-prompt + __warp_report_raw_keypress_ctrl_r_selection "$token" '' + } + zle -N __warp_report_raw_keypress_ctrl_r_selection_immediate + + # The token is captured and the buffer cleared *before* the real widget runs: BUFFER holds + # the pasted token at that point, and after the widget runs it holds the selection instead. + function __warp_run_raw_keypress_ctrl_r_widget_emacs () { + local token="$BUFFER" + BUFFER='' + CURSOR=0 + __warp_report_raw_keypress_ctrl_r_started "$token" + zle "$__warp_raw_keypress_orig_ctrl_r_widget_emacs" + local selection="$BUFFER" + BUFFER='' + CURSOR=0 + zle reset-prompt + __warp_report_raw_keypress_ctrl_r_selection "$token" "$selection" + } + function __warp_run_raw_keypress_ctrl_r_widget_viins () { + local token="$BUFFER" + BUFFER='' + CURSOR=0 + __warp_report_raw_keypress_ctrl_r_started "$token" + zle "$__warp_raw_keypress_orig_ctrl_r_widget_viins" + local selection="$BUFFER" + BUFFER='' + CURSOR=0 + zle reset-prompt + __warp_report_raw_keypress_ctrl_r_selection "$token" "$selection" + } + function __warp_run_raw_keypress_ctrl_r_widget_vicmd () { + local token="$BUFFER" + BUFFER='' + CURSOR=0 + __warp_report_raw_keypress_ctrl_r_started "$token" + zle "$__warp_raw_keypress_orig_ctrl_r_widget_vicmd" + local selection="$BUFFER" + BUFFER='' + CURSOR=0 + zle reset-prompt + __warp_report_raw_keypress_ctrl_r_selection "$token" "$selection" + } + + __warp_raw_keypress_ctrl_r_all_keymaps_safe=1 + if __warp_raw_keypress_ctrl_r_keyseq_free emacs; then + if __warp_raw_keypress_orig_ctrl_r_widget_emacs=$(__warp_classify_raw_keypress_ctrl_r_binding emacs); then + zle -N __warp_run_raw_keypress_ctrl_r_widget_emacs + bindkey -M emacs '\e]' __warp_run_raw_keypress_ctrl_r_widget_emacs + else + bindkey -M emacs '\e]' __warp_report_raw_keypress_ctrl_r_selection_immediate + fi + else + __warp_raw_keypress_ctrl_r_all_keymaps_safe=0 + fi + if __warp_raw_keypress_ctrl_r_keyseq_free viins; then + if __warp_raw_keypress_orig_ctrl_r_widget_viins=$(__warp_classify_raw_keypress_ctrl_r_binding viins); then + zle -N __warp_run_raw_keypress_ctrl_r_widget_viins + bindkey -M viins '\e]' __warp_run_raw_keypress_ctrl_r_widget_viins + else + bindkey -M viins '\e]' __warp_report_raw_keypress_ctrl_r_selection_immediate + fi + else + __warp_raw_keypress_ctrl_r_all_keymaps_safe=0 + fi + if __warp_raw_keypress_ctrl_r_keyseq_free vicmd; then + if __warp_raw_keypress_orig_ctrl_r_widget_vicmd=$(__warp_classify_raw_keypress_ctrl_r_binding vicmd); then + zle -N __warp_run_raw_keypress_ctrl_r_widget_vicmd + bindkey -M vicmd '\e]' __warp_run_raw_keypress_ctrl_r_widget_vicmd + else + bindkey -M vicmd '\e]' __warp_report_raw_keypress_ctrl_r_selection_immediate + fi + else + __warp_raw_keypress_ctrl_r_all_keymaps_safe=0 + fi + if [[ "$__warp_raw_keypress_ctrl_r_all_keymaps_safe" == 1 ]]; then + shell_plugins+=(external_ctrl_r_raw_keypress) + 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 8746391b1a3..ba77e38e64b 100644 --- a/app/src/features.rs +++ b/app/src/features.rs @@ -521,6 +521,8 @@ fn enabled_features() -> HashSet { FeatureFlag::TerminalLifecycleRecovery, #[cfg(feature = "ctrl_c_cancels_third_party_harness")] FeatureFlag::CtrlCCancelsThirdPartyHarness, + #[cfg(feature = "raw_keypress_ctrl_r_handoff")] + FeatureFlag::RawKeypressCtrlRHandoff, ]); flags diff --git a/app/src/terminal/event.rs b/app/src/terminal/event.rs index 353c9deb891..badfc4b254c 100644 --- a/app/src/terminal/event.rs +++ b/app/src/terminal/event.rs @@ -9,7 +9,9 @@ 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::{ + ExternalCtrlRRawKeypressSelectionValue, ExternalCtrlRRawKeypressStartedValue, FinishUpdateValue, +}; use super::model::block::BlockId; use super::model::lifecycle::LifecycleRecoveryRecord; use super::model::session::{SessionId, SessionInfo}; @@ -128,6 +130,15 @@ pub enum Event { /// Emitted when the assisted auto-update has completed and we're ready to /// relaunch the app. FinishUpdate(FinishUpdateValue), + /// Emitted when the shell reports the command selected in its raw-keypress ctrl-r handoff + /// wrapper widget (a prototype alternative to the foreground-command handoff in PR #15513). + ExternalCtrlRRawKeypressSelection(ExternalCtrlRRawKeypressSelectionValue), + /// Emitted the moment the raw-keypress ctrl-r handoff's wrapper widget is actually invoked, + /// before it runs the real widget (see [`ExternalCtrlRRawKeypressStartedValue`]). Positive + /// evidence the wrapper is alive, sent by the wrapper itself -- unlike inferring liveness + /// from pty output, which the handoff's own token-paste echo can produce even when nothing + /// is listening for the handoff at all. + ExternalCtrlRRawKeypressStarted(ExternalCtrlRRawKeypressStartedValue), TextSelectionChanged, ShellSpawned(ShellType), SendCompletionsPrompt, @@ -476,6 +487,22 @@ impl Debug for Event { ) } Event::FinishUpdate(data) => write!(f, "FinishUpdate({})", data.update_id), + Event::ExternalCtrlRRawKeypressSelection(data) => { + // The buffer is a selected shell command, which may carry a credential; log only + // its length rather than its contents. + write!( + f, + "ExternalCtrlRRawKeypressSelection(buffer_len: {})", + data.buffer.len() + ) + } + Event::ExternalCtrlRRawKeypressStarted(data) => { + write!( + f, + "ExternalCtrlRRawKeypressStarted(session_id: {:?})", + data.session_id + ) + } Event::TextSelectionChanged => write!(f, "TextSelectionChanged"), Event::ShellSpawned(shell_type) => write!(f, "ShellSpawned({shell_type:?})"), Event::SendCompletionsPrompt => write!(f, "SendCompletionsPrompt"), diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index c77a53fac0a..a905520f28b 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -7619,6 +7619,26 @@ impl Input { return false; } + // A raw-keypress ctrl-r handoff bail-out (see `TerminalView:: + // on_raw_keypress_ctrl_r_handoff_timeout`) arms this guard when it force-restores focus + // to the input editor: an Enter on an empty buffer immediately afterward is more likely + // a stray keystroke meant for the just-torn-down wrapper widget than a deliberate + // submission. The guard self-expires, so a later, genuinely empty Enter is unaffected. + if command.is_empty() + && self + .model + .lock() + .block_list_mut() + .active_block_mut() + .consume_raw_keypress_bailout_guard() + { + log::info!( + "Ignoring Enter on an empty input buffer immediately after a raw-keypress \ + ctrl-r handoff bail-out" + ); + return false; + } + // Save the zero state next command state before clearing it. let zerostate_next_command_suggestion_info = self .next_command_model diff --git a/app/src/terminal/input_tests.rs b/app/src/terminal/input_tests.rs index 9f07328953c..3f51297b610 100644 --- a/app/src/terminal/input_tests.rs +++ b/app/src/terminal/input_tests.rs @@ -2907,6 +2907,64 @@ fn test_histignorespace_support_in_zsh() { }); } +/// Regression test for the mechanism behind a real-world symptom: a wrapper widget (e.g. fzf) +/// left open past the raw-keypress ctrl-r handoff's inactivity bail-out, then "accepted" with +/// Enter, left a stray empty block behind because that Enter no longer reached the pty at all -- +/// it landed on the newly refocused, empty input editor and submitted it as a command. The +/// bail-out arms a short guard (see `Block::arm_raw_keypress_bailout_guard`) that this test +/// exercises directly, without needing a real handoff or timer. +#[test] +fn raw_keypress_bailout_guard_suppresses_stray_empty_enter() { + App::test((), |mut app| async move { + initialize_app(&mut app); + + let terminal = add_window_with_bootstrapped_terminal( + &mut app, None, /* history_file_commands */ + None, + ) + .await; + let input = terminal.read(&app, |view, _| view.input().clone()); + + let pty_writes: Rc>>> = Rc::new(RefCell::new(Vec::new())); + let writes = pty_writes.clone(); + app.update(|ctx| { + ctx.subscribe_to_view(&terminal, move |_, event, _| { + if let crate::terminal::view::Event::WriteBytesToPty { bytes } = event { + writes.borrow_mut().push(bytes.to_vec()); + } + }); + }); + + terminal.update(&mut app, |view, _ctx| { + view.model + .lock() + .block_list_mut() + .active_block_mut() + .arm_raw_keypress_bailout_guard(); + }); + + let executed = input.update(&mut app, |input, ctx| input.try_execute_command("", ctx)); + assert!( + !executed, + "an empty Enter within the raw-keypress bail-out guard window must not execute" + ); + assert!( + pty_writes.borrow().is_empty(), + "a suppressed empty Enter must not write to the pty, got {:?}", + pty_writes.borrow() + ); + + // The guard is one-shot: a later, unguarded empty Enter proceeds normally, exactly as + // it would have before the handoff ever started. + let executed_again = + input.update(&mut app, |input, ctx| input.try_execute_command("", ctx)); + assert!( + executed_again, + "a later, unguarded empty Enter must execute normally" + ); + }); +} + fn build_suggestion_results>( suggestions: Vec, replacement_span: S, diff --git a/app/src/terminal/model/block.rs b/app/src/terminal/model/block.rs index 8f5781ebc2f..1a26344967c 100644 --- a/app/src/terminal/model/block.rs +++ b/app/src/terminal/model/block.rs @@ -71,6 +71,12 @@ use crate::terminal::{BlockPadding, ShellHost, SizeInfo}; pub const LONG_RUNNING_COMMAND_DURATION_MS: u64 = 50; pub const LONG_RUNNING_BOTTOM_PADDING_LINES: f32 = 0.2; +/// How long after a raw-keypress ctrl-r handoff's bail-out timeout fires that an Enter on an +/// empty input buffer is treated as a stray keystroke rather than a deliberate submission. See +/// [`Block::raw_keypress_bailout_guard_until`]. +const RAW_KEYPRESS_CTRL_R_HANDOFF_BAILOUT_GUARD_DURATION: std::time::Duration = + std::time::Duration::from_secs(2); + /// We don't consider commands that were killed via Ctrl-C (error code 130) or that were killed /// by SIGPIPE (error code 141) to have failed. We also don't consider the exit code for any /// commands that didn't start execution (i.e. `preexec` was never called), as the exit code is @@ -323,6 +329,23 @@ pub struct Block { was_long_running: AtomicBool, bootstrap_stage: BootstrapStage, + /// Prototype (alternative to PR #15513): `true` while a raw-keypress ctrl-r handoff (see + /// [`crate::terminal::view::TerminalView::maybe_trigger_raw_keypress_ctrl_r_handoff`]) is in + /// flight for this block. The block never transitions out of `BeforeExecution` for this + /// handoff -- unlike the foreground-command handoff, no shell command is actually submitted -- + /// so [`Self::is_active_and_long_running`] would otherwise stay `false` and the input editor + /// would keep intercepting keystrokes meant for the shell's history widget. + raw_keypress_forward_active: bool, + + /// Set by `TerminalView::on_raw_keypress_ctrl_r_handoff_timeout` when a raw-keypress ctrl-r + /// handoff's bail-out timer fires, guarding against a stray Enter -- meant for the + /// just-torn-down wrapper widget, e.g. the user selecting an entry believing the widget is + /// still up -- landing on the now-refocused, empty input editor and submitting an empty + /// command. Consumed (and cleared) by the first Enter on an empty buffer; expires on its own + /// otherwise, so a later, deliberate empty Enter is unaffected. Never set on the normal + /// completion path, since a successful completion means the widget worked correctly. + raw_keypress_bailout_guard_until: Option, + show_bootstrap_block: bool, show_in_band_command_blocks: bool, show_memory_stats: bool, @@ -977,6 +1000,8 @@ impl Block { padding: sizes.block_padding, render_delay_complete: Arc::new(AtomicBool::new(false)), was_long_running: AtomicBool::new(false), + raw_keypress_forward_active: false, + raw_keypress_bailout_guard_until: None, state: BlockState::BeforeExecution, precmd_state: PrecmdState::BeforePrecmd, exit_code: ExitCode::from(0), @@ -1701,9 +1726,38 @@ impl Block { && self.output_grid.should_show_as_empty_when_finished() } + /// See [`Self::raw_keypress_forward_active`]'s field doc comment. + pub fn is_raw_keypress_forward_active(&self) -> bool { + self.raw_keypress_forward_active + } + + /// See [`Self::raw_keypress_forward_active`]'s field doc comment. + pub fn set_raw_keypress_forward_active(&mut self, active: bool) { + self.raw_keypress_forward_active = active; + } + + /// See [`Self::raw_keypress_bailout_guard_until`]'s field doc comment. + pub fn arm_raw_keypress_bailout_guard(&mut self) { + self.raw_keypress_bailout_guard_until = + Some(Instant::now() + RAW_KEYPRESS_CTRL_R_HANDOFF_BAILOUT_GUARD_DURATION); + } + + /// Consumes the guard armed by [`Self::arm_raw_keypress_bailout_guard`], returning `true` if + /// it was active (armed and not yet expired). Always clears the guard, so a single check -- + /// whether or not it was active -- prevents it from lingering and affecting a later, + /// unrelated empty Enter. + pub fn consume_raw_keypress_bailout_guard(&mut self) -> bool { + self.raw_keypress_bailout_guard_until + .take() + .is_some_and(|deadline| Instant::now() < deadline) + } + /// Whether a command is long running. /// We use this to determine whether to hide the input box. pub fn is_active_and_long_running(&self) -> bool { + if self.raw_keypress_forward_active { + return true; + } if self.is_empty_pre_bootstrap_block() { return false; } diff --git a/app/src/terminal/model/terminal_model.rs b/app/src/terminal/model/terminal_model.rs index 6491992a8c4..ac519c11f7b 100644 --- a/app/src/terminal/model/terminal_model.rs +++ b/app/src/terminal/model/terminal_model.rs @@ -27,7 +27,10 @@ use warpui::r#async::executor::Background; use warpui::image_cache::ImageType; use super::super::{AltScreen, BlockList}; -use super::ansi::{BootstrappedValue, FinishUpdateValue, InputBufferValue, Mode, PendingHook}; +use super::ansi::{ + BootstrappedValue, ExternalCtrlRRawKeypressSelectionValue, + ExternalCtrlRRawKeypressStartedValue, FinishUpdateValue, InputBufferValue, Mode, PendingHook, +}; use super::block::{ AgentInteractionMetadata, Block, BlockId, BlockMetadata, BlockSize, BlockState, BlocklistEnvVarMetadata, SerializedBlock, @@ -3169,6 +3172,19 @@ impl ansi::Handler for TerminalModel { delegate!(self.input_buffer(data)); } + fn external_ctrl_r_raw_keypress_selection( + &mut self, + data: ExternalCtrlRRawKeypressSelectionValue, + ) { + self.event_proxy + .send_app_event(Event::ExternalCtrlRRawKeypressSelection(data)); + } + + fn external_ctrl_r_raw_keypress_started(&mut self, data: ExternalCtrlRRawKeypressStartedValue) { + self.event_proxy + .send_app_event(Event::ExternalCtrlRRawKeypressStarted(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 4926e38c7d6..5411a00ed35 100644 --- a/app/src/terminal/model_events.rs +++ b/app/src/terminal/model_events.rs @@ -5,7 +5,9 @@ use warpui::{Entity, ModelContext, ModelHandle, SingletonEntity}; use super::event::{BootstrappedEvent, SshLoginStatus}; use super::model::ansi; -use super::model::ansi::FinishUpdateValue; +use super::model::ansi::{ + ExternalCtrlRRawKeypressSelectionValue, ExternalCtrlRRawKeypressStartedValue, FinishUpdateValue, +}; use super::model::block::BlockId; use super::model::completions::ShellCompletion; use super::model::lifecycle::LifecycleTelemetryEvent; @@ -261,6 +263,12 @@ impl ModelEventDispatcher { Event::HonorPS1OutOfSync => ModelEvent::HonorPS1OutOfSync, Event::Typeahead => ModelEvent::Typeahead, Event::FinishUpdate(data) => ModelEvent::FinishUpdate(data), + Event::ExternalCtrlRRawKeypressSelection(data) => { + ModelEvent::ExternalCtrlRRawKeypressSelection(data) + } + Event::ExternalCtrlRRawKeypressStarted(data) => { + ModelEvent::ExternalCtrlRRawKeypressStarted(data) + } Event::TextSelectionChanged => ModelEvent::SelectedTextChanged, Event::ShellSpawned(shell_type) => ModelEvent::ShellSpawned(shell_type), Event::SendCompletionsPrompt => ModelEvent::SendCompletionsPrompt, @@ -446,6 +454,11 @@ pub enum ModelEvent { /// inaccessible to views/models. Handler(AnsiHandlerEvent), FinishUpdate(FinishUpdateValue), + /// Emitted when the shell reports the command selected in its raw-keypress ctrl-r handoff + /// wrapper widget (a prototype alternative to the foreground-command handoff in PR #15513). + ExternalCtrlRRawKeypressSelection(ExternalCtrlRRawKeypressSelectionValue), + /// See [`crate::terminal::event::Event::ExternalCtrlRRawKeypressStarted`]'s doc comment. + ExternalCtrlRRawKeypressStarted(ExternalCtrlRRawKeypressStartedValue), SelectedTextChanged, ShellSpawned(ShellType), CompletionsFinished(Vec), diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index 46d006084ac..b44fcd6f8d4 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -714,6 +714,67 @@ 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 has installed a wrapper widget on +/// [`RAW_KEYPRESS_CTRL_R_HANDOFF_KEYSEQ`] (a prototype alternative to PR #15513's +/// `external_ctrl_r_history` tag). Must match the tag name used in the bootstrap scripts under +/// `app/assets/bundled/bootstrap/`. +const RAW_KEYPRESS_CTRL_R_HANDOFF_PLUGIN_TAG: &str = "external_ctrl_r_raw_keypress"; + +/// The private key sequence (Alt-]) that bootstrap binds the wrapper widget to in every relevant +/// keymap. Verified empirically to be unbound in stock bash (emacs, vi-insert, vi-command), zsh +/// (emacs, viins, vicmd), and fish (default, insert). Bash's own `\C-x\C-r` is unsuitable because +/// it collides with `re-read-init-file`. +const RAW_KEYPRESS_CTRL_R_HANDOFF_KEYSEQ: &[u8] = &[escape_sequences::C0::ESC, b']']; + +/// The pty payload that triggers a handoff: `id` wrapped in bracketed-paste markers, followed by +/// [`RAW_KEYPRESS_CTRL_R_HANDOFF_KEYSEQ`]. The markers make the shell's line editor insert the +/// digits as literal text rather than interpret them as an editing command (e.g. bash/zsh's own +/// Alt-digit numeric argument, or vi command mode's repeat count). +fn raw_keypress_ctrl_r_handoff_payload(id: RawKeypressCtrlRHandoffId) -> Vec { + let mut bytes = Vec::new(); + bytes.extend_from_slice(BRACKETED_PASTE_PREFIX.as_bytes()); + bytes.extend_from_slice(id.0.to_string().as_bytes()); + bytes.extend_from_slice(BRACKETED_PASTE_SUFFIX.as_bytes()); + bytes.extend_from_slice(RAW_KEYPRESS_CTRL_R_HANDOFF_KEYSEQ); + bytes +} + +/// How long [`Block::is_raw_keypress_forward_active`] can stay set with no completion hook and no +/// evidence the wrapper widget ever started, before it is force-cleared. This mechanism runs +/// whatever the user has bound to ctrl-r and cannot assume it is present in the shell's active +/// keymap, gets invoked, or ever returns, so the flag that hides the input editor and forwards +/// keystrokes must have a bail-out that does not depend on the wrapper's cooperation. See +/// `TerminalView::on_raw_keypress_ctrl_r_handoff_timeout`. +/// +/// This only bounds time-to-first-*start*, not the handoff's total duration. Before the wrapper +/// widget has started, it is restarted on every keystroke forwarded to the pty +/// (`TerminalView::note_raw_keypress_ctrl_r_handoff_activity`), so it only fires after a period of +/// genuine inactivity. Once the wrapper reports that it has actually run +/// (`TerminalView::note_raw_keypress_ctrl_r_handoff_started`), the timer is cancelled outright and +/// latched via `PendingRawKeypressCtrlRHandoff::started` so a later keystroke can never resurrect +/// it: from that point on there is no deadline for the rest of the handoff, however long the user +/// reads the widget's output or thinks before pressing a key. A real widget (fzf, atuin) reports +/// started within milliseconds, so this only fires for the failure mode it exists to catch: +/// nothing was ever listening for the handoff (missing/rebound binding, dead shell). +/// +/// Liveness is deliberately reported by the wrapper itself rather than inferred from raw pty +/// output: the token paste's own shell echo is output too, and arrives even when nothing is bound +/// to the private key sequence, so it cannot distinguish a live wrapper from an unbound one. See +/// also `Block::arm_raw_keypress_bailout_guard`, which independently guards against a stray +/// post-bail-out Enter submitting an empty command. +const RAW_KEYPRESS_CTRL_R_HANDOFF_TIMEOUT: Duration = Duration::from_secs(5); + +/// After the timeout bail-out fires, how long a new raw-keypress ctrl-r handoff is refused on +/// this session. The timeout sends a best-effort Ctrl-C to the pty but cannot confirm the old +/// wrapper widget actually stopped consuming input; without this cooldown, a user immediately +/// pressing ctrl-r again could have the new handoff's pasted token and Alt-] consumed by the +/// still-live old widget (as ordinary input to whatever it's doing), wasting the new attempt on +/// another full timeout instead of ever reaching a wrapper that can answer it. A completion from +/// the old widget itself can no longer be misapplied to the new handoff -- [`RawKeypressCtrlRHandoffId`] +/// is verified against the token the completion echoes back -- so this cooldown only protects the +/// new attempt's chance to actually run, not the correctness of what gets applied. +const RAW_KEYPRESS_CTRL_R_HANDOFF_COOLDOWN: Duration = Duration::from_millis(750); + 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"; @@ -2482,6 +2543,35 @@ struct LocalSessionCanonicalPwdCache { canonical: CanonicalizedPath, } +/// Identifies a single raw-keypress ctrl-r handoff *attempt*, distinct from `session_id`/ +/// `block_id`: a second ctrl-r press on the same block after the first handoff's bail-out timer +/// fired -- but before its underlying widget actually stopped consuming pty input -- would +/// otherwise be indistinguishable from the first by session/block alone. The decimal value is +/// echoed back by the shell as the completion protocol's `token`, and validated against it by +/// [`TerminalView::note_raw_keypress_ctrl_r_handoff_started`] and +/// [`TerminalView::apply_raw_keypress_ctrl_r_selection`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct RawKeypressCtrlRHandoffId(u64); + +/// State for an in-flight raw-keypress ctrl-r handoff (see +/// [`TerminalView::maybe_trigger_raw_keypress_ctrl_r_handoff`]). `session_id` lets +/// [`TerminalView::apply_raw_keypress_ctrl_r_selection`] verify that an +/// `ExternalCtrlRRawKeypressSelection` hook is actually the reply to this handoff, rather than an +/// unsolicited write to the pty. +#[derive(Clone)] +struct PendingRawKeypressCtrlRHandoff { + id: RawKeypressCtrlRHandoffId, + session_id: SessionId, + block_id: BlockId, + /// Set once [`TerminalView::note_raw_keypress_ctrl_r_handoff_started`] validates the wrapper + /// widget's `ExternalCtrlRRawKeypressStarted` report for this handoff. Once set, the bail-out + /// timer has been cancelled outright and must stay cancelled: + /// [`TerminalView::note_raw_keypress_ctrl_r_handoff_activity`] checks this before restarting + /// it, so ordinary interaction (forwarded keystrokes) can no longer resurrect a deadline that + /// the started signal already proved unnecessary. + started: bool, +} + pub struct TerminalView { pub model: Arc>, view_handle: WeakViewHandle, @@ -2598,6 +2688,22 @@ pub struct TerminalView { /// Commands that should run as separate blocks after the active pending /// command finishes successfully. pending_command_queue: VecDeque, + /// State for an in-flight raw-keypress ctrl-r handoff started by + /// [`Self::maybe_trigger_raw_keypress_ctrl_r_handoff`], if any. + pending_raw_keypress_ctrl_r_handoff: Option, + /// Bail-out timer for the in-flight handoff above, restarted on every keystroke forwarded to + /// the pty (see [`Self::note_raw_keypress_ctrl_r_handoff_activity`]) so it only fires after a + /// period of genuine inactivity. The wrapper widget runs whatever the user has bound to + /// ctrl-r, which this mechanism cannot guarantee will ever invoke it or return (wrong keymap, + /// rebound key, a hung TUI) -- so the flag that hides the input editor and forwards + /// keystrokes must never depend solely on that hook arriving. See + /// [`Self::on_raw_keypress_ctrl_r_handoff_timeout`]. + pending_raw_keypress_ctrl_r_timeout: Option, + /// Monotonically increasing counter used to mint the next [`RawKeypressCtrlRHandoffId`]. + next_raw_keypress_ctrl_r_handoff_id: u64, + /// Set by the timeout bail-out to the instant a new handoff may start again (see + /// [`RAW_KEYPRESS_CTRL_R_HANDOFF_COOLDOWN`]). `None` when no cooldown is in effect. + raw_keypress_ctrl_r_handoff_cooldown_until: Option, /// When true, enter agent view after pending setup commands complete /// (i.e. after `PendingCommandCompleted` is emitted). Set by /// `pane_tree_from_template_recursive` when a tab config has both @@ -4351,6 +4457,10 @@ impl TerminalView { is_login_shell_bootstrapped: false, awaiting_pending_command_completion: false, pending_command_queue: Default::default(), + pending_raw_keypress_ctrl_r_handoff: None, + pending_raw_keypress_ctrl_r_timeout: None, + next_raw_keypress_ctrl_r_handoff_id: 0, + raw_keypress_ctrl_r_handoff_cooldown_until: None, enter_agent_view_after_pending_commands: false, slow_bootstrap_banner, is_slow_bootstrap_banner_open: false, @@ -9199,6 +9309,281 @@ impl TerminalView { && !model.is_read_only() } + /// If ctrl-r was pressed at an idle prompt on a session whose shell has installed a + /// raw-keypress wrapper widget (reported via the [`RAW_KEYPRESS_CTRL_R_HANDOFF_PLUGIN_TAG`] + /// shell plugin tag), hands the keypress off to that widget instead of opening Warp's own + /// command search. Prototype: alternative to the foreground-command handoff mechanism in + /// PR #15513 (`maybe_trigger_external_ctrl_r_history_search` there). + /// + /// Unlike a foreground-command handoff, no shell command is submitted: Warp pastes this + /// handoff's id as a token (see [`raw_keypress_ctrl_r_handoff_payload`]) followed by + /// [`RAW_KEYPRESS_CTRL_R_HANDOFF_KEYSEQ`] directly to the pty, and marks the active block as + /// forwarding (see [`Block::set_raw_keypress_forward_active`]), which hides the input editor + /// and routes subsequent keystrokes straight to the pty exactly as for a real long-running + /// command. The wrapper widget captures the token, runs the user's own `^R` binding in a + /// genuine key-binding context, reads the resulting selection, and reports both back over the + /// `ExternalCtrlRRawKeypressSelection` DCS hook (see + /// [`Self::apply_raw_keypress_ctrl_r_selection`]), which ends the handoff. + /// + /// Returns `true` if the handoff was triggered, in which case the caller should not open + /// Warp's command search. + pub fn maybe_trigger_raw_keypress_ctrl_r_handoff( + &mut self, + ctx: &mut ViewContext, + ) -> bool { + if !FeatureFlag::RawKeypressCtrlRHandoff.is_enabled() || self.is_long_running() { + return false; + } + if self + .raw_keypress_ctrl_r_handoff_cooldown_until + .is_some_and(|until| Instant::now() < until) + { + return false; + } + let Some(session_id) = self.active_block_session_id() else { + return false; + }; + let has_raw_keypress_widget = + self.sessions + .as_ref(ctx) + .get(session_id) + .is_some_and(|session| { + session + .shell() + .plugins() + .contains(RAW_KEYPRESS_CTRL_R_HANDOFF_PLUGIN_TAG) + }); + if !has_raw_keypress_widget || self.model.lock().is_alt_screen_active() { + return false; + } + + let block_id = { + let mut model = self.model.lock(); + let active_block = model.block_list_mut().active_block_mut(); + active_block.set_raw_keypress_forward_active(true); + active_block.id().clone() + }; + let id = RawKeypressCtrlRHandoffId(self.next_raw_keypress_ctrl_r_handoff_id); + self.next_raw_keypress_ctrl_r_handoff_id += 1; + self.pending_raw_keypress_ctrl_r_handoff = Some(PendingRawKeypressCtrlRHandoff { + id, + session_id, + block_id, + started: false, + }); + // This mechanism runs whatever the user has bound to ctrl-r, so it cannot assume the + // wrapper widget is present in the shell's active keymap, gets invoked, or ever returns. + // Without a bail-out that doesn't depend on the wrapper's cooperation, any of those + // failing would leave `raw_keypress_forward_active` set indefinitely: the input editor + // stays hidden and every keystroke is forwarded to the pty with no way back except + // closing the tab. See `on_raw_keypress_ctrl_r_handoff_timeout`. + self.pending_raw_keypress_ctrl_r_timeout = + Some(self.spawn_raw_keypress_ctrl_r_handoff_timeout(id, ctx)); + + // `raw_keypress_forward_active` drives `is_active_and_long_running()`, which in turn + // hides the input editor -- but only once focus actually moves off it. Without this, + // focus stays on the input editor from the idle prompt that preceded this handoff: + // control keys (e.g. arrows, Enter) still reach the terminal via global keybindings, but + // typed filter characters would be consumed by the input editor instead of reaching the + // pty, silently breaking the wrapper widget's live filtering. This handoff only ever + // triggers while the input editor holds keyboard focus (its keybinding requires the + // `Input` context), so focusing the terminal directly is always correct here -- unlike + // `redetermine_global_focus`, this doesn't defer to a context menu, modal, or onboarding + // callout that might otherwise be silently holding one of those early-return guards open. + self.focus_terminal(ctx); + + self.write_user_bytes_to_pty(raw_keypress_ctrl_r_handoff_payload(id), ctx); + true + } + + fn spawn_raw_keypress_ctrl_r_handoff_timeout( + &self, + id: RawKeypressCtrlRHandoffId, + ctx: &mut ViewContext, + ) -> SpawnedFutureHandle { + ctx.spawn( + async move { + Timer::after(RAW_KEYPRESS_CTRL_R_HANDOFF_TIMEOUT).await; + }, + move |me, _, ctx| { + me.on_raw_keypress_ctrl_r_handoff_timeout(id, ctx); + }, + ) + } + + /// Restarts the bail-out timeout for an in-flight raw-keypress ctrl-r handoff, if any. Call + /// this whenever a keystroke is forwarded to the pty while the handoff is pending. + /// + /// A no-op once [`Self::note_raw_keypress_ctrl_r_handoff_started`] has already cancelled the + /// timer outright for this handoff: the wrapper widget is a real interactive TUI (fzf, atuin) + /// that a user can legitimately keep browsing or typing into for far longer than the timeout, + /// and once the widget has proven it started, there is no deadline left to restart. Before + /// that signal arrives, restarting on every keystroke means the timeout only fires after a + /// period of genuine inactivity (no user input *and* no completion hook), which still catches + /// the failure modes it exists for: the wrapper never starting, or hanging before it does. + fn note_raw_keypress_ctrl_r_handoff_activity(&mut self, ctx: &mut ViewContext) { + let Some(handoff) = self.pending_raw_keypress_ctrl_r_handoff.clone() else { + return; + }; + if handoff.started { + return; + } + if let Some(handle) = self.pending_raw_keypress_ctrl_r_timeout.take() { + handle.abort(); + } + self.pending_raw_keypress_ctrl_r_timeout = + Some(self.spawn_raw_keypress_ctrl_r_handoff_timeout(handoff.id, ctx)); + } + + /// Called when the shell reports that the raw-keypress ctrl-r handoff's wrapper widget has + /// actually been invoked (see [`ExternalCtrlRRawKeypressStartedValue`]). Cancels the + /// bail-out timer outright, rather than rescheduling it like + /// [`Self::note_raw_keypress_ctrl_r_handoff_activity`] does: once the wrapper widget has + /// demonstrably run, the failure mode the timer exists to catch -- nothing ever listening + /// for the handoff -- is ruled out, so there is no longer a deadline for the rest of this + /// handoff, no matter how long the user then reads or thinks. If the widget later hangs + /// after having started, that's the same as any other frozen interactive program in the + /// terminal: not automatically recovered, but still escapable via Ctrl-C (still forwarded to + /// the pty) or closing the tab. + /// + /// Only acts if `session_id` and `token` both match the pending handoff, for the same reason + /// [`Self::apply_raw_keypress_ctrl_r_selection`] checks them: an unsolicited or stale report + /// must not disarm the timer for a handoff it doesn't belong to. This is positive evidence + /// sent by the wrapper itself, unlike inferring liveness from pty output -- which the token + /// paste's own shell echo can produce even when nothing is listening for the handoff at all + /// (e.g. the private key sequence isn't bound to anything in the active keymap). + fn note_raw_keypress_ctrl_r_handoff_started(&mut self, session_id: SessionId, token: &str) { + let matches_pending = self + .pending_raw_keypress_ctrl_r_handoff + .as_ref() + .is_some_and(|handoff| { + handoff.session_id == session_id && handoff.id.0.to_string() == token + }); + if !matches_pending { + return; + } + if let Some(handoff) = self.pending_raw_keypress_ctrl_r_handoff.as_mut() { + handoff.started = true; + } + if let Some(handle) = self.pending_raw_keypress_ctrl_r_timeout.take() { + handle.abort(); + } + } + + /// Called when the shell reports the command selected in the raw-keypress ctrl-r handoff + /// wrapper widget. Applies the selection only if `session_id` and `token` both match an + /// in-flight handoff this session started (see [`PendingRawKeypressCtrlRHandoff`]); otherwise + /// ignores it as unsolicited or stale. Ends the handoff on a match, restoring normal input + /// editor behavior. + fn apply_raw_keypress_ctrl_r_selection( + &mut self, + session_id: SessionId, + token: &str, + selection: &str, + ctx: &mut ViewContext, + ) { + let matches_pending = self + .pending_raw_keypress_ctrl_r_handoff + .as_ref() + .is_some_and(|handoff| { + handoff.session_id == session_id && handoff.id.0.to_string() == token + }); + if !matches_pending || !self.end_raw_keypress_ctrl_r_handoff(session_id, ctx) { + return; + } + + if !selection.is_empty() { + let editor = self.input.as_ref(ctx).editor().clone(); + editor.update(ctx, |editor, ctx| { + editor.set_buffer_text(selection, ctx); + }); + } + } + + /// Bail-out for [`Self::maybe_trigger_raw_keypress_ctrl_r_handoff`]: fires + /// [`RAW_KEYPRESS_CTRL_R_HANDOFF_TIMEOUT`] after a period of inactivity, guaranteeing that + /// `raw_keypress_forward_active` cannot stay set indefinitely even if the wrapper widget is + /// absent from the shell's active keymap, was rebound, or hangs. A no-op if `id` doesn't match + /// the currently pending handoff -- either the completion hook already ended it (the common, + /// fast path), or a newer handoff has since started and this is a stale timer for the old one. + /// + /// Sends a best-effort Ctrl-C to the pty before tearing down: if the wrapper is a well-behaved + /// interactive TUI (fzf, atuin), this gives it a chance to exit on its own. This is not a + /// guarantee -- Warp has no way to confirm the widget actually stopped consuming pty input -- + /// which is why a cooldown (not an immediate re-arm) follows. + fn on_raw_keypress_ctrl_r_handoff_timeout( + &mut self, + id: RawKeypressCtrlRHandoffId, + ctx: &mut ViewContext, + ) { + let Some(handoff) = self.pending_raw_keypress_ctrl_r_handoff.clone() else { + return; + }; + if handoff.id != id { + return; + } + log::warn!( + "Raw-keypress ctrl-r handoff timed out after {:?} with no completion hook; restoring input editor", + RAW_KEYPRESS_CTRL_R_HANDOFF_TIMEOUT + ); + self.write_user_bytes_to_pty(vec![escape_sequences::C0::ETX], ctx); + // A user who believed they were still selecting an entry in the wrapper widget may + // press Enter immediately after this bail-out restores focus to the (empty) input + // editor; without this guard, that stray Enter would submit an empty command and leave + // a stray block behind. Not armed on the normal completion path (`apply_raw_keypress_ + // ctrl_r_selection`), since a successful completion means the widget worked correctly. + if let Some(block) = self + .model + .lock() + .block_list_mut() + .mut_block_from_id(&handoff.block_id) + { + block.arm_raw_keypress_bailout_guard(); + } + self.end_raw_keypress_ctrl_r_handoff(handoff.session_id, ctx); + self.raw_keypress_ctrl_r_handoff_cooldown_until = + Some(Instant::now() + RAW_KEYPRESS_CTRL_R_HANDOFF_COOLDOWN); + ctx.notify(); + } + + /// Shared teardown for the raw-keypress ctrl-r handoff: clears the block's forwarding flag, + /// the pending handoff state, and the bail-out timer. Used by both the normal completion path + /// ([`Self::apply_raw_keypress_ctrl_r_selection`]) and the timeout bail-out + /// ([`Self::on_raw_keypress_ctrl_r_handoff_timeout`]). Returns `true` if a matching in-flight + /// handoff was found and torn down. + fn end_raw_keypress_ctrl_r_handoff( + &mut self, + session_id: SessionId, + ctx: &mut ViewContext, + ) -> bool { + let Some(handoff) = self + .pending_raw_keypress_ctrl_r_handoff + .take_if(|handoff| handoff.session_id == session_id) + else { + return false; + }; + + if let Some(handle) = self.pending_raw_keypress_ctrl_r_timeout.take() { + handle.abort(); + } + + if let Some(block) = self + .model + .lock() + .block_list_mut() + .mut_block_from_id(&handoff.block_id) + { + block.set_raw_keypress_forward_active(false); + } + + // The forwarding flag drove `is_active_and_long_running()`, which in turn hid the input + // editor. Re-run focus determination so the editor reliably regains focus on the bail-out + // path too, not just when a selection is applied. + self.redetermine_global_focus(ctx); + + true + } + pub fn was_ever_visible(&self) -> bool { self.was_ever_visible } @@ -9229,6 +9614,7 @@ impl TerminalView { fn control_sequence_on_terminal(&mut self, bytes: &[u8], ctx: &mut ViewContext) { if self.is_long_running() { + self.note_raw_keypress_ctrl_r_handoff_activity(ctx); self.write_user_bytes_to_pty(bytes.to_owned(), ctx); } else { safe_warn!( @@ -9297,6 +9683,7 @@ impl TerminalView { /// Generally, this should be control characters rather than printable characters. fn keydown_on_terminal(&mut self, characters: &str, ctx: &mut ViewContext) { if self.is_long_running() { + self.note_raw_keypress_ctrl_r_handoff_activity(ctx); self.highlighted_link.invalidate(); self.report_possible_typeahead(characters); self.write_user_bytes_to_pty(characters.as_bytes().to_vec(), ctx); @@ -9321,7 +9708,11 @@ impl TerminalView { // Note that we check block started and NOT block.is_long_running(), because // the block starts on enter but only becomes long running on receiving Preexec. // We want to make sure we capture any input between enter and receiving Preexec. - if !model.block_list().active_block().started() { + // The one exception is a raw-keypress ctrl-r handoff (see + // `maybe_trigger_raw_keypress_ctrl_r_handoff`): the block never starts for that handoff, + // but typed characters are the fzf/atuin filter query and must reach the pty. + let active_block = model.block_list().active_block(); + if !active_block.started() && !active_block.is_raw_keypress_forward_active() { return false; } @@ -9341,6 +9732,7 @@ impl TerminalView { /// can go into the input box. fn typed_characters_on_terminal(&mut self, characters: &str, ctx: &mut ViewContext) { if self.should_write_typed_chars_to_pty(ctx) { + self.note_raw_keypress_ctrl_r_handoff_activity(ctx); self.highlighted_link.invalidate(); self.report_possible_typeahead(characters); self.write_user_bytes_to_pty(characters.as_bytes().to_vec(), ctx); @@ -12858,6 +13250,21 @@ impl TerminalView { log::warn!("Got a FinishUpdate event with non-matching update id!"); } } + ModelEvent::ExternalCtrlRRawKeypressSelection(data) => { + if let Some(session_id) = data.session_id.map(SessionId::from) { + self.apply_raw_keypress_ctrl_r_selection( + session_id, + &data.token, + &data.buffer, + ctx, + ); + } + } + ModelEvent::ExternalCtrlRRawKeypressStarted(data) => { + if let Some(session_id) = data.session_id.map(SessionId::from) { + self.note_raw_keypress_ctrl_r_handoff_started(session_id, &data.token); + } + } ModelEvent::SelectedTextChanged => { ctx.emit(Event::SelectedTextChanged); } diff --git a/app/src/terminal/view_tests.rs b/app/src/terminal/view_tests.rs index b3e69890767..648c06b1edc 100644 --- a/app/src/terminal/view_tests.rs +++ b/app/src/terminal/view_tests.rs @@ -72,12 +72,14 @@ use crate::terminal::model::ansi::{self, BootstrappedValue, InitShellValue, Pree use crate::terminal::model::block::AgentViewVisibility; use crate::terminal::model::blocks::{TotalIndex, insert_block}; use crate::terminal::model::grid::Dimensions as _; +use crate::terminal::model::session::SessionInfo; use crate::terminal::model::terminal_model::WithinBlock; use crate::terminal::session_settings::AgentToolbarChipSelection; use crate::terminal::shared_session::shared_handlers::{ RemoteUpdateGuard, apply_cli_agent_state_update, }; use crate::terminal::shared_session::{SharedSessionSource, SharedSessionStatus}; +use crate::terminal::shell::Shell; use crate::terminal::view::ambient_agent::AmbientAgentViewModelEvent; use crate::terminal::view::load_ai_conversation::{ RestoreConversationEntryBehavior, RestoredAIConversation, @@ -10186,3 +10188,729 @@ fn back_button_label_resolves_token_only_parent_linkage() { }); }); } + +/// Registers a test session with (or without) the raw-keypress ctrl-r handoff plugin tag, and +/// points the active block at it, so [`TerminalView::maybe_trigger_raw_keypress_ctrl_r_handoff`] +/// has a session to resolve. +fn setup_raw_keypress_ctrl_r_session( + view: &mut TerminalView, + ctx: &mut ViewContext, + session_id: SessionId, + has_plugin_tag: bool, +) { + let mut session_info = SessionInfo::new_for_test().with_id(session_id); + if has_plugin_tag { + session_info.shell = Shell::new( + session_info.shell.shell_type(), + session_info.shell.version().clone(), + session_info.shell.options().clone(), + HashSet::from([RAW_KEYPRESS_CTRL_R_HANDOFF_PLUGIN_TAG.to_string()]), + session_info.shell.shell_path().clone(), + ); + } + view.sessions_model().update(ctx, |sessions, _ctx| { + sessions.register_session_for_test(session_info); + }); + view.active_block_metadata = Some(BlockMetadata::new(Some(session_id), None)); +} + +#[test] +fn raw_keypress_ctrl_r_handoff_requires_plugin_tag() { + App::test((), |mut app| async move { + initialize_app_for_terminal_view(&mut app); + let _flag = FeatureFlag::RawKeypressCtrlRHandoff.override_enabled(true); + let terminal = add_window_with_terminal(&mut app, None); + let session_id = SessionId::from(0); + + terminal.update(&mut app, |view, ctx| { + setup_raw_keypress_ctrl_r_session(view, ctx, session_id, false); + + let triggered = view.maybe_trigger_raw_keypress_ctrl_r_handoff(ctx); + assert!( + !triggered, + "handoff must not trigger for a session without the plugin tag" + ); + assert!( + !view + .model + .lock() + .block_list() + .active_block() + .is_raw_keypress_forward_active(), + "the active block must not be marked as forwarding" + ); + }); + }) +} + +#[test] +fn raw_keypress_ctrl_r_handoff_triggers_and_forwards_keyseq() { + App::test((), |mut app| async move { + initialize_app_for_terminal_view(&mut app); + let _flag = FeatureFlag::RawKeypressCtrlRHandoff.override_enabled(true); + let terminal = add_window_with_terminal(&mut app, None); + let session_id = SessionId::from(0); + + let pty_writes: Rc>>> = Rc::new(RefCell::new(Vec::new())); + let writes = pty_writes.clone(); + app.update(|ctx| { + ctx.subscribe_to_view(&terminal, move |_, event, _| { + if let Event::WriteBytesToPty { bytes } = event { + writes.borrow_mut().push(bytes.to_vec()); + } + }); + }); + + let expected_payload = terminal.update(&mut app, |view, ctx| { + setup_raw_keypress_ctrl_r_session(view, ctx, session_id, true); + + let triggered = view.maybe_trigger_raw_keypress_ctrl_r_handoff(ctx); + assert!( + triggered, + "handoff must trigger for a session with the plugin tag" + ); + assert!( + view.model + .lock() + .block_list() + .active_block() + .is_raw_keypress_forward_active(), + "the active block must be marked as forwarding once the handoff starts" + ); + let id = view + .pending_raw_keypress_ctrl_r_handoff + .as_ref() + .expect("a pending handoff must be tracked") + .id; + assert!( + view.pending_raw_keypress_ctrl_r_timeout.is_some(), + "a bail-out timer must be armed" + ); + raw_keypress_ctrl_r_handoff_payload(id) + }); + + assert_eq!( + *pty_writes.borrow(), + vec![expected_payload], + "the bracketed-paste-wrapped token and private key sequence must be written to the pty exactly once" + ); + }) +} + +/// Regression test: at the idle prompt the handoff always starts from, the input editor is +/// focused. Unlike `should_write_typed_chars_to_pty` (which already exempted the handoff from +/// the "block must have started" check), nothing previously moved focus off the input editor +/// when the handoff began -- so a `TypedCharacters` event never reached `TerminalView` at all; +/// the still-focused input editor consumed it first. `maybe_trigger_raw_keypress_ctrl_r_handoff` +/// must redetermine focus itself, mirroring `end_raw_keypress_ctrl_r_handoff`'s teardown call. +#[test] +fn raw_keypress_ctrl_r_handoff_moves_focus_off_input_so_typed_chars_reach_pty() { + App::test((), |mut app| async move { + initialize_app_for_terminal_view(&mut app); + let _flag = FeatureFlag::RawKeypressCtrlRHandoff.override_enabled(true); + let terminal = add_window_with_terminal(&mut app, None); + let session_id = SessionId::from(0); + + let pty_writes: Rc>>> = Rc::new(RefCell::new(Vec::new())); + let writes = pty_writes.clone(); + app.update(|ctx| { + ctx.subscribe_to_view(&terminal, move |_, event, _| { + if let Event::WriteBytesToPty { bytes } = event { + writes.borrow_mut().push(bytes.to_vec()); + } + }); + }); + + terminal.update(&mut app, |view, ctx| { + setup_raw_keypress_ctrl_r_session(view, ctx, session_id, true); + + // Simulate the idle prompt the handoff always starts from: the input editor is + // focused, exactly as it normally is between commands. + view.focus_input_box(ctx); + }); + terminal.update(&mut app, |view, ctx| { + assert!( + view.input().is_self_or_child_focused(ctx), + "the input editor should be focused before the handoff starts" + ); + }); + + terminal.update(&mut app, |view, ctx| { + assert!(view.maybe_trigger_raw_keypress_ctrl_r_handoff(ctx)); + }); + terminal.update(&mut app, |view, ctx| { + assert!( + !view.input().is_self_or_child_focused(ctx), + "the handoff must move focus off the input editor -- otherwise the wrapper \ + widget's filter keystrokes are captured by the input editor instead of \ + reaching the pty" + ); + }); + + pty_writes.borrow_mut().clear(); + terminal.update(&mut app, |view, ctx| { + view.typed_characters_on_terminal("echo", ctx); + }); + + assert_eq!( + *pty_writes.borrow(), + vec![b"echo".to_vec()], + "typed filter characters must reach the pty once the handoff has moved focus off \ + the input editor" + ); + terminal.read(&app, |view, ctx| { + assert_eq!( + view.input() + .as_ref(ctx) + .editor() + .as_ref(ctx) + .buffer_text(ctx), + "", + "typed filter characters must not land in the input editor's buffer" + ); + }); + }) +} + +/// Regression/diagnostic test: unlike `raw_keypress_ctrl_r_handoff_moves_focus_off_input_so_typed_chars_reach_pty` +/// (which calls `typed_characters_on_terminal` directly, bypassing the framework's own event +/// dispatch), this drives a real `warpui::Event::TypedCharacters` through the actual +/// `Presenter`/element-tree dispatch pipeline -- the same path a real keystroke takes in the GUI. +/// It exists to answer, empirically, whether omitting the input editor from `TerminalView`'s +/// rendered element tree (see `is_input_box_visible` / `TerminalView::render`) is sufficient by +/// itself to stop a `TypedCharacters` event from being consumed by the input editor's own +/// `RichTextElement`, or whether some other mechanism (e.g. a stale cached tree, or a +/// focus-keyed dispatch shortcut) still routes it there. +#[test] +fn raw_keypress_ctrl_r_handoff_typed_characters_via_real_dispatch_reach_pty_not_input() { + App::test((), |mut app| async move { + initialize_app_for_terminal_view(&mut app); + let _flag = FeatureFlag::RawKeypressCtrlRHandoff.override_enabled(true); + let (window_id, terminal) = add_window_with_id_and_terminal(&mut app, None); + let session_id = SessionId::from(0); + + let pty_writes: Rc>>> = Rc::new(RefCell::new(Vec::new())); + let writes = pty_writes.clone(); + app.update(|ctx| { + ctx.subscribe_to_view(&terminal, move |_, event, _| { + if let Event::WriteBytesToPty { bytes } = event { + writes.borrow_mut().push(bytes.to_vec()); + } + }); + }); + + terminal.update(&mut app, |view, ctx| { + setup_raw_keypress_ctrl_r_session(view, ctx, session_id, true); + view.focus_input_box(ctx); + }); + + terminal.update(&mut app, |view, ctx| { + assert!(view.maybe_trigger_raw_keypress_ctrl_r_handoff(ctx)); + }); + terminal.update(&mut app, |view, ctx| { + assert!( + !view.is_input_box_visible(&view.model.lock(), ctx), + "the input editor must be omitted from the render tree once the handoff starts" + ); + }); + + let mut updated = EntityIdSet::default(); + updated.insert(app.root_view_id(window_id).unwrap()); + let invalidation = WindowInvalidation { + updated, + ..Default::default() + }; + let presenter = Rc::new(RefCell::new(Presenter::new(window_id))); + + let size_info = terminal.update(&mut app, |view, _ctx| *view.size_info); + + // Drive a real render/layout/paint cycle through the presenter, exactly as the real GUI + // does after `ctx.notify()`, so the current (input-omitting) tree is what's actually + // stored for dispatch -- not relying on a stale tree from before the handoff started. + app.update(enclose!((presenter, invalidation) move |ctx| { + presenter.borrow_mut().invalidate(invalidation, ctx); + presenter.borrow_mut().build_scene( + vec2f(size_info.pane_width_px, size_info.pane_height_px), + 1., + None, + ctx, + ); + })); + + pty_writes.borrow_mut().clear(); + app.update(enclose!((presenter) move |ctx| { + ctx.simulate_window_event( + warpui::Event::TypedCharacters { + chars: "x".to_string(), + }, + window_id, + presenter.clone(), + ); + })); + + assert_eq!( + *pty_writes.borrow(), + vec![b"x".to_vec()], + "a real TypedCharacters event dispatched through the presenter's element tree must \ + reach the pty during the handoff, not be swallowed by some other mechanism" + ); + terminal.read(&app, |view, ctx| { + assert_eq!( + view.input() + .as_ref(ctx) + .editor() + .as_ref(ctx) + .buffer_text(ctx), + "", + "the input editor's own buffer must not receive the typed character, since its \ + ChildView is omitted from the render tree during the handoff" + ); + }); + }) +} + +/// Pins the mechanism behind the DCS-based "started" signal: once the wrapper widget reports +/// (via `ExternalCtrlRRawKeypressStarted`) that it has actually been invoked for this handoff, +/// the bail-out timer must be cancelled outright -- not merely rescheduled -- so there is no +/// longer any deadline for the rest of the handoff, no matter how long the user then reads or +/// thinks. Deliberately does *not* test this via raw pty output: that was the flawed prior +/// design, since the token paste's own shell echo is itself pty output and would cancel the +/// timer even when nothing is listening for the handoff at all. +#[test] +fn raw_keypress_ctrl_r_handoff_started_cancels_bailout_timer_outright() { + App::test((), |mut app| async move { + initialize_app_for_terminal_view(&mut app); + let _flag = FeatureFlag::RawKeypressCtrlRHandoff.override_enabled(true); + let terminal = add_window_with_terminal(&mut app, None); + let session_id = SessionId::from(0); + + terminal.update(&mut app, |view, ctx| { + setup_raw_keypress_ctrl_r_session(view, ctx, session_id, true); + assert!(view.maybe_trigger_raw_keypress_ctrl_r_handoff(ctx)); + assert!( + view.pending_raw_keypress_ctrl_r_timeout.is_some(), + "a bail-out timer must be armed when the handoff starts" + ); + let token = view + .pending_raw_keypress_ctrl_r_handoff + .as_ref() + .expect("handoff should be pending") + .id + .0 + .to_string(); + + view.note_raw_keypress_ctrl_r_handoff_started(session_id, &token); + assert!( + view.pending_raw_keypress_ctrl_r_timeout.is_none(), + "a matching started report must cancel the bail-out timer outright, not \ + reschedule it" + ); + + // Only the timer was cancelled -- the handoff itself is still pending, exactly as + // it should be while the wrapper widget is up and the user is free to keep reading + // or interacting with it for as long as they like. + assert!( + view.model + .lock() + .block_list() + .active_block() + .is_raw_keypress_forward_active(), + "the handoff must remain active after the timer is cancelled" + ); + assert!( + view.pending_raw_keypress_ctrl_r_handoff.is_some(), + "the pending handoff state must be untouched by cancelling the timer" + ); + }); + }) +} + +/// An unsolicited or stale started report (wrong session or wrong token) must not cancel the +/// current handoff's timer -- otherwise a leftover report from a superseded handoff could +/// silently disarm the bail-out for the current one. +#[test] +fn raw_keypress_ctrl_r_handoff_started_ignored_for_wrong_session_or_token() { + App::test((), |mut app| async move { + initialize_app_for_terminal_view(&mut app); + let _flag = FeatureFlag::RawKeypressCtrlRHandoff.override_enabled(true); + let terminal = add_window_with_terminal(&mut app, None); + let session_id = SessionId::from(0); + let other_session_id = SessionId::from(1); + + terminal.update(&mut app, |view, ctx| { + setup_raw_keypress_ctrl_r_session(view, ctx, session_id, true); + assert!(view.maybe_trigger_raw_keypress_ctrl_r_handoff(ctx)); + let token = view + .pending_raw_keypress_ctrl_r_handoff + .as_ref() + .expect("handoff should be pending") + .id + .0 + .to_string(); + let wrong_token = format!("{token}0"); + + view.note_raw_keypress_ctrl_r_handoff_started(other_session_id, &token); + assert!( + view.pending_raw_keypress_ctrl_r_timeout.is_some(), + "a started report for a different session must not cancel this handoff's timer" + ); + + view.note_raw_keypress_ctrl_r_handoff_started(session_id, &wrong_token); + assert!( + view.pending_raw_keypress_ctrl_r_timeout.is_some(), + "a started report with a mismatched token must not cancel this handoff's timer" + ); + }); + }) +} + +/// Regression test for the gap found in a live pass on `d18b3886`: once a matching started +/// report has cancelled the bail-out timer outright, a subsequently forwarded keystroke (the +/// same path a real filter character or arrow key takes while the wrapper widget is up) must +/// not resurrect it. Before `PendingRawKeypressCtrlRHandoff::started` existed, +/// `note_raw_keypress_ctrl_r_handoff_activity` unconditionally re-armed a fresh timer on every +/// forwarded keystroke, so typing a filter query after the widget had already proven it was +/// alive would still tear the handoff down five seconds later. +#[test] +fn raw_keypress_ctrl_r_handoff_started_is_durable_against_activity() { + App::test((), |mut app| async move { + initialize_app_for_terminal_view(&mut app); + let _flag = FeatureFlag::RawKeypressCtrlRHandoff.override_enabled(true); + let terminal = add_window_with_terminal(&mut app, None); + let session_id = SessionId::from(0); + + terminal.update(&mut app, |view, ctx| { + setup_raw_keypress_ctrl_r_session(view, ctx, session_id, true); + assert!(view.maybe_trigger_raw_keypress_ctrl_r_handoff(ctx)); + let token = view + .pending_raw_keypress_ctrl_r_handoff + .as_ref() + .expect("handoff should be pending") + .id + .0 + .to_string(); + + view.note_raw_keypress_ctrl_r_handoff_started(session_id, &token); + assert!( + view.pending_raw_keypress_ctrl_r_timeout.is_none(), + "a matching started report must cancel the bail-out timer outright" + ); + + // Simulate a filter keystroke forwarded to the pty after the widget has started -- + // exactly what happens when a user types to filter, then pauses before pressing + // Enter. + view.note_raw_keypress_ctrl_r_handoff_activity(ctx); + assert!( + view.pending_raw_keypress_ctrl_r_timeout.is_none(), + "activity after the started signal must not resurrect the bail-out timer -- \ + once cancelled, there is no deadline left for the rest of the handoff" + ); + }); + }) +} + +#[test] +fn raw_keypress_ctrl_r_handoff_stale_timeout_is_ignored() { + App::test((), |mut app| async move { + initialize_app_for_terminal_view(&mut app); + let _flag = FeatureFlag::RawKeypressCtrlRHandoff.override_enabled(true); + let terminal = add_window_with_terminal(&mut app, None); + let session_id = SessionId::from(0); + + terminal.update(&mut app, |view, ctx| { + setup_raw_keypress_ctrl_r_session(view, ctx, session_id, true); + assert!(view.maybe_trigger_raw_keypress_ctrl_r_handoff(ctx)); + let current_id = view + .pending_raw_keypress_ctrl_r_handoff + .as_ref() + .expect("handoff should be pending") + .id; + + // A timer belonging to a hypothetical newer handoff (or a duplicate fired for the + // current one after it was already superseded) must not tear down the still-current + // handoff. + let stale_id = RawKeypressCtrlRHandoffId(current_id.0 + 1); + view.on_raw_keypress_ctrl_r_handoff_timeout(stale_id, ctx); + assert!( + view.model + .lock() + .block_list() + .active_block() + .is_raw_keypress_forward_active(), + "a stale timeout must not clear the forwarding flag" + ); + assert_eq!( + view.pending_raw_keypress_ctrl_r_handoff + .as_ref() + .map(|handoff| handoff.id), + Some(current_id), + "a stale timeout must not clear the pending handoff" + ); + assert!( + view.raw_keypress_ctrl_r_handoff_cooldown_until.is_none(), + "a stale timeout must not start a cooldown" + ); + + // The timer that actually matches the current handoff must tear it down. + view.on_raw_keypress_ctrl_r_handoff_timeout(current_id, ctx); + assert!( + !view + .model + .lock() + .block_list() + .active_block() + .is_raw_keypress_forward_active(), + "the matching timeout must clear the forwarding flag" + ); + assert!( + view.pending_raw_keypress_ctrl_r_handoff.is_none(), + "the matching timeout must clear the pending handoff" + ); + assert!( + view.raw_keypress_ctrl_r_handoff_cooldown_until.is_some(), + "the matching timeout must start a cooldown" + ); + }); + }) +} + +/// Regression test for the mechanism behind a real-world symptom: a wrapper widget (e.g. fzf) +/// left open past the inactivity timeout, then accepted with Enter, left a stray empty block +/// behind because that Enter no longer reached the pty at all -- it landed on the newly +/// refocused, empty input editor and submitted it as a command. Once the (matching) timeout +/// fires, forwarding must stop completely: subsequent typed characters must go to the input +/// editor's buffer, not the pty, exactly as before the handoff ever started. +#[test] +fn raw_keypress_ctrl_r_handoff_timeout_restores_normal_typed_character_routing() { + App::test((), |mut app| async move { + initialize_app_for_terminal_view(&mut app); + let _flag = FeatureFlag::RawKeypressCtrlRHandoff.override_enabled(true); + let terminal = add_window_with_terminal(&mut app, None); + let session_id = SessionId::from(0); + + let pty_writes: Rc>>> = Rc::new(RefCell::new(Vec::new())); + let writes = pty_writes.clone(); + app.update(|ctx| { + ctx.subscribe_to_view(&terminal, move |_, event, _| { + if let Event::WriteBytesToPty { bytes } = event { + writes.borrow_mut().push(bytes.to_vec()); + } + }); + }); + + let current_id = terminal.update(&mut app, |view, ctx| { + setup_raw_keypress_ctrl_r_session(view, ctx, session_id, true); + view.focus_input_box(ctx); + assert!(view.maybe_trigger_raw_keypress_ctrl_r_handoff(ctx)); + view.pending_raw_keypress_ctrl_r_handoff + .as_ref() + .expect("handoff should be pending") + .id + }); + + terminal.update(&mut app, |view, ctx| { + // Simulate the bail-out firing, e.g. because the user paused to read the wrapper + // widget's filtered list for longer than the inactivity timeout. This legitimately + // writes a best-effort Ctrl-C to the pty, so pty_writes is cleared afterward to + // isolate the *next* keystroke, which is what this test is actually about. + view.on_raw_keypress_ctrl_r_handoff_timeout(current_id, ctx); + assert!( + view.is_input_box_visible(&view.model.lock(), ctx), + "the input editor must be visible again once the bail-out has fired" + ); + }); + + pty_writes.borrow_mut().clear(); + terminal.update(&mut app, |view, ctx| { + // A stray keystroke meant for the (now torn-down) wrapper widget -- e.g. the Enter + // the user presses believing they're still selecting a fzf entry -- must land in + // the input editor's buffer, not be forwarded to the pty as if the handoff were + // still active. + view.typed_characters_on_terminal("x", ctx); + }); + + assert!( + pty_writes.borrow().is_empty(), + "a keystroke after the bail-out must not be forwarded to the pty, got {:?}", + pty_writes.borrow() + ); + terminal.read(&app, |view, ctx| { + assert_eq!( + view.input() + .as_ref(ctx) + .editor() + .as_ref(ctx) + .buffer_text(ctx), + "x", + "a keystroke after the bail-out must land in the input editor's buffer" + ); + }); + }) +} + +#[test] +fn raw_keypress_ctrl_r_handoff_cooldown_blocks_new_handoff() { + App::test((), |mut app| async move { + initialize_app_for_terminal_view(&mut app); + let _flag = FeatureFlag::RawKeypressCtrlRHandoff.override_enabled(true); + let terminal = add_window_with_terminal(&mut app, None); + let session_id = SessionId::from(0); + + terminal.update(&mut app, |view, ctx| { + setup_raw_keypress_ctrl_r_session(view, ctx, session_id, true); + assert!(view.maybe_trigger_raw_keypress_ctrl_r_handoff(ctx)); + let current_id = view + .pending_raw_keypress_ctrl_r_handoff + .as_ref() + .expect("handoff should be pending") + .id; + view.on_raw_keypress_ctrl_r_handoff_timeout(current_id, ctx); + + // Immediately re-pressing ctrl-r must be refused: the timeout's best-effort Ctrl-C + // cannot guarantee the old wrapper widget actually stopped consuming pty input. + let retriggered = view.maybe_trigger_raw_keypress_ctrl_r_handoff(ctx); + assert!( + !retriggered, + "a new handoff must be refused during the post-timeout cooldown" + ); + }); + }) +} + +#[test] +fn raw_keypress_ctrl_r_handoff_selection_ignored_for_wrong_session() { + App::test((), |mut app| async move { + initialize_app_for_terminal_view(&mut app); + let _flag = FeatureFlag::RawKeypressCtrlRHandoff.override_enabled(true); + let terminal = add_window_with_terminal(&mut app, None); + let session_id = SessionId::from(0); + let other_session_id = SessionId::from(1); + + terminal.update(&mut app, |view, ctx| { + setup_raw_keypress_ctrl_r_session(view, ctx, session_id, true); + assert!(view.maybe_trigger_raw_keypress_ctrl_r_handoff(ctx)); + let token = view + .pending_raw_keypress_ctrl_r_handoff + .as_ref() + .expect("handoff should be pending") + .id + .0 + .to_string(); + + // A selection tagged with a session that isn't the one this handoff started for + // must be treated as an unsolicited pty write, not applied, even though the token + // matches. + view.apply_raw_keypress_ctrl_r_selection(other_session_id, &token, "echo hi", ctx); + + assert!( + view.model + .lock() + .block_list() + .active_block() + .is_raw_keypress_forward_active(), + "a mismatched-session selection must not end the handoff" + ); + assert!( + view.pending_raw_keypress_ctrl_r_handoff.is_some(), + "a mismatched-session selection must leave the handoff pending" + ); + assert_eq!( + view.input.as_ref(ctx).editor().as_ref(ctx).buffer_text(ctx), + "", + "a mismatched-session selection must not be applied to the input buffer" + ); + }); + }) +} + +#[test] +fn raw_keypress_ctrl_r_handoff_selection_ignored_for_wrong_token() { + App::test((), |mut app| async move { + initialize_app_for_terminal_view(&mut app); + let _flag = FeatureFlag::RawKeypressCtrlRHandoff.override_enabled(true); + let terminal = add_window_with_terminal(&mut app, None); + let session_id = SessionId::from(0); + + terminal.update(&mut app, |view, ctx| { + setup_raw_keypress_ctrl_r_session(view, ctx, session_id, true); + assert!(view.maybe_trigger_raw_keypress_ctrl_r_handoff(ctx)); + let token = view + .pending_raw_keypress_ctrl_r_handoff + .as_ref() + .expect("handoff should be pending") + .id + .0 + .to_string(); + let wrong_token = format!("{token}0"); + + // A completion for the right session but a stale or forged token -- e.g. from a + // handoff that was superseded before it finished -- must not be applied. + view.apply_raw_keypress_ctrl_r_selection(session_id, &wrong_token, "echo hi", ctx); + + assert!( + view.model + .lock() + .block_list() + .active_block() + .is_raw_keypress_forward_active(), + "a mismatched-token selection must not end the handoff" + ); + assert!( + view.pending_raw_keypress_ctrl_r_handoff.is_some(), + "a mismatched-token selection must leave the handoff pending" + ); + assert_eq!( + view.input.as_ref(ctx).editor().as_ref(ctx).buffer_text(ctx), + "", + "a mismatched-token selection must not be applied to the input buffer" + ); + }); + }) +} + +#[test] +fn raw_keypress_ctrl_r_handoff_selection_applies_and_ends() { + App::test((), |mut app| async move { + initialize_app_for_terminal_view(&mut app); + let _flag = FeatureFlag::RawKeypressCtrlRHandoff.override_enabled(true); + let terminal = add_window_with_terminal(&mut app, None); + let session_id = SessionId::from(0); + + terminal.update(&mut app, |view, ctx| { + setup_raw_keypress_ctrl_r_session(view, ctx, session_id, true); + assert!(view.maybe_trigger_raw_keypress_ctrl_r_handoff(ctx)); + let token = view + .pending_raw_keypress_ctrl_r_handoff + .as_ref() + .expect("handoff should be pending") + .id + .0 + .to_string(); + + view.apply_raw_keypress_ctrl_r_selection(session_id, &token, "echo selected", ctx); + + assert!( + !view + .model + .lock() + .block_list() + .active_block() + .is_raw_keypress_forward_active(), + "a matching selection must end the handoff" + ); + assert!( + view.pending_raw_keypress_ctrl_r_handoff.is_none(), + "a matching selection must clear the pending handoff" + ); + assert!( + view.pending_raw_keypress_ctrl_r_timeout.is_none(), + "a matching selection must abort the bail-out timer" + ); + assert_eq!( + view.input.as_ref(ctx).editor().as_ref(ctx).buffer_text(ctx), + "echo selected", + "the selected command must be applied to the input buffer" + ); + }); + }) +} diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index 133da2abd98..12b34fc19ff 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -17276,6 +17276,20 @@ impl Workspace { return; } + // If the active session's shell has installed a raw-keypress wrapper widget on ctrl-r + // (a prototype alternative to PR #15513's foreground-command handoff), hand the keypress + // off to it instead of opening command search. Only applies to the default (ctrl-r-shaped) + // invocation, not the dedicated history-search binding, which explicitly asks for Warp's + // own UI. + 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_raw_keypress_ctrl_r_handoff(ctx) + }) + { + return; + } + // Close all overlays including chip menus before opening command search self.close_all_overlays(ctx); diff --git a/crates/warp_features/src/lib.rs b/crates/warp_features/src/lib.rs index c7a0e67c3f8..85d2a9da176 100644 --- a/crates/warp_features/src/lib.rs +++ b/crates/warp_features/src/lib.rs @@ -971,6 +971,14 @@ pub enum FeatureFlag { /// always forwarded unchanged and the harness process/sandbox are never /// signaled or torn down. CtrlCCancelsThirdPartyHarness, + + /// Prototype: alternative to `FzfCtrlRHandoff` (see PR #15513). When the active session's + /// shell reports (via the `external_ctrl_r_raw_keypress` shell plugin tag) that it has + /// installed a wrapper widget on a private key sequence, hands ctrl-r off to it by writing + /// that key sequence to the pty instead of opening Warp's own command search. The wrapper + /// invokes the user's own `^R` binding (fzf, atuin, or otherwise) from a genuine + /// key-binding context, so no per-tool client code is needed. + RawKeypressCtrlRHandoff, } static FLAG_STATES: [AtomicBool; cardinality::()] = @@ -1045,6 +1053,7 @@ pub const DOGFOOD_FLAGS: &[FeatureFlag] = &[ FeatureFlag::BoxDrawingGlyphs, FeatureFlag::PricingTransparency, FeatureFlag::PeriodicHandoffCheckpoints, + FeatureFlag::RawKeypressCtrlRHandoff, ]; /// Features enabled for feature preview build users (e.g.: Friends of Warp). diff --git a/crates/warp_terminal/src/model/ansi/dcs_hooks.rs b/crates/warp_terminal/src/model/ansi/dcs_hooks.rs index 9d18b257acf..3f75a17af39 100644 --- a/crates/warp_terminal/src/model/ansi/dcs_hooks.rs +++ b/crates/warp_terminal/src/model/ansi/dcs_hooks.rs @@ -69,6 +69,20 @@ pub(super) enum DProtoHook { InputBuffer { value: InputBufferValue, }, + /// Reports the command selected in the shell's raw-keypress ctrl-r handoff wrapper widget + /// (see [`ExternalCtrlRRawKeypressSelectionValue`]), a prototype alternative to the + /// foreground-command handoff in PR #15513. + ExternalCtrlRRawKeypressSelection { + value: ExternalCtrlRRawKeypressSelectionValue, + }, + /// Reports that the shell's raw-keypress ctrl-r handoff wrapper widget has actually been + /// invoked (see [`ExternalCtrlRRawKeypressStartedValue`]), sent the moment the wrapper + /// starts, before it runs the real widget. Positive evidence the widget is alive, as + /// opposed to inferring liveness from pty output -- which the token paste's own shell echo + /// can produce even when nothing is listening for the handoff at all. + ExternalCtrlRRawKeypressStarted { + value: ExternalCtrlRRawKeypressStartedValue, + }, Clear { value: ClearValue, }, @@ -96,6 +110,8 @@ const DPROTO_HOOK_VARIANTS: &[&str] = &[ "SSH", "InitShell", "InputBuffer", + "ExternalCtrlRRawKeypressSelection", + "ExternalCtrlRRawKeypressStarted", "Clear", "InitSubshell", "SourcedRcFileForWarp", @@ -149,6 +165,12 @@ impl<'de> Deserialize<'de> for DProtoHook { "InputBuffer" => DProtoHook::InputBuffer { value: parse_hook_value::<_, D::Error>(raw.value)?, }, + "ExternalCtrlRRawKeypressSelection" => DProtoHook::ExternalCtrlRRawKeypressSelection { + value: parse_hook_value::<_, D::Error>(raw.value)?, + }, + "ExternalCtrlRRawKeypressStarted" => DProtoHook::ExternalCtrlRRawKeypressStarted { + value: parse_hook_value::<_, D::Error>(raw.value)?, + }, "Clear" => DProtoHook::Clear { value: parse_hook_value::<_, D::Error>(raw.value)?, }, @@ -185,6 +207,10 @@ impl DProtoHook { DProtoHook::SSH { .. } => "SSH", DProtoHook::InitShell { .. } => "InitShell", DProtoHook::InputBuffer { .. } => "InputBuffer", + DProtoHook::ExternalCtrlRRawKeypressSelection { .. } => { + "ExternalCtrlRRawKeypressSelection" + } + DProtoHook::ExternalCtrlRRawKeypressStarted { .. } => "ExternalCtrlRRawKeypressStarted", DProtoHook::Clear { .. } => "Clear", DProtoHook::InitSubshell { .. } => "InitSubshell", DProtoHook::SourcedRcFileForWarp { .. } => "SourcedRcFileForWarp", @@ -204,6 +230,12 @@ 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::ExternalCtrlRRawKeypressSelection { value } => { + value.session_id.map(SessionId::from) + } + DProtoHook::ExternalCtrlRRawKeypressStarted { 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 +257,8 @@ impl DProtoHook { | DProtoHook::SSH { .. } | DProtoHook::InitShell { .. } | DProtoHook::InputBuffer { .. } + | DProtoHook::ExternalCtrlRRawKeypressSelection { .. } + | DProtoHook::ExternalCtrlRRawKeypressStarted { .. } | DProtoHook::Clear { .. } | DProtoHook::InitSubshell { .. } | DProtoHook::FinishUpdate { .. } @@ -985,6 +1019,57 @@ pub struct InputBufferValue { pub session_id: HookSessionId, } +/// Received from the pty after the shell's raw-keypress ctrl-r handoff wrapper widget (see +/// [`crate::terminal::view::TerminalView::maybe_trigger_raw_keypress_ctrl_r_handoff`]) finishes, +/// reporting the command the user selected. Empty when the user cancelled without selecting +/// anything. Warp inserts the selection into the input editor without executing it. +/// +/// `token` echoes back the handoff token Warp pastes into the shell's line buffer (via bracketed +/// paste, ahead of the private key sequence) immediately before triggering the wrapper, mirroring +/// how the foreground-command handoff's `ExternalCtrlRSelection` hook (PR #15513) echoes a token +/// passed as a shell-helper argument. The wrapper captures it from the buffer before running the +/// real widget. The client rejects a reply whose token doesn't match the pending handoff (see +/// [`crate::terminal::view::TerminalView::apply_raw_keypress_ctrl_r_selection`]), so a stale +/// completion from a superseded attempt can't be misapplied to a newer one. +#[derive(Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +pub struct ExternalCtrlRRawKeypressSelectionValue { + pub buffer: String, + #[serde(default)] + pub token: String, + #[serde(default)] + pub session_id: HookSessionId, +} + +impl std::fmt::Debug for ExternalCtrlRRawKeypressSelectionValue { + /// Redacts `buffer`, since it carries the shell command the user selected and may contain + /// sensitive data (e.g. a secret typed into an earlier command). + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ExternalCtrlRRawKeypressSelectionValue") + .field("buffer", &"") + .field("token", &self.token) + .field("session_id", &self.session_id) + .finish() + } +} + +/// Received from the pty the moment the shell's raw-keypress ctrl-r handoff wrapper widget is +/// actually invoked, before it runs the real widget (see [`crate::terminal::view::TerminalView:: +/// maybe_trigger_raw_keypress_ctrl_r_handoff`]). Unlike inferring liveness from pty output (which +/// the token paste's own shell echo can produce even when nothing is listening), this is positive +/// evidence the wrapper actually ran, sent by the wrapper itself. +/// +/// `token` echoes back the handoff token Warp pasted (see +/// [`ExternalCtrlRRawKeypressSelectionValue`]'s doc comment); the client only acts on this hook +/// if both `session_id` and `token` match the pending handoff, for the same reason the completion +/// hook does. +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +pub struct ExternalCtrlRRawKeypressStartedValue { + #[serde(default)] + pub token: String, + #[serde(default)] + pub session_id: HookSessionId, +} + /// 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..167fc541553 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,14 @@ fn every_hook_tag_dispatches_to_the_matching_variant() { serde_json::json!({"session_id": 1, "shell": "zsh"}), ), ("InputBuffer", serde_json::json!({"buffer": "echo hi"})), + ( + "ExternalCtrlRRawKeypressSelection", + serde_json::json!({"buffer": "echo hi"}), + ), + ( + "ExternalCtrlRRawKeypressStarted", + serde_json::json!({"token": "1"}), + ), ("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 ca28f0dbe26..f029f3f1b7a 100644 --- a/crates/warp_terminal/src/model/ansi/handler.rs +++ b/crates/warp_terminal/src/model/ansi/handler.rs @@ -308,6 +308,22 @@ pub trait Handler { /// input buffer (the reporting is itself triggered by Warp). fn input_buffer(&mut self, _data: InputBufferValue) {} + /// Callback for the terminal when the shell reports the command selected in its raw-keypress + /// ctrl-r handoff wrapper widget (see [`ExternalCtrlRRawKeypressSelectionValue`]). + fn external_ctrl_r_raw_keypress_selection( + &mut self, + _data: ExternalCtrlRRawKeypressSelectionValue, + ) { + } + + /// Callback for the terminal when the shell reports that its raw-keypress ctrl-r handoff + /// wrapper widget has actually been invoked (see [`ExternalCtrlRRawKeypressStartedValue`]). + fn external_ctrl_r_raw_keypress_started( + &mut self, + _data: ExternalCtrlRRawKeypressStartedValue, + ) { + } + /// 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 dbc90a02c13..ec5c1942e3e 100644 --- a/crates/warp_terminal/src/model/ansi/mod.rs +++ b/crates/warp_terminal/src/model/ansi/mod.rs @@ -606,6 +606,12 @@ 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::ExternalCtrlRRawKeypressSelection { value }) => { + self.handler.external_ctrl_r_raw_keypress_selection(value) + } + Ok(DProtoHook::ExternalCtrlRRawKeypressStarted { value }) => { + self.handler.external_ctrl_r_raw_keypress_started(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 d979b19ad47..8d2af3305a7 100644 --- a/crates/warp_terminal/src/model/ansi/mod_tests.rs +++ b/crates/warp_terminal/src/model/ansi/mod_tests.rs @@ -234,6 +234,19 @@ impl Handler for MockHandler { .push(DProtoHook::InputBuffer { value: data }) } + fn external_ctrl_r_raw_keypress_selection( + &mut self, + data: ExternalCtrlRRawKeypressSelectionValue, + ) { + self.d_proto_hooks + .push(DProtoHook::ExternalCtrlRRawKeypressSelection { value: data }) + } + + fn external_ctrl_r_raw_keypress_started(&mut self, data: ExternalCtrlRRawKeypressStartedValue) { + self.d_proto_hooks + .push(DProtoHook::ExternalCtrlRRawKeypressStarted { value: data }) + } + fn init_subshell(&mut self, data: InitSubshellValue) { self.d_proto_hooks .push(DProtoHook::InitSubshell { value: data }) @@ -953,6 +966,68 @@ fn parse_dcs_input_buffer() { } } +#[test] +fn parse_dcs_external_ctrl_r_raw_keypress_selection() { + // Anti-regression for the ansi dispatch match in `Performer::handle_decoded_hook`: proves + // the wire hook still reaches `Handler::external_ctrl_r_raw_keypress_selection` specifically + // (not the sibling `..._started` arm, which takes a distinct value type). + let bytes = hex_encoded_dcs_string( + r#"{ + "hook": "ExternalCtrlRRawKeypressSelection", + "value": { + "buffer": "echo selected", + "token": "1001", + "session_id": 167303092612201 + } + }"#, + ); + + let (_, handler) = parse_bytes(&bytes); + + assert_eq!(handler.d_proto_hooks.len(), 1); + match handler.d_proto_hooks.first().unwrap() { + DProtoHook::ExternalCtrlRRawKeypressSelection { value } => assert_eq!( + *value, + ExternalCtrlRRawKeypressSelectionValue { + buffer: "echo selected".to_string(), + token: "1001".to_string(), + session_id: Some(167303092612201), + } + ), + _ => panic!("incorrect dcs value"), + } +} + +#[test] +fn parse_dcs_external_ctrl_r_raw_keypress_started() { + // Anti-regression counterpart to `parse_dcs_external_ctrl_r_raw_keypress_selection`: proves + // the wire hook reaches `Handler::external_ctrl_r_raw_keypress_started`, not the selection + // arm. + let bytes = hex_encoded_dcs_string( + r#"{ + "hook": "ExternalCtrlRRawKeypressStarted", + "value": { + "token": "1001", + "session_id": 167303092612201 + } + }"#, + ); + + let (_, handler) = parse_bytes(&bytes); + + assert_eq!(handler.d_proto_hooks.len(), 1); + match handler.d_proto_hooks.first().unwrap() { + DProtoHook::ExternalCtrlRRawKeypressStarted { value } => assert_eq!( + *value, + ExternalCtrlRRawKeypressStartedValue { + token: "1001".to_string(), + session_id: Some(167303092612201), + } + ), + _ => panic!("incorrect dcs value"), + } +} + #[test] fn parse_sourced_rc_file_hook() { let rc_file_hook = r#"{"hook": "SourcedRcFileForWarp", "value": { "shell": "zsh" }}"#;