0-1. 学习目标与一句话

UI/TUI/Desktop/IDE 层是多种壳,共用一个 runtime。CLI、TUI、Web、Desktop、VS Code 都通过 SDK、HTTP API、SSE event stream 或 sidecar 连接后端;UI 负责输入、展示、同步和审批,不重新实现 agent loop。

来源:packages/opencode/src/cli/cmd/run.ts:768-879packages/opencode/src/cli/cmd/tui/context/sdk.tsx:24-40packages/app/src/app.tsx:295-329

2-5. 它的位置与最小源码路径

源码路径卡片

  1. cli/cmd/run.ts:768-879:non-interactive、interactive、local in-process、attach。
  2. cli/cmd/run/runtime.ts:1-15:interactive runtime 顶层职责。
  3. tui/context/sdk.tsx:24-124:TUI SDK 和事件流。
  4. tui/context/sync-v2.tsx:73-236:事件同步 UI store。
  5. packages/app/src/app.tsx:295-329:Web app provider/router 外壳。
  6. desktop/src/main/index.ts:258-345:Desktop sidecar 和窗口。
  7. sdks/vscode/src/extension.ts:45-100:VS Code terminal 和 append prompt。

6. 用户输入到 agent 行动的整体链路

CLI/TUI/Web/Desktop/IDE
  -> SDK / HTTP API
  -> session.prompt / session.command / permission.reply / session.abort
  -> backend session/tool/provider runtime
  -> Bus events / SSE
  -> UI sync store
  -> render text/tool/permission/session state

Java 类比

UI 像多个 client;SDK 像 OpenFeign/WebClient;SSE 像 WebFlux Flux<Event>;TUI sync context 像 Redux reducer;Desktop sidecar 像 Electron 启动本地 Spring Boot 服务。

7. 核心源码逐段讲解

CLI non-interactive

const events = await client.event.subscribe()
loop(client, events).catch(...)

const result = await client.session.prompt({
  sessionID,
  agent,
  model,
  variant: args.variant,
  parts: [...files, { type: "text", text: message }],
})

路径:packages/opencode/src/cli/cmd/run.ts:768-803

本地 in-process fetch

const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
  const { Server } = await import("@/server/server")
  const request = new Request(input, init)
  return Server.Default().app.fetch(request)
}) as typeof globalThis.fetch

路径:packages/opencode/src/cli/cmd/run.ts:834-839

TUI SDK event stream

const events = await sdk.global.event({
  signal: ctrl.signal,
  sseMaxRetryAttempts: 0,
})

for await (const event of events.stream) {
  if (ctrl.signal.aborted) break
  handleEvent(event)
}

路径:packages/opencode/src/cli/cmd/tui/context/sdk.tsx:83-97

TUI tool 状态同步

case "session.next.tool.called":
  update(event.properties.sessionID, (draft) => {
    const match = latestTool(activeAssistant(draft), event.properties.callID)
    if (!match) return
    match.state = { status: "running", input: event.properties.input, structured: {}, content: [] }
  })
  break
case "session.next.tool.success":
  update(event.properties.sessionID, (draft) => {
    const match = latestTool(activeAssistant(draft), event.properties.callID)
    if (match?.state.status !== "running") return
    match.state = { status: "completed", input: match.state.input, structured: event.properties.structured, content: [...event.properties.content] }
  })

路径:packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx:191-220

Web AppInterface

<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
  <ConnectionGate>
    <GlobalSDKProvider>
      <GlobalSyncProvider>
        <Route path="/:dir" component={DirectoryLayout}>
          <Route path="/session/:id?" component={SessionRoute} />
        </Route>
      </GlobalSyncProvider>
    </GlobalSDKProvider>
  </ConnectionGate>
</ServerProvider>

路径:packages/app/src/app.tsx:295-329

Desktop sidecar

const hostname = "127.0.0.1"
const url = `http://${hostname}:${port}`
const password = randomUUID()

const { listener, health } = yield* Effect.promise(() =>
  spawnLocalServer(hostname, port, password, {
    needsMigration,
    userDataPath: app.getPath("userData"),
  }),
)
server = listener
yield* Deferred.succeed(serverReady, { url, username: "opencode", password })

路径:packages/desktop/src/main/index.ts:281-313

VS Code extension

terminal.sendText(`opencode --port ${port}`)

await fetch(`http://localhost:${port}/tui/append-prompt`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ text }),
})

路径:sdks/vscode/src/extension.ts:64-100

8. 关键 TypeScript 语法复习

  • TSX:组件树本质是函数调用和 props。
  • as const:让命令数组成为 literal union。
  • Accessor<string>:Solid signal getter,类似 Supplier<String>
  • discriminated union:ServerConnection.Any = Http | Sidecar | Ssh
  • for await:消费 async iterator 事件流。
  • createMemo/createEffect/onCleanup:Solid 响应式生命周期。

9-10. 架构思想与模块协作

  • Thin client:UI 不重写 agent loop。
  • Shared runtime:所有界面共享后端 session/tool/provider/permission。
  • Event-sourced UI state:根据事件 reducer 出消息状态。
  • Adapter:Desktop sidecar、VS Code terminal、Web HTTP 都是 runtime 适配器。
  • Backpressure/coalescing:高频事件批处理,降低渲染压力。

11. mini agent 对应代码

for await (const event of client.events()) {
  renderEvent(event)
  if (event.type === "permission.asked") {
    const reply = await promptUser(event)
    await client.permission.reply(reply)
  }
}

await client.session.prompt({ text })

12. 费曼复述区

  1. 为什么 UI 层不应该重写 agent loop?
  2. TUI 如何同步 tool 状态?
  3. Desktop sidecar 解决了什么问题?
  4. VS Code extension 为什么只开 terminal?

13-15. 练习与自测

  • 入门:找出 run.ts 的 non-interactive 和 interactive 分支。
  • 进阶:读 sync-v2,列出 tool 事件如何更新 store。
  • 源码追踪:从 permission.asked 追到 footer 的 onPermissionReply。
  • 小实现:写一个 mini event store,支持 text/tool/permission 事件。
  • 自测:如果 UI 卡顿,源码里有哪些事件合并思路可借鉴?

16. 下一步阅读建议

继续读 “SDK / API / 对外扩展点”。UI 章已经看到所有界面都依赖 SDK/API;下一章看 API 如何定义、组合、生成和扩展。