From 559d9db7074c5b39564aa205369c9dfe06332d35 Mon Sep 17 00:00:00 2001 From: whitelonng Date: Fri, 28 Aug 2026 03:48:20 +0800 Subject: [PATCH] feat(web): complete the leading slash command with Tab in the composer --- ...poser-tab-completes-trigger-menu.i18n.yaml | 6 +++ ...-22-composer-tab-completes-trigger-menu.md | 37 ++++++++++++++ ...-composer-tab-completes-trigger-menu.zh.md | 37 ++++++++++++++ .../src/client/skeleton/InputBar.tsx | 7 +++ .../tests/input-bar.client.spec.tsx | 20 +++++++- .../client/ui-input-trigger/README.i18n.yaml | 4 +- packages/client/ui-input-trigger/README.md | 2 +- packages/client/ui-input-trigger/README.zh.md | 2 +- .../ui-input-trigger/src/client/controller.ts | 27 ++++++++++ packages/client/ui-input-trigger/src/types.ts | 2 +- .../tests/service.client.spec.ts | 49 +++++++++++++++++-- 11 files changed, 183 insertions(+), 10 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-22-composer-tab-completes-trigger-menu.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-22-composer-tab-completes-trigger-menu.md create mode 100644 .agents/notes/implemented/feature/2026-08-22-composer-tab-completes-trigger-menu.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-22-composer-tab-completes-trigger-menu.i18n.yaml b/.agents/notes/implemented/feature/2026-08-22-composer-tab-completes-trigger-menu.i18n.yaml new file mode 100644 index 0000000000..724e9f96f1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-22-composer-tab-completes-trigger-menu.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/feature/2026-08-22-composer-tab-completes-trigger-menu.md b/.agents/notes/implemented/feature/2026-08-22-composer-tab-completes-trigger-menu.md new file mode 100644 index 0000000000..7dda097ec8 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-22-composer-tab-completes-trigger-menu.md @@ -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 `/ ` (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 `/ ` 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. diff --git a/.agents/notes/implemented/feature/2026-08-22-composer-tab-completes-trigger-menu.zh.md b/.agents/notes/implemented/feature/2026-08-22-composer-tab-completes-trigger-menu.zh.md new file mode 100644 index 0000000000..a079abb975 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-22-composer-tab-completes-trigger-menu.zh.md @@ -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` 事件把 `/ `(触发符 + 候选项名 + 尾随分隔符)拼接到记号 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 事件拼接 `/ ` 且不调用 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)——本变更所扩展的触发管线仲裁。 diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index e291ae608b..ec82b414c9 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -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. diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index e511d3b286..2670e3072a 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -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, @@ -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). */ @@ -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, } : {}), @@ -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) diff --git a/packages/client/ui-input-trigger/README.i18n.yaml b/packages/client/ui-input-trigger/README.i18n.yaml index cfae1ce880..a536b2e23b 100644 --- a/packages/client/ui-input-trigger/README.i18n.yaml +++ b/packages/client/ui-input-trigger/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-input-trigger/README.md b/packages/client/ui-input-trigger/README.md index 248fd14e5c..64e1a00c98 100644 --- a/packages/client/ui-input-trigger/README.md +++ b/packages/client/ui-input-trigger/README.md @@ -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. diff --git a/packages/client/ui-input-trigger/README.zh.md b/packages/client/ui-input-trigger/README.zh.md index a04ce3efba..2adb35a3f8 100644 --- a/packages/client/ui-input-trigger/README.zh.md +++ b/packages/client/ui-input-trigger/README.zh.md @@ -6,7 +6,7 @@ 分层:`src/core/` 是纯内核——`detectTrigger`、`menuReduce`/`seedGroups`/`MENU_CLOSED`、`exactMatch`,零 React/DOM/cordis;`src/client/service.ts` 是壳层,把内核接到菜单快照 store、逐 hit 候选拉取(以 generation 把关、后继请求经 `AbortSignal` 取代旧请求、失败的 source 静默丢弃并留一条 console 记录)和三条 pick 路径上。`ReferenceInsert.appearance` 可以把显示类型标为 `session`、`file` 或 `folder`,且不会改变其序列化 `ref`;图标与颜色由消费它的输入框负责。`src/types.ts` 与两个 `contract.ts` 文件是冻结的跨包约定;变更需经主线程仲裁。 -MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot(列表类,会话 scope),菜单关闭期间渲染 null。键入式 trigger 会 seed 为该 trigger 注册的所有 source;程序化 launcher 只 seed 所请求的 source,并在菜单关闭或重新开始键入式 tracking 前,通过 controller 的 `launcher` 快照 store 发布该 source 名称。分组按可选的 `InputTriggerSource.order` 排序(越小越靠前,默认 0,同值保持注册序),组标题行经 `inputTriggers.menu` locale 命名空间本地化(未知 source 显示其原名)。`showGroupTitle: false` 会在 pending 与 ready 状态全程隐藏该行,ready 且候选项声明了 section 的组则以这些 section 标题行取代 source 标题。列表高度受限于 composer 上方的可用空间,指针落在菜单与所在 composer 卡片之外即关闭菜单。该 slot 由 ui-conversation 的组合器条目拥有(锚点、children 声明、生命周期);其 SlotMap 类型合并放在本包的 `src/client/slots.ts`,因为依赖方向(ui-conversation → ui-input-trigger)不允许反向的类型导入。combobox 模式:焦点始终留在 textarea,行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载。 +MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot(列表类,会话 scope),菜单关闭期间渲染 null。键入式 trigger 会 seed 为该 trigger 注册的所有 source;程序化 launcher 只 seed 所请求的 source,并在菜单关闭或重新开始键入式 tracking 前,通过 controller 的 `launcher` 快照 store 发布该 source 名称。分组按可选的 `InputTriggerSource.order` 排序(越小越靠前,默认 0,同值保持注册序),组标题行经 `inputTriggers.menu` locale 命名空间本地化(未知 source 显示其原名)。`showGroupTitle: false` 会在 pending 与 ready 状态全程隐藏该行,ready 且候选项声明了 section 的组则以这些 section 标题行取代 source 标题。列表高度受限于 composer 上方的可用空间,指针落在菜单与所在 composer 卡片之外即关闭菜单。该 slot 由 ui-conversation 的组合器条目拥有(锚点、children 声明、生命周期);其 SlotMap 类型合并放在本包的 `src/client/slots.ts`,因为依赖方向(ui-conversation → ui-input-trigger)不允许反向的类型导入。combobox 模式:焦点始终留在 textarea,行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载,输入框 keydown 背后的键盘仲裁以 Enter 选中高亮候选项、以 Tab 把前导斜杠命令补全为纯文本。 `/client` 导出接口是插件主体(`apply`/`inject`)、`InputTriggerService`、`MenuViewInjected` 与约定类型。MenuView 本身是内部实现——slot 注册以闭包持有它。 diff --git a/packages/client/ui-input-trigger/src/client/controller.ts b/packages/client/ui-input-trigger/src/client/controller.ts index 68bb5a15cc..6177e62332 100644 --- a/packages/client/ui-input-trigger/src/client/controller.ts +++ b/packages/client/ui-input-trigger/src/client/controller.ts @@ -196,6 +196,9 @@ export class InputTriggerController { this.reduce({ type: 'close' }) return 'consumed' } + case 'tab': { + return this.complete(state) + } case 'enter': { if (state.highlight === null) return 'pass' this.pick(state.highlight.source, state.highlight.index) @@ -204,6 +207,30 @@ export class InputTriggerController { } } + /** + * Tab arbitration: complete the highlighted leading slash command as plain + * text — never a pick, because picking a bare (argument-less) command + * executes it immediately. Every other open-menu state consumes the key + * without acting so the browser's focus walk cannot escape the composer + * while the menu is up. + * @param state - live menu state. + * @returns 'consumed' whenever the menu is open; 'pass' is unreachable here + * (the caller checks the open state first). + */ + private complete(state: MenuState): ArbitrateOutcome { + const highlight = state.highlight + if (highlight === null) return 'consumed' + const hit = this.hit + if (hit === null || hit.trigger !== '/' || hit.position !== 'leading') return 'consumed' + const group = state.groups.find(g => g.source === highlight.source) + const candidate = group !== undefined && group.status === 'ready' ? group.items[highlight.index] : undefined + if (candidate === undefined) return 'consumed' + this.stopFetch() + this.reduce({ type: 'close' }) + this.execute({ text: `/${candidate.name} ` }, hit.span) + return 'consumed' + } + /** * Space adjudication over the just-completed leading token: polls sources' * matchSpace (hot state, synchronous) and dispatches the outcome itself. diff --git a/packages/client/ui-input-trigger/src/types.ts b/packages/client/ui-input-trigger/src/types.ts index 5f2dd6dfe6..899ad87818 100644 --- a/packages/client/ui-input-trigger/src/types.ts +++ b/packages/client/ui-input-trigger/src/types.ts @@ -237,7 +237,7 @@ export interface TriggerGuard { } /** Keys the menu intercepts while open (all behind the IME composition guard). */ -export type ArbitrateKey = 'up' | 'down' | 'enter' | 'escape' +export type ArbitrateKey = 'up' | 'down' | 'enter' | 'tab' | 'escape' /** consumed = key handled; pick-highlighted = enter picked the highlight; pass = let the input see it. */ export type ArbitrateOutcome = 'consumed' | 'pick-highlighted' | 'pass' diff --git a/packages/client/ui-input-trigger/tests/service.client.spec.ts b/packages/client/ui-input-trigger/tests/service.client.spec.ts index a03839d7bd..58f3a6eaf7 100644 --- a/packages/client/ui-input-trigger/tests/service.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/service.client.spec.ts @@ -701,6 +701,45 @@ describe('arbitrate', () => { expect(controller.menu.getSnapshot().open).toBe(false) }) + it('tab completes the highlighted leading slash command as text, never a pick', async () => { + const cmd = readySource('/', 'command', [{ name: 'goal' }, { name: 'plan' }], () => undefined) + const { controller, actx } = controllerBench([cmd.source]) + const texts: Array<{ text: string; span: unknown }> = [] + actx.on('slash/input-insert-text', (req) => { + texts.push(req) + return true + }) + controller.track('/g', 2, { tier: 'plain' }, 1) + await tick() + expect(controller.arbitrate('tab', false)).toBe('consumed') + expect(texts).toEqual([{ text: '/goal ', span: { start: 0, end: 2, draftRev: 1 } }]) + // No pick: picking a bare command executes it; tab only completes text. + expect(cmd.picks).toEqual([]) + expect(controller.menu.getSnapshot().open).toBe(false) + }) + + it('tab consumes without acting on a non-leading token or another trigger', async () => { + const inline = readySource('/', 'command', [{ name: 'goal' }], () => undefined) + const at = readySource('@', 'reference', [{ name: 'session' }], () => undefined) + const { controller, actx } = controllerBench([inline.source, at.source]) + const texts: unknown[] = [] + actx.on('slash/input-insert-text', (req) => { + texts.push(req) + return true + }) + controller.track('prefix /g', 9, { tier: 'plain' }, 1) + await tick() + expect(controller.arbitrate('tab', false)).toBe('consumed') + expect(inline.picks).toEqual([]) + expect(controller.menu.getSnapshot().open).toBe(true) + controller.track('@s', 2, { tier: 'plain' }, 1) + await tick() + expect(controller.arbitrate('tab', false)).toBe('consumed') + expect(at.picks).toEqual([]) + expect(texts).toEqual([]) + expect(controller.menu.getSnapshot().open).toBe(true) + }) + it('escape closes and consumes', async () => { const { controller } = await menuBench() expect(controller.arbitrate('escape', false)).toBe('consumed') @@ -709,19 +748,23 @@ describe('arbitrate', () => { it('IME composition passes every key untouched', async () => { const { controller } = await menuBench() - for (const key of ['up', 'down', 'enter', 'escape'] as const) { + for (const key of ['up', 'down', 'enter', 'tab', 'escape'] as const) { expect(controller.arbitrate(key, true)).toBe('pass') } expect(controller.menu.getSnapshot().open).toBe(true) }) - it('closed menu passes; an open menu without a highlight passes enter', () => { + it('closed menu passes; an open menu without a highlight consumes tab and passes enter', () => { const cmd = deferredSource('/', 'command') const { controller } = controllerBench([cmd.source]) expect(controller.arbitrate('enter', false)).toBe('pass') - // Open with the only group still pending: nothing to pick yet. + expect(controller.arbitrate('tab', false)).toBe('pass') + // Open with the only group still pending: nothing to pick or complete yet + // — tab is consumed so the focus walk cannot escape the composer. controller.track('/g', 2, { tier: 'plain' }, 1) expect(controller.arbitrate('enter', false)).toBe('pass') + expect(controller.arbitrate('tab', false)).toBe('consumed') + expect(controller.menu.getSnapshot().open).toBe(true) }) })