Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-22-composer-tab-completes-trigger-menu.md
2026-08-22-composer-tab-completes-trigger-menu.md: 7dda097ec809bf5c191f53a98637b638bdf5d5f9
2026-08-22-composer-tab-completes-trigger-menu.zh.md: a079abb975ee74dee06de6d5745974e0d84cfd82
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Agent Note: Composer Tab completes the leading slash command as text

Status: implemented

English | [中文](2026-08-22-composer-tab-completes-trigger-menu.zh.md)

## Problem

While the `/` or `@` trigger menu was open in the composer, the Tab key fell through to the browser's default focus walk: the textarea lost focus to the next focusable control (the toolbar command button, the model seat, the Send button), and the keystroke never completed the command the user was filtering for. A first attempt at the fix made Tab pick the highlighted candidate like Enter, but that was wrong for this gesture: ui-commands' pick path executes argument-less (bare) host commands immediately (`runDetached` in `packages/client/ui-commands/src/client/service.ts`), so a Tab on the open `/` menu could run a command instead of completing its name into the draft.

## Decision

**Tab completes text; it never picks.** `ArbitrateKey` gains `'tab'` in `packages/client/ui-input-trigger/src/types.ts`. The controller's `arbitrate` routes `'tab'` to a dedicated `complete(state)` arm: for a leading `/` token with a highlighted ready candidate it splices `/<name> ` (the trigger, the candidate name, and a trailing separator) over the token span through the scoped `slash/input-insert-text` event — the same plain-text insertion path sources' `{ text }` outcomes use — then closes the menu. The draft stays plain text, so Enter-time adjudication (`matchEnter`) claims or executes the command exactly as if it had been typed by hand. Every other open-menu state — no highlight yet, an inline token, or the `@` trigger — consumes the key without acting, so the browser's focus walk cannot escape the composer while the menu is up. A closed menu, IME composition, or disposal answers `'pass'`.

**The composer routes Tab through the same arbitration and prevents the focus walk.** `InputBar.onKeyDown` intercepts `Tab` after the Escape branch: it calls `keyboard.arbitrate('tab', composing)` and preventDefaults exactly when the outcome is not `'pass'`. The workspace-trigger and absent-machine paths return before the branch, so nothing outside a live menu changes.

## Alternatives considered

**Tab picks the highlight like Enter.** Rejected after it shipped in the same change: the pick path is the source's execution path, and a bare host command executes on pick — Tab completed by running the command, which is exactly what a completion gesture must not do.

**A new per-source completion hook in the frozen trigger contract.** Rejected: the candidate name is the completion text for commands, and the plain-text insert path already exists; a contract extension would buy nothing for this gesture.

**Consume Tab only when a completion exists.** Rejected: while candidate groups are still pending, Tab would walk focus out of the composer, reproducing the original defect; consuming the key with no completion to offer is better than losing focus mid-interaction.

**Handle Tab inside MenuView instead of the composer.** Rejected: focus never enters the menu (combobox pattern — rows pick on mousedown and the textarea keeps focus), so the menu receives no key events; the textarea's keydown is the only interception point.

## Consequences

Tab on the open slash menu completes the highlighted command's name into the draft and keeps focus in the textarea; the command runs only when the user submits the completed line. The `@` reference menu and inline tokens consume Tab without completing — their pick outcomes carry structure (reference chips, popups) that a bare text splice would corrupt, so Enter/pointer remain their pick gestures. Enter is unchanged. The `ArbitrateOutcome` union is unchanged; tab always answers `'consumed'` or `'pass'`. The popupSelect command popup (the plus-button surface) keeps its own key handling and is unaffected.

## Testing

`packages/client/ui-input-trigger/tests/service.client.spec.ts` pins the controller arm: tab splices `/<name> ` through the scoped insert-text event without invoking onPick, consumes without acting on inline tokens and the `@` trigger, passes during IME composition and on a closed menu, and consumes while groups are pending. `packages/client/ui-conversation/tests/input-bar.client.spec.tsx` pins the DOM routing: a consumed arbitration preventDefaults the Tab keydown (no focus walk) and a `'pass'` arbitration leaves it native.

## Related

- [Web input machine and slash pipeline](../architecture/2026-07-25-web-input-machine-and-slash-pipeline.md) — the trigger pipeline whose arbitration this extends.
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Agent Note:输入框 Tab 键把前导斜杠命令补全为文本

Status: implemented

[English](2026-08-22-composer-tab-completes-trigger-menu.md) | 中文

## 问题

在输入框里输入 `/` 或 `@` 触发菜单打开时,Tab 键会落到浏览器的默认焦点行走:光标从文本区跳到下一个可聚焦控件(工具栏命令按钮、模型席位、发送按钮),而按键永远不会补全用户正在过滤的命令。首次尝试让 Tab 像 Enter 一样选中高亮候选项,但这对手势来说是错的:ui-commands 的 pick 路径会立即执行无参数(bare)宿主命令(`packages/client/ui-commands/src/client/service.ts` 中的 `runDetached`),因此在打开的 `/` 菜单上按 Tab 可能直接运行命令,而不是把命令名补全进草稿。

## 决定

**Tab 只补全文本,绝不 pick。** `packages/client/ui-input-trigger/src/types.ts` 中的 `ArbitrateKey` 增加 `'tab'`。控制器的 `arbitrate` 把 `'tab'` 路由到专门的 `complete(state)` 分支:当前导 `/` 记号带有已就绪的高亮候选项时,它经 scoped `slash/input-insert-text` 事件把 `/<name> `(触发符 + 候选项名 + 尾随分隔符)拼接到记号 span 上——与 source 的 `{ text }` 结果共用的同一条纯文本插入路径——然后关闭菜单。草稿保持纯文本,因此回车裁决(`matchEnter`)像手工输入一样接管或执行该命令。其余所有菜单打开状态——尚无高亮、行内记号或 `@` 触发——只消费按键而不做任何事,菜单打开期间浏览器的焦点行走无法逃出输入框。菜单关闭、输入法组合中或已销毁时回答 `'pass'`。

**输入框把 Tab 路由到同一仲裁并阻止焦点行走。** `InputBar.onKeyDown` 在 Escape 分支之后拦截 `Tab`:调用 `keyboard.arbitrate('tab', composing)`,仅当结果不是 `'pass'` 时 preventDefault。工作区触发器路径和机器缺失路径在该分支之前就返回了,因此活动菜单之外的行为没有任何变化。

## 曾考虑的替代方案

**Tab 像 Enter 一样选中高亮项。** 在同一个变更里落地后否决:pick 路径就是 source 的执行路径,而 bare 宿主命令在 pick 时即执行——Tab 以运行命令的方式完成补全,恰恰是补全手势绝不该做的事。

**在冻结的 trigger 契约里新增逐 source 的补全钩子。** 否决:对命令来说候选项名就是补全文本,且纯文本插入路径已存在;为此手势扩展契约换不来任何东西。

**只在存在补全时消费 Tab。** 否决:候选项分组仍在加载时,Tab 会把焦点走出输入框,重演原始缺陷;无可补全时吞掉按键也好过交互中途丢失焦点。

**在 MenuView 里处理 Tab,而不是在输入框里。** 否决:焦点从不进入菜单(combobox 模式——行在 mousedown 时完成 pick、文本区保持焦点),因此菜单收不到键盘事件;文本区的 keydown 是唯一的拦截点。

## 后果

在打开的斜杠菜单上按 Tab,会把高亮命令名补全进草稿并让焦点留在文本区;命令只在用户提交整行后运行。`@` 引用菜单与行内记号只消费 Tab 而不补全——它们的 pick 结果携带结构(引用芯片、弹窗),裸文本拼接会破坏语义,因此 Enter/指针仍是它们的 pick 手势。Enter 不变。`ArbitrateOutcome` 联合类型不变;tab 永远只回答 `'consumed'` 或 `'pass'`。加号按钮打开的 popupSelect 命令弹窗保留自己的按键处理,不受影响。

## 测试

`packages/client/ui-input-trigger/tests/service.client.spec.ts` 固定控制器分支:tab 经 scoped insert-text 事件拼接 `/<name> ` 且不调用 onPick、对行内记号与 `@` 触发只消费不动作、输入法组合期间与菜单关闭时放行、分组加载期间消费。`packages/client/ui-conversation/tests/input-bar.client.spec.tsx` 固定 DOM 路由:被消费的仲裁会 preventDefault 掉 Tab 的 keydown(无焦点行走),`'pass'` 仲裁则保持原生行为。

## 相关

- [Web 输入状态机与斜杠管线](../architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md)——本变更所扩展的触发管线仲裁。
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,13 @@ export function InputBar({
if (keyboard.arbitrate('escape', composing) === 'consumed') e.preventDefault()
return
}
if (e.key === 'Tab') {
// Tab completes the highlighted slash command as text instead of the
// browser's focus walk; the controller consumes it whenever the menu is
// open (even while candidates load) and passes only when no menu is up.
if (keyboard.arbitrate('tab', composing) !== 'pass') e.preventDefault()
return
}
if ((e.metaKey || e.ctrlKey) && (e.key === 'z' || e.key === 'Z' || e.key === 'y')) {
// The machine owns the undo/redo log (chip transactions have semantics
// the browser stack cannot represent); never let the native stack run.
Expand Down
20 changes: 18 additions & 2 deletions packages/client/ui-conversation/tests/input-bar.client.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { SubmitOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
import type { ArbitrateKey, ArbitrateOutcome, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
import { SessionInputShell } from '../src/client/input/facade.ts'
import type {
ComposerAttachment, ComposerAttachmentsOwnerProps,
Expand Down Expand Up @@ -98,6 +98,8 @@ interface BenchOptions {
commandMenuOpen?: boolean
busyEnter?: 'queue' | 'steer'
toggleCommandMenu?: (selection: { start: number; end: number }) => void
/** The slash menu's keyboard-arbitration verdicts (the Tab-completion benches). */
arbitrate?: (key: ArbitrateKey, composing: boolean) => ArbitrateOutcome
}

/** One pending queue row (the runtime snapshot shape, as the dock tests build it). */
Expand Down Expand Up @@ -138,10 +140,11 @@ function bench(over?: BenchOptions) {
// seat) plus the optional arbitrate face (menu-keyboard benches);
// adjudication stays untouched (undefined slash methods are never
// reached — plain-draft flows only).
...(lex !== undefined
...((lex !== undefined || over?.arbitrate !== undefined)
? {
inputTriggers: (() => ({
lexicon: { getSnapshot: () => lex ?? NO_LEXICON, subscribe: () => () => {} },
...(over?.arbitrate !== undefined ? { arbitrate: over.arbitrate } : {}),
})) as unknown as NonNullable<ShellDeps['inputTriggers']>,
}
: {}),
Expand Down Expand Up @@ -518,6 +521,19 @@ describe('Enter semantics', () => {
expect(sink).not.toHaveBeenCalled()
})

it('Tab arbitration that consumed the key preventDefaults the focus walk', () => {
const arbitrate = vi.fn(() => 'consumed' as const)
const { textarea } = bench({ draft: '/go', arbitrate })
// false = the event was preventDefault'd: no focus walk to the toolbar.
expect(fireEvent.keyDown(textarea, { key: 'Tab' })).toBe(false)
expect(arbitrate).toHaveBeenCalledWith('tab', false)
})

it('Tab without an open menu passes through to native focus behavior', () => {
const { textarea } = bench({ draft: '/go', arbitrate: () => 'pass' })
expect(fireEvent.keyDown(textarea, { key: 'Tab' })).toBe(true)
})

it('Shift+Enter newline wins even inside IME composition (unconditional precedence)', () => {
const { textarea, sink } = bench({ draft: 'hello' })
fireEvent.compositionStart(textarea)
Expand Down
4 changes: 2 additions & 2 deletions packages/client/ui-input-trigger/README.i18n.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-input-trigger/README.md
README.md: 248fd14e5c441ebb3ebf7806919d30a5f71a78e4
README.zh.md: a04ce3efba5d3fa32e895429fb119d9b29983fb0
README.md: 64e1a00c9898255b205518ee61e70809c0f9b19e
README.zh.md: 2adb35a3f8d0a76ce895cfbb0fd107736768ce1c
2 changes: 1 addition & 1 deletion packages/client/ui-input-trigger/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Input trigger pipeline plugin: `/` and `@` detection under the caret (word-bound

Layering: `src/core/` is the pure core — `detectTrigger`, `menuReduce`/`seedGroups`/`MENU_CLOSED`, `exactMatch`, zero React/DOM/cordis; `src/client/service.ts` is the shell wiring the core to the menu snapshot store, the per-hit candidate fetch (generation-gated, `AbortSignal`-superseded, failed sources drop silently with a console record), and the three pick paths. `ReferenceInsert.appearance` optionally identifies a `session`, `file`, or `folder` display without changing its serialized `ref`; the consuming composer owns the glyph and color. `src/types.ts` and the two `contract.ts` files are the frozen cross-package contract; changes require main-thread arbitration.

MenuView renders the menu store into the `conversation.input.overlay` slot (list kind, session scope) and renders null while closed. Typed triggers seed every source registered for that trigger; a programmatic launcher seeds only its requested source and publishes the source name through the controller's `launcher` snapshot store until the menu closes or typed tracking resumes. Groups sort by the optional `InputTriggerSource.order` (lower first, default 0, ties keep registration order) under title rows localized through the `inputTriggers.menu` locale namespace (an unknown source shows its raw name). `showGroupTitle: false` suppresses that row through pending and ready states, while a ready group whose candidates declare sections uses those section rows in place of the source title. The list height clamps to the space above the composer, and a pointer down outside both the menu and the surrounding composer card dismisses it. The slot is owned by ui-conversation's composer entry (anchor, children declaration, lifecycle); its SlotMap type merge lives in this package's `src/client/slots.ts` because the dependency direction (ui-conversation → ui-input-trigger) admits no reverse type import. Combobox pattern: focus stays in the textarea, rows pick on mousedown, the highlight rides `aria-activedescendant`.
MenuView renders the menu store into the `conversation.input.overlay` slot (list kind, session scope) and renders null while closed. Typed triggers seed every source registered for that trigger; a programmatic launcher seeds only its requested source and publishes the source name through the controller's `launcher` snapshot store until the menu closes or typed tracking resumes. Groups sort by the optional `InputTriggerSource.order` (lower first, default 0, ties keep registration order) under title rows localized through the `inputTriggers.menu` locale namespace (an unknown source shows its raw name). `showGroupTitle: false` suppresses that row through pending and ready states, while a ready group whose candidates declare sections uses those section rows in place of the source title. The list height clamps to the space above the composer, and a pointer down outside both the menu and the surrounding composer card dismisses it. The slot is owned by ui-conversation's composer entry (anchor, children declaration, lifecycle); its SlotMap type merge lives in this package's `src/client/slots.ts` because the dependency direction (ui-conversation → ui-input-trigger) admits no reverse type import. Combobox pattern: focus stays in the textarea, rows pick on mousedown, the highlight rides `aria-activedescendant`, and the keyboard arbitration behind the composer's keydown picks the highlighted candidate on Enter while Tab completes the leading slash command as plain text.

The `/client` exports are the plugin body (`apply`/`inject`), `InputTriggerService`, `MenuViewInjected`, and the contract types. MenuView itself is internal — the slot registration closes over it.

Expand Down
Loading
Loading