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-879、packages/opencode/src/cli/cmd/tui/context/sdk.tsx:24-40、packages/app/src/app.tsx:295-329。
2-5. 它的位置与最小源码路径
源码路径卡片
cli/cmd/run.ts:768-879:non-interactive、interactive、local in-process、attach。cli/cmd/run/runtime.ts:1-15:interactive runtime 顶层职责。tui/context/sdk.tsx:24-124:TUI SDK 和事件流。tui/context/sync-v2.tsx:73-236:事件同步 UI store。packages/app/src/app.tsx:295-329:Web app provider/router 外壳。desktop/src/main/index.ts:258-345:Desktop sidecar 和窗口。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. 费曼复述区
- 为什么 UI 层不应该重写 agent loop?
- TUI 如何同步 tool 状态?
- Desktop sidecar 解决了什么问题?
- 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 卡顿,源码里有哪些事件合并思路可借鉴?