diff --git a/.beads/.local_version b/.beads/.local_version new file mode 100644 index 00000000..5c4503b7 --- /dev/null +++ b/.beads/.local_version @@ -0,0 +1 @@ +0.49.0 diff --git a/src/commands/run.test.ts b/src/commands/run.test.ts index 4385d126..33c43fb2 100644 --- a/src/commands/run.test.ts +++ b/src/commands/run.test.ts @@ -520,6 +520,7 @@ describe('conflict resolution helpers', () => { }); describe('keyboard handler behavior', () => { + // Contract tests: mirror expected key handling semantics without instantiating the TUI. test('escape key triggers abort callback', () => { let abortCalled = false; let panelHidden = false; diff --git a/src/commands/run.tsx b/src/commands/run.tsx index a1767f42..40d75f7d 100644 --- a/src/commands/run.tsx +++ b/src/commands/run.tsx @@ -43,7 +43,6 @@ import { } from '../session/index.js'; import { ExecutionEngine } from '../engine/index.js'; import { ParallelExecutor, analyzeTaskGraph, shouldRunParallel, recommendParallelism } from '../parallel/index.js'; -import { createAiResolver } from '../parallel/ai-resolver.js'; import type { WorkerDisplayState, MergeOperation, @@ -106,6 +105,80 @@ export function isSessionComplete( return parallelAllComplete ?? (tasksCompleted >= totalTasks); } +/** + * Check if the current directory is a git repository. + * Returns true if git repository, false otherwise. + */ +function isGitRepository(cwd: string): boolean { + try { + const result = spawnSync('git', ['rev-parse', '--git-dir'], { + cwd, + encoding: 'utf-8', + timeout: 5000, + }); + return result.status === 0; + } catch { + return false; + } +} + +/** + * Initialize git repository in the given directory. + * Returns true on success, false on failure. + */ +function initGitRepository(cwd: string): boolean { + try { + const result = spawnSync('git', ['init'], { + cwd, + encoding: 'utf-8', + timeout: 10000, + }); + return result.status === 0; + } catch { + return false; + } +} + +/** + * Prompt user about git repository initialization. + * Returns 'init' to initialize, 'continue' to proceed anyway, 'abort' to exit. + */ +async function promptGitInit(): Promise<'init' | 'continue' | 'abort'> { + console.log(''); + console.log('═══════════════════════════════════════════════════════════════'); + console.log(' No Git Repository Detected '); + console.log('═══════════════════════════════════════════════════════════════'); + console.log(''); + console.log(' ⚠️ An autonomous coding agent should work under version control.'); + console.log(''); + console.log(' Without git:'); + console.log(' • Changes cannot be reverted if something goes wrong'); + console.log(' • No audit trail of modifications'); + console.log(' • Some agent tools may not work correctly'); + console.log(''); + console.log(' Options:'); + console.log(' [Y] Initialize git repository (recommended)'); + console.log(' [N] Exit and initialize manually'); + console.log(' [C] Continue without git (not recommended)'); + console.log(''); + + const { createInterface } = await import('node:readline'); + return new Promise<'init' | 'continue' | 'abort'>((resolve) => { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + rl.question(' Your choice [Y/n/c]: ', (answer) => { + rl.close(); + const choice = answer.trim().toLowerCase(); + if (choice === '' || choice === 'y' || choice === 'yes') { + resolve('init'); + } else if (choice === 'c' || choice === 'continue') { + resolve('continue'); + } else { + resolve('abort'); + } + }); + }); +} + /** * Get git repository information for the current working directory. * Returns undefined values if not a git repository or git command fails. @@ -228,6 +301,35 @@ export function parseRunArgs(args: string[]): ExtendedRuntimeOptions { } break; + case '--review': + options.review = true; + break; + + case '--no-review': + options.review = false; + break; + + case '--review-agent': + if (nextArg && !nextArg.startsWith('-')) { + options.reviewAgent = nextArg; + i++; + } + break; + + case '--review-prompt': + if (nextArg && !nextArg.startsWith('-')) { + options.reviewPromptPath = nextArg; + i++; + } + break; + + case '--review-model': + if (nextArg && !nextArg.startsWith('-')) { + options.reviewModel = nextArg; + i++; + } + break; + case '--model': if (nextArg && !nextArg.startsWith('-')) { options.model = nextArg; @@ -456,6 +558,11 @@ Options: --epic Epic ID for beads tracker (if omitted, shows epic selection) --prd PRD file path (auto-switches to json tracker) --agent Override agent plugin (e.g., claude, opencode) + --review Enable reviewer stage before completing tasks + --no-review Disable reviewer stage + --review-agent Reviewer agent plugin/name (e.g., claude, codex) + --review-prompt Custom review prompt template path + --review-model Override reviewer model (agent-specific) --model Override model (e.g., opus, sonnet) --variant Model variant/reasoning effort (minimal, high, max) --tracker Override tracker plugin (e.g., beads, beads-bv, json) @@ -978,10 +1085,6 @@ interface RunAppWrapperProps { parallelConflictTaskTitle?: string; /** Whether AI conflict resolution is running */ parallelAiResolving?: boolean; - /** The file currently being resolved by AI */ - parallelCurrentlyResolvingFile?: string; - /** Whether to show the conflict panel (true during Phase 2 conflict resolution) */ - parallelShowConflicts?: boolean; /** Maps task IDs to worker IDs for output routing in parallel mode */ parallelTaskIdToWorkerId?: Map; /** Task IDs that completed locally but merge failed (shows ⚠ in TUI) */ @@ -1002,10 +1105,12 @@ interface RunAppWrapperProps { onParallelKill?: () => Promise; /** Callback to restart parallel execution after stop/complete */ onParallelStart?: () => void; - /** Callback when user requests conflict resolution retry */ - onConflictRetry?: () => Promise; - /** Callback when user requests to skip a failed merge */ - onConflictSkip?: () => void; + /** Callback to abort conflict resolution and rollback the merge */ + onConflictAbort?: () => Promise; + /** Callback to accept AI resolution for a specific file */ + onConflictAccept?: (filePath: string) => void; + /** Callback to accept all AI resolutions */ + onConflictAcceptAll?: () => void; } /** @@ -1043,8 +1148,6 @@ function RunAppWrapper({ parallelConflictTaskId, parallelConflictTaskTitle, parallelAiResolving, - parallelCurrentlyResolvingFile, - parallelShowConflicts, parallelTaskIdToWorkerId, parallelCompletedLocallyTaskIds, parallelAutoCommitSkippedTaskIds, @@ -1055,8 +1158,9 @@ function RunAppWrapper({ onParallelResume, onParallelKill, onParallelStart, - onConflictRetry, - onConflictSkip, + onConflictAbort, + onConflictAccept, + onConflictAcceptAll, }: RunAppWrapperProps) { const [showInterruptDialog, setShowInterruptDialog] = useState(false); const [storedConfig, setStoredConfig] = useState(initialStoredConfig); @@ -1245,8 +1349,6 @@ function RunAppWrapper({ parallelConflictTaskId={parallelConflictTaskId} parallelConflictTaskTitle={parallelConflictTaskTitle} parallelAiResolving={parallelAiResolving} - parallelCurrentlyResolvingFile={parallelCurrentlyResolvingFile} - parallelShowConflicts={parallelShowConflicts} parallelTaskIdToWorkerId={parallelTaskIdToWorkerId} parallelCompletedLocallyTaskIds={parallelCompletedLocallyTaskIds} parallelAutoCommitSkippedTaskIds={parallelAutoCommitSkippedTaskIds} @@ -1257,8 +1359,9 @@ function RunAppWrapper({ onParallelResume={onParallelResume} onParallelKill={onParallelKill} onParallelStart={onParallelStart} - onConflictRetry={onConflictRetry} - onConflictSkip={onConflictSkip} + onConflictAbort={onConflictAbort} + onConflictAccept={onConflictAccept} + onConflictAcceptAll={onConflictAcceptAll} /> ); } @@ -1314,7 +1417,7 @@ async function runWithTui( if (event.type === 'iteration:completed') { currentState = updateSessionAfterIteration(currentState, event.result); // If task was completed, remove it from active tasks - if (event.result.taskCompleted) { + if (event.result.taskCompleted || event.result.taskBlocked) { currentState = removeActiveTask(currentState, event.result.task.id); } savePersistedSession(currentState).catch(() => { @@ -1565,10 +1668,6 @@ async function runParallelWithTui( conflictTaskId: '', conflictTaskTitle: '', aiResolving: false, - /** The file currently being resolved by AI */ - currentlyResolvingFile: '' as string, - /** Whether to show the conflict panel (set true at Phase 2 start, false when resolved) */ - showConflicts: false, /** Maps task IDs to their assigned worker IDs for output routing */ taskIdToWorkerId: new Map(), /** Task IDs that completed locally but merge failed (shows ⚠ in TUI) */ @@ -1720,44 +1819,24 @@ async function runParallelWithTui( case 'conflict:ai-resolving': parallelState.aiResolving = true; - parallelState.currentlyResolvingFile = event.filePath; - // Show the conflict panel now that Phase 2 (resolution) has started - parallelState.showConflicts = true; break; case 'conflict:ai-resolved': parallelState.aiResolving = false; - parallelState.currentlyResolvingFile = ''; parallelState.conflictResolutions = [...parallelState.conflictResolutions, event.result]; break; case 'conflict:ai-failed': parallelState.aiResolving = false; - parallelState.currentlyResolvingFile = ''; break; - case 'conflict:resolved': { + case 'conflict:resolved': parallelState.conflicts = []; parallelState.conflictResolutions = event.results; parallelState.conflictTaskId = ''; parallelState.conflictTaskTitle = ''; parallelState.aiResolving = false; - parallelState.currentlyResolvingFile = ''; - // Hide the conflict panel now that resolution is complete - parallelState.showConflicts = false; - // Refresh merge queue to show updated status (conflicted -> completed) - parallelState.mergeQueue = [...parallelExecutor.getState().mergeQueue]; - // Task successfully merged after conflict resolution — update tracking sets - // Remove from completedLocally (no more ⚠ warning) and add to merged (shows ✓) - const resolvedSet = new Set(parallelState.completedLocallyTaskIds); - resolvedSet.delete(event.taskId); - parallelState.completedLocallyTaskIds = resolvedSet; - parallelState.mergedTaskIds = new Set([ - ...parallelState.mergedTaskIds, - event.taskId, - ]); break; - } case 'parallel:completed': currentState = completeSession(currentState); @@ -1884,8 +1963,6 @@ async function runParallelWithTui( parallelConflictTaskId={parallelState.conflictTaskId} parallelConflictTaskTitle={parallelState.conflictTaskTitle} parallelAiResolving={parallelState.aiResolving} - parallelCurrentlyResolvingFile={parallelState.currentlyResolvingFile} - parallelShowConflicts={parallelState.showConflicts} parallelTaskIdToWorkerId={parallelState.taskIdToWorkerId} parallelCompletedLocallyTaskIds={parallelState.completedLocallyTaskIds} parallelAutoCommitSkippedTaskIds={parallelState.autoCommitSkippedTaskIds} @@ -1910,25 +1987,42 @@ async function runParallelWithTui( triggerRerender?.(); }); }} - onConflictRetry={async () => { - // Re-attempt AI conflict resolution - parallelState.aiResolving = true; - triggerRerender?.(); + onConflictAbort={async () => { + // Stop the executor gracefully. Full cleanup (worktrees, git state) is + // guaranteed by execute()'s finally block which calls this.cleanup(). + // We clear UI conflict state in finally to ensure it runs even if stop() rejects. try { - await parallelExecutor.retryConflictResolution(); - // State updates handled by conflict:resolved event - } catch { - // Retry failed - state updates handled by conflict:ai-failed event + await parallelExecutor.stop(); } finally { - parallelState.aiResolving = false; + clearConflictState(parallelState); triggerRerender?.(); } }} - onConflictSkip={() => { - // Skip this failed merge and continue - parallelExecutor.skipFailedConflict(); - // Clear conflict state - clearConflictState(parallelState); + onConflictAccept={(filePath: string) => { + // Mark file as accepted - the AI resolution continues automatically + // This is primarily for user feedback; actual resolution is AI-driven + const resolution = findResolutionByPath(parallelState.conflictResolutions, filePath); + if (resolution?.success) { + // File already resolved by AI - nothing more to do + triggerRerender?.(); + return; + } + if (!resolution) { + parallelState.conflictResolutions = [ + ...parallelState.conflictResolutions, + { + filePath, + success: false, + method: 'ai', + error: 'No AI resolution available for this file', + }, + ]; + } + triggerRerender?.(); + }} + onConflictAcceptAll={() => { + // Accept all resolutions - let AI continue and close panel + // The AI resolution process continues automatically triggerRerender?.(); }} /> @@ -2044,6 +2138,9 @@ async function runHeadless( logger.taskCompleted(event.result.task.id, event.result.iteration); // Remove from active tasks currentState = removeActiveTask(currentState, event.result.task.id); + } else if (event.result.taskBlocked) { + // Remove from active tasks when review blocks progress + currentState = removeActiveTask(currentState, event.result.task.id); } // Save state after each iteration @@ -2378,6 +2475,42 @@ export async function executeRunCommand(args: string[]): Promise { console.warn(`Warning: ${warning}`); } + // Check for git repository (only in interactive TUI mode, skip for headless/CI) + if (!options.headless && process.stdin.isTTY) { + if (!isGitRepository(cwd)) { + const choice = await promptGitInit(); + + if (choice === 'init') { + console.log(''); + console.log('Initializing git repository...'); + if (initGitRepository(cwd)) { + console.log('✓ Git repository initialized successfully'); + console.log(''); + console.log('Consider creating an initial commit:'); + console.log(' git add .'); + console.log(' git commit -m "Initial commit"'); + } else { + console.error('✗ Failed to initialize git repository'); + console.error(' Please initialize git manually before continuing'); + process.exit(1); + } + } else if (choice === 'abort') { + console.log(''); + console.log('Please initialize git repository manually:'); + console.log(' git init'); + console.log(' git add .'); + console.log(' git commit -m "Initial commit"'); + console.log(''); + process.exit(0); + } else { + // Continue without git - show warning + console.log(''); + console.log('⚠️ Continuing without git repository (not recommended)'); + console.log(''); + } + } + } + // Show environment variable exclusion report upfront (using resolved agent config) const envReport = getEnvExclusionReport( process.env, @@ -2844,17 +2977,6 @@ export async function executeRunCommand(args: string[]): Promise { filteredTaskIds, }); - // Wire up AI conflict resolution if enabled (default: true) - const conflictResolutionEnabled = storedConfig?.conflictResolution?.enabled !== false; - if (conflictResolutionEnabled) { - // Pass conflict resolution config to RalphConfig for the resolver - const configWithConflictRes = { - ...config, - conflictResolution: storedConfig?.conflictResolution, - }; - parallelExecutor.setAiResolver(createAiResolver(configWithConflictRes)); - } - // Track session branch info for completion guidance let sessionBranchForGuidance: string | null = null; let originalBranchForGuidance: string | null = null; diff --git a/src/config/index.ts b/src/config/index.ts index d16f1396..6aea9884 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -196,6 +196,9 @@ function mergeConfigs(global: StoredConfig, project: StoredConfig): StoredConfig if (project.notifications !== undefined) { merged.notifications = { ...merged.notifications, ...project.notifications }; } + if (project.review !== undefined) { + merged.review = { ...merged.review, ...project.review }; + } return merged; } @@ -432,6 +435,32 @@ export function getDefaultAgentConfig( return undefined; } +/** + * Resolve a specific agent configuration by name or plugin id. + * Unlike getDefaultAgentConfig, this does not apply shorthand agentOptions. + */ +export function getAgentConfigByName( + storedConfig: StoredConfig, + agentName: string +): AgentPluginConfig | undefined { + const registry = getAgentRegistry(); + + const found = storedConfig.agents?.find( + (a) => a.name === agentName || a.plugin === agentName + ); + if (found) return found; + + if (registry.hasPlugin(agentName)) { + return { + name: `review-${agentName}`, + plugin: agentName, + options: {}, + }; + } + + return undefined; +} + /** * Get default tracker configuration based on available plugins */ @@ -584,6 +613,18 @@ export async function buildConfig( ...(options.sandbox ?? {}), }; + const reviewEnabled = + options.review ?? storedConfig.review?.enabled ?? false; + const reviewAgentName = options.reviewAgent ?? storedConfig.review?.agent; + const reviewModel = options.reviewModel ?? storedConfig.review?.model; + const reviewAgentConfig = reviewEnabled + ? (reviewAgentName + ? (reviewAgentName === agentConfig.name || reviewAgentName === agentConfig.plugin + ? agentConfig + : getAgentConfigByName(storedConfig, reviewAgentName)) + : agentConfig) + : undefined; + return { agent: agentConfig, tracker: trackerConfig, @@ -606,7 +647,14 @@ export async function buildConfig( sandbox, // CLI --prompt takes precedence over config file prompt_template promptTemplate: options.promptPath ?? storedConfig.prompt_template, + // CLI --review-prompt (no config file option - uses project/global template dirs) + reviewPromptPath: options.reviewPromptPath, autoCommit: storedConfig.autoCommit ?? false, + review: { + enabled: reviewEnabled, + agent: reviewAgentConfig, + model: reviewModel, + }, }; } @@ -641,6 +689,15 @@ export async function validateConfig( errors.push(`Tracker plugin '${config.tracker.plugin}' not found`); } + // Validate review agent if enabled + if (config.review?.enabled) { + if (!config.review.agent) { + errors.push('Review enabled but reviewer agent not configured or found'); + } else if (!agentRegistry.hasPlugin(config.review.agent.plugin)) { + errors.push(`Review agent plugin '${config.review.agent.plugin}' not found`); + } + } + // Validate tracker-specific requirements if ( config.tracker.plugin === 'beads' || diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index c35fc703..e492d0bf 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -534,6 +534,10 @@ describe('StoredConfigSchema', () => { expect(() => StoredConfigSchema.parse({ parallel: { maxWorkers: 33 } })).toThrow(); }); + test('validates parallel.maxWorkers is integer', () => { + expect(() => StoredConfigSchema.parse({ parallel: { maxWorkers: 2.5 } })).toThrow(); + }); + test('validates parallel.mode values', () => { expect(() => StoredConfigSchema.parse({ parallel: { mode: 'invalid' } })).toThrow(); }); diff --git a/src/config/schema.ts b/src/config/schema.ts index 73728090..3982e728 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -96,6 +96,16 @@ export const ConflictResolutionConfigSchema = z.object({ maxFiles: z.number().int().min(1).max(100).optional(), }); +/** + * Review configuration schema + */ +export const ReviewConfigSchema = z.object({ + enabled: z.boolean().optional(), + agent: z.string().optional(), + model: z.string().optional(), + prompt_template: z.string().optional(), +}); + /** * Agent plugin configuration schema */ @@ -218,6 +228,9 @@ export const StoredConfigSchema = z // Conflict resolution configuration for parallel execution conflictResolution: ConflictResolutionConfigSchema.optional(), + + // Review configuration + review: ReviewConfigSchema.optional(), }) .strict(); diff --git a/src/config/types.ts b/src/config/types.ts index d5e3472b..ddd3c843 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -62,6 +62,30 @@ export interface NotificationsConfig { sound?: NotificationSoundMode; } +/** + * Review configuration for optional reviewer agent stage. + */ +export interface ReviewConfig { + /** Whether review is enabled */ + enabled?: boolean; + /** Agent name or plugin id to use for review */ + agent?: string; + /** Model override for the reviewer agent */ + model?: string; +} + +/** + * Resolved review configuration used at runtime. + */ +export interface ReviewRuntimeConfig { + /** Whether review is enabled */ + enabled: boolean; + /** Resolved reviewer agent config (if enabled) */ + agent?: AgentPluginConfig; + /** Model override for the reviewer agent */ + model?: string; +} + export type SandboxMode = 'auto' | 'bwrap' | 'sandbox-exec' | 'off'; export interface SandboxConfig { @@ -184,6 +208,18 @@ export interface RuntimeOptions { /** Enable parallel execution, optionally with worker count (--parallel [N]) */ parallel?: number | boolean; + + /** Enable or disable review stage */ + review?: boolean; + + /** Override reviewer agent plugin/name */ + reviewAgent?: string; + + /** Custom review prompt template path */ + reviewPromptPath?: string; + + /** Override model for reviewer agent */ + reviewModel?: string; } /** @@ -295,6 +331,9 @@ export interface StoredConfig { /** Conflict resolution configuration for parallel execution */ conflictResolution?: ConflictResolutionConfig; + + /** Review configuration */ + review?: ReviewConfig; } /** @@ -342,6 +381,9 @@ export interface RalphConfig { /** Custom prompt template path (resolved) */ promptTemplate?: string; + /** Custom review prompt template path (from CLI --review-prompt flag only) */ + reviewPromptPath?: string; + /** Session ID for log file naming and tracking */ sessionId?: string; @@ -357,6 +399,9 @@ export interface RalphConfig { /** Conflict resolution configuration for parallel execution */ conflictResolution?: ConflictResolutionConfig; + + /** Optional reviewer stage configuration */ + review?: ReviewRuntimeConfig; } /** diff --git a/src/engine/index.ts b/src/engine/index.ts index 9e1d44bc..87e5ba78 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -45,13 +45,18 @@ import { updateSessionIteration, updateSessionStatus, updateSessionMaxIterations import { saveIterationLog, buildSubagentTrace, getRecentProgressSummary, getCodebasePatternsForPrompt } from '../logs/index.js'; import { performAutoCommit } from './auto-commit.js'; import type { AgentSwitchEntry } from '../logs/index.js'; -import { renderPrompt } from '../templates/index.js'; +import { renderPrompt, renderReviewPrompt } from '../templates/index.js'; /** - * Pattern to detect completion signal in agent output + * Pattern to detect completion signal in agent output. */ const PROMISE_COMPLETE_PATTERN = /\s*COMPLETE\s*<\/promise>/i; +/** + * Divider to separate reviewer output in logs. + */ +const REVIEW_OUTPUT_DIVIDER = '\n\n===== REVIEW OUTPUT =====\n'; + /** * Timeout for primary agent recovery test (5 seconds). * This is intentionally short to avoid delays when testing if the rate limit has lifted. @@ -76,7 +81,7 @@ async function buildPrompt( config: RalphConfig, tracker?: TrackerPlugin ): Promise { - // Load recent progress for context (last 5 iterations) + // Load progress summary limited to 5 iterations for context const recentProgress = await getRecentProgressSummary(config.cwd, 5); // Load codebase patterns from progress.md (if any exist) @@ -138,6 +143,42 @@ export interface WorkerModeOptions { forcedTask: TrackerTask; } +/** + * Build review prompt for the reviewer agent. + * Uses the unified template resolution hierarchy for review templates. + */ +async function buildReviewPrompt( + task: TrackerTask, + config: RalphConfig, + tracker: TrackerPlugin | null, + reviewPromptTemplate?: string +): Promise { + // Load progress summary limited to 5 iterations for context + const recentProgress = await getRecentProgressSummary(config.cwd, 5); + + // Load codebase patterns from progress.md (if any exist) + const codebasePatterns = await getCodebasePatternsForPrompt(config.cwd); + + // Get PRD context if the tracker supports it + const prdContext = await tracker?.getPrdContext?.(); + + const extendedContext = { + recentProgress, + codebasePatterns, + prd: prdContext ?? undefined, + }; + + // Use the unified review template resolution system + const result = renderReviewPrompt(task, config, reviewPromptTemplate, undefined, extendedContext); + + if (result.success && result.prompt) { + return result.prompt; + } + + // This should not happen since renderReviewPrompt always falls back to built-in + throw new Error(`Review template rendering failed: ${result.error}`); +} + /** * Execution engine for the agent loop */ @@ -165,6 +206,8 @@ export class ExecutionEngine { private rateLimitedAgents: Set = new Set(); /** Primary agent instance - preserved when switching to fallback for recovery attempts */ private primaryAgentInstance: AgentPlugin | null = null; + /** Reviewer agent instance (optional) */ + private reviewAgent: AgentPlugin | null = null; /** Track agent switches during the current iteration for logging */ private currentIterationAgentSwitches: AgentSwitchEntry[] = []; /** Forced task for worker mode — engine only works on this one task */ @@ -237,6 +280,29 @@ export class ExecutionEngine { // Store reference to primary agent for recovery attempts this.primaryAgentInstance = this.agent; + // Initialize reviewer agent if configured + if (this.config.review?.enabled) { + const reviewConfig = this.config.review.agent ?? this.config.agent; + const reviewInstance = await agentRegistry.getInstance(reviewConfig); + const reviewDetect = await reviewInstance.detect(); + if (!reviewDetect.available) { + throw new Error( + `Review agent '${reviewConfig.plugin}' not available: ${reviewDetect.error}` + ); + } + + // Validate review model if specified + const reviewModel = this.config.review.model ?? this.config.model; + if (reviewModel) { + const modelError = reviewInstance.validateModel(reviewModel); + if (modelError) { + throw new Error(`Review model validation failed: ${modelError}`); + } + } + + this.reviewAgent = reviewInstance; + } + // Initialize active agent state const now = new Date().toISOString(); this.state.activeAgent = { @@ -392,6 +458,54 @@ export class ExecutionEngine { }; } + /** + * Generate a preview of the review prompt that would be used for a task. + * Used by the TUI to show users what prompt will be sent to the reviewer agent. + */ + async generateReviewPromptPreview( + taskId: string + ): Promise<{ success: true; prompt: string; source: string } | { success: false; error: string }> { + if (!this.tracker) { + return { success: false, error: 'No tracker configured' }; + } + + // Get the task (include completed tasks so we can review prompts after execution) + const tasks = await this.tracker.getTasks({ status: ['open', 'in_progress', 'completed'] }); + const task = tasks.find((t) => t.id === taskId); + if (!task) { + return { success: false, error: `Task not found: ${taskId}` }; + } + + // Get recent progress summary for context + const recentProgress = await getRecentProgressSummary(this.config.cwd, 5); + + // Get codebase patterns from progress.md (if any exist) + const codebasePatterns = await getCodebasePatternsForPrompt(this.config.cwd); + + // Get PRD context if the tracker supports it + const prdContext = await this.tracker.getPrdContext?.(); + + // Build extended template context with PRD data and patterns + const extendedContext = { + recentProgress, + codebasePatterns, + prd: prdContext ?? undefined, + }; + + // Generate the review prompt using unified template resolution + const result = renderReviewPrompt(task, this.config, this.config.reviewPromptPath, undefined, extendedContext); + + if (!result.success || !result.prompt) { + return { success: false, error: result.error ?? 'Unknown error generating review prompt' }; + } + + return { + success: true, + prompt: result.prompt, + source: result.source ?? 'unknown', + }; + } + /** * Start the execution loop */ @@ -1080,12 +1194,188 @@ export class ExecutionEngine { // Check for completion signal const promiseComplete = PROMISE_COMPLETE_PATTERN.test(agentResult.stdout); - // Determine if task was completed + // Determine if worker completed (for review stage) // IMPORTANT: Only use the explicit COMPLETE signal. // Exit code 0 alone does NOT indicate task completion - an agent may exit // cleanly after asking clarification questions or hitting a blocker. // See: https://github.com/subsy/ralph-tui/issues/259 - const taskCompleted = promiseComplete; + const workerCompleted = promiseComplete; + + let reviewEnabled = false; + let reviewPassed: boolean | undefined; + let reviewAgentId: string | undefined; + let taskBlocked = false; + let reviewStdout = ''; + let reviewStderr = ''; + + if (workerCompleted && this.config.review?.enabled) { + reviewEnabled = true; + const reviewer = this.reviewAgent ?? this.agent!; + reviewAgentId = reviewer.meta.id; + + const reviewPrompt = await buildReviewPrompt( + task, + this.config, + this.tracker ?? null, + this.config.reviewPromptPath + ); + + const reviewFlags: string[] = []; + const reviewModel = + this.config.review?.model ?? + (reviewer.meta.id === this.agent?.meta.id ? this.config.model : undefined); + if (reviewModel) { + reviewFlags.push('--model', reviewModel); + } + + const supportsReviewTracing = reviewer.meta.supportsSubagentTracing; + const isReviewDroid = reviewer.meta.id === 'droid'; + const reviewJsonlParser = isReviewDroid ? createDroidStreamingJsonlParser() : null; + + this.emit({ + type: 'engine:warning', + timestamp: new Date().toISOString(), + code: 'review-start', + message: `Review stage starting (agent: ${reviewAgentId})`, + }); + + // Emit divider to live output stream so UI can split worker and reviewer in real-time + // Note: Don't add to this.state.currentOutput to avoid duplication in final combined log + this.emit({ + type: 'agent:output', + timestamp: new Date().toISOString(), + stream: 'stdout', + data: REVIEW_OUTPUT_DIVIDER, + iteration, + }); + + try { + const reviewHandle = reviewer.execute(reviewPrompt, [], { + cwd: this.config.cwd, + flags: reviewFlags, + sandbox: this.config.sandbox, + subagentTracing: supportsReviewTracing, + onJsonlMessage: (message: Record) => { + const part = message.part as Record | undefined; + if (message.type === 'tool_use' && part?.tool) { + const openCodeMessage = { + source: 'opencode' as const, + type: message.type as string, + timestamp: message.timestamp as number | undefined, + sessionID: message.sessionID as string | undefined, + part: part as import('../plugins/agents/opencode/outputParser.js').OpenCodePart, + raw: message, + }; + if (isOpenCodeTaskTool(openCodeMessage)) { + for (const claudeMessage of openCodeTaskToClaudeMessages(openCodeMessage)) { + this.subagentParser.processMessage(claudeMessage); + } + } + return; + } + + const claudeMessage: ClaudeJsonlMessage = { + type: message.type as string | undefined, + message: message.message as string | undefined, + tool: message.tool as { name?: string; input?: Record } | undefined, + result: message.result, + cost: message.cost as { inputTokens?: number; outputTokens?: number; totalUSD?: number } | undefined, + sessionId: message.sessionId as string | undefined, + raw: message, + }; + this.subagentParser.processMessage(claudeMessage); + }, + onStdout: (data) => { + this.state.currentOutput += data; + reviewStdout += data; + this.emit({ + type: 'agent:output', + timestamp: new Date().toISOString(), + stream: 'stdout', + data, + iteration, + }); + + if (reviewJsonlParser && isReviewDroid) { + const results = reviewJsonlParser.push(data); + for (const result of results) { + if (result.success) { + if (isDroidJsonlMessage(result.message)) { + for (const normalized of toClaudeJsonlMessages(result.message)) { + this.subagentParser.processMessage(normalized); + } + } else { + this.subagentParser.processMessage(result.message); + } + } + } + } + }, + onStderr: (data) => { + this.state.currentStderr += data; + reviewStderr += data; + this.emit({ + type: 'agent:output', + timestamp: new Date().toISOString(), + stream: 'stderr', + data, + iteration, + }); + }, + }); + + this.currentExecution = reviewHandle; + const reviewResult = await reviewHandle.promise; + this.currentExecution = null; + + if (reviewJsonlParser && isReviewDroid) { + const remaining = reviewJsonlParser.flush(); + for (const result of remaining) { + if (result.success) { + if (isDroidJsonlMessage(result.message)) { + for (const normalized of toClaudeJsonlMessages(result.message)) { + this.subagentParser.processMessage(normalized); + } + } else { + this.subagentParser.processMessage(result.message); + } + } + } + } + + reviewStdout = reviewResult.stdout; + reviewStderr = reviewResult.stderr; + reviewPassed = PROMISE_COMPLETE_PATTERN.test(reviewResult.stdout); + } catch (error) { + reviewPassed = false; + reviewStderr = + error instanceof Error ? error.message : String(error); + } + + if (!reviewPassed) { + taskBlocked = true; + this.skippedTasks.add(task.id); + await this.tracker!.updateTaskStatus(task.id, 'blocked'); + if (this.forcedTask?.id === task.id) { + this.forcedTaskProcessed = true; + } + this.emit({ + type: 'engine:warning', + timestamp: new Date().toISOString(), + code: 'review-blocked', + message: `Review failed; task ${task.id} blocked`, + }); + } + } + + // Determine if task was completed (requires review if enabled) + const taskCompleted = + workerCompleted && (!reviewEnabled || reviewPassed === true); + + // Clear rate-limited agents tracking on worker completion + if (workerCompleted) { + this.clearRateLimitedAgents(); + } // Update tracker if task completed // In worker mode (forcedTask set), skip tracker update — the ParallelExecutor @@ -1102,10 +1392,6 @@ export class ExecutionEngine { task, iteration, }); - - // Clear rate-limited agents tracking on task completion - // This allows agents to be retried for the next task - this.clearRateLimitedAgents(); } // Auto-commit after task completion (before iteration log is saved) @@ -1129,7 +1415,11 @@ export class ExecutionEngine { task, agentResult, taskCompleted, + taskBlocked: taskBlocked ? true : undefined, promiseComplete, + reviewEnabled: reviewEnabled ? true : undefined, + reviewPassed, + reviewAgent: reviewAgentId, durationMs, startedAt: startedAt.toISOString(), endedAt: endedAt.toISOString(), @@ -1143,9 +1433,31 @@ export class ExecutionEngine { events.length > 0 ? buildSubagentTrace(events, states) : undefined; // Build completion summary if agent switches occurred - const completionSummary = this.buildCompletionSummary(result); + let completionSummary = this.buildCompletionSummary(result); + if (reviewEnabled) { + const reviewSummary = reviewPassed + ? 'Review passed' + : 'Review failed (task blocked)'; + completionSummary = completionSummary + ? `${completionSummary}. ${reviewSummary}` + : reviewSummary; + } + + // Combine worker and reviewer output with divider (always include divider when review enabled) + // This ensures saved logs match the live stream and clearly indicate review ran + const combinedStdout = reviewEnabled + ? `${agentResult.stdout}${REVIEW_OUTPUT_DIVIDER}${reviewStdout}` + : agentResult.stdout; + const combinedStderr = reviewStderr + ? [agentResult.stderr, reviewStderr].filter((text) => text.trim().length > 0).join('\n\n') + : agentResult.stderr; + result.agentResult = { + ...agentResult, + stdout: combinedStdout, + stderr: combinedStderr, + }; - await saveIterationLog(this.config.cwd, result, agentResult.stdout, agentResult.stderr ?? this.state.currentStderr, { + await saveIterationLog(this.config.cwd, result, combinedStdout, combinedStderr ?? this.state.currentStderr, { config: this.config, sessionId: this.config.sessionId, subagentTrace, diff --git a/src/engine/types.ts b/src/engine/types.ts index 085a1f8c..0de083f0 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -171,9 +171,21 @@ export interface IterationResult { /** Whether the task was completed */ taskCompleted: boolean; + /** Whether the task was blocked by review */ + taskBlocked?: boolean; + /** Whether COMPLETE was detected */ promiseComplete: boolean; + /** Whether review stage was enabled for this iteration */ + reviewEnabled?: boolean; + + /** Whether reviewer approved the task */ + reviewPassed?: boolean; + + /** Reviewer agent plugin id */ + reviewAgent?: string; + /** Duration of the iteration in milliseconds */ durationMs: number; @@ -301,7 +313,7 @@ export interface EngineResumedEvent extends EngineEventBase { export interface EngineWarningEvent extends EngineEventBase { type: 'engine:warning'; /** Warning code for programmatic handling */ - code: 'sandbox-network-conflict'; + code: 'sandbox-network-conflict' | 'review-start' | 'review-blocked'; /** Human-readable warning message */ message: string; } diff --git a/src/plugins/agents/output-formatting.test.ts b/src/plugins/agents/output-formatting.test.ts index 220eb26a..b3cca15b 100644 --- a/src/plugins/agents/output-formatting.test.ts +++ b/src/plugins/agents/output-formatting.test.ts @@ -14,6 +14,7 @@ import { formatUrl, formatToolCall, processAgentEvents, + stripAnsiCodes, } from './output-formatting.js'; describe('COLORS', () => { @@ -366,3 +367,30 @@ describe('processAgentEvents', () => { expect(result).toBe('visible\n'); }); }); + +describe('stripAnsiCodes', () => { + test('removes valid ANSI escape sequences', () => { + const input = '\x1b[94mHello\x1b[0m World'; + expect(stripAnsiCodes(input)).toBe('Hello World'); + }); + + test('removes corrupted ANSI escape sequences (replacement character)', () => { + // ESC byte (\x1b) gets corrupted to U+FFFD (�) during encoding + const input = '\ufffd[?2027hHello\ufffd[0m World'; + expect(stripAnsiCodes(input)).toBe('Hello World'); + }); + + test('handles mixed valid and corrupted ANSI codes', () => { + const input = '\x1b[94mValid\x1b[0m \ufffd[?2027hCorrupted\ufffd[0m Text'; + expect(stripAnsiCodes(input)).toBe('Valid Corrupted Text'); + }); + + test('returns unchanged string when no ANSI codes present', () => { + const input = 'Plain text with [brackets] but no ANSI'; + expect(stripAnsiCodes(input)).toBe(input); + }); + + test('handles empty string', () => { + expect(stripAnsiCodes('')).toBe(''); + }); +}); diff --git a/src/plugins/agents/output-formatting.ts b/src/plugins/agents/output-formatting.ts index 705b741a..0634ce72 100644 --- a/src/plugins/agents/output-formatting.ts +++ b/src/plugins/agents/output-formatting.ts @@ -434,12 +434,15 @@ export function segmentsToPlainText(segments: FormattedSegment[]): string { * - CSI sequences: ESC[...letter (colors, cursor, etc.) * - OSC sequences: ESC]...BEL (window title, etc.) * - Charset switching: ESC(A, ESC)B, etc. + * - Corrupted sequences: �[...letter (where ESC was corrupted to U+FFFD) * * Uses RegExp constructor to avoid embedded control characters in source. */ const ANSI_REGEX = new RegExp( // CSI sequences: ESC[...letter | OSC sequences: ESC]...BEL | Charset: ESC(/)AB012 - '\\x1b\\[[0-9;?]*[a-zA-Z]|\\x1b\\][^\\x07]*\\x07|\\x1b[()][AB012]', + '\\x1b\\[[0-9;?]*[a-zA-Z]|\\x1b\\][^\\x07]*\\x07|\\x1b[()][AB012]|' + + // Corrupted ANSI codes (ESC byte replaced with U+FFFD replacement character) + '\ufffd\\[[0-9;?]*[a-zA-Z]', 'g' ); diff --git a/src/session/persistence.ts b/src/session/persistence.ts index 101c7c23..15c91ecf 100644 --- a/src/session/persistence.ts +++ b/src/session/persistence.ts @@ -138,6 +138,9 @@ export interface PersistedIterationResult { /** Whether the task was completed */ taskCompleted: boolean; + /** Whether the task was blocked by review */ + taskBlocked?: boolean; + /** Duration in milliseconds */ durationMs: number; @@ -357,6 +360,7 @@ export function updateSessionAfterIteration( taskId: result.task.id, taskTitle: result.task.title, taskCompleted: result.taskCompleted, + taskBlocked: result.taskBlocked, durationMs: result.durationMs, error: result.error, startedAt: result.startedAt, diff --git a/src/setup/types.ts b/src/setup/types.ts index f4e643a8..dfe60b44 100644 --- a/src/setup/types.ts +++ b/src/setup/types.ts @@ -47,6 +47,12 @@ export interface SetupAnswers { /** Agent-specific options */ agentOptions: Record; + /** Whether reviewer stage is enabled */ + reviewEnabled: boolean; + + /** Reviewer agent plugin ID (optional) */ + reviewAgent?: string; + /** Maximum iterations per run (0 = unlimited) */ maxIterations: number; diff --git a/src/setup/wizard.test.ts b/src/setup/wizard.test.ts index daccf204..c053d350 100644 --- a/src/setup/wizard.test.ts +++ b/src/setup/wizard.test.ts @@ -233,7 +233,12 @@ describe('runSetupWizard', () => { }); // Set default mock implementations - mockPromptSelect = () => Promise.resolve('json'); + mockPromptSelect = (prompt) => { + if (prompt.includes('tracker')) return Promise.resolve('json'); + if (prompt.includes('reviewer')) return Promise.resolve('none'); + if (prompt.includes('agent')) return Promise.resolve('claude'); + return Promise.resolve('none'); + }; mockPromptNumber = () => Promise.resolve(10); mockPromptBoolean = () => Promise.resolve(false); mockIsInteractiveTerminal = () => true; @@ -259,8 +264,9 @@ describe('runSetupWizard', () => { test('creates config file in .ralph-tui directory', async () => { mockPromptSelect = (prompt) => { if (prompt.includes('tracker')) return Promise.resolve('json'); + if (prompt.includes('reviewer')) return Promise.resolve('none'); if (prompt.includes('agent')) return Promise.resolve('claude'); - return Promise.resolve(''); + return Promise.resolve('none'); }; const result = await runSetupWizard({ cwd: tempDir }); @@ -276,8 +282,9 @@ describe('runSetupWizard', () => { test('saves correct tracker and agent in config', async () => { mockPromptSelect = (prompt) => { if (prompt.includes('tracker')) return Promise.resolve('beads'); + if (prompt.includes('reviewer')) return Promise.resolve('none'); if (prompt.includes('agent')) return Promise.resolve('claude'); - return Promise.resolve(''); + return Promise.resolve('none'); }; mockPromptNumber = () => Promise.resolve(20); mockPromptBoolean = () => Promise.resolve(true); @@ -302,8 +309,9 @@ describe('runSetupWizard', () => { test('shows PRD-specific instructions for json tracker', async () => { mockPromptSelect = (prompt) => { if (prompt.includes('tracker')) return Promise.resolve('json'); + if (prompt.includes('reviewer')) return Promise.resolve('none'); if (prompt.includes('agent')) return Promise.resolve('claude'); - return Promise.resolve(''); + return Promise.resolve('none'); }; await runSetupWizard({ cwd: tempDir }); @@ -317,8 +325,9 @@ describe('runSetupWizard', () => { test('shows epic-specific instructions for beads tracker', async () => { mockPromptSelect = (prompt) => { if (prompt.includes('tracker')) return Promise.resolve('beads'); + if (prompt.includes('reviewer')) return Promise.resolve('none'); if (prompt.includes('agent')) return Promise.resolve('claude'); - return Promise.resolve(''); + return Promise.resolve('none'); }; await runSetupWizard({ cwd: tempDir }); @@ -335,8 +344,9 @@ describe('runSetupWizard', () => { test('shows epic-specific instructions for beads-bv tracker', async () => { mockPromptSelect = (prompt) => { if (prompt.includes('tracker')) return Promise.resolve('beads-bv'); + if (prompt.includes('reviewer')) return Promise.resolve('none'); if (prompt.includes('agent')) return Promise.resolve('claude'); - return Promise.resolve(''); + return Promise.resolve('none'); }; await runSetupWizard({ cwd: tempDir }); @@ -352,8 +362,9 @@ describe('runSetupWizard', () => { test('shows epic-specific instructions for beads-rust tracker', async () => { mockPromptSelect = (prompt) => { if (prompt.includes('tracker')) return Promise.resolve('beads-rust'); + if (prompt.includes('reviewer')) return Promise.resolve('none'); if (prompt.includes('agent')) return Promise.resolve('claude'); - return Promise.resolve(''); + return Promise.resolve('none'); }; await runSetupWizard({ cwd: tempDir }); @@ -387,8 +398,9 @@ describe('runSetupWizard', () => { mockPromptSelect = (prompt) => { if (prompt.includes('tracker')) return Promise.resolve('json'); + if (prompt.includes('reviewer')) return Promise.resolve('none'); if (prompt.includes('agent')) return Promise.resolve('claude'); - return Promise.resolve(''); + return Promise.resolve('none'); }; const result = await runSetupWizard({ cwd: tempDir, force: true }); @@ -404,8 +416,9 @@ describe('runSetupWizard', () => { test('config file has header comment', async () => { mockPromptSelect = (prompt) => { if (prompt.includes('tracker')) return Promise.resolve('json'); + if (prompt.includes('reviewer')) return Promise.resolve('none'); if (prompt.includes('agent')) return Promise.resolve('claude'); - return Promise.resolve(''); + return Promise.resolve('none'); }; const result = await runSetupWizard({ cwd: tempDir }); @@ -424,7 +437,12 @@ describe('checkAndRunSetup', () => { tempDir = await createTempDir(); // Set default mock implementations - mockPromptSelect = () => Promise.resolve('json'); + mockPromptSelect = (prompt) => { + if (prompt.includes('tracker')) return Promise.resolve('json'); + if (prompt.includes('reviewer')) return Promise.resolve('none'); + if (prompt.includes('agent')) return Promise.resolve('claude'); + return Promise.resolve('none'); + }; mockPromptNumber = () => Promise.resolve(10); mockPromptBoolean = () => Promise.resolve(false); mockIsInteractiveTerminal = () => true; @@ -458,8 +476,9 @@ describe('checkAndRunSetup', () => { test('runs wizard when no config exists', async () => { mockPromptSelect = (prompt) => { if (prompt.includes('tracker')) return Promise.resolve('json'); + if (prompt.includes('reviewer')) return Promise.resolve('none'); if (prompt.includes('agent')) return Promise.resolve('claude'); - return Promise.resolve(''); + return Promise.resolve('none'); }; const result = await checkAndRunSetup({ cwd: tempDir }); @@ -488,7 +507,12 @@ describe('wizard output messages', () => { capturedOutput.push(args.join(' ')); }); - mockPromptSelect = () => Promise.resolve('json'); + mockPromptSelect = (prompt) => { + if (prompt.includes('tracker')) return Promise.resolve('json'); + if (prompt.includes('reviewer')) return Promise.resolve('none'); + if (prompt.includes('agent')) return Promise.resolve('claude'); + return Promise.resolve('none'); + }; mockPromptNumber = () => Promise.resolve(10); mockPromptBoolean = () => Promise.resolve(false); mockIsInteractiveTerminal = () => true; @@ -502,8 +526,9 @@ describe('wizard output messages', () => { test('prints welcome banner', async () => { mockPromptSelect = (prompt) => { if (prompt.includes('tracker')) return Promise.resolve('json'); + if (prompt.includes('reviewer')) return Promise.resolve('none'); if (prompt.includes('agent')) return Promise.resolve('claude'); - return Promise.resolve(''); + return Promise.resolve('none'); }; await runSetupWizard({ cwd: tempDir }); @@ -515,8 +540,9 @@ describe('wizard output messages', () => { test('mentions config show command', async () => { mockPromptSelect = (prompt) => { if (prompt.includes('tracker')) return Promise.resolve('json'); + if (prompt.includes('reviewer')) return Promise.resolve('none'); if (prompt.includes('agent')) return Promise.resolve('claude'); - return Promise.resolve(''); + return Promise.resolve('none'); }; await runSetupWizard({ cwd: tempDir }); @@ -533,8 +559,9 @@ describe('wizard output messages', () => { mockPromptSelect = (prompt) => { if (prompt.includes('tracker')) return Promise.resolve('json'); + if (prompt.includes('reviewer')) return Promise.resolve('none'); if (prompt.includes('agent')) return Promise.resolve('claude'); - return Promise.resolve(''); + return Promise.resolve('none'); }; mockPromptBoolean = () => Promise.resolve(true); @@ -553,8 +580,9 @@ describe('wizard output messages', () => { mockPromptSelect = (prompt) => { if (prompt.includes('tracker')) return Promise.resolve('json'); + if (prompt.includes('reviewer')) return Promise.resolve('none'); if (prompt.includes('agent')) return Promise.resolve('claude'); - return Promise.resolve(''); + return Promise.resolve('none'); }; mockPromptBoolean = () => Promise.resolve(true); @@ -599,8 +627,9 @@ describe('tracker detection and unavailability', () => { capturedTrackerChoices = choices as typeof capturedTrackerChoices; return Promise.resolve('json'); } + if (prompt.includes('reviewer')) return Promise.resolve('none'); if (prompt.includes('agent')) return Promise.resolve('claude'); - return Promise.resolve(''); + return Promise.resolve('none'); }; await runSetupWizard({ cwd: tempDir }); @@ -625,8 +654,9 @@ describe('tracker detection and unavailability', () => { capturedTrackerChoices = choices as typeof capturedTrackerChoices; return Promise.resolve('json'); } + if (prompt.includes('reviewer')) return Promise.resolve('none'); if (prompt.includes('agent')) return Promise.resolve('claude'); - return Promise.resolve(''); + return Promise.resolve('none'); }; await runSetupWizard({ cwd: tempDir }); @@ -642,8 +672,9 @@ describe('tracker detection and unavailability', () => { capturedTrackerChoices = choices as typeof capturedTrackerChoices; return Promise.resolve('json'); } + if (prompt.includes('reviewer')) return Promise.resolve('none'); if (prompt.includes('agent')) return Promise.resolve('claude'); - return Promise.resolve(''); + return Promise.resolve('none'); }; await runSetupWizard({ cwd: tempDir }); @@ -667,8 +698,9 @@ describe('tracker detection and unavailability', () => { capturedTrackerChoices = choices as typeof capturedTrackerChoices; return Promise.resolve('json'); } + if (prompt.includes('reviewer')) return Promise.resolve('none'); if (prompt.includes('agent')) return Promise.resolve('claude'); - return Promise.resolve(''); + return Promise.resolve('none'); }; await runSetupWizard({ cwd: tempDir }); @@ -698,8 +730,9 @@ describe('tracker detection and unavailability', () => { capturedTrackerChoices = choices as typeof capturedTrackerChoices; return Promise.resolve('json'); } + if (prompt.includes('reviewer')) return Promise.resolve('none'); if (prompt.includes('agent')) return Promise.resolve('claude'); - return Promise.resolve(''); + return Promise.resolve('none'); }; await runSetupWizard({ cwd: tempDir }); @@ -725,8 +758,9 @@ describe('tracker detection and unavailability', () => { trackerDefault = (options as { default?: string })?.default; return Promise.resolve('json'); } + if (prompt.includes('reviewer')) return Promise.resolve('none'); if (prompt.includes('agent')) return Promise.resolve('claude'); - return Promise.resolve(''); + return Promise.resolve('none'); }; await runSetupWizard({ cwd: tempDir }); diff --git a/src/setup/wizard.ts b/src/setup/wizard.ts index 73f42417..f4a7b785 100644 --- a/src/setup/wizard.ts +++ b/src/setup/wizard.ts @@ -238,6 +238,14 @@ async function saveConfig( trackerOptions: answers.trackerOptions, agent: answers.agent, agentOptions: answers.agentOptions, + ...(answers.reviewEnabled + ? { + review: { + enabled: true, + agent: answers.reviewAgent, + }, + } + : {}), maxIterations: answers.maxIterations, autoCommit: answers.autoCommit, }; @@ -349,7 +357,7 @@ export async function runSetupWizard( const trackerOptions = await collectTrackerOptions(selectedTracker); // === Step 2: Select Agent === - printSection('Agent CLI Selection'); + printSection('Worker Agent Selection'); const agentPlugins = await detectAgentPlugins(); if (agentPlugins.length === 0) { @@ -378,11 +386,11 @@ export async function runSetupWizard( console.log(); const selectedAgent = await promptSelect( - 'Which agent CLI do you want to use?', + 'Which worker agent do you want to use for tasks?', agentChoices, { default: defaultAgent, - help: 'The AI agent that will execute coding tasks.', + help: 'Worker agent executes coding tasks.', } ); @@ -391,7 +399,37 @@ export async function runSetupWizard( // They can be configured later via config file const agentOptions: Record = {}; - // === Step 3: Iteration Settings === + // === Step 3: Reviewer Selection === + printSection('Reviewer Agent Selection'); + + const reviewerChoices = [ + { + value: 'none', + label: 'None (disable review)', + description: 'Skip the reviewer stage after task completion', + }, + ...agentPlugins.map((p) => ({ + value: p.id, + label: `${p.name}${p.available ? ` (v${p.version})` : ''}`, + description: p.available + ? p.description + : `${p.description} (not detected: ${p.error})`, + })), + ]; + + const selectedReviewer = await promptSelect( + 'Which reviewer agent do you want to use?', + reviewerChoices, + { + default: 'none', + help: 'Reviewer runs after each task. Choose "none" to disable.', + } + ); + + const reviewEnabled = selectedReviewer !== 'none'; + const reviewAgent = reviewEnabled ? selectedReviewer : undefined; + + // === Step 4: Iteration Settings === printSection('Iteration Settings'); const maxIterations = await promptNumber( @@ -412,7 +450,7 @@ export async function runSetupWizard( } ); - // === Step 4: Skills Installation === + // === Step 5: Skills Installation === printSection('AI Skills Installation'); // Get the selected agent's skills paths from the registry @@ -472,6 +510,8 @@ export async function runSetupWizard( trackerOptions, agent: selectedAgent, agentOptions, + reviewEnabled, + reviewAgent, maxIterations, autoCommit, }; diff --git a/src/templates/builtin.ts b/src/templates/builtin.ts index 8ab986c7..1280f1d5 100644 --- a/src/templates/builtin.ts +++ b/src/templates/builtin.ts @@ -400,3 +400,51 @@ If you discovered a **reusable pattern**, also add it to the \`## Codebase Patte When finished (or if already complete), signal completion with: COMPLETE `; + +/** + * Review template - used by the reviewer agent to evaluate worker output. + * Context-first structure: PRD → Patterns → Progress → Task → Review Instructions + */ +export const REVIEW_TEMPLATE = `## Review Task +**ID**: {{taskId}} +**Title**: {{taskTitle}} + +{{#if taskDescription}} +## Description +{{taskDescription}} +{{/if}} + +{{#if acceptanceCriteria}} +## Acceptance Criteria +{{acceptanceCriteria}} +{{/if}} + +{{#if dependsOn}} +**Dependencies**: {{dependsOn}} +{{/if}} + +{{#if prdContent}} +## PRD: {{prdName}} +{{#if prdDescription}} +{{prdDescription}} +{{/if}} + +### Progress +{{prdCompletedCount}}/{{prdTotalCount}} tasks complete +{{/if}} + +{{#if codebasePatterns}} +## Codebase Patterns (Study These First) +{{codebasePatterns}} +{{/if}} + +{{#if recentProgress}} +## Recent Progress +{{recentProgress}} +{{/if}} + +## Review Instructions +Review the changes for correctness, tests, and style. +If everything is acceptable, respond with: COMPLETE +If issues remain, explain them clearly and do NOT output the completion token. +`; diff --git a/src/templates/engine.ts b/src/templates/engine.ts index dc7b3213..71947d60 100644 --- a/src/templates/engine.ts +++ b/src/templates/engine.ts @@ -22,6 +22,7 @@ import { BEADS_RUST_TEMPLATE, BEADS_BV_TEMPLATE, JSON_TEMPLATE, + REVIEW_TEMPLATE, } from './builtin.js'; /** @@ -109,6 +110,91 @@ export function getGlobalTemplatePath(trackerType: BuiltinTemplateType): string } +/** + * Load a review template from a custom path or fall back through the resolution hierarchy. + * + * Resolution order: + * 1. customPath (explicit --review-prompt argument or config file review.prompt_template) + * 2. Project: ./.ralph-tui/templates/review.hbs (project-level customization) + * 3. Global: ~/.config/ralph-tui/templates/review.hbs (user-level customization) + * 4. Built-in review template (bundled default - final fallback) + * + * @param customPath Optional path to custom review template + * @param cwd Working directory for relative path resolution + * @returns The template load result + */ +export function loadReviewTemplate( + customPath: string | undefined, + cwd: string +): TemplateLoadResult { + // 1. Try explicit custom template first (from --review-prompt or config) + if (customPath) { + const resolvedPath = path.isAbsolute(customPath) + ? customPath + : path.resolve(cwd, customPath); + + try { + if (fs.existsSync(resolvedPath)) { + const content = fs.readFileSync(resolvedPath, 'utf-8'); + return { + success: true, + content, + source: resolvedPath, + }; + } else { + return { + success: false, + source: resolvedPath, + error: `Review template file not found: ${resolvedPath}`, + }; + } + } catch (error) { + return { + success: false, + source: resolvedPath, + error: `Failed to read review template: ${error instanceof Error ? error.message : String(error)}`, + }; + } + } + + // 2. Try project-level template: ./.ralph-tui/templates/review.hbs + const projectTemplatePath = path.join(cwd, '.ralph-tui', 'templates', 'review.hbs'); + try { + if (fs.existsSync(projectTemplatePath)) { + const content = fs.readFileSync(projectTemplatePath, 'utf-8'); + return { + success: true, + content, + source: `project:${projectTemplatePath}`, + }; + } + } catch { + // Silently fall through to next level + } + + // 3. Try global template: ~/.config/ralph-tui/templates/review.hbs + const globalTemplatePath = path.join(getUserConfigDir(), 'templates', 'review.hbs'); + try { + if (fs.existsSync(globalTemplatePath)) { + const content = fs.readFileSync(globalTemplatePath, 'utf-8'); + return { + success: true, + content, + source: `global:${globalTemplatePath}`, + }; + } + } catch { + // Silently fall through to built-in + } + + // 4. Fallback to built-in review template (final fallback) + return { + success: true, + content: REVIEW_TEMPLATE, + source: 'builtin:review', + }; +} + /** * Load a template from a custom path or fall back through the resolution hierarchy. * @@ -501,6 +587,63 @@ export function renderPrompt( } } +/** + * Render a review prompt from a template and task context. + * Uses the review template resolution hierarchy instead of worker templates. + * @param task The current task + * @param config The ralph configuration + * @param reviewPromptPath Optional custom review template path (from CLI or config) + * @param epic Optional epic information + * @param extended Extended context with progress, patterns, PRD data + * @returns The render result with the prompt or error + */ +export function renderReviewPrompt( + task: TrackerTask, + config: RalphConfig, + reviewPromptPath: string | undefined, + epic?: { id: string; title: string; description?: string }, + extended?: string | ExtendedTemplateContext +): TemplateRenderResult { + // Load the review template using the unified resolution hierarchy + const loadResult = loadReviewTemplate(reviewPromptPath, config.cwd); + if (!loadResult.success || !loadResult.content) { + return { + success: false, + error: loadResult.error ?? 'Failed to load review template', + source: loadResult.source, + }; + } + + // Build context + const context = buildTemplateContext(task, config, epic, extended); + + // Create a flat context for Handlebars (variables at top level) + const flatContext = { + ...context.vars, + task: context.task, + config: context.config, + epic: context.epic, + }; + + try { + // Compile and render + const template = compileTemplate(loadResult.content, loadResult.source); + const prompt = template(flatContext); + + return { + success: true, + prompt: prompt.trim(), + source: loadResult.source, + }; + } catch (error) { + return { + success: false, + error: `Review template rendering failed: ${error instanceof Error ? error.message : String(error)}`, + source: loadResult.source, + }; + } +} + /** * Clear the template cache (useful for testing or when templates change). */ diff --git a/src/templates/index.ts b/src/templates/index.ts index 7b657275..c087f993 100644 --- a/src/templates/index.ts +++ b/src/templates/index.ts @@ -14,7 +14,9 @@ export type { export { renderPrompt, + renderReviewPrompt, loadTemplate, + loadReviewTemplate, buildTemplateVariables, buildTemplateContext, getBuiltinTemplate, @@ -34,4 +36,5 @@ export { BEADS_TEMPLATE, BEADS_BV_TEMPLATE, JSON_TEMPLATE, + REVIEW_TEMPLATE, } from './builtin.js'; diff --git a/src/tui/components/Header.tsx b/src/tui/components/Header.tsx index 01d42380..9a13cf2a 100644 --- a/src/tui/components/Header.tsx +++ b/src/tui/components/Header.tsx @@ -171,6 +171,7 @@ export function Header({ completedTasks = 0, totalTasks = 0, agentName, + reviewerAgent, trackerName, activeAgentState, rateLimitState, @@ -186,6 +187,11 @@ export function Header({ // Get agent display info including fallback status and status line message const agentDisplay = getAgentDisplay(agentName, activeAgentState, rateLimitState); + // Build agent display string with worker/reviewer split if reviewer is configured + const agentDisplayString = reviewerAgent + ? `worker: ${agentDisplay.displayName || agentName || 'agent'} | reviewer: ${reviewerAgent}` + : agentDisplay.displayName; + // Parse model info for display const modelDisplay = currentModel ? (() => { @@ -255,19 +261,19 @@ export function Header({ {/* Right section: Agent/Tracker + Model + Sandbox + Progress (X/Y) with mini bar + elapsed time */} {/* Agent, model, tracker, and sandbox indicators */} - {(agentDisplay.displayName || trackerName || modelDisplay || sandboxDisplay) && ( + {(agentDisplayString || trackerName || modelDisplay || sandboxDisplay) && ( {agentDisplay.showRateLimitIcon && ( {RATE_LIMIT_ICON} )} - {agentDisplay.displayName && ( - {agentDisplay.displayName} + {agentDisplayString && ( + {agentDisplayString} )} - {agentDisplay.displayName && (trackerName || modelDisplay || sandboxDisplay) && | } + {agentDisplayString && (trackerName || modelDisplay || sandboxDisplay) && | } {modelDisplay && ( {modelDisplay.display} )} - {(agentDisplay.displayName || modelDisplay) && (trackerName || sandboxDisplay) && | } + {(agentDisplayString || modelDisplay) && (trackerName || sandboxDisplay) && | } {trackerName && {trackerName}} {trackerName && sandboxDisplay && | } {sandboxDisplay && ( diff --git a/src/tui/components/IterationDetailView.tsx b/src/tui/components/IterationDetailView.tsx index 8f877c80..491b1ea1 100644 --- a/src/tui/components/IterationDetailView.tsx +++ b/src/tui/components/IterationDetailView.tsx @@ -2,15 +2,22 @@ * ABOUTME: IterationDetailView component for the Ralph TUI. * Displays detailed information about a single iteration including * status, timing, events timeline, subagent tree, and scrollable agent output with syntax highlighting. + * Supports split worker/reviewer output sections with Tab key navigation. */ import type { ReactNode } from 'react'; -import { useState } from 'react'; +import { useState, useEffect, useCallback } from 'react'; +import { useKeyboard } from '@opentui/react'; import { colors, formatElapsedTime } from '../theme.js'; import type { IterationResult, IterationStatus, EngineSubagentStatus } from '../../engine/types.js'; import type { SubagentHierarchyNode, SubagentTraceStats } from '../../logs/types.js'; import type { SandboxConfig, SandboxMode } from '../../config/types.js'; +/** + * Divider to separate reviewer output in logs (matches engine constant). + */ +const REVIEW_OUTPUT_DIVIDER = '\n\n===== REVIEW OUTPUT =====\n'; + /** * Event in the iteration timeline */ @@ -41,6 +48,11 @@ export interface HistoricExecutionContext { sandboxNetwork?: boolean; } +/** + * Focus mode for output sections when split into worker/reviewer + */ +type OutputFocus = 'worker' | 'reviewer'; + /** * Props for the IterationDetailView component */ @@ -67,6 +79,8 @@ export interface IterationDetailViewProps { resolvedSandboxMode?: Exclude; /** Historic execution context from persisted logs - used for completed iterations */ historicContext?: HistoricExecutionContext; + /** Worker agent name for split output display */ + workerAgent?: string; } /** @@ -138,7 +152,7 @@ function buildTimeline(result: IterationResult): TimelineEvent[] { timestamp: result.endedAt, type: 'task_completed', description: result.promiseComplete - ? 'Task marked complete (COMPLETE detected)' + ? 'Task marked complete (completion signal detected)' : 'Task marked complete', }); } @@ -606,14 +620,44 @@ export function IterationDetailView({ sandboxConfig, resolvedSandboxMode, historicContext, + workerAgent, }: IterationDetailViewProps): ReactNode { const statusColor = statusColors[iteration.status]; const statusIndicator = statusIndicators[iteration.status]; const timeline = buildTimeline(iteration); const durationSeconds = Math.floor(iteration.durationMs / 1000); - // Get agent output - const agentOutput = iteration.agentResult?.stdout ?? ''; + // Split worker and reviewer outputs + const fullOutput = iteration.agentResult?.stdout ?? ''; + const dividerIndex = + iteration.reviewEnabled ? fullOutput.indexOf(REVIEW_OUTPUT_DIVIDER) : -1; + const hasReviewOutput = dividerIndex !== -1; + const workerOutput = hasReviewOutput + ? fullOutput.slice(0, dividerIndex) + : fullOutput; + const reviewerOutput = hasReviewOutput + ? fullOutput.slice(dividerIndex + REVIEW_OUTPUT_DIVIDER.length) + : ''; + + // Track which output section has focus for Tab navigation + const [outputFocus, setOutputFocus] = useState('worker'); + + // Handle Tab key to switch between worker and reviewer outputs + const handleKeyboard = useCallback( + (key: { name: string }) => { + if (hasReviewOutput && key.name === 'tab') { + setOutputFocus((prev) => (prev === 'worker' ? 'reviewer' : 'worker')); + } + }, + [hasReviewOutput] + ); + + useKeyboard(handleKeyboard); + + // Reset focus when iteration changes + useEffect(() => { + setOutputFocus('worker'); + }, [iteration.iteration]); // Generate output file path const outputFilePath = getOutputFilePath( @@ -635,21 +679,27 @@ export function IterationDetailView({ }} > - {/* Iteration header */} + {/* Task title */} {statusIndicator} - - {' '}Iteration {iteration.iteration} of {totalIterations} - + {iteration.task.title} + ({iteration.task.id}) - {/* Task info */} + {/* Timing info with iteration number */} - Task: - {iteration.task.id} - - {iteration.task.title} + + Started: + {formatTimestamp(iteration.startedAt)} + Ended: + {formatTimestamp(iteration.endedAt)} + Duration: + {formatElapsedTime(durationSeconds)} + Iteration: + {iteration.iteration}/{totalIterations} + {/* Dependencies section - shows blocking relationships */} @@ -701,21 +751,6 @@ export function IterationDetailView({ value={statusLabels[iteration.status]} valueColor={statusColor} /> - - - {iteration.taskCompleted && ( )} + {iteration.reviewEnabled && ( + + )} + {iteration.reviewAgent && ( + + )} + {iteration.taskBlocked && ( + + )} {iteration.error && ( - {/* Agent output section */} - {agentOutput && ( - - - - {renderOutputWithHighlighting(agentOutput)} + {/* Agent output section(s) - split into worker and reviewer if review enabled */} + {hasReviewOutput ? ( + <> + {/* Worker output section - always show when split layout active */} + + + + {workerOutput ? ( + renderOutputWithHighlighting(workerOutput) + ) : ( + No worker output + )} + + + + {outputFocus === 'worker' ? '[Focused - Tab to switch]' : '[Tab to focus]'} + + - + + {/* Reviewer output section - always show when split layout active */} + + + + {reviewerOutput ? ( + renderOutputWithHighlighting(reviewerOutput) + ) : ( + No reviewer output + )} + + + + {outputFocus === 'reviewer' ? '[Focused - Tab to switch]' : '[Tab to focus]'} + + + + + ) : ( + /* Single output section when no review */ + workerOutput && ( + + + + {renderOutputWithHighlighting(workerOutput)} + + + ) )} {/* Hint about returning */} diff --git a/src/tui/components/IterationHistoryView.tsx b/src/tui/components/IterationHistoryView.tsx index b433088c..b877f918 100644 --- a/src/tui/components/IterationHistoryView.tsx +++ b/src/tui/components/IterationHistoryView.tsx @@ -47,9 +47,13 @@ function getOutcomeText(result: IterationResult, isRunning: boolean): string { if (result.status === 'skipped') return 'Skipped'; if (result.status === 'interrupted') return 'Interrupted'; if (result.status === 'failed') return result.error || 'Failed'; + if (result.reviewEnabled) { + if (result.reviewPassed === true) return 'Review passed'; + if (result.reviewPassed === false) return 'Review failed'; + return 'Review pending'; + } // Completed - show if task was completed or just iteration - if (result.promiseComplete) return 'Task completed'; - if (result.taskCompleted) return 'Success'; + if (result.taskCompleted) return result.promiseComplete ? 'Task completed' : 'Success'; return 'Completed'; } diff --git a/src/tui/components/LeftPanel.tsx b/src/tui/components/LeftPanel.tsx index 07091cb3..1b079000 100644 --- a/src/tui/components/LeftPanel.tsx +++ b/src/tui/components/LeftPanel.tsx @@ -51,10 +51,12 @@ function TaskRow({ const titleWidth = maxWidth - indentWidth - 3 - idDisplay.length; const truncatedTitle = truncateText(task.title, Math.max(5, titleWidth)); - // Greyed-out colors for closed tasks - const idColor = isClosed ? colors.fg.dim : colors.fg.muted; + // Greyed-out colors for closed tasks, but more readable when selected + const idColor = isClosed ? (isSelected ? colors.fg.muted : colors.fg.dim) : colors.fg.muted; const titleColor = isClosed - ? colors.fg.dim + ? isSelected + ? colors.fg.secondary + : colors.fg.dim : isSelected ? colors.fg.primary : colors.fg.secondary; @@ -136,7 +138,6 @@ export const LeftPanel = memo(function LeftPanel({ return ( + + Tasks + Part 1Part 2 + * ❌ WRONG: Part 1 Part 2 + * + * The wrong pattern throws: "TextNodeRenderable only accepts strings, + * TextNodeRenderable instances, or StyledText instances" + */ + +import { describe, test, expect } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { dirname } from 'node:path'; + +describe('RightPanel text rendering', () => { + test('prevents regression: span elements must be used for multi-colored headers', () => { + // NOTE: This test intentionally reads source code to prevent a specific bug pattern. + // While fragile to formatting changes, it's a deliberate tradeoff to catch the + // nested bug that causes runtime errors in OpenTUI. + + const currentFile = fileURLToPath(import.meta.url); + const currentDir = dirname(currentFile); + const source = readFileSync(join(currentDir, 'RightPanel.tsx'), 'utf-8'); + + // Verify we use span elements for multi-colored text in headers + expect(source).toContain('Worker: '); + expect(source).toContain('{agentName'); + expect(source).toContain('Reviewer: '); + expect(source).toContain('{reviewerAgent'); + + // Verify worker/reviewer headers DON'T have invalid nested text pattern + const invalidWorkerPattern = /]*>\s*Worker:.*]*>\s*Reviewer:.*; + isFocused?: boolean; }): ReactNode { + const { width } = useTerminalDimensions(); const statusColor = getTaskStatusColor(task.status); const statusIndicator = getTaskStatusIndicator(task.status); + const sanitizeMetadataValue = (value?: string): string | undefined => { + if (!value) return undefined; + const cleaned = stripAnsiCodes(value) + .replace(/\p{C}/gu, '') + .trim(); + return cleaned.length > 0 ? cleaned : undefined; + }; + const formatTimestamp = (value?: string): string | undefined => { + if (!value) return undefined; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return value; + return parsed.toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' }); + }; + const displayType = sanitizeMetadataValue(task.type); + const displayAssignee = sanitizeMetadataValue(task.assignee); + const displayLabels = task.labels + ? task.labels + .map((label) => sanitizeMetadataValue(label)) + .filter((label): label is string => Boolean(label)) + : []; + const displayCreatedAt = formatTimestamp(task.createdAt); + const displayUpdatedAt = formatTimestamp(task.updatedAt); + const metadataRowStyle = { + flexDirection: 'row', + marginBottom: 0, + width: '100%', + backgroundColor: colors.bg.secondary, + } as const; // Check metadata for acceptance criteria (JSON tracker stores it there) const metadataCriteria = task.metadata?.acceptanceCriteria; const criteria = parseAcceptanceCriteria(task.description, undefined, metadataCriteria); const cleanDescription = extractDescription(task.description); + // Responsive layout: side-by-side on wide screens (>= 160 cols), stacked on narrow + const useWideLayout = width >= 160; + return ( - - {/* Task title and status */} - - - {statusIndicator} - {task.title} - - + {/* Task title and status */} + + + {statusIndicator} + {task.title} + + - {/* Task ID */} - - ID: {task.id} - + {/* Task ID */} + + ID: {task.id} + - {/* Metadata section - compact row of key info */} - + {/* Metadata section - compact row of key info */} + {/* Status row */} - - Status: - {task.status} + + Status: + {` ${task.status}`} {/* Priority row */} {task.priority !== undefined && ( - - Priority: - {priorityLabels[task.priority]} + + Priority: + {` ${priorityLabels[task.priority]}`} )} {/* Type row */} - {task.type && ( - - Type: - {task.type} + {displayType && ( + + Type: + {` ${displayType}`} )} {/* Assignee row */} - {task.assignee && ( - - Assignee: - {task.assignee} + {displayAssignee && ( + + Assignee: + {` ${displayAssignee}`} )} {/* Labels row */} - {task.labels && task.labels.length > 0 && ( - - Labels: + {displayLabels.length > 0 && ( + + Labels: - {task.labels.map((label, i) => ( - + {' '} + {displayLabels.map((label, i) => ( + {label} - {i < task.labels!.length - 1 ? ', ' : ''} + {i < displayLabels.length - 1 ? ', ' : ''} ))} @@ -348,63 +388,89 @@ function TaskMetadataView({ {/* Iteration row */} {task.iteration !== undefined && ( - - Iteration: - {task.iteration} + + Iteration: + {` ${task.iteration}`} )} - - {/* Description section */} - {cleanDescription && ( - - - Description + {/* Timestamp rows */} + {displayCreatedAt && ( + + Created: + {` ${displayCreatedAt}`} - - {cleanDescription} + )} + {displayUpdatedAt && ( + + Updated: + {` ${displayUpdatedAt}`} - - )} + )} + - {/* Acceptance criteria section */} - {criteria.length > 0 && ( - - - Acceptance Criteria + {/* Responsive layout for Description and Acceptance Criteria */} + {(cleanDescription || criteria.length > 0) && ( + + {/* Description section - scrollable and focusable */} + {cleanDescription && ( + + + Description + + + + {cleanDescription} + + - - {criteria.map((item, index) => ( - - - - {item.checked ? '[x]' : '[ ]'} - - - {' '} - {item.text} - - - - ))} + )} + + {/* Acceptance criteria section */} + {criteria.length > 0 && ( + + + Acceptance Criteria + + + + + {criteria.map((item, index) => ( + + + + {item.checked ? '[x]' : '[ ]'} + + + {' '} + {item.text} + + + + ))} + + + - - )} + )} + + )} {/* Dependencies section */} {((task.dependsOn && task.dependsOn.length > 0) || @@ -482,22 +548,6 @@ function TaskMetadataView({ )} - {/* Timestamps */} - {(task.createdAt || task.updatedAt) && ( - - {task.createdAt && ( - - Created: {new Date(task.createdAt).toLocaleString()} - - )} - {task.updatedAt && ( - - {' '}| Updated: {new Date(task.updatedAt).toLocaleString()} - - )} - - )} - ); } @@ -605,17 +655,98 @@ function TimingSummary({ timing }: { timing?: IterationTimingInfo }): ReactNode * Note: This shows a "point-in-time" preview - dynamic content like progress.md * may change before the actual prompt is sent during execution. */ +/** + * Simplifies template source labels for display + * - "tracker:beads-bv" -> "tracker:beads-bv" + * - "global:/path" -> "global" + * - "project:/path" -> "project" + * - "builtin" -> "builtin" + * - "/full/path" -> "cli" + */ +function simplifyTemplateSource(source: string | undefined): string { + if (!source) return 'unknown'; + + // Handle prefixed sources + if (source.startsWith('tracker:')) return source; + if (source.startsWith('global:')) return 'global'; + if (source.startsWith('project:')) return 'project'; + if (source === 'builtin') return 'builtin'; + + // Absolute path without prefix = CLI argument + if (source.startsWith('/')) return 'cli'; + + return source; +} + +/** + * Renders highlighted prompt text with syntax highlighting + */ +function renderPromptText(promptText: string): ReactNode { + return ( + + {promptText.split('\n').map((line, i) => { + // Highlight markdown headers + if (line.match(/^#+\s/)) { + return ( + + {line} + + ); + } + // Highlight bullet points + if (line.match(/^\s*[-*]\s/)) { + return ( + + {line} + + ); + } + // Highlight code fences + if (line.match(/^```/)) { + return ( + + {line} + + ); + } + // Regular text + return ( + + {line} + + ); + })} + + ); +} + function PromptPreviewView({ task, promptPreview, templateSource, + reviewPromptPreview, + reviewTemplateSource, + outputFocus, }: { task: NonNullable; promptPreview?: string; templateSource?: string; + reviewPromptPreview?: string; + reviewTemplateSource?: string; + outputFocus?: 'worker' | 'reviewer' | 'content'; }): ReactNode { + const { width } = useTerminalDimensions(); const statusColor = getTaskStatusColor(task.status); const statusIndicator = getTaskStatusIndicator(task.status); + const hasReviewPrompt = Boolean(reviewPromptPreview); + + // When review is enabled, focus is either 'worker' or 'reviewer' + // When review is disabled, focus is 'content' (which applies to worker only) + const workerFocused = outputFocus === 'worker' || (!hasReviewPrompt && outputFocus === 'content'); + const reviewerFocused = outputFocus === 'reviewer'; + + // Responsive layout: side-by-side on wide screens (>= 160 cols), stacked on narrow + const useWideLayout = width >= 160; return ( @@ -628,11 +759,14 @@ function PromptPreviewView({ ({task.id}) - {templateSource && ( - - [{templateSource}] - - )} + + {templateSource && ( + worker:{simplifyTemplateSource(templateSource)} + )} + {hasReviewPrompt && reviewTemplateSource && ( + reviewer:{simplifyTemplateSource(reviewTemplateSource)} + )} + {/* Dynamic content notice */} @@ -650,59 +784,76 @@ function PromptPreviewView({ - {/* Full-height prompt preview */} - - - {promptPreview ? ( - - {promptPreview.split('\n').map((line, i) => { - // Highlight markdown headers - if (line.match(/^#+\s/)) { - return ( - - {line} - - ); - } - // Highlight bullet points - if (line.match(/^\s*[-*]\s/)) { - return ( - - {line} - - ); - } - // Highlight code fences - if (line.match(/^```/)) { - return ( - - {line} - - ); - } - // Regular text - return ( - - {line} - - ); - })} + {/* Split or single prompt preview */} + {hasReviewPrompt ? ( + // Responsive split view: side-by-side on wide screens, stacked on narrow + + {/* Worker prompt */} + + + WORKER PROMPT - ) : ( - - Cycle views with 'o' or press Shift+O for prompt preview - - )} - - + + {promptPreview ? ( + renderPromptText(promptPreview) + ) : ( + No worker prompt + )} + + + + {/* Reviewer prompt */} + + + REVIEWER PROMPT + + + {reviewPromptPreview ? ( + renderPromptText(reviewPromptPreview) + ) : ( + Loading review prompt... + )} + + + + ) : ( + // Single view: Worker prompt only + + + {promptPreview ? ( + renderPromptText(promptPreview) + ) : ( + + Cycle views with 'o' or press Shift+O for prompt preview + + )} + + + )} ); } @@ -719,6 +870,8 @@ function TaskOutputView({ iterationTiming, agentName, currentModel, + reviewerAgent, + outputFocus, }: { task: NonNullable; currentIteration: number; @@ -727,26 +880,58 @@ function TaskOutputView({ iterationTiming?: IterationTimingInfo; agentName?: string; currentModel?: string; + reviewerAgent?: string; + outputFocus?: 'worker' | 'reviewer'; }): ReactNode { + const { width } = useTerminalDimensions(); const statusColor = getTaskStatusColor(task.status); const statusIndicator = getTaskStatusIndicator(task.status); + // Responsive layout: side-by-side on wide screens (>= 160 cols), stacked on narrow + const useWideLayout = width >= 160; + // Check if we're live streaming const isLiveStreaming = iterationTiming?.isRunning === true; + // Check if output actually has reviewer section + const hasReviewOutput = iterationOutput?.includes(REVIEW_OUTPUT_DIVIDER) ?? false; + + // Treat divider presence as implicit signal to keep split layout + // This preserves historical reviewer output even if review is currently disabled + const isReviewEnabled = (reviewerAgent !== undefined && reviewerAgent !== '') || hasReviewOutput; + // For live streaming, prefer segments for TUI-native colors // For historical/completed output, parse the string to extract readable content // ALWAYS strip ANSI codes - they cause black background artifacts in OpenTUI - const displayOutput = useMemo(() => { - if (!iterationOutput) return undefined; + const { workerOutput, reviewerOutput } = useMemo(() => { + if (iterationOutput == null) return { workerOutput: undefined, reviewerOutput: undefined }; + + // Split worker and reviewer on first divider only to avoid content loss + const dividerIndex = hasReviewOutput + ? iterationOutput.indexOf(REVIEW_OUTPUT_DIVIDER) + : -1; + const worker = dividerIndex >= 0 + ? iterationOutput.slice(0, dividerIndex) + : iterationOutput; + const reviewer = dividerIndex >= 0 + ? iterationOutput.slice(dividerIndex + REVIEW_OUTPUT_DIVIDER.length) + : undefined; + // For live output during execution, strip ANSI but keep raw content if (isLiveStreaming) { - return stripAnsiCodes(iterationOutput); + return { + workerOutput: stripAnsiCodes(worker), + reviewerOutput: reviewer ? stripAnsiCodes(reviewer) : undefined, + }; } + // For completed output (historical or from current session), parse to extract readable content // parseAgentOutput already strips ANSI codes - return parseAgentOutput(iterationOutput, agentName); - }, [iterationOutput, isLiveStreaming, agentName]); + return { + workerOutput: parseAgentOutput(worker, agentName), + reviewerOutput: reviewer ? parseAgentOutput(reviewer, reviewerAgent) : undefined, + }; + }, [iterationOutput, isLiveStreaming, agentName, reviewerAgent, hasReviewOutput]); // Note: Full segment-based coloring (FormattedText) disabled due to OpenTUI // span rendering issues causing black backgrounds and character loss. @@ -761,9 +946,37 @@ function TaskOutputView({ })() : null; + // Helper to render output lines with tool name highlighting + const renderOutputLines = (output: string | undefined) => { + if (!output || output.length === 0) return null; + + return ( + + {output.split('\n').map((line, i) => { + // Check if line starts with [toolname] pattern + const toolMatch = line.match(/^(\[[\w-]+\])(.*)/); + if (toolMatch) { + const [, toolName, rest] = toolMatch; + return ( + + {toolName} + {rest} + + ); + } + return ( + + {line} + + ); + })} + + ); + }; + return ( - {/* Compact task header with agent/model info */} + {/* Compact task header - only show task title and status */} @@ -772,68 +985,115 @@ function TaskOutputView({ ({task.id}) - {(agentName || modelDisplay) && ( - - {agentName && {agentName}} - {agentName && modelDisplay && |} - {modelDisplay && ( - {modelDisplay.display} - )} - + {/* Show model info on the right */} + {modelDisplay && ( + {modelDisplay.display} )} {/* Timing summary - shows start/end/duration */} - {/* Full-height iteration output */} - 0 - ? `Iteration ${currentIteration}` - : 'Output' - } - style={{ - flexGrow: 1, - border: true, - borderColor: colors.border.normal, - backgroundColor: colors.bg.secondary, - }} - > - - {/* Line-based coloring with tool names in green */} - {displayOutput !== undefined && displayOutput.length > 0 ? ( - - {displayOutput.split('\n').map((line, i) => { - // Check if line starts with [toolname] pattern - const toolMatch = line.match(/^(\[[\w-]+\])(.*)/); - if (toolMatch) { - const [, toolName, rest] = toolMatch; - return ( - - {toolName} - {rest} - - ); - } - return ( - - {line} - - ); - })} + {/* Split output sections when review is enabled - responsive layout */} + {isReviewEnabled ? ( + + {/* Worker output section */} + + + + Worker: + {agentName || 'agent'} + {currentIteration > 0 && ( + (Iteration {currentIteration}) + )} + - ) : displayOutput === '' ? ( - No output captured - ) : currentIteration === 0 ? ( - Task not yet executed - ) : ( - Waiting for output... - )} - - + + {workerOutput !== undefined && workerOutput.length > 0 ? ( + renderOutputLines(workerOutput) + ) : ( + No worker output yet... + )} + + + + {/* Reviewer output section */} + + + + Reviewer: + {reviewerAgent || 'reviewer'} + + + + {reviewerOutput !== undefined && reviewerOutput.length > 0 ? ( + renderOutputLines(reviewerOutput) + ) : ( + + {isLiveStreaming ? 'Waiting for reviewer...' : 'No reviewer output captured'} + + )} + + + + ) : ( + /* Single output section when no review */ + 0 + ? `Iteration ${currentIteration}` + : 'Output' + } + style={{ + flexGrow: 1, + border: true, + borderColor: colors.border.normal, + backgroundColor: colors.bg.secondary, + }} + > + + {workerOutput !== undefined && workerOutput.length > 0 ? ( + renderOutputLines(workerOutput) + ) : workerOutput === '' ? ( + No output captured + ) : currentIteration === 0 ? ( + Task not yet executed + ) : ( + Waiting for output... + )} + + + )} ); } @@ -850,8 +1110,12 @@ function TaskDetails({ iterationTiming, agentName, currentModel, + reviewerAgent, promptPreview, templateSource, + reviewPromptPreview, + reviewTemplateSource, + outputFocus, }: { task: NonNullable; currentIteration: number; @@ -861,8 +1125,12 @@ function TaskDetails({ iterationTiming?: IterationTimingInfo; agentName?: string; currentModel?: string; + reviewerAgent?: string; promptPreview?: string; templateSource?: string; + reviewPromptPreview?: string; + reviewTemplateSource?: string; + outputFocus?: 'worker' | 'reviewer' | 'content'; }): ReactNode { if (viewMode === 'output') { return ( @@ -874,6 +1142,8 @@ function TaskDetails({ iterationTiming={iterationTiming} agentName={agentName} currentModel={currentModel} + reviewerAgent={reviewerAgent} + outputFocus={outputFocus === 'worker' || outputFocus === 'reviewer' ? outputFocus : undefined} /> ); } @@ -884,11 +1154,14 @@ function TaskDetails({ task={task} promptPreview={promptPreview} templateSource={templateSource} + reviewPromptPreview={reviewPromptPreview} + reviewTemplateSource={reviewTemplateSource} + outputFocus={outputFocus} /> ); } - return ; + return ; } /** @@ -903,11 +1176,15 @@ export function RightPanel({ iterationTiming, agentName, currentModel, + reviewerAgent, promptPreview, templateSource, + reviewPromptPreview, + reviewTemplateSource, isViewingRemote = false, remoteConnectionStatus, remoteAlias, + outputFocus, }: RightPanelProps): ReactNode { // Build title with view mode indicator const modeIndicators: Record = { @@ -941,8 +1218,12 @@ export function RightPanel({ iterationTiming={iterationTiming} agentName={agentName} currentModel={currentModel} + reviewerAgent={reviewerAgent} promptPreview={promptPreview} templateSource={templateSource} + reviewPromptPreview={reviewPromptPreview} + reviewTemplateSource={reviewTemplateSource} + outputFocus={outputFocus} /> ) : ( ; /** Task IDs that completed locally but merge failed (shows ⚠ in TUI) */ @@ -200,10 +201,12 @@ export interface RunAppProps { onParallelKill?: () => Promise; /** Callback to restart parallel execution after stop/complete */ onParallelStart?: () => void; - /** Callback when user requests conflict resolution retry (r key in failure state) */ - onConflictRetry?: () => void; - /** Callback when user requests to skip a failed merge (s key in failure state) */ - onConflictSkip?: () => void; + /** Callback to abort conflict resolution and rollback the merge */ + onConflictAbort?: () => Promise; + /** Callback to accept AI resolution for a specific file */ + onConflictAccept?: (filePath: string) => void; + /** Callback to accept all AI resolutions */ + onConflictAcceptAll?: () => void; } /** @@ -453,8 +456,6 @@ export function RunApp({ parallelConflictTaskId = '', parallelConflictTaskTitle = '', parallelAiResolving = false, - parallelCurrentlyResolvingFile = '', - parallelShowConflicts = false, parallelTaskIdToWorkerId, parallelCompletedLocallyTaskIds, parallelAutoCommitSkippedTaskIds: _parallelAutoCommitSkippedTaskIds, // Reserved for future status bar warning @@ -465,8 +466,9 @@ export function RunApp({ onParallelResume, onParallelKill, onParallelStart, - onConflictRetry, - onConflictSkip, + onConflictAbort, + onConflictAccept, + onConflictAcceptAll, }: RunAppProps): ReactNode { const { width, height } = useTerminalDimensions(); const renderer = useRenderer(); @@ -563,6 +565,8 @@ export function RunApp({ // Prompt preview content and template source (for prompt view mode) const [promptPreview, setPromptPreview] = useState(undefined); const [templateSource, setTemplateSource] = useState(undefined); + const [reviewPromptPreview, setReviewPromptPreview] = useState(undefined); + const [reviewTemplateSource, setReviewTemplateSource] = useState(undefined); // Subagent tracing detail level - initialized from config, can be cycled with 't' key // Default to 'moderate' to show inline subagent sections by default const [subagentDetailLevel, setSubagentDetailLevel] = useState( @@ -597,10 +601,15 @@ export function RunApp({ // When true, auto-show will not override user's explicit hide action const [userManuallyHidPanel, setUserManuallyHidPanel] = useState(false); - // Focused pane for TAB-based navigation between output and subagent tree - // - 'output': j/k scroll output content (default) - // - 'subagentTree': j/k select nodes in the tree - const [focusedPane, setFocusedPane] = useState('output'); + // Focused pane for TAB-based navigation between panels + // Note: Selected task row always stays highlighted regardless of focus state + // Panel borders use accent color when focused, normal color when not focused + // - 'tasks': Default state, task panel border highlighted, arrows navigate task list + // - 'worker': Worker output section border highlighted, arrows scroll output + // - 'reviewer': Reviewer output section border highlighted, arrows scroll output + // - 'content': Details/Prompt section border highlighted, arrows scroll content + // - 'subagentTree': Subagent tree panel border highlighted, arrows navigate tree + const [focusedPane, setFocusedPane] = useState('tasks'); // Selected node in subagent tree for keyboard navigation // - currentTaskId (or 'main' if no task): Task root node is selected @@ -992,6 +1001,8 @@ export function RunApp({ if (!promptPreviewTaskId) { setPromptPreview('No task selected'); setTemplateSource(undefined); + setReviewPromptPreview(undefined); + setReviewTemplateSource(undefined); return; } @@ -1000,6 +1011,8 @@ export function RunApp({ setPromptPreview('Generating prompt preview...'); setTemplateSource(undefined); + setReviewPromptPreview(undefined); + setReviewTemplateSource(undefined); void (async () => { // Use remote API when viewing remote, local engine otherwise @@ -1017,6 +1030,11 @@ export function RunApp({ setPromptPreview(`Error: ${result.error}`); setTemplateSource(undefined); } + + if (storedConfig?.review?.enabled) { + setReviewPromptPreview('Review prompt preview is unavailable for remote instances.'); + setReviewTemplateSource(undefined); + } } else if (engine) { const result = await engine.generatePromptPreview(promptPreviewTaskId); // Don't update state if this effect was cancelled (user changed task again) @@ -1029,6 +1047,20 @@ export function RunApp({ setPromptPreview(`Error: ${result.error}`); setTemplateSource(undefined); } + + // If review is enabled, also generate review prompt preview + if (storedConfig?.review?.enabled) { + const reviewResult = await engine.generateReviewPromptPreview(promptPreviewTaskId); + if (cancelled) return; + + if (reviewResult.success) { + setReviewPromptPreview(reviewResult.prompt); + setReviewTemplateSource(reviewResult.source); + } else { + setReviewPromptPreview(`Error: ${reviewResult.error}`); + setReviewTemplateSource(undefined); + } + } } })(); @@ -1036,7 +1068,7 @@ export function RunApp({ return () => { cancelled = true; }; - }, [detailsViewMode, promptPreviewTaskId, engine, isViewingRemote, instanceManager]); + }, [detailsViewMode, promptPreviewTaskId, engine, isViewingRemote, instanceManager, storedConfig?.review?.enabled]); // Fetch remote iteration output when selecting a different task (for remote viewing) // This fills the remoteIterationCache so the useMemo can use it synchronously @@ -1350,17 +1382,13 @@ export function RunApp({ } }, [currentTaskId]); - // Auto-show conflict panel when Phase 2 conflict resolution starts - // Only show when parallelShowConflicts is true (set by conflict:ai-resolving event), - // not when conflicts are merely detected during Phase 1 merge attempts + // Auto-show conflict panel when conflicts are detected in parallel mode useEffect(() => { - if (isParallelMode && parallelShowConflicts && parallelConflicts.length > 0) { + if (isParallelMode && parallelConflicts.length > 0) { setShowConflictPanel(true); setConflictSelectedIndex(0); - } else if (!parallelShowConflicts) { - setShowConflictPanel(false); } - }, [isParallelMode, parallelShowConflicts, parallelConflicts]); + }, [isParallelMode, parallelConflicts]); // Calculate the number of items in iteration history (iterations + pending) const iterationHistoryLength = Math.max(iterations.length, totalIterations); @@ -1389,6 +1417,56 @@ export function RunApp({ setSelectedSubagentId(flatList[newIdx]!); }, [subagentTree, remoteSubagentTree, isViewingRemote, selectedSubagentId, displayCurrentTaskId]); + // Check if current output contains review divider (for Tab cycling logic) + // This needs to be computed before handleKeyboard to avoid stale closures + const hasReviewDividerInOutput = useMemo(() => { + const taskIdFromIterations = viewMode === 'iterations' + ? iterations[iterationSelectedIndex]?.task?.id + : undefined; + const effectiveTaskId = taskIdFromIterations ?? displayedTasks[selectedIndex]?.id; + if (!effectiveTaskId) return false; + + if (isViewingRemote) { + if (effectiveTaskId === remoteCurrentTaskId && remoteOutput) { + return remoteOutput.includes(REVIEW_OUTPUT_DIVIDER); + } + const cached = remoteIterationCache.get(effectiveTaskId); + if (cached && typeof cached.output === 'string') { + return cached.output.includes(REVIEW_OUTPUT_DIVIDER); + } + return false; + } + + const isViewingCurrentTask = effectiveTaskId === currentTaskId; + if (isViewingCurrentTask && currentOutput) { + return currentOutput.includes(REVIEW_OUTPUT_DIVIDER); + } + + const taskIteration = iterations.find((iter) => iter.task.id === effectiveTaskId); + if (taskIteration?.agentResult?.stdout) { + return taskIteration.agentResult.stdout.includes(REVIEW_OUTPUT_DIVIDER); + } + + const historicalData = historicalOutputCache.get(effectiveTaskId); + if (historicalData && typeof historicalData.output === 'string') { + return historicalData.output.includes(REVIEW_OUTPUT_DIVIDER); + } + return false; + }, [ + viewMode, + iterations, + iterationSelectedIndex, + displayedTasks, + selectedIndex, + isViewingRemote, + remoteCurrentTaskId, + remoteOutput, + remoteIterationCache, + currentTaskId, + currentOutput, + historicalOutputCache, + ]); + // Handle keyboard navigation const handleKeyboard = useCallback( (key: KeyEvent) => { @@ -1496,13 +1574,19 @@ export function RunApp({ // When conflict resolution panel is showing, handle conflict-specific keys if (showConflictPanel) { - // Check if we're in failure state (has failed resolutions and not currently resolving) - const hasFailures = !parallelAiResolving && - parallelConflictResolutions.some((r) => !r.success); - + if (key.sequence === 'A') { + if (onConflictAcceptAll) { + onConflictAcceptAll(); + } + setShowConflictPanel(false); + return; + } switch (key.name) { case 'escape': - // Close conflict panel (AI resolution continues in background) + // Abort conflict resolution and rollback + if (onConflictAbort) { + onConflictAbort().catch(() => {}); + } setShowConflictPanel(false); break; case 'j': @@ -1513,18 +1597,18 @@ export function RunApp({ case 'up': setConflictSelectedIndex((prev) => Math.max(prev - 1, 0)); break; - case 'r': - // Retry AI resolution (only in failure state) - if (hasFailures && onConflictRetry) { - onConflictRetry(); + case 'a': + // Accept AI resolution for the selected file + if (onConflictAccept && parallelConflicts[conflictSelectedIndex]) { + onConflictAccept(parallelConflicts[conflictSelectedIndex].filePath); } break; - case 's': - // Skip this task's merge (only in failure state) - if (hasFailures && onConflictSkip) { - onConflictSkip(); - setShowConflictPanel(false); + case 'r': + // Reject - abort conflict resolution for this merge + if (onConflictAbort) { + onConflictAbort().catch(() => {}); } + setShowConflictPanel(false); break; } return; @@ -1552,19 +1636,94 @@ export function RunApp({ break; case 'tab': - // Toggle focus between output and subagent tree panels - // Only works when subagent panel is visible and in output view mode - if (subagentPanelVisible && detailsViewMode === 'output') { - setFocusedPane((prev) => prev === 'output' ? 'subagentTree' : 'output'); + // Tab cycles through sections based on view mode + // Tasks remain always highlighted, Tab only switches focus for content sections + if (detailsViewMode === 'output') { + // Output view: cycle through worker/reviewer/subagentTree + // Check if reviewer pane is present: either review enabled OR output contains divider (historical) + const reviewerEnabled = !!storedConfig?.review?.enabled; + const reviewerPresent = reviewerEnabled || hasReviewDividerInOutput; + + setFocusedPane((prev) => { + // Cycle: none (tasks) -> worker -> reviewer (if present) -> subagentTree (if visible) -> none + if (prev === 'tasks') { + // First Tab press: focus worker + return 'worker'; + } + if (prev === 'worker') { + // From worker: go to reviewer if present, else subagentTree if visible, else back to tasks + if (reviewerPresent) return 'reviewer'; + if (subagentPanelVisible) return 'subagentTree'; + return 'tasks'; + } + if (prev === 'reviewer') { + // From reviewer: go to subagentTree if visible, else back to tasks + return subagentPanelVisible ? 'subagentTree' : 'tasks'; + } + if (prev === 'subagentTree') { + // From subagentTree: back to tasks + return 'tasks'; + } + return 'tasks'; + }); + } else if (detailsViewMode === 'details') { + // Details view: simple toggle between tasks and content + setFocusedPane((prev) => { + if (prev === 'tasks') { + // First Tab press: focus content pane + return 'content'; + } + if (prev === 'content') { + // From content: go to subagentTree if visible, else back to tasks + return subagentPanelVisible ? 'subagentTree' : 'tasks'; + } + if (prev === 'subagentTree') { + // From subagentTree: back to tasks + return 'tasks'; + } + return 'tasks'; + }); + } else if (detailsViewMode === 'prompt') { + // Prompt view: cycle through worker/reviewer (if review enabled) + // Note: reviewerConfigured only checks if review is enabled, not if review.agent is set + // because when review.agent is unset, the engine falls back to using the primary agent + const reviewerConfigured = !!storedConfig?.review?.enabled; + + setFocusedPane((prev) => { + // Cycle: none (tasks) -> worker -> reviewer (if enabled) -> subagentTree (if visible) -> none + if (prev === 'tasks') { + // First Tab press: focus worker + return 'worker'; + } + if (prev === 'worker') { + // From worker: go to reviewer if enabled, else subagentTree if visible, else back to tasks + if (reviewerConfigured) return 'reviewer'; + if (subagentPanelVisible) return 'subagentTree'; + return 'tasks'; + } + if (prev === 'reviewer') { + // From reviewer: go to subagentTree if visible, else back to tasks + return subagentPanelVisible ? 'subagentTree' : 'tasks'; + } + if (prev === 'subagentTree') { + // From subagentTree: back to tasks + return 'tasks'; + } + return 'tasks'; + }); } break; case 'up': case 'k': - // Focus-aware navigation: when subagent panel is visible and focused, navigate tree - if (detailsViewMode === 'output' && subagentPanelVisible && focusedPane === 'subagentTree') { + // Focus-aware navigation + if (focusedPane === 'subagentTree') { + // Subagent tree navigation navigateSubagentTree(-1); break; + } else if (focusedPane === 'worker' || focusedPane === 'reviewer' || focusedPane === 'content') { + // Content panes handle scrolling internally - don't navigate tasks + break; } // Default: navigate task/iteration/parallel lists if (viewMode === 'tasks') { @@ -1578,10 +1737,14 @@ export function RunApp({ case 'down': case 'j': - // Focus-aware navigation: when subagent panel is visible and focused, navigate tree - if (detailsViewMode === 'output' && subagentPanelVisible && focusedPane === 'subagentTree') { + // Focus-aware navigation + if (focusedPane === 'subagentTree') { + // Subagent tree navigation navigateSubagentTree(1); break; + } else if (focusedPane === 'worker' || focusedPane === 'reviewer' || focusedPane === 'content') { + // Content panes handle scrolling internally - don't navigate tasks + break; } // Default: navigate task/iteration/parallel lists if (viewMode === 'tasks') { @@ -1919,6 +2082,8 @@ export function RunApp({ return modes[nextIdx]!; }); } + // Reset focus to tasks when changing view mode + setFocusedPane('tasks'); break; case 't': @@ -2070,7 +2235,7 @@ export function RunApp({ break; } }, - [displayedTasks, selectedIndex, status, engine, onQuit, viewMode, iterations, iterationSelectedIndex, iterationHistoryLength, onIterationDrillDown, showInterruptDialog, onInterruptConfirm, onInterruptCancel, showHelp, showSettings, showQuitDialog, showKillDialog, showEpicLoader, showRemoteManagement, onStart, storedConfig, onSaveSettings, onLoadEpics, subagentDetailLevel, onSubagentPanelVisibilityChange, currentIteration, maxIterations, renderer, detailsViewMode, subagentPanelVisible, focusedPane, navigateSubagentTree, instanceTabs, selectedTabIndex, onSelectTab, isViewingRemote, displayStatus, instanceManager, isParallelMode, parallelWorkers, parallelConflicts, showConflictPanel, onParallelKill, onParallelPause, onParallelResume, onParallelStart, parallelDerivedStatus] + [displayedTasks, selectedIndex, status, engine, onQuit, viewMode, iterations, iterationSelectedIndex, iterationHistoryLength, onIterationDrillDown, showInterruptDialog, onInterruptConfirm, onInterruptCancel, showHelp, showSettings, showQuitDialog, showKillDialog, showEpicLoader, showRemoteManagement, onStart, storedConfig, onSaveSettings, onLoadEpics, subagentDetailLevel, onSubagentPanelVisibilityChange, currentIteration, maxIterations, renderer, detailsViewMode, subagentPanelVisible, focusedPane, navigateSubagentTree, instanceTabs, selectedTabIndex, onSelectTab, isViewingRemote, displayStatus, instanceManager, isParallelMode, parallelWorkers, parallelConflicts, showConflictPanel, onParallelKill, onParallelPause, onParallelResume, onParallelStart, onConflictAbort, onConflictAccept, onConflictAcceptAll, parallelDerivedStatus, hasReviewDividerInOutput] ); useKeyboard(handleKeyboard); @@ -2244,6 +2409,13 @@ export function RunApp({ return { iteration: 0, output: undefined, segments: undefined, timing: undefined }; }, [effectiveTaskId, selectedTask, selectedIteration, viewMode, currentTaskId, currentIteration, currentOutput, currentSegments, iterations, historicalOutputCache, currentIterationStartedAt, isViewingRemote, remoteStatus, remoteCurrentIteration, remoteOutput, remoteIterationCache, remoteCurrentTaskId, isParallelMode, parallelTaskIdToWorkerId, parallelWorkerOutputs]); + // Compute reviewer agent name with fallback to primary agent + // When review is enabled but review.agent is not set, the engine uses the primary agent + const reviewerAgentName = useMemo(() => { + if (!storedConfig?.review?.enabled) return undefined; + return storedConfig.review.agent?.trim() || agentName || storedConfig?.agent || agentPlugin; + }, [storedConfig, agentName, agentPlugin]); + // Compute the actual output to display based on selectedSubagentId // When a subagent is selected (not task root), try to get its specific output // NOTE: Only use selectedSubagentId when viewing the current task - subagent tree @@ -2624,6 +2796,7 @@ export function RunApp({ completedTasks={completedTasks} totalTasks={totalTasks} agentName={displayAgentName} + reviewerAgent={reviewerAgentName} trackerName={displayTrackerName} activeAgentState={isViewingRemote ? remoteActiveAgent : activeAgentState} rateLimitState={isViewingRemote ? remoteRateLimitState : rateLimitState} @@ -2694,6 +2867,7 @@ export function RunApp({ sandboxConfig={sandboxConfig} resolvedSandboxMode={resolvedSandboxMode} historicContext={iterationDetailHistoricContext} + workerAgent={agentPlugin} /> ) : viewMode === 'parallel-overview' ? ( // Parallel workers overview @@ -2727,7 +2901,7 @@ export function RunApp({ {/* Subagent Tree Panel - shown on right side when toggled with 'T' key */} {subagentPanelVisible && ( @@ -2781,11 +2959,15 @@ export function RunApp({ iterationTiming={selectedTaskIteration.timing} agentName={displayAgentInfo.agent} currentModel={displayAgentInfo.model} + reviewerAgent={reviewerAgentName} promptPreview={promptPreview} templateSource={templateSource} + reviewPromptPreview={reviewPromptPreview} + reviewTemplateSource={reviewTemplateSource} isViewingRemote={isViewingRemote} remoteConnectionStatus={instanceTabs?.[selectedTabIndex]?.status} remoteAlias={instanceTabs?.[selectedTabIndex]?.alias} + outputFocus={focusedPane === 'worker' || focusedPane === 'reviewer' ? focusedPane : focusedPane === 'content' ? 'content' : undefined} /> {/* Subagent Tree Panel - shown on right side when toggled with 'T' key */} {subagentPanelVisible && ( @@ -2914,10 +3096,7 @@ export function RunApp({ taskId={parallelConflictTaskId} taskTitle={parallelConflictTaskTitle} aiResolving={parallelAiResolving} - currentlyResolvingFile={parallelCurrentlyResolvingFile} selectedIndex={conflictSelectedIndex} - onRetry={onConflictRetry} - onSkip={onConflictSkip} /> {/* Settings View */} diff --git a/src/tui/components/SettingsView.tsx b/src/tui/components/SettingsView.tsx index 8752ce1e..88da691b 100644 --- a/src/tui/components/SettingsView.tsx +++ b/src/tui/components/SettingsView.tsx @@ -75,9 +75,9 @@ function buildSettingDefinitions( }, { key: 'agent', - label: 'Agent', + label: 'Worker', type: 'select', - description: 'AI agent plugin to use', + description: 'AI agent plugin to execute tasks', options: agents.map((a) => a.id), getValue: (config) => config.agent ?? config.defaultAgent, setValue: (config, value) => ({ @@ -87,6 +87,38 @@ function buildSettingDefinitions( }), requiresRestart: true, }, + { + key: 'reviewer', + label: 'Reviewer', + type: 'select', + description: 'Optional reviewer agent (choose none to disable)', + options: ['none', ...agents.map((a) => a.id)], + getValue: (config) => + config.review?.enabled + ? (config.review.agent ?? 'none') + : 'none', + setValue: (config, value) => { + if (value === 'none') { + return { + ...config, + review: { + ...config.review, + enabled: false, + agent: undefined, + }, + }; + } + return { + ...config, + review: { + ...config.review, + enabled: true, + agent: value as string, + }, + }; + }, + requiresRestart: true, + }, { key: 'maxIterations', label: 'Max Iterations', diff --git a/src/tui/components/TaskDetailView.tsx b/src/tui/components/TaskDetailView.tsx index 7d0ec96b..340e912f 100644 --- a/src/tui/components/TaskDetailView.tsx +++ b/src/tui/components/TaskDetailView.tsx @@ -6,6 +6,7 @@ import type { ReactNode } from 'react'; import { colors, getTaskStatusColor, getTaskStatusIndicator } from '../theme.js'; +import { stripAnsiCodes } from '../../plugins/agents/output-formatting.js'; import type { TaskDetailViewProps, TaskPriority } from '../types.js'; /** @@ -148,10 +149,10 @@ function MetadataRow({ valueColor?: string; }): ReactNode { return ( - - {label}: + + {label}: {typeof value === 'string' ? ( - {value} + {` ${value}`} ) : ( value )} @@ -167,6 +168,28 @@ function MetadataRow({ export function TaskDetailView({ task, onBack: _onBack }: TaskDetailViewProps): ReactNode { const statusColor = getTaskStatusColor(task.status); const statusIndicator = getTaskStatusIndicator(task.status); + const sanitizeMetadataValue = (value?: string): string | undefined => { + if (!value) return undefined; + const cleaned = stripAnsiCodes(value) + .replace(/\p{C}/gu, '') + .trim(); + return cleaned.length > 0 ? cleaned : undefined; + }; + const formatTimestamp = (value?: string): string | undefined => { + if (!value) return undefined; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return value; + return parsed.toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' }); + }; + const displayType = sanitizeMetadataValue(task.type); + const displayAssignee = sanitizeMetadataValue(task.assignee); + const displayLabels = task.labels + ? task.labels + .map((label) => sanitizeMetadataValue(label)) + .filter((label): label is string => Boolean(label)) + : []; + const displayCreatedAt = formatTimestamp(task.createdAt); + const displayUpdatedAt = formatTimestamp(task.updatedAt); // Check metadata for acceptance criteria (JSON tracker stores it there) const metadataCriteria = task.metadata?.acceptanceCriteria; const criteria = parseAcceptanceCriteria(task.description, undefined, metadataCriteria); @@ -221,19 +244,20 @@ export function TaskDetailView({ task, onBack: _onBack }: TaskDetailViewProps): /> )} - {task.type && } + {displayType && } - {task.assignee && } + {displayAssignee && } - {task.labels && task.labels.length > 0 && ( + {displayLabels.length > 0 && ( - {task.labels.map((label, i) => ( - + {' '} + {displayLabels.map((label, i) => ( + {label} - {i < task.labels!.length - 1 ? ', ' : ''} + {i < displayLabels.length - 1 ? ', ' : ''} ))} @@ -248,6 +272,9 @@ export function TaskDetailView({ task, onBack: _onBack }: TaskDetailViewProps): valueColor={colors.accent.primary} /> )} + + {displayCreatedAt && } + {displayUpdatedAt && } @@ -370,22 +397,6 @@ export function TaskDetailView({ task, onBack: _onBack }: TaskDetailViewProps): )} - {/* Timestamps */} - {(task.createdAt || task.updatedAt) && ( - - {task.createdAt && ( - - Created: {new Date(task.createdAt).toLocaleString()} - - )} - {task.updatedAt && ( - - {' '} - | Updated: {new Date(task.updatedAt).toLocaleString()} - - )} - - )} ); diff --git a/src/tui/theme.ts b/src/tui/theme.ts index a79bfd78..714f5a30 100644 --- a/src/tui/theme.ts +++ b/src/tui/theme.ts @@ -5,7 +5,7 @@ */ import { readFile, access, constants } from 'node:fs/promises'; -import { resolve, isAbsolute, join, dirname, sep } from 'node:path'; +import { resolve, isAbsolute, join, dirname, basename } from 'node:path'; import { fileURLToPath } from 'node:url'; /** @@ -417,8 +417,7 @@ function getThemesDir(): string { // In dist: dist/cli.js -> assets/themes (copied during build to dist/assets/) // Note: bun bundler produces flat output (dist/cli.js), not nested (dist/tui/theme.js) // Use path-segment-aware check for cross-platform compatibility (Windows uses backslashes) - const pathSegments = currentDir.split(sep); - const isInDist = pathSegments.includes('dist'); + const isInDist = basename(currentDir) === 'dist'; if (isInDist) { return join(currentDir, 'assets', 'themes'); } diff --git a/src/tui/types.ts b/src/tui/types.ts index c820a3a0..1d2c59f3 100644 --- a/src/tui/types.ts +++ b/src/tui/types.ts @@ -89,6 +89,8 @@ export interface HeaderProps { totalTasks?: number; /** Selected agent plugin name (e.g., "claude", "opencode") */ agentName?: string; + /** Reviewer agent plugin name (when review is enabled) */ + reviewerAgent?: string; /** Selected tracker plugin name (e.g., "beads", "beads-bv", "json") */ trackerName?: string; /** Active agent state from engine (tracks which agent is running and why) */ @@ -176,18 +178,26 @@ export interface RightPanelProps { iterationTiming?: IterationTimingInfo; /** Name of the agent being used */ agentName?: string; + /** Name of the reviewer agent (when review is enabled) */ + reviewerAgent?: string; /** Model being used (provider/model format) */ currentModel?: string; /** Rendered prompt content for preview (when viewMode is 'prompt') */ promptPreview?: string; /** Source of the template used for the prompt (e.g., 'tracker:beads', 'builtin:json') */ templateSource?: string; + /** Rendered review prompt content for preview (when viewMode is 'prompt' and review is enabled) */ + reviewPromptPreview?: string; + /** Source of the template used for the review prompt (e.g., 'global:review.hbs', 'builtin') */ + reviewTemplateSource?: string; /** Whether currently viewing a remote instance */ isViewingRemote?: boolean; /** Connection status when viewing remote */ remoteConnectionStatus?: ConnectionStatus; /** Alias of the remote being viewed */ remoteAlias?: string; + /** Which section has focus for keyboard navigation: 'worker'/'reviewer' in output view, 'content' in details/prompt view */ + outputFocus?: 'worker' | 'reviewer' | 'content'; } /**