🌳
pt0/peatsite/octranscripts/ocSessionRenderF.mjs
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) : '')
18export const ansi = {
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) => {
32 try {
33 const result = await codeToANSI(text, 'typescript', 'github-dark')
34 return result.replace(/\n$/, '')
35 } catch {
36 return text
37 }
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+)/)
48 const lines = []
49 let current = ''
50 for (const word of words) {
51 const testLine = current + word
52 if (stripAnsi(testLine).length > maxWidth && current) {
53 lines.push(current)
54 current = indent + word.trimStart()
55 } else {
56 current = testLine
57 }
58 }
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')
65 const lines = []
66 let oi = 0, ni = 0
67 while (oi < oldLines.length || ni < newLines.length) {
68 if (oi < oldLines.length && ni < newLines.length && oldLines[oi] === newLines[ni]) {
69 oi++; 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}`)
73 oi++
74 } else if (ni < newLines.length) {
75 const content = newLines[ni]
76 lines.push(`${ansi.diffAddBg}${ansi.diffAddText} + ${content}${ansi.reset}`)
77 ni++
78 } else {
79 oi++
80 }
81 }
82 return lines
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())
96 )
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))
105 })
106 return `${sep} ${cells.join(` ${sep} `)} ${sep}`
107 })
110export const mdToTerminal = async (text, maxWidth = 120) => {
111 let inCodeBlock = false, codeLang = '', /** @type {string[]} */ tableBuffer = []
112 const lines = text.split('\n')
113 const results = []
115 const flushTable = () => {
116 if (tableBuffer.length) { results.push(...renderTable(tableBuffer)); tableBuffer = [] }
117 }
119 for (const line of lines) {
120 if (line.startsWith('```')) {
121 flushTable()
122 if (!inCodeBlock) {
123 codeLang = line.slice(3).trim().toLowerCase()
124 inCodeBlock = true
125 } else {
126 inCodeBlock = false
127 codeLang = ''
128 }
129 results.push('')
130 continue
131 }
132 if (inCodeBlock) {
133 const highlighted = jsLangs.includes(codeLang) ? await highlightJs(line) : line
134 results.push(` ${highlighted}`)
135 continue
136 }
137 if (isTableRow(line) || isTableSep(line)) { tableBuffer.push(line); continue }
138 flushTable()
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 }
142 let processed = line
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))
148 }
149 flushTable()
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 || []
157 const lines = []
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}`)
164 lines.push('')
165 lines.push(`${ansi.white}${q.question}${ansi.reset}`)
166 lines.push('')
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}`)
172 }
173 lines.push('')
175 if (answer && answer.length > 0) {
176 const answerText = answer.join(', ')
177 lines.push(`${ansi.green}→ ${answerText}${ansi.reset}`)
178 lines.push('')
179 }
180 }
182 return lines
185const formatToolParams = (params, pathFn) => {
186 const parts = []
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}`)
192 }
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'
201 const lines = []
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))
210 return lines
211 }
213 if (tool === 'bash') {
214 const desc = input.description || truncateStr(input.command, 60)
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}`)
220 } else {
221 for (const l of bashOut.split('\n')) lines.push(` ${ansi.dim}${sanitize(l)}${ansi.reset}`)
222 }
223 }
224 return lines
225 }
227 if (tool === 'read') {
228 lines.push(`${gear} ${ansi.orange}read${ansi.reset} ${ansi.dim}${shortPath(input.filePath)}${ansi.reset}`)
229 return lines
230 }
232 if (tool === 'write') {
233 lines.push(`${gear} ${ansi.orange}write${ansi.reset} ${ansi.dim}${shortPath(input.filePath)}${ansi.reset}`)
234 return lines
235 }
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}`)
241 return lines
242 }
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}`)
248 return lines
249 }
251 if (tool === 'task') {
252 lines.push(`${gear} ${ansi.orange}task${ansi.reset} ${ansi.dim}${truncateStr(input.description, 50)}${ansi.reset}`)
253 return lines
254 }
256 if (tool === 'browser') {
257 const params = formatToolParams(input, shortPath)
258 lines.push(`${gear} ${ansi.orange}browser${ansi.reset} ${ansi.dim}${params}${ansi.reset}`)
259 return lines
260 }
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}`)
268 }
269 return lines
270 }
272 const params = formatToolParams(input, shortPath)
273 lines.push(`${gear} ${ansi.orange}${tool}${ansi.reset}${params ? ` ${ansi.dim}${params}${ansi.reset}` : ''}`)
274 return lines
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) => {
294 const allParts = []
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 })
298 }
299 allParts.sort((a, b) => (a.dbTime || 0) - (b.dbTime || 0))
300 return allParts