From 3ac9c7b7f69fe8006a0eaff7901da500392a4b22 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 00:47:12 +0000 Subject: [PATCH 01/70] Prototype: hand ctrl-r to fzf/atuin history search (CORE-3807) At an idle prompt, ctrl-r hands off to the shell's own external ctrl-r history widget (fzf or atuin) instead of opening Warp's command search, when bootstrap detects the shell has rebound ^R away from its default reverse-history-search widget. - zsh bootstrap generically detects a rebound ^R widget (not an fzf/atuin allowlist) and reports it via a new external_ctrl_r_history shell_plugins tag. A bootstrap-installed helper re-runs the detected tool's own picker command and reports the selection via a new ExternalCtrlRSelection DCS hook. - The client runs that helper through the normal command-execution path when the tag is present at an idle prompt, reusing the existing long-running-command machinery to hide the input editor and forward keystrokes to the picker's PTY-driven UI. - The selected command (or the prior buffer, on cancel) is restored into the input editor without executing, reusing the same buffer-restore path already used for prompt-chip commands like cd. - Gated behind a new FzfCtrlRHandoff feature flag (in DOGFOOD_FLAGS). --- app/Cargo.toml | 2 + app/assets/bundled/bootstrap/zsh_body.sh | 54 ++++++++++++++++- app/src/features.rs | 2 + app/src/terminal/event.rs | 8 ++- app/src/terminal/input.rs | 50 ++++++++++++++-- app/src/terminal/model/ansi/dcs_hooks.rs | 23 ++++++++ .../terminal/model/ansi/dcs_hooks_tests.rs | 4 ++ app/src/terminal/model/ansi/handler.rs | 4 ++ app/src/terminal/model/ansi/mod.rs | 3 + app/src/terminal/model/terminal_model.rs | 12 +++- app/src/terminal/model_events.rs | 6 +- app/src/terminal/view.rs | 59 +++++++++++++++++++ app/src/workspace/view.rs | 13 ++++ crates/warp_features/src/lib.rs | 7 +++ 14 files changed, 236 insertions(+), 11 deletions(-) diff --git a/app/Cargo.toml b/app/Cargo.toml index 1f28d445a1b..84e6face501 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -713,6 +713,7 @@ default = [ "orchestration_unified_stack", "ime_marked_text", "ctrl_c_cancels_third_party_harness", + "fzf_ctrl_r_handoff", ] # Enable this feature to automatically perform heap profiling. NOTE: This will # substantially slow down program execution. @@ -1052,6 +1053,7 @@ git_credential_refresh = [] prompt_cache_expiry_warning = [] osc_hyperlinks = [] ctrl_c_cancels_third_party_harness = [] +fzf_ctrl_r_handoff = [] [package.metadata.bundle.bin.warp-oss] category = "public.app-category.developer-tools" diff --git a/app/assets/bundled/bootstrap/zsh_body.sh b/app/assets/bundled/bootstrap/zsh_body.sh index 75406b2cef3..af010bb35b5 100644 --- a/app/assets/bundled/bootstrap/zsh_body.sh +++ b/app/assets/bundled/bootstrap/zsh_body.sh @@ -673,6 +673,33 @@ if [[ -z $WARP_BOOTSTRAPPED ]]; then } zle -N warp_report_input + # Runs the shell's own ctrl-r history widget (fzf or atuin, per the widget name captured in + # $_WARP_EXTERNAL_CTRL_R_WIDGET during bootstrap) as a synthetic foreground command, so Warp's + # existing long-running-command machinery hides the input editor and forwards keystrokes to the + # widget's PTY-driven UI. Reports the selected command (or an empty buffer, if cancelled) via + # the ExternalCtrlRSelection hook so Warp can insert it into the input editor without executing + # it. + # + # We re-run each tool's own underlying picker command rather than invoking its zle widget + # directly: those widgets rely on zle builtins (e.g. `zle vi-fetch-history`) that only work when + # the widget is actually bound to a key and invoked through zle, not when called as a plain + # command outside of that context. + function warp_run_external_ctrl_r_widget () { + local result="" + case "$_WARP_EXTERNAL_CTRL_R_WIDGET" in + *fzf*) + result="$(fc -rl 1 \ + | command -p awk '{ cmd=$0; sub(/^[ \t]*[0-9]+\**[ \t]+/, "", cmd); if (!seen[cmd]++) print cmd }' \ + | fzf --scheme=history --tiebreak=index +m)" + ;; + *atuin*) + result="$(atuin search -i)" + ;; + esac + local warp_escaped_selection="$(warp_escape_json "$result")" + warp_send_json_message "{ \"hook\": \"ExternalCtrlRSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"session_id\": $WARP_SESSION_ID } }" + } + function clear() { warp_send_json_message "{\"hook\": \"Clear\", \"value\": {\"session_id\": $WARP_SESSION_ID}}" } @@ -1242,7 +1269,10 @@ esac # See https://zsh.sourceforge.io/Doc/Release/Functions.html for more context # on the zshaddhistory hook. _warp_zshaddhistory() { - _is_warp_generator_command "$1" + # Also exclude the ctrl-r external history handoff helper (see + # warp_run_external_ctrl_r_widget above): it's a Warp-internal invocation, not a command + # the user meant to run again later. + _is_warp_generator_command "$1" && [[ "$1" != *"warp_run_external_ctrl_r_widget"* ]] } # Register this zshaddhistory hook after the user's RC files have been sourced, @@ -1316,6 +1346,28 @@ esac shell_plugins+=(vi) fi + # Detect whether ctrl-r has been rebound away from zsh's default reverse + # history search widgets (e.g. by fzf or atuin), so Warp can hand ctrl-r off + # to that widget at an idle prompt instead of opening Warp's own command + # search. Detection is intentionally generic -- any non-default widget -- + # rather than an allowlist of known tool names, so other ctrl-r history + # tools ride along for free. The widget name itself is only used locally + # (by warp_run_external_ctrl_r_widget below); only the generic tag is sent + # to the client. + _WARP_EXTERNAL_CTRL_R_WIDGET="" + warp_ctrl_r_binding="$(bindkey -M main '^R' 2>/dev/null)" + if [[ "$warp_ctrl_r_binding" == '"^R" '* ]]; then + warp_ctrl_r_widget="${warp_ctrl_r_binding#\"^R\" }" + case "$warp_ctrl_r_widget" in + history-incremental-search-backward|history-incremental-pattern-search-backward|undefined-key) + ;; + *) + _WARP_EXTERNAL_CTRL_R_WIDGET="$warp_ctrl_r_widget" + shell_plugins+=(external_ctrl_r_history) + ;; + esac + fi + if kernel_name="$(uname)"; then if [[ "$kernel_name" == "Darwin" ]]; then os_category="MacOS" diff --git a/app/src/features.rs b/app/src/features.rs index e5765fada7d..20fd455b8bf 100644 --- a/app/src/features.rs +++ b/app/src/features.rs @@ -523,6 +523,8 @@ fn enabled_features() -> HashSet { FeatureFlag::TerminalLifecycleRecovery, #[cfg(feature = "ctrl_c_cancels_third_party_harness")] FeatureFlag::CtrlCCancelsThirdPartyHarness, + #[cfg(feature = "fzf_ctrl_r_handoff")] + FeatureFlag::FzfCtrlRHandoff, ]); flags diff --git a/app/src/terminal/event.rs b/app/src/terminal/event.rs index b528f417650..66c134a3c79 100644 --- a/app/src/terminal/event.rs +++ b/app/src/terminal/event.rs @@ -10,7 +10,7 @@ pub use remote_server::setup::RemoteServerSetupState; use warp_util::lazy::Lazy; use super::history::HistoryEntry; -use super::model::ansi::FinishUpdateValue; +use super::model::ansi::{ExternalCtrlRSelectionValue, FinishUpdateValue}; use super::model::block::BlockId; use super::model::lifecycle::LifecycleRecoveryRecord; use super::model::session::{SessionId, SessionInfo}; @@ -130,6 +130,9 @@ 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 external ctrl-r history + /// widget (e.g. fzf or atuin). + ExternalCtrlRSelection(ExternalCtrlRSelectionValue), TextSelectionChanged, ShellSpawned(ShellType), SendCompletionsPrompt, @@ -532,6 +535,9 @@ impl Debug for Event { ) } Event::FinishUpdate(data) => write!(f, "FinishUpdate({})", data.update_id), + Event::ExternalCtrlRSelection(data) => { + write!(f, "ExternalCtrlRSelection(buffer: {:?})", data.buffer) + } 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 f687e1caaa1..2a2e40b509b 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -1777,6 +1777,12 @@ pub struct Input { /// we snapshot the current input contents here so we can restore them after the command /// completes and the buffer would normally be cleared. input_contents_before_prompt_chip_command: Option, + + /// Buffer contents to restore into the editor once the synthetic command started by + /// [`Self::trigger_external_ctrl_r_history_search`] completes and the buffer would + /// otherwise be cleared. Initially the buffer the user had before ctrl-r, overwritten with + /// the selected command by [`Self::set_external_ctrl_r_selection`] if the user accepts one. + pending_ctrl_r_handoff_restore_text: Option, } struct AmbientAgentViewState { @@ -4022,6 +4028,7 @@ impl Input { cloud_mode_composer_slash_command_data_source, ephemeral_message_model, input_contents_before_prompt_chip_command: None, + pending_ctrl_r_handoff_restore_text: None, }; #[cfg(feature = "local_fs")] @@ -7499,6 +7506,34 @@ impl Input { self.try_execute_command_with_options(command, false, ctx) } + /// Runs `helper_command` (a bootstrap-installed shell function) as if the user had typed and + /// submitted it, having snapshotted the current buffer contents so they're restored once the + /// command's block completes -- unless [`Self::set_external_ctrl_r_selection`] supplies a + /// selected command in the meantime. Returns `true` if the command was started. + pub fn trigger_external_ctrl_r_history_search( + &mut self, + helper_command: &str, + ctx: &mut ViewContext, + ) -> bool { + let current_input = self.buffer_text(ctx); + let started = + self.try_execute_command_from_source(helper_command, CommandExecutionSource::User, ctx); + if started { + self.pending_ctrl_r_handoff_restore_text = Some(current_input); + } + started + } + + /// Called when the shell reports the command selected in the external ctrl-r history search + /// (fzf/atuin). Overrides the buffer text that will be restored when the synthetic command's + /// block completes, so the selection lands in the editor instead of the pre-ctrl-r buffer. + /// A no-op if `selection` is empty (the user cancelled without selecting anything). + pub fn set_external_ctrl_r_selection(&mut self, selection: &str) { + if !selection.is_empty() { + self.pending_ctrl_r_handoff_restore_text = Some(selection.to_string()); + } + } + fn try_execute_command_with_options( &mut self, command: &str, @@ -15235,8 +15270,12 @@ impl Input { && !cloud_setup_pre_first_exchange && !self.has_queued_command_in_flight(ctx); let latest_block_id = self.model.lock().block_list().active_block_id().clone(); - let input_contents_before_prompt_chip_command = - self.input_contents_before_prompt_chip_command.take(); + // Prefer a prompt-chip restore (e.g. `cd`) over a ctrl-r handoff restore; the two + // cannot both be pending for the same block in practice. + let pending_input_restore = self + .input_contents_before_prompt_chip_command + .take() + .or_else(|| self.pending_ctrl_r_handoff_restore_text.take()); if should_clear_buffer { // We want to reinitialize the buffer whenever a command is completed so that @@ -15247,9 +15286,10 @@ impl Input { .update(ctx, |editor, ctx| editor.reinitialize_buffer(None, ctx)); self.latest_buffer_operations = Vec::new(); - // If we have a pending input restore (from a prompt chip command like cd), - // restore the input contents instead of leaving the buffer empty. - if let Some(restore_text) = input_contents_before_prompt_chip_command { + // If we have a pending input restore (from a prompt chip command like cd, or + // a ctrl-r external history handoff), restore the input contents instead of + // leaving the buffer empty. + if let Some(restore_text) = pending_input_restore { self.editor.update(ctx, |editor, ctx| { editor.set_buffer_text(&restore_text, ctx); }); diff --git a/app/src/terminal/model/ansi/dcs_hooks.rs b/app/src/terminal/model/ansi/dcs_hooks.rs index 536a8c7a8b8..942f3ff3d25 100644 --- a/app/src/terminal/model/ansi/dcs_hooks.rs +++ b/app/src/terminal/model/ansi/dcs_hooks.rs @@ -69,6 +69,11 @@ pub(super) enum DProtoHook { InputBuffer { value: InputBufferValue, }, + /// Reports the command selected in the shell's external ctrl-r history widget (e.g. fzf or + /// atuin), so it can be inserted into the input editor. See [`ExternalCtrlRSelectionValue`]. + ExternalCtrlRSelection { + value: ExternalCtrlRSelectionValue, + }, Clear { value: ClearValue, }, @@ -96,6 +101,7 @@ const DPROTO_HOOK_VARIANTS: &[&str] = &[ "SSH", "InitShell", "InputBuffer", + "ExternalCtrlRSelection", "Clear", "InitSubshell", "SourcedRcFileForWarp", @@ -149,6 +155,9 @@ impl<'de> Deserialize<'de> for DProtoHook { "InputBuffer" => DProtoHook::InputBuffer { value: parse_hook_value::<_, D::Error>(raw.value)?, }, + "ExternalCtrlRSelection" => DProtoHook::ExternalCtrlRSelection { + value: parse_hook_value::<_, D::Error>(raw.value)?, + }, "Clear" => DProtoHook::Clear { value: parse_hook_value::<_, D::Error>(raw.value)?, }, @@ -185,6 +194,7 @@ impl DProtoHook { DProtoHook::SSH { .. } => "SSH", DProtoHook::InitShell { .. } => "InitShell", DProtoHook::InputBuffer { .. } => "InputBuffer", + DProtoHook::ExternalCtrlRSelection { .. } => "ExternalCtrlRSelection", DProtoHook::Clear { .. } => "Clear", DProtoHook::InitSubshell { .. } => "InitSubshell", DProtoHook::SourcedRcFileForWarp { .. } => "SourcedRcFileForWarp", @@ -204,6 +214,7 @@ 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::ExternalCtrlRSelection { value } => value.session_id.map(SessionId::from), DProtoHook::Clear { value } => value.session_id.map(SessionId::from), DProtoHook::FinishUpdate { value } => value.session_id.map(SessionId::from), DProtoHook::PreInteractiveSSHSession { value } => value.session_id.map(SessionId::from), @@ -225,6 +236,7 @@ impl DProtoHook { | DProtoHook::SSH { .. } | DProtoHook::InitShell { .. } | DProtoHook::InputBuffer { .. } + | DProtoHook::ExternalCtrlRSelection { .. } | DProtoHook::Clear { .. } | DProtoHook::InitSubshell { .. } | DProtoHook::FinishUpdate { .. } @@ -985,6 +997,17 @@ pub struct InputBufferValue { pub session_id: HookSessionId, } +/// Received from the pty after the shell's external ctrl-r history widget (e.g. fzf or atuin, +/// detected via the `external_ctrl_r_history` [`BootstrappedValue::shell_plugins`] tag) 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. +#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct ExternalCtrlRSelectionValue { + pub buffer: 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/app/src/terminal/model/ansi/dcs_hooks_tests.rs b/app/src/terminal/model/ansi/dcs_hooks_tests.rs index f34f6fc28e6..a92c30311c5 100644 --- a/app/src/terminal/model/ansi/dcs_hooks_tests.rs +++ b/app/src/terminal/model/ansi/dcs_hooks_tests.rs @@ -170,6 +170,10 @@ fn every_hook_tag_dispatches_to_the_matching_variant() { serde_json::json!({"session_id": 1, "shell": "zsh"}), ), ("InputBuffer", serde_json::json!({"buffer": "echo hi"})), + ( + "ExternalCtrlRSelection", + serde_json::json!({"buffer": "echo hi"}), + ), ("Clear", serde_json::json!({})), ( "InitSubshell", diff --git a/app/src/terminal/model/ansi/handler.rs b/app/src/terminal/model/ansi/handler.rs index c89a636ee32..03bcc2a47eb 100644 --- a/app/src/terminal/model/ansi/handler.rs +++ b/app/src/terminal/model/ansi/handler.rs @@ -308,6 +308,10 @@ 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 + /// external ctrl-r history widget (e.g. fzf or atuin). + fn external_ctrl_r_selection(&mut self, _data: ExternalCtrlRSelectionValue) {} + /// 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/app/src/terminal/model/ansi/mod.rs b/app/src/terminal/model/ansi/mod.rs index 36a09b6a54d..c04b0d0505a 100644 --- a/app/src/terminal/model/ansi/mod.rs +++ b/app/src/terminal/model/ansi/mod.rs @@ -605,6 +605,9 @@ impl<'a, H: Handler + 'a, W: io::Write> Performer<'a, H, W> { Ok(DProtoHook::SSH { value }) => self.handler.ssh(value), Ok(DProtoHook::InitShell { value }) => self.handler.init_shell(value), Ok(DProtoHook::InputBuffer { value }) => self.handler.input_buffer(value), + Ok(DProtoHook::ExternalCtrlRSelection { value }) => { + self.handler.external_ctrl_r_selection(value) + } Ok(DProtoHook::Clear { value }) => self.handler.clear(value), Ok(DProtoHook::InitSubshell { value }) => self.handler.init_subshell(value), Ok(DProtoHook::SourcedRcFileForWarp { .. }) => { diff --git a/app/src/terminal/model/terminal_model.rs b/app/src/terminal/model/terminal_model.rs index 1bdd0d35eaf..ed7665c869f 100644 --- a/app/src/terminal/model/terminal_model.rs +++ b/app/src/terminal/model/terminal_model.rs @@ -64,9 +64,10 @@ use crate::terminal::event_listener::ChannelEventListener; pub use crate::terminal::history::HistoryEntry; use crate::terminal::model::ansi; use crate::terminal::model::ansi::{ - ClearValue, CommandFinishedValue, CompletionMetadata, ExitShellValue, Handler, InitShellValue, - InitSubshellValue, PreInteractiveSSHSessionValue, PrecmdValue, PreexecValue, PromptMetadata, - SSHValue, SourcedRcFileForWarpValue, + ClearValue, CommandFinishedValue, CompletionMetadata, ExitShellValue, + ExternalCtrlRSelectionValue, Handler, InitShellValue, InitSubshellValue, + PreInteractiveSSHSessionValue, PrecmdValue, PreexecValue, PromptMetadata, SSHValue, + SourcedRcFileForWarpValue, }; use crate::terminal::model::bootstrap::BootstrapStage; use crate::terminal::model::completions::{ @@ -3176,6 +3177,11 @@ impl ansi::Handler for TerminalModel { delegate!(self.input_buffer(data)); } + fn external_ctrl_r_selection(&mut self, data: ExternalCtrlRSelectionValue) { + self.event_proxy + .send_terminal_event(Event::ExternalCtrlRSelection(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..fd0de4d11fc 100644 --- a/app/src/terminal/model_events.rs +++ b/app/src/terminal/model_events.rs @@ -5,7 +5,7 @@ use warpui::{Entity, ModelContext, ModelHandle, SingletonEntity}; use super::event::{BootstrappedEvent, SshLoginStatus}; use super::model::ansi; -use super::model::ansi::FinishUpdateValue; +use super::model::ansi::{ExternalCtrlRSelectionValue, FinishUpdateValue}; use super::model::block::BlockId; use super::model::completions::ShellCompletion; use super::model::lifecycle::LifecycleTelemetryEvent; @@ -261,6 +261,7 @@ impl ModelEventDispatcher { Event::HonorPS1OutOfSync => ModelEvent::HonorPS1OutOfSync, Event::Typeahead => ModelEvent::Typeahead, Event::FinishUpdate(data) => ModelEvent::FinishUpdate(data), + Event::ExternalCtrlRSelection(data) => ModelEvent::ExternalCtrlRSelection(data), Event::TextSelectionChanged => ModelEvent::SelectedTextChanged, Event::ShellSpawned(shell_type) => ModelEvent::ShellSpawned(shell_type), Event::SendCompletionsPrompt => ModelEvent::SendCompletionsPrompt, @@ -446,6 +447,9 @@ pub enum ModelEvent { /// inaccessible to views/models. Handler(AnsiHandlerEvent), FinishUpdate(FinishUpdateValue), + /// Emitted when the shell reports the command selected in its external ctrl-r history + /// widget (e.g. fzf or atuin). + ExternalCtrlRSelection(ExternalCtrlRSelectionValue), SelectedTextChanged, ShellSpawned(ShellType), CompletionsFinished(Vec), diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index f807d9be0ed..994d5e158a2 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -714,6 +714,16 @@ pub const DEFAULT_ASK_AI_AUTOSUGGESTION_TEXT: &str = "What happened here?"; const WARP_MD_PATH: &str = "WARP.md"; +/// `shell_plugins` tag reported by bootstrap when the shell's `^R` binding has been rebound away +/// from its default reverse-history-search widget (e.g. by fzf or atuin). Must match the tag +/// name used in `app/assets/bundled/bootstrap/zsh_body.sh`. +const EXTERNAL_CTRL_R_HISTORY_PLUGIN_TAG: &str = "external_ctrl_r_history"; + +/// Name of the bootstrap-installed shell function invoked to hand ctrl-r off to the shell's +/// own external history widget. Must match the function name defined in +/// `app/assets/bundled/bootstrap/zsh_body.sh`. +const EXTERNAL_CTRL_R_HELPER_COMMAND: &str = "warp_run_external_ctrl_r_widget"; + 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"; @@ -9186,6 +9196,50 @@ impl TerminalView { && !model.is_read_only() } + /// If ctrl-r was pressed at an idle prompt on a session whose shell has rebound `^R` away + /// from its default reverse-history-search widget (reported via the + /// [`EXTERNAL_CTRL_R_HISTORY_PLUGIN_TAG`] shell plugin tag, e.g. by fzf or atuin), hands the + /// keypress off to that widget instead of opening Warp's own command search. + /// + /// The handoff runs a bootstrap-installed helper through the normal command-execution path + /// (as if the user had typed and submitted it), so the existing long-running-command + /// machinery hides the input editor and forwards keystrokes to the widget's PTY-driven UI. + /// The command the user selects (or the buffer they had before ctrl-r, if they cancel) is + /// restored into the input editor once the helper's block completes; see + /// [`Input::trigger_external_ctrl_r_history_search`] and + /// [`Input::set_external_ctrl_r_selection`]. + /// + /// Returns `true` if the handoff was triggered, in which case the caller should not open + /// Warp's command search. + pub fn maybe_trigger_external_ctrl_r_history_search( + &mut self, + ctx: &mut ViewContext, + ) -> bool { + if !FeatureFlag::FzfCtrlRHandoff.is_enabled() || self.is_long_running() { + return false; + } + let Some(session_id) = self.active_block_session_id() else { + return false; + }; + let has_external_ctrl_r_widget = + self.sessions + .as_ref(ctx) + .get(session_id) + .is_some_and(|session| { + session + .shell() + .plugins() + .contains(EXTERNAL_CTRL_R_HISTORY_PLUGIN_TAG) + }); + if !has_external_ctrl_r_widget || self.model.lock().is_alt_screen_active() { + return false; + } + + self.input.update(ctx, |input, ctx| { + input.trigger_external_ctrl_r_history_search(EXTERNAL_CTRL_R_HELPER_COMMAND, ctx) + }) + } + /// Returns `true` when an interactive SSH command has been detected at /// preexec and the SSH block is still running (long-running). Used by /// the workspace to derive `PendingRemoteSession` without storing @@ -12919,6 +12973,11 @@ impl TerminalView { log::warn!("Got a FinishUpdate event with non-matching update id!"); } } + ModelEvent::ExternalCtrlRSelection(data) => { + self.input.update(ctx, |input, _ctx| { + input.set_external_ctrl_r_selection(&data.buffer); + }); + } ModelEvent::SelectedTextChanged => { ctx.emit(Event::SelectedTextChanged); } diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index dcc6c5af757..24e34ecadab 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -17276,6 +17276,19 @@ impl Workspace { return; } + // If the active session's shell has rebound ctrl-r away from its default history + // search (e.g. to fzf or atuin), hand the keypress off to that widget 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_external_ctrl_r_history_search(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 fb4db69c7f8..e7a57fb9852 100644 --- a/crates/warp_features/src/lib.rs +++ b/crates/warp_features/src/lib.rs @@ -975,6 +975,12 @@ pub enum FeatureFlag { /// always forwarded unchanged and the harness process/sandbox are never /// signaled or torn down. CtrlCCancelsThirdPartyHarness, + + /// Prototype: when the active session's shell has rebound `^R` away from its default + /// reverse-history-search widget (e.g. to fzf or atuin, detected generically during + /// bootstrap and reported via the `external_ctrl_r_history` shell plugin tag), hands + /// ctrl-r off to that widget instead of opening Warp's own command search. + FzfCtrlRHandoff, } static FLAG_STATES: [AtomicBool; cardinality::()] = @@ -1049,6 +1055,7 @@ pub const DOGFOOD_FLAGS: &[FeatureFlag] = &[ FeatureFlag::BoxDrawingGlyphs, FeatureFlag::PricingTransparency, FeatureFlag::PeriodicHandoffCheckpoints, + FeatureFlag::FzfCtrlRHandoff, ]; /// Features enabled for feature preview build users (e.g.: Friends of Warp). From 44689b4d2ffeda05a1d6e67b359229989756efd1 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 01:47:55 +0000 Subject: [PATCH 02/70] Address review findings: narrow ctrl-r widget detection, verify handoff token, redact selection text, hide synthetic helper block - zsh_body.sh: narrow ctrl-r widget detection to exactly the tools warp_run_external_ctrl_r_widget can invoke (fzf/atuin), so an unsupported rebound widget falls through to Warp's normal command search instead of being tagged as invocable. Echo the handoff token back in the ExternalCtrlRSelection hook. - input.rs: require the ExternalCtrlRSelection hook's session_id and token to match an in-flight handoff before applying it, so an unsolicited or stale write to the pty can't alter the input editor. Hide the synthetic helper command's block once it completes so it doesn't clutter scrollback. - blocks.rs: add BlockList::hide_block, which hides a block and refreshes the block-heights sumtree so a completed (non-active) block's height update takes effect immediately. - event.rs / dcs_hooks.rs: redact the selected command text from Debug output in two places (Event::ExternalCtrlRSelection and ExternalCtrlRSelectionValue) so it can't leak into debug logs. --- app/assets/bundled/bootstrap/zsh_body.sh | 26 +++--- app/src/terminal/event.rs | 8 +- app/src/terminal/input.rs | 113 +++++++++++++++++++---- app/src/terminal/input_tests.rs | 79 ++++++++++++++++ app/src/terminal/model/ansi/dcs_hooks.rs | 20 +++- app/src/terminal/model/blocks.rs | 10 ++ app/src/terminal/model/blocks_tests.rs | 32 +++++++ app/src/terminal/view.rs | 10 +- 8 files changed, 260 insertions(+), 38 deletions(-) diff --git a/app/assets/bundled/bootstrap/zsh_body.sh b/app/assets/bundled/bootstrap/zsh_body.sh index af010bb35b5..b796377e27e 100644 --- a/app/assets/bundled/bootstrap/zsh_body.sh +++ b/app/assets/bundled/bootstrap/zsh_body.sh @@ -678,13 +678,15 @@ if [[ -z $WARP_BOOTSTRAPPED ]]; then # existing long-running-command machinery hides the input editor and forwards keystrokes to the # widget's PTY-driven UI. Reports the selected command (or an empty buffer, if cancelled) via # the ExternalCtrlRSelection hook so Warp can insert it into the input editor without executing - # it. + # it. The handoff token given as $1 is echoed back unchanged, so Warp can confirm the hook is + # actually the reply to the handoff it started rather than an unrelated write to the pty. # # We re-run each tool's own underlying picker command rather than invoking its zle widget # directly: those widgets rely on zle builtins (e.g. `zle vi-fetch-history`) that only work when # the widget is actually bound to a key and invoked through zle, not when called as a plain # command outside of that context. function warp_run_external_ctrl_r_widget () { + local warp_ctrl_r_token="$1" local result="" case "$_WARP_EXTERNAL_CTRL_R_WIDGET" in *fzf*) @@ -697,7 +699,8 @@ if [[ -z $WARP_BOOTSTRAPPED ]]; then ;; esac local warp_escaped_selection="$(warp_escape_json "$result")" - warp_send_json_message "{ \"hook\": \"ExternalCtrlRSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"session_id\": $WARP_SESSION_ID } }" + local warp_escaped_token="$(warp_escape_json "$warp_ctrl_r_token")" + warp_send_json_message "{ \"hook\": \"ExternalCtrlRSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"token\": \"$warp_escaped_token\", \"session_id\": $WARP_SESSION_ID } }" } function clear() { @@ -1346,22 +1349,19 @@ esac shell_plugins+=(vi) fi - # Detect whether ctrl-r has been rebound away from zsh's default reverse - # history search widgets (e.g. by fzf or atuin), so Warp can hand ctrl-r off - # to that widget at an idle prompt instead of opening Warp's own command - # search. Detection is intentionally generic -- any non-default widget -- - # rather than an allowlist of known tool names, so other ctrl-r history - # tools ride along for free. The widget name itself is only used locally - # (by warp_run_external_ctrl_r_widget below); only the generic tag is sent - # to the client. + # Detect whether ctrl-r has been rebound to fzf's or atuin's history widget, so Warp can + # hand ctrl-r off to it at an idle prompt instead of opening Warp's own command search. + # Detection is scoped to exactly the tools warp_run_external_ctrl_r_widget below knows how + # to invoke: invoking an arbitrary rebound widget outside of an active zle context isn't + # possible in general (see that function's comment), so tagging a tool we can't invoke would + # cost the user both Warp's command search and their own binding on every ctrl-r press. + # Adding another tool means adding it to both this pattern and the case below. _WARP_EXTERNAL_CTRL_R_WIDGET="" warp_ctrl_r_binding="$(bindkey -M main '^R' 2>/dev/null)" if [[ "$warp_ctrl_r_binding" == '"^R" '* ]]; then warp_ctrl_r_widget="${warp_ctrl_r_binding#\"^R\" }" case "$warp_ctrl_r_widget" in - history-incremental-search-backward|history-incremental-pattern-search-backward|undefined-key) - ;; - *) + *fzf*|*atuin*) _WARP_EXTERNAL_CTRL_R_WIDGET="$warp_ctrl_r_widget" shell_plugins+=(external_ctrl_r_history) ;; diff --git a/app/src/terminal/event.rs b/app/src/terminal/event.rs index 66c134a3c79..e72c750d3af 100644 --- a/app/src/terminal/event.rs +++ b/app/src/terminal/event.rs @@ -536,7 +536,13 @@ impl Debug for Event { } Event::FinishUpdate(data) => write!(f, "FinishUpdate({})", data.update_id), Event::ExternalCtrlRSelection(data) => { - write!(f, "ExternalCtrlRSelection(buffer: {:?})", data.buffer) + // The buffer is a selected shell command, which may carry a credential; log only + // its length rather than its contents. + write!( + f, + "ExternalCtrlRSelection(buffer_len: {})", + data.buffer.len() + ) } Event::TextSelectionChanged => write!(f, "TextSelectionChanged"), Event::ShellSpawned(shell_type) => write!(f, "ShellSpawned({shell_type:?})"), diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index 2a2e40b509b..419081050f2 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -56,6 +56,7 @@ use serde_json::json; use session_sharing_protocol::common::{AgentAttachment, ParticipantId, ServerConversationToken}; use settings::{Setting as _, ToggleableSetting}; use string_offset::{ByteOffset, CharOffset}; +use uuid::Uuid; use vec1::Vec1; use vim::vim::{VimHandler, VimMode}; use warp_cli::agent::Harness; @@ -1778,11 +1779,49 @@ pub struct Input { /// completes and the buffer would normally be cleared. input_contents_before_prompt_chip_command: Option, - /// Buffer contents to restore into the editor once the synthetic command started by - /// [`Self::trigger_external_ctrl_r_history_search`] completes and the buffer would - /// otherwise be cleared. Initially the buffer the user had before ctrl-r, overwritten with - /// the selected command by [`Self::set_external_ctrl_r_selection`] if the user accepts one. - pending_ctrl_r_handoff_restore_text: Option, + /// State for an in-flight external ctrl-r handoff started by + /// [`Self::trigger_external_ctrl_r_history_search`], if any. `None` once the handoff's block + /// has completed (see [`Self::handle_block_completed_event`]) or no handoff is in flight. + pending_ctrl_r_handoff: Option, +} + +/// State for an in-flight external ctrl-r handoff (see +/// [`Input::trigger_external_ctrl_r_history_search`]). `session_id` and `token` let +/// [`Input::set_external_ctrl_r_selection`] verify that an `ExternalCtrlRSelection` hook is +/// actually the reply to this handoff, rather than an unsolicited write to the pty (e.g. from an +/// unrelated command) or a stale reply to a handoff whose block has already completed. +struct PendingCtrlRHandoff { + session_id: SessionId, + token: String, + /// Text to restore into the editor when the handoff's block completes: the buffer the user + /// had before ctrl-r, or the selected command once a matching selection is applied. + restore_text: String, + /// The block running the synthetic helper command. Hidden once it completes (see + /// [`Input::handle_block_completed_event`]) so it doesn't clutter scrollback. + block_id: BlockId, +} + +impl PendingCtrlRHandoff { + /// Applies `selection` to `pending` if it matches an in-flight handoff for `session_id` and + /// `token`; otherwise leaves `pending` untouched. This covers both unsolicited selections (no + /// handoff was ever started, so `pending` is `None`) and stale ones (a reply to a handoff + /// whose block already completed -- clearing `pending` -- or to a different handoff). + fn maybe_apply_selection( + pending: &mut Option, + session_id: SessionId, + token: &str, + selection: &str, + ) { + let Some(handoff) = pending else { + return; + }; + if handoff.session_id != session_id || handoff.token != token { + return; + } + if !selection.is_empty() { + handoff.restore_text = selection.to_string(); + } + } } struct AmbientAgentViewState { @@ -4028,7 +4067,7 @@ impl Input { cloud_mode_composer_slash_command_data_source, ephemeral_message_model, input_contents_before_prompt_chip_command: None, - pending_ctrl_r_handoff_restore_text: None, + pending_ctrl_r_handoff: None, }; #[cfg(feature = "local_fs")] @@ -7507,31 +7546,53 @@ impl Input { } /// Runs `helper_command` (a bootstrap-installed shell function) as if the user had typed and - /// submitted it, having snapshotted the current buffer contents so they're restored once the - /// command's block completes -- unless [`Self::set_external_ctrl_r_selection`] supplies a - /// selected command in the meantime. Returns `true` if the command was started. + /// submitted it, passing a freshly generated handoff token as its argument and snapshotting + /// the current buffer contents so they're restored once the command's block completes -- + /// unless [`Self::set_external_ctrl_r_selection`] supplies a selected command in the + /// meantime. Returns `true` if the command was started. pub fn trigger_external_ctrl_r_history_search( &mut self, helper_command: &str, ctx: &mut ViewContext, ) -> bool { + let Some(session_id) = self.active_block_session_id() else { + return false; + }; let current_input = self.buffer_text(ctx); + let block_id = self.model.lock().block_list().active_block_id().clone(); + let token = Uuid::new_v4().to_string(); + let command = format!("{helper_command} {token}"); let started = - self.try_execute_command_from_source(helper_command, CommandExecutionSource::User, ctx); + self.try_execute_command_from_source(&command, CommandExecutionSource::User, ctx); if started { - self.pending_ctrl_r_handoff_restore_text = Some(current_input); + self.pending_ctrl_r_handoff = Some(PendingCtrlRHandoff { + session_id, + token, + restore_text: current_input, + block_id, + }); } started } /// Called when the shell reports the command selected in the external ctrl-r history search - /// (fzf/atuin). Overrides the buffer text that will be restored when the synthetic command's - /// block completes, so the selection lands in the editor instead of the pre-ctrl-r buffer. - /// A no-op if `selection` is empty (the user cancelled without selecting anything). - pub fn set_external_ctrl_r_selection(&mut self, selection: &str) { - if !selection.is_empty() { - self.pending_ctrl_r_handoff_restore_text = Some(selection.to_string()); - } + /// (fzf/atuin). Applies the selection only if `session_id` and `token` match an in-flight + /// handoff this session started (see [`PendingCtrlRHandoff`]); otherwise ignores it, including + /// unsolicited selections and stale replies to a handoff whose block already completed. A + /// no-op on an empty (but matching) `selection` -- the user cancelled without selecting + /// anything, so the previously snapshotted buffer stays queued for restoration. + pub fn set_external_ctrl_r_selection( + &mut self, + session_id: SessionId, + token: &str, + selection: &str, + ) { + PendingCtrlRHandoff::maybe_apply_selection( + &mut self.pending_ctrl_r_handoff, + session_id, + token, + selection, + ); } fn try_execute_command_with_options( @@ -15271,11 +15332,23 @@ impl Input { && !self.has_queued_command_in_flight(ctx); let latest_block_id = self.model.lock().block_list().active_block_id().clone(); // Prefer a prompt-chip restore (e.g. `cd`) over a ctrl-r handoff restore; the two - // cannot both be pending for the same block in practice. + // cannot both be pending for the same block in practice. Taking + // `pending_ctrl_r_handoff` here also ends that handoff: any `ExternalCtrlRSelection` + // hook that arrives after this point is treated as stale and ignored (see + // `PendingCtrlRHandoff`). + let completed_ctrl_r_handoff = self + .pending_ctrl_r_handoff + .take_if(|handoff| handoff.block_id == block_completed_event.block_id); + if let Some(handoff) = &completed_ctrl_r_handoff { + self.model + .lock() + .block_list_mut() + .hide_block(&handoff.block_id); + } let pending_input_restore = self .input_contents_before_prompt_chip_command .take() - .or_else(|| self.pending_ctrl_r_handoff_restore_text.take()); + .or_else(|| completed_ctrl_r_handoff.map(|handoff| handoff.restore_text)); if should_clear_buffer { // We want to reinitialize the buffer whenever a command is completed so that diff --git a/app/src/terminal/input_tests.rs b/app/src/terminal/input_tests.rs index 67fa86c0fcf..d99cb23f561 100644 --- a/app/src/terminal/input_tests.rs +++ b/app/src/terminal/input_tests.rs @@ -115,6 +115,85 @@ use crate::{ ReferralThemeStatus, experiments, }; +#[test] +fn external_ctrl_r_selection_matching_session_and_token_is_applied() { + let mut pending = Some(PendingCtrlRHandoff { + session_id: SessionId::from(1), + token: "tok-1".to_string(), + restore_text: "draft".to_string(), + block_id: BlockId::new(), + }); + PendingCtrlRHandoff::maybe_apply_selection( + &mut pending, + SessionId::from(1), + "tok-1", + "echo selected", + ); + assert_eq!(pending.unwrap().restore_text, "echo selected"); +} + +#[test] +fn unsolicited_external_ctrl_r_selection_without_a_pending_handoff_is_ignored() { + // No handoff was ever started (e.g. a stray write to the pty unrelated to ctrl-r): there's + // nothing to apply the selection to, and no handoff gets created. + let mut pending: Option = None; + PendingCtrlRHandoff::maybe_apply_selection( + &mut pending, + SessionId::from(1), + "tok-1", + "echo selected", + ); + assert!(pending.is_none()); +} + +#[test] +fn stale_external_ctrl_r_selection_with_mismatched_token_is_ignored() { + let mut pending = Some(PendingCtrlRHandoff { + session_id: SessionId::from(1), + token: "tok-1".to_string(), + restore_text: "draft".to_string(), + block_id: BlockId::new(), + }); + PendingCtrlRHandoff::maybe_apply_selection( + &mut pending, + SessionId::from(1), + "some-other-token", + "echo selected", + ); + assert_eq!(pending.unwrap().restore_text, "draft"); +} + +#[test] +fn stale_external_ctrl_r_selection_with_mismatched_session_is_ignored() { + let mut pending = Some(PendingCtrlRHandoff { + session_id: SessionId::from(1), + token: "tok-1".to_string(), + restore_text: "draft".to_string(), + block_id: BlockId::new(), + }); + PendingCtrlRHandoff::maybe_apply_selection( + &mut pending, + SessionId::from(2), + "tok-1", + "echo selected", + ); + assert_eq!(pending.unwrap().restore_text, "draft"); +} + +#[test] +fn cancelled_external_ctrl_r_selection_with_empty_buffer_keeps_original_draft() { + // An empty buffer means the handoff matched but the user cancelled without selecting + // anything, so the originally snapshotted draft text must be preserved, not cleared. + let mut pending = Some(PendingCtrlRHandoff { + session_id: SessionId::from(1), + token: "tok-1".to_string(), + restore_text: "draft".to_string(), + block_id: BlockId::new(), + }); + PendingCtrlRHandoff::maybe_apply_selection(&mut pending, SessionId::from(1), "tok-1", ""); + assert_eq!(pending.unwrap().restore_text, "draft"); +} + #[test] fn renders_git_checkout_prompt_chip_command_as_single_shell_argument() { let command = PromptChipShellCommand::GitCheckout { diff --git a/app/src/terminal/model/ansi/dcs_hooks.rs b/app/src/terminal/model/ansi/dcs_hooks.rs index 942f3ff3d25..b1da781ebd7 100644 --- a/app/src/terminal/model/ansi/dcs_hooks.rs +++ b/app/src/terminal/model/ansi/dcs_hooks.rs @@ -1001,13 +1001,31 @@ pub struct InputBufferValue { /// detected via the `external_ctrl_r_history` [`BootstrappedValue::shell_plugins`] tag) 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. -#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize)] +/// +/// `token` echoes back the handoff token the client sent as an argument to the shell helper that +/// emits this hook, so the client can verify this is the reply to a handoff it's actually +/// waiting on rather than an unsolicited or stale write to the pty. +#[derive(Default, Clone, PartialEq, Eq, Deserialize, Serialize)] pub struct ExternalCtrlRSelectionValue { pub buffer: String, #[serde(default)] + pub token: String, + #[serde(default)] pub session_id: HookSessionId, } +impl std::fmt::Debug for ExternalCtrlRSelectionValue { + /// 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("ExternalCtrlRSelectionValue") + .field("buffer", &"") + .field("token", &self.token) + .field("session_id", &self.session_id) + .finish() + } +} + /// Received from the pty when the terminal screen should be cleared (e.g. via /// the `clear` command or ctrl-l). #[derive(Debug, Default, Deserialize, Serialize)] diff --git a/app/src/terminal/model/blocks.rs b/app/src/terminal/model/blocks.rs index 88acfcb27b2..56f2da5d25b 100644 --- a/app/src/terminal/model/blocks.rs +++ b/app/src/terminal/model/blocks.rs @@ -1420,6 +1420,16 @@ impl BlockList { } } + /// Hides the given (possibly already-completed) block and refreshes the block heights + /// sumtree so the change is reflected immediately, even for a historical block whose height + /// entry was already committed to the sumtree. + pub fn hide_block(&mut self, block_id: &BlockId) { + if let Some(block) = self.mut_block_from_id(block_id) { + block.hide(); + self.update_blocks_and_sumtree(None, None, |_| {}, |_| {}); + } + } + pub fn is_executing_oz_environment_startup_commands(&self) -> bool { self.is_executing_oz_environment_startup_commands } diff --git a/app/src/terminal/model/blocks_tests.rs b/app/src/terminal/model/blocks_tests.rs index 1ac524acf49..b0d0bc27cb3 100644 --- a/app/src/terminal/model/blocks_tests.rs +++ b/app/src/terminal/model/blocks_tests.rs @@ -1155,6 +1155,38 @@ pub fn test_first_non_hidden_block_by_index_in_range() { ); } +#[test] +fn test_hide_block_zeroes_height_for_a_completed_block() { + // Regression test: hiding an already-completed (non-active) block must update its cached + // height in the block heights sumtree immediately, not just the block's own `hidden` flag -- + // otherwise the block would keep occupying space in the rendered blocklist. + let mut block_list = + new_bootstrapped_block_list(None, None, ChannelEventListener::new_for_test()); + + let block_index = insert_block(&mut block_list, "echo hi", "hi"); + let block_id = block_list.block_at(block_index).unwrap().id().clone(); + let transcript_scope = *block_list.transcript_scope(); + + assert!( + block_list + .block_at(block_index) + .unwrap() + .height(&transcript_scope) + > Lines::zero() + ); + + block_list.hide_block(&block_id); + + assert!(block_list.block_with_id(&block_id).unwrap().is_hidden()); + + let mut cursor = block_list.block_heights().cursor::(); + cursor.seek(&(block_index + BlockIndex(1)), SeekBias::Left); + assert_eq!( + cursor.item(), + Some(&BlockHeightItem::Block(BlockHeight::zero())) + ); +} + #[test] fn test_matching_block_by_index() { let mut block_list = diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index 994d5e158a2..6455112d56f 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -12974,9 +12974,13 @@ impl TerminalView { } } ModelEvent::ExternalCtrlRSelection(data) => { - self.input.update(ctx, |input, _ctx| { - input.set_external_ctrl_r_selection(&data.buffer); - }); + if FeatureFlag::FzfCtrlRHandoff.is_enabled() + && let Some(session_id) = data.session_id.map(SessionId::from) + { + self.input.update(ctx, |input, _ctx| { + input.set_external_ctrl_r_selection(session_id, &data.token, &data.buffer); + }); + } } ModelEvent::SelectedTextChanged => { ctx.emit(Event::SelectedTextChanged); From 166d261439109538525e485dd7643059f43ffd8c Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 01:58:14 +0000 Subject: [PATCH 03/70] Add bash support for ctrl-r external history handoff (fzf) and fix shell_plugins reporting - Detect ctrl-r rebound to fzf's __fzf_history__ via bind -X and tag external_ctrl_r_history in shell_plugins, matching the pattern already used for zsh. atuin is intentionally not supported for bash: its integration binds ctrl-r indirectly through intermediate key sequences and depends on the readline widget-chain machinery, so it can't be invoked standalone the way __fzf_history__ can. - Add warp_run_external_ctrl_r_widget for bash, mirroring the zsh helper. - Fix a pre-existing bug where shell_plugins was computed but never actually included in bash's primary (non-MSYS2) Bootstrapped JSON payload, and where the bash array was collapsed to its first element instead of being joined into the newline-separated list the client expects. --- app/assets/bundled/bootstrap/bash_body.sh | 55 +++++++++++++++++++++-- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/app/assets/bundled/bootstrap/bash_body.sh b/app/assets/bundled/bootstrap/bash_body.sh index b90ecd351ee..78589546406 100644 --- a/app/assets/bundled/bootstrap/bash_body.sh +++ b/app/assets/bundled/bootstrap/bash_body.sh @@ -799,6 +799,31 @@ if [ -z "$WARP_BOOTSTRAPPED" ]; then READLINE_LINE="" } + # Runs the shell's own ctrl-r history widget (fzf, per the function captured in + # $_WARP_EXTERNAL_CTRL_R_WIDGET during bootstrap) as a synthetic foreground command, so Warp's + # existing long-running-command machinery hides the input editor and forwards keystrokes to + # the widget's PTY-driven UI. Reports the selected command (or an empty buffer, if cancelled) + # via the ExternalCtrlRSelection hook so Warp can insert it into the input editor without + # executing it. The handoff token given as $1 is echoed back unchanged, so Warp can confirm + # the hook is actually the reply to the handoff it started rather than an unrelated write to + # the pty. + warp_run_external_ctrl_r_widget () { + local warp_ctrl_r_token="$1" + local result="" + case "$_WARP_EXTERNAL_CTRL_R_WIDGET" in + *fzf*) + # __fzf_history__ (installed by fzf's bash integration) normally writes the selection + # into $READLINE_LINE via `bind -x`, using $READLINE_POINT as a sentinel for that + # mode. Called here outside of that context, it echoes the selection to stdout + # instead -- see fzf's own fallback for that case. + result="$(__fzf_history__)" + ;; + esac + local warp_escaped_selection="$(warp_escape_json "$result")" + local warp_escaped_token="$(warp_escape_json "$warp_ctrl_r_token")" + warp_send_json_message "{ \"hook\": \"ExternalCtrlRSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"token\": \"$warp_escaped_token\", \"session_id\": $WARP_SESSION_ID } }" + } + # Check whether the prompt-related variables have OSC prompt marker sequences, # and if not, wrap them with the appropriate markers so that we can direct the # prompt bytes to the appropriate grids. @@ -1357,6 +1382,25 @@ esac shell_plugins=() + # Detect whether ctrl-r has been rebound to fzf's bash history widget via `bind -x`, so Warp + # can hand ctrl-r off to it at an idle prompt instead of opening Warp's own command search. + # Detection is scoped to exactly the tool warp_run_external_ctrl_r_widget above knows how to + # invoke: fzf's __fzf_history__ works standalone outside of readline context (see that + # function's comment). atuin's bash integration binds ctrl-r indirectly through intermediate + # key sequences and depends on the readline widget-chain machinery, so it can't be invoked + # the same way and is intentionally not tagged here. Also not supported under MSYS2 (Git Bash + # on Windows), where ctrl-r always falls through to Warp's own command search. + _WARP_EXTERNAL_CTRL_R_WIDGET="" + if [ "$WARP_IN_MSYS2" = false ]; then + warp_ctrl_r_binding="$(bind -X 2>/dev/null | command -p sed -n 's/^"\\C-r": "\(.*\)"$/\1/p')" + case "$warp_ctrl_r_binding" in + *fzf*) + _WARP_EXTERNAL_CTRL_R_WIDGET="$warp_ctrl_r_binding" + shell_plugins+=(external_ctrl_r_history) + ;; + esac + fi + function warp_bootstrapped () { local aliases="`alias`" local env_var_names="`compgen -e`" @@ -1388,8 +1432,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 +1461,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 +1476,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 } From c759cca8215f946e1187206cb9aa85861ab5bf6c Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:28:53 +0000 Subject: [PATCH 04/70] Fix hide_block to actually trigger a re-draw update_blocks_and_sumtree doesn't send a wakeup event itself (its other callers are driven by a GPUI context that already notifies the right view), so the synthetic ctrl-r helper block stayed visible after completion despite its height being zeroed in the sumtree. Add the same explicit send_wakeup_event() call unhide_block uses. Verified with computer use: the helper block no longer appears in scrollback after either cancelling or selecting a history entry. --- app/src/terminal/model/blocks.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/terminal/model/blocks.rs b/app/src/terminal/model/blocks.rs index 56f2da5d25b..6c8a1d95003 100644 --- a/app/src/terminal/model/blocks.rs +++ b/app/src/terminal/model/blocks.rs @@ -1426,8 +1426,15 @@ impl BlockList { pub fn hide_block(&mut self, block_id: &BlockId) { if let Some(block) = self.mut_block_from_id(block_id) { block.hide(); - self.update_blocks_and_sumtree(None, None, |_| {}, |_| {}); + } else { + return; } + self.update_blocks_and_sumtree(None, None, |_| {}, |_| {}); + + // update_blocks_and_sumtree doesn't itself trigger a re-draw (its other callers are + // driven by a GPUI context that already notifies the right view), so force one here, + // matching unhide_block and friends. + self.event_proxy.send_wakeup_event(); } pub fn is_executing_oz_environment_startup_commands(&self) -> bool { From 29028e125d5802cc2dfea343768c7d9809936490 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:36:07 +0000 Subject: [PATCH 05/70] Fix atuin ctrl-r invocation for zsh and add atuin support for bash zsh: atuin's own zsh integration swaps stdout/stderr through fd 3 before invoking 'atuin search -i', because atuin writes its TUI to stdout and its cursor-position query has nothing to answer it under plain command substitution (stdout is a pipe), causing it to bail during startup with a blank list. The bare invocation previously used here hit exactly that failure. Also strip the '__atuin_accept__:' prefix atuin uses to signal that enter_accept fired, since we only ever want the selection, never to run it. bash: apply the same fd-3 fix, invoking 'atuin search' directly rather than atuin's own key-binding machinery. Detection needed a different approach than fzf's: atuin's bash integration binds ctrl-r through an intermediate key sequence and a widget-index dispatcher rather than a direct 'bind -x' on ctrl-r itself, so it doesn't show up in the 'bind -X' scan used for fzf. Detect it instead via atuin's own signal for whether it bound ctrl-r () plus confirming its integration is loaded. This doesn't affect invocation, which bypasses atuin's key-binding machinery entirely either way. --- app/assets/bundled/bootstrap/bash_body.sh | 46 +++++++++++++++++++---- app/assets/bundled/bootstrap/zsh_body.sh | 11 +++++- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/app/assets/bundled/bootstrap/bash_body.sh b/app/assets/bundled/bootstrap/bash_body.sh index 78589546406..a5d9bfc2a95 100644 --- a/app/assets/bundled/bootstrap/bash_body.sh +++ b/app/assets/bundled/bootstrap/bash_body.sh @@ -818,6 +818,23 @@ if [ -z "$WARP_BOOTSTRAPPED" ]; then # instead -- see fzf's own fallback for that case. result="$(__fzf_history__)" ;; + *atuin*) + # Bypass atuin's own bash key-binding machinery entirely (which chains through + # intermediate key sequences and a widget dispatcher -- see the detection comment + # below) and invoke the underlying `atuin search` command directly, exactly as + # atuin's own integration does (__atuin_search_cmd's non-tmux branch). + # + # atuin writes its TUI to stdout; under plain command substitution that's a pipe, + # and its cursor-position query (\x1b[6n) has nothing to answer it, so it bails + # during startup. Swap stdout/stderr through fd 3 so the TUI reaches the tty while + # the selection is still captured via command substitution. + result="$(ATUIN_SHELL=bash atuin search -i 3>&1 1>&2 2>&3 3>&-)" + # If the user has atuin's enter_accept config on, Enter both selects and runs the + # command, signaled by this prefix; we only ever want the selection, never to run + # it, so strip the prefix in both cases (see atuin's __atuin_history for the same + # check). + result="${result#__atuin_accept__:}" + ;; esac local warp_escaped_selection="$(warp_escape_json "$result")" local warp_escaped_token="$(warp_escape_json "$warp_ctrl_r_token")" @@ -1382,14 +1399,23 @@ esac shell_plugins=() - # Detect whether ctrl-r has been rebound to fzf's bash history widget via `bind -x`, so Warp + # Detect whether ctrl-r has been rebound to fzf's or atuin's bash history widget, so Warp # can hand ctrl-r off to it at an idle prompt instead of opening Warp's own command search. - # Detection is scoped to exactly the tool warp_run_external_ctrl_r_widget above knows how to - # invoke: fzf's __fzf_history__ works standalone outside of readline context (see that - # function's comment). atuin's bash integration binds ctrl-r indirectly through intermediate - # key sequences and depends on the readline widget-chain machinery, so it can't be invoked - # the same way and is intentionally not tagged here. Also not supported under MSYS2 (Git Bash - # on Windows), where ctrl-r always falls through to Warp's own command search. + # Not supported under MSYS2 (Git Bash on Windows), where ctrl-r always falls through to + # Warp's own command search. + # + # fzf binds ctrl-r directly via `bind -x`, so `bind -X` reports it verbatim (e.g. + # `"\C-r": "__fzf_history__"`); the sed below extracts the bound command's name. + # + # atuin's binding is not that simple: it goes through an intermediate key sequence and a + # widget-index dispatcher (`bind '"\C-r": "\C-x\C-_A1\a..."'` plus a separate + # `bind -x '"\C-x\C-_A1\a": __atuin_widget_run 0'`) rather than a direct `-x` bind on + # `\C-r` itself, so it never shows up in the `bind -X` scan above. We detect it instead via + # atuin's own signal for whether it actually bound ctrl-r (`$__atuin_bind_ctrl_r`, set by + # `atuin init bash`) plus confirming its integration is loaded. Either way, + # warp_run_external_ctrl_r_widget above never invokes atuin's key-binding machinery -- it + # calls the underlying `atuin search` command directly, so how ctrl-r itself is wired up + # doesn't matter for invocation, only for detection. _WARP_EXTERNAL_CTRL_R_WIDGET="" if [ "$WARP_IN_MSYS2" = false ]; then warp_ctrl_r_binding="$(bind -X 2>/dev/null | command -p sed -n 's/^"\\C-r": "\(.*\)"$/\1/p')" @@ -1398,6 +1424,12 @@ esac _WARP_EXTERNAL_CTRL_R_WIDGET="$warp_ctrl_r_binding" shell_plugins+=(external_ctrl_r_history) ;; + *) + if [[ "$__atuin_bind_ctrl_r" == "true" ]] && declare -F __atuin_history >/dev/null 2>&1; then + _WARP_EXTERNAL_CTRL_R_WIDGET="atuin" + shell_plugins+=(external_ctrl_r_history) + fi + ;; esac fi diff --git a/app/assets/bundled/bootstrap/zsh_body.sh b/app/assets/bundled/bootstrap/zsh_body.sh index b796377e27e..629d8629a8c 100644 --- a/app/assets/bundled/bootstrap/zsh_body.sh +++ b/app/assets/bundled/bootstrap/zsh_body.sh @@ -695,7 +695,16 @@ if [[ -z $WARP_BOOTSTRAPPED ]]; then | fzf --scheme=history --tiebreak=index +m)" ;; *atuin*) - result="$(atuin search -i)" + # atuin writes its TUI to stdout; under plain command substitution that's a pipe, and + # its cursor-position query (\x1b[6n) has nothing to answer it, so it bails during + # startup. Swap stdout/stderr through fd 3, matching atuin's own zsh integration + # (_atuin_search -> __atuin_search_cmd), so the TUI reaches the tty while the + # selection is still captured via command substitution. + result="$(ATUIN_SHELL=zsh atuin search -i 3>&1 1>&2 2>&3 3>&-)" + # If the user has atuin's enter_accept config on, Enter both selects and runs the + # command, signaled by this prefix; we only ever want the selection, never to run it, + # so strip the prefix in both cases (see atuin's _atuin_search for the same check). + result="${result#__atuin_accept__:}" ;; esac local warp_escaped_selection="$(warp_escape_json "$result")" From 0c7bf379224ffd83bd8304e7693091aa1a695456 Mon Sep 17 00:00:00 2001 From: Warp Factory Date: Tue, 25 Aug 2026 02:23:56 +0000 Subject: [PATCH 06/70] Spike: fish shell_plugins plumbing + external ctrl-r detection/helper (CORE-3807) Adds to the fish bootstrap the pieces fish needs for the ctrl-r handoff: - warp_external_ctrl_r_widget: generic detection of a rebound ^R via `bind \cr`, treating any non---preset binding as a user/plugin rebinding. - shell_plugins in the Bootstrapped hook, newline-separated like bash/zsh, carrying the external_ctrl_r_history tag. The client already parses this field for every shell, so no client change is needed. - warp_run_external_ctrl_r_widget: runs fzf's or atuin's picker as a plain foreground command and reports the selection over the ExternalCtrlRSelection DCS hook, never touching fish's `commandline` line buffer. --- app/assets/bundled/bootstrap/fish.sh | 85 +++++++++++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/app/assets/bundled/bootstrap/fish.sh b/app/assets/bundled/bootstrap/fish.sh index 4d36d36a7e6..4a0be8e23ec 100644 --- a/app/assets/bundled/bootstrap/fish.sh +++ b/app/assets/bundled/bootstrap/fish.sh @@ -504,6 +504,80 @@ function warp_escape_json string join \n $argv | command sed -E 's/(["\\\\])/\\\\\\1/g; s/'\b'/\\\\b/g; s/'\t'/\\\\t/g; s/'\f'/\\\\f/g; s/'\r'/\\\\r/g; $!s/$/\\\\n/' | command tr -d '\n' end +# Reports the widget `^R` is bound to, if the user has rebound it away from fish's own +# history search. Returns non-zero when `^R` is still on a fish default. +# +# `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. Detection is deliberately generic: we +# report the widget name and let Warp decide what it knows how to do with it. +function warp_external_ctrl_r_widget + # fish >= 4.0 renamed key specifications, so `bind` echoes back `ctrl-r` where earlier + # versions echo `\cr`. Both spellings are accepted as input by every supported version, + # so query with the older one and match either in the output. + set -l widget "" + for binding in (bind \cr 2>/dev/null) + if string match --quiet -- 'bind --preset *' "$binding" + continue + end + # Strip the leading `bind [-M ] `, leaving just the widget/command. + set widget (string replace --regex -- '^bind (-M \S+ +)?\S+ +' '' "$binding") + end + test -n "$widget"; or return 1 + echo "$widget" +end + +# Runs the shell's own ctrl-r history tool (fzf or atuin, per the widget name captured in +# $_WARP_EXTERNAL_CTRL_R_WIDGET during bootstrap) as a synthetic foreground command, so Warp's +# existing long-running-command machinery hides the input editor and forwards keystrokes to the +# tool's PTY-driven UI. Reports the selected command (or an empty buffer, if cancelled) via the +# ExternalCtrlRSelection hook so Warp can insert it into the input editor without executing it. +# The handoff token given as $argv[1] is echoed back unchanged, so Warp can confirm the hook is +# the reply to the handoff it started rather than an unrelated write to the pty. +# +# We re-run each tool's own underlying picker rather than invoking its bound fish function: those +# functions write the selection into fish's line buffer with `commandline`, which would leave the +# text queued for execution in the shell rather than handing it to Warp's editor. +function warp_run_external_ctrl_r_widget + set -l warp_ctrl_r_token "$argv[1]" + set -l result "" + switch "$_WARP_EXTERNAL_CTRL_R_WIDGET" + case '*fzf*' + # fzf's fish integration reads history through the shell rather than a history file, so + # this mirrors the pipeline fzf-history-widget builds, minus its `commandline` calls. + set -lx FZF_DEFAULT_OPTS (__fzf_defaults '' \ + '--nth=2..,.. --scheme=history --wrap-sign="\t↳ "' \ + "--bind=ctrl-r:toggle-sort --highlight-line $FZF_CTRL_R_OPTS" \ + '--accept-nth=2.. --read0 --print0 --with-shell='(status fish-path)\\ -c) + set -lx FZF_DEFAULT_OPTS_FILE + set -lx FZF_DEFAULT_COMMAND + if type -q perl + set -a FZF_DEFAULT_OPTS '--tac' + set FZF_DEFAULT_COMMAND 'builtin history -z --reverse | command perl -0 -pe \'s/^/$.\t/g; s/\n/\n\t/gm\'' + else + set FZF_DEFAULT_COMMAND \ + 'set -l h (builtin history -z --reverse | string split0);' \ + 'for i in (seq (count $h) -1 1);' \ + 'string join0 -- $i\t(string replace -a -- \n \n\t $h[$i] | string collect);' \ + 'end' + end + test -z "$fish_private_mode"; and builtin history merge + set -l selected + if set selected (eval $FZF_DEFAULT_COMMAND \| (__fzfcmd) | string split0) + set result (string replace -a -- \n\t \n $selected[1]) + end + case '*atuin*' + # atuin writes its TUI to stdout and the selection to fd 3, so the two are swapped here to + # leave the UI on the terminal and capture only the selection. + set -l output (ATUIN_SHELL_FISH=t ATUIN_LOG=error atuin search -i 3>&1 1>&2 2>&3 | string collect) + # atuin prefixes the selection with __atuin_accept__: when `enter_accept` is on and the + # user pressed enter. Warp always inserts without executing, so the prefix is dropped. + set result (string replace "__atuin_accept__:" "" -- "$output" | string collect) + end + set -l warp_escaped_selection (warp_escape_json "$result") + set -l warp_escaped_token (warp_escape_json "$warp_ctrl_r_token") + warp_send_json_message "{ \"hook\": \"ExternalCtrlRSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"token\": \"$warp_escaped_token\", \"session_id\": $WARP_SESSION_ID } }" +end + function warp_bootstrapped set -l histfile_directory set histfile_directory "$XDG_DATA_HOME" @@ -517,6 +591,15 @@ function warp_bootstrapped set vi_mode_enabled "1" end + # Tags for shell configurations Warp needs to know about, matching the `shell_plugins` + # list bash and zsh already report. Newline-separated, one tag per line. + set -l shell_plugins + set -g _WARP_EXTERNAL_CTRL_R_WIDGET (warp_external_ctrl_r_widget) + if test -n "$_WARP_EXTERNAL_CTRL_R_WIDGET" + set -a shell_plugins external_ctrl_r_history + end + set -l escaped_shell_plugins (warp_escape_json $shell_plugins) + set -l kernel_name (uname) if test -n "$kernel_name" if [ "$kernel_name" = "Darwin" ] @@ -546,7 +629,7 @@ function warp_bootstrapped # part of its builtins (e.g. "for", "while", etc.). set -l escaped_editor (warp_escape_json "$EDITOR") set -l escaped_shell_path (warp_escape_json (status fish-path)) - set -l escaped_json "{\"hook\": \"Bootstrapped\", \"value\": {\"histfile\": \"$escaped_histfile\", \"session_id\": $WARP_SESSION_ID, \"shell\": \"fish\", \"home_dir\": \"$HOME\", \"path\": \"$PATH\", \"editor\": \"$escaped_editor\", \"abbreviations\": \"$escaped_abbr\", \"aliases\": \"$escaped_aliases\", \"function_names\": \"$function_names\", \"env_var_names\": \"$env_var_names\", \"builtins\": \"$escaped_builtins\", \"keywords\": \"\", \"shell_version\": \"$FISH_VERSION\", \"vi_mode_enabled\": \"$vi_mode_enabled\", \"os_category\": \"$os_category\", \"linux_distribution\": \"$linux_distribution\", \"wsl_name\": \"$WSL_DISTRO_NAME\", \"shell_path\": \"$escaped_shell_path\"}}" + set -l escaped_json "{\"hook\": \"Bootstrapped\", \"value\": {\"histfile\": \"$escaped_histfile\", \"session_id\": $WARP_SESSION_ID, \"shell\": \"fish\", \"home_dir\": \"$HOME\", \"path\": \"$PATH\", \"editor\": \"$escaped_editor\", \"abbreviations\": \"$escaped_abbr\", \"aliases\": \"$escaped_aliases\", \"function_names\": \"$function_names\", \"env_var_names\": \"$env_var_names\", \"builtins\": \"$escaped_builtins\", \"keywords\": \"\", \"shell_version\": \"$FISH_VERSION\", \"shell_plugins\": \"$escaped_shell_plugins\", \"vi_mode_enabled\": \"$vi_mode_enabled\", \"os_category\": \"$os_category\", \"linux_distribution\": \"$linux_distribution\", \"wsl_name\": \"$WSL_DISTRO_NAME\", \"shell_path\": \"$escaped_shell_path\"}}" warp_send_json_message $escaped_json end From a57ffb6f75b865f14da6e16650d6a8427a074d2e Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:04:56 +0000 Subject: [PATCH 07/70] Fix ctrl-r widget detection to use exact allowlist and live bash binding Third finding: detection matched any ctrl-r binding whose widget/command name merely contained "fzf" or "atuin" (zsh/bash/fish). An RC can legitimately bind ctrl-r to an unrelated fzf/atuin-flavored widget that isn't the history search we know how to invoke, which would hijack that binding and lose Warp's own command search too. Replace substring matching with an exact allowlist of each integration's canonical widget/function name in all three shells' detection and invocation dispatch. Fourth finding: bash's atuin detection relied on $__atuin_bind_ctrl_r, a flag set once during atuin's own init that a later `bind` in the RC can leave stale. Read the live `bind -X` binding for ctrl-r instead, matching only the exact __fzf_history__/__atuin_history function names. Newer atuin bash versions bind ctrl-r through an indirect widget-index dispatcher that isn't reliably distinguishable from an arbitrary user macro via bind -X alone; per the reviewer's explicit guidance we decline the handoff in that case (biasing to false negatives) rather than risk hijacking a rebound key. Also propagate the ctrl-r helper's history-exclusion (already present for zsh) to bash (HISTIGNORE) and fish (a composing fish_should_add_to_history wrapper), so the synthetic handoff invocation doesn't pollute the very history list this feature searches on the next ctrl-r. --- app/assets/bundled/bootstrap/bash_body.sh | 57 ++++++++++++----------- app/assets/bundled/bootstrap/fish.sh | 42 ++++++++++++++--- app/assets/bundled/bootstrap/zsh_body.sh | 17 +++---- 3 files changed, 73 insertions(+), 43 deletions(-) diff --git a/app/assets/bundled/bootstrap/bash_body.sh b/app/assets/bundled/bootstrap/bash_body.sh index a5d9bfc2a95..6bc3efde95a 100644 --- a/app/assets/bundled/bootstrap/bash_body.sh +++ b/app/assets/bundled/bootstrap/bash_body.sh @@ -799,7 +799,7 @@ if [ -z "$WARP_BOOTSTRAPPED" ]; then READLINE_LINE="" } - # Runs the shell's own ctrl-r history widget (fzf, per the function captured in + # Runs the shell's own ctrl-r history widget (fzf or atuin, per the function captured in # $_WARP_EXTERNAL_CTRL_R_WIDGET during bootstrap) as a synthetic foreground command, so Warp's # existing long-running-command machinery hides the input editor and forwards keystrokes to # the widget's PTY-driven UI. Reports the selected command (or an empty buffer, if cancelled) @@ -811,18 +811,17 @@ if [ -z "$WARP_BOOTSTRAPPED" ]; then local warp_ctrl_r_token="$1" local result="" case "$_WARP_EXTERNAL_CTRL_R_WIDGET" in - *fzf*) + __fzf_history__) # __fzf_history__ (installed by fzf's bash integration) normally writes the selection # into $READLINE_LINE via `bind -x`, using $READLINE_POINT as a sentinel for that # mode. Called here outside of that context, it echoes the selection to stdout # instead -- see fzf's own fallback for that case. result="$(__fzf_history__)" ;; - *atuin*) - # Bypass atuin's own bash key-binding machinery entirely (which chains through - # intermediate key sequences and a widget dispatcher -- see the detection comment - # below) and invoke the underlying `atuin search` command directly, exactly as - # atuin's own integration does (__atuin_search_cmd's non-tmux branch). + __atuin_history) + # Bypass atuin's own bash key-binding machinery entirely and invoke the underlying + # `atuin search` command directly, exactly as atuin's own integration does + # (__atuin_search_cmd's non-tmux branch). # # atuin writes its TUI to stdout; under plain command substitution that's a pipe, # and its cursor-position query (\x1b[6n) has nothing to answer it, so it bails @@ -1278,13 +1277,18 @@ esac # rcfiles. USER_HISTCONTROL="$HISTCONTROL" - # Add a pattern to ignore in-band commands in shell history, while preserving the user's + # Add patterns to ignore in-band commands in shell history, while preserving the user's # HISTIGNORE value which may been set in an RC file sourced above. It is important to # ensure that this happens _after_ the user's RC files have been sourced. + # + # This also excludes the ctrl-r external history handoff helper (see + # warp_run_external_ctrl_r_widget above): it's a Warp-internal invocation, not a command the + # user meant to run again later, and leaving it in history would otherwise pollute the very + # history list this feature searches on the next ctrl-r. if [[ ! -z $HISTIGNORE ]]; then - HISTIGNORE="*warp_run_generator_command*:$HISTIGNORE" + HISTIGNORE="*warp_run_generator_command*:*warp_run_external_ctrl_r_widget*:$HISTIGNORE" else - HISTIGNORE="*warp_run_generator_command*" + HISTIGNORE="*warp_run_generator_command*:*warp_run_external_ctrl_r_widget*" fi # If the user has PROMPT_COMMAND set in their bootstrap scripts, @@ -1404,32 +1408,29 @@ esac # Not supported under MSYS2 (Git Bash on Windows), where ctrl-r always falls through to # Warp's own command search. # - # fzf binds ctrl-r directly via `bind -x`, so `bind -X` reports it verbatim (e.g. - # `"\C-r": "__fzf_history__"`); the sed below extracts the bound command's name. + # Both fzf and (older versions of) atuin bind ctrl-r directly via `bind -x`, so `bind -X` + # reports it verbatim (e.g. `"\C-r": "__fzf_history__"`); the sed below extracts the bound + # command's name. Match against an exact allowlist of each integration's canonical bound + # function name -- not merely a name containing "fzf" or "atuin" -- since an RC can + # legitimately bind ctrl-r to an unrelated fzf- or atuin-flavored command that isn't the + # history search warp_run_external_ctrl_r_widget below knows how to invoke; rerouting that to + # the hard-coded history picker would cost the user both their own binding and Warp's command + # search. Reading this straight from `bind -X` also means detection reflects whatever ctrl-r + # is actually bound to at the end of RC processing, rather than a flag atuin's own init set + # earlier that a later `bind` in the RC can leave stale. # - # atuin's binding is not that simple: it goes through an intermediate key sequence and a - # widget-index dispatcher (`bind '"\C-r": "\C-x\C-_A1\a..."'` plus a separate - # `bind -x '"\C-x\C-_A1\a": __atuin_widget_run 0'`) rather than a direct `-x` bind on - # `\C-r` itself, so it never shows up in the `bind -X` scan above. We detect it instead via - # atuin's own signal for whether it actually bound ctrl-r (`$__atuin_bind_ctrl_r`, set by - # `atuin init bash`) plus confirming its integration is loaded. Either way, - # warp_run_external_ctrl_r_widget above never invokes atuin's key-binding machinery -- it - # calls the underlying `atuin search` command directly, so how ctrl-r itself is wired up - # doesn't matter for invocation, only for detection. + # Newer atuin (>= 18.10) instead binds ctrl-r to an intermediate key sequence dispatched + # through a separate widget-index binding, which `bind -X` alone can't reliably distinguish + # from an arbitrary user macro. We decline the handoff in that case rather than risk + # hijacking a key the user rebound to something else. _WARP_EXTERNAL_CTRL_R_WIDGET="" if [ "$WARP_IN_MSYS2" = false ]; then warp_ctrl_r_binding="$(bind -X 2>/dev/null | command -p sed -n 's/^"\\C-r": "\(.*\)"$/\1/p')" case "$warp_ctrl_r_binding" in - *fzf*) + __fzf_history__|__atuin_history) _WARP_EXTERNAL_CTRL_R_WIDGET="$warp_ctrl_r_binding" shell_plugins+=(external_ctrl_r_history) ;; - *) - if [[ "$__atuin_bind_ctrl_r" == "true" ]] && declare -F __atuin_history >/dev/null 2>&1; then - _WARP_EXTERNAL_CTRL_R_WIDGET="atuin" - shell_plugins+=(external_ctrl_r_history) - fi - ;; esac fi diff --git a/app/assets/bundled/bootstrap/fish.sh b/app/assets/bundled/bootstrap/fish.sh index 4a0be8e23ec..c8163492d35 100644 --- a/app/assets/bundled/bootstrap/fish.sh +++ b/app/assets/bundled/bootstrap/fish.sh @@ -508,8 +508,9 @@ end # history search. Returns non-zero when `^R` is still on a fish default. # # `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. Detection is deliberately generic: we -# report the widget name and let Warp decide what it knows how to do with it. +# the user (or a plugin like fzf or atuin) installed. The caller matches the reported name +# against an exact allowlist of the widget names it knows how to invoke -- see +# warp_run_external_ctrl_r_widget's comment. function warp_external_ctrl_r_widget # fish >= 4.0 renamed key specifications, so `bind` echoes back `ctrl-r` where earlier # versions echo `\cr`. Both spellings are accepted as input by every supported version, @@ -537,11 +538,17 @@ end # We re-run each tool's own underlying picker rather than invoking its bound fish function: those # functions write the selection into fish's line buffer with `commandline`, which would leave the # text queued for execution in the shell rather than handing it to Warp's editor. +# +# $_WARP_EXTERNAL_CTRL_R_WIDGET is set during bootstrap (see warp_bootstrapped) to an exact +# allowlist of each integration's canonical widget name -- not merely a name containing "fzf" or +# "atuin" -- since an RC can legitimately bind ctrl-r to an unrelated fzf- or atuin-flavored +# widget that isn't the history search below knows how to invoke. Adding another tool means +# adding its widget name to both that allowlist and the case below. function warp_run_external_ctrl_r_widget set -l warp_ctrl_r_token "$argv[1]" set -l result "" switch "$_WARP_EXTERNAL_CTRL_R_WIDGET" - case '*fzf*' + case 'fzf-history-widget' # fzf's fish integration reads history through the shell rather than a history file, so # this mirrors the pipeline fzf-history-widget builds, minus its `commandline` calls. set -lx FZF_DEFAULT_OPTS (__fzf_defaults '' \ @@ -565,7 +572,7 @@ function warp_run_external_ctrl_r_widget if set selected (eval $FZF_DEFAULT_COMMAND \| (__fzfcmd) | string split0) set result (string replace -a -- \n\t \n $selected[1]) end - case '*atuin*' + case '_atuin_search' # atuin writes its TUI to stdout and the selection to fd 3, so the two are swapped here to # leave the UI on the terminal and capture only the selection. set -l output (ATUIN_SHELL_FISH=t ATUIN_LOG=error atuin search -i 3>&1 1>&2 2>&3 | string collect) @@ -578,6 +585,24 @@ function warp_run_external_ctrl_r_widget warp_send_json_message "{ \"hook\": \"ExternalCtrlRSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"token\": \"$warp_escaped_token\", \"session_id\": $WARP_SESSION_ID } }" end +# Exclude the ctrl-r external history handoff helper (see warp_run_external_ctrl_r_widget +# above) from the user's history: it's a Warp-internal invocation, not a command the user meant +# to run again later, and leaving it in history would otherwise pollute the very history list +# this feature searches on the next ctrl-r. +# +# fish only supports a single fish_should_add_to_history function (unlike zsh's array of +# zshaddhistory hooks or bash's PROMPT_COMMAND-style stacking), so compose with any +# user-defined one -- e.g. from a plugin sourced in config.fish before this bootstrap script +# runs -- rather than clobbering it, following the same backup pattern warp_update_prompt_vars +# uses for fish_prompt. +if functions -q fish_should_add_to_history; and not functions -q warp_original_fish_should_add_to_history + functions -c fish_should_add_to_history warp_original_fish_should_add_to_history +end +function fish_should_add_to_history + string match --quiet -- 'warp_run_external_ctrl_r_widget *' $argv[1]; and return 1 + functions -q warp_original_fish_should_add_to_history; and warp_original_fish_should_add_to_history $argv +end + function warp_bootstrapped set -l histfile_directory set histfile_directory "$XDG_DATA_HOME" @@ -594,9 +619,12 @@ function warp_bootstrapped # Tags for shell configurations Warp needs to know about, matching the `shell_plugins` # list bash and zsh already report. Newline-separated, one tag per line. set -l shell_plugins - set -g _WARP_EXTERNAL_CTRL_R_WIDGET (warp_external_ctrl_r_widget) - if test -n "$_WARP_EXTERNAL_CTRL_R_WIDGET" - set -a shell_plugins external_ctrl_r_history + set -g _WARP_EXTERNAL_CTRL_R_WIDGET "" + set -l warp_ctrl_r_widget (warp_external_ctrl_r_widget) + switch "$warp_ctrl_r_widget" + case 'fzf-history-widget' '_atuin_search' + set -g _WARP_EXTERNAL_CTRL_R_WIDGET "$warp_ctrl_r_widget" + set -a shell_plugins external_ctrl_r_history end set -l escaped_shell_plugins (warp_escape_json $shell_plugins) diff --git a/app/assets/bundled/bootstrap/zsh_body.sh b/app/assets/bundled/bootstrap/zsh_body.sh index 629d8629a8c..6c225ed1d8e 100644 --- a/app/assets/bundled/bootstrap/zsh_body.sh +++ b/app/assets/bundled/bootstrap/zsh_body.sh @@ -689,12 +689,12 @@ if [[ -z $WARP_BOOTSTRAPPED ]]; then local warp_ctrl_r_token="$1" local result="" case "$_WARP_EXTERNAL_CTRL_R_WIDGET" in - *fzf*) + fzf-history-widget) result="$(fc -rl 1 \ | command -p awk '{ cmd=$0; sub(/^[ \t]*[0-9]+\**[ \t]+/, "", cmd); if (!seen[cmd]++) print cmd }' \ | fzf --scheme=history --tiebreak=index +m)" ;; - *atuin*) + atuin-search|atuin-search-viins|atuin-search-vicmd|_atuin_search_widget) # atuin writes its TUI to stdout; under plain command substitution that's a pipe, and # its cursor-position query (\x1b[6n) has nothing to answer it, so it bails during # startup. Swap stdout/stderr through fd 3, matching atuin's own zsh integration @@ -1360,17 +1360,18 @@ esac # Detect whether ctrl-r has been rebound to fzf's or atuin's history widget, so Warp can # hand ctrl-r off to it at an idle prompt instead of opening Warp's own command search. - # Detection is scoped to exactly the tools warp_run_external_ctrl_r_widget below knows how - # to invoke: invoking an arbitrary rebound widget outside of an active zle context isn't - # possible in general (see that function's comment), so tagging a tool we can't invoke would - # cost the user both Warp's command search and their own binding on every ctrl-r press. - # Adding another tool means adding it to both this pattern and the case below. + # Matched against an exact allowlist of each integration's canonical widget names -- not + # merely a name containing "fzf" or "atuin" -- since an RC can legitimately bind ctrl-r to an + # unrelated fzf- or atuin-flavored widget that isn't the history search we know how to invoke; + # rerouting those to the hard-coded history picker would cost the user both their own binding + # and Warp's command search on every ctrl-r press. Adding another tool means adding its widget + # name to both this list and the case below. _WARP_EXTERNAL_CTRL_R_WIDGET="" warp_ctrl_r_binding="$(bindkey -M main '^R' 2>/dev/null)" if [[ "$warp_ctrl_r_binding" == '"^R" '* ]]; then warp_ctrl_r_widget="${warp_ctrl_r_binding#\"^R\" }" case "$warp_ctrl_r_widget" in - *fzf*|*atuin*) + fzf-history-widget|atuin-search|atuin-search-viins|atuin-search-vicmd|_atuin_search_widget) _WARP_EXTERNAL_CTRL_R_WIDGET="$warp_ctrl_r_widget" shell_plugins+=(external_ctrl_r_history) ;; From c8dee34906dc0721f43e18707501c21b08b23125 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:30:16 +0000 Subject: [PATCH 08/70] Fix fish ctrl-r history wrapper: reject-all bug and re-source recursion The fish_should_add_to_history wrapper had two bugs, both stemming from treating "no backup exists" as a state to handle at call time instead of establishing the invariant at install time: 1. When no user-defined fish_should_add_to_history existed (the common case), warp_original_fish_should_add_to_history was never created, so the fallback branch's `functions -q warp_original...; and warp_original...` evaluated to false for every command. Fish treats a nonzero return as reject, so this silently disabled fish history recording entirely. 2. Re-sourcing this bootstrap script in the same fish process (shell reload, or a nested fish subshell) backed up whatever fish_should_add_to_history currently was -- on a second run, that's already our own wrapper, not the user's original -- into warp_original_fish_should_add_to_history, making every history check call itself and hit fish's call stack limit. Fix: always establish warp_original_fish_should_add_to_history exactly once, before installing the wrapper, as either the user's real original function or an explicit accept-everything default. The wrapper then unconditionally delegates to it, with no call-time branching on whether a backup exists. Added regression tests (bootstrap_tests.rs) that extract the real installer snippet from fish.sh and run it against a live fish process, sourced twice (simulating re-sourcing), both with and without a pre-existing user hook. Each test asserts a positive control (an ordinary command is still accepted) alongside the negative one (the ctrl-r helper invocation is rejected), so a test can't pass merely because everything is being rejected. --- app/assets/bundled/bootstrap/fish.sh | 20 +++++-- app/src/terminal/bootstrap_tests.rs | 79 ++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/app/assets/bundled/bootstrap/fish.sh b/app/assets/bundled/bootstrap/fish.sh index c8163492d35..beedc0fac96 100644 --- a/app/assets/bundled/bootstrap/fish.sh +++ b/app/assets/bundled/bootstrap/fish.sh @@ -595,12 +595,26 @@ end # user-defined one -- e.g. from a plugin sourced in config.fish before this bootstrap script # runs -- rather than clobbering it, following the same backup pattern warp_update_prompt_vars # uses for fish_prompt. -if functions -q fish_should_add_to_history; and not functions -q warp_original_fish_should_add_to_history - functions -c fish_should_add_to_history warp_original_fish_should_add_to_history +# +# warp_original_fish_should_add_to_history must exist and be safe to call *before* we install our +# own wrapper below, and this must happen only once: this bootstrap script can run more than once +# in the same fish process (a shell reload, or a nested fish subshell), and on a second run +# fish_should_add_to_history is already our own wrapper, not the user's. Backing that up as if it +# were the original would make every history check call itself. Establishing the backup here, +# before the wrapper is (re-)installed, and only when no backup exists yet, keeps the backup +# always pointing at either the user's real function or a default that accepts everything. +if not functions -q warp_original_fish_should_add_to_history + if functions -q fish_should_add_to_history + functions -c fish_should_add_to_history warp_original_fish_should_add_to_history + else + function warp_original_fish_should_add_to_history + return 0 + end + end end function fish_should_add_to_history string match --quiet -- 'warp_run_external_ctrl_r_widget *' $argv[1]; and return 1 - functions -q warp_original_fish_should_add_to_history; and warp_original_fish_should_add_to_history $argv + warp_original_fish_should_add_to_history $argv end function warp_bootstrapped diff --git a/app/src/terminal/bootstrap_tests.rs b/app/src/terminal/bootstrap_tests.rs index 18f13a8384c..f975abbfba6 100644 --- a/app/src/terminal/bootstrap_tests.rs +++ b/app/src/terminal/bootstrap_tests.rs @@ -62,3 +62,82 @@ fn test_trims_powershell_specifics() { fn decode_script(bytes: &[u8]) -> &str { std::str::from_utf8(bytes).expect("should not fail to decode") } + +fn fish_history_wrapper_installer() -> &'static str { + const FISH_SH: &str = include_str!("../../assets/bundled/bootstrap/fish.sh"); + let start_marker = "if not functions -q warp_original_fish_should_add_to_history"; + let end_marker = " warp_original_fish_should_add_to_history $argv\nend"; + let start = FISH_SH + .find(start_marker) + .expect("fish history wrapper installer start should exist"); + let end = FISH_SH[start..] + .find(end_marker) + .expect("fish history wrapper installer end should exist"); + &FISH_SH[start..start + end + end_marker.len()] +} + +fn run_fish(script: &str) -> Option { + let output = match std::process::Command::new("fish") + .args(["--no-config", "-c", script]) + .output() + { + Ok(output) => output, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return None, + Err(error) => panic!("failed to run fish: {error}"), + }; + assert!( + output.status.success(), + "fish exited with {:?}\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + Some(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +#[test] +fn test_fish_history_wrapper_accepts_normal_commands_across_resourcing() { + let installer = fish_history_wrapper_installer(); + let script = format!( + r#" +{installer} +{installer} +fish_should_add_to_history "echo normal" +echo "normal:$status" +fish_should_add_to_history "warp_run_external_ctrl_r_widget token" +echo "helper:$status" +"# + ); + let Some(stdout) = run_fish(&script) else { + return; + }; + assert!(stdout.contains("normal:0"), "{stdout}"); + assert!(stdout.contains("helper:1"), "{stdout}"); +} + +#[test] +fn test_fish_history_wrapper_preserves_user_hook_across_resourcing() { + let installer = fish_history_wrapper_installer(); + let script = format!( + r#" +function fish_should_add_to_history + string match --quiet -- "user_excluded*" $argv[1]; and return 1 + return 0 +end +{installer} +{installer} +fish_should_add_to_history "echo normal" +echo "normal:$status" +fish_should_add_to_history "warp_run_external_ctrl_r_widget token" +echo "helper:$status" +fish_should_add_to_history "user_excluded" +echo "user:$status" +"# + ); + let Some(stdout) = run_fish(&script) else { + return; + }; + assert!(stdout.contains("normal:0"), "{stdout}"); + assert!(stdout.contains("helper:1"), "{stdout}"); + assert!(stdout.contains("user:1"), "{stdout}"); +} From 51fb584edc7c4f6ed13c08d824f4f8f6b59376e9 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:38:42 +0000 Subject: [PATCH 09/70] Fix fish history wrapper: preserve a hook installed between two sourcings Third ordering the reviewer found: source #1 with no user hook installs the accept-everything default backup; a user or plugin then defines fish_should_add_to_history; source #2 saw a backup already existed and left it alone, silently discarding the intervening hook. Install-time rule that now covers all three orders (no hook, hook before, hook between): on every sourcing, if the current fish_should_add_to_history is not recognizably our own wrapper (identified by the warp_run_external_ctrl_r_widget sentinel in its body), capture it as the backup -- replacing whatever backup existed, including an earlier accept-everything default -- so the backup always reflects the latest real hook. Only when the current function already is our wrapper (i.e. an unmodified re-source) is the existing backup left alone, which is what keeps the recursion path from the previous fix closed. functions -c refuses to overwrite an existing destination, so the previous backup is erased first. Added a third regression test for this exact ordering (hook installed between two sourcings), alongside the existing no-hook and hook-before-first- source cases, each with the same positive-control assertion. --- app/assets/bundled/bootstrap/fish.sh | 30 ++++++++++++++----------- app/src/terminal/bootstrap_tests.rs | 33 +++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 14 deletions(-) diff --git a/app/assets/bundled/bootstrap/fish.sh b/app/assets/bundled/bootstrap/fish.sh index beedc0fac96..f1b09b9989f 100644 --- a/app/assets/bundled/bootstrap/fish.sh +++ b/app/assets/bundled/bootstrap/fish.sh @@ -597,19 +597,23 @@ end # uses for fish_prompt. # # warp_original_fish_should_add_to_history must exist and be safe to call *before* we install our -# own wrapper below, and this must happen only once: this bootstrap script can run more than once -# in the same fish process (a shell reload, or a nested fish subshell), and on a second run -# fish_should_add_to_history is already our own wrapper, not the user's. Backing that up as if it -# were the original would make every history check call itself. Establishing the backup here, -# before the wrapper is (re-)installed, and only when no backup exists yet, keeps the backup -# always pointing at either the user's real function or a default that accepts everything. -if not functions -q warp_original_fish_should_add_to_history - if functions -q fish_should_add_to_history - functions -c fish_should_add_to_history warp_original_fish_should_add_to_history - else - function warp_original_fish_should_add_to_history - return 0 - end +# own wrapper below. This bootstrap script can run more than once in the same fish process (a +# shell reload, or a nested fish subshell), and a user or plugin can define or replace +# fish_should_add_to_history at any point, including between two of our sourcings -- so on every +# run, re-derive the backup from whatever fish_should_add_to_history currently is, unless that's +# already our own wrapper from a previous run (identified by the warp_run_external_ctrl_r_widget +# sentinel in its body), in which case the existing backup -- the last real hook we captured, or +# the accept-everything default if none ever existed -- is left alone. Backing up our own wrapper +# as if it were the original would make every history check call itself. +if functions -q fish_should_add_to_history + and not functions fish_should_add_to_history | string match --quiet -- '*warp_run_external_ctrl_r_widget*' + # `functions -c` refuses to overwrite an existing destination, so erase any previous backup + # (e.g. an earlier accept-everything default, or a now-stale hook) before capturing this one. + functions -q warp_original_fish_should_add_to_history; and functions -e warp_original_fish_should_add_to_history + functions -c fish_should_add_to_history warp_original_fish_should_add_to_history +else if not functions -q warp_original_fish_should_add_to_history + function warp_original_fish_should_add_to_history + return 0 end end function fish_should_add_to_history diff --git a/app/src/terminal/bootstrap_tests.rs b/app/src/terminal/bootstrap_tests.rs index f975abbfba6..a9664bfeefe 100644 --- a/app/src/terminal/bootstrap_tests.rs +++ b/app/src/terminal/bootstrap_tests.rs @@ -65,7 +65,7 @@ fn decode_script(bytes: &[u8]) -> &str { fn fish_history_wrapper_installer() -> &'static str { const FISH_SH: &str = include_str!("../../assets/bundled/bootstrap/fish.sh"); - let start_marker = "if not functions -q warp_original_fish_should_add_to_history"; + let start_marker = "if functions -q fish_should_add_to_history\n and not functions fish_should_add_to_history"; let end_marker = " warp_original_fish_should_add_to_history $argv\nend"; let start = FISH_SH .find(start_marker) @@ -141,3 +141,34 @@ echo "user:$status" assert!(stdout.contains("helper:1"), "{stdout}"); assert!(stdout.contains("user:1"), "{stdout}"); } + +/// Regression test for a user/plugin hook defined *between* two sourcings of this bootstrap +/// script (e.g. a plugin loaded after Warp's shell integration, followed by a shell reload or +/// nested fish subshell): the second sourcing must capture that hook rather than discarding it +/// in favor of whatever backup (or accept-everything default) an earlier sourcing installed. +#[test] +fn test_fish_history_wrapper_captures_hook_installed_between_resourcing() { + let installer = fish_history_wrapper_installer(); + let script = format!( + r#" +{installer} +function fish_should_add_to_history + string match --quiet -- "user_excluded*" $argv[1]; and return 1 + return 0 +end +{installer} +fish_should_add_to_history "echo normal" +echo "normal:$status" +fish_should_add_to_history "warp_run_external_ctrl_r_widget token" +echo "helper:$status" +fish_should_add_to_history "user_excluded" +echo "user:$status" +"# + ); + let Some(stdout) = run_fish(&script) else { + return; + }; + assert!(stdout.contains("normal:0"), "{stdout}"); + assert!(stdout.contains("helper:1"), "{stdout}"); + assert!(stdout.contains("user:1"), "{stdout}"); +} From 279893bece746e8aefaf8d1be6d7cd77283c6ba3 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:56:49 +0000 Subject: [PATCH 10/70] Fix ctrl-r handoff polluting atuin's own history database The HISTIGNORE/zshaddhistory/fish_should_add_to_history exclusions added previously only stop the shell's own history from recording the warp_run_external_ctrl_r_widget invocation. atuin maintains a separate SQLite history database and records every command through its own preexec/precmd hooks, independent of those shell-level mechanisms, so the synthetic invocation was still landing in atuin's own history -- the exact history list this feature searches on the next ctrl-r, and the one place this pollution hurts most. Fix by deleting the entry atuin's own hooks record for this invocation, using 'atuin search --delete' with the handoff token to match it exactly. Also sweep up any invocations from before this fix (or from a session that exited before its own cleanup ran) once per bootstrap, backgrounded since it's pure hygiene and not required for the handoff to work. fzf's ctrl-r widgets (fzf-history-widget / __fzf_history__) read the shell's own history builtin directly and keep no separate store, so they don't have this problem. --- app/assets/bundled/bootstrap/bash_body.sh | 18 +++++++++++++++++- app/assets/bundled/bootstrap/fish.sh | 18 +++++++++++++++++- app/assets/bundled/bootstrap/zsh_body.sh | 19 ++++++++++++++++++- 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/app/assets/bundled/bootstrap/bash_body.sh b/app/assets/bundled/bootstrap/bash_body.sh index 6bc3efde95a..f66d9f38053 100644 --- a/app/assets/bundled/bootstrap/bash_body.sh +++ b/app/assets/bundled/bootstrap/bash_body.sh @@ -833,6 +833,12 @@ if [ -z "$WARP_BOOTSTRAPPED" ]; then # it, so strip the prefix in both cases (see atuin's __atuin_history for the same # check). result="${result#__atuin_accept__:}" + # atuin's own preexec/precmd hooks already recorded this invocation into its + # separate history database the moment it started -- independent of HISTIGNORE, + # which only keeps it out of bash's own history. Delete that entry now so it + # doesn't show up the next time the user searches with ctrl-r; the handoff token + # makes the match exact. + atuin search --delete -- "warp_run_external_ctrl_r_widget $warp_ctrl_r_token" >/dev/null 2>&1 ;; esac local warp_escaped_selection="$(warp_escape_json "$result")" @@ -1427,10 +1433,20 @@ esac if [ "$WARP_IN_MSYS2" = false ]; then warp_ctrl_r_binding="$(bind -X 2>/dev/null | command -p sed -n 's/^"\\C-r": "\(.*\)"$/\1/p')" case "$warp_ctrl_r_binding" in - __fzf_history__|__atuin_history) + __fzf_history__) _WARP_EXTERNAL_CTRL_R_WIDGET="$warp_ctrl_r_binding" shell_plugins+=(external_ctrl_r_history) ;; + __atuin_history) + _WARP_EXTERNAL_CTRL_R_WIDGET="$warp_ctrl_r_binding" + shell_plugins+=(external_ctrl_r_history) + # atuin records every command into its own history database via its own preexec/precmd + # hooks, independent of HISTIGNORE, so past handoff invocations (e.g. from before this + # cleanup existed, or a session that exited before warp_run_external_ctrl_r_widget's own + # cleanup ran) can still be sitting in it. Sweep those up once per bootstrap; backgrounded + # since it's pure hygiene and not needed for the handoff itself to work. + (atuin search --delete -- "warp_run_external_ctrl_r_widget" >/dev/null 2>&1 &) + ;; esac fi diff --git a/app/assets/bundled/bootstrap/fish.sh b/app/assets/bundled/bootstrap/fish.sh index f1b09b9989f..d4ab9e205b4 100644 --- a/app/assets/bundled/bootstrap/fish.sh +++ b/app/assets/bundled/bootstrap/fish.sh @@ -579,6 +579,12 @@ function warp_run_external_ctrl_r_widget # atuin prefixes the selection with __atuin_accept__: when `enter_accept` is on and the # user pressed enter. Warp always inserts without executing, so the prefix is dropped. set result (string replace "__atuin_accept__:" "" -- "$output" | string collect) + # atuin's own preexec/precmd hooks already recorded this invocation into its separate + # history database the moment it started -- independent of the fish_should_add_to_history + # exclusion above, which only keeps it out of fish's own history. Delete that entry now + # so it doesn't show up the next time the user searches with ctrl-r; the handoff token + # makes the match exact. + atuin search --delete -- "warp_run_external_ctrl_r_widget $warp_ctrl_r_token" >/dev/null 2>&1 end set -l warp_escaped_selection (warp_escape_json "$result") set -l warp_escaped_token (warp_escape_json "$warp_ctrl_r_token") @@ -640,9 +646,19 @@ function warp_bootstrapped set -g _WARP_EXTERNAL_CTRL_R_WIDGET "" set -l warp_ctrl_r_widget (warp_external_ctrl_r_widget) switch "$warp_ctrl_r_widget" - case 'fzf-history-widget' '_atuin_search' + case 'fzf-history-widget' + set -g _WARP_EXTERNAL_CTRL_R_WIDGET "$warp_ctrl_r_widget" + set -a shell_plugins external_ctrl_r_history + case '_atuin_search' set -g _WARP_EXTERNAL_CTRL_R_WIDGET "$warp_ctrl_r_widget" set -a shell_plugins external_ctrl_r_history + # atuin records every command into its own history database via its own preexec/precmd + # hooks, independent of the fish_should_add_to_history exclusion above, so past handoff + # invocations (e.g. from before this cleanup existed, or a session that exited before + # warp_run_external_ctrl_r_widget's own cleanup ran) can still be sitting in it. Sweep + # those up once per bootstrap; backgrounded since it's pure hygiene and not needed for + # the handoff itself to work. + atuin search --delete -- "warp_run_external_ctrl_r_widget" >/dev/null 2>&1 & end set -l escaped_shell_plugins (warp_escape_json $shell_plugins) diff --git a/app/assets/bundled/bootstrap/zsh_body.sh b/app/assets/bundled/bootstrap/zsh_body.sh index 6c225ed1d8e..93ff31938f6 100644 --- a/app/assets/bundled/bootstrap/zsh_body.sh +++ b/app/assets/bundled/bootstrap/zsh_body.sh @@ -705,6 +705,12 @@ if [[ -z $WARP_BOOTSTRAPPED ]]; then # command, signaled by this prefix; we only ever want the selection, never to run it, # so strip the prefix in both cases (see atuin's _atuin_search for the same check). result="${result#__atuin_accept__:}" + # atuin's own preexec/precmd hooks already recorded this invocation into its separate + # history database the moment it started -- independent of the zshaddhistory exclusion + # above, which only keeps it out of zsh's own history. Delete that entry now so it + # doesn't show up the next time the user searches with ctrl-r; the handoff token makes + # the match exact. + atuin search --delete -- "warp_run_external_ctrl_r_widget $warp_ctrl_r_token" >/dev/null 2>&1 ;; esac local warp_escaped_selection="$(warp_escape_json "$result")" @@ -1371,9 +1377,20 @@ esac if [[ "$warp_ctrl_r_binding" == '"^R" '* ]]; then warp_ctrl_r_widget="${warp_ctrl_r_binding#\"^R\" }" case "$warp_ctrl_r_widget" in - fzf-history-widget|atuin-search|atuin-search-viins|atuin-search-vicmd|_atuin_search_widget) + fzf-history-widget) + _WARP_EXTERNAL_CTRL_R_WIDGET="$warp_ctrl_r_widget" + shell_plugins+=(external_ctrl_r_history) + ;; + atuin-search|atuin-search-viins|atuin-search-vicmd|_atuin_search_widget) _WARP_EXTERNAL_CTRL_R_WIDGET="$warp_ctrl_r_widget" shell_plugins+=(external_ctrl_r_history) + # atuin records every command into its own history database via its own preexec/precmd + # hooks, independent of the zshaddhistory exclusion above, so past handoff invocations + # (e.g. from before this cleanup existed, or a session that exited before + # warp_run_external_ctrl_r_widget's own cleanup ran) can still be sitting in it. Sweep + # those up once per bootstrap; backgrounded since it's pure hygiene and not needed for + # the handoff itself to work. + (atuin search --delete -- "warp_run_external_ctrl_r_widget" >/dev/null 2>&1 &) ;; esac fi From 7d9b66dca395d65b83bc7037e8731ea43d76140a Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 05:08:43 +0000 Subject: [PATCH 11/70] Fix ctrl-r helper invocation polluting atuin's own history database HISTIGNORE (bash), the zshaddhistory hook (zsh), and the fish_should_add_to_history wrapper (fish) only ever stopped the *shell's* history file from recording the warp_run_external_ctrl_r_widget invocation. atuin records commands through its own preexec hook straight into its own sqlite database, independent of and untouched by any of that, so for atuin users the pollution didn't get fixed -- it moved out of scrollback and into the very history list this feature exists to search. Fix by prefixing the invocation with a leading space when executing it, honoring the "ignorespace" convention atuin implements itself in its own binary (per its docs on excluding commands), independent of the shell's own history settings. Verified empirically: a leading-space-prefixed command is excluded from atuin's search results while an unprefixed one is recorded normally. fzf has no equivalent private store to worry about here -- its history widgets (fzf-history-widget / __fzf_history__ / the fish pipeline) all read directly from the shell's own history (via fc -rl / builtin history), which the existing exclusions already cover. Updated fish's fish_should_add_to_history sentinel match to be unanchored so the new leading space doesn't defeat it, and added a regression test asserting the exact leading-space invocation shape is still rejected. Existing atuin database entries recorded before this fix are not retroactively cleaned up; users who want to remove them can delete matching entries via atuin's own history search/delete tooling. --- app/assets/bundled/bootstrap/fish.sh | 5 ++++- app/src/terminal/bootstrap_tests.rs | 6 ++++++ app/src/terminal/input.rs | 11 ++++++++++- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/app/assets/bundled/bootstrap/fish.sh b/app/assets/bundled/bootstrap/fish.sh index f1b09b9989f..fbb08c83460 100644 --- a/app/assets/bundled/bootstrap/fish.sh +++ b/app/assets/bundled/bootstrap/fish.sh @@ -617,7 +617,10 @@ else if not functions -q warp_original_fish_should_add_to_history end end function fish_should_add_to_history - string match --quiet -- 'warp_run_external_ctrl_r_widget *' $argv[1]; and return 1 + # Unanchored (not just a prefix match): the invocation is now given a leading space so atuin's + # own "ignorespace" exclusion also catches it (see trigger_external_ctrl_r_history_search), and + # that space must not defeat this match too. + string match --quiet -- '*warp_run_external_ctrl_r_widget *' $argv[1]; and return 1 warp_original_fish_should_add_to_history $argv end diff --git a/app/src/terminal/bootstrap_tests.rs b/app/src/terminal/bootstrap_tests.rs index a9664bfeefe..ddedab1a9b3 100644 --- a/app/src/terminal/bootstrap_tests.rs +++ b/app/src/terminal/bootstrap_tests.rs @@ -106,6 +106,11 @@ fish_should_add_to_history "echo normal" echo "normal:$status" fish_should_add_to_history "warp_run_external_ctrl_r_widget token" echo "helper:$status" +# The real invocation (see trigger_external_ctrl_r_history_search) is prefixed with a leading +# space, so atuin's own "ignorespace" exclusion also catches it; the wrapper must still reject +# this exact shape too. +fish_should_add_to_history " warp_run_external_ctrl_r_widget token" +echo "helper_leading_space:$status" "# ); let Some(stdout) = run_fish(&script) else { @@ -113,6 +118,7 @@ echo "helper:$status" }; assert!(stdout.contains("normal:0"), "{stdout}"); assert!(stdout.contains("helper:1"), "{stdout}"); + assert!(stdout.contains("helper_leading_space:1"), "{stdout}"); } #[test] diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index 419081050f2..1353ce34203 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -7550,6 +7550,15 @@ impl Input { /// the current buffer contents so they're restored once the command's block completes -- /// unless [`Self::set_external_ctrl_r_selection`] supplies a selected command in the /// meantime. Returns `true` if the command was started. + /// + /// The command is prefixed with a leading space, honoring the "ignorespace" convention that + /// bash/zsh support and atuin explicitly implements itself (independent of the shell's own + /// history settings -- see atuin's docs on excluding commands). Our own per-shell exclusions + /// (zsh's `_warp_zshaddhistory`, bash's `HISTIGNORE`, fish's `fish_should_add_to_history` + /// wrapper) only ever stopped the *shell's* history file from recording this invocation. + /// atuin records through its own preexec hook straight into its own database, which none of + /// that touches, so without this it would otherwise show up in the very history list this + /// feature exists to search. pub fn trigger_external_ctrl_r_history_search( &mut self, helper_command: &str, @@ -7561,7 +7570,7 @@ impl Input { let current_input = self.buffer_text(ctx); let block_id = self.model.lock().block_list().active_block_id().clone(); let token = Uuid::new_v4().to_string(); - let command = format!("{helper_command} {token}"); + let command = format!(" {helper_command} {token}"); let started = self.try_execute_command_from_source(&command, CommandExecutionSource::User, ctx); if started { From 783e1738fa467480e9e5a8141f9e92132a5bc6fa Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 05:13:21 +0000 Subject: [PATCH 12/70] Clarify atuin cleanup comments after merging with leading-space prevention A parallel fix landed independently on this branch that deletes the warp_run_external_ctrl_r_widget entry from atuin's own history database after each invocation (per-token exact delete) and sweeps any leftover entries once per bootstrap (catching pollution from before either fix existed). That is complementary to, not redundant with, the leading-space prevention: the sweep handles retroactive cleanup of already-recorded entries that the leading space can't touch, while the per-invocation delete becomes a safety net that should normally find nothing to delete now that the invocation is never recorded in the first place. Updated the per-invocation delete's comment in all three shells to describe that relationship instead of restating (now inaccurate) that atuin always records the invocation. --- app/assets/bundled/bootstrap/bash_body.sh | 11 ++++++----- app/assets/bundled/bootstrap/fish.sh | 9 +++++---- app/assets/bundled/bootstrap/zsh_body.sh | 11 ++++++----- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/app/assets/bundled/bootstrap/bash_body.sh b/app/assets/bundled/bootstrap/bash_body.sh index f66d9f38053..4d3008b6a94 100644 --- a/app/assets/bundled/bootstrap/bash_body.sh +++ b/app/assets/bundled/bootstrap/bash_body.sh @@ -833,11 +833,12 @@ if [ -z "$WARP_BOOTSTRAPPED" ]; then # it, so strip the prefix in both cases (see atuin's __atuin_history for the same # check). result="${result#__atuin_accept__:}" - # atuin's own preexec/precmd hooks already recorded this invocation into its - # separate history database the moment it started -- independent of HISTIGNORE, - # which only keeps it out of bash's own history. Delete that entry now so it - # doesn't show up the next time the user searches with ctrl-r; the handoff token - # makes the match exact. + # The invocation is given a leading space (see + # trigger_external_ctrl_r_history_search), which atuin's own "ignorespace" + # exclusion honors independent of HISTIGNORE (which only keeps it out of bash's own + # history), so this should normally be a no-op. Delete it anyway as a safety net in + # case that exclusion doesn't apply for some reason; the handoff token makes the + # match exact. atuin search --delete -- "warp_run_external_ctrl_r_widget $warp_ctrl_r_token" >/dev/null 2>&1 ;; esac diff --git a/app/assets/bundled/bootstrap/fish.sh b/app/assets/bundled/bootstrap/fish.sh index 8c79657f75c..1a9673117f0 100644 --- a/app/assets/bundled/bootstrap/fish.sh +++ b/app/assets/bundled/bootstrap/fish.sh @@ -579,10 +579,11 @@ function warp_run_external_ctrl_r_widget # atuin prefixes the selection with __atuin_accept__: when `enter_accept` is on and the # user pressed enter. Warp always inserts without executing, so the prefix is dropped. set result (string replace "__atuin_accept__:" "" -- "$output" | string collect) - # atuin's own preexec/precmd hooks already recorded this invocation into its separate - # history database the moment it started -- independent of the fish_should_add_to_history - # exclusion above, which only keeps it out of fish's own history. Delete that entry now - # so it doesn't show up the next time the user searches with ctrl-r; the handoff token + # The invocation is given a leading space (see + # trigger_external_ctrl_r_history_search), which atuin's own "ignorespace" exclusion + # honors independent of the fish_should_add_to_history exclusion above (which only keeps + # it out of fish's own history), so this should normally be a no-op. Delete it anyway as + # a safety net in case that exclusion doesn't apply for some reason; the handoff token # makes the match exact. atuin search --delete -- "warp_run_external_ctrl_r_widget $warp_ctrl_r_token" >/dev/null 2>&1 end diff --git a/app/assets/bundled/bootstrap/zsh_body.sh b/app/assets/bundled/bootstrap/zsh_body.sh index 93ff31938f6..c36bef38018 100644 --- a/app/assets/bundled/bootstrap/zsh_body.sh +++ b/app/assets/bundled/bootstrap/zsh_body.sh @@ -705,11 +705,12 @@ if [[ -z $WARP_BOOTSTRAPPED ]]; then # command, signaled by this prefix; we only ever want the selection, never to run it, # so strip the prefix in both cases (see atuin's _atuin_search for the same check). result="${result#__atuin_accept__:}" - # atuin's own preexec/precmd hooks already recorded this invocation into its separate - # history database the moment it started -- independent of the zshaddhistory exclusion - # above, which only keeps it out of zsh's own history. Delete that entry now so it - # doesn't show up the next time the user searches with ctrl-r; the handoff token makes - # the match exact. + # The invocation is given a leading space (see + # trigger_external_ctrl_r_history_search), which atuin's own "ignorespace" exclusion + # honors independent of the zshaddhistory exclusion above (which only keeps it out of + # zsh's own history), so this should normally be a no-op. Delete it anyway as a safety + # net in case that exclusion doesn't apply for some reason; the handoff token makes the + # match exact. atuin search --delete -- "warp_run_external_ctrl_r_widget $warp_ctrl_r_token" >/dev/null 2>&1 ;; esac From b2cbf5c56608cff7b9f7d94405213dba87adc0c3 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 05:29:32 +0000 Subject: [PATCH 13/70] Remove atuin search --delete calls: fuzzy match risks deleting unrelated history Both the per-invocation delete (matched on the handoff token) and the bootstrap-time sweep (matched on the bare function name) assumed the query passed to `atuin search --delete` behaved as an exact match. It does not: atuin's search defaults to fuzzy matching and tokenizes a multi-word query into independent terms, so the query can match -- and delete -- history entries the user typed themselves, not just the synthetic invocation. Because atuin deletions are sync records that propagate to a user's other machines, this could destroy real command history with no prompt and no way back. The leading-space prevention already merged onto this branch means atuin's own "ignorespace" convention keeps the invocation out of its history database in the first place, so the deletes were only ever meant to backstop that mechanism for entries recorded before it existed. That backstop isn't worth the risk: a few residual rows in a dogfood user's local atuin database is a knowable, harmless leftover; deleting an unrelated command from someone's synced history is not recoverable. Comments at each site now explain why we deliberately don't attempt a delete. --- app/assets/bundled/bootstrap/bash_body.sh | 20 +++++--------------- app/assets/bundled/bootstrap/fish.sh | 20 +++++--------------- app/assets/bundled/bootstrap/zsh_body.sh | 21 +++++---------------- 3 files changed, 15 insertions(+), 46 deletions(-) diff --git a/app/assets/bundled/bootstrap/bash_body.sh b/app/assets/bundled/bootstrap/bash_body.sh index 4d3008b6a94..c4786b8628d 100644 --- a/app/assets/bundled/bootstrap/bash_body.sh +++ b/app/assets/bundled/bootstrap/bash_body.sh @@ -836,10 +836,10 @@ if [ -z "$WARP_BOOTSTRAPPED" ]; then # The invocation is given a leading space (see # trigger_external_ctrl_r_history_search), which atuin's own "ignorespace" # exclusion honors independent of HISTIGNORE (which only keeps it out of bash's own - # history), so this should normally be a no-op. Delete it anyway as a safety net in - # case that exclusion doesn't apply for some reason; the handoff token makes the - # match exact. - atuin search --delete -- "warp_run_external_ctrl_r_widget $warp_ctrl_r_token" >/dev/null 2>&1 + # history), so atuin never records this invocation into its own history database in + # the first place. We deliberately don't try to delete it after the fact if that + # exclusion somehow doesn't apply: `atuin search --delete` fuzzy-matches its query, + # so it can remove history entries we don't own. ;; esac local warp_escaped_selection="$(warp_escape_json "$result")" @@ -1434,20 +1434,10 @@ esac if [ "$WARP_IN_MSYS2" = false ]; then warp_ctrl_r_binding="$(bind -X 2>/dev/null | command -p sed -n 's/^"\\C-r": "\(.*\)"$/\1/p')" case "$warp_ctrl_r_binding" in - __fzf_history__) + __fzf_history__|__atuin_history) _WARP_EXTERNAL_CTRL_R_WIDGET="$warp_ctrl_r_binding" shell_plugins+=(external_ctrl_r_history) ;; - __atuin_history) - _WARP_EXTERNAL_CTRL_R_WIDGET="$warp_ctrl_r_binding" - shell_plugins+=(external_ctrl_r_history) - # atuin records every command into its own history database via its own preexec/precmd - # hooks, independent of HISTIGNORE, so past handoff invocations (e.g. from before this - # cleanup existed, or a session that exited before warp_run_external_ctrl_r_widget's own - # cleanup ran) can still be sitting in it. Sweep those up once per bootstrap; backgrounded - # since it's pure hygiene and not needed for the handoff itself to work. - (atuin search --delete -- "warp_run_external_ctrl_r_widget" >/dev/null 2>&1 &) - ;; esac fi diff --git a/app/assets/bundled/bootstrap/fish.sh b/app/assets/bundled/bootstrap/fish.sh index 1a9673117f0..9505bfe830f 100644 --- a/app/assets/bundled/bootstrap/fish.sh +++ b/app/assets/bundled/bootstrap/fish.sh @@ -582,10 +582,10 @@ function warp_run_external_ctrl_r_widget # The invocation is given a leading space (see # trigger_external_ctrl_r_history_search), which atuin's own "ignorespace" exclusion # honors independent of the fish_should_add_to_history exclusion above (which only keeps - # it out of fish's own history), so this should normally be a no-op. Delete it anyway as - # a safety net in case that exclusion doesn't apply for some reason; the handoff token - # makes the match exact. - atuin search --delete -- "warp_run_external_ctrl_r_widget $warp_ctrl_r_token" >/dev/null 2>&1 + # it out of fish's own history), so atuin never records this invocation into its own + # history database in the first place. We deliberately don't try to delete it after the + # fact if that exclusion somehow doesn't apply: `atuin search --delete` fuzzy-matches its + # query, so it can remove history entries we don't own. end set -l warp_escaped_selection (warp_escape_json "$result") set -l warp_escaped_token (warp_escape_json "$warp_ctrl_r_token") @@ -650,19 +650,9 @@ function warp_bootstrapped set -g _WARP_EXTERNAL_CTRL_R_WIDGET "" set -l warp_ctrl_r_widget (warp_external_ctrl_r_widget) switch "$warp_ctrl_r_widget" - case 'fzf-history-widget' - set -g _WARP_EXTERNAL_CTRL_R_WIDGET "$warp_ctrl_r_widget" - set -a shell_plugins external_ctrl_r_history - case '_atuin_search' + case 'fzf-history-widget' '_atuin_search' set -g _WARP_EXTERNAL_CTRL_R_WIDGET "$warp_ctrl_r_widget" set -a shell_plugins external_ctrl_r_history - # atuin records every command into its own history database via its own preexec/precmd - # hooks, independent of the fish_should_add_to_history exclusion above, so past handoff - # invocations (e.g. from before this cleanup existed, or a session that exited before - # warp_run_external_ctrl_r_widget's own cleanup ran) can still be sitting in it. Sweep - # those up once per bootstrap; backgrounded since it's pure hygiene and not needed for - # the handoff itself to work. - atuin search --delete -- "warp_run_external_ctrl_r_widget" >/dev/null 2>&1 & end set -l escaped_shell_plugins (warp_escape_json $shell_plugins) diff --git a/app/assets/bundled/bootstrap/zsh_body.sh b/app/assets/bundled/bootstrap/zsh_body.sh index c36bef38018..c15c42bd629 100644 --- a/app/assets/bundled/bootstrap/zsh_body.sh +++ b/app/assets/bundled/bootstrap/zsh_body.sh @@ -708,10 +708,10 @@ if [[ -z $WARP_BOOTSTRAPPED ]]; then # The invocation is given a leading space (see # trigger_external_ctrl_r_history_search), which atuin's own "ignorespace" exclusion # honors independent of the zshaddhistory exclusion above (which only keeps it out of - # zsh's own history), so this should normally be a no-op. Delete it anyway as a safety - # net in case that exclusion doesn't apply for some reason; the handoff token makes the - # match exact. - atuin search --delete -- "warp_run_external_ctrl_r_widget $warp_ctrl_r_token" >/dev/null 2>&1 + # zsh's own history), so atuin never records this invocation into its own history + # database in the first place. We deliberately don't try to delete it after the fact if + # that exclusion somehow doesn't apply: `atuin search --delete` fuzzy-matches its query, + # so it can remove history entries we don't own. ;; esac local warp_escaped_selection="$(warp_escape_json "$result")" @@ -1378,20 +1378,9 @@ esac if [[ "$warp_ctrl_r_binding" == '"^R" '* ]]; then warp_ctrl_r_widget="${warp_ctrl_r_binding#\"^R\" }" case "$warp_ctrl_r_widget" in - fzf-history-widget) - _WARP_EXTERNAL_CTRL_R_WIDGET="$warp_ctrl_r_widget" - shell_plugins+=(external_ctrl_r_history) - ;; - atuin-search|atuin-search-viins|atuin-search-vicmd|_atuin_search_widget) + fzf-history-widget|atuin-search|atuin-search-viins|atuin-search-vicmd|_atuin_search_widget) _WARP_EXTERNAL_CTRL_R_WIDGET="$warp_ctrl_r_widget" shell_plugins+=(external_ctrl_r_history) - # atuin records every command into its own history database via its own preexec/precmd - # hooks, independent of the zshaddhistory exclusion above, so past handoff invocations - # (e.g. from before this cleanup existed, or a session that exited before - # warp_run_external_ctrl_r_widget's own cleanup ran) can still be sitting in it. Sweep - # those up once per bootstrap; backgrounded since it's pure hygiene and not needed for - # the handoff itself to work. - (atuin search --delete -- "warp_run_external_ctrl_r_widget" >/dev/null 2>&1 &) ;; esac fi From 1ebf8e96846df25753786cef8cb648a886cc51c5 Mon Sep 17 00:00:00 2001 From: Warp Agent Date: Tue, 25 Aug 2026 18:55:05 +0000 Subject: [PATCH 14/70] Detect bash+atuin ctrl-r via atuin's init-time flag on newer atuin Newer atuin (>= 18.10) binds ctrl-r through an indirect key-sequence/ widget-index dispatcher that bind -X can't identify directly, so the existing exact-allowlist live-binding match never fires for it and bash+atuin users fell through to Warp's own command search. Add a fallback for bash only: when the live-binding match doesn't resolve ctrl-r, trust atuin's own $__atuin_bind_ctrl_r init-time flag together with __atuin_history actually being defined as sufficient evidence atuin owns ctrl-r. Both are set unconditionally by `atuin init bash`, so this is real evidence, not a name guess. Accepted trade-off: a ctrl-r rebind that happens after atuin's init runs in the same session won't be detected. zsh and fish are unaffected; atuin binds a named widget directly in both already. --- app/assets/bundled/bootstrap/bash_body.sh | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/app/assets/bundled/bootstrap/bash_body.sh b/app/assets/bundled/bootstrap/bash_body.sh index c4786b8628d..487ab7dca81 100644 --- a/app/assets/bundled/bootstrap/bash_body.sh +++ b/app/assets/bundled/bootstrap/bash_body.sh @@ -1428,8 +1428,7 @@ esac # # Newer atuin (>= 18.10) instead binds ctrl-r to an intermediate key sequence dispatched # through a separate widget-index binding, which `bind -X` alone can't reliably distinguish - # from an arbitrary user macro. We decline the handoff in that case rather than risk - # hijacking a key the user rebound to something else. + # from an arbitrary user macro; the fallback below handles that case. _WARP_EXTERNAL_CTRL_R_WIDGET="" if [ "$WARP_IN_MSYS2" = false ]; then warp_ctrl_r_binding="$(bind -X 2>/dev/null | command -p sed -n 's/^"\\C-r": "\(.*\)"$/\1/p')" @@ -1439,6 +1438,19 @@ esac shell_plugins+=(external_ctrl_r_history) ;; esac + # atuin >= 18.10 binds ctrl-r through the indirect dispatcher above rather than a plain + # `bind -x`, so the exact-allowlist match above never fires for it. Rather than resolve + # that indirection, trust atuin's own init-time flag ($__atuin_bind_ctrl_r) plus + # __atuin_history actually being defined as sufficient evidence atuin owns ctrl-r. This + # is a deliberately looser check than the live-binding match above: a ctrl-r rebound + # after atuin's init runs (a rare sequencing) won't be detected, which is an accepted + # trade-off in favor of covering the common bash+atuin case at all. fzf has no equivalent + # flag and keeps using the live-binding match exclusively. + if [ -z "$_WARP_EXTERNAL_CTRL_R_WIDGET" ] && [ "$__atuin_bind_ctrl_r" = true ] && + declare -F __atuin_history >/dev/null; then + _WARP_EXTERNAL_CTRL_R_WIDGET="__atuin_history" + shell_plugins+=(external_ctrl_r_history) + fi fi function warp_bootstrapped () { From b10681a675fc2e3cb51e02844279c5d4f27c52f9 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:23:05 +0000 Subject: [PATCH 15/70] Rename FzfCtrlRHandoff feature flag to ShellWidgetHandoff Mechanical rename ahead of adding ctrl-t file search support: the flag now gates the whole shell-widget-handoff mechanism (ctrl-r today, ctrl-t next), not just the ctrl-r case it originally shipped with. --- app/Cargo.toml | 4 ++-- app/src/features.rs | 4 ++-- app/src/terminal/view.rs | 4 ++-- crates/warp_features/src/lib.rs | 13 +++++++------ 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/app/Cargo.toml b/app/Cargo.toml index 84e6face501..d0fcfef077e 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -713,7 +713,7 @@ default = [ "orchestration_unified_stack", "ime_marked_text", "ctrl_c_cancels_third_party_harness", - "fzf_ctrl_r_handoff", + "shell_widget_handoff", ] # Enable this feature to automatically perform heap profiling. NOTE: This will # substantially slow down program execution. @@ -1053,7 +1053,7 @@ git_credential_refresh = [] prompt_cache_expiry_warning = [] osc_hyperlinks = [] ctrl_c_cancels_third_party_harness = [] -fzf_ctrl_r_handoff = [] +shell_widget_handoff = [] [package.metadata.bundle.bin.warp-oss] category = "public.app-category.developer-tools" diff --git a/app/src/features.rs b/app/src/features.rs index 20fd455b8bf..a4b010d2404 100644 --- a/app/src/features.rs +++ b/app/src/features.rs @@ -523,8 +523,8 @@ fn enabled_features() -> HashSet { FeatureFlag::TerminalLifecycleRecovery, #[cfg(feature = "ctrl_c_cancels_third_party_harness")] FeatureFlag::CtrlCCancelsThirdPartyHarness, - #[cfg(feature = "fzf_ctrl_r_handoff")] - FeatureFlag::FzfCtrlRHandoff, + #[cfg(feature = "shell_widget_handoff")] + FeatureFlag::ShellWidgetHandoff, ]); flags diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index 6455112d56f..d2201fb1df6 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -9215,7 +9215,7 @@ impl TerminalView { &mut self, ctx: &mut ViewContext, ) -> bool { - if !FeatureFlag::FzfCtrlRHandoff.is_enabled() || self.is_long_running() { + if !FeatureFlag::ShellWidgetHandoff.is_enabled() || self.is_long_running() { return false; } let Some(session_id) = self.active_block_session_id() else { @@ -12974,7 +12974,7 @@ impl TerminalView { } } ModelEvent::ExternalCtrlRSelection(data) => { - if FeatureFlag::FzfCtrlRHandoff.is_enabled() + if FeatureFlag::ShellWidgetHandoff.is_enabled() && let Some(session_id) = data.session_id.map(SessionId::from) { self.input.update(ctx, |input, _ctx| { diff --git a/crates/warp_features/src/lib.rs b/crates/warp_features/src/lib.rs index e7a57fb9852..d99e2c6d12a 100644 --- a/crates/warp_features/src/lib.rs +++ b/crates/warp_features/src/lib.rs @@ -976,11 +976,12 @@ pub enum FeatureFlag { /// signaled or torn down. CtrlCCancelsThirdPartyHarness, - /// Prototype: when the active session's shell has rebound `^R` away from its default - /// reverse-history-search widget (e.g. to fzf or atuin, detected generically during - /// bootstrap and reported via the `external_ctrl_r_history` shell plugin tag), hands - /// ctrl-r off to that widget instead of opening Warp's own command search. - FzfCtrlRHandoff, + /// Prototype: when the active session's shell has rebound a key (ctrl-r, ctrl-t) away + /// from its default line-editor binding to an external tool's widget (e.g. fzf or + /// atuin, detected during bootstrap and reported via a per-binding shell plugin tag + /// such as `external_ctrl_r_history` or `external_ctrl_t_file`), hands that keypress + /// off to the tool's widget instead of Warp's own UI for it. + ShellWidgetHandoff, } static FLAG_STATES: [AtomicBool; cardinality::()] = @@ -1055,7 +1056,7 @@ pub const DOGFOOD_FLAGS: &[FeatureFlag] = &[ FeatureFlag::BoxDrawingGlyphs, FeatureFlag::PricingTransparency, FeatureFlag::PeriodicHandoffCheckpoints, - FeatureFlag::FzfCtrlRHandoff, + FeatureFlag::ShellWidgetHandoff, ]; /// Features enabled for feature preview build users (e.g.: Friends of Warp). From 52e630a703f774787d318f22a16f18c102096f5d Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:35:34 +0000 Subject: [PATCH 16/70] Add ctrl-t file-search Rust plumbing parallel to ctrl-r Adds the DCS hook (ExternalCtrlTSelection), Input-side state (PendingCtrlTHandoff, trigger/set functions), the input-restore splice branch in handle_block_completed_event, and the TerminalView entry point (maybe_trigger_external_ctrl_t_file_search) plus its shell plugin tag/helper-command constants -- all as a parallel structure alongside the existing, verified ctrl-r implementation rather than a shared abstraction. Not yet wired: the actual ctrl-t keybinding, and the three shells' bootstrap-side detection/helper scripts. Rust side builds clean. --- app/src/terminal/event.rs | 16 ++- app/src/terminal/input.rs | 152 ++++++++++++++++++++++- app/src/terminal/model/ansi/dcs_hooks.rs | 43 +++++++ app/src/terminal/model/ansi/handler.rs | 4 + app/src/terminal/model/ansi/mod.rs | 3 + app/src/terminal/model/terminal_model.rs | 11 +- app/src/terminal/model_events.rs | 8 +- app/src/terminal/view.rs | 59 +++++++++ 8 files changed, 285 insertions(+), 11 deletions(-) diff --git a/app/src/terminal/event.rs b/app/src/terminal/event.rs index e72c750d3af..7c89b07679b 100644 --- a/app/src/terminal/event.rs +++ b/app/src/terminal/event.rs @@ -10,7 +10,9 @@ pub use remote_server::setup::RemoteServerSetupState; use warp_util::lazy::Lazy; use super::history::HistoryEntry; -use super::model::ansi::{ExternalCtrlRSelectionValue, FinishUpdateValue}; +use super::model::ansi::{ + ExternalCtrlRSelectionValue, ExternalCtrlTSelectionValue, FinishUpdateValue, +}; use super::model::block::BlockId; use super::model::lifecycle::LifecycleRecoveryRecord; use super::model::session::{SessionId, SessionInfo}; @@ -133,6 +135,9 @@ pub enum Event { /// Emitted when the shell reports the command selected in its external ctrl-r history /// widget (e.g. fzf or atuin). ExternalCtrlRSelection(ExternalCtrlRSelectionValue), + /// Emitted when the shell reports the path(s) selected in its external ctrl-t file-search + /// widget (e.g. fzf). + ExternalCtrlTSelection(ExternalCtrlTSelectionValue), TextSelectionChanged, ShellSpawned(ShellType), SendCompletionsPrompt, @@ -544,6 +549,15 @@ impl Debug for Event { data.buffer.len() ) } + Event::ExternalCtrlTSelection(data) => { + // The buffer is a selected file path, which may reveal filesystem structure; + // log only its length rather than its contents. + write!( + f, + "ExternalCtrlTSelection(buffer_len: {})", + data.buffer.len() + ) + } 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 1353ce34203..85e0b1027f6 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -1783,6 +1783,11 @@ pub struct Input { /// [`Self::trigger_external_ctrl_r_history_search`], if any. `None` once the handoff's block /// has completed (see [`Self::handle_block_completed_event`]) or no handoff is in flight. pending_ctrl_r_handoff: Option, + + /// State for an in-flight external ctrl-t handoff started by + /// [`Self::trigger_external_ctrl_t_file_search`], if any. `None` once the handoff's block + /// has completed (see [`Self::handle_block_completed_event`]) or no handoff is in flight. + pending_ctrl_t_handoff: Option, } /// State for an in-flight external ctrl-r handoff (see @@ -1824,6 +1829,50 @@ impl PendingCtrlRHandoff { } } +/// State for an in-flight external ctrl-t handoff (see +/// [`Input::trigger_external_ctrl_t_file_search`]). Unlike ctrl-r, which replaces the entire +/// buffer with the selection, ctrl-t inserts the selection into the buffer the user had before +/// the handoff, at the cursor position ctrl-t was pressed at -- so this snapshots the original +/// buffer and cursor offset separately from the (initially absent) selection. +struct PendingCtrlTHandoff { + session_id: SessionId, + token: String, + /// The buffer the user had before ctrl-t was pressed, restored verbatim on cancel (or as the + /// base the selection is inserted into, on a completed selection). + original_buffer: String, + /// The byte offset within `original_buffer` that the selection is inserted at. + cursor_offset: ByteOffset, + /// The selected path(s), once a matching `ExternalCtrlTSelection` hook supplies one. `None` + /// while no selection has arrived yet, or the user cancelled without selecting anything. + insertion: Option, + /// The block running the synthetic helper command. Hidden once it completes (see + /// [`Input::handle_block_completed_event`]) so it doesn't clutter scrollback. + block_id: BlockId, +} + +impl PendingCtrlTHandoff { + /// Applies `selection` to `pending` if it matches an in-flight handoff for `session_id` and + /// `token`; otherwise leaves `pending` untouched. Mirrors + /// [`PendingCtrlRHandoff::maybe_apply_selection`] -- see its comment for why this guards + /// against unsolicited and stale selections. + fn maybe_apply_selection( + pending: &mut Option, + session_id: SessionId, + token: &str, + selection: &str, + ) { + let Some(handoff) = pending else { + return; + }; + if handoff.session_id != session_id || handoff.token != token { + return; + } + if !selection.is_empty() { + handoff.insertion = Some(selection.to_string()); + } + } +} + struct AmbientAgentViewState { view_model: ModelHandle, #[allow(dead_code)] @@ -4068,6 +4117,7 @@ impl Input { ephemeral_message_model, input_contents_before_prompt_chip_command: None, pending_ctrl_r_handoff: None, + pending_ctrl_t_handoff: None, }; #[cfg(feature = "local_fs")] @@ -7604,6 +7654,64 @@ impl Input { ); } + /// Runs `helper_command` (a bootstrap-installed shell function) as if the user had typed and + /// submitted it, mirroring [`Self::trigger_external_ctrl_r_history_search`]. Unlike ctrl-r, + /// which replaces the whole buffer with the selection, ctrl-t inserts the selection into the + /// buffer at the cursor position ctrl-t was pressed at -- so this snapshots the current + /// buffer text and cursor byte offset separately, rather than a single restorable string. + /// Returns `true` if the command was started. + /// + /// See [`Self::trigger_external_ctrl_r_history_search`] for why the command is prefixed with + /// a leading space. + pub fn trigger_external_ctrl_t_file_search( + &mut self, + helper_command: &str, + ctx: &mut ViewContext, + ) -> bool { + let Some(session_id) = self.active_block_session_id() else { + return false; + }; + let original_buffer = self.buffer_text(ctx); + let cursor_offset = self + .editor + .as_ref(ctx) + .end_byte_index_of_last_selection(ctx); + let block_id = self.model.lock().block_list().active_block_id().clone(); + let token = Uuid::new_v4().to_string(); + let command = format!(" {helper_command} {token}"); + let started = + self.try_execute_command_from_source(&command, CommandExecutionSource::User, ctx); + if started { + self.pending_ctrl_t_handoff = Some(PendingCtrlTHandoff { + session_id, + token, + original_buffer, + cursor_offset, + insertion: None, + block_id, + }); + } + started + } + + /// Called when the shell reports the path(s) selected in the external ctrl-t file search + /// (fzf). Applies the selection only if `session_id` and `token` match an in-flight handoff + /// this session started (see [`PendingCtrlTHandoff`]); otherwise ignores it, mirroring + /// [`Self::set_external_ctrl_r_selection`]. + pub fn set_external_ctrl_t_selection( + &mut self, + session_id: SessionId, + token: &str, + selection: &str, + ) { + PendingCtrlTHandoff::maybe_apply_selection( + &mut self.pending_ctrl_t_handoff, + session_id, + token, + selection, + ); + } + fn try_execute_command_with_options( &mut self, command: &str, @@ -15340,11 +15448,11 @@ impl Input { && !cloud_setup_pre_first_exchange && !self.has_queued_command_in_flight(ctx); let latest_block_id = self.model.lock().block_list().active_block_id().clone(); - // Prefer a prompt-chip restore (e.g. `cd`) over a ctrl-r handoff restore; the two + // Prefer a prompt-chip restore (e.g. `cd`) over a ctrl-r/ctrl-t handoff restore; these // cannot both be pending for the same block in practice. Taking - // `pending_ctrl_r_handoff` here also ends that handoff: any `ExternalCtrlRSelection` - // hook that arrives after this point is treated as stale and ignored (see - // `PendingCtrlRHandoff`). + // `pending_ctrl_r_handoff`/`pending_ctrl_t_handoff` here also ends that handoff: any + // `ExternalCtrlRSelection`/`ExternalCtrlTSelection` hook that arrives after this point + // is treated as stale and ignored (see `PendingCtrlRHandoff`/`PendingCtrlTHandoff`). let completed_ctrl_r_handoff = self .pending_ctrl_r_handoff .take_if(|handoff| handoff.block_id == block_completed_event.block_id); @@ -15354,10 +15462,24 @@ impl Input { .block_list_mut() .hide_block(&handoff.block_id); } + let completed_ctrl_t_handoff = self + .pending_ctrl_t_handoff + .take_if(|handoff| handoff.block_id == block_completed_event.block_id); + if let Some(handoff) = &completed_ctrl_t_handoff { + self.model + .lock() + .block_list_mut() + .hide_block(&handoff.block_id); + } let pending_input_restore = self .input_contents_before_prompt_chip_command .take() - .or_else(|| completed_ctrl_r_handoff.map(|handoff| handoff.restore_text)); + .or_else(|| completed_ctrl_r_handoff.map(|handoff| handoff.restore_text)) + .or_else(|| { + completed_ctrl_t_handoff + .as_ref() + .map(|handoff| handoff.original_buffer.clone()) + }); if should_clear_buffer { // We want to reinitialize the buffer whenever a command is completed so that @@ -15369,11 +15491,29 @@ impl Input { self.latest_buffer_operations = Vec::new(); // If we have a pending input restore (from a prompt chip command like cd, or - // a ctrl-r external history handoff), restore the input contents instead of + // a ctrl-r/ctrl-t external handoff), restore the input contents instead of // leaving the buffer empty. if let Some(restore_text) = pending_input_restore { self.editor.update(ctx, |editor, ctx| { editor.set_buffer_text(&restore_text, ctx); + // A ctrl-t handoff restores the pre-handoff buffer above, then (unlike + // ctrl-r) splices its selection in at the captured cursor offset + // instead of replacing the whole buffer, or just moves the cursor back + // to that offset if the user cancelled without selecting anything. + if let Some(handoff) = &completed_ctrl_t_handoff { + match &handoff.insertion { + Some(insertion) => editor.select_and_replace( + insertion, + [handoff.cursor_offset..handoff.cursor_offset], + PlainTextEditorViewAction::InsertSelectedText, + ctx, + ), + None => editor.select_ranges_by_byte_offset( + [handoff.cursor_offset..handoff.cursor_offset], + ctx, + ), + } + } }); self.is_editor_empty_on_last_edit = false; } else { diff --git a/app/src/terminal/model/ansi/dcs_hooks.rs b/app/src/terminal/model/ansi/dcs_hooks.rs index b1da781ebd7..c049ad94df1 100644 --- a/app/src/terminal/model/ansi/dcs_hooks.rs +++ b/app/src/terminal/model/ansi/dcs_hooks.rs @@ -74,6 +74,12 @@ pub(super) enum DProtoHook { ExternalCtrlRSelection { value: ExternalCtrlRSelectionValue, }, + /// Reports the path(s) selected in the shell's external ctrl-t file-search widget (e.g. + /// fzf), so they can be inserted into the input editor at the cursor position ctrl-t was + /// pressed at. See [`ExternalCtrlTSelectionValue`]. + ExternalCtrlTSelection { + value: ExternalCtrlTSelectionValue, + }, Clear { value: ClearValue, }, @@ -102,6 +108,7 @@ const DPROTO_HOOK_VARIANTS: &[&str] = &[ "InitShell", "InputBuffer", "ExternalCtrlRSelection", + "ExternalCtrlTSelection", "Clear", "InitSubshell", "SourcedRcFileForWarp", @@ -158,6 +165,9 @@ impl<'de> Deserialize<'de> for DProtoHook { "ExternalCtrlRSelection" => DProtoHook::ExternalCtrlRSelection { value: parse_hook_value::<_, D::Error>(raw.value)?, }, + "ExternalCtrlTSelection" => DProtoHook::ExternalCtrlTSelection { + value: parse_hook_value::<_, D::Error>(raw.value)?, + }, "Clear" => DProtoHook::Clear { value: parse_hook_value::<_, D::Error>(raw.value)?, }, @@ -195,6 +205,7 @@ impl DProtoHook { DProtoHook::InitShell { .. } => "InitShell", DProtoHook::InputBuffer { .. } => "InputBuffer", DProtoHook::ExternalCtrlRSelection { .. } => "ExternalCtrlRSelection", + DProtoHook::ExternalCtrlTSelection { .. } => "ExternalCtrlTSelection", DProtoHook::Clear { .. } => "Clear", DProtoHook::InitSubshell { .. } => "InitSubshell", DProtoHook::SourcedRcFileForWarp { .. } => "SourcedRcFileForWarp", @@ -215,6 +226,7 @@ impl DProtoHook { DProtoHook::Bootstrapped { value } => value.session_id.map(SessionId::from), DProtoHook::InputBuffer { value } => value.session_id.map(SessionId::from), DProtoHook::ExternalCtrlRSelection { value } => value.session_id.map(SessionId::from), + DProtoHook::ExternalCtrlTSelection { 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), @@ -237,6 +249,7 @@ impl DProtoHook { | DProtoHook::InitShell { .. } | DProtoHook::InputBuffer { .. } | DProtoHook::ExternalCtrlRSelection { .. } + | DProtoHook::ExternalCtrlTSelection { .. } | DProtoHook::Clear { .. } | DProtoHook::InitSubshell { .. } | DProtoHook::FinishUpdate { .. } @@ -1026,6 +1039,36 @@ impl std::fmt::Debug for ExternalCtrlRSelectionValue { } } +/// Received from the pty after the shell's external ctrl-t file-search widget (e.g. fzf, +/// detected via the `external_ctrl_t_file` [`BootstrappedValue::shell_plugins`] tag) finishes, +/// reporting the path(s) the user selected. Empty when the user cancelled without selecting +/// anything. Warp inserts the selection into the input editor, at the cursor position ctrl-t +/// was pressed at, without executing it. +/// +/// `token` echoes back the handoff token the client sent as an argument to the shell helper that +/// emits this hook, so the client can verify this is the reply to a handoff it's actually +/// waiting on rather than an unsolicited or stale write to the pty. +#[derive(Default, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct ExternalCtrlTSelectionValue { + pub buffer: String, + #[serde(default)] + pub token: String, + #[serde(default)] + pub session_id: HookSessionId, +} + +impl std::fmt::Debug for ExternalCtrlTSelectionValue { + /// Redacts `buffer`, since it carries file path(s) that may reveal sensitive information + /// about the user's filesystem. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ExternalCtrlTSelectionValue") + .field("buffer", &"") + .field("token", &self.token) + .field("session_id", &self.session_id) + .finish() + } +} + /// Received from the pty when the terminal screen should be cleared (e.g. via /// the `clear` command or ctrl-l). #[derive(Debug, Default, Deserialize, Serialize)] diff --git a/app/src/terminal/model/ansi/handler.rs b/app/src/terminal/model/ansi/handler.rs index 03bcc2a47eb..d34f4ac4b3c 100644 --- a/app/src/terminal/model/ansi/handler.rs +++ b/app/src/terminal/model/ansi/handler.rs @@ -312,6 +312,10 @@ pub trait Handler { /// external ctrl-r history widget (e.g. fzf or atuin). fn external_ctrl_r_selection(&mut self, _data: ExternalCtrlRSelectionValue) {} + /// Callback for the terminal when the shell reports the path(s) selected in its + /// external ctrl-t file-search widget (e.g. fzf). + fn external_ctrl_t_selection(&mut self, _data: ExternalCtrlTSelectionValue) {} + /// 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/app/src/terminal/model/ansi/mod.rs b/app/src/terminal/model/ansi/mod.rs index c04b0d0505a..d1120809798 100644 --- a/app/src/terminal/model/ansi/mod.rs +++ b/app/src/terminal/model/ansi/mod.rs @@ -608,6 +608,9 @@ impl<'a, H: Handler + 'a, W: io::Write> Performer<'a, H, W> { Ok(DProtoHook::ExternalCtrlRSelection { value }) => { self.handler.external_ctrl_r_selection(value) } + Ok(DProtoHook::ExternalCtrlTSelection { value }) => { + self.handler.external_ctrl_t_selection(value) + } Ok(DProtoHook::Clear { value }) => self.handler.clear(value), Ok(DProtoHook::InitSubshell { value }) => self.handler.init_subshell(value), Ok(DProtoHook::SourcedRcFileForWarp { .. }) => { diff --git a/app/src/terminal/model/terminal_model.rs b/app/src/terminal/model/terminal_model.rs index ed7665c869f..b88eaf93431 100644 --- a/app/src/terminal/model/terminal_model.rs +++ b/app/src/terminal/model/terminal_model.rs @@ -65,9 +65,9 @@ pub use crate::terminal::history::HistoryEntry; use crate::terminal::model::ansi; use crate::terminal::model::ansi::{ ClearValue, CommandFinishedValue, CompletionMetadata, ExitShellValue, - ExternalCtrlRSelectionValue, Handler, InitShellValue, InitSubshellValue, - PreInteractiveSSHSessionValue, PrecmdValue, PreexecValue, PromptMetadata, SSHValue, - SourcedRcFileForWarpValue, + ExternalCtrlRSelectionValue, ExternalCtrlTSelectionValue, Handler, InitShellValue, + InitSubshellValue, PreInteractiveSSHSessionValue, PrecmdValue, PreexecValue, PromptMetadata, + SSHValue, SourcedRcFileForWarpValue, }; use crate::terminal::model::bootstrap::BootstrapStage; use crate::terminal::model::completions::{ @@ -3182,6 +3182,11 @@ impl ansi::Handler for TerminalModel { .send_terminal_event(Event::ExternalCtrlRSelection(data)); } + fn external_ctrl_t_selection(&mut self, data: ExternalCtrlTSelectionValue) { + self.event_proxy + .send_terminal_event(Event::ExternalCtrlTSelection(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 fd0de4d11fc..d15c71850ce 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::{ExternalCtrlRSelectionValue, FinishUpdateValue}; +use super::model::ansi::{ + ExternalCtrlRSelectionValue, ExternalCtrlTSelectionValue, FinishUpdateValue, +}; use super::model::block::BlockId; use super::model::completions::ShellCompletion; use super::model::lifecycle::LifecycleTelemetryEvent; @@ -262,6 +264,7 @@ impl ModelEventDispatcher { Event::Typeahead => ModelEvent::Typeahead, Event::FinishUpdate(data) => ModelEvent::FinishUpdate(data), Event::ExternalCtrlRSelection(data) => ModelEvent::ExternalCtrlRSelection(data), + Event::ExternalCtrlTSelection(data) => ModelEvent::ExternalCtrlTSelection(data), Event::TextSelectionChanged => ModelEvent::SelectedTextChanged, Event::ShellSpawned(shell_type) => ModelEvent::ShellSpawned(shell_type), Event::SendCompletionsPrompt => ModelEvent::SendCompletionsPrompt, @@ -450,6 +453,9 @@ pub enum ModelEvent { /// Emitted when the shell reports the command selected in its external ctrl-r history /// widget (e.g. fzf or atuin). ExternalCtrlRSelection(ExternalCtrlRSelectionValue), + /// Emitted when the shell reports the path(s) selected in its external ctrl-t file-search + /// widget (e.g. fzf). + ExternalCtrlTSelection(ExternalCtrlTSelectionValue), SelectedTextChanged, ShellSpawned(ShellType), CompletionsFinished(Vec), diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index d2201fb1df6..a6fe932c4d5 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -724,6 +724,18 @@ const EXTERNAL_CTRL_R_HISTORY_PLUGIN_TAG: &str = "external_ctrl_r_history"; /// `app/assets/bundled/bootstrap/zsh_body.sh`. const EXTERNAL_CTRL_R_HELPER_COMMAND: &str = "warp_run_external_ctrl_r_widget"; +/// `shell_plugins` tag reported by bootstrap when the shell's `^T` binding has been rebound away +/// from its default line-editor binding to an external file-search widget (e.g. fzf). Independent +/// of [`EXTERNAL_CTRL_R_HISTORY_PLUGIN_TAG`] -- a shell can have either, both, or neither, since +/// each binding is detected and reported on its own. Must match the tag name used in +/// `app/assets/bundled/bootstrap/zsh_body.sh`. +const EXTERNAL_CTRL_T_FILE_PLUGIN_TAG: &str = "external_ctrl_t_file"; + +/// Name of the bootstrap-installed shell function invoked to hand ctrl-t off to the shell's own +/// external file-search widget. Must match the function name defined in +/// `app/assets/bundled/bootstrap/zsh_body.sh`. +const EXTERNAL_CTRL_T_HELPER_COMMAND: &str = "warp_run_external_ctrl_t_widget"; + pub const LONG_RUNNING_AGENT_REQUESTED_COMMAND_CONTEXT_KEY: &str = "LongRunningRequestedCommand"; pub const LONG_RUNNING_AGENT_REQUESTED_COMMAND_USER_TOOK_OVER_CONTEXT_KEY: &str = "LongRunningRequestedUserTookOverCommand"; @@ -9240,6 +9252,44 @@ impl TerminalView { }) } + /// If ctrl-t was pressed at an idle prompt on a session whose shell has rebound `^T` to an + /// external file-search widget (reported via the [`EXTERNAL_CTRL_T_FILE_PLUGIN_TAG`] shell + /// plugin tag, e.g. by fzf), hands the keypress off to that widget. Mirrors + /// [`Self::maybe_trigger_external_ctrl_r_history_search`], but lands the selection by + /// inserting it into the input editor at the cursor position rather than replacing the + /// whole buffer; see [`Input::trigger_external_ctrl_t_file_search`]. + /// + /// Returns `true` if the handoff was triggered, in which case the caller should not pass + /// ctrl-t through to the pty or handle it any other way. + pub fn maybe_trigger_external_ctrl_t_file_search( + &mut self, + ctx: &mut ViewContext, + ) -> bool { + if !FeatureFlag::ShellWidgetHandoff.is_enabled() || self.is_long_running() { + return false; + } + let Some(session_id) = self.active_block_session_id() else { + return false; + }; + let has_external_ctrl_t_widget = + self.sessions + .as_ref(ctx) + .get(session_id) + .is_some_and(|session| { + session + .shell() + .plugins() + .contains(EXTERNAL_CTRL_T_FILE_PLUGIN_TAG) + }); + if !has_external_ctrl_t_widget || self.model.lock().is_alt_screen_active() { + return false; + } + + self.input.update(ctx, |input, ctx| { + input.trigger_external_ctrl_t_file_search(EXTERNAL_CTRL_T_HELPER_COMMAND, ctx) + }) + } + /// Returns `true` when an interactive SSH command has been detected at /// preexec and the SSH block is still running (long-running). Used by /// the workspace to derive `PendingRemoteSession` without storing @@ -12982,6 +13032,15 @@ impl TerminalView { }); } } + ModelEvent::ExternalCtrlTSelection(data) => { + if FeatureFlag::ShellWidgetHandoff.is_enabled() + && let Some(session_id) = data.session_id.map(SessionId::from) + { + self.input.update(ctx, |input, _ctx| { + input.set_external_ctrl_t_selection(session_id, &data.token, &data.buffer); + }); + } + } ModelEvent::SelectedTextChanged => { ctx.emit(Event::SelectedTextChanged); } From 607d640cd756dc6c733f0a1dd1a02c6ef20dba3e Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:41:28 +0000 Subject: [PATCH 17/70] Wire the ctrl-t keybinding to the external file-search handoff Adds WorkspaceAction::TriggerExternalCtrlTFileSearch, its Workspace handler (mirrors show_command_search but with no Warp-native fallback UI, since ctrl-t currently does nothing in Warp's input box), and the default ctrl-t key binding in the Input context. Rust side of the ctrl-t feature is now fully wired end to end; only the shell-side detection/helper scripts remain. --- app/src/terminal/input.rs | 7 +++++++ app/src/workspace/action.rs | 5 +++++ app/src/workspace/view.rs | 15 +++++++++++++++ 3 files changed, 27 insertions(+) diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index 85e0b1027f6..91195f091e7 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -2180,6 +2180,13 @@ pub fn init(app: &mut AppContext) { ) .with_context_predicate(id!("Input")) .with_key_binding("tab"), + EditableBinding::new( + "workspace:trigger_external_ctrl_t_file_search", + "External File Search", + WorkspaceAction::TriggerExternalCtrlTFileSearch, + ) + .with_context_predicate(id!("Input") & !id!("VoltronActive")) + .with_key_binding("ctrl-t"), ]); if let Some(custom_action) = workflows::CategoriesView::custom_action() { diff --git a/app/src/workspace/action.rs b/app/src/workspace/action.rs index ea0d9b75747..35cd5664273 100644 --- a/app/src/workspace/action.rs +++ b/app/src/workspace/action.rs @@ -355,6 +355,10 @@ pub enum WorkspaceAction { ClickedAIAssistantIcon, ToggleKeybindingsPage, ShowCommandSearch(CommandSearchOptions), + /// If the active session's shell has rebound ctrl-t to an external file-search widget + /// (e.g. fzf), hands the keypress off to it. A no-op otherwise -- unlike `ShowCommandSearch`, + /// there's no Warp-native ctrl-t UI to fall back to. + TriggerExternalCtrlTFileSearch, CreatePersonalNotebook, ImportToPersonalDrive, ImportToTeamDrive, @@ -1072,6 +1076,7 @@ impl WorkspaceAction { | OpenPromptSuggestionsUnavailableModal | ToggleKeybindingsPage | ShowCommandSearch(_) + | TriggerExternalCtrlTFileSearch | ToggleMouseReporting | ToggleScrollReporting | ToggleFocusReporting diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index 24e34ecadab..27ee4e77543 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -17366,6 +17366,20 @@ impl Workspace { } } + /// If the active session's shell has rebound ctrl-t to an external file-search widget + /// (e.g. fzf), hands the keypress off to it. A no-op otherwise -- unlike + /// [`Self::show_command_search`], ctrl-t has no Warp-native UI to fall back to. + fn trigger_external_ctrl_t_file_search(&mut self, ctx: &mut ViewContext) { + if self.is_readonly_shared_session_active(ctx) { + return; + } + if let Some(terminal_view_handle) = self.active_session_view(ctx) { + terminal_view_handle.update(ctx, |terminal_view, ctx| { + terminal_view.maybe_trigger_external_ctrl_t_file_search(ctx); + }); + } + } + fn get_active_input_view_handle(&self, app: &AppContext) -> Option> { app.view(self.active_tab_pane_group()) .active_session_view(app) @@ -24496,6 +24510,7 @@ impl TypedActionView for Workspace { filter, init_content, }) => self.show_command_search(*filter, init_content, ctx), + TriggerExternalCtrlTFileSearch => self.trigger_external_ctrl_t_file_search(ctx), ImportToPersonalDrive => { if let Some(personal_drive) = UserWorkspaces::as_ref(ctx).personal_drive(ctx) { self.open_import_modal(personal_drive, &None, ctx); From 93d4c4d41f1e4a138fbf1c77c153f4f7e1f35bb5 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:48:29 +0000 Subject: [PATCH 18/70] Add ctrl-t file-search shell detection and helpers for bash, zsh, fish Mirrors the ctrl-r external-history detection/helper structure already present in each shell, using an independent shell_plugins tag (external_ctrl_t_file) and a parallel warp_run_external_ctrl_t_widget() helper: - bash: detects fzf's ctrl-t binding via bind -X, invokes __fzf_select__ directly. - zsh: detects via bindkey -M main '^T', invokes __fzf_select (single underscore) directly. - fish: adds warp_external_ctrl_t_widget (mirrors the ctrl-r detector), and since fish has no picker function separable from its fzf-file-widget, invokes fzf directly against a find-style command honoring $FZF_CTRL_T_COMMAND/$FZF_CTRL_T_OPTS. This deliberately skips fish's own commandline-token parsing (dir/query/prefix), landing a plain selection at the cursor like bash/zsh -- a documented simplification. All three scripts extend their respective history-exclusion mechanisms (HISTIGNORE, _warp_zshaddhistory, fish_should_add_to_history) to keep the new helper invocation out of the user's history. --- app/assets/bundled/bootstrap/bash_body.sh | 55 +++++++++++++++-- app/assets/bundled/bootstrap/fish.sh | 72 +++++++++++++++++++++-- app/assets/bundled/bootstrap/zsh_body.sh | 51 ++++++++++++++-- 3 files changed, 164 insertions(+), 14 deletions(-) diff --git a/app/assets/bundled/bootstrap/bash_body.sh b/app/assets/bundled/bootstrap/bash_body.sh index 487ab7dca81..8f6ea5f7b1e 100644 --- a/app/assets/bundled/bootstrap/bash_body.sh +++ b/app/assets/bundled/bootstrap/bash_body.sh @@ -847,6 +847,31 @@ if [ -z "$WARP_BOOTSTRAPPED" ]; then warp_send_json_message "{ \"hook\": \"ExternalCtrlRSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"token\": \"$warp_escaped_token\", \"session_id\": $WARP_SESSION_ID } }" } + # Runs the shell's own ctrl-t file-search widget (fzf, per the function captured in + # $_WARP_EXTERNAL_CTRL_T_WIDGET during bootstrap) as a synthetic foreground command, mirroring + # warp_run_external_ctrl_r_widget above. Reports the selected path(s) (or an empty buffer, if + # cancelled) via the ExternalCtrlTSelection hook so Warp can insert them into the input editor + # at the cursor position, without executing anything. The handoff token given as $1 is echoed + # back unchanged, so Warp can confirm the hook is actually the reply to the handoff it started + # rather than an unrelated write to the pty. + warp_run_external_ctrl_t_widget () { + local warp_ctrl_t_token="$1" + local result="" + case "$_WARP_EXTERNAL_CTRL_T_WIDGET" in + fzf-file-widget) + # __fzf_select__ (installed by fzf's bash integration) runs the same find|fzf + # pipeline fzf-file-widget itself uses, honoring $FZF_CTRL_T_COMMAND/$FZF_CTRL_T_OPTS + # if the user set them, and echoes the space-escaped selection to stdout -- this is + # exactly what fzf-file-widget calls before splicing the result into READLINE_LINE at + # the cursor itself, which we don't want here since we land the selection ourselves. + result="$(__fzf_select__)" + ;; + esac + local warp_escaped_selection="$(warp_escape_json "$result")" + local warp_escaped_token="$(warp_escape_json "$warp_ctrl_t_token")" + warp_send_json_message "{ \"hook\": \"ExternalCtrlTSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"token\": \"$warp_escaped_token\", \"session_id\": $WARP_SESSION_ID } }" + } + # Check whether the prompt-related variables have OSC prompt marker sequences, # and if not, wrap them with the appropriate markers so that we can direct the # prompt bytes to the appropriate grids. @@ -1288,14 +1313,16 @@ esac # HISTIGNORE value which may been set in an RC file sourced above. It is important to # ensure that this happens _after_ the user's RC files have been sourced. # - # This also excludes the ctrl-r external history handoff helper (see - # warp_run_external_ctrl_r_widget above): it's a Warp-internal invocation, not a command the - # user meant to run again later, and leaving it in history would otherwise pollute the very - # history list this feature searches on the next ctrl-r. + # This also excludes the ctrl-r/ctrl-t external handoff helpers (see + # warp_run_external_ctrl_r_widget/warp_run_external_ctrl_t_widget above): they're + # Warp-internal invocations, not commands the user meant to run again later, and leaving + # them in history would otherwise pollute the very history list ctrl-r searches (and, for + # the ctrl-t helper specifically, still show up as ordinary shell history noise even though + # ctrl-t itself doesn't search shell history). if [[ ! -z $HISTIGNORE ]]; then - HISTIGNORE="*warp_run_generator_command*:*warp_run_external_ctrl_r_widget*:$HISTIGNORE" + HISTIGNORE="*warp_run_generator_command*:*warp_run_external_ctrl_r_widget*:*warp_run_external_ctrl_t_widget*:$HISTIGNORE" else - HISTIGNORE="*warp_run_generator_command*:*warp_run_external_ctrl_r_widget*" + HISTIGNORE="*warp_run_generator_command*:*warp_run_external_ctrl_r_widget*:*warp_run_external_ctrl_t_widget*" fi # If the user has PROMPT_COMMAND set in their bootstrap scripts, @@ -1451,6 +1478,22 @@ esac _WARP_EXTERNAL_CTRL_R_WIDGET="__atuin_history" shell_plugins+=(external_ctrl_r_history) fi + + # Detect whether ctrl-t has been rebound to fzf's file-search widget, independent of + # whichever tool (if any) owns ctrl-r above -- a user may have one binding without the + # other. fzf binds ctrl-t directly via `bind -x` in every keymap (emacs, vi-insert, and + # vi-command all get their own `-x` binding to the same widget name), unlike alt-c, which + # has no `-x`-bindable form and instead uses a macro-chain trick to reach emacs mode's + # command substitution; that asymmetry is why ctrl-t doesn't need a flag-based fallback + # the way ctrl-r's newer-atuin case does. atuin has no ctrl-t equivalent. + _WARP_EXTERNAL_CTRL_T_WIDGET="" + warp_ctrl_t_binding="$(bind -X 2>/dev/null | command -p sed -n 's/^"\\C-t": "\(.*\)"$/\1/p')" + case "$warp_ctrl_t_binding" in + fzf-file-widget) + _WARP_EXTERNAL_CTRL_T_WIDGET="$warp_ctrl_t_binding" + shell_plugins+=(external_ctrl_t_file) + ;; + esac fi function warp_bootstrapped () { diff --git a/app/assets/bundled/bootstrap/fish.sh b/app/assets/bundled/bootstrap/fish.sh index 9505bfe830f..0d68562d274 100644 --- a/app/assets/bundled/bootstrap/fish.sh +++ b/app/assets/bundled/bootstrap/fish.sh @@ -527,6 +527,22 @@ function warp_external_ctrl_r_widget echo "$widget" end +# Reports the widget `^T` is bound to, if the user has rebound it away from fish's default (no +# binding at all). Returns non-zero when `^T` has no non-preset binding. See +# warp_external_ctrl_r_widget above for why `bind` is queried with the pre-4.0 key spelling and +# `--preset` bindings are skipped. +function warp_external_ctrl_t_widget + set -l widget "" + for binding in (bind \ct 2>/dev/null) + if string match --quiet -- 'bind --preset *' "$binding" + continue + end + set widget (string replace --regex -- '^bind (-M \S+ +)?\S+ +' '' "$binding") + end + test -n "$widget"; or return 1 + echo "$widget" +end + # Runs the shell's own ctrl-r history tool (fzf or atuin, per the widget name captured in # $_WARP_EXTERNAL_CTRL_R_WIDGET during bootstrap) as a synthetic foreground command, so Warp's # existing long-running-command machinery hides the input editor and forwards keystrokes to the @@ -592,10 +608,43 @@ function warp_run_external_ctrl_r_widget warp_send_json_message "{ \"hook\": \"ExternalCtrlRSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"token\": \"$warp_escaped_token\", \"session_id\": $WARP_SESSION_ID } }" end -# Exclude the ctrl-r external history handoff helper (see warp_run_external_ctrl_r_widget -# above) from the user's history: it's a Warp-internal invocation, not a command the user meant -# to run again later, and leaving it in history would otherwise pollute the very history list -# this feature searches on the next ctrl-r. +# Runs fzf directly against a find-style command as a synthetic foreground command, mirroring +# warp_run_external_ctrl_r_widget above. Reports the selected path(s) (or an empty buffer, if +# cancelled) via the ExternalCtrlTSelection hook so Warp can insert them into the input editor at +# the cursor position, without executing anything. The handoff token given as $argv[1] is echoed +# back unchanged, so Warp can confirm the hook is the reply to the handoff it started rather than +# an unrelated write to the pty. +# +# Unlike bash/zsh, fish's fzf integration has no picker function separable from its bound +# fzf-file-widget: that function inline-parses the current commandline token into a search root, +# a seed query, and an option prefix (see fzf's own __fzf_parse_commandline), then replaces just +# that token with the selection. Warp deliberately doesn't reproduce that parsing -- it always +# searches from $PWD with no seed query and lands the plain selection at the cursor, the same +# simpler behavior bash/zsh already have (see PR description for the resulting difference). +function warp_run_external_ctrl_t_widget + set -l warp_ctrl_t_token "$argv[1]" + set -l result "" + switch "$_WARP_EXTERNAL_CTRL_T_WIDGET" + case 'fzf-file-widget' + set -lx FZF_DEFAULT_OPTS (__fzf_defaults \ + "--reverse --walker=file,dir,follow,hidden --scheme=path" \ + "--multi $FZF_CTRL_T_OPTS --print0") + set -lx FZF_DEFAULT_COMMAND "$FZF_CTRL_T_COMMAND" + set -lx FZF_DEFAULT_OPTS_FILE + set -l selected + if set selected (eval (__fzfcmd) | string split0) + set result (string join ' ' (string escape -n -- $selected))' ' + end + end + set -l warp_escaped_selection (warp_escape_json "$result") + set -l warp_escaped_token (warp_escape_json "$warp_ctrl_t_token") + warp_send_json_message "{ \"hook\": \"ExternalCtrlTSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"token\": \"$warp_escaped_token\", \"session_id\": $WARP_SESSION_ID } }" +end + +# Exclude the ctrl-r/ctrl-t external handoff helpers (see warp_run_external_ctrl_r_widget/ +# warp_run_external_ctrl_t_widget above) from the user's history: they're Warp-internal +# invocations, not commands the user meant to run again later, and leaving the ctrl-r one in +# history would otherwise pollute the very history list this feature searches on the next ctrl-r. # # fish only supports a single fish_should_add_to_history function (unlike zsh's array of # zshaddhistory hooks or bash's PROMPT_COMMAND-style stacking), so compose with any @@ -628,6 +677,9 @@ function fish_should_add_to_history # own "ignorespace" exclusion also catches it (see trigger_external_ctrl_r_history_search), and # that space must not defeat this match too. string match --quiet -- '*warp_run_external_ctrl_r_widget *' $argv[1]; and return 1 + # The ctrl-t helper isn't given a leading space (it doesn't need atuin's ignorespace exclusion, + # since atuin has no ctrl-t equivalent), so match it without one. + string match --quiet -- '*warp_run_external_ctrl_t_widget*' $argv[1]; and return 1 warp_original_fish_should_add_to_history $argv end @@ -654,6 +706,18 @@ function warp_bootstrapped set -g _WARP_EXTERNAL_CTRL_R_WIDGET "$warp_ctrl_r_widget" set -a shell_plugins external_ctrl_r_history end + + # Detect whether ctrl-t has been rebound to fzf's file-search widget, independent of whichever + # tool (if any) owns ctrl-r above -- a user may have one binding without the other. fzf's + # widget name for ctrl-t is the same across shells ("fzf-file-widget"); atuin has no ctrl-t + # equivalent. + set -g _WARP_EXTERNAL_CTRL_T_WIDGET "" + set -l warp_ctrl_t_widget (warp_external_ctrl_t_widget) + switch "$warp_ctrl_t_widget" + case 'fzf-file-widget' + set -g _WARP_EXTERNAL_CTRL_T_WIDGET "$warp_ctrl_t_widget" + set -a shell_plugins external_ctrl_t_file + end set -l escaped_shell_plugins (warp_escape_json $shell_plugins) set -l kernel_name (uname) diff --git a/app/assets/bundled/bootstrap/zsh_body.sh b/app/assets/bundled/bootstrap/zsh_body.sh index c15c42bd629..9d8985d000c 100644 --- a/app/assets/bundled/bootstrap/zsh_body.sh +++ b/app/assets/bundled/bootstrap/zsh_body.sh @@ -719,6 +719,31 @@ if [[ -z $WARP_BOOTSTRAPPED ]]; then warp_send_json_message "{ \"hook\": \"ExternalCtrlRSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"token\": \"$warp_escaped_token\", \"session_id\": $WARP_SESSION_ID } }" } + # Runs the shell's own ctrl-t file-search widget (fzf, per the widget name captured in + # $_WARP_EXTERNAL_CTRL_T_WIDGET during bootstrap) as a synthetic foreground command, mirroring + # warp_run_external_ctrl_r_widget above. Reports the selected path(s) (or an empty buffer, if + # cancelled) via the ExternalCtrlTSelection hook so Warp can insert them into the input editor + # at the cursor position, without executing anything. The handoff token given as $1 is echoed + # back unchanged, so Warp can confirm the hook is actually the reply to the handoff it started + # rather than an unrelated write to the pty. + function warp_run_external_ctrl_t_widget () { + local warp_ctrl_t_token="$1" + local result="" + case "$_WARP_EXTERNAL_CTRL_T_WIDGET" in + fzf-file-widget) + # __fzf_select (installed by fzf's zsh integration) runs the same find|fzf pipeline + # fzf-file-widget itself uses, honoring $FZF_CTRL_T_COMMAND/$FZF_CTRL_T_OPTS if the user + # set them, and echoes the shell-quoted selection to stdout -- this is exactly what + # fzf-file-widget calls before splicing the result into LBUFFER at the cursor itself, + # which we don't want here since we land the selection ourselves. + result="$(__fzf_select)" + ;; + esac + local warp_escaped_selection="$(warp_escape_json "$result")" + local warp_escaped_token="$(warp_escape_json "$warp_ctrl_t_token")" + warp_send_json_message "{ \"hook\": \"ExternalCtrlTSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"token\": \"$warp_escaped_token\", \"session_id\": $WARP_SESSION_ID } }" + } + function clear() { warp_send_json_message "{\"hook\": \"Clear\", \"value\": {\"session_id\": $WARP_SESSION_ID}}" } @@ -1288,10 +1313,11 @@ esac # See https://zsh.sourceforge.io/Doc/Release/Functions.html for more context # on the zshaddhistory hook. _warp_zshaddhistory() { - # Also exclude the ctrl-r external history handoff helper (see - # warp_run_external_ctrl_r_widget above): it's a Warp-internal invocation, not a command - # the user meant to run again later. - _is_warp_generator_command "$1" && [[ "$1" != *"warp_run_external_ctrl_r_widget"* ]] + # Also exclude the ctrl-r/ctrl-t external handoff helpers (see + # warp_run_external_ctrl_r_widget/warp_run_external_ctrl_t_widget above): they're + # Warp-internal invocations, not commands the user meant to run again later. + _is_warp_generator_command "$1" && [[ "$1" != *"warp_run_external_ctrl_r_widget"* ]] && \ + [[ "$1" != *"warp_run_external_ctrl_t_widget"* ]] } # Register this zshaddhistory hook after the user's RC files have been sourced, @@ -1385,6 +1411,23 @@ esac esac fi + # Detect whether ctrl-t has been rebound to fzf's file-search widget, independent of whichever + # tool (if any) owns ctrl-r above -- a user may have one binding without the other. fzf's zle + # widget name for ctrl-t is the same across shells ("fzf-file-widget"); atuin has no ctrl-t + # equivalent. See the ctrl-r detection above for why we match against an exact allowlist rather + # than a name containing "fzf". + _WARP_EXTERNAL_CTRL_T_WIDGET="" + warp_ctrl_t_binding="$(bindkey -M main '^T' 2>/dev/null)" + if [[ "$warp_ctrl_t_binding" == '"^T" '* ]]; then + warp_ctrl_t_widget="${warp_ctrl_t_binding#\"^T\" }" + case "$warp_ctrl_t_widget" in + fzf-file-widget) + _WARP_EXTERNAL_CTRL_T_WIDGET="$warp_ctrl_t_widget" + shell_plugins+=(external_ctrl_t_file) + ;; + esac + fi + if kernel_name="$(uname)"; then if [[ "$kernel_name" == "Darwin" ]]; then os_category="MacOS" From f95202714eb8c19f8affb9b737f8acb52bd65bca Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:06:46 +0000 Subject: [PATCH 19/70] Fix ctrl-t swallowing the key on older fzf: verify the picker function exists before tagging Both zsh and fish's fzf-file-widget zle/bind name has stayed stable across fzf releases, but the private picker function each shell's warp_run_external_ctrl_t_widget calls has not: - zsh: fzf < 0.48 (still the version several distros package, e.g. Ubuntu's 0.44.1) exposes the picker as __fsel; current fzf renamed it to __fzf_select. Detection matched the widget name in both cases but invocation only ever called __fzf_select, so on older fzf the function didn't exist and ctrl-t was silently swallowed with no picker shown. - fish: same widget name in both, but older fzf's fish integration has no __fzf_defaults function at all (it builds FZF_DEFAULT_OPTS inline instead); our invocation depends on it, hitting the same swallow. Fix: detection now checks that the function(s) invocation actually depends on are defined before tagging/intercepting. - zsh falls back to __fsel when __fzf_select isn't defined, so ctrl-t keeps working on older fzf rather than merely declining. - fish declines (no tag, no interception) when __fzf_defaults or __fzfcmd is missing, since fish never exposed a picker function separable from its own commandline-token parsing in either fzf generation, making a from-scratch reimplementation more surface area than the payoff justified. Verified against both the real Ubuntu-packaged fzf 0.44.1 shell scripts and a freshly generated fzf 0.74.3 --zsh/--fish integration: zsh tags and hands off successfully on both; fish tags on the new one and correctly declines (no interception) on the old one. --- app/assets/bundled/bootstrap/fish.sh | 13 ++++++++-- app/assets/bundled/bootstrap/zsh_body.sh | 30 +++++++++++++++++------- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/app/assets/bundled/bootstrap/fish.sh b/app/assets/bundled/bootstrap/fish.sh index 0d68562d274..8f2d73469eb 100644 --- a/app/assets/bundled/bootstrap/fish.sh +++ b/app/assets/bundled/bootstrap/fish.sh @@ -715,8 +715,17 @@ function warp_bootstrapped set -l warp_ctrl_t_widget (warp_external_ctrl_t_widget) switch "$warp_ctrl_t_widget" case 'fzf-file-widget' - set -g _WARP_EXTERNAL_CTRL_T_WIDGET "$warp_ctrl_t_widget" - set -a shell_plugins external_ctrl_t_file + # warp_run_external_ctrl_t_widget calls fzf's own __fzf_defaults/__fzfcmd helpers rather + # than reproducing their option-merging logic; older fzf's fish integration (still + # packaged by several distros) binds the same "fzf-file-widget" key but has no + # __fzf_defaults function at all (it builds FZF_DEFAULT_OPTS inline instead). Only + # tag/intercept when both helpers actually exist, so a version mismatch here can never + # claim ctrl-t and then have warp_run_external_ctrl_t_widget find nothing to call -- + # that would swallow the key with no picker shown instead of leaving ctrl-t alone. + if functions -q __fzf_defaults; and functions -q __fzfcmd + set -g _WARP_EXTERNAL_CTRL_T_WIDGET "$warp_ctrl_t_widget" + set -a shell_plugins external_ctrl_t_file + end end set -l escaped_shell_plugins (warp_escape_json $shell_plugins) diff --git a/app/assets/bundled/bootstrap/zsh_body.sh b/app/assets/bundled/bootstrap/zsh_body.sh index 9d8985d000c..aaf39c98020 100644 --- a/app/assets/bundled/bootstrap/zsh_body.sh +++ b/app/assets/bundled/bootstrap/zsh_body.sh @@ -731,12 +731,18 @@ if [[ -z $WARP_BOOTSTRAPPED ]]; then local result="" case "$_WARP_EXTERNAL_CTRL_T_WIDGET" in fzf-file-widget) - # __fzf_select (installed by fzf's zsh integration) runs the same find|fzf pipeline - # fzf-file-widget itself uses, honoring $FZF_CTRL_T_COMMAND/$FZF_CTRL_T_OPTS if the user - # set them, and echoes the shell-quoted selection to stdout -- this is exactly what - # fzf-file-widget calls before splicing the result into LBUFFER at the cursor itself, - # which we don't want here since we land the selection ourselves. - result="$(__fzf_select)" + # __fzf_select (current fzf) or __fsel (fzf < 0.48, still the packaged version on some + # distros) runs the same find|fzf pipeline fzf-file-widget itself uses, honoring + # $FZF_CTRL_T_COMMAND/$FZF_CTRL_T_OPTS if the user set them, and echoes the + # shell-quoted selection to stdout -- this is exactly what fzf-file-widget calls before + # splicing the result into LBUFFER at the cursor itself, which we don't want here since + # we land the selection ourselves. Detection below only tags this widget when one of the + # two is actually defined, so this case is never reached with neither present. + if (( $+functions[__fzf_select] )); then + result="$(__fzf_select)" + else + result="$(__fsel)" + fi ;; esac local warp_escaped_selection="$(warp_escape_json "$result")" @@ -1422,8 +1428,16 @@ esac warp_ctrl_t_widget="${warp_ctrl_t_binding#\"^T\" }" case "$warp_ctrl_t_widget" in fzf-file-widget) - _WARP_EXTERNAL_CTRL_T_WIDGET="$warp_ctrl_t_widget" - shell_plugins+=(external_ctrl_t_file) + # The zle widget name has stayed "fzf-file-widget" across fzf versions, but the picker + # it delegates to was renamed from __fsel to __fzf_select along the way (fzf < 0.48 + # still ships as the packaged version on several distros); only tag/intercept when one + # of the two invocable names actually exists, so a version mismatch here can never + # claim ctrl-t and then have warp_run_external_ctrl_t_widget find nothing to call -- + # that would swallow the key with no picker shown instead of leaving ctrl-t alone. + if (( $+functions[__fzf_select] )) || (( $+functions[__fsel] )); then + _WARP_EXTERNAL_CTRL_T_WIDGET="$warp_ctrl_t_widget" + shell_plugins+=(external_ctrl_t_file) + fi ;; esac fi From 60068f0a09f56930f979ab221a182d619b877ae3 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:48:31 +0000 Subject: [PATCH 20/70] Add missing ExternalCtrlTSelection case to hook-coverage test --- app/src/terminal/model/ansi/dcs_hooks_tests.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/terminal/model/ansi/dcs_hooks_tests.rs b/app/src/terminal/model/ansi/dcs_hooks_tests.rs index a92c30311c5..f38e3e3fa07 100644 --- a/app/src/terminal/model/ansi/dcs_hooks_tests.rs +++ b/app/src/terminal/model/ansi/dcs_hooks_tests.rs @@ -174,6 +174,10 @@ fn every_hook_tag_dispatches_to_the_matching_variant() { "ExternalCtrlRSelection", serde_json::json!({"buffer": "echo hi"}), ), + ( + "ExternalCtrlTSelection", + serde_json::json!({"buffer": "/home/me/file.txt"}), + ), ("Clear", serde_json::json!({})), ( "InitSubshell", From 3e526f61d5ae2551d8709520403bfaa17d62f3fa Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:04:41 +0000 Subject: [PATCH 21/70] Use the workspace Command wrapper in the fish bootstrap test clippy::disallowed_types rejects std::process::Command; every other call site in app/src uses command::blocking::Command. Only surfaced now because presubmit's --all-targets --tests is the first invocation on this branch to compile the test target. --- app/src/terminal/bootstrap_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/terminal/bootstrap_tests.rs b/app/src/terminal/bootstrap_tests.rs index ddedab1a9b3..0fb6dda74a4 100644 --- a/app/src/terminal/bootstrap_tests.rs +++ b/app/src/terminal/bootstrap_tests.rs @@ -77,7 +77,7 @@ fn fish_history_wrapper_installer() -> &'static str { } fn run_fish(script: &str) -> Option { - let output = match std::process::Command::new("fish") + let output = match command::blocking::Command::new("fish") .args(["--no-config", "-c", script]) .output() { From 9072aa71fa9a95e3f43a6851329506c69f004924 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:20:48 +0000 Subject: [PATCH 22/70] Don't let a hidden block supply the tab's last-completed-command label hide_block() only zeroes a block's scrollback height; last_completed_command_text() walked blocks without consulting it, so the shell-widget handoff's helper invocation became the vertical tab's label once its block completed. Affected ctrl-r since it shipped, not just the new ctrl-t path. Falls through to the existing "New session" fallback when the hidden helper was the only completed block. --- app/src/terminal/view/tab_metadata.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/terminal/view/tab_metadata.rs b/app/src/terminal/view/tab_metadata.rs index 2dd1c96c462..3cb1d6aba23 100644 --- a/app/src/terminal/view/tab_metadata.rs +++ b/app/src/terminal/view/tab_metadata.rs @@ -48,6 +48,7 @@ impl TerminalView { if block.finished() && !block.is_background() && !block.is_static() + && !block.is_hidden() && (block.bootstrap_stage().is_done() || block.is_restored()) { let cmd = block.command_to_string(); From eabcfce8005841f9a0b6865d18c35f1bc16bb939 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:45:55 +0000 Subject: [PATCH 23/70] Stop ctrl-t being swallowed, and guard bash's picker before claiming the key Three review findings on the ctrl-t handoff: - The binding did not exclude LongRunningCommand, so ctrl-t was captured while a program was in the foreground, found no handoff, and was dropped instead of reaching the program. - With no handoff available at all (feature off, no matching plugin tag, alt-screen, or a failed start) the action did nothing, silently consuming a key that previously reached the shell. It now forwards DC4 to the pty. - bash tagged any fzf-file-widget binding without checking that __fzf_select__ - the picker the helper actually calls - exists, the same guard zsh and fish already had. --- app/assets/bundled/bootstrap/bash_body.sh | 11 +++++++++-- app/src/terminal/input.rs | 4 +++- app/src/terminal/view.rs | 2 +- app/src/workspace/view.rs | 11 ++++++++--- 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/app/assets/bundled/bootstrap/bash_body.sh b/app/assets/bundled/bootstrap/bash_body.sh index 8f6ea5f7b1e..3e29303f3d4 100644 --- a/app/assets/bundled/bootstrap/bash_body.sh +++ b/app/assets/bundled/bootstrap/bash_body.sh @@ -1490,8 +1490,15 @@ esac warp_ctrl_t_binding="$(bind -X 2>/dev/null | command -p sed -n 's/^"\\C-t": "\(.*\)"$/\1/p')" case "$warp_ctrl_t_binding" in fzf-file-widget) - _WARP_EXTERNAL_CTRL_T_WIDGET="$warp_ctrl_t_binding" - shell_plugins+=(external_ctrl_t_file) + # The bind -X entry has stayed "fzf-file-widget" across fzf versions, but only tag/ + # intercept when the picker warp_run_external_ctrl_t_widget actually calls + # (__fzf_select__) exists -- a version mismatch here would otherwise claim ctrl-t and + # then have nothing to call, swallowing the key with no picker shown instead of + # leaving ctrl-t alone. + if declare -F __fzf_select__ >/dev/null; then + _WARP_EXTERNAL_CTRL_T_WIDGET="$warp_ctrl_t_binding" + shell_plugins+=(external_ctrl_t_file) + fi ;; esac fi diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index 91195f091e7..0d36299dc3d 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -2185,7 +2185,9 @@ pub fn init(app: &mut AppContext) { "External File Search", WorkspaceAction::TriggerExternalCtrlTFileSearch, ) - .with_context_predicate(id!("Input") & !id!("VoltronActive")) + .with_context_predicate( + id!("Input") & !id!("VoltronActive") & !id!("LongRunningCommand"), + ) .with_key_binding("ctrl-t"), ]); diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index a6fe932c4d5..be265c9d333 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -9576,7 +9576,7 @@ impl TerminalView { /// Also calls logic to emit a sync event. Returns whether the bytes were /// actually forwarded to the PTY: `false` when the active block is under /// agent control, in which case nothing is written. - fn write_user_bytes_to_pty>>( + pub(crate) fn write_user_bytes_to_pty>>( &mut self, data: B, ctx: &mut ViewContext, diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index 27ee4e77543..d2620f2cdd4 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -402,6 +402,7 @@ use crate::terminal::ligature_settings::should_use_ligature_rendering; #[cfg(feature = "local_tty")] use crate::terminal::local_tty::docker_sandbox::resolve_sbx_path_from_user_shell; use crate::terminal::model::blockgrid::BlockGrid; +use crate::terminal::model::escape_sequences::C0; #[cfg(feature = "local_fs")] use crate::terminal::model::session::Session; use crate::terminal::model::session::SessionId; @@ -17367,15 +17368,19 @@ impl Workspace { } /// If the active session's shell has rebound ctrl-t to an external file-search widget - /// (e.g. fzf), hands the keypress off to it. A no-op otherwise -- unlike - /// [`Self::show_command_search`], ctrl-t has no Warp-native UI to fall back to. + /// (e.g. fzf), hands the keypress off to it. Unlike [`Self::show_command_search`], ctrl-t + /// has no Warp-native UI to fall back to, so when the handoff doesn't trigger (feature + /// off, no matching shell plugin, alt-screen active, or the helper failed to start), the + /// raw keystroke is forwarded to the pty instead of being swallowed. fn trigger_external_ctrl_t_file_search(&mut self, ctx: &mut ViewContext) { if self.is_readonly_shared_session_active(ctx) { return; } if let Some(terminal_view_handle) = self.active_session_view(ctx) { terminal_view_handle.update(ctx, |terminal_view, ctx| { - terminal_view.maybe_trigger_external_ctrl_t_file_search(ctx); + if !terminal_view.maybe_trigger_external_ctrl_t_file_search(ctx) { + terminal_view.write_user_bytes_to_pty(vec![C0::DC4], ctx); + } }); } } From 7419eb05fbbb5e71745040ee6f5c4ddd605322b8 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:50:53 +0000 Subject: [PATCH 24/70] Gate the ctrl-t binding on the ShellWidgetHandoff flag The binding was registered unconditionally, so every build containing this prototype changed what ctrl-t does at an idle prompt - including for users with the flag off, who also picked up the new DC4 forwarding. Gating the registration keeps pre-feature behaviour for anyone who hasn't opted in. --- app/src/terminal/input.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index 0d36299dc3d..b631b7b6c1e 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -2185,6 +2185,7 @@ pub fn init(app: &mut AppContext) { "External File Search", WorkspaceAction::TriggerExternalCtrlTFileSearch, ) + .with_enabled(|| FeatureFlag::ShellWidgetHandoff.is_enabled()) .with_context_predicate( id!("Input") & !id!("VoltronActive") & !id!("LongRunningCommand"), ) From 315e0320474995dd76bd8caf4c3b034261333a83 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:04:02 +0000 Subject: [PATCH 25/70] Add ctrl-t handoff regression tests bash detection: extracts the real ctrl-t detection snippet from bash_body.sh so the test cannot drift from what ships, and covers both directions - declines with no __fzf_select__ defined, tags when it is. That guard is the one that stops a version mismatch claiming ctrl-t and then having nothing to invoke. PendingCtrlTHandoff lifecycle: mirrors the existing ctrl-r cases - matching session and token applies, unsolicited/stale-token/stale-session ignored, and a cancelled empty selection leaves insertion None rather than Some(""). --- app/src/terminal/bootstrap_tests.rs | 77 ++++++++++++++++++++++++ app/src/terminal/input_tests.rs | 92 +++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+) diff --git a/app/src/terminal/bootstrap_tests.rs b/app/src/terminal/bootstrap_tests.rs index 0fb6dda74a4..b7ba4820f06 100644 --- a/app/src/terminal/bootstrap_tests.rs +++ b/app/src/terminal/bootstrap_tests.rs @@ -178,3 +178,80 @@ echo "user:$status" assert!(stdout.contains("helper:1"), "{stdout}"); assert!(stdout.contains("user:1"), "{stdout}"); } + +fn bash_ctrl_t_detection_snippet() -> &'static str { + const BASH_SH: &str = include_str!("../../assets/bundled/bootstrap/bash_body.sh"); + let start_marker = " _WARP_EXTERNAL_CTRL_T_WIDGET=\"\"\n warp_ctrl_t_binding="; + let end_marker = " fi\n ;;\n esac"; + let start = BASH_SH + .find(start_marker) + .expect("bash ctrl-t detection snippet start should exist"); + let end = BASH_SH[start..] + .find(end_marker) + .expect("bash ctrl-t detection snippet end should exist"); + &BASH_SH[start..start + end + end_marker.len()] +} + +fn run_bash(script: &str) -> Option { + let output = match command::blocking::Command::new("bash") + .args(["--noprofile", "--norc", "-c", script]) + .output() + { + Ok(output) => output, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return None, + Err(error) => panic!("failed to run bash: {error}"), + }; + assert!( + output.status.success(), + "bash exited with {:?}\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + Some(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +/// Regression test for the ctrl-t equivalent of bash's `declare -F __atuin_history` guard on the +/// ctrl-r path: detection must decline (no tag, no interception) when the picker function +/// `warp_run_external_ctrl_t_widget` calls -- `__fzf_select__` -- isn't actually defined, even +/// though `bind -X` reports the wrapper name ("fzf-file-widget") that detection matches against. +/// Without this guard, an fzf version that renamed its picker function would have ctrl-t tagged +/// and intercepted with nothing to invoke, swallowing the key instead of leaving it alone. +#[test] +fn test_bash_ctrl_t_detection_declines_when_picker_function_is_absent() { + let detection = bash_ctrl_t_detection_snippet(); + let script = format!( + r#" +WARP_IN_MSYS2=false +shell_plugins=() +bind -x '"\C-t": fzf-file-widget' +{detection} +printf 'widget=[%s] plugins=[%s]\n' "$_WARP_EXTERNAL_CTRL_T_WIDGET" "${{shell_plugins[*]}}" +"# + ); + let Some(stdout) = run_bash(&script) else { + return; + }; + assert!(stdout.contains("widget=[]"), "{stdout}"); + assert!(!stdout.contains("external_ctrl_t_file"), "{stdout}"); +} + +#[test] +fn test_bash_ctrl_t_detection_tags_when_picker_function_is_present() { + let detection = bash_ctrl_t_detection_snippet(); + let script = format!( + r#" +WARP_IN_MSYS2=false +shell_plugins=() +bind -x '"\C-t": fzf-file-widget' +__fzf_select__() {{ :; }} +{detection} +printf 'widget=[%s] plugins=[%s]\n' "$_WARP_EXTERNAL_CTRL_T_WIDGET" "${{shell_plugins[*]}}" +"# + ); + let Some(stdout) = run_bash(&script) else { + return; + }; + assert!(stdout.contains("widget=[fzf-file-widget]"), "{stdout}"); + assert!(stdout.contains("external_ctrl_t_file"), "{stdout}"); +} diff --git a/app/src/terminal/input_tests.rs b/app/src/terminal/input_tests.rs index d99cb23f561..564d11276ec 100644 --- a/app/src/terminal/input_tests.rs +++ b/app/src/terminal/input_tests.rs @@ -194,6 +194,98 @@ fn cancelled_external_ctrl_r_selection_with_empty_buffer_keeps_original_draft() assert_eq!(pending.unwrap().restore_text, "draft"); } +#[test] +fn external_ctrl_t_selection_matching_session_and_token_is_applied() { + let mut pending = Some(PendingCtrlTHandoff { + session_id: SessionId::from(1), + token: "tok-1".to_string(), + original_buffer: "echo ".to_string(), + cursor_offset: ByteOffset::from(5), + insertion: None, + block_id: BlockId::new(), + }); + PendingCtrlTHandoff::maybe_apply_selection( + &mut pending, + SessionId::from(1), + "tok-1", + "selected/file.txt", + ); + assert_eq!( + pending.unwrap().insertion, + Some("selected/file.txt".to_string()) + ); +} + +#[test] +fn unsolicited_external_ctrl_t_selection_without_a_pending_handoff_is_ignored() { + // No handoff was ever started (e.g. a stray write to the pty unrelated to ctrl-t): there's + // nothing to apply the selection to, and no handoff gets created. + let mut pending: Option = None; + PendingCtrlTHandoff::maybe_apply_selection( + &mut pending, + SessionId::from(1), + "tok-1", + "selected/file.txt", + ); + assert!(pending.is_none()); +} + +#[test] +fn stale_external_ctrl_t_selection_with_mismatched_token_is_ignored() { + let mut pending = Some(PendingCtrlTHandoff { + session_id: SessionId::from(1), + token: "tok-1".to_string(), + original_buffer: "echo ".to_string(), + cursor_offset: ByteOffset::from(5), + insertion: None, + block_id: BlockId::new(), + }); + PendingCtrlTHandoff::maybe_apply_selection( + &mut pending, + SessionId::from(1), + "some-other-token", + "selected/file.txt", + ); + assert_eq!(pending.unwrap().insertion, None); +} + +#[test] +fn stale_external_ctrl_t_selection_with_mismatched_session_is_ignored() { + let mut pending = Some(PendingCtrlTHandoff { + session_id: SessionId::from(1), + token: "tok-1".to_string(), + original_buffer: "echo ".to_string(), + cursor_offset: ByteOffset::from(5), + insertion: None, + block_id: BlockId::new(), + }); + PendingCtrlTHandoff::maybe_apply_selection( + &mut pending, + SessionId::from(2), + "tok-1", + "selected/file.txt", + ); + assert_eq!(pending.unwrap().insertion, None); +} + +#[test] +fn cancelled_external_ctrl_t_selection_with_empty_buffer_leaves_insertion_unset() { + // An empty buffer means the handoff matched but the user cancelled without selecting + // anything, so `insertion` must stay `None` rather than becoming `Some("")`: the landing + // logic (see Input::handle_block_completed_event) treats `None` as "just restore the + // cursor position", which a `Some("")` would bypass by splicing in empty text instead. + let mut pending = Some(PendingCtrlTHandoff { + session_id: SessionId::from(1), + token: "tok-1".to_string(), + original_buffer: "echo ".to_string(), + cursor_offset: ByteOffset::from(5), + insertion: None, + block_id: BlockId::new(), + }); + PendingCtrlTHandoff::maybe_apply_selection(&mut pending, SessionId::from(1), "tok-1", ""); + assert_eq!(pending.unwrap().insertion, None); +} + #[test] fn renders_git_checkout_prompt_chip_command_as_single_shell_argument() { let command = PromptChipShellCommand::GitCheckout { From a8652c4500e2c3ec3054714d4805f3385ec209c2 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:34:19 +0000 Subject: [PATCH 26/70] Add ctrl-t pty-fallback and cursor-splice regression tests - ctrl_t_action_forwards_to_pty_when_no_external_widget_detected (workspace/view_tests.rs): dispatches the real WorkspaceAction::TriggerExternalCtrlTFileSearch through Workspace::handle_action and asserts the DC4 byte reaches the pty when no external widget is detected, so deleting the production fallback fails the test. - Four splice tests in input_tests.rs driving Input::handle_block_completed_event directly: mid-line, at the end of the line, into an empty buffer, and after a multi-byte character before the cursor (the byte-offset-as-char-offset case). - Reformatted a pre-existing rustfmt violation in input.rs (fmt --check was failing before this commit). --- app/src/terminal/input.rs | 4 +- app/src/terminal/input_tests.rs | 116 ++++++++++++++++++++++++++++++++ app/src/workspace/view_tests.rs | 41 +++++++++++ 3 files changed, 158 insertions(+), 3 deletions(-) diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index b631b7b6c1e..d6191307670 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -2186,9 +2186,7 @@ pub fn init(app: &mut AppContext) { WorkspaceAction::TriggerExternalCtrlTFileSearch, ) .with_enabled(|| FeatureFlag::ShellWidgetHandoff.is_enabled()) - .with_context_predicate( - id!("Input") & !id!("VoltronActive") & !id!("LongRunningCommand"), - ) + .with_context_predicate(id!("Input") & !id!("VoltronActive") & !id!("LongRunningCommand")) .with_key_binding("ctrl-t"), ]); diff --git a/app/src/terminal/input_tests.rs b/app/src/terminal/input_tests.rs index 564d11276ec..05fca528de1 100644 --- a/app/src/terminal/input_tests.rs +++ b/app/src/terminal/input_tests.rs @@ -1998,6 +1998,122 @@ fn queued_command_completion_preserves_draft() { }); } +/// Builds a `BlockType::User` completion for `command`, for use with +/// `Input::handle_block_completed_event` in tests that don't care about the block's other +/// (lazily-computed) fields. +fn user_block_completed_for_test(command: &str) -> BlockType { + BlockType::User(UserBlockCompleted::new_for_test( + BlockIndex::zero(), + Arc::new(SerializedBlock::new_for_test( + command.as_bytes().to_vec(), + vec![], + )), + command.to_owned(), + command.to_owned(), + String::new(), + String::new(), + false, + None, + 0, + 0, + )) +} + +/// Drives a completed ctrl-t handoff (see `Input::handle_block_completed_event`) directly, +/// without going through the full trigger/selection flow: constructs the pending handoff with +/// the given `original_buffer`/`cursor_offset`/`insertion`, then completes its block. Returns the +/// resulting buffer text and the byte offset the cursor/selection ends up at. +async fn splice_ctrl_t_handoff( + app: &mut App, + original_buffer: &str, + cursor_offset: usize, + insertion: Option<&str>, +) -> (String, ByteOffset) { + let terminal = add_window_with_bootstrapped_terminal(app, None, None).await; + let input = terminal.read(app, |view, _| view.input().clone()); + let block_id = BlockId::new(); + input.update(app, |input, ctx| { + input.pending_ctrl_t_handoff = Some(PendingCtrlTHandoff { + session_id: SessionId::from(1), + token: "tok-1".to_string(), + original_buffer: original_buffer.to_string(), + cursor_offset: ByteOffset::from(cursor_offset), + insertion: insertion.map(str::to_string), + block_id: block_id.clone(), + }); + input.deferred_remote_operations.latest_block_id = BlockId::new(); + input.handle_block_completed_event( + BlockCompletedEvent { + block_type: user_block_completed_for_test(original_buffer), + num_secrets_obfuscated: 0, + block_index: BlockIndex::zero(), + block_id, + session_id: None, + restored_block_was_local: None, + }, + ctx, + ); + }); + input.read(app, |input, ctx| { + ( + input.buffer_text(ctx), + input + .editor() + .as_ref(ctx) + .end_byte_index_of_last_selection(ctx), + ) + }) +} + +#[test] +fn ctrl_t_handoff_splices_selection_in_middle_of_line() { + App::test((), |mut app| async move { + initialize_app(&mut app); + // Cursor sits right after "echo START ", before "END": the insertion must land there + // with both the preceding and following text preserved. + let (buffer, cursor) = + splice_ctrl_t_handoff(&mut app, "echo START END", 11, Some("FILE.txt ")).await; + assert_eq!(buffer, "echo START FILE.txt END"); + assert_eq!(cursor, ByteOffset::from("echo START FILE.txt ".len())); + }); +} + +#[test] +fn ctrl_t_handoff_splices_selection_at_end_of_line() { + App::test((), |mut app| async move { + initialize_app(&mut app); + let (buffer, cursor) = splice_ctrl_t_handoff(&mut app, "echo ", 5, Some("FILE.txt")).await; + assert_eq!(buffer, "echo FILE.txt"); + assert_eq!(cursor, ByteOffset::from("echo FILE.txt".len())); + }); +} + +#[test] +fn ctrl_t_handoff_splices_selection_into_empty_buffer() { + App::test((), |mut app| async move { + initialize_app(&mut app); + let (buffer, cursor) = splice_ctrl_t_handoff(&mut app, "", 0, Some("FILE.txt")).await; + assert_eq!(buffer, "FILE.txt"); + assert_eq!(cursor, ByteOffset::from("FILE.txt".len())); + }); +} + +/// A cursor byte offset mistakenly treated as a char offset would panic or corrupt the buffer +/// the moment a multi-byte character (here, "caf\u{e9}", where \u{e9} is 2 bytes in UTF-8) +/// precedes the cursor. +#[test] +fn ctrl_t_handoff_splices_selection_after_multi_byte_character() { + App::test((), |mut app| async move { + initialize_app(&mut app); + let original = "caf\u{e9} "; + let cursor_offset = original.len(); + let (buffer, cursor) = + splice_ctrl_t_handoff(&mut app, original, cursor_offset, Some("dest.txt")).await; + assert_eq!(buffer, "caf\u{e9} dest.txt"); + assert_eq!(cursor, ByteOffset::from("caf\u{e9} dest.txt".len())); + }); +} + /// Verifies deleting a queued row does not overwrite an existing draft. #[test] fn row_deleted_event_preserves_existing_draft() { diff --git a/app/src/workspace/view_tests.rs b/app/src/workspace/view_tests.rs index b833743e640..e743f2c9ebe 100644 --- a/app/src/workspace/view_tests.rs +++ b/app/src/workspace/view_tests.rs @@ -1856,6 +1856,47 @@ fn test_workspace_sessions_retrieves_tabs() { }); } +/// `WorkspaceAction::TriggerExternalCtrlTFileSearch` has no Warp-native UI to fall back to, so +/// when no external ctrl-t widget is detected (the default for a freshly created session, since +/// no `external_ctrl_t_file` shell_plugins tag has been reported), the action must forward a +/// plain ctrl-t byte to the pty rather than silently swallowing the keystroke. Dispatches the +/// real action through `Workspace::handle_action` -- not the terminal-view method directly -- +/// so that deleting the production fallback would make this test fail. +#[test] +fn ctrl_t_action_forwards_to_pty_when_no_external_widget_detected() { + App::test((), |mut app| async move { + initialize_app(&mut app); + let _flag = FeatureFlag::ShellWidgetHandoff.override_enabled(true); + let workspace = mock_workspace(&mut app); + + let terminal_view = workspace.update(&mut app, |workspace, ctx| { + workspace + .active_session_view(ctx) + .expect("mock_workspace should have an active session") + }); + + let pty_writes: std::rc::Rc>>> = Default::default(); + let writes = pty_writes.clone(); + app.update(|ctx| { + ctx.subscribe_to_view(&terminal_view, move |_, event, _| { + if let crate::terminal::view::Event::WriteBytesToPty { bytes } = event { + writes.borrow_mut().push(bytes.to_vec()); + } + }); + }); + + workspace.update(&mut app, |workspace, ctx| { + workspace.handle_action(&WorkspaceAction::TriggerExternalCtrlTFileSearch, ctx); + }); + + assert_eq!( + *pty_writes.borrow(), + vec![vec![0x14]], + "ctrl-t must be forwarded to the pty as a plain keystroke when no external widget is detected" + ); + }); +} + #[test] fn test_workspace_sessions_retrieves_panes() { App::test((), |mut app| async move { From a36eaa8ab61b5dcd235888b22ae0fff6ee3ef677 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:35:40 +0000 Subject: [PATCH 27/70] Cover the ctrl-t cancel cursor restore and the flag-off binding contract Both gaps were tests that would have passed with the code they name deleted. The cancel case previously stopped at maybe_apply_selection and never reached the None arm of handle_block_completed_event, so a cancelled ctrl-t on a mid-line draft could restore the text while dropping the cursor to the end. And nothing asserted that the binding is ineligible with the feature flag off, which is the whole point of gating its registration. --- app/src/terminal/input_tests.rs | 47 +++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/app/src/terminal/input_tests.rs b/app/src/terminal/input_tests.rs index 05fca528de1..489afcd1349 100644 --- a/app/src/terminal/input_tests.rs +++ b/app/src/terminal/input_tests.rs @@ -2114,6 +2114,53 @@ fn ctrl_t_handoff_splices_selection_after_multi_byte_character() { }); } +/// Cancelling (`insertion: None`) on a mid-line draft must restore the cursor to the byte +/// offset it was captured at, not merely leave the surrounding text untouched. `set_buffer_text` +/// alone would leave the cursor at the end of the restored text; only the explicit +/// `select_ranges_by_byte_offset` call in the `None` arm of `Input::handle_block_completed_event` +/// repositions it back to where ctrl-t was originally pressed. +#[test] +fn ctrl_t_handoff_cancel_restores_cursor_to_original_offset_mid_line() { + App::test((), |mut app| async move { + initialize_app(&mut app); + // Cursor originally sat right after "echo START ", before "END". + let (buffer, cursor) = splice_ctrl_t_handoff(&mut app, "echo START END", 11, None).await; + assert_eq!( + buffer, "echo START END", + "cancelling must leave the original text untouched" + ); + assert_eq!( + cursor, + ByteOffset::from(11), + "cancelling must restore the cursor to where ctrl-t was pressed, not the end of the buffer" + ); + }); +} + +/// While `ShellWidgetHandoff` is disabled (its default state, matching a user who hasn't opted +/// into this prototype), the `workspace:trigger_external_ctrl_t_file_search` binding must be +/// completely ineligible -- not merely a no-op when triggered -- so ctrl-t falls through to +/// whatever the input editor does with an unhandled key, exactly as it did before this feature +/// existed. See `EditableBinding::with_enabled` on `init()`'s registration of this binding. +#[test] +fn ctrl_t_binding_is_ineligible_when_shell_widget_handoff_flag_is_disabled() { + App::test((), |mut app| async move { + initialize_app(&mut app); + + assert!( + !FeatureFlag::ShellWidgetHandoff.is_enabled(), + "this test assumes the flag defaults to disabled in the test harness" + ); + app.read(|ctx| { + assert!( + ctx.get_binding_by_name("workspace:trigger_external_ctrl_t_file_search") + .is_none(), + "the ctrl-t binding must be ineligible while ShellWidgetHandoff is disabled" + ); + }); + }); +} + /// Verifies deleting a queued row does not overwrite an existing draft. #[test] fn row_deleted_event_preserves_existing_draft() { From 8f861ce2b60ef67122d32afb32ff6f9735f689f1 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 07:12:18 +0000 Subject: [PATCH 28/70] Fish ctrl-t: call the user's real fzf-file-widget instead of reimplementing it Replaces the hand-rolled fish ctrl-t reimplementation (which always searched from $PWD and inserted plainly, with no token-awareness) with a direct call to the user's real fzf-file-widget. - Adds CtrlTApplyMode (Splice/Replace) to fork how a completed ctrl-t handoff's selection lands: bash/zsh continue to splice a plain path at the captured cursor offset; fish, whose widget already performs its own token-aware replacement, lands the reported buffer wholesale. - Writes a 0600 draft handoff file ($XDG_RUNTIME_DIR or /tmp, warp-ctrl-t-) so the fish helper can seed fzf-file-widget with the real draft line and cursor via commandline -r/-C. The byte cursor is converted to a character offset (string-offset) since commandline -C takes characters. - fish.sh: warp_run_external_ctrl_t_widget now calls fzf-file-widget directly and reports the resulting buffer; the detection gate loosens to functions -q fzf-file-widget (previously required __fzf_defaults/__fzfcmd), widening fzf version coverage. - Tests: paired Splice/Replace regression tests for the apply-mode fork, a cancel test covering both modes, draft-file byte->char cursor-conversion and 0600-permission unit tests, and fish detection gate tests run against a real fish process mirroring the existing bash ones. --- app/assets/bundled/bootstrap/fish.sh | 76 +++++++---- app/src/terminal/bootstrap_tests.rs | 74 +++++++++++ app/src/terminal/input.rs | 139 +++++++++++++++++--- app/src/terminal/input_tests.rs | 187 ++++++++++++++++++++++++--- app/src/terminal/view.rs | 45 ++++--- 5 files changed, 442 insertions(+), 79 deletions(-) diff --git a/app/assets/bundled/bootstrap/fish.sh b/app/assets/bundled/bootstrap/fish.sh index 8f2d73469eb..c999f607eef 100644 --- a/app/assets/bundled/bootstrap/fish.sh +++ b/app/assets/bundled/bootstrap/fish.sh @@ -608,6 +608,16 @@ function warp_run_external_ctrl_r_widget warp_send_json_message "{ \"hook\": \"ExternalCtrlRSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"token\": \"$warp_escaped_token\", \"session_id\": $WARP_SESSION_ID } }" end +# Locates the draft handoff file Warp writes just before typing this helper's invocation into the +# terminal (see Input::write_ctrl_t_draft_file), identified by the handoff token given as +# $argv[1]. $XDG_RUNTIME_DIR must mirror the fallback Warp's own write uses, or this looks in the +# wrong place for a file Warp actually wrote elsewhere. +function warp_ctrl_t_draft_file_path + set -l dir "$XDG_RUNTIME_DIR" + test -n "$dir"; or set dir /tmp + echo "$dir/warp-ctrl-t-$argv[1]" +end + # Runs fzf directly against a find-style command as a synthetic foreground command, mirroring # warp_run_external_ctrl_r_widget above. Reports the selected path(s) (or an empty buffer, if # cancelled) via the ExternalCtrlTSelection hook so Warp can insert them into the input editor at @@ -615,26 +625,49 @@ end # back unchanged, so Warp can confirm the hook is the reply to the handoff it started rather than # an unrelated write to the pty. # -# Unlike bash/zsh, fish's fzf integration has no picker function separable from its bound -# fzf-file-widget: that function inline-parses the current commandline token into a search root, -# a seed query, and an option prefix (see fzf's own __fzf_parse_commandline), then replaces just -# that token with the selection. Warp deliberately doesn't reproduce that parsing -- it always -# searches from $PWD with no seed query and lands the plain selection at the cursor, the same -# simpler behavior bash/zsh already have (see PR description for the resulting difference). +# Unlike warp_run_external_ctrl_r_widget above, this calls the user's own bound fzf-file-widget +# directly rather than re-running fzf against an independent search command: that function is +# token-aware (it parses the current commandline token into a search root, a seed query, and an +# option prefix -- see fzf's own __fzf_parse_commandline -- then replaces just that token), and +# reproducing that parsing by hand would either drop it or duplicate it badly. Calling a fish +# function directly (rather than queuing it as a bound key with `commandline -f`, which would +# defer its effect to the next prompt read instead of running it now) blocks until the picker +# exits, unlike zle/bash's bind machinery, which is why this differs from the ctrl-r widget above. +# +# Since fzf-file-widget itself reads and writes the commandline, it needs the real draft line and +# cursor to do anything useful with -- which can't be passed as this helper's own argument (argv +# is visible to any local process via /proc) or embedded in the command Warp types (which would +# land it in scrollback). So Warp writes it to the file warp_ctrl_t_draft_file_path locates, +# owner-only (0600) so no other local user can read an in-progress command line, and this seeds +# the widget with it via commandline -r/-C before calling it, then clears the line again +# afterwards: since the widget already performed its own token-aware replacement, Warp takes the +# reported result as the finished buffer wholesale rather than splicing a fragment into the +# pre-handoff draft the way bash/zsh's plain-path report requires (see CtrlTApplyMode::Replace); +# leaving the widget's own edit on the commandline would otherwise queue it for execution. +# The draft file is removed on every exit from this branch, including a missing file and a +# cancelled picker, since nothing else will ever clean it up. function warp_run_external_ctrl_t_widget set -l warp_ctrl_t_token "$argv[1]" set -l result "" switch "$_WARP_EXTERNAL_CTRL_T_WIDGET" case 'fzf-file-widget' - set -lx FZF_DEFAULT_OPTS (__fzf_defaults \ - "--reverse --walker=file,dir,follow,hidden --scheme=path" \ - "--multi $FZF_CTRL_T_OPTS --print0") - set -lx FZF_DEFAULT_COMMAND "$FZF_CTRL_T_COMMAND" - set -lx FZF_DEFAULT_OPTS_FILE - set -l selected - if set selected (eval (__fzfcmd) | string split0) - set result (string join ' ' (string escape -n -- $selected))' ' + set -l draft_file (warp_ctrl_t_draft_file_path "$warp_ctrl_t_token") + set -l original_line '' + set -l char_cursor 0 + if test -f "$draft_file" + set -l draft_contents (command cat -- "$draft_file") + set char_cursor $draft_contents[1] + # The remaining lines are the draft verbatim: rejoining with the same separator the + # command-substitution split on above losslessly reconstructs it, embedded newlines + # included, since the file has no trailing newline for fish to have dropped. + set original_line (string join \n -- $draft_contents[2..]) end + commandline -r -- $original_line + commandline -C -- $char_cursor + fzf-file-widget + set result (commandline) + commandline -r '' + rm -f "$draft_file" end set -l warp_escaped_selection (warp_escape_json "$result") set -l warp_escaped_token (warp_escape_json "$warp_ctrl_t_token") @@ -715,14 +748,13 @@ function warp_bootstrapped set -l warp_ctrl_t_widget (warp_external_ctrl_t_widget) switch "$warp_ctrl_t_widget" case 'fzf-file-widget' - # warp_run_external_ctrl_t_widget calls fzf's own __fzf_defaults/__fzfcmd helpers rather - # than reproducing their option-merging logic; older fzf's fish integration (still - # packaged by several distros) binds the same "fzf-file-widget" key but has no - # __fzf_defaults function at all (it builds FZF_DEFAULT_OPTS inline instead). Only - # tag/intercept when both helpers actually exist, so a version mismatch here can never - # claim ctrl-t and then have warp_run_external_ctrl_t_widget find nothing to call -- - # that would swallow the key with no picker shown instead of leaving ctrl-t alone. - if functions -q __fzf_defaults; and functions -q __fzfcmd + # warp_run_external_ctrl_t_widget calls fzf-file-widget directly, so the only real + # requirement is that the function itself exists. `bind` already reported this name as + # ctrl-t's binding, but only tag/intercept once that's confirmed callable, so a rebind to a + # nonexistent or renamed function can never claim ctrl-t and then have + # warp_run_external_ctrl_t_widget find nothing to call -- that would swallow the key with + # no picker shown instead of leaving ctrl-t alone. + if functions -q fzf-file-widget set -g _WARP_EXTERNAL_CTRL_T_WIDGET "$warp_ctrl_t_widget" set -a shell_plugins external_ctrl_t_file end diff --git a/app/src/terminal/bootstrap_tests.rs b/app/src/terminal/bootstrap_tests.rs index b7ba4820f06..9263e8a5091 100644 --- a/app/src/terminal/bootstrap_tests.rs +++ b/app/src/terminal/bootstrap_tests.rs @@ -255,3 +255,77 @@ printf 'widget=[%s] plugins=[%s]\n' "$_WARP_EXTERNAL_CTRL_T_WIDGET" "${{shell_pl assert!(stdout.contains("widget=[fzf-file-widget]"), "{stdout}"); assert!(stdout.contains("external_ctrl_t_file"), "{stdout}"); } + +fn fish_ctrl_t_widget_query_fn() -> &'static str { + const FISH_SH: &str = include_str!("../../assets/bundled/bootstrap/fish.sh"); + let start_marker = "function warp_external_ctrl_t_widget\n set -l widget \"\"\n for binding in (bind \\ct 2>/dev/null)"; + let end_marker = " test -n \"$widget\"; or return 1\n echo \"$widget\"\nend"; + let start = FISH_SH + .find(start_marker) + .expect("fish ctrl-t widget query function start should exist"); + let end = FISH_SH[start..] + .find(end_marker) + .expect("fish ctrl-t widget query function end should exist"); + &FISH_SH[start..start + end + end_marker.len()] +} + +fn fish_ctrl_t_detection_snippet() -> &'static str { + const FISH_SH: &str = include_str!("../../assets/bundled/bootstrap/fish.sh"); + let start_marker = "set -g _WARP_EXTERNAL_CTRL_T_WIDGET \"\"\n set -l warp_ctrl_t_widget (warp_external_ctrl_t_widget)\n switch \"$warp_ctrl_t_widget\""; + let end_marker = " set -a shell_plugins external_ctrl_t_file\n end\n end"; + let start = FISH_SH + .find(start_marker) + .expect("fish ctrl-t detection snippet start should exist"); + let end = FISH_SH[start..] + .find(end_marker) + .expect("fish ctrl-t detection snippet end should exist"); + &FISH_SH[start..start + end + end_marker.len()] +} + +/// Regression test for the fish equivalent of bash's picker-function guard: detection must +/// decline (no tag, no interception) when `fzf-file-widget` -- the function +/// `warp_run_external_ctrl_t_widget` now calls directly -- isn't actually defined, even though +/// `bind` reports it as ctrl-t's binding. Without this guard, a rebind to a nonexistent or +/// renamed function would have ctrl-t tagged and intercepted with nothing to invoke, swallowing +/// the key instead of leaving it alone. +#[test] +fn test_fish_ctrl_t_detection_declines_when_picker_function_is_absent() { + let query_fn = fish_ctrl_t_widget_query_fn(); + let detection = fish_ctrl_t_detection_snippet(); + let script = format!( + r#" +{query_fn} +bind \ct fzf-file-widget +set -l shell_plugins +{detection} +printf 'widget=[%s] plugins=[%s]\n' "$_WARP_EXTERNAL_CTRL_T_WIDGET" "$shell_plugins" +"# + ); + let Some(stdout) = run_fish(&script) else { + return; + }; + assert!(stdout.contains("widget=[]"), "{stdout}"); + assert!(!stdout.contains("external_ctrl_t_file"), "{stdout}"); +} + +#[test] +fn test_fish_ctrl_t_detection_tags_when_picker_function_is_present() { + let query_fn = fish_ctrl_t_widget_query_fn(); + let detection = fish_ctrl_t_detection_snippet(); + let script = format!( + r#" +{query_fn} +function fzf-file-widget +end +bind \ct fzf-file-widget +set -l shell_plugins +{detection} +printf 'widget=[%s] plugins=[%s]\n' "$_WARP_EXTERNAL_CTRL_T_WIDGET" "$shell_plugins" +"# + ); + let Some(stdout) = run_fish(&script) else { + return; + }; + assert!(stdout.contains("widget=[fzf-file-widget]"), "{stdout}"); + assert!(stdout.contains("external_ctrl_t_file"), "{stdout}"); +} diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index d6191307670..f93d70f139e 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -1829,6 +1829,19 @@ impl PendingCtrlRHandoff { } } +/// How a completed ctrl-t handoff's selection is landed into the editor buffer (see +/// [`Input::trigger_external_ctrl_t_file_search`]). Chosen at trigger time from the session's +/// shell type: fish's `fzf-file-widget` is invoked directly and already performs its own +/// token-aware replacement, so it returns the whole new line rather than a fragment to splice in +/// at a fixed offset the way bash/zsh's plain-path selection does. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CtrlTApplyMode { + /// Insert the selection into `original_buffer` at `cursor_offset` (bash, zsh). + Splice, + /// Replace the buffer wholesale with the selection (fish). + Replace, +} + /// State for an in-flight external ctrl-t handoff (see /// [`Input::trigger_external_ctrl_t_file_search`]). Unlike ctrl-r, which replaces the entire /// buffer with the selection, ctrl-t inserts the selection into the buffer the user had before @@ -1848,6 +1861,8 @@ struct PendingCtrlTHandoff { /// The block running the synthetic helper command. Hidden once it completes (see /// [`Input::handle_block_completed_event`]) so it doesn't clutter scrollback. block_id: BlockId, + /// How `insertion` should be landed into the buffer once it arrives; see [`CtrlTApplyMode`]. + apply_mode: CtrlTApplyMode, } impl PendingCtrlTHandoff { @@ -1873,6 +1888,57 @@ impl PendingCtrlTHandoff { } } +/// Directory the ctrl-t draft handoff file (see [`write_ctrl_t_draft_file`]) is written into: +/// `$XDG_RUNTIME_DIR` when set, since it's the per-user, non-persistent directory most Linux +/// distros provide; `/tmp` otherwise. Must match the fallback the fish helper computes for itself +/// from its own environment -- see `warp_run_external_ctrl_t_widget` in `fish.sh`. +fn ctrl_t_draft_file_dir() -> PathBuf { + std::env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/tmp")) +} + +/// Path of the ctrl-t draft handoff file for `token` (see [`write_ctrl_t_draft_file`]). +fn ctrl_t_draft_file_path(token: &str) -> PathBuf { + ctrl_t_draft_file_dir().join(format!("warp-ctrl-t-{token}")) +} + +/// Writes the draft line and cursor the fish ctrl-t helper seeds `fzf-file-widget` with (see +/// [`CtrlTApplyMode::Replace`]), so its token-aware replacement operates on the real in-progress +/// command rather than an empty line. Bash/zsh never call this: their helper searches +/// independently of the draft and reports a plain path for Warp to splice in itself. +/// +/// Created with owner-only (0600) permissions from the moment the file exists -- this may contain +/// in-progress command text the user hasn't run yet, and creating the file before restricting its +/// permissions would leave a window where another local user could read it. The first line is +/// `cursor_offset` converted to a character offset, since fish's `commandline -C` takes +/// characters while `cursor_offset` is a byte offset; the remainder of the file is +/// `original_buffer` verbatim. +fn write_ctrl_t_draft_file( + token: &str, + original_buffer: &str, + cursor_offset: ByteOffset, +) -> anyhow::Result<()> { + use std::io::Write as _; + #[cfg(unix)] + use std::os::unix::fs::OpenOptionsExt as _; + + use anyhow::Context as _; + + let char_cursor = original_buffer[..cursor_offset.as_usize()].chars().count(); + let path = ctrl_t_draft_file_path(token); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + options.mode(0o600); + let mut file = options + .open(&path) + .with_context(|| format!("failed to create {}", path.display()))?; + writeln!(file, "{char_cursor}")?; + file.write_all(original_buffer.as_bytes())?; + Ok(()) +} + struct AmbientAgentViewState { view_model: ModelHandle, #[allow(dead_code)] @@ -7664,16 +7730,24 @@ impl Input { /// Runs `helper_command` (a bootstrap-installed shell function) as if the user had typed and /// submitted it, mirroring [`Self::trigger_external_ctrl_r_history_search`]. Unlike ctrl-r, - /// which replaces the whole buffer with the selection, ctrl-t inserts the selection into the - /// buffer at the cursor position ctrl-t was pressed at -- so this snapshots the current - /// buffer text and cursor byte offset separately, rather than a single restorable string. - /// Returns `true` if the command was started. + /// which replaces the whole buffer with the selection, ctrl-t either splices the selection + /// into the buffer at the cursor position ctrl-t was pressed at, or replaces the buffer + /// wholesale, depending on `apply_mode` (see [`CtrlTApplyMode`]) -- so this snapshots the + /// current buffer text and cursor byte offset separately, rather than a single restorable + /// string. Returns `true` if the command was started. /// /// See [`Self::trigger_external_ctrl_r_history_search`] for why the command is prefixed with /// a leading space. + /// + /// When `apply_mode` is [`CtrlTApplyMode::Replace`], also writes the draft handoff file (see + /// [`write_ctrl_t_draft_file`]) the fish helper reads to seed its widget with the real draft + /// line and cursor; a failure to write it aborts the trigger entirely; bash/zsh never need + /// this file, since their helper searches independently of the draft and reports a plain path + /// for Warp to splice in itself. pub fn trigger_external_ctrl_t_file_search( &mut self, helper_command: &str, + apply_mode: CtrlTApplyMode, ctx: &mut ViewContext, ) -> bool { let Some(session_id) = self.active_block_session_id() else { @@ -7686,6 +7760,12 @@ impl Input { .end_byte_index_of_last_selection(ctx); let block_id = self.model.lock().block_list().active_block_id().clone(); let token = Uuid::new_v4().to_string(); + if apply_mode == CtrlTApplyMode::Replace + && let Err(error) = write_ctrl_t_draft_file(&token, &original_buffer, cursor_offset) + { + report_error!(error.context("failed to write ctrl-t draft handoff file")); + return false; + } let command = format!(" {helper_command} {token}"); let started = self.try_execute_command_from_source(&command, CommandExecutionSource::User, ctx); @@ -7697,7 +7777,11 @@ impl Input { cursor_offset, insertion: None, block_id, + apply_mode, }); + } else if apply_mode == CtrlTApplyMode::Replace { + // The helper never ran, so it will never clean up the draft file it would have read. + let _ = std::fs::remove_file(ctrl_t_draft_file_path(&token)); } started } @@ -15484,9 +15568,16 @@ impl Input { .take() .or_else(|| completed_ctrl_r_handoff.map(|handoff| handoff.restore_text)) .or_else(|| { - completed_ctrl_t_handoff - .as_ref() - .map(|handoff| handoff.original_buffer.clone()) + completed_ctrl_t_handoff.as_ref().map(|handoff| { + // In `Replace` mode the shell's own widget already performed the + // token-aware replacement, so its selection *is* the finished buffer; + // landing it as the base text (rather than `original_buffer`, then + // splicing) avoids reconstructing what the widget already built. + match (handoff.apply_mode, &handoff.insertion) { + (CtrlTApplyMode::Replace, Some(insertion)) => insertion.clone(), + _ => handoff.original_buffer.clone(), + } + }) }); if should_clear_buffer { @@ -15505,21 +15596,27 @@ impl Input { self.editor.update(ctx, |editor, ctx| { editor.set_buffer_text(&restore_text, ctx); // A ctrl-t handoff restores the pre-handoff buffer above, then (unlike - // ctrl-r) splices its selection in at the captured cursor offset - // instead of replacing the whole buffer, or just moves the cursor back - // to that offset if the user cancelled without selecting anything. + // ctrl-r) either splices its selection in at the captured cursor + // offset, or -- for `Replace` mode -- leaves the already-finished + // buffer set above as-is, since the widget placed its own cursor + // position and there is nothing left to splice. Either mode instead + // moves the cursor back to the captured offset on cancel. if let Some(handoff) = &completed_ctrl_t_handoff { - match &handoff.insertion { - Some(insertion) => editor.select_and_replace( - insertion, - [handoff.cursor_offset..handoff.cursor_offset], - PlainTextEditorViewAction::InsertSelectedText, - ctx, - ), - None => editor.select_ranges_by_byte_offset( - [handoff.cursor_offset..handoff.cursor_offset], - ctx, - ), + match (handoff.apply_mode, &handoff.insertion) { + (CtrlTApplyMode::Splice, Some(insertion)) => editor + .select_and_replace( + insertion, + [handoff.cursor_offset..handoff.cursor_offset], + PlainTextEditorViewAction::InsertSelectedText, + ctx, + ), + (CtrlTApplyMode::Replace, Some(_)) => {} + (CtrlTApplyMode::Splice | CtrlTApplyMode::Replace, None) => { + editor.select_ranges_by_byte_offset( + [handoff.cursor_offset..handoff.cursor_offset], + ctx, + ) + } } } }); diff --git a/app/src/terminal/input_tests.rs b/app/src/terminal/input_tests.rs index 489afcd1349..249bb165640 100644 --- a/app/src/terminal/input_tests.rs +++ b/app/src/terminal/input_tests.rs @@ -203,6 +203,7 @@ fn external_ctrl_t_selection_matching_session_and_token_is_applied() { cursor_offset: ByteOffset::from(5), insertion: None, block_id: BlockId::new(), + apply_mode: CtrlTApplyMode::Splice, }); PendingCtrlTHandoff::maybe_apply_selection( &mut pending, @@ -239,6 +240,7 @@ fn stale_external_ctrl_t_selection_with_mismatched_token_is_ignored() { cursor_offset: ByteOffset::from(5), insertion: None, block_id: BlockId::new(), + apply_mode: CtrlTApplyMode::Splice, }); PendingCtrlTHandoff::maybe_apply_selection( &mut pending, @@ -258,6 +260,7 @@ fn stale_external_ctrl_t_selection_with_mismatched_session_is_ignored() { cursor_offset: ByteOffset::from(5), insertion: None, block_id: BlockId::new(), + apply_mode: CtrlTApplyMode::Splice, }); PendingCtrlTHandoff::maybe_apply_selection( &mut pending, @@ -281,6 +284,7 @@ fn cancelled_external_ctrl_t_selection_with_empty_buffer_leaves_insertion_unset( cursor_offset: ByteOffset::from(5), insertion: None, block_id: BlockId::new(), + apply_mode: CtrlTApplyMode::Splice, }); PendingCtrlTHandoff::maybe_apply_selection(&mut pending, SessionId::from(1), "tok-1", ""); assert_eq!(pending.unwrap().insertion, None); @@ -2021,10 +2025,11 @@ fn user_block_completed_for_test(command: &str) -> BlockType { /// Drives a completed ctrl-t handoff (see `Input::handle_block_completed_event`) directly, /// without going through the full trigger/selection flow: constructs the pending handoff with -/// the given `original_buffer`/`cursor_offset`/`insertion`, then completes its block. Returns the -/// resulting buffer text and the byte offset the cursor/selection ends up at. -async fn splice_ctrl_t_handoff( +/// the given `apply_mode`/`original_buffer`/`cursor_offset`/`insertion`, then completes its +/// block. Returns the resulting buffer text and the byte offset the cursor/selection ends up at. +async fn complete_ctrl_t_handoff( app: &mut App, + apply_mode: CtrlTApplyMode, original_buffer: &str, cursor_offset: usize, insertion: Option<&str>, @@ -2040,6 +2045,7 @@ async fn splice_ctrl_t_handoff( cursor_offset: ByteOffset::from(cursor_offset), insertion: insertion.map(str::to_string), block_id: block_id.clone(), + apply_mode, }); input.deferred_remote_operations.latest_block_id = BlockId::new(); input.handle_block_completed_event( @@ -2071,8 +2077,14 @@ fn ctrl_t_handoff_splices_selection_in_middle_of_line() { initialize_app(&mut app); // Cursor sits right after "echo START ", before "END": the insertion must land there // with both the preceding and following text preserved. - let (buffer, cursor) = - splice_ctrl_t_handoff(&mut app, "echo START END", 11, Some("FILE.txt ")).await; + let (buffer, cursor) = complete_ctrl_t_handoff( + &mut app, + CtrlTApplyMode::Splice, + "echo START END", + 11, + Some("FILE.txt "), + ) + .await; assert_eq!(buffer, "echo START FILE.txt END"); assert_eq!(cursor, ByteOffset::from("echo START FILE.txt ".len())); }); @@ -2082,7 +2094,14 @@ fn ctrl_t_handoff_splices_selection_in_middle_of_line() { fn ctrl_t_handoff_splices_selection_at_end_of_line() { App::test((), |mut app| async move { initialize_app(&mut app); - let (buffer, cursor) = splice_ctrl_t_handoff(&mut app, "echo ", 5, Some("FILE.txt")).await; + let (buffer, cursor) = complete_ctrl_t_handoff( + &mut app, + CtrlTApplyMode::Splice, + "echo ", + 5, + Some("FILE.txt"), + ) + .await; assert_eq!(buffer, "echo FILE.txt"); assert_eq!(cursor, ByteOffset::from("echo FILE.txt".len())); }); @@ -2092,7 +2111,9 @@ fn ctrl_t_handoff_splices_selection_at_end_of_line() { fn ctrl_t_handoff_splices_selection_into_empty_buffer() { App::test((), |mut app| async move { initialize_app(&mut app); - let (buffer, cursor) = splice_ctrl_t_handoff(&mut app, "", 0, Some("FILE.txt")).await; + let (buffer, cursor) = + complete_ctrl_t_handoff(&mut app, CtrlTApplyMode::Splice, "", 0, Some("FILE.txt")) + .await; assert_eq!(buffer, "FILE.txt"); assert_eq!(cursor, ByteOffset::from("FILE.txt".len())); }); @@ -2107,8 +2128,14 @@ fn ctrl_t_handoff_splices_selection_after_multi_byte_character() { initialize_app(&mut app); let original = "caf\u{e9} "; let cursor_offset = original.len(); - let (buffer, cursor) = - splice_ctrl_t_handoff(&mut app, original, cursor_offset, Some("dest.txt")).await; + let (buffer, cursor) = complete_ctrl_t_handoff( + &mut app, + CtrlTApplyMode::Splice, + original, + cursor_offset, + Some("dest.txt"), + ) + .await; assert_eq!(buffer, "caf\u{e9} dest.txt"); assert_eq!(cursor, ByteOffset::from("caf\u{e9} dest.txt".len())); }); @@ -2118,25 +2145,147 @@ fn ctrl_t_handoff_splices_selection_after_multi_byte_character() { /// offset it was captured at, not merely leave the surrounding text untouched. `set_buffer_text` /// alone would leave the cursor at the end of the restored text; only the explicit /// `select_ranges_by_byte_offset` call in the `None` arm of `Input::handle_block_completed_event` -/// repositions it back to where ctrl-t was originally pressed. +/// repositions it back to where ctrl-t was originally pressed. Covers both apply modes: cancel +/// behaves identically regardless of which shell started the handoff. #[test] fn ctrl_t_handoff_cancel_restores_cursor_to_original_offset_mid_line() { App::test((), |mut app| async move { initialize_app(&mut app); - // Cursor originally sat right after "echo START ", before "END". - let (buffer, cursor) = splice_ctrl_t_handoff(&mut app, "echo START END", 11, None).await; - assert_eq!( - buffer, "echo START END", - "cancelling must leave the original text untouched" - ); + for apply_mode in [CtrlTApplyMode::Splice, CtrlTApplyMode::Replace] { + // Cursor originally sat right after "echo START ", before "END". + let (buffer, cursor) = + complete_ctrl_t_handoff(&mut app, apply_mode, "echo START END", 11, None).await; + assert_eq!( + buffer, "echo START END", + "{apply_mode:?}: cancelling must leave the original text untouched" + ); + assert_eq!( + cursor, + ByteOffset::from(11), + "{apply_mode:?}: cancelling must restore the cursor to where ctrl-t was pressed, \ + not the end of the buffer" + ); + } + }); +} + +/// fish's `fzf-file-widget` already performs its own token-aware replacement, so its selection +/// (see `CtrlTApplyMode::Replace`) must land as the finished buffer wholesale -- not spliced into +/// `original_buffer` the way bash/zsh's plain-path selection is. Using a `cursor_offset` that +/// would splice into the *middle* of `original_buffer` if `Replace` were mishandled as `Splice` +/// makes that distinction observable: a regression here would interleave `original_buffer` and +/// `insertion` instead of replacing outright. +#[test] +fn ctrl_t_handoff_replace_mode_lands_selection_wholesale() { + App::test((), |mut app| async move { + initialize_app(&mut app); + let (buffer, cursor) = complete_ctrl_t_handoff( + &mut app, + CtrlTApplyMode::Replace, + "vim src/ END", + 8, + Some("vim src/nested.rs "), + ) + .await; + assert_eq!(buffer, "vim src/nested.rs "); + assert_eq!(cursor, ByteOffset::from("vim src/nested.rs ".len())); + }); +} + +/// States the `Splice`/`Replace` fork as an explicit contract: the same pre-handoff draft and +/// cursor, completed with each shell's own realistic selection shape, must diverge exactly as +/// each mode specifies. A regression that collapses the two modes together (e.g. always splicing, +/// or always replacing) would fail one arm of this test while possibly leaving the other passing. +#[test] +fn ctrl_t_apply_mode_forks_between_splice_and_replace_for_the_same_draft() { + App::test((), |mut app| async move { + initialize_app(&mut app); + + // bash/zsh: the helper reports a plain path with no knowledge of the draft, so Warp + // splices it into the token at the captured cursor offset. + let (splice_buffer, splice_cursor) = complete_ctrl_t_handoff( + &mut app, + CtrlTApplyMode::Splice, + "vim src/ END", + 8, + Some("nested.rs "), + ) + .await; + assert_eq!(splice_buffer, "vim src/nested.rs END"); + assert_eq!(splice_cursor, ByteOffset::from("vim src/nested.rs ".len())); + + // fish: fzf-file-widget already replaced the token itself, so its report is the whole + // finished line, landed wholesale. + let (replace_buffer, replace_cursor) = complete_ctrl_t_handoff( + &mut app, + CtrlTApplyMode::Replace, + "vim src/ END", + 8, + Some("vim src/nested.rs END"), + ) + .await; + assert_eq!(replace_buffer, "vim src/nested.rs END"); assert_eq!( - cursor, - ByteOffset::from(11), - "cancelling must restore the cursor to where ctrl-t was pressed, not the end of the buffer" + replace_cursor, + ByteOffset::from("vim src/nested.rs END".len()) ); }); } +/// The fish ctrl-t helper reads the draft's cursor with fish's `commandline -C`, which takes a +/// *character* offset, while `cursor_offset` here is a byte offset -- so a draft containing a +/// multi-byte character before the cursor (here, "caf\u{e9} ", where \u{e9} is 2 bytes but 1 +/// character, for 6 bytes and 5 characters total) must have that byte offset converted, not +/// copied verbatim, or the widget would seed itself at the wrong position for any non-ASCII draft. +#[test] +fn write_ctrl_t_draft_file_converts_byte_cursor_to_char_cursor_for_multi_byte_draft() { + let original_buffer = "caf\u{e9} ls"; + // Byte offset right after "café " (the \u{e9} is 2 bytes), which is character offset 5. + let cursor_offset = ByteOffset::from("caf\u{e9} ".len()); + let token = format!("test-{}", Uuid::new_v4()); + write_ctrl_t_draft_file(&token, original_buffer, cursor_offset) + .expect("draft file should be writable in a test environment"); + let path = ctrl_t_draft_file_path(&token); + let contents = std::fs::read_to_string(&path).expect("draft file should have been written"); + std::fs::remove_file(&path).ok(); + + let mut lines = contents.splitn(2, '\n'); + assert_eq!( + lines.next(), + Some("5"), + "the cursor must be written as a character offset, not the byte offset" + ); + assert_eq!( + lines.next(), + Some(original_buffer), + "the draft line must be written verbatim" + ); +} + +/// The draft file may hold an in-progress command line the user hasn't run yet, so it must never +/// be readable by another local user, even for the instant between creation and its first write. +#[cfg(unix)] +#[test] +fn write_ctrl_t_draft_file_is_created_with_owner_only_permissions() { + use std::os::unix::fs::PermissionsExt as _; + + let token = format!("test-{}", Uuid::new_v4()); + write_ctrl_t_draft_file(&token, "echo hi", ByteOffset::from(7)) + .expect("draft file should be writable in a test environment"); + let path = ctrl_t_draft_file_path(&token); + let mode = std::fs::metadata(&path) + .expect("draft file should have been written") + .permissions() + .mode(); + std::fs::remove_file(&path).ok(); + + assert_eq!( + mode & 0o777, + 0o600, + "the draft file must be owner-only (0600) from the moment it exists" + ); +} + /// While `ShellWidgetHandoff` is disabled (its default state, matching a user who hasn't opted /// into this prototype), the `workspace:trigger_external_ctrl_t_file_search` binding must be /// completely ineligible -- not merely a no-op when triggered -- so ctrl-t falls through to diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index be265c9d333..d2af120e8e9 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -415,8 +415,8 @@ use crate::terminal::input::inline_menu::InlineMenuPositioner; #[cfg(not(target_family = "wasm"))] use crate::terminal::input::slash_commands::fork_button_action; use crate::terminal::input::{ - CommandExecutionSource, InputAction, InputEmptyStateChangeReason, InputState, MenuPositioning, - MenuPositioningProvider, + CommandExecutionSource, CtrlTApplyMode, InputAction, InputEmptyStateChangeReason, InputState, + MenuPositioning, MenuPositioningProvider, }; use crate::terminal::keys::TerminalKeybindings; use crate::terminal::ligature_settings::{LigatureSettings, should_use_ligature_rendering}; @@ -9255,9 +9255,10 @@ impl TerminalView { /// If ctrl-t was pressed at an idle prompt on a session whose shell has rebound `^T` to an /// external file-search widget (reported via the [`EXTERNAL_CTRL_T_FILE_PLUGIN_TAG`] shell /// plugin tag, e.g. by fzf), hands the keypress off to that widget. Mirrors - /// [`Self::maybe_trigger_external_ctrl_r_history_search`], but lands the selection by - /// inserting it into the input editor at the cursor position rather than replacing the - /// whole buffer; see [`Input::trigger_external_ctrl_t_file_search`]. + /// [`Self::maybe_trigger_external_ctrl_r_history_search`], but lands the selection either by + /// inserting it into the input editor at the cursor position or by replacing the whole + /// buffer, depending on the session's shell; see [`Input::trigger_external_ctrl_t_file_search`] + /// and [`CtrlTApplyMode`]. /// /// Returns `true` if the handoff was triggered, in which case the caller should not pass /// ctrl-t through to the pty or handle it any other way. @@ -9271,22 +9272,32 @@ impl TerminalView { let Some(session_id) = self.active_block_session_id() else { return false; }; - let has_external_ctrl_t_widget = - self.sessions - .as_ref(ctx) - .get(session_id) - .is_some_and(|session| { - session - .shell() - .plugins() - .contains(EXTERNAL_CTRL_T_FILE_PLUGIN_TAG) - }); - if !has_external_ctrl_t_widget || self.model.lock().is_alt_screen_active() { + let Some(session) = self.sessions.as_ref(ctx).get(session_id) else { + return false; + }; + if !session + .shell() + .plugins() + .contains(EXTERNAL_CTRL_T_FILE_PLUGIN_TAG) + || self.model.lock().is_alt_screen_active() + { return false; } + // fish invokes the user's real `fzf-file-widget` directly, which already performs its + // own token-aware replacement and so returns the whole new line; bash/zsh's helper + // instead searches independently of the draft and reports a plain path to splice in at + // the cursor. See `CtrlTApplyMode` and the fish/bash/zsh helper implementations. + let apply_mode = match session.shell().shell_type() { + ShellType::Fish => CtrlTApplyMode::Replace, + ShellType::Bash | ShellType::Zsh | ShellType::PowerShell => CtrlTApplyMode::Splice, + }; self.input.update(ctx, |input, ctx| { - input.trigger_external_ctrl_t_file_search(EXTERNAL_CTRL_T_HELPER_COMMAND, ctx) + input.trigger_external_ctrl_t_file_search( + EXTERNAL_CTRL_T_HELPER_COMMAND, + apply_mode, + ctx, + ) }) } From 10b3be0567ff3c4e065ad7ace6a5df535d5a6ef3 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:09:29 +0000 Subject: [PATCH 29/70] Fix fish ctrl-t cancel losing cursor position; add real-trigger cursor coverage fzf-file-widget has no way to report cancellation distinctly from a selection: on Escape it leaves the commandline exactly as seeded, so a cancelled search reports a selection identical to the pre-handoff draft. Input::handle_block_completed_event then treats that as a genuine CtrlTApplyMode::Replace insertion (it's non-empty) and takes the no-op-on-cursor arm, since Replace mode assumes the widget already positioned the cursor -- leaving it wherever set_buffer_text's default insert() put it (the end of the buffer) instead of restoring it to where ctrl-t was pressed. This is a regression introduced by 8f861ce2b on this PR, not a pre-existing issue: before that commit fish used CtrlTApplyMode::Splice with the old hand-rolled reimplementation, which reported empty on cancel like bash/zsh do. Bash and zsh are unaffected here since their ctrl-t helpers run a plain fzf pipeline (__fzf_select__/__fsel) rather than the stateful fzf-file-widget, so cancellation genuinely yields an empty string. Fix: warp_run_external_ctrl_t_widget now compares the widget's output to what it seeded and collapses an unchanged line to empty, reusing the existing empty-selection cancel convention instead of adding Rust-side branching. Verified live that a real selection cannot round-trip to this same false-cancel case (fzf-file-widget always appends a trailing space when completing a token, and re-selecting an already-complete path duplicates it rather than reproducing it), so this is documented as unreachable rather than resolved with a second signal. Also adds ctrl_t_handoff_cancel_restores_cursor_captured_by_a_real_trigger, which drives Input::trigger_external_ctrl_t_file_search directly so the cursor_offset under test is the one actually captured live, not a hand-picked value fed straight into handle_block_completed_event -- closing a coverage gap in the existing ctrl_t_handoff_cancel_restores_cursor_to_original_offset_mid_line test. --- app/assets/bundled/bootstrap/fish.sh | 18 +++++- app/src/terminal/bootstrap_tests.rs | 51 +++++++++++++++++ app/src/terminal/input_tests.rs | 82 ++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+), 1 deletion(-) diff --git a/app/assets/bundled/bootstrap/fish.sh b/app/assets/bundled/bootstrap/fish.sh index c999f607eef..5fd67691902 100644 --- a/app/assets/bundled/bootstrap/fish.sh +++ b/app/assets/bundled/bootstrap/fish.sh @@ -618,6 +618,10 @@ function warp_ctrl_t_draft_file_path echo "$dir/warp-ctrl-t-$argv[1]" end +function warp_ctrl_t_widget_result + test "$argv[1]" = "$argv[2]"; or string collect -- "$argv[2]" +end + # Runs fzf directly against a find-style command as a synthetic foreground command, mirroring # warp_run_external_ctrl_r_widget above. Reports the selected path(s) (or an empty buffer, if # cancelled) via the ExternalCtrlTSelection hook so Warp can insert them into the input editor at @@ -646,6 +650,18 @@ end # leaving the widget's own edit on the commandline would otherwise queue it for execution. # The draft file is removed on every exit from this branch, including a missing file and a # cancelled picker, since nothing else will ever clean it up. +# +# fzf-file-widget has no way to report cancellation distinctly from a selection: on Escape it +# simply leaves the commandline exactly as seeded, so its output is indistinguishable from a +# "selection" that happens to reproduce the original line by content alone. Collapse that case to +# an empty result -- Warp's existing convention for "nothing selected", which bash/zsh's plain-path +# widgets satisfy naturally since they never seed anything for cancellation to echo back -- so a +# cancelled search restores the pre-handoff cursor instead of hitting CtrlTApplyMode::Replace's +# no-op-on-cursor insertion path. A real selection cannot round-trip to this same false-cancel +# case: fzf-file-widget always appends a trailing space when it completes a token, so replacing an +# in-progress token always changes the line, and re-selecting a path already fully typed (cursor +# past its trailing space, so there's no token left to replace) inserts a second copy rather than +# reproducing the first -- confirmed live for both shapes. function warp_run_external_ctrl_t_widget set -l warp_ctrl_t_token "$argv[1]" set -l result "" @@ -665,7 +681,7 @@ function warp_run_external_ctrl_t_widget commandline -r -- $original_line commandline -C -- $char_cursor fzf-file-widget - set result (commandline) + set result (warp_ctrl_t_widget_result "$original_line" (commandline)) commandline -r '' rm -f "$draft_file" end diff --git a/app/src/terminal/bootstrap_tests.rs b/app/src/terminal/bootstrap_tests.rs index 9263e8a5091..dd713c5b183 100644 --- a/app/src/terminal/bootstrap_tests.rs +++ b/app/src/terminal/bootstrap_tests.rs @@ -282,6 +282,57 @@ fn fish_ctrl_t_detection_snippet() -> &'static str { &FISH_SH[start..start + end + end_marker.len()] } +fn fish_ctrl_t_widget_result_fn() -> &'static str { + const FISH_SH: &str = include_str!("../../assets/bundled/bootstrap/fish.sh"); + // Locates the function boundary structurally (start of the `function` line to its matching + // `end` line) rather than by matching the literal body text, so a behavioral mutation to the + // comparison inside it changes what the test observes instead of breaking extraction itself. + let start_marker = "function warp_ctrl_t_widget_result\n"; + let start = FISH_SH + .find(start_marker) + .expect("fish ctrl-t widget result function start should exist"); + let end_marker = "\nend\n"; + let end = FISH_SH[start..] + .find(end_marker) + .expect("fish ctrl-t widget result function end should exist"); + &FISH_SH[start..start + end + end_marker.len()] +} + +#[test] +fn test_fish_ctrl_t_widget_result_is_empty_when_widget_leaves_draft_unchanged() { + let result_fn = fish_ctrl_t_widget_result_fn(); + let script = format!( + r#" +{result_fn} +set result (warp_ctrl_t_widget_result 'echo START MIDDLE' 'echo START MIDDLE') +printf 'result=[%s]\n' "$result" +"# + ); + let Some(stdout) = run_fish(&script) else { + return; + }; + assert!(stdout.contains("result=[]"), "{stdout}"); +} + +#[test] +fn test_fish_ctrl_t_widget_result_preserves_changed_line() { + let result_fn = fish_ctrl_t_widget_result_fn(); + let script = format!( + r#" +{result_fn} +set result (warp_ctrl_t_widget_result 'echo START MIDDLE' 'echo START nested.rs MIDDLE') +printf 'result=[%s]\n' "$result" +"# + ); + let Some(stdout) = run_fish(&script) else { + return; + }; + assert!( + stdout.contains("result=[echo START nested.rs MIDDLE]"), + "{stdout}" + ); +} + /// Regression test for the fish equivalent of bash's picker-function guard: detection must /// decline (no tag, no interception) when `fzf-file-widget` -- the function /// `warp_run_external_ctrl_t_widget` now calls directly -- isn't actually defined, even though diff --git a/app/src/terminal/input_tests.rs b/app/src/terminal/input_tests.rs index 249bb165640..51a44c8e076 100644 --- a/app/src/terminal/input_tests.rs +++ b/app/src/terminal/input_tests.rs @@ -2169,6 +2169,88 @@ fn ctrl_t_handoff_cancel_restores_cursor_to_original_offset_mid_line() { }); } +/// Exercises the real trigger path (`Input::trigger_external_ctrl_t_file_search`), not just the +/// completion-side apply function: the cursor offset the cancel restore uses must be the one +/// actually captured live when ctrl-t was pressed, not a hand-picked value fed straight into +/// `handle_block_completed_event`. A regression that stales or drops the captured offset between +/// trigger and completion would still pass +/// `ctrl_t_handoff_cancel_restores_cursor_to_original_offset_mid_line` (which never calls the +/// trigger) but fail this one. +#[test] +fn ctrl_t_handoff_cancel_restores_cursor_captured_by_a_real_trigger() { + App::test((), |mut app| async move { + initialize_app(&mut app); + let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; + let input = terminal.read(&app, |view, _| view.input().clone()); + + // Cursor sits right after "echo START ", before "MIDDLE" -- mirrors a user typing the + // command, arrowing left, then pressing ctrl-t. + input.update(&mut app, |input, ctx| { + input.user_insert("echo START MIDDLE", ctx); + input.editor().update(ctx, |editor, ctx| { + editor.select_ranges_by_byte_offset( + [ByteOffset::from(11)..ByteOffset::from(11)], + ctx, + ); + }); + }); + + let started = input.update(&mut app, |input, ctx| { + input.trigger_external_ctrl_t_file_search( + "warp_run_external_ctrl_t_widget", + CtrlTApplyMode::Splice, + ctx, + ) + }); + assert!(started, "the handoff command should have started"); + + let block_id = terminal.read(&app, |terminal, _| { + terminal.model.lock().block_list().active_block_id().clone() + }); + + // The test harness never actually advances the block list in response to + // `Event::ExecuteCommand` (no pty is running), so `block_id` above is still the same + // block `deferred_remote_operations.latest_block_id` was last set to -- unlike the real + // flow, where the helper command's block is a genuinely new one. Force it stale here so + // `handle_block_completed_event`'s restore branch actually runs, exactly as + // `complete_ctrl_t_handoff` does for the same reason. + input.update(&mut app, |input, _ctx| { + input.deferred_remote_operations.latest_block_id = BlockId::new(); + }); + + // Simulate the shell reporting no selection (the user cancelled) -- without ever telling + // `Input` what cursor_offset to use; it must come from what the trigger captured. + input.update(&mut app, |input, ctx| { + input.handle_block_completed_event( + BlockCompletedEvent { + block_type: user_block_completed_for_test( + " warp_run_external_ctrl_t_widget tok-1", + ), + num_secrets_obfuscated: 0, + block_index: BlockIndex::zero(), + block_id, + session_id: None, + restored_block_was_local: None, + }, + ctx, + ); + }); + + input.read(&app, |input, ctx| { + assert_eq!(input.buffer_text(ctx), "echo START MIDDLE"); + assert_eq!( + input + .editor() + .as_ref(ctx) + .end_byte_index_of_last_selection(ctx), + ByteOffset::from(11), + "cancelling a handoff whose cursor was captured by a real trigger must restore \ + the cursor to where ctrl-t was pressed, not the end of the buffer" + ); + }); + }); +} + /// fish's `fzf-file-widget` already performs its own token-aware replacement, so its selection /// (see `CtrlTApplyMode::Replace`) must land as the finished buffer wholesale -- not spliced into /// `original_buffer` the way bash/zsh's plain-path selection is. Using a `cursor_offset` that From 7a6a15b68ed9c238b79b4418beda5dee56262d5d Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:46:01 +0000 Subject: [PATCH 30/70] Address review: fix multiline draft corruption, draft-file leak on write failure, trim comment 1. warp_run_external_ctrl_t_widget's draft reconstruction rejoined cat's command-substitution-split lines with 'string join \n', but the embedded newline that reintroduces makes this set's own command substitution re-split the joined string right back into a list -- so $original_line silently became space-joined instead of multiline. Fixed by piping through 'string collect'. Added test_fish_ctrl_t_draft_decode_preserves_multiline_drafts, which exercises the fish decode snippet directly (not the Rust writer, the side that was already correct) against a real multiline draft file. 2. write_ctrl_t_draft_file left the draft file behind if writeln! or write_all failed after OpenOptions::open succeeded, leaking a partially-written in-progress command line past the owner-only permissions that exist to protect it. Split the write step out as write_ctrl_t_draft_file_with_writer so a test can inject a failure and verify cleanup without needing a real filesystem-level failure; both writes' errors now unlink the file before returning. 3. Trimmed warp_run_external_ctrl_t_widget's comment from ~35 lines of mechanics narration down to the two rationales the code can't express on its own: invoking fzf-file-widget directly for token awareness, and treating unchanged output as cancellation. All new/changed tests mutation-checked: reverting each fix reproduces the original failure with the exact wrong value, confirmed, then restored. --- app/assets/bundled/bootstrap/fish.sh | 45 ++++++++----------------- app/src/terminal/bootstrap_tests.rs | 50 ++++++++++++++++++++++++++++ app/src/terminal/input.rs | 30 +++++++++++++++-- app/src/terminal/input_tests.rs | 22 ++++++++++++ 4 files changed, 113 insertions(+), 34 deletions(-) diff --git a/app/assets/bundled/bootstrap/fish.sh b/app/assets/bundled/bootstrap/fish.sh index 5fd67691902..250a951f279 100644 --- a/app/assets/bundled/bootstrap/fish.sh +++ b/app/assets/bundled/bootstrap/fish.sh @@ -630,38 +630,16 @@ end # an unrelated write to the pty. # # Unlike warp_run_external_ctrl_r_widget above, this calls the user's own bound fzf-file-widget -# directly rather than re-running fzf against an independent search command: that function is -# token-aware (it parses the current commandline token into a search root, a seed query, and an -# option prefix -- see fzf's own __fzf_parse_commandline -- then replaces just that token), and -# reproducing that parsing by hand would either drop it or duplicate it badly. Calling a fish -# function directly (rather than queuing it as a bound key with `commandline -f`, which would -# defer its effect to the next prompt read instead of running it now) blocks until the picker -# exits, unlike zle/bash's bind machinery, which is why this differs from the ctrl-r widget above. -# -# Since fzf-file-widget itself reads and writes the commandline, it needs the real draft line and -# cursor to do anything useful with -- which can't be passed as this helper's own argument (argv -# is visible to any local process via /proc) or embedded in the command Warp types (which would -# land it in scrollback). So Warp writes it to the file warp_ctrl_t_draft_file_path locates, -# owner-only (0600) so no other local user can read an in-progress command line, and this seeds -# the widget with it via commandline -r/-C before calling it, then clears the line again -# afterwards: since the widget already performed its own token-aware replacement, Warp takes the -# reported result as the finished buffer wholesale rather than splicing a fragment into the -# pre-handoff draft the way bash/zsh's plain-path report requires (see CtrlTApplyMode::Replace); -# leaving the widget's own edit on the commandline would otherwise queue it for execution. -# The draft file is removed on every exit from this branch, including a missing file and a -# cancelled picker, since nothing else will ever clean it up. +# directly rather than re-running fzf against an independent search command, since that function +# is token-aware (see fzf's own __fzf_parse_commandline) and reproducing that parsing by hand +# would either drop it or duplicate it badly. # # fzf-file-widget has no way to report cancellation distinctly from a selection: on Escape it -# simply leaves the commandline exactly as seeded, so its output is indistinguishable from a -# "selection" that happens to reproduce the original line by content alone. Collapse that case to -# an empty result -- Warp's existing convention for "nothing selected", which bash/zsh's plain-path -# widgets satisfy naturally since they never seed anything for cancellation to echo back -- so a -# cancelled search restores the pre-handoff cursor instead of hitting CtrlTApplyMode::Replace's -# no-op-on-cursor insertion path. A real selection cannot round-trip to this same false-cancel -# case: fzf-file-widget always appends a trailing space when it completes a token, so replacing an -# in-progress token always changes the line, and re-selecting a path already fully typed (cursor -# past its trailing space, so there's no token left to replace) inserts a second copy rather than -# reproducing the first -- confirmed live for both shapes. +# leaves the commandline exactly as seeded, so its output is indistinguishable from a selection +# that reproduces the original line. Collapse that case to an empty result, Warp's existing +# convention for "nothing selected" -- a real selection can't trigger this false cancel, since +# completing a token always appends a trailing space and reselecting an already-complete path +# duplicates it rather than reproducing it (confirmed live). function warp_run_external_ctrl_t_widget set -l warp_ctrl_t_token "$argv[1]" set -l result "" @@ -675,8 +653,11 @@ function warp_run_external_ctrl_t_widget set char_cursor $draft_contents[1] # The remaining lines are the draft verbatim: rejoining with the same separator the # command-substitution split on above losslessly reconstructs it, embedded newlines - # included, since the file has no trailing newline for fish to have dropped. - set original_line (string join \n -- $draft_contents[2..]) + # included, since the file has no trailing newline for fish to have dropped. Piped + # through `string collect`, since otherwise the newline just reintroduced would make + # this `set`'s own command substitution re-split the joined string right back into a + # list. + set original_line (string join \n -- $draft_contents[2..] | string collect) end commandline -r -- $original_line commandline -C -- $char_cursor diff --git a/app/src/terminal/bootstrap_tests.rs b/app/src/terminal/bootstrap_tests.rs index dd713c5b183..24d888f2a17 100644 --- a/app/src/terminal/bootstrap_tests.rs +++ b/app/src/terminal/bootstrap_tests.rs @@ -333,6 +333,56 @@ printf 'result=[%s]\n' "$result" ); } +fn fish_ctrl_t_draft_decode_snippet() -> &'static str { + const FISH_SH: &str = include_str!("../../assets/bundled/bootstrap/fish.sh"); + // Structural, not literal-text, boundaries (see `fish_ctrl_t_widget_result_fn` above) so a + // behavioral change to the reconstruction logic changes what the test observes. + let start_marker = "if test -f \"$draft_file\"\n"; + let start = FISH_SH + .find(start_marker) + .expect("fish ctrl-t draft decode snippet start should exist"); + let end_marker = "\n end\n"; + let end = FISH_SH[start..] + .find(end_marker) + .expect("fish ctrl-t draft decode snippet end should exist"); + &FISH_SH[start..start + end + end_marker.len()] +} + +/// Regression test for the fish decode path (`warp_run_external_ctrl_t_widget` reading back the +/// draft file), not just the Rust file writer: a multiline in-progress command must survive +/// reconstruction intact. fish's command substitution splits `cat`'s output into a list by +/// newline, so rejoining it without `string collect` (see the comment on `warp_ctrl_t_widget`'s +/// reconstruction line) silently drops the embedded newline back out -- exercising only the write +/// side can never catch that, since the bug is entirely in how fish re-reads what was written. +#[test] +fn test_fish_ctrl_t_draft_decode_preserves_multiline_drafts() { + let decode_snippet = fish_ctrl_t_draft_decode_snippet(); + let draft_file = + std::env::temp_dir().join(format!("warp-ctrl-t-decode-test-{}", uuid::Uuid::new_v4())); + std::fs::write(&draft_file, "8\necho one\necho two").expect("should write test draft file"); + let draft_file_path = draft_file.display().to_string(); + let script = format!( + r#" +set -l draft_file '{draft_file_path}' +set -l char_cursor 0 +set -l original_line '' +{decode_snippet} +printf 'char_cursor=[%s]\n' "$char_cursor" +printf 'original_line=[%s]\n' "$original_line" +"# + ); + let stdout = run_fish(&script); + std::fs::remove_file(&draft_file).ok(); + let Some(stdout) = stdout else { + return; + }; + assert!(stdout.contains("char_cursor=[8]"), "{stdout}"); + assert!( + stdout.contains("original_line=[echo one\necho two]"), + "{stdout}" + ); +} + /// Regression test for the fish equivalent of bash's picker-function guard: detection must /// decline (no tag, no interception) when `fzf-file-widget` -- the function /// `warp_run_external_ctrl_t_widget` now calls directly -- isn't actually defined, even though diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index f93d70f139e..907581e57ca 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -1920,6 +1920,30 @@ fn write_ctrl_t_draft_file( cursor_offset: ByteOffset, ) -> anyhow::Result<()> { use std::io::Write as _; + + write_ctrl_t_draft_file_with_writer( + token, + original_buffer, + cursor_offset, + |file, char_cursor, original_buffer| { + writeln!(file, "{char_cursor}")?; + file.write_all(original_buffer.as_bytes()) + }, + ) +} + +/// Implementation of [`write_ctrl_t_draft_file`], taking the write step as a parameter so tests +/// can inject a failure partway through without needing a real filesystem-level write failure. +/// +/// Cleans up the file it created if `write` fails: at that point the file already exists and may +/// hold a partially-written in-progress command line, which leaving behind would defeat the +/// owner-only permissions below just as thoroughly as never cleaning it up on success. +fn write_ctrl_t_draft_file_with_writer( + token: &str, + original_buffer: &str, + cursor_offset: ByteOffset, + write: impl FnOnce(&mut std::fs::File, usize, &str) -> std::io::Result<()>, +) -> anyhow::Result<()> { #[cfg(unix)] use std::os::unix::fs::OpenOptionsExt as _; @@ -1934,8 +1958,10 @@ fn write_ctrl_t_draft_file( let mut file = options .open(&path) .with_context(|| format!("failed to create {}", path.display()))?; - writeln!(file, "{char_cursor}")?; - file.write_all(original_buffer.as_bytes())?; + if let Err(error) = write(&mut file, char_cursor, original_buffer) { + let _ = std::fs::remove_file(&path); + return Err(error).with_context(|| format!("failed to write {}", path.display())); + } Ok(()) } diff --git a/app/src/terminal/input_tests.rs b/app/src/terminal/input_tests.rs index 51a44c8e076..ad200eb849a 100644 --- a/app/src/terminal/input_tests.rs +++ b/app/src/terminal/input_tests.rs @@ -2368,6 +2368,28 @@ fn write_ctrl_t_draft_file_is_created_with_owner_only_permissions() { ); } +/// The draft file may hold a partially-written in-progress command line if the write fails after +/// creation; leaving it behind would defeat the owner-only permissions above just as thoroughly as +/// never cleaning it up on success. +#[test] +fn write_ctrl_t_draft_file_removes_the_file_when_the_write_fails() { + let token = format!("test-{}", Uuid::new_v4()); + let result = write_ctrl_t_draft_file_with_writer( + &token, + "echo hi", + ByteOffset::from(7), + |_file, _char_cursor, _original_buffer| { + Err(std::io::Error::other("simulated write failure")) + }, + ); + + assert!(result.is_err(), "a write failure must be propagated"); + assert!( + !ctrl_t_draft_file_path(&token).exists(), + "the draft file must not be left behind when the write fails" + ); +} + /// While `ShellWidgetHandoff` is disabled (its default state, matching a user who hasn't opted /// into this prototype), the `workspace:trigger_external_ctrl_t_file_search` binding must be /// completely ineligible -- not merely a no-op when triggered -- so ctrl-t falls through to From 3abda41425722e8c935425ae7b9a7c35d39254dc Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:11:09 +0000 Subject: [PATCH 31/70] Fix fish ctrl-r: call fzf-history-widget directly, not hand-built version-dependent flags warp_run_external_ctrl_r_widget's fzf case hand-built FZF_DEFAULT_OPTS via __fzf_defaults, a helper from fzf's own shell integration, passing --wrap-sign, --highlight-line, --accept-nth, and --with-shell. Against a real, still-commonly-packaged fzf 0.44.1 install, __fzf_defaults itself does not exist in that version's shell integration ("Unknown command: __fzf_defaults"), and none of those four flags are accepted by that fzf's own --help either. Fish continues past the failed `set` by default, so execution fell through to invoking fzf without the options that command was supposed to produce, while the piped-in history was still formatted assuming those options were active -- fzf then rendered its raw index-prefixed input directly, which is what showed up live as a garbled, overlapping picker, and search/selection operated over the whole line (index prefix included) instead of just the command text, which is how selecting an entry could produce something unrelated to what was highlighted. This is #15513's own code (the ctrl-r fish handoff doesn't exist on master), not a latent defect exposed by the crate-extraction merge, so it's this PR's problem despite being unrelated to that merge's conflicts. Fix: call the user's own bound fzf-history-widget directly instead of reimplementing its fzf invocation, mirroring warp_run_external_ctrl_t_widget's fzf-file-widget call. This has no version-dependent flags of its own (it ships with the fzf release it targets) and already replaces the whole commandline on selection while leaving it untouched on cancel -- exactly ctrl-r's own semantics -- so reading commandline() back and clearing it affords the same safety against queuing the result for execution that the ctrl-t fix already established. Verified in isolation before touching the live app: __fzf_defaults is genuinely undefined against the installed fzf (confirmed directly); fzf-history-widget called directly correctly replaces commandline with the selected entry; and the new warp_run_external_ctrl_r_widget, instrumented to dump its captured result to a file (avoiding the redirection artifact that produced a false empty-result reading on an earlier attempt), captures "echo bbb_history_test_two" on selection and empty on cancel. Added test_fish_ctrl_r_widget_reports_fzf_history_widget_selection and ..._reports_empty_buffer_when_widget_leaves_commandline_untouched, which stub fzf-history-widget and the interactive-only commandline builtin to exercise the delegation and result-capture wiring headlessly, without needing any specific fzf version's flags to exist -- the kind of coverage that would have caught the original defect, rather than asserting one flag's absence. Mutation-checked: hardcoding the captured result to empty makes the selection test fail with the exact wrong value while the cancel test still passes; confirmed, then reverted. Live-reverified on a fully fresh app relaunch and new fish tab: ctrl-r selection (echo beta_marker_two) and cancel (preserving predraft text) both behave correctly; the picker renders as a clean fzf list, not the previously-observed garbled overlay. --wrap-sign was present from this PR's very first ctrl-r commit (0c7bf3792), predating the PR description's "ctrl-r on every shell exercised" and "zsh, fzf 0.44.1" testing claims. Those claims did not exercise fish's ctrl-r against fzf 0.44.1 as written; the PR description is being corrected to say so plainly rather than leave the overstated claim in place. --- app/assets/bundled/bootstrap/fish.sh | 41 +++++------ crates/warp_terminal/src/bootstrap_tests.rs | 78 +++++++++++++++++++++ 2 files changed, 94 insertions(+), 25 deletions(-) diff --git a/app/assets/bundled/bootstrap/fish.sh b/app/assets/bundled/bootstrap/fish.sh index 250a951f279..8a8bbc57681 100644 --- a/app/assets/bundled/bootstrap/fish.sh +++ b/app/assets/bundled/bootstrap/fish.sh @@ -551,9 +551,19 @@ end # The handoff token given as $argv[1] is echoed back unchanged, so Warp can confirm the hook is # the reply to the handoff it started rather than an unrelated write to the pty. # -# We re-run each tool's own underlying picker rather than invoking its bound fish function: those -# functions write the selection into fish's line buffer with `commandline`, which would leave the -# text queued for execution in the shell rather than handing it to Warp's editor. +# For fzf, this calls the user's own bound fzf-history-widget directly (mirroring +# warp_run_external_ctrl_t_widget's fzf-file-widget call below) rather than hand-rebuilding its +# fzf invocation: fzf's own shell integration changes its available CLI flags and internal helper +# functions across versions, and a hand-built invocation here previously broke outright on a +# packaged fzf whose shell integration both lacked the `__fzf_defaults` helper it called and +# rejected several of the flags it passed (`--wrap-sign`, `--highlight-line`, `--accept-nth`, +# `--with-shell` are all absent from that version's own `fzf --help`) -- a version-compatibility +# liability fzf-history-widget itself doesn't have, since it ships with the fzf release it +# targets. fzf-history-widget replaces the whole commandline with its result, matching ctrl-r's +# own "replace the whole input line" semantics exactly, and reads back as empty on cancel (it +# only calls `commandline` on a successful selection); clearing the commandline afterward, as +# warp_run_external_ctrl_t_widget's fzf-file-widget call already does, prevents its selection from +# being queued for execution. # # $_WARP_EXTERNAL_CTRL_R_WIDGET is set during bootstrap (see warp_bootstrapped) to an exact # allowlist of each integration's canonical widget name -- not merely a name containing "fzf" or @@ -565,29 +575,10 @@ function warp_run_external_ctrl_r_widget set -l result "" switch "$_WARP_EXTERNAL_CTRL_R_WIDGET" case 'fzf-history-widget' - # fzf's fish integration reads history through the shell rather than a history file, so - # this mirrors the pipeline fzf-history-widget builds, minus its `commandline` calls. - set -lx FZF_DEFAULT_OPTS (__fzf_defaults '' \ - '--nth=2..,.. --scheme=history --wrap-sign="\t↳ "' \ - "--bind=ctrl-r:toggle-sort --highlight-line $FZF_CTRL_R_OPTS" \ - '--accept-nth=2.. --read0 --print0 --with-shell='(status fish-path)\\ -c) - set -lx FZF_DEFAULT_OPTS_FILE - set -lx FZF_DEFAULT_COMMAND - if type -q perl - set -a FZF_DEFAULT_OPTS '--tac' - set FZF_DEFAULT_COMMAND 'builtin history -z --reverse | command perl -0 -pe \'s/^/$.\t/g; s/\n/\n\t/gm\'' - else - set FZF_DEFAULT_COMMAND \ - 'set -l h (builtin history -z --reverse | string split0);' \ - 'for i in (seq (count $h) -1 1);' \ - 'string join0 -- $i\t(string replace -a -- \n \n\t $h[$i] | string collect);' \ - 'end' - end test -z "$fish_private_mode"; and builtin history merge - set -l selected - if set selected (eval $FZF_DEFAULT_COMMAND \| (__fzfcmd) | string split0) - set result (string replace -a -- \n\t \n $selected[1]) - end + fzf-history-widget + set result (commandline) + commandline -r '' case '_atuin_search' # atuin writes its TUI to stdout and the selection to fd 3, so the two are swapped here to # leave the UI on the terminal and capture only the selection. diff --git a/crates/warp_terminal/src/bootstrap_tests.rs b/crates/warp_terminal/src/bootstrap_tests.rs index a22769b538e..e364c9b9740 100644 --- a/crates/warp_terminal/src/bootstrap_tests.rs +++ b/crates/warp_terminal/src/bootstrap_tests.rs @@ -256,6 +256,84 @@ printf 'widget=[%s] plugins=[%s]\n' "$_WARP_EXTERNAL_CTRL_T_WIDGET" "${{shell_pl assert!(stdout.contains("external_ctrl_t_file"), "{stdout}"); } +fn fish_ctrl_r_widget_runner_fn() -> &'static str { + const FISH_SH: &str = include_str!("../../../app/assets/bundled/bootstrap/fish.sh"); + let start_marker = "function warp_run_external_ctrl_r_widget\n"; + let start = FISH_SH + .find(start_marker) + .expect("fish ctrl-r widget runner function start should exist"); + let end_marker = "\nend\n"; + let end = FISH_SH[start..] + .find(end_marker) + .expect("fish ctrl-r widget runner function end should exist"); + &FISH_SH[start..start + end + end_marker.len()] +} + +/// Regression test for `warp_run_external_ctrl_r_widget`'s fzf case: it used to hand-build +/// `FZF_DEFAULT_OPTS` with flags (`--wrap-sign`, `--highlight-line`, `--accept-nth`, +/// `--with-shell`) and call a helper function (`__fzf_defaults`) that don't exist on every fzf +/// shell integration -- confirmed to fail outright with "Unknown command: __fzf_defaults" against +/// a real, still-commonly-packaged fzf 0.44.1 install, with the picker that did appear (fzf +/// falling through to a plain invocation once that command failed) reading raw, unformatted +/// history text as its input. It now delegates entirely to the user's own `fzf-history-widget` +/// instead, so this stubs that widget and the interactive-only `commandline` builtin they both +/// call, to verify the wrapper reports whatever the widget leaves on the commandline without +/// depending on any fzf-version-specific option or helper function existing at all -- the kind of +/// test that would have caught the original defect, rather than merely asserting one flag absent. +fn fish_ctrl_r_widget_test_script(runner: &str, widget_body: &str) -> String { + format!( + r#" +function warp_escape_json + string join \n $argv +end +function warp_send_json_message + echo "$argv" +end +set -g _test_commandline_value '' +function commandline + echo "$_test_commandline_value" +end +function fzf-history-widget + {widget_body} +end +set -g _WARP_EXTERNAL_CTRL_R_WIDGET fzf-history-widget +set -g WARP_SESSION_ID 12345 +{runner} +warp_run_external_ctrl_r_widget test-token +"# + ) +} + +#[test] +fn test_fish_ctrl_r_widget_reports_fzf_history_widget_selection() { + let runner = fish_ctrl_r_widget_runner_fn(); + let script = fish_ctrl_r_widget_test_script( + runner, + "set -g _test_commandline_value 'echo selected_from_widget'", + ); + let Some(stdout) = run_fish(&script) else { + return; + }; + assert!( + stdout.contains(r#""buffer": "echo selected_from_widget""#), + "{stdout}" + ); +} + +/// `fzf-history-widget` only calls `commandline` on a successful selection, leaving it untouched +/// on cancel -- the wrapper must report that untouched (here: still-empty) state as an empty +/// buffer, matching the existing "nothing selected" convention shared with the plain-path bash/ +/// zsh widgets. +#[test] +fn test_fish_ctrl_r_widget_reports_empty_buffer_when_widget_leaves_commandline_untouched() { + let runner = fish_ctrl_r_widget_runner_fn(); + let script = fish_ctrl_r_widget_test_script(runner, "# cancelled: commandline left as-is"); + let Some(stdout) = run_fish(&script) else { + return; + }; + assert!(stdout.contains(r#""buffer": """#), "{stdout}"); +} + fn fish_ctrl_t_widget_query_fn() -> &'static str { const FISH_SH: &str = include_str!("../../../app/assets/bundled/bootstrap/fish.sh"); let start_marker = "function warp_external_ctrl_t_widget\n set -l widget \"\"\n for binding in (bind \\ct 2>/dev/null)"; From 6741b362e4683a29809579a11b44db46a7295dee Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:32:46 +0000 Subject: [PATCH 32/70] Fix multiline splitting in ctrl-r/ctrl-t fish selections; add regression coverage Root cause: `set result (commandline)` (ctrl-r) and `(warp_ctrl_t_widget_result "$original_line" (commandline))` (ctrl-t) both capture `commandline`'s output via an unquoted command substitution. fish splits an unquoted `(...)` result into a list by newline, so a multi-line buffer (e.g. a multi-line history entry selected via ctrl-r, or an in-progress multi-line draft ctrl-t seeds fzf-file-widget with) was silently corrupted: - ctrl-r: `$result` became a 2+ element list. `warp_escape_json "$result"` quotes that list back down to a single argument, and fish joins a quoted list with a *space*, not a newline -- so the reported buffer had its lines space-joined instead of newline-separated. - ctrl-t: the unquoted `(commandline)` used directly as `warp_ctrl_t_widget_result`'s second argument expanded to multiple arguments, silently truncating `$argv[2]` (and therefore both the equality comparison and the returned value) to the result's first line. This affected both a real multi-line selection (truncated to its first line) and an unchanged multi-line draft on cancel (falsely reported as a changed selection, since the truncated first line differs from the full original). Fix: pipe both `commandline` calls through `string collect`, which marks its output so the enclosing command substitution treats it as a single value regardless of embedded newlines -- the same fix already applied to the draft-file decode path in the previous commit. Verification: - Added `test_fish_ctrl_r_widget_reports_multiline_selection_with_embedded_newline`, using the real `warp_escape_json` (not the plain-join stub the other tests in this section use) since the defect is specifically in how a multi-line value gets escaped once space-joined. - Added `test_fish_ctrl_t_widget_reports_full_multiline_change_without_truncation` and `test_fish_ctrl_t_widget_reports_empty_when_multiline_draft_is_left_unchanged`, exercising the full `warp_run_external_ctrl_t_widget` runner (not just the `warp_ctrl_t_widget_result` comparison helper in isolation) against a real draft file, with a stateful `commandline` stub supporting the `-r --`/ `-C --` forms the widget actually calls. - Mutation-checked all three: reverting either `string collect` reproduces exactly the described corruption (space-joined for ctrl-r; truncated to "echo START" for both ctrl-t cases), confirmed by rerunning the affected tests, then restored. - `cargo nextest run -p warp_terminal`: 569 passed, 2 skipped (pre-existing, unrelated flakes noted in earlier commits on this branch). - `./script/format` and `cargo clippy -p warp_terminal --all-targets --all-features --tests -- -D warnings`: clean. Also confirmed the Rust side has no analogous limitation: both `PendingCtrlRHandoff::restore_text` and `PendingCtrlTHandoff::insertion` are plain `String`s piped straight into `editor.set_buffer_text`/ `select_and_replace` with no line-based splitting anywhere in that path, so a correctly-reported multi-line selection lands in the editor buffer intact. --- app/assets/bundled/bootstrap/fish.sh | 10 +- crates/warp_terminal/src/bootstrap_tests.rs | 177 ++++++++++++++++++++ 2 files changed, 185 insertions(+), 2 deletions(-) diff --git a/app/assets/bundled/bootstrap/fish.sh b/app/assets/bundled/bootstrap/fish.sh index 8a8bbc57681..8b3ea99d631 100644 --- a/app/assets/bundled/bootstrap/fish.sh +++ b/app/assets/bundled/bootstrap/fish.sh @@ -577,7 +577,10 @@ function warp_run_external_ctrl_r_widget case 'fzf-history-widget' test -z "$fish_private_mode"; and builtin history merge fzf-history-widget - set result (commandline) + # Piped through `string collect`: a multi-line selection would otherwise make this `set`'s + # own command substitution split it into a list, which `warp_escape_json` below would then + # silently space-join instead of newline-join once quoted back down to a single argument. + set result (commandline | string collect) commandline -r '' case '_atuin_search' # atuin writes its TUI to stdout and the selection to fd 3, so the two are swapped here to @@ -653,7 +656,10 @@ function warp_run_external_ctrl_t_widget commandline -r -- $original_line commandline -C -- $char_cursor fzf-file-widget - set result (warp_ctrl_t_widget_result "$original_line" (commandline)) + # (commandline | string collect), not plain (commandline): unquoted, a multi-line result + # would otherwise expand to multiple arguments here, silently truncating + # warp_ctrl_t_widget_result's $argv[2] comparison and return value to its first line alone. + set result (warp_ctrl_t_widget_result "$original_line" (commandline | string collect)) commandline -r '' rm -f "$draft_file" end diff --git a/crates/warp_terminal/src/bootstrap_tests.rs b/crates/warp_terminal/src/bootstrap_tests.rs index e364c9b9740..eda4ce3a056 100644 --- a/crates/warp_terminal/src/bootstrap_tests.rs +++ b/crates/warp_terminal/src/bootstrap_tests.rs @@ -334,6 +334,57 @@ fn test_fish_ctrl_r_widget_reports_empty_buffer_when_widget_leaves_commandline_u assert!(stdout.contains(r#""buffer": """#), "{stdout}"); } +fn fish_warp_escape_json_fn() -> &'static str { + const FISH_SH: &str = include_str!("../../../app/assets/bundled/bootstrap/fish.sh"); + let start_marker = "function warp_escape_json\n"; + let start = FISH_SH + .find(start_marker) + .expect("fish warp_escape_json function start should exist"); + let end_marker = "\nend\n"; + let end = FISH_SH[start..] + .find(end_marker) + .expect("fish warp_escape_json function end should exist"); + &FISH_SH[start..start + end + end_marker.len()] +} + +/// Regression test for `set result (commandline | string collect)` above: without `string +/// collect`, a multi-line selection makes that `set`'s own command substitution split it into a +/// list by newline, and the real `warp_escape_json` (used here instead of the plain-join stub the +/// other tests in this section use, since the defect is specifically in how it escapes -- or +/// fails to escape -- what it's given) then quotes that list back down to a single argument by +/// joining with a space instead of preserving the newline as JSON's `\n` escape. +#[test] +fn test_fish_ctrl_r_widget_reports_multiline_selection_with_embedded_newline() { + let runner = fish_ctrl_r_widget_runner_fn(); + let escape_json = fish_warp_escape_json_fn(); + let script = format!( + r#" +{escape_json} +function warp_send_json_message + echo "$argv" +end +set -g _test_commandline_value '' +function commandline + echo "$_test_commandline_value" +end +function fzf-history-widget + set -g _test_commandline_value (printf 'echo one\necho two' | string collect) +end +set -g _WARP_EXTERNAL_CTRL_R_WIDGET fzf-history-widget +set -g WARP_SESSION_ID 12345 +{runner} +warp_run_external_ctrl_r_widget test-token +"# + ); + let Some(stdout) = run_fish(&script) else { + return; + }; + assert!( + stdout.contains(r#""buffer": "echo one\necho two""#), + "{stdout}" + ); +} + fn fish_ctrl_t_widget_query_fn() -> &'static str { const FISH_SH: &str = include_str!("../../../app/assets/bundled/bootstrap/fish.sh"); let start_marker = "function warp_external_ctrl_t_widget\n set -l widget \"\"\n for binding in (bind \\ct 2>/dev/null)"; @@ -461,6 +512,132 @@ printf 'original_line=[%s]\n' "$original_line" ); } +fn fish_ctrl_t_widget_runner_fn() -> &'static str { + const FISH_SH: &str = include_str!("../../../app/assets/bundled/bootstrap/fish.sh"); + let start_marker = "function warp_run_external_ctrl_t_widget\n"; + let start = FISH_SH + .find(start_marker) + .expect("fish ctrl-t widget runner function start should exist"); + let end_marker = "\nend\n"; + let end = FISH_SH[start..] + .find(end_marker) + .expect("fish ctrl-t widget runner function end should exist"); + &FISH_SH[start..start + end + end_marker.len()] +} + +fn fish_ctrl_t_draft_file_path_fn() -> &'static str { + const FISH_SH: &str = include_str!("../../../app/assets/bundled/bootstrap/fish.sh"); + let start_marker = "function warp_ctrl_t_draft_file_path\n"; + let start = FISH_SH + .find(start_marker) + .expect("fish ctrl-t draft file path function start should exist"); + let end_marker = "\nend\n"; + let end = FISH_SH[start..] + .find(end_marker) + .expect("fish ctrl-t draft file path function end should exist"); + &FISH_SH[start..start + end + end_marker.len()] +} + +/// Builds a script that runs the full `warp_run_external_ctrl_t_widget` (not just the +/// `warp_ctrl_t_widget_result` comparison helper in isolation) against a real draft file, so the +/// `(commandline | string collect)` argument at its `fzf-file-widget` call site is exercised too +/// -- unquoted, a multi-line result there would otherwise expand to multiple arguments, silently +/// truncating that comparison to the result's first line alone. `commandline` is stubbed +/// statefully (supporting the `-r --` and `-C --` forms the widget actually calls, plus a plain +/// read) rather than as a fixed value, since the widget both seeds and reads it back. +fn fish_ctrl_t_widget_test_script(xdg_runtime_dir: &str, widget_body: &str) -> String { + let runner = fish_ctrl_t_widget_runner_fn(); + let draft_file_path_fn = fish_ctrl_t_draft_file_path_fn(); + let widget_result_fn = fish_ctrl_t_widget_result_fn(); + format!( + r#" +# Unlike the real warp_escape_json (see fish_warp_escape_json_fn above), this stub doesn't +# actually escape a real newline into JSON's `\n` -- piped through `string collect` purely so +# that leaving one in doesn't itself get re-split by the `set` below that captures this +# function's own output, which would otherwise mask the very truncation these tests exist to +# catch behind an unrelated space-joining artifact of the stub. +function warp_escape_json + string join \n $argv | string collect +end +function warp_send_json_message + echo "$argv" +end +set -gx XDG_RUNTIME_DIR '{xdg_runtime_dir}' +{draft_file_path_fn} +{widget_result_fn} +set -g _test_cl_value '' +function commandline + if test (count $argv) -ge 1; and test "$argv[1]" = '-r' + set -g _test_cl_value (string join \n -- $argv[3..] | string collect) + return 0 + end + if test (count $argv) -ge 1; and test "$argv[1]" = '-C' + return 0 + end + echo "$_test_cl_value" +end +function fzf-file-widget + {widget_body} +end +set -g _WARP_EXTERNAL_CTRL_T_WIDGET fzf-file-widget +set -g WARP_SESSION_ID 12345 +{runner} +warp_run_external_ctrl_t_widget test-token +"# + ) +} + +fn write_ctrl_t_test_draft(xdg_runtime_dir: &std::path::Path, contents: &str) { + std::fs::create_dir_all(xdg_runtime_dir).expect("should create test XDG_RUNTIME_DIR"); + std::fs::write(xdg_runtime_dir.join("warp-ctrl-t-test-token"), contents) + .expect("should write test draft file"); +} + +/// Regression test for the `(commandline | string collect)` argument at the widget's +/// `fzf-file-widget` call site: without `string collect`, a multi-line selection is split by that +/// call's own (unquoted) command substitution into multiple arguments, silently truncating +/// `warp_ctrl_t_widget_result`'s second argument -- and therefore the reported buffer -- to the +/// selection's first line alone. +#[test] +fn test_fish_ctrl_t_widget_reports_full_multiline_change_without_truncation() { + let xdg_runtime_dir = + std::env::temp_dir().join(format!("warp-ctrl-t-widget-test-{}", uuid::Uuid::new_v4())); + write_ctrl_t_test_draft(&xdg_runtime_dir, "10\necho START\nMIDDLE"); + let script = fish_ctrl_t_widget_test_script( + &xdg_runtime_dir.display().to_string(), + "commandline -r -- (printf 'echo START\\nMIDDLE nested.rs ' | string collect)", + ); + let stdout = run_fish(&script); + std::fs::remove_dir_all(&xdg_runtime_dir).ok(); + let Some(stdout) = stdout else { + return; + }; + assert!( + stdout.contains("\"buffer\": \"echo START\nMIDDLE nested.rs \""), + "{stdout}" + ); +} + +/// Companion to the test above, for the failure mode the same truncation causes on cancel: a +/// multi-line draft left unchanged gets word-split at the same call site, so +/// `warp_ctrl_t_widget_result` compares the full original line against only its own first line, +/// finds them unequal, and reports that stale first line as if it were a real selection instead +/// of the empty buffer this "unchanged" case is supposed to produce. +#[test] +fn test_fish_ctrl_t_widget_reports_empty_when_multiline_draft_is_left_unchanged() { + let xdg_runtime_dir = + std::env::temp_dir().join(format!("warp-ctrl-t-widget-test-{}", uuid::Uuid::new_v4())); + write_ctrl_t_test_draft(&xdg_runtime_dir, "10\necho START\nMIDDLE"); + let script = + fish_ctrl_t_widget_test_script(&xdg_runtime_dir.display().to_string(), "# cancelled"); + let stdout = run_fish(&script); + std::fs::remove_dir_all(&xdg_runtime_dir).ok(); + let Some(stdout) = stdout else { + return; + }; + assert!(stdout.contains(r#""buffer": """#), "{stdout}"); +} + /// Regression test for the fish equivalent of bash's picker-function guard: detection must /// decline (no tag, no interception) when `fzf-file-widget` -- the function /// `warp_run_external_ctrl_t_widget` now calls directly -- isn't actually defined, even though From 4c62d8b675002c23eef5bb27e4e17598b74cf29a Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:58:57 +0000 Subject: [PATCH 33/70] Switch ctrl-t draft file to NUL-delimited format to preserve trailing newlines Root cause: the fish reader captured the draft file via a plain command substitution, `(command cat -- "$draft_file")`, which unconditionally strips trailing newline bytes from what it captures before any list-splitting happens -- confirmed directly: a file ending in `\n` and one not ending in `\n` produced the identical split list either way. `string collect`, added in the previous commit to stop the *embedded*-newline case from being re-split, cannot fix this: it prevents further splitting of a value, but can't recover bytes the command substitution already discarded before `string collect` (or anything else) ever saw them. So a draft whose last character was a newline (e.g. a trailing blank line mid-multiline edit) was silently truncated before `fzf-file-widget` ever saw it, and cancel could not restore it byte-for-byte -- breaking the byte-for-byte cancel-restore guarantee this PR documents, not just an edge case. Fix: change the draft file format from newline-delimited (`{char_cursor}\n{draft}`) to NUL-delimited (`{char_cursor}\0{draft}\0`) on both sides: - `Input::write_ctrl_t_draft_file` now writes the cursor, a NUL, the draft verbatim, and a trailing NUL. - The fish reader now captures the file through `string split0`, which splits strictly on NUL and leaves everything else -- including a trailing newline -- untouched. A command buffer cannot itself contain a NUL byte (`commandline` has no way to represent one), so it's an unambiguous delimiter. Also checked the cursor field for the same class of issue: it's a plain integer with no embedded/trailing newline of its own, and parses identically regardless of position in the NUL-delimited stream (verified with cursor values 0 and a multi-digit value, both via `string split0` and a numeric `test -eq` comparison) -- nothing to fix there. Verification: - Updated `write_ctrl_t_draft_file_converts_byte_cursor_to_char_cursor_for_multi_byte_draft` to parse the new NUL-delimited format. - Updated the existing multiline fish-decode test and the two full-runner ctrl-t widget tests to write NUL-delimited draft files matching the new writer format. - Added `test_fish_ctrl_t_draft_decode_preserves_trailing_newline`: a draft ending in `\n`, asserted byte-for-byte through the real fish decode snippet (not just the Rust writer). - Mutation-checked: temporarily restored the prior `command cat` + `string join`/`string collect` decode (fed the same NUL-delimited file format) and confirmed both the multiline and trailing-newline decode tests fail -- the multiline one silently drops the second line entirely since the old code no longer understands NUL delimiters, and the trailing-newline one reports an empty draft -- then restored the fix. - `cargo test -p warp_terminal --lib fish_ctrl`: 11 passed. - `cargo test -p warp --lib ctrl_t`: 18 passed (including the updated `write_ctrl_t_draft_file_*` Rust-side tests). - `cargo nextest run -p warp_terminal`: 570 passed, 2 skipped (pre-existing, unrelated flakes noted in earlier commits on this branch). - `./script/format`; `cargo clippy --workspace --exclude warp_completer --all-targets --tests -- -D warnings`; `cargo clippy -p warp --all-targets --tests -- -D warnings` (matching `script/presubmit` exactly): all clean. Also investigated CI showing red with a skipped `params` (Compute workflow parameters) job: that job's own `if:` condition explicitly skips it for draft PRs ("Don't automatically run CI for draft PRs, to reduce GitHub Actions costs" -- .github/workflows/ci.yml), and this PR was still in draft state. Not a defect on this branch; marked the PR ready for review so CI actually runs. --- app/assets/bundled/bootstrap/fish.sh | 18 +++--- app/src/terminal/input.rs | 16 +++-- app/src/terminal/input_tests.rs | 11 ++-- crates/warp_terminal/src/bootstrap_tests.rs | 68 +++++++++++++++++---- 4 files changed, 83 insertions(+), 30 deletions(-) diff --git a/app/assets/bundled/bootstrap/fish.sh b/app/assets/bundled/bootstrap/fish.sh index 8b3ea99d631..e2b07fe994e 100644 --- a/app/assets/bundled/bootstrap/fish.sh +++ b/app/assets/bundled/bootstrap/fish.sh @@ -643,15 +643,15 @@ function warp_run_external_ctrl_t_widget set -l original_line '' set -l char_cursor 0 if test -f "$draft_file" - set -l draft_contents (command cat -- "$draft_file") - set char_cursor $draft_contents[1] - # The remaining lines are the draft verbatim: rejoining with the same separator the - # command-substitution split on above losslessly reconstructs it, embedded newlines - # included, since the file has no trailing newline for fish to have dropped. Piped - # through `string collect`, since otherwise the newline just reintroduced would make - # this `set`'s own command substitution re-split the joined string right back into a - # list. - set original_line (string join \n -- $draft_contents[2..] | string collect) + # NUL-delimited (see Input::write_ctrl_t_draft_file), not newline-delimited: a plain + # command substitution unconditionally strips trailing newline bytes from what it + # captures before any splitting happens, which would silently lose a trailing newline + # that's genuinely part of the draft. `string split0` splits on NUL only, so + # $draft_fields[2] is the draft verbatim -- embedded and trailing newlines included -- + # with no further reconstruction needed. + set -l draft_fields (command cat -- "$draft_file" | string split0) + set char_cursor $draft_fields[1] + set original_line $draft_fields[2] end commandline -r -- $original_line commandline -C -- $char_cursor diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index 0c1e5714376..3ce12c80a30 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -1911,10 +1911,15 @@ fn ctrl_t_draft_file_path(token: &str) -> PathBuf { /// /// Created with owner-only (0600) permissions from the moment the file exists -- this may contain /// in-progress command text the user hasn't run yet, and creating the file before restricting its -/// permissions would leave a window where another local user could read it. The first line is +/// permissions would leave a window where another local user could read it. The format is +/// `{char_cursor}\0{original_buffer}\0` -- NUL-delimited, not newline-delimited, because the fish +/// reader captures the file through a plain command substitution, which unconditionally strips +/// trailing newline bytes from what it captures before any splitting happens; a newline-delimited +/// format would silently lose a trailing newline that's genuinely part of the draft. A shell +/// command buffer cannot itself contain a NUL byte, so it's an unambiguous delimiter fish can +/// split back out with `string split0` unaffected by that stripping. `char_cursor` is /// `cursor_offset` converted to a character offset, since fish's `commandline -C` takes -/// characters while `cursor_offset` is a byte offset; the remainder of the file is -/// `original_buffer` verbatim. +/// characters while `cursor_offset` is a byte offset. fn write_ctrl_t_draft_file( token: &str, original_buffer: &str, @@ -1927,8 +1932,9 @@ fn write_ctrl_t_draft_file( original_buffer, cursor_offset, |file, char_cursor, original_buffer| { - writeln!(file, "{char_cursor}")?; - file.write_all(original_buffer.as_bytes()) + write!(file, "{char_cursor}\0")?; + file.write_all(original_buffer.as_bytes())?; + file.write_all(b"\0") }, ) } diff --git a/app/src/terminal/input_tests.rs b/app/src/terminal/input_tests.rs index 0b2b9aa706c..760e833aaf8 100644 --- a/app/src/terminal/input_tests.rs +++ b/app/src/terminal/input_tests.rs @@ -2332,16 +2332,17 @@ fn write_ctrl_t_draft_file_converts_byte_cursor_to_char_cursor_for_multi_byte_dr let contents = std::fs::read_to_string(&path).expect("draft file should have been written"); std::fs::remove_file(&path).ok(); - let mut lines = contents.splitn(2, '\n'); + let (cursor_field, rest) = contents + .split_once('\0') + .expect("the draft file must be NUL-delimited"); assert_eq!( - lines.next(), - Some("5"), + cursor_field, "5", "the cursor must be written as a character offset, not the byte offset" ); assert_eq!( - lines.next(), + rest.strip_suffix('\0'), Some(original_buffer), - "the draft line must be written verbatim" + "the draft must be written verbatim, NUL-terminated" ); } diff --git a/crates/warp_terminal/src/bootstrap_tests.rs b/crates/warp_terminal/src/bootstrap_tests.rs index eda4ce3a056..e731bc0f0ea 100644 --- a/crates/warp_terminal/src/bootstrap_tests.rs +++ b/crates/warp_terminal/src/bootstrap_tests.rs @@ -477,18 +477,28 @@ fn fish_ctrl_t_draft_decode_snippet() -> &'static str { &FISH_SH[start..start + end + end_marker.len()] } +/// Writes a NUL-delimited draft file matching [`Input::write_ctrl_t_draft_file`]'s format +/// (`{char_cursor}\0{draft}\0`) directly, rather than going through the Rust writer, so these +/// fish-side decode tests exercise exactly the bytes fish reads without depending on the writer +/// under a separate set of tests. +fn write_decode_test_draft_file(char_cursor: u32, draft: &str) -> std::path::PathBuf { + let draft_file = + std::env::temp_dir().join(format!("warp-ctrl-t-decode-test-{}", uuid::Uuid::new_v4())); + std::fs::write(&draft_file, format!("{char_cursor}\0{draft}\0")) + .expect("should write test draft file"); + draft_file +} + /// Regression test for the fish decode path (`warp_run_external_ctrl_t_widget` reading back the /// draft file), not just the Rust file writer: a multiline in-progress command must survive -/// reconstruction intact. fish's command substitution splits `cat`'s output into a list by -/// newline, so rejoining it without `string collect` (see the comment on `warp_ctrl_t_widget`'s -/// reconstruction line) silently drops the embedded newline back out -- exercising only the write +/// reconstruction intact. An unquoted command substitution splits `cat`'s output into a list by +/// newline, so reconstructing that split without care (see the comment on `warp_ctrl_t_widget`'s +/// reconstruction line) can silently drop embedded newlines back out -- exercising only the write /// side can never catch that, since the bug is entirely in how fish re-reads what was written. #[test] fn test_fish_ctrl_t_draft_decode_preserves_multiline_drafts() { let decode_snippet = fish_ctrl_t_draft_decode_snippet(); - let draft_file = - std::env::temp_dir().join(format!("warp-ctrl-t-decode-test-{}", uuid::Uuid::new_v4())); - std::fs::write(&draft_file, "8\necho one\necho two").expect("should write test draft file"); + let draft_file = write_decode_test_draft_file(8, "echo one\necho two"); let draft_file_path = draft_file.display().to_string(); let script = format!( r#" @@ -512,6 +522,37 @@ printf 'original_line=[%s]\n' "$original_line" ); } +/// Regression test for a draft whose *last* character is a newline (e.g. a trailing blank line +/// mid-multiline edit): a plain command substitution -- `(command cat -- $draft_file)` -- +/// unconditionally strips trailing newline bytes from what it captures before any splitting +/// happens, which is exactly why the decode format is NUL-delimited (see +/// `Input::write_ctrl_t_draft_file`) rather than reconstructed from a newline-split list. The +/// multiline test above only covers an *embedded* newline, which that stripping doesn't touch -- +/// this is the case it would silently corrupt. +#[test] +fn test_fish_ctrl_t_draft_decode_preserves_trailing_newline() { + let decode_snippet = fish_ctrl_t_draft_decode_snippet(); + let draft_file = write_decode_test_draft_file(3, "echo hi\n"); + let draft_file_path = draft_file.display().to_string(); + let script = format!( + r#" +set -l draft_file '{draft_file_path}' +set -l char_cursor 0 +set -l original_line '' +{decode_snippet} +printf 'char_cursor=[%s]\n' "$char_cursor" +printf 'original_line=[%s]\n' "$original_line" +"# + ); + let stdout = run_fish(&script); + std::fs::remove_file(&draft_file).ok(); + let Some(stdout) = stdout else { + return; + }; + assert!(stdout.contains("char_cursor=[3]"), "{stdout}"); + assert!(stdout.contains("original_line=[echo hi\n]"), "{stdout}"); +} + fn fish_ctrl_t_widget_runner_fn() -> &'static str { const FISH_SH: &str = include_str!("../../../app/assets/bundled/bootstrap/fish.sh"); let start_marker = "function warp_run_external_ctrl_t_widget\n"; @@ -587,10 +628,15 @@ warp_run_external_ctrl_t_widget test-token ) } -fn write_ctrl_t_test_draft(xdg_runtime_dir: &std::path::Path, contents: &str) { +/// Writes a NUL-delimited draft file matching [`Input::write_ctrl_t_draft_file`]'s format (see +/// `write_decode_test_draft_file` above) at the path the widget under test will look for. +fn write_ctrl_t_test_draft(xdg_runtime_dir: &std::path::Path, char_cursor: u32, draft: &str) { std::fs::create_dir_all(xdg_runtime_dir).expect("should create test XDG_RUNTIME_DIR"); - std::fs::write(xdg_runtime_dir.join("warp-ctrl-t-test-token"), contents) - .expect("should write test draft file"); + std::fs::write( + xdg_runtime_dir.join("warp-ctrl-t-test-token"), + format!("{char_cursor}\0{draft}\0"), + ) + .expect("should write test draft file"); } /// Regression test for the `(commandline | string collect)` argument at the widget's @@ -602,7 +648,7 @@ fn write_ctrl_t_test_draft(xdg_runtime_dir: &std::path::Path, contents: &str) { fn test_fish_ctrl_t_widget_reports_full_multiline_change_without_truncation() { let xdg_runtime_dir = std::env::temp_dir().join(format!("warp-ctrl-t-widget-test-{}", uuid::Uuid::new_v4())); - write_ctrl_t_test_draft(&xdg_runtime_dir, "10\necho START\nMIDDLE"); + write_ctrl_t_test_draft(&xdg_runtime_dir, 10, "echo START\nMIDDLE"); let script = fish_ctrl_t_widget_test_script( &xdg_runtime_dir.display().to_string(), "commandline -r -- (printf 'echo START\\nMIDDLE nested.rs ' | string collect)", @@ -627,7 +673,7 @@ fn test_fish_ctrl_t_widget_reports_full_multiline_change_without_truncation() { fn test_fish_ctrl_t_widget_reports_empty_when_multiline_draft_is_left_unchanged() { let xdg_runtime_dir = std::env::temp_dir().join(format!("warp-ctrl-t-widget-test-{}", uuid::Uuid::new_v4())); - write_ctrl_t_test_draft(&xdg_runtime_dir, "10\necho START\nMIDDLE"); + write_ctrl_t_test_draft(&xdg_runtime_dir, 10, "echo START\nMIDDLE"); let script = fish_ctrl_t_widget_test_script(&xdg_runtime_dir.display().to_string(), "# cancelled"); let stdout = run_fish(&script); From 51da3c14935a995c2890293c04e681725259a3e4 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:49:16 +0000 Subject: [PATCH 34/70] Fix CI: LF-normalize bootstrap scripts for Windows checkouts; skip bash ctrl-t detection on bash < 4 Two independent, pre-existing platform issues surfaced now that CI actually ran on Windows and macOS for the first time (this PR sat in draft, and `params`'s own `if:` skips CI entirely for draft PRs -- see the previous commit's investigation of the same guard). ## Windows: every bootstrap test that parses a real shell script failed Root cause: `app/assets/bundled/bootstrap/*.sh`/`*.txt` have no `.gitattributes` entry, so a Windows checkout's default `core.autocrlf` rewrites their LF line endings to CRLF. `crates/warp_terminal/src/ bootstrap_tests.rs` embeds these files via `include_str!` and locates snippets in them with literal `\n`-delimited `.find()` searches -- against CRLF content, every one of those searches returns `None`, and the `.expect(...)` on the result panics. This is not new to this PR's last two commits; it affects every structural-extraction bootstrap test, including ones that predate them (`test_fish_history_wrapper_*`, `test_bash_ctrl_t_detection_*`), confirming it was never actually exercised on Windows before now rather than being introduced recently. Fix: `.gitattributes` now forces `text eol=lf` for `app/assets/bundled/bootstrap/*.sh` and `*.txt`. The committed blobs are already LF-only (verified: no `\r` in the current `fish.sh`/`bash_body.sh` content), so this only changes future checkout behavior, not file content. This also matters beyond the tests: these are real shell scripts a Windows-hosted bash (MSYS2/WSL) actually executes, so a CRLF checkout could have corrupted the live integration too, not just the tests reading it. ## MacOS: bash ctrl-t detection tests assume `bind -X`, which bash 3.2 lacks `bind -X` (list `-x` bindings) is part of the readline update bash 4.0 shipped in 2009; it doesn't exist in bash 3.2, which macOS still ships as `/bin/bash` for licensing reasons. `bash_ctrl_t_detection_snippet`'s detection logic depends on `bind -X` entirely, so on bash 3.2 it silently produces nothing (stderr is redirected away) and `_WARP_EXTERNAL_CTRL_T_WIDGET` is always empty, regardless of whether the picker function exists. This is the same limitation the PR description already documents and accepts: "bash < 4 (notably macOS's system bash 3.2) binds ctrl-r and ctrl-t through readline macros that bind -X cannot see, so neither binding is detected there." That has two consequences for the two tests exercising this snippet: `test_bash_ctrl_t_detection_tags_when_picker_function_is_present` fails outright (detection can never succeed), and `test_bash_ctrl_t_detection_declines_when_picker_function_is_absent` passes, but only because its expected result (`widget=[]`) happens to match what bash 3.2 always produces -- not because it exercised the absent-function branch it claims to test. Weakening only the failing test would leave that vacuous pass in place as if it were real coverage. Fix: added `bash_major_version()` (queries `${BASH_VERSINFO[0]}`, `None` if bash isn't installed at all, mirroring `run_bash`'s existing "shell missing" skip convention) and a `bash_supports_bind_dash_capital_x()` gate; both tests now skip together on bash < 4, with a comment explaining why neither should run there rather than just the one that happened to fail. ## Verification - `cargo test -p warp_terminal --lib bootstrap`: 25 passed (this sandbox's bash is 5.2, so the new guard is a no-op here; its actual effect can only be observed on a real bash 3.2, i.e. the macOS CI runner). - `cargo nextest run -p warp_terminal --no-fail-fast bootstrap`: 25 passed, matching. - `./script/format`; `cargo clippy -p warp_terminal --all-targets --tests -- -D warnings`: clean. - Confirmed the committed `fish.sh`/`bash_body.sh` blobs contain no `\r` bytes, so the `.gitattributes` fix is purely a checkout-time behavior change, not a content change. - Could not reproduce the Windows CRLF checkout or the macOS bash 3.2 environment locally (this sandbox is Linux with a modern bash); the real confirmation is the next Windows/macOS CI run on this commit, which I will watch through to completion before reporting. --- .gitattributes | 9 ++++++ crates/warp_terminal/src/bootstrap_tests.rs | 34 +++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/.gitattributes b/.gitattributes index 9867ec12259..6c92e4a062d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,12 @@ *.pdb filter=lfs diff=lfs merge=lfs -text crates/input_classifier/models/** filter=lfs diff=lfs merge=lfs -text crates/input_classifier/models/**/*tokenizer.json -filter -diff -merge text linguist-generated=true + +# These are embedded verbatim via `include_str!` and parsed by exact-byte structural extraction +# in crates/warp_terminal/src/bootstrap_tests.rs (literal `\n`-delimited marker searches), and are +# themselves shell scripts a real bash/fish process reads. Without this, a Windows checkout's +# default `core.autocrlf` rewrites their LF line endings to CRLF, which both breaks every marker +# search in that test file (they search for literal `\n`, not `\r\n`) and would corrupt the +# scripts themselves if ever executed by a Windows-hosted bash/fish (e.g. under MSYS2/WSL). +app/assets/bundled/bootstrap/*.sh text eol=lf +app/assets/bundled/bootstrap/*.txt text eol=lf diff --git a/crates/warp_terminal/src/bootstrap_tests.rs b/crates/warp_terminal/src/bootstrap_tests.rs index e731bc0f0ea..86424901b4a 100644 --- a/crates/warp_terminal/src/bootstrap_tests.rs +++ b/crates/warp_terminal/src/bootstrap_tests.rs @@ -211,6 +211,34 @@ fn run_bash(script: &str) -> Option { Some(String::from_utf8_lossy(&output.stdout).into_owned()) } +/// Bash's major version (`${BASH_VERSINFO[0]}`), or `None` if bash isn't installed at all -- +/// mirroring `run_bash`'s "shell missing" skip convention for callers that also need to skip on +/// an installed-but-too-old bash (see `bash_ctrl_t_detection_snippet`'s callers below). +fn bash_major_version() -> Option { + let output = command::blocking::Command::new("bash") + .args([ + "--noprofile", + "--norc", + "-c", + "echo \"${BASH_VERSINFO[0]}\"", + ]) + .output() + .ok()?; + String::from_utf8_lossy(&output.stdout).trim().parse().ok() +} + +/// `bind -X` (list `-x` bindings), which this detection depends on entirely, doesn't exist in +/// bash's readline before bash 4.0 -- it errors out silently here (stderr is redirected away), +/// leaving detection permanently empty. That's a real, accepted limitation of the feature itself +/// on bash < 4 (notably macOS's system bash 3.2; see the PR's "Known limitations"), not something +/// a test workaround should paper over: on such a bash, both of the tests below would either fail +/// (the "tags" case) or pass vacuously without exercising the absent-function branch at all (the +/// "declines" case just happens to expect the same empty result `bind -X`'s absence always +/// produces). Skip both rather than let the latter masquerade as real coverage. +fn bash_supports_bind_dash_capital_x() -> Option { + Some(bash_major_version()? >= 4) +} + /// Regression test for the ctrl-t equivalent of bash's `declare -F __atuin_history` guard on the /// ctrl-r path: detection must decline (no tag, no interception) when the picker function /// `warp_run_external_ctrl_t_widget` calls -- `__fzf_select__` -- isn't actually defined, even @@ -219,6 +247,9 @@ fn run_bash(script: &str) -> Option { /// and intercepted with nothing to invoke, swallowing the key instead of leaving it alone. #[test] fn test_bash_ctrl_t_detection_declines_when_picker_function_is_absent() { + if bash_supports_bind_dash_capital_x() == Some(false) { + return; + } let detection = bash_ctrl_t_detection_snippet(); let script = format!( r#" @@ -238,6 +269,9 @@ printf 'widget=[%s] plugins=[%s]\n' "$_WARP_EXTERNAL_CTRL_T_WIDGET" "${{shell_pl #[test] fn test_bash_ctrl_t_detection_tags_when_picker_function_is_present() { + if bash_supports_bind_dash_capital_x() == Some(false) { + return; + } let detection = bash_ctrl_t_detection_snippet(); let script = format!( r#" From 2a277f3134443e77757401219b33336ff0243b83 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" Date: Wed, 26 Aug 2026 13:00:03 +0000 Subject: [PATCH 35/70] Gate the bash ctrl-t detection tests on bash 4.3, not 4.0 bind -X was added in bash 4.3 (NEWS-4.3 item q), not 4.0, so gating on the major version alone let bash 4.0-4.2 through: there the tests would fail, and the declines case would have passed vacuously. Read the minor version too. --- crates/warp_terminal/src/bootstrap_tests.rs | 39 ++++++++++++--------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/crates/warp_terminal/src/bootstrap_tests.rs b/crates/warp_terminal/src/bootstrap_tests.rs index 86424901b4a..e19aa1516b7 100644 --- a/crates/warp_terminal/src/bootstrap_tests.rs +++ b/crates/warp_terminal/src/bootstrap_tests.rs @@ -211,32 +211,37 @@ fn run_bash(script: &str) -> Option { Some(String::from_utf8_lossy(&output.stdout).into_owned()) } -/// Bash's major version (`${BASH_VERSINFO[0]}`), or `None` if bash isn't installed at all -- -/// mirroring `run_bash`'s "shell missing" skip convention for callers that also need to skip on -/// an installed-but-too-old bash (see `bash_ctrl_t_detection_snippet`'s callers below). -fn bash_major_version() -> Option { +/// Bash's `(major, minor)` version, or `None` if bash isn't installed at all -- mirroring +/// `run_bash`'s "shell missing" skip convention for callers that also need to skip on an +/// installed-but-too-old bash. The minor version matters here, not just the major: see +/// `bash_supports_bind_dash_capital_x` below. +fn bash_version() -> Option<(u32, u32)> { let output = command::blocking::Command::new("bash") .args([ "--noprofile", "--norc", "-c", - "echo \"${BASH_VERSINFO[0]}\"", + "echo \"${BASH_VERSINFO[0]} ${BASH_VERSINFO[1]}\"", ]) .output() .ok()?; - String::from_utf8_lossy(&output.stdout).trim().parse().ok() -} - -/// `bind -X` (list `-x` bindings), which this detection depends on entirely, doesn't exist in -/// bash's readline before bash 4.0 -- it errors out silently here (stderr is redirected away), -/// leaving detection permanently empty. That's a real, accepted limitation of the feature itself -/// on bash < 4 (notably macOS's system bash 3.2; see the PR's "Known limitations"), not something -/// a test workaround should paper over: on such a bash, both of the tests below would either fail -/// (the "tags" case) or pass vacuously without exercising the absent-function branch at all (the -/// "declines" case just happens to expect the same empty result `bind -X`'s absence always -/// produces). Skip both rather than let the latter masquerade as real coverage. + let stdout = String::from_utf8_lossy(&output.stdout); + let mut fields = stdout.split_whitespace(); + let major = fields.next()?.parse().ok()?; + let minor = fields.next()?.parse().ok()?; + Some((major, minor)) +} + +/// `bind -X` (list `-x` bindings), which this detection depends on entirely, was added in bash +/// 4.3 (NEWS-4.3 item q) -- on anything older it errors out silently here (stderr is redirected +/// away), leaving detection permanently empty. That's a real, accepted limitation of the feature +/// itself on those versions (notably macOS's system bash 3.2; see the PR's "Known limitations"), +/// not something a test workaround should paper over: on such a bash, both of the tests below +/// would either fail (the "tags" case) or pass vacuously without exercising the absent-function +/// branch at all (the "declines" case just happens to expect the same empty result `bind -X`'s +/// absence always produces). Skip both rather than let the latter masquerade as real coverage. fn bash_supports_bind_dash_capital_x() -> Option { - Some(bash_major_version()? >= 4) + Some(bash_version()? >= (4, 3)) } /// Regression test for the ctrl-t equivalent of bash's `declare -F __atuin_history` guard on the From a028e979f8b780294b31df49fb54987b68503ba7 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" Date: Wed, 26 Aug 2026 13:29:15 +0000 Subject: [PATCH 36/70] Gate the bash ctrl-t detection tests on a real bind -x round-trip probe Gating on the bash version was the wrong premise. The macOS and Windows CI runners have a bash new enough for bind -X, so the version gate never fired there, and the tests kept failing: in a non-interactive shell line editing need not be enabled, so a bind -x binding is never listable no matter how new bash is. Probe the round-trip the detection actually depends on instead, which covers the too-old-bash case and this one identically. --- crates/warp_terminal/src/bootstrap_tests.rs | 52 ++++++++++----------- 1 file changed, 25 insertions(+), 27 deletions(-) diff --git a/crates/warp_terminal/src/bootstrap_tests.rs b/crates/warp_terminal/src/bootstrap_tests.rs index e19aa1516b7..28704a300a9 100644 --- a/crates/warp_terminal/src/bootstrap_tests.rs +++ b/crates/warp_terminal/src/bootstrap_tests.rs @@ -211,37 +211,35 @@ fn run_bash(script: &str) -> Option { Some(String::from_utf8_lossy(&output.stdout).into_owned()) } -/// Bash's `(major, minor)` version, or `None` if bash isn't installed at all -- mirroring -/// `run_bash`'s "shell missing" skip convention for callers that also need to skip on an -/// installed-but-too-old bash. The minor version matters here, not just the major: see -/// `bash_supports_bind_dash_capital_x` below. -fn bash_version() -> Option<(u32, u32)> { - let output = command::blocking::Command::new("bash") +/// Whether this environment's bash can round-trip a `bind -x` binding back out through `bind -X`, +/// invoked exactly as the tests below invoke it. `None` if bash isn't installed at all, mirroring +/// `run_bash`'s "shell missing" skip convention. +/// +/// The detection under test depends on that round-trip entirely, and it does not hold everywhere: +/// `bind -X` only arrived in bash 4.3 (NEWS-4.3 item q), and even where it exists a +/// non-interactive shell need not have line editing enabled, in which case the binding is never +/// listable. Both show up identically here -- an empty listing -- so this probes the capability +/// rather than inferring it from a version, which would miss the second case entirely. +/// +/// Where the round-trip fails, the two tests below would either fail (the "tags" case) or pass +/// vacuously without exercising the absent-function branch at all (the "declines" case just +/// happens to expect the same empty result an unusable `bind -X` always produces). Skip both +/// rather than let the latter masquerade as real coverage. +fn bash_can_round_trip_bind_dash_x() -> Option { + let output = match command::blocking::Command::new("bash") .args([ "--noprofile", "--norc", "-c", - "echo \"${BASH_VERSINFO[0]} ${BASH_VERSINFO[1]}\"", + "bind -x '\"\\C-t\": warp_bind_x_probe' 2>/dev/null; bind -X 2>/dev/null", ]) .output() - .ok()?; - let stdout = String::from_utf8_lossy(&output.stdout); - let mut fields = stdout.split_whitespace(); - let major = fields.next()?.parse().ok()?; - let minor = fields.next()?.parse().ok()?; - Some((major, minor)) -} - -/// `bind -X` (list `-x` bindings), which this detection depends on entirely, was added in bash -/// 4.3 (NEWS-4.3 item q) -- on anything older it errors out silently here (stderr is redirected -/// away), leaving detection permanently empty. That's a real, accepted limitation of the feature -/// itself on those versions (notably macOS's system bash 3.2; see the PR's "Known limitations"), -/// not something a test workaround should paper over: on such a bash, both of the tests below -/// would either fail (the "tags" case) or pass vacuously without exercising the absent-function -/// branch at all (the "declines" case just happens to expect the same empty result `bind -X`'s -/// absence always produces). Skip both rather than let the latter masquerade as real coverage. -fn bash_supports_bind_dash_capital_x() -> Option { - Some(bash_version()? >= (4, 3)) + { + Ok(output) => output, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return None, + Err(error) => panic!("failed to run bash: {error}"), + }; + Some(String::from_utf8_lossy(&output.stdout).contains("warp_bind_x_probe")) } /// Regression test for the ctrl-t equivalent of bash's `declare -F __atuin_history` guard on the @@ -252,7 +250,7 @@ fn bash_supports_bind_dash_capital_x() -> Option { /// and intercepted with nothing to invoke, swallowing the key instead of leaving it alone. #[test] fn test_bash_ctrl_t_detection_declines_when_picker_function_is_absent() { - if bash_supports_bind_dash_capital_x() == Some(false) { + if bash_can_round_trip_bind_dash_x() == Some(false) { return; } let detection = bash_ctrl_t_detection_snippet(); @@ -274,7 +272,7 @@ printf 'widget=[%s] plugins=[%s]\n' "$_WARP_EXTERNAL_CTRL_T_WIDGET" "${{shell_pl #[test] fn test_bash_ctrl_t_detection_tags_when_picker_function_is_present() { - if bash_supports_bind_dash_capital_x() == Some(false) { + if bash_can_round_trip_bind_dash_x() == Some(false) { return; } let detection = bash_ctrl_t_detection_snippet(); From 6eeb3dfa346f614b79fbb74ee6431ffe01f4dc61 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" Date: Wed, 26 Aug 2026 13:57:56 +0000 Subject: [PATCH 37/70] Probe the exact bind -X extraction pipeline, not just a raw listing The raw-listing probe was a false positive on macOS: bind -X does list the binding there, so the gate passed and the test still failed. Detection also depends on the extraction, and command -p forces the system utility PATH, so that sed runs as BSD sed on macOS rather than GNU sed. Probe the whole pipeline, and assert it still matches bash_body.sh so the two cannot drift. Probes with a sentinel widget name rather than fzf-file-widget, so the gate does not subsume the case match the tests exist to assert. --- crates/warp_terminal/src/bootstrap_tests.rs | 58 +++++++++++++-------- 1 file changed, 36 insertions(+), 22 deletions(-) diff --git a/crates/warp_terminal/src/bootstrap_tests.rs b/crates/warp_terminal/src/bootstrap_tests.rs index 28704a300a9..a8ab8ac89c3 100644 --- a/crates/warp_terminal/src/bootstrap_tests.rs +++ b/crates/warp_terminal/src/bootstrap_tests.rs @@ -211,35 +211,49 @@ fn run_bash(script: &str) -> Option { Some(String::from_utf8_lossy(&output.stdout).into_owned()) } -/// Whether this environment's bash can round-trip a `bind -x` binding back out through `bind -X`, -/// invoked exactly as the tests below invoke it. `None` if bash isn't installed at all, mirroring -/// `run_bash`'s "shell missing" skip convention. +/// The `bind -X` extraction pipeline the ctrl-t detection is built on, mirrored from +/// `bash_body.sh` so the gate below can exercise the same capability the detection needs. +/// `bash_can_extract_ctrl_t_binding` asserts the snippet still contains this, so the two cannot +/// drift apart silently. +const BIND_DASH_X_EXTRACTION: &str = + r#"bind -X 2>/dev/null | command -p sed -n 's/^"\\C-t": "\(.*\)"$/\1/p'"#; + +/// Whether this environment can read a `bind -x` binding back out through the pipeline above, run +/// the way the tests below run it. `None` if bash isn't installed at all, mirroring `run_bash`'s +/// "shell missing" skip convention. /// -/// The detection under test depends on that round-trip entirely, and it does not hold everywhere: -/// `bind -X` only arrived in bash 4.3 (NEWS-4.3 item q), and even where it exists a -/// non-interactive shell need not have line editing enabled, in which case the binding is never -/// listable. Both show up identically here -- an empty listing -- so this probes the capability -/// rather than inferring it from a version, which would miss the second case entirely. +/// Detection depends on that end-to-end, and it does not hold everywhere. Three separate things +/// can break it, all presenting identically as empty output: `bind -X` only arrived in bash 4.3 +/// (NEWS-4.3 item q); a non-interactive shell need not have line editing enabled, so the binding +/// is never listable however new bash is; and `command -p` forces the system utility PATH, so the +/// extraction runs under BSD sed on macOS rather than GNU sed. Probing the pipeline covers all +/// three, where checking a version covers only the first and checking a raw listing only the +/// first two. /// -/// Where the round-trip fails, the two tests below would either fail (the "tags" case) or pass -/// vacuously without exercising the absent-function branch at all (the "declines" case just -/// happens to expect the same empty result an unusable `bind -X` always produces). Skip both -/// rather than let the latter masquerade as real coverage. -fn bash_can_round_trip_bind_dash_x() -> Option { +/// Deliberately probes with a sentinel widget name rather than `fzf-file-widget`, so it tests the +/// capability without also asserting the `case` match the tests below exist to check -- otherwise +/// the gate would subsume the assertion and the tests could never fail. +/// +/// Where extraction fails, those tests would either fail (the "tags" case) or pass vacuously +/// without exercising the absent-function branch at all (the "declines" case just happens to +/// expect the same empty result unusable extraction always produces). Skip both rather than let +/// the latter masquerade as real coverage. +fn bash_can_extract_ctrl_t_binding() -> Option { + assert!( + bash_ctrl_t_detection_snippet().contains(BIND_DASH_X_EXTRACTION), + "BIND_DASH_X_EXTRACTION no longer matches bash_body.sh's detection pipeline" + ); + let script = + format!("bind -x '\"\\C-t\": warp_bind_x_probe' 2>/dev/null; {BIND_DASH_X_EXTRACTION}"); let output = match command::blocking::Command::new("bash") - .args([ - "--noprofile", - "--norc", - "-c", - "bind -x '\"\\C-t\": warp_bind_x_probe' 2>/dev/null; bind -X 2>/dev/null", - ]) + .args(["--noprofile", "--norc", "-c", &script]) .output() { Ok(output) => output, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return None, Err(error) => panic!("failed to run bash: {error}"), }; - Some(String::from_utf8_lossy(&output.stdout).contains("warp_bind_x_probe")) + Some(String::from_utf8_lossy(&output.stdout).trim() == "warp_bind_x_probe") } /// Regression test for the ctrl-t equivalent of bash's `declare -F __atuin_history` guard on the @@ -250,7 +264,7 @@ fn bash_can_round_trip_bind_dash_x() -> Option { /// and intercepted with nothing to invoke, swallowing the key instead of leaving it alone. #[test] fn test_bash_ctrl_t_detection_declines_when_picker_function_is_absent() { - if bash_can_round_trip_bind_dash_x() == Some(false) { + if bash_can_extract_ctrl_t_binding() == Some(false) { return; } let detection = bash_ctrl_t_detection_snippet(); @@ -272,7 +286,7 @@ printf 'widget=[%s] plugins=[%s]\n' "$_WARP_EXTERNAL_CTRL_T_WIDGET" "${{shell_pl #[test] fn test_bash_ctrl_t_detection_tags_when_picker_function_is_present() { - if bash_can_round_trip_bind_dash_x() == Some(false) { + if bash_can_extract_ctrl_t_binding() == Some(false) { return; } let detection = bash_ctrl_t_detection_snippet(); From 3451b9157074bb4df6011257f405e3bfa24e119f Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:22:48 +0000 Subject: [PATCH 38/70] Replace ctrl-t draft temp file with a hex-encoded argument (CORE-3807) Fish's ctrl-t handoff previously wrote the in-progress draft line and cursor to a 0600 temp file at $XDG_RUNTIME_DIR/warp-ctrl-t- (falling back to /tmp), which the fish helper read, seeded fzf's widget with, and deleted. Replace the file with a single `{char_cursor}:{hex_draft}` argument appended to the helper invocation in Replace mode: - No on-disk artifact: a temp file can outlive a crash; an argv cannot. - Hex keeps the draft a single token -- no whitespace, no newlines, no quoting -- eliminating the multiline-flattening and trailing-newline bugs the file protocol produced. - Cursor and draft are combined into one argument (not two) because the invocation is typed into the terminal as literal text for the shell to parse; a separate, empty hex field (ctrl-t on a blank line) would vanish under the shell's own word-splitting. Fish decodes with a new warp_hex_decode_string (the decode counterpart to the existing warp_hex_encode_string), piped through `string collect --no-trim-newlines --allow-empty`. --no-trim-newlines preserves a trailing newline in the draft; --allow-empty preserves an empty draft as a single empty-string element rather than collapsing to zero elements, which otherwise makes `commandline -r --` receive no CMD argument at all -- and a no-CMD `commandline -r` is a *read*, not a write, so ctrl-t on a blank prompt (an entirely ordinary case) would leave the synthetic helper invocation itself on the commandline instead of seeding a blank buffer. Also fixes a cancel-cursor-restore bug found during live verification in a real fish + fzf tab: the cancel-detection comparison read `commandline` as a nested command substitution passed directly into the comparison call, immediately after fzf-file-widget's own `commandline -f repaint` on cancel. That can race the repaint and read back a stale value, so the comparison sometimes saw a false change on an untouched cancel and skipped the cursor restore. Fixed by capturing the readback into a local variable first, as its own statement, which live-testing confirms reliably fixes it. This exact pattern predates this change, but since this commit already touches the function, it isn't shipped known-broken. Deleted the Rust-side draft-file functions and their tests (including the writer-failure seam that existed only to test the file's cleanup path), and the fish-side draft-file tests. Added a dedicated argument-parsing test that decodes char_cursor/original_line directly, since the existing full-widget tests can't by themselves catch a corrupted split or decode (the same corrupted value feeds both sides of the widget's own equality check and cancels out); a single-line unchanged-draft cancel test; an empty-draft test whose `commandline` mock distinguishes a real fish read (no CMD argument) from a write, with a non-empty initial sentinel so a failed seed is distinguishable from a correctly-seeded empty draft; and fixed the widget test's `commandline` mock to match the real builtin's newline-terminated read behavior. --- app/assets/bundled/bootstrap/fish.sh | 59 ++--- app/src/terminal/input.rs | 114 ++-------- app/src/terminal/input_tests.rs | 77 ++----- crates/warp_terminal/src/bootstrap_tests.rs | 231 ++++++++++---------- 4 files changed, 180 insertions(+), 301 deletions(-) diff --git a/app/assets/bundled/bootstrap/fish.sh b/app/assets/bundled/bootstrap/fish.sh index e2b07fe994e..4dabcc6fb54 100644 --- a/app/assets/bundled/bootstrap/fish.sh +++ b/app/assets/bundled/bootstrap/fish.sh @@ -68,6 +68,11 @@ function warp_hex_encode_string printf '%s' "$argv" | od -An -v -tx1 | command tr -d ' \n' end +# warp_hex_decode_string decodes a string hex-encoded by warp_hex_encode_string. +function warp_hex_decode_string + printf '%b' (string replace --all --regex '(..)' '\\\\x$1' -- "$argv") +end + # A list of PIDs for running in-band command(s). This is used to kill running # in-band commands in preexec for a user command, so they do not interfere with # user command output. @@ -602,16 +607,6 @@ function warp_run_external_ctrl_r_widget warp_send_json_message "{ \"hook\": \"ExternalCtrlRSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"token\": \"$warp_escaped_token\", \"session_id\": $WARP_SESSION_ID } }" end -# Locates the draft handoff file Warp writes just before typing this helper's invocation into the -# terminal (see Input::write_ctrl_t_draft_file), identified by the handoff token given as -# $argv[1]. $XDG_RUNTIME_DIR must mirror the fallback Warp's own write uses, or this looks in the -# wrong place for a file Warp actually wrote elsewhere. -function warp_ctrl_t_draft_file_path - set -l dir "$XDG_RUNTIME_DIR" - test -n "$dir"; or set dir /tmp - echo "$dir/warp-ctrl-t-$argv[1]" -end - function warp_ctrl_t_widget_result test "$argv[1]" = "$argv[2]"; or string collect -- "$argv[2]" end @@ -628,6 +623,12 @@ end # is token-aware (see fzf's own __fzf_parse_commandline) and reproducing that parsing by hand # would either drop it or duplicate it badly. # +# $argv[2] carries the real in-progress draft and cursor Warp seeds the widget with (see +# Input::trigger_external_ctrl_t_file_search), as a single `{char_cursor}:{hex_draft}` token: hex +# keeps the draft a single token, and combining it with the cursor avoids an empty hex field (an +# empty draft) vanishing under the shell's own word-splitting when this invocation is typed into +# the terminal as literal text for the shell to parse. +# # fzf-file-widget has no way to report cancellation distinctly from a selection: on Escape it # leaves the commandline exactly as seeded, so its output is indistinguishable from a selection # that reproduces the original line. Collapse that case to an empty result, Warp's existing @@ -639,29 +640,33 @@ function warp_run_external_ctrl_t_widget set -l result "" switch "$_WARP_EXTERNAL_CTRL_T_WIDGET" case 'fzf-file-widget' - set -l draft_file (warp_ctrl_t_draft_file_path "$warp_ctrl_t_token") - set -l original_line '' - set -l char_cursor 0 - if test -f "$draft_file" - # NUL-delimited (see Input::write_ctrl_t_draft_file), not newline-delimited: a plain - # command substitution unconditionally strips trailing newline bytes from what it - # captures before any splitting happens, which would silently lose a trailing newline - # that's genuinely part of the draft. `string split0` splits on NUL only, so - # $draft_fields[2] is the draft verbatim -- embedded and trailing newlines included -- - # with no further reconstruction needed. - set -l draft_fields (command cat -- "$draft_file" | string split0) - set char_cursor $draft_fields[1] - set original_line $draft_fields[2] - end + set -l warp_ctrl_t_parts (string split -m 1 -- ':' "$argv[2]") + set -l char_cursor $warp_ctrl_t_parts[1] + # --allow-empty: an empty draft (ctrl-t on a blank line) decodes to zero bytes, and + # `string collect` would otherwise collapse that to zero list elements rather than one + # empty string -- `commandline -r --` with no CMD argument at all is a *read*, not a + # write, so ctrl-t on a blank line would leave the synthetic helper invocation itself on + # the commandline instead of seeding a blank buffer. + set -l original_line (warp_hex_decode_string $warp_ctrl_t_parts[2] | string collect --no-trim-newlines --allow-empty) commandline -r -- $original_line commandline -C -- $char_cursor fzf-file-widget # (commandline | string collect), not plain (commandline): unquoted, a multi-line result - # would otherwise expand to multiple arguments here, silently truncating + # would otherwise expand to multiple arguments here, truncating # warp_ctrl_t_widget_result's $argv[2] comparison and return value to its first line alone. - set result (warp_ctrl_t_widget_result "$original_line" (commandline | string collect)) + # Plain `string collect` (not --no-trim-newlines) here: a bare `commandline` read always + # ends its own output in a line terminator regardless of the buffer's actual content, so + # trimming it is what recovers the real buffer text -- keeping it would make an unchanged + # draft compare unequal to itself, misreporting a plain cancel as a real selection. + # + # Captured into $cl_readback first, not passed as a nested command substitution directly: + # confirmed live that reading `commandline` in that nested form immediately after + # fzf-file-widget's own `commandline -f repaint` on cancel can race that repaint and read + # back a stale value, so the comparison below sometimes saw a false change on an untouched + # cancel. Assigning it first, as its own statement, reliably reads the settled buffer. + set -l cl_readback (commandline | string collect) + set result (warp_ctrl_t_widget_result "$original_line" "$cl_readback") commandline -r '' - rm -f "$draft_file" end set -l warp_escaped_selection (warp_escape_json "$result") set -l warp_escaped_token (warp_escape_json "$warp_ctrl_t_token") diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index 3ce12c80a30..41357eb433b 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -1889,87 +1889,17 @@ impl PendingCtrlTHandoff { } } -/// Directory the ctrl-t draft handoff file (see [`write_ctrl_t_draft_file`]) is written into: -/// `$XDG_RUNTIME_DIR` when set, since it's the per-user, non-persistent directory most Linux -/// distros provide; `/tmp` otherwise. Must match the fallback the fish helper computes for itself -/// from its own environment -- see `warp_run_external_ctrl_t_widget` in `fish.sh`. -fn ctrl_t_draft_file_dir() -> PathBuf { - std::env::var_os("XDG_RUNTIME_DIR") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("/tmp")) -} - -/// Path of the ctrl-t draft handoff file for `token` (see [`write_ctrl_t_draft_file`]). -fn ctrl_t_draft_file_path(token: &str) -> PathBuf { - ctrl_t_draft_file_dir().join(format!("warp-ctrl-t-{token}")) -} - -/// Writes the draft line and cursor the fish ctrl-t helper seeds `fzf-file-widget` with (see -/// [`CtrlTApplyMode::Replace`]), so its token-aware replacement operates on the real in-progress -/// command rather than an empty line. Bash/zsh never call this: their helper searches -/// independently of the draft and reports a plain path for Warp to splice in itself. -/// -/// Created with owner-only (0600) permissions from the moment the file exists -- this may contain -/// in-progress command text the user hasn't run yet, and creating the file before restricting its -/// permissions would leave a window where another local user could read it. The format is -/// `{char_cursor}\0{original_buffer}\0` -- NUL-delimited, not newline-delimited, because the fish -/// reader captures the file through a plain command substitution, which unconditionally strips -/// trailing newline bytes from what it captures before any splitting happens; a newline-delimited -/// format would silently lose a trailing newline that's genuinely part of the draft. A shell -/// command buffer cannot itself contain a NUL byte, so it's an unambiguous delimiter fish can -/// split back out with `string split0` unaffected by that stripping. `char_cursor` is -/// `cursor_offset` converted to a character offset, since fish's `commandline -C` takes -/// characters while `cursor_offset` is a byte offset. -fn write_ctrl_t_draft_file( - token: &str, - original_buffer: &str, - cursor_offset: ByteOffset, -) -> anyhow::Result<()> { - use std::io::Write as _; - - write_ctrl_t_draft_file_with_writer( - token, - original_buffer, - cursor_offset, - |file, char_cursor, original_buffer| { - write!(file, "{char_cursor}\0")?; - file.write_all(original_buffer.as_bytes())?; - file.write_all(b"\0") - }, - ) -} - -/// Implementation of [`write_ctrl_t_draft_file`], taking the write step as a parameter so tests -/// can inject a failure partway through without needing a real filesystem-level write failure. -/// -/// Cleans up the file it created if `write` fails: at that point the file already exists and may -/// hold a partially-written in-progress command line, which leaving behind would defeat the -/// owner-only permissions below just as thoroughly as never cleaning it up on success. -fn write_ctrl_t_draft_file_with_writer( - token: &str, - original_buffer: &str, - cursor_offset: ByteOffset, - write: impl FnOnce(&mut std::fs::File, usize, &str) -> std::io::Result<()>, -) -> anyhow::Result<()> { - #[cfg(unix)] - use std::os::unix::fs::OpenOptionsExt as _; - - use anyhow::Context as _; - +/// Encodes the draft line and cursor the fish ctrl-t helper seeds `fzf-file-widget` with (see +/// [`CtrlTApplyMode::Replace`]) as a single `{char_cursor}:{hex_draft}` argument: hex keeps the +/// draft a single token, and combining it with the cursor avoids an empty hex field (an empty +/// draft) vanishing under the shell's own word-splitting, since the invocation is typed into the +/// terminal as literal text for the shell to parse. `char_cursor` is `cursor_offset` converted to +/// a character offset, since fish's `commandline -C` takes characters while `cursor_offset` is a +/// byte offset. Bash/zsh never call this: their helper searches independently of the draft and +/// reports a plain path for Warp to splice in itself. +fn ctrl_t_draft_arg(original_buffer: &str, cursor_offset: ByteOffset) -> String { let char_cursor = original_buffer[..cursor_offset.as_usize()].chars().count(); - let path = ctrl_t_draft_file_path(token); - let mut options = std::fs::OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - options.mode(0o600); - let mut file = options - .open(&path) - .with_context(|| format!("failed to create {}", path.display()))?; - if let Err(error) = write(&mut file, char_cursor, original_buffer) { - let _ = std::fs::remove_file(&path); - return Err(error).with_context(|| format!("failed to write {}", path.display())); - } - Ok(()) + format!("{char_cursor}:{}", hex::encode(original_buffer)) } struct AmbientAgentViewState { @@ -7777,11 +7707,10 @@ impl Input { /// See [`Self::trigger_external_ctrl_r_history_search`] for why the command is prefixed with /// a leading space. /// - /// When `apply_mode` is [`CtrlTApplyMode::Replace`], also writes the draft handoff file (see - /// [`write_ctrl_t_draft_file`]) the fish helper reads to seed its widget with the real draft - /// line and cursor; a failure to write it aborts the trigger entirely; bash/zsh never need - /// this file, since their helper searches independently of the draft and reports a plain path - /// for Warp to splice in itself. + /// When `apply_mode` is [`CtrlTApplyMode::Replace`], the command is also given the draft line + /// and cursor for the fish helper to seed its widget with (see [`ctrl_t_draft_arg`]); + /// bash/zsh never need this, since their helper searches independently of the draft and + /// reports a plain path for Warp to splice in itself. pub fn trigger_external_ctrl_t_file_search( &mut self, helper_command: &str, @@ -7798,13 +7727,13 @@ impl Input { .end_byte_index_of_last_selection(ctx); let block_id = self.model.lock().block_list().active_block_id().clone(); let token = Uuid::new_v4().to_string(); - if apply_mode == CtrlTApplyMode::Replace - && let Err(error) = write_ctrl_t_draft_file(&token, &original_buffer, cursor_offset) - { - report_error!(error.context("failed to write ctrl-t draft handoff file")); - return false; + let mut command = format!(" {helper_command} {token}"); + if apply_mode == CtrlTApplyMode::Replace { + command.push_str(&format!( + " {}", + ctrl_t_draft_arg(&original_buffer, cursor_offset) + )); } - let command = format!(" {helper_command} {token}"); let started = self.try_execute_command_from_source(&command, CommandExecutionSource::User, ctx); if started { @@ -7817,9 +7746,6 @@ impl Input { block_id, apply_mode, }); - } else if apply_mode == CtrlTApplyMode::Replace { - // The helper never ran, so it will never clean up the draft file it would have read. - let _ = std::fs::remove_file(ctrl_t_draft_file_path(&token)); } started } diff --git a/app/src/terminal/input_tests.rs b/app/src/terminal/input_tests.rs index 760e833aaf8..64aa6ad13d0 100644 --- a/app/src/terminal/input_tests.rs +++ b/app/src/terminal/input_tests.rs @@ -2321,75 +2321,34 @@ fn ctrl_t_apply_mode_forks_between_splice_and_replace_for_the_same_draft() { /// character, for 6 bytes and 5 characters total) must have that byte offset converted, not /// copied verbatim, or the widget would seed itself at the wrong position for any non-ASCII draft. #[test] -fn write_ctrl_t_draft_file_converts_byte_cursor_to_char_cursor_for_multi_byte_draft() { +fn ctrl_t_draft_arg_converts_byte_cursor_to_char_cursor_for_multi_byte_draft() { let original_buffer = "caf\u{e9} ls"; // Byte offset right after "café " (the \u{e9} is 2 bytes), which is character offset 5. let cursor_offset = ByteOffset::from("caf\u{e9} ".len()); - let token = format!("test-{}", Uuid::new_v4()); - write_ctrl_t_draft_file(&token, original_buffer, cursor_offset) - .expect("draft file should be writable in a test environment"); - let path = ctrl_t_draft_file_path(&token); - let contents = std::fs::read_to_string(&path).expect("draft file should have been written"); - std::fs::remove_file(&path).ok(); - - let (cursor_field, rest) = contents - .split_once('\0') - .expect("the draft file must be NUL-delimited"); + let arg = ctrl_t_draft_arg(original_buffer, cursor_offset); + + let (char_cursor, hex_draft) = arg + .split_once(':') + .expect("the arg must be colon-delimited"); assert_eq!( - cursor_field, "5", - "the cursor must be written as a character offset, not the byte offset" + char_cursor, "5", + "the cursor must be encoded as a character offset, not the byte offset" ); assert_eq!( - rest.strip_suffix('\0'), - Some(original_buffer), - "the draft must be written verbatim, NUL-terminated" + hex::decode(hex_draft).expect("the draft must be valid hex"), + original_buffer.as_bytes(), + "the draft must be hex-encoded verbatim" ); } -/// The draft file may hold an in-progress command line the user hasn't run yet, so it must never -/// be readable by another local user, even for the instant between creation and its first write. -#[cfg(unix)] +/// An empty draft (ctrl-t on a blank line) is the ordinary, not edge, case: the hex field for it +/// is empty, so the cursor and draft must stay combined into one argument rather than passed +/// separately, or the empty field would vanish under the shell's own word-splitting once the +/// invocation is typed into the terminal as literal text. #[test] -fn write_ctrl_t_draft_file_is_created_with_owner_only_permissions() { - use std::os::unix::fs::PermissionsExt as _; - - let token = format!("test-{}", Uuid::new_v4()); - write_ctrl_t_draft_file(&token, "echo hi", ByteOffset::from(7)) - .expect("draft file should be writable in a test environment"); - let path = ctrl_t_draft_file_path(&token); - let mode = std::fs::metadata(&path) - .expect("draft file should have been written") - .permissions() - .mode(); - std::fs::remove_file(&path).ok(); - - assert_eq!( - mode & 0o777, - 0o600, - "the draft file must be owner-only (0600) from the moment it exists" - ); -} - -/// The draft file may hold a partially-written in-progress command line if the write fails after -/// creation; leaving it behind would defeat the owner-only permissions above just as thoroughly as -/// never cleaning it up on success. -#[test] -fn write_ctrl_t_draft_file_removes_the_file_when_the_write_fails() { - let token = format!("test-{}", Uuid::new_v4()); - let result = write_ctrl_t_draft_file_with_writer( - &token, - "echo hi", - ByteOffset::from(7), - |_file, _char_cursor, _original_buffer| { - Err(std::io::Error::other("simulated write failure")) - }, - ); - - assert!(result.is_err(), "a write failure must be propagated"); - assert!( - !ctrl_t_draft_file_path(&token).exists(), - "the draft file must not be left behind when the write fails" - ); +fn ctrl_t_draft_arg_keeps_empty_draft_and_cursor_as_one_token() { + let arg = ctrl_t_draft_arg("", ByteOffset::from(0)); + assert_eq!(arg, "0:"); } /// While `ShellWidgetHandoff` is disabled (its default state, matching a user who hasn't opted diff --git a/crates/warp_terminal/src/bootstrap_tests.rs b/crates/warp_terminal/src/bootstrap_tests.rs index a8ab8ac89c3..f8a6620a89a 100644 --- a/crates/warp_terminal/src/bootstrap_tests.rs +++ b/crates/warp_terminal/src/bootstrap_tests.rs @@ -513,97 +513,76 @@ printf 'result=[%s]\n' "$result" ); } -fn fish_ctrl_t_draft_decode_snippet() -> &'static str { +/// Hex-encodes `s` the way [`ctrl_t_draft_arg`] does, for building test `{char_cursor}:{hex}` +/// arguments without depending on fish's own `warp_hex_encode_string`. +fn hex_encode(s: &str) -> String { + s.bytes().map(|b| format!("{b:02x}")).collect() +} + +fn fish_hex_decode_string_fn() -> &'static str { + const FISH_SH: &str = include_str!("../../../app/assets/bundled/bootstrap/fish.sh"); + let start_marker = "function warp_hex_decode_string\n"; + let start = FISH_SH + .find(start_marker) + .expect("fish hex decode function start should exist"); + let end_marker = "\nend\n"; + let end = FISH_SH[start..] + .find(end_marker) + .expect("fish hex decode function end should exist"); + &FISH_SH[start..start + end + end_marker.len()] +} + +/// The `string split` + `warp_hex_decode_string` argument-parsing step inside +/// `warp_run_external_ctrl_t_widget`, extracted on its own (not via the full widget runner) so a +/// dedicated test can assert the decoded `char_cursor`/`original_line` directly. The two +/// full-widget tests below can't catch a corrupted split or decode by themselves: the same +/// corrupted value seeds both sides of `warp_ctrl_t_widget_result`'s equality check and cancels +/// out. +fn fish_ctrl_t_argument_parsing_snippet() -> &'static str { const FISH_SH: &str = include_str!("../../../app/assets/bundled/bootstrap/fish.sh"); // Structural, not literal-text, boundaries (see `fish_ctrl_t_widget_result_fn` above) so a - // behavioral change to the reconstruction logic changes what the test observes. - let start_marker = "if test -f \"$draft_file\"\n"; + // behavioral change to the parsing logic itself changes what the test observes. + let start_marker = "set -l warp_ctrl_t_parts (string split -m 1 -- ':' \"$argv[2]\")\n"; let start = FISH_SH .find(start_marker) - .expect("fish ctrl-t draft decode snippet start should exist"); - let end_marker = "\n end\n"; + .expect("fish ctrl-t argument parsing snippet start should exist"); + let end_marker = "--allow-empty)\n"; let end = FISH_SH[start..] .find(end_marker) - .expect("fish ctrl-t draft decode snippet end should exist"); + .expect("fish ctrl-t argument parsing snippet end should exist"); &FISH_SH[start..start + end + end_marker.len()] } -/// Writes a NUL-delimited draft file matching [`Input::write_ctrl_t_draft_file`]'s format -/// (`{char_cursor}\0{draft}\0`) directly, rather than going through the Rust writer, so these -/// fish-side decode tests exercise exactly the bytes fish reads without depending on the writer -/// under a separate set of tests. -fn write_decode_test_draft_file(char_cursor: u32, draft: &str) -> std::path::PathBuf { - let draft_file = - std::env::temp_dir().join(format!("warp-ctrl-t-decode-test-{}", uuid::Uuid::new_v4())); - std::fs::write(&draft_file, format!("{char_cursor}\0{draft}\0")) - .expect("should write test draft file"); - draft_file -} - -/// Regression test for the fish decode path (`warp_run_external_ctrl_t_widget` reading back the -/// draft file), not just the Rust file writer: a multiline in-progress command must survive -/// reconstruction intact. An unquoted command substitution splits `cat`'s output into a list by -/// newline, so reconstructing that split without care (see the comment on `warp_ctrl_t_widget`'s -/// reconstruction line) can silently drop embedded newlines back out -- exercising only the write -/// side can never catch that, since the bug is entirely in how fish re-reads what was written. +/// Regression test for the argument-parsing step alone, asserting the decoded `char_cursor` and +/// `original_line` directly against a draft with both an embedded and a trailing newline -- the +/// case that requires `string collect --no-trim-newlines`, not just `warp_hex_decode_string` +/// itself, to survive intact. #[test] -fn test_fish_ctrl_t_draft_decode_preserves_multiline_drafts() { - let decode_snippet = fish_ctrl_t_draft_decode_snippet(); - let draft_file = write_decode_test_draft_file(8, "echo one\necho two"); - let draft_file_path = draft_file.display().to_string(); +fn test_fish_ctrl_t_argument_parsing_decodes_multiline_trailing_newline_draft() { + let hex_decode_fn = fish_hex_decode_string_fn(); + let parsing_snippet = fish_ctrl_t_argument_parsing_snippet(); + let hex_draft = hex_encode("echo one\ntwo\n"); let script = format!( r#" -set -l draft_file '{draft_file_path}' -set -l char_cursor 0 -set -l original_line '' -{decode_snippet} -printf 'char_cursor=[%s]\n' "$char_cursor" -printf 'original_line=[%s]\n' "$original_line" +{hex_decode_fn} +function warp_ctrl_t_test_parse + {parsing_snippet} + printf 'char_cursor=[%s]\n' "$char_cursor" + printf 'original_line=[%s]\n' "$original_line" +end +warp_ctrl_t_test_parse test-token '8:{hex_draft}' "# ); - let stdout = run_fish(&script); - std::fs::remove_file(&draft_file).ok(); - let Some(stdout) = stdout else { + let Some(stdout) = run_fish(&script) else { return; }; assert!(stdout.contains("char_cursor=[8]"), "{stdout}"); assert!( - stdout.contains("original_line=[echo one\necho two]"), + stdout.contains("original_line=[echo one\ntwo\n]"), "{stdout}" ); } -/// Regression test for a draft whose *last* character is a newline (e.g. a trailing blank line -/// mid-multiline edit): a plain command substitution -- `(command cat -- $draft_file)` -- -/// unconditionally strips trailing newline bytes from what it captures before any splitting -/// happens, which is exactly why the decode format is NUL-delimited (see -/// `Input::write_ctrl_t_draft_file`) rather than reconstructed from a newline-split list. The -/// multiline test above only covers an *embedded* newline, which that stripping doesn't touch -- -/// this is the case it would silently corrupt. -#[test] -fn test_fish_ctrl_t_draft_decode_preserves_trailing_newline() { - let decode_snippet = fish_ctrl_t_draft_decode_snippet(); - let draft_file = write_decode_test_draft_file(3, "echo hi\n"); - let draft_file_path = draft_file.display().to_string(); - let script = format!( - r#" -set -l draft_file '{draft_file_path}' -set -l char_cursor 0 -set -l original_line '' -{decode_snippet} -printf 'char_cursor=[%s]\n' "$char_cursor" -printf 'original_line=[%s]\n' "$original_line" -"# - ); - let stdout = run_fish(&script); - std::fs::remove_file(&draft_file).ok(); - let Some(stdout) = stdout else { - return; - }; - assert!(stdout.contains("char_cursor=[3]"), "{stdout}"); - assert!(stdout.contains("original_line=[echo hi\n]"), "{stdout}"); -} - fn fish_ctrl_t_widget_runner_fn() -> &'static str { const FISH_SH: &str = include_str!("../../../app/assets/bundled/bootstrap/fish.sh"); let start_marker = "function warp_run_external_ctrl_t_widget\n"; @@ -617,29 +596,24 @@ fn fish_ctrl_t_widget_runner_fn() -> &'static str { &FISH_SH[start..start + end + end_marker.len()] } -fn fish_ctrl_t_draft_file_path_fn() -> &'static str { - const FISH_SH: &str = include_str!("../../../app/assets/bundled/bootstrap/fish.sh"); - let start_marker = "function warp_ctrl_t_draft_file_path\n"; - let start = FISH_SH - .find(start_marker) - .expect("fish ctrl-t draft file path function start should exist"); - let end_marker = "\nend\n"; - let end = FISH_SH[start..] - .find(end_marker) - .expect("fish ctrl-t draft file path function end should exist"); - &FISH_SH[start..start + end + end_marker.len()] -} - /// Builds a script that runs the full `warp_run_external_ctrl_t_widget` (not just the -/// `warp_ctrl_t_widget_result` comparison helper in isolation) against a real draft file, so the -/// `(commandline | string collect)` argument at its `fzf-file-widget` call site is exercised too -/// -- unquoted, a multi-line result there would otherwise expand to multiple arguments, silently -/// truncating that comparison to the result's first line alone. `commandline` is stubbed -/// statefully (supporting the `-r --` and `-C --` forms the widget actually calls, plus a plain -/// read) rather than as a fixed value, since the widget both seeds and reads it back. -fn fish_ctrl_t_widget_test_script(xdg_runtime_dir: &str, widget_body: &str) -> String { +/// `warp_ctrl_t_widget_result` comparison helper in isolation) against a real `{char_cursor}:{hex}` +/// argument, so the `(commandline | string collect)` argument at its `fzf-file-widget` call site +/// is exercised too -- unquoted, a multi-line result there would otherwise expand to multiple +/// arguments, silently truncating that comparison to the result's first line alone. `commandline` +/// is stubbed statefully (supporting the `-r --` and `-C --` forms the widget actually calls, +/// plus a plain read) rather than as a fixed value, since the widget both seeds and reads it +/// back. The read stub uses `echo`, matching the real builtin's own bare-read behavior of always +/// terminating its output with a newline regardless of the buffer's actual content -- a stub that +/// used `printf '%s'` instead would silently hide a regression in the comparison's own newline +/// handling. The `-r` stub also distinguishes a *read* (no CMD argument at all, i.e. +/// `$argv[3..]` is empty) from a *write*, matching real fish semantics -- a stub that always +/// wrote, even with nothing to write, would silently hide a regression that fails to seed an +/// empty draft. `_test_cl_value` starts as a non-empty sentinel rather than `''`, so a failure to +/// seed (leaving the sentinel in place) is distinguishable from a correctly-seeded empty draft. +fn fish_ctrl_t_widget_test_script(ctrl_t_arg: &str, widget_body: &str) -> String { let runner = fish_ctrl_t_widget_runner_fn(); - let draft_file_path_fn = fish_ctrl_t_draft_file_path_fn(); + let hex_decode_fn = fish_hex_decode_string_fn(); let widget_result_fn = fish_ctrl_t_widget_result_fn(); format!( r#" @@ -654,13 +628,16 @@ end function warp_send_json_message echo "$argv" end -set -gx XDG_RUNTIME_DIR '{xdg_runtime_dir}' -{draft_file_path_fn} +{hex_decode_fn} {widget_result_fn} -set -g _test_cl_value '' +set -g _test_cl_value 'UNSEEDED-SENTINEL' function commandline if test (count $argv) -ge 1; and test "$argv[1]" = '-r' - set -g _test_cl_value (string join \n -- $argv[3..] | string collect) + if test (count $argv[3..]) -eq 0 + # No CMD argument at all is a read, not a write -- must leave $_test_cl_value untouched. + return 0 + end + set -g _test_cl_value (string collect --no-trim-newlines -- $argv[3..]) return 0 end if test (count $argv) -ge 1; and test "$argv[1]" = '-C' @@ -674,22 +651,11 @@ end set -g _WARP_EXTERNAL_CTRL_T_WIDGET fzf-file-widget set -g WARP_SESSION_ID 12345 {runner} -warp_run_external_ctrl_t_widget test-token +warp_run_external_ctrl_t_widget test-token '{ctrl_t_arg}' "# ) } -/// Writes a NUL-delimited draft file matching [`Input::write_ctrl_t_draft_file`]'s format (see -/// `write_decode_test_draft_file` above) at the path the widget under test will look for. -fn write_ctrl_t_test_draft(xdg_runtime_dir: &std::path::Path, char_cursor: u32, draft: &str) { - std::fs::create_dir_all(xdg_runtime_dir).expect("should create test XDG_RUNTIME_DIR"); - std::fs::write( - xdg_runtime_dir.join("warp-ctrl-t-test-token"), - format!("{char_cursor}\0{draft}\0"), - ) - .expect("should write test draft file"); -} - /// Regression test for the `(commandline | string collect)` argument at the widget's /// `fzf-file-widget` call site: without `string collect`, a multi-line selection is split by that /// call's own (unquoted) command substitution into multiple arguments, silently truncating @@ -697,16 +663,12 @@ fn write_ctrl_t_test_draft(xdg_runtime_dir: &std::path::Path, char_cursor: u32, /// selection's first line alone. #[test] fn test_fish_ctrl_t_widget_reports_full_multiline_change_without_truncation() { - let xdg_runtime_dir = - std::env::temp_dir().join(format!("warp-ctrl-t-widget-test-{}", uuid::Uuid::new_v4())); - write_ctrl_t_test_draft(&xdg_runtime_dir, 10, "echo START\nMIDDLE"); + let hex_draft = hex_encode("echo START\nMIDDLE"); let script = fish_ctrl_t_widget_test_script( - &xdg_runtime_dir.display().to_string(), + &format!("10:{hex_draft}"), "commandline -r -- (printf 'echo START\\nMIDDLE nested.rs ' | string collect)", ); - let stdout = run_fish(&script); - std::fs::remove_dir_all(&xdg_runtime_dir).ok(); - let Some(stdout) = stdout else { + let Some(stdout) = run_fish(&script) else { return; }; assert!( @@ -722,17 +684,44 @@ fn test_fish_ctrl_t_widget_reports_full_multiline_change_without_truncation() { /// of the empty buffer this "unchanged" case is supposed to produce. #[test] fn test_fish_ctrl_t_widget_reports_empty_when_multiline_draft_is_left_unchanged() { - let xdg_runtime_dir = - std::env::temp_dir().join(format!("warp-ctrl-t-widget-test-{}", uuid::Uuid::new_v4())); - write_ctrl_t_test_draft(&xdg_runtime_dir, 10, "echo START\nMIDDLE"); - let script = - fish_ctrl_t_widget_test_script(&xdg_runtime_dir.display().to_string(), "# cancelled"); - let stdout = run_fish(&script); - std::fs::remove_dir_all(&xdg_runtime_dir).ok(); - let Some(stdout) = stdout else { + let hex_draft = hex_encode("echo START\nMIDDLE"); + let script = fish_ctrl_t_widget_test_script(&format!("10:{hex_draft}"), "# cancelled"); + let Some(stdout) = run_fish(&script) else { + return; + }; + assert!(stdout.contains(r#""buffer": """#), "{stdout}"); +} + +/// Regression test for a plain, single-line, unchanged draft on cancel: a bare `commandline` read +/// always terminates its own output with a newline regardless of the buffer's actual content, so +/// comparing it against `original_line` with `--no-trim-newlines` (rather than the default, +/// trimming `string collect`) would make an ordinary, single-line cancel always compare unequal +/// to itself, misreporting the cancel as a real selection. +#[test] +fn test_fish_ctrl_t_widget_reports_empty_when_single_line_draft_is_left_unchanged() { + let hex_draft = hex_encode("echo START MIDDLE"); + let script = fish_ctrl_t_widget_test_script(&format!("11:{hex_draft}"), "# cancelled"); + let Some(stdout) = run_fish(&script) else { + return; + }; + assert!(stdout.contains(r#""buffer": """#), "{stdout}"); +} + +/// Regression test for an empty draft (ctrl-t on a blank line): decoding zero bytes must still +/// seed the commandline with an explicit empty buffer, not skip seeding altogether. +/// `warp_hex_decode_string` on an empty hex string produces no output at all, and piping that +/// through plain `string collect` collapses to zero list elements rather than one empty string -- +/// so `commandline -r --` would receive no CMD argument, which fish treats as a *read*, leaving +/// whatever was already on the commandline (here, the sentinel standing in for the synthetic +/// helper invocation itself) in place instead of clearing it. +#[test] +fn test_fish_ctrl_t_widget_seeds_blank_buffer_for_empty_draft() { + let script = fish_ctrl_t_widget_test_script("0:", "# cancelled"); + let Some(stdout) = run_fish(&script) else { return; }; assert!(stdout.contains(r#""buffer": """#), "{stdout}"); + assert!(!stdout.contains("UNSEEDED-SENTINEL"), "{stdout}"); } /// Regression test for the fish equivalent of bash's picker-function guard: detection must From 76ac780d8f71e466be9e73be55d24555c660df3d Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 19:12:26 +0000 Subject: [PATCH 39/70] Add fzf-gated integration tests for ctrl-r and ctrl-t handoff. Exercise real fzf history and file pickers on bash, zsh, and fish, skip when fzf is missing or the shell cannot detect the bindings, and install fzf in CI so the tests actually run. --- .github/workflows/ci.yml | 15 +- crates/integration/src/bin/integration.rs | 3 + crates/integration/src/test.rs | 2 + .../src/test/shell_widget_handoff.rs | 399 ++++++++++++++++++ crates/integration/src/util.rs | 39 ++ .../tests/data/fzf/key-bindings.bash | 133 ++++++ .../tests/data/fzf/key-bindings.fish | 175 ++++++++ .../tests/data/fzf/key-bindings.zsh | 121 ++++++ .../integration/shell_integration_tests.rs | 5 + 9 files changed, 889 insertions(+), 3 deletions(-) create mode 100644 crates/integration/src/test/shell_widget_handoff.rs create mode 100644 crates/integration/tests/data/fzf/key-bindings.bash create mode 100644 crates/integration/tests/data/fzf/key-bindings.fish create mode 100644 crates/integration/tests/data/fzf/key-bindings.zsh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41a51569e24..3d87e410753 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -146,8 +146,8 @@ jobs: uses: ConorMacBride/install-package@3e7ad059e07782ee54fa35f827df52aae0626f30 # v1 if: ${{ matrix.is_self_hosted == false }} with: - apt: zsh fish - brew: fish bash + apt: zsh fish fzf + brew: fish bash fzf - name: Echo Shells (UNIX) id: echo_shells_unix @@ -181,6 +181,15 @@ jobs: echo "powershell_path=$POWERSHELL_PATH" >> $GITHUB_OUTPUT echo "::notice title=${{ matrix.name }} Tests - Powershell Version::$POWERSHELL_VERSION" + FZF_PATH="$(command -v fzf || true)" + if [ -n "$FZF_PATH" ]; then + FZF_VERSION="$("$FZF_PATH" --version)" + echo "fzf_path=$FZF_PATH" >> $GITHUB_OUTPUT + echo "::notice title=${{ matrix.name }} Tests - fzf Version::$FZF_VERSION" + else + echo "::notice title=${{ matrix.name }} Tests - fzf Version::not installed" + fi + - name: Echo Shells (Windows) id: echo_shells_windows if: ${{ matrix.os == 'windows' }} @@ -471,7 +480,7 @@ jobs: - name: Install Shells uses: ConorMacBride/install-package@3e7ad059e07782ee54fa35f827df52aae0626f30 # v1 with: - apt: zsh fish + apt: zsh fish fzf - name: Echo default Bash id: echo_bash diff --git a/crates/integration/src/bin/integration.rs b/crates/integration/src/bin/integration.rs index 2733a2342a9..e2e657e2761 100644 --- a/crates/integration/src/bin/integration.rs +++ b/crates/integration/src/bin/integration.rs @@ -335,6 +335,9 @@ fn register_tests() -> HashMap<&'static str, BoxedBuilderFn> { register_test!(test_command_search_loads_history); register_test!(test_histfile_left_joined_with_persisted_history); + register_test!(test_fzf_ctrl_r_selects_history_unexecuted); + register_test!(test_fzf_ctrl_t_inserts_selection); + register_test!(test_fzf_ctrl_r_cancel_restores_draft); register_test!(test_history_command_is_linked_to_local_workflow); register_test!(test_up_arrow_history_enters_shift_tab_for_workflow); diff --git a/crates/integration/src/test.rs b/crates/integration/src/test.rs index c40f5111f38..d833dc8419f 100644 --- a/crates/integration/src/test.rs +++ b/crates/integration/src/test.rs @@ -32,6 +32,7 @@ mod settings_file_hot_reload; mod settings_file_migration; mod settings_navigation; mod settings_private; +mod shell_widget_handoff; mod ssh; mod subshell; mod sync_inputs; @@ -85,6 +86,7 @@ pub use settings_file_migration::*; pub use settings_navigation::*; pub use settings_private::*; use shell::ShellType; +pub use shell_widget_handoff::*; pub use ssh::*; pub use subshell::*; use sum_tree::SeekBias; diff --git a/crates/integration/src/test/shell_widget_handoff.rs b/crates/integration/src/test/shell_widget_handoff.rs new file mode 100644 index 00000000000..9101524ff73 --- /dev/null +++ b/crates/integration/src/test/shell_widget_handoff.rs @@ -0,0 +1,399 @@ +//! Live fzf ctrl-r / ctrl-t handoff tests. Skipped when fzf is not installed or the current +//! shell is not bash/zsh/fish (the shells fzf ships key-bindings for). + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Duration; + +use warp::features::FeatureFlag; +use warp::integration_testing; +use warp::integration_testing::step::new_step_with_default_assertions; +use warp::integration_testing::terminal::util::{ + ExpectedExitStatus, current_shell_starter_and_version, +}; +use warp::integration_testing::terminal::{ + assert_input_editor_contents, assert_long_running_block_executing_for_single_terminal_in_tab, + execute_command_for_single_terminal_in_tab, wait_until_bootstrapped_single_pane_for_tab, +}; +use warp::integration_testing::view_getters::{ + single_input_view_for_tab, single_terminal_view_for_tab, workspace_view, +}; +use warp::terminal::shell::ShellType; +use warpui_core::async_assert; +use warpui_core::integration::{AssertionCallback, TestStep}; + +use super::{TEST_ONLY_ASSETS, new_builder}; +use crate::Builder; +use crate::util::{ + ShellRcType, set_zsh_histfile_location, should_run_fzf_widget_handoff_tests, + write_rc_files_for_test, +}; + +const CTRL_R_HISTORY_COMMAND: &str = "echo fzf_ctrl_r_marker_alpha"; +const CTRL_R_HISTORY_OUTPUT: &str = "fzf_ctrl_r_marker_alpha"; +const CTRL_R_DRAFT: &str = "draft_before_ctrl_r"; +const CTRL_T_FILENAME: &str = "fzf_ctrl_t_marker_file.txt"; +const CTRL_T_PREFIX: &str = "echo "; +const FZF_STEP_TIMEOUT: Duration = Duration::from_secs(20); + +fn fzf_handoff_builder() -> Builder { + FeatureFlag::ShellWidgetHandoff.set_enabled(true); + new_builder() + .set_should_run_test(should_run_fzf_widget_handoff_tests) + .with_setup(|utils| { + let home = utils.test_dir(); + install_fzf_key_bindings(&home); + std::fs::write(home.join(CTRL_T_FILENAME), b"") + .expect("should be able to create the ctrl-t marker file"); + }) +} + +fn install_fzf_key_bindings(home: &Path) { + write_rc_files_for_test(home, bash_fzf_rc(home), [ShellRcType::Bash]); + write_rc_files_for_test(home, zsh_fzf_rc(home), [ShellRcType::Zsh]); + write_rc_files_for_test(home, fish_fzf_rc(home), [ShellRcType::Fish]); + set_zsh_histfile_location(home); +} + +fn bash_fzf_rc(home: &Path) -> String { + if fzf_dumps_script("--bash") { + "eval \"$(fzf --bash)\"\n".to_owned() + } else { + format!( + ". '{}'\n", + ensure_fzf_key_bindings(home, "key-bindings.bash").display() + ) + } +} + +fn zsh_fzf_rc(home: &Path) -> String { + if fzf_dumps_script("--zsh") { + "source <(fzf --zsh)\n".to_owned() + } else { + format!( + "source '{}'\n", + ensure_fzf_key_bindings(home, "key-bindings.zsh").display() + ) + } +} + +fn fish_fzf_rc(home: &Path) -> String { + if fzf_dumps_script("--fish") { + "fzf --fish | source\n".to_owned() + } else if fzf_key_bindings_path("key-bindings.fish").is_some() + || Path::new("/usr/share/fish/vendor_functions.d/fzf_key_bindings.fish").is_file() + { + // Distro packages often install the function without calling it. + "if functions -q fzf_key_bindings; fzf_key_bindings; end\n".to_owned() + } else { + format!( + "source '{}'\n", + ensure_fzf_key_bindings(home, "key-bindings.fish").display() + ) + } +} + +fn ensure_fzf_key_bindings(home: &Path, filename: &str) -> PathBuf { + if let Some(path) = fzf_key_bindings_path(filename) { + return path; + } + let dest = home.join(filename); + integration_testing::create_file_from_assets( + TEST_ONLY_ASSETS, + &format!("fzf/{filename}"), + &dest, + ); + dest +} + +fn fzf_dumps_script(flag: &str) -> bool { + Command::new("fzf") + .arg(flag) + .output() + .map(|output| output.status.success() && !output.stdout.is_empty()) + .unwrap_or(false) +} + +fn fzf_key_bindings_path(filename: &str) -> Option { + let mut candidates = vec![ + PathBuf::from(format!("/usr/share/doc/fzf/examples/{filename}")), + PathBuf::from(format!("/usr/share/fzf/{filename}")), + PathBuf::from(format!("/usr/share/fzf/shell/{filename}")), + PathBuf::from(format!("/opt/homebrew/opt/fzf/shell/{filename}")), + PathBuf::from(format!("/usr/local/opt/fzf/shell/{filename}")), + ]; + if let Ok(output) = Command::new("brew").args(["--prefix", "fzf"]).output() + && output.status.success() + { + let prefix = String::from_utf8_lossy(&output.stdout); + let prefix = prefix.trim(); + if !prefix.is_empty() { + candidates.push(PathBuf::from(prefix).join("shell").join(filename)); + } + } + candidates.into_iter().find(|path| path.is_file()) +} + +fn assert_command_search_is_closed() -> AssertionCallback { + Box::new(move |app, window_id| { + let workspace_view = workspace_view(app, window_id); + workspace_view.read(app, |workspace, _ctx| { + async_assert!( + !workspace.is_command_search_open(), + "Warp command search should not open when fzf owns the key" + ) + }) + }) +} + +fn assert_shell_plugin_tag(tag: &'static str) -> AssertionCallback { + Box::new(move |app, window_id| { + let terminal_view = single_terminal_view_for_tab(app, window_id, 0); + terminal_view.read(app, |view, ctx| { + let Some(session_id) = view.active_block_session_id() else { + return warpui_core::integration::AssertionOutcome::failure( + "expected an active session after bootstrap".into(), + ); + }; + let Some(session) = view.sessions(ctx).get(session_id) else { + return warpui_core::integration::AssertionOutcome::failure( + "expected the active session to be registered".into(), + ); + }; + let plugins = session.shell().plugins(); + async_assert!( + plugins.contains(tag), + "expected shell plugin tag {tag}, have {plugins:?}" + ) + }) + }) +} + +fn assert_shell_widget_handoff_enabled() -> AssertionCallback { + Box::new(move |_app, _window_id| { + async_assert!( + FeatureFlag::ShellWidgetHandoff.is_enabled(), + "ShellWidgetHandoff must be enabled or these tests exercise nothing" + ) + }) +} + +fn assert_fzf_shows(text: &'static str) -> AssertionCallback { + Box::new(move |app, window_id| { + let terminal_view = single_terminal_view_for_tab(app, window_id, 0); + terminal_view.read(app, |view, _| { + let model = view.model.lock(); + let alt = model.alt_screen().output_to_string(); + let block = model.block_list().active_block().output_to_string(); + async_assert!( + alt.contains(text) || block.contains(text), + "fzf should show {text:?}; alt-screen={alt:?} block={block:?}" + ) + }) + }) +} + +fn assert_input_contains(text: &'static str) -> AssertionCallback { + Box::new(move |app, window_id| { + let input = single_input_view_for_tab(app, window_id, 0); + input.read(app, |view, ctx| { + let contents = view.buffer_text(ctx); + async_assert!( + contents.contains(text), + "input {contents:?} should contain {text:?}" + ) + }) + }) +} + +fn wait_for_fzf() -> TestStep { + TestStep::new("Wait for fzf to take over the PTY") + .set_timeout(FZF_STEP_TIMEOUT) + .add_named_assertion( + "command search stayed closed", + assert_command_search_is_closed(), + ) + .add_named_assertion( + "fzf is running as a long-running command", + assert_long_running_block_executing_for_single_terminal_in_tab(true, 0), + ) +} + +/// ctrl-r opens the real fzf history picker and lands the selected command in the editor +/// unexecuted. +pub fn test_fzf_ctrl_r_selects_history_unexecuted() -> Builder { + fzf_handoff_builder() + .with_step( + wait_until_bootstrapped_single_pane_for_tab(0).add_named_assertion( + "ShellWidgetHandoff is enabled", + assert_shell_widget_handoff_enabled(), + ), + ) + .with_step( + new_step_with_default_assertions("Bootstrap reported the fzf ctrl-r plugin tag") + .add_named_assertion( + "external_ctrl_r_history is tagged", + assert_shell_plugin_tag("external_ctrl_r_history"), + ), + ) + .with_step(execute_command_for_single_terminal_in_tab( + 0, + CTRL_R_HISTORY_COMMAND.to_owned(), + ExpectedExitStatus::Success, + CTRL_R_HISTORY_OUTPUT, + )) + .with_step( + TestStep::new("Press ctrl-r") + .with_keystrokes(&["ctrl-r"]) + .set_timeout(FZF_STEP_TIMEOUT), + ) + .with_step(wait_for_fzf()) + .with_step( + TestStep::new("Filter to the unique history entry") + .with_typed_characters(&[CTRL_R_HISTORY_OUTPUT]) + .set_timeout(FZF_STEP_TIMEOUT) + .add_named_assertion( + "fzf lists the unique history entry", + assert_fzf_shows(CTRL_R_HISTORY_OUTPUT), + ), + ) + .with_step( + TestStep::new("Accept the fzf selection") + .with_keystrokes(&["enter"]) + .set_timeout(FZF_STEP_TIMEOUT), + ) + .with_step( + new_step_with_default_assertions( + "Selected history command is in the editor unexecuted", + ) + .add_named_assertion( + "command search stayed closed", + assert_command_search_is_closed(), + ) + .add_named_assertion( + "input contains the selected command", + assert_input_editor_contents(0, CTRL_R_HISTORY_COMMAND), + ), + ) +} + +/// ctrl-t opens the real fzf file picker and lands the selection in the editor. bash/zsh splice +/// at the cursor (prefix preserved); fish applies the widget's own finished buffer. +pub fn test_fzf_ctrl_t_inserts_selection() -> Builder { + let is_fish = current_shell_starter_and_version().0.shell_type() == ShellType::Fish; + fzf_handoff_builder() + .with_step(wait_until_bootstrapped_single_pane_for_tab(0).add_named_assertion( + "ShellWidgetHandoff is enabled", + assert_shell_widget_handoff_enabled(), + )) + .with_step( + new_step_with_default_assertions("Bootstrap reported the fzf ctrl-t plugin tag") + .add_named_assertion( + "external_ctrl_t_file is tagged", + assert_shell_plugin_tag("external_ctrl_t_file"), + ), + ) + .with_step( + new_step_with_default_assertions("Type a prefix so splice vs replace is observable") + .with_typed_characters(&[CTRL_T_PREFIX]) + .add_named_assertion( + "prefix is in the input", + assert_input_editor_contents(0, CTRL_T_PREFIX), + ), + ) + .with_step( + TestStep::new("Press ctrl-t") + .with_keystrokes(&["ctrl-t"]) + .set_timeout(FZF_STEP_TIMEOUT), + ) + .with_step(wait_for_fzf()) + .with_step( + TestStep::new("Filter to the unique file") + .with_typed_characters(&[CTRL_T_FILENAME]) + .set_timeout(FZF_STEP_TIMEOUT) + .add_named_assertion( + "fzf lists the unique file", + assert_fzf_shows(CTRL_T_FILENAME), + ), + ) + .with_step( + TestStep::new("Accept the fzf selection") + .with_keystrokes(&["enter"]) + .set_timeout(FZF_STEP_TIMEOUT), + ) + .with_step( + new_step_with_default_assertions("Selected file landed in the editor unexecuted") + .add_named_assertion("command search stayed closed", assert_command_search_is_closed()) + .add_named_assertion( + "input contains the selected filename", + assert_input_contains(CTRL_T_FILENAME), + ) + .add_named_assertion("prefix is preserved on bash/zsh", move |app, window_id| { + if is_fish { + return warpui_core::integration::AssertionOutcome::Success; + } + let input = single_input_view_for_tab(app, window_id, 0); + input.read(app, |view, ctx| { + let contents = view.buffer_text(ctx); + async_assert!( + contents.contains(CTRL_T_PREFIX.trim()), + "bash/zsh should splice at the cursor, keeping the prefix; got {contents:?}" + ) + }) + }), + ) +} + +/// Cancel restores the draft that was in the editor when ctrl-r was pressed. +pub fn test_fzf_ctrl_r_cancel_restores_draft() -> Builder { + fzf_handoff_builder() + .with_step( + wait_until_bootstrapped_single_pane_for_tab(0).add_named_assertion( + "ShellWidgetHandoff is enabled", + assert_shell_widget_handoff_enabled(), + ), + ) + .with_step( + new_step_with_default_assertions("Bootstrap reported the fzf ctrl-r plugin tag") + .add_named_assertion( + "external_ctrl_r_history is tagged", + assert_shell_plugin_tag("external_ctrl_r_history"), + ), + ) + .with_step(execute_command_for_single_terminal_in_tab( + 0, + CTRL_R_HISTORY_COMMAND.to_owned(), + ExpectedExitStatus::Success, + CTRL_R_HISTORY_OUTPUT, + )) + .with_step( + new_step_with_default_assertions("Type a draft to restore on cancel") + .with_typed_characters(&[CTRL_R_DRAFT]) + .add_named_assertion( + "draft is in the input", + assert_input_editor_contents(0, CTRL_R_DRAFT), + ), + ) + .with_step( + TestStep::new("Press ctrl-r") + .with_keystrokes(&["ctrl-r"]) + .set_timeout(FZF_STEP_TIMEOUT), + ) + .with_step(wait_for_fzf()) + .with_step( + TestStep::new("Cancel fzf") + .with_keystrokes(&["escape"]) + .set_timeout(FZF_STEP_TIMEOUT), + ) + .with_step( + new_step_with_default_assertions("Draft is restored after cancel") + .add_named_assertion( + "command search stayed closed", + assert_command_search_is_closed(), + ) + .add_named_assertion( + "input still has the original draft", + assert_input_editor_contents(0, CTRL_R_DRAFT), + ), + ) +} diff --git a/crates/integration/src/util.rs b/crates/integration/src/util.rs index 42ac49d595e..f5afc96cd7e 100644 --- a/crates/integration/src/util.rs +++ b/crates/integration/src/util.rs @@ -1,6 +1,7 @@ use std::fs::{OpenOptions, create_dir_all, write}; use std::io::Write; use std::path::{Path, PathBuf}; +use std::process::Command; use itertools::Itertools as _; use strum::IntoEnumIterator; @@ -223,6 +224,44 @@ pub fn skip_if_powershell_core_2303() -> bool { !matches!(starter.shell_type(), ShellType::PowerShell) } +/// True when `fzf` is on `PATH` and answers `--version`. +pub fn fzf_is_installed() -> bool { + Command::new("fzf") + .arg("--version") + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +/// Gate for live fzf ctrl-r / ctrl-t handoff tests: fzf must be installed, and the current shell +/// must be one fzf ships key-bindings for. Bash older than 4.3 is skipped because detection uses +/// `bind -X`, which that version does not have. +pub fn should_run_fzf_widget_handoff_tests() -> bool { + if !fzf_is_installed() { + return false; + } + let (starter, version) = current_shell_starter_and_version(); + match starter.shell_type() { + ShellType::Zsh | ShellType::Fish => true, + ShellType::Bash => bash_version_supports_bind_x(&version), + ShellType::PowerShell => false, + } +} + +fn bash_version_supports_bind_x(version: &str) -> bool { + let numeric: String = version + .chars() + .take_while(|c| c.is_ascii_digit() || *c == '.') + .collect(); + let Some(actual) = Version::from(&numeric) else { + return false; + }; + let Some(minimum) = Version::from("4.3") else { + return false; + }; + actual >= minimum +} + /// Gets the name of the system user for which the test binary is running. pub fn get_local_user() -> String { whoami::username() diff --git a/crates/integration/tests/data/fzf/key-bindings.bash b/crates/integration/tests/data/fzf/key-bindings.bash new file mode 100644 index 00000000000..c4dce3ba5bf --- /dev/null +++ b/crates/integration/tests/data/fzf/key-bindings.bash @@ -0,0 +1,133 @@ +# ____ ____ +# / __/___ / __/ +# / /_/_ / / /_ +# / __/ / /_/ __/ +# /_/ /___/_/ key-bindings.bash +# +# - $FZF_TMUX_OPTS +# - $FZF_CTRL_T_COMMAND +# - $FZF_CTRL_T_OPTS +# - $FZF_CTRL_R_OPTS +# - $FZF_ALT_C_COMMAND +# - $FZF_ALT_C_OPTS + +[[ $- =~ i ]] || return 0 + + +# Key bindings +# ------------ +__fzf_select__() { + local cmd opts + cmd="${FZF_CTRL_T_COMMAND:-"command find -L . -mindepth 1 \\( -path '*/.*' -o -fstype 'sysfs' -o -fstype 'devfs' -o -fstype 'devtmpfs' -o -fstype 'proc' \\) -prune \ + -o -type f -print \ + -o -type d -print \ + -o -type l -print 2> /dev/null | command cut -b3-"}" + opts="--height ${FZF_TMUX_HEIGHT:-40%} --bind=ctrl-z:ignore --reverse --scheme=path ${FZF_DEFAULT_OPTS-} ${FZF_CTRL_T_OPTS-} -m" + eval "$cmd" | + FZF_DEFAULT_OPTS="$opts" $(__fzfcmd) "$@" | + while read -r item; do + printf '%q ' "$item" # escape special chars + done +} + +__fzfcmd() { + [[ -n "${TMUX_PANE-}" ]] && { [[ "${FZF_TMUX:-0}" != 0 ]] || [[ -n "${FZF_TMUX_OPTS-}" ]]; } && + echo "fzf-tmux ${FZF_TMUX_OPTS:--d${FZF_TMUX_HEIGHT:-40%}} -- " || echo "fzf" +} + +fzf-file-widget() { + local selected="$(__fzf_select__ "$@")" + READLINE_LINE="${READLINE_LINE:0:$READLINE_POINT}$selected${READLINE_LINE:$READLINE_POINT}" + READLINE_POINT=$(( READLINE_POINT + ${#selected} )) +} + +__fzf_cd__() { + local cmd opts dir + cmd="${FZF_ALT_C_COMMAND:-"command find -L . -mindepth 1 \\( -path '*/.*' -o -fstype 'sysfs' -o -fstype 'devfs' -o -fstype 'devtmpfs' -o -fstype 'proc' \\) -prune \ + -o -type d -print 2> /dev/null | command cut -b3-"}" + opts="--height ${FZF_TMUX_HEIGHT:-40%} --bind=ctrl-z:ignore --reverse --scheme=path ${FZF_DEFAULT_OPTS-} ${FZF_ALT_C_OPTS-} +m" + dir=$(set +o pipefail; eval "$cmd" | FZF_DEFAULT_OPTS="$opts" $(__fzfcmd)) && printf 'builtin cd -- %q' "$dir" +} + +if command -v perl > /dev/null; then + __fzf_history__() { + local output opts script + opts="--height ${FZF_TMUX_HEIGHT:-40%} --bind=ctrl-z:ignore ${FZF_DEFAULT_OPTS-} -n2..,.. --scheme=history --bind=ctrl-r:toggle-sort ${FZF_CTRL_R_OPTS-} +m --read0" + script='BEGIN { getc; $/ = "\n\t"; $HISTCOUNT = $ENV{last_hist} + 1 } s/^[ *]//; print $HISTCOUNT - $. . "\t$_" if !$seen{$_}++' + output=$( + set +o pipefail + builtin fc -lnr -2147483648 | + last_hist=$(HISTTIMEFORMAT='' builtin history 1) command perl -n -l0 -e "$script" | + FZF_DEFAULT_OPTS="$opts" $(__fzfcmd) --query "$READLINE_LINE" + ) || return + READLINE_LINE=${output#*$'\t'} + if [[ -z "$READLINE_POINT" ]]; then + echo "$READLINE_LINE" + else + READLINE_POINT=0x7fffffff + fi + } +else # awk - fallback for POSIX systems + __fzf_history__() { + local output opts script n x y z d + if [[ -z $__fzf_awk ]]; then + __fzf_awk=awk + # choose the faster mawk if: it's installed && build date >= 20230322 && version >= 1.3.4 + IFS=' .' read n x y z d <<< $(command mawk -W version 2> /dev/null) + [[ $n == mawk ]] && (( d >= 20230302 && (x *1000 +y) *1000 +z >= 1003004 )) && __fzf_awk=mawk + fi + opts="--height ${FZF_TMUX_HEIGHT:-40%} --bind=ctrl-z:ignore ${FZF_DEFAULT_OPTS-} -n2..,.. --scheme=history --bind=ctrl-r:toggle-sort ${FZF_CTRL_R_OPTS-} +m --read0" + [[ $(HISTTIMEFORMAT='' builtin history 1) =~ [[:digit:]]+ ]] # how many history entries + script='function P(b) { ++n; sub(/^[ *]/, "", b); if (!seen[b]++) { printf "%d\t%s%c", '$((BASH_REMATCH + 1))' - n, b, 0 } } + NR==1 { b = substr($0, 2); next } + /^\t/ { P(b); b = substr($0, 2); next } + { b = b RS $0 } + END { if (NR) P(b) }' + output=$( + set +o pipefail + builtin fc -lnr -2147483648 2> /dev/null | # ( $'\t '$'\n' )* ; ::= [^\n]* ( $'\n' )* + command $__fzf_awk "$script" | # ( $'\t'$'\000' )* + FZF_DEFAULT_OPTS="$opts" $(__fzfcmd) --query "$READLINE_LINE" + ) || return + READLINE_LINE=${output#*$'\t'} + if [[ -z "$READLINE_POINT" ]]; then + echo "$READLINE_LINE" + else + READLINE_POINT=0x7fffffff + fi + } +fi + +# Required to refresh the prompt after fzf +bind -m emacs-standard '"\er": redraw-current-line' + +bind -m vi-command '"\C-z": emacs-editing-mode' +bind -m vi-insert '"\C-z": emacs-editing-mode' +bind -m emacs-standard '"\C-z": vi-editing-mode' + +if (( BASH_VERSINFO[0] < 4 )); then + # CTRL-T - Paste the selected file path into the command line + bind -m emacs-standard '"\C-t": " \C-b\C-k \C-u`__fzf_select__`\e\C-e\er\C-a\C-y\C-h\C-e\e \C-y\ey\C-x\C-x\C-f"' + bind -m vi-command '"\C-t": "\C-z\C-t\C-z"' + bind -m vi-insert '"\C-t": "\C-z\C-t\C-z"' + + # CTRL-R - Paste the selected command from history into the command line + bind -m emacs-standard '"\C-r": "\C-e \C-u\C-y\ey\C-u`__fzf_history__`\e\C-e\er"' + bind -m vi-command '"\C-r": "\C-z\C-r\C-z"' + bind -m vi-insert '"\C-r": "\C-z\C-r\C-z"' +else + # CTRL-T - Paste the selected file path into the command line + bind -m emacs-standard -x '"\C-t": fzf-file-widget' + bind -m vi-command -x '"\C-t": fzf-file-widget' + bind -m vi-insert -x '"\C-t": fzf-file-widget' + + # CTRL-R - Paste the selected command from history into the command line + bind -m emacs-standard -x '"\C-r": __fzf_history__' + bind -m vi-command -x '"\C-r": __fzf_history__' + bind -m vi-insert -x '"\C-r": __fzf_history__' +fi + +# ALT-C - cd into the selected directory +bind -m emacs-standard '"\ec": " \C-b\C-k \C-u`__fzf_cd__`\e\C-e\er\C-m\C-y\C-h\e \C-y\ey\C-x\C-x\C-d"' +bind -m vi-command '"\ec": "\C-z\ec\C-z"' +bind -m vi-insert '"\ec": "\C-z\ec\C-z"' diff --git a/crates/integration/tests/data/fzf/key-bindings.fish b/crates/integration/tests/data/fzf/key-bindings.fish new file mode 100644 index 00000000000..69f769703a2 --- /dev/null +++ b/crates/integration/tests/data/fzf/key-bindings.fish @@ -0,0 +1,175 @@ +# ____ ____ +# / __/___ / __/ +# / /_/_ / / /_ +# / __/ / /_/ __/ +# /_/ /___/_/ key-bindings.fish +# +# - $FZF_TMUX_OPTS +# - $FZF_CTRL_T_COMMAND +# - $FZF_CTRL_T_OPTS +# - $FZF_CTRL_R_OPTS +# - $FZF_ALT_C_COMMAND +# - $FZF_ALT_C_OPTS + +status is-interactive; or exit 0 + + +# Key bindings +# ------------ +function fzf_key_bindings + + # Store current token in $dir as root for the 'find' command + function fzf-file-widget -d "List files and folders" + set -l commandline (__fzf_parse_commandline) + set -l dir $commandline[1] + set -l fzf_query $commandline[2] + set -l prefix $commandline[3] + + # "-path \$dir'*/.*'" matches hidden files/folders inside $dir but not + # $dir itself, even if hidden. + test -n "$FZF_CTRL_T_COMMAND"; or set -l FZF_CTRL_T_COMMAND " + command find -L \$dir -mindepth 1 \\( -path \$dir'*/.*' -o -fstype 'sysfs' -o -fstype 'devfs' -o -fstype 'devtmpfs' \\) -prune \ + -o -type f -print \ + -o -type d -print \ + -o -type l -print 2> /dev/null | sed 's@^\./@@'" + + test -n "$FZF_TMUX_HEIGHT"; or set FZF_TMUX_HEIGHT 40% + begin + set -lx FZF_DEFAULT_OPTS "--height $FZF_TMUX_HEIGHT --reverse --scheme=path --bind=ctrl-z:ignore $FZF_DEFAULT_OPTS $FZF_CTRL_T_OPTS" + eval "$FZF_CTRL_T_COMMAND | "(__fzfcmd)' -m --query "'$fzf_query'"' | while read -l r; set result $result $r; end + end + if [ -z "$result" ] + commandline -f repaint + return + else + # Remove last token from commandline. + commandline -t "" + end + for i in $result + commandline -it -- $prefix + commandline -it -- (string escape $i) + commandline -it -- ' ' + end + commandline -f repaint + end + + function fzf-history-widget -d "Show command history" + test -n "$FZF_TMUX_HEIGHT"; or set FZF_TMUX_HEIGHT 40% + begin + set -lx FZF_DEFAULT_OPTS "--height $FZF_TMUX_HEIGHT $FZF_DEFAULT_OPTS --scheme=history --bind=ctrl-r:toggle-sort,ctrl-z:ignore $FZF_CTRL_R_OPTS +m" + + set -l FISH_MAJOR (echo $version | cut -f1 -d.) + set -l FISH_MINOR (echo $version | cut -f2 -d.) + + # history's -z flag is needed for multi-line support. + # history's -z flag was added in fish 2.4.0, so don't use it for versions + # before 2.4.0. + if [ "$FISH_MAJOR" -gt 2 -o \( "$FISH_MAJOR" -eq 2 -a "$FISH_MINOR" -ge 4 \) ]; + history -z | eval (__fzfcmd) --read0 --print0 -q '(commandline)' | read -lz result + and commandline -- $result + else + history | eval (__fzfcmd) -q '(commandline)' | read -l result + and commandline -- $result + end + end + commandline -f repaint + end + + function fzf-cd-widget -d "Change directory" + set -l commandline (__fzf_parse_commandline) + set -l dir $commandline[1] + set -l fzf_query $commandline[2] + set -l prefix $commandline[3] + + test -n "$FZF_ALT_C_COMMAND"; or set -l FZF_ALT_C_COMMAND " + command find -L \$dir -mindepth 1 \\( -path \$dir'*/.*' -o -fstype 'sysfs' -o -fstype 'devfs' -o -fstype 'devtmpfs' \\) -prune \ + -o -type d -print 2> /dev/null | sed 's@^\./@@'" + test -n "$FZF_TMUX_HEIGHT"; or set FZF_TMUX_HEIGHT 40% + begin + set -lx FZF_DEFAULT_OPTS "--height $FZF_TMUX_HEIGHT --reverse --scheme=path --bind=ctrl-z:ignore $FZF_DEFAULT_OPTS $FZF_ALT_C_OPTS" + eval "$FZF_ALT_C_COMMAND | "(__fzfcmd)' +m --query "'$fzf_query'"' | read -l result + + if [ -n "$result" ] + cd -- $result + + # Remove last token from commandline. + commandline -t "" + commandline -it -- $prefix + end + end + + commandline -f repaint + end + + function __fzfcmd + test -n "$FZF_TMUX"; or set FZF_TMUX 0 + test -n "$FZF_TMUX_HEIGHT"; or set FZF_TMUX_HEIGHT 40% + if [ -n "$FZF_TMUX_OPTS" ] + echo "fzf-tmux $FZF_TMUX_OPTS -- " + else if [ $FZF_TMUX -eq 1 ] + echo "fzf-tmux -d$FZF_TMUX_HEIGHT -- " + else + echo "fzf" + end + end + + bind \ct fzf-file-widget + bind \cr fzf-history-widget + bind \ec fzf-cd-widget + + if bind -M insert > /dev/null 2>&1 + bind -M insert \ct fzf-file-widget + bind -M insert \cr fzf-history-widget + bind -M insert \ec fzf-cd-widget + end + + function __fzf_parse_commandline -d 'Parse the current command line token and return split of existing filepath, fzf query, and optional -option= prefix' + set -l commandline (commandline -t) + + # strip -option= from token if present + set -l prefix (string match -r -- '^-[^\s=]+=' $commandline) + set commandline (string replace -- "$prefix" '' $commandline) + + # eval is used to do shell expansion on paths + eval set commandline $commandline + + if [ -z $commandline ] + # Default to current directory with no --query + set dir '.' + set fzf_query '' + else + set dir (__fzf_get_dir $commandline) + + if [ "$dir" = "." -a (string sub -l 1 -- $commandline) != '.' ] + # if $dir is "." but commandline is not a relative path, this means no file path found + set fzf_query $commandline + else + # Also remove trailing slash after dir, to "split" input properly + set fzf_query (string replace -r "^$dir/?" -- '' "$commandline") + end + end + + echo $dir + echo $fzf_query + echo $prefix + end + + function __fzf_get_dir -d 'Find the longest existing filepath from input string' + set dir $argv + + # Strip all trailing slashes. Ignore if $dir is root dir (/) + if [ (string length -- $dir) -gt 1 ] + set dir (string replace -r '/*$' -- '' $dir) + end + + # Iteratively check if dir exists and strip tail end of path + while [ ! -d "$dir" ] + # If path is absolute, this can keep going until ends up at / + # If path is relative, this can keep going until entire input is consumed, dirname returns "." + set dir (dirname -- "$dir") + end + + echo $dir + end + +end diff --git a/crates/integration/tests/data/fzf/key-bindings.zsh b/crates/integration/tests/data/fzf/key-bindings.zsh new file mode 100644 index 00000000000..b64f7916fc7 --- /dev/null +++ b/crates/integration/tests/data/fzf/key-bindings.zsh @@ -0,0 +1,121 @@ +# ____ ____ +# / __/___ / __/ +# / /_/_ / / /_ +# / __/ / /_/ __/ +# /_/ /___/_/ key-bindings.zsh +# +# - $FZF_TMUX_OPTS +# - $FZF_CTRL_T_COMMAND +# - $FZF_CTRL_T_OPTS +# - $FZF_CTRL_R_OPTS +# - $FZF_ALT_C_COMMAND +# - $FZF_ALT_C_OPTS + +[[ -o interactive ]] || return 0 + + +# Key bindings +# ------------ + +# The code at the top and the bottom of this file is the same as in completion.zsh. +# Refer to that file for explanation. +if 'zmodload' 'zsh/parameter' 2>'/dev/null' && (( ${+options} )); then + __fzf_key_bindings_options="options=(${(j: :)${(kv)options[@]}})" +else + () { + __fzf_key_bindings_options="setopt" + 'local' '__fzf_opt' + for __fzf_opt in "${(@)${(@f)$(set -o)}%% *}"; do + if [[ -o "$__fzf_opt" ]]; then + __fzf_key_bindings_options+=" -o $__fzf_opt" + else + __fzf_key_bindings_options+=" +o $__fzf_opt" + fi + done + } +fi + +'builtin' 'emulate' 'zsh' && 'builtin' 'setopt' 'no_aliases' + +{ + +# CTRL-T - Paste the selected file path(s) into the command line +__fsel() { + local cmd="${FZF_CTRL_T_COMMAND:-"command find -L . -mindepth 1 \\( -path '*/.*' -o -fstype 'sysfs' -o -fstype 'devfs' -o -fstype 'devtmpfs' -o -fstype 'proc' \\) -prune \ + -o -type f -print \ + -o -type d -print \ + -o -type l -print 2> /dev/null | cut -b3-"}" + setopt localoptions pipefail no_aliases 2> /dev/null + local item + eval "$cmd" | FZF_DEFAULT_OPTS="--height ${FZF_TMUX_HEIGHT:-40%} --reverse --scheme=path --bind=ctrl-z:ignore ${FZF_DEFAULT_OPTS-} ${FZF_CTRL_T_OPTS-}" $(__fzfcmd) -m "$@" | while read item; do + echo -n "${(q)item} " + done + local ret=$? + echo + return $ret +} + +__fzfcmd() { + [ -n "${TMUX_PANE-}" ] && { [ "${FZF_TMUX:-0}" != 0 ] || [ -n "${FZF_TMUX_OPTS-}" ]; } && + echo "fzf-tmux ${FZF_TMUX_OPTS:--d${FZF_TMUX_HEIGHT:-40%}} -- " || echo "fzf" +} + +fzf-file-widget() { + LBUFFER="${LBUFFER}$(__fsel)" + local ret=$? + zle reset-prompt + return $ret +} +zle -N fzf-file-widget +bindkey -M emacs '^T' fzf-file-widget +bindkey -M vicmd '^T' fzf-file-widget +bindkey -M viins '^T' fzf-file-widget + +# ALT-C - cd into the selected directory +fzf-cd-widget() { + local cmd="${FZF_ALT_C_COMMAND:-"command find -L . -mindepth 1 \\( -path '*/.*' -o -fstype 'sysfs' -o -fstype 'devfs' -o -fstype 'devtmpfs' -o -fstype 'proc' \\) -prune \ + -o -type d -print 2> /dev/null | cut -b3-"}" + setopt localoptions pipefail no_aliases 2> /dev/null + local dir="$(eval "$cmd" | FZF_DEFAULT_OPTS="--height ${FZF_TMUX_HEIGHT:-40%} --reverse --scheme=path --bind=ctrl-z:ignore ${FZF_DEFAULT_OPTS-} ${FZF_ALT_C_OPTS-}" $(__fzfcmd) +m)" + if [[ -z "$dir" ]]; then + zle redisplay + return 0 + fi + zle push-line # Clear buffer. Auto-restored on next prompt. + BUFFER="builtin cd -- ${(q)dir}" + zle accept-line + local ret=$? + unset dir # ensure this doesn't end up appearing in prompt expansion + zle reset-prompt + return $ret +} +zle -N fzf-cd-widget +bindkey -M emacs '\ec' fzf-cd-widget +bindkey -M vicmd '\ec' fzf-cd-widget +bindkey -M viins '\ec' fzf-cd-widget + +# CTRL-R - Paste the selected command from history into the command line +fzf-history-widget() { + local selected num + setopt localoptions noglobsubst noposixbuiltins pipefail no_aliases 2> /dev/null + selected=( $(fc -rl 1 | awk '{ cmd=$0; sub(/^[ \t]*[0-9]+\**[ \t]+/, "", cmd); if (!seen[cmd]++) print $0 }' | + FZF_DEFAULT_OPTS="--height ${FZF_TMUX_HEIGHT:-40%} ${FZF_DEFAULT_OPTS-} -n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,ctrl-z:ignore ${FZF_CTRL_R_OPTS-} --query=${(qqq)LBUFFER} +m" $(__fzfcmd)) ) + local ret=$? + if [ -n "$selected" ]; then + num=$selected[1] + if [ -n "$num" ]; then + zle vi-fetch-history -n $num + fi + fi + zle reset-prompt + return $ret +} +zle -N fzf-history-widget +bindkey -M emacs '^R' fzf-history-widget +bindkey -M vicmd '^R' fzf-history-widget +bindkey -M viins '^R' fzf-history-widget + +} always { + eval $__fzf_key_bindings_options + 'unset' '__fzf_key_bindings_options' +} diff --git a/crates/integration/tests/integration/shell_integration_tests.rs b/crates/integration/tests/integration/shell_integration_tests.rs index 9c05ff860b6..f7cfde3c34c 100644 --- a/crates/integration/tests/integration/shell_integration_tests.rs +++ b/crates/integration/tests/integration/shell_integration_tests.rs @@ -123,6 +123,11 @@ integration_tests! { test_command_search_loads_history, test_histfile_left_joined_with_persisted_history, + // Live fzf ctrl-r / ctrl-t handoff. Skipped unless fzf is installed; runs on bash, zsh, fish. + test_fzf_ctrl_r_selects_history_unexecuted, + test_fzf_ctrl_t_inserts_selection, + test_fzf_ctrl_r_cancel_restores_draft, + // Tests default prompt behavior. test_context_chips_prompt_at_bootstrap, From bd0cddcf32c0e4ba3b67b8bc10962dcf45e2e338 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 19:15:31 +0000 Subject: [PATCH 40/70] Use command::blocking::Command in fzf integration tests. std::process::Command is disallowed by clippy in this crate. --- crates/integration/src/test/shell_widget_handoff.rs | 2 +- crates/integration/src/util.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/integration/src/test/shell_widget_handoff.rs b/crates/integration/src/test/shell_widget_handoff.rs index 9101524ff73..af71b5e2b6c 100644 --- a/crates/integration/src/test/shell_widget_handoff.rs +++ b/crates/integration/src/test/shell_widget_handoff.rs @@ -2,9 +2,9 @@ //! shell is not bash/zsh/fish (the shells fzf ships key-bindings for). use std::path::{Path, PathBuf}; -use std::process::Command; use std::time::Duration; +use command::blocking::Command; use warp::features::FeatureFlag; use warp::integration_testing; use warp::integration_testing::step::new_step_with_default_assertions; diff --git a/crates/integration/src/util.rs b/crates/integration/src/util.rs index f5afc96cd7e..cb0c16031aa 100644 --- a/crates/integration/src/util.rs +++ b/crates/integration/src/util.rs @@ -1,8 +1,8 @@ use std::fs::{OpenOptions, create_dir_all, write}; use std::io::Write; use std::path::{Path, PathBuf}; -use std::process::Command; +use command::blocking::Command; use itertools::Itertools as _; use strum::IntoEnumIterator; use strum_macros::EnumIter; From 2ae42a4cecbc03606cc2c28029e2cf791318a4f1 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 19:36:31 +0000 Subject: [PATCH 41/70] Harden fzf integration tests for bash helper preexec. Wait for the editor to hide rather than requiring is_executing, and send the handoff key in the same step as that wait. --- .../src/test/shell_widget_handoff.rs | 65 ++++++++++--------- 1 file changed, 34 insertions(+), 31 deletions(-) diff --git a/crates/integration/src/test/shell_widget_handoff.rs b/crates/integration/src/test/shell_widget_handoff.rs index af71b5e2b6c..f3d65c563ac 100644 --- a/crates/integration/src/test/shell_widget_handoff.rs +++ b/crates/integration/src/test/shell_widget_handoff.rs @@ -12,8 +12,8 @@ use warp::integration_testing::terminal::util::{ ExpectedExitStatus, current_shell_starter_and_version, }; use warp::integration_testing::terminal::{ - assert_input_editor_contents, assert_long_running_block_executing_for_single_terminal_in_tab, - execute_command_for_single_terminal_in_tab, wait_until_bootstrapped_single_pane_for_tab, + assert_input_editor_contents, execute_command_for_single_terminal_in_tab, + wait_until_bootstrapped_single_pane_for_tab, }; use warp::integration_testing::view_getters::{ single_input_view_for_tab, single_terminal_view_for_tab, workspace_view, @@ -206,8 +206,9 @@ fn assert_input_contains(text: &'static str) -> AssertionCallback { }) } -fn wait_for_fzf() -> TestStep { +fn open_fzf(key: &'static str) -> TestStep { TestStep::new("Wait for fzf to take over the PTY") + .with_keystrokes(&[key]) .set_timeout(FZF_STEP_TIMEOUT) .add_named_assertion( "command search stayed closed", @@ -215,10 +216,34 @@ fn wait_for_fzf() -> TestStep { ) .add_named_assertion( "fzf is running as a long-running command", - assert_long_running_block_executing_for_single_terminal_in_tab(true, 0), + assert_fzf_is_running(), ) } +fn assert_fzf_is_running() -> AssertionCallback { + Box::new(move |app, window_id| { + let terminal_view = single_terminal_view_for_tab(app, window_id, 0); + terminal_view.read(app, |view, _ctx| { + let is_editor_focused = view + .input() + .read(app, |input, ctx| input.editor().is_focused(ctx)); + let buffer = view + .input() + .read(app, |input, ctx| input.buffer_text(ctx)); + let model = view.model.lock(); + let active_block = model.block_list().active_block(); + let output = active_block.output_to_string(); + let long_running = active_block.is_active_and_long_running(); + // bash may not emit preexec for the leading-space helper invocation, so do not + // require is_executing(); the editor hiding and long-running block are the handoff. + async_assert!( + !is_editor_focused && long_running, + "expected fzf long-running; editor_focused={is_editor_focused} long_running={long_running} buffer={buffer:?} output={output:?}" + ) + }) + }) +} + /// ctrl-r opens the real fzf history picker and lands the selected command in the editor /// unexecuted. pub fn test_fzf_ctrl_r_selects_history_unexecuted() -> Builder { @@ -242,12 +267,7 @@ pub fn test_fzf_ctrl_r_selects_history_unexecuted() -> Builder { ExpectedExitStatus::Success, CTRL_R_HISTORY_OUTPUT, )) - .with_step( - TestStep::new("Press ctrl-r") - .with_keystrokes(&["ctrl-r"]) - .set_timeout(FZF_STEP_TIMEOUT), - ) - .with_step(wait_for_fzf()) + .with_step(open_fzf("ctrl-r")) .with_step( TestStep::new("Filter to the unique history entry") .with_typed_characters(&[CTRL_R_HISTORY_OUTPUT]) @@ -296,28 +316,16 @@ pub fn test_fzf_ctrl_t_inserts_selection() -> Builder { .with_step( new_step_with_default_assertions("Type a prefix so splice vs replace is observable") .with_typed_characters(&[CTRL_T_PREFIX]) + .with_keystrokes(&["escape"]) .add_named_assertion( "prefix is in the input", assert_input_editor_contents(0, CTRL_T_PREFIX), ), ) + .with_step(open_fzf("ctrl-t")) .with_step( - TestStep::new("Press ctrl-t") - .with_keystrokes(&["ctrl-t"]) - .set_timeout(FZF_STEP_TIMEOUT), - ) - .with_step(wait_for_fzf()) - .with_step( - TestStep::new("Filter to the unique file") + TestStep::new("Filter to the unique file and accept") .with_typed_characters(&[CTRL_T_FILENAME]) - .set_timeout(FZF_STEP_TIMEOUT) - .add_named_assertion( - "fzf lists the unique file", - assert_fzf_shows(CTRL_T_FILENAME), - ), - ) - .with_step( - TestStep::new("Accept the fzf selection") .with_keystrokes(&["enter"]) .set_timeout(FZF_STEP_TIMEOUT), ) @@ -374,12 +382,7 @@ pub fn test_fzf_ctrl_r_cancel_restores_draft() -> Builder { assert_input_editor_contents(0, CTRL_R_DRAFT), ), ) - .with_step( - TestStep::new("Press ctrl-r") - .with_keystrokes(&["ctrl-r"]) - .set_timeout(FZF_STEP_TIMEOUT), - ) - .with_step(wait_for_fzf()) + .with_step(open_fzf("ctrl-r")) .with_step( TestStep::new("Cancel fzf") .with_keystrokes(&["escape"]) From bdfa6b75c978a00df26523fe200b52dded03add3 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:13:10 +0000 Subject: [PATCH 42/70] Assert fzf accept does not execute the selected command. Count finished blocks whose command matches the selection so a regression that runs it still fails even if the editor text looks right. --- .../src/test/shell_widget_handoff.rs | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/integration/src/test/shell_widget_handoff.rs b/crates/integration/src/test/shell_widget_handoff.rs index f3d65c563ac..bec9f1e2773 100644 --- a/crates/integration/src/test/shell_widget_handoff.rs +++ b/crates/integration/src/test/shell_widget_handoff.rs @@ -19,8 +19,8 @@ use warp::integration_testing::view_getters::{ single_input_view_for_tab, single_terminal_view_for_tab, workspace_view, }; use warp::terminal::shell::ShellType; -use warpui_core::async_assert; use warpui_core::integration::{AssertionCallback, TestStep}; +use warpui_core::{async_assert, async_assert_eq}; use super::{TEST_ONLY_ASSETS, new_builder}; use crate::Builder; @@ -193,6 +193,26 @@ fn assert_fzf_shows(text: &'static str) -> AssertionCallback { }) } +fn assert_finished_command_count(needle: &'static str, expected: usize) -> AssertionCallback { + Box::new(move |app, window_id| { + let terminal_view = single_terminal_view_for_tab(app, window_id, 0); + terminal_view.read(app, |view, _ctx| { + let model = view.model.lock(); + let count = model + .block_list() + .blocks() + .iter() + .filter(|block| block.finished() && block.command_to_string().contains(needle)) + .count(); + async_assert_eq!( + count, + expected, + "expected {expected} finished command(s) containing {needle:?}, found {count}" + ) + }) + }) +} + fn assert_input_contains(text: &'static str) -> AssertionCallback { Box::new(move |app, window_id| { let input = single_input_view_for_tab(app, window_id, 0); @@ -290,6 +310,10 @@ pub fn test_fzf_ctrl_r_selects_history_unexecuted() -> Builder { "command search stayed closed", assert_command_search_is_closed(), ) + .add_named_assertion( + "selected command was not executed again", + assert_finished_command_count(CTRL_R_HISTORY_COMMAND, 1), + ) .add_named_assertion( "input contains the selected command", assert_input_editor_contents(0, CTRL_R_HISTORY_COMMAND), @@ -336,6 +360,10 @@ pub fn test_fzf_ctrl_t_inserts_selection() -> Builder { "input contains the selected filename", assert_input_contains(CTRL_T_FILENAME), ) + .add_named_assertion( + "selected file was not executed as a command", + assert_finished_command_count(CTRL_T_FILENAME, 0), + ) .add_named_assertion("prefix is preserved on bash/zsh", move |app, window_id| { if is_fish { return warpui_core::integration::AssertionOutcome::Success; From e45376f72409f45007100252cd66523259ddc85b Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:15:16 +0000 Subject: [PATCH 43/70] Detect fzf ctrl-r/ctrl-t on bash 5.3 bind -X output. bash 5.3 prints bind -X as `"\C-r" "widget"` (space) instead of `"\C-r": "widget"` (colon). The extractor required a colon, so plugin tags were empty on macOS brew bash 5.3 even though fzf bindings installed. Accept either separator, parse the live pipeline in unit tests instead of copying it, and panic if bind -X lists a probe the extractor misses. --- app/assets/bundled/bootstrap/bash_body.sh | 9 +- crates/warp_terminal/src/bootstrap_tests.rs | 108 +++++++++++++++----- 2 files changed, 85 insertions(+), 32 deletions(-) diff --git a/app/assets/bundled/bootstrap/bash_body.sh b/app/assets/bundled/bootstrap/bash_body.sh index 0fe26a7c8dd..dfd9bfe3ed1 100644 --- a/app/assets/bundled/bootstrap/bash_body.sh +++ b/app/assets/bundled/bootstrap/bash_body.sh @@ -1614,8 +1614,9 @@ esac # Warp's own command search. # # Both fzf and (older versions of) atuin bind ctrl-r directly via `bind -x`, so `bind -X` - # reports it verbatim (e.g. `"\C-r": "__fzf_history__"`); the sed below extracts the bound - # command's name. Match against an exact allowlist of each integration's canonical bound + # reports it verbatim. bash 5.2 prints `"\C-r": "__fzf_history__"`; bash 5.3 prints + # `"\C-r" "__fzf_history__"` (space, no colon). The sed below accepts either layout. + # Match against an exact allowlist of each integration's canonical bound # function name -- not merely a name containing "fzf" or "atuin" -- since an RC can # legitimately bind ctrl-r to an unrelated fzf- or atuin-flavored command that isn't the # history search warp_run_external_ctrl_r_widget below knows how to invoke; rerouting that to @@ -1629,7 +1630,7 @@ esac # from an arbitrary user macro; the fallback below handles that case. _WARP_EXTERNAL_CTRL_R_WIDGET="" if [ "$WARP_IN_MSYS2" = false ]; then - warp_ctrl_r_binding="$(bind -X 2>/dev/null | command -p sed -n 's/^"\\C-r": "\(.*\)"$/\1/p')" + warp_ctrl_r_binding="$(bind -X 2>/dev/null | command -p sed -n 's/^"\\C-r"[ :] *"\(.*\)"$/\1/p')" case "$warp_ctrl_r_binding" in __fzf_history__|__atuin_history) _WARP_EXTERNAL_CTRL_R_WIDGET="$warp_ctrl_r_binding" @@ -1658,7 +1659,7 @@ esac # command substitution; that asymmetry is why ctrl-t doesn't need a flag-based fallback # the way ctrl-r's newer-atuin case does. atuin has no ctrl-t equivalent. _WARP_EXTERNAL_CTRL_T_WIDGET="" - warp_ctrl_t_binding="$(bind -X 2>/dev/null | command -p sed -n 's/^"\\C-t": "\(.*\)"$/\1/p')" + warp_ctrl_t_binding="$(bind -X 2>/dev/null | command -p sed -n 's/^"\\C-t"[ :] *"\(.*\)"$/\1/p')" case "$warp_ctrl_t_binding" in fzf-file-widget) # The bind -X entry has stayed "fzf-file-widget" across fzf versions, but only tag/ diff --git a/crates/warp_terminal/src/bootstrap_tests.rs b/crates/warp_terminal/src/bootstrap_tests.rs index f8a6620a89a..45758c84793 100644 --- a/crates/warp_terminal/src/bootstrap_tests.rs +++ b/crates/warp_terminal/src/bootstrap_tests.rs @@ -211,40 +211,49 @@ fn run_bash(script: &str) -> Option { Some(String::from_utf8_lossy(&output.stdout).into_owned()) } -/// The `bind -X` extraction pipeline the ctrl-t detection is built on, mirrored from -/// `bash_body.sh` so the gate below can exercise the same capability the detection needs. -/// `bash_can_extract_ctrl_t_binding` asserts the snippet still contains this, so the two cannot -/// drift apart silently. -const BIND_DASH_X_EXTRACTION: &str = - r#"bind -X 2>/dev/null | command -p sed -n 's/^"\\C-t": "\(.*\)"$/\1/p'"#; - -/// Whether this environment can read a `bind -x` binding back out through the pipeline above, run -/// the way the tests below run it. `None` if bash isn't installed at all, mirroring `run_bash`'s -/// "shell missing" skip convention. +fn bash_ctrl_t_bind_x_extraction() -> &'static str { + let snippet = bash_ctrl_t_detection_snippet(); + let start = snippet + .find("bind -X 2>/dev/null | command -p sed") + .expect("ctrl-t detection should pipe bind -X through sed"); + let rest = &snippet[start..]; + let quote = rest + .rfind('\'') + .expect("ctrl-t bind -X sed program should be single-quoted"); + &rest[..=quote] +} + +fn bash_ctrl_t_bind_x_sed_program() -> &'static str { + let extraction = bash_ctrl_t_bind_x_extraction(); + let prefix = "sed -n '"; + let start = extraction + .find(prefix) + .expect("extraction should invoke sed -n") + + prefix.len(); + let end = extraction[start..] + .find("'") + .expect("sed program should be single-quoted"); + &extraction[start..start + end] +} + +/// Whether this environment can read a `bind -x` binding back out through the pipeline in +/// `bash_body.sh`, run the way the tests below run it. `None` if bash isn't installed at all, +/// mirroring `run_bash`'s "shell missing" skip convention. /// -/// Detection depends on that end-to-end, and it does not hold everywhere. Three separate things -/// can break it, all presenting identically as empty output: `bind -X` only arrived in bash 4.3 -/// (NEWS-4.3 item q); a non-interactive shell need not have line editing enabled, so the binding -/// is never listable however new bash is; and `command -p` forces the system utility PATH, so the -/// extraction runs under BSD sed on macOS rather than GNU sed. Probing the pipeline covers all -/// three, where checking a version covers only the first and checking a raw listing only the -/// first two. +/// Skip only when `bind -X` does not list the probe at all: `bind -X` arrived in bash 4.3, and a +/// non-interactive shell need not have line editing enabled, so the binding is never listable. +/// If `bind -X` lists the probe but extraction is empty, panic -- that is a broken product +/// extractor, not a reason to skip. /// /// Deliberately probes with a sentinel widget name rather than `fzf-file-widget`, so it tests the /// capability without also asserting the `case` match the tests below exist to check -- otherwise /// the gate would subsume the assertion and the tests could never fail. -/// -/// Where extraction fails, those tests would either fail (the "tags" case) or pass vacuously -/// without exercising the absent-function branch at all (the "declines" case just happens to -/// expect the same empty result unusable extraction always produces). Skip both rather than let -/// the latter masquerade as real coverage. fn bash_can_extract_ctrl_t_binding() -> Option { - assert!( - bash_ctrl_t_detection_snippet().contains(BIND_DASH_X_EXTRACTION), - "BIND_DASH_X_EXTRACTION no longer matches bash_body.sh's detection pipeline" + let extraction = bash_ctrl_t_bind_x_extraction(); + let script = format!( + r#"bind -x '"\C-t": warp_bind_x_probe' 2>/dev/null; printf 'EXTRACTED:%s\n' "$({extraction})"; bind -X 2>/dev/null"# ); - let script = - format!("bind -x '\"\\C-t\": warp_bind_x_probe' 2>/dev/null; {BIND_DASH_X_EXTRACTION}"); + let output = match command::blocking::Command::new("bash") .args(["--noprofile", "--norc", "-c", &script]) .output() @@ -253,7 +262,50 @@ fn bash_can_extract_ctrl_t_binding() -> Option { Err(error) if error.kind() == std::io::ErrorKind::NotFound => return None, Err(error) => panic!("failed to run bash: {error}"), }; - Some(String::from_utf8_lossy(&output.stdout).trim() == "warp_bind_x_probe") + let stdout = String::from_utf8_lossy(&output.stdout); + let mut lines = stdout.lines(); + let extracted = lines + .next() + .unwrap_or("") + .strip_prefix("EXTRACTED:") + .unwrap_or("") + .trim(); + let raw: String = lines.collect(); + if extracted == "warp_bind_x_probe" { + return Some(true); + } + if raw.contains("warp_bind_x_probe") { + panic!( + "bind -X listed warp_bind_x_probe but bash_body.sh extraction returned {extracted:?}; raw bind -X: {raw:?}" + ); + } + Some(false) +} + +/// The ctrl-t `bind -X` sed from `bash_body.sh` must accept both bash 5.2 colon and bash 5.3 +/// space layouts. Does not need `bind -X` or an interactive shell. +#[test] +fn test_bash_bind_x_extraction_accepts_colon_and_space_formats() { + let sed = bash_ctrl_t_bind_x_sed_program(); + let script = format!( + r#"colon=$(printf '%s\n' '"\C-t": "fzf-file-widget"' | command -p sed -n '{sed}'); space=$(printf '%s\n' '"\C-t" "fzf-file-widget"' | command -p sed -n '{sed}'); printf 'colon=[%s] space=[%s]\n' "$colon" "$space""# + ); + + let Some(stdout) = run_bash(&script) else { + return; + }; + assert!( + stdout.contains("colon=[fzf-file-widget]"), + "colon layout should extract; got {stdout:?}" + ); + assert!( + stdout.contains("space=[fzf-file-widget]"), + "space layout should extract; got {stdout:?}" + ); + assert!( + sed.contains("[ :]"), + "ctrl-t bind -X sed must accept colon or space; got {sed:?}" + ); } /// Regression test for the ctrl-t equivalent of bash's `declare -F __atuin_history` guard on the From a9bddde8dc710fb885c2cd7a1b421b3c3d37dd56 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:41:52 +0000 Subject: [PATCH 44/70] Skip Windows WSL bash stub in bind -X unit tests. Windows CI's `bash` is the WSL launcher: it exists, so NotFound never fires, then exits 1. Probe with a sentinel before running scripts so that stub skips, while a real bash that fails a later script still panics. The sed-pattern assertion always runs; only the live bash extraction is skipped. --- crates/warp_terminal/src/bootstrap_tests.rs | 53 +++++++++++++-------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/crates/warp_terminal/src/bootstrap_tests.rs b/crates/warp_terminal/src/bootstrap_tests.rs index 45758c84793..0aeb5614e25 100644 --- a/crates/warp_terminal/src/bootstrap_tests.rs +++ b/crates/warp_terminal/src/bootstrap_tests.rs @@ -192,15 +192,36 @@ fn bash_ctrl_t_detection_snippet() -> &'static str { &BASH_SH[start..start + end + end_marker.len()] } -fn run_bash(script: &str) -> Option { - let output = match command::blocking::Command::new("bash") +fn spawn_bash(script: &str) -> Option { + match command::blocking::Command::new("bash") .args(["--noprofile", "--norc", "-c", script]) .output() { - Ok(output) => output, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return None, + Ok(output) => Some(output), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, Err(error) => panic!("failed to run bash: {error}"), - }; + } +} + +/// Windows CI's `bash` is the WSL launcher stub: it exists, so `NotFound` never fires, then +/// prints "no installed distributions" and exits 1. Require a sentinel so that stub is a skip +/// and a real bash that exits non-zero on a later script is still a failure. +fn posix_bash_is_usable() -> bool { + static USABLE: std::sync::OnceLock = std::sync::OnceLock::new(); + *USABLE.get_or_init(|| { + let Some(output) = spawn_bash("printf 'WARP_POSIX_BASH\\n'") else { + return false; + }; + output.status.success() + && String::from_utf8_lossy(&output.stdout).contains("WARP_POSIX_BASH") + }) +} + +fn run_bash(script: &str) -> Option { + if !posix_bash_is_usable() { + return None; + } + let output = spawn_bash(script)?; assert!( output.status.success(), "bash exited with {:?}\nstdout:\n{}\nstderr:\n{}", @@ -249,19 +270,14 @@ fn bash_ctrl_t_bind_x_sed_program() -> &'static str { /// capability without also asserting the `case` match the tests below exist to check -- otherwise /// the gate would subsume the assertion and the tests could never fail. fn bash_can_extract_ctrl_t_binding() -> Option { + if !posix_bash_is_usable() { + return None; + } let extraction = bash_ctrl_t_bind_x_extraction(); let script = format!( r#"bind -x '"\C-t": warp_bind_x_probe' 2>/dev/null; printf 'EXTRACTED:%s\n' "$({extraction})"; bind -X 2>/dev/null"# ); - - let output = match command::blocking::Command::new("bash") - .args(["--noprofile", "--norc", "-c", &script]) - .output() - { - Ok(output) => output, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return None, - Err(error) => panic!("failed to run bash: {error}"), - }; + let output = spawn_bash(&script)?; let stdout = String::from_utf8_lossy(&output.stdout); let mut lines = stdout.lines(); let extracted = lines @@ -287,10 +303,13 @@ fn bash_can_extract_ctrl_t_binding() -> Option { #[test] fn test_bash_bind_x_extraction_accepts_colon_and_space_formats() { let sed = bash_ctrl_t_bind_x_sed_program(); + assert!( + sed.contains("[ :]"), + "ctrl-t bind -X sed must accept colon or space; got {sed:?}" + ); let script = format!( r#"colon=$(printf '%s\n' '"\C-t": "fzf-file-widget"' | command -p sed -n '{sed}'); space=$(printf '%s\n' '"\C-t" "fzf-file-widget"' | command -p sed -n '{sed}'); printf 'colon=[%s] space=[%s]\n' "$colon" "$space""# ); - let Some(stdout) = run_bash(&script) else { return; }; @@ -302,10 +321,6 @@ fn test_bash_bind_x_extraction_accepts_colon_and_space_formats() { stdout.contains("space=[fzf-file-widget]"), "space layout should extract; got {stdout:?}" ); - assert!( - sed.contains("[ :]"), - "ctrl-t bind -X sed must accept colon or space; got {sed:?}" - ); } /// Regression test for the ctrl-t equivalent of bash's `declare -F __atuin_history` guard on the From d1275cc0c0c7e11e4654c704ab21fe97a1dbfd5b Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 23:56:40 +0000 Subject: [PATCH 45/70] Move ShellWidgetHandoff from default/dogfood into Preview. The requester asked that this not ship enabled on all default builds. Remove it from app/Cargo.toml default and DOGFOOD_FLAGS; add it to PREVIEW_FLAGS (which still enables dogfood). Leave the enabled_features() cfg bridge so `--features shell_widget_handoff` remains a force-enable hatch. --- app/Cargo.toml | 1 - crates/warp_features/src/lib.rs | 6 ++++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/Cargo.toml b/app/Cargo.toml index 2f3ddb06d2c..957d9b2e6cb 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -710,7 +710,6 @@ default = [ "orchestration_unified_stack", "ime_marked_text", "ctrl_c_cancels_third_party_harness", - "shell_widget_handoff", ] # Enable this feature to automatically perform heap profiling. NOTE: This will # substantially slow down program execution. diff --git a/crates/warp_features/src/lib.rs b/crates/warp_features/src/lib.rs index 75eaf280ca3..959946b2fcd 100644 --- a/crates/warp_features/src/lib.rs +++ b/crates/warp_features/src/lib.rs @@ -1064,13 +1064,15 @@ pub const DOGFOOD_FLAGS: &[FeatureFlag] = &[ FeatureFlag::PeriodicHandoffCheckpoints, FeatureFlag::CtrlCCancelsThirdPartyHarness, FeatureFlag::WarpingModelName, - FeatureFlag::ShellWidgetHandoff, FeatureFlag::LrcActivitySignal, ]; /// Features enabled for feature preview build users (e.g.: Friends of Warp). /// All PREVIEW_FLAGS are also automatically added to dogfood builds (WarpDev). -pub const PREVIEW_FLAGS: &[FeatureFlag] = &[FeatureFlag::NativeShellCompletions]; +pub const PREVIEW_FLAGS: &[FeatureFlag] = &[ + FeatureFlag::NativeShellCompletions, + FeatureFlag::ShellWidgetHandoff, +]; /// Features enabled for all release builds (i.e.: everything but WarpLocal). /// NOTE: if you are promoting a feature from Preview to launch, you'll likely From fde719edc3d8bb2f0efbea2fbf180f909955eadc Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:36:11 +0000 Subject: [PATCH 46/70] Retry empty fish commandline read after fzf-history-widget. macOS fish CI failed test_fzf_ctrl_r_selects_history_unexecuted with an empty editor after a successful handoff. fzf 0.74.3's widget ends on commandline -f repaint; a too-soon commandline read comes back empty, and Warp treats empty as cancel. Retry the read once, and clear the buffer first so 0.74's (commandline) --query does not filter to the helper invocation. --- app/assets/bundled/bootstrap/fish.sh | 10 ++++ crates/warp_terminal/src/bootstrap_tests.rs | 52 +++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/app/assets/bundled/bootstrap/fish.sh b/app/assets/bundled/bootstrap/fish.sh index 0ec2d1f4477..f9a7587094b 100644 --- a/app/assets/bundled/bootstrap/fish.sh +++ b/app/assets/bundled/bootstrap/fish.sh @@ -613,11 +613,21 @@ function warp_run_external_ctrl_r_widget switch "$_WARP_EXTERNAL_CTRL_R_WIDGET" case 'fzf-history-widget' test -z "$fish_private_mode"; and builtin history merge + # fzf 0.74+'s widget uses `(commandline)` as --query. This helper runs as a synthetic + # command, so clear the buffer first: otherwise the picker filters to the helper invocation + # instead of opening like an idle prompt. + commandline -r -- '' fzf-history-widget # Piped through `string collect`: a multi-line selection would otherwise make this `set`'s # own command substitution split it into a list, which `warp_escape_json` below would then # silently space-join instead of newline-join once quoted back down to a single argument. + # + # Same settle as warp_run_external_ctrl_t_widget: fzf-history-widget ends on + # `commandline -f repaint`, and a too-soon `commandline` read can come back empty after a + # real accept. Warp treats empty as cancel (restores the pre-ctrl-r draft, usually empty). + # Assign first; if that is empty, read once more. set result (commandline | string collect) + test -n "$result"; or set result (commandline | string collect) commandline -r '' case '_atuin_search' # atuin writes its TUI to stdout and the selection to fd 3, so the two are swapped here to diff --git a/crates/warp_terminal/src/bootstrap_tests.rs b/crates/warp_terminal/src/bootstrap_tests.rs index 0aeb5614e25..a4327cdfef0 100644 --- a/crates/warp_terminal/src/bootstrap_tests.rs +++ b/crates/warp_terminal/src/bootstrap_tests.rs @@ -409,6 +409,9 @@ function warp_send_json_message end set -g _test_commandline_value '' function commandline + if test (count $argv) -ge 1; and test "$argv[1]" = '-r' + return 0 + end echo "$_test_commandline_value" end function fzf-history-widget @@ -452,6 +455,52 @@ fn test_fish_ctrl_r_widget_reports_empty_buffer_when_widget_leaves_commandline_u assert!(stdout.contains(r#""buffer": """#), "{stdout}"); } +/// fzf 0.74+'s `fzf-history-widget` ends on `commandline -f repaint`. A too-soon `commandline` +/// read after a real accept can come back empty; Warp treats empty as cancel and restores the +/// pre-ctrl-r draft (usually empty), so the editor looks like the selection never landed. The +/// wrapper must retry that read once rather than reporting the empty first read. +#[test] +fn test_fish_ctrl_r_widget_retries_empty_commandline_read_after_accept() { + let runner = fish_ctrl_r_widget_runner_fn(); + let script = format!( + r#" +function warp_escape_json + string join \n $argv +end +function warp_send_json_message + echo "$argv" +end +set -g _test_commandline_value 'echo selected_after_repaint' +set -g _test_commandline_reads 0 +function commandline + if test (count $argv) -ge 1; and test "$argv[1]" = '-r' + return 0 + end + set -g _test_commandline_reads (math $_test_commandline_reads + 1) + if test $_test_commandline_reads -eq 1 + echo '' + return + end + echo "$_test_commandline_value" +end +function fzf-history-widget + true +end +set -g _WARP_EXTERNAL_CTRL_R_WIDGET fzf-history-widget +set -g WARP_SESSION_ID 12345 +{runner} +warp_run_external_ctrl_r_widget test-token +"# + ); + let Some(stdout) = run_fish(&script) else { + return; + }; + assert!( + stdout.contains(r#""buffer": "echo selected_after_repaint""#), + "{stdout}" + ); +} + fn fish_warp_escape_json_fn() -> &'static str { const FISH_SH: &str = include_str!("../../../app/assets/bundled/bootstrap/fish.sh"); let start_marker = "function warp_escape_json\n"; @@ -483,6 +532,9 @@ function warp_send_json_message end set -g _test_commandline_value '' function commandline + if test (count $argv) -ge 1; and test "$argv[1]" = '-r' + return 0 + end echo "$_test_commandline_value" end function fzf-history-widget From f26515a65c64746ebb630604fbb032d4356816c6 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 01:08:33 +0000 Subject: [PATCH 47/70] EXPERIMENT: revert ShellWidgetHandoff Preview demotion only. Temporary CI bisect. Keep the fish ctrl-r settle changes. Restore shell_widget_handoff to Cargo default and ShellWidgetHandoff to DOGFOOD_FLAGS (remove from PREVIEW_FLAGS). Do not ship. --- app/Cargo.toml | 1 + crates/warp_features/src/lib.rs | 6 ++---- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/Cargo.toml b/app/Cargo.toml index 957d9b2e6cb..2f3ddb06d2c 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -710,6 +710,7 @@ default = [ "orchestration_unified_stack", "ime_marked_text", "ctrl_c_cancels_third_party_harness", + "shell_widget_handoff", ] # Enable this feature to automatically perform heap profiling. NOTE: This will # substantially slow down program execution. diff --git a/crates/warp_features/src/lib.rs b/crates/warp_features/src/lib.rs index 959946b2fcd..75eaf280ca3 100644 --- a/crates/warp_features/src/lib.rs +++ b/crates/warp_features/src/lib.rs @@ -1064,15 +1064,13 @@ pub const DOGFOOD_FLAGS: &[FeatureFlag] = &[ FeatureFlag::PeriodicHandoffCheckpoints, FeatureFlag::CtrlCCancelsThirdPartyHarness, FeatureFlag::WarpingModelName, + FeatureFlag::ShellWidgetHandoff, FeatureFlag::LrcActivitySignal, ]; /// Features enabled for feature preview build users (e.g.: Friends of Warp). /// All PREVIEW_FLAGS are also automatically added to dogfood builds (WarpDev). -pub const PREVIEW_FLAGS: &[FeatureFlag] = &[ - FeatureFlag::NativeShellCompletions, - FeatureFlag::ShellWidgetHandoff, -]; +pub const PREVIEW_FLAGS: &[FeatureFlag] = &[FeatureFlag::NativeShellCompletions]; /// Features enabled for all release builds (i.e.: everything but WarpLocal). /// NOTE: if you are promoting a feature from Preview to launch, you'll likely From 4e3f0934557e57f2ffb2227cc27b19a9544c7b8f Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 01:46:15 +0000 Subject: [PATCH 48/70] Wire Preview flag sets into the integration channel. The integration binary's ChannelState had no additional_features, so ShellWidgetHandoff was only on at init_feature_flags when it sat in Cargo default. Wire DEBUG/DOGFOOD/PREVIEW like dev.rs so tests exercise the shipped configuration. Drop the speculative fish commandline retry. Keep the Preview demotion; do not restore the flag to default. --- app/Cargo.toml | 1 - app/assets/bundled/bootstrap/fish.sh | 10 --- app/src/bin/integration.rs | 71 +++++++++++---------- crates/integration/src/bin/integration.rs | 71 +++++++++++---------- crates/warp_features/src/lib.rs | 6 +- crates/warp_terminal/src/bootstrap_tests.rs | 52 --------------- 6 files changed, 80 insertions(+), 131 deletions(-) diff --git a/app/Cargo.toml b/app/Cargo.toml index 2f3ddb06d2c..957d9b2e6cb 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -710,7 +710,6 @@ default = [ "orchestration_unified_stack", "ime_marked_text", "ctrl_c_cancels_third_party_harness", - "shell_widget_handoff", ] # Enable this feature to automatically perform heap profiling. NOTE: This will # substantially slow down program execution. diff --git a/app/assets/bundled/bootstrap/fish.sh b/app/assets/bundled/bootstrap/fish.sh index f9a7587094b..0ec2d1f4477 100644 --- a/app/assets/bundled/bootstrap/fish.sh +++ b/app/assets/bundled/bootstrap/fish.sh @@ -613,21 +613,11 @@ function warp_run_external_ctrl_r_widget switch "$_WARP_EXTERNAL_CTRL_R_WIDGET" case 'fzf-history-widget' test -z "$fish_private_mode"; and builtin history merge - # fzf 0.74+'s widget uses `(commandline)` as --query. This helper runs as a synthetic - # command, so clear the buffer first: otherwise the picker filters to the helper invocation - # instead of opening like an idle prompt. - commandline -r -- '' fzf-history-widget # Piped through `string collect`: a multi-line selection would otherwise make this `set`'s # own command substitution split it into a list, which `warp_escape_json` below would then # silently space-join instead of newline-join once quoted back down to a single argument. - # - # Same settle as warp_run_external_ctrl_t_widget: fzf-history-widget ends on - # `commandline -f repaint`, and a too-soon `commandline` read can come back empty after a - # real accept. Warp treats empty as cancel (restores the pre-ctrl-r draft, usually empty). - # Assign first; if that is empty, read once more. set result (commandline | string collect) - test -n "$result"; or set result (commandline | string collect) commandline -r '' case '_atuin_search' # atuin writes its TUI to stdout and the selection to fd 3, so the two are swapped here to diff --git a/app/src/bin/integration.rs b/app/src/bin/integration.rs index dae1762a345..63d206f25ba 100644 --- a/app/src/bin/integration.rs +++ b/app/src/bin/integration.rs @@ -1,8 +1,8 @@ use anyhow::Result; use clap::Parser; use warp_cli::WorkerCommand; -use warp_core::AppId; use warp_core::channel::{Channel, ChannelConfig, ChannelState, OzConfig, WarpServerConfig}; +use warp_core::{AppId, features}; #[derive(Debug, Default, Parser, Clone)] #[command(name = "warp-integration")] @@ -13,40 +13,45 @@ pub struct Args { } pub fn main() -> Result<()> { - ChannelState::set(ChannelState::new( - Channel::Integration, - ChannelConfig { - app_id: AppId::new( - "dev", - "warp", - if cfg!(target_os = "macos") { - "Warp-Integration" - } else { - "WarpIntegration" + ChannelState::set( + ChannelState::new( + Channel::Integration, + ChannelConfig { + app_id: AppId::new( + "dev", + "warp", + if cfg!(target_os = "macos") { + "Warp-Integration" + } else { + "WarpIntegration" + }, + ), + logfile_name: "warp_integration.log".into(), + server_config: WarpServerConfig { + firebase_auth_api_key: "".into(), + // Use an IP in the IANA testing range, with the TCP discard port, to + // black-hole server traffic. + server_root_url: "http://192.0.2.0:9".into(), + rtc_server_url: "ws://192.0.2.0:9/graphql/v2".into(), + session_sharing_server_url: None, + iap_config: None, }, - ), - logfile_name: "warp_integration.log".into(), - server_config: WarpServerConfig { - firebase_auth_api_key: "".into(), - // Use an IP in the IANA testing range, with the TCP discard port, to - // black-hole server traffic. - server_root_url: "http://192.0.2.0:9".into(), - rtc_server_url: "ws://192.0.2.0:9/graphql/v2".into(), - session_sharing_server_url: None, - iap_config: None, - }, - oz_config: OzConfig { - // Use an IP in the IANA testing range, with the TCP discard port, to - // black-hole server traffic. - oz_root_url: "http://192.0.2.0:9".into(), - workload_audience_url: None, + oz_config: OzConfig { + // Use an IP in the IANA testing range, with the TCP discard port, to + // black-hole server traffic. + oz_root_url: "http://192.0.2.0:9".into(), + workload_audience_url: None, + }, + telemetry_config: None, + crash_reporting_config: None, + autoupdate_config: None, + mcp_static_config: None, }, - telemetry_config: None, - crash_reporting_config: None, - autoupdate_config: None, - mcp_static_config: None, - }, - )); + ) + .with_additional_features(features::DEBUG_FLAGS) + .with_additional_features(features::DOGFOOD_FLAGS) + .with_additional_features(features::PREVIEW_FLAGS), + ); let args = Args::parse(); diff --git a/crates/integration/src/bin/integration.rs b/crates/integration/src/bin/integration.rs index 130172c9023..acf355d4b24 100644 --- a/crates/integration/src/bin/integration.rs +++ b/crates/integration/src/bin/integration.rs @@ -6,8 +6,8 @@ use clap::Parser; use integration::Builder; use integration::test::*; use warp_cli::WorkerCommand; -use warp_core::AppId; use warp_core::channel::{Channel, ChannelConfig, ChannelState, OzConfig, WarpServerConfig}; +use warp_core::{AppId, features}; /// The Warp integration test runner. #[derive(Debug, Default, Parser, Clone)] @@ -24,40 +24,45 @@ pub struct Args { } pub fn main() -> Result<()> { - ChannelState::set(ChannelState::new( - Channel::Integration, - ChannelConfig { - app_id: AppId::new( - "dev", - "warp", - if cfg!(target_os = "macos") { - "Warp-Integration" - } else { - "WarpIntegration" + ChannelState::set( + ChannelState::new( + Channel::Integration, + ChannelConfig { + app_id: AppId::new( + "dev", + "warp", + if cfg!(target_os = "macos") { + "Warp-Integration" + } else { + "WarpIntegration" + }, + ), + logfile_name: "warp_integration.log".into(), + server_config: WarpServerConfig { + firebase_auth_api_key: "".into(), + iap_config: None, + // Use an IP in the IANA testing range, with the TCP discard port, to + // black-hole server traffic. + server_root_url: "http://192.0.2.0:9".into(), + rtc_server_url: "ws://192.0.2.0:9/graphql/v2".into(), + session_sharing_server_url: None, }, - ), - logfile_name: "warp_integration.log".into(), - server_config: WarpServerConfig { - firebase_auth_api_key: "".into(), - iap_config: None, - // Use an IP in the IANA testing range, with the TCP discard port, to - // black-hole server traffic. - server_root_url: "http://192.0.2.0:9".into(), - rtc_server_url: "ws://192.0.2.0:9/graphql/v2".into(), - session_sharing_server_url: None, - }, - oz_config: OzConfig { - // Use an IP in the IANA testing range, with the TCP discard port, to - // black-hole server traffic. - oz_root_url: "http://192.0.2.0:9".into(), - workload_audience_url: None, + oz_config: OzConfig { + // Use an IP in the IANA testing range, with the TCP discard port, to + // black-hole server traffic. + oz_root_url: "http://192.0.2.0:9".into(), + workload_audience_url: None, + }, + telemetry_config: None, + crash_reporting_config: None, + autoupdate_config: None, + mcp_static_config: None, }, - telemetry_config: None, - crash_reporting_config: None, - autoupdate_config: None, - mcp_static_config: None, - }, - )); + ) + .with_additional_features(features::DEBUG_FLAGS) + .with_additional_features(features::DOGFOOD_FLAGS) + .with_additional_features(features::PREVIEW_FLAGS), + ); let args = Args::parse(); diff --git a/crates/warp_features/src/lib.rs b/crates/warp_features/src/lib.rs index 75eaf280ca3..959946b2fcd 100644 --- a/crates/warp_features/src/lib.rs +++ b/crates/warp_features/src/lib.rs @@ -1064,13 +1064,15 @@ pub const DOGFOOD_FLAGS: &[FeatureFlag] = &[ FeatureFlag::PeriodicHandoffCheckpoints, FeatureFlag::CtrlCCancelsThirdPartyHarness, FeatureFlag::WarpingModelName, - FeatureFlag::ShellWidgetHandoff, FeatureFlag::LrcActivitySignal, ]; /// Features enabled for feature preview build users (e.g.: Friends of Warp). /// All PREVIEW_FLAGS are also automatically added to dogfood builds (WarpDev). -pub const PREVIEW_FLAGS: &[FeatureFlag] = &[FeatureFlag::NativeShellCompletions]; +pub const PREVIEW_FLAGS: &[FeatureFlag] = &[ + FeatureFlag::NativeShellCompletions, + FeatureFlag::ShellWidgetHandoff, +]; /// Features enabled for all release builds (i.e.: everything but WarpLocal). /// NOTE: if you are promoting a feature from Preview to launch, you'll likely diff --git a/crates/warp_terminal/src/bootstrap_tests.rs b/crates/warp_terminal/src/bootstrap_tests.rs index a4327cdfef0..0aeb5614e25 100644 --- a/crates/warp_terminal/src/bootstrap_tests.rs +++ b/crates/warp_terminal/src/bootstrap_tests.rs @@ -409,9 +409,6 @@ function warp_send_json_message end set -g _test_commandline_value '' function commandline - if test (count $argv) -ge 1; and test "$argv[1]" = '-r' - return 0 - end echo "$_test_commandline_value" end function fzf-history-widget @@ -455,52 +452,6 @@ fn test_fish_ctrl_r_widget_reports_empty_buffer_when_widget_leaves_commandline_u assert!(stdout.contains(r#""buffer": """#), "{stdout}"); } -/// fzf 0.74+'s `fzf-history-widget` ends on `commandline -f repaint`. A too-soon `commandline` -/// read after a real accept can come back empty; Warp treats empty as cancel and restores the -/// pre-ctrl-r draft (usually empty), so the editor looks like the selection never landed. The -/// wrapper must retry that read once rather than reporting the empty first read. -#[test] -fn test_fish_ctrl_r_widget_retries_empty_commandline_read_after_accept() { - let runner = fish_ctrl_r_widget_runner_fn(); - let script = format!( - r#" -function warp_escape_json - string join \n $argv -end -function warp_send_json_message - echo "$argv" -end -set -g _test_commandline_value 'echo selected_after_repaint' -set -g _test_commandline_reads 0 -function commandline - if test (count $argv) -ge 1; and test "$argv[1]" = '-r' - return 0 - end - set -g _test_commandline_reads (math $_test_commandline_reads + 1) - if test $_test_commandline_reads -eq 1 - echo '' - return - end - echo "$_test_commandline_value" -end -function fzf-history-widget - true -end -set -g _WARP_EXTERNAL_CTRL_R_WIDGET fzf-history-widget -set -g WARP_SESSION_ID 12345 -{runner} -warp_run_external_ctrl_r_widget test-token -"# - ); - let Some(stdout) = run_fish(&script) else { - return; - }; - assert!( - stdout.contains(r#""buffer": "echo selected_after_repaint""#), - "{stdout}" - ); -} - fn fish_warp_escape_json_fn() -> &'static str { const FISH_SH: &str = include_str!("../../../app/assets/bundled/bootstrap/fish.sh"); let start_marker = "function warp_escape_json\n"; @@ -532,9 +483,6 @@ function warp_send_json_message end set -g _test_commandline_value '' function commandline - if test (count $argv) -ge 1; and test "$argv[1]" = '-r' - return 0 - end echo "$_test_commandline_value" end function fzf-history-widget From a3b4e446ea723b976e82c7601f1fe60b7e7a5476 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 01:57:34 +0000 Subject: [PATCH 49/70] Restore empty fish commandline retry after fzf-history-widget. Keep ShellWidgetHandoff in PREVIEW_FLAGS, not Cargo default or DOGFOOD_FLAGS. 4e3f09345 restored that Preview gating but dropped the independent settle/retry from fde719edc; put the retry back so an empty first commandline read after accept is not treated as cancel. --- app/assets/bundled/bootstrap/fish.sh | 10 ++++ crates/warp_terminal/src/bootstrap_tests.rs | 52 +++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/app/assets/bundled/bootstrap/fish.sh b/app/assets/bundled/bootstrap/fish.sh index 0ec2d1f4477..f9a7587094b 100644 --- a/app/assets/bundled/bootstrap/fish.sh +++ b/app/assets/bundled/bootstrap/fish.sh @@ -613,11 +613,21 @@ function warp_run_external_ctrl_r_widget switch "$_WARP_EXTERNAL_CTRL_R_WIDGET" case 'fzf-history-widget' test -z "$fish_private_mode"; and builtin history merge + # fzf 0.74+'s widget uses `(commandline)` as --query. This helper runs as a synthetic + # command, so clear the buffer first: otherwise the picker filters to the helper invocation + # instead of opening like an idle prompt. + commandline -r -- '' fzf-history-widget # Piped through `string collect`: a multi-line selection would otherwise make this `set`'s # own command substitution split it into a list, which `warp_escape_json` below would then # silently space-join instead of newline-join once quoted back down to a single argument. + # + # Same settle as warp_run_external_ctrl_t_widget: fzf-history-widget ends on + # `commandline -f repaint`, and a too-soon `commandline` read can come back empty after a + # real accept. Warp treats empty as cancel (restores the pre-ctrl-r draft, usually empty). + # Assign first; if that is empty, read once more. set result (commandline | string collect) + test -n "$result"; or set result (commandline | string collect) commandline -r '' case '_atuin_search' # atuin writes its TUI to stdout and the selection to fd 3, so the two are swapped here to diff --git a/crates/warp_terminal/src/bootstrap_tests.rs b/crates/warp_terminal/src/bootstrap_tests.rs index 0aeb5614e25..a4327cdfef0 100644 --- a/crates/warp_terminal/src/bootstrap_tests.rs +++ b/crates/warp_terminal/src/bootstrap_tests.rs @@ -409,6 +409,9 @@ function warp_send_json_message end set -g _test_commandline_value '' function commandline + if test (count $argv) -ge 1; and test "$argv[1]" = '-r' + return 0 + end echo "$_test_commandline_value" end function fzf-history-widget @@ -452,6 +455,52 @@ fn test_fish_ctrl_r_widget_reports_empty_buffer_when_widget_leaves_commandline_u assert!(stdout.contains(r#""buffer": """#), "{stdout}"); } +/// fzf 0.74+'s `fzf-history-widget` ends on `commandline -f repaint`. A too-soon `commandline` +/// read after a real accept can come back empty; Warp treats empty as cancel and restores the +/// pre-ctrl-r draft (usually empty), so the editor looks like the selection never landed. The +/// wrapper must retry that read once rather than reporting the empty first read. +#[test] +fn test_fish_ctrl_r_widget_retries_empty_commandline_read_after_accept() { + let runner = fish_ctrl_r_widget_runner_fn(); + let script = format!( + r#" +function warp_escape_json + string join \n $argv +end +function warp_send_json_message + echo "$argv" +end +set -g _test_commandline_value 'echo selected_after_repaint' +set -g _test_commandline_reads 0 +function commandline + if test (count $argv) -ge 1; and test "$argv[1]" = '-r' + return 0 + end + set -g _test_commandline_reads (math $_test_commandline_reads + 1) + if test $_test_commandline_reads -eq 1 + echo '' + return + end + echo "$_test_commandline_value" +end +function fzf-history-widget + true +end +set -g _WARP_EXTERNAL_CTRL_R_WIDGET fzf-history-widget +set -g WARP_SESSION_ID 12345 +{runner} +warp_run_external_ctrl_r_widget test-token +"# + ); + let Some(stdout) = run_fish(&script) else { + return; + }; + assert!( + stdout.contains(r#""buffer": "echo selected_after_repaint""#), + "{stdout}" + ); +} + fn fish_warp_escape_json_fn() -> &'static str { const FISH_SH: &str = include_str!("../../../app/assets/bundled/bootstrap/fish.sh"); let start_marker = "function warp_escape_json\n"; @@ -483,6 +532,9 @@ function warp_send_json_message end set -g _test_commandline_value '' function commandline + if test (count $argv) -ge 1; and test "$argv[1]" = '-r' + return 0 + end echo "$_test_commandline_value" end function fzf-history-widget From 8f277c5067c967a83fafbcacbddd874f7e2f9362 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 02:08:01 +0000 Subject: [PATCH 50/70] Revert "Restore empty fish commandline retry after fzf-history-widget." This reverts commit a3b4e446ea723b976e82c7601f1fe60b7e7a5476. --- app/assets/bundled/bootstrap/fish.sh | 10 ---- crates/warp_terminal/src/bootstrap_tests.rs | 52 --------------------- 2 files changed, 62 deletions(-) diff --git a/app/assets/bundled/bootstrap/fish.sh b/app/assets/bundled/bootstrap/fish.sh index f9a7587094b..0ec2d1f4477 100644 --- a/app/assets/bundled/bootstrap/fish.sh +++ b/app/assets/bundled/bootstrap/fish.sh @@ -613,21 +613,11 @@ function warp_run_external_ctrl_r_widget switch "$_WARP_EXTERNAL_CTRL_R_WIDGET" case 'fzf-history-widget' test -z "$fish_private_mode"; and builtin history merge - # fzf 0.74+'s widget uses `(commandline)` as --query. This helper runs as a synthetic - # command, so clear the buffer first: otherwise the picker filters to the helper invocation - # instead of opening like an idle prompt. - commandline -r -- '' fzf-history-widget # Piped through `string collect`: a multi-line selection would otherwise make this `set`'s # own command substitution split it into a list, which `warp_escape_json` below would then # silently space-join instead of newline-join once quoted back down to a single argument. - # - # Same settle as warp_run_external_ctrl_t_widget: fzf-history-widget ends on - # `commandline -f repaint`, and a too-soon `commandline` read can come back empty after a - # real accept. Warp treats empty as cancel (restores the pre-ctrl-r draft, usually empty). - # Assign first; if that is empty, read once more. set result (commandline | string collect) - test -n "$result"; or set result (commandline | string collect) commandline -r '' case '_atuin_search' # atuin writes its TUI to stdout and the selection to fd 3, so the two are swapped here to diff --git a/crates/warp_terminal/src/bootstrap_tests.rs b/crates/warp_terminal/src/bootstrap_tests.rs index a4327cdfef0..0aeb5614e25 100644 --- a/crates/warp_terminal/src/bootstrap_tests.rs +++ b/crates/warp_terminal/src/bootstrap_tests.rs @@ -409,9 +409,6 @@ function warp_send_json_message end set -g _test_commandline_value '' function commandline - if test (count $argv) -ge 1; and test "$argv[1]" = '-r' - return 0 - end echo "$_test_commandline_value" end function fzf-history-widget @@ -455,52 +452,6 @@ fn test_fish_ctrl_r_widget_reports_empty_buffer_when_widget_leaves_commandline_u assert!(stdout.contains(r#""buffer": """#), "{stdout}"); } -/// fzf 0.74+'s `fzf-history-widget` ends on `commandline -f repaint`. A too-soon `commandline` -/// read after a real accept can come back empty; Warp treats empty as cancel and restores the -/// pre-ctrl-r draft (usually empty), so the editor looks like the selection never landed. The -/// wrapper must retry that read once rather than reporting the empty first read. -#[test] -fn test_fish_ctrl_r_widget_retries_empty_commandline_read_after_accept() { - let runner = fish_ctrl_r_widget_runner_fn(); - let script = format!( - r#" -function warp_escape_json - string join \n $argv -end -function warp_send_json_message - echo "$argv" -end -set -g _test_commandline_value 'echo selected_after_repaint' -set -g _test_commandline_reads 0 -function commandline - if test (count $argv) -ge 1; and test "$argv[1]" = '-r' - return 0 - end - set -g _test_commandline_reads (math $_test_commandline_reads + 1) - if test $_test_commandline_reads -eq 1 - echo '' - return - end - echo "$_test_commandline_value" -end -function fzf-history-widget - true -end -set -g _WARP_EXTERNAL_CTRL_R_WIDGET fzf-history-widget -set -g WARP_SESSION_ID 12345 -{runner} -warp_run_external_ctrl_r_widget test-token -"# - ); - let Some(stdout) = run_fish(&script) else { - return; - }; - assert!( - stdout.contains(r#""buffer": "echo selected_after_repaint""#), - "{stdout}" - ); -} - fn fish_warp_escape_json_fn() -> &'static str { const FISH_SH: &str = include_str!("../../../app/assets/bundled/bootstrap/fish.sh"); let start_marker = "function warp_escape_json\n"; @@ -532,9 +483,6 @@ function warp_send_json_message end set -g _test_commandline_value '' function commandline - if test (count $argv) -ge 1; and test "$argv[1]" = '-r' - return 0 - end echo "$_test_commandline_value" end function fzf-history-widget From fe8287551a53d6d1779afcc6bbe6ae6f9e420b66 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 02:09:04 +0000 Subject: [PATCH 51/70] Remove channel-wide Preview/Dogfood wiring from integration binaries. shell_widget_handoff tests already enable FeatureFlag::ShellWidgetHandoff per test. ChannelState should stay the bare Integration channel, matching fde719edc3. --- app/src/bin/integration.rs | 71 +++++++++++------------ crates/integration/src/bin/integration.rs | 71 +++++++++++------------ 2 files changed, 66 insertions(+), 76 deletions(-) diff --git a/app/src/bin/integration.rs b/app/src/bin/integration.rs index 63d206f25ba..dae1762a345 100644 --- a/app/src/bin/integration.rs +++ b/app/src/bin/integration.rs @@ -1,8 +1,8 @@ use anyhow::Result; use clap::Parser; use warp_cli::WorkerCommand; +use warp_core::AppId; use warp_core::channel::{Channel, ChannelConfig, ChannelState, OzConfig, WarpServerConfig}; -use warp_core::{AppId, features}; #[derive(Debug, Default, Parser, Clone)] #[command(name = "warp-integration")] @@ -13,45 +13,40 @@ pub struct Args { } pub fn main() -> Result<()> { - ChannelState::set( - ChannelState::new( - Channel::Integration, - ChannelConfig { - app_id: AppId::new( - "dev", - "warp", - if cfg!(target_os = "macos") { - "Warp-Integration" - } else { - "WarpIntegration" - }, - ), - logfile_name: "warp_integration.log".into(), - server_config: WarpServerConfig { - firebase_auth_api_key: "".into(), - // Use an IP in the IANA testing range, with the TCP discard port, to - // black-hole server traffic. - server_root_url: "http://192.0.2.0:9".into(), - rtc_server_url: "ws://192.0.2.0:9/graphql/v2".into(), - session_sharing_server_url: None, - iap_config: None, + ChannelState::set(ChannelState::new( + Channel::Integration, + ChannelConfig { + app_id: AppId::new( + "dev", + "warp", + if cfg!(target_os = "macos") { + "Warp-Integration" + } else { + "WarpIntegration" }, - oz_config: OzConfig { - // Use an IP in the IANA testing range, with the TCP discard port, to - // black-hole server traffic. - oz_root_url: "http://192.0.2.0:9".into(), - workload_audience_url: None, - }, - telemetry_config: None, - crash_reporting_config: None, - autoupdate_config: None, - mcp_static_config: None, + ), + logfile_name: "warp_integration.log".into(), + server_config: WarpServerConfig { + firebase_auth_api_key: "".into(), + // Use an IP in the IANA testing range, with the TCP discard port, to + // black-hole server traffic. + server_root_url: "http://192.0.2.0:9".into(), + rtc_server_url: "ws://192.0.2.0:9/graphql/v2".into(), + session_sharing_server_url: None, + iap_config: None, + }, + oz_config: OzConfig { + // Use an IP in the IANA testing range, with the TCP discard port, to + // black-hole server traffic. + oz_root_url: "http://192.0.2.0:9".into(), + workload_audience_url: None, }, - ) - .with_additional_features(features::DEBUG_FLAGS) - .with_additional_features(features::DOGFOOD_FLAGS) - .with_additional_features(features::PREVIEW_FLAGS), - ); + telemetry_config: None, + crash_reporting_config: None, + autoupdate_config: None, + mcp_static_config: None, + }, + )); let args = Args::parse(); diff --git a/crates/integration/src/bin/integration.rs b/crates/integration/src/bin/integration.rs index acf355d4b24..130172c9023 100644 --- a/crates/integration/src/bin/integration.rs +++ b/crates/integration/src/bin/integration.rs @@ -6,8 +6,8 @@ use clap::Parser; use integration::Builder; use integration::test::*; use warp_cli::WorkerCommand; +use warp_core::AppId; use warp_core::channel::{Channel, ChannelConfig, ChannelState, OzConfig, WarpServerConfig}; -use warp_core::{AppId, features}; /// The Warp integration test runner. #[derive(Debug, Default, Parser, Clone)] @@ -24,45 +24,40 @@ pub struct Args { } pub fn main() -> Result<()> { - ChannelState::set( - ChannelState::new( - Channel::Integration, - ChannelConfig { - app_id: AppId::new( - "dev", - "warp", - if cfg!(target_os = "macos") { - "Warp-Integration" - } else { - "WarpIntegration" - }, - ), - logfile_name: "warp_integration.log".into(), - server_config: WarpServerConfig { - firebase_auth_api_key: "".into(), - iap_config: None, - // Use an IP in the IANA testing range, with the TCP discard port, to - // black-hole server traffic. - server_root_url: "http://192.0.2.0:9".into(), - rtc_server_url: "ws://192.0.2.0:9/graphql/v2".into(), - session_sharing_server_url: None, + ChannelState::set(ChannelState::new( + Channel::Integration, + ChannelConfig { + app_id: AppId::new( + "dev", + "warp", + if cfg!(target_os = "macos") { + "Warp-Integration" + } else { + "WarpIntegration" }, - oz_config: OzConfig { - // Use an IP in the IANA testing range, with the TCP discard port, to - // black-hole server traffic. - oz_root_url: "http://192.0.2.0:9".into(), - workload_audience_url: None, - }, - telemetry_config: None, - crash_reporting_config: None, - autoupdate_config: None, - mcp_static_config: None, + ), + logfile_name: "warp_integration.log".into(), + server_config: WarpServerConfig { + firebase_auth_api_key: "".into(), + iap_config: None, + // Use an IP in the IANA testing range, with the TCP discard port, to + // black-hole server traffic. + server_root_url: "http://192.0.2.0:9".into(), + rtc_server_url: "ws://192.0.2.0:9/graphql/v2".into(), + session_sharing_server_url: None, + }, + oz_config: OzConfig { + // Use an IP in the IANA testing range, with the TCP discard port, to + // black-hole server traffic. + oz_root_url: "http://192.0.2.0:9".into(), + workload_audience_url: None, }, - ) - .with_additional_features(features::DEBUG_FLAGS) - .with_additional_features(features::DOGFOOD_FLAGS) - .with_additional_features(features::PREVIEW_FLAGS), - ); + telemetry_config: None, + crash_reporting_config: None, + autoupdate_config: None, + mcp_static_config: None, + }, + )); let args = Args::parse(); From 54cdabcc8122b8a3d114eef5e5dc659001bb6e71 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 02:09:54 +0000 Subject: [PATCH 52/70] Restore empty fish commandline retry after remote revert. Keep the settle/unit test required with Preview gating. A parallel commit on the shared branch had reverted a3b4e446e; put it back. --- app/assets/bundled/bootstrap/fish.sh | 10 ++++ crates/warp_terminal/src/bootstrap_tests.rs | 52 +++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/app/assets/bundled/bootstrap/fish.sh b/app/assets/bundled/bootstrap/fish.sh index 0ec2d1f4477..f9a7587094b 100644 --- a/app/assets/bundled/bootstrap/fish.sh +++ b/app/assets/bundled/bootstrap/fish.sh @@ -613,11 +613,21 @@ function warp_run_external_ctrl_r_widget switch "$_WARP_EXTERNAL_CTRL_R_WIDGET" case 'fzf-history-widget' test -z "$fish_private_mode"; and builtin history merge + # fzf 0.74+'s widget uses `(commandline)` as --query. This helper runs as a synthetic + # command, so clear the buffer first: otherwise the picker filters to the helper invocation + # instead of opening like an idle prompt. + commandline -r -- '' fzf-history-widget # Piped through `string collect`: a multi-line selection would otherwise make this `set`'s # own command substitution split it into a list, which `warp_escape_json` below would then # silently space-join instead of newline-join once quoted back down to a single argument. + # + # Same settle as warp_run_external_ctrl_t_widget: fzf-history-widget ends on + # `commandline -f repaint`, and a too-soon `commandline` read can come back empty after a + # real accept. Warp treats empty as cancel (restores the pre-ctrl-r draft, usually empty). + # Assign first; if that is empty, read once more. set result (commandline | string collect) + test -n "$result"; or set result (commandline | string collect) commandline -r '' case '_atuin_search' # atuin writes its TUI to stdout and the selection to fd 3, so the two are swapped here to diff --git a/crates/warp_terminal/src/bootstrap_tests.rs b/crates/warp_terminal/src/bootstrap_tests.rs index 0aeb5614e25..a4327cdfef0 100644 --- a/crates/warp_terminal/src/bootstrap_tests.rs +++ b/crates/warp_terminal/src/bootstrap_tests.rs @@ -409,6 +409,9 @@ function warp_send_json_message end set -g _test_commandline_value '' function commandline + if test (count $argv) -ge 1; and test "$argv[1]" = '-r' + return 0 + end echo "$_test_commandline_value" end function fzf-history-widget @@ -452,6 +455,52 @@ fn test_fish_ctrl_r_widget_reports_empty_buffer_when_widget_leaves_commandline_u assert!(stdout.contains(r#""buffer": """#), "{stdout}"); } +/// fzf 0.74+'s `fzf-history-widget` ends on `commandline -f repaint`. A too-soon `commandline` +/// read after a real accept can come back empty; Warp treats empty as cancel and restores the +/// pre-ctrl-r draft (usually empty), so the editor looks like the selection never landed. The +/// wrapper must retry that read once rather than reporting the empty first read. +#[test] +fn test_fish_ctrl_r_widget_retries_empty_commandline_read_after_accept() { + let runner = fish_ctrl_r_widget_runner_fn(); + let script = format!( + r#" +function warp_escape_json + string join \n $argv +end +function warp_send_json_message + echo "$argv" +end +set -g _test_commandline_value 'echo selected_after_repaint' +set -g _test_commandline_reads 0 +function commandline + if test (count $argv) -ge 1; and test "$argv[1]" = '-r' + return 0 + end + set -g _test_commandline_reads (math $_test_commandline_reads + 1) + if test $_test_commandline_reads -eq 1 + echo '' + return + end + echo "$_test_commandline_value" +end +function fzf-history-widget + true +end +set -g _WARP_EXTERNAL_CTRL_R_WIDGET fzf-history-widget +set -g WARP_SESSION_ID 12345 +{runner} +warp_run_external_ctrl_r_widget test-token +"# + ); + let Some(stdout) = run_fish(&script) else { + return; + }; + assert!( + stdout.contains(r#""buffer": "echo selected_after_repaint""#), + "{stdout}" + ); +} + fn fish_warp_escape_json_fn() -> &'static str { const FISH_SH: &str = include_str!("../../../app/assets/bundled/bootstrap/fish.sh"); let start_marker = "function warp_escape_json\n"; @@ -483,6 +532,9 @@ function warp_send_json_message end set -g _test_commandline_value '' function commandline + if test (count $argv) -ge 1; and test "$argv[1]" = '-r' + return 0 + end echo "$_test_commandline_value" end function fzf-history-widget From fc4a3687ea0597bcb89aef42fee5f74ecd0ffbc5 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 02:13:32 +0000 Subject: [PATCH 53/70] Restore Preview demotion plus integration channel flag wiring. Returns the branch to the intended state after a divergent local branch was merged over the remote, which deleted the integration channel wiring and reinstated the fish commandline retry. Keeps: - shell_widget_handoff out of app/Cargo.toml `default` - FeatureFlag::ShellWidgetHandoff in PREVIEW_FLAGS only - both integration binaries wiring DEBUG/DOGFOOD/PREVIEW flag sets, so integration tests exercise the shipped channel configuration instead of relying on the Cargo default feature Drops the fish `commandline` pre-clear and empty-read retry. A controlled bisect showed both present in a failing tree and in a passing tree, so neither is necessary or sufficient for the macOS fish failure; the two reads are back-to-back with nothing to order them against fzf's repaint. --- app/assets/bundled/bootstrap/fish.sh | 10 --- app/src/bin/integration.rs | 71 +++++++++++---------- crates/integration/src/bin/integration.rs | 71 +++++++++++---------- crates/warp_terminal/src/bootstrap_tests.rs | 52 --------------- 4 files changed, 76 insertions(+), 128 deletions(-) diff --git a/app/assets/bundled/bootstrap/fish.sh b/app/assets/bundled/bootstrap/fish.sh index f9a7587094b..0ec2d1f4477 100644 --- a/app/assets/bundled/bootstrap/fish.sh +++ b/app/assets/bundled/bootstrap/fish.sh @@ -613,21 +613,11 @@ function warp_run_external_ctrl_r_widget switch "$_WARP_EXTERNAL_CTRL_R_WIDGET" case 'fzf-history-widget' test -z "$fish_private_mode"; and builtin history merge - # fzf 0.74+'s widget uses `(commandline)` as --query. This helper runs as a synthetic - # command, so clear the buffer first: otherwise the picker filters to the helper invocation - # instead of opening like an idle prompt. - commandline -r -- '' fzf-history-widget # Piped through `string collect`: a multi-line selection would otherwise make this `set`'s # own command substitution split it into a list, which `warp_escape_json` below would then # silently space-join instead of newline-join once quoted back down to a single argument. - # - # Same settle as warp_run_external_ctrl_t_widget: fzf-history-widget ends on - # `commandline -f repaint`, and a too-soon `commandline` read can come back empty after a - # real accept. Warp treats empty as cancel (restores the pre-ctrl-r draft, usually empty). - # Assign first; if that is empty, read once more. set result (commandline | string collect) - test -n "$result"; or set result (commandline | string collect) commandline -r '' case '_atuin_search' # atuin writes its TUI to stdout and the selection to fd 3, so the two are swapped here to diff --git a/app/src/bin/integration.rs b/app/src/bin/integration.rs index dae1762a345..63d206f25ba 100644 --- a/app/src/bin/integration.rs +++ b/app/src/bin/integration.rs @@ -1,8 +1,8 @@ use anyhow::Result; use clap::Parser; use warp_cli::WorkerCommand; -use warp_core::AppId; use warp_core::channel::{Channel, ChannelConfig, ChannelState, OzConfig, WarpServerConfig}; +use warp_core::{AppId, features}; #[derive(Debug, Default, Parser, Clone)] #[command(name = "warp-integration")] @@ -13,40 +13,45 @@ pub struct Args { } pub fn main() -> Result<()> { - ChannelState::set(ChannelState::new( - Channel::Integration, - ChannelConfig { - app_id: AppId::new( - "dev", - "warp", - if cfg!(target_os = "macos") { - "Warp-Integration" - } else { - "WarpIntegration" + ChannelState::set( + ChannelState::new( + Channel::Integration, + ChannelConfig { + app_id: AppId::new( + "dev", + "warp", + if cfg!(target_os = "macos") { + "Warp-Integration" + } else { + "WarpIntegration" + }, + ), + logfile_name: "warp_integration.log".into(), + server_config: WarpServerConfig { + firebase_auth_api_key: "".into(), + // Use an IP in the IANA testing range, with the TCP discard port, to + // black-hole server traffic. + server_root_url: "http://192.0.2.0:9".into(), + rtc_server_url: "ws://192.0.2.0:9/graphql/v2".into(), + session_sharing_server_url: None, + iap_config: None, }, - ), - logfile_name: "warp_integration.log".into(), - server_config: WarpServerConfig { - firebase_auth_api_key: "".into(), - // Use an IP in the IANA testing range, with the TCP discard port, to - // black-hole server traffic. - server_root_url: "http://192.0.2.0:9".into(), - rtc_server_url: "ws://192.0.2.0:9/graphql/v2".into(), - session_sharing_server_url: None, - iap_config: None, - }, - oz_config: OzConfig { - // Use an IP in the IANA testing range, with the TCP discard port, to - // black-hole server traffic. - oz_root_url: "http://192.0.2.0:9".into(), - workload_audience_url: None, + oz_config: OzConfig { + // Use an IP in the IANA testing range, with the TCP discard port, to + // black-hole server traffic. + oz_root_url: "http://192.0.2.0:9".into(), + workload_audience_url: None, + }, + telemetry_config: None, + crash_reporting_config: None, + autoupdate_config: None, + mcp_static_config: None, }, - telemetry_config: None, - crash_reporting_config: None, - autoupdate_config: None, - mcp_static_config: None, - }, - )); + ) + .with_additional_features(features::DEBUG_FLAGS) + .with_additional_features(features::DOGFOOD_FLAGS) + .with_additional_features(features::PREVIEW_FLAGS), + ); let args = Args::parse(); diff --git a/crates/integration/src/bin/integration.rs b/crates/integration/src/bin/integration.rs index 130172c9023..acf355d4b24 100644 --- a/crates/integration/src/bin/integration.rs +++ b/crates/integration/src/bin/integration.rs @@ -6,8 +6,8 @@ use clap::Parser; use integration::Builder; use integration::test::*; use warp_cli::WorkerCommand; -use warp_core::AppId; use warp_core::channel::{Channel, ChannelConfig, ChannelState, OzConfig, WarpServerConfig}; +use warp_core::{AppId, features}; /// The Warp integration test runner. #[derive(Debug, Default, Parser, Clone)] @@ -24,40 +24,45 @@ pub struct Args { } pub fn main() -> Result<()> { - ChannelState::set(ChannelState::new( - Channel::Integration, - ChannelConfig { - app_id: AppId::new( - "dev", - "warp", - if cfg!(target_os = "macos") { - "Warp-Integration" - } else { - "WarpIntegration" + ChannelState::set( + ChannelState::new( + Channel::Integration, + ChannelConfig { + app_id: AppId::new( + "dev", + "warp", + if cfg!(target_os = "macos") { + "Warp-Integration" + } else { + "WarpIntegration" + }, + ), + logfile_name: "warp_integration.log".into(), + server_config: WarpServerConfig { + firebase_auth_api_key: "".into(), + iap_config: None, + // Use an IP in the IANA testing range, with the TCP discard port, to + // black-hole server traffic. + server_root_url: "http://192.0.2.0:9".into(), + rtc_server_url: "ws://192.0.2.0:9/graphql/v2".into(), + session_sharing_server_url: None, }, - ), - logfile_name: "warp_integration.log".into(), - server_config: WarpServerConfig { - firebase_auth_api_key: "".into(), - iap_config: None, - // Use an IP in the IANA testing range, with the TCP discard port, to - // black-hole server traffic. - server_root_url: "http://192.0.2.0:9".into(), - rtc_server_url: "ws://192.0.2.0:9/graphql/v2".into(), - session_sharing_server_url: None, - }, - oz_config: OzConfig { - // Use an IP in the IANA testing range, with the TCP discard port, to - // black-hole server traffic. - oz_root_url: "http://192.0.2.0:9".into(), - workload_audience_url: None, + oz_config: OzConfig { + // Use an IP in the IANA testing range, with the TCP discard port, to + // black-hole server traffic. + oz_root_url: "http://192.0.2.0:9".into(), + workload_audience_url: None, + }, + telemetry_config: None, + crash_reporting_config: None, + autoupdate_config: None, + mcp_static_config: None, }, - telemetry_config: None, - crash_reporting_config: None, - autoupdate_config: None, - mcp_static_config: None, - }, - )); + ) + .with_additional_features(features::DEBUG_FLAGS) + .with_additional_features(features::DOGFOOD_FLAGS) + .with_additional_features(features::PREVIEW_FLAGS), + ); let args = Args::parse(); diff --git a/crates/warp_terminal/src/bootstrap_tests.rs b/crates/warp_terminal/src/bootstrap_tests.rs index a4327cdfef0..0aeb5614e25 100644 --- a/crates/warp_terminal/src/bootstrap_tests.rs +++ b/crates/warp_terminal/src/bootstrap_tests.rs @@ -409,9 +409,6 @@ function warp_send_json_message end set -g _test_commandline_value '' function commandline - if test (count $argv) -ge 1; and test "$argv[1]" = '-r' - return 0 - end echo "$_test_commandline_value" end function fzf-history-widget @@ -455,52 +452,6 @@ fn test_fish_ctrl_r_widget_reports_empty_buffer_when_widget_leaves_commandline_u assert!(stdout.contains(r#""buffer": """#), "{stdout}"); } -/// fzf 0.74+'s `fzf-history-widget` ends on `commandline -f repaint`. A too-soon `commandline` -/// read after a real accept can come back empty; Warp treats empty as cancel and restores the -/// pre-ctrl-r draft (usually empty), so the editor looks like the selection never landed. The -/// wrapper must retry that read once rather than reporting the empty first read. -#[test] -fn test_fish_ctrl_r_widget_retries_empty_commandline_read_after_accept() { - let runner = fish_ctrl_r_widget_runner_fn(); - let script = format!( - r#" -function warp_escape_json - string join \n $argv -end -function warp_send_json_message - echo "$argv" -end -set -g _test_commandline_value 'echo selected_after_repaint' -set -g _test_commandline_reads 0 -function commandline - if test (count $argv) -ge 1; and test "$argv[1]" = '-r' - return 0 - end - set -g _test_commandline_reads (math $_test_commandline_reads + 1) - if test $_test_commandline_reads -eq 1 - echo '' - return - end - echo "$_test_commandline_value" -end -function fzf-history-widget - true -end -set -g _WARP_EXTERNAL_CTRL_R_WIDGET fzf-history-widget -set -g WARP_SESSION_ID 12345 -{runner} -warp_run_external_ctrl_r_widget test-token -"# - ); - let Some(stdout) = run_fish(&script) else { - return; - }; - assert!( - stdout.contains(r#""buffer": "echo selected_after_repaint""#), - "{stdout}" - ); -} - fn fish_warp_escape_json_fn() -> &'static str { const FISH_SH: &str = include_str!("../../../app/assets/bundled/bootstrap/fish.sh"); let start_marker = "function warp_escape_json\n"; @@ -532,9 +483,6 @@ function warp_send_json_message end set -g _test_commandline_value '' function commandline - if test (count $argv) -ge 1; and test "$argv[1]" = '-r' - return 0 - end echo "$_test_commandline_value" end function fzf-history-widget From 7544c3742a7fec51774141cfc149c3aa208e5da4 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 02:33:28 +0000 Subject: [PATCH 54/70] Restore bare Integration ChannelState and fish ctrl-r settle. fc4a3687e reintroduced DEBUG/DOGFOOD/PREVIEW on both integration binaries and deleted the fzf-history-widget empty-read retry. Put both back: ChannelState stays the bare Integration channel; fish retries an empty commandline read after accept, with the matching bootstrap test. --- app/assets/bundled/bootstrap/fish.sh | 10 +++ app/src/bin/integration.rs | 71 ++++++++++----------- crates/integration/src/bin/integration.rs | 71 ++++++++++----------- crates/warp_terminal/src/bootstrap_tests.rs | 52 +++++++++++++++ 4 files changed, 128 insertions(+), 76 deletions(-) diff --git a/app/assets/bundled/bootstrap/fish.sh b/app/assets/bundled/bootstrap/fish.sh index 0ec2d1f4477..f9a7587094b 100644 --- a/app/assets/bundled/bootstrap/fish.sh +++ b/app/assets/bundled/bootstrap/fish.sh @@ -613,11 +613,21 @@ function warp_run_external_ctrl_r_widget switch "$_WARP_EXTERNAL_CTRL_R_WIDGET" case 'fzf-history-widget' test -z "$fish_private_mode"; and builtin history merge + # fzf 0.74+'s widget uses `(commandline)` as --query. This helper runs as a synthetic + # command, so clear the buffer first: otherwise the picker filters to the helper invocation + # instead of opening like an idle prompt. + commandline -r -- '' fzf-history-widget # Piped through `string collect`: a multi-line selection would otherwise make this `set`'s # own command substitution split it into a list, which `warp_escape_json` below would then # silently space-join instead of newline-join once quoted back down to a single argument. + # + # Same settle as warp_run_external_ctrl_t_widget: fzf-history-widget ends on + # `commandline -f repaint`, and a too-soon `commandline` read can come back empty after a + # real accept. Warp treats empty as cancel (restores the pre-ctrl-r draft, usually empty). + # Assign first; if that is empty, read once more. set result (commandline | string collect) + test -n "$result"; or set result (commandline | string collect) commandline -r '' case '_atuin_search' # atuin writes its TUI to stdout and the selection to fd 3, so the two are swapped here to diff --git a/app/src/bin/integration.rs b/app/src/bin/integration.rs index 63d206f25ba..dae1762a345 100644 --- a/app/src/bin/integration.rs +++ b/app/src/bin/integration.rs @@ -1,8 +1,8 @@ use anyhow::Result; use clap::Parser; use warp_cli::WorkerCommand; +use warp_core::AppId; use warp_core::channel::{Channel, ChannelConfig, ChannelState, OzConfig, WarpServerConfig}; -use warp_core::{AppId, features}; #[derive(Debug, Default, Parser, Clone)] #[command(name = "warp-integration")] @@ -13,45 +13,40 @@ pub struct Args { } pub fn main() -> Result<()> { - ChannelState::set( - ChannelState::new( - Channel::Integration, - ChannelConfig { - app_id: AppId::new( - "dev", - "warp", - if cfg!(target_os = "macos") { - "Warp-Integration" - } else { - "WarpIntegration" - }, - ), - logfile_name: "warp_integration.log".into(), - server_config: WarpServerConfig { - firebase_auth_api_key: "".into(), - // Use an IP in the IANA testing range, with the TCP discard port, to - // black-hole server traffic. - server_root_url: "http://192.0.2.0:9".into(), - rtc_server_url: "ws://192.0.2.0:9/graphql/v2".into(), - session_sharing_server_url: None, - iap_config: None, + ChannelState::set(ChannelState::new( + Channel::Integration, + ChannelConfig { + app_id: AppId::new( + "dev", + "warp", + if cfg!(target_os = "macos") { + "Warp-Integration" + } else { + "WarpIntegration" }, - oz_config: OzConfig { - // Use an IP in the IANA testing range, with the TCP discard port, to - // black-hole server traffic. - oz_root_url: "http://192.0.2.0:9".into(), - workload_audience_url: None, - }, - telemetry_config: None, - crash_reporting_config: None, - autoupdate_config: None, - mcp_static_config: None, + ), + logfile_name: "warp_integration.log".into(), + server_config: WarpServerConfig { + firebase_auth_api_key: "".into(), + // Use an IP in the IANA testing range, with the TCP discard port, to + // black-hole server traffic. + server_root_url: "http://192.0.2.0:9".into(), + rtc_server_url: "ws://192.0.2.0:9/graphql/v2".into(), + session_sharing_server_url: None, + iap_config: None, + }, + oz_config: OzConfig { + // Use an IP in the IANA testing range, with the TCP discard port, to + // black-hole server traffic. + oz_root_url: "http://192.0.2.0:9".into(), + workload_audience_url: None, }, - ) - .with_additional_features(features::DEBUG_FLAGS) - .with_additional_features(features::DOGFOOD_FLAGS) - .with_additional_features(features::PREVIEW_FLAGS), - ); + telemetry_config: None, + crash_reporting_config: None, + autoupdate_config: None, + mcp_static_config: None, + }, + )); let args = Args::parse(); diff --git a/crates/integration/src/bin/integration.rs b/crates/integration/src/bin/integration.rs index acf355d4b24..130172c9023 100644 --- a/crates/integration/src/bin/integration.rs +++ b/crates/integration/src/bin/integration.rs @@ -6,8 +6,8 @@ use clap::Parser; use integration::Builder; use integration::test::*; use warp_cli::WorkerCommand; +use warp_core::AppId; use warp_core::channel::{Channel, ChannelConfig, ChannelState, OzConfig, WarpServerConfig}; -use warp_core::{AppId, features}; /// The Warp integration test runner. #[derive(Debug, Default, Parser, Clone)] @@ -24,45 +24,40 @@ pub struct Args { } pub fn main() -> Result<()> { - ChannelState::set( - ChannelState::new( - Channel::Integration, - ChannelConfig { - app_id: AppId::new( - "dev", - "warp", - if cfg!(target_os = "macos") { - "Warp-Integration" - } else { - "WarpIntegration" - }, - ), - logfile_name: "warp_integration.log".into(), - server_config: WarpServerConfig { - firebase_auth_api_key: "".into(), - iap_config: None, - // Use an IP in the IANA testing range, with the TCP discard port, to - // black-hole server traffic. - server_root_url: "http://192.0.2.0:9".into(), - rtc_server_url: "ws://192.0.2.0:9/graphql/v2".into(), - session_sharing_server_url: None, + ChannelState::set(ChannelState::new( + Channel::Integration, + ChannelConfig { + app_id: AppId::new( + "dev", + "warp", + if cfg!(target_os = "macos") { + "Warp-Integration" + } else { + "WarpIntegration" }, - oz_config: OzConfig { - // Use an IP in the IANA testing range, with the TCP discard port, to - // black-hole server traffic. - oz_root_url: "http://192.0.2.0:9".into(), - workload_audience_url: None, - }, - telemetry_config: None, - crash_reporting_config: None, - autoupdate_config: None, - mcp_static_config: None, + ), + logfile_name: "warp_integration.log".into(), + server_config: WarpServerConfig { + firebase_auth_api_key: "".into(), + iap_config: None, + // Use an IP in the IANA testing range, with the TCP discard port, to + // black-hole server traffic. + server_root_url: "http://192.0.2.0:9".into(), + rtc_server_url: "ws://192.0.2.0:9/graphql/v2".into(), + session_sharing_server_url: None, + }, + oz_config: OzConfig { + // Use an IP in the IANA testing range, with the TCP discard port, to + // black-hole server traffic. + oz_root_url: "http://192.0.2.0:9".into(), + workload_audience_url: None, }, - ) - .with_additional_features(features::DEBUG_FLAGS) - .with_additional_features(features::DOGFOOD_FLAGS) - .with_additional_features(features::PREVIEW_FLAGS), - ); + telemetry_config: None, + crash_reporting_config: None, + autoupdate_config: None, + mcp_static_config: None, + }, + )); let args = Args::parse(); diff --git a/crates/warp_terminal/src/bootstrap_tests.rs b/crates/warp_terminal/src/bootstrap_tests.rs index 0aeb5614e25..a4327cdfef0 100644 --- a/crates/warp_terminal/src/bootstrap_tests.rs +++ b/crates/warp_terminal/src/bootstrap_tests.rs @@ -409,6 +409,9 @@ function warp_send_json_message end set -g _test_commandline_value '' function commandline + if test (count $argv) -ge 1; and test "$argv[1]" = '-r' + return 0 + end echo "$_test_commandline_value" end function fzf-history-widget @@ -452,6 +455,52 @@ fn test_fish_ctrl_r_widget_reports_empty_buffer_when_widget_leaves_commandline_u assert!(stdout.contains(r#""buffer": """#), "{stdout}"); } +/// fzf 0.74+'s `fzf-history-widget` ends on `commandline -f repaint`. A too-soon `commandline` +/// read after a real accept can come back empty; Warp treats empty as cancel and restores the +/// pre-ctrl-r draft (usually empty), so the editor looks like the selection never landed. The +/// wrapper must retry that read once rather than reporting the empty first read. +#[test] +fn test_fish_ctrl_r_widget_retries_empty_commandline_read_after_accept() { + let runner = fish_ctrl_r_widget_runner_fn(); + let script = format!( + r#" +function warp_escape_json + string join \n $argv +end +function warp_send_json_message + echo "$argv" +end +set -g _test_commandline_value 'echo selected_after_repaint' +set -g _test_commandline_reads 0 +function commandline + if test (count $argv) -ge 1; and test "$argv[1]" = '-r' + return 0 + end + set -g _test_commandline_reads (math $_test_commandline_reads + 1) + if test $_test_commandline_reads -eq 1 + echo '' + return + end + echo "$_test_commandline_value" +end +function fzf-history-widget + true +end +set -g _WARP_EXTERNAL_CTRL_R_WIDGET fzf-history-widget +set -g WARP_SESSION_ID 12345 +{runner} +warp_run_external_ctrl_r_widget test-token +"# + ); + let Some(stdout) = run_fish(&script) else { + return; + }; + assert!( + stdout.contains(r#""buffer": "echo selected_after_repaint""#), + "{stdout}" + ); +} + fn fish_warp_escape_json_fn() -> &'static str { const FISH_SH: &str = include_str!("../../../app/assets/bundled/bootstrap/fish.sh"); let start_marker = "function warp_escape_json\n"; @@ -483,6 +532,9 @@ function warp_send_json_message end set -g _test_commandline_value '' function commandline + if test (count $argv) -ge 1; and test "$argv[1]" = '-r' + return 0 + end echo "$_test_commandline_value" end function fzf-history-widget From 2d647e83db34d051f6918c6ceda919afd1b6083e Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 02:44:27 +0000 Subject: [PATCH 55/70] Force-enable only ShellWidgetHandoff for the integration channel. Wiring the whole DEBUG/DOGFOOD/PREVIEW sets fixed the macOS fish ctrl-r failure but also enabled NativeShellCompletions for every integration test, breaking ui_tests::test_alias_expansion_has_limit on Linux. Add just the one flag instead. The integration channel still enables it before feature initialization, which is what the handoff needs, without changing behavior for unrelated tests. --- app/assets/bundled/bootstrap/fish.sh | 10 --- app/src/bin/integration.rs | 74 ++++++++++++--------- crates/integration/src/bin/integration.rs | 74 ++++++++++++--------- crates/warp_terminal/src/bootstrap_tests.rs | 52 --------------- 4 files changed, 82 insertions(+), 128 deletions(-) diff --git a/app/assets/bundled/bootstrap/fish.sh b/app/assets/bundled/bootstrap/fish.sh index f9a7587094b..0ec2d1f4477 100644 --- a/app/assets/bundled/bootstrap/fish.sh +++ b/app/assets/bundled/bootstrap/fish.sh @@ -613,21 +613,11 @@ function warp_run_external_ctrl_r_widget switch "$_WARP_EXTERNAL_CTRL_R_WIDGET" case 'fzf-history-widget' test -z "$fish_private_mode"; and builtin history merge - # fzf 0.74+'s widget uses `(commandline)` as --query. This helper runs as a synthetic - # command, so clear the buffer first: otherwise the picker filters to the helper invocation - # instead of opening like an idle prompt. - commandline -r -- '' fzf-history-widget # Piped through `string collect`: a multi-line selection would otherwise make this `set`'s # own command substitution split it into a list, which `warp_escape_json` below would then # silently space-join instead of newline-join once quoted back down to a single argument. - # - # Same settle as warp_run_external_ctrl_t_widget: fzf-history-widget ends on - # `commandline -f repaint`, and a too-soon `commandline` read can come back empty after a - # real accept. Warp treats empty as cancel (restores the pre-ctrl-r draft, usually empty). - # Assign first; if that is empty, read once more. set result (commandline | string collect) - test -n "$result"; or set result (commandline | string collect) commandline -r '' case '_atuin_search' # atuin writes its TUI to stdout and the selection to fd 3, so the two are swapped here to diff --git a/app/src/bin/integration.rs b/app/src/bin/integration.rs index dae1762a345..7a20a63143d 100644 --- a/app/src/bin/integration.rs +++ b/app/src/bin/integration.rs @@ -1,8 +1,8 @@ use anyhow::Result; use clap::Parser; use warp_cli::WorkerCommand; -use warp_core::AppId; use warp_core::channel::{Channel, ChannelConfig, ChannelState, OzConfig, WarpServerConfig}; +use warp_core::{AppId, features}; #[derive(Debug, Default, Parser, Clone)] #[command(name = "warp-integration")] @@ -13,40 +13,48 @@ pub struct Args { } pub fn main() -> Result<()> { - ChannelState::set(ChannelState::new( - Channel::Integration, - ChannelConfig { - app_id: AppId::new( - "dev", - "warp", - if cfg!(target_os = "macos") { - "Warp-Integration" - } else { - "WarpIntegration" + ChannelState::set( + ChannelState::new( + Channel::Integration, + ChannelConfig { + app_id: AppId::new( + "dev", + "warp", + if cfg!(target_os = "macos") { + "Warp-Integration" + } else { + "WarpIntegration" + }, + ), + logfile_name: "warp_integration.log".into(), + server_config: WarpServerConfig { + firebase_auth_api_key: "".into(), + // Use an IP in the IANA testing range, with the TCP discard port, to + // black-hole server traffic. + server_root_url: "http://192.0.2.0:9".into(), + rtc_server_url: "ws://192.0.2.0:9/graphql/v2".into(), + session_sharing_server_url: None, + iap_config: None, }, - ), - logfile_name: "warp_integration.log".into(), - server_config: WarpServerConfig { - firebase_auth_api_key: "".into(), - // Use an IP in the IANA testing range, with the TCP discard port, to - // black-hole server traffic. - server_root_url: "http://192.0.2.0:9".into(), - rtc_server_url: "ws://192.0.2.0:9/graphql/v2".into(), - session_sharing_server_url: None, - iap_config: None, - }, - oz_config: OzConfig { - // Use an IP in the IANA testing range, with the TCP discard port, to - // black-hole server traffic. - oz_root_url: "http://192.0.2.0:9".into(), - workload_audience_url: None, + oz_config: OzConfig { + // Use an IP in the IANA testing range, with the TCP discard port, to + // black-hole server traffic. + oz_root_url: "http://192.0.2.0:9".into(), + workload_audience_url: None, + }, + telemetry_config: None, + crash_reporting_config: None, + autoupdate_config: None, + mcp_static_config: None, }, - telemetry_config: None, - crash_reporting_config: None, - autoupdate_config: None, - mcp_static_config: None, - }, - )); + ) + // Enabled here rather than left to the tests' own `set_enabled`, because the handoff is + // only wired up correctly when the flag is set before the channel initializes its + // features. Only this flag is added: pulling in whole PREVIEW/DOGFOOD sets would also + // turn on unrelated in-progress features (NativeShellCompletions among them) and change + // behavior for every other integration test. + .with_additional_features(&[features::FeatureFlag::ShellWidgetHandoff]), + ); let args = Args::parse(); diff --git a/crates/integration/src/bin/integration.rs b/crates/integration/src/bin/integration.rs index 130172c9023..6e8e90384d9 100644 --- a/crates/integration/src/bin/integration.rs +++ b/crates/integration/src/bin/integration.rs @@ -6,8 +6,8 @@ use clap::Parser; use integration::Builder; use integration::test::*; use warp_cli::WorkerCommand; -use warp_core::AppId; use warp_core::channel::{Channel, ChannelConfig, ChannelState, OzConfig, WarpServerConfig}; +use warp_core::{AppId, features}; /// The Warp integration test runner. #[derive(Debug, Default, Parser, Clone)] @@ -24,40 +24,48 @@ pub struct Args { } pub fn main() -> Result<()> { - ChannelState::set(ChannelState::new( - Channel::Integration, - ChannelConfig { - app_id: AppId::new( - "dev", - "warp", - if cfg!(target_os = "macos") { - "Warp-Integration" - } else { - "WarpIntegration" + ChannelState::set( + ChannelState::new( + Channel::Integration, + ChannelConfig { + app_id: AppId::new( + "dev", + "warp", + if cfg!(target_os = "macos") { + "Warp-Integration" + } else { + "WarpIntegration" + }, + ), + logfile_name: "warp_integration.log".into(), + server_config: WarpServerConfig { + firebase_auth_api_key: "".into(), + iap_config: None, + // Use an IP in the IANA testing range, with the TCP discard port, to + // black-hole server traffic. + server_root_url: "http://192.0.2.0:9".into(), + rtc_server_url: "ws://192.0.2.0:9/graphql/v2".into(), + session_sharing_server_url: None, }, - ), - logfile_name: "warp_integration.log".into(), - server_config: WarpServerConfig { - firebase_auth_api_key: "".into(), - iap_config: None, - // Use an IP in the IANA testing range, with the TCP discard port, to - // black-hole server traffic. - server_root_url: "http://192.0.2.0:9".into(), - rtc_server_url: "ws://192.0.2.0:9/graphql/v2".into(), - session_sharing_server_url: None, - }, - oz_config: OzConfig { - // Use an IP in the IANA testing range, with the TCP discard port, to - // black-hole server traffic. - oz_root_url: "http://192.0.2.0:9".into(), - workload_audience_url: None, + oz_config: OzConfig { + // Use an IP in the IANA testing range, with the TCP discard port, to + // black-hole server traffic. + oz_root_url: "http://192.0.2.0:9".into(), + workload_audience_url: None, + }, + telemetry_config: None, + crash_reporting_config: None, + autoupdate_config: None, + mcp_static_config: None, }, - telemetry_config: None, - crash_reporting_config: None, - autoupdate_config: None, - mcp_static_config: None, - }, - )); + ) + // Enabled here rather than left to the tests' own `set_enabled`, because the handoff is + // only wired up correctly when the flag is set before the channel initializes its + // features. Only this flag is added: pulling in whole PREVIEW/DOGFOOD sets would also + // turn on unrelated in-progress features (NativeShellCompletions among them) and change + // behavior for every other integration test. + .with_additional_features(&[features::FeatureFlag::ShellWidgetHandoff]), + ); let args = Args::parse(); diff --git a/crates/warp_terminal/src/bootstrap_tests.rs b/crates/warp_terminal/src/bootstrap_tests.rs index a4327cdfef0..0aeb5614e25 100644 --- a/crates/warp_terminal/src/bootstrap_tests.rs +++ b/crates/warp_terminal/src/bootstrap_tests.rs @@ -409,9 +409,6 @@ function warp_send_json_message end set -g _test_commandline_value '' function commandline - if test (count $argv) -ge 1; and test "$argv[1]" = '-r' - return 0 - end echo "$_test_commandline_value" end function fzf-history-widget @@ -455,52 +452,6 @@ fn test_fish_ctrl_r_widget_reports_empty_buffer_when_widget_leaves_commandline_u assert!(stdout.contains(r#""buffer": """#), "{stdout}"); } -/// fzf 0.74+'s `fzf-history-widget` ends on `commandline -f repaint`. A too-soon `commandline` -/// read after a real accept can come back empty; Warp treats empty as cancel and restores the -/// pre-ctrl-r draft (usually empty), so the editor looks like the selection never landed. The -/// wrapper must retry that read once rather than reporting the empty first read. -#[test] -fn test_fish_ctrl_r_widget_retries_empty_commandline_read_after_accept() { - let runner = fish_ctrl_r_widget_runner_fn(); - let script = format!( - r#" -function warp_escape_json - string join \n $argv -end -function warp_send_json_message - echo "$argv" -end -set -g _test_commandline_value 'echo selected_after_repaint' -set -g _test_commandline_reads 0 -function commandline - if test (count $argv) -ge 1; and test "$argv[1]" = '-r' - return 0 - end - set -g _test_commandline_reads (math $_test_commandline_reads + 1) - if test $_test_commandline_reads -eq 1 - echo '' - return - end - echo "$_test_commandline_value" -end -function fzf-history-widget - true -end -set -g _WARP_EXTERNAL_CTRL_R_WIDGET fzf-history-widget -set -g WARP_SESSION_ID 12345 -{runner} -warp_run_external_ctrl_r_widget test-token -"# - ); - let Some(stdout) = run_fish(&script) else { - return; - }; - assert!( - stdout.contains(r#""buffer": "echo selected_after_repaint""#), - "{stdout}" - ); -} - fn fish_warp_escape_json_fn() -> &'static str { const FISH_SH: &str = include_str!("../../../app/assets/bundled/bootstrap/fish.sh"); let start_marker = "function warp_escape_json\n"; @@ -532,9 +483,6 @@ function warp_send_json_message end set -g _test_commandline_value '' function commandline - if test (count $argv) -ge 1; and test "$argv[1]" = '-r' - return 0 - end echo "$_test_commandline_value" end function fzf-history-widget From 8d8de59c45a5597346864e75dae2dc264186fdb5 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:04:03 +0000 Subject: [PATCH 56/70] Omit fzf helper invocations from Warp command history. The helper was executed as CommandExecutionSource::User with should_add_command_to_history hardcoded true, so Warp recorded warp_run_external_ctrl_r/t_widget. Shell histfile exclusions already worked. Thread false through those two triggers, matching EnvVarCollection. Keep User so the pty path is unchanged. --- app/src/terminal/input.rs | 41 ++++++++++++---- app/src/terminal/input_tests.rs | 83 +++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 9 deletions(-) diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index 6fe183435f2..cbedd515e55 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -6640,8 +6640,12 @@ impl Input { let command = render_prompt_chip_shell_command(command, shell_type); // Snapshot the current input so we can restore it after the command completes. let current_input = self.buffer_text(ctx); - if self.try_execute_command_from_source(&command, CommandExecutionSource::User, ctx) - { + if self.try_execute_command_from_source( + &command, + CommandExecutionSource::User, + true, + ctx, + ) { self.cancel_active_conversation(ctx, CancellationReason::UserCommandExecuted); if !current_input.is_empty() { self.input_contents_before_prompt_chip_command = Some(current_input); @@ -7712,6 +7716,7 @@ impl Input { ai_metadata: None, preserve_input, }, + true, ctx, ) } @@ -7779,8 +7784,13 @@ impl Input { let block_id = self.model.lock().block_list().active_block_id().clone(); let token = Uuid::new_v4().to_string(); let command = format!(" {helper_command} {token}"); - let started = - self.try_execute_command_from_source(&command, CommandExecutionSource::User, ctx); + // Not a command the user ran: Warp's history is independent of the shell histfile. + let started = self.try_execute_command_from_source( + &command, + CommandExecutionSource::User, + false, + ctx, + ); if started { self.pending_ctrl_r_handoff = Some(PendingCtrlRHandoff { session_id, @@ -7850,8 +7860,13 @@ impl Input { ctrl_t_draft_arg(&original_buffer, cursor_offset) )); } - let started = - self.try_execute_command_from_source(&command, CommandExecutionSource::User, ctx); + // Not a command the user ran: Warp's history is independent of the shell histfile. + let started = self.try_execute_command_from_source( + &command, + CommandExecutionSource::User, + false, + ctx, + ); if started { self.pending_ctrl_t_handoff = Some(PendingCtrlTHandoff { session_id, @@ -7927,10 +7942,11 @@ impl Input { self.try_execute_command_from_source( command, CommandExecutionSource::QueuedCommand, + true, ctx, ) } else { - self.try_execute_command_from_source(command, CommandExecutionSource::User, ctx) + self.try_execute_command_from_source(command, CommandExecutionSource::User, true, ctx) } } @@ -7974,6 +7990,7 @@ impl Input { &mut self, command: &str, source: CommandExecutionSource, + should_add_command_to_history: bool, ctx: &mut ViewContext, ) -> bool { if let CanExecuteCommand::No(reason) = self.can_execute_command(ctx) { @@ -8131,7 +8148,12 @@ impl Input { }); } - self.start_block_and_write_command_to_pty(command, source, ctx); + self.start_block_and_write_command_to_pty( + command, + source, + should_add_command_to_history, + ctx, + ); did_execute = true; } else { // We don't want to submit the command if precmd has not @@ -15975,6 +15997,7 @@ impl Input { &mut self, command: &str, source: CommandExecutionSource, + should_add_command_to_history: bool, ctx: &mut ViewContext, ) { start_trace!("command_execution:start"); @@ -16050,7 +16073,7 @@ impl Input { workflow_id, session_id, workflow_command, - should_add_command_to_history: true, + should_add_command_to_history, source, }))); end_trace!(); diff --git a/app/src/terminal/input_tests.rs b/app/src/terminal/input_tests.rs index c8e588394de..b0d747a2944 100644 --- a/app/src/terminal/input_tests.rs +++ b/app/src/terminal/input_tests.rs @@ -3461,6 +3461,89 @@ fn test_histignorespace_support_in_zsh() { }); } +/// The ctrl-r/ctrl-t helpers are Warp-internal. Fish does not honor histignorespace in +/// `Shell::should_add_command_to_history`, so a space prefix would still record them unless +/// `should_add_command_to_history` is false on the execute event. +#[cfg_attr(windows, ignore = "TODO(CORE-3626)")] +#[test] +fn fzf_ctrl_r_helper_is_not_added_to_warp_history() { + assert_fzf_helper_is_not_added_to_warp_history(|input, ctx| { + input.trigger_external_ctrl_r_history_search("warp_run_external_ctrl_r_widget", ctx) + }); +} + +#[cfg_attr(windows, ignore = "TODO(CORE-3626)")] +#[test] +fn fzf_ctrl_t_helper_is_not_added_to_warp_history() { + assert_fzf_helper_is_not_added_to_warp_history(|input, ctx| { + input.trigger_external_ctrl_t_file_search( + "warp_run_external_ctrl_t_widget", + CtrlTApplyMode::Splice, + ctx, + ) + }); +} + +fn assert_fzf_helper_is_not_added_to_warp_history( + trigger: impl Fn(&mut Input, &mut ViewContext) -> bool + 'static, +) { + let session_id: SessionId = 1.into(); + let session_info = SessionInfo::new_for_test() + .with_id(session_id) + .with_shell_type(ShellType::Fish); + + App::test((), |mut app| async move { + initialize_app(&mut app); + + let terminal = add_window_with_bootstrapped_terminal( + &mut app, + None, /* history_file_commands */ + Some(session_info), + ) + .await; + let input = terminal.read(&app, |view, _| view.input().clone()); + + History::handle(&app).read(&app, |history, _ctx| { + assert!(history.commands(session_id).unwrap().is_empty()); + }); + + let executed = Rc::new(RefCell::new(Vec::::new())); + let executed_for_subscription = executed.clone(); + app.update(|ctx| { + ctx.subscribe_to_view(&input, move |_, event: &super::Event, _| { + if let super::Event::ExecuteCommand(event) = event { + executed_for_subscription + .borrow_mut() + .push((**event).clone()); + } + }); + }); + + let started = input.update(&mut app, |input, ctx| trigger(input, ctx)); + assert!(started); + + let events = executed.borrow().clone(); + assert_eq!(events.len(), 1); + assert!( + !events[0].should_add_command_to_history, + "helper {} must not request Warp history", + events[0].command + ); + assert!( + events[0].command.contains("warp_run_external_ctrl_"), + "{}", + events[0].command + ); + + History::handle(&app).read(&app, |history, _ctx| { + assert!( + history.commands(session_id).unwrap().is_empty(), + "fzf helpers must not land in Warp history" + ); + }); + }); +} + fn build_suggestion_results>( suggestions: Vec, replacement_span: S, From 5a371dd505946640cb1aed091b70be0abc3bdceb Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:35:24 +0000 Subject: [PATCH 57/70] Revert fzf integration tests. Drop the live fzf handoff integration tests, Warp-history unit tests, CI fzf install, and integration-channel flag wiring. Keep Preview demotion, the bash 5.3 bind -X fix, and omitting helper invocations from Warp history. --- .github/workflows/ci.yml | 15 +- app/src/bin/integration.rs | 74 ++- app/src/terminal/input_tests.rs | 83 ---- crates/integration/src/bin/integration.rs | 77 ++-- crates/integration/src/test.rs | 2 - .../src/test/shell_widget_handoff.rs | 430 ------------------ crates/integration/src/util.rs | 39 -- .../tests/data/fzf/key-bindings.bash | 133 ------ .../tests/data/fzf/key-bindings.fish | 175 ------- .../tests/data/fzf/key-bindings.zsh | 121 ----- .../integration/shell_integration_tests.rs | 5 - 11 files changed, 69 insertions(+), 1085 deletions(-) delete mode 100644 crates/integration/src/test/shell_widget_handoff.rs delete mode 100644 crates/integration/tests/data/fzf/key-bindings.bash delete mode 100644 crates/integration/tests/data/fzf/key-bindings.fish delete mode 100644 crates/integration/tests/data/fzf/key-bindings.zsh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d87e410753..41a51569e24 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -146,8 +146,8 @@ jobs: uses: ConorMacBride/install-package@3e7ad059e07782ee54fa35f827df52aae0626f30 # v1 if: ${{ matrix.is_self_hosted == false }} with: - apt: zsh fish fzf - brew: fish bash fzf + apt: zsh fish + brew: fish bash - name: Echo Shells (UNIX) id: echo_shells_unix @@ -181,15 +181,6 @@ jobs: echo "powershell_path=$POWERSHELL_PATH" >> $GITHUB_OUTPUT echo "::notice title=${{ matrix.name }} Tests - Powershell Version::$POWERSHELL_VERSION" - FZF_PATH="$(command -v fzf || true)" - if [ -n "$FZF_PATH" ]; then - FZF_VERSION="$("$FZF_PATH" --version)" - echo "fzf_path=$FZF_PATH" >> $GITHUB_OUTPUT - echo "::notice title=${{ matrix.name }} Tests - fzf Version::$FZF_VERSION" - else - echo "::notice title=${{ matrix.name }} Tests - fzf Version::not installed" - fi - - name: Echo Shells (Windows) id: echo_shells_windows if: ${{ matrix.os == 'windows' }} @@ -480,7 +471,7 @@ jobs: - name: Install Shells uses: ConorMacBride/install-package@3e7ad059e07782ee54fa35f827df52aae0626f30 # v1 with: - apt: zsh fish fzf + apt: zsh fish - name: Echo default Bash id: echo_bash diff --git a/app/src/bin/integration.rs b/app/src/bin/integration.rs index 7a20a63143d..dae1762a345 100644 --- a/app/src/bin/integration.rs +++ b/app/src/bin/integration.rs @@ -1,8 +1,8 @@ use anyhow::Result; use clap::Parser; use warp_cli::WorkerCommand; +use warp_core::AppId; use warp_core::channel::{Channel, ChannelConfig, ChannelState, OzConfig, WarpServerConfig}; -use warp_core::{AppId, features}; #[derive(Debug, Default, Parser, Clone)] #[command(name = "warp-integration")] @@ -13,48 +13,40 @@ pub struct Args { } pub fn main() -> Result<()> { - ChannelState::set( - ChannelState::new( - Channel::Integration, - ChannelConfig { - app_id: AppId::new( - "dev", - "warp", - if cfg!(target_os = "macos") { - "Warp-Integration" - } else { - "WarpIntegration" - }, - ), - logfile_name: "warp_integration.log".into(), - server_config: WarpServerConfig { - firebase_auth_api_key: "".into(), - // Use an IP in the IANA testing range, with the TCP discard port, to - // black-hole server traffic. - server_root_url: "http://192.0.2.0:9".into(), - rtc_server_url: "ws://192.0.2.0:9/graphql/v2".into(), - session_sharing_server_url: None, - iap_config: None, + ChannelState::set(ChannelState::new( + Channel::Integration, + ChannelConfig { + app_id: AppId::new( + "dev", + "warp", + if cfg!(target_os = "macos") { + "Warp-Integration" + } else { + "WarpIntegration" }, - oz_config: OzConfig { - // Use an IP in the IANA testing range, with the TCP discard port, to - // black-hole server traffic. - oz_root_url: "http://192.0.2.0:9".into(), - workload_audience_url: None, - }, - telemetry_config: None, - crash_reporting_config: None, - autoupdate_config: None, - mcp_static_config: None, + ), + logfile_name: "warp_integration.log".into(), + server_config: WarpServerConfig { + firebase_auth_api_key: "".into(), + // Use an IP in the IANA testing range, with the TCP discard port, to + // black-hole server traffic. + server_root_url: "http://192.0.2.0:9".into(), + rtc_server_url: "ws://192.0.2.0:9/graphql/v2".into(), + session_sharing_server_url: None, + iap_config: None, + }, + oz_config: OzConfig { + // Use an IP in the IANA testing range, with the TCP discard port, to + // black-hole server traffic. + oz_root_url: "http://192.0.2.0:9".into(), + workload_audience_url: None, }, - ) - // Enabled here rather than left to the tests' own `set_enabled`, because the handoff is - // only wired up correctly when the flag is set before the channel initializes its - // features. Only this flag is added: pulling in whole PREVIEW/DOGFOOD sets would also - // turn on unrelated in-progress features (NativeShellCompletions among them) and change - // behavior for every other integration test. - .with_additional_features(&[features::FeatureFlag::ShellWidgetHandoff]), - ); + telemetry_config: None, + crash_reporting_config: None, + autoupdate_config: None, + mcp_static_config: None, + }, + )); let args = Args::parse(); diff --git a/app/src/terminal/input_tests.rs b/app/src/terminal/input_tests.rs index b0d747a2944..c8e588394de 100644 --- a/app/src/terminal/input_tests.rs +++ b/app/src/terminal/input_tests.rs @@ -3461,89 +3461,6 @@ fn test_histignorespace_support_in_zsh() { }); } -/// The ctrl-r/ctrl-t helpers are Warp-internal. Fish does not honor histignorespace in -/// `Shell::should_add_command_to_history`, so a space prefix would still record them unless -/// `should_add_command_to_history` is false on the execute event. -#[cfg_attr(windows, ignore = "TODO(CORE-3626)")] -#[test] -fn fzf_ctrl_r_helper_is_not_added_to_warp_history() { - assert_fzf_helper_is_not_added_to_warp_history(|input, ctx| { - input.trigger_external_ctrl_r_history_search("warp_run_external_ctrl_r_widget", ctx) - }); -} - -#[cfg_attr(windows, ignore = "TODO(CORE-3626)")] -#[test] -fn fzf_ctrl_t_helper_is_not_added_to_warp_history() { - assert_fzf_helper_is_not_added_to_warp_history(|input, ctx| { - input.trigger_external_ctrl_t_file_search( - "warp_run_external_ctrl_t_widget", - CtrlTApplyMode::Splice, - ctx, - ) - }); -} - -fn assert_fzf_helper_is_not_added_to_warp_history( - trigger: impl Fn(&mut Input, &mut ViewContext) -> bool + 'static, -) { - let session_id: SessionId = 1.into(); - let session_info = SessionInfo::new_for_test() - .with_id(session_id) - .with_shell_type(ShellType::Fish); - - App::test((), |mut app| async move { - initialize_app(&mut app); - - let terminal = add_window_with_bootstrapped_terminal( - &mut app, - None, /* history_file_commands */ - Some(session_info), - ) - .await; - let input = terminal.read(&app, |view, _| view.input().clone()); - - History::handle(&app).read(&app, |history, _ctx| { - assert!(history.commands(session_id).unwrap().is_empty()); - }); - - let executed = Rc::new(RefCell::new(Vec::::new())); - let executed_for_subscription = executed.clone(); - app.update(|ctx| { - ctx.subscribe_to_view(&input, move |_, event: &super::Event, _| { - if let super::Event::ExecuteCommand(event) = event { - executed_for_subscription - .borrow_mut() - .push((**event).clone()); - } - }); - }); - - let started = input.update(&mut app, |input, ctx| trigger(input, ctx)); - assert!(started); - - let events = executed.borrow().clone(); - assert_eq!(events.len(), 1); - assert!( - !events[0].should_add_command_to_history, - "helper {} must not request Warp history", - events[0].command - ); - assert!( - events[0].command.contains("warp_run_external_ctrl_"), - "{}", - events[0].command - ); - - History::handle(&app).read(&app, |history, _ctx| { - assert!( - history.commands(session_id).unwrap().is_empty(), - "fzf helpers must not land in Warp history" - ); - }); - }); -} - fn build_suggestion_results>( suggestions: Vec, replacement_span: S, diff --git a/crates/integration/src/bin/integration.rs b/crates/integration/src/bin/integration.rs index 6e8e90384d9..7dab908e55e 100644 --- a/crates/integration/src/bin/integration.rs +++ b/crates/integration/src/bin/integration.rs @@ -6,8 +6,8 @@ use clap::Parser; use integration::Builder; use integration::test::*; use warp_cli::WorkerCommand; +use warp_core::AppId; use warp_core::channel::{Channel, ChannelConfig, ChannelState, OzConfig, WarpServerConfig}; -use warp_core::{AppId, features}; /// The Warp integration test runner. #[derive(Debug, Default, Parser, Clone)] @@ -24,48 +24,40 @@ pub struct Args { } pub fn main() -> Result<()> { - ChannelState::set( - ChannelState::new( - Channel::Integration, - ChannelConfig { - app_id: AppId::new( - "dev", - "warp", - if cfg!(target_os = "macos") { - "Warp-Integration" - } else { - "WarpIntegration" - }, - ), - logfile_name: "warp_integration.log".into(), - server_config: WarpServerConfig { - firebase_auth_api_key: "".into(), - iap_config: None, - // Use an IP in the IANA testing range, with the TCP discard port, to - // black-hole server traffic. - server_root_url: "http://192.0.2.0:9".into(), - rtc_server_url: "ws://192.0.2.0:9/graphql/v2".into(), - session_sharing_server_url: None, + ChannelState::set(ChannelState::new( + Channel::Integration, + ChannelConfig { + app_id: AppId::new( + "dev", + "warp", + if cfg!(target_os = "macos") { + "Warp-Integration" + } else { + "WarpIntegration" }, - oz_config: OzConfig { - // Use an IP in the IANA testing range, with the TCP discard port, to - // black-hole server traffic. - oz_root_url: "http://192.0.2.0:9".into(), - workload_audience_url: None, - }, - telemetry_config: None, - crash_reporting_config: None, - autoupdate_config: None, - mcp_static_config: None, + ), + logfile_name: "warp_integration.log".into(), + server_config: WarpServerConfig { + firebase_auth_api_key: "".into(), + iap_config: None, + // Use an IP in the IANA testing range, with the TCP discard port, to + // black-hole server traffic. + server_root_url: "http://192.0.2.0:9".into(), + rtc_server_url: "ws://192.0.2.0:9/graphql/v2".into(), + session_sharing_server_url: None, + }, + oz_config: OzConfig { + // Use an IP in the IANA testing range, with the TCP discard port, to + // black-hole server traffic. + oz_root_url: "http://192.0.2.0:9".into(), + workload_audience_url: None, }, - ) - // Enabled here rather than left to the tests' own `set_enabled`, because the handoff is - // only wired up correctly when the flag is set before the channel initializes its - // features. Only this flag is added: pulling in whole PREVIEW/DOGFOOD sets would also - // turn on unrelated in-progress features (NativeShellCompletions among them) and change - // behavior for every other integration test. - .with_additional_features(&[features::FeatureFlag::ShellWidgetHandoff]), - ); + telemetry_config: None, + crash_reporting_config: None, + autoupdate_config: None, + mcp_static_config: None, + }, + )); let args = Args::parse(); @@ -349,9 +341,6 @@ fn register_tests() -> HashMap<&'static str, BoxedBuilderFn> { register_test!(test_command_search_loads_history); register_test!(test_histfile_left_joined_with_persisted_history); - register_test!(test_fzf_ctrl_r_selects_history_unexecuted); - register_test!(test_fzf_ctrl_t_inserts_selection); - register_test!(test_fzf_ctrl_r_cancel_restores_draft); register_test!(test_history_command_is_linked_to_local_workflow); register_test!(test_up_arrow_history_enters_shift_tab_for_workflow); diff --git a/crates/integration/src/test.rs b/crates/integration/src/test.rs index 636eeb218cd..f6f9795a6a5 100644 --- a/crates/integration/src/test.rs +++ b/crates/integration/src/test.rs @@ -33,7 +33,6 @@ mod settings_file_hot_reload; mod settings_file_migration; mod settings_navigation; mod settings_private; -mod shell_widget_handoff; mod ssh; mod subshell; mod sync_inputs; @@ -88,7 +87,6 @@ pub use settings_file_migration::*; pub use settings_navigation::*; pub use settings_private::*; use shell::ShellType; -pub use shell_widget_handoff::*; pub use ssh::*; pub use subshell::*; use sum_tree::SeekBias; diff --git a/crates/integration/src/test/shell_widget_handoff.rs b/crates/integration/src/test/shell_widget_handoff.rs deleted file mode 100644 index bec9f1e2773..00000000000 --- a/crates/integration/src/test/shell_widget_handoff.rs +++ /dev/null @@ -1,430 +0,0 @@ -//! Live fzf ctrl-r / ctrl-t handoff tests. Skipped when fzf is not installed or the current -//! shell is not bash/zsh/fish (the shells fzf ships key-bindings for). - -use std::path::{Path, PathBuf}; -use std::time::Duration; - -use command::blocking::Command; -use warp::features::FeatureFlag; -use warp::integration_testing; -use warp::integration_testing::step::new_step_with_default_assertions; -use warp::integration_testing::terminal::util::{ - ExpectedExitStatus, current_shell_starter_and_version, -}; -use warp::integration_testing::terminal::{ - assert_input_editor_contents, execute_command_for_single_terminal_in_tab, - wait_until_bootstrapped_single_pane_for_tab, -}; -use warp::integration_testing::view_getters::{ - single_input_view_for_tab, single_terminal_view_for_tab, workspace_view, -}; -use warp::terminal::shell::ShellType; -use warpui_core::integration::{AssertionCallback, TestStep}; -use warpui_core::{async_assert, async_assert_eq}; - -use super::{TEST_ONLY_ASSETS, new_builder}; -use crate::Builder; -use crate::util::{ - ShellRcType, set_zsh_histfile_location, should_run_fzf_widget_handoff_tests, - write_rc_files_for_test, -}; - -const CTRL_R_HISTORY_COMMAND: &str = "echo fzf_ctrl_r_marker_alpha"; -const CTRL_R_HISTORY_OUTPUT: &str = "fzf_ctrl_r_marker_alpha"; -const CTRL_R_DRAFT: &str = "draft_before_ctrl_r"; -const CTRL_T_FILENAME: &str = "fzf_ctrl_t_marker_file.txt"; -const CTRL_T_PREFIX: &str = "echo "; -const FZF_STEP_TIMEOUT: Duration = Duration::from_secs(20); - -fn fzf_handoff_builder() -> Builder { - FeatureFlag::ShellWidgetHandoff.set_enabled(true); - new_builder() - .set_should_run_test(should_run_fzf_widget_handoff_tests) - .with_setup(|utils| { - let home = utils.test_dir(); - install_fzf_key_bindings(&home); - std::fs::write(home.join(CTRL_T_FILENAME), b"") - .expect("should be able to create the ctrl-t marker file"); - }) -} - -fn install_fzf_key_bindings(home: &Path) { - write_rc_files_for_test(home, bash_fzf_rc(home), [ShellRcType::Bash]); - write_rc_files_for_test(home, zsh_fzf_rc(home), [ShellRcType::Zsh]); - write_rc_files_for_test(home, fish_fzf_rc(home), [ShellRcType::Fish]); - set_zsh_histfile_location(home); -} - -fn bash_fzf_rc(home: &Path) -> String { - if fzf_dumps_script("--bash") { - "eval \"$(fzf --bash)\"\n".to_owned() - } else { - format!( - ". '{}'\n", - ensure_fzf_key_bindings(home, "key-bindings.bash").display() - ) - } -} - -fn zsh_fzf_rc(home: &Path) -> String { - if fzf_dumps_script("--zsh") { - "source <(fzf --zsh)\n".to_owned() - } else { - format!( - "source '{}'\n", - ensure_fzf_key_bindings(home, "key-bindings.zsh").display() - ) - } -} - -fn fish_fzf_rc(home: &Path) -> String { - if fzf_dumps_script("--fish") { - "fzf --fish | source\n".to_owned() - } else if fzf_key_bindings_path("key-bindings.fish").is_some() - || Path::new("/usr/share/fish/vendor_functions.d/fzf_key_bindings.fish").is_file() - { - // Distro packages often install the function without calling it. - "if functions -q fzf_key_bindings; fzf_key_bindings; end\n".to_owned() - } else { - format!( - "source '{}'\n", - ensure_fzf_key_bindings(home, "key-bindings.fish").display() - ) - } -} - -fn ensure_fzf_key_bindings(home: &Path, filename: &str) -> PathBuf { - if let Some(path) = fzf_key_bindings_path(filename) { - return path; - } - let dest = home.join(filename); - integration_testing::create_file_from_assets( - TEST_ONLY_ASSETS, - &format!("fzf/{filename}"), - &dest, - ); - dest -} - -fn fzf_dumps_script(flag: &str) -> bool { - Command::new("fzf") - .arg(flag) - .output() - .map(|output| output.status.success() && !output.stdout.is_empty()) - .unwrap_or(false) -} - -fn fzf_key_bindings_path(filename: &str) -> Option { - let mut candidates = vec![ - PathBuf::from(format!("/usr/share/doc/fzf/examples/{filename}")), - PathBuf::from(format!("/usr/share/fzf/{filename}")), - PathBuf::from(format!("/usr/share/fzf/shell/{filename}")), - PathBuf::from(format!("/opt/homebrew/opt/fzf/shell/{filename}")), - PathBuf::from(format!("/usr/local/opt/fzf/shell/{filename}")), - ]; - if let Ok(output) = Command::new("brew").args(["--prefix", "fzf"]).output() - && output.status.success() - { - let prefix = String::from_utf8_lossy(&output.stdout); - let prefix = prefix.trim(); - if !prefix.is_empty() { - candidates.push(PathBuf::from(prefix).join("shell").join(filename)); - } - } - candidates.into_iter().find(|path| path.is_file()) -} - -fn assert_command_search_is_closed() -> AssertionCallback { - Box::new(move |app, window_id| { - let workspace_view = workspace_view(app, window_id); - workspace_view.read(app, |workspace, _ctx| { - async_assert!( - !workspace.is_command_search_open(), - "Warp command search should not open when fzf owns the key" - ) - }) - }) -} - -fn assert_shell_plugin_tag(tag: &'static str) -> AssertionCallback { - Box::new(move |app, window_id| { - let terminal_view = single_terminal_view_for_tab(app, window_id, 0); - terminal_view.read(app, |view, ctx| { - let Some(session_id) = view.active_block_session_id() else { - return warpui_core::integration::AssertionOutcome::failure( - "expected an active session after bootstrap".into(), - ); - }; - let Some(session) = view.sessions(ctx).get(session_id) else { - return warpui_core::integration::AssertionOutcome::failure( - "expected the active session to be registered".into(), - ); - }; - let plugins = session.shell().plugins(); - async_assert!( - plugins.contains(tag), - "expected shell plugin tag {tag}, have {plugins:?}" - ) - }) - }) -} - -fn assert_shell_widget_handoff_enabled() -> AssertionCallback { - Box::new(move |_app, _window_id| { - async_assert!( - FeatureFlag::ShellWidgetHandoff.is_enabled(), - "ShellWidgetHandoff must be enabled or these tests exercise nothing" - ) - }) -} - -fn assert_fzf_shows(text: &'static str) -> AssertionCallback { - Box::new(move |app, window_id| { - let terminal_view = single_terminal_view_for_tab(app, window_id, 0); - terminal_view.read(app, |view, _| { - let model = view.model.lock(); - let alt = model.alt_screen().output_to_string(); - let block = model.block_list().active_block().output_to_string(); - async_assert!( - alt.contains(text) || block.contains(text), - "fzf should show {text:?}; alt-screen={alt:?} block={block:?}" - ) - }) - }) -} - -fn assert_finished_command_count(needle: &'static str, expected: usize) -> AssertionCallback { - Box::new(move |app, window_id| { - let terminal_view = single_terminal_view_for_tab(app, window_id, 0); - terminal_view.read(app, |view, _ctx| { - let model = view.model.lock(); - let count = model - .block_list() - .blocks() - .iter() - .filter(|block| block.finished() && block.command_to_string().contains(needle)) - .count(); - async_assert_eq!( - count, - expected, - "expected {expected} finished command(s) containing {needle:?}, found {count}" - ) - }) - }) -} - -fn assert_input_contains(text: &'static str) -> AssertionCallback { - Box::new(move |app, window_id| { - let input = single_input_view_for_tab(app, window_id, 0); - input.read(app, |view, ctx| { - let contents = view.buffer_text(ctx); - async_assert!( - contents.contains(text), - "input {contents:?} should contain {text:?}" - ) - }) - }) -} - -fn open_fzf(key: &'static str) -> TestStep { - TestStep::new("Wait for fzf to take over the PTY") - .with_keystrokes(&[key]) - .set_timeout(FZF_STEP_TIMEOUT) - .add_named_assertion( - "command search stayed closed", - assert_command_search_is_closed(), - ) - .add_named_assertion( - "fzf is running as a long-running command", - assert_fzf_is_running(), - ) -} - -fn assert_fzf_is_running() -> AssertionCallback { - Box::new(move |app, window_id| { - let terminal_view = single_terminal_view_for_tab(app, window_id, 0); - terminal_view.read(app, |view, _ctx| { - let is_editor_focused = view - .input() - .read(app, |input, ctx| input.editor().is_focused(ctx)); - let buffer = view - .input() - .read(app, |input, ctx| input.buffer_text(ctx)); - let model = view.model.lock(); - let active_block = model.block_list().active_block(); - let output = active_block.output_to_string(); - let long_running = active_block.is_active_and_long_running(); - // bash may not emit preexec for the leading-space helper invocation, so do not - // require is_executing(); the editor hiding and long-running block are the handoff. - async_assert!( - !is_editor_focused && long_running, - "expected fzf long-running; editor_focused={is_editor_focused} long_running={long_running} buffer={buffer:?} output={output:?}" - ) - }) - }) -} - -/// ctrl-r opens the real fzf history picker and lands the selected command in the editor -/// unexecuted. -pub fn test_fzf_ctrl_r_selects_history_unexecuted() -> Builder { - fzf_handoff_builder() - .with_step( - wait_until_bootstrapped_single_pane_for_tab(0).add_named_assertion( - "ShellWidgetHandoff is enabled", - assert_shell_widget_handoff_enabled(), - ), - ) - .with_step( - new_step_with_default_assertions("Bootstrap reported the fzf ctrl-r plugin tag") - .add_named_assertion( - "external_ctrl_r_history is tagged", - assert_shell_plugin_tag("external_ctrl_r_history"), - ), - ) - .with_step(execute_command_for_single_terminal_in_tab( - 0, - CTRL_R_HISTORY_COMMAND.to_owned(), - ExpectedExitStatus::Success, - CTRL_R_HISTORY_OUTPUT, - )) - .with_step(open_fzf("ctrl-r")) - .with_step( - TestStep::new("Filter to the unique history entry") - .with_typed_characters(&[CTRL_R_HISTORY_OUTPUT]) - .set_timeout(FZF_STEP_TIMEOUT) - .add_named_assertion( - "fzf lists the unique history entry", - assert_fzf_shows(CTRL_R_HISTORY_OUTPUT), - ), - ) - .with_step( - TestStep::new("Accept the fzf selection") - .with_keystrokes(&["enter"]) - .set_timeout(FZF_STEP_TIMEOUT), - ) - .with_step( - new_step_with_default_assertions( - "Selected history command is in the editor unexecuted", - ) - .add_named_assertion( - "command search stayed closed", - assert_command_search_is_closed(), - ) - .add_named_assertion( - "selected command was not executed again", - assert_finished_command_count(CTRL_R_HISTORY_COMMAND, 1), - ) - .add_named_assertion( - "input contains the selected command", - assert_input_editor_contents(0, CTRL_R_HISTORY_COMMAND), - ), - ) -} - -/// ctrl-t opens the real fzf file picker and lands the selection in the editor. bash/zsh splice -/// at the cursor (prefix preserved); fish applies the widget's own finished buffer. -pub fn test_fzf_ctrl_t_inserts_selection() -> Builder { - let is_fish = current_shell_starter_and_version().0.shell_type() == ShellType::Fish; - fzf_handoff_builder() - .with_step(wait_until_bootstrapped_single_pane_for_tab(0).add_named_assertion( - "ShellWidgetHandoff is enabled", - assert_shell_widget_handoff_enabled(), - )) - .with_step( - new_step_with_default_assertions("Bootstrap reported the fzf ctrl-t plugin tag") - .add_named_assertion( - "external_ctrl_t_file is tagged", - assert_shell_plugin_tag("external_ctrl_t_file"), - ), - ) - .with_step( - new_step_with_default_assertions("Type a prefix so splice vs replace is observable") - .with_typed_characters(&[CTRL_T_PREFIX]) - .with_keystrokes(&["escape"]) - .add_named_assertion( - "prefix is in the input", - assert_input_editor_contents(0, CTRL_T_PREFIX), - ), - ) - .with_step(open_fzf("ctrl-t")) - .with_step( - TestStep::new("Filter to the unique file and accept") - .with_typed_characters(&[CTRL_T_FILENAME]) - .with_keystrokes(&["enter"]) - .set_timeout(FZF_STEP_TIMEOUT), - ) - .with_step( - new_step_with_default_assertions("Selected file landed in the editor unexecuted") - .add_named_assertion("command search stayed closed", assert_command_search_is_closed()) - .add_named_assertion( - "input contains the selected filename", - assert_input_contains(CTRL_T_FILENAME), - ) - .add_named_assertion( - "selected file was not executed as a command", - assert_finished_command_count(CTRL_T_FILENAME, 0), - ) - .add_named_assertion("prefix is preserved on bash/zsh", move |app, window_id| { - if is_fish { - return warpui_core::integration::AssertionOutcome::Success; - } - let input = single_input_view_for_tab(app, window_id, 0); - input.read(app, |view, ctx| { - let contents = view.buffer_text(ctx); - async_assert!( - contents.contains(CTRL_T_PREFIX.trim()), - "bash/zsh should splice at the cursor, keeping the prefix; got {contents:?}" - ) - }) - }), - ) -} - -/// Cancel restores the draft that was in the editor when ctrl-r was pressed. -pub fn test_fzf_ctrl_r_cancel_restores_draft() -> Builder { - fzf_handoff_builder() - .with_step( - wait_until_bootstrapped_single_pane_for_tab(0).add_named_assertion( - "ShellWidgetHandoff is enabled", - assert_shell_widget_handoff_enabled(), - ), - ) - .with_step( - new_step_with_default_assertions("Bootstrap reported the fzf ctrl-r plugin tag") - .add_named_assertion( - "external_ctrl_r_history is tagged", - assert_shell_plugin_tag("external_ctrl_r_history"), - ), - ) - .with_step(execute_command_for_single_terminal_in_tab( - 0, - CTRL_R_HISTORY_COMMAND.to_owned(), - ExpectedExitStatus::Success, - CTRL_R_HISTORY_OUTPUT, - )) - .with_step( - new_step_with_default_assertions("Type a draft to restore on cancel") - .with_typed_characters(&[CTRL_R_DRAFT]) - .add_named_assertion( - "draft is in the input", - assert_input_editor_contents(0, CTRL_R_DRAFT), - ), - ) - .with_step(open_fzf("ctrl-r")) - .with_step( - TestStep::new("Cancel fzf") - .with_keystrokes(&["escape"]) - .set_timeout(FZF_STEP_TIMEOUT), - ) - .with_step( - new_step_with_default_assertions("Draft is restored after cancel") - .add_named_assertion( - "command search stayed closed", - assert_command_search_is_closed(), - ) - .add_named_assertion( - "input still has the original draft", - assert_input_editor_contents(0, CTRL_R_DRAFT), - ), - ) -} diff --git a/crates/integration/src/util.rs b/crates/integration/src/util.rs index cb0c16031aa..42ac49d595e 100644 --- a/crates/integration/src/util.rs +++ b/crates/integration/src/util.rs @@ -2,7 +2,6 @@ use std::fs::{OpenOptions, create_dir_all, write}; use std::io::Write; use std::path::{Path, PathBuf}; -use command::blocking::Command; use itertools::Itertools as _; use strum::IntoEnumIterator; use strum_macros::EnumIter; @@ -224,44 +223,6 @@ pub fn skip_if_powershell_core_2303() -> bool { !matches!(starter.shell_type(), ShellType::PowerShell) } -/// True when `fzf` is on `PATH` and answers `--version`. -pub fn fzf_is_installed() -> bool { - Command::new("fzf") - .arg("--version") - .output() - .map(|output| output.status.success()) - .unwrap_or(false) -} - -/// Gate for live fzf ctrl-r / ctrl-t handoff tests: fzf must be installed, and the current shell -/// must be one fzf ships key-bindings for. Bash older than 4.3 is skipped because detection uses -/// `bind -X`, which that version does not have. -pub fn should_run_fzf_widget_handoff_tests() -> bool { - if !fzf_is_installed() { - return false; - } - let (starter, version) = current_shell_starter_and_version(); - match starter.shell_type() { - ShellType::Zsh | ShellType::Fish => true, - ShellType::Bash => bash_version_supports_bind_x(&version), - ShellType::PowerShell => false, - } -} - -fn bash_version_supports_bind_x(version: &str) -> bool { - let numeric: String = version - .chars() - .take_while(|c| c.is_ascii_digit() || *c == '.') - .collect(); - let Some(actual) = Version::from(&numeric) else { - return false; - }; - let Some(minimum) = Version::from("4.3") else { - return false; - }; - actual >= minimum -} - /// Gets the name of the system user for which the test binary is running. pub fn get_local_user() -> String { whoami::username() diff --git a/crates/integration/tests/data/fzf/key-bindings.bash b/crates/integration/tests/data/fzf/key-bindings.bash deleted file mode 100644 index c4dce3ba5bf..00000000000 --- a/crates/integration/tests/data/fzf/key-bindings.bash +++ /dev/null @@ -1,133 +0,0 @@ -# ____ ____ -# / __/___ / __/ -# / /_/_ / / /_ -# / __/ / /_/ __/ -# /_/ /___/_/ key-bindings.bash -# -# - $FZF_TMUX_OPTS -# - $FZF_CTRL_T_COMMAND -# - $FZF_CTRL_T_OPTS -# - $FZF_CTRL_R_OPTS -# - $FZF_ALT_C_COMMAND -# - $FZF_ALT_C_OPTS - -[[ $- =~ i ]] || return 0 - - -# Key bindings -# ------------ -__fzf_select__() { - local cmd opts - cmd="${FZF_CTRL_T_COMMAND:-"command find -L . -mindepth 1 \\( -path '*/.*' -o -fstype 'sysfs' -o -fstype 'devfs' -o -fstype 'devtmpfs' -o -fstype 'proc' \\) -prune \ - -o -type f -print \ - -o -type d -print \ - -o -type l -print 2> /dev/null | command cut -b3-"}" - opts="--height ${FZF_TMUX_HEIGHT:-40%} --bind=ctrl-z:ignore --reverse --scheme=path ${FZF_DEFAULT_OPTS-} ${FZF_CTRL_T_OPTS-} -m" - eval "$cmd" | - FZF_DEFAULT_OPTS="$opts" $(__fzfcmd) "$@" | - while read -r item; do - printf '%q ' "$item" # escape special chars - done -} - -__fzfcmd() { - [[ -n "${TMUX_PANE-}" ]] && { [[ "${FZF_TMUX:-0}" != 0 ]] || [[ -n "${FZF_TMUX_OPTS-}" ]]; } && - echo "fzf-tmux ${FZF_TMUX_OPTS:--d${FZF_TMUX_HEIGHT:-40%}} -- " || echo "fzf" -} - -fzf-file-widget() { - local selected="$(__fzf_select__ "$@")" - READLINE_LINE="${READLINE_LINE:0:$READLINE_POINT}$selected${READLINE_LINE:$READLINE_POINT}" - READLINE_POINT=$(( READLINE_POINT + ${#selected} )) -} - -__fzf_cd__() { - local cmd opts dir - cmd="${FZF_ALT_C_COMMAND:-"command find -L . -mindepth 1 \\( -path '*/.*' -o -fstype 'sysfs' -o -fstype 'devfs' -o -fstype 'devtmpfs' -o -fstype 'proc' \\) -prune \ - -o -type d -print 2> /dev/null | command cut -b3-"}" - opts="--height ${FZF_TMUX_HEIGHT:-40%} --bind=ctrl-z:ignore --reverse --scheme=path ${FZF_DEFAULT_OPTS-} ${FZF_ALT_C_OPTS-} +m" - dir=$(set +o pipefail; eval "$cmd" | FZF_DEFAULT_OPTS="$opts" $(__fzfcmd)) && printf 'builtin cd -- %q' "$dir" -} - -if command -v perl > /dev/null; then - __fzf_history__() { - local output opts script - opts="--height ${FZF_TMUX_HEIGHT:-40%} --bind=ctrl-z:ignore ${FZF_DEFAULT_OPTS-} -n2..,.. --scheme=history --bind=ctrl-r:toggle-sort ${FZF_CTRL_R_OPTS-} +m --read0" - script='BEGIN { getc; $/ = "\n\t"; $HISTCOUNT = $ENV{last_hist} + 1 } s/^[ *]//; print $HISTCOUNT - $. . "\t$_" if !$seen{$_}++' - output=$( - set +o pipefail - builtin fc -lnr -2147483648 | - last_hist=$(HISTTIMEFORMAT='' builtin history 1) command perl -n -l0 -e "$script" | - FZF_DEFAULT_OPTS="$opts" $(__fzfcmd) --query "$READLINE_LINE" - ) || return - READLINE_LINE=${output#*$'\t'} - if [[ -z "$READLINE_POINT" ]]; then - echo "$READLINE_LINE" - else - READLINE_POINT=0x7fffffff - fi - } -else # awk - fallback for POSIX systems - __fzf_history__() { - local output opts script n x y z d - if [[ -z $__fzf_awk ]]; then - __fzf_awk=awk - # choose the faster mawk if: it's installed && build date >= 20230322 && version >= 1.3.4 - IFS=' .' read n x y z d <<< $(command mawk -W version 2> /dev/null) - [[ $n == mawk ]] && (( d >= 20230302 && (x *1000 +y) *1000 +z >= 1003004 )) && __fzf_awk=mawk - fi - opts="--height ${FZF_TMUX_HEIGHT:-40%} --bind=ctrl-z:ignore ${FZF_DEFAULT_OPTS-} -n2..,.. --scheme=history --bind=ctrl-r:toggle-sort ${FZF_CTRL_R_OPTS-} +m --read0" - [[ $(HISTTIMEFORMAT='' builtin history 1) =~ [[:digit:]]+ ]] # how many history entries - script='function P(b) { ++n; sub(/^[ *]/, "", b); if (!seen[b]++) { printf "%d\t%s%c", '$((BASH_REMATCH + 1))' - n, b, 0 } } - NR==1 { b = substr($0, 2); next } - /^\t/ { P(b); b = substr($0, 2); next } - { b = b RS $0 } - END { if (NR) P(b) }' - output=$( - set +o pipefail - builtin fc -lnr -2147483648 2> /dev/null | # ( $'\t '$'\n' )* ; ::= [^\n]* ( $'\n' )* - command $__fzf_awk "$script" | # ( $'\t'$'\000' )* - FZF_DEFAULT_OPTS="$opts" $(__fzfcmd) --query "$READLINE_LINE" - ) || return - READLINE_LINE=${output#*$'\t'} - if [[ -z "$READLINE_POINT" ]]; then - echo "$READLINE_LINE" - else - READLINE_POINT=0x7fffffff - fi - } -fi - -# Required to refresh the prompt after fzf -bind -m emacs-standard '"\er": redraw-current-line' - -bind -m vi-command '"\C-z": emacs-editing-mode' -bind -m vi-insert '"\C-z": emacs-editing-mode' -bind -m emacs-standard '"\C-z": vi-editing-mode' - -if (( BASH_VERSINFO[0] < 4 )); then - # CTRL-T - Paste the selected file path into the command line - bind -m emacs-standard '"\C-t": " \C-b\C-k \C-u`__fzf_select__`\e\C-e\er\C-a\C-y\C-h\C-e\e \C-y\ey\C-x\C-x\C-f"' - bind -m vi-command '"\C-t": "\C-z\C-t\C-z"' - bind -m vi-insert '"\C-t": "\C-z\C-t\C-z"' - - # CTRL-R - Paste the selected command from history into the command line - bind -m emacs-standard '"\C-r": "\C-e \C-u\C-y\ey\C-u`__fzf_history__`\e\C-e\er"' - bind -m vi-command '"\C-r": "\C-z\C-r\C-z"' - bind -m vi-insert '"\C-r": "\C-z\C-r\C-z"' -else - # CTRL-T - Paste the selected file path into the command line - bind -m emacs-standard -x '"\C-t": fzf-file-widget' - bind -m vi-command -x '"\C-t": fzf-file-widget' - bind -m vi-insert -x '"\C-t": fzf-file-widget' - - # CTRL-R - Paste the selected command from history into the command line - bind -m emacs-standard -x '"\C-r": __fzf_history__' - bind -m vi-command -x '"\C-r": __fzf_history__' - bind -m vi-insert -x '"\C-r": __fzf_history__' -fi - -# ALT-C - cd into the selected directory -bind -m emacs-standard '"\ec": " \C-b\C-k \C-u`__fzf_cd__`\e\C-e\er\C-m\C-y\C-h\e \C-y\ey\C-x\C-x\C-d"' -bind -m vi-command '"\ec": "\C-z\ec\C-z"' -bind -m vi-insert '"\ec": "\C-z\ec\C-z"' diff --git a/crates/integration/tests/data/fzf/key-bindings.fish b/crates/integration/tests/data/fzf/key-bindings.fish deleted file mode 100644 index 69f769703a2..00000000000 --- a/crates/integration/tests/data/fzf/key-bindings.fish +++ /dev/null @@ -1,175 +0,0 @@ -# ____ ____ -# / __/___ / __/ -# / /_/_ / / /_ -# / __/ / /_/ __/ -# /_/ /___/_/ key-bindings.fish -# -# - $FZF_TMUX_OPTS -# - $FZF_CTRL_T_COMMAND -# - $FZF_CTRL_T_OPTS -# - $FZF_CTRL_R_OPTS -# - $FZF_ALT_C_COMMAND -# - $FZF_ALT_C_OPTS - -status is-interactive; or exit 0 - - -# Key bindings -# ------------ -function fzf_key_bindings - - # Store current token in $dir as root for the 'find' command - function fzf-file-widget -d "List files and folders" - set -l commandline (__fzf_parse_commandline) - set -l dir $commandline[1] - set -l fzf_query $commandline[2] - set -l prefix $commandline[3] - - # "-path \$dir'*/.*'" matches hidden files/folders inside $dir but not - # $dir itself, even if hidden. - test -n "$FZF_CTRL_T_COMMAND"; or set -l FZF_CTRL_T_COMMAND " - command find -L \$dir -mindepth 1 \\( -path \$dir'*/.*' -o -fstype 'sysfs' -o -fstype 'devfs' -o -fstype 'devtmpfs' \\) -prune \ - -o -type f -print \ - -o -type d -print \ - -o -type l -print 2> /dev/null | sed 's@^\./@@'" - - test -n "$FZF_TMUX_HEIGHT"; or set FZF_TMUX_HEIGHT 40% - begin - set -lx FZF_DEFAULT_OPTS "--height $FZF_TMUX_HEIGHT --reverse --scheme=path --bind=ctrl-z:ignore $FZF_DEFAULT_OPTS $FZF_CTRL_T_OPTS" - eval "$FZF_CTRL_T_COMMAND | "(__fzfcmd)' -m --query "'$fzf_query'"' | while read -l r; set result $result $r; end - end - if [ -z "$result" ] - commandline -f repaint - return - else - # Remove last token from commandline. - commandline -t "" - end - for i in $result - commandline -it -- $prefix - commandline -it -- (string escape $i) - commandline -it -- ' ' - end - commandline -f repaint - end - - function fzf-history-widget -d "Show command history" - test -n "$FZF_TMUX_HEIGHT"; or set FZF_TMUX_HEIGHT 40% - begin - set -lx FZF_DEFAULT_OPTS "--height $FZF_TMUX_HEIGHT $FZF_DEFAULT_OPTS --scheme=history --bind=ctrl-r:toggle-sort,ctrl-z:ignore $FZF_CTRL_R_OPTS +m" - - set -l FISH_MAJOR (echo $version | cut -f1 -d.) - set -l FISH_MINOR (echo $version | cut -f2 -d.) - - # history's -z flag is needed for multi-line support. - # history's -z flag was added in fish 2.4.0, so don't use it for versions - # before 2.4.0. - if [ "$FISH_MAJOR" -gt 2 -o \( "$FISH_MAJOR" -eq 2 -a "$FISH_MINOR" -ge 4 \) ]; - history -z | eval (__fzfcmd) --read0 --print0 -q '(commandline)' | read -lz result - and commandline -- $result - else - history | eval (__fzfcmd) -q '(commandline)' | read -l result - and commandline -- $result - end - end - commandline -f repaint - end - - function fzf-cd-widget -d "Change directory" - set -l commandline (__fzf_parse_commandline) - set -l dir $commandline[1] - set -l fzf_query $commandline[2] - set -l prefix $commandline[3] - - test -n "$FZF_ALT_C_COMMAND"; or set -l FZF_ALT_C_COMMAND " - command find -L \$dir -mindepth 1 \\( -path \$dir'*/.*' -o -fstype 'sysfs' -o -fstype 'devfs' -o -fstype 'devtmpfs' \\) -prune \ - -o -type d -print 2> /dev/null | sed 's@^\./@@'" - test -n "$FZF_TMUX_HEIGHT"; or set FZF_TMUX_HEIGHT 40% - begin - set -lx FZF_DEFAULT_OPTS "--height $FZF_TMUX_HEIGHT --reverse --scheme=path --bind=ctrl-z:ignore $FZF_DEFAULT_OPTS $FZF_ALT_C_OPTS" - eval "$FZF_ALT_C_COMMAND | "(__fzfcmd)' +m --query "'$fzf_query'"' | read -l result - - if [ -n "$result" ] - cd -- $result - - # Remove last token from commandline. - commandline -t "" - commandline -it -- $prefix - end - end - - commandline -f repaint - end - - function __fzfcmd - test -n "$FZF_TMUX"; or set FZF_TMUX 0 - test -n "$FZF_TMUX_HEIGHT"; or set FZF_TMUX_HEIGHT 40% - if [ -n "$FZF_TMUX_OPTS" ] - echo "fzf-tmux $FZF_TMUX_OPTS -- " - else if [ $FZF_TMUX -eq 1 ] - echo "fzf-tmux -d$FZF_TMUX_HEIGHT -- " - else - echo "fzf" - end - end - - bind \ct fzf-file-widget - bind \cr fzf-history-widget - bind \ec fzf-cd-widget - - if bind -M insert > /dev/null 2>&1 - bind -M insert \ct fzf-file-widget - bind -M insert \cr fzf-history-widget - bind -M insert \ec fzf-cd-widget - end - - function __fzf_parse_commandline -d 'Parse the current command line token and return split of existing filepath, fzf query, and optional -option= prefix' - set -l commandline (commandline -t) - - # strip -option= from token if present - set -l prefix (string match -r -- '^-[^\s=]+=' $commandline) - set commandline (string replace -- "$prefix" '' $commandline) - - # eval is used to do shell expansion on paths - eval set commandline $commandline - - if [ -z $commandline ] - # Default to current directory with no --query - set dir '.' - set fzf_query '' - else - set dir (__fzf_get_dir $commandline) - - if [ "$dir" = "." -a (string sub -l 1 -- $commandline) != '.' ] - # if $dir is "." but commandline is not a relative path, this means no file path found - set fzf_query $commandline - else - # Also remove trailing slash after dir, to "split" input properly - set fzf_query (string replace -r "^$dir/?" -- '' "$commandline") - end - end - - echo $dir - echo $fzf_query - echo $prefix - end - - function __fzf_get_dir -d 'Find the longest existing filepath from input string' - set dir $argv - - # Strip all trailing slashes. Ignore if $dir is root dir (/) - if [ (string length -- $dir) -gt 1 ] - set dir (string replace -r '/*$' -- '' $dir) - end - - # Iteratively check if dir exists and strip tail end of path - while [ ! -d "$dir" ] - # If path is absolute, this can keep going until ends up at / - # If path is relative, this can keep going until entire input is consumed, dirname returns "." - set dir (dirname -- "$dir") - end - - echo $dir - end - -end diff --git a/crates/integration/tests/data/fzf/key-bindings.zsh b/crates/integration/tests/data/fzf/key-bindings.zsh deleted file mode 100644 index b64f7916fc7..00000000000 --- a/crates/integration/tests/data/fzf/key-bindings.zsh +++ /dev/null @@ -1,121 +0,0 @@ -# ____ ____ -# / __/___ / __/ -# / /_/_ / / /_ -# / __/ / /_/ __/ -# /_/ /___/_/ key-bindings.zsh -# -# - $FZF_TMUX_OPTS -# - $FZF_CTRL_T_COMMAND -# - $FZF_CTRL_T_OPTS -# - $FZF_CTRL_R_OPTS -# - $FZF_ALT_C_COMMAND -# - $FZF_ALT_C_OPTS - -[[ -o interactive ]] || return 0 - - -# Key bindings -# ------------ - -# The code at the top and the bottom of this file is the same as in completion.zsh. -# Refer to that file for explanation. -if 'zmodload' 'zsh/parameter' 2>'/dev/null' && (( ${+options} )); then - __fzf_key_bindings_options="options=(${(j: :)${(kv)options[@]}})" -else - () { - __fzf_key_bindings_options="setopt" - 'local' '__fzf_opt' - for __fzf_opt in "${(@)${(@f)$(set -o)}%% *}"; do - if [[ -o "$__fzf_opt" ]]; then - __fzf_key_bindings_options+=" -o $__fzf_opt" - else - __fzf_key_bindings_options+=" +o $__fzf_opt" - fi - done - } -fi - -'builtin' 'emulate' 'zsh' && 'builtin' 'setopt' 'no_aliases' - -{ - -# CTRL-T - Paste the selected file path(s) into the command line -__fsel() { - local cmd="${FZF_CTRL_T_COMMAND:-"command find -L . -mindepth 1 \\( -path '*/.*' -o -fstype 'sysfs' -o -fstype 'devfs' -o -fstype 'devtmpfs' -o -fstype 'proc' \\) -prune \ - -o -type f -print \ - -o -type d -print \ - -o -type l -print 2> /dev/null | cut -b3-"}" - setopt localoptions pipefail no_aliases 2> /dev/null - local item - eval "$cmd" | FZF_DEFAULT_OPTS="--height ${FZF_TMUX_HEIGHT:-40%} --reverse --scheme=path --bind=ctrl-z:ignore ${FZF_DEFAULT_OPTS-} ${FZF_CTRL_T_OPTS-}" $(__fzfcmd) -m "$@" | while read item; do - echo -n "${(q)item} " - done - local ret=$? - echo - return $ret -} - -__fzfcmd() { - [ -n "${TMUX_PANE-}" ] && { [ "${FZF_TMUX:-0}" != 0 ] || [ -n "${FZF_TMUX_OPTS-}" ]; } && - echo "fzf-tmux ${FZF_TMUX_OPTS:--d${FZF_TMUX_HEIGHT:-40%}} -- " || echo "fzf" -} - -fzf-file-widget() { - LBUFFER="${LBUFFER}$(__fsel)" - local ret=$? - zle reset-prompt - return $ret -} -zle -N fzf-file-widget -bindkey -M emacs '^T' fzf-file-widget -bindkey -M vicmd '^T' fzf-file-widget -bindkey -M viins '^T' fzf-file-widget - -# ALT-C - cd into the selected directory -fzf-cd-widget() { - local cmd="${FZF_ALT_C_COMMAND:-"command find -L . -mindepth 1 \\( -path '*/.*' -o -fstype 'sysfs' -o -fstype 'devfs' -o -fstype 'devtmpfs' -o -fstype 'proc' \\) -prune \ - -o -type d -print 2> /dev/null | cut -b3-"}" - setopt localoptions pipefail no_aliases 2> /dev/null - local dir="$(eval "$cmd" | FZF_DEFAULT_OPTS="--height ${FZF_TMUX_HEIGHT:-40%} --reverse --scheme=path --bind=ctrl-z:ignore ${FZF_DEFAULT_OPTS-} ${FZF_ALT_C_OPTS-}" $(__fzfcmd) +m)" - if [[ -z "$dir" ]]; then - zle redisplay - return 0 - fi - zle push-line # Clear buffer. Auto-restored on next prompt. - BUFFER="builtin cd -- ${(q)dir}" - zle accept-line - local ret=$? - unset dir # ensure this doesn't end up appearing in prompt expansion - zle reset-prompt - return $ret -} -zle -N fzf-cd-widget -bindkey -M emacs '\ec' fzf-cd-widget -bindkey -M vicmd '\ec' fzf-cd-widget -bindkey -M viins '\ec' fzf-cd-widget - -# CTRL-R - Paste the selected command from history into the command line -fzf-history-widget() { - local selected num - setopt localoptions noglobsubst noposixbuiltins pipefail no_aliases 2> /dev/null - selected=( $(fc -rl 1 | awk '{ cmd=$0; sub(/^[ \t]*[0-9]+\**[ \t]+/, "", cmd); if (!seen[cmd]++) print $0 }' | - FZF_DEFAULT_OPTS="--height ${FZF_TMUX_HEIGHT:-40%} ${FZF_DEFAULT_OPTS-} -n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,ctrl-z:ignore ${FZF_CTRL_R_OPTS-} --query=${(qqq)LBUFFER} +m" $(__fzfcmd)) ) - local ret=$? - if [ -n "$selected" ]; then - num=$selected[1] - if [ -n "$num" ]; then - zle vi-fetch-history -n $num - fi - fi - zle reset-prompt - return $ret -} -zle -N fzf-history-widget -bindkey -M emacs '^R' fzf-history-widget -bindkey -M vicmd '^R' fzf-history-widget -bindkey -M viins '^R' fzf-history-widget - -} always { - eval $__fzf_key_bindings_options - 'unset' '__fzf_key_bindings_options' -} diff --git a/crates/integration/tests/integration/shell_integration_tests.rs b/crates/integration/tests/integration/shell_integration_tests.rs index 7da362bf9bb..527989fdf7d 100644 --- a/crates/integration/tests/integration/shell_integration_tests.rs +++ b/crates/integration/tests/integration/shell_integration_tests.rs @@ -130,11 +130,6 @@ integration_tests! { test_command_search_loads_history, test_histfile_left_joined_with_persisted_history, - // Live fzf ctrl-r / ctrl-t handoff. Skipped unless fzf is installed; runs on bash, zsh, fish. - test_fzf_ctrl_r_selects_history_unexecuted, - test_fzf_ctrl_t_inserts_selection, - test_fzf_ctrl_r_cancel_restores_draft, - // Tests default prompt behavior. test_context_chips_prompt_at_bootstrap, From 803dc956d925ba9e54b0e3c70af6f4b1b813f9cc Mon Sep 17 00:00:00 2001 From: Andy Carlson <2yinyang2@gmail.com> Date: Mon, 31 Aug 2026 13:25:44 -0700 Subject: [PATCH 58/70] manual cleanup --- .gitattributes | 9 -- app/assets/bundled/bootstrap/bash_body.sh | 82 +--------------- app/assets/bundled/bootstrap/fish.sh | 112 +--------------------- app/assets/bundled/bootstrap/zsh_body.sh | 61 +----------- app/src/terminal/model/blocks.rs | 7 +- app/src/terminal/model/blocks_tests.rs | 32 ------- 6 files changed, 15 insertions(+), 288 deletions(-) diff --git a/.gitattributes b/.gitattributes index 6c92e4a062d..9867ec12259 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,12 +1,3 @@ *.pdb filter=lfs diff=lfs merge=lfs -text crates/input_classifier/models/** filter=lfs diff=lfs merge=lfs -text crates/input_classifier/models/**/*tokenizer.json -filter -diff -merge text linguist-generated=true - -# These are embedded verbatim via `include_str!` and parsed by exact-byte structural extraction -# in crates/warp_terminal/src/bootstrap_tests.rs (literal `\n`-delimited marker searches), and are -# themselves shell scripts a real bash/fish process reads. Without this, a Windows checkout's -# default `core.autocrlf` rewrites their LF line endings to CRLF, which both breaks every marker -# search in that test file (they search for literal `\n`, not `\r\n`) and would corrupt the -# scripts themselves if ever executed by a Windows-hosted bash/fish (e.g. under MSYS2/WSL). -app/assets/bundled/bootstrap/*.sh text eol=lf -app/assets/bundled/bootstrap/*.txt text eol=lf diff --git a/app/assets/bundled/bootstrap/bash_body.sh b/app/assets/bundled/bootstrap/bash_body.sh index dfd9bfe3ed1..47463826e53 100644 --- a/app/assets/bundled/bootstrap/bash_body.sh +++ b/app/assets/bundled/bootstrap/bash_body.sh @@ -970,47 +970,24 @@ if [ -z "$WARP_BOOTSTRAPPED" ]; then READLINE_LINE="" } - # Runs the shell's own ctrl-r history widget (fzf or atuin, per the function captured in - # $_WARP_EXTERNAL_CTRL_R_WIDGET during bootstrap) as a synthetic foreground command, so Warp's - # existing long-running-command machinery hides the input editor and forwards keystrokes to - # the widget's PTY-driven UI. Reports the selected command (or an empty buffer, if cancelled) - # via the ExternalCtrlRSelection hook so Warp can insert it into the input editor without - # executing it. The handoff token given as $1 is echoed back unchanged, so Warp can confirm - # the hook is actually the reply to the handoff it started rather than an unrelated write to - # the pty. + # Runs the shell's ctrl-r history widget as a foreground command. warp_run_external_ctrl_r_widget () { local warp_ctrl_r_token="$1" local result="" case "$_WARP_EXTERNAL_CTRL_R_WIDGET" in __fzf_history__) - # __fzf_history__ (installed by fzf's bash integration) normally writes the selection - # into $READLINE_LINE via `bind -x`, using $READLINE_POINT as a sentinel for that - # mode. Called here outside of that context, it echoes the selection to stdout - # instead -- see fzf's own fallback for that case. result="$(__fzf_history__)" ;; __atuin_history) # Bypass atuin's own bash key-binding machinery entirely and invoke the underlying # `atuin search` command directly, exactly as atuin's own integration does # (__atuin_search_cmd's non-tmux branch). - # - # atuin writes its TUI to stdout; under plain command substitution that's a pipe, - # and its cursor-position query (\x1b[6n) has nothing to answer it, so it bails - # during startup. Swap stdout/stderr through fd 3 so the TUI reaches the tty while - # the selection is still captured via command substitution. result="$(ATUIN_SHELL=bash atuin search -i 3>&1 1>&2 2>&3 3>&-)" # If the user has atuin's enter_accept config on, Enter both selects and runs the # command, signaled by this prefix; we only ever want the selection, never to run # it, so strip the prefix in both cases (see atuin's __atuin_history for the same # check). result="${result#__atuin_accept__:}" - # The invocation is given a leading space (see - # trigger_external_ctrl_r_history_search), which atuin's own "ignorespace" - # exclusion honors independent of HISTIGNORE (which only keeps it out of bash's own - # history), so atuin never records this invocation into its own history database in - # the first place. We deliberately don't try to delete it after the fact if that - # exclusion somehow doesn't apply: `atuin search --delete` fuzzy-matches its query, - # so it can remove history entries we don't own. ;; esac local warp_escaped_selection="$(warp_escape_json "$result")" @@ -1018,23 +995,12 @@ if [ -z "$WARP_BOOTSTRAPPED" ]; then warp_send_json_message "{ \"hook\": \"ExternalCtrlRSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"token\": \"$warp_escaped_token\", \"session_id\": $WARP_SESSION_ID } }" } - # Runs the shell's own ctrl-t file-search widget (fzf, per the function captured in - # $_WARP_EXTERNAL_CTRL_T_WIDGET during bootstrap) as a synthetic foreground command, mirroring - # warp_run_external_ctrl_r_widget above. Reports the selected path(s) (or an empty buffer, if - # cancelled) via the ExternalCtrlTSelection hook so Warp can insert them into the input editor - # at the cursor position, without executing anything. The handoff token given as $1 is echoed - # back unchanged, so Warp can confirm the hook is actually the reply to the handoff it started - # rather than an unrelated write to the pty. + # Runs the shell's own ctrl-t file-search widget as a foreground command. warp_run_external_ctrl_t_widget () { local warp_ctrl_t_token="$1" local result="" case "$_WARP_EXTERNAL_CTRL_T_WIDGET" in fzf-file-widget) - # __fzf_select__ (installed by fzf's bash integration) runs the same find|fzf - # pipeline fzf-file-widget itself uses, honoring $FZF_CTRL_T_COMMAND/$FZF_CTRL_T_OPTS - # if the user set them, and echoes the space-escaped selection to stdout -- this is - # exactly what fzf-file-widget calls before splicing the result into READLINE_LINE at - # the cursor itself, which we don't want here since we land the selection ourselves. result="$(__fzf_select__)" ;; esac @@ -1609,25 +1575,7 @@ esac shell_plugins=() # Detect whether ctrl-r has been rebound to fzf's or atuin's bash history widget, so Warp - # can hand ctrl-r off to it at an idle prompt instead of opening Warp's own command search. - # Not supported under MSYS2 (Git Bash on Windows), where ctrl-r always falls through to - # Warp's own command search. - # - # Both fzf and (older versions of) atuin bind ctrl-r directly via `bind -x`, so `bind -X` - # reports it verbatim. bash 5.2 prints `"\C-r": "__fzf_history__"`; bash 5.3 prints - # `"\C-r" "__fzf_history__"` (space, no colon). The sed below accepts either layout. - # Match against an exact allowlist of each integration's canonical bound - # function name -- not merely a name containing "fzf" or "atuin" -- since an RC can - # legitimately bind ctrl-r to an unrelated fzf- or atuin-flavored command that isn't the - # history search warp_run_external_ctrl_r_widget below knows how to invoke; rerouting that to - # the hard-coded history picker would cost the user both their own binding and Warp's command - # search. Reading this straight from `bind -X` also means detection reflects whatever ctrl-r - # is actually bound to at the end of RC processing, rather than a flag atuin's own init set - # earlier that a later `bind` in the RC can leave stale. - # - # Newer atuin (>= 18.10) instead binds ctrl-r to an intermediate key sequence dispatched - # through a separate widget-index binding, which `bind -X` alone can't reliably distinguish - # from an arbitrary user macro; the fallback below handles that case. + # can hand ctrl-r off to it. _WARP_EXTERNAL_CTRL_R_WIDGET="" if [ "$WARP_IN_MSYS2" = false ]; then warp_ctrl_r_binding="$(bind -X 2>/dev/null | command -p sed -n 's/^"\\C-r"[ :] *"\(.*\)"$/\1/p')" @@ -1638,35 +1586,18 @@ esac ;; esac # atuin >= 18.10 binds ctrl-r through the indirect dispatcher above rather than a plain - # `bind -x`, so the exact-allowlist match above never fires for it. Rather than resolve - # that indirection, trust atuin's own init-time flag ($__atuin_bind_ctrl_r) plus - # __atuin_history actually being defined as sufficient evidence atuin owns ctrl-r. This - # is a deliberately looser check than the live-binding match above: a ctrl-r rebound - # after atuin's init runs (a rare sequencing) won't be detected, which is an accepted - # trade-off in favor of covering the common bash+atuin case at all. fzf has no equivalent - # flag and keeps using the live-binding match exclusively. + # `bind -x`. Instead use atuin's own init-time flag ($__atuin_bind_ctrl_r) plus + # __atuin_history being defined. if [ -z "$_WARP_EXTERNAL_CTRL_R_WIDGET" ] && [ "$__atuin_bind_ctrl_r" = true ] && declare -F __atuin_history >/dev/null; then _WARP_EXTERNAL_CTRL_R_WIDGET="__atuin_history" shell_plugins+=(external_ctrl_r_history) fi - # Detect whether ctrl-t has been rebound to fzf's file-search widget, independent of - # whichever tool (if any) owns ctrl-r above -- a user may have one binding without the - # other. fzf binds ctrl-t directly via `bind -x` in every keymap (emacs, vi-insert, and - # vi-command all get their own `-x` binding to the same widget name), unlike alt-c, which - # has no `-x`-bindable form and instead uses a macro-chain trick to reach emacs mode's - # command substitution; that asymmetry is why ctrl-t doesn't need a flag-based fallback - # the way ctrl-r's newer-atuin case does. atuin has no ctrl-t equivalent. _WARP_EXTERNAL_CTRL_T_WIDGET="" warp_ctrl_t_binding="$(bind -X 2>/dev/null | command -p sed -n 's/^"\\C-t"[ :] *"\(.*\)"$/\1/p')" case "$warp_ctrl_t_binding" in fzf-file-widget) - # The bind -X entry has stayed "fzf-file-widget" across fzf versions, but only tag/ - # intercept when the picker warp_run_external_ctrl_t_widget actually calls - # (__fzf_select__) exists -- a version mismatch here would otherwise claim ctrl-t and - # then have nothing to call, swallowing the key with no picker shown instead of - # leaving ctrl-t alone. if declare -F __fzf_select__ >/dev/null; then _WARP_EXTERNAL_CTRL_T_WIDGET="$warp_ctrl_t_binding" shell_plugins+=(external_ctrl_t_file) @@ -1706,9 +1637,6 @@ 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 diff --git a/app/assets/bundled/bootstrap/fish.sh b/app/assets/bundled/bootstrap/fish.sh index 0ec2d1f4477..e9900f1e45d 100644 --- a/app/assets/bundled/bootstrap/fish.sh +++ b/app/assets/bundled/bootstrap/fish.sh @@ -543,15 +543,9 @@ end # Reports the widget `^R` is bound to, if the user has rebound it away from fish's own # history search. Returns non-zero when `^R` is still on a fish default. -# -# `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. The caller matches the reported name -# against an exact allowlist of the widget names it knows how to invoke -- see -# warp_run_external_ctrl_r_widget's comment. function warp_external_ctrl_r_widget # fish >= 4.0 renamed key specifications, so `bind` echoes back `ctrl-r` where earlier - # versions echo `\cr`. Both spellings are accepted as input by every supported version, - # so query with the older one and match either in the output. + # versions echo `\cr`. set -l widget "" for binding in (bind \cr 2>/dev/null) if string match --quiet -- 'bind --preset *' "$binding" @@ -565,9 +559,7 @@ function warp_external_ctrl_r_widget end # Reports the widget `^T` is bound to, if the user has rebound it away from fish's default (no -# binding at all). Returns non-zero when `^T` has no non-preset binding. See -# warp_external_ctrl_r_widget above for why `bind` is queried with the pre-4.0 key spelling and -# `--preset` bindings are skipped. +# binding at all). Returns non-zero when `^T` has no non-preset binding. function warp_external_ctrl_t_widget set -l widget "" for binding in (bind \ct 2>/dev/null) @@ -580,33 +572,7 @@ function warp_external_ctrl_t_widget echo "$widget" end -# Runs the shell's own ctrl-r history tool (fzf or atuin, per the widget name captured in -# $_WARP_EXTERNAL_CTRL_R_WIDGET during bootstrap) as a synthetic foreground command, so Warp's -# existing long-running-command machinery hides the input editor and forwards keystrokes to the -# tool's PTY-driven UI. Reports the selected command (or an empty buffer, if cancelled) via the -# ExternalCtrlRSelection hook so Warp can insert it into the input editor without executing it. -# The handoff token given as $argv[1] is echoed back unchanged, so Warp can confirm the hook is -# the reply to the handoff it started rather than an unrelated write to the pty. -# -# For fzf, this calls the user's own bound fzf-history-widget directly (mirroring -# warp_run_external_ctrl_t_widget's fzf-file-widget call below) rather than hand-rebuilding its -# fzf invocation: fzf's own shell integration changes its available CLI flags and internal helper -# functions across versions, and a hand-built invocation here previously broke outright on a -# packaged fzf whose shell integration both lacked the `__fzf_defaults` helper it called and -# rejected several of the flags it passed (`--wrap-sign`, `--highlight-line`, `--accept-nth`, -# `--with-shell` are all absent from that version's own `fzf --help`) -- a version-compatibility -# liability fzf-history-widget itself doesn't have, since it ships with the fzf release it -# targets. fzf-history-widget replaces the whole commandline with its result, matching ctrl-r's -# own "replace the whole input line" semantics exactly, and reads back as empty on cancel (it -# only calls `commandline` on a successful selection); clearing the commandline afterward, as -# warp_run_external_ctrl_t_widget's fzf-file-widget call already does, prevents its selection from -# being queued for execution. -# -# $_WARP_EXTERNAL_CTRL_R_WIDGET is set during bootstrap (see warp_bootstrapped) to an exact -# allowlist of each integration's canonical widget name -- not merely a name containing "fzf" or -# "atuin" -- since an RC can legitimately bind ctrl-r to an unrelated fzf- or atuin-flavored -# widget that isn't the history search below knows how to invoke. Adding another tool means -# adding its widget name to both that allowlist and the case below. +# Runs the shell's own ctrl-r history tool as a foreground command. function warp_run_external_ctrl_r_widget set -l warp_ctrl_r_token "$argv[1]" set -l result "" @@ -614,9 +580,6 @@ function warp_run_external_ctrl_r_widget case 'fzf-history-widget' test -z "$fish_private_mode"; and builtin history merge fzf-history-widget - # Piped through `string collect`: a multi-line selection would otherwise make this `set`'s - # own command substitution split it into a list, which `warp_escape_json` below would then - # silently space-join instead of newline-join once quoted back down to a single argument. set result (commandline | string collect) commandline -r '' case '_atuin_search' @@ -626,13 +589,6 @@ function warp_run_external_ctrl_r_widget # atuin prefixes the selection with __atuin_accept__: when `enter_accept` is on and the # user pressed enter. Warp always inserts without executing, so the prefix is dropped. set result (string replace "__atuin_accept__:" "" -- "$output" | string collect) - # The invocation is given a leading space (see - # trigger_external_ctrl_r_history_search), which atuin's own "ignorespace" exclusion - # honors independent of the fish_should_add_to_history exclusion above (which only keeps - # it out of fish's own history), so atuin never records this invocation into its own - # history database in the first place. We deliberately don't try to delete it after the - # fact if that exclusion somehow doesn't apply: `atuin search --delete` fuzzy-matches its - # query, so it can remove history entries we don't own. end set -l warp_escaped_selection (warp_escape_json "$result") set -l warp_escaped_token (warp_escape_json "$warp_ctrl_r_token") @@ -643,30 +599,7 @@ function warp_ctrl_t_widget_result test "$argv[1]" = "$argv[2]"; or string collect -- "$argv[2]" end -# Runs fzf directly against a find-style command as a synthetic foreground command, mirroring -# warp_run_external_ctrl_r_widget above. Reports the selected path(s) (or an empty buffer, if -# cancelled) via the ExternalCtrlTSelection hook so Warp can insert them into the input editor at -# the cursor position, without executing anything. The handoff token given as $argv[1] is echoed -# back unchanged, so Warp can confirm the hook is the reply to the handoff it started rather than -# an unrelated write to the pty. -# -# Unlike warp_run_external_ctrl_r_widget above, this calls the user's own bound fzf-file-widget -# directly rather than re-running fzf against an independent search command, since that function -# is token-aware (see fzf's own __fzf_parse_commandline) and reproducing that parsing by hand -# would either drop it or duplicate it badly. -# -# $argv[2] carries the real in-progress draft and cursor Warp seeds the widget with (see -# Input::trigger_external_ctrl_t_file_search), as a single `{char_cursor}:{hex_draft}` token: hex -# keeps the draft a single token, and combining it with the cursor avoids an empty hex field (an -# empty draft) vanishing under the shell's own word-splitting when this invocation is typed into -# the terminal as literal text for the shell to parse. -# -# fzf-file-widget has no way to report cancellation distinctly from a selection: on Escape it -# leaves the commandline exactly as seeded, so its output is indistinguishable from a selection -# that reproduces the original line. Collapse that case to an empty result, Warp's existing -# convention for "nothing selected" -- a real selection can't trigger this false cancel, since -# completing a token always appends a trailing space and reselecting an already-complete path -# duplicates it rather than reproducing it (confirmed live). +# Runs fzf directly against a find-style command as a foreground command. function warp_run_external_ctrl_t_widget set -l warp_ctrl_t_token "$argv[1]" set -l result "" @@ -674,28 +607,10 @@ function warp_run_external_ctrl_t_widget case 'fzf-file-widget' set -l warp_ctrl_t_parts (string split -m 1 -- ':' "$argv[2]") set -l char_cursor $warp_ctrl_t_parts[1] - # --allow-empty: an empty draft (ctrl-t on a blank line) decodes to zero bytes, and - # `string collect` would otherwise collapse that to zero list elements rather than one - # empty string -- `commandline -r --` with no CMD argument at all is a *read*, not a - # write, so ctrl-t on a blank line would leave the synthetic helper invocation itself on - # the commandline instead of seeding a blank buffer. set -l original_line (warp_hex_decode_string $warp_ctrl_t_parts[2] | string collect --no-trim-newlines --allow-empty) commandline -r -- $original_line commandline -C -- $char_cursor fzf-file-widget - # (commandline | string collect), not plain (commandline): unquoted, a multi-line result - # would otherwise expand to multiple arguments here, truncating - # warp_ctrl_t_widget_result's $argv[2] comparison and return value to its first line alone. - # Plain `string collect` (not --no-trim-newlines) here: a bare `commandline` read always - # ends its own output in a line terminator regardless of the buffer's actual content, so - # trimming it is what recovers the real buffer text -- keeping it would make an unchanged - # draft compare unequal to itself, misreporting a plain cancel as a real selection. - # - # Captured into $cl_readback first, not passed as a nested command substitution directly: - # confirmed live that reading `commandline` in that nested form immediately after - # fzf-file-widget's own `commandline -f repaint` on cancel can race that repaint and read - # back a stale value, so the comparison below sometimes saw a false change on an untouched - # cancel. Assigning it first, as its own statement, reliably reads the settled buffer. set -l cl_readback (commandline | string collect) set result (warp_ctrl_t_widget_result "$original_line" "$cl_readback") commandline -r '' @@ -727,8 +642,6 @@ end # as if it were the original would make every history check call itself. if functions -q fish_should_add_to_history and not functions fish_should_add_to_history | string match --quiet -- '*warp_run_external_ctrl_r_widget*' - # `functions -c` refuses to overwrite an existing destination, so erase any previous backup - # (e.g. an earlier accept-everything default, or a now-stale hook) before capturing this one. functions -q warp_original_fish_should_add_to_history; and functions -e warp_original_fish_should_add_to_history functions -c fish_should_add_to_history warp_original_fish_should_add_to_history else if not functions -q warp_original_fish_should_add_to_history @@ -737,12 +650,7 @@ else if not functions -q warp_original_fish_should_add_to_history end end function fish_should_add_to_history - # Unanchored (not just a prefix match): the invocation is now given a leading space so atuin's - # own "ignorespace" exclusion also catches it (see trigger_external_ctrl_r_history_search), and - # that space must not defeat this match too. string match --quiet -- '*warp_run_external_ctrl_r_widget *' $argv[1]; and return 1 - # The ctrl-t helper isn't given a leading space (it doesn't need atuin's ignorespace exclusion, - # since atuin has no ctrl-t equivalent), so match it without one. string match --quiet -- '*warp_run_external_ctrl_t_widget*' $argv[1]; and return 1 warp_original_fish_should_add_to_history $argv end @@ -760,8 +668,6 @@ function warp_bootstrapped set vi_mode_enabled "1" end - # Tags for shell configurations Warp needs to know about, matching the `shell_plugins` - # list bash and zsh already report. Newline-separated, one tag per line. set -l shell_plugins set -g _WARP_EXTERNAL_CTRL_R_WIDGET "" set -l warp_ctrl_r_widget (warp_external_ctrl_r_widget) @@ -771,20 +677,10 @@ function warp_bootstrapped set -a shell_plugins external_ctrl_r_history end - # Detect whether ctrl-t has been rebound to fzf's file-search widget, independent of whichever - # tool (if any) owns ctrl-r above -- a user may have one binding without the other. fzf's - # widget name for ctrl-t is the same across shells ("fzf-file-widget"); atuin has no ctrl-t - # equivalent. set -g _WARP_EXTERNAL_CTRL_T_WIDGET "" set -l warp_ctrl_t_widget (warp_external_ctrl_t_widget) switch "$warp_ctrl_t_widget" case 'fzf-file-widget' - # warp_run_external_ctrl_t_widget calls fzf-file-widget directly, so the only real - # requirement is that the function itself exists. `bind` already reported this name as - # ctrl-t's binding, but only tag/intercept once that's confirmed callable, so a rebind to a - # nonexistent or renamed function can never claim ctrl-t and then have - # warp_run_external_ctrl_t_widget find nothing to call -- that would swallow the key with - # no picker shown instead of leaving ctrl-t alone. if functions -q fzf-file-widget set -g _WARP_EXTERNAL_CTRL_T_WIDGET "$warp_ctrl_t_widget" set -a shell_plugins external_ctrl_t_file diff --git a/app/assets/bundled/bootstrap/zsh_body.sh b/app/assets/bundled/bootstrap/zsh_body.sh index c19a547234b..8d777e97f89 100644 --- a/app/assets/bundled/bootstrap/zsh_body.sh +++ b/app/assets/bundled/bootstrap/zsh_body.sh @@ -710,18 +710,7 @@ if [[ -z $WARP_BOOTSTRAPPED ]]; then } zle -N warp_report_input - # Runs the shell's own ctrl-r history widget (fzf or atuin, per the widget name captured in - # $_WARP_EXTERNAL_CTRL_R_WIDGET during bootstrap) as a synthetic foreground command, so Warp's - # existing long-running-command machinery hides the input editor and forwards keystrokes to the - # widget's PTY-driven UI. Reports the selected command (or an empty buffer, if cancelled) via - # the ExternalCtrlRSelection hook so Warp can insert it into the input editor without executing - # it. The handoff token given as $1 is echoed back unchanged, so Warp can confirm the hook is - # actually the reply to the handoff it started rather than an unrelated write to the pty. - # - # We re-run each tool's own underlying picker command rather than invoking its zle widget - # directly: those widgets rely on zle builtins (e.g. `zle vi-fetch-history`) that only work when - # the widget is actually bound to a key and invoked through zle, not when called as a plain - # command outside of that context. + # Runs the shell's own ctrl-r history widget as a foreground command. function warp_run_external_ctrl_r_widget () { local warp_ctrl_r_token="$1" local result="" @@ -742,13 +731,6 @@ if [[ -z $WARP_BOOTSTRAPPED ]]; then # command, signaled by this prefix; we only ever want the selection, never to run it, # so strip the prefix in both cases (see atuin's _atuin_search for the same check). result="${result#__atuin_accept__:}" - # The invocation is given a leading space (see - # trigger_external_ctrl_r_history_search), which atuin's own "ignorespace" exclusion - # honors independent of the zshaddhistory exclusion above (which only keeps it out of - # zsh's own history), so atuin never records this invocation into its own history - # database in the first place. We deliberately don't try to delete it after the fact if - # that exclusion somehow doesn't apply: `atuin search --delete` fuzzy-matches its query, - # so it can remove history entries we don't own. ;; esac local warp_escaped_selection="$(warp_escape_json "$result")" @@ -756,28 +738,15 @@ if [[ -z $WARP_BOOTSTRAPPED ]]; then warp_send_json_message "{ \"hook\": \"ExternalCtrlRSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"token\": \"$warp_escaped_token\", \"session_id\": $WARP_SESSION_ID } }" } - # Runs the shell's own ctrl-t file-search widget (fzf, per the widget name captured in - # $_WARP_EXTERNAL_CTRL_T_WIDGET during bootstrap) as a synthetic foreground command, mirroring - # warp_run_external_ctrl_r_widget above. Reports the selected path(s) (or an empty buffer, if - # cancelled) via the ExternalCtrlTSelection hook so Warp can insert them into the input editor - # at the cursor position, without executing anything. The handoff token given as $1 is echoed - # back unchanged, so Warp can confirm the hook is actually the reply to the handoff it started - # rather than an unrelated write to the pty. + # Runs the shell's own ctrl-t file-search widget as a foreground command. function warp_run_external_ctrl_t_widget () { local warp_ctrl_t_token="$1" local result="" case "$_WARP_EXTERNAL_CTRL_T_WIDGET" in fzf-file-widget) - # __fzf_select (current fzf) or __fsel (fzf < 0.48, still the packaged version on some - # distros) runs the same find|fzf pipeline fzf-file-widget itself uses, honoring - # $FZF_CTRL_T_COMMAND/$FZF_CTRL_T_OPTS if the user set them, and echoes the - # shell-quoted selection to stdout -- this is exactly what fzf-file-widget calls before - # splicing the result into LBUFFER at the cursor itself, which we don't want here since - # we land the selection ourselves. Detection below only tags this widget when one of the - # two is actually defined, so this case is never reached with neither present. if (( $+functions[__fzf_select] )); then result="$(__fzf_select)" - else + else # fzf < 0.48 result="$(__fsel)" fi ;; @@ -1356,9 +1325,6 @@ esac # See https://zsh.sourceforge.io/Doc/Release/Functions.html for more context # on the zshaddhistory hook. _warp_zshaddhistory() { - # Also exclude the ctrl-r/ctrl-t external handoff helpers (see - # warp_run_external_ctrl_r_widget/warp_run_external_ctrl_t_widget above): they're - # Warp-internal invocations, not commands the user meant to run again later. _is_warp_generator_command "$1" && [[ "$1" != *"warp_run_external_ctrl_r_widget"* ]] && \ [[ "$1" != *"warp_run_external_ctrl_t_widget"* ]] } @@ -1434,14 +1400,7 @@ esac shell_plugins+=(vi) fi - # Detect whether ctrl-r has been rebound to fzf's or atuin's history widget, so Warp can - # hand ctrl-r off to it at an idle prompt instead of opening Warp's own command search. - # Matched against an exact allowlist of each integration's canonical widget names -- not - # merely a name containing "fzf" or "atuin" -- since an RC can legitimately bind ctrl-r to an - # unrelated fzf- or atuin-flavored widget that isn't the history search we know how to invoke; - # rerouting those to the hard-coded history picker would cost the user both their own binding - # and Warp's command search on every ctrl-r press. Adding another tool means adding its widget - # name to both this list and the case below. + # Detect whether ctrl-r has been rebound to fzf's or atuin's history widget. _WARP_EXTERNAL_CTRL_R_WIDGET="" warp_ctrl_r_binding="$(bindkey -M main '^R' 2>/dev/null)" if [[ "$warp_ctrl_r_binding" == '"^R" '* ]]; then @@ -1454,23 +1413,13 @@ esac esac fi - # Detect whether ctrl-t has been rebound to fzf's file-search widget, independent of whichever - # tool (if any) owns ctrl-r above -- a user may have one binding without the other. fzf's zle - # widget name for ctrl-t is the same across shells ("fzf-file-widget"); atuin has no ctrl-t - # equivalent. See the ctrl-r detection above for why we match against an exact allowlist rather - # than a name containing "fzf". + # Detect whether ctrl-t has been rebound to fzf's file-search widget. _WARP_EXTERNAL_CTRL_T_WIDGET="" warp_ctrl_t_binding="$(bindkey -M main '^T' 2>/dev/null)" if [[ "$warp_ctrl_t_binding" == '"^T" '* ]]; then warp_ctrl_t_widget="${warp_ctrl_t_binding#\"^T\" }" case "$warp_ctrl_t_widget" in fzf-file-widget) - # The zle widget name has stayed "fzf-file-widget" across fzf versions, but the picker - # it delegates to was renamed from __fsel to __fzf_select along the way (fzf < 0.48 - # still ships as the packaged version on several distros); only tag/intercept when one - # of the two invocable names actually exists, so a version mismatch here can never - # claim ctrl-t and then have warp_run_external_ctrl_t_widget find nothing to call -- - # that would swallow the key with no picker shown instead of leaving ctrl-t alone. if (( $+functions[__fzf_select] )) || (( $+functions[__fsel] )); then _WARP_EXTERNAL_CTRL_T_WIDGET="$warp_ctrl_t_widget" shell_plugins+=(external_ctrl_t_file) diff --git a/app/src/terminal/model/blocks.rs b/app/src/terminal/model/blocks.rs index 852ccacc654..a0255cde803 100644 --- a/app/src/terminal/model/blocks.rs +++ b/app/src/terminal/model/blocks.rs @@ -1420,9 +1420,6 @@ impl BlockList { } } - /// Hides the given (possibly already-completed) block and refreshes the block heights - /// sumtree so the change is reflected immediately, even for a historical block whose height - /// entry was already committed to the sumtree. pub fn hide_block(&mut self, block_id: &BlockId) { if let Some(block) = self.mut_block_from_id(block_id) { block.hide(); @@ -1431,9 +1428,7 @@ impl BlockList { } self.update_blocks_and_sumtree(None, None, |_| {}, |_| {}); - // update_blocks_and_sumtree doesn't itself trigger a re-draw (its other callers are - // driven by a GPUI context that already notifies the right view), so force one here, - // matching unhide_block and friends. + // Force a re-draw since the blocklist has changed. self.event_proxy.send_wakeup_event(); } diff --git a/app/src/terminal/model/blocks_tests.rs b/app/src/terminal/model/blocks_tests.rs index b0d0bc27cb3..1ac524acf49 100644 --- a/app/src/terminal/model/blocks_tests.rs +++ b/app/src/terminal/model/blocks_tests.rs @@ -1155,38 +1155,6 @@ pub fn test_first_non_hidden_block_by_index_in_range() { ); } -#[test] -fn test_hide_block_zeroes_height_for_a_completed_block() { - // Regression test: hiding an already-completed (non-active) block must update its cached - // height in the block heights sumtree immediately, not just the block's own `hidden` flag -- - // otherwise the block would keep occupying space in the rendered blocklist. - let mut block_list = - new_bootstrapped_block_list(None, None, ChannelEventListener::new_for_test()); - - let block_index = insert_block(&mut block_list, "echo hi", "hi"); - let block_id = block_list.block_at(block_index).unwrap().id().clone(); - let transcript_scope = *block_list.transcript_scope(); - - assert!( - block_list - .block_at(block_index) - .unwrap() - .height(&transcript_scope) - > Lines::zero() - ); - - block_list.hide_block(&block_id); - - assert!(block_list.block_with_id(&block_id).unwrap().is_hidden()); - - let mut cursor = block_list.block_heights().cursor::(); - cursor.seek(&(block_index + BlockIndex(1)), SeekBias::Left); - assert_eq!( - cursor.item(), - Some(&BlockHeightItem::Block(BlockHeight::zero())) - ); -} - #[test] fn test_matching_block_by_index() { let mut block_list = From ddb3a8d389ce46093576bd0037bcab7a65e7c16f Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:48:35 +0000 Subject: [PATCH 59/70] Unify shell-widget handoff pending state and restore master fish decode. Drop the PR-only warp_terminal bootstrap tests. Match warp_hex_decode_string to master. Model ctrl-r and ctrl-t as one pending handoff with a small apply-kind enum so they stay mutually exclusive. --- app/assets/bundled/bootstrap/fish.sh | 13 +- app/src/terminal/input.rs | 224 ++---- app/src/terminal/input_tests.rs | 232 +++--- crates/warp_terminal/src/bootstrap_tests.rs | 776 -------------------- 4 files changed, 190 insertions(+), 1055 deletions(-) diff --git a/app/assets/bundled/bootstrap/fish.sh b/app/assets/bundled/bootstrap/fish.sh index e9900f1e45d..1dc48a48aca 100644 --- a/app/assets/bundled/bootstrap/fish.sh +++ b/app/assets/bundled/bootstrap/fish.sh @@ -76,7 +76,18 @@ end # warp_hex_decode_string decodes a string hex-encoded by warp_hex_encode_string. function warp_hex_decode_string - printf '%b' (string replace --all --regex '(..)' '\\\\x$1' -- "$argv") + if test (count $argv) -eq 0 -o -z "$argv[1]" + return + end + set -l hex $argv[1] + set -l escaped '' + set -l i 1 + while test $i -le (string length -- $hex) + set -l pair (string sub -s $i -l 2 -- $hex) + set escaped "$escaped\\x$pair" + set i (math $i + 2) + end + printf '%b' $escaped end # A list of PIDs for running in-band command(s). This is used to kill running diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index cbedd515e55..ed5b833b739 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -1878,97 +1878,35 @@ pub struct Input { /// completes and the buffer would normally be cleared. input_contents_before_prompt_chip_command: Option, - /// State for an in-flight external ctrl-r handoff started by - /// [`Self::trigger_external_ctrl_r_history_search`], if any. `None` once the handoff's block - /// has completed (see [`Self::handle_block_completed_event`]) or no handoff is in flight. - pending_ctrl_r_handoff: Option, - - /// State for an in-flight external ctrl-t handoff started by - /// [`Self::trigger_external_ctrl_t_file_search`], if any. `None` once the handoff's block - /// has completed (see [`Self::handle_block_completed_event`]) or no handoff is in flight. - pending_ctrl_t_handoff: Option, + pending_shell_widget_handoff: Option, } -/// State for an in-flight external ctrl-r handoff (see -/// [`Input::trigger_external_ctrl_r_history_search`]). `session_id` and `token` let -/// [`Input::set_external_ctrl_r_selection`] verify that an `ExternalCtrlRSelection` hook is -/// actually the reply to this handoff, rather than an unsolicited write to the pty (e.g. from an -/// unrelated command) or a stale reply to a handoff whose block has already completed. -struct PendingCtrlRHandoff { - session_id: SessionId, - token: String, - /// Text to restore into the editor when the handoff's block completes: the buffer the user - /// had before ctrl-r, or the selected command once a matching selection is applied. - restore_text: String, - /// The block running the synthetic helper command. Hidden once it completes (see - /// [`Input::handle_block_completed_event`]) so it doesn't clutter scrollback. - block_id: BlockId, -} - -impl PendingCtrlRHandoff { - /// Applies `selection` to `pending` if it matches an in-flight handoff for `session_id` and - /// `token`; otherwise leaves `pending` untouched. This covers both unsolicited selections (no - /// handoff was ever started, so `pending` is `None`) and stale ones (a reply to a handoff - /// whose block already completed -- clearing `pending` -- or to a different handoff). - fn maybe_apply_selection( - pending: &mut Option, - session_id: SessionId, - token: &str, - selection: &str, - ) { - let Some(handoff) = pending else { - return; - }; - if handoff.session_id != session_id || handoff.token != token { - return; - } - if !selection.is_empty() { - handoff.restore_text = selection.to_string(); - } - } -} - -/// How a completed ctrl-t handoff's selection is landed into the editor buffer (see -/// [`Input::trigger_external_ctrl_t_file_search`]). Chosen at trigger time from the session's -/// shell type: fish's `fzf-file-widget` is invoked directly and already performs its own -/// token-aware replacement, so it returns the whole new line rather than a fragment to splice in -/// at a fixed offset the way bash/zsh's plain-path selection does. +/// How a completed ctrl-t handoff lands its selection. Fish's widget already performs token-aware +/// replacement and reports the whole line; bash/zsh report a path fragment to splice at the cursor. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CtrlTApplyMode { - /// Insert the selection into `original_buffer` at `cursor_offset` (bash, zsh). Splice, - /// Replace the buffer wholesale with the selection (fish). Replace, } -/// State for an in-flight external ctrl-t handoff (see -/// [`Input::trigger_external_ctrl_t_file_search`]). Unlike ctrl-r, which replaces the entire -/// buffer with the selection, ctrl-t inserts the selection into the buffer the user had before -/// the handoff, at the cursor position ctrl-t was pressed at -- so this snapshots the original -/// buffer and cursor offset separately from the (initially absent) selection. -struct PendingCtrlTHandoff { +enum ShellWidgetHandoffKind { + CtrlR, + CtrlT { + apply_mode: CtrlTApplyMode, + cursor_offset: ByteOffset, + }, +} + +struct PendingShellWidgetHandoff { session_id: SessionId, token: String, - /// The buffer the user had before ctrl-t was pressed, restored verbatim on cancel (or as the - /// base the selection is inserted into, on a completed selection). original_buffer: String, - /// The byte offset within `original_buffer` that the selection is inserted at. - cursor_offset: ByteOffset, - /// The selected path(s), once a matching `ExternalCtrlTSelection` hook supplies one. `None` - /// while no selection has arrived yet, or the user cancelled without selecting anything. - insertion: Option, - /// The block running the synthetic helper command. Hidden once it completes (see - /// [`Input::handle_block_completed_event`]) so it doesn't clutter scrollback. + selection: Option, block_id: BlockId, - /// How `insertion` should be landed into the buffer once it arrives; see [`CtrlTApplyMode`]. - apply_mode: CtrlTApplyMode, + kind: ShellWidgetHandoffKind, } -impl PendingCtrlTHandoff { - /// Applies `selection` to `pending` if it matches an in-flight handoff for `session_id` and - /// `token`; otherwise leaves `pending` untouched. Mirrors - /// [`PendingCtrlRHandoff::maybe_apply_selection`] -- see its comment for why this guards - /// against unsolicited and stale selections. +impl PendingShellWidgetHandoff { fn maybe_apply_selection( pending: &mut Option, session_id: SessionId, @@ -1982,7 +1920,21 @@ impl PendingCtrlTHandoff { return; } if !selection.is_empty() { - handoff.insertion = Some(selection.to_string()); + handoff.selection = Some(selection.to_string()); + } + } + + fn restore_text(&self) -> &str { + match (&self.kind, &self.selection) { + (ShellWidgetHandoffKind::CtrlR, Some(selection)) => selection, + ( + ShellWidgetHandoffKind::CtrlT { + apply_mode: CtrlTApplyMode::Replace, + .. + }, + Some(selection), + ) => selection, + _ => &self.original_buffer, } } } @@ -4259,8 +4211,7 @@ impl Input { cloud_mode_composer_slash_command_data_source, ephemeral_message_model, input_contents_before_prompt_chip_command: None, - pending_ctrl_r_handoff: None, - pending_ctrl_t_handoff: None, + pending_shell_widget_handoff: None, }; #[cfg(feature = "local_fs")] @@ -7792,30 +7743,27 @@ impl Input { ctx, ); if started { - self.pending_ctrl_r_handoff = Some(PendingCtrlRHandoff { + self.pending_shell_widget_handoff = Some(PendingShellWidgetHandoff { session_id, token, - restore_text: current_input, + original_buffer: current_input, + selection: None, block_id, + kind: ShellWidgetHandoffKind::CtrlR, }); } started } - /// Called when the shell reports the command selected in the external ctrl-r history search - /// (fzf/atuin). Applies the selection only if `session_id` and `token` match an in-flight - /// handoff this session started (see [`PendingCtrlRHandoff`]); otherwise ignores it, including - /// unsolicited selections and stale replies to a handoff whose block already completed. A - /// no-op on an empty (but matching) `selection` -- the user cancelled without selecting - /// anything, so the previously snapshotted buffer stays queued for restoration. + /// Applies `selection` only if `session_id` and `token` match the in-flight handoff. pub fn set_external_ctrl_r_selection( &mut self, session_id: SessionId, token: &str, selection: &str, ) { - PendingCtrlRHandoff::maybe_apply_selection( - &mut self.pending_ctrl_r_handoff, + PendingShellWidgetHandoff::maybe_apply_selection( + &mut self.pending_shell_widget_handoff, session_id, token, selection, @@ -7868,31 +7816,30 @@ impl Input { ctx, ); if started { - self.pending_ctrl_t_handoff = Some(PendingCtrlTHandoff { + self.pending_shell_widget_handoff = Some(PendingShellWidgetHandoff { session_id, token, original_buffer, - cursor_offset, - insertion: None, + selection: None, block_id, - apply_mode, + kind: ShellWidgetHandoffKind::CtrlT { + apply_mode, + cursor_offset, + }, }); } started } - /// Called when the shell reports the path(s) selected in the external ctrl-t file search - /// (fzf). Applies the selection only if `session_id` and `token` match an in-flight handoff - /// this session started (see [`PendingCtrlTHandoff`]); otherwise ignores it, mirroring - /// [`Self::set_external_ctrl_r_selection`]. + /// Applies `selection` only if `session_id` and `token` match the in-flight handoff. pub fn set_external_ctrl_t_selection( &mut self, session_id: SessionId, token: &str, selection: &str, ) { - PendingCtrlTHandoff::maybe_apply_selection( - &mut self.pending_ctrl_t_handoff, + PendingShellWidgetHandoff::maybe_apply_selection( + &mut self.pending_shell_widget_handoff, session_id, token, selection, @@ -15760,24 +15707,11 @@ impl Input { && !cloud_setup_pre_first_exchange && !self.has_queued_command_in_flight(ctx); let latest_block_id = self.model.lock().block_list().active_block_id().clone(); - // Prefer a prompt-chip restore (e.g. `cd`) over a ctrl-r/ctrl-t handoff restore; these - // cannot both be pending for the same block in practice. Taking - // `pending_ctrl_r_handoff`/`pending_ctrl_t_handoff` here also ends that handoff: any - // `ExternalCtrlRSelection`/`ExternalCtrlTSelection` hook that arrives after this point - // is treated as stale and ignored (see `PendingCtrlRHandoff`/`PendingCtrlTHandoff`). - let completed_ctrl_r_handoff = self - .pending_ctrl_r_handoff + // Prefer a prompt-chip restore (e.g. `cd`) over a shell-widget handoff restore. + let completed_handoff = self + .pending_shell_widget_handoff .take_if(|handoff| handoff.block_id == block_completed_event.block_id); - if let Some(handoff) = &completed_ctrl_r_handoff { - self.model - .lock() - .block_list_mut() - .hide_block(&handoff.block_id); - } - let completed_ctrl_t_handoff = self - .pending_ctrl_t_handoff - .take_if(|handoff| handoff.block_id == block_completed_event.block_id); - if let Some(handoff) = &completed_ctrl_t_handoff { + if let Some(handoff) = &completed_handoff { self.model .lock() .block_list_mut() @@ -15786,18 +15720,10 @@ impl Input { let pending_input_restore = self .input_contents_before_prompt_chip_command .take() - .or_else(|| completed_ctrl_r_handoff.map(|handoff| handoff.restore_text)) .or_else(|| { - completed_ctrl_t_handoff.as_ref().map(|handoff| { - // In `Replace` mode the shell's own widget already performed the - // token-aware replacement, so its selection *is* the finished buffer; - // landing it as the base text (rather than `original_buffer`, then - // splicing) avoids reconstructing what the widget already built. - match (handoff.apply_mode, &handoff.insertion) { - (CtrlTApplyMode::Replace, Some(insertion)) => insertion.clone(), - _ => handoff.original_buffer.clone(), - } - }) + completed_handoff + .as_ref() + .map(|handoff| handoff.restore_text().to_string()) }); if should_clear_buffer { @@ -15815,28 +15741,34 @@ impl Input { if let Some(restore_text) = pending_input_restore { self.editor.update(ctx, |editor, ctx| { editor.set_buffer_text(&restore_text, ctx); - // A ctrl-t handoff restores the pre-handoff buffer above, then (unlike - // ctrl-r) either splices its selection in at the captured cursor - // offset, or -- for `Replace` mode -- leaves the already-finished - // buffer set above as-is, since the widget placed its own cursor - // position and there is nothing left to splice. Either mode instead - // moves the cursor back to the captured offset on cancel. - if let Some(handoff) = &completed_ctrl_t_handoff { - match (handoff.apply_mode, &handoff.insertion) { - (CtrlTApplyMode::Splice, Some(insertion)) => editor - .select_and_replace( - insertion, - [handoff.cursor_offset..handoff.cursor_offset], - PlainTextEditorViewAction::InsertSelectedText, - ctx, - ), - (CtrlTApplyMode::Replace, Some(_)) => {} - (CtrlTApplyMode::Splice | CtrlTApplyMode::Replace, None) => { + if let Some(handoff) = &completed_handoff { + match (&handoff.kind, &handoff.selection) { + ( + ShellWidgetHandoffKind::CtrlT { + apply_mode: CtrlTApplyMode::Splice, + cursor_offset, + }, + Some(insertion), + ) => editor.select_and_replace( + insertion, + [*cursor_offset..*cursor_offset], + PlainTextEditorViewAction::InsertSelectedText, + ctx, + ), + ( + ShellWidgetHandoffKind::CtrlT { + apply_mode: CtrlTApplyMode::Replace, + .. + }, + Some(_), + ) => {} + (ShellWidgetHandoffKind::CtrlT { cursor_offset, .. }, None) => { editor.select_ranges_by_byte_offset( - [handoff.cursor_offset..handoff.cursor_offset], + [*cursor_offset..*cursor_offset], ctx, ) } + (ShellWidgetHandoffKind::CtrlR, _) => {} } } }); diff --git a/app/src/terminal/input_tests.rs b/app/src/terminal/input_tests.rs index c8e588394de..c88a840d613 100644 --- a/app/src/terminal/input_tests.rs +++ b/app/src/terminal/input_tests.rs @@ -116,179 +116,94 @@ use crate::{ ReferralThemeStatus, experiments, }; -#[test] -fn external_ctrl_r_selection_matching_session_and_token_is_applied() { - let mut pending = Some(PendingCtrlRHandoff { +fn pending_ctrl_r_handoff() -> PendingShellWidgetHandoff { + PendingShellWidgetHandoff { session_id: SessionId::from(1), token: "tok-1".to_string(), - restore_text: "draft".to_string(), + original_buffer: "draft".to_string(), + selection: None, block_id: BlockId::new(), - }); - PendingCtrlRHandoff::maybe_apply_selection( - &mut pending, - SessionId::from(1), - "tok-1", - "echo selected", - ); - assert_eq!(pending.unwrap().restore_text, "echo selected"); -} - -#[test] -fn unsolicited_external_ctrl_r_selection_without_a_pending_handoff_is_ignored() { - // No handoff was ever started (e.g. a stray write to the pty unrelated to ctrl-r): there's - // nothing to apply the selection to, and no handoff gets created. - let mut pending: Option = None; - PendingCtrlRHandoff::maybe_apply_selection( - &mut pending, - SessionId::from(1), - "tok-1", - "echo selected", - ); - assert!(pending.is_none()); + kind: ShellWidgetHandoffKind::CtrlR, + } } -#[test] -fn stale_external_ctrl_r_selection_with_mismatched_token_is_ignored() { - let mut pending = Some(PendingCtrlRHandoff { +fn pending_ctrl_t_handoff() -> PendingShellWidgetHandoff { + PendingShellWidgetHandoff { session_id: SessionId::from(1), token: "tok-1".to_string(), - restore_text: "draft".to_string(), + original_buffer: "echo ".to_string(), + selection: None, block_id: BlockId::new(), - }); - PendingCtrlRHandoff::maybe_apply_selection( - &mut pending, - SessionId::from(1), - "some-other-token", - "echo selected", - ); - assert_eq!(pending.unwrap().restore_text, "draft"); + kind: ShellWidgetHandoffKind::CtrlT { + apply_mode: CtrlTApplyMode::Splice, + cursor_offset: ByteOffset::from(5), + }, + } } #[test] -fn stale_external_ctrl_r_selection_with_mismatched_session_is_ignored() { - let mut pending = Some(PendingCtrlRHandoff { - session_id: SessionId::from(1), - token: "tok-1".to_string(), - restore_text: "draft".to_string(), - block_id: BlockId::new(), - }); - PendingCtrlRHandoff::maybe_apply_selection( +fn matching_shell_widget_handoff_selection_is_applied() { + let mut pending = Some(pending_ctrl_r_handoff()); + PendingShellWidgetHandoff::maybe_apply_selection( &mut pending, - SessionId::from(2), + SessionId::from(1), "tok-1", "echo selected", ); - assert_eq!(pending.unwrap().restore_text, "draft"); -} - -#[test] -fn cancelled_external_ctrl_r_selection_with_empty_buffer_keeps_original_draft() { - // An empty buffer means the handoff matched but the user cancelled without selecting - // anything, so the originally snapshotted draft text must be preserved, not cleared. - let mut pending = Some(PendingCtrlRHandoff { - session_id: SessionId::from(1), - token: "tok-1".to_string(), - restore_text: "draft".to_string(), - block_id: BlockId::new(), - }); - PendingCtrlRHandoff::maybe_apply_selection(&mut pending, SessionId::from(1), "tok-1", ""); - assert_eq!(pending.unwrap().restore_text, "draft"); -} + assert_eq!(pending.as_ref().unwrap().restore_text(), "echo selected"); -#[test] -fn external_ctrl_t_selection_matching_session_and_token_is_applied() { - let mut pending = Some(PendingCtrlTHandoff { - session_id: SessionId::from(1), - token: "tok-1".to_string(), - original_buffer: "echo ".to_string(), - cursor_offset: ByteOffset::from(5), - insertion: None, - block_id: BlockId::new(), - apply_mode: CtrlTApplyMode::Splice, - }); - PendingCtrlTHandoff::maybe_apply_selection( + let mut pending = Some(pending_ctrl_t_handoff()); + PendingShellWidgetHandoff::maybe_apply_selection( &mut pending, SessionId::from(1), "tok-1", "selected/file.txt", ); assert_eq!( - pending.unwrap().insertion, + pending.unwrap().selection, Some("selected/file.txt".to_string()) ); } #[test] -fn unsolicited_external_ctrl_t_selection_without_a_pending_handoff_is_ignored() { - // No handoff was ever started (e.g. a stray write to the pty unrelated to ctrl-t): there's - // nothing to apply the selection to, and no handoff gets created. - let mut pending: Option = None; - PendingCtrlTHandoff::maybe_apply_selection( +fn unsolicited_or_stale_shell_widget_handoff_selection_is_ignored() { + let mut pending: Option = None; + PendingShellWidgetHandoff::maybe_apply_selection( &mut pending, SessionId::from(1), "tok-1", - "selected/file.txt", + "echo selected", ); assert!(pending.is_none()); -} -#[test] -fn stale_external_ctrl_t_selection_with_mismatched_token_is_ignored() { - let mut pending = Some(PendingCtrlTHandoff { - session_id: SessionId::from(1), - token: "tok-1".to_string(), - original_buffer: "echo ".to_string(), - cursor_offset: ByteOffset::from(5), - insertion: None, - block_id: BlockId::new(), - apply_mode: CtrlTApplyMode::Splice, - }); - PendingCtrlTHandoff::maybe_apply_selection( + let mut pending = Some(pending_ctrl_r_handoff()); + PendingShellWidgetHandoff::maybe_apply_selection( &mut pending, SessionId::from(1), "some-other-token", - "selected/file.txt", + "echo selected", ); - assert_eq!(pending.unwrap().insertion, None); -} + assert_eq!(pending.as_ref().unwrap().restore_text(), "draft"); -#[test] -fn stale_external_ctrl_t_selection_with_mismatched_session_is_ignored() { - let mut pending = Some(PendingCtrlTHandoff { - session_id: SessionId::from(1), - token: "tok-1".to_string(), - original_buffer: "echo ".to_string(), - cursor_offset: ByteOffset::from(5), - insertion: None, - block_id: BlockId::new(), - apply_mode: CtrlTApplyMode::Splice, - }); - PendingCtrlTHandoff::maybe_apply_selection( + let mut pending = Some(pending_ctrl_r_handoff()); + PendingShellWidgetHandoff::maybe_apply_selection( &mut pending, SessionId::from(2), "tok-1", - "selected/file.txt", + "echo selected", ); - assert_eq!(pending.unwrap().insertion, None); + assert_eq!(pending.unwrap().restore_text(), "draft"); } #[test] -fn cancelled_external_ctrl_t_selection_with_empty_buffer_leaves_insertion_unset() { - // An empty buffer means the handoff matched but the user cancelled without selecting - // anything, so `insertion` must stay `None` rather than becoming `Some("")`: the landing - // logic (see Input::handle_block_completed_event) treats `None` as "just restore the - // cursor position", which a `Some("")` would bypass by splicing in empty text instead. - let mut pending = Some(PendingCtrlTHandoff { - session_id: SessionId::from(1), - token: "tok-1".to_string(), - original_buffer: "echo ".to_string(), - cursor_offset: ByteOffset::from(5), - insertion: None, - block_id: BlockId::new(), - apply_mode: CtrlTApplyMode::Splice, - }); - PendingCtrlTHandoff::maybe_apply_selection(&mut pending, SessionId::from(1), "tok-1", ""); - assert_eq!(pending.unwrap().insertion, None); +fn empty_shell_widget_handoff_selection_keeps_original_buffer() { + let mut pending = Some(pending_ctrl_r_handoff()); + PendingShellWidgetHandoff::maybe_apply_selection(&mut pending, SessionId::from(1), "tok-1", ""); + assert_eq!(pending.unwrap().restore_text(), "draft"); + + let mut pending = Some(pending_ctrl_t_handoff()); + PendingShellWidgetHandoff::maybe_apply_selection(&mut pending, SessionId::from(1), "tok-1", ""); + assert_eq!(pending.unwrap().selection, None); } #[test] @@ -2046,14 +1961,16 @@ async fn complete_ctrl_t_handoff( let input = terminal.read(app, |view, _| view.input().clone()); let block_id = BlockId::new(); input.update(app, |input, ctx| { - input.pending_ctrl_t_handoff = Some(PendingCtrlTHandoff { + input.pending_shell_widget_handoff = Some(PendingShellWidgetHandoff { session_id: SessionId::from(1), token: "tok-1".to_string(), original_buffer: original_buffer.to_string(), - cursor_offset: ByteOffset::from(cursor_offset), - insertion: insertion.map(str::to_string), + selection: insertion.map(str::to_string), block_id: block_id.clone(), - apply_mode, + kind: ShellWidgetHandoffKind::CtrlT { + apply_mode, + cursor_offset: ByteOffset::from(cursor_offset), + }, }); input.deferred_remote_operations.latest_block_id = BlockId::new(); input.handle_block_completed_event( @@ -2079,6 +1996,57 @@ async fn complete_ctrl_t_handoff( }) } +async fn complete_ctrl_r_handoff( + app: &mut App, + original_buffer: &str, + selection: Option<&str>, +) -> String { + let terminal = add_window_with_bootstrapped_terminal(app, None, None).await; + let input = terminal.read(app, |view, _| view.input().clone()); + let block_id = BlockId::new(); + input.update(app, |input, ctx| { + input.pending_shell_widget_handoff = Some(PendingShellWidgetHandoff { + session_id: SessionId::from(1), + token: "tok-1".to_string(), + original_buffer: original_buffer.to_string(), + selection: selection.map(str::to_string), + block_id: block_id.clone(), + kind: ShellWidgetHandoffKind::CtrlR, + }); + input.deferred_remote_operations.latest_block_id = BlockId::new(); + input.handle_block_completed_event( + BlockCompletedEvent { + block_type: user_block_completed_for_test(original_buffer), + num_secrets_obfuscated: 0, + block_index: BlockIndex::zero(), + block_id, + session_id: None, + restored_block_was_local: None, + }, + ctx, + ); + }); + input.read(app, |input, ctx| input.buffer_text(ctx)) +} + +#[test] +fn ctrl_r_handoff_replace_lands_selection() { + App::test((), |mut app| async move { + initialize_app(&mut app); + let buffer = complete_ctrl_r_handoff(&mut app, "draft", Some("echo selected")).await; + assert_eq!(buffer, "echo selected"); + }); +} + +#[test] +fn ctrl_r_handoff_cancel_restores_draft() { + App::test((), |mut app| async move { + initialize_app(&mut app); + let buffer = complete_ctrl_r_handoff(&mut app, "draft", None).await; + assert_eq!(buffer, "draft"); + }); +} + #[test] fn ctrl_t_handoff_splices_selection_in_middle_of_line() { App::test((), |mut app| async move { diff --git a/crates/warp_terminal/src/bootstrap_tests.rs b/crates/warp_terminal/src/bootstrap_tests.rs index 0aeb5614e25..18f13a8384c 100644 --- a/crates/warp_terminal/src/bootstrap_tests.rs +++ b/crates/warp_terminal/src/bootstrap_tests.rs @@ -62,779 +62,3 @@ fn test_trims_powershell_specifics() { fn decode_script(bytes: &[u8]) -> &str { std::str::from_utf8(bytes).expect("should not fail to decode") } - -fn fish_history_wrapper_installer() -> &'static str { - const FISH_SH: &str = include_str!("../../../app/assets/bundled/bootstrap/fish.sh"); - let start_marker = "if functions -q fish_should_add_to_history\n and not functions fish_should_add_to_history"; - let end_marker = " warp_original_fish_should_add_to_history $argv\nend"; - let start = FISH_SH - .find(start_marker) - .expect("fish history wrapper installer start should exist"); - let end = FISH_SH[start..] - .find(end_marker) - .expect("fish history wrapper installer end should exist"); - &FISH_SH[start..start + end + end_marker.len()] -} - -fn run_fish(script: &str) -> Option { - let output = match command::blocking::Command::new("fish") - .args(["--no-config", "-c", script]) - .output() - { - Ok(output) => output, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return None, - Err(error) => panic!("failed to run fish: {error}"), - }; - assert!( - output.status.success(), - "fish exited with {:?}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - Some(String::from_utf8_lossy(&output.stdout).into_owned()) -} - -#[test] -fn test_fish_history_wrapper_accepts_normal_commands_across_resourcing() { - let installer = fish_history_wrapper_installer(); - let script = format!( - r#" -{installer} -{installer} -fish_should_add_to_history "echo normal" -echo "normal:$status" -fish_should_add_to_history "warp_run_external_ctrl_r_widget token" -echo "helper:$status" -# The real invocation (see trigger_external_ctrl_r_history_search) is prefixed with a leading -# space, so atuin's own "ignorespace" exclusion also catches it; the wrapper must still reject -# this exact shape too. -fish_should_add_to_history " warp_run_external_ctrl_r_widget token" -echo "helper_leading_space:$status" -"# - ); - let Some(stdout) = run_fish(&script) else { - return; - }; - assert!(stdout.contains("normal:0"), "{stdout}"); - assert!(stdout.contains("helper:1"), "{stdout}"); - assert!(stdout.contains("helper_leading_space:1"), "{stdout}"); -} - -#[test] -fn test_fish_history_wrapper_preserves_user_hook_across_resourcing() { - let installer = fish_history_wrapper_installer(); - let script = format!( - r#" -function fish_should_add_to_history - string match --quiet -- "user_excluded*" $argv[1]; and return 1 - return 0 -end -{installer} -{installer} -fish_should_add_to_history "echo normal" -echo "normal:$status" -fish_should_add_to_history "warp_run_external_ctrl_r_widget token" -echo "helper:$status" -fish_should_add_to_history "user_excluded" -echo "user:$status" -"# - ); - let Some(stdout) = run_fish(&script) else { - return; - }; - assert!(stdout.contains("normal:0"), "{stdout}"); - assert!(stdout.contains("helper:1"), "{stdout}"); - assert!(stdout.contains("user:1"), "{stdout}"); -} - -/// Regression test for a user/plugin hook defined *between* two sourcings of this bootstrap -/// script (e.g. a plugin loaded after Warp's shell integration, followed by a shell reload or -/// nested fish subshell): the second sourcing must capture that hook rather than discarding it -/// in favor of whatever backup (or accept-everything default) an earlier sourcing installed. -#[test] -fn test_fish_history_wrapper_captures_hook_installed_between_resourcing() { - let installer = fish_history_wrapper_installer(); - let script = format!( - r#" -{installer} -function fish_should_add_to_history - string match --quiet -- "user_excluded*" $argv[1]; and return 1 - return 0 -end -{installer} -fish_should_add_to_history "echo normal" -echo "normal:$status" -fish_should_add_to_history "warp_run_external_ctrl_r_widget token" -echo "helper:$status" -fish_should_add_to_history "user_excluded" -echo "user:$status" -"# - ); - let Some(stdout) = run_fish(&script) else { - return; - }; - assert!(stdout.contains("normal:0"), "{stdout}"); - assert!(stdout.contains("helper:1"), "{stdout}"); - assert!(stdout.contains("user:1"), "{stdout}"); -} - -fn bash_ctrl_t_detection_snippet() -> &'static str { - const BASH_SH: &str = include_str!("../../../app/assets/bundled/bootstrap/bash_body.sh"); - let start_marker = " _WARP_EXTERNAL_CTRL_T_WIDGET=\"\"\n warp_ctrl_t_binding="; - let end_marker = " fi\n ;;\n esac"; - let start = BASH_SH - .find(start_marker) - .expect("bash ctrl-t detection snippet start should exist"); - let end = BASH_SH[start..] - .find(end_marker) - .expect("bash ctrl-t detection snippet end should exist"); - &BASH_SH[start..start + end + end_marker.len()] -} - -fn spawn_bash(script: &str) -> Option { - match command::blocking::Command::new("bash") - .args(["--noprofile", "--norc", "-c", script]) - .output() - { - Ok(output) => Some(output), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, - Err(error) => panic!("failed to run bash: {error}"), - } -} - -/// Windows CI's `bash` is the WSL launcher stub: it exists, so `NotFound` never fires, then -/// prints "no installed distributions" and exits 1. Require a sentinel so that stub is a skip -/// and a real bash that exits non-zero on a later script is still a failure. -fn posix_bash_is_usable() -> bool { - static USABLE: std::sync::OnceLock = std::sync::OnceLock::new(); - *USABLE.get_or_init(|| { - let Some(output) = spawn_bash("printf 'WARP_POSIX_BASH\\n'") else { - return false; - }; - output.status.success() - && String::from_utf8_lossy(&output.stdout).contains("WARP_POSIX_BASH") - }) -} - -fn run_bash(script: &str) -> Option { - if !posix_bash_is_usable() { - return None; - } - let output = spawn_bash(script)?; - assert!( - output.status.success(), - "bash exited with {:?}\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - Some(String::from_utf8_lossy(&output.stdout).into_owned()) -} - -fn bash_ctrl_t_bind_x_extraction() -> &'static str { - let snippet = bash_ctrl_t_detection_snippet(); - let start = snippet - .find("bind -X 2>/dev/null | command -p sed") - .expect("ctrl-t detection should pipe bind -X through sed"); - let rest = &snippet[start..]; - let quote = rest - .rfind('\'') - .expect("ctrl-t bind -X sed program should be single-quoted"); - &rest[..=quote] -} - -fn bash_ctrl_t_bind_x_sed_program() -> &'static str { - let extraction = bash_ctrl_t_bind_x_extraction(); - let prefix = "sed -n '"; - let start = extraction - .find(prefix) - .expect("extraction should invoke sed -n") - + prefix.len(); - let end = extraction[start..] - .find("'") - .expect("sed program should be single-quoted"); - &extraction[start..start + end] -} - -/// Whether this environment can read a `bind -x` binding back out through the pipeline in -/// `bash_body.sh`, run the way the tests below run it. `None` if bash isn't installed at all, -/// mirroring `run_bash`'s "shell missing" skip convention. -/// -/// Skip only when `bind -X` does not list the probe at all: `bind -X` arrived in bash 4.3, and a -/// non-interactive shell need not have line editing enabled, so the binding is never listable. -/// If `bind -X` lists the probe but extraction is empty, panic -- that is a broken product -/// extractor, not a reason to skip. -/// -/// Deliberately probes with a sentinel widget name rather than `fzf-file-widget`, so it tests the -/// capability without also asserting the `case` match the tests below exist to check -- otherwise -/// the gate would subsume the assertion and the tests could never fail. -fn bash_can_extract_ctrl_t_binding() -> Option { - if !posix_bash_is_usable() { - return None; - } - let extraction = bash_ctrl_t_bind_x_extraction(); - let script = format!( - r#"bind -x '"\C-t": warp_bind_x_probe' 2>/dev/null; printf 'EXTRACTED:%s\n' "$({extraction})"; bind -X 2>/dev/null"# - ); - let output = spawn_bash(&script)?; - let stdout = String::from_utf8_lossy(&output.stdout); - let mut lines = stdout.lines(); - let extracted = lines - .next() - .unwrap_or("") - .strip_prefix("EXTRACTED:") - .unwrap_or("") - .trim(); - let raw: String = lines.collect(); - if extracted == "warp_bind_x_probe" { - return Some(true); - } - if raw.contains("warp_bind_x_probe") { - panic!( - "bind -X listed warp_bind_x_probe but bash_body.sh extraction returned {extracted:?}; raw bind -X: {raw:?}" - ); - } - Some(false) -} - -/// The ctrl-t `bind -X` sed from `bash_body.sh` must accept both bash 5.2 colon and bash 5.3 -/// space layouts. Does not need `bind -X` or an interactive shell. -#[test] -fn test_bash_bind_x_extraction_accepts_colon_and_space_formats() { - let sed = bash_ctrl_t_bind_x_sed_program(); - assert!( - sed.contains("[ :]"), - "ctrl-t bind -X sed must accept colon or space; got {sed:?}" - ); - let script = format!( - r#"colon=$(printf '%s\n' '"\C-t": "fzf-file-widget"' | command -p sed -n '{sed}'); space=$(printf '%s\n' '"\C-t" "fzf-file-widget"' | command -p sed -n '{sed}'); printf 'colon=[%s] space=[%s]\n' "$colon" "$space""# - ); - let Some(stdout) = run_bash(&script) else { - return; - }; - assert!( - stdout.contains("colon=[fzf-file-widget]"), - "colon layout should extract; got {stdout:?}" - ); - assert!( - stdout.contains("space=[fzf-file-widget]"), - "space layout should extract; got {stdout:?}" - ); -} - -/// Regression test for the ctrl-t equivalent of bash's `declare -F __atuin_history` guard on the -/// ctrl-r path: detection must decline (no tag, no interception) when the picker function -/// `warp_run_external_ctrl_t_widget` calls -- `__fzf_select__` -- isn't actually defined, even -/// though `bind -X` reports the wrapper name ("fzf-file-widget") that detection matches against. -/// Without this guard, an fzf version that renamed its picker function would have ctrl-t tagged -/// and intercepted with nothing to invoke, swallowing the key instead of leaving it alone. -#[test] -fn test_bash_ctrl_t_detection_declines_when_picker_function_is_absent() { - if bash_can_extract_ctrl_t_binding() == Some(false) { - return; - } - let detection = bash_ctrl_t_detection_snippet(); - let script = format!( - r#" -WARP_IN_MSYS2=false -shell_plugins=() -bind -x '"\C-t": fzf-file-widget' -{detection} -printf 'widget=[%s] plugins=[%s]\n' "$_WARP_EXTERNAL_CTRL_T_WIDGET" "${{shell_plugins[*]}}" -"# - ); - let Some(stdout) = run_bash(&script) else { - return; - }; - assert!(stdout.contains("widget=[]"), "{stdout}"); - assert!(!stdout.contains("external_ctrl_t_file"), "{stdout}"); -} - -#[test] -fn test_bash_ctrl_t_detection_tags_when_picker_function_is_present() { - if bash_can_extract_ctrl_t_binding() == Some(false) { - return; - } - let detection = bash_ctrl_t_detection_snippet(); - let script = format!( - r#" -WARP_IN_MSYS2=false -shell_plugins=() -bind -x '"\C-t": fzf-file-widget' -__fzf_select__() {{ :; }} -{detection} -printf 'widget=[%s] plugins=[%s]\n' "$_WARP_EXTERNAL_CTRL_T_WIDGET" "${{shell_plugins[*]}}" -"# - ); - let Some(stdout) = run_bash(&script) else { - return; - }; - assert!(stdout.contains("widget=[fzf-file-widget]"), "{stdout}"); - assert!(stdout.contains("external_ctrl_t_file"), "{stdout}"); -} - -fn fish_ctrl_r_widget_runner_fn() -> &'static str { - const FISH_SH: &str = include_str!("../../../app/assets/bundled/bootstrap/fish.sh"); - let start_marker = "function warp_run_external_ctrl_r_widget\n"; - let start = FISH_SH - .find(start_marker) - .expect("fish ctrl-r widget runner function start should exist"); - let end_marker = "\nend\n"; - let end = FISH_SH[start..] - .find(end_marker) - .expect("fish ctrl-r widget runner function end should exist"); - &FISH_SH[start..start + end + end_marker.len()] -} - -/// Regression test for `warp_run_external_ctrl_r_widget`'s fzf case: it used to hand-build -/// `FZF_DEFAULT_OPTS` with flags (`--wrap-sign`, `--highlight-line`, `--accept-nth`, -/// `--with-shell`) and call a helper function (`__fzf_defaults`) that don't exist on every fzf -/// shell integration -- confirmed to fail outright with "Unknown command: __fzf_defaults" against -/// a real, still-commonly-packaged fzf 0.44.1 install, with the picker that did appear (fzf -/// falling through to a plain invocation once that command failed) reading raw, unformatted -/// history text as its input. It now delegates entirely to the user's own `fzf-history-widget` -/// instead, so this stubs that widget and the interactive-only `commandline` builtin they both -/// call, to verify the wrapper reports whatever the widget leaves on the commandline without -/// depending on any fzf-version-specific option or helper function existing at all -- the kind of -/// test that would have caught the original defect, rather than merely asserting one flag absent. -fn fish_ctrl_r_widget_test_script(runner: &str, widget_body: &str) -> String { - format!( - r#" -function warp_escape_json - string join \n $argv -end -function warp_send_json_message - echo "$argv" -end -set -g _test_commandline_value '' -function commandline - echo "$_test_commandline_value" -end -function fzf-history-widget - {widget_body} -end -set -g _WARP_EXTERNAL_CTRL_R_WIDGET fzf-history-widget -set -g WARP_SESSION_ID 12345 -{runner} -warp_run_external_ctrl_r_widget test-token -"# - ) -} - -#[test] -fn test_fish_ctrl_r_widget_reports_fzf_history_widget_selection() { - let runner = fish_ctrl_r_widget_runner_fn(); - let script = fish_ctrl_r_widget_test_script( - runner, - "set -g _test_commandline_value 'echo selected_from_widget'", - ); - let Some(stdout) = run_fish(&script) else { - return; - }; - assert!( - stdout.contains(r#""buffer": "echo selected_from_widget""#), - "{stdout}" - ); -} - -/// `fzf-history-widget` only calls `commandline` on a successful selection, leaving it untouched -/// on cancel -- the wrapper must report that untouched (here: still-empty) state as an empty -/// buffer, matching the existing "nothing selected" convention shared with the plain-path bash/ -/// zsh widgets. -#[test] -fn test_fish_ctrl_r_widget_reports_empty_buffer_when_widget_leaves_commandline_untouched() { - let runner = fish_ctrl_r_widget_runner_fn(); - let script = fish_ctrl_r_widget_test_script(runner, "# cancelled: commandline left as-is"); - let Some(stdout) = run_fish(&script) else { - return; - }; - assert!(stdout.contains(r#""buffer": """#), "{stdout}"); -} - -fn fish_warp_escape_json_fn() -> &'static str { - const FISH_SH: &str = include_str!("../../../app/assets/bundled/bootstrap/fish.sh"); - let start_marker = "function warp_escape_json\n"; - let start = FISH_SH - .find(start_marker) - .expect("fish warp_escape_json function start should exist"); - let end_marker = "\nend\n"; - let end = FISH_SH[start..] - .find(end_marker) - .expect("fish warp_escape_json function end should exist"); - &FISH_SH[start..start + end + end_marker.len()] -} - -/// Regression test for `set result (commandline | string collect)` above: without `string -/// collect`, a multi-line selection makes that `set`'s own command substitution split it into a -/// list by newline, and the real `warp_escape_json` (used here instead of the plain-join stub the -/// other tests in this section use, since the defect is specifically in how it escapes -- or -/// fails to escape -- what it's given) then quotes that list back down to a single argument by -/// joining with a space instead of preserving the newline as JSON's `\n` escape. -#[test] -fn test_fish_ctrl_r_widget_reports_multiline_selection_with_embedded_newline() { - let runner = fish_ctrl_r_widget_runner_fn(); - let escape_json = fish_warp_escape_json_fn(); - let script = format!( - r#" -{escape_json} -function warp_send_json_message - echo "$argv" -end -set -g _test_commandline_value '' -function commandline - echo "$_test_commandline_value" -end -function fzf-history-widget - set -g _test_commandline_value (printf 'echo one\necho two' | string collect) -end -set -g _WARP_EXTERNAL_CTRL_R_WIDGET fzf-history-widget -set -g WARP_SESSION_ID 12345 -{runner} -warp_run_external_ctrl_r_widget test-token -"# - ); - let Some(stdout) = run_fish(&script) else { - return; - }; - assert!( - stdout.contains(r#""buffer": "echo one\necho two""#), - "{stdout}" - ); -} - -fn fish_ctrl_t_widget_query_fn() -> &'static str { - const FISH_SH: &str = include_str!("../../../app/assets/bundled/bootstrap/fish.sh"); - let start_marker = "function warp_external_ctrl_t_widget\n set -l widget \"\"\n for binding in (bind \\ct 2>/dev/null)"; - let end_marker = " test -n \"$widget\"; or return 1\n echo \"$widget\"\nend"; - let start = FISH_SH - .find(start_marker) - .expect("fish ctrl-t widget query function start should exist"); - let end = FISH_SH[start..] - .find(end_marker) - .expect("fish ctrl-t widget query function end should exist"); - &FISH_SH[start..start + end + end_marker.len()] -} - -fn fish_ctrl_t_detection_snippet() -> &'static str { - const FISH_SH: &str = include_str!("../../../app/assets/bundled/bootstrap/fish.sh"); - let start_marker = "set -g _WARP_EXTERNAL_CTRL_T_WIDGET \"\"\n set -l warp_ctrl_t_widget (warp_external_ctrl_t_widget)\n switch \"$warp_ctrl_t_widget\""; - let end_marker = " set -a shell_plugins external_ctrl_t_file\n end\n end"; - let start = FISH_SH - .find(start_marker) - .expect("fish ctrl-t detection snippet start should exist"); - let end = FISH_SH[start..] - .find(end_marker) - .expect("fish ctrl-t detection snippet end should exist"); - &FISH_SH[start..start + end + end_marker.len()] -} - -fn fish_ctrl_t_widget_result_fn() -> &'static str { - const FISH_SH: &str = include_str!("../../../app/assets/bundled/bootstrap/fish.sh"); - // Locates the function boundary structurally (start of the `function` line to its matching - // `end` line) rather than by matching the literal body text, so a behavioral mutation to the - // comparison inside it changes what the test observes instead of breaking extraction itself. - let start_marker = "function warp_ctrl_t_widget_result\n"; - let start = FISH_SH - .find(start_marker) - .expect("fish ctrl-t widget result function start should exist"); - let end_marker = "\nend\n"; - let end = FISH_SH[start..] - .find(end_marker) - .expect("fish ctrl-t widget result function end should exist"); - &FISH_SH[start..start + end + end_marker.len()] -} - -#[test] -fn test_fish_ctrl_t_widget_result_is_empty_when_widget_leaves_draft_unchanged() { - let result_fn = fish_ctrl_t_widget_result_fn(); - let script = format!( - r#" -{result_fn} -set result (warp_ctrl_t_widget_result 'echo START MIDDLE' 'echo START MIDDLE') -printf 'result=[%s]\n' "$result" -"# - ); - let Some(stdout) = run_fish(&script) else { - return; - }; - assert!(stdout.contains("result=[]"), "{stdout}"); -} - -#[test] -fn test_fish_ctrl_t_widget_result_preserves_changed_line() { - let result_fn = fish_ctrl_t_widget_result_fn(); - let script = format!( - r#" -{result_fn} -set result (warp_ctrl_t_widget_result 'echo START MIDDLE' 'echo START nested.rs MIDDLE') -printf 'result=[%s]\n' "$result" -"# - ); - let Some(stdout) = run_fish(&script) else { - return; - }; - assert!( - stdout.contains("result=[echo START nested.rs MIDDLE]"), - "{stdout}" - ); -} - -/// Hex-encodes `s` the way [`ctrl_t_draft_arg`] does, for building test `{char_cursor}:{hex}` -/// arguments without depending on fish's own `warp_hex_encode_string`. -fn hex_encode(s: &str) -> String { - s.bytes().map(|b| format!("{b:02x}")).collect() -} - -fn fish_hex_decode_string_fn() -> &'static str { - const FISH_SH: &str = include_str!("../../../app/assets/bundled/bootstrap/fish.sh"); - let start_marker = "function warp_hex_decode_string\n"; - let start = FISH_SH - .find(start_marker) - .expect("fish hex decode function start should exist"); - let end_marker = "\nend\n"; - let end = FISH_SH[start..] - .find(end_marker) - .expect("fish hex decode function end should exist"); - &FISH_SH[start..start + end + end_marker.len()] -} - -/// The `string split` + `warp_hex_decode_string` argument-parsing step inside -/// `warp_run_external_ctrl_t_widget`, extracted on its own (not via the full widget runner) so a -/// dedicated test can assert the decoded `char_cursor`/`original_line` directly. The two -/// full-widget tests below can't catch a corrupted split or decode by themselves: the same -/// corrupted value seeds both sides of `warp_ctrl_t_widget_result`'s equality check and cancels -/// out. -fn fish_ctrl_t_argument_parsing_snippet() -> &'static str { - const FISH_SH: &str = include_str!("../../../app/assets/bundled/bootstrap/fish.sh"); - // Structural, not literal-text, boundaries (see `fish_ctrl_t_widget_result_fn` above) so a - // behavioral change to the parsing logic itself changes what the test observes. - let start_marker = "set -l warp_ctrl_t_parts (string split -m 1 -- ':' \"$argv[2]\")\n"; - let start = FISH_SH - .find(start_marker) - .expect("fish ctrl-t argument parsing snippet start should exist"); - let end_marker = "--allow-empty)\n"; - let end = FISH_SH[start..] - .find(end_marker) - .expect("fish ctrl-t argument parsing snippet end should exist"); - &FISH_SH[start..start + end + end_marker.len()] -} - -/// Regression test for the argument-parsing step alone, asserting the decoded `char_cursor` and -/// `original_line` directly against a draft with both an embedded and a trailing newline -- the -/// case that requires `string collect --no-trim-newlines`, not just `warp_hex_decode_string` -/// itself, to survive intact. -#[test] -fn test_fish_ctrl_t_argument_parsing_decodes_multiline_trailing_newline_draft() { - let hex_decode_fn = fish_hex_decode_string_fn(); - let parsing_snippet = fish_ctrl_t_argument_parsing_snippet(); - let hex_draft = hex_encode("echo one\ntwo\n"); - let script = format!( - r#" -{hex_decode_fn} -function warp_ctrl_t_test_parse - {parsing_snippet} - printf 'char_cursor=[%s]\n' "$char_cursor" - printf 'original_line=[%s]\n' "$original_line" -end -warp_ctrl_t_test_parse test-token '8:{hex_draft}' -"# - ); - let Some(stdout) = run_fish(&script) else { - return; - }; - assert!(stdout.contains("char_cursor=[8]"), "{stdout}"); - assert!( - stdout.contains("original_line=[echo one\ntwo\n]"), - "{stdout}" - ); -} - -fn fish_ctrl_t_widget_runner_fn() -> &'static str { - const FISH_SH: &str = include_str!("../../../app/assets/bundled/bootstrap/fish.sh"); - let start_marker = "function warp_run_external_ctrl_t_widget\n"; - let start = FISH_SH - .find(start_marker) - .expect("fish ctrl-t widget runner function start should exist"); - let end_marker = "\nend\n"; - let end = FISH_SH[start..] - .find(end_marker) - .expect("fish ctrl-t widget runner function end should exist"); - &FISH_SH[start..start + end + end_marker.len()] -} - -/// Builds a script that runs the full `warp_run_external_ctrl_t_widget` (not just the -/// `warp_ctrl_t_widget_result` comparison helper in isolation) against a real `{char_cursor}:{hex}` -/// argument, so the `(commandline | string collect)` argument at its `fzf-file-widget` call site -/// is exercised too -- unquoted, a multi-line result there would otherwise expand to multiple -/// arguments, silently truncating that comparison to the result's first line alone. `commandline` -/// is stubbed statefully (supporting the `-r --` and `-C --` forms the widget actually calls, -/// plus a plain read) rather than as a fixed value, since the widget both seeds and reads it -/// back. The read stub uses `echo`, matching the real builtin's own bare-read behavior of always -/// terminating its output with a newline regardless of the buffer's actual content -- a stub that -/// used `printf '%s'` instead would silently hide a regression in the comparison's own newline -/// handling. The `-r` stub also distinguishes a *read* (no CMD argument at all, i.e. -/// `$argv[3..]` is empty) from a *write*, matching real fish semantics -- a stub that always -/// wrote, even with nothing to write, would silently hide a regression that fails to seed an -/// empty draft. `_test_cl_value` starts as a non-empty sentinel rather than `''`, so a failure to -/// seed (leaving the sentinel in place) is distinguishable from a correctly-seeded empty draft. -fn fish_ctrl_t_widget_test_script(ctrl_t_arg: &str, widget_body: &str) -> String { - let runner = fish_ctrl_t_widget_runner_fn(); - let hex_decode_fn = fish_hex_decode_string_fn(); - let widget_result_fn = fish_ctrl_t_widget_result_fn(); - format!( - r#" -# Unlike the real warp_escape_json (see fish_warp_escape_json_fn above), this stub doesn't -# actually escape a real newline into JSON's `\n` -- piped through `string collect` purely so -# that leaving one in doesn't itself get re-split by the `set` below that captures this -# function's own output, which would otherwise mask the very truncation these tests exist to -# catch behind an unrelated space-joining artifact of the stub. -function warp_escape_json - string join \n $argv | string collect -end -function warp_send_json_message - echo "$argv" -end -{hex_decode_fn} -{widget_result_fn} -set -g _test_cl_value 'UNSEEDED-SENTINEL' -function commandline - if test (count $argv) -ge 1; and test "$argv[1]" = '-r' - if test (count $argv[3..]) -eq 0 - # No CMD argument at all is a read, not a write -- must leave $_test_cl_value untouched. - return 0 - end - set -g _test_cl_value (string collect --no-trim-newlines -- $argv[3..]) - return 0 - end - if test (count $argv) -ge 1; and test "$argv[1]" = '-C' - return 0 - end - echo "$_test_cl_value" -end -function fzf-file-widget - {widget_body} -end -set -g _WARP_EXTERNAL_CTRL_T_WIDGET fzf-file-widget -set -g WARP_SESSION_ID 12345 -{runner} -warp_run_external_ctrl_t_widget test-token '{ctrl_t_arg}' -"# - ) -} - -/// Regression test for the `(commandline | string collect)` argument at the widget's -/// `fzf-file-widget` call site: without `string collect`, a multi-line selection is split by that -/// call's own (unquoted) command substitution into multiple arguments, silently truncating -/// `warp_ctrl_t_widget_result`'s second argument -- and therefore the reported buffer -- to the -/// selection's first line alone. -#[test] -fn test_fish_ctrl_t_widget_reports_full_multiline_change_without_truncation() { - let hex_draft = hex_encode("echo START\nMIDDLE"); - let script = fish_ctrl_t_widget_test_script( - &format!("10:{hex_draft}"), - "commandline -r -- (printf 'echo START\\nMIDDLE nested.rs ' | string collect)", - ); - let Some(stdout) = run_fish(&script) else { - return; - }; - assert!( - stdout.contains("\"buffer\": \"echo START\nMIDDLE nested.rs \""), - "{stdout}" - ); -} - -/// Companion to the test above, for the failure mode the same truncation causes on cancel: a -/// multi-line draft left unchanged gets word-split at the same call site, so -/// `warp_ctrl_t_widget_result` compares the full original line against only its own first line, -/// finds them unequal, and reports that stale first line as if it were a real selection instead -/// of the empty buffer this "unchanged" case is supposed to produce. -#[test] -fn test_fish_ctrl_t_widget_reports_empty_when_multiline_draft_is_left_unchanged() { - let hex_draft = hex_encode("echo START\nMIDDLE"); - let script = fish_ctrl_t_widget_test_script(&format!("10:{hex_draft}"), "# cancelled"); - let Some(stdout) = run_fish(&script) else { - return; - }; - assert!(stdout.contains(r#""buffer": """#), "{stdout}"); -} - -/// Regression test for a plain, single-line, unchanged draft on cancel: a bare `commandline` read -/// always terminates its own output with a newline regardless of the buffer's actual content, so -/// comparing it against `original_line` with `--no-trim-newlines` (rather than the default, -/// trimming `string collect`) would make an ordinary, single-line cancel always compare unequal -/// to itself, misreporting the cancel as a real selection. -#[test] -fn test_fish_ctrl_t_widget_reports_empty_when_single_line_draft_is_left_unchanged() { - let hex_draft = hex_encode("echo START MIDDLE"); - let script = fish_ctrl_t_widget_test_script(&format!("11:{hex_draft}"), "# cancelled"); - let Some(stdout) = run_fish(&script) else { - return; - }; - assert!(stdout.contains(r#""buffer": """#), "{stdout}"); -} - -/// Regression test for an empty draft (ctrl-t on a blank line): decoding zero bytes must still -/// seed the commandline with an explicit empty buffer, not skip seeding altogether. -/// `warp_hex_decode_string` on an empty hex string produces no output at all, and piping that -/// through plain `string collect` collapses to zero list elements rather than one empty string -- -/// so `commandline -r --` would receive no CMD argument, which fish treats as a *read*, leaving -/// whatever was already on the commandline (here, the sentinel standing in for the synthetic -/// helper invocation itself) in place instead of clearing it. -#[test] -fn test_fish_ctrl_t_widget_seeds_blank_buffer_for_empty_draft() { - let script = fish_ctrl_t_widget_test_script("0:", "# cancelled"); - let Some(stdout) = run_fish(&script) else { - return; - }; - assert!(stdout.contains(r#""buffer": """#), "{stdout}"); - assert!(!stdout.contains("UNSEEDED-SENTINEL"), "{stdout}"); -} - -/// Regression test for the fish equivalent of bash's picker-function guard: detection must -/// decline (no tag, no interception) when `fzf-file-widget` -- the function -/// `warp_run_external_ctrl_t_widget` now calls directly -- isn't actually defined, even though -/// `bind` reports it as ctrl-t's binding. Without this guard, a rebind to a nonexistent or -/// renamed function would have ctrl-t tagged and intercepted with nothing to invoke, swallowing -/// the key instead of leaving it alone. -#[test] -fn test_fish_ctrl_t_detection_declines_when_picker_function_is_absent() { - let query_fn = fish_ctrl_t_widget_query_fn(); - let detection = fish_ctrl_t_detection_snippet(); - let script = format!( - r#" -{query_fn} -bind \ct fzf-file-widget -set -l shell_plugins -{detection} -printf 'widget=[%s] plugins=[%s]\n' "$_WARP_EXTERNAL_CTRL_T_WIDGET" "$shell_plugins" -"# - ); - let Some(stdout) = run_fish(&script) else { - return; - }; - assert!(stdout.contains("widget=[]"), "{stdout}"); - assert!(!stdout.contains("external_ctrl_t_file"), "{stdout}"); -} - -#[test] -fn test_fish_ctrl_t_detection_tags_when_picker_function_is_present() { - let query_fn = fish_ctrl_t_widget_query_fn(); - let detection = fish_ctrl_t_detection_snippet(); - let script = format!( - r#" -{query_fn} -function fzf-file-widget -end -bind \ct fzf-file-widget -set -l shell_plugins -{detection} -printf 'widget=[%s] plugins=[%s]\n' "$_WARP_EXTERNAL_CTRL_T_WIDGET" "$shell_plugins" -"# - ); - let Some(stdout) = run_fish(&script) else { - return; - }; - assert!(stdout.contains("widget=[fzf-file-widget]"), "{stdout}"); - assert!(stdout.contains("external_ctrl_t_file"), "{stdout}"); -} From a5e00f842f1695b5daa1d75bccc4ab6b20c5c67d Mon Sep 17 00:00:00 2001 From: Andy Carlson <2yinyang2@gmail.com> Date: Mon, 31 Aug 2026 15:17:58 -0700 Subject: [PATCH 60/70] more cleanup --- app/assets/bundled/bootstrap/bash_body.sh | 7 ------- app/assets/bundled/bootstrap/fish.sh | 5 +---- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/app/assets/bundled/bootstrap/bash_body.sh b/app/assets/bundled/bootstrap/bash_body.sh index 47463826e53..bca309ffbfb 100644 --- a/app/assets/bundled/bootstrap/bash_body.sh +++ b/app/assets/bundled/bootstrap/bash_body.sh @@ -1449,13 +1449,6 @@ esac # Add patterns to ignore in-band commands in shell history, while preserving the user's # HISTIGNORE value which may been set in an RC file sourced above. It is important to # ensure that this happens _after_ the user's RC files have been sourced. - # - # This also excludes the ctrl-r/ctrl-t external handoff helpers (see - # warp_run_external_ctrl_r_widget/warp_run_external_ctrl_t_widget above): they're - # Warp-internal invocations, not commands the user meant to run again later, and leaving - # them in history would otherwise pollute the very history list ctrl-r searches (and, for - # the ctrl-t helper specifically, still show up as ordinary shell history noise even though - # ctrl-t itself doesn't search shell history). if [[ ! -z $HISTIGNORE ]]; then HISTIGNORE="*warp_run_generator_command*:*warp_run_external_ctrl_r_widget*:*warp_run_external_ctrl_t_widget*:$HISTIGNORE" else diff --git a/app/assets/bundled/bootstrap/fish.sh b/app/assets/bundled/bootstrap/fish.sh index 1dc48a48aca..9784dc34fe2 100644 --- a/app/assets/bundled/bootstrap/fish.sh +++ b/app/assets/bundled/bootstrap/fish.sh @@ -74,7 +74,6 @@ function warp_completions_hex_encode string replace -a -- ' ' '' (string join '' $od_output) end -# warp_hex_decode_string decodes a string hex-encoded by warp_hex_encode_string. function warp_hex_decode_string if test (count $argv) -eq 0 -o -z "$argv[1]" return @@ -632,9 +631,7 @@ function warp_run_external_ctrl_t_widget end # Exclude the ctrl-r/ctrl-t external handoff helpers (see warp_run_external_ctrl_r_widget/ -# warp_run_external_ctrl_t_widget above) from the user's history: they're Warp-internal -# invocations, not commands the user meant to run again later, and leaving the ctrl-r one in -# history would otherwise pollute the very history list this feature searches on the next ctrl-r. +# warp_run_external_ctrl_t_widget above) from the user's history. # # fish only supports a single fish_should_add_to_history function (unlike zsh's array of # zshaddhistory hooks or bash's PROMPT_COMMAND-style stacking), so compose with any From 6e761fa5c328b29c8bd6dbdf2ed5579f7be5b465 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:36:15 +0000 Subject: [PATCH 61/70] Unify ExternalCtrlR/T selection into one shell-widget hook. Both payloads were identical (buffer, token, session_id); PendingShellWidgetHandoff already holds the ctrl-r vs ctrl-t apply distinction. Emit and parse a single ExternalShellWidgetSelection hook and apply it through one Input setter. --- app/assets/bundled/bootstrap/bash_body.sh | 4 +- app/assets/bundled/bootstrap/fish.sh | 4 +- app/assets/bundled/bootstrap/zsh_body.sh | 4 +- app/src/terminal/event.rs | 26 +----- app/src/terminal/input.rs | 19 +---- app/src/terminal/model/terminal_model.rs | 15 ++-- app/src/terminal/model_events.rs | 16 ++-- app/src/terminal/view.rs | 19 ++--- .../warp_terminal/src/model/ansi/dcs_hooks.rs | 80 ++++--------------- .../src/model/ansi/dcs_hooks_tests.rs | 6 +- .../warp_terminal/src/model/ansi/handler.rs | 8 +- crates/warp_terminal/src/model/ansi/mod.rs | 7 +- .../warp_terminal/src/model/ansi/mod_tests.rs | 59 ++------------ 13 files changed, 56 insertions(+), 211 deletions(-) diff --git a/app/assets/bundled/bootstrap/bash_body.sh b/app/assets/bundled/bootstrap/bash_body.sh index bca309ffbfb..627799260b1 100644 --- a/app/assets/bundled/bootstrap/bash_body.sh +++ b/app/assets/bundled/bootstrap/bash_body.sh @@ -992,7 +992,7 @@ if [ -z "$WARP_BOOTSTRAPPED" ]; then esac local warp_escaped_selection="$(warp_escape_json "$result")" local warp_escaped_token="$(warp_escape_json "$warp_ctrl_r_token")" - warp_send_json_message "{ \"hook\": \"ExternalCtrlRSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"token\": \"$warp_escaped_token\", \"session_id\": $WARP_SESSION_ID } }" + warp_send_json_message "{ \"hook\": \"ExternalShellWidgetSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"token\": \"$warp_escaped_token\", \"session_id\": $WARP_SESSION_ID } }" } # Runs the shell's own ctrl-t file-search widget as a foreground command. @@ -1006,7 +1006,7 @@ if [ -z "$WARP_BOOTSTRAPPED" ]; then esac local warp_escaped_selection="$(warp_escape_json "$result")" local warp_escaped_token="$(warp_escape_json "$warp_ctrl_t_token")" - warp_send_json_message "{ \"hook\": \"ExternalCtrlTSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"token\": \"$warp_escaped_token\", \"session_id\": $WARP_SESSION_ID } }" + warp_send_json_message "{ \"hook\": \"ExternalShellWidgetSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"token\": \"$warp_escaped_token\", \"session_id\": $WARP_SESSION_ID } }" } # Check whether the prompt-related variables have OSC prompt marker sequences, diff --git a/app/assets/bundled/bootstrap/fish.sh b/app/assets/bundled/bootstrap/fish.sh index 9784dc34fe2..87f6062a529 100644 --- a/app/assets/bundled/bootstrap/fish.sh +++ b/app/assets/bundled/bootstrap/fish.sh @@ -602,7 +602,7 @@ function warp_run_external_ctrl_r_widget end set -l warp_escaped_selection (warp_escape_json "$result") set -l warp_escaped_token (warp_escape_json "$warp_ctrl_r_token") - warp_send_json_message "{ \"hook\": \"ExternalCtrlRSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"token\": \"$warp_escaped_token\", \"session_id\": $WARP_SESSION_ID } }" + warp_send_json_message "{ \"hook\": \"ExternalShellWidgetSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"token\": \"$warp_escaped_token\", \"session_id\": $WARP_SESSION_ID } }" end function warp_ctrl_t_widget_result @@ -627,7 +627,7 @@ function warp_run_external_ctrl_t_widget end set -l warp_escaped_selection (warp_escape_json "$result") set -l warp_escaped_token (warp_escape_json "$warp_ctrl_t_token") - warp_send_json_message "{ \"hook\": \"ExternalCtrlTSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"token\": \"$warp_escaped_token\", \"session_id\": $WARP_SESSION_ID } }" + warp_send_json_message "{ \"hook\": \"ExternalShellWidgetSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"token\": \"$warp_escaped_token\", \"session_id\": $WARP_SESSION_ID } }" end # Exclude the ctrl-r/ctrl-t external handoff helpers (see warp_run_external_ctrl_r_widget/ diff --git a/app/assets/bundled/bootstrap/zsh_body.sh b/app/assets/bundled/bootstrap/zsh_body.sh index 8d777e97f89..4dcbf856576 100644 --- a/app/assets/bundled/bootstrap/zsh_body.sh +++ b/app/assets/bundled/bootstrap/zsh_body.sh @@ -735,7 +735,7 @@ if [[ -z $WARP_BOOTSTRAPPED ]]; then esac local warp_escaped_selection="$(warp_escape_json "$result")" local warp_escaped_token="$(warp_escape_json "$warp_ctrl_r_token")" - warp_send_json_message "{ \"hook\": \"ExternalCtrlRSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"token\": \"$warp_escaped_token\", \"session_id\": $WARP_SESSION_ID } }" + warp_send_json_message "{ \"hook\": \"ExternalShellWidgetSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"token\": \"$warp_escaped_token\", \"session_id\": $WARP_SESSION_ID } }" } # Runs the shell's own ctrl-t file-search widget as a foreground command. @@ -753,7 +753,7 @@ if [[ -z $WARP_BOOTSTRAPPED ]]; then esac local warp_escaped_selection="$(warp_escape_json "$result")" local warp_escaped_token="$(warp_escape_json "$warp_ctrl_t_token")" - warp_send_json_message "{ \"hook\": \"ExternalCtrlTSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"token\": \"$warp_escaped_token\", \"session_id\": $WARP_SESSION_ID } }" + warp_send_json_message "{ \"hook\": \"ExternalShellWidgetSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"token\": \"$warp_escaped_token\", \"session_id\": $WARP_SESSION_ID } }" } function clear() { diff --git a/app/src/terminal/event.rs b/app/src/terminal/event.rs index be833846d4c..4e2ab4f7aed 100644 --- a/app/src/terminal/event.rs +++ b/app/src/terminal/event.rs @@ -9,9 +9,7 @@ pub use warp_terminal::event::{ExecutedExecutorCommandEvent, ParseGeneratorOutpu use warp_util::lazy::Lazy; use super::history::HistoryEntry; -use super::model::ansi::{ - ExternalCtrlRSelectionValue, ExternalCtrlTSelectionValue, FinishUpdateValue, -}; +use super::model::ansi::{ExternalShellWidgetSelectionValue, FinishUpdateValue}; use super::model::block::BlockId; use super::model::lifecycle::LifecycleRecoveryRecord; use super::model::session::{SessionId, SessionInfo}; @@ -130,12 +128,7 @@ 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 external ctrl-r history - /// widget (e.g. fzf or atuin). - ExternalCtrlRSelection(ExternalCtrlRSelectionValue), - /// Emitted when the shell reports the path(s) selected in its external ctrl-t file-search - /// widget (e.g. fzf). - ExternalCtrlTSelection(ExternalCtrlTSelectionValue), + ExternalShellWidgetSelection(ExternalShellWidgetSelectionValue), TextSelectionChanged, ShellSpawned(ShellType), ImageReceived { @@ -483,21 +476,10 @@ impl Debug for Event { ) } Event::FinishUpdate(data) => write!(f, "FinishUpdate({})", data.update_id), - Event::ExternalCtrlRSelection(data) => { - // The buffer is a selected shell command, which may carry a credential; log only - // its length rather than its contents. + Event::ExternalShellWidgetSelection(data) => { write!( f, - "ExternalCtrlRSelection(buffer_len: {})", - data.buffer.len() - ) - } - Event::ExternalCtrlTSelection(data) => { - // The buffer is a selected file path, which may reveal filesystem structure; - // log only its length rather than its contents. - write!( - f, - "ExternalCtrlTSelection(buffer_len: {})", + "ExternalShellWidgetSelection(buffer_len: {})", data.buffer.len() ) } diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index ed5b833b739..7b76e32e58c 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -7712,7 +7712,7 @@ impl Input { /// Runs `helper_command` (a bootstrap-installed shell function) as if the user had typed and /// submitted it, passing a freshly generated handoff token as its argument and snapshotting /// the current buffer contents so they're restored once the command's block completes -- - /// unless [`Self::set_external_ctrl_r_selection`] supplies a selected command in the + /// unless [`Self::set_external_shell_widget_selection`] supplies a selected command in the /// meantime. Returns `true` if the command was started. /// /// The command is prefixed with a leading space, honoring the "ignorespace" convention that @@ -7756,7 +7756,7 @@ impl Input { } /// Applies `selection` only if `session_id` and `token` match the in-flight handoff. - pub fn set_external_ctrl_r_selection( + pub fn set_external_shell_widget_selection( &mut self, session_id: SessionId, token: &str, @@ -7831,21 +7831,6 @@ impl Input { started } - /// Applies `selection` only if `session_id` and `token` match the in-flight handoff. - pub fn set_external_ctrl_t_selection( - &mut self, - session_id: SessionId, - token: &str, - selection: &str, - ) { - PendingShellWidgetHandoff::maybe_apply_selection( - &mut self.pending_shell_widget_handoff, - session_id, - token, - selection, - ); - } - fn try_execute_command_with_options( &mut self, command: &str, diff --git a/app/src/terminal/model/terminal_model.rs b/app/src/terminal/model/terminal_model.rs index e1617ccb490..6223d306303 100644 --- a/app/src/terminal/model/terminal_model.rs +++ b/app/src/terminal/model/terminal_model.rs @@ -66,9 +66,9 @@ pub use crate::terminal::history::HistoryEntry; use crate::terminal::model::ansi; use crate::terminal::model::ansi::{ ClearValue, CommandFinishedValue, CompletionMetadata, ExitShellValue, - ExternalCtrlRSelectionValue, ExternalCtrlTSelectionValue, Handler, InitShellValue, - InitSubshellValue, PreInteractiveSSHSessionValue, PrecmdValue, PreexecValue, PromptMetadata, - SSHValue, SourcedRcFileForWarpValue, + ExternalShellWidgetSelectionValue, Handler, InitShellValue, InitSubshellValue, + PreInteractiveSSHSessionValue, PrecmdValue, PreexecValue, PromptMetadata, SSHValue, + SourcedRcFileForWarpValue, }; use crate::terminal::model::bootstrap::BootstrapStage; use crate::terminal::model::completions::{ShellCompletion, ShellCompletionUpdate}; @@ -3202,14 +3202,9 @@ impl ansi::Handler for TerminalModel { delegate!(self.input_buffer(data)); } - fn external_ctrl_r_selection(&mut self, data: ExternalCtrlRSelectionValue) { + fn external_shell_widget_selection(&mut self, data: ExternalShellWidgetSelectionValue) { self.event_proxy - .send_app_event(Event::ExternalCtrlRSelection(data)); - } - - fn external_ctrl_t_selection(&mut self, data: ExternalCtrlTSelectionValue) { - self.event_proxy - .send_app_event(Event::ExternalCtrlTSelection(data)); + .send_app_event(Event::ExternalShellWidgetSelection(data)); } fn init_subshell(&mut self, data: InitSubshellValue) { diff --git a/app/src/terminal/model_events.rs b/app/src/terminal/model_events.rs index b022b3a666e..4c73d536fe8 100644 --- a/app/src/terminal/model_events.rs +++ b/app/src/terminal/model_events.rs @@ -5,9 +5,7 @@ use warpui::{Entity, ModelContext, ModelHandle, SingletonEntity}; use super::event::{BootstrappedEvent, SshLoginStatus}; use super::model::ansi; -use super::model::ansi::{ - ExternalCtrlRSelectionValue, ExternalCtrlTSelectionValue, FinishUpdateValue, -}; +use super::model::ansi::{ExternalShellWidgetSelectionValue, FinishUpdateValue}; use super::model::block::BlockId; use super::model::completions::ShellCompletion; use super::model::lifecycle::LifecycleTelemetryEvent; @@ -265,8 +263,9 @@ impl ModelEventDispatcher { Event::HonorPS1OutOfSync => ModelEvent::HonorPS1OutOfSync, Event::Typeahead => ModelEvent::Typeahead, Event::FinishUpdate(data) => ModelEvent::FinishUpdate(data), - Event::ExternalCtrlRSelection(data) => ModelEvent::ExternalCtrlRSelection(data), - Event::ExternalCtrlTSelection(data) => ModelEvent::ExternalCtrlTSelection(data), + Event::ExternalShellWidgetSelection(data) => { + ModelEvent::ExternalShellWidgetSelection(data) + } Event::TextSelectionChanged => ModelEvent::SelectedTextChanged, Event::ShellSpawned(shell_type) => ModelEvent::ShellSpawned(shell_type), Event::ImageReceived { @@ -451,12 +450,7 @@ pub enum ModelEvent { /// inaccessible to views/models. Handler(AnsiHandlerEvent), FinishUpdate(FinishUpdateValue), - /// Emitted when the shell reports the command selected in its external ctrl-r history - /// widget (e.g. fzf or atuin). - ExternalCtrlRSelection(ExternalCtrlRSelectionValue), - /// Emitted when the shell reports the path(s) selected in its external ctrl-t file-search - /// widget (e.g. fzf). - ExternalCtrlTSelection(ExternalCtrlTSelectionValue), + ExternalShellWidgetSelection(ExternalShellWidgetSelectionValue), SelectedTextChanged, ShellSpawned(ShellType), CompletionsFinished(Vec, Option), diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index 20d8b6ea1e0..7889dfcfdf9 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -9227,7 +9227,7 @@ impl TerminalView { /// The command the user selects (or the buffer they had before ctrl-r, if they cancel) is /// restored into the input editor once the helper's block completes; see /// [`Input::trigger_external_ctrl_r_history_search`] and - /// [`Input::set_external_ctrl_r_selection`]. + /// [`Input::set_external_shell_widget_selection`]. /// /// Returns `true` if the handoff was triggered, in which case the caller should not open /// Warp's command search. @@ -12994,21 +12994,16 @@ impl TerminalView { log::warn!("Got a FinishUpdate event with non-matching update id!"); } } - ModelEvent::ExternalCtrlRSelection(data) => { + ModelEvent::ExternalShellWidgetSelection(data) => { if FeatureFlag::ShellWidgetHandoff.is_enabled() && let Some(session_id) = data.session_id.map(SessionId::from) { self.input.update(ctx, |input, _ctx| { - input.set_external_ctrl_r_selection(session_id, &data.token, &data.buffer); - }); - } - } - ModelEvent::ExternalCtrlTSelection(data) => { - if FeatureFlag::ShellWidgetHandoff.is_enabled() - && let Some(session_id) = data.session_id.map(SessionId::from) - { - self.input.update(ctx, |input, _ctx| { - input.set_external_ctrl_t_selection(session_id, &data.token, &data.buffer); + input.set_external_shell_widget_selection( + session_id, + &data.token, + &data.buffer, + ); }); } } diff --git a/crates/warp_terminal/src/model/ansi/dcs_hooks.rs b/crates/warp_terminal/src/model/ansi/dcs_hooks.rs index 511c3a4614a..abb141b156d 100644 --- a/crates/warp_terminal/src/model/ansi/dcs_hooks.rs +++ b/crates/warp_terminal/src/model/ansi/dcs_hooks.rs @@ -69,16 +69,8 @@ pub(super) enum DProtoHook { InputBuffer { value: InputBufferValue, }, - /// Reports the command selected in the shell's external ctrl-r history widget (e.g. fzf or - /// atuin), so it can be inserted into the input editor. See [`ExternalCtrlRSelectionValue`]. - ExternalCtrlRSelection { - value: ExternalCtrlRSelectionValue, - }, - /// Reports the path(s) selected in the shell's external ctrl-t file-search widget (e.g. - /// fzf), so they can be inserted into the input editor at the cursor position ctrl-t was - /// pressed at. See [`ExternalCtrlTSelectionValue`]. - ExternalCtrlTSelection { - value: ExternalCtrlTSelectionValue, + ExternalShellWidgetSelection { + value: ExternalShellWidgetSelectionValue, }, Clear { value: ClearValue, @@ -107,8 +99,7 @@ const DPROTO_HOOK_VARIANTS: &[&str] = &[ "SSH", "InitShell", "InputBuffer", - "ExternalCtrlRSelection", - "ExternalCtrlTSelection", + "ExternalShellWidgetSelection", "Clear", "InitSubshell", "SourcedRcFileForWarp", @@ -162,10 +153,7 @@ impl<'de> Deserialize<'de> for DProtoHook { "InputBuffer" => DProtoHook::InputBuffer { value: parse_hook_value::<_, D::Error>(raw.value)?, }, - "ExternalCtrlRSelection" => DProtoHook::ExternalCtrlRSelection { - value: parse_hook_value::<_, D::Error>(raw.value)?, - }, - "ExternalCtrlTSelection" => DProtoHook::ExternalCtrlTSelection { + "ExternalShellWidgetSelection" => DProtoHook::ExternalShellWidgetSelection { value: parse_hook_value::<_, D::Error>(raw.value)?, }, "Clear" => DProtoHook::Clear { @@ -204,8 +192,7 @@ impl DProtoHook { DProtoHook::SSH { .. } => "SSH", DProtoHook::InitShell { .. } => "InitShell", DProtoHook::InputBuffer { .. } => "InputBuffer", - DProtoHook::ExternalCtrlRSelection { .. } => "ExternalCtrlRSelection", - DProtoHook::ExternalCtrlTSelection { .. } => "ExternalCtrlTSelection", + DProtoHook::ExternalShellWidgetSelection { .. } => "ExternalShellWidgetSelection", DProtoHook::Clear { .. } => "Clear", DProtoHook::InitSubshell { .. } => "InitSubshell", DProtoHook::SourcedRcFileForWarp { .. } => "SourcedRcFileForWarp", @@ -225,8 +212,9 @@ impl DProtoHook { DProtoHook::CommandFinished { value } => value.session_id.map(SessionId::from), DProtoHook::Bootstrapped { value } => value.session_id.map(SessionId::from), DProtoHook::InputBuffer { value } => value.session_id.map(SessionId::from), - DProtoHook::ExternalCtrlRSelection { value } => value.session_id.map(SessionId::from), - DProtoHook::ExternalCtrlTSelection { value } => value.session_id.map(SessionId::from), + DProtoHook::ExternalShellWidgetSelection { value } => { + value.session_id.map(SessionId::from) + } DProtoHook::Clear { value } => value.session_id.map(SessionId::from), DProtoHook::FinishUpdate { value } => value.session_id.map(SessionId::from), DProtoHook::PreInteractiveSSHSession { value } => value.session_id.map(SessionId::from), @@ -248,8 +236,7 @@ impl DProtoHook { | DProtoHook::SSH { .. } | DProtoHook::InitShell { .. } | DProtoHook::InputBuffer { .. } - | DProtoHook::ExternalCtrlRSelection { .. } - | DProtoHook::ExternalCtrlTSelection { .. } + | DProtoHook::ExternalShellWidgetSelection { .. } | DProtoHook::Clear { .. } | DProtoHook::InitSubshell { .. } | DProtoHook::FinishUpdate { .. } @@ -1010,46 +997,11 @@ pub struct InputBufferValue { pub session_id: HookSessionId, } -/// Received from the pty after the shell's external ctrl-r history widget (e.g. fzf or atuin, -/// detected via the `external_ctrl_r_history` [`BootstrappedValue::shell_plugins`] tag) 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 the client sent as an argument to the shell helper that -/// emits this hook, so the client can verify this is the reply to a handoff it's actually -/// waiting on rather than an unsolicited or stale write to the pty. -#[derive(Default, Clone, PartialEq, Eq, Deserialize, Serialize)] -pub struct ExternalCtrlRSelectionValue { - pub buffer: String, - #[serde(default)] - pub token: String, - #[serde(default)] - pub session_id: HookSessionId, -} - -impl std::fmt::Debug for ExternalCtrlRSelectionValue { - /// 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("ExternalCtrlRSelectionValue") - .field("buffer", &"") - .field("token", &self.token) - .field("session_id", &self.session_id) - .finish() - } -} - -/// Received from the pty after the shell's external ctrl-t file-search widget (e.g. fzf, -/// detected via the `external_ctrl_t_file` [`BootstrappedValue::shell_plugins`] tag) finishes, -/// reporting the path(s) the user selected. Empty when the user cancelled without selecting -/// anything. Warp inserts the selection into the input editor, at the cursor position ctrl-t -/// was pressed at, without executing it. -/// -/// `token` echoes back the handoff token the client sent as an argument to the shell helper that -/// emits this hook, so the client can verify this is the reply to a handoff it's actually -/// waiting on rather than an unsolicited or stale write to the pty. +/// Selection reported by an external shell widget (ctrl-r history or ctrl-t file search). +/// Empty `buffer` means the user cancelled. `token` echoes the handoff token so a stale or +/// unsolicited write can be ignored. #[derive(Default, Clone, PartialEq, Eq, Deserialize, Serialize)] -pub struct ExternalCtrlTSelectionValue { +pub struct ExternalShellWidgetSelectionValue { pub buffer: String, #[serde(default)] pub token: String, @@ -1057,11 +1009,9 @@ pub struct ExternalCtrlTSelectionValue { pub session_id: HookSessionId, } -impl std::fmt::Debug for ExternalCtrlTSelectionValue { - /// Redacts `buffer`, since it carries file path(s) that may reveal sensitive information - /// about the user's filesystem. +impl std::fmt::Debug for ExternalShellWidgetSelectionValue { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ExternalCtrlTSelectionValue") + f.debug_struct("ExternalShellWidgetSelectionValue") .field("buffer", &"") .field("token", &self.token) .field("session_id", &self.session_id) 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 f38e3e3fa07..d6351c3ea3d 100644 --- a/crates/warp_terminal/src/model/ansi/dcs_hooks_tests.rs +++ b/crates/warp_terminal/src/model/ansi/dcs_hooks_tests.rs @@ -171,13 +171,9 @@ fn every_hook_tag_dispatches_to_the_matching_variant() { ), ("InputBuffer", serde_json::json!({"buffer": "echo hi"})), ( - "ExternalCtrlRSelection", + "ExternalShellWidgetSelection", serde_json::json!({"buffer": "echo hi"}), ), - ( - "ExternalCtrlTSelection", - serde_json::json!({"buffer": "/home/me/file.txt"}), - ), ("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 d57f5010ac6..231af3dd903 100644 --- a/crates/warp_terminal/src/model/ansi/handler.rs +++ b/crates/warp_terminal/src/model/ansi/handler.rs @@ -306,13 +306,7 @@ 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 - /// external ctrl-r history widget (e.g. fzf or atuin). - fn external_ctrl_r_selection(&mut self, _data: ExternalCtrlRSelectionValue) {} - - /// Callback for the terminal when the shell reports the path(s) selected in its - /// external ctrl-t file-search widget (e.g. fzf). - fn external_ctrl_t_selection(&mut self, _data: ExternalCtrlTSelectionValue) {} + fn external_shell_widget_selection(&mut self, _data: ExternalShellWidgetSelectionValue) {} /// Callback emitted during the initialization process for subshells with where the shell type /// is initiall not known. diff --git a/crates/warp_terminal/src/model/ansi/mod.rs b/crates/warp_terminal/src/model/ansi/mod.rs index b5795e50e6b..4f279bf2969 100644 --- a/crates/warp_terminal/src/model/ansi/mod.rs +++ b/crates/warp_terminal/src/model/ansi/mod.rs @@ -604,11 +604,8 @@ 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::ExternalCtrlRSelection { value }) => { - self.handler.external_ctrl_r_selection(value) - } - Ok(DProtoHook::ExternalCtrlTSelection { value }) => { - self.handler.external_ctrl_t_selection(value) + Ok(DProtoHook::ExternalShellWidgetSelection { value }) => { + self.handler.external_shell_widget_selection(value) } Ok(DProtoHook::Clear { value }) => self.handler.clear(value), Ok(DProtoHook::InitSubshell { value }) => self.handler.init_subshell(value), diff --git a/crates/warp_terminal/src/model/ansi/mod_tests.rs b/crates/warp_terminal/src/model/ansi/mod_tests.rs index 969ec9e5c41..fc3fea718c3 100644 --- a/crates/warp_terminal/src/model/ansi/mod_tests.rs +++ b/crates/warp_terminal/src/model/ansi/mod_tests.rs @@ -237,14 +237,9 @@ impl Handler for MockHandler { .push(DProtoHook::InputBuffer { value: data }) } - fn external_ctrl_r_selection(&mut self, data: super::ExternalCtrlRSelectionValue) { + fn external_shell_widget_selection(&mut self, data: super::ExternalShellWidgetSelectionValue) { self.d_proto_hooks - .push(DProtoHook::ExternalCtrlRSelection { value: data }) - } - - fn external_ctrl_t_selection(&mut self, data: super::ExternalCtrlTSelectionValue) { - self.d_proto_hooks - .push(DProtoHook::ExternalCtrlTSelection { value: data }) + .push(DProtoHook::ExternalShellWidgetSelection { value: data }) } fn init_subshell(&mut self, data: InitSubshellValue) { @@ -985,16 +980,11 @@ fn parse_dcs_input_buffer() { } } -/// End-to-end regression for the ctrl-r/ctrl-t handoff dispatch: this is the exact path by which -/// a shell-reported selection reaches the terminal handler, and it was hand-reapplied at this -/// module's new location during a merge with master's crate-extraction refactor (which relocated -/// this file from `app/src/terminal/model/ansi/mod.rs`) -- so it needs its own coverage here, -/// not just a mock-level unit test of the handler trait in isolation. #[test] -fn parse_dcs_external_ctrl_r_selection() { +fn parse_dcs_external_shell_widget_selection() { let bytes = hex_encoded_dcs_string( r#"{ - "hook": "ExternalCtrlRSelection", + "hook": "ExternalShellWidgetSelection", "value": { "buffer": "echo selected", "token": "tok-1", @@ -1007,9 +997,9 @@ fn parse_dcs_external_ctrl_r_selection() { assert_eq!(handler.d_proto_hooks.len(), 1); match handler.d_proto_hooks.first().unwrap() { - DProtoHook::ExternalCtrlRSelection { value } => assert_eq!( + DProtoHook::ExternalShellWidgetSelection { value } => assert_eq!( *value, - ExternalCtrlRSelectionValue { + ExternalShellWidgetSelectionValue { buffer: "echo selected".to_string(), token: "tok-1".to_string(), session_id: Some(167303092612201), @@ -1019,14 +1009,11 @@ fn parse_dcs_external_ctrl_r_selection() { } } -/// The session_id gate is what stops an unrelated pty write (or a stale reply after the client -/// gave up on the handoff) from being treated as a real selection -- this must still apply at the -/// dispatch's new location, not just for hooks the merge didn't touch. #[test] -fn parse_dcs_external_ctrl_r_selection_with_unregistered_session_is_rejected() { +fn parse_dcs_external_shell_widget_selection_with_unregistered_session_is_rejected() { let bytes = hex_encoded_dcs_string( r#"{ - "hook": "ExternalCtrlRSelection", + "hook": "ExternalShellWidgetSelection", "value": { "buffer": "echo selected", "token": "tok-1", @@ -1043,36 +1030,6 @@ fn parse_dcs_external_ctrl_r_selection_with_unregistered_session_is_rejected() { ); } -/// See `parse_dcs_external_ctrl_r_selection` above for why this end-to-end coverage matters. -#[test] -fn parse_dcs_external_ctrl_t_selection() { - let bytes = hex_encoded_dcs_string( - r#"{ - "hook": "ExternalCtrlTSelection", - "value": { - "buffer": "src/main.rs", - "token": "tok-2", - "session_id": 167303092612201 - } - }"#, - ); - - let (_, handler) = parse_bytes(&bytes); - - assert_eq!(handler.d_proto_hooks.len(), 1); - match handler.d_proto_hooks.first().unwrap() { - DProtoHook::ExternalCtrlTSelection { value } => assert_eq!( - *value, - ExternalCtrlTSelectionValue { - buffer: "src/main.rs".to_string(), - token: "tok-2".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" }}"#; From fa8fffd856023cb0e95eb1b8fa8af65e3ceea360 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:21:29 +0000 Subject: [PATCH 62/70] Remove redundant shell-widget handoff token. One pending handoff plus session_id and block_id already identify the in-flight widget. Drop the UUID token from pending state, the protocol payload, helper argv, and Bash/Fish/Zsh JSON. --- app/assets/bundled/bootstrap/bash_body.sh | 8 ++--- app/assets/bundled/bootstrap/fish.sh | 12 +++---- app/assets/bundled/bootstrap/zsh_body.sh | 8 ++--- app/src/terminal/input.rs | 36 +++++-------------- app/src/terminal/input_tests.rs | 25 ++----------- app/src/terminal/view.rs | 6 +--- .../warp_terminal/src/model/ansi/dcs_hooks.rs | 6 +--- .../warp_terminal/src/model/ansi/mod_tests.rs | 3 -- 8 files changed, 22 insertions(+), 82 deletions(-) diff --git a/app/assets/bundled/bootstrap/bash_body.sh b/app/assets/bundled/bootstrap/bash_body.sh index 627799260b1..00017808b7d 100644 --- a/app/assets/bundled/bootstrap/bash_body.sh +++ b/app/assets/bundled/bootstrap/bash_body.sh @@ -972,7 +972,6 @@ if [ -z "$WARP_BOOTSTRAPPED" ]; then # Runs the shell's ctrl-r history widget as a foreground command. warp_run_external_ctrl_r_widget () { - local warp_ctrl_r_token="$1" local result="" case "$_WARP_EXTERNAL_CTRL_R_WIDGET" in __fzf_history__) @@ -991,13 +990,11 @@ if [ -z "$WARP_BOOTSTRAPPED" ]; then ;; esac local warp_escaped_selection="$(warp_escape_json "$result")" - local warp_escaped_token="$(warp_escape_json "$warp_ctrl_r_token")" - warp_send_json_message "{ \"hook\": \"ExternalShellWidgetSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"token\": \"$warp_escaped_token\", \"session_id\": $WARP_SESSION_ID } }" + warp_send_json_message "{ \"hook\": \"ExternalShellWidgetSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"session_id\": $WARP_SESSION_ID } }" } # Runs the shell's own ctrl-t file-search widget as a foreground command. warp_run_external_ctrl_t_widget () { - local warp_ctrl_t_token="$1" local result="" case "$_WARP_EXTERNAL_CTRL_T_WIDGET" in fzf-file-widget) @@ -1005,8 +1002,7 @@ if [ -z "$WARP_BOOTSTRAPPED" ]; then ;; esac local warp_escaped_selection="$(warp_escape_json "$result")" - local warp_escaped_token="$(warp_escape_json "$warp_ctrl_t_token")" - warp_send_json_message "{ \"hook\": \"ExternalShellWidgetSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"token\": \"$warp_escaped_token\", \"session_id\": $WARP_SESSION_ID } }" + warp_send_json_message "{ \"hook\": \"ExternalShellWidgetSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"session_id\": $WARP_SESSION_ID } }" } # Check whether the prompt-related variables have OSC prompt marker sequences, diff --git a/app/assets/bundled/bootstrap/fish.sh b/app/assets/bundled/bootstrap/fish.sh index 87f6062a529..853b83e0381 100644 --- a/app/assets/bundled/bootstrap/fish.sh +++ b/app/assets/bundled/bootstrap/fish.sh @@ -584,7 +584,6 @@ end # Runs the shell's own ctrl-r history tool as a foreground command. function warp_run_external_ctrl_r_widget - set -l warp_ctrl_r_token "$argv[1]" set -l result "" switch "$_WARP_EXTERNAL_CTRL_R_WIDGET" case 'fzf-history-widget' @@ -601,8 +600,7 @@ function warp_run_external_ctrl_r_widget set result (string replace "__atuin_accept__:" "" -- "$output" | string collect) end set -l warp_escaped_selection (warp_escape_json "$result") - set -l warp_escaped_token (warp_escape_json "$warp_ctrl_r_token") - warp_send_json_message "{ \"hook\": \"ExternalShellWidgetSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"token\": \"$warp_escaped_token\", \"session_id\": $WARP_SESSION_ID } }" + warp_send_json_message "{ \"hook\": \"ExternalShellWidgetSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"session_id\": $WARP_SESSION_ID } }" end function warp_ctrl_t_widget_result @@ -611,11 +609,10 @@ end # Runs fzf directly against a find-style command as a foreground command. function warp_run_external_ctrl_t_widget - set -l warp_ctrl_t_token "$argv[1]" set -l result "" switch "$_WARP_EXTERNAL_CTRL_T_WIDGET" case 'fzf-file-widget' - set -l warp_ctrl_t_parts (string split -m 1 -- ':' "$argv[2]") + set -l warp_ctrl_t_parts (string split -m 1 -- ':' "$argv[1]") set -l char_cursor $warp_ctrl_t_parts[1] set -l original_line (warp_hex_decode_string $warp_ctrl_t_parts[2] | string collect --no-trim-newlines --allow-empty) commandline -r -- $original_line @@ -626,8 +623,7 @@ function warp_run_external_ctrl_t_widget commandline -r '' end set -l warp_escaped_selection (warp_escape_json "$result") - set -l warp_escaped_token (warp_escape_json "$warp_ctrl_t_token") - warp_send_json_message "{ \"hook\": \"ExternalShellWidgetSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"token\": \"$warp_escaped_token\", \"session_id\": $WARP_SESSION_ID } }" + warp_send_json_message "{ \"hook\": \"ExternalShellWidgetSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"session_id\": $WARP_SESSION_ID } }" end # Exclude the ctrl-r/ctrl-t external handoff helpers (see warp_run_external_ctrl_r_widget/ @@ -658,7 +654,7 @@ else if not functions -q warp_original_fish_should_add_to_history end end function fish_should_add_to_history - string match --quiet -- '*warp_run_external_ctrl_r_widget *' $argv[1]; and return 1 + string match --quiet -- '*warp_run_external_ctrl_r_widget*' $argv[1]; and return 1 string match --quiet -- '*warp_run_external_ctrl_t_widget*' $argv[1]; and return 1 warp_original_fish_should_add_to_history $argv end diff --git a/app/assets/bundled/bootstrap/zsh_body.sh b/app/assets/bundled/bootstrap/zsh_body.sh index 4dcbf856576..42bc2bbcb22 100644 --- a/app/assets/bundled/bootstrap/zsh_body.sh +++ b/app/assets/bundled/bootstrap/zsh_body.sh @@ -712,7 +712,6 @@ if [[ -z $WARP_BOOTSTRAPPED ]]; then # Runs the shell's own ctrl-r history widget as a foreground command. function warp_run_external_ctrl_r_widget () { - local warp_ctrl_r_token="$1" local result="" case "$_WARP_EXTERNAL_CTRL_R_WIDGET" in fzf-history-widget) @@ -734,13 +733,11 @@ if [[ -z $WARP_BOOTSTRAPPED ]]; then ;; esac local warp_escaped_selection="$(warp_escape_json "$result")" - local warp_escaped_token="$(warp_escape_json "$warp_ctrl_r_token")" - warp_send_json_message "{ \"hook\": \"ExternalShellWidgetSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"token\": \"$warp_escaped_token\", \"session_id\": $WARP_SESSION_ID } }" + warp_send_json_message "{ \"hook\": \"ExternalShellWidgetSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"session_id\": $WARP_SESSION_ID } }" } # Runs the shell's own ctrl-t file-search widget as a foreground command. function warp_run_external_ctrl_t_widget () { - local warp_ctrl_t_token="$1" local result="" case "$_WARP_EXTERNAL_CTRL_T_WIDGET" in fzf-file-widget) @@ -752,8 +749,7 @@ if [[ -z $WARP_BOOTSTRAPPED ]]; then ;; esac local warp_escaped_selection="$(warp_escape_json "$result")" - local warp_escaped_token="$(warp_escape_json "$warp_ctrl_t_token")" - warp_send_json_message "{ \"hook\": \"ExternalShellWidgetSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"token\": \"$warp_escaped_token\", \"session_id\": $WARP_SESSION_ID } }" + warp_send_json_message "{ \"hook\": \"ExternalShellWidgetSelection\", \"value\": { \"buffer\": \"$warp_escaped_selection\", \"session_id\": $WARP_SESSION_ID } }" } function clear() { diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index 7b76e32e58c..8b33efdad9a 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -56,7 +56,6 @@ use serde_json::json; use session_sharing_protocol::common::{AgentAttachment, ParticipantId, ServerConversationToken}; use settings::{Setting as _, ToggleableSetting}; use string_offset::{ByteOffset, CharOffset}; -use uuid::Uuid; use vec1::Vec1; use vim::vim::{VimHandler, VimMode}; use warp_cli::agent::Harness; @@ -1899,7 +1898,6 @@ enum ShellWidgetHandoffKind { struct PendingShellWidgetHandoff { session_id: SessionId, - token: String, original_buffer: String, selection: Option, block_id: BlockId, @@ -1907,16 +1905,11 @@ struct PendingShellWidgetHandoff { } impl PendingShellWidgetHandoff { - fn maybe_apply_selection( - pending: &mut Option, - session_id: SessionId, - token: &str, - selection: &str, - ) { + fn maybe_apply_selection(pending: &mut Option, session_id: SessionId, selection: &str) { let Some(handoff) = pending else { return; }; - if handoff.session_id != session_id || handoff.token != token { + if handoff.session_id != session_id { return; } if !selection.is_empty() { @@ -7710,10 +7703,9 @@ impl Input { } /// Runs `helper_command` (a bootstrap-installed shell function) as if the user had typed and - /// submitted it, passing a freshly generated handoff token as its argument and snapshotting - /// the current buffer contents so they're restored once the command's block completes -- - /// unless [`Self::set_external_shell_widget_selection`] supplies a selected command in the - /// meantime. Returns `true` if the command was started. + /// submitted it, snapshotting the current buffer contents so they're restored once the + /// command's block completes -- unless [`Self::set_external_shell_widget_selection`] supplies + /// a selected command in the meantime. Returns `true` if the command was started. /// /// The command is prefixed with a leading space, honoring the "ignorespace" convention that /// bash/zsh support and atuin explicitly implements itself (independent of the shell's own @@ -7733,8 +7725,7 @@ impl Input { }; let current_input = self.buffer_text(ctx); let block_id = self.model.lock().block_list().active_block_id().clone(); - let token = Uuid::new_v4().to_string(); - let command = format!(" {helper_command} {token}"); + let command = format!(" {helper_command}"); // Not a command the user ran: Warp's history is independent of the shell histfile. let started = self.try_execute_command_from_source( &command, @@ -7745,7 +7736,6 @@ impl Input { if started { self.pending_shell_widget_handoff = Some(PendingShellWidgetHandoff { session_id, - token, original_buffer: current_input, selection: None, block_id, @@ -7755,17 +7745,11 @@ impl Input { started } - /// Applies `selection` only if `session_id` and `token` match the in-flight handoff. - pub fn set_external_shell_widget_selection( - &mut self, - session_id: SessionId, - token: &str, - selection: &str, - ) { + /// Applies `selection` only if `session_id` matches the in-flight handoff. + pub fn set_external_shell_widget_selection(&mut self, session_id: SessionId, selection: &str) { PendingShellWidgetHandoff::maybe_apply_selection( &mut self.pending_shell_widget_handoff, session_id, - token, selection, ); } @@ -7800,8 +7784,7 @@ impl Input { .as_ref(ctx) .end_byte_index_of_last_selection(ctx); let block_id = self.model.lock().block_list().active_block_id().clone(); - let token = Uuid::new_v4().to_string(); - let mut command = format!(" {helper_command} {token}"); + let mut command = format!(" {helper_command}"); if apply_mode == CtrlTApplyMode::Replace { command.push_str(&format!( " {}", @@ -7818,7 +7801,6 @@ impl Input { if started { self.pending_shell_widget_handoff = Some(PendingShellWidgetHandoff { session_id, - token, original_buffer, selection: None, block_id, diff --git a/app/src/terminal/input_tests.rs b/app/src/terminal/input_tests.rs index c88a840d613..e3a01644894 100644 --- a/app/src/terminal/input_tests.rs +++ b/app/src/terminal/input_tests.rs @@ -119,7 +119,6 @@ use crate::{ fn pending_ctrl_r_handoff() -> PendingShellWidgetHandoff { PendingShellWidgetHandoff { session_id: SessionId::from(1), - token: "tok-1".to_string(), original_buffer: "draft".to_string(), selection: None, block_id: BlockId::new(), @@ -130,7 +129,6 @@ fn pending_ctrl_r_handoff() -> PendingShellWidgetHandoff { fn pending_ctrl_t_handoff() -> PendingShellWidgetHandoff { PendingShellWidgetHandoff { session_id: SessionId::from(1), - token: "tok-1".to_string(), original_buffer: "echo ".to_string(), selection: None, block_id: BlockId::new(), @@ -147,7 +145,6 @@ fn matching_shell_widget_handoff_selection_is_applied() { PendingShellWidgetHandoff::maybe_apply_selection( &mut pending, SessionId::from(1), - "tok-1", "echo selected", ); assert_eq!(pending.as_ref().unwrap().restore_text(), "echo selected"); @@ -156,7 +153,6 @@ fn matching_shell_widget_handoff_selection_is_applied() { PendingShellWidgetHandoff::maybe_apply_selection( &mut pending, SessionId::from(1), - "tok-1", "selected/file.txt", ); assert_eq!( @@ -171,25 +167,14 @@ fn unsolicited_or_stale_shell_widget_handoff_selection_is_ignored() { PendingShellWidgetHandoff::maybe_apply_selection( &mut pending, SessionId::from(1), - "tok-1", "echo selected", ); assert!(pending.is_none()); - let mut pending = Some(pending_ctrl_r_handoff()); - PendingShellWidgetHandoff::maybe_apply_selection( - &mut pending, - SessionId::from(1), - "some-other-token", - "echo selected", - ); - assert_eq!(pending.as_ref().unwrap().restore_text(), "draft"); - let mut pending = Some(pending_ctrl_r_handoff()); PendingShellWidgetHandoff::maybe_apply_selection( &mut pending, SessionId::from(2), - "tok-1", "echo selected", ); assert_eq!(pending.unwrap().restore_text(), "draft"); @@ -198,11 +183,11 @@ fn unsolicited_or_stale_shell_widget_handoff_selection_is_ignored() { #[test] fn empty_shell_widget_handoff_selection_keeps_original_buffer() { let mut pending = Some(pending_ctrl_r_handoff()); - PendingShellWidgetHandoff::maybe_apply_selection(&mut pending, SessionId::from(1), "tok-1", ""); + PendingShellWidgetHandoff::maybe_apply_selection(&mut pending, SessionId::from(1), ""); assert_eq!(pending.unwrap().restore_text(), "draft"); let mut pending = Some(pending_ctrl_t_handoff()); - PendingShellWidgetHandoff::maybe_apply_selection(&mut pending, SessionId::from(1), "tok-1", ""); + PendingShellWidgetHandoff::maybe_apply_selection(&mut pending, SessionId::from(1), ""); assert_eq!(pending.unwrap().selection, None); } @@ -1963,7 +1948,6 @@ async fn complete_ctrl_t_handoff( input.update(app, |input, ctx| { input.pending_shell_widget_handoff = Some(PendingShellWidgetHandoff { session_id: SessionId::from(1), - token: "tok-1".to_string(), original_buffer: original_buffer.to_string(), selection: insertion.map(str::to_string), block_id: block_id.clone(), @@ -2007,7 +1991,6 @@ async fn complete_ctrl_r_handoff( input.update(app, |input, ctx| { input.pending_shell_widget_handoff = Some(PendingShellWidgetHandoff { session_id: SessionId::from(1), - token: "tok-1".to_string(), original_buffer: original_buffer.to_string(), selection: selection.map(str::to_string), block_id: block_id.clone(), @@ -2199,9 +2182,7 @@ fn ctrl_t_handoff_cancel_restores_cursor_captured_by_a_real_trigger() { input.update(&mut app, |input, ctx| { input.handle_block_completed_event( BlockCompletedEvent { - block_type: user_block_completed_for_test( - " warp_run_external_ctrl_t_widget tok-1", - ), + block_type: user_block_completed_for_test(" warp_run_external_ctrl_t_widget"), num_secrets_obfuscated: 0, block_index: BlockIndex::zero(), block_id, diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index 7889dfcfdf9..ba360ea0af2 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -12999,11 +12999,7 @@ impl TerminalView { && let Some(session_id) = data.session_id.map(SessionId::from) { self.input.update(ctx, |input, _ctx| { - input.set_external_shell_widget_selection( - session_id, - &data.token, - &data.buffer, - ); + input.set_external_shell_widget_selection(session_id, &data.buffer); }); } } diff --git a/crates/warp_terminal/src/model/ansi/dcs_hooks.rs b/crates/warp_terminal/src/model/ansi/dcs_hooks.rs index abb141b156d..cd0e2b102d4 100644 --- a/crates/warp_terminal/src/model/ansi/dcs_hooks.rs +++ b/crates/warp_terminal/src/model/ansi/dcs_hooks.rs @@ -998,14 +998,11 @@ pub struct InputBufferValue { } /// Selection reported by an external shell widget (ctrl-r history or ctrl-t file search). -/// Empty `buffer` means the user cancelled. `token` echoes the handoff token so a stale or -/// unsolicited write can be ignored. +/// Empty `buffer` means the user cancelled. #[derive(Default, Clone, PartialEq, Eq, Deserialize, Serialize)] pub struct ExternalShellWidgetSelectionValue { pub buffer: String, #[serde(default)] - pub token: String, - #[serde(default)] pub session_id: HookSessionId, } @@ -1013,7 +1010,6 @@ impl std::fmt::Debug for ExternalShellWidgetSelectionValue { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ExternalShellWidgetSelectionValue") .field("buffer", &"") - .field("token", &self.token) .field("session_id", &self.session_id) .finish() } diff --git a/crates/warp_terminal/src/model/ansi/mod_tests.rs b/crates/warp_terminal/src/model/ansi/mod_tests.rs index fc3fea718c3..94d79ce5de5 100644 --- a/crates/warp_terminal/src/model/ansi/mod_tests.rs +++ b/crates/warp_terminal/src/model/ansi/mod_tests.rs @@ -987,7 +987,6 @@ fn parse_dcs_external_shell_widget_selection() { "hook": "ExternalShellWidgetSelection", "value": { "buffer": "echo selected", - "token": "tok-1", "session_id": 167303092612201 } }"#, @@ -1001,7 +1000,6 @@ fn parse_dcs_external_shell_widget_selection() { *value, ExternalShellWidgetSelectionValue { buffer: "echo selected".to_string(), - token: "tok-1".to_string(), session_id: Some(167303092612201), } ), @@ -1016,7 +1014,6 @@ fn parse_dcs_external_shell_widget_selection_with_unregistered_session_is_reject "hook": "ExternalShellWidgetSelection", "value": { "buffer": "echo selected", - "token": "tok-1", "session_id": 999999999999999 } }"#, From 54e0d160fd0ec2e378e032052a289942b89d4790 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:33:40 +0000 Subject: [PATCH 63/70] Make shell-widget selection apply an instance method. --- app/src/terminal/input.rs | 18 +++++------ app/src/terminal/input_tests.rs | 53 ++++++++++----------------------- 2 files changed, 22 insertions(+), 49 deletions(-) diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index 8b33efdad9a..da09cc88101 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -1905,15 +1905,12 @@ struct PendingShellWidgetHandoff { } impl PendingShellWidgetHandoff { - fn maybe_apply_selection(pending: &mut Option, session_id: SessionId, selection: &str) { - let Some(handoff) = pending else { - return; - }; - if handoff.session_id != session_id { + fn maybe_apply_selection(&mut self, session_id: SessionId, selection: &str) { + if self.session_id != session_id { return; } if !selection.is_empty() { - handoff.selection = Some(selection.to_string()); + self.selection = Some(selection.to_string()); } } @@ -7747,11 +7744,10 @@ impl Input { /// Applies `selection` only if `session_id` matches the in-flight handoff. pub fn set_external_shell_widget_selection(&mut self, session_id: SessionId, selection: &str) { - PendingShellWidgetHandoff::maybe_apply_selection( - &mut self.pending_shell_widget_handoff, - session_id, - selection, - ); + let Some(handoff) = self.pending_shell_widget_handoff.as_mut() else { + return; + }; + handoff.maybe_apply_selection(session_id, selection); } /// Runs `helper_command` (a bootstrap-installed shell function) as if the user had typed and diff --git a/app/src/terminal/input_tests.rs b/app/src/terminal/input_tests.rs index e3a01644894..06aedcbdb7b 100644 --- a/app/src/terminal/input_tests.rs +++ b/app/src/terminal/input_tests.rs @@ -141,54 +141,31 @@ fn pending_ctrl_t_handoff() -> PendingShellWidgetHandoff { #[test] fn matching_shell_widget_handoff_selection_is_applied() { - let mut pending = Some(pending_ctrl_r_handoff()); - PendingShellWidgetHandoff::maybe_apply_selection( - &mut pending, - SessionId::from(1), - "echo selected", - ); - assert_eq!(pending.as_ref().unwrap().restore_text(), "echo selected"); + let mut handoff = pending_ctrl_r_handoff(); + handoff.maybe_apply_selection(SessionId::from(1), "echo selected"); + assert_eq!(handoff.restore_text(), "echo selected"); - let mut pending = Some(pending_ctrl_t_handoff()); - PendingShellWidgetHandoff::maybe_apply_selection( - &mut pending, - SessionId::from(1), - "selected/file.txt", - ); - assert_eq!( - pending.unwrap().selection, - Some("selected/file.txt".to_string()) - ); + let mut handoff = pending_ctrl_t_handoff(); + handoff.maybe_apply_selection(SessionId::from(1), "selected/file.txt"); + assert_eq!(handoff.selection, Some("selected/file.txt".to_string())); } #[test] fn unsolicited_or_stale_shell_widget_handoff_selection_is_ignored() { - let mut pending: Option = None; - PendingShellWidgetHandoff::maybe_apply_selection( - &mut pending, - SessionId::from(1), - "echo selected", - ); - assert!(pending.is_none()); - - let mut pending = Some(pending_ctrl_r_handoff()); - PendingShellWidgetHandoff::maybe_apply_selection( - &mut pending, - SessionId::from(2), - "echo selected", - ); - assert_eq!(pending.unwrap().restore_text(), "draft"); + let mut handoff = pending_ctrl_r_handoff(); + handoff.maybe_apply_selection(SessionId::from(2), "echo selected"); + assert_eq!(handoff.restore_text(), "draft"); } #[test] fn empty_shell_widget_handoff_selection_keeps_original_buffer() { - let mut pending = Some(pending_ctrl_r_handoff()); - PendingShellWidgetHandoff::maybe_apply_selection(&mut pending, SessionId::from(1), ""); - assert_eq!(pending.unwrap().restore_text(), "draft"); + let mut handoff = pending_ctrl_r_handoff(); + handoff.maybe_apply_selection(SessionId::from(1), ""); + assert_eq!(handoff.restore_text(), "draft"); - let mut pending = Some(pending_ctrl_t_handoff()); - PendingShellWidgetHandoff::maybe_apply_selection(&mut pending, SessionId::from(1), ""); - assert_eq!(pending.unwrap().selection, None); + let mut handoff = pending_ctrl_t_handoff(); + handoff.maybe_apply_selection(SessionId::from(1), ""); + assert_eq!(handoff.selection, None); } #[test] From 4152cf5f98af1b22f79708ffac6adc770be1366c Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:42:24 +0000 Subject: [PATCH 64/70] Inline ctrl-t draft encoding and drop helper tests. --- app/src/terminal/input.rs | 29 +++++++++----------------- app/src/terminal/input_tests.rs | 36 --------------------------------- 2 files changed, 9 insertions(+), 56 deletions(-) diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index da09cc88101..3a0a6bb1dfa 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -1929,19 +1929,6 @@ impl PendingShellWidgetHandoff { } } -/// Encodes the draft line and cursor the fish ctrl-t helper seeds `fzf-file-widget` with (see -/// [`CtrlTApplyMode::Replace`]) as a single `{char_cursor}:{hex_draft}` argument: hex keeps the -/// draft a single token, and combining it with the cursor avoids an empty hex field (an empty -/// draft) vanishing under the shell's own word-splitting, since the invocation is typed into the -/// terminal as literal text for the shell to parse. `char_cursor` is `cursor_offset` converted to -/// a character offset, since fish's `commandline -C` takes characters while `cursor_offset` is a -/// byte offset. Bash/zsh never call this: their helper searches independently of the draft and -/// reports a plain path for Warp to splice in itself. -fn ctrl_t_draft_arg(original_buffer: &str, cursor_offset: ByteOffset) -> String { - let char_cursor = original_buffer[..cursor_offset.as_usize()].chars().count(); - format!("{char_cursor}:{}", hex::encode(original_buffer)) -} - struct AmbientAgentViewState { view_model: ModelHandle, #[allow(dead_code)] @@ -7762,9 +7749,13 @@ impl Input { /// a leading space. /// /// When `apply_mode` is [`CtrlTApplyMode::Replace`], the command is also given the draft line - /// and cursor for the fish helper to seed its widget with (see [`ctrl_t_draft_arg`]); - /// bash/zsh never need this, since their helper searches independently of the draft and - /// reports a plain path for Warp to splice in itself. + /// and cursor as `{char_cursor}:{hex_draft}` so the fish helper can seed its widget. Hex keeps + /// the draft a single token, and combining it with the cursor avoids an empty hex field (an + /// empty draft) vanishing under the shell's own word-splitting, since the invocation is typed + /// into the terminal as literal text. `char_cursor` is `cursor_offset` converted to a character + /// offset, since fish's `commandline -C` takes characters while `cursor_offset` is a byte + /// offset. Bash/zsh never need this, since their helper searches independently of the draft + /// and reports a plain path for Warp to splice in itself. pub fn trigger_external_ctrl_t_file_search( &mut self, helper_command: &str, @@ -7782,10 +7773,8 @@ impl Input { let block_id = self.model.lock().block_list().active_block_id().clone(); let mut command = format!(" {helper_command}"); if apply_mode == CtrlTApplyMode::Replace { - command.push_str(&format!( - " {}", - ctrl_t_draft_arg(&original_buffer, cursor_offset) - )); + let char_cursor = original_buffer[..cursor_offset.as_usize()].chars().count(); + command.push_str(&format!(" {char_cursor}:{}", hex::encode(&original_buffer))); } // Not a command the user ran: Warp's history is independent of the shell histfile. let started = self.try_execute_command_from_source( diff --git a/app/src/terminal/input_tests.rs b/app/src/terminal/input_tests.rs index 06aedcbdb7b..a7440926e49 100644 --- a/app/src/terminal/input_tests.rs +++ b/app/src/terminal/input_tests.rs @@ -2248,42 +2248,6 @@ fn ctrl_t_apply_mode_forks_between_splice_and_replace_for_the_same_draft() { }); } -/// The fish ctrl-t helper reads the draft's cursor with fish's `commandline -C`, which takes a -/// *character* offset, while `cursor_offset` here is a byte offset -- so a draft containing a -/// multi-byte character before the cursor (here, "caf\u{e9} ", where \u{e9} is 2 bytes but 1 -/// character, for 6 bytes and 5 characters total) must have that byte offset converted, not -/// copied verbatim, or the widget would seed itself at the wrong position for any non-ASCII draft. -#[test] -fn ctrl_t_draft_arg_converts_byte_cursor_to_char_cursor_for_multi_byte_draft() { - let original_buffer = "caf\u{e9} ls"; - // Byte offset right after "café " (the \u{e9} is 2 bytes), which is character offset 5. - let cursor_offset = ByteOffset::from("caf\u{e9} ".len()); - let arg = ctrl_t_draft_arg(original_buffer, cursor_offset); - - let (char_cursor, hex_draft) = arg - .split_once(':') - .expect("the arg must be colon-delimited"); - assert_eq!( - char_cursor, "5", - "the cursor must be encoded as a character offset, not the byte offset" - ); - assert_eq!( - hex::decode(hex_draft).expect("the draft must be valid hex"), - original_buffer.as_bytes(), - "the draft must be hex-encoded verbatim" - ); -} - -/// An empty draft (ctrl-t on a blank line) is the ordinary, not edge, case: the hex field for it -/// is empty, so the cursor and draft must stay combined into one argument rather than passed -/// separately, or the empty field would vanish under the shell's own word-splitting once the -/// invocation is typed into the terminal as literal text. -#[test] -fn ctrl_t_draft_arg_keeps_empty_draft_and_cursor_as_one_token() { - let arg = ctrl_t_draft_arg("", ByteOffset::from(0)); - assert_eq!(arg, "0:"); -} - /// While `ShellWidgetHandoff` is disabled (its default state, matching a user who hasn't opted /// into this prototype), the `workspace:trigger_external_ctrl_t_file_search` binding must be /// completely ineligible -- not merely a no-op when triggered -- so ctrl-t falls through to From c5feca607207744fada900308f9cefbd3fc4426a Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:46:13 +0000 Subject: [PATCH 65/70] Drop helper_command param from ctrl-r handoff trigger. --- app/src/terminal/input.rs | 21 +++++++++++---------- app/src/terminal/view.rs | 7 +------ 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index 3a0a6bb1dfa..4f0bf59b3a2 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -1929,6 +1929,11 @@ impl PendingShellWidgetHandoff { } } +/// Name of the bootstrap-installed shell function invoked to hand ctrl-r off to the shell's +/// own external history widget. Must match the function name defined in +/// `app/assets/bundled/bootstrap/zsh_body.sh`. +const EXTERNAL_CTRL_R_HELPER_COMMAND: &str = "warp_run_external_ctrl_r_widget"; + struct AmbientAgentViewState { view_model: ModelHandle, #[allow(dead_code)] @@ -7686,10 +7691,10 @@ impl Input { self.try_execute_command_with_options(command, false, ctx) } - /// Runs `helper_command` (a bootstrap-installed shell function) as if the user had typed and - /// submitted it, snapshotting the current buffer contents so they're restored once the - /// command's block completes -- unless [`Self::set_external_shell_widget_selection`] supplies - /// a selected command in the meantime. Returns `true` if the command was started. + /// Runs [`EXTERNAL_CTRL_R_HELPER_COMMAND`] (a bootstrap-installed shell function) as if the + /// user had typed and submitted it, snapshotting the current buffer contents so they're restored + /// once the command's block completes -- unless [`Self::set_external_shell_widget_selection`] + /// supplies a selected command in the meantime. Returns `true` if the command was started. /// /// The command is prefixed with a leading space, honoring the "ignorespace" convention that /// bash/zsh support and atuin explicitly implements itself (independent of the shell's own @@ -7699,17 +7704,13 @@ impl Input { /// atuin records through its own preexec hook straight into its own database, which none of /// that touches, so without this it would otherwise show up in the very history list this /// feature exists to search. - pub fn trigger_external_ctrl_r_history_search( - &mut self, - helper_command: &str, - ctx: &mut ViewContext, - ) -> bool { + pub fn trigger_external_ctrl_r_history_search(&mut self, ctx: &mut ViewContext) -> bool { let Some(session_id) = self.active_block_session_id() else { return false; }; let current_input = self.buffer_text(ctx); let block_id = self.model.lock().block_list().active_block_id().clone(); - let command = format!(" {helper_command}"); + let command = format!(" {EXTERNAL_CTRL_R_HELPER_COMMAND}"); // Not a command the user ran: Warp's history is independent of the shell histfile. let started = self.try_execute_command_from_source( &command, diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index ba360ea0af2..05b993bf2ce 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -721,11 +721,6 @@ const WARP_MD_PATH: &str = "WARP.md"; /// name used in `app/assets/bundled/bootstrap/zsh_body.sh`. const EXTERNAL_CTRL_R_HISTORY_PLUGIN_TAG: &str = "external_ctrl_r_history"; -/// Name of the bootstrap-installed shell function invoked to hand ctrl-r off to the shell's -/// own external history widget. Must match the function name defined in -/// `app/assets/bundled/bootstrap/zsh_body.sh`. -const EXTERNAL_CTRL_R_HELPER_COMMAND: &str = "warp_run_external_ctrl_r_widget"; - /// `shell_plugins` tag reported by bootstrap when the shell's `^T` binding has been rebound away /// from its default line-editor binding to an external file-search widget (e.g. fzf). Independent /// of [`EXTERNAL_CTRL_R_HISTORY_PLUGIN_TAG`] -- a shell can have either, both, or neither, since @@ -9256,7 +9251,7 @@ impl TerminalView { } self.input.update(ctx, |input, ctx| { - input.trigger_external_ctrl_r_history_search(EXTERNAL_CTRL_R_HELPER_COMMAND, ctx) + input.trigger_external_ctrl_r_history_search(ctx) }) } From da4a7166e5808cb0dc9cbbe639e8467099c887f6 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:51:06 +0000 Subject: [PATCH 66/70] Move ctrl-t helper command const into input.rs. --- app/src/terminal/input.rs | 20 ++++++++++++-------- app/src/terminal/input_tests.rs | 6 +----- app/src/terminal/view.rs | 11 +---------- 3 files changed, 14 insertions(+), 23 deletions(-) diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index 4f0bf59b3a2..f9a9b7c277e 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -1934,6 +1934,11 @@ impl PendingShellWidgetHandoff { /// `app/assets/bundled/bootstrap/zsh_body.sh`. const EXTERNAL_CTRL_R_HELPER_COMMAND: &str = "warp_run_external_ctrl_r_widget"; +/// Name of the bootstrap-installed shell function invoked to hand ctrl-t off to the shell's own +/// external file-search widget. Must match the function name defined in +/// `app/assets/bundled/bootstrap/zsh_body.sh`. +const EXTERNAL_CTRL_T_HELPER_COMMAND: &str = "warp_run_external_ctrl_t_widget"; + struct AmbientAgentViewState { view_model: ModelHandle, #[allow(dead_code)] @@ -7738,12 +7743,12 @@ impl Input { handoff.maybe_apply_selection(session_id, selection); } - /// Runs `helper_command` (a bootstrap-installed shell function) as if the user had typed and - /// submitted it, mirroring [`Self::trigger_external_ctrl_r_history_search`]. Unlike ctrl-r, - /// which replaces the whole buffer with the selection, ctrl-t either splices the selection - /// into the buffer at the cursor position ctrl-t was pressed at, or replaces the buffer - /// wholesale, depending on `apply_mode` (see [`CtrlTApplyMode`]) -- so this snapshots the - /// current buffer text and cursor byte offset separately, rather than a single restorable + /// Runs [`EXTERNAL_CTRL_T_HELPER_COMMAND`] (a bootstrap-installed shell function) as if the + /// user had typed and submitted it, mirroring [`Self::trigger_external_ctrl_r_history_search`]. + /// Unlike ctrl-r, which replaces the whole buffer with the selection, ctrl-t either splices the + /// selection into the buffer at the cursor position ctrl-t was pressed at, or replaces the + /// buffer wholesale, depending on `apply_mode` (see [`CtrlTApplyMode`]) -- so this snapshots + /// the current buffer text and cursor byte offset separately, rather than a single restorable /// string. Returns `true` if the command was started. /// /// See [`Self::trigger_external_ctrl_r_history_search`] for why the command is prefixed with @@ -7759,7 +7764,6 @@ impl Input { /// and reports a plain path for Warp to splice in itself. pub fn trigger_external_ctrl_t_file_search( &mut self, - helper_command: &str, apply_mode: CtrlTApplyMode, ctx: &mut ViewContext, ) -> bool { @@ -7772,7 +7776,7 @@ impl Input { .as_ref(ctx) .end_byte_index_of_last_selection(ctx); let block_id = self.model.lock().block_list().active_block_id().clone(); - let mut command = format!(" {helper_command}"); + let mut command = format!(" {EXTERNAL_CTRL_T_HELPER_COMMAND}"); if apply_mode == CtrlTApplyMode::Replace { let char_cursor = original_buffer[..cursor_offset.as_usize()].chars().count(); command.push_str(&format!(" {char_cursor}:{}", hex::encode(&original_buffer))); diff --git a/app/src/terminal/input_tests.rs b/app/src/terminal/input_tests.rs index a7440926e49..f6b85231d6b 100644 --- a/app/src/terminal/input_tests.rs +++ b/app/src/terminal/input_tests.rs @@ -2132,11 +2132,7 @@ fn ctrl_t_handoff_cancel_restores_cursor_captured_by_a_real_trigger() { }); let started = input.update(&mut app, |input, ctx| { - input.trigger_external_ctrl_t_file_search( - "warp_run_external_ctrl_t_widget", - CtrlTApplyMode::Splice, - ctx, - ) + input.trigger_external_ctrl_t_file_search(CtrlTApplyMode::Splice, ctx) }); assert!(started, "the handoff command should have started"); diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index 05b993bf2ce..90a14bc4c70 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -728,11 +728,6 @@ const EXTERNAL_CTRL_R_HISTORY_PLUGIN_TAG: &str = "external_ctrl_r_history"; /// `app/assets/bundled/bootstrap/zsh_body.sh`. const EXTERNAL_CTRL_T_FILE_PLUGIN_TAG: &str = "external_ctrl_t_file"; -/// Name of the bootstrap-installed shell function invoked to hand ctrl-t off to the shell's own -/// external file-search widget. Must match the function name defined in -/// `app/assets/bundled/bootstrap/zsh_body.sh`. -const EXTERNAL_CTRL_T_HELPER_COMMAND: &str = "warp_run_external_ctrl_t_widget"; - pub const LONG_RUNNING_AGENT_REQUESTED_COMMAND_CONTEXT_KEY: &str = "LongRunningRequestedCommand"; pub const LONG_RUNNING_AGENT_REQUESTED_COMMAND_USER_TOOK_OVER_CONTEXT_KEY: &str = "LongRunningRequestedUserTookOverCommand"; @@ -9296,11 +9291,7 @@ impl TerminalView { }; self.input.update(ctx, |input, ctx| { - input.trigger_external_ctrl_t_file_search( - EXTERNAL_CTRL_T_HELPER_COMMAND, - apply_mode, - ctx, - ) + input.trigger_external_ctrl_t_file_search(apply_mode, ctx) }) } From 807dd8686804cbe9a7085741e3cd2c6855c71e49 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:04:39 +0000 Subject: [PATCH 67/70] Replace ShellWidgetHandoffKind with ShellWidgetApplyMode. --- app/src/terminal/input.rs | 102 ++++++++++++++------------------ app/src/terminal/input_tests.rs | 49 ++++++++------- app/src/terminal/view.rs | 14 +++-- 3 files changed, 77 insertions(+), 88 deletions(-) diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index f9a9b7c277e..180a8b2f792 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -1880,28 +1880,22 @@ pub struct Input { pending_shell_widget_handoff: Option, } -/// How a completed ctrl-t handoff lands its selection. Fish's widget already performs token-aware -/// replacement and reports the whole line; bash/zsh report a path fragment to splice at the cursor. +/// How a completed shell-widget handoff lands its selection. Fish's ctrl-t widget already performs +/// token-aware replacement and reports the whole line; bash/zsh ctrl-t report a path fragment to +/// splice at the cursor. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum CtrlTApplyMode { +pub enum ShellWidgetApplyMode { Splice, Replace, } -enum ShellWidgetHandoffKind { - CtrlR, - CtrlT { - apply_mode: CtrlTApplyMode, - cursor_offset: ByteOffset, - }, -} - struct PendingShellWidgetHandoff { session_id: SessionId, original_buffer: String, selection: Option, block_id: BlockId, - kind: ShellWidgetHandoffKind, + apply_mode: ShellWidgetApplyMode, + cursor_offset: Option, } impl PendingShellWidgetHandoff { @@ -1915,16 +1909,11 @@ impl PendingShellWidgetHandoff { } fn restore_text(&self) -> &str { - match (&self.kind, &self.selection) { - (ShellWidgetHandoffKind::CtrlR, Some(selection)) => selection, - ( - ShellWidgetHandoffKind::CtrlT { - apply_mode: CtrlTApplyMode::Replace, - .. - }, - Some(selection), - ) => selection, - _ => &self.original_buffer, + match (self.apply_mode, &self.selection) { + (ShellWidgetApplyMode::Replace, Some(selection)) => selection, + (ShellWidgetApplyMode::Replace, None) | (ShellWidgetApplyMode::Splice, _) => { + &self.original_buffer + } } } } @@ -7729,7 +7718,8 @@ impl Input { original_buffer: current_input, selection: None, block_id, - kind: ShellWidgetHandoffKind::CtrlR, + apply_mode: ShellWidgetApplyMode::Replace, + cursor_offset: None, }); } started @@ -7747,24 +7737,24 @@ impl Input { /// user had typed and submitted it, mirroring [`Self::trigger_external_ctrl_r_history_search`]. /// Unlike ctrl-r, which replaces the whole buffer with the selection, ctrl-t either splices the /// selection into the buffer at the cursor position ctrl-t was pressed at, or replaces the - /// buffer wholesale, depending on `apply_mode` (see [`CtrlTApplyMode`]) -- so this snapshots - /// the current buffer text and cursor byte offset separately, rather than a single restorable - /// string. Returns `true` if the command was started. + /// buffer wholesale, depending on `apply_mode` (see [`ShellWidgetApplyMode`]) -- so this + /// snapshots the current buffer text and cursor byte offset separately, rather than a single + /// restorable string. Returns `true` if the command was started. /// /// See [`Self::trigger_external_ctrl_r_history_search`] for why the command is prefixed with /// a leading space. /// - /// When `apply_mode` is [`CtrlTApplyMode::Replace`], the command is also given the draft line - /// and cursor as `{char_cursor}:{hex_draft}` so the fish helper can seed its widget. Hex keeps - /// the draft a single token, and combining it with the cursor avoids an empty hex field (an - /// empty draft) vanishing under the shell's own word-splitting, since the invocation is typed - /// into the terminal as literal text. `char_cursor` is `cursor_offset` converted to a character - /// offset, since fish's `commandline -C` takes characters while `cursor_offset` is a byte - /// offset. Bash/zsh never need this, since their helper searches independently of the draft - /// and reports a plain path for Warp to splice in itself. + /// When `apply_mode` is [`ShellWidgetApplyMode::Replace`], the command is also given the draft + /// line and cursor as `{char_cursor}:{hex_draft}` so the fish helper can seed its widget. Hex + /// keeps the draft a single token, and combining it with the cursor avoids an empty hex field + /// (an empty draft) vanishing under the shell's own word-splitting, since the invocation is + /// typed into the terminal as literal text. `char_cursor` is `cursor_offset` converted to a + /// character offset, since fish's `commandline -C` takes characters while `cursor_offset` is a + /// byte offset. Bash/zsh never need this, since their helper searches independently of the + /// draft and reports a plain path for Warp to splice in itself. pub fn trigger_external_ctrl_t_file_search( &mut self, - apply_mode: CtrlTApplyMode, + apply_mode: ShellWidgetApplyMode, ctx: &mut ViewContext, ) -> bool { let Some(session_id) = self.active_block_session_id() else { @@ -7777,7 +7767,7 @@ impl Input { .end_byte_index_of_last_selection(ctx); let block_id = self.model.lock().block_list().active_block_id().clone(); let mut command = format!(" {EXTERNAL_CTRL_T_HELPER_COMMAND}"); - if apply_mode == CtrlTApplyMode::Replace { + if apply_mode == ShellWidgetApplyMode::Replace { let char_cursor = original_buffer[..cursor_offset.as_usize()].chars().count(); command.push_str(&format!(" {char_cursor}:{}", hex::encode(&original_buffer))); } @@ -7794,10 +7784,8 @@ impl Input { original_buffer, selection: None, block_id, - kind: ShellWidgetHandoffKind::CtrlT { - apply_mode, - cursor_offset, - }, + apply_mode, + cursor_offset: Some(cursor_offset), }); } started @@ -15699,33 +15687,29 @@ impl Input { self.editor.update(ctx, |editor, ctx| { editor.set_buffer_text(&restore_text, ctx); if let Some(handoff) = &completed_handoff { - match (&handoff.kind, &handoff.selection) { + match ( + handoff.apply_mode, + &handoff.selection, + handoff.cursor_offset, + ) { ( - ShellWidgetHandoffKind::CtrlT { - apply_mode: CtrlTApplyMode::Splice, - cursor_offset, - }, + ShellWidgetApplyMode::Splice, Some(insertion), + Some(cursor_offset), ) => editor.select_and_replace( insertion, - [*cursor_offset..*cursor_offset], + [cursor_offset..cursor_offset], PlainTextEditorViewAction::InsertSelectedText, ctx, ), - ( - ShellWidgetHandoffKind::CtrlT { - apply_mode: CtrlTApplyMode::Replace, - .. - }, - Some(_), - ) => {} - (ShellWidgetHandoffKind::CtrlT { cursor_offset, .. }, None) => { - editor.select_ranges_by_byte_offset( - [*cursor_offset..*cursor_offset], + (_, None, Some(cursor_offset)) => editor + .select_ranges_by_byte_offset( + [cursor_offset..cursor_offset], ctx, - ) - } - (ShellWidgetHandoffKind::CtrlR, _) => {} + ), + (ShellWidgetApplyMode::Replace, Some(_), _) + | (ShellWidgetApplyMode::Splice, Some(_), None) + | (_, None, None) => {} } } }); diff --git a/app/src/terminal/input_tests.rs b/app/src/terminal/input_tests.rs index f6b85231d6b..9b504c8a3bf 100644 --- a/app/src/terminal/input_tests.rs +++ b/app/src/terminal/input_tests.rs @@ -122,7 +122,8 @@ fn pending_ctrl_r_handoff() -> PendingShellWidgetHandoff { original_buffer: "draft".to_string(), selection: None, block_id: BlockId::new(), - kind: ShellWidgetHandoffKind::CtrlR, + apply_mode: ShellWidgetApplyMode::Replace, + cursor_offset: None, } } @@ -132,10 +133,8 @@ fn pending_ctrl_t_handoff() -> PendingShellWidgetHandoff { original_buffer: "echo ".to_string(), selection: None, block_id: BlockId::new(), - kind: ShellWidgetHandoffKind::CtrlT { - apply_mode: CtrlTApplyMode::Splice, - cursor_offset: ByteOffset::from(5), - }, + apply_mode: ShellWidgetApplyMode::Splice, + cursor_offset: Some(ByteOffset::from(5)), } } @@ -1914,7 +1913,7 @@ fn user_block_completed_for_test(command: &str) -> BlockType { /// block. Returns the resulting buffer text and the byte offset the cursor/selection ends up at. async fn complete_ctrl_t_handoff( app: &mut App, - apply_mode: CtrlTApplyMode, + apply_mode: ShellWidgetApplyMode, original_buffer: &str, cursor_offset: usize, insertion: Option<&str>, @@ -1928,10 +1927,8 @@ async fn complete_ctrl_t_handoff( original_buffer: original_buffer.to_string(), selection: insertion.map(str::to_string), block_id: block_id.clone(), - kind: ShellWidgetHandoffKind::CtrlT { - apply_mode, - cursor_offset: ByteOffset::from(cursor_offset), - }, + apply_mode, + cursor_offset: Some(ByteOffset::from(cursor_offset)), }); input.deferred_remote_operations.latest_block_id = BlockId::new(); input.handle_block_completed_event( @@ -1971,7 +1968,8 @@ async fn complete_ctrl_r_handoff( original_buffer: original_buffer.to_string(), selection: selection.map(str::to_string), block_id: block_id.clone(), - kind: ShellWidgetHandoffKind::CtrlR, + apply_mode: ShellWidgetApplyMode::Replace, + cursor_offset: None, }); input.deferred_remote_operations.latest_block_id = BlockId::new(); input.handle_block_completed_event( @@ -2015,7 +2013,7 @@ fn ctrl_t_handoff_splices_selection_in_middle_of_line() { // with both the preceding and following text preserved. let (buffer, cursor) = complete_ctrl_t_handoff( &mut app, - CtrlTApplyMode::Splice, + ShellWidgetApplyMode::Splice, "echo START END", 11, Some("FILE.txt "), @@ -2032,7 +2030,7 @@ fn ctrl_t_handoff_splices_selection_at_end_of_line() { initialize_app(&mut app); let (buffer, cursor) = complete_ctrl_t_handoff( &mut app, - CtrlTApplyMode::Splice, + ShellWidgetApplyMode::Splice, "echo ", 5, Some("FILE.txt"), @@ -2047,9 +2045,14 @@ fn ctrl_t_handoff_splices_selection_at_end_of_line() { fn ctrl_t_handoff_splices_selection_into_empty_buffer() { App::test((), |mut app| async move { initialize_app(&mut app); - let (buffer, cursor) = - complete_ctrl_t_handoff(&mut app, CtrlTApplyMode::Splice, "", 0, Some("FILE.txt")) - .await; + let (buffer, cursor) = complete_ctrl_t_handoff( + &mut app, + ShellWidgetApplyMode::Splice, + "", + 0, + Some("FILE.txt"), + ) + .await; assert_eq!(buffer, "FILE.txt"); assert_eq!(cursor, ByteOffset::from("FILE.txt".len())); }); @@ -2066,7 +2069,7 @@ fn ctrl_t_handoff_splices_selection_after_multi_byte_character() { let cursor_offset = original.len(); let (buffer, cursor) = complete_ctrl_t_handoff( &mut app, - CtrlTApplyMode::Splice, + ShellWidgetApplyMode::Splice, original, cursor_offset, Some("dest.txt"), @@ -2087,7 +2090,7 @@ fn ctrl_t_handoff_splices_selection_after_multi_byte_character() { fn ctrl_t_handoff_cancel_restores_cursor_to_original_offset_mid_line() { App::test((), |mut app| async move { initialize_app(&mut app); - for apply_mode in [CtrlTApplyMode::Splice, CtrlTApplyMode::Replace] { + for apply_mode in [ShellWidgetApplyMode::Splice, ShellWidgetApplyMode::Replace] { // Cursor originally sat right after "echo START ", before "END". let (buffer, cursor) = complete_ctrl_t_handoff(&mut app, apply_mode, "echo START END", 11, None).await; @@ -2132,7 +2135,7 @@ fn ctrl_t_handoff_cancel_restores_cursor_captured_by_a_real_trigger() { }); let started = input.update(&mut app, |input, ctx| { - input.trigger_external_ctrl_t_file_search(CtrlTApplyMode::Splice, ctx) + input.trigger_external_ctrl_t_file_search(ShellWidgetApplyMode::Splice, ctx) }); assert!(started, "the handoff command should have started"); @@ -2182,7 +2185,7 @@ fn ctrl_t_handoff_cancel_restores_cursor_captured_by_a_real_trigger() { } /// fish's `fzf-file-widget` already performs its own token-aware replacement, so its selection -/// (see `CtrlTApplyMode::Replace`) must land as the finished buffer wholesale -- not spliced into +/// (see `ShellWidgetApplyMode::Replace`) must land as the finished buffer wholesale -- not spliced into /// `original_buffer` the way bash/zsh's plain-path selection is. Using a `cursor_offset` that /// would splice into the *middle* of `original_buffer` if `Replace` were mishandled as `Splice` /// makes that distinction observable: a regression here would interleave `original_buffer` and @@ -2193,7 +2196,7 @@ fn ctrl_t_handoff_replace_mode_lands_selection_wholesale() { initialize_app(&mut app); let (buffer, cursor) = complete_ctrl_t_handoff( &mut app, - CtrlTApplyMode::Replace, + ShellWidgetApplyMode::Replace, "vim src/ END", 8, Some("vim src/nested.rs "), @@ -2217,7 +2220,7 @@ fn ctrl_t_apply_mode_forks_between_splice_and_replace_for_the_same_draft() { // splices it into the token at the captured cursor offset. let (splice_buffer, splice_cursor) = complete_ctrl_t_handoff( &mut app, - CtrlTApplyMode::Splice, + ShellWidgetApplyMode::Splice, "vim src/ END", 8, Some("nested.rs "), @@ -2230,7 +2233,7 @@ fn ctrl_t_apply_mode_forks_between_splice_and_replace_for_the_same_draft() { // finished line, landed wholesale. let (replace_buffer, replace_cursor) = complete_ctrl_t_handoff( &mut app, - CtrlTApplyMode::Replace, + ShellWidgetApplyMode::Replace, "vim src/ END", 8, Some("vim src/nested.rs END"), diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index 90a14bc4c70..c4de3c8a709 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -417,8 +417,8 @@ use crate::terminal::input::inline_menu::InlineMenuPositioner; #[cfg(not(target_family = "wasm"))] use crate::terminal::input::slash_commands::fork_button_action; use crate::terminal::input::{ - CommandExecutionSource, CtrlTApplyMode, InputAction, InputEmptyStateChangeReason, InputState, - MenuPositioning, MenuPositioningProvider, + CommandExecutionSource, InputAction, InputEmptyStateChangeReason, InputState, MenuPositioning, + MenuPositioningProvider, ShellWidgetApplyMode, }; use crate::terminal::keys::TerminalKeybindings; use crate::terminal::ligature_settings::{LigatureSettings, should_use_ligature_rendering}; @@ -9256,7 +9256,7 @@ impl TerminalView { /// [`Self::maybe_trigger_external_ctrl_r_history_search`], but lands the selection either by /// inserting it into the input editor at the cursor position or by replacing the whole /// buffer, depending on the session's shell; see [`Input::trigger_external_ctrl_t_file_search`] - /// and [`CtrlTApplyMode`]. + /// and [`ShellWidgetApplyMode`]. /// /// Returns `true` if the handoff was triggered, in which case the caller should not pass /// ctrl-t through to the pty or handle it any other way. @@ -9284,10 +9284,12 @@ impl TerminalView { // fish invokes the user's real `fzf-file-widget` directly, which already performs its // own token-aware replacement and so returns the whole new line; bash/zsh's helper // instead searches independently of the draft and reports a plain path to splice in at - // the cursor. See `CtrlTApplyMode` and the fish/bash/zsh helper implementations. + // the cursor. See `ShellWidgetApplyMode` and the fish/bash/zsh helper implementations. let apply_mode = match session.shell().shell_type() { - ShellType::Fish => CtrlTApplyMode::Replace, - ShellType::Bash | ShellType::Zsh | ShellType::PowerShell => CtrlTApplyMode::Splice, + ShellType::Fish => ShellWidgetApplyMode::Replace, + ShellType::Bash | ShellType::Zsh | ShellType::PowerShell => { + ShellWidgetApplyMode::Splice + } }; self.input.update(ctx, |input, ctx| { From 18e3d5b7970dc55c7fbc2c4d704a9ade895e066a Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:36:07 +0000 Subject: [PATCH 68/70] Unify shell-widget handoff triggers and drop added test comments. --- app/src/terminal/input.rs | 99 +++++++++++---------------------- app/src/terminal/input_tests.rs | 62 ++------------------- app/src/terminal/view.rs | 23 ++++++-- 3 files changed, 55 insertions(+), 129 deletions(-) diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index 180a8b2f792..65634f7f9aa 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -1921,12 +1921,12 @@ impl PendingShellWidgetHandoff { /// Name of the bootstrap-installed shell function invoked to hand ctrl-r off to the shell's /// own external history widget. Must match the function name defined in /// `app/assets/bundled/bootstrap/zsh_body.sh`. -const EXTERNAL_CTRL_R_HELPER_COMMAND: &str = "warp_run_external_ctrl_r_widget"; +pub(crate) const EXTERNAL_CTRL_R_HELPER_COMMAND: &str = "warp_run_external_ctrl_r_widget"; /// Name of the bootstrap-installed shell function invoked to hand ctrl-t off to the shell's own /// external file-search widget. Must match the function name defined in /// `app/assets/bundled/bootstrap/zsh_body.sh`. -const EXTERNAL_CTRL_T_HELPER_COMMAND: &str = "warp_run_external_ctrl_t_widget"; +pub(crate) const EXTERNAL_CTRL_T_HELPER_COMMAND: &str = "warp_run_external_ctrl_t_widget"; struct AmbientAgentViewState { view_model: ModelHandle, @@ -7685,46 +7685,6 @@ impl Input { self.try_execute_command_with_options(command, false, ctx) } - /// Runs [`EXTERNAL_CTRL_R_HELPER_COMMAND`] (a bootstrap-installed shell function) as if the - /// user had typed and submitted it, snapshotting the current buffer contents so they're restored - /// once the command's block completes -- unless [`Self::set_external_shell_widget_selection`] - /// supplies a selected command in the meantime. Returns `true` if the command was started. - /// - /// The command is prefixed with a leading space, honoring the "ignorespace" convention that - /// bash/zsh support and atuin explicitly implements itself (independent of the shell's own - /// history settings -- see atuin's docs on excluding commands). Our own per-shell exclusions - /// (zsh's `_warp_zshaddhistory`, bash's `HISTIGNORE`, fish's `fish_should_add_to_history` - /// wrapper) only ever stopped the *shell's* history file from recording this invocation. - /// atuin records through its own preexec hook straight into its own database, which none of - /// that touches, so without this it would otherwise show up in the very history list this - /// feature exists to search. - pub fn trigger_external_ctrl_r_history_search(&mut self, ctx: &mut ViewContext) -> bool { - let Some(session_id) = self.active_block_session_id() else { - return false; - }; - let current_input = self.buffer_text(ctx); - let block_id = self.model.lock().block_list().active_block_id().clone(); - let command = format!(" {EXTERNAL_CTRL_R_HELPER_COMMAND}"); - // Not a command the user ran: Warp's history is independent of the shell histfile. - let started = self.try_execute_command_from_source( - &command, - CommandExecutionSource::User, - false, - ctx, - ); - if started { - self.pending_shell_widget_handoff = Some(PendingShellWidgetHandoff { - session_id, - original_buffer: current_input, - selection: None, - block_id, - apply_mode: ShellWidgetApplyMode::Replace, - cursor_offset: None, - }); - } - started - } - /// Applies `selection` only if `session_id` matches the in-flight handoff. pub fn set_external_shell_widget_selection(&mut self, session_id: SessionId, selection: &str) { let Some(handoff) = self.pending_shell_widget_handoff.as_mut() else { @@ -7733,41 +7693,46 @@ impl Input { handoff.maybe_apply_selection(session_id, selection); } - /// Runs [`EXTERNAL_CTRL_T_HELPER_COMMAND`] (a bootstrap-installed shell function) as if the - /// user had typed and submitted it, mirroring [`Self::trigger_external_ctrl_r_history_search`]. - /// Unlike ctrl-r, which replaces the whole buffer with the selection, ctrl-t either splices the - /// selection into the buffer at the cursor position ctrl-t was pressed at, or replaces the - /// buffer wholesale, depending on `apply_mode` (see [`ShellWidgetApplyMode`]) -- so this - /// snapshots the current buffer text and cursor byte offset separately, rather than a single - /// restorable string. Returns `true` if the command was started. + /// Runs `helper_command` (a bootstrap-installed shell function) as if the user had typed and + /// submitted it, snapshotting the current buffer so it can be restored once the command's block + /// completes -- unless [`Self::set_external_shell_widget_selection`] supplies a selection in + /// the meantime. Returns `true` if the command was started. /// - /// See [`Self::trigger_external_ctrl_r_history_search`] for why the command is prefixed with - /// a leading space. + /// When `capture_cursor` is set, the caret is restored on cancel. Combined with Replace, the + /// command also gets `{char_cursor}:{hex_draft}` so a fish file widget can seed itself. Hex + /// keeps the draft a single token, and combining it with the cursor keeps an empty draft from + /// vanishing under shell word-splitting. `char_cursor` converts the captured byte offset to + /// characters because fish's `commandline -C` takes characters. /// - /// When `apply_mode` is [`ShellWidgetApplyMode::Replace`], the command is also given the draft - /// line and cursor as `{char_cursor}:{hex_draft}` so the fish helper can seed its widget. Hex - /// keeps the draft a single token, and combining it with the cursor avoids an empty hex field - /// (an empty draft) vanishing under the shell's own word-splitting, since the invocation is - /// typed into the terminal as literal text. `char_cursor` is `cursor_offset` converted to a - /// character offset, since fish's `commandline -C` takes characters while `cursor_offset` is a - /// byte offset. Bash/zsh never need this, since their helper searches independently of the - /// draft and reports a plain path for Warp to splice in itself. - pub fn trigger_external_ctrl_t_file_search( + /// The command is prefixed with a leading space, honoring the "ignorespace" convention that + /// bash/zsh support and atuin explicitly implements itself (independent of the shell's own + /// history settings -- see atuin's docs on excluding commands). Our own per-shell exclusions + /// (zsh's `_warp_zshaddhistory`, bash's `HISTIGNORE`, fish's `fish_should_add_to_history` + /// wrapper) only ever stopped the *shell's* history file from recording this invocation. + /// atuin records through its own preexec hook straight into its own database, which none of + /// that touches, so without this it would otherwise show up in the very history list this + /// feature exists to search. + pub fn trigger_external_shell_widget_handoff( &mut self, + helper_command: &str, apply_mode: ShellWidgetApplyMode, + capture_cursor: bool, ctx: &mut ViewContext, ) -> bool { let Some(session_id) = self.active_block_session_id() else { return false; }; let original_buffer = self.buffer_text(ctx); - let cursor_offset = self - .editor - .as_ref(ctx) - .end_byte_index_of_last_selection(ctx); + let cursor_offset = capture_cursor.then(|| { + self.editor + .as_ref(ctx) + .end_byte_index_of_last_selection(ctx) + }); let block_id = self.model.lock().block_list().active_block_id().clone(); - let mut command = format!(" {EXTERNAL_CTRL_T_HELPER_COMMAND}"); - if apply_mode == ShellWidgetApplyMode::Replace { + let mut command = format!(" {helper_command}"); + if let Some(cursor_offset) = cursor_offset + && apply_mode == ShellWidgetApplyMode::Replace + { let char_cursor = original_buffer[..cursor_offset.as_usize()].chars().count(); command.push_str(&format!(" {char_cursor}:{}", hex::encode(&original_buffer))); } @@ -7785,7 +7750,7 @@ impl Input { selection: None, block_id, apply_mode, - cursor_offset: Some(cursor_offset), + cursor_offset, }); } started diff --git a/app/src/terminal/input_tests.rs b/app/src/terminal/input_tests.rs index 9b504c8a3bf..392e013c4ba 100644 --- a/app/src/terminal/input_tests.rs +++ b/app/src/terminal/input_tests.rs @@ -1886,9 +1886,6 @@ fn queued_command_completion_preserves_draft() { }); } -/// Builds a `BlockType::User` completion for `command`, for use with -/// `Input::handle_block_completed_event` in tests that don't care about the block's other -/// (lazily-computed) fields. fn user_block_completed_for_test(command: &str) -> BlockType { BlockType::User(UserBlockCompleted::new_for_test( BlockIndex::zero(), @@ -1907,10 +1904,6 @@ fn user_block_completed_for_test(command: &str) -> BlockType { )) } -/// Drives a completed ctrl-t handoff (see `Input::handle_block_completed_event`) directly, -/// without going through the full trigger/selection flow: constructs the pending handoff with -/// the given `apply_mode`/`original_buffer`/`cursor_offset`/`insertion`, then completes its -/// block. Returns the resulting buffer text and the byte offset the cursor/selection ends up at. async fn complete_ctrl_t_handoff( app: &mut App, apply_mode: ShellWidgetApplyMode, @@ -2009,8 +2002,6 @@ fn ctrl_r_handoff_cancel_restores_draft() { fn ctrl_t_handoff_splices_selection_in_middle_of_line() { App::test((), |mut app| async move { initialize_app(&mut app); - // Cursor sits right after "echo START ", before "END": the insertion must land there - // with both the preceding and following text preserved. let (buffer, cursor) = complete_ctrl_t_handoff( &mut app, ShellWidgetApplyMode::Splice, @@ -2058,9 +2049,6 @@ fn ctrl_t_handoff_splices_selection_into_empty_buffer() { }); } -/// A cursor byte offset mistakenly treated as a char offset would panic or corrupt the buffer -/// the moment a multi-byte character (here, "caf\u{e9}", where \u{e9} is 2 bytes in UTF-8) -/// precedes the cursor. #[test] fn ctrl_t_handoff_splices_selection_after_multi_byte_character() { App::test((), |mut app| async move { @@ -2080,18 +2068,11 @@ fn ctrl_t_handoff_splices_selection_after_multi_byte_character() { }); } -/// Cancelling (`insertion: None`) on a mid-line draft must restore the cursor to the byte -/// offset it was captured at, not merely leave the surrounding text untouched. `set_buffer_text` -/// alone would leave the cursor at the end of the restored text; only the explicit -/// `select_ranges_by_byte_offset` call in the `None` arm of `Input::handle_block_completed_event` -/// repositions it back to where ctrl-t was originally pressed. Covers both apply modes: cancel -/// behaves identically regardless of which shell started the handoff. #[test] fn ctrl_t_handoff_cancel_restores_cursor_to_original_offset_mid_line() { App::test((), |mut app| async move { initialize_app(&mut app); for apply_mode in [ShellWidgetApplyMode::Splice, ShellWidgetApplyMode::Replace] { - // Cursor originally sat right after "echo START ", before "END". let (buffer, cursor) = complete_ctrl_t_handoff(&mut app, apply_mode, "echo START END", 11, None).await; assert_eq!( @@ -2108,13 +2089,6 @@ fn ctrl_t_handoff_cancel_restores_cursor_to_original_offset_mid_line() { }); } -/// Exercises the real trigger path (`Input::trigger_external_ctrl_t_file_search`), not just the -/// completion-side apply function: the cursor offset the cancel restore uses must be the one -/// actually captured live when ctrl-t was pressed, not a hand-picked value fed straight into -/// `handle_block_completed_event`. A regression that stales or drops the captured offset between -/// trigger and completion would still pass -/// `ctrl_t_handoff_cancel_restores_cursor_to_original_offset_mid_line` (which never calls the -/// trigger) but fail this one. #[test] fn ctrl_t_handoff_cancel_restores_cursor_captured_by_a_real_trigger() { App::test((), |mut app| async move { @@ -2122,8 +2096,6 @@ fn ctrl_t_handoff_cancel_restores_cursor_captured_by_a_real_trigger() { let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; let input = terminal.read(&app, |view, _| view.input().clone()); - // Cursor sits right after "echo START ", before "MIDDLE" -- mirrors a user typing the - // command, arrowing left, then pressing ctrl-t. input.update(&mut app, |input, ctx| { input.user_insert("echo START MIDDLE", ctx); input.editor().update(ctx, |editor, ctx| { @@ -2135,7 +2107,12 @@ fn ctrl_t_handoff_cancel_restores_cursor_captured_by_a_real_trigger() { }); let started = input.update(&mut app, |input, ctx| { - input.trigger_external_ctrl_t_file_search(ShellWidgetApplyMode::Splice, ctx) + input.trigger_external_shell_widget_handoff( + EXTERNAL_CTRL_T_HELPER_COMMAND, + ShellWidgetApplyMode::Splice, + true, + ctx, + ) }); assert!(started, "the handoff command should have started"); @@ -2143,18 +2120,10 @@ fn ctrl_t_handoff_cancel_restores_cursor_captured_by_a_real_trigger() { terminal.model.lock().block_list().active_block_id().clone() }); - // The test harness never actually advances the block list in response to - // `Event::ExecuteCommand` (no pty is running), so `block_id` above is still the same - // block `deferred_remote_operations.latest_block_id` was last set to -- unlike the real - // flow, where the helper command's block is a genuinely new one. Force it stale here so - // `handle_block_completed_event`'s restore branch actually runs, exactly as - // `complete_ctrl_t_handoff` does for the same reason. input.update(&mut app, |input, _ctx| { input.deferred_remote_operations.latest_block_id = BlockId::new(); }); - // Simulate the shell reporting no selection (the user cancelled) -- without ever telling - // `Input` what cursor_offset to use; it must come from what the trigger captured. input.update(&mut app, |input, ctx| { input.handle_block_completed_event( BlockCompletedEvent { @@ -2184,12 +2153,6 @@ fn ctrl_t_handoff_cancel_restores_cursor_captured_by_a_real_trigger() { }); } -/// fish's `fzf-file-widget` already performs its own token-aware replacement, so its selection -/// (see `ShellWidgetApplyMode::Replace`) must land as the finished buffer wholesale -- not spliced into -/// `original_buffer` the way bash/zsh's plain-path selection is. Using a `cursor_offset` that -/// would splice into the *middle* of `original_buffer` if `Replace` were mishandled as `Splice` -/// makes that distinction observable: a regression here would interleave `original_buffer` and -/// `insertion` instead of replacing outright. #[test] fn ctrl_t_handoff_replace_mode_lands_selection_wholesale() { App::test((), |mut app| async move { @@ -2207,17 +2170,11 @@ fn ctrl_t_handoff_replace_mode_lands_selection_wholesale() { }); } -/// States the `Splice`/`Replace` fork as an explicit contract: the same pre-handoff draft and -/// cursor, completed with each shell's own realistic selection shape, must diverge exactly as -/// each mode specifies. A regression that collapses the two modes together (e.g. always splicing, -/// or always replacing) would fail one arm of this test while possibly leaving the other passing. #[test] fn ctrl_t_apply_mode_forks_between_splice_and_replace_for_the_same_draft() { App::test((), |mut app| async move { initialize_app(&mut app); - // bash/zsh: the helper reports a plain path with no knowledge of the draft, so Warp - // splices it into the token at the captured cursor offset. let (splice_buffer, splice_cursor) = complete_ctrl_t_handoff( &mut app, ShellWidgetApplyMode::Splice, @@ -2229,8 +2186,6 @@ fn ctrl_t_apply_mode_forks_between_splice_and_replace_for_the_same_draft() { assert_eq!(splice_buffer, "vim src/nested.rs END"); assert_eq!(splice_cursor, ByteOffset::from("vim src/nested.rs ".len())); - // fish: fzf-file-widget already replaced the token itself, so its report is the whole - // finished line, landed wholesale. let (replace_buffer, replace_cursor) = complete_ctrl_t_handoff( &mut app, ShellWidgetApplyMode::Replace, @@ -2247,11 +2202,6 @@ fn ctrl_t_apply_mode_forks_between_splice_and_replace_for_the_same_draft() { }); } -/// While `ShellWidgetHandoff` is disabled (its default state, matching a user who hasn't opted -/// into this prototype), the `workspace:trigger_external_ctrl_t_file_search` binding must be -/// completely ineligible -- not merely a no-op when triggered -- so ctrl-t falls through to -/// whatever the input editor does with an unhandled key, exactly as it did before this feature -/// existed. See `EditableBinding::with_enabled` on `init()`'s registration of this binding. #[test] fn ctrl_t_binding_is_ineligible_when_shell_widget_handoff_flag_is_disabled() { App::test((), |mut app| async move { diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index c4de3c8a709..7ebda5267f8 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -417,8 +417,9 @@ use crate::terminal::input::inline_menu::InlineMenuPositioner; #[cfg(not(target_family = "wasm"))] use crate::terminal::input::slash_commands::fork_button_action; use crate::terminal::input::{ - CommandExecutionSource, InputAction, InputEmptyStateChangeReason, InputState, MenuPositioning, - MenuPositioningProvider, ShellWidgetApplyMode, + CommandExecutionSource, EXTERNAL_CTRL_R_HELPER_COMMAND, EXTERNAL_CTRL_T_HELPER_COMMAND, + InputAction, InputEmptyStateChangeReason, InputState, MenuPositioning, MenuPositioningProvider, + ShellWidgetApplyMode, }; use crate::terminal::keys::TerminalKeybindings; use crate::terminal::ligature_settings::{LigatureSettings, should_use_ligature_rendering}; @@ -9216,7 +9217,7 @@ impl TerminalView { /// machinery hides the input editor and forwards keystrokes to the widget's PTY-driven UI. /// The command the user selects (or the buffer they had before ctrl-r, if they cancel) is /// restored into the input editor once the helper's block completes; see - /// [`Input::trigger_external_ctrl_r_history_search`] and + /// [`Input::trigger_external_shell_widget_handoff`] and /// [`Input::set_external_shell_widget_selection`]. /// /// Returns `true` if the handoff was triggered, in which case the caller should not open @@ -9246,7 +9247,12 @@ impl TerminalView { } self.input.update(ctx, |input, ctx| { - input.trigger_external_ctrl_r_history_search(ctx) + input.trigger_external_shell_widget_handoff( + EXTERNAL_CTRL_R_HELPER_COMMAND, + ShellWidgetApplyMode::Replace, + false, + ctx, + ) }) } @@ -9255,7 +9261,7 @@ impl TerminalView { /// plugin tag, e.g. by fzf), hands the keypress off to that widget. Mirrors /// [`Self::maybe_trigger_external_ctrl_r_history_search`], but lands the selection either by /// inserting it into the input editor at the cursor position or by replacing the whole - /// buffer, depending on the session's shell; see [`Input::trigger_external_ctrl_t_file_search`] + /// buffer, depending on the session's shell; see [`Input::trigger_external_shell_widget_handoff`] /// and [`ShellWidgetApplyMode`]. /// /// Returns `true` if the handoff was triggered, in which case the caller should not pass @@ -9293,7 +9299,12 @@ impl TerminalView { }; self.input.update(ctx, |input, ctx| { - input.trigger_external_ctrl_t_file_search(apply_mode, ctx) + input.trigger_external_shell_widget_handoff( + EXTERNAL_CTRL_T_HELPER_COMMAND, + apply_mode, + true, + ctx, + ) }) } From 7848009182a3eab195936977b847fef08ad37796 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:47:21 +0000 Subject: [PATCH 69/70] Move shell-widget helper command constants into view.rs. --- app/src/terminal/input.rs | 10 ---------- app/src/terminal/input_tests.rs | 2 +- app/src/terminal/view.rs | 15 ++++++++++++--- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index 65634f7f9aa..c01b7059a89 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -1918,16 +1918,6 @@ impl PendingShellWidgetHandoff { } } -/// Name of the bootstrap-installed shell function invoked to hand ctrl-r off to the shell's -/// own external history widget. Must match the function name defined in -/// `app/assets/bundled/bootstrap/zsh_body.sh`. -pub(crate) const EXTERNAL_CTRL_R_HELPER_COMMAND: &str = "warp_run_external_ctrl_r_widget"; - -/// Name of the bootstrap-installed shell function invoked to hand ctrl-t off to the shell's own -/// external file-search widget. Must match the function name defined in -/// `app/assets/bundled/bootstrap/zsh_body.sh`. -pub(crate) const EXTERNAL_CTRL_T_HELPER_COMMAND: &str = "warp_run_external_ctrl_t_widget"; - struct AmbientAgentViewState { view_model: ModelHandle, #[allow(dead_code)] diff --git a/app/src/terminal/input_tests.rs b/app/src/terminal/input_tests.rs index 392e013c4ba..2267464b582 100644 --- a/app/src/terminal/input_tests.rs +++ b/app/src/terminal/input_tests.rs @@ -2108,7 +2108,7 @@ fn ctrl_t_handoff_cancel_restores_cursor_captured_by_a_real_trigger() { let started = input.update(&mut app, |input, ctx| { input.trigger_external_shell_widget_handoff( - EXTERNAL_CTRL_T_HELPER_COMMAND, + "warp_run_external_ctrl_t_widget", ShellWidgetApplyMode::Splice, true, ctx, diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index 7ebda5267f8..ac621c50a64 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -417,9 +417,8 @@ use crate::terminal::input::inline_menu::InlineMenuPositioner; #[cfg(not(target_family = "wasm"))] use crate::terminal::input::slash_commands::fork_button_action; use crate::terminal::input::{ - CommandExecutionSource, EXTERNAL_CTRL_R_HELPER_COMMAND, EXTERNAL_CTRL_T_HELPER_COMMAND, - InputAction, InputEmptyStateChangeReason, InputState, MenuPositioning, MenuPositioningProvider, - ShellWidgetApplyMode, + CommandExecutionSource, InputAction, InputEmptyStateChangeReason, InputState, MenuPositioning, + MenuPositioningProvider, ShellWidgetApplyMode, }; use crate::terminal::keys::TerminalKeybindings; use crate::terminal::ligature_settings::{LigatureSettings, should_use_ligature_rendering}; @@ -729,6 +728,16 @@ const EXTERNAL_CTRL_R_HISTORY_PLUGIN_TAG: &str = "external_ctrl_r_history"; /// `app/assets/bundled/bootstrap/zsh_body.sh`. const EXTERNAL_CTRL_T_FILE_PLUGIN_TAG: &str = "external_ctrl_t_file"; +/// Name of the bootstrap-installed shell function invoked to hand ctrl-r off to the shell's +/// own external history widget. Must match the function name defined in +/// `app/assets/bundled/bootstrap/zsh_body.sh`. +const EXTERNAL_CTRL_R_HELPER_COMMAND: &str = "warp_run_external_ctrl_r_widget"; + +/// Name of the bootstrap-installed shell function invoked to hand ctrl-t off to the shell's own +/// external file-search widget. Must match the function name defined in +/// `app/assets/bundled/bootstrap/zsh_body.sh`. +const EXTERNAL_CTRL_T_HELPER_COMMAND: &str = "warp_run_external_ctrl_t_widget"; + pub const LONG_RUNNING_AGENT_REQUESTED_COMMAND_CONTEXT_KEY: &str = "LongRunningRequestedCommand"; pub const LONG_RUNNING_AGENT_REQUESTED_COMMAND_USER_TOOK_OVER_CONTEXT_KEY: &str = "LongRunningRequestedUserTookOverCommand"; From 4c89de0f9f005d0bb6cdef13c8231e4324897e1e Mon Sep 17 00:00:00 2001 From: Andy Carlson <2yinyang2@gmail.com> Date: Mon, 31 Aug 2026 18:07:04 -0700 Subject: [PATCH 70/70] more manual cleanup --- app/src/terminal/input.rs | 26 +++++--------------------- app/src/terminal/view.rs | 12 ++---------- app/src/workspace/action.rs | 25 ++----------------------- app/src/workspace/view.rs | 9 +-------- app/src/workspace/view_tests.rs | 6 ------ crates/warp_features/src/lib.rs | 6 +----- 6 files changed, 11 insertions(+), 73 deletions(-) diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index c01b7059a89..0e792c2932d 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -7683,25 +7683,9 @@ impl Input { handoff.maybe_apply_selection(session_id, selection); } - /// Runs `helper_command` (a bootstrap-installed shell function) as if the user had typed and - /// submitted it, snapshotting the current buffer so it can be restored once the command's block - /// completes -- unless [`Self::set_external_shell_widget_selection`] supplies a selection in - /// the meantime. Returns `true` if the command was started. - /// - /// When `capture_cursor` is set, the caret is restored on cancel. Combined with Replace, the - /// command also gets `{char_cursor}:{hex_draft}` so a fish file widget can seed itself. Hex - /// keeps the draft a single token, and combining it with the cursor keeps an empty draft from - /// vanishing under shell word-splitting. `char_cursor` converts the captured byte offset to - /// characters because fish's `commandline -C` takes characters. - /// - /// The command is prefixed with a leading space, honoring the "ignorespace" convention that - /// bash/zsh support and atuin explicitly implements itself (independent of the shell's own - /// history settings -- see atuin's docs on excluding commands). Our own per-shell exclusions - /// (zsh's `_warp_zshaddhistory`, bash's `HISTIGNORE`, fish's `fish_should_add_to_history` - /// wrapper) only ever stopped the *shell's* history file from recording this invocation. - /// atuin records through its own preexec hook straight into its own database, which none of - /// that touches, so without this it would otherwise show up in the very history list this - /// feature exists to search. + /// Runs `helper_command` (a bootstrap-installed shell function), snapshotting the current + /// buffer so it can be restored once the command's block completes. Returns `true` if the + /// command was started. pub fn trigger_external_shell_widget_handoff( &mut self, helper_command: &str, @@ -7719,6 +7703,7 @@ impl Input { .end_byte_index_of_last_selection(ctx) }); let block_id = self.model.lock().block_list().active_block_id().clone(); + // Prefixed with a leading space, the "ignorespace" convention. let mut command = format!(" {helper_command}"); if let Some(cursor_offset) = cursor_offset && apply_mode == ShellWidgetApplyMode::Replace @@ -7726,11 +7711,10 @@ impl Input { let char_cursor = original_buffer[..cursor_offset.as_usize()].chars().count(); command.push_str(&format!(" {char_cursor}:{}", hex::encode(&original_buffer))); } - // Not a command the user ran: Warp's history is independent of the shell histfile. let started = self.try_execute_command_from_source( &command, CommandExecutionSource::User, - false, + false, /* should_add_command_to_history */ ctx, ); if started { diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index ac621c50a64..ce450b5d9ff 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -9221,14 +9221,6 @@ impl TerminalView { /// [`EXTERNAL_CTRL_R_HISTORY_PLUGIN_TAG`] shell plugin tag, e.g. by fzf or atuin), hands the /// keypress off to that widget instead of opening Warp's own command search. /// - /// The handoff runs a bootstrap-installed helper through the normal command-execution path - /// (as if the user had typed and submitted it), so the existing long-running-command - /// machinery hides the input editor and forwards keystrokes to the widget's PTY-driven UI. - /// The command the user selects (or the buffer they had before ctrl-r, if they cancel) is - /// restored into the input editor once the helper's block completes; see - /// [`Input::trigger_external_shell_widget_handoff`] and - /// [`Input::set_external_shell_widget_selection`]. - /// /// Returns `true` if the handoff was triggered, in which case the caller should not open /// Warp's command search. pub fn maybe_trigger_external_ctrl_r_history_search( @@ -9259,7 +9251,7 @@ impl TerminalView { input.trigger_external_shell_widget_handoff( EXTERNAL_CTRL_R_HELPER_COMMAND, ShellWidgetApplyMode::Replace, - false, + false, /* capture_cursor */ ctx, ) }) @@ -9311,7 +9303,7 @@ impl TerminalView { input.trigger_external_shell_widget_handoff( EXTERNAL_CTRL_T_HELPER_COMMAND, apply_mode, - true, + true, /* capture_cursor */ ctx, ) }) diff --git a/app/src/workspace/action.rs b/app/src/workspace/action.rs index 35cd5664273..5c0fa344582 100644 --- a/app/src/workspace/action.rs +++ b/app/src/workspace/action.rs @@ -142,25 +142,14 @@ pub enum WorkspaceAction { RenamePane(PaneViewLocator), ResetPaneName(PaneViewLocator), RenameActiveTab, - /// Renames the focused pane in the active tab. Mirrors `RenameActiveTab` - /// so the action is reachable from the binding registry / Command Palette - /// (see #9351). The context-menu path keeps using `RenamePane(locator)`. RenameActivePane, SetActiveTabName(String), CycleActiveTabColor, - /// Sets the manual color override for the active tab. - /// - /// - `Color(_)` — apply that color. - /// - `Cleared` — explicitly clear (suppresses any directory default). - /// - `Unset` — remove the manual override (lets the directory default apply, if any). SetActiveTabColor(SelectedTabColor), ToggleTabRightClickMenu { tab_index: usize, anchor: TabContextMenuAnchor, }, - /// Toggles the multi-tab selection right-click menu. - /// Dispatched by the UI when the right-clicked tab is part of a multi-tab - /// selection (cmd-click or shift-click). ToggleTabSelectionRightClickMenu { tab_index: usize, anchor: TabContextMenuAnchor, @@ -188,9 +177,6 @@ pub enum WorkspaceAction { ToggleTabGroupCollapsed(TabGroupId), /// Opens an inline editor over the given group's header for renaming. RenameTabGroup(TabGroupId), - /// Cancels any active rename (tab, pane, or group) without committing the - /// new name. Dispatched when clicking on the vtab panel background while a - /// rename editor is open. CancelActiveRename, /// Creates a new tab group containing the tab at the given index. NewTabGroupFromTab(usize), @@ -211,8 +197,7 @@ pub enum WorkspaceAction { ToggleTabMultiSelection { locator: PaneViewLocator, }, - /// Clears the tab multi-selection. Dispatched from the UI when the user takes - /// an action that should cancel any active selections. + /// Clears the tab multi-selection. ClearTabMultiSelection, /// Creates a new tab group from the current tab multi-selection. NewTabGroupFromSelectedTabs, @@ -340,8 +325,6 @@ pub enum WorkspaceAction { color: AnsiColorIdentifier, tab_index: usize, }, - /// Toggles the color for a tab group. Clears the color if it was already - /// set to `color`; otherwise applies `color` as the uniform group color. ToggleTabGroupColor { color: AnsiColorIdentifier, group_id: TabGroupId, @@ -355,9 +338,6 @@ pub enum WorkspaceAction { ClickedAIAssistantIcon, ToggleKeybindingsPage, ShowCommandSearch(CommandSearchOptions), - /// If the active session's shell has rebound ctrl-t to an external file-search widget - /// (e.g. fzf), hands the keypress off to it. A no-op otherwise -- unlike `ShowCommandSearch`, - /// there's no Warp-native ctrl-t UI to fall back to. TriggerExternalCtrlTFileSearch, CreatePersonalNotebook, ImportToPersonalDrive, @@ -395,8 +375,7 @@ pub enum WorkspaceAction { ToggleLeftPanel, /// Toggles directly to the Warp Drive tab of the left panel in Code Mode V2 ToggleWarpDrive, - /// Unconditionally opens Warp Drive. This is used in the case of user lifecycle - /// events like new user onboarding or when the user joins a team. + /// Unconditionally opens Warp Drive. OpenWarpDrive, /// Toggles the right panel. This happens as an explicit action from the user. ToggleRightPanel, diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index 0cdb1c4502e..62335d0b8f0 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -17297,10 +17297,6 @@ impl Workspace { return; } - // If the active session's shell has rebound ctrl-r away from its default history - // search (e.g. to fzf or atuin), hand the keypress off to that widget 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| { @@ -17388,10 +17384,7 @@ impl Workspace { } /// If the active session's shell has rebound ctrl-t to an external file-search widget - /// (e.g. fzf), hands the keypress off to it. Unlike [`Self::show_command_search`], ctrl-t - /// has no Warp-native UI to fall back to, so when the handoff doesn't trigger (feature - /// off, no matching shell plugin, alt-screen active, or the helper failed to start), the - /// raw keystroke is forwarded to the pty instead of being swallowed. + /// (e.g. fzf), hands the keypress off to it. fn trigger_external_ctrl_t_file_search(&mut self, ctx: &mut ViewContext) { if self.is_readonly_shared_session_active(ctx) { return; diff --git a/app/src/workspace/view_tests.rs b/app/src/workspace/view_tests.rs index 2d8ebcb9478..3522f311f7f 100644 --- a/app/src/workspace/view_tests.rs +++ b/app/src/workspace/view_tests.rs @@ -1860,12 +1860,6 @@ fn test_workspace_sessions_retrieves_tabs() { }); } -/// `WorkspaceAction::TriggerExternalCtrlTFileSearch` has no Warp-native UI to fall back to, so -/// when no external ctrl-t widget is detected (the default for a freshly created session, since -/// no `external_ctrl_t_file` shell_plugins tag has been reported), the action must forward a -/// plain ctrl-t byte to the pty rather than silently swallowing the keystroke. Dispatches the -/// real action through `Workspace::handle_action` -- not the terminal-view method directly -- -/// so that deleting the production fallback would make this test fail. #[test] fn ctrl_t_action_forwards_to_pty_when_no_external_widget_detected() { App::test((), |mut app| async move { diff --git a/crates/warp_features/src/lib.rs b/crates/warp_features/src/lib.rs index 959946b2fcd..18dcde7ac97 100644 --- a/crates/warp_features/src/lib.rs +++ b/crates/warp_features/src/lib.rs @@ -977,11 +977,7 @@ pub enum FeatureFlag { /// signaled or torn down. CtrlCCancelsThirdPartyHarness, - /// Prototype: when the active session's shell has rebound a key (ctrl-r, ctrl-t) away - /// from its default line-editor binding to an external tool's widget (e.g. fzf or - /// atuin, detected during bootstrap and reported via a per-binding shell plugin tag - /// such as `external_ctrl_r_history` or `external_ctrl_t_file`), hands that keypress - /// off to the tool's widget instead of Warp's own UI for it. + /// Uses fzf or atuin for history search instead of Warp's command search. ShellWidgetHandoff, /// Attaches process-tree liveness signals to long-running command