Files
galaxy/specs/APP-4060/TECH.md
T

14 KiB

APP-4060: Tech Spec

Problem

Custom toolbar command patterns always produce CLIAgent::Unknown sessions. The detection path (detect_cli_agent_from_modelcommand_matches_cli_agent_footer_settingsCompiledCommandsForCodingAgentToolbar::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:

#[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:

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:

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_settingsdetect_cli_agent_from_toolbar_settings and change its return type:

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):

// 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:

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:

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>():

// 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:

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

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.