Sfoglia il codice sorgente

feat(client): accept and leave with Enter, Tab, and Escape in the composer

The popup select panel and the model seat hold focus while open, and their Tab
fell through to the browser's traversal, leaving an open surface that only a
pointer press clears. Both now settle on Enter or Tab, leave on Escape or
Shift+Tab, walk with the arrows, and park on the value in use: the panel's
highlight on the row its options mark current, the seat's drilled pane on its
checked row, with the keyboard handed back to the cell that drilled in when the
pane is left.

Dismissal rides the conversation's own focus(), so the caret returns where the
draft left it instead of at the start.
liukx0205 1 settimana fa
parent
commit
c47a469aec

+ 6 - 0
.agents/notes/implemented/feature/2026-09-14-composer-selection-keyboard.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-09-14-composer-selection-keyboard.md
+2026-09-14-composer-selection-keyboard.md: da5c7267bf5506024183e11271dd20357f351104
+2026-09-14-composer-selection-keyboard.zh.md: 0723b390a9cf4abe38322149a06d768d808787d3

File diff suppressed because it is too large
+ 44 - 0
.agents/notes/implemented/feature/2026-09-14-composer-selection-keyboard.md


File diff suppressed because it is too large
+ 44 - 0
.agents/notes/implemented/feature/2026-09-14-composer-selection-keyboard.zh.md


+ 33 - 2
apps/web/tests/declared-reasoning.e2e.ts

@@ -76,15 +76,46 @@ describe.skipIf(MODE === 'record')('web e2e: declared reasoning efforts reach th
     const snapshot = await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
 
-    // Picking a level is the same gesture that saves the default selection, so
+    // Keyboard: the clicked cell unmounts with its pane, so the drilled pane's
+    // checked row takes the focus it left behind. ↑↓ walk the rows from there
+    // and Tab settles the focused one exactly as Enter would.
+    await expect.poll(
+      () => levels.nth(0).evaluate(element => element === document.activeElement),
+      { timeout: 10_000 },
+    ).toBe(true)
+    await page.keyboard.press('ArrowDown')
+    expect(await levels.nth(1).evaluate(element => element === document.activeElement)).toBe(true)
+    await page.keyboard.press('ArrowDown')
+    expect(await levels.nth(2).evaluate(element => element === document.activeElement)).toBe(true)
+
+    // Settling with Tab is the same gesture that saves the default selection, so
     // the effort lands in the Agent default Settings section beside provider/model.
-    await page.getByRole('menuitemradio', { name: 'High' }).click()
+    await page.keyboard.press('Tab')
+    await expect.poll(() => levels.count(), { timeout: 10_000 }).toBe(0)
     await expect.poll(
       async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'),
       { timeout: 10_000 },
     ).toContain('reasoningEffort: high')
     await expect.poll(() => trigger.getAttribute('aria-label'), { timeout: 10_000 })
       .toBe('选择模型,当前 Acme Think,推理等级 High')
+
+    // Reopening the drilled pane parks the keyboard on the level in use, and
+    // Shift+Tab walks back out like Escape: to the drilled cell, then closed.
+    await trigger.click()
+    await page.getByRole('menuitem', { name: /推理等级/ }).click()
+    const high = page.getByRole('menuitemradio', { name: 'High' })
+    await expect.poll(
+      () => high.evaluate(element => element === document.activeElement),
+      { timeout: 10_000 },
+    ).toBe(true)
+    await page.keyboard.press('Shift+Tab')
+    await expect.poll(
+      () => page.getByRole('menuitem', { name: /推理等级/ })
+        .evaluate(element => element === document.activeElement),
+      { timeout: 10_000 },
+    ).toBe(true)
+    await page.keyboard.press('Shift+Tab')
+    await expect.poll(() => page.getByRole('menu').count(), { timeout: 10_000 }).toBe(0)
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)
 

+ 2 - 2
packages/client/ui-commands/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-commands/README.md
-README.md: f802179e08009cd4db8a3b786e52e81b26bb21da
-README.zh.md: 1dee58ab42d67a62099d57d4e631e440d3349f21
+README.md: f8cba29179f9960042a4a9e67e86496591a76d08
+README.zh.md: 1cea3ae7aea857f2b2d219abf0a7192c2f222792

+ 1 - 1
packages/client/ui-commands/README.md

@@ -25,7 +25,7 @@ Typing a `/` command opens a registered popup, a client action, a host command's
 <a id="use-this-package"></a>
 ## Use this package
 
-Mount this plugin alongside `ui-input-trigger` and `ui-conversation`; the `/` source then appears in the trigger menu, and business packages register their command surfaces through `ctx.commandUi`. Typing `/model` opens the registered popup; a host command with an argument claim opens its input or executes directly. The composer's `+` button and a typed `/` open the same menu: an Add section (File, Goal, Plan, Feedback) and a Commands section (Compact, Permission, Model, Export) in usage order, each row with a glyph, a localized title and description, and the command name as an alias where the localized title differs from it.
+Mount this plugin alongside `ui-input-trigger` and `ui-conversation`; the `/` source then appears in the trigger menu, and business packages register their command surfaces through `ctx.commandUi`. Typing `/model` opens the registered popup; a host command with an argument claim opens its input or executes directly. The popup holds composer focus: typing filters the loaded rows locally, `↑`/`↓` walk them, Enter and `Tab` accept the highlighted row, and Escape and `Shift+Tab` return to the composer. The highlight opens on the row the options mark as the session's current value, so accepting on a freshly opened panel confirms it. The composer's `+` button and a typed `/` open the same menu: an Add section (File, Goal, Plan, Feedback) and a Commands section (Compact, Permission, Model, Export) in usage order, each row with a glyph, a localized title and description, and the command name as an alias where the localized title differs from it.
 
 ### Kinds and decorations
 

+ 1 - 1
packages/client/ui-commands/README.zh.md

@@ -25,7 +25,7 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-与 `ui-input-trigger` 及 `ui-conversation` 一起挂载本插件;`/` source 随即出现在触发菜单中,业务包经 `ctx.commandUi` 注册自己的命令表面。键入 `/model` 打开已注册的弹窗;带参数声明的宿主命令打开其输入或直接执行。composer 的 `+` 按钮与键入的 `/` 打开同一个菜单:「添加」小节(文件、目标、计划、反馈)与「指令」小节(压缩、权限、模型、下载日志)按使用频次排列,每行带图标、本地化的标题与说明,本地化标题与命令名不同时还显示命令名作为别名。
+与 `ui-input-trigger` 及 `ui-conversation` 一起挂载本插件;`/` source 随即出现在触发菜单中,业务包经 `ctx.commandUi` 注册自己的命令表面。键入 `/model` 打开已注册的弹窗;带参数声明的宿主命令打开其输入或直接执行。弹窗持有 composer 焦点:键入即在已加载的行上本地筛选,`↑`/`↓` 在行间移动,回车与 `Tab` 接受高亮行,Escape 与 `Shift+Tab` 把焦点还给 composer。高亮落在选项标记为会话当前值的行上,因此在刚打开的弹窗上接受即确认当前值。composer 的 `+` 按钮与键入的 `/` 打开同一个菜单:「添加」小节(文件、目标、计划、反馈)与「指令」小节(压缩、权限、模型、下载日志)按使用频次排列,每行带图标、本地化的标题与说明,本地化标题与命令名不同时还显示命令名作为别名。
 
 ### 种类与装饰
 

+ 15 - 5
packages/client/ui-commands/src/client/PopupSelectView.tsx

@@ -3,11 +3,12 @@
  * store into the conversation.input.overlay anchor. Unlike the slash menu
  * (combobox — textarea keeps focus), this shell HOLDS focus while open: the
  * inner search input takes focus, plain typing filters the loaded options
- * locally, Enter/↑↓ drive the filtered highlight (scrolled into view), Escape
- * dismisses back to the composer, and ←→ keep the search input's native
- * caret. Any pointer interaction outside the box dismisses (the click's own
- * target takes focus). Closed state renders null; the overlay slot stays
- * mounted. The card height clamps to the space above the composer.
+ * locally, Enter and Tab accept the filtered highlight, ↑↓ walk it (wrapping,
+ * scrolled into view), and Escape and Shift+Tab dismiss back to the composer.
+ * ←→ keep the search input's native caret. Any pointer interaction outside the
+ * box dismisses (the click's own target takes focus). Closed state renders
+ * null; the overlay slot stays mounted. The card height clamps to the space
+ * above the composer.
  */
 import { useEffect, useRef } from 'react'
 import { useSyncExternalStore } from 'react'
@@ -95,6 +96,15 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) {
         ev.preventDefault()
         void popup.select(state.active)
         return
+      // Tab settles like Enter and Shift+Tab dismisses like Escape, so the
+      // card's keys mean what they mean in the composer. Both must be consumed:
+      // the shell HOLDS focus, and native traversal would leave an open card
+      // whose search input lost focus.
+      case 'Tab':
+        ev.preventDefault()
+        if (ev.shiftKey) popup.dismiss({ focusComposer: true })
+        else void popup.select(state.active)
+        return
       case 'Escape':
         ev.preventDefault()
         popup.dismiss({ focusComposer: true })

+ 24 - 3
packages/client/ui-commands/src/client/popup.ts

@@ -63,7 +63,12 @@ export interface PopupState {
   readonly options: readonly SelectOption[]
   /** Local filter text over the loaded options. */
   readonly search: string
-  /** Highlight index into the filtered row list (0 when empty/pending). */
+  /**
+   * Highlight index into the filtered row list: 0 until options land; afterwards
+   * the row the loaded list marks as the current value
+   * ({@link SelectOption.active}), else 0. A search rebases it to the top of the
+   * filtered rows.
+   */
   readonly active: number
   /** A select() settlement is in flight: further select/search/highlight no-op until it settles. */
   readonly submitting: boolean
@@ -93,6 +98,20 @@ export function filterOptions(options: readonly SelectOption[], search: string):
   return options.filter(o => o.label.toLowerCase().includes(query) || (o.detail?.toLowerCase().includes(query) ?? false))
 }
 
+/**
+ * Highlight index for a freshly loaded row list: the row marked as the current
+ * value when the live search still shows it, else the top row. Opening parks
+ * the highlight on the value the session already uses, so an accept gesture
+ * made without looking confirms that value instead of the topmost row.
+ * @param options - the loaded rows.
+ * @param search - the shell's live filter text (non-empty after a retry).
+ * @returns index into the filtered rows.
+ */
+function currentIndex(options: readonly SelectOption[], search: string): number {
+  const at = filterOptions(options, search).findIndex(option => option.active === true)
+  return at === -1 ? 0 : at
+}
+
 /** One open shell's bindings (spec + open-time context + segment snapshot + options-fetch abort). */
 interface OpenBinding<TCtx> {
   readonly command: string
@@ -145,7 +164,8 @@ export class PopupSelectController<TCtx = unknown> {
     binding.spec.options(binding.context, binding.abort.signal).then(
       (options) => {
         if (this.binding !== binding) return
-        this.state.set({ ...this.state.getSnapshot(), status: 'ready', options, active: 0, error: null })
+        const current = this.state.getSnapshot()
+        this.state.set({ ...current, status: 'ready', options, active: currentIndex(options, current.search), error: null })
       },
       (error: unknown) => {
         if (this.binding !== binding) return
@@ -166,7 +186,8 @@ export class PopupSelectController<TCtx = unknown> {
 
   /**
    * Replace the local search text (pure local filter — the provider is never
-   * re-queried) and rebase the highlight onto the new filtered list.
+   * re-queried) and rebase the highlight to the top of the new filtered list:
+   * typing searches for something other than the current value.
    * @param search - the shell search input's text.
    */
   setSearch(search: string): void {

+ 3 - 19
packages/client/ui-commands/src/client/service.ts

@@ -156,7 +156,7 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
    * Resolve the per-session popup controller (lazy; dies with the session
    * scope). The controller's consume callback dispatches the scoped
    * consume-token event back to this session; focusComposer reaches the
-   * composer through the overlay slot currency.
+   * session's composer through the conversation input face.
    * @param actx - session-scope ctx.
    * @returns the resident controller.
    */
@@ -173,33 +173,17 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
           ? { kind: 'span', span: segment.span }
           : { kind: 'bare-token', token: segment.token },
       }) === true,
-      focusComposer: () => { this.focusHooks.get(id)?.() },
+      // The shell took the keyboard; the composer restores it, caret included.
+      focusComposer: () => { actx.get('conversation')?.input.for(actx).focus() },
     })
     popups.set(id, controller)
     actx.effect(() => () => {
       controller.dispose()
       popups.delete(id)
-      this.focusHooks.delete(id)
     }, 'command: session popup')
     return controller
   }
 
-  /** Composer focus hooks by session (the overlay wiring binds the textarea focus here). */
-  private readonly focusHooks = new Map<SessionId, () => void>()
-
-  /**
-   * Bind one session's composer-focus hook (overlay slot wiring; unbind on unmount).
-   * @param id - session id.
-   * @param focus - textarea focus callback.
-   * @returns the unbind disposer.
-   */
-  bindComposerFocus(id: SessionId, focus: () => void): () => void {
-    this.focusHooks.set(id, focus)
-    return () => {
-      if (this.focusHooks.get(id) === focus) this.focusHooks.delete(id)
-    }
-  }
-
   /**
    * Menu candidates: host catalog + contribution availability, built-in rows
    * localized, then position filtering; sections for an empty query, the

+ 39 - 7
packages/client/ui-commands/tests/popup-view.client.spec.tsx

@@ -2,8 +2,9 @@
 /**
  * PopupSelectView interaction spec: the search input takes
  * focus on open and plain typing filters locally, ↑↓ move the filtered
- * highlight while ←→ stay native to the input, Enter selects single-flight,
- * Escape dismisses back through focusComposer, outside pointerdown dismisses
+ * highlight while ←→ stay native to the input, Enter and Tab select
+ * single-flight, Escape and Shift+Tab dismiss back through focusComposer,
+ * outside pointerdown dismisses
  * plainly, the submitting/failed states render pending text and a working
  * retry button, the highlighted row scrolls into view, and the card height
  * clamps to the space above the composer.
@@ -112,24 +113,56 @@ describe('PopupSelectView', () => {
 
   it('ArrowUp/Down move the filtered highlight; ArrowLeft/Right are left to the native caret', async () => {
     const { search } = await mountOpen()
+    // Open parks the highlight on the current-value row (Light, index 1).
+    expect(screen.getAllByRole('option')[1]!.getAttribute('aria-selected')).toBe('true')
     act(() => { fireEvent.keyDown(search, { key: 'ArrowDown' }) })
     let options = screen.getAllByRole('option')
-    expect(options[1]!.getAttribute('aria-selected')).toBe('true')
+    expect(options[2]!.getAttribute('aria-selected')).toBe('true')
     act(() => { fireEvent.keyDown(search, { key: 'ArrowUp' }) })
     options = screen.getAllByRole('option')
-    expect(options[0]!.getAttribute('aria-selected')).toBe('true')
+    expect(options[1]!.getAttribute('aria-selected')).toBe('true')
     // fireEvent returns false when preventDefault was called: arrow left/right must NOT be intercepted.
     expect(fireEvent.keyDown(search, { key: 'ArrowLeft' })).toBe(true)
     expect(fireEvent.keyDown(search, { key: 'ArrowRight' })).toBe(true)
   })
 
+  it('Tab accepts the highlighted row like Enter; Shift+Tab dismisses like Escape', async () => {
+    const onSelect = vi.fn()
+    const leaving = await mountOpen({ onSelect })
+    // false = preventDefault ran: the shell holds focus, so Tab may not fall through.
+    // Shift+Tab leaves without settling: no pick, focus back to the composer.
+    expect(fireEvent.keyDown(leaving.search, { key: 'Tab', shiftKey: true })).toBe(false)
+    expect(onSelect).not.toHaveBeenCalled()
+    expect(leaving.focusComposer).toHaveBeenCalledTimes(1)
+    expect(leaving.view.container.childElementCount).toBe(0)
+    cleanup()
+
+    const settling = await mountOpen({ onSelect })
+    // Tab settles the parked highlight — the current value — exactly like Enter.
+    await act(async () => { fireEvent.keyDown(settling.search, { key: 'Tab' }) })
+    expect(onSelect).toHaveBeenCalledExactlyOnceWith(OPTIONS[1], 'ctx-A')
+    expect(settling.consume).toHaveBeenCalledExactlyOnceWith(SEGMENT)
+    expect(settling.focusComposer).toHaveBeenCalledTimes(1)
+    expect(settling.view.container.childElementCount).toBe(0)
+  })
+
+  it('Tab is consumed while the rows are still loading: no pick, no focus escape', async () => {
+    const popup = new PopupSelectController<string>({ consume: () => true, focusComposer: () => {} })
+    render(<PopupSelectView popup={popup} t={t} />)
+    await act(async () => { popup.open('theme', spec({ options: () => new Promise(() => {}) }), 'ctx-A', SEGMENT) })
+    const search = screen.getByRole('textbox', { name: '筛选选项' })
+    expect(fireEvent.keyDown(search, { key: 'Tab' })).toBe(false)
+    expect(document.activeElement).toBe(search)
+    expect(screen.getByText('正在加载选项…')).toBeTruthy()
+  })
+
   it('scrolls the highlighted row into view when the highlight moves', async () => {
     const { search } = await mountOpen()
     scrollIntoView.mockClear()
     act(() => { fireEvent.keyDown(search, { key: 'ArrowDown' }) })
     const options = screen.getAllByRole('option')
     expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' })
-    expect(scrollIntoView.mock.instances.at(-1)).toBe(options[1])
+    expect(scrollIntoView.mock.instances.at(-1)).toBe(options[2])
   })
 
   it('caps the card height at the design maximum when the composer sits low enough', async () => {
@@ -144,12 +177,11 @@ describe('PopupSelectView', () => {
     expect(screen.getByLabelText('/theme 选项').style.maxHeight).toBe('188px')
   })
 
-  it('Enter selects the highlighted row: onSelect, consume, close, focusComposer', async () => {
+  it('Enter accepts the parked highlight — the current value on open — then consumes, closes, and refocuses', async () => {
     const seen: Array<{ option: SelectOption; context: string }> = []
     const { view, search, consume, focusComposer } = await mountOpen({
       onSelect: (option, context) => { seen.push({ option, context }) },
     })
-    act(() => { fireEvent.keyDown(search, { key: 'ArrowDown' }) })
     await act(async () => { fireEvent.keyDown(search, { key: 'Enter' }) })
     expect(seen).toEqual([{ option: OPTIONS[1], context: 'ctx-A' }])
     expect(consume).toHaveBeenCalledExactlyOnceWith(SEGMENT)

+ 38 - 7
packages/client/ui-commands/tests/popup.client.spec.ts

@@ -65,7 +65,37 @@ describe('open and options load', () => {
     expect(popup.state.getSnapshot()).toMatchObject({ open: true, command: 'theme', status: 'pending', search: '', submitting: false, error: null })
     release(OPTIONS)
     await Promise.resolve()
-    expect(popup.state.getSnapshot()).toMatchObject({ status: 'ready', options: OPTIONS, active: 0 })
+    // The highlight parks on the row the list marks as the current value
+    // (Light at index 1), not on the topmost row.
+    expect(popup.state.getSnapshot()).toMatchObject({ status: 'ready', options: OPTIONS, active: 1 })
+  })
+
+  it('parks the highlight on the top row when no row marks the current value', async () => {
+    const rows: SelectOption[] = [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }]
+    const popup = new PopupSelectController<Ctx>(makeDeps())
+    popup.open('theme', spec({ options: () => Promise.resolve(rows) }), CTX_A, SEGMENT)
+    await Promise.resolve()
+    expect(popup.state.getSnapshot().active).toBe(0)
+  })
+
+  it('a retry re-parks the highlight inside the search text it retained', async () => {
+    let attempts = 0
+    const rows: SelectOption[] = [
+      { id: 'sun', label: 'Sun' },
+      { id: 'moon', label: 'Moon', active: true },
+      { id: 'star', label: 'Star' },
+    ]
+    const { popup } = await readyPopup({
+      options: () => {
+        attempts += 1
+        return attempts === 1 ? Promise.reject(new Error('directory down')) : Promise.resolve(rows)
+      },
+    })
+    await Promise.resolve()
+    popup.setSearch('n') // Sun, Moon — the current value sits at filtered index 1
+    popup.retry()
+    await Promise.resolve()
+    expect(popup.state.getSnapshot()).toMatchObject({ status: 'ready', search: 'n', active: 1 })
   })
 
   it('loads options exactly once: search filters locally without re-querying the provider', async () => {
@@ -147,14 +177,15 @@ describe('open and options load', () => {
 })
 
 describe('search / move / highlight over the filtered list', () => {
-  it('setSearch rebases the highlight to 0 and ignores closed shells and identical text', async () => {
+  it('setSearch rebases the highlight to the top row and ignores closed shells and identical text', async () => {
     const { popup } = await readyPopup()
+    expect(popup.state.getSnapshot().active).toBe(1) // parked on the current-value row
     popup.move(1)
-    expect(popup.state.getSnapshot().active).toBe(1)
-    popup.setSearch('s')
-    expect(popup.state.getSnapshot()).toMatchObject({ search: 's', active: 0 })
+    expect(popup.state.getSnapshot().active).toBe(2)
+    popup.setSearch('a') // Dark, Sepia
+    expect(popup.state.getSnapshot()).toMatchObject({ search: 'a', active: 0 })
     const before = popup.state.getSnapshot()
-    popup.setSearch('s')
+    popup.setSearch('a')
     expect(popup.state.getSnapshot()).toBe(before)
     const closed = new PopupSelectController<Ctx>(makeDeps())
     closed.setSearch('x')
@@ -261,7 +292,7 @@ describe('select', () => {
     popup.setSearch('x') // locked while submitting
     popup.move(1)
     popup.highlight(1)
-    expect(popup.state.getSnapshot()).toMatchObject({ search: '', active: 0 })
+    expect(popup.state.getSnapshot()).toMatchObject({ search: '', active: 1 })
     release()
     await first
     expect(onSelect).toHaveBeenCalledTimes(1)

+ 9 - 33
packages/client/ui-commands/tests/service.client.spec.ts

@@ -122,12 +122,15 @@ async function bench(opts: BenchOptions = {}) {
   })
   /** Notices the fake conversation face collected (runDetached routing). */
   const notices: Array<{ scope: SessionId | undefined; level: 'info' | 'error'; text: string }> = []
+  /** Composer focuses the fake conversation face collected (popup dismissal). */
+  const focuses: Array<SessionId | undefined> = []
   const removeConversation = ctx.provide('conversation', {
     input: {
       for: (actx: Context) => ({
         notify: (level: 'info' | 'error', text: string) => {
           notices.push({ scope: scopeOf(actx), level, text })
         },
+        focus: () => { focuses.push(scopeOf(actx)) },
       }),
     },
   })
@@ -146,7 +149,7 @@ async function bench(opts: BenchOptions = {}) {
     await source.candidates(session, { query: '', position: 'leading', drilled: false, signal: new AbortController().signal })
   }
   return {
-    ctx, fiber, command, source, mint, warm, listCalls, executeCalls, executions, registered, notices, remote,
+    ctx, fiber, command, source, mint, warm, listCalls, executeCalls, executions, registered, notices, focuses, remote,
     removeSessions, removeConversation,
   }
 }
@@ -939,7 +942,7 @@ describe('popupFor', () => {
   })
 
   it('dismisses matching popups and confirmations while preserving other commands and drafts', async () => {
-    const { command, source, mint } = await bench()
+    const { command, source, mint, focuses } = await bench()
     const pending = Promise.withResolvers<readonly SelectOption[]>()
     let pendingSignal: AbortSignal | undefined
     const onSelect = vi.fn()
@@ -967,8 +970,6 @@ describe('popupFor', () => {
     const other = command.popupFor(mint('other').ctx)
     const consume = vi.fn(() => true as const)
     scope.ctx.on('slash/input-consume-token', consume)
-    const focus = vi.fn()
-    command.bindComposerFocus(sid('s1'), focus)
     menuPick(source, 'theme', proj('s1'))
     menuPick(source, 'theme', proj('s2'))
     menuPick(source, 'other', proj('other'))
@@ -990,7 +991,7 @@ describe('popupFor', () => {
     expect(second.state.getSnapshot()).toMatchObject({ open: false, options: [] })
     expect(onSelect).not.toHaveBeenCalled()
     expect(consume).not.toHaveBeenCalled()
-    expect(focus).not.toHaveBeenCalled()
+    expect(focuses).toEqual([])
   })
 
   it('resolves lazily per session; a foreign session gets its own controller; unscoped ctx throws', async () => {
@@ -1002,8 +1003,8 @@ describe('popupFor', () => {
     expect(() => command.popupFor(ctx)).toThrow('requires a session scope')
   })
 
-  it('a successful select dispatches the scoped consume-token and fires the bound composer focus', async () => {
-    const { command, source, mint } = await bench()
+  it('a successful select dispatches the scoped consume-token and focuses the composer', async () => {
+    const { command, source, mint, focuses } = await bench()
     const onSelect = vi.fn()
     command.register(themeContribution({ ui: themeUi({ onSelect }) }))
     const scope = mint('s1')
@@ -1012,38 +1013,13 @@ describe('popupFor', () => {
       consumes.push(r)
       return true
     })
-    const focus = vi.fn()
-    command.bindComposerFocus(sid('s1'), focus)
-
     expect(menuPick(source, 'theme', proj('s1'), 6)).toBe('handled')
     const popup = command.popupFor(scope.ctx)
     await Promise.resolve() // options land
     await popup.select(0)
     expect(onSelect).toHaveBeenCalledExactlyOnceWith({ id: 'dark', label: 'Dark' } satisfies SelectOption, proj('s1'))
     expect(consumes).toEqual([{ guard: { kind: 'span', span: { start: 0, end: 6, draftRev: 3 } } }])
-    expect(focus).toHaveBeenCalledTimes(1)
-  })
-
-  it('unbinding an old composer preserves its replacement and unbinding the current one removes focus', async () => {
-    const { command, source, mint } = await bench()
-    command.register(themeContribution())
-    const scope = mint('s1')
-    scope.ctx.on('slash/input-consume-token', () => true)
-    const oldFocus = vi.fn()
-    const newFocus = vi.fn()
-    const unbindOld = command.bindComposerFocus(sid('s1'), oldFocus)
-    const unbindNew = command.bindComposerFocus(sid('s1'), newFocus)
-    unbindOld()
-    menuPick(source, 'theme', proj('s1'))
-    await Promise.resolve()
-    await command.popupFor(scope.ctx).select(0)
-    expect(oldFocus).not.toHaveBeenCalled()
-    expect(newFocus).toHaveBeenCalledOnce()
-    unbindNew()
-    menuPick(source, 'theme', proj('s1'))
-    await Promise.resolve()
-    await command.popupFor(scope.ctx).select(0)
-    expect(newFocus).toHaveBeenCalledOnce()
+    expect(focuses).toEqual([sid('s1')])
   })
 
   it('the enter path opens with the bare-token guard', async () => {

+ 2 - 2
packages/client/ui-model-selection/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-model-selection/README.md
-README.md: f3c373619fec0482876d8ac540f06e188b5b34d9
-README.zh.md: b8ee9cd73bb3fd5cdf0d0ecd8559b271c5abc933
+README.md: b3c3b25c258dd1fd8088eb7d699806f6ecd2cc1a
+README.zh.md: e705c4f1a309da66099abba6cc3cd8a2e9b26b0e

+ 1 - 1
packages/client/ui-model-selection/README.md

@@ -25,7 +25,7 @@ The Web GUI lets users switch the model and reasoning effort for an existing ses
 <a id="use-this-package"></a>
 ## Use this package
 
-Mount this plugin alongside `ui-conversation` and the commands package; the composer then shows the model seat next to the pending indicator, and `/model` opens the same directory as a popup. Both surfaces show the host-reported current selection when the exact provider/model pair remains in the advertised groups; a missing catalog row leaves the routable selection intact while the trigger prompts `Select model`.
+Mount this plugin alongside `ui-conversation` and the commands package; the composer then shows the model seat next to the pending indicator, and `/model` opens the same directory as a popup. While the seat's menu is open, `↑`/`↓` move focus across the rows of the shown pane, Tab settles the focused row, and Escape and `Shift+Tab` leave a drilled pane first and otherwise close back to the trigger. Drilling lands on the row of the value in use, and going back lands on the cell that opened the pane left. Both surfaces show the host-reported current selection when the exact provider/model pair remains in the advertised groups; a missing catalog row leaves the routable selection intact while the trigger prompts `Select model`.
 
 ### Model and effort
 

+ 1 - 1
packages/client/ui-model-selection/README.zh.md

@@ -25,7 +25,7 @@ Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有
 <a id="use-this-package"></a>
 ## 使用本包
 
-与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。当确切提供方/模型对仍在已公布分组中时,两个界面都显示 Host 报告的当前选择;目录行缺席时,可路由的选择保持不变,触发器提示 `Select model`。
+与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。模型位菜单打开期间,`↑`/`↓` 在所显示面板的行间移动焦点,`Tab` 选定聚焦行,Escape 与 `Shift+Tab` 先退出已下钻的面板,否则关闭并回到触发器。下钻落在正在使用的那一行,返回则落在打开该面板的格子上。当确切提供方/模型对仍在已公布分组中时,两个界面都显示 Host 报告的当前选择;目录行缺席时,可路由的选择保持不变,触发器提示 `Select model`。
 
 ### 模型与推理强度
 

+ 4 - 1
packages/client/ui-model-selection/src/client/ModelSelect.module.css

@@ -34,7 +34,10 @@
 }
 
 .trigger:focus-visible {
-  box-shadow: 0 0 0 2px var(--dsw-alias-border-l3);
+  /* The same fill a hovered trigger gets: the trigger is one of the row-like
+     controls of the composer, and the ring its neighbours never drew read as a
+     different kind of state. */
+  background: var(--dsw-alias-interactive-bg-hover);
 }
 
 .trigger:disabled {

+ 73 - 9
packages/client/ui-model-selection/src/client/ModelSelect.tsx

@@ -5,11 +5,17 @@
  * each drilling into its own list — the provider-grouped model list over
  * the shared directory, and the effort levels. The trigger (313:14108's
  * ToggleButton) shows both: model name + effort in the caption tone.
- * Data and submission ride the SAME per-session ModelDirectory as the
- * /model popup; exact-model reasoning metadata and the selected effort come
- * from the Host rather than a client-owned vocabulary. A rejected selection
- * announces through the shared transient Toast anchored to the composer
- * card; the in-menu strip with Retry remains the catalog-load surface.
+ * While open, ↑/↓ move focus across the rows of the shown pane (wrapping; a
+ * step taken while the trigger still holds focus enters at the near end), Tab
+ * settles like Enter, and Escape and Shift+Tab leave a drilled pane first and
+ * otherwise close back to the trigger. A drilled pane hands focus to the row
+ * of the value in use, and returning to the root pane hands it back to the
+ * cell that opened it. Data and submission ride the SAME per-session
+ * ModelDirectory as the /model popup; exact-model reasoning metadata and the
+ * selected effort come from the Host rather than a client-owned vocabulary. A
+ * rejected selection announces through the shared transient Toast anchored to
+ * the composer card; the in-menu strip with Retry remains the catalog-load
+ * surface.
  */
 import {
   useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore,
@@ -123,6 +129,27 @@ export function ModelSelect(
     return () => { document.removeEventListener('mousedown', closeOutside) }
   }, [open])
 
+  // A pane switch unmounts the row that had focus, which drops focus onto the
+  // page body — outside the card's subtree, where its key handling no longer
+  // sees a keystroke. Every switch therefore names where the keyboard lands:
+  // drilling on the pane's current value, coming back on the cell that opened
+  // the pane left.
+  const paneFocus = useRef<'drill' | 'model' | 'effort' | null>(null)
+  useEffect(() => {
+    const intent = paneFocus.current
+    paneFocus.current = null
+    if (!open || intent === null) return
+    if (intent === 'drill') {
+      // The checked row is the value in use; a pane without one opens on its
+      // first row.
+      const checked = menuRef.current?.querySelector<HTMLElement>('[role="menuitemradio"][aria-checked="true"]')
+      const target = checked ?? itemRefs.current.find(item => item !== null)
+      target?.focus()
+      return
+    }
+    itemRefs.current[intent === 'effort' ? 1 : 0]?.focus()
+  }, [open, pane])
+
   // Portaled placement (the Menu primitive's portal rules: fixed from the
   // anchor rect, measured before paint, clamped inside the viewport): above
   // the trigger, right edges aligned. Depends on pane and directory state
@@ -171,11 +198,27 @@ export function ModelSelect(
     if (restoreFocus) queueMicrotask(() => { triggerRef.current?.focus() })
   }
 
+  const drill = (next: Pane): void => {
+    paneFocus.current = 'drill'
+    setPane(next)
+  }
+
+  /** Leave a drilled pane for the root one, handing the keyboard back to its cell. */
+  const back = (from: Exclude<Pane, 'root'>): void => {
+    paneFocus.current = from
+    setPane('root')
+  }
+
   const moveFocus = (offset: number): void => {
     const items = itemRefs.current.filter(item => item !== null)
     if (items.length === 0) return
     const active = items.findIndex(item => item === document.activeElement)
-    const next = (Math.max(active, 0) + offset + items.length) % items.length
+    // Focus outside the rows (the trigger, which keeps it while the menu
+    // opens) enters at the end the step comes from: the first row forward,
+    // the last row backward.
+    const next = active === -1
+      ? (offset > 0 ? 0 : items.length - 1)
+      : (active + offset + items.length) % items.length
     items[next]?.focus()
   }
 
@@ -183,11 +226,32 @@ export function ModelSelect(
     if (event.key === 'Escape' && open) {
       event.preventDefault()
       // Escape backs out of a drilled pane first, then closes.
-      if (pane !== 'root') setPane('root')
+      if (pane !== 'root') back(pane)
       else close(true)
       return
     }
     if (!open) return
+    // Tab settles like Enter and Shift+Tab leaves like Escape, so the menu's
+    // keys mean what they mean in the composer. Both are consumed: the card
+    // keeps the browser's focus traversal out while it is open.
+    if (event.key === 'Tab') {
+      event.preventDefault()
+      if (event.shiftKey) {
+        if (pane !== 'root') back(pane)
+        else close(true)
+        return
+      }
+      // Settling activates the row the keyboard is on; with focus still on the
+      // trigger, Tab enters the menu at the value in use instead.
+      const focused = document.activeElement
+      if (focused instanceof HTMLElement && menuRef.current?.contains(focused) === true) {
+        focused.click()
+        return
+      }
+      const checked = menuRef.current?.querySelector<HTMLElement>('[role="menuitemradio"][aria-checked="true"]')
+      ;(checked ?? itemRefs.current.find(item => item !== null))?.focus()
+      return
+    }
     if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
       event.preventDefault()
       moveFocus(event.key === 'ArrowDown' ? 1 : -1)
@@ -299,13 +363,13 @@ export function ModelSelect(
         >
           {pane === 'root' && (
             <>
-              <button ref={itemRef()} type="button" role="menuitem" className={css.cell} onClick={() => { setPane('model') }}>
+              <button ref={itemRef()} type="button" role="menuitem" className={css.cell} onClick={() => { drill('model') }}>
                 <span className={css.cellLabel}>{t('menu.model')}</span>
                 <span className={css.cellValue}>{modelLabel}</span>
                 <IconChevronRightOutline14 className={css.cellChevron} />
               </button>
               {reasoning !== undefined && (
-                <button ref={itemRef()} type="button" role="menuitem" className={css.cell} onClick={() => { setPane('effort') }}>
+                <button ref={itemRef()} type="button" role="menuitem" className={css.cell} onClick={() => { drill('effort') }}>
                   <span className={css.cellLabel}>{t('menu.effort')}</span>
                   <span className={css.cellValue}>{effortLabel}</span>
                   <IconChevronRightOutline14 className={css.cellChevron} />

+ 144 - 0
packages/client/ui-model-selection/tests/model-select.client.spec.tsx

@@ -252,3 +252,147 @@ describe('ModelSelect reasoning effort', () => {
     expect(load).not.toHaveBeenCalled()
   })
 })
+
+describe('ModelSelect keyboard walk', () => {
+  function mountOpen() {
+    const select = vi.fn().mockResolvedValue(true)
+    render(<ModelSelect
+      locked={false}
+      available
+      directory={createSnapshotStore(state())}
+      load={vi.fn()}
+      select={select}
+      t={t}
+    />)
+    fireEvent.click(screen.getByRole('button', { name: /选择模型/ }))
+    return select
+  }
+
+  it('↑↓ walk the rows of the shown pane, wrapping, and stay open', () => {
+    mountOpen()
+    // The trigger holds focus while the menu opens: the first forward step
+    // enters at the first cell instead of skipping it. false = preventDefault ran.
+    const cells = screen.getAllByRole('menuitem')
+    expect(fireEvent.keyDown(cells[0]!, { key: 'ArrowDown' })).toBe(false)
+    expect(document.activeElement).toBe(cells[0])
+
+    fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
+    const rows = screen.getAllByRole('menuitemradio')
+    expect(rows.map(row => row.textContent)).toEqual(['Off', 'High', 'Max'])
+    // The pane opens on its checked row, so walking starts from High.
+    fireEvent.keyDown(rows[1]!, { key: 'ArrowDown' })
+    expect(document.activeElement).toBe(rows[2])
+    fireEvent.keyDown(rows[2]!, { key: 'ArrowDown' }) // wraps to the top
+    expect(document.activeElement).toBe(rows[0])
+    fireEvent.keyDown(rows[0]!, { key: 'ArrowUp' }) // wraps to the bottom
+    expect(document.activeElement).toBe(rows[2])
+    expect(screen.getByRole('menu')).toBeTruthy()
+  })
+
+  it('Tab settles the focused row like Enter and closes the menu', async () => {
+    const select = mountOpen()
+    fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
+    const rows = screen.getAllByRole('menuitemradio')
+    fireEvent.keyDown(rows[1]!, { key: 'ArrowDown' }) // High → Max
+    expect(fireEvent.keyDown(rows[2]!, { key: 'Tab' })).toBe(false)
+    expect(select).toHaveBeenCalledWith({
+      provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'max',
+    })
+    await waitFor(() => { expect(screen.queryByRole('menu')).toBeNull() })
+  })
+
+  it('Shift+Tab leaves a drilled pane and then closes, like Escape', () => {
+    mountOpen()
+    fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
+    const rows = screen.getAllByRole('menuitemradio')
+    expect(fireEvent.keyDown(rows[0]!, { key: 'Tab', shiftKey: true })).toBe(false)
+    // Back on the drilled cell, then closed on the second press.
+    const cells = screen.getAllByRole('menuitem')
+    expect(document.activeElement).toBe(cells[1])
+    expect(screen.getByRole('menu')).toBeTruthy()
+    fireEvent.keyDown(cells[1]!, { key: 'Tab', shiftKey: true })
+    expect(screen.queryByRole('menu')).toBeNull()
+  })
+
+  it('Tab with the keyboard still on the trigger enters the menu at the value in use', () => {
+    mountOpen()
+    const trigger = screen.getByRole('button', { name: /选择模型/ })
+    expect(fireEvent.keyDown(trigger, { key: 'Tab' })).toBe(false)
+    // The root pane's first cell carries the current selection.
+    const cells = screen.getAllByRole('menuitem')
+    expect(document.activeElement).toBe(cells[0])
+    expect(screen.getByRole('menu')).toBeTruthy()
+  })
+
+  it('a backward step from outside the list enters at the last row, and a closed menu leaves Tab native', () => {
+    mountOpen()
+    const [modelRow, effortRow] = screen.getAllByRole('menuitem')
+    expect(fireEvent.keyDown(modelRow!, { key: 'ArrowUp' })).toBe(false)
+    expect(document.activeElement).toBe(effortRow)
+    const trigger = screen.getByRole('button', { name: /选择模型/ })
+    fireEvent.keyDown(trigger, { key: 'Escape' })
+    expect(screen.queryByRole('menu')).toBeNull()
+    expect(fireEvent.keyDown(trigger, { key: 'Tab' })).toBe(true)
+  })
+
+  it('hands a drilled pane the focus its unmounted cell left behind, on the value in use', () => {
+    mountOpen()
+    fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
+    const rows = screen.getAllByRole('menuitemradio')
+    // The fixture's model defaults to the High effort: the checked row is where
+    // the keyboard lands, not the top of the list.
+    expect(rows[1]!.getAttribute('aria-checked')).toBe('true')
+    expect(document.activeElement).toBe(rows[1])
+    // The walk continues from there.
+    fireEvent.keyDown(rows[1]!, { key: 'ArrowDown' })
+    expect(document.activeElement).toBe(rows[2])
+  })
+
+  it('drills into the model list on the selected model', () => {
+    mountOpen()
+    fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ }))
+    const rows = screen.getAllByRole('menuitemradio')
+    expect(rows[0]!.getAttribute('aria-checked')).toBe('true')
+    expect(document.activeElement).toBe(rows[0])
+  })
+
+  it('Escape returns to the root pane with the keyboard on the cell that drilled in', () => {
+    mountOpen()
+    fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
+    const rows = screen.getAllByRole('menuitemradio')
+    fireEvent.keyDown(rows[0]!, { key: 'Escape' })
+    // The root pane is back with its two cells.
+    const cells = screen.getAllByRole('menuitem')
+    // Back on the drilled cell, so the next keystroke still reaches the menu.
+    expect(document.activeElement).toBe(cells[1])
+    expect(screen.getByRole('menu')).toBeTruthy()
+    // A second Escape closes back to the trigger.
+    fireEvent.keyDown(cells[1]!, { key: 'Escape' })
+    expect(screen.queryByRole('menu')).toBeNull()
+  })
+
+  it('Escape from the model list lands back on the model cell', () => {
+    mountOpen()
+    fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ }))
+    fireEvent.keyDown(screen.getAllByRole('menuitemradio')[0]!, { key: 'Escape' })
+    const cells = screen.getAllByRole('menuitem')
+    expect(document.activeElement).toBe(cells[0])
+  })
+
+  it('a pane whose rows mark no current value opens on its first row', () => {
+    // The session runs a model the catalog no longer lists: no row is checked.
+    render(<ModelSelect
+      locked={false}
+      available
+      directory={createSnapshotStore(state({ current: { provider: 'gone', model: 'gone' } }))}
+      load={vi.fn()}
+      select={vi.fn().mockResolvedValue(true)}
+      t={t}
+    />)
+    fireEvent.click(screen.getByRole('button', { name: /选择模型/ }))
+    fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ }))
+    const rows = screen.getAllByRole('menuitemradio')
+    expect(rows.every(row => row.getAttribute('aria-checked') === 'false')).toBe(true)
+    expect(document.activeElement).toBe(rows[0])
+  })
+})

Some files were not shown because too many files changed in this diff