给前端 Agent 命令面板加键盘闭环时,我会先把焦点账本建起来
最近在给一个前端 Agent 控制台补命令面板。鼠标路径很顺:点开、搜索、选择工具、确认执行。真正上键盘以后,问题开始集中暴露:用户按快捷键唤起面板,焦点有时落在旧输入框里;上下键切候选项时,屏幕阅读器读不到当前项;命令执行后,焦点回不到触发位置;弹层关闭和路由切换叠在一起时,还会把 Enter 留给页面里的旧按钮。这个问题提醒我,命令面板不能只看成一个弹层组件,它更像 Agent 工作台里的输入协议。

原创示意图:把键盘输入、搜索框、候选项高亮、执行守卫和焦点恢复账本放进同一条可追踪链路。 来源:Codex image generation
问题背景
W3C ARIA APG 的 combobox pattern 把输入框、弹出候选列表和键盘交互放在同一个模式里讨论;listbox pattern 也明确了上下键、Home、End 等常见导航行为。MDN 对 KeyboardEvent.key 的说明适合做统一按键分发,HTMLElement.focus 则是恢复焦点的基础能力。把这些资料放在一起看,前端 Agent 命令面板至少跨了四个边界:全局快捷键、搜索输入、候选项导航、命令执行后的焦点归还。
踩坑和关键难点
第一个坑是把当前高亮项存在组件局部 state 里。搜索词变化、工具列表刷新、权限过滤或 Agent 状态更新时,候选项数组会重排,原来的 index 可能指向另一个命令。用户按 Enter 后,执行的工具和视觉高亮不一致。
第二个坑是把 DOM focus 直接移到每个候选项。这样看起来简单,但输入框会失去连续输入能力,中文输入法组合态也容易被打断。更稳的做法是让输入框保留焦点,用 active descendant 记录当前候选项,再把视觉高亮和辅助技术提示同步到同一份状态。
第三个坑是关闭面板时没有恢复账本。命令可能成功、失败、被取消,也可能跳到确认弹窗或新路由。每条路径都自己处理 focus,最后一定会漏分支。
解决思路
我把命令面板拆成一份焦点账本。账本里记录 triggerElementId、panelId、inputId、activeCommandId、queryVersion、openedAt 和 closeReason。组件渲染只消费账本,键盘事件只更新账本,命令执行只读取稳定的 activeCommandId。这样一来,列表重排不会改变目标命令,弹层关闭也能统一走恢复逻辑。
按键层只处理少量协议:Escape 关闭,ArrowUp 和 ArrowDown 移动候选项,Enter 触发当前命令,Tab 离开面板。搜索词变化时生成新的 queryVersion,然后用 commandId 重新定位高亮项;如果旧命令已经被过滤,就落到第一个可执行项。这里不追求每次都保留 index,优先保证用户看到的项和即将执行的项一致。
关键步骤
第一步是把快捷键入口收口。全局快捷键只负责打开面板和记录触发源,不直接聚焦候选项。打开后用一次显式 focus() 把焦点送进搜索框,并保存触发元素,供关闭时恢复。
第二步是建立候选项协议。每个命令都必须有稳定 id、显示名称、可用状态、风险级别和执行入口。高亮状态保存 commandId,渲染层再映射成当前数组下标。禁用项可以展示,但不能成为 active command。
第三步是固定键盘分发。不要让输入框、候选项、弹层外壳各自监听 Enter。面板容器集中处理 key,先判断输入法组合态,再根据账本决定下一步。这样可以避免重复执行,也方便记录审计事件。
第四步是统一关闭路径。成功执行、取消、异常、路由离开都调用同一个 closeCommandPanel(reason)。这个函数先撤销临时监听,再清空 active command,最后尝试把焦点还给触发源。触发源已经卸载时,降级聚焦到主工作区的稳定入口。
可复用经验
命令面板看上去是 UI 细节,实际承担着 Agent 工具调用的入口责任。我的经验是先把焦点当成业务状态记录下来,再谈动画、视觉和快捷键丰富度。只要触发源、当前候选项、查询版本和关闭原因能被追踪,后面接高风险工具确认、命令历史、权限过滤和离线回放都会轻很多。键盘闭环做稳以后,鼠标路径也会变得更可靠,因为两条路径最终都落在同一份命令协议上。