Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
+53
View File
@@ -0,0 +1,53 @@
# PRODUCT.md — CODE-1779: Drag-and-drop file paths in WSL (and Git Bash)
Linear: https://linear.app/warpdotdev/issue/CODE-1779/windows-drag-and-drop-file-paths-in-wsl
Upstream: https://github.com/warpdotdev/warp/issues/6191
Figma: none provided (no visual design — the change is purely in which string lands in the input buffer)
## Summary
When a Warp tab is attached to a Unix-like shell on Windows — WSL, or MSYS2 / Git Bash — dragging a file or folder from Windows Explorer onto Warp should insert a path in that shell's native form, not a Windows-native path. For WSL that's `/mnt/c/Users/andy/Downloads`; for Git Bash that's `/c/Users/andy/Downloads`. WSL already works correctly on the terminal grid when a long-running command is active; this spec covers the input editor (broken for both WSL and Git Bash today) and adds matching behavior for Git Bash.
## Behavior
1. Dropping one or more files or folders from Windows Explorer onto the Warp input editor inserts each path in the active session's native form:
- **WSL session** — drive-letter paths are mapped under `/mnt/<drive>/…` with forward slashes.
- **MSYS2 / Git Bash session** — drive-letter paths are mapped under `/<drive>/…` with forward slashes (MSYS2's POSIX-style path convention, which Git Bash and the MSYS2 runtime translate automatically when invoking native Windows binaries).
- **All other sessions** — paths are inserted exactly as dropped (see invariant 5).
2. Conversion rules for a single dropped path:
- WSL session:
- `C:\Users\andy\file.txt``/mnt/c/Users/andy/file.txt`.
- `D:\Pictures\Screenshot 2025-05-14 155816.png``/mnt/d/Pictures/Screenshot 2025-05-14 155816.png` (spaces preserved; shell escaping then applies on top).
- `C:\` and `C:` both → `/mnt/c`.
- Uppercase drive letters are lowercased (`E:\foo``/mnt/e/foo`).
- UNC-style paths with no drive letter (`\\server\share\file`) have backslashes converted to forward slashes (`//server/share/file`); no further remapping is attempted.
- MSYS2 / Git Bash session:
- `C:\Users\andy\file.txt``/c/Users/andy/file.txt`.
- `D:\Pictures\Screenshot 2025-05-14 155816.png``/d/Pictures/Screenshot 2025-05-14 155816.png`.
- `C:\` and `C:` both → `/c`.
- Uppercase drive letters are lowercased (`E:\foo``/e/foo`).
- UNC-style paths with no drive letter (`\\server\share\file`) have backslashes converted to forward slashes (`//server/share/file`); no further remapping is attempted.
3. Dropping multiple paths in a single drop inserts each one individually, separated by a single space, with each path transformed per (2). A trailing space is appended to the inserted text so back-to-back drops don't concatenate tokens (unchanged from today).
4. Shell-specific escaping (quoting spaces, special characters) applies on top of the transformed path using the active session's shell family — identical to the non-WSL/non-MSYS2 behavior today.
5. When the active session is neither WSL nor MSYS2/Git Bash (local PowerShell, cmd, SSH into a remote host, Warpified remote, etc.), dropped paths are inserted exactly as they are today. No transformation happens.
6. Image auto-attachment (dragging an image file into Agent Mode / an empty buffer, which attaches it as AI image context) continues to use the original Windows-native path for filesystem reads, regardless of WSL / MSYS2 state. Transformed paths would not be readable from the Windows host.
7. If an image auto-attach fails (e.g. per-query or per-conversation image limit is exceeded) and the path is inserted as text as a fallback, that inserted text is the transformed path (per invariant 2) when the session is WSL or MSYS2 — matching invariant (1).
8. Parity with the terminal grid:
- WSL: dropping onto the grid already inserts WSL-style paths during a long-running command. The input editor must match, so the user sees the same text regardless of whether the drop target was the grid or the input editor.
- MSYS2 / Git Bash: the grid's long-running behavior (drop a path, get the Windows-native path written to the PTY without shell escaping, so native Windows executables receive the form they expect) is **intentional and unchanged** by this ticket. The input editor is a different context — the shell itself processes the text next, so MSYS2-style paths are the right default there. Grid and input-editor behavior therefore differ on MSYS2 by design; this is noted so it isn't flagged as a bug in review.
9. The transformation is driven by the *currently active block's session*. Switching blocks (and therefore sessions) between drops updates which rule applies on the next drop.
10. Non-regressions:
- Dropping into any non-terminal editor (notebooks, settings, themes, etc.) is unchanged — no path transformation is applied.
- Dropping into a non-WSL, non-MSYS2 terminal session (PowerShell, cmd, SSH, remote Warpified) is unchanged.
- The terminal-grid long-running-command code paths for both WSL and MSYS2 are unchanged.
- Dropping image-only content into the input in Agent Mode still attaches the images; nothing about attachment behavior changes.
- Pasting paths via clipboard is out of scope for this ticket and remains unchanged.
+106
View File
@@ -0,0 +1,106 @@
# TECH.md — CODE-1779: Drag-and-drop file paths in WSL (and Git Bash)
See `PRODUCT.md` for user-visible behavior.
## Context
There are two drop paths relevant to this ticket:
1. **Drop onto the terminal grid** (e.g. during a long-running command). Handled by `TerminalView::drag_and_drop_files` in `app/src/terminal/view.rs (23007-23085)`. This path already applies `warp_util::path::convert_windows_path_to_wsl` when `session.is_wsl()` before shell-escaping and writing to the PTY. For MSYS2 long-running it deliberately keeps Windows-native paths and skips shell escaping (so native Windows binaries receive the form they expect). **Not changing this path.**
2. **Drop onto the input editor** (typical case when there's no long-running command). The editor element's `drag_and_drop_file` in `app/src/editor/view/element.rs (663-681)` dispatches `EditorAction::DragAndDropFiles`, which routes to `EditorView::drag_and_drop_files` in `app/src/editor/view/mod.rs (7893-7914)`. That function emits `Event::DroppedImageFiles` for image paths (terminal input handles attachment) and, for everything else, calls `warpui::clipboard_utils::escaped_paths_str` followed by `self.user_insert`. **No path conversion happens — this is the bug for both WSL and MSYS2.**
Why the fix doesn't belong inside `EditorView`: `EditorView` is a general-purpose text editor used across dozens of surfaces (notebooks, settings, code editors, modals, etc. — see the many `EditorView::new(...)` / `single_line(...)` call sites). It already carries `shell_family` for escaping, which is a shell concern but not tied to any particular parent; WSL / MSYS2 path conversion is a terminal-session concern and must not leak into the editor.
Existing helpers:
- `warp_util::path::convert_windows_path_to_wsl` in `crates/warp_util/src/path.rs (673-691)`, with tests at `crates/warp_util/src/path_test.rs (629-649)`.
- **No equivalent exists yet for MSYS2.** We'll add `convert_windows_path_to_msys2` alongside it (same shape, `/<drive>/…` instead of `/mnt/<drive>/…`).
- `Session::is_wsl()` at `app/src/terminal/model/session.rs:972` and `Session::is_msys2()` at `app/src/terminal/model/session.rs:980` both already exist and return `false` on non-Windows platforms.
- `TerminalInput::active_session` is available in `app/src/terminal/input.rs:11011`.
Shell-family setup on the editor already happens in `TerminalInput::set_active_block_metadata` at `app/src/terminal/input.rs (13034-13065)`, which is called on every active-block (and therefore session) change. That's the natural place to install/clear the transformer too.
## Proposed changes
### 1. Add `convert_windows_path_to_msys2` to `warp_util::path`
In `crates/warp_util/src/path.rs`, alongside `convert_windows_path_to_wsl`, add:
```rust path=null start=null
/// Converts a Windows-native path string to an MSYS2 / Git Bash POSIX-style path.
///
/// Drive-letter paths (e.g. `C:\Users\aloke\file.txt`) are mapped to
/// `/<drive>/Users/aloke/file.txt`. Paths that don't start with a drive letter
/// are returned as-is with backslashes replaced by forward slashes.
pub fn convert_windows_path_to_msys2(windows_path: &str) -> String { /* ... */ }
```
Implementation mirrors `convert_windows_path_to_wsl` but emits `/<drive>` instead of `/mnt/<drive>`. Consider factoring both into a shared internal helper that takes a `&'static str` drive prefix (`"/mnt/"` vs `"/"`) to avoid duplication.
Add unit tests in `crates/warp_util/src/path_test.rs` matching PRODUCT.md invariant (2):
- `C:\Users\andy\file.txt` → `/c/Users/andy/file.txt`
- Spaces preserved
- `C:\` and `C:` both → `/c`
- Uppercase drive lowercased
- UNC fallback converts backslashes to slashes
### 2. Add a generic "path transformer" hook to `EditorView`
In `app/src/editor/view/mod.rs`:
- Add a public type alias `pub type PathTransformerFn = Rc<dyn Fn(&str) -> String>;` near the existing `CursorColorsFn` (line ~1587). `Rc` is already imported.
- Add `pub drag_drop_path_transformer: Option<PathTransformerFn>` to `EditorOptions` (default `None` in both `Default for EditorOptions` and `From<SingleLineEditorOptions> for EditorOptions`).
- Add a mirrored private field on `EditorView`, initialize it from `options.drag_drop_path_transformer` in `new_internal`, and add a `set_drag_drop_path_transformer` setter next to the existing `set_shell_family`. No public getter is needed: the only caller that would want to read the transformer back is the parent that installed it, and that parent already knows which transformation applies to the current session.
- In `EditorView::drag_and_drop_files`, after the image-files branch returns early, run each remaining (non-image) path through the transformer if present, then pass the transformed list — not `paths_as_strings` — into `escaped_paths_str`. Image paths stay untransformed because `Event::DroppedImageFiles` consumers (terminal input) need to read the original file from the host.
Design note: we deliberately picked a closure over an event/delegation flag. The editor already takes other domain-agnostic closures (`render_decorator_elements`, `cursor_colors_fn`, `keymap_context_modifier`), and a pure `Fn(&str) -> String` hook is the smallest, most focused extension point for what amounts to "rewrite each path string before inserting." It keeps image attachment, shell escaping, and insertion flow inside the editor unchanged, and it imposes no terminal vocabulary on the editor. A single closure type also naturally covers multiple concrete transformations (WSL, MSYS2, and anything else we add later).
### 3. Install the transformer from `TerminalInput`
In `app/src/terminal/input.rs`:
- In `set_active_block_metadata` (line ~13034), alongside the existing `editor.set_shell_family(...)` call, select a transformer based on the session and install or clear it:
- `session.is_wsl()``Rc::new(|p: &str| warp_util::path::convert_windows_path_to_wsl(p))`.
- `session.is_msys2()``Rc::new(|p: &str| warp_util::path::convert_windows_path_to_msys2(p))`.
- Otherwise → `None`.
Both session predicates return `false` off-Windows, so no `cfg!(windows)` gating is needed at the call site. Check `is_wsl()` before `is_msys2()` for clarity (they are mutually exclusive in practice, but the order documents priority).
- In the existing `EditorEvent::DroppedImageFiles` handler (line ~9493), when the image-attach fallback inserts paths as text, apply the same WSL / MSYS2 conversion to `image_filepaths` (branching on `session.is_wsl()` / `session.is_msys2()` just as step above does) before calling `escaped_paths_str`. This preserves PRODUCT.md invariant (7) for both WSL and MSYS2.
We rely on `set_active_block_metadata` being called whenever the active session changes; it already updates `shell_family` and `path_separators`, so the transformer follows the same lifecycle and always reflects the currently active session.
### 4. No changes needed elsewhere
- `TerminalView::drag_and_drop_files` (terminal grid path) already converts correctly for WSL, and deliberately does not convert for MSYS2 long-running commands. Per PRODUCT.md invariant (8), leave it alone.
- No other `EditorView::new(...)` call site sets the new option, so the default `None` transformer means identical behavior for notebooks, settings, etc.
## Testing and validation
Covers the invariants in `PRODUCT.md`.
- **Unit tests for `convert_windows_path_to_msys2` (invariant 2, MSYS2 cases).** In `crates/warp_util/src/path_test.rs`, mirror the existing `test_convert_windows_path_to_wsl` test with MSYS2-equivalent expectations (`/c/...`, `/d/...`, etc.).
- **Existing coverage for the WSL conversion itself (invariant 2, WSL cases).** `test_convert_windows_path_to_wsl` in `crates/warp_util/src/path_test.rs (629-649)` already verifies drive-letter lowercasing, spaces, UNC, and empty-suffix behavior. No new tests needed there.
- **Unit test — transformer wiring (invariants 1, 2, 5, 9).** In `app/src/editor/view/mod_test.rs`, add a test that:
- Creates an `EditorView` with `shell_family: Some(Posix)` and runs two scenarios:
1. `drag_drop_path_transformer: Some(Rc::new(|p| warp_util::path::convert_windows_path_to_wsl(p)))` — WSL behavior.
2. `drag_drop_path_transformer: Some(Rc::new(|p| warp_util::path::convert_windows_path_to_msys2(p)))` — MSYS2 behavior.
- Each scenario dispatches `EditorAction::DragAndDropFiles` with representative inputs (`C:\foo`, `D:\bar baz.txt`, a UNC path, and a path with no drive letter) and asserts the buffer contents match PRODUCT.md invariant (2) with shell escaping applied on top.
- A third scenario clears the transformer and asserts the original Windows paths are inserted verbatim (PRODUCT.md invariant 5).
- **Image-attach fallback (invariants 6, 7).** In `app/src/terminal/input_test.rs`, if there is already coverage for the `DroppedImageFiles` fallback path, extend it to assert that when a transformer is installed, the fallback text is the transformed paths. If no such test exists yet, add a focused one that mocks an over-limit scenario and inspects the buffer. Exercise both a WSL transformer and an MSYS2 transformer. Image attachment itself is unchanged and needs no new test.
- **Manual verification (invariants 1, 3, 8, 10).** On a Windows machine with both a WSL distro and Git Bash installed:
- Drag a file from Explorer into a WSL tab's input editor → buffer reads `/mnt/c/…` with shell escaping.
- Drag a file from Explorer into a Git Bash tab's input editor → buffer reads `/c/…` with shell escaping.
- Drag the same file onto a WSL tab while a long-running command is active → grid receives `/mnt/c/…` (regression check for existing behavior).
- Drag the same file onto a Git Bash tab while a long-running command is active → grid receives `C:\…` without shell escaping (regression check: deliberate existing behavior per PRODUCT.md invariant 8).
- Drag into a local PowerShell tab → buffer reads `C:\…` unchanged.
- Drag into a notebook editor → buffer reads `C:\…` unchanged.
- Drag an image file in Agent Mode with an empty buffer (WSL or Git Bash) → image attaches normally; no text inserted.
## Risks and mitigations
- **Stale transformer after session switch.** Because we install/clear the transformer in `set_active_block_metadata`, it always matches the active session. If that function is ever bypassed for a session change, the transformer could lag. Mitigation: piggy-back on the exact same call site as `set_shell_family`, which already has this assumption and has proven stable.
- **Cross-platform builds.** `convert_windows_path_to_wsl` is pure string manipulation and compiles on all platforms, as does `Session::is_wsl()` (returns `false` off-Windows). No `cfg!(windows)` gating needed at the call site, though in practice the transformer will only ever be non-`None` on Windows because no other platform has WSL sessions.
+26
View File
@@ -0,0 +1,26 @@
# Run Warp at startup (Windows)
Linear: [CODE-1786](https://linear.app/warpdotdev/issue/CODE-1786/windows-run-warp-at-startup)
GitHub: [warpdotdev/warp#5957](https://github.com/warpdotdev/warp/issues/5957)
## Summary
Bring the macOS-only "Start Warp at login" setting to Windows so that Warp launches automatically whenever the user signs in to Windows. This lets users rely on the global hotkey (e.g. `Ctrl+J`) to summon Warp at any time without having to manually start it first, matching the parity the macOS version already ships.
## Problem
On macOS, Warp exposes a **Start Warp at login** toggle in **Settings → Features → General** backed by `SMAppService`. On Windows there is no equivalent — users must either launch Warp manually after every sign-in, or manually drop a shortcut into `shell:startup` (as documented in the GitHub issue workaround). That defeats "terminal that is always there" use cases, especially for users who use the global hotkey as their primary entry point.
## Behavior
1. **Setting exists on Windows.** The **Features → General** settings page shows a toggle labeled **Start Warp at login** when Warp is running on Windows. The existing macOS toggle keeps its current label behavior ("requires macOS 13+"); the Windows toggle shows no OS qualifier.
2. **Default on first install.** On a fresh Windows install, the toggle defaults to the same value it does on macOS (on — matching the existing `add_app_as_login_item` default of `true`). The registration does not actually happen until the user has been through onboarding and the app state is persisted at least once.
3. **Enabling the toggle registers Warp to start at login.** When the user flips the toggle on, Warp registers itself with Windows so that the current user's next sign-in launches Warp automatically. Registration completes without requiring administrator elevation. If registration fails, the toggle still reflects the user's intended state but a warning is logged and the setting is not marked as registered so a later retry can succeed.
4. **Disabling the toggle unregisters Warp.** When the user flips the toggle off, Warp removes its startup registration. A subsequent sign-in does not launch Warp. Unregistering is idempotent: if Warp was already not registered (e.g. user removed it manually), toggling off completes without error.
5. **Respect manual user changes.** If the user disables Warp's startup entry outside of Warp — for example via **Settings → Apps → Startup**, **Task Manager → Startup apps**, or by editing the registry — Warp must not silently re-enable it on the next launch. Warp only (re-)runs its registration logic when the toggle is explicitly changed by the user, mirroring the existing macOS `app_added_as_login_item` bookkeeping.
6. **Launch behavior at login.** When Warp launches as a result of a Windows sign-in, it launches silently without stealing focus — no splash screen, no window auto-popped to the foreground. The user's normal activation paths (global hotkey, clicking the tray/taskbar entry, etc.) surface the window on demand. Session restoration follows the existing **Restore previous session** setting.
7. **Per-channel isolation.** Dev, Preview, and Stable installs register under distinct identifiers so that having multiple channels installed does not cause one channel to overwrite another's startup entry, and toggling the setting in one channel does not affect the others.
8. **Dev builds do not register.** Running Warp from `cargo run` or any unbundled/dev-channel binary does not write a startup entry, even if the toggle is on. This mirrors the existing macOS check that skips registration when not running from a real bundle, and prevents developer machines from autostarting every local build.
9. **Integration test builds do not register.** When the `WARP_INTEGRATION` environment variable is set, Warp skips all registration/unregistration work regardless of the toggle state, mirroring the existing macOS guard.
10. **Moving or renaming the install.** If the Warp executable moves (e.g. an update reinstalls to a new path, or a portable install is relocated), the existing registration keeps pointing at the old path until the user toggles **Start Warp at login** off and back on, at which point Warp rewrites the entry against the new path. A stale entry whose target no longer exists is left in place (Windows itself handles the dangling entry). Automatic detection on launch is tracked as a follow-up.
11. **Visibility to the user outside Warp.** The startup entry must be visible and removable in Windows' standard UIs — at minimum, **Settings → Apps → Startup** on Windows 10/11 and **Task Manager → Startup apps**. The entry's display name is "Warp" (or the channel-specific name, e.g. "Warp Preview", "Warp Dev") so users can identify it.
12. **Telemetry.** Toggling the setting emits the same `ToggleLoginItem` features-page telemetry event that the macOS toggle emits today, so rollout can be measured consistently across platforms.
13. **Not applicable on web/Linux.** The setting remains hidden on Web. Linux is out of scope for this ticket (see Non-goals).
## Non-goals
- Linux autostart (`~/.config/autostart/*.desktop`) is not covered here.
- Running Warp as a background service with no user session, running elevated, or starting before user login.
- Any in-app notification or onboarding prompt about the setting; discoverability is handled by the existing Features page UI.
- Configuring what Warp does *at* startup (tabs, working directories, shells) — "Restore previous session" already owns that behavior and is unchanged.
+120
View File
@@ -0,0 +1,120 @@
# Run Warp at startup (Windows) — Tech Spec
Linear: [CODE-1786](https://linear.app/warpdotdev/issue/CODE-1786/windows-run-warp-at-startup)
See `PRODUCT.md` for user-visible behavior.
## Context
The macOS "Start Warp at login" feature is already plumbed end-to-end. This ticket extends that plumbing to Windows by adding a new registration backend and broadening the existing setting/UI's platform gate; no new user-visible surface is being designed.
Relevant code today:
- `app/src/terminal/general_settings.rs:36-55``add_app_as_login_item` (`LoginItem`) and `app_added_as_login_item` (`AppAddedAsLoginItem`), both gated on `SupportedPlatforms::MAC` with defaults `true` / `false` respectively. The second setting is the "already registered" bookkeeping that prevents clobbering a manual unregister.
- `app/src/lib.rs:2229-2239` — startup wiring. Subscribes to `GeneralSettingsChangedEvent::LoginItem` and calls `maybe_register_app_as_login_item` on change + once at launch. Entire block is `#[cfg(target_os = "macos")]`.
- `app/src/lib.rs:2279-2362``maybe_register_app_as_login_item`. macOS-only. Guards on `WARP_INTEGRATION`, skips when bundle identifier is missing or equals `dev.warp.Warp-Local`, runs `SMAppService register/unregisterAndReturnError:` off the UI thread, then writes the result back to `app_added_as_login_item`.
- `app/src/settings_view/features_page.rs` — toggle UI:
- action enum: `FeaturesPageAction::ToggleLoginItem` (~l618)
- telemetry: `ToggleLoginItem` branch (~l975-978)
- handler: `ToggleLoginItem => ...` (~l1855-1857)
- widget: `LoginItemWidget` (~l4488-4533), rendered only when `add_app_as_login_item.is_supported_on_current_platform()` returns true (~l2481-2486). Label is hard-coded to `"Start Warp at login (requires macOS 13+)"`.
- `crates/settings/src/lib.rs:161-219``SupportedPlatforms` enum and `matches_current_platform`. Supports `OR(…, …)` so `MAC` + `WINDOWS` can be expressed without adding a new variant.
- `crates/warp_core/src/channel/state.rs:119-125, 40``ChannelState::app_id()` returns the current channel's `AppId` (e.g. `dev.openwarp.OpenWarp`, `dev.warp.Warp`, `dev.warp.WarpPreview`, `dev.warp.WarpDev`). We can reuse `application_name()` for the channel-specific registry value name on Windows.
- `app/Cargo.toml:356-377` — Windows-only dependency block. `winreg`, `windows-registry`, and `windows` are already pulled in and available for a new module. `winreg` is already used for reading registry values (`crates/warpui/src/windowing/winit/windows/registry.rs`), so we should follow that pattern for consistency.
## Proposed changes
### 1. Loosen the setting's platform gate
Change both settings in `app/src/terminal/general_settings.rs` from `SupportedPlatforms::MAC` to `SupportedPlatforms::OR(Box::new(SupportedPlatforms::MAC), Box::new(SupportedPlatforms::WINDOWS))`. Defaults stay the same (`true` / `false`). The `toml_path` / description stay unchanged since the TOML key is already platform-neutral (`general.login_item`).
No migration is required: for existing Windows users, the setting simply starts populating with its default value the next time preferences are loaded.
### 2. Add a Windows registration backend
Create `app/src/login_item/mod.rs` (or reuse an existing "platform adapters" spot — see "Module placement" below) to own the cross-platform entry point, replacing the current macOS-only free function.
```rust path=null start=null
// app/src/login_item/mod.rs
pub fn maybe_register_app_as_login_item(ctx: &mut AppContext) {
if std::env::var("WARP_INTEGRATION").is_ok() {
log::debug!("Not registering as a login item in integration tests");
return;
}
#[cfg(target_os = "macos")]
macos::maybe_register(ctx);
#[cfg(target_os = "windows")]
windows::maybe_register(ctx);
}
```
Move the existing macOS body into `login_item/macos.rs` unchanged.
Add `login_item/windows.rs` with the Windows implementation:
- Pull the desired state + the already-registered flag off `GeneralSettings` the same way the macOS path does.
- Short-circuit if `add_app_as_login_item && app_added_as_login_item` (the existing "don't clobber a manual unregister" contract from `lib.rs:2290-2296`).
- Resolve the executable path via `std::env::current_exe()?` then `dunce::canonicalize`. `std::fs::canonicalize` on Windows always returns a Win32 verbatim (`\\?\`) path, which is ugly in Settings → Apps → Startup / Task Manager and trips up some third-party tools that parse the `Run` value; `dunce` strips the prefix when safe and leaves it alone for real UNC / long paths. Bail with a debug log if resolution fails.
- Skip registration for dev/local builds. The cleanest check is `ChannelState::is_release_bundle()` (see `crates/warp_core/src/channel/state.rs:77-79`); use it as the Windows equivalent of the macOS "bundle identifier missing / `dev.warp.Warp-Local`" guard. Explicitly keep the behavior that enabling the toggle in a dev build persists the preference but does not write the registry value, since the preference is synced via user settings and would bleed into release runs otherwise — we only skip the I/O, not the preference update. (Matches macOS: toggling in a non-bundled build is a no-op on the system side.)
- Off-thread the registry I/O via `ctx.spawn`, mirroring the macOS path and the existing async signature `|settings, app_added_as_login_item, ctx| { ... }`. Return `bool` for "registered successfully" so the existing completion handler can set `app_added_as_login_item`.
- Registry work itself:
```rust path=null start=null
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
const RUN_SUBKEY: &str =
r"Software\Microsoft\Windows\CurrentVersion\Run";
fn value_name() -> String {
// e.g. "Warp", "WarpPreview", "WarpDev", "OpenWarp"
ChannelState::app_id().application_name().to_owned()
}
fn register(exe: &Path) -> std::io::Result<()> {
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let (run_key, _) = hkcu.create_subkey(RUN_SUBKEY)?;
// Quote the path so spaces (e.g. "C:\Program Files\Warp\warp.exe") survive parsing.
let value = format!("\"{}\"", exe.display());
run_key.set_value(&value_name(), &value)
}
fn unregister() -> std::io::Result<()> {
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let run_key = hkcu.open_subkey_with_flags(
RUN_SUBKEY,
winreg::enums::KEY_SET_VALUE,
)?;
match run_key.delete_value(value_name()) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
```
`HKCU\Software\Microsoft\Windows\CurrentVersion\Run` is the user-scope per-login key; it does not require admin, and is what Windows 10/11's **Settings → Apps → Startup** and **Task Manager → Startup apps** surface. This satisfies Behavior invariants 34, 7, 11.
- The per-channel value name from `application_name()` keeps Dev/Preview/Stable isolated (Behavior 7): `Warp`, `WarpPreview`, `WarpDev`, `OpenWarp`.
- Behavior 10 ("moving the install") is partially covered: the short-circuit `add_app_as_login_item && app_added_as_login_item` intentionally prevents re-registration on every launch, so a moved install keeps the stale path until the user toggles the setting off and back on (which rewrites against the new `current_exe()`). Automatic detection + rewrite when the stored path differs is tracked as a follow-up.
### 3. Rewire startup
Replace the existing `#[cfg(target_os = "macos")]` block in `app/src/lib.rs:2229-2239` with a block gated on `cfg(any(target_os = "macos", target_os = "windows"))`, calling the new cross-platform `login_item::maybe_register_app_as_login_item`. The subscription to `GeneralSettingsChangedEvent::LoginItem` stays identical. Delete the old `maybe_register_app_as_login_item` body from `lib.rs:2279-2362`.
### 4. Update the Features page UI
Two tiny changes in `app/src/settings_view/features_page.rs`:
- `LoginItemWidget::render` (~l4509): change the hard-coded label to a platform-dependent string, e.g.
```rust path=null start=null
#[cfg(target_os = "macos")]
let label = "Start Warp at login (requires macOS 13+)";
#[cfg(target_os = "windows")]
let label = "Start Warp at login";
```
Anything else — action enum, telemetry, handler, and widget-registration path — already flows through `is_supported_on_current_platform()` on the setting itself, so broadening the setting in step 1 automatically turns the toggle on for Windows.
- Update `search_terms` for the widget (~l4497) to keep the macOS keyword but add "windows", so the settings search surface finds it on both OSes.
### Module placement
We don't have an existing `login_item` module. A new `app/src/login_item/{mod,macos,windows}.rs` layout is the smallest, most obvious split and matches other OS-sharded areas (`app/src/terminal/local_tty/windows/*`, `app/src/util/file/external_editor/{mod,windows}.rs`, `app/src/antivirus/windows.rs`, `app/src/terminal/audible_bell/{mod,windows}.rs`). Add `mod login_item;` in `app/src/lib.rs` and re-export `maybe_register_app_as_login_item` there.
## Testing and validation
Verification maps back to the numbered invariants in `PRODUCT.md`.
Unit tests (Windows, gated with `#[cfg(target_os = "windows")]`):
- **Invariant 3 (enable registers).** Drive `register()` against a temporary `HKCU` subkey (or wrap the subkey path in a trait and use a fake in tests) and assert the value is `"\"<path>\""` under the expected name.
- **Invariant 4 (disable unregisters, idempotent).** Call `unregister()` twice in a row; both must succeed. Calling `unregister()` when the value was never set must succeed.
- **Invariant 7 (per-channel isolation).** With different `ChannelState` app IDs, `value_name()` returns distinct strings (`Warp`, `WarpPreview`, `WarpDev`). Registering under one does not touch another.
- **Invariant 10 (path move).** Register with path A, then with path B; the stored value is B. No leftover value under a different name.
Because the real `Software\Microsoft\Windows\CurrentVersion\Run` hive is shared user state that should not be mutated by tests, the registry helpers should accept an injectable subkey path (e.g. `register_with_subkey(subkey, …)`) and the tests should drive them against `Software\Warp\TestRun\<uuid>` under HKCU, cleaning up on drop. Follow the `crates/warpui/src/windowing/winit/windows/registry.rs` style for the read-side helper for consistency.
Cross-platform tests (run on every OS):
- **Invariant 1/13 (platform gate).** Assert `LoginItem::supported_platforms().matches_current_platform()` is true on mac+windows, false on wasm. This is already covered structurally by `SupportedPlatforms::OR`; a small regression test in `general_settings`/`features_page` confirms the intent stays stable.
- **Invariant 9 (integration test guard).** With `WARP_INTEGRATION` set, calling `maybe_register_app_as_login_item` does no registry I/O. Can be asserted by calling with a mock subkey provider that panics if invoked.
Manual validation (Windows):
1. **Invariants 1, 2, 11.** Install a release-bundle Windows build, verify **Features → General** shows *Start Warp at login*, toggle default is on, and `HKCU\Software\Microsoft\Windows\CurrentVersion\Run\Warp` exists with the installed exe path. Confirm the entry is visible in **Settings → Apps → Startup** and **Task Manager → Startup apps** with the display name "Warp".
2. **Invariant 34.** Toggle off; confirm the registry value is gone and Windows UIs no longer show the entry. Toggle back on; confirm value is rewritten.
3. **Invariant 5.** With toggle on, delete the registry value (or disable from Task Manager). Relaunch Warp; verify the value is **not** recreated and `app_added_as_login_item` stays true. Toggle off then on in-app; verify the value *is* recreated.
4. **Invariant 6.** Sign out and back into Windows. Warp launches without stealing focus; the global hotkey surfaces it as expected.
5. **Invariant 7.** Install both Stable and Preview channels; enable the setting in each; confirm both registry values (`Warp`, `WarpPreview`) coexist and unregistering one doesn't affect the other.
6. **Invariant 8.** Run `cargo run` from a dev checkout with the toggle on; confirm no registry value is written and the preference updates still persist.
7. **Invariant 10.** Move/rename the install directory, relaunch Warp, toggle off+on; confirm the registry value points at the new path.
Telemetry (**Invariant 12**): filter the dashboard for `FeaturesPageAction { action: "ToggleLoginItem" }` and confirm Windows events arrive alongside macOS ones post-rollout.
## Risks and mitigations
- **Silently re-enabling a user's manual removal.** Mitigated by reusing the existing `app_added_as_login_item` bookkeeping contract — Windows code *must* respect it the same way macOS does. Covered by Invariant 5 and the relevant manual test.
- **Path quoting bugs.** Windows start entries are fragile with paths containing spaces. Store the value as a single quoted string, and add a regression test for a path with spaces.
- **Verbatim path prefix.** `std::fs::canonicalize` returns `\\?\`-prefixed paths on Windows, which render oddly in Settings → Apps → Startup and confuse some third-party launchers. Use `dunce::canonicalize` so the stored `Run` value is a plain drive-letter path for the common case, while still tolerating real UNC / long paths.
- **Shared registry key across tests/processes.** Use injectable subkey paths in unit tests so the real `Run` key is never touched by CI.
- **Dev-build regressions.** Gate actual registry I/O on `ChannelState::is_release_bundle()` to avoid developer machines auto-launching random `target/debug/warp.exe` artifacts.
## Follow-ups
- Linux autostart (`~/.config/autostart/warp.desktop`) is a natural next step with the same setting and the same UX, but is out of scope for this ticket.
- Optional future polish: automatically rewrite the stored path when `current_exe()` differs from the registered value, so users never need to re-toggle after a move.
+50
View File
@@ -0,0 +1,50 @@
# CODE-1793 — CLI coding agent paste being mangled by Warp
Linear: https://linear.app/warpdotdev/issue/CODE-1793/claude-code-native-image-paste-being-bypassed
## Context
When a CLI coding agent like Claude Code runs as a long-running command in a Warp terminal, Warp intercepts Ctrl+V (and the platform paste action) and converts the clipboard to a shell-escaped text paste that is sent to the PTY. Two different user flows break on Windows as a result:
1. **Raw image data in the clipboard (e.g. screenshot from `Win+Shift+S` / Snipping Tool).** The Windows clipboard has only `CF_DIB`, no `CF_HDROP`. `arboard`'s `file_list()` returns no paths, `plain_text` is empty, and Warp's text-paste path sends nothing. Claude Code's native image-paste handler never gets a chance to run and the user sees nothing happen.
2. **Image file copied from Explorer.** The clipboard has a `CF_HDROP` path. Warp reads the path, shell-escapes it via `ShellFamily::escape`, and writes it as text. PowerShell-family escaping uses backtick escapes, which the CLI agent's path-detection does not recognize; Windows Terminal by contrast pastes the path verbatim and the agent's path-detection attaches the image correctly.
CLI coding agents have their own native clipboard-image paste. The keystroke for raw image data differs per platform: `Ctrl+V` on macOS and Linux, `Alt+V` on Windows (see [anthropics/claude-code#18590](https://github.com/anthropics/claude-code/issues/18590)). For image *paths* pasted as text, agents do their own path-detection on the pasted text and load the file from disk — as long as the path is verbatim.
Relevant code:
- `app/src/terminal/view.rs:14156``TerminalView::paste`. When the input box isn't focused/visible (true during a long-running command like `claude`/`codex`/`opencode`), this reads the clipboard as text and writes it to the PTY, optionally wrapping it in bracketed paste.
- `app/src/terminal/view.rs:7601``TerminalView::read_from_clipboard``clipboard_content_with_escaped_paths` at `app/src/util/clipboard.rs:8`. Converts `ClipboardContent.paths` into a shell-escaped space-joined string; falls back to `plain_text` when there are no paths. Image data on `ClipboardContent.images` is ignored on this path.
- `crates/warpui/src/windowing/winit/windows/clipboard.rs:36` — Windows `read()`. `arboard`'s `file_list()` only returns paths when the clipboard carries `CF_HDROP`; screenshot-tool captures do not.
- `crates/warp_util/src/path.rs:218``ShellFamily::escape`. Uses backtick escapes for PowerShell; the escaped string isn't the form CLI agents recognize as an image path.
- `app/src/terminal/cli_agent_sessions/mod.rs:296``CLIAgentSessionsModel::session(view_id)` gives the active CLI agent (if any) for a terminal.
- `app/src/terminal/cli_agent.rs:108``CLIAgent` enum.
Why the existing paste path can't just "pass-through" Ctrl+V: the `terminal:paste` / Windows `ctrl-v` bindings at `app/src/terminal/view/init.rs` intercept the keystroke and dispatch `TerminalAction::Paste`. If `paste()` returns without writing anything, the agent never sees the keystroke at all.
## Implemented changes
Two changes to `TerminalView::paste` in `app/src/terminal/view.rs`, gated on a new `active_cli_agent_handles_image_paste_natively(ctx)` helper that returns `true` whenever `CLIAgentSessionsModel::as_ref(ctx).session(self.view_id).is_some()` — i.e. any active CLI agent session on this terminal. The paste target must also be the PTY (`!should_paste_in_input`) and the event must not be a middle-click (`!middle_click`), since middle-click is an X11/Linux text-paste convention.
### 1. Forward the native paste keystroke for raw clipboard image data
Before the existing text-paste logic, if we're in a CLI-agent paste and `ctx.clipboard().read().has_image_data()` is true, write the platform-appropriate keystroke the CLI agent expects and return early:
- Windows: `ESC 'v'` (`[0x1b, b'v']`) — `Alt+V`.
- macOS / Linux: `[0x16]``Ctrl+V` (SYN).
This intentionally fires only when raw image data is present. A clipboard that only has file paths (Explorer copy) falls through to #2 — Claude Code's native `Alt+V` handler only reads raw image bytes and errors out ("No image found in clipboard. Use alt+v to paste images.") if we hand it a path-only clipboard.
### 2. Skip shell-escaping on pasted file paths in CLI-agent pastes
`TerminalView::read_from_clipboard` and `TerminalView::middle_click_paste_content` now take `Option<ShellFamily>` instead of `ShellFamily`. `paste()` passes `None` when `is_cli_agent_paste` is true; otherwise it passes `Some(self.shell_family(ctx))` as before. `clipboard_content_with_escaped_paths` already handled `None` by returning paths verbatim, so no changes were needed there.
The net effect: a path like `C:\Users\andy\Pictures\screenshot.png` is sent to the agent exactly as Windows Terminal would paste it — the agent's file-path detection recognizes it and attaches the image. No PowerShell backtick escaping is applied.
### Scope
`active_cli_agent_handles_image_paste_natively` returns `true` for *any* active CLI agent session, including `CLIAgent::Unknown` (user-configured regex matches). The bar to register a session at all is that Warp's CLI-agent detection matched the command; once matched, the coding-agent contract (verbatim paths, optional native image paste) applies uniformly. No per-agent allowlist is maintained.
Everything else (regular text pastes, pastes into Warp's input editor, middle-click, plain shells without an active CLI agent session) continues through the existing `read_from_clipboard` → bracketed-paste path with shell-escaping unchanged.
A feature flag isn't warranted: the change is scoped by the active CLI agent session and reverts the hijack to faithful pass-through behavior, which is strictly closer to what the agent would see running under a plain terminal emulator.
## Testing and validation
Manual verification on Windows (primary platform for the bug):
1. Run `claude` in a Warp terminal until the Claude Code TUI is active.
2. Capture a screenshot with `Win+Shift+S`. Press `Ctrl+V` in Warp. Claude Code should show the `[Image #N]` attachment chip (it receives `Alt+V` and reads the clipboard itself). Previously: nothing happened.
3. In Explorer, copy an image file (`.png`). Press `Ctrl+V`. Claude Code should attach the image via its path-detection on the unescaped path. Previously: PowerShell-escaped path was pasted as text and the agent didn't recognize it.
4. Repeat 23 with `codex` and `opencode` — unescaped file-path paste should attach the image for both. (Raw image data via Alt+V is Claude-specific; Codex/OpenCode paths are the primary case for those agents.)
5. Copy plain text. Press `Ctrl+V`. Text should paste as before; the raw-image early return does not fire and `clipboard_content_with_escaped_paths(..., None)` returns `plain_text` unchanged.
6. Outside any CLI agent (plain `pwsh`), copy a screenshot and Ctrl+V. Behavior is unchanged from today (no CLI agent session → helper returns `false` → shell-escape still applied).
Cross-platform regression checks:
- macOS: `claude` with a screenshot in the clipboard + `Ctrl+V` still attaches the image. On macOS, `Cmd+V` in Warp dispatches the same `TerminalAction::Paste`; the keystroke branch writes `0x16` which matches what macOS Claude Code expects.
- Linux: same as macOS with `Ctrl+V`.
- Middle-click paste on Linux still inserts text (early branch skipped because `middle_click` is true, which also means shell escaping is still applied on that path).
- Pasting into Warp's own input editor (Agent Mode, rich input) is unaffected because `should_paste_in_input` short-circuits before the new branches.
Automated:
- `cargo check -p warp --lib`.
- Existing paste tests in `app/src/terminal/view_test.rs` and `app/src/terminal/input_test.rs` continue to pass — the test helper `read_from_clipboard(ctx)` was updated to pass `Some(ShellFamily::Posix)` to match the new signature, and the new branches only fire when a CLI agent session is active, which those tests don't set up.
## Risks and mitigations
- **Sending `Alt+V` / `Ctrl+V` bytes to a non-Claude TUI that happens to be detected as a CLI agent session.** Only fires when the clipboard actually has raw image data (`has_image_data()` is true), so for normal text/path pastes this branch never runs. Agents that don't handle the keystroke will simply ignore the byte.
- **Future CLI agent updates change the paste keystroke.** The mapping is one `cfg!(windows)` branch inside `paste()`; updating it is a one-line change.
- **Clipboard with both image data and text.** If image data is present we forward the keystroke and don't paste the text. This matches the pre-existing macOS behavior (where the path text was technically inserted but Claude Code attached the image and ignored the text) and is what users asking for native image paste expect.
- **User-defined `CLIAgent::Unknown` regex matches.** These are treated the same as known agents — verbatim paths, keystroke forwarding for raw images. The regex matching is opt-in (user adds the pattern) so the assumption that they want CLI-agent semantics is reasonable.
+47
View File
@@ -0,0 +1,47 @@
# PRODUCT — Distinguish left vs right Alt on Windows and Linux
Linear: [CODE-1794](https://linear.app/warpdotdev/issue/CODE-1794/windowslinux-right-alt-isnt-recognized-breaking-right-alt-as-meta)
## Summary
On Windows and Linux, the "Right Alt as meta" and "Left Alt as meta" settings under Keys in Warp should each independently control only their own physical Alt key. Today, right Alt is never recognized as right Alt, so enabling "Right Alt as meta" is a no-op and enabling "Left Alt as meta" incorrectly treats both Alt keys as meta.
## Problem
Warp users on Windows and Linux who rely on the extra-meta-keys settings to make a single Alt key behave as meta (for example, shell users who want right Alt to emit ESC-prefixed keys while left Alt still works as a regular modifier for keybindings like Ctrl+Alt+R "Resume conversation") cannot do so today. The setting either has no effect (right-alt-as-meta) or applies to both keys at once (left-alt-as-meta), breaking keybindings that need a plain Alt modifier.
## Behavior
1. The Settings → Keys page exposes two independent checkboxes, "Left Alt as meta" and "Right Alt as meta", each of which controls only its own physical key. Toggling one must not change the behavior of the other.
2. When "Left Alt as meta" is enabled and "Right Alt as meta" is disabled:
- Pressing a character with left Alt held produces a meta-prefixed keystroke (Alt is stripped, meta is set), both for keybindings and for PTY input (ESC-prefixed text in the shell).
- Pressing the same character with right Alt held produces a normal Alt-prefixed keystroke. Keybindings that use Alt (e.g. `ctrl-alt-r`) fire as expected, and Alt-only bindings see `alt: true, meta: false`.
3. When "Right Alt as meta" is enabled and "Left Alt as meta" is disabled:
- Pressing a character with right Alt held produces a meta-prefixed keystroke.
- Pressing the same character with left Alt held produces a normal Alt-prefixed keystroke.
4. When both settings are enabled, either Alt key produces a meta-prefixed keystroke (current combined behavior is preserved).
5. When both settings are disabled, neither Alt key is treated as meta and both behave as plain Alt modifiers. This is the default.
6. The existing Windows/Linux keybinding `Ctrl+Alt+R` ("Resume conversation"), and any other `ctrl-alt-*` keybinding, continues to work with either Alt key whenever that Alt side is not configured to be treated as meta.
7. On macOS, the existing Option-as-meta behavior, which already distinguishes left and right Option via the platform-native path, is unchanged.
8. Settings changes take effect on the next keystroke. The user does not have to relaunch Warp.
9. Alt state does not get "stuck":
- If the user holds Alt, switches windows via Alt+Tab, releases Alt while Warp is not focused, and then refocuses Warp, Warp must not continue to believe that Alt is held. The next character key pressed in Warp reports `alt: false`.
- If either Alt key release is lost for any other reason (dropped event, OS-level remap), Warp recovers whenever the OS next reports that no Alt is held.
10. The per-side distinction applies only to Alt for this feature. Other modifiers (Shift, Ctrl, Cmd/Super) continue to behave as before.
11. The diagnostic log line emitted when a key is rewritten to meta identifies which side triggered the conversion (left alt, right alt, or both), so bug reports of the form "my right Alt still acts like meta" can be triaged from logs without a repro.
## Success criteria
- On Windows, with "Left Alt as meta" enabled and "Right Alt as meta" disabled, `Ctrl + RightAlt + R` fires the Resume conversation keybinding, and `LeftAlt + b` sends ESC-b to the PTY.
- On Windows and Linux, toggling "Right Alt as meta" on its own changes right Alt behavior and leaves left Alt untouched, and vice versa.
- Existing macOS behavior for Option-as-meta is unchanged.
+91
View File
@@ -0,0 +1,91 @@
# TECH — Distinguish left vs right Alt on Windows and Linux
Linear: [CODE-1794](https://linear.app/warpdotdev/issue/CODE-1794/windowslinux-right-alt-isnt-recognized-breaking-right-alt-as-meta)
See `PRODUCT.md` for user-visible behavior.
## Context
The extra-meta-keys setting is consumed by the `apply_extra_meta_keys` event munger in `app/src/lib.rs:461-480`. It reads `details.left_alt` and `details.right_alt` from the `KeyEventDetails` attached to a `KeyDown` event and rewrites the keystroke (strips `alt`, sets `meta`) when the corresponding side is enabled.
On macOS, those flags are populated by the platform-native NSEvent path (`crates/warpui/src/platform/mac/event.rs`) which reads `NSEvent.modifierFlags` and correctly distinguishes the two Option keys.
On Windows and Linux, events flow through winit (`crates/warpui/src/windowing/winit/event_loop/...`). Before this change, `convert_keyboard_input_event` in `crates/warpui/src/windowing/winit/event_loop/key_events.rs:125-134` populated `KeyEventDetails` as:
```rust path=null start=null
details: KeyEventDetails {
left_alt: window_state.modifiers.alt_key(),
right_alt: false,
key_without_modifiers,
},
```
`winit::keyboard::ModifiersState` is side-agnostic: `alt_key()` returns true whenever any Alt is held, and there is no corresponding `left_alt_key()` / `right_alt_key()` on that type. Per-side state on `winit::event::Modifiers` (`lalt_state()` / `ralt_state()`) is unreliable on some Linux backends. As a result, right Alt was never reported as right Alt, and left Alt was set to "any Alt is held", so `apply_extra_meta_keys` could never distinguish the two.
Winit does reliably report the physical key of each individual `KeyboardInput` event via `event.physical_key`, which is a `PhysicalKey::Code(KeyCode)`. `KeyCode::AltLeft` and `KeyCode::AltRight` are distinct variants and are already used elsewhere in this file (`try_from_winit_keycode`, `event_loop/mod.rs:91-107`) to produce side-aware `ModifierKeyChanged` events for voice input and similar consumers.
Relevant files:
- `app/src/lib.rs:458-480` — `apply_extra_meta_keys`, the consumer of `details.left_alt` / `right_alt`.
- `app/src/settings/mod.rs:181-199` — `ExtraMetaKeys` struct with `left_alt` / `right_alt` bools.
- `crates/warpui/src/windowing/winit/event_loop/mod.rs` — `WindowState` and event dispatch for the winit platform.
- `crates/warpui/src/windowing/winit/event_loop/key_events.rs` — winit → warpui keyboard event conversion.
- `crates/warpui_core/src/event.rs` — `KeyEventDetails` definition.
## Proposed changes
Track per-side Alt press state in `WindowState` based on `PhysicalKey::Code(AltLeft/AltRight)` from `KeyboardInput` events, and surface those flags through `KeyEventDetails` so the existing `apply_extra_meta_keys` munger distinguishes the two sides without any other changes.
1. `crates/warpui/src/windowing/winit/event_loop/mod.rs` — Add two booleans to `WindowState`:
- `left_alt_pressed: bool`
- `right_alt_pressed: bool`
Initialize both to `false` in `WindowState::new`. These are per-window state to match the existing `modifiers` field.
2. In `convert_window_event`, inside `WindowEvent::KeyboardInput`, update the flags before the existing modifier-key early return:
- Match on `event.physical_key` for `KeyCode::AltLeft` / `KeyCode::AltRight`.
- Set the corresponding flag to `event.state == ElementState::Pressed`.
- Do this before the `try_from_winit_keycode` short-circuit so the flag is always updated, even when the event is forwarded as `ConvertedEvent::ModifierKeyChanged` instead of flowing through `convert_keyboard_input_event`.
3. Add two belt-and-suspenders resets so dropped release events can't leave a side "stuck":
- `WindowEvent::ModifiersChanged`: if the resulting `state.alt_key()` is false, clear both flags.
- `WindowEvent::Focused(false)`: clear both flags before the existing focus-out bookkeeping.
These mirror how the synthetic-event guard already protects against Alt+Tab races in `convert_keyboard_input_event` (`key_events.rs:81-83`).
4. `crates/warpui/src/windowing/winit/event_loop/key_events.rs` — In `convert_keyboard_input_event`, populate `KeyEventDetails` from the tracked flags instead of from `ModifiersState`:
```rust path=null start=null
details: KeyEventDetails {
left_alt: window_state.left_alt_pressed,
right_alt: window_state.right_alt_pressed,
key_without_modifiers,
},
```
5. `app/src/lib.rs` — Tag the existing `log::info!("Treating option as meta")` with which side triggered the conversion (`left alt`, `right alt`, or `left+right alt`). This is a small log change that makes future bug reports triageable from logs alone.
No new public types or cross-crate API changes. No setting migration. No feature flag: the change is bounded to the Windows/Linux winit path and strictly narrows existing incorrect behavior (the old code treated any Alt as `left_alt: true` and never reported `right_alt`).
### Tradeoffs considered
- **Use `winit::event::Modifiers::lalt_state()` / `ralt_state()` in `ModifiersChanged`.** Simpler to write, but per-side state on that API is documented as unreliable on some Linux/X11 backends (can return `Unknown`). `PhysicalKey::Code` is the portable, stable signal.
- **Query the Windows API directly (`GetKeyState(VK_LMENU/VK_RMENU)`).** Works on Windows but is platform-specific and redundant with what winit already delivers in `KeyboardInput`.
- **Populate `KeyEventDetails` from `event.physical_key` at `convert_keyboard_input_event` time.** `physical_key` on a non-Alt event tells us which character key is being pressed, not which Alt side is currently held. We need the accumulated per-side modifier state, which is what the `WindowState` flags give us.
## Testing and validation
Invariant references are to the numbered behaviors in `PRODUCT.md`.
- Unit tests in `crates/warpui/src/windowing/winit/event_loop/key_events_tests.rs` (existing file) that drive `convert_keyboard_input_event` with a `WindowState` set to combinations of `left_alt_pressed` / `right_alt_pressed` and assert `KeyEventDetails.left_alt` / `right_alt` round-trip correctly. Covers invariants 2, 3, 4, 5, 10.
- Unit test(s) for `apply_extra_meta_keys` in `app/src/lib.rs` exercising the four `(left_alt, right_alt)` × `(ExtraMetaKeys.left_alt, ExtraMetaKeys.right_alt)` combinations. Covers invariants 2, 3, 4, 5, 11 (via the tagged log message if captured).
- Manual verification on Windows (primary risk surface):
- With `ExtraMetaKeys { left_alt: true, right_alt: false }`: `LeftAlt+b` sends ESC-b to the PTY; `Ctrl+RightAlt+R` fires the Resume conversation keybinding (invariant 2, 6).
- With `ExtraMetaKeys { left_alt: false, right_alt: true }`: `RightAlt+b` sends ESC-b; `Ctrl+LeftAlt+R` fires the Resume conversation keybinding (invariant 3, 6).
- Toggling one setting on the Keys page without touching the other and re-testing (invariant 1, 8).
- Alt+Tab out of Warp with Alt held, release outside the window, refocus: next character key reports plain Alt (invariant 9).
- Manual verification on Linux (X11 and Wayland) for invariants 2, 3, 5, 9. Confirms the per-side tracking works regardless of the per-side `Modifiers` state reliability caveat.
- Manual verification on macOS that the Option-as-meta path is unchanged (invariant 7) — the change is gated to the winit path and should be a no-op on macOS, but a smoke test is cheap.
## Risks and mitigations
- **Dropped key-release events leave a side "stuck" as pressed.** Mitigated by clearing both flags on `WindowEvent::Focused(false)` and whenever `ModifiersChanged` reports no Alt is held. The common Alt+Tab path hits both.
- **Synthetic focus-in key events.** The existing synthetic-event guard in `convert_keyboard_input_event` (`key_events.rs:81-83`) drops synthetic press events so they cannot re-arm the flag when refocusing. The new tracking runs in `convert_window_event`, before that guard; that is intentional so the flag reflects physical state, but synthetic press events for AltLeft/AltRight on focus-in could briefly set a flag that `Focused(true)` doesn't clear. Mitigated by the `ModifiersChanged` safety net, which fires whenever the real modifier state differs from our tracked state.
- **Other modifier keys (Shift/Ctrl/Cmd).** This change intentionally does not add per-side tracking for those; `ExtraMetaKeys` only exposes Alt sides today, and extending to other modifiers is out of scope.
@@ -0,0 +1,31 @@
# Disable Suggested Rules Setting
## Summary
Add a user-facing toggle to disable the AI's suggested-rules chips that appear after agent responses. When disabled, the AI will not present inline rule-suggestion chips to the user.
Figma: none provided
## Behavior
1. A new setting, **Suggested Rules**, appears in the **Knowledge** section of AI settings (Settings → Oz → Knowledge), directly below the existing **Rules** toggle. The setting is only present when the `SuggestedRules` feature flag is enabled.
2. The **Suggested Rules** toggle is on by default (`true`). The description reads: "Let AI suggest rules to save based on your interactions."
3. When **Suggested Rules** is on, the agent may show inline rule-suggestion chips at the bottom of an agent response block after the response completes, as it does today. No change to existing behavior.
4. When **Suggested Rules** is turned off, no rule-suggestion chips appear at the bottom of any agent response block — including responses that would otherwise have generated suggestions. Suggestions that have already been rendered (from a past response in the same session) are not retroactively hidden; only future responses are affected.
5. The toggle is independently controllable from the parent **Rules** toggle (which gates whether saved rules are included in agent requests). Turning **Rules** off does not automatically turn **Suggested Rules** off, and vice versa.
6. The toggle is synced to the cloud across devices via the standard global sync mechanism (same behavior as other Active AI settings such as Prompt Suggestions).
7. The setting is scoped to the `agents.warp_agent.active_ai.rule_suggestions_enabled` TOML key. Users who set this key in their settings file have their preference respected on next launch.
8. The **Suggested Rules** toggle is disabled (greyed out, not interactive) when the top-level global AI toggle (**Oz**) is off, matching the visual and interaction behavior of all other AI sub-settings.
9. The setting is not visible in the settings UI when the `SuggestedRules` feature flag is disabled. The setting value is still persisted if previously set, so enabling the flag later restores the user's preference.
10. Agent mode workflow suggestion chips (the fallback shown when there are no rule suggestions) are unaffected by this setting — they are controlled by the `SuggestedAgentModeWorkflows` feature flag.
11. A **"Don't show again"** button appears in the suggestions footer of the AI block, to the left of the existing **Dismiss** button, whenever one or more rule-suggestion chips are visible. Clicking it permanently disables the **Suggested Rules** setting (same effect as toggling it off in Settings) and immediately removes the rule-suggestion chips from the current block.
@@ -0,0 +1,109 @@
# Disable Suggested Rules Setting — Tech Spec
See `PRODUCT.md` for user-visible behavior.
## Context
Rule suggestions are inline chip views shown at the bottom of an AI block after the agent response completes. The feature is gated by `FeatureFlag::SuggestedRules`.
**Relevant files:**
- `app/src/settings/ai.rs``AISettings` settings group; all Active AI toggles (`prompt_suggestions_enabled_internal`, `code_suggestions_enabled_internal`, etc.) follow the same `define_settings_group!` + getter pattern.
- `app/src/ai/blocklist/block.rs (21372189)``handle_complete_output` creates `SuggestionChipView` instances for each suggested rule. The check `if FeatureFlag::SuggestedRules.is_enabled()` guards the entire block.
- `app/src/settings_view/ai_page.rs``AIFactWidget` renders the Knowledge section. Contains the `ToggleRules` and `ToggleWarpDriveContext` toggle rows.
The pattern for an opt-out Active AI setting already exists verbatim for Prompt Suggestions (`prompt_suggestions_enabled_internal` / `is_prompt_suggestions_enabled`) and Code Suggestions.
## Proposed Changes
### 1. New setting (`app/src/settings/ai.rs`)
Add `rule_suggestions_enabled_internal: RuleSuggestionsEnabled` to the `define_settings_group!(AISettings, …)` macro:
```
rule_suggestions_enabled_internal: RuleSuggestionsEnabled {
type: bool,
default: true,
supported_platforms: SupportedPlatforms::ALL,
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
private: false,
toml_path: "agents.warp_agent.active_ai.rule_suggestions_enabled",
description: "Controls whether the agent suggests rules to save after responses.",
feature_flag: FeatureFlag::SuggestedRules,
}
```
The `feature_flag` field causes the setting to be excluded from the user-facing JSON schema when `SuggestedRules` is not enabled for the current build channel.
Add the getter to `impl AISettings`:
```rust
pub fn is_rule_suggestions_enabled(&self, app: &warpui::AppContext) -> bool {
self.is_active_ai_enabled(app) && *self.rule_suggestions_enabled_internal
}
```
This follows the same pattern as `is_prompt_suggestions_enabled` and `is_code_suggestions_enabled`.
### 2. Enforcement in `handle_complete_output` (`app/src/ai/blocklist/block.rs`)
Change the guard from:
```rust
if FeatureFlag::SuggestedRules.is_enabled() {
```
to:
```rust
if FeatureFlag::SuggestedRules.is_enabled()
&& AISettings::as_ref(ctx).is_rule_suggestions_enabled(ctx)
{
```
This is the single chokepoint where `SuggestionChipView::new_rule_chip` instances are created. No other code path renders rule-suggestion chips.
### 3. UI toggle in `AIFactWidget` (`app/src/settings_view/ai_page.rs`)
- Add `rule_suggestions_toggle: SwitchStateHandle` to `AIFactWidget`.
- Add `RuleSuggestionsEnabled` to the `use crate::settings::{…}` import block.
- Add `ToggleRuleSuggestions` variant to `AISettingsPageAction`.
- Add a handler for `ToggleRuleSuggestions` in `handle_action` that calls `settings.rule_suggestions_enabled_internal.toggle_and_save_value(ctx)`.
- Add a `render_rule_suggestions_toggle` method to `AIFactWidget` following the same structure as `render_warp_drive_context_toggle`.
- In `AIFactWidget::render`, call `render_rule_suggestions_toggle` conditionally:
```rust
if FeatureFlag::SuggestedRules.is_enabled() {
column.add_child(self.render_rule_suggestions_toggle(view, ai_settings, app));
}
```
Insert this between the rules toggle + "Manage rules" button and the Warp Drive context toggle.
The toggle renders with `is_any_ai_enabled` as the `is_toggleable` argument (not `is_active_ai_enabled`), consistent with other Knowledge-section toggles like `ToggleRules`.
### 4. "Don't show again" button (`app/src/ai/blocklist/block.rs`, `app/src/ai/blocklist/block/view_impl/output.rs`, `app/src/ai/blocklist/block/view_impl.rs`)
A second dismiss button is added to the suggestions footer that permanently disables the setting in addition to clearing the chips.
**`block.rs`:**
- Add `disable_rule_suggestions_button: ViewHandle<ActionButton>` field to `AIBlock`.
- Create the button in `AIBlock::new` with label `"Don't show again"` and theme `SuggestionDismissButtonTheme`, dispatching `AIBlockAction::DisableRuleSuggestions` on click.
- Add `AIBlockAction::DisableRuleSuggestions` variant. The handler:
1. Calls `conversation.dismiss_current_suggestions()` on the history model (for persistence of the dismissal).
2. Sets `settings.rule_suggestions_enabled_internal` to `false` via `set_value`.
3. Calls `self.suggested_rules.clear()` to immediately remove the chip views from the block, ensuring the footer disappears even if `conversation.existing_suggestions` has not yet been populated by `mark_request_completed`.
**`output.rs` (`Props` and `render_suggested_rules_and_prompts_footer`):**
- Add `disable_rule_suggestions_button: &'a ViewHandle<ActionButton>` to `Props`.
- In `render_suggested_rules_and_prompts_footer`, build a `right_buttons` row that conditionally prepends `disable_rule_suggestions_button` (with `margin_right: 4.0`) before `dismiss_suggestion_button` when `has_suggested_rules` is true.
**`view_impl.rs`:**
- Pass `disable_rule_suggestions_button: &self.disable_rule_suggestions_button` when constructing `output::Props`.
## Testing and Validation
- **Behavior 12 (toggle placement and default):** Open Settings → Knowledge. Confirm the toggle appears under the Rules toggle and is on by default.
- **Behavior 3 (on = chips appear):** With the toggle on, run an agent interaction that returns rule suggestions. Verify chips appear as before.
- **Behavior 4 (off = chips suppressed):** Turn the toggle off. Run a new agent interaction. Verify no rule-suggestion chips appear. Existing chips from prior responses in the session remain visible.
- **Behavior 5 (independence):** Turn Rules off; verify Suggested Rules toggle state is unchanged and vice versa.
- **Behavior 8 (greyed out when global AI off):** Turn global AI off. Confirm the Suggested Rules toggle is visually disabled and non-interactive.
- **Behavior 9 (hidden without feature flag):** In a build without `SuggestedRules` enabled, confirm the toggle is absent from the settings UI.
@@ -0,0 +1,16 @@
# Session Restore: Preserve PWD for WSL and Git Bash
## Summary
When Warp restores a session that was running in WSL or an MSYS2-based shell (Git Bash, MSYS2), it re-opens that terminal in the same working directory the user was in when the session was saved.
## Behavior
1. When a Warp session is snapshotted (e.g. on app quit, window close, or session save), the current working directory is persisted as part of the session state.
2. When a WSL session is restored, the new terminal opens with its working directory set to the same path the user was in before. For example, if the user was in `/home/user/projects`, the restored terminal starts there.
3. When an MSYS2 or Git Bash session is restored, the new terminal opens with its working directory set to the same path the user was in before. For example, if the user was in `/c/Users/user/projects`, the restored MSYS2 session starts in `/c/Users/user/projects`.
4. If the working directory stored in the snapshot no longer exists, the terminal opens with no startup directory override — falling back to the shell's default.
5. Session restore behavior for plain Windows shells (PowerShell, Cmd) and native-Unix shells is unaffected.
@@ -0,0 +1,58 @@
# Session Restore PWD for WSL and Git Bash — Tech Spec
See `PRODUCT.md` for user-visible behavior.
## Context
When Warp saves a session snapshot it records the terminal's current working directory via `TerminalView::active_session_path_if_local`, which calls `ShellLaunchData::maybe_convert_absolute_path` on the raw Unix-style `$PWD` string the shell reports:
- For WSL, `/home/user/projects``\\WSL$\<distro>\home\user\projects` (Windows UNC path).
- For MSYS2/Git Bash, `/c/Users/user/projects``C:\Users\user\projects` (native drive path).
So by the time the path is written into `TerminalSnapshot::cwd`, it is already a Windows-native path.
**Relevant files:**
- `crates/warp_terminal/src/shell/mod.rs (768790)``ShellLaunchData::maybe_convert_absolute_path`, which performs the Unix → Windows conversion at snapshot time.
- `app/src/terminal/view.rs (65066528)``active_session_path_if_local`, which calls `maybe_convert_absolute_path` and is the write path into the snapshot.
- `app/src/pane_group/mod.rs (15331570)` — session restore logic that reads `TerminalSnapshot::cwd` and computes `startup_directory`.
## Why we store host-native paths in sqlite
The snapshot stores `cwd` as a Windows-native path rather than the guest-native Unix path for three reasons:
1. **`CreateProcessW` requires it.** `lpCurrentDirectory` must be a Windows path. Storing it host-native means no conversion is needed at restore time.
2. **`is_dir()` works natively.** Windows can stat `\\WSL$\<distro>\...` paths directly, letting the restore code verify the directory still exists without any extra logic.
3. **Avoids per-shell branching at restore time.** Storing the guest-native path and re-converting at restore time would require extracting the distro or MSYS2 executable from `shell_launch_data` again — exactly the logic that caused the original bug.
## Root Cause
The restore code in `pane_group/mod.rs` was re-running the Unix→Windows conversion on `cwd`, passing the already-converted Windows path back into `convert_wsl_to_windows_host_path` / `convert_msys2_to_windows_native_path`. Both functions expect a Unix-style input; given a Windows path they fail and return `None`, so `startup_directory` was always `None` for WSL and MSYS2 sessions, causing the restored terminal to open in the shell's default directory instead of the saved one.
The `TODO(CORE-3130)` comment in the old WSL branch also noted that the resulting path was being ignored downstream — a sign the whole conversion was unnecessary.
## Proposed Changes
**`app/src/pane_group/mod.rs`**
Replace the `shell_launch_data`-aware path conversion block with a direct `PathBuf::from(cwd)`:
```rust
let startup_directory = terminal_snapshot
.cwd
.map(PathBuf::from)
.filter(|path| path.is_dir());
```
`CreateProcessW`'s `lpCurrentDirectory` accepts both forms:
- `\\WSL$\<distro>\...` UNC paths — `wsl.exe` translates these back to Linux paths on startup.
- Native `C:\...` drive paths — MSYS2's `bash.exe` maps them to the corresponding MSYS2 path (e.g. `/c/...`) via its own mount table on startup.
The `chosen_shell` / `wsl_distro` / `msys2_executable` locals derived from `shell_launch_data` are no longer needed for path conversion. `chosen_shell` (used only for `AvailableShells::get_from_shell_launch_data`) is retained in a simplified form; the other two are removed. The `convert_msys2_to_windows_native_path`, `msys2_exe_to_root`, and `WindowsPath` imports that were used solely for the now-deleted conversion are also removed.
## Testing and Validation
- **Behavior 2 (WSL):** Open a WSL terminal, `cd` to a non-default directory (e.g. `~/projects`), quit Warp, relaunch. Confirm the restored WSL tab opens in `~/projects`.
- **Behavior 3 (MSYS2/Git Bash):** Open a Git Bash terminal, `cd /c/Users/<user>/projects`, quit Warp, relaunch. Confirm the restored tab opens in `/c/Users/<user>/projects`.
- **Behavior 4 (missing directory):** Delete the saved directory before relaunching. Confirm the tab opens without error, falling back to the shell default.
- **Behavior 5 (unaffected shells):** Verify PowerShell and Cmd session restore continues to work as before.