/** * Widget renderer - handles the visual rendering of the supervisor status widget. */ import type { ExtensionContext } from '@earendil-works/pi-coding-agent'; import { truncateToWidth } from '@earendil-works/pi-tui'; import type { SupervisorIntervention, SupervisorState } from '../types.js'; import type { WidgetAction, WidgetState } from './types.js'; import { WIDGET_ID, CLEAR_DELAY_MS } from './types.js'; import { startLineClearAnimation, type RenderFn } from './animations.js'; /** Toggle the widget on/off. Returns the new visibility state. */ export function toggleWidget(state: WidgetState): boolean { state.widgetVisible = !state.widgetVisible; return state.widgetVisible; } /** Update footer + widget. Call this every time state or action changes. */ export function updateUI( ctx: ExtensionContext, state: WidgetState, supervisorState: SupervisorState | null, action: WidgetAction = { type: 'watching' } ): void { // Check if we're receiving new thinking content const hasNewThinking = action.type === 'analyzing' && action.thinking && action.thinking !== state.lastThinking; // Detect when leaving analyzing mode (for clear animation) const wasAnalyzing = state.lastActionType === 'analyzing'; const isNowAnalyzing = action.type === 'analyzing'; const leavingAnalyzing = wasAnalyzing && !isNowAnalyzing; if (state.clearTimer) { clearTimeout(state.clearTimer); state.clearTimer = null; } if (state.animationTimer) { clearTimeout(state.animationTimer); state.animationTimer = null; } // Reset animation state for new thinking (streaming replacement) if (hasNewThinking) { state.hiddenFromBottomCount = 0; state.lastThinkingLines = []; } // When leaving analyzing, keep the thinking visible so it can animate away. // For done: shown before delayed clear animation. // For steering/watching: animated away immediately (no delay). // Clear lastThinking when leaving to a non-done action so the render falls back // to preserved lines (for animation) or shows no thinking (no lines to animate). if (leavingAnalyzing && action.type !== 'done') { state.lastThinking = ''; } // Always update last state first // For 'done', supervisorState may already be inactive (stopped before this call), // so we also capture state on that transition. if (supervisorState?.active || action.type === 'done') { if (supervisorState) { state.lastActiveState = { outcome: supervisorState.outcome, interventions: [...supervisorState.interventions], }; } state.lastActionType = action.type; if (action.type === 'analyzing' && action.thinking) { state.lastThinking = action.thinking; } } // Handle inferring specially — but only if there's no thinking to animate away first. // When transitioning from analyzing (with thinking lines visible) to inferring, // we animate the thinking down before showing the inferring state. if (action.type === 'inferring' && !(leavingAnalyzing && state.lastThinkingLines.length > 0)) { // Clear any stale thinking when inferring from a non-analyzing state if (leavingAnalyzing) { state.lastThinking = ''; } if (state.widgetVisible) { const inferState = { outcome: '', interventions: state.lastActiveState?.interventions ?? [] }; renderWithState(ctx, state, inferState, action, '', 0); } return; } // We need the clear animation when: // 1. Supervisor is inactive and thinking lines are still visible (stop), or // 2. Leaving analyzing with thinking lines to a non-analyzing action (steering/watching). // This animates the thinking text down instead of vanishing it instantly. // 3. Done action — always show "✓ done" briefly before clearing, even without thinking. const hasThinkingToAnimate = state.lastActiveState && state.lastThinkingLines.length > 0; const isDoneTransition = action.type === 'done' && state.lastActiveState; const needsClearAnimation = (hasThinkingToAnimate && (!supervisorState?.active || leavingAnalyzing)) || isDoneTransition; // Leaving analyzing to a non-done, non-analyzing action — animate the thinking // text away immediately (no delay), so it doesn't vanish instantly. // For 'done', use the delayed clear path instead so "✓ done" stays visible briefly. if (needsClearAnimation && leavingAnalyzing && !isDoneTransition) { const boundRender: RenderFn = (ctx, snap, action, thinking, hideFromBottom) => { renderWithState(ctx, state, snap, action, thinking, hideFromBottom); }; let fallbackAction: WidgetAction; if (action.type === 'steering') { fallbackAction = { type: 'steering', message: '', reframeTier: action.reframeTier }; } else if (action.type === 'waiting') { fallbackAction = { type: 'waiting', message: action.message, reframeTier: action.reframeTier, }; } else if (action.type === 'inferring') { fallbackAction = { type: 'inferring' }; } else { const reframeTier = 'reframeTier' in action ? (action.reframeTier ?? 0) : 0; fallbackAction = { type: action.type, reframeTier } as WidgetAction; } state.lastActionType = fallbackAction.type; state.storedAction = fallbackAction; startLineClearAnimation(ctx, state, boundRender); return; } // When supervisor becomes inactive (after stop), start the clear animation. // This handles the 'done' transition: the widget shows all thinking lines, // then after CLEAR_DELAY_MS they animate away. if (needsClearAnimation) { state.clearTimer = setTimeout(() => { const boundRender: RenderFn = (ctx, snap, action, thinking, hideFromBottom) => { renderWithState(ctx, state, snap, action, thinking, hideFromBottom); }; startLineClearAnimation(ctx, state, boundRender); }, CLEAR_DELAY_MS); const fallbackAction: WidgetAction = action.type === 'steering' ? { type: 'steering', message: '', reframeTier: action.reframeTier } : { type: 'done', reframeTier: 0 }; state.lastActionType = fallbackAction.type; state.storedAction = fallbackAction; // Render with lastThinkingLines content when lastThinking is empty renderWithState( ctx, state, state.lastActiveState!, fallbackAction, state.lastThinking, state.hiddenFromBottomCount ); return; } if (!supervisorState || !supervisorState.active) { // Don't clear if a done clear animation is scheduled — it handles the teardown. if (state.clearTimer || state.animationTimer) return; state.lastThinkingLines = []; ctx.ui.setWidget(WIDGET_ID, undefined); return; } if (!state.widgetVisible) { ctx.ui.setWidget(WIDGET_ID, undefined); return; } renderWithState( ctx, state, state.lastActiveState!, action, state.lastThinking, state.hiddenFromBottomCount ); } /** Main render function - creates the widget content */ function renderWithState( ctx: ExtensionContext, widgetState: WidgetState, snap: { outcome: string; interventions: SupervisorIntervention[] }, action: WidgetAction, lastThinking: string, hideFromBottom: number = 0 ): void { ctx.ui.setWidget(WIDGET_ID, (tui, theme) => { let actionStr: string; let thinking = lastThinking; switch (action.type) { case 'watching': actionStr = theme.fg('dim', 'watching'); break; case 'analyzing': actionStr = theme.fg('warning', '⟳ analyzing'); thinking = action.thinking ?? lastThinking; break; case 'steering': actionStr = theme.fg('warning', 'steering'); break; case 'done': actionStr = theme.fg('accent', '✓ done'); break; case 'waiting': actionStr = theme.fg('warning', `⏳ ${action.message}`); break; break; case 'inferring': actionStr = theme.fg('dim', 'scanning'); break; } const sep = theme.fg('dim', ' · '); let headerText: string; if (action.type === 'done') headerText = 'Supervised'; else if (action.type === 'inferring') headerText = 'Inferring'; else headerText = 'Supervising'; const header = `${theme.fg('accent', '◉')} ${theme.fg('accent', headerText)}`; const hasGoal = snap.outcome.length > 0; const goalLabel = hasGoal ? `${theme.fg('dim', 'Goal:')} ` : ''; const goalQuoteOpen = hasGoal ? theme.fg('muted', '"') : ''; const goalQuoteClose = hasGoal ? theme.fg('muted', '"') : ''; const steerCount = snap.interventions.length; const steers = steerCount > 0 ? theme.fg('dim', `↗ ${steerCount}`) : ''; const reframeTier = 'reframeTier' in action ? (action.reframeTier ?? 0) : 0; const reframeStr = reframeTier > 0 ? theme.fg('error', `↻${reframeTier}`) : ''; const suffixParts = [steers, reframeStr, actionStr].filter(Boolean); const thinkingPrefix = theme.fg('dim', ' '); const rawThinking = thinking; return { render: (width: number) => { const paddedWidth = Math.max(0, width - 1); widgetState.lastRenderedWidth = paddedWidth; const suffix = suffixParts.length > 0 ? sep + suffixParts.join(sep) : ''; const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, ''); let line: string; if (hasGoal) { const prefix = header + sep + goalLabel + goalQuoteOpen; const prefixWidth = stripAnsi(prefix).length; const suffixWidth = stripAnsi(suffix).length; const closeQuoteWidth = stripAnsi(goalQuoteClose).length; const availableForGoal = Math.max( 0, paddedWidth - prefixWidth - suffixWidth - closeQuoteWidth ); const rawGoal = snap.outcome.replace(/\r?\n/g, ' '); const truncatedGoal = truncateToWidth(rawGoal, availableForGoal); const goalText = theme.fg('muted', truncatedGoal); line = prefix + goalText + goalQuoteClose + suffix; } else { const parts = [header, ...suffixParts].filter(Boolean); line = parts.join(sep); } const l1 = truncateToWidth(line, paddedWidth); if (!rawThinking) { // No live thinking text — render from preserved lines for display/animation // (e.g. after the supervisor is done, lastThinking is empty but lastThinkingLines // holds the previously rendered lines for display/animation). if (widgetState.lastThinkingLines.length > 0) { const visibleCount = Math.max(0, widgetState.lastThinkingLines.length - hideFromBottom); const visibleLines = widgetState.lastThinkingLines .slice(0, visibleCount) .map((ln) => truncateToWidth(theme.fg('dim', ln), paddedWidth)); if (visibleCount === 0) return [l1]; return [l1, ...visibleLines]; } return [l1]; } const thinkingIndent = stripAnsi(thinkingPrefix).length; const maxContentWidth = Math.max(0, paddedWidth - thinkingIndent); const thinkingWords = rawThinking.replace(/[\r\n]+/g, ' ').split(' '); const thinkingLines: string[] = []; const plainLines: string[] = []; let currentThinkingLine = ''; let currentPlainLine = ''; for (const word of thinkingWords) { const testLine = currentPlainLine ? `${currentPlainLine} ${word}` : word; if (testLine.length <= maxContentWidth) { currentPlainLine = testLine; currentThinkingLine = currentThinkingLine ? `${currentThinkingLine} ${word}` : word; } else { if (currentThinkingLine) { thinkingLines.push( truncateToWidth(thinkingPrefix + theme.fg('dim', currentThinkingLine), paddedWidth) ); plainLines.push(' ' + currentPlainLine); } currentPlainLine = word; currentThinkingLine = word; } } if (currentThinkingLine) { thinkingLines.push( truncateToWidth(thinkingPrefix + theme.fg('dim', currentThinkingLine), paddedWidth) ); plainLines.push(' ' + currentPlainLine); } widgetState.lastThinkingLines = plainLines; return [l1, ...thinkingLines]; }, invalidate: () => {}, }; }); }