第 19 章:终端界面与客户端架构
终端 AI Agent 的界面设计面临一个根本张力:终端的字符网格天然适合"纯文本 in / 纯文本 out",但 Agent 的输出是流式 Markdown、工具调用摘要、权限请求等富结构内容——如何在 80×24 的约束下提供接近 IDE 的交互体验,同时保持终端原生的键盘驱动效率,是本章的核心问题。
19.1 TUI 设计哲学
极简与极致的共存
终端 AI Agent 的界面设计存在两个极端:
- 极简派——不做任何 TUI 框架,直接用 ANSI escape code 在 stdout 上打字。输入是 readline,输出是滚动文本流。
- 极致派——用 ratatui / Ink 等框架搭建完整 TUI,支持面板分割、弹窗、fuzzy search、语法高亮、流式 Markdown 渲染。
OpenCode 的创始人明确说过:"built by neovim users... push the limits of what's possible in the terminal"。这不是一句空话——它意味着 TUI 不是 GUI 的降级替代,而是一等公民:键盘快捷键覆盖所有操作,鼠标是可选的补充。

极简 CLI 交互:mini-codex 的设计
并非所有场景都需要全功能 TUI。极简 CLI 的设计哲学是零依赖、零学习成本:用户输入一行文本,Agent 输出一段文本,中间穿插工具调用的审批提示。整个 UI 层不到 250 行 Rust 代码。
极简 CLI 的核心 UI 元素只有四个:
| 元素 | 实现方式 | 作用 |
|---|---|---|
| 状态栏 | ANSI dim 文本 | 显示 workspace、model、token 用量 |
| 角色前缀 | 彩色 role> 标记 | 区分 user / assistant / tool 输出 |
| 审批提示 | 阻塞式 stdin 读取 | [y]es / [n]o / [a]uto 三选一 |
| 等待动画 | 后台线程旋转字符 | |/-\ 四帧 spinner,120ms 间隔 |
输出截断策略体现了极简设计的务实取舍:预览最多 10 行 / 1500 字符,超出部分折叠但完整保留在会话历史中。这个设计点很重要——截断只影响显示,不影响 Agent 的上下文。
fn fold_lines_for_display(text: &str, max_lines: usize) -> String {
let lines = text.lines().collect::<Vec<_>>();
let clipped_lines = lines.iter().take(max_lines).copied()
.collect::<Vec<_>>().join("\n");
let mut preview = if clipped_lines.chars().count() > MAX_PREVIEW_CHARS {
clipped_lines.chars().take(MAX_PREVIEW_CHARS).collect()
} else { clipped_lines };
if lines.len() > max_lines || text.chars().count() > preview.chars().count() {
preview.push_str("[... folded in terminal view; full output kept in session history ...]");
}
preview
}Spinner 的实现揭示了终端 UI 的一个基本约束:你不能在 async 等待的同时更新屏幕。解决方案是启动一个独立线程做旋转动画,通过 AtomicBool 信号停止:
let handle = thread::spawn(move || {
let frames = ["|", "/", "-", "\\"];
let mut index = 0usize;
while !signal.load(Ordering::Relaxed) {
print!("\rworking{} waiting for model response", frames[index % frames.len()]);
io::stdout().flush().ok();
thread::sleep(Duration::from_millis(120));
index += 1;
}
print!("\r{}\r", " ".repeat(64)); // 清除 spinner 行
});这种"独立线程 + 原子信号"模式是终端 spinner 的标准做法。\r 回到行首覆盖写入,停止时用空格清除残留字符。
19.2 流式渲染与交互设计
流式 Markdown 渲染
LLM 的输出是 token-by-token 到达的。用户期望看到实时打字效果,但 Markdown 的语法结构(代码块、列表、表格)要求"看到完整结构才能正确渲染"。这产生了一个根本矛盾:渲染的正确性需要完整输入,但用户体验需要即时反馈。
Codex TUI 的解决方案是 MarkdownStreamCollector——一个基于换行符的增量渲染器:
核心策略:只渲染已完成的逻辑行。收到每个 delta token 后追加到内部 buffer,然后找到最后一个换行符位置,只渲染换行符之前的内容。尚未以换行符结尾的末行被视为"不完整",暂不渲染。
Token 流: "## H" → "ello\n" → "- it" → "em 1\n" → "- item" → " 2\n"
↑ 提交渲染 ↑ 提交渲染 ↑ 提交渲染commit_complete_lines() 的逻辑:
- 在 buffer 中找到最后一个
\n的位置 - 对
\n之前的全部内容调用 Markdown 渲染器(pulldown-cmark) - 比对上次已提交的行数,只返回新增的行
- 更新已提交行数计数器
流结束时调用 finalize_and_drain():如果 buffer 末尾没有换行符,强制追加一个,然后渲染并返回所有未提交的行。
这个方案的精妙之处在于重复渲染但只取增量。每次 commit 都从头渲染整个 buffer(而不是试图增量解析 Markdown),但只返回新增的行。这避免了增量 Markdown 解析的复杂性——Markdown 的上下文敏感语法(如多行代码块的 ``` 配对)让真正的增量解析极其困难。
渲染器本身使用 pulldown-cmark 解析 Markdown AST,然后转换为 ratatui 的 Line<'static> / Span<'static> 结构。对不同元素应用不同样式:
| Markdown 元素 | 终端样式 |
|---|---|
# H1 | bold + underline |
## H2 | bold |
### H3 | bold + italic |
`code` | cyan |
**strong** | bold |
*emphasis* | italic |
[link](url) | cyan + underline |
> blockquote | green |
代码块使用 highlight_code_to_lines 做语法高亮,根据语言标识(```python 等)选择对应的高亮规则。
输入编辑器
终端输入编辑器要解决的核心问题:如何在一个字符网格中实现多行编辑、历史回溯、自动补全三件事。
多行输入。传统 CLI 用 Enter 提交,但 Agent 场景中用户经常需要粘贴多行代码或写多段指令。Codex TUI 的解决方案:Enter 提交,Alt+Enter(或配置的快捷键)插入换行。OpenCode TUI 的 textarea 支持完整的编辑操作集:
submit | newline | move-left/right/up/down | select-* |
line-home/end | buffer-home/end | delete-line |
delete-to-line-end/start | backspace/delete |
undo/redo | word-forward/backward | select-word-* |
delete-word-forward/backward所有快捷键通过配置文件映射,支持 Ctrl / Meta / Shift / Super 修饰键组合。
历史回溯。Codex TUI 维护双层历史:
- 持久化历史:跨会话,存储在
~/.codex/history.jsonl,仅保存纯文本 - 本地历史:当前会话,保存完整提交状态(文本、text elements、图片附件、粘贴占位符)
Up/Down 箭头在两层历史中导航。设计约束:当编辑区有非空内容时,只有在内容与上次回溯条目匹配且光标在边界位置时,才将 Up/Down 视为历史回溯——否则作为光标移动处理。这避免了多行编辑与历史回溯的冲突。
粘贴检测。Windows 终端不可靠地支持 bracketed paste,多行粘贴可能被终端拆成一连串快速的 Char + Enter 事件。Codex TUI 实现了 PasteBurst 状态机来检测这种情况:
Idle → [fast char] → Pending → [another fast char] → Active Buffer → [timeout] → Flush as paste
↓ [slow/non-char]
Flush as typed char核心判据:字符到达间隔是否低于阈值。一旦判定为粘贴,Enter 不再触发提交,而是作为换行追加到缓冲区。检测窗口结束后,整个缓冲区作为一次 handle_paste() 调用提交。
Slash Commands 设计
Slash commands 是终端 Agent 的核心交互模式之一。用户输入 / 触发命令弹窗,支持 fuzzy match 过滤。
Codex TUI 定义了 40+ 个内建 slash command,按使用频率排序(非字母序——这是有意为之的设计决策):
| 分类 | 命令 | 描述 |
|---|---|---|
| 模型控制 | /model /fast | 选择模型和推理强度 |
| 会话管理 | /new /resume /fork /rename | 创建/恢复/分叉/重命名会话 |
| 代码审查 | /review /diff | 审查变更、显示 diff |
| 上下文控制 | /compact /mention | 压缩上下文、引用文件 |
| 权限管理 | /approvals /permissions | 配置工具执行权限 |
| 系统命令 | /status /clear /quit | 状态查看、清屏、退出 |
每个命令有三个元数据属性:
description:弹窗中的说明文本supports_inline_args:是否支持/review branch_name形式的内联参数available_during_task:任务运行中是否可用(如/copy、/diff可以,但/compact、/model不行)
命令的可见性还受 feature flag 控制。例如 /fast 只在 fast_command_enabled 为 true 时出现,/realtime 只在实验性语音模式启用时可见。
OpenCode 的 slash command 采用不同架构:命令不是硬编码的枚举,而是动态聚合自三个来源:
- 内建命令(
commandsource):/init(创建 AGENTS.md)、/review(代码审查) - MCP prompts(
mcpsource):从 MCP Server 的prompts/list动态获取 - Skills(
skillsource):从 skill 配置目录加载
每个命令本质上是一个 template string,支持 $1、$2 等位置参数和 $ARGUMENTS 通配参数。执行时将用户输入替换到模板中,作为 prompt 发送给 Agent。
会话切换
会话管理是终端 Agent 区别于一次性 chat 的关键能力。
Codex TUI 的 /resume 命令打开一个会话列表弹窗,显示历史会话的标题和时间。选择后恢复该会话的完整上下文(包括消息历史和工具状态)。/fork 从当前会话分叉出新会话,继承截止到当前点的所有消息。
OpenCode 的会话管理通过 TUI 事件总线驱动:
const TuiEvent = {
CommandExecute: BusEvent.define("tui.command.execute", z.object({
command: z.union([
z.enum(["session.list", "session.new", "session.share",
"session.interrupt", "session.compact", ...]),
z.string(),
]),
})),
SessionSelect: BusEvent.define("tui.session.select", z.object({
sessionID: SessionID.zod,
})),
}TUI 层发出事件,由 worker 进程中的 Server 处理。这种解耦意味着会话切换不需要重建整个 TUI——只需通知 Server 切换到目标 session,然后通过 SSE 事件流接收新会话的状态更新。
19.3 Client-Server 架构
为什么需要 Client-Server 分离
传统 CLI 工具是单进程的:UI 逻辑和业务逻辑在同一个进程中。但 AI Agent 的复杂性打破了这个模式:
- Agent 的后台任务(工具执行、文件监控、LSP 集成)可能运行很长时间
- 用户可能想从多个终端/设备同时查看同一个 Agent 的状态
- TUI 进程崩溃不应丢失 Agent 状态
- 需要支持 headless 运行(CI/CD、远程服务器场景)
这些需求推动了 Client-Server 分离架构。

Server 端架构
Codex App Server 是一个 Rust 实现的本地服务器,持有 Agent 的全部状态。启动时接受一个 --listen 参数决定传输方式:
codex-app-server --listen stdio:// # VSCode 扩展模式
codex-app-server --listen ws://127.0.0.1:0 # WebSocket 模式Server 内部分为两个异步任务循环:
graph LR
subgraph Server Process
TR[Transport Layer<br>stdio / WebSocket] -->|TransportEvent| PL[Processor Loop<br>JSON-RPC dispatch]
PL -->|OutgoingEnvelope| OL[Outbound Loop<br>per-connection routing]
OL --> TR
end
C1[Client 1<br>TUI] <-->|JSON-RPC| TR
C2[Client 2<br>IDE] <-->|JSON-RPC| TR- Processor Loop:处理入站 JSON-RPC 消息,dispatch 到对应的 handler(thread 管理、config API、model 调用等)
- Outbound Loop:管理每个连接的写端状态,将
OutgoingEnvelope路由到正确的连接
两个循环通过 bounded channel(容量 128)通信,不共享可变状态。连接的开闭通过 OutboundControlEvent 枚举协调:
enum OutboundControlEvent {
Opened { connection_id, writer, allow_legacy_notifications, ... },
Closed { connection_id },
DisconnectAll, // 优雅重启时断开所有连接
}OpenCode Server 是 TypeScript 实现,基于 Hono 框架构建 HTTP API。它暴露一套 RESTful + SSE 接口:
| 路由 | 方法 | 用途 |
|---|---|---|
/session | GET/POST/DELETE | 会话 CRUD |
/session/:id/message | GET/POST | 消息读写 |
/session/:id/abort | POST | 中断运行中的任务 |
/session/:id/fork | POST | 分叉会话 |
/event | GET (SSE) | 实时事件流 |
/command | GET | 列出可用命令 |
/agent | GET | 列出 Agent 配置 |
/provider | GET | 列出 Provider |
Server 有两种运行模式:
- In-process:TUI 进程内直接调用 Server 的
fetch方法,无网络开销。通过 Bun Worker 实现进程内 IPC。 - Network:监听 TCP 端口,支持 Basic Auth 认证,支持 mDNS 发现。
// In-process 模式:Worker 进程直接调用 Server.Default().fetch
const fetchFn = async (input, init) => {
const request = new Request(input, init);
return Server.Default().fetch(request);
};
// Network 模式:Bun.serve 监听端口
const server = Bun.serve({
hostname: opts.hostname,
port: opts.port,
fetch: app.fetch,
});Client 端架构
TUI Client 不直接与 Agent 通信,而是通过 Server 的 API。OpenCode 的 TUI 启动流程:
- 主进程(TUI 渲染线程)启动一个 Bun Worker 作为 Server 后端
- Worker 初始化 Server 实例,开始监听事件
- 主进程通过 RPC 与 Worker 通信
- 根据是否需要网络访问,选择 in-process fetch 或 TCP 端口
// in-process 路径:无网络,直接 RPC 调用 Worker
const transport = {
url: "http://opencode.internal",
fetch: createWorkerFetch(client), // RPC proxy
events: createEventSource(client), // RPC event forwarding
};
// network 路径:Worker 启动 HTTP server
const transport = {
url: (await client.call("server", networkOpts)).url,
fetch: undefined, // 使用标准 fetch
events: undefined, // 使用标准 EventSource
};Attach 模式 允许 TUI 连接到已运行的远程 Server:
opencode attach http://192.168.1.100:4096 --password secret这使得以下工作流成为可能:在远程服务器上启动 opencode serve,然后从本地笔记本电脑 opencode attach 连接——Agent 的计算密集操作(LLM 调用、文件操作)在服务器上执行,TUI 仅负责渲染。
SSE 广播:多客户端实时同步
Server 端的事件广播通过 Server-Sent Events (SSE) 实现。
事件总线架构:
graph TB
subgraph Server
Agent[Agent Loop] -->|Bus.publish| EB[Event Bus<br>Bus.subscribeAll]
EB -->|GlobalBus.emit| GE[Global EventEmitter]
end
GE -->|SSE| C1["/event endpoint<br>Client 1"]
GE -->|SSE| C2["/event endpoint<br>Client 2"]
GE -->|SSE| C3["/event endpoint<br>Client 3"]当 Agent 执行任何操作(创建消息、工具调用、权限请求等),通过 Bus.publish() 发布事件。Bus 将事件转发到两个地方:
- 本 Instance 内的所有订阅者(本地 subscriber)
GlobalBus.emit("event", ...)通知所有 SSE 连接
SSE endpoint 的实现:
app.get("/event", async (c) => {
c.header("X-Accel-Buffering", "no"); // 禁止 nginx 缓冲
c.header("X-Content-Type-Options", "nosniff");
return streamSSE(c, async (stream) => {
const q = new AsyncQueue<string | null>();
// 连接建立时发送确认事件
q.push(JSON.stringify({ type: "server.connected", properties: {} }));
// 10 秒心跳防止代理超时
const heartbeat = setInterval(() => {
q.push(JSON.stringify({ type: "server.heartbeat", properties: {} }));
}, 10_000);
// 订阅所有 Bus 事件
const unsub = Bus.subscribeAll((event) => {
q.push(JSON.stringify(event));
});
// 通过 AsyncQueue 驱动 SSE 写入
for await (const data of q) {
if (data === null) return;
await stream.writeSSE({ data });
}
});
});设计要点:
X-Accel-Buffering: no:告诉 nginx 等反向代理不要缓冲 SSE 流,否则事件会被积攒后批量推送- 10 秒心跳:防止中间代理因为长时间无数据而关闭连接
- AsyncQueue:将事件从 Bus 的同步回调桥接到 SSE 的异步写入,解耦生产者和消费者速率
事件类型使用 discriminated union 定义,通过 Zod schema 在编译时保证类型安全:
export function define<Type extends string, Properties extends ZodType>(
type: Type, properties: Properties
) {
return { type, properties };
}
// 所有已注册的事件类型自动生成 discriminated union
export function payloads() {
return z.discriminatedUnion("type",
registry.entries().map(([type, def]) =>
z.object({ type: z.literal(type), properties: def.properties })
).toArray()
);
}断线恢复
SSE 客户端实现了自动重连逻辑:
async function workspaceEventLoop(space, stop) {
while (!stop.aborted) {
const res = await adaptor.fetch(space, "/event", { signal: stop })
.catch(() => undefined);
if (!res || !res.ok || !res.body) {
await sleep(1000); // 连接失败,1 秒后重试
continue;
}
await parseSSE(res.body, stop, (event) => {
GlobalBus.emit("event", { directory: space.id, payload: event });
});
await sleep(250); // SSE 流断开,250ms 后重连
}
}SSE 解析器处理底层流分帧:
export async function parseSSE(body, signal, onEvent) {
const reader = body.getReader();
const decoder = new TextDecoder();
let buf = "";
while (!signal.aborted) {
const chunk = await reader.read();
if (chunk.done) break;
buf += decoder.decode(chunk.value, { stream: true });
buf = buf.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const chunks = buf.split("\n\n"); // SSE 事件以双换行分隔
buf = chunks.pop() ?? ""; // 最后一段可能不完整
chunks.forEach((chunk) => {
const data = [];
chunk.split("\n").forEach((line) => {
if (line.startsWith("data:")) data.push(line.replace(/^data:\s*/, ""));
if (line.startsWith("id:")) last = line.replace(/^id:\s*/, "");
if (line.startsWith("retry:")) { /* 更新重连间隔 */ }
});
if (data.length) onEvent(JSON.parse(data.join("\n")));
});
}
}当前方案的一个已知限制:断线期间的事件会丢失。SSE 标准定义了 id 字段和 Last-Event-ID header 来支持断点续传,但当前实现没有使用这个机制。客户端重连后从 server.connected 事件重新开始,依赖上层(TUI)通过 API 轮询补齐缺失状态。
多客户端一致性问题
当多个客户端连接到同一个 Server 时,需要解决几个一致性问题:
1. 连接生命周期管理
Codex App Server 使用 ConnectionId 标识每个连接,跟踪其初始化状态:
struct ConnectionState {
session: ConnectionSessionState,
outbound_initialized: Arc<AtomicBool>,
outbound_experimental_api_enabled: Arc<AtomicBool>,
outbound_opted_out_notification_methods: Arc<RwLock<HashSet<String>>>,
}每个连接有独立的 initialized 标志和 feature flag。只有完成 initialize 握手的连接才会收到事件通知。这允许不同客户端(TUI、IDE、Web)以不同的 capability 集连接同一个 Server。
2. 优雅关闭
WebSocket 模式下,Server 收到 SIGTERM 时不立即退出,而是进入 graceful drain 状态:
fn on_signal(&mut self, connection_count: usize, running_turn_count: usize) {
self.requested = true;
info!("received shutdown signal; entering graceful restart drain \
(connections={}, runningAssistantTurns={})", connection_count, running_turn_count);
}
fn update(&mut self, running_turn_count: usize, connection_count: usize) -> ShutdownAction {
if self.forced || running_turn_count == 0 { return ShutdownAction::Finish; }
ShutdownAction::Noop // 等待所有 assistant turn 完成
}第一个 SIGTERM:等待运行中的 assistant turn 完成。第二个 SIGTERM:强制关闭所有连接。
3. stdio 与 WebSocket 的行为差异
| 行为 | stdio 模式 | WebSocket 模式 |
|---|---|---|
| 客户端数量 | 恰好 1 个 | 0..N 个 |
| 连接断开时 | Server 退出 | 仅移除该连接 |
| 优雅关闭 | 不支持(无 SIGTERM 处理) | 支持 drain 等待 |
| 认证 | 不需要(同一进程) | 需要(Origin header 检查) |
WebSocket 模式额外拒绝包含 Origin header 的请求——这是防止浏览器跨站 WebSocket 劫持的安全措施。
19.4 多端 UI
终端 TUI
终端 TUI 是 AI Coding Agent 的原生栖息地。Codex 使用 Rust + ratatui 构建,OpenCode 使用 TypeScript + 自研 TUI 框架(@opentui/core)。
两者的核心 UI 布局类似:
┌──────────────────────────────────────┐
│ Status Bar: model / tokens / branch │
├──────────────────────────────────────┤
│ │
│ Message History │
│ (scrollable, Markdown rendered) │
│ │
│ assistant> Here's the fix: │
│ ```python │
│ def solve(n): │
│ return n * (n + 1) // 2 │
│ ``` │
│ │
├──────────────────────────────────────┤
│ > Input area (multi-line) [?] │
└──────────────────────────────────────┘Codex TUI 的 ratatui 架构分为多个 widget 层:
- ChatWidget:顶层容器,管理上下窗格的比例
- MarkdownStreamCollector:将流式 token 转化为 ratatui
Line序列 - ChatComposer:底部输入框,集成 paste burst 检测、slash command 弹窗、文件 mention 弹窗
- SelectionList:fuzzy search 弹窗的通用列表组件
Desktop 应用
Tauri 路线:OpenCode 的 CORS 配置揭示了 Tauri 集成的设计:
origin(input) {
if (input === "tauri://localhost" ||
input === "http://tauri.localhost" ||
input === "https://tauri.localhost") return input;
if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) return input;
}Tauri 应用的 WebView 发出的请求带有 tauri://localhost 或 http://tauri.localhost origin。Server 专门白名单这些 origin,使 Tauri 包装的 Web 前端可以直接调用本地 Server API。
Electron 路线:Codex 的 App Server 设计天然支持 Electron 集成——通过 ws:// transport 连接到本地 Server,UI 完全在 Electron 的 renderer 进程中实现。
Web 界面
OpenCode 支持通过浏览器直接访问:
opencode web # 自动开浏览器
opencode web --hostname 0.0.0.0 --port 4096 # 局域网可访问
opencode web --mdns # mDNS 广播,手机可发现Web 模式的实现极为精简:Server 在处理完所有 API 路由后,将未匹配的请求代理到 app.opencode.ai:
app.all("/*", async (c) => {
const response = await proxy(`https://app.opencode.ai${c.req.path}`, {
...c.req,
headers: { ...c.req.raw.headers, host: "app.opencode.ai" },
});
response.headers.set("Content-Security-Policy",
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; ...");
return response;
});这意味着 Web 前端的静态资源托管在云端(app.opencode.ai),本地 Server 只做 API 后端和反向代理。CSP header 限制了前端只能加载同源资源和 WASM,防止 XSS 攻击。
从手机或平板上访问 http://server-ip:4096 就能获得完整的 Agent 交互界面。--mdns 选项通过 mDNS 广播服务地址,使同一局域网的设备无需记住 IP 就能发现并连接。
IDE 集成
Codex App Server 的 --session-source 参数声明了客户端身份:
codex-app-server --listen stdio:// --session-source vscode不同的 session source 可能影响 Server 的行为(如 analytics、feature gates)。当前支持的 source 包括 vscode、terminal 等。
IDE 集成的典型模式是:IDE 扩展 spawn 一个 App Server 进程,通过 stdio 或 WebSocket 与其通信。Server 管理所有 Agent 状态,扩展只负责 UI 渲染和用户交互转发。这与 LSP(Language Server Protocol)的架构思想一脉相承——IDE 不需要理解 Agent 的内部逻辑,只需实现标准化的消息协议。
架构对比总结
| 维度 | Codex | OpenCode |
|---|---|---|
| TUI 实现 | Rust (ratatui) | TypeScript (@opentui/core) |
| Server 语言 | Rust (axum + tokio) | TypeScript (Hono + Bun) |
| 通信协议 | JSON-RPC over stdio/WebSocket | REST + SSE over HTTP |
| 多客户端支持 | WebSocket 模式原生支持 | HTTP 天然支持 |
| 实时推送 | WebSocket 双向 | SSE 单向推送 |
| Desktop | 通过 WebSocket 接入 | Tauri WebView 接入 |
| Web | 不直接支持 | 内建 opencode web |
| 远程连接 | SSH port-forwarding | opencode attach + Basic Auth |
| 断线恢复 | WebSocket 重连 | SSE 自动重连 + API 轮询 |
两种架构各有取舍:Codex 的 JSON-RPC + WebSocket 方案更适合低延迟双向通信(如实时 agent turn 的流式输出),OpenCode 的 REST + SSE 方案更 Web-native,浏览器/移动端接入零成本。
最终,无论哪种架构,核心设计原则是一致的:Server 持有 Agent 状态,Client 只是渲染层;多个 Client 可以连接同一个 Server;Client 断线不影响 Agent 执行。这种分离使得终端、桌面、Web、手机端能够用不同的技术栈实现最优的 UI 体验,同时共享同一个 Agent 后端。