UNPKG

build-in-public-bot

Version:

AI-powered CLI bot for automating build-in-public tweets with code screenshots

618 lines 25.3 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.TerminalCapture = void 0; const canvas_1 = require("canvas"); const child_process_1 = require("child_process"); const ansi_parser_1 = require("./ansi-parser"); const theme_loader_1 = require("./theme-loader"); const logger_1 = require("../utils/logger"); class TerminalCapture { ansiParser; terminalData; selections = []; annotations = []; themeLoader; theme; constructor() { this.ansiParser = new ansi_parser_1.ANSIParser(); this.themeLoader = theme_loader_1.ThemeLoader.getInstance(); } async capture(options = {}) { const height = options.height || 30; try { const editorInfo = this.detectEditor(); if (editorInfo.isEditor) { logger_1.logger.debug(`Detected editor environment: ${editorInfo.editor}`); } const content = this.getTerminalContent(height); const lines = this.ansiParser.parse(content); const cols = parseInt((0, child_process_1.execSync)('tput cols', { encoding: 'utf-8' }).trim()); this.terminalData = { lines, width: cols, height: lines.length }; const themeName = options.theme || 'default'; this.theme = await this.themeLoader.getTheme(themeName); return this.terminalData; } catch (error) { logger_1.logger.error('Failed to capture terminal:', error); throw new Error('Terminal capture failed'); } } getTerminalContent(lines) { try { if (process.platform === 'darwin') { return this.captureMacOS(lines); } else if (process.platform === 'linux') { return this.captureLinux(lines); } else { return this.captureGeneric(lines); } } catch (error) { logger_1.logger.warn('Failed to capture terminal content, using fallback'); return this.captureGeneric(lines); } } detectEditor() { const vim = process.env.VIM; const vimruntime = process.env.VIMRUNTIME; const term = process.env.TERM; const termProgram = process.env.TERM_PROGRAM; if (vim || vimruntime) { return { isEditor: true, editor: 'vim' }; } if (term === 'screen' || term === 'tmux') { return { isEditor: true, editor: 'terminal-multiplexer' }; } if (termProgram) { const editorPrograms = ['nvim', 'vim', 'emacs', 'nano']; const foundEditor = editorPrograms.find(editor => termProgram.toLowerCase().includes(editor)); if (foundEditor) { return { isEditor: true, editor: foundEditor }; } } try { const processes = (0, child_process_1.execSync)('ps -o comm= -p $PPID', { encoding: 'utf-8' }).trim(); const editorProcesses = ['vim', 'nvim', 'emacs', 'nano', 'code']; const foundEditor = editorProcesses.find(editor => processes.includes(editor)); if (foundEditor) { return { isEditor: true, editor: foundEditor }; } } catch { } return { isEditor: false }; } captureMacOS(lines) { return this.captureGeneric(lines); } captureLinux(lines) { try { const vcsDevice = '/dev/vcsa'; const content = (0, child_process_1.execSync)(`tail -n ${lines} ${vcsDevice}`, { encoding: 'utf-8' }); return content; } catch { return this.captureGeneric(lines); } } captureGeneric(lines) { const testContent = [ '\x1b[32m$ \x1b[0mgit status', 'On branch \x1b[36mmaster\x1b[0m', 'Your branch is up to date with \x1b[32m\'origin/master\'\x1b[0m.', '', 'Changes not staged for commit:', ' (use "git add <file>..." to update what will be committed)', ' (use "git restore <file>..." to discard changes in working directory)', '\x1b[31m modified: src/cli.ts\x1b[0m', '\x1b[31m modified: package.json\x1b[0m', '', 'Untracked files:', ' (use "git add <file>..." to include in what will be committed)', '\x1b[31m src/services/terminal-capture.ts\x1b[0m', '', 'no changes added to commit (use "git add" and/or "git commit -a")', '\x1b[32m$ \x1b[0m' ]; return testContent.slice(-lines).join('\n'); } selectLines(ranges) { if (!this.terminalData) return; for (const range of ranges) { const startLine = range.start - 1; const endLine = range.end === -1 ? this.terminalData.lines.length - 1 : range.end - 1; this.selections.push({ type: 'line', start: { line: startLine, col: 0 }, end: { line: endLine, col: -1 }, style: 'highlight' }); } } highlightPattern(pattern, style = 'highlight') { if (!this.terminalData) return; const regex = new RegExp(pattern, 'gi'); this.terminalData.lines.forEach((line, lineIdx) => { let match; while ((match = regex.exec(line.plainText)) !== null) { this.selections.push({ type: 'pattern', start: { line: lineIdx, col: match.index }, end: { line: lineIdx, col: match.index + match[0].length }, style }); } }); } highlightRegex(regex, style = 'highlight') { if (!this.terminalData) return; this.terminalData.lines.forEach((line, lineIdx) => { const globalRegex = new RegExp(regex.source, regex.flags.includes('g') ? regex.flags : regex.flags + 'g'); let match; while ((match = globalRegex.exec(line.plainText)) !== null) { this.selections.push({ type: 'pattern', start: { line: lineIdx, col: match.index }, end: { line: lineIdx, col: match.index + match[0].length }, style }); } }); } selectLastCommand() { if (!this.terminalData) return; const prompts = this.findPrompts(); if (prompts.length === 0) return; const lastPrompt = prompts[prompts.length - 1]; this.selections.push({ type: 'command', start: { line: lastPrompt.line, col: 0 }, end: { line: this.terminalData.lines.length - 1, col: -1 }, style: 'command' }); } selectCommand(index) { if (!this.terminalData) return; const prompts = this.findPrompts(); if (prompts.length === 0) return; const targetIndex = index < 0 ? prompts.length + index : index; if (targetIndex < 0 || targetIndex >= prompts.length) return; const prompt = prompts[targetIndex]; const nextPrompt = prompts[targetIndex + 1]; this.selections.push({ type: 'command', start: { line: prompt.line, col: 0 }, end: nextPrompt ? { line: nextPrompt.line - 1, col: -1 } : { line: this.terminalData.lines.length - 1, col: -1 }, style: 'command' }); } selectErrors() { if (!this.terminalData) return; const errorPatterns = [ /error:/i, /failed/i, /exception/i, /traceback/i, /^\s*at\s+/, /ENOENT/, /EACCES/, /Error\s*:/ ]; this.terminalData.lines.forEach((line, lineIdx) => { for (const pattern of errorPatterns) { if (pattern.test(line.plainText)) { this.selections.push({ type: 'line', start: { line: lineIdx, col: 0 }, end: { line: lineIdx, col: -1 }, style: 'error' }); break; } } }); } selectDiff() { if (!this.terminalData) return; this.terminalData.lines.forEach((line, lineIdx) => { const text = line.plainText; if (text.startsWith('+') && !text.startsWith('+++')) { this.selections.push({ type: 'line', start: { line: lineIdx, col: 0 }, end: { line: lineIdx, col: -1 }, style: 'diff-add' }); } else if (text.startsWith('-') && !text.startsWith('---')) { this.selections.push({ type: 'line', start: { line: lineIdx, col: 0 }, end: { line: lineIdx, col: -1 }, style: 'diff-remove' }); } else if (text.startsWith('@@ ')) { this.selections.push({ type: 'line', start: { line: lineIdx, col: 0 }, end: { line: lineIdx, col: -1 }, style: 'diff-header' }); } }); } showCursor(line, col) { if (!this.terminalData) return; this.terminalData.cursorPosition = { line: line - 1, col: col - 1 }; } dimExcept(ranges) { if (!this.terminalData) return; const dimSelections = []; let lastEnd = 0; const sortedRanges = [...ranges].sort((a, b) => a.start - b.start); for (const range of sortedRanges) { const start = range.start - 1; if (start > lastEnd) { dimSelections.push({ type: 'line', start: { line: lastEnd, col: 0 }, end: { line: start - 1, col: -1 }, style: 'dim' }); } lastEnd = (range.end === -1 ? this.terminalData.lines.length : range.end); } if (lastEnd < this.terminalData.lines.length) { dimSelections.push({ type: 'line', start: { line: lastEnd, col: 0 }, end: { line: this.terminalData.lines.length - 1, col: -1 }, style: 'dim' }); } this.selections.push(...dimSelections); } addArrow(from, to, label) { this.annotations.push({ type: 'arrow', data: { from: { line: from.line - 1, col: from.col - 1 }, to: { line: to.line - 1, col: to.col - 1 }, label } }); } addBox(box) { this.annotations.push({ type: 'box', data: { startLine: box.startLine - 1, endLine: box.endLine - 1, startCol: box.startCol, endCol: box.endCol === -1 ? undefined : box.endCol } }); } addNote(line, text) { this.annotations.push({ type: 'note', data: { line: line - 1, text } }); } async render(_format = 'png') { if (!this.terminalData) { throw new Error('No terminal data to render'); } const fontSize = 14; const lineHeight = fontSize * 1.5; const charWidth = fontSize * 0.6; const innerPadding = 40; const outerPadding = 60; const windowControlsHeight = 50; const maxContentWidth = Math.round(this.terminalData.width * charWidth); const maxContentHeight = Math.round(this.terminalData.lines.length * lineHeight); const contentWidth = Math.min(maxContentWidth, 800); const contentHeight = Math.min(maxContentHeight, 600); const terminalWidth = Math.round(contentWidth + (innerPadding * 2)); const terminalHeight = Math.round(contentHeight + (innerPadding * 2) + windowControlsHeight); const totalWidth = Math.round(terminalWidth + (outerPadding * 2)); const totalHeight = Math.round(terminalHeight + (outerPadding * 2)); const canvas = (0, canvas_1.createCanvas)(totalWidth, totalHeight); const ctx = canvas.getContext('2d'); const gradient = ctx.createLinearGradient(0, 0, totalWidth, totalHeight); gradient.addColorStop(0, '#667eea'); gradient.addColorStop(1, '#764ba2'); ctx.fillStyle = gradient; ctx.fillRect(0, 0, totalWidth, totalHeight); const terminalCanvas = (0, canvas_1.createCanvas)(terminalWidth + 60, terminalHeight + 60); const terminalCtx = terminalCanvas.getContext('2d'); const shadowOffset = 30; const shadowX = shadowOffset + 5; const shadowY = shadowOffset + 8; const cornerRadius = 12; terminalCtx.fillStyle = 'rgba(0, 0, 0, 0.25)'; this.drawRoundedRect(terminalCtx, shadowX, shadowY, terminalWidth, terminalHeight, cornerRadius); terminalCtx.fill(); try { terminalCtx.filter = 'blur(8px)'; terminalCtx.fillStyle = 'rgba(0, 0, 0, 0.15)'; this.drawRoundedRect(terminalCtx, shadowX, shadowY, terminalWidth, terminalHeight, cornerRadius); terminalCtx.fill(); terminalCtx.filter = 'none'; } catch { } terminalCtx.fillStyle = this.theme.background; this.drawRoundedRect(terminalCtx, shadowOffset, shadowOffset, terminalWidth, terminalHeight, cornerRadius); terminalCtx.fill(); terminalCtx.save(); this.drawRoundedRect(terminalCtx, shadowOffset, shadowOffset, terminalWidth, terminalHeight, cornerRadius); terminalCtx.clip(); this.drawWindowControls(terminalCtx, terminalWidth, this.theme, shadowOffset); terminalCtx.restore(); terminalCtx.save(); const editorPadding = 20; const editorX = shadowOffset + editorPadding; const editorY = shadowOffset + windowControlsHeight + editorPadding; const editorWidth = terminalWidth - (editorPadding * 2); const editorHeight = terminalHeight - windowControlsHeight - (editorPadding * 2); terminalCtx.beginPath(); terminalCtx.rect(editorX, editorY, editorWidth, editorHeight); terminalCtx.clip(); terminalCtx.font = `${fontSize}px "SF Mono", Monaco, Consolas, monospace`; terminalCtx.textBaseline = 'top'; const contentStartX = editorX; const contentStartY = editorY; this.renderSelections(terminalCtx, contentStartX, contentStartY, lineHeight, charWidth); this.renderLines(terminalCtx, contentStartX, contentStartY, lineHeight, charWidth, fontSize); if (this.terminalData.cursorPosition) { this.renderCursor(terminalCtx, contentStartX, contentStartY, lineHeight, charWidth); } this.renderAnnotations(terminalCtx, contentStartX, contentStartY, lineHeight, charWidth); terminalCtx.restore(); ctx.drawImage(terminalCanvas, outerPadding - shadowOffset, outerPadding - shadowOffset); return canvas.toBuffer('image/png'); } renderSelections(ctx, startX, startY, lineHeight, charWidth) { if (!this.terminalData) return; for (const selection of this.selections) { const style = this.getSelectionStyle(selection.style || 'highlight'); ctx.fillStyle = style.background; const maxLine = this.terminalData.lines.length - 1; const clampedStartLine = Math.max(0, Math.min(selection.start.line, maxLine)); const clampedEndLine = Math.max(0, Math.min(selection.end.line, maxLine)); if (clampedStartLine === clampedEndLine) { const selStartX = startX + (selection.start.col * charWidth); const maxWidth = this.terminalData.width * charWidth; const selEndX = selection.end.col === -1 ? startX + maxWidth : Math.min(startX + maxWidth, startX + (selection.end.col * charWidth)); const y = startY + (clampedStartLine * lineHeight); if (clampedStartLine < this.terminalData.lines.length) { ctx.fillRect(selStartX, y, Math.max(0, selEndX - selStartX), lineHeight); } } else { for (let line = clampedStartLine; line <= clampedEndLine && line < this.terminalData.lines.length; line++) { const selStartX = line === clampedStartLine ? startX + (selection.start.col * charWidth) : startX; const maxWidth = this.terminalData.width * charWidth; const selEndX = line === clampedEndLine && selection.end.col !== -1 ? Math.min(startX + maxWidth, startX + (selection.end.col * charWidth)) : startX + maxWidth; const y = startY + (line * lineHeight); ctx.fillRect(selStartX, y, Math.max(0, selEndX - selStartX), lineHeight); } } } } renderLines(ctx, startX, startY, lineHeight, charWidth, fontSize) { if (!this.terminalData) return; this.terminalData.lines.forEach((line, lineIdx) => { let x = startX; const y = startY + (lineIdx * lineHeight) + (lineHeight - fontSize) / 2; for (const segment of line.segments) { const style = this.applyANSIStyle(segment.style); ctx.fillStyle = style.color; if (style.bold) { ctx.font = `bold ${fontSize}px "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace`; } else { ctx.font = `${fontSize}px "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace`; } ctx.fillText(segment.text, x, y); if (style.underline) { ctx.strokeStyle = style.color; ctx.beginPath(); ctx.moveTo(x, y + fontSize); ctx.lineTo(x + segment.text.length * charWidth, y + fontSize); ctx.stroke(); } x += segment.text.length * charWidth; } }); } renderCursor(ctx, startX, startY, lineHeight, charWidth) { if (!this.terminalData?.cursorPosition) return; const x = startX + (this.terminalData.cursorPosition.col * charWidth); const y = startY + (this.terminalData.cursorPosition.line * lineHeight); ctx.fillStyle = this.theme.text; ctx.fillRect(x, y, charWidth, lineHeight); } renderAnnotations(ctx, startX, startY, lineHeight, charWidth) { if (!this.terminalData) return; for (const annotation of this.annotations) { switch (annotation.type) { case 'arrow': this.renderArrow(ctx, annotation.data, startX, startY, lineHeight, charWidth); break; case 'box': this.renderBox(ctx, annotation.data, startX, startY, lineHeight, charWidth); break; case 'note': this.renderNote(ctx, annotation.data, startX, startY, lineHeight, charWidth); break; } } } renderArrow(ctx, data, startX, startY, lineHeight, charWidth) { const fromX = startX + (data.from.col * charWidth); const fromY = startY + (data.from.line * lineHeight) + (lineHeight / 2); const toX = startX + (data.to.col * charWidth); const toY = startY + (data.to.line * lineHeight) + (lineHeight / 2); ctx.strokeStyle = '#ff6b6b'; ctx.lineWidth = 2; ctx.setLineDash([]); ctx.beginPath(); ctx.moveTo(fromX, fromY); ctx.lineTo(toX, toY); ctx.stroke(); const angle = Math.atan2(toY - fromY, toX - fromX); const arrowLength = 10; const arrowAngle = Math.PI / 6; ctx.beginPath(); ctx.moveTo(toX, toY); ctx.lineTo(toX - arrowLength * Math.cos(angle - arrowAngle), toY - arrowLength * Math.sin(angle - arrowAngle)); ctx.moveTo(toX, toY); ctx.lineTo(toX - arrowLength * Math.cos(angle + arrowAngle), toY - arrowLength * Math.sin(angle + arrowAngle)); ctx.stroke(); if (data.label) { const labelX = (fromX + toX) / 2; const labelY = (fromY + toY) / 2 - 10; ctx.fillStyle = '#ff6b6b'; ctx.font = '12px "SF Mono", Monaco, Consolas, monospace'; ctx.fillText(data.label, labelX, labelY); } } renderBox(ctx, data, startX, startY, lineHeight, charWidth) { const x = startX + (data.startCol * charWidth); const y = startY + (data.startLine * lineHeight); const width = data.endCol ? (data.endCol - data.startCol) * charWidth : this.terminalData.width * charWidth; const height = (data.endLine - data.startLine + 1) * lineHeight; ctx.strokeStyle = '#4ecdc4'; ctx.lineWidth = 2; ctx.setLineDash([5, 5]); ctx.strokeRect(x, y, width, height); } renderNote(ctx, data, startX, startY, lineHeight, charWidth) { const x = startX + (this.terminalData.width * charWidth) + 20; const y = startY + (data.line * lineHeight); ctx.fillStyle = 'rgba(255, 235, 59, 0.9)'; ctx.fillRect(x, y - 5, 200, 25); ctx.fillStyle = '#333'; ctx.font = '12px "SF Mono", Monaco, Consolas, monospace'; ctx.fillText(data.text, x + 5, y + 10); } findPrompts() { if (!this.terminalData) return []; const prompts = []; const promptPatterns = [ /^[$#%>]\s/, /^[^@]+@[^:]+:[^$]+[$#]\s/, /^>>>\s/, /^In\s*\[\d+\]:\s/, /^julia>\s/ ]; this.terminalData.lines.forEach((line, idx) => { for (const pattern of promptPatterns) { if (pattern.test(line.plainText)) { prompts.push({ line: idx, col: 0 }); break; } } }); return prompts; } applyANSIStyle(style) { let color = style.foreground || this.theme.text; if (style.inverse) { color = style.background || this.theme.background; } if (style.dim) { color = color + '80'; } return { color, bold: style.bold || false, underline: style.underline || false }; } getSelectionStyle(styleName) { const styles = { highlight: { background: '#3b82f680' }, error: { background: '#ef444480' }, command: { background: '#6366f120' }, 'diff-add': { background: '#10b98180' }, 'diff-remove': { background: '#ef444480' }, 'diff-header': { background: '#6366f180' }, dim: { background: '#00000020' } }; return styles[styleName] || styles.highlight; } getAvailableThemes() { return this.themeLoader.getAllThemes(); } getEditorInfo() { return this.detectEditor(); } drawRoundedRect(ctx, x, y, width, height, radius) { ctx.beginPath(); ctx.moveTo(x + radius, y); ctx.lineTo(x + width - radius, y); ctx.quadraticCurveTo(x + width, y, x + width, y + radius); ctx.lineTo(x + width, y + height - radius); ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); ctx.lineTo(x + radius, y + height); ctx.quadraticCurveTo(x, y + height, x, y + height - radius); ctx.lineTo(x, y + radius); ctx.quadraticCurveTo(x, y, x + radius, y); ctx.closePath(); } drawWindowControls(ctx, width, theme, offsetX = 0) { ctx.fillStyle = theme.windowControls?.background || '#2d2d2d'; ctx.fillRect(offsetX, offsetX, width, 50); const buttonY = 20 + offsetX; const buttonRadius = 8; ctx.beginPath(); ctx.arc(25 + offsetX, buttonY, buttonRadius, 0, 2 * Math.PI); ctx.fillStyle = '#ff5f57'; ctx.fill(); ctx.beginPath(); ctx.arc(55 + offsetX, buttonY, buttonRadius, 0, 2 * Math.PI); ctx.fillStyle = '#ffbd2e'; ctx.fill(); ctx.beginPath(); ctx.arc(85 + offsetX, buttonY, buttonRadius, 0, 2 * Math.PI); ctx.fillStyle = '#28ca42'; ctx.fill(); } } exports.TerminalCapture = TerminalCapture; //# sourceMappingURL=terminal-capture.js.map