Skip to content

Native shell completions: drive all shells through in-band generators - #15294

Draft
warp-agent-staging[bot] wants to merge 22 commits into
factory/zsh-compadd-describe-flag-fixfrom
factory/native-shell-completions-generator
Draft

Native shell completions: drive all shells through in-band generators#15294
warp-agent-staging[bot] wants to merge 22 commits into
factory/zsh-compadd-describe-flag-fixfrom
factory/native-shell-completions-generator

Conversation

@warp-agent-staging

@warp-agent-staging warp-agent-staging Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Computer-use video recordings

View video recording - Screen recording of native shell completions working in zsh on the final PR head: typing `git ch` key by key with the completion menu appearing, then `echo hi` producing exactly one block, then `ls --col`.

Computer-use screenshots

  • PowerShell Get-ChildItem - — parameter completions with type annotations, from the pass on 92e615d3; the PowerShell path is untouched by later commits.
  • fish git ch — matches with descriptions on f50ff27e, output area clean.
  • zsh git ch — menu open with the output area clean on f50ff27e.
  • bash ls --col--color, --color=, no descriptions, matching bash's own compspec output.

Plans: none — this change was tracked in Linear rather than a plan document.

Summary

Implements in-band, generator-based native shell completions for all four shells (zsh, bash, fish, PowerShell), replacing the zsh-only, key-triggered Ctrl-Y/OSC 9280 round trip described in CORE-3794. It is stacked on #15313, which fixes CORE-3795 (the compadd shim's -d flag lookup missing _describe's clustered -ld) and must merge first, since the zsh path here depends on descriptions being resolved correctly.

Stack

  1. #15313 — the compadd shim description fix (CORE-3795). Merge first.
  2. this PR — generator-based native shell completions for all four shells (CORE-3794), based on that branch.

This PR targets factory/zsh-compadd-describe-flag-fix rather than master, so its diff shows only the completions work. The CORE-3795 fix and the review history relating to it, retained below, now live in #15313.

The feature remains behind FeatureFlag::NativeShellCompletions (off on every channel) and the ForceNativeShellCompletions private pref; neither is promoted by this PR.

This PR has been through one round of review. All findings below were fixed and re-verified empirically; see "Response to review" for what changed and why.

Mechanism per shell

All four shells now compute completions for an arbitrary command line in the user's own live session and emit them through the existing OSC 9280 "completions" wire protocol (\e]9280;A;incrementally_typed\a, then \e]9280;C;<match>\a and optionally \e]9280;D?description;<description>\a per match, then \e]9280;B\a). This protocol was already fully implemented on the Rust side (ansi/mod.rs, terminal_model.rs, completions.rs) and shell-agnostic, so no client-side parsing changes were needed for bash/fish/PowerShell — reused instead of inventing a second wire format.

  • zsh (zsh_body.sh): select is the only builtin that lets an ordinary command reach a real ZLE completion context (entersubsh() nulls shout/clears USEZLE for any subshell, $( ), pipeline segment, or backgrounded job — see Src/exec.c:1156,1205). So this cannot go through the existing (backgrounded) warp_run_generator_command; it's a new foreground-only entry point, warp_run_generator_command_foreground_completions <hex-encoded line>. It probes ZLE capability and the decoded line for emptiness up front (zero matches, not an error/hang, in either case), then temporarily takes over the zle-line-init widget for exactly the one select iteration it drives: it saves whatever was bound there before (by widget name, via zle -A/zle -N, not functions[...] — see "Response to review" for why that distinction matters), installs its own capture widget, runs { select _ in 1; do break; done } 2>/dev/null, and restores the prior binding immediately afterward, unconditionally. The capture widget chains to whatever it replaced, sets BUFFER, invokes the existing warp_complete_via_compadd_override_internal widget (unchanged — the compadd shim + warp_main_completer/_generic), then submits a throwaway single-space buffer via accept-line. The DCS bracketing already used elsewhere in the bootstrap swallows part of the select redraw (not all of it — see the known limitation below). This request is synchronous and can't use the async cancel-by-PID machinery — same as the widget it replaces, not a regression.
  • bash (bash_body.sh): warp_run_generator_command_native_completions <hex-encoded line> resolves complete -p <cmd>, lazily loads the compspec via whichever of _comp_complete_load/_comp_load/_completion_loader bash-completion exposes, synthesizes COMP_WORDS/COMP_CWORD/COMP_LINE/COMP_POINT/COMP_TYPE=9/COMP_KEY=9 as locals (dynamically scoped, so the compspec function sees them exactly as bash's own real completion machinery presents them, and they vanish again on return rather than leaking into the user's session), and calls the -F function directly (not compgen -F, which warns and returns unfiltered results). Names only — bash has no description channel. Deliberate simplification: word-splitting uses read -ra (with IFS forced to bash's default, independent of the session's actual $IFS) rather than eval, so a partially-typed, unbalanced quote or embedded $( ) in the line under completion can never be executed; the observed tradeoff is that a quoted argument containing a space yields zero matches rather than the differently-split matches bash's own tokenizer would produce.
  • fish (fish.sh): warp_run_generator_command_native_completions <hex-encoded line> calls complete -C "<line>" (the same entry point crates/warp_terminal/src/shell/mod.rs already uses for executable discovery), which returns match<TAB>description pairs directly.
  • PowerShell (pwsh.ps1): unlike the other three shells, this never executes anything as a command at all (see "Fifth round" below for why that turned out to be necessary, not just cleaner). A dedicated PSReadLine key handler (Alt+3) reads the hex-encoded line directly out of the input buffer via GetBufferState, decodes it, calls [System.Management.Automation.CommandCompletion]::CompleteInput($line, $line.Length, $null), and reverts the buffer — never AcceptLine. Multi-line parameter-set tooltips are collapsed to one line for display.

All four functions hex-decode their sole argument (warp_hex_decode_string / Warp-Decode-HexString, mirroring the existing warp_hex_encode_string) instead of shell-quoting it. The client (native_shell_completions.rs) hex-encodes the buffer text on the way out, so the argument only ever contains [0-9a-f] and needs zero shell-specific quoting.

The generator function names for zsh/bash/fish all start with/contain warp_run_generator_command, so each shell's existing history-exclusion and generator-cancellation checks (_is_warp_generator_command, HISTIGNORE) recognize them without any changes to that logic. PowerShell has no generator function name to recognize at all here, for the same reason it never executes anything as a command.

What was removed vs. kept

Removed (the old zsh-only trigger/round-trip, and one dead path it exposed):

  • NativeShellCompletionsState::AwaitingPrompt and the whole enum (pty_controller.rs)
  • The SendCompletionsPrompt event end-to-end: Event::SendCompletionsPrompt, ModelEvent::SendCompletionsPrompt, TerminalModel::send_completions_prompt, the ansi::Handler::send_completions_prompt trait method, the OSC 9280;P dispatch arm, and the view.rs match arm
  • The write-blocking clause in PtyController::can_write_to_pty
  • The Ctrl-Y write and the warp_complete_via_compadd_override wrapper widget + its bindkey '^Y' (zsh)
  • New in response to review: the ^X/list-choices path (warp_complete_via_list_choices, warp_read_completion_buffer, its zle -N/bindkey '^X' registrations, and its zstyles). This was already unreachable before this PR — nothing on the Rust side has ever written ^X to trigger it (confirmed by grep) — but removing the client's ability to answer the 9280;P read (above) turned it from "unreachable" into "would hang the shell on read ... < /dev/tty if anything ever did trigger it." Removing it was the reviewer's offered alternative to "keep the handler and fix the stale comment"; given the hang risk, removal was the safer choice.

Kept: the OSC 9280 A/C/D/B wire protocol end to end, the compadd shim (whose description fix now lives in #15313) and warp_main_completer/_generic, and the zle -C warp_complete_via_compadd_override_internal list-choices warp_main_completer widget registration.

New: PtyController::run_native_shell_completions now resolves the active session's ShellType, builds the per-shell command via native_shell_completions::generator_command_for, and writes it through the same in-band-command path other generator commands already use (bytes_to_execute_command + start_in_band_command_execution), instead of a bespoke keystroke write. ShellType::supports_native_shell_completions now returns true for all four shells.

Decisions made that the design left open

  • Wire transport: reused OSC 9280 for all shells instead of the generic OSC 9277 in-band-command channel — the smaller diff, and avoids adding a second output format.
  • Argument passing: hex-encoding instead of shell quoting, to eliminate injection/quoting risk across all four shells at once.
  • bash word-splitting: read -ra instead of eval, trading quote fidelity for never executing arbitrary substrings of a partially-typed line (see the exact observed tradeoff above).

Explicitly out of scope

  • Promoting FeatureFlag::NativeShellCompletions or changing its channel gating.
  • A match cap for pathological cases (e.g. a very broad completion context). Flagged to the requester as a policy question; left out of this PR.
  • Full production-quality (e.g. history-aware) alias-to-command completion parity for zsh beyond what a real .zshrc/compinit setup would provide — see validation notes below.

Response to review

An independent review surfaced two critical issues in the zsh path, a Rust queue-draining bug, a regression in the CORE-3795 fix itself, and several empty-input/leakage/portability issues across the other shells. All are fixed and re-verified:

  • zsh hook ownership (critical). The original version installed zle-line-init/zle-line-finish as permanent global functions and captured any prior hook via functions[zle-line-init]. That capture is empty for a widget bound to a differently-named function — exactly what add-zle-hook-widget produces, which is how p10k, zsh-syntax-highlighting, and zsh-autosuggestions all register. So it silently replaced (and never restored) those plugins' line hooks for every zsh user on the channel, completions or not. Fixed by moving the takeover entirely inside warp_run_generator_command_foreground_completions, scoped to the one select it drives: save/restore go through widget names (zle -A, $widgets), not functions[...], so a differently-named bound widget survives intact. Verified with a harness simulating add-zle-hook-widget: the plugin's widget binding is unchanged before and after a completion request, and its function still runs (call counter increments) during the request.
  • Request must always terminate (critical). The armed flag was only cleared inside the capture widget, and the OSC terminator only emitted from inside it; if some other widget ran instead, select would sit at an invisible prompt eating the user's next keystroke, with no timeout on the client side. Fixed: the widget takeover/restoration described above bounds this independently of the flag, and the flag is now also guarded so a repeat firing of the capture widget (observed to happen under select) is idempotent rather than looping. The empty-line and non-interactive/no-ZLE cases now also return immediately with a real (empty) terminator instead of arming anything.
  • Rust queue draining. execute_next_queued_write's is_command check didn't include RunNativeShellCompletions, so it would immediately drain the next queued write into a shell that was mid-select — those bytes would be consumed by the read and lost. Fixed by treating RunNativeShellCompletions like Command there, matching what the removed AwaitingPrompt clause used to prevent.
  • CORE-3795 fix regression. Switching the presence test from (I) to (i) broke it: (i) returns one past the array length (not 0) when nothing matches, so the if (( __d_idx )) guard was true on every compadd call. Reverted to (I), and additionally restricted the search to the same leading flags-only prefix the existing -O/-A/-D check uses, so a real completion candidate that happens to look like a flag (a literal -d/-ld match, e.g. from ls/find) is never mistaken for the flag itself. Verified with isolated unit cases for -ld (clustered), plain -d, and both false-positive shapes.
  • Empty input, everywhere. An empty decoded line used to fall through to each shell's own "complete everything" behavior — measured as thousands of matches dumped synchronously into the user's own shell. zsh and fish now return zero matches immediately on an empty line; PowerShell's decoder now returns '' on empty/missing input instead of crashing on GetString($null), and the whole native-completions function decodes/completes inside a try/finally so the OSC terminator is always emitted even if something throws. Fish's decoder also crashed (a missing-operand test plus a five-line stack trace landing in-band) on a missing/empty argument; both are now guarded explicitly.
  • fish printf portability. Dropping command from command printf (to get \x decoding right on macOS, where the external printf(1)'s %b only understands octal escapes) surfaced that fish's builtin printf doesn't treat a leading -- as an end-of-options marker the way external printf(1) does — it printed the two literal characters instead of anything from the format. Fixed by dropping the now-unneeded -- (the format string is a fixed literal, never user input, so it was never needed for safety here).
  • bash smaller items: read -ra now forces IFS to bash's default rather than trusting the session's value; COMP_POINT is now a real byte count (wc -c under LC_ALL=C) instead of ${#line}'s locale-dependent character count; COMP_WORDS/COMP_CWORD/COMP_LINE/COMP_POINT/COMP_TYPE/COMP_KEY are now local (dynamically scoped, so the compspec function still sees them, but they're gone again the moment the request returns) instead of leaking into the user's global session state on every request.
  • Tests: the hex round-trip test only exercised the hex crate's own encode/decode symmetry, which wouldn't catch a change to the wire format; it now asserts the actual contract (lowercase, unseparated, even-length hex) and decodes by hand the way the four shell scripts do. The queueing test's command assertion compared against generator_command_for's own output (trivially true regardless of correctness); it now asserts the exact literal, keeping the shell_type-from-active-session assertion as the one that matters. Fixed a build error the reviewer's build agent found (ShellCompletion has no PartialEq) by asserting is_empty() instead of == Vec::new().
  • Stale/incorrect comments: removed a comment describing the deleted Ctrl-Y mechanism (referred to code that no longer exists — a stale historical note rather than a description of current behavior), and corrected the DCS-swallowing comment, which claimed the redraw was fully swallowed; measured, it isn't (see below).

Known limitation, reported but intentionally not addressed in this PR: DCS passthrough ends at the first ESC byte, so the bracketing around zsh's select only swallows what precedes zsh's first escape sequence in the redraw, not the whole thing. Whether any of the remainder is visible in the actual app is exactly what the separate visual-verification pass (computer-use) is checking; if it is, that's a follow-up, not a blocker for this PR's mechanism.

Blocking client-integration bug found by in-app verification, and its fix

After the review round above, in-app (computer-use) verification found the feature unusable end to end: typing with native completions on produced a new visible block per keystroke, the real input buffer got truncated/emptied, and the session stopped accepting real commands afterward (Enter submitted empty lines, Up-arrow/Ctrl+U broke). Root-caused to two separate issues, both fixed:

  1. is_in_band_command() didn't recognize the new generator names. It only matched the literal "warp_run_generator_command " / "Warp-Run-GeneratorCommand " prefixes (with a trailing space). All four native-completions generator function names are longer (warp_run_generator_command_foreground_completions, warp_run_generator_command_native_completions, Warp-Run-GeneratorCommand-NativeCompletions) and never matched, so the client classified every completions request as a normal, visible user command — a new block per keystroke. Fixed by relaxing the check to match on the shared prefix alone, the same substring convention the shell scripts themselves already use for this exact purpose (_is_warp_generator_command).
  2. Nothing restored the real input buffer after a request. Running the generator command as a foreground command necessarily kills and replaces the shell's real input buffer (bytes_to_execute_command) to type the invocation and press Enter — required for zsh's select mechanism, and used uniformly across all four shells for consistency. Nothing wrote the user's actual buffer back afterward, so the next keystroke landed on an empty buffer, corrupting every subsequent request and, on Enter, submitting whatever fragments had accumulated. PtyController now tracks the buffer_text a request was computed from and, once results come back (ModelEvent::CompletionsFinished), queues it to the front of the write queue so it's written back to the pty verbatim as soon as the line editor is active again — ahead of anything else queued in the meantime, including a newer completions request for what the user has typed since.

Both fixes are shell-agnostic (the prefix relaxation covers all four generator names; the buffer restoration is generic to RunNativeShellCompletions), so they should equally resolve the failure for bash/fish/PowerShell if those hit the same underlying issue. Added a unit test for is_in_band_command covering all four generator name shapes plus a negative case.

I could not verify this fix live (no computer-use in this environment) — requesting re-verification from the computer-use pass specifically for: no new visible blocks per keystroke, the input line correctly accumulating what's typed (e.g. git checkout --detach), and the session remaining fully functional afterward (Enter, Up-arrow, Ctrl+U). If some residual flicker or latency remains even with these fixes, that would point at the deeper, harder-to-fix architectural tension flagged in CORE-3794's discussion (a foreground command execution cycle inherently happening once per keystroke) rather than at either of these two bugs specifically, and is worth flagging back to the requester as a product-level tradeoff (e.g. debouncing, or reserving native completions for explicit invocation) rather than something to keep patching silently.

Validation

Build: cargo check -p warp (the full app crate, all its GUI/wgpu/winit dependencies) is reliably OOM-killed in this sandbox (~4GB RAM, no swap), even with --no-default-features --features local_fs,local_tty, -j 1, CARGO_INCREMENTAL=0, and -C debuginfo=0 — tried again after the review fixes with the same result. All dependency crates compile first; only the final warp crate itself hits the ceiling. cargo check -p warp_terminal (the much smaller crate covering the ShellType change) passes. The rest of the Rust changes are reviewed carefully by hand but not compiler-verified in this environment. ./script/format ran clean. Could not run cargo clippy or cargo nextest run for the same memory reason. An independent build agent on a 32GB runner reported cargo check -p warp clean as originally authored, with one --all-targets test-only error (fixed here, see above).

Shell scripts: verified empirically, both originally and again after the review fixes, by extracting each function into a standalone harness and driving a real interactive shell under a PTY (zsh 5.9, bash 5.2.21, fish 3.7.0, PowerShell 7.6.5 — all installed in this sandbox), comparing OSC-captured matches against genuine interactive Tab completion for the same line in the same shell process:

  • zsh: git ch → 8 matches with descriptions from _describe (the CORE-3795 case), unchanged before/after the hook-ownership rewrite. A live-only compdef resolves correctly. A simulated add-zle-hook-widget-style plugin hook (a differently-named bound function) is now provably preserved across a completion request: its widget binding and callable function are identical before and after, and it still fires once during the request. Empty line → zero matches immediately, no dump. A live-only alias (as opposed to compdef) didn't resolve to the aliased command's completions in my minimal compinit-only harness — but neither did real interactive Tab for the same alias in the same shell, so this is a property of the harness (no real .zshrc), not a divergence between old and new behavior; flagged rather than claimed as reproduced.
  • bash: git chcheckout/cherry-pick/cherry, matching interactive Tab. Confirmed COMP_WORDS et al. are unset immediately after a request (declare -p fails to find them) — no leakage. Confirmed a custom session IFS (:) produces identical output to the default. Confirmed a quoted argument with an embedded space now yields zero matches (not a crash, not a mis-split). Empty line → zero matches immediately.
  • fish: git ch → matches with descriptions, matching complete -C's own output. Empty hex, missing argument, and any input at all now decode/complete without the stack trace the reviewer measured; confirmed the builtin-printf \x decode produces the exact original bytes.
  • PowerShell: Get-Ch → single match Get-ChildItem, confirmed identical to a real interactive-Tab PTY comparison in pwsh (a correction from my first pass, which incorrectly assumed PowerShell was unverifiable here — pwsh runs fine on Linux). cd /tm/tmp, matching CompletionText exactly (interactive Tab additionally appends / for a directory result, which is PSReadLine's own insertion behavior on top of CompletionText, not part of the completion data). Empty string, missing argument, and malformed (odd-length) hex all now cleanly emit only the start/end OSC markers instead of throwing.

I did not attempt to reproduce or verify visually in the actual Warp app (no computer-use in this environment); that pass is being run separately.

For the visual verification pass

  • Enable the feature: on Linux, set the private pref in ~/.config/warp-terminal/user_preferences.json: {"prefs": {"ForceNativeShellCompletions": "true"}} (create the file/dirs if absent). Restart Warp after writing it. (The real flag is FeatureFlag::NativeShellCompletions in crates/warp_features/src/lib.rs:165, off on every channel — the pref bypasses that.)
  • Build/launch: ./script/bootstrap once, then ./script/run (or cargo run) to build and launch the desktop app. ./script/presubmit runs fmt/clippy/tests if there's enough memory on the runner.
  • PowerShell setup (if the runner needs it installed):
    curl -fsSL "https://packages.microsoft.com/config/ubuntu/$(. /etc/os-release; echo $VERSION_ID)/packages-microsoft-prod.deb" -o /tmp/packages-microsoft-prod.deb
    sudo dpkg -i /tmp/packages-microsoft-prod.deb
    sudo apt-get update && sudo apt-get install -y powershell
    
  • Demonstration command lines per shell:
    • zsh: git ch (descriptions from _describe, the CORE-3795 case). For live fidelity, define a throwaway compdef on a made-up command name in the same session, then complete it.
    • bash: git ch, ls --col (flag completion, no descriptions — bash has none).
    • fish: git ch (with descriptions), cd /et (path).
    • PowerShell: Get-Ch (single unambiguous match + tooltip), Get-ChildItem - (multiple flags + tooltips), cd /tm (path).
  • Specifically worth checking, given the known limitation above: whether any part of the zsh select redraw (beyond what the DCS bracketing swallows) is visible on screen for a moment during a completion request.
  • Fragile spots to expect, so a real bug can be told apart from environment noise:
    • zsh: if nothing happens, check the session is genuinely interactive and TERM != emacs — the generator deliberately reports zero matches rather than hanging otherwise.
    • zsh: user prompt frameworks defining zle-line-init (p10k, autosuggestions, syntax-highlighting, vi-mode) should keep working normally after a completion request — that's the specific thing the critical fix above addresses.
    • bash: only completes commands with an existing/lazily-loadable -F compspec; compgen/-W-only compspecs aren't attempted.
    • PowerShell: cmdlet/parameter completion works out of the box; module completers (posh-git etc.) need the profile to load them.

Second verification round: PowerShell fix, phantom blocks, history/title leaks

The first client-integration fix (above) resolved zsh, bash, and fish end to end, confirmed by the computer-use pass: exact input accumulation, correct menus with descriptions, and normal session behavior (Enter/Ctrl+U/history) afterward. That pass found four more issues, three of them now fixed here, one investigated and explained:

  1. PowerShell was still broken, differently (blocking). The generator invocation was getting appended to whatever was already in the buffer rather than replacing it, and the concatenated garbage was then auto-executed. Root cause: PowerShell's kill-buffer chord (Alt+2, sent as the two-byte sequence ESC '2') requires PSReadLine to disambiguate an escape sequence, and when those two bytes arrive in the same write/read as the command text that follows, PSReadLine sometimes fails to recognize the chord at all — leaving the existing buffer untouched while the (undecoded) command text types on top of it. Confirmed empirically with a PTY harness: sending the chord and the command text as two separate pty writes (even with zero explicit delay between them) reliably fixes it, while a single combined write reliably reproduces the bug. Fixed by splitting PtyController::send_write_to_event_loop's PowerShell writes into two Message::Input calls at the exact byte boundary bytes_to_execute_command already establishes (the kill-buffer bytes, then everything else), via a new split_kill_buffer_write helper with unit tests. The other three shells are unaffected — their kill-buffer byte is a single, unambiguous control character with no escape-sequence parsing involved, so split_kill_buffer_write is a no-op for them.
  2. The buffer restore write rendered as a phantom block. Once the real bug from the first fix was gone, the fix itself introduced a new, purely cosmetic artifact: writing the restored buffer text back to the pty caused the shell to echo it, and the client rendered that echo as a new "background output" block mirroring what was being typed, on top of the live input. Root cause: EarlyOutput's typeahead-vs-background-output classification only recognizes explicitly-registered input (push_user_input) when the shell uses TypeaheadMode::InputMatching (legacy bash only) — for TypeaheadMode::ShellReported (zsh, fish, PowerShell, and most bash), any raw character echo received while no block is running becomes background output, since normal typing for those shells never touches the pty until Enter and this scenario had never come up before. Fixed by adding EarlyOutput::push_expected_echo (and a TerminalModel wrapper), which registers input as expected echo regardless of TypeaheadMode, and changing handle_potential_typeahead to always try consuming it first. PtyController now calls this immediately before writing the restored buffer text back, so the echo is recognized as typeahead (and correctly fed back into the input editor, which is what typeahead is for) instead of falling through to background-output handling. This is additive and touches nothing else: push_user_input and its existing InputMatching-only behavior are unchanged, and nothing else populates the new registration path, so ShellReported-mode sessions behave exactly as before unless something explicitly calls the new method. Added a unit test exercising this for TypeaheadMode::ShellReported specifically (the mode the existing tests show not auto-matching without it).
  3. A stray No such widget `zle-line-init' string, with byte-for-byte identical stale metadata, rode along in the phantom blocks — across zsh, bash, and fish, including runs where zsh wasn't involved at all. The verifier could not reproduce this by hand in the same live session (0 bytes on stderr, balanced widget bookkeeping). Identical stale content appearing across otherwise-unrelated shells is not something a real shell could produce live; it's much more consistent with a restored block from an earlier test pass — before the is_in_band_command fix landed, when generator commands really were visible, ordinary blocks — surfacing again via Warp's session/tab restoration. That block would have satisfied TerminalModel::restored_block_commands()'s filter (which didn't check for in-band commands) and fed straight into the Up-arrow history overlay, which is exactly finding 4 below. Given both findings point at the same restored-block path and the phantom-block mechanism above is now fixed independently, I did not chase this further as a separate live bug; the defensive fix in item 4 should prevent it from recurring regardless of the exact history of any given block.
  4. Generator commands leaking into the client-side Up-arrow history overlay (fixed defensively). TerminalModel::restored_block_commands() filtered restored blocks on is_restored() && !is_background() && state() != DoneWithNoExecution, but never checked is_in_band_command_block() — so a restored, pre-fix generator-command block (see above) would have been included. Added that check. Also added a defensive is_in_band_command check at the top of update_command_history (the ExecuteCommandEvent-triggered path), even though generator commands are never expected to reach it (they're written directly via PtyController, bypassing ExecuteCommandEvent entirely) — cheap insurance against any future code path accidentally routing one through there.
  5. fish sidebar tab title hijacked to show the generator command. Fish's own default fish_title function sets the window title to the currently-running command (truncated to 20 chars) via its own, independent OSC 0/2 title-setting mechanism — entirely separate from Warp's warp_preexec JSON hook, which does already know about in-band commands. Since nothing overrode fish_title, it dutifully showed warp_run_generator_command_nativ… while a completions request was running. Fixed by overriding fish_title in fish.sh to fall back to its own existing "just show pwd" behavior (the same thing it already does for its own fish builtin case) when the command matches the generator-command prefix, otherwise reproducing upstream's exact format (including the INSIDE_EMACS/SSH-hostname handling) unchanged. Verified against the installed fish for a real command, a generator command, fish's builtin case, and no argv at all.

Two settings needed to exercise the feature at all, worth calling out so nobody re-derives them: terminal.input.completions_open_while_typing defaults to false (nothing fires as you type until it's turned on), and a restored tab keeps its original shell regardless of WARP_SHELL_PATH — open a fresh tab and confirm the shell before trusting a per-shell result.

Verification for this round: items 1, 2, and 5 were verified empirically — item 1 via a PTY harness comparing the combined-write (broken) and split-write (fixed) cases for both the Alt+2 chord and a Ctrl+2/NUL alternative I ruled out along the way (also broken, so the fix is specifically about write-splitting, not chord choice); item 2 via a new unit test in early_output_tests.rs exercising TypeaheadMode::ShellReported; item 5 via the installed fish directly. Items 3 and 4 are explained and defensively fixed but not independently reproduced live, since the theory is that they were already stale/historical by the time this round started. I could not run the actual computer-use verification myself in this environment; requesting a third pass to confirm PowerShell now works end to end and that the phantom block and tab-title issues are gone in zsh/bash/fish/PowerShell as applicable.

Fourth round: fixed a regression from the second round, and the fish history leak

The third verification pass (on e44204c) found that the phantom-block fix from the second round introduced a new regression: typing intermittently duplicated the buffer (ggigigigitgigit, compounding, occasionally CPU-pinning the app for 25+ seconds). It also confirmed PowerShell was still broken in the same way, and pinpointed the fish history leak's exact cause.

Root cause of the regression, confirmed (not speculative): push_expected_echo (added in the second round to stop the restore write's echo from rendering as a phantom block) fed the restored text into the same queue push_user_input uses for real typeahead. A match there is surfaced via TerminalEvent::Typeahead, which the input editor consumes with insert_typeahead_text — correct for real typeahead, where the editor lost that text and needs it back, but wrong here: the input editor's own buffer was never cleared in the first place (only the real shell's buffer was, by the kill-buffer+type+Enter cycle). So re-inserting the restored text via the typeahead path duplicated it on top of what the editor already had, and the duplication compounded on the next keystroke's own restore.

Fix: gave push_expected_echo its own backing queue (EarlyOutput::expected_echo, separate from unmatched_input) and a dedicated consume_expected_echo, checked in input()/carriage_return()/linefeed() before the existing typeahead/background-output logic. A match there is now dropped entirely — never surfaced as typeahead, never rendered as background output — which still satisfies the original phantom-block fix's goal without the side effect that caused the duplication. handle_potential_typeahead itself is reverted to its original, pre-second-round behavior. Updated the existing unit test to assert typeahead() stays empty (previously asserted it got populated, which was the bug).

fish history leak, root cause and fix: fish has no configurable history-exclusion mechanism (unlike bash's HISTIGNORE or zsh's hist_ignore_space) — a leading space is fish's only, default, non-configurable way to omit a command from its history file. generator_command_for's fish case never added one. Fixed by adding it, matching the exact convention InBandCommandExecutor::execute_command_internal already uses for the pre-existing warp_run_generator_command mechanism; bytes_to_execute_command's bracketed-paste leading-whitespace preservation (which this depends on) already existed for this exact reason. Added a dedicated test locking in the leading space for fish and confirming the other three shells don't gain one.

PowerShell — not re-attempted this round, per explicit instruction. The orchestrator is taking the underlying design question (whether native completions should fire per keystroke at all, versus only on explicit invocation) to the requester, and asked me to hold off on further chord-level fixes until that comes back, and specifically asked my opinion on an alternative approach: binding a PSReadLine key handler that calls [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState() directly, running CommandCompletion::CompleteInput on that text, and emitting the OSC from inside the handler — no kill-buffer, no command text typed, no Enter, no buffer restore.

I agree this is the right redesign for PowerShell specifically, independent of how the per-keystroke question is resolved. It's structurally the same idea as zsh's select trick (reach the completion engine directly rather than faking a command execution) and it's a more natural fit for PSReadLine's architecture, where a key handler can call .NET APIs directly without any command-execution semantics at all. It would eliminate all three PowerShell failure modes observed so far (the atomicity issue, the no-menu case, and the corrupted-history case) simply by never touching the real buffer, kill-buffer, or Enter — and it already has a working precedent in this same bootstrap script: the existing Alt+1 input-reporting handler does exactly this shape (GetBufferState + Warp-Send-JsonMessage, no command execution) for a different purpose. I have not implemented this, since it was explicitly out of scope for this round pending the design decision.

Fifth round: PowerShell redesign, zsh restore fix, tab title leak, async-path research

PowerShell: redesigned around GetBufferState, no command execution at all

The kill-buffer+type+Enter+restore idiom (shared with the other three shells) turned out to be fundamentally unsafe for PowerShell specifically: PSReadLine doesn't reliably disambiguate the kill-buffer chord when concatenated with what follows it, even split across two pty writes. Rather than continue chasing chord-level fixes, PowerShell's native completions are redesigned around a dedicated PSReadLine key handler (Alt+3) that reads the buffer directly via GetBufferState, computes completions via CommandCompletion::CompleteInput, and reverts the buffer — never AcceptLine. This is structurally the same trick zsh's select uses (reach a real completion context without faking a command), and it eliminates every PowerShell failure mode found in verification at the root, since none of them can occur when nothing is ever typed as a command or submitted:

  • No kill-buffer chord, so no chord-disambiguation race.
  • No command text, no Enter — nothing is ever submitted, so there's no history entry to exclude (the AddToHistoryHandler check is now unused for this path) and no way for it to auto-execute.
  • No buffer restore needed afterward, since the real buffer is never touched by anything but the hex text itself, which is reverted immediately.

generator_command_for's PowerShell case now returns just the hex-encoded buffer text (no function-call syntax). send_write_to_event_loop's handling of RunNativeShellCompletions branches on shell_type: for PowerShell it types the hex text (registered via push_expected_echo so it isn't rendered as a phantom block) immediately followed by the trigger chord, with is_for_command=false and no buffer_text stored for restoration. execute_next_queued_write's is_command gating is now shell-type-aware for the same reason — PowerShell's write never transitions the line editor back to active the way a real command's precmd would, so gating queue draining on it would stall forever.

Verified empirically end-to-end via tmux (a bare, unsized PTY made RevertLine throw — needed a real terminal size): the Alt+3 binding registers correctly (not shadowed by the default DigitArgument binding), Get-Ch decodes and completes to Get-ChildItem with its full description via the same OSC 9280 wire format the other three shells use, the buffer is confirmed empty afterward via GetBufferState, nothing auto-executes, and the session stays fully functional (Write-Host right after runs normally).

zsh: fixed a live "No such widget `zle-line-init'" error

Root-caused with a minimal, isolated repro (a zle-line-init handler that calls accept-line on itself inside a select loop, nothing else involved): deleting the zle-line-init widget via zle -D after that specific pattern corrupts zsh's own internal state for the next interactive prompt read, which then fails outright with the widget error — regardless of whether the widget being deleted is one we bound ourselves or something else. An earlier version of this fix checked ${+widgets[zle-line-init]} before deleting, but that doesn't help: the widget still exists at that point (we're the ones who bound it), so the check is true and the delete still runs, still corrupting the next prompt — confirmed this doesn't actually resolve the error in the common case. Fixed by never deleting zle-line-init when nothing was bound to it before our takeover: the armed-flag-guarded capture widget is left in place instead, which is a transparent no-op for every future firing until the next request re-arms it.

zsh and bash: fixed the tab-title leak

warp_set_title_active_on_preexec (both shells) is a preexec hook that fires for every command, registered before the user's RC files are sourced — so it runs for native-completions requests too, briefly setting the tab title to warp_run_generator_comma.... Neither shell's title hook had the same generator-command exclusion warp_preexec's own PID-killing logic already has. Fixed both to skip title-setting for generator commands, matching the existing convention (zsh reuses _is_warp_generator_command; bash mirrors its own warp_preexec's prefix check).

Async-path research: can bash/fish/PowerShell drop the foreground round trip?

Tested empirically (not reasoned about) whether the three non-zsh shells could compute completions through the existing backgrounded warp_run_generator_command mechanism instead of a foreground one, which would drop the kill-buffer/Enter/restore/block-classification machinery entirely for whichever shells can do it:

  • bash: yes. A backgrounded subshell (( ... & wait ), the exact existing generator pattern) produced identical results to foreground, including a completion registered live in that session via complete -F — confirmed visible in the subshell since it's a true fork of the interactive process.
  • fish: no. fish's generator mechanism spawns a separate fish process (fish can't background functions), not a fork. complete -C there sees persisted/config-file completions fine, but a completion registered live in the current session was invisible to the child process — a real loss of live-session fidelity, which is the entire point of this feature.
  • PowerShell's own existing generator mechanism: also no. Tested the actual [powershell]::Create()/runspace-pool shape from this bootstrap script, not just Start-Job. It's a separate execution context that doesn't inherit live-session state either (it has to explicitly load common functions). This confirms the GetBufferState redesign above is the right call for PowerShell independent of this question.

This is left as a design/scope question for review, not implemented in this PR: bash could move to the async path (dropping the foreground machinery entirely for that shell), but fish and PowerShell cannot without giving up live fidelity, and zsh already can't for the structural ZLE reason explained above.

Fish: confirmed a non-command-execution route exists, not implemented

Per a follow-up question: does fish have an equivalent of zsh's zle widget / PowerShell's PSReadLine handler — something that reads the live buffer and emits completions without ever executing a command? Confirmed empirically that it does: bind can bind a key directly to a fish function (running in the live interactive process, not a child process) that reads commandline (fish's equivalent of $BUFFER/GetBufferState), calls complete -C on it, and returns without ever calling commandline -f execute. Tested via tmux: bound to \ex, it correctly returned git ch's real completions with descriptions, matching standalone complete -C output. (An initial test under fish --no-config returned filename completions instead of git's — a test-harness artifact from skipping fish's own completion autoloading, not a limitation of the mechanism; a normal fish session with completions loaded works correctly.) This would let fish drop the foreground command-execution path entirely, the same way the PowerShell redesign above does — not implemented in this PR, reported as a viable follow-up alongside the bash async-path result.

Sixth round: a correctness cleanup, and two known gaps written down honestly

A fifth verification pass found the requester's own machine reproducing a phantom block containing a single trailing character (e.g. typing starship pr in zsh left a block containing just r), distinct from the earlier phantom-block shapes already fixed above. Investigating it surfaced a real, independent defect in the write queue, which is fixed here — but the requester's own repro (Tab-triggered, a single completions request, on a buffer already fully typed before the request was made) has nothing queued behind that request's restore at all, so this fix does not explain or resolve that specific symptom. It's included on its own merits as a correctness cleanup; the phantom-block investigation itself is ongoing and not part of this update.

The defect: ModelEvent::CompletionsFinished queues the buffer restore to the front of pending_writes and drains it via execute_next_queued_write. That function is meant to stop draining immediately behind a foreground command — RunNativeShellCompletions already gets that treatment for the three shells that run it as one — but the restore write undoes such a command's buffer-clearing effect without being recognized as needing the same protection, since it goes out as a plain PtyWrite::Bytes rather than being tied to the command whose aftermath it's cleaning up. If a newer completions request's own write is already queued behind the restore when the restore drains, the existing recursion sends that newer request's kill-buffer immediately behind the restore, with no gap for the shell to have processed it.

The fix, and a correction along the way: the first shape of this fix I considered was gating execute_next_queued_write's is_command check on the restore write the same way it already gates RunNativeShellCompletions. Writing the actual change surfaced that this would deadlock: that gate's only way of unblocking is the shell's own precmd firing LineEditorStatusEvent::Active again, and nothing about a plain buffer write — no command runs, no prompt cycle happens — ever causes that on its own; every later write would stay queued until an unrelated real command happened to run. Implemented instead as: skip queueing the restore at all when a newer RunNativeShellCompletions request is already waiting behind it, since that request's own kill-buffer is about to clear the line again anyway, making the restore pointless and racy to send. This doesn't touch execute_next_queued_write's draining logic or unblocking condition at all.

Traced every path a superseding request can take to confirm this is safe rather than a new way to lose the buffer (full argument is in the code comment at the skip site, in pty_controller.rs): either the newer request's write never reaches the pty at all (rejected by before_write_fn, or retain-filtered away by a still-newer request while still queued), in which case the real buffer was never touched and there's nothing to restore; or its kill-buffer does go out, at which point it can no longer be retain-filtered, so it will run to completion and fire its own CompletionsFinished, where the same check repeats. That recursion is bounded by real keystrokes, so the first request in the chain that finishes with nothing newer queued behind it has its restore sent — and submitting a command requires the user to stop typing regardless, which is what lets the chain resolve before Enter is reachable.

Two known gaps, written down rather than left to only survive in review discussion:

  1. A pre-existing robustness gap, not introduced by this change: if a request's kill-buffer goes out but its generator command then hangs, crashes, or is interrupted (e.g. Ctrl-C) before emitting the completions-finished OSC marker, CompletionsFinished never fires for it and the buffer is never restored. This was already true before this fix and remains true after it, since the original code also only ever restored on that event firing. Not fixed in this PR.
  2. No test coverage for this dispatch path, at either the old or new behavior. pty_controller_lifecycle_tests.rs has no precedent for driving ModelEvent::CompletionsFinished through the real Event channel ModelEventDispatcher forwards from, synchronously inside App::test — every existing test in that file calls PtyController methods directly instead. I don't have a confirmed way to verify a test sending through that channel would be exercised before an assertion runs, and would rather leave this gap explicit than write a test that passes without actually exercising the code path.

Replaces the zsh-only, key-triggered OSC 9280 round trip with a single
generator-shaped client path across zsh, bash, fish, and PowerShell.

- zsh: a new foreground generator (select can't run through the
  backgrounded warp_run_generator_command) chains the user's
  zle-line-init/zle-line-finish, arms a guard flag, and reuses the
  existing compadd shim's OSC 9280 emission. Also fixes CORE-3795 (the
  shim's -d flag lookup missed _describe's clustered -ld).
- bash: complete -p plus bash-completion's lazy loader, synthesizes
  COMP_WORDS/COMP_CWORD/COMP_LINE/COMP_POINT, calls the -F function
  directly, emits COMPREPLY (names only).
- fish: complete -C "<line>", already used elsewhere in the bootstrap
  for executable discovery.
- PowerShell: [System.Management.Automation.CommandCompletion]::CompleteInput.

Removes the client-side trigger state machine
(NativeShellCompletionsState::AwaitingPrompt, SendCompletionsPrompt,
the write-blocking clause, and the Ctrl-Y write) and reuses the
existing OSC 9280 completions wire protocol for all four shells.

CHANGELOG-NONE
@warp-agent-staging

Copy link
Copy Markdown
Contributor Author

This PR was generated with Warp.

Comment @warp-factory on this PR to send it follow-up work.

View run View conversation

warp-agent and others added 4 commits August 19, 2026 00:03
Two mechanical fixes found by building and running presubmit, neither of
which the author's 4GB sandbox could surface:

- pty_controller_lifecycle_tests.rs: ShellCompletion doesn't derive
  (E0369). Assert the results are empty instead.
- native_shell_completions.rs: the repo forbids inline #[cfg(test)] test
  modules (script/check_no_inline_test_modules). Move the tests to a
  sibling native_shell_completions_tests.rs included via #[path].
…ORE-3795 regression, empty-input handling

Critical fixes:
- zsh: scope the zle-line-init takeover to the single select iteration
  it drives, capturing/restoring by widget name (zle -A/-N) rather than
  functions[...], so a differently-named bound widget (add-zle-hook-widget,
  used by p10k/zsh-syntax-highlighting/zsh-autosuggestions) survives a
  completion request instead of being silently replaced forever.
- zsh: guarantee the request always terminates independently of the armed
  flag by restoring the widget unconditionally right after select returns,
  and make the capture idempotent against repeat firings.
- pty_controller.rs: RunNativeShellCompletions was missing from the
  is_command check in execute_next_queued_write, so the next queued write
  could be drained straight into a shell mid-select and lost.
- zsh: the CORE-3795 fix regressed -- (i) returns one past the array
  length (not 0) on no match, so the presence guard was always true.
  Reverted to (I) and restricted the search to the leading flags prefix
  so a real -d/-ld completion candidate is never mistaken for the flag.

Robustness fixes:
- zsh/fish/PowerShell: empty decoded line now returns zero matches
  immediately instead of dumping every top-level command/file.
- fish: guard missing/empty hex argument in the decoder (was a stack
  trace landing in-band); drop "command" from printf for macOS \x
  decoding, then drop the now-unneeded "--" since fish's builtin printf
  doesn't treat it as an end-of-options marker.
- PowerShell: decode inside try/finally so the OSC terminator is always
  emitted even if decoding or completion throws; empty/missing hex now
  decodes to '' instead of crashing on GetString(null).
- bash: force IFS for read -ra instead of trusting the session's value;
  COMP_POINT is now a byte count, not a locale-dependent character
  count; COMP_WORDS/COMP_CWORD/COMP_LINE/COMP_POINT/COMP_TYPE/COMP_KEY
  are now local so they never leak into the user's session.
- Removed the already-orphaned ^X/list-choices completions path: it had
  no client-side trigger before this PR, and removing the client's
  ability to answer the 9280;P read turned it from unreachable into a
  hang risk if anything ever did trigger it.

Test fixes:
- Fixed ShellCompletion PartialEq build error (assert is_empty() instead
  of == Vec::new()).
- Rewrote the hex round-trip test to assert the actual per-shell decoder
  contract (lowercase, unseparated, even-length) instead of just
  round-tripping through the hex crate.
- Replaced a self-referential command comparison in the queueing test
  with the exact literal, keeping the shell-type-from-session assertion.

All shell-side fixes re-verified empirically via PTY-driven harnesses.
…d the input line

In-app verification found that typing with native completions on produced a
new visible block per keystroke, truncated/emptied the real input buffer,
and left the session unable to submit real commands afterward (Enter
produced empty blocks, Up-arrow/Ctrl+U were broken). Root-caused to two
separate issues, both fixed here:

1. `is_in_band_command()` only recognized the literal
   "warp_run_generator_command " / "Warp-Run-GeneratorCommand " prefixes
   (with a trailing space). All four native-completions generator function
   names are longer ("warp_run_generator_command_foreground_completions",
   "warp_run_generator_command_native_completions",
   "Warp-Run-GeneratorCommand-NativeCompletions") and never matched, so the
   client classified every completions request as a normal, visible user
   command -- hence one new block per keystroke. Relaxed the check to match
   on the shared prefix alone, the same substring convention the shell
   scripts themselves already use for this exact purpose
   (`_is_warp_generator_command`).

2. Running the generator command as a foreground command necessarily kills
   and replaces the shell's real input buffer (see `bytes_to_execute_command`)
   to type the invocation and press Enter -- required for zsh's `select`
   mechanism, and used uniformly across all four shells. Nothing restored the
   user's actual buffer afterward, so on the very next keystroke the shell's
   buffer was still empty, corrupting every subsequent request (a two-
   character buffer produced a truncated completions request, and the final
   Enter submitted whatever fragments had landed on the empty buffer in the
   meantime). `PtyController` now tracks the buffer_text a native-completions
   request was computed from, and once results come back
   (`ModelEvent::CompletionsFinished`), queues it to the front of the write
   queue so it is written back to the pty verbatim as soon as the line editor
   is active again -- ahead of anything queued in the meantime, including a
   newer completions request for what the user has typed since.

Both fixes are shell-agnostic: the prefix relaxation covers all four
generator names, and the buffer restoration is generic to
`RunNativeShellCompletions`, so this should equally resolve the failure for
bash/fish/PowerShell if they hit the same underlying issue.

Added a focused unit test for `is_in_band_command` covering all four
generator name shapes plus a negative case. Could not verify this live (no
computer-use in this environment); requesting re-verification from the
computer-use pass.
…-verification pass

- PowerShell: split the kill-buffer chord (Alt+2, an ESC-prefixed two-byte
  sequence) into its own pty write. PSReadLine sometimes fails to disambiguate
  it when it arrives concatenated with the command text that follows in a
  single write/read, leaving the buffer unclouded and typing the command text
  literally on top of it. Splitting the write (with no explicit delay needed)
  reliably fixes this, confirmed empirically via a PTY harness.

- Fix the phantom block that appeared when PtyController restored the input
  buffer after a native-completions request: the restore write's echo was
  being treated as unexpected background output. Added
  EarlyOutput::push_expected_echo, which registers characters as expected
  echo regardless of TypeaheadMode (push_user_input only does this for
  InputMatching mode), and made handle_potential_typeahead consume it first.
  PtyController now calls this before writing the restored buffer text back.

- Defensively exclude in-band/generator command blocks from client-side
  history: TerminalModel::restored_block_commands() and
  update_command_history() both now skip is_in_band_command-matching text,
  so a generator command can never leak into the Up-arrow history overlay
  (this also plausibly explains the stray "No such widget `zle-line-init'"
  string reported in phantom blocks, which had identical stale metadata
  across unrelated shells -- consistent with a restored, pre-fix block from
  an earlier test run rather than live output).

- fish: override fish_title (which by default shows the currently-running
  command, truncated, in the window/tab title via its own OSC title-setting
  path, independent of Warp's own hooks) to fall back to its own
  "just show pwd" behavior for generator commands, matching upstream's
  format otherwise. Verified with the installed fish for a real command, a
  generator command, fish's own builtin case, and no argv.

@warp-agent-staging warp-agent-staging Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overview

Moves native shell completions onto in-band generator commands for zsh, bash, fish and PowerShell, replacing the zsh-only Ctrl-Y/OSC-handshake trigger. Two blockers remain: in-app verification shows the per-keystroke round trip races the user's typing, and PowerShell does not work at all.

Concerns

  • Per-keystroke firing is racy by construction. Each request kills the shell's real buffer, runs a foreground command, then writes the buffer back; verification found this intermittently duplicating input (ggigi → screen-filling repetition) and pinning the app at 70-130% CPU for 25+ seconds in bash and fish, requiring a force-kill, with the corrupted text submitted to the real shell on Enter. Whether this should fire on every keystroke at all, or only on explicit invocation, is a design decision to settle before further patches.
  • PowerShell is non-functional across two fix attempts. Typing produces merged, auto-executed commands (GeWarp-Run-GeneratorCommand-NativeCompletions 476574) and no completion menu; the Alt+2 binding does register, so the failure is in how the chord bytes are interpreted at the live prompt, and BackwardDeleteLine is not a whole-buffer kill in the first place. Consider a PSReadLine key handler that reads GetBufferState() and calls CompleteInput directly, which needs no buffer kill, no command text, no Enter and no restore.
  • fish generator commands land in fish's own history file, which Warp reads, so they appear in the Up-arrow overlay. bytes_to_execute_command documents that a leading space omits a command from fish history and generator_command_for emits none; zsh and bash are unaffected via hist_ignore_space and HISTIGNORE.
  • Exercising the feature requires two non-default settings, terminal.input.completions_open_while_typing (defaults false) and general.default_session_mode = "terminal". Worth stating in the description so reviewers do not conclude the feature is dead.

Verdict

Checks: build pass, tests pass (this PR's 11 tests; full workspace suite not run), CI skipped (draft), visual proof present

Found: 2 critical, 1 important, 0 suggestions, 0 nits, 1 question

Verified in the running app on Linux across four passes: the shell-side capture is correct in all four shells, including the CORE-3795 description fix. Every remaining failure is in the client-side round trip.

Responding as wilson: Open session · View factory task

…usion

- Fixed the regression from e44204c where typing duplicated the buffer
  (g -> gigi -> gigitgigit, compounding, sometimes CPU-pinning the app).
  Root cause: push_expected_echo populated the same unmatched_input queue
  push_user_input uses, so a restore write's echo was surfaced as genuine
  typeahead via TerminalEvent::Typeahead -> insert_typeahead_text. Real
  typeahead is meant to be inserted because the editor lost that text;
  here the editor never lost it (only the real shell's buffer was
  cleared), so re-inserting it duplicated it on top of what was already
  there, and the duplication compounded on the next keystroke's restore.
  Fixed by giving push_expected_echo its own backing queue
  (EarlyOutput::expected_echo) and a dedicated consume_expected_echo,
  wired into input()/carriage_return()/linefeed() ahead of the existing
  typeahead/background-output logic: a match there is dropped entirely
  now, never surfaced as typeahead and never rendered as background
  output. handle_potential_typeahead itself is reverted to its original,
  unmodified behavior. Updated the unit test to assert typeahead() stays
  empty rather than getting duplicated into it.

- Fixed the fish native-completions history leak: fish has no
  configurable history-exclusion mechanism (unlike bash's HISTIGNORE or
  zsh's hist_ignore_space) -- a leading space is the only, default,
  non-configurable way to omit a command from its history file.
  generator_command_for's fish case never added one, so every request
  leaked into ~/.local/share/fish/fish_history. Added the leading space,
  matching the exact convention InBandCommandExecutor already uses for
  the existing warp_run_generator_command mechanism; the bracketed-paste
  leading-whitespace preservation this depends on already existed in
  bytes_to_execute_command. Updated existing tests for the new leading
  space and added a dedicated test locking in the behavior (and that the
  other three shells don't gain an unwanted leading space).
…leak

The zle-line-init restore's else branch unconditionally ran `zle -D
zle-line-init`, which errors if the widget no longer exists by the time
the select loop returns (e.g. a chained hook that itself rebinds
zle-line-init via add-zle-hook-widget during the chain call). Guard it
with the same existence check the other branch already gets for free
from zle -A's auto-creation semantics.

Also: warp_set_title_active_on_preexec (zsh and bash) is a preexec hook
that fires for every command including generator commands, and had no
exclusion for them (unlike warp_preexec's own PID-killing logic, which
already excludes warp_run_generator_command*). This briefly set the tab
title to 'warp_run_generator_comma...' during every native-completions
request. Fixed both to skip title-setting for generator commands,
matching the existing exclusion convention.
…mand execution

Replaces the kill-buffer+type+Enter+restore idiom for PowerShell with a
dedicated PSReadLine key handler (Alt+3) that reads the buffer directly
via GetBufferState, computes completions via CompleteInput, and reverts
the buffer -- never AcceptLine. This eliminates all three PowerShell
failure modes found in verification (kill-buffer chord atomicity,
buffer concatenation, auto-execution) at the root, since none of them
can occur when nothing is ever typed as a command or submitted:

- generator_command_for's PowerShell case now returns just the
  hex-encoded buffer text, with no function-call syntax at all.
- send_write_to_event_loop's RunNativeShellCompletions handling branches
  on shell_type: PowerShell types the hex text (registered via
  push_expected_echo so it isn't rendered as a phantom block) followed
  immediately by the new trigger chord, with is_for_command=false and
  no buffer_text stored for restoration, since nothing here ever
  touches the real buffer to begin with.
- execute_next_queued_write's is_command gating is now shell-type-aware
  for the same reason: PowerShell's write never transitions the line
  editor back to active the way a real command's precmd would, so
  gating queue draining on it would stall forever.
- Removed the old Warp-Run-GeneratorCommand-NativeCompletions function
  and its module export; PowerShell no longer needs the
  AddToHistoryHandler exclusion either, since nothing is ever submitted.

Verified empirically end to end via tmux (needed a real terminal size --
a bare 0x0-sized PTY made RevertLine throw): the Alt+3 binding registers
correctly, Get-Ch decodes and completes to Get-ChildItem with its
description via the same OSC 9280 wire format the other three shells
use, the buffer ends up empty afterward (confirmed via GetBufferState),
nothing auto-executes, and the session stays fully functional
afterward. Added a unit test for the new dispatch path and updated the
existing generator_command_for tests for the format change.
…ces the error

The previous fix (checking \${+widgets[zle-line-init]} before deleting) does not
actually prevent the "No such widget \`zle-line-init'" error in the common case:
zle-line-init still exists at that point (we are the ones who bound it), so the
elif branch is still true and zle -D still runs, still corrupting zsh's internal
state for the next prompt read. Verified empirically with a minimal repro (select
+ a zle-line-init handler that calls accept-line on itself, then zle -D
zle-line-init) that the elif-guarded delete still reproduces the error, and that
never deleting the widget in the "nothing was bound before" case (only restoring
when something WAS bound) does not.
…tions request

The previous fix (e62d9c3) stopped restoring/deleting zle-line-init
after each select, leaving our capture widget permanently bound to
avoid the 'No such widget' error from a chained hook rebinding it
during the chain call. But it didn't guard the *next* request's own
takeover: on request 2+, zle-line-init is already bound to our own
capture widget, so `zle -A zle-line-init _warp_saved_zle_line_init`
aliases the saved-widget name to itself. The widget's own
chain-to-saved-widget call then recurses into itself indefinitely
(measured: 'maximum nested function level reached'), so it never emits
the OSC terminator or calls accept-line, leaving the select blocked on
a real read forever -- wedging the session (no further menus, Enter
stops working) with nothing visible in the GUI, since the select's
stderr redirect swallows the error.

Fixed by comparing the current zle-line-init binding against our own
widget name before capturing: skip the takeover entirely when it's
already us, since there's nothing new to save.

Verified with the minimum bar requested: at least two (here, three)
consecutive completions requests in one session, both with and without
a simulated chained hook (add-zle-hook-widget style, matching how
p10k/zsh-syntax-highlighting/zsh-autosuggestions register). All three
requests return the correct 8 matches with clean stderr in both
scenarios; the chained hook's call count increases consistently across
requests (confirming the chain keeps working, not just not-crashing);
and the session remains fully responsive to ordinary commands
afterward in both scenarios.
…and a phantom-block race

- last_completed_command_text() (used as the vertical tab's primary label
  fallback whenever the OSC-set title equals the working directory, which is
  the common idle-prompt case) never excluded in-band command blocks, so a
  just-completed native-completions request's full, untruncated command text
  could surface as the tab label. This explains why the leak showed the
  complete command rather than warp_title's 25-char-truncated output, and why
  the earlier zsh/fish preexec-hook guards didn't fully address it: this path
  never went through warp_title in the first place. Fixed by excluding
  is_in_band_command_block() blocks, matching restored_block_commands()'s
  existing filter.

- fish_title's leading-space-defeats-the-match issue (found in the last
  verification round) is fixed by trimming before matching.

- warp_preexec's generator-command-detection guard had the same leading-space
  issue, plus a separate, pre-existing bug: 'test (! string match -q ...)'
  always evaluates false regardless of the match, since string match -q
  prints nothing for '!' to negate via command substitution, and bare 'test'
  with no arguments is false. This meant stale generator PIDs were never
  killed for *any* command, not just ones affected by the leading space.
  Fixed by using fish's own 'not', which negates a command's exit status
  directly, verified empirically for both a real command and a generator
  command.

- Root-caused and fixed the intermittent phantom block containing typed text:
  ModelEvent::CompletionsFinished's buffer-restore write and
  LineEditorStatusEvent::Active's input-reporting-sequence write both
  push_front from the same underlying trigger (the shell returning to a fresh
  prompt after the generator command completes), in an order that depends on
  unrelated PTY buffering/event-processing timing. When input reporting fires
  after the restore, it reports and clears the text just written back,
  producing PTY output push_expected_echo never registered -- rendering as an
  unexpected background (phantom) block. Fixed with a one-shot flag that
  skips re-queueing input reporting immediately after a restore, since it
  would be redundant: the client already knows the buffer's contents.
…ore than once per redraw

Root cause, from PHANTOM_DIAG evidence on a real session: push_expected_echo
registered the restored buffer's characters exactly once, but both zsh's
ZLE and fish's line editor echo that buffer more than once while redrawing
after a native-completions restore -- zsh echoes one character, returns to
column 0, then reprints the whole line; fish echoes the whole buffer,
returns to column 0 (twice), then echoes it again. The one-shot queue had
nothing left to match on the second (or third) copy, so the surplus
characters fell through to an unmatched background block -- frozen at one
character for zsh, since the surplus overwrites in place at column 0 each
cycle, and growing with the buffer for fish, since the whole buffer is
re-echoed and re-mismatched every keystroke.

Fixed by replacing the one-shot VecDeque-based queue with a Vec plus a
match-position index: matched content is never removed, only the position
advances, and a carriage return rearms the position back to 0 unconditionally
(regardless of how much of the current pass matched), since a carriage
return is exactly what precedes each repeat of the echo. push_expected_echo
now replaces the registered content outright rather than appending, since
each restore is an independent echo to expect. Defensively cleared on
precmd too, so a restore whose echo never fully arrives can't bleed into an
unrelated later prompt cycle.

Added three regression tests reproducing both shells' exact echo shapes
from the diagnostic evidence, plus one confirming the precmd boundary.
…t-suite build error

Diagnostics on a real session showed CompletionsFinished (and the
push_expected_echo call it triggers) fires when 9280;B is parsed, which
precedes the shell's own in-band-command precmd DCS -- so precmd normally
lands inside the restore window, not after it. The defensive clear added
alongside the matcher fix wiped a just-registered echo before its own
characters had even arrived in ~5 of 6 restores, which is worse than the
original bug: the whole buffer fell through instead of just a surplus
copy. Removed the clear; push_expected_echo replacing its content outright
on every call already bounds staleness, which was the clear's only
intended purpose.

Replaced the test that encoded the clear's behavior (and so was passing
while asserting the bug) with two tests for the actual invariants: the
registration survives a precmd within the same restore window, and
staleness is bounded by the next push_expected_echo call rather than by
precmd.

Separately, pty_controller_lifecycle_tests.rs used bytes.as_ref() on a
Cow<'_, [u8]>, which is E0283-ambiguous once typed_path is in the
dependency graph (it adds a competing AsRef impl) -- present since the
pwsh test landed, unrelated to this commit's own changes. Switched to
slice indexing, which is unambiguous.
…e current one

Diagnostics showed a third echo shape: fish sometimes returns to column 0
mid-line and continues the same echo from wherever it left off, rather
than restarting from the beginning. The unconditional rearm (reset to 0)
from the previous fix discarded the in-progress match position in exactly
this case, so the continuation was compared against the wrong expected
character and fell through.

Replaced the single match position with a set of live candidate positions
(expected_echo_positions). A carriage return now *adds* a position-0
candidate without discarding whatever was already live, since it isn't
knowable in advance whether a given carriage return means a restart or a
mid-line continuation. Each subsequent character advances every candidate
whose next expected character matches it and drops the rest; a character
counts as expected echo if any live candidate predicts it. This is a
generalization of the two-shape fix, not a special case: with only one
candidate ever live, it behaves exactly as before.

Added tests for the exact split-continuation shape from the diagnostics,
its minimal two-character form, and the ambiguous case where a carriage
return leaves two candidates that both match the same next character.
@warp-agent-staging
warp-agent-staging Bot changed the base branch from master to factory/zsh-compadd-describe-flag-fix August 19, 2026 15:57
`FeatureFlag::NativeShellCompletions` had no corresponding cargo feature, so
the only ways to turn it on were editing `DOGFOOD_FLAGS` or setting the
`ForceNativeShellCompletions` private pref -- unlike every other flag, which
can be enabled per-build with `cargo run --features <name>`.

Declare `native_shell_completions` in the app crate and map it to the flag in
`enabled_features()`, following the same pattern as the neighboring
completions flags. It is not added to any default or channel feature set, so
the flag stays off unless asked for explicitly.
…existing fallback

The requester hit this: in natural-language mode, native shell completions
being enabled anywhere disabled the file-path completions fallback that
AI input mode depends on entirely, and also sent the natural-language
buffer text to the shell for completion, which the shell has no basis to
answer meaningfully.

Confirmed via a git diff against master that this file was never touched
by this PR: the gap already existed for zsh (the only shell native
completions supported before this PR), and was reachable whenever a zsh
session had the feature/pref on while in AI input mode. This PR's
expansion of native shell completions to all four shells, plus the
ForceNativeShellCompletions pref used for testing it, is what made the
requester actually hit it, not a change to this file.

use_native_shell_completions gated fallback_strategy (via a match keyed
only on completions_trigger) and independently gated whether a native
shell completions request was dispatched at all (via a check on
use_native_shell_completions alone, regardless of trigger). Both need
input_type excluded: fallback_strategy's Keybinding/SlashCommandAutoOpen
case needs the same file-path fallback AI mode already used before this
existed, and the native-completions dispatch must never fire for AI mode
at all, on any trigger -- there's no command spec to hand a natural-
language buffer to a shell for.

Extracted the eligibility and fallback-strategy decisions into small pure
functions (should_use_native_shell_completions,
completions_fallback_strategy_for_trigger) so they're directly testable
without driving the full Input view's GUI test harness, and added eight
unit tests pinning every branch, including the exact regression: AI input
mode must keep the FilePaths fallback for an explicit Tab press even when
native shell completions would otherwise be eligible.
… request would immediately undo

Correctness cleanup, NOT a fix for the reported lone-trailing-character
ghost block: the requester's own repro (Tab-triggered, a single
completions request, on a buffer that was fully typed before the request
was made) has no newer request queued behind the restore at all, so this
change cannot explain or fix that symptom. Pushing it on its own merits.

The defect: CompletionsFinished queues the buffer restore to the front of
pending_writes and drains it via execute_next_queued_write. That function
is meant to stop draining immediately behind a foreground command --
RunNativeShellCompletions already gets that treatment for the three
shells that run it as one -- but the restore write undoes such a
command's buffer-clearing effect without being flagged as needing the
same protection, since it goes out as a plain PtyWrite::Bytes rather than
being recognized as tied to a command's aftermath. If a newer completions
request's own write is already sitting in the queue behind the restore
when the restore drains, execute_next_queued_write's existing recursion
sends that newer request's kill-buffer immediately behind the restore,
before the shell can have processed it.

I originally described this fix as gating execute_next_queued_write's
is_command check on the restore the same way it already does for
RunNativeShellCompletions. Writing the actual change surfaced that this
would deadlock: the gate's only means of unblocking is the shell's own
precmd firing LineEditorStatusEvent::Active again, and nothing about a
plain buffer write -- no command runs, no prompt cycle happens -- ever
causes that to fire on its own. Gating on it would leave every write
queued after a restore stuck until an unrelated real command happened to
run, which is worse than the race it would fix.

Implemented instead as: don't queue the restore at all when a newer
RunNativeShellCompletions request is already waiting behind it, since
that request's own kill-buffer is about to clear the line again anyway,
making the restore pointless to send. This sidesteps the race without
touching execute_next_queued_write's draining logic or its unblocking
condition at all -- the newer request proceeds exactly as it would
otherwise, and produces its own, current restore once it completes.

No test added: the existing PtyController test file has no precedent for
driving ModelEvent::CompletionsFinished's dispatch path (via the raw
Event channel ModelEventDispatcher forwards from) synchronously inside
App::test, and I don't have a confirmed way to verify a test exercising
it wouldn't just pass trivially without exercising the code path.
…ssion

A future reader of the CompletionsFinished handler will see a restore
being deliberately dropped and needs the reasoning at the point where
that happens, not only in a review thread.
…eal command starts

HELD, NOT PUSHED: waiting on macOS reproduction to confirm the
trailing-fragment shape before landing it, per explicit instruction.

Verified data from a realistic rc (starship, zsh-autosuggestions,
zsh-syntax-highlighting) showed the previously-suspected mechanisms are
not it: no BUFFER mutation from compadd (ruling out common-prefix
insertion), and the write-ordering skip from 171901e's precondition
occurring without leaking (ruling that out too, and it would leak a
leading fragment, never trailing). The actual shape: after a full pass
already matched the whole registered text, a carriage return is
followed not by another full repeat but by only a short trailing
fragment -- e.g. just the buffer's last character. Seeding only
position 0 on a carriage return can't match a restart that isn't at
the beginning, so the fragment falls through and starts a background
block.

Generalized maybe_rearm_expected_echo to add every position in the
registered text on a carriage return, not just 0. This is deliberately
permissive, documented as such in the updated doc comment: once a
carriage return has been seen, any single incoming character that
occurs anywhere in the registered text is absorbed rather than
surfaced as background output, for as long as the candidate it matched
keeps predicting correctly. Traced this by hand against all six
existing tests in early_output_tests.rs plus a new one pinning the
exact trailing-fragment shape: every existing case either seeds the
same positions as before (no behavioral change) or a superset that
still resolves to the same matched/unmatched outcome.

Being more permissive raised a real safety question: what ends the
window? expected_echo is only replaced by push_expected_echo, and the
defensive precmd clear was removed in 838bac9 because it raced the
restore. With nothing else bounding it, a stale pattern from the last
restore would stay live indefinitely -- and after this change, any
later carriage return (including one from a completely unrelated, later
real command) rearms every position in it again, risking silently
swallowing a character of that command's own echo or output if it
happened to match something in the stale pattern. Traced
start_active_block (BlockList) and confirmed it only calls
reset_user_input, never touching expected_echo/expected_echo_positions
-- so nothing was bounding this. Added
EarlyOutput::reset_expected_echo and call it from start_active_block,
the transition a real user command goes through
(start_active_block_for_in_band_command, what a generator/completions
command uses instead, is intentionally left untouched, since clearing
there would defeat the restore before it ever completes). Added a test
pinning this: after a full restore match, starting a real command, then
a later carriage return and a character that would have matched the
stale pattern's first position -- that character must show up as real
background output, not be silently dropped.

I don't have a way to compile-check or run this test suite in this
sandbox; verified only by hand-tracing the state transitions against
the exact logged sequence, against every existing test's inputs, and
against this new safety property.
…just on carriage return

macOS reproduction (raw PTY bytes, both diagnostic-logged and captured
directly) showed the carriage-return-only rearm from the previous
commit was addressing the wrong rewind entirely for the reported
symptom, and identified a second, wider gap in the same class.

The trigger is terminal.input.honor_ps1 = true (the shell draws its
own prompt rather than Warp). With it off, zsh's redraw is
`s`, CR, then the full reprint -- the carriage-return rearm from the
previous commit already handles that. With it on, the same restore's
redraw is `s`, a literal backspace, then the full reprint -- zero
carriage returns anywhere in the exchange. Since
maybe_rearm_expected_echo is only reached from carriage_return(), and
backspace() previously delegated straight to the background block with
no interaction with expected_echo at all, the reprint's own first
character (checked against whatever position the pre-backspace
character advanced to) had no live candidate that could match it, and
fell through. Measured: 0 backspaces across 16 requests with the
prompt honored off, exactly 1 backspace in each of 10 requests with it
on.

A second, wider gap in the same class: with zsh-syntax-highlighting
loaded, a further redraw pass recolours the already-matched command
word using CUB (cursor-backward, \x1b[<n>D) rather than a carriage
return or backspace. All of the recoloured characters (up to 8 in the
measured case) fell through, rendering a visible block.

Both are rewinds, the same class of event a carriage return is, but
with one difference a carriage return doesn't have: the exact distance
is known (1 for backspace, the escape sequence's own parameter for
CUB), so each live candidate's new position can be computed directly
-- position minus distance -- rather than seeding every position in
the pattern the way the carriage-return case has to (a carriage return
is an absolute jump to column 0, not a relative move, so its distance
back isn't recoverable from the byte alone). Added
EarlyOutput::rearm_after_rewind(distance), called from new backspace()
and move_backward() implementations in EarlyOutputHandler (previously
both blindly delegated to the background block with no interaction
with expected_echo at all). Kept maybe_rearm_expected_echo and its
all-positions behavior for carriage returns unchanged.

Traced both new shapes by hand against the exact measured byte
sequences (an 11-character restore with a leading backspace before the
full reprint; a full match followed by an 11-column CUB and an 8-
character partial recolour reprint) to confirm neither falls through
under the new logic, and added a test for each pinning the exact
shape. Also re-traced all eight prior tests in early_output_tests.rs
(the six from before this fix plus the two added in the previous,
carriage-return-only commit) against the new backspace/CUB paths:
none of them exercise backspace or move_backward at all, so this
change is a pure addition for them -- no behavioral change, and no
regression risk from the new code paths being unreachable in those
tests.

Documented the boundary explicitly in the expected_echo_positions doc
comment, per instruction: carriage return, backspace, and CUB are
handled; other rewind mechanisms -- an absolute cursor move, or other
column-addressing escape sequences -- are not, and would need the same
treatment as backspace/CUB if a redraw shape using one of those ever
surfaces.

Kept reset_expected_echo on the real-command path from the prior
commit unchanged; that gap was independent of which rewind mechanism
triggers the leak and remains fixed the same way.

I don't have a way to compile-check or run this test suite in this
sandbox; verified only by hand-tracing the state transitions against
the exact measured byte sequences and against every existing test's
inputs.
The all-positions widening from an earlier commit was motivated by a
trailing-fragment-after-a-carriage-return hypothesis for the reported
ghost block. That hypothesis was disproven by macOS reproduction: the
actual rewind was an unhandled backspace (or, in a further case, CUB),
not an ambiguous carriage return, and both are now handled by
rearm_after_rewind with their exact, known distance.

Traced both measured shapes against position-0-only carriage-return
rearm plus the new distance-based rearm: neither depends on the
carriage-return path being widened, since neither shape involves a
carriage return at all. The all-positions widening was therefore never
actually needed to explain a real, measured symptom -- it was carrying
risk (a materially wider blast radius for whatever stale-pattern gap
existed) without a corresponding benefit. Reverted to the original,
narrower rule.

Removed the test written for the disproven hypothesis
(test_push_expected_echo_survives_a_carriage_return_followed_by_only_a_trailing_fragment):
retraced it against the reverted logic and confirmed it now fails
(a bare trailing fragment after a carriage return, with no backspace
or CUB involved, has no evidence of occurring in a real session and
is exactly the kind of shape this revert is meant to stop guessing
at). Re-traced all other existing tests, including the two added for
backspace/CUB in the prior commit, against the reverted carriage-return
logic: none of them depend on the widening, so no other test changes.

Also verified via `git show <sha>:app/src/terminal/model/blocks.rs`
that `reset_expected_echo`'s call site in `start_active_block`
(added two commits ago) is present in the already-pushed history --
it was reported as missing, but grepping the actual committed blob
content at the pushed SHA (not just the local working tree) confirms
it is there. No change needed for that; flagging in this message
since it was raised as a blocking concern.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant