From 684ccc2b0732885f558a0832fd39a8a1c1a3e198 Mon Sep 17 00:00:00 2001 From: arkon Date: Mon, 19 Jan 2026 17:09:56 +0100 Subject: [PATCH] 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 --- src/inner-loop-tracker.ts | 83 +++++- src/types.ts | 2 + src/web/public/app.js | 290 +++++++++++++------ src/web/public/index.html | 91 +++++- src/web/public/styles.css | 488 +++++++++++++++++++++++++------- test/inner-loop-tracker.test.ts | 97 +++++++ 6 files changed, 859 insertions(+), 192 deletions(-) diff --git a/src/inner-loop-tracker.ts b/src/inner-loop-tracker.ts index 0092f34d..2739c280 100644 --- a/src/inner-loop-tracker.ts +++ b/src/inner-loop-tracker.ts @@ -29,6 +29,19 @@ const LOOP_START_PATTERN = /Loop started at|Starting.*loop|Ralph loop started|

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 */ diff --git a/src/types.ts b/src/types.ts index 33b48f1a..fd044442 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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, }; diff --git a/src/web/public/app.js b/src/web/public/app.js index c12379a7..592bab19 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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 = `${this.escapeHtml(state.loop.completionPhrase)}`; + } else if (phrasePreview) { + phrasePreview.textContent = ''; + } + + // Handle collapsed/expanded state if (this.innerStatePanelCollapsed) { panel.classList.add('collapsed'); - toggle.innerHTML = '▶'; // Right arrow - content.innerHTML = this.renderInnerStateSummary(state); + if (toggle) toggle.innerHTML = '▶'; } else { panel.classList.remove('collapsed'); - toggle.innerHTML = '▼'; // Down arrow - content.innerHTML = this.renderInnerStateExpanded(state); + if (toggle) toggle.innerHTML = '▼'; + + // 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(`${loopText}`); - } else if (loop.completionPhrase) { - parts.push(`✓ ${this.escapeHtml(loop.completionPhrase)}`); - } + 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(`${todoText}`); + // 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 += '

'; - if (loop.active) { - html += `
🔄 Loop Active`; - if (loop.completionPhrase) { - html += ` - waiting for: ${this.escapeHtml(loop.completionPhrase)}`; - } - html += '
'; - if (loop.elapsedHours !== null) { - html += `
Elapsed: ${loop.elapsedHours.toFixed(2)} hours
`; - } - if (loop.cycleCount > 0) { - html += `
Cycle: #${loop.cycleCount}
`; - } - } else if (loop.completionPhrase) { - html += `
✓ Completed: ${this.escapeHtml(loop.completionPhrase)}
`; - } - html += '
'; + 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 += '
'; - html += `
Tasks (${completed}/${total} complete)
`; - html += '
    '; - - for (const todo of state.todos) { - const icon = this.getTodoIcon(todo.status); - const statusClass = `todo-${todo.status.replace('_', '-')}`; - html += `
  • ${icon} ${this.escapeHtml(todo.content)}
  • `; - } - - html += '
'; - html += '
'; - } - - return html || '
No inner state data
'; } - 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 = '
No tasks detected
'; + 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 ` +
+ ${icon} + ${this.escapeHtml(todo.content)} +
+ `; + }).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() { diff --git a/src/web/public/index.html b/src/web/public/index.html index 9b19c7fa..f2b249b1 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -72,13 +72,92 @@ - -