Skip to content

Conversation

@Jopo-JP
Copy link

@Jopo-JP Jopo-JP commented Feb 9, 2026

Summary

This PR introduces a --watch (-w) flag to the view command, significantly improving the Developer Experience (DX) by allowing real-time monitoring of specifications and changes without manual refreshes.

Problem

Currently, users must repeatedly execute openspec view to check the status of their specs or track progress on changes. This breaks flow and makes it difficult to visualize updates as they happen.

Solution

I implemented a robust watch mode that refreshes the dashboard automatically. Key implementation details include:

  • Smart Rendering: The dashboard is only repainted when the underlying data actually changes. This eliminates the annoying "flicker" common in simple CLI loops and keeps the terminal scrollback clean.
  • Full Fidelity: All existing ANSI colors, formatting, and layout from the standard view command are strictly preserved.
  • Graceful Handling: The process handles SIGINT (Ctrl+C) correctly, ensuring the cursor is restored and the process exits cleanly.
  • Efficient Polling: Uses a 2-second polling interval which strikes a balance between responsiveness and resource usage.

Test Plan

  • Unit Tests: Added comprehensive tests in test/core/view.test.ts to verify the watch logic and ensure the draft/active/completed sorting remains deterministic.
  • Manual Verification:
    1. Run npm run dev:cli -- view --watch
    2. Modify a spec or task file in another terminal.
    3. Verify the dashboard updates instantly without flickering.
    4. Scroll up/down in the terminal (updates should not forcibly scroll you to the top unless content changes).

Summary by CodeRabbit

  • New Features

    • Added --watch (-w) flag to the view command for real-time monitoring
    • View refreshes every 2 seconds in watch mode, only re-rendering when content changes
    • Watch mode supports cancellation and shows clear-screen refreshes with prominent error display on update failures
  • Tests

    • Added/updated tests covering watch mode, cancellation, and consolidated output assertions
  • Documentation

    • Updated CLI docs to document the new --watch option and usage changes

@Jopo-JP Jopo-JP requested a review from TabishB as a code owner February 9, 2026 13:35
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 9, 2026

📝 Walkthrough

Walkthrough

Adds a --watch/-w option to the CLI and extends ViewCommand.execute to support watch mode with AbortSignal. Watch mode renders the dashboard to a string, refreshes every 2s only on content change, clears the screen between renders, and supports graceful shutdown via SIGINT/AbortController.

Changes

Cohort / File(s) Summary
CLI entrypoint
src/cli/index.ts
Accepts --watch/-w, constructs AbortController when watching, wires SIGINT to abort(), and calls ViewCommand.execute(path, { watch, signal }) or non-watch path.
Core view logic
src/core/view.ts
ViewCommand.execute signature expanded to (..., options?: { watch?: boolean; signal?: AbortSignal }). Rendering refactored to build dashboard strings (getDashboardOutput, getSummaryOutput), non-watch prints full string, watch mode: initial render then loop every 2s, clear screen and reprint only on content changes, and show errors on update.
Command registry / flags
src/core/completions/command-registry.ts
Adds watch (-w) flag to the view command registry.
Docs
docs/cli.md
Updates view usage to openspec view [options] and documents new --watch / -w option.
Tests
test/core/view.test.ts
Adjusts logging assertions to aggregate multi-call output, adapts section checks, and adds a watch-mode test using AbortController that asserts clear-screen sequence and watched output.
Manifest / minor
package.json (manifest edits)
Small manifest changes recorded (+/- lines).

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant CLI
    participant ViewCommand
    participant Terminal

    User->>CLI: run `openspec view --watch`
    CLI->>ViewCommand: execute(path, { watch: true, signal })
    ViewCommand->>ViewCommand: build dashboard string (getDashboardOutput)
    ViewCommand->>Terminal: write initial dashboard

    loop every 2s
        ViewCommand->>ViewCommand: rebuild dashboard string
        alt content changed
            ViewCommand->>Terminal: clear screen (ESC)
            ViewCommand->>Terminal: write updated dashboard
        else no change
            Note over ViewCommand,Terminal: skip reprint
        end
    end

    User->>CLI: Ctrl+C (SIGINT)
    CLI->>ViewCommand: signal.abort()
    ViewCommand->>Terminal: stop loop and exit
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 I nibble lines and stitch them bright,
I hop each two-second, watch the light,
I clear the screen and show what's new,
A gentle SIGINT, and I’m through —
Hooray for dashboards, fresh and tight.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main feature: adding a --watch flag to the view command. It accurately reflects the primary change across all modified files.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

No actionable comments were generated in the recent review. 🎉

🧹 Recent nitpick comments
src/core/view.ts (2)

38-38: setInterval with an async callback can overlap if update() takes longer than 2 seconds.

Since setInterval doesn't await the returned promise, a slow getDashboardOutput (e.g., large project with many spec files to parse) could cause concurrent updates racing on process.stdout.write. Consider using a setTimeout-based loop that schedules the next tick only after the current update completes.

♻️ Proposed setTimeout-based approach
-      const interval = setInterval(update, 2000);
+      let timeout: ReturnType<typeof setTimeout>;
+      const scheduleNext = () => {
+        timeout = setTimeout(async () => {
+          await update();
+          scheduleNext();
+        }, 2000);
+      };
+      scheduleNext();

Then replace clearInterval(interval) with clearTimeout(timeout) in both the early-abort check and the abort listener.


54-56: No-signal path creates a permanently pending promise.

When options.signal is not provided, await new Promise(() => {}) never resolves, so execute() never returns. This works in CLI context (SIGINT kills the process), but any programmatic caller without a signal will hang indefinitely. Consider documenting this contract or requiring signal when watch is true.

test/core/view.test.ts (2)

129-171: Watch-mode test: mock restoration should be in afterEach for resilience.

If an assertion fails before Line 170, stdoutSpy.mockRestore() is never called, leaving process.stdout.write mocked for any subsequent tests. Move the spy setup/teardown into the existing beforeEach/afterEach hooks, or use a try/finally.

♻️ Safer mock lifecycle
     // 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 });
-    ...
-    // Restore mock
-    stdoutSpy.mockRestore();
+    try {
+      const watchPromise = viewCommand.execute(tempDir, { watch: true, signal: controller.signal });
+
+      await new Promise(resolve => setTimeout(resolve, 100));
+      controller.abort();
+      await watchPromise;
+
+      const calls = stdoutSpy.mock.calls.map(args => args[0].toString());
+      const fullOutput = calls.join('');
+
+      expect(fullOutput).toContain('\x1B[2J');
+      expect(fullOutput).toContain('OpenSpec Dashboard');
+      expect(fullOutput).toContain('watch-change');
+    } finally {
+      stdoutSpy.mockRestore();
+    }

149-150: Timing-dependent wait (100ms) could be flaky on slow CI runners.

The setTimeout(resolve, 100) assumes the initial render (including file I/O) completes within 100ms. This is likely fine for local runs with temp-dir fixtures, but consider a slightly more generous timeout (e.g., 500ms) or polling for the spy to have been called.

Tip

Issue Planner is now in beta. Read the docs and try it out! Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link

greptile-apps bot commented Feb 9, 2026

Greptile Overview

Greptile Summary

This PR adds a --watch/-w flag to openspec view and refactors the view rendering into a string-producing path so it can be periodically re-rendered without flicker. In watch mode, ViewCommand.execute() polls every 2s, recomputes the dashboard output, and only clears/repaints the terminal when the rendered content changes; non-watch mode prints the rendered output once.

Key areas touched:

  • src/cli/index.ts wires the new flag into the CLI and forwards it to ViewCommand.
  • src/core/view.ts adds watch-mode looping + getDashboardOutput() / getSummaryOutput() helpers.
  • test/core/view.test.ts updates assertions to account for consolidated output.

Confidence Score: 4/5

  • Mostly safe to merge, but there are a couple correctness/robustness issues to address first.
  • Core functionality is straightforward and isolated, but watch mode currently registers a persistent SIGINT listener with process.on and never removes it (can accumulate listeners in long-lived processes), and the updated tests use raw output for substring assertions while separately stripping ANSI codes, which can make them fail in color-enabled environments.
  • src/core/view.ts, test/core/view.test.ts

Important Files Changed

Filename Overview
src/cli/index.ts Adds --watch/-w option to view and forwards it into ViewCommand.execute().
src/core/view.ts Implements watch mode by periodically re-rendering dashboard output; introduces a persistent SIGINT handler that isn’t removed (listener accumulation risk).
test/core/view.test.ts Updates tests to handle single-call logging; introduces inconsistent ANSI stripping in some assertions that can make tests flaky in color-enabled environments.

Sequence Diagram

sequenceDiagram
  participant U as User
  participant CLI as src/cli/index.ts (commander)
  participant V as ViewCommand
  participant FS as File system
  participant T as Task progress utils

  U->>CLI: openspec view --watch
  CLI->>V: execute(targetPath, {watch:true})
  loop every 2s
    V->>FS: read openspec/changes + openspec/specs
    V->>T: getTaskProgressForChange(...)
    V-->>V: getDashboardOutput()
    alt output changed
      V->>CLI: stdout.write(clear + output)
    else unchanged
      V-->>CLI: no write
    end
  end
  U->>CLI: Ctrl+C (SIGINT)
  CLI->>V: SIGINT handler
  V->>V: clearInterval + exit(0)
Loading

Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

3 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

src/core/view.ts Outdated
Comment on lines 39 to 43
process.on('SIGINT', () => {
clearInterval(interval);
console.log('\nExiting watch mode...');
process.exit(0);
});
Copy link

Choose a reason for hiding this comment

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

SIGINT handler leaks

process.on('SIGINT', ...) is registered inside execute() every time watch mode runs, but it’s never removed. In a long-lived process (or if ViewCommand.execute() is invoked multiple times in the same Node process, e.g. tests/embedders), this accumulates listeners and can trigger MaxListenersExceededWarning / multiple exit handlers firing. Prefer process.once('SIGINT', ...) or remove the listener when clearing the interval.

Comment on lines 56 to +59
// 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');
Copy link

Choose a reason for hiding this comment

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

ANSI stripping mismatch

These assertions use allOutput (raw, potentially ANSI-colored) while the new lines array is stripAnsi’d. If chalk color output is enabled in the test environment, expect(allOutput).toContain('Draft Changes') / similar can fail due to escape codes splitting the plain text. Use the stripped version (lines.join('\n') or stripAnsi(allOutput)) consistently for substring assertions.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/core/view.ts`:
- Around line 29-31: The catch in the update loop (where getDashboardOutput is
called) only logs to stderr so the stale dashboard remains visible; change the
catch in src/core/view.ts to clear or overwrite the painted dashboard and print
the error prominently to stdout (use the same screen-clearing helper used
elsewhere or write \u001b[2J\u001b[0;0H to stdout) and include the error message
styled with chalk; additionally add a simple retry/failure counter (e.g.,
failedUpdateCount) inside the updater function to exit or stop retrying after N
consecutive failures to avoid spamming stderr when getDashboardOutput repeatedly
throws.
🧹 Nitpick comments (6)
src/core/view.ts (5)

39-43: SIGINT listener is never removed and could leak in tests.

process.on('SIGINT', ...) registers a permanent listener. If execute is ever called more than once (e.g., in a test harness), listeners accumulate. Consider using process.once('SIGINT', ...) or storing the handler and calling process.removeListener alongside clearInterval.

Proposed fix
-      process.on('SIGINT', () => {
+      const onSigint = () => {
         clearInterval(interval);
         console.log('\nExiting watch mode...');
         process.exit(0);
-      });
+      };
+      process.once('SIGINT', onSigint);

46-46: await new Promise(() => {}) blocks forever — intentional but fragile.

This is the standard "keep-alive" pattern for CLI watch loops, but it means execute() never returns in watch mode. The only exit path is process.exit(0) inside the SIGINT handler. This makes unit-testing watch mode very difficult (no way to programmatically stop it). Consider accepting an AbortSignal or returning the interval handle for testability.


214-215: totalChanges is computed but never used.

This variable is dead code. Remove it or wire it into the summary output.

Proposed fix
-    const totalChanges =
-      changesData.draft.length + changesData.active.length + changesData.completed.length;

228-231: No-op forEach on completed changes.

This loop body is empty. Either remove it or implement the intended logic (e.g., accumulating task counts from completed changes into totalTasks/completedTasks).

Proposed fix — remove the no-op loop
-    changesData.completed.forEach(() => {
-      // Completed changes count as 100% done (we don't know exact task count)
-      // This is a simplification
-    });

53-121: Inconsistent output assembly: append vs direct output +=.

Lines 118–119 concatenate directly to output (skipping append), so they don't get the trailing \n that append adds. Meanwhile, getSummaryOutput (Line 65) is also concatenated directly. The mixing is easy to misread and could introduce subtle double-newline or missing-newline issues during future edits. Consider using append consistently, or document the intent.

test/core/view.test.ts (1)

9-128: No tests cover watch mode.

The watch mode path (options.watch = true) is untestable with the current design since execute never returns. Consider adding a note/TODO, or refactoring execute to accept an AbortSignal so watch mode can be stopped programmatically in tests.

- 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
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/core/view.ts`:
- 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.
🧹 Nitpick comments (3)
src/core/view.ts (3)

39-39: Async callback in setInterval can cause overlapping executions.

setInterval(update, 2000) fires every 2 s regardless of whether the previous update() (which is async) has finished. If getDashboardOutput ever takes longer than 2 s (e.g., large project, slow disk), two invocations will race and interleave process.stdout.write calls, producing garbled output.

A simple fix is to use a recursive setTimeout instead:

Proposed fix
-      const interval = setInterval(update, 2000);
+      let timeout: ReturnType<typeof setTimeout>;
+      const scheduleNext = () => {
+        timeout = setTimeout(async () => {
+          await update();
+          scheduleNext();
+        }, 2000);
+      };
+      scheduleNext();

Adjust cleanup references from clearInterval(interval) to clearTimeout(timeout) accordingly.


60-69: Never-resolving promise on Line 68 keeps the function from ever returning.

When no AbortSignal is provided, await new Promise(() => {}) hangs forever — the only escape is process.exit(0) inside the SIGINT handler. This is acceptable for CLI entry points but makes the method impossible to use programmatically or in integration tests without an AbortSignal.

Consider documenting this contract explicitly (e.g., a JSDoc note that callers must pass signal if they need the function to return), or provide a fallback that resolves when SIGINT fires instead of calling process.exit:

Sketch
      } else {
-        await new Promise(() => {});
+        await new Promise<void>((resolve) => {
+          const onSigint = () => {
+            clearInterval(interval);
+            console.log('\nExiting watch mode...');
+            resolve();
+          };
+          process.once('SIGINT', onSigint);
+        });
       }

This would unify the exit path and let the function return cleanly without process.exit.


41-47: process.exit(0) in cleanup is a hard exit that bypasses any callers awaiting execute.

When SIGINT fires (without an AbortSignal), the cleanup handler calls process.exit(0), which immediately terminates the process. Any finally blocks or post-execute logic in the caller will never run. This is related to the never-resolving promise on Line 68 — if the promise were made resolvable on SIGINT (as suggested above), the process.exit here becomes unnecessary and cleanup becomes more composable.

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.

src/core/view.ts Outdated
clearInterval(interval);
if (!options.signal?.aborted) {
console.log('\nExiting watch mode...');
process.exit(0);
Copy link
Contributor

Choose a reason for hiding this comment

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

Calling process.exit(0) inside ViewCommand couples core logic to process lifecycle and can terminate host processes unexpectedly when reused outside the CLI entrypoint. Suggested outcome: make execute() return cleanly from cleanup (clear interval/remove listeners), and let src/cli/index.ts own process-exit behavior for SIGINT.

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,
Copy link
Contributor

Choose a reason for hiding this comment

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

This test currently verifies AbortSignal resolution, but it does not verify watch-mode rendering behavior. initialOutput is never asserted and process.stdout.write is not mocked, so UI regressions can pass silently. Suggested outcome: mock/spy process.stdout.write, assert the initial render includes watch-change, and (optionally) assert a second render after mutating tasks.md.

.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.

- Remove process.exit from ViewCommand, return Promise<void> 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) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants