Skip to content

[Feature] /diff: add per-turn diff with interactive selection (parity with claude-code) #4272

@BZ-D

Description

@BZ-D

Summary

Today /diff in qwen-code can only show one aggregate view — git working-tree vs HEAD — for the whole session (packages/cli/src/ui/commands/diffCommand.ts:50, fetchGitDiff(cwd)). It writes the result straight into the scroll history (MessageType.DIFF_STATS), with no interaction: you can't pick a specific conversation turn, you can't see per-file hunks, you can't page through long file lists.

claude-code ships a much richer /diff: a dialog that lets users left/right-arrow through Current → T1 → T2 → …, up/down to select a file, Enter to open hunk-level detail, ESC to back out. This issue asks for parity.

Motivation

  • After a long multi-turn session, "what changed since I started" is the easy question. The hard ones are "what did this turn change?" and "show me the hunks for foo.ts from turn 4". Today users have to manually git stash / git diff between turns to answer this.
  • /rewind already presents per-turn checkpoints; users naturally expect /diff to mirror the same axis.
  • The infrastructure to do this is already in qwen-code — we just don't expose it. See "Implementation sketch" below.

How claude-code does it (for reference)

  • commands/diff/diff.tsx — thin entry that renders a DiffDialog with the full messages array.
  • hooks/useTurnDiffs.ts — incrementally walks Message[], splits at non-tool-result user messages, and accumulates structuredPatch hunks emitted by FileEditTool / FileWriteTool tool results into a per-turn Map<filePath, hunks>. Resets the cache if messages.length shrinks (post-rewind). No on-disk checkpoint needed because the tool results themselves carry the patch.
  • hooks/useDiffData.ts — separate fetch for the "Current" working-tree-vs-HEAD source.
  • components/diff/DiffDialog.tsx — combines [{type:'current'}, ...turns] into a source switcher, with viewMode: 'list' | 'detail', paginated DiffFileList (5 visible at a time, ↑/↓ N more indicators), and DiffDetailView for hunks.
  • Keybindings: diff:nextSource / previousSource / nextFile / previousFile / viewDetails / back / dismiss.

Why qwen-code can't copy the data layer verbatim

claude-code emits structuredPatch on the tool result; qwen-code's edit / write_file tools don't. But we already have an equivalent — better, even — source: FileHistoryService takes a per-turn snapshot of every tracked file (packages/core/src/core/client.ts:1278), keyed by promptId. getDiffStats(promptId) (packages/core/src/services/fileHistoryService.ts:511) already powers the diff summary inside RewindSelector.

Bonus: because snapshots are physical file backups (not API history walking), this approach survives chat compression — a turn that's been compressed out of API history still has a usable snapshot. claude-code's message-stream approach loses those turns.

Caveat to surface in the UI: only files touched via edit / write_file are snapshotted. Files mutated by run_shell_command won't appear in per-turn diffs (same limitation as /rewind). The "Current" source still covers them via git diff.

Implementation sketch

Data layer (packages/core)

  1. packages/core/src/services/fileHistoryService.ts — add:
    • getTurnDiff(promptId): Promise<TurnDiff> — find target snapshot + its predecessor, run diffLines on each tracked file's backup pair, return {filePath, hunks, linesAdded, linesRemoved, isNewFile}[].
    • A "two-backup" variant of computeDiffStatsForFile (the current one diffs backup → worktree; we need backup_prev → backup_target).
    • listTurns() or just rely on getSnapshots() for the turn list.
  2. packages/core/src/index.ts — re-export the new types.

UI layer (packages/cli)

  1. packages/cli/src/ui/commands/diffCommand.ts — in interactive mode, return { type: 'dialog', dialog: 'diff' } instead of pushing DIFF_STATS. Keep the existing plain-text path for non_interactive / acp.
  2. packages/cli/src/ui/commands/types.ts — extend the dialog union with 'diff'.
  3. packages/cli/src/ui/components/DiffDialog.tsx (new) — source switcher (Current / T1 / T2…), viewMode state, key handling.
  4. packages/cli/src/ui/components/DiffFileList.tsx (new) — paginated file list; reuse DiffStatsDisplay colors.
  5. packages/cli/src/ui/components/DiffDetailView.tsx (new) — hunk view; reuse DiffRenderer.tsx.
  6. packages/cli/src/ui/hooks/useTurnDiffs.ts (new) — bridge user history items (HistoryItemUser.promptId) to fileHistoryService.getTurnDiff(promptId), with memoization.
  7. packages/cli/src/ui/hooks/useDiffData.ts (new) — "Current" source via existing fetchGitDiff + fetchGitDiffHunks (packages/core/src/utils/gitDiff.ts:285, already exported).
  8. packages/cli/src/ui/AppContainer.tsxisDiffDialogOpen state, open/close handlers, render hook-up, include in dialogsVisibleRef.
  9. packages/cli/src/ui/contexts/UIStateContext.tsx — expose the open state.
  10. packages/cli/src/ui/keybindings/ — register diff:nextSource / previousSource / nextFile / previousFile / viewDetails / back / dismiss.

Tests / docs

  1. diffCommand.test.ts + dialog/hook tests covering: single turn, multi turn, new file, deleted file, binary, large file truncation, post-compression turn, IDE mode, empty turn.
  2. docs/cli/commands.md — update /diff description and call out the run_shell_command limitation.

Design notes / open questions

  • IDE mode: /rewind is disabled there because computeApiTruncationIndex can't account for IDE-injected user Content. /diff is read-only and reads from physical snapshots, so disabling shouldn't be necessary. Confirm before shipping.
  • Source ordering: claude-code shows most-recent turn first. Match that.
  • First turn: predecessor for T1 is "HEAD at session start" — easiest source is the very first snapshot (taken before the first edit) or fallback to git show :file if backup is missing.

Acceptance criteria

  • /diff in interactive mode opens a dialog (not a scroll-line item).
  • Left/right arrows switch between Current and each historical turn (most recent first).
  • Up/down selects a file; Enter opens hunk view; ESC backs out one level.
  • File list paginates when > 5 entries.
  • Non-interactive / ACP paths unchanged.
  • New unit tests pass; existing /diff tests still pass with the non-interactive contract preserved.

cc @BZ-D

Metadata

Metadata

Assignees

Labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions