build-in-public-bot
Version:
AI-powered CLI bot for automating build-in-public tweets with code screenshots
618 lines • 25.3 kB
JavaScript
"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