# UI / TUI / Desktop / IDE 相关

> 源码基线：`eec0843ce422`。本章沿着源码中的典型交互路径讲解，不把它冒充成真实运行录屏。

## 0. 本章学习目标

读完本章，你应该能：

- 画出 CLI、TUI、Web app、Desktop 和 VS Code 与 runtime 的边界。
- 解释为什么这些入口不需要各自实现一次 agent loop。
- 沿着“把 VS Code 当前文件加入提示词”追到 TUI HTTP 入口。
- 区分命令请求、状态事件和界面本地状态。
- 判断新增一种客户端时应该复用 SDK/API，还是直接耦合内部模块。

## 1. 一句话讲明白

OpenCode 的多个界面是“不同驾驶舱，共用同一台发动机”：界面负责采集输入、发出命令、订阅事件和呈现审批，session/tool/provider/permission runtime 才负责 agent 行动。

中心问题是：**终端、桌面和 IDE 的交互完全不同，它们怎样共享能力，又不把 UI 变成第二套 runtime？**

## 2. 先看位置，而不是组件树

```text
CLI non-interactive ------ SDK session.prompt ----+
                                                   |
TUI ---------------------- SDK + event stream -----+--> Server / Session runtime
                                                   |
Web app ------------------ SDK + HTTP/SSE ----------+
                                                   |
Desktop ------ Electron shell + @opencode-ai/app --+

VS Code extension
  -> 创建 opencode terminal
  -> POST /tui/append-prompt
  -> TUI prompt state
```

这张图里最重要的不是框架，而是两种方向：

- **命令向内**：prompt、abort、permission reply、append prompt。
- **事件向外**：message part、tool 状态、permission request、session 状态。

界面可以有自己的路由、store 和渲染生命周期，但不能拥有 agent 的业务真相。

## 3. 最小机制：一个客户端只需要三件事

先去掉 Desktop 打包、Solid 响应式和终端渲染，最小界面适配器是：

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

onUserSubmit(text):
  client.session.prompt(text)

for event in events:
  reduce(uiState, event)
  render(uiState)
```

CLI 非交互模式正好展示了这个骨架：先订阅 `client.event.subscribe()`，再调用 `client.session.prompt(...)`，见 `packages/opencode/src/cli/cmd/run.ts:768-803`。

OpenCode 产品层再加上：审批回复、中断、重放、路由、窗口生命周期、文件引用和本地 sidecar。

## 4. 四种“壳”并不相同

| 入口 | 主要职责 | 与 runtime 的连接 | 不负责什么 |
|---|---|---|---|
| CLI non-interactive | 脚本化输入与输出 | SDK 请求 + 事件订阅 | UI store、agent loop |
| TUI | 持续交互、实时状态、审批 | SDK、事件流或 in-process fetch | provider/tool 决策 |
| Web / Desktop | 图形路由与跨平台桌面外壳 | `@opencode-ai/app` + SDK/API | 重写 session 语义 |
| VS Code | IDE 命令、当前文件/选区上下文 | terminal + TUI HTTP endpoint | 嵌入完整 agent runtime |

`packages/app/package.json:42-47` 显示 Web app 直接依赖 workspace 中的 SDK、UI 和 core；`packages/desktop/package.json:37-41` 显示 Desktop 在开发时复用 `@opencode-ai/app` 与 `@opencode-ai/ui`。这是包边界层面的证据。

## 5. 最小源码路径

按下面顺序读，先获得闭环，再看各端差异：

1. `packages/opencode/src/cli/cmd/run.ts:768-803`：最小“订阅事件 + 发 prompt”。
2. `packages/opencode/src/cli/cmd/run.ts:807-879`：interactive attach、本地 in-process 两条路径。
3. `packages/opencode/src/cli/cmd/tui/context/sdk.tsx:24-97`：TUI client 与事件入口。
4. `packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx:73-236`：事件如何归并到界面状态。
5. `packages/app/package.json:42-47`：Web app 的共享依赖。
6. `packages/desktop/package.json:12-25`、`37-41`：桌面构建与 app/UI 复用。
7. `sdks/vscode/src/extension.ts:8-41`：IDE 命令与已有 terminal。
8. `sdks/vscode/src/extension.ts:45-100`：启动 OpenCode、探活、追加 prompt。
9. `sdks/vscode/src/extension.ts:103-136`：把文件和选区编码成 `@file#Lx-Ly`。

## 6. 一条具体源码旅程：把 VS Code 选区交给 OpenCode

假设用户在 VS Code 选中 `src/order.ts` 的第 20–28 行，然后执行“Add Filepath to Terminal”。以下是源码证明的典型路径。

### 第一步：扩展只采集 IDE 上下文

`getActiveFile()` 读取 active editor，要求文件属于 workspace，然后生成相对引用：

```text
@src/order.ts#L20-L28
```

对应 `sdks/vscode/src/extension.ts:103-136`。扩展没有读取文件内容，也没有推理“这段代码是什么意思”；它只把 IDE 独有状态翻译成 OpenCode 能理解的引用。

### 第二步：复用或创建 terminal

`opencode.openTerminal` 会先找名为 `opencode` 的 terminal；存在就聚焦，不存在才新建，见 `sdks/vscode/src/extension.ts:8-22`。

新 terminal 会得到随机端口和两个环境变量，再发送：

```text
opencode --port <port>
```

证据在 `sdks/vscode/src/extension.ts:45-65`。这里的 `OPENCODE_CALLER=vscode` 是调用方标记，不等于 IDE 内嵌了一套 runtime。

### 第三步：先探活，再追加提示词

扩展最多尝试十次访问 `/app`，每次间隔 200ms；连接成功才调用 `appendPrompt`，见 `sdks/vscode/src/extension.ts:72-91`。

`appendPrompt` 发出：

```http
POST /tui/append-prompt
Content-Type: application/json

{ "text": "In @src/order.ts#L20-L28" }
```

对应 `sdks/vscode/src/extension.ts:93-100`。

这里有一个可靠性边界：十次探活失败后，源码不会继续 POST，但也没有在这段函数中向用户呈现详细错误。新增 IDE 客户端时，应明确连接失败如何反馈，而不是静默丢失上下文。

### 第四步：TUI 接收的是“编辑提示词”命令

扩展调用的是 `/tui/append-prompt`，不是 session prompt endpoint。这表示它只把文本放进 TUI 输入区，仍由用户决定何时提交。

这是从 endpoint 命名和扩展调用路径得到的设计解释；是否立即触发 agent，应以 TUI route handler 为准，不能从 VS Code 代码臆测。

### 第五步：真正提交后回到公共 session 边界

一旦 TUI 提交，界面通过 SDK 与后端交互；`run.ts` 的非交互路径清楚证明公共边界是 session API，而不是 UI 直接调用 provider，见 `packages/opencode/src/cli/cmd/run.ts:768-803`。

这条旅程说明 IDE 扩展应该是“上下文适配器”，而不是“agent 的 IDE 分叉版”。

## 7. TUI 为什么需要事件归并

TUI SDK context 用 `createOpencodeClient` 创建 client，传入 base URL、directory、fetch 与 headers，见 `packages/opencode/src/cli/cmd/tui/context/sdk.tsx:24-31`。

没有外部 event source 时，它调用 global event stream，并通过 async iterator 消费：`packages/opencode/src/cli/cmd/tui/context/sdk.tsx:83-97`。

事件可能非常密集。源码会先排队，再在 Solid `batch` 中发给订阅者，避免每个 token/part 都引发独立渲染，见 `packages/opencode/src/cli/cmd/tui/context/sdk.tsx:42-73`。

这不是 agent 内核，而是产品层的背压策略：

```text
runtime event burst
  -> queue
  -> batch
  -> sync reducer
  -> minimum necessary render
```

Java 开发者可以类比 WebFlux 消费 SSE 后批量更新 projection，但不要把 Solid store 当成后端事务；它只是可重建的界面投影。

## 8. 本地模式为什么仍走“HTTP 形状”

交互本地模式把 SDK 的 fetch 指向 `Server.Default().app.fetch(request)`，见 `packages/opencode/src/cli/cmd/run.ts:832-879`。

也就是说，请求不一定经过真实监听端口，但仍穿过相同 handler 契约：

```text
SDK request
  -> custom in-process fetch
  -> Server app handler
  -> runtime service
```

取舍：

- 好处：本地模式少一个端口和网络生命周期，API 语义仍可复用。
- 代价：in-process 与真实 HTTP 在代理、网络失败、跨进程隔离上并不等价，测试不能完全互相替代。

## 9. Web、Desktop 与 UI package 的边界证据

本章 metadata 只把三个 package manifest 列为核心证据，因此这里不推断未列出的 Desktop 启动细节。

可以确认的是：

- `@opencode-ai/app` 导出 app 源入口，并依赖 SDK、UI、core，见 `packages/app/package.json:6-9`、`42-47`。
- `@opencode-ai/ui` 公开组件、hooks、context、styles、theme 等细粒度入口，见 `packages/ui/package.json:6-25`。
- Desktop 的 main 指向 Electron 构建产物，并在 dev dependencies 复用 app/UI，见 `packages/desktop/package.json:12-25`、`37-41`。

因此，“Desktop 复用 Web app/UI 包”是有源码依据的；“Desktop 在某处以某参数启动 sidecar”若未继续检查具体 main/server 文件，就不应在本章写成已证明事实。

## 10. OpenCode 的选择

| 选择 | 好处 | 代价 |
|---|---|---|
| UI 统一走 SDK/API | 多入口共享行为，易做自动化客户端 | API 演进必须兼顾多个消费者 |
| 请求与事件分离 | 命令简单，长任务可持续更新 | 前端要处理乱序、重连与投影一致性 |
| 本地 in-process fetch | 复用 handler，又省去监听端口 | 与真实网络路径仍有差异 |
| Desktop 复用 app/UI package | 减少图形界面重复 | 桌面能力需通过清晰 adapter 注入 |
| VS Code 只做 terminal/context bridge | 扩展薄、维护成本低 | 原生 IDE 体验与错误反馈受限 |

## 11. 可以带走的方法

### 方法一：让 UI 保存投影，不保存业务真相

session message 和 permission 状态来自 runtime；UI store 只负责为了展示而索引、排序、折叠。

验证问题：刷新或换一个客户端后，是否能从 server 状态重建界面？

### 方法二：把平台特有信息翻译成通用输入

VS Code 知道当前文件和选区，runtime 知道 `@file#Lx-Ly`。适配层只做翻译。

验证问题：去掉 IDE API 后，agent loop 是否仍能处理同样的文件引用？

### 方法三：命令与事件分别设计失败语义

命令需要确认接收或拒绝；事件流需要重连、去重和最终状态同步。不要用“已经发出请求”代替“界面最终一致”。

验证问题：事件断线后，客户端怎样发现自己漏了状态？

## 12. 费曼复述与练习

请用自己的话回答：

1. 为什么 TUI 不应该直接 import provider 并调用模型？
2. in-process fetch 与 HTTP 请求相同和不同的部分各是什么？
3. VS Code extension 为什么调用 append prompt，而不是直接发 session prompt？

练习阶梯：

- **入门**：给地图中的每条箭头标上 request 或 event。
- **进阶**：写一个只支持 `submit()`、`subscribe()`、`abort()` 的客户端接口。
- **源码追踪**：从 `sdks/vscode/src/extension.ts:103` 走到 `:100`，解释文件引用为何先经过 terminal 探活。
- **迁移**：设计一个 JetBrains 插件，只列出 IDE adapter 必须承担的职责。

## 最后复盘：壳可以变，协议边界不能漂

```text
用户界面采集意图
  -> SDK/API 发命令
  -> runtime 产生状态与事件
  -> 客户端归并为视图
```

下一章会把这条“公共边界”展开：typed HTTP API 怎样变成 generated SDK，插件又怎样在受控 hook 点扩展行为，而不靠 monkey patch 侵入 runtime。
