diff --git a/next/src/components/preview-pane.tsx b/next/src/components/preview-pane.tsx
index ee8a531f..d11adf9d 100644
--- a/next/src/components/preview-pane.tsx
+++ b/next/src/components/preview-pane.tsx
@@ -542,7 +542,13 @@ function LogLine({ entry }: { entry: LogEntry }) {
function PreviewPlaceholder({ status }: { status: string }) {
const t = useT();
const isRunning = status === "running";
+ const [, setTick] = useState(0);
const stats = useStore((s) => selectActiveTask(s)?.stats ?? EMPTY_STATS);
+ useEffect(() => {
+ if (!isRunning) return;
+ const id = setInterval(() => setTick((n) => n + 1), 500);
+ return () => clearInterval(id);
+ }, [isRunning]);
const secWaited = stats.startedAt ? ((Date.now() - stats.startedAt) / 1000).toFixed(1) : "0";
return (
diff --git a/next/src/lib/agents/__tests__/argv.test.ts b/next/src/lib/agents/__tests__/argv.test.ts
new file mode 100644
index 00000000..f2bec15f
--- /dev/null
+++ b/next/src/lib/agents/__tests__/argv.test.ts
@@ -0,0 +1,164 @@
+import { describe, expect, it } from "vitest";
+import { parseLine, makeParser } from "../argv";
+
+describe("parseLine opencode", () => {
+ it("extracts text from nested part payload", () => {
+ const line = JSON.stringify({
+ type: "text",
+ sessionID: "ses_test",
+ part: {
+ type: "text",
+ text: "ok",
+ },
+ });
+
+ expect(parseLine("opencode", line)).toContainEqual({
+ kind: "delta",
+ text: "ok",
+ });
+ });
+
+ it("emits one delta when top-level and nested text are both present", () => {
+ const line = JSON.stringify({
+ type: "text",
+ text: "ok",
+ part: {
+ type: "text",
+ text: "ok",
+ },
+ });
+
+ expect(parseLine("opencode", line)).toEqual([
+ {
+ kind: "delta",
+ text: "ok",
+ },
+ ]);
+ });
+
+ it("falls back to top-level text when nested text is empty", () => {
+ const line = JSON.stringify({
+ type: "text",
+ content: "ok",
+ part: {
+ type: "text",
+ text: "",
+ },
+ });
+
+ expect(parseLine("opencode", line)).toEqual([
+ {
+ kind: "delta",
+ text: "ok",
+ },
+ ]);
+ });
+
+ it("extracts session only from step start payload", () => {
+ expect(
+ parseLine(
+ "opencode",
+ JSON.stringify({
+ type: "step_start",
+ sessionID: "ses_test",
+ part: {
+ type: "step-start",
+ },
+ }),
+ ),
+ ).toContainEqual({
+ kind: "meta",
+ key: "session",
+ value: "ses_test",
+ });
+
+ expect(
+ parseLine(
+ "opencode",
+ JSON.stringify({
+ type: "text",
+ sessionID: "ses_test",
+ part: {
+ type: "text",
+ text: "ok",
+ },
+ }),
+ ),
+ ).not.toContainEqual({
+ kind: "meta",
+ key: "session",
+ value: "ses_test",
+ });
+ });
+
+ it("extracts usage from step finish payload and accumulates successive steps", () => {
+ const line1 = JSON.stringify({
+ type: "step_finish",
+ part: {
+ type: "step-finish",
+ tokens: {
+ input: 10,
+ output: 2,
+ cache: {
+ read: 3,
+ write: 4,
+ },
+ },
+ cost: 0.01,
+ },
+ });
+
+ const line2 = JSON.stringify({
+ type: "step_finish",
+ part: {
+ type: "step-finish",
+ tokens: {
+ input: 5,
+ output: 1,
+ cache: {
+ read: 1,
+ write: 1,
+ },
+ },
+ cost: 0.005,
+ },
+ });
+
+ const parser = makeParser("opencode");
+ expect(parser(line1)).toEqual([
+ {
+ kind: "meta",
+ key: "usage",
+ value: {
+ input_tokens: 10,
+ output_tokens: 2,
+ cache_read_input_tokens: 3,
+ cache_creation_input_tokens: 4,
+ },
+ },
+ {
+ kind: "meta",
+ key: "cost_usd",
+ value: 0.01,
+ },
+ ]);
+
+ expect(parser(line2)).toEqual([
+ {
+ kind: "meta",
+ key: "usage",
+ value: {
+ input_tokens: 15,
+ output_tokens: 3,
+ cache_read_input_tokens: 4,
+ cache_creation_input_tokens: 5,
+ },
+ },
+ {
+ kind: "meta",
+ key: "cost_usd",
+ value: 0.015,
+ },
+ ]);
+ });
+});
diff --git a/next/src/lib/agents/argv.ts b/next/src/lib/agents/argv.ts
index c6cc169d..cec5842c 100644
--- a/next/src/lib/agents/argv.ts
+++ b/next/src/lib/agents/argv.ts
@@ -160,7 +160,14 @@ export type AgentParse =
* with `--include-partial-messages` (or the equivalent) writes its output
* twice.
*/
-export type ParseState = { sawStreamEventText?: boolean };
+export type ParseState = {
+ sawStreamEventText?: boolean;
+ opencodeAccumulatedInputTokens?: number;
+ opencodeAccumulatedOutputTokens?: number;
+ opencodeAccumulatedCacheReadTokens?: number;
+ opencodeAccumulatedCacheWriteTokens?: number;
+ opencodeAccumulatedCost?: number;
+};
/**
* Build a stateful per-invocation parser. Feed every stdout line through the
@@ -362,7 +369,47 @@ function parseLineWithState(agent: string, line: string, state: ParseState): Age
if (typeof obj.text === "string") out.push({ kind: "delta", text: obj.text });
}
- if (agent === "opencode" || agent === "qwen") {
+ if (agent === "opencode") {
+ const part =
+ obj.part && typeof obj.part === "object"
+ ? (obj.part as Record)
+ : null;
+ const text = [part?.text, part?.content, part?.message, obj.text, obj.content, obj.message].find(
+ (value): value is string => typeof value === "string" && value.length > 0,
+ );
+ if (text) out.push({ kind: "delta", text });
+ if (obj.type === "step_start" && typeof obj.sessionID === "string") {
+ out.push({ kind: "meta", key: "session", value: obj.sessionID });
+ }
+ if (part?.tokens && typeof part.tokens === "object") {
+ const tokens = part.tokens as {
+ input?: number;
+ output?: number;
+ cache?: { read?: number; write?: number };
+ };
+ state.opencodeAccumulatedInputTokens = (state.opencodeAccumulatedInputTokens ?? 0) + (tokens.input ?? 0);
+ state.opencodeAccumulatedOutputTokens = (state.opencodeAccumulatedOutputTokens ?? 0) + (tokens.output ?? 0);
+ state.opencodeAccumulatedCacheReadTokens = (state.opencodeAccumulatedCacheReadTokens ?? 0) + (tokens.cache?.read ?? 0);
+ state.opencodeAccumulatedCacheWriteTokens = (state.opencodeAccumulatedCacheWriteTokens ?? 0) + (tokens.cache?.write ?? 0);
+
+ out.push({
+ kind: "meta",
+ key: "usage",
+ value: {
+ input_tokens: state.opencodeAccumulatedInputTokens,
+ output_tokens: state.opencodeAccumulatedOutputTokens,
+ cache_read_input_tokens: state.opencodeAccumulatedCacheReadTokens,
+ cache_creation_input_tokens: state.opencodeAccumulatedCacheWriteTokens,
+ },
+ });
+ }
+ if (typeof part?.cost === "number") {
+ state.opencodeAccumulatedCost = (state.opencodeAccumulatedCost ?? 0) + part.cost;
+ out.push({ kind: "meta", key: "cost_usd", value: state.opencodeAccumulatedCost });
+ }
+ }
+
+ if (agent === "qwen") {
if (typeof obj.text === "string") out.push({ kind: "delta", text: obj.text });
if (typeof obj.content === "string") out.push({ kind: "delta", text: obj.content });
if (typeof obj.message === "string") out.push({ kind: "delta", text: obj.message });
diff --git a/next/src/lib/i18n.ts b/next/src/lib/i18n.ts
index 7b0036ee..b563f749 100644
--- a/next/src/lib/i18n.ts
+++ b/next/src/lib/i18n.ts
@@ -558,7 +558,7 @@ const en: Dict = {
"preview.placeholder.idleTitle.accent": "⚡",
"preview.placeholder.idleTitle.part2": "convert",
"preview.placeholder.runningDescr":
- "First bytes land in a few seconds, then it streams. Waited {sec}s so far.",
+ "The agent is generating. Some CLIs, including OpenCode, return larger chunks instead of token streams. Waited {sec}s.",
"preview.placeholder.idleDescr":
"Preview · view source · one-click copy for WeChat / Twitter / Zhihu · PNG export.",
"preview.placeholder.chip.article": "📖 Article",
@@ -918,7 +918,7 @@ const zhCN: Dict = {
"preview.placeholder.idleTitle.part1": "把内容粘到左侧, 然后",
"preview.placeholder.idleTitle.accent": "⚡",
"preview.placeholder.idleTitle.part2": "转换",
- "preview.placeholder.runningDescr": "首字会在几秒内到达, 之后流式更新。当前已等 {sec}s。",
+ "preview.placeholder.runningDescr": "Agent 正在生成。有些 CLI(包括 OpenCode)会成块返回, 而不是逐字流式输出。当前已等 {sec}s。",
"preview.placeholder.idleDescr": "支持预览 / 查看源码 / 一键复制公众号·推特·知乎 / 截图导出 PNG。",
"preview.placeholder.chip.article": "📖 文章",
"preview.placeholder.chip.deck": "🎬 PPT",
diff --git a/next/src/lib/use-convert.ts b/next/src/lib/use-convert.ts
index 17306c5a..c82abad6 100644
--- a/next/src/lib/use-convert.ts
+++ b/next/src/lib/use-convert.ts
@@ -187,7 +187,18 @@ function handleEvent(taskId: string, event: string, data: unknown, startedAt: nu
break;
}
case "delta": {
- if (typeof d.text === "string") store.appendHtmlFor(taskId, d.text);
+ if (typeof d.text === "string" && d.text) {
+ const task = store.tasks.find((t) => t.id === taskId);
+ const isFirstChunk = !task?.stats.firstByteAt;
+ store.appendHtmlFor(taskId, d.text);
+ if (isFirstChunk) {
+ store.pushLogFor(taskId, {
+ kind: "delta",
+ elapsed,
+ text: `收到首个 HTML 片段 (${formatBytes(d.text.length)})`,
+ });
+ }
+ }
break;
}
case "html": {