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
+114
View File
@@ -0,0 +1,114 @@
# APP-4060: Allow selection of specific CLI agent for custom toolbar commands
## Summary
Allow users to specify which CLI agent (Claude Code, Gemini, Codex, etc.) a custom toolbar command maps to. Today, user-configured regex patterns in "Commands that enable the toolbar" always produce a generic `Unknown` agent session, which means they lack agent-specific features like the correct icon, rich input submit strategy, plugin support, and brand styling. This change adds a per-pattern agent selector dropdown to the settings UI so that custom commands can receive the full agent experience.
## Problem
Users at companies like Uber wrap CLI agents behind custom scripts (e.g. `aifx agent run claude` or a custom alias that doesn't start with `claude`). The existing command detection logic doesn't recognise these commands as a specific CLI agent, so:
1. The toolbar shows a generic appearance instead of the agent's icon and branding.
2. The session is tagged as `CLIAgent::Unknown`, which means:
- No plugin listener is created (no rich status, no auto-show/hide of Rich Input).
- Rich input submit strategy defaults to `Inline` instead of the agent-appropriate strategy.
- Agent-specific skill providers are not available in the slash menu.
- Notifications lack the agent's display name and icon.
Users can already add regex patterns to enable the toolbar, but there is no way to tell Warp which agent a pattern represents.
## Goals
1. Let users assign a specific CLI agent to each custom toolbar command pattern.
2. When a pattern matches a command, the resulting session should behave identically to a natively-detected agent session (icons, branding, plugin listener eligibility, rich input strategy, skills, notifications).
3. Preserve the existing `aifx agent run claude` → Claude detection for Uber team members (no change needed — already works).
## Non-goals
- Changing the auto-detection logic for commands that already resolve to a known agent.
- Supporting multiple agent assignments per pattern (one pattern = one agent).
- Adding new CLI agent types.
- Changing the plugin install/update flow for custom commands (the existing install chip logic already handles per-agent plugin checks).
## Figma / design references
Figma: none provided.
## User experience
### Settings UI: "Commands that enable the toolbar"
Each item in the command list gains a dropdown to its right. The dropdown shows all known CLI agents plus a default "Any CLI Agent" option.
#### Dropdown items
Each item in the dropdown displays:
- The agent's icon (from `CLIAgent::icon()`) at the standard menu item icon size.
- The agent's display name (from `CLIAgent::display_name()`).
- "CLI Agent" appears first, with no icon. This is the default selection for new patterns.
Agents with `CLIAgent::Unknown` are not shown as a selectable option (it's represented by "CLI Agent").
#### Layout of each command list row
```
[command regex text] [Agent Dropdown ▼] [× remove]
```
The command text is left-aligned and shrinkable. The dropdown is fixed-width, grouped with the command text on the left side. The remove button is right-aligned. The dropdown uses the standard `Dropdown` component. When collapsed, the top bar shows the selected agent's name.
#### Adding a new command
When a user submits a new command via the text input, it is added with the default "CLI Agent" selection. The user can then change the agent via the dropdown.
#### Removing a command
When a command is removed, its agent mapping is also cleaned up from the backing setting.
#### Changing the agent
Selecting an agent from the dropdown immediately persists the choice. The setting syncs to cloud like other AI settings.
### Session behavior when a custom pattern matches
When a user-configured regex matches a running command AND the pattern has a specific agent assigned:
1. `detect_cli_agent_from_model` returns the assigned `CLIAgent` variant (e.g. `CLIAgent::Claude`) instead of `CLIAgent::Unknown`.
2. The session is created with that agent, giving it:
- The correct icon, display name, and brand color in the toolbar.
- The correct rich input submit strategy (`BracketedPaste` for Codex, `DelayedEnter` for Gemini/OpenCode/Copilot/Auggie, `Inline` for Claude/Amp/Droid).
- Eligibility for plugin listener registration (if `is_agent_supported` returns true for that agent).
- The correct skill providers in the rich input slash menu.
- The correct plugin install/update chip (if a plugin manager exists for that agent).
3. If the pattern has no agent assigned ("CLI Agent"), behavior is unchanged — the session uses `CLIAgent::Unknown` as today.
### `aifx agent run claude`
`aifx agent run claude` is already detected as `CLIAgent::Claude` for Uber team members via the `is_aifx_agent_run_claude` special case. No changes to this behavior — the Uber-team gate stays in place. Non-Uber users who want the same behavior can add a custom pattern and assign it to Claude.
## Success criteria
1. **Dropdown renders for each command**: Every item in the "Commands that enable the toolbar" list shows a dropdown between the command text and the remove button.
2. **Dropdown lists all agents with icons**: The dropdown contains "Any CLI Agent" (no icon) followed by each `CLIAgent` variant (except `Unknown`) with the correct icon and display name.
3. **Default is "CLI Agent"**: Newly added commands default to "CLI Agent".
4. **Selection persists**: Changing the dropdown updates the backing setting and survives app restart.
5. **Selection syncs to cloud**: The agent mapping setting syncs via the same mechanism as other AI settings.
6. **Correct agent in session**: When a custom pattern with an assigned agent matches, `CLIAgentSessionsModel` receives the specified `CLIAgent`, not `Unknown`.
7. **Full agent experience**: A custom pattern assigned to e.g. Claude shows the Claude icon in the toolbar, uses the Claude rich input submit strategy, shows Claude-specific skills in the slash menu, and is eligible for the Claude plugin listener.
8. **Plugin listener created**: If a pattern is assigned to an agent that supports plugin listeners (Claude, Codex, OpenCode), the plugin lifecycle (install chip, update chip, listener registration) works as if the command was natively detected.
9. **Removal cleans up**: Removing a command from the list also removes its agent mapping from the backing store.
10. **aifx unchanged**: `aifx agent run claude` continues to be detected as `CLIAgent::Claude` for Uber team members.
11. **Backward compatible**: Existing custom commands (with no agent assigned) continue to work as `CLIAgent::Unknown`.
## Validation
1. **Manual testing**: Add a custom command pattern (e.g. `my-claude-wrapper`), assign it to Claude, run `my-claude-wrapper` in the terminal, and verify:
- The toolbar shows the Claude icon and branding.
- Rich input uses Claude's submit strategy.
- The plugin install chip appears (if the Claude plugin isn't installed).
- Notifications show "Claude Code" as the agent name.
2. **aifx test**: Verify that `aifx agent run claude` continues to be detected as Claude for Uber team members (no regression).
3. **Default behavior**: Add a command pattern with "CLI Agent" and verify it shows the generic toolbar (same as today).
4. **Persistence**: Change an agent selection, restart Warp, and verify the selection is preserved.
5. **Removal**: Remove a command and verify its agent mapping is gone (re-add the same command and confirm it defaults to "CLI Agent").
+283
View File
@@ -0,0 +1,283 @@
# APP-4060: Tech Spec
## Problem
Custom toolbar command patterns always produce `CLIAgent::Unknown` sessions. The detection path (`detect_cli_agent_from_model` → `command_matches_cli_agent_footer_settings` → `CompiledCommandsForCodingAgentToolbar::matches_command`) returns a `bool` with no way to carry an agent assignment. This means plugin listeners, rich input strategy, skills, and branding are all lost for custom commands.
## Relevant code
- `app/src/settings/ai.rs (1168-1178)` — `CLIAgentToolbarEnabledCommands` setting (`Vec<String>`)
- `app/src/settings/ai.rs (1776-1822)` — `CompiledCommandsForCodingAgentToolbar` singleton (compiled regexes, `matches_command`)
- `app/src/settings/ai.rs (1698-1734)` — `add_cli_agent_footer_enabled_command` / `remove_cli_agent_footer_enabled_command`
- `app/src/terminal/view/use_agent_footer/mod.rs (328-362)` — `detect_cli_agent_from_model` and `command_matches_cli_agent_footer_settings`
- `app/src/terminal/view.rs (10027-10073)` — long-running command timer that creates `CLIAgentSession` and optionally registers plugin listener
- `app/src/terminal/cli_agent.rs (272-305)` — `CLIAgent::detect` (alias resolution, `aifx` special case)
- `app/src/terminal/cli_agent_sessions/mod.rs (104-127)` — `CLIAgentSession` struct (stores `agent: CLIAgent`)
- `app/src/terminal/cli_agent_sessions/listener/mod.rs (41-61)` — `is_agent_supported` / `create_handler` (determines plugin listener eligibility)
- `app/src/terminal/cli_agent_sessions/plugin_manager/mod.rs (146-189)` — `plugin_manager_for` (install/update chip eligibility)
- `app/src/settings_view/ai_page.rs (5086-5093)` — `CLIAgentWidget` struct
- `app/src/settings_view/ai_page.rs (5232-5270)` — command list rendering via `render_input_list`
- `app/src/settings_view/ai_page.rs (360-413)` — `AISettingsPageView` struct fields
- `app/src/settings_view/ai_page.rs (1870-1936)` — `AISettingsPageAction` enum
- `app/src/settings_view/ai_page.rs (2310-2314)` — `RemoveCLIAgentToolbarEnabledCommand` handler
- `app/src/settings_view/settings_page.rs (1000-1053)` — `InputListItem` / `render_input_list`
- `app/src/view_components/dropdown.rs (66-97)` — `Dropdown` / `DropdownItem`
- `app/src/menu.rs (389-714)` — `MenuItemFields` (supports `with_icon`)
## Current state
### Detection flow
When a command becomes long-running (after `LONG_RUNNING_COMMAND_DURATION_MS`), the timer in `view.rs:10019` fires:
1. `detect_cli_agent_from_model` (`use_agent_footer/mod.rs:328`) is called.
2. It first tries `CLIAgent::detect` which checks the resolved command against known `command_prefix()` values and the `aifx agent run claude` special case.
3. If `CLIAgent::detect` returns `None`, it falls back to `command_matches_cli_agent_footer_settings` which calls `CompiledCommandsForCodingAgentToolbar::matches_command` — a `bool`-returning method.
4. If the fallback matches, `CLIAgent::Unknown` is returned unconditionally (`mod.rs:357`).
### Session creation
The detected `CLIAgent` is passed to `CLIAgentSessionsModel::set_session` (`view.rs:10042`), which stores it on `CLIAgentSession.agent`. Every downstream consumer reads the agent from this session:
- Toolbar rendering uses `session.agent` for icon/branding.
- `rich_input_submit_strategy(session.agent)` selects the PTY write strategy.
- `session.agent.supported_skill_providers()` filters the slash menu.
- `is_agent_supported(&agent)` / `plugin_manager_for(agent)` gate the plugin listener and install chip.
### Plugin listener lifecycle
For natively-detected agents, two paths can create a listener:
1. **OSC 777 sentinel**: When the plugin sends `warp://cli-agent` with a `SessionStart` event, `view.rs:10960` calls `register_listener`. This only fires if the agent plugin is installed.
2. **Proactive registration**: For Codex specifically, `register_cli_agent_listener` is called immediately on detection (`view.rs:10069`) because Codex uses OSC 9 plain-text notifications.
Both paths check `is_agent_supported(&agent)`, which currently returns `true` only for `Claude | OpenCode | Codex`. For `CLIAgent::Unknown`, no listener is ever created.
With this change, when a custom pattern maps to e.g. `CLIAgent::Claude`, the session stores `CLIAgent::Claude`, making the entire plugin lifecycle work automatically — the OSC 777 sentinel path will register a listener when the plugin sends events, and `plugin_manager_for(CLIAgent::Claude)` will return the Claude plugin manager for the install/update chip.
### `aifx agent run claude`
`CLIAgent::detect` (`cli_agent.rs:298-304`) already detects `aifx agent run claude` as `CLIAgent::Claude` for Uber team members via the `is_aifx_agent_run_claude` helper. No changes needed — this already correctly categorises the command as Claude, and the Uber-team gate stays in place.
## Proposed changes
### 1. Migrate setting type from `Vec<String>` to `HashMap<String, String>`
Replace the existing `CLIAgentToolbarEnabledCommands` setting with a new type that carries both pattern and agent in one structure. Since users never downgrade, we only need forward migration.
Change the setting type from `Vec<String>` to `HashMap<String, String>`, keeping the same `toml_path` (`agents.third_party.cli_agent_toolbar_enabled_commands`). Keys are regex patterns, values are serialized `CLIAgent` names (e.g. `"Claude"`, `"Gemini"`). An empty string value means "Any CLI Agent" (`CLIAgent::Unknown`).
**Migration via custom `Deserialize`**: Implement `Deserialize` for the new setting's value type that handles both formats:
- Old format `["pattern1", "pattern2"]` → `{"pattern1": "", "pattern2": ""}`
- New format `{"pattern1": "Claude", "pattern2": ""}` → used directly
This is achieved with a `#[serde(untagged)]` helper enum:
```rust
#[derive(Deserialize)]
#[serde(untagged)]
enum CommandMapOrVec {
Map(HashMap<String, String>),
Vec(Vec<String>),
}
```
Then a `Deserialize` impl for the wrapper type that tries `Map` first, falls back to `Vec`, converting each string to a key with empty value.
**File**: `app/src/settings/ai.rs`
Update helper methods:
- `add_cli_agent_footer_enabled_command(&mut self, command, ctx)`: Inserts key with empty-string value.
- `remove_cli_agent_footer_enabled_command(&mut self, command, ctx)`: Removes the key (also removes agent mapping).
- `set_cli_agent_for_command(&mut self, pattern, agent: Option<CLIAgent>, ctx)`: Updates the value for an existing key. `None` sets empty string, `Some(agent)` sets `agent.to_serialized_name()`.
### 2. Update `CompiledCommandsForCodingAgentToolbar`
Replace the `Vec<Regex>` with `Vec<(Regex, CLIAgent)>` compiled from the HashMap:
```rust
struct CompiledCommandsForCodingAgentToolbar {
regexes: Vec<(Regex, CLIAgent)>,
}
```
Each entry is built by iterating the HashMap, compiling the key as a regex, and resolving the value to a `CLIAgent` via `CLIAgent::from_serialized_name` (empty string → `CLIAgent::Unknown`).
Delete `matches_command` (the only caller is `command_matches_cli_agent_footer_settings`) and replace with:
```rust
pub fn matched_agent(app: &AppContext, command: &str) -> Option<CLIAgent> {
Self::as_ref(app)
.regexes
.iter()
.find(|(regex, _)| regex.is_match(command))
.map(|(_, agent)| *agent)
}
```
Subscribe to the setting's change event to rebuild compiled regexes (same as today, but now the single HashMap setting emits one event for any change).
**File**: `app/src/settings/ai.rs`
### 3. Update detection to propagate the matched agent
In `app/src/terminal/view/use_agent_footer/mod.rs`:
Rename `command_matches_cli_agent_footer_settings` → `detect_cli_agent_from_toolbar_settings` and change its return type:
```rust
fn detect_cli_agent_from_toolbar_settings(command: &str, app: &AppContext) -> Option<CLIAgent> {
CompiledCommandsForCodingAgentToolbar::matched_agent(app, command)
}
```
Update `detect_cli_agent_from_model` (`mod.rs:353-357`):
```rust
// Before:
Self::command_matches_cli_agent_footer_settings(&command, ctx).then_some(CLIAgent::Unknown)
// After:
Self::detect_cli_agent_from_toolbar_settings(&command, ctx)
```
### 4. Plugin listener registration for custom agent patterns
No changes needed. The session is created with whatever `CLIAgent` is returned by `detect_cli_agent_from_model`. Once that returns a concrete agent (e.g. `CLIAgent::Claude`), the existing code automatically:
- Stores it on `CLIAgentSession.agent`.
- The OSC 777 sentinel path (`view.rs:10960`) registers a listener when the plugin sends events, because `is_agent_supported(&CLIAgent::Claude)` returns `true`.
- `plugin_manager_for(CLIAgent::Claude)` returns the Claude plugin manager, so the install/update chip appears.
- Codex proactive registration (`view.rs:10069`) already checks `cli_agent == Some(CLIAgent::Codex)`, which works for custom patterns assigned to Codex.
### 5. Settings UI: per-command agent dropdown
#### New action
Add to `AISettingsPageAction`:
```rust
SetCLIAgentForCommand { pattern: String, agent: Option<CLIAgent> }
```
`None` means "CLI Agent" (set empty string in HashMap). `Some(agent)` sets the serialized name.
#### New view state
Add to `AISettingsPageView`:
```rust
cli_agent_footer_command_dropdowns: Vec<ViewHandle<Dropdown<AISettingsPageAction>>>,
```
Created in `new()` and rebuilt whenever the setting changes (in the existing subscription handler that already rebuilds `cli_agent_footer_command_mouse_state_handles`).
Each dropdown uses the standard `Dropdown` component, configured with:
- `set_top_bar_max_width(160.)` for a compact appearance.
- `set_menu_width(180.)` to fit icon + name.
- `set_main_axis_size(MainAxisSize::Min)` to wrap to content width.
Menu items are built from `enum_iterator::all::<CLIAgent>()`:
```rust
// First item: "CLI Agent" (no icon)
MenuItemFields::new("CLI Agent")
.with_on_select_action(DropdownAction::SelectActionAndClose(
AISettingsPageAction::SetCLIAgentForCommand { pattern, agent: None }
))
.into_item(),
// Then each known agent (skip Unknown):
MenuItemFields::new(agent.display_name())
.with_icon(agent.icon().unwrap())
.with_on_select_action(DropdownAction::SelectActionAndClose(
AISettingsPageAction::SetCLIAgentForCommand { pattern, agent: Some(agent) }
))
.into_item(),
```
#### Rendering
Replace the `render_input_list` call in `CLIAgentWidget::render` with custom rendering that interleaves dropdowns. Each row becomes:
```
Flex::row()
[Shrinkable: command text (monospace)]
[ChildView: dropdown]
[close button]
```
The dropdown's initial selection is set based on the current HashMap value for that pattern.
#### Action handler
In `handle_action` for `SetCLIAgentForCommand`:
```rust
AISettingsPageAction::SetCLIAgentForCommand { pattern, agent } => {
AISettings::handle(ctx).update(ctx, |settings, ctx| {
settings.set_cli_agent_for_command(&pattern, agent, ctx);
});
}
```
**File**: `app/src/settings_view/ai_page.rs`
## End-to-end flow
```mermaid
sequenceDiagram
participant User
participant Settings as Settings UI
participant AISettings
participant Terminal as Terminal View
participant Detector as detect_cli_agent_from_model
participant Compiled as CompiledCommandsForCodingAgentToolbar
participant Sessions as CLIAgentSessionsModel
participant Listener as CLIAgentSessionListener
User->>Settings: Add pattern "uber-cli", select "Claude Code"
Settings->>AISettings: add_cli_agent_footer_enabled_command("uber-cli")
Settings->>AISettings: set_cli_agent_for_command("uber-cli", Some(Claude))
AISettings->>Compiled: Rebuild regexes with agent mappings
User->>Terminal: Run "uber-cli" command
Note over Terminal: Command becomes long-running after timeout
Terminal->>Detector: detect_cli_agent_from_model()
Detector->>Detector: CLIAgent::detect() → None (not a known prefix)
Detector->>Compiled: matched_agent("uber-cli")
Compiled-->>Detector: Some(CLIAgent::Claude)
Detector-->>Terminal: Some(CLIAgent::Claude)
Terminal->>Sessions: set_session(CLIAgentSession { agent: Claude, ... })
Note over Sessions: Session now has Claude branding, strategy, skills
Note over Terminal: If Claude plugin is installed, it sends OSC 777 SessionStart
Terminal->>Listener: CLIAgentSessionListener::new(Claude)
Listener->>Sessions: register_listener(Claude, ...)
Note over Sessions: Full plugin-backed session with rich status
```
## Risks and mitigations
1. **Setting migration**: Custom `Deserialize` must handle both old `Vec<String>` and new `HashMap<String, String>` formats. A malformed TOML value that is neither an array of strings nor a map would fall back to the default (empty HashMap). This matches the existing behavior for corrupt settings.
2. **HashMap iteration order**: `HashMap` does not guarantee iteration order, so the display order of commands in the settings UI may differ from insertion order. This is acceptable — the items are displayed in a flat list with no meaningful ordering. If deterministic order is needed later, we can switch to `BTreeMap`.
3. **Orphaned entries impossible**: Because pattern and agent live in the same HashMap entry, removing a pattern always removes its agent assignment. No cleanup logic needed.
## Testing and validation
1. **Unit tests for `matched_agent`**: Test that a pattern with an agent mapping returns the correct `CLIAgent`, and that a pattern with an empty value returns `CLIAgent::Unknown`.
2. **Deserialization migration test**: Verify that the old `["pattern1", "pattern2"]` JSON format deserializes correctly into the new HashMap with empty-string values.
3. **Integration / manual test**: Add a custom pattern, assign to Claude, run a matching command, verify:
- Toolbar shows Claude icon.
- Plugin install chip appears.
- Rich input uses Claude's submit strategy.
4. **Persistence test**: Restart Warp and verify the dropdown reflects the saved selection.
5. **Backward compatibility test**: Existing patterns (migrated with empty agent value) continue to produce `CLIAgent::Unknown` sessions.