Răsfoiți Sursa

Merge pull request #4173 from deepseek-harness/worktree/sidebar-guide-terminal-menu

feat(web): launch terminals from provider guide menus
Yichen Jiang 1 lună în urmă
părinte
comite
3cbc19d1d0
45 a modificat fișierele cu 504 adăugiri și 245 ștergeri
  1. 2 2
      .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml
  2. 3 1
      .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md
  3. 3 1
      .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md
  4. 3 0
      apps/web/tests/expected/sidebar-terminal/guide.expected.md
  5. 0 3
      apps/web/tests/expected/sidebar-terminal/selection.expected.md
  6. 3 3
      apps/web/tests/expected/sidebar-terminal/shell-menu.expected.md
  7. 21 21
      apps/web/tests/sidebar-terminal.e2e.ts
  8. 2 2
      docs/subsystems/slots.i18n.yaml
  9. 2 1
      docs/subsystems/slots.md
  10. 2 1
      docs/subsystems/slots.zh.md
  11. 2 2
      packages/api/terminal-controller/README.i18n.yaml
  12. 1 1
      packages/api/terminal-controller/README.md
  13. 1 1
      packages/api/terminal-controller/README.zh.md
  14. 30 3
      packages/api/terminal-controller/src/client/index.ts
  15. 16 36
      packages/api/terminal-controller/src/client/model.ts
  16. 0 5
      packages/api/terminal-controller/tests/model.client.spec.ts
  17. 41 47
      packages/api/terminal-controller/tests/recovery.client.spec.ts
  18. 1 0
      packages/client/ui-sidebar-files/src/client/definition.tsx
  19. 2 2
      packages/client/ui-sidebar-right/README.i18n.yaml
  20. 3 1
      packages/client/ui-sidebar-right/README.md
  21. 3 1
      packages/client/ui-sidebar-right/README.zh.md
  22. 17 1
      packages/client/ui-sidebar-right/src/client/contract/slots.ts
  23. 4 1
      packages/client/ui-sidebar-right/src/client/index.ts
  24. 3 3
      packages/client/ui-sidebar-right/src/client/tab-info.ts
  25. 7 1
      packages/client/ui-sidebar-right/src/client/tab-registry.ts
  26. 2 0
      packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.module.css
  27. 18 12
      packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx
  28. 1 1
      packages/client/ui-sidebar-right/tests/apply.client.spec.ts
  29. 23 4
      packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx
  30. 2 2
      packages/client/ui-sidebar-right/tests/seat.client.spec.tsx
  31. 13 2
      packages/client/ui-sidebar-right/tests/tab-registry.client.spec.ts
  32. 2 2
      packages/client/ui-sidebar-terminal/README.i18n.yaml
  33. 1 1
      packages/client/ui-sidebar-terminal/README.md
  34. 1 1
      packages/client/ui-sidebar-terminal/README.zh.md
  35. 0 5
      packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css
  36. 2 35
      packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx
  37. 8 0
      packages/client/ui-sidebar-terminal/src/client/TerminalGuide.module.css
  38. 74 0
      packages/client/ui-sidebar-terminal/src/client/TerminalGuide.tsx
  39. 1 1
      packages/client/ui-sidebar-terminal/src/client/face.ts
  40. 19 5
      packages/client/ui-sidebar-terminal/src/client/index.ts
  41. 2 2
      packages/client/ui-sidebar-terminal/src/client/locales.ts
  42. 23 10
      packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts
  43. 0 21
      packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx
  44. 94 0
      packages/client/ui-sidebar-terminal/tests/terminal-guide.client.spec.tsx
  45. 46 1
      packages/extensions/cordis-client-runner/src/client/slot-catalog.ts

+ 2 - 2
.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.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 .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md
-2026-09-09-web-sidebar-terminal.md: db7f42e9f53940262d806ff1133c1059e3f74829
-2026-09-09-web-sidebar-terminal.zh.md: cbb6bc6bab5e3ed4927399b90363332f1624825b
+2026-09-09-web-sidebar-terminal.md: cdc10181360362c31a7fbfe226886444ae1eb6c2
+2026-09-09-web-sidebar-terminal.zh.md: 9e759371b0a39e4667b97111d84267236b6ad954

+ 3 - 1
.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md

@@ -10,9 +10,11 @@ Web users need an interactive shell beside a Session to inspect the workspace an
 
 ## Decision
 
+Guide entries declare stable ids within their provider. A keyed `sidebar.right.tab.guide.entry` slot dispatches by the active provider id, so an extension replacing a builtin also controls its guide rendering. The sidebar owns card placement and the default fallback; provider components own their controls and read the enclosing tab through framework hooks. An entry slot allows a shell menu without replacing the entire guide or nesting an interactive button inside another button.
+
 The application theme owns terminal background, default text, cursor and selection colors. The terminal body reads the rendered DSH palette after framework-delivered theme changes, so CSS token overrides remain effective. Updating the existing xterm instance preserves its output and PTY attachment; shell-provided ANSI colors remain independent.
 
-`api-terminal-controller` owns user terminals by Session and exposes the `terminal` Remote namespace. `ui-sidebar-terminal` registers native right-sidebar tabs, xterm.js rendering and FitAddon sizing. A new terminal offers installed shells and waits for Start. Host discovery verifies the configured candidates, with the execution default first; creation accepts only a currently discovered path. The browser remembers the last selected shell path in origin-scoped localStorage and falls back to the current default if that path is unavailable. The terminal type declares independent instances, so ordinary page deduplication cannot collapse separate processes when opening or docking tabs. The existing sidebar controls open additional tabs; double-clicking a tab title renames its terminal. Terminal processes use the composed subprocess provider and Session sandbox policy. Shell resolution occurs during discovery and creation; reading limits and reconnecting an existing process do not depend on the default executable remaining available. Interactive shell configuration supplies Tab completion and optional inline suggestions.
+`api-terminal-controller` owns user terminals by Session and exposes the `terminal` Remote namespace. `ui-sidebar-terminal` registers native right-sidebar tabs, xterm.js rendering and FitAddon sizing. The terminal guide card has a primary action for the remembered available shell and a separate installed-shell menu. Selecting a menu item records its path and opens a new terminal immediately; discovery alone allocates no process. Each tab owns its startup and close lifecycle. Host discovery verifies the configured candidates, with the execution default first; creation accepts only a currently discovered path. The browser remembers the last selected shell path in origin-scoped localStorage and falls back to the current default if that path is unavailable. The terminal type declares independent instances, so ordinary page deduplication cannot collapse separate processes when opening or docking tabs. The existing sidebar controls open additional tabs; double-clicking a tab title renames its terminal. Terminal processes use the composed subprocess provider and Session sandbox policy. Shell resolution occurs during discovery and creation; reading limits and reconnecting an existing process do not depend on the default executable remaining available. Interactive shell configuration supplies Tab completion and optional inline suggestions.
 
 Close and replacement remove the tab synchronously and run process cleanup in the background. The Client first records the unfinished close request under a terminal-specific localStorage key; success removes it, and startup retries requests that remain. A cleanup failure produces a lightweight notification with a retry action without reopening the tab. Independent keys prevent another window from overwriting unrelated cleanup requests. Collapse, tab/Session switching, floating, fullscreen and browser disconnection preserve the process. Component cleanup and `TabDomain.signal` only detach browser work because the same lifetime can end during plugin reload. Failed process cleanup retains ownership, including failures after allocation but before create publication. Session owner disposal and Host plugin disposal also clean up terminals. A definitive missing-Session response retires its saved close request because the Session owns process cleanup; transport failures remain retryable. Client plugin disposal awaits every detached stream so a replacement plugin does not inherit unfinished Client cleanup.
 

+ 3 - 1
.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md

@@ -10,9 +10,11 @@ Web 用户需要在 Session 旁使用交互式 shell 检查工作区和运行命
 
 ## 决定
 
+开始页入口声明 provider 内稳定的 id。keyed `sidebar.right.tab.guide.entry` slot 按当前生效的 provider id 分发,因此替代 builtin 的 extension 也控制对应入口的渲染。侧栏负责卡片排列和默认回退,provider 组件负责自己的控件,并通过框架 hook 读取所在标签页。入口 slot 可以承载 shell 菜单,无需替换整个开始页或把交互按钮嵌套在另一个按钮内。
+
 应用主题负责终端背景、默认文字、光标和选区颜色。终端正文在收到框架传递的主题变化后读取 DSH 渲染配色,使 CSS token 覆盖继续生效。更新现有 xterm 实例会保留输出和 PTY 连接;shell 输出的 ANSI 颜色独立生效。
 
-`api-terminal-controller` 按 Session 管理用户终端并提供 `terminal` Remote namespace。`ui-sidebar-terminal` 注册原生右侧栏标签页,使用 xterm.js 渲染和 FitAddon 测量尺寸。新终端提供已安装 shell 的选择并等待用户启动。Host 探测会验证配置的候选,并把执行环境默认项放在首位;创建只接受当前探测返回的路径。浏览器在当前站点 localStorage 中记住上次选择的 shell 路径,该路径不可用时回到当前默认项。终端类型声明独立实例,因此打开或停靠标签页时,普通页面的去重规则不会合并不同进程。已有侧栏控件负责打开更多标签页,双击标签页标题可重命名终端。终端进程使用组合的 subprocess provider 和 Session sandbox policy。shell 在探测和创建时解析;读取限制和重新连接已有进程不依赖默认可执行文件仍然可用。交互式 shell 配置提供 Tab 补全和可选的内联建议。
+`api-terminal-controller` 按 Session 管理用户终端并提供 `terminal` Remote namespace。`ui-sidebar-terminal` 注册原生右侧栏标签页,使用 xterm.js 渲染和 FitAddon 测量尺寸。终端开始页卡片的主操作打开上次选择且仍可用的 shell,独立菜单提供已安装 shell。选择菜单项会记录路径并立即打开新终端;仅探测 shell 不分配进程。每个标签页拥有自己的启动和关闭生命周期。Host 探测会验证配置的候选,并把执行环境默认项放在首位;创建只接受当前探测返回的路径。浏览器在当前站点 localStorage 中记住上次选择的 shell 路径,该路径不可用时回到当前默认项。终端类型声明独立实例,因此打开或停靠标签页时,普通页面的去重规则不会合并不同进程。已有侧栏控件负责打开更多标签页,双击标签页标题可重命名终端。终端进程使用组合的 subprocess provider 和 Session sandbox policy。shell 在探测和创建时解析;读取限制和重新连接已有进程不依赖默认可执行文件仍然可用。交互式 shell 配置提供 Tab 补全和可选的内联建议。
 
 关闭和替换会同步移除标签页,并在后台清理进程。Client 先以终端独立的 localStorage key 保存未完成的关闭请求;成功后删除,启动时重试剩余请求。清理失败时显示带重试操作的轻量通知,不重新打开标签页。独立 key 避免其他窗口覆盖无关的清理请求。折叠、切换标签页或 Session、浮动、全屏和浏览器断线均保留进程。组件清理和 `TabDomain.signal` 只停止浏览器工作,因为插件重新加载也会结束这些生命周期。进程清理失败时保留所有权,包括分配完成但 create 尚未发布时的失败。Session owner 和 Host 插件卸载也会清理终端。 明确的 Session 不存在响应会清除已保存的关闭请求,因为进程清理由 Session 负责;传输失败仍可重试。Client 插件卸载等待所有断开的流结束,避免替换插件继承未完成的 Client 清理。
 

+ 3 - 0
apps/web/tests/expected/sidebar-terminal/guide.expected.md

@@ -0,0 +1,3 @@
+- button "New terminal Run commands in the Session workspace"
+- button "Choose shell" [expanded]:
+  - img

+ 0 - 3
apps/web/tests/expected/sidebar-terminal/selection.expected.md

@@ -1,3 +0,0 @@
-- text: Shell
-- button "Shell": bash — /bin/bash
-- button "Start terminal"

+ 3 - 3
apps/web/tests/expected/sidebar-terminal/shell-menu.expected.md

@@ -1,5 +1,5 @@
 - menu:
-  - menuitem "bash — /bin/bash":
-    - text: bash — /bin/bash
+  - menuitem "bash":
+    - text: bash
     - img
-  - menuitem "sh — /bin/sh"
+  - menuitem "sh"

+ 21 - 21
apps/web/tests/sidebar-terminal.e2e.ts

@@ -18,8 +18,7 @@ async function openTerminal(page: Page, waitForShell = true): Promise<void> {
   if (await expand.isVisible()) await expand.click()
   const entry = page.locator('[data-sidebar-right-guide-entry="terminal"]')
   if (!await entry.isVisible()) await page.locator('[data-dockkit-add-tab]').click()
-  await entry.click()
-  await page.getByRole('button', { name: 'Start terminal', exact: true }).click()
+  await entry.getByRole('button', { name: /^New terminal/u }).click()
   if (waitForShell) await expect.poll(async () => await page.locator('.xterm-rows:visible').innerText()).toContain('bash-')
 }
 
@@ -215,42 +214,43 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
     expect(tripwire.pageErrors).toEqual([])
   })
 
-  it('offers installed shells, remembers the choice after reload, and restores without a picker', async () => {
+  it('chooses a shell from the guide menu, opens it directly, and remembers it after reload', async () => {
     onTestFailed(() => saveFailureShot(page, 'sidebar-terminal-shell-choice'))
     await page.locator('[data-sidebar-right-expand]').click()
     const entry = page.locator('[data-sidebar-right-guide-entry="terminal"]')
-    expect(await entry.innerText()).toBe('New terminal\nRun commands in the Session workspace')
     await page.locator('[data-sidebar-right-guide]').screenshot({ path: `${shots}/terminal-guide.png`, animations: 'disabled' })
-    await entry.click()
-    const selector = page.getByRole('button', { name: 'Shell', exact: true })
-    await selector.waitFor()
-    expect(await selector.innerText()).toContain('bash — /bin/bash')
-    expect(handles).toHaveLength(0)
-    await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-terminal/selection.expected.md', import.meta.url)),
-      await page.locator('[data-sidebar-terminal]').ariaSnapshot(), webSnapshotMode())
+    const selector = entry.getByRole('button', { name: 'Choose shell', exact: true })
+    const cardBox = (await entry.boundingBox())!
+    const triggerBox = (await selector.boundingBox())!
+    expect(Math.abs(cardBox.x + cardBox.width - triggerBox.x - triggerBox.width)).toBeLessThanOrEqual(2)
+    await page.emulateMedia({ colorScheme: 'dark' })
     await selector.click()
+    await page.getByRole('menuitem', { name: 'bash', exact: true }).waitFor()
+    expect(handles).toHaveLength(0)
+    await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-terminal/guide.expected.md', import.meta.url)),
+      await entry.ariaSnapshot(), webSnapshotMode())
     await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-terminal/shell-menu.expected.md', import.meta.url)),
       await page.getByRole('menu').ariaSnapshot(), webSnapshotMode())
     await page.screenshot({ path: `${shots}/shell-menu.png`, fullPage: true })
-    await page.getByRole('menuitem', { name: 'sh — /bin/sh', exact: true }).click()
+    await page.keyboard.press('Escape')
+    expect(handles).toHaveLength(0)
+    await selector.click()
+    await page.getByRole('menuitem', { name: 'sh', exact: true }).click()
     expect(await page.evaluate(() => localStorage.getItem('dsh.terminal.shell'))).toBe('/bin/sh')
-    await page.screenshot({ path: `${shots}/shell-choice.png`, fullPage: true })
-    await page.getByRole('button', { name: 'Start terminal', exact: true }).click()
+    await page.locator('.xterm-helper-textarea:visible').waitFor()
     await command(page, "printf 'CHOSEN_SHELL:%s\\n' \"$0\"")
     const screen = page.locator('.xterm-rows:visible')
     await expect.poll(() => screen.innerText()).toContain('CHOSEN_SHELL:/bin/sh')
-    expect(await page.evaluate(() => localStorage.getItem('dsh.terminal.shell'))).toBe('/bin/sh')
     const retained = processIdentity(0)
     await page.reload({ waitUntil: 'load' })
     await page.locator('.xterm-helper-textarea:visible').waitFor()
-    expect(await selector.count()).toBe(0)
     expect(alive(retained)).toBe(true)
     await page.locator('[data-dockkit-add-tab]').click()
-    await page.locator('[data-sidebar-right-guide-entry="terminal"]').click()
-    await selector.waitFor()
-    expect(await selector.innerText()).toContain('sh — /bin/sh')
-    expect(handles).toHaveLength(1)
-    await page.getByRole('button', { name: 'Start terminal', exact: true }).click()
+    await selector.click()
+    await page.getByRole('menuitem', { name: 'sh', exact: true }).waitFor()
+    expect(await page.getByRole('menuitem', { name: 'sh', exact: true }).locator('svg').count()).toBe(1)
+    await page.keyboard.press('Escape')
+    await entry.getByRole('button', { name: /^New terminal/u }).click()
     await expect.poll(() => handles.length).toBe(2)
     expect(scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!.id).map(info => info.shell.path)).toEqual(['/bin/sh', '/bin/sh'])
     expect(tripwire.pageErrors).toEqual([])

+ 2 - 2
docs/subsystems/slots.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 docs/subsystems/slots.md
-slots.md: f43262dc586daa5efb5881fff968fc57b9f5f1d5
-slots.zh.md: 7452454b1f6a0645047cf08091b548169e4932d4
+slots.md: be45c83478a198320138040294e0c1e94c9bb013
+slots.zh.md: fe4c6efa806017e38d3d43ad0991a44f8b5dada1

+ 2 - 1
docs/subsystems/slots.md

@@ -166,7 +166,8 @@ root
 ├─ rightbar
 │  └─ rightbar.session
 │     ├─ sidebar.right.pane.tab
-│     │  └─ sidebar.right.tab.guide
+│     │  ├─ sidebar.right.tab.guide
+│     │  └─ sidebar.right.tab.guide.entry
 │     ├─ sidebar.right.pane.tab.title
 │     └─ sidebar.right.tab.menu.item
 └─ shell.overlay

+ 2 - 1
docs/subsystems/slots.zh.md

@@ -166,7 +166,8 @@ root
 ├─ rightbar
 │  └─ rightbar.session
 │     ├─ sidebar.right.pane.tab
-│     │  └─ sidebar.right.tab.guide
+│     │  ├─ sidebar.right.tab.guide
+│     │  └─ sidebar.right.tab.guide.entry
 │     ├─ sidebar.right.pane.tab.title
 │     └─ sidebar.right.tab.menu.item
 └─ shell.overlay

+ 2 - 2
packages/api/terminal-controller/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/api/terminal-controller/README.md
-README.md: 21bcfb335246dc4b95a3cd70993c8444ad7de057
-README.zh.md: 6ba88bcecc55c850cf477257c8055b5babae9b52
+README.md: 7bcd04ad2848b4f263aaff21d0bee5aea2333343
+README.zh.md: 3db761f32c0e7c99fa622dd7adb62f3ac301b766

+ 1 - 1
packages/api/terminal-controller/README.md

@@ -52,7 +52,7 @@ The latest attachment owns input and resize. Detachment releases input control w
 
 Client views keep the association between sidebar tabs and terminal identities in memory. Recovery queries the Host for retained terminals; a new view may create a process, while a recovered view reports a missing target without creating a replacement. The Client model acknowledges screen writes after the browser emulator processes them, serializes input and ignores stale attachment responses. Client-owned errors carry locale keys. Plugin disposal awaits active and previously detached output streams without closing Host processes.
 
-New views wait for explicit shell selection and start. The last selected shell path is stored under `dsh.terminal.shell` in origin-scoped localStorage. A saved path is preselected only while Host discovery still lists it; otherwise the current default is selected. Storage failures do not prevent startup. Restoring existing terminals neither reads this preference nor discovers shells.
+New views start automatically, using an explicit guide selection or the remembered available shell. The last selected shell path is stored under `dsh.terminal.shell` in origin-scoped localStorage. Default launches verify the saved path through Host discovery and fall back to the current default when it is absent. The guide records a selection before opening its tab; each new tab retains its own chosen path and allocation identity. Storage failures do not prevent startup. Restoring existing terminals neither reads this preference nor discovers shells.
 
 Closing saves an unfinished cleanup request before releasing the tab, then awaits Host cleanup in the background. A failure exposes a retry notification. Each request has its own terminal-ID localStorage key and is removed after successful cleanup or a definitive `session/not-found` response; startup retries saved requests. Transport failures retain the request. This stores cleanup intent, not sidebar layout, open-tab mappings, selected tabs or process PIDs. If browser storage is unavailable, cleanup remains usable in memory but cannot be recovered after reload.
 

+ 1 - 1
packages/api/terminal-controller/README.zh.md

@@ -52,7 +52,7 @@ Host 通过 `ctx.subprocess.spawnTerminal` 创建 `TERM=xterm-256color` 的终
 
 Client 视图只在内存中关联侧栏标签页与终端标识。恢复操作查询 Host 保留的终端;新视图可以创建进程,恢复视图在目标缺失时显示错误,不创建替代进程。Client 模型在浏览器完成屏幕解析后确认帧,按序发送输入,并忽略旧连接迟到的响应。 Client 自产错误携带本地化键。插件卸载等待活跃及先前断开的输出流结束,不关闭 Host 进程。
 
-新视图等待用户选择 shell 并启动。上次选择的 shell 路径保存在当前站点 localStorage 的 `dsh.terminal.shell` 中。仅当 Host 探测仍列出该路径时才预选它,否则选择当前默认项。存储失败不影响启动。恢复已有终端既不读取这一偏好,也不探测 shell。
+新视图自动启动,使用开始页明确选中的 shell,或上次选择且仍可用的 shell。上次选择的路径保存在当前站点 localStorage 的 `dsh.terminal.shell` 中。默认启动通过 Host 探测验证保存的路径,不可用时回到当前默认项。开始页在打开标签页前记录选择,每个新标签页保留自己的 shell 路径和分配身份。存储失败不影响启动。恢复已有终端既不读取这一偏好,也不探测 shell。
 
 关闭时先保存未完成的清理请求并释放标签页,再在后台等待 Host 清理。失败时提供重试通知。每个请求使用独立的终端 ID localStorage key,清理成功或收到明确的 `session/not-found` 响应后删除;启动时重试已保存的请求。传输失败时保留请求。保存的是清理意图,不是侧栏布局、打开标签页映射、选中标签页或进程 PID。浏览器存储不可用时,内存中的清理仍可工作,但刷新后无法恢复该请求。
 

+ 30 - 3
packages/api/terminal-controller/src/client/index.ts

@@ -6,7 +6,8 @@ import type {} from '@deepseek-ai/dsh-api-gateway/client'
 import { TerminalView, type TerminalRemote } from './model.ts'
 import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
 import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
-import type { WebTerminalId, WebTerminalInfo } from '../types.ts'
+import type { TerminalShell, WebTerminalId, WebTerminalInfo } from '../types.ts'
+import { preferredShell, rememberShell } from './shell-preference.ts'
 import { TerminalCloseRequests, type TerminalCloseRequest } from './close-requests.ts'
 
 export type { TerminalView, TerminalViewState, TerminalViewIssue, TerminalRenderFrame, TerminalRemote } from './model.ts'
@@ -18,6 +19,12 @@ declare module '@deepseek-ai/cordis' {
   }
 }
 
+/** Host-discovered shell menu with the browser's remembered available choice. */
+export interface TerminalLaunchShells {
+  readonly shells: readonly TerminalShell[]
+  readonly selectedShell: string | undefined
+}
+
 /** A failed background close that can be retried without restoring its tab. */
 export interface TerminalCloseFailure {
   readonly id: WebTerminalId
@@ -55,21 +62,41 @@ export class ClientTerminals extends Service {
    * @param sessionId - owning Session.
    * @param key - sidebar occurrence key.
    * @param terminalId - existing Host identity when restoring a listed terminal.
+   * @param shellPath - explicit shell for a new terminal; restored terminals retain their own shell.
    * @returns its observable state and terminal commands.
    */
-  view(sessionId: SessionId, key: string, terminalId?: WebTerminalId): TerminalView {
+  view(sessionId: SessionId, key: string, terminalId?: WebTerminalId, shellPath?: string): TerminalView {
     let views = this.views.get(sessionId)
     if (views === undefined) { views = new Map(); this.views.set(sessionId, views) }
     let view = views.get(key)
     if (view === undefined) {
       const id = terminalId ?? randomUUID() as WebTerminalId
-      view = new TerminalView(sessionId, this.remote, this.ctx.remote, id, terminalId === undefined)
+      view = new TerminalView(sessionId, this.remote, this.ctx.remote, id, terminalId === undefined, shellPath)
       views.set(key, view)
       void view.refresh()
     }
     return view
   }
 
+  /**
+   * Discover available launch choices on demand without allocating a PTY.
+   * @param sessionId - target Session.
+   * @param signal - the menu request lifetime.
+   * @returns installed shells and the currently usable browser preference.
+   */
+  async launchShells(sessionId: SessionId, signal: AbortSignal): Promise<TerminalLaunchShells> {
+    const result = await this.remote.shells(sessionId, signal)
+    if (!result.ok) throw new Error(result.error.message)
+    const previous = preferredShell()
+    return { shells: result.value, selectedShell: result.value.find(shell => shell.path === previous)?.path ?? result.value[0]?.path }
+  }
+
+  /**
+   * Remember the guide selection before allocating its terminal tab.
+   * @param path - shell selected from Host discovery.
+   */
+  selectShell(path: string): void { rememberShell(path) }
+
   /**
    * Save a close intent and release the tab immediately; cleanup outlives DOM unmount and reload.
    * @param sessionId - owning Session.

+ 16 - 36
packages/api/terminal-controller/src/client/model.ts

@@ -7,7 +7,7 @@ import { RemoteError, remoteErrorOf, type RemoteResult } from '@deepseek-ai/dsh-
 import type {} from '@deepseek-ai/dsh-api-terminal-controller/remote'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type {
-  TerminalShell, TerminalAttachmentId, TerminalEnvironment, TerminalFrame,
+  TerminalAttachmentId, TerminalEnvironment, TerminalFrame,
   WebTerminalId, WebTerminalInfo,
 } from '../types.ts'
 
@@ -36,9 +36,7 @@ export interface TerminalRenderFrame {
 
 /** Observable state of one sidebar occurrence. */
 export interface TerminalViewState {
-  readonly phase: 'idle' | 'loading' | 'selecting' | 'creating' | 'connecting' | 'connected' | 'disconnected' | 'closing' | 'closed' | 'failed'
-  readonly shells?: readonly TerminalShell[] | undefined
-  readonly selectedShell?: string | undefined
+  readonly phase: 'idle' | 'loading' | 'creating' | 'connecting' | 'connected' | 'disconnected' | 'closing' | 'closed' | 'failed'
   readonly environment?: TerminalEnvironment | undefined
   readonly title?: string | undefined
   readonly info?: WebTerminalInfo | undefined
@@ -71,6 +69,7 @@ export class TerminalView {
    * @param gateway - reconnecting stream factory.
    * @param id - Host terminal identity, reused when recovering an item from its Session list.
    * @param createWhenMissing - allow allocation only for a new tab, never a listed terminal.
+   * @param shellPath - explicit shell chosen at the guide; omission uses the remembered available shell.
    */
   constructor(
     private readonly sessionId: SessionId,
@@ -78,10 +77,11 @@ export class TerminalView {
     private readonly gateway: Pick<ClientRemote, '$stream'>,
     readonly id: WebTerminalId,
     private readonly createWhenMissing = true,
+    private readonly shellPath?: string,
   ) {}
 
   /**
-   * Attach the DOM lifetime, loading shell choices or reconnecting the saved process.
+   * Attach the DOM lifetime, starting the chosen shell or reconnecting the saved process.
    * @returns a detach callback that leaves the terminal process alive.
    */
   mount(): () => void {
@@ -113,49 +113,29 @@ export class TerminalView {
       const info = valueOf(available).find(item => item.id === this.id)
       if (info !== undefined) this.adopt(info)
       else if (this.createWhenMissing) {
-        const shells = valueOf(await this.remote.shells(this.sessionId, this.lifetime.signal))
+        let path = this.shellPath
+        if (path === undefined) {
+          const shells = valueOf(await this.remote.shells(this.sessionId, this.lifetime.signal))
+          const previous = preferredShell()
+          path = shells.find(shell => shell.path === previous)?.path ?? shells[0]?.path
+        }
         if (this.stopped()) return
-        const previous = this.state.getSnapshot().selectedShell ?? preferredShell()
-        const selectedShell = shells.find(shell => shell.path === previous)?.path ?? shells[0]?.path
-        this.patch({ phase: 'selecting', shells, selectedShell })
+        if (path !== undefined) rememberShell(path)
+        await this.create(valueOf(environment), path)
       }
       else throw new TerminalViewError('missingTerminal')
     })().catch((error: unknown) => { this.fail(error) }).finally(() => { this.loading = undefined })
     return this.loading
   }
 
-  /**
-   * Select and remember one verified shell before starting the terminal.
-   * @param path - executable path offered by Host discovery.
-   */
-  selectShell(path: string): void {
-    const state = this.state.getSnapshot()
-    if (state.phase === 'selecting' && state.shells?.some(shell => shell.path === path)) {
-      this.patch({ selectedShell: path })
-      rememberShell(path)
-    }
-  }
-
-  /**
-   * Start the selected shell once; failed attempts retain their selection for retry.
-   * @returns after allocation settles; failures are exposed in observable state.
-   */
-  start(): Promise<void> {
-    if (this.creation !== undefined) return this.creation
-    const state = this.state.getSnapshot()
-    if (state.phase !== 'selecting' || state.environment === undefined || state.selectedShell === undefined
-      || this.closing !== undefined || this.lifetime.signal.aborted) return Promise.resolve()
-    rememberShell(state.selectedShell)
-    return this.create(state.environment, state.selectedShell)
-  }
-
   private stopped(): boolean { return this.lifetime.signal.aborted || this.closing !== undefined }
 
-  private async create(environment: TerminalEnvironment, shellPath: string): Promise<void> {
+  private async create(environment: TerminalEnvironment, shellPath?: string): Promise<void> {
     this.patch({ phase: 'creating', error: undefined, issue: undefined })
     this.creation = (async () => {
       const info = valueOf(await this.remote.create(this.sessionId, {
-        id: this.id, shellPath, cols: Math.min(80, environment.maxCols), rows: Math.min(24, environment.maxRows),
+        id: this.id, ...shellPath === undefined ? {} : { shellPath },
+        cols: Math.min(80, environment.maxCols), rows: Math.min(24, environment.maxRows),
       }, this.lifetime.signal))
       if (!this.lifetime.signal.aborted) {
         this.adopt(info)

+ 0 - 5
packages/api/terminal-controller/tests/model.client.spec.ts

@@ -62,7 +62,6 @@ function fixture(prepareStream?: <Item>(stream: RemoteStream<Item>) => void) {
 async function mount(model: TerminalView) {
   const detach = model.mount()
   await model.refresh()
-  await model.start()
   return detach
 }
 
@@ -436,7 +435,6 @@ it('classifies a discovery carrier failure as disconnected and supports explicit
   expect(model.state.getSnapshot()).toMatchObject({ phase: 'disconnected', error: 'offline' })
   expect(remote.create).not.toHaveBeenCalled()
   await model.refresh()
-  await model.start()
   expect(model.state.getSnapshot().info?.id).toBe(info.id)
   expect(remote.create).toHaveBeenCalledOnce()
 })
@@ -449,14 +447,12 @@ it('retains state when disposal overtakes successful allocation or a failed disc
     if (operation === 'creation') vi.mocked(remote.create).mockReturnValueOnce(creation.promise)
     else vi.mocked(remote.environment).mockReturnValueOnce(discovery.promise)
     const loading = model.refresh()
-    const starting = operation === 'creation' ? loading.then(() => model.start()) : undefined
     if (operation === 'creation') await expect.poll(() => remote.create).toHaveBeenCalledOnce()
     await model.dispose()
     const before = model.state.getSnapshot()
     if (operation === 'creation') creation.resolve(success(info))
     else discovery.reject(new Error('late discovery failure'))
     await loading
-    await starting
     expect(model.state.getSnapshot()).toBe(before)
     expect(remote.follow).not.toHaveBeenCalled()
   }
@@ -560,7 +556,6 @@ it('exposes a localized quota error and clears it after a successful retry', asy
   const { model, remote } = fixture()
   vi.mocked(remote.create).mockResolvedValueOnce({ ok: false, error: new RemoteError('terminal/limit-reached', 'Session terminal limit reached', { limit: 8 }) })
   await model.refresh()
-  await model.start()
   expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', issue: 'terminalLimit' })
   await connected(model)
   expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', issue: undefined, error: undefined })

+ 41 - 47
packages/api/terminal-controller/tests/recovery.client.spec.ts

@@ -59,7 +59,7 @@ function fixture() {
   return { remote, view, service }
 }
 
-it('waits for shell selection and deduplicates overlapping starts', async () => {
+it('starts automatically and deduplicates overlapping mounts and refreshes', async () => {
   const h = fixture()
   const model = h.view()
   const creation = Promise.withResolvers<RemoteResult<WebTerminalInfo>>()
@@ -67,14 +67,11 @@ it('waits for shell selection and deduplicates overlapping starts', async () =>
   model.mount()
   const loading = model.refresh()
   expect(model.refresh()).toBe(loading)
-  await loading
-  expect(h.remote.create).not.toHaveBeenCalled()
-  const starting = model.start()
+  await expect.poll(() => h.remote.create).toHaveBeenCalledOnce()
   const remounting = model.refresh()
-  void model.start()
   expect(vi.mocked(h.remote.create).mock.calls[0]?.[1]).toEqual({ id: info.id, shellPath: info.shell.path, cols: 80, rows: 24 })
   creation.resolve(success(info))
-  await starting
+  await loading
   await remounting
   await expect.poll(() => model.state.getSnapshot().writable).toBe(true)
   expect(h.remote.create).toHaveBeenCalledOnce()
@@ -111,10 +108,8 @@ it('retries lost create acknowledgements with the saved id and closes after a re
   const model = h.view()
   vi.mocked(h.remote.create).mockResolvedValueOnce(failure('response lost'))
   await model.refresh()
-  await model.start()
   expect(model.state.getSnapshot().error).toBe('response lost')
   await model.refresh()
-  await model.start()
   expect(vi.mocked(h.remote.create).mock.calls.map(call => call[1].id)).toEqual([info.id, info.id])
   await model.close()
   expect(h.remote.close).toHaveBeenCalledWith(sessionId, info.id)
@@ -126,8 +121,7 @@ it('waits for an in-flight creation while close detaches immediately and prevent
   const creation = Promise.withResolvers<RemoteResult<WebTerminalInfo>>()
   vi.mocked(h.remote.create).mockReturnValueOnce(creation.promise)
   const unmount = model.mount()
-  await model.refresh()
-  const starting = model.start()
+  const starting = model.refresh()
   await expect.poll(() => vi.mocked(h.remote.create).mock.calls.length).toBe(1)
   const closing = model.close()
   expect(model.close()).toBe(closing)
@@ -330,14 +324,14 @@ it('saves close intents without persisting any active terminal or sidebar state'
   const model = service.view(sessionId, 'new-tab')
   await model.refresh()
   expect(model.id).toMatch(/^[0-9a-f-]{36}$/)
-  expect(data.size).toBe(0)
+  expect([...data.entries()]).toEqual([['dsh.terminal.shell', info.shell.path]])
   const pending = Promise.withResolvers<RemoteResult<void>>()
   vi.mocked(h.remote.close).mockReturnValueOnce(pending.promise)
   service.close(sessionId, 'new-tab')
-  const request = { sessionId, id: model.id, title: 'new-tab' }
-  expect([...data.entries()]).toEqual([[`dsh.terminal.close.v1.${model.id}`, JSON.stringify(request)]])
+  const request = { sessionId, id: model.id, title: info.title }
+  expect([...data.entries()]).toEqual([['dsh.terminal.shell', info.shell.path], [`dsh.terminal.close.v1.${model.id}`, JSON.stringify(request)]])
   pending.resolve(success(undefined))
-  await expect.poll(() => data.size).toBe(0)
+  await expect.poll(() => [...data.keys()]).toEqual(['dsh.terminal.shell'])
 })
 
 it.each(['{broken', 'null', '{}', '[{}]', '{"sessionId":"s","id":"bad/id","title":"x"}', '{"sessionId":"s","id":"different","title":"x"}'])('discards malformed saved cleanup: %s', (raw) => {
@@ -394,7 +388,7 @@ it.each(['saved', 'view'] as const)('clears a %s close request after the Host co
   }
   await expect.poll(() => vi.mocked(h.remote.close).mock.calls.length).toBe(1)
   await dispose()
-  expect(data.size).toBe(0)
+  expect([...data.entries()]).toEqual(source === 'view' ? [['dsh.terminal.shell', info.shell.path]] : [])
   expect(service.closeFailures.getSnapshot()).toEqual([])
   expect(new TerminalCloseRequests().pending()).toEqual([])
   await h.service()
@@ -426,7 +420,6 @@ it('waits for both active and detached stream finalizers during plugin disposal
   const model = service.view(sessionId, 'tab')
   model.mount()
   await model.refresh()
-  await model.start()
   await expect.poll(() => model.state.getSnapshot().writable).toBe(true)
   model.connect()
   await started[0]!.promise
@@ -445,53 +438,55 @@ it('waits for both active and detached stream finalizers during plugin disposal
   expect(h.remote.close).not.toHaveBeenCalled()
 })
 
-it('remembers a shell choice immediately for new views and falls back when that shell is absent', async () => {
+it('discovers menu choices without creating a process and remembers a choice before opening its tab', async () => {
   const data = storage()
   const h = fixture()
   const alternate = { name: 'bash', path: '/bin/bash', args: ['-i'] }
   vi.mocked(h.remote.shells).mockResolvedValue(success([info.shell, alternate]))
-  vi.mocked(h.remote.create).mockImplementation(async (_sessionId, request) => success({ ...info, shell: alternate, id: request.id }))
-  const first = h.view()
-  await first.start()
-  first.selectShell(alternate.path)
-  await first.refresh()
-  expect(first.state.getSnapshot()).toMatchObject({ phase: 'selecting', selectedShell: info.shell.path })
-  first.selectShell('/not-listed')
-  expect(first.state.getSnapshot().selectedShell).toBe(info.shell.path)
-  first.selectShell(alternate.path)
+  const { service } = await h.service()
+  expect(await service.launchShells(sessionId, new AbortController().signal)).toEqual({
+    shells: [info.shell, alternate], selectedShell: info.shell.path,
+  })
+  service.selectShell(alternate.path)
   expect(data.get('dsh.terminal.shell')).toBe(alternate.path)
-  const beforeLaunch = h.view()
-  await beforeLaunch.refresh()
-  expect(beforeLaunch.state.getSnapshot().selectedShell).toBe(alternate.path)
   expect(h.remote.create).not.toHaveBeenCalled()
-  await first.start()
-  expect(data.get('dsh.terminal.shell')).toBe(alternate.path)
-  expect(h.remote.create).toHaveBeenCalledWith(sessionId, expect.objectContaining({ shellPath: alternate.path }), expect.any(AbortSignal))
-  const second = h.view()
-  await second.refresh()
-  expect(second.state.getSnapshot().selectedShell).toBe(alternate.path)
-  await second.dispose()
-  await second.start()
+  expect((await service.launchShells(sessionId, new AbortController().signal)).selectedShell).toBe(alternate.path)
+  const model = service.view(sessionId, 'chosen', undefined, alternate.path)
+  await model.refresh()
+  expect(h.remote.create).toHaveBeenLastCalledWith(
+    sessionId, expect.objectContaining({ shellPath: alternate.path }), expect.any(AbortSignal),
+  )
+  expect(h.remote.shells).toHaveBeenCalledTimes(2)
+  await h.view().refresh()
+  expect(h.remote.create).toHaveBeenLastCalledWith(
+    sessionId, expect.objectContaining({ shellPath: alternate.path }), expect.any(AbortSignal),
+  )
   vi.mocked(h.remote.shells).mockResolvedValue(success([info.shell]))
-  const third = h.view()
-  await third.refresh()
-  expect(third.state.getSnapshot().selectedShell).toBe(info.shell.path)
-  expect(h.remote.create).toHaveBeenCalledOnce()
+  await h.view().refresh()
+  expect(h.remote.create).toHaveBeenLastCalledWith(
+    sessionId, expect.objectContaining({ shellPath: info.shell.path }), expect.any(AbortSignal),
+  )
+  vi.mocked(h.remote.shells).mockResolvedValueOnce(failure('host offline'))
+  await expect(service.launchShells(sessionId, new AbortController().signal)).rejects.toThrow('host offline')
+  vi.mocked(h.remote.shells).mockResolvedValue(success([]))
+  expect((await service.launchShells(sessionId, new AbortController().signal)).selectedShell).toBeUndefined()
+  await h.view().refresh()
+  expect(vi.mocked(h.remote.create).mock.calls.at(-1)?.[1]).not.toHaveProperty('shellPath')
 })
 
-it('retains the last selection across a failed launch', async () => {
+it('retains the last selection across a failed automatic launch', async () => {
   const data = storage()
   const h = fixture()
   vi.mocked(h.remote.create).mockResolvedValueOnce(failure('shell disappeared'))
   const model = h.view()
   await model.refresh()
-  await model.start()
   expect(data.get('dsh.terminal.shell')).toBe(info.shell.path)
+  expect(model.state.getSnapshot().error).toBe('shell disappeared')
   await model.refresh()
-  expect(model.state.getSnapshot().selectedShell).toBe(info.shell.path)
+  expect(h.remote.create).toHaveBeenCalledTimes(2)
   await model.close()
-  await model.start()
-  expect(h.remote.create).toHaveBeenCalledOnce()
+  await model.refresh()
+  expect(h.remote.create).toHaveBeenCalledTimes(2)
 })
 
 it('keeps launch usable when browser storage is denied and stops late shell discovery after close', async () => {
@@ -500,7 +495,6 @@ it('keeps launch usable when browser storage is denied and stops late shell disc
   const h = fixture()
   const model = h.view()
   await model.refresh()
-  await model.start()
   expect(model.state.getSnapshot().info).toBeDefined()
   const delayed = h.view()
   const shells = Promise.withResolvers<Awaited<ReturnType<TerminalRemote['shells']>>>()

+ 1 - 0
packages/client/ui-sidebar-files/src/client/definition.tsx

@@ -33,6 +33,7 @@ export function filesDefinition(t: TranslateNS<'sidebarFiles'>): SidebarRightTab
     priority: 'builtin',
     title: () => t('type.label'),
     guide: [{
+      id: 'workspace',
       order: 10,
       title: () => t('guide.title'),
       description: () => t('guide.description'),

+ 2 - 2
packages/client/ui-sidebar-right/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-sidebar-right/README.md
-README.md: 75c600edf014564894c9299422c840bce15eb8e8
-README.zh.md: 203bc30d0550234e9ed97c1ccac70bed433d0605
+README.md: e5d6e1bef68296e77c6986137bba83ad3f888f8d
+README.zh.md: 75cd092b5cc8602975340b1e434ade2abf7fb442

+ 3 - 1
packages/client/ui-sidebar-right/README.md

@@ -98,11 +98,13 @@ Tab owners register `registerCloseHandler(kind, handler)` through an effect. A h
 <a id="the-guide"></a>
 ## The guide
 
+Each `guide` entry has an `id` unique within its provider. The guide renders `sidebar.right.tab.guide.entry` by the active definition id, forwarding the entry id, resolved title and optional description, plus the framework `useTabInfo` hook. A provider can register a custom card under its definition id; otherwise the standard icon/title button renders. The sidebar owns width and placement, while custom components own their markup and interaction. Entry identity remains stable across sorting and unrelated registrations.
+
 A tab type can declare `multiple: true` for independent instances such as terminals. Each open receives a distinct content address, so placement and docking preserve those instances; ordinary pages retain per-pane uniqueness.
 
 Default pages depend on the number of registered guide entries, not the number of tab types or open tabs. Exactly one entry opens its page directly; zero or multiple entries open the guide. Explicitly adding a guide still opens the guide, even with one entry. The sole docked guide is the only tab that cannot close; closing any other sole tab also collapses the column. The chip, context menu, and `close` API apply the same rule.
 
-The guide tab is a muted compass over one entry capsule per `guide` entry the registered types contributed, in `order`, centred in the body; the guide has no words of its own. A capsule shows the entry's glyph — or the guide's quieter cube placeholder when the entry registered none — and its title; while at most four entries are listed, an entry that registered a `description` shows it under the title, and a longer list drops every description. Picking a capsule calls `tab.actions.openTab(entry.kind, { replaceTab: true })`, so the guide gives way to the page it opened. A pane holds at most one guide tab. The strip's add control is drawn only while its pane holds none and opens one there with `openTab('guide', { paneId, revealIfOpened: false })`, so a guide in another pane does not capture the click; opening the guide into a pane that already has one focuses it instead; a guide dragged, dropped, or docked into such a pane merges into it — the arriving guide closes and the pane's own is focused; `duplicateTab` on the guide records nothing. A split, an expanded empty root pane, and the pane a sole tab vacates by dropping on its own edge use the same default-page rule, one tab per new pane; the self-edge drop leaves the dragged tab focused. A plain `openTab('guide')` opens or focuses the guide only within the active or named pane. Splitting an empty pane does nothing and returns no new pane. The product allows two horizontal panes, initially equal, with divider ratios limited to 20%–80%. Insufficient width blocks a new split; with two panes already present, a body drop moves the tab between panes instead of creating a third. At the two-pane limit, split controls are hidden; closing back to one pane restores them.
+The guide tab is a muted compass over one entry capsule per `guide` entry the registered types contributed, in `order`, centred in the body; the guide has no words of its own. A capsule shows the entry's glyph — or the guide's quieter cube placeholder when the entry registered none — and its title; while at most four entries are listed, an entry that registered a `description` shows it under the title, and a longer list drops every description. Picking a default capsule calls `tab.actions.openTab(entry.kind, { replaceTab: true })`, so the guide gives way to the page it opened. A pane holds at most one guide tab. The strip's add control is drawn only while its pane holds none and opens one there with `openTab('guide', { paneId, revealIfOpened: false })`, so a guide in another pane does not capture the click; opening the guide into a pane that already has one focuses it instead; a guide dragged, dropped, or docked into such a pane merges into it — the arriving guide closes and the pane's own is focused; `duplicateTab` on the guide records nothing. A split, an expanded empty root pane, and the pane a sole tab vacates by dropping on its own edge use the same default-page rule, one tab per new pane; the self-edge drop leaves the dragged tab focused. A plain `openTab('guide')` opens or focuses the guide only within the active or named pane. Splitting an empty pane does nothing and returns no new pane. The product allows two horizontal panes, initially equal, with divider ratios limited to 20%–80%. Insufficient width blocks a new split; with two panes already present, a body drop moves the tab between panes instead of creating a third. At the two-pane limit, split controls are hidden; closing back to one pane restores them.
 
 <a id="copy"></a>
 ## Copy

+ 3 - 1
packages/client/ui-sidebar-right/README.zh.md

@@ -98,11 +98,13 @@ Tab域按(Session,Tab id)保留导航、中止信号与绑定动作;私
 <a id="the-guide"></a>
 ## 引导页
 
+每个 `guide` 入口具有 provider 内唯一的 `id`。开始页按当前生效的定义 id 渲染 `sidebar.right.tab.guide.entry`,传入入口 id、解析后的标题、可选描述,以及框架提供的 `useTabInfo` hook。provider 可按自己的定义 id 注册自定义卡片;未注册时使用默认图标与标题按钮。侧栏负责宽度和排列,自定义组件负责内部结构和交互。排序或其他注册变化不会改变入口身份。
+
 终端等需要独立实例的 tab 类型可声明 `multiple: true`。每次打开都会获得独立内容地址,因此移动和停靠会保留这些实例;普通页面仍在每个格内去重。
 
 默认页取决于已注册的引导入口数,不取决于 tab 类型数或已打开的 tab 数。恰好一个入口时直接打开对应页面;没有入口或有多个入口时打开引导页。即使只有一个入口,显式添加引导页仍会打开引导页。只有作为唯一停靠 tab 的引导页不可关闭;关闭其它任何唯一 tab 时会同时收起整列。chip、上下文菜单与 `close` API 使用同一规则。
 
-引导 tab 是一枚弱化的罗盘,下方是各已注册类型贡献的每个 `guide` 条目一个入口胶囊,按 `order` 排列并在正文中居中;引导页自己没有文字。胶囊显示条目的图标——条目没注册图标时用引导页自己更浅的立方体占位符——和标题;列出的条目不超过四个时,注册了 `description` 的条目在标题下显示它,更长的列表则去掉所有描述。选中一个胶囊会调用 `tab.actions.openTab(entry.kind, { replaceTab: true })`,于是引导页让位给它打开的页。一个格最多持有一个引导 tab。tab 条的添加控件只在该格没有引导 tab 时绘制,并以 `openTab('guide', { paneId, revealIfOpened: false })` 在该格打开一个,这样别的格里的引导页不会截走这次点击;把引导页开进已有引导页的格则改为聚焦它;把引导页拖入、放入或收回到这样的格会合并进去——来者关闭,该格自己的被聚焦;对引导页 `duplicateTab` 不记录任何东西。分栏、展开且为空的根格、以及唯一 tab 拖到本格边缘分屏后腾出的格使用相同的默认页规则,每个新格一个 tab;这种本格边缘拖放让被拖的 tab 保持聚焦。普通的 `openTab('guide')` 只在活跃或指定分栏内打开或聚焦引导页。对空分栏执行 split 不产生变化,也不返回新分栏。产品最多保留左右两格,默认均分,分隔条限定在 20%~80%。宽度不足以容纳两格时不允许新分栏;已有两格时,正文拖放用于跨格移动,不再创建第三格。达到两格上限时隐藏分栏控件;关闭回单格后恢复。
+引导 tab 是一枚弱化的罗盘,下方是各已注册类型贡献的每个 `guide` 条目一个入口胶囊,按 `order` 排列并在正文中居中;引导页自己没有文字。胶囊显示条目的图标——条目没注册图标时用引导页自己更浅的立方体占位符——和标题;列出的条目不超过四个时,注册了 `description` 的条目在标题下显示它,更长的列表则去掉所有描述。点击默认胶囊会调用 `tab.actions.openTab(entry.kind, { replaceTab: true })`,于是引导页让位给它打开的页。一个格最多持有一个引导 tab。tab 条的添加控件只在该格没有引导 tab 时绘制,并以 `openTab('guide', { paneId, revealIfOpened: false })` 在该格打开一个,这样别的格里的引导页不会截走这次点击;把引导页开进已有引导页的格则改为聚焦它;把引导页拖入、放入或收回到这样的格会合并进去——来者关闭,该格自己的被聚焦;对引导页 `duplicateTab` 不记录任何东西。分栏、展开且为空的根格、以及唯一 tab 拖到本格边缘分屏后腾出的格使用相同的默认页规则,每个新格一个 tab;这种本格边缘拖放让被拖的 tab 保持聚焦。普通的 `openTab('guide')` 只在活跃或指定分栏内打开或聚焦引导页。对空分栏执行 split 不产生变化,也不返回新分栏。产品最多保留左右两格,默认均分,分隔条限定在 20%~80%。宽度不足以容纳两格时不允许新分栏;已有两格时,正文拖放用于跨格移动,不再创建第三格。达到两格上限时隐藏分栏控件;关闭回单格后恢复。
 
 <a id="copy"></a>
 ## 文案

+ 17 - 1
packages/client/ui-sidebar-right/src/client/contract/slots.ts

@@ -16,7 +16,7 @@
  *   actions that are gestures on the layout itself; this seat is for actions that
  *   mean something about the tab's content.
  *
- * TYPE HOME RATIONALE: this package declares all four at runtime, and anything
+ * TYPE HOME RATIONALE: this package declares these slots at runtime, and anything
  * registering into one already depends on it for the declaration. The types
  * therefore live with their declarer.
  */
@@ -78,6 +78,14 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
       hookContext: UseSidebarRightTabInfo
       inject: { hooks: { tabInfo: SlotHookFactory<'sidebar.right.tab.guide', UseSidebarRightTabInfo> } }
     }
+    /** One provider's guide card, with the standard card as the owner's fallback. */
+    'sidebar.right.tab.guide.entry': {
+      kind: 'keyed'
+      scope: 'session'
+      owner: SidebarRightGuideEntryOwnerProps
+      hookContext: UseSidebarRightTabInfo
+      inject: { hooks: { tabInfo: SlotHookFactory<'sidebar.right.tab.guide.entry', UseSidebarRightTabInfo> } }
+    }
     /**
      * Extra items at the end of one tab's actions menu, in registration order.
      * Entries decide their own visibility from the tab they are given. Without a
@@ -171,3 +179,11 @@ export interface SidebarRightTabMenuOwnerProps {
    */
   dismiss: () => void
 }
+
+/** Resolved guide copy and entry identity supplied to a provider's card renderer. */
+export interface SidebarRightGuideEntryOwnerProps {
+  readonly entryId: string
+  readonly kind: string
+  readonly title: string
+  readonly description?: string
+}

+ 4 - 1
packages/client/ui-sidebar-right/src/client/index.ts

@@ -57,7 +57,7 @@ export type {
 } from './tab-registry.ts'
 export type {
   SidebarRightTabInfo, SidebarRightTabInjected, UseSidebarRightTabInfo, SidebarRightTabActions,
-  SidebarRightTabMenuOwnerProps, SidebarRightTabNavigation, SidebarRightTabPlacement,
+  SidebarRightTabMenuOwnerProps, SidebarRightTabNavigation, SidebarRightTabPlacement, SidebarRightGuideEntryOwnerProps,
 } from './contract/slots.ts'
 export type {
   SidebarRightNavigationParams, SidebarRightResourceParams, SidebarRightResourceParamsMap,
@@ -189,6 +189,9 @@ export function apply(ctx: ClientContext): void {
       name: 'sidebar.right.pane.tab',
       key: GUIDE_ID,
       children: {
+        'sidebar.right.tab.guide.entry': {
+          kind: 'keyed', scope: 'session', inject: { hooks: { tabInfo: guideTabInfoFactory } },
+        },
         'sidebar.right.tab.guide': {
           kind: 'chain', scope: 'session', inject: { hooks: { tabInfo: guideTabInfoFactory } },
         },

+ 3 - 3
packages/client/ui-sidebar-right/src/client/tab-info.ts

@@ -51,10 +51,10 @@ export const tabInfoFactory: SlotHookFactory<'sidebar.right.pane.tab', UseSideba
 }
 
 /**
- * Forward the framework-bound tab hook to a guide replacement.
+ * Forward the framework-bound tab hook to guide content.
  * @param _standard - the guide's framework standard props.
  * @param useTabInfo - the enclosing tab's framework-bound reader.
  * @returns the same reader for the replacement.
  */
-export const guideTabInfoFactory: SlotHookFactory<'sidebar.right.tab.guide', UseSidebarRightTabInfo> =
-  (_standard, useTabInfo) => useTabInfo
+export const guideTabInfoFactory =
+  (_standard: unknown, useTabInfo: UseSidebarRightTabInfo): UseSidebarRightTabInfo => useTabInfo

+ 7 - 1
packages/client/ui-sidebar-right/src/client/tab-registry.ts

@@ -59,6 +59,8 @@ const DEFAULT_BAND: SidebarRightTabPriority = 'extension'
 
 /** One entry capsule the guide page offers, contributed by the type it opens (picking it opens that type as a page). */
 export interface SidebarRightGuideEntry {
+  /** Stable entry identity within its provider. */
+  readonly id: string
   /** Ascending position among every registered type's entries. */
   readonly order: number
   /**
@@ -79,6 +81,8 @@ export interface SidebarRightGuideEntry {
 
 /** A guide entry as the registry lists it: with the kind of the type that contributed it, which is what picking it opens. */
 export interface SidebarRightGuideBox extends SidebarRightGuideEntry {
+  /** Active implementation identity used to dispatch the entry renderer. */
+  readonly providerId: string
   readonly kind: string
 }
 
@@ -243,6 +247,8 @@ export class SidebarRightTabRegistry {
    */
   register(definition: SidebarRightTabDefinition): () => void {
     const { id, kind } = definition
+    const entries = definition.guide ?? []
+    if (new Set(entries.map(entry => entry.id)).size !== entries.length) throw new Error(`sidebarRight: duplicate guide entry id in "${id}"`)
     const band = definition.priority ?? DEFAULT_BAND
     if (this.ids.has(id)) throw new Error(`sidebarRight: tab type id "${id}" is already registered`)
     const held = this.kinds.get(kind)
@@ -396,7 +402,7 @@ export class SidebarRightTabRegistry {
   private refresh(): void {
     this.cached = this.active().map(entry => entry.definition)
     this.guideEntries = this.cached
-      .flatMap(definition => (definition.guide ?? []).map(entry => ({ ...entry, kind: definition.kind })))
+      .flatMap(definition => (definition.guide ?? []).map(entry => ({ ...entry, kind: definition.kind, providerId: definition.id })))
       .sort((left, right) => left.order - right.order)
     notifySubscribers(this.listeners, '[ui-sidebar-right] tab registry')
   }

+ 2 - 0
packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.module.css

@@ -117,3 +117,5 @@
   flex: none;
   color: var(--dsw-alias-label-tertiary);
 }
+
+.entryCell { width: 380px; max-width: 100%; }

+ 18 - 12
packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx

@@ -33,7 +33,7 @@ export interface GuideInjected {
 /** The guide body's composed props: the tab it draws, its chain child, and the entries. */
 export type GuideBodyProps =
   & PropsRuntime<'sidebar.right.pane.tab'>
-  & PropsRenderSlots<'sidebar.right.tab.guide'>
+  & PropsRenderSlots<'sidebar.right.tab.guide' | 'sidebar.right.tab.guide.entry'>
   & InjectFace<GuideInjected>
 
 /** Entry count past which the guide drops the capsules' descriptions to stay light. */
@@ -67,30 +67,36 @@ function EntryBox({ entry, described, onPick }: {
 }
 
 /** The shipped guide: the tab's own compass over the doors out of the column. */
-function ShippedGuide({ entries, onPick }: {
-  entries: readonly SidebarRightGuideBox[]
-  onPick: (entry: SidebarRightGuideBox) => void
-}): ReactNode {
+function ShippedGuide({ children }: { children: ReactNode }): ReactNode {
   return (
     <div className={css.guide} data-sidebar-right-guide>
       <span className={css.hero} aria-hidden="true"><CompassGlyph size={56} /></span>
-      {/* Keyed by position in the ordered list: one type may contribute several capsules, and `order` is not unique. */}
-      {entries.map((entry, index) => (
-        <EntryBox key={`${entry.kind}:${index}`} entry={entry} described={entries.length <= MAX_DESCRIBED_ENTRIES} onPick={onPick} />
-      ))}
+      {children}
     </div>
   )
 }
 
 /** The guide tab's body, replaceable through its chain child. */
-export function GuideBody({ useTabInfo, useGuideEntries, renderSlotChain }: GuideBodyProps): ReactNode {
+export function GuideBody({ useTabInfo, useGuideEntries, renderSlot, renderSlotChain }: GuideBodyProps): ReactNode {
   const { tab } = useTabInfo()
   const entries = useGuideEntries(entries => entries)
   const options = {
     hookContext: useTabInfo,
     fallback: (
-      <ShippedGuide entries={entries}
-        onPick={(entry) => { tab.actions.openTab(entry.kind, { replaceTab: true }) }} />
+      <ShippedGuide>{entries.map((entry) => {
+        const described = entries.length <= MAX_DESCRIBED_ENTRIES
+        const description = described ? entry.description?.() : undefined
+        return <div key={JSON.stringify([entry.providerId, entry.id])} className={css.entryCell}>
+          {renderSlot('sidebar.right.tab.guide.entry', {
+            entryId: entry.id, kind: entry.kind, title: entry.title(),
+            ...description === undefined ? {} : { description },
+          }, {
+            entryKey: entry.providerId, hookContext: useTabInfo,
+            fallback: <EntryBox entry={entry} described={described}
+              onPick={(selected) => { tab.actions.openTab(selected.kind, { replaceTab: true }) }} />,
+          })}
+        </div>
+      })}</ShippedGuide>
     ),
   } satisfies ChainRenderOpts & { hookContext: HookContextOf<'sidebar.right.tab.guide'> }
   return renderSlotChain('sidebar.right.tab.guide', {}, options)

+ 1 - 1
packages/client/ui-sidebar-right/tests/apply.client.spec.ts

@@ -179,7 +179,7 @@ describe('ui-sidebar-right apply', () => {
       id: 'spec/files',
       kind: 'files',
       title: () => 'Files',
-      guide: [{ order: 10, title: () => 'Files' }],
+      guide: [{ id: 'default', order: 10, title: () => 'Files' }],
     })
     expect(seen).toHaveBeenCalledOnce()
     expect(guideEntries.getSnapshot().map(entry => entry.kind)).toEqual(['files'])

+ 23 - 4
packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx

@@ -30,7 +30,7 @@ function Glyph({ size }: IconProps): ReactNode {
 /** One entry capsule as the registry lists it. */
 function box(kind: string, order: number, icon?: SidebarRightGuideBox['icon'], description?: string): SidebarRightGuideBox {
   return {
-    kind,
+    kind, providerId: `provider/${kind}`, id: `${kind}-${order}`,
     order,
     title: () => `${kind} title`,
     ...icon === undefined ? {} : { icon },
@@ -42,7 +42,7 @@ function box(kind: string, order: number, icon?: SidebarRightGuideBox['icon'], d
  * Mount the body with the entries observable and a chain that renders its
  * fallback, which is what the chain does with no registrant.
  */
-function mountGuide(entries: readonly SidebarRightGuideBox[]) {
+function mountGuide(entries: readonly SidebarRightGuideBox[], custom?: (key: string) => ReactNode) {
   const guideEntries = createSnapshotStore<readonly SidebarRightGuideBox[]>(entries)
   const openTab = vi.fn()
   const renderSlot = vi.fn((_seat: string, _owner: unknown, options: { fallback: ReactNode }) => options.fallback)
@@ -50,11 +50,13 @@ function mountGuide(entries: readonly SidebarRightGuideBox[]) {
     useTabInfo: () => ({ tab: { ...TAB, actions: { openResource: vi.fn(), openTab, close: vi.fn() } } }),
     useGuideEntries: bindSnapshotSelector(guideEntries),
     renderSlotChain: renderSlot,
+    renderSlot: vi.fn((_slot: string, _owner: unknown, options: { entryKey: string; fallback: ReactNode }) =>
+      custom?.(options.entryKey) ?? options.fallback),
   } as unknown as GuideBodyProps
   const view = render(<GuideBody {...props} />)
   const boxes = (): string[] =>
     [...view.container.querySelectorAll('[data-sidebar-right-guide-entry]')].map(node => node.getAttribute('data-sidebar-right-guide-entry') ?? '')
-  return { view, guideEntries, openTab, renderSlot, boxes, useTabInfo: props.useTabInfo }
+  return { view, guideEntries, openTab, renderSlot, entrySlot: props.renderSlot, boxes, useTabInfo: props.useTabInfo }
 }
 
 describe('GuideBody', () => {
@@ -120,7 +122,10 @@ describe('GuideBody', () => {
   it('keeps two boxes of one type at the same order apart', () => {
     const errors = vi.spyOn(console, 'error').mockImplementation(() => {})
     try {
-      const { boxes } = mountGuide([box('notes', 10), box('notes', 10)])
+      const { boxes } = mountGuide([
+        { ...box('notes', 10), providerId: 'a:b', id: 'c' },
+        { ...box('notes', 10), providerId: 'a', id: 'b:c' },
+      ])
       expect(boxes()).toEqual(['notes', 'notes'])
       // React reports colliding keys through console.error; two boxes rendered
       // without one is the whole assertion.
@@ -131,3 +136,17 @@ describe('GuideBody', () => {
     }
   })
 })
+
+it('dispatches custom guide content by active provider identity and retains the fallback for other entries', () => {
+  const custom = (key: string) => key === 'extension/terminal' ? <button>Choose a shell</button> : undefined
+  const terminal = { ...box('terminal', 20), providerId: 'extension/terminal' }
+  const h = mountGuide([box('files', 10), terminal], custom)
+  expect(h.view.getByText('Choose a shell')).toBeDefined()
+  expect(h.view.getByText('files title')).toBeDefined()
+  expect(h.entrySlot).toHaveBeenCalledWith('sidebar.right.tab.guide.entry', {
+    entryId: terminal.id, kind: 'terminal', title: 'terminal title',
+  }, expect.objectContaining({ entryKey: 'extension/terminal', hookContext: h.useTabInfo }))
+  act(() => { h.guideEntries.set([{ ...terminal, providerId: 'builtin/terminal' }]) })
+  expect(h.view.queryByText('Choose a shell')).toBeNull()
+  expect(h.view.getByText('terminal title')).toBeDefined()
+})

+ 2 - 2
packages/client/ui-sidebar-right/tests/seat.client.spec.tsx

@@ -94,7 +94,7 @@ async function mountSeat(viewportWidth = 1440, canShow = true, entryCount = 0) {
     runtime.ctx.sidebarRightTabs.register({
       id: 'test/text', kind: 'text', priority: 'builtin', patterns: ['dsh-resource://file/**'],
       title: address => address.slice(address.lastIndexOf('/') + 1),
-      guide: Array.from({ length: entryCount }, (_, order) => ({ order, title: () => 'Test', description: () => 'Test page' })),
+      guide: Array.from({ length: entryCount }, (_, order) => ({ id: String(order), order, title: () => 'Test', description: () => 'Test page' })),
     })
     runtime.slots.register({ name: 'sidebar.right.pane.tab', key: 'test/text' }, Body)
     runtime.slots.register({ name: 'sidebar.right.pane.tab.title', key: 'test/text' }, Title)
@@ -459,7 +459,7 @@ describe('slot-owned useTabInfo', () => {
     await act(async () => {
       h.runtime.ctx.sidebarRightTabs.register({
         id: 'test/files', kind: 'files', title: () => 'Files',
-        guide: [{ order: 1, title: () => 'Files' }],
+        guide: [{ id: 'default', order: 1, title: () => 'Files' }],
       })
     })
     expect(h.view.container.querySelector('[data-sidebar-right-guide-entry="files"]')).not.toBeNull()

+ 13 - 2
packages/client/ui-sidebar-right/tests/tab-registry.client.spec.ts

@@ -210,7 +210,7 @@ describe('SidebarRightTabRegistry — ids and page types', () => {
       kind: 'files',
       priority: 'builtin',
       title: () => 'Files',
-      guide: [{ order: 10, title: () => 'Files' }],
+      guide: [{ id: 'default', order: 10, title: () => 'Files' }],
     })
     expect(ranked(registry, 'dsh-resource://file/session/s/a.txt')).toEqual([])
     expect(registry.get('files')?.title('x')).toBe('Files')
@@ -258,7 +258,7 @@ describe('SidebarRightTabRegistry — lifetime', () => {
 
   it('collects every type\'s guide entries in order, reference-stable between changes', () => {
     const registry = new SidebarRightTabRegistry(new Context())
-    const entry = (order: number) => ({ order, title: () => `#${order}` })
+    const entry = (order: number) => ({ id: String(order), order, title: () => `#${order}` })
     registry.register(typeFor('files', [], { guide: [entry(10)] }))
     const first = registry.guide()
     expect(registry.guide()).toBe(first)
@@ -290,3 +290,14 @@ describe('SidebarRightTabRegistry — lifetime', () => {
     expect(registry.entries()).not.toBe(first)
   })
 })
+
+it('uses the active provider id for guide dispatch and refuses duplicate entry identities', () => {
+  const registry = new SidebarRightTabRegistry(new Context())
+  const guide = [{ id: 'new', order: 10, title: () => 'Terminal' }]
+  registry.register(typeFor('terminal', [], { id: 'builtin/terminal', priority: 'builtin', guide }))
+  const remove = registry.register(typeFor('terminal', [], { id: 'extension/terminal', priority: 'extension', guide }))
+  expect(registry.guide()[0]?.providerId).toBe('extension/terminal')
+  remove()
+  expect(registry.guide()[0]?.providerId).toBe('builtin/terminal')
+  expect(() => registry.register(typeFor('duplicate', [], { guide: [guide[0]!, guide[0]!] }))).toThrow('duplicate guide entry id')
+})

+ 2 - 2
packages/client/ui-sidebar-terminal/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-sidebar-terminal/README.md
-README.md: 0a3809af01caec728536aafa06333a2f254feae1
-README.zh.md: 954febb206ad9f4344f57365b0e091df702ebbe8
+README.md: 0245c699253b68ce4f8a441d5f1bd988340b8a32
+README.zh.md: 6d103ed97a17f0b72c810b9d81102d24760d5f63

+ 1 - 1
packages/client/ui-sidebar-terminal/README.md

@@ -25,7 +25,7 @@ Choose an installed shell from the right sidebar's Start page to run commands in
 <a id="use-this-package"></a>
 ## Use this package
 
-Open the right sidebar and select **New terminal** on the Start page; first select **New tab** when another content tab is open. Choose an installed **Shell** and select **Start terminal**. The last selected shell is preselected from localStorage; first use or an unavailable saved choice selects the current default. Use the sidebar's **New tab** control to open more terminals.
+Open the right sidebar and click **New terminal** to open the remembered available shell immediately. Its adjacent arrow opens the installed-shell menu; selecting an item remembers it and opens that shell directly. Discovery runs when the menu opens and does not allocate a terminal. A failed lookup offers Retry in the menu. Use **New tab** to return to the guide and open another terminal.
 
 Double-click the terminal's tab title to rename it. **Take control** makes the current attachment writable when another page owns input. A failed connection offers **Reconnect**. An exited shell remains visible with its exit code and never restarts automatically. Exited terminals count toward the Session limit; close unused tabs when the limit is reached.
 

+ 1 - 1
packages/client/ui-sidebar-terminal/README.zh.md

@@ -25,7 +25,7 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用此包
 
-展开右侧栏,在开始页选择**新建终端**;已有其他标签页时,先选择**新标签页**。选择已安装的 **Shell**,再点击**启动终端**。localStorage 会记住上次选择的 shell 并预选;首次使用或已保存选项不可用时,预选当前默认项。通过侧栏的**新标签页**控件打开更多终端。
+展开右侧栏,点击**新建终端**即可直接打开上次选择且仍可用的 shell。旁边的箭头展开已安装 shell 菜单;选择后立即记住该项并打开终端。菜单展开时才查询 shell,不分配终端进程;查询失败可在菜单内重试。通过**新标签页**回到开始页并打开更多终端。
 
 双击终端标签页标题即可重命名。另一页面持有输入权时,**接管输入**让当前连接可写。连接失败时可以**重新连接**。shell 退出后仍显示屏幕和退出码,不自动重启。 已退出的终端计入 Session 数量上限;达到上限时请关闭不用的标签页。
 

+ 0 - 5
packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css

@@ -8,8 +8,3 @@
 .status button { padding: 6px 10px; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 6px; color: inherit; background: transparent; cursor: pointer; }
 .status button:hover { background: var(--dsw-alias-bg-l2); }
 .error { margin: 0; padding: 10px 12px; overflow-wrap: anywhere; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-l2); }
-.launch { display: flex; align-items: end; flex-wrap: wrap; gap: 8px; padding: 12px; }
-.shellField { display: flex; flex: 1; flex-direction: column; gap: 6px; min-width: 0; }
-.shellMenu { width: 100%; min-width: 0; }
-.shellTrigger { width: 100%; min-width: 0; justify-content: space-between; gap: 8px; }
-.shellValue { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

+ 2 - 35
packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx

@@ -1,6 +1,5 @@
 /** Sidebar terminal screen and connection recovery. */
-import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
-import { Button, Menu, IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
+import { useEffect, useLayoutEffect, useRef, type ReactNode } from 'react'
 import { Terminal } from '@xterm/xterm'
 import { FitAddon } from '@xterm/addon-fit'
 import type { TerminalViewState, TerminalView } from '@deepseek-ai/dsh-api-terminal-controller/client'
@@ -15,7 +14,7 @@ import css from './TerminalBody.module.css'
 export type TerminalBodyProps = PropsRuntime<'sidebar.right.pane.tab'> & PropsLocale<'sidebarTerminal'> & InjectFace<TerminalBodyInjected>
 
 /**
- * Offer shell selection or render the retained terminal with the application theme.
+ * Render the retained terminal with the application theme.
  * @param props - sidebar occurrence, model lookup and translated copy.
  * @returns the terminal screen and any pending or exceptional state.
  */
@@ -37,7 +36,6 @@ export function TerminalBody({ useTabInfo, useTerminal, useTheme, view, t }: Ter
   const readOnly = state.phase === 'connected' && state.info?.state === 'running' && !state.writable
   return (
     <section className={css.root} data-sidebar-terminal>
-      {state.phase === 'selecting' && <TerminalLauncher state={state} model={model} t={t} />}
       {(status !== undefined || retry || readOnly) && <div className={css.status} role="status">
         {status}
         {readOnly && <>{t('readonly')} <button type="button" onClick={() => { model.connect() }}>{t('control')}</button></>}
@@ -51,37 +49,6 @@ export function TerminalBody({ useTabInfo, useTerminal, useTheme, view, t }: Ter
   )
 }
 
-function TerminalLauncher({ state, model, t }: {
-  state: TerminalViewState
-  model: TerminalView
-  t: TerminalBodyProps['t']
-}): ReactNode {
-  const [open, setOpen] = useState(false)
-  const selectionId = useId()
-  const selected = state.shells?.find(shell => shell.path === state.selectedShell)
-  return <form className={css.launch} onSubmit={(event) => { event.preventDefault(); void model.start() }}>
-    <div className={css.shellField}>
-      <span>{t('shell')}</span>
-      <Menu
-        open={open} autoFocus portal className={css.shellMenu}
-        items={state.shells?.map(shell => ({ id: shell.path, label: `${shell.name} — ${shell.path}` })) ?? []}
-        selectedId={state.selectedShell}
-        onClose={() => { setOpen(false) }}
-        onSelect={(path) => { model.selectShell(path); setOpen(false) }}
-        anchor={<Button
-          variant="outline" className={css.shellTrigger}
-          aria-label={t('shell')} aria-describedby={selectionId} aria-haspopup="menu" aria-expanded={open}
-          disabled={selected === undefined} onClick={() => { setOpen(value => !value) }}
-        >
-          <span id={selectionId} className={css.shellValue}>{selected === undefined ? t('shell') : `${selected.name} — ${selected.path}`}</span>
-          <span aria-hidden="true"><IconChevronDownOutline14 /></span>
-        </Button>}
-      />
-    </div>
-    <Button type="submit" variant="outline" disabled={selected === undefined}>{t('start')}</Button>
-  </form>
-}
-
 /* oxlint-disable typescript/no-non-null-assertion -- React sets the DOM ref, then these effects initialize and use the emulator. */
 function TerminalScreen({ state, model, visible, label, theme }: {
   state: TerminalViewState

+ 8 - 0
packages/client/ui-sidebar-terminal/src/client/TerminalGuide.module.css

@@ -0,0 +1,8 @@
+.entry { box-sizing: border-box; display: flex; align-items: stretch; width: 100%; overflow: hidden; border: 0.5px solid var(--dsw-alias-border-l4); border-radius: 24px; background: var(--dsw-alias-bg-layer-1); }
+.main { flex: 1; justify-content: flex-start; gap: 14px; min-width: 0; height: auto; min-height: 56px; padding: 14px 20px; border-radius: 24px 0 0 24px; text-align: left; }
+.icon { flex: none; }
+.text { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
+.title { overflow: hidden; color: var(--dsw-alias-label-primary); font-size: 15px; line-height: 1.4; white-space: nowrap; text-overflow: ellipsis; }
+.description { overflow: hidden; color: var(--dsw-alias-label-caption); font-size: 13px; line-height: 1.4; white-space: nowrap; text-overflow: ellipsis; }
+.trigger { align-self: stretch; flex: none; height: auto; width: 44px; padding: 0; border-radius: 0 24px 24px 0; }
+.menu { display: flex; align-self: stretch; flex: none; }

+ 74 - 0
packages/client/ui-sidebar-terminal/src/client/TerminalGuide.tsx

@@ -0,0 +1,74 @@
+/** Shell launch menu owned by the terminal provider's guide entry. */
+import { useEffect, useState, type ReactNode } from 'react'
+import { Button, IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { TerminalLaunchShells } from '@deepseek-ai/dsh-api-terminal-controller/client'
+import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
+import type {} from './locales.ts'
+import { TerminalGuideIcon } from './TerminalIcon.tsx'
+import css from './TerminalGuide.module.css'
+
+/** Provider-owned discovery and browser preference writes. */
+export interface TerminalGuideInjected {
+  /** @param signal - open menu lifetime. @returns current Host choices and browser preference. */
+  readonly loadShells: (signal: AbortSignal) => Promise<TerminalLaunchShells>
+  /** @param path - Host-discovered shell selected for the next terminal. */
+  readonly selectShell: (path: string) => void
+}
+
+/** Session guide owner props, framework hooks and terminal actions. */
+export type TerminalGuideProps = PropsRuntime<'sidebar.right.tab.guide.entry'> & PropsLocale<'sidebarTerminal'> & InjectFace<TerminalGuideInjected>
+
+type MenuState = { phase: 'loading' } | { phase: 'ready'; choices: TerminalLaunchShells } | { phase: 'failed'; message: string }
+
+/**
+ * Open the remembered shell from the card or choose another shell from its menu.
+ * @param props - guide copy, enclosing tab actions and cancellable discovery.
+ * @returns separate launch and menu buttons within one guide card.
+ */
+export function TerminalGuide({ title, description, kind, useTabInfo, loadShells, selectShell, t }: TerminalGuideProps): ReactNode {
+  const { tab } = useTabInfo()
+  const [open, setOpen] = useState(false)
+  const [attempt, setAttempt] = useState(0)
+  const [state, setState] = useState<MenuState>({ phase: 'loading' })
+  useEffect(() => {
+    if (!open) return
+    const lifetime = new AbortController()
+    void loadShells(lifetime.signal).then((choices) => {
+      if (!lifetime.signal.aborted) setState({ phase: 'ready', choices })
+    }, (error: unknown) => {
+      if (!lifetime.signal.aborted) setState({ phase: 'failed', message: error instanceof Error ? error.message : String(error) })
+    })
+    return () => { lifetime.abort() }
+  }, [open, attempt, loadShells])
+  const items = state.phase === 'ready'
+    ? state.choices.shells.map(shell => ({ id: shell.path, label: shell.name }))
+    : state.phase === 'loading'
+      ? [{ id: 'loading', label: t('shellLoading'), disabled: true }]
+      : [{ id: 'error', label: t('failed', { message: state.message }), disabled: true }, { id: 'retry', label: t('retry') }]
+  return <div className={css.entry} data-sidebar-right-guide-entry={kind}>
+    <Button variant="ghost" className={css.main} onClick={() => { tab.actions.openTab('terminal', { replaceTab: true }) }}>
+      <TerminalGuideIcon size={description === undefined ? 22 : 26} className={css.icon} />
+      <span className={css.text}>
+        <span className={css.title}>{title}</span>
+        {description !== undefined && <span className={css.description}>{description}</span>}
+      </span>
+    </Button>
+    <Menu
+      open={open} portal autoFocus align="end" className={css.menu}
+      items={items.length === 0 ? [{ id: 'empty', label: t('shellEmpty'), disabled: true }] : items}
+      selectedId={state.phase === 'ready' ? state.choices.selectedShell : undefined}
+      onClose={() => { setOpen(false) }}
+      onSelect={(path) => {
+        if (state.phase === 'failed') { setState({ phase: 'loading' }); setAttempt(value => value + 1); return }
+        selectShell(path)
+        setOpen(false)
+        tab.actions.openTab('terminal', { replaceTab: true, params: { shellPath: path } })
+      }}
+      anchor={<Button variant="ghost" className={css.trigger} aria-label={t('shell')} aria-haspopup="menu" aria-expanded={open}
+        onClick={() => { setState({ phase: 'loading' }); setOpen(value => !value) }}>
+        <IconChevronDownOutline14 />
+      </Button>}
+    />
+  </div>
+}

+ 1 - 1
packages/client/ui-sidebar-terminal/src/client/face.ts

@@ -20,6 +20,6 @@ export interface TerminalBodyInjected extends TerminalInjected {
 declare module '@deepseek-ai/dsh-client-ui-sidebar-right/client' {
   interface SidebarRightTabParamsMap {
     /** An existing Host terminal selected from the Session terminal list. */
-    terminal: { terminalId: WebTerminalId }
+    terminal: { terminalId: WebTerminalId } | { shellPath: string }
   }
 }

+ 19 - 5
packages/client/ui-sidebar-terminal/src/client/index.ts

@@ -1,7 +1,7 @@
 /** Register interactive terminal tabs and explicit process cleanup with the sidebar. */
 import type { Context } from '@deepseek-ai/cordis'
 import type { WebTerminalId } from '@deepseek-ai/dsh-api-terminal-controller/types'
-import type { TabId } from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
+import type { SidebarRightTabParamsMap, TabId } from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type {} from '@deepseek-ai/dsh-api-terminal-controller/client'
 import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
@@ -10,6 +10,7 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
 import type {} from '@deepseek-ai/dsh-client-ui-session/client'
 import type {} from '@deepseek-ai/dsh-client-ui-theme/client'
 import { TerminalGuideIcon } from './TerminalIcon.tsx'
+import { TerminalGuide, type TerminalGuideInjected } from './TerminalGuide.tsx'
 import { TerminalBody } from './TerminalBody.tsx'
 import { TerminalTitle } from './TerminalTitle.tsx'
 import { TerminalRecovery, type TerminalRecoveryInjected } from './TerminalRecovery.tsx'
@@ -28,18 +29,24 @@ export function apply(ctx: Context): void {
   let disposed = false
   const recovered = new Map<SessionId, Promise<void>>()
   ctx.effect(() => () => { disposed = true; recovered.clear() }, 'ui-sidebar-terminal.lifetime')
+  const target = (sessionId: SessionId, key: string): SidebarRightTabParamsMap['terminal'] | undefined =>
+    ctx.sidebarRight.tabDomain.occurrence(sessionId, { id: key as TabId }).navigation.getSnapshot().params
   const terminalId = (sessionId: SessionId, key: string): WebTerminalId | undefined => {
-    const params = ctx.sidebarRight.tabDomain.occurrence(sessionId, { id: key as TabId }).navigation.getSnapshot().params
-    return (params as { terminalId?: WebTerminalId } | undefined)?.terminalId
+    const params = target(sessionId, key)
+    return params !== undefined && 'terminalId' in params ? params.terminalId : undefined
+  }
+  const view = (sessionId: SessionId, key: string) => {
+    const params = target(sessionId, key)
+    return ctx.webTerminals.view(sessionId, key, terminalId(sessionId, key),
+      params !== undefined && 'shellPath' in params ? params.shellPath : undefined)
   }
-  const view = (sessionId: SessionId, key: string) => ctx.webTerminals.view(sessionId, key, terminalId(sessionId, key))
   const namespace = 'sidebarTerminal'
   const id = '@deepseek-ai/dsh-client-ui-sidebar-terminal'
   const t = ctx.locale.bind(namespace)
   ctx.effect(() => ctx.locale.register(namespace, { zh, en }), 'ui-sidebar-terminal.copy')
   ctx.effect(() => ctx.sidebarRightTabs.register({
     id, kind: 'terminal', multiple: true, priority: 'builtin', title: () => t('title'),
-    guide: [{ order: 20, title: () => t('new'), description: () => t('description'), icon: TerminalGuideIcon }],
+    guide: [{ id: 'new', order: 20, title: () => t('new'), description: () => t('description'), icon: TerminalGuideIcon }],
   }), 'ui-sidebar-terminal.type')
   ctx.effect(() => ctx.sidebarRight.registerCloseHandler('terminal', (sessionId, tab) => {
     ctx.webTerminals.close(sessionId, tab.id, terminalId(sessionId, tab.id))
@@ -52,6 +59,13 @@ export function apply(ctx: Context): void {
     getSnapshot: () => ctx.theme.getTheme(),
     subscribe: listener => ctx.on('theme/change', listener),
   }
+  ctx.effect(() => ctx.slots.inject('sidebar.right.tab.guide.entry', () => ctx.slots.register({
+    name: 'sidebar.right.tab.guide.entry', key: id, locale: namespace,
+    inject: (sessionId): TerminalGuideInjected => ({
+      loadShells: signal => ctx.webTerminals.launchShells(sessionId, signal),
+      selectShell: (path) => { ctx.webTerminals.selectShell(path) },
+    }),
+  }, TerminalGuide)), 'ui-sidebar-terminal.guide')
   ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register(
     { name: 'sidebar.right.pane.tab', key: id, locale: namespace,
       inject: (sessionId): TerminalBodyInjected => ({ ...inject(sessionId), hooks: { theme } }),

+ 2 - 2
packages/client/ui-sidebar-terminal/src/client/locales.ts

@@ -10,7 +10,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
 /** Simplified Chinese terminal copy. */
 export const zh = {
   recoveryFailed: '恢复终端失败:{message}', retryRecovery: '重试恢复终端',
-  shell: 'Shell', start: '启动终端', description: '在会话工作区运行命令',
+  shell: '选择 Shell', shellLoading: '正在读取 Shell…', shellEmpty: '没有可用的 Shell', description: '在会话工作区运行命令',
   title: '终端', new: '新建终端', loading: '正在读取终端环境…', creating: '正在启动…',
   connecting: '正在连接…', disconnected: '连接已断开。', reconnect: '重新连接',
   readonly: '此页面当前只读。', control: '接管输入',
@@ -27,7 +27,7 @@ export const zh = {
 /** English terminal copy. */
 export const en = {
   recoveryFailed: 'Terminal recovery failed: {message}', retryRecovery: 'Retry terminal recovery',
-  shell: 'Shell', start: 'Start terminal', description: 'Run commands in the Session workspace',
+  shell: 'Choose shell', shellLoading: 'Loading shells…', shellEmpty: 'No shells available', description: 'Run commands in the Session workspace',
   title: 'Terminal', new: 'New terminal', loading: 'Reading terminal environment…', creating: 'Starting…',
   connecting: 'Connecting…', disconnected: 'Disconnected.', reconnect: 'Reconnect',
   readonly: 'This view is read-only.', control: 'Take control',

+ 23 - 10
packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts

@@ -10,6 +10,7 @@ import { SidebarRightTabRegistry } from '@deepseek-ai/dsh-client-ui-sidebar-righ
 import type { SidebarRightCloseHandler } from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
 import { apply, inject } from '../src/client/index.ts'
 import { apply as hostApply } from '../src/index.ts'
+import { TerminalGuide, type TerminalGuideInjected } from '../src/client/TerminalGuide.tsx'
 import { TerminalBody } from '../src/client/TerminalBody.tsx'
 import { TerminalTitle } from '../src/client/TerminalTitle.tsx'
 import { TerminalRecovery, type TerminalRecoveryInjected } from '../src/client/TerminalRecovery.tsx'
@@ -38,9 +39,10 @@ async function mountPlugin() {
   const model = { state: {} }
   const terminals = {
     view: vi.fn(() => model), close: vi.fn(), closeFailures: {}, retryClose: vi.fn(),
+    launchShells: vi.fn(async () => ({ shells: [], selectedShell: undefined })), selectShell: vi.fn(),
     recover: vi.fn(async (_sessionId: SessionId): Promise<WebTerminalInfo[]> => []),
   }
-  let params: { terminalId: WebTerminalId } | undefined
+  let params: { terminalId: WebTerminalId } | { shellPath: string } | undefined
   const occurrence = vi.fn(() => ({ navigation: { getSnapshot: () => ({ params }) } }))
   const openTabIn = vi.fn()
   ctx.provide('webTerminals', terminals as never)
@@ -82,13 +84,20 @@ it('registers terminal views, recovery and cleanup, then releases every contribu
     expect(definition.multiple).toBe(true)
     expect(h.dictionaries.get('sidebarTerminal')).toEqual({ en, zh })
     expect(h.entries.map(entry => [entry.name, entry.component, entry.locale])).toEqual([
+      ['sidebar.right.tab.guide.entry', TerminalGuide, 'sidebarTerminal'],
       ['sidebar.right.pane.tab', TerminalBody, 'sidebarTerminal'],
       ['sidebar.right.pane.tab.title', TerminalTitle, 'sidebarTerminal'],
       ['conversation.session.header.actions', TerminalRecovery, 'sidebarTerminal'],
       ['shell.overlay', TerminalCleanup, 'sidebarTerminal'],
     ])
     const sessionId = 'session' as SessionId
-    const face = h.entries[0]!.inject(sessionId) as TerminalBodyInjected
+    const launcher = h.entries[0]!.inject(sessionId) as TerminalGuideInjected
+    const signal = new AbortController().signal
+    await launcher.loadShells(signal)
+    expect(h.terminals.launchShells).toHaveBeenCalledWith(sessionId, signal)
+    launcher.selectShell('/bin/bash')
+    expect(h.terminals.selectShell).toHaveBeenCalledWith('/bin/bash')
+    const face = h.entries[1]!.inject(sessionId) as TerminalBodyInjected
     expect(face.hooks.theme.getSnapshot()).toBe(h.theme)
     const changed = vi.fn()
     const unsubscribe = face.hooks.theme.subscribe(changed)
@@ -98,11 +107,15 @@ it('registers terminal views, recovery and cleanup, then releases every contribu
     h.emitTheme()
     expect(changed).toHaveBeenCalledOnce()
     expect(face.view('tab')).toBe(h.model)
-    expect(h.terminals.view).toHaveBeenLastCalledWith(sessionId, 'tab', undefined)
+    expect(h.terminals.view).toHaveBeenLastCalledWith(sessionId, 'tab', undefined, undefined)
     const terminalId = 'retained' as WebTerminalId
     h.setParams({ terminalId })
+    h.setParams({ shellPath: '/bin/bash' })
+    face.view('tab')
+    expect(h.terminals.view).toHaveBeenLastCalledWith(sessionId, 'tab', undefined, '/bin/bash')
+    h.setParams({ terminalId })
     expect(face.keyedHooks.terminal('tab')).toBe(h.model.state)
-    expect(h.terminals.view).toHaveBeenLastCalledWith(sessionId, 'tab', terminalId)
+    expect(h.terminals.view).toHaveBeenLastCalledWith(sessionId, 'tab', terminalId, undefined)
     expect(h.occurrence).toHaveBeenLastCalledWith(sessionId, { id: 'tab' })
     if (h.closeHandler === undefined) throw new Error('Terminal close handler was not registered')
     h.closeHandler(sessionId, { id: 'tab' } as Parameters<SidebarRightCloseHandler>[1])
@@ -110,7 +123,7 @@ it('registers terminal views, recovery and cleanup, then releases every contribu
     h.setParams(undefined)
     h.closeHandler(sessionId, { id: 'new-tab' } as Parameters<SidebarRightCloseHandler>[1])
     expect(h.terminals.close).toHaveBeenLastCalledWith(sessionId, 'new-tab', undefined)
-    const cleanupFace = h.entries[3]!.inject(sessionId) as TerminalCleanupInjected
+    const cleanupFace = h.entries[4]!.inject(sessionId) as TerminalCleanupInjected
     expect(cleanupFace.hooks.closeFailures).toBe(h.terminals.closeFailures)
     cleanupFace.retryClose('terminal' as Parameters<TerminalCleanupInjected['retryClose']>[0])
     expect(h.terminals.retryClose).toHaveBeenCalledWith('terminal')
@@ -128,8 +141,8 @@ it('shares pending and completed recovery across Session headers and opens each
   const pending = Promise.withResolvers<WebTerminalInfo[]>()
   h.terminals.recover.mockImplementationOnce(() => pending.promise)
   const sessionId = 'session' as SessionId
-  const recovery = h.entries[2]!.inject(sessionId) as TerminalRecoveryInjected
-  const remounted = h.entries[2]!.inject(sessionId) as TerminalRecoveryInjected
+  const recovery = h.entries[3]!.inject(sessionId) as TerminalRecoveryInjected
+  const remounted = h.entries[3]!.inject(sessionId) as TerminalRecoveryInjected
   const completion = recovery.restore()
   try {
     expect(remounted.restore()).toBe(completion)
@@ -145,7 +158,7 @@ it('shares pending and completed recovery across Session headers and opens each
     expect(h.terminals.recover).toHaveBeenCalledTimes(1)
     expect(h.openTabIn).toHaveBeenCalledTimes(2)
     const otherSession = 'other-session' as SessionId
-    await (h.entries[2]!.inject(otherSession) as TerminalRecoveryInjected).restore()
+    await (h.entries[3]!.inject(otherSession) as TerminalRecoveryInjected).restore()
     expect(h.terminals.recover).toHaveBeenLastCalledWith(otherSession)
     expect(h.terminals.recover).toHaveBeenCalledTimes(2)
     expect(h.openTabIn).toHaveBeenCalledTimes(2)
@@ -161,7 +174,7 @@ it('allows a failed Host lookup to be retried without marking the Session recove
   const unavailable = new Error('Host unavailable')
   h.terminals.recover.mockRejectedValueOnce(unavailable)
   const sessionId = 'session' as SessionId
-  const recovery = h.entries[2]!.inject(sessionId) as TerminalRecoveryInjected
+  const recovery = h.entries[3]!.inject(sessionId) as TerminalRecoveryInjected
   try {
     await expect(recovery.restore()).rejects.toBe(unavailable)
     expect(h.openTabIn).not.toHaveBeenCalled()
@@ -177,7 +190,7 @@ it('does not open retained terminals when their lookup completes after plugin un
   const h = await mountPlugin()
   const pending = Promise.withResolvers<WebTerminalInfo[]>()
   h.terminals.recover.mockImplementationOnce(() => pending.promise)
-  const recovery = h.entries[2]!.inject('session' as SessionId) as TerminalRecoveryInjected
+  const recovery = h.entries[3]!.inject('session' as SessionId) as TerminalRecoveryInjected
   const completion = recovery.restore()
   try {
     expect(h.terminals.recover).toHaveBeenCalledOnce()

+ 0 - 21
packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx

@@ -68,7 +68,6 @@ function mount(initial: TerminalViewState | undefined = idle, dictionary = en) {
   let theme: ThemeSnapshot = { preference: 'light', fontSize: 14, active: { id: 'light', colorScheme: 'light', tokens: {} }, themes: [], revision: 0 }
   const detach = vi.fn()
   const model = {
-    start: vi.fn(async () => {}), selectShell: vi.fn(),
     mount: vi.fn(() => detach), refresh: vi.fn(async () => {}),
     rename: vi.fn(async () => {}), connect: vi.fn(), write: vi.fn(), resize: vi.fn(), acknowledge: vi.fn(),
   }
@@ -338,23 +337,3 @@ it.each([en, zh])('translates known terminal failures while retaining unknown Ho
   h.update({ ...idle, phase: 'failed', error: 'Host permission denied' })
   expect(h.view.getByRole('alert').textContent).toContain('Host permission denied')
 })
-
-it('lets the user select an installed shell and start it before rendering a screen', () => {
-  const other = { path: '/bin/zsh', name: 'zsh', args: ['-i'] }
-  const h = mount({ phase: 'selecting', writable: false, environment, shells: [info.shell, other], selectedShell: other.path })
-  const selector = h.view.getByRole('button', { name: 'Shell' })
-  expect(selector.textContent).toContain(other.path)
-  expect(fake.terminals).toHaveLength(0)
-  fireEvent.click(selector)
-  expect(h.view.getByRole('menu')).toBeTruthy()
-  fireEvent.keyDown(document, { key: 'Escape' })
-  expect(h.view.queryByRole('menu')).toBeNull()
-  fireEvent.click(selector)
-  fireEvent.click(h.view.getByRole('menuitem', { name: `bash — ${info.shell.path}` }))
-  expect(h.view.queryByRole('menu')).toBeNull()
-  expect(h.model.selectShell).toHaveBeenCalledWith(info.shell.path)
-  fireEvent.click(h.view.getByRole('button', { name: 'Start terminal' }))
-  expect(h.model.start).toHaveBeenCalledOnce()
-  h.update({ phase: 'selecting', writable: false, environment })
-  expect(h.view.getByRole('button', { name: 'Start terminal' })).toHaveProperty('disabled', true)
-})

+ 94 - 0
packages/client/ui-sidebar-terminal/tests/terminal-guide.client.spec.tsx

@@ -0,0 +1,94 @@
+// @vitest-environment jsdom
+/** Guide shell discovery, direct launch and cancellation stay within the entry lifetime. */
+import { act, cleanup, fireEvent, render } from '@testing-library/react'
+import { afterEach, expect, it, vi } from 'vitest'
+import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
+import type { TerminalLaunchShells } from '@deepseek-ai/dsh-api-terminal-controller/client'
+import { TerminalGuide, type TerminalGuideProps } from '../src/client/TerminalGuide.tsx'
+import { en } from '../src/client/locales.ts'
+
+afterEach(cleanup)
+const choices: TerminalLaunchShells = {
+  shells: [{ name: 'bash', path: '/bin/bash', args: ['-i'] }, { name: 'zsh', path: '/bin/zsh', args: ['-i'] }],
+  selectedShell: '/bin/zsh',
+}
+
+function mount(description: string | undefined = en.description) {
+  const loadShells = vi.fn<TerminalGuideProps['loadShells']>(async () => choices)
+  const selectShell = vi.fn()
+  const openTab = vi.fn()
+  // The fixture supplies only the guide's consumed owner and framework shares.
+  const props = {
+    entryId: 'new', kind: 'terminal', title: en.new, description, t: makeTranslate(en),
+    useTabInfo: () => ({ tab: { actions: { openTab } } }), loadShells, selectShell,
+  } as unknown as TerminalGuideProps
+  const view = render(<TerminalGuide {...props} />)
+  const open = () => fireEvent.click(view.getByRole('button', { name: en.shell }))
+  return { view, props, open, loadShells, selectShell, openTab }
+}
+
+it('opens the default shell from the main button without querying a menu', () => {
+  const h = mount()
+  fireEvent.click(h.view.getByRole('button', { name: /^New terminal/u }))
+  expect(h.openTab).toHaveBeenCalledExactlyOnceWith('terminal', { replaceTab: true })
+  expect(h.loadShells).not.toHaveBeenCalled()
+  expect(h.view.container.querySelector('button button')).toBeNull()
+})
+
+it('loads on demand, remembers a selected shell before opening, and cancels on menu dismissal', async () => {
+  const h = mount()
+  h.open()
+  await h.view.findByRole('menuitem', { name: 'zsh' })
+  const signal = h.loadShells.mock.calls[0]![0]
+  h.selectShell.mockImplementation(() => { expect(h.openTab).not.toHaveBeenCalled() })
+  fireEvent.click(h.view.getByRole('menuitem', { name: 'bash' }))
+  expect(h.selectShell).toHaveBeenCalledExactlyOnceWith('/bin/bash')
+  expect(h.openTab).toHaveBeenCalledExactlyOnceWith('terminal', { replaceTab: true, params: { shellPath: '/bin/bash' } })
+  expect(signal.aborted).toBe(true)
+  h.open()
+  await h.view.findByRole('menuitem', { name: 'bash' })
+  fireEvent.keyDown(h.view.getByRole('menu'), { key: 'Escape' })
+  expect(h.view.queryByRole('menu')).toBeNull()
+  expect(h.loadShells.mock.calls[1]![0].aborted).toBe(true)
+})
+
+it.each([new Error('offline'), 'offline'])('offers an in-place retry after discovery fails: %s', async (error) => {
+  const h = mount()
+  h.loadShells.mockRejectedValueOnce(error)
+  h.open()
+  await h.view.findByText('Terminal error: offline')
+  fireEvent.click(h.view.getByRole('menuitem', { name: en.retry }))
+  await h.view.findByRole('menuitem', { name: 'bash' })
+  expect(h.loadShells).toHaveBeenCalledTimes(2)
+  expect(h.openTab).not.toHaveBeenCalled()
+})
+
+it('shows empty discovery without opening a terminal and supports title-only cards', async () => {
+  const h = mount()
+  const { description: _description, ...titleOnly } = h.props
+  h.view.rerender(<TerminalGuide {...titleOnly} />)
+  h.loadShells.mockResolvedValue({ shells: [], selectedShell: undefined })
+  h.open()
+  await h.view.findByText(en.shellEmpty)
+  expect(h.view.queryByText(en.description)).toBeNull()
+  expect(h.openTab).not.toHaveBeenCalled()
+})
+
+it.each(['resolve', 'reject'] as const)('ignores a late %s after a loading entry unmounts', async (outcome) => {
+  const h = mount()
+  const pending = Promise.withResolvers<TerminalLaunchShells>()
+  h.loadShells.mockReturnValue(pending.promise)
+  h.open()
+  expect(h.view.getByText(en.shellLoading)).toBeDefined()
+  const signal = h.loadShells.mock.calls[0]![0]
+  h.view.unmount()
+  expect(signal.aborted).toBe(true)
+  await act(async () => {
+    if (outcome === 'resolve') pending.resolve(choices)
+    else pending.reject(new Error('late failure'))
+    await pending.promise.catch((_lateDiscoveryFailure: unknown) => {
+      /* The cancelled entry consumes the rejection without publishing it. */
+    })
+  })
+  expect(h.openTab).not.toHaveBeenCalled()
+})

+ 46 - 1
packages/extensions/cordis-client-runner/src/client/slot-catalog.ts

@@ -2450,6 +2450,51 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'sidebar.right.tab.guide\', () => ctx.slots.register(\n      { name: \'sidebar.right.tab.guide\', select: owner => null },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
     source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:75',
   },
+  {
+    key: 'sidebar.right.tab.guide.entry',
+    kind: 'keyed',
+    scope: 'session',
+    summary: 'One provider\'s guide card, with the standard card as the owner\'s fallback.',
+    doc: 'One provider\'s guide card, with the standard card as the owner\'s fallback.',
+    registerOptions: [
+      {
+        name: 'key',
+        requirement: 'required',
+        type: 'string',
+        doc: 'Your cell key: the entry renders where the owner dispatches this exact key. Registering an already-occupied key replaces that occupant.',
+      },
+    ],
+    ownerProps: [
+      '/** Resolved guide copy and entry identity supplied to a provider\'s card renderer. */\nexport interface SidebarRightGuideEntryOwnerProps {\n  readonly entryId: string\n  readonly kind: string\n  readonly title: string\n  readonly description?: string\n}',
+    ],
+    ownerPropsReferences: [],
+    standardProps: [
+      'useResource: UseResource',
+      'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
+      'usePanelInfo: UsePanelInfo',
+      'useSessions: UseSessions',
+      'useSessionPendingInteraction: UseSessionPendingInteraction',
+      'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
+      'useChat: UseChat',
+      'useConversation: UseConversation',
+      'useInput: SnapshotSelectorHook<InputState>',
+      'inputActions: InputActions',
+      'useSession: SessionSnapshotSelector',
+      'sessionId: SessionId',
+      'useProjection: UseProjection',
+      'useTrajectory: UseTrajectory',
+    ],
+    keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet',
+    hookContext: 'UseSidebarRightTabInfo',
+    slotInject: '{ hooks: { tabInfo: SlotHookFactory<\'sidebar.right.tab.guide.entry\', UseSidebarRightTabInfo> } }',
+    declaredBy: 'an entry in \'sidebar.right.pane.tab\' (client-ui-sidebar-right), so it exists while that entry is mounted',
+    occupants: [
+      'client-ui-sidebar-terminal TerminalGuide',
+    ],
+    replaceRisk: 'none',
+    example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'sidebar.right.tab.guide.entry\', () => ctx.slots.register(\n      { name: \'sidebar.right.tab.guide.entry\', key: \'<one key the owner dispatches>\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
+    source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:82',
+  },
   {
     key: 'sidebar.right.tab.menu.item',
     kind: 'list',
@@ -2505,7 +2550,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     occupants: [],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'sidebar.right.tab.menu.item\', () => ctx.slots.register(\n      { name: \'sidebar.right.tab.menu.item\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:86',
+    source: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts:94',
   },
   {
     key: 'sidebar.settings',