qwen-code Web Shell 提问面板键盘交互设计:AskUserQuestion 的完整按键契约与无障碍实现
【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code
导读
qwen-code 的 Web Shell 在需要向用户收集决策信息时,会通过ask_user_question权限请求渲染一个多步提问面板(AskUserQuestion)。本设计文档docs/design/2026-08-10-web-shell-ask-user-question-keyboard.md定义了该面板完整的键盘交互契约:从选项导航、自定义回答输入,到跨问题跳转、全局提交/取消,以及无障碍语义与分栏面板焦点守卫。本文以该设计文档为骨架,结合 AskUserQuestion.tsx 的实现与 AskUserQuestion.test.tsx 的 60+ 条用例,逐条讲解按键契约背后的实现原理,帮助你理解并复现这套可访问的键盘交互设计。
问题背景:键盘流在哪些场景会中断
设计文档首先指出了既有实现的痛点:Web Shell 提问浮层虽然支持在单个选项列表内做键盘导航,但当用户在多个问题之间移动、输入自定义答案、或到达最终动作(提交)时,键盘流会断裂;此外,回到一个已回答过的问题时,焦点可能落在与已勾选答案不同的选项上。
这些问题可归纳为三类交互缺口:
- 跨问题导航缺失:多问题表单中,仅靠上下键无法在问题间移动;
- 自定义输入无键盘出口:进入 "Other" 自定义输入框后,缺少明确的提交/退出路径;
- 状态-焦点不同步:返回已答题时,焦点没有恢复到该题的当前答案(即“安全默认值”与真实答案不一致)。
解决方向不是简单加几个 keydown 监听,而是建立一套完整、可验证的交互契约(Interaction Contract),并用组件测试逐条锁定行为。
交互契约:逐键语义详解
设计文档给出了一条覆盖全生命周期的按键契约。下面结合源码 handleKeyDown 逐条展开。
打开与初始聚焦
Opening the topmost question focuses its current answer, or the first option when the question has not been visited.
面板成为最顶层问题(topmost)时,自动将焦点移到当前答案上;若该问题尚未被访问过,则聚焦第一个选项。实现位于聚焦副作用(AskUserQuestion.tsx):
useEffect(() => { const wasActive = prevKeyboardActiveRef.current; const prevRequestId = prevRequestIdRef.current; prevKeyboardActiveRef.current = keyboardActive; if (!keyboardActive || !current) return; const requestChanged = request.id !== prevRequestId; if (requestChanged && currentIdx !== 0) return; if (wasActive && !requestChanged) return; prevRequestIdRef.current = request.id; const idx = selectedIdxRef.current ?? 0; if (idx === current.options.length) customRef.current?.focus(); else optionRefs.current[idx]?.focus(); }, [current, currentIdx, keyboardActive, request.id]);这里用 ref 记录了上一请求的 request.id,确保“新请求到达”和“同一请求内重新聚焦”两种情况能被区分:新请求到达且当前在第 0 题时才拉取焦点;同一请求下已处于激活状态则不重复抢焦点。
选项移动:Up/Down 与 j/k
Up/Down and j/k move through options. In a single-select question, focus and the checked answer move together. Space toggles the focused multi-select option.
- Up/Down 与 j/k在选项间移动,移动逻辑由 moveSelection 实现——它基于selectedIdxRef(同步 ref)计算
(base + delta + total) % total,形成首尾循环,且利用 ref 保证连按方向键时在重渲染前就能正确前进(对应测试advances on rapid repeated ArrowDown without a re-render in between)。 - 单选(single-select)遵循 radiogroup 契约:焦点移动即答案提交,
aria-checked跟随焦点。核心是 selectIndex:移动到普通选项时调用handleSelectOption提交答案;移动到 "Other" 时清除已选普通答案。 - 多选(multi-select)使用 toggle 按钮语义,
Space切换当前聚焦项的选中状态,由 handleToggle 维护selectedMulti映射。 - 额外支持Home/End 跳到首/末选项,以及数字键 1-9 直接选择对应选项(每个选项按钮通过
aria-keyshortcuts暴露数字快捷键,见渲染逻辑 AskUserQuestion.tsx)。
Enter:前进与提交
Enter advances to the next question. On the last question, it submits the current answers.
Enter在非末题时进入下一题;在末题时提交当前所有答案。源码中的 advanceQuestion 负责这一分支:非末题调用selectQuestion(currentIdx + 1),末题则调用handleSubmit。
值得注意的是多选场景的细节:若当前聚焦的是多选选项且尚未选中,Enter会先选中该项再前进(见 handleKeyDown),避免用户按下 Enter 后该项未被记录。
Previous / Next:保留目标题状态
Previous and Next move focus into the destination question, preserving its checked option or custom-answer trigger.
向前/向后切题时,焦点进入目标题,并恢复该题已勾选的选项或自定义答案触发器,而不是回到“安全默认值”。这是对最初“返回已答题焦点错位”问题的直接修复,核心是 getSelectedIndexForQuestion:
const getSelectedIndexForQuestion = useCallback( (questionIdx: number): number | null => { if (Object.hasOwn(selectedIdxByQuestionRef.current, questionIdx)) { return selectedIdxByQuestionRef.current[questionIdx] ?? null; } // 无记录时:优先自定义答案 -> 已勾选选项 -> 第 0 个选项 ... }, [answers, customInputs, questions], );selectedIdxByQuestionRef按题目索引记录了每题的焦点位置,切题时由 selectQuestion 读出并写回selectedIdxRef,随后触发聚焦副作用把焦点落到对应选项或 "Other" 触发器上。对应测试包括restores focus to the checked answer when returning to a question、restores a custom answer and its focus when returning to a question。
Left / Right:任意非编辑控件的横移
Left and Right perform the same navigation from any non-editable dialog control.
横向箭头承担“上一题/下一题”职责,并且从对话框内任何非编辑控件(选项按钮、动作按钮等)都能触发,前提是当前聚焦元素不在可编辑目标(输入框)内。实现通过isEditableTarget(e.target)判断后,在非选项目标和选项目标两条分支中都注册了ArrowLeft/ArrowRight处理(AskUserQuestion.tsx)。测试moves between questions with horizontal arrows from an action button专门覆盖了从动作按钮横移的场景。
Command/Ctrl+Enter:任意位置全局提交
Command/Ctrl+Enter submits the current answers from anywhere in the dialog.
无论焦点在选项、动作按钮还是自定义输入框,Command/Ctrl+Enter都会提交当前全部答案。提交前必须满足所有问题均已作答(allQuestionsAnswered),否则不触发;提交按钮上也用aria-keyshortcuts="Control+Enter Meta+Enter"向读屏器宣告该快捷键。快捷标签按平台显示⌘↵或Ctrl↵(见 submitShortcutLabel)。
Escape:编辑退出与请求取消的两段式语义
Escape while editing a custom answer exits editing, preserves the text, and restores focus to the Other trigger. Escape elsewhere cancels the request, so pressing Escape a second time after leaving the input cancels.
这是设计中最精巧的语义:在自定义输入框内按 Escape 只退出编辑——保留已输入文本,焦点回到 "Other" 触发器;再次按 Escape 才取消整个请求。实现分两层:
- 输入框自身拦截 Escape(handleCustomInputKeyDown):
preventDefault+stopPropagation,设置focusCustomTriggerAfterEditRef后退出编辑态,由副作用把焦点还给触发器; - 面板层在非编辑目标上按 Escape 时调用
handleCancel(取消走reject_once/reject_always选项)。
测试exits custom-input editing on Escape, then cancels on a second Escape完整验证了两段式流程;同时输入框对IME 组合输入(isComposing/keyCode === 229)做了豁免,避免中文等输入法在选词阶段被 Escape/Enter 打断。
上下文快捷键提示(Contextual Hint)
A short contextual hint makes the available keys visible.
面板底部常驻一行随状态变化的快捷键提示,由 shortcutHint 计算逻辑 根据"是否多选 / 是否末题 / 是否正在编辑自定义输入"组合出不同文案,i18n 文案位于 i18n.tsx:
| 状态 | 文案(英文) |
|---|---|
| 单选、多题中间 | ↑↓ select · Enter next |
| 单选、末题 | ↑↓ select · Enter submit |
| 多选 | ↑↓ move · Enter next/↑↓ move · Enter submit |
| 空 "Other" 触发器聚焦 | ↑↓ select · Enter edit/↑↓ move · Enter edit |
| 输入框为空 | Type an answer · Esc stop editing |
| 输入框有内容 | Enter next/Enter submit · Esc stop editing |
若处于第 2 题及以后,还会在前缀追加← previous,提示用户可用左箭头返回。
折叠态:动作快捷键失效
Action shortcuts are inactive while the dialog is collapsed.
面板支持折叠(collapse)为一行标题栏。折叠状态下,handleKeyDown只允许Escape(取消)与Command/Ctrl+Enter(提交)被preventDefault拦截,其余动作快捷键全部静默(AskUserQuestion.tsx),同时快捷键提示区被隐藏。测试keeps the shortcut footer hidden while the dialog is collapsed与keeps action shortcuts inert while the dialog is collapsed分别锁定这两点。
无障碍设计:非模态多步表单
The overlay is a non-modal multi-step form rather than a brief urgent alert, so it uses
role="dialog"withoutaria-modal.
设计文档明确了无障碍定位:这不是一个短暂的紧急弹窗(alert),而是非模态的多步表单,因此:
- 面板根节点使用
role="dialog"且不带aria-modal——非模态意味着用户仍可与页面其他部分交互,无需强制焦点圈闭(测试exposes a non-modal dialog of real buttons and focuses the first option同时断言role === 'dialog'且无aria-modal属性); - 保留既有
radiogroup与 toggle 按钮语义:单选选项是role="radio"+aria-checked(互斥语义),多选选项是aria-pressed的 toggle 按钮(见渲染逻辑 AskUserQuestion.tsx); - 当前问题继续标注对话框及其选项组:展开态下对话框用
aria-labelledby同时引用工具名标题(headingId)与问题文本(questionTextId),避免读屏器丢失工具名上下文(代码注释 AskUserQuestion.tsx),选项组radiogroup/group用aria-labelledby={questionTextId}关联当前问题文本; - 选项使用roving tabindex(当前项
tabIndex={0},其余-1),把面板整体纳入 Tab 序列而非每个选项独立占位; - 数字快捷键通过
aria-keyshortcuts暴露;提交按钮带aria-busy表达提交中的加载状态。
焦点守卫:split-view 分栏下的 keyboardActive
Split-view panes keep their existing
keyboardActivefocus guard.
当 Web Shell 处于split-view(分栏)模式时,多个面板可能同时展示提问/审批组件。此时AskUserQuestion的keyboardActiveprop 传false,禁止组件自动抢焦点,避免一个面板的提问偷走用户正在操作的面板的焦点。关键设计点(见 AskUserQuestion.tsx 的 prop 注释):
Whether this question should pull keyboard focus to its first option when it becomes the topmost one. Defaults to true. Split-view panes pass false so a question in one pane doesn't steal focus from the pane the user is in; like ToolApproval, keyboard handling is focus-scoped, so it stays operable once the user tabs/clicks into it.
- 默认
true(单面板场景自动聚焦); - 分栏场景下 ChatPane.tsx 对
ToolApproval与AskUserQuestion均传keyboardActive={false}; - 由于键盘处理是**焦点作用域(focus-scoped)**的,用户 Tab 或点击进入某面板后,该面板的提问依然完整可用——只是不主动抢焦点。测试
does not steal focus when keyboardActive is false (split-view panes)验证了这一行为。
影响范围与边界
设计文档明确了本次改动的边界:
- 改动仅限Web Shell 提问组件本体、其样式、翻译文案与聚焦的组件测试;
- 权限载荷(permission payload)与 daemon 协议完全不变——提问数据仍通过
PermissionRequest.rawInput.questions传入(types.ts),onConfirm(id, optionId, answers)的回调签名不变; - 分栏面板保留各自的
keyboardActive焦点守卫,互不干扰。
这保证了该交互升级对服务端协议与权限链路透明,风险被严格限制在 UI 层。
源码验证路径
想深入验证上述契约,可按以下路径继续探索:
- 交互契约实现:AskUserQuestion.tsx(
handleKeyDown、selectIndex、selectQuestion、advanceQuestion等核心回调) - 行为契约测试(60+ 条,覆盖本文全部按键语义):AskUserQuestion.test.tsx(accessibility 与 multiple questions 两大 describe 块)
- 快捷键提示文案:i18n.tsx(
askUser.shortcuts.*英文与中文两套) - 分栏焦点守卫调用方:ChatPane.tsx
- 权限请求数据结构:types.ts
设计文档本身位于 docs/design/2026-08-10-web-shell-ask-user-question-keyboard.md,可作为团队内部评审与后续迭代的基准契约。
【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考