feat(ralph): enhance Ralph Wiggum loop visualization

Add polished, animated Ralph Wiggum Loop panel with:
- Circular progress ring showing task completion percentage
- Task cards with status indicators (pending/in-progress/completed)
- Animated status badge with pulsing indicator when active
- Completion celebration animation with animated checkmark
- Smart time formatting ("1h 23m" instead of "1.38 hours")

Enhanced detection patterns based on official Ralph plugin:
- /ralph-loop command detection
- Iteration patterns: "Iteration 5/50", "[5/50]"
- Max iterations from YAML: "max-iterations: 50"
- TodoWrite tool output detection

Adds maxIterations field to InnerLoopState for tracking loop limits.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-19 17:09:56 +01:00
co-authored by Claude Opus 4.5
parent 1ffefa41aa
commit 684ccc2b07
6 changed files with 859 additions and 192 deletions
+80 -3
View File
@@ -29,6 +29,19 @@ const LOOP_START_PATTERN = /Loop started at|Starting.*loop|Ralph loop started|<p
const ELAPSED_TIME_PATTERN = /Elapsed:\s*(\d+(?:\.\d+)?)\s*hours?/i;
const CYCLE_PATTERN = /cycle\s*#?(\d+)|respawn cycle #(\d+)/i;
// New patterns for improved Ralph detection (based on official Ralph Wiggum plugin)
// Iteration patterns: "Iteration 5/50", "[5/50]", "iteration #5"
const ITERATION_PATTERN = /(?:iteration|iter\.?)\s*#?(\d+)(?:\s*[\/of]\s*(\d+))?|\[(\d+)\/(\d+)\]/i;
// Ralph loop start: "/ralph-loop" command or "Starting Ralph loop"
const RALPH_START_PATTERN = /\/ralph-loop|starting ralph(?:\s+wiggum)?\s+loop|ralph loop (?:started|beginning)/i;
// Max iterations: "max-iterations 50" or "maxIterations: 50" or "max_iterations=50"
const MAX_ITERATIONS_PATTERN = /max[_-]?iterations?\s*[=:]\s*(\d+)/i;
// TodoWrite tool output - detect the tool being used
const TODOWRITE_PATTERN = /TodoWrite|todo(?:s)?\s*(?:updated|written|saved)|Todos have been modified/i;
// ANSI escape code removal for cleaner parsing
const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*m/g;
@@ -143,7 +156,20 @@ export class InnerLoopTracker extends EventEmitter {
* Detect loop start and status indicators
*/
private detectLoopStatus(line: string): void {
// Check for loop start
// Check for Ralph loop start command (/ralph-loop)
if (RALPH_START_PATTERN.test(line)) {
if (!this._loopState.active) {
this._loopState.active = true;
this._loopState.startedAt = Date.now();
this._loopState.cycleCount = 0;
this._loopState.maxIterations = null;
this._loopState.elapsedHours = null;
this._loopState.lastActivity = Date.now();
this.emit('loopUpdate', this.loopState);
}
}
// Check for generic loop start
if (LOOP_START_PATTERN.test(line)) {
// Check if this is a promise match (loop ending) vs loop start
if (!PROMISE_PATTERN.test(line)) {
@@ -152,6 +178,7 @@ export class InnerLoopTracker extends EventEmitter {
this._loopState.active = true;
this._loopState.startedAt = Date.now();
this._loopState.cycleCount = 0;
this._loopState.maxIterations = null;
this._loopState.elapsedHours = null;
this._loopState.lastActivity = Date.now();
this.emit('loopUpdate', this.loopState);
@@ -159,6 +186,40 @@ export class InnerLoopTracker extends EventEmitter {
}
}
// Check for max iterations setting
const maxIterMatch = line.match(MAX_ITERATIONS_PATTERN);
if (maxIterMatch) {
const maxIter = parseInt(maxIterMatch[1]);
if (!isNaN(maxIter) && maxIter > 0) {
this._loopState.maxIterations = maxIter;
this._loopState.lastActivity = Date.now();
this.emit('loopUpdate', this.loopState);
}
}
// Check for iteration patterns: "Iteration 5/50", "[5/50]"
const iterMatch = line.match(ITERATION_PATTERN);
if (iterMatch) {
// Pattern captures: group 1&2 for "Iteration X/Y", group 3&4 for "[X/Y]"
const currentIter = parseInt(iterMatch[1] || iterMatch[3]);
const maxIter = iterMatch[2] || iterMatch[4] ? parseInt(iterMatch[2] || iterMatch[4]) : null;
if (!isNaN(currentIter)) {
// If not already active, start the loop
if (!this._loopState.active) {
this._loopState.active = true;
this._loopState.startedAt = Date.now();
}
this._loopState.cycleCount = currentIter;
if (maxIter !== null && !isNaN(maxIter)) {
this._loopState.maxIterations = maxIter;
}
this._loopState.lastActivity = Date.now();
this.emit('loopUpdate', this.loopState);
}
}
// Check for elapsed time
const elapsedMatch = line.match(ELAPSED_TIME_PATTERN);
if (elapsedMatch) {
@@ -167,7 +228,7 @@ export class InnerLoopTracker extends EventEmitter {
this.emit('loopUpdate', this.loopState);
}
// Check for cycle count
// Check for cycle count (legacy pattern)
const cycleMatch = line.match(CYCLE_PATTERN);
if (cycleMatch) {
const cycleNum = parseInt(cycleMatch[1] || cycleMatch[2]);
@@ -177,6 +238,12 @@ export class InnerLoopTracker extends EventEmitter {
this.emit('loopUpdate', this.loopState);
}
}
// Check for TodoWrite tool usage - indicates active task tracking
if (TODOWRITE_PATTERN.test(line)) {
this._loopState.lastActivity = Date.now();
// Don't emit update just for activity, let todo detection handle it
}
}
/**
@@ -321,10 +388,11 @@ export class InnerLoopTracker extends EventEmitter {
/**
* Mark the loop as started (can be called externally)
*/
startLoop(completionPhrase?: string): void {
startLoop(completionPhrase?: string, maxIterations?: number): void {
this._loopState.active = true;
this._loopState.startedAt = Date.now();
this._loopState.cycleCount = 0;
this._loopState.maxIterations = maxIterations ?? null;
this._loopState.elapsedHours = null;
this._loopState.lastActivity = Date.now();
if (completionPhrase) {
@@ -333,6 +401,15 @@ export class InnerLoopTracker extends EventEmitter {
this.emit('loopUpdate', this.loopState);
}
/**
* Update max iterations (can be called externally)
*/
setMaxIterations(maxIterations: number | null): void {
this._loopState.maxIterations = maxIterations;
this._loopState.lastActivity = Date.now();
this.emit('loopUpdate', this.loopState);
}
/**
* Mark the loop as stopped
*/
+2
View File
@@ -255,6 +255,7 @@ export interface InnerLoopState {
completionPhrase: string | null;
startedAt: number | null;
cycleCount: number;
maxIterations: number | null;
lastActivity: number;
elapsedHours: number | null;
}
@@ -283,6 +284,7 @@ export function createInitialInnerLoopState(): InnerLoopState {
completionPhrase: null,
startedAt: null,
cycleCount: 0,
maxIterations: null,
lastActivity: Date.now(),
elapsedHours: null,
};
+207 -83
View File
@@ -505,6 +505,10 @@ class ClaudemanApp {
existing.loop.active = false;
this.updateInnerState(data.sessionId, existing);
}
// Show celebration animation if this is the active session
if (data.sessionId === this.activeSessionId) {
this.showRalphCelebration(data.phrase);
}
});
}
@@ -1791,7 +1795,7 @@ class ClaudemanApp {
return result;
}
// ========== Inner State (Ralph Loop & Todo List) ==========
// ========== Enhanced Ralph Wiggum Loop Panel ==========
updateInnerState(sessionId, updates) {
const existing = this.innerStates.get(sessionId) || { loop: null, todos: [] };
@@ -1811,10 +1815,9 @@ class ClaudemanApp {
renderInnerStatePanel() {
const panel = this.$('innerStatePanel');
const content = this.$('innerStateContent');
const toggle = this.$('innerStateToggle');
const toggle = this.$('ralphToggle');
if (!panel) return; // Panel not in DOM yet
if (!panel) return;
const state = this.innerStates.get(this.activeSessionId);
@@ -1829,108 +1832,229 @@ class ClaudemanApp {
panel.style.display = '';
// Calculate completion percentage
const todos = state?.todos || [];
const completed = todos.filter(t => t.status === 'completed').length;
const total = todos.length;
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
// Update progress rings
this.updateRalphRing(percent);
// Update status badge
this.updateRalphStatus(state?.loop);
// Update stats
this.updateRalphStats(state?.loop, completed, total);
// Update phrase preview
const phrasePreview = this.$('ralphPhrasePreview');
if (phrasePreview && state?.loop?.completionPhrase) {
phrasePreview.innerHTML = `<code>${this.escapeHtml(state.loop.completionPhrase)}</code>`;
} else if (phrasePreview) {
phrasePreview.textContent = '';
}
// Handle collapsed/expanded state
if (this.innerStatePanelCollapsed) {
panel.classList.add('collapsed');
toggle.innerHTML = '&#x25B6;'; // Right arrow
content.innerHTML = this.renderInnerStateSummary(state);
if (toggle) toggle.innerHTML = '&#x25B6;';
} else {
panel.classList.remove('collapsed');
toggle.innerHTML = '&#x25BC;'; // Down arrow
content.innerHTML = this.renderInnerStateExpanded(state);
if (toggle) toggle.innerHTML = '&#x25BC;';
// Update expanded view content
this.updateRalphExpandedView(state);
}
}
renderInnerStateSummary(state) {
const parts = [];
// Loop status
if (state?.loop) {
const loop = state.loop;
if (loop.active) {
let loopText = '🔄 Loop';
if (loop.completionPhrase) {
loopText += `: "${this.escapeHtml(loop.completionPhrase)}"`;
}
if (loop.elapsedHours !== null) {
loopText += ` (${loop.elapsedHours.toFixed(1)}h)`;
}
parts.push(`<span class="inner-loop-status active">${loopText}</span>`);
} else if (loop.completionPhrase) {
parts.push(`<span class="inner-loop-status completed">✓ ${this.escapeHtml(loop.completionPhrase)}</span>`);
}
updateRalphRing(percent) {
// Mini ring (in summary)
const miniProgress = this.$('ralphRingMiniProgress');
const miniText = this.$('ralphRingMiniText');
if (miniProgress) {
// Circumference = 2 * PI * r = 2 * PI * 15.9 ≈ 100
const offset = 100 - percent;
miniProgress.style.strokeDashoffset = offset;
}
if (miniText) {
miniText.textContent = `${percent}%`;
}
// Todo summary
if (state?.todos?.length > 0) {
const completed = state.todos.filter(t => t.status === 'completed').length;
const total = state.todos.length;
const inProgress = state.todos.filter(t => t.status === 'in_progress').length;
let todoText = `Tasks: ${completed}/${total}`;
if (inProgress > 0) {
todoText += ` (${inProgress} in progress)`;
}
parts.push(`<span class="inner-todo-summary">${todoText}</span>`);
// Large ring (in expanded view)
const largeProgress = this.$('ralphRingProgress');
const largePercent = this.$('ralphRingPercent');
if (largeProgress) {
// Circumference = 2 * PI * r = 2 * PI * 42 ≈ 264
const offset = 264 - (264 * percent / 100);
largeProgress.style.strokeDashoffset = offset;
}
if (largePercent) {
largePercent.textContent = `${percent}%`;
}
return parts.join(' | ');
}
renderInnerStateExpanded(state) {
let html = '';
updateRalphStatus(loop) {
const badge = this.$('ralphStatusBadge');
const statusText = badge?.querySelector('.ralph-status-text');
if (!badge || !statusText) return;
// Loop details
if (state?.loop) {
const loop = state.loop;
html += '<div class="inner-loop-details">';
if (loop.active) {
html += `<div class="inner-loop-active">🔄 <strong>Loop Active</strong>`;
if (loop.completionPhrase) {
html += ` - waiting for: <code>${this.escapeHtml(loop.completionPhrase)}</code>`;
}
html += '</div>';
if (loop.elapsedHours !== null) {
html += `<div class="inner-loop-elapsed">Elapsed: ${loop.elapsedHours.toFixed(2)} hours</div>`;
}
if (loop.cycleCount > 0) {
html += `<div class="inner-loop-cycles">Cycle: #${loop.cycleCount}</div>`;
}
} else if (loop.completionPhrase) {
html += `<div class="inner-loop-completed">✓ Completed: <code>${this.escapeHtml(loop.completionPhrase)}</code></div>`;
}
html += '</div>';
badge.classList.remove('active', 'completed');
if (loop?.active) {
badge.classList.add('active');
statusText.textContent = 'Running';
} else if (loop?.completionPhrase && !loop?.active) {
badge.classList.add('completed');
statusText.textContent = 'Complete';
} else {
statusText.textContent = 'Idle';
}
// Todo list
if (state?.todos?.length > 0) {
const completed = state.todos.filter(t => t.status === 'completed').length;
const total = state.todos.length;
html += '<div class="inner-todo-list">';
html += `<div class="inner-todo-header">Tasks (${completed}/${total} complete)</div>`;
html += '<ul class="inner-todo-items">';
for (const todo of state.todos) {
const icon = this.getTodoIcon(todo.status);
const statusClass = `todo-${todo.status.replace('_', '-')}`;
html += `<li class="${statusClass}">${icon} ${this.escapeHtml(todo.content)}</li>`;
}
html += '</ul>';
html += '</div>';
}
return html || '<div class="inner-state-empty">No inner state data</div>';
}
getTodoIcon(status) {
updateRalphStats(loop, completed, total) {
// Time stat
const timeEl = this.$('ralphStatTime');
if (timeEl) {
if (loop?.elapsedHours !== null && loop?.elapsedHours !== undefined) {
timeEl.textContent = this.formatRalphTime(loop.elapsedHours);
} else if (loop?.startedAt) {
const hours = (Date.now() - loop.startedAt) / (1000 * 60 * 60);
timeEl.textContent = this.formatRalphTime(hours);
} else {
timeEl.textContent = '0m';
}
}
// Cycles stat
const cyclesEl = this.$('ralphStatCycles');
if (cyclesEl) {
if (loop?.maxIterations) {
cyclesEl.textContent = `${loop.cycleCount || 0}/${loop.maxIterations}`;
} else {
cyclesEl.textContent = String(loop?.cycleCount || 0);
}
}
// Tasks stat
const tasksEl = this.$('ralphStatTasks');
if (tasksEl) {
tasksEl.textContent = `${completed}/${total}`;
}
}
formatRalphTime(hours) {
if (hours < 0.0167) return '0m'; // < 1 minute
if (hours < 1) {
const minutes = Math.round(hours * 60);
return `${minutes}m`;
}
const h = Math.floor(hours);
const m = Math.round((hours - h) * 60);
if (m === 0) return `${h}h`;
return `${h}h ${m}m`;
}
updateRalphExpandedView(state) {
// Update phrase
const phraseEl = this.$('ralphPhrase');
if (phraseEl) {
phraseEl.textContent = state?.loop?.completionPhrase || '--';
}
// Update elapsed
const elapsedEl = this.$('ralphElapsed');
if (elapsedEl) {
if (state?.loop?.elapsedHours !== null && state?.loop?.elapsedHours !== undefined) {
elapsedEl.textContent = this.formatRalphTime(state.loop.elapsedHours);
} else if (state?.loop?.startedAt) {
const hours = (Date.now() - state.loop.startedAt) / (1000 * 60 * 60);
elapsedEl.textContent = this.formatRalphTime(hours);
} else {
elapsedEl.textContent = '0m';
}
}
// Update iterations
const iterationsEl = this.$('ralphIterations');
if (iterationsEl) {
if (state?.loop?.maxIterations) {
iterationsEl.textContent = `${state.loop.cycleCount || 0} / ${state.loop.maxIterations}`;
} else {
iterationsEl.textContent = String(state?.loop?.cycleCount || 0);
}
}
// Update tasks count
const todos = state?.todos || [];
const completed = todos.filter(t => t.status === 'completed').length;
const tasksCountEl = this.$('ralphTasksCount');
if (tasksCountEl) {
tasksCountEl.textContent = `${completed}/${todos.length}`;
}
// Render task cards
this.renderRalphTasks(todos);
}
renderRalphTasks(todos) {
const grid = this.$('ralphTasksGrid');
if (!grid) return;
if (todos.length === 0) {
grid.innerHTML = '<div class="inner-state-empty">No tasks detected</div>';
return;
}
// Sort: in_progress first, then pending, then completed
const sorted = [...todos].sort((a, b) => {
const order = { in_progress: 0, pending: 1, completed: 2 };
return (order[a.status] || 1) - (order[b.status] || 1);
});
grid.innerHTML = sorted.map(todo => {
const statusClass = `task-${todo.status.replace('_', '-')}`;
const icon = this.getRalphTaskIcon(todo.status);
return `
<div class="ralph-task-card ${statusClass}">
<span class="ralph-task-icon">${icon}</span>
<span class="ralph-task-content">${this.escapeHtml(todo.content)}</span>
</div>
`;
}).join('');
}
getRalphTaskIcon(status) {
switch (status) {
case 'completed': return '✓';
case 'in_progress': return '◐';
case 'pending':
default: return '☐';
default: return '○';
}
}
showRalphCelebration(phrase) {
const celebration = this.$('ralphCelebration');
const text = this.$('ralphCelebrationText');
if (!celebration) return;
if (text) {
text.textContent = phrase ? `${phrase}` : 'Complete!';
}
celebration.classList.add('show');
// Auto-hide after 3 seconds
setTimeout(() => {
celebration.classList.remove('show');
}, 3000);
}
// Legacy method for backwards compatibility
getTodoIcon(status) {
return this.getRalphTaskIcon(status);
}
// ========== Screen Sessions (in Monitor Panel) ==========
async loadScreens() {
+85 -6
View File
@@ -72,13 +72,92 @@
<button class="btn-icon-only" onclick="app.stopRespawn()" title="Stop Respawn">&#x25A0;</button>
</div>
<!-- Inner State Panel (Ralph Loop + Todo List detection) -->
<div class="inner-state-panel collapsed" id="innerStatePanel" style="display: none;">
<div class="inner-state-header" onclick="app.toggleInnerStatePanel()">
<span class="inner-state-toggle" id="innerStateToggle">&#x25B6;</span>
<span class="inner-state-title">Inner State</span>
<!-- Enhanced Ralph Wiggum Loop Panel -->
<div class="ralph-panel collapsed" id="innerStatePanel" style="display: none;">
<!-- Collapsed Summary Bar -->
<div class="ralph-summary" onclick="app.toggleInnerStatePanel()">
<div class="ralph-ring-mini" id="ralphRingMini">
<svg viewBox="0 0 36 36" class="ralph-ring-svg">
<circle class="ralph-ring-bg" cx="18" cy="18" r="15.9" />
<circle class="ralph-ring-progress" cx="18" cy="18" r="15.9" id="ralphRingMiniProgress" />
</svg>
<span class="ralph-ring-text" id="ralphRingMiniText">0%</span>
</div>
<div class="ralph-summary-info">
<span class="ralph-status-badge" id="ralphStatusBadge">
<span class="ralph-status-dot"></span>
<span class="ralph-status-text">Idle</span>
</span>
<span class="ralph-phrase-preview" id="ralphPhrasePreview"></span>
</div>
<div class="ralph-summary-stats">
<span class="ralph-stat"><span class="ralph-stat-icon">&#x23F1;</span><span id="ralphStatTime">0m</span></span>
<span class="ralph-stat"><span class="ralph-stat-icon">&#x21BB;</span><span id="ralphStatCycles">0</span></span>
<span class="ralph-stat"><span class="ralph-stat-icon">&#x2713;</span><span id="ralphStatTasks">0/0</span></span>
</div>
<span class="ralph-toggle" id="ralphToggle">&#x25B6;</span>
</div>
<!-- Expanded Detail View -->
<div class="ralph-detail" id="ralphDetail">
<div class="ralph-progress-section">
<!-- Large Progress Ring -->
<div class="ralph-ring">
<svg viewBox="0 0 100 100" class="ralph-ring-svg-large">
<defs>
<linearGradient id="ralphGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#3b82f6" />
<stop offset="100%" stop-color="#22c55e" />
</linearGradient>
</defs>
<circle class="ralph-ring-track" cx="50" cy="50" r="42" />
<circle class="ralph-ring-fill" cx="50" cy="50" r="42" id="ralphRingProgress" />
</svg>
<div class="ralph-ring-center">
<div class="ralph-ring-percent" id="ralphRingPercent">0%</div>
<div class="ralph-ring-label">Complete</div>
</div>
</div>
<!-- Meta Info -->
<div class="ralph-meta">
<div class="ralph-meta-item">
<span class="ralph-meta-label">Waiting for</span>
<code id="ralphPhrase">--</code>
</div>
<div class="ralph-meta-row">
<div class="ralph-meta-item">
<span class="ralph-meta-label">Elapsed</span>
<span id="ralphElapsed">0m</span>
</div>
<div class="ralph-meta-item">
<span class="ralph-meta-label">Iterations</span>
<span id="ralphIterations">0</span>
</div>
</div>
</div>
</div>
<!-- Task Cards -->
<div class="ralph-tasks-section">
<div class="ralph-tasks-header">
<span>Tasks</span>
<span class="ralph-tasks-count" id="ralphTasksCount">0/0</span>
</div>
<div class="ralph-tasks-grid" id="ralphTasksGrid"></div>
</div>
</div>
<!-- Completion Celebration -->
<div class="ralph-celebration" id="ralphCelebration">
<div class="ralph-celebration-content">
<div class="ralph-checkmark">
<svg viewBox="0 0 52 52">
<circle class="ralph-checkmark-circle" cx="26" cy="26" r="25" />
<path class="ralph-checkmark-check" d="M14.1 27.2l7.1 7.2 16.7-16.8" />
</svg>
</div>
<div class="ralph-celebration-text" id="ralphCelebrationText">Complete!</div>
</div>
</div>
<div class="inner-state-content" id="innerStateContent"></div>
</div>
<!-- Main Terminal Area -->
+388 -100
View File
@@ -447,166 +447,454 @@ body {
to { transform: rotate(360deg); }
}
/* Inner State Panel (Ralph Loop + Todo List) */
.inner-state-panel {
/* ===== Enhanced Ralph Wiggum Loop Panel ===== */
.ralph-panel {
background: var(--bg-card);
border-bottom: 1px solid var(--border);
font-size: 0.75rem;
flex-shrink: 0;
}
.inner-state-panel.collapsed .inner-state-content {
max-height: 28px;
overflow: hidden;
position: relative;
}
.inner-state-header {
/* Collapsed Summary Bar */
.ralph-summary {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.75rem;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
cursor: pointer;
user-select: none;
transition: background 0.15s;
}
.inner-state-header:hover {
.ralph-summary:hover {
background: var(--bg-hover);
}
.inner-state-toggle {
/* Mini Progress Ring (in summary) */
.ralph-ring-mini {
position: relative;
width: 32px;
height: 32px;
flex-shrink: 0;
}
.ralph-ring-svg {
width: 100%;
height: 100%;
transform: rotate(-90deg);
}
.ralph-ring-bg {
fill: none;
stroke: var(--bg-input);
stroke-width: 3;
}
.ralph-ring-progress {
fill: none;
stroke: url(#ralphGradient);
stroke-width: 3;
stroke-linecap: round;
stroke-dasharray: 100;
stroke-dashoffset: 100;
transition: stroke-dashoffset 0.5s ease-out;
}
.ralph-ring-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 0.55rem;
font-weight: 600;
color: var(--text);
}
/* Summary Info */
.ralph-summary-info {
flex: 1;
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
}
.ralph-status-badge {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.2rem 0.5rem;
border-radius: 4px;
background: var(--bg-input);
font-size: 0.7rem;
font-weight: 500;
}
.ralph-status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-muted);
transition: background 0.2s;
}
.ralph-status-badge.active .ralph-status-dot {
background: var(--accent);
animation: ralph-pulse 1.5s ease-in-out infinite;
}
.ralph-status-badge.completed .ralph-status-dot {
background: var(--green);
}
@keyframes ralph-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.6; transform: scale(1.2); }
}
.ralph-phrase-preview {
color: var(--text-dim);
font-size: 0.7rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 200px;
}
.ralph-phrase-preview code {
background: var(--bg-input);
padding: 0.1rem 0.3rem;
border-radius: 2px;
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.65rem;
}
/* Summary Stats */
.ralph-summary-stats {
display: flex;
align-items: center;
gap: 0.75rem;
}
.ralph-stat {
display: flex;
align-items: center;
gap: 0.25rem;
color: var(--text-dim);
font-size: 0.7rem;
}
.ralph-stat-icon {
font-size: 0.75rem;
opacity: 0.7;
}
.ralph-toggle {
color: var(--text-dim);
font-size: 0.6rem;
transition: transform 0.2s;
flex-shrink: 0;
}
.inner-state-title {
color: var(--text-dim);
font-weight: 500;
.ralph-panel:not(.collapsed) .ralph-toggle {
transform: rotate(90deg);
}
.inner-state-content {
padding: 0 0.75rem 0.5rem;
transition: max-height 0.2s;
/* Hide detail when collapsed */
.ralph-panel.collapsed .ralph-detail {
display: none;
}
.inner-state-panel.collapsed .inner-state-content {
padding: 0 0.75rem;
/* Expanded Detail View */
.ralph-detail {
padding: 0.75rem;
border-top: 1px solid var(--border);
background: var(--bg-dark);
}
.ralph-progress-section {
display: flex;
align-items: center;
gap: 1rem;
align-items: flex-start;
gap: 1.5rem;
margin-bottom: 1rem;
}
/* Loop status in summary view */
.inner-loop-status {
padding: 0.15rem 0.5rem;
border-radius: 3px;
font-size: 0.7rem;
/* Large Progress Ring */
.ralph-ring {
position: relative;
width: 100px;
height: 100px;
flex-shrink: 0;
}
.inner-loop-status.active {
background: rgba(59, 130, 246, 0.2);
color: var(--accent-hover);
.ralph-ring-svg-large {
width: 100%;
height: 100%;
transform: rotate(-90deg);
}
.inner-loop-status.completed {
background: rgba(34, 197, 94, 0.2);
color: var(--green);
.ralph-ring-track {
fill: none;
stroke: var(--bg-input);
stroke-width: 6;
}
.inner-todo-summary {
color: var(--text-dim);
.ralph-ring-fill {
fill: none;
stroke: url(#ralphGradient);
stroke-width: 6;
stroke-linecap: round;
stroke-dasharray: 264;
stroke-dashoffset: 264;
transition: stroke-dashoffset 0.5s ease-out;
}
/* Loop details in expanded view */
.inner-loop-details {
margin-bottom: 0.5rem;
padding: 0.5rem;
background: rgba(59, 130, 246, 0.1);
border-radius: 4px;
border-left: 2px solid var(--accent);
.ralph-ring-center {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
}
.inner-loop-active {
color: var(--accent-hover);
}
.inner-loop-active code {
background: var(--bg-input);
padding: 0.1rem 0.3rem;
border-radius: 2px;
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.7rem;
}
.inner-loop-elapsed,
.inner-loop-cycles {
color: var(--text-dim);
font-size: 0.7rem;
margin-top: 0.25rem;
}
.inner-loop-completed {
color: var(--green);
}
.inner-loop-completed code {
background: var(--bg-input);
padding: 0.1rem 0.3rem;
border-radius: 2px;
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.7rem;
}
/* Todo list in expanded view */
.inner-todo-list {
padding: 0.5rem;
background: var(--bg-input);
border-radius: 4px;
}
.inner-todo-header {
.ralph-ring-percent {
font-size: 1.25rem;
font-weight: 700;
color: var(--text);
font-weight: 500;
margin-bottom: 0.35rem;
padding-bottom: 0.35rem;
border-bottom: 1px solid var(--border);
line-height: 1;
}
.inner-todo-items {
list-style: none;
padding: 0;
margin: 0;
.ralph-ring-label {
font-size: 0.6rem;
color: var(--text-dim);
margin-top: 0.15rem;
}
/* Meta Info */
.ralph-meta {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.ralph-meta-item {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.ralph-meta-label {
font-size: 0.65rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.ralph-meta code {
background: var(--bg-input);
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.75rem;
color: var(--accent);
word-break: break-all;
}
.ralph-meta-row {
display: flex;
gap: 1.5rem;
}
.ralph-meta-item span:not(.ralph-meta-label) {
font-size: 0.9rem;
font-weight: 500;
color: var(--text);
}
/* Task Cards Section */
.ralph-tasks-section {
background: var(--bg-card);
border-radius: 6px;
overflow: hidden;
}
.ralph-tasks-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
background: var(--bg-input);
font-weight: 500;
color: var(--text);
font-size: 0.75rem;
}
.ralph-tasks-count {
color: var(--text-dim);
font-weight: 400;
}
.ralph-tasks-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 0.5rem;
padding: 0.5rem;
max-height: 200px;
overflow-y: auto;
}
.inner-todo-items li {
padding: 0.25rem 0;
color: var(--text-dim);
font-family: 'SF Mono', Monaco, monospace;
/* Task Cards */
.ralph-task-card {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.5rem;
background: var(--bg-dark);
border-radius: 4px;
border-left: 2px solid var(--text-muted);
transition: border-color 0.2s, background 0.2s;
}
.ralph-task-card.task-pending {
border-left-color: var(--text-muted);
}
.ralph-task-card.task-in-progress {
border-left-color: var(--accent);
background: rgba(59, 130, 246, 0.05);
}
.ralph-task-card.task-completed {
border-left-color: var(--green);
opacity: 0.75;
}
.ralph-task-icon {
flex-shrink: 0;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.7rem;
white-space: nowrap;
border-radius: 50%;
}
.ralph-task-card.task-pending .ralph-task-icon {
color: var(--text-muted);
}
.ralph-task-card.task-in-progress .ralph-task-icon {
color: var(--accent);
animation: ralph-spin 2s linear infinite;
}
.ralph-task-card.task-completed .ralph-task-icon {
color: var(--green);
}
@keyframes ralph-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.ralph-task-content {
flex: 1;
font-size: 0.7rem;
color: var(--text-dim);
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.inner-todo-items li.todo-completed {
color: var(--green);
.ralph-task-card.task-completed .ralph-task-content {
text-decoration: line-through;
opacity: 0.7;
}
.inner-todo-items li.todo-in-progress {
color: var(--accent-hover);
/* Completion Celebration */
.ralph-celebration {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: none;
align-items: center;
justify-content: center;
background: rgba(10, 10, 10, 0.95);
z-index: 10;
}
.inner-todo-items li.todo-pending {
color: var(--text-dim);
.ralph-celebration.show {
display: flex;
animation: ralph-celebration-in 0.3s ease-out;
}
@keyframes ralph-celebration-in {
from { opacity: 0; }
to { opacity: 1; }
}
.ralph-celebration-content {
text-align: center;
}
.ralph-checkmark {
width: 60px;
height: 60px;
margin: 0 auto 0.75rem;
}
.ralph-checkmark svg {
width: 100%;
height: 100%;
}
.ralph-checkmark-circle {
fill: none;
stroke: var(--green);
stroke-width: 2;
stroke-dasharray: 166;
stroke-dashoffset: 166;
animation: ralph-circle 0.6s ease-out forwards;
}
.ralph-checkmark-check {
fill: none;
stroke: var(--green);
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 48;
stroke-dashoffset: 48;
animation: ralph-check 0.3s ease-out 0.4s forwards;
}
@keyframes ralph-circle {
to { stroke-dashoffset: 0; }
}
@keyframes ralph-check {
to { stroke-dashoffset: 0; }
}
.ralph-celebration-text {
font-size: 1rem;
font-weight: 600;
color: var(--green);
}
/* Empty state (legacy support) */
.inner-state-empty {
color: var(--text-muted);
font-style: italic;
padding: 0.5rem;
}
/* Main - Terminal Area */