# 终端界面

> 终端界面包 @opencode-ai/tui 的结构：OpenTUI 渲染器与 SolidJS 组件、Provider 组合、会话视图与输入框、键位主题与插件插槽，以及从主包拆出的十个阶段与 --mini 模式。

- 作者：David（道雾轩）
- 专栏：OpenCode 源码解读（https://daiw.org/manual/opencode.md）
- 最后更新：2026-09-15
- 原文：https://daiw.org/manual/opencode/tui
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

`opencode` 默认命令打开的全屏界面来自 `packages/tui`（包名 `@opencode-ai/tui`）。它用 OpenTUI 的 `@opentui/core` 做终端渲染，`@opentui/solid` 把 SolidJS 组件映射到终端，`@opentui/keymap` 管键位。根入口 `packages/tui/src/index.tsx` 只有一行，导出 `run` 函数与 `TuiInput` 类型。下文不带 `packages/` 前缀的路径均相对 `packages/tui/src/`。

## 怎么启动

| 命令 | 说明 |
| --- | --- |
| `opencode [project]` | 在指定目录启动；`-m provider/model`、`--agent`、`--prompt` 预设初值 |
| `opencode -c`、`opencode -s <id>` | 继续最近或指定会话，加 `--fork` 先分叉 |
| `opencode --auto` | 自动批准未被显式拒绝的权限（危险） |
| `opencode attach <url>` | 连接已在运行的服务端，`-p`、`-u` 提供认证 |

`TuiInput` 只有九个字段：`url`、`args`、`config`、`onSnapshot`、`directory`、`fetch`、`headers`、`events`、`pluginHost`。起不起 Worker、开不开端口、带什么认证头都由宿主决定，TUI 只拿到 URL、可选的 `fetch` 与事件源（见[HTTP 服务端](https://daiw.org/manual/opencode/server)）。两个宿主共用这个入口：旧 CLI 的 `packages/opencode/src/cli/tui/layer.ts`，以及新 CLI 的 `packages/cli/src/tui.ts`。

## 从主包拆出来：十个阶段

TUI 原本住在 `packages/opencode/src/cli/cmd/tui`，经 `@/*` 别名随意引用后端模块。`specs/tui-package.md` 记录了拆包计划，核心约束是：

> The SDK is the TUI's OpenCode boundary. Missing backend data or operations must be added to the server API and generated SDK rather than imported from backend implementation modules.

（`specs/tui-package.md:29`）

十个阶段从叶子到根推进，每步单独提交并保持可构建：

| 阶段 | 内容 |
| --- | --- |
| 1 | 建包骨架与冒烟测试 |
| 2 | 迁移展示工具、主题、键位与叶子组件 |
| 3 | 工具渲染改按 SDK 工具名分派 |
| 4 | 全局状态改为显式的运行时输入 |
| 5 | 配置解析与配置文件发现分离 |
| 6 | SDK 状态、路由与后端操作全部改走 SDK |
| 7 | 插件展示与插件加载分离 |
| 8 | 迁入应用根与渲染器生命周期 |
| 9 | 两个 CLI 变成薄适配层 |
| 10 | 删除兼容路径，收窄导出 |

第 3 阶段的规则值得借鉴：入参与元数据在包边界上一律按 `unknown` 处理，未知工具走通用兜底渲染，单个渲染器出错不能拖垮整个会话视图。

规格里十个阶段都已勾选完成，但代码与目标依赖图仍有差距：`package.json` 依然依赖 `@opencode-ai/core`，10 个源文件里还有 14 处引入它的 `Flag`、`Global`、`InstallationVersion` 等基础设施模块。领域数据与操作确实只走 SDK 了，没剥干净的是“进程环境”这一层。

## 渲染器与 Provider

`run`（`packages/tui/src/app.tsx:186`）是一个 Effect：用 `acquireRelease` 创建渲染器、在作用域结束时销毁，键位注册、插件宿主清理与 `SIGHUP` 处理也挂在同一作用域上，保证正常退出、中断、启动失败时终端都能复原。渲染器参数里有 `targetFps: 60` 与 `exitOnCtrlC: false`，后者意味着 `Ctrl+C` 交给键位系统处理（默认绑定为退出应用），而不是由渲染器直接结束进程。

之后是一棵很深的 Provider 树（`app.tsx` 第 247～349 行），由外向内：

```text
Exit → Epilogue → TuiPaths → TuiTerminalEnvironment → TuiStartup → Clipboard
→ Keymap → Args → KV → Toast → Route → TuiConfig → PluginRuntime
→ SDK → Permission → Project → Sync → Data → Theme → Local → PromptStash
→ Dialog → Frecency → PromptHistory → PromptRef → EditorContext → Location
```

顺序即依赖：`Sync` 要用 `SDK` 发请求、用 `Permission` 判断是否自动批准，所以包在它们里面。`Sync`、`Data` 分别是 V1、V2 的领域数据 store（见[事件与同步](https://daiw.org/manual/opencode/events-sync)）；`Local` 管当前选中的模型与 agent；`KV` 是本地键值存储，侧边栏开关之类的偏好存在这里，而不是服务端。

## 路由与会话视图

路由只有三种（`context/route.tsx`）：`home`、带 `sessionID` 的 `session`，以及插件注册的 `plugin` 路由。`app.tsx` 用 `Switch`、`Match` 切换，插件路由找不到时显示 `PluginRouteMissing`。

会话视图 `routes/session/index.tsx` 有 2706 行，是全包最大的文件。挂载时调用 `sync.session.sync(sessionID)` 拉取基线，此后完全由事件驱动；消息按 part 类型渲染文本、推理与工具调用，工具按名字分派到各自的渲染器。权限请求优先于提问：有待处理的权限时显示 `PermissionPrompt`，没有权限但有待回答的问题时才显示 `QuestionPrompt`。

权限对话框（`permission.tsx`）给出 `Allow once`、`Allow always`、`Reject` 三个选项，分别以 `once`、`always`、`reject` 调 `sdk.client.permission.reply`，选“总是允许”还要再确认一次；提问对话框（`question.tsx`）调 `question.reply` 或 `question.reject`。TUI 自己从不裁决权限，只把用户的选择交回服务端（规则见[权限系统](https://daiw.org/manual/opencode/permission)）。

## 输入框：@、/ 与 !

`component/prompt/index.tsx`（1716 行）的提交逻辑按输入分三路：

```ts
    if (store.mode === "shell") {
      move.startSubmit()
      void sdk.client.session.shell({
        // ...
        command: inputText,
      })
      setStore("mode", "normal")
    } else if (
      inputText.startsWith("/") &&
      sync.data.command.some((x) => x.name === inputText.split("\n")[0].split(" ")[0].slice(1))
    ) {
```

（`packages/tui/src/component/prompt/index.tsx:1059`，删减后）

- 光标位于输入框开头、补全菜单未弹出时敲 `!`，进入 shell 模式，提交走 `session.shell`；按 `Esc` 或在开头退格即退出。
- 以 `/` 开头且命中已知命令时走 `session.command`，首行其余部分与后续各行拼成参数。
- 其余情况走 `session.prompt`，除文本外还附上文件、agent 等 part 以及编辑器选区上下文。

自动补全（`component/prompt/autocomplete.tsx`）有两种触发：`@` 弹出文件、非主 agent 与 MCP 资源，文件经 SDK 搜索，直接沿用服务端给出的排序；`/` 弹出斜杠命令。

## 键位、主题与插件

键位建立在 `@opentui/keymap` 之上，`keymap.tsx` 另加了一个“模式栈”，让 shell 模式、对话框等层级各有自己的绑定。默认 leader 键是 `ctrl+x`（`packages/tui/src/config/keybind.ts:41`），常用操作都挂在它后面：`<leader>n` 新建会话、`<leader>l` 会话列表、`<leader>t` 主题、`<leader>b` 侧边栏。

主题是 `theme/assets/` 下 33 份内置 JSON，从 `opencode`、`tokyonight` 到 `gruvbox`；此外会扫描配置目录下的 `themes/*.json` 作为自定义主题（`packages/tui/src/context/theme.tsx:55`）。

`feature-plugins/builtins.ts` 注册了 12 个内置插件：首页页脚与提示，侧边栏的上下文、MCP、LSP、待办、文件与页脚，以及通知、插件管理、which-key 和 diff 查看器。TUI 自己的功能也用对外的 `@opencode-ai/plugin/tui` 契约写成，这是对插件 API 最好的检验。插件通过插槽（`plugin/slots.tsx`，基于 OpenTUI 的 `createSolidSlotRegistry`）向界面注入内容，渲染出错只记日志；插件的发现、安装与执行由宿主注入的 `pluginHost` 负责，缺失或失败时 TUI 退化为基础界面照常启动（见[插件系统](https://daiw.org/manual/opencode/plugins)）。

## `--mini`：另一种终端形态

`opencode --mini` 不启动全屏 TUI，而是交给 `packages/opencode/src/cli/cmd/run.ts` 的 `runMini`，后者以 `mini: true` 直接调用 `opencode run` 的 handler。该文件头注释（第 5 行起）列出三种模式：非交互的单次运行、进程内服务端的本地交互模式（“split-footer direct mode”），以及连接已有服务端的 attach 交互模式。

“split-footer”的形态从同级 `run/` 目录的文件名（`scrollback.writer.tsx`、`footer.prompt.tsx` 等）就能看出：对话内容写进终端的滚动区，底部一块页脚承载输入框、权限与提问面板，输出因此留在终端历史里。实际连接远端的入口是 `opencode attach <url> --mini`；`--no-replay` 与 `--replay-limit` 控制恢复会话时回放多少历史，`--mini` 不能与 `--port`、`--hostname` 等网络参数同用。

下一篇：[ACP、GitHub 与其他入口](https://daiw.org/manual/opencode/frontends)——编辑器、GitHub Actions、脚本与桌面端如何接入同一个内核。
