mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge branch 'master' into fix/web-zstd-session-logs
This commit is contained in:
@@ -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
|
||||
2026-07-22-tui-interactive-extension-service.md: 82e7c751b6e5b7500f9f7d7004fda8b905dccabb
|
||||
2026-07-22-tui-interactive-extension-service.zh.md: d7340e3f5dcf45e95b2d6e15ce3fc33726a555ae
|
||||
@@ -0,0 +1,41 @@
|
||||
# Agent Note: Effect-owned TUI interactive extensions
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-22-tui-interactive-extension-service.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Cordis plugins can register human commands through `ctx.commands`, but a command that needs terminal interaction has no supported presentation boundary. It must either remain non-interactive or capture the TUI's private pi-tui tree, focus state, renderer, and shutdown lifecycle. That coupling makes the extension depend on one front door's internals, lets independently developed overlays compete for focus, and leaves plugin unload with no reliable way to remove queued or visible UI.
|
||||
|
||||
## Decision
|
||||
|
||||
A mounted `@deepseek-ai/dsh-tui` provides `ctx.tui` after terminal startup succeeds. The service belongs to that exact terminal and agent, disappears before terminal teardown, and causes plugins that inject it to unload and reload with provider availability. Other front doors do not emulate it.
|
||||
|
||||
`ctx.tui.openOverlay()` is the first and only interactive extension primitive. It accepts a component factory, constrained layout options, and an optional abort signal. The factory receives a frozen host with the current viewport, semantic theme functions, display-text escaping, redraw, close, and a lifetime signal. It does not receive the pi-tui `TUI`, overlay handle, editor, transcript tree, focus controller, or terminal object.
|
||||
|
||||
One private overlay manager serializes built-in and plugin requests in FIFO order. The model selector and `ctx.userInteraction` question panel use the same manager, so all modal interaction has one focus owner. Closing the active overlay restores pi-tui's previous focus before the next request activates. Overlay state is process-local presentation: it is neither appended to the session log nor rebuilt during resume.
|
||||
|
||||
The service method runs through Cordis's traceable service proxy. It installs an effect on the calling plugin fiber before admitting the request; caller disposal therefore removes a queued request or closes an active overlay and awaits the same settled outcome. TUI shutdown first rejects admission, then disposes the service fiber so dependent plugins and their effects quiesce, settles remaining built-in work, and only then drains and stops the terminal.
|
||||
|
||||
Component construction, rendering, input, and invalidation run behind an exception boundary. A failure closes that request with an `error` outcome, reports a visible terminal error, and lets the queue continue. Components are trusted package code: their rendered lines may contain ANSI styling, and they must call `host.display()` before including untrusted text.
|
||||
|
||||
## Verification
|
||||
|
||||
Manager tests pin FIFO admission, cancellation, repeated close, shutdown outcomes, guarded callbacks, host capabilities, and per-file coverage. Cordis lifecycle tests pin caller ownership, provider loss and return, unloading-time rejection, and cleanup quiescence. Fake-terminal integration tests exercise plugin overlays alongside built-in questions, restored editor input, terminal remount, startup rollback, and service disappearance. Existing TUI interaction tests continue to exercise the model selector and question panel through the shared path.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Expose pi-tui objects directly.** This gives plugins maximum freedom but makes private focus, rendering, and teardown state a public compatibility contract. It also cannot arbitrate independently loaded overlays.
|
||||
|
||||
**Put interactive callbacks on command definitions.** Commands are shared by TUI and ACP and remain useful without a terminal. Adding terminal state to `ctx.commands` would couple discovery and dispatch to one presentation implementation.
|
||||
|
||||
**Create a complete TUI slot and action framework at once.** Actions, editor replacement, transcript renderers, status regions, and completion providers have different composition and conflict rules. Shipping them behind one broad API would freeze those rules before a concrete consumer proves them.
|
||||
|
||||
**Persist open overlays in session events.** Modal presentation is not model-visible session state, and arbitrary component state is not replayable. The plugin that owns durable data records that data through its domain service and recreates presentation when appropriate.
|
||||
|
||||
## Consequences
|
||||
|
||||
Interactive plugins gain a small stable front door with deterministic focus and Cordis-owned cleanup, while the TUI keeps authority over terminal lifecycle and pi-tui internals. Built-in dialogs and extensions cannot overlap or strand focus.
|
||||
|
||||
The API deliberately covers modal overlays only. Human command registration remains on `ctx.commands`; actions, slots, editor replacement, event renderers, and completion providers require separate contracts when real consumers establish their ordering and ownership semantics. FIFO serialization also means one stalled overlay blocks later modal work until its owner closes, aborts, or unloads it.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Agent Note: 由 effect 持有的 TUI 交互扩展
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-22-tui-interactive-extension-service.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Cordis 插件可以通过 `ctx.commands` 注册用户命令,但需要终端交互的命令没有受支持的呈现边界。它只能保持非交互,或者捕获 TUI 私有的 pi-tui 树、焦点状态、渲染器和关闭生命周期。此类耦合会使扩展依赖某个入口的内部实现,让各自独立开发的浮层争抢焦点,并导致插件卸载时无法可靠移除排队中或已显示的 UI。
|
||||
|
||||
## 决策
|
||||
|
||||
挂载的 `@deepseek-ai/dsh-tui` 在终端成功启动后提供 `ctx.tui`。该服务只属于挂载时绑定的终端与 agent(智能体),在终端拆卸前消失,并使注入它的插件随着提供方的可用与否卸载和重新加载。其他入口不会模拟该服务。
|
||||
|
||||
`ctx.tui.openOverlay()` 是第一个也是唯一一个交互扩展原语。它接受组件工厂、受限的布局选项,以及可选的中止信号。工厂收到一个冻结的 host,其中包含当前视口、语义化主题函数、显示文本转义、重绘、关闭和生命周期信号。它不会收到 pi-tui `TUI`、浮层句柄、编辑器、transcript(文本记录)树、焦点控制器或终端对象。
|
||||
|
||||
一个私有浮层管理器按 FIFO 顺序串行处理内置请求和插件请求。模型选择器与 `ctx.userInteraction` 问题面板使用同一个管理器,因此所有模态交互只有一个焦点所有者。关闭活动浮层时,系统会先恢复 pi-tui 之前的焦点,再激活下一项请求。浮层状态是进程本地的呈现状态:它既不会追加到会话日志,也不会在恢复期间重建。
|
||||
|
||||
服务方法通过 Cordis 的可追踪服务代理运行。它在接纳请求前,向调用方插件的 fiber 注册一个 effect;因此,调用方执行 dispose(资源释放)时会移除排队中的请求或关闭活动浮层,并等待同一个结果完成结算。TUI 关闭时会先拒绝新请求,再 dispose 服务 fiber,让依赖插件及其 effect 完全静止,然后结算其余内置工作,最后才排空并停止终端。
|
||||
|
||||
组件构造、渲染、输入与失效处理均在异常边界内运行。任何失败都会以 `error` 结果关闭对应请求、在终端中报告一条可见错误,并让队列继续处理。组件属于受信任的包代码:其渲染行可以包含 ANSI 样式,但加入不受信任的文本前必须调用 `host.display()`。
|
||||
|
||||
## 验证
|
||||
|
||||
管理器测试固定了 FIFO 准入、取消、重复关闭、关闭结果、受保护回调、host 能力和逐文件覆盖率。Cordis 生命周期测试固定了调用方所有权、提供方消失与恢复、卸载期间的拒绝,以及清理达到完全静止。模拟终端集成测试覆盖插件浮层与内置问题的协作、编辑器输入焦点恢复、终端重新挂载、启动回滚和服务消失。既有 TUI 交互测试继续通过共享路径覆盖模型选择器与问题面板。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**直接暴露 pi-tui 对象。** 这会赋予插件最大的自由度,却会把私有的焦点、渲染与拆卸状态变成公开兼容性契约,也无法在独立加载的浮层之间进行仲裁。
|
||||
|
||||
**在命令定义中加入交互回调。** 命令由 TUI 与 ACP 共享,即使没有终端也仍然有用。向 `ctx.commands` 添加终端状态,会让发现与分派流程耦合到某一种呈现实现。
|
||||
|
||||
**一次性建立完整的 TUI slot 与 action 框架。** action、编辑器替换、transcript 渲染器、状态区域和补全提供方具有不同的组合规则与冲突规则。在具体消费方验证这些规则之前就将其纳入一个宽泛 API,会过早固化这些规则。
|
||||
|
||||
**将打开的浮层持久化为会话事件。** 模态呈现并非模型可见的会话状态,任意组件状态也无法回放。拥有持久数据的插件应通过自身的领域服务记录这些数据,并在适当时重新创建呈现。
|
||||
|
||||
## 后果
|
||||
|
||||
交互式插件获得一个小而稳定的入口,具备确定性的焦点管理和由 Cordis 持有的清理机制;TUI 则继续掌控终端生命周期和 pi-tui 内部实现。内置对话框与扩展无法重叠,也不会遗留失去归属的焦点。
|
||||
|
||||
该 API 有意只覆盖模态浮层。用户命令仍然在 `ctx.commands` 上注册;action、slot、编辑器替换、事件渲染器和补全提供方需要另行设计契约,等待实际消费方确定其顺序与所有权语义。FIFO 串行处理也意味着,一个停滞的浮层会阻塞后续模态工作,直至其所有者关闭、中止或卸载该浮层。
|
||||
@@ -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
|
||||
2026-07-20-config-hot-reload-resilience.md: 1a8e29c603ede50b60199e9151fca58dadcc3d40
|
||||
2026-07-20-config-hot-reload-resilience.zh.md: 6c7a421bfa84504a36d5329e13a485bf72cc6b6c
|
||||
@@ -0,0 +1,38 @@
|
||||
# Agent Note: A config hot-reload must not kill or degrade a live app
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-20-config-hot-reload-resilience.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The demo apps mount `@cordisjs/plugin-hmr` as a leaf so a running agent picks up `cordis.yml` edits. One bad edit killed the process: `Include.refresh()` rethrew the YAML parse error, the HMR watcher awaits `refresh()` inside an async chokidar callback nobody catches, and the resulting unhandled rejection tripped `dsh-app-boot`'s fail-loud handler — `exit(1)` mid-session, losing the live TUI. Two adjacent defects made even *valid* reloads wrong: a file that parses to `undefined` (empty or mid-write truncated — editors and `sed -i` routinely produce these states) crashed the entry walk instead of reading as invalid, and a re-read never re-applied the include's `config.patches`, so any hot-reload of an overlay-based tree (Code Mode, personal overlays) silently reverted patched entries and removed inserted ones.
|
||||
|
||||
## Decision
|
||||
|
||||
Harden the vendored `@cordisjs/plugin-include` (logged as local modification 8 in [vendor/README.md](../../../../vendor/README.md)) rather than the callers:
|
||||
|
||||
- `refresh()` awaits the whole read-and-update and catches failures, logs a warning, and keeps the last good entry tree. A hot-reload is advisory; the invariant is that no file state reachable by an editor may take the process down.
|
||||
- `read()` rejects a non-array parse result with a `TypeError`, folding the `undefined`-parse case into the same "invalid file" signal, and commits `content`/`data` only after a successful parse — so reverting an edit to the exact last good content correctly reads as "unchanged".
|
||||
- `refresh()` and the `internal/update` listener apply `this.applyPatches(...)` before `root.update()`, restoring parity with `[Service.init]`. `applyPatches` deep-copies the cached parse (`structuredClone`) instead of mutating it, so repeated application converges and removing a patch reverts to the file's own values. The listener uses the incoming config's `patches` and persists that config itself: it vetoes the fiber restart (children update in place), and `Fiber.update` only assigns `this.config` behind `next()`, so without the explicit assignment the next re-read would re-apply the old overlay.
|
||||
|
||||
Boot-time behavior stays fail-loud and gets a sharper diagnostic: `[Service.init]` falls back to `initial` (or "config file not found") only on `ENOENT`; an existing-but-invalid file now fails with its real parse error instead of being mislabelled as absent or silently overwritten by `initial`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Catch in the HMR watcher callback instead of `refresh()`.** Rejected: it would leave `refresh()` a trap for every other caller (the `internal/update` path shares the same tree-update logic), and it cannot fix the `undefined`-parse or patch-loss defects, which live inside the include.
|
||||
|
||||
**Filter config-file rejections in `installFailLoud`.** Rejected: the fail-loud handler exists to make late load failures visible; teaching it to classify exceptions by origin would silently swallow genuine boot failures and leave the stale-`data` crash in place.
|
||||
|
||||
**A PTY e2e proving the TUI survives a bad edit.** Rejected as the primary gate: the PTY smoke reads the repo's committed `cordis.yml`, so corrupting it in-place is not test-safe, and a temp copy cannot resolve the tree's bare package specifiers. The unit spec drives the exact `refresh()` entry point the watcher calls; the fix was additionally verified manually against the live TUI (bad YAML, empty file, restored file).
|
||||
|
||||
## Consequences
|
||||
|
||||
- A bad `cordis.yml` edit now logs `ignoring config reload at <file>` and the agent keeps running on the last good tree; the next valid edit applies normally. With no logger exporter mounted in the TUI demos the warning is currently invisible on screen — surfacing loader warnings in the TUI is deferred.
|
||||
- Overlay trees survive base-file reloads with patches intact instead of silently reverting to the unpatched base.
|
||||
- The vendored include diverges further from upstream; the divergence is logged in the vendor manifest and re-applies on the next sync.
|
||||
- Known gap, out of scope here: the HMR watcher only handles chokidar `change` events, so editors that replace the file by rename (BSD `sed -i`, `git checkout`) do not trigger a config reload at all; and a reloaded app-entry config does not visibly restart the running TUI (pre-existing on the unmodified tree).
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/app-boot/tests/config-reload.spec.ts` boots real Loader trees against temp configs and pins: an invalid-YAML edit and an empty-file edit both resolve `refresh()` without rejection and keep the previous entry config; a subsequent valid edit applies; an overlay tree re-applies both entry patches and inserted entries on re-read; a hot-update of the include entry's own `patches` applies immediately, survives the next file re-read, and reverts cleanly when the patches are removed. The assertions fail on the unpatched vendored include.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Agent Note: 配置热重载不得杀死或降级正在运行的应用
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-20-config-hot-reload-resilience.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
各示例应用把 `@cordisjs/plugin-hmr` 挂载为叶子配置项,让运行中的 agent 能感知 `cordis.yml` 的编辑。一次错误的编辑就会杀死进程:`Include.refresh()` 把 YAML 解析错误原样抛出,HMR 的文件监听器在一个无人捕获的异步 chokidar 回调里 await `refresh()`,产生的未处理 rejection 触发 `dsh-app-boot` 的快速失败处理器——会话中途 `exit(1)`,正在运行的 TUI 就此丢失。另有两个相邻缺陷让*合法*的重载也出错:解析结果为 `undefined` 的文件(空文件或写入中途被截断的文件——编辑器和 `sed -i` 常态性地产生这类中间状态)会让配置项遍历直接崩溃,而不是被判定为无效文件;并且重新读取时从不重新应用 include 的 `config.patches`,因此对基于 overlay 的配置树(Code Mode、个人 overlay)做任何热重载,都会悄悄把打过补丁的配置项回退、并把插入的配置项移除。
|
||||
|
||||
## Decision
|
||||
|
||||
加固 vendor 的 `@cordisjs/plugin-include`(在 [vendor/README.md](../../../../vendor/README.md) 中记录为本地修改第 8 条),而不是修改调用方:
|
||||
|
||||
- `refresh()` await 整个「读取并更新」过程并捕获失败,记录一条警告,并保留上一份完好的配置树。热重载是尽力而为的;不变式是编辑器可能产生的任何文件状态都不得导致进程退出。
|
||||
- `read()` 对非数组的解析结果抛出 `TypeError`,把 `undefined` 解析结果并入同一个「无效文件」信号,并且只在解析成功后才提交 `content`/`data`——因此把编辑撤销回与上一份完好内容完全一致时,会正确地判定为「无变化」。
|
||||
- `refresh()` 与 `internal/update` 监听器在 `root.update()` 之前调用 `this.applyPatches(...)`,与 `[Service.init]` 保持一致。`applyPatches` 对缓存的解析结果做深拷贝(`structuredClone`)而不是就地修改,因此重复应用会收敛,移除补丁会回退到文件自身的值。监听器使用传入配置中的 `patches` 并自行持久化该配置:它否决 fiber 重启(子配置项就地更新),而 `Fiber.update` 只在 `next()` 之后才赋值 `this.config`,若不显式赋值,下一次重新读取会重新应用旧的 overlay。
|
||||
|
||||
启动期行为保持快速失败并获得更准确的诊断:`[Service.init]` 只在 `ENOENT` 时回退到 `initial`(或「config file not found」);存在但无效的文件现在会以真实的解析错误失败,而不是被误标为文件缺失、或被 `initial` 静默覆盖。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**在 HMR 监听回调里捕获,而不是在 `refresh()` 里。** 否决:这会让 `refresh()` 继续成为其他所有调用方的陷阱(`internal/update` 路径共享同一套树更新逻辑),而且无法修复 `undefined` 解析结果与补丁丢失这两个位于 include 内部的缺陷。
|
||||
|
||||
**在 `installFailLoud` 里过滤配置文件相关的 rejection。** 否决:快速失败处理器的存在意义就是让延迟出现的加载失败可见;教它按来源给异常分类会悄悄吞掉真正的启动失败,并且原样保留陈旧 `data` 导致的崩溃。
|
||||
|
||||
**用 PTY e2e 证明 TUI 能在错误编辑后存活。** 否决其作为主要门禁:PTY 冒烟测试读取仓库中已提交的 `cordis.yml`,就地破坏它对测试不安全,而临时副本无法解析该配置树的裸包说明符。单元测试直接驱动监听器所调用的 `refresh()` 入口;此外还对运行中的 TUI 做了人工验证(错误 YAML、空文件、恢复文件)。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 现在错误的 `cordis.yml` 编辑会记录 `ignoring config reload at <file>`,agent 继续运行在上一份完好的配置树上;下一次合法编辑正常生效。TUI 示例没有挂载任何日志导出器,这条警告目前不会显示在屏幕上——在 TUI 中呈现 loader 警告的工作暂缓。
|
||||
- overlay 配置树在基础文件重载后补丁保持完整,不再悄悄回退到未打补丁的基础配置。
|
||||
- vendor 的 include 与上游进一步分叉;该分叉已记录在 vendor 的 manifest 里,下次同步时重新应用。
|
||||
- 已知缺口,不在本次范围内:HMR 监听器只处理 chokidar 的 `change` 事件,因此通过重命名替换文件的编辑方式(BSD `sed -i`、`git checkout`)完全不会触发配置重载;应用配置项重载后也不会可见地重启运行中的 TUI(未修改的代码树上即已如此)。
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/app-boot/tests/config-reload.spec.ts` 用真实 Loader 树加载临时配置并固定以下行为:无效 YAML 编辑和空文件编辑都让 `refresh()` 正常 resolve 而不产生 rejection,并保留之前的配置项配置;随后的合法编辑正常生效;overlay 配置树在重新读取时重新应用配置项补丁和插入的配置项;对 include 配置项自身 `patches` 的热更新立即生效、在下一次文件重读后依然保持、并在补丁移除后干净地回退。这些断言在未打补丁的 vendor include 上会失败。
|
||||
@@ -117,7 +117,7 @@ fs/web/todo execute in-process, so their sandbox semantics are policy at their s
|
||||
|
||||
- **Unit:** pin platform selection and profiles, fail-closed runner classification, per-call mode/root resolution, per-process facts, escalation validation and outcomes, permission preset folding and write-through, narrator coalescing, ACP advertisement and validation, and turn-enclosed config writes.
|
||||
- **Keyless real-runner:** exercise bwrap, Landlock, and Seatbelt against real filesystem effects at provider and bash-consumer layers; one real Cordis context concurrently drives two project sessions through shipped bash and fs tools, proving own-root success and sibling-root denial. Packed-install coverage proves the registry launcher remains executable. The real ACP composition pins permission switching and rejects unknown presets. CI rejects a silent all-skip.
|
||||
- **With-key:** drive a real model, runner, bridge answerer, and disk effect through granted and rejected escalation; unavailable credentials or runners self-skip.
|
||||
- **With-key:** start the real ACP composition in read-only mode, let a model-driven bash write hit the runner's denial marker, then drive the bridge answerer and disk effect through granted and rejected workspace-write retries; unavailable credentials or runners self-skip.
|
||||
- **Snapshot:** pin the permission config-option wire, preset and knob events, prompt deltas and notices, and both scripted approval branches. A real ACP example scenario places its session under the user home while the deployment fallback points at `/tmp`, then pins a successful workspace-write mutation; this distinguishes session-root resolution from the process fallback without depending on runner-specific denial text. Other snapshots start unconfined so unrelated fixtures remain platform-independent, and policy scenarios switch explicitly.
|
||||
|
||||
## Deferred phases
|
||||
|
||||
@@ -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
|
||||
2026-07-20-dsh-cli-personal-config.md: e349374a6bc7fc0137bf14836469aef8bae8d49d
|
||||
2026-07-20-dsh-cli-personal-config.zh.md: 88210dc386a245002de927950dab2852e40218ea
|
||||
2026-07-20-dsh-cli-personal-config.md: 514bb5b12a3e04c7deaad1e8616472eed1c920e1
|
||||
2026-07-20-dsh-cli-personal-config.zh.md: 16fada82c59c8a356e6df112234e6b7565aae1bf
|
||||
|
||||
@@ -22,6 +22,8 @@ Two coupled pieces, aligned with the `apps/` assembly tier proposed by the `dsh
|
||||
|
||||
The PTY smoke's launcher isolates `$DSH_HOME` to a per-test directory, exactly as it already isolates `DSH_AGENTS_HOME`, so a developer's real personal overlay cannot leak into fixtures; only the dsh CLI reads personal config, so no other test launcher needed changes.
|
||||
|
||||
Hot-reload interplay: the include re-applies its `patches` on every config re-read (the [config hot-reload resilience Agent Note](../bug-fix/2026-07-20-config-hot-reload-resilience.md)), so a live `cordis.yml` edit keeps the personal overlay applied.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A standalone `bin/dsh` wrapper owning the `dsh` name.** Rejected after reading PR #443: that PR establishes `apps/cli` as the `dsh` CLI with subcommand dispatch (`web`, `-p`) and leaves the default slot unclaimed. Two competing `dsh` entrypoints would collide in `$PATH` and in product identity; claiming the default slot inside the same package shape confines the eventual merge conflict to the small dispatch chain.
|
||||
|
||||
@@ -22,6 +22,8 @@ Status: implemented
|
||||
|
||||
PTY 冒烟测试的启动器把 `$DSH_HOME` 隔离到每个测试自己的目录,与它已有的 `DSH_AGENTS_HOME` 隔离方式完全一致,开发者真实的个人 overlay 不可能泄漏进 fixture;只有 dsh CLI 读取个人配置,因此其他测试启动器无需改动。
|
||||
|
||||
与热重载的交互:include 在每次配置重读时重新应用其 `patches`(见[配置热重载韧性 Agent Note](../bug-fix/2026-07-20-config-hot-reload-resilience.md)),因此运行中编辑 `cordis.yml` 后个人 overlay 仍保持生效。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**独立的 `bin/dsh` 包装脚本占有 `dsh` 这个名字。** 读过 PR #443 后否决:该 PR 把 `apps/cli` 确立为带子命令分发(`web`、`-p`)的 `dsh` CLI,并且默认位空缺。两个互相竞争的 `dsh` 入口会在 `$PATH` 和产品身份上冲突;在同一包形态内认领默认位,把最终的合并冲突限制在小小的分发链上。
|
||||
|
||||
@@ -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
|
||||
2026-07-21-tui-reload-command.md: de9a5502214a610d88024730b1c0c1044a396c92
|
||||
2026-07-21-tui-reload-command.zh.md: 25d1d448459221698ca63377f8f18d05a0fa3d21
|
||||
2026-07-21-tui-reload-command.md: e5600f0ab5cd82dc556df76006fcf532d8c7d302
|
||||
2026-07-21-tui-reload-command.zh.md: 3798b0518df1c379cca808bd4af38490016567cb
|
||||
|
||||
@@ -10,7 +10,7 @@ HMR's file watcher only reacts to in-place `change` events under its configured
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh-tui` gains an **experimental, dev-only** `/reload` slash command: it walks `ctx.loader.entries()` and calls `refresh()` on every file-backed subtree (`Include`), i.e. the exact code path the HMR watcher's config-change branch drives, invoked manually and watcher-independent. Unchanged files are no-ops (content comparison in `Include.read`).
|
||||
`dsh-tui` gains an **experimental, dev-only** `/reload` slash command: it walks `ctx.loader.entries()` and calls `refresh()` on every file-backed subtree (`Include`), i.e. the exact code path the HMR watcher's config-change branch drives, invoked manually and watcher-independent. Unchanged files are no-ops (content comparison in `Include.read`); invalid files warn and keep the running tree (the hot-reload-resilience contract); include `patches` — including the dsh CLI's personal overlay — re-apply on every re-read.
|
||||
|
||||
The TUI reaches the Loader **structurally** (`ctx.loader` via a local type, not `inject`): tests and embedders run the TUI without a Loader, where `/reload` degrades to a warning notice instead of failing the mount. Module-source hot reload stays watcher-owned; `/reload` refreshes configs only.
|
||||
|
||||
@@ -28,8 +28,8 @@ The TUI reaches the Loader **structurally** (`ctx.loader` via a local type, not
|
||||
- The command reports tree count and completion as transcript notices; per-file failures surface only in loader logs, which the TUI does not display — acceptable for a dev-only surface, noted in the completion message.
|
||||
- A re-entrancy guard serializes reloads: `/reload` while one is in flight is refused with a warning, keeping the loader's unmutexed tree-update pass single-writer; the guard releases on completion or failure.
|
||||
- `/reload` runs only while the agent is idle: a reload can dispose and re-mount entries, which under an active turn could tear tools or the adapter out from under in-flight calls. The check is advisory (a send can race in after it) but removes the common footgun.
|
||||
- If any `refresh()` rejects, the command reports the failure instead of leaving an unhandled rejection.
|
||||
- If `refresh()`'s never-reject contract ever changes, the command reports the failure instead of leaving an unhandled rejection.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/tui/tests/tui.spec.ts` pins: `/reload` refreshes every file-backed subtree and skips plain entries (structural fake Loader), reports completion, refuses re-entry while a gated refresh is in flight and runs again after release, releases the guard on the failure arm, refuses a running agent and runs again at idle, reports a rejecting refresh, and degrades to a warning without a Loader — including mounted as a real plugin fiber, where a throwing service lookup would escape. Verified live in tmux against the real tree: a probe edit reloads successfully.
|
||||
`packages/ui/tui/tests/tui.spec.ts` pins: `/reload` refreshes every file-backed subtree and skips plain entries (structural fake Loader), reports completion, refuses re-entry while a gated refresh is in flight and runs again after release, releases the guard on the failure arm, refuses a running agent and runs again at idle, reports a rejecting refresh, and degrades to a warning without a Loader — including mounted as a real plugin fiber, where a throwing service lookup would escape. Verified live in tmux against the real tree: probe edit → reload applies; invalid edit → reload keeps the running tree.
|
||||
|
||||
@@ -10,7 +10,7 @@ HMR 的文件监听器只对其配置根目录(示例中即配置叶子所在
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh-tui` 增加一个**实验性、仅供开发**的 `/reload` 斜杠命令:遍历 `ctx.loader.entries()`,对每个文件后端的子树(`Include`)调用 `refresh()`——即 HMR 监听器配置变更分支所走的同一条代码路径,改为手动触发、不依赖监听器。未变化的文件是无操作(`Include.read` 做内容比较)。
|
||||
`dsh-tui` 增加一个**实验性、仅供开发**的 `/reload` 斜杠命令:遍历 `ctx.loader.entries()`,对每个文件后端的子树(`Include`)调用 `refresh()`——即 HMR 监听器配置变更分支所走的同一条代码路径,改为手动触发、不依赖监听器。未变化的文件是无操作(`Include.read` 做内容比较);无效文件记录警告并保留运行中的树(热重载韧性契约);include 的 `patches`——包括 dsh CLI 的个人 overlay——在每次重读时重新应用。
|
||||
|
||||
TUI 以**结构方式**访问 Loader(通过局部类型访问 `ctx.loader`,而非 `inject`):测试和嵌入方在没有 Loader 的情况下运行 TUI,此时 `/reload` 退化为一条警告通知而不是挂载失败。模块源码热重载仍由监听器负责;`/reload` 只刷新配置。
|
||||
|
||||
@@ -28,8 +28,8 @@ TUI 以**结构方式**访问 Loader(通过局部类型访问 `ctx.loader`,
|
||||
- 命令以 transcript 通知报告树数量与完成;单文件失败只出现在 loader 日志里,TUI 不显示——对仅供开发的表面可以接受,完成消息中已注明。
|
||||
- 重入保护串行化重载:前一次进行中时 `/reload` 会被拒绝并提示警告,使 loader 无互斥的树更新过程保持单写者;保护在完成或失败时释放。
|
||||
- `/reload` 只在 agent 空闲时运行:重载可能卸载并重新挂载配置项,在活跃轮次下这会把工具或适配器从进行中的调用脚下抽掉。检查是建议性的(检查后仍可能有 send 竞争进来),但消除了常见的坑。
|
||||
- 任一 `refresh()` 若 reject,命令会报告失败而不是留下未处理的 rejection。
|
||||
- 若 `refresh()` 的永不 reject 契约将来改变,命令会报告失败而不是留下未处理的 rejection。
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/tui/tests/tui.spec.ts` 固定:`/reload` 刷新每个文件后端子树并跳过普通配置项(结构化的假 Loader)、报告完成、在门控的刷新进行中拒绝重入并在释放后可再次运行、失败分支同样释放保护、拒绝运行中的 agent 并在空闲后可再次运行、报告 reject 的 refresh、无 Loader 时退化为警告——包括作为真实插件 fiber 挂载的情形,在那里会抛出的服务查找会泄露出去。已在 tmux 中对真实配置树实机验证:探针编辑后 reload 成功生效。
|
||||
`packages/ui/tui/tests/tui.spec.ts` 固定:`/reload` 刷新每个文件后端子树并跳过普通配置项(结构化的假 Loader)、报告完成、在门控的刷新进行中拒绝重入并在释放后可再次运行、失败分支同样释放保护、拒绝运行中的 agent 并在空闲后可再次运行、报告 reject 的 refresh、无 Loader 时退化为警告——包括作为真实插件 fiber 挂载的情形,在那里会抛出的服务查找会泄露出去。已在 tmux 中对真实配置树实机验证:探针编辑 → reload 生效;无效编辑 → reload 保留运行中的树。
|
||||
|
||||
@@ -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
|
||||
architecture.md: 6ff2aa1ad4ca2ef051322f9d95631fe626d26e84
|
||||
architecture.zh.md: b4b26efec16d85f1fb26589c5c9bffbb35e39564
|
||||
architecture.md: 46b103ec788adbf7673e8b75643c71191318b42f
|
||||
architecture.zh.md: 2684fe745fe8afd9ebf79f047dd9798ff432e506
|
||||
|
||||
@@ -185,7 +185,7 @@ New behavior attaches to a documented extension point; a loop change updates thi
|
||||
| Confine spawned processes | a `ctx.sandbox` backend; consumers wrap their argv before spawning |
|
||||
| Intercept a request, tool, or turn | use its `agent/*` or `tools/*` event; `agent/turn-stop` is the serial terminal stop |
|
||||
| Add a session-stable prefix outside history | compose `agent/session-prefix`; the request header logs it |
|
||||
| Add UI or editor integration | drive `ctx.agents` and render from `session/event` |
|
||||
| Add UI or editor integration | drive `ctx.agents` and render from `session/event`; terminal-only overlays use `ctx.tui` |
|
||||
| Add durable session state | add a `SessionEventMap` member and render/replay from the log |
|
||||
| Add asynchronous session-title generation | register the sole provider on `ctx.sessionTitle` |
|
||||
| Manage a same-session objective | use `ctx.goals`; continue through `Agent` and `agent/*` |
|
||||
|
||||
@@ -185,7 +185,7 @@ forever:
|
||||
| 限制生成的进程 | 使用 `ctx.sandbox` 后端;消费方在生成进程前包装 argv |
|
||||
| 拦截请求、工具或轮次 | 使用相应的 `agent/*` 或 `tools/*` 事件;`agent/turn-stop` 是串行终止判定点 |
|
||||
| 添加历史记录之外的会话稳定前缀 | 组合 `agent/session-prefix`;请求头会记录该前缀 |
|
||||
| 添加 UI 或编辑器集成 | 驱动 `ctx.agents` 并从 `session/event` 渲染 |
|
||||
| 添加 UI 或编辑器集成 | 驱动 `ctx.agents` 并从 `session/event` 渲染;仅终端可用的浮层使用 `ctx.tui` |
|
||||
| 添加持久会话状态 | 添加一个 `SessionEventMap` 成员,并从日志渲染和回放 |
|
||||
| 添加异步会话标题生成 | 在 `ctx.sessionTitle` 上注册唯一提供方 |
|
||||
| 管理同会话目标 | 使用 `ctx.goals`;通过 `Agent` 和 `agent/*` 续跑 |
|
||||
|
||||
@@ -61,6 +61,7 @@ flowchart LR
|
||||
svc_planMode["ctx.planMode<br/>Plan collaboration state"]
|
||||
pkg_commands["commands"]
|
||||
svc_commands["ctx.commands<br/>Human command registry"]
|
||||
svc_tui["ctx.tui<br/>Mounted-terminal interaction service"]
|
||||
pkg_skill["skill"]
|
||||
svc_skills["ctx.skills<br/>Skill provider registry"]
|
||||
pkg_skill_local["skill-local"]
|
||||
@@ -172,6 +173,7 @@ flowchart LR
|
||||
pkg_token_meter --> svc_tokenMeter
|
||||
pkg_tool_bash --> svc_bashEnv
|
||||
pkg_tools --> svc_tools
|
||||
pkg_tui --> svc_tui
|
||||
pkg_tui --> svc_userInteraction
|
||||
pkg_user_interaction --> svc_userInteraction
|
||||
pkg_web --> svc_web
|
||||
@@ -277,6 +279,7 @@ flowchart LR
|
||||
| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. |
|
||||
| `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | [`acp`](../packages/ui/acp) | - | Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions. |
|
||||
| `ctx.commands` | `core` | [`commands`](../packages/ui/commands) | - | [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | - | Plugins register direct human commands; TUI and ACP consume the same effective per-agent catalog without sending invocations to the model. |
|
||||
| `ctx.tui` | `bundle` | [`tui`](../packages/ui/tui) | - | - | - | One TUI front door provides a FIFO overlay host; injected plugins receive caller-fiber ownership without access to pi-tui or terminal lifecycle state. |
|
||||
| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. |
|
||||
| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tui-demo`](../packages/examples/tui-demo) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. |
|
||||
| `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. |
|
||||
|
||||
@@ -1590,7 +1590,7 @@ export interface TuiConfig {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/ui/tui/src/index.ts:161`](../packages/ui/tui/src/index.ts)
|
||||
Source: [`packages/ui/tui/src/index.ts:216`](../packages/ui/tui/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-tui-demo`
|
||||
|
||||
|
||||
@@ -1614,6 +1614,29 @@ Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:524`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
## `ctx.tui` — `TuiExtensionService` (abstract seam)
|
||||
|
||||
Optional terminal-local interaction service provided by one mounted TUI.
|
||||
|
||||
The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugins receive only effect-owned overlay sessions.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Queue an interactive overlay owned by the calling plugin fiber.
|
||||
*
|
||||
* The TUI displays one overlay at a time in FIFO order. Disposing the caller
|
||||
* removes a queued overlay or closes an active one before plugin teardown
|
||||
* settles. This live presentation is neither logged nor replayed.
|
||||
*
|
||||
* @param request - component factory, layout constraints, and cancellation.
|
||||
* @returns the effect-owned overlay session.
|
||||
* @throws when the TUI has begun shutting down.
|
||||
*/
|
||||
abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession
|
||||
```
|
||||
|
||||
Source: [`packages/ui/tui/src/index.ts:131`](../../packages/ui/tui/src/index.ts)
|
||||
|
||||
## `ctx.userInteraction` — `UserInteractionService`
|
||||
|
||||
`ctx.userInteraction`: one active UI provider plus an `ask()` surface.
|
||||
|
||||
@@ -25,13 +25,11 @@ import { cleanupAcpExampleTest } from './cleanup.ts'
|
||||
* model nor a sandbox runner is ever exercised.
|
||||
*
|
||||
* With-key escalation flow (self-skips without DEEPSEEK_API_KEY or a usable
|
||||
* platform runner): a scripted ACP client plays the human. The prompt asserts
|
||||
* a prior denial (the organic denial→marker path lives on the sandbox e2e
|
||||
* legs and unit tiers), the real model escalates with `sandbox_permissions` +
|
||||
* `justification`, the bridge prompts THIS client over
|
||||
* `session/request_permission`, the client answers `allow-once`, and the
|
||||
* retried write must land ON DISK (world-verified) — under the granted mode,
|
||||
* a temp-dir session cwd is writable either way.
|
||||
* platform runner): a scripted ACP client plays the human. The subprocess
|
||||
* starts read-only, its first real bash write is denied, the model retries with
|
||||
* `sandbox_permissions` + `justification`, and the bridge prompts THIS client
|
||||
* over `session/request_permission`. An approved workspace-write retry must
|
||||
* then land ON DISK (world-verified).
|
||||
*/
|
||||
|
||||
const AGENT: AgentUnderTest = {
|
||||
@@ -58,14 +56,21 @@ interface Spawned extends LaunchedAcpTestAgent {
|
||||
permissionRequests: RequestPermissionRequest[]
|
||||
}
|
||||
|
||||
/** Boot the example as an ACP subprocess; the scripted client answers every permission prompt with `answer`. */
|
||||
function launchExampleAcpAgent(cwd: string, answer: 'allow-once' | 'reject-once'): Spawned {
|
||||
/** Boot the example with an optional sandbox override; the scripted client answers every permission prompt with `answer`. */
|
||||
function launchExampleAcpAgent(
|
||||
cwd: string,
|
||||
answer: 'allow-once' | 'reject-once',
|
||||
sandboxMode?: 'read-only' | 'workspace-write' | 'danger-full-access',
|
||||
): Spawned {
|
||||
const permissionRequests: RequestPermissionRequest[] = []
|
||||
const launched = launchAcpTestAgent({
|
||||
agent: AGENT,
|
||||
cwd,
|
||||
// A dummy key lets the adapter boot keylessly; live tests carry the real key.
|
||||
env: { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot' },
|
||||
env: {
|
||||
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot',
|
||||
DSH_PERMISSION_MODE: sandboxMode,
|
||||
},
|
||||
requestPermission(params) {
|
||||
permissionRequests.push(params)
|
||||
const option = params.options.find(o => o.optionId === answer)
|
||||
@@ -78,6 +83,17 @@ function launchExampleAcpAgent(cwd: string, answer: 'allow-once' | 'reject-once'
|
||||
return Object.assign(launched, { permissionRequests })
|
||||
}
|
||||
|
||||
function escalationPrompt(path: string, content: string): string {
|
||||
return `Create ${path} containing exactly ${JSON.stringify(content)} using bash, not filesystem tools. `
|
||||
+ 'First try the command without sandbox_permissions. If the sandbox denies it, retry that exact command once '
|
||||
+ 'with sandbox_permissions set to workspace-write and a one-sentence justification.'
|
||||
}
|
||||
|
||||
function includesReadOnlyDenial(updates: LaunchedAcpTestAgent['updates']): boolean {
|
||||
return updates.some(update => update.sessionUpdate === 'tool_call_update'
|
||||
&& JSON.stringify(update.content).includes('[sandbox: file access denied under read-only mode]'))
|
||||
}
|
||||
|
||||
let spawned: Spawned | undefined
|
||||
let workdir: string | undefined
|
||||
|
||||
@@ -137,17 +153,20 @@ describe('default sandbox composition keyless smoke (real cordis.yml via the Loa
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY || !hasRunner)('default sandbox composition e2e: the live approval loop', () => {
|
||||
it('denial → model escalation → editor prompt → allow-once → the retried write lands on disk', async () => {
|
||||
workdir = await mkdtemp(join(tmpdir(), 'sandbox-acp-e2e-'))
|
||||
spawned = launchExampleAcpAgent(workdir, 'allow-once')
|
||||
const { client, permissionRequests } = spawned
|
||||
spawned = launchExampleAcpAgent(workdir, 'allow-once', 'read-only')
|
||||
const { client, permissionRequests, updates } = spawned
|
||||
|
||||
await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await client.newSession({ cwd: workdir, mcpServers: [] })
|
||||
const res = await client.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: `The sandbox already denied writing ${workdir}/escalated.txt. Create it now containing exactly "ACP_ESCALATION_OK": `
|
||||
+ 'one single bash call with sandbox_permissions set to danger-full-access and a one-sentence justification, then stop.' }],
|
||||
prompt: [{
|
||||
type: 'text',
|
||||
text: `${escalationPrompt(join(workdir, 'escalated.txt'), 'ACP_ESCALATION_OK')} Then stop.`,
|
||||
}],
|
||||
})
|
||||
expect(['end_turn', 'max_tokens']).toContain(res.stopReason)
|
||||
expect(includesReadOnlyDenial(updates)).toBe(true)
|
||||
|
||||
// The WORLD: the approved escalated retry landed the write.
|
||||
const proof = await readFile(join(workdir, 'escalated.txt'), 'utf8')
|
||||
@@ -166,17 +185,20 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || !hasRunner)('default sandbox co
|
||||
|
||||
it('a rejected escalation stays denied: no write lands, the turn still ends', async () => {
|
||||
workdir = await mkdtemp(join(tmpdir(), 'sandbox-acp-e2e-'))
|
||||
spawned = launchExampleAcpAgent(workdir, 'reject-once')
|
||||
const { client, permissionRequests } = spawned
|
||||
spawned = launchExampleAcpAgent(workdir, 'reject-once', 'read-only')
|
||||
const { client, permissionRequests, updates } = spawned
|
||||
|
||||
await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await client.newSession({ cwd: workdir, mcpServers: [] })
|
||||
const res = await client.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: `The sandbox already denied writing ${workdir}/refused.txt. Create it now containing "NO": `
|
||||
+ 'one single bash call with sandbox_permissions set to danger-full-access and a one-sentence justification. If that is rejected, stop and say so.' }],
|
||||
prompt: [{
|
||||
type: 'text',
|
||||
text: `${escalationPrompt(join(workdir, 'refused.txt'), 'NO')} If approval is rejected, stop and say so.`,
|
||||
}],
|
||||
})
|
||||
expect(['end_turn', 'max_tokens']).toContain(res.stopReason)
|
||||
expect(includesReadOnlyDenial(updates)).toBe(true)
|
||||
|
||||
// The WORLD: rejected means the file never appeared.
|
||||
await expect(readFile(join(workdir, 'refused.txt'), 'utf8')).rejects.toThrow()
|
||||
|
||||
@@ -758,6 +758,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'tui',
|
||||
summary: 'Optional terminal-local interaction service provided by one mounted TUI.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession',
|
||||
jsDoc: '/**\n * Queue an interactive overlay owned by the calling plugin fiber.\n *\n * The TUI displays one overlay at a time in FIFO order. Disposing the caller\n * removes a queued overlay or closes an active one before plugin teardown\n * settles. This live presentation is neither logged nor replayed.\n *\n * @param request - component factory, layout constraints, and cancellation.\n * @returns the effect-owned overlay session.\n * @throws when the TUI has begun shutting down.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'userInteraction',
|
||||
summary: '`ctx.userInteraction`: one active UI provider plus an `ask()` surface.',
|
||||
@@ -2031,6 +2041,58 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'ToolSchema',
|
||||
declaration: 'export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TuiComponent',
|
||||
declaration: 'export interface TuiComponent {\n render(width: number): string[];\n handleInput?(data: string): void;\n wantsKeyRelease?: boolean;\n invalidate(): void;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TuiFocusable',
|
||||
declaration: 'export interface TuiFocusable {\n focused: boolean;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TuiOverlayAnchor',
|
||||
declaration: 'export type TuiOverlayAnchor = \'center\' | \'top-left\' | \'top-right\' | \'bottom-left\' | \'bottom-right\' | \'top-center\' | \'bottom-center\' | \'left-center\' | \'right-center\';',
|
||||
},
|
||||
{
|
||||
name: 'TuiOverlayCloseReason',
|
||||
declaration: 'export type TuiOverlayCloseReason = \'closed\' | \'aborted\' | \'owner-disposed\' | \'tui-disposed\' | \'error\';',
|
||||
},
|
||||
{
|
||||
name: 'TuiOverlayHost',
|
||||
declaration: 'export interface TuiOverlayHost {\n readonly signal: AbortSignal;\n readonly viewport: TuiViewport;\n readonly theme: TuiTheme;\n display(value: string): string;\n invalidate(): void;\n close(): void;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TuiOverlayMargin',
|
||||
declaration: 'export interface TuiOverlayMargin {\n readonly top?: number;\n readonly right?: number;\n readonly bottom?: number;\n readonly left?: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TuiOverlayOptions',
|
||||
declaration: 'export interface TuiOverlayOptions {\n readonly width?: number | `${number}%`;\n readonly minWidth?: number;\n readonly maxHeight?: number | `${number}%`;\n readonly anchor?: TuiOverlayAnchor;\n readonly margin?: number | TuiOverlayMargin;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TuiOverlayOutcome',
|
||||
declaration: 'export type TuiOverlayOutcome = {\n readonly reason: Exclude<TuiOverlayCloseReason, \'error\'>;\n} | {\n readonly reason: \'error\';\n readonly error: unknown;\n};',
|
||||
},
|
||||
{
|
||||
name: 'TuiOverlayRequest',
|
||||
declaration: 'export interface TuiOverlayRequest {\n readonly create: (host: TuiOverlayHost) => TuiComponent & Partial<TuiFocusable>;\n readonly options?: TuiOverlayOptions;\n readonly signal?: AbortSignal;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TuiOverlaySession',
|
||||
declaration: 'export interface TuiOverlaySession {\n readonly state: TuiOverlayState;\n readonly closed: Promise<TuiOverlayOutcome>;\n close(): Promise<TuiOverlayOutcome>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TuiOverlayState',
|
||||
declaration: 'export type TuiOverlayState = \'queued\' | \'active\' | \'closed\';',
|
||||
},
|
||||
{
|
||||
name: 'TuiTheme',
|
||||
declaration: 'export interface TuiTheme {\n readonly text: (value: string) => string;\n readonly muted: (value: string) => string;\n readonly dim: (value: string) => string;\n readonly accent: (value: string) => string;\n readonly success: (value: string) => string;\n readonly warning: (value: string) => string;\n readonly error: (value: string) => string;\n readonly bold: (value: string) => string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TuiViewport',
|
||||
declaration: 'export interface TuiViewport {\n readonly columns: number;\n readonly rows: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TurnEndReason',
|
||||
declaration: 'export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];',
|
||||
|
||||
@@ -10,11 +10,11 @@ Integrations that expose the agent to an external editor or client. These are **
|
||||
| `permission/` | User-facing permission presets (`workspace-write`/`danger-full-access`): one product-level select bundling the sandbox-mode and approval-policy knobs, written through to their session events | `ctx.permission` |
|
||||
| `user-interaction/` | Abstract human question/answer seam used by UI-backed confirmation tools | `ctx.userInteraction` |
|
||||
| `tool-ask-user/` | Model-facing `ask_user_question` tool over `ctx.userInteraction` | (registers on `ctx.tools`) |
|
||||
| `tui/` | Interactive pi-tui terminal channel; renders session titles/events and tool intents, and answers `ctx.userInteraction` | (drives `ctx.agents`) |
|
||||
| `tui/` | Interactive pi-tui terminal channel; renders session titles/events and tool intents, answers `ctx.userInteraction`, and hosts effect-owned plugin overlays | `ctx.tui` (drives `ctx.agents`) |
|
||||
| `jsonrpc/` | Stdio JSON-RPC server for out-of-process SDK clients | (drives `ctx.agents`) |
|
||||
| `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) |
|
||||
|
||||
A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). [`tui`](tui/README.md) is the interactive terminal front door; non-interactive tasks use the headless `cli-demo` app instead of a UI channel. [`commands`](commands/README.md) is the human-only discovery and dispatch plane shared by TUI and ACP; command input and output do not become model messages.
|
||||
A UI integration is a client-driver plugin, not a loop change: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). [`tui`](tui/README.md) is the interactive terminal front door and supplies the terminal-local `ctx.tui` extension service; non-interactive tasks use the headless `cli-demo` app instead of a UI channel. [`commands`](commands/README.md) is the human-only discovery and dispatch plane shared by TUI and ACP; command input and output do not become model messages.
|
||||
|
||||
`user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with their UI channel owners. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and the app/bridge packages provide concrete providers.
|
||||
|
||||
|
||||
128
packages/ui/app-boot/tests/config-reload.spec.ts
Normal file
128
packages/ui/app-boot/tests/config-reload.spec.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Config hot-reload resilience of the booted include tree. `dsh-app-boot`
|
||||
* installs a fail-loud unhandled-rejection handler, so a `refresh()` that
|
||||
* rethrows a config-file parse error would kill a live app on one bad
|
||||
* `cordis.yml` edit (the HMR watcher awaits `refresh()` in an async event
|
||||
* callback nobody else catches). These tests pin the vendored
|
||||
* `@cordisjs/plugin-include` contract that boot relies on: an invalid file
|
||||
* keeps the last good tree, and a valid re-read re-applies overlay patches
|
||||
* exactly like the initial load.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import type { Include } from '@cordisjs/plugin-include'
|
||||
import { boot } from '../src/index.ts'
|
||||
|
||||
const NAME = 'dsh-test-bin'
|
||||
|
||||
const NOOP_PLUGIN = 'export const name = "noop"\nexport function apply() {}\n'
|
||||
|
||||
interface TreeFixture {
|
||||
ctx: Context
|
||||
dir: string
|
||||
include: Include
|
||||
}
|
||||
|
||||
async function bootTree(configBody: string): Promise<TreeFixture> {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-'))
|
||||
writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
|
||||
writeFileSync(join(dir, 'cordis.yml'), configBody)
|
||||
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
|
||||
const entry = [...ctx.loader.entries()].find(candidate => candidate.subtree !== undefined)
|
||||
if (entry?.subtree === undefined) throw new Error('booted tree has no include entry')
|
||||
return { ctx, dir, include: entry.subtree as Include }
|
||||
}
|
||||
|
||||
function entryConfig(ctx: Context, id: string): unknown {
|
||||
return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config
|
||||
}
|
||||
|
||||
describe('include refresh with an invalid file', () => {
|
||||
it('keeps the last good tree instead of throwing, then applies the next valid edit', async () => {
|
||||
const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n config:\n value: 1\n')
|
||||
try {
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
|
||||
|
||||
writeFileSync(join(dir, 'cordis.yml'), 'invalid: [unclosed\n')
|
||||
await expect(include.refresh()).resolves.toBeUndefined()
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
|
||||
|
||||
// An empty file parses to `undefined` without a YAML error; it must be
|
||||
// treated exactly like a parse failure, not crash the entry walk.
|
||||
writeFileSync(join(dir, 'cordis.yml'), '')
|
||||
await expect(include.refresh()).resolves.toBeUndefined()
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
|
||||
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: 2\n')
|
||||
await include.refresh()
|
||||
await ctx.loader.await()
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 2 })
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('include refresh with overlay patches', () => {
|
||||
it('re-applies entry patches and inserted entries on every re-read (parity with initial load)', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-overlay-'))
|
||||
writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
|
||||
writeFileSync(join(dir, 'base.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: base\n')
|
||||
writeFileSync(join(dir, 'cordis.yml'), [
|
||||
'- id: base',
|
||||
" name: 'cordis:include'",
|
||||
' config:',
|
||||
' path: ./base.yml',
|
||||
' patches:',
|
||||
' - id: noop',
|
||||
' name: ./noop.mjs',
|
||||
' config:',
|
||||
' value: patched',
|
||||
' - insert:',
|
||||
' - id: extra',
|
||||
' name: ./noop.mjs',
|
||||
'',
|
||||
].join('\n'))
|
||||
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
|
||||
try {
|
||||
const entry = [...ctx.loader.entries()].find(candidate => candidate.options.id === 'base')
|
||||
if (entry?.subtree === undefined) throw new Error('overlay tree has no base include entry')
|
||||
const include = entry.subtree as Include
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched' })
|
||||
expect(entryConfig(ctx, 'extra')).toBeUndefined()
|
||||
expect([...ctx.loader.entries()].some(candidate => candidate.options.id === 'extra')).toBe(true)
|
||||
|
||||
writeFileSync(join(dir, 'base.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: edited\n')
|
||||
await include.refresh()
|
||||
await ctx.loader.await()
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched' })
|
||||
expect([...ctx.loader.entries()].some(candidate => candidate.options.id === 'extra')).toBe(true)
|
||||
|
||||
// Hot-update of the include entry's own config (the `internal/update`
|
||||
// path): the new patches must apply now AND stick for later re-reads —
|
||||
// the listener vetoes the fiber restart, so it must persist the new
|
||||
// config itself or the next refresh() re-applies the old overlay.
|
||||
await entry.update({ config: { path: './base.yml', patches: [{ id: 'noop', name: './noop.mjs', config: { value: 'patched-v2' } }] } })
|
||||
await ctx.loader.await()
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched-v2' })
|
||||
expect([...ctx.loader.entries()].some(candidate => candidate.options.id === 'extra')).toBe(false)
|
||||
|
||||
writeFileSync(join(dir, 'base.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: edited-2\n')
|
||||
await include.refresh()
|
||||
await ctx.loader.await()
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched-v2' })
|
||||
|
||||
// Removing every patch must revert to the file's own values: patching
|
||||
// may not bake earlier patch results into the cached parse.
|
||||
await entry.update({ config: { path: './base.yml', patches: [] } })
|
||||
await ctx.loader.await()
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'edited-2' })
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,8 @@ Interactive terminals on macOS, Linux, and Windows are supported. Windows uses p
|
||||
|
||||
This package owns interactive terminal presentation and input only. It injects `agents`, [`commands`](../commands/README.md), `llm`, `systemPrompt`, `tokenMeter`, `tools`, and `userInteraction`, optionally reads a `skills` service (present only when one is mounted), then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries.
|
||||
|
||||
After terminal startup succeeds, the package provides the terminal-local `ctx.tui` extension service. A plugin that injects it can call `openOverlay()` with a component factory and constrained layout options; the host exposes the viewport, semantic theme, display-text escaping, redraw, close, and a lifetime signal, but not the pi-tui tree, terminal, focus controller, or overlay handle. Plugin overlays, the model selector, and user questions share one FIFO modal queue. Each request is an effect of the calling plugin fiber, so unload removes queued work or closes visible work before cleanup settles; terminal shutdown unloads dependents before stopping pi-tui. Overlay state is not logged or replayed. Component code is trusted and may render ANSI styling, but must pass untrusted text through `host.display()`. The [interactive-extension Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md) owns the boundary and rejected alternatives.
|
||||
|
||||
The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes `<session title> — <configured title>`. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelContext()` for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode and the current model with reasoning state; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear.
|
||||
|
||||
An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label differs from the session's host directory. The override changes only the footer label; tools continue to use the session `cwd`.
|
||||
@@ -57,7 +59,7 @@ When `resumeCommand` is set and a `sessionPersistence` backend is mounted, exiti
|
||||
maxToolOutputLines: 6
|
||||
```
|
||||
|
||||
Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal aborts running commands, removes the TUI definitions, stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR.
|
||||
Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal stops extension admission, unloads the `ctx.tui` provider and its dependent plugins, aborts running commands, removes the TUI definitions, stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR.
|
||||
|
||||
## Color
|
||||
|
||||
|
||||
165
packages/ui/tui/src/extension.ts
Normal file
165
packages/ui/tui/src/extension.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Public interactive-extension contract for one mounted TUI front door.
|
||||
*
|
||||
* Plugins receive terminal-specific rendering primitives without access to
|
||||
* the live pi-tui tree, focus controller, overlay handles, or terminal
|
||||
* lifecycle. Registrations and open overlays remain owned by the calling
|
||||
* Cordis fiber.
|
||||
* @module @deepseek-ai/dsh-tui/extension
|
||||
*/
|
||||
|
||||
/** Terminal component shape accepted from a trusted TUI extension. */
|
||||
export interface TuiComponent {
|
||||
/**
|
||||
* Render this component for the supplied viewport width.
|
||||
* @param width - Available terminal columns.
|
||||
* @returns terminal lines owned by this component.
|
||||
*/
|
||||
render(width: number): string[]
|
||||
/**
|
||||
* Handle one terminal input sequence while this component owns focus.
|
||||
* @param data - Raw terminal input sequence.
|
||||
*/
|
||||
handleInput?(data: string): void
|
||||
/** Receive key-release events instead of having them filtered by the host. */
|
||||
wantsKeyRelease?: boolean
|
||||
/** Drop cached rendering derived from theme, size, or component state. */
|
||||
invalidate(): void
|
||||
}
|
||||
|
||||
/** Optional focus state forwarded by the host to a component. */
|
||||
export interface TuiFocusable {
|
||||
/** Whether the component currently owns terminal focus. */
|
||||
focused: boolean
|
||||
}
|
||||
|
||||
/** Read-only semantic color roles supplied by the mounted TUI. */
|
||||
export interface TuiTheme {
|
||||
/** Render ordinary foreground text. */
|
||||
readonly text: (value: string) => string
|
||||
/** Render secondary information. */
|
||||
readonly muted: (value: string) => string
|
||||
/** Render low-emphasis hints. */
|
||||
readonly dim: (value: string) => string
|
||||
/** Render the active accent role. */
|
||||
readonly accent: (value: string) => string
|
||||
/** Render a successful outcome. */
|
||||
readonly success: (value: string) => string
|
||||
/** Render a warning. */
|
||||
readonly warning: (value: string) => string
|
||||
/** Render an error. */
|
||||
readonly error: (value: string) => string
|
||||
/** Apply the host's bold role. */
|
||||
readonly bold: (value: string) => string
|
||||
}
|
||||
|
||||
/** Current terminal viewport exposed without the mutable Terminal object. */
|
||||
export interface TuiViewport {
|
||||
/** Terminal columns. */
|
||||
readonly columns: number
|
||||
/** Terminal rows. */
|
||||
readonly rows: number
|
||||
}
|
||||
|
||||
/** Supported overlay anchor points. */
|
||||
export type TuiOverlayAnchor =
|
||||
| 'center'
|
||||
| 'top-left'
|
||||
| 'top-right'
|
||||
| 'bottom-left'
|
||||
| 'bottom-right'
|
||||
| 'top-center'
|
||||
| 'bottom-center'
|
||||
| 'left-center'
|
||||
| 'right-center'
|
||||
|
||||
/** Terminal-edge spacing for an overlay. */
|
||||
export interface TuiOverlayMargin {
|
||||
/** Rows reserved above the overlay. */
|
||||
readonly top?: number
|
||||
/** Columns reserved to the right of the overlay. */
|
||||
readonly right?: number
|
||||
/** Rows reserved below the overlay. */
|
||||
readonly bottom?: number
|
||||
/** Columns reserved to the left of the overlay. */
|
||||
readonly left?: number
|
||||
}
|
||||
|
||||
/** Position and size constraints retained under TUI host ownership. */
|
||||
export interface TuiOverlayOptions {
|
||||
/** Width in columns or as a percentage of terminal width. */
|
||||
readonly width?: number | `${number}%`
|
||||
/** Minimum width in columns. */
|
||||
readonly minWidth?: number
|
||||
/** Maximum height in rows or as a percentage of terminal height. */
|
||||
readonly maxHeight?: number | `${number}%`
|
||||
/** Overlay anchor; defaults to the terminal center. */
|
||||
readonly anchor?: TuiOverlayAnchor
|
||||
/** Terminal-edge spacing. */
|
||||
readonly margin?: number | TuiOverlayMargin
|
||||
}
|
||||
|
||||
/** Capabilities available while an overlay component is queued or visible. */
|
||||
export interface TuiOverlayHost {
|
||||
/**
|
||||
* Aborts when the request, caller fiber, overlay session, or TUI closes.
|
||||
* Extension work started for the overlay must cooperate with this signal.
|
||||
*/
|
||||
readonly signal: AbortSignal
|
||||
/** Current viewport; a fresh immutable value is returned on every read. */
|
||||
readonly viewport: TuiViewport
|
||||
/** Semantic styles that follow terminal color-scheme changes. */
|
||||
readonly theme: TuiTheme
|
||||
/**
|
||||
* Escape control characters in untrusted display text.
|
||||
* @param value - text crossing into terminal presentation.
|
||||
* @returns a printable representation that cannot emit terminal controls.
|
||||
*/
|
||||
display(value: string): string
|
||||
/** Invalidate the component and schedule one contained terminal redraw. */
|
||||
invalidate(): void
|
||||
/** Close this overlay normally; repeated calls are no-ops. */
|
||||
close(): void
|
||||
}
|
||||
|
||||
/** One effect-owned request to create an interactive overlay. */
|
||||
export interface TuiOverlayRequest {
|
||||
/**
|
||||
* Construct the component when this request reaches the front of the modal
|
||||
* queue. A throw closes the session with `reason: "error"`.
|
||||
*/
|
||||
readonly create: (host: TuiOverlayHost) => TuiComponent & Partial<TuiFocusable>
|
||||
/** Host-owned position and size constraints. */
|
||||
readonly options?: TuiOverlayOptions
|
||||
/** Optional request cancellation in addition to caller and TUI ownership. */
|
||||
readonly signal?: AbortSignal
|
||||
}
|
||||
|
||||
/** Stable reason an overlay stopped being queued or visible. */
|
||||
export type TuiOverlayCloseReason =
|
||||
| 'closed'
|
||||
| 'aborted'
|
||||
| 'owner-disposed'
|
||||
| 'tui-disposed'
|
||||
| 'error'
|
||||
|
||||
/** Settled overlay outcome; component failures retain their original value. */
|
||||
export type TuiOverlayOutcome =
|
||||
| { readonly reason: Exclude<TuiOverlayCloseReason, 'error'> }
|
||||
| { readonly reason: 'error'; readonly error: unknown }
|
||||
|
||||
/** Live state of an overlay operation. */
|
||||
export type TuiOverlayState = 'queued' | 'active' | 'closed'
|
||||
|
||||
/** Handle returned to the extension that opened an overlay. */
|
||||
export interface TuiOverlaySession {
|
||||
/** Current queue/display state. */
|
||||
readonly state: TuiOverlayState
|
||||
/** Settles exactly once after the overlay leaves the queue or display. */
|
||||
readonly closed: Promise<TuiOverlayOutcome>
|
||||
/**
|
||||
* Close the overlay normally and await its settled outcome.
|
||||
* @returns the same immutable value exposed through {@link closed}.
|
||||
*/
|
||||
close(): Promise<TuiOverlayOutcome>
|
||||
}
|
||||
@@ -31,13 +31,12 @@ import {
|
||||
type EditorTheme,
|
||||
type Focusable,
|
||||
type MarkdownTheme,
|
||||
type OverlayHandle,
|
||||
type SelectListTheme,
|
||||
type SlashCommand,
|
||||
type Terminal,
|
||||
type TerminalColorScheme,
|
||||
} from '@earendil-works/pi-tui'
|
||||
import type { Context } from 'cordis'
|
||||
import { Service, type Context, type Fiber } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import {
|
||||
installAgentLlmTarget,
|
||||
@@ -90,6 +89,62 @@ import {
|
||||
type AskUserQuestionItem,
|
||||
type AskUserQuestionRequest,
|
||||
} from '@deepseek-ai/dsh-user-interaction'
|
||||
import {
|
||||
TuiExtensionServiceImpl,
|
||||
TuiOverlayManager,
|
||||
} from './overlay-manager.ts'
|
||||
import type {
|
||||
TuiOverlayRequest,
|
||||
TuiOverlaySession,
|
||||
TuiTheme,
|
||||
} from './extension.ts'
|
||||
|
||||
export type {
|
||||
TuiComponent,
|
||||
TuiFocusable,
|
||||
TuiOverlayAnchor,
|
||||
TuiOverlayCloseReason,
|
||||
TuiOverlayHost,
|
||||
TuiOverlayMargin,
|
||||
TuiOverlayOptions,
|
||||
TuiOverlayOutcome,
|
||||
TuiOverlayRequest,
|
||||
TuiOverlaySession,
|
||||
TuiOverlayState,
|
||||
TuiTheme,
|
||||
TuiViewport,
|
||||
} from './extension.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** Terminal-only interaction service, available only while a TUI is mounted. */
|
||||
tui: TuiExtensionService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional terminal-local interaction service provided by one mounted TUI.
|
||||
*
|
||||
* The concrete provider retains pi-tui, focus, and terminal lifecycle state.
|
||||
* Plugins receive only effect-owned overlay sessions.
|
||||
*/
|
||||
export abstract class TuiExtensionService extends Service {
|
||||
/** Exact agent driven by this terminal instance. */
|
||||
abstract readonly agent: Agent
|
||||
|
||||
/**
|
||||
* Queue an interactive overlay owned by the calling plugin fiber.
|
||||
*
|
||||
* The TUI displays one overlay at a time in FIFO order. Disposing the caller
|
||||
* removes a queued overlay or closes an active one before plugin teardown
|
||||
* settles. This live presentation is neither logged nor replayed.
|
||||
*
|
||||
* @param request - component factory, layout constraints, and cancellation.
|
||||
* @returns the effect-owned overlay session.
|
||||
* @throws when the TUI has begun shutting down.
|
||||
*/
|
||||
abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession
|
||||
}
|
||||
|
||||
export const name = 'ui-tui'
|
||||
export const inject = ['agents', 'commands', 'userInteraction', 'tools', 'llm', 'systemPrompt', 'tokenMeter']
|
||||
@@ -1290,7 +1345,7 @@ interface PendingQuestion {
|
||||
resolve(answer: AskUserQuestionAnswer): void
|
||||
reject(error: unknown): void
|
||||
onAbort: () => void
|
||||
overlay: OverlayHandle | undefined
|
||||
overlay: TuiOverlaySession | undefined
|
||||
}
|
||||
|
||||
/** Add session candidates to pi-tui's existing command/file provider. */
|
||||
@@ -1511,7 +1566,8 @@ export function createTuiChat(
|
||||
const commandControllers = new Set<AbortController>()
|
||||
const referenceControllers = new Set<AbortController>()
|
||||
let activeQuestion: PendingQuestion | undefined
|
||||
let modelOverlay: OverlayHandle | undefined
|
||||
let modelOverlay: TuiOverlaySession | undefined
|
||||
let tuiServiceFiber: Fiber | undefined
|
||||
const target: AgentLlmTargetRef = { current: initialTarget(agent), assembled: undefined }
|
||||
let contextWindow: number | undefined
|
||||
let contextResolution: Promise<
|
||||
@@ -1569,6 +1625,41 @@ export function createTuiChat(
|
||||
requestRender()
|
||||
}
|
||||
|
||||
const extensionTheme: TuiTheme = Object.freeze({
|
||||
text: (value: string) => palette.text(value),
|
||||
muted: (value: string) => palette.muted(value),
|
||||
dim: (value: string) => palette.dim(value),
|
||||
accent: (value: string) => palette.accent(value),
|
||||
success: (value: string) => palette.success(value),
|
||||
warning: (value: string) => palette.warning(value),
|
||||
error: (value: string) => palette.error(value),
|
||||
bold: (value: string) => palette.bold(value),
|
||||
})
|
||||
const overlayManager = new TuiOverlayManager({
|
||||
viewport: () => Object.freeze({
|
||||
columns: runtime.terminal.columns,
|
||||
rows: runtime.terminal.rows,
|
||||
}),
|
||||
theme: () => extensionTheme,
|
||||
display: displayText,
|
||||
show: (component, options) => ui.showOverlay(component, options === undefined
|
||||
? undefined
|
||||
: {
|
||||
...options,
|
||||
...typeof options.margin === 'object'
|
||||
? { margin: { ...options.margin } }
|
||||
: {},
|
||||
}),
|
||||
invalidate: requestRender,
|
||||
reportError: (error) => {
|
||||
const message = errorChain(error)
|
||||
ctx.logger.warn(`ui-tui: overlay failed: ${message}`)
|
||||
/* v8 ignore next -- shutdown removes overlays before the terminal stops */
|
||||
if (disposed) return
|
||||
appendNotice(`TUI overlay failed: ${message}`, 'error')
|
||||
},
|
||||
})
|
||||
|
||||
const disposeTargetListeners = installAgentLlmTarget(agent.ctx, target)
|
||||
|
||||
const resolveContextWindow = (selected: AgentLlmTarget | undefined): void => {
|
||||
@@ -1608,29 +1699,29 @@ export function createTuiChat(
|
||||
appendNotice(`Current model: ${current}\nNo models are advertised by registered providers.`, 'warning')
|
||||
return
|
||||
}
|
||||
modelOverlay?.hide()
|
||||
modelOverlay = undefined
|
||||
const close = (): void => {
|
||||
modelOverlay?.hide()
|
||||
modelOverlay = undefined
|
||||
requestRender()
|
||||
}
|
||||
const dialog = new ModelDialog(
|
||||
choices,
|
||||
target.current,
|
||||
resolved.maxModelOptions,
|
||||
palette,
|
||||
(selected) => {
|
||||
close()
|
||||
selectModel(selected)
|
||||
void modelOverlay?.close()
|
||||
const session = overlayManager.open({
|
||||
create: () => new ModelDialog(
|
||||
choices,
|
||||
target.current,
|
||||
resolved.maxModelOptions,
|
||||
palette,
|
||||
(selected) => {
|
||||
void session.close()
|
||||
selectModel(selected)
|
||||
},
|
||||
() => { void session.close() },
|
||||
),
|
||||
options: {
|
||||
width: resolved.modelDialogWidth,
|
||||
maxHeight: resolved.modelDialogMaxHeight,
|
||||
anchor: 'center',
|
||||
margin: 1,
|
||||
},
|
||||
close,
|
||||
)
|
||||
modelOverlay = ui.showOverlay(dialog, {
|
||||
width: resolved.modelDialogWidth,
|
||||
maxHeight: resolved.modelDialogMaxHeight,
|
||||
anchor: 'center',
|
||||
margin: 1,
|
||||
})
|
||||
modelOverlay = session
|
||||
void session.closed.then(() => {
|
||||
if (modelOverlay === session) modelOverlay = undefined
|
||||
})
|
||||
requestRender()
|
||||
}
|
||||
@@ -1933,7 +2024,7 @@ export function createTuiChat(
|
||||
}
|
||||
|
||||
const rejectQuestion = (pending: PendingQuestion): void => {
|
||||
pending.overlay?.hide()
|
||||
void pending.overlay?.close()
|
||||
pending.overlay = undefined
|
||||
removeAbortListener(pending)
|
||||
pending.reject(new UserInteractionError(
|
||||
@@ -1956,31 +2047,48 @@ export function createTuiChat(
|
||||
startNextQuestion()
|
||||
return
|
||||
}
|
||||
const dialog = new QuestionDialog(
|
||||
question,
|
||||
pending.index + 1,
|
||||
pending.request.questions.length,
|
||||
pending.request.questions.length - pending.answers.length,
|
||||
resolved.maxQuestionOptions,
|
||||
palette,
|
||||
(selection) => {
|
||||
pending.overlay?.hide()
|
||||
pending.overlay = undefined
|
||||
pending.answers.push({ id: question.id, ...selection })
|
||||
pending.index += 1
|
||||
show()
|
||||
const session = overlayManager.open({
|
||||
...pending.request.signal === undefined ? {} : { signal: pending.request.signal },
|
||||
create: () => new QuestionDialog(
|
||||
question,
|
||||
pending.index + 1,
|
||||
pending.request.questions.length,
|
||||
pending.request.questions.length - pending.answers.length,
|
||||
resolved.maxQuestionOptions,
|
||||
palette,
|
||||
(selection) => {
|
||||
pending.overlay = undefined
|
||||
void session.close()
|
||||
pending.answers.push({ id: question.id, ...selection })
|
||||
pending.index += 1
|
||||
show()
|
||||
},
|
||||
() => {
|
||||
activeQuestion = undefined
|
||||
rejectQuestion(pending)
|
||||
startNextQuestion()
|
||||
},
|
||||
),
|
||||
options: {
|
||||
width: resolved.questionDialogWidth,
|
||||
maxHeight: resolved.questionDialogMaxHeight,
|
||||
anchor: 'bottom-left',
|
||||
margin: { bottom: 1 },
|
||||
},
|
||||
() => {
|
||||
activeQuestion = undefined
|
||||
rejectQuestion(pending)
|
||||
startNextQuestion()
|
||||
},
|
||||
)
|
||||
pending.overlay = ui.showOverlay(dialog, {
|
||||
width: resolved.questionDialogWidth,
|
||||
maxHeight: resolved.questionDialogMaxHeight,
|
||||
anchor: 'bottom-left',
|
||||
margin: { bottom: 1 },
|
||||
})
|
||||
pending.overlay = session
|
||||
void session.closed.then((result) => {
|
||||
if (pending.overlay !== session) return
|
||||
pending.overlay = undefined
|
||||
/* v8 ignore next 2 -- close, abort, and shutdown settle the owner before this callback */
|
||||
if (result.reason !== 'error') return
|
||||
activeQuestion = undefined
|
||||
removeAbortListener(pending)
|
||||
pending.reject(new UserInteractionError(
|
||||
`ask_user_question TUI failed: ${errorChain(result.error)}`,
|
||||
'ASK_ABORTED',
|
||||
))
|
||||
startNextQuestion()
|
||||
})
|
||||
requestRender()
|
||||
}
|
||||
@@ -2051,20 +2159,23 @@ export function createTuiChat(
|
||||
const shutdown = (exitProcess: boolean): Promise<void> => {
|
||||
shuttingDown ??= (async () => {
|
||||
disposed = true
|
||||
overlayManager.beginShutdown()
|
||||
contextResolution = undefined
|
||||
clearStatus()
|
||||
modelOverlay?.hide()
|
||||
modelOverlay = undefined
|
||||
for (const controller of commandControllers) controller.abort(new Error('TUI disposed'))
|
||||
commandControllers.clear()
|
||||
for (const controller of referenceControllers) controller.abort(new Error('TUI disposed'))
|
||||
referenceControllers.clear()
|
||||
await tuiServiceFiber?.dispose()
|
||||
tuiServiceFiber = undefined
|
||||
if (activeQuestion !== undefined) {
|
||||
const pending = activeQuestion
|
||||
activeQuestion = undefined
|
||||
rejectQuestion(pending)
|
||||
}
|
||||
for (const pending of questionQueue.splice(0)) rejectQuestion(pending)
|
||||
await overlayManager.dispose()
|
||||
modelOverlay = undefined
|
||||
disposeUserInteraction()
|
||||
await runtime.terminal.drainInput(100, 20)
|
||||
ui.stop()
|
||||
@@ -2510,7 +2621,7 @@ export function createTuiChat(
|
||||
}
|
||||
|
||||
const removeInputListener = ui.addInputListener((data) => {
|
||||
if (activeQuestion !== undefined || modelOverlay !== undefined) return undefined
|
||||
if (overlayManager.hasActiveOverlay()) return undefined
|
||||
if (matchesKey(data, Key.ctrl('o'))) {
|
||||
toggleTools()
|
||||
return { consume: true }
|
||||
@@ -2654,6 +2765,9 @@ export function createTuiChat(
|
||||
ui.stop()
|
||||
throw error
|
||||
}
|
||||
tuiServiceFiber = ctx.inject([], (serviceCtx) => {
|
||||
new TuiExtensionServiceImpl(serviceCtx, agent, overlayManager)
|
||||
})
|
||||
startBannerReveal()
|
||||
|
||||
return {
|
||||
|
||||
369
packages/ui/tui/src/overlay-manager.ts
Normal file
369
packages/ui/tui/src/overlay-manager.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* Private bridge between the public TUI extension contract and pi-tui.
|
||||
*
|
||||
* The manager serializes modal ownership, guards extension callbacks, and
|
||||
* settles every queued or active operation before terminal teardown.
|
||||
* @module @deepseek-ai/dsh-tui/overlay-manager
|
||||
*/
|
||||
|
||||
import { Service, type Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { TuiExtensionService } from './index.ts'
|
||||
import type {
|
||||
Component,
|
||||
Focusable,
|
||||
OverlayHandle,
|
||||
} from '@earendil-works/pi-tui'
|
||||
import type {
|
||||
TuiComponent,
|
||||
TuiFocusable,
|
||||
TuiOverlayCloseReason,
|
||||
TuiOverlayHost,
|
||||
TuiOverlayOutcome,
|
||||
TuiOverlayOptions,
|
||||
TuiOverlayRequest,
|
||||
TuiOverlaySession,
|
||||
TuiOverlayState,
|
||||
TuiTheme,
|
||||
TuiViewport,
|
||||
} from './extension.ts'
|
||||
|
||||
/** pi-tui operations retained by the front door instead of exposed to plugins. */
|
||||
export interface TuiOverlayDriver {
|
||||
/** Current terminal viewport. */
|
||||
viewport(): TuiViewport
|
||||
/** Current semantic theme facade. */
|
||||
theme(): TuiTheme
|
||||
/** Escape text at the terminal display boundary. */
|
||||
display(value: string): string
|
||||
/** Mount one guarded component and return its private pi-tui handle. */
|
||||
show(component: Component, options: TuiOverlayOptions | undefined): OverlayHandle
|
||||
/** Invalidate the mounted UI and request a render. */
|
||||
invalidate(): void
|
||||
/** Report a contained extension failure. */
|
||||
reportError(error: unknown): void
|
||||
}
|
||||
|
||||
interface OverlayEntry {
|
||||
readonly request: TuiOverlayRequest
|
||||
readonly controller: AbortController
|
||||
readonly signal: AbortSignal
|
||||
readonly closed: Promise<TuiOverlayOutcome>
|
||||
readonly resolveClosed: (outcome: TuiOverlayOutcome) => void
|
||||
readonly session: TuiOverlaySession
|
||||
state: TuiOverlayState
|
||||
component?: GuardedOverlayComponent
|
||||
handle?: OverlayHandle
|
||||
removeRequestAbort?: () => void
|
||||
outcome?: TuiOverlayOutcome
|
||||
failing?: boolean
|
||||
}
|
||||
|
||||
/** Turn a close reason into its immutable public outcome. */
|
||||
function outcome(reason: Exclude<TuiOverlayCloseReason, 'error'>): TuiOverlayOutcome {
|
||||
return Object.freeze({ reason })
|
||||
}
|
||||
|
||||
/** Retain only supported layout fields before a queued request returns to its caller. */
|
||||
function retainOptions(options: TuiOverlayOptions): TuiOverlayOptions {
|
||||
return Object.freeze({
|
||||
...options.width === undefined ? {} : { width: options.width },
|
||||
...options.minWidth === undefined ? {} : { minWidth: options.minWidth },
|
||||
...options.maxHeight === undefined ? {} : { maxHeight: options.maxHeight },
|
||||
...options.anchor === undefined ? {} : { anchor: options.anchor },
|
||||
...options.margin === undefined
|
||||
? {}
|
||||
: {
|
||||
margin: typeof options.margin === 'object'
|
||||
? Object.freeze({ ...options.margin })
|
||||
: options.margin,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Guard plugin component methods while preserving focus and key-release state. */
|
||||
class GuardedOverlayComponent implements Component, Focusable {
|
||||
constructor(
|
||||
private readonly component: TuiComponent & Partial<TuiFocusable>,
|
||||
private readonly fail: (error: unknown) => void,
|
||||
) {}
|
||||
|
||||
get focused(): boolean {
|
||||
try {
|
||||
return this.component.focused ?? false
|
||||
} catch (error) {
|
||||
this.fail(error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
set focused(value: boolean) {
|
||||
try {
|
||||
if ('focused' in this.component) this.component.focused = value
|
||||
} catch (error) {
|
||||
this.fail(error)
|
||||
}
|
||||
}
|
||||
|
||||
get wantsKeyRelease(): boolean {
|
||||
try {
|
||||
return this.component.wantsKeyRelease ?? false
|
||||
} catch (error) {
|
||||
this.fail(error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
try {
|
||||
return this.component.render(width)
|
||||
} catch (error) {
|
||||
this.fail(error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
try {
|
||||
this.component.handleInput?.(data)
|
||||
} catch (error) {
|
||||
this.fail(error)
|
||||
}
|
||||
}
|
||||
|
||||
invalidate(): boolean {
|
||||
try {
|
||||
this.component.invalidate()
|
||||
return true
|
||||
} catch (error) {
|
||||
this.fail(error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** FIFO modal owner for one mounted TUI. */
|
||||
export class TuiOverlayManager {
|
||||
private readonly queue: OverlayEntry[] = []
|
||||
private active: OverlayEntry | undefined
|
||||
private accepting = true
|
||||
private disposeTask: Promise<void> | undefined
|
||||
|
||||
constructor(private readonly driver: TuiOverlayDriver) {}
|
||||
|
||||
/**
|
||||
* Whether one extension or built-in overlay currently owns terminal focus.
|
||||
* @returns `true` while an overlay is active.
|
||||
*/
|
||||
hasActiveOverlay(): boolean {
|
||||
return this.active !== undefined
|
||||
}
|
||||
|
||||
/** Reject new work while the TUI unloads dependent extension fibers. */
|
||||
beginShutdown(): void {
|
||||
this.accepting = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue one overlay without assigning Cordis ownership.
|
||||
* @param request - component factory, constraints, and request signal.
|
||||
* @returns an internal session that can close with an ownership reason.
|
||||
*/
|
||||
open(request: TuiOverlayRequest): TuiOverlaySession & {
|
||||
closeWith(reason: Exclude<TuiOverlayCloseReason, 'error'>): Promise<TuiOverlayOutcome>
|
||||
} {
|
||||
if (!this.accepting) throw new Error('TUI is shutting down')
|
||||
const requestSignal = request.signal
|
||||
const retainedRequest: TuiOverlayRequest = Object.freeze({
|
||||
create: request.create,
|
||||
...request.options === undefined ? {} : { options: retainOptions(request.options) },
|
||||
...requestSignal === undefined ? {} : { signal: requestSignal },
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const signal = requestSignal === undefined
|
||||
? controller.signal
|
||||
: AbortSignal.any([requestSignal, controller.signal])
|
||||
const deferred = Promise.withResolvers<TuiOverlayOutcome>()
|
||||
const session: TuiOverlaySession & {
|
||||
closeWith(reason: Exclude<TuiOverlayCloseReason, 'error'>): Promise<TuiOverlayOutcome>
|
||||
} = {
|
||||
get state(): TuiOverlayState {
|
||||
return entry.state
|
||||
},
|
||||
closed: deferred.promise,
|
||||
close: () => this.close(entry, outcome('closed')),
|
||||
closeWith: (reason: Exclude<TuiOverlayCloseReason, 'error'>) =>
|
||||
this.close(entry, outcome(reason)),
|
||||
}
|
||||
const entry: OverlayEntry = {
|
||||
request: retainedRequest,
|
||||
controller,
|
||||
signal,
|
||||
closed: deferred.promise,
|
||||
resolveClosed: deferred.resolve,
|
||||
session,
|
||||
state: 'queued',
|
||||
}
|
||||
if (requestSignal?.aborted === true) {
|
||||
void this.close(entry, outcome('aborted'))
|
||||
return session
|
||||
}
|
||||
if (requestSignal !== undefined) {
|
||||
const onAbort = (): void => { void this.close(entry, outcome('aborted')) }
|
||||
requestSignal.addEventListener('abort', onAbort, { once: true })
|
||||
entry.removeRequestAbort = () => { requestSignal.removeEventListener('abort', onAbort) }
|
||||
}
|
||||
this.queue.push(entry)
|
||||
this.activateNext()
|
||||
return session
|
||||
}
|
||||
|
||||
/** Stop accepting work and settle every active or queued overlay. */
|
||||
dispose(): Promise<void> {
|
||||
if (this.disposeTask !== undefined) return this.disposeTask
|
||||
this.beginShutdown()
|
||||
const entries = [
|
||||
...this.active === undefined ? [] : [this.active],
|
||||
...this.queue,
|
||||
]
|
||||
return this.disposeTask = Promise.all(
|
||||
entries.map(entry => this.close(entry, outcome('tui-disposed'))),
|
||||
).then(() => {})
|
||||
}
|
||||
|
||||
private activateNext(): void {
|
||||
if (!this.accepting || this.active !== undefined) return
|
||||
const entry = this.queue.shift()
|
||||
if (entry === undefined) return
|
||||
this.active = entry
|
||||
entry.state = 'active'
|
||||
const host = this.host(entry)
|
||||
let component: TuiComponent & Partial<TuiFocusable>
|
||||
try {
|
||||
component = entry.request.create(host)
|
||||
} catch (error) {
|
||||
this.fail(entry, error)
|
||||
return
|
||||
}
|
||||
if (this.active !== entry) return
|
||||
const guarded = new GuardedOverlayComponent(component, (error) => {
|
||||
this.fail(entry, error)
|
||||
})
|
||||
entry.component = guarded
|
||||
try {
|
||||
const handle = this.driver.show(guarded, entry.request.options)
|
||||
if (this.active !== entry) {
|
||||
this.hide(handle)
|
||||
return
|
||||
}
|
||||
entry.handle = handle
|
||||
this.driver.invalidate()
|
||||
} catch (error) {
|
||||
this.fail(entry, error)
|
||||
}
|
||||
}
|
||||
|
||||
private host(entry: OverlayEntry): TuiOverlayHost {
|
||||
const driver = this.driver
|
||||
return Object.freeze({
|
||||
get signal(): AbortSignal {
|
||||
return entry.signal
|
||||
},
|
||||
get viewport(): TuiViewport {
|
||||
return Object.freeze({ ...driver.viewport() })
|
||||
},
|
||||
get theme(): TuiTheme {
|
||||
return driver.theme()
|
||||
},
|
||||
display: (value: string) => this.driver.display(value),
|
||||
invalidate: () => {
|
||||
if (this.active !== entry || entry.component === undefined || entry.failing === true) return
|
||||
if (!entry.component.invalidate() || this.active !== entry) return
|
||||
try {
|
||||
this.driver.invalidate()
|
||||
} catch (error) {
|
||||
this.fail(entry, error)
|
||||
}
|
||||
},
|
||||
close: () => { void this.close(entry, outcome('closed')) },
|
||||
})
|
||||
}
|
||||
|
||||
private fail(entry: OverlayEntry, error: unknown): void {
|
||||
if (entry.state === 'closed' || entry.failing === true) return
|
||||
entry.failing = true
|
||||
this.report(error)
|
||||
queueMicrotask(() => {
|
||||
void this.close(entry, Object.freeze({ reason: 'error', error }))
|
||||
})
|
||||
}
|
||||
|
||||
private report(error: unknown): void {
|
||||
try {
|
||||
this.driver.reportError(error)
|
||||
} catch {
|
||||
// Error reporting is a containment boundary, never a second failure path.
|
||||
}
|
||||
}
|
||||
|
||||
private hide(handle: OverlayHandle): void {
|
||||
try {
|
||||
handle.hide()
|
||||
} catch (error) {
|
||||
this.report(error)
|
||||
}
|
||||
}
|
||||
|
||||
private close(entry: OverlayEntry, result: TuiOverlayOutcome): Promise<TuiOverlayOutcome> {
|
||||
if (entry.outcome !== undefined) return entry.closed
|
||||
entry.outcome = result
|
||||
entry.state = 'closed'
|
||||
entry.removeRequestAbort?.()
|
||||
delete entry.removeRequestAbort
|
||||
if (!entry.controller.signal.aborted) entry.controller.abort(result)
|
||||
const queuedIndex = this.queue.indexOf(entry)
|
||||
if (queuedIndex >= 0) this.queue.splice(queuedIndex, 1)
|
||||
if (this.active === entry) {
|
||||
this.active = undefined
|
||||
if (entry.handle !== undefined) this.hide(entry.handle)
|
||||
delete entry.handle
|
||||
}
|
||||
delete entry.component
|
||||
entry.resolveClosed(result)
|
||||
try {
|
||||
this.driver.invalidate()
|
||||
} catch (error) {
|
||||
this.report(error)
|
||||
}
|
||||
queueMicrotask(() => { this.activateNext() })
|
||||
return entry.closed
|
||||
}
|
||||
}
|
||||
|
||||
/** Cordis service whose method effects bind to the calling plugin fiber. */
|
||||
export class TuiExtensionServiceImpl extends Service implements TuiExtensionService {
|
||||
constructor(
|
||||
ctx: Context,
|
||||
readonly agent: Agent,
|
||||
private readonly overlays: TuiOverlayManager,
|
||||
) {
|
||||
super(ctx, 'tui')
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
openOverlay(request: TuiOverlayRequest): TuiOverlaySession {
|
||||
let operation: ReturnType<TuiOverlayManager['open']> | undefined
|
||||
const disposeOwner = this.ctx.effect(
|
||||
() => () => operation?.closeWith('owner-disposed'),
|
||||
'tui.openOverlay()',
|
||||
)
|
||||
try {
|
||||
operation = this.overlays.open(request)
|
||||
} catch (error) {
|
||||
void disposeOwner()
|
||||
throw error
|
||||
}
|
||||
void operation.closed.then(() => { void disposeOwner() })
|
||||
return operation
|
||||
}
|
||||
}
|
||||
587
packages/ui/tui/tests/extension.spec.ts
Normal file
587
packages/ui/tui/tests/extension.spec.ts
Normal file
@@ -0,0 +1,587 @@
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type {
|
||||
Component,
|
||||
OverlayHandle,
|
||||
} from '@earendil-works/pi-tui'
|
||||
import type {
|
||||
TuiComponent,
|
||||
TuiOverlayHost,
|
||||
TuiOverlayOptions,
|
||||
TuiOverlaySession,
|
||||
TuiTheme,
|
||||
} from '../src/extension.ts'
|
||||
import {
|
||||
TuiExtensionServiceImpl,
|
||||
TuiOverlayManager,
|
||||
type TuiOverlayDriver,
|
||||
} from '../src/overlay-manager.ts'
|
||||
|
||||
const theme: TuiTheme = Object.freeze({
|
||||
text: (value: string) => `text:${value}`,
|
||||
muted: (value: string) => `muted:${value}`,
|
||||
dim: (value: string) => `dim:${value}`,
|
||||
accent: (value: string) => `accent:${value}`,
|
||||
success: (value: string) => `success:${value}`,
|
||||
warning: (value: string) => `warning:${value}`,
|
||||
error: (value: string) => `error:${value}`,
|
||||
bold: (value: string) => `bold:${value}`,
|
||||
})
|
||||
|
||||
interface ShownOverlay {
|
||||
component: Component
|
||||
options: TuiOverlayOptions | undefined
|
||||
hidden: boolean
|
||||
focused: boolean
|
||||
}
|
||||
|
||||
interface DriverFixture {
|
||||
driver: TuiOverlayDriver
|
||||
shown: ShownOverlay[]
|
||||
errors: unknown[]
|
||||
invalidations: number
|
||||
showError?: unknown
|
||||
onShow?: (component: Component) => void
|
||||
}
|
||||
|
||||
function driverFixture(): DriverFixture {
|
||||
const fixture: DriverFixture = {
|
||||
shown: [],
|
||||
errors: [],
|
||||
invalidations: 0,
|
||||
driver: undefined as never,
|
||||
}
|
||||
fixture.driver = {
|
||||
viewport: () => ({ columns: 96, rows: 32 }),
|
||||
theme: () => theme,
|
||||
display: value => `safe:${value}`,
|
||||
show(component, options) {
|
||||
if (fixture.showError !== undefined) throw fixture.showError
|
||||
const shown: ShownOverlay = {
|
||||
component,
|
||||
options,
|
||||
hidden: false,
|
||||
focused: true,
|
||||
}
|
||||
fixture.shown.push(shown)
|
||||
const handle: OverlayHandle = {
|
||||
hide() {
|
||||
shown.hidden = true
|
||||
shown.focused = false
|
||||
},
|
||||
setHidden(hidden) {
|
||||
shown.hidden = hidden
|
||||
},
|
||||
isHidden: () => shown.hidden,
|
||||
focus() {
|
||||
shown.focused = true
|
||||
},
|
||||
unfocus() {
|
||||
shown.focused = false
|
||||
},
|
||||
isFocused: () => shown.focused,
|
||||
}
|
||||
fixture.onShow?.(component)
|
||||
return handle
|
||||
},
|
||||
invalidate() {
|
||||
fixture.invalidations += 1
|
||||
},
|
||||
reportError(error) {
|
||||
fixture.errors.push(error)
|
||||
},
|
||||
}
|
||||
return fixture
|
||||
}
|
||||
|
||||
function component(lines = ['overlay']): TuiComponent {
|
||||
return {
|
||||
render: () => lines,
|
||||
invalidate() {},
|
||||
}
|
||||
}
|
||||
|
||||
async function microtask(): Promise<void> {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
describe('TuiOverlayManager', () => {
|
||||
it('serializes overlays, exposes the constrained host, and settles normal close once', async () => {
|
||||
const fixture = driverFixture()
|
||||
const manager = new TuiOverlayManager(fixture.driver)
|
||||
let firstHost: TuiOverlayHost | undefined
|
||||
const firstComponent = {
|
||||
focused: false,
|
||||
wantsKeyRelease: true,
|
||||
inputs: [] as string[],
|
||||
invalidated: 0,
|
||||
render: (width: number) => [`first:${String(width)}`],
|
||||
handleInput(data: string) {
|
||||
this.inputs.push(data)
|
||||
},
|
||||
invalidate() {
|
||||
this.invalidated += 1
|
||||
},
|
||||
}
|
||||
const first = manager.open({
|
||||
create(host) {
|
||||
firstHost = host
|
||||
return firstComponent
|
||||
},
|
||||
options: { width: '75%', minWidth: 24, maxHeight: 20, anchor: 'center', margin: { bottom: 1 } },
|
||||
})
|
||||
const secondOptions: TuiOverlayOptions = { width: 40, margin: { bottom: 2 } }
|
||||
const second = manager.open({
|
||||
create: () => component(['second']),
|
||||
options: secondOptions,
|
||||
})
|
||||
;(secondOptions as { width: number }).width = 80
|
||||
;(secondOptions.margin as { bottom: number }).bottom = 4
|
||||
|
||||
expect(manager.hasActiveOverlay()).toBe(true)
|
||||
expect(first.state).toBe('active')
|
||||
expect(second.state).toBe('queued')
|
||||
expect(fixture.shown).toHaveLength(1)
|
||||
expect(fixture.shown[0]?.options).toEqual({
|
||||
width: '75%',
|
||||
minWidth: 24,
|
||||
maxHeight: 20,
|
||||
anchor: 'center',
|
||||
margin: { bottom: 1 },
|
||||
})
|
||||
expect(firstHost?.viewport).toEqual({ columns: 96, rows: 32 })
|
||||
expect(Object.isFrozen(firstHost?.viewport)).toBe(true)
|
||||
expect(firstHost?.theme.accent('x')).toBe('accent:x')
|
||||
expect(firstHost?.display('\u001b')).toBe('safe:\u001b')
|
||||
firstHost?.invalidate()
|
||||
expect(firstComponent.invalidated).toBe(1)
|
||||
expect(fixture.shown[0]?.component.render(40)).toEqual(['first:40'])
|
||||
fixture.shown[0]!.component.handleInput?.('x')
|
||||
fixture.shown[0]!.component.invalidate()
|
||||
expect(firstComponent.inputs).toEqual(['x'])
|
||||
expect(firstComponent.invalidated).toBe(2)
|
||||
expect(fixture.shown[0]?.component.wantsKeyRelease).toBe(true)
|
||||
;(fixture.shown[0]?.component as Component & { focused: boolean }).focused = true
|
||||
expect(firstComponent.focused).toBe(true)
|
||||
expect((fixture.shown[0]?.component as Component & { focused: boolean }).focused).toBe(true)
|
||||
|
||||
const firstOutcome = await first.close()
|
||||
expect(firstOutcome).toEqual({ reason: 'closed' })
|
||||
expect(await first.close()).toBe(firstOutcome)
|
||||
expect(firstHost?.signal.aborted).toBe(true)
|
||||
const beforeClosedInvalidation = fixture.invalidations
|
||||
firstHost?.invalidate()
|
||||
expect(fixture.invalidations).toBe(beforeClosedInvalidation)
|
||||
await microtask()
|
||||
|
||||
expect(first.state).toBe('closed')
|
||||
expect(second.state).toBe('active')
|
||||
expect(fixture.shown[0]?.hidden).toBe(true)
|
||||
expect(fixture.shown[1]?.options).toEqual({ width: 40, margin: { bottom: 2 } })
|
||||
expect(Object.isFrozen(fixture.shown[1]?.options)).toBe(true)
|
||||
expect(Object.isFrozen(fixture.shown[1]?.options?.margin)).toBe(true)
|
||||
expect(fixture.shown[1]?.component.wantsKeyRelease).toBe(false)
|
||||
expect((fixture.shown[1]?.component as Component & { focused: boolean }).focused).toBe(false)
|
||||
;(fixture.shown[1]?.component as Component & { focused: boolean }).focused = true
|
||||
fixture.shown[1]!.component.handleInput?.('ignored')
|
||||
await second.close()
|
||||
await microtask()
|
||||
|
||||
const numericMargin = manager.open({
|
||||
create: () => component(['numeric margin']),
|
||||
options: { margin: 1 },
|
||||
})
|
||||
expect(fixture.shown[2]?.options).toEqual({ margin: 1 })
|
||||
await numericMargin.close()
|
||||
await microtask()
|
||||
|
||||
const emptyOptions = manager.open({
|
||||
create: () => component(['empty options']),
|
||||
options: {},
|
||||
})
|
||||
expect(fixture.shown[3]?.options).toEqual({})
|
||||
await emptyOptions.close()
|
||||
await microtask()
|
||||
|
||||
expect(manager.hasActiveOverlay()).toBe(false)
|
||||
await manager.dispose()
|
||||
await manager.dispose()
|
||||
})
|
||||
|
||||
it('removes pre-aborted, active, and queued requests without activating cancelled work', async () => {
|
||||
const fixture = driverFixture()
|
||||
const manager = new TuiOverlayManager(fixture.driver)
|
||||
const preAborted = new AbortController()
|
||||
preAborted.abort()
|
||||
const pre = manager.open({
|
||||
signal: preAborted.signal,
|
||||
create: () => component(['never']),
|
||||
})
|
||||
expect(await pre.closed).toEqual({ reason: 'aborted' })
|
||||
expect(fixture.shown).toHaveLength(0)
|
||||
|
||||
const activeAbort = new AbortController()
|
||||
let activeHost: TuiOverlayHost | undefined
|
||||
const active = manager.open({
|
||||
signal: activeAbort.signal,
|
||||
create(host) {
|
||||
activeHost = host
|
||||
return component(['active'])
|
||||
},
|
||||
})
|
||||
const queuedAbort = new AbortController()
|
||||
const queued = manager.open({
|
||||
signal: queuedAbort.signal,
|
||||
create: () => component(['queued']),
|
||||
})
|
||||
queuedAbort.abort()
|
||||
expect(await queued.closed).toEqual({ reason: 'aborted' })
|
||||
expect(queued.state).toBe('closed')
|
||||
activeAbort.abort()
|
||||
expect(await active.closed).toEqual({ reason: 'aborted' })
|
||||
expect(activeHost?.signal.aborted).toBe(true)
|
||||
await microtask()
|
||||
expect(fixture.shown).toHaveLength(1)
|
||||
expect(manager.hasActiveOverlay()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not mount entries closed or aborted during component construction', async () => {
|
||||
const fixture = driverFixture()
|
||||
const manager = new TuiOverlayManager(fixture.driver)
|
||||
const closed = manager.open({
|
||||
create(host) {
|
||||
host.invalidate()
|
||||
host.close()
|
||||
return component(['closed during construction'])
|
||||
},
|
||||
})
|
||||
await expect(closed.closed).resolves.toEqual({ reason: 'closed' })
|
||||
|
||||
const controller = new AbortController()
|
||||
const aborted = manager.open({
|
||||
signal: controller.signal,
|
||||
create() {
|
||||
controller.abort()
|
||||
return component(['aborted during construction'])
|
||||
},
|
||||
})
|
||||
await expect(aborted.closed).resolves.toEqual({ reason: 'aborted' })
|
||||
|
||||
const after = manager.open({ create: () => component(['after construction closes']) })
|
||||
expect(fixture.shown).toHaveLength(1)
|
||||
expect(fixture.shown[0]?.component.render(40)).toEqual(['after construction closes'])
|
||||
await after.close()
|
||||
})
|
||||
|
||||
it('hides a handle returned after reentrant closure during mounting', async () => {
|
||||
const fixture = driverFixture()
|
||||
const manager = new TuiOverlayManager(fixture.driver)
|
||||
fixture.onShow = (shown) => {
|
||||
;(shown as Component & { focused: boolean }).focused = true
|
||||
}
|
||||
const closed = manager.open({
|
||||
create(host) {
|
||||
return {
|
||||
get focused(): boolean {
|
||||
return false
|
||||
},
|
||||
set focused(_value: boolean) {
|
||||
host.close()
|
||||
},
|
||||
render: () => ['closed during mount'],
|
||||
invalidate() {},
|
||||
}
|
||||
},
|
||||
})
|
||||
await expect(closed.closed).resolves.toEqual({ reason: 'closed' })
|
||||
expect(fixture.shown[0]?.hidden).toBe(true)
|
||||
expect(manager.hasActiveOverlay()).toBe(false)
|
||||
|
||||
delete fixture.onShow
|
||||
const after = manager.open({ create: () => component(['after mount close']) })
|
||||
expect(fixture.shown[1]?.hidden).toBe(false)
|
||||
expect(fixture.shown[1]?.component.render(40)).toEqual(['after mount close'])
|
||||
await after.close()
|
||||
})
|
||||
|
||||
it('stops admission and disposes active and queued overlays with the TUI', async () => {
|
||||
const fixture = driverFixture()
|
||||
const manager = new TuiOverlayManager(fixture.driver)
|
||||
const active = manager.open({ create: () => component(['active']) })
|
||||
const queued = manager.open({ create: () => component(['queued']) })
|
||||
manager.beginShutdown()
|
||||
expect(() => manager.open({ create: () => component() })).toThrow('TUI is shutting down')
|
||||
await manager.dispose()
|
||||
expect(await active.closed).toEqual({ reason: 'tui-disposed' })
|
||||
expect(await queued.closed).toEqual({ reason: 'tui-disposed' })
|
||||
expect(fixture.shown).toHaveLength(1)
|
||||
expect(fixture.shown[0]?.hidden).toBe(true)
|
||||
await manager.dispose()
|
||||
})
|
||||
|
||||
it('contains factory, mount, render, input, and invalidation failures', async () => {
|
||||
const fixture = driverFixture()
|
||||
const manager = new TuiOverlayManager(fixture.driver)
|
||||
const factoryError = new Error('factory failed')
|
||||
const factory = manager.open({
|
||||
create() {
|
||||
throw factoryError
|
||||
},
|
||||
})
|
||||
const afterFactory = manager.open({ create: () => component(['after factory']) })
|
||||
expect(await factory.closed).toEqual({ reason: 'error', error: factoryError })
|
||||
await microtask()
|
||||
expect(afterFactory.state).toBe('active')
|
||||
await afterFactory.close()
|
||||
await microtask()
|
||||
|
||||
const showError = new Error('show failed')
|
||||
fixture.showError = showError
|
||||
const show = manager.open({ create: () => component(['show']) })
|
||||
expect(await show.closed).toEqual({ reason: 'error', error: showError })
|
||||
delete fixture.showError
|
||||
await microtask()
|
||||
|
||||
const renderError = new Error('render failed')
|
||||
const rendering = manager.open({
|
||||
create: () => ({
|
||||
render() {
|
||||
throw renderError
|
||||
},
|
||||
invalidate() {
|
||||
throw new Error('must be suppressed after the first failure')
|
||||
},
|
||||
}),
|
||||
})
|
||||
const renderComponent = fixture.shown.at(-1)!.component
|
||||
expect(renderComponent.render(20)).toEqual([])
|
||||
renderComponent.invalidate()
|
||||
expect(fixture.errors.filter(error => error === renderError)).toHaveLength(1)
|
||||
expect(await rendering.closed).toEqual({ reason: 'error', error: renderError })
|
||||
await microtask()
|
||||
|
||||
const inputError = new Error('input failed')
|
||||
const input = manager.open({
|
||||
create: () => ({
|
||||
render: () => ['input'],
|
||||
handleInput() {
|
||||
throw inputError
|
||||
},
|
||||
invalidate() {},
|
||||
}),
|
||||
})
|
||||
fixture.shown.at(-1)!.component.handleInput?.('x')
|
||||
expect(await input.closed).toEqual({ reason: 'error', error: inputError })
|
||||
await microtask()
|
||||
|
||||
const invalidateError = new Error('invalidate failed')
|
||||
let invalidatingHost: TuiOverlayHost | undefined
|
||||
const invalidating = manager.open({
|
||||
create(host) {
|
||||
invalidatingHost = host
|
||||
return {
|
||||
render: () => ['invalidate'],
|
||||
invalidate() {
|
||||
throw invalidateError
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
const invalidationsBeforeFailure = fixture.invalidations
|
||||
invalidatingHost?.invalidate()
|
||||
invalidatingHost?.invalidate()
|
||||
expect(fixture.invalidations).toBe(invalidationsBeforeFailure)
|
||||
expect(await invalidating.closed).toEqual({ reason: 'error', error: invalidateError })
|
||||
await microtask()
|
||||
|
||||
const focusError = new Error('focus failed')
|
||||
const focus = manager.open({
|
||||
create: () => ({
|
||||
get focused(): boolean {
|
||||
throw focusError
|
||||
},
|
||||
set focused(_value: boolean) {
|
||||
throw new Error('focus assignment failed')
|
||||
},
|
||||
get wantsKeyRelease(): boolean {
|
||||
throw new Error('key-release query failed')
|
||||
},
|
||||
render: () => ['focus'],
|
||||
invalidate() {},
|
||||
}),
|
||||
})
|
||||
const guarded = fixture.shown.at(-1)!.component as Component & { focused: boolean }
|
||||
expect(guarded.focused).toBe(false)
|
||||
guarded.focused = true
|
||||
expect(guarded.wantsKeyRelease).toBe(false)
|
||||
expect(await focus.closed).toEqual({ reason: 'error', error: focusError })
|
||||
expect(fixture.errors).toEqual([
|
||||
factoryError,
|
||||
showError,
|
||||
renderError,
|
||||
inputError,
|
||||
invalidateError,
|
||||
focusError,
|
||||
])
|
||||
})
|
||||
|
||||
it('contains host redraw, overlay removal, and error-reporter failures', async () => {
|
||||
const fixture = driverFixture()
|
||||
const manager = new TuiOverlayManager(fixture.driver)
|
||||
let host: TuiOverlayHost | undefined
|
||||
const invalidationError = new Error('redraw failed')
|
||||
let redrawFails = false
|
||||
fixture.driver.invalidate = () => {
|
||||
if (redrawFails) throw invalidationError
|
||||
}
|
||||
fixture.driver.reportError = () => { throw new Error('report failed') }
|
||||
const invalidating = manager.open({
|
||||
create(value) {
|
||||
host = value
|
||||
return component()
|
||||
},
|
||||
})
|
||||
redrawFails = true
|
||||
host?.invalidate()
|
||||
expect(await invalidating.closed).toEqual({ reason: 'error', error: invalidationError })
|
||||
await microtask()
|
||||
|
||||
redrawFails = false
|
||||
fixture.driver.invalidate = () => {}
|
||||
const hideError = new Error('hide failed')
|
||||
fixture.driver.show = () => ({
|
||||
hide() { throw hideError },
|
||||
setHidden() {},
|
||||
isHidden: () => false,
|
||||
focus() {},
|
||||
unfocus() {},
|
||||
isFocused: () => true,
|
||||
})
|
||||
const hiding = manager.open({
|
||||
create(value) {
|
||||
host = value
|
||||
return component()
|
||||
},
|
||||
})
|
||||
host?.close()
|
||||
expect(await hiding.closed).toEqual({ reason: 'closed' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('TuiExtensionService', () => {
|
||||
it('binds an open overlay to the calling plugin fiber', async () => {
|
||||
const ctx = new Context()
|
||||
const fixture = driverFixture()
|
||||
const manager = new TuiOverlayManager(fixture.driver)
|
||||
const agent = {} as Agent
|
||||
const provider = ctx.plugin((providerCtx) => {
|
||||
new TuiExtensionServiceImpl(providerCtx, agent, manager)
|
||||
})
|
||||
await provider
|
||||
let session: TuiOverlaySession | undefined
|
||||
let host: TuiOverlayHost | undefined
|
||||
const consumer = ctx.inject(['tui'], (consumerCtx) => {
|
||||
expect(consumerCtx.tui.agent).toBe(agent)
|
||||
session = consumerCtx.tui.openOverlay({
|
||||
create(value) {
|
||||
host = value
|
||||
return component(['plugin'])
|
||||
},
|
||||
})
|
||||
})
|
||||
await consumer
|
||||
expect(session?.state).toBe('active')
|
||||
|
||||
await consumer.dispose()
|
||||
expect(await session?.closed).toEqual({ reason: 'owner-disposed' })
|
||||
expect(host?.signal.aborted).toBe(true)
|
||||
await provider.dispose()
|
||||
await manager.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('unloads and reloads dependent plugins with the mounted TUI service', async () => {
|
||||
const ctx = new Context()
|
||||
const agent = {} as Agent
|
||||
const sessions: TuiOverlaySession[] = []
|
||||
let starts = 0
|
||||
const consumer = ctx.inject(['tui'], (consumerCtx) => {
|
||||
starts += 1
|
||||
sessions.push(consumerCtx.tui.openOverlay({ create: () => component([`start:${String(starts)}`]) }))
|
||||
})
|
||||
|
||||
const firstFixture = driverFixture()
|
||||
const firstManager = new TuiOverlayManager(firstFixture.driver)
|
||||
const firstProvider = ctx.plugin((providerCtx) => {
|
||||
new TuiExtensionServiceImpl(providerCtx, agent, firstManager)
|
||||
})
|
||||
await firstProvider
|
||||
await consumer
|
||||
expect(starts).toBe(1)
|
||||
await firstProvider.dispose()
|
||||
expect(await sessions[0]?.closed).toEqual({ reason: 'owner-disposed' })
|
||||
|
||||
const secondFixture = driverFixture()
|
||||
const secondManager = new TuiOverlayManager(secondFixture.driver)
|
||||
const secondProvider = ctx.plugin((providerCtx) => {
|
||||
new TuiExtensionServiceImpl(providerCtx, agent, secondManager)
|
||||
})
|
||||
await secondProvider
|
||||
await vi.waitFor(() => { expect(starts).toBe(2) })
|
||||
await sessions[1]?.close()
|
||||
await consumer.dispose()
|
||||
await secondProvider.dispose()
|
||||
await firstManager.dispose()
|
||||
await secondManager.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects new service work after terminal shutdown begins', async () => {
|
||||
const ctx = new Context()
|
||||
const fixture = driverFixture()
|
||||
const manager = new TuiOverlayManager(fixture.driver)
|
||||
const provider = ctx.plugin((providerCtx) => {
|
||||
new TuiExtensionServiceImpl(providerCtx, {} as Agent, manager)
|
||||
})
|
||||
await provider
|
||||
manager.beginShutdown()
|
||||
const consumer = ctx.inject(['tui'], (consumerCtx) => {
|
||||
expect(() => consumerCtx.tui.openOverlay({ create: () => component() }))
|
||||
.toThrow('TUI is shutting down')
|
||||
})
|
||||
await consumer
|
||||
await consumer.dispose()
|
||||
await provider.dispose()
|
||||
await manager.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('does not admit an overlay when called from an unloading plugin', async () => {
|
||||
const ctx = new Context()
|
||||
const fixture = driverFixture()
|
||||
const manager = new TuiOverlayManager(fixture.driver)
|
||||
const provider = ctx.plugin((providerCtx) => {
|
||||
new TuiExtensionServiceImpl(providerCtx, {} as Agent, manager)
|
||||
})
|
||||
await provider
|
||||
let error: unknown
|
||||
const consumer = ctx.inject(['tui'], (consumerCtx) => {
|
||||
consumerCtx.effect(() => () => {
|
||||
try {
|
||||
consumerCtx.tui.openOverlay({ create: () => component() })
|
||||
} catch (value) {
|
||||
error = value
|
||||
}
|
||||
})
|
||||
})
|
||||
await consumer
|
||||
await consumer.dispose()
|
||||
expect(error).toMatchObject({ code: 'INACTIVE_EFFECT' })
|
||||
expect(fixture.shown).toHaveLength(0)
|
||||
await provider.dispose()
|
||||
await manager.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
mountTui,
|
||||
renderSkillInvocation,
|
||||
resolveTuiConfig,
|
||||
type TuiOverlayHost,
|
||||
type TuiOverlaySession,
|
||||
type TuiRuntime,
|
||||
} from '../src/index.ts'
|
||||
import {
|
||||
@@ -1379,6 +1381,15 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
expect(result.terminal.output).toContain('advertised by multiple providers')
|
||||
expect(result.terminal.output).toContain('already alpha/a1')
|
||||
|
||||
result.terminal.send('/model')
|
||||
result.terminal.send('\r')
|
||||
result.terminal.send('/model')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('Select model')
|
||||
result.terminal.send('\x1b')
|
||||
await tick()
|
||||
|
||||
result.agent.status = 'running'
|
||||
result.terminal.send('/model')
|
||||
result.terminal.send('\r')
|
||||
@@ -2193,6 +2204,141 @@ describe('TUI user-interaction dialogs', () => {
|
||||
.rejects.toMatchObject({ code: 'NO_PROVIDER' })
|
||||
await result.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects malformed questions when a dialog cannot be constructed', async () => {
|
||||
const result = await setup()
|
||||
const broken = {
|
||||
id: 'broken',
|
||||
question: 'Broken question',
|
||||
get options(): never {
|
||||
throw new Error('question setup failed')
|
||||
},
|
||||
}
|
||||
const answer = result.ctx.userInteraction.ask({ questions: [broken] })
|
||||
await expect(answer).rejects.toThrow('ask_user_question TUI failed: question setup failed')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('TUI overlay failed: question setup failed')
|
||||
await dispose(result)
|
||||
})
|
||||
})
|
||||
|
||||
describe('TUI extension service', () => {
|
||||
it('renders effect-owned plugin overlays in the shared FIFO and restores editor input', async () => {
|
||||
const result = await setup()
|
||||
const sessions: TuiOverlaySession[] = []
|
||||
const hosts: TuiOverlayHost[] = []
|
||||
const plugin = result.ctx.inject(['tui'], (pluginCtx) => {
|
||||
expect(pluginCtx.tui.agent).toBe(result.agent)
|
||||
for (const label of ['first', 'second']) {
|
||||
sessions.push(pluginCtx.tui.openOverlay({
|
||||
create(host) {
|
||||
hosts.push(host)
|
||||
return {
|
||||
focused: false,
|
||||
render: width => [
|
||||
host.theme.accent(`${label} plugin overlay`),
|
||||
[
|
||||
host.theme.text('text'),
|
||||
host.theme.muted('muted'),
|
||||
host.theme.dim('dim'),
|
||||
host.theme.success('success'),
|
||||
host.theme.warning('warning'),
|
||||
host.theme.error('error'),
|
||||
host.theme.bold('bold'),
|
||||
].join(' '),
|
||||
`${String(host.viewport.columns)}x${String(host.viewport.rows)} · ${String(width)}`,
|
||||
],
|
||||
handleInput(data) {
|
||||
host.invalidate()
|
||||
if (data === label[0]) host.close()
|
||||
},
|
||||
invalidate() {},
|
||||
}
|
||||
},
|
||||
options: { width: 50, maxHeight: 8, anchor: 'center', margin: 1 },
|
||||
}))
|
||||
}
|
||||
})
|
||||
await plugin
|
||||
await vi.waitFor(() => {
|
||||
expect(result.terminal.output).toContain('first plugin overlay')
|
||||
})
|
||||
expect(sessions.map(session => session.state)).toEqual(['active', 'queued'])
|
||||
expect(hosts).toHaveLength(1)
|
||||
|
||||
const question = result.ctx.userInteraction.ask({
|
||||
questions: [{ id: 'after-plugin', question: 'Question after plugins?', options: [{ label: 'Yes' }] }],
|
||||
})
|
||||
result.terminal.send('f')
|
||||
await expect(sessions[0]!.closed).resolves.toEqual({ reason: 'closed' })
|
||||
await vi.waitFor(() => {
|
||||
expect(result.terminal.output).toContain('second plugin overlay')
|
||||
})
|
||||
expect(hosts).toHaveLength(2)
|
||||
expect(sessions[1]?.state).toBe('active')
|
||||
|
||||
result.terminal.send('s')
|
||||
await expect(sessions[1]!.closed).resolves.toEqual({ reason: 'closed' })
|
||||
await vi.waitFor(() => {
|
||||
expect(result.terminal.output).toContain('Question after plugins?')
|
||||
})
|
||||
result.terminal.send('\r')
|
||||
await expect(question).resolves.toEqual({
|
||||
answers: [{ id: 'after-plugin', selected: ['Yes'] }],
|
||||
})
|
||||
|
||||
result.terminal.send('editor works again')
|
||||
result.terminal.send('\r')
|
||||
expect(result.agent.sent.at(-1)).toEqual([{ type: 'text', text: 'editor works again' }])
|
||||
await plugin.dispose()
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('unloads and reloads dependent plugins with the mounted TUI', async () => {
|
||||
const result = await setup()
|
||||
const sessions: TuiOverlaySession[] = []
|
||||
const signals: AbortSignal[] = []
|
||||
let starts = 0
|
||||
const plugin = result.ctx.inject(['tui'], (pluginCtx) => {
|
||||
starts += 1
|
||||
sessions.push(pluginCtx.tui.openOverlay({
|
||||
create(host) {
|
||||
signals.push(host.signal)
|
||||
return {
|
||||
render: () => [`plugin mount ${String(starts)}`],
|
||||
invalidate() {},
|
||||
}
|
||||
},
|
||||
}))
|
||||
})
|
||||
await plugin
|
||||
await vi.waitFor(() => {
|
||||
expect(result.terminal.output).toContain('plugin mount 1')
|
||||
})
|
||||
|
||||
await result.controller.dispose()
|
||||
await expect(sessions[0]!.closed).resolves.toEqual({ reason: 'owner-disposed' })
|
||||
expect(signals[0]?.aborted).toBe(true)
|
||||
expect(result.ctx.get('tui')).toBeUndefined()
|
||||
|
||||
const secondTerminal = new FakeTerminal()
|
||||
const secondController = createTuiChat(result.ctx, {
|
||||
sessionId: result.agent.id,
|
||||
color: false,
|
||||
welcome: 'Mounted again.',
|
||||
}, {
|
||||
terminal: secondTerminal,
|
||||
exit: vi.fn(),
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
expect(starts).toBe(2)
|
||||
expect(secondTerminal.output).toContain('plugin mount 2')
|
||||
})
|
||||
await sessions[1]?.close()
|
||||
await secondController.dispose()
|
||||
await plugin.dispose()
|
||||
await result.ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal mounting', () => {
|
||||
@@ -2355,6 +2501,7 @@ describe('terminal mounting', () => {
|
||||
expect(ctx.commands.list(ctx.agents.get(SessionId('failed-start-session'))!)).toEqual([])
|
||||
expect(terminal.stopped).toBe(1)
|
||||
expect(terminal.progress).toEqual([false, true, false])
|
||||
expect(ctx.get('tui')).toBeUndefined()
|
||||
await expect(ctx.userInteraction.ask({ questions: [{ id: 'late', question: 'Late?' }] }))
|
||||
.rejects.toMatchObject({ code: 'NO_PROVIDER' })
|
||||
session.append('assistant/chunk', {
|
||||
|
||||
@@ -198,6 +198,8 @@ const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
|
||||
LocaleDict: 'service-local dictionary shape is owned by packages/client/i18n/src/index.ts',
|
||||
ThemeTokens: 'service-local token dictionary is owned by packages/client/ui-theme/src/index.ts',
|
||||
Translate: 'service-local bound translator is owned by packages/client/i18n/src/index.ts',
|
||||
TuiOverlayRequest: 'service-local extension contract is owned by packages/ui/tui/README.md',
|
||||
TuiOverlaySession: 'service-local extension contract is owned by packages/ui/tui/README.md',
|
||||
InvariantRegistration: 'service-local lifecycle handle is owned by packages/support/invariants/README.md',
|
||||
PresetOption: 'deployment menu metadata is owned by packages/ui/permission/README.md',
|
||||
PresetSpec: 'deployment preset composition is owned by packages/ui/permission/README.md',
|
||||
|
||||
@@ -197,6 +197,13 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
consumers: ['tui', 'acp'],
|
||||
note: 'Plugins register direct human commands; TUI and ACP consume the same effective per-agent catalog without sending invocations to the model.',
|
||||
},
|
||||
{
|
||||
key: 'tui',
|
||||
pkg: 'tui',
|
||||
title: 'Mounted-terminal interaction service',
|
||||
mode: 'bundle',
|
||||
note: 'One TUI front door provides a FIFO overlay host; injected plugins receive caller-fiber ownership without access to pi-tui or terminal lifecycle state.',
|
||||
},
|
||||
{
|
||||
key: 'skills',
|
||||
pkg: 'skill',
|
||||
|
||||
1
vendor/README.md
vendored
1
vendor/README.md
vendored
@@ -37,6 +37,7 @@ Keep this log exhaustive — every divergence from upstream must be listed.
|
||||
5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface.
|
||||
6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup.
|
||||
7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork.
|
||||
8. **`include/src/index.ts` hot-reload hardening**: `refresh()` awaits the full read-and-update and catches failures (logging a warning and keeping the last good entry tree) instead of rethrowing — upstream's throw escaped `@cordisjs/plugin-hmr`'s async watcher callback as an unhandled rejection, so one bad `cordis.yml` edit killed a live app. `read()` rejects a non-array parse result (an empty or mid-write truncated file parses to `undefined`, which upstream later crashed on) and commits `content`/`data` only on success, so reverting an edit to the exact last good content reads as "unchanged". `refresh()` and the `internal/update` listener re-apply `config.patches` before `root.update()`, matching initial load; upstream applied patches only in `[Service.init]`, so any config hot-reload silently reverted overlay-patched entries and removed inserted ones. `applyPatches` deep-copies via `structuredClone` instead of mutating the cached parse (repeated application converges; removing a patch reverts), and the veto-style `internal/update` listener persists the incoming config itself (`Fiber.update` only assigns behind `next()`), so later re-reads use the new patches. `[Service.init]` falls back to `initial` only on `ENOENT`; an existing-but-invalid file fails loud with its real parse error instead of "config file not found" (or a silent overwrite). Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`.
|
||||
|
||||
## Sync procedure
|
||||
|
||||
|
||||
63
vendor/include/src/index.ts
vendored
63
vendor/include/src/index.ts
vendored
@@ -77,7 +77,15 @@ export class Include extends EntryTree {
|
||||
|
||||
ctx.on('internal/update', (config, _, next) => {
|
||||
if (config.path !== this.config.path) return next()
|
||||
this.root.update(this.data!)
|
||||
// Veto the fiber restart (children update in place), but persist the new
|
||||
// config ourselves — `Fiber.update` only assigns `this.config` behind
|
||||
// `next()`, and a stale `this.config.patches` would make the next
|
||||
// `refresh()` re-apply the old overlay.
|
||||
this.config = config
|
||||
this.root.update(this.applyPatches(this.data!, config.patches)).catch((error) => {
|
||||
this.ctx.logger.warn('config update at %C failed', this.filename)
|
||||
this.ctx.logger.warn(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -93,22 +101,37 @@ export class Include extends EntryTree {
|
||||
private async read(forced = false) {
|
||||
const content = await readFile(this.filename, 'utf8')
|
||||
if (!forced && this.content === content) return false
|
||||
this.content = content
|
||||
let data: any
|
||||
if (this.type === 'application/yaml') {
|
||||
this.data = yaml.load(this.content, { schema }) as any
|
||||
data = yaml.load(content, { schema })
|
||||
} else if (this.type === 'application/json') {
|
||||
this.data = JSON.parse(this.content) as any
|
||||
data = JSON.parse(content)
|
||||
} else {
|
||||
const module = await import(/* @vite-ignore */ this.filename)
|
||||
this.data = module.default || module
|
||||
data = module.default || module
|
||||
}
|
||||
// An empty or truncated file (common mid-edit: editors and `sed -i` write
|
||||
// through temp states) parses to `undefined`, not an error; reject every
|
||||
// non-array shape here so callers see one "invalid file" signal. Content
|
||||
// and data commit only on success, so an edit that is later reverted to
|
||||
// the exact last good content correctly reads as "unchanged".
|
||||
if (!Array.isArray(data)) {
|
||||
throw new TypeError(`config file must be a top-level array of entries: ${this.filename}`)
|
||||
}
|
||||
this.content = content
|
||||
this.data = data
|
||||
await this.checkAccess()
|
||||
return true
|
||||
}
|
||||
|
||||
private applyPatches(data: EntryOptions[]): EntryOptions[] {
|
||||
const { patches } = this.config
|
||||
if (!patches?.length) return data
|
||||
private applyPatches(data: EntryOptions[], patches = this.config.patches): EntryOptions[] {
|
||||
// Always detach from the cached parse: patching shared entry objects would
|
||||
// bake earlier patch values into `this.data`, so repeated application
|
||||
// (config hot-reloads) could never revert a removed or changed patch. The
|
||||
// supported extensions guarantee JSON-safe plain data, so `structuredClone`
|
||||
// cannot throw here.
|
||||
if (!patches?.length) return [...data]
|
||||
data = structuredClone(data)
|
||||
|
||||
const entryMap = new Map<string, EntryOptions>()
|
||||
const buildMap = (entries: EntryOptions[]) => {
|
||||
@@ -174,7 +197,11 @@ export class Include extends EntryTree {
|
||||
async* [Service.init]() {
|
||||
try {
|
||||
await this.read()
|
||||
} catch {
|
||||
} catch (error) {
|
||||
// Only a missing file falls back to `initial` (or the not-found error):
|
||||
// an existing-but-invalid file must fail loud with its real parse error,
|
||||
// never be mislabelled as absent or silently overwritten.
|
||||
if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') throw error
|
||||
if (this.config.initial) {
|
||||
this.writeFile(this.config.initial as any)
|
||||
await this.read()
|
||||
@@ -184,18 +211,26 @@ export class Include extends EntryTree {
|
||||
}
|
||||
|
||||
yield () => this.stop()
|
||||
const data = this.applyPatches([...this.data!])
|
||||
await this.root.update(data)
|
||||
await this.root.update(this.applyPatches(this.data!))
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.root.stop()
|
||||
}
|
||||
|
||||
/** Re-read the file and refresh child entries when content changed. */
|
||||
/**
|
||||
* Re-read the file and refresh child entries when content changed. An
|
||||
* unreadable or unparsable file logs a warning and keeps the last good
|
||||
* tree: a hot-reload of a live app must never take the process down.
|
||||
*/
|
||||
async refresh() {
|
||||
if (!await this.read()) return
|
||||
this.root.update(this.data!)
|
||||
try {
|
||||
if (!await this.read()) return
|
||||
await this.root.update(this.applyPatches(this.data!))
|
||||
} catch (error) {
|
||||
this.ctx.logger.warn('config reload at %C failed; keeping the running tree', this.filename)
|
||||
this.ctx.logger.warn(error)
|
||||
}
|
||||
}
|
||||
|
||||
private async _writeFile(config: EntryOptions[]) {
|
||||
|
||||
Reference in New Issue
Block a user