# Agent Note: 由 effect 持有的 TUI 交互扩展

Status: implemented
Archived: 2026-08-04

[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 是唯一已交付的消费方。向 `ctx.commands` 添加终端状态，会让发现与分派流程耦合到某一种呈现实现。

**一次性建立完整的 TUI slot 与 action 框架。** action、编辑器替换、transcript 渲染器、状态区域和补全提供方具有不同的组合规则与冲突规则。在具体消费方验证这些规则之前就将其纳入一个宽泛 API，会过早固化这些规则。

**将打开的浮层持久化为会话事件。** 模态呈现并非模型可见的会话状态，任意组件状态也无法回放。拥有持久数据的插件应通过自身的领域服务记录这些数据，并在适当时重新创建呈现。

## 后果

交互式插件获得一个小而稳定的入口，具备确定性的焦点管理和由 Cordis 持有的清理机制；TUI 则继续掌控终端生命周期和 pi-tui 内部实现。内置对话框与扩展无法重叠，也不会遗留失去归属的焦点。

该 API 有意只覆盖模态浮层。用户命令仍然在 `ctx.commands` 上注册；action、slot、编辑器替换、事件渲染器和补全提供方需要另行设计契约，等待实际消费方确定其顺序与所有权语义。FIFO 串行处理也意味着，一个停滞的浮层会阻塞后续模态工作，直至其所有者关闭、中止或卸载该浮层。
