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)
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.
packages/core/src/index.ts — re-export the new types.
UI layer (packages/cli)
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.
packages/cli/src/ui/commands/types.ts — extend the dialog union with 'diff'.
packages/cli/src/ui/components/DiffDialog.tsx (new) — source switcher (Current / T1 / T2…), viewMode state, key handling.
packages/cli/src/ui/components/DiffFileList.tsx (new) — paginated file list; reuse DiffStatsDisplay colors.
packages/cli/src/ui/components/DiffDetailView.tsx (new) — hunk view; reuse DiffRenderer.tsx.
packages/cli/src/ui/hooks/useTurnDiffs.ts (new) — bridge user history items (HistoryItemUser.promptId) to fileHistoryService.getTurnDiff(promptId), with memoization.
packages/cli/src/ui/hooks/useDiffData.ts (new) — "Current" source via existing fetchGitDiff + fetchGitDiffHunks (packages/core/src/utils/gitDiff.ts:285, already exported).
packages/cli/src/ui/AppContainer.tsx — isDiffDialogOpen state, open/close handlers, render hook-up, include in dialogsVisibleRef.
packages/cli/src/ui/contexts/UIStateContext.tsx — expose the open state.
packages/cli/src/ui/keybindings/ — register diff:nextSource / previousSource / nextFile / previousFile / viewDetails / back / dismiss.
Tests / docs
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.
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
Summary
Today
/diffin 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-codeships a much richer/diff: a dialog that lets users left/right-arrow throughCurrent → T1 → T2 → …, up/down to select a file, Enter to open hunk-level detail, ESC to back out. This issue asks for parity.Motivation
foo.tsfrom turn 4". Today users have to manuallygit stash/git diffbetween turns to answer this./rewindalready presents per-turn checkpoints; users naturally expect/diffto mirror the same axis.How claude-code does it (for reference)
commands/diff/diff.tsx— thin entry that renders aDiffDialogwith the fullmessagesarray.hooks/useTurnDiffs.ts— incrementally walksMessage[], splits at non-tool-result user messages, and accumulatesstructuredPatchhunks emitted byFileEditTool/FileWriteTooltool results into a per-turnMap<filePath, hunks>. Resets the cache ifmessages.lengthshrinks (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, withviewMode: 'list' | 'detail', paginatedDiffFileList(5 visible at a time,↑/↓ N moreindicators), andDiffDetailViewfor hunks.diff:nextSource / previousSource / nextFile / previousFile / viewDetails / back / dismiss.Why qwen-code can't copy the data layer verbatim
claude-code emits
structuredPatchon the tool result; qwen-code'sedit/write_filetools don't. But we already have an equivalent — better, even — source:FileHistoryServicetakes a per-turn snapshot of every tracked file (packages/core/src/core/client.ts:1278), keyed bypromptId.getDiffStats(promptId)(packages/core/src/services/fileHistoryService.ts:511) already powers the diff summary insideRewindSelector.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_fileare snapshotted. Files mutated byrun_shell_commandwon't appear in per-turn diffs (same limitation as/rewind). The "Current" source still covers them viagit diff.Implementation sketch
Data layer (
packages/core)packages/core/src/services/fileHistoryService.ts— add:getTurnDiff(promptId): Promise<TurnDiff>— find target snapshot + its predecessor, rundiffLineson each tracked file's backup pair, return{filePath, hunks, linesAdded, linesRemoved, isNewFile}[].computeDiffStatsForFile(the current one diffsbackup → worktree; we needbackup_prev → backup_target).listTurns()or just rely ongetSnapshots()for the turn list.packages/core/src/index.ts— re-export the new types.UI layer (
packages/cli)packages/cli/src/ui/commands/diffCommand.ts— ininteractivemode, return{ type: 'dialog', dialog: 'diff' }instead of pushingDIFF_STATS. Keep the existing plain-text path fornon_interactive/acp.packages/cli/src/ui/commands/types.ts— extend thedialogunion with'diff'.packages/cli/src/ui/components/DiffDialog.tsx(new) — source switcher (Current / T1 / T2…),viewModestate, key handling.packages/cli/src/ui/components/DiffFileList.tsx(new) — paginated file list; reuseDiffStatsDisplaycolors.packages/cli/src/ui/components/DiffDetailView.tsx(new) — hunk view; reuseDiffRenderer.tsx.packages/cli/src/ui/hooks/useTurnDiffs.ts(new) — bridge user history items (HistoryItemUser.promptId) tofileHistoryService.getTurnDiff(promptId), with memoization.packages/cli/src/ui/hooks/useDiffData.ts(new) — "Current" source via existingfetchGitDiff+fetchGitDiffHunks(packages/core/src/utils/gitDiff.ts:285, already exported).packages/cli/src/ui/AppContainer.tsx—isDiffDialogOpenstate, open/close handlers, render hook-up, include indialogsVisibleRef.packages/cli/src/ui/contexts/UIStateContext.tsx— expose the open state.packages/cli/src/ui/keybindings/— registerdiff:nextSource / previousSource / nextFile / previousFile / viewDetails / back / dismiss.Tests / docs
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.docs/cli/commands.md— update/diffdescription and call out therun_shell_commandlimitation.Design notes / open questions
/rewindis disabled there becausecomputeApiTruncationIndexcan't account for IDE-injected user Content./diffis read-only and reads from physical snapshots, so disabling shouldn't be necessary. Confirm before shipping.git show :fileif backup is missing.Acceptance criteria
/diffin interactive mode opens a dialog (not a scroll-line item)./difftests still pass with the non-interactive contract preserved.cc @BZ-D