UI / TUI / Desktop / IDE 相关
eec0843ce422
Agent 生成档案
Section titled “Agent 生成档案”- 章节 ID:
11-ui-tui-desktop-ide - 章节摘要:沿一次 VS Code 选区进入 TUI 的路径,理解 CLI、TUI、Web、Desktop 与 IDE 如何通过命令和事件共享同一套 runtime。
- 教程版本:
eec0843c - 源码基线:
eec0843ce42298080569ca31a6455bc3f699d213 - 章节元数据:/versions/eec0843c/data/chapters.json
- 源码映射:/versions/eec0843c/data/source-map.json
主要源码路径
Section titled “主要源码路径”packages/opencode/src/cli/cmd/run.tspackages/opencode/src/cli/cmd/tui/packages/app/package.jsonpackages/ui/package.jsonpackages/desktop/package.jsonsdks/vscode/package.jsonsdks/vscode/src/extension.ts
源码基线:
eec0843ce422。本章沿着源码中的典型交互路径讲解,不把它冒充成真实运行录屏。
0. 本章学习目标
Section titled “0. 本章学习目标”读完本章,你应该能:
- 画出 CLI、TUI、Web app、Desktop 和 VS Code 与 runtime 的边界。
- 解释为什么这些入口不需要各自实现一次 agent loop。
- 沿着“把 VS Code 当前文件加入提示词”追到 TUI HTTP 入口。
- 区分命令请求、状态事件和界面本地状态。
- 判断新增一种客户端时应该复用 SDK/API,还是直接耦合内部模块。
1. 一句话讲明白
Section titled “1. 一句话讲明白”OpenCode 的多个界面是“不同驾驶舱,共用同一台发动机”:界面负责采集输入、发出命令、订阅事件和呈现审批,session/tool/provider/permission runtime 才负责 agent 行动。
中心问题是:终端、桌面和 IDE 的交互完全不同,它们怎样共享能力,又不把 UI 变成第二套 runtime?
2. 先看位置,而不是组件树
Section titled “2. 先看位置,而不是组件树”CLI non-interactive ------ SDK session.prompt ----+ |TUI ---------------------- SDK + event stream -----+--> Server / Session runtime |Web app ------------------ SDK + HTTP/SSE ----------+ |Desktop ------ Electron shell + @opencode-ai/app --+
VS Code extension -> 创建 opencode terminal -> POST /tui/append-prompt -> TUI prompt state这张图里最重要的不是框架,而是两种方向:
- 命令向内:prompt、abort、permission reply、append prompt。
- 事件向外:message part、tool 状态、permission request、session 状态。
界面可以有自己的路由、store 和渲染生命周期,但不能拥有 agent 的业务真相。
3. 最小机制:一个客户端只需要三件事
Section titled “3. 最小机制:一个客户端只需要三件事”先去掉 Desktop 打包、Solid 响应式和终端渲染,最小界面适配器是:
client = connect(runtime)events = subscribe(client)
onUserSubmit(text): client.session.prompt(text)
for event in events: reduce(uiState, event) render(uiState)CLI 非交互模式正好展示了这个骨架:先订阅 client.event.subscribe(),再调用 client.session.prompt(...),见 packages/opencode/src/cli/cmd/run.ts:768-803。
packages/opencode/src/cli/cmd/run.ts
packages/opencode/src/cli/cmd/run.ts:768-803
768 if (!args.interactive) {区分交互与非交互。769 const events = await client.event.subscribe()订阅运行时事件。770 loop(client, events).catch((e) => {771 console.error(e)772 process.exit(1)773 })774775 if (args.command) {处理命令执行。776 const result = await client.session.command({注册 CLI 子命令。777 sessionID,778 agent,779 model: args.model,选择模型或 provider。780 command: args.command,处理命令执行。781 arguments: message,782 variant: args.variant,783 })784 if (result.error) {按条件进入分支。785 if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error))按条件进入分支。786 process.exitCode = 1787 }788 return返回给上一层。789 }790791 const model = pick(args.model)792 const result = await client.session.prompt({把输入交给会话主流程。793 sessionID,794 agent,795 model,796 variant: args.variant,797 parts: [...files, { type: "text", text: message }],798 })799 if (result.error) {按条件进入分支。800 if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error))按条件进入分支。801 process.exitCode = 1802 }803 return返回给上一层。
OpenCode 产品层再加上:审批回复、中断、重放、路由、窗口生命周期、文件引用和本地 sidecar。
4. 四种“壳”并不相同
Section titled “4. 四种“壳”并不相同”| 入口 | 主要职责 | 与 runtime 的连接 | 不负责什么 |
|---|---|---|---|
| CLI non-interactive | 脚本化输入与输出 | SDK 请求 + 事件订阅 | UI store、agent loop |
| TUI | 持续交互、实时状态、审批 | SDK、事件流或 in-process fetch | provider/tool 决策 |
| Web / Desktop | 图形路由与跨平台桌面外壳 | @opencode-ai/app + SDK/API | 重写 session 语义 |
| VS Code | IDE 命令、当前文件/选区上下文 | terminal + TUI HTTP endpoint | 嵌入完整 agent runtime |
packages/app/package.json:42-47 显示 Web app 直接依赖 workspace 中的 SDK、UI 和 core;packages/desktop/package.json:37-41 显示 Desktop 在开发时复用 @opencode-ai/app 与 @opencode-ai/ui。这是包边界层面的证据。
packages/app/package.json
packages/app/package.json:42-47
42 "dependencies": {运行时依赖。43 "@kobalte/core": "catalog:",44 "@sentry/solid": "catalog:",45 "@opencode-ai/sdk": "workspace:*",46 "@opencode-ai/ui": "workspace:*",47 "@opencode-ai/core": "workspace:*",
packages/desktop/package.json
packages/desktop/package.json:37-41
37 "@actions/artifact": "4.0.0",38 "@lydell/node-pty": "catalog:",39 "@opencode-ai/app": "workspace:*",40 "@opencode-ai/ui": "workspace:*",41 "@sentry/solid": "catalog:",
5. 最小源码路径
Section titled “5. 最小源码路径”按下面顺序读,先获得闭环,再看各端差异:
packages/opencode/src/cli/cmd/run.ts:768-803:最小“订阅事件 + 发 prompt”。
packages/opencode/src/cli/cmd/run.ts
packages/opencode/src/cli/cmd/run.ts:768-803
768 if (!args.interactive) {区分交互与非交互。769 const events = await client.event.subscribe()订阅运行时事件。770 loop(client, events).catch((e) => {771 console.error(e)772 process.exit(1)773 })774775 if (args.command) {处理命令执行。776 const result = await client.session.command({注册 CLI 子命令。777 sessionID,778 agent,779 model: args.model,选择模型或 provider。780 command: args.command,处理命令执行。781 arguments: message,782 variant: args.variant,783 })784 if (result.error) {按条件进入分支。785 if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error))按条件进入分支。786 process.exitCode = 1787 }788 return返回给上一层。789 }790791 const model = pick(args.model)792 const result = await client.session.prompt({把输入交给会话主流程。793 sessionID,794 agent,795 model,796 variant: args.variant,797 parts: [...files, { type: "text", text: message }],798 })799 if (result.error) {按条件进入分支。800 if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error))按条件进入分支。801 process.exitCode = 1802 }803 return返回给上一层。
packages/opencode/src/cli/cmd/run.ts:807-879:interactive attach、本地 in-process 两条路径。
packages/opencode/src/cli/cmd/run.ts
packages/opencode/src/cli/cmd/run.ts:807-879
807 const { runInteractiveMode } = await runtimeTask进入 CLI 主执行路径。808 try {开始保护性执行。809 await runInteractiveMode({进入 CLI 主执行路径。810 sdk: client,811 directory: cwd,812 sessionID,813 sessionTitle: sess.title,814 resume: Boolean(args.session || args.continue) && !args.fork,815 replay,816 replayLimit: args["replay-limit"],817 agent,818 model,819 variant: args.variant,820 files,821 initialInput,822 createSession: createFreshSession,823 thinking,824 demo: args.demo,825 })826 } catch (error) {827 dieInteractive(error)828 }829 return返回给上一层。830 }831832 if (args.interactive && !args.attach && !args.session && !args.continue) {区分交互与非交互。833 const model = pick(args.model)834 const { runInteractiveLocalMode } = await runtimeTask835 const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {836 const { Server } = await import("@/server/server")按需加载模块。837 const request = new Request(input, init)838 return Server.Default().app.fetch(request)复用后端请求入口。839 }) as typeof globalThis.fetch840841 try {开始保护性执行。842 return await runInteractiveLocalMode({返回给上一层。843 directory: directory ?? root,844 fetch: fetchFn,845 resolveAgent: localAgent,846 session,847 share,848 createSession: createFreshSession,849 agent: args.agent,850 model,851 variant: args.variant,852 replay,853 replayLimit: args["replay-limit"],854 files,855 initialInput,856 thinking,857 demo: args.demo,858 })859 } catch (error) {860 dieInteractive(error)861 }862 }863864 if (args.attach) {按条件进入分支。865 const sdk = attachSDK(directory)866 return await execute(sdk)进入 CLI 主执行路径。867 }868869 const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {870 const { Server } = await import("@/server/server")按需加载模块。871 const request = new Request(input, init)872 return Server.Default().app.fetch(request)复用后端请求入口。873 }) as typeof globalThis.fetch874 const sdk = createOpencodeClient({创建 SDK 客户端。875 baseUrl: "http://opencode.internal",876 fetch: fetchFn,877 directory,878 })879 await execute(sdk)进入 CLI 主执行路径。
packages/opencode/src/cli/cmd/tui/context/sdk.tsx:24-97:TUI client 与事件入口。
packages/opencode/src/cli/cmd/tui/context/sdk.tsx
packages/opencode/src/cli/cmd/tui/context/sdk.tsx:24-97
24 function createSDK() {定义一段可复用逻辑。25 return createOpencodeClient({创建 SDK 客户端。26 baseUrl: props.url,27 signal: abort.signal,28 directory: props.directory,29 fetch: props.fetch,30 headers: props.headers,31 })32 }3334 let sdk = createSDK()3536 const emitter = createGlobalEmitter<{读写本地文件。37 event: GlobalEvent读写本地文件。38 }>()3940 let queue: GlobalEvent[] = []读写本地文件。41 let timer: Timer | undefined42 let last = 043 const retryDelay = 100044 const maxRetryDelay = 300004546 const flush = () => {47 if (queue.length === 0) return按条件进入分支。48 const events = queue49 queue = []50 timer = undefined51 last = Date.now()52 // Batch all event emissions so all store updates result in a single render53 batch(() => {54 for (const event of events) {遍历集合。55 emitter.emit("event", event)56 }57 })58 }5960 const handleEvent = (event: GlobalEvent) => {读写本地文件。61 queue.push(event)62 const elapsed = Date.now() - last6364 if (timer) return按条件进入分支。65 // If we just flushed recently (within 16ms), batch this with future events66 // Otherwise, process immediately to avoid latency67 if (elapsed < 16) {按条件进入分支。68 timer = setTimeout(flush, 16)69 return返回给上一层。70 }71 flush()72 }7374 function startSSE() {定义一段可复用逻辑。75 sse?.abort()76 const ctrl = new AbortController()用于中断运行任务。77 sse = ctrl78 ;(async () => {79 let attempt = 080 while (true) {持续循环到退出条件。81 if (abort.signal.aborted || ctrl.signal.aborted) break按条件进入分支。8283 const events = await sdk.global.event({84 signal: ctrl.signal,85 sseMaxRetryAttempts: 0,86 })8788 if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {按条件进入分支。89 // Start syncing workspaces, it's important to do this after90 // we've started listening to events91 await sdk.sync.start().catch(() => {})92 }9394 for await (const event of events.stream) {消费异步流。95 if (ctrl.signal.aborted) break按条件进入分支。96 handleEvent(event)97 }
packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx:73-236:事件如何归并到界面状态。
packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx
packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx:73-236
73 event.subscribe((event) => {订阅运行时事件。74 switch (event.type) {75 case "session.next.prompted": {76 update(event.properties.sessionID, (draft) => {77 draft.unshift({78 id: event.id,79 type: "user",80 text: event.properties.prompt.text,81 files: event.properties.prompt.files,82 agents: event.properties.prompt.agents,83 time: { created: event.properties.timestamp },84 })85 })86 break87 }88 case "session.next.synthetic":89 update(event.properties.sessionID, (draft) => {90 draft.unshift({91 id: event.id,92 type: "synthetic",93 sessionID: event.properties.sessionID,94 text: event.properties.text,95 time: { created: event.properties.timestamp },96 })97 })98 break99 case "session.next.shell.started":处理命令执行。100 update(event.properties.sessionID, (draft) => {101 draft.unshift({102 id: event.id,103 type: "shell",处理命令执行。104 callID: event.properties.callID,105 command: event.properties.command,处理命令执行。106 output: "",107 time: { created: event.properties.timestamp },108 })109 })110 break111 case "session.next.shell.ended":处理命令执行。112 update(event.properties.sessionID, (draft) => {113 const match = activeShell(draft, event.properties.callID)114 if (!match) return按条件进入分支。115 match.output = event.properties.output116 match.time.completed = event.properties.timestamp117 })118 break119 case "session.next.step.started":120 update(event.properties.sessionID, (draft) => {121 const currentAssistant = activeAssistant(draft)122 if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp按条件进入分支。123 draft.unshift({124 id: event.id,125 type: "assistant",126 agent: event.properties.agent,127 model: event.properties.model,选择模型或 provider。128 content: [],129 snapshot: event.properties.snapshot ? { start: event.properties.snapshot } : undefined,130 time: { created: event.properties.timestamp },131 })132 })133 break134 case "session.next.step.ended":135 update(event.properties.sessionID, (draft) => {136 const currentAssistant = activeAssistant(draft)137 if (!currentAssistant) return按条件进入分支。138 currentAssistant.time.completed = event.properties.timestamp139 currentAssistant.finish = event.properties.finish140 currentAssistant.cost = event.properties.cost141 currentAssistant.tokens = event.properties.tokens142 if (event.properties.snapshot)按条件进入分支。143 currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.properties.snapshot }144 })145 break146 case "session.next.step.failed":147 update(event.properties.sessionID, (draft) => {148 const currentAssistant = activeAssistant(draft)149 if (!currentAssistant) return按条件进入分支。150 currentAssistant.time.completed = event.properties.timestamp151 currentAssistant.finish = "error"152 currentAssistant.error = event.properties.error153 })154 break155 case "session.next.text.started":156 update(event.properties.sessionID, (draft) => {157 activeAssistant(draft)?.content.push({ type: "text", text: "" })158 })159 break160 case "session.next.text.delta":161 update(event.properties.sessionID, (draft) => {162 const match = latestText(activeAssistant(draft))163 if (match) match.text += event.properties.delta按条件进入分支。164 })165 break166 case "session.next.text.ended":167 update(event.properties.sessionID, (draft) => {168 const match = latestText(activeAssistant(draft))169 if (match) match.text = event.properties.text按条件进入分支。170 })171 break172 case "session.next.tool.input.started":173 update(event.properties.sessionID, (draft) => {174 activeAssistant(draft)?.content.push({175 type: "tool",176 id: event.properties.callID,177 name: event.properties.name,178 time: { created: event.properties.timestamp },179 state: { status: "pending", input: "" },180 })181 })182 break183 case "session.next.tool.input.delta":184 update(event.properties.sessionID, (draft) => {185 const match = latestTool(activeAssistant(draft), event.properties.callID)186 if (match?.state.status === "pending") match.state.input += event.properties.delta按条件进入分支。187 })188 break189 case "session.next.tool.input.ended":190 break191 case "session.next.tool.called":192 update(event.properties.sessionID, (draft) => {193 const match = latestTool(activeAssistant(draft), event.properties.callID)194 if (!match) return按条件进入分支。195 match.time.ran = event.properties.timestamp196 match.provider = event.properties.provider选择模型或 provider。197 match.state = { status: "running", input: event.properties.input, structured: {}, content: [] }198 })199 break200 case "session.next.tool.progress":201 update(event.properties.sessionID, (draft) => {202 const match = latestTool(activeAssistant(draft), event.properties.callID)203 if (match?.state.status !== "running") return按条件进入分支。204 match.state.structured = event.properties.structured205 match.state.content = [...event.properties.content]206 })207 break208 case "session.next.tool.success":209 update(event.properties.sessionID, (draft) => {210 const match = latestTool(activeAssistant(draft), event.properties.callID)211 if (match?.state.status !== "running") return按条件进入分支。212 match.state = {213 status: "completed",214 input: match.state.input,215 structured: event.properties.structured,216 content: [...event.properties.content],217 }218 match.provider = event.properties.provider选择模型或 provider。219 match.time.completed = event.properties.timestamp220 })221 break222 case "session.next.tool.failed":223 update(event.properties.sessionID, (draft) => {224 const match = latestTool(activeAssistant(draft), event.properties.callID)225 if (match?.state.status !== "running") return按条件进入分支。226 match.state = {227 status: "error",228 error: event.properties.error,229 input: match.state.input,230 structured: match.state.structured,231 content: match.state.content,232 }233 match.provider = event.properties.provider选择模型或 provider。234 match.time.completed = event.properties.timestamp235 })236 break
packages/app/package.json:42-47:Web app 的共享依赖。
packages/app/package.json
packages/app/package.json:42-47
42 "dependencies": {运行时依赖。43 "@kobalte/core": "catalog:",44 "@sentry/solid": "catalog:",45 "@opencode-ai/sdk": "workspace:*",46 "@opencode-ai/ui": "workspace:*",47 "@opencode-ai/core": "workspace:*",
packages/desktop/package.json:12-25、37-41:桌面构建与 app/UI 复用。
packages/desktop/package.json
packages/desktop/package.json:12-25
12 "scripts": {项目脚本入口。13 "typecheck": "tsgo -b",常用工程命令。14 "predev": "bun ./scripts/predev.ts",15 "dev": "electron-vite dev",常用工程命令。16 "prebuild": "bun ./scripts/prebuild.ts",17 "build": "electron-vite build",常用工程命令。18 "preview": "electron-vite preview",19 "package": "electron-builder --config electron-builder.config.ts",20 "package:mac": "electron-builder --mac --config electron-builder.config.ts",21 "package:win": "electron-builder --win --config electron-builder.config.ts",22 "package:linux": "electron-builder --linux --config electron-builder.config.ts",23 "native:build": "bun install --cwd native"24 },25 "main": "./out/main/index.js",
sdks/vscode/src/extension.ts:8-41:IDE 命令与已有 terminal。
sdks/vscode/src/extension.ts
sdks/vscode/src/extension.ts:8-41
8export function activate(context: vscode.ExtensionContext) {对外暴露模块成员。9 const openNewTerminalDisposable = vscode.commands.registerCommand("opencode.openNewTerminal", async () => {处理命令执行。10 await openTerminal()11 })1213 const openTerminalDisposable = vscode.commands.registerCommand("opencode.openTerminal", async () => {处理命令执行。14 // An opencode terminal already exists => focus it15 const existingTerminal = vscode.window.terminals.find((t) => t.name === TERMINAL_NAME)16 if (existingTerminal) {按条件进入分支。17 existingTerminal.show()18 return返回给上一层。19 }2021 await openTerminal()22 })2324 let addFilepathDisposable = vscode.commands.registerCommand("opencode.addFilepathToTerminal", async () => {处理命令执行。25 const fileRef = getActiveFile()26 if (!fileRef) {按条件进入分支。27 return返回给上一层。28 }2930 const terminal = vscode.window.activeTerminal31 if (!terminal) {按条件进入分支。32 return返回给上一层。33 }3435 if (terminal.name === TERMINAL_NAME) {按条件进入分支。36 // @ts-ignore37 const port = terminal.creationOptions.env?.["_EXTENSION_OPENCODE_PORT"]38 port ? await appendPrompt(parseInt(port), fileRef) : terminal.sendText(fileRef, false)39 terminal.show()40 }41 })
sdks/vscode/src/extension.ts:45-100:启动 OpenCode、探活、追加 prompt。
sdks/vscode/src/extension.ts
sdks/vscode/src/extension.ts:45-100
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 })
sdks/vscode/src/extension.ts:103-136:把文件和选区编码成@file#Lx-Ly。
sdks/vscode/src/extension.ts
sdks/vscode/src/extension.ts:103-136
103 function getActiveFile() {定义一段可复用逻辑。104 const activeEditor = vscode.window.activeTextEditor105 if (!activeEditor) {按条件进入分支。106 return返回给上一层。107 }108109 const document = activeEditor.document110 const workspaceFolder = vscode.workspace.getWorkspaceFolder(document.uri)111 if (!workspaceFolder) {按条件进入分支。112 return返回给上一层。113 }114115 // Get the relative path from workspace root116 const relativePath = vscode.workspace.asRelativePath(document.uri)117 let filepathWithAt = `@${relativePath}`118119 // Check if there's a selection and add line numbers120 const selection = activeEditor.selection121 if (!selection.isEmpty) {按条件进入分支。122 // Convert to 1-based line numbers123 const startLine = selection.start.line + 1124 const endLine = selection.end.line + 1125126 if (startLine === endLine) {按条件进入分支。127 // Single line selection128 filepathWithAt += `#L${startLine}`129 } else {130 // Multi-line selection131 filepathWithAt += `#L${startLine}-${endLine}`132 }133 }134135 return filepathWithAt返回给上一层。136 }
6. 一条具体源码旅程:把 VS Code 选区交给 OpenCode
Section titled “6. 一条具体源码旅程:把 VS Code 选区交给 OpenCode”假设用户在 VS Code 选中 src/order.ts 的第 20–28 行,然后执行“Add Filepath to Terminal”。以下是源码证明的典型路径。
第一步:扩展只采集 IDE 上下文
Section titled “第一步:扩展只采集 IDE 上下文”getActiveFile() 读取 active editor,要求文件属于 workspace,然后生成相对引用:
@src/order.ts#L20-L28对应 sdks/vscode/src/extension.ts:103-136。扩展没有读取文件内容,也没有推理“这段代码是什么意思”;它只把 IDE 独有状态翻译成 OpenCode 能理解的引用。
sdks/vscode/src/extension.ts
sdks/vscode/src/extension.ts:103-136
103 function getActiveFile() {定义一段可复用逻辑。104 const activeEditor = vscode.window.activeTextEditor105 if (!activeEditor) {按条件进入分支。106 return返回给上一层。107 }108109 const document = activeEditor.document110 const workspaceFolder = vscode.workspace.getWorkspaceFolder(document.uri)111 if (!workspaceFolder) {按条件进入分支。112 return返回给上一层。113 }114115 // Get the relative path from workspace root116 const relativePath = vscode.workspace.asRelativePath(document.uri)117 let filepathWithAt = `@${relativePath}`118119 // Check if there's a selection and add line numbers120 const selection = activeEditor.selection121 if (!selection.isEmpty) {按条件进入分支。122 // Convert to 1-based line numbers123 const startLine = selection.start.line + 1124 const endLine = selection.end.line + 1125126 if (startLine === endLine) {按条件进入分支。127 // Single line selection128 filepathWithAt += `#L${startLine}`129 } else {130 // Multi-line selection131 filepathWithAt += `#L${startLine}-${endLine}`132 }133 }134135 return filepathWithAt返回给上一层。136 }
第二步:复用或创建 terminal
Section titled “第二步:复用或创建 terminal”opencode.openTerminal 会先找名为 opencode 的 terminal;存在就聚焦,不存在才新建,见 sdks/vscode/src/extension.ts:8-22。
sdks/vscode/src/extension.ts
sdks/vscode/src/extension.ts:8-22
8export function activate(context: vscode.ExtensionContext) {对外暴露模块成员。9 const openNewTerminalDisposable = vscode.commands.registerCommand("opencode.openNewTerminal", async () => {处理命令执行。10 await openTerminal()11 })1213 const openTerminalDisposable = vscode.commands.registerCommand("opencode.openTerminal", async () => {处理命令执行。14 // An opencode terminal already exists => focus it15 const existingTerminal = vscode.window.terminals.find((t) => t.name === TERMINAL_NAME)16 if (existingTerminal) {按条件进入分支。17 existingTerminal.show()18 return返回给上一层。19 }2021 await openTerminal()22 })
新 terminal 会得到随机端口和两个环境变量,再发送:
opencode --port <port>证据在 sdks/vscode/src/extension.ts:45-65。这里的 OPENCODE_CALLER=vscode 是调用方标记,不等于 IDE 内嵌了一套 runtime。
sdks/vscode/src/extension.ts
sdks/vscode/src/extension.ts:45-65
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}`)
第三步:先探活,再追加提示词
Section titled “第三步:先探活,再追加提示词”扩展最多尝试十次访问 /app,每次间隔 200ms;连接成功才调用 appendPrompt,见 sdks/vscode/src/extension.ts:72-91。
sdks/vscode/src/extension.ts
sdks/vscode/src/extension.ts:72-91
72 // 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 }
appendPrompt 发出:
POST /tui/append-promptContent-Type: application/json
{ "text": "In @src/order.ts#L20-L28" }对应 sdks/vscode/src/extension.ts:93-100。
sdks/vscode/src/extension.ts
sdks/vscode/src/extension.ts:93-100
93 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 })
这里有一个可靠性边界:十次探活失败后,源码不会继续 POST,但也没有在这段函数中向用户呈现详细错误。新增 IDE 客户端时,应明确连接失败如何反馈,而不是静默丢失上下文。
第四步:TUI 接收的是“编辑提示词”命令
Section titled “第四步:TUI 接收的是“编辑提示词”命令”扩展调用的是 /tui/append-prompt,不是 session prompt endpoint。这表示它只把文本放进 TUI 输入区,仍由用户决定何时提交。
这是从 endpoint 命名和扩展调用路径得到的设计解释;是否立即触发 agent,应以 TUI route handler 为准,不能从 VS Code 代码臆测。
第五步:真正提交后回到公共 session 边界
Section titled “第五步:真正提交后回到公共 session 边界”一旦 TUI 提交,界面通过 SDK 与后端交互;run.ts 的非交互路径清楚证明公共边界是 session API,而不是 UI 直接调用 provider,见 packages/opencode/src/cli/cmd/run.ts:768-803。
packages/opencode/src/cli/cmd/run.ts
packages/opencode/src/cli/cmd/run.ts:768-803
768 if (!args.interactive) {区分交互与非交互。769 const events = await client.event.subscribe()订阅运行时事件。770 loop(client, events).catch((e) => {771 console.error(e)772 process.exit(1)773 })774775 if (args.command) {处理命令执行。776 const result = await client.session.command({注册 CLI 子命令。777 sessionID,778 agent,779 model: args.model,选择模型或 provider。780 command: args.command,处理命令执行。781 arguments: message,782 variant: args.variant,783 })784 if (result.error) {按条件进入分支。785 if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error))按条件进入分支。786 process.exitCode = 1787 }788 return返回给上一层。789 }790791 const model = pick(args.model)792 const result = await client.session.prompt({把输入交给会话主流程。793 sessionID,794 agent,795 model,796 variant: args.variant,797 parts: [...files, { type: "text", text: message }],798 })799 if (result.error) {按条件进入分支。800 if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error))按条件进入分支。801 process.exitCode = 1802 }803 return返回给上一层。
这条旅程说明 IDE 扩展应该是“上下文适配器”,而不是“agent 的 IDE 分叉版”。
7. TUI 为什么需要事件归并
Section titled “7. TUI 为什么需要事件归并”TUI SDK context 用 createOpencodeClient 创建 client,传入 base URL、directory、fetch 与 headers,见 packages/opencode/src/cli/cmd/tui/context/sdk.tsx:24-31。
packages/opencode/src/cli/cmd/tui/context/sdk.tsx
packages/opencode/src/cli/cmd/tui/context/sdk.tsx:24-31
24 function createSDK() {定义一段可复用逻辑。25 return createOpencodeClient({创建 SDK 客户端。26 baseUrl: props.url,27 signal: abort.signal,28 directory: props.directory,29 fetch: props.fetch,30 headers: props.headers,31 })
没有外部 event source 时,它调用 global event stream,并通过 async iterator 消费:packages/opencode/src/cli/cmd/tui/context/sdk.tsx:83-97。
packages/opencode/src/cli/cmd/tui/context/sdk.tsx
packages/opencode/src/cli/cmd/tui/context/sdk.tsx:83-97
83 const events = await sdk.global.event({84 signal: ctrl.signal,85 sseMaxRetryAttempts: 0,86 })8788 if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {按条件进入分支。89 // Start syncing workspaces, it's important to do this after90 // we've started listening to events91 await sdk.sync.start().catch(() => {})92 }9394 for await (const event of events.stream) {消费异步流。95 if (ctrl.signal.aborted) break按条件进入分支。96 handleEvent(event)97 }
事件可能非常密集。源码会先排队,再在 Solid batch 中发给订阅者,避免每个 token/part 都引发独立渲染,见 packages/opencode/src/cli/cmd/tui/context/sdk.tsx:42-73。
packages/opencode/src/cli/cmd/tui/context/sdk.tsx
packages/opencode/src/cli/cmd/tui/context/sdk.tsx:42-73
42 let last = 043 const retryDelay = 100044 const maxRetryDelay = 300004546 const flush = () => {47 if (queue.length === 0) return按条件进入分支。48 const events = queue49 queue = []50 timer = undefined51 last = Date.now()52 // Batch all event emissions so all store updates result in a single render53 batch(() => {54 for (const event of events) {遍历集合。55 emitter.emit("event", event)56 }57 })58 }5960 const handleEvent = (event: GlobalEvent) => {读写本地文件。61 queue.push(event)62 const elapsed = Date.now() - last6364 if (timer) return按条件进入分支。65 // If we just flushed recently (within 16ms), batch this with future events66 // Otherwise, process immediately to avoid latency67 if (elapsed < 16) {按条件进入分支。68 timer = setTimeout(flush, 16)69 return返回给上一层。70 }71 flush()72 }73
这不是 agent 内核,而是产品层的背压策略:
runtime event burst -> queue -> batch -> sync reducer -> minimum necessary renderJava 开发者可以类比 WebFlux 消费 SSE 后批量更新 projection,但不要把 Solid store 当成后端事务;它只是可重建的界面投影。
8. 本地模式为什么仍走“HTTP 形状”
Section titled “8. 本地模式为什么仍走“HTTP 形状””交互本地模式把 SDK 的 fetch 指向 Server.Default().app.fetch(request),见 packages/opencode/src/cli/cmd/run.ts:832-879。
packages/opencode/src/cli/cmd/run.ts
packages/opencode/src/cli/cmd/run.ts:832-879
832 if (args.interactive && !args.attach && !args.session && !args.continue) {区分交互与非交互。833 const model = pick(args.model)834 const { runInteractiveLocalMode } = await runtimeTask835 const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {836 const { Server } = await import("@/server/server")按需加载模块。837 const request = new Request(input, init)838 return Server.Default().app.fetch(request)复用后端请求入口。839 }) as typeof globalThis.fetch840841 try {开始保护性执行。842 return await runInteractiveLocalMode({返回给上一层。843 directory: directory ?? root,844 fetch: fetchFn,845 resolveAgent: localAgent,846 session,847 share,848 createSession: createFreshSession,849 agent: args.agent,850 model,851 variant: args.variant,852 replay,853 replayLimit: args["replay-limit"],854 files,855 initialInput,856 thinking,857 demo: args.demo,858 })859 } catch (error) {860 dieInteractive(error)861 }862 }863864 if (args.attach) {按条件进入分支。865 const sdk = attachSDK(directory)866 return await execute(sdk)进入 CLI 主执行路径。867 }868869 const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {870 const { Server } = await import("@/server/server")按需加载模块。871 const request = new Request(input, init)872 return Server.Default().app.fetch(request)复用后端请求入口。873 }) as typeof globalThis.fetch874 const sdk = createOpencodeClient({创建 SDK 客户端。875 baseUrl: "http://opencode.internal",876 fetch: fetchFn,877 directory,878 })879 await execute(sdk)进入 CLI 主执行路径。
也就是说,请求不一定经过真实监听端口,但仍穿过相同 handler 契约:
SDK request -> custom in-process fetch -> Server app handler -> runtime service取舍:
- 好处:本地模式少一个端口和网络生命周期,API 语义仍可复用。
- 代价:in-process 与真实 HTTP 在代理、网络失败、跨进程隔离上并不等价,测试不能完全互相替代。
9. Web、Desktop 与 UI package 的边界证据
Section titled “9. Web、Desktop 与 UI package 的边界证据”本章 metadata 只把三个 package manifest 列为核心证据,因此这里不推断未列出的 Desktop 启动细节。
可以确认的是:
-
@opencode-ai/app导出 app 源入口,并依赖 SDK、UI、core,见packages/app/package.json:6-9、42-47。packages/app/package.json
packages/app/package.json:6-96 "exports": {包对外暴露入口。7 ".": "./src/index.ts",8 "./vite": "./vite.js",9 "./index.css": "./src/index.css" -
@opencode-ai/ui公开组件、hooks、context、styles、theme 等细粒度入口,见packages/ui/package.json:6-25。packages/ui/package.json
packages/ui/package.json:6-256 "exports": {包对外暴露入口。7 "./package.json": "./package.json",8 "./*": "./src/components/*.tsx",9 "./session-diff": "./src/components/session-diff.ts",10 "./i18n/*": "./src/i18n/*.ts",11 "./pierre": "./src/pierre/index.ts",12 "./pierre/*": "./src/pierre/*.ts",13 "./hooks": "./src/hooks/index.ts",14 "./context": "./src/context/index.ts",15 "./context/*": "./src/context/*.tsx",16 "./styles": "./src/styles/index.css",17 "./styles/tailwind": "./src/styles/tailwind/index.css",18 "./theme": "./src/theme/index.ts",19 "./theme/*": "./src/theme/*.ts",20 "./theme/context": "./src/theme/context.tsx",21 "./icons/provider": "./src/components/provider-icons/types.ts",22 "./icons/file-type": "./src/components/file-icons/types.ts",23 "./icons/app": "./src/components/app-icons/types.ts",24 "./fonts/*": "./src/assets/fonts/*",25 "./audio/*": "./src/assets/audio/*" -
Desktop 的 main 指向 Electron 构建产物,并在 dev dependencies 复用 app/UI,见
packages/desktop/package.json:12-25、37-41。packages/desktop/package.json
packages/desktop/package.json:12-2512 "scripts": {项目脚本入口。13 "typecheck": "tsgo -b",常用工程命令。14 "predev": "bun ./scripts/predev.ts",15 "dev": "electron-vite dev",常用工程命令。16 "prebuild": "bun ./scripts/prebuild.ts",17 "build": "electron-vite build",常用工程命令。18 "preview": "electron-vite preview",19 "package": "electron-builder --config electron-builder.config.ts",20 "package:mac": "electron-builder --mac --config electron-builder.config.ts",21 "package:win": "electron-builder --win --config electron-builder.config.ts",22 "package:linux": "electron-builder --linux --config electron-builder.config.ts",23 "native:build": "bun install --cwd native"24 },25 "main": "./out/main/index.js",
因此,“Desktop 复用 Web app/UI 包”是有源码依据的;“Desktop 在某处以某参数启动 sidecar”若未继续检查具体 main/server 文件,就不应在本章写成已证明事实。
10. OpenCode 的选择
Section titled “10. OpenCode 的选择”| 选择 | 好处 | 代价 |
|---|---|---|
| UI 统一走 SDK/API | 多入口共享行为,易做自动化客户端 | API 演进必须兼顾多个消费者 |
| 请求与事件分离 | 命令简单,长任务可持续更新 | 前端要处理乱序、重连与投影一致性 |
| 本地 in-process fetch | 复用 handler,又省去监听端口 | 与真实网络路径仍有差异 |
| Desktop 复用 app/UI package | 减少图形界面重复 | 桌面能力需通过清晰 adapter 注入 |
| VS Code 只做 terminal/context bridge | 扩展薄、维护成本低 | 原生 IDE 体验与错误反馈受限 |
11. 可以带走的方法
Section titled “11. 可以带走的方法”方法一:让 UI 保存投影,不保存业务真相
Section titled “方法一:让 UI 保存投影,不保存业务真相”session message 和 permission 状态来自 runtime;UI store 只负责为了展示而索引、排序、折叠。
验证问题:刷新或换一个客户端后,是否能从 server 状态重建界面?
方法二:把平台特有信息翻译成通用输入
Section titled “方法二:把平台特有信息翻译成通用输入”VS Code 知道当前文件和选区,runtime 知道 @file#Lx-Ly。适配层只做翻译。
验证问题:去掉 IDE API 后,agent loop 是否仍能处理同样的文件引用?
方法三:命令与事件分别设计失败语义
Section titled “方法三:命令与事件分别设计失败语义”命令需要确认接收或拒绝;事件流需要重连、去重和最终状态同步。不要用“已经发出请求”代替“界面最终一致”。
验证问题:事件断线后,客户端怎样发现自己漏了状态?
12. 费曼复述与练习
Section titled “12. 费曼复述与练习”请用自己的话回答:
- 为什么 TUI 不应该直接 import provider 并调用模型?
- in-process fetch 与 HTTP 请求相同和不同的部分各是什么?
- VS Code extension 为什么调用 append prompt,而不是直接发 session prompt?
练习阶梯:
-
入门:给地图中的每条箭头标上 request 或 event。
-
进阶:写一个只支持
submit()、subscribe()、abort()的客户端接口。 -
源码追踪:从
sdks/vscode/src/extension.ts:103走到:100,解释文件引用为何先经过 terminal 探活。sdks/vscode/src/extension.ts
sdks/vscode/src/extension.ts:103103 function getActiveFile() {定义一段可复用逻辑。 -
迁移:设计一个 JetBrains 插件,只列出 IDE adapter 必须承担的职责。
最后复盘:壳可以变,协议边界不能漂
Section titled “最后复盘:壳可以变,协议边界不能漂”用户界面采集意图 -> SDK/API 发命令 -> runtime 产生状态与事件 -> 客户端归并为视图下一章会把这条“公共边界”展开:typed HTTP API 怎样变成 generated SDK,插件又怎样在受控 hook 点扩展行为,而不靠 monkey patch 侵入 runtime。