Skip to content

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

Draft
warp-agent-staging[bot] wants to merge 48 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 48 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.

Current status: known issues and limitations

This PR has been through eighteen rounds of iterative fixes as of 8c2858c (full history below, kept as the evidence behind each one -- not required reading to review the current state). This section is what a reviewer actually needs.

Feature gating, unchanged: still behind FeatureFlag::NativeShellCompletions (off on every channel) and the ForceNativeShellCompletions private pref. Neither is promoted by this PR.

Tracked, deferred issues -- each is a real, confirmed bug, not fixed in this PR, and each has its own Linear issue:

  • CORE-3799 -- committed block headers and tab titles get corrupted on Linux, one-shot per completions request, under terminal.input.honor_ps1 = true (confirmed one-shot, not cumulative, across five request lengths). The tab-title symptom is confirmed to be the same off-by-N accounting gap as the header corruption, not a separate defect, and should be fixed together with it. Root cause and two candidate fixes are written up in the Tenth round below. Confirmed not reproducible on macOS -- Linux-specific.
  • CORE-3800 -- the completions menu re-sorts matches alphabetically instead of the shell's own best-match order, and duplicate matches from the shell aren't collapsed in the menu. Root cause for the re-sort: completions.rs's From<ShellData> for Vec<ShellCompletion> does an unconditional output.sort_by(|a, b| a.name.cmp(&b.name)), with its own pre-existing TODO predating this PR ("we need to get metadata from the shell about how the results should be sorted"). A client-side menu/data-model fix, not a wire-protocol issue -- the shell already sends its own order and the duplicate on the wire.
  • CORE-3801 -- a wrapped (multi-row) restored buffer leaks its tail into a background block; a stray-block-content-accumulation symptom likely shares this root cause. The leaked content is confirmed to always be the buffer's own trailing characters (the leak's length has varied by head, 1 to 6 characters, consistent with an off-by-a-small-constant rather than a different mechanism). Must be fixed before this feature is promoted to any channel -- a stray block containing a fragment of the user's own text is a genuine data-integrity issue in the user's terminal output, not a cosmetic rough edge. Root cause (traced character-by-character against the matcher's own rules), the exact measured leak shape (total - W bytes), and what a real fix needs (threading terminal width/restore-column state into early_output.rs, currently untracked there) are in the Thirteenth round below.
  • CORE-3802 -- accepted completions are inserted with no shell escaping at all (a filename with a space, a semicolon, or parens all insert as literal, unescaped text and either fail or split into two commands). Predates this PR -- insertion has never escaped -- but this PR's own fixes (the ; payload-truncation fix in particular) exposed it more routinely. Same family as CORE-3800/CORE-3801: client-side handling of already-correct results, not a wire-protocol issue. See the Fourteenth round below.

A completions menu opens with no entry preselected on the first Tab, by design (handle_completion_suggestions_results picks Unselected when classic completions are enabled, First otherwise) -- confirmed independently on bash, PowerShell, and zsh. Pressing Enter immediately after the first Tab submits the raw, pre-Tab line rather than the top entry; press Down (or Tab again) first. This single behavior caused three independent verification passes to misreport a working feature as broken, so it's called out here at the top rather than only in the round history.

A cost characteristic worth knowing, not a bug: the native-completions generator is dispatched whenever the feature is enabled, before Warp's own bundled spec results are consulted -- only which result set is used is conditional, not whether the shell pays for a foreground request. So in the flag-only configuration, every keystroke costs a foreground shell round trip even on a command where a bundled spec ultimately wins.

Documented limitations (expected behavior given the mechanism, not bugs):

  • DCS passthrough around zsh's select redraw only swallows what precedes the redraw's first ESC byte, not the whole thing (see the original review round) -- whatever follows is a rendering question for the visual-verification pass, not a mechanism bug.
  • bash has no description channel (COMPREPLY is names only); zsh/fish/PowerShell all resolve descriptions.
  • A live-only zsh alias (as opposed to a compdef) doesn't resolve to the aliased command's completions under a minimal compinit-only test harness -- matches real interactive Tab behavior in the same harness, so this is a harness property, not a divergence from real zsh.
  • A handful of smaller PowerShell findings (menu ordering beyond CORE-3800, __NounName completeness for $_., non-token-end cursor positions producing no menu, a different, still-cosmetic stale as-you-type menu -- see below) are reported but not chased further in this PR -- none looked load-bearing enough to block on, and they need a running app/computer-use to characterize properly, which the verification passes are better positioned to do than static reasoning here.
  • Accepting a completion candidate that contained a control character (e.g. a filename with an embedded newline) strips that character before insertion (see the Fourteenth round), so the resulting text no longer matches the real file -- running it fails with a real but confusing "path does not exist" error, naming a path the user never actually chose. The right long-term fix is at display time (don't offer a candidate the editor can't faithfully insert), not implemented here.
  • As-you-type completions (terminal.input.completions_open_while_typing = true) had a request-stream gate bug that silently dropped completions for the buffer a fast typist was left looking at -- this is now fixed and verified live (Sixteenth-Eighteenth rounds below); see those for the mechanism and the fix. One separate, smaller as-you-type cosmetic issue remains open: a superseded request's menu can persist briefly on screen instead of closing immediately (clears on the next keystroke either way, and Enter always runs the correct command) -- inferred cause is in the Fourteenth round, not chased further.

Honest gaps in verification: this implementation agent has had no computer-use access for almost this entire PR, so shell-script changes are verified against real installed interpreters (zsh 5.9, bash 5.2.21, fish 3.7.0, PowerShell 7.6.5) driving the actual production entry points end to end, but client-side/UI changes are hand-reviewed rather than run in the actual Warp app unless a separate verification pass (computer-use or a build agent) explicitly confirmed them -- see the screenshots/video linked above for what has been confirmed that way. The full warp/app Rust crate reliably OOMs in this ~4GB sandbox, so almost every Rust change in app/src/... across this PR is hand-reviewed against the exact types/traits/borrow semantics involved rather than compiler-verified; only the small warp_completer crate could be built and tested directly here.

Testing traps found during this PR (read before re-testing any of the above)

Four testing techniques used during this PR's own verification produced false negatives that each cost a full round to catch. Recording them here so the next person doesn't repeat them:

  1. Homogeneous test fixtures can hide a real matching bug. The Ninth round's wrapped-line-leak fix looked complete when tested with repeating text ('a' * N): a wrong candidate position at the wrap boundary still matches by coincidence when every character in the buffer is identical (buffer[W] == buffer[W-1]). With realistic, non-repeating text, the exact same leak reproduced completely unchanged (Thirteenth round). Use non-repeating fixtures for anything that depends on matching a specific position in a buffer, not just matching some character.
  2. The End key commits the inline ghost/autosuggestion into the real buffer. A verification check that presses End to reach "the end of the line" will instead commit and then measure the ghost autosuggestion as if it were real, already-present buffer content. An apparent PowerShell insertion bug was reported, investigated, and retracted this way during the Fourteenth round's verification once this was understood. Don't use End as a "jump to the true end of the buffer" probe without accounting for this.
  3. A replacement span being "some plausible-looking value" is not the same as it being correct. The Ninth round's zsh replacement-span fix was verified by confirming an OSC 9280;S was emitted with numbers that looked reasonable across 8 shapes (including cd /et) -- but never by confirming the span actually covered the same text the real candidates started with. The Twelfth round found that for any multi-component path, the span covered the entire $PREFIX while the real candidates were bare basenames, so every one of those "verified" shapes had silently been broken the whole time; native completions inserted nothing for any of them. Verify a span by checking it against the actual candidate text returned for the same input, not by checking that the numbers alone look sane.
  4. A generic test instantiated with a stand-in type can never exercise the real type's equality. The Seventeenth round's should_retry_as_you_type_completions was unit tested by instantiating its generic parameter with String, and every test passed -- but the actual production caller compared EditorSnapshot values, whose derived PartialEq also compares internal state (buffer_text_runs) that a completions round trip perturbs independently of the visible text and selections the tests exercised. The result: 18 of 23 retries in one live session were redundant duplicates that the passing tests gave no signal about (Eighteenth round). This is the most general of the four traps here: when a generic helper's correctness depends on the equality semantics of whatever type it's called with in production, a test written against a convenient stand-in type proves nothing about that call site -- bind at least one test to the real type, however inconvenient to construct.

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.

Seventh round: root cause and fix for the trailing-character phantom block

The requester hit a phantom block containing a single trailing character (e.g. a block reading just r after typing starship pr in zsh) on his own machine, distinct from the phantom-block shapes already fixed in earlier rounds. Root-caused and fixed via cross-platform reproduction (Linux and macOS) with byte-level diagnostics.

Root cause. EarlyOutput's expected-echo matcher (added in an earlier round to stop the buffer-restore write's own echo from rendering as a phantom block) only rearmed on a carriage return. A line editor's redraw doesn't always rewind with a carriage return, though:

  • With Warp drawing the prompt (terminal.input.honor_ps1 = false), the restored line starts at column 0, so ZLE's rewind is a plain carriage return -- already handled.
  • With terminal.input.honor_ps1 = true (the shell draws its own prompt), the line no longer starts at column 0, so ZLE switches its rewind onto relative motion instead: a backspace for a one-character rewind, or CUB (\x1b[<n>D) for a full-line rewind. Neither was handled at all -- backspace() and move_backward() blindly delegated to the background-output path with zero interaction with the matcher.
  • A further redraw pass (e.g. zsh-syntax-highlighting recolouring the command word after the fact) rewinds with whichever of the above and then skips forward over the unchanged remainder using CUF (\x1b[<n>C) rather than re-echoing it. This was also unhandled, leaving the matcher's live candidate stranded at its pre-move position instead of advancing past what the CUF skipped over -- which mismatches, and leaks, whatever arrives next. This is very likely the actual mechanism behind the reported single trailing character.

Fix. Backspace and CUB now shift every live candidate back by their exact, known distance (EarlyOutput::rearm_after_rewind); CUF shifts every live candidate forward by its own known distance (EarlyOutput::advance_after_forward_move) -- both additive, never discarding existing candidates, the same principle the original carriage-return handling already used. The carriage-return case itself was narrowed back to seeding only position 0 (an earlier attempt widened it to seed every position, motivated by a since-disproven hypothesis that the trailing-character shape was carriage-return-based rather than a rewind with a known distance) -- traced both measured shapes against the narrower rule and confirmed neither needs the wider one.

Also fixed, found while investigating: expected_echo/expected_echo_positions were never cleared when a real command starts, so a pattern left over from the last restore could in principle persist indefinitely across later commands' own carriage returns/backspaces/CUB/CUF, risking a character of unrelated output being silently absorbed if it happened to match something in the stale text. Added EarlyOutput::reset_expected_echo, called from BlockList::start_active_block (never from start_active_block_for_in_band_command, which a generator/completions request's own command uses, since clearing there would defeat the restore before it can complete).

Validation. Cross-platform reproduction with byte-level diagnostics on a scratch branch (not merged into this PR): Linux confirmed the backspace/CUB shapes and the compiled, machine-run early_output test suite (20/20 passing, including a deliberately discriminating test for the CUF fix -- verified failing against the pre-CUF-fix code and passing against the final code); macOS confirmed all three motions firing and being absorbed across 40 restores over four configurations, with reset_expected_echo firing once per command start and real command output remaining intact.

Two honest limitations, not defects:

  • The reporter's exact trailing-character offset was never reproduced on either platform. All three additional motions (backspace, CUB, CUF) are covered and the arithmetic for his reported case works out consistently with the CUF mechanism, but that specific interleaving is closed by construction rather than by direct observation of his exact byte sequence.
  • The stale-registration gap fixed by reset_expected_echo is real by construction, but was never observed actually biting: on the pre-fix build, a stale pattern was consulted 350+ times after its own request had finished, and in every case it only ever matched characters that genuinely belonged to its own restore echo (i.e. it got lucky, not that the gap doesn't exist). Describing this as closing a real, if unobserved, gap rather than fixing something caught in the act.

Separate, pre-existing, cosmetic issue found during this verification (not fixed here): with terminal.input.honor_ps1 = true, the command block's header intermittently drops part of the prompt and splices it into the command text, sourced from the shell's own erase-and-rewind bytes; command execution and output remain correct. Worth a follow-up issue; not addressed in this PR.

Eighth round: cobra description padding, three fish defects, and two zsh gaps in the CORE-3794 mechanism itself

This round's findings are split between defensive hardening for paths the client doesn't currently reach, and two real, previously-undetected gaps in the zsh completion mechanism this PR adds.

bash: cobra-generated completions baked descriptions into the inserted text

cobra's "bash completion V2" scripts (gh, kubectl, and most modern Go CLIs) branch on $COMP_TYPE: under 9 (plain Tab) with more than one match, they bake a padded "name (description)" string directly into the COMPREPLY entry -- safe for real readline, which only ever inserts an entry when it's unique, but not for us, since we display every entry in a menu and insert whichever one is picked. Measured with gh's real completion script: gh pr che under COMP_TYPE=9 returned "checkout (Check out a pull request in git)" verbatim, which would have been inserted as that whole string. cobra's own case statement strips descriptions unconditionally under COMP_TYPE 37 (menu-complete) or 42, regardless of match count (cobra#1508). Fixed by switching COMP_TYPE from 9 to 37 in _warp_native_bash_completions. Confirmed empirically this has no effect on non-cobra completions: bash-completion (which drives the vast majority of scripts, including git's) never reads $COMP_TYPE at all, and _git's completion function produces byte-identical output under COMP_TYPE 9 and 37. Added bash_native_completions_test.sh, a self-contained regression test (synthetic cobra-style and ordinary bash-completion-style functions, no gh/git dependency) covering both shapes; confirmed it fails against the pre-fix COMP_TYPE=9 behavior and passes against the fix. It isn't wired into any CI job -- this repo has no shell-script-level test harness for the bootstrap scripts -- so it's a manually-run regression test for now, not an automated one.

Whitespace-prefix buffer guards: reachable via the cursor, not just a whitespace-only line

fish's native-completions guard (test -n "$line") didn't cover a whitespace-only line, and neither did zsh's ([[ -z $line ]]) -- both would fall through to "complete everything," the same class of bug already fixed for the empty-string case in an earlier round. Measured for fish by calling warp_run_generator_command_native_completions directly against a real fish 3.7.0 session: a single space produced 1007 matches (51KB). Both are now fixed by trimming before the emptiness check. bash and PowerShell were checked and already handle this correctly (bash's derived $cmd comes out empty and returns early; PowerShell's CompleteInput on whitespace already returns zero matches and a negative ReplacementIndex, already documented in pwsh.ps1).

Why this is reachable, not just a theoretical guard: the client sends buffer_text[0..cursor_position] (input.rs:12435), so what has to be whitespace-only for this guard to matter is the prefix up to the cursor, not the whole line. The client genuinely does send an empty-prefix request in normal use: with a non-empty line already typed and the cursor moved to column 0, the app issues RunNativeShellCompletions("") against that non-empty line -- observed directly in the app's own logs, not inferred. This is exactly the case the empty-string guard (from an earlier round) protects against, so that guard is load-bearing in normal use, not paranoid. The whitespace variant (leading spaces, cursor placed after them, then Tab) is the same manoeuvre one column over, and is expected to be reachable the same way, though the exact repro is still being measured in-app as of this writing (an initial attempt was confounded by Warp's Home binding being a smart-home that jumps to the first non-whitespace character rather than column 0). What's already confirmed directly in the app, independent of that measurement: a fully whitespace-only line with the cursor at the end of it does not currently trigger a request at all (1/2/3 leading spaces + Tab with nothing else typed produced no run_native_shell_completions call in the logs, identical before and after this fix, while a real query like star + Tab worked normally in both). Two edges of the whitespace-prefix case remain untested and are named here rather than left implicit: a buffer containing a literal tab character (untestable through the Tab-triggered harness, since Tab is the trigger itself), and whether terminal.input.completions_open_while_typing = true (the as-you-type path, off by default) changes any of the above.

Also fixed in fish: warp_preexec's generator-kill loop had a typo predating this PR -- the loop variable is pid but the kill command used $pids (undefined, expands to nothing), so kill -9 $pids never killed anything. This was previously dormant because of a separate negation bug (test (! ...), fixed in an earlier round) that kept this branch from ever running; fixing that made the typo's effect live for the first time. This one is a real, currently-reachable bug (a stale generator job now survives and keeps running after a real command starts), not defensive hardening. Verified empirically: pre-fix, a background job tracked in _warp_generator_pids survived the kill loop (jobs still showed it running afterward); post-fix, the same job is killed as soon as warp_preexec fires for a real command.

zsh: native completions were silently discarded for any sub-token replacement

The most impactful finding this round: cd /et and echo $HOM showed no menu at all in the app, despite the shell genuinely returning matches (etc; HOME plus several HOMEBREW_*). Root cause: only pwsh.ps1 reports the OSC 9280;S replacement span added in an earlier round, so for zsh the client falls back to a whitespace-derived token guess and filters candidates by requiring them to start with it -- etc doesn't start with the guessed token /et, HOME doesn't start with $HOM, so both were silently dropped. This affects any candidate that only replaces a sub-token of the current word: a path segment after a literal /, a $/${ parameter sigil, or similar.

Fixed by extending the 9280;S span protocol to zsh's compadd override shim: start = ${#line} - ${#PREFIX}, length = ${#PREFIX}, reported once before each match batch. This holds regardless of how $PREFIX arose, because it always carries the literal characters typed for the segment being completed, never an expanded or dequoted form -- verified against 8 shapes with a real zsh session and raw PTY capture of the actual OSC output: cd /et, cd /usr/lo (nested path), echo $HOM, echo ${HO (brace param), foo=/tmp/ (assignment value), cd foo\ ba (literal backslash-escape), cd "foo ba (open quote, correctly excluding the quote character), and cd ~/Doc (literal un-expanded ~). Checked whether bash and fish share this problem instead of assuming: they don't -- both report whole-word candidates (bash's /etc, fish's /etc/) that already start with the client's whitespace-derived guess.

zsh: a zstyle context pattern that never matched anything, silently dropping every _describe-driven description

A second, independent zsh finding: uvx --p and atuin (a real subcommand list) returned matches with every description empty, while the same shell's own interactive listing shows real descriptions. Root cause: the list-grouped/insert-tab/verbose/list-separator styles were registered against ':completion:warp_complete_via_compadd_override:*' -- the name of the widget. But this completion runs from inside the zle-line-init hook, so $curcontext's leading component is always literally zle-line-init (measured, e.g. zle-line-init:complete:atuin:argument-1), never the widget name -- so none of these styles were ever actually applied to any real completion request. With verbose never turned on, _describe-driven completions (the mechanism clap_complete's zsh generator, and many other completion functions, use for subcommand/flag descriptions) never populated a real description array to begin with; the compadd shim's own -d detection was already correct, there was simply nothing for it to find. Fixed by matching ':completion:zle-line-init:*' instead. Verified end-to-end with a real downloaded atuin binary and its actual clap_complete-generated zsh completion script: before the fix, atuin returned 32 matches with every description empty; after, real per-subcommand descriptions come through (confirmed the three that still show empty -- wrapped, config, contributors -- genuinely have no description in atuin's own --help output either). Also confirmed atuin --h, an _arguments-driven flag rather than a _describe one, now correctly returns --help with description "Print help", so the fix isn't limited to one code path.

Not yet addressed, pending further data

  • Large zsh result sets producing no menu at all (a git checkout with thousands of matches vs. smaller sets that render fine) is under active investigation by the requester, bisecting the exact match-count threshold; not addressed in this round pending that number.
  • A predicted, not-yet-confirmed wrapped-line gap: on macOS zsh, a line that wraps uses \x1b[A/\x1b[1B cursor motions (moving between rows) that the linear-character-stream expected-echo matcher from the seventh round doesn't handle -- being verified visually before any fix is attempted, since this is a different axis (row motion vs. in-row motion) from everything fixed so far and deserves to be reasoned about on its own rather than pattern-matched onto the existing rearm helpers.
  • bash COMP_TYPE change not independently re-verified in a working bash-completion environment as of this writing -- my own verification's sandbox initially lacked the bash-completion package; I installed it and re-verified against real gh/git completion scripts (see above), but a separate agent with dedicated bash-completion/cobra/clap/node-provider coverage is checking the blast radius of this change across a wider set of real-world completion scripts, since it now applies to every bash completion request, not just cobra's.

Ninth round: zsh -S suffix dropped, and a wrapped-line redraw leak

Two more zsh-specific findings from continued verification, plus a set of passes worth recording since they cover exactly what earlier rounds were most worried about.

zsh: ls --col inserted a semantically different command

zsh's own listing for ls --col is --color= followed by the value list (always auto never); native completions inserted --color plus a space and offered always as a ghost, so accepting it produced ls --color always -- always becomes a path operand rather than the option's value, changing what the command does. Root cause: compadd's -S suf gives the string it adds after every match, meant to be inserted (_arguments's '--color=-(never auto always)' spec passes -S '=' so the option and value join correctly). The compadd override shim already parsed this correctly into $asuf via zparseopts (confirmed: holds = for this exact case) but the match-display loop only ever appended the directory suffix ($dsuf, from -f) -- $asuf was extracted and then silently never used. Fixed by appending it to each match. $hsuf (from -s) is -S's display-only counterpart and is deliberately left out of the inserted text. Verified with a real zsh session (raw PTY capture of the OSC output): ls --col now emits --color= (was --color); regression-checked git ch and cd /et to confirm completions with no -S suffix are unchanged.

A wrapped-line redraw leaked its tail into a background block

Confirmed on Linux zsh, with an exact rule: once a restored buffer is longer than the terminal width minus the prompt's own width, every byte from the wrap point on leaked into a background block -- exactly total - (columns - prompt_width) bytes in each measured case (125/205/55-character buffers at two terminal widths; clean at shorter lengths and at 20/50/74/80-character buffers that didn't reach the wrap point). This is very likely the true explanation for the trailing-character phantom block from an earlier round, which nobody had reproduced directly, and reaches down to buffers as short as 55 characters at a narrow window width.

Root cause: ZLE's redraw for a wrapped line clears the remainder of the first display row with literal space characters (not an erase-to-end-of-line escape) before moving to the next row with CUD (\x1b[1B). The first such space doesn't match the buffer's own text at that position, so it was treated as the first real mismatch that ends the expected-echo window (existing, documented behavior -- also what makes a stale registration eventually stop absorbing unrelated output). Once the window closes, everything that follows leaks, even though the rest of the redraw (the second row's real characters) does go on to match. move_up/move_down (CUU/CUD) themselves were already harmless no-ops with respect to the window either way -- they're a pure screen-position detail of a redraw already known to be in progress, so leaving them untouched was correct, not an oversight.

Fixed by no longer letting a space end the window on its own, as long as at least one live candidate still expects more of the registered pattern -- deliberately narrower than "the window is open at all," so a stray space received after the pattern is already fully matched still renders normally; only a space received mid-redraw is treated as wrap padding. Added a test reproducing the exact measured byte shape (first-row characters, space-fill, CUD, second-row characters) and confirming it doesn't leak, plus a companion test confirming the narrower scoping doesn't swallow an unrelated space once the pattern is already fully matched.

Not verified against a live build: cargo check -p warp reliably OOMs in this sandbox, as in every prior round of this PR -- reviewed by hand against the exact measured byte sequence and the file's existing test patterns, with the two new tests requesting the verifier's usual full build/test pass.

A second, related-looking finding from the same round, not fixed here: committed block headers and tab titles get corrupted (truncated and fused with command text, e.g. MINRC /workecho HEALTH2-OK) immediately after a completions request, one-shot per request. My hypothesis, explicitly not yet confirmed: the expected-echo matcher's swallow (both the space case above and the pre-existing carriage-return/backspace/CUB/CUF cases) never advances any grid's own cursor-position tracking for the swallowed characters, since they never reach a grid's input() at all. If the same physical redraw is split across this swallow and a later grid that does receive characters, that grid's cursor tracking would end up behind by exactly the swallowed count -- consistent with a fused/truncated header. I have not verified this against the actual grid/cursor code, and a real fix would need to advance cursor state without rendering visible content, which is a larger change than I want to make speculatively without confirmation. Flagging for the verifier to check whether the corruption's byte offset matches the swallowed count, which would confirm or rule this out.

Passes worth recording

From continued verification of earlier-round fixes, since they cover exactly what those rounds were most worried about at the time:

  • No wedge at all with a plugin-heavy rc.
  • A deferred zle-line-init installed by a plugin after our own takeover both survives and keeps firing -- 48 firings logged across a session, including alongside a completions request's own buffer and the real prompt read that follows it, with no nesting error and no "No such widget" error at any point.
  • The shell's history file stayed clean of generator commands and hex across four separate rounds of verification.
  • A live-only compdef and a live-only alias (registered only in the running session, not from any file) both resolved correctly through native completions, confirming results are genuinely coming from the live session rather than a static/cached completion database.

Not a finding: ls --col on macOS returning nothing is correct, since BSD ls has no --color option at all.

Tenth round: a crash fix, three more real bugs, and one deferred to a follow-up

The PowerShell matrix found a crash and two more real bugs; the bash matrix found a live regression in the previous round's own fix. All are fixed except one, which is deliberately deferred.

Crash: PowerShell's UTF-16 replacement-span offsets used as UTF-8 byte offsets

Typing e.g. echo 中 Get-Ch (any multi-byte character before the completed token) and pressing Tab crashed the app -- panic: byte index N is not a char boundary -- reported live twice, once auto-recovering into a fresh session (losing the window/tab) and once taking the window down entirely. Root cause: CommandCompletion.ReplacementIndex/ReplacementLength are .NET UTF-16 code-unit offsets; pwsh.ps1 sent them as-is over 9280;S, and the client slices its UTF-8 buffer directly at those offsets (Span::slice, &source[start..end]), landing mid-character whenever any multi-byte text precedes the token. This was a known, explicitly-named gap in the original PowerShell span commit ("only exact for ASCII lines"), not a new regression -- it just hadn't been exercised with non-ASCII input until this pass.

Fixed at two layers: (1) pwsh.ps1 now converts to UTF-8 byte offsets at the source, using [System.Text.Encoding]::UTF8.GetByteCount() on the relevant substrings of $line -- verified against the installed pwsh for both a CJK character and an emoji (a UTF-16 surrogate pair) before the token, confirming the converted byte range slices cleanly to the token. (2) Span::slice itself can no longer panic on any input, clamping both offsets to the nearest valid char boundary and to the string's bounds -- a wrong menu is recoverable, a panicked window is not. This is the first Rust change in this PR verified by an actual compiler and test run rather than by hand: crates/warp_completer is small enough to build in this sandbox (cargo check/cargo test -p warp_completer --lib meta, 6/6 passing, 5 new tests).

zsh's own replacement span had the same class of bug

${#var} in zsh counts characters, not bytes, while the wire format is byte offsets. Any accented or CJK character before the completed token shifted the reported start left by exactly the extra UTF-8 byte count (ls /tmp/café/xy off by 1, ls /tmp/日本/ni off by 4) -- caught before it could panic anything, since Span::slice's new clamp absorbs it, but still a wrong query and a wrong insertion. Fixed with local LC_ALL=C before the span computation, which makes ${#...} count bytes (confirmed empirically); scoped to the rest of that compadd() call, which is fine since nothing later in it does character counting. Verified against the exact reported shapes with real café/日本 directories in a real zsh session, raw PTY capture of the OSC output, and byte-level slicing of the real line to confirm the corrected offsets land exactly on the real path. Checked bash and fish for the same units question: neither currently emits a 9280;S span at all (their own candidates are already whole-word), so this bug class doesn't apply to them, and bash's separate COMP_POINT computation was already byte-safe from an earlier round.

bash: COMP_TYPE=37 (from the ninth round) regressed make; reverted, cobra padding split instead

The bash matrix verified the cobra fix worked but found make -- the one script out of 841 in a stock bash-completion install that actually reads $COMP_TYPE -- regressed: it branches on $COMP_TYPE to choose a full directory-prefixed path (9) vs. just the next path component (anything else), so make sub/dir/ + Tab returned deploy instead of sub/dir/deploy under 37; since the bare component doesn't contain the typed prefix, the client's own filter discarded it and no menu appeared at all -- silently dead, one level into any prefixed target.

Reverted COMP_TYPE to 9 (faithful to real readline for all 841 stock scripts). To still avoid cobra's own padding, the padded "name (description)" shape is now split apart after the completion function returns -- a name, two or more spaces, then a parenthesised description to the very end of the entry -- into a bare name plus a real description, rather than avoided by changing COMP_TYPE. This gets bash a real description channel for the first time, something neither COMP_TYPE value alone provided. Caught my own bug while implementing: a first version's regex used a greedy name group that absorbed some of cobra's own column-alignment padding (which varies per entry, e.g. checks vs checkout) into the "name" when there were more than 2 spaces; fixed by requiring the name group to end in a non-space character. Verified against real gh, git, and a real Makefile with nested targets. Added a make-shaped fixture and a description-recovery assertion to bash_native_completions_test.sh, and wired it into script/presubmit so this can't regress silently again.

PowerShell: honor_ps1 = true leaked the hex-encoded restore text on every request

Root cause: PSReadLine's redraw always rewinds with absolute cursor addressing (CUP), but the matcher only trusted a CUP to column 0, deliberately conservative pending a real-prompt measurement. That measurement now exists: with a real 29-column prompt, every redraw addresses column 30 (0-based column 29) -- exactly the case the conservative rule declined to trust, converting a hypothetical wrong-position risk into a guaranteed leak whenever the prompt is non-empty. The same measurement gives the fix: PSReadLine always re-renders the entire buffer from its own start, so any CUP means "the buffer's own position 0" regardless of screen column -- column 1 with a zero-width prompt and column 30 with a 29-wide one were both measured to be the buffer's start, and even an "empty" prompt isn't column 0 (PowerShell substitutes its own PS> fallback, measured at column 8). zsh, the only other line editor that matters here, was separately measured to never emit absolute cursor addressing at all for this restore, so widening the rule has no effect on it. Fixed by having goto/goto_col always rearm position 0. Also handled: the echo is cumulative, not one-shot (a 12-character buffer re-echoes as an increasingly long prefix across 12 separate redraws, each with its own CUP) -- already correct, since every CUP occurrence rearms independently, confirmed with a new test.

PowerShell: not yet fixed in this PR

  • Descriptions/matches containing ;, BEL, or ESC corrupt the wire payload. Fixed in the eleventh round below.
  • The menu re-sorts alphabetically, discarding the shell's own best-match order. Found independently in bash and PowerShell: $PSVersionTable.PS + Tab shows PSCompatibleVersions before PSVersion (the shell's own first/best match), and a raw wire order of zeta, alpha, mu renders as alpha, mu, zeta. This changes what accepting the top entry means, cross-shell, and needs a client-side fix (or an explicit product decision to keep it) rather than silence -- flagging for a follow-up.
  • Duplicate matches aren't collapsed -- git checkout mas puts master on the wire twice (bash) and the menu shows two identical rows. Also a follow-up, not fixed here.

Deferred to a follow-up: committed-block-header corruption under honor_ps1 = true

Confirmed, not just hypothesized: the header-corruption finding from the ninth round's PR body is the same mechanism as the wrapped-line leak, confirmed 3 for 3 at three different buffer lengths -- the missing character count from the header equals the preceding completions request's buffer length exactly (13, 14, and 11 characters missing for 13-, 14-, and 11-character requests respectively), corroborated from the unrelated wrapped-line measurement (swallowed = columns − prompt_width, i.e. the window spans exactly the columns the echo occupies on the first row).

Confirmed one-shot per request, not cumulative: re-measured with a fixed 24-character prompt across five request lengths (6, 11, 14, 18, 22 characters) -- each one's own missing-character count exactly matched its own request length (6, 11, 14, 18, 22 respectively), and two no-Tab controls in between returned the full, uncorrupted 24-character header. An earlier-looking 18→13→10→6→2 progression across ascending request lengths was a testing-order artifact, not evidence of a shared, leaking counter. This matters for whichever of the two candidate fixes below gets picked: the fix needs a per-request cursor advance (each request's own swallowed-byte count applied once, to the one grid/header it affects), not a mechanism aimed at resetting some cumulative or leaked state between requests.

Mechanism: characters swallowed by the expected-echo matcher (consume_expected_echo's match, and the space-fill case added this round) never reach any grid's input(), so no grid's cursor tracking advances for them, even though the real terminal's cursor did move by that many columns. honor_ps1 = true is the only mode affected, because that's the only mode where the shell's own prompt is streamed as literal characters with relative-cursor-based redraws (rather than delivered via a hook/DCS) -- a later prompt's own rewind, computed relative to what the real terminal now has, ends up misaligned against what this client's model has, since the model is missing the swallowed columns. Not fixed by the wrapped-line fix in this round, since that fix stops the leak but doesn't add the missing cursor accounting.

Two candidate fixes, deliberately not implemented here (this PR is already well past its original scope, and early_output.rs has been through seven rounds of fixes and multiple wrong attempts at one bound -- a structural change here belongs in its own reviewable change):

  1. Track a virtual cursor offset in EarlyOutput itself and apply it wherever a later prompt's own relative cursor math could be affected.
  2. Don't swallow at all -- render normally into the background block during the window (so the grid's cursor tracking stays correct by construction), then discard/hide that block retroactively if it turns out to be entirely expected echo, using the pending_background_block mechanism this file already has.

Leaning toward (2): it likely also subsumes a separately-found defect (a wrapped buffer plus Tab can leave residue that Ctrl+U doesn't clear, merging two separately-typed commands into one block -- input corruption, not just rendering, on the same wrap path). The question that decides it -- does pending_background_block defer rendering until the window closes, or render immediately and remove later, i.e. would (2) trade the scrollback artifact for a visible flash -- resolves in (2)'s favor: a background block is inserted into the real block list and starts processing real characters immediately on the first one (start_background), but stays at zero height (invisible) until render_delay_complete flips, ~100ms later (BACKGROUND_OUTPUT_RENDER_DELAY_MS) -- a delay that already exists for exactly this reason (to avoid a flicker when typeahead is briefly captured into a background block and then cleared before it would have rendered). A completions round trip finishing within that window, as it normally does, would mean the discarded block never had a chance to become visible.

Also from this round's verification, not yet fully chased: the wrapped-line leak is not honor_ps1-specific after all (it reproduces at honor_ps1 = false too, clearing once a later command runs, vs. persisting permanently at honor_ps1 = true -- the fix should apply to both, but this distinction is worth the verifier re-confirming explicitly in both modes).

Eleventh round: hex-encoding the match/description payloads (the ;/BEL/ESC class from the tenth round)

This closes the one item left "in progress" at the end of the tenth round: OSC 9280's C (match) and D?description params are ;-delimited and the client reads only the third param, so a literal ; inside a match or description truncated everything after it -- corrupting insertion, not just the menu (e.g. a file named semi;colon.txt would insert as the unterminated semi). A BEL or ESC byte in the same text would end the whole OSC sequence outright, before the completions-parsing code even runs at all. Per guidance, this is fixed as a whole class (not just the semicolon case that motivated it), since BEL and ESC are equally reachable through the same filenames/tooltips.

Fix: hex-encode both fields in all four shells, reusing each shell's own existing warp_hex_encode_string/Warp-Encode-HexString helper (already used for JSON hook payloads and the in-progress buffer text, so this is the established idiom for exactly this problem, not a new one):

  • zsh: the compadd shim's match/description display loop.
  • bash: _warp_native_bash_completions's COMPREPLY reply loop (after the cobra-padding split from the tenth round, so the split happens on plain text first, then both resulting pieces are encoded).
  • fish: warp_run_generator_command_native_completions's complete -C loop.
  • PowerShell: the Alt+3 handler's CompletionMatches loop.

Client-side, added decode_hex_completions_payload (ansi/mod.rs) to decode and UTF-8-validate the payload, degrading gracefully -- skip the match, or treat as no description -- on a missing, malformed, or non-UTF-8 payload rather than surfacing a wrong string. The S (replacement span) OSC is untouched; it's just two decimal numbers, no text content, so it was never exposed to this bug class.

Verification:

  • New Rust unit tests for decode_hex_completions_payload covering ;, BEL, ESC, multibyte text, and missing/malformed hex, plus OSC-dispatch-level integration tests in mod_tests.rs exercising the same cases end-to-end through osc_dispatch. ./script/format clean. As in every prior round, cargo check -p warp/app reliably OOMs in this sandbox, so this Rust change is hand-reviewed against the exact Params type (&[&[u8]], confirmed by reading osc_dispatch's own signature and existing sibling code in the same file using the identical params.get(2).map(|osc_data| String::from_utf8_lossy(osc_data)) pattern) rather than compiler-verified.
  • Extended bash_native_completions_test.sh with a semicolon-containing match (semi;colon.txt), and updated its OSC-payload collection helper to hex-decode before comparing against plain-text expectations (all 5 cases, including the pre-existing cobra/make ones, still pass).
  • Empirically verified each shell's actual emission code (the exact lines added to each script, copied verbatim into a standalone harness) against the real installed zsh 5.9 / bash 5.2.21 / fish 3.7.0 / PowerShell 7.6.5 interpreters: a match/description containing ;, BEL, or ESC now arrives on the wire as pure hex digits ([0-9a-fA-F] only) and decodes back to the exact original text, confirmed by capturing and hex-decoding the actual emitted OSC bytes for each shell.

Two nits from the crash-fix's in-app confirmation, folded in here: Span::slice's doc comment attributed the UTF-16-to-byte-offset conversion to "the client-side conversion at the OSC boundary" when it's actually shell-side, in pwsh.ps1 -- corrected. The replacement-span computation in pwsh.ps1 could throw if a shell ever reported ReplacementIndex + ReplacementLength past the end of the line (Substring throws, and the surrounding try/catch would turn that into a silent, warning-free empty completions response); not reproducible with any real CommandCompletion input tried, but clamped to $line.Length defensively anyway -- costs nothing, and a silent empty response is a worse failure mode than a slightly-wrong span. Verified: cargo fmt/cargo test -p warp_completer --lib meta (6/6, unaffected by the doc-only change), and a standalone pwsh script confirming the clamp no longer throws for an out-of-range input while leaving the normal case unchanged.

Correction carried over from the crash-fix's verification: mid-line Tab completion is not a blanket no-op -- it works when the cursor sits at a token end. The "does nothing" cases found so far are all non-token-end positions (mid-token inside a path, before a closing quote, at position 0); this will be stated precisely (not as a general "mid-line completion doesn't work") whenever that investigation is written up.

Twelfth round: zsh path/value sub-token completions were silently inserting nothing (regression)

The zsh matrix proved by execution (Tab, then Enter, then checking what actually ran) that native completions inserted nothing for any multi-component path -- cd /et, ls /tmp/plain/xy, cat ~/.zsh, ls /workspace/warp/cra all ran verbatim with no menu, despite the real shell genuinely having matches (cd /et had previously worked on an older commit, confirming this was a regression from the span work rather than a pre-existing gap). Path completion is the single most common completion there is, so this outranked everything else outstanding.

Root cause, confirmed by instrumenting the real compadd shim in a live, compinit-initialized zsh session (not static reasoning): _path_files -- the completion function behind path arguments for cd/ls/cat/etc. -- restores $PREFIX to the entire remaining path before calling compadd (e.g. /tmp/somedir/et for cd /tmp/somedir/et, not just et), and reports the directory portion separately via compadd's -p flag (parsed by the shim into $hpre back in the ninth round, but never used) purely for display. The real match strings are bare basenames (etc). The ninth round's span computation used the whole $PREFIX, so it reported a span that no real candidate ever starts with (/tmp/somedir/et vs. a candidate of etc) -- the client's own filter then discarded every candidate, which is indistinguishable from Tab doing nothing. The eight shapes the ninth round claimed to verify included cd /et and cd /usr/lo, both of which go through this exact path -- that verification evidently didn't confirm actual insertion correctness against the real match text, only that some plausible-looking span was emitted.

Fix: strip $hpre (and $apre, from -P, handled the same way for symmetry though not directly reproduced) from the front of $PREFIX when it's a genuine prefix, before computing the span -- matching exactly what _path_files itself excludes from insertion.

Verification, against a real zsh 5.9 session, compinit-initialized, driving the actual warp_run_generator_command_foreground_completions entry point end to end and capturing the real emitted OSC bytes:

  • cd /tmp/somedir/et (2-level nested path): span corrected from (3,21) (covering the whole path) to (22,2) (covering just et) -- etc/etcetera now pass the filter.
  • cd /et against a real /etc: span (4,2), one match etc -- the verifier's exact repro, now correct.
  • ls /tmp/plain/xy against two real matching directories: span (14,2), both xyz/xyzzy pass.
  • Regression-checked echo $HOM (a sub-token case that doesn't involve -p/-P, so $hpre/$apre are empty there): unchanged, span (6,3), still matches HOME.

ls --color= value completion (always/auto/never) is not a shell-side bug. The verifier also reported no menu ever appearing for the value after ls --color= + Tab, even though the -S suffix itself (from the ninth round) landed correctly. Tested this exact case against the fix above: the shell-side OSC output is already correct on its own -- span (11,0) (a zero-length span right after the =, matching real zsh) and matches never/always/auto with empty descriptions, exactly mirroring real interactive Tab's own value listing. Since $PREFIX is already empty in this case, $hpre was never the problem here. If the app still shows no menu for this case, the bug is client-side (Rust), not in the shell script -- flagging back rather than claiming a fix for something not actually broken here.

zsh -n syntax check passes. Not verified in-app (no computer-use in this environment); requesting the verifier's zsh matrix be re-run on this commit, specifically re-confirming cd /et, ls /tmp/plain/xy, cat ~/.zsh, and ls /workspace/warp/cra now insert correctly end to end, and separately confirming whether ls --color= + Tab still shows no menu (which would point at a client-side bug worth a dedicated follow-up).

Thirteenth round: the wrapped-line leak is still present, and this round's fix is deferred rather than forced

Cross-platform re-verification found the ninth round's wrapped-line fix insufficient, and root-caused why in a way that points at a real gap in the matcher's design rather than a missed case it can patch around.

The ninth round's own test technique hid this: both that round's and this round's initial local re-check used repeating text ('a' * N), under which the leak looked essentially fixed. With realistic, non-repeating text, the leak reproduces exactly as before the ninth round's fix: a stray block holding the tail, length total − W (the same rule as originally measured), reproduced on a real pty with a 97-character line at 80 columns. Noting this so the technique isn't reused uncorrected: any future local verification of this matcher needs non-repeating fixtures, since a repeating one can make an incorrect match look correct by coincidence (buffer[W] == buffer[W-1] for 'a' * N, so a wrong position still "matches").

Root cause, traced character-by-character against the matcher's own rules (confirmed independently on Linux and macOS): a wrapped redraw doesn't do one clean pass per row. It prints the continuation row's first character, then issues another carriage return and reprints the entire row again from its own start. On the exact measured byte sequence (… mi + space + \r + \x1b[K + k + \r + ke november oscar…): the space is absorbed by the existing fix; the first \r seeds candidate 0 (giving {0, 79}); the lone k matches candidate 79, advancing it to 80 (now expecting buffer[80], which is e); the second \r seeds 0 again ({0, 80}); the reprinted row's first character is k again -- but buffer[80] is e, not k, because the reprint restarts from the row's own start (position 79), not from 80. Neither live candidate (0 or 80) expects k, so this is a genuine mismatch, and it's exactly this reprint's first character that closes the window and leaks the rest -- one character earlier than where the visible leak actually starts, but the same underlying reason in every measured case: total − W.

Why this isn't a small patch: the existing rearm rules (rearm_at_column(0) on \r, rearm_after_rewind/advance_after_forward_move for backspace/CUB/CUF with a known distance) all work because the distance or target position is either always 0 (full restart) or given directly by the escape sequence's own parameter. A mid-redraw \r that restarts the current row doesn't carry its own row-start position in the byte stream at all -- that position (row_index * W for a fixed-width wrap, or W₀ + (row_index - 1) * W accounting for the first row's narrower width under the prompt) has to be computed externally, from the terminal's column count and the column where the restore began, neither of which EarlyOutput currently tracks or receives. Getting this wrong in either direction reintroduces exactly the failure modes the seven prior rounds on this file already fought through (either under-seeding, which leaks, or over-seeding, which risks matching wrong positions in genuinely different output). CUU/CUD were independently re-confirmed as no-ops for this specific mechanism, so this isn't a vertical-motion-tracking problem -- it's specifically about knowing where the current row starts in the pattern.

What a real fix needs: thread the terminal's column count and the column where the restore write begins into push_expected_echo (or a sibling call), then have \r seed a candidate at every row-boundary position implied by those two numbers (0, W₀, W₀ + W, W₀ + 2W, …) up to the pattern's length, rather than only 0. This is new state and a new rearm rule, not an adjustment to an existing one -- exactly the kind of structural change the tenth round's deferred header-corruption fix already flagged as needing its own reviewable change rather than a same-round patch. Deferring this alongside that issue for the same reason, and because they may end up sharing a fix: both are consequences of EarlyOutput's linear character-stream model having no notion of screen column/row at all, which the header-corruption issue's own two candidate fixes already grapple with.

This must be fixed before FeatureFlag::NativeShellCompletions is promoted to any channel. A visible stray block containing a fragment of the user's own text is acceptable to carry behind an off-by-default flag during this PR's iteration, but is not acceptable to ship: it's a genuine, silent data-integrity issue in the user's terminal output, not a cosmetic rough edge.

New, unreported this round: stray-block content appears to accumulate across successive requests rather than being replaced -- a later over-width restore produced a block containing the previous request's leftover leaked fragment concatenated with the new one. This is very likely a direct symptom of the leak above rather than an independent defect: background_block_mut() reuses any existing, unfinished background block, and nothing currently finishes or clears it between two completions requests that both leak into it, so a second leak's fragment naturally appends to the first's. Recorded here rather than chased as its own fix, since it should resolve as a side effect once/if the row-start fix above lands -- worth the verifier explicitly re-checking once it does, rather than assuming.

Confirmed working correctly on this round's re-verification, for the PR record: descriptions (atuin 29/32 non-empty, uvx --p 8/10, starship pr 3/3, --prerelease= keeping its =); multibyte replacement-span byte offsets (ls /tmp/café/xyS;3,13, confirmed correct against a hand-encoded UTF-8 byte count); and the $hpre fix above, independently re-derived and confirmed by a second measurement pass (cd /usr/loPREFIX=[/usr/lo]/hpre=[/usr/]/candidate local; cd ~/DocPREFIX=[~/Doc]/hpre=[~/]/candidate Documents/; echo $HOMPREFIX=[HOM]/IPREFIX=[$], no hpre at all, which is exactly why the $ sigil case never needed this fix).

Platform-specific note for CORE-3799: the committed-block-header mangling (tenth round's deferred finding) does not reproduce on macOS at all, at either short or long command lengths, which narrows that defect to Linux specifically -- worth carrying into whatever follow-up eventually picks up both deferred issues.

Fourteenth round: hex-encoder correctness/performance bugs, cobra splitter false positives, and control characters in accepted completions

Verification of the eleventh round's hex-encoding fix (bash matrix) found real bugs in the fix itself, plus one new, independent issue in accepted completion text.

BLOCKING: bash's and fish's hex encoder destroyed -n/-e/-E candidates, and appended a trailing newline to every payload

warp_hex_encode_string piped its argument through echo in both bash and fish. echo treats an argument that looks like one of its own flags as that flag rather than literal text: measured, echo -n prints nothing, echo -e/echo -E print only echo's own trailing newline. This is not synthetic -- kubectl - offers -n, ssh - offers -n/-e/-E, and npm - offers -n/-E, all as real completions, so those exact flags either vanished from the menu entirely or encoded to a blank row. The same echo also appended a trailing newline to every payload, latent until now since nothing downstream happened to be sensitive to it.

Fixed bash's encoder by rewriting it as a pure-bash, no-fork loop (printf -v byte by byte under LC_ALL=C for byte-safe indexing), which sidesteps echo entirely. Fixed fish's encoder by switching echo to printf '%s' (verified fish's builtin echo has the identical flag-swallowing bug; printf does not). Verified both against -n/-e/-E/multibyte text with the real installed interpreters, and added an exact-byte assertion on bash's encoder (warp_hex_encode_string "checkout" must equal 636865636b6f7574, not that plus a trailing 0a) so a regression here fails a test directly rather than several layers removed from the bug.

bash's fork-per-payload encoder cost ~4x on a large completions result set

Measured directly: echo | od | tr forks two processes per hex-encode call, and a large result set (e.g. git checkout with thousands of branches, each match and description encoded separately) made that cost add up to something perceptible in the app. The same no-fork bash rewrite above eliminates this: benchmarked the encoder alone at 3656 calls (matching the reported scale) at ~4.47s before vs. ~0.19s after, and the full completions pipeline at the same scale at ~1.99s (down from an originally reported ~8.4s).

The cobra description-splitter had two false-positive classes

Two independent bugs in the tenth round's cobra-padding splitter (_warp_native_bash_completions):

  • The split regex's first group was greedy, so a description that itself contained a parenthesised, multi-space-padded aside (e.g. cmd (outer (inner) tail)) backtracked to the last multi-space run in the entry instead of the first, folding part of the description into the name.
  • The regex alone can't distinguish a genuine cobra-padded reply from a real candidate that merely happens to look padded -- e.g. a real file named Backup (copy) -- so a lone such candidate got split too, silently discarding everything after the first (.

Fixed the regex to structurally stop at the first multi-space run (by requiring the name capture group to consist only of single-space-separated tokens, never containing a 2+-space run itself), which needs no backtracking and can't prefer a later split point. Added a structural guard on top: cobra only pads a reply once it has more than one match, and pads every entry in such a reply -- so the split is now only applied when a reply has 2+ non-empty entries and all of them match the padded shape, never for a lone candidate that merely looks padded. Verified with new fixtures for both shapes (a two-entry reply where one description contains nested parens; a single real Backup (copy)-shaped candidate) alongside the existing cobra/ordinary/make fixtures, all 10 passing.

New: a literal control character in accepted completion text could get the input editor stuck

Independent of the wire encoding (which is correct now): once a payload contains a real newline (e.g. from a pathological but legal filename), accepting that completion inserted the literal newline into the buffer. This put the editor into a stuck multi-row state -- Ctrl+U only cleared the current logical row, repeated presses left stale content, Escape reduced it to one row, and Ctrl+C was needed to fully clear -- and, since should_use_native_shell_completions bails on a multiline buffer, silently disabled native shell completions for the rest of that line. A BEL or ESC byte lands in the buffer the same way but is far less disruptive, rendering as a harmless box glyph rather than restructuring the editor.

Treated as one decision about control characters in accepted text, not a newline special case: added strip_control_characters (input.rs), applied to both completion-acceptance insertion paths (insert_completion_result_into_editor and insert_completion_prefix_into_editor). Chose strip over escape: the input editor is shell-agnostic and doesn't know which shell-specific quoting would even make an escaped control character round-trip correctly if the command were actually run, whereas a completion candidate is sourced from the shell's own completion machinery (never typed or pasted by the user), so a raw control character in it was never something the user intended to insert -- unlike a deliberate multi-line paste, which the editor continues to support unmodified (this is scoped to the completion-acceptance path only). Added unit tests per control-character class: newline, carriage return, BEL, ESC, tab, DEL, and a C1 control, plus a check that surrounding multibyte text is left untouched.

Verification: bash_native_completions_test.sh 10/10 passing (including the new -n, exact-byte, nested-paren, and lone-padded-filename fixtures); the bash encoder and cobra-splitter fixes verified directly against the real bash 5.2.21 interpreter and against real gh/Makefile fixtures as in prior rounds; fish's fixed encoder verified against a real fish 3.7.0 interpreter. bash -n/fish -n syntax checks pass; cargo fmt clean on input.rs. input.rs's change is hand-reviewed, not compiler-verified -- the full app crate reliably OOMs in this sandbox, as in every prior Rust change in this PR -- with particular attention paid to Cow<str> borrow/move semantics inside the FnMut closures it's used in, to avoid a move-out-of-captured-variable compile error.

PowerShell in-app verification of this round's commit confirmed the fix at the byte level, not just visually: the stuck multi-row state is gone (a newline fixture now accepts to a single row, one Ctrl+U clears it, and completions keep firing on the same line immediately afterward), and the shell's own history file contains no C0, C1, or DEL byte anywhere across five submitted fixtures (newline, BEL, ESC, tab, DEL -- the last two weren't in the original report but behave identically, since char::is_control covers them uniformly). No regression on any fixture without a control character (semicolon, %20, unicode, space, apostrophe, including PowerShell's own doubled-quote escaping). The honor_ps1 leak (tenth round) also remains fixed after the bash/fish encoder rewrites.

One user-visible consequence of the strip decision, worth stating plainly, confirmed on both PowerShell and bash: stripping a control character out of a path changes what the accepted text names. A file that was genuinely selected from the menu (e.g. nlline-x.txt with an embedded newline on PowerShell, bel<BEL>ring.txt on bash) no longer matches the real filename once the control character is stripped for insertion, so running the completed line fails -- PowerShell: a real but confusing Cannot find path '...' because it does not exist; bash: cat belring.txt -> no such file, where real bash's own Tab inserts the literal byte and the command works. The strip decision stands (the failure this way is loud and local, versus escaping's alternative of the input editor guessing shell-specific quoting it has no way to get right), but do not describe control-character filenames as fully supported -- accepting one now fails loudly rather than corrupting the terminal, which is the tradeoff, not a fix for the underlying case. The better long-term answer is at display time (not offering a candidate the editor can't faithfully insert in the first place), not implemented here.

Two more PowerShell behaviors characterized this round, both by design/explained rather than bugs:

  • No preselection on the first Tab is by design. handle_completion_suggestions_results picks Unselected when classic completions are enabled and First otherwise, so the first Tab opens the menu with nothing highlighted and a second Tab selects the top entry. Worth writing down because "accept the top entry" being a two-Tab operation made several earlier reports read as though a single Tab sometimes did nothing.
  • The stale as-you-type menu (with terminal.input.completions_open_while_typing = true) is reproducible and purely cosmetic -- it clears on the next keystroke and Enter runs the correct command regardless. Leading hypothesis (inference, not measurement): the function that applies completion results early-returns when the buffer or selection has changed since the request was issued, and that early return leaves the suggestions mode as-is rather than closing it, so a superseded request's menu is abandoned on screen rather than dismissed.

bash in-app verification of this round's commit passed all six checks (with PowerShell's pass above, both shells whose paths changed under this round are now confirmed):

  • -n/-e/-E are back everywhere: kubectl - has -n, ssh - runs -m, -n, -o consecutively and includes -e/-E, npm - has both, and a dedicated -n/-e/-E fixture renders with no blank row anywhere.
  • Exact payload bytes clean (checkout -> 636865636b6f7574, no trailing 0a); also confirmed the rewrite's multibyte handling is correct under LC_ALL=C -- 日本語.txt encodes per byte, byte-identical to od (e697a5e69cace8aa9e2e747874), not per code point, which would have been silently dropped client-side as invalid UTF-8 had the locale not taken effect.
  • The performance cliff is gone: four runs at 3,656 matches gave 3343/3550/4050/3922 ms against 8434 ms before -- the residual is attributed to git's own ref generation and machine load, not the encoder, and the wait is no longer perceptible in the app.
  • The splitter's structural guard was tested properly, not just at face value: a two-entry padded fixture (cmd (outer (inner) tail) alongside a second padded entry) splits at the leftmost run as intended, and cat Back against the real Backup (copy) file now completes to the full filename with both spaces preserved.
  • The 18-line provider A/B is byte-identical once decoded; make sub/dir/ and a real gh pr c both regress clean.

New issue filed from this round's bash verification, CORE-3802: accepted completions are inserted without any shell escaping -- cat Back inserts cat Backup (copy) and hits a syntax error, cat semi inserts an unescaped ; and splits into two commands, and a filename containing a space becomes two arguments, all against real bash ground truth that escapes each case correctly. This predates this PR (insertion has never escaped), but was partly masked for the ; case specifically because the payload used to be truncated on the wire before reaching the line (fixed in the Eleventh round) -- fixing the wire truncation exposed the insertion-side gap more routinely. Same family as CORE-3800/CORE-3801 (client-side handling of results), and the issue notes that PowerShell's own candidates already arrive pre-quoted from the shell, so any future client-side escaping fix must not double-apply to them.

Fifteenth round: zsh/Linux final verification, a trailing-space-after-= fix, and a design cost note

The zsh/Linux verification pass concluded, judging results by what actually executes rather than what the menu displays, across six rounds on five different heads.

The Twelfth round's $hpre path fix is confirmed correct by execution

cd /et ran cd /etc (pwd confirmed); ls /workspace/warp/cra listed the real crates; ls ~/.zfun resolved the tilde; multi-candidate cases now show correct menus where they previously showed nothing. Spans read directly from the production entry point all check out, including the multibyte case that exercises the Tenth round's LC_ALL=C fix: ls /tmp/utf8test/café/xy -> S;23,2, where 23 is the correct byte offset on a 24-character line.

Correction to an earlier report: "inserts nothing" was actually "menu opens unselected"

The zsh verifier's own earlier "path completions insert nothing" finding was wrong in its own diagnosis, not just imprecise: the real behavior is that the menu opens with no entry preselected, so pressing Enter immediately submits the raw, pre-Tab line, which looks identical to nothing having happened unless the menu itself is watched. Pressing Down first, then Enter, accepts normally. This is the same root cause PowerShell's verifier independently found and traced to source: handle_completion_suggestions_results picks Unselected when classic completions are enabled and First otherwise -- by design, not a bug, but it makes "accept the top entry" a two-Tab (or Tab-then-Down) operation, and it has now caused two independent verifiers to misreport a working feature as broken. Recorded prominently since a third misreport is exactly the failure mode this is trying to prevent.

Fixed: a trailing space after a completion ending in = broke hand-typing the value

ls --col + Tab correctly completes to ls --color= (the -S suffix fix from the Ninth round), and typing = by hand and pressing Tab again correctly opens a working never/always/auto value menu. But accepting the first completion (--color=) left the cursor one character past a trailing space that insert_completion_result_into_editor unconditionally appends unless the completion ends with a path separator -- so typing the value by hand afterward produced ls --color= always instead of ls --color=always. Confirmed client-side, not shell-side: the shell's own OSC output (span, -S suffix) was already correct.

Fix: extended the no-trailing-space exemption to also cover a completion ending in =, matching the shell-side convention this exact shape follows (a trailing = means a value goes directly after it, with no space). Shell-agnostic and client-side, so it applies uniformly regardless of which shell produced the =-suffixed completion. Pushed as b80bfb2. cargo fmt clean; not compiler-verified for the same reason as every other app/src/... change in this PR.

A design cost characteristic worth stating rather than leaving for a reviewer to discover

From reading input.rs: the native-completions generator is dispatched whenever the feature is enabled, before Warp's own bundled spec results are consulted -- only which result set gets used is conditional, not whether the shell pays for a foreground completions round trip. So in the flag-only configuration, every keystroke pays for a foreground shell request even on a command where a bundled spec ultimately wins and the shell's own result is discarded. This is a real cost characteristic of the current design, not a bug, and is worth a reviewer knowing about rather than discovering independently.

Downgraded and reconfirmed findings

  • The Ctrl+U residue reported in an earlier round could not be reproduced in 10 attempts across two heads and several terminal widths. Downgrading that earlier single sighting to unconfirmed rather than continuing to carry it as a known issue.
  • Plugin-hook wedge safety reconfirmed emphatically: a zle-line-init hook installed by a plugin after Warp's own takeover fired 48 times in one round, including during a completions request itself, with no nesting error, and eight interleaved real commands all returned working prompts afterward.
  • History stayed clean across all six rounds (183 entries, zero generator matches or hex). Live-only compdef and live-only alias both resolve, reconfirming results come from the live session.

CORE-3799 and CORE-3801 updated directly on Linear with sharper detail from this pass

  • CORE-3801 (wrapped-line leak): the leaked content is confirmed to always be the buffer's own trailing characters, ending in the buffer's true last character (e.g. xABCDE from a 205-character line) -- 6 characters leaked on this head vs. 1 on an earlier head, consistent with an off-by-a-small-constant rather than a different mechanism.
  • CORE-3799 (header/tab-title corruption): the tab-title mangling symptom is confirmed to be the same off-by-N accounting gap as the header corruption, not a separate defect, and should be fixed together with it.

One remaining gap in zsh coverage, for whoever picks this up next

terminal.input.completions_open_while_typing = true (the as-you-type path) was never exercised on zsh in this pass -- every zsh result to date is Tab-triggered. This matters specifically for zsh because as-you-type would fire a foreground select in the user's live shell on every keystroke while typing, not just on an explicit Tab; whether that's viable at all is an open question this PR doesn't answer for zsh. macOS verification is covering this as of this writing.

Sixteenth round: fish's fork-per-payload cost, and the root cause of the as-you-type request stream dying

The fish large-result-set investigation ruled out a match-count threshold entirely (git checkout rendered cleanly at up to 50,000 candidates pushed synthetically, with vte's osc_raw confirmed as an unbounded Vec<u8> on native builds) and found something more serious instead: with completions_open_while_typing = true, no case renders a menu at all, independent of match count (git log -- at 149 candidates and functions at 321 both failed the same way).

Fixed: fish's encoder still forked twice per payload, unlike bash's rewrite

fish's warp_hex_encode_string still piped through od -An -v -tx1 | command tr -d ' \n' (two forks), the same class of cost the Fourteenth round's bash rewrite eliminated there. Measured directly: for git checkout , the shell side alone took 7.2s, of which complete -C itself is only 0.137s -- almost the entire cost is these forks, at roughly 1ms per candidate (each match and description hex-encoded separately). At that latency, every as-you-type request is guaranteed to be stale before it returns, regardless of any other fix.

fish has no byte-safe equivalent of bash's LC_ALL=C string-indexing trick (fish strings are Unicode codepoints internally, not raw bytes), so a true no-fork rewrite isn't available the way it was for bash. Fixed by stripping od's spaces and line-wrap newlines with fish's own builtin string replace instead of forking tr, halving the fork count (one fork instead of two). Pushed as 1a58623. Verified against -n/-e/-E, multibyte, semicolon, embedded-newline, and >16-byte (od-line-wrapping) inputs with the real installed fish 3.7.0 interpreter; fish -n syntax check passes. This is a partial mitigation, not a fix on its own -- it roughly halves the cost, not eliminates it, and by itself doesn't make fish's as-you-type viable at scale. The remaining cost is structural, not a missing optimization: forking od at all is what's expensive, and fish has no builtin path around that for raw bytes the way bash's locale trick provides -- the ceiling here is fish's own string model, not an implementation gap this PR left behind.

A measurement-mode caveat: nearly every completions-behavior claim throughout this PR's rounds (the zsh/bash/fish/PowerShell matrices, span/description/suffix verification, etc.) was measured with explicit Tab triggering, not as-you-type -- the one exception called out in the Fourteenth round (the stale as-you-type menu) was already flagged as as-you-type-specific. This as-you-type dispatch gap didn't invalidate any of those Tab-triggered results, which are the bulk of this PR's verification, but it did mean none of them should have been read as claims about as-you-type behavior specifically. The gap itself is now fixed and confirmed live (see the Seventeenth and Eighteenth rounds below), so this caveat applies to reading the history up to this point, not to the current state of as-you-type dispatch.

Root cause found for the as-you-type request stream dying after 2-3 characters

Traced to open_completion_suggestions (input.rs), which gates every as-you-type dispatch on is_command_grid_active() || is_cli_agent_shell_mode. Block::is_command_grid_active() is defined as self.state == BlockState::BeforeExecution -- true only in the window after a fresh prompt but before any command has started executing. The moment a native-completions request's own generator command begins executing as a foreground command (start_in_band_command_execution transitions the active block out of BeforeExecution), this gate goes false and stays false for as long as that generator command is running on the real shell.

Since this check runs synchronously inside each keystroke's own edit-event handler, not on a timer or retry, every keystroke typed while a previous native-completions request is still executing silently no-ops at this gate -- no request is dispatched, and critically, nothing re-attempts the check later when the gate reopens, since only a new keystroke re-runs it. If the user's last keystroke in a typing burst lands while a request is still in flight, no request is ever issued for that final buffer state, even after the shell returns to an idle prompt moments later. This is a case of the completions mechanism gating on its own side effect: the check exists to avoid firing completions while a genuinely long-running user command occupies the foreground, but a native-completions generator command is itself exactly such a foreground command, so back-to-back requests during fast typing trip the same gate meant for unrelated long-running commands.

This also directly explains the staleness-guard symptom reported alongside it: handle_completion_suggestions_results's exact-buffer-equality check (if buffer_text != editor_snapshot_when_completer_was_ran.text() ... return;) is a reasonable guard on its own -- a result computed for an older buffer genuinely shouldn't apply to a newer one -- but with no later keystroke able to fire a fresh request once the buffer moves ahead while the gate above is closed, a dropped-as-stale result is never followed by a replacement request for the current buffer either. Fixing the gate above should resolve both symptoms together, since a fresh request would then have a path to fire once the shell returns to idle, even without a further keystroke.

Hypothesis confirmed before implementing, by a verifier instrumenting the gate directly (same 10 characters, git log --, two runs each): slow typing (2s/character, 8 keystrokes) got the gate open and a request dispatched for 7 of 8 keystrokes; a fast burst of the same 10 characters got the gate open for only 1-2 of 8, with the rest blocked and no request ever issued for the final buffer. The predicted asymmetry held exactly. A further finding changed the fix's required scope: the gate also blocked a keystroke landing mid-burst during the slow runs, recovering only because the next keystroke happened to land after the gate reopened -- so a fix that only re-dispatches once at the end of a burst would still leave intermediate holes; it has to re-check on every return to idle.

Seventeenth round: fixed the as-you-type request-stream gate, trailing-edge

Implemented direction 2 from the Sixteenth round, with the mid-burst finding folded in: on every return to idle (AnsiHandlerEvent::Precmd -- the same hook that flips is_command_grid_active() back to true), check whether the buffer differs from the one the most recently dispatched as-you-type native-completions request was computed from, and if so, dispatch once more for the current buffer. Direction 1 (loosening the gate while a request is in flight) was deliberately not taken: it would mean writing to the pty during a foreground command, which is exactly the class of thing that produced the second-round phantom block, the fourth-round duplication regression, and the sixth-round write-queue race earlier in this PR. Direction 2 never fires during a command -- it only notices the world changed while it was busy -- which also matches the constraint that can't be removed: the request is synchronous and can take seconds.

Added a new field, native_completions_as_you_type_dispatch_snapshot, tracking the editor snapshot the last such dispatch was computed from; set in run_completions_async right where a native-completions request is dispatched, and checked on every Precmd via a new retry_as_you_type_completions_if_buffer_changed. The exact invariant specified for this -- compare against the last dispatched snapshot, not merely whether results are pending -- is what keeps it from looping: a successful retry updates the tracked snapshot to the buffer it was just dispatched for, so the very next Precmd sees no difference and does nothing unless the user typed more in the meantime. This also naturally coalesces a fast burst into one trailing request for wherever the buffer ended up, and gives the staleness guard (Sixteenth round) a replacement request instead of a dead end.

Extracted the actual yes/no decision into a small generic function, should_retry_as_you_type_completions, specifically so the loop-guard invariant could be unit tested without a live EditorSnapshot (which has no public constructor outside its own module) -- three cases: no prior dispatch, an unchanged buffer (must not retry), and a changed buffer (must retry).

Verification: cargo fmt clean on input.rs. The extracted decision function was verified via a standalone rustc compile outside this sandbox's memory-constrained app crate (all three cases pass) rather than as an in-tree cargo test, since the app crate can't be built here; the rest of the change (the new field, the model_events subscription wiring, and the dispatch-time tracking) is hand-reviewed against the exact existing patterns it mirrors -- the sibling TerminalModeSwapped subscription arm and the editor.read(ctx, |view, ctx| view.snapshot_model(ctx)) idiom already used elsewhere in the same function -- rather than compiler-verified. Not verified live; requesting the verifier who instrumented the gate re-run both the fast-burst and the mid-burst-during-slow-typing experiments against this commit to confirm the blocked keystrokes stop disappearing.

Eighteenth round: the retry guard was comparing the wrong thing

Live verification of the Seventeenth round's fix confirmed the core mechanism -- the gate itself was never loosened, the retry rescued both the fast-burst and the mid-burst-during-slow-typing cases exactly as designed, and 90 seconds hands-off with the buffer unchanged produced zero visible activity (47 retries correctly suppressed by the loop guard) -- but found a real defect in what the guard compared.

The defect: of 23 retries logged in one session, only 5 had an actually-different buffer. The other 18 fired with identical text and identical selections and dispatched a redundant, duplicate request for the buffer that had just been dispatched a moment earlier. Root cause: the guard compared whole EditorSnapshot values, and EditorSnapshot's own derived PartialEq compares more than text and selections -- it also compares buffer_text_runs, an internal representation that a completions round trip perturbs (most plausibly because the PTY controller writes the buffer text back to undo the foreground command's own buffer-clearing) without changing anything visible to the user. Two snapshots that looked identical still compared unequal.

Severity was bounded rather than a loop -- it fires exactly once per dispatch, and the very next Precmd is correctly guarded, since by then the snapshot really hasn't changed again -- but it added one redundant full foreground generator command for whichever buffer happened to be in flight when a round trip perturbed the snapshot (not specifically the largest one in a burst), 18 of them across a 124-request session -- about 15% of all as-you-type shell work in that session going to duplicate requests that never should have fired. Cheap at 277ms for a small result set, not cheap at 6.3s for a large one.

Fix: introduced AsYouTypeCompletionsBufferState, holding only the two fields that actually matter (text, selections), built from EditorSnapshot's own public accessors. The retry guard now compares that narrower type instead of the whole EditorSnapshot, so the round trip's perturbation of buffer_text_runs no longer produces a false "changed" result. should_retry_as_you_type_completions itself is unchanged and was already correct -- the defect was entirely in what got passed to it.

The test gap that let this through, and how it's closed: every existing test for should_retry_as_you_type_completions instantiated the generic function with String, so none of them ever exercised EditorSnapshot's real equality behavior -- exactly the verifier's point that an assertion has to bind to the type actually used in production. Added as_you_type_completions_buffer_state_tests, constructing AsYouTypeCompletionsBufferState directly with real string_offset::CharOffset/vec1::Vec1 values (matching this file's own existing Vec1::new usage elsewhere), including the exact case that would have caught the regression: identical text and cursor position must not trigger a retry.

Pushed as 8c2858c. Verification: cargo fmt clean. The new tests are hand-traced against AsYouTypeCompletionsBufferState's derived PartialEq (structural equality over types already known to be correctly comparable) rather than compiled and run -- the app crate still OOMs here.

Confirmed live on 8c2858c, closing out the as-you-type work: redundant retries went from 18 to 0 in the same session shape (23 retries total -> 4, all 4 genuine), the fast-burst and mid-burst-during-slow-typing rescues still work identically to the Seventeenth round (a fast burst still blocks most gate calls and still opens the menu; the forced mid-burst case at 8,112 candidates is still rescued a second later with no further keystroke), and the loop guard still holds over 40 seconds hands-off with exactly one guard skip per retry. No burst ended without a menu. This closes the as-you-type request-stream investigation; the as-you-type mode can now be treated as verified for the mechanism this PR is responsible for (dispatch reliability), separate from the smaller cosmetic stale-menu issue noted above, which remains open.

Nineteenth round: macOS as-you-type sweep -- confirmed the path fix, found a new, orthogonal intermittency, added observability

A macOS sweep of 8c2858c across both honor_ps1 settings confirmed the Twelfth round's $hpre path fix (cd /et -> cd /etc with the leading / preserved, not the cd etc mis-insertion originally feared) and descriptions (atuin , starship pr). Also confirmed: no plugin-hook wedge, Ctrl-C/resize mid-request harmless, no generator commands in history, multibyte intact, and block headers are well-formed on macOS -- the CORE-3799 mangling is Linux-specific, narrowing that issue further. The wrap leak (CORE-3801) reproduces exactly as expected and needs no new action.

New finding: the as-you-type mid-burst rescue is intermittent under honor_ps1=false, and it's a different mechanism than the Sixteenth-Eighteenth rounds' fix

Same case, same head, both outcomes observed: after a fast git log -- burst, the rescue fired once, then didn't fire across four consecutive retries at 15s/30s. The failing stretch clustered specifically in the honor_ps1=false (Universal Developer Input) configuration and specifically on shell-only cases (git log --, cd /et, echo $HOM, foo=/tmp/, cd ~/Doc, zqfoo ), while starship pr/atuin kept working throughout -- i.e. the same command intermittently produces no completions menu at all, with the variable being when it's typed, not what is typed.

Investigated the verifier's hypothesis (offered as unproven) by reading the code rather than guessing: should_use_native_shell_completions excludes AI-mode input (input.rs:1542-ish, !is_ai_input), and in the Universal input box, honor_ps1=false uses per-buffer natural-language detection (NLD) to decide AI-vs-Shell mode -- confirmed from the block-header text they observed (~ (nld overridden)).

Confirmed by code reading: BlocklistAIInputModel::detect_and_set_input_type runs its classification asynchronously (ctx.spawn, debounced) and can flip input_type at any time relative to a completions round trip. Both the original as-you-type dispatch (open_completion_suggestions, on a keystroke) and the Seventeenth round's trailing-edge retry (retry_as_you_type_completions_if_buffer_changed, on Precmd) route through the same run_completions_async, which re-reads self.ai_input_model.input_type() fresh at call time rather than caching a value from when the buffer was first typed. So yes, the retry is gated behind the identical decision, and it's more exposed to the classifier's async timing than a single one-shot dispatch would be, since it deliberately re-evaluates state at a later point in time specifically to catch up on what changed. If NLD's async verdict flips a shell buffer to AI mode in the window between an original dispatch and a later retry, native shell completions silently stop being used for that exact buffer -- which is consistent with the observed "same command, different outcome" pattern.

Confirmed by code reading, answering the verifier's second question: neither a gate-blocked dispatch (is_command_grid_active false) nor an AI-mode-classification skip (use_native_shell_completions false via is_ai()) had any logging at either site before this round -- both were equally silent, which is exactly what made this hard to see from the outside.

Not confirmed, and flagged rather than guessed at: why starship pr/atuin were unaffected while the other cases went quiet in the same session. This depends on the actual runtime verdict of the NLD classifier (heuristic/ML-based), which isn't something traceable from the call-site code alone -- I don't have a theory that explains that specific asymmetry, and didn't want to invent one.

Fixed: added observability, not a classifier-interaction fix. This intermittency is a property of the pre-existing is_ai() exclusion interacting with async classification, not a bug introduced by the retry work -- resolving it would mean a design decision (e.g. freezing completions eligibility for the duration of a round trip, or having NLD defer to an in-flight completions request) that's out of scope here. Added a log::debug! line (pushed as 4fc7e35) firing specifically when AI-mode classification is the sole reason use_native_shell_completions is false, distinguishable by construction from every other skip reason (feature disabled, shell unsupported, multiline buffer) checked in the same condition. No buffer/command text is logged, per this repo's logging guidance for a hot per-keystroke path and for user-generated command content. Verification: cargo fmt clean; log::debug!/log::warn! already used extensively elsewhere in this same file, confirming no new import is needed. Not compiled (app crate OOMs) and not verified live. Requesting the verifier enable this log level and re-run a failing case to directly confirm (or rule out) the hypothesis, and separately look at last_ai_autodetection_source()/the classifier's own decision for the starship/atuin asymmetry, since that's outside what I can determine from static code reading.

Also reported: uvx --p shows no menu despite the shell returning 10 matches (8 with descriptions)

Reproduces on both honor_ps1 settings, unlike the intermittent case above. Not yet root-caused -- may be the same AI-mode classification issue (worth checking with the new log line first) or a separate cause. Flagging for the next verification pass rather than guessing further without being able to run it.

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.
…o assert directly rather than via block routing

Two changes, both requested after the first machine-checked run
(8a5cea4 compiled clean, 19/20 tests passed including both backspace/
CUB shape tests -- the backspace and CUB handling is now verified by
a compiler, not just hand-traced).

**Forward motion.** macOS reproduction found a further gap in the same
class: with honor_ps1 off, a syntax-highlighting recolour pass rewinds
with a carriage return, re-echoes the 8-character command word, then
skips forward over the unchanged remainder with CUF (\x1b[<n>C) rather
than re-echoing it. Nothing consumed that, leaving the sole live
candidate stranded at its pre-move position instead of advancing past
what the CUF skipped over -- which mismatches, and leaks, whatever
arrives next. This is very likely what actually explains the original
report (a single trailing character leaking), which neither Linux nor
macOS reproduced directly, since the buffer size in the original report
(11 characters, "starship pr") is exactly the case where the stranded
candidate and the correctly-advanced one land in the same place by
coincidence, and it takes a longer buffer or a different split to see
the two diverge.

Added EarlyOutput::advance_after_forward_move, symmetric with
rearm_after_rewind but shifting forward: for a forward move, unlike a
rewind, there's no ambiguity about whether a redraw follows, since the
terminal moving the cursor over content without redrawing it is itself
an assertion that the content already there is correct. Gave
move_forward its own EarlyOutputHandler implementation (previously
blindly delegated in the generic background-output section, same as
backspace/move_backward were before the prior commit). Documented in
the expected_echo_positions doc comment which four motions are now
handled -- carriage return, backspace, CUB, CUF -- and that absolute
column addressing (\x1b[<n>G, \x1b[<n>;<m>H) is the next gap of this
kind if it surfaces, along with why honor_ps1 is the actual switch
between the absolute (carriage-return) and relative (backspace/CUB)
rewind mechanisms, not just a detail of one measured session.

Added a test for the measured recolour+CUF shape, using a longer
buffer ("starship prompt") than the exactly-measured "starship pr" so
the CUF lands mid-pattern rather than exactly at the pattern's end,
where the stranded and correctly-advanced positions would otherwise
coincide and the test couldn't tell the fix apart from its absence.
Traced it by hand: without the fix, the stranded position 8 mismatches
the recolour's continuation and leaks; with it, the candidate advances
to 11 (position 8 + 3, matching the CUF's parameter) and everything
after matches cleanly to the end.

**Test fix, not a code fix.** The first machine-checked run flagged
test_starting_a_real_command_clears_a_stale_expected_echo_registration
as the one failure out of 20. Read the dispatch path before concluding
either way: BlockList::is_early_output() returns false once the active
block is started() (which start_active_block sets), and BlockList's
own dispatch routes input straight to the active block once that's
true, bypassing EarlyOutputHandler -- and therefore
consume_expected_echo -- entirely regardless of whether
reset_expected_echo ran. So the test's assertion (a background block
containing the fed-in character) was unobservable by construction: a
character fed in after start_active_block can never land in a
background block either way, which is why it failed for a reason
unrelated to what it claimed to test. reset_expected_echo's call site
in start_active_block is unconditional and was never the problem.

Rewrote the test to assert directly on what it's meant to pin down:
that early_output_mut().expected_echo and .expected_echo_positions are
both empty immediately after start_active_block, accessed directly
since the test module is a descendant of the early_output module and
can see its private fields. Dropped the carriage-return-and-character
steps that were trying to observe the reset indirectly through block
routing.

I don't have a way to compile-check or run this test suite in this
sandbox. The two shape-pinning tests from the prior commit are now
machine-verified (19/20 passed on 8a5cea4); the forward-motion test
and this test's replacement are hand-traced only, pending the next
machine-checked run.

@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

Second review of the accumulated changes since the first one, concentrated on the expected-echo state machine that now decides whether shell bytes are absorbed or rendered. The change is in good shape overall — rewind/advance arithmetic is correct at the boundaries, the lifecycle clear is ordered correctly against command submission, and the old widget/^Y/SendCompletionsPrompt path is removed cleanly — but two items need a decision before this is enabled anywhere.

Concerns

  • A live expected-echo registration silently deletes characters from unrelated background output, repeatedly. Measured by extracting the matcher and running it: with pattern git ch, output grep -rn foo\r\n renders as rep -rn foo\r\n; with pattern ls -la, a progress stream \rloading 10%\rloading 20%\rloading 50%\rloading 99%\rdone\n loses four characters. A mismatching candidate is never dropped and maybe_rearm_expected_echo re-seeds position 0 on every carriage return, so an inert, fully-matched pattern is re-armed by the next \r and eats another character — and carriage returns are exactly what progress-printing background jobs emit. This corrects the PR body's earlier framing: the gap is not about a registration outliving its request, it only has to still be live when unrelated output arrives, which is its normal state. The correction is to bound the registration in time (clear once the shell settles back at a prompt after the restore), not to narrow the matcher: narrowing does not help since the first character matched in one case, and clearing on full match is wrong because the fish double-echo and the CUB recolour legitimately match again after a full match.
  • The zsh zle-line-init takeover can still recurse when a plugin rebinds after us. The self-check covers aliasing our own widget to itself, but not the reverse order: our widget is installed, a deferred plugin (zinit turbo, zsh-defer, zvm_after_init) then registers a hook via add-zle-hook-widget preserving ours as the previous binding, and the next request saves that dispatcher and rebinds to us — so we chain to the dispatcher, which invokes us. That is the same nested-function-level failure as before, and it fires on the unarmed path, wedging every ordinary prompt read rather than one request. A reentrancy guard inside the widget closes both this and the case the current check handles.
  • The expected-echo tests only assert one direction. All eleven assert that nothing leaked; none asserts that anything still renders. Replacing consume_expected_echo's body with "swallow whenever a pattern is registered" leaves the suite green, which is the same shape as the finding above taken to its limit. Two tests close it: unmatched output must reach the background block verbatim, and the git ch / grep case, which fails today.

Verdict

Checks: build pass (cargo check clean, 20/20 early_output tests, verified on a separate machine), tests pass, CI skipped (draft), visual proof present — verified in-app on Linux and macOS, 40 restores across four configurations with zero leaks after the rewind fixes

Found: 1 critical, 2 important, 5 suggestions, 6 nits

The remaining items are recorded with the author: candidate positions are never pruned, consume_expected_echo can steal genuine typeahead in InputMatching mode (bash 3.2, macOS system bash, now a supported shell), in-flight state is assigned before the write can be rejected which contradicts the skip-safety comment, fish_title is overridden unconditionally though fish.sh runs after config.fish, one test cannot observe the property it names, two doc comments still describe the restore echo as "recognized as typeahead" (the wording behind the earlier duplication regression), and bash_body.sh handles only -F compspecs.

Responding as wilson: Open session · View factory task

…nit reentrancy guard; add one-directional test coverage

Second adversarial review found one high-severity and two medium-
severity issues in the accumulated diff. Fixes all three.

**HIGH: a live pattern silently deleted characters from real,
unrelated background output.** Extracting the matcher into a
standalone program and feeding it independent input reproduced it
directly: pattern "git ch", background output "grep -rn foo\r\n"
rendered as "rep -rn foo\r\n" (the leading "g" consumed, since it
happens to match the registered pattern's own first character); a
progress-printing job's own carriage returns re-armed a supposedly
inert, fully-matched pattern and it kept eating characters. The
pattern had no time bound at all -- once registered, it stayed live
until the next `push_expected_echo` call happened to overwrite it,
which could be an arbitrary amount of real, unrelated shell activity
later.

Bounded it to a single restore's own redraw window. `PtyController`
now calls the existing `EarlyOutput::reset_expected_echo` a second
time, from the `LineEditorStatusEvent::Active` handling that already
fires once per restore (the same place `just_restored_native_completions_buffer`
already gets cleared). `Active` is emitted a fixed delay
(`LINE_EDITOR_ACTIVATION_DELAY`) after the shell reaches precmd/end-
prompt, which is well after any redraw -- including a later recolour
pass -- following the restore write has had a chance to play out, so
this is the natural point past which the registration is no longer
describing an in-flight redraw. `reset_expected_echo`'s doc comment
now describes both call sites (the pre-existing one on a real command
starting, and this new one) and the concrete corruption this closes.

Also fixed two comments that still said a matched echo is "recognized
as typeahead" -- the wording that caused an earlier round's
duplication regression, since a typeahead match gets *inserted* into
the input editor, which is wrong here (the editor's copy was never
cleared). A match here is dropped entirely; the comments now say so.

**MEDIUM: the zle-line-init takeover still recurses in the reverse
bind order.** The self-check added for the "we alias the saved name to
ourselves" case only closes that one case. A deferred plugin loader
(zinit turbo mode, zsh-defer, zvm_after_init) that registers its own
zle-line-init hook via add-zle-hook-widget *after* our takeover
preserves our widget as the previous binding rather than the reverse,
so zle-line-init ends up bound to that plugin's own dispatcher, which
itself still calls us. The self-check doesn't recognize that as
"already us", saves the dispatcher, and rebinds to us again -- chaining
to that saved dispatcher then invokes us a second time, the same
"maximum nested function level reached" recursion as the self-check's
case, reached from the opposite bind order and on the *unarmed* path,
wedging the shell on every ordinary prompt read.

Added a reentrancy guard inside _warp_native_completions_zle_line_init
itself: an early return if already running, set/cleared around the
function body via zsh's `{ ... } always { ... }` construct so the flag
clears on every exit path including the early returns. This closes
both the self-check's case and the reverse-order case the same way,
without needing to reason about which binding order caused it. Kept
the existing self-check unchanged -- it still serves a distinct
purpose (avoiding overwriting the saved widget with an alias to
ourselves, which would lose the chain to a genuinely different,
earlier-saved hook) that the reentrancy guard alone doesn't cover.

Verified against the exact reverse-order scenario with an isolated,
logged reproduction: a real interactive zsh session, our takeover, a
simulated deferred plugin registering its dispatcher afterward, a
second takeover (which saves the dispatcher, confirmed via
${widgets[_warp_saved_zle_line_init]}), then firing zle-line-init
through a real `select`-driven ZLE context. The trace shows the
expected shape exactly: our own widget runs, chains to the saved
dispatcher, the dispatcher's own chain call back into us hits
"GUARD-BLOCKED" and returns immediately (no recursion), the dispatcher
continues normally and the plugin's own hook still fires, control
returns to our outer call, and it completes normally (accept-line
runs, the always-block clears the guard). The shell remained fully
responsive afterward.

**MEDIUM: the test suite only asserted one direction.** All eleven
`push_expected_echo` tests asserted that nothing leaked; none asserted
that anything still renders when it should have. Replacing
`consume_expected_echo`'s body with `!self.expected_echo.is_empty()`
-- absorbing everything whenever anything is registered, regardless of
match -- would have left the whole suite green. Added two tests
closing this: one feeding output that shares no prefix at all with a
live pattern (must render verbatim), and one reproducing the measured
"git ch" + "grep -rn foo" corruption directly, using
`reset_expected_echo` to simulate the restore window having closed
(exactly what `PtyController` now does), asserting the previously-lost
output renders in full.

I don't have a way to compile-check the Rust changes or run this test
suite in my sandbox; the zsh reentrancy fix is the one piece here
verified against a real, interactive zsh session in this round rather
than only hand-traced.
…e mismatch, not on LineEditorStatusEvent::Active

`fd2ee71`'s bound was wrong. The verifier's logged ordering from a
real GUI session settles it directly, without needing to reason about
what `Active` should mean from its name:

    push_expected_echo("starship pr"): after ... positions={0}
    LineEditorStatusEvent::Active fired, ..., pending_writes_len=1
    LineEditorStatusEvent::Active: skipping input reporting (just restored)
    consume_expected_echo('s'): match=true positions_before={0} positions_after={1}
    input('s'): consumed as expected_echo

`Active` fires with the restore write still sitting in
`pending_writes` (`pending_writes_len=1`) -- it's that handler's own
`execute_next_queued_write` call that flushes it. Every echoed
character arrives strictly after. So `Active` is not "well after any
redraw has played out" for this path; it is before the write goes out
at all. Clearing there discarded the pattern before a single echoed
character could arrive, which would have made the whole restored
buffer fall through -- an 11-character phantom block instead of the
1-character one being fixed, and it would have started leaking in the
`honor_ps1 = false` configuration that was previously clean. Reverted
that call and the doc comments that justified it.

The window has no clean opening-and-closing *signal* pair in this
codebase: `Active` fires too early, and nothing else was found that
reliably lands only after a restore's own echo -- including any later
recolour/redraw passes -- has fully arrived. Rather than keep
searching for one, bound the window by what the data itself proves:
`consume_expected_echo` now clears the registration on the first
character that no live candidate predicts. A live pattern's whole
purpose is to explain an in-flight redraw; the first character it
can't explain is either the redraw genuinely being over, or something
having gone wrong, and either way there is no more legitimate echo
left to protect past that point.

**What window this actually bounds, stated plainly**: from
registration until the first unmatched character, not "restore start
to restore end" in wall-clock terms. Traced by hand against every
existing shape (plain match, CR-then-restart, CR-then-continue,
ambiguous double candidates, backspace, CUB, CUF) to confirm none of
them ever produces a spurious mismatch mid-sequence -- at least one
live candidate always predicts correctly throughout a genuine,
measured redraw, so the new clearing branch is unreachable on all of
them and none needed updating.

**The residual exposure, and it is real, not eliminated**: if an
already-exhausted pattern (fully matched, its only remaining position
out of bounds) is rearmed by some *unrelated* command's own carriage
return/backspace/CUB, and that unrelated command's own output happens
to start with a character the rearm coincidentally predicts, that one
character is swallowed before the very next, genuinely non-matching
character closes the window. Added a test
(`test_a_rearm_can_still_leak_exactly_one_coincidentally_matching_character`)
that demonstrates exactly this and pins the bound at one character,
not zero -- register "gi", fully match it, an unrelated carriage
return rearms position 0, then "grep" arrives and renders as "rep".
This is progress over the pre-existing unbounded case (a stale pattern
could otherwise corrupt output across arbitrarily many later,
unrelated commands until the next completions request happened to
overwrite it) but it is a bound, not a fix, and the PR body should say
so plainly.

Also added a test for the complementary, common case where the
exposure is fully eliminated: once a pattern has been reset (by
`start_active_block`, or by this same mismatch-triggered clear having
already fired), completely unrelated output renders with zero
characters lost, including when it happens to share a leading
character with the (now-cleared) stale pattern.

Removed `TerminalModel::reset_expected_echo`, the passthrough added
for the reverted `PtyController` call -- it has no other caller.
`EarlyOutput::reset_expected_echo` itself is unchanged and still used
by `BlockList::start_active_block`.

Same standing caveat: hand-traced, not compiled, in this sandbox. Held
locally rather than pushed per instruction, pending the verifier's
confirmation from the same GUI pass that surfaced the ordering
problem in the first place.
…hell

Root cause of "$_. and $_.Na member completions return nothing" despite
the shell producing every candidate correctly (measured: 300/300 candidates
received across six requests, zero truncated downstream): the client
computes its own replacement span with a whitespace heuristic, then
filters the shell's candidates against that span sliced out of the
buffer. For `Get-Process | Where-Object { $_.`, PowerShell's own
CommandCompletion reports a zero-length replacement span right after the
`.` (ReplacementIndex=32, ReplacementLength=0), but the whitespace
heuristic derives the query "$_." -- no member name contains that
substring, so every one of the 124 correct candidates gets filtered out
and the menu never opens. `$_.Na` (one correct candidate, `Name`) fails
the same way with query "$_.Na" instead of PowerShell's own "Na". Cmdlet
and flag completions ("Get-Proc", "Get-Process -") happen to work only
because the whitespace token coincides with PowerShell's own span.

Chose the protocol-extension option (report the shell's span, use it
verbatim when present) over suppressing client-side filtering: PowerShell
already had `ReplacementIndex`/`ReplacementLength` in hand, so it costs
one new OSC sub-message rather than touching all four shell scripts, and
unlike suppressing the filter, it doesn't throw away the legitimate cases
where filtering is what narrows a large candidate set to what's relevant
(e.g. "Get-Process -").

- New OSC 9280;S sub-message reports `<start>,<length>` byte offsets;
  optional, so shells that don't send it are unaffected. Threaded through
  `Handler::on_completion_replacement_span_received` ->
  `IsReceivingCompletionsOutput::Yes.replacement_span` ->
  `Event`/`ModelEvent::CompletionsFinished`'s new second field ->
  `PtyController`'s results channel -> `input.rs`, which now prefers it
  over the whitespace-derived span when present.
- `pwsh.ps1` emits it from `CommandCompletion.ReplacementIndex`/
  `ReplacementLength`. Known gap, stated in the emission comment:
  these are .NET UTF-16 code-unit offsets, sent as-is, so this is only
  exact for ASCII lines -- true of every reported case, but non-ASCII
  PowerShell lines are a real limitation worth documenting rather than
  solving here.
- Verified empirically with pwsh 7.6.5, installed in-sandbox: the hex
  round-trip decodes correctly for all five test lines, the emitted OSC
  byte sequence parses exactly as the new Rust-side constant/split
  expects (`ESC ] 9280 ; S ; 32,2 BEL` for `$_.Na`), and the derived
  query for each of the five lines (`Get-Process | Where-Object { $_.`,
  `$_.Na`, `Get-Proc`, `Get-Process -`, `$PSVersionTable.`) now matches
  PowerShell's own span exactly, retaining the correct candidate set
  (124, 1, 1, 18, 32 respectively) instead of discarding it.
- Confirmed unverified, not compile-checked: the Rust-side plumbing.
  I cannot compile the full `warp` crate in this sandbox; `./script/format`
  accepts every touched file, and I traced the new call chain (including
  every existing call site of the changed types) by hand rather than by
  running rustc.
- Untested, stated as a limitation for the PR body: whether bash, zsh and
  fish have an equivalent bare-member-completion gap, and whether
  `completions_open_while_typing = true` takes a different filtering path.
  Neither was exercised in this change.
…ttern

`da66d6b`'s "clear on the first genuine mismatch" rule broke every
carriage-return-driven redraw shape (7 of 23 `early_output` tests,
verified by the reviewer on the pristine pushed blobs, not local
instrumentation). Root cause: `carriage_return()` and `linefeed()` probe
`consume_expected_echo` with `'\r'`/`'\n'` before rearming, to decide
whether those control bytes are themselves part of the registered
pattern. A restored buffer's text never contains either, so that probe
is *always* a mismatch -- and with the clear living inside
`consume_expected_echo` itself, every single carriage return destroyed
the registration before `maybe_rearm_expected_echo` ever got a chance to
run, regardless of whether the real echo characters around it matched.

This is the third attempt at bounding this window, and all three are
worth recording so a fourth person doesn't repeat them:
1. `LineEditorStatusEvent::Active`: wrong because it fires *before* the
   restore write is even flushed (measured: `pending_writes_len=1` at
   the moment `Active` fires, and that same handler is what flushes it),
   so it discarded the whole restored buffer rather than protecting it.
2. Clearing on any mismatch inside `consume_expected_echo` itself: wrong
   because `carriage_return()`/`linefeed()`'s own control-byte probes are
   guaranteed mismatches unrelated to whether the echo is actually over,
   destroying the pattern on every CR-driven redraw.
3. This one: `consume_expected_echo` goes back to having no side effect
   on a mismatch (pure, as it always should have been for a function
   also used as a probe); the window instead ends at
   `EarlyOutputHandler::input()`'s call site specifically, which only
   ever receives real characters, never the CR/LF control-byte probes.

Traced this by hand against every one of the file's 23 tests before
pushing (all 7 previously-failing ones, plus the 16 that were already
passing, to make sure none regressed) -- but per instruction this is not
being treated as sufficient on its own; the verifier's test-suite run is
what should actually confirm it, and will also compile-check the
PowerShell replacement-span plumbing from the immediately preceding
commit, across all twelve files it touched.

The residual described in `da66d6b`'s own test
(`test_a_rearm_can_still_leak_exactly_one_coincidentally_matching_character`)
is unchanged: a rearmed, already-exhausted pattern can still absorb
exactly one coincidentally-matching character from unrelated output
before the next, genuinely non-matching character closes the window.
…e index

Two fixes, both to the PowerShell replacement-span work in 796a600:

1. E0027 build error. `IsReceivingCompletionsOutput::Yes` gained
   `replacement_span` in 796a600; `TerminalModel::input`'s raw-completions
   branch destructures the variant but wasn't updated, so it fails to
   compile. Decided the raw path does not need the span (rather than
   reflexively adding `..`): the only shell that currently emits the
   span, PowerShell, always uses `incrementally_typed` format, never
   `raw`; and this branch only accumulates raw text character by
   character regardless, so it has nothing to do with a span even if one
   were ever set here. Bound and ignored it explicitly
   (`replacement_span: _`) with a comment saying why, rather than a
   silent `..` that would hide the next field addition the same way this
   one was missed. Re-checked every other `IsReceivingCompletionsOutput`
   pattern site in the file by hand this time (not just the one the
   compiler found) to confirm none of the others has the same gap.

2. A whitespace-only line (measured: `"   "`) makes PowerShell's
   `CompleteInput` report `ReplacementIndex=-1, ReplacementLength=-1` --
   there's nothing to anchor a replacement to and no matches either. The
   client's parser correctly rejects a negative pair and falls back to
   the whitespace heuristic, so this degraded safely, but it did so with
   a warning logged on trivially reachable input (type spaces, press
   Tab). `pwsh.ps1` now skips the OSC entirely when
   `ReplacementIndex -lt 0`, verified against the exact reproduction with
   pwsh 7.6.5 installed in-sandbox.
PSReadLine's own redraw rewinds with absolute cursor addressing
(`\x1b[1;1H`), not with any of the four motions already handled --
measured live: `push_expected_echo`, one character matched, then that
exact CUP, then the reprint's first character reads as a genuine
mismatch. `EarlyOutputHandler::input()`'s clearing rule (added to fix the
CR/LF probe bug two commits ago) then reads that mismatch as proof the
echo is over and clears the registration, so the fully-registered-but-
now-cleared pattern's *entire* remaining reprint falls through to
background output instead of just the one character a rewind gap would
otherwise cost. Measured as a scratch unit test on the same tree: 16 of
16 characters leaked with the clearing branch as pushed, versus 1 with it
disabled -- the clearing rule turned an existing one-character leak into
a whole-buffer one on this specific, previously undocumented motion.

Generalized the carriage-return rearm (previously `maybe_rearm_expected_echo`,
hardcoded to position 0) into `rearm_at_column(column)`, on the premise
already used for CUB/CUF: a registered pattern's echo always starts at
column 0, so an absolute column from the escape sequence is already an
absolute position in the pattern, exactly as a relative distance already
maps onto a relative position shift for CUB/CUF. `carriage_return()` now
calls `rearm_at_column(0)`; `goto`/`goto_col` (CUP/CHA) call it with
their own column argument. The row component of `goto` is irrelevant to
matching, which only tracks a linear character stream, not screen
position.

Added a test reproducing the measured shape (one matched character, then
`goto(VisibleRow(0), 0)`, then the full reprint) and traced it against
both the fixed and unfixed logic to confirm it distinguishes them.
Updated the `expected_echo_positions` doc comment, which explicitly
named absolute column addressing as an unhandled gap, to describe it as
the fifth handled motion instead -- and to explain why PSReadLine's own
redraw doesn't follow the `honor_ps1` split (relative vs. carriage
return) the other three shells' redraws do: it always addresses
absolutely, regardless of who owns the prompt.
`9e5ee3c` generalized the carriage-return rearm to any absolute column
from CUP/CHA, on the premise that a registered pattern's echo always
starts at column 0. That premise held in the byte stream actually
measured (`\x1b[1;1H` immediately followed by the pattern's own first
character), but it does not obviously hold in general: a carriage
return means "column 0" unconditionally by construction, while an
absolute column from CUP/CHA does not -- if a redraw re-renders a
nonempty prompt together with the buffer, the column it addresses
partway through that redraw could just as easily be the prompt's own
width as the buffer's start, and nothing in the byte stream
distinguishes the two. Exactly the same reasoning is why zsh's ZLE
switches from a carriage return to relative motion (backspace/CUB) when
the shell draws its own prompt -- absolute positioning stops lining up
with the buffer once a prompt is in the way.

This has not been measured either way: no session with a real, nonempty
prompt has confirmed whether PSReadLine's CUP column reflects the prompt
width in that case. Rather than risk seeding a candidate at a
confidently wrong position (which, depending on what follows, risks
absorbing genuinely unrelated output rather than just failing to protect
the pattern), `goto`/`goto_col` now only rearm when the column is 0 --
column 0 is safe under either reading, since it is either genuinely the
buffer's start or so early into a redrawn prompt that nothing of the
pattern could plausibly be confused with prompt text there. Any other
column is treated as no information rather than acted on.

The test added in `9e5ee3c` (`goto(VisibleRow(0), 0)`) still exercises
this path, since it uses column 0, which remains trusted.

Updated both doc comments (the field-level one and `rearm_at_column`
itself) to state this as the conservative choice it is, rather than as
a general property that happens to match the one measured case -- and
to say explicitly that widening it to arbitrary columns needs a real
nonempty-prompt measurement first, not just tracing.
cobra-generated "bash completion V2" scripts (kubectl, gh, and most modern
Go CLIs) bake a padded "name  (description)" string directly into a
COMPREPLY entry whenever COMP_TYPE is 9 (plain Tab) and there is more than
one match -- real readline only ever inserts an entry when it's unique, so
the padding is safe there, but _warp_native_bash_completions displays every
entry in a menu and inserts whichever one is picked, so the padded string
was being inserted verbatim.

Verified with gh's actual completion script (gh completion -s bash):
`gh pr che` under COMP_TYPE=9 yields
COMPREPLY=("checkout  (Check out a pull request in git)" "checks    (...)")

cobra's own case statement (__gh_handle_completion_types) strips
descriptions unconditionally under COMP_TYPE 37 (menu-complete) or 42
(insert-completions), regardless of match count -- see
spf13/cobra#1508. Switched COMP_TYPE from 9 to 37
in _warp_native_bash_completions.

Confirmed this has no effect on non-cobra completions: bash-completion
(which drives the vast majority of scripts, including git's) never reads
$COMP_TYPE at all -- grep -rn "COMP_TYPE" /usr/share/bash-completion/ finds
zero matches, and `_git`'s completion function produces byte-identical
output under COMP_TYPE 9 and 37.

Verification performed:
- End-to-end, via _warp_native_bash_completions itself (not just a
  standalone simulation), against gh (cobra) and git (bash-completion),
  confirming: gh now emits bare "checkout"/"checks" instead of the padded
  strings, and git's output is unchanged.
- Added app/assets/bundled/bootstrap/bash_native_completions_test.sh, a
  self-contained regression test (no gh/git dependency) covering both
  shapes the review asked for: a synthetic cobra-style completion function
  (padded under COMP_TYPE 9, bare under 37/42) and a synthetic ordinary
  bash-completion-style function (ignores COMP_TYPE entirely). Confirmed
  it fails against the pre-fix COMP_TYPE=9 behavior and passes against the
  fix.
- This script is not wired into any existing CI job -- the repo has no
  shell-script-level test harness for the bootstrap scripts (only Rust
  unit tests, which can't exercise bash's own completion machinery). Noted
  as a known limitation; run manually via
  `bash app/assets/bundled/bootstrap/bash_native_completions_test.sh`.
- Not run through cargo build/test/clippy (no Rust changes in this commit);
  ./script/format made no changes (it doesn't format shell scripts).
…po; harden zsh's analogous guard

Three fish defects reported from live testing, plus a proactive zsh fix
based on the same root cause as the first:

1. warp_run_generator_command_native_completions's empty-buffer guard
   (`test -n "$line"`) didn't cover whitespace-only lines. Measured: a
   single space enumerated $PATH (990+ matches, tens of KB) synchronously
   in the user's shell via `complete -C " "`. Fixed by trimming before the
   check (`test -n "$(string trim -- "$line")"`).

   Verified empirically with a real fish 3.7.0 session: pre-fix, a single
   space produced 1007 "9280;C" matches (51KB); post-fix, zero.

2. warp_preexec's generator-kill loop had a typo predating this PR: the
   loop variable is `pid` but the kill command used `$pids` (undefined,
   expands to nothing), so `kill -9 $pids` never killed anything. This was
   previously dormant because of a separate negation bug (`test (! ...)`)
   this PR's earlier fix corrected, making the typo's effect live for the
   first time. Fixed `$pids` -> `$pid`.

   Verified empirically: with the pre-fix script, a background job tracked
   in `_warp_generator_pids` survived warp_preexec's kill loop (`jobs`
   still showed it running afterward); with the fix, the same job is
   killed (SIGKILL) as soon as warp_preexec fires for a real command.

3. Large result sets producing no menu at all is still under investigation
   by the orchestrator (bisecting the exact match-count threshold); not
   addressed in this commit pending that data.

Additionally, applied the same whitespace-trim guard to zsh's
warp_run_generator_command_foreground_completions, which had the identical
gap (`[[ -z $line ]]` doesn't catch whitespace-only). This is the same
class of bug as (1): completing a blank command-position word triggers a
full command enumeration in every shell's completion engine we've checked
so far (fish's `complete -C`, and this is standard zsh Tab-on-blank-buffer
behavior too). Bash and PowerShell were also checked and already handle
this correctly:
  - bash: `_warp_native_bash_completions` already returns early because
    a whitespace-only line's derived `$cmd` is empty
    (`[[ -z "$cmd" ]] && return`). Verified: 0 bytes of output for "   ".
  - PowerShell: `CompleteInput` on a whitespace-only line already returns
    0 matches and a negative ReplacementIndex/Length (this exact case is
    already documented in pwsh.ps1's comment). Verified with pwsh 7.6.5.

The zsh fix could not be empirically confirmed to reproduce end-to-end in
this sandbox: the completion widget's output is emitted mid-`zle` (via the
`compadd`-override shim, itself invoked through `list-choices`), which
writes directly to the controlling terminal rather than through the
calling shell's redirected stdout, and piping/subshelling the call (to
capture output another way) disables ZLE entirely per the code's own
documented `USEZLE`-clearing behavior -- so this fix is applied
defensively, on the strength of the same root-cause reasoning verified for
fish, rather than an empirically reproduced zsh-specific dump. Flagging
this as a known verification gap for the reviewer.

Not run through cargo build/test/clippy (no Rust changes in this commit);
./script/format made no changes (it doesn't format shell scripts).
…ontext

Two independent zsh native-completions fixes:

1. Report the replacement span (OSC 9280;S), extending the mechanism already
   built for PowerShell to zsh.

   Root cause: without a shell-reported span, input.rs falls back to a
   whitespace-derived guess at the typed token, then filters candidates by
   requiring them to start with that guess. This silently discards every
   completion that only replaces a sub-token of the current word -- any path
   after a literal `/` (`cd /et` -> candidate `etc` doesn't start with the
   guessed token `/et`), a `$`/`${` parameter sigil (`echo $HOM` -> candidate
   `HOME` doesn't start with `$HOM`), or similar.

   Fix: in the compadd override shim, report `start = ${#line} - ${#PREFIX}`,
   `length = ${#PREFIX}` before each match batch. This holds regardless of
   how $PREFIX arose (bare word, following an IPREFIX like `/` or `${`,
   inside an open quote, with a literal un-expanded `~`, or with a literal
   backslash-escape) because $PREFIX always carries the exact characters
   typed for the segment being completed, not an expanded or dequoted form.

   Verified end-to-end against a real zsh session (compinit -C, PTY capture
   of the actual OSC output) across 8 shapes: `cd /et` (S;3,3), `cd /usr/lo`
   (S;3,7), `echo $HOM` (S;6,3), `echo ${HO` (S;7,2), `foo=/tmp/` (S;4,5),
   `cd foo\ ba` (S;3,7, literal backslash), `cd "foo ba` (S;4,6, open quote,
   excluding the quote char), and `cd ~/Doc` (S;3,5, literal un-expanded
   `~`). Every case's span, once applied, reproduces exactly the typed
   prefix as a substring of the line, and the client's own filter (verified
   separately) accepts the real candidates it was previously discarding.

   Checked whether bash and fish share this problem: they don't, and this is
   now empirically confirmed rather than assumed. Both `_warp_native_bash_completions`
   and fish's `complete -C` return whole-word replacements as their
   candidates (e.g. `cd /et` -> bash emits `/etc`, fish emits `/etc/`), which
   already start with the client's whitespace-derived guess and so survive
   the existing filter with no span needed.

2. Fix zsh's `zstyle` context pattern, which never matched anything.

   Root cause: the `list-grouped`/`insert-tab`/`verbose`/`list-separator`
   styles were registered against
   `':completion:warp_complete_via_compadd_override:*'` -- the name of the
   registered *widget*. But this completion runs from inside the
   `zle-line-init` hook (see the comment on
   `_warp_native_completions_zle_line_init`), so $curcontext's leading
   component is always literally `zle-line-init` regardless of which shell
   command is being completed (measured, e.g.
   `zle-line-init:complete:atuin:argument-1`) -- never the widget name. So
   none of these styles were ever actually applied to any real completion
   request. In particular, `verbose` being unset meant `_describe`-driven
   completions (the mechanism `clap_complete`'s zsh generator, and many
   other completion functions, use for subcommand/flag descriptions) never
   populated real per-item descriptions -- `compdescribe`'s non-verbose mode
   leaves the description array empty, and our own compadd shim's `-d`
   detection was already working correctly; there was simply nothing in
   the description array for it to find.

   Fixed by matching `':completion:zle-line-init:*'` instead.

   Verified end-to-end with a real `atuin` binary (downloaded its actual
   release binary, generated its real `clap_complete`-produced zsh
   completion script, sourced it in a PTY-driven zsh session): before the
   fix, `atuin ` returned 32 matches with every description empty; after,
   the real per-subcommand descriptions come through (`setup` ->
   "Setup Atuin features", `history` -> "Manipulate shell history", etc.).
   The three subcommands that still show an empty description (`wrapped`,
   `config`, `contributors`) genuinely have no description in atuin's own
   `--help` output either, confirming this isn't a remaining gap. Also
   verified `atuin --h` (an `_arguments`-driven flag, not `_describe`) now
   correctly returns `--help` with description "Print help", confirming the
   fix isn't limited to the `_describe` code path.

Both fixes were tested together in the same session against the real
zsh_body.sh (not a scratch copy) to confirm the zstyle change doesn't
regress the span reporting or vice versa.

Not run through cargo build/test/clippy (no Rust changes in this commit).
./script/format made no changes (it doesn't format shell scripts).
`ls --col` under native completions inserted `--color` (plus a space,
offering `always` as a ghost), while a real interactive Tab inserts
`ls --color=` followed by the value list -- accepting `always` then landed
as a separate positional argument instead of the option's value, changing
the command's meaning.

Root cause: compadd's `-S suf` gives the string it adds after every match
and is meant to be inserted (e.g. `_arguments`'s
`'--color=-(never auto always)'` spec passes `-S '='` so the option and its
value join correctly). The `compadd` override shim already parses this into
`$asuf` via `zparseopts` (confirmed correct: `${(v)asuf}` holds `=` for
this exact case), but the match-display loop only ever appended the
directory suffix (`$dsuf`, from `-f`) -- `$asuf` was extracted and then
silently never used. Appended it to each match.

`$hsuf` (from `-s`) is `-S`'s display-only counterpart -- a "hint" shown but
never inserted, the same relationship `-p`/`$hpre` has to `-P`/`$apre` --
so it's deliberately left out of the inserted text.

Verified with a real zsh session (compinit -C, raw PTY capture of the OSC
output): `ls --col` now emits `9280;C;--color=` (was `9280;C;--color`).
Regression-checked `git ch` (8 matches with descriptions, unaffected) and
`cd /et` (`etc`, unaffected) to confirm ordinary completions with no `-S`
suffix are unchanged.

Not run through cargo build/test/clippy (no Rust changes in this commit).
./script/format made no changes (it doesn't format shell scripts).
…und block

Confirmed on Linux zsh: once a restored buffer is longer than the terminal
width minus the prompt's own width, ZLE's redraw clears the remainder of
the first display row with literal space characters (not an
erase-to-end-of-line escape) before moving to the next row with CUD. Every
byte from the wrap point on leaked into a background block -- exactly
`total - (columns - prompt_width)` bytes in each measured case (125/205/55
character buffers at two terminal widths).

Root cause: the first space of that fill doesn't match the buffer's own
text at that position, so it was treated as the first real mismatch
`EarlyOutputHandler::input()` uses to end the expected-echo window (per
existing, documented behavior -- this is also what makes a stale
registration eventually stop absorbing unrelated output). Once the window
closes, everything that follows leaks, even though the rest of the redraw
(the second row's real characters) does go on to match the buffer.

Fix: a space character no longer ends the window on its own, as long as at
least one live candidate still expects more of the registered pattern
(`awaiting_more_expected_echo`) -- narrower than "the window is open at
all," so a stray space after the pattern is already fully matched still
renders normally, only a space received mid-redraw is treated as wrap
padding. `move_up`/`move_down` (CUU/CUD) already didn't touch the window
either way (pure screen-position detail of a redraw already known to be in
progress), so no change was needed there.

Added `test_push_expected_echo_survives_wrapped_line_space_fill_at_the_wrap_boundary`,
reproducing the measured byte shape (first-row characters, space-fill,
CUD, second-row characters) and asserting it doesn't leak; and
`test_a_stray_space_after_a_full_match_still_leaks_normally`, confirming
the narrower scoping doesn't swallow unrelated output that happens to
start with a space once the pattern is already fully matched.

Not verified against a live build in this sandbox (cargo check -p warp
reliably OOMs here, as in every prior round of this PR) -- reviewed by
hand against the exact measured byte sequence and the existing test
patterns in this file, but requesting the verifier's usual full build/test
pass to confirm both new tests (and the existing 24) pass.

Separately: a second, related-looking finding from the same verification
round -- committed block headers and tab titles getting corrupted
(truncated and fused with command text) immediately after a completions
request -- was NOT fixed in this commit. My hypothesis, not yet confirmed:
`consume_expected_echo`'s swallow (this fix's new space case, and the
pre-existing CR/backspace/CUB/CUF cases) never advances any grid's own
cursor-position tracking for the swallowed characters, since they never
reach `block.input(c)` at all. If the same physical redraw is split across
this swallow and a later grid that does receive characters (e.g. once the
real active block starts), that grid's cursor tracking would be behind by
exactly the swallowed count, which is consistent with a fused/truncated
header. I have not verified this against the actual grid/cursor code, and
a real fix would need to advance cursor state without rendering visible
content, which is a larger change than I want to make speculatively --
flagging this for the verifier to check whether the corruption's byte
offset matches the swallowed count, which would confirm or rule this out.
… byte offsets

P0: typing e.g. "echo <accented/CJK/emoji char> Get-Ch" and pressing Tab
crashed the app (panic: "byte index N is not a char boundary"), twice
reported live -- the first crash auto-recovered into a fresh session
(losing the window/tab), the second took the window down entirely.

Root cause: `CommandCompletion.ReplacementIndex`/`ReplacementLength` are
.NET string offsets, i.e. UTF-16 code units, but pwsh.ps1 sent them as-is
over the OSC 9280;S wire message, and the client uses them as UTF-8 byte
offsets directly into `buffer_text` (`Span::slice`, `&source[start..end]`).
Any multi-byte character before the completed token shifts the two
countings apart and can land the byte index mid-character. Measured:
"echo 中 Get-Ch" is 13 UTF-16 units but 15 UTF-8 bytes; ReplacementIndex 7
(correct in UTF-16 units) falls inside "中"'s 3-byte UTF-8 encoding
(bytes 5..8) when misread as a byte offset. This was a known, named gap
in the original PowerShell span commit ("only exact for ASCII lines"),
not a new regression -- it just hadn't been exercised with non-ASCII input
until now. The pre-span whitespace-derived fallback never had this problem,
since it always derived offsets from the buffer itself.

Two fixes, at two different layers:

1. Convert at the source (pwsh.ps1), where the exact string is known:
   compute the UTF-8 byte length of `$line.Substring(0, ReplacementIndex)`
   and of the substring through the end of the replacement range via
   `[System.Text.Encoding]::UTF8.GetByteCount`, and send the difference as
   the byte-based start/length instead of the raw UTF-16 offsets. Verified
   with the installed pwsh for both a CJK character (3-byte UTF-8, 1
   UTF-16 unit) and an emoji (4-byte UTF-8, a UTF-16 surrogate pair, 2
   units) before the token: the converted byte range, sliced by hand
   against the actual UTF-8 bytes, yields exactly "Get-Ch" in both cases.
   This keeps the wire protocol uniformly byte-offset-based, matching what
   zsh/bash/fish already send, with no client-side plumbing changes.

2. Defense in depth: `Span::slice` itself can no longer panic on any input,
   by clamping both offsets to the nearest valid char boundary at or below
   them (and to the string's bounds). This covers any other span whose
   units turn out to be wrong for a reason not yet found -- a wrong menu
   is recoverable, a panicked window is not. Added `floor_char_boundary`
   (a dependency-free equivalent of the standard library's still-unstable
   `str::floor_char_boundary`) and five new tests in `meta_tests.rs`
   covering the exact crashing shape, out-of-bounds offsets in both
   directions, and an end-before-start span.

Verified: `cargo check -p warp_completer` and `cargo test -p warp_completer
--lib meta` both pass cleanly (6/6, including the 5 new tests) -- this
crate builds within this sandbox's memory limit, unlike the full `warp`
app crate. The `.ps1` change itself can't be exercised through that same
build, so it was verified directly against the installed pwsh (7.6.5) by
reproducing the exact byte-offset arithmetic from the two reported crash
lines and confirming the corrected slice.
The bash matrix verified the cobra fix works (bare names, gh/kubectl
correct) but found it regressed `make`: bash-completion's own `make`
completion script -- the one script out of 841 in a stock install that
actually reads $COMP_TYPE -- branches on it to choose between a full
directory-prefixed path (COMP_TYPE 9) and just the next path component
(any other value). Under 37, `make sub/dir/` + Tab returned `deploy`
instead of `sub/dir/deploy`; since the bare component no longer contains
the typed prefix, the client's own filter discarded it and no menu
appeared at all -- silently dead, one level into any prefixed target.

COMP_TYPE 9 is faithful to real readline for all 841 stock scripts (grep
confirms nothing else reads $COMP_TYPE); 37 was only faithful for 840.
Reverted to 9.

To still avoid cobra's own padded "name  (description)" entries (the
original problem 9 causes for cobra-generated scripts), the padding is
now split apart *after* the completion function returns, rather than
avoided by changing COMP_TYPE: a name, two or more spaces, then a
parenthesised description running to the very end of the entry, is split
into a bare name plus a real description -- something neither COMP_TYPE
value alone provided, since bash previously had no description channel
at all. Anchored narrowly (both the multi-space run and the trailing,
unescaped `)$` must be present) so a legitimate candidate that happens to
contain parentheses elsewhere passes through unchanged.

Found and fixed while implementing: the first version of the split regex
used `(.+)` for the name group, which is greedy and matches `.` against
spaces too -- with more than 2 padding spaces (cobra pads shorter names
further to align columns), it silently absorbed some of the padding into
the "name" instead of leaving all of it for the space-quantifier group.
Fixed by requiring the name group to end in a non-space character
(`(.*[^[:space:]])`), which cannot happen if trailing spaces are
absorbed. Caught this with a real `gh` completion (`checks` padded with
more spaces than `checkout`, to align both to the longest entry's width)
before it reached committed tests.

Verified against real gh, git, and a make-shaped Makefile in this sandbox:
- `gh pr che` -> `checkout` (description "Check out a pull request in
  git") and `checks` (description "Show CI status for a single pull
  request"), both clean, no trailing whitespace.
- `git ch` -> unaffected (`checkout`, `cherry-pick`, `cherry`, no
  descriptions -- bash-completion never reads $COMP_TYPE).
- `make sub/`, `make sub/dir/`, `make build/` against a real Makefile with
  nested targets -> full relative paths (`sub/dir/`, `sub/dir/deploy`,
  `build/artifact`), matching real interactive Tab.

Updated `bash_native_completions_test.sh`: the cobra fixture now also
asserts the recovered description text (not just that the name is bare),
and a new `make`-shaped fixture (a synthetic COMP_TYPE-branching compspec
returning a directory-prefixed path under 9 and a bare component
otherwise) locks in the directory-prefix behavior this revert restores.
Also wired the script into `script/presubmit` (`bash
bash_native_completions_test.sh`, alongside the existing PSScriptAnalyzer
step for the same reason) per the reviewer's point that a bootstrap
script with no test harness is exactly how a COMP_TYPE regression ships
unnoticed -- this was previously a manually-run script that nothing
required anyone to remember.

Verified: `bash app/assets/bundled/bootstrap/bash_native_completions_test.sh`
passes 4/4 (2 new assertions plus the make case). `bash -n` on
bash_body.sh, the test script, and script/presubmit all pass.
Same class of bug as the PowerShell UTF-16 crash, caught before it could
crash anything (Span::slice's new defensive clamp would have absorbed
it), but still wrong: zsh's `${#var}` counts *characters*, while the OSC
9280;S wire format is byte offsets (matching the client's own buffer
indexing and every other shell's span). Measured: any accented or CJK
character before the completed token shifts the reported start left by
exactly the extra UTF-8 byte count --

  ls /tmp/plain/xy   -> reported 14, correct byte start 14 (delta 0, ASCII)
  ls /tmp/café/xy    -> reported 13, correct byte start 14 (delta 1)
  ls /tmp/日本/ni    -> reported 11, correct byte start 15 (delta 4)
  echo café/xy       -> reported 10, correct byte start 11 (delta 1)

ASCII lines are unaffected, which is why ordinary testing missed it.

Fixed with `local LC_ALL=C` before the span computation, which makes
zsh's `${#...}` count bytes instead of characters (confirmed
empirically). Scoped via `local` to the rest of this `compadd()` call --
deliberately not narrower, since nothing later in the function does
character counting that C locale would change: `$#__hits`/`$#__dscr`/
`${#dirsuf}` are array element counts (locale-independent), and the
description prefix-strip (`##$__hits[$i] #`) is a literal byte-for-byte
match.

Verified against the real reported shapes (real files under `café/` and
`日本/` directories, a real zsh session, raw PTY capture of the OSC
output): `ls /tmp/utf8test/café/xy` now reports `S;3,22`, and slicing the
line's actual UTF-8 bytes at `[3:25]` (3 + 22 = 25, the line's real byte
length) decodes cleanly to `/tmp/utf8test/café/xy` -- not a character
count, and not a boundary violation. Same check for the CJK case
(`S;3,23`, slice `[3:26]` decodes to `/tmp/utf8test/日本/ni`) and the
plain ASCII control case (byte and character counts coincide, so it was
already correct and stays correct).

$PREFIX did not need separate treatment: it's already correctly counted
in bytes by the same `LC_ALL=C` setting, since both `${#_WARP_NATIVE_COMPLETIONS_LINE}`
and `${#PREFIX}` are evaluated under it.

Not run through cargo build/test/clippy (no Rust changes in this commit).
./script/format made no changes (it doesn't format shell scripts).
`terminal.input.honor_ps1 = true` leaked the hex-encoded restore text
(e.g. "4765742d4368696c") into a background block on every completions
request, persisting across Ctrl+U and unrelated commands. Absent
entirely with honor_ps1 = false.

Root cause: PSReadLine's redraw always rewinds with absolute cursor
addressing (CUP), but the previous rule only trusted a CUP to column 0,
deliberately conservative pending a real-prompt measurement (a redraw
that re-renders a nonempty prompt together with the buffer could in
principle address a column reflecting the prompt's own width rather than
the buffer's start). That measurement now exists: with a real 29-column
prompt, every redraw is `\x1b[2;30H` (column 30, i.e. column 29 in this
codebase's 0-based columns) -- exactly the case the conservative rule
declined to trust, so the conservative rule converted a hypothetical
wrong-position risk into a guaranteed leak whenever the prompt is
non-empty.

The same measurement gives the fix, and it's better than threading a
prompt width through: PSReadLine always re-renders the *entire* buffer
from its own start, so a CUP unconditionally means "the buffer's own
position 0" regardless of which screen column it lands on -- column 1
with a zero-width prompt and column 30 with a 29-wide one were both
measured to be the buffer's start. (An "empty" prompt isn't column 0
either -- PowerShell substitutes its own `PS>` fallback, measured at
column 8 -- reinforcing that the column itself carries no information
worth gating on for this line editor.) zsh, the other line editor this
matters for, was separately measured to never emit absolute cursor
addressing at all for this restore, so widening the rule has no effect
on it.

Fixed by having `goto`/`goto_col` (CUP/CHA) always rearm position 0,
rather than only when the reported column is 0.

Also addressed, from the same measurement: the echo is *cumulative*, not
one-shot -- a 12-character buffer was measured to re-echo as an
increasingly long prefix across 12 separate redraws ("1", "12", "123",
... rather than the full buffer once), each preceded by its own CUP. A
single seed wouldn't be enough; this already works correctly since
`goto`/`goto_col` call `rearm_at_column` unconditionally on every CUP
occurrence, not just the first -- confirmed with a new test simulating
exactly this shape.

Added two tests: one reproducing the measured nonzero-column case (CUP
to column 29, matching the real 29-column prompt) and confirming it no
longer leaks; one reproducing the cumulative multi-redraw echo shape
across 7 redraws of increasing length, each preceded by its own CUP.

Not verified against a live build in this sandbox (cargo check -p warp
reliably OOMs here) -- reviewed by hand against the exact measured
byte/column values and the file's existing test patterns, requesting the
verifier's usual full build/test pass (including the app-level repro:
honor_ps1 = true with a real prompt, no leaked hex on repeated requests).
OSC 9280's C (match) and D?description params are semicolon-delimited,
and the client only reads the third param, so a literal `;` inside a
match or description (e.g. a filename like semi;colon.txt, or a .NET
tooltip like "int Count { get; }") silently truncated everything after
it -- corrupting insertion, not just display. A BEL or ESC byte in the
same text would end the whole OSC sequence outright, before the
completions-parsing code even runs.

Fix the whole class at once by hex-encoding both fields in all four
shells, reusing each shell's existing warp_hex_encode_string /
Warp-Encode-HexString helper (already used for JSON hook payloads and
the in-progress buffer text):
- zsh: compadd shim's match/description display loop
- bash: _warp_native_bash_completions's COMPREPLY reply loop
- fish: warp_run_generator_command_native_completions's complete -C loop
- PowerShell: the Alt+3 handler's CompletionMatches loop

Client-side, add decode_hex_completions_payload (app/src/terminal/model/ansi/mod.rs)
to decode and validate the hex payload, degrading gracefully (skip the
match, or treat as no description) on a missing/malformed/non-UTF-8
payload rather than surfacing a wrong string. The S (replacement span)
OSC is untouched -- it's just two decimal numbers, no text content.

Verified:
- New Rust unit tests for decode_hex_completions_payload covering `;`,
  BEL, ESC, multibyte text, and missing/malformed hex (both mod_tests.rs
  and OSC-dispatch-level integration tests). cargo fmt clean; the
  full `warp`/`app` crate can't be compiled in this sandbox (reliably
  OOMs), so these are hand-reviewed against the exact `Params` type
  (`&[&[u8]]`) rather than compiler-verified.
- Extended bash_native_completions_test.sh with a semicolon-containing
  match, and updated its OSC-payload collection helper to hex-decode
  before comparing (all 5 cases pass).
- Empirically verified each shell's actual emission code (copied
  verbatim from each script) against real zsh/bash/fish/pwsh
  interpreters: a match/description containing `;`, BEL, or ESC now
  arrives on the wire as pure hex digits and decodes back to the exact
  original text.
- Span::slice's doc comment attributed the UTF-16-to-byte-offset
  conversion to "the client-side conversion at the OSC boundary", but
  the conversion happens shell-side, in pwsh.ps1. Corrected so a future
  units bug isn't chased in the wrong layer.
- pwsh.ps1's replacement-span computation could throw if a shell ever
  reported ReplacementIndex + ReplacementLength past the end of the
  line (Substring would throw, and the surrounding try/catch would
  turn that into a silent, warning-free empty completions response).
  Not reproducible with any real CommandCompletion input tried, but
  clamped defensively anyway -- costs nothing, and a silent empty
  response is a worse failure mode than a slightly-wrong span.

Verified: cargo fmt clean; `cargo test -p warp_completer --lib meta`
(6/6, unaffected by the doc-only change); a standalone pwsh script
confirmed the clamp no longer throws for an out-of-range
ReplacementIndex+ReplacementLength while leaving the normal in-range
case's computed span unchanged.
Verifier proved by execution (Tab+Enter) that native completions inserted
nothing for any multi-component path (cd /et, ls /tmp/plain/xy, cat
~/.zsh, ls /workspace/warp/cra), despite real zsh having matches.

Root cause, confirmed empirically by instrumenting the real compadd shim
in a live zsh session: _path_files (the completion function behind path
arguments for cd/ls/cat/etc.) restores $PREFIX to the *entire* remaining
path before calling compadd (e.g. "/tmp/zsh_path_test/et" for
`cd /tmp/zsh_path_test/et`, not just "et"), and reports the directory
portion separately via compadd's `-p` flag (parsed into $hpre) purely for
*display* -- the real match strings are bare basenames ("etc"). The
existing replacement-span computation used the whole $PREFIX, reporting
a span the actual match text ("etc") never starts with. The client's own
filter then discarded every candidate, silently doing nothing on Tab.

Fixed by stripping $hpre (and $apre, from -P, handled the same way but
not directly reproduced) from the front of $PREFIX when it's a genuine
prefix, before computing the span -- matching what _path_files itself
excludes from insertion.

Verified against a real zsh 5.9 session (compinit-initialized, driving
the actual `warp_run_generator_command_foreground_completions` entry
point end to end, not just static reasoning):
- `cd /tmp/zsh_path_test/et` (2-level nested path): span corrected from
  (3,21) [covering the whole path, which "etc"/"etcetera" don't start
  with] to (22,2) [covering just "et"] -- matches now pass the client's
  filter.
- `cd /et` (real /etc): span (4,2), one match "etc" -- matches the
  verifier's exact repro.
- `ls /tmp/plain/xy` (two matches xyz/xyzzy): span (14,2), both pass.
- `ls --color=` (value completion after the -S suffix fix): span
  (11,0), matches never/always/auto -- already correct before this fix
  (PREFIX was already empty there), confirming the reported "no menu"
  for this case is not a shell-side bug; flagging back to the
  orchestrator as likely client-side.
- Regression-checked `echo $HOM` (sub-token case unaffected by hpre,
  since it's empty there): unchanged, span (6,3), still matches "HOME".

`zsh -n` syntax check passes. Not independently re-verified in-app
(no computer-use in this environment); requesting the verifier's zsh
matrix be re-run on this commit.
…rip control chars from accepted completions

Fixes four issues found by verification of the hex-encoding commit (f852ae0):

1. BLOCKING: bash's and fish's warp_hex_encode_string piped through `echo`,
   which treats an argument that looks like one of its own flags (-n, -e,
   -E) as that flag instead of literal text. Measured: kubectl -, ssh -,
   and npm - all offer exactly such a candidate as a real completion, so
   `-n` encoded to nothing and `-e`/`-E` encoded to just echo's own
   trailing newline.
2. The same `echo` appended a trailing newline to every payload (latent
   until now).
3. Forking two processes per hex-encode call (echo|od|tr) cost ~4x on a
   large completions result set (measured: 8.4s -> ~2s for 3656 matches).

Fixed bash's encoder with a pure-bash, no-fork loop using `printf -v` byte
by byte (LC_ALL=C for byte-safe indexing), which fixes all three at once.
Fixed fish's encoder by switching `echo` to `printf '%s'` (fish's echo has
the same flag-swallowing bug).

4. The cobra description-splitter regex greedily backtracked to the last
   multi-space run in an entry, so a description that itself contained a
   parenthesised, multi-space-padded aside folded part of the description
   into the name. Separately, the regex alone couldn't distinguish a
   genuine cobra-padded reply from a real candidate that merely looks
   padded (e.g. a file named "Backup  (copy)"), splitting the latter too.

Fixed the regex to structurally stop at the first multi-space run instead
of the last, and added a structural guard: cobra only pads a reply with
more than one match and pads every entry in such a reply, so the split is
only applied when there are 2+ non-empty COMPREPLY entries and all of
them match the padded shape -- never for a lone candidate that merely
looks padded.

Also, client-side: an accepted completion match/prefix can contain a
literal control character (e.g. a real but pathological filename with an
embedded newline), which previously got inserted verbatim -- a newline in
particular puts the input editor into a stuck multi-row state (Ctrl+U
only clears the current row) and silently disables native shell
completions for the rest of that buffer (should_use_native_shell_completions
bails on a multiline buffer). Added strip_control_characters (input.rs),
applied to both completion-acceptance insertion paths, with unit tests
per control-character class (newline, CR, BEL, ESC, tab, DEL, a C1
control), distinct from a deliberate multi-line paste, which the editor
continues to support unmodified.

Verified:
- bash_native_completions_test.sh: 10/10 passing, including new fixtures
  for the exact -n case, an exact-byte assertion on the encoder itself,
  a nested-paren cobra description, and a lone real "Backup  (copy)"-
  shaped candidate that must not be split.
- Directly benchmarked the old vs. new bash encoder at the reported
  3656-match scale (real timing, not estimated): ~4.47s -> ~0.19s for
  the encoder alone; ~1.99s for the full completions pipeline at the
  same scale (down from the originally reported 8.4s).
- Fish's fixed encoder verified directly against -n/-e/-E/multibyte
  cases with a real fish 3.7.0 interpreter.
- `bash -n`/`fish -n` syntax checks pass; `cargo fmt` clean on input.rs.
  input.rs's changes are hand-reviewed, not compiler-verified (the full
  `app` crate reliably OOMs in this sandbox, as in every prior Rust
  change in this PR) -- carefully checked against Cow<str> borrow/move
  semantics inside the FnMut closures it's used in.
zsh's `ls --col` + Tab correctly completes to `ls --color=` (the -S
suffix fix from an earlier round), but insert_completion_result_into_editor
unconditionally appends a trailing space unless the completion ends with
a path separator. `--color=` doesn't end with a slash, so the editor
appended a space after it, leaving the cursor one character past where
a hand-typed value should go -- typing "always" next produced
"ls --color= always" instead of "ls --color=always".

Extend the no-trailing-space exemption to also cover a completion ending
in '=', matching the shell-side convention this exact case follows (an
'=' suffix means a value goes directly after it, with no space). This is
shell-agnostic and client-side, so it applies uniformly regardless of
which shell produced the '='-suffixed completion.

Found during zsh/Linux in-app verification: `ls --col` + Tab (completing
to `--color=` via the -S suffix) worked, and typing `=` by hand then
Tab (retriggering completions) produced a correct value menu -- but
accepting the first completion left the cursor past a trailing space,
so hand-typing the value produced the extra space. Confirmed as a
client-side cursor/insertion issue, not a shell-side one; the shell's
own OSC output (span, suffix) was already correct.

Verified: `cargo fmt` clean on input.rs. Not compiler-verified -- the
full `app` crate reliably OOMs in this sandbox, as with every prior Rust
change in this PR -- hand-reviewed against the existing, identical
`ends_with` condition pattern immediately above it.
…uiltin

fish's warp_hex_encode_string piped through `od -An -v -tx1 | command tr -d
' \n'` -- two external process forks per call. Measured to cost ~1ms per
candidate on a large completions result set (e.g. `git checkout ` with
thousands of branches, each match and description encoded separately),
7.2s in the shell for one such request, of which complete -C itself is
only 0.137s -- the rest is almost entirely these forks.

Unlike bash, fish has no byte-safe equivalent of `LC_ALL=C` string
indexing (fish strings are Unicode codepoints internally, not raw bytes),
so a true no-fork encoder isn't available here the way it was for bash's
rewrite. But `od`'s own output only needs its spaces and line-wrap
newlines stripped, which fish's builtin `string replace` can do without
forking `tr`. This halves the per-call fork count (1 fork instead of 2),
without touching od itself (still needed to get raw UTF-8 bytes out of a
fish string).

Verified: round-trips correctly for -n/-e/-E (the previously-broken
flag-swallowing cases), multibyte (café), semicolon, embedded newline,
short and multi-line (>16 byte, wrapping od's line width) inputs, and
empty string, all against the real installed fish 3.7.0 interpreter.
`fish -n` syntax check passes.

This is a partial mitigation, not a full fix -- forking od at all is
still real cost at scale, and does not by itself make as-you-type
completions viable for large result sets in fish. Investigating the
separate as-you-type request-stream issue (reported alongside this) is
higher priority and not yet resolved.
open_completion_suggestions gates every as-you-type dispatch on
Block::is_command_grid_active() (state == BlockState::BeforeExecution),
which exists to avoid firing completions while a long-running user
command occupies the foreground -- but a native-shell-completions
generator command is itself exactly such a foreground command. The gate
goes false the moment that generator command starts and stays false
until it finishes, and since the check only ever runs inside a
keystroke's own edit-event handler, no *new* keystroke means no
recheck. Typing faster than a request's round trip means every
keystroke after the first lands while the gate is closed and silently
no-ops -- no request ever fires for the buffer the user is left looking
at, even once the shell goes idle again moments later. This also starves
handle_completion_suggestions_results's staleness guard of a
replacement request: a stale result is correctly dropped, but nothing
was ever going to retry for the buffer that superseded it.

Confirmed by a verifier instrumenting the gate directly: slow typing
(2s/character) got a request per keystroke; a fast burst of the same
10 characters got exactly one request (for the third character) and
seven consecutive blocked checks, with no request ever issued for the
final buffer. The gate also blocks a keystroke landing mid-burst during
otherwise-slow typing, not only a fast burst's last character -- so the
fix needs to fire on every return to idle, not just once typing appears
to have stopped.

Fix, trailing-edge: track the editor snapshot the last as-you-type
native-completions dispatch was computed from
(native_completions_as_you_type_dispatch_snapshot). On every
AnsiHandlerEvent::Precmd (i.e. every time the active block returns to
idle, for any reason -- a real command finishing or a completions
generator command finishing), check whether the current buffer differs
from that snapshot, and if so, dispatch once more via
open_completion_suggestions for whatever the buffer is now. Precmd is
the same hook that flips is_command_grid_active() back to true.

The loop-guard invariant -- comparing against the last *dispatched*
snapshot, not just checking whether results are pending -- means a
successful retry updates the tracked snapshot to the buffer it was
just dispatched for, so the very next Precmd sees no difference and
does nothing unless the user typed more since. This also naturally
coalesces a fast burst into one trailing request for the final state,
rather than a queue of stale ones.

Extracted the actual decision (does the buffer differ from the last
dispatch) into a small generic function, should_retry_as_you_type_
completions, specifically so it can be unit tested without a live
EditorSnapshot (which has no public constructor outside its own
module) -- including the exact loop-guard case (unchanged buffer must
not retry).

Verified: cargo fmt clean on input.rs. The core decision logic
(should_retry_as_you_type_completions) was additionally verified via a
standalone rustc compile outside this sandbox's memory-constrained app
crate, confirming all three cases (no prior dispatch, unchanged buffer,
changed buffer) behave as intended. The rest of the change (wiring
into the existing model_events subscription, the new field, and the
dispatch-time tracking in run_completions_async) is hand-reviewed
against the exact existing patterns it mirrors (the sibling
TerminalModeSwapped subscription arm, and the existing
editor.read(ctx, |view, ctx| view.snapshot_model(ctx)) idiom used
elsewhere in this same function) rather than compiler-verified -- the
full app crate reliably OOMs in this sandbox, as with every prior Rust
change in this PR. Not verified live (no computer-use in this
environment); requesting the verifier who instrumented the gate
re-run both the fast-burst and mid-burst-during-slow-typing
experiments against this commit to confirm the blocked keystrokes stop
disappearing.
…shot)

The trailing-edge retry (previous commit) compared whole EditorSnapshot
values to decide whether the buffer had moved on since the last
dispatch. Verified live: of 23 retries in one session, only 5 had an
actually-changed buffer -- the other 18 fired with identical text and
identical selections and dispatched a redundant, duplicate request for
the buffer that had just been dispatched. EditorSnapshot's own derived
PartialEq compares more than text and selections (its internal
buffer_text_runs), and a completions round trip perturbs that without
changing anything visible -- so two snapshots that looked the same to
the user still compared unequal.

Severity was bounded (fires once per dispatch, the following Precmd is
correctly guarded) rather than an infinite loop, but it silently doubled
the shell work for roughly every as-you-type completion: an extra full
foreground generator command each time, cheap on a small result set,
not cheap on a large one.

Fix: introduced AsYouTypeCompletionsBufferState, holding only the two
fields that actually matter (text, selections), built from
EditorSnapshot's own accessors. The retry guard now compares that
narrower type instead of the whole EditorSnapshot, so a round trip's
internal perturbation of buffer_text_runs no longer produces a false
"changed" result. should_retry_as_you_type_completions itself is
unchanged -- generic and still correct -- the fix is entirely in what
gets passed to it.

Also fixed the test gap that let this through: every existing test for
should_retry_as_you_type_completions instantiated the generic with
String, so none of them ever exercised EditorSnapshot's real equality
behavior. Added as_you_type_completions_buffer_state_tests, testing the
exact AsYouTypeCompletionsBufferState type used in production
(constructed directly, with real string_offset::CharOffset/vec1::Vec1
values, matching this file's own existing use of Vec1::new elsewhere)
-- including the case that would have caught the regression: identical
text and cursor position must not trigger a retry.

Verified: cargo fmt clean on input.rs. The new tests were manually
traced against AsYouTypeCompletionsBufferState's derived PartialEq
(structural equality over `text: String` and
`selections: Vec1<Range<CharOffset>>`, both already known to be
correctly Eq/PartialEq-comparable from their own crates) rather than
compiled and run -- the full app crate reliably OOMs in this sandbox,
as with every prior Rust change in this PR. Not verified live;
requesting the verifier who found this re-run their session-long
instrumentation (the text_differs/sel_differs logging) against this
commit to confirm the 18 redundant-request cases are gone while the 5
genuine ones and the loop guard both still hold.
…cation

A verifier sweeping macOS as-you-type behavior found the same shell
command (git log --, cd /et, echo \$HOM, etc.) intermittently produce no
completions menu at all, in a configuration where natural-language
detection classifies each buffer's input type (AI vs. Shell)
asynchronously and per-buffer. Investigated their hypothesis by reading
should_use_native_shell_completions's call site.

Confirmed: run_completions_async re-reads self.ai_input_model.input_type()
fresh on every call, and both the original as-you-type dispatch and the
new trailing-edge retry (previous two commits) go through this exact
function. detect_and_set_input_type runs its classification
asynchronously (spawned, debounced), so its verdict can genuinely change
between when a request is first dispatched and when a later retry
re-evaluates eligibility -- if the buffer gets classified as AI input in
that window, native shell completions silently stop being used for that
buffer, with nothing in the existing code distinguishing this from the
open_completion_suggestions gate blocking dispatch entirely (both are
currently silent).

This isn't a bug introduced by the retry work -- it's a property of the
pre-existing is_ai() exclusion in should_use_native_shell_completions,
which the retry's re-evaluation-at-a-later-time exposes to more than a
single one-shot dispatch would be. Not fixing the classifier interaction
here (that's a separate design question -- e.g. whether completions
eligibility should be frozen for a request's duration, or whether NLD
should defer to an in-flight completions round trip) -- adding
observability so it stops being invisible, per the verifier's own
question about whether a silent skip is distinguishable in logs.

Added a debug! log line (per logging-and-error-reporting: hot per-
keystroke path, so debug not info; no buffer/command text logged,
consistent with never logging user-generated command content) firing
specifically when AI-mode classification is the sole reason native
shell completions were skipped -- distinguishable from every other
skip reason (feature disabled, shell doesn't support it, multiline
buffer) by construction, since those are checked in the same
condition.

Verified: cargo fmt clean; log::debug!/log::warn! already used
extensively elsewhere in this exact file, confirming the macro is
already in scope with no new import needed. Not compiled -- the app
crate still OOMs in this sandbox. Not verified live; this is purely
additive (no behavior change), so the risk is contained to the log
call itself compiling and firing under the exact boolean condition
described. Requesting the verifier enable this log level and re-run a
failing case to confirm whether the hypothesis holds (the skip fires
exactly on the buffers that went quiet) or whether the real cause lies
elsewhere.
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