跳转到内容

UI / TUI / Desktop / IDE 相关

旧版 eec0843c 源码提交 eec0843ce422
状态已完成
难度中等到较难
预计阅读35 分钟
  • 章节 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
  • packages/opencode/src/cli/cmd/run.ts
  • packages/opencode/src/cli/cmd/tui/
  • packages/app/package.json
  • packages/ui/package.json
  • packages/desktop/package.json
  • sdks/vscode/package.json
  • sdks/vscode/src/extension.ts

源码基线:eec0843ce422。本章沿着源码中的典型交互路径讲解,不把它冒充成真实运行录屏。

读完本章,你应该能:

  • 画出 CLI、TUI、Web app、Desktop 和 VS Code 与 runtime 的边界。
  • 解释为什么这些入口不需要各自实现一次 agent loop。
  • 沿着“把 VS Code 当前文件加入提示词”追到 TUI HTTP 入口。
  • 区分命令请求、状态事件和界面本地状态。
  • 判断新增一种客户端时应该复用 SDK/API,还是直接耦合内部模块。

OpenCode 的多个界面是“不同驾驶舱,共用同一台发动机”:界面负责采集输入、发出命令、订阅事件和呈现审批,session/tool/provider/permission runtime 才负责 agent 行动。

中心问题是:终端、桌面和 IDE 的交互完全不同,它们怎样共享能力,又不把 UI 变成第二套 runtime?

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。

入口主要职责与 runtime 的连接不负责什么
CLI non-interactive脚本化输入与输出SDK 请求 + 事件订阅UI store、agent loop
TUI持续交互、实时状态、审批SDK、事件流或 in-process fetchprovider/tool 决策
Web / Desktop图形路由与跨平台桌面外壳@opencode-ai/app + SDK/API重写 session 语义
VS CodeIDE 命令、当前文件/选区上下文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:",

按下面顺序读,先获得闭环,再看各端差异:

  1. 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返回给上一层。
  1. 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 主执行路径。
  1. 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          }
  1. 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
  1. 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:*",
  1. packages/desktop/package.json:12-2537-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",
  1. 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  })
  1. 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    })
  1. 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”。以下是源码证明的典型路径。

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  }

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-prompt
Content-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 分叉版”。

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 render

Java 开发者可以类比 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-942-47

    packages/app/package.json packages/app/package.json:6-9
    6  "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-25
    6  "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-2537-41

    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",

因此,“Desktop 复用 Web app/UI 包”是有源码依据的;“Desktop 在某处以某参数启动 sidecar”若未继续检查具体 main/server 文件,就不应在本章写成已证明事实。

选择好处代价
UI 统一走 SDK/API多入口共享行为,易做自动化客户端API 演进必须兼顾多个消费者
请求与事件分离命令简单,长任务可持续更新前端要处理乱序、重连与投影一致性
本地 in-process fetch复用 handler,又省去监听端口与真实网络路径仍有差异
Desktop 复用 app/UI package减少图形界面重复桌面能力需通过清晰 adapter 注入
VS Code 只做 terminal/context bridge扩展薄、维护成本低原生 IDE 体验与错误反馈受限

方法一:让 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 “方法三:命令与事件分别设计失败语义”

命令需要确认接收或拒绝;事件流需要重连、去重和最终状态同步。不要用“已经发出请求”代替“界面最终一致”。

验证问题:事件断线后,客户端怎样发现自己漏了状态?

请用自己的话回答:

  1. 为什么 TUI 不应该直接 import provider 并调用模型?
  2. in-process fetch 与 HTTP 请求相同和不同的部分各是什么?
  3. 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:103
    103  function getActiveFile() {定义一段可复用逻辑。
  • 迁移:设计一个 JetBrains 插件,只列出 IDE adapter 必须承担的职责。

最后复盘:壳可以变,协议边界不能漂

Section titled “最后复盘:壳可以变,协议边界不能漂”
用户界面采集意图
-> SDK/API 发命令
-> runtime 产生状态与事件
-> 客户端归并为视图

下一章会把这条“公共边界”展开:typed HTTP API 怎样变成 generated SDK,插件又怎样在受控 hook 点扩展行为,而不靠 monkey patch 侵入 runtime。