diff --git a/.changeset/add-diff-slash-command.md b/.changeset/add-diff-slash-command.md new file mode 100644 index 0000000000..92f576ac8c --- /dev/null +++ b/.changeset/add-diff-slash-command.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add the /diff slash command to view changes made in the current session. Run /diff to open a file selector that combines AI tool edits with git working-tree changes. diff --git a/apps/kimi-code/src/tui/commands/diff.ts b/apps/kimi-code/src/tui/commands/diff.ts new file mode 100644 index 0000000000..118f332814 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/diff.ts @@ -0,0 +1,356 @@ +import { relative as makeRelative, resolve as resolvePath } from 'node:path'; + +import { + isInsideGitRepo, + listChangedFiles, + runGitDiffForFile, + runGitNumstat, + runUntrackedNumstat, +} from '#/utils/git/git-diff'; +import { + computeDiffLines, + makeDiffStyles, + renderDiffLinesClustered, +} from '../components/media/diff-preview'; +import { + DiffFileSelectorComponent, + type DiffSelectorFile, + type DiffSource, +} from '../components/dialogs/diff-file-selector'; +import { DiffViewerComponent } from '../components/dialogs/diff-viewer'; +import { buildDiffPanelLines, DiffPanelComponent } from '../components/messages/diff-panel'; +import { currentTheme } from '../theme'; +import { + collectSessionEditsByTurn, + type SessionEditWithTurn, +} from '../utils/session-edits'; +import { groupTurns, type TranscriptTurn } from '../utils/transcript-window'; +import { formatErrorMessage } from '../utils/event-payload'; +import type { TranscriptEntry } from '../types'; +import type { SlashCommandHost } from './dispatch'; + +export async function handleDiffCommand(host: SlashCommandHost, _args: string): Promise { + const workDir = host.state.appState.workDir; + + try { + const { sources, sessionEdits } = await buildDiffSources( + workDir, + host.state.transcriptEntries, + host.state.transcriptContainer.children, + ); + + if (sources.every((s) => s.files.length === 0)) { + host.showStatus('No changed files.'); + return; + } + + const singleSourceWithSingleFile = sources.length === 1 && sources[0]!.files.length === 1; + if (singleSourceWithSingleFile) { + await showDiffForChoice(host, workDir, sessionEdits, sources[0]!.files[0]!); + return; + } + + const mountSelector = (initialSourceIndex = 0, initialSelectedIndex = 0): void => { + const selector = new DiffFileSelectorComponent({ + sources, + initialSourceIndex, + initialSelectedIndex, + onSelect: (choice) => { + void showDiffViewer(host, workDir, sessionEdits, choice, () => { + mountSelector(selector.getActiveSourceIndex(), selector.getSelectedIndex()); + }); + }, + onCancel: () => { + host.restoreEditor(); + }, + }); + host.mountEditorReplacement(selector); + }; + mountSelector(); + } catch (error) { + host.showError(formatErrorMessage(error)); + } +} + +interface DiffSourcesResult { + readonly sources: DiffSource[]; + readonly sessionEdits: readonly SessionEditWithTurn[]; +} + +async function buildDiffSources( + workDir: string, + entries: readonly TranscriptEntry[], + components: readonly unknown[], +): Promise { + const sessionEdits = collectSessionEditsByTurn(entries, components).map((edit) => ({ + ...edit, + path: makeRelative(workDir, resolvePath(workDir, edit.path)), + })); + + const sources: DiffSource[] = []; + + // Git working-tree changes are shown when inside a git repo; otherwise only + // session edits are available. + const inGitRepo = isInsideGitRepo(workDir); + const gitFiles = inGitRepo ? await listChangedFiles(workDir) : []; + const gitNumstat = inGitRepo ? await runGitNumstat(workDir) : new Map(); + + // Current source = all git changes, including files also edited by session tools. + const currentFiles = await Promise.all( + gitFiles + .map((f): DiffSelectorFile | undefined => { + const status = normalizeGitStatus(f.status); + if (status === undefined) return undefined; + const relativePath = makeRelative(workDir, resolvePath(workDir, f.path)); + const numstat = status === 'untracked' ? undefined : gitNumstat.get(relativePath); + return { + path: relativePath, + status, + source: 'git', + additions: numstat?.additions, + deletions: numstat?.deletions, + }; + }) + .filter((f): f is DiffSelectorFile => f !== undefined) + .map(async (f) => { + if (f.status === 'untracked') { + const stat = await runUntrackedNumstat(workDir, f.path); + return { ...f, additions: stat.additions, deletions: stat.deletions }; + } + return f; + }), + ); + sources.push({ label: 'Current', files: currentFiles }); + + // Turn sources from session edits. + const turns = groupTurns(entries); + const turnIndexById = new Map(); + for (const [index, turn] of turns.entries()) { + if (turn.turnId !== undefined) { + turnIndexById.set(turn.turnId, index + 1); + } + } + + const editsByTurn = new Map(); + for (const edit of sessionEdits) { + const list = editsByTurn.get(edit.turnId) ?? []; + list.push(edit); + editsByTurn.set(edit.turnId, list); + } + + const turnEntries: Array<{ readonly turnId: string | undefined; readonly source: DiffSource }> = + []; + for (const [turnId, edits] of editsByTurn) { + const files = buildFilesFromSessionEdits(edits); + if (files.length === 0) continue; + const turnNumber = turnId !== undefined ? turnIndexById.get(turnId) : undefined; + const label = turnNumber !== undefined ? `T${String(turnNumber)}` : '-'; + const subtitle = buildTurnSubtitle(turnNumber, turnId, turns); + turnEntries.push({ turnId, source: { label, subtitle, files } }); + } + + // Sort turn sources newest first (highest turn number first). + turnEntries.sort((a, b) => { + const ia = a.turnId !== undefined ? (turnIndexById.get(a.turnId) ?? 0) : 0; + const ib = b.turnId !== undefined ? (turnIndexById.get(b.turnId) ?? 0) : 0; + return ib - ia; + }); + + sources.push(...turnEntries.map((e) => e.source)); + return { sources, sessionEdits }; +} + +const LARGE_FILE_LINE_THRESHOLD = 1000; +const EXPAND_CONTEXT_LINES = 999_999; + +function countLines(text: string): number { + if (text.length === 0) return 0; + return text.split('\n').length; +} + +function isLargeEdit(before: string, after: string): boolean { + return countLines(before) > LARGE_FILE_LINE_THRESHOLD || countLines(after) > LARGE_FILE_LINE_THRESHOLD; +} + +function buildFilesFromSessionEdits(edits: readonly SessionEditWithTurn[]): DiffSelectorFile[] { + const seen = new Set(); + const files: DiffSelectorFile[] = []; + for (const edit of edits) { + if (seen.has(edit.path)) continue; + seen.add(edit.path); + const hasBefore = edit.before.length > 0; + let additions = 0; + let deletions = 0; + if (isLargeEdit(edit.before, edit.after)) { + additions = countLines(edit.after); + deletions = countLines(edit.before); + } else { + const diffLines = computeDiffLines(edit.before, edit.after); + for (const line of diffLines) { + if (line.kind === 'add') additions++; + else if (line.kind === 'delete') deletions++; + } + } + files.push({ + path: edit.path, + status: hasBefore ? 'modified' : 'added', + source: 'session', + turnId: edit.turnId, + additions, + deletions, + }); + } + return files; +} + +function buildTurnSubtitle( + turnNumber: number | undefined, + turnId: string | undefined, + turns: readonly TranscriptTurn[], +): string | undefined { + if (turnNumber === undefined || turnId === undefined) return undefined; + const turn = turns.find((t) => t.turnId === turnId); + if (turn === undefined) return undefined; + const userEntry = turn.entries.find((e) => e.kind === 'user'); + if (userEntry === undefined) return undefined; + const text = userEntry.content.trim().replaceAll(/\s+/g, ' '); + const max = 40; + const truncated = text.length > max ? `${text.slice(0, max)}...` : text; + return `Turn ${String(turnNumber)} "${truncated}"`; +} + +function normalizeGitStatus(status: string): DiffSelectorFile['status'] | undefined { + switch (status) { + case 'modified': + return 'modified'; + case 'added': + case 'copied': + return 'added'; + case 'deleted': + return 'deleted'; + case 'untracked': + return 'untracked'; + case 'renamed': + // listChangedFiles passes --no-renames, so renames are reported as + // separate delete/add entries. This branch is defensive only. + return 'modified'; + default: + // Filter out statuses we do not know how to render (e.g. ignored). + return undefined; + } +} + +async function showDiffForChoice( + host: SlashCommandHost, + workDir: string, + sessionEdits: readonly SessionEditWithTurn[], + choice: DiffSelectorFile, +): Promise { + try { + const lines = await buildDiffLinesForChoice(workDir, sessionEdits, choice); + const panel = new DiffPanelComponent(() => lines); + host.state.transcriptContainer.addChild(panel); + host.state.ui.requestRender(); + } catch (error) { + host.showError(formatErrorMessage(error)); + } +} + +async function showDiffViewer( + host: SlashCommandHost, + workDir: string, + sessionEdits: readonly SessionEditWithTurn[], + choice: DiffSelectorFile, + onBack: () => void, +): Promise { + const viewer = new DiffViewerComponent({ + onBack, + onToggleExpand: (expanded) => buildDiffLinesForChoice(workDir, sessionEdits, choice, expanded), + requestRender: () => host.state.ui.requestRender(), + }); + host.mountEditorReplacement(viewer); + + try { + const lines = await buildDiffLinesForChoice(workDir, sessionEdits, choice); + viewer.setLines(lines); + host.state.ui.requestRender(); + } catch (error) { + host.showError(formatErrorMessage(error)); + onBack(); + } +} + +async function buildDiffLinesForChoice( + workDir: string, + sessionEdits: readonly SessionEditWithTurn[], + choice: DiffSelectorFile, + expanded: boolean = false, +): Promise { + const rawGitDiff = + choice.source === 'git' + ? await runGitDiffForFile( + workDir, + { + path: choice.path, + status: choice.status, + }, + expanded ? EXPAND_CONTEXT_LINES : 3, + ) + : undefined; + + if (choice.source === 'session') { + return buildSessionEditLines(sessionEdits, choice.path, choice.turnId, { + contextLines: expanded ? 10 : 2, + maxLines: expanded ? undefined : 40, + }); + } + return buildDiffPanelLines(rawGitDiff ?? ''); +} + +interface SessionEditLineOptions { + readonly contextLines?: number; + readonly maxLines?: number; +} + +function buildSessionEditLines( + sessionEdits: readonly SessionEditWithTurn[], + path: string, + turnId: string | undefined, + opts: SessionEditLineOptions = {}, +): string[] { + const edits = sessionEdits.filter((e) => e.path === path && e.turnId === turnId); + if (edits.length === 0) return []; + + const output: string[] = []; + for (const [index, edit] of edits.entries()) { + if (index > 0) { + output.push(''); + output.push(' ───────────────'); + output.push(''); + } + if (isLargeEdit(edit.before, edit.after)) { + const s = makeDiffStyles(); + const beforeLines = countLines(edit.before); + const afterLines = countLines(edit.after); + let header = ''; + if (afterLines > 0) header += s.addBold(`+${String(afterLines)} `); + if (beforeLines > 0) header += s.delBold(`-${String(beforeLines)} `); + header += path; + output.push(header); + output.push( + currentTheme.fg( + 'textMuted', + ` File too large to render inline diff (${beforeLines} → ${afterLines} lines).`, + ), + ); + } else { + output.push( + ...renderDiffLinesClustered(edit.before, edit.after, path, { + contextLines: opts.contextLines ?? 2, + maxLines: opts.maxLines ?? 40, + }), + ); + } + } + return output; +} diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index 7508bc06a5..225e0f97bc 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -38,6 +38,7 @@ import { import { handleGoalCommand } from './goal'; import { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info'; import { handleAddDirCommand } from './add-dir'; +import { handleDiffCommand } from './diff'; import { parseSlashInput } from './parse'; import { handlePluginsCommand } from './plugins'; import { handleProviderCommand } from './provider'; @@ -276,6 +277,9 @@ async function handleBuiltInSlashCommand( case 'add-dir': await handleAddDirCommand(host, args); return; + case 'diff': + await handleDiffCommand(host, args); + return; case 'experiments': await showExperimentsPanel(host); return; diff --git a/apps/kimi-code/src/tui/commands/index.ts b/apps/kimi-code/src/tui/commands/index.ts index 784ef7e615..fad1873652 100644 --- a/apps/kimi-code/src/tui/commands/index.ts +++ b/apps/kimi-code/src/tui/commands/index.ts @@ -25,6 +25,7 @@ export { handleSwarmCommand } from './swarm'; export { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info'; export { handlePluginsCommand } from './plugins'; export { handleReloadCommand, handleReloadTuiCommand } from './reload'; +export { handleDiffCommand } from './diff'; export { handleGoalCommand, parseGoalCommand } from './goal'; export { goalArgumentCompletions } from './registry'; export { handleForkCommand, handleInitCommand, handleTitleCommand } from './session'; diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 8e5a163867..fec290441d 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -212,6 +212,13 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 80, availability: 'always', }, + { + name: 'diff', + aliases: [], + description: 'View uncommitted changes and per-turn diffs', + priority: 70, + availability: 'idle-only', + }, { name: 'new', aliases: ['clear'], diff --git a/apps/kimi-code/src/tui/components/dialogs/diff-file-selector.ts b/apps/kimi-code/src/tui/components/dialogs/diff-file-selector.ts new file mode 100644 index 0000000000..bd8cf15c6b --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/diff-file-selector.ts @@ -0,0 +1,277 @@ +/** + * DiffFileSelector — lets the user pick a changed file to view its diff. + * + * Files may come from explicit session edits (Edit/Write tool calls) or from + * the git working tree as a fallback. Multiple sources are shown as tabs. + */ + +import { + Container, + Key, + matchesKey, + truncateToWidth, + visibleWidth, + type Focusable, +} from '@moonshot-ai/pi-tui'; + +import { SELECT_POINTER } from '#/tui/constant/symbols'; +import { currentTheme } from '../../theme'; +import { SearchableList } from '../../utils/searchable-list'; +import { renderTabStrip } from '../../utils/tab-strip'; + +const MAX_VISIBLE_CHOICES = 8; +const PREFERRED_SELECTED_OFFSET = 3; + +export type DiffFileStatus = 'modified' | 'added' | 'deleted' | 'untracked'; + +export interface DiffSelectorFile { + readonly path: string; + readonly status: DiffFileStatus; + readonly source: 'session' | 'git'; + readonly turnId?: string; + readonly additions?: number; + readonly deletions?: number; +} + +export interface DiffSource { + readonly label: string; + readonly subtitle?: string; + readonly files: readonly DiffSelectorFile[]; +} + +export interface DiffFileSelectorOptions { + readonly sources: readonly DiffSource[]; + readonly initialSourceIndex?: number; + readonly initialSelectedIndex?: number; + readonly onSelect: (file: DiffSelectorFile) => void; + readonly onCancel: () => void; +} + +function statusLabel(status: DiffFileStatus): string { + switch (status) { + case 'modified': + return 'M'; + case 'added': + return 'A'; + case 'deleted': + return 'D'; + case 'untracked': + return '?'; + } +} + +function statusWord(status: DiffFileStatus): string | undefined { + switch (status) { + case 'modified': + return 'modified'; + case 'added': + return 'added'; + case 'deleted': + return 'deleted'; + case 'untracked': + return 'untracked'; + } +} + +export class DiffFileSelectorComponent extends Container implements Focusable { + focused = false; + private readonly opts: DiffFileSelectorOptions; + private list: SearchableList; + private submitted = false; + private activeSourceIndex = 0; + private readonly selectedIndexBySource: number[]; + + constructor(opts: DiffFileSelectorOptions) { + super(); + this.opts = opts; + this.activeSourceIndex = opts.initialSourceIndex ?? 0; + this.selectedIndexBySource = Array.from({ length: opts.sources.length }, () => 0); + this.selectedIndexBySource[this.activeSourceIndex] = opts.initialSelectedIndex ?? 0; + this.list = this.buildList(); + } + + getActiveSourceIndex(): number { + return this.activeSourceIndex; + } + + private get activeSource(): DiffSource { + return this.opts.sources[this.activeSourceIndex] ?? { label: '', files: [] }; + } + + private buildList(): SearchableList { + return new SearchableList({ + items: this.activeSource.files, + toSearchText: (file) => file.path, + initialIndex: this.selectedIndexBySource[this.activeSourceIndex] ?? 0, + }); + } + + getSelectedIndex(): number { + return this.list.view().selectedIndex; + } + + handleInput(data: string): void { + if (this.submitted) return; + + if (matchesKey(data, Key.escape)) { + this.opts.onCancel(); + return; + } + + if (matchesKey(data, Key.left)) { + if (this.activeSourceIndex > 0) { + this.selectedIndexBySource[this.activeSourceIndex] = this.list.view().selectedIndex; + this.activeSourceIndex--; + this.list = this.buildList(); + } + return; + } + + if (matchesKey(data, Key.right)) { + if (this.activeSourceIndex < this.opts.sources.length - 1) { + this.selectedIndexBySource[this.activeSourceIndex] = this.list.view().selectedIndex; + this.activeSourceIndex++; + this.list = this.buildList(); + } + return; + } + + if (this.list.handleKey(data)) { + return; + } + + if (matchesKey(data, Key.enter)) { + const selected = this.list.selected(); + if (selected !== undefined) { + this.submitted = true; + this.opts.onSelect(selected); + } + } + } + + override render(width: number): string[] { + const source = this.activeSource; + const view = this.list.view(); + const hintParts = ['←/→ source', '↑↓ navigate', 'type to filter', 'Enter select', 'Esc cancel']; + + let topInfo: string; + if (source.subtitle !== undefined && source.subtitle.length > 0) { + topInfo = this.renderSubtitle(source.subtitle); + } else if (this.activeSourceIndex === 0) { + topInfo = ` ${currentTheme.boldFg('primary', 'Uncommitted changes')} ${currentTheme.fg('textMuted', '(git diff HEAD)')}`; + } else { + topInfo = ` ${currentTheme.boldFg('primary', source.label)}`; + } + + const { totalAdditions, totalDeletions, maxRightWidth } = this.computeSourceMetrics(source); + const summaryParts: string[] = [ + `${String(source.files.length)} file${source.files.length === 1 ? '' : 's'} changed`, + ]; + if (totalAdditions > 0) { + summaryParts.push(currentTheme.fg('diffAdded', `+${String(totalAdditions)}`)); + } + if (totalDeletions > 0) { + summaryParts.push(currentTheme.fg('diffRemoved', `-${String(totalDeletions)}`)); + } + + const fileLines: string[] = []; + if (view.items.length === 0) { + fileLines.push(currentTheme.fg('textMuted', ' No changed files')); + } else { + const visibleCount = Math.min(MAX_VISIBLE_CHOICES, view.items.length); + const maxStart = view.items.length - visibleCount; + const start = Math.min( + Math.max(0, view.selectedIndex - PREFERRED_SELECTED_OFFSET), + maxStart, + ); + const end = start + visibleCount; + + for (let i = start; i < end; i++) { + const file = view.items[i]; + if (file === undefined) continue; + fileLines.push(this.renderFileLine(file, i === view.selectedIndex, width, maxRightWidth)); + } + } + + const lines: string[] = [ + currentTheme.fg('primary', '─'.repeat(width)), + topInfo, + currentTheme.fg('textMuted', ' ' + hintParts.join(' · ')), + renderTabStrip({ + labels: this.opts.sources.map((s) => s.label), + activeIndex: this.activeSourceIndex, + width, + colors: currentTheme.palette, + }), + currentTheme.fg('textMuted', ' ' + summaryParts.join(' ')), + '', + ...fileLines, + '', + currentTheme.fg('primary', '─'.repeat(width)), + ]; + return lines.map((line) => truncateToWidth(line, width)); + } + + private renderSubtitle(subtitle: string): string { + const match = /^Turn (\d+) "(.*)"$/.exec(subtitle); + if (match === null) { + return currentTheme.boldFg('primary', ` ${subtitle}`); + } + const turnNumber = match[1]!; + const prompt = match[2]!; + return ` ${currentTheme.boldFg('primary', `Turn ${turnNumber}`)} ${currentTheme.fg('textMuted', `"${prompt}"`)}`; + } + + private computeSourceMetrics( + source: DiffSource, + ): { totalAdditions: number; totalDeletions: number; maxRightWidth: number } { + let totalAdditions = 0; + let totalDeletions = 0; + let maxRightWidth = 0; + for (const file of source.files) { + totalAdditions += file.additions ?? 0; + totalDeletions += file.deletions ?? 0; + const rightWidth = visibleWidth(this.buildRightText(file)); + if (rightWidth > maxRightWidth) { + maxRightWidth = rightWidth; + } + } + return { totalAdditions, totalDeletions, maxRightWidth }; + } + + private buildRightText(file: DiffSelectorFile): string { + const statParts: string[] = []; + if (file.additions !== undefined && file.additions > 0) { + statParts.push(currentTheme.fg('diffAdded', `+${String(file.additions)}`)); + } + if (file.deletions !== undefined && file.deletions > 0) { + statParts.push(currentTheme.fg('diffRemoved', `-${String(file.deletions)}`)); + } + if (statParts.length > 0) { + return statParts.join(' '); + } + const word = statusWord(file.status); + return word !== undefined ? currentTheme.fg('textMuted', word) : ''; + } + + private renderFileLine( + file: DiffSelectorFile, + isSelected: boolean, + width: number, + maxRightWidth: number, + ): string { + const pointer = isSelected ? SELECT_POINTER : ' '; + const status = statusLabel(file.status); + const rightText = this.buildRightText(file); + const rightPadding = ' '.repeat(Math.max(0, maxRightWidth - visibleWidth(rightText))); + const prefix = ` ${pointer} ${status} `; + const separatorWidth = 1; + const labelBudget = Math.max(8, width - visibleWidth(prefix) - maxRightWidth - separatorWidth); + const label = truncateToWidth(file.path, labelBudget, '…', true); + const token = isSelected ? 'primary' : 'text'; + let line = currentTheme.fg(isSelected ? 'primary' : 'textDim', prefix); + line += isSelected ? currentTheme.boldFg(token, label) : currentTheme.fg(token, label); + line += ' ' + rightPadding + rightText; + return line; + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/diff-viewer.ts b/apps/kimi-code/src/tui/components/dialogs/diff-viewer.ts new file mode 100644 index 0000000000..67a229aac4 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/diff-viewer.ts @@ -0,0 +1,97 @@ +/** + * DiffViewer — full-screen diff viewer used by the `/diff` file picker. + * + * Displays a previously computed diff panel and allows the user to press Esc + * to return to the file list, or ctrl+o to toggle expanded context. + */ + +import type { Component, Focusable } from '@moonshot-ai/pi-tui'; +import { Key, matchesKey, truncateToWidth } from '@moonshot-ai/pi-tui'; + +import { currentTheme } from '#/tui/theme'; +import { formatErrorMessage } from '#/tui/utils/event-payload'; +import { DiffPanelComponent } from '../messages/diff-panel'; + +const TITLE = 'Diff viewer — Esc to return to file list'; +const FOOTER_EXPAND = 'ctrl+o expand context · Esc to return to file list'; +const FOOTER_COLLAPSE = 'ctrl+o collapse context · Esc to return to file list'; +const FOOTER = 'Press Esc to return to file list'; + +export interface DiffViewerOptions { + readonly initialLines?: readonly string[]; + readonly onBack: () => void; + /** + * If provided, pressing ctrl+o toggles expanded context. The callback + * receives the desired expanded state and should return the matching diff + * lines. + */ + readonly onToggleExpand?: ( + expanded: boolean, + ) => Promise | readonly string[]; + /** + * Called after async expansion finishes so the host can request a render. + * Without this, expanded/collapsed content may not appear until the next + * input event. + */ + readonly requestRender?: () => void; +} + +export class DiffViewerComponent implements Component, Focusable { + focused = false; + private readonly panel: DiffPanelComponent; + private lines: readonly string[]; + private expanded = false; + private toggling = false; + + constructor(private readonly opts: DiffViewerOptions) { + this.lines = opts.initialLines ?? [currentTheme.fg('textDim', 'Loading diff...')]; + this.panel = new DiffPanelComponent(() => this.lines); + } + + setLines(lines: readonly string[]): void { + this.lines = lines; + this.panel.invalidate(); + } + + handleInput(data: string): void { + if (matchesKey(data, Key.escape)) { + this.opts.onBack(); + return; + } + + if (matchesKey(data, Key.ctrl('o')) && this.opts.onToggleExpand && !this.toggling) { + this.toggling = true; + const nextExpanded = !this.expanded; + void Promise.resolve(this.opts.onToggleExpand(nextExpanded)) + .then((lines) => { + this.expanded = nextExpanded; + this.setLines(lines); + this.opts.requestRender?.(); + }) + .catch((error: unknown) => { + this.setLines([ + currentTheme.fg('diffRemoved', `Failed to load expanded diff: ${formatErrorMessage(error)}`), + ]); + this.opts.requestRender?.(); + }) + .finally(() => { + this.toggling = false; + }); + } + } + + render(width: number): string[] { + const title = truncateToWidth(currentTheme.fg('textDim', TITLE), Math.max(1, width)); + let footerText = FOOTER; + if (this.opts.onToggleExpand) { + footerText = this.expanded ? FOOTER_COLLAPSE : FOOTER_EXPAND; + } + const footer = truncateToWidth(currentTheme.fg('textDim', footerText), Math.max(1, width)); + const panelLines = this.panel.render(width); + return [title, ...panelLines, '', footer]; + } + + invalidate(): void { + this.panel.invalidate(); + } +} diff --git a/apps/kimi-code/src/tui/components/media/diff-preview.ts b/apps/kimi-code/src/tui/components/media/diff-preview.ts index 1fec48b267..6f02ec031d 100644 --- a/apps/kimi-code/src/tui/components/media/diff-preview.ts +++ b/apps/kimi-code/src/tui/components/media/diff-preview.ts @@ -20,7 +20,7 @@ interface DiffStyles { meta: (s: string) => string; } -function makeDiffStyles(): DiffStyles { +export function makeDiffStyles(): DiffStyles { const palette = currentTheme.palette; return { add: (s) => chalk.hex(palette.diffAdded)(s), diff --git a/apps/kimi-code/src/tui/components/messages/diff-panel.ts b/apps/kimi-code/src/tui/components/messages/diff-panel.ts new file mode 100644 index 0000000000..05b08f2ee3 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/diff-panel.ts @@ -0,0 +1,98 @@ +/** + * DiffPanelComponent — renders `git diff` output in a bordered panel with + * theme-aware colouring for added/removed/context/meta lines. + */ + +import type { Component } from '@moonshot-ai/pi-tui'; +import { truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; + +import { currentTheme } from '#/tui/theme'; + +const LEFT_MARGIN = 2; +const SIDE_PADDING = 1; +const BOX_OVERHEAD = LEFT_MARGIN + 2 + 2 * SIDE_PADDING; + +export function buildDiffPanelLines(diffOutput: string): string[] { + const raw = diffOutput.trimEnd(); + if (raw.length === 0) { + return [currentTheme.fg('textDim', 'No changes.')]; + } + + const lines: string[] = []; + for (const line of raw.split('\n')) { + lines.push(colorizeDiffLine(line)); + } + return lines; +} + +function colorizeDiffLine(line: string): string { + const theme = currentTheme; + + // Hunk header. + if (line.startsWith('@@') && line.includes('@@')) { + return theme.fg('diffGutter', line); + } + if (line.startsWith('+')) { + return theme.fg('diffAdded', line); + } + if (line.startsWith('-')) { + return theme.fg('diffRemoved', line); + } + // Git metadata lines: diff header, file paths, mode/rename/similarity info. + if (/^(diff |index |--- |\+\+\+|rename |similarity |new |deleted |old |\\)/.test(line)) { + return theme.fg('diffMeta', line); + } + return line; +} + +export class DiffPanelComponent implements Component { + /** Cached coloured lines; rebuilt from `buildLines` on every invalidate. */ + private lines: readonly string[]; + + constructor(private readonly buildLines: () => readonly string[]) { + this.lines = buildLines(); + } + + invalidate(): void { + // Diff bodies embed palette colours, so a theme switch must re-run the + // builder to repaint the cached lines. + this.lines = this.buildLines(); + } + + render(width: number): string[] { + const safeWidth = Math.max(0, width); + if (safeWidth <= 0) return ['']; + + const paint = (s: string) => currentTheme.fg('diffGutter', s); + const availableInterior = safeWidth - BOX_OVERHEAD; + if (availableInterior < 1) { + return [ + truncateToWidth('Diff', safeWidth, '…'), + ...this.lines.map((line) => truncateToWidth(line, safeWidth, '…')), + ]; + } + + const indent = ' '.repeat(LEFT_MARGIN); + const longestLine = this.lines.reduce((max, line) => Math.max(max, visibleWidth(line)), 0); + const contentWidth = Math.max( + 1, + Math.min(availableInterior, Math.max(longestLine, visibleWidth(' Diff '))), + ); + const horzLen = contentWidth + 2 * SIDE_PADDING; + const title = truncateToWidth(' Diff ', horzLen, '…'); + + const trailingDashLen = Math.max(0, horzLen - visibleWidth(title)); + const top = + indent + paint('╭') + paint(title) + paint('─'.repeat(trailingDashLen)) + paint('╮'); + const bottom = indent + paint('╰' + '─'.repeat(horzLen) + '╯'); + + const out: string[] = [top]; + for (const line of this.lines) { + const clipped = visibleWidth(line) > contentWidth ? truncateToWidth(line, contentWidth) : line; + const pad = Math.max(0, contentWidth - visibleWidth(clipped)); + out.push(indent + paint('│') + ' ' + clipped + ' '.repeat(pad) + ' ' + paint('│')); + } + out.push(bottom); + return out.map((line) => truncateToWidth(line, safeWidth, '…')); + } +} diff --git a/apps/kimi-code/src/tui/utils/session-edits.ts b/apps/kimi-code/src/tui/utils/session-edits.ts new file mode 100644 index 0000000000..0f20ec5fb1 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/session-edits.ts @@ -0,0 +1,119 @@ +/** + * Collect file edits performed by the AI in the current session. + * + * Scans transcript entries for `Edit`/`Write` tool calls (and any tool whose + * display is already a diff), returning the before/after snippets so the + * `/diff` command can render them independently of git. + */ + +import type { ToolInputDisplay } from '@moonshot-ai/kimi-code-sdk'; + +import type { TranscriptEntry } from '../types'; + +interface ToolCallViewLike { + toolCallView?: { + turnId?: string; + display?: ToolInputDisplay; + }; +} + +export interface SessionEdit { + readonly path: string; + readonly before: string; + readonly after: string; +} + +export interface SessionEditWithTurn { + readonly turnId: string | undefined; + readonly path: string; + readonly before: string; + readonly after: string; +} + +function isFileIoDisplay(display: ToolInputDisplay): display is Extract< + ToolInputDisplay, + { kind: 'file_io' } +> { + return display.kind === 'file_io'; +} + +function isDiffDisplay(display: ToolInputDisplay): display is Extract< + ToolInputDisplay, + { kind: 'diff' } +> { + return display.kind === 'diff'; +} + +function extractEditsFromDisplay(display: ToolInputDisplay | undefined): SessionEdit[] { + if (display === undefined) return []; + + if (isFileIoDisplay(display)) { + if (display.operation === 'edit') { + const before = display.before ?? ''; + const after = display.after ?? ''; + if (before !== after && display.path.length > 0) { + return [{ path: display.path, before, after }]; + } + } else if (display.operation === 'write') { + const content = display.content ?? ''; + if (display.path.length > 0) { + return [{ path: display.path, before: '', after: content }]; + } + } + } else if (isDiffDisplay(display)) { + if (display.path.length > 0) { + return [{ path: display.path, before: display.before, after: display.after }]; + } + } + return []; +} + +function extractEditsWithTurnFromEntry(entry: TranscriptEntry): SessionEditWithTurn[] { + return extractEditsFromDisplay(entry.toolCallData?.display).map((edit) => ({ + ...edit, + turnId: entry.turnId, + })); +} + +function extractEditsWithTurnFromComponent(component: unknown): SessionEditWithTurn[] { + const view = (component as ToolCallViewLike).toolCallView; + if (view === undefined) return []; + const turnId = view.turnId; + return extractEditsFromDisplay(view.display).map((edit) => ({ + ...edit, + turnId, + })); +} + +function editKey(edit: SessionEdit, turnId: string | undefined): string { + return `${turnId ?? ''}:${edit.path}:${edit.before}:${edit.after}`; +} + +export function collectSessionEditsByTurn( + entries: readonly TranscriptEntry[], + components?: readonly unknown[], +): readonly SessionEditWithTurn[] { + const seen = new Set(); + const edits: SessionEditWithTurn[] = []; + for (const entry of entries) { + for (const edit of extractEditsWithTurnFromEntry(entry)) { + const key = editKey(edit, edit.turnId); + if (seen.has(key)) continue; + seen.add(key); + edits.push(edit); + } + } + if (components !== undefined) { + for (const component of components) { + for (const edit of extractEditsWithTurnFromComponent(component)) { + const key = editKey(edit, edit.turnId); + if (seen.has(key)) continue; + seen.add(key); + edits.push(edit); + } + } + } + return edits; +} + + diff --git a/apps/kimi-code/src/utils/git/git-diff.ts b/apps/kimi-code/src/utils/git/git-diff.ts new file mode 100644 index 0000000000..7383053ffb --- /dev/null +++ b/apps/kimi-code/src/utils/git/git-diff.ts @@ -0,0 +1,315 @@ +/** + * Git diff helpers for the `/diff` slash command. + * + * - `isInsideGitRepo` detects whether a directory is inside a git work tree. + * - `listChangedFiles` returns the current working-tree changes from + * `git status --porcelain`. + * - `runGitDiffForFile` produces a unified diff for a single changed file, + * including untracked files (via `git diff --no-index`). + */ + +import { execFile, spawnSync } from 'node:child_process'; + +const GIT_TIMEOUT_MS = 10_000; + +const SHELL_METACHAR_RE = /[;|&<>$`\\"\n\r]/; + +export class GitDiffError extends Error { + constructor(message: string) { + super(message); + this.name = 'GitDiffError'; + } +} + +export type GitFileStatus = + | 'modified' + | 'added' + | 'deleted' + | 'renamed' + | 'copied' + | 'untracked' + | 'ignored' + | 'unknown'; + +export interface GitChangedFile { + readonly path: string; + readonly status: GitFileStatus; +} + +export function isInsideGitRepo(workDir: string): boolean { + try { + const result = spawnSync('git', ['-C', workDir, 'rev-parse', '--is-inside-work-tree'], { + encoding: 'utf8', + timeout: 1_000, + }); + return result.status === 0 && result.stdout.trim() === 'true'; + } catch { + return false; + } +} + +function assertSafeArg(part: string): void { + if (SHELL_METACHAR_RE.test(part)) { + throw new GitDiffError(`Invalid git argument: ${part}`); + } +} + +export async function listChangedFiles(workDir: string): Promise { + const prefix = await getGitPrefix(workDir); + return new Promise((resolve, reject) => { + execFile( + 'git', + ['-C', workDir, 'status', '--porcelain', '-z', '--no-renames', '-uall'], + { + encoding: 'utf8', + timeout: GIT_TIMEOUT_MS, + maxBuffer: 8 * 1024 * 1024, + }, + (error, stdout) => { + if (error !== null) { + reject(new GitDiffError(`git status failed: ${error.message}`)); + return; + } + resolve(parsePorcelainZ(stdout, prefix)); + }, + ); + }); +} + +function getGitPrefix(workDir: string): Promise { + return new Promise((resolve, reject) => { + execFile( + 'git', + ['-C', workDir, 'rev-parse', '--show-prefix'], + { + encoding: 'utf8', + timeout: GIT_TIMEOUT_MS, + }, + (error, stdout) => { + if (error !== null) { + reject(new GitDiffError(`git rev-parse failed: ${error.message}`)); + return; + } + resolve(stdout.trimEnd()); + }, + ); + }); +} + +function parsePorcelainZ(stdout: string, prefix: string): readonly GitChangedFile[] { + if (stdout.length === 0) return []; + + const entries: GitChangedFile[] = []; + const parts = stdout.split('\0'); + let i = 0; + while (i < parts.length) { + const entry = parts[i]; + i++; + if (entry === undefined || entry.length < 3) continue; + + const indexStatus = entry[0]!; + const worktreeStatus = entry[1]!; + let path = entry.slice(3); + if (path.length === 0) continue; + + if (prefix.length > 0) { + if (!path.startsWith(prefix)) continue; + path = path.slice(prefix.length); + if (path.length === 0) continue; + } + + const status = classifyStatus(indexStatus, worktreeStatus); + entries.push({ path, status }); + } + return entries; +} + +function classifyStatus(indexStatus: string, worktreeStatus: string): GitFileStatus { + const status = indexStatus !== ' ' ? indexStatus : worktreeStatus; + switch (status) { + case 'M': + return 'modified'; + case 'A': + return 'added'; + case 'D': + return 'deleted'; + case 'R': + return 'renamed'; + case 'C': + return 'copied'; + case '?': + return 'untracked'; + case '!': + return 'ignored'; + default: + return 'unknown'; + } +} + +export async function runGitDiffForFile( + workDir: string, + file: GitChangedFile, + contextLines: number = 3, +): Promise { + assertSafeArg(file.path); + + const unifiedArg = `-U${String(contextLines)}`; + + if (file.status === 'untracked') { + return runUntrackedDiff(workDir, file.path, unifiedArg); + } + + return runGit(workDir, ['diff', 'HEAD', unifiedArg, '--', file.path]); +} + +function isWorkdirMissingError(error: Error): boolean { + return error.message.includes('cannot change to') || error.message.includes('No such file or directory'); +} + +function isEmptyRepoError(error: Error): boolean { + // 'HEAD' is a git ref name and appears in the error regardless of locale + // (e.g. English "ambiguous argument 'HEAD'" or Chinese "歧义参数 'HEAD'"). + return error.message.includes("'HEAD'"); +} + +function runUntrackedDiff(workDir: string, path: string, unifiedArg: string): Promise { + return new Promise((resolve, reject) => { + execFile( + 'git', + ['-C', workDir, 'diff', '--no-index', unifiedArg, '--', '/dev/null', path], + { + encoding: 'utf8', + timeout: GIT_TIMEOUT_MS, + maxBuffer: 8 * 1024 * 1024, + }, + (error, stdout, stderr) => { + // `git diff --no-index` exits with code 1 when files differ, which is expected. + if (error !== null && (error as { code?: unknown }).code !== 1) { + // If the workDir was removed between starting the diff and the callback + // (e.g. during test teardown), treat it as an empty diff rather than an + // unhandled rejection. + if (isWorkdirMissingError(error) || isEmptyRepoError(error)) { + resolve(''); + return; + } + reject(new GitDiffError(`git diff failed: ${stderr.trim() || error.message}`)); + return; + } + resolve(stdout); + }, + ); + }); +} + +function runGit(workDir: string, args: readonly string[]): Promise { + return new Promise((resolve, reject) => { + execFile( + 'git', + ['-C', workDir, ...args], + { + encoding: 'utf8', + timeout: GIT_TIMEOUT_MS, + maxBuffer: 8 * 1024 * 1024, + }, + (error, stdout, stderr) => { + if (error !== null) { + if (isWorkdirMissingError(error) || isEmptyRepoError(error)) { + resolve(''); + return; + } + reject(new GitDiffError(`git diff failed: ${stderr.trim() || error.message}`)); + return; + } + resolve(stdout); + }, + ); + }); +} + +export async function runGitNumstat( + workDir: string, +): Promise> { + const prefix = await getGitPrefix(workDir); + return new Promise((resolve, reject) => { + execFile( + 'git', + ['-C', workDir, 'diff', 'HEAD', '--no-renames', '--numstat'], + { + encoding: 'utf8', + timeout: GIT_TIMEOUT_MS, + maxBuffer: 8 * 1024 * 1024, + }, + (error, stdout) => { + if (error !== null) { + if (isWorkdirMissingError(error) || isEmptyRepoError(error)) { + resolve(new Map()); + return; + } + reject(new GitDiffError(`git diff --numstat failed: ${error.message}`)); + return; + } + resolve(parseNumstat(stdout, prefix)); + }, + ); + }); +} + +export async function runUntrackedNumstat( + workDir: string, + path: string, +): Promise<{ additions: number; deletions: number }> { + assertSafeArg(path); + return new Promise((resolve, reject) => { + execFile( + 'git', + ['-C', workDir, 'diff', '--no-index', '--numstat', '--', '/dev/null', path], + { + encoding: 'utf8', + timeout: GIT_TIMEOUT_MS, + maxBuffer: 8 * 1024 * 1024, + }, + (error, stdout, stderr) => { + if (error !== null && (error as { code?: unknown }).code !== 1) { + if (isWorkdirMissingError(error)) { + resolve({ additions: 0, deletions: 0 }); + return; + } + reject( + new GitDiffError( + `git diff --no-index --numstat failed: ${stderr.trim() || error.message}`, + ), + ); + return; + } + const stats = parseNumstat(stdout); + const stat = stats.get(path) ?? stats.get(`/dev/null => ${path}`); + resolve(stat ?? { additions: 0, deletions: 0 }); + }, + ); + }); +} + +function parseNumstat( + stdout: string, + workTreePrefix: string = '', +): Map { + const normalizedPrefix = + workTreePrefix.length > 0 && !workTreePrefix.endsWith('/') ? `${workTreePrefix}/` : workTreePrefix; + const stats = new Map(); + for (const line of stdout.split('\n')) { + if (line.length === 0) continue; + const parts = line.split('\t'); + if (parts.length < 3) continue; + const additions = parts[0] === '-' ? 0 : Number(parts[0]); + const deletions = parts[1] === '-' ? 0 : Number(parts[1]); + const rawPath = parts[2]!; + const path = + normalizedPrefix.length > 0 && rawPath.startsWith(normalizedPrefix) + ? rawPath.slice(normalizedPrefix.length) + : rawPath; + if (!Number.isNaN(additions) && !Number.isNaN(deletions)) { + stats.set(path, { additions, deletions }); + } + } + return stats; +} diff --git a/apps/kimi-code/test/tui/commands/diff-real-repo.test.ts b/apps/kimi-code/test/tui/commands/diff-real-repo.test.ts new file mode 100644 index 0000000000..f1fd5ddc30 --- /dev/null +++ b/apps/kimi-code/test/tui/commands/diff-real-repo.test.ts @@ -0,0 +1,413 @@ +/** + * Integration test for /diff: uses a real git repository on disk and verifies + * the selector / diff-panel behaviour end-to-end. + */ + +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Key, matchesKey } from '@moonshot-ai/pi-tui'; + +import { handleDiffCommand } from '#/tui/commands/diff'; +import type { SlashCommandHost } from '#/tui/commands/dispatch'; +import { runGitNumstat, runUntrackedNumstat } from '#/utils/git/git-diff'; + +const DOWN_ARROW = '\u001B[B'; +const RIGHT_ARROW = '\u001B[C'; +const LEFT_ARROW = '\u001B[D'; +const ENTER = '\r'; + +function makeHost(workDir: string, transcriptEntries: unknown[] = []) { + const state = { + appState: { workDir }, + transcriptEntries, + transcriptContainer: { addChild: vi.fn() }, + ui: { requestRender: vi.fn() }, + }; + const host = { + state, + showError: vi.fn(), + showStatus: vi.fn(), + mountEditorReplacement: vi.fn(), + restoreEditor: vi.fn(), + } as unknown as SlashCommandHost & { + state: typeof state; + showError: ReturnType; + showStatus: ReturnType; + mountEditorReplacement: ReturnType; + restoreEditor: ReturnType; + }; + return host; +} + +function runGit(cwd: string, ...args: string[]): void { + execFileSync('git', args, { cwd, encoding: 'utf8' }); +} + +function editToolEntry(path: string, before: string, after: string, turnId?: string) { + return { + id: '1', + kind: 'tool_call' as const, + renderMode: 'plain' as const, + content: '', + turnId, + toolCallData: { + id: 'tc-1', + name: 'Edit', + args: {}, + display: { kind: 'file_io' as const, operation: 'edit' as const, path, before, after }, + }, + }; +} + +function userEntry(content: string, turnId?: string) { + return { + id: 'u-1', + kind: 'user' as const, + renderMode: 'plain' as const, + content, + turnId, + }; +} + +describe('handleDiffCommand with real git repo', () => { + let repoDir: string; + + beforeEach(() => { + repoDir = mkdtempSync(join(tmpdir(), 'kimi-diff-test-')); + runGit(repoDir, 'init', '--quiet'); + runGit(repoDir, 'config', 'user.email', 'test@example.com'); + runGit(repoDir, 'config', 'user.name', 'Test User'); + + writeFileSync(join(repoDir, 'hello.txt'), 'first line\n', 'utf8'); + runGit(repoDir, 'add', 'hello.txt'); + runGit(repoDir, 'commit', '--quiet', '-m', 'initial'); + }); + + afterEach(() => { + execFileSync('rm', ['-rf', repoDir]); + }); + + it('renders a diff panel directly when only one file changed', async () => { + writeFileSync(join(repoDir, 'hello.txt'), 'first line\nsecond line\n', 'utf8'); + const host = makeHost(repoDir); + + await handleDiffCommand(host, ''); + + expect(host.showError).not.toHaveBeenCalled(); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + expect(host.state.transcriptContainer.addChild).toHaveBeenCalledTimes(1); + expect(host.state.ui.requestRender).toHaveBeenCalledTimes(1); + + const panel = host.state.transcriptContainer.addChild.mock.calls[0]![0] as { + render(width: number): string[]; + }; + const rendered = panel.render(120).join('\n'); + + expect(rendered).toContain('diff --git a/hello.txt b/hello.txt'); + expect(rendered).toContain('+second line'); + expect(rendered).toContain(' Diff '); + }); + + it('shows a status message when there are no changes', async () => { + const host = makeHost(repoDir); + + await handleDiffCommand(host, ''); + + expect(host.showStatus).toHaveBeenCalledWith('No changed files.'); + expect(host.state.transcriptContainer.addChild).not.toHaveBeenCalled(); + }); + + it('opens a selector when multiple files changed', async () => { + writeFileSync(join(repoDir, 'hello.txt'), 'first line\nsecond line\n', 'utf8'); + writeFileSync(join(repoDir, 'world.txt'), 'new file\n', 'utf8'); + const host = makeHost(repoDir); + + await handleDiffCommand(host, ''); + + expect(host.mountEditorReplacement).toHaveBeenCalledTimes(1); + expect(host.state.transcriptContainer.addChild).not.toHaveBeenCalled(); + }); + + it('renders a diff panel for an untracked file', async () => { + writeFileSync(join(repoDir, 'untracked.txt'), 'untracked content\n', 'utf8'); + const host = makeHost(repoDir); + + await handleDiffCommand(host, ''); + + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + expect(host.state.transcriptContainer.addChild).toHaveBeenCalledTimes(1); + + const panel = host.state.transcriptContainer.addChild.mock.calls[0]![0] as { + render(width: number): string[]; + }; + const rendered = panel.render(120).join('\n'); + + expect(rendered).toContain('untracked.txt'); + expect(rendered).toContain('+untracked content'); + }); + + it('renders a session-edit diff when selecting it from the file list', async () => { + const fooPath = join(repoDir, 'foo.ts'); + writeFileSync(fooPath, 'original line\n', 'utf8'); + runGit(repoDir, 'add', 'foo.ts'); + runGit(repoDir, 'commit', '--quiet', '-m', 'add foo'); + + writeFileSync(fooPath, 'original line\nnew line\n', 'utf8'); + writeFileSync(join(repoDir, 'bar.ts'), 'bar content\n', 'utf8'); + + const host = makeHost(repoDir, [ + userEntry('prompt', 't1'), + editToolEntry(fooPath, 'original line\n', 'original line\nnew line\n', 't1'), + ]); + + await handleDiffCommand(host, ''); + + expect(host.mountEditorReplacement).toHaveBeenCalledTimes(1); + const selector = host.mountEditorReplacement.mock.calls[0]![0] as { + handleInput(data: string): void; + render(width: number): string[]; + }; + + const list = selector.render(80).join('\n'); + expect(list).toContain('bar.ts'); + expect(list).toContain('foo.ts'); + + selector.handleInput(RIGHT_ARROW); + const turnTab = selector.render(80).join('\n'); + expect(turnTab).toContain('foo.ts'); + expect(turnTab).not.toContain('bar.ts'); + + selector.handleInput(ENTER); + + // Wait for the async diff viewer to mount and load. + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(host.mountEditorReplacement).toHaveBeenCalledTimes(2); + expect(host.showError).not.toHaveBeenCalled(); + const viewer = host.mountEditorReplacement.mock.calls[1]![0] as { + handleInput(data: string): void; + render(width: number): string[]; + }; + const rendered = viewer.render(120).join('\n'); + expect(rendered).toContain('foo.ts'); + expect(rendered).not.toContain('No changes.'); + expect(rendered).toMatch(/original line|new line/); + expect(rendered).toContain('ctrl+o expand context'); + + // Expand context with ctrl+o and verify the diff still renders. + viewer.handleInput('\u000F'); + await vi.waitFor(() => { + const expanded = viewer.render(120).join('\n'); + return expanded.includes('ctrl+o collapse context'); + }); + const expanded = viewer.render(120).join('\n'); + expect(expanded).toContain('foo.ts'); + expect(expanded).toMatch(/original line|new line/); + + // Collapse back with ctrl+o. + viewer.handleInput('\u000F'); + await vi.waitFor(() => { + const collapsed = viewer.render(120).join('\n'); + return collapsed.includes('ctrl+o expand context'); + }); + + // Esc returns to the file list. + viewer.handleInput('\u001B'); + expect(host.mountEditorReplacement).toHaveBeenCalledTimes(3); + }); + + it('renders a git diff when selecting a git-only file from the list', async () => { + writeFileSync(join(repoDir, 'foo.ts'), 'original line\n', 'utf8'); + runGit(repoDir, 'add', 'foo.ts'); + runGit(repoDir, 'commit', '--quiet', '-m', 'add foo'); + + writeFileSync(join(repoDir, 'foo.ts'), 'original line\nnew line\n', 'utf8'); + writeFileSync(join(repoDir, 'bar.ts'), 'bar content\n', 'utf8'); + + const host = makeHost(repoDir); + + await handleDiffCommand(host, ''); + + expect(host.mountEditorReplacement).toHaveBeenCalledTimes(1); + const selector = host.mountEditorReplacement.mock.calls[0]![0] as { + handleInput(data: string): void; + render(width: number): string[]; + }; + + // Move down to select bar.ts (untracked). + selector.handleInput(DOWN_ARROW); + selector.handleInput(ENTER); + + // Wait for the async git diff to complete. + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(host.mountEditorReplacement).toHaveBeenCalledTimes(2); + expect(host.showError).not.toHaveBeenCalled(); + const viewer = host.mountEditorReplacement.mock.calls[1]![0] as { + handleInput(data: string): void; + render(width: number): string[]; + }; + const rendered = viewer.render(120).join('\n'); + + expect(rendered).toContain('bar.ts'); + expect(rendered).not.toContain('No changes.'); + expect(rendered).toContain('+bar content'); + expect(rendered).toContain('ctrl+o expand context'); + + // Expand context with ctrl+o and verify the diff still renders. + viewer.handleInput('\u000F'); + await vi.waitFor(() => { + const expanded = viewer.render(120).join('\n'); + return expanded.includes('ctrl+o collapse context'); + }); + const expanded = viewer.render(120).join('\n'); + expect(expanded).toContain('bar.ts'); + expect(expanded).toContain('+bar content'); + + // Collapse back with ctrl+o. + viewer.handleInput('\u000F'); + await vi.waitFor(() => { + const collapsed = viewer.render(120).join('\n'); + return collapsed.includes('ctrl+o expand context'); + }); + + // Esc returns to the file list. + viewer.handleInput('\u001B'); + expect(host.mountEditorReplacement).toHaveBeenCalledTimes(3); + }); + + it('renders diffs correctly when the workDir is a git subdirectory', async () => { + writeFileSync(join(repoDir, 'foo.txt'), 'foo content\n', 'utf8'); + mkdirSync(join(repoDir, 'sub')); + runGit(repoDir, 'add', 'foo.txt'); + runGit(repoDir, 'commit', '--quiet', '-m', 'init'); + + writeFileSync(join(repoDir, 'sub', 'bar.ts'), 'bar content\n', 'utf8'); + + const host = makeHost(join(repoDir, 'sub')); + + await handleDiffCommand(host, ''); + + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + expect(host.state.transcriptContainer.addChild).toHaveBeenCalledTimes(1); + + const panel = host.state.transcriptContainer.addChild.mock.calls[0]![0] as { + render(width: number): string[]; + }; + const rendered = panel.render(120).join('\n'); + + expect(rendered).toContain('bar.ts'); + expect(rendered).not.toContain('No changes.'); + expect(rendered).toContain('+bar content'); + }); + + it('renders turn tabs for session edits grouped by turn', async () => { + writeFileSync(join(repoDir, 'a.ts'), 'a original\n', 'utf8'); + writeFileSync(join(repoDir, 'b.ts'), 'b original\n', 'utf8'); + runGit(repoDir, 'add', 'a.ts', 'b.ts'); + runGit(repoDir, 'commit', '--quiet', '-m', 'add files'); + + const aPath = join(repoDir, 'a.ts'); + const bPath = join(repoDir, 'b.ts'); + const host = makeHost(repoDir, [ + userEntry('first prompt', 't1'), + editToolEntry(aPath, 'a original\n', 'a edited\n', 't1'), + userEntry('second prompt', 't2'), + editToolEntry(bPath, 'b original\n', 'b edited\n', 't2'), + ]); + + await handleDiffCommand(host, ''); + + expect(host.mountEditorReplacement).toHaveBeenCalledTimes(1); + const selector = host.mountEditorReplacement.mock.calls[0]![0] as { + handleInput(data: string): void; + render(width: number): string[]; + }; + + const rendered = selector.render(80).join('\n'); + expect(rendered).toContain('Current'); + expect(rendered).toContain('T1'); + expect(rendered).toContain('T2'); + expect(rendered).not.toContain('a.ts'); + expect(rendered).not.toContain('b.ts'); + + selector.handleInput(RIGHT_ARROW); + const t2 = selector.render(80).join('\n'); + expect(t2).toContain('b.ts'); + expect(t2).not.toContain('a.ts'); + + selector.handleInput(RIGHT_ARROW); + const t1 = selector.render(80).join('\n'); + expect(t1).toContain('a.ts'); + expect(t1).not.toContain('b.ts'); + }); + + it('includes session-edited files in the Current source', async () => { + writeFileSync(join(repoDir, 'a.ts'), 'a original\n', 'utf8'); + writeFileSync(join(repoDir, 'git-only.ts'), 'git original\n', 'utf8'); + runGit(repoDir, 'add', 'a.ts', 'git-only.ts'); + runGit(repoDir, 'commit', '--quiet', '-m', 'add files'); + + writeFileSync(join(repoDir, 'git-only.ts'), 'git modified\n', 'utf8'); + + const aPath = join(repoDir, 'a.ts'); + writeFileSync(aPath, 'a edited\n', 'utf8'); + const host = makeHost(repoDir, [ + userEntry('prompt', 't1'), + editToolEntry(aPath, 'a original\n', 'a edited\n', 't1'), + ]); + + await handleDiffCommand(host, ''); + + const selector = host.mountEditorReplacement.mock.calls[0]![0] as { + handleInput(data: string): void; + render(width: number): string[]; + }; + + const current = selector.render(80).join('\n'); + expect(current).toContain('git-only.ts'); + expect(current).toContain('a.ts'); + + selector.handleInput(RIGHT_ARROW); + const turnTab = selector.render(80).join('\n'); + expect(turnTab).toContain('a.ts'); + expect(turnTab).not.toContain('git-only.ts'); + }); +}); + +describe('git diff numstat helpers', () => { + let repoDir: string; + + beforeEach(() => { + repoDir = mkdtempSync(join(tmpdir(), 'kimi-diff-numstat-test-')); + runGit(repoDir, 'init', '--quiet'); + runGit(repoDir, 'config', 'user.email', 'test@example.com'); + runGit(repoDir, 'config', 'user.name', 'Test User'); + + writeFileSync(join(repoDir, 'tracked.txt'), 'line1\nline2\n', 'utf8'); + runGit(repoDir, 'add', 'tracked.txt'); + runGit(repoDir, 'commit', '--quiet', '-m', 'initial'); + }); + + afterEach(() => { + execFileSync('rm', ['-rf', repoDir]); + }); + + it('returns stats for tracked modifications', async () => { + writeFileSync(join(repoDir, 'tracked.txt'), 'line1\nline2\nline3\n', 'utf8'); + + const stats = await runGitNumstat(repoDir); + + expect(stats.get('tracked.txt')).toEqual({ additions: 1, deletions: 0 }); + }); + + it('returns stats for untracked files', async () => { + writeFileSync(join(repoDir, 'untracked.txt'), 'new1\nnew2\nnew3\n', 'utf8'); + + const stat = await runUntrackedNumstat(repoDir, 'untracked.txt'); + + expect(stat).toEqual({ additions: 3, deletions: 0 }); + }); +}); diff --git a/apps/kimi-code/test/tui/commands/diff.test.ts b/apps/kimi-code/test/tui/commands/diff.test.ts new file mode 100644 index 0000000000..900e19557b --- /dev/null +++ b/apps/kimi-code/test/tui/commands/diff.test.ts @@ -0,0 +1,702 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { handleDiffCommand } from '#/tui/commands/diff'; +import type { SlashCommandHost } from '#/tui/commands/dispatch'; +import { ToolCallComponent } from '#/tui/components/messages/tool-call'; +import { + isInsideGitRepo, + listChangedFiles, + runGitDiffForFile, + runGitNumstat, + runUntrackedNumstat, +} from '#/utils/git/git-diff'; + +vi.mock('#/utils/git/git-diff', () => ({ + isInsideGitRepo: vi.fn(), + listChangedFiles: vi.fn(), + runGitDiffForFile: vi.fn(), + runGitNumstat: vi.fn(), + runUntrackedNumstat: vi.fn(), +})); + +function makeHost( + workDir: string, + transcriptEntries: unknown[] = [], + transcriptChildren: unknown[] = [], +) { + const state = { + appState: { workDir }, + transcriptEntries, + transcriptContainer: { addChild: vi.fn(), children: transcriptChildren }, + ui: { requestRender: vi.fn() }, + }; + const host = { + state, + showError: vi.fn(), + showStatus: vi.fn(), + mountEditorReplacement: vi.fn(), + restoreEditor: vi.fn(), + } as unknown as SlashCommandHost & { + state: typeof state; + showError: ReturnType; + showStatus: ReturnType; + mountEditorReplacement: ReturnType; + restoreEditor: ReturnType; + }; + return host; +} + +const mockedIsInsideGitRepo = vi.mocked(isInsideGitRepo); +const mockedListChangedFiles = vi.mocked(listChangedFiles); +const mockedRunGitDiffForFile = vi.mocked(runGitDiffForFile); +const mockedRunGitNumstat = vi.mocked(runGitNumstat); +const mockedRunUntrackedNumstat = vi.mocked(runUntrackedNumstat); + +const RIGHT_ARROW = '\u001B[C'; +const LEFT_ARROW = '\u001B[D'; +const DOWN_ARROW = '\u001B[B'; +const ENTER = '\r'; + +function editToolEntry(path: string, before: string, after: string, turnId?: string) { + return { + id: '1', + kind: 'tool_call' as const, + renderMode: 'plain' as const, + content: '', + turnId, + toolCallData: { + id: 'tc-1', + name: 'Edit', + args: {}, + display: { kind: 'file_io' as const, operation: 'edit' as const, path, before, after }, + }, + }; +} + +function userEntry(content: string, turnId?: string) { + return { + id: 'u-1', + kind: 'user' as const, + renderMode: 'plain' as const, + content, + turnId, + }; +} + +describe('handleDiffCommand', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedRunGitNumstat.mockResolvedValue(new Map()); + mockedRunUntrackedNumstat.mockResolvedValue({ additions: 0, deletions: 0 }); + }); + + it('shows session edits when the workspace is not a git repository', async () => { + mockedIsInsideGitRepo.mockReturnValue(false); + const host = makeHost('/not-a-repo', [ + userEntry('prompt', 't1'), + editToolEntry('a.ts', 'old', 'new', 't1'), + ]); + + await handleDiffCommand(host, ''); + + expect(mockedListChangedFiles).not.toHaveBeenCalled(); + expect(host.mountEditorReplacement).toHaveBeenCalled(); + const selector = host.mountEditorReplacement.mock.calls[0]![0] as { + render(width: number): string[]; + handleInput(data: string): void; + }; + selector.handleInput(RIGHT_ARROW); // switch to T1 + const rendered = selector.render(80).join('\n'); + expect(rendered).toContain('T1'); + expect(rendered).toContain('a.ts'); + }); + + it('shows a status message when there are no changed files and no git repo', async () => { + mockedIsInsideGitRepo.mockReturnValue(false); + const host = makeHost('/not-a-repo'); + + await handleDiffCommand(host, ''); + + expect(mockedListChangedFiles).not.toHaveBeenCalled(); + expect(host.showStatus).toHaveBeenCalledWith('No changed files.'); + }); + + it('shows a status message when there are no changed files', async () => { + mockedIsInsideGitRepo.mockReturnValue(true); + mockedListChangedFiles.mockResolvedValue([]); + const host = makeHost('/repo'); + + await handleDiffCommand(host, ''); + + expect(host.showStatus).toHaveBeenCalledWith('No changed files.'); + expect(host.state.transcriptContainer.addChild).not.toHaveBeenCalled(); + }); + + it('opens a selector with a turn tab when only one session file was edited', async () => { + mockedIsInsideGitRepo.mockReturnValue(true); + mockedListChangedFiles.mockResolvedValue([]); + const host = makeHost('/repo', [ + userEntry('prompt', 't1'), + editToolEntry('foo.ts', 'old', 'new', 't1'), + ]); + + await handleDiffCommand(host, ''); + + expect(host.mountEditorReplacement).toHaveBeenCalledTimes(1); + const selector = host.mountEditorReplacement.mock.calls[0]![0] as { + handleInput(data: string): void; + render(width: number): string[]; + }; + selector.handleInput(RIGHT_ARROW); + const rendered = selector.render(80).join('\n'); + expect(rendered).toContain('foo.ts'); + expect(mockedRunGitDiffForFile).not.toHaveBeenCalled(); + }); + + it('opens a selector with Current and turn tabs', async () => { + mockedIsInsideGitRepo.mockReturnValue(true); + mockedListChangedFiles.mockResolvedValue([ + { path: 'git-only.ts', status: 'modified' }, + ]); + const host = makeHost('/repo', [ + userEntry('prompt', 't1'), + editToolEntry('session.ts', 'a', 'b', 't1'), + ]); + + await handleDiffCommand(host, ''); + + expect(host.mountEditorReplacement).toHaveBeenCalledTimes(1); + const selector = host.mountEditorReplacement.mock.calls[0]![0] as { + handleInput(data: string): void; + render(width: number): string[]; + }; + const rendered = selector.render(80).join('\n'); + expect(rendered).toContain('Current'); + expect(rendered).toContain('T1'); + expect(rendered).toContain('git-only.ts'); + expect(rendered).not.toContain('session.ts'); + + selector.handleInput(RIGHT_ARROW); + const turnTab = selector.render(80).join('\n'); + expect(turnTab).toContain('session.ts'); + expect(turnTab).not.toContain('git-only.ts'); + }); + + it('renders session edit diff content when selecting a session file', async () => { + mockedIsInsideGitRepo.mockReturnValue(true); + mockedListChangedFiles.mockResolvedValue([ + { path: 'git-only.ts', status: 'modified' }, + ]); + const host = makeHost('/repo', [ + userEntry('prompt', 't1'), + editToolEntry('session.ts', 'old content', 'new content', 't1'), + ]); + + await handleDiffCommand(host, ''); + + expect(host.mountEditorReplacement).toHaveBeenCalledTimes(1); + const selector = host.mountEditorReplacement.mock.calls[0]![0] as { + handleInput(data: string): void; + render(width: number): string[]; + }; + selector.handleInput(RIGHT_ARROW); + selector.handleInput(ENTER); + + // showDiffViewer is async; wait for the viewer to finish loading. + await vi.waitFor(() => expect(host.mountEditorReplacement).toHaveBeenCalledTimes(2)); + const viewer = host.mountEditorReplacement.mock.calls[1]![0] as { + handleInput(data: string): void; + render(width: number): string[]; + }; + await vi.waitFor(() => { + const rendered = viewer.render(120).join('\n'); + return !rendered.includes('Loading diff'); + }); + const rendered = viewer.render(120).join('\n'); + expect(rendered).toContain('session.ts'); + expect(rendered).not.toContain('No changes.'); + expect(rendered).toMatch(/old content|new content/); + expect(rendered).toContain('ctrl+o expand context'); + + // Expand context with ctrl+o. + viewer.handleInput('\u000F'); + await vi.waitFor(() => { + const expanded = viewer.render(120).join('\n'); + return expanded.includes('ctrl+o collapse context'); + }); + + // Collapse back with ctrl+o. + viewer.handleInput('\u000F'); + await vi.waitFor(() => { + const collapsed = viewer.render(120).join('\n'); + return collapsed.includes('ctrl+o expand context'); + }); + }); + + it('uses git status as fallback for files not edited by tool calls', async () => { + mockedIsInsideGitRepo.mockReturnValue(true); + mockedListChangedFiles.mockResolvedValue([ + { path: 'bash-edited.ts', status: 'modified' }, + ]); + mockedRunGitDiffForFile.mockResolvedValue('diff --git a/bash-edited.ts b/bash-edited.ts\n+change'); + const host = makeHost('/repo'); + + await handleDiffCommand(host, ''); + + expect(mockedRunGitDiffForFile).toHaveBeenCalledWith( + '/repo', + expect.objectContaining({ path: 'bash-edited.ts' }), + expect.any(Number), + ); + expect(host.state.transcriptContainer.addChild).toHaveBeenCalledTimes(1); + }); + + it('prefers session edits over git status for the same file', async () => { + mockedIsInsideGitRepo.mockReturnValue(true); + mockedListChangedFiles.mockResolvedValue([ + { path: 'foo.ts', status: 'modified' }, + ]); + const host = makeHost('/repo', [ + userEntry('prompt', 't1'), + editToolEntry('foo.ts', 'old', 'new', 't1'), + ]); + + await handleDiffCommand(host, ''); + + expect(host.mountEditorReplacement).toHaveBeenCalledTimes(1); + expect(mockedRunGitDiffForFile).not.toHaveBeenCalled(); + const selector = host.mountEditorReplacement.mock.calls[0]![0] as { + handleInput(data: string): void; + render(width: number): string[]; + }; + selector.handleInput(RIGHT_ARROW); + const rendered = selector.render(80).join('\n'); + expect(rendered).toContain('foo.ts'); + expect(rendered).not.toContain('git'); + }); + + it('collects session edits from transcript ToolCallComponents when entries lack tool_call data', async () => { + mockedIsInsideGitRepo.mockReturnValue(true); + mockedListChangedFiles.mockResolvedValue([ + { path: 'foo.ts', status: 'modified' }, + ]); + const component = new ToolCallComponent( + { + id: 'tc-1', + name: 'Edit', + args: {}, + display: { + kind: 'file_io', + operation: 'edit', + path: 'foo.ts', + before: 'old content', + after: 'new content', + }, + }, + undefined, + ); + const host = makeHost('/repo', [], [component]); + + await handleDiffCommand(host, ''); + + expect(host.mountEditorReplacement).toHaveBeenCalledTimes(1); + const selector = host.mountEditorReplacement.mock.calls[0]![0] as { + handleInput(data: string): void; + render(width: number): string[]; + }; + // Components without a turnId land in the unnamed turn tab. + selector.handleInput(RIGHT_ARROW); + selector.handleInput(ENTER); + + await vi.waitFor(() => expect(host.mountEditorReplacement).toHaveBeenCalledTimes(2)); + expect(mockedRunGitDiffForFile).not.toHaveBeenCalled(); + }); + + it('shows an error when git status fails', async () => { + mockedIsInsideGitRepo.mockReturnValue(true); + mockedListChangedFiles.mockRejectedValue(new Error('git crashed')); + const host = makeHost('/repo'); + + await handleDiffCommand(host, ''); + + expect(host.showError).toHaveBeenCalledWith('git crashed'); + }); + + it('groups session edits into turn tabs', async () => { + mockedIsInsideGitRepo.mockReturnValue(true); + mockedListChangedFiles.mockResolvedValue([]); + const host = makeHost('/repo', [ + userEntry('first prompt', 't1'), + editToolEntry('a.ts', 'old', 'new', 't1'), + userEntry('second prompt', 't2'), + editToolEntry('b.ts', 'x', 'y', 't2'), + ]); + + await handleDiffCommand(host, ''); + + expect(host.mountEditorReplacement).toHaveBeenCalledTimes(1); + const selector = host.mountEditorReplacement.mock.calls[0]![0] as { + handleInput(data: string): void; + render(width: number): string[]; + }; + const rendered = selector.render(80).join('\n'); + expect(rendered).toContain('Current'); + expect(rendered).toContain('T1'); + expect(rendered).toContain('T2'); + // Default active source is Current, which has no files here. + expect(rendered).not.toContain('a.ts'); + expect(rendered).not.toContain('b.ts'); + + // Turn sources are sorted newest-first, so the first turn tab is T2. + selector.handleInput(RIGHT_ARROW); + const newest = selector.render(80).join('\n'); + expect(newest).toContain('b.ts'); + expect(newest).not.toContain('a.ts'); + + selector.handleInput(RIGHT_ARROW); + const older = selector.render(80).join('\n'); + expect(older).toContain('a.ts'); + expect(older).not.toContain('b.ts'); + }); + + it('switches turn tabs with left/right arrows', async () => { + mockedIsInsideGitRepo.mockReturnValue(true); + mockedListChangedFiles.mockResolvedValue([]); + const host = makeHost('/repo', [ + userEntry('first prompt', 't1'), + editToolEntry('a.ts', 'old', 'new', 't1'), + userEntry('second prompt', 't2'), + editToolEntry('b.ts', 'x', 'y', 't2'), + ]); + + await handleDiffCommand(host, ''); + + const selector = host.mountEditorReplacement.mock.calls[0]![0] as { + handleInput(data: string): void; + render(width: number): string[]; + }; + selector.handleInput(RIGHT_ARROW); + expect(selector.render(80).join('\n')).toContain('b.ts'); + + selector.handleInput(RIGHT_ARROW); + expect(selector.render(80).join('\n')).toContain('a.ts'); + + selector.handleInput(LEFT_ARROW); + expect(selector.render(80).join('\n')).toContain('b.ts'); + }); + + it('includes session-edited files in the Current source', async () => { + mockedIsInsideGitRepo.mockReturnValue(true); + mockedListChangedFiles.mockResolvedValue([ + { path: 'a.ts', status: 'modified' }, + { path: 'git-only.ts', status: 'modified' }, + ]); + mockedRunGitNumstat.mockResolvedValue( + new Map([ + ['a.ts', { additions: 1, deletions: 1 }], + ['git-only.ts', { additions: 1, deletions: 0 }], + ]), + ); + const host = makeHost('/repo', [ + userEntry('prompt', 't1'), + editToolEntry('a.ts', 'old', 'new', 't1'), + ]); + + await handleDiffCommand(host, ''); + + const selector = host.mountEditorReplacement.mock.calls[0]![0] as { + handleInput(data: string): void; + render(width: number): string[]; + }; + const current = selector.render(80).join('\n'); + expect(current).toContain('Current'); + expect(current).toContain('git-only.ts'); + expect(current).toContain('a.ts'); + + selector.handleInput(RIGHT_ARROW); + const turnTab = selector.render(80).join('\n'); + expect(turnTab).toContain('a.ts'); + expect(turnTab).not.toContain('git-only.ts'); + }); + + it('shows the correct file for each turn source', async () => { + mockedIsInsideGitRepo.mockReturnValue(true); + mockedListChangedFiles.mockResolvedValue([]); + const host = makeHost('/repo', [ + userEntry('first prompt', 't1'), + editToolEntry('a.ts', 'old', 'new', 't1'), + userEntry('second prompt', 't2'), + editToolEntry('b.ts', 'x', 'y', 't2'), + ]); + + await handleDiffCommand(host, ''); + + const selector = host.mountEditorReplacement.mock.calls[0]![0] as { + handleInput(data: string): void; + render(width: number): string[]; + }; + // Default active source is Current; turn sources are sorted newest-first. + selector.handleInput(RIGHT_ARROW); + expect(selector.render(80).join('\n')).toContain('b.ts'); + + selector.handleInput(RIGHT_ARROW); + expect(selector.render(80).join('\n')).toContain('a.ts'); + }); + + it('renders turn subtitles from user message content', async () => { + mockedIsInsideGitRepo.mockReturnValue(true); + mockedListChangedFiles.mockResolvedValue([]); + const host = makeHost('/repo', [ + userEntry('hello world this is a long prompt that will be truncated', 't1'), + editToolEntry('a.ts', 'old', 'new', 't1'), + ]); + + await handleDiffCommand(host, ''); + + const selector = host.mountEditorReplacement.mock.calls[0]![0] as { + handleInput(data: string): void; + render(width: number): string[]; + }; + selector.handleInput(RIGHT_ARROW); + const rendered = selector.render(80).join('\n'); + expect(rendered).toContain('Turn 1'); + expect(rendered).toContain('hello world this is a long prompt that w...'); + }); + + it('shows only the edits from the selected turn when a file was edited in multiple turns', async () => { + mockedIsInsideGitRepo.mockReturnValue(true); + mockedListChangedFiles.mockResolvedValue([]); + const host = makeHost('/repo', [ + userEntry('first', 't1'), + editToolEntry('foo.ts', 'line1\n', 'line1\nline2\n', 't1'), + userEntry('second', 't2'), + editToolEntry('foo.ts', 'line1\nline2\n', 'line1\nline2\nline3\n', 't2'), + ]); + + await handleDiffCommand(host, ''); + + const selector = host.mountEditorReplacement.mock.calls[0]![0] as { + handleInput(data: string): void; + render(width: number): string[]; + }; + + // Turn sources are sorted newest-first, so first RIGHT_ARROW lands on T2. + selector.handleInput(RIGHT_ARROW); + selector.handleInput(ENTER); + + await vi.waitFor(() => expect(host.mountEditorReplacement).toHaveBeenCalledTimes(2)); + const viewerT2 = host.mountEditorReplacement.mock.calls[1]![0] as { + handleInput(data: string): void; + render(width: number): string[]; + }; + const t2Rendered = viewerT2.render(120).join('\n'); + expect(t2Rendered).toContain('+ line3'); + expect(t2Rendered).not.toContain('+ line2'); + + // Go back and switch to T1. + viewerT2.handleInput('\u001B'); + expect(host.mountEditorReplacement).toHaveBeenCalledTimes(3); + const selectorAgain = host.mountEditorReplacement.mock.calls[2]![0] as { + handleInput(data: string): void; + render(width: number): string[]; + }; + selectorAgain.handleInput(RIGHT_ARROW); + selectorAgain.handleInput(RIGHT_ARROW); + selectorAgain.handleInput(ENTER); + + await vi.waitFor(() => expect(host.mountEditorReplacement).toHaveBeenCalledTimes(4)); + const viewerT1 = host.mountEditorReplacement.mock.calls[3]![0] as { + render(width: number): string[]; + }; + const t1Rendered = viewerT1.render(120).join('\n'); + expect(t1Rendered).toContain('+ line2'); + expect(t1Rendered).not.toContain('+ line3'); + }); + + it('returns to the same turn tab after exiting the file diff viewer', async () => { + mockedIsInsideGitRepo.mockReturnValue(true); + mockedListChangedFiles.mockResolvedValue([]); + const host = makeHost('/repo', [ + userEntry('first prompt', 't1'), + editToolEntry('a.ts', 'old', 'new', 't1'), + userEntry('second prompt', 't2'), + editToolEntry('b.ts', 'x', 'y', 't2'), + ]); + + await handleDiffCommand(host, ''); + + const selector = host.mountEditorReplacement.mock.calls[0]![0] as { + handleInput(data: string): void; + render(width: number): string[]; + }; + + // Turn sources are sorted newest-first: first RIGHT_ARROW lands on T2. + selector.handleInput(RIGHT_ARROW); + selector.handleInput(ENTER); + + await vi.waitFor(() => expect(host.mountEditorReplacement).toHaveBeenCalledTimes(2)); + const viewer = host.mountEditorReplacement.mock.calls[1]![0] as { + handleInput(data: string): void; + render(width: number): string[]; + }; + + // Exit the viewer with Esc; the selector should remount on T2. + viewer.handleInput('\u001B'); + await vi.waitFor(() => expect(host.mountEditorReplacement).toHaveBeenCalledTimes(3)); + const selectorAgain = host.mountEditorReplacement.mock.calls[2]![0] as { + render(width: number): string[]; + }; + const rendered = selectorAgain.render(80).join('\n'); + expect(rendered).toContain('b.ts'); + expect(rendered).not.toContain('a.ts'); + }); + + it('returns to the same selected file after exiting the file diff viewer', async () => { + mockedIsInsideGitRepo.mockReturnValue(true); + mockedListChangedFiles.mockResolvedValue([]); + const host = makeHost('/repo', [ + userEntry('prompt', 't1'), + editToolEntry('a.ts', 'old-a', 'new-a', 't1'), + editToolEntry('b.ts', 'old-b', 'new-b', 't1'), + ]); + + await handleDiffCommand(host, ''); + + const selector = host.mountEditorReplacement.mock.calls[0]![0] as { + handleInput(data: string): void; + render(width: number): string[]; + }; + + // Move to T1 and select the second file (b.ts). + selector.handleInput(RIGHT_ARROW); + selector.handleInput(DOWN_ARROW); + selector.handleInput(ENTER); + + await vi.waitFor(() => expect(host.mountEditorReplacement).toHaveBeenCalledTimes(2)); + const viewer = host.mountEditorReplacement.mock.calls[1]![0] as { + handleInput(data: string): void; + render(width: number): string[]; + }; + + // Exit the viewer with Esc; the selector should remount with b.ts still selected. + viewer.handleInput('\u001B'); + await vi.waitFor(() => expect(host.mountEditorReplacement).toHaveBeenCalledTimes(3)); + const selectorAgain = host.mountEditorReplacement.mock.calls[2]![0] as { + render(width: number): string[]; + }; + const rendered = selectorAgain.render(80).join('\n'); + expect(rendered).toContain('❯ M b.ts'); + expect(rendered).not.toContain('❯ M a.ts'); + }); + + it('shows per-file addition and deletion counts', async () => { + mockedIsInsideGitRepo.mockReturnValue(true); + mockedListChangedFiles.mockResolvedValue([ + { path: 'git-only.ts', status: 'modified' }, + { path: 'another.ts', status: 'untracked' }, + ]); + mockedRunGitNumstat.mockResolvedValue( + new Map([['git-only.ts', { additions: 3, deletions: 2 }]]), + ); + mockedRunUntrackedNumstat.mockResolvedValue({ additions: 5, deletions: 0 }); + const host = makeHost('/repo'); + + await handleDiffCommand(host, ''); + + const selector = host.mountEditorReplacement.mock.calls[0]![0] as { + render(width: number): string[]; + }; + const rendered = selector.render(80).join('\n'); + expect(rendered).toContain('+3'); + expect(rendered).toContain('-2'); + expect(rendered).toContain('+5'); + expect(rendered).toContain('git-only.ts'); + }); + + it('renders turn subtitle with the correct 1-based turn number', async () => { + mockedIsInsideGitRepo.mockReturnValue(true); + mockedListChangedFiles.mockResolvedValue([]); + const host = makeHost('/repo', [ + userEntry('hello world this is a prompt', 't1'), + editToolEntry('a.ts', 'old', 'new', 't1'), + ]); + + await handleDiffCommand(host, ''); + + const selector = host.mountEditorReplacement.mock.calls[0]![0] as { + handleInput(data: string): void; + render(width: number): string[]; + }; + selector.handleInput(RIGHT_ARROW); + const rendered = selector.render(80).join('\n'); + expect(rendered).toContain('Turn 1'); + expect(rendered).not.toContain('Turn 0'); + }); + + it('shows session-edited files in the Current source when they are also git changes', async () => { + mockedIsInsideGitRepo.mockReturnValue(true); + mockedListChangedFiles.mockResolvedValue([ + { path: 'a.ts', status: 'modified' }, + ]); + mockedRunGitNumstat.mockResolvedValue( + new Map([['a.ts', { additions: 1, deletions: 1 }]]), + ); + const host = makeHost('/repo', [ + userEntry('prompt', 't1'), + editToolEntry('a.ts', 'old', 'new', 't1'), + ]); + + await handleDiffCommand(host, ''); + + const selector = host.mountEditorReplacement.mock.calls[0]![0] as { + render(width: number): string[]; + }; + const rendered = selector.render(80).join('\n'); + expect(rendered).toContain('a.ts'); + }); + + it('filters out git statuses that cannot be rendered', async () => { + mockedIsInsideGitRepo.mockReturnValue(true); + mockedListChangedFiles.mockResolvedValue([ + { path: 'git-only.ts', status: 'modified' }, + { path: 'another.ts', status: 'modified' }, + { path: 'ignored.ts', status: 'ignored' }, + ]); + const host = makeHost('/repo'); + + await handleDiffCommand(host, ''); + + const selector = host.mountEditorReplacement.mock.calls[0]![0] as { + render(width: number): string[]; + }; + const rendered = selector.render(80).join('\n'); + expect(rendered).toContain('git-only.ts'); + expect(rendered).toContain('another.ts'); + expect(rendered).not.toContain('ignored.ts'); + }); + + it('falls back to line counts for large session edits to avoid full LCS', async () => { + mockedIsInsideGitRepo.mockReturnValue(true); + mockedListChangedFiles.mockResolvedValue([]); + mockedRunGitNumstat.mockResolvedValue(new Map()); + + const largeBefore = Array.from({ length: 1200 }, (_, i) => `before ${i}`).join('\n'); + const largeAfter = Array.from({ length: 1500 }, (_, i) => `after ${i}`).join('\n'); + + const host = makeHost('/repo', [ + userEntry('prompt', 't1'), + editToolEntry('a.ts', largeBefore, largeAfter, 't1'), + ]); + + await handleDiffCommand(host, ''); + + const selector = host.mountEditorReplacement.mock.calls[0]![0] as { + render(width: number): string[]; + handleInput(data: string): void; + }; + selector.handleInput(RIGHT_ARROW); // switch to T1 + const rendered = selector.render(80).join('\n'); + expect(rendered).toContain('+1500'); + expect(rendered).toContain('-1200'); + }); +}); diff --git a/apps/kimi-code/test/tui/components/dialogs/diff-file-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/diff-file-selector.test.ts new file mode 100644 index 0000000000..6fd3f83ca1 --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/diff-file-selector.test.ts @@ -0,0 +1,304 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { DiffFileSelectorComponent } from '#/tui/components/dialogs/diff-file-selector'; +import { currentTheme } from '#/tui/theme'; + +const ANSI_SGR = /\u001B\[[0-9;]*m/g; + +function strip(text: string): string { + return text.replaceAll(ANSI_SGR, ''); +} + +describe('DiffFileSelectorComponent', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders changed files with status labels and optional change counts', () => { + const component = new DiffFileSelectorComponent({ + sources: [ + { + label: 'Current', + files: [ + { path: 'modified.ts', status: 'modified', source: 'session' }, + { path: 'added.ts', status: 'added', source: 'git' }, + { path: 'deleted.ts', status: 'deleted', source: 'git' }, + { path: 'untracked.ts', status: 'untracked', source: 'git' }, + ], + }, + ], + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const rendered = component.render(80).map(strip).join('\n'); + + expect(rendered).toContain('Uncommitted changes (git diff HEAD)'); + + expect(rendered).toContain('M modified.ts'); + expect(rendered).toContain('A added.ts'); + expect(rendered).toContain('D deleted.ts'); + expect(rendered).toContain('? untracked.ts'); + }); + + it('highlights only "Uncommitted changes" and grays out "(git diff HEAD)"', () => { + const boldFgSpy = vi + .spyOn(currentTheme, 'boldFg') + .mockImplementation((token, text) => `<>`); + const fgSpy = vi.spyOn(currentTheme, 'fg').mockImplementation((token, text) => `<>`); + + const component = new DiffFileSelectorComponent({ + sources: [ + { + label: 'Current', + files: [{ path: 'modified.ts', status: 'modified', source: 'session' }], + }, + ], + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const rendered = component.render(80).join('\n'); + + expect(rendered).toContain('<>'); + expect(rendered).toContain('<>'); + expect(boldFgSpy).toHaveBeenCalledWith('primary', 'Uncommitted changes'); + expect(fgSpy).toHaveBeenCalledWith('textMuted', '(git diff HEAD)'); + }); + + it('calls onSelect with the selected file on Enter', () => { + const onSelect = vi.fn(); + const component = new DiffFileSelectorComponent({ + sources: [ + { + label: 'Current', + files: [ + { path: 'a.ts', status: 'modified', source: 'session' }, + { path: 'b.ts', status: 'modified', source: 'git' }, + ], + }, + ], + onSelect, + onCancel: vi.fn(), + }); + + component.handleInput('\r'); // Enter + + expect(onSelect).toHaveBeenCalledWith({ + path: 'a.ts', + status: 'modified', + source: 'session', + }); + }); + + it('calls onCancel on Escape', () => { + const onCancel = vi.fn(); + const component = new DiffFileSelectorComponent({ + sources: [ + { + label: 'Current', + files: [{ path: 'a.ts', status: 'modified', source: 'session' }], + }, + ], + onSelect: vi.fn(), + onCancel, + }); + + component.handleInput('\u001B'); // Escape + + expect(onCancel).toHaveBeenCalled(); + }); + + describe('multi-source', () => { + it('renders multiple tabs', () => { + const component = new DiffFileSelectorComponent({ + sources: [ + { label: 'Current', files: [{ path: 'git.txt', status: 'modified', source: 'git' }] }, + { label: 'T1', files: [{ path: 'session.txt', status: 'modified', source: 'session' }] }, + ], + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const rendered = component.render(80).map(strip).join('\n'); + + expect(rendered).toContain('Current'); + expect(rendered).toContain('T1'); + expect(rendered).toContain('git.txt'); + }); + + it('renders the turn subtitle above the tab strip', () => { + const component = new DiffFileSelectorComponent({ + sources: [ + { label: 'Current', files: [{ path: 'git.txt', status: 'modified', source: 'git' }] }, + { + label: 'T1', + subtitle: 'Turn 1 "hello"', + files: [{ path: 'session.txt', status: 'modified', source: 'session' }], + }, + ], + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + component.handleInput('\u001B[C'); // switch to T1 + const rendered = component.render(80).map(strip).join('\n'); + const subtitleIndex = rendered.indexOf('Turn 1 "hello"'); + const tabIndex = rendered.indexOf('Current'); + expect(subtitleIndex).toBeGreaterThan(-1); + expect(tabIndex).toBeGreaterThan(-1); + expect(subtitleIndex).toBeLessThan(tabIndex); + }); + + it('renders the tab label as the subtitle for an unnamed turn source', () => { + const component = new DiffFileSelectorComponent({ + sources: [ + { label: 'Current', files: [{ path: 'git.txt', status: 'modified', source: 'git' }] }, + { label: 'T1', files: [{ path: 'session.txt', status: 'modified', source: 'session' }] }, + ], + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + component.handleInput('\u001B[C'); // switch to T1 + const rendered = component.render(80).map(strip).join('\n'); + expect(rendered).toContain('T1'); + expect(rendered).not.toContain('Uncommitted changes'); + }); + + it('renders a summary line and right-aligns stats or status labels', () => { + const component = new DiffFileSelectorComponent({ + sources: [ + { + label: 'Current', + files: [ + { path: 'modified.ts', status: 'modified', source: 'git', additions: 3, deletions: 2 }, + { path: 'untracked.ts', status: 'untracked', source: 'git' }, + ], + }, + ], + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const rendered = component.render(80).map(strip).join('\n'); + expect(rendered).toContain('2 files changed +3 -2'); + expect(rendered).toContain('+3 -2'); + expect(rendered).toContain('untracked'); + }); + + it('switches source with left/right arrows and updates the file list', () => { + const component = new DiffFileSelectorComponent({ + sources: [ + { label: 'Current', files: [{ path: 'git.txt', status: 'modified', source: 'git' }] }, + { + label: 'T1', + subtitle: 'Turn 1 "hello"', + files: [{ path: 'session.txt', status: 'modified', source: 'session' }], + }, + ], + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const first = component.render(80).map(strip).join('\n'); + expect(first).toContain('Current'); + expect(first).toContain('T1'); + expect(first).toContain('git.txt'); + expect(first).not.toContain('session.txt'); + expect(first).not.toContain('Turn 1 "hello"'); + + component.handleInput('\u001B[C'); // right arrow + const second = component.render(80).map(strip).join('\n'); + expect(second).toContain('session.txt'); + expect(second).not.toContain('git.txt'); + expect(second).toContain('Turn 1 "hello"'); + + component.handleInput('\u001B[D'); // left arrow + const third = component.render(80).map(strip).join('\n'); + expect(third).toContain('git.txt'); + expect(third).not.toContain('session.txt'); + expect(third).not.toContain('Turn 1 "hello"'); + }); + + it('calls onSelect with the file from the active source', () => { + const onSelect = vi.fn(); + const component = new DiffFileSelectorComponent({ + sources: [ + { label: 'Current', files: [{ path: 'git.txt', status: 'modified', source: 'git' }] }, + { + label: 'T1', + files: [{ path: 'session.txt', status: 'modified', source: 'session' }], + }, + ], + onSelect, + onCancel: vi.fn(), + }); + + component.handleInput('\u001B[C'); // switch to T1 + component.handleInput('\r'); // Enter + + expect(onSelect).toHaveBeenCalledWith({ + path: 'session.txt', + status: 'modified', + source: 'session', + }); + }); + + it('calls onCancel on Escape when multiple sources are shown', () => { + const onCancel = vi.fn(); + const component = new DiffFileSelectorComponent({ + sources: [ + { label: 'Current', files: [{ path: 'git.txt', status: 'modified', source: 'git' }] }, + { label: 'T1', files: [{ path: 'session.txt', status: 'modified', source: 'session' }] }, + ], + onSelect: vi.fn(), + onCancel, + }); + + component.handleInput('\u001B'); // Escape + + expect(onCancel).toHaveBeenCalled(); + }); + + it('remembers the cursor position per source when switching tabs', () => { + const component = new DiffFileSelectorComponent({ + sources: [ + { + label: 'Current', + files: [ + { path: 'a.ts', status: 'modified', source: 'git' }, + { path: 'b.ts', status: 'modified', source: 'git' }, + ], + }, + { + label: 'T1', + files: [ + { path: 'x.ts', status: 'modified', source: 'session' }, + { path: 'y.ts', status: 'modified', source: 'session' }, + ], + }, + ], + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + // Move to the second file in Current. + component.handleInput('\u001B[B'); // down + expect(component.getSelectedIndex()).toBe(1); + + // Switch to T1 and move to its second file. + component.handleInput('\u001B[C'); // right + component.handleInput('\u001B[B'); // down + expect(component.getSelectedIndex()).toBe(1); + + // Switch back to Current: cursor should still be on the second file. + component.handleInput('\u001B[D'); // left + expect(component.getSelectedIndex()).toBe(1); + + // Switch back to T1: cursor should still be on its second file. + component.handleInput('\u001B[C'); // right + expect(component.getSelectedIndex()).toBe(1); + }); + }); +}); diff --git a/apps/kimi-code/test/tui/components/dialogs/diff-viewer.test.ts b/apps/kimi-code/test/tui/components/dialogs/diff-viewer.test.ts new file mode 100644 index 0000000000..c51fd62d62 --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/diff-viewer.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { DiffViewerComponent } from '#/tui/components/dialogs/diff-viewer'; +import { currentTheme } from '#/tui/theme'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function strip(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +describe('DiffViewerComponent', () => { + it('renders initial lines and a back footer', () => { + const component = new DiffViewerComponent({ + initialLines: ['line 1', 'line 2'], + onBack: vi.fn(), + }); + + const output = component.render(40).map(strip); + expect(output[0]).toContain('Diff viewer'); + expect(output.some((line) => line.includes('line 1'))).toBe(true); + expect(output.some((line) => line.includes('line 2'))).toBe(true); + expect(output.at(-1)).toContain('Esc to return'); + }); + + it('calls onBack when Escape is pressed', () => { + const onBack = vi.fn(); + const component = new DiffViewerComponent({ onBack }); + + component.handleInput('\u001B'); + + expect(onBack).toHaveBeenCalled(); + }); + + it('toggles expanded context on ctrl+o and updates lines', async () => { + const onToggleExpand = vi.fn().mockResolvedValue(['expanded line']); + const component = new DiffViewerComponent({ + initialLines: ['collapsed line'], + onToggleExpand, + onBack: vi.fn(), + }); + + component.handleInput('\u000F'); // ctrl+o + + await vi.waitFor(() => { + const output = component.render(40).map(strip); + expect(output.some((line) => line.includes('expanded line'))).toBe(true); + }); + expect(onToggleExpand).toHaveBeenCalledWith(true); + }); + + it('requests a render after async expansion finishes', async () => { + const onToggleExpand = vi.fn().mockResolvedValue(['expanded line']); + const requestRender = vi.fn(); + const component = new DiffViewerComponent({ + initialLines: ['collapsed line'], + onToggleExpand, + onBack: vi.fn(), + requestRender, + }); + + component.handleInput('\u000F'); // ctrl+o + + await vi.waitFor(() => { + const output = component.render(40).map(strip); + expect(output.some((line) => line.includes('expanded line'))).toBe(true); + }); + expect(requestRender).toHaveBeenCalled(); + }); + + it('requests a render after an expand failure', async () => { + const onToggleExpand = vi.fn().mockRejectedValue(new Error('boom')); + const requestRender = vi.fn(); + const component = new DiffViewerComponent({ + initialLines: ['collapsed line'], + onToggleExpand, + onBack: vi.fn(), + requestRender, + }); + + component.handleInput('\u000F'); // ctrl+o + + await vi.waitFor(() => { + const output = component.render(40).map(strip); + expect(output.some((line) => line.includes('Failed to load expanded diff'))).toBe(true); + }); + expect(requestRender).toHaveBeenCalled(); + }); + + it('resets toggling state after an expand failure so ctrl+o remains usable', async () => { + const onToggleExpand = vi.fn().mockRejectedValue(new Error('boom')); + const component = new DiffViewerComponent({ + initialLines: ['collapsed line'], + onToggleExpand, + onBack: vi.fn(), + }); + + component.handleInput('\u000F'); // ctrl+o + + await vi.waitFor(() => { + const output = component.render(40).map(strip); + expect(output.some((line) => line.includes('Failed to load expanded diff'))).toBe(true); + }); + + // A second ctrl+o should still be accepted (toggling was reset). + onToggleExpand.mockResolvedValueOnce(['recovered line']); + component.handleInput('\u000F'); + + await vi.waitFor(() => { + const output = component.render(40).map(strip); + expect(output.some((line) => line.includes('recovered line'))).toBe(true); + }); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/diff-panel.test.ts b/apps/kimi-code/test/tui/components/messages/diff-panel.test.ts new file mode 100644 index 0000000000..187f92fc07 --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/diff-panel.test.ts @@ -0,0 +1,120 @@ +import { visibleWidth } from '@moonshot-ai/pi-tui'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { + buildDiffPanelLines, + DiffPanelComponent, +} from '#/tui/components/messages/diff-panel'; +import { currentTheme, darkColors, lightColors } from '#/tui/theme'; + +afterEach(() => { + currentTheme.setPalette(darkColors); +}); + +function strip(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +const SAMPLE_DIFF = `diff --git a/src/foo.ts b/src/foo.ts +index 1234567..abcdefg 100644 +--- a/src/foo.ts ++++ b/src/foo.ts +@@ -1,3 +1,3 @@ + export function foo() { +- return 1; ++ return 2; + } +\\ No newline at end of file`; + +describe('buildDiffPanelLines', () => { + it('returns "No changes." for empty output', () => { + const lines = buildDiffPanelLines(''); + expect(lines).toHaveLength(1); + expect(strip(lines[0] ?? '')).toBe('No changes.'); + }); + + it('returns "No changes." for whitespace-only output', () => { + const lines = buildDiffPanelLines(' \n\n '); + expect(strip(lines[0] ?? '')).toBe('No changes.'); + }); + + it('colorizes diff headers as meta', () => { + const lines = buildDiffPanelLines(SAMPLE_DIFF); + const stripped = lines.map(strip); + expect(stripped[0]).toBe('diff --git a/src/foo.ts b/src/foo.ts'); + expect(stripped[1]).toBe('index 1234567..abcdefg 100644'); + expect(stripped[2]).toBe('--- a/src/foo.ts'); + expect(stripped[3]).toBe('+++ b/src/foo.ts'); + }); + + it('colorizes hunk headers as gutter', () => { + const lines = buildDiffPanelLines(SAMPLE_DIFF); + expect(strip(lines[4] ?? '')).toBe('@@ -1,3 +1,3 @@'); + }); + + it('colorizes added and removed lines', () => { + const lines = buildDiffPanelLines(SAMPLE_DIFF); + const stripped = lines.map(strip); + expect(stripped).toContain('- return 1;'); + expect(stripped).toContain('+ return 2;'); + }); + + it('leaves context lines uncolored', () => { + const lines = buildDiffPanelLines(SAMPLE_DIFF); + const stripped = lines.map(strip); + expect(stripped).toContain(' export function foo() {'); + expect(stripped).toContain(' }'); + }); + + it('colorizes "No newline at end of file" as meta', () => { + const lines = buildDiffPanelLines(SAMPLE_DIFF); + expect(strip(lines.at(-1) ?? '')).toBe('\\ No newline at end of file'); + }); +}); + +describe('DiffPanelComponent', () => { + it('wraps diff lines in a bordered panel', () => { + const component = new DiffPanelComponent(() => buildDiffPanelLines(SAMPLE_DIFF)); + const output = component.render(80).map(strip); + + expect(output[0]).toContain(' Diff '); + expect(output.some((line) => line.includes('diff --git a/src/foo.ts b/src/foo.ts'))).toBe(true); + }); + + it('truncates lines wider than the terminal so the panel never overflows', () => { + const longLine = '+' + 'x'.repeat(200); + const component = new DiffPanelComponent(() => [longLine]); + const width = 60; + + const output = component.render(width); + for (const line of output) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + }); + + it('keeps the bordered panel within narrow terminal widths', () => { + const component = new DiffPanelComponent(() => buildDiffPanelLines(SAMPLE_DIFF)); + + for (const width of [39, 24, 20, 10, 4, 1]) { + for (const line of component.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); + + it('rebuilds its body from the active palette on invalidate', () => { + const component = new DiffPanelComponent(() => [ + `color=${currentTheme.color('diffAdded')}`, + ]); + const bodyOf = (): string => { + const line = component.render(80).map(strip).find((l) => l.includes('color=')); + if (line === undefined) throw new Error('body line not found'); + return line; + }; + + expect(bodyOf()).toContain(darkColors.diffAdded); + currentTheme.setPalette(lightColors); + component.invalidate(); + expect(bodyOf()).toContain(lightColors.diffAdded); + }); +}); diff --git a/apps/kimi-code/test/tui/utils/session-edits.test.ts b/apps/kimi-code/test/tui/utils/session-edits.test.ts new file mode 100644 index 0000000000..06c3031bd0 --- /dev/null +++ b/apps/kimi-code/test/tui/utils/session-edits.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from 'vitest'; + +import { ToolCallComponent } from '#/tui/components/messages/tool-call'; +import { collectSessionEditsByTurn } from '#/tui/utils/session-edits'; + +function baseEntry() { + return { id: '1', kind: 'tool_call' as const, renderMode: 'plain' as const, content: '' }; +} + +function baseToolCall() { + return { id: 'tc-1', name: 'Edit', args: {} }; +} + +function fileIoEdit(path: string, before: string, after: string) { + return { + ...baseEntry(), + toolCallData: { + ...baseToolCall(), + name: 'Edit', + display: { kind: 'file_io' as const, operation: 'edit' as const, path, before, after }, + }, + }; +} + +function fileIoWrite(path: string, content: string) { + return { + ...baseEntry(), + toolCallData: { + ...baseToolCall(), + name: 'Write', + display: { kind: 'file_io' as const, operation: 'write' as const, path, content }, + }, + }; +} + +function diffDisplay(path: string, before: string, after: string) { + return { + ...baseEntry(), + toolCallData: { + ...baseToolCall(), + name: 'SomeTool', + display: { kind: 'diff' as const, path, before, after }, + }, + }; +} + +describe('collectSessionEditsByTurn', () => { + it('groups edits by turnId', () => { + const edits = collectSessionEditsByTurn( + [ + { ...fileIoEdit('a.ts', 'old', 'new'), turnId: 't1' }, + { ...fileIoWrite('b.ts', 'content'), turnId: 't2' }, + { ...fileIoEdit('a.ts', 'x', 'y'), turnId: 't1' }, + ], + [], + ); + expect(edits).toEqual([ + { turnId: 't1', path: 'a.ts', before: 'old', after: 'new' }, + { turnId: 't2', path: 'b.ts', before: '', after: 'content' }, + { turnId: 't1', path: 'a.ts', before: 'x', after: 'y' }, + ]); + }); + + it('falls back to components when entries lack tool_call data', () => { + const component = new ToolCallComponent( + { + id: 'tc-1', + name: 'Edit', + args: {}, + display: { + kind: 'file_io', + operation: 'edit', + path: 'c.ts', + before: 'old', + after: 'new', + }, + turnId: 't3', + }, + undefined, + ); + const edits = collectSessionEditsByTurn([], [component]); + expect(edits).toEqual([{ turnId: 't3', path: 'c.ts', before: 'old', after: 'new' }]); + }); + + it('collects Write tool calls as additions with turnId', () => { + const edits = collectSessionEditsByTurn([ + { ...fileIoWrite('b.ts', 'content'), turnId: 't1' }, + ]); + expect(edits).toEqual([{ turnId: 't1', path: 'b.ts', before: '', after: 'content' }]); + }); + + it('collects diff-style tool displays with turnId', () => { + const edits = collectSessionEditsByTurn([ + { ...diffDisplay('c.ts', 'before', 'after'), turnId: 't2' }, + ]); + expect(edits).toEqual([{ turnId: 't2', path: 'c.ts', before: 'before', after: 'after' }]); + }); + + it('ignores non-file tool calls', () => { + const entries = [ + { + ...baseEntry(), + turnId: 't1', + toolCallData: { + ...baseToolCall(), + name: 'Bash', + display: { kind: 'command' as const, command: 'echo hi' }, + }, + }, + ]; + expect(collectSessionEditsByTurn(entries)).toEqual([]); + }); + + it('skips edit calls where before and after are identical', () => { + const edits = collectSessionEditsByTurn([ + { ...fileIoEdit('a.ts', 'same', 'same'), turnId: 't1' }, + ]); + expect(edits).toEqual([]); + }); + + it('uses undefined turnId when entry has no turnId', () => { + const edits = collectSessionEditsByTurn([fileIoEdit('a.ts', 'old', 'new')]); + expect(edits).toEqual([{ turnId: undefined, path: 'a.ts', before: 'old', after: 'new' }]); + }); + + it('collects edits from both entries and components preserving order', () => { + const component = new ToolCallComponent( + { + id: 'tc-2', + name: 'Write', + args: {}, + display: { + kind: 'file_io', + operation: 'write', + path: 'd.ts', + content: 'from component', + }, + turnId: 't4', + }, + undefined, + ); + const edits = collectSessionEditsByTurn( + [{ ...fileIoEdit('a.ts', 'old', 'new'), turnId: 't1' }], + [component], + ); + expect(edits).toEqual([ + { turnId: 't1', path: 'a.ts', before: 'old', after: 'new' }, + { turnId: 't4', path: 'd.ts', before: '', after: 'from component' }, + ]); + }); + + it('deduplicates edits that appear in both entries and components', () => { + const component = new ToolCallComponent( + { + id: 'tc-1', + name: 'Edit', + args: {}, + display: { + kind: 'file_io', + operation: 'edit', + path: 'a.ts', + before: 'old', + after: 'new', + }, + turnId: 't1', + }, + undefined, + ); + const edits = collectSessionEditsByTurn( + [{ ...fileIoEdit('a.ts', 'old', 'new'), turnId: 't1' }], + [component], + ); + expect(edits).toEqual([{ turnId: 't1', path: 'a.ts', before: 'old', after: 'new' }]); + }); + + it('ignores components that do not look like tool calls', () => { + const edits = collectSessionEditsByTurn([], [{ notAToolCall: true }]); + expect(edits).toEqual([]); + }); +}); diff --git a/apps/kimi-code/test/utils/git/git-diff.test.ts b/apps/kimi-code/test/utils/git/git-diff.test.ts new file mode 100644 index 0000000000..71ff81261f --- /dev/null +++ b/apps/kimi-code/test/utils/git/git-diff.test.ts @@ -0,0 +1,285 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { execFile, spawnSync } from 'node:child_process'; +import { + GitDiffError, + isInsideGitRepo, + listChangedFiles, + runGitDiffForFile, + runGitNumstat, + runUntrackedNumstat, +} from '#/utils/git/git-diff'; + +vi.mock('node:child_process', () => ({ + execFile: vi.fn(), + spawnSync: vi.fn(), +})); + +const mockedExecFile = vi.mocked(execFile); +const mockedSpawnSync = vi.mocked(spawnSync); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('isInsideGitRepo', () => { + it('returns true when git reports inside a work tree', () => { + mockedSpawnSync.mockReturnValue({ status: 0, stdout: 'true\n', stderr: '' } as never); + expect(isInsideGitRepo('/repo')).toBe(true); + expect(mockedSpawnSync).toHaveBeenCalledWith( + 'git', + ['-C', '/repo', 'rev-parse', '--is-inside-work-tree'], + expect.any(Object), + ); + }); + + it('returns false when git exits non-zero', () => { + mockedSpawnSync.mockReturnValue({ status: 128, stdout: '', stderr: 'fatal' } as never); + expect(isInsideGitRepo('/not-repo')).toBe(false); + }); +}); + +describe('listChangedFiles', () => { + it('parses empty status output as no changes', async () => { + mockedExecFile.mockImplementation((_cmd, args, _opts, callback) => { + if (Array.isArray(args) && args.includes('rev-parse')) { + callback?.(null, '\n', ''); + return undefined as never; + } + callback?.(null, '', ''); + return undefined as never; + }); + + const files = await listChangedFiles('/repo'); + + expect(files).toHaveLength(0); + }); + + it('parses modified, added, deleted and untracked files', async () => { + mockedExecFile.mockImplementation((_cmd, args, _opts, callback) => { + if (Array.isArray(args) && args.includes('rev-parse')) { + callback?.(null, '\n', ''); + return undefined as never; + } + callback?.(null, ' M modified.ts\0A added.ts\0 D deleted.ts\0?? untracked.ts\0', ''); + return undefined as never; + }); + + const files = await listChangedFiles('/repo'); + + expect(files).toEqual([ + { path: 'modified.ts', status: 'modified' }, + { path: 'added.ts', status: 'added' }, + { path: 'deleted.ts', status: 'deleted' }, + { path: 'untracked.ts', status: 'untracked' }, + ]); + }); + + it('rejects on git status failure', async () => { + mockedExecFile.mockImplementation((_cmd, args, _opts, callback) => { + if (Array.isArray(args) && args.includes('rev-parse')) { + callback?.(null, '\n', ''); + return undefined as never; + } + callback?.(new Error('exit 128'), '', 'bad'); + return undefined as never; + }); + + await expect(listChangedFiles('/repo')).rejects.toThrow('git status failed'); + }); + + it('strips the repo-root prefix when running from a subdirectory', async () => { + mockedExecFile.mockImplementation((_cmd, args, _opts, callback) => { + if (Array.isArray(args) && args.includes('rev-parse')) { + callback?.(null, 'sub/\n', ''); + return undefined as never; + } + callback?.(null, ' M sub/modified.ts\0?? sub/new.ts\0 M other.ts\0', ''); + return undefined as never; + }); + + const files = await listChangedFiles('/repo/sub'); + + expect(files).toEqual([ + { path: 'modified.ts', status: 'modified' }, + { path: 'new.ts', status: 'untracked' }, + ]); + }); +}); + +describe('runGitDiffForFile', () => { + it('runs git diff HEAD for a modified file', async () => { + mockedExecFile.mockImplementation((_cmd, _args, _opts, callback) => { + callback?.(null, 'diff output', ''); + return undefined as never; + }); + + const result = await runGitDiffForFile('/repo', { + path: 'foo.ts', + status: 'modified', + }); + + expect(result).toBe('diff output'); + expect(mockedExecFile).toHaveBeenCalledWith( + 'git', + ['-C', '/repo', 'diff', 'HEAD', '-U3', '--', 'foo.ts'], + expect.any(Object), + expect.any(Function), + ); + }); + + it('runs git diff --no-index for an untracked file', async () => { + mockedExecFile.mockImplementation((_cmd, _args, _opts, callback) => { + callback?.(Object.assign(new Error('diff'), { code: 1 }), 'untracked diff', ''); + return undefined as never; + }); + + const result = await runGitDiffForFile('/repo', { + path: 'new.ts', + status: 'untracked', + }); + + expect(result).toBe('untracked diff'); + expect(mockedExecFile).toHaveBeenCalledWith( + 'git', + ['-C', '/repo', 'diff', '--no-index', '-U3', '--', '/dev/null', 'new.ts'], + expect.any(Object), + expect.any(Function), + ); + }); + + it('rejects shell metacharacters in file paths', async () => { + await expect( + runGitDiffForFile('/repo', { + path: 'foo;rm -rf /', + status: 'modified', + }), + ).rejects.toBeInstanceOf(GitDiffError); + expect(mockedExecFile).not.toHaveBeenCalled(); + }); + + it('rejects when git diff --no-index exits with a real error', async () => { + mockedExecFile.mockImplementation((_cmd, _args, _opts, callback) => { + callback?.(Object.assign(new Error('permission denied'), { code: 128 }), '', 'bad'); + return undefined as never; + }); + + await expect( + runGitDiffForFile('/repo', { + path: 'x.ts', + status: 'untracked', + }), + ).rejects.toBeInstanceOf(GitDiffError); + }); + + it('returns an empty diff in an empty git repo without HEAD', async () => { + mockedExecFile.mockImplementation((_cmd, _args, _opts, callback) => { + callback?.( + Object.assign(new Error("fatal: 歧义参数 'HEAD': 未知的版本或路径不在工作树中。"), { code: 128 }), + '', + "fatal: ambiguous argument 'HEAD': unknown revision or path not in the working tree.", + ); + return undefined as never; + }); + + const result = await runGitDiffForFile('/repo', { + path: 'a.ts', + status: 'modified', + }); + + expect(result).toBe(''); + }); +}); + +describe('runGitNumstat', () => { + it('strips the repo-root prefix so keys are relative to workDir', async () => { + mockedExecFile.mockImplementation((_cmd, args, _opts, callback) => { + if (Array.isArray(args) && args.includes('rev-parse')) { + callback?.(null, 'sub/\n', ''); + return undefined as never; + } + callback?.(null, '3\t2\tsub/src/foo.ts\n', ''); + return undefined as never; + }); + + const stats = await runGitNumstat('/repo/sub'); + + expect(stats.get('src/foo.ts')).toEqual({ additions: 3, deletions: 2 }); + expect(stats.has('sub/src/foo.ts')).toBe(false); + }); + + it('uses paths as-is when workDir is the repo root', async () => { + mockedExecFile.mockImplementation((_cmd, args, _opts, callback) => { + if (Array.isArray(args) && args.includes('rev-parse')) { + callback?.(null, '\n', ''); + return undefined as never; + } + callback?.(null, '5\t0\tfoo.ts\n', ''); + return undefined as never; + }); + + const stats = await runGitNumstat('/repo'); + + expect(stats.get('foo.ts')).toEqual({ additions: 5, deletions: 0 }); + }); + + it('passes --no-renames so renamed files are reported as separate additions/deletions', async () => { + mockedExecFile.mockImplementation((_cmd, args, _opts, callback) => { + if (Array.isArray(args) && args.includes('rev-parse')) { + callback?.(null, '\n', ''); + return undefined as never; + } + callback?.(null, '0\t5\told.ts\n3\t0\tnew.ts\n', ''); + return undefined as never; + }); + + await runGitNumstat('/repo'); + + expect(mockedExecFile).toHaveBeenCalledWith( + 'git', + ['-C', '/repo', 'diff', 'HEAD', '--no-renames', '--numstat'], + expect.any(Object), + expect.any(Function), + ); + }); + + it('returns empty stats in an empty git repo without HEAD', async () => { + mockedExecFile.mockImplementation((_cmd, args, _opts, callback) => { + if (Array.isArray(args) && args.includes('rev-parse')) { + callback?.(null, '\n', ''); + return undefined as never; + } + callback?.( + Object.assign(new Error("fatal: 歧义参数 'HEAD': 未知的版本或路径不在工作树中。"), { code: 128 }), + '', + "fatal: ambiguous argument 'HEAD': unknown revision or path not in the working tree.", + ); + return undefined as never; + }); + + const stats = await runGitNumstat('/repo'); + + expect(stats.size).toBe(0); + }); +}); + +describe('runUntrackedNumstat', () => { + it('returns additions/deletions for an untracked file', async () => { + mockedExecFile.mockImplementation((_cmd, _args, _opts, callback) => { + callback?.(Object.assign(new Error('diff'), { code: 1 }), '3\t0\t/dev/null => new.ts\n', ''); + return undefined as never; + }); + + const stat = await runUntrackedNumstat('/repo', 'new.ts'); + + expect(stat).toEqual({ additions: 3, deletions: 0 }); + }); + + it('rejects when git exits with a real error', async () => { + mockedExecFile.mockImplementation((_cmd, _args, _opts, callback) => { + callback?.(Object.assign(new Error('permission denied'), { code: 128 }), '', 'bad'); + return undefined as never; + }); + + await expect(runUntrackedNumstat('/repo', 'new.ts')).rejects.toBeInstanceOf(GitDiffError); + }); +}); diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index f74812c0e0..7dea454c48 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -99,6 +99,7 @@ Prompt mode exits with code `0` when the goal completes, `3` when it blocks, and | Command | Alias | Description | Always available | | --- | --- | --- | --- | | `/help` | `/h`, `/?` | Show keyboard shortcuts and all available commands | Yes | +| `/diff` | — | View uncommitted changes and per-turn diffs | Yes | | `/btw [question]` | — | Open a side conversation in a forked sub-Agent without affecting the current main Agent turn; without a question, opens the panel first to wait for input | Yes | | `/usage` | — | Show token usage, context consumption, and quota information | Yes | | `/status` | — | Show the current session runtime state: version, model, working directory, permission mode, etc. | Yes | diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index 2180108350..d55e6871d7 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -97,6 +97,7 @@ Prompt 模式在目标完成时以退出码 `0` 退出,在目标阻塞时以 ` | 命令 | 别名 | 说明 | 随时可用 | | --- | --- | --- | --- | | `/help` | `/h`、`/?` | 显示快捷键和所有可用命令 | 是 | +| `/diff` | — | 查看未提交变更和各轮次 diff | 是 | | `/btw [问题]` | — | 在 fork 出的子 Agent 中打开旁路对话,不改变当前主 Agent 轮次;不带问题时会先打开面板等待输入 | 是 | | `/usage` | — | 显示 token 用量、上下文占用以及配额信息 | 是 | | `/status` | — | 显示当前会话运行时状态:版本、模型、工作目录、权限模式等 | 是 |