# UI / TUI / Desktop / IDE 相关

> 源码基线：OpenCode `v1.18.16`（提交 `a3647eb025c7`）。本章沿一条“VS Code 把当前选区送到终端，再由交互式客户端提交”的典型路径读源码，不把它写成真实运行录屏。

## 0. 本章学习目标

读完本章，你应该能：

- 画出 non-interactive CLI、交互式 run、独立 TUI、Web、Desktop 和 VS Code 与 runtime 的边界。
- 解释为什么不同界面不需要各自实现 agent loop。
- 沿着“把 VS Code 当前文件加入 prompt”追到 `/tui/append-prompt`。
- 区分命令、全局事件流、界面 projection 与 session 事实。
- 说明 `v1.18.16` 的 TUI 重组为什么没有改变 SDK/API 边界。

## 1. 一句话讲明白

OpenCode 的界面是不同驾驶舱，共用同一个 session runtime：界面采集输入、调用 SDK、订阅事件并呈现审批；agent loop、tool、provider 与 permission 才拥有业务真相。

中心问题是：

> **终端、桌面和 IDE 的交互方式完全不同，它们怎样共享能力，又不长出第二套 runtime？**

## v1.18.16 相比旧基线改变了什么

- 旧基线里庞大的 `packages/opencode/src/cli/cmd/tui/` 组件树已经移除；交互式 `run` 的 UI、transport、session projection 与 footer 被拆到 `cli/cmd/run/`。
- 独立 TUI 的稳定入口改由 `packages/opencode/src/cli/tui/layer.ts` 包装 `@opencode-ai/tui`，`tui` thread command 仍通过公共 runtime layer 启动。
- VS Code 的协议没有被这次重组打破：扩展仍启动带端口的 `opencode` terminal，再向 `/tui/append-prompt` 写入文件引用。
- 因而要区分“UI 目录移动”和“产品协议改变”。前者变化很大，后者仍围绕 SDK、typed HTTP API 与 event stream。

<!-- source-ref path="packages/opencode/src/cli/cmd/run/runtime.ts" lines="181-270" title="v1.18.16 交互式 run runtime" note="界面创建 projection，并把 permission/question 回复交给 SDK。" -->

## 2. 先画地图：界面不拥有 agent 事实

```text
non-interactive run ---- client.session.prompt --------+
                                                       |
interactive run -------- SDK + global event stream ----+--> typed HTTP API
                                                       |          |
独立 TUI -------------- @opencode-ai/tui + runtime ----+          v
                                                       |    Session runtime
Web app ---------------- SDK + HTTP/SSE ----------------+    Agent / Tool / LLM
                                                       |
Desktop ---------------- Electron shell + shared app --+

VS Code extension
  -> 启动 opencode terminal
  -> POST /tui/append-prompt
  -> 交互式客户端 prompt buffer
```

图里有四类信息，不能混在一起：

| 信息 | 例子 | 权威来源 |
|---|---|---|
| 用户意图 | prompt、abort、permission reply | UI 发出的 command |
| agent 事实 | message、part、tool state | session runtime |
| 变化通知 | part updated、permission asked | global event stream |
| 视觉状态 | 当前滚动、选中 tab、输入草稿 | UI 本地 projection |

UI 可以丢掉并重建视觉状态；session message 不能因为一次重绘而丢失。

## 3. 最小机制：命令向内，事件向外

去掉框架和渲染细节，一个最小客户端只需三步：

```text
client = connect(runtime)
events = subscribe(client)
projection = emptyState()

onUserSubmit(text):
  client.session.prompt({ parts: [text] })

for event in events:
  projection = apply(projection, event)
  render(projection)
```

这个模型的关键不是“使用 SSE”，而是单向职责：

- command 只表达希望 runtime 做什么；
- event 只陈述 runtime 已发生什么；
- projection 只负责把事实整理成界面需要的形状。

Java 开发者可以类比 CQRS read model，但不要把界面 projection 当成数据库事务。OpenCode 的恢复依据仍是 session message 与服务状态。

## 4. 交互式 run：本地和 attach 共用一个 UI runtime

`run.ts` 根据连接方式准备 SDK client，然后把控制交给两个入口：

- `runInteractiveMode`：调用方已经有 SDK client，适合 attach。
- `runInteractiveLocalMode`：本地模式注入 in-process `fetch`，但仍创建 SDK client。

两者最终都进入 `runInteractiveRuntime`。这个函数先并行取得 TUI config、session history 与保存的 variant，再建立 `RuntimeState`。状态里包含当前 session、history、agent、model、variant 和本地行；它是 UI projection，不是 agent loop。

证据见 `packages/opencode/src/cli/cmd/run/runtime.ts:181-270`。

这里的设计收益是：UI 不需要在“远程 server”与“本地进程”之间维护两套交互逻辑。差异被压缩到 client/fetch 的构造处。

## 5. event transport：为什么不能每个事件都直接改屏幕

交互式界面面对的不是一次响应，而是持续变化：

- assistant text 增量；
- ToolPart 从 pending 到 running 再到 completed；
- permission/question 请求；
- session title、usage、subagent 与错误；
- instance disposed 或 event stream 关闭。

`RunStreamTransport.watch` 消费 global event stream。启动或 replay 期间，它先把带 sessionID 的事件放入 buffer；只有完成 bootstrap 并确认 tracked session 后才应用到 projection。

<!-- source-ref path="packages/opencode/src/cli/cmd/run/stream.transport.ts" lines="1127-1185" title="event stream 的过滤、缓冲与应用" note="transport 先判断生命周期和 session 归属，再更新界面 projection。" -->

这解决两个常见竞态：

1. 历史消息还没加载完，实时事件先到，造成重复或倒序。
2. global stream 包含别的 session，当前界面误把它渲染进来。

如果 stream 非正常关闭，transport 会写入 fault；它不会把“没有新事件”误当成任务正常结束。

## 6. 一条具体旅程：VS Code 当前选区怎样进入 prompt

现在追一条完整但很窄的路径。

### 第一步：扩展找到或创建 terminal

`opencode.openTerminal` 先查找名为 `opencode` 的 terminal；存在就聚焦，不存在才创建。新 terminal 会得到随机端口、`OPENCODE_CALLER=vscode` 与 `_EXTENSION_OPENCODE_PORT`，然后执行 `opencode --port <port>`。

这证明 VS Code extension 没有内嵌 agent runtime，它启动的仍是 OpenCode 进程。

### 第二步：把 IDE 状态翻译成文件引用

`getActiveFile()` 读取 active editor 与 workspace relative path，并生成：

```text
@src/example.ts
@src/example.ts#L12
@src/example.ts#L12-L24
```

扩展没有读取和解释文件内容。它只把 IDE 独有的文件/选区状态翻译成 OpenCode prompt 能理解的引用。

### 第三步：先探活，再 append prompt

扩展最多尝试十次访问 `/app`，每次间隔 200ms。连接成功后，它向 `/tui/append-prompt` POST：

```json
{ "text": "In @src/example.ts#L12-L24" }
```

<!-- source-ref path="sdks/vscode/src/extension.ts" lines="45-100" title="VS Code 启动 terminal 并追加 prompt" note="探活成功后才写入 TUI prompt buffer。" -->

### 第四步：TUI API 发布界面命令事件

server 的 `appendPrompt` handler 不调用 `SessionPrompt.prompt`，而是发布 `TuiEvent.PromptAppend`。这表示“追加到输入框”与“提交给 agent”是两个动作。

`packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts:31-44` 是这一边界的直接证据。

直到用户在交互式客户端提交，session runtime 才收到真正的 prompt command。

## 7. Desktop 与 Web：复用 UI 不等于复用进程模型

Web app 与 Desktop 可以共享大量页面、store 和组件，但运行边界不同：

- Web app 连接已存在的 server。
- Desktop 的 main process 负责窗口、IPC、更新与 local server 生命周期。
- renderer 仍复用 app 层，不直接调用 session 内部函数。

`packages/desktop/src/main/index.ts` 中的 `spawnLocalServer` 说明 Desktop shell 负责准备后端；它不意味着 renderer 拥有 provider/tool 状态机。

所以“共享 UI package”只能证明前端代码复用，不能推出进程、认证或生命周期完全相同。

## 8. 失败分支：界面必须把断裂显式化

至少处理这些失败：

| 失败 | 不应做什么 | 合理行为 |
|---|---|---|
| terminal 尚未就绪 | 立即假装 append 成功 | 有界探活，失败后保留用户输入 |
| event stream 关闭 | 把页面停住当成 idle | 标记 fault 并允许重连 |
| instance disposed | 继续向旧 client 发命令 | 关闭 scope，提示重新连接 |
| replay 与实时事件交错 | 直接逐条渲染 | buffer 后按归属 drain |
| permission reply 失败 | 乐观显示已批准 | 以 SDK 返回和后续事件为准 |

界面的错误不是“美观问题”。如果它把未送达显示成已送达，用户会对 agent 的真实状态形成错误判断。

## 9. 设计取舍

### 取舍一：统一 API 增加了一层适配

本地交互也经 SDK/in-process fetch，链路比直接 import session service 长。但它让远程、本地和 IDE 使用同一能力合同，减少 UI 分叉。

### 取舍二：event projection 带来一致性工作

事件流让多个客户端实时更新，也要求处理 replay、buffer、归属和断线。直接共享内存更简单，却无法跨进程或可靠恢复。

### 取舍三：TUI 实现可替换，协议应稳定

`v1.18.16` 已证明 UI 目录可以大规模重组。如果 VS Code 依赖内部 Solid component，它会随重组一起破裂；依赖 `/tui/append-prompt` 和 terminal 协议，则迁移面小得多。

## 10. 可迁移的方法

### 方法一：把 UI 看成可重建 projection

验证问题：关闭并重新打开界面后，能否只靠 server/session 数据恢复关键状态？

### 方法二：为外部集成设计意图级命令

`appendPrompt` 表达“把文本放进草稿”，而不是“修改某个输入框组件的内部 state”。

验证问题：更换 TUI 框架后，IDE 集成是否仍可使用同一命令？

### 方法三：在 bootstrap 边界解决 replay 竞态

验证问题：历史加载期间到达的实时事件会丢失、重复，还是被有界缓冲并按 session 归属应用？

## 11. 费曼复述

请不用源码回答：

1. 为什么本地交互模式仍值得走 SDK？
2. command、event、projection 与 session fact 有什么区别？
3. VS Code 为什么调用 append-prompt，而不是直接调用 provider？
4. `v1.18.16` 的 TUI 目录重组为什么没有迫使 extension 重写？

如果你只能回答“因为前后端分离”，还不够。你应该能说出：稳定 API 隔离执行内核，event stream 提供事实变化，projection 可重建，外部集成依赖意图级协议而非 UI 组件。

## 12. 练习阶梯

1. 在 `sdks/vscode/src/extension.ts` 找出端口、探活和 append 的三个边界。
2. 在 `stream.transport.ts` 解释 `booting/replaying` 时为什么先 buffer。
3. 为自己的 mini agent 写三个命令：`submitPrompt`、`abort`、`replyPermission`。
4. 再写 projection reducer，并证明重复事件不会产生重复 ToolPart。

## 13. 收束与下一章

这一章确认：OpenCode 的 UI 可以重组，稳定的 runtime 合同不能跟着组件树漂移。命令经 SDK/API 向内，事实经事件向外，界面只维护可重建 projection。

下一章继续追问：如果另一个程序不只是显示状态，而是要系统性调用 session、启动 server 或插入插件逻辑，OpenCode 对外开放了哪些正式扩展边界？
