From 1f542c254a7a06711c0de6ccd24f8771ebfd6cc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 20:16:46 +0000 Subject: [PATCH 1/7] Add product spec for honoring user shell bindkeys (GH-537) Captures desired behavior for honoring user-defined zsh, bash, and fish keybindings in Warp's input editor: live-shell discovery, best-effort widget coverage, precedence (user-customized Warp > shell bindkeys > Warp defaults), per-tab independence, and graceful fallback when widgets are unsupported. Open questions are surfaced inline for the tech spec to resolve. https://claude.ai/code/session_01AbtuqGqnwn4X9yo2jdQAs5 --- specs/GH537/product.md | 312 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100644 specs/GH537/product.md diff --git a/specs/GH537/product.md b/specs/GH537/product.md new file mode 100644 index 000000000..fd33eacf0 --- /dev/null +++ b/specs/GH537/product.md @@ -0,0 +1,312 @@ +# PRODUCT.md — Honor user-defined shell bindkeys in Warp's input editor + +Issue: https://github.com/warpdotdev/warp/issues/537 + +Figma: none provided. + +## Summary + +Warp's input editor currently ignores user-defined keybindings declared in the +user's shell — `bindkey` in zsh, `bind` / `~/.inputrc` in bash readline, and +`bind` in fish. When a user types in a Warp prompt, those customizations have +no effect, even though the same keys work in any other terminal running the +same shell. This spec covers honoring those user bindings inside the Warp +input editor (the prompt where shell commands are typed) for zsh, bash, and +fish, sourced from the user's actual running shell so that whatever the shell +reports is what Warp respects. + +## Goals / Non-goals + +In scope: + +- Honoring user-defined keybindings in Warp's shell command input editor for + zsh, bash, and fish sessions. +- Discovery via the user's live shell session (querying the shell for its + current binding table), so dynamic and conditionally-declared bindings are + picked up — not by parsing rc files. +- Best-effort coverage of the action set: any shell widget / readline function + / fish input function that has a Warp-input equivalent is honored. Widgets + with no clean Warp equivalent degrade gracefully (see below) rather than + silently stealing the keystroke. +- Keymap modes: emacs vs vi (insert/command/visual) for the shells that + expose them. Mode switches initiated by the user (e.g. `bindkey -v`, + `set -o vi`, vi-mode plugins, fish bind modes) take effect without restart. +- Conflict policy with Warp's own keybindings (see Behavior #14). + +Out of scope for this spec: + +- Bindings inside surfaces other than the shell command input editor — the + AI prompt input, command palette, search, settings — keep their existing + Warp keybindings unchanged. (See Open question at #5.) +- Other shells (PowerShell, nushell, xonsh, csh family). Adding more shells + follows the same shape but is not required for this issue to land. +- A Warp-native keybinding-import config surface where users redeclare their + bindings inside Warp settings. The intent of this issue is "the bindings I + already have should just work" — not "give me yet another config". +- Static parsing of `~/.zshrc`, `~/.inputrc`, `~/.config/fish/`, etc. The + source of truth is the live shell. +- Honoring shell-level abbreviations, aliases, completions, syntax + highlighting, or autosuggestion plugins. Only key-to-action bindings. + +## Behavior + +### Discovery and lifecycle + +1. When a Warp tab starts a supported shell (zsh, bash, fish), Warp queries + that shell for its current keybinding table once the shell is ready to + accept commands but before the first user keystroke is processed by the + input editor. Until the table arrives, the input editor uses Warp's + default keymap; once the table arrives, user bindings take effect on the + next keystroke. + +2. The query mechanism is shell-native and visible only to Warp internals — + the user does not see the query command echoed in their scrollback, in + history, or in any block. Equivalents in spirit (not literal): + - zsh: `bindkey -L` for each keymap (`main`, `emacs`, `viins`, `vicmd`, + `vivis`, `viopp`, `command`, `isearch`, `menuselect`, plus any + user-defined keymaps). + - bash: `bind -p` for the current keymap and `bind -p -m emacs` / + `-m vi-insert` / `-m vi-command` for the others. + - fish: `bind` with no args, plus `bind -M insert` / `default` / + `visual` / etc. + +3. If the shell fails to start, exits before the query completes, or returns + an unparseable response, Warp logs a diagnostic and falls back to its + default keymap for that tab. The tab remains usable; no user-facing error + toast is required. + +4. When the user changes their bindings inside an existing session + (`bindkey '^X^E' edit-command-line`, `bind '"\C-x\C-e": edit-and-execute-command'`, + sourcing a new rc file mid-session, switching emacs/vi mode), Warp picks + up the change without requiring a restart of the tab. The implementation + may either re-query on a signal (prompt redraw, mode change, OSC hint) or + re-query periodically — but the user-visible invariant is: a binding + declared at the shell prompt is honored on the next keystroke after the + declaration completes. + +5. Each tab tracks its own bindings independently. Changing bindings in one + tab does not affect another tab, even if both run the same shell. + +6. Closing and reopening a tab re-queries from scratch. Warp does not cache + bindings across tab restarts; the user's current shell state is always + the source of truth. + +### Honoring bindings in the input editor + +7. While the user is typing in the shell command input editor, every key + press is matched against the user's binding table for the active keymap + first. If a match is found and the bound action has a Warp equivalent, + Warp performs that action and consumes the keystroke. Otherwise the + keystroke falls through to Warp's default handling (see #14 for the + precedence list). + +8. Multi-key sequences (`^X^E`, `^[f`, `gg`, fish `\\cx\\ce`) are honored as + a single action. While Warp is mid-sequence (one or more matching prefix + keys received but the sequence is not yet uniquely resolved), no action + fires and no character is inserted; the sequence either completes (action + fires) or is abandoned by a non-matching keystroke (in which case Warp + handles all the buffered keys as it would have without the binding — + matching readline / ZLE behavior). + +9. `self-insert` and "insert literal string" bindings (e.g. zsh + `bindkey -s '^X' 'echo hi\n'`) insert the literal text into the input + buffer at the cursor, exactly as the equivalent shell would. Newlines in + the inserted string are treated as literal newlines in the input buffer + unless the binding is `accept-line` or equivalent. + +10. The full set of widgets / functions whose semantics Warp must honor when + bound includes, at minimum: + - Cursor motion: `forward-char`, `backward-char`, `forward-word`, + `backward-word`, `beginning-of-line`, `end-of-line`, + `beginning-of-buffer-or-history`, `end-of-buffer-or-history`. + - Deletion: `backward-delete-char`, `delete-char`, `backward-kill-word`, + `kill-word`, `kill-line`, `backward-kill-line`, `kill-whole-line`, + `unix-word-rubout`, `unix-line-discard`. + - Yank / kill ring: `yank`, `yank-pop`, `kill-region`, `copy-region-as-kill`. + - History: `up-line-or-history`, `down-line-or-history`, `up-history`, + `down-history`, `history-incremental-search-backward`, + `history-incremental-search-forward`, + `history-search-backward` / `-forward`, fish's history-pager bindings. + - Editing: `transpose-chars`, `transpose-words`, `upcase-word`, + `downcase-word`, `capitalize-word`, `quoted-insert`, `tab-insert`, + `overwrite-mode`, `undo`, `redo` (where supported). + - Submission and abort: `accept-line`, `accept-and-hold`, + `accept-and-infer-next-history`, `accept-search`, `send-break` + (`^C`), `eof` / `delete-char-or-list` (`^D` on empty line). + - Vi mode: `vi-cmd-mode`, `vi-insert`, `vi-replace`, `vi-add-next`, + `vi-add-eol`, `vi-change`, `vi-delete`, `vi-yank`, `vi-put-after`, + `vi-put-before`, `vi-find-next-char` / `-prev-char`, `vi-repeat-find`, + `vi-up-line-or-history`, `vi-down-line-or-history`, `vi-goto-mark`, + `vi-set-mark`, `vi-replace-chars`, `vi-substitute`, + `vi-change-whole-line`, plus fish-mode equivalents. + - Completion: `complete-word`, `expand-or-complete`, + `expand-or-complete-prefix`, `menu-complete`, `reverse-menu-complete`. + These trigger Warp's existing completion UI (not the shell's), but + from whichever key the user has bound them to. Behavior of the + completion UI itself is unchanged by this spec. + +11. Widgets that have no Warp equivalent (examples: `quoted-insert` in some + edge cases, `redisplay`, `clear-screen` if Warp already binds it + differently, user-defined named widgets / functions whose body is shell + code) are handled as follows: + - If the widget has a documented behavior Warp can replicate cheaply, + Warp replicates it. + - Otherwise the keystroke falls through to Warp's default handling for + that key, and Warp emits a one-time-per-session diagnostic naming the + unsupported widget and the key it was bound to. Telemetry records the + unsupported widget name (no key contents) so Warp can prioritize + coverage. + - **Open question:** for user-defined shell-function widgets (e.g. zsh + `zle -N my-widget; bindkey '^X' my-widget`), v1 treats these as + unsupported. A future iteration could forward the keystroke to the + shell to let it execute the widget. Confirm v1 = unsupported is + acceptable. + +12. `clear-screen` (typically `^L`) clears Warp's block list to the current + prompt, matching the user's expectation from a real terminal — even if + Warp's default `^L` already does this, the binding must continue to + work when remapped to another key. + +### Modes + +13. When the shell is in vi mode, the active keymap follows the shell's + current mode (insert / command / visual / replace). Warp learns about + mode transitions through the same mechanism it uses for binding + discovery (see #4) — when the shell signals a mode change (by repaint, + OSC, prompt update, or whichever signal the implementation lands on), + Warp's input editor switches keymaps so the next keystroke is matched + against the new map. Visible mode indicators (cursor shape, vim-mode + plugin status text in the prompt) remain whatever the shell already + drew; Warp does not add its own. + - **Open question:** what's the canonical signal for mode change across + the three shells? Tech spec must answer this concretely. If no + reliable signal exists for one shell, document the fallback (poll on + every prompt redraw, etc.). + +### Precedence and conflicts + +14. Resolution order for a single keystroke in the shell command input + editor, highest priority first: + 1. User-customized Warp keybindings (anything the user has explicitly + set in Warp settings). + 2. User shell bindkeys for the active keymap. + 3. Warp's default keybindings. + 4. Default character insertion. + + Rationale: a key the user has explicitly bound in Warp settings is the + strongest signal of intent. Below that, the user's shell bindings + override Warp's defaults — that is the entire point of this issue. Warp + defaults are the floor. + +15. When a user shell binding shadows a Warp default, no warning, banner, or + toast appears. The user already declared this binding in their shell + config; the override is the desired behavior. Diagnostics for shadowed + Warp defaults may be available through verbose logging but are not + user-facing. + +16. When a user shell binding cannot be honored because the bound widget is + unsupported (#11), the keystroke falls through to Warp's default — it + does not steal the keystroke and produce nothing. The user sees the + Warp default fire on that key, which may differ from what their shell + would have done. The diagnostic from #11 is the user's signal that + something they configured isn't supported yet. + +### Multi-tab and multi-shell scenarios + +17. A window with multiple tabs running different shells (one zsh, one + bash, one fish) honors each tab's bindings independently. Switching + focus between tabs changes the active binding table to that tab's. + +18. SSH sessions: when the user SSHes from a Warp tab to a remote host and + a shell starts on the remote, Warp does not query the remote shell for + bindings in v1. The local Warp input editor continues to use the + bindings of the local shell that started the tab, or Warp defaults if + the local shell wasn't a supported one. Honoring remote bindings + requires the remote-side Warp agent and is out of scope here. + +19. Subshells started inside a session (`bash` typed at a zsh prompt, + `tmux`, etc.) keep the parent tab's binding table. The user does not + see a re-query, and bindings the subshell may have configured do not + take effect in the Warp input editor. Re-querying on every subshell + transition is feasible but a follow-up. + +### Surface boundaries + +20. Bindings only apply while the user's input focus is in the shell + command input editor of a tab whose shell is one of zsh / bash / fish. + They do not affect: + - Warp command palette, settings, search, AI prompt input, + block-level chrome (the keystrokes there continue to use Warp's own + keymap). + - Tabs whose shell is not a supported shell — those tabs use Warp + defaults. + - Any modal overlay rendered above the input editor (file palette, + command palette, suggestions popover focus, etc.). + +21. Switching focus from the input editor to another surface and back does + not require re-querying. The binding table from the most recent query + remains valid for the duration of the tab unless invalidated by #4. + +### AI / agent prompt input + +22. The AI prompt input editor does not honor shell bindkeys by default — + it is not a shell, and shell vi/emacs muscle memory there would + conflict with the AI input's own conventions. + - **Open question:** add an opt-in setting "Use my shell bindings in AI + prompts too"? Default off either way. Resolve before implementation. + +### Settings, opt-out, and discoverability + +23. The feature is on by default for supported shells once it ships. + - **Open question:** ship behind a feature flag for staged rollout + (default off → dogfood → preview → stable), or default on from + release? Tech spec / release plan to decide. + +24. A single setting "Honor shell keybindings in input editor" lives under + the Keybindings section of settings. Toggling it off restores Warp's + default keymap for all tabs immediately (no restart). Toggling it back + on re-queries each tab's shell. + +25. The Keybindings settings page surfaces, somewhere reachable, a + read-only view of the bindings Warp has imported for the active tab — + enough that a user debugging "why didn't my binding work" can see what + Warp received from the shell and which entries Warp marked unsupported. + Format: a list of `key → action (status)` rows where status is one of + `honored`, `shadowed-by-warp-user`, `unsupported`. The exact UI is left + to the tech spec; the behavioral requirement is that the information is + discoverable without enabling debug logging. + +### Performance and correctness invariants + +26. The initial binding query must not block the user's first keystroke + perceptibly. If the query has not completed by the time the user types, + the keystroke is handled with Warp defaults; it is not buffered or + delayed. Late-arriving bindings apply to subsequent keystrokes. + +27. The query must not appear in the user's shell history, in scrollback, + in the block list, or as visible output. Side effects on the shell's + own state (kill ring, last-status `$?`, etc.) must be avoided or + cleaned up. + +28. Receiving a malformed or partial response from the shell never causes + a crash, hang, or stuck input editor. The fallback is always: drop the + bad data, log a diagnostic, keep using whatever binding table was + valid before. + +29. Existing Warp keybindings that the user has not customized continue to + work unchanged on tabs running unsupported shells, on tabs where the + feature is disabled, and on tabs where the query has not yet completed + or failed. + +## Open questions + +Collected from inline references above plus a few cross-cutting ones the +tech spec must resolve: + +- v1 handling of user-defined named widgets whose body is shell code (#11). +- Canonical signal for vi-mode transitions across zsh, bash, and fish (#13). +- AI prompt input opt-in for shell bindings (#22). +- Default-on vs feature-flagged staged rollout (#23). +- Telemetry events for unsupported widgets, query failures, and toggle + usage — confirm what we can / want to record under existing privacy + rules. From 143b9609a345fb5c721f5e69c872c0ea0c28374b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 20:20:45 +0000 Subject: [PATCH 2/7] Add tech spec for honoring user shell bindkeys (GH-537) Plans the implementation grounded in current Warp subsystems: bootstrap-side binding query via a new DProtoHook::ShellBindings DCS variant (zsh/bash/fish), per-tab ShellBindings storage on the Shell struct, fine-grained widget-to-InputAction expansion, a third shell_bindings tier in Keymap with precedence user-Warp > shell > defaults, plus settings toggle, feature flag for staged rollout, and telemetry. Resolves the open questions carried from product.md. https://claude.ai/code/session_01AbtuqGqnwn4X9yo2jdQAs5 --- specs/GH537/tech.md | 296 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 296 insertions(+) create mode 100644 specs/GH537/tech.md diff --git a/specs/GH537/tech.md b/specs/GH537/tech.md new file mode 100644 index 000000000..ba1dc7ebb --- /dev/null +++ b/specs/GH537/tech.md @@ -0,0 +1,296 @@ +# TECH.md — Honor user-defined shell bindkeys in Warp's input editor + +Issue: https://github.com/warpdotdev/warp/issues/537 +Product spec: [`product.md`](./product.md) + +## Context + +Warp's input editor receives raw keystrokes, matches them against the +`Keymap` table, and dispatches `InputAction` variants. Today that table +knows nothing about the user's shell bindings, so user customizations +(`bindkey '^X^E' edit-command-line`, readline `bind`, fish `bind`) are +ignored. See `product.md` for the user-visible behavior we want. + +Relevant code, with line ranges: + +- **Input editor and actions** — `app/src/terminal/input.rs (1072-1149)`. + `InputAction` is the dispatched action type. Today it covers + Warp-flavored actions (`FocusInputBox`, `CtrlR`, `CtrlD`, + `MaybeOpenCompletionSuggestions`, etc.) but does **not** have the + fine-grained editor verbs ZLE / readline expose + (`backward-kill-word`, `transpose-chars`, `kill-line`, `yank-pop`, + `up-history`, `vi-cmd-mode`, …). The buffer model lives in + `InputBufferModel` in the same file. `crates/editor/src/editor.rs + (18-55)` exposes the underlying `EditorView` trait. +- **Keymap** — `crates/warpui_core/src/keymap.rs (25-38, 44-49, 72-150)`. + `Keymap { fixed_bindings, editable_bindings }` indexed by name, with + `Trigger::{Keystrokes(Vec), Standard(StandardAction), + Custom(CustomTag)}` and `ContextPredicate` for context-scoped layering. + Resolution: `editable_bindings` (user-overridden) wins over + `fixed_bindings` (Warp defaults). Matching lives in + `crates/warpui_core/src/keymap/matcher.rs`. +- **Shell type and session** — `crates/warp_terminal/src/shell/mod.rs + (58-96, 250-255)`. `ShellType { Zsh, Bash, Fish, PowerShell }`, + `Shell { type, version, options, plugins, shell_path }`, + `ShellStarter::init()` at line 79. `app/src/terminal/local_tty/shell.rs + (1-200)` for spawn details. +- **Bootstrap and DCS hooks** — `app/src/terminal/bootstrap.rs (1-150)` + injects a per-shell init script from `bundled/bootstrap/{zsh,bash,fish, + pwsh}.sh`. Script-to-app communication uses + `app/src/terminal/model/ansi/dcs_hooks.rs (1-150)`: `DProtoHook` + variants (`Bootstrapped`, `Precmd`, `Preexec`, `InputBuffer`, + `InitShell`, …) carry hex-encoded JSON payloads + (`HEX_ENCODED_JSON_MARKER = 'd'`). DCS dispatch arrives as + `ModelEvent::PluggableNotification` in + `app/src/terminal/model_events.rs (468-472)`. **There is no live + "invisible command exec" primitive today**; bootstrap-emitted DCS + payloads are the right plumbing to extend. +- **Settings** — `app/src/terminal/keys_settings.rs (15-71, 26-34)`. + `define_settings_group!` macro is the pattern for new boolean toggles + (see `quake_mode_enabled`). Feature flags live in + `crates/warp_features/src/lib.rs (9+)`. +- **Telemetry** — `app/src/server/telemetry/events.rs (1237+, 2920)`. + `TelemetryEvent` enum + `send_telemetry_from_ctx!` macro. +- **Vi-mode tracking** — none today. The `vim` crate is Warp's own + in-editor vi emulation, not shell awareness. + +## Proposed changes + +The implementation has five logical pieces. Each maps cleanly to one +subsystem above. + +### 1. Bootstrap-side binding query + +Extend the bootstrap scripts to dump the user's binding table to Warp +via a new DCS hook variant. Doing the query in bootstrap (rather than +adding a runtime invisible-exec primitive) avoids polluting history, +scrollback, and last-status; it also runs before the first prompt so +bindings are available when the user starts typing. + +- `bundled/bootstrap/zsh.sh`: for each keymap (`main emacs viins vicmd + vivis viopp command isearch menuselect`), run `bindkey -L -M + $keymap` and emit a JSON object `{ keymap: [ { keys, widget }, … ] }`. + Also emit `KEYMAP` so the active keymap is known. +- `bundled/bootstrap/bash.sh`: `bind -p` for the current keymap and + `bind -p -m emacs / vi-insert / vi-command` for the others. Detect vi + vs emacs via `set -o | grep -E '^(vi|emacs)'`. +- `bundled/bootstrap/fish.sh`: `bind` (default mode), `bind -M insert`, + `bind -M default`, `bind -M visual`. Track `$fish_bind_mode`. + +The payload is emitted as a new `DProtoHook::ShellBindings` variant in +`dcs_hooks.rs` carrying `{ shell, keymaps: Vec, active_keymap, +schema_version }`. Reuse `HEX_ENCODED_JSON_MARKER`. + +Re-queries: extend the existing `Precmd` hook (already fired every +prompt) to include a 64-bit hash of the current binding table. The app +caches the last-seen hash per tab; on mismatch it asks the bootstrap +script to re-emit a full `ShellBindings` payload (via a small helper +function in the bootstrap script, triggered by an env-var flag). This +keeps steady-state cost to one hash computation per prompt while +correctly handling dynamic rebinds. + +### 2. Shell-bindings storage on `Shell` + +Add `bindings: Option` and `active_keymap: KeymapMode` to +the `Shell` struct in `crates/warp_terminal/src/shell/mod.rs`. New +types: + +```rust +pub struct ShellBindings { + pub schema_version: u32, + pub keymaps: HashMap>, + pub table_hash: u64, +} + +pub struct ShellBinding { + pub keys: Vec, // parsed from "^X^E", "\C-x\C-e", "\\cx\\ce" + pub widget: ShellWidget, // see #3 + pub raw_widget_name: String, // for telemetry/debug UI +} + +pub enum KeymapMode { Emacs, ViInsert, ViCommand, ViVisual, Other(String) } +``` + +Mutation flows through a new `ModelEvent::ShellBindingsUpdated { tab_id, +bindings }` raised when a `ShellBindings` DCS hook arrives. +`active_keymap` is updated from the `Precmd` payload. + +### 3. Widget mapping + +`ShellWidget` is an enum covering the widgets enumerated in PRODUCT.md +#10 — e.g. `BackwardKillWord`, `KillLine`, `AcceptLine`, `Yank`, +`HistorySearchBackward`, `ViCmdMode`, `CompleteWord`, +`SelfInsert(String)`, `Unsupported(String)`. Parsing +`bindkey -L` / `bind -p` / fish `bind` happens in a new +`crates/warp_terminal/src/shell/bindings.rs` with three small parsers +(one per shell) feeding a common normalizer. + +This forces a real expansion of `InputAction` in +`app/src/terminal/input.rs`. Today's coarse actions are not granular +enough; we add fine-grained verbs that match ZLE/readline semantics +(`BackwardKillWord`, `KillLine`, `TransposeChars`, `UpHistory`, +`HistorySearchBackward`, `Yank`, `YankPop`, `ViChange`, …) and route +them through `InputBufferModel`. Many of these are small additions +because the buffer already supports the underlying mutations +(word-aware cursor motion, kill-ring) — they just lack public action +entry points. + +A widget→`InputAction` map (`shell/widget_dispatch.rs`) is the bridge: +honored widgets dispatch the matching `InputAction`, +`SelfInsert(string)` writes the literal string to the buffer at the +cursor, `Unsupported(name)` returns a sentinel that tells the matcher +to fall through (PRODUCT #11, #16). + +### 4. Keymap matcher integration + +Extend `Keymap` in `crates/warpui_core/src/keymap.rs` with a third +binding tier that lives outside the persisted user keymap: + +```rust +pub struct Keymap { + pub fixed_bindings: Vec, + pub editable_bindings: Vec, + pub shell_bindings: Vec, // new +} +``` + +`ShellTabBinding` carries a tab id and the parsed `ShellBinding`. The +matcher consults bindings in this order (PRODUCT #14): + +1. `editable_bindings` scoped to tabs of any kind (user Warp overrides) +2. `shell_bindings` for the current tab's `tab_id` and `active_keymap` +3. `fixed_bindings` (Warp defaults) + +`shell_bindings` are populated by the `ShellBindingsUpdated` event and +cleared on tab close. Multi-tab independence (PRODUCT #5, #17) falls +out of tab-scoping naturally. Switching tabs swaps which +`shell_bindings` set is consulted via the existing +`ContextPredicate`-style filtering. + +Mid-sequence handling for multi-key bindings (`^X^E`, `gg`) reuses the +existing `Matcher::match_keystrokes` prefix logic — the shell bindings +participate in the same sequence machine, so PRODUCT #8 needs no +special case. + +### 5. Settings, feature flag, debug surface + +- New boolean setting in `app/src/terminal/keys_settings.rs` via + `define_settings_group!`: `honor_shell_bindkeys` (default `true`) + with `toml_path: "terminal.input.honor_shell_bindkeys"`. The matcher + short-circuits the `shell_bindings` tier when this is off (PRODUCT + #24). +- New `FeatureFlag::HonorShellBindkeys` in + `crates/warp_features/src/lib.rs` so we can stage rollout + (default off → dogfood → preview → stable). Resolves PRODUCT + open-question #23. +- Read-only debug view (PRODUCT #25): a small panel under the + Keybindings settings section that lists the active tab's + `ShellBindings` as `key → widget (status)` rows. Status is derived + by walking the matcher precedence chain. No new persistence. +- Telemetry events in + `app/src/server/telemetry/events.rs`: + - `HonorShellBindkeysToggled { enabled: bool }` + - `ShellBindkeysQueryFailed { shell_type, reason }` + - `UnsupportedShellBindkeyWidget { shell_type, widget_name }` — name + only, never key contents. + - `ShellBindkeysApplied { shell_type, honored_count, + unsupported_count }` once per tab on first apply. + +### Open questions carried from PRODUCT.md + +- **#11 (user-defined named widgets)** — v1 marks them `Unsupported` and + falls through. Forwarding the keystroke to the shell so it can run + the widget is feasible (write the key on the PTY) but introduces + ordering hazards with Warp's input editor; deferred. +- **#13 (vi-mode signal)** — zsh: `Precmd` payload includes + `$KEYMAP`. bash: `Precmd` includes the result of + `bind -v | grep editing-mode`. fish: `Precmd` includes + `$fish_bind_mode`. All three read cheaply on every prompt; no + separate hook needed. +- **#22 (AI prompt input)** — v1: not honored. The matcher's tab-scoped + `shell_bindings` tier only activates on tabs whose focus is the shell + command input editor, not on the AI prompt input. +- **#23 (rollout)** — gated by `FeatureFlag::HonorShellBindkeys` (above). + +## Risks and mitigations + +- **Bootstrap script size and shell start latency.** The query adds a + burst of work at shell start. Mitigation: dump in a single + invocation per keymap, drop output through DCS without invoking + external binaries, and benchmark on the slowest of our supported + shells. Budget: < 30 ms added to shell start; if a real shell blows + this we move that keymap behind on-demand fetch. +- **Plugin / framework interactions** (oh-my-zsh, prezto, fzf widgets, + zsh-vi-mode). These rebind heavily and often dynamically. The hash + re-query in `Precmd` (#1) catches any rebind that's settled before + a prompt redraws. Vi-mode plugins that swap keymaps reactively are + tracked through the `KEYMAP` payload field. +- **Widget coverage gaps.** Many widgets have no Warp equivalent + initially. The `Unsupported(name)` fallthrough plus telemetry on + hit count tells us which to prioritize. +- **Privacy.** Telemetry never includes key contents or widget bodies; + only widget names (which are well-known shell vocabulary) and + counts. +- **Bootstrap parsing fragility.** `bindkey -L`, `bind -p`, and fish + `bind` outputs are stable but quoting differs. Each parser has a + property-test fixture set covering edge cases (escapes, multi-byte, + bound to nothing, named widgets). + +## Testing and validation + +Tests are organized to map to numbered PRODUCT invariants. Use +`rust-unit-tests` for new crate-level coverage and +`warp-integration-test` for end-to-end flows. + +- **Bootstrap parsers** — unit tests in + `crates/warp_terminal/src/shell/bindings.rs` per shell, covering + fixtures generated from real `bindkey -L` / `bind -p` / `bind` + output. Asserts widget normalization. Covers PRODUCT #2, #9, #10, + multi-key sequences for #8. +- **Matcher precedence** — unit tests in + `crates/warpui_core/src/keymap/matcher.rs` that assert resolution + order across fixed / editable / shell tiers. Covers PRODUCT #14, #15. +- **Tab independence** — unit test that two `Shell` instances carry + independent `bindings`; matching one tab's keystroke does not + consult another tab's shell bindings. Covers PRODUCT #5, #17. +- **Lifecycle** — integration test (`crates/integration`) that boots a + zsh shell with a known rc file declaring `bindkey '^X^E' kill-line`, + starts a Warp tab, types `^X^E`, asserts the buffer was killed. + Repeat for bash and fish with shell-appropriate equivalents. Covers + PRODUCT #1, #2, #7. +- **Dynamic rebind** — integration test that types + `bindkey '^X^E' beginning-of-line` at the prompt, presses Enter, + then `^X^E` on the next prompt and asserts the new behavior. Covers + PRODUCT #4. +- **Vi mode** — integration test that runs `bindkey -v`, switches to + command mode, presses `gg`, asserts cursor at buffer start. Covers + PRODUCT #13. +- **Unsupported widget fallthrough** — integration test binding a key + to a user-defined named widget; assert Warp default fires on that + key and a telemetry event is recorded. Covers PRODUCT #11, #16. +- **Conflict precedence with user Warp keybinding** — set a Warp + keybinding for `^A`, also have shell `bindkey '^A' kill-whole-line`, + assert Warp keybinding wins. Covers PRODUCT #14 #1. +- **Shell start failure** — integration test where the bootstrap + errors mid-script: bindings are absent, default keymap applies, no + crash. Covers PRODUCT #3, #28. +- **Pre-bootstrap keystroke** — type before the `Bootstrapped` payload + arrives; assert the keystroke is handled with Warp defaults and not + buffered. Covers PRODUCT #26. +- **Setting toggle** — flip `honor_shell_bindkeys` off mid-session; + assert shell bindings stop applying without restart; flip on; assert + re-query happens. Covers PRODUCT #24. +- **Manual** — run Warp against a developer's real zsh+oh-my-zsh + config, a real bash with a populated `~/.inputrc`, and a real fish + with `bind` declarations in `~/.config/fish/`. Capture a short loom + walkthrough showing each shell's bindings honored. + +## Follow-ups + +- Forward unsupported user-defined widgets back to the shell (PRODUCT + #11 follow-up). +- Honor remote-shell bindings over SSH (PRODUCT #18). +- Re-query on subshell transitions (PRODUCT #19). +- Optional opt-in: honor shell bindings in the AI prompt input + (PRODUCT #22). +- Extend to PowerShell, nushell, xonsh once the core lands. From b41c0f8d7122b319df75e1a5e9a3e509cb23eba1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 20:42:23 +0000 Subject: [PATCH 3/7] Address Oz spec review on PR #9847 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six review comments, all important: 1. product.md #9: bindkey -s injects into the shell input queue, so a newline in the bound string accepts the line — clarified to match shell semantics rather than describing literal text insertion. 2. product.md #11 + tech.md telemetry: user-defined widget names can be arbitrary or private. Telemetry now only sends widget names from a documented shell-vocabulary allowlist; user-defined names redact to the bucket "user-defined". Allowlist co-located with the parser as single source of truth. 3. tech.md: ShellBindings DCS is a privileged terminal-control payload. Added a trust boundary — per-tab WARP_BOOTSTRAP_NONCE embedded in every payload, size caps (256 KiB total, 4 KiB per entry), strict schema validation, no partial application. Prevents arbitrary process output from spoofing bindings. 4. tech.md re-query: original flow was not implementable (the app can't flip an env var inside a running shell). Reworked so the bootstrap caches its own __warp_bindings_hash and decides shell-side when to re-emit; the app is consume-only. Preserves PRODUCT #26/#27. 5. tech.md fish: current bootstrap resets fish_key_bindings and installs four Warp binds on top, which would clobber user vi mode. New plan stops the reset, lets the user's scheme run, then reinstalls only Warp's four reserved binds explicitly per mode — resolves the original input-reporting conflict without losing user bindings. 6. tech.md vi mode: precmd-only mode tracking misses transitions that fire inside the editor (Esc, gg, etc). Added an in-app vi mode state machine driven by dispatched widgets, with shell-reported mode treated as initial state and resync. https://claude.ai/code/session_01AbtuqGqnwn4X9yo2jdQAs5 --- specs/GH537/product.md | 31 +++++---- specs/GH537/tech.md | 143 ++++++++++++++++++++++++++++++++++------- 2 files changed, 141 insertions(+), 33 deletions(-) diff --git a/specs/GH537/product.md b/specs/GH537/product.md index fd33eacf0..d0c8c2820 100644 --- a/specs/GH537/product.md +++ b/specs/GH537/product.md @@ -108,11 +108,16 @@ Out of scope for this spec: handles all the buffered keys as it would have without the binding — matching readline / ZLE behavior). -9. `self-insert` and "insert literal string" bindings (e.g. zsh - `bindkey -s '^X' 'echo hi\n'`) insert the literal text into the input - buffer at the cursor, exactly as the equivalent shell would. Newlines in - the inserted string are treated as literal newlines in the input buffer - unless the binding is `accept-line` or equivalent. +9. "Insert literal string" bindings (e.g. zsh `bindkey -s '^X' 'echo hi\n'`, + readline `"\C-x": "echo hi\n"`) inject the bound text into the input + stream as if the user had typed each character — matching shell + input-queue semantics, not literal text insertion. A newline in the + bound string therefore submits the line (it triggers `accept-line`), + `^A` moves the cursor to the start, and so on. The injected + characters are processed through the same key-resolution chain as + real keystrokes, including any other bindings they happen to trigger. + Plain `self-insert` (no string macro) inserts the literal key + character at the cursor as expected. 10. The full set of widgets / functions whose semantics Warp must honor when bound includes, at minimum: @@ -153,9 +158,14 @@ Out of scope for this spec: Warp replicates it. - Otherwise the keystroke falls through to Warp's default handling for that key, and Warp emits a one-time-per-session diagnostic naming the - unsupported widget and the key it was bound to. Telemetry records the - unsupported widget name (no key contents) so Warp can prioritize - coverage. + unsupported widget and the key it was bound to. Telemetry records + unsupported-widget hits with the widget name verbatim only when the + name appears in the documented shell-vocabulary allowlist (the + well-known ZLE, readline, and fish input function names enumerated in + #10); user-defined or otherwise unknown widget names are reported as + the bucket `user-defined` with no further identifying information, + since user-defined widget names can be arbitrary or private. Key + contents, key sequences, and binding bodies are never recorded. - **Open question:** for user-defined shell-function widgets (e.g. zsh `zle -N my-widget; bindkey '^X' my-widget`), v1 treats these as unsupported. A future iteration could forward the keystroke to the @@ -307,6 +317,5 @@ tech spec must resolve: - Canonical signal for vi-mode transitions across zsh, bash, and fish (#13). - AI prompt input opt-in for shell bindings (#22). - Default-on vs feature-flagged staged rollout (#23). -- Telemetry events for unsupported widgets, query failures, and toggle - usage — confirm what we can / want to record under existing privacy - rules. +- (Resolved) Telemetry redaction policy for widget names — see #11; the + rule is allowlist-or-bucket, never raw user-defined names. diff --git a/specs/GH537/tech.md b/specs/GH537/tech.md index ba1dc7ebb..27eadf3a9 100644 --- a/specs/GH537/tech.md +++ b/specs/GH537/tech.md @@ -74,20 +74,85 @@ bindings are available when the user starts typing. - `bundled/bootstrap/bash.sh`: `bind -p` for the current keymap and `bind -p -m emacs / vi-insert / vi-command` for the others. Detect vi vs emacs via `set -o | grep -E '^(vi|emacs)'`. -- `bundled/bootstrap/fish.sh`: `bind` (default mode), `bind -M insert`, - `bind -M default`, `bind -M visual`. Track `$fish_bind_mode`. +- `bundled/bootstrap/fish.sh`: this requires reworking the existing + bootstrap, which currently sets + `fish_key_bindings = fish_default_key_bindings` (line 306) and then + installs four Warp-required binds (`\cP`, `\ep`, `\ew`, `\ei`) on + top — clobbering any user `fish_vi_key_bindings` setting and any + user-installed binds. To honor user fish bindings without losing + Warp's required reporting binds, we change the bootstrap to: + + 1. Capture the user's `fish_key_bindings` value at the very top of + the bootstrap, and stop the unconditional reset at line 306. The + user's chosen scheme runs as configured. + 2. After the user's scheme runs, install Warp's four reserved binds + (`\cP`, `\ep`, `\ew`, `\ei`) explicitly in every bind mode the + user uses (default, insert, visual for vi mode; default for + emacs; plus any custom modes discovered via `bind -L`). Those + four keys are reserved for Warp and intentionally shadow user + bindings on them — the explicit precedence boundary from + PRODUCT #14. + 3. Snapshot the resulting `bind` output per mode and emit it as the + `ShellBindings` payload. The vi-mode-vs-input-reporting conflict + that originally motivated the reset is resolved here because the + reporting bind is reinstalled in whichever mode is active, instead + of the scheme being reset wholesale. + + Mode tracking uses `$fish_bind_mode` for the initial snapshot and + the in-app vi state machine described in the open-questions section + for transitions. The payload is emitted as a new `DProtoHook::ShellBindings` variant in -`dcs_hooks.rs` carrying `{ shell, keymaps: Vec, active_keymap, -schema_version }`. Reuse `HEX_ENCODED_JSON_MARKER`. - -Re-queries: extend the existing `Precmd` hook (already fired every -prompt) to include a 64-bit hash of the current binding table. The app -caches the last-seen hash per tab; on mismatch it asks the bootstrap -script to re-emit a full `ShellBindings` payload (via a small helper -function in the bootstrap script, triggered by an env-var flag). This -keeps steady-state cost to one hash computation per prompt while -correctly handling dynamic rebinds. +`dcs_hooks.rs` carrying `{ shell, keymaps: Vec, +active_keymap, schema_version, nonce }`. Reuse `HEX_ENCODED_JSON_MARKER`. + +The `ShellBindings` payload is a privileged terminal-control message +(it can rewrite local key handling) and is only accepted from the +bootstrap context: + +- Each Warp-spawned shell receives a per-session, per-tab nonce in its + environment (`WARP_BOOTSTRAP_NONCE`, generated when the tab spawns + and not exported beyond it). The bootstrap embeds this nonce in + every `ShellBindings` and `Precmd` payload it emits. The app-side + handler rejects any payload whose nonce does not match the expected + value for that tab — `cat`'d files, curl responses, and other + process output that happens to contain a DCS sequence cannot spoof + bindings because they cannot read the nonce. +- Payloads exceeding a fixed total size cap (256 KiB across all + keymaps combined) are rejected and logged. Individual binding + entries exceeding a per-key cap (4 KiB) are dropped from the + payload before parsing. +- Schema validation is strict: any field type mismatch, unknown + `schema_version`, or malformed Keystroke string causes the entire + payload to be discarded — partial application is never attempted. +- The same nonce check applies to the binding-hash field on the + existing `Precmd` hook; an unsigned or mismatched hash leaves the + previous binding table in place. + +### Re-query mechanism + +Re-queries are driven entirely shell-side; the app never has to mutate +shell state to trigger a re-emit (which the running shell can't observe +anyway — flipping an env var from outside has no effect on the live +session). The bootstrap script keeps a shell-scoped variable +`__warp_bindings_hash` initialized at startup to the hash emitted +alongside the first `ShellBindings` payload. On every `precmd` the +script: + +1. Recomputes the 64-bit hash of the current binding table. +2. Emits the hash in the `Precmd` DCS payload (informational; the app + uses it for telemetry and to detect mid-session resyncs). +3. If the new hash differs from `__warp_bindings_hash`, emits a fresh + `ShellBindings` payload with the full table and updates + `__warp_bindings_hash` to the new value. + +The app-side handler simply consumes whatever arrives. Steady state is +one hash computation per prompt; the full payload is re-emitted only on +real changes (new `bindkey`, mode switch via `bindkey -v`, sourcing a +new rc file, plugin rebind). PRODUCT #26 holds because the work runs +inside `warp_precmd` after the user's command output, asynchronously to +keystrokes; PRODUCT #27 holds because the entire flow is DCS-only with +no visible shell state mutation. ### 2. Shell-bindings storage on `Shell` @@ -191,8 +256,15 @@ special case. `app/src/server/telemetry/events.rs`: - `HonorShellBindkeysToggled { enabled: bool }` - `ShellBindkeysQueryFailed { shell_type, reason }` - - `UnsupportedShellBindkeyWidget { shell_type, widget_name }` — name - only, never key contents. + - `UnsupportedShellBindkeyWidget { shell_type, widget_name }` — the + `widget_name` field is sent verbatim only when it appears in the + shell-vocabulary allowlist (the well-known ZLE/readline/fish + widget names enumerated in PRODUCT #10). Names outside the + allowlist (user-defined functions, plugin-private widgets) are + redacted to the literal string `user-defined`. Key contents and + binding bodies are never sent. The allowlist lives in + `crates/warp_terminal/src/shell/bindings.rs` so it is the same + source of truth used by the parser. - `ShellBindkeysApplied { shell_type, honored_count, unsupported_count }` once per tab on first apply. @@ -202,11 +274,33 @@ special case. falls through. Forwarding the keystroke to the shell so it can run the widget is feasible (write the key on the PTY) but introduces ordering hazards with Warp's input editor; deferred. -- **#13 (vi-mode signal)** — zsh: `Precmd` payload includes - `$KEYMAP`. bash: `Precmd` includes the result of - `bind -v | grep editing-mode`. fish: `Precmd` includes - `$fish_bind_mode`. All three read cheaply on every prompt; no - separate hook needed. +- **#13 (vi-mode signal)** — vi mode is tracked by an in-app state + machine, not by polling the shell. Reading the shell's mode only at + `precmd` would miss every transition that fires inside the input + editor (Esc → command, `i` → insert, `v` → visual, etc.) because no + prompt hook runs between those keystrokes. Concretely: + + - `active_keymap: KeymapMode` lives on each tab's `Shell` struct + (see Proposed Changes #2). + - **Initial state and resync** come from the shell. The bootstrap + payload includes the current mode (zsh `$KEYMAP`, bash + `bind -v | grep editing-mode`, fish `$fish_bind_mode`); each + `Precmd` payload also includes the mode and is treated as + authoritative — if it disagrees with the in-app state, the + in-app state is corrected to the shell's value, since the shell + just observed whichever sequence of widgets actually executed. + - **Transitions between prompts** are driven by the dispatched + widget. The widget dispatcher maintains a small transition table: + `vi-cmd-mode` / Esc → `ViCommand`, `vi-insert` / + `vi-add-next` / `vi-add-eol` / `vi-substitute` / + `vi-change-whole-line` → `ViInsert`, `vi-replace` → `ViReplace`, + `vi-visual` → `ViVisual`, `accept-line` → reset to shell-reported + mode at next prompt. The dispatcher updates `active_keymap` + synchronously *before* the next keystroke is matched, so the + next keystroke resolves against the new keymap. + - This is the only feasible model: any per-keystroke shell roundtrip + would require an invisible-exec primitive (we don't have one) or + block on the PTY (violates PRODUCT #26). - **#22 (AI prompt input)** — v1: not honored. The matcher's tab-scoped `shell_bindings` tier only activates on tabs whose focus is the shell command input editor, not on the AI prompt input. @@ -228,9 +322,14 @@ special case. - **Widget coverage gaps.** Many widgets have no Warp equivalent initially. The `Unsupported(name)` fallthrough plus telemetry on hit count tells us which to prioritize. -- **Privacy.** Telemetry never includes key contents or widget bodies; - only widget names (which are well-known shell vocabulary) and - counts. +- **Privacy.** Telemetry never includes key contents or widget bodies. + Widget names are sent verbatim only when in the shell-vocabulary + allowlist; user-defined or otherwise unknown names are redacted to + the bucket `user-defined` (see Proposed changes #5). +- **DCS spoofing.** Arbitrary process output containing a DCS sequence + could otherwise rewrite local key handling. Mitigated by the per-tab + nonce gate, size cap, and strict schema validation described in + Proposed changes #1. - **Bootstrap parsing fragility.** `bindkey -L`, `bind -p`, and fish `bind` outputs are stable but quoting differs. Each parser has a property-test fixture set covering edge cases (escapes, multi-byte, From 7a2985b800f711a2a8c15eb7bae420ad291e853b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 21:03:50 +0000 Subject: [PATCH 4/7] Address Oz round-2 spec review on PR #9847 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five round-2 review comments, all important: 7. tech.md DCS nonce: env-var-only is insufficient because same-uid processes can read /proc//environ. Bootstrap now copies the nonce into a non-exported shell-local variable as its first action, then unsets the env var so descendants cannot read it. Threat model documented explicitly: defends against innocent DCS-in-output and later-spawned descendants, not against same-uid attackers who can already inject keystrokes via TIOCSTI / modify rc files. 8. tech.md string macro contradicts PRODUCT #9: literal buffer writes skip key resolution. Reworked widget enum to distinguish SelfInsert (single-char), Macro(String) (re-injected through the input pipeline so newlines submit and control chars trigger actions), Action, and Unsupported. Added a per-macro length bound to prevent bind-cycle loops. 9. tech.md toggle re-query: original PRODUCT #24 said toggling on re-queries each tab; tech said re-queries are shell-side only. The technical reality is shell-side; PRODUCT #24 is updated to match — toggle on resumes from cached table, drift picked up on next precmd. Pressing Enter on an empty line is the user-visible escape hatch. 10. tech.md zsh keymaps: PRODUCT #2 includes user-defined zsh keymaps but tech only enumerated a fixed list. Bootstrap now uses `bindkey -l` to discover all keymaps including any user-defined ones, then dumps each. 11. tech.md fish reserved keys: \cP, \ep, \ew, \ei conflict with PRODUCT #14's clean precedence rules. Documented as an explicit product-level exception in PRODUCT #14 (fish-only, structural because fish integration uses bind rather than precmd hooks); user bindings on these keys are tagged reserved-by-warp in the debug view. Lifting the exception is a tracked follow-up. https://claude.ai/code/session_01AbtuqGqnwn4X9yo2jdQAs5 --- specs/GH537/product.md | 35 ++++++++++++---- specs/GH537/tech.md | 90 ++++++++++++++++++++++++++++++++++-------- 2 files changed, 101 insertions(+), 24 deletions(-) diff --git a/specs/GH537/product.md b/specs/GH537/product.md index d0c8c2820..e8245f264 100644 --- a/specs/GH537/product.md +++ b/specs/GH537/product.md @@ -197,17 +197,33 @@ Out of scope for this spec: 14. Resolution order for a single keystroke in the shell command input editor, highest priority first: - 1. User-customized Warp keybindings (anything the user has explicitly + 1. Reserved infrastructure keys (see below). + 2. User-customized Warp keybindings (anything the user has explicitly set in Warp settings). - 2. User shell bindkeys for the active keymap. - 3. Warp's default keybindings. - 4. Default character insertion. + 3. User shell bindkeys for the active keymap. + 4. Warp's default keybindings. + 5. Default character insertion. Rationale: a key the user has explicitly bound in Warp settings is the strongest signal of intent. Below that, the user's shell bindings override Warp's defaults — that is the entire point of this issue. Warp defaults are the floor. + **Reserved infrastructure keys (fish only).** Fish ships shell + integration with Warp via `bind` (rather than via process-level + hooks like zsh's `precmd` or bash's `PROMPT_COMMAND`), so a small + set of keys is structurally needed for Warp ↔ shell communication + and cannot be honored as user-controlled in v1: `\cP` (clear input + buffer), `\ep` (switch to PS1 prompt), `\ew` (switch to Warp + prompt), and `\ei` (input reporting). User fish bindings on these + four keys are noted in the imported binding table as + `reserved-by-warp` and will not fire; bindings on every other key + follow the regular precedence. zsh and bash do not have any + equivalent reserved keys because their integration uses event + hooks. Lifting this fish-specific exception is a follow-up + (re-implement Warp's fish integration without bind-level + interception). + 15. When a user shell binding shadows a Warp default, no warning, banner, or toast appears. The user already declared this binding in their shell config; the override is the desired behavior. Diagnostics for shadowed @@ -273,9 +289,14 @@ Out of scope for this spec: release? Tech spec / release plan to decide. 24. A single setting "Honor shell keybindings in input editor" lives under - the Keybindings section of settings. Toggling it off restores Warp's - default keymap for all tabs immediately (no restart). Toggling it back - on re-queries each tab's shell. + the Keybindings section of settings. Toggling it off immediately + restores Warp's default keymap for all tabs (no restart). Toggling + it back on resumes matching against each tab's most recently + received binding table; any drift since the toggle was off is + picked up on the tab's next `precmd` payload, since re-queries are + shell-driven (see TECH.md §1). Users who want a fresh re-import + without waiting for the next prompt can press Enter on an empty + line, which fires `precmd` immediately. 25. The Keybindings settings page surfaces, somewhere reachable, a read-only view of the bindings Warp has imported for the active tab — diff --git a/specs/GH537/tech.md b/specs/GH537/tech.md index 27eadf3a9..63102fafe 100644 --- a/specs/GH537/tech.md +++ b/specs/GH537/tech.md @@ -67,10 +67,16 @@ adding a runtime invisible-exec primitive) avoids polluting history, scrollback, and last-status; it also runs before the first prompt so bindings are available when the user starts typing. -- `bundled/bootstrap/zsh.sh`: for each keymap (`main emacs viins vicmd - vivis viopp command isearch menuselect`), run `bindkey -L -M - $keymap` and emit a JSON object `{ keymap: [ { keys, widget }, … ] }`. - Also emit `KEYMAP` so the active keymap is known. +- `bundled/bootstrap/zsh.sh`: discover keymaps dynamically with + `bindkey -l` (this enumerates the standard set — `main`, `emacs`, + `viins`, `vicmd`, `vivis`, `viopp`, `command`, `isearch`, + `menuselect` — and any user-defined keymaps created via + `bindkey -N `), then run `bindkey -L -M $keymap` per keymap and + emit a JSON object `{ keymap_name: [ { keys, widget }, … ] }`. Also + emit `KEYMAP` so the active keymap is known. User-defined keymaps + pass through with their declared name; the matcher honors them when + they are referenced as the active keymap (resolves PRODUCT #2's + reference to "user-defined keymaps"). - `bundled/bootstrap/bash.sh`: `bind -p` for the current keymap and `bind -p -m emacs / vi-insert / vi-command` for the others. Detect vi vs emacs via `set -o | grep -E '^(vi|emacs)'`. @@ -111,13 +117,39 @@ The `ShellBindings` payload is a privileged terminal-control message bootstrap context: - Each Warp-spawned shell receives a per-session, per-tab nonce in its - environment (`WARP_BOOTSTRAP_NONCE`, generated when the tab spawns - and not exported beyond it). The bootstrap embeds this nonce in - every `ShellBindings` and `Precmd` payload it emits. The app-side - handler rejects any payload whose nonce does not match the expected - value for that tab — `cat`'d files, curl responses, and other - process output that happens to contain a DCS sequence cannot spoof - bindings because they cannot read the nonce. + initial environment (`WARP_BOOTSTRAP_NONCE`). The very first action in + the bootstrap script is to copy this value into a non-exported, + shell-local variable (`typeset -g` in zsh, plain assignment in bash + with `export -n`, `set -l` plus careful scoping in fish), then + `unset WARP_BOOTSTRAP_NONCE` and remove it from the inherited + environment so it is not visible to any descendant process. Every + `ShellBindings` and `Precmd` payload the bootstrap emits embeds this + value. The app rejects any payload whose nonce does not match the + expected value for that tab. + + **Threat model** (documented explicitly so the limits are not + oversold). The nonce defends against: + - Innocent process output that happens to contain a DCS sequence + (`cat`'d binary file, curl response, log dump, terminal-art). + - Descendants of the user's shell that did not exist at bootstrap + time and never had the chance to read the nonce. + + It does **not** defend against: + - A process spawned during the narrow window between the shell + starting and the bootstrap unsetting the variable. We minimize + this window by making the unset the first non-trivial line of the + bootstrap, before any user rc file is sourced. + - A same-user process that already has read access to the parent + shell's environment (`/proc//environ` on Linux, + `procfs`/`ps eww` on macOS — both gated by same-uid). Such a + process can already inject keystrokes through `TIOCSTI` (where + enabled), modify rc files, or attach via debugger; defending the + DCS channel against this attacker offers no marginal security. + - A privileged adversary; out of scope for any user-mode mitigation. + + This trust boundary is the same one Warp's existing shell-integration + hooks already implicitly rely on. The nonce makes that boundary + explicit and raises the bar above pure-output spoofing. - Payloads exceeding a fixed total size cap (256 KiB across all keymaps combined) are rejected and logged. Individual binding entries exceeding a per-key cap (4 KiB) are dropped from the @@ -200,11 +232,29 @@ because the buffer already supports the underlying mutations (word-aware cursor motion, kill-ring) — they just lack public action entry points. -A widget→`InputAction` map (`shell/widget_dispatch.rs`) is the bridge: -honored widgets dispatch the matching `InputAction`, -`SelfInsert(string)` writes the literal string to the buffer at the -cursor, `Unsupported(name)` returns a sentinel that tells the matcher -to fall through (PRODUCT #11, #16). +A widget→`InputAction` map (`shell/widget_dispatch.rs`) is the bridge. +Honored widgets dispatch the matching `InputAction`. The widget enum +distinguishes: + +- `SelfInsert` (no payload) — the dispatched key character is inserted + literally at the cursor. This is the trivial `bindkey -e` / + `bind self-insert` case, plus any binding that evaluates to a single + printable keystroke. +- `Macro(String)` — the bound text is fed back through the input + pipeline one keystroke at a time, exactly as if the user had typed + each character. The injected stream goes through the same + key-resolution chain as real input (PRODUCT #9): a newline therefore + triggers `accept-line` and submits the command, `^A` triggers + `beginning-of-line`, and so on. This is the path for zsh + `bindkey -s '^X' 'echo hi\n'`, readline `"\C-x": "echo hi\n"`, and + fish string-bind macros. Macro re-injection is bounded (a small + per-macro-character limit prevents bind-cycle infinite loops; the + input pipeline rejects further macro expansion once the limit is + reached and emits a diagnostic). +- `Action(InputAction)` — every other widget. The dispatcher fires the + mapped `InputAction` directly. +- `Unsupported(name)` — returns a sentinel that tells the matcher to + fall through (PRODUCT #11, #16). ### 4. Keymap matcher integration @@ -243,7 +293,13 @@ special case. `define_settings_group!`: `honor_shell_bindkeys` (default `true`) with `toml_path: "terminal.input.honor_shell_bindkeys"`. The matcher short-circuits the `shell_bindings` tier when this is off (PRODUCT - #24). + #24). Because re-queries are shell-side (bootstrap + `precmd` + driven), turning the toggle back on does not actively re-query — it + resumes matching against the most recent table the bootstrap emitted, + and any change since then will arrive on the next `precmd`. PRODUCT + #24 is updated to reflect this (toggling off restores defaults + immediately; toggling on resumes from the cached table and picks up + changes on the next prompt). - New `FeatureFlag::HonorShellBindkeys` in `crates/warp_features/src/lib.rs` so we can stage rollout (default off → dogfood → preview → stable). Resolves PRODUCT From 740f8e777824d3913cae1493edef65df240f02a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 21:18:41 +0000 Subject: [PATCH 5/7] Address Oz round-3 spec review on PR #9847 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five round-3 review comments, all important: 1. PRODUCT #4 (async timing): the "next keystroke after declaration" guarantee conflicted with #26's non-blocking guarantee. Reworded so the invariant is "the first keystroke after Warp has parsed the next ShellBindings payload from that prompt", with the small async window made explicit. Declarations never block typing. 2. PRODUCT #14 (zsh/bash reserved keys): claim that only fish has reserved keys was wrong — current zsh and bash bootstraps install binds for ^P/\C-p, \ei, \ep, \ew. Listed the reserved set per shell explicitly (zsh: 2, bash: 4, fish: 4); user bindings on those keys import as "reserved-by-warp" and don't fire. 3. tech.md fish nonce: fish runs config.fish before --init-command, so an env-var nonce is readable to user code. Added a fish-specific path: nonce written to a 0600 tempfile under the runtime dir, path passed via --init-command, bootstrap reads then rms the file. WARP_BOOTSTRAP_NONCE env var not used for fish at all. 4. tech.md warpui_core layering: ShellTabBinding/ShellBinding can't live in the UI core. Reworked: warpui_core gets a generic set_contextual(scope_key, bindings) API only; the terminal layer (new keymap_bridge.rs) translates ShellBinding -> Binding with a ContextPredicate::TabIs predicate and BindingOrigin::Shell tag. Resolution order is enforced by predicate evaluation + origin tag, not by a new tier-typed Vec. 5. tech.md toggle test: now asserts cached-table reuse on toggle-on plus next-precmd refresh, matching the relaxed PRODUCT #24 from round 2. https://claude.ai/code/session_01AbtuqGqnwn4X9yo2jdQAs5 --- specs/GH537/product.md | 47 +++++++++++------- specs/GH537/tech.md | 110 +++++++++++++++++++++++++++++------------ 2 files changed, 107 insertions(+), 50 deletions(-) diff --git a/specs/GH537/product.md b/specs/GH537/product.md index e8245f264..7fe664b37 100644 --- a/specs/GH537/product.md +++ b/specs/GH537/product.md @@ -78,11 +78,15 @@ Out of scope for this spec: 4. When the user changes their bindings inside an existing session (`bindkey '^X^E' edit-command-line`, `bind '"\C-x\C-e": edit-and-execute-command'`, sourcing a new rc file mid-session, switching emacs/vi mode), Warp picks - up the change without requiring a restart of the tab. The implementation - may either re-query on a signal (prompt redraw, mode change, OSC hint) or - re-query periodically — but the user-visible invariant is: a binding - declared at the shell prompt is honored on the next keystroke after the - declaration completes. + up the change without requiring a restart of the tab. Discovery is + driven shell-side at every `precmd`, so the change is detected when + the prompt next redraws. The user-visible invariant: a binding + declared at the shell prompt is honored starting with the first + keystroke after Warp has parsed the next `ShellBindings` payload + from that prompt. Keystrokes pressed during the small async window + between the prompt firing and the payload being parsed use the + previous keymap (consistent with the non-blocking guarantee in #26); + declarations never block typing. 5. Each tab tracks its own bindings independently. Changing bindings in one tab does not affect another tab, even if both run the same shell. @@ -209,20 +213,25 @@ Out of scope for this spec: override Warp's defaults — that is the entire point of this issue. Warp defaults are the floor. - **Reserved infrastructure keys (fish only).** Fish ships shell - integration with Warp via `bind` (rather than via process-level - hooks like zsh's `precmd` or bash's `PROMPT_COMMAND`), so a small - set of keys is structurally needed for Warp ↔ shell communication - and cannot be honored as user-controlled in v1: `\cP` (clear input - buffer), `\ep` (switch to PS1 prompt), `\ew` (switch to Warp - prompt), and `\ei` (input reporting). User fish bindings on these - four keys are noted in the imported binding table as - `reserved-by-warp` and will not fire; bindings on every other key - follow the regular precedence. zsh and bash do not have any - equivalent reserved keys because their integration uses event - hooks. Lifting this fish-specific exception is a follow-up - (re-implement Warp's fish integration without bind-level - interception). + **Reserved infrastructure keys.** A small set of keys is structurally + needed for Warp ↔ shell communication (input reporting, prompt-mode + switching, kill-buffer signaling) and cannot be honored as + user-controlled in v1. User bindings on these keys are imported into + Warp's debug view tagged `reserved-by-warp` and do not fire; + bindings on every other key follow the regular precedence above. + The reserved set per shell: + + - **zsh:** `^P` (Warp uses for `kill-buffer`), `\ei` (input reporting). + - **bash:** `\C-p` (`kill-whole-line` for clear-buffer), `\ei` + (input reporting), `\ep` (switch to PS1 prompt), `\ew` (switch to + Warp prompt). + - **fish:** `\cP` (clear input buffer), `\ep` (switch to PS1 + prompt), `\ew` (switch to Warp prompt), `\ei` (input reporting). + + These match the keys Warp's existing bootstrap already installs in + each shell. Lifting the exception (re-implementing each integration + point without bind-level interception) is a tracked follow-up; the + integrations exist today and replacing them is out of scope here. 15. When a user shell binding shadows a Warp default, no warning, banner, or toast appears. The user already declared this binding in their shell diff --git a/specs/GH537/tech.md b/specs/GH537/tech.md index 63102fafe..5e7648663 100644 --- a/specs/GH537/tech.md +++ b/specs/GH537/tech.md @@ -135,10 +135,25 @@ bootstrap context: time and never had the chance to read the nonce. It does **not** defend against: - - A process spawned during the narrow window between the shell - starting and the bootstrap unsetting the variable. We minimize - this window by making the unset the first non-trivial line of the - bootstrap, before any user rc file is sourced. + - A process spawned during the window between the shell starting + and the bootstrap unsetting the variable. For zsh and bash this + window is closed by making the unset the first non-trivial line + of the bootstrap, before any user rc file is sourced. + + **Fish-specific caveat.** Warp launches fish as + `fish -f no-mark-prompt --login --init-command ''` + (`app/src/terminal/local_tty/shell.rs:632`). Fish runs `config.fish` + and any user functions *before* `--init-command`, so the env-var + nonce is readable to user code that runs at config time. To close + this gap the fish path passes the nonce out-of-band: Warp writes + the nonce to a tempfile under the user's runtime dir with mode + `0600`, passes the path as the first argument of `--init-command`, + and the bootstrap reads it then `rm`s the file before any further + work. The `WARP_BOOTSTRAP_NONCE` env var is not used for fish at + all. This brings fish to parity with zsh/bash on later-spawned + descendants but does not protect against an adversarial + `config.fish` written before Warp launched, which is consistent + with the same-uid threat model below. - A same-user process that already has read access to the parent shell's environment (`/proc//environ` on Linux, `procfs`/`ps eww` on macOS — both gated by same-uid). Such a @@ -258,29 +273,58 @@ distinguishes: ### 4. Keymap matcher integration -Extend `Keymap` in `crates/warpui_core/src/keymap.rs` with a third -binding tier that lives outside the persisted user keymap: - -```rust -pub struct Keymap { - pub fixed_bindings: Vec, - pub editable_bindings: Vec, - pub shell_bindings: Vec, // new -} -``` - -`ShellTabBinding` carries a tab id and the parsed `ShellBinding`. The -matcher consults bindings in this order (PRODUCT #14): - -1. `editable_bindings` scoped to tabs of any kind (user Warp overrides) -2. `shell_bindings` for the current tab's `tab_id` and `active_keymap` -3. `fixed_bindings` (Warp defaults) - -`shell_bindings` are populated by the `ShellBindingsUpdated` event and -cleared on tab close. Multi-tab independence (PRODUCT #5, #17) falls -out of tab-scoping naturally. Switching tabs swaps which -`shell_bindings` set is consulted via the existing -`ContextPredicate`-style filtering. +`warpui_core` is a UI-layer crate and must not learn about shells, +tabs, or PTYs. Shell bindings are therefore normalized into ordinary +`Binding` instances at the terminal layer before they are handed to +the matcher; the matcher itself stays unchanged at the type level. + +Concretely: + +- `crates/warpui_core/src/keymap.rs` gains no new public types. The + existing `Keymap { fixed_bindings, editable_bindings }` is extended + internally with a third `Vec` slot — call it + `contextual_bindings` — populated and cleared by the embedder via a + small `Keymap::set_contextual(scope_key, bindings)` API. Bindings in + this slot use the existing `ContextPredicate` to scope themselves to + a context the embedder defines (the embedder owns the meaning; the + matcher just evaluates the predicate). +- The terminal layer (`app/src/terminal/keymap_bridge.rs`, new) owns + shell-binding state per tab. On a `ShellBindingsUpdated` event it: + 1. Translates each `ShellBinding`'s widget into the appropriate + `InputAction` (or `Macro` injection / `Unsupported` sentinel) via + `shell/widget_dispatch.rs`. + 2. Emits one `Binding` per honored shell binding, with a + `ContextPredicate::TabIs(tab_id)` predicate and a binding origin + tag (`BindingOrigin::Shell`) so it remains distinguishable for + the debug view (PRODUCT #25) and for precedence (the matcher's + existing fixed-vs-editable ordering, plus the new + `BindingOrigin::Shell` tier slotted between them). + 3. Calls `Keymap::set_contextual(("shell", tab_id), bindings)`. + +This keeps `warpui_core` free of any shell concept and confines the +new types (`ShellBinding`, `ShellWidget`, `BindingOrigin::Shell`) to +the terminal/app layer. The matcher's resolution order (PRODUCT #14) +is enforced by the predicate evaluation order plus the origin tag, +not by a new tier-typed Vec. + +Effective resolution order for a keystroke in the active tab +(PRODUCT #14, enforced by predicate + origin-tag ordering, not by +separate Vecs): + +1. Reserved infrastructure keys for the tab's shell. +2. `editable_bindings` (user Warp overrides) whose context matches. +3. Bindings from `set_contextual(("shell", current_tab_id), …)` — + tagged `BindingOrigin::Shell`, scoped to this tab via the + predicate. +4. `fixed_bindings` (Warp defaults). + +The terminal layer's per-tab store is the source of truth for what is +currently in the contextual slot. `ShellBindingsUpdated` writes +through it and into `Keymap::set_contextual`; tab close calls +`set_contextual(("shell", tab_id), &[])` to clear. Multi-tab +independence (PRODUCT #5, #17) falls out of `TabIs(tab_id)` predicates +naturally — switching tabs does not require swapping anything; the +matcher just resolves predicates against the new active tab. Mid-sequence handling for multi-key bindings (`^X^E`, `gg`) reuses the existing `Matcher::match_keystrokes` prefix logic — the shell bindings @@ -292,7 +336,7 @@ special case. - New boolean setting in `app/src/terminal/keys_settings.rs` via `define_settings_group!`: `honor_shell_bindkeys` (default `true`) with `toml_path: "terminal.input.honor_shell_bindkeys"`. The matcher - short-circuits the `shell_bindings` tier when this is off (PRODUCT + short-circuits the `BindingOrigin::Shell` tier when this is off (PRODUCT #24). Because re-queries are shell-side (bootstrap + `precmd` driven), turning the toggle back on does not actively re-query — it resumes matching against the most recent table the bootstrap emitted, @@ -358,7 +402,7 @@ special case. would require an invisible-exec primitive (we don't have one) or block on the PTY (violates PRODUCT #26). - **#22 (AI prompt input)** — v1: not honored. The matcher's tab-scoped - `shell_bindings` tier only activates on tabs whose focus is the shell + `BindingOrigin::Shell` tier only activates on tabs whose focus is the shell command input editor, not on the AI prompt input. - **#23 (rollout)** — gated by `FeatureFlag::HonorShellBindkeys` (above). @@ -433,8 +477,12 @@ Tests are organized to map to numbered PRODUCT invariants. Use arrives; assert the keystroke is handled with Warp defaults and not buffered. Covers PRODUCT #26. - **Setting toggle** — flip `honor_shell_bindkeys` off mid-session; - assert shell bindings stop applying without restart; flip on; assert - re-query happens. Covers PRODUCT #24. + assert shell bindings stop applying without restart and Warp's + default keymap takes over. Flip on; assert (a) the most recently + cached binding table from each tab resumes immediately (no fresh + query is issued from the toggle), and (b) the next `precmd` payload + on each tab refreshes that table if anything changed. Covers PRODUCT + #24. - **Manual** — run Warp against a developer's real zsh+oh-my-zsh config, a real bash with a populated `~/.inputrc`, and a real fish with `bind` declarations in `~/.config/fish/`. Capture a short loom From 92691194594250a7389040eb38cbb58d2a0a8e37 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 21:30:16 +0000 Subject: [PATCH 6/7] Address Oz round-4 spec review on PR #9847 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four round-4 review comments, all important: 1. PRODUCT #11 diagnostics: applied the same allowlist/bucket redaction to diagnostics as telemetry, since "local-only" is not guaranteed forever. User-defined widget names log as "user-defined"; bound key sequences are not included in diagnostics at all. 2. tech.md precmd state preservation: spelled out the discipline needed to keep PRODUCT #27. Hash function saves $?/$status first thing, never touches set -o / setopt / shopt / KEYMAP / fish_key_bindings, uses local/__warp_-prefixed temporaries, avoids subshells where it can capture $? before/after where it can't, uses \bindkey / builtin bind to bypass user aliases, and does not modify any DEBUG trap. An integration test asserts $?/options/KEYMAP/non-__warp_ vars all unchanged across precmd. 3. tech.md ContextPredicate: TabIs(u64) wasn't expressible because the existing predicate is &'static str only (warpui_core/src/keymap/context.rs:10-17). Reworked: tab scoping moves to a storage tier, not into ContextPredicate. New API: ScopeKey { category, id } + set_contextual / clear_contextual / set_active_scopes. Matcher iterates only over active scopes; bindings still use the existing predicate for in-scope conditions. 4. tech.md matcher prefix replay: the current matcher returns None and clears pending state on mismatch (matcher.rs:258+), so buffered prefix keys are dropped — that contradicts PRODUCT #8 (replay on abandoned prefix). Spec'd an explicit API change: MatchOutcome::AbandonedPrefix(replay, current); legacy callers wrap via a match_or_replay() helper that flattens to today's behavior. https://claude.ai/code/session_01AbtuqGqnwn4X9yo2jdQAs5 --- specs/GH537/product.md | 15 ++-- specs/GH537/tech.md | 176 ++++++++++++++++++++++++++++++----------- 2 files changed, 141 insertions(+), 50 deletions(-) diff --git a/specs/GH537/product.md b/specs/GH537/product.md index 7fe664b37..cb00e800b 100644 --- a/specs/GH537/product.md +++ b/specs/GH537/product.md @@ -161,12 +161,15 @@ Out of scope for this spec: - If the widget has a documented behavior Warp can replicate cheaply, Warp replicates it. - Otherwise the keystroke falls through to Warp's default handling for - that key, and Warp emits a one-time-per-session diagnostic naming the - unsupported widget and the key it was bound to. Telemetry records - unsupported-widget hits with the widget name verbatim only when the - name appears in the documented shell-vocabulary allowlist (the - well-known ZLE, readline, and fish input function names enumerated in - #10); user-defined or otherwise unknown widget names are reported as + that key, and Warp emits a one-time-per-session diagnostic noting + the unsupported widget. The diagnostic uses the same redaction + policy as telemetry: the widget name is included verbatim only if + it is in the documented shell-vocabulary allowlist (the well-known + ZLE/readline/fish input function names enumerated in #10); + user-defined or otherwise unknown names are written as + `user-defined`. The bound key sequence is not included in the + diagnostic. Telemetry records unsupported-widget hits under the + same rule; user-defined or otherwise unknown widget names are reported as the bucket `user-defined` with no further identifying information, since user-defined widget names can be arbitrary or private. Key contents, key sequences, and binding bodies are never recorded. diff --git a/specs/GH537/tech.md b/specs/GH537/tech.md index 5e7648663..0c4203212 100644 --- a/specs/GH537/tech.md +++ b/specs/GH537/tech.md @@ -198,8 +198,49 @@ one hash computation per prompt; the full payload is re-emitted only on real changes (new `bindkey`, mode switch via `bindkey -v`, sourcing a new rc file, plugin rebind). PRODUCT #26 holds because the work runs inside `warp_precmd` after the user's command output, asynchronously to -keystrokes; PRODUCT #27 holds because the entire flow is DCS-only with -no visible shell state mutation. +keystrokes. + +**Preserving shell state during the hash step (PRODUCT #27).** The +hash function runs as the very first action of `warp_precmd` and must +leave shell-observable state untouched. The discipline: + +- **Last-status (`$?` / `$status`).** Save before any other + expression: zsh `local __warp_status=$?`, bash + `local __warp_status=$?`, fish `set -l __warp_status $status`. Any + value the user reads from `$?` later in their own `precmd` chain + sees the saved value, restored via `return $__warp_status` at the + end of the function (or `set -e status $__warp_status` in fish). +- **Shell options.** No `set -o`, `setopt`, `shopt`, or + `set -gx fish_