终端界面
终端界面包 @opencode-ai/tui 的结构:OpenTUI 渲染器与 SolidJS 组件、Provider 组合、会话视图与输入框、键位主题与插件插槽,以及从主包拆出的十个阶段与 --mini 模式。
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 服务端)。两个宿主共用这个入口:旧 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 行),由外向内:
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(见事件与同步);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 自己从不裁决权限,只把用户的选择交回服务端(规则见权限系统)。
输入框:@、/ 与 !
component/prompt/index.tsx(1716 行)的提交逻辑按输入分三路:
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 退化为基础界面照常启动(见插件系统)。
--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 与其他入口——编辑器、GitHub Actions、脚本与桌面端如何接入同一个内核。