Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---
Expand Down
16 changes: 14 additions & 2 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,10 +190,22 @@ 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')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding --watch here is good, but it currently creates a docs/completions drift: view still has no flags in src/core/completions/command-registry.ts, and docs/cli.md does not mention watch mode. Suggested outcome: update command-registry + docs in the same PR so runtime behavior, generated completions, and docs stay in sync.

.action(async (options?: { watch?: boolean }) => {
try {
const viewCommand = new ViewCommand();
await viewCommand.execute('.');

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}`);
Expand Down
8 changes: 7 additions & 1 deletion src/core/completions/command-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
135 changes: 94 additions & 41 deletions src/core/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,77 +5,132 @@ import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progre
import { MarkdownParser } from './parsers/markdown-parser.js';

export class ViewCommand {
async execute(targetPath: string = '.'): Promise<void> {
async execute(targetPath: string = '.', options: { watch?: boolean; signal?: AbortSignal } = {}): Promise<void> {
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) {
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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

\x1B[3J clears terminal scrollback history.

The PR description states the goal is to "preserve terminal scrolling" and avoid forcing scroll, but \x1B[3J explicitly erases the scrollback buffer. If the intent is only to clear the visible screen and reposition the cursor, drop the \x1B[3J sequence and keep only \x1B[2J\x1B[H. Same applies to Line 32.

🤖 Prompt for AI Agents
In `@src/core/view.ts` at line 27, The code currently clears the terminal
including scrollback by writing '\x1B[3J' in the process.stdout.write calls;
update both occurrences in src/core/view.ts (the process.stdout.write(...) calls
that build the clear sequence) to remove the '\x1B[3J' token so the sequence
becomes only '\x1B[2J\x1B[H' + output, thereby clearing the visible screen and
repositioning the cursor while preserving terminal scrollback.

}
} catch (error) {
// 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);
}
};

// Initial render
await update();

const interval = setInterval(update, 2000);

// Keep the process running until aborted
if (options.signal) {
if (options.signal.aborted) {
clearInterval(interval);
return;
}

await new Promise<void>((resolve) => {
options.signal!.addEventListener('abort', () => {
clearInterval(interval);
console.log('\nExiting watch mode...');
resolve();
});
});
} else {
await new Promise(() => {});
}
} else {
const output = await this.getDashboardOutput(openspecDir);
console.log(output);
}
}

private async getDashboardOutput(openspecDir: string): Promise<string> {
let output = '';
const append = (str: string) => { output += str + '\n'; };

console.log(chalk.bold('\nOpenSpec Dashboard\n'));
console.log('═'.repeat(60));
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 =
change.progress.total > 0
? 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}%`)}`
);
});
}

// 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`));
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;
}

private async getChangesData(openspecDir: string): Promise<{
Expand Down Expand Up @@ -131,18 +186,18 @@ export class ViewCommand {

private async getSpecsData(openspecDir: string): Promise<Array<{ name: string; requirementCount: number }>> {
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');
Expand All @@ -161,12 +216,13 @@ export class ViewCommand {
return specs;
}

private displaySummary(
private getSummaryOutput(
changesData: { draft: any[]; active: any[]; completed: any[] },
specsData: any[]
): void {
const totalChanges =
changesData.draft.length + changesData.active.length + changesData.completed.length;
): string {
let output = '';
const append = (str: string) => { output += str + '\n'; };

const totalSpecs = specsData.length;
const totalRequirements = specsData.reduce((sum, spec) => sum + spec.requirementCount, 0);

Expand All @@ -179,29 +235,26 @@ 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
});

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 {
Expand Down
Loading