aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
338 lines (297 loc) • 9.68 kB
JavaScript
/**
* Panel Renderer Module
*
* Story 11.6: Projeto Bob - Painel de Observabilidade CLI
*
* Handles rendering of the observability panel in the terminal
* using box drawing characters and ANSI colors via chalk.
*
* @module core/ui/panel-renderer
* @version 1.0.0
*/
;
const chalk = require('chalk');
/**
* Box drawing characters (Unicode)
*/
const BOX = {
topLeft: '┌',
topRight: '┐',
bottomLeft: '└',
bottomRight: '┘',
horizontal: '─',
vertical: '│',
teeRight: '├',
teeLeft: '┤',
};
/**
* Status indicators
*/
const STATUS = {
completed: chalk.green('✓'),
current: chalk.yellow('●'),
pending: chalk.gray('○'),
error: chalk.red('✗'),
bullet: chalk.gray('•'),
};
/**
* Panel Renderer class
*/
class PanelRenderer {
/**
* Creates a new PanelRenderer instance
* @param {Object} options - Renderer options
*/
constructor(options = {}) {
this.options = {
width: 60,
...options,
};
}
/**
* Creates a horizontal line
* @param {number} width - Line width
* @returns {string} Horizontal line
*/
horizontalLine(width = this.options.width) {
return BOX.horizontal.repeat(width - 2);
}
/**
* Creates a top border
* @param {number} width - Border width
* @returns {string} Top border line
*/
topBorder(width = this.options.width) {
return chalk.cyan(`${BOX.topLeft}${this.horizontalLine(width)}${BOX.topRight}`);
}
/**
* Creates a bottom border
* @param {number} width - Border width
* @returns {string} Bottom border line
*/
bottomBorder(width = this.options.width) {
return chalk.cyan(`${BOX.bottomLeft}${this.horizontalLine(width)}${BOX.bottomRight}`);
}
/**
* Creates a separator line
* @param {number} width - Line width
* @returns {string} Separator line
*/
separator(width = this.options.width) {
return chalk.cyan(`${BOX.teeRight}${this.horizontalLine(width)}${BOX.teeLeft}`);
}
/**
* Creates a content line with borders
* @param {string} content - Line content
* @param {number} width - Total line width
* @returns {string} Bordered content line
*/
contentLine(content, width = this.options.width) {
const stripped = this.stripAnsi(content);
const padding = width - stripped.length - 4;
const paddedContent = content + ' '.repeat(Math.max(0, padding));
return `${chalk.cyan(BOX.vertical)} ${paddedContent} ${chalk.cyan(BOX.vertical)}`;
}
/**
* Strips ANSI codes from string for length calculation
* @param {string} str - String with potential ANSI codes
* @returns {string} String without ANSI codes
*/
stripAnsi(str) {
// eslint-disable-next-line no-control-regex
return str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '');
}
/**
* Formats elapsed time for display
* @param {Object} state - Panel state
* @returns {Object} Formatted times
*/
formatElapsedTime(state) {
const now = Date.now();
const formatDuration = (ms) => {
if (!ms || ms < 0) return '--';
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}h${minutes % 60}m`;
}
if (minutes > 0) {
return `${minutes}m${seconds % 60}s`;
}
return `${seconds}s`;
};
return {
story: state.elapsed.story_start
? formatDuration(now - state.elapsed.story_start)
: '--',
session: state.elapsed.session_start
? formatDuration(now - state.elapsed.session_start)
: '--',
};
}
/**
* Renders the pipeline progress
* @param {Object} pipeline - Pipeline state
* @returns {string} Formatted pipeline string
*/
renderPipeline(pipeline) {
const parts = pipeline.stages.map((stage) => {
const isCompleted = pipeline.completed_stages.includes(stage);
const isCurrent = pipeline.current_stage === stage;
if (stage === 'Story') {
// Show story progress
const progress = pipeline.story_progress || '0/0';
if (isCurrent) {
return chalk.yellow(`[${progress}]`);
}
if (isCompleted) {
return chalk.green(`[${progress} ${STATUS.completed}]`);
}
return chalk.gray(`[${progress}]`);
}
if (isCompleted) {
return chalk.green(`[${stage} ${STATUS.completed}]`);
}
if (isCurrent) {
return chalk.yellow(`[${stage} ${STATUS.current}]`);
}
return chalk.gray(`[${stage}]`);
});
return parts.join(chalk.gray(' → '));
}
/**
* Renders the minimal mode panel
* @param {Object} state - Panel state
* @returns {string} Rendered panel
*/
renderMinimal(state) {
const lines = [];
const w = this.options.width;
const elapsed = this.formatElapsedTime(state);
// Header
lines.push(this.topBorder(w));
lines.push(this.contentLine(chalk.bold.cyan('🔧 Bob Status'), w));
lines.push(this.separator(w));
// Pipeline
const pipelineStr = `Pipeline: ${this.renderPipeline(state.pipeline)}`;
lines.push(this.contentLine(pipelineStr, w));
// Current Agent
const agentId = state.current_agent.id || '--';
const agentTask = state.current_agent.task || 'idle';
lines.push(this.contentLine(`Current: ${chalk.yellow(`[${agentId}]`)} ${agentTask}`, w));
// Active Terminals
const termCount = state.active_terminals.count;
const termAgents = state.active_terminals.list
.map((t) => t.agent)
.slice(0, 3)
.join(', ');
const termStr = termCount > 0
? `${termCount} active (${termAgents})`
: chalk.gray('none');
lines.push(this.contentLine(`Terminals: ${termStr}`, w));
// Elapsed Time
lines.push(this.contentLine(
`Elapsed: ${chalk.cyan(elapsed.story)} (story) | ${chalk.cyan(elapsed.session)} (session)`,
w,
));
// Errors (if any)
if (state.errors.length > 0) {
lines.push(this.separator(w));
const errorMsg = state.errors[state.errors.length - 1].message;
lines.push(this.contentLine(`${STATUS.error} ${chalk.red(errorMsg.slice(0, 50))}`, w));
}
// Footer
lines.push(this.bottomBorder(w));
return lines.join('\n') + '\n';
}
/**
* Renders the detailed mode panel
* @param {Object} state - Panel state
* @returns {string} Rendered panel
*/
renderDetailed(state) {
const lines = [];
const w = this.options.width;
const elapsed = this.formatElapsedTime(state);
// Header
lines.push(this.topBorder(w));
lines.push(this.contentLine(chalk.bold.cyan('🔧 Bob Status — Modo Educativo'), w));
lines.push(this.separator(w));
// Pipeline section
lines.push(this.contentLine(chalk.bold('Pipeline:'), w));
lines.push(this.contentLine(` ${this.renderPipeline(state.pipeline)}`, w));
lines.push(this.contentLine('', w));
// Current Agent section
lines.push(this.contentLine(chalk.bold('Current Agent:'), w));
const agentId = state.current_agent.id || '--';
const agentName = state.current_agent.name || '';
const agentTask = state.current_agent.task || 'idle';
lines.push(this.contentLine(
` ${chalk.yellow(agentId)} ${agentName ? `(${agentName})` : ''} ${agentTask}`,
w,
));
if (state.current_agent.reason) {
lines.push(this.contentLine(
` ${chalk.gray(`Por que ${agentId}?`)} ${state.current_agent.reason}`,
w,
));
}
lines.push(this.contentLine('', w));
// Active Terminals section
lines.push(this.contentLine(chalk.bold('Active Terminals:'), w));
if (state.active_terminals.list.length > 0) {
state.active_terminals.list.slice(0, 4).forEach((terminal) => {
const pidStr = terminal.pid ? `(PID ${terminal.pid})` : '';
lines.push(this.contentLine(
` ${STATUS.bullet} ${chalk.yellow(terminal.agent.padEnd(12))} ${chalk.gray(pidStr)} — ${terminal.task}`,
w,
));
});
} else {
lines.push(this.contentLine(` ${chalk.gray('No active terminals')}`, w));
}
lines.push(this.contentLine('', w));
// Elapsed Time section
lines.push(this.contentLine(chalk.bold('Elapsed:'), w));
lines.push(this.contentLine(
` Story: ${chalk.cyan(elapsed.story)} | Session: ${chalk.cyan(elapsed.session)}`,
w,
));
lines.push(this.contentLine('', w));
// Trade-offs section (if any)
if (state.tradeoffs.length > 0) {
lines.push(this.contentLine(chalk.bold('Trade-offs considerados:'), w));
state.tradeoffs.slice(-3).forEach((tradeoff) => {
lines.push(this.contentLine(
` ${STATUS.bullet} ${tradeoff.choice}: ${chalk.green(tradeoff.selected)} (${tradeoff.reason})`,
w,
));
});
lines.push(this.contentLine('', w));
}
// Next Steps section (if any)
if (state.next_steps.length > 0) {
lines.push(this.contentLine(chalk.bold('Next Steps:'), w));
state.next_steps.slice(0, 3).forEach((step, i) => {
lines.push(this.contentLine(` ${i + 1}. ${step}`, w));
});
}
// Errors (if any)
if (state.errors.length > 0) {
lines.push(this.separator(w));
lines.push(this.contentLine(chalk.bold.red('Errors:'), w));
state.errors.slice(-2).forEach((error) => {
lines.push(this.contentLine(` ${STATUS.error} ${chalk.red(error.message.slice(0, 45))}`, w));
});
}
// Footer
lines.push(this.bottomBorder(w));
return lines.join('\n') + '\n';
}
}
module.exports = {
PanelRenderer,
BOX,
STATUS,
};