Warp 自定义工具栏命令与 CLI Agent 关联:从 `CLIAgent::Unknown` 到完整插件生命周期的设计实践

原创2026-10-03 18:12:011,853 阅读
文章标签:人工智能AI Agent代码智能体桌面应用

Warp 自定义工具栏命令与 CLI Agent 关联:从 CLIAgent::Unknown 到完整插件生命周期的设计实践

导读

本文以 specs/APP-4060/TECH.md 为核心,深入讲解 Warp(Warp is an agentic development environment, born out of the terminal)如何解决"自定义工具栏命令总是被识别为 CLIAgent::Unknown"这一工程问题:通过将设置项从 Vec<String> 迁移为 HashMap<String, String>(键为命令正则、值为序列化的 CLI Agent 名称),让用户配置的任意命令模式都能映射到具体 Agent,从而完整获得插件监听器、Rich Input 提交策略、技能(skills)与品牌图标。读完本文,你将掌握这一方案的数据模型迁移、正则编译缓存、检测链改造与设置界面落地的完整技术细节,并能依据当前仓库源码逐行验证实现。

背景与问题:自定义命令为何总是 Unknown

Warp 的终端会为第三方 CLI 编码 Agent(如 Claude、Gemini、Codex)渲染专门的"使用 Agent 页脚"(use agent footer)。原生检测路径 detect_cli_agent_from_model 依序执行两次判定:

  1. 先调用 CLIAgent::detect,将命令解析为 shell 中已展开的实际命令(含别名解析、转义字符处理),与每个 Agent 的 command_prefix() 前缀比对;
  2. 若未命中任何已知前缀,则回退到用户配置的工具栏命令模式,调用 CompiledCommandsForCodingAgentToolbar::matches_command 做正则匹配。

问题恰恰出在第二步:旧实现中 matches_command 只返回 bool,无法携带"该模式应归属哪个 Agent"的信息。于是 use_agent_footer/mod.rs 中的回退分支只能硬编码返回 CLIAgent::Unknown(旧代码 command_matches_cli_agent_footer_settings(...).then_some(CLIAgent::Unknown))。

Unknown 带来的连锁后果是系统性的,见 use_agent_footer/mod.rs 中所有下游消费者:

  • 工具栏渲染使用 session.agent 决定图标与品牌标识;
  • rich_input_submit_strategy(session.agent) 选择写入 PTY 的策略;
  • session.agent.supported_skill_providers() 过滤斜杠菜单中的技能;
  • is_agent_supported(&agent) 决定能否创建插件监听器;
  • plugin_manager_for(agent) 决定是否出现插件安装/更新 chip。

由于这些能力全部以具体 Agent 身份为前提,CLIAgent::Unknown 意味着自定义命令用户永远拿不到上述任何一项增强。

数据模型迁移:Vec<String> → HashMap<String, String>

设置项定义与 toml 路径

方案的第一步是改造设置类型。app/src/settings/ai.rs 中定义了设置项(配置路径保持不变):

cli_agent_footer_enabled_commands: CLIAgentToolbarEnabledCommands {
    type: ToolbarCommandMap,
    default: ToolbarCommandMap::default(),
    supported_platforms: SupportedPlatforms::ALL,
    sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
    surface: settings::SettingSurfaces::GUI,
    private: false,
    toml_path: "agents.third_party.cli_agent_toolbar_enabled_commands",
    max_table_depth: 1,
    description: "Maps custom toolbar command patterns to specific CLI agents.",
}
  • 键(key):正则表达式模式,用于匹配完整命令字符串;
  • 值(value):序列化的 CLIAgent 名称(如 "Claude"、"Gemini");空字符串表示"任意 CLI Agent"(即 CLIAgent::Unknown)。

对应的 TOML 写法如下:

[agents.third_party]
cli_agent_toolbar_enabled_commands = { "uber-cli" = "Claude", "my-agent" = "", "codex-dev" = "Codex" }

空值条目("my-agent" = "")等价于旧的 ["my-agent"] 行为:匹配到模式但保持 Unknown,是向后兼容的显式表达。

兼容旧格式:#[serde(untagged)] 自定义反序列化

用户从不降级版本,因此只需要前向迁移。核心手段是用 #[serde(untagged)] 辅助枚举同时接受两种 JSON/TOML 形态:

#[derive(Deserialize)]
#[serde(untagged)]
enum CommandMapOrVec {
    Map(HashMap<String, String>),
    Vec(Vec<String>),
}

随后为包装类型实现 Deserialize:先尝试 Map,失败则回退到 Vec,把每个字符串转换为"空值键":

  • 旧格式 ["pattern1", "pattern2"] → {"pattern1": "", "pattern2": ""}
  • 新格式 {"pattern1": "Claude", "pattern2": ""} → 直接使用

若 TOML 值既不是字符串数组也不是 Map(损坏数据),则回退到默认值(空 Map),与设置系统对损坏设置的既有处理保持一致。

当前仓库中该类型已落地为 ToolbarCommandMap(IndexMap<String, String>)(见 app/src/settings/ai.rs),并带有自定义 Deserialize 实现(同文件 L1148)。值得注意的细节是:实现选用 IndexMap 而非 HashMap,天然解决了文档"风险与缓解"一节中关于迭代顺序不确定的问题——设置 UI 中的命令展示顺序与插入顺序保持一致,无需后续再迁移到 BTreeMap。

辅助方法三件套

设置类型配套三个变更方法(均已实现于 app/src/settings/ai.rs):

// 插入新命令模式,值为空字符串(即 Unknown / 任意 CLI Agent)
add_cli_agent_footer_enabled_command(&mut self, command, ctx);

// 移除模式(同时移除该模式对应的 Agent 映射,天然无孤儿条目)
remove_cli_agent_footer_enabled_command(&mut self, command, ctx);

// 为已存在的模式更新 Agent;None 写空串,Some(agent) 写 agent.to_serialized_name()
set_cli_agent_for_command(&mut self, pattern, agent: Option<CLIAgent>, ctx);

其中 set_cli_agent_for_command 对不存在的 pattern 直接返回(if !map.contains_key(pattern) { return; }),避免凭空产生映射。

序列化协议:to_serialized_name / from_serialized_name

CLIAgent 枚举定义在 app/src/terminal/cli_agent.rs,当前包含 19 个变体:Claude、Gemini、Codex、Amp、Droid、OpenCode、Copilot、Pi、OhMyPi、Auggie、CursorCli、Goose、Hermes、Vibe、Antigravity、Grok、Kiro、WarpTui(Warp 自己的无头 TUI),以及 Unknown(由用户配置正则模式匹配到的未知/自定义 Agent)。

序列化通过 serde_json 实现(同文件 L243-L253):

pub fn to_serialized_name(&self) -> String {
    serde_json::to_value(self)
        .ok()
        .and_then(|v| v.as_str().map(str::to_owned))
        .unwrap_or_default()
}

pub fn from_serialized_name(name: &str) -> CLIAgent {
    serde_json::from_value(name.into()).unwrap_or(CLIAgent::Unknown)
}

from_serialized_name 的反向失败兜底恰好是 Unknown,因此空字符串值会自然解析为 CLIAgent::Unknown,与"空值 = 任意 CLI Agent"的语义精确对应。注释还明确了 to_serialized_name 的用途是会话共享协议中不透明的 cli_agent 字符串字段,与用于分析的稳定 telemetry_name() 相互独立。

编译缓存:CompiledCommandsForCodingAgentToolbar

Vec<String> 时代的编译缓存只保存正则数组,方案将其升级为"正则 + Agent"配对数组:

struct CompiledCommandsForCodingAgentToolbar {
    regexes: Vec<(Regex, CLIAgent)>,
}

构建过程(app/src/settings/ai.rs)遍历 Map,将每个 key 编译为正则(Regex::new(pattern).ok()?,编译失败则跳过该条目),再用 CLIAgent::from_serialized_name 把 value 解析为枚举:

fn parse(app: &AppContext) -> Vec<(Regex, CLIAgent)> {
    AISettings::as_ref(app)
        .cli_agent_footer_enabled_commands
        .value()
        .iter()
        .filter_map(|(pattern, agent_name)| {
            let regex = Regex::new(pattern).ok()?;
            let agent = CLIAgent::from_serialized_name(agent_name);
            Some((regex, agent))
        })
        .collect()
}

旧方法 matches_command 只被 command_matches_cli_agent_footer_settings 一处调用,因此被整体删除,替换为返回 Agent 的 matched_agent:

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

匹配语义为按注册顺序取第一个命中的模式(find 短路)。该单例通过 app.add_singleton_model 注册,并订阅 AISettingsChangedEvent::CLIAgentToolbarEnabledCommands 事件——由于现在只有一个 Map 设置项,任何增删改都会触发同一事件并重建全部正则,订阅与重建逻辑与旧版一致但更简洁:

app.subscribe_to_model(&ai_settings, move |_, event, ctx| {
    if matches!(event, AISettingsChangedEvent::CLIAgentToolbarEnabledCommands { .. }) {
        let regexes = Self::parse(ctx);
        handle.update(ctx, |me, _| { me.regexes = regexes; });
    }
});

检测链改造:让 Agent 身份一路传递

检测入口 detect_cli_agent_from_model 位于 app/src/terminal/view/use_agent_footer/mod.rs,当前实现已完整呈现新链路:

  1. 仅当活动块处于"活跃且长时间运行"状态(is_active_and_long_running())才进入检测,长运行阈值由 view.rs 中的 LONG_RUNNING_COMMAND_DURATION_MS 定时器决定;
  2. 先取当前活动块命令(secret 混淆后的 command_with_secrets_obfuscated(false));
  3. 优先调用 CLIAgent::detect,结合会话的 shell 转义字符与别名表:
    • 解析别名:若命令首词命中别名表,用别名值替换首词得到 resolved_command(例如用户把 foo 别名到 claude,运行 foo 即可检出 Claude);
    • 遍历全部 CLIAgent 变体(跳过 Unknown),用 matches_command 比对命令前缀,并叠加 aifx agent run claude 特例;
  4. 原生检测返回 None 时,回退到工具栏设置:CompiledCommandsForCodingAgentToolbar::matched_agent(ctx, &command),返回 Option<CLIAgent>,同时通过 command.split_whitespace().next() 提取自定义命令前缀一并返回(用于 UI 标注该 Agent 来自自定义模式)。

改造后的回退分支从"无条件 Unknown"变为"返回模式上绑定的具体 Agent":

// Before:
Self::command_matches_cli_agent_footer_settings(&command, ctx).then_some(CLIAgent::Unknown)

// After:
Self::detect_cli_agent_from_toolbar_settings(&command, ctx)  // 即 matched_agent

检测出的 Agent 随即被传给 CLIAgentSessionsModel::set_session,存储于 CLIAgentSession.agent(结构体见 cli_agent_sessions/mod.rs),作为会话内唯一事实来源(single source of truth),后续所有消费者都从会话中读取。

aifx agent run claude 特例:无需改动的原生通道

CLIAgent::detect(cli_agent.rs)中已包含 Uber 团队特例:is_aifx_agent_run_claude 检查 resolved 命令是否以 aifx agent run claude 开头,且当前用户属于 Uber 团队(通过 UserWorkspaces 中的 UBER_TEAM_UID 判定)。命中即归类为 CLIAgent::Claude。该路径在本次方案中无需任何改动,团队门控保持不变。

插件监听器生命周期:自定义模式的自动激活

监听器创建有两条既有路径,二者都以会话中存储的 CLIAgent 为判断依据,因此本次改动不需要新增监听器相关代码:

  1. OSC 777 哨兵路径:插件发送 warp://cli-agent 的 SessionStart 事件时(view.rs 中 register_listener 调用点),只要 is_agent_supported(&agent) 返回 true 即注册监听器。is_agent_supported(cli_agent_sessions/listener/mod.rs)目前仅对 Claude | OpenCode | Codex 返回 true。
  2. 主动注册路径:Codex 使用 OSC 9 纯文本通知而非 OSC 777,因此在检出后立即调用 register_cli_agent_listener,前提是 cli_agent == Some(CLIAgent::Codex)。

当自定义模式映射到例如 CLIAgent::Claude 后:会话存 Claude → OSC 777 哨兵到达时 is_agent_supported(Claude) == true → 自动注册监听器;plugin_manager_for(CLIAgent::Claude)(cli_agent_sessions/plugin_manager/mod.rs)返回 Claude 插件管理器,安装/更新 chip 随即出现。对映射到 Codex 的自定义模式,主动注册路径同样生效。整套插件生命周期无需新代码即自动跑通。

设置界面:每命令 Agent 下拉框

方案在设置页为每个命令行新增 Agent 下拉框。从当前仓库源码看,该 UI 已落在 app/src/settings_view/cli_agents_page.rs(spec 原文档指向的 ai_page.rs 路径在仓库中已重构为独立页面文件)。

新 action

SetCLIAgentForCommand { pattern: String, agent: Option<CLIAgent> }

None 表示"CLI Agent"(在 Map 中写空字符串),Some(agent) 写入序列化名称。

新视图状态

在页面视图结构体中维护每行下拉框句柄(cli_agent_footer_command_dropdowns: Vec<ViewHandle<Dropdown<AISettingsPageAction>>>),在 new() 中创建,并在既有的设置变更订阅回调(原本用于重建鼠标事件句柄)中随设置变化一并重建。

每个下拉框使用标准 Dropdown 组件,紧凑配置:

  • set_top_bar_max_width(160.):紧凑顶栏;
  • set_menu_width(180.):容纳图标 + 名称;
  • set_main_axis_size(MainAxisSize::Min):宽度贴合内容。

菜单项由 enum_iterator::all::<CLIAgent>() 枚举生成:

// 第一项:"CLI Agent"(无图标)
MenuItemFields::new("CLI Agent")
    .with_on_select_action(DropdownAction::SelectActionAndClose(
        AISettingsPageAction::SetCLIAgentForCommand { pattern, agent: None }
    ))
    .into_item(),

// 随后每个已知 Agent(跳过 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(),

MenuItemFields 位于 app/src/menu.rs,支持 with_icon 注入图标;display_name()(如 Claude Code)与 icon() 均由 cli_agent.rs 中每个枚举变体提供。

行渲染

CLIAgentWidget 的渲染从整体 render_input_list(见 settings_view/settings_page.rs 的 InputListItem / render_input_list)改为逐行自定义布局,交错插入下拉框:

Flex::row()
  [Shrinkable: 命令文本(等宽字体)]
  [ChildView: 下拉框]
  [关闭按钮]

下拉框初始选中项依据该 pattern 在 Map 中的当前值设定。

action 处理

AISettingsPageAction::SetCLIAgentForCommand { pattern, agent } => {
    AISettings::handle(ctx).update(ctx, |settings, ctx| {
        settings.set_cli_agent_for_command(&pattern, agent, ctx);
    });
}

一次下拉选择即完成"写 Map → 触发 CLIAgentToolbarEnabledCommands 事件 → 重建编译正则"的完整闭环。当前仓库 cli_agents_page.rs 中的 CLIAgentsPageAction::SetCLIAgentForCommand { pattern, agent } 处理器与此设计一一对应。

端到端流程

以下时序图完整呈现"配置 → 运行 → 插件激活"全过程(源自 spec 文档,语义不变):

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

风险与缓解

  1. 设置迁移:自定义 Deserialize 必须同时接受旧 Vec<String> 与新 HashMap<String, String> 两种格式;既非字符串数组也非 Map 的损坏 TOML 值回退到默认空 Map,与设置系统既有行为一致。
  2. 迭代顺序:HashMap 不保证迭代顺序,设置 UI 展示顺序可能与插入顺序不一致。spec 提出必要时切换到 BTreeMap;当前仓库实现直接采用 IndexMap,从源头保持了插入序,该风险已实际化解。
  3. 孤儿条目不可能出现:pattern 与 agent 共处同一个 Map 条目,删除 pattern 必然连带删除其 Agent 映射,无需额外清理逻辑。

测试与验证

spec 规划的验证矩阵覆盖五个层面,其中前三项可直接用单元测试覆盖:

  1. matched_agent 单元测试:带 Agent 映射的模式返回对应 CLIAgent;空值模式返回 CLIAgent::Unknown。
  2. 反序列化迁移测试:验证旧格式 ["pattern1", "pattern2"] 正确反序列化为空字符串值的新 Map。
  3. 集成/手动测试:添加自定义模式并指派 Claude,运行匹配命令后验证:工具栏显示 Claude 图标、插件安装 chip 出现、Rich Input 使用 Claude 的提交策略。
  4. 持久化测试:重启 Warp 后验证下拉框反映已保存的选择(设置项 sync_to_cloud: Globally 与本地持久化共同保证)。
  5. 向后兼容测试:迁移后仍为空值的既有模式继续产生 CLIAgent::Unknown 会话,行为与旧版一致。

仓库中相关实现的可追踪证据:设置定义与序列化迁移见 app/src/settings/ai.rs(ToolbarCommandMap 及 CompiledCommandsForCodingAgentToolbar 单例);检测链见 app/src/terminal/view/use_agent_footer/mod.rs;Agent 枚举与原生检测见 app/src/terminal/cli_agent.rs;设置界面交互见 app/src/settings_view/cli_agents_page.rs。结合这些源码,读者可以完整验证从配置写入、正则编译、命令检测到插件生命周期激活的每一条链路。

登录后查看全文
warp