Warp 自定义工具栏命令与 CLI Agent 关联:从 `CLIAgent::Unknown` 到完整插件生命周期的设计实践
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 依序执行两次判定:
- 先调用
CLIAgent::detect,将命令解析为 shell 中已展开的实际命令(含别名解析、转义字符处理),与每个 Agent 的command_prefix()前缀比对; - 若未命中任何已知前缀,则回退到用户配置的工具栏命令模式,调用
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,当前实现已完整呈现新链路:
- 仅当活动块处于"活跃且长时间运行"状态(
is_active_and_long_running())才进入检测,长运行阈值由view.rs中的LONG_RUNNING_COMMAND_DURATION_MS定时器决定; - 先取当前活动块命令(secret 混淆后的
command_with_secrets_obfuscated(false)); - 优先调用
CLIAgent::detect,结合会话的 shell 转义字符与别名表:- 解析别名:若命令首词命中别名表,用别名值替换首词得到
resolved_command(例如用户把foo别名到claude,运行foo即可检出 Claude); - 遍历全部
CLIAgent变体(跳过Unknown),用matches_command比对命令前缀,并叠加aifx agent run claude特例;
- 解析别名:若命令首词命中别名表,用别名值替换首词得到
- 原生检测返回
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 为判断依据,因此本次改动不需要新增监听器相关代码:
- 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。 - 主动注册路径: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
风险与缓解
- 设置迁移:自定义
Deserialize必须同时接受旧Vec<String>与新HashMap<String, String>两种格式;既非字符串数组也非 Map 的损坏 TOML 值回退到默认空 Map,与设置系统既有行为一致。 - 迭代顺序:
HashMap不保证迭代顺序,设置 UI 展示顺序可能与插入顺序不一致。spec 提出必要时切换到BTreeMap;当前仓库实现直接采用IndexMap,从源头保持了插入序,该风险已实际化解。 - 孤儿条目不可能出现:pattern 与 agent 共处同一个 Map 条目,删除 pattern 必然连带删除其 Agent 映射,无需额外清理逻辑。
测试与验证
spec 规划的验证矩阵覆盖五个层面,其中前三项可直接用单元测试覆盖:
matched_agent单元测试:带 Agent 映射的模式返回对应CLIAgent;空值模式返回CLIAgent::Unknown。- 反序列化迁移测试:验证旧格式
["pattern1", "pattern2"]正确反序列化为空字符串值的新 Map。 - 集成/手动测试:添加自定义模式并指派 Claude,运行匹配命令后验证:工具栏显示 Claude 图标、插件安装 chip 出现、Rich Input 使用 Claude 的提交策略。
- 持久化测试:重启 Warp 后验证下拉框反映已保存的选择(设置项
sync_to_cloud: Globally与本地持久化共同保证)。 - 向后兼容测试:迁移后仍为空值的既有模式继续产生
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。结合这些源码,读者可以完整验证从配置写入、正则编译、命令检测到插件生命周期激活的每一条链路。