Files
paperclip-adapter-opencode-k8s/src/ui-parser.ts
T
Pawla Abdul 241da1d4f9 Use rich TranscriptEntry kinds in UI parser for pretty output
The UI parser was mapping all OpenCode JSONL events to plain stdout/stderr
transcript entries, causing the Paperclip UI to render everything as raw
terminal output. Updated parseStdoutLine to use structured kinds:

- text events → "assistant" (renders as chat bubbles)
- tool_use events → "tool_call" / "tool_result" (shows tool invocations)
- step_finish events → "result" (shows token/cost metrics)
- step_start events → "system" (shows step transitions)
- assistant events → "assistant" (nested content blocks)

Fixes FAR-43.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-12 15:15:19 +00:00

186 lines
5.8 KiB
TypeScript

/**
* Self-contained stdout parser for OpenCode JSONL output.
* Zero external imports — required by the Paperclip adapter plugin UI parser contract.
*
* Maps OpenCode event types to rich TranscriptEntry kinds so the Paperclip UI
* renders structured assistant messages, tool calls, results, etc. instead of
* plain stdout text.
*/
type TranscriptEntry =
| { kind: "assistant"; ts: string; text: string; delta?: boolean }
| { kind: "tool_call"; ts: string; name: string; input: unknown; toolUseId?: string }
| { kind: "tool_result"; ts: string; toolUseId: string; toolName?: string; content: string; isError: boolean }
| { kind: "result"; ts: string; text: string; inputTokens: number; outputTokens: number; cachedTokens: number; costUsd: number; subtype: string; isError: boolean; errors: string[] }
| { kind: "system"; ts: string; text: string }
| { kind: "stderr"; ts: string; text: string }
| { kind: "stdout"; ts: string; text: string };
function asRecord(value: unknown): Record<string, unknown> {
if (value && typeof value === "object") {
return value as Record<string, unknown>;
}
return {};
}
function asString(value: unknown, fallback: string): string {
if (typeof value === "string") return value;
return fallback;
}
function asNumber(value: unknown, fallback: number): number {
if (typeof value === "number") return value;
return fallback;
}
function safeJsonParse(text: string): Record<string, unknown> | null {
try {
const parsed = JSON.parse(text);
if (parsed && typeof parsed === "object") {
return parsed as Record<string, unknown>;
}
return null;
} catch {
return null;
}
}
function errorText(value: unknown): string {
if (typeof value === "string") return value;
const rec = asRecord(value);
const message = asString(rec.message, "").trim();
if (message) return message;
const data = asRecord(rec.data);
const nestedMessage = asString(data.message, "").trim();
if (nestedMessage) return nestedMessage;
const name = asString(rec.name, "").trim();
if (name) return name;
const code = asString(rec.code, "").trim();
if (code) return code;
try {
return JSON.stringify(rec);
} catch {
return "";
}
}
/**
* Parse a single stdout line into transcript entries for UI display.
* This is the Paperclip UI parser contract.
*/
export function parseStdoutLine(line: string, ts: string): TranscriptEntry[] {
const trimmed = line.trim();
if (!trimmed) return [];
const event = safeJsonParse(trimmed);
if (!event) {
// Non-JSON — treat as raw stdout (e.g. K8s pod lifecycle messages)
return [{ kind: "stdout", ts, text: trimmed }];
}
const type = asString(event.type, "");
const part = asRecord(event.part ?? {});
// Assistant text fragments — render as assistant chat bubbles
if (type === "text") {
const text = asString(part.text, "");
if (text) return [{ kind: "assistant", ts, text, delta: true }];
return [];
}
// Tool use events — map to tool_call / tool_result depending on status
if (type === "tool_use") {
const toolName = asString(part.tool, "") || asString(part.type, "tool");
const state = asRecord(part.state ?? {});
const status = asString(state.status, "");
const toolUseId = asString(part.id ?? part.toolUseId ?? "", "") || toolName;
const description = asString(state.description, "").trim();
if (status === "error") {
const err = asString(state.error, "").trim();
return [{
kind: "tool_result",
ts,
toolUseId,
toolName,
content: err || "Tool error",
isError: true,
}];
}
if (status === "completed" || status === "done") {
const output = asString(state.output, "").trim();
return [{
kind: "tool_result",
ts,
toolUseId,
toolName,
content: output || description || "Done",
isError: false,
}];
}
// pending / running / other — show as a tool call invocation
const input = description || undefined;
return [{ kind: "tool_call", ts, name: toolName, input, toolUseId }];
}
// Step finish — render as a structured result with token/cost metrics
if (type === "step_finish") {
const message = asString(part.message, "").trim();
const reason = asString(part.reason, "");
const tokens = asRecord(part.tokens ?? {});
const cache = asRecord(tokens.cache ?? {});
const inputTokens = asNumber(tokens.input, 0);
const outputTokens = asNumber(tokens.output, 0) + asNumber(tokens.reasoning, 0);
const cachedTokens = asNumber(cache.read, 0);
const costUsd = asNumber(part.cost, 0);
return [{
kind: "result",
ts,
text: message || `Step finished: ${reason || "done"}`,
inputTokens,
outputTokens,
cachedTokens,
costUsd,
subtype: reason || "step_finish",
isError: false,
errors: [],
}];
}
// Step start — render as system message
if (type === "step_start") {
return [{ kind: "system", ts, text: "Starting step…" }];
}
// Assistant message (nested content blocks)
if (type === "assistant") {
const content = part.message ?? part;
const contentRecord = asRecord(content);
if (contentRecord.content) {
const contentArr = Array.isArray(contentRecord.content)
? contentRecord.content
: [contentRecord.content];
for (const item of contentArr) {
const itemRecord = asRecord(item);
if (itemRecord.type === "text" && typeof itemRecord.text === "string") {
const text = (itemRecord.text as string).trim();
if (text) return [{ kind: "assistant", ts, text }];
}
}
}
return [];
}
// Error events
if (type === "error") {
const text = errorText(event.error ?? event.message ?? event).trim();
if (text) return [{ kind: "stderr", ts, text }];
return [];
}
return [];
}