diff --git a/.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.i18n.yaml index e78d7bdc51..a5e163f76e 100644 --- a/.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.md -2026-09-06-embedded-stream-record-readers.md: 972fee634833cef5fd7b0a54f69780b9370f0cc3 -2026-09-06-embedded-stream-record-readers.zh.md: faba6e179887a9943926f2c73aa8e42a10cee300 +2026-09-06-embedded-stream-record-readers.md: 76e109de577d070093bb7123aa50d9a4ae7fcbe5 +2026-09-06-embedded-stream-record-readers.zh.md: 33f4b31117197c47e7f2ff1637bc13fa2cbf1a8c diff --git a/.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.md b/.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.md index 972fee6348..76e109de57 100644 --- a/.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.md +++ b/.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.md @@ -20,7 +20,7 @@ After v2 embedded streams settlement widened with the message content and Chat a - Run readers: `runFirstTokenTime` and `runFirstVisibleTime` reconstruct the first qualifying member's time from `time0` and the `dt` gaps and stop scanning there; a name-bearing Tool-call run yields `time0` without reading a fragment. - Stream readers: `assistantStreamFirstTokenTime`, `assistantStreamHasVisibleContent`, `assistantStreamHasVisibleText`, `lastAssistantStreamChunk(stream, type)` (backward scan), `assistantStreamChunks(stream, type)`, `joinAssistantStreamText`, and `assembleAssistantStream`, which feeds a `BlockAssembler` one joined delta per run (assembly only concatenates, so blocks, usage, finish, and replay state equal the per-member result). `RawStreamChunkType` excludes the delta types, so a raw-chunk lookup can never silently skip packed members. -Session Stats reads `assistantStreamFirstTokenTime`; the token meter reads `lastAssistantStreamChunk(stream, 'usage')` and assembles provider output through `assembleAssistantStream`; the subagent output fold appends `joinAssistantStreamText`; the Session Controller scans `assistantStreamChunks(stream, 'block-end')` for images. +Session Stats, Chat, and Trajectory read `assistantStreamFirstTokenTime` from both `assistant/attempt` and `assistant/message`, retaining the Step's first token across retries. Chat and Trajectory settle content from the assembled message while reading timing independently, so reopening history retains TTFT and decoding metrics without expanding streams. The token meter reads `lastAssistantStreamChunk(stream, 'usage')` and assembles provider output through `assembleAssistantStream`; the subagent output fold appends `joinAssistantStreamText`; the Session Controller scans `assistantStreamChunks(stream, 'block-end')` for images. `expandAssistantStream` keeps its strict validation and its remaining callers, which need every member or validate the stream at a durable boundary: Session restore validation, the v1-to-v2 migration validator and publication Worker replay, the reconnect baseline, and test support. diff --git a/.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.zh.md b/.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.zh.md index faba6e1798..33f4b31117 100644 --- a/.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.zh.md @@ -20,7 +20,7 @@ Session 格式 v2 将每次模型尝试的紧凑流(`AssistantStreamRecord[]` - Run 读取器:`runFirstTokenTime` 与 `runFirstVisibleTime` 从 `time0` 与 `dt` 间隔重建首个合格成员的时间并停止扫描;带名称的 Tool-call run 直接产出 `time0`,不读片段。 - 流读取器:`assistantStreamFirstTokenTime`、`assistantStreamHasVisibleContent`、`assistantStreamHasVisibleText`、`lastAssistantStreamChunk(stream, type)`(逆向扫描)、`assistantStreamChunks(stream, type)`、`joinAssistantStreamText` 与 `assembleAssistantStream`(每个 run 向 `BlockAssembler` 喂入一个拼接后的 delta;组装只做拼接,因此 blocks、usage、finish 与 replay state 与逐成员结果一致)。`RawStreamChunkType` 排除 delta 类型,因此原始 chunk 查找不可能静默跳过打包成员。 -Session Stats 读取 `assistantStreamFirstTokenTime`;token 计量读取 `lastAssistantStreamChunk(stream, 'usage')` 并通过 `assembleAssistantStream` 组装提供商输出;子代理输出折叠追加 `joinAssistantStreamText`;Session Controller 用 `assistantStreamChunks(stream, 'block-end')` 扫描镜像。 +Session Stats、Chat 与 Trajectory 从 `assistant/attempt` 和 `assistant/message` 读取 `assistantStreamFirstTokenTime`,跨重试保留步骤的首个 token。Chat 与 Trajectory 从组装后的消息结算内容,并独立读取计时,因此重新打开历史时无需展开流便能保留 TTFT 与解码指标。token 计量读取 `lastAssistantStreamChunk(stream, 'usage')` 并通过 `assembleAssistantStream` 组装提供商输出;子代理输出折叠追加 `joinAssistantStreamText`;Session Controller 用 `assistantStreamChunks(stream, 'block-end')` 扫描镜像。 `expandAssistantStream` 保留其严格校验与其余调用方(需要每个成员或在持久边界校验流):Session 恢复校验、v1-to-v2 迁移校验器与发布 Worker 重放、重连基线、测试支撑。 diff --git a/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.i18n.yaml new file mode 100644 index 0000000000..c9f4dad8fa --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md +2026-09-09-ptc-trajectory-code-inspection.md: 477d199f519b5d515e5d58430bd902d9d209e46b +2026-09-09-ptc-trajectory-code-inspection.zh.md: f305569176214c63ac549b0ec5103289e3e57a88 diff --git a/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md b/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md new file mode 100644 index 0000000000..477d199f51 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md @@ -0,0 +1,29 @@ +# Agent Note: Inspect recorded PTC source in Trajectory + +Status: implemented + +English | [中文](2026-09-09-ptc-trajectory-code-inspection.zh.md) + +## Problem + +PTC programs arrive as JSON string arguments. Escaping makes long programs difficult to read in a generic argument tree, while historical calls may use a different runtime language from the current deployment. Recorded result text can contain both printed output and a returned value without retaining their separation. + +## Decision + +[Trajectory](../../../../packages/client/ui-trajectory/README.md) identifies calls by their recorded tool name and derives a code inspector from validated `run_code` arguments. The validated program carries the original JSON text; copying preserves source and argument bytes and exposes the original JSON through a separate toggle. Syntax highlighting requires an unambiguous TypeScript or Python hint in that call's recorded parameter description; unknown or conflicting hints leave plain text. Unsupported arguments retain the generic inspector. + +The result view preserves recorded text and uses a tree only for complete JSON objects or arrays. Errors retain captured output. Code views sample the shared wrapping preference when opened and keep their own choice while mounted. + +The [PTC runtime decision](2026-06-15-ptc.md) still owns execution and settlement; the [client presentation decision](../architecture/2026-08-23-client-derived-tool-presentation.md) still owns deriving UI from recorded facts. This inspector adds no Session events or host presentation fields. + +## Alternatives considered + +**Keep source inside the argument tree.** JSON escaping obscures program structure and makes copying executable source cumbersome. + +**Use the active runtime language or infer it from source.** Either can mislabel a historical program. Recorded schema hints constrain highlighting without changing the recording. + +**Split printed output from return values.** Historical rendered text does not establish that distinction; a parser could invent a separation the producer never recorded. + +## Consequences + +Readers can inspect and copy recorded programs without changing replay data. Schemas with no recognizable language hint receive no syntax highlighting. Component tests cover recorded-name recognition, schema fallback, exact source and argument copying, output states, and independent wrapping. JSON-tree tests cover clipping geometry, missing `ResizeObserver`, clipboard settlement after hover changes or unmount, and value-read counts during hover. Thinking tests cover body arrival, manual disclosure, and switching records; the [PTC browser scenario](../../../../apps/web/tests/ptc-round.e2e.ts) pins the assembled inspector and verifies overflow and the original-JSON round trip. diff --git a/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md b/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md new file mode 100644 index 0000000000..f305569176 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 在 Trajectory 中检查已记录的 PTC 源码 + +Status: implemented + +[English](2026-09-09-ptc-trajectory-code-inspection.md) | 中文 + +## 问题 + +PTC 程序以 JSON 字符串参数传入。转义使长程序在通用参数树中难以阅读,而历史调用使用的运行时语言可能不同于当前部署。记录的结果文本可以同时包含打印输出与返回值,却未保留二者的分界。 + +## 决策 + +[Trajectory](../../../../packages/client/ui-trajectory/README.zh.md) 按已记录的工具名识别调用,并从已验证的 `run_code` 参数派生代码检查器。验证后的程序携带原始 JSON 文本;复制时保留源码与参数字节,并通过独立切换按钮展示原始 JSON。语法高亮要求该调用记录的参数说明包含明确且无冲突的 TypeScript 或 Python 提示;未知或冲突的提示使用纯文本。不支持的参数保留通用检查器。 + +结果视图保留记录的文本,只对完整 JSON 对象或数组使用树形展示。错误保留已捕获的输出。代码视图在打开时读取共享换行偏好,并在挂载期间保留自身的选择。 + +[PTC 运行时决策](2026-06-15-ptc.zh.md) 仍负责执行与结算;[客户端展示决策](../architecture/2026-08-23-client-derived-tool-presentation.zh.md) 仍负责从已记录事实派生 UI。此检查器不增加 Session 事件或宿主展示字段。 + +## 考虑过的替代方案 + +**把源码保留在参数树中。** JSON 转义遮蔽程序结构,也使复制可执行源码变得繁琐。 + +**使用当前运行时语言,或从源码推断语言。** 两种方式都可能错误标注历史程序。已记录 Schema 的提示约束高亮,无需修改记录。 + +**拆分打印输出与返回值。** 历史渲染文本无法确定二者的分界;解析器可能凭空添加生产方从未记录的分隔。 + +## 后果 + +读者可以检查和复制已记录的程序,无需修改回放数据。Schema 没有可识别的语言提示时不提供语法高亮。组件测试覆盖记录工具名识别、Schema 回退、源码与参数原样复制、输出状态及独立换行。JSON 树测试覆盖裁剪几何、缺少 `ResizeObserver`、悬停切换或卸载后剪贴板写入落定,以及悬停期间读取值的次数。思考测试覆盖正文到达、手动展开折叠及记录切换;[PTC 浏览器场景](../../../../apps/web/tests/ptc-round.e2e.ts) 固定组装后的检查器展示,并验证溢出和原始 JSON 的往返切换。 diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml new file mode 100644 index 0000000000..21ad4429b4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md +2026-09-09-web-sidebar-terminal.md: ce1162a84d3f96336cbb217dbdfe62d12ac23280 +2026-09-09-web-sidebar-terminal.zh.md: 51e0f374b5fde389323868e56fbd86d3bc5720b3 diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md new file mode 100644 index 0000000000..ce1162a84d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md @@ -0,0 +1,45 @@ +# Agent Note: Web sidebar terminals + +Status: implemented + +English | [中文](2026-09-09-web-sidebar-terminal.zh.md) + +## Problem + +Web users need an interactive shell beside a Session to inspect the workspace and run commands. The Agent's persistent terminal tools control prompts and wait for semantic results; a human terminal instead needs raw keyboard input, normal shell configuration and a full screen. Browser rendering and transport can disappear while a command is still running. + +## Decision + +`api-terminal-controller` owns user terminals by Session and exposes the `terminal` Remote namespace. `ui-sidebar-terminal` registers native right-sidebar tabs, xterm.js rendering and FitAddon sizing. A new terminal offers installed shells and waits for Start. Host discovery verifies the configured candidates, with the execution default first; creation accepts only a currently discovered path. The browser remembers the last selected shell path in origin-scoped localStorage and falls back to the current default if that path is unavailable. The terminal type declares independent instances, so ordinary page deduplication cannot collapse separate processes when opening or docking tabs. The existing sidebar controls open additional tabs; double-clicking a tab title renames its terminal. Terminal processes use the composed subprocess provider and Session sandbox policy. Shell resolution occurs during discovery and creation; reading limits and reconnecting an existing process do not depend on the default executable remaining available. Interactive shell configuration supplies Tab completion and optional inline suggestions. + +Close and replacement remove the tab synchronously and run process cleanup in the background. The Client first records the unfinished close request under a terminal-specific localStorage key; success removes it, and startup retries requests that remain. A cleanup failure produces a lightweight notification with a retry action without reopening the tab. Independent keys prevent another window from overwriting unrelated cleanup requests. Collapse, tab/Session switching, floating, fullscreen and browser disconnection preserve the process. Component cleanup and `TabDomain.signal` only detach browser work because the same lifetime can end during plugin reload. Failed process cleanup retains ownership, including failures after allocation but before create publication. Session owner disposal and Host plugin disposal also clean up terminals. A definitive missing-Session response retires its saved close request because the Session owns process cleanup; transport failures remain retryable. Client plugin disposal awaits every detached stream so a replacement plugin does not inherit unfinished Client cleanup. + +Sidebar layout, open-tab mappings, selection and process PIDs are not persisted. When the Session header mounts, the Client queries `terminal.list` and opens retained Host terminals as new tabs. Listing takes the Session ID directly because history can outlive its Agent and terminal owner; an offline Session has no retained terminals to restore. Their `params.terminalId` association exists only in the current page. New and recovered views use different `createWhenMissing` values: only a new view may allocate a process; a recovered target that disappears reports an error. Host state supplies recovery identities and titles, so the browser does not maintain a second active-terminal registry. + +The caller retains a terminal ID in memory before creating it. Repeating create with the same Session and open ID does not allocate a second process. Closing an uncertain create uses that ID even when no creation response arrived. The Host records closed IDs before awaiting allocation, preventing a delayed create from reviving a closed terminal. Each attachment begins with a consistent, bounded serialized xterm screen; ordered output follows through the Gateway's existing multiplexed Remote stream. Output and screen snapshots share one operation queue. Followers retain final output on normal closure and fail explicitly on buffer overflow. Browser render acknowledgement prevents React batching from dropping increments. + +The latest attachment controls input and dimensions; other attachments remain read-only. Every physical stream opening gets a fresh input attachment identity, including automatic transport recovery. Input RPCs are serialized, and results from a superseded attachment cannot downgrade a replacement connection. Terminal output creates no model input, Agent tool result or Session event. The existing [persistent Agent terminal decision](2026-07-16-persistent-pty-sessions.md) continues to govern model-owned sessions; this feature extends the [portable subprocess provider](../architecture/2026-07-28-portable-execution-world-consumers.md) with terminal environment facts and resize. Control-transfer and process-exit refusals only disable input, preserving the healthy output view. Client-owned error identifiers are translated by the terminal UI, including guidance to close retained exited terminals when the quota is full. + +## Alternatives considered + +**Persist complete shell profiles in the browser.** Only the selected path is a user preference. Arguments and availability belong to Host discovery; persisting them would allow stale profiles to bypass current execution configuration. The sidebar continues to own the terminal list and tab controls. + +**Keep the tab visible until process cleanup finishes.** A slow or failed termination would delay the user's close action. Saving the cleanup intent allows immediate removal while preserving failure reporting and retry. + +**Persist sidebar layout or an active tab-to-process registry.** Layout persistence is outside this feature. An additional active registry duplicates Host state and can restore stale associations. Only an unfinished close is an independent user request that must survive page reload. + +**Share the Agent terminal registry.** Its controlled prompts and semantic send/wait behavior would change human shell configuration and blur process ownership. User terminals share the subprocess capability instead. + +**Add a dedicated terminal WebSocket.** The existing Remote stream transport already owns authentication, cancellation and reconnection. A second carrier would duplicate those responsibilities. + +**Replay only a bounded raw byte tail.** A tail can begin inside an escape sequence or omit an alternate-screen transition. A serialized terminal screen provides a consistent recovery point with bounded history. + +**Kill when a React body or tab signal is disposed.** Unmount, Session switching and plugin reload can end those lifetimes without an explicit close. Cleanup must follow the user's close operation. + +**Build a Web completion engine.** Native shell completion already handles commands, paths and configured plugins through ordinary PTY input. An independent completion UI adds shell-specific parsing and synchronization; it is outside this feature. + +## Consequences + +A kept-open terminal retains a process and bounded screen memory. Reload restores Host-retained terminals rather than the previous sidebar layout; Host restart does not restore processes. An exited shell remains visible without automatic respawn. Background cleanup may outlive its tab, and unavailable browser storage limits retry recovery to the current page. Native PTY support and descendant cleanup guarantees remain provider-specific. One writable attachment avoids competing resize and input streams, while explicit takeover permits recovery from another page. Changing sandbox mode requires closing retained terminals first. + +The implementation retains the Agent-terminal and portable-execution notes because their ownership and provider decisions remain independently useful; neither is superseded by browser terminals. diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md new file mode 100644 index 0000000000..51e0f374b5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md @@ -0,0 +1,45 @@ +# Agent Note: Web sidebar terminals + +Status: implemented + +[English](2026-09-09-web-sidebar-terminal.md) | 中文 + +## 问题 + +Web 用户需要在 Session 旁使用交互式 shell 检查工作区和运行命令。Agent 的持久终端工具控制提示符并等待语义结果;人工终端需要原始键盘输入、正常 shell 配置和完整屏幕。命令仍在运行时,浏览器渲染和网络连接可能消失。 + +## 决定 + +`api-terminal-controller` 按 Session 管理用户终端并提供 `terminal` Remote namespace。`ui-sidebar-terminal` 注册原生右侧栏标签页,使用 xterm.js 渲染和 FitAddon 测量尺寸。新终端提供已安装 shell 的选择并等待用户启动。Host 探测会验证配置的候选,并把执行环境默认项放在首位;创建只接受当前探测返回的路径。浏览器在当前站点 localStorage 中记住上次选择的 shell 路径,该路径不可用时回到当前默认项。终端类型声明独立实例,因此打开或停靠标签页时,普通页面的去重规则不会合并不同进程。已有侧栏控件负责打开更多标签页,双击标签页标题可重命名终端。终端进程使用组合的 subprocess provider 和 Session sandbox policy。shell 在探测和创建时解析;读取限制和重新连接已有进程不依赖默认可执行文件仍然可用。交互式 shell 配置提供 Tab 补全和可选的内联建议。 + +关闭和替换会同步移除标签页,并在后台清理进程。Client 先以终端独立的 localStorage key 保存未完成的关闭请求;成功后删除,启动时重试剩余请求。清理失败时显示带重试操作的轻量通知,不重新打开标签页。独立 key 避免其他窗口覆盖无关的清理请求。折叠、切换标签页或 Session、浮动、全屏和浏览器断线均保留进程。组件清理和 `TabDomain.signal` 只停止浏览器工作,因为插件重新加载也会结束这些生命周期。进程清理失败时保留所有权,包括分配完成但 create 尚未发布时的失败。Session owner 和 Host 插件卸载也会清理终端。 明确的 Session 不存在响应会清除已保存的关闭请求,因为进程清理由 Session 负责;传输失败仍可重试。Client 插件卸载等待所有断开的流结束,避免替换插件继承未完成的 Client 清理。 + +侧栏布局、打开标签页映射、选中项和进程 PID 不持久化。Session header 挂载时,Client 查询 `terminal.list`,把 Host 保留的终端打开为新标签页。列表直接使用 Session ID,因为历史记录可以比 Agent 和终端 owner 存活更久;离线 Session 没有需要恢复的保留终端。`params.terminalId` 关联只在当前页面中保留。新视图与恢复视图使用不同的 `createWhenMissing`:只有新视图可以分配进程,恢复目标消失时显示错误。恢复标识和标题来自 Host 状态,浏览器不维护第二份活跃终端注册表。 + +调用者在创建之前把终端 ID 保留在内存中。相同 Session 和未关闭 ID 的重复 create 不再分配进程。创建结果不确定时,关闭仍使用该 ID,即使没有收到创建响应。Host 在等待分配完成前记录已关闭 ID,防止迟到的 create 复活已关闭终端。每次连接先接收一致、有界的 xterm 序列化屏幕,后续有序输出使用 Gateway 已有的复用 Remote stream。输出和屏幕快照共享操作队列。订阅者正常关闭时保留末尾输出,缓存超限时明确失败。浏览器在完成渲染后确认帧,避免 React 批处理丢失增量。 + +最新连接控制输入和尺寸,其他连接保持只读。每次物理流建立都创建新的输入连接标识,包括传输自动恢复。输入 RPC 按序发送,旧连接的结果不能把新连接降级为失败。终端输出不产生模型输入、Agent 工具结果或 Session 事件。[Agent 持久终端决策](2026-07-16-persistent-pty-sessions.zh.md)仍约束模型拥有的终端;此功能为[可移植 subprocess provider](../architecture/2026-07-28-portable-execution-world-consumers.zh.md)增加执行环境事实和 resize。 控制权转移和进程退出导致的拒绝只禁用输入,保留健康的输出视图。Client 自产错误标识由终端 UI 翻译,包括名额用满时关闭已保留的退出终端的提示。 + +## 考虑过的替代方案 + +**在浏览器保存完整 shell profile。** 用户偏好只包含选中的路径。参数和可用性由 Host 探测决定;持久化这些信息会让过期 profile 绕过当前执行配置。终端列表和标签页控件仍由侧栏负责。 + +**进程清理完成前保留标签页。** 缓慢或失败的终止会拖延用户关闭操作。保存清理意图后,可以立即移除标签页,同时保留错误反馈与重试。 + +**持久化侧栏布局或活跃标签页到进程的注册表。** 布局持久化不属于此功能。额外的活跃注册表重复 Host 状态,可能恢复过期关联。只有未完成的关闭操作是需要跨页面刷新保留的独立用户请求。 + +**共用 Agent 终端注册表。** 受控提示符和语义化 send/wait 会改变人工 shell 配置并混淆进程所有权。用户终端只共享 subprocess 能力。 + +**增加专用终端 WebSocket。** 现有 Remote stream 已管理认证、取消和重连。第二条传输通道会重复这些职责。 + +**只重放有界原始字节尾部。** 字节尾部可能从转义序列中间开始,或缺失备用屏幕切换。序列化终端屏幕能在保留有界历史的同时提供一致恢复点。 + +**React 正文或 tab signal 卸载时 kill。** 组件卸载、Session 切换和插件重载都可能结束这些生命周期,而用户并未关闭终端。清理必须跟随显式关闭操作。 + +**自建 Web 补全引擎。** shell 原生补全通过普通 PTY 输入处理命令、路径和已配置插件。独立补全界面需要针对 shell 解析和同步,不属于此功能。 + +## 影响 + +保留终端会保留进程和有界屏幕内存。刷新恢复的是 Host 保留的终端,不是此前的侧栏布局;Host 重启不恢复进程。shell 退出后保持可见,不自动重启。后台清理可能比标签页存活更久,浏览器存储不可用时只能在当前页面保留重试能力。原生 PTY 支持和后代进程清理保证仍由 provider 决定。单一可写连接避免竞争的输入和尺寸流,显式接管允许从另一页面恢复操作。改变 sandbox mode 前需要关闭保留的终端。 + +Agent 终端和可移植执行环境两篇记录仍保留,其所有权与 provider 决策继续独立有效,不被浏览器终端取代。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 00227af565..5f61422ee3 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -58,7 +58,10 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT | | [`@trycua/cua-driver`](https://github.com/trycua/cua) | MIT | | [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT | +| [`@xterm/addon-fit`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-fit) | MIT | +| [`@xterm/addon-serialize`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-serialize) | MIT | | [`@xterm/headless`](https://github.com/xtermjs/xterm.js) | MIT | +| [`@xterm/xterm`](https://github.com/xtermjs/xterm.js) | MIT | | [`@yarnpkg/parsers`](https://github.com/yarnpkg/berry) | BSD-2-Clause | | [`acorn`](https://github.com/acornjs/acorn) | MIT | | [`anser`](https://github.com/IonicaBizau/anser) | MIT | diff --git a/apps/web/package.json b/apps/web/package.json index 785ec5d2d3..8ff2f5e995 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -59,6 +59,7 @@ "ws": "8.21.0", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-workspace-changes": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-experimental-auto-review": "workspace:^" } } diff --git a/apps/web/tests/assembled-remote.ts b/apps/web/tests/assembled-remote.ts index fd84fb468c..69739dc7a7 100644 --- a/apps/web/tests/assembled-remote.ts +++ b/apps/web/tests/assembled-remote.ts @@ -2,6 +2,7 @@ * RemoteMock scenario for built-client tests that do not own a Host. * The adjacent JSON is maintained with this module when Remote responses or * the current Session header version change. + * Fixture Sessions have no retained Host terminals to restore. */ import { readFileSync } from 'node:fs' @@ -139,6 +140,7 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass 'settings/openSettingsDocument': ok({ opened: true }), 'settings/openAgentPresetDirectory': ok({ opened: true }), 'subagents/list': ok({ entries: [], parentAvailable: true }), + 'terminal/list': ok([]), 'skills/list': ok({ skills: [] }), 'session/canOpenWorkspacePath': ok(true), 'session/openWorkspacePath': ok({ opened: true }), diff --git a/apps/web/tests/clickable-links-gallery.e2e.ts b/apps/web/tests/clickable-links-gallery.e2e.ts index 8d5ce306d5..a5d8101278 100644 --- a/apps/web/tests/clickable-links-gallery.e2e.ts +++ b/apps/web/tests/clickable-links-gallery.e2e.ts @@ -45,6 +45,8 @@ const OVERLAY = fileURLToPath(new URL('./changed-files.overlay.yml', import.meta const MODE = webSnapshotMode() const SEED_ID = 'clickable-links-gallery-web-e2e' const DONE = 'LINK_GALLERY_DONE' +// Seeded events and browser time share a day independently of host timezones. +const GALLERY_TIME = Date.UTC(2026, 0, 15, 12) const GUIDE_URL = 'https://docs.example.test/guide' const API_URL = 'https://docs.example.test/api' @@ -192,7 +194,6 @@ const CALLS: GalleryCall[] = [ */ function galleryFixture(imageUrl: string): string { const session = Session.create(SessionId('clickable-links-gallery-source')) - const eventTimeOrigin = new Date().setHours(12, 0, 0, 0) session.append('turn/start', { turn: 1 }) const user = session.append('user/message', createUserMessage({ content: text('Assemble the link gallery: write the report and styles, inspect the sources, and summarize.'), @@ -289,7 +290,7 @@ function galleryFixture(imageUrl: string): string { }), ...session.snapshotEvents().map(event => JSON.stringify({ ...event, - time: eventTimeOrigin + event.seq * 1_000, + time: GALLERY_TIME + event.seq * 1_000, })), '', ].join('\n') @@ -305,9 +306,10 @@ describe('web e2e: clickable links gallery', () => { beforeAll(async () => { scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY }) imageUrl = new URL('/favicon.svg', scaffold.baseUrl).toString() - await seedSession(scaffold, galleryFixture(imageUrl), SEED_ID) + await seedSession(scaffold, galleryFixture(imageUrl), SEED_ID, undefined, { createdAt: GALLERY_TIME }) browser = await chromium.launch() page = await newEnglishPage(browser) + await page.clock.setFixedTime(GALLERY_TIME + 60_000) tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index 14245fafa0..a02bafac00 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -243,6 +243,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S await select(original, 'LIGHTHOUSE') await open() + await column.locator('[data-sidebar-right-guide-entry="files"]').click() // The content-box panel adds its one rendered border pixel outside the // CSS width assigned by the grid solver. await expect.poll(() => sidebarSnapshot(page), { timeout: 5_000 }) @@ -256,6 +257,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S await expect.poll(() => split.isDisabled()).toBe(false) await split.click() await expect.poll(() => panes.count()).toBe(2) + await panes.last().locator('[data-sidebar-right-guide-entry="files"]').click() await panes.first().locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).click() await expect.poll(() => panes.first().locator('[data-files-state="tree"]').count()).toBe(1) const retainedA = await paneSnapshot(page) @@ -278,6 +280,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S await expect.poll(() => detailsTrack(page)).toBe(0) await open() expect(await panel.getAttribute('data-sidebar-right-panel')).toBe('push') + await column.locator('[data-sidebar-right-guide-entry="files"]').click() await column.locator('[data-files-state="tree"]').waitFor({ timeout: 15_000 }) const workspaceDirectory = column.locator('[data-files-entry="directory"] > button').filter({ hasText: /^workspace$/ }) await workspaceDirectory.waitFor({ timeout: 15_000 }) diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index 8715b98d3d..0e1c570f2a 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -134,6 +134,7 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () const column = page.locator('[data-rightbar-col]') await page.locator('[data-sidebar-right-expand]').click() + await column.locator('[data-sidebar-right-guide-entry="files"]').click() await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' }) await column.locator('[data-files-reload]').click() const filesTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('Files', { exact: true }) }) diff --git a/apps/web/tests/expected/sidebar-terminal/limit.expected.md b/apps/web/tests/expected/sidebar-terminal/limit.expected.md new file mode 100644 index 0000000000..845c392f43 --- /dev/null +++ b/apps/web/tests/expected/sidebar-terminal/limit.expected.md @@ -0,0 +1 @@ +- alert: "Terminal error: The terminal limit has been reached. Close unused terminals and try again. Exited terminals also count toward the limit." diff --git a/apps/web/tests/expected/sidebar-terminal/running.expected.md b/apps/web/tests/expected/sidebar-terminal/running.expected.md new file mode 100644 index 0000000000..91f8d58a99 --- /dev/null +++ b/apps/web/tests/expected/sidebar-terminal/running.expected.md @@ -0,0 +1 @@ +- textbox "Terminal" diff --git a/apps/web/tests/expected/sidebar-terminal/selection.expected.md b/apps/web/tests/expected/sidebar-terminal/selection.expected.md new file mode 100644 index 0000000000..21961c3c4d --- /dev/null +++ b/apps/web/tests/expected/sidebar-terminal/selection.expected.md @@ -0,0 +1,3 @@ +- text: Shell +- button "Shell": bash — /bin/bash +- button "Start terminal" diff --git a/apps/web/tests/expected/sidebar-terminal/shell-menu.expected.md b/apps/web/tests/expected/sidebar-terminal/shell-menu.expected.md new file mode 100644 index 0000000000..50be37e43b --- /dev/null +++ b/apps/web/tests/expected/sidebar-terminal/shell-menu.expected.md @@ -0,0 +1,5 @@ +- menu: + - menuitem "bash — /bin/bash": + - text: bash — /bin/bash + - img + - menuitem "sh — /bin/sh" diff --git a/apps/web/tests/fixtures/sidebar-terminal.patch.yml b/apps/web/tests/fixtures/sidebar-terminal.patch.yml new file mode 100644 index 0000000000..8b96197c79 --- /dev/null +++ b/apps/web/tests/fixtures/sidebar-terminal.patch.yml @@ -0,0 +1,8 @@ +- id: terminal-controller + config: + maxTerminals: 2 + shellCandidates: [/bin/bash, /bin/sh] + shell: + path: /bin/bash + name: bash + args: [--noprofile, --norc, -i] diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 7ad725a827..4af796b3ef 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -290,7 +290,12 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () try { await input.press('Enter') if (MODE !== 'record') { - const liveTail = page.locator('[data-variant="think"][data-state="running"] [data-follow-end]') + const thinking = page.locator('[data-variant="think"][data-state="running"]') + const disclosure = thinking.getByRole('button') + await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true') + await disclosure.click() + await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('false') + const liveTail = thinking.locator('[data-follow-end]') await expect.poll(async () => { if (await liveTail.count() !== 1) return false return await liveTail.evaluate((element) => { diff --git a/apps/web/tests/markdown-wide-table.e2e.ts b/apps/web/tests/markdown-wide-table.e2e.ts index 09f980ab5c..45d889c6d4 100644 --- a/apps/web/tests/markdown-wide-table.e2e.ts +++ b/apps/web/tests/markdown-wide-table.e2e.ts @@ -56,6 +56,7 @@ const TAIL_MARKER = 'MWT_TABLES_DONE' const FILL_MARKER = 'MWT_FILL_C1' const WIDE_MARKER = 'MWT_WIDE_C01' const LONG_CELL_MARKER = 'MWT_LONGCELL_F1' +const SHORT_MARKER = 'MWT_SHORT_C1' const MARKERS = [FILL_MARKER, WIDE_MARKER, LONG_CELL_MARKER] /** Golden-facing names, in {@link MARKERS} order. */ const TABLE_NAMES = ['fill', 'wide', 'long-cell'] @@ -76,13 +77,20 @@ const SENTENCE = 'This cell carries one full sentence so the unwrapped table is const LONG_TOKEN = 'workspace/deepseek-harness/packages/client/ui-primitives/src/markdown/render.tsx/'.repeat(3) const CJK_SENTENCE = '这个单元格包含一段较长的中文说明,用来验证长内容在窄列宽下按最小可读宽度换行而不是把列压缩到无法阅读。' -/** The assistant markdown: one 3-column fill, one 12-column wide, one long-cell table. */ +/** The assistant markdown includes fitting and overflowing wide tables. */ function tablesMarkdown(): string { const wideHeader = [WIDE_MARKER, ...Array.from({ length: 11 }, (_, i) => `C${String(i + 2).padStart(2, '0')}`)] const wideRow = (row: number): string[] => Array.from({ length: 12 }, (_, i) => `v${String(row)}${String(i + 1).padStart(2, '0')}`) return [ - 'Three markdown tables exercise the wide-table layout rules.', + 'Markdown tables exercise the wide-table layout rules.', + '', + `| ${SHORT_MARKER} | C2 | C3 | C4 |`, + '| --- | --- | --- | --- |', + '| 1 | 2 | 3 | 4 |', + '| 5 | 6 | 7 | 8 |', + '', + 'The paragraph after the short table stays in place.', '', `| ${FILL_MARKER} | Current approach | Proposed approach |`, '| --- | --- | --- |', @@ -103,7 +111,7 @@ function tablesMarkdown(): string { ].join('\n') } -/** Build one closed, invariant-checked session fixture carrying the three tables. */ +/** Build one closed, invariant-checked session fixture carrying the tables. */ function wideTableFixture(): string { const session = Session.create(SessionId('markdown-wide-table-source')) const eventTimeOrigin = new Date().setHours(12, 0, 0, 0) @@ -257,7 +265,8 @@ describe('web e2e: markdown tables fill the column, wide ones break out and scro beforeAll(async () => { scaffold = await launchWebScaffold({}) await seedSession(scaffold, wideTableFixture(), SEED_ID) - browser = await chromium.launch() + // The geometry assertions include the space occupied by native scrollbars. + browser = await chromium.launch({ ignoreDefaultArgs: ['--hide-scrollbars'] }) page = await newEnglishPage(browser) tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) @@ -386,13 +395,42 @@ describe('web e2e: markdown tables fill the column, wide ones break out and scro // Resting hidden overflow keeps the scroll position reachable and intact. expect(await wide.evaluate(element => element.scrollLeft)).toBeGreaterThanOrEqual(0) await wide.hover() - await expect.poll(overflowState, { timeout: 5_000 }).toBe('auto 0px') + await expect.poll(overflowState, { timeout: 5_000 }).toBe('scroll 0px') // Pointer leaves: the bar rests hidden again. await page.mouse.move(4, 4) await expect.poll(overflowState, { timeout: 5_000 }).toBe('hidden 8px') expect(tripwire.pageErrors).toEqual([]) }, 120_000) + it('keeps a fitting wide table and its following paragraph stationary during interaction', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-short-table-height')) + await settleAt(1680) + const short = page.locator('[class*="tableScroll"]', { hasText: SHORT_MARKER }) + await short.evaluate((element) => { element.scrollIntoView({ block: 'center', behavior: 'instant' }) }) + await page.mouse.move(4, 4) + await short.evaluate((element) => { element.blur() }) + expect(await short.evaluate(element => element.classList.contains('md-table-wide'))).toBe(true) + expect(await short.evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1) + const position = () => short.evaluate((element) => { + const following = element.nextElementSibling + if (following === null) throw new Error('short table has no following paragraph') + return { + height: element.getBoundingClientRect().height, + followingTop: following.getBoundingClientRect().top, + } + }) + const resting = await position() + await short.hover() + await expect.poll(position).toEqual(resting) + await page.mouse.move(4, 4) + await short.focus() + expect(await short.evaluate(element => document.activeElement === element)).toBe(true) + await expect.poll(position).toEqual(resting) + await short.evaluate((element) => { element.blur() }) + await expect.poll(position).toEqual(resting) + expect(tripwire.pageErrors).toEqual([]) + }, 120_000) + it('keeps the fill/scroll relations under page zoom', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table-zoom')) await sweep() diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index a76b88bfb2..22d35431e1 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -24,6 +24,7 @@ import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './supp const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/navigation-panes', import.meta.url)) const SEED = join(SNAPSHOT_DIR, 'session.v3.jsonl') const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md') +const TIMING_EXPECTED = join(SNAPSHOT_DIR, 'timing.expected.md') const SEARCH_EXPECTED = join(SNAPSHOT_DIR, 'search-results.expected.md') const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md') const MODE = webSnapshotMode() @@ -97,7 +98,8 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { const raw = await readFile(SEED, 'utf8') expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the two drive prompts') .toEqual([PROMPT_TURN1, PROMPT_TURN2]) - await seedSession(scaffold, raw, SEED_ID) + // The inspector's calendar date must not depend on the day the test runs. + await seedSession(scaffold, raw, SEED_ID, undefined, { createdAt: Date.UTC(2026, 0, 1) }) } browser = await chromium.launch() }, 120_000) @@ -264,13 +266,30 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') }) await page.getByRole('tab', { name: 'Result' }).click() await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1) - expect(await page.locator('[data-timeline-span="message"][data-assistant-timing="true"]').count()).toBe(0) + expect(await page.locator('[data-timeline-span="message"][data-assistant-timing="true"]').count()).toBeGreaterThan(0) const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE) await details.getByRole('button', { name: 'Close details' }).click() }, 60_000) + it.skipIf(MODE === 'record')('restores Assistant timing from recorded history', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timing')) + await ensureSeedOpen(page) + await page.getByRole('tab', { name: 'Trajectory' }).click() + await page.getByRole('button', { name: 'Request #1', exact: true }).click() + const details = page.getByRole('complementary', { name: 'Event details' }) + await details.getByRole('tab', { name: 'Timing', exact: true }).click() + const panel = details.getByRole('tabpanel', { name: 'Timing' }) + for (const metric of ['TTFT', 'Generation', 'Throughput']) { + const value = panel.getByText(metric, { exact: true }).locator('..').locator('dd') + await expect.poll(() => value.textContent()).toMatch(/^\d/) + } + expect(await panel.getByText('First token unavailable', { exact: true }).count()).toBe(0) + const snapshot = await captureStableAria(page, '#trajectory-detail-panel', scaffold.workspaceCwd) + await compareOrRefreshGolden(TIMING_EXPECTED, snapshot, MODE) + }, 60_000) + it.skipIf(MODE === 'record')('downloads through the Session Header and /export with one dialog', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-export')) await ensureSeedOpen(page) @@ -504,7 +523,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { it.skipIf(MODE === 'record')('keeps the recorded fixture inventory exact', async () => { await assertFixtureInventory(SNAPSHOT_DIR, [ 'session.v3.jsonl', 'search-results.expected.md', 'trajectory.expected.md', - 'terminal-card.expected.md', + 'terminal-card.expected.md', 'timing.expected.md', ]) }) }) diff --git a/apps/web/tests/ptc-round.e2e.ts b/apps/web/tests/ptc-round.e2e.ts index 045c475bb6..54bcc461c9 100644 --- a/apps/web/tests/ptc-round.e2e.ts +++ b/apps/web/tests/ptc-round.e2e.ts @@ -8,13 +8,15 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { - acknowledgeReloadConnectionLoss, captureExpandedTurnProcessAria, compareOrRefreshGolden, fixtureUserPrompts, + acknowledgeReloadConnectionLoss, captureExpandedTurnProcessAria, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/ptc-round/session.v3.jsonl', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/ui.expected.md', import.meta.url)) +const TRAJECTORY_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/trajectory.expected.md', import.meta.url)) +const CODE_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/code.expected.md', import.meta.url)) const MODE = webSnapshotMode() // Elicits the successful and failed sub-rows this scenario asserts. @@ -151,6 +153,47 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => { await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) }) + it.skipIf(MODE === 'record')('inspects recorded PTC source, wrapping, and original JSON in the trajectory', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-ptc-inspector')) + const call = sessionEvents.find(event => event.type === 'tool/call' && event.data.name === 'run_code') + if (call?.type !== 'tool/call') throw new Error('recorded PTC call missing') + const args = JSON.parse(call.data.arguments) as { code: string; description: string } + await page.getByRole('tab', { name: 'Trajectory', exact: true }).click() + const row = page.locator('tr[data-kind="tool"]').filter({ hasText: 'run_code' }).first() + await row.click() + await page.getByRole('tab', { name: 'Code', exact: true }).waitFor() + expect(await page.getByRole('tabpanel').textContent()).toContain(args.description) + expect(await page.getByRole('tabpanel').locator('dl').first().locator('dt').allTextContents()) + .toEqual(['Hierarchy', 'Status']) + const overview = await Promise.all([1, 2].map(index => captureStableAria( + page, `[role="tabpanel"] [class*="overviewSections"] > section:nth-child(${index})`, scaffold.workspaceCwd, + ))) + await compareOrRefreshGolden(TRAJECTORY_EXPECTED, overview.join('\n'), MODE) + + await page.getByRole('button', { name: 'Code', exact: true }).click() + const source = page.locator('[data-line-numbers] pre') + await source.waitFor() + expect(await source.textContent()).toBe(args.code.endsWith('\n') ? args.code.slice(0, -1) : args.code) + const wrap = page.getByRole('button', { name: 'Wrap lines', exact: true }) + expect(await wrap.getAttribute('aria-pressed')).toBe('false') + const content = page.locator('[data-wrap]').filter({ has: source }) + expect(await content.evaluate(element => element.scrollWidth > element.clientWidth)).toBe(true) + await wrap.click() + expect(await wrap.getAttribute('aria-pressed')).toBe('true') + await expect.poll(() => content.evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1) + const code = await captureStableAria(page, '[role="tabpanel"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(CODE_EXPECTED, code, MODE) + + await page.getByRole('button', { name: 'Original JSON' }).click() + const json = page.getByRole('tree', { name: 'parameters JSON' }) + await json.waitFor() + expect(await json.textContent()).toContain(args.description) + await page.getByRole('button', { name: 'Original JSON' }).click() + expect(await source.textContent()).toBe(args.code.endsWith('\n') ? args.code.slice(0, -1) : args.code) + await page.getByRole('tab', { name: 'Summary', exact: true }).click() + expect(await wrap.getAttribute('aria-pressed')).toBe('true') + }) + it.skipIf(MODE === 'record')('stayed clean: no page errors, no reconnect churn', () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) diff --git a/apps/web/tests/sidebar-right.e2e.ts b/apps/web/tests/sidebar-right.e2e.ts index 1cc92ddb79..b4ecf4e883 100644 --- a/apps/web/tests/sidebar-right.e2e.ts +++ b/apps/web/tests/sidebar-right.e2e.ts @@ -132,6 +132,7 @@ async function resetSidebar(page: Page): Promise { const column = page.locator('[data-rightbar-col]') await expandOf(page).waitFor({ timeout: 15_000 }) await ensureExpanded(page, column) + await column.locator('[data-sidebar-right-guide-entry="files"]').click() await expect.poll(async () => await tabTitles(column)).toEqual(['Files']) await width(column) return column @@ -391,6 +392,10 @@ describe('web e2e: shipped right Sidebar', () => { expect(await centreY(selector), selector).toBe(textLine) } + await expect.poll(async () => await tabTitles(column)).toEqual(['Start']) + await expect.poll(async () => await column.locator('[data-sidebar-right-guide-entry]').count()).toBe(2) + await column.locator('[data-sidebar-right-guide-entry="files"]').click() + // A manual guide is closable beside Files and suppresses another add // control in its pane until it is closed. const addTab = column.locator('[data-dockkit-add-tab]') @@ -762,10 +767,13 @@ describe('web e2e: shipped right Sidebar', () => { ) await expect.poll(async () => await panes.count()).toBe(2) - const splitFiles = panes.nth(1).locator('[data-dockkit-tab]').filter({ hasText: 'Files' }) - expect(await splitFiles.locator('[data-dockkit-tab-close]').count()).toBe(1) - await dragTo(page, splitFiles, await pointIn(panes.first(), 0.5, 0.5)) + const splitGuide = panes.nth(1).locator('[data-dockkit-tab]').filter({ hasText: 'Start' }) + expect(await splitGuide.locator('[data-dockkit-tab-close]').count()).toBe(1) + await dragTo(page, splitGuide, await pointIn(panes.first(), 0.5, 0.5)) await expect.poll(async () => await tabTitles(panes.nth(1))).toEqual([SAMPLE_NAME]) + const movedGuide = panes.first().locator('[data-dockkit-tab]').filter({ hasText: 'Start' }) + await movedGuide.hover() + await movedGuide.locator('[data-dockkit-tab-close]').click() // Neither pane holds a guide, so both offer an add control. const filePane = panes.filter({ has: page.locator('[data-dockkit-tab-title]', { hasText: SAMPLE_NAME }) }) @@ -902,7 +910,7 @@ describe('web e2e: shipped right Sidebar', () => { expect(await panes.count()).toBe(2) expect(await splitButtons.count()).toBe(0) - // 5. A manual guide and the document float while Files stays docked. + // 5. The split's guide and the document float while Files stays docked. const floatOne = panes.last().locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }) await floatByDrag(page, floatOne) await expect.poll(async () => await floats.count()).toBe(1) @@ -911,8 +919,7 @@ describe('web e2e: shipped right Sidebar', () => { await dragElement(page, floats.first().locator('[data-dockkit-float-grip]'), { x: box.x + 140, y: box.y + 90 }) await expect.poll(async () => (await floats.first().boundingBox())?.x ?? box.x).not.toBe(box.x) - await panes.last().locator('[data-dockkit-add-tab]').click() - await expect.poll(async () => await tabTitles(panes.last())).toEqual(['Files', 'Start']) + await expect.poll(async () => await tabTitles(panes.last())).toEqual(['Start']) const second = panes.last().locator('[data-dockkit-tab]').filter({ hasText: 'Start' }) await floatByDrag(page, second) await expect.poll(async () => await floats.count()).toBe(2) @@ -967,7 +974,7 @@ describe('web e2e: shipped right Sidebar', () => { // Any other tab standing alone closes together with the column. Open the // sample file, close the guide (an ordinary close with two tabs), then // close the file: the column collapses in the same gesture, and the - // settle rule reseeds the current default, so reopening shows Files. + // settle rule reseeds the current default, so reopening shows Start. await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click() await expect.poll(async () => await tabTitles(column)).toEqual(['Start', SAMPLE_NAME]) await column.locator('[data-dockkit-tab]').first().hover() @@ -977,8 +984,8 @@ describe('web e2e: shipped right Sidebar', () => { await column.locator('[data-dockkit-tab-close]').first().click() await expect.poll(async () => await column.locator('[data-sidebar-right-open]').count()).toBe(0) await expandOf(page).click() - await expect.poll(async () => await tabTitles(column)).toEqual(['Files']) - expect(await column.locator('[data-files-state="tree"]').count()).toBe(1) + await expect.poll(async () => await tabTitles(column)).toEqual(['Start']) + expect(await column.locator('[data-sidebar-right-guide]').count()).toBe(1) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) @@ -1004,6 +1011,7 @@ describe('web e2e: shipped right Sidebar', () => { await ensureExpanded(page, column) await expect.poll(async () => await column.locator('[data-dockkit-tab]').count()).toBeGreaterThan(0) + await column.locator('[data-sidebar-right-guide-entry="files"]').click() await column.locator('[data-dockkit-add-tab]').click() await expect.poll(async () => await tabTitles(column)).toEqual(['Files', 'Start']) // No "more" control on the chip: the chip carries its close, and the menu @@ -1051,6 +1059,8 @@ describe('web e2e: shipped right Sidebar', () => { const column = zhPage.locator('[data-rightbar-col]') await expandOf(zhPage).waitFor({ timeout: 20_000 }) await expandOf(zhPage).click() + await expect.poll(async () => await tabTitles(column)).toEqual(['开始']) + await column.locator('[data-sidebar-right-guide-entry="files"]').click() await expect.poll(async () => await tabTitles(column)).toEqual(['文件']) await column.locator('[data-dockkit-add-tab]').click() diff --git a/apps/web/tests/sidebar-terminal.e2e.ts b/apps/web/tests/sidebar-terminal.e2e.ts new file mode 100644 index 0000000000..ba1ee4ca94 --- /dev/null +++ b/apps/web/tests/sidebar-terminal.e2e.ts @@ -0,0 +1,227 @@ +/** Shipped sidebar terminal over the real Loader, Remote mux, Chromium and local PTY. */ +import { mkdir } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { chromium, type Browser, type Page } from 'playwright' +import { afterEach, beforeEach, describe, expect, it, onTestFailed, vi } from 'vitest' +import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/dsh-api-terminal-controller' +import type { SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess' +import { createProcessInspector, type ProcessIdentity } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts' +import { compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const expected = fileURLToPath(new URL('./expected/sidebar-terminal/running.expected.md', import.meta.url)) +const shots = fileURLToPath(new URL('../../../.artifacts/screenshots/sidebar-terminal/', import.meta.url)) + +async function openTerminal(page: Page, waitForShell = true): Promise { + const expand = page.locator('[data-sidebar-right-expand]') + if (await expand.isVisible()) await expand.click() + const entry = page.locator('[data-sidebar-right-guide-entry="terminal"]') + if (!await entry.isVisible()) await page.locator('[data-dockkit-add-tab]').click() + await entry.click() + await page.getByRole('button', { name: 'Start terminal', exact: true }).click() + if (waitForShell) await expect.poll(async () => await page.locator('.xterm-rows:visible').innerText()).toContain('bash-') +} + +async function command(page: Page, text: string): Promise { + await page.locator('.xterm-helper-textarea:visible').click() + await page.keyboard.insertText(text) + await page.keyboard.press('Enter') +} + +describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + let handles: SubprocessTerminalHandle[] + const inspector = createProcessInspector() + const alive = (identity: ProcessIdentity) => inspector.isAlive(identity) + const processIdentity = (index: number): ProcessIdentity => { + const pid = handles[index]!.pid + const identity = inspector.snapshot().tree(pid).find(member => member.pid === pid) + if (identity === undefined) throw new Error(`Terminal process ${pid} is missing`) + return identity + } + + beforeEach(async () => { + scaffold = await launchWebScaffold({ extraOverlayPath: fileURLToPath(new URL('./fixtures/sidebar-terminal.patch.yml', import.meta.url)) }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + const agent = scaffold.ctx.agents.list()[0] + if (agent === undefined) throw new Error('Workspace did not create a Session') + handles = [] + const subprocess = agent.ctx.get('subprocess') + if (subprocess === undefined) throw new Error('Session subprocess provider is missing') + const spawn = subprocess.spawnTerminal.bind(subprocess) + vi.spyOn(subprocess, 'spawnTerminal').mockImplementation(async (spec) => { + const handle = await spawn(spec) + handles.push(handle) + return handle + }) + agent.session.append('turn/start', { turn: 1 }) + agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'Open a terminal.' }], source: { kind: 'user' } }), { surfaceOp: 'append' }) + agent.session.append('step/start', { turn: 1, step: 1 }) + agent.session.append('assistant/message', { stream: [], turn: 1, step: 1, message: createMessage({ role: 'assistant', content: [{ type: 'text', text: 'Ready for terminal input.' }], source: { kind: 'model', provider: 'fixture', model: 'fixture' } }) }, { surfaceOp: 'append' }) + agent.session.append('step/end', { turn: 1, step: 1 }) + agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + await scaffold.ctx.sessions.flush(agent.session) + await page.getByText('Ready for terminal input.').waitFor() + await mkdir(shots, { recursive: true }) + }, 180_000) + + afterEach(async () => { + try { await browser?.close() } finally { + try { await scaffold?.close() } finally { vi.restoreAllMocks() } + } + }) + + it('completes commands, preserves the process through collapse and reload, resizes, and kills on tab close', async () => { + onTestFailed(() => saveFailureShot(page, 'sidebar-terminal')) + await openTerminal(page) + const terminal = page.locator('[data-sidebar-terminal]') + await command(page, "PS1=''; printf '\\033cTERMINAL_READY\\n'") + const screen = page.locator('.xterm-rows:visible') + await expect.poll(async () => await screen.innerText()).toContain('TERMINAL_READY') + const aria = await terminal.ariaSnapshot() + await compareOrRefreshGolden(expected, aria, webSnapshotMode()) + await command(page, "printf 'DSH_PID:%s\\n' \"$$\"") + await expect.poll(async () => await screen.innerText()).toMatch(/DSH_PID:\d+/u) + const pid = Number((await screen.innerText()).match(/DSH_PID:(\d+)/u)?.[1]) + const firstProcess = processIdentity(0) + expect(alive(firstProcess)).toBe(true) + await command(page, 'dsh_terminal_completion_probe(){ printf "completed_from_shell\\n"; }') + await page.keyboard.press('Control+l') + await page.keyboard.insertText('dsh_terminal_completion_pro') + await page.keyboard.press('Tab') + await page.keyboard.press('Enter') + await expect.poll(async () => await screen.innerText()).toContain('completed_from_shell') + await command(page, "printf 'PERSIST:%s\\n' \"$TERM\"") + await expect.poll(async () => await screen.innerText()).toContain('PERSIST:xterm-256color') + await page.locator('[data-dockkit-tab-title]').getByText('bash', { exact: true }).dblclick() + await page.getByRole('textbox', { name: 'Terminal name', exact: true }).fill('Development') + await page.getByRole('textbox', { name: 'Terminal name', exact: true }).press('Enter') + await expect.poll(async () => await page.locator('[data-dockkit-tab-title]').allInnerTexts()).toContain('Development') + await openTerminal(page) + await command(page, "printf 'SECOND_PID:%s\\n' \"$$\"") + await expect.poll(async () => await screen.innerText()).toMatch(/SECOND_PID:\d+/u) + const secondPid = Number((await screen.innerText()).match(/SECOND_PID:(\d+)/u)?.[1]) + // Shell PIDs belong to the sandbox namespace; process liveness uses Host identities. + const secondProcess = processIdentity(1) + expect(secondProcess.pid).not.toBe(firstProcess.pid) + await page.locator('[data-dockkit-tab]').filter({ hasText: 'Development' }).click() + await expect.poll(async () => await screen.innerText()).toContain('PERSIST:xterm-256color') + expect(alive(secondProcess)).toBe(true) + await page.getByRole('button', { name: 'Collapse right sidebar', exact: true }).click() + expect(alive(firstProcess)).toBe(true) + await page.locator('[data-sidebar-right-expand]').click() + const terminals = () => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!.id) + const dockedCols = terminals()[0]!.cols + await page.getByRole('button', { name: 'Fullscreen', exact: true }).click() + await expect.poll(() => terminals()[0]!.cols).toBeGreaterThan(dockedCols) + await command(page, "printf 'SIZE:'; stty size") + await expect.poll(async () => await screen.innerText()).toContain(`SIZE:${terminals()[0]!.rows} ${terminals()[0]!.cols}`) + await page.screenshot({ path: `${shots}/fullscreen.png`, fullPage: true }) + await page.reload({ waitUntil: 'load' }) + await page.locator('[data-dockkit-tab]').filter({ hasText: 'Development' }).waitFor({ timeout: 15_000 }) + await expect.poll(async () => await page.locator('[data-dockkit-tab-title]').allInnerTexts()).toEqual(['Development', 'bash']) + expect(terminals()).toHaveLength(2) + expect(alive(firstProcess)).toBe(true) + expect(alive(secondProcess)).toBe(true) + await expect.poll(async () => await screen.innerText()).toContain(`SECOND_PID:${secondPid}`) + const secondTab = page.locator('[data-dockkit-tab]').filter({ hasText: 'bash' }) + await secondTab.hover() + await secondTab.locator('[data-dockkit-tab-close]').click() + await expect.poll(async () => await secondTab.count()).toBe(0) + await expect.poll(() => alive(secondProcess), { timeout: 10_000 }).toBe(false) + await expect.poll(async () => await screen.innerText()).toContain('PERSIST:xterm-256color') + await command(page, "printf 'RECOVERED_PID:%s\\n' \"$$\"") + await expect.poll(async () => await screen.innerText()).toContain(`RECOVERED_PID:${pid}`) + await page.screenshot({ path: `${shots}/recovered.png`, fullPage: true }) + const previousMembers = new Set(inspector.snapshot().tree(firstProcess.pid).map(member => member.pid)) + await command(page, "sleep 120 & printf 'CHILD_PID:%s\\n' $!") + await expect.poll(async () => await screen.innerText()).toMatch(/CHILD_PID:\d+/u) + const descendants = inspector.snapshot().tree(firstProcess.pid).filter(member => member.pid !== firstProcess.pid) + expect(descendants.some(member => !previousMembers.has(member.pid))).toBe(true) + expect(descendants.every(alive)).toBe(true) + const tab = page.locator('[data-dockkit-tab]').filter({ hasText: 'Development' }) + await tab.hover() + await tab.locator('[data-dockkit-tab-close]').click() + await expect.poll(() => alive(firstProcess), { timeout: 10_000 }).toBe(false) + await expect.poll(() => descendants.some(alive), { timeout: 10_000 }).toBe(false) + await expect.poll(() => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!.id).length).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + }) + + it('offers installed shells, remembers the choice after reload, and restores without a picker', async () => { + onTestFailed(() => saveFailureShot(page, 'sidebar-terminal-shell-choice')) + await page.locator('[data-sidebar-right-expand]').click() + const entry = page.locator('[data-sidebar-right-guide-entry="terminal"]') + expect(await entry.innerText()).toBe('New terminal\nRun commands in the Session workspace') + await page.locator('[data-sidebar-right-guide]').screenshot({ path: `${shots}/terminal-guide.png`, animations: 'disabled' }) + await entry.click() + const selector = page.getByRole('button', { name: 'Shell', exact: true }) + await selector.waitFor() + expect(await selector.innerText()).toContain('bash — /bin/bash') + expect(handles).toHaveLength(0) + await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-terminal/selection.expected.md', import.meta.url)), + await page.locator('[data-sidebar-terminal]').ariaSnapshot(), webSnapshotMode()) + await selector.click() + await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-terminal/shell-menu.expected.md', import.meta.url)), + await page.getByRole('menu').ariaSnapshot(), webSnapshotMode()) + await page.screenshot({ path: `${shots}/shell-menu.png`, fullPage: true }) + await page.getByRole('menuitem', { name: 'sh — /bin/sh', exact: true }).click() + expect(await page.evaluate(() => localStorage.getItem('dsh.terminal.shell'))).toBe('/bin/sh') + await page.screenshot({ path: `${shots}/shell-choice.png`, fullPage: true }) + await page.getByRole('button', { name: 'Start terminal', exact: true }).click() + await command(page, "printf 'CHOSEN_SHELL:%s\\n' \"$0\"") + const screen = page.locator('.xterm-rows:visible') + await expect.poll(() => screen.innerText()).toContain('CHOSEN_SHELL:/bin/sh') + expect(await page.evaluate(() => localStorage.getItem('dsh.terminal.shell'))).toBe('/bin/sh') + const retained = processIdentity(0) + await page.reload({ waitUntil: 'load' }) + await page.locator('.xterm-helper-textarea:visible').waitFor() + expect(await selector.count()).toBe(0) + expect(alive(retained)).toBe(true) + await page.locator('[data-dockkit-add-tab]').click() + await page.locator('[data-sidebar-right-guide-entry="terminal"]').click() + await selector.waitFor() + expect(await selector.innerText()).toContain('sh — /bin/sh') + expect(handles).toHaveLength(1) + await page.getByRole('button', { name: 'Start terminal', exact: true }).click() + await expect.poll(() => handles.length).toBe(2) + expect(scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!.id).map(info => info.shell.path)).toEqual(['/bin/sh', '/bin/sh']) + expect(tripwire.pageErrors).toEqual([]) + }) + + it('explains that exited terminals count toward the quota and permits creation after closing one', async () => { + onTestFailed(() => saveFailureShot(page, 'sidebar-terminal-quota')) + const terminals = () => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!.id) + for (let count = 1; count <= 2; count++) { + await openTerminal(page) + await command(page, 'exit') + await expect.poll(() => terminals().filter(info => info.state === 'exited').length).toBe(count) + } + await openTerminal(page, false) + const alert = page.getByRole('alert') + await expect.poll(async () => await alert.innerText()).toContain('Exited terminals also count toward the limit.') + const expectedLimit = fileURLToPath(new URL('./expected/sidebar-terminal/limit.expected.md', import.meta.url)) + await compareOrRefreshGolden(expectedLimit, await alert.ariaSnapshot(), webSnapshotMode()) + const failed = page.locator('[data-dockkit-tab][aria-selected="true"]') + await failed.hover() + await failed.locator('[data-dockkit-tab-close]').click() + const exited = page.locator('[data-dockkit-tab]').filter({ hasText: 'bash' }).first() + await exited.hover() + await exited.locator('[data-dockkit-tab-close]').click() + await expect.poll(() => terminals().length).toBe(1) + await openTerminal(page) + await expect.poll(() => terminals().filter(info => info.state === 'running').length).toBe(1) + expect(tripwire.pageErrors).toEqual([]) + }) + +}) diff --git a/apps/web/tests/turn-tail-actions.e2e.ts b/apps/web/tests/turn-tail-actions.e2e.ts index 0a176a9af0..fdc1ef26f5 100644 --- a/apps/web/tests/turn-tail-actions.e2e.ts +++ b/apps/web/tests/turn-tail-actions.e2e.ts @@ -201,8 +201,8 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { await timeTrigger.click() const timeDialog = page.getByRole('dialog', { name: 'Turn time and speed' }) expect(await timeDialog.count()).toBe(1) - expect(await timeDialog.getByText(/tok\/s/).count()).toBe(0) - expect(await timeDialog.getByText('Time to first token (TTFT)', { exact: true }).count()).toBe(0) + expect(await timeDialog.getByText(/tok\/s/).count()).toBeGreaterThan(0) + expect(await timeDialog.getByText('Time to first token (TTFT)', { exact: true }).count()).toBe(1) await page.keyboard.press('Escape') await trigger.click() diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index f6827345d7..18a5fda787 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -101,6 +101,7 @@ "tests/feedback-release.e2e.ts", "tests/agent-team-panel.e2e.ts", "tests/sidebar-right.e2e.ts", + "tests/sidebar-terminal.e2e.ts", "tests/startup-auto-selection.e2e.ts", "tests/changed-files.e2e.ts", "tests/changed-files-turn.e2e.ts", diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 515cc566ec..9a135cdbfa 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: acc2947f49a915ba27428d869f2404832b41e6e6 -capability-seams.zh.md: 019061bf17f00af8d091104ac15936051f080a1c +capability-seams.md: 2b5a06b77fed90e0d9ef518b99e745379b41d8c2 +capability-seams.zh.md: 7b7630592f94807a56239fc9c8c198fe22c3db39 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index acc2947f49..2b5a06b77f 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -53,6 +53,8 @@ flowchart LR svc_settingsController["ctx.settingsController
Host settings-surface Remote controller"] pkg_api_workspace_files["api-workspace-files"] svc_workspaceFiles["ctx.workspaceFiles
Host workspace file Remote service"] + pkg_api_terminal_controller["api-terminal-controller"] + svc_terminalController["ctx.terminalController
Session interactive terminal Remote controller"] pkg_api_workspace_controller["api-workspace-controller"] svc_workspaceController["ctx.workspaceController
Host Workspace Remote controller"] svc_directoryPickerController["ctx.directoryPickerController
Host directory-picking Remote controller"] @@ -242,6 +244,7 @@ flowchart LR pkg_api_session_controller --> svc_sessionSkillCatalog pkg_api_settings_controller --> svc_credentialsController pkg_api_settings_controller --> svc_settingsController + pkg_api_terminal_controller --> svc_terminalController pkg_api_workspace_controller --> svc_directoryPickerController pkg_api_workspace_controller --> svc_workspaceController pkg_api_workspace_files --> svc_workspaceFiles @@ -508,6 +511,7 @@ flowchart LR | `ctx.credentialsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the credential-reference seam onto the generated Remote namespace: batch fan-out, view projection, and refusal mapping live here, not on the seam Definition. | | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the user-settings seam onto the generated Remote namespace: the read is always redacted and every refusal is classified here, not on the seam Definition. | | `ctx.workspaceFiles` | `core` | [`api-workspace-files`](../packages/api/workspace-files) | - | - | - | Serves stat, paged text, byte windows, directory listings, and the change feed for files inside a Session's workspace root, confined by lstat, containment, and a stat re-check. | +| `ctx.terminalController` | `core` | [`api-terminal-controller`](../packages/api/terminal-controller) | - | - | - | Owns user terminal processes, default shell resolution and bounded screen recovery through the subprocess provider and typed Remote transport. | | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Owns Workspace commands and reconnect-safe Workspace state delivery through the generated Remote namespace. | | `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Carries the picking seam onto the wire: capability gating, cancellation, and the seam-coded failures a browser directory flow discriminates on. | | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 019061bf17..7b7630592f 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -55,6 +55,8 @@ flowchart LR svc_settingsController["ctx.settingsController
Host settings-surface Remote controller"] pkg_api_workspace_files["api-workspace-files"] svc_workspaceFiles["ctx.workspaceFiles
Host workspace file Remote service"] + pkg_api_terminal_controller["api-terminal-controller"] + svc_terminalController["ctx.terminalController
Session interactive terminal Remote controller"] pkg_api_workspace_controller["api-workspace-controller"] svc_workspaceController["ctx.workspaceController
Host Workspace Remote controller"] svc_directoryPickerController["ctx.directoryPickerController
Host directory-picking Remote controller"] @@ -244,6 +246,7 @@ flowchart LR pkg_api_session_controller --> svc_sessionSkillCatalog pkg_api_settings_controller --> svc_credentialsController pkg_api_settings_controller --> svc_settingsController + pkg_api_terminal_controller --> svc_terminalController pkg_api_workspace_controller --> svc_directoryPickerController pkg_api_workspace_controller --> svc_workspaceController pkg_api_workspace_files --> svc_workspaceFiles @@ -510,6 +513,7 @@ flowchart LR | `ctx.credentialsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把凭据引用 seam 投影到生成的 Remote namespace:批量扇出、视图投影与拒绝映射都在这里,而不在 seam Definition 上。 | | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把用户设置 seam 投影到生成的 Remote namespace:读取一律脱敏,所有拒绝在这里分类,而不在 seam Definition 上。 | | `ctx.workspaceFiles` | `core` | [`api-workspace-files`](../packages/api/workspace-files) | - | - | - | 为会话工作区根内的文件提供 stat、分页文本、字节窗口、目录列举与变更流,经 lstat、包含关系与 stat 重检限定。 | +| `ctx.terminalController` | `core` | [`api-terminal-controller`](../packages/api/terminal-controller) | - | - | - | 通过子进程提供方与类型化 Remote 传输管理用户终端进程、解析默认 shell,并恢复有界终端屏幕。 | | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 通过生成的 Remote namespace 负责 Workspace 命令和可在重连后收敛的 Workspace 状态投递。 | | `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 把选目录 seam 送上线:能力门禁、取消传播,以及浏览器目录流程用于分支判断的 seam 错误码。 | | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | 配套子路径注册所属包本地的检查;该服务负责选择、唯一性、子 fiber,以及标明所属包的失败。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index a1c43f27da..1c3d69be91 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: c07e7e424b996256fa99c268d07c58d9fbf5afed -config-catalog.zh.md: baf131582578adcf06e012791c4c281251f1d278 +config-catalog.md: fba7074ec4e97b765a9ca627ced216d27ab1db85 +config-catalog.zh.md: 329d38717ec1b4c650a5a03c521eaead7b2f42d6 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index c07e7e424b..fba7074ec4 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -229,6 +229,45 @@ export interface Config { Source: [`packages/api/settings-controller/src/index.ts:36`](../packages/api/settings-controller/src/index.ts) + + +## `@deepseek-ai/dsh-api-terminal-controller` + +Requires: `subprocess` · `sandboxPolicy` · `sessionProjections` · `typert` + +```ts config-catalog +/** Deployment limits and an optional shell profile. */ +export interface Config { + /** Explicit shell profile; omission uses the execution environment's default shell. */ + readonly shell?: { + /** Executable path or PATH name, verified by the subprocess provider. */ + path: string + /** User-visible profile name. */ + name: string + /** Arguments passed to the interactive shell. */ + args: string[] + } | undefined + /** Executable names or paths checked for the new-terminal shell selector. */ + readonly shellCandidates: string[] + /** Maximum retained terminals and pending allocations per Session. */ + readonly maxTerminals: number + /** Maximum terminal width in columns. */ + readonly maxCols: number + /** Maximum terminal height in rows. */ + readonly maxRows: number + /** Screen history rows retained for reconnecting clients. */ + readonly scrollback: number + /** Maximum queued UTF-8 frame bytes per output follower before disconnection. */ + readonly maxBufferedBytes: number + /** Maximum UTF-8 bytes in one input request. */ + readonly maxInputBytes: number + /** Provider process-termination grace period in milliseconds. */ + readonly disposeGraceMs: number +} +``` + +Source: [`packages/api/terminal-controller/src/index.ts:28`](../packages/api/terminal-controller/src/index.ts) + ## `@deepseek-ai/dsh-api-workspace-files` @@ -3543,6 +3582,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` ([`packages/client/ui-sidebar-documentpreview/src/index.ts`](../packages/client/ui-sidebar-documentpreview/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-files` ([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-right` ([`packages/client/ui-sidebar-right/src/index.ts`](../packages/client/ui-sidebar-right/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-sidebar-terminal` ([`packages/client/ui-sidebar-terminal/src/index.ts`](../packages/client/ui-sidebar-terminal/src/index.ts)) - `@deepseek-ai/dsh-client-ui-skill` ([`packages/client/ui-skill/src/index.ts`](../packages/client/ui-skill/src/index.ts)) - `@deepseek-ai/dsh-client-ui-subagent` ([`packages/client/ui-subagent/src/index.ts`](../packages/client/ui-subagent/src/index.ts)) - `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index baf1315825..329d38717e 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -231,6 +231,45 @@ export interface Config { 来源:[`packages/api/settings-controller/src/index.ts:36`](../packages/api/settings-controller/src/index.ts) + + +## `@deepseek-ai/dsh-api-terminal-controller` + +Requires: `subprocess` · `sandboxPolicy` · `sessionProjections` · `typert` + +```ts config-catalog +/** Deployment limits and an optional shell profile. */ +export interface Config { + /** Explicit shell profile; omission uses the execution environment's default shell. */ + readonly shell?: { + /** Executable path or PATH name, verified by the subprocess provider. */ + path: string + /** User-visible profile name. */ + name: string + /** Arguments passed to the interactive shell. */ + args: string[] + } | undefined + /** Executable names or paths checked for the new-terminal shell selector. */ + readonly shellCandidates: string[] + /** Maximum retained terminals and pending allocations per Session. */ + readonly maxTerminals: number + /** Maximum terminal width in columns. */ + readonly maxCols: number + /** Maximum terminal height in rows. */ + readonly maxRows: number + /** Screen history rows retained for reconnecting clients. */ + readonly scrollback: number + /** Maximum queued UTF-8 frame bytes per output follower before disconnection. */ + readonly maxBufferedBytes: number + /** Maximum UTF-8 bytes in one input request. */ + readonly maxInputBytes: number + /** Provider process-termination grace period in milliseconds. */ + readonly disposeGraceMs: number +} +``` + +来源: [`packages/api/terminal-controller/src/index.ts:28`](../packages/api/terminal-controller/src/index.ts) + ## `@deepseek-ai/dsh-api-workspace-files` @@ -3545,6 +3584,7 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`([`packages/client/ui-sidebar-documentpreview/src/index.ts`](../packages/client/ui-sidebar-documentpreview/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-files`([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-right`([`packages/client/ui-sidebar-right/src/index.ts`](../packages/client/ui-sidebar-right/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-sidebar-terminal`([`packages/client/ui-sidebar-terminal/src/index.ts`](../packages/client/ui-sidebar-terminal/src/index.ts)) - `@deepseek-ai/dsh-client-ui-skill`([`packages/client/ui-skill/src/index.ts`](../packages/client/ui-skill/src/index.ts)) - `@deepseek-ai/dsh-client-ui-subagent`([`packages/client/ui-subagent/src/index.ts`](../packages/client/ui-subagent/src/index.ts)) - `@deepseek-ai/dsh-client-ui-theme`([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index a258bcd725..3c931aa37a 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 4d81c97dedfa5e3d8ebfe1bb8697f4be46a14e89 -event-producer-consumer.zh.md: 6c667f4c991f4d8245c9ebcf5a59b522014f9079 +event-producer-consumer.md: 3a7c8798771b00ebea90a1e8bfbc0265a9a13592 +event-producer-consumer.zh.md: 1041898b52bed1b20bd9a3cedb3d93d1515d614f diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 4d81c97ded..3a7c879877 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -82,7 +82,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event string | Dispatchers | Listeners | | --- | --- | --- | -| `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) | +| `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), `terminal-controller`, [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules` | | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent), `inspector` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 6c667f4c99..1041898b52 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -84,7 +84,7 @@ | Event string | Dispatchers | Listeners | | --- | --- | --- | -| `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) | +| `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), `terminal-controller`, [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules` | | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent), `inspector` | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index eeed49362f..d65ff0c9c4 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: b8f6ecef1389a8b4393e78d1429a5ab823db8d6d -module-graph.zh.md: 471277027416588b9fd5794a71ddb40cd49f4799 +module-graph.md: 37fc86c39054b9e9f60098ac9b191a435afe8e2e +module-graph.zh.md: cf01c57e30caa3d1a608e48a4a4dfad6f9ab2798 diff --git a/docs/module-graph.md b/docs/module-graph.md index b8f6ecef13..37fc86c390 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -114,6 +114,7 @@ flowchart TD pkg_api_remotes["api-remotes"] pkg_api_session_controller["api-session-controller"] pkg_api_settings_controller["api-settings-controller"] + pkg_api_terminal_controller["api-terminal-controller"] pkg_api_workspace_controller["api-workspace-controller"] pkg_api_workspace_files["api-workspace-files"] end @@ -176,6 +177,7 @@ flowchart TD pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"] pkg_client_ui_sidebar_files["client-ui-sidebar-files"] pkg_client_ui_sidebar_right["client-ui-sidebar-right"] + pkg_client_ui_sidebar_terminal["client-ui-sidebar-terminal"] pkg_client_ui_skill["client-ui-skill"] pkg_client_ui_slots["client-ui-slots"] pkg_client_ui_subagent["client-ui-subagent"] @@ -429,6 +431,7 @@ flowchart TD pkg_web_search_perplexity --> pkg_launch_environment pkg_web_search_perplexity --> pkg_web pkg_api_remotes --> pkg_scope + pkg_api_terminal_controller --> pkg_subprocess pkg_attachment_local --> pkg_attachment pkg_attachment_local --> pkg_home_paths pkg_client_file_upload --> pkg_scope @@ -1319,6 +1322,7 @@ flowchart TD | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — | | [`client-ui-sidebar-files`](../packages/client/ui-sidebar-files) | `client` | — | | [`client-ui-sidebar-right`](../packages/client/ui-sidebar-right) | `client` | — | +| [`client-ui-sidebar-terminal`](../packages/client/ui-sidebar-terminal) | `client` | — | | [`client-ui-skill`](../packages/client/ui-skill) | `client` | — | | [`client-ui-slots`](../packages/client/ui-slots) | `client` | — | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | — | @@ -1374,6 +1378,7 @@ flowchart TD | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | +| [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | | [`client-file-upload`](../packages/client/file-upload) | `client` | [`scope`](../packages/core/scope) | | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 4712770274..cf01c57e30 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -116,6 +116,7 @@ flowchart TD pkg_api_remotes["api-remotes"] pkg_api_session_controller["api-session-controller"] pkg_api_settings_controller["api-settings-controller"] + pkg_api_terminal_controller["api-terminal-controller"] pkg_api_workspace_controller["api-workspace-controller"] pkg_api_workspace_files["api-workspace-files"] end @@ -178,6 +179,7 @@ flowchart TD pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"] pkg_client_ui_sidebar_files["client-ui-sidebar-files"] pkg_client_ui_sidebar_right["client-ui-sidebar-right"] + pkg_client_ui_sidebar_terminal["client-ui-sidebar-terminal"] pkg_client_ui_skill["client-ui-skill"] pkg_client_ui_slots["client-ui-slots"] pkg_client_ui_subagent["client-ui-subagent"] @@ -431,6 +433,7 @@ flowchart TD pkg_web_search_perplexity --> pkg_launch_environment pkg_web_search_perplexity --> pkg_web pkg_api_remotes --> pkg_scope + pkg_api_terminal_controller --> pkg_subprocess pkg_attachment_local --> pkg_attachment pkg_attachment_local --> pkg_home_paths pkg_client_file_upload --> pkg_scope @@ -1321,6 +1324,7 @@ flowchart TD | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — | | [`client-ui-sidebar-files`](../packages/client/ui-sidebar-files) | `client` | — | | [`client-ui-sidebar-right`](../packages/client/ui-sidebar-right) | `client` | — | +| [`client-ui-sidebar-terminal`](../packages/client/ui-sidebar-terminal) | `client` | — | | [`client-ui-skill`](../packages/client/ui-skill) | `client` | — | | [`client-ui-slots`](../packages/client/ui-slots) | `client` | — | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | — | @@ -1376,6 +1380,7 @@ flowchart TD | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | +| [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | | [`client-file-upload`](../packages/client/file-upload) | `client` | [`scope`](../packages/core/scope) | | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | diff --git a/docs/subsystems/subprocess.i18n.yaml b/docs/subsystems/subprocess.i18n.yaml index 4ea87e23f1..327e1ca6a2 100644 --- a/docs/subsystems/subprocess.i18n.yaml +++ b/docs/subsystems/subprocess.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/subprocess.md -subprocess.md: d51291cdc42a50d4ecd408064bb6fb770e3e3f2e -subprocess.zh.md: 054496701bc4be8f539ce0e1b5b573050d1bf961 +subprocess.md: e724903386b8c743da522a419401ac49ff1c1509 +subprocess.zh.md: b11a1cb0891123670be654d4c82105054979c911 diff --git a/docs/subsystems/subprocess.md b/docs/subsystems/subprocess.md index d51291cdc4..e724903386 100644 --- a/docs/subsystems/subprocess.md +++ b/docs/subsystems/subprocess.md @@ -244,7 +244,9 @@ interface SubprocessOutcome { `spawnTerminal(spec)` is the non-pipe process primitive. The provider allocates the controlling terminal and owns UTF-8 text transport, foreground-process-group inspection and signalling, and one awaited TERM-to-KILL operation that reaches quiescence for every session member the provider can still observe; providers document substrate-specific observability limits. The PTY backend remains responsible for prompt detection, readiness inference, scrollback, sandbox policy, and persistent-session ownership; ordinary `spawn()` cannot reconstruct controlling-terminal semantics. -The terminal spec fully specifies argv, cwd, environment overrides, dimensions, cleanup grace, and optional allocation cancellation. Its handle exposes `pid`, ordered output, `done`, `write`, `inspectForeground`, `signalForeground`, and awaited `terminate`; the exact public shapes are generated into the [`ctx.subprocess` service catalog](#ctxsubprocess--subprocessruntime-abstract-seam). +The terminal spec fully specifies argv, cwd, environment overrides, terminal type, dimensions, cleanup grace, and optional allocation cancellation. Its handle exposes `pid`, ordered output, `done`, `write`, `resize`, `inspectForeground`, `signalForeground`, and awaited `terminate`; [`SubprocessTerminalSpawnSpec` and `SubprocessTerminalHandle`](../../packages/subprocess/subprocess/src/types.ts) define these fields and operations. `resize(cols, rows)` updates the live PTY dimensions and rejects after process exit. + +`terminalEnvironment(signal?)` returns `SubprocessTerminalEnvironment`: the execution environment platform (`posix` or `windows`) and optional `defaultShell`. These facts come from the provider rather than the Web server or browser. `resolveExecutable` verifies shell candidates; `SubprocessExecutableNotFoundError` identifies a missing executable, while provider and transport failures remain errors. ## Service behavior @@ -287,6 +289,13 @@ Implementations must honor these semantics: */ abstract resolveExecutable( command: string, env?: Readonly>, signal?: AbortSignal, ): Promise +/** + * Inspect shell-selection facts in the provider's execution environment. + * @param signal - cancellation of remote environment inspection. + * @returns platform and preferred shell; executable lookup and allocation remain separate operations. + */ +abstract terminalEnvironment(signal?: AbortSignal): Promise + /** * Start one managed child process from a fully-specified spec; this seam * applies no defaults. diff --git a/docs/subsystems/subprocess.zh.md b/docs/subsystems/subprocess.zh.md index 054496701b..b11a1cb089 100644 --- a/docs/subsystems/subprocess.zh.md +++ b/docs/subsystems/subprocess.zh.md @@ -244,7 +244,9 @@ interface SubprocessOutcome { `spawnTerminal(spec)` 是非管道进程原语。提供方分配控制终端,并负责 UTF-8 文本传输、前台进程组检查与信号发送,以及一项须等待的 TERM→KILL 操作;该操作会使提供方仍可观察到的每个会话成员完全停稳,提供方则会记录执行基底特有的可观察性限制。PTY 后端仍负责提示符检测、就绪推断、scrollback、沙箱策略和持久会话所有权;普通 `spawn()` 无法重建控制终端语义。 -终端 spec 完全指定 argv、cwd、环境覆盖、尺寸、清理宽限期与可选的分配取消。其句柄公开 `pid`、有序输出、`done`、`write`、`inspectForeground`、`signalForeground` 和须等待的 `terminate`;确切的公共形状生成到 [`ctx.subprocess` 服务目录](#ctxsubprocess--subprocessruntime-abstract-seam)中。 +终端 spec 完全指定 argv、cwd、环境覆盖、终端类型、尺寸、清理宽限期与可选的分配取消。其句柄公开 `pid`、有序输出、`done`、`write`、`resize`、`inspectForeground`、`signalForeground` 和须等待的 `terminate`;[`SubprocessTerminalSpawnSpec` 与 `SubprocessTerminalHandle`](../../packages/subprocess/subprocess/src/types.ts) 定义这些字段和操作。`resize(cols, rows)` 更新正在运行的 PTY 尺寸,进程退出后拒绝调用。 + +`terminalEnvironment(signal?)` 返回 `SubprocessTerminalEnvironment`:执行环境平台(`posix` 或 `windows`)与可选的 `defaultShell`。这些事实来自提供方,而非 Web 服务器或浏览器。`resolveExecutable` 验证候选 shell;`SubprocessExecutableNotFoundError` 表示可执行文件不存在,提供方与传输故障仍作为错误报告。 ## 服务行为 @@ -287,6 +289,13 @@ Implementations must honor these semantics: */ abstract resolveExecutable( command: string, env?: Readonly>, signal?: AbortSignal, ): Promise +/** + * Inspect shell-selection facts in the provider's execution environment. + * @param signal - cancellation of remote environment inspection. + * @returns platform and preferred shell; executable lookup and allocation remain separate operations. + */ +abstract terminalEnvironment(signal?: AbortSignal): Promise + /** * Start one managed child process from a fully-specified spec; this seam * applies no defaults. diff --git a/docs/subsystems/workspace.i18n.yaml b/docs/subsystems/workspace.i18n.yaml index 72e034d222..b9004c4754 100644 --- a/docs/subsystems/workspace.i18n.yaml +++ b/docs/subsystems/workspace.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/workspace.md -workspace.md: 2c557c435c9eae9257e41ed2362fed832e437422 -workspace.zh.md: 76630506a2db6347e7155aedb12304a1c434959d +workspace.md: 77e5dd39afb6d2840751d6d56a7a50f9fea2adaa +workspace.zh.md: ad226e34b4fb508e9ce68e90349848d28632d559 diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index 2c557c435c..77e5dd39af 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -183,6 +183,97 @@ Host service backing the generated `ctx.remote.directoryPicker` namespace. The s Source: [`packages/api/workspace-controller/src/directory-picker.ts`](../../packages/api/workspace-controller/src/directory-picker.ts) + + +### `ctx.terminalController` — `TerminalController` + +Typed Remote control of transient Session-owned terminal processes. + +```ts cordis-catalog +/** + * Read the Session working directory and terminal limits without resolving a shell. + * @param agent - Session owner supplied by the Gateway. + * @param signal - request cancellation. + * @returns the Session workspace directory and terminal limits. + */ +@Remote environment(agent: Agent, signal: AbortSignal): TerminalEnvironment + +/** + * Discover installed shells in the Session's execution environment. + * @param agent - Session owner supplied by the Gateway. + * @param signal - request cancellation. + * @returns verified profiles, with the configured or system default first. + */ +@Remote shells(agent: Agent, signal: AbortSignal): Promise + +/** + * List retained terminals without resolving or activating an Agent. + * @param sessionId - displayed Session identity, including offline history. + * @returns terminals retained for this Host lifetime. + */ +@Remote list(sessionId: SessionId): WebTerminalInfo[] + +/** + * Allocate an interactive shell once for a caller-generated identity. + * @param agent - Session owner supplied by the Gateway. + * @param request - initial dimensions and idempotency identity. + * @param signal - allocation cancellation; committed terminals survive disconnection. + * @returns the existing or newly committed terminal. + */ +@Remote async create(agent: Agent, request: TerminalCreateRequest, signal: AbortSignal): Promise + +/** + * Attach to a terminal without binding its process lifetime to the transport. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param attachmentId - new exclusive input attachment. + * @param signal - physical stream cancellation. + * @returns screen recovery followed by output and metadata changes. + */ +@Remote({ mode: 'stream' }) follow(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, signal: AbortSignal): AsyncIterable + +/** + * Deliver raw input, including Tab completion and control characters. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param attachmentId - current writable attachment. + * @param data - input bytes represented as UTF-8 text. + * @returns after provider input acceptance. + */ +@Remote async write(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, data: string): Promise + +/** + * Update the dimensions of the PTY and recovery screen. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param attachmentId - current writable attachment. + * @param cols - column count. + * @param rows - row count. + * @returns after the resize completes. + */ +@Remote async resize(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, cols: number, rows: number): Promise + +/** + * Rename a terminal without changing its shell. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param title - nonempty display title, at most 120 characters. + */ +@Remote rename(agent: Agent, id: WebTerminalId, title: string): void + +/** + * Close an identity to future creation and kill its process range; repeated closes succeed. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @returns after provider cleanup succeeds. A failure retains the terminal for retry. + */ +@Remote async close(agent: Agent, id: WebTerminalId): Promise +``` + +Types: [Agent](core.md) · [SessionId](core.md) + +Source: [`packages/api/terminal-controller/src/index.ts`](../../packages/api/terminal-controller/src/index.ts) + ### `ctx.workspaceController` — `WorkspaceController` diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index 76630506a2..ad226e34b4 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -183,6 +183,97 @@ Host service backing the generated `ctx.remote.directoryPicker` namespace. The s Source: [`packages/api/workspace-controller/src/directory-picker.ts`](../../packages/api/workspace-controller/src/directory-picker.ts) + + +### `ctx.terminalController` — `TerminalController` + +Typed Remote control of transient Session-owned terminal processes. + +```ts cordis-catalog +/** + * Read the Session working directory and terminal limits without resolving a shell. + * @param agent - Session owner supplied by the Gateway. + * @param signal - request cancellation. + * @returns the Session workspace directory and terminal limits. + */ +@Remote environment(agent: Agent, signal: AbortSignal): TerminalEnvironment + +/** + * Discover installed shells in the Session's execution environment. + * @param agent - Session owner supplied by the Gateway. + * @param signal - request cancellation. + * @returns verified profiles, with the configured or system default first. + */ +@Remote shells(agent: Agent, signal: AbortSignal): Promise + +/** + * List retained terminals without resolving or activating an Agent. + * @param sessionId - displayed Session identity, including offline history. + * @returns terminals retained for this Host lifetime. + */ +@Remote list(sessionId: SessionId): WebTerminalInfo[] + +/** + * Allocate an interactive shell once for a caller-generated identity. + * @param agent - Session owner supplied by the Gateway. + * @param request - initial dimensions and idempotency identity. + * @param signal - allocation cancellation; committed terminals survive disconnection. + * @returns the existing or newly committed terminal. + */ +@Remote async create(agent: Agent, request: TerminalCreateRequest, signal: AbortSignal): Promise + +/** + * Attach to a terminal without binding its process lifetime to the transport. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param attachmentId - new exclusive input attachment. + * @param signal - physical stream cancellation. + * @returns screen recovery followed by output and metadata changes. + */ +@Remote({ mode: 'stream' }) follow(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, signal: AbortSignal): AsyncIterable + +/** + * Deliver raw input, including Tab completion and control characters. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param attachmentId - current writable attachment. + * @param data - input bytes represented as UTF-8 text. + * @returns after provider input acceptance. + */ +@Remote async write(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, data: string): Promise + +/** + * Update the dimensions of the PTY and recovery screen. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param attachmentId - current writable attachment. + * @param cols - column count. + * @param rows - row count. + * @returns after the resize completes. + */ +@Remote async resize(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, cols: number, rows: number): Promise + +/** + * Rename a terminal without changing its shell. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param title - nonempty display title, at most 120 characters. + */ +@Remote rename(agent: Agent, id: WebTerminalId, title: string): void + +/** + * Close an identity to future creation and kill its process range; repeated closes succeed. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @returns after provider cleanup succeeds. A failure retains the terminal for retry. + */ +@Remote async close(agent: Agent, id: WebTerminalId): Promise +``` + +Types: [Agent](core.zh.md) · [SessionId](core.zh.md) + +Source: [`packages/api/terminal-controller/src/index.ts`](../../packages/api/terminal-controller/src/index.ts) + ### `ctx.workspaceController` — `WorkspaceController` diff --git a/packages/api/README.i18n.yaml b/packages/api/README.i18n.yaml index ec53eaf4d8..86d8d25086 100644 --- a/packages/api/README.i18n.yaml +++ b/packages/api/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/README.md -README.md: 5bc878fa53b9ad0c3795c13e9442274b98820190 -README.zh.md: 8aa364850e2378c7361807ae1065cfdd6eeaa9cc +README.md: f1aefa7d4564539f94202e869bacb85857f939d5 +README.zh.md: 870bbd32552166fdbb06221baaf54b9a130c6069 diff --git a/packages/api/README.md b/packages/api/README.md index 5bc878fa53..f1aefa7d45 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -31,6 +31,7 @@ The packages below provide the Remote layer; the package READMEs own the exhaust | [`session-controller/`](session-controller/README.md) | Owns Session commands, history streams, live control state, and Agent/Session identity policy. | `ctx.sessionController` / `ctx.remote.session` | | [`settings-controller/`](settings-controller/README.md) | Owns the configuration-surface reads and writes over the settings-domain seams. | `ctx.settingsController`, `ctx.credentialsController` / `ctx.remote.settings`, `ctx.remote.credentials` | | [`workspace-controller/`](workspace-controller/README.md) | Owns Workspace mutations and the complete Client Workspace projection. | `ctx.workspaceController` / `ctx.remote.workspace` | +| [`terminal-controller/`](terminal-controller/README.md) | Session-owned interactive shells, screen recovery and browser terminal control. | `ctx.terminalController` / `ctx.remote.terminal` | | [`workspace-files/`](workspace-files/README.md) | Owns bounded workspace file access — `stat`, paged `read`, `list`, and the instrumented-operation `changes` feed — and the Client `file` resource provider over it. | `ctx.workspaceFiles` / `ctx.remote.workspaceFiles` | Remote calls run Client → Host over the application's shared Connection. API Gateway owns Remote transport, while the controller packages own Session, configuration-surface, and Workspace behavior. Feature packages register exact Connection Fetch routes for responses that do not fit Remote invocation, such as streamed downloads. diff --git a/packages/api/README.zh.md b/packages/api/README.zh.md index 8aa364850e..870bbd3255 100644 --- a/packages/api/README.zh.md +++ b/packages/api/README.zh.md @@ -31,6 +31,7 @@ kind: "package-group" | [`session-controller/`](session-controller/README.zh.md) | 拥有会话命令、历史记录流、实时控制状态与 Agent/Session 身份策略。 | `ctx.sessionController` / `ctx.remote.session` | | [`settings-controller/`](settings-controller/README.zh.md) | 拥有 settings 域各 seam 之上的配置界面读写。 | `ctx.settingsController`、`ctx.credentialsController` / `ctx.remote.settings`、`ctx.remote.credentials` | | [`workspace-controller/`](workspace-controller/README.zh.md) | 拥有 Workspace 变更与完整 Client Workspace 投影。 | `ctx.workspaceController` / `ctx.remote.workspace` | +| [`terminal-controller/`](terminal-controller/README.zh.md) | Session 拥有的交互式 shell、屏幕恢复和浏览器终端控制。 | `ctx.terminalController` / `ctx.remote.terminal` | | [`workspace-files/`](workspace-files/README.zh.md) | 拥有有界的工作区文件访问——`stat`、分页 `read`、`list` 与已埋点操作的 `changes` 流——以及其上的 Client `file` 资源提供方。 | `ctx.workspaceFiles` / `ctx.remote.workspaceFiles` | Remote 调用沿 Client → Host 方向运行在应用共享的 Connection 之上。API Gateway 拥有 Remote 传输,各控制器包分别拥有 Session、配置界面与 Workspace 行为。流式下载等不适合 Remote 调用的响应由功能包注册精确的 Connection Fetch 路由。 diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 51c2ea252f..3aefa8bddc 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -86,6 +86,7 @@ "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-files": "workspace:^", "zod": "^4.4.3", - "@deepseek-ai/dsh-command-feedback": "workspace:^" + "@deepseek-ai/dsh-command-feedback": "workspace:^", + "@deepseek-ai/dsh-api-terminal-controller": "workspace:^" } } diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 88dc11c361..ea622cd552 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -16,6 +16,7 @@ import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote' import subagentsRemote from '@deepseek-ai/dsh-subagent/remote' import sessionRemote from '@deepseek-ai/dsh-api-session-controller/remote' import workspaceRemote from '@deepseek-ai/dsh-api-workspace-controller/remote' +import terminalRemote from '@deepseek-ai/dsh-api-terminal-controller/remote' import workspaceFilesRemote from '@deepseek-ai/dsh-api-workspace-files/remote' import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' @@ -40,6 +41,8 @@ export type {} from '@deepseek-ai/dsh-api-workspace-controller/remote' export type * from '@deepseek-ai/dsh-api-workspace-controller/types' export type {} from '@deepseek-ai/dsh-api-workspace-files/remote' export type * from '@deepseek-ai/dsh-api-workspace-files/types' +export type {} from '@deepseek-ai/dsh-api-terminal-controller/remote' +export type * from '@deepseek-ai/dsh-api-terminal-controller/types' export type { SessionJob as JobView } from '@deepseek-ai/dsh-api-session-controller/types' // The forwarded-event allowlist's selection seat: without it in the consumer's // compilation face `TypertRemoteEvent` is `never` and every `$on` call fails. @@ -156,7 +159,7 @@ export async function apply(ctx: Context): Promise<() => Promise> { for (const contribution of [ agentPresetsRemote, commandsRemote, settingsControllerRemote, goalsRemote, llmRemote, dynamicRemote, pluginInventoryRemote, messageFeedbackRemote, sessionFeedbackRemote, fileUploadsRemote, sessionReferencesRemote, - permissionPresetsRemote, subagentsRemote, sessionRemote, workspaceRemote, workspaceFilesRemote, + permissionPresetsRemote, subagentsRemote, sessionRemote, workspaceRemote, workspaceFilesRemote, terminalRemote, ]) { disposers.push(await ctx.remote.$mount(contribution)) } diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index 31f11c38d5..c00198c04a 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -85,6 +85,9 @@ }, { "path": "../../feedback/command-feedback" + }, + { + "path": "../terminal-controller/tsconfig.client.json" } ] } diff --git a/packages/api/remotes/tsconfig.host.json b/packages/api/remotes/tsconfig.host.json index 55679eba43..66799352d2 100644 --- a/packages/api/remotes/tsconfig.host.json +++ b/packages/api/remotes/tsconfig.host.json @@ -70,6 +70,9 @@ }, { "path": "../../typert/protocol" + }, + { + "path": "../terminal-controller/tsconfig.host.json" } ] } diff --git a/packages/api/terminal-controller/README.i18n.yaml b/packages/api/terminal-controller/README.i18n.yaml new file mode 100644 index 0000000000..0a1f8343a9 --- /dev/null +++ b/packages/api/terminal-controller/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/api/terminal-controller/README.md +README.md: c302a2b7269bdb6a76d0c6056d5851a84a876e9a +README.zh.md: dfc50e30f3a35b70c40bb76f9832cbd9548ba2cf diff --git a/packages/api/terminal-controller/README.md b/packages/api/terminal-controller/README.md new file mode 100644 index 0000000000..c302a2b726 --- /dev/null +++ b/packages/api/terminal-controller/README.md @@ -0,0 +1,93 @@ +--- +description: "Interactive user terminals with execution-environment shell defaults, bounded screen recovery and typed Remote control." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-api-terminal-controller + +English | [中文](README.zh.md) + +## Summary + +Open the execution environment's default shell in a Session workspace from the Web sidebar. Reconnect to existing processes and close their complete provider-owned process ranges. Terminal output stays outside the Agent transcript. Keeping a terminal open retains its process and a bounded screen buffer. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +The Web bundle mounts this package with the subprocess provider, sandbox policy and Typert Gateway. `remote.terminal` exposes `environment`, `shells`, `list`, `create`, `follow`, `write`, `resize`, `rename` and `close`; each operation is scoped by Session identity. Listing reads retained Host terminals directly, so viewing an offline Session neither activates an Agent nor produces a recovery error. + +Shell discovery lists the execution environment's declared default shell first. Only when the provider omits that default does resolution use `/bin/sh` on POSIX or `cmd.exe` on Windows. An optional `shell` profile overrides that choice with executable `path`, display `name` and `args` (default `[]`). The selector also probes `shellCandidates` through the execution provider and omits only confirmed lookup misses. Creation accepts a discovered `shellPath` and verifies it again; resolution or transport failure is reported without launching a different shell. Environment lookup returns the working directory and limits without resolving a shell, so an unavailable default does not prevent reattaching to an existing process. Automatic POSIX profiles start interactively, and PowerShell uses `-NoLogo`, so completion and startup configuration remain shell-owned. The Session workspace supplies the initial directory; its sandbox policy also applies to the terminal. + +| Configuration | Default | Meaning | +|---|---|---| +| `shell` | omitted | Use the execution environment's default shell, or one explicit profile | +| `shellCandidates` | `zsh`, `bash`, `fish`, `sh`, `ksh`, `tcsh`, `csh`, `pwsh`, `powershell`, `cmd`, `nu` | Additional executable names or paths offered when installed | +| `maxTerminals` | `8` | Retained terminals and pending allocations per Session | +| `maxCols`, `maxRows` | `500`, `200` | Maximum PTY dimensions | +| `scrollback` | `1000` | Retained screen history rows | +| `maxBufferedBytes` | `2097152` | Output queued for one follower | +| `maxInputBytes` | `65536` | Maximum input request bytes | +| `disposeGraceMs` | `1000` | Provider termination grace in milliseconds | + + +## Understand the implementation + +
+Implementation internals — click to expand + +The Host uses `ctx.subprocess.spawnTerminal` with `TERM=xterm-256color`; it never launches a desktop terminal application. Streaming UTF-8 decoding preserves split characters and leading BOMs, and replaces incomplete trailing bytes at EOF. Unary control uses the Gateway, and `follow` uses its multiplexed Remote stream transport. Headless xterm and its serializer produce each opening screen after all preceding output writes, then monotone output sequences identify subsequent frames. Slow followers fail explicitly; a new attachment restores the current screen. + +The latest attachment owns input and resize. Detachment releases input control without killing the process. Explicit close awaits process cleanup and final output; cleanup failure retains the resource for retry. The Session remembers closed identities and rejects their delayed or repeated creation, including creation already in progress when close arrives. A new terminal uses a new identity. Pending allocations remain owned even if cancellation and cleanup both fail. Session owner disposal and controller disposal also terminate owned processes. An open or pending terminal prevents changing that Session's sandbox mode. Input or resize refused after control transfer or process exit leaves the output attachment intact and disables input; rejected input is not replayed. + +Client views keep the association between sidebar tabs and terminal identities in memory. Recovery queries the Host for retained terminals; a new view may create a process, while a recovered view reports a missing target without creating a replacement. The Client model acknowledges screen writes after the browser emulator processes them, serializes input and ignores stale attachment responses. Client-owned errors carry locale keys. Plugin disposal awaits active and previously detached output streams without closing Host processes. + +New views wait for explicit shell selection and start. The last selected shell path is stored under `dsh.terminal.shell` in origin-scoped localStorage. A saved path is preselected only while Host discovery still lists it; otherwise the current default is selected. Storage failures do not prevent startup. Restoring existing terminals neither reads this preference nor discovers shells. + +Closing saves an unfinished cleanup request before releasing the tab, then awaits Host cleanup in the background. A failure exposes a retry notification. Each request has its own terminal-ID localStorage key and is removed after successful cleanup or a definitive `session/not-found` response; startup retries saved requests. Transport failures retain the request. This stores cleanup intent, not sidebar layout, open-tab mappings, selected tabs or process PIDs. If browser storage is unavailable, cleanup remains usable in memory but cannot be recovered after reload. + +
+ + +## Further Exploration + +- [Subprocess](../../subprocess/subprocess/README.md) +- [Right Sidebar](../../client/ui-sidebar-right/README.md) +- [Web terminal decision](../../../.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md) + + +## Model Experience + +None, as this package handles user terminal interaction without adding model input. + +#### KV Cache effect + +None; terminal output travels only between the browser and Host. + +## Known Limitations and Deferred Work + + + +- Processes and screens survive browser reloads, but not Host or Session owner disposal. There is no durable terminal restoration or automatic shell respawn. Exited terminals count toward `maxTerminals`; close unused tabs to release their screens and quota. +- The subprocess provider determines native PTY availability and process-tree cleanup guarantees. Finding an executable does not prove PTY allocation will succeed. +- Screen recovery retains bounded history, not a complete transcript. Only one attachment at a time can write or resize. + + +### Dev Note + +
+Working context for maintainers — click to expand + +No runtime invariant companion is published. One owner orders terminal metadata and screen updates; the provider exposes no independently observed dimensions to compare. + +
diff --git a/packages/api/terminal-controller/README.zh.md b/packages/api/terminal-controller/README.zh.md new file mode 100644 index 0000000000..dfc50e30f3 --- /dev/null +++ b/packages/api/terminal-controller/README.zh.md @@ -0,0 +1,93 @@ +--- +description: "用户交互式终端:执行环境默认 shell、有界屏幕恢复和类型化 Remote 控制。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-api-terminal-controller + +[English](README.md) | 中文 + +## 概述 + +从 Web 侧栏在会话工作区打开执行环境的默认 shell。重新连接已有进程,并关闭 provider 管理的完整进程范围。终端输出不进入 Agent 对话记录。保留终端会占用进程和有界屏幕缓存。 + +## 目录 + +- [使用此包](#use-this-package) +- [了解实现](#understand-the-implementation) +- [延伸阅读](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用此包 + +Web bundle 将此包与 subprocess provider、sandbox policy 和 Typert Gateway 一起挂载。`remote.terminal` 提供 `environment`、`shells`、`list`、`create`、`follow`、`write`、`resize`、`rename` 和 `close`;每个操作均按 Session 标识限定范围。列表直接读取 Host 保留的终端,因此查看离线 Session 不会激活 Agent,也不会产生恢复错误。 + +Shell 探测结果首先列出执行环境声明的默认 shell。仅当 provider 未声明默认值时,才在 POSIX 使用 `/bin/sh`,在 Windows 使用 `cmd.exe`。可选的 `shell` profile 通过可执行路径 `path`、显示名称 `name` 和参数 `args`(默认 `[]`)覆盖这一选择。选择器还会通过执行 provider 探测 `shellCandidates`,仅省略确定未找到的候选。创建请求接受探测返回的 `shellPath` 并再次验证;解析或传输失败会直接报告,不启动其他 shell。环境查询只返回工作目录和限制,不解析 shell,因此默认 shell 不可用时仍可重新连接已有进程。POSIX 自动 profile 以交互模式启动,PowerShell 使用 `-NoLogo`,补全和启动配置仍由 shell 提供。初始目录来自 Session 工作区,终端遵循同一 sandbox policy。 + +| 配置 | 默认值 | 含义 | +|---|---|---| +| `shell` | 省略 | 使用执行环境默认 shell,或指定一个 profile | +| `shellCandidates` | `zsh`、`bash`、`fish`、`sh`、`ksh`、`tcsh`、`csh`、`pwsh`、`powershell`、`cmd`、`nu` | 已安装时供用户选择的额外可执行名称或路径 | +| `maxTerminals` | `8` | 每个 Session 保留的终端和创建请求上限 | +| `maxCols`、`maxRows` | `500`、`200` | PTY 最大尺寸 | +| `scrollback` | `1000` | 屏幕历史行数 | +| `maxBufferedBytes` | `2097152` | 单个订阅者的输出排队上限 | +| `maxInputBytes` | `65536` | 单次输入请求的字节上限 | +| `disposeGraceMs` | `1000` | provider 终止宽限期,单位毫秒 | + + +## 了解实现 + +
+实现细节 + +Host 通过 `ctx.subprocess.spawnTerminal` 创建 `TERM=xterm-256color` 的终端,不启动桌面终端应用。流式 UTF-8 解码保留跨块字符和开头的 BOM,并在 EOF 将不完整的尾部字节替换为替代字符。控制请求走 Gateway,`follow` 使用其复用的 Remote stream。Headless xterm 和序列化 addon 在此前输出写入后生成初始屏幕,后续增量携带单调序号。过慢的订阅者明确失败;重新连接恢复当前屏幕。 + +最新连接持有输入和尺寸控制权。断开连接只释放输入权,不结束进程。显式关闭等待进程清理和最后输出;清理失败时保留资源以便重试。Session 记住已关闭的标识并拒绝迟到或重复的创建请求,包括关闭到达时仍在进行的创建。新终端使用新标识。取消创建且清理失败时,已分配的进程仍有所有者。Session owner 和 controller 卸载也会终止所拥有的进程。存在终端或创建请求时不能改变该 Session 的 sandbox mode。 控制权转移或进程退出后被拒绝的输入和尺寸请求保留输出连接并禁用输入,不重发被拒绝的输入。 + +Client 视图只在内存中关联侧栏标签页与终端标识。恢复操作查询 Host 保留的终端;新视图可以创建进程,恢复视图在目标缺失时显示错误,不创建替代进程。Client 模型在浏览器完成屏幕解析后确认帧,按序发送输入,并忽略旧连接迟到的响应。 Client 自产错误携带本地化键。插件卸载等待活跃及先前断开的输出流结束,不关闭 Host 进程。 + +新视图等待用户选择 shell 并启动。上次选择的 shell 路径保存在当前站点 localStorage 的 `dsh.terminal.shell` 中。仅当 Host 探测仍列出该路径时才预选它,否则选择当前默认项。存储失败不影响启动。恢复已有终端既不读取这一偏好,也不探测 shell。 + +关闭时先保存未完成的清理请求并释放标签页,再在后台等待 Host 清理。失败时提供重试通知。每个请求使用独立的终端 ID localStorage key,清理成功或收到明确的 `session/not-found` 响应后删除;启动时重试已保存的请求。传输失败时保留请求。保存的是清理意图,不是侧栏布局、打开标签页映射、选中标签页或进程 PID。浏览器存储不可用时,内存中的清理仍可工作,但刷新后无法恢复该请求。 + +
+ + +## 延伸阅读 + +- [Subprocess](../../subprocess/subprocess/README.zh.md) +- [Right Sidebar](../../client/ui-sidebar-right/README.zh.md) +- [Web terminal decision](../../../.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md) + + +## 模型体验 + +无;此包只处理用户交互式终端,不向模型请求添加内容。 + +#### KV 缓存影响 + +无;终端输出只在浏览器与 Host 之间传输。 + +## 已知限制与延期工作 + + + +- 浏览器刷新保留进程和屏幕;Host 或 Session owner 卸载不保留。不提供持久终端恢复或自动重启 shell。 已退出的终端仍计入 `maxTerminals`;关闭不用的标签页可释放其屏幕和名额。 +- 原生 PTY 可用性和进程树清理保证由 subprocess provider 决定。找到可执行文件并不保证 PTY 分配成功。 +- 屏幕恢复只保留有界历史,不保留完整记录。同一时刻只有一个连接可输入或调整尺寸。 + + +### 开发备注 + +
+维护说明 + +不发布运行时 invariant companion。终端元数据与屏幕更新由同一对象按序写入,没有独立的进程尺寸观测可供比较。 + +
diff --git a/packages/api/terminal-controller/package.json b/packages/api/terminal-controller/package.json new file mode 100644 index 0000000000..0504a7dfcc --- /dev/null +++ b/packages/api/terminal-controller/package.json @@ -0,0 +1,93 @@ +{ + "name": "@deepseek-ai/dsh-api-terminal-controller", + "description": "Session-owned interactive terminals with shell discovery, screen recovery and typed Remote control", + "version": "0.1.5-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/api/terminal-controller" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-gateway" + ], + "external": [ + "@deepseek-ai/dsh-api-gateway/client" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "dependencies": { + "@deepseek-ai/dsh-deque": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^", + "@xterm/headless": "^6.0.0", + "@xterm/addon-serialize": "^0.14.0", + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "@deepseek-ai/dsh-util-crypto": "workspace:^" + }, + "files": [ + "lib/index.js", + "lib/client.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" + ] +} diff --git a/packages/api/terminal-controller/src/client/close-requests.ts b/packages/api/terminal-controller/src/client/close-requests.ts new file mode 100644 index 0000000000..f91581a4b5 --- /dev/null +++ b/packages/api/terminal-controller/src/client/close-requests.ts @@ -0,0 +1,73 @@ +/** Unfinished close requests survive reload independently of the removed sidebar tabs. */ +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WebTerminalId } from '../types.ts' + +/** An explicit cleanup request; no process or open-tab metadata is mirrored here. */ +export interface TerminalCloseRequest { + readonly sessionId: SessionId + readonly id: WebTerminalId + readonly title: string +} + +const PREFIX = 'dsh.terminal.close.v1.' + +/** Each request has its own storage key, so other browser windows cannot overwrite its cleanup. */ +export class TerminalCloseRequests { + private readonly requests = new Map() + + constructor() { + try { + if (typeof localStorage === 'undefined') return + for (let index = 0; index < localStorage.length; index++) { + const key = localStorage.key(index) + if (key?.startsWith(PREFIX)) this.load(key) + } + } catch (error) { console.error('Terminal cleanup recovery failed:', error) } + } + + /** + * Read cleanup work still awaiting Host confirmation. + * @returns unfinished requests owned by this browser instance. + */ + pending(): readonly TerminalCloseRequest[] { return [...this.requests.values()] } + + /** + * Retain cleanup across reload before removing a tab. + * @param request - close intent to save before removing its tab. + */ + save(request: TerminalCloseRequest): void { + this.requests.set(request.id, request) + try { + if (typeof localStorage !== 'undefined') localStorage.setItem(PREFIX + request.id, JSON.stringify(request)) + } catch (error) { console.error('Terminal cleanup persistence failed:', error) } + } + + /** + * Forget confirmed cleanup in memory and browser storage. + * @param id - terminal whose Host cleanup succeeded. + */ + remove(id: WebTerminalId): void { + this.requests.delete(id) + try { + if (typeof localStorage !== 'undefined') localStorage.removeItem(PREFIX + id) + } catch (error) { console.error('Terminal cleanup persistence failed:', error) } + } + + private load(key: string): void { + try { + const raw = localStorage.getItem(key) + if (raw === null) return + const parsed: unknown = JSON.parse(raw) + if (!isRequest(parsed) || key !== PREFIX + parsed.id) throw new Error('Invalid terminal cleanup request') + this.requests.set(parsed.id, parsed) + } catch (error) { console.error('Terminal cleanup recovery failed:', error) } + } +} + +function isRequest(value: unknown): value is TerminalCloseRequest { + if (typeof value !== 'object' || value === null) return false + const request = value as Record + return typeof request.sessionId === 'string' && request.sessionId.length > 0 + && typeof request.id === 'string' && /^[\w-]{1,128}$/u.test(request.id) + && typeof request.title === 'string' +} diff --git a/packages/api/terminal-controller/src/client/index.ts b/packages/api/terminal-controller/src/client/index.ts new file mode 100644 index 0000000000..1daec7edd8 --- /dev/null +++ b/packages/api/terminal-controller/src/client/index.ts @@ -0,0 +1,150 @@ +/** Client terminal model service; views are keyed independently from Host terminal identities. */ +import { Service, type Context } from '@deepseek-ai/cordis' +import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type {} from '@deepseek-ai/dsh-api-gateway/client' +import { TerminalView, type TerminalRemote } from './model.ts' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import { randomUUID } from '@deepseek-ai/dsh-util-crypto' +import type { WebTerminalId, WebTerminalInfo } from '../types.ts' +import { TerminalCloseRequests, type TerminalCloseRequest } from './close-requests.ts' + +export type { TerminalView, TerminalViewState, TerminalViewIssue, TerminalRenderFrame, TerminalRemote } from './model.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + /** React-free browser terminal views and explicit process cleanup. */ + webTerminals: ClientTerminals + } +} + +/** A failed background close that can be retried without restoring its tab. */ +export interface TerminalCloseFailure { + readonly id: WebTerminalId + readonly title: string + readonly message: string +} + +/** Session and occurrence lookup, independent tab and terminal identities and background cleanup. */ +export class ClientTerminals extends Service { + /** Failed cleanup tasks; successful and in-progress closes have no visible notification. */ + readonly closeFailures: SnapshotStore = createSnapshotStore([]) + private readonly requests = new TerminalCloseRequests() + private readonly closing = new Map>() + private readonly closed = new Set(this.requests.pending().map(request => request.id)) + private disposed = false + private readonly views = new Map>() + + /** + * @param ctx - Client root Context with Gateway and terminal Remote namespace. + * @param remote - generated terminal namespace. + */ + constructor(ctx: Context, private readonly remote: TerminalRemote) { + super(ctx, 'webTerminals') + ctx.effect(() => async () => { + this.disposed = true + const detaching = [...this.views.values()].flatMap(views => [...views.values()].map(view => view.dispose())) + this.views.clear() + await Promise.all([...detaching, ...this.closing.values()]) + }, 'terminal-controller.client.views') + for (const request of this.requests.pending()) this.cleanup(request) + } + + /** + * Return the stable model for one sidebar occurrence. + * @param sessionId - owning Session. + * @param key - sidebar occurrence key. + * @param terminalId - existing Host identity when restoring a listed terminal. + * @returns its observable state and terminal commands. + */ + view(sessionId: SessionId, key: string, terminalId?: WebTerminalId): TerminalView { + let views = this.views.get(sessionId) + if (views === undefined) { views = new Map(); this.views.set(sessionId, views) } + let view = views.get(key) + if (view === undefined) { + const id = terminalId ?? randomUUID() as WebTerminalId + view = new TerminalView(sessionId, this.remote, this.ctx.remote, id, terminalId === undefined) + views.set(key, view) + void view.refresh() + } + return view + } + + /** + * Save a close intent and release the tab immediately; cleanup outlives DOM unmount and reload. + * @param sessionId - owning Session. + * @param key - sidebar occurrence key, including an inactive restored tab. + * @param terminalId - restored identity if the tab has no model yet. + */ + close(sessionId: SessionId, key: string, terminalId?: WebTerminalId): void { + const views = this.views.get(sessionId) + const view = views?.get(key) + const id = view?.id ?? terminalId + if (id === undefined) return + const request: TerminalCloseRequest = { sessionId, id, title: view?.state.getSnapshot().title ?? key } + this.closed.add(id) + this.requests.save(request) + views?.delete(key) + if (views?.size === 0) this.views.delete(sessionId) + this.cleanup(request, view) + } + + /** + * Query Host terminals that have neither a tab in this page nor an unfinished close. + * @param sessionId - Session being displayed. + * @returns terminals available for opening as recovered tabs. + */ + async recover(sessionId: SessionId): Promise { + const result = await this.remote.list(sessionId) + if (!result.ok) throw new Error(result.error.message) + const held = new Set([...(this.views.get(sessionId)?.values() ?? [])].map(view => view.id)) + const closing = new Set(this.requests.pending().map(request => request.id)) + return result.value.filter(info => !held.has(info.id) && !closing.has(info.id) && !this.closed.has(info.id)) + } + + /** + * Retry a saved close request without reopening its tab. + * @param id - failed terminal identity. + */ + retryClose(id: WebTerminalId): void { + const record = this.requests.pending().find(item => item.id === id) + if (record !== undefined) this.cleanup(record) + } + + private cleanup(record: TerminalCloseRequest, view?: TerminalView): void { + if (this.closing.has(record.id) || this.disposed) return + this.closeFailures.set(this.closeFailures.getSnapshot().filter(failure => failure.id !== record.id)) + const pending = (async () => { + if (view !== undefined) await view.close() + else { + const result = await this.remote.close(record.sessionId, record.id) + if (!result.ok) throw result.error + } + this.requests.remove(record.id) + })().catch((error: unknown) => { + if (remoteErrorOf(error)?.code === 'session/not-found') { + this.requests.remove(record.id) + return + } + if (!this.disposed) this.closeFailures.set([...this.closeFailures.getSnapshot(), { + id: record.id, title: record.title, + message: error instanceof Error ? error.message : String(error), + }]) + }).then(async () => { + await view?.dispose() + this.closing.delete(record.id) + }) + this.closing.set(record.id, pending) + } +} + +/** Required Client transport and terminal namespace. */ +export const inject = ['remote', 'remote.terminal'] + +/** + * Install the Client terminal models. + * @param ctx - Client root Context. + */ +export function apply(ctx: Context): void { + new ClientTerminals(ctx, ctx.remote.terminal) +} diff --git a/packages/api/terminal-controller/src/client/model.ts b/packages/api/terminal-controller/src/client/model.ts new file mode 100644 index 0000000000..f6c1a350ad --- /dev/null +++ b/packages/api/terminal-controller/src/client/model.ts @@ -0,0 +1,352 @@ +/** React-free browser terminal state and reconnecting Remote-stream ownership. */ +import { preferredShell, rememberShell } from './shell-preference.ts' +import { randomUUID } from '@deepseek-ai/dsh-util-crypto' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import { RemoteStreamCarrierError, type ClientRemote, type RemoteStream } from '@deepseek-ai/dsh-api-gateway/client' +import { RemoteError, remoteErrorOf, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type {} from '@deepseek-ai/dsh-api-terminal-controller/remote' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { + TerminalShell, TerminalAttachmentId, TerminalEnvironment, TerminalFrame, + WebTerminalId, WebTerminalInfo, +} from '../types.ts' + +/** The generated terminal namespace's browser-facing operations. */ +export type TerminalRemote = ClientRemote['terminal'] + +/** Product error identifiers translated by the terminal UI. */ +export type TerminalViewIssue = 'missingTerminal' | 'inputFull' | 'attachmentEnded' | 'invalidOutput' | 'terminalLimit' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + /** Client terminal failure preserved through the Remote stream supervisor. */ + 'terminal/view': { readonly issue: TerminalViewIssue } + } +} + +class TerminalViewError extends RemoteError<'terminal/view'> { + constructor(issue: TerminalViewIssue, message: string = issue) { super('terminal/view', message, { issue }) } +} + +/** One screen write awaiting the DOM emulator's callback. */ +export interface TerminalRenderFrame { + readonly revision: number + readonly frame: Extract +} + +/** Observable state of one sidebar occurrence. */ +export interface TerminalViewState { + readonly phase: 'idle' | 'loading' | 'selecting' | 'creating' | 'connecting' | 'connected' | 'disconnected' | 'closing' | 'closed' | 'failed' + readonly shells?: readonly TerminalShell[] | undefined + readonly selectedShell?: string | undefined + readonly environment?: TerminalEnvironment | undefined + readonly title?: string | undefined + readonly info?: WebTerminalInfo | undefined + readonly writable: boolean + readonly render?: TerminalRenderFrame | undefined + readonly error?: string | undefined + readonly issue?: TerminalViewIssue | undefined +} + +/** A view survives DOM unmount; its process only ends on explicit close. */ +export class TerminalView { + /** Observable controls, process metadata and the next screen update awaiting acknowledgement. */ + readonly state: SnapshotStore = createSnapshotStore({ phase: 'idle', writable: false }) + private readonly lifetime = new AbortController() + private stream: RemoteStream | undefined + private mounted = false + private attachmentId: TerminalAttachmentId | undefined + private pendingRender: { revision: number; resolve: () => void } | undefined + private revision = 0 + private creation: Promise | undefined + private loading: Promise | undefined + private closing: Promise | undefined + private writes: Promise = Promise.resolve() + private queuedInput = 0 + private readonly detaching = new Set>() + + /** + * @param sessionId - Session owning the terminal. + * @param remote - typed terminal Remote operations. + * @param gateway - reconnecting stream factory. + * @param id - Host terminal identity, reused when recovering an item from its Session list. + * @param createWhenMissing - allow allocation only for a new tab, never a listed terminal. + */ + constructor( + private readonly sessionId: SessionId, + private readonly remote: TerminalRemote, + private readonly gateway: Pick, + readonly id: WebTerminalId, + private readonly createWhenMissing = true, + ) {} + + /** + * Attach the DOM lifetime, loading shell choices or reconnecting the saved process. + * @returns a detach callback that leaves the terminal process alive. + */ + mount(): () => void { + this.mounted = true + if (this.state.getSnapshot().info === undefined) void this.refresh() + else this.connect() + return () => { + this.mounted = false + this.detach() + } + } + + /** + * Start or recover this tab, deduplicating mounts and retries during allocation. + * Only a new tab may allocate a shell; listed terminals cannot be silently replaced. + * @returns after environment lookup and creation or recovery settle. + */ + refresh(): Promise { + if (this.creation !== undefined) return this.creation + if (this.loading !== undefined) return this.loading + if (this.closing !== undefined || this.lifetime.signal.aborted) return Promise.resolve() + this.patch({ phase: 'loading', error: undefined, issue: undefined }) + this.loading = (async () => { + const [environment, available] = await Promise.all([ + this.remote.environment(this.sessionId, this.lifetime.signal), this.remote.list(this.sessionId), + ]) + if (this.stopped()) return + this.patch({ environment: valueOf(environment) }) + const info = valueOf(available).find(item => item.id === this.id) + if (info !== undefined) this.adopt(info) + else if (this.createWhenMissing) { + const shells = valueOf(await this.remote.shells(this.sessionId, this.lifetime.signal)) + if (this.stopped()) return + const previous = this.state.getSnapshot().selectedShell ?? preferredShell() + const selectedShell = shells.find(shell => shell.path === previous)?.path ?? shells[0]?.path + this.patch({ phase: 'selecting', shells, selectedShell }) + } + else throw new TerminalViewError('missingTerminal') + })().catch((error: unknown) => { this.fail(error) }).finally(() => { this.loading = undefined }) + return this.loading + } + + /** + * Select and remember one verified shell before starting the terminal. + * @param path - executable path offered by Host discovery. + */ + selectShell(path: string): void { + const state = this.state.getSnapshot() + if (state.phase === 'selecting' && state.shells?.some(shell => shell.path === path)) { + this.patch({ selectedShell: path }) + rememberShell(path) + } + } + + /** + * Start the selected shell once; failed attempts retain their selection for retry. + * @returns after allocation settles; failures are exposed in observable state. + */ + start(): Promise { + if (this.creation !== undefined) return this.creation + const state = this.state.getSnapshot() + if (state.phase !== 'selecting' || state.environment === undefined || state.selectedShell === undefined + || this.closing !== undefined || this.lifetime.signal.aborted) return Promise.resolve() + rememberShell(state.selectedShell) + return this.create(state.environment, state.selectedShell) + } + + private stopped(): boolean { return this.lifetime.signal.aborted || this.closing !== undefined } + + private async create(environment: TerminalEnvironment, shellPath: string): Promise { + this.patch({ phase: 'creating', error: undefined, issue: undefined }) + this.creation = (async () => { + const info = valueOf(await this.remote.create(this.sessionId, { + id: this.id, shellPath, cols: Math.min(80, environment.maxCols), rows: Math.min(24, environment.maxRows), + }, this.lifetime.signal)) + if (!this.lifetime.signal.aborted) { + this.adopt(info) + } + })().catch((error: unknown) => { this.fail(error) }).finally(() => { this.creation = undefined }) + await this.creation + } + + private adopt(info: WebTerminalInfo): void { + this.patch({ info, title: info.title }) + if (this.mounted && this.closing === undefined) this.connect() + } + + /** Reattach with a fresh screen and regain input control. */ + connect(): void { + const info = this.state.getSnapshot().info + if (info === undefined || !this.mounted || this.closing !== undefined || this.lifetime.signal.aborted) return + this.detach() + const stream = this.gateway.$stream({ + name: 'Browser terminal output', + open: (signal) => { + const attachmentId = randomUUID() as TerminalAttachmentId + this.attachmentId = attachmentId + return this.remote.follow(this.sessionId, info.id, attachmentId, signal) + }, + ended: () => new TerminalViewError('attachmentEnded'), + carrierFailed: () => { if (this.stream === stream) this.patch({ phase: 'disconnected', writable: false }) }, + }) + this.stream = stream + this.patch({ phase: 'connecting', writable: false, error: undefined, issue: undefined, render: undefined }) + void this.consume(stream) + } + + /** + * Release the next stream item after xterm has parsed this frame. + * @param revision - locally delivered render revision. + */ + acknowledge(revision: number): void { + if (this.pendingRender?.revision !== revision) return + this.pendingRender.resolve() + this.pendingRender = undefined + } + + /** + * Serialize raw input so concurrent RPC requests cannot reorder keystrokes. + * @param data - input from the terminal emulator. + */ + write(data: string): void { + const state = this.state.getSnapshot() + const attachmentId = this.attachmentId + if (!state.writable || state.info === undefined || attachmentId === undefined) return + const bytes = new TextEncoder().encode(data).byteLength + if (this.queuedInput + bytes > (state.environment?.maxInputBytes ?? 0)) { this.fail(new TerminalViewError('inputFull')); return } + this.queuedInput += bytes + const id = state.info.id + this.writes = this.writes.then(async () => { + if (this.attachmentId !== attachmentId || !this.state.getSnapshot().writable) return + valueOf(await this.remote.write(this.sessionId, id, attachmentId, data)) + }).catch((error: unknown) => { if (this.attachmentId === attachmentId) this.fail(error) }).finally(() => { this.queuedInput -= bytes }) + } + + /** + * Resize only from the currently writable view. + * @param cols - measured column count. + * @param rows - measured row count. + */ + resize(cols: number, rows: number): void { + const state = this.state.getSnapshot() + const attachmentId = this.attachmentId + if (!state.writable || state.info === undefined || attachmentId === undefined) return + if (state.info.cols === cols && state.info.rows === rows) return + const id = state.info.id + cols = Math.min(cols, state.environment?.maxCols ?? cols) + rows = Math.min(rows, state.environment?.maxRows ?? rows) + this.writes = this.writes.then(async () => { + if (this.attachmentId !== attachmentId || !this.state.getSnapshot().writable) return + valueOf(await this.remote.resize(this.sessionId, id, attachmentId, cols, rows)) + }).catch((error: unknown) => { if (this.attachmentId === attachmentId) this.fail(error) }) + } + + /** + * Update the Host terminal's display name. + * @param title - user-entered terminal title. + * @returns after the rename settles and its result is reflected in view state. + */ + async rename(title: string): Promise { + if (title.trim() === this.state.getSnapshot().title || this.lifetime.signal.aborted) return + try { + valueOf(await this.remote.rename(this.sessionId, this.id, title)) + const current = this.state.getSnapshot().info + this.patch({ ...(current === undefined ? {} : { info: { ...current, title: title.trim() } }), title: title.trim() }) + } catch (error) { this.fail(error) } + } + + /** + * Explicitly terminate this view's process independently of its DOM lifetime. + * @returns after Host process cleanup succeeds; failures remain retryable by the owner. + */ + close(): Promise { + if (this.closing !== undefined) return this.closing + this.patch({ phase: 'closing', writable: false, error: undefined, issue: undefined }) + this.detach() + this.closing = (async () => { + // Even a refused or lost creation response may leave an allocation to close. + await this.creation + valueOf(await this.remote.close(this.sessionId, this.id)) + this.detach() + this.patch({ phase: 'closed', writable: false }) + })().catch((error: unknown) => { this.closing = undefined; this.fail(error); throw error }) + return this.closing + } + + /** + * Stop Client work on plugin unload without closing Host terminals. + * @returns after active and previously detached stream iterators have closed. + */ + async dispose(): Promise { + this.mounted = false + this.lifetime.abort() + this.detach() + await Promise.all(this.detaching) + } + + private detach(): void { + const previous = this.stream + this.stream = undefined + this.attachmentId = undefined + this.pendingRender?.resolve() + this.pendingRender = undefined + if (previous !== undefined) { + const cleanup = previous.dispose().finally(() => { this.detaching.delete(cleanup) }) + this.detaching.add(cleanup) + } + } + + private async consume(stream: RemoteStream): Promise { + let generation = 0 + let sequence = 0 + try { + for await (const item of stream) { + if (this.stream !== stream) return + const frame = item.value + if (generation !== item.generation) { + if (frame.type !== 'snapshot') throw new TerminalViewError('invalidOutput', 'Terminal output generation is missing its screen snapshot') + generation = item.generation + sequence = frame.sequence + item.accept() + } else if (frame.type === 'output') { + if (frame.sequence !== sequence + 1) throw new TerminalViewError('invalidOutput', 'Terminal output sequence has a gap') + sequence = frame.sequence + } else if (frame.type === 'snapshot') throw new TerminalViewError('invalidOutput', 'Unexpected terminal screen snapshot') + if (frame.type !== 'output') { + this.patch({ info: frame.info, title: frame.info.title, phase: 'connected', writable: frame.info.state === 'running' && frame.info.controllerId === this.attachmentId }) + } + if (frame.type !== 'state') { + const revision = ++this.revision + await new Promise((resolve) => { + this.pendingRender = { revision, resolve } + const aborted = (): void => { this.acknowledge(revision) } + item.signal.addEventListener('abort', aborted, { once: true }) + this.pendingRender.resolve = () => { item.signal.removeEventListener('abort', aborted); resolve() } + this.patch({ render: { revision, frame } }) + if (item.signal.aborted) aborted() + }) + } + } + } catch (error) { + if (this.stream === stream) { + if (this.state.getSnapshot().info?.state === 'exited') this.patch({ phase: 'closed', writable: false }) + else this.fail(error) + } + } + } + + private patch(patch: Partial): void { + if (this.lifetime.signal.aborted) return + this.state.set({ ...this.state.getSnapshot(), ...patch }) + } + + private fail(error: unknown): void { + const failure = remoteErrorOf(error) + if (failure?.code === 'terminal/control-unavailable') { + this.patch({ writable: false, error: undefined, issue: undefined }) + return + } + const issue = failure?.code === 'terminal/view' ? failure.details.issue : failure?.code === 'terminal/limit-reached' ? 'terminalLimit' : undefined + this.patch({ phase: error instanceof RemoteStreamCarrierError ? 'disconnected' : 'failed', writable: false, issue, error: error instanceof Error ? error.message : String(error) }) + } +} + +function valueOf(result: RemoteResult): T { + if (!result.ok) throw result.error + return result.value +} diff --git a/packages/api/terminal-controller/src/client/shell-preference.ts b/packages/api/terminal-controller/src/client/shell-preference.ts new file mode 100644 index 0000000000..8e8a461591 --- /dev/null +++ b/packages/api/terminal-controller/src/client/shell-preference.ts @@ -0,0 +1,20 @@ +/** Browser-local shell preference; Host discovery decides whether the saved path is usable. */ +const KEY = 'dsh.terminal.shell' + +/** + * Read the browser preference. + * @returns the last selected shell path, or null when storage is unavailable. + */ +export function preferredShell(): string | null { + try { return typeof localStorage === 'undefined' ? null : localStorage.getItem(KEY) } + catch (_storageUnavailable) { return null } +} + +/** + * Remember the selected shell without making storage a startup dependency. + * @param path - verified executable path offered by the Host. + */ +export function rememberShell(path: string): void { + try { if (typeof localStorage !== 'undefined') localStorage.setItem(KEY, path) } + catch (_storageUnavailable) { /* Private browsing or quota failure leaves this launch usable. */ } +} diff --git a/packages/api/terminal-controller/src/index.ts b/packages/api/terminal-controller/src/index.ts new file mode 100644 index 0000000000..920982897e --- /dev/null +++ b/packages/api/terminal-controller/src/index.ts @@ -0,0 +1,360 @@ +/** Session-scoped browser terminals over the composed subprocess and sandbox providers. */ +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import type { SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess' +import type {} from '@deepseek-ai/dsh-sandbox-policy' +import type {} from '@deepseek-ai/dsh-sandbox' +import type {} from '@deepseek-ai/dsh-session-projection' +import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { discoverShells, resolveShell } from './shells.ts' +import { BrowserTerminal } from './terminal.ts' +import type { + TerminalShell, TerminalAttachmentId, TerminalCreateRequest, TerminalEnvironment, TerminalFrame, + WebTerminalId, WebTerminalInfo, +} from './types.ts' + +export type * from './types.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Interactive user terminals, separate from the Agent terminal tool registry. */ + terminalController: TerminalController + } +} + +/** Deployment limits and an optional shell profile. */ +export interface Config { + /** Explicit shell profile; omission uses the execution environment's default shell. */ + readonly shell?: { + /** Executable path or PATH name, verified by the subprocess provider. */ + path: string + /** User-visible profile name. */ + name: string + /** Arguments passed to the interactive shell. */ + args: string[] + } | undefined + /** Executable names or paths checked for the new-terminal shell selector. */ + readonly shellCandidates: string[] + /** Maximum retained terminals and pending allocations per Session. */ + readonly maxTerminals: number + /** Maximum terminal width in columns. */ + readonly maxCols: number + /** Maximum terminal height in rows. */ + readonly maxRows: number + /** Screen history rows retained for reconnecting clients. */ + readonly scrollback: number + /** Maximum queued UTF-8 frame bytes per output follower before disconnection. */ + readonly maxBufferedBytes: number + /** Maximum UTF-8 bytes in one input request. */ + readonly maxInputBytes: number + /** Provider process-termination grace period in milliseconds. */ + readonly disposeGraceMs: number +} + +interface OwnedSession { + readonly lifetime: AbortController + readonly closedIds: Set + cleanup?: Promise + readonly terminals: Map + readonly pending: Map> + readonly allocations: Map +} + +/** Typed Remote control of transient Session-owned terminal processes. */ +export class TerminalController extends TypertRemoteService { + static inject = ['subprocess', 'sandboxPolicy', 'sessionProjections', 'typert'] + static Config: z = z.object({ + shell: z.union([z.object({ + path: z.string().required(), name: z.string().required(), args: z.array(z.string()).default([]), + }), z.const(undefined)]), + shellCandidates: z.array(z.string().min(1)).default(['zsh', 'bash', 'fish', 'sh', 'ksh', 'tcsh', 'csh', 'pwsh', 'powershell', 'cmd', 'nu']), + maxTerminals: z.number().step(1).min(1).default(8), + maxCols: z.number().step(1).min(2).default(500), + maxRows: z.number().step(1).min(1).default(200), + scrollback: z.number().step(1).min(0).default(1000), + maxBufferedBytes: z.number().step(1).min(1024).default(2 * 1024 * 1024), + maxInputBytes: z.number().step(1).min(1).default(64 * 1024), + disposeGraceMs: z.number().step(1).min(1).default(1000), + }) + + private readonly owners = new Map() + private readonly lifetime = new AbortController() + + /** + * @param ctx - Host context carrying typed Remote and execution providers. + * @param config - validated terminal limits and optional shell profile. + */ + constructor(ctx: Context, private readonly config: Config) { + super(ctx, 'terminalController', { namespace: 'terminal' }) + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName !== 'session/event') return + const [session, event] = args as [Session, SessionEvent] + if (event.type !== 'sandbox/mode') return + const owner = this.owners.get(session.id) + if (owner === undefined || owner.terminals.size + owner.pending.size + owner.allocations.size === 0) return + const current = ctx.sessionProjections.stateOf(session, 'sandboxMode') ?? ctx.sandboxPolicy.defaultMode + if (event.data.mode !== current) throw new Error('Close browser terminals before changing the Session sandbox mode') + }, { global: true }) + ctx.effect(() => async () => { + this.lifetime.abort(new Error('Terminal controller disposed')) + const results = await Promise.allSettled([...this.owners].map(([id, owner]) => this.disposeOwner(id, owner))) + const errors = results.filter(result => result.status === 'rejected').map(result => result.reason as unknown) + if (errors.length > 0) throw new AggregateError(errors, 'Browser terminal cleanup failed') + }, 'terminal-controller.processes') + } + + /** + * Read the Session working directory and terminal limits without resolving a shell. + * @param agent - Session owner supplied by the Gateway. + * @param signal - request cancellation. + * @returns the Session workspace directory and terminal limits. + */ + @Remote + environment(agent: Agent, signal: AbortSignal): TerminalEnvironment { + signal.throwIfAborted() + const { sandboxPolicy } = this.execution(agent) + return { cwd: sandboxPolicy.resolve({ session: agent.session }).workspaceRoot, + maxInputBytes: this.config.maxInputBytes, maxCols: this.config.maxCols, + maxRows: this.config.maxRows, scrollback: this.config.scrollback } + } + + /** + * Discover installed shells in the Session's execution environment. + * @param agent - Session owner supplied by the Gateway. + * @param signal - request cancellation. + * @returns verified profiles, with the configured or system default first. + */ + @Remote + shells(agent: Agent, signal: AbortSignal): Promise { + signal.throwIfAborted() + return discoverShells(this.execution(agent).subprocess, this.config.shell, this.config.shellCandidates, signal) + } + + /** + * List retained terminals without resolving or activating an Agent. + * @param sessionId - displayed Session identity, including offline history. + * @returns terminals retained for this Host lifetime. + */ + @Remote + list(sessionId: SessionId): WebTerminalInfo[] { + const owner = this.owners.get(sessionId) + if (owner === undefined) return [] + return [...owner.terminals.values(), ...owner.allocations.values()].map(terminal => terminal.info) + } + + /** + * Allocate an interactive shell once for a caller-generated identity. + * @param agent - Session owner supplied by the Gateway. + * @param request - initial dimensions and idempotency identity. + * @param signal - allocation cancellation; committed terminals survive disconnection. + * @returns the existing or newly committed terminal. + */ + @Remote + async create(agent: Agent, request: TerminalCreateRequest, signal: AbortSignal): Promise { + this.lifetime.signal.throwIfAborted() + if (!/^[\w-]{1,128}$/u.test(request.id)) throw new Error('Invalid terminal identity') + this.dimensions(request.cols, request.rows) + const owner = this.owner(agent) + owner.lifetime.signal.throwIfAborted() + this.requireOpen(owner, request.id) + const existing = owner.terminals.get(request.id) + if (existing !== undefined) return existing.info + const pending = owner.pending.get(request.id) + if (pending !== undefined) { + const terminal = await pending + this.requireOpen(owner, request.id) + return terminal.info + } + if (owner.allocations.has(request.id)) throw new Error('Close the failed terminal allocation before creating it again') + if (new Set([...owner.terminals.keys(), ...owner.pending.keys(), ...owner.allocations.keys()]).size >= this.config.maxTerminals) throw new RemoteError('terminal/limit-reached', 'Session terminal limit reached', { limit: this.config.maxTerminals }) + const allocation = this.spawn(agent, owner, request, AbortSignal.any([signal, this.lifetime.signal, owner.lifetime.signal])) + owner.pending.set(request.id, allocation) + try { + const terminal = await allocation + owner.terminals.set(request.id, terminal) + owner.allocations.delete(request.id) + this.requireOpen(owner, request.id) + return terminal.info + } finally { + owner.pending.delete(request.id) + } + } + + /** + * Attach to a terminal without binding its process lifetime to the transport. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param attachmentId - new exclusive input attachment. + * @param signal - physical stream cancellation. + * @returns screen recovery followed by output and metadata changes. + */ + @Remote({ mode: 'stream' }) + follow(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, signal: AbortSignal): AsyncIterable { + if (!/^[\w-]{1,128}$/u.test(attachmentId)) throw new Error('Invalid terminal attachment identity') + return this.terminal(agent, id).follow(attachmentId, signal) + } + + /** + * Deliver raw input, including Tab completion and control characters. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param attachmentId - current writable attachment. + * @param data - input bytes represented as UTF-8 text. + * @returns after provider input acceptance. + */ + @Remote + async write(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, data: string): Promise { + if (Buffer.byteLength(data, 'utf8') > this.config.maxInputBytes) throw new Error('Terminal input exceeds the configured limit') + await this.terminal(agent, id).write(attachmentId, data) + } + + /** + * Update the dimensions of the PTY and recovery screen. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param attachmentId - current writable attachment. + * @param cols - column count. + * @param rows - row count. + * @returns after the resize completes. + */ + @Remote + async resize(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, cols: number, rows: number): Promise { + this.dimensions(cols, rows) + await this.terminal(agent, id).resize(attachmentId, cols, rows) + } + + /** + * Rename a terminal without changing its shell. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param title - nonempty display title, at most 120 characters. + */ + @Remote + rename(agent: Agent, id: WebTerminalId, title: string): void { + if (title.trim().length === 0 || title.length > 120) throw new Error('Terminal title must contain 1–120 characters') + this.terminal(agent, id).rename(title.trim()) + } + + /** + * Close an identity to future creation and kill its process range; repeated closes succeed. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @returns after provider cleanup succeeds. A failure retains the terminal for retry. + */ + @Remote + async close(agent: Agent, id: WebTerminalId): Promise { + const owner = this.owner(agent) + owner.closedIds.add(id) + // create publishes the allocation before this wait settles; close owns it even if create then rejects. + await owner.pending.get(id)?.catch(() => { /* Creation reports its failure; close still owns any allocated process. */ }) + const terminal = owner.terminals.get(id) + if (terminal !== undefined) { + await terminal.close() + owner.terminals.delete(id) + } else { + const allocation = owner.allocations.get(id) + if (allocation === undefined) return + await allocation.handle.terminate() + owner.allocations.delete(id) + } + } + + private owner(agent: Agent): OwnedSession { + let owner = this.owners.get(agent.id) + if (owner === undefined) { + owner = { terminals: new Map(), pending: new Map(), allocations: new Map(), closedIds: new Set(), lifetime: new AbortController() } + this.owners.set(agent.id, owner) + const owned = owner + agent.ctx.effect(() => async () => { await this.disposeOwner(agent.id, owned) }, 'terminal-controller.owner') + } + return owner + } + + private disposeOwner(id: SessionId, owner: OwnedSession): Promise { + if (owner.cleanup !== undefined) return owner.cleanup + owner.lifetime.abort(new Error('Terminal Session owner disposed')) + owner.cleanup = (async () => { + await Promise.allSettled(owner.pending.values()) + const results = await Promise.allSettled([ + ...[...owner.terminals.values()].map(terminal => terminal.close()), + ...[...owner.allocations.values()].map(allocation => allocation.handle.terminate()), + ]) + const errors = results.filter(result => result.status === 'rejected').map(result => result.reason as unknown) + if (errors.length > 0) throw new AggregateError(errors, 'Session terminal cleanup failed') + owner.terminals.clear() + owner.allocations.clear() + this.owners.delete(id) + })().catch((error: unknown) => { delete owner.cleanup; throw error }) + return owner.cleanup + } + + private terminal(agent: Agent, id: WebTerminalId): BrowserTerminal { + const terminal = this.owners.get(agent.id)?.terminals.get(id) + if (terminal === undefined) throw new Error('Terminal no longer exists in this Session') + return terminal + } + + private requireOpen(owner: OwnedSession, id: WebTerminalId): void { + if (owner.closedIds.has(id)) throw new Error('Terminal was closed in this Session') + } + + private dimensions(cols: number, rows: number): void { + if (!Number.isSafeInteger(cols) || cols < 2 || cols > this.config.maxCols + || !Number.isSafeInteger(rows) || rows < 1 || rows > this.config.maxRows) throw new Error('Terminal dimensions exceed the configured limits') + } + + private execution(agent: Agent): { subprocess: Context['subprocess']; sandboxPolicy: Context['sandboxPolicy'] } { + // The Agent context selects execution providers but does not inject consumer services. + const subprocess = agent.ctx.get('subprocess') + const sandboxPolicy = agent.ctx.get('sandboxPolicy') + if (subprocess === undefined || sandboxPolicy === undefined) throw new Error('The Session execution environment requires subprocess and sandbox policy providers') + return { subprocess, sandboxPolicy } + } + + private async spawn(agent: Agent, owner: OwnedSession, request: TerminalCreateRequest, signal: AbortSignal): Promise { + const environment = this.environment(agent, signal) + const { subprocess, sandboxPolicy } = this.execution(agent) + const shell = request.shellPath === undefined + ? await resolveShell(subprocess, this.config.shell, signal) + : (await this.shells(agent, signal)).find(candidate => candidate.path === request.shellPath) + if (shell === undefined) throw new Error('Selected shell is not available in this execution environment') + const policy = sandboxPolicy.resolve({ session: agent.session }) + let argv = [shell.path, ...shell.args] + if (policy.mode !== 'danger-full-access') { + const sandbox = agent.ctx.get('sandbox') + if (sandbox === undefined) throw new Error('The Session sandbox mode requires an execution sandbox provider') + argv = (await sandbox.confine(argv, { ...policy, mode: policy.mode }, signal)).argv + } + const handle = await subprocess.spawnTerminal({ + argv, cwd: environment.cwd, cols: request.cols, rows: request.rows, + terminalType: 'xterm-256color', env: { DSH_SESSION_ID: agent.id }, + graceMs: this.config.disposeGraceMs, signal, + }) + const allocation = { + handle, + info: { + id: request.id, shell, title: shell.name, cwd: environment.cwd, + cols: request.cols, rows: request.rows, state: 'running', exitCode: null, + } as WebTerminalInfo, + } + owner.allocations.set(request.id, allocation) + try { + signal.throwIfAborted() + return new BrowserTerminal(handle, allocation.info, this.config.scrollback, this.config.maxBufferedBytes) + } catch (error) { + allocation.info = { ...allocation.info, state: 'failed', error: error instanceof Error ? error.message : String(error) } + try { + await handle.terminate() + owner.allocations.delete(request.id) + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], 'Terminal allocation cleanup failed') + } + throw error + } + } +} + +/** Browser terminal service plugin. */ +export default TerminalController diff --git a/packages/api/terminal-controller/src/shells.ts b/packages/api/terminal-controller/src/shells.ts new file mode 100644 index 0000000000..c8010c38c4 --- /dev/null +++ b/packages/api/terminal-controller/src/shells.ts @@ -0,0 +1,57 @@ +/** Shell selection and executable verification use the target execution provider. */ +import { SubprocessExecutableNotFoundError, type SubprocessRuntime } from '@deepseek-ai/dsh-subprocess' +import type { TerminalShell } from './types.ts' + +/** + * Resolve the configured shell or the execution environment's default shell. + * @param subprocess - target execution provider. + * @param configured - optional profile overriding the environment's default shell. + * @param signal - resolution cancellation. + * @returns one verified shell; a declared default that cannot resolve rejects. + */ +export async function resolveShell( + subprocess: SubprocessRuntime, configured: TerminalShell | undefined, signal: AbortSignal, +): Promise { + let shell = configured + if (shell === undefined) { + const environment = await subprocess.terminalEnvironment(signal) + shell = profile(environment.defaultShell ?? (environment.platform === 'windows' ? 'cmd.exe' : '/bin/sh')) + } + const path = await subprocess.resolveExecutable(shell.path, undefined, signal) + return { ...shell, path } +} + +function profile(path: string): TerminalShell { + const name = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1) + const kind = name.toLowerCase().replace(/\.exe$/u, '') + return { path, name, args: kind === 'cmd' ? [] : kind === 'pwsh' || kind === 'powershell' ? ['-NoLogo'] : ['-i'] } +} + +/** + * List verified candidates after the configured or environment-default shell. + * @param subprocess - target execution provider. + * @param configured - optional default profile. + * @param candidates - executable names or paths permitted for shell selection. + * @param signal - discovery cancellation. + * @returns unique installed shells, with the default first; transport failures reject. + */ +export async function discoverShells( + subprocess: SubprocessRuntime, configured: TerminalShell | undefined, + candidates: readonly string[], signal: AbortSignal, +): Promise { + const preferred = await resolveShell(subprocess, configured, signal) + const found = await Promise.all(candidates.map(async (candidate) => { + try { return await resolveShell(subprocess, profile(candidate), signal) } + catch (error) { + if (error instanceof SubprocessExecutableNotFoundError) return undefined + throw error + } + })) + const shells = new Map() + for (const shell of [preferred, ...found]) { + if (shell === undefined) continue + const key = shell.path.includes('\\') ? shell.path.toLowerCase() : shell.path + if (!shells.has(key)) shells.set(key, shell) + } + return [...shells.values()] +} diff --git a/packages/api/terminal-controller/src/stream.ts b/packages/api/terminal-controller/src/stream.ts new file mode 100644 index 0000000000..4ee89f254f --- /dev/null +++ b/packages/api/terminal-controller/src/stream.ts @@ -0,0 +1,75 @@ +/** A bounded output queue for one Remote stream generation. */ +import { Deque } from '@deepseek-ai/dsh-deque' +import type { TerminalFrame } from './types.ts' + +/** Slow followers fail explicitly; a later attachment recovers from the screen. */ +export class TerminalFollower { + private readonly queue = new Deque<{ frame: TerminalFrame; bytes: number }>() + private bytes = 0 + private wake: (() => void) | undefined + private closed = false + private finished = false + private failure: Error | undefined + + /** @param maxBytes - maximum queued UTF-8 bytes for this follower. */ + constructor(private readonly maxBytes: number) {} + + /** + * Queue a frame or fail this follower when its byte limit is exceeded. + * @param frame - next ordered frame. + */ + push(frame: TerminalFrame): void { + if (this.closed || this.finished) return + const bytes = Buffer.byteLength(JSON.stringify(frame), 'utf8') + if (this.bytes + bytes > this.maxBytes) { + this.failure = new Error('Terminal output consumer exceeded its buffer; reconnect to recover the current screen') + this.close() + return + } + this.queue.pushBack({ frame, bytes }) + this.bytes += bytes + this.wake?.() + } + + /** Finish after delivering every queued frame, including the final exit state. */ + finish(): void { + this.finished = true + this.wake?.() + } + + /** Stop this follower without stopping its terminal. */ + close(): void { + this.closed = true + this.queue.clear() + this.bytes = 0 + this.wake?.() + } + + /** + * Drain until detached or failed. + * @param signal - Remote generation cancellation. + * @returns ordered terminal frames. + */ + async *read(signal: AbortSignal): AsyncIterable { + const abort = (): void => { this.close() } + signal.addEventListener('abort', abort, { once: true }) + if (signal.aborted) abort() + try { + while (!this.closed) { + const next = this.queue.popFront() + if (next !== undefined) { + this.bytes -= next.bytes + yield next.frame + } else { + if (this.finished) break + await new Promise((resolve) => { this.wake = resolve }) + this.wake = undefined + } + } + if (this.failure !== undefined) throw this.failure + } finally { + signal.removeEventListener('abort', abort) + this.close() + } + } +} diff --git a/packages/api/terminal-controller/src/terminal.ts b/packages/api/terminal-controller/src/terminal.ts new file mode 100644 index 0000000000..9e8dfcafff --- /dev/null +++ b/packages/api/terminal-controller/src/terminal.ts @@ -0,0 +1,175 @@ +/** One PTY, a bounded terminal emulator and its detachable browser followers. */ +import { createRequire } from 'node:module' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import type { Terminal as HeadlessTerminal } from '@xterm/headless' +import type { SerializeAddon as Serializer } from '@xterm/addon-serialize' +import type { SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess' +import { TerminalFollower } from './stream.ts' +import type { TerminalAttachmentId, TerminalFrame, WebTerminalInfo } from './types.ts' + +const { Terminal, SerializeAddon } = loadXterm() + +function loadXterm() { + // The Preview's CommonJS wrapper owns its outer require binding; these literal calls also retain the CJS entries. + const require = createRequire(import.meta.url) + const { Terminal } = require('@xterm/headless') as typeof import('@xterm/headless') + const { SerializeAddon } = require('@xterm/addon-serialize') as typeof import('@xterm/addon-serialize') + return { Terminal, SerializeAddon } +} + +/** Process lifetime is independent of follower and component lifetimes. */ +export class BrowserTerminal { + private readonly screen: HeadlessTerminal + private readonly serializer: Serializer + private readonly followers = new Set() + private sequence = 0 + private operations: Promise = Promise.resolve() + private readonly drained: Promise + private closing: Promise | undefined + private controller: { id: TerminalAttachmentId; follower: TerminalFollower } | undefined + + /** + * @param handle - allocated terminal process range. + * @param info - initial metadata. + * @param scrollback - maximum retained scrollback rows. + * @param maxBufferedBytes - per-follower queue cap. + */ + constructor( + private readonly handle: SubprocessTerminalHandle, + public info: WebTerminalInfo, + scrollback: number, + private readonly maxBufferedBytes: number, + ) { + this.screen = new Terminal({ cols: info.cols, rows: info.rows, scrollback, allowProposedApi: true }) + this.serializer = new SerializeAddon() + this.screen.loadAddon(this.serializer) + this.drained = this.consume() + } + + /** + * Attach with exclusive input control; an older attachment becomes read-only. + * @param id - browser attachment identity. + * @param signal - attachment cancellation; never terminates the process. + * @returns a consistent screen followed by ordered output and state changes. + */ + async *follow(id: TerminalAttachmentId, signal: AbortSignal): AsyncIterable { + signal.throwIfAborted() + const follower = new TerminalFollower(this.maxBufferedBytes) + const baseline = await this.enqueue(() => { + signal.throwIfAborted() + this.controller = { id, follower } + this.info = { ...this.info, controllerId: id } + this.broadcast({ type: 'state', info: this.info }) + const snapshot: TerminalFrame = { type: 'snapshot', sequence: this.sequence, screen: this.serializer.serialize(), info: this.info } + this.followers.add(follower) + return snapshot + }) + try { + yield baseline + yield* follower.read(signal) + } finally { + this.followers.delete(follower) + follower.close() + if (this.controller?.follower === follower) { + this.controller = undefined + const { controllerId: _controllerId, ...info } = this.info + this.info = info + this.broadcast({ type: 'state', info }) + } + } + } + + /** + * Send raw terminal input without command interpretation. + * @param id - current writable attachment. + * @param data - UTF-8 input, including shell completion/control keys. + * @returns when the provider accepts the input. + */ + write(id: TerminalAttachmentId, data: string): Promise { + return this.enqueue(async () => { this.requireController(id); await this.handle.write(data) }) + } + + /** + * Resize the PTY and recovery screen in the same operation order as output. + * @param id - current writable attachment. + * @param cols - validated column count. + * @param rows - validated row count. + * @returns when the provider and emulator use the new dimensions. + */ + resize(id: TerminalAttachmentId, cols: number, rows: number): Promise { + return this.enqueue(async () => { + this.requireController(id) + await this.handle.resize(cols, rows) + this.screen.resize(cols, rows) + this.info = { ...this.info, cols, rows } + this.broadcast({ type: 'state', info: this.info }) + }) + } + + /** + * Publish a display name to every attached view. + * @param title - validated user title. + */ + rename(title: string): void { + this.info = { ...this.info, title } + this.broadcast({ type: 'state', info: this.info }) + } + + /** + * Terminate the complete provider-owned process range before releasing its screen. + * @returns after process cleanup and final output drainage; failures remain retryable. + */ + close(): Promise { + if (this.closing !== undefined) return this.closing + this.closing = (async () => { + await this.handle.terminate() + await this.drained + for (const follower of this.followers) follower.finish() + this.followers.clear() + this.screen.dispose() + })().catch((error: unknown) => { this.closing = undefined; throw error }) + return this.closing + } + + private requireController(id: TerminalAttachmentId): void { + if (this.closing !== undefined || this.info.state !== 'running') throw new RemoteError('terminal/control-unavailable', 'Terminal is not running', { reason: 'not-running' }) + if (this.controller?.id !== id) throw new RemoteError('terminal/control-unavailable', 'Terminal input is controlled by another attachment', { reason: 'read-only' }) + } + + private broadcast(frame: TerminalFrame): void { + for (const follower of this.followers) follower.push(frame) + } + + private enqueue(operation: () => T | Promise): Promise { + const pending = this.operations.then(operation) + this.operations = pending.catch(() => { /* The caller owns this operation's failure; later cleanup must still run. */ }) + return pending + } + + private async consume(): Promise { + const decoder = new TextDecoder('utf-8', { ignoreBOM: true }) + const outcome = this.handle.done.then(value => ({ value }), (error: unknown) => ({ error })) + try { + for await (const chunk of this.handle.output) { + // Node Readable's iterator is untyped; this provider explicitly emits Buffer chunks. + const data = decoder.decode(chunk as Buffer, { stream: true }) + await this.output(data) + } + await this.output(decoder.decode()) + const result = await outcome + if ('error' in result) throw result.error + this.info = { ...this.info, state: 'exited', exitCode: result.value.exitCode } + } catch (error) { + this.info = { ...this.info, state: 'failed', error: error instanceof Error ? error.message : String(error) } + } + this.broadcast({ type: 'state', info: this.info }) + } + + private async output(data: string): Promise { + if (data.length === 0) return + await this.enqueue(async () => { + await new Promise((resolve) => { this.screen.write(data, resolve) }) + this.broadcast({ type: 'output', sequence: ++this.sequence, data }) + }) + } +} diff --git a/packages/api/terminal-controller/src/types.ts b/packages/api/terminal-controller/src/types.ts new file mode 100644 index 0000000000..fac1f4363c --- /dev/null +++ b/packages/api/terminal-controller/src/types.ts @@ -0,0 +1,63 @@ +/** Browser terminal identities, metadata and screen-stream frames. */ +import type { Branded } from '@deepseek-ai/dsh-brand' +import type {} from '@deepseek-ai/dsh-typert-protocol' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + /** Input or resize was refused without invalidating the output attachment. */ + 'terminal/control-unavailable': { readonly reason: 'read-only' | 'not-running' } + /** Retained screens and pending allocations consume the Session's terminal quota. */ + 'terminal/limit-reached': { readonly limit: number } + } +} + +/** A terminal identity scoped to one Session and one Host lifetime. */ +export type WebTerminalId = Branded<'WebTerminalId'> +/** An attachment allowed to write and resize one terminal. */ +export type TerminalAttachmentId = Branded<'TerminalAttachmentId'> + +/** An executable shell verified in the subprocess provider's execution environment. */ +export interface TerminalShell { + readonly path: string + readonly args: readonly string[] + readonly name: string +} + +/** Working directory and limits shared by new and restored terminals. */ +export interface TerminalEnvironment { + readonly cwd: string + readonly maxInputBytes: number + readonly maxCols: number + readonly maxRows: number + readonly scrollback: number +} + +/** Host-owned terminal state; process exit never creates a replacement shell. */ +export interface WebTerminalInfo { + readonly id: WebTerminalId + readonly title: string + readonly shell: TerminalShell + /** Initial working directory; shell directory changes do not update this field. */ + readonly cwd: string + readonly cols: number + readonly rows: number + readonly state: 'running' | 'exited' | 'failed' + readonly exitCode: number | null + readonly error?: string + readonly controllerId?: TerminalAttachmentId +} + +/** Create is idempotent for an open identity; closed identities cannot be recreated. */ +export interface TerminalCreateRequest { + /** A path returned by shell discovery; absent selects the execution default. */ + readonly shellPath?: string + readonly id: WebTerminalId + readonly cols: number + readonly rows: number +} + +/** Every attachment begins with a complete bounded screen, then ordered output. */ +export type TerminalFrame = + | { readonly type: 'snapshot'; readonly sequence: number; readonly screen: string; readonly info: WebTerminalInfo } + | { readonly type: 'output'; readonly sequence: number; readonly data: string } + | { readonly type: 'state'; readonly info: WebTerminalInfo } diff --git a/packages/api/terminal-controller/tests/controller.spec.ts b/packages/api/terminal-controller/tests/controller.spec.ts new file mode 100644 index 0000000000..a42ac6c44e --- /dev/null +++ b/packages/api/terminal-controller/tests/controller.spec.ts @@ -0,0 +1,543 @@ +/** Session identity, allocation races, confinement and real PTY behavior. */ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { PassThrough } from 'node:stream' +import { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' +import { SubprocessExecutableNotFoundError, type SubprocessRuntime, type SubprocessTerminalEnvironment, type SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess' +import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TerminalController, type Config } from '../src/index.ts' +import { resolveShell } from '../src/shells.ts' +import type { TerminalAttachmentId, WebTerminalId } from '../src/types.ts' + +const roots: Context[] = [] +afterEach(async () => { await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) }) +const config: Config = { shellCandidates: ['zsh', 'bash', 'sh'], shell: { path: '/bin/bash', name: 'bash', args: ['--noprofile', '--norc', '-i'] }, maxTerminals: 2, maxCols: 200, maxRows: 100, scrollback: 100, maxBufferedBytes: 100_000, maxInputBytes: 1000, disposeGraceMs: 100 } +const id = 'test-terminal' as WebTerminalId +const request = { id, cols: 80, rows: 24 } +const signal = (): AbortSignal => new AbortController().signal + +function owner(ctx: Context, id = 'session'): Agent { + return { id: id as SessionId, ctx, session: { id: id as SessionId } } as unknown as Agent +} + +function fixture(overrides: Partial = {}) { + const ctx = new Context() + roots.push(ctx) + const effects = vi.spyOn(ctx.fiber, 'effect') + const sandboxPolicy = { defaultMode: 'danger-full-access', resolve: vi.fn((): SandboxExecutionPolicy => ({ mode: 'danger-full-access', workspaceRoot: '/workspace' })) } + const projections = { stateOf: vi.fn((): SandboxMode | null => null) } + ctx.provide('sandboxPolicy', sandboxPolicy as never) + ctx.provide('sessionProjections', projections as never) + const output = new PassThrough() + const done = Promise.withResolvers<{ exitCode: number; signal: null }>() + const handle = { + pid: 123, output, done: done.promise, write: vi.fn(async () => {}), resize: vi.fn(async () => {}), + inspectForeground: async () => undefined, signalForeground: async () => 123, + terminate: vi.fn(async () => { output.end(); done.resolve({ exitCode: 0, signal: null }) }) } + const checked: SubprocessTerminalHandle = handle + const subprocess = { terminalEnvironment: vi.fn(async (): Promise => ({ platform: 'posix', defaultShell: '/bin/bash' })), resolveExecutable: vi.fn(async (path: string) => path), spawnTerminal: vi.fn(async () => checked) } + ctx.provide('subprocess', subprocess as never) + const controller = new TerminalController(ctx, { ...config, ...overrides }) + const disposeEffect = (label: string): Promise => { + const index = effects.mock.calls.findIndex(call => call[1] === label) + const result = effects.mock.results[index] + if (result?.type !== 'return' || typeof result.value !== 'function') throw new Error(`Missing effect: ${label}`) + return result.value() + } + return { ctx, agent: owner(ctx), controller, subprocess, handle, sandboxPolicy, projections, disposeEffect } +} + +describe('TerminalController', () => { + it('resolves execution services from an Agent plugin context without consumer injections', async () => { + const { ctx, controller } = fixture() + let agent: Agent | undefined + await ctx.plugin((child) => { agent = owner(child) }) + if (agent === undefined) throw new Error('Agent plugin did not load') + expect(controller.environment(agent, signal())).toMatchObject({ cwd: '/workspace' }) + await controller.create(agent, request, signal()) + await controller.close(agent, id) + }) + + it('creates the environment default shell and keeps an existing identity when that default changes', async () => { + const { controller, agent, subprocess } = fixture({ shell: undefined }) + subprocess.terminalEnvironment.mockResolvedValue({ platform: 'posix', defaultShell: '/usr/local/bin/zsh' }) + expect(controller.environment(agent, signal())).toEqual({ cwd: '/workspace', maxCols: 200, maxRows: 100, maxInputBytes: 1000, scrollback: 100 }) + expect(subprocess.terminalEnvironment).not.toHaveBeenCalled() + const created = await controller.create(agent, request, signal()) + expect(created.shell.path).toBe('/usr/local/bin/zsh') + expect(subprocess.spawnTerminal).toHaveBeenCalledWith(expect.objectContaining({ argv: ['/usr/local/bin/zsh', '-i'] })) + subprocess.terminalEnvironment.mockResolvedValue({ platform: 'posix', defaultShell: '/bin/sh' }) + expect(await controller.create(agent, request, signal())).toBe(created) + expect(subprocess.terminalEnvironment).toHaveBeenCalledOnce() + expect(subprocess.spawnTerminal).toHaveBeenCalledOnce() + }) + + it('restores a running terminal after its default executable becomes unavailable', async () => { + const { controller, agent, subprocess } = fixture({ shell: undefined }) + await controller.create(agent, request, signal()) + subprocess.resolveExecutable.mockRejectedValue(new SubprocessExecutableNotFoundError('default shell was removed')) + expect(controller.environment(agent, signal())).toMatchObject({ cwd: '/workspace', maxInputBytes: 1000 }) + expect(controller.list(agent.id)).toMatchObject([{ id, state: 'running' }]) + const stream = controller.follow(agent, id, 'restored' as TerminalAttachmentId, signal())[Symbol.asyncIterator]() + try { + expect(await stream.next()).toMatchObject({ done: false, value: { type: 'snapshot', info: { id, state: 'running' } } }) + expect(subprocess.resolveExecutable).toHaveBeenCalledOnce() + await expect(controller.create(agent, { ...request, id: 'new-terminal' as WebTerminalId }, signal())).rejects.toThrow('default shell was removed') + expect(subprocess.spawnTerminal).toHaveBeenCalledOnce() + } finally { await stream.return?.() } + }) + + it('honors cancellation before reading environment limits', () => { + const { controller, agent, subprocess, sandboxPolicy } = fixture() + const abort = new AbortController() + abort.abort(new Error('request cancelled')) + expect(() => controller.environment(agent, abort.signal)).toThrow('request cancelled') + expect(subprocess.terminalEnvironment).not.toHaveBeenCalled() + expect(sandboxPolicy.resolve).not.toHaveBeenCalled() + }) + + it('deduplicates concurrent creates, scopes access by Session, and preserves terminals when a stream aborts', async () => { + const { controller, agent, ctx, subprocess, handle } = fixture() + const [first, second] = await Promise.all([controller.create(agent, request, signal()), controller.create(agent, request, signal())]) + expect(first.id).toBe(second.id) + expect(subprocess.spawnTerminal).toHaveBeenCalledOnce() + expect(subprocess.spawnTerminal).toHaveBeenCalledWith(expect.objectContaining({ terminalType: 'xterm-256color', cwd: '/workspace' })) + expect(controller.list('other' as SessionId)).toEqual([]) + expect(() => controller.follow(owner(ctx, 'other'), id, 'writer' as TerminalAttachmentId, signal())).toThrow('no longer exists') + const abort = new AbortController() + const stream = controller.follow(agent, id, 'writer' as TerminalAttachmentId, abort.signal)[Symbol.asyncIterator]() + const firstFrame = await stream.next() + if (firstFrame.done === true) throw new Error('Terminal stream ended before its snapshot') + expect(firstFrame.value.type).toBe('snapshot') + abort.abort() + await stream.return?.() + expect(handle.terminate).not.toHaveBeenCalled() + await controller.close(agent, id) + expect(handle.terminate).toHaveBeenCalledOnce() + expect(controller.list(agent.id)).toEqual([]) + await controller.close(agent, id) + }) + + it('waits for pending allocation when explicitly closing and rolls back cancellation before publication', async () => { + const { controller, agent, subprocess, handle } = fixture() + const allocation = Promise.withResolvers() + subprocess.spawnTerminal.mockImplementationOnce(() => allocation.promise) + const creating = controller.create(agent, request, signal()) + const rejected = expect(creating).rejects.toThrow('closed in this Session') + const closing = controller.close(agent, id) + allocation.resolve(handle) + await rejected + await closing + expect(controller.list(agent.id)).toEqual([]) + expect(handle.terminate).toHaveBeenCalledOnce() + }) + + it('rejects a delayed create after close even when that identity has never been allocated', async () => { + const { controller, agent, subprocess } = fixture() + await controller.close(agent, id) + await controller.close(agent, id) + await expect(controller.create(agent, request, signal())).rejects.toThrow('closed in this Session') + expect(subprocess.spawnTerminal).not.toHaveBeenCalled() + const nextId = 'next-terminal' as WebTerminalId + await expect(controller.create(agent, { ...request, id: nextId }, signal())).resolves.toMatchObject({ id: nextId }) + expect(subprocess.spawnTerminal).toHaveBeenCalledOnce() + }) + + it('rejects original, duplicate and later creates when close arrives during allocation', async () => { + const { controller, agent, subprocess, handle } = fixture() + const allocation = Promise.withResolvers() + const spawning = Promise.withResolvers() + subprocess.spawnTerminal.mockImplementationOnce(() => { spawning.resolve(undefined); return allocation.promise }) + const creating = controller.create(agent, request, signal()) + const duplicate = controller.create(agent, request, signal()) + const rejected = expect(creating).rejects.toThrow('closed in this Session') + const duplicateRejected = expect(duplicate).rejects.toThrow('closed in this Session') + try { + await spawning.promise + const closing = controller.close(agent, id) + await expect(controller.create(agent, request, signal())).rejects.toThrow('closed in this Session') + allocation.resolve(handle) + await Promise.all([rejected, duplicateRejected, closing]) + await expect(controller.create(agent, request, signal())).rejects.toThrow('closed in this Session') + expect(handle.terminate).toHaveBeenCalledOnce() + expect(subprocess.spawnTerminal).toHaveBeenCalledOnce() + expect(controller.list(agent.id)).toEqual([]) + } finally { allocation.resolve(handle) } + }) + + it('retains failed allocation cleanup for a later explicit close', async () => { + const { controller, agent, subprocess, handle } = fixture() + const abort = new AbortController() + subprocess.spawnTerminal.mockImplementationOnce(async () => { abort.abort(new Error('request disconnected')); return handle }) + vi.mocked(handle.terminate).mockRejectedValueOnce(new Error('cleanup failed')) + await expect(controller.create(agent, request, abort.signal)).rejects.toThrow('cleanup failed') + expect(controller.list(agent.id)).toMatchObject([{ id, state: 'failed' }]) + await controller.close(agent, id) + expect(handle.terminate).toHaveBeenCalledTimes(2) + expect(controller.list(agent.id)).toEqual([]) + }) + + it('retains a terminal when process cleanup fails so closing can be retried', async () => { + const { controller, agent, handle } = fixture() + await controller.create(agent, request, signal()) + vi.mocked(handle.terminate).mockRejectedValueOnce(new Error('process still alive')) + await expect(controller.close(agent, id)).rejects.toThrow('still alive') + expect(controller.list(agent.id)).toHaveLength(1) + await expect(controller.create(agent, request, signal())).rejects.toThrow('closed in this Session') + await controller.close(agent, id) + expect(controller.list(agent.id)).toHaveLength(0) + }) + + it('rejects invalid dimensions, unavailable shells and oversized input before executing them', async () => { + const { controller, agent, subprocess } = fixture() + await expect(controller.create(agent, { ...request, cols: 1 }, signal())).rejects.toThrow('dimensions') + subprocess.resolveExecutable.mockRejectedValueOnce(new SubprocessExecutableNotFoundError('shell unavailable')) + await expect(controller.create(agent, request, signal())).rejects.toThrow('unavailable') + expect(subprocess.spawnTerminal).not.toHaveBeenCalled() + await expect(controller.write(agent, id, 'writer' as TerminalAttachmentId, 'x'.repeat(1001))).rejects.toThrow('input') + }) + + it('keeps the committed identity on repeated creation and counts pending reservations toward the Session limit', async () => { + const { controller, agent, subprocess, handle } = fixture({ maxTerminals: 1 }) + const allocation = Promise.withResolvers() + const spawning = Promise.withResolvers() + subprocess.spawnTerminal.mockImplementationOnce(() => { spawning.resolve(undefined); return allocation.promise }) + const creating = controller.create(agent, request, signal()) + try { + await spawning.promise + await expect(controller.create(agent, { ...request, id: 'another' as WebTerminalId }, signal())).rejects.toMatchObject({ code: 'terminal/limit-reached', details: { limit: 1 } }) + } finally { allocation.resolve(handle) } + const first = await creating + expect(await controller.create(agent, request, signal())).toBe(first) + expect(subprocess.spawnTerminal).toHaveBeenCalledOnce() + }) + + it('keeps failed allocations reserved until their cleanup succeeds', async () => { + const { controller, agent, subprocess, handle } = fixture({ maxTerminals: 1 }) + const abort = new AbortController() + subprocess.spawnTerminal.mockImplementationOnce(async () => { abort.abort('lost request'); return handle }) + handle.terminate.mockRejectedValueOnce(new Error('still alive')) + await expect(controller.create(agent, request, abort.signal)).rejects.toThrow('cleanup failed') + expect(controller.list(agent.id)).toMatchObject([{ id, state: 'failed', error: 'lost request' }]) + await expect(controller.create(agent, request, signal())).rejects.toThrow('Close the failed terminal allocation') + await expect(controller.create(agent, { ...request, id: 'another' as WebTerminalId }, signal())).rejects.toMatchObject({ code: 'terminal/limit-reached', details: { limit: 1 } }) + await controller.close(agent, id) + expect(controller.list(agent.id)).toEqual([]) + }) + + it('lets close reclaim an allocation whose request failed while close was waiting', async () => { + const { controller, agent, subprocess, handle } = fixture() + const allocation = Promise.withResolvers() + const abort = new AbortController() + subprocess.spawnTerminal.mockImplementationOnce(() => allocation.promise) + const creating = controller.create(agent, request, abort.signal) + const rejected = expect(creating).rejects.toThrow('cancelled') + const closing = controller.close(agent, id) + abort.abort(new Error('cancelled')) + allocation.resolve(handle) + await rejected + await closing + expect(handle.terminate).toHaveBeenCalledOnce() + expect(controller.list(agent.id)).toEqual([]) + }) + + it('routes input, resize and trimmed names through the active attachment', async () => { + const { controller, agent, handle } = fixture({ maxInputBytes: 6 }) + await controller.create(agent, request, signal()) + const attachmentId = 'writer' as TerminalAttachmentId + const stream = controller.follow(agent, id, attachmentId, signal())[Symbol.asyncIterator]() + await stream.next() + try { + await controller.write(agent, id, attachmentId, '终端') + expect(handle.write).toHaveBeenCalledWith('终端') + await expect(controller.write(agent, id, attachmentId, '终端!')).rejects.toThrow('input exceeds') + await controller.resize(agent, id, attachmentId, 200, 100) + expect(handle.resize).toHaveBeenCalledWith(200, 100) + controller.rename(agent, id, ' server logs ') + expect(controller.list(agent.id)).toMatchObject([{ title: 'server logs', cols: 200, rows: 100 }]) + } finally { await stream.return?.() } + }) + + it.each([{ cols: 1.5, rows: 24 }, { cols: 201, rows: 24 }, { cols: 80, rows: 1.5 }, { cols: 80, rows: 0 }, { cols: 80, rows: 101 }])('rejects dimensions $cols × $rows before allocation or resize', async (dimensions) => { + const { controller, agent, subprocess, handle } = fixture() + await expect(controller.create(agent, { ...request, ...dimensions }, signal())).rejects.toThrow('dimensions') + await expect(controller.resize(agent, id, 'writer' as TerminalAttachmentId, dimensions.cols, dimensions.rows)).rejects.toThrow('dimensions') + expect(subprocess.spawnTerminal).not.toHaveBeenCalled() + expect(handle.resize).not.toHaveBeenCalled() + }) + + it('rejects invalid wire identities and names without changing retained terminals', async () => { + const { controller, agent } = fixture() + await expect(controller.create(agent, { ...request, id: '../terminal' as WebTerminalId }, signal())).rejects.toThrow('Invalid terminal identity') + expect(() => controller.follow(agent, id, '' as TerminalAttachmentId, signal())).toThrow('attachment identity') + expect(() => { controller.rename(agent, id, ' ') }).toThrow('1–120') + expect(() => { controller.rename(agent, id, 'x'.repeat(121)) }).toThrow('1–120') + await controller.close(agent, id) + expect(controller.list(agent.id)).toEqual([]) + }) + + it('uses the Session sandbox policy to confine its selected shell', async () => { + const { controller, agent, ctx, sandboxPolicy, subprocess } = fixture() + const policy: SandboxExecutionPolicy = { mode: 'workspace-write', workspaceRoot: '/workspace', sessionId: agent.id } + sandboxPolicy.resolve.mockReturnValue(policy) + const confine = vi.fn((argv: readonly string[]) => ({ argv: ['sandbox-runner', ...argv] })) + ctx.provide('sandbox', { confine } as never) + await controller.create(agent, request, signal()) + expect(confine).toHaveBeenCalledWith(['/bin/bash', '--noprofile', '--norc', '-i'], policy, expect.any(AbortSignal)) + expect(subprocess.spawnTerminal).toHaveBeenCalledWith(expect.objectContaining({ argv: ['sandbox-runner', '/bin/bash', '--noprofile', '--norc', '-i'], env: { DSH_SESSION_ID: agent.id }, graceMs: 100 })) + }) + + it('rejects a confined Session without a sandbox provider before spawning', async () => { + const { controller, agent, sandboxPolicy, subprocess } = fixture() + sandboxPolicy.resolve.mockReturnValue({ mode: 'read-only', workspaceRoot: '/workspace' }) + await expect(controller.create(agent, request, signal())).rejects.toThrow('requires an execution sandbox provider') + expect(subprocess.spawnTerminal).not.toHaveBeenCalled() + }) + + it.each(['subprocess', 'sandboxPolicy'] as const)('fails clearly when the Session lacks %s', (missing) => { + const { controller, subprocess, sandboxPolicy } = fixture() + const isolated = new Context() + roots.push(isolated) + if (missing !== 'subprocess') isolated.provide('subprocess', subprocess as never) + if (missing !== 'sandboxPolicy') isolated.provide('sandboxPolicy', sandboxPolicy as never) + expect(() => controller.environment(owner(isolated), signal())).toThrow('requires subprocess and sandbox policy providers') + }) + + it('blocks sandbox-mode changes only while that Session retains a terminal', async () => { + const { controller, agent, ctx, projections } = fixture() + const mode = (mode: SandboxMode): void => { ctx.emit('session/event', agent.session, { type: 'sandbox/mode', data: { mode } } as SessionEvent) } + ctx.emit('session/disposed', agent.session) + ctx.emit('session/event', agent.session, { type: 'turn/start', data: { turn: 1 } } as SessionEvent) + expect(() => { mode('workspace-write') }).not.toThrow() + await controller.create(agent, request, signal()) + expect(() => { mode('danger-full-access') }).not.toThrow() + expect(() => { mode('workspace-write') }).toThrow('Close browser terminals') + projections.stateOf.mockReturnValue('read-only') + expect(() => { mode('read-only') }).not.toThrow() + expect(() => { mode('danger-full-access') }).toThrow('Close browser terminals') + await controller.close(agent, id) + expect(() => { mode('workspace-write') }).not.toThrow() + }) + + it('terminates committed processes when the Session effect ends', async () => { + const { controller, agent, handle, disposeEffect } = fixture() + await controller.create(agent, request, signal()) + await disposeEffect('terminal-controller.owner') + expect(handle.terminate).toHaveBeenCalledOnce() + expect(controller.list(agent.id)).toEqual([]) + }) + + it('cleans up a Session when its real Agent plugin fiber is disposed', async () => { + const { controller, ctx, handle } = fixture() + let agent: Agent | undefined + const fiber = await ctx.plugin((child) => { agent = owner(child) }) + if (agent === undefined) throw new Error('Agent plugin did not load') + await controller.create(agent, request, signal()) + await fiber.dispose() + expect(handle.terminate).toHaveBeenCalledOnce() + expect(controller.list(agent.id)).toEqual([]) + }) + + it('waits for pending creation during Host disposal and rolls back its process before resolving', async () => { + const { controller, agent, subprocess, handle, disposeEffect } = fixture() + const allocation = Promise.withResolvers() + const spawning = Promise.withResolvers() + subprocess.spawnTerminal.mockImplementationOnce(() => { spawning.resolve(undefined); return allocation.promise }) + const creating = controller.create(agent, request, signal()) + const rejected = expect(creating).rejects.toThrow('disposed') + await spawning.promise + const disposing = disposeEffect('terminal-controller.processes') + allocation.resolve(handle) + await rejected + await disposing + expect(handle.terminate).toHaveBeenCalledOnce() + expect(controller.list(agent.id)).toEqual([]) + await expect(controller.create(agent, request, signal())).rejects.toThrow('controller disposed') + }) + + it('shares cleanup between the Session and Host effects while awaiting process termination', async () => { + const { controller, agent, handle, disposeEffect } = fixture() + await controller.create(agent, request, signal()) + const stopping = Promise.withResolvers() + const stopped = Promise.withResolvers() + const terminate = handle.terminate.getMockImplementation() + if (terminate === undefined) throw new Error('Missing process cleanup fixture') + handle.terminate.mockImplementationOnce(async () => { stopping.resolve(undefined); await stopped.promise; await terminate() }) + const ownerDisposal = disposeEffect('terminal-controller.owner') + try { + await stopping.promise + const hostDisposal = disposeEffect('terminal-controller.processes') + stopped.resolve(undefined) + await Promise.all([ownerDisposal, hostDisposal]) + expect(handle.terminate).toHaveBeenCalledOnce() + expect(controller.list(agent.id)).toEqual([]) + } finally { stopped.resolve(undefined) } + }) + + it('retains cleanup failures from both Session and Host disposal for explicit retry', async () => { + const { controller, agent, handle, disposeEffect } = fixture() + await controller.create(agent, request, signal()) + handle.terminate.mockRejectedValueOnce(new Error('still alive')) + await expect(disposeEffect('terminal-controller.owner')).rejects.toThrow('Session terminal cleanup failed') + await expect(controller.create(agent, request, signal())).rejects.toThrow('Session owner disposed') + handle.terminate.mockRejectedValueOnce(new Error('still alive again')) + await expect(disposeEffect('terminal-controller.processes')).rejects.toThrow('Browser terminal cleanup failed') + expect(controller.list(agent.id)).toHaveLength(1) + await controller.close(agent, id) + expect(controller.list(agent.id)).toEqual([]) + expect(handle.terminate).toHaveBeenCalledTimes(3) + }) + + it('reclaims retained failed allocations when the owning Session ends', async () => { + const { controller, agent, subprocess, handle, disposeEffect } = fixture() + const abort = new AbortController() + subprocess.spawnTerminal.mockImplementationOnce(async () => { abort.abort(new Error('disconnected')); return handle }) + handle.terminate.mockRejectedValueOnce(new Error('first cleanup failed')) + await expect(controller.create(agent, request, abort.signal)).rejects.toThrow('cleanup failed') + await disposeEffect('terminal-controller.owner') + expect(handle.terminate).toHaveBeenCalledTimes(2) + expect(controller.list(agent.id)).toEqual([]) + }) +}) + +describe('shell resolution', () => { + it('accepts an omitted shell profile and defaults configured arguments to an empty list', () => { + // Loader input is unvalidated; the schema's declared type describes its normalized output. + const parse = (input: unknown): Config => TerminalController.Config(input as Config) + expect(parse({}).shell).toBeUndefined() + expect(parse({ shell: { path: 'custom', name: 'Project shell' } }).shell).toEqual({ path: 'custom', name: 'Project shell', args: [] }) + expect(() => parse({ shell: { name: 'Project shell' } })).toThrow() + }) + + it.each([ + { platform: 'posix' as const, path: '/bin/sh', name: 'sh', args: ['-i'] }, + { platform: 'windows' as const, path: 'cmd.exe', name: 'cmd.exe', args: [] }, + ])('uses the conservative $platform fallback only when the provider omits its default shell', async ({ platform, path, name, args }) => { + const { subprocess } = fixture() + subprocess.terminalEnvironment.mockResolvedValue({ platform }) + const runtime = subprocess as unknown as SubprocessRuntime + const requestSignal = signal() + expect(await resolveShell(runtime, undefined, requestSignal)).toEqual({ path, name, args }) + expect(subprocess.resolveExecutable).toHaveBeenCalledExactlyOnceWith(path, undefined, requestSignal) + }) + + it.each([ + { platform: 'posix' as const, defaultShell: '/usr/local/bin/fish', name: 'fish', args: ['-i'] }, + { platform: 'windows' as const, defaultShell: 'C:\\Windows\\CMD.EXE', name: 'CMD.EXE', args: [] }, + { platform: 'windows' as const, defaultShell: 'C:\\PowerShell\\powershell.exe', name: 'powershell.exe', args: ['-NoLogo'] }, + { platform: 'posix' as const, defaultShell: '/usr/local/bin/pwsh', name: 'pwsh', args: ['-NoLogo'] }, + ])('resolves only the declared default $defaultShell with its interactive arguments', async ({ platform, defaultShell, name, args }) => { + const { subprocess } = fixture() + subprocess.terminalEnvironment.mockResolvedValue({ platform, defaultShell }) + subprocess.resolveExecutable.mockResolvedValue('/resolved/shell') + const runtime = subprocess as unknown as SubprocessRuntime + const requestSignal = signal() + expect(await resolveShell(runtime, undefined, requestSignal)).toEqual({ path: '/resolved/shell', name, args }) + expect(subprocess.terminalEnvironment).toHaveBeenCalledExactlyOnceWith(requestSignal) + expect(subprocess.resolveExecutable).toHaveBeenCalledExactlyOnceWith(defaultShell, undefined, requestSignal) + }) + + it('uses an explicit profile without querying the environment and preserves its name and arguments', async () => { + const { subprocess } = fixture() + subprocess.resolveExecutable.mockResolvedValue('/resolved/custom') + const runtime = subprocess as unknown as SubprocessRuntime + const requestSignal = signal() + const configured = { path: 'custom', name: 'Project shell', args: ['--project'] } + expect(await resolveShell(runtime, configured, requestSignal)).toEqual({ ...configured, path: '/resolved/custom' }) + expect(subprocess.terminalEnvironment).not.toHaveBeenCalled() + expect(subprocess.resolveExecutable).toHaveBeenCalledExactlyOnceWith('custom', undefined, requestSignal) + }) + + it.each([new SubprocessExecutableNotFoundError('missing default'), new Error('transport unavailable')])('reports default resolution failure without attempting fallback: %s', async (failure) => { + const { subprocess } = fixture() + subprocess.resolveExecutable.mockRejectedValue(failure) + const runtime = subprocess as unknown as SubprocessRuntime + await expect(resolveShell(runtime, undefined, signal())).rejects.toBe(failure) + expect(subprocess.resolveExecutable).toHaveBeenCalledOnce() + }) + + it('reports an unavailable configured shell without consulting the provider default', async () => { + const { subprocess } = fixture() + const failure = new SubprocessExecutableNotFoundError('missing configured shell') + subprocess.resolveExecutable.mockRejectedValue(failure) + const runtime = subprocess as unknown as SubprocessRuntime + await expect(resolveShell(runtime, { path: '/missing', name: 'missing', args: [] }, signal())).rejects.toBe(failure) + expect(subprocess.resolveExecutable).toHaveBeenCalledOnce() + expect(subprocess.terminalEnvironment).not.toHaveBeenCalled() + }) + + it('propagates environment cancellation before verifying an executable', async () => { + const { subprocess } = fixture() + const cancelled = new Error('request disconnected') + subprocess.terminalEnvironment.mockRejectedValue(cancelled) + const runtime = subprocess as unknown as SubprocessRuntime + await expect(resolveShell(runtime, undefined, signal())).rejects.toBe(cancelled) + expect(subprocess.resolveExecutable).not.toHaveBeenCalled() + }) +}) + +it.skipIf(process.platform === 'win32')('runs a real interactive shell with completion, TERM and live window dimensions', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'dsh-web-terminal-')) + const ctx = new Context() + const runtime = await ctx.plugin(LocalSubprocessRuntime) + try { + const handle = await ctx.subprocess.spawnTerminal({ argv: ['/bin/bash', '--noprofile', '--norc', '-i'], cwd, rows: 24, cols: 80, terminalType: 'xterm-256color', graceMs: 100, env: { PS1: 'READY> ', PS2: '' } }) + let output = '' + handle.output.on('data', (data: Buffer) => { output += data.toString('utf8') }) + try { + await expect.poll(() => output).toContain('READY>') + await handle.resize(100, 30) + await handle.write("printf 'TERM:%s\\n' \"$TERM\"; stty size\r") + await expect.poll(() => output).toContain('TERM:xterm-256color') + await expect.poll(() => output).toContain('30 100') + // Bash's builtin completion expands this unambiguous command before Enter. + await handle.write('histor\t') + await expect.poll(() => output).toContain('history') + } finally { await handle.terminate() } + await expect(handle.done).resolves.toBeDefined() + } finally { + await runtime.dispose() + await ctx.fiber.dispose() + await rm(cwd, { recursive: true, force: true }) + } +}) + +it('discovers installed shells once per path, preserves default arguments, and refuses unlisted paths', async () => { + const h = fixture({ shellCandidates: ['bash', 'zsh', 'missing'] }) + h.subprocess.resolveExecutable.mockImplementation(async (path) => { + if (path === 'missing') throw new SubprocessExecutableNotFoundError('absent') + return path.startsWith('/') ? path : `/bin/${path}` + }) + const shells = await h.controller.shells(h.agent, signal()) + expect(shells).toEqual([config.shell, { path: '/bin/zsh', name: 'zsh', args: ['-i'] }]) + expect(h.subprocess.spawnTerminal).not.toHaveBeenCalled() + await expect(h.controller.create(h.agent, { ...request, shellPath: '/bin/unlisted' }, signal())).rejects.toThrow('Selected shell is not available') + expect(h.subprocess.spawnTerminal).not.toHaveBeenCalled() + const created = await h.controller.create(h.agent, { ...request, shellPath: '/bin/zsh' }, signal()) + expect(created.shell.path).toBe('/bin/zsh') + expect(h.subprocess.spawnTerminal).toHaveBeenCalledWith(expect.objectContaining({ argv: ['/bin/zsh', '-i'] })) + h.subprocess.resolveExecutable.mockRejectedValue(new Error('SSH disconnected')) + await expect(h.controller.shells(h.agent, signal())).rejects.toThrow('SSH disconnected') + expect(await h.controller.create(h.agent, { ...request, shellPath: '/bin/bash' }, signal())).toBe(created) +}) + +it('propagates optional-shell discovery transport errors and cancellation', async () => { + const h = fixture({ shellCandidates: ['fish'] }) + h.subprocess.resolveExecutable.mockImplementation(async (path) => { + if (path === 'fish') throw new Error('lookup transport failed') + return path + }) + await expect(h.controller.shells(h.agent, signal())).rejects.toThrow('lookup transport failed') + const cancelled = AbortSignal.abort(new Error('cancelled discovery')) + expect(() => h.controller.shells(h.agent, cancelled)).toThrow('cancelled discovery') +}) + +it('deduplicates Windows executable paths regardless of letter case', async () => { + const h = fixture({ shell: { path: 'C:\\Windows\\cmd.exe', name: 'Command Prompt', args: [] }, shellCandidates: ['c:\\windows\\cmd.exe'] }) + expect(await h.controller.shells(h.agent, signal())).toEqual([{ path: 'C:\\Windows\\cmd.exe', name: 'Command Prompt', args: [] }]) +}) diff --git a/packages/api/terminal-controller/tests/model.client.spec.ts b/packages/api/terminal-controller/tests/model.client.spec.ts new file mode 100644 index 0000000000..ac1a7243f9 --- /dev/null +++ b/packages/api/terminal-controller/tests/model.client.spec.ts @@ -0,0 +1,567 @@ +/** Browser view ownership across slow RPCs, remounts and transport generations. */ +import { setImmediate } from 'node:timers/promises' +import { afterEach, expect, it, vi } from 'vitest' +import { RemoteStream, RemoteStreamCarrierError, type ClientRemote, type RemoteStreamOptions } from '@deepseek-ai/dsh-api-gateway/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { RemoteError, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type {} from '@deepseek-ai/dsh-api-terminal-controller/remote' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { TerminalView, type TerminalRemote } from '../src/client/model.ts' +import type { TerminalEnvironment, TerminalFrame, WebTerminalId, WebTerminalInfo } from '../src/types.ts' + +const sessionId = 'session' as SessionId +const info: WebTerminalInfo = { id: 'terminal' as WebTerminalId, shell: { name: 'bash', path: '/bin/bash', args: ['-i'] }, title: 'bash', cwd: '/workspace', rows: 24, cols: 80, state: 'running', exitCode: null } +const environment: TerminalEnvironment = { + cwd: info.cwd, maxInputBytes: 1000, maxCols: 200, maxRows: 100, scrollback: 100, +} +const success = (value: T): RemoteResult => ({ ok: true, value }) +const cleanups: (() => void | Promise)[] = [] +afterEach(async () => { for (const close of cleanups.splice(0).reverse()) await close() }) + +function untilAborted(signal?: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal?.aborted) resolve() + else signal?.addEventListener('abort', () => { resolve() }, { once: true }) + }) +} + +function failure(message: string): RemoteResult { + return { ok: false, error: new RemoteError('gateway/bad-request', message, {}) } +} + +function fixture(prepareStream?: (stream: RemoteStream) => void) { + const remote: TerminalRemote = { + shells: vi.fn(async () => success([info.shell])), + environment: vi.fn(async () => success(environment)), list: vi.fn(async () => success([])), + create: vi.fn(async (_sessionId, request) => success({ ...info, id: request.id })), + close: vi.fn(async () => success(undefined)), rename: vi.fn(async () => success(undefined)), + write: vi.fn(async () => success(undefined)), resize: vi.fn(async () => success(undefined)), + follow: vi.fn(async function* (_sessionId, id, attachmentId, signal) { + yield { type: 'snapshot', sequence: 0, screen: 'ready', info: { ...info, id, controllerId: attachmentId } } + await untilAborted(signal) + }), + } + const options: RemoteStreamOptions[] = [] + const streams: { dispose(): Promise; restart(): void }[] = [] + const generation = createSnapshotStore[0]['generation']['getSnapshot']>>(undefined) + const gateway: Pick = { + $stream: (option) => { + options.push(option) + const stream = new RemoteStream({ generation }, option) + streams.push(stream) + prepareStream?.(stream) + return stream + }, + } + cleanups.push(async () => { await Promise.all(streams.map(stream => stream.dispose())) }) + const model = new TerminalView(sessionId, remote, gateway, info.id) + cleanups.push(() => model.dispose()) + return { model, remote, gateway, options, streams, generation } +} + +async function mount(model: TerminalView) { + const detach = model.mount() + await model.refresh() + await model.start() + return detach +} + +function acknowledge(model: TerminalView): void { + const render = model.state.getSnapshot().render + if (render !== undefined) model.acknowledge(render.revision) +} + +async function connected(model: TerminalView): Promise { + await mount(model) + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + acknowledge(model) +} + +it('does not let a late input failure downgrade a newer connection', async () => { + const { model, remote } = fixture() + await mount(model) + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + acknowledge(model) + const input = Promise.withResolvers>() + vi.mocked(remote.write).mockReturnValueOnce(input.promise) + model.write('x') + await expect.poll(() => vi.mocked(remote.write).mock.calls.length).toBe(1) + model.connect() + await expect.poll(() => vi.mocked(remote.follow).mock.calls.length).toBe(2) + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + model.write('current input') + input.reject(new Error('old attachment was replaced')) + await expect.poll(() => vi.mocked(remote.write).mock.calls.some(call => call[3] === 'current input')).toBe(true) + expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', writable: true, error: undefined }) +}) + +it('waits for render acknowledgement before publishing subsequent output', async () => { + const { model, remote } = fixture() + vi.mocked(remote.follow).mockImplementation(async function* (_sessionId, id, controllerId, signal) { + const frames: TerminalFrame[] = [ + { type: 'snapshot', sequence: 0, screen: '', info: { ...info, id, controllerId } }, + { type: 'output', sequence: 1, data: 'first' }, + { type: 'output', sequence: 2, data: 'second' }, + ] + for (const frame of frames) yield frame + await untilAborted(signal) + }) + await mount(model) + await expect.poll(() => model.state.getSnapshot().render?.frame.type).toBe('snapshot') + acknowledge(model) + await expect.poll(() => model.state.getSnapshot().render?.frame).toMatchObject({ type: 'output', sequence: 1 }) + acknowledge(model) + await expect.poll(() => model.state.getSnapshot().render?.frame).toMatchObject({ type: 'output', sequence: 2 }) +}) + +it('serializes input and resizes, clamps geometry, and suppresses unchanged sizes', async () => { + const { model, remote } = fixture() + await connected(model) + const input = Promise.withResolvers>() + vi.mocked(remote.write).mockReturnValueOnce(input.promise) + model.resize(80, 24) + model.write('first') + model.write('second') + model.resize(500, 300) + await expect.poll(() => vi.mocked(remote.write).mock.calls.length).toBe(1) + expect(remote.resize).not.toHaveBeenCalled() + input.resolve(success(undefined)) + await expect.poll(() => vi.mocked(remote.resize).mock.calls.length).toBe(1) + const attachmentId = vi.mocked(remote.follow).mock.calls[0]![2] + const id = model.state.getSnapshot().info!.id + expect(vi.mocked(remote.write).mock.calls.map(call => call[3])).toEqual(['first', 'second']) + expect(remote.resize).toHaveBeenCalledWith(sessionId, id, attachmentId, 200, 100) + model.resize(90, 24) + await expect.poll(() => vi.mocked(remote.resize).mock.calls.length).toBe(2) +}) + +it('bounds queued input by UTF-8 bytes and releases the byte budget after settlement', async () => { + const { model, remote } = fixture() + vi.mocked(remote.environment).mockResolvedValue(success({ ...environment, maxInputBytes: 6 })) + await connected(model) + const input = Promise.withResolvers>() + vi.mocked(remote.write).mockReturnValueOnce(input.promise) + model.write('界界') + await expect.poll(() => vi.mocked(remote.write).mock.calls.length).toBe(1) + model.write('a') + expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', issue: 'inputFull' }) + input.resolve(success(undefined)) + model.connect() + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + model.write('界界') + await expect.poll(() => vi.mocked(remote.write).mock.calls.length).toBe(2) + expect(vi.mocked(remote.write).mock.calls.map(call => call[3])).toEqual(['界界', '界界']) +}) + +it('drops queued commands from a detached attachment and ignores its late resize failure', async () => { + const { model, remote } = fixture() + await connected(model) + const input = Promise.withResolvers>() + vi.mocked(remote.resize).mockReturnValueOnce(input.promise) + model.resize(100, 30) + await expect.poll(() => vi.mocked(remote.resize).mock.calls.length).toBe(1) + model.write('stale') + model.resize(110, 35) + model.connect() + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + model.write('current') + input.resolve(failure('old attachment')) + await expect.poll(() => vi.mocked(remote.write).mock.calls.length).toBe(1) + expect(vi.mocked(remote.write).mock.calls[0]?.[3]).toBe('current') + expect(remote.resize).toHaveBeenCalledOnce() + expect(model.state.getSnapshot().phase).toBe('connected') +}) + +it('publishes current input and resize failures and can reconnect for another attempt', async () => { + const { model, remote } = fixture() + await connected(model) + vi.mocked(remote.write).mockResolvedValueOnce(failure('input refused')) + model.write('x') + await expect.poll(() => model.state.getSnapshot().error).toBe('input refused') + model.connect() + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + vi.mocked(remote.resize).mockResolvedValueOnce(failure('resize refused')) + model.resize(100, 30) + await expect.poll(() => model.state.getSnapshot().error).toBe('resize refused') +}) + +it('treats carrier loss as disconnected only for the active attachment', async () => { + const { model, options } = fixture() + await connected(model) + const first = options[0]! + first.carrierFailed?.(new RemoteStreamCarrierError('offline')) + expect(model.state.getSnapshot()).toMatchObject({ phase: 'disconnected', writable: false }) + model.connect() + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + first.carrierFailed?.(new RemoteStreamCarrierError('late old failure')) + expect(model.state.getSnapshot().phase).toBe('connected') +}) + +it.each([ + ['output before snapshot', [{ type: 'output', sequence: 1, data: 'orphan' }], 'missing its screen snapshot'], + ['sequence gap', [{ type: 'snapshot', sequence: 4, screen: '', info }, { type: 'output', sequence: 6, data: 'gap' }], 'sequence has a gap'], + ['repeated snapshot', [{ type: 'snapshot', sequence: 4, screen: '', info }, { type: 'snapshot', sequence: 4, screen: '', info }], 'Unexpected terminal screen snapshot'], +] satisfies readonly (readonly [string, readonly TerminalFrame[], string])[])('refuses a malformed output stream: %s', async (_name, frames, message) => { + const { model, remote } = fixture() + vi.mocked(remote.follow).mockImplementation(async function* () { yield* frames }) + await mount(model) + if (frames[0]?.type === 'snapshot') { + await expect.poll(() => model.state.getSnapshot().render).toBeDefined() + acknowledge(model) + } + await expect.poll(() => model.state.getSnapshot().error).toContain(message) + expect(model.state.getSnapshot().writable).toBe(false) +}) + +it('retains an exited screen and closes controls when its stream ends', async () => { + const { model, remote } = fixture() + vi.mocked(remote.follow).mockImplementation(async function* (_sessionId, id, controllerId) { + yield { type: 'snapshot', sequence: 0, screen: 'last screen', info: { ...info, id, controllerId } } + yield { type: 'state', info: { ...info, id, controllerId, state: 'exited', exitCode: 3 } } + }) + await connected(model) + await expect.poll(() => model.state.getSnapshot().phase).toBe('closed') + expect(model.state.getSnapshot()).toMatchObject({ writable: false, info: { state: 'exited', exitCode: 3 }, render: { frame: { screen: 'last screen' } } }) +}) + +it('reports an attachment ending while the shell is still running and permits manual reconnect', async () => { + const { model, remote } = fixture() + vi.mocked(remote.follow).mockImplementationOnce(async function* (_sessionId, id, controllerId) { + yield { type: 'snapshot', sequence: 0, screen: '', info: { ...info, id, controllerId } } + }) + await connected(model) + await expect.poll(() => model.state.getSnapshot().issue).toBe('attachmentEnded') + model.connect() + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) +}) + +it('publishes remote control transfer without replacing the retained screen', async () => { + const { model, remote } = fixture() + vi.mocked(remote.follow).mockImplementation(async function* (_sessionId, id, controllerId, signal) { + yield { type: 'snapshot', sequence: 0, screen: 'retained', info: { ...info, id, controllerId } } + yield { type: 'state', info: { ...info, id } } + await untilAborted(signal) + }) + await connected(model) + await expect.poll(() => model.state.getSnapshot().writable).toBe(false) + expect(model.state.getSnapshot().render?.frame).toMatchObject({ type: 'snapshot', screen: 'retained' }) +}) + +/** Pause delivery after the real Gateway iterator settles, before the model observes it. */ +function deliveryBarrier() { + const ready = Promise.withResolvers() + const release = Promise.withResolvers() + let held = false + function prepare(stream: RemoteStream): void { + if (held) return + held = true + const iterator = stream[Symbol.asyncIterator]() + let first = true + vi.spyOn(stream, Symbol.asyncIterator).mockReturnValue({ + async next() { + if (!first) return iterator.next() + first = false + try { + return await iterator.next() + } finally { + ready.resolve(undefined) + await release.promise + } + }, + async return() { return iterator.return!() }, + }) + } + cleanups.push(() => { release.resolve(undefined) }) + return { ready: ready.promise, release: () => { release.resolve(undefined) }, prepare } +} + +it('ignores an already delivered screen when its attachment is replaced before the model receives it', async () => { + const barrier = deliveryBarrier() + const { model, remote } = fixture(barrier.prepare) + await mount(model) + await barrier.ready + model.connect() + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + const current = model.state.getSnapshot() + barrier.release() + await model.rename('settled') + expect(model.state.getSnapshot().render).toBe(current.render) + expect(remote.follow).toHaveBeenCalledTimes(2) +}) + +it('ignores an already settled stream error after a newer attachment has become writable', async () => { + const barrier = deliveryBarrier() + const { model, remote } = fixture(barrier.prepare) + vi.mocked(remote.follow).mockImplementationOnce(async function* () { + yield* [] + throw new Error('retired attachment failed') + }) + await mount(model) + await barrier.ready + model.connect() + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + barrier.release() + await model.rename('settled') + expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', writable: true, error: undefined }) +}) + +it('releases a pending screen render on carrier generation cancellation and accepts the replacement screen', async () => { + const { model, remote, streams } = fixture() + await mount(model) + await expect.poll(() => model.state.getSnapshot().render).toBeDefined() + const revision = model.state.getSnapshot().render!.revision + streams[0]!.restart() + await expect.poll(() => model.state.getSnapshot().render?.revision).toBe(revision + 1) + expect(remote.follow).toHaveBeenCalledTimes(2) + expect(model.state.getSnapshot().writable).toBe(true) +}) + +it('does not wait for the DOM callback of a screen whose generation was already cancelled at delivery', async () => { + const barrier = deliveryBarrier() + const { model, remote, streams } = fixture(barrier.prepare) + await mount(model) + await barrier.ready + streams[0]!.restart() + barrier.release() + await expect.poll(() => model.state.getSnapshot().render?.revision).toBe(2) + expect(remote.follow).toHaveBeenCalledTimes(2) + expect(model.state.getSnapshot().writable).toBe(true) +}) + +it.each(['write', 'resize'] as const)('keeps a fresh attachment writable when a pending %s fails after automatic transport recovery', async (operation) => { + const { model, remote, generation } = fixture() + generation.set({ id: 1, host: { home: '/home/fixture' } }) + const disconnected = Promise.withResolvers() + vi.mocked(remote.follow).mockImplementationOnce(async function* (_sessionId, id, controllerId, signal) { + yield { type: 'snapshot', sequence: 0, screen: 'before disconnect', info: { ...info, id, controllerId } } + await Promise.race([disconnected.promise, untilAborted(signal)]) + if (signal?.aborted) return + throw new RemoteStreamCarrierError('connection lost') + }) + await connected(model) + const pending = Promise.withResolvers>() + if (operation === 'write') { + vi.mocked(remote.write).mockReturnValueOnce(pending.promise) + model.write('old input') + } else { + vi.mocked(remote.resize).mockReturnValueOnce(pending.promise) + model.resize(100, 30) + } + await expect.poll(() => vi.mocked(remote[operation]).mock.calls.length).toBe(1) + const oldAttachmentId = vi.mocked(remote.follow).mock.calls[0]![2] + generation.set(undefined) + disconnected.resolve(undefined) + await expect.poll(() => model.state.getSnapshot().phase).toBe('disconnected') + expect(model.state.getSnapshot().writable).toBe(false) + expect(remote.follow).toHaveBeenCalledOnce() + + generation.set({ id: 2, host: { home: '/home/fixture' } }) + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + expect(remote.follow).toHaveBeenCalledTimes(2) + const attachmentId = vi.mocked(remote.follow).mock.calls[1]![2] + expect(attachmentId).not.toBe(oldAttachmentId) + expect(model.state.getSnapshot().info?.controllerId).toBe(attachmentId) + expect(model.state.getSnapshot().render?.frame).toMatchObject({ type: 'snapshot', screen: 'ready' }) + + model.write('fresh input') + pending.reject(new Error('old attachment was replaced')) + await expect.poll(() => vi.mocked(remote.write).mock.calls.some(call => call[3] === 'fresh input')).toBe(true) + expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', writable: true, error: undefined }) + expect(vi.mocked(remote.write).mock.calls.at(-1)?.[2]).toBe(attachmentId) + expect(remote.create).toHaveBeenCalledOnce() + expect(remote.close).not.toHaveBeenCalled() +}) + +it('ignores controls before discovery and reconnects an existing process after remount', async () => { + const { model, remote } = fixture() + model.connect() + model.write('early') + model.resize(100, 30) + model.acknowledge(100) + expect(remote.follow).not.toHaveBeenCalled() + expect(remote.write).not.toHaveBeenCalled() + expect(remote.resize).not.toHaveBeenCalled() + const unmount = await mount(model) + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + unmount() + model.connect() + model.write('detached') + model.resize(100, 30) + expect(remote.follow).toHaveBeenCalledOnce() + expect(remote.write).not.toHaveBeenCalled() + expect(remote.resize).not.toHaveBeenCalled() + model.mount() + await expect.poll(() => remote.follow).toHaveBeenCalledTimes(2) + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + expect(remote.create).toHaveBeenCalledOnce() +}) + +it('keeps controls inert when process metadata or the attachment is unavailable', async () => { + const { model, remote } = fixture() + await connected(model) + const state = model.state.getSnapshot() + model.state.set({ ...state, info: undefined }) + model.write('missing metadata') + model.resize(100, 30) + expect(remote.write).not.toHaveBeenCalled() + expect(remote.resize).not.toHaveBeenCalled() + model.state.set({ ...state, environment: undefined }) + model.resize(100, 30) + await expect.poll(() => remote.resize).toHaveBeenCalledOnce() + expect(vi.mocked(remote.resize).mock.calls[0]?.slice(-2)).toEqual([100, 30]) + model.write('unknown budget') + expect(model.state.getSnapshot().issue).toBe('inputFull') + expect(remote.write).not.toHaveBeenCalled() +}) + +it('renames an existing terminal, skips unchanged names, and exposes rename failures', async () => { + const { model, remote } = fixture() + await connected(model) + await model.rename(` ${info.title} `) + expect(remote.rename).not.toHaveBeenCalled() + await model.rename(' Build ') + expect(model.state.getSnapshot()).toMatchObject({ title: 'Build', info: { title: 'Build' } }) + vi.mocked(remote.rename).mockRejectedValueOnce('rename connection lost') + await model.rename('Other') + expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', error: 'rename connection lost', title: 'Build' }) + await model.dispose() + await model.rename('Ignored') + expect(remote.rename).toHaveBeenCalledTimes(2) +}) + +it('classifies a discovery carrier failure as disconnected and supports explicit retry', async () => { + const { model, remote } = fixture() + vi.mocked(remote.environment).mockRejectedValueOnce(new RemoteStreamCarrierError('offline')) + await model.refresh() + expect(model.state.getSnapshot()).toMatchObject({ phase: 'disconnected', error: 'offline' }) + expect(remote.create).not.toHaveBeenCalled() + await model.refresh() + await model.start() + expect(model.state.getSnapshot().info?.id).toBe(info.id) + expect(remote.create).toHaveBeenCalledOnce() +}) + +it('retains state when disposal overtakes successful allocation or a failed discovery', async () => { + for (const operation of ['creation', 'discovery'] as const) { + const { model, remote } = fixture() + const creation = Promise.withResolvers>() + const discovery = Promise.withResolvers>() + if (operation === 'creation') vi.mocked(remote.create).mockReturnValueOnce(creation.promise) + else vi.mocked(remote.environment).mockReturnValueOnce(discovery.promise) + const loading = model.refresh() + const starting = operation === 'creation' ? loading.then(() => model.start()) : undefined + if (operation === 'creation') await expect.poll(() => remote.create).toHaveBeenCalledOnce() + await model.dispose() + const before = model.state.getSnapshot() + if (operation === 'creation') creation.resolve(success(info)) + else discovery.reject(new Error('late discovery failure')) + await loading + await starting + expect(model.state.getSnapshot()).toBe(before) + expect(remote.follow).not.toHaveBeenCalled() + } +}) + +it('ignores a successful rename after the view is disposed', async () => { + const { model, remote } = fixture() + await connected(model) + const rename = Promise.withResolvers>() + vi.mocked(remote.rename).mockReturnValueOnce(rename.promise) + const pending = model.rename('Late') + await model.dispose() + const before = model.state.getSnapshot() + rename.resolve(success(undefined)) + await pending + expect(model.state.getSnapshot()).toBe(before) +}) + +it('allows retry after failed process cleanup and never reconnects while close is pending', async () => { + const { model, remote } = fixture() + await connected(model) + const closing = Promise.withResolvers>() + vi.mocked(remote.close).mockReturnValueOnce(closing.promise) + const first = model.close() + expect(model.close()).toBe(first) + model.connect() + expect(remote.follow).toHaveBeenCalledOnce() + closing.resolve(failure('close refused')) + await expect(first).rejects.toThrow('close refused') + expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', error: 'close refused' }) + await model.close() + expect(model.state.getSnapshot()).toMatchObject({ phase: 'closed', writable: false }) + expect(remote.close).toHaveBeenCalledTimes(2) +}) + +it('does not publish a close result or reconnect after disposal', async () => { + const { model, remote } = fixture() + await connected(model) + const closing = Promise.withResolvers>() + vi.mocked(remote.close).mockReturnValueOnce(closing.promise) + const pending = model.close() + await model.dispose() + const before = model.state.getSnapshot() + closing.resolve(success(undefined)) + await pending + model.mount() + model.connect() + expect(model.state.getSnapshot()).toBe(before) + expect(remote.follow).toHaveBeenCalledOnce() +}) + +it.each(['write', 'resize'] as const)('keeps the output connection when %s loses control before its state frame arrives', async (operation) => { + const { model, remote } = fixture() + const transfer = Promise.withResolvers() + const response = Promise.withResolvers>() + cleanups.push(() => { transfer.resolve(undefined); response.resolve(success(undefined)) }) + vi.mocked(remote.follow).mockImplementation(async function* (_session, id, controllerId, signal) { + yield { type: 'snapshot', sequence: 0, screen: 'retained screen', info: { ...info, id, controllerId } } + await transfer.promise + yield { type: 'state', info: { ...info, id } } + await untilAborted(signal) + }) + await connected(model) + vi.mocked(remote[operation]).mockReturnValueOnce(response.promise) + if (operation === 'write') model.write('before transfer') + else model.resize(100, 30) + model.write('queued before transfer') + await expect.poll(() => vi.mocked(remote[operation]).mock.calls.length).toBe(1) + response.resolve({ ok: false, error: new RemoteError('terminal/control-unavailable', 'Another window owns input', { reason: 'read-only' }) }) + await expect.poll(() => model.state.getSnapshot().writable).toBe(false) + expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', error: undefined, issue: undefined, render: { frame: { screen: 'retained screen' } } }) + transfer.resolve(undefined) + await expect.poll(() => model.state.getSnapshot().info?.controllerId).toBeUndefined() + expect(remote.follow).toHaveBeenCalledOnce() + expect(vi.mocked(remote.write).mock.calls.some(call => call[3] === 'queued before transfer')).toBe(false) +}) + +it('keeps an exited screen when a pending input is refused after the exit state arrives', async () => { + const { model, remote } = fixture() + const exit = Promise.withResolvers() + const response = Promise.withResolvers>() + cleanups.push(() => { exit.resolve(undefined); response.resolve(success(undefined)) }) + vi.mocked(remote.follow).mockImplementation(async function* (_session, id, controllerId, signal) { + yield { type: 'snapshot', sequence: 0, screen: 'final screen', info: { ...info, id, controllerId } } + await exit.promise + yield { type: 'state', info: { ...info, id, state: 'exited', exitCode: 0 } } + await untilAborted(signal) + }) + await connected(model) + vi.mocked(remote.write).mockReturnValueOnce(response.promise) + model.write('exit race') + await expect.poll(() => vi.mocked(remote.write).mock.calls.length).toBe(1) + exit.resolve(undefined) + await expect.poll(() => model.state.getSnapshot().info?.state).toBe('exited') + response.resolve({ ok: false, error: new RemoteError('terminal/control-unavailable', 'Terminal is not running', { reason: 'not-running' }) }) + await setImmediate() + expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', writable: false, error: undefined, issue: undefined, render: { frame: { screen: 'final screen' } } }) +}) + +it('exposes a localized quota error and clears it after a successful retry', async () => { + const { model, remote } = fixture() + vi.mocked(remote.create).mockResolvedValueOnce({ ok: false, error: new RemoteError('terminal/limit-reached', 'Session terminal limit reached', { limit: 8 }) }) + await model.refresh() + await model.start() + expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', issue: 'terminalLimit' }) + await connected(model) + expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', issue: undefined, error: undefined }) +}) diff --git a/packages/api/terminal-controller/tests/recovery.client.spec.ts b/packages/api/terminal-controller/tests/recovery.client.spec.ts new file mode 100644 index 0000000000..3839e2024a --- /dev/null +++ b/packages/api/terminal-controller/tests/recovery.client.spec.ts @@ -0,0 +1,515 @@ +/** Reloaded tab identities and nonblocking close requests use independent lifetimes. */ +import { setImmediate } from 'node:timers/promises' +import { afterEach, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { RemoteError, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteStream, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' +import type {} from '@deepseek-ai/dsh-api-terminal-controller/remote' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WebTerminalId, WebTerminalInfo, TerminalEnvironment } from '../src/types.ts' +import { TerminalView, type TerminalRemote } from '../src/client/model.ts' +import { TerminalCloseRequests } from '../src/client/close-requests.ts' +import * as TerminalClient from '../src/client/index.ts' + +const sessionId = 'session' as SessionId +const info: WebTerminalInfo = { id: 'terminal' as WebTerminalId, shell: { name: 'zsh', path: '/bin/zsh', args: ['-i'] }, title: 'zsh', cwd: '/workspace', rows: 24, cols: 80, state: 'running', exitCode: null } +const environment: TerminalEnvironment = { cwd: info.cwd, maxInputBytes: 1000, maxCols: 200, maxRows: 100, scrollback: 100 } +const success = (value: T): RemoteResult => ({ ok: true, value }) +const failure = (message: string): RemoteResult => ({ ok: false, error: new RemoteError('gateway/bad-request', message, {}) }) +const cleanups: (() => void | Promise)[] = [] +afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks() }) + +function storage() { + const data = new Map() + vi.stubGlobal('localStorage', { get length() { return data.size }, key: (index: number) => [...data.keys()][index] ?? null, getItem: (key: string) => data.get(key) ?? null, setItem: (key: string, value: string) => { data.set(key, value) }, removeItem: (key: string) => { data.delete(key) } }) + return data +} + +function fixture() { + const remote: TerminalRemote = { + shells: vi.fn(async () => success([info.shell])), + environment: vi.fn(async () => success(environment)), list: vi.fn(async () => success([])), + create: vi.fn(async (_session, request) => success({ ...info, id: request.id })), + close: vi.fn(async () => success(undefined)), rename: vi.fn(async () => success(undefined)), + write: vi.fn(async () => success(undefined)), resize: vi.fn(async () => success(undefined)), + follow: vi.fn(async function* (_session, id, controllerId, signal) { + yield { type: 'snapshot', sequence: 0, screen: 'retained', info: { ...info, id, controllerId } } + await new Promise((resolve) => { + if (signal?.aborted) resolve() + else signal?.addEventListener('abort', () => { resolve() }, { once: true }) + }) + }), + } + const gateway: Pick = { $stream: options => new RemoteStream({ generation: createSnapshotStore(undefined) }, options) } + function view() { + const model = new TerminalView(sessionId, remote, gateway, info.id) + cleanups.push(() => model.dispose()) + return model + } + async function service() { + const ctx = new Context() + ctx.provide('remote', { ...gateway, terminal: remote } as never) + ctx.provide('remote.terminal', remote) + const fiber = ctx.plugin(TerminalClient) + cleanups.push(async () => { await ctx.fiber.dispose() }) + await fiber + return { service: ctx.webTerminals, dispose: () => fiber.dispose() } + } + return { remote, view, service } +} + +it('waits for shell selection and deduplicates overlapping starts', async () => { + const h = fixture() + const model = h.view() + const creation = Promise.withResolvers>() + vi.mocked(h.remote.create).mockReturnValueOnce(creation.promise) + model.mount() + const loading = model.refresh() + expect(model.refresh()).toBe(loading) + await loading + expect(h.remote.create).not.toHaveBeenCalled() + const starting = model.start() + const remounting = model.refresh() + void model.start() + expect(vi.mocked(h.remote.create).mock.calls[0]?.[1]).toEqual({ id: info.id, shellPath: info.shell.path, cols: 80, rows: 24 }) + creation.resolve(success(info)) + await starting + await remounting + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + expect(h.remote.create).toHaveBeenCalledOnce() +}) + +it('recovers a listed process without creating another, including its title and screen', async () => { + const h = fixture() + vi.mocked(h.remote.list).mockResolvedValue(success([{ ...info, title: 'Build' }])) + const model = h.view() + model.mount() + await model.refresh() + expect(h.remote.create).not.toHaveBeenCalled() + await expect.poll(() => model.state.getSnapshot().render?.frame).toMatchObject({ type: 'snapshot', screen: 'retained' }) + expect(h.remote.follow).toHaveBeenCalledWith(sessionId, info.id, expect.any(String), expect.any(AbortSignal)) +}) + +it('does not recreate a recovered terminal that disappeared after the recovery list was shown', async () => { + const h = fixture() + const { service } = await h.service() + vi.mocked(h.remote.list).mockResolvedValueOnce(success([info])) + expect(await service.recover(sessionId)).toEqual([info]) + const model = service.view(sessionId, 'recovered', info.id) + await model.refresh() + expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', writable: false }) + expect(model.state.getSnapshot().issue).toBe('missingTerminal') + expect(h.remote.create).not.toHaveBeenCalled() + model.mount() + await model.refresh() + expect(h.remote.create).not.toHaveBeenCalled() +}) + +it('retries lost create acknowledgements with the saved id and closes after a refused allocation', async () => { + const h = fixture() + const model = h.view() + vi.mocked(h.remote.create).mockResolvedValueOnce(failure('response lost')) + await model.refresh() + await model.start() + expect(model.state.getSnapshot().error).toBe('response lost') + await model.refresh() + await model.start() + expect(vi.mocked(h.remote.create).mock.calls.map(call => call[1].id)).toEqual([info.id, info.id]) + await model.close() + expect(h.remote.close).toHaveBeenCalledWith(sessionId, info.id) +}) + +it('waits for an in-flight creation while close detaches immediately and prevents a late connection', async () => { + const h = fixture() + const model = h.view() + const creation = Promise.withResolvers>() + vi.mocked(h.remote.create).mockReturnValueOnce(creation.promise) + const unmount = model.mount() + await model.refresh() + const starting = model.start() + await expect.poll(() => vi.mocked(h.remote.create).mock.calls.length).toBe(1) + const closing = model.close() + expect(model.close()).toBe(closing) + unmount() + expect(h.remote.close).not.toHaveBeenCalled() + creation.resolve(failure('cancelled allocation')) + await starting + await closing + expect(h.remote.close).toHaveBeenCalledWith(sessionId, info.id) + expect(h.remote.follow).not.toHaveBeenCalled() +}) + +it('does not allocate after close or disposal overtakes discovery', async () => { + for (const action of ['close', 'dispose'] as const) { + const h = fixture() + const model = h.view() + const discovery = Promise.withResolvers>() + vi.mocked(h.remote.environment).mockReturnValueOnce(discovery.promise) + const loading = model.refresh() + await model[action]() + discovery.resolve(success(environment)) + await loading + await model.refresh() + expect(h.remote.create).not.toHaveBeenCalled() + } +}) + +it('updates a restored inactive terminal title and ignores late results after disposal', async () => { + const h = fixture() + const model = h.view() + await model.rename(' Build ') + expect(h.remote.rename).toHaveBeenCalledWith(sessionId, info.id, ' Build ') + expect(model.state.getSnapshot().title).toBe('Build') + const rename = Promise.withResolvers>() + vi.mocked(h.remote.rename).mockReturnValueOnce(rename.promise) + const pending = model.rename('Late') + await model.dispose() + rename.resolve(success(undefined)) + await pending + expect(model.state.getSnapshot().title).toBe('Build') +}) + +it('removes a view synchronously, keeps cleanup retryable, and never deletes a replacement view', async () => { + storage() + const h = fixture() + const { service } = await h.service() + const first = service.view(sessionId, 'tab') + first.mount() + await first.refresh() + const closing = Promise.withResolvers>() + vi.mocked(h.remote.close).mockReturnValueOnce(closing.promise) + service.close(sessionId, 'tab') + const replacement = service.view(sessionId, 'tab') + expect(replacement).not.toBe(first) + expect(service.closeFailures.getSnapshot()).toEqual([]) + closing.resolve(failure('termination refused')) + await expect.poll(() => service.closeFailures.getSnapshot().length).toBe(1) + const failed = service.closeFailures.getSnapshot()[0]! + service.retryClose(failed.id) + service.retryClose(failed.id) + await expect.poll(() => service.closeFailures.getSnapshot()).toEqual([]) + expect(service.view(sessionId, 'tab')).toBe(replacement) + await expect.poll(() => new TerminalCloseRequests().pending()).toEqual([]) +}) + +it('closes an inactive restored tab by tab identity and retries saved close requests after reload', async () => { + storage() + const h = fixture() + const first = await h.service() + first.service.close(sessionId, 'inactive', 'inactive' as WebTerminalId) + await expect.poll(() => vi.mocked(h.remote.close).mock.calls.length).toBe(1) + expect(h.remote.close).toHaveBeenCalledWith(sessionId, 'inactive') + await first.dispose() + const pending = new TerminalCloseRequests() + pending.save({ sessionId, id: 'second' as WebTerminalId, title: 'Second' }) + await h.service() + await expect.poll(() => vi.mocked(h.remote.close).mock.calls.length).toBe(2) + expect(h.remote.close).toHaveBeenCalledWith(sessionId, 'second') + await expect.poll(() => new TerminalCloseRequests().pending()).toEqual([]) +}) + +it('keeps concurrent windows close requests independent and removes only the settled request', () => { + storage() + const a = new TerminalCloseRequests() + const b = new TerminalCloseRequests() + a.save({ sessionId, id: 'a' as WebTerminalId, title: 'A' }) + b.save({ sessionId, id: 'b' as WebTerminalId, title: 'B' }) + expect(new TerminalCloseRequests().pending().map(request => request.id)).toEqual(['a', 'b']) + a.remove('a' as WebTerminalId) + expect(new TerminalCloseRequests().pending().map(request => request.id)).toEqual(['b']) +}) + +it('recovers Host terminals by Session while excluding held, pending-close, and already-closed identities', async () => { + storage() + const h = fixture() + const { service } = await h.service() + const held = service.view(sessionId, 'held') + await held.refresh() + const closing = service.view(sessionId, 'closing') + await closing.refresh() + const done = service.view(sessionId, 'closed') + await done.refresh() + const unheld: WebTerminalInfo = { ...info, id: 'unheld' as WebTerminalId } + const pending = Promise.withResolvers>() + vi.mocked(h.remote.close).mockReturnValueOnce(pending.promise) + service.close(sessionId, 'closing') + service.close(sessionId, 'closed') + await expect.poll(() => new TerminalCloseRequests().pending().length).toBe(1) + vi.mocked(h.remote.list).mockResolvedValue(success([ + { ...info, id: held.id }, { ...info, id: closing.id }, { ...info, id: done.id }, unheld, + ])) + expect(await service.recover(sessionId)).toEqual([unheld]) + expect(h.remote.list).toHaveBeenLastCalledWith(sessionId) + const otherSession = 'other-session' as SessionId + vi.mocked(h.remote.list).mockResolvedValueOnce(success([info])) + expect(await service.recover(otherSession)).toEqual([info]) + pending.resolve(success(undefined)) + await expect.poll(() => new TerminalCloseRequests().pending()).toEqual([]) + expect(await service.recover(sessionId)).toEqual([unheld]) +}) + +it('queries current held views after a slow recovery response and reports discovery errors', async () => { + const h = fixture() + const { service } = await h.service() + const listed = Promise.withResolvers>() + vi.mocked(h.remote.list).mockReturnValueOnce(listed.promise) + const recovering = service.recover(sessionId) + vi.mocked(h.remote.list).mockResolvedValueOnce(success([info])) + const model = service.view(sessionId, 'recovered', info.id) + await model.refresh() + listed.resolve(success([info])) + expect(await recovering).toEqual([]) + expect(service.view(sessionId, 'recovered', info.id)).toBe(model) + expect(model.id).toBe(info.id) + vi.mocked(h.remote.list).mockResolvedValueOnce(failure('Session unavailable')) + await expect(service.recover(sessionId)).rejects.toThrow('Session unavailable') +}) + +it('ignores an unknown tab and retries only saved close requests', async () => { + storage() + const h = fixture() + const { service } = await h.service() + service.close(sessionId, 'unknown') + service.retryClose('unknown' as WebTerminalId) + expect(h.remote.close).not.toHaveBeenCalled() + expect(new TerminalCloseRequests().pending()).toEqual([]) +}) + +it('retains an inactive close failure with its tab title until retry succeeds', async () => { + const data = storage() + const h = fixture() + const { service } = await h.service() + vi.mocked(h.remote.close).mockResolvedValueOnce(failure('Host refused cleanup')) + service.close(sessionId, 'Build', info.id) + expect(data.has(`dsh.terminal.close.v1.${info.id}`)).toBe(true) + await expect.poll(() => service.closeFailures.getSnapshot()).toEqual([{ id: info.id, title: 'Build', message: 'Host refused cleanup' }]) + vi.mocked(h.remote.list).mockResolvedValueOnce(success([info])) + expect(await service.recover(sessionId)).toEqual([]) + const pending = Promise.withResolvers>() + vi.mocked(h.remote.close).mockReturnValueOnce(pending.promise) + service.retryClose(info.id) + service.retryClose(info.id) + expect(service.closeFailures.getSnapshot()).toEqual([]) + expect(h.remote.close).toHaveBeenCalledTimes(2) + pending.resolve(success(undefined)) + await expect.poll(() => data.size).toBe(0) +}) + +it('preserves a close failure from a non-Error rejection', async () => { + storage() + const h = fixture() + const { service } = await h.service() + vi.mocked(h.remote.close).mockRejectedValueOnce('carrier closed') + service.close(sessionId, 'Build', info.id) + await expect.poll(() => service.closeFailures.getSnapshot()).toEqual([{ id: info.id, title: 'Build', message: 'carrier closed' }]) +}) + +it('waits for pending cleanup during service disposal without publishing a late failure', async () => { + storage() + const h = fixture() + const { service, dispose } = await h.service() + const pending = Promise.withResolvers>() + vi.mocked(h.remote.close).mockReturnValueOnce(pending.promise) + service.close(sessionId, 'Build', info.id) + let finished = false + const disposing = dispose().then(() => { finished = true }) + expect(finished).toBe(false) + pending.resolve(failure('transport is stopping')) + await disposing + expect(service.closeFailures.getSnapshot()).toEqual([]) + expect(new TerminalCloseRequests().pending()).toEqual([{ sessionId, id: info.id, title: 'Build' }]) + service.retryClose(info.id) + expect(h.remote.close).toHaveBeenCalledOnce() +}) + +it('saves close intents without persisting any active terminal or sidebar state', async () => { + const data = storage() + const h = fixture() + const { service } = await h.service() + const model = service.view(sessionId, 'new-tab') + await model.refresh() + expect(model.id).toMatch(/^[0-9a-f-]{36}$/) + expect(data.size).toBe(0) + const pending = Promise.withResolvers>() + vi.mocked(h.remote.close).mockReturnValueOnce(pending.promise) + service.close(sessionId, 'new-tab') + const request = { sessionId, id: model.id, title: 'new-tab' } + expect([...data.entries()]).toEqual([[`dsh.terminal.close.v1.${model.id}`, JSON.stringify(request)]]) + pending.resolve(success(undefined)) + await expect.poll(() => data.size).toBe(0) +}) + +it.each(['{broken', 'null', '{}', '[{}]', '{"sessionId":"s","id":"bad/id","title":"x"}', '{"sessionId":"s","id":"different","title":"x"}'])('discards malformed saved cleanup: %s', (raw) => { + const data = storage() + data.set('dsh.terminal.close.v1.terminal', raw) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + expect(new TerminalCloseRequests().pending()).toEqual([]) + expect(error).toHaveBeenCalledOnce() +}) + +it('skips unrelated storage and cleanup keys removed during enumeration', () => { + const getItem = vi.fn(() => null) + vi.stubGlobal('localStorage', { + length: 3, + key: (index: number) => ['unrelated', 'dsh.terminal.close.v1.gone', null][index], + getItem, + }) + expect(new TerminalCloseRequests().pending()).toEqual([]) + expect(getItem).toHaveBeenCalledExactlyOnceWith('dsh.terminal.close.v1.gone') +}) + +it('keeps close requests usable without browser storage', () => { + vi.stubGlobal('localStorage', undefined) + const requests = new TerminalCloseRequests() + requests.save({ sessionId, id: info.id, title: 'Build' }) + expect(requests.pending()).toEqual([{ sessionId, id: info.id, title: 'Build' }]) + requests.remove(info.id) + expect(requests.pending()).toEqual([]) +}) + +it('keeps cleanup usable in memory when storage access itself is denied', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.stubGlobal('localStorage', undefined) + Object.defineProperty(globalThis, 'localStorage', { configurable: true, get() { throw new Error('storage denied') } }) + const requests = new TerminalCloseRequests() + requests.save({ sessionId, id: info.id, title: 'Build' }) + expect(requests.pending()).toHaveLength(1) + requests.remove(info.id) + expect(requests.pending()).toEqual([]) + expect(error).toHaveBeenCalledTimes(3) +}) + +it.each(['saved', 'view'] as const)('clears a %s close request after the Host confirms that its Session does not exist', async (source) => { + const data = storage() + const h = fixture() + const requests = new TerminalCloseRequests() + if (source === 'saved') requests.save({ sessionId, id: info.id, title: 'Build' }) + vi.mocked(h.remote.close).mockResolvedValue({ ok: false, error: new RemoteError('session/not-found', 'Deleted Session', { sessionId }) }) + const { service, dispose } = await h.service() + if (source === 'view') { + const view = service.view(sessionId, 'tab') + await view.refresh() + service.close(sessionId, 'tab') + } + await expect.poll(() => vi.mocked(h.remote.close).mock.calls.length).toBe(1) + await dispose() + expect(data.size).toBe(0) + expect(service.closeFailures.getSnapshot()).toEqual([]) + expect(new TerminalCloseRequests().pending()).toEqual([]) + await h.service() + expect(h.remote.close).toHaveBeenCalledOnce() +}) + +it('waits for both active and detached stream finalizers during plugin disposal without closing Host processes', async () => { + const h = fixture() + const { service, dispose } = await h.service() + const started = [Promise.withResolvers(), Promise.withResolvers()] + const release = [Promise.withResolvers(), Promise.withResolvers()] + const finished = [Promise.withResolvers(), Promise.withResolvers()] + cleanups.push(() => { for (const barrier of release) barrier.resolve(undefined) }) + let index = 0 + vi.mocked(h.remote.follow).mockImplementation(async function* (_session, id, controllerId, signal) { + const current = index++ + try { + yield { type: 'snapshot', sequence: 0, screen: 'screen', info: { ...info, id, controllerId } } + await new Promise((resolve) => { + if (signal?.aborted) resolve() + else signal?.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } finally { + started[current]!.resolve(undefined) + await release[current]!.promise + finished[current]!.resolve(undefined) + } + }) + const model = service.view(sessionId, 'tab') + model.mount() + await model.refresh() + await model.start() + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + model.connect() + await started[0]!.promise + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + let disposed = false + const disposing = dispose().then(() => { disposed = true }) + await started[1]!.promise + release[1]!.resolve(undefined) + await finished[1]!.promise + // Drain runnable disposal continuations; only the held old finalizer may keep teardown pending. + await setImmediate() + expect(disposed).toBe(false) + release[0]!.resolve(undefined) + await disposing + expect(disposed).toBe(true) + expect(h.remote.close).not.toHaveBeenCalled() +}) + +it('remembers a shell choice immediately for new views and falls back when that shell is absent', async () => { + const data = storage() + const h = fixture() + const alternate = { name: 'bash', path: '/bin/bash', args: ['-i'] } + vi.mocked(h.remote.shells).mockResolvedValue(success([info.shell, alternate])) + vi.mocked(h.remote.create).mockImplementation(async (_sessionId, request) => success({ ...info, shell: alternate, id: request.id })) + const first = h.view() + await first.start() + first.selectShell(alternate.path) + await first.refresh() + expect(first.state.getSnapshot()).toMatchObject({ phase: 'selecting', selectedShell: info.shell.path }) + first.selectShell('/not-listed') + expect(first.state.getSnapshot().selectedShell).toBe(info.shell.path) + first.selectShell(alternate.path) + expect(data.get('dsh.terminal.shell')).toBe(alternate.path) + const beforeLaunch = h.view() + await beforeLaunch.refresh() + expect(beforeLaunch.state.getSnapshot().selectedShell).toBe(alternate.path) + expect(h.remote.create).not.toHaveBeenCalled() + await first.start() + expect(data.get('dsh.terminal.shell')).toBe(alternate.path) + expect(h.remote.create).toHaveBeenCalledWith(sessionId, expect.objectContaining({ shellPath: alternate.path }), expect.any(AbortSignal)) + const second = h.view() + await second.refresh() + expect(second.state.getSnapshot().selectedShell).toBe(alternate.path) + await second.dispose() + await second.start() + vi.mocked(h.remote.shells).mockResolvedValue(success([info.shell])) + const third = h.view() + await third.refresh() + expect(third.state.getSnapshot().selectedShell).toBe(info.shell.path) + expect(h.remote.create).toHaveBeenCalledOnce() +}) + +it('retains the last selection across a failed launch', async () => { + const data = storage() + const h = fixture() + vi.mocked(h.remote.create).mockResolvedValueOnce(failure('shell disappeared')) + const model = h.view() + await model.refresh() + await model.start() + expect(data.get('dsh.terminal.shell')).toBe(info.shell.path) + await model.refresh() + expect(model.state.getSnapshot().selectedShell).toBe(info.shell.path) + await model.close() + await model.start() + expect(h.remote.create).toHaveBeenCalledOnce() +}) + +it('keeps launch usable when browser storage is denied and stops late shell discovery after close', async () => { + vi.stubGlobal('localStorage', undefined) + Object.defineProperty(globalThis, 'localStorage', { configurable: true, get() { throw new Error('denied') } }) + const h = fixture() + const model = h.view() + await model.refresh() + await model.start() + expect(model.state.getSnapshot().info).toBeDefined() + const delayed = h.view() + const shells = Promise.withResolvers>>() + vi.mocked(h.remote.shells).mockReturnValueOnce(shells.promise) + const loading = delayed.refresh() + await expect.poll(() => h.remote.shells).toHaveBeenCalledTimes(2) + await delayed.close() + shells.resolve(success([info.shell])) + await loading + expect(delayed.state.getSnapshot().phase).toBe('closed') + expect(h.remote.create).toHaveBeenCalledOnce() +}) diff --git a/packages/api/terminal-controller/tests/stream.spec.ts b/packages/api/terminal-controller/tests/stream.spec.ts new file mode 100644 index 0000000000..a5fd9357ff --- /dev/null +++ b/packages/api/terminal-controller/tests/stream.spec.ts @@ -0,0 +1,80 @@ +/** Output followers drain on process exit and discard queued data on detach. */ +import { describe, expect, it } from 'vitest' +import { TerminalFollower } from '../src/stream.ts' +import type { TerminalFrame } from '../src/types.ts' + +const frame: TerminalFrame = { type: 'output', sequence: 1, data: '终端' } +const signal = (): AbortSignal => new AbortController().signal + +describe('TerminalFollower', () => { + it('accepts the exact byte limit and restores capacity after each frame is consumed', async () => { + const follower = new TerminalFollower(Buffer.byteLength(JSON.stringify(frame), 'utf8')) + const stream = follower.read(signal())[Symbol.asyncIterator]() + try { + follower.push(frame) + expect(await stream.next()).toEqual({ done: false, value: frame }) + follower.push(frame) + follower.finish() + follower.push({ ...frame, sequence: 2 }) + expect(await stream.next()).toEqual({ done: false, value: frame }) + expect(await stream.next()).toEqual({ done: true, value: undefined }) + } finally { follower.close(); await stream.return?.() } + }) + + it('fails accumulated overflow using encoded byte size', async () => { + const follower = new TerminalFollower(Buffer.byteLength(JSON.stringify(frame), 'utf8')) + follower.push(frame) + follower.push(frame) + await expect(follower.read(signal())[Symbol.asyncIterator]().next()).rejects.toThrow('reconnect to recover') + }) + + it('discards queued data when detached and ignores later output', async () => { + const follower = new TerminalFollower(1000) + follower.push(frame) + follower.close() + follower.push(frame) + expect(await follower.read(signal())[Symbol.asyncIterator]().next()).toEqual({ done: true, value: undefined }) + }) + + it('does not deliver buffered output to an already-aborted request', async () => { + const follower = new TerminalFollower(1000) + follower.push(frame) + const abort = new AbortController() + abort.abort() + expect(await follower.read(abort.signal)[Symbol.asyncIterator]().next()).toEqual({ done: true, value: undefined }) + }) + + it('wakes a waiting reader for output and then for graceful completion', async () => { + const follower = new TerminalFollower(1000) + const stream = follower.read(signal())[Symbol.asyncIterator]() + try { + const reading = stream.next() + follower.push(frame) + expect(await reading).toEqual({ done: false, value: frame }) + const finishing = stream.next() + follower.finish() + expect(await finishing).toEqual({ done: true, value: undefined }) + } finally { follower.close(); await stream.return?.() } + }) + + it('wakes a waiting reader on cancellation', async () => { + const follower = new TerminalFollower(1000) + const abort = new AbortController() + const stream = follower.read(abort.signal)[Symbol.asyncIterator]() + try { + const reading = stream.next() + abort.abort() + expect(await reading).toEqual({ done: true, value: undefined }) + } finally { follower.close(); await stream.return?.() } + }) + + it('closes the follower when its consumer returns before EOF', async () => { + const follower = new TerminalFollower(1000) + const stream = follower.read(signal())[Symbol.asyncIterator]() + follower.push(frame) + await stream.next() + await stream.return?.() + follower.push(frame) + expect(await follower.read(signal())[Symbol.asyncIterator]().next()).toEqual({ done: true, value: undefined }) + }) +}) diff --git a/packages/api/terminal-controller/tests/terminal.spec.ts b/packages/api/terminal-controller/tests/terminal.spec.ts new file mode 100644 index 0000000000..bf6bd16318 --- /dev/null +++ b/packages/api/terminal-controller/tests/terminal.spec.ts @@ -0,0 +1,228 @@ +/** Terminal screen continuity and process ownership under real output scheduling. */ +import { PassThrough } from 'node:stream' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SubprocessOutcome, SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess' +import { BrowserTerminal } from '../src/terminal.ts' +import { TerminalFollower } from '../src/stream.ts' +import type { TerminalAttachmentId, TerminalFrame, WebTerminalId, WebTerminalInfo } from '../src/types.ts' + +const info: WebTerminalInfo = { id: 'terminal-test' as WebTerminalId, title: 'bash', shell: { path: '/bin/bash', name: 'bash', args: ['-i'] }, cwd: '/workspace', cols: 80, rows: 24, state: 'running', exitCode: null } +const attachment = (id: string): TerminalAttachmentId => id as TerminalAttachmentId +const cleanups: (() => Promise)[] = [] +afterEach(async () => { await Promise.all(cleanups.splice(0).map(close => close())) }) + +function fixture() { + const output = new PassThrough() + const outcome = Promise.withResolvers() + const handle = { + pid: 123, output, done: outcome.promise, write: vi.fn(async () => {}), resize: vi.fn(async () => {}), + inspectForeground: async () => undefined, signalForeground: async () => 123, + terminate: vi.fn(async () => { output.end(); outcome.resolve({ exitCode: 0, signal: null }) }), + } + const checked: SubprocessTerminalHandle = handle + const terminal = new BrowserTerminal(checked, info, 100, 100_000) + cleanups.push(() => terminal.close()) + return { terminal, output, outcome, handle } +} + +async function attach(terminal: BrowserTerminal, id = 'first') { + const controller = new AbortController() + const iterator = terminal.follow(attachment(id), controller.signal)[Symbol.asyncIterator]() + const baseline = await readFrame(iterator) + cleanups.push(async () => { controller.abort(); await iterator.return?.() }) + return { controller, iterator, baseline } +} + +describe('BrowserTerminal', () => { + it('restores the screen after detach without re-executing the shell or replaying duplicate output', async () => { + const { terminal, output, handle } = fixture() + const first = await attach(terminal) + expect(first.baseline).toMatchObject({ type: 'snapshot', sequence: 0 }) + output.write(Buffer.from('hello\r\n')) + expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', sequence: 1, data: 'hello\r\n' }) + first.controller.abort() + await first.iterator.return?.() + expect(handle.terminate).not.toHaveBeenCalled() + output.write(Buffer.from('world')) + await expect.poll(() => terminal.info.state).toBe('running') + const second = await attach(terminal, 'second') + // The baseline and its output queue share the same ordered screen write queue. + const frames = [second.baseline] + if (second.baseline.type === 'snapshot' && !second.baseline.screen.includes('world')) frames.push(await readFrame(second.iterator)) + expect(JSON.stringify(frames)).toContain('world') + expect(JSON.stringify(frames)).toContain('hello') + expect(handle.terminate).not.toHaveBeenCalled() + }) + + it('preserves split UTF-8, gives the newest attachment input, and resizes both PTY and recovery screen', async () => { + const { terminal, output, handle } = fixture() + const first = await attach(terminal) + const encoded = Buffer.from('终端') + output.write(encoded.subarray(0, 2)) + await expect.poll(() => output.readableLength).toBe(0) + output.write(encoded.subarray(2)) + expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: '终端' }) + const second = await attach(terminal, 'second') + await expect(terminal.write(attachment('first'), 'ignored')).rejects.toMatchObject({ code: 'terminal/control-unavailable', details: { reason: 'read-only' } }) + await terminal.write(attachment('second'), '\t') + expect(handle.write).toHaveBeenCalledWith('\t') + await terminal.resize(attachment('second'), 100, 30) + expect(handle.resize).toHaveBeenCalledWith(100, 30) + expect(terminal.info).toMatchObject({ cols: 100, rows: 30 }) + second.controller.abort() + await second.iterator.return?.() + expect(terminal.info.controllerId).toBeUndefined() + }) + + it('keeps exit facts and screen until explicit cleanup and never starts a replacement process', async () => { + const { terminal, output, outcome, handle } = fixture() + const first = await attach(terminal) + output.end('done') + outcome.resolve({ exitCode: 7, signal: null }) + expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: 'done' }) + expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'exited', exitCode: 7 } }) + expect(handle.terminate).not.toHaveBeenCalled() + const second = await attach(terminal, 'second') + expect(second.baseline).toMatchObject({ type: 'snapshot', info: { state: 'exited', exitCode: 7 } }) + await terminal.close() + expect(handle.terminate).toHaveBeenCalledOnce() + }) + + it('drains final output and exit state before closing followers', async () => { + const { terminal, handle, output, outcome } = fixture() + const first = await attach(terminal) + vi.mocked(handle.terminate).mockImplementationOnce(async () => { + output.end('FINAL OUTPUT\r\n') + outcome.resolve({ exitCode: 0, signal: null }) + }) + await terminal.close() + expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: 'FINAL OUTPUT\r\n' }) + expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'exited' } }) + expect((await first.iterator.next()).done).toBe(true) + }) + + it('rejects input before attachment, during close and after process exit', async () => { + const { terminal, handle, output, outcome } = fixture() + await expect(terminal.write(attachment('first'), 'ignored')).rejects.toMatchObject({ code: 'terminal/control-unavailable', details: { reason: 'read-only' } }) + const first = await attach(terminal) + output.end() + outcome.resolve({ exitCode: 0, signal: null }) + expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'exited' } }) + await expect(terminal.write(attachment('first'), 'ignored')).rejects.toMatchObject({ code: 'terminal/control-unavailable', details: { reason: 'not-running' } }) + await expect(terminal.resize(attachment('first'), 100, 30)).rejects.toMatchObject({ code: 'terminal/control-unavailable', details: { reason: 'not-running' } }) + const closing = terminal.close() + await expect(terminal.write(attachment('first'), 'ignored')).rejects.toMatchObject({ code: 'terminal/control-unavailable', details: { reason: 'not-running' } }) + await closing + expect(handle.write).not.toHaveBeenCalled() + expect(handle.resize).not.toHaveBeenCalled() + }) + + it('preserves the input controller when an older follower detaches', async () => { + const { terminal, handle } = fixture() + const first = await attach(terminal) + const second = await attach(terminal, 'second') + first.controller.abort() + await first.iterator.return?.() + expect(terminal.info.controllerId).toBe(attachment('second')) + terminal.rename('build output') + expect(await readFrame(second.iterator)).toMatchObject({ type: 'state', info: { title: 'build output', controllerId: 'second' } }) + await terminal.write(attachment('second'), 'pwd\r') + expect(handle.write).toHaveBeenCalledWith('pwd\r') + }) + + it('does not grant input to attachments cancelled before their snapshot is ready', async () => { + const { terminal, handle } = fixture() + const aborted = new AbortController() + aborted.abort(new Error('already detached')) + await expect(terminal.follow(attachment('cancelled'), aborted.signal)[Symbol.asyncIterator]().next()).rejects.toThrow('already detached') + await attach(terminal) + const writing = Promise.withResolvers() + const written = Promise.withResolvers() + handle.write.mockImplementationOnce(async () => { writing.resolve(undefined); await written.promise }) + const pendingWrite = terminal.write(attachment('first'), 'pwd\r') + try { + await writing.promise + const abort = new AbortController() + const pendingAttachment = terminal.follow(attachment('late'), abort.signal)[Symbol.asyncIterator]().next() + const rejected = expect(pendingAttachment).rejects.toThrow('detached while waiting') + abort.abort(new Error('detached while waiting')) + written.resolve(undefined) + await pendingWrite + await rejected + expect(terminal.info.controllerId).toBe(attachment('first')) + } finally { written.resolve(undefined) } + }) + + it('continues accepting operations after a provider write or resize fails', async () => { + const { terminal, handle } = fixture() + await attach(terminal) + handle.write.mockRejectedValueOnce(new Error('input transport failed')) + handle.resize.mockRejectedValueOnce(new Error('resize transport failed')) + await expect(terminal.write(attachment('first'), 'failed')).rejects.toThrow('input transport failed') + await expect(terminal.resize(attachment('first'), 100, 30)).rejects.toThrow('resize transport failed') + expect(terminal.info).toMatchObject({ cols: 80, rows: 24 }) + await terminal.write(attachment('first'), 'accepted') + await terminal.resize(attachment('first'), 90, 25) + expect(handle.write).toHaveBeenLastCalledWith('accepted') + expect(terminal.info).toMatchObject({ cols: 90, rows: 25 }) + }) + + it.each([new Error('process wait failed'), 'remote process wait failed'])('publishes a failed process outcome and retains its recovery screen: %s', async (failure) => { + const { terminal, output, outcome } = fixture() + const first = await attach(terminal) + output.end('last output') + outcome.reject(failure) + expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: 'last output' }) + expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'failed', error: failure instanceof Error ? failure.message : failure } }) + const second = await attach(terminal, 'second') + expect(second.baseline).toMatchObject({ type: 'snapshot', screen: 'last output', info: { state: 'failed' } }) + }) + + it('publishes output-stream failure even when the process wait succeeds', async () => { + const { terminal, output, outcome } = fixture() + const first = await attach(terminal) + output.destroy(new Error('output transport failed')) + outcome.resolve({ exitCode: 0, signal: null }) + expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'failed', error: 'output transport failed' } }) + }) + + it('flushes incomplete UTF-8 at EOF before publishing the process exit', async () => { + const { terminal, output, outcome } = fixture() + const first = await attach(terminal) + output.end(Buffer.from([0xe7, 0xbb])) + outcome.resolve({ exitCode: 0, signal: null }) + expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: '�' }) + expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'exited' } }) + }) + + it('preserves a leading UTF-8 BOM in the terminal output stream', async () => { + const { terminal, output } = fixture() + const first = await attach(terminal) + output.write(Buffer.from('\uFEFF终端')) + expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: '\uFEFF终端' }) + }) + + it('shares concurrent close attempts and permits retry after termination fails', async () => { + const { terminal, handle } = fixture() + await attach(terminal) + handle.terminate.mockRejectedValueOnce(new Error('process range remains alive')) + const first = terminal.close() + expect(terminal.close()).toBe(first) + await expect(first).rejects.toThrow('remains alive') + await terminal.write(attachment('first'), 'retry cleanup next') + await terminal.close() + expect(handle.terminate).toHaveBeenCalledTimes(2) + }) + + it('reports overflow rather than silently discarding output', async () => { + const follower = new TerminalFollower(64) + follower.push({ type: 'output', sequence: 1, data: 'x'.repeat(128) }) + await expect(follower.read(new AbortController().signal)[Symbol.asyncIterator]().next()).rejects.toThrow('buffer') + }) +}) + +async function readFrame(iterator: AsyncIterator): Promise { + const result = await iterator.next() + if (result.done === true) throw new Error('Terminal stream ended before its expected frame') + return result.value +} diff --git a/packages/api/terminal-controller/tsconfig.client.json b/packages/api/terminal-controller/tsconfig.client.json new file mode 100644 index 0000000000..3f2a5ca277 --- /dev/null +++ b/packages/api/terminal-controller/tsconfig.client.json @@ -0,0 +1,38 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "files": [ + "src/client/index.ts", + "src/client/model.ts", + "src/client/shell-preference.ts", + "src/types.ts", + "src/client/close-requests.ts" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../gateway/tsconfig.client.json" + }, + { + "path": "../../client/store" + }, + { + "path": "../../core/session" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../typert/protocol" + }, + { + "path": "../../util/crypto" + } + ] +} diff --git a/packages/api/terminal-controller/tsconfig.host.json b/packages/api/terminal-controller/tsconfig.host.json new file mode 100644 index 0000000000..132c120afa --- /dev/null +++ b/packages/api/terminal-controller/tsconfig.host.json @@ -0,0 +1,53 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "files": [ + "src/index.ts", + "src/types.ts", + "src/terminal.ts", + "src/stream.ts", + "src/shells.ts" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/session" + }, + { + "path": "../../fs/fs" + }, + { + "path": "../../sandbox/sandbox" + }, + { + "path": "../../sandbox/sandbox-policy" + }, + { + "path": "../../session/session-projection" + }, + { + "path": "../../subprocess/subprocess" + }, + { + "path": "../../typert/protocol" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../util/deque" + } + ] +} diff --git a/packages/api/terminal-controller/tsconfig.json b/packages/api/terminal-controller/tsconfig.json new file mode 100644 index 0000000000..2eca820546 --- /dev/null +++ b/packages/api/terminal-controller/tsconfig.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.host.json" + }, + { + "path": "./tsconfig.client.json" + } + ] +} diff --git a/packages/api/terminal-controller/tsdown.config.ts b/packages/api/terminal-controller/tsdown.config.ts new file mode 100644 index 0000000000..cb9d252eb2 --- /dev/null +++ b/packages/api/terminal-controller/tsdown.config.ts @@ -0,0 +1,7 @@ +import { clientBundle } from '../../client/tsdown.client.ts' + +export default clientBundle( + '@deepseek-ai/dsh-api-terminal-controller', + ['lib/types/index.js'], + { hostPhase: true }, +) diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index acedbfc42b..a070f507d0 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -104,6 +104,8 @@ # Workspace file service: bounded read, directory listing, and the # agent-write change feed inside the session workspace root. + - id: terminal-controller + name: '@deepseek-ai/dsh-api-terminal-controller' - id: workspace-files name: '@deepseek-ai/dsh-api-workspace-files' @@ -228,6 +230,8 @@ name: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview' # The right Sidebar's workspace file tree tab type. + - id: ui-sidebar-terminal + name: '@deepseek-ai/dsh-client-ui-sidebar-terminal' - id: ui-sidebar-files name: '@deepseek-ai/dsh-client-ui-sidebar-files' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 10820384d4..a7ae947880 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -119,7 +119,9 @@ "@deepseek-ai/dsh-workspace": "workspace:^", "@deepseek-ai/schemastery": "workspace:^", "commander": "^15.0.0", - "open": "^11.0.0" + "open": "^11.0.0", + "@deepseek-ai/dsh-api-terminal-controller": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar-terminal": "workspace:^" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/client/locale/src/locales/en.ts b/packages/client/locale/src/locales/en.ts index 70ce31598c..d16b4913ae 100644 --- a/packages/client/locale/src/locales/en.ts +++ b/packages/client/locale/src/locales/en.ts @@ -34,8 +34,6 @@ export const en = { 'unknown': 'Unknown', 'none': 'None', 'truncated': 'Truncated', - 'json.collapseNode': 'Collapse JSON node', - 'json.expandNode': 'Expand JSON node', 'json.label': 'JSON', 'markdown.footnotes': 'Footnotes', 'markdown.truncatedCharacters': '… truncated at {total} characters', diff --git a/packages/client/locale/src/locales/zh.ts b/packages/client/locale/src/locales/zh.ts index 30cee308d5..2e932cef41 100644 --- a/packages/client/locale/src/locales/zh.ts +++ b/packages/client/locale/src/locales/zh.ts @@ -32,8 +32,6 @@ export const zh = { 'unknown': '未知', 'none': '无', 'truncated': '已截断', - 'json.collapseNode': '收起 JSON 节点', - 'json.expandNode': '展开 JSON 节点', 'json.label': 'JSON', 'markdown.footnotes': '脚注', 'markdown.truncatedCharacters': '… 已截断,共 {total} 字符', diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index b946373430..4ca225d5ac 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -12,7 +12,7 @@ */ import { readFile } from 'node:fs/promises' import { existsSync, globSync, readFileSync } from 'node:fs' -import { isBuiltin } from 'node:module' +import { createRequire, isBuiltin } from 'node:module' import { basename, dirname, isAbsolute, relative, resolve as resolvePath, sep } from 'node:path' import { fileURLToPath } from 'node:url' import type { TsdownPlugin, UserConfig } from 'tsdown' @@ -681,6 +681,7 @@ const SOURCEMAP_COMMENT = /\n\/\/# sourceMappingURL=.*\s*$/ /** Resolve an emitted JS asset import against its source-tree counterpart. */ function sourceAssetPath(source: string, importer: string): string { + if (!source.startsWith('.') && !isAbsolute(source)) return createRequire(importer).resolve(source) const emitted = resolvePath(dirname(importer), source) if (existsSync(emitted)) return emitted const boundary = emitted.indexOf(TYPES_MARKER) diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 2c5c3fda05..c65f99e1d5 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md -README.md: 2333739f2827eb673525b9f00547d12e8d343888 -README.zh.md: 0de859abfcfac1679d605cf4447a2f4ac2312f5d +README.md: 254c511a023aa8217b73cc0fe3802f13f2a63d80 +README.zh.md: d79f8b5060bd6a452ddaf018074adaea88b85604 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 2333739f28..254c511a02 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -41,6 +41,8 @@ Each nonempty appended `system/message` owns a collapsed prompt row, including a A completed Turn shows an expandable usage row only when the loaded window includes `turn/start` and every started model attempt reports safe, exact usage. The row omits unavailable optional buckets. Incomplete or contradictory accounting hides the complete disclosure instead of presenting a partial total. +Reply timing survives reopening history: recorded token timestamps supply first-token latency and generation duration, including the Step's first token from an earlier retry attempt. Missing Step starts or token timestamps leave the affected metrics unavailable. + ## Completed-turn footer diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 0de859abfc..d79f8b5060 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -43,6 +43,8 @@ kind: "package-reference" 只有当已加载窗口包含 `turn/start`,且每次已启动的模型尝试都报告安全、精确的用量时,已完成轮次才显示可展开的用量行。该行会省略不可用的可选用量桶。记账不完整或相互矛盾时,整个详情都不显示,避免把部分总量冒充完整结果。 +重新打开历史后仍保留回复计时:记录中的 token 时间戳提供首 token 延迟和生成耗时,包括步骤中更早重试尝试的首个 token。缺少步骤起点或 token 时间戳时,受影响的指标保持不可用。 + ## 已完成轮次的页脚 diff --git a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx index 3212801e00..51300f6d02 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx @@ -58,6 +58,8 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ return { resolve: value => localPathMediaUrl(protocol, origin, value) } }, []) const last = blocks.length - 1 + const hasBodyOrToolCall = blocks.some(block => block.kind === 'tool-call' + || (block.kind === 'text' && block.text.trim() !== '')) // Tool-call heads render as tool rows in the chat view's grouping pass, so // a node that is only those heads (or empty) would paint an empty root // between tool groups — skip the shell unless something visible remains. @@ -89,7 +91,12 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ hidden={reasoningHidden} reveal={revealProcess} > - + , ) break diff --git a/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx b/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx index bc7859b364..bb22eea778 100644 --- a/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx +++ b/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx @@ -22,11 +22,18 @@ function latestLine(text: string): string { * the complete text. * @param props.text - complete or streaming reasoning text. * @param props.running - whether this block is the streaming tail. + * @param props.defaultExpanded - expanded state until the reader toggles it. * @param props.t - conversation locale seat for the running status. * @returns the reasoning disclosure. */ -export function ReasoningRow({ text, running, t }: { text: string; running: boolean; t: ChatViewSlotProps['t'] }) { - const [expanded, setExpanded] = useState(false) +export function ReasoningRow({ text, running, defaultExpanded, t }: { + text: string + running: boolean + defaultExpanded: boolean + t: ChatViewSlotProps['t'] +}) { + const [expandedOverride, setExpandedOverride] = useState() + const expanded = expandedOverride ?? defaultExpanded const summary = (running ? latestLine(text) : firstLine(text)).replaceAll('**', '') return ( @@ -47,7 +54,7 @@ export function ReasoningRow({ text, running, t }: { text: string; running: bool open={expanded} expandable expandOnRowClick - onToggle={() => { setExpanded(value => !value) }} + onToggle={() => { setExpandedOverride(!expanded) }} collapsedContent={( <> diff --git a/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts b/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts index 41093124a8..aecfa43c5e 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts @@ -4,6 +4,7 @@ import type { ConversationNodeContext, ConversationNodeDefinition, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { StreamChunk } from '@deepseek-ai/dsh-llm' +import { assistantStreamFirstTokenTime } from '@deepseek-ai/dsh-llm/assistant-stream' import type {} from '@deepseek-ai/dsh-llm-retry/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { AssistantChatData } from '../contract/chat-nodes.ts' @@ -161,6 +162,17 @@ function updateChunk( } } +/** The Step retains its first token across live chunks and settled retry attempts. */ +function settleTiming( + state: AssistantState, + event: SessionEvent<'assistant/message' | 'assistant/attempt'>, +): AssistantState { + return { + ...state, + firstTokenTime: state.firstTokenTime ?? assistantStreamFirstTokenTime(event.data.stream), + } +} + function settleMessage( state: AssistantState, match: ConversationMatch, @@ -168,7 +180,7 @@ function settleMessage( ): AssistantState { const blocks = toAssistantBlocks(event.data.message.content) return { - ...state, + ...settleTiming(state, event), blocks, visibleBlocks: countVisibleBlocks(blocks), hidden: false, @@ -236,6 +248,11 @@ function fallbackState(context: ConversationNodeContext): Assist state = updateChunk(state, match.event.data.chunk, match.event.seq, match.event.time) continue } + if (match.event.type === 'assistant/attempt') { + state ??= initialState(match.event.data.turn, match.event.data.step) + state = settleTiming(state, match.event) + continue + } if (match.event.type === 'assistant/message') { state ??= initialState(match.event.data.turn, match.event.data.step) state = settleMessage(state, match, match.event) @@ -296,6 +313,7 @@ export const assistantDefinition: ConversationNodeDefinition = { match: (event) => { if (event.type === 'step/start') return { id: `${event.data.turn}:${event.data.step}`, role: 'start' } if (event.type === 'assistant/live-chunk' + || event.type === 'assistant/attempt' || (event.type === 'assistant/message' && event.surfaceOp === 'append')) { return { id: `${event.data.turn}:${event.data.step}`, role: 'update' } } @@ -313,13 +331,14 @@ export const assistantDefinition: ConversationNodeDefinition = { return updateChunk(context.state, match.event.data.chunk, match.event.seq, match.event.time) } if (match.event.type === 'assistant/message') return settleMessage(context.state, match, match.event) + if (match.event.type === 'assistant/attempt') return settleTiming(context.state, match.event) if (match.event.type === 'llm/retry') { return resetForRetry(context.state) } return context.state }, publication: (match) => { - if (match.event.type === 'step/start') return 'none' + if (match.event.type === 'step/start' || match.event.type === 'assistant/attempt') return 'none' if (match.event.type !== 'assistant/live-chunk') return 'immediate' const type = match.event.data.chunk.type return type === 'usage' || type === 'finish' ? 'none' : 'animation-frame' diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts index 3e8983fc46..8fa1b90d0a 100644 --- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts @@ -1056,9 +1056,16 @@ describe('built-in conversation node Definitions', () => { const finalNode = (node(finalizedPacked, 'assistant-step')?.data as AssistantChatData).finalNode expect(finalNode).toMatchObject({ blocks: [{ kind: 'text', text: 'done' }], - timing: { firstTokenTime: null }, + timing: { firstTokenTime: 1_999 }, }) + const windowed = assembler(finalizedInputs.slice(2), true) + const timing = () => (node(snapshot(windowed), 'assistant-step')?.data as AssistantChatData).finalNode?.timing + expect(timing()).toMatchObject({ stepStartTime: null, firstTokenTime: 1_999 }) + windowed.prepend(finalizedInputs.slice(0, 2), false) + windowed.flush() + expect(timing()).toEqual(finalNode?.timing) + const namedToolHistory = [ at(40, 'turn/start', { turn: 3 }), at(41, 'step/start', { turn: 3, step: 1 }), @@ -1083,7 +1090,7 @@ describe('built-in conversation node Definitions', () => { const namedTool = (node(namedToolPacked, 'assistant-step')?.data as AssistantChatData).finalNode expect(namedTool).toMatchObject({ blocks: [{ kind: 'tool-call', callId: 'call-2', name: 'read', argsRaw: '' }], - timing: { firstTokenTime: null }, + timing: { firstTokenTime: 4_000 }, }) }) diff --git a/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx b/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx index 439b79bc7a..0711d89770 100644 --- a/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx @@ -14,6 +14,26 @@ const t = makeTranslate(zh, commonZh) const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => null describe('ReasoningRow', () => { + it('collapses when body text arrives and preserves a later manual expansion', () => { + const reasoning = { kind: 'reasoning' as const, text: 'Inspect the session\nCheck persistence' } + const view = render( + , + ) + expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('true') + view.rerender( + , + ) + expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('false') + fireEvent.click(view.getByText('思考')) + view.rerender( + , + ) + expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('true') + expect(view.getByText(/Check persistence/)).toBeTruthy() + }) + it('follows the latest streaming line, then restores the settled first line', () => { const view = render( { />, ) expect(view.getByText('运行中')).toBeTruthy() + expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('true') + fireEvent.click(view.getByText('思考')) expect(view.getByText('Newest reasoning tokens').parentElement?.getAttribute('data-follow-end')) .toBe('true') @@ -62,6 +84,7 @@ describe('ReasoningRow', () => { ) const row = view.getByRole('button') + fireEvent.click(view.getByText('思考')) fireEvent.click(view.getByText('Inspect the session')) expect(row.getAttribute('aria-expanded')).toBe('true') expect(view.getByText(/Check persistence/)).toBeTruthy() @@ -91,6 +114,7 @@ describe('ReasoningRow', () => { />, ) + fireEvent.click(view.getByText('思考')) expect(view.getByText('Comparing checkout and merge bases')).toBeTruthy() expect(view.queryByText('**Comparing checkout and merge bases**')).toBeNull() @@ -98,7 +122,7 @@ describe('ReasoningRow', () => { expect(view.container.querySelector('[class*="thinkBody"]')?.textContent).toBe(text) }) - it('expanded Think drops the inline summary and renders plain prose, no IN card', () => { + it('opens reasoning-only replies as plain prose and allows manual collapse', () => { const view = render( { renderMessageImages={renderMessageImages} />, ) - fireEvent.click(view.getByText('思考')) + expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('true') expect(view.getAllByText(/Inspect the session/)).toHaveLength(1) expect(view.queryByText('IN')).toBeNull() expect(view.container.querySelector('[class*="ioCard"]')).toBeNull() expect(view.container.querySelector('[class*="thinkBody"]')).not.toBeNull() + fireEvent.click(view.getByText('思考')) + expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('false') + expect(view.queryByText(/Check persistence/)).toBeNull() }) }) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 2e24bbc618..ff5374fea9 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: d22fa2eed35457c0599fd41473102b1aebe4bd2e -README.zh.md: b53cf76dc8b8a253da83d1e94b6f9f1b5abf19cc +README.md: 07e61be1bb993716c85273aac8ee6e650d221365 +README.zh.md: 194d8e994d82ae167ce2c7af06d9b199ef066b27 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index d22fa2eed3..07e61be1bb 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -52,7 +52,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `HoverCard` | Hover preview the pointer can rest on and select from; optional copy button. | | `Toast` | Transient top-center banner held for the owner's `holdMs`. | | `JsonTree`, `JsonBlock` | Read-only JSON inspection. | -| `MarkdownText`, `CodeBlock` | Untrusted GFM with TeX math, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter, and `contentRef` exposes its stable source wrapper to an owner that uses it as a scrollport. | +| `MarkdownText`, `CodeBlock` | Untrusted GFM with TeX math, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter, and `contentRef` exposes its stable source wrapper to an owner that uses it as a scrollport. Set `showHeader={false}` when the owner supplies its own language and copy toolbar. | | `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. | | `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIcon`, `LinkIcon` | Glyphs and brand marks. Use `LinkIcon` for 14px clickable-link categories. | | `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 `LinkIcon` for link-leading glyphs and image previews for image content. | @@ -74,6 +74,8 @@ The catalog above lists what each export is for; this section covers the behavio `MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows. When supplied with `UserTextReferences`, file and skill references become keyboard-accessible preview buttons using the same hover and focus styling as prose file links; the first pointer click can open a preview, while subsequent clicks and existing text selections retain native selection handling. Keyboard activation opens previews even when text is selected. +`JsonTree` clamps collapsed strings to `collapsedStringLines` (three by default). Expanded strings show raw text, retain sibling commas, and fit within the window and outer scrolling containers. Resize and ancestor-scroll events update that limit. Row copy feedback updates independently of JSON value rendering; pending clipboard writes cannot update a different row or an unmounted tree. + ### Localizing copy The atoms cannot read the application locale, so every piece of user-facing copy arrives through required label props. `HoverCard`, `TerminalBlock`, `JsonTree`, `CodeBlock`, `MarkdownText`, `JsonBlock`, `ConnectionIndicator`, `Modal`, `DiffBlock`, `ReadBlock`, `SearchBlock`, and `WebBlock` accept complete localized labels. The package owns no language fallback; omission fails typechecking, and each feature maps its typed `t` seat into the primitive's label interface. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index b53cf76dc8..194d8e994d 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -52,7 +52,7 @@ kind: "package-library" | `HoverCard` | 指针可停留、可选中的悬停预览;可选带复制按钮。 | | `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 | | `JsonTree`、`JsonBlock` | 只读 JSON 查看。 | -| `MarkdownText`、`CodeBlock` | 不可信 GFM 与 TeX 数学,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏,`contentRef` 则向需要把稳定源码包装节点用作滚动区的 owner 提供该节点。 | +| `MarkdownText`、`CodeBlock` | 不可信 GFM 与 TeX 数学,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏,`contentRef` 则向需要把稳定源码包装节点用作滚动区的 owner 提供该节点。调用方提供自己的语言与复制工具栏时,设置 `showHeader={false}`。 | | `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 | | `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIcon`、`LinkIcon` | 字形与品牌标识。`LinkIcon` 用于 14px 的可点击链接分类。 | | `FileTypeIcon`、`classifyFileType`、`fileExtension` | 按类别着色的 28px 文件或文件夹图形,以及它背后共享的不区分大小写文件名映射。代码与配置文件使用细分的全彩技术图形;链接前置图形使用 `LinkIcon`,图片内容使用图片预览。 | @@ -74,6 +74,8 @@ kind: "package-library" `MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。 传入 `UserTextReferences` 时,文件和 skill 引用成为支持键盘操作的预览按钮,复用正文文件链接的悬停和聚焦样式;第一次指针点击可以打开预览,后续点击和已有选区保留原生选择行为。键盘激活在存在选区时仍可打开预览。 +`JsonTree` 把折叠字符串限制为 `collapsedStringLines` 行(默认三行)。展开后显示原始文本、保留同级逗号,并限制在窗口与外层滚动容器内;尺寸变化和祖先滚动事件会更新此限制。行复制反馈独立于 JSON 值渲染更新;尚未完成的剪贴板写入不会更新另一行或已卸载的树。 + ### 本地化文案 这些原子组件无法读取应用 locale,因此每段面向用户的文案都必须通过 label prop 提供。`HoverCard`、`TerminalBlock`、`JsonTree`、`CodeBlock`、`MarkdownText`、`JsonBlock`、`ConnectionIndicator`、`Modal`、`DiffBlock`、`ReadBlock`、`SearchBlock` 与 `WebBlock` 接收完整的本地化 label。本包不拥有语言回退;遗漏会导致类型检查失败,各功能会把带类型的 `t` 席位映射到 primitive 的 label 接口。 diff --git a/packages/client/ui-primitives/src/JsonTree.module.css b/packages/client/ui-primitives/src/JsonTree.module.css index 33ec3f30d3..9abbc23bdc 100644 --- a/packages/client/ui-primitives/src/JsonTree.module.css +++ b/packages/client/ui-primitives/src/JsonTree.module.css @@ -29,17 +29,17 @@ .container { box-sizing: border-box; - width: max-content; - min-width: 100%; + width: 100%; + min-width: 0; margin: 0; - padding: 6px 8px 8px; + padding: 6px 8px 8px 8px; white-space: pre; } .expandedTopLevel { box-sizing: border-box; - width: max-content; - min-width: 100%; + width: 100%; + min-width: 0; padding: 6px 8px 8px 14px; } @@ -66,11 +66,13 @@ .row { position: relative; box-sizing: border-box; - min-width: 100%; + min-width: 0; min-height: 16px; margin: 0; padding: 0 0 0 10px; list-style: none; + overflow-wrap: anywhere; + white-space: pre-wrap; } .row:not(.topLevelBracket):hover:not(:has(.row:hover))::after, @@ -81,13 +83,13 @@ top: 0; right: 0; left: 0; - height: 16px; + height: var(--json-tree-row-height, 16px); background: var(--json-tree-hover); content: ''; pointer-events: none; } -.row > span:not(.expander) { +.row > span:not(.expander, .copySlot) { position: relative; z-index: 1; } @@ -106,6 +108,100 @@ color: var(--json-tree-string); } +.stringField { + position: relative; + z-index: 1; + display: flow-root; +} + +.stringText { + display: block; + max-height: calc(var(--json-tree-collapsed-lines) * 16px); + padding-left: 12px; + overflow: hidden; + text-indent: -12px; + word-break: break-all; +} + +.stringRaw { + margin: 4px 0 0 12px; + overflow: auto; + color: var(--dsw-alias-label-primary); + font: inherit; + overflow-wrap: normal; + white-space: pre; + word-break: normal; +} + +.stringActions { + position: absolute; + top: 1px; + right: 4px; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; + width: fit-content; + height: 16px; +} + +.stringRaw[data-wrap='true'] { + overflow-wrap: anywhere; + white-space: pre-wrap; + word-break: break-all; +} + +.stringActions > .copySlot { + opacity: 1; + pointer-events: auto; +} + +.stringToggleSlot { + display: flex; + float: right; + align-items: flex-end; + height: calc(var(--json-tree-collapsed-lines) * 16px); + margin-left: 2px; + shape-outside: inset(calc((var(--json-tree-collapsed-lines) - 1) * 16px) 0 0); + pointer-events: none; +} + +.stringToggle { + display: inline-flex; + align-items: baseline; + gap: 2px; + margin: 0; + padding: 0; + border: 0; + color: var(--dsw-alias-label-secondary); + background: transparent; + font: inherit; + text-indent: 0; + white-space: nowrap; + cursor: pointer; + pointer-events: auto; +} + +.stringToggle:hover { + color: var(--dsw-alias-label-primary); +} + +.stringToggle:focus-visible { + outline: 1px solid var(--dsw-alias-state-business-primary); + outline-offset: 1px; +} + +.row:has(> .stringField) { + --json-tree-row-height: 100%; +} + +.summary { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .numberValue { color: var(--json-tree-number); } @@ -134,13 +230,38 @@ color: var(--dsw-alias-label-tertiary); } -.copyAnchor { - position: fixed; - z-index: 3; +.copySlot { display: inline-flex; + width: 20px; + height: 16px; + opacity: 0; + pointer-events: none; } -.copyButton { +.row > .copySlot { + position: absolute; + z-index: 2; + top: 0; + right: 0; +} + +.row:has(> .stringField) > .copySlot { + top: auto; + bottom: 0; +} + +.row:has(> .copySlot) > .stringField .stringToggleSlot { + padding-right: 24px; +} + +.row:hover:not(:has(.row:hover)) > .copySlot, +.row[data-json-copy-active] > .copySlot, +.copySlot:focus-within { + opacity: 1; + pointer-events: auto; +} + +.actionButton { box-sizing: border-box; display: inline-flex; align-items: center; @@ -152,22 +273,24 @@ border: 0; border-radius: 3px; color: var(--dsw-alias-label-secondary); - background: var(--dsw-alias-bg-layer-1); - box-shadow: -5px 0 5px var(--dsw-alias-bg-layer-1); + background: transparent; cursor: pointer; } -.copyButton:hover { +.actionButton:hover { color: var(--dsw-alias-label-primary); - background: var(--dsw-alias-interactive-bg-hover); } -.copyButton:focus-visible { +.actionButton[aria-pressed='true'] { + color: var(--dsw-alias-state-business-primary); +} + +.actionButton:focus-visible { outline: 1px solid var(--dsw-alias-state-business-primary); outline-offset: -1px; } -.copyButton[data-state='failed'] { +.actionButton[data-state='failed'] { color: var(--dsw-alias-state-error-primary); } diff --git a/packages/client/ui-primitives/src/JsonTree.tsx b/packages/client/ui-primitives/src/JsonTree.tsx index 8399f3681e..7a3f2e8b7f 100644 --- a/packages/client/ui-primitives/src/JsonTree.tsx +++ b/packages/client/ui-primitives/src/JsonTree.tsx @@ -1,12 +1,12 @@ import clsx from 'clsx' -import { useEffect, useId, useRef, useState } from 'react' +import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react' import type { + CSSProperties, KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, ReactNode, - UIEvent as ReactUIEvent, } from 'react' -import { IconCheckOutline16, IconCopyOutline16 } from './icons/index.tsx' +import { IconCheckOutline16, IconCopyOutline16, IconWrapLinesOutline16 } from './icons/index.tsx' import { Menu } from './Menu.tsx' import type { MenuEntry } from './Menu.tsx' import css from './JsonTree.module.css' @@ -65,10 +65,103 @@ interface RowTarget { value: unknown } -interface CopyTarget extends RowTarget { - left: number - side: 'bottom' | 'top' - top: number +type CopyMode = 'json' | 'path' | 'prettyJson' | 'value' + +interface CopySnapshot { + id: string + target: RowTarget + state: 'idle' | 'copied' | 'failed' + menuOpen: boolean +} + +/** Notify only the old and new row actions; JSON values do not subscribe to hover state. */ +function createCopyStore() { + let current: CopySnapshot | undefined + const listeners = new Map void>>() + return { + get: () => current, + set(next: CopySnapshot | undefined) { + const previous = current?.id + current = next + for (const id of new Set([previous, next?.id])) { + if (id === undefined) continue + for (const listener of listeners.get(id) ?? []) listener() + } + }, + subscribe(id: string, listener: () => void) { + let row = listeners.get(id) + if (row === undefined) listeners.set(id, row = new Set()) + row.add(listener) + return () => { + row.delete(listener) + if (row.size === 0) listeners.delete(id) + } + }, + } +} + +function JsonCopyAction({ store, target, persistent, labels, onCopy, onClose }: { + store: ReturnType + target: RowTarget + persistent: boolean + labels: JsonTreeLabels + onCopy: (target: RowTarget, mode: CopyMode) => Promise + onClose: () => void +}) { + const id = pathId(target.path) + const subscribe = useCallback((listener: () => void) => store.subscribe(id, listener), [id, store]) + const getSnapshot = () => { + const current = store.get() + return current?.id === id ? current : undefined + } + const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) + const buttonRef = useRef(null) + const state = snapshot?.state ?? 'idle' + const object = typeof target.value === 'object' && target.value !== null + const copyTitle = state === 'copied' + ? labels.copied + : state === 'failed' + ? labels.copyFailed + : object ? labels.copyPrettyJson : labels.copyValue + return ( + + {(persistent || snapshot !== undefined) && ( + void onCopy(target, object ? 'prettyJson' : 'value')} + onContextMenu={(event) => { + event.preventDefault() + event.stopPropagation() + store.set({ id, target, state, menuOpen: true }) + }} + > + {state === 'copied' + ? + : } + + )} + items={object ? objectCopyMenuItems(labels) : valueCopyMenuItems(labels)} + onSelect={(mode) => { + void onCopy(target, mode as CopyMode) + }} + onClose={onClose} + getAnchorRect={() => (buttonRef.current as HTMLButtonElement).getBoundingClientRect()} + /> + )} + + ) } function isExpandableValue(value: unknown): value is object | unknown[] { @@ -224,6 +317,8 @@ function NodeField({ } interface JsonTreeNodeProps { + collapsedStringLines: number + stringWrapping: JsonTreeProps['stringWrapping'] field?: string initialExpanded: boolean labels: JsonTreeLabels @@ -231,11 +326,180 @@ interface JsonTreeNodeProps { onClaimTabStop: (id: string) => void onRowHover: (row: HTMLElement, target: RowTarget) => void path: JsonPath + renderCopy: ((target: RowTarget, persistent?: boolean) => ReactNode) | undefined tabStopId: string | null value: unknown } +function JsonString({ + collapsedStringLines, + stringWrapping, + field, + labels, + lastElement, + renderCopy, + value, +}: { + collapsedStringLines: number + stringWrapping: JsonTreeProps['stringWrapping'] + field: string | undefined + labels: JsonTreeLabels + lastElement: boolean + renderCopy: ((persistent?: boolean) => ReactNode) | undefined + value: string +}) { + const contentsId = useId() + const contentRef = useRef(null) + const rawRef = useRef(null) + const [expanded, setExpanded] = useState(false) + const [wrapped, setWrapped] = useState(false) + const [truncated, setTruncated] = useState(false) + + useLayoutEffect(() => { + if (expanded) return + const content = contentRef.current as HTMLSpanElement + const measure = () => { + const lineHeight = Number.parseFloat(getComputedStyle(content).lineHeight) + setTruncated(content.scrollHeight > lineHeight * collapsedStringLines) + } + measure() + if (typeof ResizeObserver === 'undefined') return + const observer = new ResizeObserver(measure) + observer.observe(content) + return () => { observer.disconnect() } + }, [collapsedStringLines, expanded, field, lastElement, value]) + + useLayoutEffect(() => { + if (!expanded) return + const raw = rawRef.current as HTMLPreElement + // Keep raw text within the window and clipping ancestors outside the tree. + // Capture scrolling because an ancestor can move the string without resizing it. + const clips: HTMLElement[] = [] + const tree = raw.closest(`.${css.root}`) as HTMLElement + for (let parent = tree.parentElement; parent !== null; parent = parent.parentElement) { + if (/auto|scroll|hidden|clip/.test(getComputedStyle(parent).overflowY)) clips.push(parent) + } + const measure = () => { + let top = 0 + let bottom = window.innerHeight + for (const clip of clips) { + const rect = clip.getBoundingClientRect() + const style = getComputedStyle(clip) + top = Math.max(top, rect.top + clip.clientTop) + bottom = Math.min(bottom, rect.top + clip.clientTop + clip.clientHeight + - Number.parseFloat(style.paddingBottom)) + } + const available = bottom - Math.max(top, raw.getBoundingClientRect().top) + raw.style.maxHeight = `${Math.max(16, available - 4)}px` + } + measure() + const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(measure) + observer?.observe(raw) + for (const clip of clips) observer?.observe(clip) + window.addEventListener('resize', measure) + window.addEventListener('scroll', measure, true) + return () => { + observer?.disconnect() + window.removeEventListener('resize', measure) + window.removeEventListener('scroll', measure, true) + } + }, [expanded, value]) + + if (expanded) { + const fieldId = `${contentsId}-field` + return ( +
+ {field !== undefined && {fieldText(field)}:} +
+          {value}
+        
+ {!lastElement && ,} +
+ {stringWrapping !== undefined && ( + + )} + + {renderCopy?.(true)} +
+
+ ) + } + + return ( + <> + {renderCopy?.()} + + + {truncated && ( + + + + )} + {field !== undefined && {fieldText(field)}:} + {primitiveValue(value)} + {!lastElement && ,} + + + + ) +} + function JsonTreeNode({ + collapsedStringLines, + stringWrapping, field, initialExpanded, labels, @@ -243,6 +507,7 @@ function JsonTreeNode({ onClaimTabStop, onRowHover, path, + renderCopy, tabStopId, value, }: JsonTreeNodeProps) { @@ -281,10 +546,25 @@ function JsonTreeNode({ onRowHover(event.currentTarget, { path, value }) }} > + {typeof value !== 'string' && renderCopy?.({ path, value })} {children} ) + if (typeof value === 'string') { + return row( + renderCopy({ path, value }, persistent)} + />, + ) + } + if (!container) { return row(( <> @@ -322,14 +602,18 @@ function JsonTreeNode({ onClick={toggle} onKeyDown={onExpanderKeyDown} /> - - {previewValue(value, 0)} - {!lastElement && ,} + + + {previewValue(value, 0)} + {!lastElement && ,} + {expanded && (
    {entries.map(([key, item], index) => ( ))}
@@ -356,7 +641,7 @@ function formattedPath(path: JsonPath): string { }, '$') } -function copyText(target: CopyTarget, mode: 'json' | 'path' | 'prettyJson' | 'value'): string { +function copyText(target: RowTarget, mode: CopyMode): string { if (mode === 'path') return formattedPath(target.path) if (mode === 'prettyJson') return JSON.stringify(target.value, null, 2) if (mode === 'json') return JSON.stringify(target.value) @@ -376,6 +661,17 @@ export interface JsonTreeProps { label: string /** Optional positioning class owned by the caller. */ className?: string | undefined + /** Maximum visible lines per collapsed string; defaults to 3. */ + collapsedStringLines?: number + /** Optional wrap toggle; each expansion reads the shared default without changing other open strings. */ + stringWrapping?: { + /** Localized label for the wrapping toggle. */ + label: string + /** Read the wrapping preference when a string is expanded. @returns Whether to wrap long lines. */ + getDefault: () => boolean + /** Remember a user toggle for future expansions. @param wrapped - Whether to wrap long lines. */ + setDefault: (wrapped: boolean) => void + } | undefined /** Whether JSON rows expose copy actions. */ copyable?: boolean /** Whether the top-level object or array is always expanded. */ @@ -393,6 +689,8 @@ export function JsonTree({ data, label, className, + collapsedStringLines = 3, + stringWrapping, copyable = true, expandTopLevel = true, labels, @@ -407,14 +705,10 @@ export function JsonTree({ ? null : pathId([Array.isArray(data) ? firstExpandableIndex : firstExpandableEntry[0]]) : isExpandableValue(data) && rootEntries.length > 0 ? pathId([]) : null - const rootRef = useRef(null) const activeRowRef = useRef() - const copyButtonRef = useRef(null) - const copyMenuOpenRef = useRef(false) const resetTimer = useRef>() - const [copyTarget, setCopyTarget] = useState() - const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle') - const [copyMenuOpen, setCopyMenuOpen] = useState(false) + const copySequence = useRef(0) + const [copyStore] = useState(createCopyStore) const [tabStopId, setTabStopId] = useState(initialTabStopId) const setActiveRow = (row: HTMLElement | undefined) => { @@ -424,121 +718,74 @@ export function JsonTree({ } const clearCopyTarget = () => { + copySequence.current += 1 + if (resetTimer.current !== undefined) clearTimeout(resetTimer.current) setActiveRow(undefined) - setCopyTarget(undefined) - setCopyState('idle') - copyMenuOpenRef.current = false - setCopyMenuOpen(false) - } - - const copyPosition = (row: HTMLElement): Pick => { - const root = rootRef.current - /* v8 ignore next -- row events and viewport listeners run only after the root ref mounts. */ - if (root === null) throw new Error('JsonTree root is not mounted') - const rootRect = root.getBoundingClientRect() - const rowRect = row.getBoundingClientRect() - return { - left: rootRect.left + root.clientWidth - 26, - side: rowRect.top - rootRect.top > root.clientHeight / 2 ? 'top' : 'bottom', - top: rowRect.top, - } - } - - const positionCopyButton = (row: HTMLElement, target: RowTarget) => { - const position = copyPosition(row) - setCopyTarget({ ...target, ...position }) - } - - const repositionCopyButton = (row: HTMLElement) => { - const position = copyPosition(row) - setCopyTarget((current) => { - /* v8 ignore next -- an active row and its copy target are installed together. */ - if (current === undefined) return current - return { ...current, ...position } - }) + copyStore.set(undefined) } useEffect(() => () => { + copySequence.current += 1 if (resetTimer.current !== undefined) clearTimeout(resetTimer.current) activeRowRef.current?.removeAttribute('data-json-copy-active') }, []) useEffect(() => { - activeRowRef.current?.removeAttribute('data-json-copy-active') - activeRowRef.current = undefined - copyMenuOpenRef.current = false - setCopyTarget(undefined) - setCopyState('idle') - setCopyMenuOpen(false) + clearCopyTarget() setTabStopId(initialTabStopId) }, [data, expandTopLevel, initialTabStopId]) - useEffect(() => { - const reposition = () => { - const row = activeRowRef.current - if (row !== undefined) repositionCopyButton(row) - } - window.addEventListener('scroll', reposition, true) - window.addEventListener('resize', reposition) - return () => { - window.removeEventListener('scroll', reposition, true) - window.removeEventListener('resize', reposition) - } - }, []) - const handleRowHover = (row: HTMLElement, target: RowTarget) => { - if (!copyable || copyMenuOpenRef.current) return + if (!copyable || copyStore.get()?.menuOpen) return if (activeRowRef.current === row) return setActiveRow(row) - setCopyState('idle') - copyMenuOpenRef.current = false - setCopyMenuOpen(false) - positionCopyButton(row, target) + copyStore.set({ id: pathId(target.path), target, state: 'idle', menuOpen: false }) } const handleRootMouseOver = (event: ReactMouseEvent) => { - if (!copyable || copyMenuOpenRef.current) return + if (!copyable || copyStore.get()?.menuOpen) return /* v8 ignore next -- browser mouse events delivered through React target an Element. */ if (!(event.target instanceof Element)) return if (event.target.closest('[data-json-copy-button]') === null) clearCopyTarget() } - const handleScroll = (_event: ReactUIEvent) => { - const row = activeRowRef.current - if (row !== undefined) repositionCopyButton(row) - } - - const copy = async (mode: 'json' | 'path' | 'prettyJson' | 'value') => { - /* v8 ignore next -- copy controls only render while their target exists. */ - if (copyTarget === undefined) return - try { - await navigator.clipboard.writeText(copyText(copyTarget, mode)) - setCopyState('copied') - } catch { - setCopyState('failed') + const copy = async (target: RowTarget, mode: CopyMode) => { + const sequence = ++copySequence.current + const snapshot: CopySnapshot = { + id: pathId(target.path), target, state: 'idle', menuOpen: false, } + copyStore.set(snapshot) + let state: CopySnapshot['state'] + try { + await navigator.clipboard.writeText(copyText(target, mode)) + state = 'copied' + } catch { + state = 'failed' + } + const current = copyStore.get() + if (sequence !== copySequence.current || current?.target !== target) return + copyStore.set({ ...current, state }) if (resetTimer.current !== undefined) clearTimeout(resetTimer.current) - resetTimer.current = setTimeout(() => { setCopyState('idle') }, 1_500) + resetTimer.current = setTimeout(() => { + const current = copyStore.get() + if (current?.target === target) copyStore.set({ ...current, state: 'idle' }) + }, 1_500) } const [rootOpen, rootClose] = bracketOf(data) - const copyTargetIsObject = typeof copyTarget?.value === 'object' && copyTarget.value !== null - const defaultCopyMode = copyTargetIsObject ? 'prettyJson' : 'value' - const copyTitle = copyState === 'copied' - ? labels.copied - : copyState === 'failed' - ? labels.copyFailed - : copyTargetIsObject ? labels.copyPrettyJson : labels.copyValue + const renderCopy = copyable ? (target: RowTarget, persistent = false) => ( + + ) : undefined return (
{ - if (!copyMenuOpenRef.current) clearCopyTarget() + if (!copyStore.get()?.menuOpen) clearCopyTarget() }} - onScroll={handleScroll} > {expandTopLevel ? ( @@ -551,6 +798,7 @@ export function JsonTree({ handleRowHover(event.currentTarget, { path: [], value: data }) }} > + {renderCopy?.({ path: [], value: data })} {rootOpen}
( ))}
@@ -581,6 +832,8 @@ export function JsonTree({ : (
)} - {copyTarget !== undefined && ( - - void copy(defaultCopyMode)} - onContextMenu={(event) => { - event.preventDefault() - event.stopPropagation() - copyMenuOpenRef.current = true - setCopyMenuOpen(true) - }} - > - {copyState === 'copied' - ? - : } - - )} - items={copyTargetIsObject ? objectCopyMenuItems(labels) : valueCopyMenuItems(labels)} - onSelect={(id) => { - void copy(id as 'json' | 'path' | 'prettyJson' | 'value') - copyMenuOpenRef.current = false - setCopyMenuOpen(false) - }} - onClose={clearCopyTarget} - getAnchorRect={() => ( - copyButtonRef.current as HTMLButtonElement - ).getBoundingClientRect()} - /> - - )} ) } diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 7f1a3de926..a7bf608e47 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -938,6 +938,14 @@ export const IconArchiveOutline20 = ({ size = 20, className }: IconProps) => ( ) +/** Line-wrapping toggle glyph. */ +export const IconWrapLinesOutline16 = ({ size = 16, className }: IconProps) => ( + +) + /** * Plan row glyph of the composer menu (#3567): a document with three ruled * lines and a pencil over its corner. Drawn on a 14 grid whose content spans diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx index 4bd573d57e..14bfd6290b 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx @@ -29,6 +29,8 @@ export interface CodeBlockProps { contentRef?: Ref | undefined /** Show a numbered gutter without adding numbers to copied source. Defaults to false. */ lineNumbers?: boolean | undefined + /** Show the language and copy header; false when the caller supplies a toolbar. Defaults to true. */ + showHeader?: boolean | undefined /** Copy-button idle label; the owner passes localized copy (this package is cordis-free, so copy arrives via props). */ copyLabel: string /** Copy-button label during the post-copy confirmation window. */ @@ -61,7 +63,9 @@ function renderLine(line: readonly HighlightSpan[], index: number): ReactNode { ) } -export function CodeBlock({ code, lang, streaming, className, contentRef, lineNumbers = false, copyLabel, copiedLabel }: CodeBlockProps) { +export function CodeBlock({ + code, lang, streaming, className, contentRef, lineNumbers = false, showHeader = true, copyLabel, copiedLabel, +}: CodeBlockProps) { const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code const sourceLines = lineNumbers ? trimmed.split('\n') : undefined const rootRef = useRef(null) @@ -180,7 +184,7 @@ export function CodeBlock({ code, lang, streaming, className, contentRef, lineNu '--dsl-code-block-line-number-width': `${Math.max(2, String(sourceLines.length).length)}ch`, } as CSSProperties}> {/* These paired attributes are stable semantic hooks for owner styling and DOM tests. */} -
+ {showHeader &&
{lang ?? ''}
@@ -189,7 +193,7 @@ export function CodeBlock({ code, lang, streaming, className, contentRef, lineNu
-
+
}
{body}
) diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css index d29c50c99a..652affcf85 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css @@ -198,10 +198,11 @@ scrollbar STYLES (neither hover-conditioned `::-webkit-scrollbar*` rules nor a :hover `scrollbar-color` change reaches the painted bar), so the toggle is `overflow-x` itself — a layout change repaints reliably. The - resting padding matches the themed bar's height, so on an overflowing - table the appearing bar exactly replaces it and nothing below shifts. + resting padding matches the themed bar's height on the WebKit scrollbar + path. There, `scroll` reserves that height even when the table fits. + Other engines and overlay scrollbars may reserve a different height. Wheel and trackpad scrolling need the pointer over the table, which is - already the hover that re-enables `auto`. */ + already the hover that re-enables scrolling. */ .tableScroll:global(.md-table-wide) { overflow-x: hidden; padding-bottom: var(--dsh-scrollbar-width, 8px); @@ -209,7 +210,7 @@ .tableScroll:global(.md-table-wide):hover, .tableScroll:global(.md-table-wide):focus-visible { - overflow-x: auto; + overflow-x: scroll; padding-bottom: 0; } diff --git a/packages/client/ui-primitives/tests/icons.client.spec.tsx b/packages/client/ui-primitives/tests/icons.client.spec.tsx index d8b566704a..98f56c5d8f 100644 --- a/packages/client/ui-primitives/tests/icons.client.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.client.spec.tsx @@ -17,8 +17,8 @@ const icons = Object.fromEntries( const iconNames = Object.keys(icons) describe('ic_ds_ icon set', () => { - it('exports the full icon set (46 deepsuite + 21 figma extracts + eleven product glyphs outside those sets)', () => { - expect(iconNames.length).toBe(78) + it('exports the full icon set (46 deepsuite + 21 figma extracts + twelve product glyphs outside those sets)', () => { + expect(iconNames.length).toBe(79) // The composer menu's own glyphs, pinned by name. expect(iconNames).toEqual(expect.arrayContaining(['IconPlanOutline14', 'IconCompactOutline16', 'IconShieldOutline16'])) }) diff --git a/packages/client/ui-primitives/tests/json-tree.client.spec.tsx b/packages/client/ui-primitives/tests/json-tree.client.spec.tsx index 691ba88652..7a7c335ac0 100644 --- a/packages/client/ui-primitives/tests/json-tree.client.spec.tsx +++ b/packages/client/ui-primitives/tests/json-tree.client.spec.tsx @@ -12,10 +12,12 @@ function JsonTree(props: Omit, 'label' return } -let writeText: ReturnType +let writeText: ReturnType> +let originalClipboard: PropertyDescriptor | undefined beforeEach(() => { - writeText = vi.fn().mockResolvedValue(undefined) + originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard') + writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText }, @@ -25,9 +27,162 @@ beforeEach(() => { afterEach(() => { cleanup() vi.useRealTimers() + vi.restoreAllMocks() + vi.unstubAllGlobals() + if (originalClipboard === undefined) Reflect.deleteProperty(navigator, 'clipboard') + else Object.defineProperty(navigator, 'clipboard', originalClipboard) }) +function stubStringLayout(scrollHeight = 200): void { + vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(scrollHeight) + const computedStyle = window.getComputedStyle.bind(window) + vi.spyOn(window, 'getComputedStyle').mockImplementation((element) => { + const style = computedStyle(element) + style.lineHeight = '16px' + if (style.paddingBottom === '') style.paddingBottom = '0px' + return style + }) +} + describe('JsonTree', () => { + it('ignores clipboard settlement after the row changes or the tree unmounts', async () => { + vi.useFakeTimers() + const pending: (() => void)[] = [] + writeText.mockImplementation(() => new Promise((resolve) => { pending.push(resolve) })) + const view = render() + const rows = screen.getAllByRole('treeitem') + fireEvent.mouseOver(rows[0] as HTMLElement) + fireEvent.click(screen.getByRole('button', { name: 'Copy value' })) + fireEvent.mouseOver(rows[1] as HTMLElement) + await act(async () => { pending[0]!() }) + expect(screen.queryByRole('button', { name: 'Copied' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Copy value' })) + view.unmount() + const timerCount = vi.getTimerCount() + await act(async () => { pending[1]!() }) + expect(vi.getTimerCount()).toBe(timerCount) + }) + + it('updates copy actions without rereading JSON properties on hover', () => { + const readValue = vi.fn(() => 'payload '.repeat(100)) + const data = Object.fromEntries(Array.from({ length: 500 }, (_, index) => [ + `field${index}`, + { get value() { return readValue() } }, + ])) + render() + const rows = within(screen.getByRole('tree')).getAllByRole('treeitem') + readValue.mockClear() + + fireEvent.mouseOver(rows[0] as HTMLElement) + expect(within(rows[0] as HTMLElement).getByRole('button', { name: 'Copy pretty JSON' })).toBeTruthy() + fireEvent.mouseOver(rows[1] as HTMLElement) + expect(within(rows[0] as HTMLElement).queryByRole('button', { name: 'Copy pretty JSON' })).toBeNull() + expect(within(rows[1] as HTMLElement).getByRole('button', { name: 'Copy pretty JSON' })).toBeTruthy() + expect(readValue).not.toHaveBeenCalled() + }) + + it('expands raw strings without ResizeObserver and keeps the visible viewport limit', () => { + stubStringLayout() + vi.stubGlobal('ResizeObserver', undefined) + let rawTop = 150 + const view = render( +
+ +
, + ) + const clip = view.container.firstElementChild as HTMLElement + vi.spyOn(clip, 'clientTop', 'get').mockReturnValue(2) + vi.spyOn(clip, 'clientHeight', 'get').mockReturnValue(140) + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + return new DOMRect(0, this === clip ? 100 : rawTop, 200, 140) + }) + const rows = within(screen.getByRole('tree')).getAllByRole('treeitem') + fireEvent.click(within(rows[0] as HTMLElement).getByRole('button', { name: 'Expand JSON node' })) + const raw = rows[0]?.querySelector('pre') as HTMLPreElement + expect(raw.textContent).toBe('raw\ntext') + expect(raw.style.maxHeight).toBe('78px') + expect(raw.nextElementSibling?.textContent).toBe(',') + + rawTop = 80 + fireEvent.scroll(clip) + expect(raw.style.maxHeight).toBe('126px') + rawTop = 300 + fireEvent.resize(window) + expect(raw.style.maxHeight).toBe('16px') + fireEvent.click(within(rows[0] as HTMLElement).getByRole('button', { name: 'Collapse JSON node' })) + expect(raw.isConnected).toBe(false) + rawTop = 100 + fireEvent.scroll(clip) + expect(raw.style.maxHeight).toBe('16px') + + fireEvent.click(within(rows[1] as HTMLElement).getByRole('button', { name: 'Expand JSON node' })) + expect(rows[1]?.querySelector('pre')?.nextElementSibling?.textContent).not.toBe(',') + }) + + it('shows the string expander only beyond the configured collapsed line count', () => { + stubStringLayout(48) + let resize: (() => void) | undefined + const disconnect = vi.fn() + vi.stubGlobal('ResizeObserver', class { + constructor(callback: () => void) { resize = callback } + observe() {} + disconnect = disconnect + }) + const data = { text: 'three lines of text' } + const view = render() + expect(screen.queryByRole('button', { name: 'Expand JSON node' })).toBeNull() + view.rerender() + expect(screen.getByRole('button', { name: 'Expand JSON node' })).toBeTruthy() + vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(16) + act(() => { resize?.() }) + expect(screen.queryByRole('button', { name: 'Expand JSON node' })).toBeNull() + view.unmount() + expect(disconnect).toHaveBeenCalledTimes(2) + }) + + it('keeps raw strings intact and samples the wrapping preference on every expansion', async () => { + stubStringLayout() + vi.stubGlobal('ResizeObserver', class { + observe() {} + disconnect() {} + }) + let wrapped = false + const stringWrapping = { + label: 'Wrap lines', + getDefault: () => wrapped, + setDefault: (value: boolean) => { wrapped = value }, + } + const original = ` leading spaces\n\t"quoted" \\${'long'.repeat(100)}\nlast line\n` + render() + const [first, second] = within(screen.getByRole('tree')).getAllByRole('treeitem') + const a = within(first as HTMLElement) + const b = within(second as HTMLElement) + fireEvent.click(a.getByRole('button', { name: 'Expand JSON node' })) + fireEvent.click(b.getByRole('button', { name: 'Expand JSON node' })) + expect(a.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('false') + + fireEvent.click(a.getByRole('button', { name: 'Wrap lines' })) + expect(wrapped).toBe(true) + expect(a.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('true') + expect(b.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('false') + + fireEvent.click(b.getByRole('button', { name: 'Collapse JSON node' })) + fireEvent.click(b.getByRole('button', { name: 'Expand JSON node' })) + expect(b.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('true') + fireEvent.click(b.getByRole('button', { name: 'Wrap lines' })) + expect(wrapped).toBe(false) + expect(a.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('true') + + fireEvent.click(a.getByRole('button', { name: 'Collapse JSON node' })) + fireEvent.click(a.getByRole('button', { name: 'Expand JSON node' })) + const toggle = a.getByRole('button', { name: 'Wrap lines' }) + expect(toggle.getAttribute('aria-pressed')).toBe('false') + const contents = document.getElementById(toggle.getAttribute('aria-controls') as string) + expect(contents?.textContent).toBe(original) + fireEvent.click(a.getByRole('button', { name: 'Copy value' })) + await waitFor(() => { expect(writeText).toHaveBeenCalledWith(original) }) + }) + it('keeps the top level open and renders expandable value previews', () => { render( { view.unmount() }) - it('keeps copy placement synchronized and clears stale targets', () => { + it('keeps the copy action on its hovered row and clears stale targets', () => { const view = render() const root = view.container.firstElementChild as HTMLElement const tree = screen.getByRole('tree') const firstRow = within(tree).getAllByRole('treeitem')[0] as HTMLElement const secondRow = within(tree).getAllByRole('treeitem')[1] as HTMLElement - Object.defineProperty(root, 'clientHeight', { configurable: true, value: 100 }) - Object.defineProperty(root, 'clientWidth', { configurable: true, value: 300 }) - vi.spyOn(root, 'getBoundingClientRect').mockReturnValue({ - bottom: 100, - height: 100, - left: 10, - right: 310, - top: 0, - width: 300, - x: 10, - y: 0, - toJSON: () => ({}), - }) - vi.spyOn(firstRow, 'getBoundingClientRect').mockReturnValue({ - bottom: 91, - height: 16, - left: 10, - right: 200, - top: 75, - width: 190, - x: 10, - y: 75, - toJSON: () => ({}), - }) - fireEvent.mouseOver(firstRow) const copyButton = screen.getByRole('button', { name: 'Copy pretty JSON' }) - expect((copyButton.closest('span')?.parentElement as HTMLElement).style.left).toBe('284px') + expect(firstRow.contains(copyButton)).toBe(true) fireEvent.mouseOver(copyButton) expect(screen.getByRole('button', { name: 'Copy pretty JSON' })).toBeDefined() fireEvent.mouseOver(firstRow) fireEvent.scroll(root) - fireEvent.scroll(window) - fireEvent.resize(window) fireEvent.contextMenu(copyButton) fireEvent.mouseOver(secondRow) diff --git a/packages/client/ui-sidebar-right/README.i18n.yaml b/packages/client/ui-sidebar-right/README.i18n.yaml index b8e427d0f9..3384df0f37 100644 --- a/packages/client/ui-sidebar-right/README.i18n.yaml +++ b/packages/client/ui-sidebar-right/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-right/README.md -README.md: c947b5411f51aff54f480949af203d192662adbf -README.zh.md: 641a8fab400bbfadaf9a1b4b96ab8369a8121edf +README.md: 75c600edf014564894c9299422c840bce15eb8e8 +README.zh.md: 203bc30d0550234e9ed97c1ccac70bed433d0605 diff --git a/packages/client/ui-sidebar-right/README.md b/packages/client/ui-sidebar-right/README.md index c947b5411f..75c600edf0 100644 --- a/packages/client/ui-sidebar-right/README.md +++ b/packages/client/ui-sidebar-right/README.md @@ -67,7 +67,7 @@ After every action the kit's settle planner keeps the expanded surface populated The docked surface's last tab carries one more rule, decided in the store's `closeTab` and mirrored to the kit through `canCloseTab`: the guide standing as the only docked tab draws no close control and no menu close item — its chip sits quiet, and with no extension item contributed a secondary press opens no menu — and a programmatic close of it records nothing; any other tab standing alone closes together with the column in one entry — the layout stays empty until the next expansion seeds its current default page. Floating panels take no part in the rule: they render whether or not the column is expanded, and their tabs close freely. -State is memory-only. A reload returns every session to the collapsed default; switching sessions keeps each surface where it was. +Layout, open tabs, navigation parameters and selection are memory-only. A reload constructs the collapsed default; content plugins can then reopen their own live content, as [terminal recovery](../ui-sidebar-terminal/README.md#use-this-package) does. Switching sessions keeps each surface where it was. ## Extension seats @@ -84,7 +84,7 @@ Two more seats extend what is already there: `sidebar.right.tab.guide` (chain) r ## `ctx.sidebarRight` -`openResource(address, options?)` and `openTab(kind, options?)` are the navigation controller, and every way into the column calls one of them: the conversation's file links and a tool row's line reference (`openResource(fileAddress, { params: { line } })`), the strip's add control and a guide entry box (`openTab`), a file tree's rows (`tab.actions.openResource`). A resource address is a `dsh-resource:///…` URI; without `options.kind` the registry claims it (globs and `canOpen`, best band wins), with it that kind's type in force opens it. A page is named by kind; the tab is recorded under an address this package composes and nobody else spells (`contract/seed.ts`). Both run the same steps as one history entry: a resource tab already showing the same (kind, contentId) is focused wherever it sits unless `revealIfOpened: false`; page tabs always deduplicate within the target pane, regardless of that option; otherwise a new tab lands in `options.replaceTab`'s pane and slot (closing that tab), else `options.paneId`, else the active docked pane; the panel expands, because content the user cannot see is not opened. Then the Tab domain records the navigation — `params` reach the body as `navigation.params`, with `revision` stepped — outside the layout history. `params` is typed by what is opened: a viewer for a resource type merges its entry into `SidebarRightResourceParamsMap` (the text preview declares `{ line?: number }`); a page type that takes parameters merges into `SidebarRightTabParamsMap` under its kind; values are JSON-shaped by convention, unchecked at run time. An address outside `dsh-resource://`, one no type claims, or a kind nothing registered throws: that is a wiring mistake, not a user error. +`openResource(address, options?)` and `openTab(kind, options?)` are the navigation controller, and every way into the column calls one of them: the conversation's file links and a tool row's line reference (`openResource(fileAddress, { params: { line } })`), the strip's add control and a guide entry box (`openTab`), a file tree's rows (`tab.actions.openResource`). A resource address is a `dsh-resource:///…` URI; without `options.kind` the registry claims it (globs and `canOpen`, best band wins), with it that kind's type in force opens it. A page is named by kind; the tab is recorded under an address this package composes and nobody else spells (`contract/seed.ts`). Both run the same steps as one history entry: a resource tab already showing the same (kind, contentId) is focused wherever it sits unless `revealIfOpened: false`; ordinary page tabs always deduplicate within the target pane, regardless of that option; otherwise a new tab lands in `options.replaceTab`'s pane and slot (closing that tab), else `options.paneId`, else the active docked pane; the panel expands, because content the user cannot see is not opened. Then the Tab domain records the navigation — `params` reach the body as `navigation.params`, with `revision` stepped — outside the layout history. `params` is typed by what is opened: a viewer for a resource type merges its entry into `SidebarRightResourceParamsMap` (the text preview declares `{ line?: number }`); a page type that takes parameters merges into `SidebarRightTabParamsMap` under its kind; values are JSON-shaped by convention, unchecked at run time. An address outside `dsh-resource://`, one no type claims, or a kind nothing registered throws: that is a wiring mistake, not a user error. `close(tabId)` closes a tab; `active()` reads the active tab. `isExpanded()` and `toggleExpanded()` read and drive the column's expansion; the presentation switch is the panel's own control and not part of this face. Layout operations, for callers that arrange the column programmatically, each recorded like the gesture it stands in for: `focus(tabId)` focuses a tab and its pane; `split(paneId?)` splits a docked pane (the active one by default) under the same pane budget and room rule as the strip's control and returns the new pane's id, or `undefined` — recording nothing — when it cannot; `float(tabId, rect?)` takes a docked tab out into a panel; `dock(paneId)` returns a floating panel to the active docked pane. A tab or pane that does not exist, or already is where the call would put it, is left alone. The face exposes operations only: no layout snapshot, no operation log, no lookup by address. `_undo()` / `_redo()` step the mounted surface's history; they are `@internal` — the sequence has no user-facing control, and these exist for tests. Commands need a mounted session surface; with none, they throw rather than write into a surface nobody draws. @@ -93,10 +93,14 @@ Two more seats extend what is already there: `sidebar.right.tab.guide` (chain) r The Tab domain retains navigation, an abort signal, and bound actions per (Session, tab id). A private assembly callback adopts each Session's store and reconciles records on its commits. Only record removal or plugin unload aborts the signal; closing the sidebar and switching Sessions retain records, while undo restores a new occurrence. `useTabInfo()` composes framework-bound store and navigation hooks without manual component subscriptions or render-time record creation. `tab.actions` always target their own Session; `tab.visible` distinguishes bodies from titles, and floating tabs remain visible when the sidebar closes. `adopt` is absent from the public controller. +Tab owners register `registerCloseHandler(kind, handler)` through an effect. A handler synchronously retains any background cleanup before allowing explicit close or replacement. The resource owner tracks completion and retry; the sidebar does not wait. A thrown handler preserves the tab. Collapse, presentation changes and plugin disposal do not invoke close handlers; the tab abort signal identifies occurrence disposal, not an explicit close. + ## The guide -Default pages depend on the number of registered guide entries, not the number of tab types or open tabs. Exactly one entry opens its page directly (Files in the shipped composition); zero or multiple entries open the guide. Explicitly adding a guide still opens the guide, even with one entry. The sole docked guide is the only tab that cannot close; closing any other sole tab also collapses the column. The chip, context menu, and `close` API apply the same rule. +A tab type can declare `multiple: true` for independent instances such as terminals. Each open receives a distinct content address, so placement and docking preserve those instances; ordinary pages retain per-pane uniqueness. + +Default pages depend on the number of registered guide entries, not the number of tab types or open tabs. Exactly one entry opens its page directly; zero or multiple entries open the guide. Explicitly adding a guide still opens the guide, even with one entry. The sole docked guide is the only tab that cannot close; closing any other sole tab also collapses the column. The chip, context menu, and `close` API apply the same rule. The guide tab is a muted compass over one entry capsule per `guide` entry the registered types contributed, in `order`, centred in the body; the guide has no words of its own. A capsule shows the entry's glyph — or the guide's quieter cube placeholder when the entry registered none — and its title; while at most four entries are listed, an entry that registered a `description` shows it under the title, and a longer list drops every description. Picking a capsule calls `tab.actions.openTab(entry.kind, { replaceTab: true })`, so the guide gives way to the page it opened. A pane holds at most one guide tab. The strip's add control is drawn only while its pane holds none and opens one there with `openTab('guide', { paneId, revealIfOpened: false })`, so a guide in another pane does not capture the click; opening the guide into a pane that already has one focuses it instead; a guide dragged, dropped, or docked into such a pane merges into it — the arriving guide closes and the pane's own is focused; `duplicateTab` on the guide records nothing. A split, an expanded empty root pane, and the pane a sole tab vacates by dropping on its own edge use the same default-page rule, one tab per new pane; the self-edge drop leaves the dragged tab focused. A plain `openTab('guide')` opens or focuses the guide only within the active or named pane. Splitting an empty pane does nothing and returns no new pane. The product allows two horizontal panes, initially equal, with divider ratios limited to 20%–80%. Insufficient width blocks a new split; with two panes already present, a body drop moves the tab between panes instead of creating a third. At the two-pane limit, split controls are hidden; closing back to one pane restores them. @@ -118,7 +122,7 @@ None; this package neither assembles nor sends a provider request. -- **Memory-only.** Nothing is persisted; a reload starts every session collapsed. +- **Memory-only layout.** Sidebar state is not persisted; content-specific recovery cannot reproduce the previous layout or selection. - **No surface without a session.** State is keyed by session id, so the hero screen shows nothing on the right. - **Hard-coded stacking.** The panel and the float host use fixed z-index values because the client has no z-index token layer yet. - **Undo is not exposed.** The recorded sequence is stepped only through the `@internal` service methods; product controls are deliberately absent. diff --git a/packages/client/ui-sidebar-right/README.zh.md b/packages/client/ui-sidebar-right/README.zh.md index 641a8fab40..203bc30d05 100644 --- a/packages/client/ui-sidebar-right/README.zh.md +++ b/packages/client/ui-sidebar-right/README.zh.md @@ -67,7 +67,7 @@ kind: "package-reference" 停靠面的最后一个 tab 还多带一条规则,由 store 的 `closeTab` 决定并经 `canCloseTab` 镜像给套件:作为唯一停靠 tab 的引导页不画关闭控件也不画菜单里的关闭项——它的 chip 呈安静样式,在没有扩展条目时次键按下也不弹出菜单——对它的编程式关闭什么都不记录;任何其它 tab 独自留下时,点击关闭会连同整列一起收起,记为一条历史,布局保持为空,直到下次展开时创建当时的默认页。浮动面板不参与这条规则:它们无论列是否展开都会渲染,其 tab 照常关闭。 -状态只在内存中。刷新会让每个会话回到折叠的默认态;切换会话则让每个停靠面留在原处。 +布局、打开的标签页、导航参数和选中项只在内存中。刷新会构造折叠的默认态;内容插件随后可重新打开自己仍存活的内容,例如[终端恢复](../ui-sidebar-terminal/README.zh.md#use-this-package)。切换会话则让每个停靠面留在原处。 ## 扩展席位 @@ -93,10 +93,14 @@ tab 类型分两阶段注册,随包发布的引导类型走的正是别的包 Tab域按(Session,Tab id)保留导航、中止信号与绑定动作;私有装配回调收养各会话的store,并在每次提交时对齐记录。记录消失或插件卸载才中止signal,收起和切会话不销毁记录;undo恢复的是新occurrence。`useTabInfo()` 组合框架绑定的store与导航hook,不在组件中手写订阅或在渲染时创建记录。`tab.actions` 始终作用于自己的会话;`tab.visible` 区分正文与标题,浮窗不受整栏收起影响。`adopt` 不在公开控制器上。 +标签页所有者通过 effect 注册 `registerCloseHandler(kind, handler)`。handler 在允许显式关闭或替换前同步保存后台清理任务。资源所有者跟踪完成和重试,侧栏不等待清理。handler 抛错时保留标签页。折叠、展示方式改变和插件卸载不调用关闭 handler;tab abort signal 标识 occurrence 卸载,不代表显式关闭。 + ## 引导页 -默认页取决于已注册的引导入口数,不取决于 tab 类型数或已打开的 tab 数。恰好一个入口时直接打开对应页面(随包组合中为 Files);没有入口或有多个入口时打开引导页。即使只有一个入口,显式添加引导页仍会打开引导页。只有作为唯一停靠 tab 的引导页不可关闭;关闭其它任何唯一 tab 时会同时收起整列。chip、上下文菜单与 `close` API 使用同一规则。 +终端等需要独立实例的 tab 类型可声明 `multiple: true`。每次打开都会获得独立内容地址,因此移动和停靠会保留这些实例;普通页面仍在每个格内去重。 + +默认页取决于已注册的引导入口数,不取决于 tab 类型数或已打开的 tab 数。恰好一个入口时直接打开对应页面;没有入口或有多个入口时打开引导页。即使只有一个入口,显式添加引导页仍会打开引导页。只有作为唯一停靠 tab 的引导页不可关闭;关闭其它任何唯一 tab 时会同时收起整列。chip、上下文菜单与 `close` API 使用同一规则。 引导 tab 是一枚弱化的罗盘,下方是各已注册类型贡献的每个 `guide` 条目一个入口胶囊,按 `order` 排列并在正文中居中;引导页自己没有文字。胶囊显示条目的图标——条目没注册图标时用引导页自己更浅的立方体占位符——和标题;列出的条目不超过四个时,注册了 `description` 的条目在标题下显示它,更长的列表则去掉所有描述。选中一个胶囊会调用 `tab.actions.openTab(entry.kind, { replaceTab: true })`,于是引导页让位给它打开的页。一个格最多持有一个引导 tab。tab 条的添加控件只在该格没有引导 tab 时绘制,并以 `openTab('guide', { paneId, revealIfOpened: false })` 在该格打开一个,这样别的格里的引导页不会截走这次点击;把引导页开进已有引导页的格则改为聚焦它;把引导页拖入、放入或收回到这样的格会合并进去——来者关闭,该格自己的被聚焦;对引导页 `duplicateTab` 不记录任何东西。分栏、展开且为空的根格、以及唯一 tab 拖到本格边缘分屏后腾出的格使用相同的默认页规则,每个新格一个 tab;这种本格边缘拖放让被拖的 tab 保持聚焦。普通的 `openTab('guide')` 只在活跃或指定分栏内打开或聚焦引导页。对空分栏执行 split 不产生变化,也不返回新分栏。产品最多保留左右两格,默认均分,分隔条限定在 20%~80%。宽度不足以容纳两格时不允许新分栏;已有两格时,正文拖放用于跨格移动,不再创建第三格。达到两格上限时隐藏分栏控件;关闭回单格后恢复。 @@ -118,7 +122,7 @@ Tab域按(Session,Tab id)保留导航、中止信号与绑定动作;私 -- **只在内存中。** 不持久化任何东西;刷新让每个会话从折叠态开始。 +- **布局只在内存中。** 侧栏状态不持久化;内容自身的恢复无法还原此前的布局或选中项。 - **没有会话就没有停靠面。** 状态按会话 id 键控,因此 hero 画面右侧什么都不显示。 - **硬编码的层叠。** 面板与浮窗宿主使用固定的 z-index 值,因为客户端还没有 z-index token 层。 - **未暴露撤销。** 记录的序列只能通过 `@internal` 服务方法步进;产品控件是有意缺席的。 diff --git a/packages/client/ui-sidebar-right/package.json b/packages/client/ui-sidebar-right/package.json index 05c813fcc2..5a1495b09a 100644 --- a/packages/client/ui-sidebar-right/package.json +++ b/packages/client/ui-sidebar-right/package.json @@ -67,7 +67,8 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "picomatch": "^4.0.4", - "clsx": "^2.0.0" + "clsx": "^2.0.0", + "@deepseek-ai/dsh-util-crypto": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-sidebar-right/src/client/index.ts b/packages/client/ui-sidebar-right/src/client/index.ts index 6e2df4fe40..5ffcbd34e1 100644 --- a/packages/client/ui-sidebar-right/src/client/index.ts +++ b/packages/client/ui-sidebar-right/src/client/index.ts @@ -49,7 +49,7 @@ export type { ExpandButtonProps } from './shell/ExpandButton.tsx' export type { SidebarRightState, SurfaceState } from './stores.ts' export type { ISidebarRight, SidebarRightBinding, SidebarRightOpenResourceOptions, SidebarRightOpenTabOptions, - SidebarRightPlacement, SurfaceActions, + SidebarRightPlacement, SidebarRightCloseHandler, SurfaceActions, } from './service.ts' export type { SidebarRightGuideBox, SidebarRightGuideEntry, SidebarRightTabClaim, SidebarRightTabDefinition, @@ -135,7 +135,7 @@ export function apply(ctx: ClientContext): void { }, } const layout: ILayout = ctx.layout - const injected: Omit = { + const injected: Omit = { syncPresentation({ shown, track, fullscreen }) { if (shown) layout.openRightbar(track, fullscreen) else layout.closeRightbar() @@ -162,6 +162,10 @@ export function apply(ctx: ClientContext): void { store, inject: (sessionId): SidebarRightInjected => ({ ...injected, + closeTab: (tabId) => { + try { controller.closeIn(sessionId, tabId) } + catch (error) { console.error('Sidebar tab close failed:', error) } + }, keyedHooks: { tabNavigation: key => controller.tabDomain.occurrence(sessionId, { id: key as TabId }).navigation }, occurrence: tab => controller.tabDomain.occurrence(sessionId, tab), }), diff --git a/packages/client/ui-sidebar-right/src/client/service.ts b/packages/client/ui-sidebar-right/src/client/service.ts index e80a291a4f..def6c222cc 100644 --- a/packages/client/ui-sidebar-right/src/client/service.ts +++ b/packages/client/ui-sidebar-right/src/client/service.ts @@ -27,14 +27,15 @@ * The registration adopts Session stores and injects the mounted seat binding; * callers use the service's navigation methods. */ +import { randomUUID } from '@deepseek-ai/dsh-util-crypto' import type { FloatRect, PaneId, TabId, TabRecord } from '@deepseek-ai/dsh-client-ui-dockkit' -import { activeDockPaneId, canSplit, dockPaneIds, findTabPane, getPane } from '@deepseek-ai/dsh-client-ui-dockkit' +import { activeDockPaneId, canSplit, findContentTab, dockPaneIds, findTabPane, getPane } from '@deepseek-ai/dsh-client-ui-dockkit' import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SidebarRightNavigationParams, SidebarRightResourceParams, SidebarRightTabParamsFor } from './contract/params.ts' import { pageAddress } from './contract/seed.ts' import type { SidebarRightTabClaim, SidebarRightTabRegistry } from './tab-registry.ts' -import type { SidebarRightState, SurfaceState } from './stores.ts' +import { canCloseTab, type SidebarRightState, type SurfaceState } from './stores.ts' import type { createSidebarRightStore } from './stores.ts' import { TabDomain, type PinResource } from './tab-domain.ts' @@ -134,6 +135,9 @@ export interface SidebarRightOpenTabOptions extends S /** The scheme every resource address carries; anything else is not a resource this face opens. */ const RESOURCE_SCHEME = 'dsh-resource://' +/** Synchronous close/replacement hook; resource owners retain any background cleanup. */ +export type SidebarRightCloseHandler = (sessionId: SessionId, tab: TabRecord) => void + /** The outward right-Sidebar face (`ctx.sidebarRight`). */ export interface ISidebarRight { /** @@ -203,6 +207,19 @@ export interface ISidebarRight { /** Cross-plugin right-Sidebar face (ctx.sidebarRight). */ export class SidebarRightController implements ISidebarRight { private binding: SidebarRightBinding | undefined + private readonly closeHandlers = new Map() + + /** + * Register resource cleanup before explicit removal. Failure preserves the tab. + * @param kind - tab kind owned by the registering plugin. + * @param handler - saves any background cleanup before returning and allowing removal. + * @returns an effect-scoped unregister callback. + */ + registerCloseHandler(kind: string, handler: SidebarRightCloseHandler): () => void { + if (this.closeHandlers.has(kind)) throw new Error(`sidebarRight: close handler already registered for ${kind}`) + this.closeHandlers.set(kind, handler) + return () => { if (this.closeHandlers.get(kind) === handler) this.closeHandlers.delete(kind) } + } /** * The Tab domain this controller navigates into; synced from each adopted @@ -294,7 +311,16 @@ export class SidebarRightController implements ISidebarRight { */ closeIn(sessionId: SessionId, tabId: TabId): void { const actions = this.actionsFor(sessionId) - if (actions !== undefined) actions.closeTab(sessionId, tabId) + const surface = this.adopted.get(sessionId)?.store.getSnapshot().bySession[sessionId] + if (actions === undefined || surface === undefined) return + const tab = surface.layout.tabs[tabId] + if (tab === undefined || !canCloseTab(surface, tabId)) return + this.removeAfterCleanup(sessionId, tab, () => { actions.closeTab(sessionId, tabId) }) + } + + private removeAfterCleanup(sessionId: SessionId, tab: TabRecord, commit: () => void): void { + this.closeHandlers.get(tab.kind)?.(sessionId, tab) + commit() } /** Claim a resource and place it in one session; an address outside the scheme or one no type claims throws. */ @@ -319,7 +345,7 @@ export class SidebarRightController implements ISidebarRight { ): void { const definition = this.tabs.get(kind) if (definition === undefined) throw new Error(`sidebarRight: no tab type is registered as "${kind}"`) - const address = pageAddress(kind) + const address = definition.multiple === true ? `${pageAddress(kind)}/${randomUUID()}` : pageAddress(kind) this.place(sessionId, actions, { kind, contentId: address, title: definition.title(address) }, address, options, options.params) } @@ -332,14 +358,20 @@ export class SidebarRightController implements ISidebarRight { placement: SidebarRightPlacement, params: SidebarRightNavigationParams, ): void { - actions.openContent(sessionId, { + const commit = (): void => { actions.openContent(sessionId, { kind: claim.kind, contentId: claim.contentId, title: claim.title, ...placement.paneId === undefined ? {} : { paneId: placement.paneId }, ...placement.replaceTab === undefined ? {} : { replaceTab: placement.replaceTab }, ...placement.revealIfOpened === undefined ? {} : { revealIfOpened: placement.revealIfOpened }, - }, (tabId) => { this.tabDomain.navigate(sessionId, tabId, { address, params }) }) + }, (tabId) => { this.tabDomain.navigate(sessionId, tabId, { address, params }) }) } + const layout = this.adopted.get(sessionId)?.store.getSnapshot().bySession[sessionId]?.layout + const replaced = placement.replaceTab === undefined ? undefined : layout?.tabs[placement.replaceTab] + const revealed = layout === undefined || placement.revealIfOpened === false + ? undefined : findContentTab(layout, claim.contentId, claim.kind) + if (replaced === undefined || replaced.id === revealed) { commit(); return } + this.removeAfterCleanup(sessionId, replaced, commit) } /** @@ -348,6 +380,7 @@ export class SidebarRightController implements ISidebarRight { */ close(tabId: TabId): void { const { sessionId, actions } = this.require() + if (this.adopted.has(sessionId)) { this.closeIn(sessionId, tabId); return } actions.closeTab(sessionId, tabId) } diff --git a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx index 355eedc190..476134c47d 100644 --- a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx +++ b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx @@ -97,6 +97,8 @@ export interface SidebarRightInjected { * the guide opened by kind, through the same path as every other open. */ readonly openTab: (kind: string, options?: SidebarRightOpenTabOptions) => void + /** Close through the resource owner's cleanup handler. */ + readonly closeTab: (tabId: TabId) => void readonly hooks: { readonly tabTypes: HostObservable } @@ -123,6 +125,7 @@ interface PanelProps { readonly t: RightbarSeatProps['t'] readonly renderSlot: Children['renderSlot'] readonly openTab: SidebarRightInjected['openTab'] + readonly closeTab: SidebarRightInjected['closeTab'] readonly useTabTypes: RightbarSeatProps['useTabTypes'] readonly useTabNavigation: RightbarSeatProps['useTabNavigation'] readonly useStore: Store['useStore'] @@ -145,7 +148,7 @@ function guideIn(layout: LayoutState, paneId: PaneId): TabId | undefined { * @param openTab - the navigation face's `openTab`, which the strip's add control asks for a guide through. * @returns the intents the kit reports gestures to. */ -export function intentsFor(sessionId: SessionId, actions: Store['actions'], openTab: PanelProps['openTab']): DockIntents { +export function intentsFor(sessionId: SessionId, actions: Store['actions'], openTab: PanelProps['openTab'], closeTab?: PanelProps['closeTab']): DockIntents { return { focusTab: (tabId) => { actions.focusTab(sessionId, tabId) }, focusPane: (paneId) => { actions.focusPane(sessionId, paneId) }, @@ -155,7 +158,7 @@ export function intentsFor(sessionId: SessionId, actions: Store['actions'], open // guides in other panes; the store settles the open on a guide the pane // already holds, so the ask is idempotent all the same. addTab: (paneId) => { openTab(GUIDE_KIND, { paneId, revealIfOpened: false }) }, - closeTab: (tabId) => { actions.closeTab(sessionId, tabId) }, + closeTab: closeTab ?? ((tabId) => { actions.closeTab(sessionId, tabId) }), duplicateTab: (tabId) => { actions.duplicateTab(sessionId, tabId) }, floatTab: (tabId, rect?: FloatRect) => { actions.floatTab(sessionId, tabId, rect) }, unfloatPane: (paneId) => { actions.unfloatPane(sessionId, paneId) }, @@ -311,7 +314,7 @@ function SidebarPanel(panel: PanelProps & { width: number; panelRef: RefObject guideIn(surface.layout, paneId) === undefined} canCloseTab={tabId => canCloseTab(surface, tabId)} - intents={intentsFor(sessionId, actions, openTab)} + intents={intentsFor(sessionId, actions, openTab, panel.closeTab)} labels={dockLabels(t)} renderTab={bodiesFor(panel)} renderTabTitle={titlesFor(panel)} @@ -334,7 +337,7 @@ function Floats(panel: PanelProps): ReactNode { canCloseTab(surface, tabId)} - intents={intentsFor(sessionId, actions, openTab)} + intents={intentsFor(sessionId, actions, openTab, panel.closeTab)} labels={dockLabels(t)} renderTab={bodiesFor(panel)} renderTabTitle={titlesFor(panel)} @@ -351,7 +354,7 @@ function Floats(panel: PanelProps): ReactNode { * which session it is acting on, because this is the seat that knows both. */ export function RightbarSeat({ - sessionId, width, viewportWidth, canShow, useStore, actions, t, renderSlot, syncPresentation, bindService, openTab, + sessionId, width, viewportWidth, canShow, useStore, actions, t, renderSlot, syncPresentation, bindService, openTab, closeTab, useTabTypes, useTabNavigation, occurrence, }: RightbarSeatProps): ReactNode { // One store instance per session, so this map holds this session's surface. @@ -416,7 +419,7 @@ export function RightbarSeat({ if (surface === undefined) return null const panel: PanelProps = { - sessionId, actions, t, renderSlot, surface, openTab, useTabTypes, useTabNavigation, useStore, occurrence, + sessionId, actions, t, renderSlot, surface, openTab, closeTab, useTabTypes, useTabNavigation, useStore, occurrence, fullscreen, autoFullscreen, reportRoom, } return ( diff --git a/packages/client/ui-sidebar-right/src/client/tab-registry.ts b/packages/client/ui-sidebar-right/src/client/tab-registry.ts index 1b93096010..88f676a204 100644 --- a/packages/client/ui-sidebar-right/src/client/tab-registry.ts +++ b/packages/client/ui-sidebar-right/src/client/tab-registry.ts @@ -95,6 +95,8 @@ export interface SidebarRightTabDefinition { readonly id: string /** Type discriminator: what the tabs of this type are, and what `openTab` names. */ readonly kind: string + /** Each open by kind creates independent content; omission keeps one page per kind in each pane. */ + readonly multiple?: boolean /** * Resource-address globs this type recognizes; omit for a page type, which is * opened by kind and recognizes no address. diff --git a/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx b/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx index e88c5b9563..d809426429 100644 --- a/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx @@ -80,7 +80,7 @@ describe('GuideBody', () => { cleanup() }) - it('picking a box opens that type in the guide\'s own place', () => { + it('picking a box without a reveal preference preserves the default opening behavior', () => { const { view, openTab } = mountGuide([box('files', 10)]) const entry = view.container.querySelector('[data-sidebar-right-guide-entry="files"]') if (entry === null) throw new Error('expected the files box') @@ -89,6 +89,7 @@ describe('GuideBody', () => { cleanup() }) + it('draws an empty guide while no type contributed an entry, and follows the registry when one does', () => { const { view, guideEntries, boxes } = mountGuide([]) expect(view.container.querySelector('[data-sidebar-right-guide]')).not.toBeNull() diff --git a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx index ac1a0173b6..58c0368169 100644 --- a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx @@ -585,3 +585,22 @@ describe('intentsFor — the kit\'s gestures as one session\'s store actions', ( expect(openTab).toHaveBeenCalledWith('guide', { paneId: PANE_1, revealIfOpened: false }) }) }) + +it('keeps a resource tab and reports a synchronous cleanup failure from its close button', async () => { + const h = await mountSeat() + const tab = h.open('terminal') + const failure = new Error('process still running') + const release = h.controller.registerCloseHandler('text', () => { throw failure }) + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + fireEvent.click(element(h.view.container, `[data-dockkit-tab-close="${tab.id}"]`)) + await expect.poll(() => logged.mock.calls).toEqual([['Sidebar tab close failed:', failure]]) + expect(h.layout().tabs[tab.id]).toBeDefined() + release() + fireEvent.click(element(h.view.container, `[data-dockkit-tab-close="${tab.id}"]`)) + expect(h.layout().tabs[tab.id]).toBeUndefined() + } finally { + logged.mockRestore() + release() + } +}) diff --git a/packages/client/ui-sidebar-right/tests/service.client.spec.ts b/packages/client/ui-sidebar-right/tests/service.client.spec.ts index dc4b16d5c5..de35bc78f0 100644 --- a/packages/client/ui-sidebar-right/tests/service.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/service.client.spec.ts @@ -80,6 +80,25 @@ function harness() { } describe('SidebarRightController — opening', () => { + it('keeps independently opened instances distinct when they return to the same pane', () => { + const h = harness() + h.tabs.register({ id: 'test/terminal', kind: 'terminal', multiple: true, title: () => 'terminal' }) + const release = h.publish() + try { + h.controller.openTab('terminal') + h.controller.openTab('terminal') + const terminals = Object.values(h.layout().tabs).filter(tab => tab.kind === 'terminal') + expect(terminals).toHaveLength(2) + expect(terminals[0]!.contentId).not.toBe(terminals[1]!.contentId) + const pane = findTabPane(h.layout(), terminals[0]!.id).id + h.instance.actions.floatTab(SESSION, terminals[0]!.id) + const floating = findTabPane(h.layout(), terminals[0]!.id).id + h.instance.actions.unfloatPane(SESSION, floating) + expect(getPane(h.layout(), pane).tabs).toContain(terminals[0]!.id) + expect(getPane(h.layout(), pane).tabs).toContain(terminals[1]!.id) + } finally { release() } + }) + it('refuses every write while no seat is mounted', () => { const { controller } = harness() expect(() => { controller.openResource('dsh-resource://file/session/s-test/a.txt') }).toThrow('no session surface is mounted') @@ -590,3 +609,89 @@ describe('SidebarRightController — binding lifetime', () => { expect(() => { controller.toggleExpanded() }).not.toThrow() }) }) + +describe('explicit tab resource cleanup', () => { + it.each(['close', 'replace'] as const)('immediately removes the tab after a synchronous cleanup handler during %s', (operation) => { + const h = harness() + const release = h.adopt(SESSION, h.instance) + const unbind = h.publish() + h.controller.openResource('dsh-resource://file/session/s-test/terminal') + const tabId = h.tabOf('terminal') + const original = h.layout().tabs[tabId] + const handler = vi.fn(() => { expect(h.layout().tabs[tabId]).toBe(original) }) + const unregister = h.controller.registerCloseHandler('text', handler) + try { + const before = h.entries() + if (operation === 'close') h.controller.close(tabId) + else h.controller.openTab('guide', { replaceTab: tabId, revealIfOpened: false }) + expect(handler).toHaveBeenCalledExactlyOnceWith(SESSION, original) + expect(h.layout().tabs[tabId]).toBeUndefined() + expect(h.entries()).toBe(before + 1) + h.controller.closeIn(SESSION, tabId) + expect(handler).toHaveBeenCalledOnce() + } finally { + unregister() + unbind() + release() + } + }) + + it('does not invoke cleanup on collapse or when revealing the same tab', () => { + const h = harness() + h.adopt(SESSION, h.instance) + h.publish() + const address = 'dsh-resource://file/session/s-test/terminal' + h.controller.openResource(address) + h.publish() + const tabId = h.tabOf('terminal') + const handler = vi.fn() + h.controller.registerCloseHandler('text', handler) + h.controller.toggleExpanded() + h.controller.openResource(address, { replaceTab: tabId }) + expect(handler).not.toHaveBeenCalled() + expect(h.layout().tabs[tabId]).toBeDefined() + }) + + it.each(['close', 'replace'] as const)('preserves a tab when its synchronous %s handler throws', (operation) => { + const h = harness() + h.adopt(SESSION, h.instance) + h.publish() + h.controller.openResource('dsh-resource://file/session/s-test/terminal') + const tabId = h.tabOf('terminal') + const release = h.controller.registerCloseHandler('text', () => { throw new Error('cannot retain cleanup') }) + const perform = () => { + if (operation === 'close') h.controller.closeIn(SESSION, tabId) + else h.controller.openTab('guide', { replaceTab: tabId, revealIfOpened: false }) + } + expect(perform).toThrow('cannot retain cleanup') + expect(h.layout().tabs[tabId]).toBeDefined() + release() + perform() + expect(h.layout().tabs[tabId]).toBeUndefined() + }) +}) + +it('releases close handlers without a stale disposer removing a replacement', () => { + const h = harness() + h.adopt(SESSION, h.instance) + h.publish() + const first = vi.fn(() => {}) + const second = vi.fn(() => {}) + const release = h.controller.registerCloseHandler('text', first) + expect(() => h.controller.registerCloseHandler('text', second)).toThrow('already registered') + release() + const releaseSecond = h.controller.registerCloseHandler('text', second) + release() + h.controller.openResource('dsh-resource://file/session/s-test/one') + h.publish() + h.controller.closeIn(SESSION, h.tabOf('one')) + expect(first).not.toHaveBeenCalled() + expect(second).toHaveBeenCalledOnce() + releaseSecond() + h.controller.openResource('dsh-resource://file/session/s-test/two') + h.publish() + const tabId = h.tabOf('two') + h.controller.openTab('guide', { replaceTab: tabId, revealIfOpened: false }) + expect(h.layout().tabs[tabId]).toBeUndefined() + expect(second).toHaveBeenCalledOnce() +}) diff --git a/packages/client/ui-sidebar-terminal/README.i18n.yaml b/packages/client/ui-sidebar-terminal/README.i18n.yaml new file mode 100644 index 0000000000..ad7c48a193 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/client/ui-sidebar-terminal/README.md +README.md: dec4c0a88649b74e90afa23ef07d3f11a9dc67cb +README.zh.md: a10cde602ad4edfd13652a93535c4f5d7fd26b52 diff --git a/packages/client/ui-sidebar-terminal/README.md b/packages/client/ui-sidebar-terminal/README.md new file mode 100644 index 0000000000..dec4c0a886 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/README.md @@ -0,0 +1,80 @@ +--- +description: "Open, recover and control interactive shell tabs in the Web right sidebar." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-sidebar-terminal + +English | [中文](README.zh.md) + +## Summary + +Choose an installed shell from the right sidebar's Start page to run commands in the Session workspace. Rename terminals in their tabs and recover retained processes after reloading the page. Collapse the sidebar to keep commands running; close a terminal tab to request process termination. Tab completion follows the shell configuration. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Open the right sidebar and select **New terminal** on the Start page; first select **New tab** when another content tab is open. Choose an installed **Shell** and select **Start terminal**. The last selected shell is preselected from localStorage; first use or an unavailable saved choice selects the current default. Use the sidebar's **New tab** control to open more terminals. + +Double-click the terminal's tab title to rename it. **Take control** makes the current attachment writable when another page owns input. A failed connection offers **Reconnect**. An exited shell remains visible with its exit code and never restarts automatically. Exited terminals count toward the Session limit; close unused tabs when the limit is reached. + +Closing or replacing a terminal tab removes it immediately and ends its process in the background. A cleanup failure shows a small notification with **Retry**; retrying does not reopen the tab. Collapsing, switching tabs or Sessions, floating and fullscreen presentation preserve the process. + +Displaying a Session after a page reload reopens its retained Host terminals as new tabs. A recovery failure offers **Retry terminal recovery**. A recovered process that disappears reports an error instead of starting another shell. The [sidebar layout remains memory-only](../../client/ui-sidebar-right/README.md#state). + + +## Understand the implementation + +
+Implementation internals — click to expand + +This plugin registers the `terminal` type and body/title seats with the right sidebar. The guide uses a compact dark terminal card, while tab titles retain the line glyph. The React-free terminal model belongs to `api-terminal-controller`; keyed framework hooks expose its state. `ui-primitives` Menu and Button provide the shell picker and startup controls, including keyboard navigation and the selected-item marker. xterm.js and FitAddon render the screen and measure the viewport. The body reserves an 8px gap below the tab strip within the pane height. Input, including Tab and control characters, travels unchanged to the PTY. + +A Session header contribution queries Host terminals and opens recovery tabs. Their navigation parameters carry `terminalId` only within the current page; a recovered view cannot allocate a replacement process. The sidebar's close handler schedules cleanup through the [terminal controller](../../api/terminal-controller/README.md#understand-the-implementation) and returns synchronously. Browser component cleanup and the tab's abort signal only detach browser work. + +
+ + +## Further Exploration + +- [Subprocess](../../subprocess/subprocess/README.md) +- [Right Sidebar](../../client/ui-sidebar-right/README.md) +- [Web terminal decision](../../../.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md) + + +## Model Experience + +None, as this package handles user terminal interaction without adding model input. + +#### KV Cache effect + +None; terminal output travels only between the browser and Host. + +## Known Limitations and Deferred Work + + + +- Shell discovery or native PTY startup can fail. The tab reports the failure without launching a different shell. +- Completion menus and inline suggestions depend on shell configuration. The Web UI adds no independent completion engine. +- Terminal history is bounded. The feature does not send terminal output to the Agent, provide split terminal panes inside a tab, or restore processes after Host restart. + + +### Dev Note + +
+Working context for maintainers — click to expand + +No runtime invariant companion is published. One owner orders terminal metadata and screen updates; the provider exposes no independently observed dimensions to compare. + +
diff --git a/packages/client/ui-sidebar-terminal/README.zh.md b/packages/client/ui-sidebar-terminal/README.zh.md new file mode 100644 index 0000000000..a10cde602a --- /dev/null +++ b/packages/client/ui-sidebar-terminal/README.zh.md @@ -0,0 +1,80 @@ +--- +description: "在 Web 右侧栏打开、恢复和控制交互式 shell 标签页。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-sidebar-terminal + +[English](README.md) | 中文 + +## 概述 + +从右侧栏开始页选择已安装的 shell,在会话工作区运行命令。在标签页上重命名终端,并在刷新页面后恢复保留的进程。折叠侧栏让命令继续运行,关闭终端标签页则请求结束进程。Tab 补全使用 shell 的配置。 + +## 目录 + +- [使用此包](#use-this-package) +- [了解实现](#understand-the-implementation) +- [延伸阅读](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用此包 + +展开右侧栏,在开始页选择**新建终端**;已有其他标签页时,先选择**新标签页**。选择已安装的 **Shell**,再点击**启动终端**。localStorage 会记住上次选择的 shell 并预选;首次使用或已保存选项不可用时,预选当前默认项。通过侧栏的**新标签页**控件打开更多终端。 + +双击终端标签页标题即可重命名。另一页面持有输入权时,**接管输入**让当前连接可写。连接失败时可以**重新连接**。shell 退出后仍显示屏幕和退出码,不自动重启。 已退出的终端计入 Session 数量上限;达到上限时请关闭不用的标签页。 + +关闭或替换终端标签页会立即移除标签页,并在后台结束进程。清理失败时显示带**重试**操作的轻量通知;重试不会重新打开标签页。折叠、切换标签页或 Session、浮动和全屏都保留进程。 + +刷新页面后显示某个 Session,会把 Host 保留的终端重新打开为新标签页。恢复失败时可点击**重试恢复终端**。恢复目标进程消失时显示错误,不启动另一个 shell。[侧栏布局仅保存在内存中](../../client/ui-sidebar-right/README.zh.md#state)。 + + +## 了解实现 + +
+实现细节 + +插件向右侧栏注册 `terminal` 类型及正文和标题 seat。开始页入口使用紧凑的黑底终端图标,页签标题保留线条图标。无 React 依赖的终端模型属于 `api-terminal-controller`,通过框架 keyed hooks 暴露状态。`ui-primitives` 的 Menu 与 Button 提供 shell 选择和启动控件,支持键盘导航与选中标记。xterm.js 与 FitAddon 负责屏幕渲染和视口测量。正文在面板高度内为页签条下方预留 8px 间距。输入原样传到 PTY,包括 Tab 和控制字符。 + +Session header contribution 查询 Host 终端并打开恢复标签页。导航参数中的 `terminalId` 只在当前页面内保留;恢复视图不能分配替代进程。侧栏关闭 handler 通过[终端 controller](../../api/terminal-controller/README.zh.md#understand-the-implementation)安排清理并同步返回。浏览器组件清理和 tab 的 abort signal 只停止浏览器工作。 + +
+ + +## 延伸阅读 + +- [Subprocess](../../subprocess/subprocess/README.zh.md) +- [Right Sidebar](../../client/ui-sidebar-right/README.zh.md) +- [Web terminal decision](../../../.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md) + + +## 模型体验 + +无;此包只处理用户交互式终端,不向模型请求添加内容。 + +#### KV 缓存影响 + +无;终端输出只在浏览器与 Host 之间传输。 + +## 已知限制与延期工作 + + + +- 默认 shell 或原生 PTY 可能启动失败。标签页显示错误,不启动其他 shell。 +- 补全菜单和内联建议取决于 shell 配置,Web UI 不提供独立补全引擎。 +- 终端历史有上限。此功能不向 Agent 发送终端输出,不在单个标签页内拆分终端,也不在 Host 重启后恢复进程。 + + +### 开发备注 + +
+维护说明 + +不发布运行时 invariant companion。终端元数据与屏幕更新由同一对象按序写入,没有独立的进程尺寸观测可供比较。 + +
diff --git a/packages/client/ui-sidebar-terminal/package.json b/packages/client/ui-sidebar-terminal/package.json new file mode 100644 index 0000000000..0f7ebb30d0 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/package.json @@ -0,0 +1,76 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-sidebar-terminal", + "description": "Interactive shell tabs for the right Sidebar", + "version": "0.1.5-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-sidebar-terminal" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-terminal-controller", + "@deepseek-ai/dsh-client-ui-sidebar-right", + "@deepseek-ai/dsh-api-remotes" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-dockkit": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@testing-library/react": "^16.1.0", + "@types/react": "~18.3.1", + "@types/react-dom": "~18.3.0", + "react": "^18.2.0", + "clsx": "^2.0.0", + "react-dom": "^18.2.0", + "@deepseek-ai/dsh-api-terminal-controller": "workspace:^", + "@xterm/xterm": "^6.0.0", + "@xterm/addon-fit": "^0.11.0", + "@deepseek-ai/dsh-client-ui-layout": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^" + }, + "files": [ + "lib/index.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css b/packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css new file mode 100644 index 0000000000..80fa425b11 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css @@ -0,0 +1,14 @@ +.root { display: flex; flex: 1; flex-direction: column; box-sizing: border-box; height: 100%; min-height: 0; padding-top: 8px; color: var(--dsw-alias-label-primary); font-size: 13px; } +.title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.name { width: 120px; min-width: 48px; max-width: 100%; padding: 0 4px; color: inherit; font: inherit; background: var(--dsw-alias-bg-l1); border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 4px; outline: none; } +.screen { flex: 1; min-height: 0; min-width: 0; overflow: hidden; padding: 8px; background: #17191d; } +.screen :global(.xterm) { height: 100%; } +.status { display: flex; align-items: center; gap: 8px; padding: 5px 12px; background: var(--dsw-alias-bg-l2); } +.status button { padding: 6px 10px; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 6px; color: inherit; background: transparent; cursor: pointer; } +.status button:hover { background: var(--dsw-alias-bg-l2); } +.error { margin: 0; padding: 10px 12px; overflow-wrap: anywhere; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-l2); } +.launch { display: flex; align-items: end; flex-wrap: wrap; gap: 8px; padding: 12px; } +.shellField { display: flex; flex: 1; flex-direction: column; gap: 6px; min-width: 0; } +.shellMenu { width: 100%; min-width: 0; } +.shellTrigger { width: 100%; min-width: 0; justify-content: space-between; gap: 8px; } +.shellValue { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx b/packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx new file mode 100644 index 0000000000..d9adf4fae2 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx @@ -0,0 +1,159 @@ +/** Sidebar terminal screen and connection recovery. */ +import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react' +import { Button, Menu, IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import { Terminal } from '@xterm/xterm' +import { FitAddon } from '@xterm/addon-fit' +import type { TerminalViewState, TerminalView } from '@deepseek-ai/dsh-api-terminal-controller/client' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { TerminalInjected } from './face.ts' +import type {} from './locales.ts' +import '@xterm/xterm/css/xterm.css' +import css from './TerminalBody.module.css' + +/** Standard sidebar owner share plus terminal model and localized copy. */ +export type TerminalBodyProps = PropsRuntime<'sidebar.right.pane.tab'> & PropsLocale<'sidebarTerminal'> & InjectFace + +/** + * Offer shell selection for a new terminal or mount a restored screen. + * @param props - sidebar occurrence, model lookup and translated copy. + * @returns the terminal screen and any pending or exceptional state. + */ +export function TerminalBody({ useTabInfo, useTerminal, view, t }: TerminalBodyProps): ReactNode { + const { tab } = useTabInfo() + const model = view(tab.id) + const state = useTerminal(tab.id) + useEffect(() => model.mount(), [model]) + if (state === undefined) return null + const error = state.issue === undefined ? state.error ?? state.info?.error : t(state.issue) + let status: string | undefined + if (state.phase === 'idle' || state.phase === 'loading') status = t('loading') + else if (state.phase === 'creating' || state.phase === 'connecting' || state.phase === 'disconnected') status = t(state.phase) + else if (state.info?.state === 'exited') status = t('exited', { code: String(state.info.exitCode ?? '—') }) + else if (state.info?.state === 'failed') status = t('unavailable') + else if (state.phase === 'closed') status = t('closed') + const retry = state.phase === 'failed' || state.phase === 'disconnected' + const readOnly = state.phase === 'connected' && state.info?.state === 'running' && !state.writable + return ( +
+ {state.phase === 'selecting' && } + {(status !== undefined || retry || readOnly) &&
+ {status} + {readOnly && <>{t('readonly')} } + {retry && (state.info === undefined + ? + : )} +
} + {state.info !== undefined && } + {error !== undefined &&

{t('failed', { message: error })}

} +
+ ) +} + +function TerminalLauncher({ state, model, t }: { + state: TerminalViewState + model: TerminalView + t: TerminalBodyProps['t'] +}): ReactNode { + const [open, setOpen] = useState(false) + const selectionId = useId() + const selected = state.shells?.find(shell => shell.path === state.selectedShell) + return
{ event.preventDefault(); void model.start() }}> +
+ {t('shell')} + ({ id: shell.path, label: `${shell.name} — ${shell.path}` })) ?? []} + selectedId={state.selectedShell} + onClose={() => { setOpen(false) }} + onSelect={(path) => { model.selectShell(path); setOpen(false) }} + anchor={} + /> +
+ +
+} + +/* oxlint-disable typescript/no-non-null-assertion -- React sets the DOM ref, then these effects initialize and use the emulator. */ +function TerminalScreen({ state, model, visible, label }: { + state: TerminalViewState + model: TerminalView + visible: boolean + label: string +}): ReactNode { + const element = useRef(null) + const terminal = useRef() + const fit = useRef() + const lastRevision = useRef(0) + const current = useRef({ state, visible }) + current.current = { state, visible } + + useLayoutEffect(() => { + const node = element.current! + const xterm = new Terminal({ cursorBlink: true, fontSize: 13, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', scrollback: current.current.state.environment?.scrollback ?? 0, theme: { background: '#17191d', foreground: '#e7e9ee' } }) + const addon = new FitAddon() + xterm.loadAddon(addon) + xterm.open(node) + xterm.textarea?.setAttribute('aria-label', label) + terminal.current = xterm + fit.current = addon + lastRevision.current = 0 + const input = xterm.onData((data) => { model.write(data) }) + const measure = (): void => { + if (!current.current.visible || !current.current.state.writable || node.clientWidth === 0 || node.clientHeight === 0) return + fitScreen(xterm, addon, current.current.state, model) + } + const observer = new ResizeObserver(measure) + observer.observe(node) + return () => { + observer.disconnect() + input.dispose() + xterm.dispose() + terminal.current = undefined + fit.current = undefined + } + }, [model]) + + useLayoutEffect(() => { + const xterm = terminal.current! + const render = state.render + if (render === undefined || render.revision <= lastRevision.current) return + lastRevision.current = render.revision + if (render.frame.type === 'snapshot') { + xterm.reset() + xterm.resize(render.frame.info.cols, render.frame.info.rows) + } + xterm.write(render.frame.type === 'snapshot' ? render.frame.screen : render.frame.data, () => { model.acknowledge(render.revision) }) + }, [state.render, model]) + + useLayoutEffect(() => { + const xterm = terminal.current! + xterm.options.disableStdin = !state.writable + if (visible && state.writable && element.current?.clientWidth && element.current.clientHeight) { + fitScreen(xterm, fit.current!, state, model) + } else if (state.info !== undefined && !state.writable) xterm.resize(state.info.cols, state.info.rows) + }, [visible, state.writable, state.info?.cols, state.info?.rows, model]) + useEffect(() => { + terminal.current!.textarea?.setAttribute('aria-label', label) + }, [label]) + useEffect(() => { if (visible && state.writable) terminal.current!.focus() }, [visible, state.writable]) + return
+} +/* oxlint-enable typescript/no-non-null-assertion */ + +function fitScreen(xterm: Terminal, fit: FitAddon, state: TerminalViewState, model: TerminalView): void { + const dimensions = fit.proposeDimensions() + const environment = state.environment + if (dimensions === undefined || environment === undefined) return + const cols = Math.min(dimensions.cols, environment.maxCols) + const rows = Math.min(dimensions.rows, environment.maxRows) + if (cols < 2 || rows < 1) return + xterm.resize(cols, rows) + model.resize(cols, rows) +} diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalCleanup.module.css b/packages/client/ui-sidebar-terminal/src/client/TerminalCleanup.module.css new file mode 100644 index 0000000000..aabc259179 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalCleanup.module.css @@ -0,0 +1,3 @@ +.stack { position: fixed; right: 20px; bottom: 20px; display: grid; gap: 8px; max-width: min(440px, calc(100vw - 40px)); pointer-events: auto; } +.notice { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 8px; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-l1); box-shadow: 0 4px 20px #0002; font-size: 13px; overflow-wrap: anywhere; } +.notice button { flex-shrink: 0; padding: 4px 8px; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 4px; color: inherit; background: transparent; cursor: pointer; } diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalCleanup.tsx b/packages/client/ui-sidebar-terminal/src/client/TerminalCleanup.tsx new file mode 100644 index 0000000000..97783a36a6 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalCleanup.tsx @@ -0,0 +1,29 @@ +/** Failed background cleanup remains actionable after the originating tab disappears. */ +import type { ReactNode } from 'react' +import type { TerminalCloseFailure } from '@deepseek-ai/dsh-api-terminal-controller/client' +import type { WebTerminalId } from '@deepseek-ai/dsh-api-terminal-controller/types' +import type { HostObservable, InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-layout/client' +import type {} from './locales.ts' +import css from './TerminalCleanup.module.css' + +/** The model owns cleanup; this seat only shows failures and requests retries. */ +export interface TerminalCleanupInjected { + readonly hooks: { readonly closeFailures: HostObservable } + /** @param id - failed cleanup identity. */ + readonly retryClose: (id: WebTerminalId) => void +} + +/** + * Render failed cleanup tasks without recreating or blocking any sidebar tab. + * @param props - root overlay hooks, retry command and localized copy. + * @returns a compact alert stack, empty when no close has failed. + */ +export function TerminalCleanup({ useCloseFailures, retryClose, t }: PropsRuntime<'shell.overlay'> & PropsLocale<'sidebarTerminal'> & InjectFace): ReactNode { + const failures = useCloseFailures(value => value) + if (failures.length === 0) return null + return
{failures.map(failure =>
+ {t('cleanupFailed', { title: failure.title, message: failure.message })} + +
)}
+} diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalIcon.tsx b/packages/client/ui-sidebar-terminal/src/client/TerminalIcon.tsx new file mode 100644 index 0000000000..df5525a08d --- /dev/null +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalIcon.tsx @@ -0,0 +1,25 @@ +/** Terminal glyphs for the sidebar guide and tab title. */ +import type { ReactNode } from 'react' +import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives' + +/** + * Render the tab title's terminal prompt in the surrounding text color. + * @returns a decorative sixteen-pixel line glyph. + */ +export function TerminalIcon(): ReactNode { + return +} + +/** + * Render the guide's terminal card within the folder glyph's painted bounds. + * @param props - canvas size and layout class supplied by the guide. + * @returns a dark rounded terminal card with a white prompt. + */ +export function TerminalGuideIcon({ size = 26, className }: IconProps): ReactNode { + return +} diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalRecovery.tsx b/packages/client/ui-sidebar-terminal/src/client/TerminalRecovery.tsx new file mode 100644 index 0000000000..6557af4b3a --- /dev/null +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalRecovery.tsx @@ -0,0 +1,29 @@ +/** A Session header lifetime restores retained Host terminals without saving sidebar layout. */ +import { useEffect, useState, type ReactNode } from 'react' +import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from './locales.ts' + +/** The plugin coordinates once-per-page recovery; the component only owns an error notice. */ +export interface TerminalRecoveryInjected { + /** @returns after retained terminals have been opened as sidebar tabs. */ + readonly restore: () => Promise +} + +/** + * Restore terminals when a Session is displayed, with a retry action on lookup failure. + * @param props - Session header lifetime, restoration callback and localized copy. + * @returns nothing on success, or an unobtrusive retry control. + */ +export function TerminalRecovery({ restore, t }: PropsRuntime<'conversation.session.header.actions'> & PropsLocale<'sidebarTerminal'> & TerminalRecoveryInjected): ReactNode { + const [error, setError] = useState() + const [attempt, setAttempt] = useState(0) + useEffect(() => { + let active = true + void restore().then(() => { if (active) setError(undefined) }, (reason: unknown) => { + if (active) setError(reason instanceof Error ? reason.message : String(reason)) + }) + return () => { active = false } + }, [restore, attempt]) + return error === undefined ? null : +} diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalTitle.tsx b/packages/client/ui-sidebar-terminal/src/client/TerminalTitle.tsx new file mode 100644 index 0000000000..59cb1246b2 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalTitle.tsx @@ -0,0 +1,60 @@ +/** Live terminal names in docked and floating tab chrome. */ +import { useLayoutEffect, useRef, useState, type ReactNode } from 'react' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { TerminalIcon } from './TerminalIcon.tsx' +import type { TerminalInjected } from './face.ts' +import type {} from './locales.ts' +import css from './TerminalBody.module.css' + +/** + * Render the terminal name, editable in place on double-click. + * @param props - sidebar occurrence, terminal model and localized copy. + * @returns the terminal icon and current name or its editor. + */ +export function TerminalTitle({ useTabInfo, useTerminal, view, t }: PropsRuntime<'sidebar.right.pane.tab.title'> & PropsLocale<'sidebarTerminal'> & InjectFace): ReactNode { + const { tab } = useTabInfo() + const title = useTerminal(tab.id, state => state?.info?.title ?? state?.title) ?? tab.title + const [editing, setEditing] = useState(false) + const input = useRef(null) + const label = useRef(null) + const cancelled = useRef(false) + useLayoutEffect(() => { + if (editing) return + // Dockkit captures the pointer on its drag handle, which receives the double-click. + const chip = label.current?.closest('[data-dockkit-tab], [data-dockkit-float-grip]') + const rename = (event: Event): void => { + event.stopPropagation() + cancelled.current = false + setEditing(true) + } + chip?.addEventListener('dblclick', rename) + return () => { chip?.removeEventListener('dblclick', rename) } + }, [editing]) + useLayoutEffect(() => { + if (!editing) return + input.current?.focus() + input.current?.select() + }, [editing]) + return <> + + {editing ? { event.stopPropagation() }} + onClick={(event) => { event.stopPropagation() }} + onDoubleClick={(event) => { event.stopPropagation() }} + onBlur={(event) => { + setEditing(false) + const next = event.currentTarget.value.trim() + if (!cancelled.current && next !== '' && next !== title) void view(tab.id).rename(next) + }} + onKeyDown={(event) => { + event.stopPropagation() + // oxlint-disable-next-line typescript/no-deprecated -- Some IMEs report composition only through keyCode 229. + if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return + if (event.key === 'Escape') { + cancelled.current = true + event.currentTarget.blur() + } else if (event.key === 'Enter') event.currentTarget.blur() + }} /> + : {title}} + +} diff --git a/packages/client/ui-sidebar-terminal/src/client/face.ts b/packages/client/ui-sidebar-terminal/src/client/face.ts new file mode 100644 index 0000000000..95920e23b1 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/src/client/face.ts @@ -0,0 +1,19 @@ +/** Injected terminal commands and keyed observable state. */ +import type { WebTerminalId } from '@deepseek-ai/dsh-api-terminal-controller/types' +import type { TerminalView, TerminalViewState } from '@deepseek-ai/dsh-api-terminal-controller/client' +import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' + +/** The terminal's React-free model is resolved by sidebar occurrence. */ +export interface TerminalInjected { + /** @param key - sidebar occurrence key. @returns its terminal commands. */ + readonly view: (key: string) => TerminalView + readonly keyedHooks: { readonly terminal: (key: string) => HostObservable } +} + + +declare module '@deepseek-ai/dsh-client-ui-sidebar-right/client' { + interface SidebarRightTabParamsMap { + /** An existing Host terminal selected from the Session terminal list. */ + terminal: { terminalId: WebTerminalId } + } +} diff --git a/packages/client/ui-sidebar-terminal/src/client/index.ts b/packages/client/ui-sidebar-terminal/src/client/index.ts new file mode 100644 index 0000000000..cf71fb2f87 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/src/client/index.ts @@ -0,0 +1,81 @@ +/** Register interactive terminal tabs and explicit process cleanup with the sidebar. */ +import type { Context } from '@deepseek-ai/cordis' +import type { WebTerminalId } from '@deepseek-ai/dsh-api-terminal-controller/types' +import type { TabId } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type {} from '@deepseek-ai/dsh-api-terminal-controller/client' +import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' +import { TerminalGuideIcon } from './TerminalIcon.tsx' +import { TerminalBody } from './TerminalBody.tsx' +import { TerminalTitle } from './TerminalTitle.tsx' +import { TerminalRecovery, type TerminalRecoveryInjected } from './TerminalRecovery.tsx' +import { TerminalCleanup, type TerminalCleanupInjected } from './TerminalCleanup.tsx' +import type { TerminalInjected } from './face.ts' +import { en, zh } from './locales.ts' + +/** Services needed by the terminal's two sidebar seats. */ +export const inject = ['slots', 'locale', 'sidebarRight', 'sidebarRightTabs', 'webTerminals'] + +/** + * Register the terminal type, observable views and background process cleanup. + * @param ctx - Client root Context with sidebar and terminal services. + */ +export function apply(ctx: Context): void { + let disposed = false + const recovered = new Map>() + ctx.effect(() => () => { disposed = true; recovered.clear() }, 'ui-sidebar-terminal.lifetime') + const terminalId = (sessionId: SessionId, key: string): WebTerminalId | undefined => { + const params = ctx.sidebarRight.tabDomain.occurrence(sessionId, { id: key as TabId }).navigation.getSnapshot().params + return (params as { terminalId?: WebTerminalId } | undefined)?.terminalId + } + const view = (sessionId: SessionId, key: string) => ctx.webTerminals.view(sessionId, key, terminalId(sessionId, key)) + const namespace = 'sidebarTerminal' + const id = '@deepseek-ai/dsh-client-ui-sidebar-terminal' + const t = ctx.locale.bind(namespace) + ctx.effect(() => ctx.locale.register(namespace, { zh, en }), 'ui-sidebar-terminal.copy') + ctx.effect(() => ctx.sidebarRightTabs.register({ + id, kind: 'terminal', multiple: true, priority: 'builtin', title: () => t('title'), + guide: [{ order: 20, title: () => t('new'), description: () => t('description'), icon: TerminalGuideIcon }], + }), 'ui-sidebar-terminal.type') + ctx.effect(() => ctx.sidebarRight.registerCloseHandler('terminal', (sessionId, tab) => { + ctx.webTerminals.close(sessionId, tab.id, terminalId(sessionId, tab.id)) + }), 'ui-sidebar-terminal.close') + const inject = (sessionId: SessionId): TerminalInjected => ({ + view: key => view(sessionId, key), + keyedHooks: { terminal: key => view(sessionId, key).state }, + }) + ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register( + { name: 'sidebar.right.pane.tab', key: id, locale: namespace, inject }, TerminalBody, + )), 'ui-sidebar-terminal.body') + ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register( + { name: 'sidebar.right.pane.tab.title', key: id, locale: namespace, inject }, TerminalTitle, + )), 'ui-sidebar-terminal.title') + ctx.effect(() => ctx.slots.inject('conversation.session.header.actions', () => ctx.slots.register({ + name: 'conversation.session.header.actions', id, locale: namespace, + inject: (sessionId): TerminalRecoveryInjected => ({ + restore: () => { + let pending = recovered.get(sessionId) + if (pending === undefined) { + pending = ctx.webTerminals.recover(sessionId).then((terminals) => { + if (disposed) return + for (const info of terminals) ctx.sidebarRight.openTabIn(sessionId, 'terminal', { + params: { terminalId: info.id }, + }) + }).catch((error: unknown) => { recovered.delete(sessionId); throw error }) + recovered.set(sessionId, pending) + } + return pending + }, + }), + }, TerminalRecovery)), 'ui-sidebar-terminal.recovery') + ctx.effect(() => ctx.slots.inject('shell.overlay', () => ctx.slots.register({ + name: 'shell.overlay', id, locale: namespace, + inject: (): TerminalCleanupInjected => ({ + hooks: { closeFailures: ctx.webTerminals.closeFailures }, + retryClose: (terminalId) => { ctx.webTerminals.retryClose(terminalId) }, + }), + }, TerminalCleanup)), 'ui-sidebar-terminal.cleanup') +} diff --git a/packages/client/ui-sidebar-terminal/src/client/locales.ts b/packages/client/ui-sidebar-terminal/src/client/locales.ts new file mode 100644 index 0000000000..d6ee5a5d17 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/src/client/locales.ts @@ -0,0 +1,42 @@ +/** Copy owned by the sidebar terminal feature. */ +import type {} from '@deepseek-ai/dsh-client-ui-slots' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + sidebarTerminal: keyof typeof zh + } +} + +/** Simplified Chinese terminal copy. */ +export const zh = { + recoveryFailed: '恢复终端失败:{message}', retryRecovery: '重试恢复终端', + shell: 'Shell', start: '启动终端', description: '在会话工作区运行命令', + title: '终端', new: '新建终端', loading: '正在读取终端环境…', creating: '正在启动…', + connecting: '正在连接…', disconnected: '连接已断开。', reconnect: '重新连接', + readonly: '此页面当前只读。', control: '接管输入', + closed: '终端已关闭。', exited: '进程已退出({code})', failed: '终端错误:{message}', + rename: '终端名称', unavailable: '不可用', retry: '重试', + cleanupFailed: '终端「{title}」未能结束:{message}', + missingTerminal: '此终端已不存在,请新建终端。', + inputFull: '输入缓冲区已满,请重新连接后重试。', + attachmentEnded: '终端连接已结束,请重新连接。', + invalidOutput: '终端画面传输异常,请重新连接。', + terminalLimit: '终端数量已达上限,请关闭不用的终端后重试。已退出的终端也计入数量。', +} satisfies Record + +/** English terminal copy. */ +export const en = { + recoveryFailed: 'Terminal recovery failed: {message}', retryRecovery: 'Retry terminal recovery', + shell: 'Shell', start: 'Start terminal', description: 'Run commands in the Session workspace', + title: 'Terminal', new: 'New terminal', loading: 'Reading terminal environment…', creating: 'Starting…', + connecting: 'Connecting…', disconnected: 'Disconnected.', reconnect: 'Reconnect', + readonly: 'This view is read-only.', control: 'Take control', + closed: 'Terminal closed.', exited: 'Process exited ({code})', failed: 'Terminal error: {message}', + rename: 'Terminal name', unavailable: 'Unavailable', retry: 'Retry', + cleanupFailed: 'Terminal “{title}” could not be ended: {message}', + missingTerminal: 'This terminal no longer exists. Open a new terminal.', + inputFull: 'The input buffer is full. Reconnect and try again.', + attachmentEnded: 'The terminal connection ended. Reconnect to continue.', + invalidOutput: 'The terminal screen could not be received. Reconnect to recover it.', + terminalLimit: 'The terminal limit has been reached. Close unused terminals and try again. Exited terminals also count toward the limit.', +} satisfies Record diff --git a/packages/client/ui-sidebar-terminal/src/css-modules.d.ts b/packages/client/ui-sidebar-terminal/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-sidebar-terminal/src/index.ts b/packages/client/ui-sidebar-terminal/src/index.ts new file mode 100644 index 0000000000..d7b0a4904f --- /dev/null +++ b/packages/client/ui-sidebar-terminal/src/index.ts @@ -0,0 +1,4 @@ +/** Host companion for the interactive terminal Client plugin. */ + +/** Mount the browser-only plugin through the Client loader. */ +export function apply(): void {} diff --git a/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts b/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts new file mode 100644 index 0000000000..dbbd4fa040 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts @@ -0,0 +1,182 @@ +// @vitest-environment jsdom +/** Terminal type, copy, seats and explicit cleanup follow the plugin lifetime. */ +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { Context } from '@deepseek-ai/cordis' +import { expect, it, vi } from 'vitest' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WebTerminalId, WebTerminalInfo } from '@deepseek-ai/dsh-api-terminal-controller/types' +import { SidebarRightTabRegistry } from '@deepseek-ai/dsh-client-ui-sidebar-right/src/client/tab-registry.ts' +import type { SidebarRightCloseHandler } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import { apply, inject } from '../src/client/index.ts' +import { apply as hostApply } from '../src/index.ts' +import { TerminalBody } from '../src/client/TerminalBody.tsx' +import { TerminalTitle } from '../src/client/TerminalTitle.tsx' +import { TerminalRecovery, type TerminalRecoveryInjected } from '../src/client/TerminalRecovery.tsx' +import { TerminalCleanup, type TerminalCleanupInjected } from '../src/client/TerminalCleanup.tsx' +import type { TerminalInjected } from '../src/client/face.ts' +import { en, zh } from '../src/client/locales.ts' + +vi.mock('@xterm/xterm', () => ({ Terminal: vi.fn() })) + +const terminalInfo = (id: string): WebTerminalInfo => ({ id: id as WebTerminalId, title: id, shell: { path: '/bin/sh', name: 'sh', args: ['-i'] }, cwd: '/workspace', cols: 80, rows: 24, state: 'running', exitCode: null }) + +async function mountPlugin() { + const ctx = new Context() + const tabs = new SidebarRightTabRegistry(ctx) + ctx.provide('sidebarRightTabs', tabs) + const entries: { + name: string + key?: string + id?: string + locale: string + component: unknown + inject: (id: SessionId) => unknown + }[] = [] + const dictionaries = new Map() + let closeHandler: SidebarRightCloseHandler | undefined + const model = { state: {} } + const terminals = { + view: vi.fn(() => model), close: vi.fn(), closeFailures: {}, retryClose: vi.fn(), + recover: vi.fn(async (_sessionId: SessionId): Promise => []), + } + let params: { terminalId: WebTerminalId } | undefined + const occurrence = vi.fn(() => ({ navigation: { getSnapshot: () => ({ params }) } })) + const openTabIn = vi.fn() + ctx.provide('webTerminals', terminals as never) + ctx.provide('sidebarRight', { + tabDomain: { occurrence }, openTabIn, + registerCloseHandler: (kind: string, handler: SidebarRightCloseHandler) => { expect(kind).toBe('terminal'); closeHandler = handler; return () => { closeHandler = undefined } }, + } as never) + ctx.provide('slots', { + inject: (_name: string, register: () => () => void) => register(), + register: (options: Omit, component: unknown) => { const entry = { ...options, component }; entries.push(entry); return () => { entries.splice(entries.indexOf(entry), 1) } }, + } as never) + ctx.provide('locale', { + bind: () => (key: string) => key, + register: (name: string, values: unknown) => { dictionaries.set(name, values); return () => { dictionaries.delete(name) } }, + } as never) + const fiber = await ctx.plugin({ inject, apply }) + return { + tabs, entries, dictionaries, terminals, model, occurrence, openTabIn, + get closeHandler() { return closeHandler }, + setParams(next: typeof params) { params = next }, + async dispose() { await fiber.dispose(); await ctx.fiber.dispose() }, + } +} + +it('registers terminal views, recovery and cleanup, then releases every contribution on unload', async () => { + expect(hostApply).not.toThrow() + const h = await mountPlugin() + try { + const definition = h.tabs.get('terminal')! + expect(definition.title('sidebar://terminal')).toBe('title') + expect(definition.guide?.map(entry => [entry.order, entry.title(), entry.description?.()])).toEqual([[20, 'new', 'description']]) + const Icon = definition.guide?.[0]?.icon + if (Icon === undefined) throw new Error('Terminal guide icon was not registered') + expect(renderToStaticMarkup(createElement(Icon, { size: 22 }))).toContain('width="22"') + expect(renderToStaticMarkup(createElement(Icon))).toContain('width="26"') + expect(definition.multiple).toBe(true) + expect(h.dictionaries.get('sidebarTerminal')).toEqual({ en, zh }) + expect(h.entries.map(entry => [entry.name, entry.component, entry.locale])).toEqual([ + ['sidebar.right.pane.tab', TerminalBody, 'sidebarTerminal'], + ['sidebar.right.pane.tab.title', TerminalTitle, 'sidebarTerminal'], + ['conversation.session.header.actions', TerminalRecovery, 'sidebarTerminal'], + ['shell.overlay', TerminalCleanup, 'sidebarTerminal'], + ]) + const sessionId = 'session' as SessionId + const face = h.entries[0]!.inject(sessionId) as TerminalInjected + expect(face.view('tab')).toBe(h.model) + expect(h.terminals.view).toHaveBeenLastCalledWith(sessionId, 'tab', undefined) + const terminalId = 'retained' as WebTerminalId + h.setParams({ terminalId }) + expect(face.keyedHooks.terminal('tab')).toBe(h.model.state) + expect(h.terminals.view).toHaveBeenLastCalledWith(sessionId, 'tab', terminalId) + expect(h.occurrence).toHaveBeenLastCalledWith(sessionId, { id: 'tab' }) + if (h.closeHandler === undefined) throw new Error('Terminal close handler was not registered') + h.closeHandler(sessionId, { id: 'tab' } as Parameters[1]) + expect(h.terminals.close).toHaveBeenLastCalledWith(sessionId, 'tab', terminalId) + h.setParams(undefined) + h.closeHandler(sessionId, { id: 'new-tab' } as Parameters[1]) + expect(h.terminals.close).toHaveBeenLastCalledWith(sessionId, 'new-tab', undefined) + const cleanupFace = h.entries[3]!.inject(sessionId) as TerminalCleanupInjected + expect(cleanupFace.hooks.closeFailures).toBe(h.terminals.closeFailures) + cleanupFace.retryClose('terminal' as Parameters[0]) + expect(h.terminals.retryClose).toHaveBeenCalledWith('terminal') + } finally { + await h.dispose() + } + expect(h.closeHandler).toBeUndefined() + expect(h.tabs.get('terminal')).toBeUndefined() + expect(h.entries).toEqual([]) + expect(h.dictionaries.size).toBe(0) +}) + +it('shares pending and completed recovery across Session headers and opens each returned terminal once', async () => { + const h = await mountPlugin() + const pending = Promise.withResolvers() + h.terminals.recover.mockImplementationOnce(() => pending.promise) + const sessionId = 'session' as SessionId + const recovery = h.entries[2]!.inject(sessionId) as TerminalRecoveryInjected + const remounted = h.entries[2]!.inject(sessionId) as TerminalRecoveryInjected + const completion = recovery.restore() + try { + expect(remounted.restore()).toBe(completion) + expect(h.terminals.recover).toHaveBeenCalledExactlyOnceWith(sessionId) + expect(h.openTabIn).not.toHaveBeenCalled() + pending.resolve([terminalInfo('build'), terminalInfo('tests')]) + await completion + expect(h.openTabIn.mock.calls).toEqual([ + [sessionId, 'terminal', { params: { terminalId: 'build' } }], + [sessionId, 'terminal', { params: { terminalId: 'tests' } }], + ]) + await remounted.restore() + expect(h.terminals.recover).toHaveBeenCalledTimes(1) + expect(h.openTabIn).toHaveBeenCalledTimes(2) + const otherSession = 'other-session' as SessionId + await (h.entries[2]!.inject(otherSession) as TerminalRecoveryInjected).restore() + expect(h.terminals.recover).toHaveBeenLastCalledWith(otherSession) + expect(h.terminals.recover).toHaveBeenCalledTimes(2) + expect(h.openTabIn).toHaveBeenCalledTimes(2) + } finally { + pending.resolve([]) + await completion + await h.dispose() + } +}) + +it('allows a failed Host lookup to be retried without marking the Session recovered', async () => { + const h = await mountPlugin() + const unavailable = new Error('Host unavailable') + h.terminals.recover.mockRejectedValueOnce(unavailable) + const sessionId = 'session' as SessionId + const recovery = h.entries[2]!.inject(sessionId) as TerminalRecoveryInjected + try { + await expect(recovery.restore()).rejects.toBe(unavailable) + expect(h.openTabIn).not.toHaveBeenCalled() + await recovery.restore() + await recovery.restore() + expect(h.terminals.recover).toHaveBeenCalledTimes(2) + } finally { + await h.dispose() + } +}) + +it('does not open retained terminals when their lookup completes after plugin unload', async () => { + const h = await mountPlugin() + const pending = Promise.withResolvers() + h.terminals.recover.mockImplementationOnce(() => pending.promise) + const recovery = h.entries[2]!.inject('session' as SessionId) as TerminalRecoveryInjected + const completion = recovery.restore() + try { + expect(h.terminals.recover).toHaveBeenCalledOnce() + await h.dispose() + pending.resolve([terminalInfo('build')]) + await completion + expect(h.openTabIn).not.toHaveBeenCalled() + } finally { + pending.resolve([]) + await completion + await h.dispose() + } +}) diff --git a/packages/client/ui-sidebar-terminal/tests/cleanup.client.spec.tsx b/packages/client/ui-sidebar-terminal/tests/cleanup.client.spec.tsx new file mode 100644 index 0000000000..fb1f61aab4 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/tests/cleanup.client.spec.tsx @@ -0,0 +1,53 @@ +// @vitest-environment jsdom +/** Cleanup errors remain reachable after the terminal tab has disappeared. */ +import { afterEach, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, within } from '@testing-library/react' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import type { TerminalCloseFailure } from '@deepseek-ai/dsh-api-terminal-controller/client' +import type { WebTerminalId } from '@deepseek-ai/dsh-api-terminal-controller/types' +import { TerminalCleanup } from '../src/client/TerminalCleanup.tsx' +import { en } from '../src/client/locales.ts' + +afterEach(cleanup) + +type CleanupProps = Parameters[0] +const unusedHook = (): never => { throw new Error('Terminal cleanup does not consume global hooks') } +const standard: Omit = { + useSessions: unusedHook, useSessionPendingInteraction: unusedHook, usePanelInfo: unusedHook, + useWorkspaces: unusedHook, useResource: unusedHook, +} + +it('stays absent for ordinary closes and offers a retry only after cleanup fails', () => { + let failures: readonly TerminalCloseFailure[] = [] + const retryClose = vi.fn() + const useCloseFailures: CleanupProps['useCloseFailures'] = selector => selector(failures) + const props: CleanupProps = { ...standard, useCloseFailures, retryClose, t: makeTranslate(en) } + const view = render() + expect(view.container.childElementCount).toBe(0) + failures = [{ id: 'terminal' as WebTerminalId, title: 'Build', message: 'Host unavailable' }] + view.rerender() + expect(view.getByRole('alert').textContent).toContain('Terminal “Build” could not be ended: Host unavailable') + fireEvent.click(view.getByRole('button', { name: en.retry })) + expect(retryClose).toHaveBeenCalledWith('terminal') + failures = [] + view.rerender() + expect(view.container.childElementCount).toBe(0) +}) + +it('keeps cleanup failures separate and retries the terminal selected by the user', () => { + const failures: readonly TerminalCloseFailure[] = [ + { id: 'build' as WebTerminalId, title: 'Build', message: 'Host unavailable' }, + { id: 'tests' as WebTerminalId, title: 'Tests', message: 'Process did not exit' }, + ] + const retryClose = vi.fn() + const useCloseFailures: CleanupProps['useCloseFailures'] = selector => selector(failures) + const props: CleanupProps = { ...standard, useCloseFailures, retryClose, t: makeTranslate(en) } + const view = render() + const notices = view.getAllByRole('alert') + expect(notices.map(notice => notice.textContent)).toEqual([ + 'Terminal “Build” could not be ended: Host unavailableRetry', + 'Terminal “Tests” could not be ended: Process did not exitRetry', + ]) + fireEvent.click(within(notices[1]!).getByRole('button', { name: en.retry })) + expect(retryClose).toHaveBeenCalledExactlyOnceWith('tests') +}) diff --git a/packages/client/ui-sidebar-terminal/tests/recovery.client.spec.tsx b/packages/client/ui-sidebar-terminal/tests/recovery.client.spec.tsx new file mode 100644 index 0000000000..853ea8ff3e --- /dev/null +++ b/packages/client/ui-sidebar-terminal/tests/recovery.client.spec.tsx @@ -0,0 +1,83 @@ +// @vitest-environment jsdom +/** Session terminal recovery hides ordinary work and keeps failed lookup retryable. */ +import { afterEach, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { TerminalRecovery } from '../src/client/TerminalRecovery.tsx' +import { en } from '../src/client/locales.ts' + +afterEach(cleanup) + +function props(restore: () => Promise): Parameters[0] { + // The renderer supplies Session shares; recovery needs only the injected callback and copy. + return { restore, t: makeTranslate(en) } as Parameters[0] +} + +it('restores on mount without adding controls during a successful lookup', async () => { + const pending = Promise.withResolvers() + const restore = vi.fn(() => pending.promise) + const view = render() + try { + expect(restore).toHaveBeenCalledOnce() + expect(view.container.childElementCount).toBe(0) + await act(async () => { pending.resolve(undefined); await pending.promise }) + view.rerender() + expect(restore).toHaveBeenCalledOnce() + expect(view.container.childElementCount).toBe(0) + } finally { + await act(async () => { pending.resolve(undefined); await pending.promise }) + } +}) + +it.each([new Error('Host unavailable'), 'Host unavailable'])('offers recovery retry for a failed lookup: %s', async (reason) => { + const pending = Promise.withResolvers() + const restore = vi.fn<() => Promise>().mockRejectedValueOnce(reason).mockImplementationOnce(() => pending.promise) + const view = render() + try { + const retry = await view.findByRole('button', { name: en.retryRecovery }) + expect(retry.getAttribute('title')).toBe('Terminal recovery failed: Host unavailable') + fireEvent.click(retry) + expect(restore).toHaveBeenCalledTimes(2) + expect(view.container.childElementCount).toBe(0) + await act(async () => { pending.resolve(undefined); await pending.promise }) + expect(view.container.childElementCount).toBe(0) + } finally { + await act(async () => { pending.resolve(undefined); await pending.promise }) + } +}) + +it('keeps the current Session failure when an earlier Session lookup later succeeds', async () => { + const pending = Promise.withResolvers() + const previous = vi.fn(() => pending.promise) + const current = vi.fn(async () => { throw new Error('Current Session failed') }) + const view = render() + try { + view.rerender() + const retry = await view.findByRole('button', { name: en.retryRecovery }) + expect(retry.getAttribute('title')).toContain('Current Session failed') + await act(async () => { pending.resolve(undefined); await pending.promise }) + expect(view.getByRole('button', { name: en.retryRecovery }).getAttribute('title')).toContain('Current Session failed') + } finally { + await act(async () => { pending.resolve(undefined); await pending.promise }) + } +}) + +it('ignores an earlier Session failure after a replacement Session recovers', async () => { + const pending = Promise.withResolvers() + const previous = vi.fn(() => pending.promise) + const completed = Promise.resolve() + const current = vi.fn(() => completed) + const view = render() + try { + view.rerender() + await act(async () => { await completed }) + await act(async () => { + pending.reject(new Error('Previous Session failed')) + await expect(pending.promise).rejects.toThrow('Previous Session failed') + }) + expect(view.container.childElementCount).toBe(0) + } finally { + pending.resolve(undefined) + await Promise.allSettled([pending.promise]) + } +}) diff --git a/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx b/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx new file mode 100644 index 0000000000..7070b43df9 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx @@ -0,0 +1,333 @@ +// @vitest-environment jsdom +/** Terminal startup, title editing and xterm's screen lifetime. */ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/react' +import type { ReactNode } from 'react' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import type { TerminalViewState } from '@deepseek-ai/dsh-api-terminal-controller/client' +import type { WebTerminalId } from '@deepseek-ai/dsh-api-terminal-controller/types' +import { TerminalBody, type TerminalBodyProps } from '../src/client/TerminalBody.tsx' +import { TerminalTitle } from '../src/client/TerminalTitle.tsx' +import { en, zh } from '../src/client/locales.ts' + +const fake = vi.hoisted(() => ({ + terminals: [] as FakeTerminal[], + dimensions: { cols: 120, rows: 40 } as { cols: number; rows: number } | undefined, +})) +class FakeTerminal { + options: { disableStdin?: boolean } + textarea: HTMLTextAreaElement | undefined = document.createElement('textarea') + input: ((data: string) => void) | undefined + readonly disposeInput = vi.fn() + readonly resize = vi.fn() + readonly reset = vi.fn() + readonly focus = vi.fn() + readonly dispose = vi.fn() + readonly write = vi.fn((_data: string, callback: () => void) => { callback() }) + readonly loadAddon = vi.fn() + constructor(options: object) { this.options = options; fake.terminals.push(this) } + open(node: HTMLElement) { node.appendChild(this.textarea!) } + onData(input: (data: string) => void) { this.input = input; return { dispose: this.disposeInput } } +} +vi.mock('@xterm/xterm', () => ({ Terminal: vi.fn(function (options: object) { return new FakeTerminal(options) }) })) +vi.mock('@xterm/addon-fit', () => ({ FitAddon: class { proposeDimensions() { return fake.dimensions } } })) + +let measure: (() => void) | undefined +const disconnect = vi.fn() +let boxWidth = 800 +let boxHeight = 600 +beforeEach(() => { + fake.terminals.length = 0 + fake.dimensions = { cols: 120, rows: 40 } + boxWidth = 800 + boxHeight = 600 + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => boxWidth) + vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => boxHeight) + vi.stubGlobal('ResizeObserver', class { constructor(callback: () => void) { measure = callback } observe() {} disconnect = disconnect }) +}) +afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals(); disconnect.mockClear(); measure = undefined }) + +const info = { id: 'terminal' as WebTerminalId, title: 'bash', shell: { path: '/bin/bash', name: 'bash', args: ['-i'] }, cwd: '/workspace', cols: 80, rows: 24, state: 'running' as const, exitCode: null } +const environment = { cwd: '/workspace', maxCols: 200, maxRows: 100, scrollback: 1000, maxInputBytes: 1000 } +const idle: TerminalViewState = { phase: 'idle', writable: false, environment } +const titleSurfaces = [ + { + name: 'docked tab', selector: '[data-dockkit-tab]', + wrap: (title: ReactNode) =>
{title}
, + }, + { + name: 'floating grip', selector: '[data-dockkit-float-grip]', + wrap: (title: ReactNode) =>
{title}
, + }, +] +function mount(initial: TerminalViewState | undefined = idle, dictionary = en) { + let state: TerminalViewState | undefined = initial + let visible = true + const detach = vi.fn() + const model = { + start: vi.fn(async () => {}), selectShell: vi.fn(), + mount: vi.fn(() => detach), refresh: vi.fn(async () => {}), + rename: vi.fn(async () => {}), connect: vi.fn(), write: vi.fn(), resize: vi.fn(), acknowledge: vi.fn(), + } + const openTab = vi.fn() + const tab = () => ({ tab: { id: 'tab', title: 'Terminal', visible, actions: { openTab } } }) + // The test supplies the owner and model hooks consumed here; the remaining slot props are framework-owned. + const props = { + view: () => model, + useTerminal: (_key: string, select?: (value: TerminalViewState | undefined) => unknown) => select === undefined ? state : select(state), + useTabInfo: tab, t: makeTranslate(dictionary), + } as unknown as TerminalBodyProps + const view = render() + return { + view, props, model, detach, openTab, + update(next: TerminalViewState | undefined, shown = visible) { + state = next; visible = shown; view.rerender() + }, + } +} + +it('mounts automatic startup and offers retry only when startup fails', () => { + const h = mount({ ...idle, phase: 'loading', environment: undefined }) + expect(h.model.mount).toHaveBeenCalledOnce() + expect(h.view.getByRole('status').textContent).toBe(en.loading) + expect(h.view.queryByRole('combobox')).toBeNull() + expect(h.view.queryByRole('button')).toBeNull() + h.update({ ...idle, phase: 'failed', error: 'unavailable' }) + expect(h.view.getByRole('alert').textContent).toContain('unavailable') + fireEvent.click(h.view.getByRole('button', { name: en.retry })) + expect(h.model.refresh).toHaveBeenCalledOnce() + h.update({ ...idle, phase: 'disconnected' }) + fireEvent.click(h.view.getByRole('button', { name: en.retry })) + expect(h.model.refresh).toHaveBeenCalledTimes(2) + h.update({ ...idle, phase: 'creating' }) + expect(h.view.getByRole('status').textContent).toBe(en.creating) + h.update({ ...idle, phase: 'closing' }) + expect(h.view.queryByRole('status')).toBeNull() + expect(h.view.queryByRole('button')).toBeNull() + h.update(undefined) + expect(h.view.container.childElementCount).toBe(0) + expect(h.model.mount).toHaveBeenCalledOnce() +}) + +it('shows only the terminal screen while connected and detaches on unmount', () => { + const h = mount({ ...idle, info, phase: 'connected', writable: true }) + expect(h.view.getByRole('textbox', { name: en.title })).toBeDefined() + expect(h.view.queryByRole('status')).toBeNull() + expect(h.view.queryByRole('button')).toBeNull() + expect(h.view.queryByRole('combobox')).toBeNull() + expect(h.view.queryByRole('heading')).toBeNull() + expect(h.view.queryByText('/workspace')).toBeNull() + expect(h.view.container.querySelector('header')).toBeNull() + h.view.unmount() + expect(h.detach).toHaveBeenCalledOnce() +}) + +it('keeps one emulator across rename and locale updates, applies snapshots and output once, and releases listeners', () => { + const snapshot: TerminalViewState = { ...idle, info, phase: 'connected', writable: true, render: { revision: 1, frame: { type: 'snapshot', sequence: 0, info, screen: 'restored' } } } + const h = mount(snapshot) + const terminal = fake.terminals[0]! + expect(terminal.reset).toHaveBeenCalledOnce() + expect(terminal.write).toHaveBeenCalledWith('restored', expect.any(Function)) + expect(h.model.acknowledge).toHaveBeenCalledWith(1) + expect(terminal.focus).toHaveBeenCalledOnce() + terminal.input?.('help\t') + expect(h.model.write).toHaveBeenCalledWith('help\t') + h.update({ ...snapshot, render: { revision: 2, frame: { type: 'output', sequence: 1, data: 'live' } } }) + expect(terminal.write).toHaveBeenCalledWith('live', expect.any(Function)) + h.update({ ...snapshot, render: { revision: 2, frame: { type: 'output', sequence: 1, data: 'duplicate' } } }) + expect(terminal.write).toHaveBeenCalledTimes(2) + h.update({ ...snapshot, info: { ...info, title: 'Development' } }) + h.view.rerender() + expect(fake.terminals).toHaveLength(1) + expect(terminal.textarea?.getAttribute('aria-label')).toBe(zh.title) + h.view.unmount() + expect(terminal.dispose).toHaveBeenCalledOnce() + expect(terminal.disposeInput).toHaveBeenCalledOnce() + expect(disconnect).toHaveBeenCalledOnce() +}) + +it('displays disconnect, close and exit states and offers explicit reconnection and takeover', () => { + const h = mount({ ...idle, info, phase: 'connecting' }) + expect(h.view.getByRole('status').textContent).toBe(en.connecting) + h.update({ ...idle, info, phase: 'connected' }) + expect(h.view.getByRole('status').textContent).toContain(en.readonly) + expect(fake.terminals[0]!.options.disableStdin).toBe(true) + fireEvent.click(h.view.getByRole('button', { name: en.control })) + expect(h.model.connect).toHaveBeenCalledOnce() + for (const phase of ['disconnected', 'failed'] as const) { + h.update({ ...idle, info, phase }) + fireEvent.click(h.view.getByRole('button', { name: en.reconnect })) + } + expect(h.model.connect).toHaveBeenCalledTimes(3) + h.update({ ...idle, info, phase: 'closing' }) + expect(h.view.queryByRole('status')).toBeNull() + h.update({ ...idle, info, phase: 'closed' }) + expect(h.view.getByRole('status').textContent).toBe(en.closed) + h.update({ ...idle, info: { ...info, state: 'exited', exitCode: 5 }, phase: 'connected' }) + expect(h.view.getByRole('status').textContent).toBe('Process exited (5)') + h.update({ ...idle, info: { ...info, state: 'exited', error: 'provider stopped' }, phase: 'connected' }) + expect(h.view.getByRole('status').textContent).toBe('Process exited (—)') + expect(h.view.getByRole('alert').textContent).toContain('provider stopped') + h.update({ ...idle, info: { ...info, state: 'failed', error: 'provider unreachable' }, phase: 'connected' }) + expect(h.view.getByRole('status').textContent).toBe(en.unavailable) +}) + +it('fits only visible writable terminals with measurable dimensions and clamps the provider limits', () => { + const state: TerminalViewState = { ...idle, info, phase: 'connected', writable: true } + const h = mount(state) + const terminal = fake.terminals[0]! + expect(h.model.resize).toHaveBeenCalledWith(120, 40) + h.model.resize.mockClear() + fake.dimensions = { cols: 500, rows: 300 } + measure?.() + expect(h.model.resize).toHaveBeenCalledWith(200, 100) + h.model.resize.mockClear() + h.update(state, false) + measure?.() + h.update({ ...state, writable: false }, true) + measure?.() + expect(h.model.resize).not.toHaveBeenCalled() + expect(terminal.resize).toHaveBeenLastCalledWith(80, 24) + boxWidth = 0 + h.update(state) + measure?.() + boxWidth = 800; boxHeight = 0 + measure?.() + h.update({ ...state, info: { ...info, cols: 81 } }) + expect(h.model.resize).not.toHaveBeenCalled() + boxHeight = 600 + fake.dimensions = undefined + measure?.() + h.update({ ...state, environment: undefined }) + fake.dimensions = { cols: 40, rows: 20 } + measure?.() + h.update(state) + for (const dimensions of [{ cols: 1, rows: 20 }, { cols: 40, rows: 0 }]) { fake.dimensions = dimensions; measure?.() } + expect(h.model.resize).not.toHaveBeenCalled() +}) + +it.each(titleSurfaces)('shows saved and attached terminal names in the $name', (surface) => { + const h = mount(idle) + const chip = () => surface.wrap() + const title = render(chip()) + expect(title.container.textContent).toBe('Terminal') + h.update({ ...idle, title: 'Saved terminal' }) + title.rerender(chip()) + expect(title.container.textContent).toBe('Saved terminal') + h.update({ ...idle, info }) + title.rerender(chip()) + expect(title.container.textContent).toBe('bash') + expect(title.container.querySelector('svg')?.getAttribute('aria-hidden')).toBe('true') + expect(title.container.querySelector('svg rect')).toBeNull() +}) + +it.each(titleSurfaces)('renames from the $name pointer target, preserving dragging and isolating editor shortcuts', (surface) => { + const h = mount({ ...idle, title: 'Saved terminal' }) + const outer = vi.fn() + const title = render(
+ {surface.wrap()} +
) + fireEvent.pointerDown(title.getByText('Saved terminal')) + expect(outer).toHaveBeenCalledOnce() + outer.mockClear() + fireEvent.doubleClick(title.container.querySelector(surface.selector)!) + const input = title.getByRole('textbox', { name: en.rename }) as HTMLInputElement + expect(document.activeElement).toBe(input) + expect(input.selectionStart).toBe(0) + expect(input.selectionEnd).toBe('Saved terminal'.length) + fireEvent.pointerDown(input) + fireEvent.click(input) + fireEvent.doubleClick(input) + fireEvent.keyDown(input, { key: 'ArrowLeft' }) + fireEvent.change(input, { target: { value: ' Development ' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + expect(h.model.rename).toHaveBeenCalledWith('Development') + expect(h.model.rename).toHaveBeenCalledOnce() + expect(title.queryByRole('textbox', { name: en.rename })).toBeNull() + expect(outer).not.toHaveBeenCalled() +}) + +it.each(titleSurfaces)('handles Escape, unchanged names and IME composition while editing the $name', (surface) => { + const h = mount({ ...idle, info }) + const title = render(surface.wrap()) + const edit = () => { + fireEvent.doubleClick(title.container.querySelector(surface.selector)!) + return title.getByRole('textbox', { name: en.rename }) + } + let input = edit() + fireEvent.change(input, { target: { value: 'Cancelled' } }) + fireEvent.keyDown(input, { key: 'Escape' }) + expect(h.model.rename).not.toHaveBeenCalled() + input = edit() + fireEvent.blur(input) + expect(h.model.rename).not.toHaveBeenCalled() + input = edit() + fireEvent.change(input, { target: { value: ' ' } }) + fireEvent.blur(input) + expect(h.model.rename).not.toHaveBeenCalled() + input = edit() + fireEvent.change(input, { target: { value: '开发' } }) + fireEvent.keyDown(input, { key: 'Enter', isComposing: true }) + fireEvent.keyDown(input, { key: 'Enter', keyCode: 229 }) + expect(title.getByRole('textbox', { name: en.rename })).toBe(input) + expect(h.model.rename).not.toHaveBeenCalled() + fireEvent.blur(input) + expect(h.model.rename).toHaveBeenCalledWith('开发') +}) + +it.each(titleSurfaces)('removes the native $name listener when its title unmounts', (surface) => { + const h = mount({ ...idle, info }) + const outer = vi.fn() + const seat = (shown: boolean) =>
+ {surface.wrap(shown && )} +
+ const title = render(seat(false)) + const chip = title.container.querySelector(surface.selector)! + const add = vi.spyOn(chip, 'addEventListener') + const remove = vi.spyOn(chip, 'removeEventListener') + title.rerender(seat(true)) + const listener = add.mock.calls.find(([type]) => type === 'dblclick')?.[1] + expect(listener).toBeTypeOf('function') + title.rerender(seat(false)) + expect(remove).toHaveBeenCalledExactlyOnceWith('dblclick', listener) + fireEvent.doubleClick(chip) + expect(outer).toHaveBeenCalledOnce() + expect(h.model.rename).not.toHaveBeenCalled() +}) + +it('starts a recovered screen with no local history when environment discovery is unavailable', () => { + mount({ ...idle, info, environment: undefined }) + expect(fake.terminals[0]!.options).toHaveProperty('scrollback', 0) +}) + + +it.each([en, zh])('translates known terminal failures while retaining unknown Host diagnostics', (dictionary) => { + const h = mount(idle, dictionary) + for (const issue of ['missingTerminal', 'inputFull', 'attachmentEnded', 'invalidOutput', 'terminalLimit'] as const) { + h.update({ ...idle, phase: 'failed', issue, error: 'raw diagnostic' }) + expect(h.view.getByRole('alert').textContent).toContain(dictionary[issue]) + expect(h.view.getByRole('alert').textContent).not.toContain('raw diagnostic') + } + h.update({ ...idle, phase: 'failed', error: 'Host permission denied' }) + expect(h.view.getByRole('alert').textContent).toContain('Host permission denied') +}) + +it('lets the user select an installed shell and start it before rendering a screen', () => { + const other = { path: '/bin/zsh', name: 'zsh', args: ['-i'] } + const h = mount({ phase: 'selecting', writable: false, environment, shells: [info.shell, other], selectedShell: other.path }) + const selector = h.view.getByRole('button', { name: 'Shell' }) + expect(selector.textContent).toContain(other.path) + expect(fake.terminals).toHaveLength(0) + fireEvent.click(selector) + expect(h.view.getByRole('menu')).toBeTruthy() + fireEvent.keyDown(document, { key: 'Escape' }) + expect(h.view.queryByRole('menu')).toBeNull() + fireEvent.click(selector) + fireEvent.click(h.view.getByRole('menuitem', { name: `bash — ${info.shell.path}` })) + expect(h.view.queryByRole('menu')).toBeNull() + expect(h.model.selectShell).toHaveBeenCalledWith(info.shell.path) + fireEvent.click(h.view.getByRole('button', { name: 'Start terminal' })) + expect(h.model.start).toHaveBeenCalledOnce() + h.update({ phase: 'selecting', writable: false, environment }) + expect(h.view.getByRole('button', { name: 'Start terminal' })).toHaveProperty('disabled', true) +}) diff --git a/packages/client/ui-sidebar-terminal/tsconfig.json b/packages/client/ui-sidebar-terminal/tsconfig.json new file mode 100644 index 0000000000..4e229078ec --- /dev/null +++ b/packages/client/ui-sidebar-terminal/tsconfig.json @@ -0,0 +1,51 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../../core/session" + }, + { + "path": "../locale" + }, + { + "path": "../store" + }, + { + "path": "../ui-dockkit" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-session" + }, + { + "path": "../ui-sidebar-right" + }, + { + "path": "../ui-slots" + }, + { + "path": "../../api/terminal-controller/tsconfig.client.json" + }, + { + "path": "../ui-layout" + }, + { + "path": "../ui-conversation" + } + ] +} diff --git a/packages/client/ui-sidebar-terminal/tsdown.config.ts b/packages/client/ui-sidebar-terminal/tsdown.config.ts new file mode 100644 index 0000000000..016643c5fb --- /dev/null +++ b/packages/client/ui-sidebar-terminal/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-sidebar-terminal', ['lib/types/index.js']) diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index 9541cacecf..1dae344665 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md -README.md: b3846ccbe6c6cb4ff2a68b74ddc3d4c219953c08 -README.zh.md: f4b788eacf0de69342d33e5f33bb966b4a1d8246 +README.md: 63bd664b902d8d40f8a05153770306d3b03dbe4e +README.zh.md: 89db03a99ac68065211f92ff7e47307813c1ab76 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index b3846ccbe6..63bd664b90 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -29,10 +29,14 @@ Open the Trajectory tab in the conversation's view ring to inspect agent activit ### Inspecting records +Calls are identified by their recorded tool name. For `run_code`, the row shows the program description and the inspector opens numbered, highlighted source. The Code tab provides wrapping, a `{}` toggle for the original JSON arguments, and exact source copying, including trailing newlines. Copying the original arguments retains their recorded JSON whitespace. Each newly opened code view takes the last wrapping choice; changing it leaves other open views as they are. Output preserves the recorded text, using a tree for complete JSON objects or arrays. Highlighting uses only an unambiguous TypeScript or Python hint in the recorded tool schema; missing or conflicting hints leave plain source. See the [PTC inspection decision](../../../.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md) for replay constraints. + Selection, timeline navigation, folding, and search cover the React-visible window. Request numbers and cumulative usage cover the complete resident snapshot. Selecting a record opens a local inspector for token usage, duration, Input, Output, Timing, and durable images. Image URLs use the Conversation-owned per-session cache, so Chat and Trajectory share one authorized read per attachment. A user record shows the generic-file count beside its text, while a record without text shows its image and file counts. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. ### The timing overview +Historical replies retain TTFT, generation duration, and throughput when their recorded streams contain token timestamps. TTFT measures from the Step start to its first token, including output from an earlier retry attempt; an unloaded Step start or a stream without tokens leaves the corresponding metric unavailable. + A fixed Overview above the ledger projects real record start/duration timing from left to right; Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range; wheel gestures zoom the time domain; a right-button click clears the selected interval, and a right-button drag pans an already zoomed viewport. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. ----- diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index f4b788eacf..89db03a99a 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -29,10 +29,14 @@ Trajectory 标签页让你以按轮次组织的事件记录表和交互式时间 ### 检查记录 +调用按已记录的工具名识别。对于 `run_code`,记录行展示程序说明,检查器显示带行号和语法高亮的源码。“代码”页提供换行、查看原始 JSON 参数的 `{}` 切换按钮,以及保留末尾换行的源码原样复制。复制原始参数时保留记录中 JSON 的空白。每个新打开的代码视图采用上次选择的换行状态;切换时,其他已打开视图保持原状。输出保留记录中的文本,完整 JSON 对象或数组使用树形展示。高亮只采用已记录工具 Schema 中明确且无冲突的 TypeScript 或 Python 提示;提示缺失或冲突时显示纯文本源码。回放约束见 [PTC 检查决策](../../../.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md)。 + 选择、时间线导航、折叠与搜索只覆盖 React 可见窗口。请求编号与累计用量覆盖完整的驻留快照。选择记录会打开局部检查器,查看 token 用量、耗时、输入、输出、计时与持久保留的图片。图片 URL 使用 Conversation 拥有的逐会话缓存,因此 Chat 与 Trajectory 对每个附件共享一次已授权读取。用户记录会在文本旁显示通用文件数量;记录没有文本时,则显示图片与文件数量。独立运行的压缩请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。 ### 时间概览 +当记录的流包含 token 时间戳时,历史回复会保留 TTFT、生成耗时与吞吐量。TTFT 从步骤开始计量到首个 token,包括更早重试尝试中的输出;步骤起点尚未加载或流中没有 token 时,对应指标保持不可用。 + 固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;助手时间条区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻与耗时详情。拖选区间会把记录表聚焦到该闭区间内任何时刻处于活动的记录;滚轮手势用于缩放时间域;右键单击会清除所选区间,在已放大的视口上按住右键拖动则会平移。初始视图与流式更新都停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。 ----- diff --git a/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx b/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx index a25b09833e..bb0b247979 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx @@ -57,6 +57,7 @@ export function TrajectoryCell({ assistantMetrics: _assistantMetrics, result: _result, callId: _callId, + toolName: _toolName, isError: _isError, timeSeconds, startedAt: _startedAt, diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index 35266eed00..c743e0e503 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -137,6 +137,10 @@ width: 122px; } +.eventColumn:where(:lang(zh)) { + width: 84px; +} + .contentColumn { width: auto; } @@ -448,6 +452,10 @@ width: 76px; } +.kindSlot:where(:lang(zh)) { + width: 44px; +} + .kindSlot :global([role='tooltip']) { border: 0; background: var(--dsw-alias-bg-layer-2); @@ -1038,6 +1046,7 @@ .detailBodySummary > .overview { flex: 0 1 auto; min-height: 0; + padding-bottom: 0; overflow: auto; overscroll-behavior: contain; } @@ -1176,13 +1185,13 @@ display: flex; flex: 1 1 0; max-height: max-content; - min-height: 28px; + min-height: 24px; flex-direction: column; overflow: hidden; } .overviewSection + .overviewSection { - padding-top: 8px; + padding-top: 2px; } .overviewHeading { @@ -1191,7 +1200,7 @@ align-items: flex-end; box-sizing: border-box; width: 100%; - height: 28px; + height: 24px; margin: 0; padding: 0 0 3px 14px; color: var(--dsw-alias-label-secondary); @@ -1243,13 +1252,157 @@ background: var(--dsw-alias-bg-layer-1); } +.overviewPreview[data-scroll-more] { + mask-image: linear-gradient(to bottom, #000 calc(100% - 12px), transparent); +} + .overviewPreview > :last-child { margin-bottom: 0; padding-bottom: 0; } +.programDescription { + flex: none; + margin: 0; + padding: 8px 14px 2px; + color: var(--dsw-alias-label-primary); + font: var(--dsw-font-xs-13); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.programIcon { + margin-right: 4px; + vertical-align: -2px; +} + +.programSummary { + margin-left: 5px; + font-family: var(--dsw-font-family); +} + +.detailBodyProgram, +.programPanel { + display: flex; + min-height: 0; + flex-direction: column; + overflow: hidden; +} + +.programPanel { + flex: 1; + background: var(--dsw-alias-bg-layer-1); +} + +.programActions { + display: inline-flex; + flex: none; + height: 16px; + align-items: center; + gap: 4px; + margin-left: auto; + padding-right: 14px; +} + +.programLanguage { + padding-right: 4px; + color: var(--dsw-alias-label-tertiary); + font: 11px/16px var(--ds-font-family-code); +} + +.programAction { + display: inline-flex; + box-sizing: border-box; + width: 20px; + height: 16px; + align-items: center; + justify-content: center; + padding: 0; + border: 0; + color: var(--dsw-alias-label-secondary); + background: transparent; + cursor: pointer; +} + +.programAction:hover { + color: var(--dsw-alias-label-primary); +} + +.programAction[aria-pressed='true'] { + color: var(--dsw-alias-state-business-primary); +} + +.programAction[data-state='failed'] { + color: var(--dsw-alias-state-error-primary); +} + +.programAction:focus-visible { + outline: 1px solid var(--dsw-alias-state-business-primary); + outline-offset: -1px; +} + +.programContent { + min-width: 0; + min-height: 0; +} + +.programPanel > .programContent { + flex: 1; + overflow: auto; + overscroll-behavior: contain; +} + +.programSource { + margin: 0 !important; + border-radius: 0; + background: var(--dsw-alias-bg-layer-1); + --dsl-code-block-line-white-space: pre; +} + +.programSource :where(pre) { + padding: 2px 14px 8px; + overflow: visible; + border-radius: 0; + background: var(--dsw-alias-bg-layer-1) !important; + font: 12px/16px var(--ds-font-family-code); + overflow-wrap: normal; + word-break: normal; + tab-size: 2; +} + +.programSource pre code > :global(.line) { + box-sizing: border-box; + width: max-content; + min-width: 100%; +} + +.programContent[data-wrap='true'] .programSource { + --dsl-code-block-line-white-space: pre-wrap; +} + +.programContent[data-wrap='true'] .programSource pre code > :global(.line) { + width: auto; + overflow-wrap: anywhere; + word-break: break-all; +} + +.programOutput { + margin: 0; + padding: 2px 14px 8px; + color: var(--dsw-alias-label-primary); + font: 12px/16px var(--ds-font-family-code); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.programError::first-line { + color: var(--dsw-alias-state-error-primary); + font-weight: 600; +} + .overviewPreview .jsonPreview > :first-child, .overviewPreview .schemaTree > :first-child { + padding-top: 2px; padding-bottom: 0; } @@ -1405,7 +1558,7 @@ } .markdownPreview { - padding: 6px 14px 8px; + padding: 2px 14px 8px; } .markdownPayload { @@ -1435,7 +1588,7 @@ } .schemaPreview .schemaIntro { - padding-top: 6px; + padding-top: 2px; } .schemaName { @@ -1482,7 +1635,7 @@ .payloadPreview { min-height: 0; - padding: 6px 14px 8px; + padding: 2px 14px 8px; } .sourceBlocks { @@ -1733,7 +1886,7 @@ .resultBlocksPreview { min-height: 0; - padding: 6px 14px 8px; + padding: 2px 14px 8px; gap: 6px; } @@ -1748,7 +1901,7 @@ } .overviewPreview .noPayload { - padding: 6px 14px 8px; + padding: 2px 14px 0; } .noPayload { diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index d2a952704f..6babf88c4e 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -1,18 +1,24 @@ /** Turn-aware trajectory event ledger with a local record inspector. */ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react' import type { CSSProperties, ReactNode } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' import { + CodeBlock, + IconCheckOutline16, IconChevronRightOutline14, + IconCodeOutline16, + IconWrapLinesOutline16, + IconCopyOutline16, IconSettingsOutline16, IconSparkle16, IconUserOutline16, JsonTree, MarkdownText, Tooltip, + writeClipboard, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { JsonTreeLabels, MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives' +import type { JsonTreeLabels, JsonTreeProps, MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives' import { structuredPatch } from 'diff' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { @@ -30,6 +36,7 @@ import type { TrajectoryTurnModel } from './layout.ts' import { trajectoryPreviewText } from './trajectory-preview.ts' import type { TrajectoryKey, TrajectoryTranslate } from './locales.ts' import { COMPACTION_INTERRUPTED_ERROR } from './copy-codes.ts' +import { codeProgram, PTC_TOOL_NAME, type CodeProgram } from './code-program.ts' import css from './TrajectoryTable.module.css' const BOTTOM_FOLLOW_THRESHOLD_PX = 2 @@ -183,6 +190,7 @@ interface ParentRecords { } interface ToolCallTextParts { + program: boolean name: string args?: string } @@ -232,8 +240,8 @@ function jsonTreeLabels(t: TrajectoryTranslate): JsonTreeLabels { copyCompactJson: t('copy.compactJson'), copied: t('copied'), copyFailed: t('copy.failed'), - collapseNode: t('json.collapseNode'), - expandNode: t('json.expandNode'), + collapseNode: t('collapse'), + expandNode: t('expand'), copyButtonTitle: action => t('copy.optionsHint', { action }), } } @@ -376,6 +384,8 @@ function AssistantTimingPanel({ /** Props for the trajectory ledger. */ export interface TrajectoryTableProps { + /** Wrapping control shared by all JSON inspectors. */ + stringWrapping?: JsonTreeProps['stringWrapping'] /** Trajectory locale seat. */ t: TrajectoryTranslate /** Slot-backed durable image renderer shared with the Chat gallery. */ @@ -844,10 +854,12 @@ function RequestUsagePanel({ function RequestOptions({ options, preview = false, + stringWrapping, t, }: { options: AssistantRequestConfig | undefined preview?: boolean + stringWrapping: JsonTreeProps['stringWrapping'] t: TrajectoryTranslate }) { if (options === undefined) { @@ -856,6 +868,8 @@ function RequestOptions({ return ( {t('source.notRecorded')}

const data = typeof source === 'object' && source !== null @@ -895,6 +913,8 @@ function MessageSource({ record, t }: { record: TableRecord; t: TrajectoryTransl return ( recordDisplayText(cell, t), [ - cell.kind, cell.text, cell.previewMarkdown, + cell.kind, cell.text, cell.toolName, cell.previewMarkdown, cell.inputDetail, cell.outputDetail, cell.thinkingDetail, t, ], ) @@ -1057,7 +1084,7 @@ function RecordPresentation({ [cell.result, cell.resultPreviewMarkdown], ) const toolCallOnly = isToolCallOnly(cell, t) - const toolCallText = toolCallTextParts(cell.kind, displayText) + const toolCallText = toolCallTextParts(cell, displayText) const listDisplayText = toolCallOnly ? t('record.toolCallOnly') : toolCallText === undefined @@ -1087,10 +1114,11 @@ function RecordListText({ return ( <> + {toolCallText.program && } {toolCallText.name || '—'} {toolCallText.args !== undefined && ( - + {toolCallText.args} )} @@ -1281,8 +1309,13 @@ function ToolGlyph() { function ToolCatalog({ tools, + stringWrapping, t, -}: { tools: ConversationPromptSnapshot['tools']; t: TrajectoryTranslate }) { +}: { + tools: ConversationPromptSnapshot['tools'] + stringWrapping: JsonTreeProps['stringWrapping'] + t: TrajectoryTranslate +}) { if (tools.length === 0) return

{t('record.toolsMissing')}

return (
@@ -1300,6 +1333,7 @@ function ToolCatalog({ )} : (
{t('timing.started')}
{t('timing.duration')}
{formatElapsedSeconds(record.cell.timeSeconds, t)}
-
{t('timing.source')}
{record.cell.timeSeconds === null ? t('timing.notAvailable') : t('timing.sessionTimestamps')}
+ {!preview && ( +
{t('timing.source')}
{record.cell.timeSeconds === null ? t('timing.notAvailable') : t('timing.sessionTimestamps')}
+ )}
) } @@ -1560,14 +1600,16 @@ function RequestTiming({ assistant, anchor, request, + preview = false, t, }: { assistant: TableRecord | undefined anchor: TableRecord | undefined request: TrajectoryRequestNumber | undefined + preview?: boolean t: TrajectoryTranslate }) { - if (assistant !== undefined) return + if (assistant !== undefined) return if (request?.startedAt !== undefined) { const duration = request.completedAt === null || request.completedAt === undefined ? null @@ -1576,10 +1618,12 @@ function RequestTiming({
{t('timing.started')}
{t('timing.duration')}
{formatElapsedSeconds(duration, t)}
-
-
{t('timing.source')}
-
{duration === null ? t('timing.sessionTimestampsRunning') : t('timing.sessionTimestamps')}
-
+ {!preview && ( +
+
{t('timing.source')}
+
{duration === null ? t('timing.sessionTimestampsRunning') : t('timing.sessionTimestamps')}
+
+ )}
) } @@ -1599,12 +1643,14 @@ function RecordPayload({ direction, preview = false, renderImages, + stringWrapping, t, }: { record: TableRecord direction: 'input' | 'output' preview?: boolean renderImages: RenderMessageImages + stringWrapping: JsonTreeProps['stringWrapping'] t: TrajectoryTranslate }) { const value = direction === 'input' ? record.cell.inputDetail : record.cell.outputDetail @@ -1624,6 +1670,8 @@ function RecordPayload({ return ( {t('record.parameters')} ('idle') + useEffect(() => { + if (state === 'idle') return + const timer = setTimeout(() => { setState('idle') }, 1_500) + return () => { clearTimeout(timer) } + }, [state]) + const title = state === 'idle' ? label : t(state === 'copied' ? 'copied' : 'copy.failed') + return ( + + ) +} + +function ProgramInput({ program, initialWrapped, stringWrapping, onOpen, t }: { + program: CodeProgram + initialWrapped: boolean + stringWrapping: JsonTreeProps['stringWrapping'] + onOpen?: () => void + t: TrajectoryTranslate +}) { + const contentsId = useId() + const [wrapped, setWrapped] = useState(initialWrapped) + const [showJson, setShowJson] = useState(false) + const actions = ( + + {!showJson && program.language !== undefined && ( + {program.language} + )} + {!showJson && ( + + )} + {onOpen === undefined && ( + + )} + + + ) + const body = ( +
+ {showJson + ? + : } +
+ ) + return onOpen === undefined + ? ( +
+
+ {t('code.source')} + {actions} +
+ {body} +
+ ) + : {body} +} + +function ProgramOutput({ record, stringWrapping, onOpen, t }: { + record: TableRecord + stringWrapping: JsonTreeProps['stringWrapping'] + onOpen?: () => void + t: TrajectoryTranslate +}) { + const output = record.cell.outputDetail + const json = output === undefined || record.cell.isError === true ? undefined : parseJsonContainer(output) + const actions = output === undefined ? undefined : ( + + + + ) + const body = ( +
+ {output === undefined || output === '' + ?

{t(stateOf(record) === 'running' ? 'code.running' : 'record.noOutput')}

+ : json !== undefined + ? + :
{output}
} +
+ ) + return onOpen === undefined + ? ( +
+
+ {t('code.output')} + {actions} +
+ {body} +
+ ) + : {body} +} + function OverviewSection({ label, onOpen, + actions, children, }: { label: string onOpen: () => void + actions?: ReactNode children: ReactNode }) { + const previewRef = useRef(null) + const [hasMore, setHasMore] = useState(false) + const measureOverflow = useCallback((preview: HTMLElement) => { + setHasMore(preview.scrollHeight - preview.clientHeight - preview.scrollTop > 1) + }, []) + + useLayoutEffect(() => { + const preview = previewRef.current as HTMLDivElement + const measure = () => { measureOverflow(preview) } + measure() + if (typeof ResizeObserver === 'undefined') return + const observer = new ResizeObserver(measure) + observer.observe(preview) + for (const child of preview.children) observer.observe(child) + return () => { observer.disconnect() } + }, [children, measureOverflow]) + return (

@@ -1783,10 +1996,14 @@ function OverviewSection({ {label} + {actions}

{ measureOverflow(event.currentTarget) }} > {children}
@@ -1802,6 +2019,7 @@ function OverviewSection({ */ export function TrajectoryTable({ t, + stringWrapping, renderImages, requestNumbers: sessionRequestNumbers, turns, @@ -1828,7 +2046,8 @@ export function TrajectoryTable({ const [selectedRecordId, setSelectedRecordId] = useState(null) const [selectedRequest, setSelectedRequest] = useState(null) const [activeTab, setActiveTab] = useState('overview') - const [thinkingExpanded, setThinkingExpanded] = useState(false) + const [codeWrappingOnOpen, setCodeWrappingOnOpen] = useState(false) + const [thinkingDisclosure, setThinkingDisclosure] = useState<{ recordId: string; expanded: boolean }>() const [detailsWidth, setDetailsWidth] = useState(null) const [toolRequestOffset, setToolRequestOffset] = useState(null) const detailsResizeDrag = useRef(null) @@ -1860,6 +2079,16 @@ export function TrajectoryTable({ const selected = selectedTemplate === undefined ? undefined : currentRecord(selectedTemplate) + const selectedProgram = selected === undefined ? undefined : codeProgram(selected.cell) + const thinkingExpanded = thinkingDisclosure?.recordId === selectedRecordId + ? thinkingDisclosure.expanded + : selected?.cell.kind === 'message' + && Boolean(selected.cell.thinkingDetail?.trim()) + const setThinkingExpanded = (expanded: boolean) => { + if (selectedRecordId !== null) { + setThinkingDisclosure({ recordId: selectedRecordId, expanded }) + } + } const selectedIndex = selected?.cell.index ?? null useEffect(() => { onSelectedIndexChange?.(selectedIndex) @@ -2060,6 +2289,7 @@ export function TrajectoryTable({ } const activateTab = (tab: DetailTab) => { + setCodeWrappingOnOpen(stringWrapping?.getDefault() ?? false) tabHistory.current.delete(tab) tabHistory.current.add(tab) setActiveTab(tab) @@ -2076,6 +2306,7 @@ export function TrajectoryTable({ } const selectRecord = useCallback((index: number) => { + setCodeWrappingOnOpen(stringWrapping?.getDefault() ?? false) const record = allRecords.find(candidate => candidate.cell.index === index) onRecordSelect?.(index) setSelectedRequest(null) @@ -2085,7 +2316,7 @@ export function TrajectoryTable({ const available = new Set(tabs.map(tab => tab.id)) const recent = [...tabHistory.current].reverse().find(tab => available.has(tab)) setActiveTab(recent ?? tabs[0]?.id ?? 'overview') - }, [allRecords, onRecordSelect]) + }, [allRecords, onRecordSelect, stringWrapping]) useEffect(() => { if ( recordSelection === null @@ -2809,7 +3040,9 @@ export function TrajectoryTable({ id="trajectory-detail-panel" className={activeTab === 'overview' ? `${css.detailBody} ${css.detailBodySummary}` - : css.detailBody} + : selectedProgram !== undefined && (activeTab === 'input' || activeTab === 'output') + ? `${css.detailBody} ${css.detailBodyProgram}` + : css.detailBody} role="tabpanel" aria-labelledby={`trajectory-detail-${activeTab}`} > @@ -2916,7 +3149,7 @@ export function TrajectoryTable({
{selectedRequestOptions !== undefined && ( { activateTab('options') }}> - + )} { activateTab('usage') }}> @@ -2927,6 +3160,7 @@ export function TrajectoryTable({ assistant={selectedRequestAssistant} anchor={selectedRequestAnchor} request={selectedRequestInfo} + preview t={t} /> @@ -2934,7 +3168,7 @@ export function TrajectoryTable({ )} {selectedRequestInfo !== undefined && activeTab === 'options' && ( - + )} {selectedRequestInfo !== undefined && activeTab === 'usage' && ( + )} {!promptSelected && selected?.cell.kind === 'compacted' @@ -3020,6 +3254,9 @@ export function TrajectoryTable({ && selectedState !== undefined && activeTab === 'overview' && ( <> + {selectedProgram !== undefined && selectedProgram.description !== '' && ( +

{selectedProgram.description}

+ )}
- {isMarkdownRecord(selected) + {selectedProgram !== undefined ? ( <> - { activateTab('rendered') }}> - - + { activateTab('input') }} t={t} /> + { activateTab('output') }} t={t} /> ) - : ( - <> - {selected.cell.inputDetail && ( - { activateTab('input') }}> - + : isMarkdownRecord(selected) + ? ( + <> + { activateTab('rendered') }}> + - )} - {selected.cell.outputDetail && ( - { activateTab('output') }}> - + + ) + : ( + <> + {selected.cell.inputDetail && ( + { activateTab('input') }}> + + + )} + {selected.cell.outputDetail && ( + { activateTab('output') }}> + + + )} + { activateTab('schema') }}> + - )} - { activateTab('schema') }}> - - - - )} + + )} {selectedAssistantRequestTarget !== undefined && ( - + )} {(selected.cell.kind === 'tool' || selected.cell.kind === 'subtool') && ( { activateTab('timing') }}> - + )}
@@ -3186,16 +3433,22 @@ export function TrajectoryTable({ /> )} {!promptSelected && selected !== undefined && activeTab === 'source' && ( - + )} {!promptSelected && selected !== undefined && activeTab === 'input' && ( - + selectedProgram === undefined + ? + : )} {!promptSelected && selected !== undefined && activeTab === 'output' && ( - + selectedProgram === undefined + ? + : )} {!promptSelected && selected !== undefined && activeTab === 'schema' && ( - + )} {!promptSelected && selected !== undefined && activeTab === 'timing' && ( diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 4120779d9d..6c2cd98ec8 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -7,6 +7,7 @@ import type { } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { InjectFace, PropsLocale, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { JsonTreeProps } from '@deepseek-ai/dsh-client-ui-primitives' import { TrajectoryTable, type TrajectoryRequestNumber, @@ -74,6 +75,8 @@ function partialStructureSignature(partial: TrajectorySnapshot['partial']): stri /** Session-bound controls not already supplied by the conversation view slot. */ export interface TrajectoryViewInjected { + /** Shared wrapping preference read by expansion handlers. */ + jsonStringWrapping?: Omit, 'label'> hooks: { duration: SnapshotStore } @@ -128,7 +131,7 @@ function addUsage( export function TrajectoryView({ useSession, useTrajectory, useDuration, loadOlder, loadImage, setActualDuration, - viewRequest, completeViewRequest, renderSlot, t, + viewRequest, completeViewRequest, renderSlot, t, jsonStringWrapping, }: ConvViewProps & PropsRenderSlots<'conversation.trajectory.images'> & InjectFace @@ -541,6 +544,10 @@ export function TrajectoryView({
+ language: 'typescript' | 'python' | undefined +} + +function record(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record + : undefined +} + +function parseRecord(value: string | undefined): Record | undefined { + if (value === undefined) return undefined + try { + return record(JSON.parse(value)) + } catch { + return undefined + } +} + +function recordedLanguage(schemaRaw: string | undefined): CodeProgram['language'] { + const schema = parseRecord(schemaRaw) + const properties = record(record(schema?.parameters)?.properties) + const description = record(properties?.code)?.description + if (typeof description !== 'string') return undefined + const typescript = /\bTypeScript\b/i.test(description) + const python = /\bPython\b/i.test(description) + if (typescript === python) return undefined + return typescript ? 'typescript' : 'python' +} + +/** + * Resolve a PTC program without guessing its language from source or current runtime settings. + * @param cell - Recorded tool arguments and the schema visible at call time. + * @returns The program, or undefined for another tool or unsupported arguments. + */ +export function codeProgram(cell: TrajectoryCellProps): CodeProgram | undefined { + if (cell.kind !== 'tool' && cell.kind !== 'subtool') return undefined + if (cell.toolName !== PTC_TOOL_NAME || cell.inputDetail === undefined) return undefined + const args = parseRecord(cell.inputDetail) + if (typeof args?.code !== 'string') return undefined + if (args.description !== undefined && typeof args.description !== 'string') return undefined + return { + rawInput: cell.inputDetail, + source: args.code, + description: typeof args.description === 'string' && args.description.trim() !== '' + ? args.description + : args.code.split(/\r?\n/).find(line => line.trim() !== '')?.trim() ?? '', + arguments: args, + language: recordedLanguage(cell.schemaDetail), + } +} diff --git a/packages/client/ui-trajectory/src/client/index.ts b/packages/client/ui-trajectory/src/client/index.ts index 118f3b2c28..935a43ff2c 100644 --- a/packages/client/ui-trajectory/src/client/index.ts +++ b/packages/client/ui-trajectory/src/client/index.ts @@ -15,6 +15,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-session/client' import { createTrajectoryDurationStore } from './duration-store.ts' +import { createTrajectoryStringWrappingStore } from './string-wrapping-store.ts' import { en, NS, zh } from './locales.ts' import { registerTrajectoryAssistantDefinition } from './trajectory-assistant-definition.ts' import { registerTrajectoryCompactionDefinitions } from './trajectory-compaction-definition.ts' @@ -64,6 +65,7 @@ export function apply(ctx: Context): void { // re-registration. const t = ctx.locale.bind(NS) const duration = createTrajectoryDurationStore() + const stringWrapping = createTrajectoryStringWrappingStore() registerTrajectoryMessageDefinitions(ctx) registerTrajectoryRequestHeaderDefinition(ctx) registerTrajectoryAssistantDefinition(ctx) @@ -91,6 +93,10 @@ export function apply(ctx: Context): void { const trajectory = ctx.uiConversation.binding(sessionId).target('trajectory') return { hooks: { duration }, + jsonStringWrapping: { + getDefault: () => stringWrapping.getSnapshot(), + setDefault: (value) => { stringWrapping.set(value) }, + }, loadOlder: async () => { const before = trajectory.getSnapshot() await session.loadOlder() diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index fd361899d0..7691b7f74a 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -1062,8 +1062,9 @@ function expandSubCalls( function summarizeCall( name: string, argsRaw: string, -): Pick { +): Pick { return { + toolName: name, text: name, ...(argsRaw === '' ? {} : { previewMarkdown: argsRaw }), } diff --git a/packages/client/ui-trajectory/src/client/locales.ts b/packages/client/ui-trajectory/src/client/locales.ts index 6bdc621413..7e76444ca1 100644 --- a/packages/client/ui-trajectory/src/client/locales.ts +++ b/packages/client/ui-trajectory/src/client/locales.ts @@ -37,7 +37,7 @@ export const zh = { 'turn.label': '第 {turn} 轮', 'section.betweenTurns': '轮次之间', 'group.message': '消息', - 'group.step': '步骤 {step}', + 'group.step': '第 {step} 步', 'group.compaction': '压缩 {seq}', 'status.failed': '失败', 'status.pending': '等待中', @@ -114,6 +114,13 @@ export const zh = { 'record.payloadJson': '参数 JSON', 'record.outputJson': '结果 JSON', 'record.thinking': '思考', + 'record.wrapLines': '自动换行', + 'code.source': '代码', + 'code.output': '输出', + 'code.copySource': '复制代码', + 'code.copyOutput': '复制输出', + 'code.originalJson': '原始 JSON', + 'code.running': '执行中…', 'record.systemPromptMissing': '本次请求没有系统提示词', 'record.toolsMissing': '本次请求没有工具', 'record.systemPrompt': '系统提示词', @@ -307,6 +314,13 @@ export const en: Record = { 'record.payloadJson': 'Payload JSON', 'record.outputJson': 'Result JSON', 'record.thinking': 'Thinking', + 'record.wrapLines': 'Wrap lines', + 'code.source': 'Code', + 'code.output': 'Output', + 'code.copySource': 'Copy code', + 'code.copyOutput': 'Copy output', + 'code.originalJson': 'Original JSON', + 'code.running': 'Running…', 'record.systemPromptMissing': 'No system prompt in this request', 'record.toolsMissing': 'No tools in this request', 'record.systemPrompt': 'System Prompt', diff --git a/packages/client/ui-trajectory/src/client/string-wrapping-store.ts b/packages/client/ui-trajectory/src/client/string-wrapping-store.ts new file mode 100644 index 0000000000..bcd86dfccc --- /dev/null +++ b/packages/client/ui-trajectory/src/client/string-wrapping-store.ts @@ -0,0 +1,13 @@ +import { + createSnapshotStore, type SnapshotStore, +} from '@deepseek-ai/dsh-client-store' + +/** + * Create the browser-wide default for expanded JSON strings. + * @returns A persisted preference sampled only when a string is expanded. + */ +export function createTrajectoryStringWrappingStore(): SnapshotStore { + return createSnapshotStore(false, { + persist: { name: 'dsh.trajectory.jsonStringWrapping' }, + }) +} diff --git a/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts index 6b1d6cd004..2e885c4fdb 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts @@ -5,6 +5,7 @@ import type { PartialAssistant, RequestView, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { StreamChunk } from '@deepseek-ai/dsh-llm' +import { assistantStreamFirstTokenTime } from '@deepseek-ai/dsh-llm/assistant-stream' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import { trajectoryNode } from './trajectory-definition-common.ts' import { @@ -191,6 +192,17 @@ function updateChunk( } } +/** The Step retains its first token across live chunks and settled retry attempts. */ +function settleTiming( + state: AssistantState, + event: SessionEvent<'assistant/message' | 'assistant/attempt'>, +): AssistantState { + return { + ...state, + firstTokenTime: state.firstTokenTime ?? assistantStreamFirstTokenTime(event.data.stream), + } +} + function settleMessage( state: AssistantState, match: ConversationMatch, @@ -198,7 +210,7 @@ function settleMessage( ): AssistantState { const blocks = toAssistantBlocks(event.data.message.content) return { - ...state, + ...settleTiming(state, event), sawChunk: false, blocks, visibleBlocks: countVisibleBlocks(blocks), @@ -226,6 +238,9 @@ function fallbackState(context: ConversationNodeContext): Assist if (event.type === 'assistant/live-chunk') { state ??= initialState(event.data.turn, event.data.step, event.seq, event.time, false) state = updateChunk(state, event.data.chunk, event.seq, event.time) + } else if (event.type === 'assistant/attempt') { + state ??= initialState(event.data.turn, event.data.step, event.seq, event.time, false) + state = settleTiming(state, event) } else if (event.type === 'assistant/message') { state ??= initialState(event.data.turn, event.data.step, event.seq, event.time, false) state = settleMessage(state, match, event) @@ -325,6 +340,7 @@ const trajectoryAssistantDefinition: ConversationNodeDefinition } if (event.type === 'assistant/live-chunk' || event.type === 'assistant/message' + || event.type === 'assistant/attempt' || event.type === 'llm/retry' || event.type === 'step/end') { return { id: `${event.data.turn}:${event.data.step}`, role: 'update' } @@ -348,6 +364,7 @@ const trajectoryAssistantDefinition: ConversationNodeDefinition return updateChunk(context.state, match.event.data.chunk, match.event.seq, match.event.time) } if (match.event.type === 'assistant/message') return settleMessage(context.state, match, match.event) + if (match.event.type === 'assistant/attempt') return settleTiming(context.state, match.event) if (match.event.type === 'step/end') return { ...context.state, stepEnd: match } if (match.event.type !== 'llm/retry') return context.state const data = match.event.data @@ -372,7 +389,7 @@ const trajectoryAssistantDefinition: ConversationNodeDefinition } }, publication: (match) => { - if (match.event.type === 'step/start') return 'none' + if (match.event.type === 'step/start' || match.event.type === 'assistant/attempt') return 'none' if (match.event.type !== 'assistant/live-chunk') return 'immediate' const type = match.event.data.chunk.type return type === 'usage' || type === 'finish' ? 'none' : 'animation-frame' diff --git a/packages/client/ui-trajectory/src/client/trajectory-record.ts b/packages/client/ui-trajectory/src/client/trajectory-record.ts index 2bcb268f22..017143c487 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-record.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-record.ts @@ -80,6 +80,8 @@ export interface TrajectoryCellProps extends HTMLAttributes { resultPreviewMarkdown?: string /** Tool call id used to link message source blocks to tool records. */ callId?: string + /** Recorded tool name; independent of the display summary. */ + toolName?: string /** Tool-only result failure state. */ isError?: boolean /** Own duration in seconds, or `null` when no duration is known. */ diff --git a/packages/client/ui-trajectory/tests/code-inspector.client.spec.tsx b/packages/client/ui-trajectory/tests/code-inspector.client.spec.tsx new file mode 100644 index 0000000000..fab29e0637 --- /dev/null +++ b/packages/client/ui-trajectory/tests/code-inspector.client.spec.tsx @@ -0,0 +1,116 @@ +// @vitest-environment jsdom +/** PTC source, output, and independent wrapping through the trajectory inspector. */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, waitFor, within } from '@testing-library/react' +import { TrajectoryTable } from '../src/client/TrajectoryTable.tsx' +import type { TrajectoryCellProps } from '../src/client/trajectory-record.ts' +import { t } from './locale.client.ts' + +afterEach(() => { + cleanup() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +const SOURCE = 'const value = await tools.bash({ command: "pwd" })\nreturn value\n' +const ARGS = JSON.stringify({ code: SOURCE, description: 'Read the working directory' }) +const CELL: TrajectoryCellProps = { + index: 1, kind: 'tool', toolName: 'run_code', text: `run_code · ${ARGS}`, callId: 'code-1', + inputDetail: ARGS, outputDetail: 'printed line\n42', result: 'printed line', timeSeconds: 0.5, +} + +function Harness({ cell = CELL, getDefault = () => false, setDefault = () => {} }: { + cell?: TrajectoryCellProps + getDefault?: () => boolean + setDefault?: (value: boolean) => void +}) { + return null} t={t} + stringWrapping={{ label: 'Wrap lines', getDefault, setDefault }} + collapsedTurns={new Set()} onToggleTurn={() => {}} + collapsedAssistants={new Set()} onToggleAssistant={() => {}} + /> +} + +describe('PTC trajectory inspector', () => { + it('shows the description, numbered source, original arguments, and verbatim output', () => { + const view = render() + const ui = within(view.container) + fireEvent.click(ui.getByRole('row', { name: /run_code Read the working directory/ })) + expect(ui.getAllByRole('tab').map(tab => tab.textContent)).toEqual(['Summary', 'Code', 'Result', 'Schema', 'Timing']) + expect(ui.getByRole('tabpanel').textContent).toContain('Read the working directory') + expect(view.container.querySelector('[data-line-numbers] pre')?.textContent).toBe(SOURCE.trimEnd()) + expect(ui.getByRole('tabpanel').textContent).toContain('printed line\n42') + fireEvent.click(ui.getByRole('button', { name: 'Code' })) + expect(ui.getByRole('tab', { name: 'Code' }).getAttribute('aria-selected')).toBe('true') + fireEvent.click(ui.getByRole('button', { name: 'Original JSON' })) + expect(ui.getByRole('tree', { name: 'parameters JSON' }).textContent).toContain('description:') + fireEvent.click(ui.getByRole('button', { name: 'Original JSON' })) + expect(view.container.querySelector('[data-line-numbers] pre')?.textContent).toBe(SOURCE.trimEnd()) + }) + + it('copies exact source, raw JSON, and output with feedback', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('navigator', { clipboard: { writeText } }) + const view = render() + const ui = within(view.container) + fireEvent.click(ui.getByRole('row', { name: /run_code Read/ })) + fireEvent.click(ui.getByRole('button', { name: 'Copy code' })) + await waitFor(() => { expect(writeText).toHaveBeenLastCalledWith(SOURCE) }) + await waitFor(() => { expect(ui.getByRole('button', { name: 'Copied' })).toBeTruthy() }) + fireEvent.click(ui.getByRole('button', { name: 'Copy output' })) + await waitFor(() => { expect(writeText).toHaveBeenLastCalledWith('printed line\n42') }) + fireEvent.click(ui.getByRole('tab', { name: 'Code' })) + fireEvent.click(ui.getByRole('button', { name: 'Original JSON' })) + fireEvent.click(ui.getByRole('button', { name: 'Copy JSON' })) + await waitFor(() => { expect(writeText).toHaveBeenLastCalledWith(ARGS) }) + }) + + it('samples the last wrapping choice on open without changing another open inspector', () => { + let preference = false + const props = { getDefault: () => preference, setDefault: (value: boolean) => { preference = value } } + const first = render() + const second = render() + const a = within(first.container) + const b = within(second.container) + fireEvent.click(a.getByRole('row', { name: /run_code Read/ })) + fireEvent.click(b.getByRole('row', { name: /run_code Read/ })) + fireEvent.click(a.getByRole('button', { name: 'Wrap lines' })) + expect(preference).toBe(true) + expect(a.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('true') + expect(b.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('false') + fireEvent.click(b.getByRole('tab', { name: 'Code' })) + expect(b.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('true') + fireEvent.click(b.getByRole('button', { name: 'Wrap lines' })) + expect(preference).toBe(false) + expect(a.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('true') + fireEvent.click(a.getByRole('button', { name: 'Close details' })) + fireEvent.click(a.getByRole('row', { name: /run_code Read/ })) + expect(a.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('false') + }) + + it('shows pending execution and keeps a failed run’s captured output', () => { + const pending = { ...CELL, timeSeconds: null } + delete pending.result + delete pending.outputDetail + const view = render() + const ui = within(view.container) + fireEvent.click(ui.getByRole('row', { name: /run_code Read/ })) + expect(ui.getByRole('tabpanel').textContent).toContain('Running…') + view.rerender() + expect(ui.getByRole('tabpanel').textContent).toContain('Failed') + expect(ui.getByRole('tabpanel').textContent).toContain('Error: execution failed\nCaptured output:\nstep one') + }) + + it('uses a tree for complete JSON output and generic input for malformed arguments', () => { + const view = render() + const ui = within(view.container) + fireEvent.click(ui.getByRole('row', { name: /run_code Read/ })) + expect(ui.getByRole('tree', { name: 'Result JSON' }).textContent).toContain('passed:12') + view.rerender() + expect(ui.getByRole('tab', { name: 'Payload' })).toBeTruthy() + expect(ui.queryByRole('tab', { name: 'Code' })).toBeNull() + expect(ui.getByRole('tree', { name: 'Payload JSON' }).textContent).toContain('code:3') + }) +}) diff --git a/packages/client/ui-trajectory/tests/code-program.client.spec.ts b/packages/client/ui-trajectory/tests/code-program.client.spec.ts new file mode 100644 index 0000000000..a9bd21c807 --- /dev/null +++ b/packages/client/ui-trajectory/tests/code-program.client.spec.ts @@ -0,0 +1,70 @@ +/** Recorded PTC argument validation and historical language selection. */ +import { describe, expect, it } from 'vitest' +import { codeProgram } from '../src/client/code-program.ts' +import type { TrajectoryCellProps } from '../src/client/trajectory-record.ts' + +const CELL: TrajectoryCellProps = { + index: 1, kind: 'tool', toolName: 'run_code', text: 'run_code · {}', timeSeconds: 1, + inputDetail: JSON.stringify({ code: 'return 42\n', description: 'Compute the answer' }), +} + +describe('recorded code programs', () => { + it('keeps the program and arguments verbatim', () => { + expect(codeProgram(CELL)).toEqual({ + rawInput: CELL.inputDetail, + source: 'return 42\n', description: 'Compute the answer', + arguments: { code: 'return 42\n', description: 'Compute the answer' }, language: undefined, + }) + }) + + it.each([ + ['The program: the body of an async TypeScript function.', 'typescript'], + ['The program: the body of an async Python function.', 'python'], + ['A TypeScript or Python program.', undefined], + ['An executable program.', undefined], + ])('uses the recorded parameter description %s', (description, language) => { + const schemaDetail = JSON.stringify({ parameters: { properties: { code: { description } } } }) + expect(codeProgram({ ...CELL, schemaDetail })?.language).toBe(language) + }) + + it.each([undefined, '{', '{}', 'null', '[]'])('keeps missing or unusable schemas plain: %s', (schemaDetail) => { + expect(codeProgram({ ...CELL, ...(schemaDetail === undefined ? {} : { schemaDetail }) })?.language).toBeUndefined() + }) + + it('uses the first nonempty source line when a description is unavailable', () => { + const source = '\n const x = 1\nreturn x\n' + for (const description of [undefined, '', ' ']) { + expect(codeProgram({ ...CELL, inputDetail: JSON.stringify({ code: source, description }) })) + .toMatchObject({ source, description: 'const x = 1' }) + } + }) + + it('retains empty source and unknown argument fields for JSON inspection', () => { + expect(codeProgram({ ...CELL, inputDetail: '{"code":"","extra":true}' })) + .toMatchObject({ source: '', description: '', arguments: { code: '', extra: true } }) + }) + + it.each([undefined, '{', '[]', 'null', '42', '{}', '{"code":42}', '{"code":"x","description":[]}' ])( + 'declines incomplete or unsupported arguments: %s', (inputDetail) => { + const cell = { ...CELL } + if (inputDetail === undefined) delete cell.inputDetail + else cell.inputDetail = inputDetail + expect(codeProgram(cell)).toBeUndefined() + }, + ) + + it('uses recorded tool metadata independently of display text', () => { + expect(codeProgram({ ...CELL, text: 'Execute program' })?.source).toBe('return 42\n') + const withoutName = { ...CELL } + delete withoutName.toolName + expect(codeProgram(withoutName)).toBeUndefined() + const rawInput = ' { "code": "return 42" }\n' + expect(codeProgram({ ...CELL, inputDetail: rawInput })?.rawInput).toBe(rawInput) + }) + + it('does not specialize other tools or non-tool records', () => { + expect(codeProgram({ ...CELL, kind: 'message' })).toBeUndefined() + expect(codeProgram({ ...CELL, toolName: 'run_code_extra' })).toBeUndefined() + expect(codeProgram({ ...CELL, kind: 'subtool' })?.source).toBe('return 42\n') + }) +}) diff --git a/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts b/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts index a3a0623be2..1f15cc202a 100644 --- a/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts +++ b/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts @@ -335,13 +335,20 @@ describe('Trajectory conversation Definitions', () => { const finalizedPacked = snapshot(assembler(finalizedInputs)) expect(finalizedPacked.eventNodes.find(node => node.kind === 'assistant')).toMatchObject({ blocks: [{ kind: 'text', text: 'done' }], - timing: { firstTokenTime: null }, + timing: { firstTokenTime: 3_000 }, }) expect(finalizedPacked.requests).toMatchObject([{ purpose: 'assistant', retry: 1, }]) + const windowed = assembler(finalizedInputs.slice(2), true) + const assistant = () => snapshot(windowed).eventNodes.find(node => node.kind === 'assistant') + expect(assistant()).toMatchObject({ timing: { stepStartTime: null, firstTokenTime: 3_000 } }) + windowed.prepend(finalizedInputs.slice(0, 2), false) + windowed.flush() + expect(assistant()).toEqual(finalizedPacked.eventNodes.find(node => node.kind === 'assistant')) + const namedToolHistory = [ at(40, 'turn/start', { turn: 3 }), at(41, 'step/start', { turn: 3, step: 1 }), @@ -365,7 +372,7 @@ describe('Trajectory conversation Definitions', () => { const namedToolPacked = snapshot(assembler(namedToolInputs)) expect(namedToolPacked.eventNodes.find(node => node.kind === 'assistant')).toMatchObject({ blocks: [{ kind: 'tool-call', callId: 'call-2', name: 'read', argsRaw: '' }], - timing: { firstTokenTime: null }, + timing: { firstTokenTime: 4_000 }, }) }) diff --git a/packages/client/ui-trajectory/tests/string-wrapping-store.client.spec.ts b/packages/client/ui-trajectory/tests/string-wrapping-store.client.spec.ts new file mode 100644 index 0000000000..7ab269f2cf --- /dev/null +++ b/packages/client/ui-trajectory/tests/string-wrapping-store.client.spec.ts @@ -0,0 +1,26 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, expect, it } from 'vitest' +import { createTrajectoryStringWrappingStore } from '../src/client/string-wrapping-store.ts' + +const key = 'dsh.trajectory.jsonStringWrapping' +let original: string | null + +beforeEach(() => { + original = localStorage.getItem(key) + localStorage.removeItem(key) +}) + +afterEach(() => { + if (original === null) localStorage.removeItem(key) + else localStorage.setItem(key, original) +}) + +it('restores the last wrapping choice when the view is recreated', () => { + const first = createTrajectoryStringWrappingStore() + expect(first.getSnapshot()).toBe(false) + first.set(true) + const second = createTrajectoryStringWrappingStore() + expect(second.getSnapshot()).toBe(true) + second.set(false) + expect(createTrajectoryStringWrappingStore().getSnapshot()).toBe(false) +}) diff --git a/packages/client/ui-trajectory/tests/table.client.spec.tsx b/packages/client/ui-trajectory/tests/table.client.spec.tsx index b16475162a..5a882141bb 100644 --- a/packages/client/ui-trajectory/tests/table.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.client.spec.tsx @@ -231,7 +231,12 @@ describe('TrajectoryTable', () => { expect(panel.querySelector('[data-summary-scroll-region]')).toBeNull() }) - it('keeps long thinking collapsed until the user asks to render it', () => { + it.each([ + { outputDetail: undefined, toolCall: false }, + { outputDetail: 'Visible answer', toolCall: false }, + { outputDetail: undefined, toolCall: true }, + { outputDetail: 'Visible answer', toolCall: true }, + ])('opens thinking with output=$outputDetail and toolCall=$toolCall', ({ outputDetail, toolCall }) => { const thinking = 'private chain '.repeat(1_000) const turns: readonly TrajectoryTurnModel[] = [{ turn: 1, @@ -242,6 +247,10 @@ describe('TrajectoryTable', () => { kind: 'message', text: 'private chain…', thinkingDetail: thinking, + ...(outputDetail === undefined ? {} : { outputDetail }), + ...(toolCall ? { sourceBlocks: [{ + type: 'tool-call', content: '{}', callId: 'call-1', toolName: 'read', + }] } : {}), timeSeconds: 1, }], }], @@ -249,14 +258,39 @@ describe('TrajectoryTable', () => { render() fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ })) + fireEvent.click(screen.getByRole('tab', { name: 'Preview' })) const toggle = screen.getByRole('button', { name: 'Thinking' }) - expect(toggle.getAttribute('aria-expanded')).toBe('false') - expect(screen.queryByText(thinking)).toBeNull() - - fireEvent.click(toggle) - expect(screen.getByRole('button', { name: 'Thinking' })).toBe(toggle) expect(toggle.getAttribute('aria-expanded')).toBe('true') expect(toggle.parentElement?.textContent?.length).toBeGreaterThan(thinking.length) + + fireEvent.click(toggle) + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByText(thinking)).toBeNull() + fireEvent.click(screen.getByRole('tab', { name: 'Summary' })) + expect(screen.getByRole('button', { name: 'Thinking' }).getAttribute('aria-expanded')).toBe('false') + fireEvent.click(screen.getByRole('tab', { name: 'Preview' })) + fireEvent.click(screen.getByRole('button', { name: 'Thinking' })) + expect(screen.getByRole('button', { name: 'Thinking' }).getAttribute('aria-expanded')).toBe('true') + }) + + it('opens thinking on another record after collapsing the selected record', () => { + const turns: readonly TrajectoryTurnModel[] = [{ + turn: 1, + groups: [{ + title: 'Step 1', + cells: [1, 2].map(index => ({ + index, kind: 'message', text: `Answer ${index}`, outputDetail: `Answer ${index}`, + thinkingDetail: `Reasoning ${index}`, timeSeconds: 1, + })), + }], + }] + render() + fireEvent.click(screen.getByRole('row', { name: /ASSISTANT, Answer 1/ })) + fireEvent.click(screen.getByRole('button', { name: 'Thinking' })) + expect(screen.getByRole('button', { name: 'Thinking' }).getAttribute('aria-expanded')).toBe('false') + fireEvent.click(screen.getByRole('row', { name: /ASSISTANT, Answer 2/ })) + expect(screen.getByRole('button', { name: 'Thinking' }).getAttribute('aria-expanded')).toBe('true') + expect(screen.getByText('Reasoning 2')).toBeTruthy() }) it('keeps raw HTML tags in a Markdown-derived context preview', () => { diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx index 79a0e14b0e..d0a00549d4 100644 --- a/packages/client/ui-trajectory/tests/views.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx @@ -538,7 +538,7 @@ describe('tab switching in ConversationRoot', () => { fireEvent.keyDown(screen.getByRole('row', { name: /工具/ }), { key: 'Enter' }) expect(screen.getByRole('complementary', { name: '事件详情' })).toBeTruthy() - expect(screen.getByText('第 1 轮 · 步骤 1')).toBeTruthy() + expect(screen.getByText('第 1 轮 · 第 1 步')).toBeTruthy() expect(screen.getByText('已完成')).toBeTruthy() expect(screen.getByRole('tab', { name: '结果' })).toBeTruthy() diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index f343e0f1a5..ea086375d7 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1150,6 +1150,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'client-ui-agent-preset AgentPresetLabel id \'agent-preset\'', 'client-ui-jobs JobListAction id \'job-list\'', 'client-ui-schedule ScheduleCatalogAction id \'schedule-catalog\'', + 'client-ui-sidebar-terminal TerminalRecovery', 'experimental-client-ui-agent-team TeamAction id \'agent-team\'', ], replaceRisk: 'none', @@ -2071,7 +2072,9 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ hookContext: '', slotInject: '', declaredBy: 'an entry in \'root\' (client-ui-layout), so it exists while that entry is mounted', - occupants: [], + occupants: [ + 'client-ui-sidebar-terminal TerminalCleanup', + ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'shell.overlay\', () => ctx.slots.register(\n { name: \'shell.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', source: 'packages/client/ui-layout/src/client/index.ts:91', @@ -2302,6 +2305,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'client-ui-sidebar-documentpreview TextPreview', 'client-ui-sidebar-files FilesBody', 'client-ui-sidebar-right GuideBody', + 'client-ui-sidebar-terminal TerminalBody', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.pane.tab\', () => ctx.slots.register(\n { name: \'sidebar.right.pane.tab\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', @@ -2347,6 +2351,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'client-ui-sidebar-documentpreview TextTitle', 'client-ui-sidebar-files FilesTitle', 'client-ui-sidebar-right GuideTitle', + 'client-ui-sidebar-terminal TerminalTitle', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.pane.tab.title\', () => ctx.slots.register(\n { name: \'sidebar.right.pane.tab.title\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 987788853b..ed9336bb31 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2439,6 +2439,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'command', description: 'absolute executable path or bare PATH name.' }, { name: 'env', description: 'explicit environment entries used for lookup.' }, { name: 'signal', description: 'aborts remote or local lookup.' }], returns: 'a canonical executable path.', }, + { + signature: 'abstract terminalEnvironment(signal?: AbortSignal): Promise', + description: 'Inspect shell-selection facts in the provider\'s execution environment.', + parameters: [{ name: 'signal', description: 'cancellation of remote environment inspection.' }], + returns: 'platform and preferred shell; executable lookup and allocation remain separate operations.', + }, { signature: 'abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle', description: 'Start one managed child process from a fully-specified spec; this seam applies no defaults.', @@ -2509,6 +2515,66 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'terminalController', + summary: 'Typed Remote control of transient Session-owned terminal processes.', + description: 'Typed Remote control of transient Session-owned terminal processes.', + methods: [ + { + signature: '@Remote environment(agent: Agent, signal: AbortSignal): TerminalEnvironment', + description: 'Read the Session working directory and terminal limits without resolving a shell.', + parameters: [{ name: 'agent', description: 'Session owner supplied by the Gateway.' }, { name: 'signal', description: 'request cancellation.' }], + returns: 'the Session workspace directory and terminal limits.', + }, + { + signature: '@Remote shells(agent: Agent, signal: AbortSignal): Promise', + description: 'Discover installed shells in the Session\'s execution environment.', + parameters: [{ name: 'agent', description: 'Session owner supplied by the Gateway.' }, { name: 'signal', description: 'request cancellation.' }], + returns: 'verified profiles, with the configured or system default first.', + }, + { + signature: '@Remote list(sessionId: SessionId): WebTerminalInfo[]', + description: 'List retained terminals without resolving or activating an Agent.', + parameters: [{ name: 'sessionId', description: 'displayed Session identity, including offline history.' }], + returns: 'terminals retained for this Host lifetime.', + }, + { + signature: '@Remote async create(agent: Agent, request: TerminalCreateRequest, signal: AbortSignal): Promise', + description: 'Allocate an interactive shell once for a caller-generated identity.', + parameters: [{ name: 'agent', description: 'Session owner supplied by the Gateway.' }, { name: 'request', description: 'initial dimensions and idempotency identity.' }, { name: 'signal', description: 'allocation cancellation; committed terminals survive disconnection.' }], + returns: 'the existing or newly committed terminal.', + }, + { + signature: '@Remote({ mode: \'stream\' }) follow(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, signal: AbortSignal): AsyncIterable', + description: 'Attach to a terminal without binding its process lifetime to the transport.', + parameters: [{ name: 'agent', description: 'Session owner supplied by the Gateway.' }, { name: 'id', description: 'terminal identity.' }, { name: 'attachmentId', description: 'new exclusive input attachment.' }, { name: 'signal', description: 'physical stream cancellation.' }], + returns: 'screen recovery followed by output and metadata changes.', + }, + { + signature: '@Remote async write(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, data: string): Promise', + description: 'Deliver raw input, including Tab completion and control characters.', + parameters: [{ name: 'agent', description: 'Session owner supplied by the Gateway.' }, { name: 'id', description: 'terminal identity.' }, { name: 'attachmentId', description: 'current writable attachment.' }, { name: 'data', description: 'input bytes represented as UTF-8 text.' }], + returns: 'after provider input acceptance.', + }, + { + signature: '@Remote async resize(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, cols: number, rows: number): Promise', + description: 'Update the dimensions of the PTY and recovery screen.', + parameters: [{ name: 'agent', description: 'Session owner supplied by the Gateway.' }, { name: 'id', description: 'terminal identity.' }, { name: 'attachmentId', description: 'current writable attachment.' }, { name: 'cols', description: 'column count.' }, { name: 'rows', description: 'row count.' }], + returns: 'after the resize completes.', + }, + { + signature: '@Remote rename(agent: Agent, id: WebTerminalId, title: string): void', + description: 'Rename a terminal without changing its shell.', + parameters: [{ name: 'agent', description: 'Session owner supplied by the Gateway.' }, { name: 'id', description: 'terminal identity.' }, { name: 'title', description: 'nonempty display title, at most 120 characters.' }], + }, + { + signature: '@Remote async close(agent: Agent, id: WebTerminalId): Promise', + description: 'Close an identity to future creation and kill its process range; repeated closes succeed.', + parameters: [{ name: 'agent', description: 'Session owner supplied by the Gateway.' }, { name: 'id', description: 'terminal identity.' }], + returns: 'after provider cleanup succeeds. A failure retains the terminal for retry.', + }, + ], + }, { key: 'terminals', summary: 'In-process registry for replaceable PTY backends and exact-Agent sessions.', @@ -5942,13 +6008,17 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SubprocessStdio', declaration: 'export interface SubprocessStdio {\n stdin: SubprocessStdinMode;\n stdout: SubprocessOutputMode;\n stderr: SubprocessOutputMode;\n control?: \'pipe\';\n}', }, + { + name: 'SubprocessTerminalEnvironment', + declaration: 'export interface SubprocessTerminalEnvironment {\n platform: \'posix\' | \'windows\';\n defaultShell?: string;\n}', + }, { name: 'SubprocessTerminalForeground', declaration: 'export interface SubprocessTerminalForeground {\n processGroupId: number;\n inputWaiting: boolean;\n}', }, { name: 'SubprocessTerminalHandle', - declaration: 'export interface SubprocessTerminalHandle {\n readonly pid: number;\n readonly output: Readable;\n readonly done: Promise;\n write(data: string): Promise;\n inspectForeground(): Promise;\n signalForeground(signal: SubprocessTerminalSignal): Promise;\n terminate(): Promise;\n}', + declaration: 'export interface SubprocessTerminalHandle {\n readonly pid: number;\n readonly output: Readable;\n readonly done: Promise;\n write(data: string): Promise;\n resize(cols: number, rows: number): Promise;\n inspectForeground(): Promise;\n signalForeground(signal: SubprocessTerminalSignal): Promise;\n terminate(): Promise;\n}', }, { name: 'SubprocessTerminalSignal', @@ -5956,7 +6026,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubprocessTerminalSpawnSpec', - declaration: 'export interface SubprocessTerminalSpawnSpec {\n argv: readonly string[];\n cwd: string;\n env?: Record | undefined;\n rows: number;\n cols: number;\n graceMs: number;\n signal?: AbortSignal | undefined;\n}', + declaration: 'export interface SubprocessTerminalSpawnSpec {\n argv: readonly string[];\n cwd: string;\n env?: Record | undefined;\n rows: number;\n cols: number;\n terminalType: string;\n graceMs: number;\n signal?: AbortSignal | undefined;\n}', }, { name: 'SurfaceEvent', @@ -6038,6 +6108,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'TeamWaitResult', declaration: 'export interface TeamWaitResult {\n readonly timedOut: boolean;\n}', }, + { + name: 'TerminalAttachmentId', + declaration: 'export type TerminalAttachmentId = Branded<\'TerminalAttachmentId\'>;', + }, { name: 'TerminalBackend', declaration: 'export interface TerminalBackend {\n readonly type: string;\n spawn(spec: TerminalBackendSpawnSpec): Promise;\n}', @@ -6054,6 +6128,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'TerminalCallView', declaration: 'export interface TerminalCallView {\n card: \'terminal\';\n title: string;\n description?: string;\n cwd?: string;\n}', }, + { + name: 'TerminalCreateRequest', + declaration: 'export interface TerminalCreateRequest {\n readonly shellPath?: string;\n readonly id: WebTerminalId;\n readonly cols: number;\n readonly rows: number;\n}', + }, + { + name: 'TerminalEnvironment', + declaration: 'export interface TerminalEnvironment {\n readonly cwd: string;\n readonly maxInputBytes: number;\n readonly maxCols: number;\n readonly maxRows: number;\n readonly scrollback: number;\n}', + }, + { + name: 'TerminalFrame', + declaration: 'export type TerminalFrame = {\n readonly type: \'snapshot\';\n readonly sequence: number;\n readonly screen: string;\n readonly info: WebTerminalInfo;\n} | {\n readonly type: \'output\';\n readonly sequence: number;\n readonly data: string;\n} | {\n readonly type: \'state\';\n readonly info: WebTerminalInfo;\n};', + }, { name: 'TerminalReadRequest', declaration: 'export interface TerminalReadRequest {\n offset?: number;\n count?: number;\n}', @@ -6098,6 +6184,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'TerminalSessionStatus', declaration: 'export type TerminalSessionStatus = {\n kind: \'running\';\n} | {\n kind: \'exited\';\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n};', }, + { + name: 'TerminalShell', + declaration: 'export interface TerminalShell {\n readonly path: string;\n readonly args: readonly string[];\n readonly name: string;\n}', + }, { name: 'TerminalSignal', declaration: 'export type TerminalSignal = \'SIGINT\' | \'SIGTERM\' | \'SIGKILL\' | \'SIGTSTP\' | \'SIGHUP\';', @@ -6466,6 +6556,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'WebSource', declaration: 'export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n}', }, + { + name: 'WebTerminalId', + declaration: 'export type WebTerminalId = Branded<\'WebTerminalId\'>;', + }, + { + name: 'WebTerminalInfo', + declaration: 'export interface WebTerminalInfo {\n readonly id: WebTerminalId;\n readonly title: string;\n readonly shell: TerminalShell;\n readonly cwd: string;\n readonly cols: number;\n readonly rows: number;\n readonly state: \'running\' | \'exited\' | \'failed\';\n readonly exitCode: number | null;\n readonly error?: string;\n readonly controllerId?: TerminalAttachmentId;\n}', + }, { name: 'WebUpgradeRoute', declaration: 'export interface WebUpgradeRoute {\n path: string;\n handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise;\n}', diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index be3bef5b5b..40ed836309 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -110,7 +110,8 @@ describe('resolveLocalTarget', () => { await mkdir(join(dir, 'nested')) await writeFile(join(dir, 'created.txt'), 'native') const drive = parse(dir).root.slice(0, 2) - const driveCwd = `${drive}${relative(process.cwd(), dir)}` + // Resolve the temp drive independently when the checkout lives on another volume. + const driveCwd = `${drive}${relative(resolve(drive), dir)}` for (const cwd of [dir, driveCwd]) { for (const requested of ['created.txt', `${drive}nested/../created.txt`]) { const target = await resolveLocalTarget(cwd, requested) diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index 212cf5750f..22511aef39 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -148,6 +148,7 @@ class FakeHandle implements SubprocessHandle { * assert on the exact spawn specs and settled handles. */ class FakeSubprocess extends SubprocessRuntime { + async terminalEnvironment() { return { platform: 'posix' as const } } spawns: SubprocessSpawnSpec[] = [] override async resolveExecutable(command: string): Promise { return command } override spawnTerminal(): Promise { throw new Error('search tools spawn pipes, never terminals') } diff --git a/packages/shell/pwsh-local/tests/executor.spec.ts b/packages/shell/pwsh-local/tests/executor.spec.ts index 227eac7b6a..1e21231a26 100644 --- a/packages/shell/pwsh-local/tests/executor.spec.ts +++ b/packages/shell/pwsh-local/tests/executor.spec.ts @@ -188,6 +188,7 @@ describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () => describe('spawn construction (pure, every platform)', () => { /** A subprocess service that records spawn specs and settles instantly. */ class CapturingSubprocessRuntime extends SubprocessRuntime { + async terminalEnvironment() { return { platform: 'posix' as const } } specs: SubprocessSpawnSpec[] = [] done: Promise = Promise.resolve({ exitCode: 0, signal: null }) stderrText = '' diff --git a/packages/ssh/ssh/src/helper-processes.ts b/packages/ssh/ssh/src/helper-processes.ts index e52945378b..c672a2deba 100644 --- a/packages/ssh/ssh/src/helper-processes.ts +++ b/packages/ssh/ssh/src/helper-processes.ts @@ -331,6 +331,19 @@ export class RemoteProcesses { return terminal.signalForeground(z.enum(['SIGINT', 'SIGTERM', 'SIGKILL', 'SIGTSTP', 'SIGHUP']).parse(value)) } + /** + * Resize an allocated terminal without replacing its process. + * @param id - terminal reservation. + * @param cols - positive terminal width. + * @param rows - positive terminal height. + * @returns after the local provider accepts the dimensions. + */ + async resizeTerminal(id: SshProcessId, cols: number, rows: number): Promise { + const terminal = this.record(id).terminal + if (terminal === undefined) throw new Error('SSH handle does not own a terminal') + await terminal.resize(cols, rows) + } + /** Stop every owned process on lease expiry or disconnect. */ async close(): Promise { if (this.closing) return diff --git a/packages/ssh/ssh/src/helper.ts b/packages/ssh/ssh/src/helper.ts index 61c1c82404..519d68a4a2 100644 --- a/packages/ssh/ssh/src/helper.ts +++ b/packages/ssh/ssh/src/helper.ts @@ -6,13 +6,14 @@ import type { Readable, Writable } from 'node:stream' import { Context } from '@deepseek-ai/cordis' import { FsError, type FsTarget, type FsWriteIntent, type FsVersion } from '@deepseek-ai/dsh-fs' import { SandboxedFileSystem } from '@deepseek-ai/dsh-fs-sandbox' +import { SubprocessExecutableNotFoundError } from '@deepseek-ai/dsh-subprocess' import { LocalSubprocessRuntime } from '@deepseek-ai/dsh-subprocess-local' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { SessionProjectionRegistry } from '@deepseek-ai/dsh-session-projection' import type { SandboxExecutionPolicy, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { z } from 'zod' -import { SshRpcPeer, SSH_MAX_PROCESS_HANDLES, SSH_MAX_TEXT_STREAMS, SSH_PROTOCOL_VERSION } from './protocol.ts' +import { SshRpcPeer, RemoteOperationError, SSH_MAX_PROCESS_HANDLES, SSH_MAX_TEXT_STREAMS, SSH_PROTOCOL_VERSION } from './protocol.ts' import { RemoteProcesses } from './helper-processes.ts' import { editSchema, environmentSchema, intentSchema, policySchema, processIdSchema, remotePath, targetSchema, textStreamIdSchema } from './schemas.ts' import type { SshTextStreamId } from './schemas.ts' @@ -114,6 +115,14 @@ export async function runSshHelper(transport: HelperTransport): Promise { if (method === 'process.done') return processes.done(processIdRequest.parse(raw).id) if (method === 'process.wait') return processes.wait(processIdRequest.parse(raw).id, signal) if (method === 'process.terminate') { await processes.terminate(processIdRequest.parse(raw).id); return null } + if (method === 'terminal.environment') { object.parse(raw); return ctx.subprocess.terminalEnvironment(signal) } + if (method === 'terminal.resize') { + const input = z.object({ + id: processIdSchema, cols: z.number().int().positive(), rows: z.number().int().positive(), + }).strict().parse(raw) + await processes.resizeTerminal(input.id, input.cols, input.rows) + return null + } if (method === 'terminal.write' || method === 'terminal.inspect' || method === 'terminal.signal') { const input = z.object({ id: processIdSchema, value: z.string().optional() }).strict().parse(raw) return processes.terminal(input.id, method === 'terminal.write' ? 'write' : method === 'terminal.inspect' ? 'inspect' : 'signal', input.value) @@ -123,7 +132,12 @@ export async function runSshHelper(transport: HelperTransport): Promise { const env = input.env === undefined ? undefined : Object.fromEntries( Object.entries(input.env).filter((entry): entry is [string, string] => entry[1] !== null), ) - return ctx.subprocess.resolveExecutable(input.command, env, signal) + try { + return await ctx.subprocess.resolveExecutable(input.command, env, signal) + } catch (error) { + if (error instanceof SubprocessExecutableNotFoundError) throw new RemoteOperationError(error.message, 'SUBPROCESS_EXECUTABLE_NOT_FOUND') + throw error + } } if (method === 'sandbox') { const input = z.object({ argv: z.array(z.string()).min(1), policy: policySchema }).strict().parse(raw) diff --git a/packages/ssh/ssh/src/schemas.ts b/packages/ssh/ssh/src/schemas.ts index 23b754067e..1b7db7dc92 100644 --- a/packages/ssh/ssh/src/schemas.ts +++ b/packages/ssh/ssh/src/schemas.ts @@ -48,7 +48,9 @@ export const spawnSchema = z.object({ stderr: z.union([z.literal('pipe'), z.literal('inherit'), collection]), control: z.literal('pipe').optional(), }).strict().optional(), - terminal: z.object({ rows: z.number().int().positive(), cols: z.number().int().positive() }).strict().optional(), + terminal: z.object({ + terminalType: z.string().min(1), rows: z.number().int().positive(), cols: z.number().int().positive(), + }).strict().optional(), }).strict().refine(value => (value.stdio === undefined) !== (value.terminal === undefined), 'select ordinary or terminal execution') /** Connection handshake binds sockets and workspace to one helper process. */ export const helloSchema = z.object({ protocol: z.literal(1), hash: z.string().regex(/^[0-9a-f]{64}$/), platform: z.enum(['linux', 'darwin']), nodeVersion: z.string(), node: remotePath, root: remotePath, workspace: remotePath, bootstrapHash: z.string().regex(/^[0-9a-f]{64}$/).optional() }).strict() diff --git a/packages/ssh/ssh/tests/helper-processes.spec.ts b/packages/ssh/ssh/tests/helper-processes.spec.ts index a7b42bd9d8..c94ffb7c8a 100644 --- a/packages/ssh/ssh/tests/helper-processes.spec.ts +++ b/packages/ssh/ssh/tests/helper-processes.spec.ts @@ -19,7 +19,7 @@ vi.mock('node:fs/promises', async (original) => { const outcome = { exitCode: 0, signal: null } const ordinaryRequest = { argv: ['target'], cwd: '/tmp', graceMs: 20, stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' } } -const terminalRequest = { argv: ['target'], cwd: '/tmp', graceMs: 20, terminal: { rows: 24, cols: 80 } } +const terminalRequest = { argv: ['target'], cwd: '/tmp', graceMs: 20, terminal: { terminalType: 'dumb', rows: 24, cols: 80 } } async function harness() { const root = await mkdtemp('/tmp/dsh-ssh-owner-') @@ -76,10 +76,12 @@ function terminal() { const inspectForeground = vi.fn(async () => undefined) const signalForeground = vi.fn(async () => 42) const terminate = vi.fn(async () => { output.end(); completion.resolve(outcome) }) + const resize = vi.fn(async (_cols: number, _rows: number) => {}) const handle: SubprocessTerminalHandle = { + resize, pid: 42, output, done: completion.promise, write, inspectForeground, signalForeground, terminate, } - return { handle, completion, output, write, inspectForeground, signalForeground, terminate } + return { handle, completion, output, write, resize, inspectForeground, signalForeground, terminate } } describe.skipIf(process.platform === 'win32')('SSH helper process settlement', () => { @@ -99,6 +101,8 @@ describe.skipIf(process.platform === 'win32')('SSH helper process settlement', ( await expect(test.owner.start(run.id)).rejects.toThrow('already requested') await test.owner.terminal(run.id, 'write', 'input') expect(child.write).toHaveBeenCalledWith('input') + await test.owner.resizeTerminal(run.id, 120, 40) + expect(child.resize).toHaveBeenCalledWith(120, 40) expect(await test.owner.terminal(run.id, 'inspect')).toBeNull() child.inspectForeground.mockResolvedValueOnce({ processGroupId: 42, inputWaiting: true }) expect(await test.owner.terminal(run.id, 'inspect')).toEqual({ processGroupId: 42, inputWaiting: true }) @@ -144,6 +148,7 @@ describe.skipIf(process.platform === 'win32')('SSH helper process settlement', ( const run = await test.prepare({ ...ordinaryRequest, env: { VALUE: 'kept', REMOVED: null }, stdio: { ...ordinaryRequest.stdio, stdin: 'pipe' } }) await expect(test.owner.done(run.id)).rejects.toThrow('has not started') await expect(test.owner.wait(run.id)).rejects.toThrow('was not started') + await expect(test.owner.resizeTerminal(run.id, 80, 24)).rejects.toThrow('does not own a terminal') const bytes: Buffer[] = [] child.stdin.on('data', (chunk: Buffer) => { bytes.push(Buffer.from(chunk)) }) const ended = once(child.stdin, 'end') diff --git a/packages/ssh/ssh/tests/helper-runtime.spec.ts b/packages/ssh/ssh/tests/helper-runtime.spec.ts index 1e4aa21070..3336986a8e 100644 --- a/packages/ssh/ssh/tests/helper-runtime.spec.ts +++ b/packages/ssh/ssh/tests/helper-runtime.spec.ts @@ -93,6 +93,9 @@ describe.skipIf(process.platform === 'win32')('SSH helper runtime', () => { await expect(test.client.request('sandbox', { argv: ['true'], policy: { mode: 'danger-full-access', workspaceRoot: test.root } }, z.unknown())).rejects.toThrow('does not need') expect(await test.client.request('executable', { command: process.execPath, env: { REMOVED: null } }, z.string())).toBe(process.execPath) expect(await test.client.request('executable', { command: process.execPath }, z.string())).toBe(process.execPath) + expect(await test.client.request('terminal.environment', {}, z.unknown())).toMatchObject({ platform: 'posix' }) + await expect(test.client.request('executable', { command: `${test.root}/absent` }, z.string())).rejects.toMatchObject({ code: 'SUBPROCESS_EXECUTABLE_NOT_FOUND' }) + await expect(test.client.request('executable', { command: './relative' }, z.string())).rejects.toThrow('relative') const wrapped = await test.client.request('sandbox', { argv: ['true'], policy: policy(test.root) }, z.looseObject({ argv: z.array(z.string()), enforcement: z.enum(['full', 'partial']) })) expect(wrapped.argv).toEqual(['confined', 'true']) expect(confine).toHaveBeenCalledWith(['true'], policy(test.root), expect.any(AbortSignal)) diff --git a/packages/ssh/ssh/tests/live.e2e.ts b/packages/ssh/ssh/tests/live.e2e.ts index 045ea794d6..6fd71de3c5 100644 --- a/packages/ssh/ssh/tests/live.e2e.ts +++ b/packages/ssh/ssh/tests/live.e2e.ts @@ -191,7 +191,7 @@ describe.skipIf(!enabled)('POSIX SSH runtime acceptance', () => { const test = await setup() try { const terminal = await test.ctx.subprocess.spawnTerminal({ - argv: ['/bin/bash', '--noprofile', '--norc', '-i'], cwd: test.root, rows: 24, cols: 80, graceMs: 500, + terminalType: 'dumb', argv: ['/bin/bash', '--noprofile', '--norc', '-i'], cwd: test.root, rows: 24, cols: 80, graceMs: 500, }) const output: string[] = [] const received = Promise.withResolvers() diff --git a/packages/ssh/ssh/tests/stream-capability.spec.ts b/packages/ssh/ssh/tests/stream-capability.spec.ts index fc1ca16187..9729a9f50d 100644 --- a/packages/ssh/ssh/tests/stream-capability.spec.ts +++ b/packages/ssh/ssh/tests/stream-capability.spec.ts @@ -21,7 +21,7 @@ describe.skipIf(process.platform === 'win32')('SSH stream capabilities', () => { const owner = new RemoteProcesses(new Context(), root, 1, 5000) let stream: Socket | undefined try { - const prepared = await owner.prepare({ argv: ['true'], cwd: root, graceMs: 100, terminal: { rows: 24, cols: 80 } }) + const prepared = await owner.prepare({ argv: ['true'], cwd: root, graceMs: 100, terminal: { terminalType: 'dumb', rows: 24, cols: 80 } }) const endpoint = prepared.streams.terminal! const raw = createConnection(endpoint.path) await once(raw, 'connect') @@ -40,7 +40,7 @@ describe.skipIf(process.platform === 'win32')('SSH stream capabilities', () => { const owner = new RemoteProcesses(new Context(), root, 1, 5000) const sockets: Socket[] = [] try { - const prepared = await owner.prepare({ argv: ['true'], cwd: root, graceMs: 100, terminal: { rows: 24, cols: 80 } }) + const prepared = await owner.prepare({ argv: ['true'], cwd: root, graceMs: 100, terminal: { terminalType: 'dumb', rows: 24, cols: 80 } }) const endpoint = prepared.streams.terminal! const raw = [createConnection(endpoint.path), createConnection(endpoint.path)] await Promise.all(raw.map(socket => once(socket, 'connect'))) diff --git a/packages/ssh/ssh/tests/stream-lifetime.spec.ts b/packages/ssh/ssh/tests/stream-lifetime.spec.ts index 9f88a2224e..2feeb1d52e 100644 --- a/packages/ssh/ssh/tests/stream-lifetime.spec.ts +++ b/packages/ssh/ssh/tests/stream-lifetime.spec.ts @@ -12,7 +12,7 @@ describe('SSH TLS cancellation ownership', () => { const root = await mkdtemp('/tmp/dsh-ssh-tls-life-') const owner = new RemoteProcesses(new Context(), root, 1, 5000) try { - const prepared = await owner.prepare({ argv: ['true'], cwd: root, graceMs: 100, terminal: { rows: 24, cols: 80 } }) + const prepared = await owner.prepare({ argv: ['true'], cwd: root, graceMs: 100, terminal: { terminalType: 'dumb', rows: 24, cols: 80 } }) const endpoint = prepared.streams.terminal! const raw = createConnection({ path: endpoint.path, allowHalfOpen: true }) raw.on('error', () => {}) diff --git a/packages/ssh/subprocess-ssh/README.i18n.yaml b/packages/ssh/subprocess-ssh/README.i18n.yaml index df7872748e..4b47b611a5 100644 --- a/packages/ssh/subprocess-ssh/README.i18n.yaml +++ b/packages/ssh/subprocess-ssh/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ssh/subprocess-ssh/README.md -README.md: ea647cd15b97ae281e145c5ac3afbf1e4cc4740c -README.zh.md: 455da9cade52540eb69581ddc3ec858553c444a5 +README.md: 15afa74f041e9944036c451dacaaedb53ecd9ff9 +README.zh.md: 3417f184fbcb702edeadf3b510663b1a9a58192f diff --git a/packages/ssh/subprocess-ssh/README.md b/packages/ssh/subprocess-ssh/README.md index ea647cd15b..15afa74f04 100644 --- a/packages/ssh/subprocess-ssh/README.md +++ b/packages/ssh/subprocess-ssh/README.md @@ -33,6 +33,8 @@ A direct exit reported by `done` does not prove managed-range quiescence; `waitF ----- +Terminal shell facts and executable verification come from the remote helper. Lookup misses remain distinct from SSH failures. PTY creation forwards the caller's `terminalType`, and `resize()` updates the remote terminal without replacing its process. + ## Understand the implementation diff --git a/packages/ssh/subprocess-ssh/README.zh.md b/packages/ssh/subprocess-ssh/README.zh.md index 455da9cade..3417f184fb 100644 --- a/packages/ssh/subprocess-ssh/README.zh.md +++ b/packages/ssh/subprocess-ssh/README.zh.md @@ -33,6 +33,8 @@ kind: "package-reference" ----- +终端 shell 信息与可执行文件验证来自远端 helper。查找未命中与 SSH 故障分别报告。PTY 创建传递调用方的 `terminalType`,`resize()` 更新远端终端尺寸,不替换进程。 + ## 理解实现 diff --git a/packages/ssh/subprocess-ssh/src/index.ts b/packages/ssh/subprocess-ssh/src/index.ts index da69932102..ea2ba64907 100644 --- a/packages/ssh/subprocess-ssh/src/index.ts +++ b/packages/ssh/subprocess-ssh/src/index.ts @@ -2,13 +2,13 @@ import { Duplex, PassThrough, type Readable, type Writable } from 'node:stream' import type { Socket } from 'node:net' import { Context } from '@deepseek-ai/cordis' -import { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess' -import type { SubprocessCollectedOutputs, SubprocessHandle, SubprocessOutcome, SubprocessOutputMode, SubprocessSpawnSpec, SubprocessTerminalHandle, SubprocessTerminalSignal, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { SubprocessRuntime, SubprocessExecutableNotFoundError } from '@deepseek-ai/dsh-subprocess' +import type { SubprocessCollectedOutputs, SubprocessHandle, SubprocessOutcome, SubprocessOutputMode, SubprocessSpawnSpec, SubprocessTerminalHandle, SubprocessTerminalEnvironment, SubprocessTerminalSignal, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { OutputCollector } from '@deepseek-ai/dsh-subprocess-local/output' import type { SshConnection } from '@deepseek-ai/dsh-ssh' import { doneSchema, foregroundSchema, outputSnapshotFrameLimit, outputSnapshotSchema, preparedSchema, remotePath, streamEndpointSchema } from '@deepseek-ai/dsh-ssh/schemas' import type { SshProcessId } from '@deepseek-ai/dsh-ssh/schemas' -import { SshRpcPeer } from '@deepseek-ai/dsh-ssh/protocol' +import { SshRpcPeer, RemoteOperationError } from '@deepseek-ai/dsh-ssh/protocol' import { z } from 'zod' function environment(env?: NodeJS.ProcessEnv): Record | undefined { @@ -252,8 +252,23 @@ export class SshSubprocessRuntime extends SubprocessRuntime { }) } - override resolveExecutable(command: string, env?: Readonly>, signal?: AbortSignal): Promise { - return this.ctx.ssh.request('executable', { command, env }, remotePath, signal) + override async resolveExecutable(command: string, env?: Readonly>, signal?: AbortSignal): Promise { + try { + return await this.ctx.ssh.request('executable', { command, env }, remotePath, signal) + } catch (error) { + if (error instanceof RemoteOperationError && error.code === 'SUBPROCESS_EXECUTABLE_NOT_FOUND') { + throw new SubprocessExecutableNotFoundError(error.message, { cause: error }) + } + throw error + } + } + + override terminalEnvironment(signal?: AbortSignal): Promise { + return this.ctx.ssh.request('terminal.environment', {}, z.object({ + platform: z.enum(['posix', 'windows']), defaultShell: z.string().optional(), + }).strict().transform(value => ({ platform: value.platform, + ...(value.defaultShell === undefined ? {} : { defaultShell: value.defaultShell }), + })), signal) } override spawn(spec: SubprocessSpawnSpec): SubprocessHandle { @@ -279,7 +294,7 @@ export class SshSubprocessRuntime extends SubprocessRuntime { const ssh = this.ctx.ssh const prepared = await ssh.request('process.prepare', { argv: spec.argv, cwd: spec.cwd, env: environment(spec.env), graceMs: spec.graceMs, - terminal: { rows: spec.rows, cols: spec.cols }, + terminal: { rows: spec.rows, cols: spec.cols, terminalType: spec.terminalType }, }, preparedSchema, signal) const id = prepared.id let socket: Socket | undefined @@ -298,6 +313,7 @@ export class SshSubprocessRuntime extends SubprocessRuntime { const abort = (): void => { void handle.terminate().catch(() => { void ssh.dispose().catch(() => {}) }) } const handle: SubprocessTerminalHandle = { pid: started.pid, output, done, + resize: async (cols, rows) => { await ssh.request('terminal.resize', { id, cols, rows }, z.null()) }, write: async (data) => { await ssh.request('terminal.write', { id, value: data }, z.null()) }, inspectForeground: async () => await ssh.request('terminal.inspect', { id }, foregroundSchema) ?? undefined, signalForeground: (signal: SubprocessTerminalSignal) => ssh.request('terminal.signal', { id, value: signal }, z.number().int().positive()), diff --git a/packages/ssh/subprocess-ssh/tests/lifecycle.spec.ts b/packages/ssh/subprocess-ssh/tests/lifecycle.spec.ts index 8f62ee1cea..4444a7e5eb 100644 --- a/packages/ssh/subprocess-ssh/tests/lifecycle.spec.ts +++ b/packages/ssh/subprocess-ssh/tests/lifecycle.spec.ts @@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest' import { SshSubprocessRuntime } from '../src/index.ts' const id = 'a7b17d9c-5ebf-40d8-9e82-3d1ddae8517b' -const spec = { argv: ['bash'], cwd: '/workspace', rows: 24, cols: 80, graceMs: 100 } +const spec = { terminalType: 'dumb', argv: ['bash'], cwd: '/workspace', rows: 24, cols: 80, graceMs: 100 } describe('SSH terminal allocation lifecycle', () => { it.each(['prepare', 'connect', 'start'])('joins a pending %s before completing provider disposal', async (stage) => { diff --git a/packages/ssh/subprocess-ssh/tests/shell-discovery.spec.ts b/packages/ssh/subprocess-ssh/tests/shell-discovery.spec.ts new file mode 100644 index 0000000000..7af2872b28 --- /dev/null +++ b/packages/ssh/subprocess-ssh/tests/shell-discovery.spec.ts @@ -0,0 +1,58 @@ +/** Shell facts and lookup failures retain remote execution semantics. */ +import { Context } from '@deepseek-ai/cordis' +import { SubprocessExecutableNotFoundError } from '@deepseek-ai/dsh-subprocess' +import { RemoteOperationError } from '@deepseek-ai/dsh-ssh/protocol' +import { expect, it, vi } from 'vitest' +import { z } from 'zod' +import { SshSubprocessRuntime } from '../src/index.ts' + +it('validates remote shell facts and distinguishes a lookup miss from transport failure', async () => { + const ctx = new Context() + let facts: unknown = { platform: 'posix', defaultShell: '/remote/bin/zsh' } + let failure: Error | undefined + const request = vi.fn(async (method: string, _params: unknown, schema: z.ZodType): Promise => { + if (failure !== undefined) throw failure + return schema.parse(method === 'terminal.environment' ? facts : '/remote/bin/bash') + }) + ctx.provide('ssh', { request } as never) + const runtime = new SshSubprocessRuntime(ctx) + const signal = new AbortController().signal + try { + expect(await runtime.terminalEnvironment(signal)).toEqual(facts) + expect(request).toHaveBeenCalledWith('terminal.environment', {}, expect.anything(), signal) + facts = { platform: 'posix' } + expect(await runtime.terminalEnvironment()).toEqual(facts) + facts = { platform: 'untrusted' } + await expect(runtime.terminalEnvironment()).rejects.toBeInstanceOf(z.ZodError) + expect(await runtime.resolveExecutable('bash', undefined, signal)).toBe('/remote/bin/bash') + failure = new RemoteOperationError('not installed', 'SUBPROCESS_EXECUTABLE_NOT_FOUND') + await expect(runtime.resolveExecutable('absent')).rejects.toBeInstanceOf(SubprocessExecutableNotFoundError) + failure = new RemoteOperationError('permission', 'EACCES') + await expect(runtime.resolveExecutable('bash')).rejects.toBe(failure) + failure = new Error('SSH disconnected') + await expect(runtime.resolveExecutable('bash')).rejects.toBe(failure) + } finally { await ctx.fiber.dispose() } +}) + +it.skipIf(process.platform === 'win32')('queries the real helper and resizes its PTY without replacing the shell', async () => { + const { createHelperHarness } = await import('../../ssh/tests/fixtures/helper.ts') + const helper = await createHelperHarness() + const ctx = new Context() + ctx.provide('ssh', helper.connection as never) + const runtime = new SshSubprocessRuntime(ctx) + try { + expect(await runtime.terminalEnvironment()).toMatchObject({ platform: 'posix' }) + await expect(runtime.resolveExecutable(`${helper.root}/absent`)).rejects.toBeInstanceOf(SubprocessExecutableNotFoundError) + const terminal = await runtime.spawnTerminal({ argv: ['/bin/sh', '-i'], cwd: helper.root, cols: 80, rows: 24, terminalType: 'xterm-256color', graceMs: 100 }) + let output = '' + terminal.output.on('data', (chunk: Buffer) => { output += chunk.toString() }) + await terminal.resize(120, 40) + await terminal.write('printf "REMOTE_TERM:%s\\n" "$TERM"; stty size\n') + await expect.poll(() => output).toContain('REMOTE_TERM:xterm-256color') + await expect.poll(() => output).toMatch(/40\s+120/u) + await terminal.terminate() + await terminal.done + } finally { + try { await ctx.fiber.dispose() } finally { await helper.close() } + } +}) diff --git a/packages/ssh/subprocess-ssh/tests/terminal-behavior.spec.ts b/packages/ssh/subprocess-ssh/tests/terminal-behavior.spec.ts index b29902f6e4..7ec4e4b15c 100644 --- a/packages/ssh/subprocess-ssh/tests/terminal-behavior.spec.ts +++ b/packages/ssh/subprocess-ssh/tests/terminal-behavior.spec.ts @@ -8,7 +8,7 @@ import { z } from 'zod' import { SshSubprocessRuntime } from '../src/index.ts' const id = 'aa910b47-e7d7-467b-8421-3331569dd02b' -const spec: SubprocessTerminalSpawnSpec = { argv: ['bash'], cwd: '/remote/workspace', rows: 24, cols: 80, graceMs: 100 } +const spec: SubprocessTerminalSpawnSpec = { argv: ['bash'], cwd: '/remote/workspace', terminalType: 'dumb', rows: 24, cols: 80, graceMs: 100 } const completion = { outcome: { exitCode: 0, signal: null }, spills: {}, collected: {} } async function setup(options: { @@ -42,7 +42,7 @@ async function setup(options: { if (options.terminateFailure !== undefined) throw options.terminateFailure finished.resolve(completion) value = null - } else if (method === 'terminal.write') value = null + } else if (method === 'terminal.write' || method === 'terminal.resize') value = null else if (method === 'terminal.inspect') value = foreground else if (method === 'terminal.signal') value = 321 else throw new Error(`Unexpected terminal request ${method}`) @@ -89,13 +89,15 @@ describe('SSH terminal behavior', () => { test.remote.write('terminal output') expect(String((await data)[0])).toBe('terminal output') await handle.write('input\n') + await handle.resize(120, 40) + expect(test.calls.find(call => call.method === 'terminal.resize')?.params).toEqual({ id, cols: 120, rows: 40 }) expect(await handle.inspectForeground()).toBeUndefined() test.setForeground({ processGroupId: 321, inputWaiting: true }) expect(await handle.inspectForeground()).toEqual({ processGroupId: 321, inputWaiting: true }) expect(await handle.signalForeground('SIGINT')).toBe(321) expect(test.calls.find(call => call.method === 'process.prepare')?.params).toEqual({ argv: ['bash'], cwd: spec.cwd, env: { KEEP: 'value' }, graceMs: 100, - terminal: { rows: 24, cols: 80 }, + terminal: { terminalType: 'dumb', rows: 24, cols: 80 }, }) expect(test.calls.find(call => call.method === 'terminal.write')?.params).toEqual({ id, value: 'input\n' }) expect(test.calls.find(call => call.method === 'terminal.signal')?.params).toEqual({ id, value: 'SIGINT' }) diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index 6d83648d23..2794f825bd 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -35,10 +35,12 @@ import { seedStoredSession } from './persistence-helpers.ts' type Script = ConstructorParameters[0] const roots: string[] = [] +const projectionCacheDisposers: Array<() => Promise> = [] const persistenceDisposers: Array<() => Promise> = [] const projCacheRoots: string[] = [] afterEach(async () => { + await Promise.all(projectionCacheDisposers.splice(0).map(dispose => dispose())) await Promise.all(persistenceDisposers.splice(0).map(dispose => dispose())) for (const root of projCacheRoots.splice(0)) rmSync(root, { recursive: true, force: true }) for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) @@ -64,7 +66,8 @@ async function setup( await ctx.plugin(Storage) await ctx.plugin({ name: storageJsonName, inject: storageJsonInject, apply: storageJsonApply, Config: storageJsonConfig }, { root }) await ctx.plugin({ name: storageDomainName, inject: storageDomainInject, apply: storageDomainApply, Config: storageDomainConfig }, { backend: 'json' }) - await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) + const cache = await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) + projectionCacheDisposers.push(() => cache.dispose()) } await ctx.plugin(TestSessionQuery) await ctx.plugin(SubagentRuntime) diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 8bc3e74eb6..4e2a3943ad 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subprocess/subprocess-local/README.md -README.md: 7673b72a3afeef7f411c7e11bfacdc65ead299be -README.zh.md: 7960e9e75cf99444a91bed421621a9df36872cc7 +README.md: 5379aff31cdcf036614a6664ce59330d319bd459 +README.zh.md: f61596ac94b7f1270275cb93593c90a281c9ca90 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 7673b72a3a..5379aff31c 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -29,7 +29,7 @@ Mount the provider beside its consumers and start processes exactly as the subpr ### Mounting the provider -Load the provider in the same composition as its consumers. It has no config fields: every choice arrives on the spawn request, so deployment-varying decisions stay with the caller's configuration. +Load the provider in the same composition as its consumers. It has no config fields: every choice arrives on the spawn request, so deployment-varying decisions stay with the caller's configuration. `terminalEnvironment()` reads a nonempty `SHELL` on POSIX, falling back to the account login shell, or a nonempty `ComSpec` on Windows. Empty values are omitted so the consumer can choose its platform fallback. ```yaml - name: '@deepseek-ai/dsh-subprocess-local' @@ -121,6 +121,8 @@ Read these pages when the provider-level contract is not enough. They move from ----- +Terminal allocation advertises the caller-provided `terminalType` through TERM and node-pty. Dynamic resize updates the existing PTY. Output backpressure pauses native reads until the consumer drains; explicit termination resumes a paused reader to receive the exit notification. + ## Model Experience diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index 7960e9e75c..f61596ac94 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 挂载提供方 -在与消费方相同的组合中加载本提供方。它没有任何配置字段:每项选择都随 spawn 请求到达,因此随部署变化的决策留在调用方的配置里。 +在与消费方相同的组合中加载本提供方。它没有任何配置字段:每项选择都随 spawn 请求到达,因此随部署变化的决策留在调用方的配置里。 `terminalEnvironment()` 在 POSIX 读取非空的 `SHELL`,缺失时使用账户登录 shell;在 Windows 读取非空的 `ComSpec`。空值会被省略,由消费者选择平台回退。 ```yaml - name: '@deepseek-ai/dsh-subprocess-local' @@ -121,6 +121,8 @@ spill 文件以 `0600` 权限、`O_EXCL` 与随机名称在 `0700` 每进程目 ----- +终端分配通过 TERM 和 node-pty 使用调用者指定的 `terminalType`。动态 resize 更新已有 PTY。输出背压会暂停原生读取,待消费者排空后恢复;显式终止会恢复暂停的读取,以接收退出通知。 + ## 模型体验 diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index 0a7a9a15c5..44a15359cb 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -10,16 +10,18 @@ import { constants } from 'node:fs' import { access, stat } from 'node:fs/promises' +import { userInfo } from 'node:os' import { delimiter, extname, isAbsolute, resolve } from 'node:path' import type { Duplex } from 'node:stream' import { Context } from '@deepseek-ai/cordis' import * as nodePty from 'node-pty' import type { IPtyForkOptions } from 'node-pty' -import { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess' +import { SubprocessRuntime, SubprocessExecutableNotFoundError } from '@deepseek-ai/dsh-subprocess' import type { SubprocessHandle, SubprocessSpawnSpec, SubprocessTerminalHandle, + SubprocessTerminalEnvironment, SubprocessTerminalSpawnSpec, } from '@deepseek-ai/dsh-subprocess' import { @@ -156,7 +158,7 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { } } signal?.throwIfAborted() - throw new Error(absolute + throw new SubprocessExecutableNotFoundError(absolute ? `subprocess-local: command ${JSON.stringify(command)} is not an executable file` : `subprocess-local: command ${JSON.stringify(command)} was not found on PATH`) } @@ -240,6 +242,15 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { ) } + /** @inheritdoc */ + // oxlint-disable-next-line typescript/require-await -- Keep the provider promise rejection semantics for cancelled inspection. + async terminalEnvironment(signal?: AbortSignal): Promise { + signal?.throwIfAborted() + const platform = process.platform === 'win32' ? 'windows' : 'posix' + const defaultShell = platform === 'windows' ? process.env.ComSpec || undefined : process.env.SHELL || userInfo().shell || undefined + return { platform, ...defaultShell === undefined ? {} : { defaultShell } } + } + // Local PTY allocation is synchronous, but the provider contract permits remote asynchronous allocation. // oxlint-disable-next-line typescript/require-await -- Preserve promise rejection semantics at the async provider contract. async spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise { @@ -250,11 +261,11 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { spec.signal?.throwIfAborted() const env = targetEnvironment(spec) const options: IPtyForkOptions = { - name: 'dumb', + name: spec.terminalType, rows: spec.rows, cols: spec.cols, cwd: spec.cwd, - env, + env: { ...env, TERM: spec.terminalType }, } const inspector = this.terminalInspector ?? createProcessInspector() const containmentMode = this.selectContainmentMode('terminal') @@ -262,7 +273,7 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { ? prepareLinuxTerminalScope(spec, { ...env, PWD: spec.cwd, - TERM: 'dumb', + TERM: spec.terminalType, }) : undefined if (scope !== undefined) { diff --git a/packages/subprocess/subprocess-local/src/terminal.ts b/packages/subprocess/subprocess-local/src/terminal.ts index 8d165dbd20..3f9f9c9811 100644 --- a/packages/subprocess/subprocess-local/src/terminal.ts +++ b/packages/subprocess/subprocess-local/src/terminal.ts @@ -65,6 +65,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { private cleanup: Promise | undefined private managedOwnerCleaned = false private exited = false + private outputPaused = false private trackedDescendants: ProcessIdentity[] = [] /** The spawned shell's start identity; scans stop adopting members once the root pid no longer carries it. */ private readonly rootIdentity: ProcessIdentity | undefined @@ -86,7 +87,19 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { this.pid = terminal.pid this.rootIdentity = inspector.snapshot().tree(this.pid).find(member => member.pid === this.pid) this.done = this.outcome.promise - this.dataDisposable = terminal.onData((data) => { this.output.write(Buffer.from(data, 'utf8')) }) + const resume = (): void => { + if (!this.outputPaused) return + this.outputPaused = false + if (!this.exited) terminal.resume() + } + this.output.on('drain', resume) + this.output.once('close', () => { this.output.off('drain', resume) }) + this.dataDisposable = terminal.onData((data) => { + if (!this.output.write(Buffer.from(data, 'utf8')) && this.cleanup === undefined && !this.outputPaused) { + this.outputPaused = true + terminal.pause() + } + }) this.exitDisposable = terminal.onExit(({ exitCode, signal: exitSignal }) => { if (this.exited) return this.exited = true @@ -115,6 +128,12 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { this.terminal.write(data) } + // oxlint-disable-next-line typescript/require-await -- Provider operations share promise rejection semantics. + async resize(cols: number, rows: number): Promise { + if (this.exited) throw new Error('terminal process has exited') + this.terminal.resize(cols, rows) + } + // Local inspection is synchronous; the seam returns a promise for remote transports. // oxlint-disable-next-line typescript/require-await -- Preserve promise rejection semantics at the async provider contract. async inspectForeground(): Promise { @@ -154,6 +173,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { terminate(): Promise { if (this.cleanup !== undefined) return this.cleanup + if (this.outputPaused) { this.outputPaused = false; this.terminal.resume() } const cleanup = this.closeOnce() this.cleanup = cleanup void cleanup.catch(() => { this.cleanup = undefined }) diff --git a/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts b/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts index 721c5134c3..080b503152 100644 --- a/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts +++ b/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts @@ -48,6 +48,7 @@ if (kind === 'ordinary') { cwd: process.cwd(), rows: 24, cols: 80, + terminalType: 'dumb', graceMs: 30_000, }) } diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index 99a2890ae1..091edb8884 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -288,10 +288,14 @@ describe('Linux scope establishment and quiescence', () => { result.owner.cleanup?.() }) - it('accepts request consumption followed by rapid --collect unload as stopped', async () => { + it.each([undefined, 'pipe'] as const)('accepts request consumption and rapid --collect unload with control %s', async (control) => { const states = [activeUnit(), unloadedUnit()] - const { child, result, requestPath } = launch(async () => states.shift() ?? missingUnit()) - expect(consumeLinuxLaunchRequest(requestPath)).toEqual({ cwd: '/target', env: { TARGET: 'yes' } }) + const controlOptions = control === undefined ? {} : { control } + const { child, result, requestPath } = launch(async () => states.shift() ?? missingUnit(), {}, { + ...spec(), stdio: { ...spec().stdio, ...controlOptions }, + }) + expect(consumeLinuxLaunchRequest(requestPath)).toEqual({ cwd: '/target', env: { TARGET: 'yes' }, ...controlOptions }) + expect(result.control).toBe(control === undefined ? undefined : child.control) const waiting = result.owner.waitForExit() child.exit(0, null) await expect(result.direct).resolves.toEqual({ exitCode: 0, signal: null }) @@ -953,6 +957,7 @@ describe('Linux PTY bootstrap reuse', () => { env: { TARGET: 'yes' }, rows: 24, cols: 80, + terminalType: 'dumb', graceMs: 100, } as const diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index d15ce5e2ea..ef04b338f9 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -1,4 +1,6 @@ import { PassThrough } from 'node:stream' +import os from 'node:os' +import { syncBuiltinESMExports } from 'node:module' import { describe, expect, it, vi } from 'vitest' import { basename, dirname, relative, resolve } from 'node:path' import { Context } from '@deepseek-ai/cordis' @@ -41,6 +43,57 @@ function spec(command: string, overrides: Partial = {}): Su } describe('LocalSubprocessRuntime', () => { + it('discovers the platform shell without inventing a missing default and honors cancellation', async () => { + let loginShell: string | null = '/account/shell' + const userInfo = vi.spyOn(os, 'userInfo').mockImplementation(() => ({ + uid: 1, gid: 1, username: 'terminal-user', homedir: '/home/terminal-user', shell: loginShell, + })) + let fiber: Awaited> | undefined + let restorePlatform: (() => void) | undefined + try { + syncBuiltinESMExports() + const ctx = new Context() + fiber = await ctx.plugin(LocalSubprocessRuntime) + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + restorePlatform = () => { platform.mockRestore() } + vi.stubEnv('SHELL', '/environment/shell') + await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ + platform: 'posix', defaultShell: '/environment/shell', + }) + expect(userInfo).not.toHaveBeenCalled() + vi.stubEnv('SHELL', undefined) + await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ + platform: 'posix', defaultShell: '/account/shell', + }) + vi.stubEnv('SHELL', '') + await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'posix', defaultShell: '/account/shell' }) + loginShell = '' + await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'posix' }) + loginShell = null + await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'posix' }) + platform.mockReturnValue('win32') + vi.stubEnv('ComSpec', 'C:\\Windows\\System32\\cmd.exe') + await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ + platform: 'windows', defaultShell: 'C:\\Windows\\System32\\cmd.exe', + }) + vi.stubEnv('ComSpec', undefined) + await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'windows' }) + vi.stubEnv('ComSpec', '') + await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'windows' }) + platform.mockReturnValue('linux') + userInfo.mockClear() + const reason = new Error('terminal inspection cancelled') + await expect(ctx.subprocess.terminalEnvironment(AbortSignal.abort(reason))).rejects.toBe(reason) + expect(userInfo).not.toHaveBeenCalled() + } finally { + restorePlatform?.() + vi.unstubAllEnvs() + userInfo.mockRestore() + syncBuiltinESMExports() + await fiber?.dispose() + } + }) + it('places the host-exit finalizer before listeners that predate the service', async () => { const baseline = new Set(process.listeners('exit')) const prior = vi.fn() @@ -224,7 +277,7 @@ describe('LocalSubprocessRuntime', () => { const ctx = new Context() const fiber = await ctx.plugin(LocalSubprocessRuntime) const base: SubprocessTerminalSpawnSpec = { - argv: ['bash'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 10, + argv: ['bash'], cwd: process.cwd(), rows: 24, cols: 80, terminalType: 'dumb', graceMs: 10, } await expect(ctx.subprocess.spawnTerminal({ ...base, argv: [] })).rejects.toThrow('must contain a program') await expect(ctx.subprocess.spawnTerminal({ ...base, argv: [''] })).rejects.toThrow('must contain a program') @@ -241,6 +294,7 @@ describe('LocalSubprocessRuntime', () => { output: new PassThrough(), done: Promise.resolve({ exitCode: 0, signal: null }), write: async () => {}, + resize: async () => {}, inspectForeground: async () => undefined, signalForeground: async () => 1, terminate, @@ -268,6 +322,7 @@ describe('LocalSubprocessRuntime', () => { output: new PassThrough(), done: Promise.resolve({ exitCode: 0, signal: null }), write: async () => {}, + resize: async () => {}, inspectForeground: async () => undefined, signalForeground: async () => 1, terminate: vi.fn(async () => { throw firstFailure }), @@ -320,6 +375,7 @@ describe('LocalSubprocessRuntime', () => { output: new PassThrough(), done: Promise.resolve({ exitCode: 0, signal: null }), write: async () => {}, + resize: async () => {}, inspectForeground: async () => undefined, signalForeground: async () => 1, terminate: vi.fn(async () => { throw failure }), @@ -399,7 +455,7 @@ describe('LocalSubprocessRuntime', () => { const fiber = await ctx.plugin(IsolatedLocalSubprocessRuntime) const service = ctx.subprocess as InstanceType const handle = await ctx.subprocess.spawnTerminal({ - argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 1, + argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, terminalType: 'dumb', graceMs: 1, }) expect((service as unknown as { terminals: Set }).terminals.size).toBe(1) exitListener?.({ exitCode: 0 }) @@ -498,6 +554,7 @@ describe('LocalSubprocessRuntime', () => { cwd: targetCwd, rows: 24, cols: 80, + terminalType: 'dumb', graceMs: 10, env: { PWD: '/stale-parent-cwd', TERM: 'xterm-256color', TARGET_VALUE: 'preserved' }, }) @@ -593,7 +650,7 @@ describe('LocalSubprocessRuntime', () => { runtime.terminalInspector = inspector await expect(runtime.spawnTerminal({ - argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 10, + argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, terminalType: 'dumb', graceMs: 10, })).rejects.toBe(launchFailure) expect(cleanup).toHaveBeenCalledOnce() } finally { @@ -643,7 +700,7 @@ describe('LocalSubprocessRuntime', () => { signalProcess: () => {}, } const handle = await ctx.subprocess.spawnTerminal({ - argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 1, + argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, terminalType: 'dumb', graceMs: 1, }) const terminate = vi.spyOn(handle, 'terminate') exitListener?.({ exitCode: 0 }) diff --git a/packages/subprocess/subprocess-local/tests/native-containment.spec.ts b/packages/subprocess/subprocess-local/tests/native-containment.spec.ts index 8b4eb7369f..d64240044b 100644 --- a/packages/subprocess/subprocess-local/tests/native-containment.spec.ts +++ b/packages/subprocess/subprocess-local/tests/native-containment.spec.ts @@ -253,6 +253,7 @@ while :; do sleep 60; done cwd: scratch, rows: 24, cols: 80, + terminalType: 'dumb', graceMs: 100, }) const output = captureTerminalOutput(handle) diff --git a/packages/subprocess/subprocess-local/tests/terminal.spec.ts b/packages/subprocess/subprocess-local/tests/terminal.spec.ts index 7b02470f7c..bf34abdd79 100644 --- a/packages/subprocess/subprocess-local/tests/terminal.spec.ts +++ b/packages/subprocess/subprocess-local/tests/terminal.spec.ts @@ -13,6 +13,9 @@ import type { SubprocessTerminalSignal } from '@deepseek-ai/dsh-subprocess' class FakePty { pid = 123 + readonly pause = vi.fn() + readonly resume = vi.fn() + readonly resize = vi.fn() readonly writes: string[] = [] readonly kills: string[] = [] autoExitOnKill = true @@ -114,6 +117,41 @@ function makeHandle(pty: FakePty, inspector: ProcessInspector, graceMs: number): } describe('LocalTerminalHandle', () => { + it('pauses native output until the consumer drains and resumes before termination', async () => { + const pty = new FakePty() + const handle = makeHandle(pty, new FakeInspector(), 10) + const chunk = 'x'.repeat(handle.output.readableHighWaterMark + handle.output.writableHighWaterMark) + pty.emitData(chunk) + expect(pty.pause).toHaveBeenCalledOnce() + const chunks: Buffer[] = [] + handle.output.on('data', (data: Buffer) => { chunks.push(data) }) + await vi.waitFor(() => { expect(pty.resume).toHaveBeenCalledOnce() }) + expect(Buffer.concat(chunks).toString()).toBe(chunk) + handle.output.pause() + pty.emitData(chunk) + expect(pty.pause).toHaveBeenCalledTimes(2) + await handle.terminate() + handle.output.emit('drain') + expect(pty.resume).toHaveBeenCalledTimes(2) + handle.output.destroy() + }) + + it('does not resume native output when a pending drain follows the PTY exit', async () => { + const pty = new FakePty() + const handle = makeHandle(pty, new FakeInspector(), 10) + try { + pty.emitData('x'.repeat(handle.output.readableHighWaterMark + handle.output.writableHighWaterMark)) + expect(pty.pause).toHaveBeenCalledOnce() + pty.emitExit(0) + await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }) + handle.output.emit('drain') + expect(pty.resume).not.toHaveBeenCalled() + } finally { + await handle.terminate() + handle.output.destroy() + } + }) + it('terminates a managed range with TERM when it stops within the grace period', async () => { vi.useFakeTimers() const pty = new FakePty() @@ -419,6 +457,8 @@ describe('LocalTerminalHandle', () => { pty.emitData('hello €') await handle.write('input\r') + await handle.resize(100, 30) + expect(pty.resize).toHaveBeenCalledWith(100, 30) expect(pty.writes).toEqual(['input\r']) expect(await handle.inspectForeground()).toEqual({ processGroupId: 456, inputWaiting: true }) expect(inspector.stdinChecks).toEqual([[456, 123]]) @@ -427,6 +467,7 @@ describe('LocalTerminalHandle', () => { pty.emitExit(7, 9) pty.emitExit(0) + await expect(handle.resize(80, 24)).rejects.toThrow('terminal process has exited') expect(await handle.done).toEqual({ exitCode: null, signal: 'SIGKILL' }) await handle.terminate() expect(Buffer.concat(chunks).toString('utf8')).toBe('hello €') diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml index bac6edcaea..13d8005605 100644 --- a/packages/subprocess/subprocess/README.i18n.yaml +++ b/packages/subprocess/subprocess/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subprocess/subprocess/README.md -README.md: ccb48051981f57eee9830cf9d3696d0293cae5d8 -README.zh.md: eca1530759ee923c7b588983330b9bcf22fbaf45 +README.md: 5ce671d0fc25eb2acab80ea364f7ad94a0ce3d8f +README.zh.md: c8d1f0ea1ea80f6bd23845c8b9b7e7d505561b81 diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md index ccb4805198..5ce671d0fc 100644 --- a/packages/subprocess/subprocess/README.md +++ b/packages/subprocess/subprocess/README.md @@ -127,6 +127,8 @@ Read these pages when the package-level contract is not enough. They move from t ----- +Terminal consumers use `terminalEnvironment()` to read the provider platform and preferred shell, and `resolveExecutable()` to verify candidates. A completed lookup miss throws `SubprocessExecutableNotFoundError`; transport failures remain distinct. `spawnTerminal` requires `terminalType` and initial dimensions, and its handle supports `resize(cols, rows)` without reallocating the process. + ## Model Experience diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md index eca1530759..c8d1f0ea1e 100644 --- a/packages/subprocess/subprocess/README.zh.md +++ b/packages/subprocess/subprocess/README.zh.md @@ -127,6 +127,8 @@ spawn 会立即返回活动句柄,而不公开目标身份。`done` 独立报 ----- +终端消费者通过 `terminalEnvironment()` 读取 provider 平台和首选 shell,通过 `resolveExecutable()` 验证候选。确定未找到可执行文件时抛出 `SubprocessExecutableNotFoundError`,传输故障仍单独报告。`spawnTerminal` 要求 `terminalType` 和初始尺寸,返回的 handle 通过 `resize(cols, rows)` 调整尺寸,不重新分配进程。 + ## 模型体验 diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts index 0cda343664..c914b48ede 100644 --- a/packages/subprocess/subprocess/src/index.ts +++ b/packages/subprocess/subprocess/src/index.ts @@ -12,7 +12,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import { proxyEnvironmentForChild } from '@deepseek-ai/dsh-http-proxy' import { DSH_ENV_PREFIX } from './types.ts' import type { SubprocessHandle, SubprocessSpawnSpec } from './types.ts' -import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from './types.ts' +import type { SubprocessTerminalEnvironment, SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from './types.ts' export { DSH_ENV_PREFIX } from './types.ts' export type { @@ -30,6 +30,7 @@ export type { SubprocessStdinMode, SubprocessStdio, SubprocessTerminalForeground, + SubprocessTerminalEnvironment, SubprocessTerminalHandle, SubprocessTerminalSignal, SubprocessTerminalSpawnSpec, @@ -134,6 +135,13 @@ export abstract class SubprocessRuntime extends Service { signal?: AbortSignal, ): Promise + /** + * Inspect shell-selection facts in the provider's execution environment. + * @param signal - cancellation of remote environment inspection. + * @returns platform and preferred shell; executable lookup and allocation remain separate operations. + */ + abstract terminalEnvironment(signal?: AbortSignal): Promise + /** * Start one managed child process from a fully-specified spec; this seam * applies no defaults. @@ -154,3 +162,15 @@ export abstract class SubprocessRuntime extends Service { } export default SubprocessRuntime + +/** Executable lookup completed without finding an executable file. */ +export class SubprocessExecutableNotFoundError extends Error { + /** + * @param message - provider-specific lookup diagnostic. + * @param options - original provider failure, when available. + */ + constructor(message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'SubprocessExecutableNotFoundError' + } +} diff --git a/packages/subprocess/subprocess/src/types.ts b/packages/subprocess/subprocess/src/types.ts index 0d5b084f91..1274c08399 100644 --- a/packages/subprocess/subprocess/src/types.ts +++ b/packages/subprocess/subprocess/src/types.ts @@ -202,6 +202,14 @@ export interface SubprocessHandle { */ export type SubprocessTerminalSignal = 'SIGINT' | 'SIGTERM' | 'SIGKILL' | 'SIGTSTP' | 'SIGHUP' +/** Shell-selection facts from the subprocess provider's execution environment. */ +export interface SubprocessTerminalEnvironment { + /** Operating-system family that interprets executable paths and shell arguments. */ + platform: 'posix' | 'windows' + /** Login or environment-selected shell, when the provider can resolve one. */ + defaultShell?: string +} + /** A fully specified terminal-process spawn. */ export interface SubprocessTerminalSpawnSpec { /** Executable and arguments; `argv[0]` is the program. */ @@ -214,6 +222,8 @@ export interface SubprocessTerminalSpawnSpec { rows: number /** Initial terminal column count. */ cols: number + /** Terminal emulation advertised to the child through TERM. */ + terminalType: string /** TERM-to-KILL cleanup grace for the complete terminal session. */ graceMs: number /** Cancellation of terminal allocation; a published handle owns its later lifetime. */ @@ -246,6 +256,12 @@ export interface SubprocessTerminalHandle { * @param data - text to deliver without implicit newline conversion. */ write(data: string): Promise + /** + * Change the terminal dimensions and notify its foreground application. + * @param cols - positive terminal column count. + * @param rows - positive terminal row count. + */ + resize(cols: number, rows: number): Promise /** * Inspect the current foreground process group. * @returns its id and input-wait fact, or undefined when no foreground group can be resolved. diff --git a/packages/subprocess/subprocess/tests/service.spec.ts b/packages/subprocess/subprocess/tests/service.spec.ts index 02ee5be4d5..d76f6c56b9 100644 --- a/packages/subprocess/subprocess/tests/service.spec.ts +++ b/packages/subprocess/subprocess/tests/service.spec.ts @@ -16,6 +16,7 @@ import type { * is all an implementation owes the abstract class. */ class StubSubprocessRuntime extends SubprocessRuntime { + async terminalEnvironment() { return { platform: 'posix' as const } } async resolveExecutable(command: string): Promise { return `/bin/${command}` } @@ -43,6 +44,7 @@ class StubSubprocessRuntime extends SubprocessRuntime { output: new PassThrough(), done: Promise.resolve({ exitCode: 0, signal: null }), write: async () => {}, + resize: async () => {}, inspectForeground: async () => ({ processGroupId: 1, inputWaiting: true }), signalForeground: async () => 1, terminate: async () => {}, diff --git a/packages/terminal/terminal-bash/src/index.ts b/packages/terminal/terminal-bash/src/index.ts index 35eb5b89bc..2371121f59 100644 --- a/packages/terminal/terminal-bash/src/index.ts +++ b/packages/terminal/terminal-bash/src/index.ts @@ -201,6 +201,7 @@ export class BashTerminalBackend implements TerminalBackend { env: childEnvironment(spec, this.config.shellDialect), rows: this.config.rows, cols: this.config.cols, + terminalType: 'dumb', graceMs: this.config.disposeGraceMs, signal: spec.signal, }) diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts index ec81bc1609..7030b72e3e 100644 --- a/packages/terminal/terminal-bash/tests/index.spec.ts +++ b/packages/terminal/terminal-bash/tests/index.spec.ts @@ -72,6 +72,7 @@ function terminalHandle(): SubprocessTerminalHandle { output, done: Promise.resolve({ exitCode: 0, signal: null }), write: async () => {}, + resize: async () => {}, inspectForeground: async () => ({ processGroupId: 123, inputWaiting: true }), signalForeground: async () => 123, terminate: async () => { output.end() }, @@ -79,6 +80,7 @@ function terminalHandle(): SubprocessTerminalHandle { } class StubSubprocessRuntime extends SubprocessRuntime { + async terminalEnvironment() { return { platform: 'posix' as const } } async resolveExecutable(command: string): Promise { return command } spawn(_spec: SubprocessSpawnSpec): SubprocessHandle { throw new Error('unused') } async spawnTerminal(_spec: SubprocessTerminalSpawnSpec): Promise { @@ -359,6 +361,7 @@ describe('BashTerminalBackend startup rollback', () => { output, done: outcome.promise, write: async () => {}, + resize: async () => {}, inspectForeground: async () => ({ processGroupId: 123, inputWaiting: true }), signalForeground: async () => 123, async terminate() { diff --git a/packages/terminal/terminal-bash/tests/session.spec.ts b/packages/terminal/terminal-bash/tests/session.spec.ts index b28c579375..d8051db3b6 100644 --- a/packages/terminal/terminal-bash/tests/session.spec.ts +++ b/packages/terminal/terminal-bash/tests/session.spec.ts @@ -86,6 +86,8 @@ class FakeTerminal implements SubprocessTerminalHandle { }) } + async resize(_cols: number, _rows: number): Promise {} + async write(data: string): Promise { if (this.throwWrite) throw new Error('write failed') this.writes.push(data) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ea054fa33..7cfb1b16c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -637,6 +637,9 @@ importers: '@deepseek-ai/dsh-pwsh-local': specifier: workspace:^ version: link:../../packages/shell/pwsh-local + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../packages/subprocess/subprocess-local '@deepseek-ai/dsh-remote-mock': specifier: workspace:^ version: link:../../packages/test-support/remote-mock @@ -933,6 +936,9 @@ importers: '@deepseek-ai/dsh-api-settings-controller': specifier: workspace:^ version: link:../settings-controller + '@deepseek-ai/dsh-api-terminal-controller': + specifier: workspace:^ + version: link:../terminal-controller '@deepseek-ai/dsh-api-workspace-controller': specifier: workspace:^ version: link:../workspace-controller @@ -1173,6 +1179,67 @@ importers: specifier: workspace:^ version: link:../../typert/protocol + packages/api/terminal-controller: + dependencies: + '@deepseek-ai/dsh-deque': + specifier: workspace:^ + version: link:../../util/deque + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + '@xterm/addon-serialize': + specifier: ^0.14.0 + version: 0.14.0 + '@xterm/headless': + specifier: ^6.0.0 + version: 6.0.0 + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-api-gateway': + specifier: workspace:^ + version: link:../gateway + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../../client/store + '@deepseek-ai/dsh-fs': + specifier: workspace:^ + version: link:../../fs/fs + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-util-crypto': + specifier: workspace:^ + version: link:../../util/crypto + packages/api/workspace-controller: dependencies: '@deepseek-ai/dsh-deque': @@ -1814,6 +1881,9 @@ importers: '@deepseek-ai/dsh-api-settings-controller': specifier: workspace:^ version: link:../../api/settings-controller + '@deepseek-ai/dsh-api-terminal-controller': + specifier: workspace:^ + version: link:../../api/terminal-controller '@deepseek-ai/dsh-api-workspace-controller': specifier: workspace:^ version: link:../../api/workspace-controller @@ -1943,6 +2013,9 @@ importers: '@deepseek-ai/dsh-client-ui-sidebar-right': specifier: workspace:^ version: link:../../client/ui-sidebar-right + '@deepseek-ai/dsh-client-ui-sidebar-terminal': + specifier: workspace:^ + version: link:../../client/ui-sidebar-terminal '@deepseek-ai/dsh-client-ui-skill': specifier: workspace:^ version: link:../../client/ui-skill @@ -4088,6 +4161,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-util-crypto': + specifier: workspace:^ + version: link:../../util/crypto '@testing-library/react': specifier: ^16.1.0 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -4113,6 +4189,78 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + packages/client/ui-sidebar-terminal: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-api-terminal-controller': + specifier: workspace:^ + version: link:../../api/terminal-controller + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../store + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-dockkit': + specifier: workspace:^ + version: link:../ui-dockkit + '@deepseek-ai/dsh-client-ui-layout': + specifier: workspace:^ + version: link:../ui-layout + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session + '@deepseek-ai/dsh-client-ui-sidebar-right': + specifier: workspace:^ + version: link:../ui-sidebar-right + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + '@types/react-dom': + specifier: ~18.3.0 + version: 18.3.7(@types/react@18.3.31) + '@xterm/addon-fit': + specifier: ^0.11.0 + version: 0.11.0 + '@xterm/xterm': + specifier: ^6.0.0 + version: 6.0.0 + clsx: + specifier: ^2.0.0 + version: 2.1.1 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + packages/client/ui-skill: devDependencies: '@deepseek-ai/cordis': @@ -15189,9 +15337,18 @@ packages: resolution: {integrity: sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==} engines: {node: '>=10.0.0'} + '@xterm/addon-fit@0.11.0': + resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==} + + '@xterm/addon-serialize@0.14.0': + resolution: {integrity: sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA==} + '@xterm/headless@6.0.0': resolution: {integrity: sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==} + '@xterm/xterm@6.0.0': + resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==} + '@yao-pkg/pkg-fetch@3.6.4': resolution: {integrity: sha512-2JXvS9HbMudLlzEjSaJ7bLNnF/WlTv7iaTAJp2Tk8pBsgwCyL9p4rIN8cztHlyZF7qVzr1EaBjd7DQooFk0azQ==} hasBin: true @@ -21806,8 +21963,14 @@ snapshots: '@xmldom/xmldom@0.8.15': {} + '@xterm/addon-fit@0.11.0': {} + + '@xterm/addon-serialize@0.14.0': {} + '@xterm/headless@6.0.0': {} + '@xterm/xterm@6.0.0': {} + '@yao-pkg/pkg-fetch@3.6.4': dependencies: picocolors: 1.1.1 diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index ae2bee4a08..d245d7b799 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -127,6 +127,7 @@ export const SERVICE_PAGE: Record = { workspaceRegistry: 'workspace.md', workspaceController: 'workspace.md', workspaceFiles: 'workspace.md', + terminalController: 'workspace.md', directoryPickerController: 'workspace.md', } @@ -149,6 +150,7 @@ export const SERVICE_PAGE: Record = { * to a model as `cordis_runtime_inspect what:"client"`). */ export const SERVICE_WALK_EXEMPTIONS: Record = { + webTerminals: 'client-side terminal view models — packages/api/terminal-controller/README.md owns the API', appReady: 'not a service: launcher-provided successful-startup signal — packages/boot/cmdline/README.md owns the launcher contract', appExit: 'not a service: launcher-provided bounded process-exit callback — packages/boot/cmdline/README.md owns the launcher contract', cmdlineArgs: 'not a service: launcher-provided immutable app argument accessor — packages/boot/cmdline/README.md owns the launcher contract', @@ -401,6 +403,7 @@ export const LINK_MAP: Readonly> = { SubprocessOutputReader: 'subprocess.md', SubprocessSpawnSpec: 'subprocess.md', SubprocessTerminalHandle: 'subprocess.md', + SubprocessTerminalEnvironment: 'subprocess.md', SubprocessTerminalSpawnSpec: 'subprocess.md', PtcRunRequest: 'ptc-runtime.md', PtcRunSpec: 'ptc-runtime.md', @@ -786,6 +789,13 @@ export const TYPE_LINK_EXEMPTIONS: Readonly> = { WorkspaceFileRange: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md', WorkspaceFileStat: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md', WorkspaceFileText: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md', + TerminalShell: 'Browser terminal shell profiles are owned by packages/api/terminal-controller/README.md', + TerminalEnvironment: 'Browser terminal environment fields are owned by packages/api/terminal-controller/README.md', + WebTerminalInfo: 'Browser terminal metadata is owned by packages/api/terminal-controller/README.md', + TerminalCreateRequest: 'Browser terminal allocation fields are owned by packages/api/terminal-controller/README.md', + TerminalAttachmentId: 'Browser terminal input ownership is owned by packages/api/terminal-controller/README.md', + TerminalFrame: 'Browser terminal stream frames are owned by packages/api/terminal-controller/README.md', + WebTerminalId: 'Browser terminal identity is owned by packages/api/terminal-controller/README.md', } /** Repository data policy consumed by the Cordis catalog projector. */ diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index d5d69e5cd8..c76ed3bda0 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -217,6 +217,13 @@ const SERVICE_ROLES: ServiceRole[] = [ mode: 'core', note: 'Serves stat, paged text, byte windows, directory listings, and the change feed for files inside a Session\'s workspace root, confined by lstat, containment, and a stat re-check.', }, + { + key: 'terminalController', + pkg: 'api-terminal-controller', + title: 'Session interactive terminal Remote controller', + mode: 'core', + note: 'Owns user terminal processes, default shell resolution and bounded screen recovery through the subprocess provider and typed Remote transport.', + }, { key: 'workspaceController', pkg: 'api-workspace-controller', diff --git a/scripts/package-dependency-policy.ts b/scripts/package-dependency-policy.ts index fa1db54c75..835c252675 100644 --- a/scripts/package-dependency-policy.ts +++ b/scripts/package-dependency-policy.ts @@ -54,6 +54,7 @@ const SAFE_HOST_DEPENDENCY_EXPORTS = { /** Runtime exports that require every consumer to resolve the provider's shared peer instance. */ const PEER_REQUIRED_HOST_EXPORTS = { + '@deepseek-ai/dsh-subprocess': ['SubprocessExecutableNotFoundError'], '@deepseek-ai/dsh-scope': ['carrierKeyOf', 'scopeOf', 'scopeTarget'], '@deepseek-ai/dsh-session': ['SESSION_FORMAT_VERSION'], '@deepseek-ai/dsh-session-persistence': ['SessionPersistenceNotFoundError'], diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 6a1a48d57a..83c1c1f0c7 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -44,6 +44,8 @@ const NO_MODEL_EXPERIENCE_SECTION: Readonly> = { * blocks. A package moves on or off this list with its context behavior. */ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { + 'packages/api/terminal-controller': { kind: 'none', reason: 'User-owned terminal processes and screen streams never enter model requests or Session events.' }, + 'packages/client/ui-sidebar-terminal': { kind: 'none', reason: 'The browser renders user terminal screens without exposing them to the model.' }, 'packages/ssh/ssh': { kind: 'none', reason: 'The connection owner transports private provider operations; consumers own all model-facing content.' }, 'packages/ssh/fs-ssh': { kind: 'indirect', reason: 'The remote filesystem delegates model rendering to the existing filesystem consumers.' }, 'packages/ssh/subprocess-ssh': { kind: 'indirect', reason: 'The remote process provider delegates model rendering to Bash, terminal, LSP and ptc-runtime consumers.' }, diff --git a/snapshots/session/cordis-inspect-jsdoc/session.v3.jsonl b/snapshots/session/cordis-inspect-jsdoc/session.v3.jsonl index 0c58c12c6e..a184b87d82 100644 --- a/snapshots/session/cordis-inspect-jsdoc/session.v3.jsonl +++ b/snapshots/session/cordis-inspect-jsdoc/session.v3.jsonl @@ -19,7 +19,7 @@ {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788778146017,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788778146017,"index":0,"dt":[],"id":"inspect-subprocess-api","name":"cordis_inspect_query","args":["{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"]},{"type":"chunk","time":1788778146017,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}}},{"type":"chunk","time":1788778146017,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788778146017,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"inspect-subprocess-api"},"content":[{"type":"tool-result","toolCallId":"inspect-subprocess-api","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"subprocess\",\n \"description\": \"Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).\\n\\nImplementations must honor these semantics:\\n\\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\\n- spawn returns a live handle synchronously. Target identity remains provider-private; `done` resolves with the spawned command's exit facts and may reject for spawn or provider failures.\\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\\n- SubprocessHandle.terminate (and the spec's abort signal) starts the provider's documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its signalling and observability limits.\\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"subprocess\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"subprocess\"\n ],\n \"expression\": \"ctx.subprocess\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"abstract resolveExecutable( command: string, env?: Readonly>, signal?: AbortSignal, ): Promise\",\n \"description\": \"Resolve one configured executable in this provider's execution world. Absolute paths are verified; bare names use the provider's scrubbed PATH plus explicit environment overrides. Relative paths containing separators are rejected: the resolution base is undefined, so providers fail loud instead of guessing.\",\n \"parameters\": [\n {\n \"name\": \"command\",\n \"description\": \"absolute executable path or bare PATH name.\"\n },\n {\n \"name\": \"env\",\n \"description\": \"explicit environment entries used for lookup.\"\n },\n {\n \"name\": \"signal\",\n \"description\": \"aborts remote or local lookup.\"\n }\n ],\n \"returns\": \"a canonical executable path.\"\n },\n {\n \"signature\": \"abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle\",\n \"description\": \"Start one managed child process from a fully-specified spec; this seam applies no defaults.\",\n \"parameters\": [\n {\n \"name\": \"spec\",\n \"description\": \"argv, directory, stdio dispositions, grace, cancellation, and environment.\"\n }\n ],\n \"returns\": \"the live process handle (streams/readers, signalling, outcome promise).\",\n \"throws\": [\n \"synchronously when pre-aborted or when argv, cwd, environment, or grace is invalid before handle creation.\"\n ]\n },\n {\n \"signature\": \"abstract spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise\",\n \"description\": \"Allocate a real terminal and start one owned process session. This is the only non-pipe process primitive: implementations own terminal byte I/O, foreground groups, signals, and whole-session quiescence.\",\n \"parameters\": [\n {\n \"name\": \"spec\",\n \"description\": \"fully specified argv, cwd, environment, dimensions, grace, and allocation cancellation.\"\n }\n ],\n \"returns\": \"the live terminal handle after allocation succeeds.\"\n }\n ]\n },\n \"referencedTypes\": [\n {\n \"name\": \"SubprocessCollect\",\n \"declaration\": \"export interface SubprocessCollect {\\n maxBytes: number;\\n spill?: {\\n maxBytes: number;\\n };\\n}\"\n },\n {\n \"name\": \"SubprocessCollectedOutputs\",\n \"declaration\": \"export interface SubprocessCollectedOutputs {\\n readonly stdout?: SubprocessOutputReader;\\n readonly stderr?: SubprocessOutputReader;\\n}\"\n },\n {\n \"name\": \"SubprocessHandle\",\n \"declaration\": \"export interface SubprocessHandle {\\n readonly stdin: Writable | undefined;\\n readonly stdout: Readable | undefined;\\n readonly stderr: Readable | undefined;\\n readonly control: Duplex | undefined;\\n readonly collected: SubprocessCollectedOutputs;\\n readonly done: Promise;\\n terminate(): void;\\n waitForExit(signal?: AbortSignal): Promise;\\n}\"\n },\n {\n \"name\": \"SubprocessOutcome\",\n \"declaration\": \"export interface SubprocessOutcome {\\n exitCode: number | null;\\n signal: NodeJS.Signals | null;\\n}\"\n },\n {\n \"name\": \"SubprocessOutputMode\",\n \"declaration\": \"export type SubprocessOutputMode = 'pipe' | 'inherit' | SubprocessCollect;\"\n },\n {\n \"name\": \"SubprocessOutputRead\",\n \"declaration\": \"export interface SubprocessOutputRead {\\n text: string;\\n nextOffset: number;\\n lossy: boolean;\\n spillPath?: string;\\n}\"\n },\n {\n \"name\": \"SubprocessOutputReader\",\n \"declaration\": \"export interface SubprocessOutputReader {\\n readFrom(fromByte: number): SubprocessOutputRead;\\n}\"\n },\n {\n \"name\": \"SubprocessSpawnSpec\",\n \"declaration\": \"export interface SubprocessSpawnSpec {\\n argv: readonly string[];\\n cwd: string;\\n stdio: SubprocessStdio;\\n graceMs: number;\\n signal?: AbortSignal | undefined;\\n env?: NodeJS.ProcessEnv | undefined;\\n}\"\n },\n {\n \"name\": \"SubprocessStdinMode\",\n \"declaration\": \"export type SubprocessStdinMode = 'ignore' | 'pipe' | {\\n readonly data: string;\\n};\"\n },\n {\n \"name\": \"SubprocessStdio\",\n \"declaration\": \"export interface SubprocessStdio {\\n stdin: SubprocessStdinMode;\\n stdout: SubprocessOutputMode;\\n stderr: SubprocessOutputMode;\\n control?: 'pipe';\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalForeground\",\n \"declaration\": \"export interface SubprocessTerminalForeground {\\n processGroupId: number;\\n inputWaiting: boolean;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalHandle\",\n \"declaration\": \"export interface SubprocessTerminalHandle {\\n readonly pid: number;\\n readonly output: Readable;\\n readonly done: Promise;\\n write(data: string): Promise;\\n inspectForeground(): Promise;\\n signalForeground(signal: SubprocessTerminalSignal): Promise;\\n terminate(): Promise;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalSignal\",\n \"declaration\": \"export type SubprocessTerminalSignal = 'SIGINT' | 'SIGTERM' | 'SIGKILL' | 'SIGTSTP' | 'SIGHUP';\"\n },\n {\n \"name\": \"SubprocessTerminalSpawnSpec\",\n \"declaration\": \"export interface SubprocessTerminalSpawnSpec {\\n argv: readonly string[];\\n cwd: string;\\n env?: Record | undefined;\\n rows: number;\\n cols: number;\\n graceMs: number;\\n signal?: AbortSignal | undefined;\\n}\"\n }\n ]\n }\n}"}],"isError":false}],"role":"user","id":"{{message:7}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"inspect-subprocess-api"},"content":[{"type":"tool-result","toolCallId":"inspect-subprocess-api","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"subprocess\",\n \"description\": \"Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).\\n\\nImplementations must honor these semantics:\\n\\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\\n- spawn returns a live handle synchronously. Target identity remains provider-private; `done` resolves with the spawned command's exit facts and may reject for spawn or provider failures.\\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\\n- SubprocessHandle.terminate (and the spec's abort signal) starts the provider's documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its signalling and observability limits.\\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"subprocess\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"subprocess\"\n ],\n \"expression\": \"ctx.subprocess\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"abstract resolveExecutable( command: string, env?: Readonly>, signal?: AbortSignal, ): Promise\",\n \"description\": \"Resolve one configured executable in this provider's execution world. Absolute paths are verified; bare names use the provider's scrubbed PATH plus explicit environment overrides. Relative paths containing separators are rejected: the resolution base is undefined, so providers fail loud instead of guessing.\",\n \"parameters\": [\n {\n \"name\": \"command\",\n \"description\": \"absolute executable path or bare PATH name.\"\n },\n {\n \"name\": \"env\",\n \"description\": \"explicit environment entries used for lookup.\"\n },\n {\n \"name\": \"signal\",\n \"description\": \"aborts remote or local lookup.\"\n }\n ],\n \"returns\": \"a canonical executable path.\"\n },\n {\n \"signature\": \"abstract terminalEnvironment(signal?: AbortSignal): Promise\",\n \"description\": \"Inspect shell-selection facts in the provider's execution environment.\",\n \"parameters\": [\n {\n \"name\": \"signal\",\n \"description\": \"cancellation of remote environment inspection.\"\n }\n ],\n \"returns\": \"platform and preferred shell; executable lookup and allocation remain separate operations.\"\n },\n {\n \"signature\": \"abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle\",\n \"description\": \"Start one managed child process from a fully-specified spec; this seam applies no defaults.\",\n \"parameters\": [\n {\n \"name\": \"spec\",\n \"description\": \"argv, directory, stdio dispositions, grace, cancellation, and environment.\"\n }\n ],\n \"returns\": \"the live process handle (streams/readers, signalling, outcome promise).\",\n \"throws\": [\n \"synchronously when pre-aborted or when argv, cwd, environment, or grace is invalid before handle creation.\"\n ]\n },\n {\n \"signature\": \"abstract spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise\",\n \"description\": \"Allocate a real terminal and start one owned process session. This is the only non-pipe process primitive: implementations own terminal byte I/O, foreground groups, signals, and whole-session quiescence.\",\n \"parameters\": [\n {\n \"name\": \"spec\",\n \"description\": \"fully specified argv, cwd, environment, dimensions, grace, and allocation cancellation.\"\n }\n ],\n \"returns\": \"the live terminal handle after allocation succeeds.\"\n }\n ]\n },\n \"referencedTypes\": [\n {\n \"name\": \"SubprocessCollect\",\n \"declaration\": \"export interface SubprocessCollect {\\n maxBytes: number;\\n spill?: {\\n maxBytes: number;\\n };\\n}\"\n },\n {\n \"name\": \"SubprocessCollectedOutputs\",\n \"declaration\": \"export interface SubprocessCollectedOutputs {\\n readonly stdout?: SubprocessOutputReader;\\n readonly stderr?: SubprocessOutputReader;\\n}\"\n },\n {\n \"name\": \"SubprocessHandle\",\n \"declaration\": \"export interface SubprocessHandle {\\n readonly stdin: Writable | undefined;\\n readonly stdout: Readable | undefined;\\n readonly stderr: Readable | undefined;\\n readonly control: Duplex | undefined;\\n readonly collected: SubprocessCollectedOutputs;\\n readonly done: Promise;\\n terminate(): void;\\n waitForExit(signal?: AbortSignal): Promise;\\n}\"\n },\n {\n \"name\": \"SubprocessOutcome\",\n \"declaration\": \"export interface SubprocessOutcome {\\n exitCode: number | null;\\n signal: NodeJS.Signals | null;\\n}\"\n },\n {\n \"name\": \"SubprocessOutputMode\",\n \"declaration\": \"export type SubprocessOutputMode = 'pipe' | 'inherit' | SubprocessCollect;\"\n },\n {\n \"name\": \"SubprocessOutputRead\",\n \"declaration\": \"export interface SubprocessOutputRead {\\n text: string;\\n nextOffset: number;\\n lossy: boolean;\\n spillPath?: string;\\n}\"\n },\n {\n \"name\": \"SubprocessOutputReader\",\n \"declaration\": \"export interface SubprocessOutputReader {\\n readFrom(fromByte: number): SubprocessOutputRead;\\n}\"\n },\n {\n \"name\": \"SubprocessSpawnSpec\",\n \"declaration\": \"export interface SubprocessSpawnSpec {\\n argv: readonly string[];\\n cwd: string;\\n stdio: SubprocessStdio;\\n graceMs: number;\\n signal?: AbortSignal | undefined;\\n env?: NodeJS.ProcessEnv | undefined;\\n}\"\n },\n {\n \"name\": \"SubprocessStdinMode\",\n \"declaration\": \"export type SubprocessStdinMode = 'ignore' | 'pipe' | {\\n readonly data: string;\\n};\"\n },\n {\n \"name\": \"SubprocessStdio\",\n \"declaration\": \"export interface SubprocessStdio {\\n stdin: SubprocessStdinMode;\\n stdout: SubprocessOutputMode;\\n stderr: SubprocessOutputMode;\\n control?: 'pipe';\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalEnvironment\",\n \"declaration\": \"export interface SubprocessTerminalEnvironment {\\n platform: 'posix' | 'windows';\\n defaultShell?: string;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalForeground\",\n \"declaration\": \"export interface SubprocessTerminalForeground {\\n processGroupId: number;\\n inputWaiting: boolean;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalHandle\",\n \"declaration\": \"export interface SubprocessTerminalHandle {\\n readonly pid: number;\\n readonly output: Readable;\\n readonly done: Promise;\\n write(data: string): Promise;\\n resize(cols: number, rows: number): Promise;\\n inspectForeground(): Promise;\\n signalForeground(signal: SubprocessTerminalSignal): Promise;\\n terminate(): Promise;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalSignal\",\n \"declaration\": \"export type SubprocessTerminalSignal = 'SIGINT' | 'SIGTERM' | 'SIGKILL' | 'SIGTSTP' | 'SIGHUP';\"\n },\n {\n \"name\": \"SubprocessTerminalSpawnSpec\",\n \"declaration\": \"export interface SubprocessTerminalSpawnSpec {\\n argv: readonly string[];\\n cwd: string;\\n env?: Record | undefined;\\n rows: number;\\n cols: number;\\n terminalType: string;\\n graceMs: number;\\n signal?: AbortSignal | undefined;\\n}\"\n }\n ]\n }\n}"}],"isError":false}],"role":"user","id":"{{message:7}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} {"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788778146037,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1788778146037,"index":0,"dt":[],"texts":["CORDIS_INSPECT_JSDOC_OK"]},{"type":"chunk","time":1788778146037,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}},{"type":"chunk","time":1788778146037,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788778146037,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} diff --git a/snapshots/web/auto-review-denial/ui.expected.md b/snapshots/web/auto-review-denial/ui.expected.md index c430cf3f33..b849cc3a7b 100644 --- a/snapshots/web/auto-review-denial/ui.expected.md +++ b/snapshots/web/auto-review-denial/ui.expected.md @@ -121,9 +121,11 @@ - 'button "Request #2"' - text: ASSISTANT - cell "(tool call only)" - - 'row "TOOL, run_code {\"code\":\"try { await tools.bash({command: \"echo hidden-input-ptc\"}) } catch (error) { return \"denial caught\" }\",\"description\":\"Catch an Auto-denied inner call\"}"': + - row "TOOL, run_code Catch an Auto-denied inner call": - cell "TOOL" - - 'cell "run_code{\"code\":\"try { await tools.bash({command: \"echo hidden-input-ptc\"}) } catch (error) { return \"denial caught\" }\",\"description\":\"Catch an Auto-denied inner call\"} → denial caught"' + - cell "run_codeCatch an Auto-denied inner call → denial caught": + - img + - text: run_codeCatch an Auto-denied inner call → denial caught - 'row "SUBTOOL, bash {\"command\":\"echo hidden-input-ptc\"}" [selected]': - cell "SUBTOOL" - 'cell "bash{\"command\":\"echo hidden-input-ptc\"} → AUTO_REVIEW_DENIED"' diff --git a/snapshots/web/navigation-panes/timing.expected.md b/snapshots/web/navigation-panes/timing.expected.md new file mode 100644 index 0000000000..0a7a17c389 --- /dev/null +++ b/snapshots/web/navigation-panes/timing.expected.md @@ -0,0 +1,12 @@ +- tabpanel "Timing": + - term: Started + - definition: + - button "2026-01-01 {{clock}}" + - term: Total duration + - definition: {{duration}} + - term: TTFT + - definition: {{duration}} + - term: Generation + - definition: {{duration}} + - term: Throughput + - definition: {{throughput}} tok/s diff --git a/snapshots/web/ptc-round/code.expected.md b/snapshots/web/ptc-round/code.expected.md new file mode 100644 index 0000000000..5e1d42ae59 --- /dev/null +++ b/snapshots/web/ptc-round/code.expected.md @@ -0,0 +1,7 @@ +- tabpanel "Code": + - text: Code typescript + - button "Wrap lines" [pressed] + - button "Original JSON" + - button "Copy code": + - img + - code: "const bashResult = await tools.bash({ command: \"echo CODE_ROUND_OK\", description: \"Echo CODE_ROUND_OK\" }); let readError = null; try { await tools.read({ file_path: \"missing.txt\" }); } catch (e) { readError = { toolName: e.toolName, message: e.message }; } return { bash: bashResult.stdout.text.trim(), readError };" diff --git a/snapshots/web/ptc-round/trajectory.expected.md b/snapshots/web/ptc-round/trajectory.expected.md new file mode 100644 index 0000000000..ed88d3b743 --- /dev/null +++ b/snapshots/web/ptc-round/trajectory.expected.md @@ -0,0 +1,22 @@ +- heading "Code typescript Wrap lines Copy code" [level=3]: + - button "Code": + - text: Code + - img + - text: typescript + - button "Wrap lines" + - button "Copy code": + - img +- code: "const bashResult = await tools.bash({ command: \"echo CODE_ROUND_OK\", description: \"Echo CODE_ROUND_OK\" }); let readError = null; try { await tools.read({ file_path: \"missing.txt\" }); } catch (e) { readError = { toolName: e.toolName, message: e.message }; } return { bash: bashResult.stdout.text.trim(), readError };" +- heading "Output Copy output" [level=3]: + - button "Output": + - text: Output + - img + - button "Copy output": + - img +- text: "{" +- tree "Result JSON": + - treeitem "bash:\"CODE_ROUND_OK\"," + - 'treeitem "Expand readError:{toolName: \"read\", message: \"cannot read \\\"{{cwd}}/workspace/missing.txt\\\": not found\"}"': + - button "Expand" + - text: "readError:{toolName: \"read\", message: \"cannot read \\\"{{cwd}}/workspace/missing.txt\\\": not found\"}" +- text: "}" diff --git a/tsconfig.base.json b/tsconfig.base.json index 602b98ed09..9a102cf77c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -28,6 +28,11 @@ // not declaration path aliases, keep each package/vendor source compiled // under its own tsconfig boundary. "paths": { + "@deepseek-ai/dsh-api-terminal-controller": ["./packages/api/terminal-controller/src/index.ts"], + "@deepseek-ai/dsh-api-terminal-controller/types": ["./packages/api/terminal-controller/src/types.ts"], + "@deepseek-ai/dsh-api-terminal-controller/client": ["./packages/api/terminal-controller/src/client/index.ts"], + "@deepseek-ai/dsh-client-ui-sidebar-terminal": ["./packages/client/ui-sidebar-terminal/src/index.ts"], + "@deepseek-ai/dsh-client-ui-sidebar-terminal/client": ["./packages/client/ui-sidebar-terminal/src/client/index.ts"], "@deepseek-ai/dsh-ssh/protocol": ["./packages/ssh/ssh/src/protocol.ts"], "@deepseek-ai/dsh-ssh/schemas": ["./packages/ssh/ssh/src/schemas.ts"], "@deepseek-ai/dsh-subprocess-local/output": ["./packages/subprocess/subprocess-local/src/output.ts"], diff --git a/tsconfig.client.json b/tsconfig.client.json index a5be2366cd..0ab7e0edcb 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -42,6 +42,8 @@ "packages/client/*/tests/**/*.host.spec.ts" ], "references": [ + { "path": "./packages/client/ui-sidebar-terminal" }, + { "path": "./packages/api/terminal-controller/tsconfig.client.json" }, // Producer-to-UI spill tests use the ptc-runtime contract with the real tool registry. { "path": "./packages/ptc-runtime/ptc-runtime" }, // Shared leaf: web e2e boots the real host webserver (fixture + real-host diff --git a/tsconfig.host.json b/tsconfig.host.json index 953ac48e5f..91137f5ac7 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -106,6 +106,7 @@ "apps/web/tests/goal-multi-turn-actions.e2e.ts", "apps/web/tests/agent-team-panel.e2e.ts", "apps/web/tests/sidebar-right.e2e.ts", + "apps/web/tests/sidebar-terminal.e2e.ts", "apps/web/tests/chat-scroll-fixture.ts", "apps/web/tests/chat-scroll-contract.e2e.ts", "apps/web/tests/chat-long-interactions.e2e.ts", @@ -143,6 +144,7 @@ "scripts/client-bundle-purity.spec.ts" ], "references": [ + { "path": "./packages/api/terminal-controller/tsconfig.host.json" }, { "path": "./packages/computer-use/computer-use" }, { "path": "./packages/experimental/computer-use-cua-driver-mcp" }, { "path": "./packages/experimental/computer-use-cua-driver-native" },