news 2026/9/12 19:25:09

qwen-code Web Shell 提问面板键盘交互设计:AskUserQuestion 的完整按键契约与无障碍实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
qwen-code Web Shell 提问面板键盘交互设计:AskUserQuestion 的完整按键契约与无障碍实现

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 提问浮层虽然支持在单个选项列表内做键盘导航,但当用户在多个问题之间移动、输入自定义答案、或到达最终动作(提交)时,键盘流会断裂;此外,回到一个已回答过的问题时,焦点可能落在与已勾选答案不同的选项上

这些问题可归纳为三类交互缺口:

  1. 跨问题导航缺失:多问题表单中,仅靠上下键无法在问题间移动;
  2. 自定义输入无键盘出口:进入 "Other" 自定义输入框后,缺少明确的提交/退出路径;
  3. 状态-焦点不同步:返回已答题时,焦点没有恢复到该题的当前答案(即“安全默认值”与真实答案不一致)。

解决方向不是简单加几个 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 questionrestores 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 collapsedkeeps 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 usesrole="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/grouparia-labelledby={questionTextId}关联当前问题文本;
  • 选项使用roving tabindex(当前项tabIndex={0},其余-1),把面板整体纳入 Tab 序列而非每个选项独立占位;
  • 数字快捷键通过aria-keyshortcuts暴露;提交按钮带aria-busy表达提交中的加载状态。

焦点守卫:split-view 分栏下的 keyboardActive

Split-view panes keep their existingkeyboardActivefocus guard.

当 Web Shell 处于split-view(分栏)模式时,多个面板可能同时展示提问/审批组件。此时AskUserQuestionkeyboardActiveprop 传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 对ToolApprovalAskUserQuestion均传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(handleKeyDownselectIndexselectQuestionadvanceQuestion等核心回调)
  • 行为契约测试(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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/12 19:24:34

配电网故障恢复:统一建模与Matlab实现

1. 项目背景与核心价值配电网故障恢复一直是电力系统运维中的关键难题。传统方法往往将网络重构和孤岛运行分开处理,导致恢复方案可能不是全局最优。这个项目提出了一种创新思路——将孤岛划分与网络重构统一建模,通过Matlab实现了一套完整的解决方案。我…

作者头像 李华
网站建设 2026/9/12 19:24:12

LeetCode hot100——994.腐烂的橘子

题目在给定的 m x n 网格 grid 中,每个单元格可以有以下三个值之一:值 0 代表空单元格;值 1 代表新鲜橘子;值 2 代表腐烂的橘子。每分钟,腐烂的橘子 周围 4 个方向上相邻 的新鲜橘子都会腐烂。返回 直到单元格中没有新…

作者头像 李华
网站建设 2026/9/12 19:24:10

小程序体验优化提升带货转化率的7个关键点

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/12 19:23:03

手表App开发选型不踩坑:平台、跨端框架与UI交互指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/12 19:19:17

框架脚手架搭建,推送github一键使用

参考: vue的官方脚手架 vuejs/create-vue: 🛠️ The recommended way to start a Vite-powered Vue project 脚手架仓库搭建总流程 第一步:初始化脚手架工程(CLI 外壳) 新建工程根目录: 在本地新建一…

作者头像 李华
网站建设 2026/9/12 19:18:15

确定性刹车实测:agent 说的每句话,先过工具这一关

一、为什么我盯上这个项目 前面 DseWiki 那篇我拉了那个废弃 wiki 上的 14,591 条留言,结论很冷:agent 之间会自己交接、自己定规矩,没有一条想到通知人类。当时稿还没发,OpenAI 官方就确认了这起事件(前后不到 48 小时),说在搞披露框架。 官方在补制度,工程侧在补工具。 今…

作者头像 李华