From fed3f6028b249c0c9c733aaeb077386dd5a4e298 Mon Sep 17 00:00:00 2001 From: Nikolas Thiel <21254390+Jopo-JP@users.noreply.github.com> Date: Mon, 9 Feb 2026 13:24:30 +0000 Subject: [PATCH 1/3] feat(cli): add --watch flag to view command --- src/cli/index.ts | 5 +- src/core/view.ts | 106 ++++++++++++++++++++++++++++++----------- test/core/view.test.ts | 30 ++++++------ 3 files changed, 95 insertions(+), 46 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index 006f21c36..e12b72e84 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -190,10 +190,11 @@ program program .command('view') .description('Display an interactive dashboard of specs and changes') - .action(async () => { + .option('-w, --watch', 'Watch for changes and update real-time') + .action(async (options?: { watch?: boolean }) => { try { const viewCommand = new ViewCommand(); - await viewCommand.execute('.'); + await viewCommand.execute('.', { watch: options?.watch }); } catch (error) { console.log(); // Empty line for spacing ora().fail(`Error: ${(error as Error).message}`); diff --git a/src/core/view.ts b/src/core/view.ts index e67c35268..b7d172c83 100644 --- a/src/core/view.ts +++ b/src/core/view.ts @@ -5,37 +5,78 @@ import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progre import { MarkdownParser } from './parsers/markdown-parser.js'; export class ViewCommand { - async execute(targetPath: string = '.'): Promise { + async execute(targetPath: string = '.', options: { watch?: boolean } = {}): Promise { const openspecDir = path.join(targetPath, 'openspec'); - + if (!fs.existsSync(openspecDir)) { console.error(chalk.red('No openspec directory found')); process.exit(1); } - console.log(chalk.bold('\nOpenSpec Dashboard\n')); - console.log('═'.repeat(60)); + if (options.watch) { + let lastOutput = ''; + + const update = async () => { + try { + const output = await this.getDashboardOutput(openspecDir); + + // Only update if content changed + if (output !== lastOutput) { + lastOutput = output; + // Clear screen, scrollback and move cursor to home + process.stdout.write('\x1B[2J\x1B[3J\x1B[H' + output); + } + } catch (error) { + console.error(chalk.red(`Error updating dashboard: ${(error as Error).message}`)); + } + }; + + // Initial render + await update(); + + const interval = setInterval(update, 2000); + + process.on('SIGINT', () => { + clearInterval(interval); + console.log('\nExiting watch mode...'); + process.exit(0); + }); + + // Keep the process running + await new Promise(() => {}); + } else { + const output = await this.getDashboardOutput(openspecDir); + console.log(output); + } + } + + private async getDashboardOutput(openspecDir: string): Promise { + let output = ''; + const append = (str: string) => { output += str + '\n'; }; + + append(chalk.bold('\nOpenSpec Dashboard\n')); + append('═'.repeat(60)); // Get changes and specs data const changesData = await this.getChangesData(openspecDir); const specsData = await this.getSpecsData(openspecDir); // Display summary metrics - this.displaySummary(changesData, specsData); + output += this.getSummaryOutput(changesData, specsData); // Display draft changes if (changesData.draft.length > 0) { - console.log(chalk.bold.gray('\nDraft Changes')); - console.log('─'.repeat(60)); + append(chalk.bold.gray('\nDraft Changes')); + append('─'.repeat(60)); changesData.draft.forEach((change) => { - console.log(` ${chalk.gray('○')} ${change.name}`); + append(` ${chalk.gray('○')} ${change.name}`); }); } // Display active changes if (changesData.active.length > 0) { - console.log(chalk.bold.cyan('\nActive Changes')); - console.log('─'.repeat(60)); + append(chalk.bold.cyan('\nActive Changes')); + append('─'.repeat(60)); changesData.active.forEach((change) => { const progressBar = this.createProgressBar(change.progress.completed, change.progress.total); const percentage = @@ -43,7 +84,7 @@ export class ViewCommand { ? Math.round((change.progress.completed / change.progress.total) * 100) : 0; - console.log( + append( ` ${chalk.yellow('◉')} ${chalk.bold(change.name.padEnd(30))} ${progressBar} ${chalk.dim(`${percentage}%`)}` ); }); @@ -51,31 +92,33 @@ export class ViewCommand { // Display completed changes if (changesData.completed.length > 0) { - console.log(chalk.bold.green('\nCompleted Changes')); - console.log('─'.repeat(60)); + append(chalk.bold.green('\nCompleted Changes')); + append('─'.repeat(60)); changesData.completed.forEach((change) => { - console.log(` ${chalk.green('✓')} ${change.name}`); + append(` ${chalk.green('✓')} ${change.name}`); }); } // Display specifications if (specsData.length > 0) { - console.log(chalk.bold.blue('\nSpecifications')); - console.log('─'.repeat(60)); - + append(chalk.bold.blue('\nSpecifications')); + append('─'.repeat(60)); + // Sort specs by requirement count (descending) specsData.sort((a, b) => b.requirementCount - a.requirementCount); - + specsData.forEach(spec => { const reqLabel = spec.requirementCount === 1 ? 'requirement' : 'requirements'; - console.log( + append( ` ${chalk.blue('▪')} ${chalk.bold(spec.name.padEnd(30))} ${chalk.dim(`${spec.requirementCount} ${reqLabel}`)}` ); }); } - console.log('\n' + '═'.repeat(60)); - console.log(chalk.dim(`\nUse ${chalk.white('openspec list --changes')} or ${chalk.white('openspec list --specs')} for detailed views`)); + output += '\n' + '═'.repeat(60) + '\n'; + output += chalk.dim(`\nUse ${chalk.white('openspec list --changes')} or ${chalk.white('openspec list --specs')} for detailed views`); + + return output; } private async getChangesData(openspecDir: string): Promise<{ @@ -161,10 +204,13 @@ export class ViewCommand { return specs; } - private displaySummary( + private getSummaryOutput( changesData: { draft: any[]; active: any[]; completed: any[] }, specsData: any[] - ): void { + ): string { + let output = ''; + const append = (str: string) => { output += str + '\n'; }; + const totalChanges = changesData.draft.length + changesData.active.length + changesData.completed.length; const totalSpecs = specsData.length; @@ -184,24 +230,26 @@ export class ViewCommand { // This is a simplification }); - console.log(chalk.bold('Summary:')); - console.log( + append(chalk.bold('Summary:')); + append( ` ${chalk.cyan('●')} Specifications: ${chalk.bold(totalSpecs)} specs, ${chalk.bold(totalRequirements)} requirements` ); if (changesData.draft.length > 0) { - console.log(` ${chalk.gray('●')} Draft Changes: ${chalk.bold(changesData.draft.length)}`); + append(` ${chalk.gray('●')} Draft Changes: ${chalk.bold(changesData.draft.length)}`); } - console.log( + append( ` ${chalk.yellow('●')} Active Changes: ${chalk.bold(changesData.active.length)} in progress` ); - console.log(` ${chalk.green('●')} Completed Changes: ${chalk.bold(changesData.completed.length)}`); + append(` ${chalk.green('●')} Completed Changes: ${chalk.bold(changesData.completed.length)}`); if (totalTasks > 0) { const overallProgress = Math.round((completedTasks / totalTasks) * 100); - console.log( + append( ` ${chalk.magenta('●')} Task Progress: ${chalk.bold(`${completedTasks}/${totalTasks}`)} (${overallProgress}% complete)` ); } + + return output; } private createProgressBar(completed: number, total: number, width: number = 20): string { diff --git a/test/core/view.test.ts b/test/core/view.test.ts index b8b56df1e..bdd966223 100644 --- a/test/core/view.test.ts +++ b/test/core/view.test.ts @@ -49,29 +49,27 @@ describe('ViewCommand', () => { const viewCommand = new ViewCommand(); await viewCommand.execute(tempDir); - const output = logOutput.map(stripAnsi).join('\n'); + // Combine all log output and split by newlines to handle both single-call and multi-call logging + const allOutput = logOutput.join('\n'); + const lines = allOutput.split('\n').map(stripAnsi); // Draft section should contain empty and no-tasks changes - expect(output).toContain('Draft Changes'); - expect(output).toContain('empty-change'); - expect(output).toContain('no-tasks-change'); + expect(allOutput).toContain('Draft Changes'); + expect(allOutput).toContain('empty-change'); + expect(allOutput).toContain('no-tasks-change'); // Completed section should only contain changes with all tasks done - expect(output).toContain('Completed Changes'); - expect(output).toContain('completed-change'); + expect(allOutput).toContain('Completed Changes'); + expect(allOutput).toContain('completed-change'); // Verify empty-change and no-tasks-change are in Draft section (marked with ○) - const draftLines = logOutput - .map(stripAnsi) - .filter((line) => line.includes('○')); + const draftLines = lines.filter((line) => line.includes('○')); const draftNames = draftLines.map((line) => line.trim().replace('○ ', '')); expect(draftNames).toContain('empty-change'); expect(draftNames).toContain('no-tasks-change'); // Verify completed-change is in Completed section (marked with ✓) - const completedLines = logOutput - .map(stripAnsi) - .filter((line) => line.includes('✓')); + const completedLines = lines.filter((line) => line.includes('✓')); const completedNames = completedLines.map((line) => line.trim().replace('✓ ', '')); expect(completedNames).toContain('completed-change'); expect(completedNames).not.toContain('empty-change'); @@ -109,9 +107,11 @@ describe('ViewCommand', () => { const viewCommand = new ViewCommand(); await viewCommand.execute(tempDir); - const activeLines = logOutput - .map(stripAnsi) - .filter(line => line.includes('◉')); + // Combine all log output and split by newlines to handle both single-call and multi-call logging + const allOutput = logOutput.join('\n'); + const lines = allOutput.split('\n').map(stripAnsi); + + const activeLines = lines.filter(line => line.includes('◉')); const activeOrder = activeLines.map(line => { const afterBullet = line.split('◉')[1] ?? ''; From 9fc3fee113c4e88e32072c2a5fbb463fd436624c Mon Sep 17 00:00:00 2001 From: Nikolas Thiel <21254390+Jopo-JP@users.noreply.github.com> Date: Mon, 9 Feb 2026 15:22:58 +0000 Subject: [PATCH 2/3] refactor: address review feedback for view --watch flag - Fix SIGINT listener leak by using process.once - Improve error visibility in watch mode by clearing screen and showing errors in red - Remove dead code (unused totalChanges and empty loop) - Standardize string construction for consistent output - Add AbortSignal support and unit tests for watch mode --- src/core/view.ts | 58 +++++++++++++++++++++++++++--------------- test/core/view.test.ts | 33 ++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 20 deletions(-) diff --git a/src/core/view.ts b/src/core/view.ts index b7d172c83..8952ca1ab 100644 --- a/src/core/view.ts +++ b/src/core/view.ts @@ -5,7 +5,7 @@ import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progre import { MarkdownParser } from './parsers/markdown-parser.js'; export class ViewCommand { - async execute(targetPath: string = '.', options: { watch?: boolean } = {}): Promise { + async execute(targetPath: string = '.', options: { watch?: boolean; signal?: AbortSignal } = {}): Promise { const openspecDir = path.join(targetPath, 'openspec'); if (!fs.existsSync(openspecDir)) { @@ -27,7 +27,9 @@ export class ViewCommand { process.stdout.write('\x1B[2J\x1B[3J\x1B[H' + output); } } catch (error) { - console.error(chalk.red(`Error updating dashboard: ${(error as Error).message}`)); + // Clear screen and show error prominently + const errorOutput = chalk.red(`\nError updating dashboard: ${(error as Error).message}\n`); + process.stdout.write('\x1B[2J\x1B[3J\x1B[H' + errorOutput); } }; @@ -36,14 +38,35 @@ export class ViewCommand { const interval = setInterval(update, 2000); - process.on('SIGINT', () => { + const cleanup = () => { clearInterval(interval); - console.log('\nExiting watch mode...'); - process.exit(0); - }); + if (!options.signal?.aborted) { + console.log('\nExiting watch mode...'); + process.exit(0); + } + }; + + // Register cleanup handler + process.once('SIGINT', cleanup); - // Keep the process running - await new Promise(() => {}); + // Keep the process running until aborted or SIGINT + if (options.signal) { + if (options.signal.aborted) { + clearInterval(interval); + process.removeListener('SIGINT', cleanup); + return; + } + + await new Promise((resolve) => { + options.signal!.addEventListener('abort', () => { + clearInterval(interval); + process.removeListener('SIGINT', cleanup); + resolve(); + }); + }); + } else { + await new Promise(() => {}); + } } else { const output = await this.getDashboardOutput(openspecDir); console.log(output); @@ -115,8 +138,10 @@ export class ViewCommand { }); } - output += '\n' + '═'.repeat(60) + '\n'; - output += chalk.dim(`\nUse ${chalk.white('openspec list --changes')} or ${chalk.white('openspec list --specs')} for detailed views`); + append(''); + append('═'.repeat(60)); + append(''); + append(chalk.dim(`Use ${chalk.white('openspec list --changes')} or ${chalk.white('openspec list --specs')} for detailed views`)); return output; } @@ -174,18 +199,18 @@ export class ViewCommand { private async getSpecsData(openspecDir: string): Promise> { const specsDir = path.join(openspecDir, 'specs'); - + if (!fs.existsSync(specsDir)) { return []; } const specs: Array<{ name: string; requirementCount: number }> = []; const entries = fs.readdirSync(specsDir, { withFileTypes: true }); - + for (const entry of entries) { if (entry.isDirectory()) { const specFile = path.join(specsDir, entry.name, 'spec.md'); - + if (fs.existsSync(specFile)) { try { const content = fs.readFileSync(specFile, 'utf-8'); @@ -211,8 +236,6 @@ export class ViewCommand { let output = ''; const append = (str: string) => { output += str + '\n'; }; - const totalChanges = - changesData.draft.length + changesData.active.length + changesData.completed.length; const totalSpecs = specsData.length; const totalRequirements = specsData.reduce((sum, spec) => sum + spec.requirementCount, 0); @@ -225,11 +248,6 @@ export class ViewCommand { completedTasks += change.progress.completed; }); - changesData.completed.forEach(() => { - // Completed changes count as 100% done (we don't know exact task count) - // This is a simplification - }); - append(chalk.bold('Summary:')); append( ` ${chalk.cyan('●')} Specifications: ${chalk.bold(totalSpecs)} specs, ${chalk.bold(totalRequirements)} requirements` diff --git a/test/core/view.test.ts b/test/core/view.test.ts index bdd966223..465ad9fd7 100644 --- a/test/core/view.test.ts +++ b/test/core/view.test.ts @@ -125,5 +125,38 @@ describe('ViewCommand', () => { 'gamma-change' ]); }); + + it('runs in watch mode and respects AbortSignal', async () => { + const changesDir = path.join(tempDir, 'openspec', 'changes'); + await fs.mkdir(changesDir, { recursive: true }); + await fs.mkdir(path.join(changesDir, 'watch-change'), { recursive: true }); + + // Create initial state + await fs.writeFile( + path.join(changesDir, 'watch-change', 'tasks.md'), + '- [ ] Task 1\n' + ); + + const viewCommand = new ViewCommand(); + const controller = new AbortController(); + + // Start watch mode in background + const watchPromise = viewCommand.execute(tempDir, { watch: true, signal: controller.signal }); + + // Allow initial render + await new Promise(resolve => setTimeout(resolve, 100)); + + // Verify initial output + const initialOutput = logOutput.join('\n'); // Note: ViewCommand uses process.stdout.write which we haven't mocked here fully for this test setup, + // but let's assume for this specific test structure we might need to mock process.stdout.write or adjust expectations. + // Since we mocked console.log in beforeEach, and ViewCommand switched to process.stdout.write, + // we need to mock process.stdout.write for this test to be effective. + + // Abort watch mode + controller.abort(); + + // Should resolve quickly + await expect(watchPromise).resolves.toBeUndefined(); + }); }); From d2ff3375414591e767b065d1a74a0e042e459b75 Mon Sep 17 00:00:00 2001 From: Nikolas Thiel <21254390+Jopo-JP@users.noreply.github.com> Date: Thu, 12 Feb 2026 07:46:54 +0000 Subject: [PATCH 3/3] refactor: address PR #685 review feedback - Remove process.exit from ViewCommand, return Promise instead - Move signal handling (SIGINT) to CLI entry point - Add AbortSignal support to ViewCommand.execute - Improve watch mode tests with stdout mocking - Add --watch flag to command registry and docs Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/cli.md | 8 +++++- src/cli/index.ts | 13 +++++++++- src/core/completions/command-registry.ts | 8 +++++- src/core/view.ts | 19 +++------------ test/core/view.test.ts | 31 ++++++++++++++++-------- 5 files changed, 50 insertions(+), 29 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index e064e9dac..08327cf72 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -197,9 +197,15 @@ Active changes: Display an interactive dashboard for exploring specs and changes. ``` -openspec view +openspec view [options] ``` +**Options:** + +| Option | Description | +|--------|-------------| +| `--watch`, `-w` | Watch for file changes and refresh dashboard | + Opens a terminal-based interface for navigating your project's specifications and changes. --- diff --git a/src/cli/index.ts b/src/cli/index.ts index e12b72e84..0e6adb042 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -194,7 +194,18 @@ program .action(async (options?: { watch?: boolean }) => { try { const viewCommand = new ViewCommand(); - await viewCommand.execute('.', { watch: options?.watch }); + + if (options?.watch) { + const controller = new AbortController(); + + process.once('SIGINT', () => { + controller.abort(); + }); + + await viewCommand.execute('.', { watch: true, signal: controller.signal }); + } else { + await viewCommand.execute('.', { watch: false }); + } } catch (error) { console.log(); // Empty line for spacing ora().fail(`Error: ${(error as Error).message}`); diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index 8de89351c..924d93182 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -70,7 +70,13 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ { name: 'view', description: 'Display an interactive dashboard of specs and changes', - flags: [], + flags: [ + { + name: 'watch', + short: 'w', + description: 'Watch for file changes and refresh dashboard', + }, + ], }, { name: 'validate', diff --git a/src/core/view.ts b/src/core/view.ts index 8952ca1ab..4c2725d4f 100644 --- a/src/core/view.ts +++ b/src/core/view.ts @@ -9,8 +9,7 @@ export class ViewCommand { const openspecDir = path.join(targetPath, 'openspec'); if (!fs.existsSync(openspecDir)) { - console.error(chalk.red('No openspec directory found')); - process.exit(1); + throw new Error('No openspec directory found'); } if (options.watch) { @@ -38,29 +37,17 @@ export class ViewCommand { const interval = setInterval(update, 2000); - const cleanup = () => { - clearInterval(interval); - if (!options.signal?.aborted) { - console.log('\nExiting watch mode...'); - process.exit(0); - } - }; - - // Register cleanup handler - process.once('SIGINT', cleanup); - - // Keep the process running until aborted or SIGINT + // Keep the process running until aborted if (options.signal) { if (options.signal.aborted) { clearInterval(interval); - process.removeListener('SIGINT', cleanup); return; } await new Promise((resolve) => { options.signal!.addEventListener('abort', () => { clearInterval(interval); - process.removeListener('SIGINT', cleanup); + console.log('\nExiting watch mode...'); resolve(); }); }); diff --git a/test/core/view.test.ts b/test/core/view.test.ts index 465ad9fd7..1ee26e7ae 100644 --- a/test/core/view.test.ts +++ b/test/core/view.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; @@ -129,9 +129,9 @@ describe('ViewCommand', () => { it('runs in watch mode and respects AbortSignal', async () => { const changesDir = path.join(tempDir, 'openspec', 'changes'); await fs.mkdir(changesDir, { recursive: true }); - await fs.mkdir(path.join(changesDir, 'watch-change'), { recursive: true }); // Create initial state + await fs.mkdir(path.join(changesDir, 'watch-change'), { recursive: true }); await fs.writeFile( path.join(changesDir, 'watch-change', 'tasks.md'), '- [ ] Task 1\n' @@ -140,23 +140,34 @@ describe('ViewCommand', () => { const viewCommand = new ViewCommand(); const controller = new AbortController(); + // Mock process.stdout.write + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + // Start watch mode in background const watchPromise = viewCommand.execute(tempDir, { watch: true, signal: controller.signal }); // Allow initial render await new Promise(resolve => setTimeout(resolve, 100)); - // Verify initial output - const initialOutput = logOutput.join('\n'); // Note: ViewCommand uses process.stdout.write which we haven't mocked here fully for this test setup, - // but let's assume for this specific test structure we might need to mock process.stdout.write or adjust expectations. - // Since we mocked console.log in beforeEach, and ViewCommand switched to process.stdout.write, - // we need to mock process.stdout.write for this test to be effective. - // Abort watch mode controller.abort(); - // Should resolve quickly - await expect(watchPromise).resolves.toBeUndefined(); + // Should resolve + await watchPromise; + + // Verify calls + const calls = stdoutSpy.mock.calls.map(args => args[0].toString()); + const fullOutput = calls.join(''); + + // Check for clear screen + expect(fullOutput).toContain('\x1B[2J'); + // Check for dashboard header + expect(fullOutput).toContain('OpenSpec Dashboard'); + // Check for content + expect(fullOutput).toContain('watch-change'); + + // Restore mock + stdoutSpy.mockRestore(); }); });