Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 79 additions & 55 deletions src/loop/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type IssueRef,
type SemanticPrType,
} from '../automation/git.js';
import { drawBox, drawSeparator, getTerminalWidth } from '../ui/box.js';
import { ProgressRenderer } from '../ui/progress-renderer.js';
import { type Agent, type AgentRunOptions, runAgent } from './agents.js';
import { CircuitBreaker, type CircuitBreakerConfig } from './circuit-breaker.js';
Expand Down Expand Up @@ -385,44 +386,48 @@ export async function runLoop(options: LoopOptions): Promise<LoopResult> {
// Get initial task count for estimates
const initialTaskCount = parsePlanTasks(options.cwd);

// Show startup summary box
const startupLines: string[] = [];
startupLines.push(chalk.cyan.bold(' Ralph-Starter'));
startupLines.push(` Agent: ${chalk.white(options.agent.name)}`);
startupLines.push(` Max loops: ${chalk.white(String(maxIterations))}`);
if (validationCommands.length > 0) {
startupLines.push(
` Validation: ${chalk.white(validationCommands.map((c) => c.name).join(', '))}`
);
}
if (options.commit) {
startupLines.push(` Auto-commit: ${chalk.green('enabled')}`);
}
if (detectedSkills.length > 0) {
startupLines.push(` Skills: ${chalk.white(`${detectedSkills.length} detected`)}`);
}
if (rateLimiter) {
startupLines.push(` Rate limit: ${chalk.white(`${options.rateLimit}/hour`)}`);
}

console.log();
console.log(chalk.cyan.bold('Starting Ralph Wiggum Loop'));
console.log(chalk.dim(`Agent: ${options.agent.name}`));
console.log(drawBox(startupLines, { color: chalk.cyan }));

// Show task count and estimates if we have tasks
if (initialTaskCount.total > 0) {
console.log(
chalk.dim(
`Tasks: ${initialTaskCount.pending} pending, ${initialTaskCount.completed} completed`
` Tasks: ${initialTaskCount.pending} pending, ${initialTaskCount.completed} completed`
)
);

// Show estimate
const estimate = estimateLoop(initialTaskCount);
console.log();
console.log(chalk.yellow.bold('📋 Estimate:'));
for (const line of formatEstimateDetailed(estimate)) {
console.log(chalk.yellow(` ${line}`));
console.log(chalk.dim(` ${line}`));
}
} else {
console.log(
chalk.dim(`Task: ${options.task.slice(0, 60)}${options.task.length > 60 ? '...' : ''}`)
chalk.dim(` Task: ${options.task.slice(0, 60)}${options.task.length > 60 ? '...' : ''}`)
);
}

console.log();
if (validationCommands.length > 0) {
console.log(chalk.dim(`Validation: ${validationCommands.map((c) => c.name).join(', ')}`));
}
if (detectedSkills.length > 0) {
console.log(chalk.dim(`Skills: ${detectedSkills.map((s) => s.name).join(', ')}`));
}
if (options.completionPromise) {
console.log(chalk.dim(`Completion promise: ${options.completionPromise}`));
}
if (rateLimiter) {
console.log(chalk.dim(`Rate limit: ${options.rateLimit}/hour`));
}
console.log();

// Track completed tasks to show progress diff between iterations
Expand Down Expand Up @@ -527,20 +532,29 @@ export async function runLoop(options: LoopOptions): Promise<LoopResult> {
previousCompletedTasks = completedTasks;

// Show loop header with task info
console.log(chalk.cyan(`\n═══════════════════════════════════════════════════════════════`));
const headerLines: string[] = [];
if (currentTask && totalTasks > 0) {
const taskNum = completedTasks + 1;
const cleanName = cleanTaskName(currentTask.name);
const taskName = cleanName.length > 40 ? `${cleanName.slice(0, 37)}...` : cleanName;
console.log(chalk.cyan.bold(` Task ${taskNum}/${totalTasks} │ ${taskName}`));
const tw = getTerminalWidth();
const maxNameLen = Math.max(20, tw - 30);
const taskName =
cleanName.length > maxNameLen ? `${cleanName.slice(0, maxNameLen - 3)}...` : cleanName;
headerLines.push(` Task ${taskNum}/${totalTasks} │ ${chalk.white.bold(taskName)}`);
headerLines.push(chalk.dim(` ${options.agent.name} │ Iter ${i}/${maxIterations}`));
} else {
console.log(chalk.cyan.bold(` Loop ${i}/${maxIterations} │ Running ${options.agent.name}`));
headerLines.push(
` Loop ${i}/${maxIterations} │ ${chalk.white.bold(`Running ${options.agent.name}`)}`
);
}
console.log(chalk.cyan(`═══════════════════════════════════════════════════════════════\n`));
console.log();
console.log(drawBox(headerLines, { color: chalk.cyan }));
console.log();

// Create progress renderer for this iteration
const iterProgress = new ProgressRenderer();
iterProgress.start('Working...');
iterProgress.updateProgress(i, maxIterations, costTracker?.getStats()?.totalCost?.totalCost);

// Build iteration-specific task with current task context
let iterationTask: string;
Expand Down Expand Up @@ -724,23 +738,23 @@ Complete these subtasks, then mark them done in IMPLEMENTATION_PLAN.md by changi
const feedback = formatValidationFeedback(validationResults);
spinner.fail(chalk.red(`Loop ${i}: Validation failed`));

// Show which validations failed (UX 4: specific validation errors)
// Show compact validation summary
const failedSummaries: string[] = [];
for (const vr of validationResults) {
if (!vr.success) {
console.log(chalk.red(` ✗ ${vr.command}`));
if (vr.error) {
const errorLines = vr.error.split('\n').slice(0, 5);
for (const line of errorLines) {
console.log(chalk.dim(` ${line}`));
}
} else if (vr.output) {
const outputLines = vr.output.split('\n').slice(0, 5);
for (const line of outputLines) {
console.log(chalk.dim(` ${line}`));
}
}
const errorText = vr.error || vr.output || '';
const failCount = (errorText.match(/fail/gi) || []).length;
const errorCount = (errorText.match(/error/gi) || []).length;
const hint =
failCount > 0
? `${failCount} failures`
: errorCount > 0
? `${errorCount} errors`
: 'failed';
failedSummaries.push(`${vr.command} (${hint})`);
}
}
console.log(chalk.red(` ✗ ${failedSummaries.join(' │ ')}`));

// Record failure in circuit breaker
const errorMsg = validationResults
Expand Down Expand Up @@ -833,34 +847,44 @@ Complete these subtasks, then mark them done in IMPLEMENTATION_PLAN.md by changi
}

if (status === 'done') {
console.log();
console.log(
chalk.green.bold('═══════════════════════════════════════════════════════════════')
);
console.log(chalk.green.bold(' ✓ Task completed successfully!'));
console.log(
chalk.green.bold('═══════════════════════════════════════════════════════════════')
);

// Show completion reason (UX 3: clear completion signals)
const completionReason = getCompletionReason(result.output, completionOptions);
console.log(chalk.dim(` Reason: ${completionReason}`));
console.log(chalk.dim(` Iterations: ${i}`));
if (costTracker) {
const stats = costTracker.getStats();
console.log(chalk.dim(` Total cost: ${formatCost(stats.totalCost.totalCost)}`));
}
const duration = Date.now() - startTime;
const minutes = Math.floor(duration / 60000);
const seconds = Math.floor((duration % 60000) / 1000);
console.log(chalk.dim(` Time: ${minutes}m ${seconds}s`));

const completionLines: string[] = [];
completionLines.push(chalk.green.bold(' ✓ Task completed successfully'));
const details: string[] = [`Iterations: ${i}`, `Time: ${minutes}m ${seconds}s`];
if (costTracker) {
const stats = costTracker.getStats();
details.push(`Cost: ${formatCost(stats.totalCost.totalCost)}`);
}
completionLines.push(chalk.dim(` ${details.join(' │ ')}`));
completionLines.push(chalk.dim(` Reason: ${completionReason}`));

console.log();
console.log(drawBox(completionLines, { color: chalk.green }));
console.log();

finalIteration = i;
exitReason = 'completed';
break;
}

// Status separator between iterations
const elapsed = Date.now() - startTime;
const elapsedMin = Math.floor(elapsed / 60000);
const elapsedSec = Math.floor((elapsed % 60000) / 1000);
const costLabel = costTracker
? ` │ ${formatCost(costTracker.getStats().totalCost.totalCost)}`
: '';
const taskLabel = completedTasks > 0 ? ` │ Tasks: ${completedTasks}/${totalTasks}` : '';
console.log(
drawSeparator(
`Iter ${i}/${maxIterations}${taskLabel}${costLabel} │ ${elapsedMin}m ${elapsedSec}s`
)
);

// Small delay between iterations
await new Promise((resolve) => setTimeout(resolve, 1000));
}
Expand Down
68 changes: 68 additions & 0 deletions src/ui/box.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import chalk, { type ChalkInstance } from 'chalk';

/**
* Get terminal width with a sensible fallback
*/
export function getTerminalWidth(): number {
return process.stdout.columns || 80;
}

/**
* Draw a box with box-drawing characters around content lines
*/
export function drawBox(
lines: string[],
options: { color?: ChalkInstance; width?: number } = {}
): string {
const color = options.color || chalk.cyan;
const width = options.width || Math.min(60, getTerminalWidth() - 4);
const innerWidth = width - 2;

const output: string[] = [];
output.push(color(`┌${'─'.repeat(innerWidth)}┐`));

for (const line of lines) {
// Strip ANSI codes to measure real length
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape sequence detection requires control characters
const stripped = line.replace(/\u001b\[[0-9;]*m/g, '');
const padding = Math.max(0, innerWidth - stripped.length);
output.push(color('│') + line + ' '.repeat(padding) + color('│'));
}

output.push(color(`└${'─'.repeat(innerWidth)}┘`));
return output.join('\n');
}

/**
* Draw a horizontal separator with an optional centered label
*/
export function drawSeparator(label?: string, width?: number): string {
const w = width || Math.min(60, getTerminalWidth() - 4);

if (!label) {
return chalk.dim('─'.repeat(w));
}

const labelLen = label.length + 2; // space on each side
const sideLen = Math.max(1, Math.floor((w - labelLen) / 2));
const left = '─'.repeat(sideLen);
const right = '─'.repeat(w - sideLen - labelLen);
return chalk.dim(`${left} ${label} ${right}`);
}

/**
* Render a progress bar
*/
export function renderProgressBar(
current: number,
total: number,
options: { width?: number; label?: string } = {}
): string {
const barWidth = options.width || 20;
const ratio = Math.min(1, Math.max(0, current / total));
const filled = Math.round(ratio * barWidth);
const empty = barWidth - filled;
const bar = `${'█'.repeat(filled)}${'░'.repeat(empty)}`;
const info = options.label ? ` │ ${options.label}` : '';
return `${chalk.cyan(bar)} ${current}/${total}${chalk.dim(info)}`;
}
Comment on lines +56 to +68
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Division by zero produces an empty progress bar.

When total is 0, the ratio calculation results in NaN, causing both filled and empty to be NaN. While String.prototype.repeat handles this gracefully (returning empty strings), the progress bar will appear empty rather than showing a meaningful state.

🛡️ Proposed fix to handle zero total
 export function renderProgressBar(
   current: number,
   total: number,
   options: { width?: number; label?: string } = {}
 ): string {
   const barWidth = options.width || 20;
-  const ratio = Math.min(1, Math.max(0, current / total));
+  const ratio = total > 0 ? Math.min(1, Math.max(0, current / total)) : 0;
   const filled = Math.round(ratio * barWidth);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function renderProgressBar(
current: number,
total: number,
options: { width?: number; label?: string } = {}
): string {
const barWidth = options.width || 20;
const ratio = Math.min(1, Math.max(0, current / total));
const filled = Math.round(ratio * barWidth);
const empty = barWidth - filled;
const bar = `${'█'.repeat(filled)}${'░'.repeat(empty)}`;
const info = options.label ? ` │ ${options.label}` : '';
return `${chalk.cyan(bar)} ${current}/${total}${chalk.dim(info)}`;
}
export function renderProgressBar(
current: number,
total: number,
options: { width?: number; label?: string } = {}
): string {
const barWidth = options.width || 20;
const ratio = total > 0 ? Math.min(1, Math.max(0, current / total)) : 0;
const filled = Math.round(ratio * barWidth);
const empty = barWidth - filled;
const bar = `${'█'.repeat(filled)}${'░'.repeat(empty)}`;
const info = options.label ? ` │ ${options.label}` : '';
return `${chalk.cyan(bar)} ${current}/${total}${chalk.dim(info)}`;
}
🤖 Prompt for AI Agents
In `@src/ui/box.ts` around lines 56 - 68, In renderProgressBar, guard against
total === 0 before computing ratio so current/total doesn't produce NaN; for
example, if total === 0 set ratio = current > 0 ? 1 : 0 (then clamp with
Math.min/Math.max as before) so filled/empty are valid integers and the bar
shows full when progress exists or empty when none; update the ratio computation
near the top of renderProgressBar to handle this case.

52 changes: 46 additions & 6 deletions src/ui/progress-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@ export function formatElapsed(ms: number): string {
}

/**
* ProgressRenderer - Single-line progress display with shimmer effect
* ProgressRenderer - Single-line progress display with progress bar
*
* Features:
* - Animated spinner
* - Shimmer text effect
* - Readable text (subtle pulse)
* - Progress bar with iteration tracking
* - Elapsed time counter
* - Live cost display
* - Dynamic step updates
* - Sub-step indicator
*/
Expand All @@ -33,6 +35,9 @@ export class ProgressRenderer {
private lastRender = '';
private lastStepUpdate = 0;
private minStepInterval = 500; // ms - debounce step updates
private currentIteration = 0;
private maxIterations = 0;
private currentCost = 0;

/**
* Start the progress renderer
Expand All @@ -49,6 +54,17 @@ export class ProgressRenderer {
this.interval = setInterval(() => this.render(), 100);
}

/**
* Update iteration progress for the progress bar
*/
updateProgress(iteration: number, maxIterations: number, cost?: number): void {
this.currentIteration = iteration;
this.maxIterations = maxIterations;
if (cost !== undefined) {
this.currentCost = cost;
}
}

/**
* Update the main step text (debounced to prevent rapid switching)
*/
Expand All @@ -72,7 +88,7 @@ export class ProgressRenderer {
}

/**
* Render the progress line
* Render the progress line(s)
*/
private render(): void {
this.frame++;
Expand All @@ -82,17 +98,33 @@ export class ProgressRenderer {
const timeStr = formatElapsed(elapsed);
const shimmerText = applyShimmer(this.currentStep, this.frame);

// Main line
// Main line: spinner + step + time
let line = ` ${chalk.cyan(spinner)} ${shimmerText} ${chalk.dim(timeStr)}`;

// Sub-step on same line if present
if (this.subStep) {
line += chalk.dim(` - ${this.subStep}`);
}

// Progress bar line (if iteration info is available)
if (this.maxIterations > 0) {
const barWidth = 16;
const ratio = Math.min(1, this.currentIteration / this.maxIterations);
const filled = Math.round(ratio * barWidth);
const empty = barWidth - filled;
const bar = `${'█'.repeat(filled)}${'░'.repeat(empty)}`;
const costStr = this.currentCost > 0 ? ` │ $${this.currentCost.toFixed(2)}` : '';
line += `\n ${chalk.cyan(bar)} ${chalk.dim(`${this.currentIteration}/${this.maxIterations}${costStr}`)}`;
}

// Only update if changed (reduces flicker)
if (line !== this.lastRender) {
process.stdout.write(`\r\x1B[K${line}`);
// Clear current line(s) and write
const lineCount = this.maxIterations > 0 ? 2 : 1;
const clearUp = lineCount > 1 ? `\x1B[${lineCount - 1}A\r\x1B[J` : '\r\x1B[K';
// On first render, don't try to go up
const clear = this.lastRender ? clearUp : '\r\x1B[K';
process.stdout.write(`${clear}${line}`);
this.lastRender = line;
}
}
Expand All @@ -110,8 +142,16 @@ export class ProgressRenderer {
const timeStr = formatElapsed(elapsed);
const icon = success ? chalk.green('✓') : chalk.red('✗');
const message = finalMessage || this.currentStep;
const costStr = this.currentCost > 0 ? chalk.dim(` ~$${this.currentCost.toFixed(2)}`) : '';

// Clear progress bar line if present
if (this.maxIterations > 0 && this.lastRender) {
process.stdout.write('\x1B[1A\r\x1B[J');
} else {
process.stdout.write('\r\x1B[K');
}

process.stdout.write(`\r\x1B[K ${icon} ${message} ${chalk.dim(`(${timeStr})`)}\n`);
process.stdout.write(` ${icon} ${message} ${chalk.dim(`(${timeStr})`)}${costStr}\n`);
this.lastRender = '';
}

Expand Down
Loading
Loading