# Round Diff View — Design Spec

## Problem

After each submit/rewrite cycle, the reviewer sees the full updated spec but has no way to tell what changed. They waste time re-reading unchanged content to find the AI's modifications. This is the most-requested feature across similar tools.

## Solution

Show a unified diff view inline in the pager after a spec reload. Removed lines appear as red-tinted ghost rows with `-` prefix, added/changed lines appear with green-tinted background and `+` prefix. Unchanged lines render normally. The diff is auto-shown on reload and persists until approve. A `\d` toggle hides/shows it.

## Design

### DiffState class

New file: `revspec/diff_state.py`

Encapsulates all diff computation and query logic. The pager and app only interact through this interface.

```python
class DiffState:
    """Computes and exposes line-level diff between two spec versions."""

    def __init__(self, old_lines: list[str], new_lines: list[str]):
        # Uses difflib.SequenceMatcher.get_opcodes() to compute diff
        # Populates:
        #   _added: set[int]  — 0-based new-file line indices that are new or changed
        #   _removed_blocks: dict[int, list[str]]  — maps new-file index to removed
        #       lines that should appear BEFORE that index. Key len(new_lines) holds
        #       removals at the end of the file.
        #   _is_active: bool  — starts True

    def is_added(self, new_idx: int) -> bool:
        """True if this new-file line was added or changed."""

    def removed_lines_before(self, new_idx: int) -> list[str]:
        """Returns removed lines that should appear before new_idx. Empty list if none."""

    def has_diff(self) -> bool:
        """True if any lines differ between old and new."""

    def toggle(self) -> bool:
        """Flip active state. Returns new state."""

    @property
    def is_active(self) -> bool:
        """Whether diff annotations are currently visible."""
```

**Diff algorithm**: `difflib.SequenceMatcher` from stdlib. `get_opcodes()` returns operations: `equal`, `replace`, `insert`, `delete`. We iterate these to populate `_added` and `_removed_blocks`:

- `equal(i1, i2, j1, j2)` — no action
- `insert(i1, i2, j1, j2)` — mark `new_lines[j1:j2]` as added
- `delete(i1, i2, j1, j2)` — store `old_lines[i1:i2]` as removed block before position `j1`
- `replace(i1, i2, j1, j2)` — store `old_lines[i1:i2]` as removed block before `j1`, mark `new_lines[j1:j2]` as added

### Lifecycle

`RevspecApp` holds `self._diff_state: DiffState | None`, initialized to `None`.

**Created** in `_do_reload()`:
1. Before `state.reset(new_lines)`, snapshot `old_lines = list(self.state.spec_lines)`
2. After reset + replay, create `DiffState(old_lines, new_lines)`
3. If `diff_state.has_diff()` is False, set `self._diff_state = None` and show transient `"Spec reloaded — no changes detected"`
4. If diff exists, set `self._diff_state = diff_state` (starts active)

**Applies to all reload triggers**: submit polling, Ctrl+R manual reload — any reload where content changed produces a diff.

**Cleared** to `None`:
- On approve (`_approve()`) — the spec is finalized, nothing to diff
- On initial app load — no previous version exists

### Visual Model Integration

In `pager.py`'s `rebuild_visual_model()`, when `diff_state` is active, interleave ghost rows:

**New row type**: `("diff_removed", text)` — a non-interactive ghost row.

**Wrapped ghost rows**: `("diff_removed_wrap", text, segment)` — continuation row for long removed lines when wrap is enabled.

**Build logic**:
```python
for i, line in enumerate(spec_lines):
    if diff_state and diff_state.is_active:
        for removed_text in diff_state.removed_lines_before(i):
            rows.append(("diff_removed", removed_text))
            # Add wrap rows if wrapping enabled
            if wrap_width and len(removed_text) > content_width:
                for seg in range(1, segments):
                    rows.append(("diff_removed_wrap", removed_text, seg))

    # Existing spec row logic (unchanged)
    rows.append(("spec", i))
    # ... existing wrap/table logic ...

# Removed lines after the last spec line
if diff_state and diff_state.is_active:
    for removed_text in diff_state.removed_lines_before(len(spec_lines)):
        rows.append(("diff_removed", removed_text))
```

**Mapping impact**:
- `_spec_to_visual`: maps spec line to visual row, skipping ghost rows — unchanged logic, just computed after ghost rows are inserted
- `spec_line_at_visual_row(y)`: returns `None` for ghost rows (or nearest spec line)
- `visual_row_for_cursor()`: unchanged — cursor only targets spec lines

### Rendering

In `render_line(y)`, three diff-related rendering paths:

**Ghost rows (`diff_removed` / `diff_removed_wrap`)**:
- Background: `THEME["diff_removed_bg"]` (dark red, `#3b1a1e`)
- `-` prefix in gutter area, colored `THEME["red"]`
- Content rendered as plain text (no markdown styling)
- No cursor prefix, no gutter indicator, no line number
- Full pager width

**Added spec lines** (real spec lines where `diff_state.is_added(idx)` is True):
- Background: `THEME["diff_added_bg"]` (dark green, `#1a3b1e`)
- `+` prefix in gutter area alongside line number, colored `THEME["green"]`
- All normal interactions: cursor, gutter indicators, commenting, search
- Markdown rendering applied normally

**Unchanged spec lines**: No diff styling. Completely normal rendering.

### Theme

Two new colors in `theme.py`:

```python
"diff_added_bg": "#1a3b1e",    # subtle dark green background
"diff_removed_bg": "#3b1a1e",  # subtle dark red background
```

Muted enough to not overwhelm content. The `+`/`-` markers in `THEME["green"]`/`THEME["red"]` provide a secondary visual signal.

### Toggle & Keybinding

**Registry entry** in `key_dispatch.py`:
```python
KeySequence("backslashd", "\\d", "diff", "_toggle_diff"),
```

**Handler** in `app.py`:
```python
def _toggle_diff(self):
    if self._diff_state is None:
        self._show_transient("No diff available", "warning")
        return
    active = self._diff_state.toggle()
    pager_widget.invalidate_table_cache()
    self._refresh()
    self._show_transient(f"Diff view {'on' if active else 'off'}")
```

**Auto-show**: `DiffState` starts with `_is_active = True`. Diff is visible immediately after reload.

**Hint bar**: `\` pending key shows `[\d] diff` alongside `[\w] wrap` and `[\n] lines`.

**Top bar**: When diff is active, show `[DIFF]` indicator in green in the top status bar.

### Navigation

Ghost rows are invisible to all navigation and interaction:

- **Cursor** (`j`/`k`): moves by spec line, skips ghost rows
- **Search** (`/`, `n`/`N`): searches only real spec lines
- **Comments** (`c`): only on real spec lines
- **Thread jumps** (`]t`/`[t`, `]r`/`[r`): operates on spec line indices
- **Line jump** (`:15`): jumps to spec line 15, unaffected by ghost row count
- **Screen positions** (`H`/`M`/`L`): map to spec lines, skip ghost rows
- **Scroll**: Textual's ScrollView handles viewport scrolling. Ghost rows are visible as you scroll through but cursor never stops on them.

### Edge Cases

- **Empty diff**: `has_diff()` returns False — no diff state created, transient message shown
- **Full rewrite**: All lines get `+`/`-` treatment — correct behavior, reviewer should see everything changed
- **Wrap + diff**: Ghost rows wrap at the same width as spec lines, using `diff_removed_wrap` row type
- **Tables in removed lines**: Rendered as plain text — no table box-drawing for ghost rows
- **Scroll position**: Cursor resets to line 1 on reload (existing behavior)

## Files Changed

| File | Change |
|------|--------|
| `revspec/diff_state.py` | **New** — DiffState class |
| `revspec/pager.py` | Add ghost row types to visual model, render diff styling |
| `revspec/app.py` | Create/clear DiffState on reload/approve, `_toggle_diff` handler |
| `revspec/key_dispatch.py` | Add `\d` to SEQUENCE_REGISTRY |
| `revspec/theme.py` | Add `diff_added_bg`, `diff_removed_bg` colors |
| `revspec/hints.py` | Add `[\d] diff` to `\` pending hints |
| `revspec/renderer.py` | Minor — pass diff-added bg to line rendering |
| `tests/test_diff_state.py` | **New** — unit tests for DiffState |

## Out of Scope

- Side-by-side diff view (future enhancement)
- Diff persistence across sessions (diff is in-memory only)
- Word-level diff highlighting within changed lines
- JSONL protocol changes (none needed)
