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 ELAPSED_TIME_PATTERN = /Elapsed:\s*(\d+(?:\.\d+)?)\s*hours?/i;
const CYCLE_PATTERN = /cycle\s*#?(\d+)|respawn cycle #(\d+)/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 // ANSI escape code removal for cleaner parsing
const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*m/g; const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*m/g;
@@ -143,7 +156,20 @@ export class InnerLoopTracker extends EventEmitter {
* Detect loop start and status indicators * Detect loop start and status indicators
*/ */
private detectLoopStatus(line: string): void { 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)) { if (LOOP_START_PATTERN.test(line)) {
// Check if this is a promise match (loop ending) vs loop start // Check if this is a promise match (loop ending) vs loop start
if (!PROMISE_PATTERN.test(line)) { if (!PROMISE_PATTERN.test(line)) {
@@ -152,6 +178,7 @@ export class InnerLoopTracker extends EventEmitter {
this._loopState.active = true; this._loopState.active = true;
this._loopState.startedAt = Date.now(); this._loopState.startedAt = Date.now();
this._loopState.cycleCount = 0; this._loopState.cycleCount = 0;
this._loopState.maxIterations = null;
this._loopState.elapsedHours = null; this._loopState.elapsedHours = null;
this._loopState.lastActivity = Date.now(); this._loopState.lastActivity = Date.now();
this.emit('loopUpdate', this.loopState); 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 // Check for elapsed time
const elapsedMatch = line.match(ELAPSED_TIME_PATTERN); const elapsedMatch = line.match(ELAPSED_TIME_PATTERN);
if (elapsedMatch) { if (elapsedMatch) {
@@ -167,7 +228,7 @@ export class InnerLoopTracker extends EventEmitter {
this.emit('loopUpdate', this.loopState); this.emit('loopUpdate', this.loopState);
} }
// Check for cycle count // Check for cycle count (legacy pattern)
const cycleMatch = line.match(CYCLE_PATTERN); const cycleMatch = line.match(CYCLE_PATTERN);
if (cycleMatch) { if (cycleMatch) {
const cycleNum = parseInt(cycleMatch[1] || cycleMatch[2]); const cycleNum = parseInt(cycleMatch[1] || cycleMatch[2]);
@@ -177,6 +238,12 @@ export class InnerLoopTracker extends EventEmitter {
this.emit('loopUpdate', this.loopState); 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) * 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.active = true;
this._loopState.startedAt = Date.now(); this._loopState.startedAt = Date.now();
this._loopState.cycleCount = 0; this._loopState.cycleCount = 0;
this._loopState.maxIterations = maxIterations ?? null;
this._loopState.elapsedHours = null; this._loopState.elapsedHours = null;
this._loopState.lastActivity = Date.now(); this._loopState.lastActivity = Date.now();
if (completionPhrase) { if (completionPhrase) {
@@ -333,6 +401,15 @@ export class InnerLoopTracker extends EventEmitter {
this.emit('loopUpdate', this.loopState); 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 * Mark the loop as stopped
*/ */
+2
View File
@@ -255,6 +255,7 @@ export interface InnerLoopState {
completionPhrase: string | null; completionPhrase: string | null;
startedAt: number | null; startedAt: number | null;
cycleCount: number; cycleCount: number;
maxIterations: number | null;
lastActivity: number; lastActivity: number;
elapsedHours: number | null; elapsedHours: number | null;
} }
@@ -283,6 +284,7 @@ export function createInitialInnerLoopState(): InnerLoopState {
completionPhrase: null, completionPhrase: null,
startedAt: null, startedAt: null,
cycleCount: 0, cycleCount: 0,
maxIterations: null,
lastActivity: Date.now(), lastActivity: Date.now(),
elapsedHours: null, elapsedHours: null,
}; };
+207 -83
View File
@@ -505,6 +505,10 @@ class ClaudemanApp {
existing.loop.active = false; existing.loop.active = false;
this.updateInnerState(data.sessionId, existing); 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; return result;
} }
// ========== Inner State (Ralph Loop & Todo List) ========== // ========== Enhanced Ralph Wiggum Loop Panel ==========
updateInnerState(sessionId, updates) { updateInnerState(sessionId, updates) {
const existing = this.innerStates.get(sessionId) || { loop: null, todos: [] }; const existing = this.innerStates.get(sessionId) || { loop: null, todos: [] };
@@ -1811,10 +1815,9 @@ class ClaudemanApp {
renderInnerStatePanel() { renderInnerStatePanel() {
const panel = this.$('innerStatePanel'); const panel = this.$('innerStatePanel');
const content = this.$('innerStateContent'); const toggle = this.$('ralphToggle');
const toggle = this.$('innerStateToggle');
if (!panel) return; // Panel not in DOM yet if (!panel) return;
const state = this.innerStates.get(this.activeSessionId); const state = this.innerStates.get(this.activeSessionId);
@@ -1829,108 +1832,229 @@ class ClaudemanApp {
panel.style.display = ''; 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) { if (this.innerStatePanelCollapsed) {
panel.classList.add('collapsed'); panel.classList.add('collapsed');
toggle.innerHTML = '&#x25B6;'; // Right arrow if (toggle) toggle.innerHTML = '&#x25B6;';
content.innerHTML = this.renderInnerStateSummary(state);
} else { } else {
panel.classList.remove('collapsed'); panel.classList.remove('collapsed');
toggle.innerHTML = '&#x25BC;'; // Down arrow if (toggle) toggle.innerHTML = '&#x25BC;';
content.innerHTML = this.renderInnerStateExpanded(state);
// Update expanded view content
this.updateRalphExpandedView(state);
} }
} }
renderInnerStateSummary(state) { updateRalphRing(percent) {
const parts = []; // Mini ring (in summary)
const miniProgress = this.$('ralphRingMiniProgress');
// Loop status const miniText = this.$('ralphRingMiniText');
if (state?.loop) { if (miniProgress) {
const loop = state.loop; // Circumference = 2 * PI * r = 2 * PI * 15.9 ≈ 100
if (loop.active) { const offset = 100 - percent;
let loopText = '🔄 Loop'; miniProgress.style.strokeDashoffset = offset;
if (loop.completionPhrase) { }
loopText += `: "${this.escapeHtml(loop.completionPhrase)}"`; if (miniText) {
} miniText.textContent = `${percent}%`;
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>`);
}
} }
// Todo summary // Large ring (in expanded view)
if (state?.todos?.length > 0) { const largeProgress = this.$('ralphRingProgress');
const completed = state.todos.filter(t => t.status === 'completed').length; const largePercent = this.$('ralphRingPercent');
const total = state.todos.length; if (largeProgress) {
const inProgress = state.todos.filter(t => t.status === 'in_progress').length; // Circumference = 2 * PI * r = 2 * PI * 42 ≈ 264
let todoText = `Tasks: ${completed}/${total}`; const offset = 264 - (264 * percent / 100);
if (inProgress > 0) { largeProgress.style.strokeDashoffset = offset;
todoText += ` (${inProgress} in progress)`; }
} if (largePercent) {
parts.push(`<span class="inner-todo-summary">${todoText}</span>`); largePercent.textContent = `${percent}%`;
} }
return parts.join(' | ');
} }
renderInnerStateExpanded(state) { updateRalphStatus(loop) {
let html = ''; const badge = this.$('ralphStatusBadge');
const statusText = badge?.querySelector('.ralph-status-text');
if (!badge || !statusText) return;
// Loop details badge.classList.remove('active', 'completed');
if (state?.loop) {
const loop = state.loop; if (loop?.active) {
html += '<div class="inner-loop-details">'; badge.classList.add('active');
if (loop.active) { statusText.textContent = 'Running';
html += `<div class="inner-loop-active">🔄 <strong>Loop Active</strong>`; } else if (loop?.completionPhrase && !loop?.active) {
if (loop.completionPhrase) { badge.classList.add('completed');
html += ` - waiting for: <code>${this.escapeHtml(loop.completionPhrase)}</code>`; statusText.textContent = 'Complete';
} } else {
html += '</div>'; statusText.textContent = 'Idle';
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>';
} }
// 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) { switch (status) {
case 'completed': return '✓'; case 'completed': return '✓';
case 'in_progress': return '◐'; case 'in_progress': return '◐';
case 'pending': 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) ========== // ========== Screen Sessions (in Monitor Panel) ==========
async loadScreens() { async loadScreens() {
+85 -6
View File
@@ -72,13 +72,92 @@
<button class="btn-icon-only" onclick="app.stopRespawn()" title="Stop Respawn">&#x25A0;</button> <button class="btn-icon-only" onclick="app.stopRespawn()" title="Stop Respawn">&#x25A0;</button>
</div> </div>
<!-- Inner State Panel (Ralph Loop + Todo List detection) --> <!-- Enhanced Ralph Wiggum Loop Panel -->
<div class="inner-state-panel collapsed" id="innerStatePanel" style="display: none;"> <div class="ralph-panel collapsed" id="innerStatePanel" style="display: none;">
<div class="inner-state-header" onclick="app.toggleInnerStatePanel()"> <!-- Collapsed Summary Bar -->
<span class="inner-state-toggle" id="innerStateToggle">&#x25B6;</span> <div class="ralph-summary" onclick="app.toggleInnerStatePanel()">
<span class="inner-state-title">Inner State</span> <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>
<div class="inner-state-content" id="innerStateContent"></div>
</div> </div>
<!-- Main Terminal Area --> <!-- Main Terminal Area -->
+388 -100
View File
@@ -447,166 +447,454 @@ body {
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
/* Inner State Panel (Ralph Loop + Todo List) */ /* ===== Enhanced Ralph Wiggum Loop Panel ===== */
.inner-state-panel { .ralph-panel {
background: var(--bg-card); background: var(--bg-card);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
font-size: 0.75rem; font-size: 0.75rem;
flex-shrink: 0; flex-shrink: 0;
}
.inner-state-panel.collapsed .inner-state-content {
max-height: 28px;
overflow: hidden; overflow: hidden;
position: relative;
} }
.inner-state-header { /* Collapsed Summary Bar */
.ralph-summary {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.75rem;
padding: 0.35rem 0.75rem; padding: 0.5rem 0.75rem;
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
transition: background 0.15s;
} }
.inner-state-header:hover { .ralph-summary:hover {
background: var(--bg-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); color: var(--text-dim);
font-size: 0.6rem; font-size: 0.6rem;
transition: transform 0.2s; transition: transform 0.2s;
flex-shrink: 0;
} }
.inner-state-title { .ralph-panel:not(.collapsed) .ralph-toggle {
color: var(--text-dim); transform: rotate(90deg);
font-weight: 500;
} }
.inner-state-content { /* Hide detail when collapsed */
padding: 0 0.75rem 0.5rem; .ralph-panel.collapsed .ralph-detail {
transition: max-height 0.2s; display: none;
} }
.inner-state-panel.collapsed .inner-state-content { /* Expanded Detail View */
padding: 0 0.75rem; .ralph-detail {
padding: 0.75rem;
border-top: 1px solid var(--border);
background: var(--bg-dark);
}
.ralph-progress-section {
display: flex; display: flex;
align-items: center; align-items: flex-start;
gap: 1rem; gap: 1.5rem;
margin-bottom: 1rem;
} }
/* Loop status in summary view */ /* Large Progress Ring */
.inner-loop-status { .ralph-ring {
padding: 0.15rem 0.5rem; position: relative;
border-radius: 3px; width: 100px;
font-size: 0.7rem; height: 100px;
flex-shrink: 0;
} }
.inner-loop-status.active { .ralph-ring-svg-large {
background: rgba(59, 130, 246, 0.2); width: 100%;
color: var(--accent-hover); height: 100%;
transform: rotate(-90deg);
} }
.inner-loop-status.completed { .ralph-ring-track {
background: rgba(34, 197, 94, 0.2); fill: none;
color: var(--green); stroke: var(--bg-input);
stroke-width: 6;
} }
.inner-todo-summary { .ralph-ring-fill {
color: var(--text-dim); 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 */ .ralph-ring-center {
.inner-loop-details { position: absolute;
margin-bottom: 0.5rem; top: 50%;
padding: 0.5rem; left: 50%;
background: rgba(59, 130, 246, 0.1); transform: translate(-50%, -50%);
border-radius: 4px; text-align: center;
border-left: 2px solid var(--accent);
} }
.inner-loop-active { .ralph-ring-percent {
color: var(--accent-hover); font-size: 1.25rem;
} font-weight: 700;
.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 {
color: var(--text); color: var(--text);
font-weight: 500; line-height: 1;
margin-bottom: 0.35rem;
padding-bottom: 0.35rem;
border-bottom: 1px solid var(--border);
} }
.inner-todo-items { .ralph-ring-label {
list-style: none; font-size: 0.6rem;
padding: 0; color: var(--text-dim);
margin: 0; 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; max-height: 200px;
overflow-y: auto; overflow-y: auto;
} }
.inner-todo-items li { /* Task Cards */
padding: 0.25rem 0; .ralph-task-card {
color: var(--text-dim); display: flex;
font-family: 'SF Mono', Monaco, monospace; 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; 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; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
} }
.inner-todo-items li.todo-completed { .ralph-task-card.task-completed .ralph-task-content {
color: var(--green);
text-decoration: line-through; text-decoration: line-through;
opacity: 0.7;
} }
.inner-todo-items li.todo-in-progress { /* Completion Celebration */
color: var(--accent-hover); .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 { .ralph-celebration.show {
color: var(--text-dim); 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 { .inner-state-empty {
color: var(--text-muted); color: var(--text-muted);
font-style: italic; font-style: italic;
padding: 0.5rem;
} }
/* Main - Terminal Area */ /* Main - Terminal Area */
+97
View File
@@ -293,6 +293,7 @@ describe('InnerLoopTracker', () => {
completionPhrase: 'RESTORED', completionPhrase: 'RESTORED',
startedAt: Date.now() - 1000, startedAt: Date.now() - 1000,
cycleCount: 5, cycleCount: 5,
maxIterations: 50,
lastActivity: Date.now(), lastActivity: Date.now(),
elapsedHours: 1.5, elapsedHours: 1.5,
}; };
@@ -307,10 +308,106 @@ describe('InnerLoopTracker', () => {
expect(tracker.loopState.active).toBe(true); expect(tracker.loopState.active).toBe(true);
expect(tracker.loopState.completionPhrase).toBe('RESTORED'); expect(tracker.loopState.completionPhrase).toBe('RESTORED');
expect(tracker.loopState.cycleCount).toBe(5); expect(tracker.loopState.cycleCount).toBe(5);
expect(tracker.loopState.maxIterations).toBe(50);
expect(tracker.todos).toHaveLength(2); expect(tracker.todos).toHaveLength(2);
}); });
}); });
describe('Enhanced Ralph Detection Patterns', () => {
it('should detect /ralph-loop command', () => {
const loopHandler = vi.fn();
tracker.on('loopUpdate', loopHandler);
tracker.processTerminalData('/ralph-loop\n');
expect(loopHandler).toHaveBeenCalled();
expect(tracker.loopState.active).toBe(true);
});
it('should detect "Starting Ralph Wiggum loop"', () => {
tracker.processTerminalData('Starting Ralph Wiggum loop now\n');
expect(tracker.loopState.active).toBe(true);
});
it('should detect "ralph loop started"', () => {
tracker.processTerminalData('ralph loop started at 10:00\n');
expect(tracker.loopState.active).toBe(true);
});
it('should detect iteration pattern "Iteration 5/50"', () => {
const loopHandler = vi.fn();
tracker.on('loopUpdate', loopHandler);
tracker.processTerminalData('Iteration 5/50\n');
expect(tracker.loopState.active).toBe(true);
expect(tracker.loopState.cycleCount).toBe(5);
expect(tracker.loopState.maxIterations).toBe(50);
});
it('should detect iteration pattern "[5/50]"', () => {
tracker.processTerminalData('[5/50] Working on task...\n');
expect(tracker.loopState.active).toBe(true);
expect(tracker.loopState.cycleCount).toBe(5);
expect(tracker.loopState.maxIterations).toBe(50);
});
it('should detect iteration pattern without max "Iteration 3"', () => {
tracker.processTerminalData('Iteration 3 - processing\n');
expect(tracker.loopState.active).toBe(true);
expect(tracker.loopState.cycleCount).toBe(3);
expect(tracker.loopState.maxIterations).toBeNull();
});
it('should detect max-iterations setting', () => {
tracker.processTerminalData('Setting max-iterations: 100\n');
expect(tracker.loopState.maxIterations).toBe(100);
});
it('should detect maxIterations setting', () => {
tracker.processTerminalData('maxIterations=75\n');
expect(tracker.loopState.maxIterations).toBe(75);
});
it('should detect max_iterations setting', () => {
tracker.processTerminalData('config: max_iterations = 25\n');
expect(tracker.loopState.maxIterations).toBe(25);
});
it('should detect TodoWrite tool output', () => {
const loopHandler = vi.fn();
tracker.on('loopUpdate', loopHandler);
// TodoWrite detection should update lastActivity but not emit
tracker.processTerminalData('TodoWrite: Todos have been modified successfully\n');
expect(tracker.loopState.lastActivity).toBeGreaterThan(0);
});
});
describe('startLoop with maxIterations', () => {
it('should set maxIterations when starting loop', () => {
tracker.startLoop('COMPLETE', 100);
expect(tracker.loopState.active).toBe(true);
expect(tracker.loopState.completionPhrase).toBe('COMPLETE');
expect(tracker.loopState.maxIterations).toBe(100);
});
it('should allow setting maxIterations separately', () => {
tracker.startLoop('TEST');
expect(tracker.loopState.maxIterations).toBeNull();
tracker.setMaxIterations(50);
expect(tracker.loopState.maxIterations).toBe(50);
});
});
describe('ANSI Escape Handling', () => { describe('ANSI Escape Handling', () => {
it('should strip ANSI escape codes before parsing', () => { it('should strip ANSI escape codes before parsing', () => {
// ANSI colored output // ANSI colored output