# 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`) - `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` to `HashMap` 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` to `HashMap`, 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), Vec(Vec), } ``` 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, 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` 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 { 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 { 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 } ``` `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>>, ``` 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::()`: ```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` and new `HashMap` 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.