1// Shared rendering logic for OpenCode session transcripts 2// Used by both epOcToMp4AI.mjs (video) and epOcToHtmlAI.mjs (HTML) 4import { homedir } from 'os' 5import { codeToANSI } from '@shikijs/cli' 6import stripAnsi from 'strip-ansi' 12const userHome = homedir() 14export const sanitize = (s) => (s || '').replaceAll(userHome, '~') 15export const isSensitivePath = (p) => (p || '').includes(secretsRelDir) 16export const shortPath = (p) => sanitize(p ? toPtRelPath(p) : '') 19 bold: '\x1b[1m', dim: '\x1b[90m', reset: '\x1b[0m', 20 white: '\x1b[97m', gray: '\x1b[90m', 21 cyan: '\x1b[96m', yellow: '\x1b[93m', green: '\x1b[92m', red: '\x1b[91m', blue: '\x1b[94m', 22 orange: '\x1b[38;5;215m', teal: '\x1b[38;5;43m', pink: '\x1b[38;5;212m', 23 bgGray: '\x1b[48;5;236m', 24 bgNearBlack: '\x1b[48;2;17;17;17m', 25 barPurple: '\x1b[48;2;157;124;216m', 26 barBlue: '\x1b[48;2;59;130;246m', 27 diffAddBg: '\x1b[48;2;10;14;10m', diffDelBg: '\x1b[48;2;14;10;10m', 28 diffAddText: '\x1b[38;2;140;195;140m', diffDelText: '\x1b[38;2;220;100;90m', 31export const highlightJs = async (text) => { 33 const result = await codeToANSI(text, 'typescript', 'github-dark') 34 return result.replace(/\n$/, '') 40export const padToWidth = (line, width) => { 41 const visibleLen = stripAnsi(line).length 42 return line + ' '.repeat(Math.max(0, width - visibleLen)) 45export const wrapLine = (line, maxWidth, indent = '') => { 46 if (stripAnsi(line).length <= maxWidth) return [line] 47 const words = line.split(/(\s+)/) 50 for (const word of words) { 51 const testLine = current + word 52 if (stripAnsi(testLine).length > maxWidth && current) { 54 current = indent + word.trimStart() 59 if (current) lines.push(current) 60 return lines.length ? lines : [''] 63export const generateDiff = async (oldStr, newStr, filePath, width = 120) => { 64 const oldLines = (oldStr || '').split('\n'), newLines = (newStr || '').split('\n') 67 while (oi < oldLines.length || ni < newLines.length) { 68 if (oi < oldLines.length && ni < newLines.length && oldLines[oi] === newLines[ni]) { 70 } else if (oi < oldLines.length && !newLines.includes(oldLines[oi])) { 71 const content = oldLines[oi] 72 lines.push(`${ansi.diffDelBg}${ansi.diffDelText} - ${content}${ansi.reset}`) 74 } else if (ni < newLines.length) { 75 const content = newLines[ni] 76 lines.push(`${ansi.diffAddBg}${ansi.diffAddText} + ${content}${ansi.reset}`) 85const jsLangs = ['js', 'javascript', 'ts', 'typescript', 'jsx', 'tsx', 'mjs', 'mts', ''] 87export const isTableRow = (line) => line.trim().startsWith('|') && line.trim().endsWith('|') 88export const isTableSep = (line) => /^\|[-:\s|]+\|$/.test(line.trim()) 90const processInlineCode = (text) => text.replace(/`([^`]+)`/g, `${ansi.teal}$1${ansi.reset}`) 91const stripBackticks = (text) => text.replace(/`([^`]+)`/g, '$1') 93const renderTable = (tableLines) => { 94 const rows = tableLines.filter(l => !isTableSep(l)).map(l => 95 l.trim().slice(1, -1).split('|').map(c => c.trim()) 97 if (!rows.length) return [] 98 const colWidths = rows[0].map((_, i) => Math.max(...rows.map(r => stripBackticks(r[i] || '').length))) 99 const sep = `${ansi.dim}│${ansi.reset}` 100 return rows.map((row) => { 101 const cells = row.map((cell, ci) => { 102 const processed = processInlineCode(cell) 103 const visibleLen = stripBackticks(cell).length 104 return processed + ' '.repeat(Math.max(0, colWidths[ci] - visibleLen)) 106 return `${sep} ${cells.join(` ${sep} `)} ${sep}` 110export const mdToTerminal = async (text, maxWidth = 120) => { 111 let inCodeBlock = false, codeLang = '', /** @type {string[]} */ tableBuffer = [] 112 const lines = text.split('\n') 115 const flushTable = () => { 116 if (tableBuffer.length) { results.push(...renderTable(tableBuffer)); tableBuffer = [] } 119 for (const line of lines) { 120 if (line.startsWith('```')) { 123 codeLang = line.slice(3).trim().toLowerCase() 133 const highlighted = jsLangs.includes(codeLang) ? await highlightJs(line) : line 134 results.push(` ${highlighted}`) 137 if (isTableRow(line) || isTableSep(line)) { tableBuffer.push(line); continue } 139 if (line.startsWith('### ')) { results.push(...wrapLine(`${ansi.white}${line.slice(4)}${ansi.reset}`, maxWidth)); continue } 140 if (line.startsWith('## ')) { results.push(...wrapLine(`${ansi.yellow}${line.slice(3)}${ansi.reset}`, maxWidth)); continue } 141 if (line.startsWith('# ')) { results.push(...wrapLine(`${ansi.bold}${ansi.white}${line.slice(2)}${ansi.reset}`, maxWidth)); continue } 143 processed = processed.replace(/\*\*([^*]+)\*\*/g, `${ansi.bold}${ansi.white}$1${ansi.reset}`) 144 processed = processed.replace(/`([^`]+)`/g, `${ansi.teal}$1${ansi.reset}`) 145 if (processed.match(/^[-*] /)) processed = `${ansi.green}•${ansi.reset} ${processed.slice(2)}` 146 if (processed.match(/^\d+\. /)) processed = `${ansi.dim}${processed.match(/^\d+/)[0]}.${ansi.reset} ${processed.replace(/^\d+\. /, '')}` 147 results.push(...wrapLine(processed, maxWidth)) 150 return results.join('\n') 153export const renderQuestion = (part) => { 154 const { state } = part 155 const questions = state?.input?.questions || [] 156 const answers = state?.metadata?.answers || [] 159 for (let i = 0; i < questions.length; i++) { 160 const q = questions[i] 161 const answer = answers[i] 163 lines.push(`${ansi.yellow}${q.header}${ansi.reset}`) 165 lines.push(`${ansi.white}${q.question}${ansi.reset}`) 168 for (const opt of (q.options || [])) { 169 const bullet = q.multiple ? '☐' : '○' 170 lines.push(` ${ansi.dim}${bullet}${ansi.reset} ${ansi.white}${opt.label}${ansi.reset}`) 171 if (opt.description) lines.push(` ${ansi.dim}${opt.description}${ansi.reset}`) 175 if (answer && answer.length > 0) { 176 const answerText = answer.join(', ') 177 lines.push(`${ansi.green}→ ${answerText}${ansi.reset}`) 185const formatToolParams = (params, pathFn) => { 187 for (const [k, v] of Object.entries(params)) { 188 if (v === undefined || v === null || k === 'oldString' || k === 'newString' || k === 'content' || k === 'files') continue 189 const noTruncate = k === 'filePath' || k === 'target' || k === 'question' 190 const val = k === 'filePath' ? pathFn(v) : (typeof v === 'string' && !noTruncate ? truncateStr(v, 60) : v) 191 parts.push(`${k}=${val}`) 193 return parts.length ? `[${parts.join(', ')}]` : '' 196export const renderToolOutput = async (part, width = 120) => { 197 const { tool, state } = part 198 if (!state) return [` ${ansi.dim}↳ ${tool}${ansi.reset}`] 199 const input = state.input || {}, output = state.output || state.metadata?.output || '' 200 const isRunning = state.status !== 'completed' 202 const gear = `${ansi.dim}⚙${ansi.reset}` 204 if (tool === 'question') return renderQuestion(part) 206 if (tool === 'edit' && input.oldString && input.newString) { 207 lines.push(`${gear} ${ansi.orange}edit${ansi.reset} ${ansi.dim}${shortPath(input.filePath)}${ansi.reset}`) 208 if (isSensitivePath(input.filePath)) lines.push(` ${ansi.dim}<omitted>${ansi.reset}`) 209 else lines.push(...await generateDiff(input.oldString, input.newString, input.filePath, width)) 213 if (tool === 'bash') { 215 lines.push(`${gear} ${ansi.orange}bash${ansi.reset} ${ansi.dim}${desc}${ansi.reset}`) 216 const bashOut = state.metadata?.output || state.output || '' 217 if (bashOut && !isRunning) { 218 if (isSensitivePath(input.command)) { 219 lines.push(` ${ansi.dim}<omitted>${ansi.reset}`) 221 for (const l of bashOut.split('\n')) lines.push(` ${ansi.dim}${sanitize(l)}${ansi.reset}`) 227 if (tool === 'read') { 228 lines.push(`${gear} ${ansi.orange}read${ansi.reset} ${ansi.dim}${shortPath(input.filePath)}${ansi.reset}`) 232 if (tool === 'write') { 233 lines.push(`${gear} ${ansi.orange}write${ansi.reset} ${ansi.dim}${shortPath(input.filePath)}${ansi.reset}`) 237 if (tool === 'grep') { 238 const matchCount = output ? output.split('\n').filter(l => l.trim()).length : 0 239 lines.push(`${gear} ${ansi.orange}grep${ansi.reset} ${ansi.dim}[pattern="${truncateStr(input.pattern, 30)}"]${ansi.reset}`) 240 if (!isRunning) lines.push(`${ansi.dim}→ ${matchCount} matches${ansi.reset}`) 244 if (tool === 'glob') { 245 const fileCount = output ? output.split('\n').filter(l => l.trim()).length : 0 246 lines.push(`${gear} ${ansi.orange}glob${ansi.reset} ${ansi.dim}[pattern=${input.pattern}]${ansi.reset}`) 247 if (!isRunning) lines.push(`${ansi.dim}→ ${fileCount} files${ansi.reset}`) 251 if (tool === 'task') { 252 lines.push(`${gear} ${ansi.orange}task${ansi.reset} ${ansi.dim}${truncateStr(input.description, 50)}${ansi.reset}`) 256 if (tool === 'browser') { 257 const params = formatToolParams(input, shortPath) 258 lines.push(`${gear} ${ansi.orange}browser${ansi.reset} ${ansi.dim}${params}${ansi.reset}`) 262 if (tool === 'todowrite') { 263 const todos = input.todos || [] 264 lines.push(`${gear} ${ansi.orange}todowrite${ansi.reset}`) 265 for (const t of todos) { 266 const icon = t.status === 'completed' ? `${ansi.green}✓${ansi.reset}` : t.status === 'in_progress' ? `${ansi.yellow}→${ansi.reset}` : `${ansi.dim}○${ansi.reset}` 267 lines.push(` ${icon} ${ansi.dim}${t.content}${ansi.reset}`) 272 const params = formatToolParams(input, shortPath) 273 lines.push(`${gear} ${ansi.orange}${tool}${ansi.reset}${params ? ` ${ansi.dim}${params}${ansi.reset}` : ''}`) 277export const partContainsStr = (p, str) => [ 278 p.text, p.state?.output, p.state?.metadata?.output, 279 p.state?.input?.command, p.state?.input?.description, p.state?.input?.prompt, p.state?.input?.content, 280].some(v => v?.includes(str)) 282export const getPartTime = (p) => p.time?.start || p.state?.time?.start || 0 284export const extractSeanceTranscript = (part) => { 285 if (part.tool !== 'task') return null 286 const input = part.state?.input || {} 287 if (!input.prompt?.includes(ghostPrompt)) return null 288 const output = part.state?.output || '' 289 const match = output.match(/<task_result>([\s\S]*?)<\/task_result>/) 290 return match ? match[1].trim() : null 293export const flattenSessionParts = (messages, partsByMsg) => { 295 for (const msg of messages) { 296 const msgParts = partsByMsg[msg.id] || [] 297 for (const p of msgParts) allParts.push({ ...p, role: msg.role, agent: msg.agent }) 299 allParts.sort((a, b) => (a.dbTime || 0) - (b.dbTime || 0))