diff --git a/cli/package.json b/cli/package.json index 24a8bf66..089f5a59 100644 --- a/cli/package.json +++ b/cli/package.json @@ -37,6 +37,7 @@ "@paperclipai/adapter-claude-local": "workspace:*", "@paperclipai/adapter-codex-local": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*", + "@paperclipai/adapter-gemini-local": "workspace:*", "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-pi-local": "workspace:*", "@paperclipai/adapter-openclaw-gateway": "workspace:*", diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index ee13b54a..115d03b3 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { execFileSync } from "node:child_process"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { copyGitHooksToWorktreeGitDir, copySeededSecretsKey, @@ -23,6 +23,20 @@ import { } from "../commands/worktree-lib.js"; import type { PaperclipConfig } from "../config/schema.js"; +const ORIGINAL_CWD = process.cwd(); +const ORIGINAL_ENV = { ...process.env }; + +afterEach(() => { + process.chdir(ORIGINAL_CWD); + for (const key of Object.keys(process.env)) { + if (!(key in ORIGINAL_ENV)) delete process.env[key]; + } + for (const [key, value] of Object.entries(ORIGINAL_ENV)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +}); + function buildSourceConfig(): PaperclipConfig { return { $meta: { diff --git a/cli/src/adapters/registry.ts b/cli/src/adapters/registry.ts index 21b915f5..e4443f55 100644 --- a/cli/src/adapters/registry.ts +++ b/cli/src/adapters/registry.ts @@ -2,6 +2,7 @@ import type { CLIAdapterModule } from "@paperclipai/adapter-utils"; import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli"; import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli"; import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli"; +import { printGeminiStreamEvent } from "@paperclipai/adapter-gemini-local/cli"; import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli"; import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli"; import { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli"; @@ -33,6 +34,11 @@ const cursorLocalCLIAdapter: CLIAdapterModule = { formatStdoutEvent: printCursorStreamEvent, }; +const geminiLocalCLIAdapter: CLIAdapterModule = { + type: "gemini_local", + formatStdoutEvent: printGeminiStreamEvent, +}; + const openclawGatewayCLIAdapter: CLIAdapterModule = { type: "openclaw_gateway", formatStdoutEvent: printOpenClawGatewayStreamEvent, @@ -45,6 +51,7 @@ const adaptersByType = new Map( openCodeLocalCLIAdapter, piLocalCLIAdapter, cursorLocalCLIAdapter, + geminiLocalCLIAdapter, openclawGatewayCLIAdapter, processCLIAdapter, httpCLIAdapter, diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index f1552329..4f0ed887 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -119,6 +119,14 @@ function nonEmpty(value: string | null | undefined): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } +function isCurrentSourceConfigPath(sourceConfigPath: string): boolean { + const currentConfigPath = process.env.PAPERCLIP_CONFIG; + if (!currentConfigPath || currentConfigPath.trim().length === 0) { + return false; + } + return path.resolve(currentConfigPath) === path.resolve(sourceConfigPath); +} + function resolveWorktreeMakeName(name: string): string { const value = nonEmpty(name); if (!value) { @@ -440,9 +448,10 @@ export function copySeededSecretsKey(input: { mkdirSync(path.dirname(input.targetKeyFilePath), { recursive: true }); + const allowProcessEnvFallback = isCurrentSourceConfigPath(input.sourceConfigPath); const sourceInlineMasterKey = nonEmpty(input.sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY) ?? - nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY); + (allowProcessEnvFallback ? nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY) : null); if (sourceInlineMasterKey) { writeFileSync(input.targetKeyFilePath, sourceInlineMasterKey, { encoding: "utf8", @@ -458,7 +467,7 @@ export function copySeededSecretsKey(input: { const sourceKeyFileOverride = nonEmpty(input.sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY_FILE) ?? - nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE); + (allowProcessEnvFallback ? nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE) : null); const sourceConfiguredKeyPath = sourceKeyFileOverride ?? input.sourceConfig.secrets.localEncrypted.keyFilePath; const sourceKeyFilePath = resolveRuntimeLikePath(sourceConfiguredKeyPath, input.sourceConfigPath); diff --git a/cli/src/prompts/server.ts b/cli/src/prompts/server.ts index 00611560..e5c26180 100644 --- a/cli/src/prompts/server.ts +++ b/cli/src/prompts/server.ts @@ -162,4 +162,3 @@ export async function promptServer(opts?: { auth, }; } - diff --git a/docs/adapters/gemini-local.md b/docs/adapters/gemini-local.md new file mode 100644 index 00000000..51380b05 --- /dev/null +++ b/docs/adapters/gemini-local.md @@ -0,0 +1,45 @@ +--- +title: Gemini Local +summary: Gemini CLI local adapter setup and configuration +--- + +The `gemini_local` adapter runs Google's Gemini CLI locally. It supports session persistence with `--resume`, skills injection, and structured `stream-json` output parsing. + +## Prerequisites + +- Gemini CLI installed (`gemini` command available) +- `GEMINI_API_KEY` or `GOOGLE_API_KEY` set, or local Gemini CLI auth configured + +## Configuration Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `cwd` | string | Yes | Working directory for the agent process (absolute path; created automatically if missing when permissions allow) | +| `model` | string | No | Gemini model to use. Defaults to `auto`. | +| `promptTemplate` | string | No | Prompt used for all runs | +| `instructionsFilePath` | string | No | Markdown instructions file prepended to the prompt | +| `env` | object | No | Environment variables (supports secret refs) | +| `timeoutSec` | number | No | Process timeout (0 = no timeout) | +| `graceSec` | number | No | Grace period before force-kill | +| `yolo` | boolean | No | Pass `--approval-mode yolo` for unattended operation | + +## Session Persistence + +The adapter persists Gemini session IDs between heartbeats. On the next wake, it resumes the existing conversation with `--resume` so the agent retains context. + +Session resume is cwd-aware: if the working directory changed since the last run, a fresh session starts instead. + +If resume fails with an unknown session error, the adapter automatically retries with a fresh session. + +## Skills Injection + +The adapter symlinks Paperclip skills into the Gemini global skills directory (`~/.gemini/skills`). Existing user skills are not overwritten. + +## Environment Test + +Use the "Test Environment" button in the UI to validate the adapter config. It checks: + +- Gemini CLI is installed and accessible +- Working directory is absolute and available (auto-created if missing and permitted) +- API key/auth hints (`GEMINI_API_KEY` or `GOOGLE_API_KEY`) +- A live hello probe (`gemini --output-format json "Respond with hello."`) to verify CLI readiness diff --git a/docs/adapters/overview.md b/docs/adapters/overview.md index 4237f87f..44b879d7 100644 --- a/docs/adapters/overview.md +++ b/docs/adapters/overview.md @@ -20,6 +20,7 @@ When a heartbeat fires, Paperclip: |---------|----------|-------------| | [Claude Local](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally | | [Codex Local](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex CLI locally | +| [Gemini Local](/adapters/gemini-local) | `gemini_local` | Runs Gemini CLI locally | | OpenCode Local | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) | | OpenClaw | `openclaw` | Sends wake payloads to an OpenClaw webhook | | [Process](/adapters/process) | `process` | Executes arbitrary shell commands | @@ -54,7 +55,7 @@ Three registries consume these modules: ## Choosing an Adapter -- **Need a coding agent?** Use `claude_local`, `codex_local`, or `opencode_local` +- **Need a coding agent?** Use `claude_local`, `codex_local`, `gemini_local`, or `opencode_local` - **Need to run a script or command?** Use `process` - **Need to call an external service?** Use `http` - **Need something custom?** [Create your own adapter](/adapters/creating-an-adapter) diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 8eb01190..3ffbaec1 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -189,7 +189,7 @@ export type TranscriptEntry = | { kind: "assistant"; ts: string; text: string; delta?: boolean } | { kind: "thinking"; ts: string; text: string; delta?: boolean } | { kind: "user"; ts: string; text: string } - | { kind: "tool_call"; ts: string; name: string; input: unknown } + | { kind: "tool_call"; ts: string; name: string; input: unknown; toolUseId?: string } | { kind: "tool_result"; ts: string; toolUseId: string; content: string; isError: boolean } | { kind: "init"; ts: string; model: string; sessionId: string } | { kind: "result"; ts: string; text: string; inputTokens: number; outputTokens: number; cachedTokens: number; costUsd: number; subtype: string; isError: boolean; errors: string[] } diff --git a/packages/adapters/claude-local/src/ui/parse-stdout.ts b/packages/adapters/claude-local/src/ui/parse-stdout.ts index 51c2b3c6..f7bd1b2a 100644 --- a/packages/adapters/claude-local/src/ui/parse-stdout.ts +++ b/packages/adapters/claude-local/src/ui/parse-stdout.ts @@ -71,6 +71,12 @@ export function parseClaudeStdoutLine(line: string, ts: string): TranscriptEntry kind: "tool_call", ts, name: typeof block.name === "string" ? block.name : "unknown", + toolUseId: + typeof block.id === "string" + ? block.id + : typeof block.tool_use_id === "string" + ? block.tool_use_id + : undefined, input: block.input ?? {}, }); } diff --git a/packages/adapters/codex-local/src/ui/parse-stdout.ts b/packages/adapters/codex-local/src/ui/parse-stdout.ts index bc28f510..7f4028a0 100644 --- a/packages/adapters/codex-local/src/ui/parse-stdout.ts +++ b/packages/adapters/codex-local/src/ui/parse-stdout.ts @@ -64,6 +64,7 @@ function parseCommandExecutionItem( kind: "tool_call", ts, name: "command_execution", + toolUseId: id || command || "command_execution", input: { id, command, @@ -148,6 +149,7 @@ function parseCodexItem( kind: "tool_call", ts, name: asString(item.name, "unknown"), + toolUseId: asString(item.id), input: item.input ?? {}, }]; } diff --git a/packages/adapters/cursor-local/src/ui/parse-stdout.ts b/packages/adapters/cursor-local/src/ui/parse-stdout.ts index 33fd970b..43e56d55 100644 --- a/packages/adapters/cursor-local/src/ui/parse-stdout.ts +++ b/packages/adapters/cursor-local/src/ui/parse-stdout.ts @@ -142,6 +142,12 @@ function parseAssistantMessage(messageRaw: unknown, ts: string): TranscriptEntry kind: "tool_call", ts, name, + toolUseId: + asString(part.tool_use_id) || + asString(part.toolUseId) || + asString(part.call_id) || + asString(part.id) || + undefined, input, }); continue; @@ -199,6 +205,7 @@ function parseCursorToolCallEvent(event: Record, ts: string): T kind: "tool_call", ts, name: toolName, + toolUseId: callId, input, }]; } diff --git a/packages/adapters/gemini-local/package.json b/packages/adapters/gemini-local/package.json new file mode 100644 index 00000000..6b214f7e --- /dev/null +++ b/packages/adapters/gemini-local/package.json @@ -0,0 +1,51 @@ +{ + "name": "@paperclipai/adapter-gemini-local", + "version": "0.2.7", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./server": "./src/server/index.ts", + "./ui": "./src/ui/index.ts", + "./cli": "./src/cli/index.ts" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./server": { + "types": "./dist/server/index.d.ts", + "import": "./dist/server/index.js" + }, + "./ui": { + "types": "./dist/ui/index.d.ts", + "import": "./dist/ui/index.js" + }, + "./cli": { + "types": "./dist/cli/index.d.ts", + "import": "./dist/cli/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "files": [ + "dist", + "skills" + ], + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@paperclipai/adapter-utils": "workspace:*", + "picocolors": "^1.1.1" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "typescript": "^5.7.3" + } +} diff --git a/packages/adapters/gemini-local/src/cli/format-event.ts b/packages/adapters/gemini-local/src/cli/format-event.ts new file mode 100644 index 00000000..48611f02 --- /dev/null +++ b/packages/adapters/gemini-local/src/cli/format-event.ts @@ -0,0 +1,208 @@ +import pc from "picocolors"; + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function asString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function asNumber(value: unknown, fallback = 0): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function stringifyUnknown(value: unknown): string { + if (typeof value === "string") return value; + if (value === null || value === undefined) return ""; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function errorText(value: unknown): string { + if (typeof value === "string") return value; + const rec = asRecord(value); + if (!rec) return ""; + const msg = + (typeof rec.message === "string" && rec.message) || + (typeof rec.error === "string" && rec.error) || + (typeof rec.code === "string" && rec.code) || + ""; + if (msg) return msg; + try { + return JSON.stringify(rec); + } catch { + return ""; + } +} + +function printTextMessage(prefix: string, colorize: (text: string) => string, messageRaw: unknown): void { + if (typeof messageRaw === "string") { + const text = messageRaw.trim(); + if (text) console.log(colorize(`${prefix}: ${text}`)); + return; + } + + const message = asRecord(messageRaw); + if (!message) return; + + const directText = asString(message.text).trim(); + if (directText) console.log(colorize(`${prefix}: ${directText}`)); + + const content = Array.isArray(message.content) ? message.content : []; + for (const partRaw of content) { + const part = asRecord(partRaw); + if (!part) continue; + const type = asString(part.type).trim(); + + if (type === "output_text" || type === "text" || type === "content") { + const text = asString(part.text).trim() || asString(part.content).trim(); + if (text) console.log(colorize(`${prefix}: ${text}`)); + continue; + } + + if (type === "thinking") { + const text = asString(part.text).trim(); + if (text) console.log(pc.gray(`thinking: ${text}`)); + continue; + } + + if (type === "tool_call") { + const name = asString(part.name, asString(part.tool, "tool")); + console.log(pc.yellow(`tool_call: ${name}`)); + const input = part.input ?? part.arguments ?? part.args; + if (input !== undefined) console.log(pc.gray(stringifyUnknown(input))); + continue; + } + + if (type === "tool_result" || type === "tool_response") { + const isError = part.is_error === true || asString(part.status).toLowerCase() === "error"; + const contentText = + asString(part.output) || + asString(part.text) || + asString(part.result) || + stringifyUnknown(part.output ?? part.result ?? part.text ?? part.response); + console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`)); + if (contentText) console.log((isError ? pc.red : pc.gray)(contentText)); + } + } +} + +function printUsage(parsed: Record) { + const usage = asRecord(parsed.usage) ?? asRecord(parsed.usageMetadata); + const usageMetadata = asRecord(usage?.usageMetadata); + const source = usageMetadata ?? usage ?? {}; + const input = asNumber(source.input_tokens, asNumber(source.inputTokens, asNumber(source.promptTokenCount))); + const output = asNumber(source.output_tokens, asNumber(source.outputTokens, asNumber(source.candidatesTokenCount))); + const cached = asNumber( + source.cached_input_tokens, + asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount)), + ); + const cost = asNumber(parsed.total_cost_usd, asNumber(parsed.cost_usd, asNumber(parsed.cost))); + console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`)); +} + +export function printGeminiStreamEvent(raw: string, _debug: boolean): void { + const line = raw.trim(); + if (!line) return; + + let parsed: Record | null = null; + try { + parsed = JSON.parse(line) as Record; + } catch { + console.log(line); + return; + } + + const type = asString(parsed.type); + + if (type === "system") { + const subtype = asString(parsed.subtype); + if (subtype === "init") { + const sessionId = + asString(parsed.session_id) || + asString(parsed.sessionId) || + asString(parsed.sessionID) || + asString(parsed.checkpoint_id); + const model = asString(parsed.model); + const details = [sessionId ? `session: ${sessionId}` : "", model ? `model: ${model}` : ""] + .filter(Boolean) + .join(", "); + console.log(pc.blue(`Gemini init${details ? ` (${details})` : ""}`)); + return; + } + if (subtype === "error") { + const text = errorText(parsed.error ?? parsed.message ?? parsed.detail); + if (text) console.log(pc.red(`error: ${text}`)); + return; + } + console.log(pc.blue(`system: ${subtype || "event"}`)); + return; + } + + if (type === "assistant") { + printTextMessage("assistant", pc.green, parsed.message); + return; + } + + if (type === "user") { + printTextMessage("user", pc.gray, parsed.message); + return; + } + + if (type === "thinking") { + const text = asString(parsed.text).trim() || asString(asRecord(parsed.delta)?.text).trim(); + if (text) console.log(pc.gray(`thinking: ${text}`)); + return; + } + + if (type === "tool_call") { + const subtype = asString(parsed.subtype).trim().toLowerCase(); + const toolCall = asRecord(parsed.tool_call ?? parsed.toolCall); + const [toolName] = toolCall ? Object.keys(toolCall) : []; + if (!toolCall || !toolName) { + console.log(pc.yellow(`tool_call${subtype ? `: ${subtype}` : ""}`)); + return; + } + const payload = asRecord(toolCall[toolName]) ?? {}; + if (subtype === "started" || subtype === "start") { + console.log(pc.yellow(`tool_call: ${toolName}`)); + console.log(pc.gray(stringifyUnknown(payload.args ?? payload.input ?? payload.arguments ?? payload))); + return; + } + if (subtype === "completed" || subtype === "complete" || subtype === "finished") { + const isError = + parsed.is_error === true || + payload.is_error === true || + payload.error !== undefined || + asString(payload.status).toLowerCase() === "error"; + console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`)); + console.log((isError ? pc.red : pc.gray)(stringifyUnknown(payload.result ?? payload.output ?? payload.error))); + return; + } + console.log(pc.yellow(`tool_call: ${toolName}${subtype ? ` (${subtype})` : ""}`)); + return; + } + + if (type === "result") { + printUsage(parsed); + const subtype = asString(parsed.subtype, "result"); + const isError = parsed.is_error === true; + if (subtype || isError) { + console.log((isError ? pc.red : pc.blue)(`result: subtype=${subtype} is_error=${isError ? "true" : "false"}`)); + } + return; + } + + if (type === "error") { + const text = errorText(parsed.error ?? parsed.message ?? parsed.detail); + if (text) console.log(pc.red(`error: ${text}`)); + return; + } + + console.log(line); +} diff --git a/packages/adapters/gemini-local/src/cli/index.ts b/packages/adapters/gemini-local/src/cli/index.ts new file mode 100644 index 00000000..49ec4426 --- /dev/null +++ b/packages/adapters/gemini-local/src/cli/index.ts @@ -0,0 +1 @@ +export { printGeminiStreamEvent } from "./format-event.js"; diff --git a/packages/adapters/gemini-local/src/index.ts b/packages/adapters/gemini-local/src/index.ts new file mode 100644 index 00000000..3ceef32e --- /dev/null +++ b/packages/adapters/gemini-local/src/index.ts @@ -0,0 +1,48 @@ +export const type = "gemini_local"; +export const label = "Gemini CLI (local)"; +export const DEFAULT_GEMINI_LOCAL_MODEL = "auto"; + +export const models = [ + { id: DEFAULT_GEMINI_LOCAL_MODEL, label: "Auto" }, + { id: "gemini-2.5-pro", label: "Gemini 2.5 Pro" }, + { id: "gemini-2.5-flash", label: "Gemini 2.5 Flash" }, + { id: "gemini-2.5-flash-lite", label: "Gemini 2.5 Flash Lite" }, + { id: "gemini-2.0-flash", label: "Gemini 2.0 Flash" }, + { id: "gemini-2.0-flash-lite", label: "Gemini 2.0 Flash Lite" }, +]; + +export const agentConfigurationDoc = `# gemini_local agent configuration + +Adapter: gemini_local + +Use when: +- You want Paperclip to run the Gemini CLI locally on the host machine +- You want Gemini chat sessions resumed across heartbeats with --resume +- You want Paperclip skills injected locally without polluting the global environment + +Don't use when: +- You need webhook-style external invocation (use http or openclaw_gateway) +- You only need a one-shot script without an AI coding agent loop (use process) +- Gemini CLI is not installed on the machine that runs Paperclip + +Core fields: +- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible) +- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt +- promptTemplate (string, optional): run prompt template +- model (string, optional): Gemini model id. Defaults to auto. +- approvalMode (string, optional): "default", "auto_edit", or "yolo" (default: "default") +- sandbox (boolean, optional): run in sandbox mode (default: false, passes --sandbox=none) +- command (string, optional): defaults to "gemini" +- extraArgs (string[], optional): additional CLI args +- env (object, optional): KEY=VALUE environment variables + +Operational fields: +- timeoutSec (number, optional): run timeout in seconds +- graceSec (number, optional): SIGTERM grace period in seconds + +Notes: +- Runs use positional prompt arguments, not stdin. +- Sessions resume with --resume when stored session cwd matches the current cwd. +- Paperclip auto-injects local skills into \`~/.gemini/skills/\` via symlinks, so the CLI can discover both credentials and skills in their natural location. +- Authentication can use GEMINI_API_KEY / GOOGLE_API_KEY or local Gemini CLI login. +`; diff --git a/packages/adapters/gemini-local/src/server/execute.ts b/packages/adapters/gemini-local/src/server/execute.ts new file mode 100644 index 00000000..37a94232 --- /dev/null +++ b/packages/adapters/gemini-local/src/server/execute.ts @@ -0,0 +1,421 @@ +import fs from "node:fs/promises"; +import type { Dirent } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; +import { + asBoolean, + asNumber, + asString, + asStringArray, + buildPaperclipEnv, + ensureAbsoluteDirectory, + ensureCommandResolvable, + ensurePathInEnv, + parseObject, + redactEnvForLogs, + renderTemplate, + runChildProcess, +} from "@paperclipai/adapter-utils/server-utils"; +import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js"; +import { + describeGeminiFailure, + detectGeminiAuthRequired, + isGeminiTurnLimitResult, + isGeminiUnknownSessionError, + parseGeminiJsonl, +} from "./parse.js"; +import { firstNonEmptyLine } from "./utils.js"; + +const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); +const PAPERCLIP_SKILLS_CANDIDATES = [ + path.resolve(__moduleDir, "../../skills"), + path.resolve(__moduleDir, "../../../../../skills"), +]; + +function hasNonEmptyEnvValue(env: Record, key: string): boolean { + const raw = env[key]; + return typeof raw === "string" && raw.trim().length > 0; +} + +function resolveGeminiBillingType(env: Record): "api" | "subscription" { + return hasNonEmptyEnvValue(env, "GEMINI_API_KEY") || hasNonEmptyEnvValue(env, "GOOGLE_API_KEY") + ? "api" + : "subscription"; +} + +function renderPaperclipEnvNote(env: Record): string { + const paperclipKeys = Object.keys(env) + .filter((key) => key.startsWith("PAPERCLIP_")) + .sort(); + if (paperclipKeys.length === 0) return ""; + return [ + "Paperclip runtime note:", + `The following PAPERCLIP_* environment variables are available in this run: ${paperclipKeys.join(", ")}`, + "Do not assume these variables are missing without checking your shell environment.", + "", + "", + ].join("\n"); +} + +async function resolvePaperclipSkillsDir(): Promise { + for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) { + const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false); + if (isDir) return candidate; + } + return null; +} + +function geminiSkillsHome(): string { + return path.join(os.homedir(), ".gemini", "skills"); +} + +/** + * Inject Paperclip skills directly into `~/.gemini/skills/` via symlinks. + * This avoids needing GEMINI_CLI_HOME overrides, so the CLI naturally finds + * both its auth credentials and the injected skills in the real home directory. + */ +async function ensureGeminiSkillsInjected( + onLog: AdapterExecutionContext["onLog"], +): Promise { + const skillsDir = await resolvePaperclipSkillsDir(); + if (!skillsDir) return; + + const skillsHome = geminiSkillsHome(); + try { + await fs.mkdir(skillsHome, { recursive: true }); + } catch (err) { + await onLog( + "stderr", + `[paperclip] Failed to prepare Gemini skills directory ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`, + ); + return; + } + + let entries: Dirent[]; + try { + entries = await fs.readdir(skillsDir, { withFileTypes: true }); + } catch (err) { + await onLog( + "stderr", + `[paperclip] Failed to read Paperclip skills from ${skillsDir}: ${err instanceof Error ? err.message : String(err)}\n`, + ); + return; + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const source = path.join(skillsDir, entry.name); + const target = path.join(skillsHome, entry.name); + const existing = await fs.lstat(target).catch(() => null); + if (existing) continue; + + try { + await fs.symlink(source, target); + await onLog("stderr", `[paperclip] Linked Gemini skill: ${entry.name}\n`); + } catch (err) { + await onLog( + "stderr", + `[paperclip] Failed to link Gemini skill "${entry.name}": ${err instanceof Error ? err.message : String(err)}\n`, + ); + } + } +} + +export async function execute(ctx: AdapterExecutionContext): Promise { + const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx; + + const promptTemplate = asString( + config.promptTemplate, + "You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.", + ); + const command = asString(config.command, "gemini"); + const model = asString(config.model, DEFAULT_GEMINI_LOCAL_MODEL).trim(); + const approvalMode = asString(config.approvalMode, asBoolean(config.yolo, false) ? "yolo" : "default"); + const sandbox = asBoolean(config.sandbox, false); + + const workspaceContext = parseObject(context.paperclipWorkspace); + const workspaceCwd = asString(workspaceContext.cwd, ""); + const workspaceSource = asString(workspaceContext.source, ""); + const workspaceId = asString(workspaceContext.workspaceId, ""); + const workspaceRepoUrl = asString(workspaceContext.repoUrl, ""); + const workspaceRepoRef = asString(workspaceContext.repoRef, ""); + const workspaceHints = Array.isArray(context.paperclipWorkspaces) + ? context.paperclipWorkspaces.filter( + (value): value is Record => typeof value === "object" && value !== null, + ) + : []; + const configuredCwd = asString(config.cwd, ""); + const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0; + const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; + const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd(); + await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); + await ensureGeminiSkillsInjected(onLog); + + const envConfig = parseObject(config.env); + const hasExplicitApiKey = + typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0; + const env: Record = { ...buildPaperclipEnv(agent) }; + env.PAPERCLIP_RUN_ID = runId; + const wakeTaskId = + (typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) || + (typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) || + null; + const wakeReason = + typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0 + ? context.wakeReason.trim() + : null; + const wakeCommentId = + (typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) || + (typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) || + null; + const approvalId = + typeof context.approvalId === "string" && context.approvalId.trim().length > 0 + ? context.approvalId.trim() + : null; + const approvalStatus = + typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0 + ? context.approvalStatus.trim() + : null; + const linkedIssueIds = Array.isArray(context.issueIds) + ? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0) + : []; + if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId; + if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason; + if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId; + if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId; + if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus; + if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); + if (effectiveWorkspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd; + if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource; + if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId; + if (workspaceRepoUrl) env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl; + if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef; + if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); + + for (const [key, value] of Object.entries(envConfig)) { + if (typeof value === "string") env[key] = value; + } + if (!hasExplicitApiKey && authToken) { + env.PAPERCLIP_API_KEY = authToken; + } + const billingType = resolveGeminiBillingType(env); + const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); + await ensureCommandResolvable(command, cwd, runtimeEnv); + + const timeoutSec = asNumber(config.timeoutSec, 0); + const graceSec = asNumber(config.graceSec, 20); + const extraArgs = (() => { + const fromExtraArgs = asStringArray(config.extraArgs); + if (fromExtraArgs.length > 0) return fromExtraArgs; + return asStringArray(config.args); + })(); + + const runtimeSessionParams = parseObject(runtime.sessionParams); + const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? ""); + const runtimeSessionCwd = asString(runtimeSessionParams.cwd, ""); + const canResumeSession = + runtimeSessionId.length > 0 && + (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd)); + const sessionId = canResumeSession ? runtimeSessionId : null; + if (runtimeSessionId && !canResumeSession) { + await onLog( + "stderr", + `[paperclip] Gemini session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`, + ); + } + + const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); + const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : ""; + let instructionsPrefix = ""; + if (instructionsFilePath) { + try { + const instructionsContents = await fs.readFile(instructionsFilePath, "utf8"); + instructionsPrefix = + `${instructionsContents}\n\n` + + `The above agent instructions were loaded from ${instructionsFilePath}. ` + + `Resolve any relative file references from ${instructionsDir}.\n\n`; + await onLog( + "stderr", + `[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`, + ); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + await onLog( + "stderr", + `[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`, + ); + } + } + const commandNotes = (() => { + const notes: string[] = ["Prompt is passed to Gemini as the final positional argument."]; + if (approvalMode !== "default") notes.push(`Added --approval-mode ${approvalMode} for unattended execution.`); + if (!instructionsFilePath) return notes; + if (instructionsPrefix.length > 0) { + notes.push( + `Loaded agent instructions from ${instructionsFilePath}`, + `Prepended instructions + path directive to prompt (relative references from ${instructionsDir}).`, + ); + return notes; + } + notes.push( + `Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`, + ); + return notes; + })(); + + const renderedPrompt = renderTemplate(promptTemplate, { + agentId: agent.id, + companyId: agent.companyId, + runId, + company: { id: agent.companyId }, + agent, + run: { id: runId, source: "on_demand" }, + context, + }); + const paperclipEnvNote = renderPaperclipEnvNote(env); + const prompt = `${instructionsPrefix}${paperclipEnvNote}${renderedPrompt}`; + + const buildArgs = (resumeSessionId: string | null) => { + const args = ["--output-format", "stream-json"]; + if (resumeSessionId) args.push("--resume", resumeSessionId); + if (model && model !== DEFAULT_GEMINI_LOCAL_MODEL) args.push("--model", model); + if (approvalMode !== "default") args.push("--approval-mode", approvalMode); + if (sandbox) { + args.push("--sandbox"); + } else { + args.push("--sandbox=none"); + } + if (extraArgs.length > 0) args.push(...extraArgs); + args.push(prompt); + return args; + }; + + const runAttempt = async (resumeSessionId: string | null) => { + const args = buildArgs(resumeSessionId); + if (onMeta) { + await onMeta({ + adapterType: "gemini_local", + command, + cwd, + commandNotes, + commandArgs: args.map((value, index) => ( + index === args.length - 1 ? `` : value + )), + env: redactEnvForLogs(env), + prompt, + context, + }); + } + + const proc = await runChildProcess(runId, command, args, { + cwd, + env, + timeoutSec, + graceSec, + onLog, + }); + return { + proc, + parsed: parseGeminiJsonl(proc.stdout), + }; + }; + + const toResult = ( + attempt: { + proc: { + exitCode: number | null; + signal: string | null; + timedOut: boolean; + stdout: string; + stderr: string; + }; + parsed: ReturnType; + }, + clearSessionOnMissingSession = false, + isRetry = false, + ): AdapterExecutionResult => { + const authMeta = detectGeminiAuthRequired({ + parsed: attempt.parsed.resultEvent, + stdout: attempt.proc.stdout, + stderr: attempt.proc.stderr, + }); + + if (attempt.proc.timedOut) { + return { + exitCode: attempt.proc.exitCode, + signal: attempt.proc.signal, + timedOut: true, + errorMessage: `Timed out after ${timeoutSec}s`, + errorCode: authMeta.requiresAuth ? "gemini_auth_required" : null, + clearSession: clearSessionOnMissingSession, + }; + } + + const clearSessionForTurnLimit = isGeminiTurnLimitResult(attempt.parsed.resultEvent, attempt.proc.exitCode); + + // On retry, don't fall back to old session ID — the old session was stale + const canFallbackToRuntimeSession = !isRetry; + const resolvedSessionId = attempt.parsed.sessionId + ?? (canFallbackToRuntimeSession ? (runtimeSessionId ?? runtime.sessionId ?? null) : null); + const resolvedSessionParams = resolvedSessionId + ? ({ + sessionId: resolvedSessionId, + cwd, + ...(workspaceId ? { workspaceId } : {}), + ...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}), + ...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}), + } as Record) + : null; + const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : ""; + const stderrLine = firstNonEmptyLine(attempt.proc.stderr); + const structuredFailure = attempt.parsed.resultEvent + ? describeGeminiFailure(attempt.parsed.resultEvent) + : null; + const fallbackErrorMessage = + parsedError || + structuredFailure || + stderrLine || + `Gemini exited with code ${attempt.proc.exitCode ?? -1}`; + + return { + exitCode: attempt.proc.exitCode, + signal: attempt.proc.signal, + timedOut: false, + errorMessage: (attempt.proc.exitCode ?? 0) === 0 ? null : fallbackErrorMessage, + errorCode: (attempt.proc.exitCode ?? 0) !== 0 && authMeta.requiresAuth ? "gemini_auth_required" : null, + usage: attempt.parsed.usage, + sessionId: resolvedSessionId, + sessionParams: resolvedSessionParams, + sessionDisplayId: resolvedSessionId, + provider: "google", + model, + billingType, + costUsd: attempt.parsed.costUsd, + resultJson: attempt.parsed.resultEvent ?? { + stdout: attempt.proc.stdout, + stderr: attempt.proc.stderr, + }, + summary: attempt.parsed.summary, + clearSession: clearSessionForTurnLimit || Boolean(clearSessionOnMissingSession && !resolvedSessionId), + }; + }; + + const initial = await runAttempt(sessionId); + if ( + sessionId && + !initial.proc.timedOut && + (initial.proc.exitCode ?? 0) !== 0 && + isGeminiUnknownSessionError(initial.proc.stdout, initial.proc.stderr) + ) { + await onLog( + "stderr", + `[paperclip] Gemini resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`, + ); + const retry = await runAttempt(null); + return toResult(retry, true, true); + } + + return toResult(initial); +} diff --git a/packages/adapters/gemini-local/src/server/index.ts b/packages/adapters/gemini-local/src/server/index.ts new file mode 100644 index 00000000..1d35a2bf --- /dev/null +++ b/packages/adapters/gemini-local/src/server/index.ts @@ -0,0 +1,70 @@ +export { execute } from "./execute.js"; +export { testEnvironment } from "./test.js"; +export { + parseGeminiJsonl, + isGeminiUnknownSessionError, + describeGeminiFailure, + detectGeminiAuthRequired, + isGeminiTurnLimitResult, +} from "./parse.js"; +import type { AdapterSessionCodec } from "@paperclipai/adapter-utils"; + +function readNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +export const sessionCodec: AdapterSessionCodec = { + deserialize(raw: unknown) { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null; + const record = raw as Record; + const sessionId = + readNonEmptyString(record.sessionId) ?? + readNonEmptyString(record.session_id) ?? + readNonEmptyString(record.sessionID); + if (!sessionId) return null; + const cwd = + readNonEmptyString(record.cwd) ?? + readNonEmptyString(record.workdir) ?? + readNonEmptyString(record.folder); + const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id); + const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url); + const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref); + return { + sessionId, + ...(cwd ? { cwd } : {}), + ...(workspaceId ? { workspaceId } : {}), + ...(repoUrl ? { repoUrl } : {}), + ...(repoRef ? { repoRef } : {}), + }; + }, + serialize(params: Record | null) { + if (!params) return null; + const sessionId = + readNonEmptyString(params.sessionId) ?? + readNonEmptyString(params.session_id) ?? + readNonEmptyString(params.sessionID); + if (!sessionId) return null; + const cwd = + readNonEmptyString(params.cwd) ?? + readNonEmptyString(params.workdir) ?? + readNonEmptyString(params.folder); + const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id); + const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url); + const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref); + return { + sessionId, + ...(cwd ? { cwd } : {}), + ...(workspaceId ? { workspaceId } : {}), + ...(repoUrl ? { repoUrl } : {}), + ...(repoRef ? { repoRef } : {}), + }; + }, + getDisplayId(params: Record | null) { + if (!params) return null; + return ( + readNonEmptyString(params.sessionId) ?? + readNonEmptyString(params.session_id) ?? + readNonEmptyString(params.sessionID) + ); + }, +}; diff --git a/packages/adapters/gemini-local/src/server/parse.ts b/packages/adapters/gemini-local/src/server/parse.ts new file mode 100644 index 00000000..f25b0e88 --- /dev/null +++ b/packages/adapters/gemini-local/src/server/parse.ts @@ -0,0 +1,242 @@ +import { asNumber, asString, parseJson, parseObject } from "@paperclipai/adapter-utils/server-utils"; + +function collectMessageText(message: unknown): string[] { + if (typeof message === "string") { + const trimmed = message.trim(); + return trimmed ? [trimmed] : []; + } + + const record = parseObject(message); + const direct = asString(record.text, "").trim(); + const lines: string[] = direct ? [direct] : []; + const content = Array.isArray(record.content) ? record.content : []; + + for (const partRaw of content) { + const part = parseObject(partRaw); + const type = asString(part.type, "").trim(); + if (type === "output_text" || type === "text" || type === "content") { + const text = asString(part.text, "").trim() || asString(part.content, "").trim(); + if (text) lines.push(text); + } + } + + return lines; +} + +function readSessionId(event: Record): string | null { + return ( + asString(event.session_id, "").trim() || + asString(event.sessionId, "").trim() || + asString(event.sessionID, "").trim() || + asString(event.checkpoint_id, "").trim() || + asString(event.thread_id, "").trim() || + null + ); +} + +function asErrorText(value: unknown): string { + if (typeof value === "string") return value; + const rec = parseObject(value); + const message = + asString(rec.message, "") || + asString(rec.error, "") || + asString(rec.code, "") || + asString(rec.detail, ""); + if (message) return message; + try { + return JSON.stringify(rec); + } catch { + return ""; + } +} + +function accumulateUsage( + target: { inputTokens: number; cachedInputTokens: number; outputTokens: number }, + usageRaw: unknown, +) { + const usage = parseObject(usageRaw); + const usageMetadata = parseObject(usage.usageMetadata); + const source = Object.keys(usageMetadata).length > 0 ? usageMetadata : usage; + + target.inputTokens += asNumber( + source.input_tokens, + asNumber(source.inputTokens, asNumber(source.promptTokenCount, 0)), + ); + target.cachedInputTokens += asNumber( + source.cached_input_tokens, + asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount, 0)), + ); + target.outputTokens += asNumber( + source.output_tokens, + asNumber(source.outputTokens, asNumber(source.candidatesTokenCount, 0)), + ); +} + +export function parseGeminiJsonl(stdout: string) { + let sessionId: string | null = null; + const messages: string[] = []; + let errorMessage: string | null = null; + let costUsd: number | null = null; + let resultEvent: Record | null = null; + const usage = { + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + }; + + for (const rawLine of stdout.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line) continue; + + const event = parseJson(line); + if (!event) continue; + + const foundSessionId = readSessionId(event); + if (foundSessionId) sessionId = foundSessionId; + + const type = asString(event.type, "").trim(); + + if (type === "assistant") { + messages.push(...collectMessageText(event.message)); + continue; + } + + if (type === "result") { + resultEvent = event; + accumulateUsage(usage, event.usage ?? event.usageMetadata); + const resultText = + asString(event.result, "").trim() || + asString(event.text, "").trim() || + asString(event.response, "").trim(); + if (resultText && messages.length === 0) messages.push(resultText); + costUsd = asNumber(event.total_cost_usd, asNumber(event.cost_usd, asNumber(event.cost, costUsd ?? 0))) || costUsd; + const isError = event.is_error === true || asString(event.subtype, "").toLowerCase() === "error"; + if (isError) { + const text = asErrorText(event.error ?? event.message ?? event.result).trim(); + if (text) errorMessage = text; + } + continue; + } + + if (type === "error") { + const text = asErrorText(event.error ?? event.message ?? event.detail).trim(); + if (text) errorMessage = text; + continue; + } + + if (type === "system") { + const subtype = asString(event.subtype, "").trim().toLowerCase(); + if (subtype === "error") { + const text = asErrorText(event.error ?? event.message ?? event.detail).trim(); + if (text) errorMessage = text; + } + continue; + } + + if (type === "text") { + const part = parseObject(event.part); + const text = asString(part.text, "").trim(); + if (text) messages.push(text); + continue; + } + + if (type === "step_finish" || event.usage || event.usageMetadata) { + accumulateUsage(usage, event.usage ?? event.usageMetadata); + costUsd = asNumber(event.total_cost_usd, asNumber(event.cost_usd, asNumber(event.cost, costUsd ?? 0))) || costUsd; + continue; + } + } + + return { + sessionId, + summary: messages.join("\n\n").trim(), + usage, + costUsd, + errorMessage, + resultEvent, + }; +} + +export function isGeminiUnknownSessionError(stdout: string, stderr: string): boolean { + const haystack = `${stdout}\n${stderr}` + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .join("\n"); + + return /unknown\s+session|session\s+.*\s+not\s+found|resume\s+.*\s+not\s+found|checkpoint\s+.*\s+not\s+found|cannot\s+resume|failed\s+to\s+resume/i.test( + haystack, + ); +} + +function extractGeminiErrorMessages(parsed: Record): string[] { + const messages: string[] = []; + const errorMsg = asString(parsed.error, "").trim(); + if (errorMsg) messages.push(errorMsg); + + const raw = Array.isArray(parsed.errors) ? parsed.errors : []; + for (const entry of raw) { + if (typeof entry === "string") { + const msg = entry.trim(); + if (msg) messages.push(msg); + continue; + } + if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue; + const obj = entry as Record; + const msg = asString(obj.message, "") || asString(obj.error, "") || asString(obj.code, ""); + if (msg) { + messages.push(msg); + continue; + } + try { + messages.push(JSON.stringify(obj)); + } catch { + // skip non-serializable entry + } + } + + return messages; +} + +export function describeGeminiFailure(parsed: Record): string | null { + const status = asString(parsed.status, ""); + const errors = extractGeminiErrorMessages(parsed); + + const detail = errors[0] ?? ""; + const parts = ["Gemini run failed"]; + if (status) parts.push(`status=${status}`); + if (detail) parts.push(detail); + return parts.length > 1 ? parts.join(": ") : null; +} + +const GEMINI_AUTH_REQUIRED_RE = /(?:not\s+authenticated|please\s+authenticate|api[_ ]?key\s+(?:required|missing|invalid)|authentication\s+required|unauthorized|invalid\s+credentials|not\s+logged\s+in|login\s+required|run\s+`?gemini\s+auth(?:\s+login)?`?\s+first)/i; + +export function detectGeminiAuthRequired(input: { + parsed: Record | null; + stdout: string; + stderr: string; +}): { requiresAuth: boolean } { + const errors = extractGeminiErrorMessages(input.parsed ?? {}); + const messages = [...errors, input.stdout, input.stderr] + .join("\n") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + + const requiresAuth = messages.some((line) => GEMINI_AUTH_REQUIRED_RE.test(line)); + return { requiresAuth }; +} + +export function isGeminiTurnLimitResult( + parsed: Record | null | undefined, + exitCode?: number | null, +): boolean { + if (exitCode === 53) return true; + if (!parsed) return false; + + const status = asString(parsed.status, "").trim().toLowerCase(); + if (status === "turn_limit" || status === "max_turns") return true; + + const error = asString(parsed.error, "").trim(); + return /turn\s*limit|max(?:imum)?\s+turns?/i.test(error); +} diff --git a/packages/adapters/gemini-local/src/server/test.ts b/packages/adapters/gemini-local/src/server/test.ts new file mode 100644 index 00000000..8f63e5e2 --- /dev/null +++ b/packages/adapters/gemini-local/src/server/test.ts @@ -0,0 +1,223 @@ +import path from "node:path"; +import type { + AdapterEnvironmentCheck, + AdapterEnvironmentTestContext, + AdapterEnvironmentTestResult, +} from "@paperclipai/adapter-utils"; +import { + asBoolean, + asString, + asStringArray, + ensureAbsoluteDirectory, + ensureCommandResolvable, + ensurePathInEnv, + parseObject, + runChildProcess, +} from "@paperclipai/adapter-utils/server-utils"; +import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js"; +import { detectGeminiAuthRequired, parseGeminiJsonl } from "./parse.js"; +import { firstNonEmptyLine } from "./utils.js"; + +function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { + if (checks.some((check) => check.level === "error")) return "fail"; + if (checks.some((check) => check.level === "warn")) return "warn"; + return "pass"; +} + +function isNonEmpty(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function commandLooksLike(command: string, expected: string): boolean { + const base = path.basename(command).toLowerCase(); + return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`; +} + +function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null { + const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout); + if (!raw) return null; + const clean = raw.replace(/\s+/g, " ").trim(); + const max = 240; + return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean; +} + +export async function testEnvironment( + ctx: AdapterEnvironmentTestContext, +): Promise { + const checks: AdapterEnvironmentCheck[] = []; + const config = parseObject(ctx.config); + const command = asString(config.command, "gemini"); + const cwd = asString(config.cwd, process.cwd()); + + try { + await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); + checks.push({ + code: "gemini_cwd_valid", + level: "info", + message: `Working directory is valid: ${cwd}`, + }); + } catch (err) { + checks.push({ + code: "gemini_cwd_invalid", + level: "error", + message: err instanceof Error ? err.message : "Invalid working directory", + detail: cwd, + }); + } + + const envConfig = parseObject(config.env); + const env: Record = {}; + for (const [key, value] of Object.entries(envConfig)) { + if (typeof value === "string") env[key] = value; + } + const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); + try { + await ensureCommandResolvable(command, cwd, runtimeEnv); + checks.push({ + code: "gemini_command_resolvable", + level: "info", + message: `Command is executable: ${command}`, + }); + } catch (err) { + checks.push({ + code: "gemini_command_unresolvable", + level: "error", + message: err instanceof Error ? err.message : "Command is not executable", + detail: command, + }); + } + + const configGeminiApiKey = env.GEMINI_API_KEY; + const hostGeminiApiKey = process.env.GEMINI_API_KEY; + const configGoogleApiKey = env.GOOGLE_API_KEY; + const hostGoogleApiKey = process.env.GOOGLE_API_KEY; + const hasGca = env.GOOGLE_GENAI_USE_GCA === "true" || process.env.GOOGLE_GENAI_USE_GCA === "true"; + if ( + isNonEmpty(configGeminiApiKey) || + isNonEmpty(hostGeminiApiKey) || + isNonEmpty(configGoogleApiKey) || + isNonEmpty(hostGoogleApiKey) || + hasGca + ) { + const source = hasGca + ? "Google account login (GCA)" + : isNonEmpty(configGeminiApiKey) || isNonEmpty(configGoogleApiKey) + ? "adapter config env" + : "server environment"; + checks.push({ + code: "gemini_api_key_present", + level: "info", + message: "Gemini API credentials are set for CLI authentication.", + detail: `Detected in ${source}.`, + }); + } else { + checks.push({ + code: "gemini_api_key_missing", + level: "info", + message: "No explicit API key detected. Gemini CLI may still authenticate via `gemini auth login` (OAuth).", + hint: "If the hello probe fails with an auth error, set GEMINI_API_KEY or GOOGLE_API_KEY in adapter env, or run `gemini auth login`.", + }); + } + + const canRunProbe = + checks.every((check) => check.code !== "gemini_cwd_invalid" && check.code !== "gemini_command_unresolvable"); + if (canRunProbe) { + if (!commandLooksLike(command, "gemini")) { + checks.push({ + code: "gemini_hello_probe_skipped_custom_command", + level: "info", + message: "Skipped hello probe because command is not `gemini`.", + detail: command, + hint: "Use the `gemini` CLI command to run the automatic installation and auth probe.", + }); + } else { + const model = asString(config.model, DEFAULT_GEMINI_LOCAL_MODEL).trim(); + const approvalMode = asString(config.approvalMode, asBoolean(config.yolo, false) ? "yolo" : "default"); + const sandbox = asBoolean(config.sandbox, false); + const extraArgs = (() => { + const fromExtraArgs = asStringArray(config.extraArgs); + if (fromExtraArgs.length > 0) return fromExtraArgs; + return asStringArray(config.args); + })(); + + const args = ["--output-format", "stream-json"]; + if (model && model !== DEFAULT_GEMINI_LOCAL_MODEL) args.push("--model", model); + if (approvalMode !== "default") args.push("--approval-mode", approvalMode); + if (sandbox) { + args.push("--sandbox"); + } else { + args.push("--sandbox=none"); + } + if (extraArgs.length > 0) args.push(...extraArgs); + args.push("Respond with hello."); + + const probe = await runChildProcess( + `gemini-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`, + command, + args, + { + cwd, + env, + timeoutSec: 45, + graceSec: 5, + onLog: async () => { }, + }, + ); + const parsed = parseGeminiJsonl(probe.stdout); + const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage); + const authMeta = detectGeminiAuthRequired({ + parsed: parsed.resultEvent, + stdout: probe.stdout, + stderr: probe.stderr, + }); + + if (probe.timedOut) { + checks.push({ + code: "gemini_hello_probe_timed_out", + level: "warn", + message: "Gemini hello probe timed out.", + hint: "Retry the probe. If this persists, verify Gemini can run `Respond with hello.` from this directory manually.", + }); + } else if ((probe.exitCode ?? 1) === 0) { + const summary = parsed.summary.trim(); + const hasHello = /\bhello\b/i.test(summary); + checks.push({ + code: hasHello ? "gemini_hello_probe_passed" : "gemini_hello_probe_unexpected_output", + level: hasHello ? "info" : "warn", + message: hasHello + ? "Gemini hello probe succeeded." + : "Gemini probe ran but did not return `hello` as expected.", + ...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}), + ...(hasHello + ? {} + : { + hint: "Try `gemini --output-format json \"Respond with hello.\"` manually to inspect full output.", + }), + }); + } else if (authMeta.requiresAuth) { + checks.push({ + code: "gemini_hello_probe_auth_required", + level: "warn", + message: "Gemini CLI is installed, but authentication is not ready.", + ...(detail ? { detail } : {}), + hint: "Run `gemini auth` or configure GEMINI_API_KEY / GOOGLE_API_KEY in adapter env/shell, then retry the probe.", + }); + } else { + checks.push({ + code: "gemini_hello_probe_failed", + level: "error", + message: "Gemini hello probe failed.", + ...(detail ? { detail } : {}), + hint: "Run `gemini --output-format json \"Respond with hello.\"` manually in this working directory to debug.", + }); + } + } + } + + return { + adapterType: ctx.adapterType, + status: summarizeStatus(checks), + checks, + testedAt: new Date().toISOString(), + }; +} diff --git a/packages/adapters/gemini-local/src/server/utils.ts b/packages/adapters/gemini-local/src/server/utils.ts new file mode 100644 index 00000000..fb11c75d --- /dev/null +++ b/packages/adapters/gemini-local/src/server/utils.ts @@ -0,0 +1,8 @@ +export function firstNonEmptyLine(text: string): string { + return ( + text + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? "" + ); +} diff --git a/packages/adapters/gemini-local/src/ui/build-config.ts b/packages/adapters/gemini-local/src/ui/build-config.ts new file mode 100644 index 00000000..1fd7ac65 --- /dev/null +++ b/packages/adapters/gemini-local/src/ui/build-config.ts @@ -0,0 +1,75 @@ +import type { CreateConfigValues } from "@paperclipai/adapter-utils"; +import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js"; + +function parseCommaArgs(value: string): string[] { + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +function parseEnvVars(text: string): Record { + const env: Record = {}; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq <= 0) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + env[key] = value; + } + return env; +} + +function parseEnvBindings(bindings: unknown): Record { + if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {}; + const env: Record = {}; + for (const [key, raw] of Object.entries(bindings)) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + if (typeof raw === "string") { + env[key] = { type: "plain", value: raw }; + continue; + } + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue; + const rec = raw as Record; + if (rec.type === "plain" && typeof rec.value === "string") { + env[key] = { type: "plain", value: rec.value }; + continue; + } + if (rec.type === "secret_ref" && typeof rec.secretId === "string") { + env[key] = { + type: "secret_ref", + secretId: rec.secretId, + ...(typeof rec.version === "number" || rec.version === "latest" + ? { version: rec.version } + : {}), + }; + } + } + return env; +} + +export function buildGeminiLocalConfig(v: CreateConfigValues): Record { + const ac: Record = {}; + if (v.cwd) ac.cwd = v.cwd; + if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath; + if (v.promptTemplate) ac.promptTemplate = v.promptTemplate; + ac.model = v.model || DEFAULT_GEMINI_LOCAL_MODEL; + ac.timeoutSec = 0; + ac.graceSec = 15; + const env = parseEnvBindings(v.envBindings); + const legacy = parseEnvVars(v.envVars); + for (const [key, value] of Object.entries(legacy)) { + if (!Object.prototype.hasOwnProperty.call(env, key)) { + env[key] = { type: "plain", value }; + } + } + if (Object.keys(env).length > 0) ac.env = env; + if (v.dangerouslyBypassSandbox) ac.approvalMode = "yolo"; + ac.sandbox = !v.dangerouslyBypassSandbox; + if (v.command) ac.command = v.command; + if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs); + return ac; +} diff --git a/packages/adapters/gemini-local/src/ui/index.ts b/packages/adapters/gemini-local/src/ui/index.ts new file mode 100644 index 00000000..5d7708b1 --- /dev/null +++ b/packages/adapters/gemini-local/src/ui/index.ts @@ -0,0 +1,2 @@ +export { parseGeminiStdoutLine } from "./parse-stdout.js"; +export { buildGeminiLocalConfig } from "./build-config.js"; diff --git a/packages/adapters/gemini-local/src/ui/parse-stdout.ts b/packages/adapters/gemini-local/src/ui/parse-stdout.ts new file mode 100644 index 00000000..47426fa3 --- /dev/null +++ b/packages/adapters/gemini-local/src/ui/parse-stdout.ts @@ -0,0 +1,274 @@ +import type { TranscriptEntry } from "@paperclipai/adapter-utils"; + +function safeJsonParse(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function asString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function asNumber(value: unknown, fallback = 0): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function stringifyUnknown(value: unknown): string { + if (typeof value === "string") return value; + if (value === null || value === undefined) return ""; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function errorText(value: unknown): string { + if (typeof value === "string") return value; + const rec = asRecord(value); + if (!rec) return ""; + const msg = + (typeof rec.message === "string" && rec.message) || + (typeof rec.error === "string" && rec.error) || + (typeof rec.code === "string" && rec.code) || + ""; + if (msg) return msg; + try { + return JSON.stringify(rec); + } catch { + return ""; + } +} + +function collectTextEntries(messageRaw: unknown, ts: string, kind: "assistant" | "user"): TranscriptEntry[] { + if (typeof messageRaw === "string") { + const text = messageRaw.trim(); + return text ? [{ kind, ts, text }] : []; + } + + const message = asRecord(messageRaw); + if (!message) return []; + + const entries: TranscriptEntry[] = []; + const directText = asString(message.text).trim(); + if (directText) entries.push({ kind, ts, text: directText }); + + const content = Array.isArray(message.content) ? message.content : []; + for (const partRaw of content) { + const part = asRecord(partRaw); + if (!part) continue; + const type = asString(part.type).trim(); + if (type !== "output_text" && type !== "text" && type !== "content") continue; + const text = asString(part.text).trim() || asString(part.content).trim(); + if (text) entries.push({ kind, ts, text }); + } + + return entries; +} + +function parseAssistantMessage(messageRaw: unknown, ts: string): TranscriptEntry[] { + if (typeof messageRaw === "string") { + const text = messageRaw.trim(); + return text ? [{ kind: "assistant", ts, text }] : []; + } + + const message = asRecord(messageRaw); + if (!message) return []; + + const entries: TranscriptEntry[] = []; + const directText = asString(message.text).trim(); + if (directText) entries.push({ kind: "assistant", ts, text: directText }); + + const content = Array.isArray(message.content) ? message.content : []; + for (const partRaw of content) { + const part = asRecord(partRaw); + if (!part) continue; + const type = asString(part.type).trim(); + + if (type === "output_text" || type === "text" || type === "content") { + const text = asString(part.text).trim() || asString(part.content).trim(); + if (text) entries.push({ kind: "assistant", ts, text }); + continue; + } + + if (type === "thinking") { + const text = asString(part.text).trim(); + if (text) entries.push({ kind: "thinking", ts, text }); + continue; + } + + if (type === "tool_call") { + const name = asString(part.name, asString(part.tool, "tool")); + entries.push({ + kind: "tool_call", + ts, + name, + input: part.input ?? part.arguments ?? part.args ?? {}, + }); + continue; + } + + if (type === "tool_result" || type === "tool_response") { + const toolUseId = + asString(part.tool_use_id) || + asString(part.toolUseId) || + asString(part.call_id) || + asString(part.id) || + "tool_result"; + const contentText = + asString(part.output) || + asString(part.text) || + asString(part.result) || + stringifyUnknown(part.output ?? part.result ?? part.text ?? part.response); + const isError = part.is_error === true || asString(part.status).toLowerCase() === "error"; + entries.push({ + kind: "tool_result", + ts, + toolUseId, + content: contentText, + isError, + }); + } + } + + return entries; +} + +function parseTopLevelToolEvent(parsed: Record, ts: string): TranscriptEntry[] { + const subtype = asString(parsed.subtype).trim().toLowerCase(); + const callId = asString(parsed.call_id, asString(parsed.callId, asString(parsed.id, "tool_call"))); + const toolCall = asRecord(parsed.tool_call ?? parsed.toolCall); + if (!toolCall) { + return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}` }]; + } + + const [toolName] = Object.keys(toolCall); + if (!toolName) { + return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}` }]; + } + const payload = asRecord(toolCall[toolName]) ?? {}; + + if (subtype === "started" || subtype === "start") { + return [{ + kind: "tool_call", + ts, + name: toolName, + input: payload.args ?? payload.input ?? payload.arguments ?? payload, + }]; + } + + if (subtype === "completed" || subtype === "complete" || subtype === "finished") { + const result = payload.result ?? payload.output ?? payload.error; + const isError = + parsed.is_error === true || + payload.is_error === true || + payload.error !== undefined || + asString(payload.status).toLowerCase() === "error"; + return [{ + kind: "tool_result", + ts, + toolUseId: callId, + content: result !== undefined ? stringifyUnknown(result) : `${toolName} completed`, + isError, + }]; + } + + return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}: ${toolName}` }]; +} + +function readSessionId(parsed: Record): string { + return ( + asString(parsed.session_id) || + asString(parsed.sessionId) || + asString(parsed.sessionID) || + asString(parsed.checkpoint_id) || + asString(parsed.thread_id) + ); +} + +function readUsage(parsed: Record) { + const usage = asRecord(parsed.usage) ?? asRecord(parsed.usageMetadata); + const usageMetadata = asRecord(usage?.usageMetadata); + const source = usageMetadata ?? usage ?? {}; + return { + inputTokens: asNumber(source.input_tokens, asNumber(source.inputTokens, asNumber(source.promptTokenCount))), + outputTokens: asNumber(source.output_tokens, asNumber(source.outputTokens, asNumber(source.candidatesTokenCount))), + cachedTokens: asNumber( + source.cached_input_tokens, + asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount)), + ), + }; +} + +export function parseGeminiStdoutLine(line: string, ts: string): TranscriptEntry[] { + const parsed = asRecord(safeJsonParse(line)); + if (!parsed) { + return [{ kind: "stdout", ts, text: line }]; + } + + const type = asString(parsed.type); + + if (type === "system") { + const subtype = asString(parsed.subtype); + if (subtype === "init") { + const sessionId = readSessionId(parsed); + return [{ kind: "init", ts, model: asString(parsed.model, "gemini"), sessionId }]; + } + if (subtype === "error") { + const text = errorText(parsed.error ?? parsed.message ?? parsed.detail); + return [{ kind: "stderr", ts, text: text || "error" }]; + } + return [{ kind: "system", ts, text: `system: ${subtype || "event"}` }]; + } + + if (type === "assistant") { + return parseAssistantMessage(parsed.message, ts); + } + + if (type === "user") { + return collectTextEntries(parsed.message, ts, "user"); + } + + if (type === "thinking") { + const text = asString(parsed.text).trim() || asString(asRecord(parsed.delta)?.text).trim(); + return text ? [{ kind: "thinking", ts, text }] : []; + } + + if (type === "tool_call") { + return parseTopLevelToolEvent(parsed, ts); + } + + if (type === "result") { + const usage = readUsage(parsed); + const errors = parsed.is_error === true + ? [errorText(parsed.error ?? parsed.message ?? parsed.result)].filter(Boolean) + : []; + return [{ + kind: "result", + ts, + text: asString(parsed.result) || asString(parsed.text) || asString(parsed.response), + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + cachedTokens: usage.cachedTokens, + costUsd: asNumber(parsed.total_cost_usd, asNumber(parsed.cost_usd, asNumber(parsed.cost))), + subtype: asString(parsed.subtype, "result"), + isError: parsed.is_error === true, + errors, + }]; + } + + if (type === "error") { + const text = errorText(parsed.error ?? parsed.message ?? parsed.detail); + return [{ kind: "stderr", ts, text: text || "error" }]; + } + + return [{ kind: "stdout", ts, text: line }]; +} diff --git a/packages/adapters/gemini-local/tsconfig.json b/packages/adapters/gemini-local/tsconfig.json new file mode 100644 index 00000000..2f355cfe --- /dev/null +++ b/packages/adapters/gemini-local/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/adapters/openclaw-gateway/src/server/execute.ts b/packages/adapters/openclaw-gateway/src/server/execute.ts index ffd55a05..eaacbd33 100644 --- a/packages/adapters/openclaw-gateway/src/server/execute.ts +++ b/packages/adapters/openclaw-gateway/src/server/execute.ts @@ -1069,7 +1069,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise = { ...payloadTemplate, - paperclip: paperclipPayload, message, sessionKey, idempotencyKey: ctx.runId, diff --git a/packages/adapters/opencode-local/src/ui/parse-stdout.ts b/packages/adapters/opencode-local/src/ui/parse-stdout.ts index 2060125a..f8c98633 100644 --- a/packages/adapters/opencode-local/src/ui/parse-stdout.ts +++ b/packages/adapters/opencode-local/src/ui/parse-stdout.ts @@ -50,6 +50,7 @@ function parseToolUse(parsed: Record, ts: string): TranscriptEn kind: "tool_call", ts, name: toolName, + toolUseId: asString(part.callID) || asString(part.id) || undefined, input, }; diff --git a/packages/db/src/runtime-config.test.ts b/packages/db/src/runtime-config.test.ts index 55371e09..4627e691 100644 --- a/packages/db/src/runtime-config.test.ts +++ b/packages/db/src/runtime-config.test.ts @@ -46,6 +46,7 @@ describe("resolveDatabaseTarget", () => { const projectDir = path.join(tempDir, "repo"); fs.mkdirSync(projectDir, { recursive: true }); process.chdir(projectDir); + delete process.env.PAPERCLIP_CONFIG; writeJson(path.join(projectDir, ".paperclip", "config.json"), { database: { mode: "embedded-postgres", embeddedPostgresPort: 54329 }, }); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index ba75dc8e..bf2d3665 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -26,6 +26,7 @@ export const AGENT_ADAPTER_TYPES = [ "http", "claude_local", "codex_local", + "gemini_local", "opencode_local", "pi_local", "cursor", diff --git a/server/package.json b/server/package.json index aeb09944..1dd9b073 100644 --- a/server/package.json +++ b/server/package.json @@ -37,6 +37,7 @@ "@paperclipai/adapter-claude-local": "workspace:*", "@paperclipai/adapter-codex-local": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*", + "@paperclipai/adapter-gemini-local": "workspace:*", "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-pi-local": "workspace:*", "@paperclipai/adapter-openclaw-gateway": "workspace:*", diff --git a/server/src/__tests__/adapter-session-codecs.test.ts b/server/src/__tests__/adapter-session-codecs.test.ts index 82285b6a..acac2692 100644 --- a/server/src/__tests__/adapter-session-codecs.test.ts +++ b/server/src/__tests__/adapter-session-codecs.test.ts @@ -5,6 +5,10 @@ import { sessionCodec as cursorSessionCodec, isCursorUnknownSessionError, } from "@paperclipai/adapter-cursor-local/server"; +import { + sessionCodec as geminiSessionCodec, + isGeminiUnknownSessionError, +} from "@paperclipai/adapter-gemini-local/server"; import { sessionCodec as opencodeSessionCodec, isOpenCodeUnknownSessionError, @@ -82,6 +86,24 @@ describe("adapter session codecs", () => { }); expect(cursorSessionCodec.getDisplayId?.(serialized ?? null)).toBe("cursor-session-1"); }); + + it("normalizes gemini session params with cwd", () => { + const parsed = geminiSessionCodec.deserialize({ + session_id: "gemini-session-1", + cwd: "/tmp/gemini", + }); + expect(parsed).toEqual({ + sessionId: "gemini-session-1", + cwd: "/tmp/gemini", + }); + + const serialized = geminiSessionCodec.serialize(parsed); + expect(serialized).toEqual({ + sessionId: "gemini-session-1", + cwd: "/tmp/gemini", + }); + expect(geminiSessionCodec.getDisplayId?.(serialized ?? null)).toBe("gemini-session-1"); + }); }); describe("codex resume recovery detection", () => { @@ -146,3 +168,26 @@ describe("cursor resume recovery detection", () => { ).toBe(false); }); }); + +describe("gemini resume recovery detection", () => { + it("detects unknown session errors from gemini output", () => { + expect( + isGeminiUnknownSessionError( + "", + "unknown session id abc", + ), + ).toBe(true); + expect( + isGeminiUnknownSessionError( + "", + "checkpoint latest not found", + ), + ).toBe(true); + expect( + isGeminiUnknownSessionError( + "{\"type\":\"result\",\"subtype\":\"success\"}", + "", + ), + ).toBe(false); + }); +}); diff --git a/server/src/__tests__/codex-local-adapter.test.ts b/server/src/__tests__/codex-local-adapter.test.ts index 6136f76a..07733399 100644 --- a/server/src/__tests__/codex-local-adapter.test.ts +++ b/server/src/__tests__/codex-local-adapter.test.ts @@ -70,6 +70,7 @@ describe("codex_local ui stdout parser", () => { kind: "tool_call", ts, name: "command_execution", + toolUseId: "item_2", input: { id: "item_2", command: "/bin/zsh -lc ls" }, }, ]); diff --git a/server/src/__tests__/cursor-local-adapter.test.ts b/server/src/__tests__/cursor-local-adapter.test.ts index 258109cb..a1949af1 100644 --- a/server/src/__tests__/cursor-local-adapter.test.ts +++ b/server/src/__tests__/cursor-local-adapter.test.ts @@ -165,6 +165,7 @@ describe("cursor ui stdout parser", () => { kind: "tool_call", ts, name: "shellToolCall", + toolUseId: "call_shell_1", input: { command: longCommand }, }, ]); @@ -254,7 +255,7 @@ describe("cursor ui stdout parser", () => { }), ts, ), - ).toEqual([{ kind: "tool_call", ts, name: "readToolCall", input: { path: "README.md" } }]); + ).toEqual([{ kind: "tool_call", ts, name: "readToolCall", toolUseId: "call_1", input: { path: "README.md" } }]); expect( parseCursorStdoutLine( diff --git a/server/src/__tests__/gemini-local-adapter-environment.test.ts b/server/src/__tests__/gemini-local-adapter-environment.test.ts new file mode 100644 index 00000000..d4170e31 --- /dev/null +++ b/server/src/__tests__/gemini-local-adapter-environment.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { testEnvironment } from "@paperclipai/adapter-gemini-local/server"; + +async function writeFakeGeminiCommand(binDir: string, argsCapturePath: string): Promise { + const commandPath = path.join(binDir, "gemini"); + const script = `#!/usr/bin/env node +const fs = require("node:fs"); +const outPath = process.env.PAPERCLIP_TEST_ARGS_PATH; +if (outPath) { + fs.writeFileSync(outPath, JSON.stringify(process.argv.slice(2)), "utf8"); +} +console.log(JSON.stringify({ + type: "assistant", + message: { content: [{ type: "output_text", text: "hello" }] }, +})); +console.log(JSON.stringify({ + type: "result", + subtype: "success", + result: "hello", +})); +`; + await fs.writeFile(commandPath, script, "utf8"); + await fs.chmod(commandPath, 0o755); + return commandPath; +} + +describe("gemini_local environment diagnostics", () => { + it("creates a missing working directory when cwd is absolute", async () => { + const cwd = path.join( + os.tmpdir(), + `paperclip-gemini-local-cwd-${Date.now()}-${Math.random().toString(16).slice(2)}`, + "workspace", + ); + + await fs.rm(path.dirname(cwd), { recursive: true, force: true }); + + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "gemini_local", + config: { + command: process.execPath, + cwd, + }, + }); + + expect(result.checks.some((check) => check.code === "gemini_cwd_valid")).toBe(true); + expect(result.checks.some((check) => check.level === "error")).toBe(false); + const stats = await fs.stat(cwd); + expect(stats.isDirectory()).toBe(true); + await fs.rm(path.dirname(cwd), { recursive: true, force: true }); + }); + + it("passes model and yolo flags to the hello probe", async () => { + const root = path.join( + os.tmpdir(), + `paperclip-gemini-local-probe-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + const binDir = path.join(root, "bin"); + const cwd = path.join(root, "workspace"); + const argsCapturePath = path.join(root, "args.json"); + await fs.mkdir(binDir, { recursive: true }); + await writeFakeGeminiCommand(binDir, argsCapturePath); + + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "gemini_local", + config: { + command: "gemini", + cwd, + model: "gemini-2.5-pro", + yolo: true, + env: { + GEMINI_API_KEY: "test-key", + PAPERCLIP_TEST_ARGS_PATH: argsCapturePath, + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`, + }, + }, + }); + + expect(result.status).not.toBe("fail"); + const args = JSON.parse(await fs.readFile(argsCapturePath, "utf8")) as string[]; + expect(args).toContain("--model"); + expect(args).toContain("gemini-2.5-pro"); + expect(args).toContain("--approval-mode"); + expect(args).toContain("yolo"); + await fs.rm(root, { recursive: true, force: true }); + }); +}); diff --git a/server/src/__tests__/gemini-local-adapter.test.ts b/server/src/__tests__/gemini-local-adapter.test.ts new file mode 100644 index 00000000..41da0530 --- /dev/null +++ b/server/src/__tests__/gemini-local-adapter.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it, vi } from "vitest"; +import { isGeminiUnknownSessionError, parseGeminiJsonl } from "@paperclipai/adapter-gemini-local/server"; +import { parseGeminiStdoutLine } from "@paperclipai/adapter-gemini-local/ui"; +import { printGeminiStreamEvent } from "@paperclipai/adapter-gemini-local/cli"; + +describe("gemini_local parser", () => { + it("extracts session, summary, usage, cost, and terminal error message", () => { + const stdout = [ + JSON.stringify({ type: "system", subtype: "init", session_id: "gemini-session-1", model: "gemini-2.5-pro" }), + JSON.stringify({ + type: "assistant", + message: { + content: [{ type: "output_text", text: "hello" }], + }, + }), + JSON.stringify({ + type: "result", + subtype: "success", + session_id: "gemini-session-1", + usage: { + promptTokenCount: 12, + cachedContentTokenCount: 3, + candidatesTokenCount: 7, + }, + total_cost_usd: 0.00123, + result: "done", + }), + JSON.stringify({ type: "error", message: "model access denied" }), + ].join("\n"); + + const parsed = parseGeminiJsonl(stdout); + expect(parsed.sessionId).toBe("gemini-session-1"); + expect(parsed.summary).toBe("hello"); + expect(parsed.usage).toEqual({ + inputTokens: 12, + cachedInputTokens: 3, + outputTokens: 7, + }); + expect(parsed.costUsd).toBeCloseTo(0.00123, 6); + expect(parsed.errorMessage).toBe("model access denied"); + }); +}); + +describe("gemini_local stale session detection", () => { + it("treats missing session messages as an unknown session error", () => { + expect(isGeminiUnknownSessionError("", "unknown session id abc")).toBe(true); + expect(isGeminiUnknownSessionError("", "checkpoint latest not found")).toBe(true); + }); +}); + +describe("gemini_local ui stdout parser", () => { + it("parses assistant, thinking, and result events", () => { + const ts = "2026-03-08T00:00:00.000Z"; + + expect( + parseGeminiStdoutLine( + JSON.stringify({ + type: "assistant", + message: { + content: [ + { type: "output_text", text: "I checked the repo." }, + { type: "thinking", text: "Reviewing adapter registry" }, + { type: "tool_call", name: "shell", input: { command: "ls -1" } }, + { type: "tool_result", tool_use_id: "tool_1", output: "AGENTS.md\n", status: "ok" }, + ], + }, + }), + ts, + ), + ).toEqual([ + { kind: "assistant", ts, text: "I checked the repo." }, + { kind: "thinking", ts, text: "Reviewing adapter registry" }, + { kind: "tool_call", ts, name: "shell", input: { command: "ls -1" } }, + { kind: "tool_result", ts, toolUseId: "tool_1", content: "AGENTS.md\n", isError: false }, + ]); + + expect( + parseGeminiStdoutLine( + JSON.stringify({ + type: "result", + subtype: "success", + result: "Done", + usage: { + promptTokenCount: 10, + candidatesTokenCount: 5, + cachedContentTokenCount: 2, + }, + total_cost_usd: 0.00042, + is_error: false, + }), + ts, + ), + ).toEqual([ + { + kind: "result", + ts, + text: "Done", + inputTokens: 10, + outputTokens: 5, + cachedTokens: 2, + costUsd: 0.00042, + subtype: "success", + isError: false, + errors: [], + }, + ]); + }); +}); + +function stripAnsi(value: string): string { + return value.replace(/\x1b\[[0-9;]*m/g, ""); +} + +describe("gemini_local cli formatter", () => { + it("prints init, assistant, result, and error events", () => { + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); + let joined = ""; + + try { + printGeminiStreamEvent( + JSON.stringify({ type: "system", subtype: "init", session_id: "gemini-session-1", model: "gemini-2.5-pro" }), + false, + ); + printGeminiStreamEvent( + JSON.stringify({ + type: "assistant", + message: { content: [{ type: "output_text", text: "hello" }] }, + }), + false, + ); + printGeminiStreamEvent( + JSON.stringify({ + type: "result", + subtype: "success", + usage: { + promptTokenCount: 10, + candidatesTokenCount: 5, + cachedContentTokenCount: 2, + }, + total_cost_usd: 0.00042, + }), + false, + ); + printGeminiStreamEvent( + JSON.stringify({ type: "error", message: "boom" }), + false, + ); + joined = spy.mock.calls.map((call) => stripAnsi(call.join(" "))).join("\n"); + } finally { + spy.mockRestore(); + } + + expect(joined).toContain("Gemini init"); + expect(joined).toContain("assistant: hello"); + expect(joined).toContain("tokens: in=10 out=5 cached=2 cost=$0.000420"); + expect(joined).toContain("error: boom"); + }); +}); diff --git a/server/src/__tests__/gemini-local-execute.test.ts b/server/src/__tests__/gemini-local-execute.test.ts new file mode 100644 index 00000000..92f8779a --- /dev/null +++ b/server/src/__tests__/gemini-local-execute.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { execute } from "@paperclipai/adapter-gemini-local/server"; + +async function writeFakeGeminiCommand(commandPath: string): Promise { + const script = `#!/usr/bin/env node +const fs = require("node:fs"); + +const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH; +const payload = { + argv: process.argv.slice(2), + paperclipEnvKeys: Object.keys(process.env) + .filter((key) => key.startsWith("PAPERCLIP_")) + .sort(), +}; +if (capturePath) { + fs.writeFileSync(capturePath, JSON.stringify(payload), "utf8"); +} +console.log(JSON.stringify({ + type: "system", + subtype: "init", + session_id: "gemini-session-1", + model: "gemini-2.5-pro", +})); +console.log(JSON.stringify({ + type: "assistant", + message: { content: [{ type: "output_text", text: "hello" }] }, +})); +console.log(JSON.stringify({ + type: "result", + subtype: "success", + session_id: "gemini-session-1", + result: "ok", +})); +`; + await fs.writeFile(commandPath, script, "utf8"); + await fs.chmod(commandPath, 0o755); +} + +type CapturePayload = { + argv: string[]; + paperclipEnvKeys: string[]; +}; + +describe("gemini execute", () => { + it("passes prompt as final argument and injects paperclip env vars", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-execute-")); + const workspace = path.join(root, "workspace"); + const commandPath = path.join(root, "gemini"); + const capturePath = path.join(root, "capture.json"); + await fs.mkdir(workspace, { recursive: true }); + await writeFakeGeminiCommand(commandPath); + + const previousHome = process.env.HOME; + process.env.HOME = root; + + let invocationPrompt = ""; + try { + const result = await execute({ + runId: "run-1", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Gemini Coder", + adapterType: "gemini_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: commandPath, + cwd: workspace, + model: "gemini-2.5-pro", + yolo: true, + env: { + PAPERCLIP_TEST_CAPTURE_PATH: capturePath, + }, + promptTemplate: "Follow the paperclip heartbeat.", + }, + context: {}, + authToken: "run-jwt-token", + onLog: async () => {}, + onMeta: async (meta) => { + invocationPrompt = meta.prompt ?? ""; + }, + }); + + expect(result.exitCode).toBe(0); + expect(result.errorMessage).toBeNull(); + + const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload; + expect(capture.argv).toContain("--output-format"); + expect(capture.argv).toContain("stream-json"); + expect(capture.argv).toContain("--approval-mode"); + expect(capture.argv).toContain("yolo"); + expect(capture.argv.at(-1)).toContain("Follow the paperclip heartbeat."); + expect(capture.argv.at(-1)).toContain("Paperclip runtime note:"); + expect(capture.paperclipEnvKeys).toEqual( + expect.arrayContaining([ + "PAPERCLIP_AGENT_ID", + "PAPERCLIP_API_KEY", + "PAPERCLIP_API_URL", + "PAPERCLIP_COMPANY_ID", + "PAPERCLIP_RUN_ID", + ]), + ); + expect(invocationPrompt).toContain("Paperclip runtime note:"); + expect(invocationPrompt).toContain("PAPERCLIP_API_URL"); + } finally { + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + await fs.rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/server/src/__tests__/openclaw-gateway-adapter.test.ts b/server/src/__tests__/openclaw-gateway-adapter.test.ts index 04a44e72..07bab9da 100644 --- a/server/src/__tests__/openclaw-gateway-adapter.test.ts +++ b/server/src/__tests__/openclaw-gateway-adapter.test.ts @@ -456,33 +456,6 @@ describe("openclaw gateway adapter execute", () => { expect(String(payload?.message ?? "")).toContain("wake now"); expect(String(payload?.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); expect(String(payload?.message ?? "")).toContain("PAPERCLIP_TASK_ID=task-123"); - expect(payload?.paperclip).toEqual( - expect.objectContaining({ - runId: "run-123", - companyId: "company-123", - agentId: "agent-123", - taskId: "task-123", - issueId: "issue-123", - workspace: expect.objectContaining({ - cwd: "/tmp/worktrees/pap-123", - strategy: "git_worktree", - }), - workspaces: [ - expect.objectContaining({ - id: "workspace-1", - cwd: "/tmp/project", - }), - ], - workspaceRuntime: expect.objectContaining({ - services: [ - expect.objectContaining({ - name: "preview", - lifecycle: "ephemeral", - }), - ], - }), - }), - ); expect(logs.some((entry) => entry.includes("[openclaw-gateway:event] run=run-123 stream=assistant"))).toBe(true); } finally { diff --git a/server/src/__tests__/opencode-local-adapter.test.ts b/server/src/__tests__/opencode-local-adapter.test.ts index e37bc1ec..d4f89a49 100644 --- a/server/src/__tests__/opencode-local-adapter.test.ts +++ b/server/src/__tests__/opencode-local-adapter.test.ts @@ -103,6 +103,7 @@ describe("opencode_local ui stdout parser", () => { kind: "tool_call", ts, name: "bash", + toolUseId: "call_1", input: { command: "ls -1" }, }, { diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 9fe536a0..14cdf6d9 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -17,6 +17,12 @@ import { sessionCodec as cursorSessionCodec, } from "@paperclipai/adapter-cursor-local/server"; import { agentConfigurationDoc as cursorAgentConfigurationDoc, models as cursorModels } from "@paperclipai/adapter-cursor-local"; +import { + execute as geminiExecute, + testEnvironment as geminiTestEnvironment, + sessionCodec as geminiSessionCodec, +} from "@paperclipai/adapter-gemini-local/server"; +import { agentConfigurationDoc as geminiAgentConfigurationDoc, models as geminiModels } from "@paperclipai/adapter-gemini-local"; import { execute as openCodeExecute, testEnvironment as openCodeTestEnvironment, @@ -80,6 +86,16 @@ const cursorLocalAdapter: ServerAdapterModule = { agentConfigurationDoc: cursorAgentConfigurationDoc, }; +const geminiLocalAdapter: ServerAdapterModule = { + type: "gemini_local", + execute: geminiExecute, + testEnvironment: geminiTestEnvironment, + sessionCodec: geminiSessionCodec, + models: geminiModels, + supportsLocalAgentJwt: true, + agentConfigurationDoc: geminiAgentConfigurationDoc, +}; + const openclawGatewayAdapter: ServerAdapterModule = { type: "openclaw_gateway", execute: openclawGatewayExecute, @@ -118,6 +134,7 @@ const adaptersByType = new Map( openCodeLocalAdapter, piLocalAdapter, cursorLocalAdapter, + geminiLocalAdapter, openclawGatewayAdapter, processAdapter, httpAdapter, diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index c27b893a..91a47d95 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -37,12 +37,14 @@ import { DEFAULT_CODEX_LOCAL_MODEL, } from "@paperclipai/adapter-codex-local"; import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local"; +import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local"; import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server"; export function agentRoutes(db: Db) { const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record = { claude_local: "instructionsFilePath", codex_local: "instructionsFilePath", + gemini_local: "instructionsFilePath", opencode_local: "instructionsFilePath", cursor: "instructionsFilePath", }; @@ -232,6 +234,10 @@ export function agentRoutes(db: Db) { } return ensureGatewayDeviceKey(adapterType, next); } + if (adapterType === "gemini_local" && !asNonEmptyString(next.model)) { + next.model = DEFAULT_GEMINI_LOCAL_MODEL; + return ensureGatewayDeviceKey(adapterType, next); + } // OpenCode requires explicit model selection — no default if (adapterType === "cursor" && !asNonEmptyString(next.model)) { next.model = DEFAULT_CURSOR_LOCAL_MODEL; diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index ac6de363..06928f08 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -70,6 +70,10 @@ const ADAPTER_DEFAULT_RULES_BY_TYPE: Record { if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, chunk); if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, chunk); + const ts = new Date().toISOString(); if (handle) { await runLogStore.append(handle, { stream, chunk, - ts: new Date().toISOString(), + ts, }); } @@ -1388,6 +1389,7 @@ export function heartbeatService(db: Db) { payload: { runId: run.id, agentId: run.agentId, + ts, stream, chunk: payloadChunk, truncated: payloadChunk.length !== chunk.length, diff --git a/ui/package.json b/ui/package.json index e34b0d50..5ce15553 100644 --- a/ui/package.json +++ b/ui/package.json @@ -17,6 +17,7 @@ "@paperclipai/adapter-claude-local": "workspace:*", "@paperclipai/adapter-codex-local": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*", + "@paperclipai/adapter-gemini-local": "workspace:*", "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-pi-local": "workspace:*", "@paperclipai/adapter-openclaw-gateway": "workspace:*", diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 114034d1..a3e35de1 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -23,6 +23,7 @@ import { Activity } from "./pages/Activity"; import { Inbox } from "./pages/Inbox"; import { CompanySettings } from "./pages/CompanySettings"; import { DesignGuide } from "./pages/DesignGuide"; +import { RunTranscriptUxLab } from "./pages/RunTranscriptUxLab"; import { OrgChart } from "./pages/OrgChart"; import { NewAgent } from "./pages/NewAgent"; import { AuthPage } from "./pages/Auth"; @@ -145,6 +146,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> ); @@ -246,6 +248,7 @@ export function App() { } /> } /> } /> + } /> }> {boardRoutes()} diff --git a/ui/src/adapters/gemini-local/config-fields.tsx b/ui/src/adapters/gemini-local/config-fields.tsx new file mode 100644 index 00000000..a7302bfc --- /dev/null +++ b/ui/src/adapters/gemini-local/config-fields.tsx @@ -0,0 +1,64 @@ +import type { AdapterConfigFieldsProps } from "../types"; +import { + DraftInput, + Field, + ToggleField, +} from "../../components/agent-config-primitives"; +import { ChoosePathButton } from "../../components/PathInstructionsModal"; + +const inputClass = + "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; +const instructionsFileHint = + "Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Prepended to the Gemini prompt at runtime."; + +export function GeminiLocalConfigFields({ + isCreate, + values, + set, + config, + eff, + mark, +}: AdapterConfigFieldsProps) { + return ( + <> + +
+ + isCreate + ? set!({ instructionsFilePath: v }) + : mark("adapterConfig", "instructionsFilePath", v || undefined) + } + immediate + className={inputClass} + placeholder="/absolute/path/to/AGENTS.md" + /> + +
+
+ + isCreate + ? set!({ dangerouslyBypassSandbox: v }) + : mark("adapterConfig", "yolo", v) + } + /> + + ); +} diff --git a/ui/src/adapters/gemini-local/index.ts b/ui/src/adapters/gemini-local/index.ts new file mode 100644 index 00000000..d4cb89e5 --- /dev/null +++ b/ui/src/adapters/gemini-local/index.ts @@ -0,0 +1,12 @@ +import type { UIAdapterModule } from "../types"; +import { parseGeminiStdoutLine } from "@paperclipai/adapter-gemini-local/ui"; +import { GeminiLocalConfigFields } from "./config-fields"; +import { buildGeminiLocalConfig } from "@paperclipai/adapter-gemini-local/ui"; + +export const geminiLocalUIAdapter: UIAdapterModule = { + type: "gemini_local", + label: "Gemini CLI (local)", + parseStdoutLine: parseGeminiStdoutLine, + ConfigFields: GeminiLocalConfigFields, + buildAdapterConfig: buildGeminiLocalConfig, +}; diff --git a/ui/src/adapters/index.ts b/ui/src/adapters/index.ts index b92299e0..a4be1438 100644 --- a/ui/src/adapters/index.ts +++ b/ui/src/adapters/index.ts @@ -6,3 +6,4 @@ export type { UIAdapterModule, AdapterConfigFieldsProps, } from "./types"; +export type { RunLogChunk } from "./transcript"; diff --git a/ui/src/adapters/registry.ts b/ui/src/adapters/registry.ts index 1a36af6b..d8c46738 100644 --- a/ui/src/adapters/registry.ts +++ b/ui/src/adapters/registry.ts @@ -2,6 +2,7 @@ import type { UIAdapterModule } from "./types"; import { claudeLocalUIAdapter } from "./claude-local"; import { codexLocalUIAdapter } from "./codex-local"; import { cursorLocalUIAdapter } from "./cursor"; +import { geminiLocalUIAdapter } from "./gemini-local"; import { openCodeLocalUIAdapter } from "./opencode-local"; import { piLocalUIAdapter } from "./pi-local"; import { openClawGatewayUIAdapter } from "./openclaw-gateway"; @@ -12,6 +13,7 @@ const adaptersByType = new Map( [ claudeLocalUIAdapter, codexLocalUIAdapter, + geminiLocalUIAdapter, openCodeLocalUIAdapter, piLocalUIAdapter, cursorLocalUIAdapter, diff --git a/ui/src/adapters/transcript.ts b/ui/src/adapters/transcript.ts index 143f472a..394fd999 100644 --- a/ui/src/adapters/transcript.ts +++ b/ui/src/adapters/transcript.ts @@ -1,8 +1,8 @@ import type { TranscriptEntry, StdoutLineParser } from "./types"; -type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string }; +export type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string }; -function appendTranscriptEntry(entries: TranscriptEntry[], entry: TranscriptEntry) { +export function appendTranscriptEntry(entries: TranscriptEntry[], entry: TranscriptEntry) { if ((entry.kind === "thinking" || entry.kind === "assistant") && entry.delta) { const last = entries[entries.length - 1]; if (last && last.kind === entry.kind && last.delta) { @@ -14,6 +14,12 @@ function appendTranscriptEntry(entries: TranscriptEntry[], entry: TranscriptEntr entries.push(entry); } +export function appendTranscriptEntries(entries: TranscriptEntry[], incoming: TranscriptEntry[]) { + for (const entry of incoming) { + appendTranscriptEntry(entries, entry); + } +} + export function buildTranscript(chunks: RunLogChunk[], parser: StdoutLineParser): TranscriptEntry[] { const entries: TranscriptEntry[] = []; let stdoutBuffer = ""; @@ -34,18 +40,14 @@ export function buildTranscript(chunks: RunLogChunk[], parser: StdoutLineParser) for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; - for (const entry of parser(trimmed, chunk.ts)) { - appendTranscriptEntry(entries, entry); - } + appendTranscriptEntries(entries, parser(trimmed, chunk.ts)); } } const trailing = stdoutBuffer.trim(); if (trailing) { const ts = chunks.length > 0 ? chunks[chunks.length - 1]!.ts : new Date().toISOString(); - for (const entry of parser(trailing, ts)) { - appendTranscriptEntry(entries, entry); - } + appendTranscriptEntries(entries, parser(trailing, ts)); } return entries; diff --git a/ui/src/components/ActiveAgentsPanel.tsx b/ui/src/components/ActiveAgentsPanel.tsx index d3dcd1d3..5991bb9a 100644 --- a/ui/src/components/ActiveAgentsPanel.tsx +++ b/ui/src/components/ActiveAgentsPanel.tsx @@ -1,191 +1,19 @@ -import { useEffect, useMemo, useRef, useState, type MutableRefObject } from "react"; +import { useMemo } from "react"; import { Link } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; -import type { Issue, LiveEvent } from "@paperclipai/shared"; +import type { Issue } from "@paperclipai/shared"; import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats"; import { issuesApi } from "../api/issues"; -import { getUIAdapter } from "../adapters"; import type { TranscriptEntry } from "../adapters"; import { queryKeys } from "../lib/queryKeys"; import { cn, relativeTime } from "../lib/utils"; import { ExternalLink } from "lucide-react"; import { Identity } from "./Identity"; +import { RunTranscriptView } from "./transcript/RunTranscriptView"; +import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts"; -type FeedTone = "info" | "warn" | "error" | "assistant" | "tool"; - -interface FeedItem { - id: string; - ts: string; - runId: string; - agentId: string; - agentName: string; - text: string; - tone: FeedTone; - dedupeKey: string; - streamingKind?: "assistant" | "thinking"; -} - -const MAX_FEED_ITEMS = 40; -const MAX_FEED_TEXT_LENGTH = 220; -const MAX_STREAMING_TEXT_LENGTH = 4000; const MIN_DASHBOARD_RUNS = 4; -function readString(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value : null; -} - -function summarizeEntry(entry: TranscriptEntry): { text: string; tone: FeedTone } | null { - if (entry.kind === "assistant") { - const text = entry.text.trim(); - return text ? { text, tone: "assistant" } : null; - } - if (entry.kind === "thinking") { - const text = entry.text.trim(); - return text ? { text: `[thinking] ${text}`, tone: "info" } : null; - } - if (entry.kind === "tool_call") { - return { text: `tool ${entry.name}`, tone: "tool" }; - } - if (entry.kind === "tool_result") { - const base = entry.content.trim(); - return { - text: entry.isError ? `tool error: ${base}` : `tool result: ${base}`, - tone: entry.isError ? "error" : "tool", - }; - } - if (entry.kind === "stderr") { - const text = entry.text.trim(); - return text ? { text, tone: "error" } : null; - } - if (entry.kind === "system") { - const text = entry.text.trim(); - return text ? { text, tone: "warn" } : null; - } - if (entry.kind === "stdout") { - const text = entry.text.trim(); - return text ? { text, tone: "info" } : null; - } - return null; -} - -function createFeedItem( - run: LiveRunForIssue, - ts: string, - text: string, - tone: FeedTone, - nextId: number, - options?: { - streamingKind?: "assistant" | "thinking"; - preserveWhitespace?: boolean; - }, -): FeedItem | null { - if (!text.trim()) return null; - const base = options?.preserveWhitespace ? text : text.trim(); - const maxLength = options?.streamingKind ? MAX_STREAMING_TEXT_LENGTH : MAX_FEED_TEXT_LENGTH; - const normalized = base.length > maxLength ? base.slice(-maxLength) : base; - return { - id: `${run.id}:${nextId}`, - ts, - runId: run.id, - agentId: run.agentId, - agentName: run.agentName, - text: normalized, - tone, - dedupeKey: `feed:${run.id}:${ts}:${tone}:${normalized}`, - streamingKind: options?.streamingKind, - }; -} - -function parseStdoutChunk( - run: LiveRunForIssue, - chunk: string, - ts: string, - pendingByRun: Map, - nextIdRef: MutableRefObject, -): FeedItem[] { - const pendingKey = `${run.id}:stdout`; - const combined = `${pendingByRun.get(pendingKey) ?? ""}${chunk}`; - const split = combined.split(/\r?\n/); - pendingByRun.set(pendingKey, split.pop() ?? ""); - const adapter = getUIAdapter(run.adapterType); - - const summarized: Array<{ text: string; tone: FeedTone; streamingKind?: "assistant" | "thinking" }> = []; - const appendSummary = (entry: TranscriptEntry) => { - if (entry.kind === "assistant" && entry.delta) { - const text = entry.text; - if (!text.trim()) return; - const last = summarized[summarized.length - 1]; - if (last && last.streamingKind === "assistant") { - last.text += text; - } else { - summarized.push({ text, tone: "assistant", streamingKind: "assistant" }); - } - return; - } - - if (entry.kind === "thinking" && entry.delta) { - const text = entry.text; - if (!text.trim()) return; - const last = summarized[summarized.length - 1]; - if (last && last.streamingKind === "thinking") { - last.text += text; - } else { - summarized.push({ text: `[thinking] ${text}`, tone: "info", streamingKind: "thinking" }); - } - return; - } - - const summary = summarizeEntry(entry); - if (!summary) return; - summarized.push({ text: summary.text, tone: summary.tone }); - }; - - const items: FeedItem[] = []; - for (const line of split.slice(-8)) { - const trimmed = line.trim(); - if (!trimmed) continue; - const parsed = adapter.parseStdoutLine(trimmed, ts); - if (parsed.length === 0) { - const fallback = createFeedItem(run, ts, trimmed, "info", nextIdRef.current++); - if (fallback) items.push(fallback); - continue; - } - for (const entry of parsed) { - appendSummary(entry); - } - } - - for (const summary of summarized) { - const item = createFeedItem(run, ts, summary.text, summary.tone, nextIdRef.current++, { - streamingKind: summary.streamingKind, - preserveWhitespace: !!summary.streamingKind, - }); - if (item) items.push(item); - } - - return items; -} - -function parseStderrChunk( - run: LiveRunForIssue, - chunk: string, - ts: string, - pendingByRun: Map, - nextIdRef: MutableRefObject, -): FeedItem[] { - const pendingKey = `${run.id}:stderr`; - const combined = `${pendingByRun.get(pendingKey) ?? ""}${chunk}`; - const split = combined.split(/\r?\n/); - pendingByRun.set(pendingKey, split.pop() ?? ""); - - const items: FeedItem[] = []; - for (const line of split.slice(-8)) { - const item = createFeedItem(run, ts, line, "error", nextIdRef.current++); - if (item) items.push(item); - } - return items; -} - function isRunActive(run: LiveRunForIssue): boolean { return run.status === "queued" || run.status === "running"; } @@ -195,11 +23,6 @@ interface ActiveAgentsPanelProps { } export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) { - const [feedByRun, setFeedByRun] = useState>(new Map()); - const seenKeysRef = useRef(new Set()); - const pendingByRunRef = useRef(new Map()); - const nextIdRef = useRef(1); - const { data: liveRuns } = useQuery({ queryKey: [...queryKeys.liveRuns(companyId), "dashboard"], queryFn: () => heartbeatsApi.liveRunsForCompany(companyId, MIN_DASHBOARD_RUNS), @@ -220,179 +43,30 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) { return map; }, [issues]); - const runById = useMemo(() => new Map(runs.map((r) => [r.id, r])), [runs]); - const activeRunIds = useMemo(() => new Set(runs.filter(isRunActive).map((r) => r.id)), [runs]); - - // Clean up pending buffers for runs that ended - useEffect(() => { - const stillActive = new Set(); - for (const runId of activeRunIds) { - stillActive.add(`${runId}:stdout`); - stillActive.add(`${runId}:stderr`); - } - for (const key of pendingByRunRef.current.keys()) { - if (!stillActive.has(key)) { - pendingByRunRef.current.delete(key); - } - } - }, [activeRunIds]); - - // WebSocket connection for streaming - useEffect(() => { - if (activeRunIds.size === 0) return; - - let closed = false; - let reconnectTimer: number | null = null; - let socket: WebSocket | null = null; - - const appendItems = (runId: string, items: FeedItem[]) => { - if (items.length === 0) return; - setFeedByRun((prev) => { - const next = new Map(prev); - const existing = [...(next.get(runId) ?? [])]; - for (const item of items) { - if (seenKeysRef.current.has(item.dedupeKey)) continue; - seenKeysRef.current.add(item.dedupeKey); - - const last = existing[existing.length - 1]; - if ( - item.streamingKind && - last && - last.runId === item.runId && - last.streamingKind === item.streamingKind - ) { - const mergedText = `${last.text}${item.text}`; - const nextText = - mergedText.length > MAX_STREAMING_TEXT_LENGTH - ? mergedText.slice(-MAX_STREAMING_TEXT_LENGTH) - : mergedText; - existing[existing.length - 1] = { - ...last, - ts: item.ts, - text: nextText, - dedupeKey: last.dedupeKey, - }; - continue; - } - - existing.push(item); - } - if (seenKeysRef.current.size > 6000) { - seenKeysRef.current.clear(); - } - next.set(runId, existing.slice(-MAX_FEED_ITEMS)); - return next; - }); - }; - - const scheduleReconnect = () => { - if (closed) return; - reconnectTimer = window.setTimeout(connect, 1500); - }; - - const connect = () => { - if (closed) return; - const protocol = window.location.protocol === "https:" ? "wss" : "ws"; - const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(companyId)}/events/ws`; - socket = new WebSocket(url); - - socket.onmessage = (message) => { - const raw = typeof message.data === "string" ? message.data : ""; - if (!raw) return; - - let event: LiveEvent; - try { - event = JSON.parse(raw) as LiveEvent; - } catch { - return; - } - - if (event.companyId !== companyId) return; - const payload = event.payload ?? {}; - const runId = readString(payload["runId"]); - if (!runId || !activeRunIds.has(runId)) return; - - const run = runById.get(runId); - if (!run) return; - - if (event.type === "heartbeat.run.event") { - const seq = typeof payload["seq"] === "number" ? payload["seq"] : null; - const eventType = readString(payload["eventType"]) ?? "event"; - const messageText = readString(payload["message"]) ?? eventType; - const dedupeKey = `${runId}:event:${seq ?? `${eventType}:${messageText}:${event.createdAt}`}`; - if (seenKeysRef.current.has(dedupeKey)) return; - seenKeysRef.current.add(dedupeKey); - if (seenKeysRef.current.size > 6000) seenKeysRef.current.clear(); - const tone = eventType === "error" ? "error" : eventType === "lifecycle" ? "warn" : "info"; - const item = createFeedItem(run, event.createdAt, messageText, tone, nextIdRef.current++); - if (item) appendItems(run.id, [item]); - return; - } - - if (event.type === "heartbeat.run.status") { - const status = readString(payload["status"]) ?? "updated"; - const dedupeKey = `${runId}:status:${status}:${readString(payload["finishedAt"]) ?? ""}`; - if (seenKeysRef.current.has(dedupeKey)) return; - seenKeysRef.current.add(dedupeKey); - if (seenKeysRef.current.size > 6000) seenKeysRef.current.clear(); - const tone = status === "failed" || status === "timed_out" ? "error" : "warn"; - const item = createFeedItem(run, event.createdAt, `run ${status}`, tone, nextIdRef.current++); - if (item) appendItems(run.id, [item]); - return; - } - - if (event.type === "heartbeat.run.log") { - const chunk = readString(payload["chunk"]); - if (!chunk) return; - const stream = readString(payload["stream"]) === "stderr" ? "stderr" : "stdout"; - if (stream === "stderr") { - appendItems(run.id, parseStderrChunk(run, chunk, event.createdAt, pendingByRunRef.current, nextIdRef)); - return; - } - appendItems(run.id, parseStdoutChunk(run, chunk, event.createdAt, pendingByRunRef.current, nextIdRef)); - } - }; - - socket.onerror = () => { - socket?.close(); - }; - - socket.onclose = () => { - scheduleReconnect(); - }; - }; - - connect(); - - return () => { - closed = true; - if (reconnectTimer !== null) window.clearTimeout(reconnectTimer); - if (socket) { - socket.onmessage = null; - socket.onerror = null; - socket.onclose = null; - socket.close(1000, "active_agents_panel_unmount"); - } - }; - }, [activeRunIds, companyId, runById]); + const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({ + runs, + companyId, + maxChunksPerRun: 120, + }); return (
-

+

Agents

{runs.length === 0 ? ( -
+

No recent agent runs.

) : ( -
+
{runs.map((run) => ( ))} @@ -405,104 +79,76 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) { function AgentRunCard({ run, issue, - feed, + transcript, + hasOutput, isActive, }: { run: LiveRunForIssue; issue?: Issue; - feed: FeedItem[]; + transcript: TranscriptEntry[]; + hasOutput: boolean; isActive: boolean; }) { - const bodyRef = useRef(null); - const recent = feed.slice(-20); - - useEffect(() => { - const body = bodyRef.current; - if (!body) return; - body.scrollTo({ top: body.scrollHeight, behavior: "smooth" }); - }, [feed.length]); - return (
- {/* Header */} -
-
- {isActive ? ( - - - - - ) : ( - - - - )} - - {isActive && ( - Live - )} -
- - - -
+
+
+
+
+ {isActive ? ( + + + + + ) : ( + + )} + +
+
+ {isActive ? "Live now" : run.finishedAt ? `Finished ${relativeTime(run.finishedAt)}` : `Started ${relativeTime(run.createdAt)}`} +
+
- {/* Issue context */} - {run.issueId && ( -
- {issue?.identifier ?? run.issueId.slice(0, 8)} - {issue?.title ? ` - ${issue.title}` : ""} +
- )} - {/* Feed body */} -
- {isActive && recent.length === 0 && ( -
Waiting for output...
- )} - {!isActive && recent.length === 0 && ( -
- {run.finishedAt ? `Finished ${relativeTime(run.finishedAt)}` : `Started ${relativeTime(run.createdAt)}`} + {run.issueId && ( +
+ + {issue?.identifier ?? run.issueId.slice(0, 8)} + {issue?.title ? ` - ${issue.title}` : ""} +
)} - {recent.map((item, index) => ( -
- {relativeTime(item.ts)} - - {item.text} - -
- ))} +
+ +
+
); diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 103a3cb4..5f92a588 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -16,6 +16,7 @@ import { DEFAULT_CODEX_LOCAL_MODEL, } from "@paperclipai/adapter-codex-local"; import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local"; +import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local"; import { Popover, PopoverContent, @@ -282,6 +283,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const isLocal = adapterType === "claude_local" || adapterType === "codex_local" || + adapterType === "gemini_local" || adapterType === "opencode_local" || adapterType === "cursor"; const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]); @@ -374,9 +376,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) { ) : adapterType === "cursor" ? eff("adapterConfig", "mode", String(config.mode ?? "")) - : adapterType === "opencode_local" - ? eff("adapterConfig", "variant", String(config.variant ?? "")) + : adapterType === "opencode_local" + ? eff("adapterConfig", "variant", String(config.variant ?? "")) : eff("adapterConfig", "effort", String(config.effort ?? "")); + const showThinkingEffort = adapterType !== "gemini_local"; const codexSearchEnabled = adapterType === "codex_local" ? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search))) : false; @@ -494,6 +497,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) { nextValues.model = DEFAULT_CODEX_LOCAL_MODEL; nextValues.dangerouslyBypassSandbox = DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX; + } else if (t === "gemini_local") { + nextValues.model = DEFAULT_GEMINI_LOCAL_MODEL; } else if (t === "cursor") { nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL; } else if (t === "opencode_local") { @@ -510,6 +515,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) { model: t === "codex_local" ? DEFAULT_CODEX_LOCAL_MODEL + : t === "gemini_local" + ? DEFAULT_GEMINI_LOCAL_MODEL : t === "cursor" ? DEFAULT_CURSOR_LOCAL_MODEL : "", @@ -615,6 +622,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) { placeholder={ adapterType === "codex_local" ? "codex" + : adapterType === "gemini_local" + ? "gemini" : adapterType === "cursor" ? "agent" : adapterType === "opencode_local" @@ -646,24 +655,28 @@ export function AgentConfigForm(props: AgentConfigFormProps) {

)} - - isCreate - ? set!({ thinkingEffort: v }) - : mark("adapterConfig", thinkingEffortKey, v || undefined) - } - open={thinkingEffortOpen} - onOpenChange={setThinkingEffortOpen} - /> - {adapterType === "codex_local" && - codexSearchEnabled && - currentThinkingEffort === "minimal" && ( -

- Codex may reject `minimal` thinking when search is enabled. -

- )} + {showThinkingEffort && ( + <> + + isCreate + ? set!({ thinkingEffort: v }) + : mark("adapterConfig", thinkingEffortKey, v || undefined) + } + open={thinkingEffortOpen} + onOpenChange={setThinkingEffortOpen} + /> + {adapterType === "codex_local" && + codexSearchEnabled && + currentThinkingEffort === "minimal" && ( +

+ Codex may reject `minimal` thinking when search is enabled. +

+ )} + + )} = { claude_local: "Claude (local)", codex_local: "Codex (local)", + gemini_local: "Gemini CLI (local)", opencode_local: "OpenCode (local)", openclaw_gateway: "OpenClaw Gateway", cursor: "Cursor (local)", diff --git a/ui/src/components/LiveRunWidget.tsx b/ui/src/components/LiveRunWidget.tsx index 9d176179..2c0f702e 100644 --- a/ui/src/components/LiveRunWidget.tsx +++ b/ui/src/components/LiveRunWidget.tsx @@ -1,262 +1,32 @@ -import { useEffect, useMemo, useRef, useState, type MutableRefObject } from "react"; +import { useMemo, useState } from "react"; import { Link } from "@/lib/router"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import type { LiveEvent } from "@paperclipai/shared"; import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats"; -import { getUIAdapter } from "../adapters"; -import type { TranscriptEntry } from "../adapters"; import { queryKeys } from "../lib/queryKeys"; -import { cn, relativeTime, formatDateTime } from "../lib/utils"; +import { formatDateTime } from "../lib/utils"; import { ExternalLink, Square } from "lucide-react"; import { Identity } from "./Identity"; import { StatusBadge } from "./StatusBadge"; +import { RunTranscriptView } from "./transcript/RunTranscriptView"; +import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts"; interface LiveRunWidgetProps { issueId: string; companyId?: string | null; } -type FeedTone = "info" | "warn" | "error" | "assistant" | "tool"; - -interface FeedItem { - id: string; - ts: string; - runId: string; - agentId: string; - agentName: string; - text: string; - tone: FeedTone; - dedupeKey: string; - streamingKind?: "assistant" | "thinking"; -} - -const MAX_FEED_ITEMS = 80; -const MAX_FEED_TEXT_LENGTH = 220; -const MAX_STREAMING_TEXT_LENGTH = 4000; -const LOG_POLL_INTERVAL_MS = 2000; -const LOG_READ_LIMIT_BYTES = 256_000; - -function readString(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value : null; -} - function toIsoString(value: string | Date | null | undefined): string | null { if (!value) return null; return typeof value === "string" ? value : value.toISOString(); } -function summarizeEntry(entry: TranscriptEntry): { text: string; tone: FeedTone } | null { - if (entry.kind === "assistant") { - const text = entry.text.trim(); - return text ? { text, tone: "assistant" } : null; - } - if (entry.kind === "thinking") { - const text = entry.text.trim(); - return text ? { text: `[thinking] ${text}`, tone: "info" } : null; - } - if (entry.kind === "tool_call") { - return { text: `tool ${entry.name}`, tone: "tool" }; - } - if (entry.kind === "tool_result") { - const base = entry.content.trim(); - return { - text: entry.isError ? `tool error: ${base}` : `tool result: ${base}`, - tone: entry.isError ? "error" : "tool", - }; - } - if (entry.kind === "stderr") { - const text = entry.text.trim(); - return text ? { text, tone: "error" } : null; - } - if (entry.kind === "system") { - const text = entry.text.trim(); - return text ? { text, tone: "warn" } : null; - } - if (entry.kind === "stdout") { - const text = entry.text.trim(); - return text ? { text, tone: "info" } : null; - } - return null; -} - -function createFeedItem( - run: LiveRunForIssue, - ts: string, - text: string, - tone: FeedTone, - nextId: number, - options?: { - streamingKind?: "assistant" | "thinking"; - preserveWhitespace?: boolean; - }, -): FeedItem | null { - if (!text.trim()) return null; - const base = options?.preserveWhitespace ? text : text.trim(); - const maxLength = options?.streamingKind ? MAX_STREAMING_TEXT_LENGTH : MAX_FEED_TEXT_LENGTH; - const normalized = base.length > maxLength ? base.slice(-maxLength) : base; - return { - id: `${run.id}:${nextId}`, - ts, - runId: run.id, - agentId: run.agentId, - agentName: run.agentName, - text: normalized, - tone, - dedupeKey: `feed:${run.id}:${ts}:${tone}:${normalized}`, - streamingKind: options?.streamingKind, - }; -} - -function parseStdoutChunk( - run: LiveRunForIssue, - chunk: string, - ts: string, - pendingByRun: Map, - nextIdRef: MutableRefObject, -): FeedItem[] { - const pendingKey = `${run.id}:stdout`; - const combined = `${pendingByRun.get(pendingKey) ?? ""}${chunk}`; - const split = combined.split(/\r?\n/); - pendingByRun.set(pendingKey, split.pop() ?? ""); - const adapter = getUIAdapter(run.adapterType); - - const summarized: Array<{ text: string; tone: FeedTone; streamingKind?: "assistant" | "thinking" }> = []; - const appendSummary = (entry: TranscriptEntry) => { - if (entry.kind === "assistant" && entry.delta) { - const text = entry.text; - if (!text.trim()) return; - const last = summarized[summarized.length - 1]; - if (last && last.streamingKind === "assistant") { - last.text += text; - } else { - summarized.push({ text, tone: "assistant", streamingKind: "assistant" }); - } - return; - } - - if (entry.kind === "thinking" && entry.delta) { - const text = entry.text; - if (!text.trim()) return; - const last = summarized[summarized.length - 1]; - if (last && last.streamingKind === "thinking") { - last.text += text; - } else { - summarized.push({ text: `[thinking] ${text}`, tone: "info", streamingKind: "thinking" }); - } - return; - } - - const summary = summarizeEntry(entry); - if (!summary) return; - summarized.push({ text: summary.text, tone: summary.tone }); - }; - - const items: FeedItem[] = []; - for (const line of split.slice(-8)) { - const trimmed = line.trim(); - if (!trimmed) continue; - const parsed = adapter.parseStdoutLine(trimmed, ts); - if (parsed.length === 0) { - if (run.adapterType === "openclaw_gateway") { - continue; - } - const fallback = createFeedItem(run, ts, trimmed, "info", nextIdRef.current++); - if (fallback) items.push(fallback); - continue; - } - for (const entry of parsed) { - appendSummary(entry); - } - } - - for (const summary of summarized) { - const item = createFeedItem(run, ts, summary.text, summary.tone, nextIdRef.current++, { - streamingKind: summary.streamingKind, - preserveWhitespace: !!summary.streamingKind, - }); - if (item) items.push(item); - } - - return items; -} - -function parseStderrChunk( - run: LiveRunForIssue, - chunk: string, - ts: string, - pendingByRun: Map, - nextIdRef: MutableRefObject, -): FeedItem[] { - const pendingKey = `${run.id}:stderr`; - const combined = `${pendingByRun.get(pendingKey) ?? ""}${chunk}`; - const split = combined.split(/\r?\n/); - pendingByRun.set(pendingKey, split.pop() ?? ""); - - const items: FeedItem[] = []; - for (const line of split.slice(-8)) { - const item = createFeedItem(run, ts, line, "error", nextIdRef.current++); - if (item) items.push(item); - } - return items; -} - -function parsePersistedLogContent( - runId: string, - content: string, - pendingByRun: Map, -): Array<{ ts: string; stream: "stdout" | "stderr" | "system"; chunk: string }> { - if (!content) return []; - - const pendingKey = `${runId}:records`; - const combined = `${pendingByRun.get(pendingKey) ?? ""}${content}`; - const split = combined.split("\n"); - pendingByRun.set(pendingKey, split.pop() ?? ""); - - const parsed: Array<{ ts: string; stream: "stdout" | "stderr" | "system"; chunk: string }> = []; - for (const line of split) { - const trimmed = line.trim(); - if (!trimmed) continue; - try { - const raw = JSON.parse(trimmed) as { ts?: unknown; stream?: unknown; chunk?: unknown }; - const stream = raw.stream === "stderr" || raw.stream === "system" ? raw.stream : "stdout"; - const chunk = typeof raw.chunk === "string" ? raw.chunk : ""; - const ts = typeof raw.ts === "string" ? raw.ts : new Date().toISOString(); - if (!chunk) continue; - parsed.push({ ts, stream, chunk }); - } catch { - // Ignore malformed log rows. - } - } - - return parsed; +function isRunActive(status: string): boolean { + return status === "queued" || status === "running"; } export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) { const queryClient = useQueryClient(); - const [feed, setFeed] = useState([]); const [cancellingRunIds, setCancellingRunIds] = useState(new Set()); - const seenKeysRef = useRef(new Set()); - const pendingByRunRef = useRef(new Map()); - const pendingLogRowsByRunRef = useRef(new Map()); - const logOffsetByRunRef = useRef(new Map()); - const runMetaByIdRef = useRef(new Map()); - const nextIdRef = useRef(1); - const bodyRef = useRef(null); - - const handleCancelRun = async (runId: string) => { - setCancellingRunIds((prev) => new Set(prev).add(runId)); - try { - await heartbeatsApi.cancel(runId); - queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId) }); - queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId) }); - } finally { - setCancellingRunIds((prev) => { - const next = new Set(prev); - next.delete(runId); - return next; - }); - } - }; const { data: liveRuns } = useQuery({ queryKey: queryKeys.issues.liveRuns(issueId), @@ -297,329 +67,94 @@ export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) { ); }, [activeRun, issueId, liveRuns]); - const runById = useMemo(() => new Map(runs.map((run) => [run.id, run])), [runs]); - const activeRunIds = useMemo(() => new Set(runs.map((run) => run.id)), [runs]); - const runIdsKey = useMemo( - () => runs.map((run) => run.id).sort((a, b) => a.localeCompare(b)).join(","), - [runs], - ); - const appendItems = (items: FeedItem[]) => { - if (items.length === 0) return; - setFeed((prev) => { - const next = [...prev]; - for (const item of items) { - if (seenKeysRef.current.has(item.dedupeKey)) continue; - seenKeysRef.current.add(item.dedupeKey); + const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({ runs, companyId }); - const last = next[next.length - 1]; - if ( - item.streamingKind && - last && - last.runId === item.runId && - last.streamingKind === item.streamingKind - ) { - const mergedText = `${last.text}${item.text}`; - const nextText = - mergedText.length > MAX_STREAMING_TEXT_LENGTH - ? mergedText.slice(-MAX_STREAMING_TEXT_LENGTH) - : mergedText; - next[next.length - 1] = { - ...last, - ts: item.ts, - text: nextText, - dedupeKey: last.dedupeKey, - }; - continue; - } - - next.push(item); - } - if (seenKeysRef.current.size > 6000) { - seenKeysRef.current.clear(); - } - if (next.length === prev.length) return prev; - return next.slice(-MAX_FEED_ITEMS); - }); + const handleCancelRun = async (runId: string) => { + setCancellingRunIds((prev) => new Set(prev).add(runId)); + try { + await heartbeatsApi.cancel(runId); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId) }); + } finally { + setCancellingRunIds((prev) => { + const next = new Set(prev); + next.delete(runId); + return next; + }); + } }; - useEffect(() => { - const body = bodyRef.current; - if (!body) return; - body.scrollTo({ top: body.scrollHeight, behavior: "smooth" }); - }, [feed.length]); - - useEffect(() => { - for (const run of runs) { - runMetaByIdRef.current.set(run.id, { agentId: run.agentId, agentName: run.agentName }); - } - }, [runs]); - - useEffect(() => { - const stillActive = new Set(); - for (const runId of activeRunIds) { - stillActive.add(`${runId}:stdout`); - stillActive.add(`${runId}:stderr`); - } - for (const key of pendingByRunRef.current.keys()) { - if (!stillActive.has(key)) { - pendingByRunRef.current.delete(key); - } - } - const liveRunIds = new Set(activeRunIds); - for (const key of pendingLogRowsByRunRef.current.keys()) { - const runId = key.replace(/:records$/, ""); - if (!liveRunIds.has(runId)) { - pendingLogRowsByRunRef.current.delete(key); - } - } - for (const runId of logOffsetByRunRef.current.keys()) { - if (!liveRunIds.has(runId)) { - logOffsetByRunRef.current.delete(runId); - } - } - }, [activeRunIds]); - - useEffect(() => { - if (runs.length === 0) return; - - let cancelled = false; - - const readRunLog = async (run: LiveRunForIssue) => { - const offset = logOffsetByRunRef.current.get(run.id) ?? 0; - try { - const result = await heartbeatsApi.log(run.id, offset, LOG_READ_LIMIT_BYTES); - if (cancelled) return; - - const rows = parsePersistedLogContent(run.id, result.content, pendingLogRowsByRunRef.current); - const items: FeedItem[] = []; - for (const row of rows) { - if (row.stream === "stderr") { - items.push( - ...parseStderrChunk(run, row.chunk, row.ts, pendingByRunRef.current, nextIdRef), - ); - continue; - } - if (row.stream === "system") { - const item = createFeedItem(run, row.ts, row.chunk, "warn", nextIdRef.current++); - if (item) items.push(item); - continue; - } - items.push( - ...parseStdoutChunk(run, row.chunk, row.ts, pendingByRunRef.current, nextIdRef), - ); - } - appendItems(items); - - if (result.nextOffset !== undefined) { - logOffsetByRunRef.current.set(run.id, result.nextOffset); - return; - } - if (result.content.length > 0) { - logOffsetByRunRef.current.set(run.id, offset + result.content.length); - } - } catch { - // Ignore log read errors while run output is initializing. - } - }; - - const readAll = async () => { - await Promise.all(runs.map((run) => readRunLog(run))); - }; - - void readAll(); - const interval = window.setInterval(() => { - void readAll(); - }, LOG_POLL_INTERVAL_MS); - - return () => { - cancelled = true; - window.clearInterval(interval); - }; - }, [runIdsKey, runs]); - - useEffect(() => { - if (!companyId || activeRunIds.size === 0) return; - - let closed = false; - let reconnectTimer: number | null = null; - let socket: WebSocket | null = null; - - const scheduleReconnect = () => { - if (closed) return; - reconnectTimer = window.setTimeout(connect, 1500); - }; - - const connect = () => { - if (closed) return; - const protocol = window.location.protocol === "https:" ? "wss" : "ws"; - const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(companyId)}/events/ws`; - socket = new WebSocket(url); - - socket.onmessage = (message) => { - const raw = typeof message.data === "string" ? message.data : ""; - if (!raw) return; - - let event: LiveEvent; - try { - event = JSON.parse(raw) as LiveEvent; - } catch { - return; - } - - if (event.companyId !== companyId) return; - const payload = event.payload ?? {}; - const runId = readString(payload["runId"]); - if (!runId || !activeRunIds.has(runId)) return; - - const run = runById.get(runId); - if (!run) return; - - if (event.type === "heartbeat.run.event") { - const seq = typeof payload["seq"] === "number" ? payload["seq"] : null; - const eventType = readString(payload["eventType"]) ?? "event"; - const messageText = readString(payload["message"]) ?? eventType; - const dedupeKey = `${runId}:event:${seq ?? `${eventType}:${messageText}:${event.createdAt}`}`; - if (seenKeysRef.current.has(dedupeKey)) return; - seenKeysRef.current.add(dedupeKey); - if (seenKeysRef.current.size > 2000) { - seenKeysRef.current.clear(); - } - const tone = eventType === "error" ? "error" : eventType === "lifecycle" ? "warn" : "info"; - const item = createFeedItem(run, event.createdAt, messageText, tone, nextIdRef.current++); - if (item) appendItems([item]); - return; - } - - if (event.type === "heartbeat.run.status") { - const status = readString(payload["status"]) ?? "updated"; - const dedupeKey = `${runId}:status:${status}:${readString(payload["finishedAt"]) ?? ""}`; - if (seenKeysRef.current.has(dedupeKey)) return; - seenKeysRef.current.add(dedupeKey); - if (seenKeysRef.current.size > 2000) { - seenKeysRef.current.clear(); - } - const tone = status === "failed" || status === "timed_out" ? "error" : "warn"; - const item = createFeedItem(run, event.createdAt, `run ${status}`, tone, nextIdRef.current++); - if (item) appendItems([item]); - return; - } - - if (event.type === "heartbeat.run.log") { - const chunk = readString(payload["chunk"]); - if (!chunk) return; - const stream = readString(payload["stream"]) === "stderr" ? "stderr" : "stdout"; - if (stream === "stderr") { - appendItems(parseStderrChunk(run, chunk, event.createdAt, pendingByRunRef.current, nextIdRef)); - return; - } - appendItems(parseStdoutChunk(run, chunk, event.createdAt, pendingByRunRef.current, nextIdRef)); - } - }; - - socket.onerror = () => { - socket?.close(); - }; - - socket.onclose = () => { - scheduleReconnect(); - }; - }; - - connect(); - - return () => { - closed = true; - if (reconnectTimer !== null) window.clearTimeout(reconnectTimer); - if (socket) { - socket.onmessage = null; - socket.onerror = null; - socket.onclose = null; - socket.close(1000, "issue_live_widget_unmount"); - } - }; - }, [activeRunIds, companyId, runById]); - - if (runs.length === 0 && feed.length === 0) return null; - - const recent = feed.slice(-25); + if (runs.length === 0) return null; return ( -
- {runs.length > 0 ? ( - runs.map((run) => ( -
-
- - - - - {formatDateTime(run.startedAt ?? run.createdAt)} - -
-
- Run - - {run.id.slice(0, 8)} - - -
- - - Open run - - -
-
-
- )) - ) : ( -
- Recent run updates +
+
+
+ Live Runs +
+
+ Streamed with the same transcript UI used on the full run detail page.
- )} - -
- {recent.length === 0 && ( -
Waiting for run output...
- )} - {recent.map((item, index) => ( -
- {relativeTime(item.ts)} -
- - [{item.runId.slice(0, 8)}] - {item.text} -
-
- ))}
+
+ {runs.map((run) => { + const isActive = isRunActive(run.status); + const transcript = transcriptByRun.get(run.id) ?? []; + return ( +
+
+
+ + + +
+ + {run.id.slice(0, 8)} + + + {formatDateTime(run.startedAt ?? run.createdAt)} +
+
+ +
+ {isActive && ( + + )} + + Open run + + +
+
+ +
+ +
+
+ ); + })} +
); } diff --git a/ui/src/components/NewAgentDialog.tsx b/ui/src/components/NewAgentDialog.tsx index 18830792..15114bf7 100644 --- a/ui/src/components/NewAgentDialog.tsx +++ b/ui/src/components/NewAgentDialog.tsx @@ -14,6 +14,7 @@ import { ArrowLeft, Bot, Code, + Gem, MousePointer2, Sparkles, Terminal, @@ -24,6 +25,7 @@ import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon"; type AdvancedAdapterType = | "claude_local" | "codex_local" + | "gemini_local" | "opencode_local" | "pi_local" | "cursor" @@ -50,6 +52,12 @@ const ADVANCED_ADAPTER_OPTIONS: Array<{ desc: "Local Codex agent", recommended: true, }, + { + value: "gemini_local", + label: "Gemini CLI", + icon: Gem, + desc: "Local Gemini agent", + }, { value: "opencode_local", label: "OpenCode", diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 5451e278..a043655f 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -25,6 +25,7 @@ import { DEFAULT_CODEX_LOCAL_MODEL } from "@paperclipai/adapter-codex-local"; import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local"; +import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local"; import { AsciiArtAnimation } from "./AsciiArtAnimation"; import { ChoosePathButton } from "./PathInstructionsModal"; import { HintIcon } from "./agent-config-primitives"; @@ -33,6 +34,7 @@ import { Building2, Bot, Code, + Gem, ListTodo, Rocket, ArrowLeft, @@ -51,6 +53,7 @@ type Step = 1 | 2 | 3 | 4; type AdapterType = | "claude_local" | "codex_local" + | "gemini_local" | "opencode_local" | "pi_local" | "cursor" @@ -165,11 +168,17 @@ export function OnboardingWizard() { enabled: Boolean(createdCompanyId) && onboardingOpen && step === 2 }); const isLocalAdapter = - adapterType === "claude_local" || adapterType === "codex_local" || adapterType === "opencode_local" || adapterType === "cursor"; + adapterType === "claude_local" || + adapterType === "codex_local" || + adapterType === "gemini_local" || + adapterType === "opencode_local" || + adapterType === "cursor"; const effectiveAdapterCommand = command.trim() || (adapterType === "codex_local" ? "codex" + : adapterType === "gemini_local" + ? "gemini" : adapterType === "cursor" ? "agent" : adapterType === "opencode_local" @@ -268,6 +277,8 @@ export function OnboardingWizard() { model: adapterType === "codex_local" ? model || DEFAULT_CODEX_LOCAL_MODEL + : adapterType === "gemini_local" + ? model || DEFAULT_GEMINI_LOCAL_MODEL : adapterType === "cursor" ? model || DEFAULT_CURSOR_LOCAL_MODEL : model, @@ -655,6 +666,12 @@ export function OnboardingWizard() { desc: "Local Codex agent", recommended: true }, + { + value: "gemini_local" as const, + label: "Gemini CLI", + icon: Gem, + desc: "Local Gemini agent" + }, { value: "opencode_local" as const, label: "OpenCode", @@ -699,6 +716,8 @@ export function OnboardingWizard() { setAdapterType(nextType); if (nextType === "codex_local" && !model) { setModel(DEFAULT_CODEX_LOCAL_MODEL); + } else if (nextType === "gemini_local" && !model) { + setModel(DEFAULT_GEMINI_LOCAL_MODEL); } else if (nextType === "cursor" && !model) { setModel(DEFAULT_CURSOR_LOCAL_MODEL); } @@ -732,6 +751,7 @@ export function OnboardingWizard() { {/* Conditional adapter fields */} {(adapterType === "claude_local" || adapterType === "codex_local" || + adapterType === "gemini_local" || adapterType === "opencode_local" || adapterType === "pi_local" || adapterType === "cursor") && ( @@ -904,6 +924,8 @@ export function OnboardingWizard() { ? `${effectiveAdapterCommand} -p --mode ask --output-format json \"Respond with hello.\"` : adapterType === "codex_local" ? `${effectiveAdapterCommand} exec --json -` + : adapterType === "gemini_local" + ? `${effectiveAdapterCommand} --output-format json \"Respond with hello.\"` : adapterType === "opencode_local" ? `${effectiveAdapterCommand} run --format json "Respond with hello."` : `${effectiveAdapterCommand} --print - --output-format stream-json --verbose`} @@ -912,11 +934,15 @@ export function OnboardingWizard() { Prompt:{" "} Respond with hello.

- {adapterType === "cursor" || adapterType === "codex_local" || adapterType === "opencode_local" ? ( + {adapterType === "cursor" || adapterType === "codex_local" || adapterType === "gemini_local" || adapterType === "opencode_local" ? (

If auth fails, set{" "} - {adapterType === "cursor" ? "CURSOR_API_KEY" : "OPENAI_API_KEY"} + {adapterType === "cursor" + ? "CURSOR_API_KEY" + : adapterType === "gemini_local" + ? "GEMINI_API_KEY" + : "OPENAI_API_KEY"} {" "} in env or run{" "} @@ -925,6 +951,8 @@ export function OnboardingWizard() { ? "agent login" : adapterType === "codex_local" ? "codex login" + : adapterType === "gemini_local" + ? "gemini auth" : "opencode auth login"} .

diff --git a/ui/src/components/agent-config-primitives.tsx b/ui/src/components/agent-config-primitives.tsx index 2d26753d..77a5b14c 100644 --- a/ui/src/components/agent-config-primitives.tsx +++ b/ui/src/components/agent-config-primitives.tsx @@ -60,6 +60,7 @@ export const help: Record = { export const adapterLabels: Record = { claude_local: "Claude (local)", codex_local: "Codex (local)", + gemini_local: "Gemini CLI (local)", opencode_local: "OpenCode (local)", openclaw_gateway: "OpenClaw Gateway", cursor: "Cursor (local)", diff --git a/ui/src/components/transcript/RunTranscriptView.tsx b/ui/src/components/transcript/RunTranscriptView.tsx new file mode 100644 index 00000000..762f8df4 --- /dev/null +++ b/ui/src/components/transcript/RunTranscriptView.tsx @@ -0,0 +1,1000 @@ +import { useMemo, useState } from "react"; +import type { TranscriptEntry } from "../../adapters"; +import { MarkdownBody } from "../MarkdownBody"; +import { cn, formatTokens } from "../../lib/utils"; +import { + Check, + ChevronDown, + ChevronRight, + CircleAlert, + TerminalSquare, + User, + Wrench, +} from "lucide-react"; + +export type TranscriptMode = "nice" | "raw"; +export type TranscriptDensity = "comfortable" | "compact"; + +interface RunTranscriptViewProps { + entries: TranscriptEntry[]; + mode?: TranscriptMode; + density?: TranscriptDensity; + limit?: number; + streaming?: boolean; + collapseStdout?: boolean; + emptyMessage?: string; + className?: string; +} + +type TranscriptBlock = + | { + type: "message"; + role: "assistant" | "user"; + ts: string; + text: string; + streaming: boolean; + } + | { + type: "thinking"; + ts: string; + text: string; + streaming: boolean; + } + | { + type: "tool"; + ts: string; + endTs?: string; + name: string; + toolUseId?: string; + input: unknown; + result?: string; + isError?: boolean; + status: "running" | "completed" | "error"; + } + | { + type: "activity"; + ts: string; + activityId?: string; + name: string; + status: "running" | "completed"; + } + | { + type: "command_group"; + ts: string; + endTs?: string; + items: Array<{ + ts: string; + endTs?: string; + input: unknown; + result?: string; + isError?: boolean; + status: "running" | "completed" | "error"; + }>; + } + | { + type: "stdout"; + ts: string; + text: string; + } + | { + type: "event"; + ts: string; + label: string; + tone: "info" | "warn" | "error" | "neutral"; + text: string; + detail?: string; + }; + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function compactWhitespace(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +function truncate(value: string, max: number): string { + return value.length > max ? `${value.slice(0, Math.max(0, max - 1))}…` : value; +} + +function stripMarkdown(value: string): string { + return compactWhitespace( + value + .replace(/```[\s\S]*?```/g, " code ") + .replace(/`([^`]+)`/g, "$1") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/[*_#>-]/g, " "), + ); +} + +function humanizeLabel(value: string): string { + return value + .replace(/[_-]+/g, " ") + .trim() + .replace(/\b\w/g, (char) => char.toUpperCase()); +} + +function stripWrappedShell(command: string): string { + const trimmed = compactWhitespace(command); + const shellWrapped = trimmed.match(/^(?:(?:\/bin\/)?(?:zsh|bash|sh)|cmd(?:\.exe)?(?:\s+\/d)?(?:\s+\/s)?(?:\s+\/c)?)\s+(?:-lc|\/c)\s+(.+)$/i); + const inner = shellWrapped?.[1] ?? trimmed; + const quoted = inner.match(/^(['"])([\s\S]*)\1$/); + return compactWhitespace(quoted?.[2] ?? inner); +} + +function formatUnknown(value: unknown): string { + if (typeof value === "string") return value; + if (value === null || value === undefined) return ""; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function formatToolPayload(value: unknown): string { + if (typeof value === "string") { + try { + return JSON.stringify(JSON.parse(value), null, 2); + } catch { + return value; + } + } + return formatUnknown(value); +} + +function extractToolUseId(input: unknown): string | undefined { + const record = asRecord(input); + if (!record) return undefined; + const candidates = [ + record.toolUseId, + record.tool_use_id, + record.callId, + record.call_id, + record.id, + ]; + for (const candidate of candidates) { + if (typeof candidate === "string" && candidate.trim()) { + return candidate; + } + } + return undefined; +} + +function summarizeRecord(record: Record, keys: string[]): string | null { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" && value.trim()) { + return truncate(compactWhitespace(value), 120); + } + } + return null; +} + +function summarizeToolInput(name: string, input: unknown, density: TranscriptDensity): string { + const compactMax = density === "compact" ? 72 : 120; + if (typeof input === "string") { + const normalized = isCommandTool(name, input) ? stripWrappedShell(input) : compactWhitespace(input); + return truncate(normalized, compactMax); + } + const record = asRecord(input); + if (!record) { + const serialized = compactWhitespace(formatUnknown(input)); + return serialized ? truncate(serialized, compactMax) : `Inspect ${name} input`; + } + + const command = typeof record.command === "string" + ? record.command + : typeof record.cmd === "string" + ? record.cmd + : null; + if (command && isCommandTool(name, record)) { + return truncate(stripWrappedShell(command), compactMax); + } + + const direct = + summarizeRecord(record, ["command", "cmd", "path", "filePath", "file_path", "query", "url", "prompt", "message"]) + ?? summarizeRecord(record, ["pattern", "name", "title", "target", "tool"]) + ?? null; + if (direct) return truncate(direct, compactMax); + + if (Array.isArray(record.paths) && record.paths.length > 0) { + const first = record.paths.find((value): value is string => typeof value === "string" && value.trim().length > 0); + if (first) { + return truncate(`${record.paths.length} paths, starting with ${first}`, compactMax); + } + } + + const keys = Object.keys(record); + if (keys.length === 0) return `No ${name} input`; + if (keys.length === 1) return truncate(`${keys[0]} payload`, compactMax); + return truncate(`${keys.length} fields: ${keys.slice(0, 3).join(", ")}`, compactMax); +} + +function parseStructuredToolResult(result: string | undefined) { + if (!result) return null; + const lines = result.split(/\r?\n/); + const metadata = new Map(); + let bodyStartIndex = lines.findIndex((line) => line.trim() === ""); + if (bodyStartIndex === -1) bodyStartIndex = lines.length; + + for (let index = 0; index < bodyStartIndex; index += 1) { + const match = lines[index]?.match(/^([a-z_]+):\s*(.+)$/i); + if (match) { + metadata.set(match[1].toLowerCase(), compactWhitespace(match[2])); + } + } + + const body = lines.slice(Math.min(bodyStartIndex + 1, lines.length)) + .map((line) => compactWhitespace(line)) + .filter(Boolean) + .join("\n"); + + return { + command: metadata.get("command") ?? null, + status: metadata.get("status") ?? null, + exitCode: metadata.get("exit_code") ?? null, + body, + }; +} + +function isCommandTool(name: string, input: unknown): boolean { + if (name === "command_execution" || name === "shell" || name === "shellToolCall" || name === "bash") { + return true; + } + if (typeof input === "string") { + return /\b(?:bash|zsh|sh|cmd|powershell)\b/i.test(input); + } + const record = asRecord(input); + return Boolean(record && (typeof record.command === "string" || typeof record.cmd === "string")); +} + +function displayToolName(name: string, input: unknown): string { + if (isCommandTool(name, input)) return "Executing command"; + return humanizeLabel(name); +} + +function summarizeToolResult(result: string | undefined, isError: boolean | undefined, density: TranscriptDensity): string { + if (!result) return isError ? "Tool failed" : "Waiting for result"; + const structured = parseStructuredToolResult(result); + if (structured) { + if (structured.body) { + return truncate(structured.body.split("\n")[0] ?? structured.body, density === "compact" ? 84 : 140); + } + if (structured.status === "completed") return "Completed"; + if (structured.status === "failed" || structured.status === "error") { + return structured.exitCode ? `Failed with exit code ${structured.exitCode}` : "Failed"; + } + } + const lines = result + .split(/\r?\n/) + .map((line) => compactWhitespace(line)) + .filter(Boolean); + const firstLine = lines[0] ?? result; + return truncate(firstLine, density === "compact" ? 84 : 140); +} + +function parseSystemActivity(text: string): { activityId?: string; name: string; status: "running" | "completed" } | null { + const match = text.match(/^item (started|completed):\s*([a-z0-9_-]+)(?:\s+\(id=([^)]+)\))?$/i); + if (!match) return null; + return { + status: match[1].toLowerCase() === "started" ? "running" : "completed", + name: humanizeLabel(match[2] ?? "Activity"), + activityId: match[3] || undefined, + }; +} + +function groupCommandBlocks(blocks: TranscriptBlock[]): TranscriptBlock[] { + const grouped: TranscriptBlock[] = []; + let pending: Array["items"][number]> = []; + let groupTs: string | null = null; + let groupEndTs: string | undefined; + + const flush = () => { + if (pending.length === 0 || !groupTs) return; + grouped.push({ + type: "command_group", + ts: groupTs, + endTs: groupEndTs, + items: pending, + }); + pending = []; + groupTs = null; + groupEndTs = undefined; + }; + + for (const block of blocks) { + if (block.type === "tool" && isCommandTool(block.name, block.input)) { + if (!groupTs) { + groupTs = block.ts; + } + groupEndTs = block.endTs ?? block.ts; + pending.push({ + ts: block.ts, + endTs: block.endTs, + input: block.input, + result: block.result, + isError: block.isError, + status: block.status, + }); + continue; + } + + flush(); + grouped.push(block); + } + + flush(); + return grouped; +} + +function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): TranscriptBlock[] { + const blocks: TranscriptBlock[] = []; + const pendingToolBlocks = new Map>(); + const pendingActivityBlocks = new Map>(); + + for (const entry of entries) { + const previous = blocks[blocks.length - 1]; + + if (entry.kind === "assistant" || entry.kind === "user") { + const isStreaming = streaming && entry.kind === "assistant" && entry.delta === true; + if (previous?.type === "message" && previous.role === entry.kind) { + previous.text += previous.text.endsWith("\n") || entry.text.startsWith("\n") ? entry.text : `\n${entry.text}`; + previous.ts = entry.ts; + previous.streaming = previous.streaming || isStreaming; + } else { + blocks.push({ + type: "message", + role: entry.kind, + ts: entry.ts, + text: entry.text, + streaming: isStreaming, + }); + } + continue; + } + + if (entry.kind === "thinking") { + const isStreaming = streaming && entry.delta === true; + if (previous?.type === "thinking") { + previous.text += previous.text.endsWith("\n") || entry.text.startsWith("\n") ? entry.text : `\n${entry.text}`; + previous.ts = entry.ts; + previous.streaming = previous.streaming || isStreaming; + } else { + blocks.push({ + type: "thinking", + ts: entry.ts, + text: entry.text, + streaming: isStreaming, + }); + } + continue; + } + + if (entry.kind === "tool_call") { + const toolBlock: Extract = { + type: "tool", + ts: entry.ts, + name: displayToolName(entry.name, entry.input), + toolUseId: entry.toolUseId ?? extractToolUseId(entry.input), + input: entry.input, + status: "running", + }; + blocks.push(toolBlock); + if (toolBlock.toolUseId) { + pendingToolBlocks.set(toolBlock.toolUseId, toolBlock); + } + continue; + } + + if (entry.kind === "tool_result") { + const matched = + pendingToolBlocks.get(entry.toolUseId) + ?? [...blocks].reverse().find((block): block is Extract => block.type === "tool" && block.status === "running"); + + if (matched) { + matched.result = entry.content; + matched.isError = entry.isError; + matched.status = entry.isError ? "error" : "completed"; + matched.endTs = entry.ts; + pendingToolBlocks.delete(entry.toolUseId); + } else { + blocks.push({ + type: "tool", + ts: entry.ts, + endTs: entry.ts, + name: "tool", + toolUseId: entry.toolUseId, + input: null, + result: entry.content, + isError: entry.isError, + status: entry.isError ? "error" : "completed", + }); + } + continue; + } + + if (entry.kind === "init") { + blocks.push({ + type: "event", + ts: entry.ts, + label: "init", + tone: "info", + text: `model ${entry.model}${entry.sessionId ? ` • session ${entry.sessionId}` : ""}`, + }); + continue; + } + + if (entry.kind === "result") { + blocks.push({ + type: "event", + ts: entry.ts, + label: "result", + tone: entry.isError ? "error" : "info", + text: entry.text.trim() || entry.errors[0] || (entry.isError ? "Run failed" : "Completed"), + }); + continue; + } + + if (entry.kind === "stderr") { + blocks.push({ + type: "event", + ts: entry.ts, + label: "stderr", + tone: "error", + text: entry.text, + }); + continue; + } + + if (entry.kind === "system") { + if (compactWhitespace(entry.text).toLowerCase() === "turn started") { + continue; + } + const activity = parseSystemActivity(entry.text); + if (activity) { + const existing = activity.activityId ? pendingActivityBlocks.get(activity.activityId) : undefined; + if (existing) { + existing.status = activity.status; + existing.ts = entry.ts; + if (activity.status === "completed" && activity.activityId) { + pendingActivityBlocks.delete(activity.activityId); + } + } else { + const block: Extract = { + type: "activity", + ts: entry.ts, + activityId: activity.activityId, + name: activity.name, + status: activity.status, + }; + blocks.push(block); + if (activity.status === "running" && activity.activityId) { + pendingActivityBlocks.set(activity.activityId, block); + } + } + continue; + } + blocks.push({ + type: "event", + ts: entry.ts, + label: "system", + tone: "warn", + text: entry.text, + }); + continue; + } + + if (previous?.type === "stdout") { + previous.text += previous.text.endsWith("\n") || entry.text.startsWith("\n") ? entry.text : `\n${entry.text}`; + previous.ts = entry.ts; + } else { + blocks.push({ + type: "stdout", + ts: entry.ts, + text: entry.text, + }); + } + } + + return groupCommandBlocks(blocks); +} + +function TranscriptMessageBlock({ + block, + density, +}: { + block: Extract; + density: TranscriptDensity; +}) { + const isAssistant = block.role === "assistant"; + const compact = density === "compact"; + + return ( +
+ {!isAssistant && ( +
+ + User +
+ )} + {compact ? ( +
+ {truncate(stripMarkdown(block.text), 360)} +
+ ) : ( + + {block.text} + + )} + {block.streaming && ( +
+ + + + + Streaming +
+ )} +
+ ); +} + +function TranscriptThinkingBlock({ + block, + density, +}: { + block: Extract; + density: TranscriptDensity; +}) { + return ( +
+ {block.text} +
+ ); +} + +function TranscriptToolCard({ + block, + density, +}: { + block: Extract; + density: TranscriptDensity; +}) { + const [open, setOpen] = useState(block.status === "error"); + const compact = density === "compact"; + const parsedResult = parseStructuredToolResult(block.result); + const statusLabel = + block.status === "running" + ? "Running" + : block.status === "error" + ? "Errored" + : "Completed"; + const statusTone = + block.status === "running" + ? "text-cyan-700 dark:text-cyan-300" + : block.status === "error" + ? "text-red-700 dark:text-red-300" + : "text-emerald-700 dark:text-emerald-300"; + const detailsClass = cn( + "space-y-3", + block.status === "error" && "rounded-xl border border-red-500/20 bg-red-500/[0.06] p-3", + ); + const iconClass = cn( + "mt-0.5 h-3.5 w-3.5 shrink-0", + block.status === "error" + ? "text-red-600 dark:text-red-300" + : block.status === "completed" + ? "text-emerald-600 dark:text-emerald-300" + : "text-cyan-600 dark:text-cyan-300", + ); + const summary = block.status === "running" + ? summarizeToolInput(block.name, block.input, density) + : block.status === "completed" && parsedResult?.body + ? truncate(parsedResult.body.split("\n")[0] ?? parsedResult.body, compact ? 84 : 140) + : summarizeToolResult(block.result, block.isError, density); + + return ( +
+
+ {block.status === "error" ? ( + + ) : block.status === "completed" ? ( + + ) : ( + + )} +
+
+ + {block.name} + + + {statusLabel} + +
+
+ {summary} +
+
+ +
+ {open && ( +
+
+
+
+
+ Input +
+
+                  {formatToolPayload(block.input) || ""}
+                
+
+
+
+ Result +
+
+                  {block.result ? formatToolPayload(block.result) : "Waiting for result..."}
+                
+
+
+
+
+ )} +
+ ); +} + +function hasSelectedText() { + if (typeof window === "undefined") return false; + return (window.getSelection()?.toString().length ?? 0) > 0; +} + +function TranscriptCommandGroup({ + block, + density, +}: { + block: Extract; + density: TranscriptDensity; +}) { + const [open, setOpen] = useState(false); + const compact = density === "compact"; + const runningItem = [...block.items].reverse().find((item) => item.status === "running"); + const latestItem = block.items[block.items.length - 1] ?? null; + const hasError = block.items.some((item) => item.status === "error"); + const isRunning = Boolean(runningItem); + const showExpandedErrorState = open && hasError; + const title = isRunning + ? "Executing command" + : block.items.length === 1 + ? "Executed command" + : `Executed ${block.items.length} commands`; + const subtitle = runningItem + ? summarizeToolInput("command_execution", runningItem.input, density) + : null; + const statusTone = isRunning + ? "text-cyan-700 dark:text-cyan-300" + : "text-foreground/70"; + + return ( +
+
{ + if (hasSelectedText()) return; + setOpen((value) => !value); + }} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setOpen((value) => !value); + } + }} + > +
+ {block.items.slice(0, Math.min(block.items.length, 3)).map((_, index) => ( + 0 && "-ml-1.5", + isRunning + ? "border-cyan-500/25 bg-cyan-500/[0.08] text-cyan-600 dark:text-cyan-300" + : "border-border/70 bg-background text-foreground/55", + isRunning && "animate-pulse", + )} + > + + + ))} +
+
+
+ {title} +
+ {subtitle && ( +
+ {subtitle} +
+ )} + {!subtitle && latestItem?.status === "error" && open && ( +
+ Command failed +
+ )} +
+ +
+ {open && ( +
+ {block.items.map((item, index) => ( +
+
+ + + + + {summarizeToolInput("command_execution", item.input, density)} + +
+ {item.result && ( +
+                  {formatToolPayload(item.result)}
+                
+ )} +
+ ))} +
+ )} +
+ ); +} + +function TranscriptActivityRow({ + block, + density, +}: { + block: Extract; + density: TranscriptDensity; +}) { + return ( +
+ {block.status === "completed" ? ( + + ) : ( + + + + + )} +
+ {block.name} +
+
+ ); +} + +function TranscriptEventRow({ + block, + density, +}: { + block: Extract; + density: TranscriptDensity; +}) { + const compact = density === "compact"; + const toneClasses = + block.tone === "error" + ? "rounded-xl border border-red-500/20 bg-red-500/[0.06] p-3 text-red-700 dark:text-red-300" + : block.tone === "warn" + ? "text-amber-700 dark:text-amber-300" + : block.tone === "info" + ? "text-sky-700 dark:text-sky-300" + : "text-foreground/75"; + + return ( +
+
+ {block.tone === "error" ? ( + + ) : block.tone === "warn" ? ( + + ) : ( + + )} +
+ {block.label === "result" && block.tone !== "error" ? ( +
+ {block.text} +
+ ) : ( +
+ + {block.label} + + {block.text ? {block.text} : null} +
+ )} + {block.detail && ( +
+              {block.detail}
+            
+ )} +
+
+
+ ); +} + +function TranscriptStdoutRow({ + block, + density, + collapseByDefault, +}: { + block: Extract; + density: TranscriptDensity; + collapseByDefault: boolean; +}) { + const [open, setOpen] = useState(!collapseByDefault); + + return ( +
+
+ + stdout + + +
+ {open && ( +
+          {block.text}
+        
+ )} +
+ ); +} + +function RawTranscriptView({ + entries, + density, +}: { + entries: TranscriptEntry[]; + density: TranscriptDensity; +}) { + const compact = density === "compact"; + return ( +
+ {entries.map((entry, idx) => ( +
+ + {entry.kind} + +
+            {entry.kind === "tool_call"
+              ? `${entry.name}\n${formatToolPayload(entry.input)}`
+              : entry.kind === "tool_result"
+                ? formatToolPayload(entry.content)
+                : entry.kind === "result"
+                  ? `${entry.text}\n${formatTokens(entry.inputTokens)} / ${formatTokens(entry.outputTokens)} / $${entry.costUsd.toFixed(6)}`
+                  : entry.kind === "init"
+                    ? `model=${entry.model}${entry.sessionId ? ` session=${entry.sessionId}` : ""}`
+                    : entry.text}
+          
+
+ ))} +
+ ); +} + +export function RunTranscriptView({ + entries, + mode = "nice", + density = "comfortable", + limit, + streaming = false, + collapseStdout = false, + emptyMessage = "No transcript yet.", + className, +}: RunTranscriptViewProps) { + const blocks = useMemo(() => normalizeTranscript(entries, streaming), [entries, streaming]); + const visibleBlocks = limit ? blocks.slice(-limit) : blocks; + const visibleEntries = limit ? entries.slice(-limit) : entries; + + if (entries.length === 0) { + return ( +
+ {emptyMessage} +
+ ); + } + + if (mode === "raw") { + return ( +
+ +
+ ); + } + + return ( +
+ {visibleBlocks.map((block, index) => ( +
+ {block.type === "message" && } + {block.type === "thinking" && } + {block.type === "tool" && } + {block.type === "command_group" && } + {block.type === "stdout" && ( + + )} + {block.type === "activity" && } + {block.type === "event" && } +
+ ))} +
+ ); +} diff --git a/ui/src/components/transcript/useLiveRunTranscripts.ts b/ui/src/components/transcript/useLiveRunTranscripts.ts new file mode 100644 index 00000000..993663d8 --- /dev/null +++ b/ui/src/components/transcript/useLiveRunTranscripts.ts @@ -0,0 +1,283 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import type { LiveEvent } from "@paperclipai/shared"; +import { heartbeatsApi, type LiveRunForIssue } from "../../api/heartbeats"; +import { buildTranscript, getUIAdapter, type RunLogChunk, type TranscriptEntry } from "../../adapters"; + +const LOG_POLL_INTERVAL_MS = 2000; +const LOG_READ_LIMIT_BYTES = 256_000; + +interface UseLiveRunTranscriptsOptions { + runs: LiveRunForIssue[]; + companyId?: string | null; + maxChunksPerRun?: number; +} + +function readString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +function isTerminalStatus(status: string): boolean { + return status === "failed" || status === "timed_out" || status === "cancelled" || status === "succeeded"; +} + +function parsePersistedLogContent( + runId: string, + content: string, + pendingByRun: Map, +): Array { + if (!content) return []; + + const pendingKey = `${runId}:records`; + const combined = `${pendingByRun.get(pendingKey) ?? ""}${content}`; + const split = combined.split("\n"); + pendingByRun.set(pendingKey, split.pop() ?? ""); + + const parsed: Array = []; + for (const line of split) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const raw = JSON.parse(trimmed) as { ts?: unknown; stream?: unknown; chunk?: unknown }; + const stream = raw.stream === "stderr" || raw.stream === "system" ? raw.stream : "stdout"; + const chunk = typeof raw.chunk === "string" ? raw.chunk : ""; + const ts = typeof raw.ts === "string" ? raw.ts : new Date().toISOString(); + if (!chunk) continue; + parsed.push({ + ts, + stream, + chunk, + dedupeKey: `log:${runId}:${ts}:${stream}:${chunk}`, + }); + } catch { + // Ignore malformed log rows. + } + } + + return parsed; +} + +export function useLiveRunTranscripts({ + runs, + companyId, + maxChunksPerRun = 200, +}: UseLiveRunTranscriptsOptions) { + const [chunksByRun, setChunksByRun] = useState>(new Map()); + const seenChunkKeysRef = useRef(new Set()); + const pendingLogRowsByRunRef = useRef(new Map()); + const logOffsetByRunRef = useRef(new Map()); + + const runById = useMemo(() => new Map(runs.map((run) => [run.id, run])), [runs]); + const activeRunIds = useMemo( + () => new Set(runs.filter((run) => !isTerminalStatus(run.status)).map((run) => run.id)), + [runs], + ); + const runIdsKey = useMemo( + () => runs.map((run) => run.id).sort((a, b) => a.localeCompare(b)).join(","), + [runs], + ); + + const appendChunks = (runId: string, chunks: Array) => { + if (chunks.length === 0) return; + setChunksByRun((prev) => { + const next = new Map(prev); + const existing = [...(next.get(runId) ?? [])]; + let changed = false; + + for (const chunk of chunks) { + if (seenChunkKeysRef.current.has(chunk.dedupeKey)) continue; + seenChunkKeysRef.current.add(chunk.dedupeKey); + existing.push({ ts: chunk.ts, stream: chunk.stream, chunk: chunk.chunk }); + changed = true; + } + + if (!changed) return prev; + if (seenChunkKeysRef.current.size > 12000) { + seenChunkKeysRef.current.clear(); + } + next.set(runId, existing.slice(-maxChunksPerRun)); + return next; + }); + }; + + useEffect(() => { + const knownRunIds = new Set(runs.map((run) => run.id)); + setChunksByRun((prev) => { + const next = new Map(); + for (const [runId, chunks] of prev) { + if (knownRunIds.has(runId)) { + next.set(runId, chunks); + } + } + return next.size === prev.size ? prev : next; + }); + + for (const key of pendingLogRowsByRunRef.current.keys()) { + const runId = key.replace(/:records$/, ""); + if (!knownRunIds.has(runId)) { + pendingLogRowsByRunRef.current.delete(key); + } + } + for (const runId of logOffsetByRunRef.current.keys()) { + if (!knownRunIds.has(runId)) { + logOffsetByRunRef.current.delete(runId); + } + } + }, [runs]); + + useEffect(() => { + if (runs.length === 0) return; + + let cancelled = false; + + const readRunLog = async (run: LiveRunForIssue) => { + const offset = logOffsetByRunRef.current.get(run.id) ?? 0; + try { + const result = await heartbeatsApi.log(run.id, offset, LOG_READ_LIMIT_BYTES); + if (cancelled) return; + + appendChunks(run.id, parsePersistedLogContent(run.id, result.content, pendingLogRowsByRunRef.current)); + + if (result.nextOffset !== undefined) { + logOffsetByRunRef.current.set(run.id, result.nextOffset); + return; + } + if (result.content.length > 0) { + logOffsetByRunRef.current.set(run.id, offset + result.content.length); + } + } catch { + // Ignore log read errors while output is initializing. + } + }; + + const readAll = async () => { + await Promise.all(runs.map((run) => readRunLog(run))); + }; + + void readAll(); + const interval = window.setInterval(() => { + void readAll(); + }, LOG_POLL_INTERVAL_MS); + + return () => { + cancelled = true; + window.clearInterval(interval); + }; + }, [runIdsKey, runs]); + + useEffect(() => { + if (!companyId || activeRunIds.size === 0) return; + + let closed = false; + let reconnectTimer: number | null = null; + let socket: WebSocket | null = null; + + const scheduleReconnect = () => { + if (closed) return; + reconnectTimer = window.setTimeout(connect, 1500); + }; + + const connect = () => { + if (closed) return; + const protocol = window.location.protocol === "https:" ? "wss" : "ws"; + const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(companyId)}/events/ws`; + socket = new WebSocket(url); + + socket.onmessage = (message) => { + const raw = typeof message.data === "string" ? message.data : ""; + if (!raw) return; + + let event: LiveEvent; + try { + event = JSON.parse(raw) as LiveEvent; + } catch { + return; + } + + if (event.companyId !== companyId) return; + const payload = event.payload ?? {}; + const runId = readString(payload["runId"]); + if (!runId || !activeRunIds.has(runId)) return; + if (!runById.has(runId)) return; + + if (event.type === "heartbeat.run.log") { + const chunk = readString(payload["chunk"]); + if (!chunk) return; + const ts = readString(payload["ts"]) ?? event.createdAt; + const stream = + readString(payload["stream"]) === "stderr" + ? "stderr" + : readString(payload["stream"]) === "system" + ? "system" + : "stdout"; + appendChunks(runId, [{ + ts, + stream, + chunk, + dedupeKey: `log:${runId}:${ts}:${stream}:${chunk}`, + }]); + return; + } + + if (event.type === "heartbeat.run.event") { + const seq = typeof payload["seq"] === "number" ? payload["seq"] : null; + const eventType = readString(payload["eventType"]) ?? "event"; + const messageText = readString(payload["message"]) ?? eventType; + appendChunks(runId, [{ + ts: event.createdAt, + stream: eventType === "error" ? "stderr" : "system", + chunk: messageText, + dedupeKey: `socket:event:${runId}:${seq ?? `${eventType}:${messageText}:${event.createdAt}`}`, + }]); + return; + } + + if (event.type === "heartbeat.run.status") { + const status = readString(payload["status"]) ?? "updated"; + appendChunks(runId, [{ + ts: event.createdAt, + stream: isTerminalStatus(status) && status !== "succeeded" ? "stderr" : "system", + chunk: `run ${status}`, + dedupeKey: `socket:status:${runId}:${status}:${readString(payload["finishedAt"]) ?? ""}`, + }]); + } + }; + + socket.onerror = () => { + socket?.close(); + }; + + socket.onclose = () => { + scheduleReconnect(); + }; + }; + + connect(); + + return () => { + closed = true; + if (reconnectTimer !== null) window.clearTimeout(reconnectTimer); + if (socket) { + socket.onmessage = null; + socket.onerror = null; + socket.onclose = null; + socket.close(1000, "live_run_transcripts_unmount"); + } + }; + }, [activeRunIds, companyId, runById]); + + const transcriptByRun = useMemo(() => { + const next = new Map(); + for (const run of runs) { + const adapter = getUIAdapter(run.adapterType); + next.set(run.id, buildTranscript(chunksByRun.get(run.id) ?? [], adapter.parseStdoutLine)); + } + return next; + }, [chunksByRun, runs]); + + return { + transcriptByRun, + hasOutputForRun(runId: string) { + return (chunksByRun.get(runId)?.length ?? 0) > 0; + }, + }; +} diff --git a/ui/src/fixtures/runTranscriptFixtures.ts b/ui/src/fixtures/runTranscriptFixtures.ts new file mode 100644 index 00000000..df120344 --- /dev/null +++ b/ui/src/fixtures/runTranscriptFixtures.ts @@ -0,0 +1,226 @@ +import type { TranscriptEntry } from "../adapters"; + +export interface RunTranscriptFixtureMeta { + sourceRunId: string; + fixtureLabel: string; + agentName: string; + agentId: string; + issueIdentifier: string; + issueTitle: string; + startedAt: string; + finishedAt: string | null; +} + +export const runTranscriptFixtureMeta: RunTranscriptFixtureMeta = { + sourceRunId: "65a79d5d-5f85-4392-a5cc-8fb48beb9e71", + fixtureLabel: "Sanitized development fixture", + agentName: "CodexCoder", + agentId: "codexcoder-fixture", + issueIdentifier: "PAP-473", + issueTitle: "Humanize run transcripts across run detail and live surfaces", + startedAt: "2026-03-11T15:21:05.948Z", + finishedAt: null, +}; + +// Sanitized from a real development run. Paths, secrets, env vars, and user-local identifiers +// are replaced with safe placeholders while preserving the interaction shape. +export const runTranscriptFixtureEntries: TranscriptEntry[] = [ + { + kind: "stderr", + ts: "2026-03-11T15:21:05.594Z", + text: "[paperclip] Skipping saved session resume for task \"PAP-473\" because wake reason is issue_assigned.", + }, + { + kind: "init", + ts: "2026-03-11T15:21:05.948Z", + model: "codex", + sessionId: "sess_fixture_01", + }, + { + kind: "system", + ts: "2026-03-11T15:21:07.249Z", + text: "turn started", + }, + { + kind: "assistant", + ts: "2026-03-11T15:21:18.851Z", + text: "I’m starting with the Paperclip heartbeat procedure and the repo-required docs so I can see what task is actually assigned and then work only on that scope.", + }, + { + kind: "tool_call", + ts: "2026-03-11T15:21:19.026Z", + name: "command_execution", + toolUseId: "item_1", + input: { + command: "sed -n '1,220p' doc/GOAL.md", + cwd: "/workspace/paperclip", + }, + }, + { + kind: "tool_call", + ts: "2026-03-11T15:21:19.026Z", + name: "command_execution", + toolUseId: "item_2", + input: { + command: "sed -n '1,320p' doc/SPEC-implementation.md", + cwd: "/workspace/paperclip", + }, + }, + { + kind: "tool_result", + ts: "2026-03-11T15:21:19.034Z", + toolUseId: "item_1", + content: "command: sed -n '1,220p' doc/GOAL.md\nstatus: completed\nexit_code: 0\n\n# Paperclip\n\nPaperclip is the backbone of the autonomous economy.", + isError: false, + }, + { + kind: "tool_result", + ts: "2026-03-11T15:21:19.035Z", + toolUseId: "item_2", + content: "command: sed -n '1,320p' doc/SPEC-implementation.md\nstatus: completed\nexit_code: 0\n\n# Paperclip V1 Implementation Spec\n\nPaperclip V1 must provide a full control-plane loop for autonomous agents.", + isError: false, + }, + { + kind: "assistant", + ts: "2026-03-11T15:22:08.806Z", + text: "I’ve got the rendering entry points. I’m reading the existing transcript parser and the three UI surfaces together so I can collapse them onto one renderer instead of layering another one-off view.", + }, + { + kind: "thinking", + ts: "2026-03-11T15:22:12.044Z", + text: "The current transcript UX duplicates parsing logic in multiple surfaces. A shared presentation layer will make the detail page and live surfaces behave the same way while keeping raw fallback available.", + }, + { + kind: "tool_call", + ts: "2026-03-11T15:22:15.401Z", + name: "command_execution", + toolUseId: "item_3", + input: { + command: "rg -n \"LiveRunWidget|ActiveAgentsPanel|buildTranscript|TranscriptEntry|transcript\" ui/src server/src packages", + cwd: "/workspace/paperclip", + }, + }, + { + kind: "tool_result", + ts: "2026-03-11T15:22:15.982Z", + toolUseId: "item_3", + content: "command: rg -n ...\nstatus: completed\nexit_code: 0\n\nui/src/components/ActiveAgentsPanel.tsx\nui/src/components/LiveRunWidget.tsx\nui/src/pages/AgentDetail.tsx", + isError: false, + }, + { + kind: "system", + ts: "2026-03-11T15:22:21.617Z", + text: "item started: web_search (id=ws_fixture_reasoning_ui)", + }, + { + kind: "system", + ts: "2026-03-11T15:22:24.806Z", + text: "item completed: web_search (id=ws_fixture_reasoning_ui)", + }, + { + kind: "assistant", + ts: "2026-03-11T15:22:29.214Z", + text: "I’ve confirmed the duplication: the run page has the only full transcript renderer, while live surfaces still collapse output into feed strings. I’m pulling markdown utilities and adapter shapes now so the new renderer can stay shared.", + }, + { + kind: "tool_call", + ts: "2026-03-11T15:23:04.251Z", + name: "command_execution", + toolUseId: "item_4", + input: { + command: "pnpm -r typecheck", + cwd: "/workspace/paperclip", + }, + }, + { + kind: "tool_result", + ts: "2026-03-11T15:23:26.903Z", + toolUseId: "item_4", + content: "command: pnpm -r typecheck\nstatus: completed\nexit_code: 0\n\npackages/adapter-utils typecheck: Done\nserver typecheck: Done\nui typecheck: Done\ncli typecheck: Done", + isError: false, + }, + { + kind: "assistant", + ts: "2026-03-11T15:23:34.102Z", + text: "Typecheck is clean. I’m running the remaining repo checks now so the transcript work is verified under the full workspace build, not just the UI package.", + }, + { + kind: "tool_call", + ts: "2026-03-11T15:23:40.511Z", + name: "command_execution", + toolUseId: "item_5", + input: { + command: "pnpm test:run", + cwd: "/workspace/paperclip", + }, + }, + { + kind: "tool_result", + ts: "2026-03-11T15:23:56.944Z", + toolUseId: "item_5", + content: "command: pnpm test:run\nstatus: failed\nexit_code: 1\n\nFAIL packages/db/src/runtime-config.test.ts\nFAIL src/__tests__/worktree.test.ts\n\nAll transcript-adapter tests passed after snapshot updates.", + isError: true, + }, + { + kind: "thinking", + ts: "2026-03-11T15:24:02.188Z", + text: "The remaining failures are unrelated to transcript rendering, so I should isolate them in the handoff and keep the UI change set focused.", + }, + { + kind: "assistant", + ts: "2026-03-11T15:24:08.490Z", + text: "The new parser metadata changed a few adapter test snapshots exactly where expected, and the remaining red tests are outside this change set. I’m keeping the transcript patch focused and shipping with that caveat called out.", + }, + { + kind: "tool_call", + ts: "2026-03-11T15:24:21.327Z", + name: "command_execution", + toolUseId: "item_6", + input: { + command: "pnpm build", + cwd: "/workspace/paperclip", + }, + }, + { + kind: "tool_result", + ts: "2026-03-11T15:24:44.030Z", + toolUseId: "item_6", + content: "command: pnpm build\nstatus: completed\nexit_code: 0\n\nui build: built successfully\ncli build: Done", + isError: false, + }, + { + kind: "tool_call", + ts: "2026-03-11T15:24:52.504Z", + name: "command_execution", + toolUseId: "item_7", + input: { + command: "git commit -m \"Humanize run transcripts across run detail and live surfaces\"", + cwd: "/workspace/paperclip", + }, + }, + { + kind: "tool_result", + ts: "2026-03-11T15:24:53.104Z", + toolUseId: "item_7", + content: "command: git commit ...\nstatus: completed\nexit_code: 0\n\n87b8e21 Humanize run transcripts across run detail and live surfaces", + isError: false, + }, + { + kind: "assistant", + ts: "2026-03-11T15:25:00.908Z", + text: "Implemented the transcript UI refactor and switched the run detail page, issue live widget, and dashboard agent card onto the same rendering language.", + }, + { + kind: "result", + ts: "2026-03-11T15:25:05.440Z", + text: "Transcript rollout complete with shared nice/raw rendering and compact live variants.", + inputTokens: 11240, + outputTokens: 3460, + cachedTokens: 520, + costUsd: 0.048121, + subtype: "success", + isError: false, + errors: [], + }, +]; + diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 77923019..b0eeb89f 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -17,7 +17,6 @@ import { AgentConfigForm } from "../components/AgentConfigForm"; import { PageTabBar } from "../components/PageTabBar"; import { adapterLabels, roleLabels } from "../components/agent-config-primitives"; import { getUIAdapter, buildTranscript } from "../adapters"; -import type { TranscriptEntry } from "../adapters"; import { StatusBadge } from "../components/StatusBadge"; import { agentStatusDot, agentStatusDotDefault } from "../lib/status-colors"; import { MarkdownBody } from "../components/MarkdownBody"; @@ -58,6 +57,7 @@ import { } from "lucide-react"; import { Input } from "@/components/ui/input"; import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker"; +import { RunTranscriptView, type TranscriptMode } from "../components/transcript/RunTranscriptView"; import { isUuidLike, type Agent, type HeartbeatRun, type HeartbeatRunEvent, type AgentRuntimeState, type LiveEvent } from "@paperclipai/shared"; import { agentRouteRef } from "../lib/utils"; @@ -1675,6 +1675,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin const [logOffset, setLogOffset] = useState(0); const [isFollowing, setIsFollowing] = useState(false); const [isStreamingConnected, setIsStreamingConnected] = useState(false); + const [transcriptMode, setTranscriptMode] = useState("nice"); const logEndRef = useRef(null); const pendingLogLineRef = useRef(""); const scrollContainerRef = useRef(null); @@ -2028,6 +2029,10 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin const adapter = useMemo(() => getUIAdapter(adapterType), [adapterType]); const transcript = useMemo(() => buildTranscript(logLines, adapter.parseStdoutLine), [logLines, adapter]); + useEffect(() => { + setTranscriptMode("nice"); + }, [run.id]); + if (loading && logLoading) { return

Loading run logs...

; } @@ -2120,6 +2125,23 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin Transcript ({transcript.length})
+
+ {(["nice", "raw"] as const).map((mode) => ( + + ))} +
{isLive && !isFollowing && (
-
- {transcript.length === 0 && !run.logRef && ( -
No persisted transcript for this run.
+
+ + {logError && ( +
+ {logError} +
)} - {transcript.map((entry, idx) => { - const time = new Date(entry.ts).toLocaleTimeString("en-US", { hour12: false }); - const grid = "grid grid-cols-[auto_auto_1fr] gap-x-2 sm:gap-x-3 items-baseline"; - const tsCell = "text-neutral-400 dark:text-neutral-600 select-none w-12 sm:w-16 text-[10px] sm:text-xs"; - const lblCell = "w-14 sm:w-20 text-[10px] sm:text-xs"; - const contentCell = "min-w-0 whitespace-pre-wrap break-words overflow-hidden"; - const expandCell = "col-span-full md:col-start-3 md:col-span-1"; - - if (entry.kind === "assistant") { - return ( -
- {time} - assistant - {entry.text} -
- ); - } - - if (entry.kind === "thinking") { - return ( -
- {time} - thinking - {entry.text} -
- ); - } - - if (entry.kind === "user") { - return ( -
- {time} - user - {entry.text} -
- ); - } - - if (entry.kind === "tool_call") { - return ( -
- {time} - tool_call - {entry.name} -
-                  {JSON.stringify(entry.input, null, 2)}
-                
-
- ); - } - - if (entry.kind === "tool_result") { - return ( -
- {time} - tool_result - {entry.isError ? error : } -
-                  {(() => { try { return JSON.stringify(JSON.parse(entry.content), null, 2); } catch { return entry.content; } })()}
-                
-
- ); - } - - if (entry.kind === "init") { - return ( -
- {time} - init - model: {entry.model}{entry.sessionId ? `, session: ${entry.sessionId}` : ""} -
- ); - } - - if (entry.kind === "result") { - return ( -
- {time} - result - - tokens in={formatTokens(entry.inputTokens)} out={formatTokens(entry.outputTokens)} cached={formatTokens(entry.cachedTokens)} cost=${entry.costUsd.toFixed(6)} - - {(entry.subtype || entry.isError || entry.errors.length > 0) && ( -
- subtype={entry.subtype || "unknown"} is_error={entry.isError ? "true" : "false"} - {entry.errors.length > 0 ? ` errors=${entry.errors.join(" | ")}` : ""} -
- )} - {entry.text && ( -
{entry.text}
- )} -
- ); - } - - const rawText = entry.text; - const label = - entry.kind === "stderr" ? "stderr" : - entry.kind === "system" ? "system" : - "stdout"; - const color = - entry.kind === "stderr" ? "text-red-600 dark:text-red-300" : - entry.kind === "system" ? "text-blue-600 dark:text-blue-300" : - "text-neutral-500"; - return ( -
- {time} - {label} - {rawText} -
- ) - })} - {logError &&
{logError}
}
diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx index fbae126d..741d8449 100644 --- a/ui/src/pages/Agents.tsx +++ b/ui/src/pages/Agents.tsx @@ -23,6 +23,7 @@ import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared"; const adapterLabels: Record = { claude_local: "Claude", codex_local: "Codex", + gemini_local: "Gemini", opencode_local: "OpenCode", cursor: "Cursor", openclaw_gateway: "OpenClaw Gateway", diff --git a/ui/src/pages/InviteLanding.tsx b/ui/src/pages/InviteLanding.tsx index ada19fd5..571cf739 100644 --- a/ui/src/pages/InviteLanding.tsx +++ b/ui/src/pages/InviteLanding.tsx @@ -15,6 +15,7 @@ const joinAdapterOptions: AgentAdapterType[] = [...AGENT_ADAPTER_TYPES]; const adapterLabels: Record = { claude_local: "Claude (local)", codex_local: "Codex (local)", + gemini_local: "Gemini CLI (local)", opencode_local: "OpenCode (local)", openclaw_gateway: "OpenClaw Gateway", cursor: "Cursor (local)", @@ -22,7 +23,7 @@ const adapterLabels: Record = { http: "HTTP", }; -const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "opencode_local", "cursor"]); +const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "cursor"]); function dateTime(value: string) { return new Date(value).toLocaleString(); diff --git a/ui/src/pages/NewAgent.tsx b/ui/src/pages/NewAgent.tsx index b28d9710..364e35a0 100644 --- a/ui/src/pages/NewAgent.tsx +++ b/ui/src/pages/NewAgent.tsx @@ -24,10 +24,12 @@ import { DEFAULT_CODEX_LOCAL_MODEL, } from "@paperclipai/adapter-codex-local"; import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local"; +import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local"; const SUPPORTED_ADVANCED_ADAPTER_TYPES = new Set([ "claude_local", "codex_local", + "gemini_local", "opencode_local", "pi_local", "cursor", @@ -43,6 +45,8 @@ function createValuesForAdapterType( nextValues.model = DEFAULT_CODEX_LOCAL_MODEL; nextValues.dangerouslyBypassSandbox = DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX; + } else if (adapterType === "gemini_local") { + nextValues.model = DEFAULT_GEMINI_LOCAL_MODEL; } else if (adapterType === "cursor") { nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL; } else if (adapterType === "opencode_local") { diff --git a/ui/src/pages/OrgChart.tsx b/ui/src/pages/OrgChart.tsx index 89b4d581..981545c0 100644 --- a/ui/src/pages/OrgChart.tsx +++ b/ui/src/pages/OrgChart.tsx @@ -118,6 +118,7 @@ function collectEdges(nodes: LayoutNode[]): Array<{ parent: LayoutNode; child: L const adapterLabels: Record = { claude_local: "Claude", codex_local: "Codex", + gemini_local: "Gemini", opencode_local: "OpenCode", cursor: "Cursor", openclaw_gateway: "OpenClaw Gateway", diff --git a/ui/src/pages/RunTranscriptUxLab.tsx b/ui/src/pages/RunTranscriptUxLab.tsx new file mode 100644 index 00000000..80759cf9 --- /dev/null +++ b/ui/src/pages/RunTranscriptUxLab.tsx @@ -0,0 +1,334 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { cn, formatDateTime } from "../lib/utils"; +import { Identity } from "../components/Identity"; +import { StatusBadge } from "../components/StatusBadge"; +import { RunTranscriptView, type TranscriptDensity, type TranscriptMode } from "../components/transcript/RunTranscriptView"; +import { runTranscriptFixtureEntries, runTranscriptFixtureMeta } from "../fixtures/runTranscriptFixtures"; +import { ExternalLink, FlaskConical, LayoutPanelLeft, MonitorCog, PanelsTopLeft, RadioTower } from "lucide-react"; + +type SurfaceId = "detail" | "live" | "dashboard"; + +const surfaceOptions: Array<{ + id: SurfaceId; + label: string; + eyebrow: string; + description: string; + icon: typeof LayoutPanelLeft; +}> = [ + { + id: "detail", + label: "Run Detail", + eyebrow: "Full transcript", + description: "The long-form run page with the `Nice | Raw` toggle and the most inspectable transcript view.", + icon: MonitorCog, + }, + { + id: "live", + label: "Issue Widget", + eyebrow: "Live stream", + description: "The issue-detail live run widget, optimized for following an active run without leaving the task page.", + icon: RadioTower, + }, + { + id: "dashboard", + label: "Dashboard Card", + eyebrow: "Dense card", + description: "The active-agents dashboard card, tuned for compact scanning while keeping the same transcript language.", + icon: PanelsTopLeft, + }, +]; + +function previewEntries(surface: SurfaceId) { + if (surface === "dashboard") { + return runTranscriptFixtureEntries.slice(-9); + } + if (surface === "live") { + return runTranscriptFixtureEntries.slice(-14); + } + return runTranscriptFixtureEntries; +} + +function RunDetailPreview({ + mode, + streaming, + density, +}: { + mode: TranscriptMode; + streaming: boolean; + density: TranscriptDensity; +}) { + return ( +
+
+
+ + Run Detail + + + + {formatDateTime(runTranscriptFixtureMeta.startedAt)} + +
+
+ Transcript ({runTranscriptFixtureEntries.length}) +
+
+
+ +
+
+ ); +} + +function LiveWidgetPreview({ + streaming, + mode, + density, +}: { + streaming: boolean; + mode: TranscriptMode; + density: TranscriptDensity; +}) { + return ( +
+
+
+ Live Runs +
+
+ Compact live transcript stream for the issue detail page. +
+
+
+
+
+ +
+ + {runTranscriptFixtureMeta.sourceRunId.slice(0, 8)} + + + {formatDateTime(runTranscriptFixtureMeta.startedAt)} +
+
+ + Open run + + +
+
+ +
+
+
+ ); +} + +function DashboardPreview({ + streaming, + mode, + density, +}: { + streaming: boolean; + mode: TranscriptMode; + density: TranscriptDensity; +}) { + return ( +
+
+
+
+
+
+ + +
+
+ {streaming ? "Live now" : "Finished 2m ago"} +
+
+ + + +
+
+ {runTranscriptFixtureMeta.issueIdentifier} - {runTranscriptFixtureMeta.issueTitle} +
+
+
+ +
+
+
+ ); +} + +export function RunTranscriptUxLab() { + const [selectedSurface, setSelectedSurface] = useState("detail"); + const [detailMode, setDetailMode] = useState("nice"); + const [streaming, setStreaming] = useState(true); + const [density, setDensity] = useState("comfortable"); + + const selected = surfaceOptions.find((option) => option.id === selectedSurface) ?? surfaceOptions[0]; + + return ( +
+
+
+ + +
+
+
+
+ {selected.eyebrow} +
+

{selected.label}

+

+ {selected.description} +

+
+ +
+ + Source run {runTranscriptFixtureMeta.sourceRunId.slice(0, 8)} + + + {runTranscriptFixtureMeta.issueIdentifier} + +
+
+ +
+ + Controls + +
+ {(["nice", "raw"] as const).map((mode) => ( + + ))} +
+
+ {(["comfortable", "compact"] as const).map((nextDensity) => ( + + ))} +
+ +
+ + {selectedSurface === "detail" ? ( +
+ +
+ ) : selectedSurface === "live" ? ( +
+ +
+ ) : ( + + )} +
+
+
+
+ ); +}