跳转到内容

UI / TUI / Desktop / IDE 相关

v1.18.16 源码提交 a3647eb025c7 · 官方 Release
状态已完成
难度中等到较难
预计阅读35 分钟
  • 章节 ID:11-ui-tui-desktop-ide
  • 章节摘要:沿一次 VS Code 选区进入 TUI 的路径,理解 CLI、TUI、Web、Desktop 与 IDE 如何通过命令和事件共享同一套 runtime。
  • 教程版本:v1.18.16
  • 源码基线:a3647eb025c7615159d417dcc49fc39fdaeba65b
  • 章节元数据:/versions/v1-18-16/data/chapters.json
  • 源码映射:/versions/v1-18-16/data/source-map.json
  • packages/opencode/src/cli/cmd/run.ts
  • packages/opencode/src/cli/cmd/run/
  • packages/opencode/src/cli/tui/
  • packages/app/package.json
  • packages/ui/package.json
  • packages/desktop/package.json
  • sdks/vscode/package.json
  • sdks/vscode/src/extension.ts

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

读完本章,你应该能:

  • 画出 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 边界。

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

中心问题是:

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

  • 旧基线里庞大的 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/tuitui thread command 仍通过公共 runtime layer 启动。
  • VS Code 的协议没有被这次重组打破:扩展仍启动带端口的 opencode terminal,再向 /tui/append-prompt 写入文件引用。
  • 因而要区分“UI 目录移动”和“产品协议改变”。前者变化很大,后者仍围绕 SDK、typed HTTP API 与 event stream。
v1.18.16 交互式 run runtime packages/opencode/src/cli/cmd/run/runtime.ts:181-270

界面创建 projection,并把 permission/question 回复交给 SDK。

181async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDeps = {}): Promise<void> {定义一段可复用逻辑。182  const start = performance.now()183  const log = trace()184  const tuiConfigTask = resolveRunTuiConfig()185  const ctx = await input.boot()186  const modelTask = resolveModelInfo(ctx.sdk, ctx.directory, ctx.model)187  const sessionTask =188    ctx.resume === true189      ? resolveSessionInfo(ctx.sdk, ctx.sessionID, ctx.model)190      : Promise.resolve({191          first: true,192          history: [],193          variant: undefined,194        })195  const savedTask = resolveSavedVariant(ctx.model)196  const [tuiConfig, session, savedVariant] = await Promise.all([tuiConfigTask, sessionTask, savedTask])并行等待多个任务。197  const state: RuntimeState = {198    shown: !session.first,199    aborting: false,200    model: ctx.model,选择模型或 provider。201    providers: [],选择模型或 provider。202    variants: [],203    limits: {},204    activeVariant: resolveVariant(ctx.variant, session.variant, savedVariant, []),205    sessionID: ctx.sessionID,206    history: [...session.history],207    localRows: [],208    sessionTitle: ctx.sessionTitle,209    agent: ctx.agent,210  }211  const ensureSession = () => {212    if (!input.resolveSession || state.sessionID) {按条件进入分支。213      return Promise.resolve()返回给上一层。214    }215216    if (state.session) {按条件进入分支。217      return state.session返回给上一层。218    }219220    state.session = input.resolveSession(ctx).then((next) => {221      state.sessionID = next.sessionID222      state.sessionTitle = next.sessionTitle ?? state.sessionTitle223      state.agent = next.agent224    })225    return state.session返回给上一层。226  }227228  const shell = await (deps.createRuntimeLifecycle ?? createRuntimeLifecycle)({处理命令执行。229    directory: ctx.directory,230    findFiles: (query) =>231      ctx.sdk.find232        .files({ query, directory: ctx.directory })233        .then((x) => x.data ?? [])234        .catch(() => []),235    agents: [],236    resources: [],237    sessionID: state.sessionID,238    sessionTitle: state.sessionTitle,239    getSessionID: () => state.sessionID,240    first: session.first,241    history: session.history,242    agent: state.agent,243    model: state.model,选择模型或 provider。244    variant: state.activeVariant,245    tuiConfig,246    backgroundSubagents: input.backgroundSubagents,247    onPermissionReply: async (next) => {248      if (state.demo?.permission(next)) {按条件进入分支。249        return返回给上一层。250      }251252      log?.write("send.permission.reply", next)回写审批结果。253      await ctx.sdk.permission.reply(next)回写审批结果。254    },255    onQuestionReply: async (next) => {256      if (state.demo?.questionReply(next)) {按条件进入分支。257        return返回给上一层。258      }259260      await ctx.sdk.question.reply(next)261    },262    onQuestionReject: async (next) => {263      if (state.demo?.questionReject(next)) {按条件进入分支。264        return返回给上一层。265      }266267      await ctx.sdk.question.reject(next)268    },269    onCycleVariant: () => {270      if (!state.model || state.variants.length === 0) {按条件进入分支。

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

Section titled “2. 先画地图:界面不拥有 agent 事实”
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 replyUI 发出的 command
agent 事实message、part、tool statesession runtime
变化通知part updated、permission askedglobal event stream
视觉状态当前滚动、选中 tab、输入草稿UI 本地 projection

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

3. 最小机制:命令向内,事件向外

Section titled “3. 最小机制:命令向内,事件向外”

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

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

Section titled “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

packages/opencode/src/cli/cmd/run/runtime.ts packages/opencode/src/cli/cmd/run/runtime.ts:181-270
181async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDeps = {}): Promise<void> {定义一段可复用逻辑。182  const start = performance.now()183  const log = trace()184  const tuiConfigTask = resolveRunTuiConfig()185  const ctx = await input.boot()186  const modelTask = resolveModelInfo(ctx.sdk, ctx.directory, ctx.model)187  const sessionTask =188    ctx.resume === true189      ? resolveSessionInfo(ctx.sdk, ctx.sessionID, ctx.model)190      : Promise.resolve({191          first: true,192          history: [],193          variant: undefined,194        })195  const savedTask = resolveSavedVariant(ctx.model)196  const [tuiConfig, session, savedVariant] = await Promise.all([tuiConfigTask, sessionTask, savedTask])并行等待多个任务。197  const state: RuntimeState = {198    shown: !session.first,199    aborting: false,200    model: ctx.model,选择模型或 provider。201    providers: [],选择模型或 provider。202    variants: [],203    limits: {},204    activeVariant: resolveVariant(ctx.variant, session.variant, savedVariant, []),205    sessionID: ctx.sessionID,206    history: [...session.history],207    localRows: [],208    sessionTitle: ctx.sessionTitle,209    agent: ctx.agent,210  }211  const ensureSession = () => {212    if (!input.resolveSession || state.sessionID) {按条件进入分支。213      return Promise.resolve()返回给上一层。214    }215216    if (state.session) {按条件进入分支。217      return state.session返回给上一层。218    }219220    state.session = input.resolveSession(ctx).then((next) => {221      state.sessionID = next.sessionID222      state.sessionTitle = next.sessionTitle ?? state.sessionTitle223      state.agent = next.agent224    })225    return state.session返回给上一层。226  }227228  const shell = await (deps.createRuntimeLifecycle ?? createRuntimeLifecycle)({处理命令执行。229    directory: ctx.directory,230    findFiles: (query) =>231      ctx.sdk.find232        .files({ query, directory: ctx.directory })233        .then((x) => x.data ?? [])234        .catch(() => []),235    agents: [],236    resources: [],237    sessionID: state.sessionID,238    sessionTitle: state.sessionTitle,239    getSessionID: () => state.sessionID,240    first: session.first,241    history: session.history,242    agent: state.agent,243    model: state.model,选择模型或 provider。244    variant: state.activeVariant,245    tuiConfig,246    backgroundSubagents: input.backgroundSubagents,247    onPermissionReply: async (next) => {248      if (state.demo?.permission(next)) {按条件进入分支。249        return返回给上一层。250      }251252      log?.write("send.permission.reply", next)回写审批结果。253      await ctx.sdk.permission.reply(next)回写审批结果。254    },255    onQuestionReply: async (next) => {256      if (state.demo?.questionReply(next)) {按条件进入分支。257        return返回给上一层。258      }259260      await ctx.sdk.question.reply(next)261    },262    onQuestionReject: async (next) => {263      if (state.demo?.questionReject(next)) {按条件进入分支。264        return返回给上一层。265      }266267      await ctx.sdk.question.reject(next)268    },269    onCycleVariant: () => {270      if (!state.model || state.variants.length === 0) {按条件进入分支。

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

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

Section titled “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。

event stream 的过滤、缓冲与应用 packages/opencode/src/cli/cmd/run/stream.transport.ts:1127-1185

transport 先判断生命周期和 session 归属,再更新界面 projection。

1127        const watch = Effect.fn("RunStreamTransport.watch")(() =>Effect 异步工作流。1128          Stream.fromAsyncIterable(events.stream, (error) =>1129            error instanceof Error ? error : new Error(String(error)),1130          ).pipe(1131            Stream.takeUntil(() => input.footer.isClosed || abort.signal.aborted),1132            Stream.runForEach(1133              Effect.fn("RunStreamTransport.event")(function* (item: unknown) {Effect 异步工作流。1134                if (input.footer.isClosed) {按条件进入分支。1135                  abort.abort()1136                  return返回给上一层。1137                }11381139                if (isMatchingDisposeEvent(item, input.directory)) {按条件进入分支。1140                  yield* fail(new Error("instance disposed"))等待 Effect 结果。1141                  yield* closeScope()等待 Effect 结果。1142                  return返回给上一层。1143                }11441145                const event = globalPayloadEvent(item)1146                if (!event) {按条件进入分支。1147                  return返回给上一层。1148                }11491150                const sessionID = sid(event)1151                if (booting || replaying) {按条件进入分支。1152                  if (sessionID) {按条件进入分支。1153                    input.trace?.write("recv.event", event)1154                    buffered.push(event)1155                  }1156                  return返回给上一层。1157                }11581159                if (!tracked(sessionID)) {按条件进入分支。1160                  if (sessionID) {按条件进入分支。1161                    input.trace?.write("recv.event", event)1162                    buffered.push(event)1163                  }1164                  return返回给上一层。1165                }11661167                input.trace?.write("recv.event", event)1168                yield* applyEvent(event)等待 Effect 结果。1169                yield* drainBuffered()等待 Effect 结果。1170              }),1171            ),1172            Effect.catch((error) => (abort.signal.aborted ? Effect.void : fail(error))),Effect 异步工作流。1173            Effect.ensuring(Effect 异步工作流。1174              Effect.gen(function* () {Effect 异步工作流。1175                if (!abort.signal.aborted && !state.fault) {按条件进入分支。1176                  yield* fail(new Error("global event stream closed"))等待 Effect 结果。1177                }1178                closeStream()1179              }),1180            ),1181          ),1182        )11831184        yield* Scope.provide(scope)(watch().pipe(Effect.forkScoped))Effect 异步工作流。1185        yield* bootstrap()等待 Effect 结果。

这解决两个常见竞态:

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

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

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

Section titled “6. 一条具体旅程:VS Code 当前选区怎样进入 prompt”

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

第一步:扩展找到或创建 terminal

Section titled “第一步:扩展找到或创建 terminal”

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

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

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

Section titled “第二步:把 IDE 状态翻译成文件引用”

getActiveFile() 读取 active editor 与 workspace relative path,并生成:

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

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

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

1{ "text": "In @src/example.ts#L12-L24" }
VS Code 启动 terminal 并追加 prompt sdks/vscode/src/extension.ts:45-100

探活成功后才写入 TUI prompt buffer。

45  async function openTerminal() {定义一段可复用逻辑。46    // Create a new terminal in split screen47    const port = Math.floor(Math.random() * (65535 - 16384 + 1)) + 1638448    const terminal = vscode.window.createTerminal({49      name: TERMINAL_NAME,50      iconPath: {51        light: vscode.Uri.file(context.asAbsolutePath("images/button-dark.svg")),52        dark: vscode.Uri.file(context.asAbsolutePath("images/button-light.svg")),53      },54      location: {55        viewColumn: vscode.ViewColumn.Beside,56        preserveFocus: false,57      },58      env: {59        _EXTENSION_OPENCODE_PORT: port.toString(),60        OPENCODE_CALLER: "vscode",61      },62    })6364    terminal.show()65    terminal.sendText(`opencode --port ${port}`)6667    const fileRef = getActiveFile()68    if (!fileRef) {按条件进入分支。69      return返回给上一层。70    }7172    // Wait for the terminal to be ready73    let tries = 1074    let connected = false75    do {76      await new Promise((resolve) => setTimeout(resolve, 200))77      try {开始保护性执行。78        await fetch(`http://localhost:${port}/app`)79        connected = true80        break81      } catch {}8283      tries--84    } while (tries > 0)8586    // If connected, append the prompt to the terminal87    if (connected) {按条件进入分支。88      await appendPrompt(port, `In ${fileRef}`)89      terminal.show()90    }91  }9293  async function appendPrompt(port: number, text: string) {定义一段可复用逻辑。94    await fetch(`http://localhost:${port}/tui/append-prompt`, {95      method: "POST",96      headers: {97        "Content-Type": "application/json",98      },99      body: JSON.stringify({ text }),100    })

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

Section titled “第四步:TUI API 发布界面命令事件”

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

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

packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts:31-44
31    const publishCommand = (command: typeof TuiEvent.CommandExecute.data.Type.command | undefined) =>处理命令执行。32      events.publish(TuiEvent.CommandExecute, { command } as typeof TuiEvent.CommandExecute.data.Type)广播状态变化。3334    const appendPrompt = Effect.fn("TuiHttpApi.appendPrompt")(function* (ctx: {Effect 异步工作流。35      payload: typeof TuiEvent.PromptAppend.data.Type36    }) {37      yield* events.publish(TuiEvent.PromptAppend, ctx.payload)广播状态变化。38      return true返回给上一层。39    })4041    const openHelp = Effect.fn("TuiHttpApi.openHelp")(function* () {Effect 异步工作流。42      yield* publishCommand("help.show")等待 Effect 结果。43      return true返回给上一层。44    })

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

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

Section titled “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. 失败分支:界面必须把断裂显式化

Section titled “8. 失败分支:界面必须把断裂显式化”

至少处理这些失败:

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

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

取舍一:统一 API 增加了一层适配

Section titled “取舍一:统一 API 增加了一层适配”

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

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

Section titled “取舍二:event projection 带来一致性工作”

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

取舍三:TUI 实现可替换,协议应稳定

Section titled “取舍三:TUI 实现可替换,协议应稳定”

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

方法一:把 UI 看成可重建 projection

Section titled “方法一:把 UI 看成可重建 projection”

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

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

Section titled “方法二:为外部集成设计意图级命令”

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

验证问题:更换 TUI 框架后,IDE 集成是否仍可使用同一命令?

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

Section titled “方法三:在 bootstrap 边界解决 replay 竞态”

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

请不用源码回答:

  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 组件。

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

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

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