From d54ffa3e2799fb5bb2569b2ca712678890069ff7 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Sun, 12 Apr 2026 09:52:01 -0400 Subject: [PATCH] Add CLIAdapterModule with pretty JSONL formatting New src/cli/format-event.ts handles formatting OpenCode JSONL events: - step_start: skip in normal mode, show in debug - text: display as-is - tool_use: show errors, skip in normal mode - step_finish: show message + tokens/cost in debug - error: display error message Exports cliAdapter.formatStdoutEvent for Paperclip UI to call. Also fixes ui-parser.ts to re-export from format-event.ts. Co-Authored-By: Claude Opus 4.6 --- src/cli/format-event.ts | 202 ++++++++++++++++++++++++++++++++++++++++ src/cli/index.ts | 1 + src/index.ts | 13 +++ src/ui-parser.ts | 31 +----- 4 files changed, 219 insertions(+), 28 deletions(-) create mode 100644 src/cli/format-event.ts create mode 100644 src/cli/index.ts diff --git a/src/cli/format-event.ts b/src/cli/format-event.ts new file mode 100644 index 0000000..75410be --- /dev/null +++ b/src/cli/format-event.ts @@ -0,0 +1,202 @@ +import type { TranscriptEntry } from "@paperclipai/adapter-utils"; + +type JsonEvent = { + type: string; + part?: Record; + sessionID?: string; +}; + +function safeJsonParse(text: string): JsonEvent | null { + try { + const parsed = JSON.parse(text); + if (parsed && typeof parsed === "object") { + return parsed as JsonEvent; + } + return null; + } catch { + return null; + } +} + +function asRecord(value: unknown): Record { + if (value && typeof value === "object") { + return value as Record; + } + 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 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 ""; + } +} + +/** + * Format an OpenCode JSONL event for terminal display. + * Returns formatted string or empty string to skip display. + */ +export function formatEvent(line: string, debug: boolean): string { + const trimmed = line.trim(); + if (!trimmed) return ""; + + const event = safeJsonParse(trimmed); + if (!event) { + // Non-JSON lines print as-is + return trimmed; + } + + const type = asString(event.type, ""); + const part = asRecord(event.part ?? {}); + + switch (type) { + case "step_start": { + const sessionId = asString(event.sessionID, ""); + if (debug) { + return `[step_start]${sessionId ? ` session=${sessionId}` : ""}`; + } + return ""; // Skip step_start in normal mode + } + + case "text": { + const text = asString(part.text, "").trim(); + if (text) { + return text; + } + return ""; + } + + case "tool_use": { + const toolType = asString(part.type, ""); + const state = asRecord(part.state ?? {}); + const status = asString(state.status, ""); + const toolName = asString(part.tool, ""); + const description = asString(state.description, ""); + + if (debug) { + const output = asString(state.output ?? "", ""); + const error = asString(state.error, ""); + let result = `[tool:${toolName}] ${status}`; + if (description) result += ` - ${description}`; + if (output) result += `\n → ${output.substring(0, 200)}`; + if (error) result += `\n ✗ ${error}`; + return result; + } + + if (status === "error") { + const err = asString(state.error, "").trim(); + if (err) return `⚠ ${err}`; + } + return ""; // Skip tool calls in normal mode unless error + } + + case "step_finish": { + const reason = asString(part.reason, ""); + const message = asString(part.message, "").trim(); + const tokens = asRecord(part.tokens ?? {}); + const totalTokens = asNumber(tokens.total, 0); + const cost = asNumber(part.cost, 0); + + let result = message || `[step_finish] ${reason}`; + if (debug || totalTokens > 0 || cost > 0) { + const parts: string[] = []; + if (totalTokens > 0) parts.push(`tokens=${totalTokens}`); + if (cost > 0) parts.push(`cost$${cost.toFixed(4)}`); + if (parts.length > 0) result += ` (${parts.join(", ")})`; + } + return result; + } + + case "error": { + const text = errorText(event).trim(); + if (text) return `✗ ${text}`; + return ""; + } + + case "assistant": { + // Nested assistant message content + 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") { + return itemRecord.text.trim(); + } + } + } + return ""; + } + + default: + return debug ? `[${type}]` : ""; + } +} + +/** + * 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 text + return [{ kind: "stdout", ts, text: trimmed }]; + } + + const type = asString(event.type, ""); + const part = asRecord(event.part ?? {}); + + if (type === "text") { + const text = asString(part.text, "").trim(); + if (text) return [{ kind: "stdout", ts, text }]; + return []; + } + + if (type === "step_finish") { + const text = asString(part.message, "").trim(); + if (text) return [{ kind: "stdout", ts, text }]; + return []; + } + + // Skip non-display events (step_start, tool_use in normal mode) + if (type === "step_start" || type === "tool_use") { + return []; + } + + if (type === "error") { + const text = errorText(event).trim(); + if (text) return [{ kind: "stderr", ts, text }]; + return []; + } + + return []; +} diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 0000000..2e5b526 --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1 @@ +export { formatEvent, parseStdoutLine } from "./format-event.js"; diff --git a/src/index.ts b/src/index.ts index 022c4e5..6b74de0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,19 @@ export const label = "OpenCode (Kubernetes)"; export const models: undefined = undefined; +import type { CLIAdapterModule } from "@paperclipai/adapter-utils"; +import { formatEvent } from "./cli/format-event.js"; + +export const cliAdapter: CLIAdapterModule = { + type, + formatStdoutEvent: (line: string, debug: boolean) => { + const formatted = formatEvent(line, debug); + if (formatted) { + console.log(formatted); + } + }, +}; + export const agentConfigurationDoc = `# opencode_k8s agent configuration Adapter: opencode_k8s diff --git a/src/ui-parser.ts b/src/ui-parser.ts index 3d8e749..d98b587 100644 --- a/src/ui-parser.ts +++ b/src/ui-parser.ts @@ -3,32 +3,7 @@ * Zero external imports — required by the Paperclip adapter plugin UI parser contract. */ -type TranscriptEntry = - | { kind: "stdout"; ts: string; text: string } - | { kind: "stderr"; ts: string; text: string } - | { kind: "system"; ts: string; text: string }; +import type { TranscriptEntry } from "@paperclipai/adapter-utils"; +import { parseStdoutLine } from "./cli/format-event.js"; -export function parseStdoutLine(line: string, ts: string): TranscriptEntry[] { - const trimmed = line.trim(); - if (!trimmed) return []; - - // Try to parse as JSONL event from opencode run --format json - try { - const event = JSON.parse(trimmed); - const type = typeof event.type === "string" ? event.type : ""; - const part = typeof event.part === "object" && event.part !== null ? event.part : {}; - - if (type === "text" && typeof part.text === "string" && part.text.trim()) { - return [{ kind: "stdout", ts, text: part.text.trim() }]; - } - - // Skip non-display event types - if (type === "step_start" || type === "step_finish" || type === "tool_use") { - return []; - } - } catch { - // Not JSON — treat as raw stdout - } - - return [{ kind: "stdout", ts, text: trimmed }]; -} +export { parseStdoutLine };