mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-09 04:02:35 +00:00
fix(client): complete highlighted commands with Tab
This commit is contained in:
+2
-2
@@ -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 .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
|
||||
2026-07-25-web-input-machine-and-slash-pipeline.md: 3508de5e8a3980a87c344c5b76c060f6119ee686
|
||||
2026-07-25-web-input-machine-and-slash-pipeline.zh.md: eebfdae780157dfd0dace1386169c5fee8c1d564
|
||||
2026-07-25-web-input-machine-and-slash-pipeline.md: 69899efcda42eb1087aaa68d1eba8c08dd14f361
|
||||
2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 7e37dd67a2d5a7943a8c601a890d2de7489b227d
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ Calls that stay un-evented (registry registration → explicit call → await):
|
||||
A trigger/menu/pick pipeline with zero knowledge of "commands":
|
||||
|
||||
- The service holds only the source registry (`InputTriggerSource{trigger: '/'|'@', name, order?, candidates, onPick, matchSpace?, matchEnter?}`; (trigger,name) unique; the optional `order` sorts the roster — lower first, default 0, ties keep registration order — and that sorted roster is both group order and polling order) and `sessionOf(sctx)`. Implementing a match hook IS the declaration of participation in space/enter adjudication; the pipeline polls in roster order, the first non-undefined answer wins, and no claimant means the default sink. matchSpace is synchronous (space fires mid-keystroke; hot cache only); matchEnter is asynchronous (it may await the source's own warmup, and a warmup failure rejects).
|
||||
- The controller holds the single authoritative hit (span included; retained for Space after the menu closes), the per-session menu store, the candidate-fetch generation, keyboard arbitration (combobox mode: focus stays in the composer surface, ↑↓/Enter/Escape are intercepted and all pass the IME composition guard, with the single exception Shift+Enter unconditionally going first), and pick orchestration (outcome → self-dispatched bail events). `toggleSource(name, syntheticHit)` is the chrome-launch path: it seeds only that registered source over the caller's composer selection and publishes `launcher = name` until close; ordinary typed tracking clears the launcher and restores the full trigger roster. Both paths render the same MenuView and execute the same `onPick` chain. A `dismiss()` verb backs MenuView's injected `onDismiss` (a pointer down outside both the menu and the surrounding composer card closes the menu; MenuView also localizes group titles through the `slash.menu` locale namespace and clamps its height to the viewport space above the composer via ui-primitives' `useAnchoredMaxHeight`); at each session scope's birth it runs `warm(projection)` once over the source roster — within that scope the projection holds only the stable sessionId, with no published/capability transitions; the scope disposer tears down the controller.
|
||||
- The controller holds the single authoritative hit (span included; retained for Space after the menu closes), the per-session menu store, the candidate-fetch generation, keyboard arbitration (combobox mode: focus stays in the composer surface; ↑↓/Enter/Escape are intercepted; Tab settles a highlighted completion, using the candidate's drill action when available and its ordinary pick otherwise, while no highlight preserves native focus traversal; all arbitration passes the IME composition guard, with the single exception Shift+Enter unconditionally going first), and pick orchestration (outcome → self-dispatched bail events). `toggleSource(name, syntheticHit)` is the chrome-launch path: it seeds only that registered source over the caller's composer selection and publishes `launcher = name` until close; ordinary typed tracking clears the launcher and restores the full trigger roster. Both paths render the same MenuView and execute the same `onPick` chain. A `dismiss()` verb backs MenuView's injected `onDismiss` (a pointer down outside both the menu and the surrounding composer card closes the menu; MenuView also localizes group titles through the `slash.menu` locale namespace and clamps its height to the viewport space above the composer via ui-primitives' `useAnchoredMaxHeight`); at each session scope's birth it runs `warm(projection)` once over the source roster — within that scope the projection holds only the stable sessionId, with no published/capability transitions; the scope disposer tears down the controller.
|
||||
- Trigger-detection word boundaries (`user@host` and URL `/` never trigger) and the guard tiers (plain: `/` everywhere + `@` inline / claimed: `/` suppressed, `@` live / frozen: none) are the frozen pure core.
|
||||
|
||||
### hub / facade: the resident shell and the strict-session input body
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ Status: implemented
|
||||
对「命令」零知识的触发/菜单/pick 流水线:
|
||||
|
||||
- 服务只有 source 注册表(`InputTriggerSource{trigger: '/'|'@', name, order?, candidates, onPick, matchSpace?, matchEnter?}`;(trigger,name) 唯一;可选 `order` 对 roster 排序——越小越靠前、默认 0、同值保持注册序——排序后的 roster 同时是组序与轮询序)与 `sessionOf(sctx)`。实现 match 钩子即参与空格/回车裁决的声明;流水线按 roster 序轮询,首个非 undefined 应答胜出,无人认领落 default sink。matchSpace 同步(空格在击键中触发,只许热缓存);matchEnter 异步(可 await 源自身预热,预热失败即 reject)。
|
||||
- controller 持有唯一权威 hit(含 span;菜单关闭后为 Space 保留)、每会话 menu store、候选 fetch generation、键盘仲裁(combobox 模式:焦点始终在编辑器表面,↑↓/Enter/Escape 拦截且全程过 IME composition 守卫,唯一例外 Shift+Enter 无条件先行),以及 pick 编排(outcome → 自派 bail 事件)。`toggleSource(name, syntheticHit)` 是 chrome launcher 路径:它基于调用方的编辑器 selection,只 seed 对应的已注册 source,并发布 `launcher = name` 直至关闭;普通的键入式 tracking 会清除 launcher 并恢复完整的 trigger roster。两条路径渲染同一个 MenuView,并执行同一条 `onPick` 链。`dismiss()` 动词支撑 MenuView 注入的 `onDismiss`(指针落在菜单与所在 composer 卡片之外即关闭菜单;MenuView 还经 `slash.menu` locale 命名空间本地化组标题,并经 ui-primitives 的 `useAnchoredMaxHeight` 把高度收敛到 composer 上方的视口空间);每个会话作用域出生时对 source roster 做一次 `warm(projection)`,projection 在该 scope 内只有稳定的 sessionId,无 published/能力跃迁;scope disposer 拆除 controller。
|
||||
- controller 持有唯一权威 hit(含 span;菜单关闭后为 Space 保留)、每会话 menu store、候选 fetch generation、键盘仲裁(combobox 模式:焦点始终在编辑器表面;↑↓/Enter/Escape 会被拦截;Tab 会选定高亮补全项,候选项可下钻时走 drill 动作,否则走普通 pick,无高亮时保留原生焦点遍历;所有仲裁都经过 IME composition 守卫,唯一例外是 Shift+Enter 无条件先行),以及 pick 编排(outcome → 自派 bail 事件)。`toggleSource(name, syntheticHit)` 是 chrome launcher 路径:它基于调用方的编辑器 selection,只 seed 对应的已注册 source,并发布 `launcher = name` 直至关闭;普通的键入式 tracking 会清除 launcher 并恢复完整的 trigger roster。两条路径渲染同一个 MenuView,并执行同一条 `onPick` 链。`dismiss()` 动词支撑 MenuView 注入的 `onDismiss`(指针落在菜单与所在 composer 卡片之外即关闭菜单;MenuView 还经 `slash.menu` locale 命名空间本地化组标题,并经 ui-primitives 的 `useAnchoredMaxHeight` 把高度收敛到 composer 上方的视口空间);每个会话作用域出生时对 source roster 做一次 `warm(projection)`,projection 在该 scope 内只有稳定的 sessionId,无 published/能力跃迁;scope disposer 拆除 controller。
|
||||
- 触发检测词边界(`user@host`、URL `/` 永不触发)、守卫分档(plain:`/` 到处 + `@` 行内 / claimed:`/` 抑制、`@` 活 / frozen:全无)为冻结纯核。
|
||||
|
||||
### hub / facade:常驻外壳与严格会话输入体
|
||||
|
||||
@@ -43,15 +43,19 @@ describe('web e2e: /goal human transcript presentation', () => {
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('shows the bare input and result from a fresh session without a model turn', async () => {
|
||||
it('completes with Tab and shows the bare input and result without a model turn', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-command-presentation'))
|
||||
await expect.poll(() => page.getByText('Into the Unknown', { exact: false }).count(), {
|
||||
timeout: 15_000,
|
||||
}).toBe(1)
|
||||
const input = page.locator('[data-composer-input]').first()
|
||||
await input.fill('/goal')
|
||||
await input.press('Enter')
|
||||
await input.fill('/go')
|
||||
const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
|
||||
await menu.getByRole('option', { name: 'goal set or view the goal for a long-running task' })
|
||||
.waitFor({ timeout: 10_000 })
|
||||
await input.press('Tab')
|
||||
await expect.poll(() => input.textContent()).toBe('/goal ')
|
||||
await expect.poll(() => menu.count()).toBe(0)
|
||||
await input.press('Enter')
|
||||
|
||||
const commandInput = page.locator('[data-command-input]')
|
||||
|
||||
@@ -68,7 +68,7 @@ export function registerComposerKeymap(editor: LexicalEditor, handlers: Composer
|
||||
|
||||
const arrow = (key: ArbitrateKey) => (event: KeyboardEvent | null): boolean => {
|
||||
const inComposition = event !== null && isComposingEvent(event, recentlyComposing)
|
||||
if (handlers.arbitrate(key, inComposition) === 'consumed') {
|
||||
if (handlers.arbitrate(key, inComposition) !== 'pass') {
|
||||
event?.preventDefault()
|
||||
return true
|
||||
}
|
||||
@@ -84,8 +84,8 @@ export function registerComposerKeymap(editor: LexicalEditor, handlers: Composer
|
||||
}),
|
||||
editor.registerCommand(KEY_ARROW_UP_COMMAND, arrow('up'), COMMAND_PRIORITY_CRITICAL),
|
||||
editor.registerCommand(KEY_ARROW_DOWN_COMMAND, arrow('down'), COMMAND_PRIORITY_CRITICAL),
|
||||
// Tab drills into a drillable highlighted row; otherwise it passes so the
|
||||
// browser keeps its native focus traversal.
|
||||
// Tab acts only when the trigger menu has a highlighted completion;
|
||||
// otherwise it passes so the browser keeps its native focus traversal.
|
||||
editor.registerCommand(KEY_TAB_COMMAND, arrow('tab'), COMMAND_PRIORITY_CRITICAL),
|
||||
editor.registerCommand(KEY_ESCAPE_COMMAND, (event) => {
|
||||
// Escape layering: an open overlay closes; claimed without an overlay
|
||||
|
||||
@@ -43,6 +43,7 @@ describe('keymap keydown routing', () => {
|
||||
registerPlainText(editor)
|
||||
const arbitrate = vi.fn<(key: string, composing: boolean) => 'consumed' | 'pick-highlighted' | 'pass'>()
|
||||
.mockReturnValueOnce('consumed')
|
||||
.mockReturnValueOnce('pick-highlighted')
|
||||
.mockReturnValue('pass')
|
||||
registerComposerKeymap(editor, {
|
||||
arbitrate,
|
||||
@@ -56,8 +57,9 @@ describe('keymap keydown routing', () => {
|
||||
const consumed = fireEvent.keyDown(root, { key: 'Tab', keyCode: 9 })
|
||||
expect(arbitrate).toHaveBeenCalledWith('tab', false)
|
||||
expect(consumed).toBe(false) // consumed: preventDefault fired
|
||||
const picked = fireEvent.keyDown(root, { key: 'Tab', keyCode: 9 })
|
||||
expect(picked).toBe(false) // picked: the completion replaces native traversal
|
||||
const passed = fireEvent.keyDown(root, { key: 'Tab', keyCode: 9 })
|
||||
expect(passed).toBe(true) // pass: the browser keeps native focus traversal
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -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: 1f316229327d33f48e0950a2c27e143c6c991f99
|
||||
README.zh.md: 324541120406eac8677afcfda0967540dd74848e
|
||||
README.md: 83fcffbd1dbf20ad070ef9edccb74f2e5beed9e6
|
||||
README.zh.md: a7c2bbad1a1a982a99327d21d7afd6466cbd201e
|
||||
|
||||
@@ -29,7 +29,7 @@ Mount this plugin alongside `ui-conversation`; the menu then appears in the inpu
|
||||
|
||||
### Keyboard and mouse
|
||||
|
||||
The composer surface keeps focus while the menu is open: rows pick on mousedown, the highlight rides `aria-activedescendant`, and a pointer press outside both the menu and the composer card dismisses it. Space and Enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order; the first non-undefined answer wins, and a source can refuse a submission it cannot consume whole. A candidate declaring `drill: true` carries a second verb beside the settling pick: its trailing chevron and the Tab key route the same row through `onPick` with `action: 'drill'` (every other path reports `'pick'`), and Tab passes untouched on rows without the flag so native focus traversal survives. A source implementing the optional `header` hook additionally publishes crumbs above its group: the pipeline re-polls it on every hit with the live query and whether a drill, rather than typing, produced it, and a crumb pick routes back through `onPick` with `action: 'drill'`.
|
||||
The composer surface keeps focus while the menu is open: rows pick on mousedown, the highlight rides `aria-activedescendant`, and a pointer press outside both the menu and the composer card dismisses it. Space and Enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order; the first non-undefined answer wins, and a source can refuse a submission it cannot consume whole. Tab acts on the highlighted completion: a candidate declaring `drill: true` routes through `onPick` with `action: 'drill'`, while an ordinary candidate settles through `action: 'pick'`; without a highlight, Tab passes untouched so native focus traversal survives. A drillable row's trailing chevron exposes the same second verb to pointer users. A source implementing the optional `header` hook additionally publishes crumbs above its group: the pipeline re-polls it on every hit with the live query and whether a drill, rather than typing, produced it, and a crumb pick routes back through `onPick` with `action: 'drill'`.
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ kind: "package-reference"
|
||||
|
||||
### 键盘与鼠标
|
||||
|
||||
菜单打开期间 composer 表面保持焦点:行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载,指针落在菜单与所在 composer 卡片之外即关闭菜单。空格与回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子;第一个非 undefined 的应答胜出,source 也可以拒绝它无法整体消费的提交。声明 `drill: true` 的候选行在选定 pick 之外携带第二个动词:行尾的 chevron 与 Tab 键把同一行以 `action: 'drill'` 送入 `onPick`(其余路径一律报告 `'pick'`);未声明该标记的行上 Tab 原样放行,原生焦点遍历不受影响。实现可选 `header` 钩子的 source 还会在其分组上方发布面包屑:管线在每次命中时用实时查询、以及该查询由下钻还是由键入产生这一事实重新询问它,点击面包屑经 `onPick` 以 `action: 'drill'` 回到该 source。
|
||||
菜单打开期间 composer 表面保持焦点:行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载,指针落在菜单与所在 composer 卡片之外即关闭菜单。空格与回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子;第一个非 undefined 的应答胜出,source 也可以拒绝它无法整体消费的提交。Tab 会作用于高亮补全项:声明 `drill: true` 的候选项以 `action: 'drill'` 进入 `onPick`,普通候选项则以 `action: 'pick'` 完成选定;没有高亮项时 Tab 原样放行,原生焦点遍历不受影响。可下钻行尾的 chevron 向指针用户提供同一个动词。实现可选 `header` 钩子的 source 还会在其分组上方发布面包屑:管线在每次命中时用实时查询、以及该查询由下钻还是由键入产生这一事实重新询问它,点击面包屑经 `onPick` 以 `action: 'drill'` 回到该 source。
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -214,7 +214,11 @@ export class InputTriggerController {
|
||||
* Keyboard arbitration while the menu is open.
|
||||
* @param key - intercepted key.
|
||||
* @param composing - inside IME composition: everything passes.
|
||||
* @returns consumed / pick-highlighted / pass.
|
||||
* @returns `pass` when the browser keeps the key (closed menu, no
|
||||
* highlight, or a vanished candidate), `consumed` when the menu handled
|
||||
* the key without a settling pick (move, close, drill descent, or a
|
||||
* pending-refinement no-op), or `pick-highlighted` when the highlighted
|
||||
* candidate settled and the menu closed.
|
||||
*/
|
||||
arbitrate(key: ArbitrateKey, composing: boolean): ArbitrateOutcome {
|
||||
if (composing || this.disposed) return 'pass'
|
||||
@@ -245,16 +249,19 @@ export class InputTriggerController {
|
||||
return 'pick-highlighted'
|
||||
}
|
||||
case 'tab': {
|
||||
// Tab drills into the highlighted candidate when it offers descent;
|
||||
// otherwise the key passes so native focus behavior is untouched.
|
||||
if (state.highlight === null) return 'pass'
|
||||
const group = state.groups.find(g => g.source === state.highlight?.source)
|
||||
const item = group !== undefined && group.status === 'ready'
|
||||
? group.items[state.highlight.index]
|
||||
: undefined
|
||||
if (item?.drill !== true) return 'pass'
|
||||
this.pick(state.highlight.source, state.highlight.index, 'drill')
|
||||
return 'consumed'
|
||||
// Pending refinement keeps the stale highlight visible: consume the
|
||||
// gesture rather than pick a stale row or let Tab move focus away.
|
||||
if (group === undefined || group.status !== 'ready') return 'consumed'
|
||||
const item = group.items[state.highlight.index]
|
||||
if (item === undefined) return 'pass'
|
||||
if (item.drill === true) {
|
||||
this.pick(state.highlight.source, state.highlight.index, 'drill')
|
||||
return 'consumed'
|
||||
}
|
||||
this.pick(state.highlight.source, state.highlight.index)
|
||||
return 'pick-highlighted'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -889,7 +889,7 @@ describe('arbitrate', () => {
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('tab drills into a drillable highlight and passes on plain rows', async () => {
|
||||
it('tab drills into a drillable highlight and picks a plain completion', async () => {
|
||||
const drillable = readySource('/', 'command', [{ name: 'src', drill: true }, { name: 'plan' }], () => undefined)
|
||||
const { controller } = controllerBench([drillable.source])
|
||||
controller.track('/s', 2, { tier: 'plain' }, 1)
|
||||
@@ -897,12 +897,37 @@ describe('arbitrate', () => {
|
||||
expect(controller.arbitrate('tab', false)).toBe('consumed')
|
||||
expect(drillable.picks[0]!.action).toBe('drill')
|
||||
expect(drillable.picks[0]!.candidate.name).toBe('src')
|
||||
// Plain row (no drill flag): the key passes so native focus stays intact.
|
||||
// Plain row (no drill flag): Tab settles the highlighted completion.
|
||||
controller.track('/s', 2, { tier: 'plain' }, 2)
|
||||
await tick()
|
||||
controller.arbitrate('down', false)
|
||||
expect(controller.arbitrate('tab', false)).toBe('pass')
|
||||
expect(drillable.picks).toHaveLength(1)
|
||||
expect(controller.arbitrate('tab', false)).toBe('pick-highlighted')
|
||||
expect(drillable.picks[1]!.action).toBe('pick')
|
||||
expect(drillable.picks[1]!.candidate.name).toBe('plan')
|
||||
expect(controller.menu.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('tab during a pending refinement is consumed: no pick, no focus traversal', async () => {
|
||||
const picks: string[] = []
|
||||
const cmd = deferredSource('/', 'command', {
|
||||
onPick: (pick) => { picks.push(pick.candidate.name); return undefined },
|
||||
})
|
||||
const { controller } = controllerBench([cmd.source])
|
||||
controller.track('/g', 2, { tier: 'plain' }, 1)
|
||||
cmd.pending[0]!.resolve([{ name: 'goal' }, { name: 'plan' }])
|
||||
await tick()
|
||||
expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 0 })
|
||||
// Refinement: previous rows and highlight stay visible while the fetch pends.
|
||||
controller.track('/go', 3, { tier: 'plain' }, 2)
|
||||
expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 0 })
|
||||
expect(controller.arbitrate('tab', false)).toBe('consumed')
|
||||
expect(picks).toHaveLength(0)
|
||||
expect(controller.menu.getSnapshot().open).toBe(true)
|
||||
// Settled: the same gesture settles the highlighted completion.
|
||||
cmd.pending[1]!.resolve([{ name: 'goal' }])
|
||||
await tick()
|
||||
expect(controller.arbitrate('tab', false)).toBe('pick-highlighted')
|
||||
expect(picks).toEqual(['goal'])
|
||||
})
|
||||
|
||||
it('a settling pick reports the pick action', async () => {
|
||||
@@ -913,19 +938,21 @@ 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', 'escape', 'tab'] 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 passes picking keys', () => {
|
||||
const cmd = deferredSource('/', 'command')
|
||||
const { controller } = controllerBench([cmd.source])
|
||||
expect(controller.arbitrate('enter', false)).toBe('pass')
|
||||
expect(controller.arbitrate('tab', false)).toBe('pass')
|
||||
// Open with the only group still pending: nothing to pick yet.
|
||||
controller.track('/g', 2, { tier: 'plain' }, 1)
|
||||
expect(controller.arbitrate('enter', false)).toBe('pass')
|
||||
expect(controller.arbitrate('tab', false)).toBe('pass')
|
||||
})
|
||||
|
||||
it('enter during a pending refinement is consumed: no pick, no submit fallthrough', async () => {
|
||||
|
||||
Reference in New Issue
Block a user