Merge model selection pending state from master

This commit is contained in:
zhc committed 2026-09-24 19:39:50 +08:00
commit 8cfb9d4a67
321 files changed
+4127 -3994

No files matched your search

@@ -30,8 +30,8 @@
en: 7bc0fab85c7c91d0
zh: 3eafcde6b1e3249c
/agent-note-the-subprocess-sandbox-confinement-seam-native-runners-escalation-and-per-session-modes/decision/design-detail/the-bash-consumer:
en: af561a23104227cd
zh: ac6fd59518d1bb55
en: f7f359c89a0e8fd6
zh: 07d0d551a35716e0
/agent-note-the-subprocess-sandbox-confinement-seam-native-runners-escalation-and-per-session-modes/decision/design-detail/escalation-one-approved-wider-retry-after-a-denial:
en: 035640e276225e97
zh: 09fd95921529369b
@@ -78,7 +78,7 @@ Provider-owned I/O requires asynchronous confinement. `confine(argv, policy, sig
`dsh-bash-sandbox` extends `LocalBashExecutor`, awaits `ctx.sandbox` for the exact `['bash', '-c', command]` argv with the execution signal, rechecks cancellation, and directly spawns the provider result. This leaves shell semantics and `BASH_ENV` on the inner Bash after the shipped native runner establishes confinement. A provider error propagates unchanged. An asynchronous rejection counts as a runner failure only when the caller-owned workdir is independently usable and Node reports `ENOENT` or `EACCES` with either an `error.path` equal to provider argv[0] or, when `error.path` is absent, an exact `syscall: 'spawn <runner>'`; a present path also requires `syscall: 'spawn'` or the exact `spawn <runner>`. Other codes, invalid workdirs, resource failures, unrelated syscalls, and unstructured rejections retain stage-neutral provider-failure semantics. Foreground execution converts attributable runner failures to `SANDBOX_UNAVAILABLE` with the original detail; an attributable asynchronous background rejection stamps `runnerFailed: true`, `denied: false`. A `SubprocessRuntime` that synchronously throws the same runner-identifying shape makes background start reject with `SANDBOX_UNAVAILABLE` before handle publication, while other synchronous errors propagate unchanged. After a direct outcome exists, foreground and background use one runner-failure classifier that requires the rule's exit-code check and a remaining fatal line after informational exclusions. A match takes priority over denial: foreground execution throws `SANDBOX_UNAVAILABLE` with that fatal line as detail; a settled `ShellProcess` stamps `sandbox.runnerFailed`, and the bash producer renders it through generic `job_output`.
The model sees the current effective file policy in the owner-derived `sandbox:policy` context, while the static tool description explains the denial marker (`[sandbox: file access denied under <mode> mode]`), encourages attempting commands that may be denied, and forbids retrying around a denial; when the escalation fields are advertised, a denied result additionally carries the escalation hint itself, so the sanctioned same-turn retry is prompted at the decision point rather than depending on the model recalling the description (§ Escalation). [The current-policy decision](../../archived/feature/2026-07-30-current-sandbox-policy-context.md) owns the context's rationale and boundaries.
The model sees the current effective file policy in the owner-derived `sandbox:policy` context, while the static tool description explains the denial marker (`[sandbox: file access denied under <mode> mode]`) and forbids retrying around a denial, and the `sandbox:policy` context tells the model to try an available tool normally; a rejected escalation's error text says the action stays denied; when the escalation fields are advertised, a denied result additionally carries the escalation hint itself, so the sanctioned same-turn retry is prompted at the decision point rather than depending on the model recalling the description (§ Escalation). [The current-policy decision](../../archived/feature/2026-07-30-current-sandbox-policy-context.md) owns the context's rationale and boundaries.
#### Escalation: one approved wider retry after a denial
@@ -78,7 +78,7 @@ Landlock launcher 源码和包家族位于 `native/system`,与 harness 消费
`dsh-bash-sandbox` 扩展 `LocalBashExecutor`,携带执行信号等待 `ctx.sandbox` 为精确的 `['bash', '-c', command]` argv 完成限制准备,重新检查取消后直接 spawn 提供方返回的 argv。这样,随附的原生 runner 建立约束后,shell 语义与 `BASH_ENV` 仍由内层 Bash 处理。提供方错误原样传播。异步 rejection 只有在调用方拥有的 workdir 经独立验证可用,Node 报告 `ENOENT` 或 `EACCES`,并且错误符合以下一种形态时,才判定为 runner 失败:`error.path` 等于提供方返回的 `argv[0]`,同时 `syscall` 为 `'spawn'` 或精确的 `'spawn <runner>'`;或者 `error.path` 不存在,同时 `syscall` 为精确的 `'spawn <runner>'`。其他错误码、无效 workdir、资源失败、无关 syscall 与无结构 rejection 保留不声明阶段的 provider-failure 语义。前台执行会将可归因的 runner failure 转为 `SANDBOX_UNAVAILABLE` 并附上原始详情;可归因的异步后台 rejection 则盖章 `runnerFailed: true`、`denied: false`。如果 `SubprocessRuntime` 同步抛出同样能指明 runner 的形态,后台启动会在发布句柄前以 `SANDBOX_UNAVAILABLE` 拒绝;其他同步错误原样传播。direct outcome 已存在后,前台与后台共用一个 runner failure 分类器:先排除信息性行,再要求规则的退出码检查与余下的一行致命诊断同时匹配。匹配结果优先于拒绝:前台执行抛出 `SANDBOX_UNAVAILABLE`,并以该致命行作为详情;结算后的 `ShellProcess` 会盖章 `sandbox.runnerFailed`,bash 生产者再通过通用 `job_output` 渲染它。
模型会在归属方派生的 `sandbox:policy` 上下文中看到当前有效的文件策略;静态工具描述则解释拒绝标记(`[sandbox: file access denied under <mode> mode]`),鼓励尝试可能被拒绝的命令,并禁止绕过拒绝重试。当升级字段被公布时,被拒绝的结果还会携带升级提示本身,使被认可的同轮次重试在决策点获得提示,而非依赖模型回忆描述(§ 升级机制)。[当前策略决策](../../archived/feature/2026-07-30-current-sandbox-policy-context.md)负责该上下文的理由与边界。
模型会在归属方派生的 `sandbox:policy` 上下文中看到当前有效的文件策略;静态工具描述则解释拒绝标记(`[sandbox: file access denied under <mode> mode]`)并禁止绕过拒绝重试,`sandbox:policy` 上下文要求模型正常尝试可用工具;被拒绝的升级请求的错误文本说明该操作仍被拒绝。当升级字段被公布时,被拒绝的结果还会携带升级提示本身,使被认可的同轮次重试在决策点获得提示,而非依赖模型回忆描述(§ 升级机制)。[当前策略决策](../../archived/feature/2026-07-30-current-sandbox-policy-context.md)负责该上下文的理由与边界。
#### 升级机制:拒绝后一次经批准的更宽重试
+9 -1
View File
@@ -1,6 +1,6 @@
---
name: agent-experience
description: Use when designing agent tools, skills, context loading, or multi-step workflows to make information discoverable and use context efficiently.
description: Use when writing or changing a model-facing tool definition (name, description, parameter schema, or its system-prompt section), and when designing skills, context loading, or multi-step workflows, to make information discoverable and use context efficiently.
metadata:
date: "2026-09-11"
---
@@ -11,3 +11,11 @@ metadata:
- **Prefer bounded outputs:** return concise results with identifiers or paths for retrieving details; avoid dumping entire logs or documents.
- **Use locality:** include a bounded amount of likely needed adjacent context with an operation’s result. For example, deliver a thread reply with a few preceding messages. Mark omissions and truncation explicitly, and provide a way to retrieve more.
- **Evaluate total work:** saving context is useful only if it does not cause more searches, repeated reads, or mistakes.
## Tool definitions
- **Delete obvious constraints.** Omit rules the model learns from the call result, such as a missing file failing to read or a failed send not being delivered.
- **Describe behavior, not implementation.** State what the tool does and returns; omit internal mechanisms, output markers the model sees in results, and enforcement details.
- **Put parameter rules on the parameter.** Defaults, ranges, pairing, and when-to-set rules belong in that parameter's description or schema; a description that varies with configuration usually means the varying part belongs to a parameter.
- **Say each fact once.** Do not repeat the tool definition in its system-prompt section, a parameter description in the tool description, or one tool's rules in another tool.
- **Measure the change.** Compare first-turn prompt tokens before and after a tool-definition change.
@@ -25,8 +25,8 @@
- text: 代码工作工具 开启后显示轨迹、本轮代码差异,新对话中的 Agent 预设切换
- switch "代码工作工具" [checked]
- text: 快捷键
- paragraph: 查看当前可用的快捷键和输入操作
- button "查看快捷键"
- paragraph: 查看和编辑当前可用的快捷键和输入操作
- button "编辑快捷键"
- text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为
- button "排队发送"
- text: 当前版本:{{version}}
@@ -25,8 +25,8 @@
- text: Coding Tools Shows trajectory, code diffs, and Agent preset switching in new chats
- switch "Coding Tools" [checked]
- text: Keyboard shortcuts
- paragraph: Browse available shortcuts and input actions
- button "View shortcuts"
- paragraph: View and edit available shortcuts and input actions
- button "Edit shortcuts"
- text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior
- button "Queue"
- text: "Current version: {{version}}"
@@ -25,8 +25,8 @@
- text: Coding Tools Shows trajectory, code diffs, and Agent preset switching in new chats
- switch "Coding Tools"
- text: Keyboard shortcuts
- paragraph: Browse available shortcuts and input actions
- button "View shortcuts"
- paragraph: View and edit available shortcuts and input actions
- button "Edit shortcuts"
- text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior
- button "Queue"
- text: "Current version: {{version}}"
@@ -25,8 +25,8 @@
- text: Coding Tools Shows trajectory, code diffs, and Agent preset switching in new chats
- switch "Coding Tools"
- text: Keyboard shortcuts
- paragraph: Browse available shortcuts and input actions
- button "View shortcuts"
- paragraph: View and edit available shortcuts and input actions
- button "Edit shortcuts"
- text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior
- button "Queue"
- text: "Current version: {{version}}"
@@ -25,8 +25,8 @@
- text: 代码工作工具 开启后显示轨迹、本轮代码差异,新对话中的 Agent 预设切换
- switch "代码工作工具"
- text: 快捷键
- paragraph: 查看当前可用的快捷键和输入操作
- button "查看快捷键"
- paragraph: 查看和编辑当前可用的快捷键和输入操作
- button "编辑快捷键"
- text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为
- button "排队发送"
- text: 当前版本:{{version}}
@@ -1,5 +1,3 @@
- listitem:
- text: New Session
- button "Remove shortcut for New Session"
- button "Edit shortcut for New Session"
- button "New Session A B": A B
- text: New Session A B
@@ -1,5 +1,3 @@
- listitem:
- text: New Session
- button "Remove shortcut for New Session"
- button "Edit shortcut for New Session"
- button "New Session ⌘ C": ⌘ C
- text: New Session ⌘ C
@@ -1,5 +1,3 @@
- listitem:
- text: New Session
- button "Remove shortcut for New Session"
- button "Edit shortcut for New Session"
- button "New Session Ctrl + C": Ctrl + C
- text: New Session Ctrl + C
@@ -7,126 +7,72 @@
- region "Application":
- list:
- listitem:
- text: Open keyboard shortcuts
- button "Remove shortcut for Open keyboard shortcuts"
- button "Edit shortcut for Open keyboard shortcuts"
- button "Open keyboard shortcuts ⌘ /": ⌘ /
- text: Open keyboard shortcuts ⌘ /
- listitem:
- text: New Session
- button "Remove shortcut for New Session"
- button "Edit shortcut for New Session"
- button "New Session ⌥ ⌘ N": ⌥ ⌘ N
- text: New Session ⌥ ⌘ N
- listitem:
- text: Toggle left sidebar
- button "Remove shortcut for Toggle left sidebar"
- button "Edit shortcut for Toggle left sidebar"
- button "Toggle left sidebar ⌥ ⌘ B": ⌥ ⌘ B
- text: Toggle left sidebar ⌥ ⌘ B
- listitem:
- text: Search sessions
- button "Remove shortcut for Search sessions"
- button "Edit shortcut for Search sessions"
- button "Search sessions ⌥ ⌘ K": ⌥ ⌘ K
- text: Search sessions ⌥ ⌘ K
- listitem:
- text: Add workspace
- button "Remove shortcut for Add workspace"
- button "Edit shortcut for Add workspace"
- button "Add workspace ⌥ ⌘ O": ⌥ ⌘ O
- text: Add workspace ⌥ ⌘ O
- listitem:
- text: Rename session
- button "Remove shortcut for Rename session"
- button "Edit shortcut for Rename session"
- button "Rename session ⇧ ⌘ R": ⇧ ⌘ R
- text: Rename session ⇧ ⌘ R
- listitem:
- text: Fork session
- button "Remove shortcut for Fork session"
- button "Edit shortcut for Fork session"
- button "Fork session ⇧ ⌘ F": ⇧ ⌘ F
- text: Fork session ⇧ ⌘ F
- listitem:
- text: Archive session
- button "Remove shortcut for Archive session"
- button "Edit shortcut for Archive session"
- button "Archive session ⌥ ⌘ A": ⌥ ⌘ A
- text: Archive session ⌥ ⌘ A
- listitem:
- text: Open settings
- button "Remove shortcut for Open settings"
- button "Edit shortcut for Open settings"
- button "Open settings ⌘ ,": ⌘ ,
- text: Open settings ⌘ ,
- listitem:
- text: Toggle right sidebar
- button "Remove shortcut for Toggle right sidebar"
- button "Edit shortcut for Toggle right sidebar"
- button "Toggle right sidebar ⇧ ⌘ B": ⇧ ⌘ B
- text: Toggle right sidebar ⇧ ⌘ B
- listitem:
- text: Workspace files
- button "Remove shortcut for Workspace files"
- button "Edit shortcut for Workspace files"
- button "Workspace files ⌥ ⌘ P": ⌥ ⌘ P
- text: Workspace files ⌥ ⌘ P
- listitem:
- text: New terminal
- button "Remove shortcut for New terminal"
- button "Edit shortcut for New terminal"
- 'button "New terminal ⌃ `"': "⌃ `"
- text: "New terminal ⌃ `"
- listitem:
- text: Split
- button "Remove shortcut for Split"
- button "Edit shortcut for Split"
- button "Split ⌘ \\": ⌘ \
- text: Split ⌘ \
- listitem:
- text: Toggle panel fullscreen
- button "Remove shortcut for Toggle panel fullscreen"
- button "Edit shortcut for Toggle panel fullscreen"
- button "Toggle panel fullscreen ⌥ ⌘ Enter": ⌥ ⌘ Enter
- text: Toggle panel fullscreen ⌥ ⌘ Enter
- listitem:
- text: Refresh current page
- button "Edit shortcut for Refresh current page"
- button "Refresh current page No shortcut": No shortcut
- text: Refresh current page No shortcut
- listitem:
- text: Close current page or window
- button "Remove shortcut for Close current page or window"
- button "Edit shortcut for Close current page or window"
- button "Close current page or window ⌥ ⌘ W": ⌥ ⌘ W
- listitem:
- text: Stop generating
- button "Stop generating Esc Esc" [disabled]: Esc Esc
- text: Close current page or window ⌥ ⌘ W
- region "Message input":
- heading "Message input" [level=3]
- list:
- listitem:
- text: Use the complementary Queue / Steer action
- button "Use the complementary Queue / Steer action ⌘ Enter" [disabled]: ⌘ Enter
- listitem:
- text: Open reference menu
- button "Open reference menu @" [disabled]: "@"
- listitem:
- text: New line
- button "New line ⇧ Enter" [disabled]: ⇧ Enter
- listitem:
- text: Send message
- button "Send message Enter" [disabled]: Enter
- listitem:
- text: Open command menu
- button "Open command menu /" [disabled]: /
- listitem: Use the complementary Queue / Steer action ⌘ Enter
- listitem: Open reference menu @
- listitem: New line ⇧ Enter
- listitem: Send message Enter
- listitem: Open command menu /
- listitem: Stop generating Esc Esc
- region "Menus and dialogs":
- heading "Menus and dialogs" [level=3]
- list:
- listitem:
- text: Close menu or top dialog
- button "Close menu or top dialog Esc" [disabled]: Esc
- listitem:
- text: Move menu selection
- button "Move menu selection ↑ ↓" [disabled]: ↑ ↓
- listitem:
- text: Select menu item
- button "Select menu item Enter" [disabled]: Enter
- listitem: Close menu or top dialog Esc
- listitem: Move menu selection ↑ ↓
- listitem: Select menu item Enter
- region "Approval area":
- heading "Approval area" [level=3]
- list:
- listitem:
- text: Allow once
- button "Allow once Enter" [disabled]: Enter
- listitem:
- text: Reject
- button "Reject Esc" [disabled]: Esc
- listitem: Allow once Enter
- listitem: Reject Esc
- contentinfo:
- button "Restore all defaults" [disabled]
- text: 0 customized
@@ -4,6 +4,7 @@
- button "Close keyboard shortcuts"
- search "Search shortcuts":
- searchbox "Search shortcuts": abc
- button "Clear search"
- status: No matching shortcuts
- contentinfo:
- button "Restore all defaults"
@@ -7,127 +7,72 @@
- region "Application":
- list:
- listitem:
- text: Open keyboard shortcuts
- button "Remove shortcut for Open keyboard shortcuts"
- button "Edit shortcut for Open keyboard shortcuts"
- button "Open keyboard shortcuts Ctrl + /": Ctrl + /
- text: Open keyboard shortcuts Ctrl + /
- listitem:
- text: New Session
- button "Remove shortcut for New Session"
- button "Edit shortcut for New Session"
- button "New Session Ctrl + Alt + N": Ctrl + Alt + N
- text: New Session Ctrl + Alt + N
- listitem:
- text: Toggle left sidebar
- button "Remove shortcut for Toggle left sidebar"
- button "Edit shortcut for Toggle left sidebar"
- button "Toggle left sidebar Ctrl + Alt + B": Ctrl + Alt + B
- text: Toggle left sidebar Ctrl + Alt + B
- listitem:
- text: Search sessions
- button "Remove shortcut for Search sessions"
- button "Edit shortcut for Search sessions"
- button "Search sessions Ctrl + Alt + K": Ctrl + Alt + K
- text: Search sessions Ctrl + Alt + K
- listitem:
- text: Add workspace
- button "Remove shortcut for Add workspace"
- button "Edit shortcut for Add workspace"
- button "Add workspace Ctrl + Alt + O": Ctrl + Alt + O
- text: Add workspace Ctrl + Alt + O
- listitem:
- text: Rename session
- button "Remove shortcut for Rename session"
- button "Edit shortcut for Rename session"
- button "Rename session Ctrl + Shift + R": Ctrl + Shift + R
- text: Rename session Ctrl + Shift + R
- listitem:
- text: Fork session
- button "Remove shortcut for Fork session"
- button "Edit shortcut for Fork session"
- button "Fork session Ctrl + Shift + F": Ctrl + Shift + F
- text: Fork session Ctrl + Shift + F
- listitem:
- text: Archive session
- button "Remove shortcut for Archive session"
- button "Edit shortcut for Archive session"
- button "Archive session Ctrl + Alt + A": Ctrl + Alt + A
- text: Archive session Ctrl + Alt + A
- listitem:
- text: Open settings
- button "Remove shortcut for Open settings"
- button "Edit shortcut for Open settings"
- button "Open settings Ctrl + ,": Ctrl + ,
- text: Open settings Ctrl + ,
- listitem:
- text: Toggle right sidebar
- button "Remove shortcut for Toggle right sidebar"
- button "Edit shortcut for Toggle right sidebar"
- button "Toggle right sidebar Ctrl + Shift + B": Ctrl + Shift + B
- text: Toggle right sidebar Ctrl + Shift + B
- listitem:
- text: Workspace files
- button "Remove shortcut for Workspace files"
- button "Edit shortcut for Workspace files"
- button "Workspace files Ctrl + Alt + P": Ctrl + Alt + P
- text: Workspace files Ctrl + Alt + P
- listitem:
- text: New terminal
- button "Remove shortcut for New terminal"
- button "Edit shortcut for New terminal"
- 'button "New terminal Ctrl + `"': "Ctrl + `"
- text: "New terminal Ctrl + `"
- listitem:
- text: Split
- button "Remove shortcut for Split"
- button "Edit shortcut for Split"
- button "Split Ctrl + \\": Ctrl + \
- text: Split Ctrl + \
- listitem:
- text: Toggle panel fullscreen
- button "Remove shortcut for Toggle panel fullscreen"
- button "Edit shortcut for Toggle panel fullscreen"
- button "Toggle panel fullscreen Ctrl + Alt + Enter": Ctrl + Alt + Enter
- text: Toggle panel fullscreen Ctrl + Alt + Enter
- listitem:
- text: Refresh current page
- button "Remove shortcut for Refresh current page"
- button "Edit shortcut for Refresh current page"
- button "Refresh current page Ctrl + Alt + R": Ctrl + Alt + R
- text: Refresh current page Ctrl + Alt + R
- listitem:
- text: Close current page or window
- button "Remove shortcut for Close current page or window"
- button "Edit shortcut for Close current page or window"
- button "Close current page or window Ctrl + Alt + W": Ctrl + Alt + W
- listitem:
- text: Stop generating
- button "Stop generating Esc Esc" [disabled]: Esc Esc
- text: Close current page or window Ctrl + Alt + W
- region "Message input":
- heading "Message input" [level=3]
- list:
- listitem:
- text: Use the complementary Queue / Steer action
- button "Use the complementary Queue / Steer action Ctrl + Enter" [disabled]: Ctrl + Enter
- listitem:
- text: Open reference menu
- button "Open reference menu @" [disabled]: "@"
- listitem:
- text: New line
- button "New line Shift + Enter" [disabled]: Shift + Enter
- listitem:
- text: Send message
- button "Send message Enter" [disabled]: Enter
- listitem:
- text: Open command menu
- button "Open command menu /" [disabled]: /
- listitem: Use the complementary Queue / Steer action Ctrl + Enter
- listitem: Open reference menu @
- listitem: New line Shift + Enter
- listitem: Send message Enter
- listitem: Open command menu /
- listitem: Stop generating Esc Esc
- region "Menus and dialogs":
- heading "Menus and dialogs" [level=3]
- list:
- listitem:
- text: Close menu or top dialog
- button "Close menu or top dialog Esc" [disabled]: Esc
- listitem:
- text: Move menu selection
- button "Move menu selection ↑ ↓" [disabled]: ↑ ↓
- listitem:
- text: Select menu item
- button "Select menu item Enter" [disabled]: Enter
- listitem: Close menu or top dialog Esc
- listitem: Move menu selection ↑ ↓
- listitem: Select menu item Enter
- region "Approval area":
- heading "Approval area" [level=3]
- list:
- listitem:
- text: Allow once
- button "Allow once Enter" [disabled]: Enter
- listitem:
- text: Reject
- button "Reject Esc" [disabled]: Esc
- listitem: Allow once Enter
- listitem: Reject Esc
- contentinfo:
- button "Restore all defaults" [disabled]
- text: 0 customized
@@ -4,6 +4,7 @@
- button "关闭快捷键"
- search "搜索快捷键":
- searchbox "搜索快捷键": abc
- button "清空搜索"
- status: 没有匹配的快捷键
- contentinfo:
- button "恢复全部默认"
@@ -7,126 +7,72 @@
- region "应用操作":
- list:
- listitem:
- text: 快捷键速查
- button "移除快捷键速查快捷键"
- button "修改快捷键速查快捷键"
- button "快捷键速查 ⌘ /": ⌘ /
- text: 快捷键速查 ⌘ /
- listitem:
- text: 新会话
- button "移除新会话快捷键"
- button "修改新会话快捷键"
- button "新会话 ⌥ ⌘ N": ⌥ ⌘ N
- text: 新会话 ⌥ ⌘ N
- listitem:
- text: 展开/收起左侧栏
- button "移除展开/收起左侧栏快捷键"
- button "修改展开/收起左侧栏快捷键"
- button "展开/收起左侧栏 ⌥ ⌘ B": ⌥ ⌘ B
- text: 展开/收起左侧栏 ⌥ ⌘ B
- listitem:
- text: 搜索会话
- button "移除搜索会话快捷键"
- button "修改搜索会话快捷键"
- button "搜索会话 ⌥ ⌘ K": ⌥ ⌘ K
- text: 搜索会话 ⌥ ⌘ K
- listitem:
- text: 添加工作区
- button "移除添加工作区快捷键"
- button "修改添加工作区快捷键"
- button "添加工作区 ⌥ ⌘ O": ⌥ ⌘ O
- text: 添加工作区 ⌥ ⌘ O
- listitem:
- text: 重命名会话
- button "移除重命名会话快捷键"
- button "修改重命名会话快捷键"
- button "重命名会话 ⇧ ⌘ R": ⇧ ⌘ R
- text: 重命名会话 ⇧ ⌘ R
- listitem:
- text: 分叉会话
- button "移除分叉会话快捷键"
- button "修改分叉会话快捷键"
- button "分叉会话 ⇧ ⌘ F": ⇧ ⌘ F
- text: 分叉会话 ⇧ ⌘ F
- listitem:
- text: 归档会话
- button "移除归档会话快捷键"
- button "修改归档会话快捷键"
- button "归档会话 ⌥ ⌘ A": ⌥ ⌘ A
- text: 归档会话 ⌥ ⌘ A
- listitem:
- text: 打开设置
- button "移除打开设置快捷键"
- button "修改打开设置快捷键"
- button "打开设置 ⌘ ,": ⌘ ,
- text: 打开设置 ⌘ ,
- listitem:
- text: 展开/收起右侧栏
- button "移除展开/收起右侧栏快捷键"
- button "修改展开/收起右侧栏快捷键"
- button "展开/收起右侧栏 ⇧ ⌘ B": ⇧ ⌘ B
- text: 展开/收起右侧栏 ⇧ ⌘ B
- listitem:
- text: 工作区文件
- button "移除工作区文件快捷键"
- button "修改工作区文件快捷键"
- button "工作区文件 ⌥ ⌘ P": ⌥ ⌘ P
- text: 工作区文件 ⌥ ⌘ P
- listitem:
- text: 新建终端
- button "移除新建终端快捷键"
- button "修改新建终端快捷键"
- 'button "新建终端 ⌃ `"': "⌃ `"
- text: "新建终端 ⌃ `"
- listitem:
- text: 分栏
- button "移除分栏快捷键"
- button "修改分栏快捷键"
- button "分栏 ⌘ \\": ⌘ \
- text: 分栏 ⌘ \
- listitem:
- text: 面板全屏/退出全屏
- button "移除面板全屏/退出全屏快捷键"
- button "修改面板全屏/退出全屏快捷键"
- button "面板全屏/退出全屏 ⌥ ⌘ Enter": ⌥ ⌘ Enter
- text: 面板全屏/退出全屏 ⌥ ⌘ Enter
- listitem:
- text: 刷新当前页面
- button "修改刷新当前页面快捷键"
- button "刷新当前页面 暂无快捷键": 暂无快捷键
- text: 刷新当前页面 暂无快捷键
- listitem:
- text: 关闭当前页面/窗口
- button "移除关闭当前页面/窗口快捷键"
- button "修改关闭当前页面/窗口快捷键"
- button "关闭当前页面/窗口 ⌥ ⌘ W": ⌥ ⌘ W
- listitem:
- text: 停止生成
- button "停止生成 Esc Esc" [disabled]: Esc Esc
- text: 关闭当前页面/窗口 ⌥ ⌘ W
- region "消息输入":
- heading "消息输入" [level=3]
- list:
- listitem:
- text: 使用互补的 Queue/Steer 方式
- button "使用互补的 Queue/Steer 方式 ⌘ Enter" [disabled]: ⌘ Enter
- listitem:
- text: 打开引用菜单
- button "打开引用菜单 @" [disabled]: "@"
- listitem:
- text: 换行
- button "换行 ⇧ Enter" [disabled]: ⇧ Enter
- listitem:
- text: 发送消息
- button "发送消息 Enter" [disabled]: Enter
- listitem:
- text: 打开命令菜单
- button "打开命令菜单 /" [disabled]: /
- listitem: 使用互补的 Queue/Steer 方式 ⌘ Enter
- listitem: 打开引用菜单 @
- listitem: 换行 ⇧ Enter
- listitem: 发送消息 Enter
- listitem: 打开命令菜单 /
- listitem: 停止生成 Esc Esc
- region "菜单与弹层":
- heading "菜单与弹层" [level=3]
- list:
- listitem:
- text: 关闭菜单或顶层弹窗
- button "关闭菜单或顶层弹窗 Esc" [disabled]: Esc
- listitem:
- text: 移动菜单选择
- button "移动菜单选择 ↑ ↓" [disabled]: ↑ ↓
- listitem:
- text: 选择菜单项
- button "选择菜单项 Enter" [disabled]: Enter
- listitem: 关闭菜单或顶层弹窗 Esc
- listitem: 移动菜单选择 ↑ ↓
- listitem: 选择菜单项 Enter
- region "审批区域":
- heading "审批区域" [level=3]
- list:
- listitem:
- text: 允许一次
- button "允许一次 Enter" [disabled]: Enter
- listitem:
- text: 拒绝
- button "拒绝 Esc" [disabled]: Esc
- listitem: 允许一次 Enter
- listitem: 拒绝 Esc
- contentinfo:
- button "恢复全部默认" [disabled]
- text: 0 项已自定义
@@ -1 +1 @@
Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description](<path/to/image.png>) when an image supports an explanation or comparison. Use [Description](<path/to/image.png>) when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix.
Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description](<path/to/image.png>) when an image supports an explanation or comparison. Use [Description](<path/to/image.png>) when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix.
+16 -3
View File
@@ -23,9 +23,9 @@ async function bind(page: Page, primary: string, label: string, previous?: strin
expect(await row.getByText('Unavailable', { exact: true }).count()).toBe(0)
}
if (previous !== undefined) {
const remove = dialog.getByRole('button', { name: `Remove shortcut for ${previous}`, exact: true })
await remove.click()
await remove.waitFor({ state: 'hidden' })
await dialog.getByRole('button', { name: `Edit shortcut for ${previous}`, exact: true }).click()
await dialog.getByRole('button', { name: 'Remove', exact: true }).click()
await dialog.getByRole('group').waitFor({ state: 'hidden' })
}
await dialog.getByRole('button', { name: `Edit shortcut for ${label}`, exact: true }).click()
const recorder = dialog.getByRole('group', { name: label, exact: true })
@@ -125,6 +125,19 @@ describe.skipIf(process.platform === 'win32')('Web sidebar shortcuts', () => {
await terminalGuide.focus()
expect(await page.getByRole('tooltip').count()).toBe(0)
const terminalEntry = panel.locator('[data-sidebar-right-guide-entry="terminal"]')
const shellMenu = terminalEntry.getByRole('button', { name: 'Choose shell', exact: true })
const titleBounds = await terminalEntry.getByText('New terminal', { exact: true }).boundingBox()
const arrowBounds = await shellMenu.boundingBox()
expect(arrowBounds!.x).toBeGreaterThanOrEqual(titleBounds!.x + titleBounds!.width)
expect(arrowBounds!.x - titleBounds!.x - titleBounds!.width).toBeLessThan(8)
await shellMenu.click()
await page.getByRole('menu').waitFor()
expect(await panel.locator('[data-sidebar-terminal]').count()).toBe(0)
await page.keyboard.press('Escape')
await page.getByRole('menu').waitFor({ state: 'detached' })
expect(await shellMenu.evaluate(element => element === document.activeElement)).toBe(true)
await bind(page, primary, 'Browser', 'Toggle right sidebar')
const browserGuide = panel.locator('[data-sidebar-right-guide-entry="browser"]')
await browserGuide.hover()
+3 -2
View File
@@ -61,8 +61,9 @@ describe.skipIf(mode === 'record')('web e2e: workspace shortcuts', () => {
expect(await row.getByText('Unavailable', { exact: true }).count()).toBe(0)
}
if (previous !== undefined) {
await reference.getByRole('button', { name: `Remove shortcut for ${previous}`, exact: true }).click()
await expect.poll(() => reference.getByRole('button', { name: `Remove shortcut for ${previous}`, exact: true }).count()).toBe(0)
await reference.getByRole('button', { name: `Edit shortcut for ${previous}`, exact: true }).click()
await reference.getByRole('button', { name: 'Remove', exact: true }).click()
await reference.getByRole('group').waitFor({ state: 'hidden' })
}
await reference.getByRole('button', { name: `Edit shortcut for ${label}`, exact: true }).click()
await page.keyboard.press('Meta+Shift+Comma')
+97 -38
View File
@@ -19,8 +19,8 @@ describe('web e2e: shortcut reference', () => {
afterAll(async () => { await browser?.close(); await scaffold?.close() })
it.each([
{ locale: 'zh-CN', platform: 'MacIntel', title: '快捷键', settings: '设置', view: '查看快捷键', search: '搜索快捷键', key: 'Meta' },
{ locale: 'en-US', platform: 'Win32', title: 'Keyboard shortcuts', settings: 'Settings', view: 'View shortcuts', search: 'Search shortcuts', key: 'Control' },
{ locale: 'zh-CN', platform: 'MacIntel', title: '快捷键', settings: '设置', view: '编辑快捷键', search: '搜索快捷键', key: 'Meta' },
{ locale: 'en-US', platform: 'Win32', title: 'Keyboard shortcuts', settings: 'Settings', view: 'Edit shortcuts', search: 'Search shortcuts', key: 'Control' },
])('opens, searches, and restores nested focus in $locale', async ({ locale, platform, title, settings, view, search, key }) => {
const referenceKey = platform === 'Win32' ? 'Control+Slash' : 'Meta+Slash'
const context = await browser.newContext({ locale, colorScheme: 'light', viewport: { width: 1440, height: 1000 } })
@@ -46,14 +46,13 @@ describe('web e2e: shortcut reference', () => {
const resetAll = dialog.getByRole('button', { name: locale === 'zh-CN' ? '恢复全部默认' : 'Restore all defaults', exact: true })
expect(await resetAll.isDisabled()).toBe(true)
expect(await dialog.boundingBox()).toMatchObject({ width: 480, height: 600 })
expect(await dialog.getByRole('search').boundingBox()).toMatchObject({ width: 432, height: 36 })
expect(await dialog.getByRole('search').boundingBox()).toMatchObject({ width: 440, height: 36 })
const rows = dialog.getByRole('listitem')
const stopRow = dialog.getByRole('region', { name: locale === 'zh-CN' ? '应用操作' : 'Application', exact: true })
const stopRow = dialog.getByRole('region', { name: locale === 'zh-CN' ? '消息输入' : 'Message input', exact: true })
.getByRole('listitem').last()
await stopRow.getByText(locale === 'zh-CN' ? '停止生成' : 'Stop generating', { exact: true }).waitFor()
const stopButtons = stopRow.getByRole('button')
expect(await stopButtons.count()).toBe(1)
expect(await stopButtons.isDisabled()).toBe(true)
expect(await stopButtons.count()).toBe(0)
expect(new Set(await rows.evaluateAll(items => items.map(row => row.getBoundingClientRect().height))))
.toEqual(new Set([42]))
expect(await rows.evaluateAll(items => items.every(row => row.getBoundingClientRect().height >= 42
@@ -72,29 +71,22 @@ describe('web e2e: shortcut reference', () => {
const dialogShadow = await dialog.evaluate(element => getComputedStyle(element).boxShadow)
await compareOrRefreshGolden(join(expected, `${locale}.expected.md`),
await captureStableAria(page, '[data-shortcut-modal="shortcuts"]', scaffold.workspaceCwd), mode)
const fixedButtons = dialog.getByRole('button', { disabled: true })
const fixedButton = fixedButtons.first()
expect(await fixedButton.evaluate(element => getComputedStyle(element).opacity)).toBe('0.45')
const fixedBackground = await fixedButton.locator('span').evaluate(element => getComputedStyle(element).backgroundColor)
await fixedButton.hover()
expect(await fixedButton.locator('span').evaluate(element => getComputedStyle(element).backgroundColor)).toBe(fixedBackground)
const fixedBackground = await stopRow.evaluate(element => getComputedStyle(element).backgroundColor)
await stopRow.hover()
expect(await stopRow.evaluate(element => getComputedStyle(element).backgroundColor)).toBe(fixedBackground)
const editLabel = locale === 'zh-CN' ? '修改展开/收起左侧栏快捷键' : 'Edit shortcut for Toggle left sidebar'
const removeLabel = locale === 'zh-CN' ? '移除展开/收起左侧栏快捷键' : 'Remove shortcut for Toggle left sidebar'
const row = dialog.getByRole('listitem').filter({ has: page.getByRole('button', { name: editLabel, exact: true }) })
const editButton = page.getByRole('button', { name: editLabel, exact: true })
expect(await editButton.evaluate(element => getComputedStyle(element.parentElement!).opacity)).toBe('0')
expect(await row.locator('[class*="rowActions"]').evaluate(element => getComputedStyle(element).opacity)).toBe('0')
const boundRow = dialog.getByRole('listitem').filter({ has: page.getByText(
locale === 'zh-CN' ? '快捷键速查' : 'Open keyboard shortcuts', { exact: true },
) })
const badge = boundRow.getByRole('button').last()
const badgeColor = () => badge.locator('span').evaluate(element => getComputedStyle(element).backgroundColor)
const restingColor = await badgeColor()
await boundRow.locator(':scope > span').first().hover()
expect(await badgeColor()).toBe(restingColor)
await badge.hover()
expect(await badgeColor()).toBe('rgba(38, 49, 72, 0.14)')
await boundRow.getByRole('button').first().hover()
expect(await badgeColor()).toBe(restingColor)
const badge = boundRow.getByRole('button', { name: locale === 'zh-CN' ? '修改快捷键速查快捷键' : 'Edit shortcut for Open keyboard shortcuts', exact: true })
const restingColor = await boundRow.evaluate(element => getComputedStyle(element).backgroundColor)
await badge.hover({ position: { x: 12, y: 12 } })
expect(await boundRow.evaluate(element => getComputedStyle(element).backgroundColor)).not.toBe(restingColor)
expect(await boundRow.evaluate(element => getComputedStyle(element).borderRadius)).toBe('12px')
expect(await badge.boundingBox()).toEqual(await boundRow.boundingBox())
await badge.click()
await boundRow.getByRole('group').waitFor()
expect(await searchSurfaceStyle()).toEqual(focusedSearchSurface)
@@ -104,7 +96,7 @@ describe('web e2e: shortcut reference', () => {
await page.keyboard.press('Escape')
await boundRow.getByRole('group').waitFor({ state: 'hidden' })
await dialog.getByRole('heading', { name: title, exact: true }).hover()
const boundActionsOpacity = () => boundRow.getByRole('button').first().evaluate(element => getComputedStyle(element.parentElement!).opacity)
const boundActionsOpacity = () => boundRow.locator('[class*="rowActions"]').evaluate(element => getComputedStyle(element).opacity)
expect(await boundActionsOpacity()).toBe('0')
expect(await dialog.evaluate(element => element === document.activeElement)).toBe(true)
expect(await dialog.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none')
@@ -118,14 +110,17 @@ describe('web e2e: shortcut reference', () => {
await dialog.getByRole('heading', { name: title, exact: true }).hover()
expect(await boundActionsOpacity()).toBe('0')
await row.hover()
expect(await editButton.evaluate(element => getComputedStyle(element.parentElement!).opacity)).toBe('1')
expect(await row.getByRole('button', { name: removeLabel, exact: true }).count()).toBe(1)
expect(await row.locator('[class*="rowActions"]').evaluate(element => getComputedStyle(element).opacity)).toBe('1')
expect(await row.getByRole('button').count()).toBe(1)
await editButton.click()
const inline = dialog.getByRole('group', { name: locale === 'zh-CN' ? '展开/收起左侧栏' : 'Toggle left sidebar', exact: true })
const recorder = inline.getByRole('button', { name: locale === 'zh-CN' ? '按下快捷键' : 'Press a shortcut', exact: true })
expect(await inline.getByRole('button', { name: locale === 'zh-CN' ? '移除' : 'Remove', exact: true }).count()).toBe(1)
expect(await page.getByRole('dialog').count()).toBe(2)
expect(await recorder.evaluate(element => getComputedStyle(element).color)).toBe('rgb(129, 133, 140)')
expect(await recorder.evaluate(element => ({
color: getComputedStyle(element).color, border: getComputedStyle(element).borderColor,
})))
.toEqual({ color: 'rgb(97, 102, 107)', border: 'rgba(0, 0, 0, 0.16)' })
expect(await recorder.evaluate(element => ({
focused: element === document.activeElement,
outline: getComputedStyle(element).outlineStyle, shadow: getComputedStyle(element).boxShadow,
@@ -155,7 +150,7 @@ describe('web e2e: shortcut reference', () => {
expect(await dialog.evaluate(element => getComputedStyle(element).boxShadow)).toBe(dialogShadow)
expect(await editButton.evaluate(element => element.matches(':focus-visible'))).toBe(false)
await dialog.getByRole('heading', { name: title, exact: true }).hover()
expect(await editButton.evaluate(element => getComputedStyle(element.parentElement!).opacity)).toBe('0')
expect(await row.locator('[class*="rowActions"]').evaluate(element => getComputedStyle(element).opacity)).toBe('0')
await page.keyboard.press('Shift+Tab')
expect(await resetAll.evaluate(element => element === document.activeElement)).toBe(true)
expect(await resetAll.evaluate(element => getComputedStyle(element).outlineStyle)).not.toBe('none')
@@ -168,12 +163,14 @@ describe('web e2e: shortcut reference', () => {
}))).toEqual({ background: 'rgb(67, 69, 74)', color: 'rgb(255, 255, 255)' })
await page.emulateMedia({ colorScheme: 'light' })
await expect.poll(() => successToast.evaluate(element => getComputedStyle(element).backgroundColor)).toBe('rgb(53, 54, 56)')
await page.getByRole('button', { name: removeLabel, exact: true }).click()
await editButton.click()
await inline.getByRole('button', { name: locale === 'zh-CN' ? '移除' : 'Remove', exact: true }).click()
await inline.waitFor({ state: 'hidden' })
const unbound = row.getByText(locale === 'zh-CN' ? '暂无快捷键' : 'No shortcut', { exact: true })
await unbound.waitFor()
expect(await unbound.evaluate(element => getComputedStyle(element).color)).toBe('rgb(129, 133, 140)')
expect(await row.getByRole('button', { name: removeLabel, exact: true }).count()).toBe(0)
await unbound.click()
expect(await row.getByRole('button').count()).toBe(1)
await editButton.click({ position: { x: 12, y: 12 } })
await inline.waitFor()
expect(await inline.getByRole('button', { name: locale === 'zh-CN' ? '移除' : 'Remove', exact: true }).count()).toBe(0)
await page.keyboard.press('Escape')
@@ -182,7 +179,7 @@ describe('web e2e: shortcut reference', () => {
expect(await dialog.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none')
expect(await dialog.evaluate(element => getComputedStyle(element).boxShadow)).toBe(dialogShadow)
await dialog.getByRole('heading', { name: title, exact: true }).hover()
expect(await editButton.evaluate(element => getComputedStyle(element.parentElement!).opacity)).toBe('0')
expect(await row.getByRole('button').count()).toBe(1)
expect(await page.getByRole('dialog').count()).toBe(2)
for (const query of ['abc', 'sendEnter']) {
await page.getByRole('searchbox').fill(query)
@@ -245,11 +242,13 @@ describe('web e2e: shortcut reference', () => {
await page.setViewportSize({ width: 1440, height: 1000 })
await openSettings(page, locale === 'zh-CN' ? 'zh' : 'en')
const viewButton = page.getByRole('button', { name: view, exact: true })
const viewHint = `${view} ${key === 'Meta' ? '⌘ /' : 'Ctrl + /'}`
const viewHint = `${locale === 'zh-CN' ? '全局唤起查看' : 'Open from anywhere'} ${key === 'Meta' ? '⌘ /' : 'Ctrl + /'}`
await viewButton.hover()
expect(await page.getByRole('tooltip').textContent()).toBe(viewHint)
expect(await page.getByRole('tooltip').getAttribute('aria-label')).toBe(viewHint)
await viewButton.click()
await boundRow.getByRole('button').first().click()
await badge.click()
await boundRow.getByRole('button', { name: locale === 'zh-CN' ? '移除' : 'Remove', exact: true }).click()
await boundRow.getByRole('group').waitFor({ state: 'hidden' })
await boundRow.getByText(locale === 'zh-CN' ? '暂无快捷键' : 'No shortcut', { exact: true }).waitFor()
await page.keyboard.press('Escape')
await dialog.waitFor({ state: 'hidden' })
@@ -266,7 +265,7 @@ describe('web e2e: shortcut reference', () => {
await page.keyboard.press('Escape')
await dialog.waitFor({ state: 'hidden' })
await viewButton.hover()
expect(await page.getByRole('tooltip').textContent()).toBe(viewHint)
expect(await page.getByRole('tooltip').getAttribute('aria-label')).toBe(viewHint)
await viewButton.click()
await page.getByRole('searchbox').fill('abc')
expect(await dialog.getByRole('listitem').count()).toBe(0)
@@ -286,7 +285,7 @@ describe('web e2e: shortcut reference', () => {
await confirm.getByRole('button', { name: locale === 'zh-CN' ? '恢复默认' : 'Restore default', exact: true }).click()
await confirm.waitFor({ state: 'hidden' })
expect(await resetAll.isDisabled()).toBe(true)
expect(await dialog.locator('footer').textContent()).toContain(locale === 'zh-CN' ? '0 项已自定义' : '0 customized')
expect(await dialog.locator('footer').textContent()).toBe(locale === 'zh-CN' ? '恢复全部默认' : 'Restore all defaults')
expect(await page.getByRole('searchbox').evaluate(element => element === document.activeElement)).toBe(true)
expect(await searchInput.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none')
expect(await searchSurfaceStyle()).toEqual(focusedSearchSurface)
@@ -322,6 +321,65 @@ describe('web e2e: shortcut reference', () => {
} finally { await context.close() }
})
it.each([
{ platform: 'Win32', primary: 'Control', keys: 'Ctrl + Alt + N' },
{ platform: 'MacIntel', primary: 'Meta', keys: '⌥ ⇧ ⌘ F12' },
])('shows the shortcut inside New Session on hover and keyboard focus at wide and narrow widths on $platform', async ({ platform, primary, keys }) => {
const context = await browser.newContext({ locale: 'en-US', viewport: { width: 1440, height: 1000 } })
try {
await context.addInitScript((value) => { Object.defineProperty(navigator, 'platform', { value }) }, platform)
const page = await context.newPage()
await page.goto(scaffold.authenticatedUrl)
const newSession = page.getByRole('button', { name: 'New session', exact: true }).filter({ hasText: 'New Session' })
await newSession.waitFor()
if (platform === 'MacIntel') await page.evaluate(() => { document.documentElement.dataset.platform = 'darwin' })
await page.keyboard.press(`${primary}+/`)
await page.getByRole('button', { name: 'Edit shortcut for New Session', exact: true }).click()
await page.keyboard.press(platform === 'Win32' ? 'Control+Alt+N' : 'Meta+Alt+Shift+F12')
await page.getByRole('group', { name: 'New Session', exact: true }).waitFor({ state: 'hidden' })
await page.keyboard.press('Escape')
for (const colorScheme of ['light', 'dark'] as const) {
await page.emulateMedia({ colorScheme })
for (const width of [420, 264]) {
const handle = (await page.locator('[data-side="sidebar"]').boundingBox())!
await page.mouse.move(handle.x + handle.width / 2, 240)
await page.mouse.down()
await page.mouse.move(width, 240, { steps: 10 })
await page.mouse.up()
await expect.poll(() => newSession.locator('kbd').allTextContents()).toEqual(keys.split(' '))
const centerOffset = () => newSession.evaluate((element) => {
const button = element.getBoundingClientRect()
const content = element.querySelector('[class*="newSessionContent"]')!.getBoundingClientRect()
return Math.abs(button.x + button.width / 2 - content.x - content.width / 2)
})
expect(await centerOffset()).toBeLessThan(1)
const hintStyle = () => newSession.locator('[class*="newSessionShortcut"]').evaluate(element => ({
opacity: getComputedStyle(element).opacity, mask: getComputedStyle(element.previousElementSibling!).maskImage,
}))
expect(await hintStyle()).toEqual({ opacity: '0', mask: 'none' })
await newSession.hover()
expect(await centerOffset()).toBeLessThan(1)
const mask = await newSession.locator('[class*="newSessionLabelMask"]').boundingBox()
const hint = await newSession.locator('[class*="newSessionShortcut"]').boundingBox()
expect(mask!.x + mask!.width).toBeLessThanOrEqual(hint!.x + 1)
expect((await hintStyle()).opacity).toBe('1')
expect((await hintStyle()).mask).toContain('linear-gradient')
await newSession.screenshot({ path: fileURLToPath(new URL(`../../../.artifacts/new-session-${process.pid}-${platform}-${colorScheme}-${width}.png`, import.meta.url)) })
expect(await page.getByRole('tooltip').count()).toBe(0)
await page.mouse.move(700, 100)
expect(await hintStyle()).toEqual({ opacity: '0', mask: 'none' })
await newSession.focus()
await page.keyboard.press('Shift+Tab')
await page.keyboard.press('Tab')
expect(await newSession.evaluate(element => element.matches(':focus-visible'))).toBe(true)
expect((await hintStyle()).opacity).toBe('1')
expect(await page.getByRole('tooltip').count()).toBe(0)
await newSession.evaluate((element) => { element.blur() })
}
}
} finally { await context.close() }
})
it.each([
{ platform: 'Win32', modifier: 'Control', defaultBinding: 'Control+Alt+B' },
{ platform: 'MacIntel', modifier: 'Meta', defaultBinding: 'Alt+Meta+B' },
@@ -371,7 +429,8 @@ describe('web e2e: shortcut reference', () => {
await other.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await other.getByRole('button', { name: 'Settings', exact: true }).waitFor()
await other.keyboard.press(referenceKey)
await other.getByRole('button', { name: 'Remove shortcut for Toggle left sidebar', exact: true }).click()
await other.getByRole('button', { name: 'Edit shortcut for Toggle left sidebar', exact: true }).click()
await other.getByRole('button', { name: 'Remove', exact: true }).click()
await editor.getByText('Shortcut configuration or available commands changed. Review the latest bindings before saving.', { exact: true }).waitFor()
await page.bringToFront()
await editor.getByRole('button', { name: 'Press a shortcut', exact: true }).focus()
+3 -2
View File
@@ -1176,8 +1176,9 @@ describe('web e2e: shipped right Sidebar', () => {
}
expect(await terminalEntry.evaluate(node => getComputedStyle(node).overflow)).toBe('hidden')
const terminalActions = terminalEntry.getByRole('button')
for (const action of await terminalActions.all()) {
expect(await action.evaluate(node => getComputedStyle(node).borderRadius)).toBe('0px')
const terminalButtons = await terminalActions.all()
for (const [index, action] of terminalButtons.entries()) {
expect(await action.evaluate(node => getComputedStyle(node).borderRadius)).toBe(index === 0 ? '0px' : '4px')
await action.hover()
expect(await action.evaluate(node => getComputedStyle(node).backgroundColor)).not.toBe('rgba(0, 0, 0, 0)')
}
+3 -2
View File
@@ -341,9 +341,10 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
const entry = page.locator('[data-sidebar-right-guide-entry="terminal"]')
await page.locator('[data-sidebar-right-guide]').screenshot({ path: `${shots}/terminal-guide.png`, animations: 'disabled' })
const selector = entry.getByRole('button', { name: 'Choose shell', exact: true })
const cardBox = (await entry.boundingBox())!
const titleBox = (await entry.getByText('New terminal', { exact: true }).boundingBox())!
const triggerBox = (await selector.boundingBox())!
expect(Math.abs(cardBox.x + cardBox.width - triggerBox.x - triggerBox.width)).toBeLessThanOrEqual(2)
expect(triggerBox.x).toBeGreaterThanOrEqual(titleBox.x + titleBox.width)
expect(triggerBox.x - titleBox.x - titleBox.width).toBeLessThan(8)
await page.emulateMedia({ colorScheme: 'dark' })
await selector.click()
await page.getByRole('menuitem', { name: 'bash', exact: true }).waitFor()
+2 -2
View File
@@ -1116,8 +1116,8 @@
en: 93c4403f94ecdc14
zh: 2b2eb0c400881d71
/session-persistence-event-catalog/resolved-persistence-types/sandboxmode:
en: 13071a796a3bfcd2
zh: 93e5fbb1cdf78a79
en: c9fa5343ea99733b
zh: d6de41351c57a73f
/session-persistence-event-catalog/resolved-persistence-types/schedulechange:
en: 16be53be76c16720
zh: 38fd164dde86ccac
+1 -1
View File
@@ -4307,7 +4307,7 @@ One of:
SHA-256: `fab2bb09bc5e3b9db0685e8f5500b029af1a2b33e70bc703c1d5d0f596fbc1fd`
Sources: [`packages/sandbox/sandbox/src/index.ts:29`](../packages/sandbox/sandbox/src/index.ts)
Sources: [`packages/sandbox/sandbox/src/index.ts:30`](../packages/sandbox/sandbox/src/index.ts)
One of:
+1 -1
View File
@@ -4309,7 +4309,7 @@ SHA-256: `530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6`
SHA-256: `fab2bb09bc5e3b9db0685e8f5500b029af1a2b33e70bc703c1d5d0f596fbc1fd`
来源:[`packages/sandbox/sandbox/src/index.ts:29`](../packages/sandbox/sandbox/src/index.ts)
来源:[`packages/sandbox/sandbox/src/index.ts:30`](../packages/sandbox/sandbox/src/index.ts)
以下类型之一:
+1 -1
View File
@@ -93879,7 +93879,7 @@
"packages/sandbox/sandbox/src/index.ts#SandboxMode"
],
"sources": [
"packages/sandbox/sandbox/src/index.ts:29"
"packages/sandbox/sandbox/src/index.ts:30"
]
},
{
+50 -50
View File
@@ -54,8 +54,8 @@
en: e713a4a11a15d13c
zh: e713a4a11a15d13c
/tool-schema-catalog/deepseek-ai-dsh-tool-ask-user/ask-user-question:
en: 3d5e09e59e7d5274
zh: 624c14da73524315
en: f42b7f17ad59577a
zh: 7e1eee22ff35d4b1
/tool-schema-catalog/deepseek-ai-dsh-tools:
en: 76fbbf0fe38aef10
zh: 76fbbf0fe38aef10
@@ -66,26 +66,26 @@
en: b500b530845e26a9
zh: b500b530845e26a9
/tool-schema-catalog/deepseek-ai-dsh-plan-mode/exit-plan-mode:
en: a4bf2ed3f9f55ff1
zh: ce7b136d3ad3946a
en: b410483c2bd0c1a1
zh: a07ceea6abf1dda3
/tool-schema-catalog/deepseek-ai-dsh-tool-bash:
en: 408ff0a5189bd7ec
zh: 408ff0a5189bd7ec
/tool-schema-catalog/deepseek-ai-dsh-tool-bash/bash:
en: 70add265cf4cfda5
zh: 47c2e23d50557d10
en: 67ee2b8d4997bd1b
zh: 82d1fb17b619c364
/tool-schema-catalog/deepseek-ai-dsh-tool-present:
en: f656575e6df094f6
zh: f656575e6df094f6
/tool-schema-catalog/deepseek-ai-dsh-tool-present/present:
en: 4af0d859e1d39dc5
zh: 780e75b1b42a6410
en: 24ef5efe4c0edf50
zh: a2914d345bd8eefa
/tool-schema-catalog/deepseek-ai-dsh-tool-pwsh:
en: b222fc05f3f8ee4a
zh: b222fc05f3f8ee4a
/tool-schema-catalog/deepseek-ai-dsh-tool-pwsh/pwsh:
en: 775abe9511776e1d
zh: 76ae33dab0dd399a
en: 9d7055b9fb8ba9de
zh: 0647025c36e80237
/tool-schema-catalog/deepseek-ai-dsh-tool-cordis:
en: 76c7c62f9029b6f5
zh: 76c7c62f9029b6f5
@@ -123,8 +123,8 @@
en: 94bfc5b03c63baef
zh: 339338a0e74696c3
/tool-schema-catalog/deepseek-ai-dsh-tool-fs/read-image:
en: 4e82f36d958c9c1c
zh: 1f87182c327bec10
en: f6f7a1140066ce04
zh: d528e01a0939795a
/tool-schema-catalog/deepseek-ai-dsh-tool-fs/write:
en: f80700c5d57e8d87
zh: fb7cccbba0d8d486
@@ -132,11 +132,11 @@
en: 703fea005ce84c50
zh: 703fea005ce84c50
/tool-schema-catalog/deepseek-ai-dsh-tool-fs-search/glob:
en: 418d627507ddd693
zh: 85f14b433c93efbe
en: 3ba734ba7852cdd5
zh: c6ce31ff2c195520
/tool-schema-catalog/deepseek-ai-dsh-tool-fs-search/grep:
en: 224288aaf3fbd214
zh: 9e39dadfaa3768e7
en: 6e1f7da62cc9a35b
zh: ee7f3081887f8067
/tool-schema-catalog/deepseek-ai-dsh-tool-terminal:
en: 77501c1b592b6a88
zh: 77501c1b592b6a88
@@ -162,29 +162,29 @@
en: 74fff3741e7c13de
zh: 74fff3741e7c13de
/tool-schema-catalog/deepseek-ai-dsh-tool-goal/create-goal:
en: b22408124a0a6f34
zh: 7b3bf9ceb5d7f1dc
en: 9ae877cdf8a641a3
zh: f83c3c09e80e4190
/tool-schema-catalog/deepseek-ai-dsh-tool-goal/get-goal:
en: 73ab2b24d4de682d
zh: 82c42a427774c67d
en: 0b517f13dfde11fe
zh: b7ee7482e8ecde56
/tool-schema-catalog/deepseek-ai-dsh-tool-goal/update-goal:
en: a5ef842aa3d1109e
zh: 8f0a9135944e6f1d
en: e6b92fac5aad585c
zh: 6b3cdd87b059a98f
/tool-schema-catalog/deepseek-ai-dsh-schedule:
en: 573da0cf49b1eed6
zh: 573da0cf49b1eed6
/tool-schema-catalog/deepseek-ai-dsh-schedule/schedule-create:
en: 687b784279240ed2
zh: a23fa1b08c15642b
en: 0a10b5db8919d316
zh: 175d680a29f59b63
/tool-schema-catalog/deepseek-ai-dsh-schedule/schedule-delete:
en: 3b5a084ce0d87d13
zh: c1356d4b062f0e63
en: cff096695f06e298
zh: 3d0063a1e529377f
/tool-schema-catalog/deepseek-ai-dsh-schedule/schedule-list:
en: 4422ae2e2a4ca897
zh: 56431645118d6ca0
en: d8543c8503d0947e
zh: 205e6c813ad2b37c
/tool-schema-catalog/deepseek-ai-dsh-schedule/schedule-update:
en: a86e8751a34e9f06
zh: 92a32fd4fc3657b4
en: 3d5e2517b510238b
zh: d6e5c4c4465c9645
/tool-schema-catalog/deepseek-ai-dsh-tool-lsp:
en: 32982123b1f0e5bc
zh: 32982123b1f0e5bc
@@ -201,8 +201,8 @@
en: 927a5ac0974b7642
zh: 927a5ac0974b7642
/tool-schema-catalog/deepseek-ai-dsh-tool-skill/skill:
en: cd716dc11308817d
zh: 08b4ed76101642a2
en: 9b159d5831855afb
zh: 275ee82af0e0f59a
/tool-schema-catalog/deepseek-ai-dsh-tool-session-query:
en: eac1dff4b6d9e195
zh: eac1dff4b6d9e195
@@ -228,32 +228,32 @@
en: c89084b31ffccbce
zh: d7b3b7000f2d2be9
/tool-schema-catalog/deepseek-ai-dsh-tool-subagent/subagent:
en: df17e5091f9cdfc9
zh: 3b31fe9df04242f2
en: 55cbd29d70bcf3d7
zh: 886a1b57446bd5e1
/tool-schema-catalog/deepseek-ai-dsh-tool-subagent-control:
en: ab82e130c3413f04
zh: ab82e130c3413f04
/tool-schema-catalog/deepseek-ai-dsh-tool-subagent-control/interrupt-agent:
en: 3eb5ac28342e354f
zh: 91ca5628d8e333f0
en: 0cec3f2520050839
zh: 06735f086cea84f1
/tool-schema-catalog/deepseek-ai-dsh-tool-subagent-control/list-agents:
en: e688153e9ba4a684
zh: 56c36cb0140c9202
en: ae22e8543d123c39
zh: 4943a5c5980fc2d3
/tool-schema-catalog/deepseek-ai-dsh-tool-subagent-control/send-message:
en: b4d8ffa51c3c9cce
zh: 32d5281b2dec0d81
en: 2ebdc450faef6446
zh: 7e3cff35fedc6a32
/tool-schema-catalog/deepseek-ai-dsh-tool-jobs:
en: 9be673b1604d0114
zh: 9be673b1604d0114
/tool-schema-catalog/deepseek-ai-dsh-tool-jobs/job-kill:
en: 39e53ddd7ea31c2d
zh: 16640c2952c263b6
en: 14294cc1988ae768
zh: 80a9152bdc02723f
/tool-schema-catalog/deepseek-ai-dsh-tool-jobs/job-list:
en: 0c37fb1fed979d72
zh: 5ea9d07f5a4a8a6c
/tool-schema-catalog/deepseek-ai-dsh-tool-jobs/job-output:
en: 2a1d3887688abd05
zh: c1cafbabccc2e021
en: f927cca09b283e21
zh: 2acd9b36737f17c2
/tool-schema-catalog/deepseek-ai-dsh-experimental-tool-agent-team:
en: 6b1106f417b50db6
zh: 6b1106f417b50db6
@@ -288,14 +288,14 @@
en: dea62d26fb18ff68
zh: dea62d26fb18ff68
/tool-schema-catalog/deepseek-ai-dsh-tool-todo/todo-write:
en: 3e63adceedda7304
zh: bc20a7b69ce2cddd
en: 8c64fe5f2e58a890
zh: e3c780fc842aeb43
/tool-schema-catalog/deepseek-ai-dsh-tool-workflow:
en: bb38a9d29f46563c
zh: bb38a9d29f46563c
/tool-schema-catalog/deepseek-ai-dsh-tool-workflow/workflow:
en: 4ca5c5f9ebc188e5
zh: 98bbc45e441019af
en: fdd667cfe21481e8
zh: 88864d3b958398a4
/tool-schema-catalog/deepseek-ai-dsh-tool-workspace-dependencies:
en: 96bffc40b80df4b8
zh: 96bffc40b80df4b8
@@ -309,5 +309,5 @@
en: 8673f203aea9bdf2
zh: 4e17783ffdf34c83
/tool-schema-catalog/deepseek-ai-dsh-tool-web/web-search:
en: 096d6a19c83af773
zh: fa5213caf9401216
en: cc1ad38bf0006710
zh: 324eacb827a09e93
+59 -62
View File
@@ -449,7 +449,7 @@ Source: [`packages/experimental/browser-use-stagehand-native/src/index.ts`](../p
### `ask_user_question`
Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding. Send one or more questions, each with a stable id that will be echoed in the answer.
Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding.
```json
{
@@ -571,7 +571,7 @@ Owned by the tool registry as a reserved transport outside filterable capability
### `exit_plan_mode`
Use only in plan mode. Present your plan for the user's review and, on approval, leave plan mode. Send the COMPLETE plan as markdown, starting with a # heading that names it. The user may approve (carry out the plan from your next step) or keep planning — their feedback comes back in the tool result; revise and present again.
Use only in plan mode. Present your plan for the user's review and, on approval, leave plan mode. The user may approve (carry out the plan from your next step) or keep planning — their feedback comes back in the tool result; revise and present again.
```json
{
@@ -598,7 +598,7 @@ exit_plan_mode stays in the model-facing schema while planning is inactive so tr
### `bash`
Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`. A foreground command that reaches its timeout is not killed: it moves to the background the same way, returning its job id and the output so far.
Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell; pass `workdir` instead of using `cd`. Managed `$DSH_*` variables expose current harness environment facts. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]`, a policy denial: do not retry another way.
```json
{
@@ -642,7 +642,7 @@ The bash tool is the model-facing consumer of the bash executor seam. With a job
### `present`
Declare selected existing files accessible through the Session filesystem as final deliverables. Use present when the user needs a separate file deliverable, especially Office documents, spreadsheets, and slide decks. Prefer showing results in your final response when that is sufficient; creating or editing a file does not by itself require present. Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.
Declare existing files as final deliverables for the user. Use it when the user needs a separate file, especially Office documents, spreadsheets, and slide decks; prefer your final response when that suffices. The user opens the current files; their contents are not copied.
```json
{
@@ -650,6 +650,7 @@ Declare selected existing files accessible through the Session filesystem as fin
"properties": {
"files": {
"type": "array",
"description": "Usually the 1-2 most important deliverables; at most 4 per call.",
"items": {
"type": "object",
"additionalProperties": false,
@@ -685,7 +686,7 @@ Deliveries belong to the calling Session; Web ui-deliverables supplies source-fi
### `pwsh`
Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\...`); read environment variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$env:DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`. A foreground command that reaches its timeout is not killed: it moves to the background the same way, returning its job id and the output so far.
Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. Each call runs in a fresh pwsh process; pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\...`); read environment variables with `$env:NAME`. Managed `$env:DSH_*` variables expose current harness environment facts. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]`, a policy denial: do not retry another way.
```json
{
@@ -958,7 +959,7 @@ Edit an existing UTF-8 text file by replacing literal text.
},
"old_string": {
"type": "string",
"description": "Literal text to replace. Must match exactly."
"description": "Literal text to replace."
},
"new_string": {
"type": "string",
@@ -1010,7 +1011,7 @@ Source: [`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts
### `read_image`
Read a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. Harness validates and downscales large supported images before the next model request, so use this tool directly instead of installing image libraries or creating thumbnails merely to inspect an image. Independent files may be read concurrently in small batches. Requires the current model to accept image input.
Read a PNG/JPEG/WebP/GIF file and return the image itself. Large images are downscaled automatically; do not install image libraries or create thumbnails to inspect an image.
```json
{
@@ -1063,7 +1064,7 @@ The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-observation-p
### `glob`
Find files whose paths match a glob pattern. Returns matching file paths — never directories — including hidden and ignored files (VCS metadata directories are excluded). Up to 100 paths come back in modification-time order; a larger result instead returns 100 paths sampled across top-level entries, says so, and reports where the complete sorted list was saved. This tool does not enumerate directory entries.
Find files, not directories, whose paths match a glob pattern, including hidden and ignored files. Returns up to 100 paths in modification-time order; a larger result is sampled across top-level entries and reports where the complete list was saved.
```json
{
@@ -1088,7 +1089,7 @@ Source: [`packages/fs/tool-fs-search/src/index.ts`](../packages/fs/tool-fs-searc
### `grep`
Search file contents with a ripgrep regular expression. Returns matching lines with line numbers, grouped by file. Returns the first 250 matches inline; a capped result reports where the complete match list was saved. Use read on a matched file for surrounding context.
Search file contents with a ripgrep regular expression. Returns matching lines with line numbers, grouped by file. Returns up to 250 matches; a larger result reports where the complete match list was saved.
```json
{
@@ -1288,7 +1289,7 @@ The six terminal tools are opt-in and complement one-shot shell/filesystem tools
### `create_goal`
Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say "create a goal". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.
Create a persisted goal that keeps this session working across automatic continuation rounds. Use it when the direct human request is a long-running objective, even if the user did not say "goal"; not for single-turn work.
```json
{
@@ -1313,7 +1314,7 @@ Source: [`packages/goal/tool-goal/src/index.ts`](../packages/goal/tool-goal/src/
### `get_goal`
Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.
Read the current session goal, including the id and revision that update_goal requires.
```json
{
@@ -1326,7 +1327,7 @@ Source: [`packages/goal/tool-goal/src/index.ts`](../packages/goal/tool-goal/src/
### `update_goal`
Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.
Update the current goal.
```json
{
@@ -1342,7 +1343,7 @@ Update the exact current goal revision. edit, pause, and resume require a direct
},
"action": {
"type": "string",
"description": "edit | pause | resume | complete | blocked",
"description": "edit, pause, and resume require a direct top-level human request. complete and blocked are also allowed during an automatic continuation of this goal; blocked is rejected before the configured minimum round count.",
"enum": [
"edit",
"pause",
@@ -1361,7 +1362,7 @@ Update the exact current goal revision. edit, pause, and resume require a direct
},
"blocked_reason": {
"type": "string",
"description": "Concrete blocking condition; required only with action blocked."
"description": "Required only with action blocked: the concrete condition that persisted across rounds and blocks progress."
}
},
"required": [
@@ -1382,7 +1383,7 @@ create, edit, pause, and resume require direct-human root authority; complete an
### `schedule_create`
Create one reminder in the current session. Supply a non-empty prompt, a title, and exactly one selector: a positive safe-integer after_seconds delay, at as a strict offset date-time or local date/time object, safe-integer every_seconds of at least 60, daily as {time: "23:00:00", time_zone: "Asia/Shanghai"}, weekly as {time: "09:00:00", time_zone: "Asia/Shanghai", weekdays: [1, 3]} with Monday 1 through Sunday 7, or cron as {expression: "*/15 9-17 * * 1-5", time_zone: "Asia/Shanghai"} with the five fields minute hour day-of-month month day-of-week. Every creation requires a title of at most 120 characters, non-empty after trimming; it names the task on its card, its detail heading, and in the task lists. Daily, weekly, and cron reminders retain that local time and zone; missing wall-clock times skip the date and repeated times use only the earlier instant. A cron day-of-month and day-of-week pair matches when either field matches once both are restricted. Fixed-rate targets stay creation-aligned until an interval edit establishes a new anchor. All four recurring kinds batch one latest occurrence per overdue rule. The Host restores this session when a reminder is due. After downtime, each recurring reminder delivers its latest missed occurrence once. Delivery can repeat after a crash.
Create a reminder in the current session that delivers prompt when it becomes due. Supply exactly one timing parameter: after_seconds, at, every_seconds, daily, weekly, or cron. Local times that do not exist in the zone are skipped; repeated local times fire once, at the earlier instant. After downtime, a recurring reminder delivers only its latest missed occurrence. Delivery can repeat after a crash.
```json
{
@@ -1394,19 +1395,19 @@ Create one reminder in the current session. Supply a non-empty prompt, a title,
},
"title": {
"type": "string",
"description": "Required task name of at most 120 characters, non-empty after trimming; it becomes the task card title, the detail heading, and the name in the task lists."
"description": "Task name of at most 120 characters, shown on the task card and in task lists."
},
"after_seconds": {
"type": "number",
"description": "Positive safe-integer delay in seconds."
"description": "Delay in whole seconds."
},
"every_seconds": {
"type": "number",
"description": "Fixed-rate safe-integer interval in seconds, at least 60."
"description": "Fixed-rate interval in whole seconds, at least 60, aligned to the creation time; changing it with schedule_update re-aligns it to the save time."
},
"daily": {
"type": "object",
"description": "Daily local wall-clock time in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once.",
"description": "Every day at a local time.",
"additionalProperties": false,
"properties": {
"time": {
@@ -1425,7 +1426,7 @@ Create one reminder in the current session. Supply a non-empty prompt, a title,
},
"weekly": {
"type": "object",
"description": "Weekly local wall-clock time on explicit ISO weekdays in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once per date.",
"description": "On the given weekdays at a local time.",
"additionalProperties": false,
"properties": {
"time": {
@@ -1438,7 +1439,7 @@ Create one reminder in the current session. Supply a non-empty prompt, a title,
},
"weekdays": {
"type": "array",
"description": "Non-empty ISO weekdays, Monday 1 through Sunday 7, without repetitions.",
"description": "ISO weekdays, Monday 1 through Sunday 7, without repetitions.",
"items": {
"type": "integer"
}
@@ -1452,12 +1453,12 @@ Create one reminder in the current session. Supply a non-empty prompt, a title,
},
"cron": {
"type": "object",
"description": "Five-field Vixie cron expression evaluated in an explicit IANA zone; skips nonexistent local times and uses the earlier repeated time once per date.",
"description": "Five-field Vixie cron expression in a time zone.",
"additionalProperties": false,
"properties": {
"expression": {
"type": "string",
"description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\"."
"description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\". When both day fields are restricted, a date matches if either one matches."
},
"time_zone": {
"type": "string",
@@ -1495,7 +1496,7 @@ Create one reminder in the current session. Supply a non-empty prompt, a title,
]
}
],
"description": "Absolute target as strict offset RFC 3339 or local date/time with an explicit IANA zone."
"description": "Absolute target: an RFC 3339 date-time with offset, or a local date, time, and IANA time_zone."
}
},
"required": [
@@ -1509,7 +1510,7 @@ Source: [`packages/schedule/schedule/src/tools.ts`](../packages/schedule/schedul
### `schedule_delete`
Delete one retained reminder in the current session by its exact id, whether active or inactive. Unknown or already-deleted ids return deleted false. Deletion does not retract a queued message.
Delete a reminder in the current session, active or inactive. Deletion does not retract a reminder message that is already queued.
```json
{
@@ -1517,7 +1518,7 @@ Delete one retained reminder in the current session by its exact id, whether act
"properties": {
"id": {
"type": "string",
"description": "Exact schedule id."
"description": "Schedule id returned by schedule_list."
}
},
"required": [
@@ -1530,7 +1531,7 @@ Source: [`packages/schedule/schedule/src/tools.ts`](../packages/schedule/schedul
### `schedule_list`
List every active reminder in the current session, including its exact id, title, UTC target, scheduled or overdue state, and host delivery mode. The returned order is not significant.
List the active reminders in the current session.
```json
{
@@ -1543,7 +1544,7 @@ Source: [`packages/schedule/schedule/src/tools.ts`](../packages/schedule/schedul
### `schedule_update`
Change one reminder in the current session in place, keeping its id and its saved delivery records: address it by the exact id schedule_list returned, then supply a new title or prompt, or exactly one new selector from at, every_seconds, daily, weekly, or cron in the same forms schedule_create accepts. An omitted field keeps its stored value. After is not updatable: create a new reminder for a relative delay. The Host compares the record it finds for that id with the stored one, so a concurrent edit returns schedule_conflict instead of overwriting it; an inactive or unknown reminder returns updated false. Editing an every_seconds interval anchors the new fixed rate at the accepted save time; a name or instruction change alone keeps the committed target.
Change a reminder in place, keeping its id. Supply a new title, prompt, or at most one timing parameter; omitted fields keep their stored values. To change a relative delay, create a new reminder.
```json
{
@@ -1551,23 +1552,23 @@ Change one reminder in the current session in place, keeping its id and its save
"properties": {
"id": {
"type": "string",
"description": "Exact schedule id that schedule_list returned for one reminder."
"description": "Schedule id returned by schedule_list."
},
"title": {
"type": "string",
"description": "New task name of at most 120 characters, non-empty after trimming; omitted keeps the stored name."
"description": "New task name of at most 120 characters."
},
"prompt": {
"type": "string",
"description": "New reminder content, non-empty after trimming; omitted keeps the stored instruction."
"description": "New reminder content."
},
"every_seconds": {
"type": "number",
"description": "Fixed-rate safe-integer interval in seconds, at least 60."
"description": "Fixed-rate interval in whole seconds, at least 60, aligned to the creation time; changing it with schedule_update re-aligns it to the save time."
},
"daily": {
"type": "object",
"description": "Daily local wall-clock time in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once.",
"description": "Every day at a local time.",
"additionalProperties": false,
"properties": {
"time": {
@@ -1586,7 +1587,7 @@ Change one reminder in the current session in place, keeping its id and its save
},
"weekly": {
"type": "object",
"description": "Weekly local wall-clock time on explicit ISO weekdays in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once per date.",
"description": "On the given weekdays at a local time.",
"additionalProperties": false,
"properties": {
"time": {
@@ -1599,7 +1600,7 @@ Change one reminder in the current session in place, keeping its id and its save
},
"weekdays": {
"type": "array",
"description": "Non-empty ISO weekdays, Monday 1 through Sunday 7, without repetitions.",
"description": "ISO weekdays, Monday 1 through Sunday 7, without repetitions.",
"items": {
"type": "integer"
}
@@ -1613,12 +1614,12 @@ Change one reminder in the current session in place, keeping its id and its save
},
"cron": {
"type": "object",
"description": "Five-field Vixie cron expression evaluated in an explicit IANA zone; skips nonexistent local times and uses the earlier repeated time once per date.",
"description": "Five-field Vixie cron expression in a time zone.",
"additionalProperties": false,
"properties": {
"expression": {
"type": "string",
"description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\"."
"description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\". When both day fields are restricted, a date matches if either one matches."
},
"time_zone": {
"type": "string",
@@ -1656,7 +1657,7 @@ Change one reminder in the current session in place, keeping its id and its save
]
}
],
"description": "Absolute target as strict offset RFC 3339 or local date/time with an explicit IANA zone."
"description": "Absolute target: an RFC 3339 date-time with offset, or a local date, time, and IANA time_zone."
}
},
"required": [
@@ -1754,7 +1755,7 @@ A fixed foreground workflow starts one fresh structured child per round; the mod
### `skill`
Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.
Load the full instructions for a skill. Call it before acting on a task that names or clearly matches a skill in the session skill catalog.
```json
{
@@ -2036,7 +2037,7 @@ Source: [`packages/subagent/tool-subagent/src/list-models.ts`](../packages/subag
### `subagent`
Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`.
Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. This call waits for the result by default.
```json
{
@@ -2052,7 +2053,7 @@ Delegate a self-contained task to a subagent (a separate agent that works in its
},
"run_in_background": {
"type": "boolean",
"description": "Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill."
"description": "Run as a background job and return its id (collect with job_output, stop with job_kill). Defaults to false."
}
},
"required": [
@@ -2072,7 +2073,7 @@ The registered delegation name is the load-time `toolName` config (default `suba
### `interrupt_agent`
Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.
Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a direct child's conversation later with send_message. Subagents it started will keep running.
```json
{
@@ -2080,7 +2081,7 @@ Request cancellation of a background agent's current turn by its agent id. The t
"properties": {
"agent_id": {
"type": "string",
"description": "The agent id of the running agent to interrupt."
"description": "The id of an agent created under you: your direct child or a deeper descendant."
}
},
"required": [
@@ -2093,7 +2094,7 @@ Source: [`packages/subagent/tool-subagent-control/src/index.ts`](../packages/sub
### `list_agents`
List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics only in `descendants` scope. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.
List subagents you started, with their ids, labels, and status. running means it is working; inactive means it is not currently working. You will be notified when a subagent finishes; there is no need to keep checking its status. Use send_message to continue the conversation.
```json
{
@@ -2101,7 +2102,7 @@ List your continuable background subagents by durable id and label. Use it to re
"properties": {
"scope": {
"type": "string",
"description": "children (default) lists direct children only; descendants walks the complete tree below you.",
"description": "children (default) lists direct children, which accept send_message in any status. descendants lists the whole tree below you with each entry's parent session id and depth; entries deeper than 1 accept only interrupt_agent.",
"enum": [
"children",
"descendants"
@@ -2115,7 +2116,7 @@ Source: [`packages/subagent/tool-subagent-control/src/list-agents.ts`](../packag
### `send_message`
Send a message to a direct continuable child by its agent id. If you are a resident continuable child, you may also target your direct parent. If the target is still working, the message steers its nearest step; if it is inactive, the message starts or resumes a turn. This call returns no answer from the agent — only confirmation that the message was delivered. A failure means the message was NOT delivered.
Send a message to an agent. A working agent receives it at its next step; an idle agent starts a new turn with it. Returns delivery confirmation, not the agent's answer.
```json
{
@@ -2147,7 +2148,7 @@ The globally named control tools over continuable background subagents: provider
### `job_kill`
Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.
Request cancellation of a running background job.
```json
{
@@ -2185,7 +2186,7 @@ Source: [`packages/jobs/tool-jobs/src/index.ts`](../packages/jobs/tool-jobs/src/
### `job_output`
Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.
Read a background job: output since the previous read for stream jobs, or the result of a finished final-output job.
```json
{
@@ -2197,11 +2198,11 @@ Read a background job. Stream jobs return only output since the previous read; f
},
"wait": {
"type": "boolean",
"description": "Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."
"description": "Block until the job finishes or the timeout expires; a timed-out wait leaves the job running. Defaults to false."
},
"timeout_ms": {
"type": "number",
"description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."
"description": "Max wait in milliseconds with wait: true. Defaults to and is capped by configuration."
}
},
"required": [
@@ -2510,7 +2511,7 @@ All nine tools are scoped to implicit Team Leads and durable teammates. The ship
### `todo_write`
Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).
Record and update a task list to plan multi-step work and show progress; skip it for trivial single-step tasks. Add one todo per concrete step before you start. While work remains, keep the todos being worked on `in_progress`, several only when work runs in parallel. Mark each todo `completed` as soon as it is done.
```json
{
@@ -2562,17 +2563,13 @@ todo_write is session-owned state; UIs render the latest todo/write event as a c
Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.
The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result.
Script-body hooks:
- `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.
- `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.
- `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides.
- `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. A stage throw drops that ITEM to `null` and skips its remaining stages.
- `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.
- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.
Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.
Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground by default: this call returns when the whole script finishes. Set `run_in_background: true` for a long run: the call returns a job id immediately, the run keeps orchestrating in the background, and its return value arrives with the job's completion notice (check on it with `job_output`, stop it with `job_kill`).
Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) end the whole script instead of producing `null`. The script has no filesystem, network, timer, or Node.js APIs; the agents do the work.
```json
{
@@ -2580,11 +2577,11 @@ Constraints: concurrency and total-agent caps apply; no filesystem, network, tim
"properties": {
"script": {
"type": "string",
"description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`)."
"description": "The plain JavaScript body, not TypeScript and without an `export const meta` statement; top-level await is allowed. End with `return <value>`; the JSON-serializable value is this tool's result."
},
"meta": {
"type": "object",
"description": "The workflow identity block (plain JSON — never code).",
"description": "The workflow identity as plain JSON, not code.",
"additionalProperties": true,
"properties": {
"name": {
@@ -2697,7 +2694,7 @@ Source: [`packages/web/tool-web/src/index.ts`](../packages/web/tool-web/src/inde
### `web_search`
Search the web for current information. Provide 1–4 queries in the required queries array. Returns an optional summary answer and a list of source URLs.
Search the web for current information. Returns an optional summary answer and a list of source URLs.
```json
{
@@ -2705,7 +2702,7 @@ Search the web for current information. Provide 1–4 queries in the required qu
"properties": {
"queries": {
"type": "array",
"description": "Required search queries; accepts 1–4 items and merges their results.",
"description": "1–4 search queries; their results are merged.",
"items": {
"type": "string"
}
+59 -62
View File
@@ -453,7 +453,7 @@
### `ask_user_question`
继续操作前,如果需要确认、选择或缺失的信息,请向用户提出简明问题。发送一个或多个问题,每个问题都带一个稳定 id,该 id 会在答案中原样返回。
继续操作前,如果需要确认、选择或缺失的信息,请向用户提出简明问题。
```json
{
@@ -575,7 +575,7 @@ ask_user_question 会暂停工具调用,直到当前 UI 提供方返回人类
### `exit_plan_mode`
仅在规划模式下使用。提交计划供用户评审,并在获批后退出规划模式。发送**完整的** Markdown 计划,以一个为计划命名的 # 标题开头。用户可以批准(从你的下一步骤起执行计划),也可以要求继续规划;其反馈会通过工具结果返回,请修改后再次提交。
仅在规划模式下使用。提交计划供用户评审,并在获批后退出规划模式。用户可以批准(从你的下一步骤起执行计划),也可以要求继续规划;其反馈会通过工具结果返回,请修改后再次提交。
```json
{
@@ -602,7 +602,7 @@ ask_user_question 会暂停工具调用,直到当前 UI 提供方返回人类
### `bash`
执行 bash 命令(`bash -c`)并返回 stdout/stderr。每次调用都在新 shell 中运行:调用之间不保留任何状态(cwd、变量、函数),请传入 `workdir`,不要使用 `cd`。非零退出会报告为 `[exit code: N]`。当前 harness 环境信息通过托管的 `$DSH_*` 变量公开,需要时请检查这些变量。命令可能在文件沙箱中运行;被阻止的文件操作报告为 `[sandbox: file access denied under <mode> mode]`,这是策略拒绝,而不是命令缺陷,请勿换一种方式重试。较长的输出会截断,只保留尾部;如可用,完整输出会保存到文件并报告其路径。对于长时间运行的命令,请设置 `run_in_background: true`:调用会立即返回 job id;使用 `job_output` 读取输出,使用 `job_kill` 停止任务。到达超时的前台命令不会被杀:它以同样的方式转入后台,返回其 job id 与已捕获的输出。
执行 bash 命令(`bash -c`)并返回 stdout/stderr。每次调用都在新 shell 中运行;请传入 `workdir`,不要使用 `cd`。托管的 `$DSH_*` 变量公开当前 harness 环境信息。较长的输出会截断,只保留尾部;如可用,完整输出会保存到文件并报告其路径。命令可能在文件沙箱中运行;被阻止的文件操作报告为 `[sandbox: file access denied under <mode> mode]`,这是策略拒绝:请勿换一种方式重试。
```json
{
@@ -646,7 +646,7 @@ bash 工具是 bash 执行器 seam 面向模型的消费方。组合中有 job
### `present`
选择 Session 文件系统可访问的已有文件,声明为最终交付物。当用户需要独立的文件交付物时使用 present,尤其是 Office 文档、电子表格和演示文稿。如果最终回复已经足够展示结果,优先在回复中展示;创建或编辑文件本身不要求调用 present。通常选择最重要的 1 至 2 项交付物,任务需要时可以更多,但一次 present 调用最多 4 个文件。文件必须已存在。用户打开当前源文件;不复制或保存其内容。
将已有文件声明为交付给用户的最终交付物。当用户需要独立文件时使用,尤其是 Office 文档、电子表格和演示文稿;如果最终回复已经足够,优先使用最终回复。用户打开的是当前文件;不复制其内容。
```json
{
@@ -654,6 +654,7 @@ bash 工具是 bash 执行器 seam 面向模型的消费方。组合中有 job
"properties": {
"files": {
"type": "array",
"description": "Usually the 1-2 most important deliverables; at most 4 per call.",
"items": {
"type": "object",
"additionalProperties": false,
@@ -689,7 +690,7 @@ bash 工具是 bash 执行器 seam 面向模型的消费方。组合中有 job
### `pwsh`
执行 PowerShell 命令(`pwsh -Command`)并返回 stdout/stderr。每次调用都在新的 pwsh 进程中运行:调用之间不保留任何状态(cwd、变量、函数),请传入 `workdir`,不要使用 `cd`。路径采用 Windows 原生形式(`C:\...`);使用 `$env:NAME` 读取环境变量。非零退出会报告为 `[exit code: N]`。当前 harness 环境信息通过托管的 `$env:DSH_*` 变量公开,需要时请检查这些变量。命令可能在文件沙箱中运行;被阻止的文件操作报告为 `[sandbox: file access denied under <mode> mode]`,这是策略拒绝,而不是命令缺陷,请勿换一种方式重试。较长的输出会截断,只保留尾部;如可用,完整输出会保存到文件并报告其路径。在 Windows 上,被强制终止的命令会以 `[exit code: 1]` 结算且不带信号标记,请将其视为中断,而不是命令失败。对于长时间运行的命令,请设置 `run_in_background: true`:调用会立即返回 job id;使用 `job_output` 读取输出,使用 `job_kill` 停止任务。到达超时的前台命令不会被杀:它以同样的方式转入后台,返回其 job id 与已捕获的输出。
执行 PowerShell 命令(`pwsh -Command`)并返回 stdout/stderr。每次调用都在新的 pwsh 进程中运行;请传入 `workdir`,不要使用 `cd`。路径采用 Windows 原生形式(`C:\...`);使用 `$env:NAME` 读取环境变量。托管的 `$env:DSH_*` 变量公开当前 harness 环境信息。较长的输出会截断,只保留尾部;如可用,完整输出会保存到文件并报告其路径。在 Windows 上,被强制终止的命令会以 `[exit code: 1]` 结算且不带信号标记,请将其视为中断,而不是命令失败。命令可能在文件沙箱中运行;被阻止的文件操作报告为 `[sandbox: file access denied under <mode> mode]`,这是策略拒绝:请勿换一种方式重试。
```json
{
@@ -964,7 +965,7 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费
},
"old_string": {
"type": "string",
"description": "Literal text to replace. Must match exactly."
"description": "Literal text to replace."
},
"new_string": {
"type": "string",
@@ -1016,7 +1017,7 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费
### `read_image`
读取 PNG/JPEG/WebP/GIF 文件并返回图像本身。无扩展名的路径同样被接受;格式按文件内容检测,因此规范化附件路径可以直接传入,无需复制或重命名。Harness 会在下一次模型请求前校验并缩小受支持的大图,因此仅为查看图片时应直接使用此工具,无需安装图片库或创建缩略图。可以用小批次并发读取彼此独立的文件。要求当前模型接受图像输入。
读取 PNG/JPEG/WebP/GIF 文件并返回图像本身。大图会自动缩小;不要为了查看图片而安装图片库或创建缩略图。
```json
{
@@ -1069,7 +1070,7 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费
### `glob`
查找路径匹配 glob 模式的文件。只返回匹配的文件路径,绝不返回目录;包括隐藏文件和被忽略的文件,但排除 VCS 元数据目录。最多按修改时间顺序返回 100 条路径;如果结果更多,则改为返回从顶层条目中抽样的 100 条路径,说明已抽样,并报告完整排序列表的保存位置。该工具不枚举目录条目。
查找路径匹配 glob 模式的文件(不含目录),包括隐藏文件和被忽略的文件。最多按修改时间顺序返回 100 条路径;更大的结果会从顶层条目中抽样,并报告完整列表的保存位置。
```json
{
@@ -1094,7 +1095,7 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费
### `grep`
使用 ripgrep 正则表达式搜索文件内容。返回带行号的匹配行,并按文件分组。前 250 条匹配会直接返回;结果达到上限时会报告完整匹配列表的保存位置。如需周边上下文,请对匹配的文件使用 read。
使用 ripgrep 正则表达式搜索文件内容。返回带行号的匹配行,并按文件分组。最多返回 250 条匹配;更大的结果会报告完整匹配列表的保存位置。
```json
{
@@ -1294,7 +1295,7 @@ glob 和 grep 是无条件可用的发现工具,通过 ctx.subprocess spawn
### `create_goal`
当当前直接人类请求是需要跨自主 Goal Round 持续推进的长期目标时,创建一个持久化的同会话完成目标。即使用户没有明确说「创建目标」,你也可以推断其意图。不要用于简单的单轮工作。执行时会拒绝非人类权限和 subagent 权限。
创建一个持久化目标,使当前会话跨自动延续 Round 持续工作。当直接人类请求是长期目标时使用,即使用户没有说「目标」;不要用于单轮工作。
```json
{
@@ -1319,7 +1320,7 @@ glob 和 grep 是无条件可用的发现工具,通过 ctx.subprocess spawn
### `get_goal`
读取当前的同会话目标,包括确切的 id/revision、目标、阶段、已完成的延续 Round 数、Round 上限、存在时的阻塞原因,以及是否已准备下一次延续。更新目标前请先调用此工具。
读取当前会话目标,包括 update_goal 所需的 id 和 revision。
```json
{
@@ -1332,7 +1333,7 @@ glob 和 grep 是无条件可用的发现工具,通过 ctx.subprocess spawn
### `update_goal`
更新确切的当前目标 revision。edit、pause 和 resume 要求直接的顶层人类请求。在自动延续当前目标期间,也允许 complete 和 blocked。在达到配置的最小 Round 数之前会拒绝 blocked;模型仍须判断相同条件是否在这些 Round 中持续存在,并在 blocked_reason 中予以说明。
更新当前目标。
```json
{
@@ -1348,7 +1349,7 @@ glob 和 grep 是无条件可用的发现工具,通过 ctx.subprocess spawn
},
"action": {
"type": "string",
"description": "edit | pause | resume | complete | blocked",
"description": "edit, pause, and resume require a direct top-level human request. complete and blocked are also allowed during an automatic continuation of this goal; blocked is rejected before the configured minimum round count.",
"enum": [
"edit",
"pause",
@@ -1367,7 +1368,7 @@ glob 和 grep 是无条件可用的发现工具,通过 ctx.subprocess spawn
},
"blocked_reason": {
"type": "string",
"description": "Concrete blocking condition; required only with action blocked."
"description": "Required only with action blocked: the concrete condition that persisted across rounds and blocks progress."
}
},
"required": [
@@ -1388,7 +1389,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
### `schedule_create`
在当前会话中创建一条提醒。请提供非空 prompt、标题,以及恰好一个 selector:正的安全整数 after_seconds 延时;作为严格带偏移日期时间或本地日期/时间对象的 at;不小于 60 的安全整数 every_seconds;形如 {time: "23:00:00", time_zone: "Asia/Shanghai"} 的 daily;形如 {time: "09:00:00", time_zone: "Asia/Shanghai", weekdays: [1, 3]} 的 weekly,其中周一为 1、周日为 7;或形如 {expression: "*/15 9-17 * * 1-5", time_zone: "Asia/Shanghai"} 的 cron,其五个字段为 minute hour day-of-month month day-of-week。每次创建都必须提供标题,最多 120 个字符且去除首尾空白后非空;该标题用于任务卡片、详情标题和任务列表。每日、每周与 cron 提醒保留指定的本地时间与时区;不存在的钟表时间会跳过该日期,重复时刻只取较早实例。cron 的 day-of-month 与 day-of-week 都受限时,匹配任一字段即算匹配。固定速率提醒在间隔编辑确立新起点前始终与创建时刻对齐。四类重复提醒都会将每条逾期规则的最新一个发生时点合并为一批投递。宿主会在提醒到期时恢复此会话。停机后,每条重复提醒只投递最新错过的一次。崩溃后可能重复投递。
在当前会话中创建一条提醒,到期时投递 prompt。请恰好提供一个时间参数:after_seconds、at、every_seconds、daily、weekly 或 cron。时区中不存在的本地时间会被跳过;重复出现的本地时间只在较早的时刻触发一次。停机后,重复提醒只投递最近错过的一次。崩溃后可能重复投递。
```json
{
@@ -1400,19 +1401,19 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
},
"title": {
"type": "string",
"description": "Required task name of at most 120 characters, non-empty after trimming; it becomes the task card title, the detail heading, and the name in the task lists."
"description": "Task name of at most 120 characters, shown on the task card and in task lists."
},
"after_seconds": {
"type": "number",
"description": "Positive safe-integer delay in seconds."
"description": "Delay in whole seconds."
},
"every_seconds": {
"type": "number",
"description": "Fixed-rate safe-integer interval in seconds, at least 60."
"description": "Fixed-rate interval in whole seconds, at least 60, aligned to the creation time; changing it with schedule_update re-aligns it to the save time."
},
"daily": {
"type": "object",
"description": "Daily local wall-clock time in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once.",
"description": "Every day at a local time.",
"additionalProperties": false,
"properties": {
"time": {
@@ -1431,7 +1432,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
},
"weekly": {
"type": "object",
"description": "Weekly local wall-clock time on explicit ISO weekdays in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once per date.",
"description": "On the given weekdays at a local time.",
"additionalProperties": false,
"properties": {
"time": {
@@ -1444,7 +1445,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
},
"weekdays": {
"type": "array",
"description": "Non-empty ISO weekdays, Monday 1 through Sunday 7, without repetitions.",
"description": "ISO weekdays, Monday 1 through Sunday 7, without repetitions.",
"items": {
"type": "integer"
}
@@ -1458,12 +1459,12 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
},
"cron": {
"type": "object",
"description": "Five-field Vixie cron expression evaluated in an explicit IANA zone; skips nonexistent local times and uses the earlier repeated time once per date.",
"description": "Five-field Vixie cron expression in a time zone.",
"additionalProperties": false,
"properties": {
"expression": {
"type": "string",
"description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\"."
"description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\". When both day fields are restricted, a date matches if either one matches."
},
"time_zone": {
"type": "string",
@@ -1501,7 +1502,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
]
}
],
"description": "Absolute target as strict offset RFC 3339 or local date/time with an explicit IANA zone."
"description": "Absolute target: an RFC 3339 date-time with offset, or a local date, time, and IANA time_zone."
}
},
"required": [
@@ -1515,7 +1516,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
### `schedule_delete`
使用确切 id 删除当前会话中保留的一条提醒,活动或未运行的提醒均可删除。未知或已经删除的 id 会返回 deleted false。删除不会撤回已经入队的消息。
删除当前会话中的一条提醒,活动或已结束的均可。删除不会撤回已经入队的提醒消息。
```json
{
@@ -1523,7 +1524,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
"properties": {
"id": {
"type": "string",
"description": "Exact schedule id."
"description": "Schedule id returned by schedule_list."
}
},
"required": [
@@ -1536,7 +1537,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
### `schedule_list`
列出当前会话中的所有活动提醒,包括确切 id、标题、UTC 目标、scheduled 或 overdue 状态,以及 host 交付模式。返回顺序无关紧要。
列出当前会话中的活动提醒。
```json
{
@@ -1553,7 +1554,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
### `schedule_update`
在当前会话中原地修改一条提醒,保留其 id 与已保存的投递记录:用 `schedule_list` 返回的精确 id 定位,然后给出新的 title 或 prompt,或从 at、every_seconds、daily、weekly、cron 中给出恰好一个新选择器(形式与 `schedule_create` 相同)。未提供的字段保持原值。`after` 不支持更新;需要相对延迟时请新建一条提醒。宿主会把它为该 id 读到的记录与已存储记录比对,因此并发编辑返回 `schedule_conflict`,而不是覆盖已存储的修改;已结束或不存在的提醒返回 `updated: false`。修改 `every_seconds` 间隔会把新的固定速率锚定在接受保存的时刻;只改名称或指令则保留已提交的目标。
原地修改一条提醒并保留其 id。提供新的 title、prompt,或至多一个时间参数;未提供的字段保持原值。需要相对延迟时请新建一条提醒。
```json
{
@@ -1561,23 +1562,23 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
"properties": {
"id": {
"type": "string",
"description": "Exact schedule id that schedule_list returned for one reminder."
"description": "Schedule id returned by schedule_list."
},
"title": {
"type": "string",
"description": "New task name of at most 120 characters, non-empty after trimming; omitted keeps the stored name."
"description": "New task name of at most 120 characters."
},
"prompt": {
"type": "string",
"description": "New reminder content, non-empty after trimming; omitted keeps the stored instruction."
"description": "New reminder content."
},
"every_seconds": {
"type": "number",
"description": "Fixed-rate safe-integer interval in seconds, at least 60."
"description": "Fixed-rate interval in whole seconds, at least 60, aligned to the creation time; changing it with schedule_update re-aligns it to the save time."
},
"daily": {
"type": "object",
"description": "Daily local wall-clock time in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once.",
"description": "Every day at a local time.",
"additionalProperties": false,
"properties": {
"time": {
@@ -1596,7 +1597,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
},
"weekly": {
"type": "object",
"description": "Weekly local wall-clock time on explicit ISO weekdays in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once per date.",
"description": "On the given weekdays at a local time.",
"additionalProperties": false,
"properties": {
"time": {
@@ -1609,7 +1610,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
},
"weekdays": {
"type": "array",
"description": "Non-empty ISO weekdays, Monday 1 through Sunday 7, without repetitions.",
"description": "ISO weekdays, Monday 1 through Sunday 7, without repetitions.",
"items": {
"type": "integer"
}
@@ -1623,12 +1624,12 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
},
"cron": {
"type": "object",
"description": "Five-field Vixie cron expression evaluated in an explicit IANA zone; skips nonexistent local times and uses the earlier repeated time once per date.",
"description": "Five-field Vixie cron expression in a time zone.",
"additionalProperties": false,
"properties": {
"expression": {
"type": "string",
"description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\"."
"description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\". When both day fields are restricted, a date matches if either one matches."
},
"time_zone": {
"type": "string",
@@ -1666,7 +1667,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
]
}
],
"description": "Absolute target as strict offset RFC 3339 or local date/time with an explicit IANA zone."
"description": "Absolute target: an RFC 3339 date-time with offset, or a local date, time, and IANA time_zone."
}
},
"required": [
@@ -1764,7 +1765,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
### `skill`
加载可用 skill(技能)的完整说明。在执行点名某项 skill 或与其明确匹配的任务前,请使用会话 skill 目录中的确切名称调用此工具。
加载某项 skill(技能)的完整说明。在执行点名某项 skill 或与会话 skill 目录中某项 skill 明确匹配的任务前,请调用此工具。
```json
{
@@ -2046,7 +2047,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
### `subagent`
将一项自包含任务委派给 subagent(在自身上下文中工作的独立 agent),用它卸载聚焦且独立的工作,例如研究、限定范围的实现或分析,以免消耗当前对话的上下文。subagent 会返回结果,但不会返回中间步骤。请提供完整、独立的提示词,因为它看不到当前对话。此调用默认等待结果。设置 `run_in_background: true` 可返回 job id;使用 `job_output` 收集结果,使用 `job_kill` 停止任务。
将一项自包含任务委派给 subagent(在自身上下文中工作的独立 agent),用它卸载聚焦且独立的工作,例如研究、限定范围的实现或分析,以免消耗当前对话的上下文。subagent 会返回结果,但不会返回中间步骤。此调用默认等待结果。
```json
{
@@ -2062,7 +2063,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
},
"run_in_background": {
"type": "boolean",
"description": "Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill."
"description": "Run as a background job and return its id (collect with job_output, stop with job_kill). Defaults to false."
}
},
"required": [
@@ -2082,7 +2083,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
### `interrupt_agent`
根据 agent id 请求取消后台 agent 的当前轮次。目标可以是你的直接子级,也可以是在你下方创建的更深层 agent。只有当前轮次会停止:已经排队发给该 agent 的消息会一直搁置到后续的 send_message;它启动的 agent 会继续运行;该 agent 本身仍可接受后续操作。停止请求被接受后,此调用立即返回,因此目标可能还会短暂运行;中断一个已经完成的 agent 是可接受的空操作。
请 subagent 停止当前工作。此调用不等待其停止即返回。之后可以用 send_message 继续与直接子级的对话。它启动的 subagent 会继续运行。
```json
{
@@ -2090,7 +2091,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
"properties": {
"agent_id": {
"type": "string",
"description": "The agent id of the running agent to interrupt."
"description": "The id of an agent created under you: your direct child or a deeper descendant."
}
},
"required": [
@@ -2103,7 +2104,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
### `list_agents`
按持久 id 和标签列出你的可继续后台 subagent。用它回忆你启动过哪些 subagent,而不是轮询完成情况——subagent 完成时你会被告知。状态来自实时注册表:running 表示 agent 此刻正在工作;inactive 表示没有轮次在执行,包括已加载和需要恢复的 child。inactive 不表示任务完成、成功、失败或等待其他 agent。`send_message` 会在运行中 child 的最近 step 边界 steer 消息,或为 inactive child 启动或恢复轮次,且无论处于哪种状态,直接子级都仍可作为 `send_message` 的目标。该快照并非投递承诺;`send_message` 会执行权威检查,仍可能失败。无法读取的子级仅在 `descendants` 作用域中作为诊断信息报告。`descendants` 作用域会按稳定的前序顺序遍历你下方的整棵树,并为每个条目标注其持久的直接父会话 id 和深度。只有深度为 1 的条目可以使用 `send_message`;更深的条目只能作为 `interrupt_agent` 的候选目标。
列出你启动的 subagent 及其 id、标签和状态。running 表示正在工作;inactive 表示当前未在工作。subagent 完成时你会收到通知,无需反复查看状态。使用 send_message 继续对话。
```json
{
@@ -2111,7 +2112,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
"properties": {
"scope": {
"type": "string",
"description": "children (default) lists direct children only; descendants walks the complete tree below you.",
"description": "children (default) lists direct children, which accept send_message in any status. descendants lists the whole tree below you with each entry's parent session id and depth; entries deeper than 1 accept only interrupt_agent.",
"enum": [
"children",
"descendants"
@@ -2125,7 +2126,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
### `send_message`
根据 agent id 向直接可继续 child 发送消息。如果你是驻留的可继续 child,也可以把自己的直接 parent 作为目标。如果目标仍在工作,消息会 steer 其最近的 step;如果目标处于 inactive,消息会启动或恢复一个轮次。此调用不会返回该 agent 的答案,只会确认消息已投递。调用失败表示消息**未**投递。
向某个 agent 发送消息。工作中的 agent 会在下一个 step 收到消息;空闲的 agent 会以该消息开始新一轮。返回投递确认,而不是该 agent 的答案。
```json
{
@@ -2157,7 +2158,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
### `job_kill`
根据 job id 请求取消正在运行的后台任务。此调用立即返回;任务的工作真正停止后,会以 killed 状态结算。
请求取消正在运行的后台任务。
```json
{
@@ -2195,7 +2196,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
### `job_output`
读取后台任务。流式任务只返回自上次读取以来的输出;最终输出任务会在结算后返回结果。每个响应都以 `[status: ...]` 结尾。读取默认不阻塞;设置 `wait: true` 后,最长等待到配置的上限。
读取后台任务:流式任务返回自上次读取以来的输出,已完成的最终输出任务返回其结果。
```json
{
@@ -2207,11 +2208,11 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
},
"wait": {
"type": "boolean",
"description": "Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."
"description": "Block until the job finishes or the timeout expires; a timed-out wait leaves the job running. Defaults to false."
},
"timeout_ms": {
"type": "number",
"description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."
"description": "Max wait in milliseconds with wait: true. Defaults to and is capped by configuration."
}
},
"required": [
@@ -2521,7 +2522,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
### `todo_write`
记录并更新当前工作的结构化任务列表。每次调用都要发送**完整列表**,它会**替换**之前的列表,不支持局部更新或逐项编辑。请用它规划多步骤工作并展示进度:开始前为每个具体步骤添加一项 todo。将当前正在处理的每项 todo 标记为 `in_progress`;确实并行运行时(例如并发 subagent 或后台命令)可同时标记多项,顺序工作则标记 1 项。只要工作尚未完成,就应至少有一项任务为 `in_progress`。某项 todo 完成后立即标记为 `completed`,不要批量标记完成;只有全部工作完成后,才可以没有 `in_progress` 项。简单的单步骤任务无需使用列表。状态:`pending`(未开始)、`in_progress`(正在处理)、`completed`(已完成)。
记录并更新任务列表,用于规划多步骤工作并展示进度;简单的单步骤任务无需使用。开始前为每个具体步骤添加一项 todo。只要工作尚未完成,就将正在处理的 todo 标记为 `in_progress`,仅在工作并行运行时同时标记多项。某项 todo 完成后立即标记为 `completed`。
```json
{
@@ -2573,18 +2574,14 @@ todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为
运行用于大规模编排 subagent 的 JavaScript 工作流脚本。当工作会分散到许多相互独立的部分时,请使用此工具,例如审查大量文件、执行迁移、开展多角度研究或对发现进行对抗式验证;此时应将编排写成脚本,而不是逐轮委派。
工作流的身份通过 `meta` 参数以 JSON 形式传入:必填的 `name`(简短 kebab-case)和 `description` 字符串,以及可选的 `whenToUse` 字符串和 `phases` 数组(`{title, detail?, provider?, model?}`)。`script` 参数只能是纯 JavaScript **函数体**,不能是 TypeScript,也不能包含 `export const meta` 语句;meta 是参数而非代码。脚本支持顶层 await;请以 `return <value>` 结尾,该值必须可以 JSON 序列化,并作为此工具的结果。
脚本函数体提供以下钩子:
- `agent(prompt, opts?): Promise<any>`:运行一个 subagent 直至完成。不提供 `opts.schema` 时,解析为子级最终文本;提供 `opts.schema` 时,它必须是以对象为根、且**只能**使用 type/properties/required/additionalProperties/items/enum/const/oneOf 的 JSON Schema,不支持 pattern/format/数值边界,此时解析为通过校验的对象。子级失败时解析为 `null`,可使用 `.filter(Boolean)` 过滤。其他选项包括 `label`(显示名称)、`phase`(进度组),以及相互独立的 `provider`/`model` LLM(大语言模型)目标覆盖项,两者可单独提供。其他任何选项(`effort`/`isolation`/`agentType`)都会明确报错。
- `pipeline(items, ...stages): Promise<any[]>`:让每个条目分别经过各阶段,阶段之间**没有**屏障;多阶段工作优先使用它。每个阶段接收 `(prev, item, index)`。普通的阶段异常会将该**条目**变为 `null`,并跳过它的剩余阶段。
- `agent(prompt, opts?): Promise<any>`:运行一个 subagent 直至完成。不提供 `opts.schema` 时,解析为子级最终文本;提供 `opts.schema` 时,它必须是以对象为根、且**只能**使用 type/properties/required/additionalProperties/items/enum/const/oneOf 的 JSON Schema,此时解析为通过校验的对象。子级失败时解析为 `null`,可使用 `.filter(Boolean)` 过滤。其他选项包括 `label`(显示名称)、`phase`(进度组),以及相互独立的 `provider`/`model` LLM(大语言模型)目标覆盖项。
- `pipeline(items, ...stages): Promise<any[]>`:让每个条目分别经过各阶段,阶段之间**没有**屏障;多阶段工作优先使用它。每个阶段接收 `(prev, item, index)`。阶段异常会将该**条目**变为 `null`,并跳过它的剩余阶段。
- `parallel(thunks): Promise<any[]>`:并发运行零参数函数并等待**全部**完成。它会形成屏障,仅当某个阶段确实需要汇总全部先前结果时使用。抛出异常的 thunk 解析为 `null`。
- `phase(title)`:开始一个进度阶段;`log(message)`:说明进度;`args`:工具调用的 `args` 输入,原样提供。
如果误用钩子(参数错误、未知选项、不受支持的 schema、触发上限),抛出的错误**总会**终止脚本,绝不会退化为单个条目的 `null`。
约束:并发上限和 agent 总数上限均会生效;不提供文件系统、网络、定时器或 Node.js API。具体工作由 agent 完成,脚本只负责编排。该运行默认在前台执行:整个脚本完成后,调用才会返回。长时间运行请设置 `run_in_background: true`:调用会立刻返回任务 id,运行在后台继续编排,其返回值随任务的完成播报送达(用 `job_output` 查看进展,用 `job_kill` 停止)。
如果误用钩子(参数错误、未知选项、不受支持的 schema、触发上限),整个脚本会终止,而不会产生 `null`。脚本没有文件系统、网络、定时器或 Node.js API;具体工作由 agent 完成。
```json
{
@@ -2592,11 +2589,11 @@ todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为
"properties": {
"script": {
"type": "string",
"description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`)."
"description": "The plain JavaScript body, not TypeScript and without an `export const meta` statement; top-level await is allowed. End with `return <value>`; the JSON-serializable value is this tool's result."
},
"meta": {
"type": "object",
"description": "The workflow identity block (plain JSON — never code).",
"description": "The workflow identity as plain JSON, not code.",
"additionalProperties": true,
"properties": {
"name": {
@@ -2709,7 +2706,7 @@ todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为
### `web_search`
在 Web 上搜索最新信息。在必填的 `queries` 数组中提供 1–4 个查询。返回可选的摘要答案和来源 URL 列表。
在 Web 上搜索最新信息。返回可选的摘要答案和来源 URL 列表。
```json
{
@@ -2717,7 +2714,7 @@ todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为
"properties": {
"queries": {
"type": "array",
"description": "Required search queries; accepts 1–4 items and merges their results.",
"description": "1–4 search queries; their results are merged.",
"items": {
"type": "string"
}
@@ -18,8 +18,8 @@
en: 13ae68865fa315d7
zh: c152f020abf20f35
/deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props:
en: 72a6f8429c1ab3d0
zh: ae76012917c96dcf
en: ea2f1e81b1f9a38a
zh: a2d4745b3e93e5a9
/deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries:
en: bb1244310f5c0382
zh: 824b84077432b559
+1 -1
View File
@@ -86,7 +86,7 @@ File chips and editable skill references share a whole-reference hover backgroun
When another writer owns the Session, the send-error toast asks the user to quit other running DSH instances and retry.
Two independent Escape presses in the focused Chat or Composer stop its current running turn and preserve queued messages. The interval comes from the shortcuts plugin’s `stopSequenceMs` configuration (500 ms by default). A menu, approval, modal, terminal, embedded webpage, composition, repeated key, changed input region, Session or turn breaks the sequence. The shortcut uses the same scoped cancellation as the Stop button. The plugin registers Stop as a fixed action in the `application` display group. Its registration reserves plain Escape against editable shortcuts and supplies the `Esc Esc` sequence shown in the Stop button’s hover and keyboard-focus tooltip.
Two independent Escape presses in the focused Chat or Composer stop its current running turn and preserve queued messages. The interval comes from the shortcuts plugin’s `stopSequenceMs` configuration (500 ms by default). A menu, approval, modal, terminal, embedded webpage, composition, repeated key, changed input region, Session or turn breaks the sequence. The shortcut uses the same scoped cancellation as the Stop button. The plugin registers Stop as a fixed action in the `input` display group. Its registration reserves plain Escape against editable shortcuts and supplies the `Esc Esc` sequence shown in the Stop button’s hover and keyboard-focus tooltip.
<a id="temporary-composer-entries"></a>
## Temporary composer entries
+1 -1
View File
@@ -86,7 +86,7 @@ Send 和 Stop 按钮禁用时不显示提示气泡,轮次结束后由 Stop 切
当会话被其他写句柄占用时,发送失败的 toast 提示用户退出其他正在运行的 DSH 后重试。
在获得焦点的 Chat 或 Composer 中连续独立按下两次 Esc,可停止当前运行轮次并保留排队消息。间隔由 shortcuts 插件的 `stopSequenceMs` 配置决定,默认 500 ms。菜单、审批、模态层、终端、内嵌网页、输入法、重复按键,以及输入区域、Session 或轮次变化会清空序列。快捷键与 Stop 按钮调用同一作用域取消操作。插件将 Stop 注册为 `application` 展示分组中的固定操作。该注册使普通 Esc 不能分配给可编辑快捷键,并为 Stop 按钮的悬停和键盘聚焦提示提供 `Esc Esc` 序列。
在获得焦点的 Chat 或 Composer 中连续独立按下两次 Esc,可停止当前运行轮次并保留排队消息。间隔由 shortcuts 插件的 `stopSequenceMs` 配置决定,默认 500 ms。菜单、审批、模态层、终端、内嵌网页、输入法、重复按键,以及输入区域、Session 或轮次变化会清空序列。快捷键与 Stop 按钮调用同一作用域取消操作。插件将 Stop 注册为 `input` 展示分组中的固定操作。该注册使普通 Esc 不能分配给可编辑快捷键,并为 Stop 按钮的悬停和键盘聚焦提示提供 `Esc Esc` 序列。
<a id="temporary-composer-entries"></a>
## 临时 composer entry
@@ -257,7 +257,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
), 'ui-conversation: fixed stop input')
scope.effect(() => {
const command: ShortcutFixedCommand = {
id: 'response.stop' as ShortcutCommandId, label: () => t('input.stop'), keys: ['Esc', 'Esc'], bindings: [{ code: 'Escape', modifiers: [] }], group: 'application',
id: 'response.stop' as ShortcutCommandId, label: () => t('input.stop'), keys: ['Esc', 'Esc'], bindings: [{ code: 'Escape', modifiers: [] }], group: 'input',
}
const dispose = scope.shortcuts.registerFixed(command)
stopShortcut.set(command.keys)
@@ -9,7 +9,6 @@ const PLAN_NEXT_ACTION_EN = 'describe your task to generate plan'
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'shortcut.hint': '{label} {keys}',
'shortcut.newline': '换行',
'shortcut.complementary': '使用互补的 Queue/Steer 方式',
'shortcut.slash': '打开命令菜单',
@@ -380,7 +379,6 @@ export type ConversationKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'shortcut.hint': '{label} {keys}',
'shortcut.newline': 'New line',
'shortcut.complementary': 'Use the complementary Queue / Steer action',
'shortcut.slash': 'Open command menu',
@@ -311,7 +311,6 @@ export const InputBar = memo(function InputBar({
: running && steeringAvailable && !disabled && !uploadsPending && plainMessageDraft
? t(primarySubmitMode === 'steer' ? 'input.send.steer' : 'input.send.queue')
: t('input.send')
const stopHint = stopKeys.length ? t('shortcut.hint', { label: t('input.stop'), keys: stopKeys.join(' ') }) : t('input.stop')
const onPrimary = (): void => {
if (primaryStops) {
stop?.()
@@ -459,7 +458,7 @@ export const InputBar = memo(function InputBar({
{renderSlot('conversation.input.activity', { locked, onActiveChange: setActivity })}
</div>}
{interruptible && (
<Tooltip label={stopHint} side="top" delayMs={500} disabled={stop === undefined}>
<Tooltip label={t('input.stop')} shortcutKeys={stopKeys} side="top" delayMs={500} disabled={stop === undefined}>
<button
type="button"
className={css.primary}
@@ -474,7 +473,7 @@ export const InputBar = memo(function InputBar({
</button>
</Tooltip>
)}
<Tooltip label={primaryStops ? stopHint : primaryLabel} side="top" delayMs={500} disabled={primaryDisabled}>
<Tooltip label={primaryStops ? t('input.stop') : primaryLabel} shortcutKeys={primaryStops ? stopKeys : undefined} side="top" delayMs={500} disabled={primaryDisabled}>
<button
type="button"
className={css.primary}
@@ -856,7 +856,7 @@ describe('running and lock semantics', () => {
if (trigger === 'hover') fireEvent.mouseEnter(stop)
else fireEvent.focus(stop)
act(() => { vi.advanceTimersByTime(500) })
expect(view.getByRole('tooltip').textContent).toBe(`${label} Esc Esc`)
expect(view.getByRole('tooltip').getAttribute('aria-label')).toBe(`${label} Esc Esc`)
act(() => { stopShortcut.set([]) })
expect(view.getByRole('tooltip').textContent).toBe(label)
} finally {
@@ -870,7 +870,7 @@ describe('running and lock semantics', () => {
const { button, view, session } = bench({ running: true })
fireEvent.mouseEnter(button)
act(() => { vi.advanceTimersByTime(500) })
expect(view.getByRole('tooltip').textContent).toBe('停止生成 Esc Esc')
expect(view.getByRole('tooltip').getAttribute('aria-label')).toBe('停止生成 Esc Esc')
// Disabling a hovered native button need not deliver mouseleave.
act(() => { session.set(snapshotOf({ running: false })) })
+1 -1
View File
@@ -18,7 +18,7 @@ const FILE_REFERENCE_PROMPT = 'Prefer showing the primary results within your fi
+ 'Use ![Description](<path/to/image.png>) when an image supports an explanation or comparison. Use [Description](<path/to/image.png>) when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. '
+ 'Do not call present just to list edited source files, or run commands to check whether a diff view will appear. '
+ 'Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. '
+ 'Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. Avoid repeating results already shown inline unless the separate card adds useful access. '
+ 'Avoid repeating results already shown inline unless the separate card adds useful access. '
+ 'Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. '
+ 'Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix.'
@@ -29,7 +29,7 @@ describe('ui-deliverables node plugin', () => {
const section = (await ctx.systemPrompt.assemble()).sections
.find(entry => entry.name === 'ui:deliverable-file-references')
expect(section?.text).toMatchInlineSnapshot('"Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description](<path/to/image.png>) when an image supports an explanation or comparison. Use [Description](<path/to/image.png>) when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix."')
expect(section?.text).toMatchInlineSnapshot('"Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description](<path/to/image.png>) when an image supports an explanation or comparison. Use [Description](<path/to/image.png>) when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix."')
// Only live request pins track current guidance; seeded sessions retain their recorded prompt.
for (const scenario of ['fresh-round-trip', 'ptc-round']) {
+2 -2
View File
@@ -21,8 +21,8 @@
en: 789c879dd83f463a
zh: 37f2dc45f9588810
/deepseek-ai-dsh-client-ui-dockkit/interaction-rules-worth-keeping:
en: 2c92ba93f7d6ee1c
zh: b4da347bf1e24408
en: 7e5c1e9a9f9f244f
zh: 7e3a8fcef86bc7dd
/deepseek-ai-dsh-client-ui-dockkit/build-shape:
en: 21216ba5798d7f5f
zh: ac505fdcd5c114d4
+1 -1
View File
@@ -65,7 +65,7 @@ A tab's `kind` is an opaque string. Seeded tabs are factories (`DockControllerOp
<a id="interaction-rules-worth-keeping"></a>
## Interaction rules worth keeping
The split control accepts effective shortcut tooltip and ARIA labels from its embedder. A disabled control has a keyboard-focusable wrapper that explains the pane-budget or width restriction. Docked and floating pane containers can receive programmatic focus without entering the normal tab sequence or drawing a focus outline; their controls retain their own keyboard focus indicators. Tab navigation and selection use unmodified keys and leave composition input to its owner.
The split control accepts localized tooltip text, separate effective keys through `splitPaneKeys`, and an ARIA combination from its embedder. Close controls receive their keys through `closeTabKeys`. A disabled control has a keyboard-focusable wrapper that explains the pane-budget or width restriction. Docked and floating pane containers can receive programmatic focus without entering the normal tab sequence or drawing a focus outline; their controls retain their own keyboard focus indicators. Tab navigation and selection use unmodified keys and leave composition input to its owner.
These are not stylistic; each one fixes a defect found in a real browser.
+1 -1
View File
@@ -65,7 +65,7 @@ Sidebar 使用的 `DockLayout` 在选中项变化、跨格移动与浮动期间
<a id="interaction-rules-worth-keeping"></a>
## 值得保留的交互规则
分栏控件接收宿主传入的有效快捷键提示和 ARIA 标签。禁用控件由可键盘聚焦的容器承载,说明分栏数量或宽度限制。停靠与浮动面板容器支持程序设置焦点,不进入正常 Tab 顺序,也不绘制焦点外框;内部控件保留各自的键盘焦点提示。标签导航和选择只使用无修饰键组合,输入法按键交回其所属输入区域。
分栏控件接收嵌入方传入的本地化提示文本、`splitPaneKeys` 独立按键及 ARIA 组合。关闭控件通过 `closeTabKeys` 接收按键。禁用控件由可键盘聚焦的容器承载,说明分栏数量或宽度限制。停靠与浮动面板容器支持程序设置焦点,不进入正常 Tab 顺序,也不绘制焦点外框;内部控件保留各自的键盘焦点提示。标签导航和选择只使用无修饰键组合,输入法按键交回其所属输入区域。
这些不是风格偏好;每一条都修复了在真实浏览器里发现的缺陷。
@@ -130,8 +130,8 @@ export function FloatHeader({ paneId, tab, labels, intents, renderTabTitle, canC
</button>
</Tooltip>
{(canCloseTab?.(tab.id) ?? true) && (
<Tooltip label={labels.closeTabTooltip ?? labels.closeFloat} side="bottom" delayMs={500}>
<button type="button" className={css.iconButton} aria-label={labels.closeTabTooltip ?? labels.closeFloat}
<Tooltip label={labels.closeTab} shortcutKeys={labels.closeTabKeys} side="bottom" delayMs={500}>
<button type="button" className={css.iconButton} aria-label={labels.closeTab}
data-dockkit-float-close={paneId} onPointerDown={(event) => { event.stopPropagation() }}
onClick={() => { intents.closeTab(tab.id) }}>
<IconCloseOutlineRegular />
@@ -313,7 +313,7 @@ export function TabStrip({ state, pane, callbacks }: TabPanelProps): ReactNode {
>
<TabTitle>{callbacks.renderTabTitle?.(tab) ?? tab.title}</TabTitle>
{closable && (
<Tooltip disabled={menu !== undefined} label={callbacks.labels.closeTabTooltip ?? callbacks.labels.closeTab} side="bottom" delayMs={500}>
<Tooltip disabled={menu !== undefined} label={callbacks.labels.closeTab} shortcutKeys={callbacks.labels.closeTabKeys} side="bottom" delayMs={500}>
<button
type="button"
className={css.tabClose}
@@ -365,7 +365,7 @@ export function TabStrip({ state, pane, callbacks }: TabPanelProps): ReactNode {
)}
<div className={css.stripFill} data-dockkit-strip-fill />
{!(callbacks.hideSplitWhenBlocked && block !== undefined) && (
<Tooltip label={block === undefined ? callbacks.labels.splitPaneTooltip ?? callbacks.labels.splitPane : splitBlockedTitle(callbacks.labels, block)} side="bottom" delayMs={500}>
<Tooltip label={block === undefined ? callbacks.labels.splitPane : splitBlockedTitle(callbacks.labels, block)} shortcutKeys={callbacks.labels.splitPaneKeys} side="bottom" delayMs={500}>
<span
tabIndex={block === undefined ? undefined : 0}
aria-label={block === undefined ? undefined : splitBlockedTitle(callbacks.labels, block)}
@@ -19,20 +19,20 @@ export interface DockLabels {
readonly emptyPane: string
/** The split control, while splitting is allowed. */
readonly splitPane: string
/** Effective localized action and shortcut tooltip, when supplied. */
readonly splitPaneTooltip?: string
/** ARIA combination from the effective binding, when bound. */
readonly splitPaneShortcut?: string | undefined
/** Effective split keys shown as separate tooltip keycaps. */
readonly splitPaneKeys?: readonly string[] | undefined
/** The split control, once the pane budget is spent. */
readonly splitPaneDisabled: string
/** The split control, while the pane is too narrow for two working halves. */
readonly splitPaneNarrow: string
/** Destroy a tab: the chip's close control and the menu's close item. */
readonly closeTab: string
/** Effective close label and keycaps supplied by the embedder. */
readonly closeTabTooltip?: string
/** Effective close combination, when bound. */
readonly closeTabShortcut?: string | undefined
/** Effective close keys shown as separate tooltip keycaps. */
readonly closeTabKeys?: readonly string[] | undefined
/** The strip's add control, which seats the embedder's seeded tab. */
readonly addTab: string
/** Send a floating panel back into the docked tree. */
@@ -1367,7 +1367,7 @@ describe('FloatLayer', () => {
it('closes a floating panel through its own control', () => {
const { intents, tabId } = floating()
fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.closeFloat }))
fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.closeTab }))
expect(intents.closeTab).toHaveBeenCalledWith(tabId)
})
@@ -1375,7 +1375,7 @@ describe('FloatLayer', () => {
const canCloseTab = vi.fn(() => false)
const { intents, tabId } = floating(canCloseTab)
expect(canCloseTab).toHaveBeenCalledWith(tabId)
expect(screen.queryByRole('button', { name: TEST_LABELS.closeFloat })).toBeNull()
expect(screen.queryByRole('button', { name: TEST_LABELS.closeTab })).toBeNull()
expect(screen.getByTestId('float-body').textContent).toBe('a.txt')
fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.dockFloat }))
expect(intents.unfloatPane).toHaveBeenCalledTimes(1)
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-client-ui-model-selection/use-this-package:
en: 8451ab527ba71867
zh: 933fac58cfa592b9
en: e5a1df3a4dfe5041
zh: 6269e73026b32a33
/deepseek-ai-dsh-client-ui-model-selection/use-this-package/model-and-effort:
en: e38d0195677a7b69
zh: 4eea8a9b1664afd9
@@ -27,8 +27,8 @@
en: 261bbf42296243af
zh: 72779fb7b8fbc473
/deepseek-ai-dsh-client-ui-model-selection/understand-the-implementation:
en: 57fc1965fbcebc55
zh: 81475301449fb082
en: 493088e8de6be91c
zh: 9ecee4605b4dbad4
/deepseek-ai-dsh-client-ui-model-selection/further-exploration:
en: 14acd4692e923480
zh: ff89aed852aa0da8
+2 -2
View File
@@ -33,7 +33,7 @@ The unselected model label uses the same regular weight as an available model na
Mount this plugin alongside `ui-conversation` and the commands package; the composer then shows the model seat next to the pending indicator, and `/model` opens the same directory as a popup. While the seat's menu is open, `↑`/`↓` move focus across the rows of the shown pane, Tab settles the focused row, and Escape and `Shift+Tab` leave a drilled pane first and otherwise close back to the trigger. Drilling lands on the row of the value in use, and going back lands on the cell that opened the pane left. The composer shows the catalog name while the selected model is available, and its saved `provider/model` ID when the model or provider is removed, including account sign-out. The stored provider, model, and reasoning effort remain unchanged.
Mouse selection uses native browser clicks, including their cancellation behavior; a press alone never selects. Opening the menu focuses its trigger, and clicking the trigger again closes the menu and returns focus there. While a selection is pending, focus stays on the trigger; a rejected selection leaves the menu open, and Tab returns to the current row.
Mouse selection uses native browser clicks, including their cancellation behavior; a press alone never selects. Opening the menu focuses its trigger, and clicking the trigger again closes the menu and returns focus there. While a selection from either entry is pending, focus stays on the trigger, the trigger shows a spinner in place of its chevron, and each row whose value the selection carries shows one in place of its check; a rejected selection leaves the menu open, and Tab returns to the current row.
### Model and effort
@@ -59,7 +59,7 @@ Menus use the shared `MenuSurface` material, including the macOS backing for bac
<details>
<summary>Implementation internals — click to expand</summary>
Two entries over ONE per-session directory owned by `ModelDirectoryResolver` (`ctx.modelDirectories`): the `/model` popupSelect contribution (registered through `ctx.commandUi`) and the composer's named `conversation.input.model` seat both load the session's available directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance, so a switch made in either entry is what the other shows next. Directory loads and selections share a generation counter so an older response never overwrites a newer one; a connection reset drops every resident projection and repulls the Host-restored selection before display. Directories are per-session, resolved lazily, and disposed with the session scope; addressed subagent sessions expose neither entry. Every resident directory refetches directly on forwarded `llm/adapters-updated`, `settings/document-updated`, and credential update events.
Two entries over ONE per-session directory owned by `ModelDirectoryResolver` (`ctx.modelDirectories`): the `/model` popupSelect contribution (registered through `ctx.commandUi`) and the composer's named `conversation.input.model` seat both load the session's available directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance, so a switch made in either entry is what the other shows next. Directory loads and selections share a generation counter so an older response never overwrites a newer one. The directory publishes the latest submitted selection as `pending` until it settles or a connection reset invalidates it; a connection reset drops every resident projection and repulls the Host-restored selection before display. Directories are per-session, resolved lazily, and disposed with the session scope; addressed subagent sessions expose neither entry. Every resident directory refetches directly on forwarded `llm/adapters-updated`, `settings/document-updated`, and credential update events.
</details>
@@ -33,7 +33,7 @@ DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示
与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。模型位菜单打开期间,`↑`/`↓` 在所显示面板的行间移动焦点,`Tab` 选定聚焦行,Escape 与 `Shift+Tab` 先退出已下钻的面板,否则关闭并回到触发器。下钻落在正在使用的那一行,返回则落在打开该面板的格子上。所选模型可用时,composer 显示目录中的模型名称;模型或提供方被删除时(包括账号退登),改为显示已保存的 `provider/model` ID。保存的提供方、模型和推理强度均保持不变。
鼠标选择沿用浏览器原生点击及其取消行为;仅按下按钮不会选定。打开菜单时聚焦触发按钮,再次点击触发按钮会关闭菜单并把焦点还给它。等待选择结果时,焦点停在触发按钮;选择被拒绝后菜单保持打开,Tab 可回到当前选中的行。
鼠标选择沿用浏览器原生点击及其取消行为;仅按下按钮不会选定。打开菜单时聚焦触发按钮,再次点击触发按钮会关闭菜单并把焦点还给它。等待任一入口发起的选择结果时,焦点停在触发按钮,触发按钮以加载图标代替下拉箭头,该选择包含的值所在行以加载图标代替勾选标记;选择被拒绝后菜单保持打开,Tab 可回到当前选中的行。
### 模型与推理强度
@@ -59,7 +59,7 @@ DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示
<details>
<summary>实现细节——点击展开</summary>
两个入口共用一份由 `ModelDirectoryResolver`(`ctx.modelDirectories`)持有的会话级目录:`/model` popupSelect 贡献项(经 `ctx.commandUi` 注册)与 composer 的具名 `conversation.input.model` 位都经 `session.models` 加载会话的可用目录、经 `session.selectModel` 通过同一个 `ModelDirectory` 实例提交,因此任一入口所做的切换正是另一个入口接下来显示的。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置丢弃所有常驻投影,并在显示前重新拉取 Host 恢复的选择。目录按会话惰性解析,随会话作用域一并 dispose(资源释放);已寻址 subagent 会话不公开任一入口。每份常驻目录都会直接在转发的 `llm/adapters-updated`、`settings/document-updated` 与凭据更新事件上重拉。
两个入口共用一份由 `ModelDirectoryResolver`(`ctx.modelDirectories`)持有的会话级目录:`/model` popupSelect 贡献项(经 `ctx.commandUi` 注册)与 composer 的具名 `conversation.input.model` 位都经 `session.models` 加载会话的可用目录、经 `session.selectModel` 通过同一个 `ModelDirectory` 实例提交,因此任一入口所做的切换正是另一个入口接下来显示的。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果。目录把最近一次提交的选择发布为 `pending`,直到它完成或被连接重置作废;连接重置丢弃所有常驻投影,并在显示前重新拉取 Host 恢复的选择。目录按会话惰性解析,随会话作用域一并 dispose(资源释放);已寻址 subagent 会话不公开任一入口。每份常驻目录都会直接在转发的 `llm/adapters-updated`、`settings/document-updated` 与凭据更新事件上重拉。
</details>
@@ -15,7 +15,9 @@
* selected effort come from the Host rather than a client-owned vocabulary. A
* rejected selection announces through the shared transient Toast anchored to
* the composer card; the in-menu strip with Retry remains the catalog-load
* surface.
* surface. While the directory's pending selection is unsettled, the trigger
* shows a spinner in place of its chevron, and each row whose value that
* selection carries shows one in place of its check mark.
*/
import { MenuSurface } from '@deepseek-ai/dsh-client-ui-primitives'
import {
@@ -27,7 +29,7 @@ import clsx from 'clsx'
import type { ModelReasoningEffort, ModelSelection } from '@deepseek-ai/dsh-api-remotes/client'
import {
IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconChevronRightOutlineRegular,
IconDataOutlineRegular, IconWarningOutlineRegular, Toast,
IconDataOutlineRegular, IconWarningOutlineRegular, StateDot, Toast,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ModelSelectInjected } from './slots.ts'
@@ -114,7 +116,8 @@ export function ModelSelect(
label: effort.name,
})),
], [reasoning, t])
const busy = state.status === 'selecting'
const { pending } = state
const busy = pending !== null
const reload = (): void => {
lastActionRef.current = 'load'
@@ -366,6 +369,7 @@ export function ModelSelect(
aria-expanded={open}
aria-controls={open ? `${id}-menu` : undefined}
title={triggerLabel}
aria-busy={busy}
disabled={locked}
onClick={() => {
if (open) {
@@ -378,7 +382,9 @@ export function ModelSelect(
<IconDataOutlineRegular className={css.triggerIcon} size={16} />
<span className={css.triggerLabel}>{modelLabel}</span>
{effortLabel !== undefined && <span className={css.triggerEffort}>{effortLabel}</span>}
<IconChevronDownOutlineRegular className={clsx(css.chevron, open && css.chevronOpen)} />
{busy
? <StateDot state="ongoing" />
: <IconChevronDownOutlineRegular className={clsx(css.chevron, open && css.chevronOpen)} />}
</button>
{/* Portaled to body (Menu primitive's portal mode) so the sidebar and
@@ -452,7 +458,9 @@ export function ModelSelect(
<span className={css.modelName}>{model.name}</span>
</span>
<span className={css.check}>
{selected ? <IconCheckOutlineRegular /> : null}
{pending?.provider === group.id && pending.model === model.id
? <StateDot state="ongoing" />
: selected ? <IconCheckOutlineRegular /> : null}
</span>
</button>
)
@@ -492,7 +500,10 @@ export function ModelSelect(
<span className={css.modelName}>{level.label}</span>
</span>
<span className={css.check}>
{effectiveEffort === level.effort ? <IconCheckOutlineRegular /> : null}
{pending !== null && pending.provider === state.current?.provider
&& pending.model === state.current.model && pending.reasoningEffort === level.effort
? <StateDot state="ongoing" />
: effectiveEffort === level.effort ? <IconCheckOutlineRegular /> : null}
</span>
</button>
))}
@@ -28,6 +28,8 @@ export interface ModelDirectoryState {
failures: readonly ModelCatalogFailure[]
/** Lifecycle of the in-flight operation. */
status: 'idle' | 'loading' | 'ready' | 'selecting' | 'error'
/** Selection submitted by the latest `select` until it settles; null otherwise. */
pending: ModelSelection | null
/** Whole-request or selection failure text; null when none. */
error: string | null
}
@@ -36,7 +38,7 @@ export interface ModelDirectoryState {
export class ModelDirectory {
/** The shared snapshot both entries render from (uSES-safe store). */
readonly store: SnapshotStore<ModelDirectoryState> = createSnapshotStore<ModelDirectoryState>({
current: null, routable: null, groups: [], failures: [], status: 'idle', error: null,
current: null, routable: null, groups: [], failures: [], status: 'idle', pending: null, error: null,
})
/** Latest selection operation wins; an older response never overwrites a newer one. */
@@ -90,7 +92,7 @@ export class ModelDirectory {
const previousEffort = previous?.reasoningEffort ?? (previous === null ? undefined : this.catalog.reasoningFor(previous)?.defaultEffort)
const nextEffort = selection.reasoningEffort ?? this.catalog.reasoningFor(selection)?.defaultEffort
const generation = ++this.generation
this.store.update((s) => { s.status = 'selecting'; s.error = null })
this.store.update((s) => { s.status = 'selecting'; s.pending = selection; s.error = null })
const result = await this.sessions.selectModel({
sessionId: this.sessionId,
provider: selection.provider,
@@ -105,6 +107,7 @@ export class ModelDirectory {
if (!result.ok) {
this.store.update((s) => {
s.status = 'error'
s.pending = null
s.error = `${result.error.code}: ${result.error.message}`
})
return result
@@ -117,7 +120,7 @@ export class ModelDirectory {
session_id: this.sessionId, model_name: to, switch_from: previousEffort ?? 'default', switch_to: nextEffort ?? 'default',
})
}
this.store.update((s) => { s.status = 'ready'; s.error = null })
this.store.update((s) => { s.status = 'ready'; s.pending = null; s.error = null })
this.syncInputs()
return { ok: true, value: undefined }
}
@@ -130,6 +133,7 @@ export class ModelDirectory {
++this.generation
this.store.update((state) => {
if (state.status === 'selecting') state.status = 'idle'
state.pending = null
state.error = null
})
this.syncInputs()
@@ -165,6 +169,7 @@ export class ModelDirectory {
groups: catalog.value?.groups ?? [],
failures: catalog.value?.failures ?? [],
status: catalog.status === 'error' ? 'error' : 'loading',
pending: this.store.getSnapshot().pending,
error: catalog.error,
})
return
@@ -181,6 +186,7 @@ export class ModelDirectory {
status: this.store.getSnapshot().status === 'selecting'
? 'selecting'
: 'ready',
pending: this.store.getSnapshot().pending,
error: null,
})
}
@@ -208,7 +208,7 @@ describe('ui-model-selection dual entry', () => {
b.rejectSelection()
await expect(b.popup().onSelect(options[0]!, input)).rejects.toThrow(zh['error.sessionInUse'])
expect(b.ctx.modelDirectories.directoryFor(sid('owned')).store.getSnapshot()).toMatchObject({
status: 'error', error: 'session/writer-held: writer held',
status: 'error', pending: null, error: 'session/writer-held: writer held',
})
})
@@ -252,12 +252,12 @@ describe('ui-model-selection dual entry', () => {
const b = await bench()
b.mint('s1')
const seatFace = b.seat().inject!(sid('s1'))
// Switch through the SEAT entry.
expect(await seatFace.select({
provider: 'deepseek-official',
model: 'deepseek-v4-pro',
reasoningEffort: 'max',
})).toEqual({ ok: true, value: undefined })
// Switch through the SEAT entry; the directory holds the submission until it settles.
const selection = { provider: 'deepseek-official', model: 'deepseek-v4-pro', reasoningEffort: 'max' }
const settled = seatFace.select(selection)
expect(seatFace.directory.getSnapshot()).toMatchObject({ status: 'selecting', pending: selection })
expect(await settled).toEqual({ ok: true, value: undefined })
expect(seatFace.directory.getSnapshot()).toMatchObject({ status: 'ready', pending: null })
expect(b.hostCurrent()).toEqual({
provider: 'deepseek-official',
model: 'deepseek-v4-pro',
@@ -339,6 +339,19 @@ describe('ui-model-selection dual entry', () => {
expect(b.calls.models).toBe(1)
})
it('drops a pending selection on connection reset and ignores its late settlement', async () => {
const b = await bench()
b.mint('s1')
const face = b.seat().inject!(sid('s1'))
await b.ctx.modelDirectories.directoryFor(sid('s1')).load()
const late = face.select({ provider: 'deepseek-official', model: 'deepseek-v4-pro' })
expect(face.directory.getSnapshot().pending).toEqual({ provider: 'deepseek-official', model: 'deepseek-v4-pro' })
b.ctx.emit('connection/reset')
expect(face.directory.getSnapshot()).toMatchObject({ status: 'loading', pending: null })
await late
expect(face.directory.getSnapshot().pending).toBeNull()
})
it('hides the effective selection until the reconnected catalog validates it', async () => {
const b = await bench()
b.mint('s1')
@@ -47,6 +47,7 @@ function state(overrides: Partial<ModelDirectoryState> = {}): ModelDirectoryStat
}],
failures: [],
status: 'ready',
pending: null,
error: null,
...overrides,
}
@@ -223,6 +224,65 @@ describe('ModelSelect reasoning effort', () => {
expect(screen.queryByRole('button', { name: '重试' })).toBeNull()
})
it('spins on the trigger and the chosen model row until the selection settles, across pane changes', async () => {
const groups = [{
id: 'deepseek-official',
name: 'DeepSeek',
models: [
{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', reasoning },
{ id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' },
],
}]
const directory = createSnapshotStore<ModelDirectoryState>(state({ groups }))
let settle!: () => void
const select = vi.fn((selection: ModelSelection) => {
directory.set(state({ groups, status: 'selecting', pending: selection }))
return new Promise<{ ok: true; value: undefined }>((resolve) => {
settle = () => {
directory.set(state({ groups, current: selection }))
resolve({ ok: true, value: undefined })
}
})
})
render(<ModelSelect locked={false} available directory={directory} load={vi.fn()} select={select} t={t} />)
const spinners = () => document.querySelectorAll('[data-state="ongoing"]')
const trigger = screen.getByRole('button', { name: /选择模型|当前/ })
fireEvent.click(trigger)
fireEvent.click(screen.getByRole('menuitem', { name: /模型/ }))
fireEvent.click(screen.getByRole('menuitemradio', { name: /DeepSeek-V4-Pro/ }))
expect(spinners()).toHaveLength(2)
expect(screen.getByRole('menuitemradio', { name: /DeepSeek-V4-Pro/ }).querySelector('[data-state="ongoing"]')).not.toBeNull()
expect(trigger.querySelector('[data-state="ongoing"]')).not.toBeNull()
expect(trigger.getAttribute('aria-busy')).toBe('true')
// Leaving the pane unmounts the row; the trigger keeps the feedback.
fireEvent.keyDown(trigger, { key: 'Escape' })
expect(screen.queryByRole('menuitemradio')).toBeNull()
expect(spinners()).toHaveLength(1)
expect(trigger.querySelector('[data-state="ongoing"]')).not.toBeNull()
await act(async () => { settle() })
expect(spinners()).toHaveLength(0)
expect(trigger.getAttribute('aria-busy')).toBe('false')
})
it('spins on the chosen effort row only', () => {
const directory = createSnapshotStore<ModelDirectoryState>(state())
const select = vi.fn((selection: ModelSelection) => {
directory.set(state({ status: 'selecting', pending: selection }))
return new Promise<undefined>(() => {})
})
render(<ModelSelect locked={false} available directory={directory} load={vi.fn()} select={select} t={t} />)
fireEvent.click(screen.getByRole('button', { name: /选择模型|当前/ }))
fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
fireEvent.click(screen.getByRole('menuitemradio', { name: /Max/ }))
expect(screen.getAllByRole('menuitemradio')
.filter(row => row.querySelector('[data-state="ongoing"]') !== null)
.map(row => row.textContent)).toEqual(['Max'])
})
it('portals the placed menu card to body and closes only on truly-outside mousedown', () => {
const offsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth')!
const offsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight')!
@@ -132,7 +132,7 @@ export function OpenTargetButton(props: OpenTargetButtonProps): ReactNode {
anchor={(
<div className={css.split} data-open-target={kind} data-size={props.prominent ? 'large' : 'compact'}
data-open-path={kind === 'file' && !props.prominent ? '' : undefined} data-state={disabled ? 'busy' : 'idle'}>
<Tooltip portal label={props.shortcut?.keys.length ? t('shortcut.hint', { label: primaryLabel, keys: props.shortcut.keys.join(' ') }) : primaryLabel} side="bottom" delayMs={500}>
<Tooltip portal label={primaryLabel} shortcutKeys={props.shortcut?.keys} side="bottom" delayMs={500}>
<button type="button" className={css.main} disabled={disabled} aria-label={props.prominent ? undefined : primaryLabel}
aria-keyshortcuts={props.shortcut?.aria}
data-open-path-open={kind === 'file' && !props.prominent ? '' : undefined}
@@ -42,7 +42,6 @@ export const zh = {
'open.title': '用 {app} 打开',
'path.appDefault': '{app}(默认)',
'path.appsError': '无法获取应用列表',
'shortcut.hint': '{label} {keys}',
'shortcut.busy': '正在打开工作区',
'shortcut.unavailable': '当前工作区或本地应用不可用',
'open.tooltip': '在本地打开',
@@ -63,7 +62,6 @@ export const en: Record<OpenInAppKey, string> = {
'open.title': 'Open in {app}',
'path.appDefault': '{app} (default)',
'path.appsError': 'Could not load applications',
'shortcut.hint': '{label} {keys}',
'shortcut.busy': 'Opening workspace',
'shortcut.unavailable': 'Current workspace or local application unavailable',
'open.tooltip': 'Open locally',
@@ -15,11 +15,11 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-client-ui-primitives/use-this-package:
en: 91ac7c457248a593
zh: 744c9cea337bb20f
en: ddec1f6f9b018747
zh: 9555f132daf8901c
/deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog:
en: 33299e518ff2cad1
zh: 880c7da903bc20d4
en: 54ced5d959bd06c6
zh: 5a7fc2504f92a788
/deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons:
en: 83052e14c3fb34cf
zh: 0adb09885723296a
+3 -2
View File
@@ -25,7 +25,7 @@ Use `dsh-client-ui-primitives` to build web-client controls and render agent out
<a id="use-this-package"></a>
## Use this package
`Toast` uses the theme’s toast background and label colors in both light and dark modes. `Menu` data rows and `MenuItemButton` component rows accept owner-provided effective shortcuts and align their keys at the trailing edge as muted text without a background, including submenus. `ShortcutKeys` renders caller-supplied effective keys together in one rounded badge; its optional `className` lets the owner style interaction states. `Modal` and the settings shell share top-layer Escape and Tab handling through `useModalLayer`, restoring the previous focus on close. `closeTopModal(document)` requests the foreground modal’s current close action; newer menus or unregistered dialogs block dismissal of the modal behind them. Components use `isBehindModal` to avoid moving focus behind a nested dialog. Menus consume their local Escape before modal dismissal. `observeComposition` supplies the same composition-end and legacy IME guard to local modal and recording handlers; callers dispose its document listeners with their interaction lifetime.
`Toast` uses the theme’s toast background and label colors in both light and dark modes. `Menu` data rows and `MenuItemButton` component rows accept owner-provided effective shortcuts and align their keys at the trailing edge as muted text without a background, including submenus. `ShortcutKeys` defaults to unboxed keys for menu and inline hints. Its `tooltip` variant uses lighter keycaps over the dark bubble and groups plus-separated combinations into one filled block. `Tooltip.shortcutKeys` vertically centers keycaps beside optional localized action text, or shows only keys when the label is empty. The optional `className` supplies owner interaction styles. `Modal` and the settings shell share top-layer Escape and Tab handling through `useModalLayer`, restoring the previous focus on close. `closeTopModal(document)` requests the foreground modal’s current close action; newer menus or unregistered dialogs block dismissal of the modal behind them. Components use `isBehindModal` to avoid moving focus behind a nested dialog. Menus consume their local Escape before modal dismissal. `observeComposition` supplies the same composition-end and legacy IME guard to local modal and recording handlers; callers dispose its document listeners with their interaction lifetime.
Automatic modal entry and modal/menu return focus, including after Escape and application close shortcuts, use `focusWithoutRing(element, options?)` to suppress the outline until Tab or directional navigation resumes normal focus styling. Dialog containers keep their outline-free styling. Existing borders, shadows, and error states remain intact. Mark a dialog's initial control with `data-modal-autofocus` so the modal captures its invoking control before moving focus. Controls mounted with the dialog must not use React `autoFocus`, which runs before that capture. Tab and Shift+Tab from the dialog container enter its first and last focusable controls.
@@ -66,7 +66,8 @@ Check this table before writing a control in a feature package. A plugin cannot
| `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. |
| `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIconRegular`/`ReferenceIconMedium`, `LinkIconRegular`/`LinkIconMedium` | Glyphs and brand marks. Use `LinkIconMedium` for 14px clickable-link categories and known-site marks. |
| `PermissionIconReadOnlyRegular`/`Medium`, `PermissionIconWorkspaceWriteRegular`/`Medium`, `PermissionIconFullAccessRegular`/`Medium` | Permission-mode glyphs for read-only, workspace-write, and full-access choices. |
| `PluginArtworkTerminal`/`Loop`/`Subagent`/`Search`/`Default` | Fixed-palette 36×36 plugin artwork for the plugin-management surfaces; `Default` marks plugins without artwork of their own. Def ids are per-instance, so the same artwork repeats safely on one page. |
| `PluginArtworkTerminal`/`Loop`/`Subagent`/`Search`/`Default` | Fixed-palette 36×36 plugin artwork; `Terminal` supplies the light-blue prompt in plugin cards and sidebar guide entries. `Default` marks plugins without artwork of their own. Def ids are per-instance, so the same artwork repeats safely on one page. |
| `GuideArtworkBrowser`/`Files` | Fixed-palette 36×36 browser and folder artwork for sidebar guide entries. |
| `FileTypeIcon`, `classifyFileType`, `fileExtension` | A category-colored 28px file or folder glyph and the shared case-insensitive filename mapping behind it. Code and configuration files use detailed full-color technology glyphs; use `LinkIconMedium` for link-leading glyphs and image previews for image content. |
| `languageForPath`, `CODE_HIGHLIGHT_EXTENSIONS`, `useCodeHighlighter` | The lazy line-token highlighter shared by code preview and diff review. The filename grammar selection is re-exported from `@deepseek-ai/dsh-util-code-language`, the single extension table also behind the read card's persisted short-id `lang` hint. |
+3 -2
View File
@@ -25,7 +25,7 @@ kind: "package-library"
<a id="use-this-package"></a>
## 使用本包
`Toast` 在浅色和深色模式下均使用主题的系统提示背景与文字颜色。`Menu` 数据条目与 `MenuItemButton` 组件条目接收功能 owner 提供的有效快捷键,并在末端以无背景的浅灰色文字对齐显示,子菜单也采用同一呈现。`ShortcutKeys` 将调用方提供的有效按键组合显示在同一个圆角标签中;可选的 `className` 供调用方设置交互状态样式。`Modal` 与设置外壳通过 `useModalLayer` 共用顶层 Esc 和 Tab 处理,关闭时恢复先前焦点。`closeTopModal(document)` 请求前台弹窗当前的关闭操作;上层菜单或未注册的对话框会阻止关闭其后方弹窗。组件通过 `isBehindModal` 避免将焦点移到嵌套弹窗后方。菜单先消费自己的 Esc,再由模态层处理关闭。 `observeComposition` 为局部弹层和录键处理提供相同的 composition-end 与旧版 IME 保护;调用方随交互生命周期释放其 document 监听。
`Toast` 在浅色和深色模式下均使用主题的系统提示背景与文字颜色。`Menu` 数据条目与 `MenuItemButton` 组件条目接收功能 owner 提供的有效快捷键,并在末端以无背景的浅灰色文字对齐显示,子菜单也采用同一呈现。`ShortcutKeys` 默认以无方块的按键文字显示菜单和行内提示。`tooltip` 变体在深色气泡上使用稍浅的键帽,以加号连接的组合则共用一个填充色块。`Tooltip.shortcutKeys` 将键帽与可选的本地化操作文本垂直居中排列,标签为空时只显示按键。可选的 `className` 供调用方设置交互状态样式。`Modal` 与设置外壳通过 `useModalLayer` 共用顶层 Esc 和 Tab 处理,关闭时恢复先前焦点。`closeTopModal(document)` 请求前台弹窗当前的关闭操作;上层菜单或未注册的对话框会阻止关闭其后方弹窗。组件通过 `isBehindModal` 避免将焦点移到嵌套弹窗后方。菜单先消费自己的 Esc,再由模态层处理关闭。 `observeComposition` 为局部弹层和录键处理提供相同的 composition-end 与旧版 IME 保护;调用方随交互生命周期释放其 document 监听。
弹窗自动进入及弹窗、菜单回焦,包括通过 Esc 和应用关闭快捷键触发的回焦,均使用 `focusWithoutRing(element, options?)`,在 Tab 或方向键导航恢复正常焦点样式前抑制外轮廓线。弹窗容器仍不绘制焦点外框。原有边框、阴影和错误状态保持不变。用 `data-modal-autofocus` 标记弹窗的初始控件,让模态层先保存触发控件,再移动焦点。随弹窗挂载的控件不得使用 React `autoFocus`,因为它会在保存触发控件前执行。弹窗容器获得焦点时,Tab 和 Shift+Tab 分别进入第一个和最后一个可聚焦控件。
@@ -66,7 +66,8 @@ kind: "package-library"
| `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 |
| `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIconRegular`/`ReferenceIconMedium`、`LinkIconRegular`/`LinkIconMedium` | 字形与品牌标识。`LinkIconMedium` 用于 14px 的可点击链接分类及已知站点标记。 |
| `PermissionIconReadOnlyRegular`/`Medium`、`PermissionIconWorkspaceWriteRegular`/`Medium`、`PermissionIconFullAccessRegular`/`Medium` | 只读、工作区写入与完全访问选项使用的权限模式图形。 |
| `PluginArtworkTerminal`/`Loop`/`Subagent`/`Search`/`Default` | 插件管理界面使用的固定配色 36×36 插件插画;`Default` 用于没有自有插画的插件。def id 按实例生成,同一插画可在一页中安全重复。 |
| `PluginArtworkTerminal`/`Loop`/`Subagent`/`Search`/`Default` | 固定配色的 36×36 插件插画;`Terminal` 为插件卡片和侧边栏开始页入口提供浅蓝色提示符。`Default` 用于没有自有插画的插件。def id 按实例生成,同一插画可在一页中安全重复。 |
| `GuideArtworkBrowser`/`Files` | 固定配色的 36×36 浏览器与文件夹插画,用于侧栏引导入口。 |
| `FileTypeIcon`、`classifyFileType`、`fileExtension` | 按类别着色的 28px 文件或文件夹图形,以及它背后共享的不区分大小写文件名映射。代码与配置文件使用细分的全彩技术图形;链接前置图形使用 `LinkIconMedium`,图片内容使用图片预览。 |
| `languageForPath`、`CODE_HIGHLIGHT_EXTENSIONS`、`useCodeHighlighter` | 代码预览与 diff review 共用的惰性逐行 token 高亮。文件名 grammar 选择再导出自 `@deepseek-ai/dsh-util-code-language`,即 read 卡片持久化短 id `lang` 提示背后的同一张扩展名表。 |
@@ -201,11 +201,9 @@
}
.shortcut .shortcutKeys {
height: auto;
padding: 0;
border-radius: 0;
background: transparent;
color: var(--dsw-alias-label-tertiary);
color: var(--dsw-alias-label-caption);
font-size: 11px;
line-height: 16px;
}
.check {
@@ -1,16 +1,33 @@
.keys {
display: inline-flex;
flex: none;
align-items: center;
gap: 3px;
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 16px;
white-space: nowrap;
}
.key {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 3px;
height: 26px;
padding: 6px 16px;
border-radius: 10px;
background: var(--dsw-alias-bg-module-platform);
color: var(--dsw-alias-label-primary);
font-size: 12px;
line-height: 14px;
white-space: nowrap;
font: inherit;
}
.key { font: inherit; }
.tooltip { color: inherit; font-size: 11px; line-height: 14px; gap: 2px; }
.tooltip .key {
min-width: 16px;
height: 16px;
padding: 0 2px;
border-radius: 4px;
border: 0;
background: var(--dsw-alias-tooltip-key-bg);
}
.separator { font: inherit; }
.joined { box-sizing: border-box; height: 16px; padding: 0 4px; border-radius: 4px; background: var(--dsw-alias-tooltip-key-bg); }
.joined .key { min-width: 0; height: auto; padding: 0; border: 0; background: transparent; }
@@ -1,12 +1,12 @@
/** Shared shortcut badges; callers supply the effective platform presentation. */
/** Shared shortcut keycaps; callers supply the effective platform presentation. */
import css from './ShortcutKeys.module.css'
import clsx from 'clsx'
/**
* Render one command's keycaps without owning binding defaults or localized copy.
* @param props - effective key labels and optional interaction styling.
* @returns one rounded badge containing the key sequence.
* @param props - effective key labels, presentation variant and optional interaction styling.
* @returns unboxed keys by default, or tooltip keycaps with plus-separated combinations grouped together.
*/
export function ShortcutKeys({ keys, className }: { keys: readonly string[]; className?: string | undefined }) {
return <span className={clsx(css.keys, className)}>{keys.map((key, index) => <kbd key={index} className={css.key}>{key}</kbd>)}</span>
export function ShortcutKeys({ keys, variant = 'plain', className }: { keys: readonly string[]; variant?: 'plain' | 'tooltip'; className?: string | undefined }) {
return <span className={clsx(css.keys, variant === 'tooltip' && css.tooltip, variant === 'tooltip' && keys.includes('+') && css.joined, className)}>{keys.map((key, index) => <kbd key={index} className={key === '+' ? css.separator : css.key}>{key}</kbd>)}</span>
}
@@ -1,4 +1,7 @@
.bubble {
display: inline-flex;
align-items: center;
gap: 8px;
position: fixed;
z-index: 100;
/* Fixed-position shrink-to-fit measures only the space from `left` to the
@@ -20,6 +23,11 @@
}
.bubble[data-portal] { z-index: 1100; }
.label { min-width: 0; }
/* A 16px keycap centered beside a 20px label has 5px of vertical clearance. */
.bubble[data-has-shortcut] { padding-inline-end: 5px; }
.bubble[data-has-shortcut]:not(:has(.label)) { padding: 3px; }
.bubble[data-side='right'] {
transform: translateY(-50%);
@@ -3,6 +3,7 @@
import { cloneElement, createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
import type { FocusEventHandler, MouseEventHandler, MutableRefObject, ReactElement, Ref } from 'react'
import { createPortal } from 'react-dom'
import { ShortcutKeys } from './ShortcutKeys.tsx'
import css from './Tooltip.module.css'
// Tooltips take the wide answer — any key returns to the keyboard. Focus rings read the
// narrower `data-input-modality` attribute the same module publishes.
@@ -31,7 +32,8 @@ type TooltipLabel = string | (() => string)
/**
* Attach a hover/focus tooltip to an anchor element.
* @param props.label - bubble text, or a resolver evaluated only while the bubble is visible.
* @param props.label - bubble text, or a resolver evaluated only while visible; an empty string shows only shortcut keys.
* @param props.shortcutKeys - effective key labels rendered as platform-formatted keycaps after optional text.
* @param props.side - placement relative to the anchor (default 'right').
* @param props.align - horizontal anchor-edge alignment for 'bottom'/'top' bubbles: 'end' pins
* the bubble's right edge to the anchor's (for anchors beside other hover surfaces the centered
@@ -51,7 +53,7 @@ type TooltipLabel = string | (() => string)
* anchor dismisses the bubble until the next trigger, and focus arriving after a pointer
* interaction (a closing menu refocusing its trigger) never raises it.
*/
export function Tooltip({ label, side = 'right', align = 'center', delayMs = 0, gap = 8, disabled = false, portal = false, maxWidth, children }: { label: TooltipLabel; side?: TooltipSide; align?: 'center' | 'end'; delayMs?: number; gap?: number; disabled?: boolean; portal?: boolean; maxWidth?: number; children: ReactElement<AnchorProps> }) {
export function Tooltip({ label, shortcutKeys, side = 'right', align = 'center', delayMs = 0, gap = 8, disabled = false, portal = false, maxWidth, children }: { label: TooltipLabel; shortcutKeys?: readonly string[] | undefined; side?: TooltipSide; align?: 'center' | 'end'; delayMs?: number; gap?: number; disabled?: boolean; portal?: boolean; maxWidth?: number; children: ReactElement<AnchorProps> }) {
const anchor = useRef<HTMLElement | null>(null)
// React 18 keeps the element's ref outside props; forward it so wrapping an
// anchor in Tooltip never silently severs the owner's ref.
@@ -182,10 +184,13 @@ export function Tooltip({ label, side = 'right', align = 'center', delayMs = 0,
data-side={side}
data-portal={portal || undefined}
data-align={align}
data-has-shortcut={shortcutKeys?.length ? true : undefined}
style={{ left: pos.x, top: y, visibility: 'hidden', ...maxWidth === undefined ? {} : { maxWidth } }}
role="tooltip"
aria-label={shortcutKeys?.length ? [resolvedLabel, shortcutKeys.join(' ')].filter(Boolean).join(' ') : undefined}
>
{resolvedLabel}
{resolvedLabel && <span className={css.label}>{resolvedLabel}</span>}
{shortcutKeys !== undefined && shortcutKeys.length > 0 && <ShortcutKeys keys={shortcutKeys} variant="tooltip" />}
</span>
)
@@ -0,0 +1,32 @@
/** Fixed-palette artwork for sidebar guide entries. */
import type { IconProps } from './icons/props.ts'
/**
* Render fixed-palette artwork for a sidebar guide entry.
* @param props - canvas size and layout class supplied by the guide.
* @returns an ornamental SVG hidden from assistive technology.
*/
export function GuideArtworkBrowser({ size = 36, className }: IconProps) {
return (
<svg width={size} height={size} className={className} aria-hidden="true" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.9995 28.1465C23.6034 28.1465 28.1461 23.6038 28.1461 18C28.1461 12.3963 23.6034 7.85352 17.9995 7.85352C12.3958 7.85352 7.85303 12.3963 7.85303 18C7.85303 23.6038 12.3958 28.1465 17.9995 28.1465Z" stroke="#539CFA" strokeWidth="2" />
<path d="M8.57764 18H27.4211" stroke="#539CFA" strokeWidth="2" strokeLinecap="square" />
<path d="M17.999 28.1467C20.0576 28.1467 21.6228 23.6039 21.6228 18C21.6228 12.3963 20.0576 7.85352 17.999 7.85352" stroke="#539CFA" strokeWidth="2" />
<path d="M17.9992 28.1467C15.9407 28.1467 14.3755 23.6039 14.3755 18C14.3755 12.3963 15.9407 7.85352 17.9992 7.85352" stroke="#539CFA" strokeWidth="2" />
</svg>
)
}
/**
* Render fixed-palette artwork for a sidebar guide entry.
* @param props - canvas size and layout class supplied by the guide.
* @returns an ornamental SVG hidden from assistive technology.
*/
export function GuideArtworkFiles({ size = 36, className }: IconProps) {
return (
<svg width={size} height={size} className={className} aria-hidden="true" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.7603 27.922H24.6817C26.3441 27.922 27.1753 27.922 27.8102 27.5984C28.3687 27.3139 28.8228 26.8598 29.1074 26.3012C29.4309 25.6663 29.4309 24.8351 29.4309 23.1727V15.4936" stroke="#FFCD78" strokeWidth="1.97886" />
<path d="M13.1597 8.07812C13.4182 8.07818 13.6727 8.14336 13.8989 8.26855L16.7554 9.84961C16.9817 9.97485 17.2369 10.041 17.4956 10.041H26.106C26.9492 10.0412 27.6323 10.7251 27.6323 11.5684V24.5371C27.6323 25.3805 26.9483 26.0645 26.105 26.0645H8.09619C7.25281 26.0645 6.56884 25.3805 6.56885 24.5371V9.60449C6.56909 8.76133 7.25297 8.07812 8.09619 8.07812H13.1597ZM9.81592 14.5508V16.5293H24.3999V14.5508H9.81592Z" fill="#FFBC4D" />
</svg>
)
}
@@ -107,5 +107,6 @@ export * from './icons/index.tsx'
export {
PluginArtworkTerminal, PluginArtworkLoop, PluginArtworkSubagent, PluginArtworkSearch, PluginArtworkDefault,
} from './plugin-artwork.tsx'
export { GuideArtworkBrowser, GuideArtworkFiles } from './guide-artwork.tsx'
export { ImageLightbox } from './ImageLightbox.tsx'
export type { ImageLightboxLabels } from './ImageLightbox.tsx'
@@ -12,11 +12,11 @@ import type { IconProps } from './icons/props.ts'
*/
const useArtworkId = (): string => `dsh_plugin_art_${useId().replaceAll(':', '')}`
/** Terminal plugin artwork (prompt chevron and cursor bar). */
/** Light-blue terminal artwork shared by plugin cards and sidebar guide entries. */
export const PluginArtworkTerminal = ({ size = 36, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 11L16.606 17.606C16.6841 17.6841 16.6841 17.8107 16.606 17.8888L10 24.4948" stroke="#145AF3" strokeWidth="3.5" />
<path d="M20.1211 24.4946H26.8685" stroke="#145AF3" strokeWidth="3.5" />
<path d="M10 11L16.606 17.606C16.6841 17.6841 16.6841 17.8107 16.606 17.8888L10 24.4948" stroke="#679EFE" strokeWidth="3.5" />
<path d="M20.1211 24.4946H26.8685" stroke="#679EFE" strokeWidth="3.5" />
</svg>
)
@@ -2,12 +2,15 @@
import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import {
GuideArtworkBrowser, GuideArtworkFiles,
PluginArtworkDefault, PluginArtworkLoop, PluginArtworkSearch, PluginArtworkSubagent, PluginArtworkTerminal,
} from '@deepseek-ai/dsh-client-ui-primitives'
afterEach(cleanup)
const artworks = {
GuideArtworkBrowser,
GuideArtworkFiles,
PluginArtworkTerminal,
PluginArtworkLoop,
PluginArtworkSubagent,
@@ -39,6 +39,18 @@ afterEach(() => {
})
describe('Tooltip', () => {
it('updates independent keycaps and the accessible combination while visible', () => {
const view = render(<Tooltip label="Reload" shortcutKeys={['⌘', 'R']}><button>anchor</button></Tooltip>)
fireEvent.focus(screen.getByText('anchor'))
expect(Array.from(screen.getByRole('tooltip', { name: 'Reload ⌘ R' }).querySelectorAll('kbd'), key => key.textContent)).toEqual(['⌘', 'R'])
view.rerender(<Tooltip label="Reload" shortcutKeys={['Ctrl', '+', 'R']}><button>anchor</button></Tooltip>)
expect(Array.from(screen.getByRole('tooltip', { name: 'Reload Ctrl + R' }).querySelectorAll('kbd'), key => key.textContent)).toEqual(['Ctrl', '+', 'R'])
view.rerender(<Tooltip label="Reload" shortcutKeys={[]}><button>anchor</button></Tooltip>)
expect(screen.getByRole('tooltip').querySelector('kbd')).toBeNull()
fireEvent.click(screen.getByText('anchor'))
expect(screen.queryByRole('tooltip')).toBeNull()
})
it('fits from observed sizes without synchronously measuring the bubble', () => {
automaticResize = false
const measured = vi.spyOn(Element.prototype, 'getBoundingClientRect')
@@ -218,7 +218,7 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
wide, settingsOpen: open, openSettings: actions.open,
...(shortcut?.keys.length ? { settingsShortcut: { keys: shortcut.keys, aria: shortcut.aria } } : {}),
openOnboarding: (id) => { close(); setRequestedOnboarding(id) },
}, { fallback: <Tooltip disabled={open} label={shortcut?.keys.length ? t('shortcut.hint', { label: t('trigger'), keys: shortcut.keys.join(' ') }) : t('trigger')}>
}, { fallback: <Tooltip disabled={open} label={t('trigger')} shortcutKeys={shortcut?.keys}>
<button
type="button"
className={clsx(css.trigger, !wide && css.rail)}
@@ -2,7 +2,6 @@
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'shortcut.hint': '{label} {keys}',
'trigger': '设置',
'shortcut.open': '打开设置',
'desktop.update.available': '新版本',
@@ -44,7 +43,6 @@ export type SettingsKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'shortcut.hint': '{label} {keys}',
'trigger': 'Settings',
'shortcut.open': 'Open settings',
'desktop.update.available': 'Update',
@@ -513,7 +513,7 @@ it('shows the effective settings binding on focus and exposes it to assistive te
const trigger = screen.getByRole('button', { name: 'Settings' })
expect(trigger.getAttribute('aria-keyshortcuts')).toBe('Meta+,')
fireEvent.focus(trigger)
expect(screen.getByRole('tooltip').textContent).toBe('Settings ⌘ ,')
expect(screen.getByRole('tooltip').getAttribute('aria-label')).toBe('Settings ⌘ ,')
})
it('passes current Settings key labels to the launcher and removes them when unbound', () => {
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 0cd57b7b8d8c9b59
/deepseek-ai-dsh-client-ui-shortcuts/use-this-package:
en: 527535d67439c5f5
zh: 6eabb96b25c4d080
en: 2d35beb03b54d4ea
zh: 12f3c6ec1a79c673
/deepseek-ai-dsh-client-ui-shortcuts/understand-the-implementation:
en: f56f2610e7258825
zh: 249d7cdd55c57166
+4 -4
View File
@@ -25,15 +25,15 @@ Browse the commands available in the current window and find them by action, Eng
<a id="use-this-package"></a>
## Use this package
The Web app bundle mounts this package together with `shortcuts`. The reference shows only registered commands and existing fixed input actions. Desktop shows the effective device keycaps; commands without a Web default show No shortcut. The Settings entry and Mod+/ open the same single dialog, titled “Keyboard shortcuts” in English and “快捷键” in Chinese. Its 480×600 CSS-pixel card shrinks to fit smaller windows; the header and search remain above an internally scrolling list. The search field receives focus on open without changing its border color. Automatic entry and return focus in the reference, recorder, and reset confirmation omit outlines, including returns after Escape and application close shortcuts. Containers remain outline-free, and Tab and directional navigation retain their visible focus indicators. The Settings entry uses a filled capsule button without a dropdown icon and reads the same effective binding for its tooltip and `aria-keyshortcuts`; both are absent without a binding.
The Web app bundle mounts this package together with `shortcuts`. The reference shows only registered commands and existing fixed input actions. Desktop shows the effective device keys; commands without a Web default show No shortcut. The Settings entry and Mod+/ open the same single dialog, titled “Keyboard shortcuts” in English and “快捷键” in Chinese. Its 480×600 CSS-pixel card shrinks to fit smaller windows; the header and search remain above an internally scrolling list. The search field receives focus on open without changing its border color. Automatic entry and return focus in the reference, recorder, and reset confirmation omit outlines, including returns after Escape and application close shortcuts. Containers remain outline-free, and Tab and directional navigation retain their visible focus indicators. The Settings entry is labeled “Edit shortcuts”, with an “Open from anywhere” shortcut tooltip, and uses a filled button with the same standard radius as adjacent settings controls and no dropdown icon and reads the same effective binding for its tooltip and `aria-keyshortcuts`; both are absent without a binding.
`Mod+/` opens the reference when no modal is open or Settings is in front, and closes it when the reference is in front. While the reference is in front, the Settings command cannot open or close Settings. Held-key repeats are ignored. Recording, pending writes, and a nested confirmation prevent the reference shortcut from closing the dialog.
Core actions follow a fixed product order, independent of plugin registration, unloading, or remounting. Stopping the current response ends the core list and remains read-only. Extension commands outside that order follow the core actions in stable command ID order; other fixed actions retain their groups.
Core actions follow a fixed product order, independent of plugin registration, unloading, or remounting. Stopping the current response ends the Message input group and remains read-only. Extension commands outside that order follow the core actions in stable command ID order; other fixed actions retain their groups.
Search matches an ordered subsequence within one label, English alias, or key combination, ignoring case. Each matching command appears once. Search relevance takes precedence; equal matches retain the display order. Application and fixed-action rows remain 42 CSS pixels high during normal display and recording; long command names are truncated. Application rows show command names and binding controls without availability subtitles or reason tooltips, including conflicting and invalid bindings. Commands that cannot execute remain silent. Fixed actions show disabled, dimmed key buttons without a separate status label; they cannot enter recording and do not highlight on hover.
Search matches an ordered subsequence within one label, English alias, or key combination, ignoring case. Each matching command appears once. Search relevance takes precedence; equal matches retain the display order. Application and fixed-action rows remain 42 CSS pixels high during normal display and recording; long command names are truncated. Application rows show command names and binding controls without availability subtitles or reason tooltips, including conflicting and invalid bindings. Commands that cannot execute remain silent. Both platforms show each editable row’s complete key combination in one settings-control grey block with secondary ink. Fixed actions use unboxed tertiary text, cannot enter recording, and do not highlight on hover. Group titles align with command labels.
Hover an application row or focus it with the keyboard to reveal its actions. The key badge highlights only while hovered; selecting it or Edit starts the same inline recorder. Unbound commands omit both remove controls. Windows and macOS Desktop record one or two overlapping non-modifier keys with optional modifiers; a third key rejects the draft. Releasing the first key saves the current combination, so sequential A then B records only A. Saving rejects combinations occupied by editable or fixed actions, including overlapping single-key and two-key bindings. The dialog close control exits recording. The recorder keeps the same height as key badges and uses a single border. Other environments retain Escape cancellation and Tab navigation. Saving and cancellation return focus to the dialog. macOS Web can record Option+Command+N reported as a dead key; composition and ordinary accent input remain protected. Remove clears the binding; Restore Default removes only that command’s override. System toasts announce successful writes and failures; a rejected combination marks the recorder with an error border. Release its non-modifier keys to record another combination without clicking; modifiers may remain held. Repeated identical errors leave a visible toast and its dismissal time unchanged; different errors and successful writes replace it immediately. An error can appear again after dismissal. Failed writes retain recorder focus and the draft for Retry Save or another recording. Configuration or command-catalog changes require review before a draft can be retried. Read failures disable editing and identify the active bindings and affected document in a system toast: Web uses the current origin’s localStorage entry `dsh.keybindings.v1`; Desktop uses `userData/keybindings.json`. Back up and repair damaged data before reloading the page or restarting Harness; upgrade Harness for a document written by a newer version. Restore All never overwrites unreadable data. The footer shows Restore All Defaults and the current runtime/platform’s override count, including removed bindings and commands that are not currently mounted. Search does not change the count. The action is disabled without overrides. Its confirmation initially focuses Cancel; cancellation returns focus to Restore All Defaults, including when opened from the recorder. Confirming restores only this profile. A stale revision closes the confirmation and requires another confirmation against the latest configuration; a failed write retains the accepted bindings and allows retry.
Hover an application row to highlight its rounded surface and reveal the edit icon; keyboard focus also reveals the icon. Clicking anywhere on the row starts its inline recorder. Unbound commands omit the inline Remove action. Windows and macOS Desktop record one or two overlapping non-modifier keys with optional modifiers; a third key rejects the draft. Releasing the first key saves the current combination, so sequential A then B records only A. Saving rejects combinations occupied by editable or fixed actions, including overlapping single-key and two-key bindings. The dialog close control exits recording. The recorder keeps the same height as key badges and uses a neutral grey border with secondary text. Other environments retain Escape cancellation and Tab navigation. Saving and cancellation return focus to the dialog. Clicking blank dialog space outside the inline editor closes it and discards any held combination; clicking its status text preserves the draft. macOS Web can record Option+Command+N reported as a dead key; composition and ordinary accent input remain protected. Remove clears the binding; Restore Default removes only that command’s override. System toasts announce successful writes and failures; a rejected combination marks the recorder with an error border. Release its non-modifier keys to record another combination without clicking; modifiers may remain held. Repeated identical errors leave a visible toast and its dismissal time unchanged; different errors and successful writes replace it immediately. An error can appear again after dismissal. Failed writes retain recorder focus and the draft for Retry Save or another recording. Configuration or command-catalog changes require review before a draft can be retried. Read failures disable editing and identify the active bindings and affected document in a system toast: Web uses the current origin’s localStorage entry `dsh.keybindings.v1`; Desktop uses `userData/keybindings.json`. Back up and repair damaged data before reloading the page or restarting Harness; upgrade Harness for a document written by a newer version. Restore All never overwrites unreadable data. The footer shows Restore All Defaults and the current runtime/platform’s override count, including removed bindings and commands that are not currently mounted. Search does not change the count. At zero overrides, the count is hidden and the action is disabled. Its confirmation initially focuses Cancel; cancellation returns focus to Restore All Defaults, including when opened from the recorder. Confirming restores only this profile. A stale revision closes the confirmation and requires another confirmation against the latest configuration; a failed write retains the accepted bindings and allows retry.
-----
+4 -4
View File
@@ -25,15 +25,15 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用此包
Web 应用 bundle 将此包与 `shortcuts` 一同挂载。速查仅展示已注册命令和现有固定输入操作。桌面端展示设备对应的有效键帽;没有 Web 默认键位的命令显示“暂无快捷键”。设置入口与 Mod+/打开同一个单实例弹窗,中文标题为“快捷键”,英文标题为“Keyboard shortcuts”。卡片尺寸为 480×600 CSS 像素,小窗口中缩小以保持可见;标题和搜索框位于内部滚动列表上方。打开时聚焦搜索框,边框颜色保持不变。速查窗、录键框及重置确认框内的自动进入和回焦均不绘制外轮廓线,包括通过 Esc 和应用关闭快捷键触发的回焦。容器仍不绘制焦点外框,Tab 和方向键导航保留可见焦点提示。设置入口使用无下拉图标的填充胶囊按钮,并从同一有效绑定读取提示及 `aria-keyshortcuts`;没有绑定时不显示提示,也不设置该属性。
Web 应用 bundle 将此包与 `shortcuts` 一同挂载。速查仅展示已注册命令和现有固定输入操作。桌面端展示设备对应的有效键位;没有 Web 默认键位的命令显示“暂无快捷键”。设置入口与 Mod+/打开同一个单实例弹窗,中文标题为“快捷键”,英文标题为“Keyboard shortcuts”。卡片尺寸为 480×600 CSS 像素,小窗口中缩小以保持可见;标题和搜索框位于内部滚动列表上方。打开时聚焦搜索框,边框颜色保持不变。速查窗、录键框及重置确认框内的自动进入和回焦均不绘制外轮廓线,包括通过 Esc 和应用关闭快捷键触发的回焦。容器仍不绘制焦点外框,Tab 和方向键导航保留可见焦点提示。设置入口标为“编辑快捷键”,快捷键提示文案为“全局唤起查看”,使用与相邻设置控件一致的标准圆角填充按钮,不带下拉图标,并从同一有效绑定读取提示及 `aria-keyshortcuts`;没有绑定时不显示提示,也不设置该属性。
没有模态弹窗或设置位于最前方时,`Mod+/` 打开速查;速查位于最前方时,该快捷键关闭速查。速查位于前方时,设置命令不能打开或关闭设置。长按产生的重复按键不执行操作。录键期间、写入尚未完成或嵌套确认框打开时,速查快捷键不会关闭弹窗。
核心操作采用固定产品顺序,不受插件注册、卸载或重新挂载影响。停止当前回复位于核心列表末尾,仍为只读项。未列入该顺序的扩展命令在核心操作之后按命令 ID 稳定排列;其余固定操作保留原有分组。
核心操作采用固定产品顺序,不受插件注册、卸载或重新挂载影响。停止当前回复位于“消息输入”分组末尾,仍为只读项。未列入该顺序的扩展命令在核心操作之后按命令 ID 稳定排列;其余固定操作保留原有分组。
搜索在单个操作名称、英文别名或按键组合内按字符顺序模糊匹配,忽略大小写。每个命中命令只出现一次。搜索时优先按相关性排序,同分项保留展示顺序。正常显示和录制时,应用操作行和固定操作行均保持 42 CSS 像素高;过长的命令名称会被截断。应用命令行只显示名称和改键控件,不显示可用性副标题或原因悬浮提示,冲突和无效绑定也不例外。无法执行的命令静默结束。固定操作使用灰色禁用的键位按钮,不额外显示状态文字;按钮不能进入录键,悬停时不高亮。
搜索在单个操作名称、英文别名或按键组合内按字符顺序模糊匹配,忽略大小写。每个命中命令只出现一次。搜索时优先按相关性排序,同分项保留展示顺序。正常显示和录制时,应用操作行和固定操作行均保持 42 CSS 像素高;过长的命令名称会被截断。应用命令行只显示名称和改键控件,不显示可用性副标题或原因悬浮提示,冲突和无效绑定也不例外。无法执行的命令静默结束。两个平台均将可编辑行的完整快捷键放在同一个设置控件灰色色块中,使用次级文字色。固定操作使用无背景的三级文字色,不能进入录键,悬停时不高亮。分组标题与命令名称左对齐。
悬停应用操作行或通过键盘聚焦可显示操作图标。键位按钮仅在自身悬停时加深底色;点击它或编辑图标均进入同一行内录键。未绑定的命令不显示删除图标和“移除”文字按钮。Windows 和 macOS Desktop 可录制一至两个按住时间重叠的非修饰键,可搭配修饰键;第三个键会使草稿被拒绝。释放第一个键即保存当前组合,因此先后按 A、B 只录入 A。保存会拒绝被可编辑或固定操作占用的组合,包括相互重叠的单键与双键绑定。弹窗关闭控件可退出录制。录制框与键位标签保持相同高度,边框为单层。其他环境保留 Escape 取消和 Tab 导航。保存及取消后焦点返回弹窗。macOS Web 可录制被报告为 dead key 的 Option+Command+N;输入法组合和普通重音输入仍受保护。移除清空绑定,恢复默认只删除该命令的覆盖。系统提示反馈保存成功和失败;组合被拒绝时,录键框显示错误边框。松开非修饰键后即可直接录入新组合,无需再次点击,修饰键可继续按住。同一错误重复发生时沿用仍在显示的提示及其消失时间;不同错误和保存成功立即替换提示。提示消失后,同类错误可再次显示。写入失败保留录键框焦点和草稿,可重试保存或直接录入新组合。配置或命令目录变化后,需要核对最新配置才能重试草稿。读取失败时禁用编辑,并通过系统提示说明当前使用的键位及受影响的配置位置:Web 使用当前来源的 localStorage 项 `dsh.keybindings.v1`;Desktop 使用 `userData/keybindings.json`。内容损坏时先备份并修复配置,再刷新页面或重新启动 Harness;配置由更新版本写入时应升级 Harness。“恢复全部默认”不会覆盖无法读取的数据。底部显示“恢复全部默认”和当前运行环境及平台的自定义项数,包括已移除的绑定和当前未挂载的命令。搜索不影响计数。没有自定义项时,该操作禁用。确认框默认聚焦“取消”;取消后焦点返回“恢复全部默认”,从录制框打开确认时也如此。确认后仅恢复当前配置分组。配置版本过期时关闭确认框,需要针对最新配置重新确认;写入失败保留已接受的绑定,允许重试。
悬停应用操作行时,整行显示圆角高亮并露出编辑图标;键盘聚焦也会显示该图标。点击行内任意位置进入行内录键。未绑定的命令不显示行内“移除”按钮。Windows 和 macOS Desktop 可录制一至两个按住时间重叠的非修饰键,可搭配修饰键;第三个键会使草稿被拒绝。释放第一个键即保存当前组合,因此先后按 A、B 只录入 A。保存会拒绝被可编辑或固定操作占用的组合,包括相互重叠的单键与双键绑定。弹窗关闭控件可退出录制。录制框与键位标签保持相同高度,使用中性灰边框与次级文字色。其他环境保留 Escape 取消和 Tab 导航。保存及取消后焦点返回弹窗。点击行内编辑区外的弹窗空白处会关闭编辑区,并丢弃尚未释放的组合;点击编辑区内的状态文字会保留草稿。macOS Web 可录制被报告为 dead key 的 Option+Command+N;输入法组合和普通重音输入仍受保护。移除清空绑定,恢复默认只删除该命令的覆盖。系统提示反馈保存成功和失败;组合被拒绝时,录键框显示错误边框。松开非修饰键后即可直接录入新组合,无需再次点击,修饰键可继续按住。同一错误重复发生时沿用仍在显示的提示及其消失时间;不同错误和保存成功立即替换提示。提示消失后,同类错误可再次显示。写入失败保留录键框焦点和草稿,可重试保存或直接录入新组合。配置或命令目录变化后,需要核对最新配置才能重试草稿。读取失败时禁用编辑,并通过系统提示说明当前使用的键位及受影响的配置位置:Web 使用当前来源的 localStorage 项 `dsh.keybindings.v1`;Desktop 使用 `userData/keybindings.json`。内容损坏时先备份并修复配置,再刷新页面或重新启动 Harness;配置由更新版本写入时应升级 Harness。“恢复全部默认”不会覆盖无法读取的数据。底部显示“恢复全部默认”和当前运行环境及平台的自定义项数,包括已移除的绑定和当前未挂载的命令。搜索不影响计数。没有自定义项时隐藏计数,并禁用该操作。确认框默认聚焦“取消”;取消后焦点返回“恢复全部默认”,从录制框打开确认时也如此。确认后仅恢复当前配置分组。配置版本过期时关闭确认框,需要针对最新配置重新确认;写入失败保留已接受的绑定,允许重试。
-----
@@ -129,7 +129,6 @@ EditorProps) {
// macOS can omit the character keyup while Command is held.
if (desktopChords && (held.size === 0 || platform === 'macos' && /^Meta(Left|Right)$/u.test(event.code))) { held.clear(); blocked = false }
if (binding === null) return
if (desktopChords && document.activeElement !== recorder.current) { reset(); return }
handlers.current.capture(binding, targetId)
}
const blur = (): void => { reset(); if (desktopChords) { setCandidate(null); setCaptured(false) } }
@@ -167,6 +166,7 @@ EditorProps) {
{target.binding !== null && <button type="button" className={css.inlineAction} disabled={readonly}
onClick={() => { void save({ type: 'set', id: target.id, binding: null }) }}>{t('clear')}</button>}
<button ref={recorder} type="button" className={clsx(css.recorder, message && css.invalid)} disabled={!nativeReady} aria-disabled={busy || !nativeReady}
onBlur={() => { restart.current() }}
aria-label={t('record')} aria-invalid={message !== ''} aria-describedby={descriptionId}
onClick={() => { if (!writing.current) { restart.current(); setCaptured(false); setMessage(''); setRetry(null) } }}>
{captured && message === '' ? <ShortcutKeys keys={describeBinding(candidate).keys} className={css.recorded} /> : t('record')}
@@ -1,7 +1,6 @@
/** Supplied shortcut-control and feedback artwork, colored by the owning UI. */
const artwork = {
edit: <><path d="M7.4553 1.01221C8.02829 0.676881 8.73746 0.676959 9.31048 1.01221C9.52928 1.14026 9.72631 1.34353 9.98255 1.59978C10.2388 1.85601 10.4421 2.05305 10.5701 2.27185C10.9053 2.84485 10.9054 3.55407 10.5701 4.12703C10.4421 4.34576 10.2387 4.54293 9.98255 4.7991L4.99422 9.78744C4.71024 10.0714 4.50494 10.2832 4.2488 10.4324C3.99266 10.5815 3.70709 10.6557 3.32001 10.7625L2.43268 11.0072C2.08118 11.1042 1.75876 11.1948 1.50071 11.2265C1.2387 11.2585 0.884455 11.2512 0.607798 10.9745C0.331166 10.6979 0.323805 10.3436 0.35587 10.0816C0.387501 9.82358 0.47815 9.50112 0.575111 9.14964L0.819864 8.26232C0.926638 7.87525 1.00083 7.58966 1.14992 7.33353C1.2991 7.07739 1.5109 6.87209 1.79489 6.58811L6.78322 1.59978C7.03941 1.34359 7.23656 1.14027 7.4553 1.01221ZM11.6568 11.13H5.6639L6.72503 10.0689H11.6568V11.13ZM2.54509 7.33831C2.22348 7.65993 2.13155 7.75784 2.06755 7.86768C2.0036 7.97757 1.96369 8.10606 1.84272 8.54454L1.59797 9.43187C1.5011 9.78304 1.44118 10.0065 1.4162 10.1653C1.57504 10.1404 1.79876 10.0814 2.15046 9.98436L3.03779 9.7396C3.47631 9.61863 3.60475 9.57874 3.71464 9.51478C3.82448 9.45077 3.92239 9.35885 4.24401 9.03723L8.04047 5.23998L6.34155 3.54106L2.54509 7.33831ZM8.77473 1.92824C8.53277 1.78664 8.233 1.78664 7.99104 1.92824C7.91987 1.97 7.83628 2.04713 7.53343 2.34998L7.09176 2.79085L8.79068 4.48977L9.23235 4.0489C9.53517 3.74608 9.61232 3.66245 9.65409 3.59128C9.79567 3.34935 9.79564 3.04954 9.65409 2.8076C9.61237 2.73635 9.53565 2.65329 9.23235 2.34998C8.92899 2.04662 8.84598 1.96995 8.77473 1.92824Z" /></>,
trash: <><path d="M10.8591 3.6309L10.6608 7.58679C10.5831 9.14078 10.5507 9.759 10.0404 10.471C9.82871 10.7663 9.56281 11.0188 9.25726 11.2155C8.86839 11.4658 8.43152 11.5741 7.91934 11.6252C7.41588 11.6755 6.78752 11.6746 6.00041 11.6746C5.21331 11.6746 4.58495 11.6755 4.08149 11.6252C3.56931 11.5741 3.13244 11.4658 2.74357 11.2155C2.43802 11.0188 2.17212 10.7663 1.96042 10.471C1.45015 9.759 1.41777 9.14078 1.34007 7.58679L1.14177 3.6309L2.16797 3.57998L2.3655 7.5351C2.44708 9.16675 2.49381 9.45152 2.79527 9.87221C2.93161 10.0624 3.10308 10.2255 3.29988 10.3521C3.49696 10.479 3.7524 10.5598 4.18411 10.6029C4.62464 10.6469 5.19266 10.6477 6.00041 10.6477C6.80817 10.6477 7.37618 10.6469 7.81671 10.6029C8.24843 10.5598 8.50387 10.479 8.70095 10.3521C8.89775 10.2255 9.06922 10.0624 9.20556 9.87221C9.50702 9.45152 9.55375 9.16674 9.63533 7.5351L9.83285 3.57998L10.8591 3.6309ZM4.07301 4.67176H5.09998V8.54355H4.07301V4.67176ZM6.90085 4.67176H7.92782V8.54355H6.90085V4.67176ZM6.40241 0.326222C6.88525 0.326222 7.23958 0.320593 7.5729 0.428842C7.67721 0.462737 7.77917 0.504814 7.8769 0.55461C8.1891 0.713729 8.43522 0.968642 8.77656 1.30999L9.37376 1.90642H11.5311V2.93416H0.469727V1.90642H2.62707L3.22427 1.30999C3.56561 0.968643 3.81173 0.713729 4.12393 0.55461C4.22166 0.504814 4.32362 0.462737 4.42793 0.428842C4.76125 0.320593 5.11558 0.326222 5.59842 0.326222H6.40241ZM5.59842 1.35397C5.04948 1.35397 4.88773 1.3593 4.74505 1.40566C4.69201 1.4229 4.64044 1.4444 4.59074 1.4697C4.47341 1.52948 4.36804 1.62229 4.08149 1.90642H7.91934C7.63279 1.62229 7.52742 1.52948 7.41009 1.4697C7.36039 1.4444 7.30882 1.4229 7.25578 1.40566C7.1131 1.3593 6.95135 1.35397 6.40241 1.35397H5.59842Z" /></>,
error: <><path d="M8.90039 3.97328V9.07973H7.09961V3.97328H8.90039Z" /><path d="M8.90039 10.341V12.3654H7.09961V10.341H8.90039Z" /><path d="M14.5996 8C14.5996 4.35492 11.6451 1.40039 8 1.40039C4.35492 1.40039 1.40039 4.35492 1.40039 8C1.40039 11.6451 4.35492 14.5996 8 14.5996C11.6451 14.5996 14.5996 11.6451 14.5996 8ZM15.9004 8C15.9004 12.363 12.363 15.9004 8 15.9004C3.63695 15.9004 0.0996094 12.363 0.0996094 8C0.0996094 3.63695 3.63695 0.0996094 8 0.0996094C12.363 0.0996094 15.9004 3.63695 15.9004 8Z" /></>,
success: <><path d="M12.5303 6.53027L8.80273 10.2578C8.54967 10.5109 8.31796 10.7439 8.10645 10.9141C7.88375 11.0932 7.616 11.2602 7.27344 11.3145C7.09229 11.3431 6.90771 11.3431 6.72656 11.3145C6.384 11.2602 6.11625 11.0932 5.89355 10.9141C5.68204 10.7439 5.45033 10.5109 5.19727 10.2578L3.46973 8.53027L4.53027 7.46973L6.25781 9.19727C6.53457 9.47402 6.70036 9.63859 6.83398 9.74609C6.95637 9.84453 6.98241 9.83644 6.96094 9.83301C6.98679 9.83709 7.01321 9.83709 7.03906 9.83301C7.01759 9.83644 7.04363 9.84453 7.16602 9.74609C7.29964 9.63859 7.46543 9.47402 7.74219 9.19727L11.4697 5.46973L12.5303 6.53027Z" /><path d="M14.5996 8C14.5996 4.35492 11.6451 1.40039 8 1.40039C4.35492 1.40039 1.40039 4.35492 1.40039 8C1.40039 11.6451 4.35492 14.5996 8 14.5996C11.6451 14.5996 14.5996 11.6451 14.5996 8ZM15.9004 8C15.9004 12.363 12.363 15.9004 8 15.9004C3.63695 15.9004 0.0996094 12.363 0.0996094 8C0.0996094 3.63695 3.63695 0.0996094 8 0.0996094C12.363 0.0996094 15.9004 3.63695 15.9004 8Z" /></>,
}
@@ -12,6 +11,6 @@ const artwork = {
* @returns an ornamental SVG hidden from assistive technology.
*/
export function ShortcutIcon({ kind, className }: { kind: keyof typeof artwork; className?: string | undefined }) {
const size = kind === 'edit' || kind === 'trash' ? 12 : 16
const size = kind === 'edit' ? 12 : 16
return <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} fill="currentColor" className={className} aria-hidden="true">{artwork[kind]}</svg>
}
@@ -39,7 +39,7 @@
max-width: 280px;
padding: 0 14px;
border: none;
border-radius: 18px;
border-radius: var(--dsw-radius-md);
background: var(--dsw-alias-bg-module-platform);
color: var(--dsw-alias-label-primary);
font: inherit;
@@ -61,6 +61,7 @@
box-shadow: 0 0 1px rgba(0, 0, 0, 0.2), 0 0 4px rgba(0, 0, 0, 0.02), 0 12px 32px rgba(0, 0, 0, 0.08);
}
.dialog:focus { outline: none; }
.contents { display: flex; flex-direction: column; flex: 1; min-height: 0; }
.header {
position: relative;
flex: 0 0 46px;
@@ -92,10 +93,11 @@
}
.close:hover { background: var(--dsw-alias-interactive-bg-hover); }
.searchRow {
flex: 0 0 72px;
box-sizing: border-box;
flex: 0 0 60px;
display: flex;
align-items: center;
padding: 0 24px;
padding: 14px 20px 10px;
}
.searchField {
box-sizing: border-box;
@@ -130,7 +132,7 @@
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding: 0 24px 18px;
padding: 0 16px 18px;
}
.footer {
flex: none;
@@ -138,7 +140,7 @@
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 60px;
min-height: 52px;
padding: 0 24px;
border-top: 0.5px solid var(--dsw-alias-border-l2);
font-size: 12px;
@@ -152,7 +154,7 @@
border: 0;
border-radius: 4px;
background: transparent;
color: var(--dsw-alias-label-primary);
color: var(--dsw-alias-label-secondary);
font: inherit;
cursor: pointer;
}
@@ -161,7 +163,7 @@
.resetAll:disabled { cursor: default; opacity: 0.45; }
.modifiedCount { color: var(--dsw-alias-label-tertiary); }
.group {
margin: 18px 0 4px;
margin: 18px 8px 4px;
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 18px;
@@ -169,32 +171,37 @@
}
.rows { padding: 0; margin: 0; list-style: none; }
.row {
position: relative;
border-radius: var(--dsw-radius-md);
box-sizing: border-box;
min-height: 42px;
padding: 8px 0;
padding: 8px;
color: var(--dsw-alias-label-primary);
font-size: 13px;
line-height: 18px;
}
.binding { flex-shrink: 0; }
.commandLabel { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.binding { position: relative; flex-shrink: 0; pointer-events: none; }
.binding:has(.inlineEditor) { pointer-events: auto; }
.commandLabel { position: relative; pointer-events: none; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hint { color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 18px; }
p.hint { margin: 6px 0; }
/* Keyboard focus reveals actions without retaining a mouse interaction's hover state. */
.rowActions { display: inline-flex; align-items: center; gap: 8px; opacity: 0; }
.rowActions { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 26px; color: var(--dsw-alias-label-tertiary); opacity: 0; }
.row:hover .rowActions, .row:has(:focus-visible) .rowActions { opacity: 1; }
.iconButton { display: inline-grid; place-items: center; width: 20px; height: 26px; border: 0; padding: 0; background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; }
.iconButton:focus-visible, .inlineAction:focus-visible { outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 2px; border-radius: 4px; }
.keyButton { display: inline-flex; padding: 0; border: 0; border-radius: 10px; background: transparent; cursor: pointer; }
.keyButton:focus-visible { outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 2px; }
.keyButton:enabled:hover .keyBadge, .keyButton:enabled:hover .unbound { background: var(--dsw-alias-interactive-bg-hover-accent); }
.inlineAction:focus-visible { outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 2px; border-radius: 4px; }
.rowButton { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; border-radius: inherit; background: transparent; cursor: pointer; }
.row:has(.rowButton:enabled):hover { background: var(--dsw-alias-interactive-bg-hover); }
.rowButton:focus-visible { outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: -1px; }
.binding .keyBadge { color: var(--dsw-alias-label-secondary); }
.binding .fixedKeyBadge { height: 26px; color: var(--dsw-alias-label-tertiary); }
.keyBadge { box-sizing: border-box; justify-content: center; height: 26px; padding: 0 12px; border-radius: 10px; background: var(--dsw-alias-bg-module-platform); }
.unbound { display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; height: 26px; padding: 6px 16px; border-radius: 10px; background: var(--dsw-alias-bg-module-platform); color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 14px; white-space: nowrap; }
.inlineEditor { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
.binding:has(.inlineEditor) { max-width: 100%; }
.inlineControls { display: flex; justify-content: flex-end; align-items: center; gap: 8px; }
.inlineAction { padding: 0; border: 0; background: transparent; color: var(--dsw-alias-label-tertiary); font: inherit; font-size: 12px; line-height: 18px; white-space: nowrap; cursor: pointer; }
.recorder { display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; min-width: 96px; height: 26px; padding: 3px 14px; border: 1px solid var(--dsw-alias-label-tertiary); border-radius: 10px; background: var(--dsw-alias-bg-module-platform); color: var(--dsw-alias-label-tertiary); font: inherit; font-size: 12px; line-height: 18px; white-space: nowrap; cursor: pointer; }
.recorder { display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; min-width: 96px; height: 26px; padding: 3px 14px; border: 0.5px solid var(--dsw-alias-border-l4); border-radius: 10px; background: var(--dsw-alias-bg-module-platform); color: var(--dsw-alias-label-secondary); font: inherit; font-size: 12px; line-height: 18px; white-space: nowrap; cursor: pointer; }
.recorder:focus-visible { outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 2px; }
.recorder.invalid { border: 1px solid var(--dsw-alias-state-error-primary); box-shadow: none; }
.recorded { height: auto; padding: 0; background: transparent; color: inherit; }
@@ -203,4 +210,9 @@ p.hint { margin: 6px 0; }
.toastError { color: var(--dsw-alias-state-warn-secondary); }
.srOnly { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
@media (hover: none) { .rowActions { opacity: 1; } }
.button:disabled, .iconButton:disabled, .keyButton:disabled, .inlineAction:disabled, .recorder[aria-disabled="true"] { cursor: default; opacity: 0.45; }
.button:disabled, .rowButton:disabled, .inlineAction:disabled, .recorder[aria-disabled="true"] { cursor: default; opacity: 0.45; }
.search::-webkit-search-cancel-button { -webkit-appearance: none; }
.clearSearch { box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; flex: none; width: 16px; height: 16px; padding: 0; border: 0.5px solid var(--dsw-alias-label-tertiary); border-radius: 50%; corner-shape: round; background: transparent; color: var(--dsw-alias-label-secondary); cursor: pointer; }
.clearSearch:hover { background: var(--dsw-alias-interactive-bg-hover); }
.clearSearch:focus-visible { outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 2px; }
@@ -42,7 +42,6 @@ const coreActionOrder = new Map<string, number>([
'pane.fullscreen.toggle',
'page.refresh',
'page.close',
'response.stop',
].map((id, index) => [id, index]))
/**
@@ -57,7 +56,7 @@ export function ShortcutsRow({ actions, t, useCatalog }: PropsRuntime<'settings.
<div className={css.settingTitle}>{t('settings')}</div>
<p className={css.settingDescription}>{t('description')}</p>
</div>
<Tooltip disabled={!shortcut?.keys.length} label={shortcut?.keys.length ? t('hint', { label: t('view'), keys: shortcut.keys.join(' ') }) : t('view')}>
<Tooltip disabled={!shortcut?.keys.length} label={t('global-hint')} shortcutKeys={shortcut?.keys}>
<button className={css.button} type="button" aria-label={t('view')} aria-keyshortcuts={shortcut?.aria}
onClick={() => { actions.open() }}>{t('view')}</button>
</Tooltip>
@@ -114,11 +113,6 @@ export function ShortcutReference({
if (mounted.current) setBusy(false)
return result
}
const remove = async (row: ShortcutCatalogEntry): Promise<void> => {
const result = await persist({ type: 'set', id: row.id, binding: null }, config.revision)
if (!mounted.current) return
notify(result.status === 'saved' ? t('saved') : shortcutFailure(result, catalog, t, runtime), result.status !== 'saved')
}
const resetAll = async (): Promise<void> => {
/* v8 ignore next -- The reset control is accessible only while its confirmation is open. */
if (resetRevision === null) return
@@ -136,6 +130,7 @@ export function ShortcutReference({
const input = search.current
if (open && input !== null && !isBehindModal(input)) focusWithoutRing(input)
}, [open, focusRequest])
// Stop stays after the other fixed input actions.
const entries = [
...catalog.map(row => ({
...row,
@@ -143,7 +138,8 @@ export function ShortcutReference({
group: 'application' as const,
})),
...fixedCatalog.map(row => ({ ...row, names: [row.id, row.keys.join(' ')] })),
].sort((left, right) => (coreActionOrder.get(left.id) ?? coreActionOrder.size) - (coreActionOrder.get(right.id) ?? coreActionOrder.size)
].sort((left, right) => Number(left.id === 'response.stop') - Number(right.id === 'response.stop')
|| (coreActionOrder.get(left.id) ?? coreActionOrder.size) - (coreActionOrder.get(right.id) ?? coreActionOrder.size)
|| Number(left.id > right.id) - Number(left.id < right.id))
const ranked = rankByName(entries.flatMap(row => row.names.map(name => ({ name, label: row.label, row }))), query.trim())
const matches = [...new Set(ranked.map(match => match.row))]
@@ -156,65 +152,63 @@ export function ShortcutReference({
}, [open, resetRevision, modifiedCount])
return <><Modal open={open} onClose={closeReference} title={t('title')} headless
shortcutModal="shortcuts" className={css.dialog as string}>
<header className={css.header}>
<h2 className={css.title}>{t('title')}</h2>
<button type="button" className={css.close} aria-label={t('close')} disabled={busy} onClick={closeReference}>
<IconCloseOutlineRegular size={14} />
</button>
</header>
<div className={css.searchRow}>
<div className={css.searchField} role="search" aria-label={t('search')}>
<svg className={css.searchIcon} width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path d="M10.28 5.86536C10.28 3.41381 8.29245 1.42644 5.84094 1.42627C3.38929 1.42627 1.40186 3.4137 1.40186 5.86536C1.40203 8.31686 3.38939 10.3044 5.84094 10.3044C8.29234 10.3043 10.2799 8.31676 10.28 5.86536ZM11.4174 5.86536C11.4172 8.94498 8.92057 11.4416 5.84094 11.4418C2.76117 11.4418 0.263843 8.94509 0.263672 5.86536C0.263672 2.78548 2.76106 0.288086 5.84094 0.288086C8.92067 0.288258 11.4174 2.78559 11.4174 5.86536Z" fill="currentColor" />
<path d="M13.7372 12.9078L12.9323 13.7127L9.9732 10.7536L10.7781 9.94867L13.7372 12.9078Z" fill="currentColor" />
</svg>
<input ref={search} data-modal-autofocus className={css.search} type="search" aria-label={t('search')}
placeholder={t('search')} value={query} onChange={(event) => { actions.search(event.target.value) }} />
<div className={css.contents} onPointerDownCapture={(event) => {
if (target === null || busy || !(event.target instanceof Element) || event.target.closest('button, input, a, [contenteditable="true"], [role="group"]') !== null) return
event.preventDefault()
closeEditor()
}}>
<header className={css.header}>
<h2 className={css.title}>{t('title')}</h2>
<button type="button" className={css.close} aria-label={t('close')} disabled={busy} onClick={closeReference}>
<IconCloseOutlineRegular size={14} />
</button>
</header>
<div className={css.searchRow}>
<div className={css.searchField} role="search" aria-label={t('search')}>
<svg className={css.searchIcon} width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path d="M10.28 5.86536C10.28 3.41381 8.29245 1.42644 5.84094 1.42627C3.38929 1.42627 1.40186 3.4137 1.40186 5.86536C1.40203 8.31686 3.38939 10.3044 5.84094 10.3044C8.29234 10.3043 10.2799 8.31676 10.28 5.86536ZM11.4174 5.86536C11.4172 8.94498 8.92057 11.4416 5.84094 11.4418C2.76117 11.4418 0.263843 8.94509 0.263672 5.86536C0.263672 2.78548 2.76106 0.288086 5.84094 0.288086C8.92067 0.288258 11.4174 2.78559 11.4174 5.86536Z" fill="currentColor" />
<path d="M13.7372 12.9078L12.9323 13.7127L9.9732 10.7536L10.7781 9.94867L13.7372 12.9078Z" fill="currentColor" />
</svg>
<input ref={search} data-modal-autofocus className={css.search} type="search" aria-label={t('search')}
placeholder={t('search')} value={query} onChange={(event) => { actions.search(event.target.value) }} />
{query !== '' && <button type="button" className={css.clearSearch} aria-label={t('clear-search')}
onClick={() => { actions.search(''); search.current?.focus() }}><IconCloseOutlineRegular size={10} /></button>}
</div>
</div>
</div>
<div className={css.list} aria-busy={config.status === 'loading'}>
{(['application', 'input', 'menus', 'approval'] as const).map((group) => {
const rows = matches.filter(row => row.group === group)
if (rows.length === 0) return null
return <section key={group} aria-label={t(group)}>
{group !== 'application' && <h3 className={css.group}>{t(group)}</h3>}
<ul className={css.rows}>{rows.map(row => <li key={row.id} className={css.row}>
<span className={css.commandLabel}>{row.label}</span>
<span className={css.binding}>
{'modified' in row
? target?.id === row.id
? <ShortcutEditor key={row.id} target={row} {...editorProps} />
: <>
<span className={css.rowActions}>
{row.binding !== null && <button type="button" className={css.iconButton} aria-label={t('remove-label', { command: row.label })}
disabled={busy || config.status !== 'ready'} onClick={() => { void remove(row) }}><ShortcutIcon kind="trash" /></button>}
<button type="button" className={css.iconButton}
aria-label={t('edit-label', { command: row.label })} disabled={busy || config.status !== 'ready'}
onClick={() => { setTarget(row) }}><ShortcutIcon kind="edit" /></button>
</span>
<button type="button" className={css.keyButton} disabled={busy || config.status !== 'ready'}
aria-label={t('hint', { label: row.label, keys: row.keys.length === 0 ? t('unbound') : row.keys.join(' ') })}
onClick={() => { setTarget(row) }}>
<div className={css.list} aria-busy={config.status === 'loading'}>
{(['application', 'input', 'menus', 'approval'] as const).map((group) => {
const rows = matches.filter(row => row.group === group)
if (rows.length === 0) return null
return <section key={group} aria-label={t(group)}>
{group !== 'application' && <h3 className={css.group}>{t(group)}</h3>}
<ul className={css.rows}>{rows.map(row => <li key={row.id} className={css.row}>
{'modified' in row && target?.id !== row.id && <button type="button" className={css.rowButton}
aria-label={t('edit-label', { command: row.label })} disabled={busy || config.status !== 'ready'}
onClick={() => { setTarget(row) }} />}
<span className={css.commandLabel}>{row.label}</span>
<span className={css.binding}>
{'modified' in row
? target?.id === row.id
? <ShortcutEditor key={row.id} target={row} {...editorProps} />
: <>
<span className={css.rowActions}><ShortcutIcon kind="edit" /></span>
{row.keys.length === 0 ? <span className={css.unbound}>{t('unbound')}</span> : <ShortcutKeys keys={row.keys} className={css.keyBadge} />}
</button>
</>
: <button type="button" className={css.keyButton} disabled
aria-label={t('hint', { label: row.label, keys: row.keys.join(' ') })}>
<ShortcutKeys keys={row.keys} className={css.keyBadge} />
</button>}
</span>
</li>)}</ul>
</section>
})}
{matches.length === 0 && <p className={css.hint} role="status">{t('empty')}</p>}
</>
: <ShortcutKeys keys={row.keys} className={css.fixedKeyBadge} />}
</span>
</li>)}</ul>
</section>
})}
{matches.length === 0 && <p className={css.hint} role="status">{t('empty')}</p>}
</div>
<footer className={css.footer}>
<button type="button" className={css.resetAll} disabled={busy || config.status !== 'ready' || modifiedCount === 0}
onClick={(event) => { event.currentTarget.focus(); setTarget(null); setResetRevision(config.revision) }}>
<IconRefreshOutlineRegular size={12} />{t('reset-all')}
</button>
{modifiedCount > 0 && <span className={css.modifiedCount}>{t('modified-count', { count: modifiedCount })}</span>}
</footer>
</div>
<footer className={css.footer}>
<button type="button" className={css.resetAll} disabled={busy || config.status !== 'ready' || modifiedCount === 0}
onClick={(event) => { event.currentTarget.focus(); setTarget(null); setResetRevision(config.revision) }}>
<IconRefreshOutlineRegular size={16} />{t('reset-all')}
</button>
<span className={css.modifiedCount}>{t('modified-count', { count: modifiedCount })}</span>
</footer>
</Modal>
<Modal open={open && resetRevision !== null} title={t('reset-title')} description={t('reset-description')}
closeLabel={t('close-confirmation')} onClose={() => { if (!busy) setResetRevision(null) }}
@@ -1,6 +1,6 @@
/** Shortcut reference copy; only implemented operations appear in the catalog. */
export const zh = {
'edit-label': '修改{command}快捷键', 'remove-label': '移除{command}快捷键',
'edit-label': '修改{command}快捷键',
record: '按下快捷键', 'record-help': '松开组合键即保存。Tab 切换操作,Esc 退出录键。',
'web-help': '浏览器可用组合:Mod+/、Mod+Shift+,、Mod+Shift+.。Mod 在 Mac 上为 Command,其他系统为 Ctrl。',
'unsupported-key': '暂不支持这个按键。', reserved: '此组合由系统或文本编辑操作保留。',
@@ -26,16 +26,16 @@ export const zh = {
'web-reload': '刷新页面', 'desktop-reload': '重新启动 Harness',
'using-defaults': '当前使用默认键位。', 'using-accepted': '当前继续使用上次读取成功的键位。',
'native-failed': '无法启用桌面录键保护,请关闭后重试。',
hint: '{label} {keys}',
title: '快捷键', open: '快捷键速查', settings: '快捷键', view: '查看快捷键',
description: '查看当前可用的快捷键和输入操作', search: '搜索快捷键', close: '关闭快捷键',
'global-hint': '全局唤起查看', 'clear-search': '清空搜索',
title: '快捷键', open: '快捷键速查', settings: '快捷键', view: '编辑快捷键',
description: '查看和编辑当前可用的快捷键和输入操作', search: '搜索快捷键', close: '关闭快捷键',
application: '应用操作', input: '消息输入', menus: '菜单与弹层', approval: '审批区域', unbound: '暂无快捷键',
empty: '没有匹配的快捷键',
move: '移动菜单选择', select: '选择菜单项', dismiss: '关闭菜单或顶层弹窗',
}
/** Typed English counterpart. */
export const en: Record<keyof typeof zh, string> = {
'edit-label': 'Edit shortcut for {command}', 'remove-label': 'Remove shortcut for {command}',
'edit-label': 'Edit shortcut for {command}',
record: 'Press a shortcut', 'record-help': 'Release the keys to save. Tab moves between actions; Esc cancels.',
'web-help': 'Browser combinations: Mod+/, Mod+Shift+,, Mod+Shift+.. Mod is Command on Mac and Ctrl elsewhere.',
'unsupported-key': 'This key is not supported.', reserved: 'This combination is reserved for system or text editing actions.',
@@ -61,9 +61,9 @@ export const en: Record<keyof typeof zh, string> = {
'web-reload': 'reload the page', 'desktop-reload': 'restart Harness',
'using-defaults': 'Default bindings are active.', 'using-accepted': 'The last successfully read bindings remain active.',
'native-failed': 'Could not protect desktop key recording. Exit recording and try again.',
hint: '{label} {keys}',
title: 'Keyboard shortcuts', open: 'Open keyboard shortcuts', settings: 'Keyboard shortcuts', view: 'View shortcuts',
description: 'Browse available shortcuts and input actions', search: 'Search shortcuts', close: 'Close keyboard shortcuts',
'global-hint': 'Open from anywhere', 'clear-search': 'Clear search',
title: 'Keyboard shortcuts', open: 'Open keyboard shortcuts', settings: 'Keyboard shortcuts', view: 'Edit shortcuts',
description: 'View and edit available shortcuts and input actions', search: 'Search shortcuts', close: 'Close keyboard shortcuts',
application: 'Application', input: 'Message input', menus: 'Menus and dialogs', approval: 'Approval area', unbound: 'No shortcut',
empty: 'No matching shortcuts',
move: 'Move menu selection', select: 'Select menu item', dismiss: 'Close menu or top dialog',
@@ -40,7 +40,7 @@ describe('assembled shortcut command owners', () => {
const fixedIds = () => shortcuts.fixedCatalog.getSnapshot().map(row => row.id)
expect(fixedIds()).toEqual(expect.arrayContaining(conversationIds))
expect(shortcuts.fixedCatalog.getSnapshot().find(row => row.id === 'response.stop'))
.toMatchObject({ group: 'application', keys: ['Esc', 'Esc'] })
.toMatchObject({ group: 'input', keys: ['Esc', 'Esc'] })
await client.unload('@deepseek-ai/dsh-client-ui-shortcuts')
expect(fixedIds()).toEqual(expect.arrayContaining(conversationIds))
expect(fixedIds()).not.toContain('fixed.move')
@@ -38,8 +38,12 @@ it('keeps a reference open while a clear operation is pending', async () => {
const f = referenceFixture()
const pending = Promise.withResolvers<Awaited<ReturnType<typeof f.edit>>>()
f.edit.mockReturnValueOnce(pending.promise)
fireEvent.click(screen.getByRole('button', { name: 'Remove shortcut for Open settings' }))
fireEvent.click(screen.getByRole('button', { name: 'Edit shortcut for Open settings' }))
fireEvent.click(screen.getByRole('button', { name: en.clear }))
fireEvent.click(screen.getByRole('button', { name: en.close }))
fireEvent.pointerDown(screen.getByRole('heading', { name: en.title }))
fireEvent.click(screen.getByRole('dialog').previousElementSibling!)
expect(screen.getByRole('group', { name: 'Open settings' })).toBeTruthy()
expect(f.store.getSnapshot().open).toBe(true)
await act(async () => { pending.resolve({ status: 'saved', snapshot: f.config.getSnapshot() }); await pending.promise })
expect(screen.getByRole('alert').textContent).toBe(en.saved)
@@ -70,7 +74,7 @@ it.each(['macos', 'windows'] as const)('shows the reference, filters labels and
describeBinding: describe, platform, t: makeTranslate(en) } as Parameters<typeof ShortcutReference>[0]
render(<><ShortcutsRow {...props} /><ShortcutReference {...props} /></>)
expect(screen.queryByRole('dialog')).toBeNull()
const opener = screen.getByRole('button', { name: 'View shortcuts' }); opener.focus(); fireEvent.click(opener)
const opener = screen.getByRole('button', { name: en.view }); opener.focus(); fireEvent.click(opener)
expect(opener.getAttribute('aria-keyshortcuts')).toBe(platform === 'macos' ? 'Meta+/' : 'Control+/')
const search = screen.getByRole('searchbox')
expect(document.activeElement).toBe(search)
@@ -100,7 +104,7 @@ it.each(['macos', 'windows'] as const)('shows the reference, filters labels and
expect(store.getSnapshot().query).toBe('')
expect(document.activeElement).toBe(opener)
fireEvent.mouseEnter(opener)
expect(screen.getByRole('tooltip').textContent).toBe(platform === 'macos' ? 'View shortcuts ⌘ /' : 'View shortcuts Ctrl + /')
expect(screen.getByRole('tooltip').getAttribute('aria-label')).toBe(`${en['global-hint']} ${platform === 'macos' ? '⌘ /' : 'Ctrl + /'}`)
act(() => { catalog.set(catalog.getSnapshot().map(row => row.id === 'shortcuts.open'
? { ...row, binding: null, keys: [], aria: undefined } : row)) })
expect(screen.queryByRole('tooltip')).toBeNull()
@@ -186,7 +190,7 @@ it('keeps core actions in product order across registration, remount and label c
...template, id: id as ShortcutCommandId, label,
}))
const stop: ShortcutFixedCatalogEntry = { id: 'response.stop' as ShortcutCommandId, label: 'Stop reply',
keys: ['Esc', 'Esc'], bindings: [{ code: 'Escape', modifiers: [] }], group: 'application' }
keys: ['Esc', 'Esc'], bindings: [{ code: 'Escape', modifiers: [] }], group: 'input' }
const fixed: ShortcutFixedCatalogEntry[] = [
{ ...stop, id: 'fixed.send' as ShortcutCommandId, label: 'Send', keys: ['Enter'], bindings: [{ code: 'Enter', modifiers: [] }], group: 'input' },
{ ...stop, id: 'fixed.newline' as ShortcutCommandId, label: 'Newline', keys: ['Shift', 'Enter'], bindings: [{ code: 'Enter', modifiers: ['shift'] }], group: 'input' },
@@ -196,21 +200,21 @@ it('keeps core actions in product order across registration, remount and label c
stop,
]
const labels = (group: string) => within(screen.getByRole('region', { name: group })).getAllByRole('listitem')
.map(row => row.firstElementChild?.textContent)
const expected = [...core.map(([, label]) => label), 'Stop reply', 'Zulu extension', 'Alpha extension']
.map(row => row.querySelector(':scope > span')?.textContent)
const expected = [...core.map(([, label]) => label), 'Zulu extension', 'Alpha extension']
for (const reversed of [true, false]) {
act(() => {
catalog.set(reversed ? [...extensions, ...commands].reverse() : [...extensions, ...commands])
fixedCatalog.set(reversed ? [...fixed].reverse() : fixed)
})
expect(labels(en.application)).toEqual(expected)
expect(labels(en.input)).toEqual(['Newline', 'Send'])
expect(labels(en.input)).toEqual(['Newline', 'Send', 'Stop reply'])
expect(labels(en.menus)).toEqual(['Dismiss', 'Select'])
expect(labels(en.approval)).toEqual(['Approve'])
}
const stopButton = within(screen.getByRole('region', { name: en.application })).getByRole('button', { name: 'Stop reply Esc Esc' })
expect(stopButton.hasAttribute('disabled')).toBe(true)
fireEvent.click(stopButton)
const stopRow = screen.getByText('Stop reply').closest('li')!
expect(within(stopRow).queryByRole('button')).toBeNull()
fireEvent.click(stopRow)
expect(screen.queryByRole('button', { name: 'Edit shortcut for Stop reply' })).toBeNull()
expect(screen.queryByRole('group', { name: 'Stop reply' })).toBeNull()
act(() => { catalog.set(extensions); fixedCatalog.set(fixed.filter(row => row.id !== stop.id)) })
@@ -223,7 +227,7 @@ it('keeps core actions in product order across registration, remount and label c
store.actions.search('操作')
})
expect(labels(en.application)).toEqual(expected.map(label => `操作:${label}`))
expect(labels(en.input)).toEqual(['操作:Newline', '操作:Send'])
expect(labels(en.input)).toEqual(['操作:Newline', '操作:Send', '操作:Stop reply'])
})
it('keeps product order for tied searches while prioritizing stronger matches', () => {
@@ -268,9 +272,9 @@ it('shows mounted fixed actions as searchable read-only rows and follows their l
const approve = { id: 'approval.accept' as ShortcutCommandId, label: 'Approve', keys: ['Enter'], bindings: [{ code: 'Enter', modifiers: [] }], group: 'approval' as const }
expect(screen.queryByText(send.label)).toBeNull()
act(() => { fixedCatalog.set([send, stop, approve]) })
expect(screen.getByRole('button', { name: 'Send from catalog Enter' }).hasAttribute('disabled')).toBe(true)
expect(within(screen.getByText(send.label).closest('li')!).queryByRole('button')).toBeNull()
expect(screen.getByRole('region', { name: 'Approval area' }).textContent).toContain('Approve')
expect(screen.getByRole('button', { name: 'Stop reply Esc Esc' }).hasAttribute('disabled')).toBe(true)
expect(within(screen.getByText(stop.label).closest('li')!).queryByRole('button')).toBeNull()
expect(screen.queryByRole('button', { name: 'Edit shortcut for Stop reply' })).toBeNull()
act(() => { store.actions.search('response.stop') })
expect(screen.getAllByRole('listitem')).toHaveLength(1)
@@ -305,12 +309,13 @@ it.each(['conflict', 'reserved'] as const)('omits status labels for a %s binding
expect(screen.getByRole('button', { name: 'Edit shortcut for Open settings' }).hasAttribute('disabled')).toBe(false)
})
it('removes from the row and replaces failure feedback with a fresh system success toast', async () => {
it('removes from the inline editor and replaces failure feedback with a fresh system success toast', async () => {
vi.useFakeTimers()
try {
const f = referenceFixture()
f.edit.mockResolvedValueOnce({ status: 'write-failed', snapshot: f.config.getSnapshot() })
const remove = screen.getByRole('button', { name: 'Remove shortcut for Open settings' })
fireEvent.click(screen.getByRole('button', { name: 'Edit shortcut for Open settings' }))
const remove = screen.getByRole('button', { name: en.clear })
await act(async () => { remove.click() })
expect(screen.getByRole('alert').textContent).toBe(en['write-failed'])
expect(screen.getByRole('dialog').contains(screen.getByRole('alert'))).toBe(false)
@@ -325,10 +330,10 @@ it('removes from the row and replaces failure feedback with a fresh system succe
} finally { vi.useRealTimers() }
})
it('opens the same recorder from a key badge and retains the editor when restoring defaults fails', async () => {
it('opens the recorder from the application row and retains the editor when restoring defaults fails', async () => {
const f = referenceFixture()
f.edit.mockResolvedValueOnce({ status: 'write-failed', snapshot: f.config.getSnapshot() })
fireEvent.click(screen.getByRole('button', { name: 'Open settings ⇧ ⌘ ,' }))
fireEvent.click(screen.getByRole('button', { name: 'Edit shortcut for Open settings' }))
await act(async () => { screen.getByRole('button', { name: en.reset }).click() })
expect(f.edit).toHaveBeenLastCalledWith({ type: 'reset', id: 'settings.open' }, f.config.getSnapshot().revision)
expect(screen.getByRole('alert').textContent).toBe(en['write-failed'])
@@ -390,7 +395,8 @@ it('blocks reference dismissal during a removal and ignores its completion after
let settle!: (value: Awaited<ReturnType<typeof f.edit>>) => void
const reply = new Promise<Awaited<ReturnType<typeof f.edit>>>((resolve) => { settle = resolve })
f.edit.mockReturnValueOnce(reply)
fireEvent.click(screen.getByRole('button', { name: 'Remove shortcut for Open settings' }))
fireEvent.click(screen.getByRole('button', { name: 'Edit shortcut for Open settings' }))
fireEvent.click(screen.getByRole('button', { name: en.clear }))
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
expect(f.store.getSnapshot().open).toBe(true)
f.view.unmount()
@@ -482,7 +488,7 @@ it('restores defaults after confirmation and retains accepted configuration when
await act(async () => { confirm.click() })
expect(screen.queryByRole('dialog', { name: en['reset-title'] })).toBeNull()
expect(screen.getByRole('alert').textContent).toBe(en['reset-saved'])
expect(screen.getByText('0 customized')).toBeTruthy()
expect(screen.queryByText('0 customized')).toBeNull()
expect(restore.hasAttribute('disabled')).toBe(true)
expect(document.activeElement).toBe(screen.getByRole('searchbox'))
})
@@ -523,3 +529,57 @@ it('keeps an active recorder focused when another window resets the profile', ()
expect(document.activeElement).toBe(recorder)
expect(screen.getByRole('group', { name: 'Open settings' })).toBeTruthy()
})
it('clears a search and returns focus to the search field', () => {
referenceFixture()
const search = screen.getByRole('searchbox')
fireEvent.change(search, { target: { value: 'absent' } })
expect(screen.queryAllByRole('listitem')).toHaveLength(0)
fireEvent.click(screen.getByRole('button', { name: en['clear-search'] }))
expect(screen.getAllByRole('listitem')).toHaveLength(1)
expect(document.activeElement).toBe(search)
expect(screen.queryByRole('button', { name: en['clear-search'] })).toBeNull()
})
it('closes the editor and drops a pending recording when blank space is clicked', async () => {
const f = referenceFixture()
fireEvent.click(screen.getByRole('button', { name: 'Edit shortcut for Open settings' }))
const recorder = screen.getByRole('button', { name: en.record })
expect(document.activeElement).toBe(recorder)
fireEvent.keyDown(recorder, { key: '.', code: 'Period', metaKey: true, shiftKey: true })
fireEvent.pointerDown(screen.getByRole('heading', { name: en.title }))
expect(document.activeElement).toBe(screen.getByRole('dialog'))
await act(async () => { fireEvent.keyUp(document.activeElement!, { key: '.', code: 'Period' }) })
expect(f.edit).not.toHaveBeenCalled()
expect(screen.queryByRole('group', { name: 'Open settings' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Edit shortcut for Open settings' }))
const reopened = screen.getByRole('button', { name: en.record })
await act(async () => {
fireEvent.keyDown(reopened, { key: '.', code: 'Period', metaKey: true, shiftKey: true })
fireEvent.keyUp(reopened, { key: '.', code: 'Period' })
})
expect(f.edit).toHaveBeenCalledOnce()
})
it('preserves a failed draft when its status text is clicked, then retries after reviewing an external change', async () => {
const f = referenceFixture()
fireEvent.pointerDown(screen.getByRole('heading', { name: en.title }))
expect(f.store.getSnapshot().open).toBe(true)
fireEvent.click(screen.getByRole('button', { name: 'Edit shortcut for Open settings' }))
f.edit.mockResolvedValueOnce({ status: 'write-failed', snapshot: f.config.getSnapshot() })
const recorder = screen.getByRole('button', { name: en.record })
fireEvent.pointerDown(recorder)
await act(async () => {
fireEvent.keyDown(recorder, { key: '.', code: 'Period', metaKey: true, shiftKey: true })
fireEvent.keyUp(recorder, { key: '.', code: 'Period' })
})
const editor = screen.getByRole('group', { name: 'Open settings' })
fireEvent.pointerDown(within(editor).getByText(en['write-failed']))
expect(within(editor).getByRole('button', { name: en['retry-save'] })).toBeTruthy()
act(() => { f.config.set({ ...f.config.getSnapshot(), revision: initialShortcutConfig().revision }) })
fireEvent.pointerDown(within(editor).getByText(en.stale))
fireEvent.click(within(editor).getByRole('button', { name: en.review }))
await act(async () => { within(editor).getByRole('button', { name: en['retry-save'] }).click() })
expect(f.edit).toHaveBeenLastCalledWith({ type: 'set', id: 'settings.open', binding: { code: 'Period', modifiers: ['shift', 'meta'] } }, f.config.getSnapshot().revision)
expect(screen.queryByRole('group', { name: 'Open settings' })).toBeNull()
})
@@ -21,8 +21,8 @@
en: fb79cb7d59bc01af
zh: 5f6202d8b0b62d7d
/deepseek-ai-dsh-client-ui-sidebar-browser/use-this-package/minimal-configuration:
en: fbbc95839824742c
zh: fb49613017959ff4
en: 32511e10645b4154
zh: ce0e0efd60dda336
/deepseek-ai-dsh-client-ui-sidebar-browser/understand-the-implementation:
en: 42b15ac6321706aa
zh: e15901d8462ec7c7
+1 -1
View File
@@ -42,7 +42,7 @@ The package has no plugin configuration. A Web profile enables the shipped entry
Client plugins can open a tab through `ctx.sidebarRight.openTab('browser', { params: { url } })`. The optional URL passes the same validation as address-bar input before navigation.
The `browser.new` command opens a separate Browser page in the focused dock pane, replacing a guide and retaining existing content pages. From the conversation or a floating content page, it uses the active dock pane. Desktop defaults to Cmd+T on macOS and Ctrl+T on Windows; Windows and macOS Web use the [shortcut service’s platform defaults](../shortcuts/README.md); Linux Web leaves the command unbound. The guide button displays the effective shortcut inline without a duplicate tooltip.
The `browser.new` command opens a separate Browser page in the focused dock pane, replacing a guide and retaining existing content pages. From the conversation or a floating content page, it uses the active dock pane. Desktop defaults to Cmd+T on macOS and Ctrl+T on Windows; Windows and macOS Web use the [shortcut service’s platform defaults](../shortcuts/README.md); Linux Web leaves the command unbound. The guide button uses a blue globe and displays the effective shortcut inline without a duplicate tooltip.
The toolbar provides Back, Forward, Reload, Go, and Open in system browser. Web also offers a per-tab sandbox toggle; disabling it is temporary and displays a warning. Desktop shows the observed page title. After a restart, Browser shows the saved title and URL; Restore or Reload opens that address only when requested.
@@ -42,7 +42,7 @@ Browser 在 Web profile 中默认禁用,在 Desktop 中默认启用。Web 用
Client 插件可以调用 `ctx.sidebarRight.openTab('browser', { params: { url } })` 打开 tab。可选 URL 会在导航前接受与地址栏输入相同的校验。
命令 `browser.new` 在焦点停靠分栏打开独立浏览器页,替换开始页并保留已有内容页。从聊天区或浮动内容页触发时,使用活动停靠分栏。桌面默认键在 macOS 上为 Cmd+T,在 Windows 上为 Ctrl+T;Windows 和 macOS Web 使用[快捷键服务的平台默认值](../shortcuts/README.zh.md);Linux Web 默认不绑定此命令。开始页按钮内显示有效快捷键,不额外弹出重复提示。
命令 `browser.new` 在焦点停靠分栏打开独立浏览器页,替换开始页并保留已有内容页。从聊天区或浮动内容页触发时,使用活动停靠分栏。桌面默认键在 macOS 上为 Cmd+T,在 Windows 上为 Ctrl+T;Windows 和 macOS Web 使用[快捷键服务的平台默认值](../shortcuts/README.zh.md);Linux Web 默认不绑定此命令。开始页按钮使用蓝色地球图标,并在按钮内显示有效快捷键,不额外弹出重复提示。
工具栏提供后退、前进、刷新、前往和在系统浏览器中打开。Web 还提供逐 tab sandbox 开关;关闭它是临时选择,并会显示警告。Desktop 显示观察到的页面标题。重启后,Browser 展示保存的标题和 URL;只有点击恢复或刷新才打开该地址。
@@ -1,7 +1,7 @@
/** Static Browser tab type and guide declaration. */
import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client'
import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
import { IconGlobeOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import { GuideArtworkBrowser } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarRightTabDefinition } from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
import type {} from './locales.ts'
@@ -21,7 +21,7 @@ export function browserDefinition(t: TranslateNS<'sidebarBrowser'>): SidebarRigh
title: () => t('type.label'),
guide: [{
id: 'new', commandId: 'browser.new' as ShortcutCommandId, order: 30, title: () => t('guide.title'),
description: () => t('guide.description'), icon: IconGlobeOutlineRegular,
description: () => t('guide.description'), icon: GuideArtworkBrowser,
}],
}
}
@@ -1,6 +1,5 @@
/** Locale-owned Browser tab copy. */
export const zh = {
'shortcut.hint': '{label} {keys}',
'type.label': '浏览器',
'guide.title': '浏览器',
'guide.description': '浏览网页',
@@ -34,7 +33,6 @@ export type SidebarBrowserKey = keyof typeof zh
/** English dictionary with the same keys. */
export const en = {
'shortcut.hint': '{label} {keys}',
'type.label': 'Browser',
'guide.title': 'Browser',
'guide.description': 'Browse web pages',
@@ -81,7 +81,7 @@ export function BrowserBody(props: BrowserBodyProps): ReactNode {
<form className={css.toolbar} onSubmit={submit}>
<button type="button" className={css.tool} aria-label={t('back')} title={t('back')} disabled={!frame.canGoBack} onClick={() => { goBack(tab.id) }}><IconChevronLeftOutlineRegular /></button>
<button type="button" className={css.tool} aria-label={t('forward')} title={t('forward')} disabled={!frame.canGoForward} onClick={() => { goForward(tab.id) }}><IconChevronRightOutlineRegular /></button>
<Tooltip label={tab.refreshShortcut?.keys.length ? t('shortcut.hint', { label: t('reload'), keys: tab.refreshShortcut.keys.join(' ') }) : t('reload')} side="bottom" delayMs={500}>
<Tooltip label={t('reload')} shortcutKeys={tab.refreshShortcut?.keys} side="bottom" delayMs={500}>
<button type="button" className={css.tool} aria-label={t('reload')} aria-keyshortcuts={tab.refreshShortcut?.aria} disabled={target === undefined || mountEpoch === 0} onClick={() => { reload(tab.id) }}><IconRefreshOutlineRegular /></button>
</Tooltip>
<div className={css.addressBox}>
@@ -310,7 +310,7 @@ export function TextPreview({
</span>
{/* Preview and file-tree refresh controls own different reload lifecycles and locale namespaces. */}
{/* jscpd:ignore-start */}
<Tooltip label={tab.refreshShortcut?.keys.length ? t('shortcut.hint', { label: t('reload'), keys: tab.refreshShortcut.keys.join(' ') }) : t('reload')} side="bottom" delayMs={500}>
<Tooltip label={t('reload')} shortcutKeys={tab.refreshShortcut?.keys} side="bottom" delayMs={500}>
<button
type="button"
className={css.tool}
@@ -8,7 +8,6 @@
/** Simplified Chinese dictionary and key-set source of truth. */
export const zh = {
'shortcut.hint': '{label} {keys}',
loading: '文档渲染中...',
loadMore: '加载更多',
changed: '文件已更新,当前显示为旧内容',
@@ -38,7 +37,6 @@ export type SidebarDocumentPreviewKey = keyof typeof zh
/** English dictionary, checked against the Chinese key set. */
export const en = {
'shortcut.hint': '{label} {keys}',
loading: 'Rendering document...',
loadMore: 'Load more',
changed: 'The file has changed, showing the previous content.',
@@ -15,8 +15,8 @@
en: 9411fb4809692e09
zh: dd13990d39c5be7f
/deepseek-ai-dsh-client-ui-sidebar-files/what-it-registers:
en: 72afb24f7aa8cbb7
zh: 17baab21526fe175
en: a13aadd9b0294870
zh: d30dc67f177f6c2d
/deepseek-ai-dsh-client-ui-sidebar-files/the-tree:
en: a0ae0b1f223b3df1
zh: 8ddc325519bd75b2
+1 -1
View File
@@ -26,7 +26,7 @@ Browse a Session's workspace tree and open files in Sidebar previews. The root a
The `workspace.files` command opens or focuses the file page in the focused pane; from the conversation it uses the current Session's active dock pane. A guide gives way to the file page, and repeated opens retain one file page per pane. Desktop defaults to Mod+P. The guide entry displays the effective shortcut; Windows and macOS Web use the [shortcut service’s platform defaults](../shortcuts/README.md); Linux Web leaves this command unbound by default.
- **The type** — `ctx.sidebarRightTabs.register(...)` with kind `files`, id `@deepseek-ai/dsh-client-ui-sidebar-files`, band `builtin`, no patterns, and one guide entry (order 10, its title and description from the `sidebarFiles` namespace, its glyph the shared folder icon) that opens the type.
- **The type** — `ctx.sidebarRightTabs.register(...)` with kind `files`, id `@deepseek-ai/dsh-client-ui-sidebar-files`, band `builtin`, no patterns, and one guide entry (order 10, its title and description from the `sidebarFiles` namespace, its glyph a yellow folder) that opens the type.
- **The body** — the keyed `sidebar.right.pane.tab` seat under that id: a header row under the strip, then the tree. The shared [`PathLabel`](../ui-primitives/README.md#component-catalog) displays the root path with subdued directories and a primary final segment. A clipped path retains its trailing characters with a left-edge fade; hovering reveals the full path. The reload control stays at its right.
- **The chip title** — the keyed `sidebar.right.pane.tab.title` seat under that id: a shared `FileTypeIcon` folder glyph at 16px before the type's label. The tree's own rows never draw this sheet.
@@ -26,7 +26,7 @@ kind: "package-reference"
命令 `workspace.files` 在焦点分栏内打开或聚焦文件页;从聊天区触发时使用当前会话的活动停靠分栏。文件页替换开始页,同一分栏内重复打开仍保留一个文件页。桌面默认键为 Mod+P。开始页入口显示有效快捷键;Windows 和 macOS Web 使用[快捷键服务的平台默认值](../shortcuts/README.zh.md);Linux Web 默认不绑定此命令。
- **类型**:`ctx.sidebarRightTabs.register(...)`,kind 为 `files`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-files`,档位 `builtin`,没有 patterns,另有一个打开该类型的引导页入口(order 10,标题与描述取自 `sidebarFiles` 命名空间,图标是共享的文件夹图标)。
- **类型**:`ctx.sidebarRightTabs.register(...)`,kind 为 `files`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-files`,档位 `builtin`,没有 patterns,另有一个打开该类型的引导页入口(order 10,标题与描述取自 `sidebarFiles` 命名空间,图标为黄色文件夹)。
- **正文**:以该 id 为键的 `sidebar.right.pane.tab` slot:strip 下的一行标题行,然后是树。共享的 [`PathLabel`](../ui-primitives/README.zh.md#component-catalog) 显示根路径,目录使用弱化颜色,最后一段使用主色。路径过长时保留尾部字符并在左侧渐隐;悬停显示完整路径。重新读取控件位于右端。
- **标签页标题**:以该 id 为键的 `sidebar.right.pane.tab.title` slot:类型标签前的一枚 16px 共享 `FileTypeIcon` 文件夹图标。树本身的行不画这枚图标。
@@ -198,7 +198,7 @@ export function FilesBody({
{state.autoRefresh ? <IconPauseOutlineRegular /> : <IconPlayOutlineRegular />}
</button>
</span>
<Tooltip label={tab.refreshShortcut?.keys.length ? t('shortcut.hint', { label: t('reload'), keys: tab.refreshShortcut.keys.join(' ') }) : t('reload')} side="bottom" delayMs={500}>
<Tooltip label={t('reload')} shortcutKeys={tab.refreshShortcut?.keys} side="bottom" delayMs={500}>
<button
type="button"
className={css.tool}
@@ -9,7 +9,7 @@ import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client
import type { SidebarRightTabDefinition } from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
import type {} from './locales.ts'
import { FileTypeIcon, type IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
import { GuideArtworkFiles } from '@deepseek-ai/dsh-client-ui-primitives'
/** The tab kind this package owns. */
export const FILES_KIND = 'files'
@@ -17,11 +17,6 @@ export const FILES_KIND = 'files'
/** This implementation's identity in the tab system, and the key its body registers under. */
export const FILES_ID = '@deepseek-ai/dsh-client-ui-sidebar-files'
/** The type's coloured folder sheet at the guide capsule's glyph size, as the chip title draws it. */
function FolderSheetGlyph({ size, className }: IconProps) {
return <FileTypeIcon kind="folder" size={size} className={className} />
}
/**
* The files type's registry definition.
* @param t - namespace-bound translate, read fresh on every label call.
@@ -39,7 +34,7 @@ export function filesDefinition(t: TranslateNS<'sidebarFiles'>): SidebarRightTab
order: 10,
title: () => t('guide.title'),
description: () => t('guide.description'),
icon: FolderSheetGlyph,
icon: GuideArtworkFiles,
}],
}
}
@@ -20,7 +20,6 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/** Simplified Chinese dictionary and key-set source of truth. */
export const zh = {
'shortcut.hint': '{label} {keys}',
'shortcut.noSession': '请先选择会话',
'type.label': '文件',
'guide.title': '工作区文件',
@@ -45,7 +44,6 @@ export type SidebarFilesKey = keyof typeof zh
/** English dictionary, checked against the Chinese key set. */
export const en = {
'shortcut.hint': '{label} {keys}',
'shortcut.noSession': 'Select a session first',
'type.label': 'Files',
'guide.title': 'Workspace files',
@@ -21,8 +21,8 @@
en: 6d7f145ecb1fd140
zh: 666d142ef249f6cb
/deepseek-ai-dsh-client-ui-sidebar-right/the-expand-button:
en: db83aaf5415e988f
zh: 8d8af5b3a504f17c
en: 28b0bc3d4050b0e0
zh: 733b44ad9309f49a
/deepseek-ai-dsh-client-ui-sidebar-right/state:
en: 2b15cdf5f1383291
zh: 57018036d77363ba
@@ -36,8 +36,8 @@
en: 717b86e05b44ebdd
zh: 0f4f694d0ea1d7f8
/deepseek-ai-dsh-client-ui-sidebar-right/the-guide:
en: 937225e990fdf48e
zh: bf28954a0f9b8338
en: 601f9f6635babc4d
zh: e345004fe30a65b9
/deepseek-ai-dsh-client-ui-sidebar-right/copy:
en: 4315a1c03ca5fef3
zh: c09a09f1d9b99107
+2 -2
View File
@@ -52,7 +52,7 @@ The panel has no header row. Its two controls — the presentation switch and th
<a id="the-expand-button"></a>
## The expand button
The shortcut reference includes right-sidebar toggle, split and panel fullscreen commands. The same effective binding appears on the corresponding controls. Split and fullscreen require focus within a visible dock pane; conversation focus leaves them unavailable. Split remains visible when unavailable; its focusable tooltip shows the current binding and explains the pane limit or width requirement. Files, browser, and terminal providers contribute their own commands. Guide buttons display effective keycaps inline without duplicate tooltips.
The shortcut reference includes right-sidebar toggle, split and panel fullscreen commands. The same effective binding appears on the corresponding controls. Split and fullscreen require focus within a visible dock pane; conversation focus leaves them unavailable. Split remains visible when unavailable; its focusable tooltip shows the current binding and explains the pane limit or width requirement. Files, browser, and terminal providers contribute their own commands. Guide buttons display effective shortcuts as unboxed text inline without duplicate tooltips.
While the panel is hidden, one button in the conversation header's corner seat (`conversation.session.header.corner`, past the utilities' right edge and level with the Session log control) is the way back in. Its glyph is the left sidebar's collapse icon mirrored. It uses the shared compact Button, matching the adjacent More action’s radius and hover fill. It shares the panel's store (the slot runtime allows one handle across two same-scope seats); while the panel is shown it renders nothing, and the corner seat collapses with it. A collapsed Sidebar therefore costs the conversation nothing: no rail, no width, and the transcript's scrollbar stays at the column's edge. Without a session there is no button and no panel.
@@ -112,7 +112,7 @@ Close and refresh commands resolve the focused pane and tab occurrence. Page bod
<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. Guide cards use the shared R20 radius, including hover fills and provider-owned split buttons. Entry identity remains stable across sorting and unrelated registrations. Shortcut labels align at the right, with space reserved for the terminal shell menu.
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. Guide cards use the shared R20 radius, including hover fills and provider-owned split buttons. Entry identity remains stable across sorting and unrelated registrations. Unboxed shortcut text aligns at the right. The terminal shell menu sits beside its entry title.
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.
@@ -52,7 +52,7 @@ kind: "package-reference"
<a id="the-expand-button"></a>
## 展开按钮
快捷键速查包含右侧栏切换、分栏和面板全屏命令,对应控件显示同一份有效键位。分栏与全屏要求焦点位于可见的停靠分栏内;焦点在聊天区时不可用。不可分栏时控件仍可见,可聚焦的提示显示当前键位,并说明分栏数量上限或宽度要求。文件、浏览器和终端提供者分别注册自己的命令。开始页按钮内显示有效键帽,不额外弹出重复提示。
快捷键速查包含右侧栏切换、分栏和面板全屏命令,对应控件显示同一份有效键位。分栏与全屏要求焦点位于可见的停靠分栏内;焦点在聊天区时不可用。不可分栏时控件仍可见,可聚焦的提示显示当前键位,并说明分栏数量上限或宽度要求。文件、浏览器和终端提供者分别注册自己的命令。开始页按钮内以无背景文字显示有效快捷键,不额外弹出重复提示。
面板隐藏时,会话 header 角落席位里的一个按钮(`conversation.session.header.corner`,在工具组右缘之外,与 Session 日志控件齐平)是回去的路。它的图形是左侧 sidebar 折叠图标的镜像。它使用公共紧凑 Button,与相邻更多操作按钮共用圆角和 hover 底色。它与面板共用一个存储(slot 运行时允许两个同作用域席位共用一个 handle);面板显示时它什么也不渲染,角落席位随之收起。于是折叠的 Sidebar 不花会话区任何代价:没有轨条、没有宽度,转录的滚动条留在列的边缘。没有会话就没有按钮也没有面板。
@@ -112,7 +112,7 @@ Tab 域按(Session,Tab id)保留品牌化 occurrence id、导航、中止
<a id="the-guide"></a>
## 引导页
每个 `guide` 入口具有 provider 内唯一的 `id`。开始页按当前生效的定义 id 渲染 `sidebar.right.tab.guide.entry`,传入入口 id、解析后的标题、可选描述,以及框架提供的 `useTabInfo` hook。provider 可按自己的定义 id 注册自定义卡片;未注册时使用默认图标与标题按钮。侧栏负责宽度和排列,自定义组件负责内部结构和交互。引导卡片统一使用 R20,包含悬停底色与提供方自有的分体按钮。排序或其他注册变化不会改变入口身份。快捷键标签右对齐,并为终端 Shell 菜单预留空间。
每个 `guide` 入口具有 provider 内唯一的 `id`。开始页按当前生效的定义 id 渲染 `sidebar.right.tab.guide.entry`,传入入口 id、解析后的标题、可选描述,以及框架提供的 `useTabInfo` hook。provider 可按自己的定义 id 注册自定义卡片;未注册时使用默认图标与标题按钮。侧栏负责宽度和排列,自定义组件负责内部结构和交互。引导卡片统一使用 R20,包含悬停底色与提供方自有的分体按钮。排序或其他注册变化不会改变入口身份。无背景的快捷键文字右对齐。终端 Shell 菜单位于入口标题旁。
终端等需要独立实例的 tab 类型可声明 `multiple: true`。每次打开都会获得独立内容地址,因此移动和停靠会保留这些实例;普通页面仍在每个格内去重。
@@ -20,17 +20,16 @@ import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
* @returns every string the kit renders.
*/
export function dockLabels(t: TranslateNS<'sidebarRight'>, split?: ShortcutCatalogEntry, close?: ShortcutCatalogEntry): DockLabels {
const splitHint = (label: string) => split?.keys.length ? t('shortcut.hint', { label, keys: split.keys.join(' ') }) : label
return {
emptyPane: t('dock.emptyPane'),
splitPane: t('dock.splitPane'),
splitPaneTooltip: splitHint(t('dock.splitPane')),
splitPaneShortcut: split?.aria,
splitPaneDisabled: splitHint(t('dock.splitPaneDisabled')),
splitPaneNarrow: splitHint(t('dock.splitPaneNarrow')),
splitPaneKeys: split?.keys,
splitPaneDisabled: t('dock.splitPaneDisabled'),
splitPaneNarrow: t('dock.splitPaneNarrow'),
closeTab: t('dock.closeTab'),
closeTabTooltip: close?.keys.length ? t('shortcut.hint', { label: t('dock.closeTab'), keys: close.keys.join(' ') }) : t('dock.closeTab'),
closeTabShortcut: close?.aria,
closeTabKeys: close?.keys,
addTab: t('dock.addTab'),
dockFloat: t('dock.dockFloat'),
closeFloat: t('dock.closeFloat'),
@@ -8,7 +8,6 @@
/** Simplified Chinese dictionary and key-set source of truth. */
export const zh = {
'shortcut.hint': '{label} {keys}',
'command.close': '关闭当前页面/窗口',
'command.refresh': '刷新当前页面',
'command.noRefresh': '当前页面不支持刷新',
@@ -50,7 +49,6 @@ export type SidebarRightKey = keyof typeof zh
/** English dictionary, checked against the Chinese key set. */
export const en = {
'shortcut.hint': '{label} {keys}',
'command.close': 'Close current page or window',
'command.refresh': 'Refresh current page',
'command.noRefresh': 'This page cannot be refreshed',
Loaded 100 of 321 files, more files were not shown because too many files have changed in this diff. Show more