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
+135
View File
@@ -0,0 +1,135 @@
# Product Spec: OpenCode Plugin Install & Update Flow
Linear: [APP-3690](https://linear.app/warpdotdev/issue/APP-3690)
Figma: none provided
## Summary
Add install and update chip support for the OpenCode Warp plugin (`opencode-warp`), matching the existing Claude Code chip UX. Clicking the chip opens a modal with manual installation/update instructions.
## Problem
When a user runs OpenCode inside Warp, the Warp plugin enriches the experience with native notifications and session status tracking. Today, Warp has no way to prompt OpenCode users to install or update this plugin — `plugin_manager_for(CLIAgent::OpenCode)` returns `None`.
## Goals
- OpenCode users see install/update chips that prompt them to set up the plugin.
- Clicking the chip opens a modal with clear, copy-pasteable instructions.
- Outdated plugin versions are detected via the `SessionStart` event and users are prompted to update.
## Prerequisites
The `opencode-warp` npm package must be published before this feature ships. The name is currently available on npm.
## Non-Goals
- Auto-install (one-click install that runs commands automatically). See "Why no auto-install" below.
- Filesystem-based version detection. All version information comes from the plugin's `SessionStart` event.
## Why No Auto-Install
Claude Code has a CLI for plugin management (`claude plugin install`, `claude plugin update`), so the Claude flow shells out those commands for one-click install/update. OpenCode has no equivalent — there are no CLI commands for managing plugins.
OpenCode plugins are installed by either:
1. Adding the npm package name to the `"plugin"` array in `opencode.json` (requires JSON config editing).
2. Placing a `.js`/`.ts` file in `~/.config/opencode/plugins/` (requires downloading a file).
Both paths require either manipulating JSON config files from Rust (fragile — OpenCode supports JSONC with comments, multiple config locations, etc.) or downloading files via curl/npm from a CDN (fragile — depends on curl availability, CDN reachability, correct URL construction). Neither is as clean or reliable as Claude's dedicated CLI.
Since the install is a one-time action and the instructions are simple (add one line to a JSON file), a manual instructions modal is the right tradeoff: reliable, works everywhere (local and SSH), and avoids a class of edge cases.
## How OpenCode Plugins Work
OpenCode has two plugin loading mechanisms:
1. **npm packages** listed in `opencode.json` under the `"plugin"` array. OpenCode auto-installs these via Bun into `~/.cache/opencode/node_modules/` at startup.
2. **Local files** placed in `~/.config/opencode/plugins/` (global) or `.opencode/plugins/` (project). These are loaded directly at startup.
OpenCode has no CLI commands for plugin management (no `/plugin install`, no `/plugin update`, etc.).
**Bun caching behavior:** Once a package is installed in `node_modules`, `bun install` checks that the package exists with an appropriate version and skips re-downloading. This means npm plugins do **not** auto-update — the cached version persists until explicitly cleared.
## User Experience
### Install Chip
When a user starts an OpenCode session and the plugin has never connected (no listener, no `plugin_version` reported):
- A green chip appears: "Notifications setup instructions"
- Clicking opens a modal with manual install instructions.
**Install instructions modal:**
- Title: "Install Warp Plugin for OpenCode"
- Subtitle: "Add the Warp plugin to your OpenCode configuration, then restart OpenCode."
- Steps:
1. "Add the plugin to your config" — copyable snippet:
```json
{ "plugin": ["opencode-warp"] }
```
with explanatory text: "Add `"opencode-warp"` to the `plugin` array in your `opencode.json` (project root) or `~/.config/opencode/opencode.json` (global)."
2. "Restart OpenCode to activate"
### Update Chip
When the plugin is connected but reports a version below `MINIMUM_PLUGIN_VERSION`:
- Label: "Plugin update instructions"
- Same green styling, same dismiss (X) button as install chip.
- Same chip position — install and update never appear simultaneously.
**Why updates don't happen automatically:** Bun caches installed npm packages and does not re-resolve to `latest` on subsequent startups. The cached version persists until the user explicitly clears it.
**Version detection:**
- Sole signal: `plugin_version` field from the `SessionStart` event.
- No filesystem-based version checks. If the plugin hasn't connected, we don't know its version — we show the install chip instead.
- Since the opencode-warp plugin has never been released, there is no legacy version that predates version reporting. Every installed version will report `plugin_version`.
**Update instructions modal:**
- Title: "Update Warp Plugin for OpenCode"
- Subtitle: "Clear the cached plugin and restart OpenCode to pull the latest version."
- Steps:
1. "Remove the cached plugin" — copyable command: `rm -rf ~/.cache/opencode/node_modules/opencode-warp`
2. "Restart OpenCode" — "OpenCode will re-download the latest version on startup."
### Chip Visibility Logic
Simplified from the Claude Code flow since there are no filesystem checks:
1. Plugin connected, version >= minimum → **no chip**
2. Plugin connected, version < minimum or not reported → **update chip**
3. No listener connected → **install chip** (unless dismissed)
4. Chip dismissed → **no chip** (install and update have independent dismiss state)
5. Notifications disabled in settings → **no chip**
Note: without filesystem checks, we cannot distinguish "not installed" from "installed but hasn't connected yet." To avoid a flicker where the install chip appears briefly before the plugin connects, the chip should be debounced — wait a few seconds after session start before showing the install chip. If the plugin connects and sends its `SessionStart` event during that window, the chip never appears.
### Dismiss Behavior
Same as Claude Code: install chip and update chip have independent dismiss state. The update chip tracks which minimum version was dismissed, so a new minimum causes it to reappear.
## Edge Cases
- **Plugin connects after brief delay:** Install chip shows momentarily, then disappears when `SessionStart` arrives. This is the expected startup race.
- **User installs via npm config path:** Plugin connects, reports version, chip disappears. Works identically.
- **User installs via local file path:** Same — plugin connects, reports version.
- **SSH sessions:** Same modal experience as local. The instructions work on any machine.
- **Multiple tabs:** All tabs share the same session state. Dismiss state is shared via the existing settings infrastructure.
## Success Criteria
1. Starting an OpenCode session in Warp when the plugin has never connected shows a green "Notifications setup instructions" chip (after a brief debounce).
2. Clicking the chip opens a modal with install instructions including a copyable config snippet.
3. After the user installs and restarts OpenCode, the plugin connects and the chip disappears.
4. When the plugin reports a version below `MINIMUM_PLUGIN_VERSION`, the "Plugin update instructions" chip appears.
5. Clicking the update chip opens a modal with cache-clear instructions.
6. Dismissing the update chip hides it for the current minimum version; a newer minimum causes reappearance.
7. Dismissing the install chip hides the install chip (independent from update dismiss).
8. The install and update chips never appear simultaneously.
## Validation
- Unit tests: `compare_versions()` (reuse from Claude module or extract to shared utility).
- Unit tests: chip visibility for each state in the chip visibility logic.
- Manual testing: full install flow on a real OpenCode session — verify instructions work, restart activates the plugin, notifications appear.
- Manual testing: update flow — verify cache clear instructions work, restart pulls new version.
- Manual testing: SSH session — verify modal appears and instructions work on the remote.
+249
View File
@@ -0,0 +1,249 @@
# Tech Spec: OpenCode Plugin Install & Update Flow
See `specs/APP-3690/PRODUCT.md` for the product spec.
## 1. Problem
`plugin_manager_for(CLIAgent::OpenCode)` returns `None` (`plugin_manager/mod.rs:90`), so OpenCode sessions never show the install/update chip. We need an `OpenCodePluginManager` that implements the `CliAgentPluginManager` trait and wires into the existing footer/modal infrastructure.
Unlike Claude Code, OpenCode has no CLI for plugin management, so this implementation is manual-only (instructions modal, no auto-install). This requires a small trait refactor to make auto-operations optional and to generalize the per-agent minimum version.
## 2. Relevant Code
- `plugin_manager/mod.rs` — trait definition, `plugin_manager_for()` factory, `PluginInstructions` types
- `plugin_manager/claude.rs` — reference implementation; owns `compare_versions` and `MINIMUM_PLUGIN_VERSION`
- `agent_input_footer/mod.rs (677-744)` — `plugin_chip_kind()` determines which chip to show
- `agent_input_footer/mod.rs (748-758)` — `should_use_manual_mode()` determines auto vs modal
- `agent_input_footer/mod.rs (760-849)` — `handle_plugin_operation()` shared async handler (unused by OpenCode)
- `agent_input_footer/mod.rs (978-999)` — render logic matching `(chip_kind, manual)` to buttons
- `workspace/view/plugin_install_modal.rs` — generic instructions modal (already agent-agnostic, takes `&'static PluginInstructions`)
- `cli_agent_sessions/mod.rs (86-104)` — `CLIAgentSession` struct with `plugin_version`, `listener`
- `terminal/view.rs:10732-10771` — `register_cli_agent_listener()` uses `MINIMUM_PLUGIN_VERSION` from `claude.rs`
- `settings/ai.rs:1093-1105` — `plugin_chip_dismissed_for_version` setting
## 3. Current State
The plugin manager system works well for Claude Code:
- The trait has six required methods, all implemented by `ClaudeCodePluginManager`.
- The footer routes between auto-install and manual-modal based on `should_use_manual_mode()`.
- The modal is already generic (takes `&'static PluginInstructions`, not a `CLIAgent`).
Three things need generalizing:
1. **`compare_versions` and `MINIMUM_PLUGIN_VERSION`** live in `claude.rs` but are imported by `agent_input_footer/mod.rs:65` and `terminal/view.rs:134`. With two agents needing different minimum versions, these should come from the trait.
2. **All trait methods are required.** OpenCode doesn't need `install()`, `update()`, `is_installed()`, or `needs_update()`. These should have sensible defaults.
3. **Install chip flicker.** Claude avoids flicker by checking `is_installed()` on the filesystem. OpenCode has no filesystem checks, so the install chip would flash on session start before the plugin connects. A debounce is needed.
## 4. Proposed Changes
### 4a. Trait Refactor (`plugin_manager/mod.rs`)
**Move `compare_versions` from `claude.rs` to `mod.rs`.** It's a generic semver utility, not Claude-specific. Same signature, just a new home.
**Add two new required methods to the trait:**
```rust
fn minimum_plugin_version(&self) -> &'static str;
fn can_auto_install(&self) -> bool;
```
`minimum_plugin_version()` replaces the hardcoded `MINIMUM_PLUGIN_VERSION` constant at all call sites. Each agent returns its own constant.
`can_auto_install()` tells the footer whether this agent can auto-install/update. Claude returns `true`, OpenCode returns `false`.
**Add default implementations for the four auto-operation methods:**
```rust
fn is_installed(&self) -> bool { false }
fn needs_update(&self) -> bool { false }
async fn install(&self) -> Result<(), PluginInstallError> {
Err(PluginInstallError {
message: "Auto-install not supported for this agent".to_owned(),
log: String::new(),
})
}
async fn update(&self) -> Result<(), PluginInstallError> {
Err(PluginInstallError {
message: "Auto-update not supported for this agent".to_owned(),
log: String::new(),
})
}
```
Claude overrides all four. OpenCode uses the defaults. The defaults return `Err` — the caller (`handle_plugin_operation`) already logs errors from failed operations. These should never be reached since `should_use_manual_mode()` returns `true` for agents where `can_auto_install()` is `false`.
### 4b. OpenCode Plugin Manager (`plugin_manager/opencode.rs`)
New file. Minimal implementation — just the two required methods plus instructions:
- `can_auto_install()` → `false`
- `minimum_plugin_version()` → `"0.1.0"` (keep in sync with `opencode-warp` npm package version)
- `install_instructions()` → static `PluginInstructions` with title "Install Warp Plugin for OpenCode", steps to add `"opencode-warp"` to the `plugin` array in `opencode.json` + restart
- `update_instructions()` → static `PluginInstructions` with title "Update Warp Plugin for OpenCode", steps to `rm -rf ~/.cache/opencode/node_modules/opencode-warp` + restart
All auto-operation methods (`is_installed`, `needs_update`, `install`, `update`) use the trait defaults.
### 4c. Factory Registration (`plugin_manager/mod.rs`)
```rust
CLIAgent::OpenCode => Some(Box::new(opencode::OpenCodePluginManager)),
```
### 4d. Footer: Generalize `MINIMUM_PLUGIN_VERSION` (`agent_input_footer/mod.rs`)
Replace the import of `claude::{compare_versions, MINIMUM_PLUGIN_VERSION}` with:
- `use super::compare_versions` (from `mod.rs`)
- All references to `MINIMUM_PLUGIN_VERSION` become `manager.minimum_plugin_version()`
This affects three places in `plugin_chip_kind()`:
- `agent_input_footer/mod.rs:704` — version comparison for connected plugin
- `agent_input_footer/mod.rs:712` — dismissed version comparison
- `agent_input_footer/mod.rs:732` — dismissed version comparison (filesystem fallback path)
And one place in the dismiss handler:
- `agent_input_footer/mod.rs:1583` — storing the dismissed version
For the dismiss handler, the manager needs to be resolved to get its `minimum_plugin_version()`. Since `plugin_chip_kind()` already resolves the manager, and the dismiss handler knows whether it's dismissing an update chip, this is straightforward — resolve the manager from the session's agent.
### 4e. Footer: `should_use_manual_mode()` (`agent_input_footer/mod.rs:748`)
Add a check for `can_auto_install()` before the existing conditions:
```rust
fn should_use_manual_mode(&self, app: &AppContext) -> bool {
let sessions_model = CLIAgentSessionsModel::as_ref(app);
let session = match sessions_model.session(self.terminal_view_id) {
Some(s) => s,
None => return false,
};
if let Some(manager) = plugin_manager_for(session.agent) {
if !manager.can_auto_install() {
return true;
}
}
if session.is_remote() {
return true;
}
sessions_model.has_plugin_auto_failed(session.agent, &session.remote_host)
}
```
For OpenCode, this always returns `true`, so the footer always opens the modal instead of attempting auto-install. The `handle_plugin_operation` / `handle_install_plugin` / `handle_update_plugin` code paths are never reached.
### 4e-ii. Footer: Chip Button Labels
The install chip buttons are created at construction time with hardcoded labels:
- `install_plugin_button`: "Enable Claude Code notifications" — Claude-specific
- `plugin_instructions_button`: "Notifications setup instructions"
- `update_plugin_button`: "Update Warp plugin"
- `update_instructions_button`: "Plugin update instructions"
The auto-install button label hardcodes "Claude Code". Make it dynamic using the agent's `display_name()`: format as `"Enable {name} notifications"` where `name` comes from `session.agent.display_name()` (e.g., "Enable Claude Code notifications", "Enable OpenCode notifications"). Since the button is created once at construction, update the label via `set_label` in the render path (or when the session changes), the same way the compose button label is already updated dynamically (`agent_input_footer/mod.rs:374`).
### 4f. `terminal/view.rs`: Generalize `register_cli_agent_listener`
`terminal/view.rs:10753` hardcodes `MINIMUM_PLUGIN_VERSION` from `claude.rs`. Replace with:
```rust
let plugin_version = plugin_manager_for(agent)
.map(|m| m.minimum_plugin_version().to_owned());
```
This returns the correct minimum version for whichever agent is active, and `None` if the agent has no plugin manager (which preserves the existing wasm fallback behavior).
### 4g. Install Chip Debounce (`agent_input_footer/mod.rs`)
**Problem:** For agents without filesystem checks (OpenCode), `plugin_chip_kind()` would show the install chip immediately on session start, before the plugin has time to connect and send `SessionStart`.
**Solution:** Add `plugin_chip_ready: bool` to `AgentInputFooter`. It starts `false` and is set to `true` after a debounce timer fires. When a `Started` event fires for a non-auto-install agent, spawn a one-shot timer via `ctx.spawn(Timer::after(PLUGIN_CHIP_DEBOUNCE), ...)`. When the timer fires, set `plugin_chip_ready = true` and call `ctx.notify()` to trigger a re-render. In the timer callback, check whether a listener has connected in the meantime — if so, skip setting the flag.
In `plugin_chip_kind()`, the "no listener" branch checks:
```rust
if !manager.can_auto_install() && !self.plugin_chip_ready {
return None;
}
```
Reset `plugin_chip_ready = false` when a session ends or when a listener connects.
For Claude (which has `can_auto_install() == true`), this check is skipped — Claude uses `is_installed()` instead.
`PLUGIN_CHIP_DEBOUNCE` is a constant in `agent_input_footer/mod.rs` (`Duration::from_secs(3)`).
### 4h. Claude Module Cleanup (`plugin_manager/claude.rs`)
- Remove `compare_versions` (moved to `mod.rs`)
- Add `minimum_plugin_version()` returning `MINIMUM_PLUGIN_VERSION`
- Add `can_auto_install()` returning `true`
- Keep `MINIMUM_PLUGIN_VERSION` as a module-level constant (still useful for the `needs_update()` and `update()` implementations within this module)
## 5. End-to-End Flow
### Install
1. User starts OpenCode in Warp. Command detection creates a `CLIAgentSession`. The footer's `Started` subscription fires, spawning a debounce timer (`plugin_chip_ready` starts `false`).
2. Footer renders. `plugin_chip_kind()` finds `plugin_manager_for(OpenCode)` = `Some`. No listener. `can_auto_install()` is `false`. `plugin_chip_ready` is `false` → returns `None`. No chip.
3. If plugin is installed: `SessionStart` arrives within ~1s, listener is created, `plugin_version` is set, `plugin_chip_ready` reset to `false`. Footer re-renders. Chip never appears.
4. If plugin is not installed: timer fires after 3s, sets `plugin_chip_ready = true`, calls `ctx.notify()`. `plugin_chip_kind()` returns `Install`. `should_use_manual_mode()` returns `true`. Footer shows the instructions chip.
5. User clicks → modal opens with install steps (add to `opencode.json`, restart).
6. User follows steps, restarts OpenCode. Plugin connects, sends `SessionStart`. Chip disappears.
### Update
1. We bump `MINIMUM_PLUGIN_VERSION` in `opencode.rs` to `"0.2.0"`.
2. Plugin connects with `plugin_version: "0.1.0"`.
3. `plugin_chip_kind()`: listener present, `compare_versions("0.1.0", "0.2.0")` is `Less` → `Update`.
4. `should_use_manual_mode()` returns `true`. Footer shows update instructions chip.
5. User clicks → modal shows cache-clear + restart steps.
6. User follows steps. On restart, Bun re-resolves `opencode-warp` from npm (cache was cleared), installs latest. Plugin connects with `"0.2.0"`. Chip disappears.
## 6. Risks and Mitigations
**Risk: `PluginInstructionStep.command` contains JSON, not a shell command.**
**Risk: `PluginInstructionStep.command` contains JSON, not a shell command.** The install modal's copy button copies the `command` field to clipboard. For OpenCode install, this will be a JSON snippet. **Mitigation:** Already fine — the modal copies any string. A JSON snippet is useful to copy even if it's not a terminal command.
**Risk: Stale `MINIMUM_PLUGIN_VERSION`.** The minimum version is compiled into the Warp binary. **Mitigation:** Same as Claude — by design. We only prompt updates when Warp needs new plugin behavior.
## 7. Testing and Validation
### Unit tests (`plugin_manager/opencode_tests.rs`)
- `opencode_manager_can_auto_install_is_false`
- `opencode_manager_returns_install_instructions` — non-empty steps
- `opencode_manager_returns_update_instructions` — non-empty steps
- `opencode_manager_minimum_version` — returns expected value
### Unit tests (`plugin_manager/mod_tests.rs`)
- Update `returns_none_for_unsupported_agents` — remove `CLIAgent::OpenCode` from assertion
- Add `returns_manager_for_opencode`
- Move `compare_versions` tests here from `claude_tests.rs`
### Unit tests (`plugin_manager/claude_tests.rs`)
- Remove `compare_versions` tests (moved)
- Add `claude_manager_can_auto_install_is_true`
- Add `claude_manager_minimum_version`
### Manual testing
- Start an OpenCode session → install chip appears after ~3s debounce (not immediately)
- If plugin is installed: chip never appears (plugin connects before debounce)
- Click install chip → modal opens with correct OpenCode-specific instructions
- Follow install steps, restart → chip disappears
- Bump minimum version locally → update chip appears
- Click update chip → modal shows cache-clear instructions
- Dismiss install chip → stays hidden
- Dismiss update chip → stays hidden; bump minimum → reappears
- Claude flow unchanged — verify no regressions
## 8. Follow-Ups
- **Auto-install:** If OpenCode adds a plugin management CLI, implement `install()` and `update()` on `OpenCodePluginManager` and flip `can_auto_install()` to `true`.
- **Publish `opencode-warp` to npm:** Must happen before this feature ships.
## 9. Files Changed
- **New:** `plugin_manager/opencode.rs` — `OpenCodePluginManager`, `MINIMUM_PLUGIN_VERSION`, install/update instructions
- **New:** `plugin_manager/opencode_tests.rs`
- **Modified:** `plugin_manager/mod.rs` — `compare_versions` moved here, `minimum_plugin_version()` + `can_auto_install()` added to trait, default impls for `is_installed`/`needs_update`/`install`/`update`, factory wires OpenCode
- **Modified:** `plugin_manager/claude.rs` — remove `compare_versions` (moved), add `minimum_plugin_version()` + `can_auto_install()` overrides
- **Modified:** `plugin_manager/claude_tests.rs` — remove `compare_versions` tests (moved), add new trait method tests
- **Modified:** `plugin_manager/mod_tests.rs` — add OpenCode factory test, receive `compare_versions` tests
- **Modified:** `agent_input_footer/mod.rs` — add `plugin_chip_ready: bool` + debounce timer, update imports (`compare_versions` from `mod.rs`), `plugin_chip_kind()` uses `manager.minimum_plugin_version()` + `plugin_chip_ready` guard, `should_use_manual_mode()` checks `can_auto_install()`, dismiss handler resolves minimum version from manager, rename auto-install chip label to be agent-generic
- **Modified:** `terminal/view.rs` — `register_cli_agent_listener()` uses `plugin_manager_for(agent)?.minimum_plugin_version()` instead of Claude's constant