diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index bea6dcda3..b38a117ab 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -166,6 +166,8 @@ export default { '{{count}} file changed, +{{added}} / -{{removed}}', '{{count}} files changed, +{{added}} / -{{removed}}': '{{count}} files changed, +{{added}} / -{{removed}}', + '{{count}} file changed': '{{count}} file changed', + '{{count}} files changed': '{{count}} files changed', '…and {{hidden}} more (showing first {{shown}})': '…and {{hidden}} more (showing first {{shown}})', '(binary)': '(binary)', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 86fa75a21..fb2f674e7 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -161,6 +161,8 @@ export default { '{{count}} 个文件变更,+{{added}} / -{{removed}}', '{{count}} files changed, +{{added}} / -{{removed}}': '{{count}} 个文件变更,+{{added}} / -{{removed}}', + '{{count}} file changed': '{{count}} 个文件变更', + '{{count}} files changed': '{{count}} 个文件变更', '…and {{hidden}} more (showing first {{shown}})': '…还有 {{hidden}} 个(仅显示前 {{shown}} 个)', '(binary)': '(二进制)', diff --git a/packages/cli/src/ui/commands/diffCommand.test.ts b/packages/cli/src/ui/commands/diffCommand.test.ts index 45f27ceaf..cdab8c8c0 100644 --- a/packages/cli/src/ui/commands/diffCommand.test.ts +++ b/packages/cli/src/ui/commands/diffCommand.test.ts @@ -22,7 +22,24 @@ vi.mock('@qwen-code/qwen-code-core', async () => { }); function makeContextWithCwd(cwd = '/tmp/repo'): CommandContext { + // Non-interactive by default here because these tests assert on the + // plain-text `MessageActionReturn`; interactive mode dispatches via + // `context.ui.addItem` and is covered in a separate describe block. return createMockCommandContext({ + executionMode: 'non_interactive', + services: { + config: { + getWorkingDir: () => cwd, + getProjectRoot: () => cwd, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + }, + }); +} + +function makeInteractiveContext(cwd = '/tmp/repo'): CommandContext { + return createMockCommandContext({ + executionMode: 'interactive', services: { config: { getWorkingDir: () => cwd, @@ -243,6 +260,86 @@ describe('diffCommand', () => { }); }); +describe('diffCommand interactive mode', () => { + let mockFetchGitDiff: Mock; + + beforeEach(() => { + vi.clearAllMocks(); + mockFetchGitDiff = vi.mocked(fetchGitDiff); + }); + + it('dispatches a diff_stats history item instead of returning text', async () => { + if (!diffCommand.action) throw new Error('Command has no action'); + const ctx = makeInteractiveContext(); + mockFetchGitDiff.mockResolvedValue({ + stats: { filesCount: 2, linesAdded: 7, linesRemoved: 3 }, + perFileStats: new Map([ + ['src/a.ts', { added: 5, removed: 2, isBinary: false }], + ['src/b.ts', { added: 2, removed: 1, isBinary: false }], + ]), + } satisfies GitDiffResult); + + const result = await diffCommand.action(ctx, ''); + expect(result).toBeUndefined(); + expect(ctx.ui.addItem).toHaveBeenCalledTimes(1); + const call = (ctx.ui.addItem as Mock).mock.calls[0][0]; + expect(call.type).toBe('diff_stats'); + expect(call.model).toMatchObject({ + filesCount: 2, + linesAdded: 7, + linesRemoved: 3, + hiddenCount: 0, + }); + expect(call.model.rows).toHaveLength(2); + expect(call.model.rows[0]).toMatchObject({ + filename: 'src/a.ts', + added: 5, + removed: 2, + isBinary: false, + isUntracked: false, + }); + }); + + it('still returns a plain-text info message for the "clean tree" case', async () => { + if (!diffCommand.action) throw new Error('Command has no action'); + const ctx = makeInteractiveContext(); + mockFetchGitDiff.mockResolvedValue({ + stats: { filesCount: 0, linesAdded: 0, linesRemoved: 0 }, + perFileStats: new Map(), + } satisfies GitDiffResult); + + const result = await diffCommand.action(ctx, ''); + expect(result).toMatchObject({ type: 'message', messageType: 'info' }); + expect(ctx.ui.addItem).not.toHaveBeenCalled(); + }); + + it('still returns an error MessageActionReturn when fetchGitDiff throws', async () => { + if (!diffCommand.action) throw new Error('Command has no action'); + const ctx = makeInteractiveContext(); + mockFetchGitDiff.mockRejectedValueOnce(new Error('boom')); + + const result = await diffCommand.action(ctx, ''); + expect(result).toMatchObject({ type: 'message', messageType: 'error' }); + expect(ctx.ui.addItem).not.toHaveBeenCalled(); + }); + + it('propagates hiddenCount to the history item for fast-path results', async () => { + if (!diffCommand.action) throw new Error('Command has no action'); + const ctx = makeInteractiveContext(); + mockFetchGitDiff.mockResolvedValue({ + stats: { filesCount: 60, linesAdded: 100, linesRemoved: 20 }, + perFileStats: new Map([ + ['src/a.ts', { added: 1, removed: 0, isBinary: false }], + ]), + } satisfies GitDiffResult); + + await diffCommand.action(ctx, ''); + const call = (ctx.ui.addItem as Mock).mock.calls[0][0]; + expect(call.model.hiddenCount).toBe(59); + expect(call.model.rows).toHaveLength(1); + }); +}); + describe('diffCommand registration', () => { it('declares all execution modes so it works in non-interactive and ACP', () => { expect(diffCommand.supportedModes).toEqual([ diff --git a/packages/cli/src/ui/commands/diffCommand.ts b/packages/cli/src/ui/commands/diffCommand.ts index 14c2b8671..c7337369a 100644 --- a/packages/cli/src/ui/commands/diffCommand.ts +++ b/packages/cli/src/ui/commands/diffCommand.ts @@ -4,7 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { fetchGitDiff, type PerFileStats } from '@qwen-code/qwen-code-core'; +import { + fetchGitDiff, + type GitDiffResult, + type PerFileStats, +} from '@qwen-code/qwen-code-core'; import { CommandKind, type CommandContext, @@ -12,10 +16,16 @@ import { type SlashCommand, } from './types.js'; import { t } from '../../i18n/index.js'; +import { + MessageType, + type DiffRenderModel, + type DiffRenderRow, + type HistoryItemDiffStats, +} from '../types.js'; async function diffAction( context: CommandContext, -): Promise { +): Promise { const { config } = context.services; if (!config) { return { @@ -34,7 +44,7 @@ async function diffAction( }; } - let result: Awaited>; + let result: GitDiffResult | null; try { result = await fetchGitDiff(cwd); } catch (error) { @@ -57,8 +67,7 @@ async function diffAction( }; } - const { stats, perFileStats } = result; - if (stats.filesCount === 0) { + if (result.stats.filesCount === 0) { return { type: 'message', messageType: 'info', @@ -66,77 +75,133 @@ async function diffAction( }; } - const header = - stats.filesCount === 1 - ? t('{{count}} file changed, +{{added}} / -{{removed}}', { - count: String(stats.filesCount), - added: String(stats.linesAdded), - removed: String(stats.linesRemoved), - }) - : t('{{count}} files changed, +{{added}} / -{{removed}}', { - count: String(stats.filesCount), - added: String(stats.linesAdded), - removed: String(stats.linesRemoved), - }); - const rows = formatPerFile(perFileStats); - const hidden = stats.filesCount - perFileStats.size; - const capNote = - hidden > 0 && perFileStats.size > 0 - ? `\n ${t('…and {{hidden}} more (showing first {{shown}})', { - hidden: String(hidden), - shown: String(perFileStats.size), - })}` - : ''; + const model = buildDiffRenderModel(result); + + // Interactive path: dispatch a structured history item so `DiffStatsDisplay` + // can render with theme colors. Non-interactive / ACP stay on the + // plain-text MessageActionReturn path so pipes, logs, and transports that + // don't speak Ink still see legible output. + if (context.executionMode === 'interactive') { + const item: Omit = { + type: MessageType.DIFF_STATS, + model, + }; + context.ui.addItem(item, Date.now()); + return; + } return { type: 'message', messageType: 'info', - content: - rows.length > 0 ? `${header}\n${rows.join('\n')}${capNote}` : header, + content: renderDiffModelText(model), }; } -function formatPerFile(perFileStats: Map): string[] { - if (perFileStats.size === 0) return []; +/** + * Convert the raw `fetchGitDiff` result into a display-ready structure that + * both the Ink component and the plain-text renderer consume. Order of rows + * mirrors git's numstat output (which uses alphabetical or insertion order). + */ +export function buildDiffRenderModel(result: GitDiffResult): DiffRenderModel { + const rows: DiffRenderRow[] = []; + for (const [filename, s] of result.perFileStats) { + rows.push(toRow(filename, s)); + } + const hiddenCount = Math.max(0, result.stats.filesCount - rows.length); + return { + filesCount: result.stats.filesCount, + linesAdded: result.stats.linesAdded, + linesRemoved: result.stats.linesRemoved, + rows, + hiddenCount, + }; +} +function toRow(filename: string, s: PerFileStats): DiffRenderRow { + if (s.isBinary) { + return { + filename, + isBinary: true, + isUntracked: Boolean(s.isUntracked), + truncated: false, + }; + } + return { + filename, + added: s.added, + removed: s.isUntracked ? 0 : s.removed, + isBinary: false, + isUntracked: Boolean(s.isUntracked), + truncated: Boolean(s.truncated), + }; +} + +/** + * Plain-text rendering of a `DiffRenderModel`. Used in non-interactive / ACP + * modes where no Ink renderer is available, and as the source of truth for + * the text column layout the Ink component mirrors. + */ +export function renderDiffModelText(model: DiffRenderModel): string { + const { filesCount, linesAdded, linesRemoved, rows, hiddenCount } = model; + const header = + filesCount === 1 + ? t('{{count}} file changed, +{{added}} / -{{removed}}', { + count: String(filesCount), + added: String(linesAdded), + removed: String(linesRemoved), + }) + : t('{{count}} files changed, +{{added}} / -{{removed}}', { + count: String(filesCount), + added: String(linesAdded), + removed: String(linesRemoved), + }); + const lines = formatRowsText(rows); + const capNote = + hiddenCount > 0 && rows.length > 0 + ? `\n ${t('…and {{hidden}} more (showing first {{shown}})', { + hidden: String(hiddenCount), + shown: String(rows.length), + })}` + : ''; + return lines.length > 0 ? `${header}\n${lines.join('\n')}${capNote}` : header; +} + +function formatRowsText(rows: DiffRenderRow[]): string[] { + if (rows.length === 0) return []; let maxAdded = 0; let maxRemoved = 0; - for (const s of perFileStats.values()) { - if (s.isBinary) continue; - if (s.added > maxAdded) maxAdded = s.added; - if (s.removed > maxRemoved) maxRemoved = s.removed; + for (const r of rows) { + if (r.isBinary) continue; + if ((r.added ?? 0) > maxAdded) maxAdded = r.added ?? 0; + if ((r.removed ?? 0) > maxRemoved) maxRemoved = r.removed ?? 0; } const addWidth = String(maxAdded).length; const remWidth = String(maxRemoved).length; - // Width of the `+X -Y` stat column so `~` (binary) rows line up with it. const statColumnWidth = 1 + addWidth + 1 + 1 + remWidth; - const rows: string[] = []; - for (const [filename, s] of perFileStats) { - if (s.isBinary) { - const suffix = s.isUntracked + const out: string[] = []; + for (const r of rows) { + if (r.isBinary) { + const suffix = r.isUntracked ? ` ${t('(binary, new)')}` : ` ${t('(binary)')}`; - rows.push(` ${padMarker('~', statColumnWidth)} ${filename}${suffix}`); - } else { - const added = `+${String(s.added).padStart(addWidth)}`; - const removed = `-${String(s.removed).padStart(remWidth)}`; - let suffix = ''; - if (s.isUntracked) { - // `truncated` means we only counted part of a large new file — surface - // that so `+N` isn't read as the exact line count. - suffix = s.truncated ? ` ${t('(new, partial)')}` : ` ${t('(new)')}`; - } - rows.push(` ${added} ${removed} ${filename}${suffix}`); + out.push(` ${padMarker('~', statColumnWidth)} ${r.filename}${suffix}`); + continue; } + const added = `+${String(r.added ?? 0).padStart(addWidth)}`; + const removed = `-${String(r.removed ?? 0).padStart(remWidth)}`; + let suffix = ''; + if (r.isUntracked) { + suffix = r.truncated ? ` ${t('(new, partial)')}` : ` ${t('(new)')}`; + } + out.push(` ${added} ${removed} ${r.filename}${suffix}`); } - return rows; + return out; } function padMarker(marker: string, width: number): string { if (marker.length >= width) return marker; - const pad = ' '.repeat(width - marker.length); - return `${marker}${pad}`; + return `${marker}${' '.repeat(width - marker.length)}`; } export const diffCommand: SlashCommand = { diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index a37544db0..0939038f9 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -51,6 +51,7 @@ import { ArenaAgentCard, ArenaSessionCard } from './arena/ArenaCards.js'; import { InsightProgressMessage } from './messages/InsightProgressMessage.js'; import { BtwMessage } from './messages/BtwMessage.js'; import { MemorySavedMessage } from './messages/MemorySavedMessage.js'; +import { DiffStatsDisplay } from './messages/DiffStatsDisplay.js'; import { useCompactMode } from '../contexts/CompactModeContext.js'; interface HistoryItemDisplayProps { @@ -174,6 +175,9 @@ const HistoryItemDisplayComponent: React.FC = ({ {itemForDisplay.type === 'stats' && ( )} + {itemForDisplay.type === 'diff_stats' && ( + + )} {itemForDisplay.type === 'model_stats' && ( )} diff --git a/packages/cli/src/ui/components/messages/DiffStatsDisplay.test.tsx b/packages/cli/src/ui/components/messages/DiffStatsDisplay.test.tsx new file mode 100644 index 000000000..abf0edc5f --- /dev/null +++ b/packages/cli/src/ui/components/messages/DiffStatsDisplay.test.tsx @@ -0,0 +1,148 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from 'ink-testing-library'; +import { describe, it, expect } from 'vitest'; +import { DiffStatsDisplay } from './DiffStatsDisplay.js'; +import type { DiffRenderModel } from '../../types.js'; + +function stripAnsi(s: string): string { + // eslint-disable-next-line no-control-regex + return s.replace(/\x1b\[[0-9;]*m/g, ''); +} + +describe('DiffStatsDisplay', () => { + it('renders header and per-file rows aligned in columns', () => { + const model: DiffRenderModel = { + filesCount: 2, + linesAdded: 7, + linesRemoved: 3, + hiddenCount: 0, + rows: [ + { + filename: 'src/a.ts', + added: 5, + removed: 2, + isBinary: false, + isUntracked: false, + truncated: false, + }, + { + filename: 'src/b.ts', + added: 2, + removed: 1, + isBinary: false, + isUntracked: false, + truncated: false, + }, + ], + }; + const { lastFrame } = render(); + const visible = stripAnsi(lastFrame() ?? ''); + expect(visible).toContain('2 files changed'); + expect(visible).toContain('+7'); + expect(visible).toContain('-3'); + const aRow = visible.split('\n').find((l) => l.endsWith('src/a.ts'))!; + const bRow = visible.split('\n').find((l) => l.endsWith('src/b.ts'))!; + // Columns align — "src/a.ts" and "src/b.ts" start at the same offset. + expect(aRow.indexOf('src/a.ts')).toBe(bRow.indexOf('src/b.ts')); + }); + + it('renders the (new) marker for untracked text files', () => { + const model: DiffRenderModel = { + filesCount: 1, + linesAdded: 3, + linesRemoved: 0, + hiddenCount: 0, + rows: [ + { + filename: 'notes.md', + added: 3, + removed: 0, + isBinary: false, + isUntracked: true, + truncated: false, + }, + ], + }; + const { lastFrame } = render(); + const visible = stripAnsi(lastFrame() ?? ''); + expect(visible).toContain('notes.md'); + expect(visible).toContain('(new)'); + expect(visible).not.toContain('(new, partial)'); + }); + + it('renders the (new, partial) marker for truncated untracked text files', () => { + const model: DiffRenderModel = { + filesCount: 1, + linesAdded: 10000, + linesRemoved: 0, + hiddenCount: 0, + rows: [ + { + filename: 'big.log', + added: 10000, + removed: 0, + isBinary: false, + isUntracked: true, + truncated: true, + }, + ], + }; + const visible = stripAnsi( + render().lastFrame() ?? '', + ); + expect(visible).toContain('(new, partial)'); + }); + + it('renders binary rows with a ~ marker and no +N/-M', () => { + const model: DiffRenderModel = { + filesCount: 1, + linesAdded: 0, + linesRemoved: 0, + hiddenCount: 0, + rows: [ + { + filename: 'img.png', + isBinary: true, + isUntracked: false, + truncated: false, + }, + ], + }; + const visible = stripAnsi( + render().lastFrame() ?? '', + ); + const rowLine = visible.split('\n').find((l) => l.includes('img.png'))!; + expect(rowLine).toContain('~'); + expect(rowLine).toContain('(binary)'); + expect(rowLine).not.toMatch(/\+\d/); + }); + + it('renders the "…and N more" note when hiddenCount > 0', () => { + const model: DiffRenderModel = { + filesCount: 60, + linesAdded: 100, + linesRemoved: 20, + hiddenCount: 59, + rows: [ + { + filename: 'src/a.ts', + added: 1, + removed: 0, + isBinary: false, + isUntracked: false, + truncated: false, + }, + ], + }; + const visible = stripAnsi( + render().lastFrame() ?? '', + ); + expect(visible).toContain('60 files changed'); + expect(visible).toMatch(/59 more/); + }); +}); diff --git a/packages/cli/src/ui/components/messages/DiffStatsDisplay.tsx b/packages/cli/src/ui/components/messages/DiffStatsDisplay.tsx new file mode 100644 index 000000000..caba4d374 --- /dev/null +++ b/packages/cli/src/ui/components/messages/DiffStatsDisplay.tsx @@ -0,0 +1,130 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../../semantic-colors.js'; +import type { DiffRenderModel, DiffRenderRow } from '../../types.js'; +import { t } from '../../../i18n/index.js'; + +interface DiffStatsDisplayProps { + model: DiffRenderModel; +} + +/** + * Colored rendering of `/diff` output for interactive mode. Mirrors the + * layout of the plain-text fallback (see `renderDiffModelText`) so the two + * modes stay visually aligned, but uses Ink primitives with `theme.status.*` + * tokens instead of baking ANSI into the text. + */ +export const DiffStatsDisplay: React.FC = ({ + model, +}) => { + const { filesCount, linesAdded, linesRemoved, rows, hiddenCount } = model; + + // Reproduce the numeric-column alignment of the text renderer so the + // interactive and non-interactive outputs are visually interchangeable. + let maxAdded = 0; + let maxRemoved = 0; + for (const r of rows) { + if (r.isBinary) continue; + if ((r.added ?? 0) > maxAdded) maxAdded = r.added ?? 0; + if ((r.removed ?? 0) > maxRemoved) maxRemoved = r.removed ?? 0; + } + const addWidth = String(maxAdded).length; + const remWidth = String(maxRemoved).length; + const statColumnWidth = 1 + addWidth + 1 + 1 + remWidth; + + const headerLabel = + filesCount === 1 + ? t('{{count}} file changed', { count: String(filesCount) }) + : t('{{count}} files changed', { count: String(filesCount) }); + + return ( + + + {headerLabel} + , + +{linesAdded} + / + -{linesRemoved} + + {rows.map((row, i) => ( + + ))} + {hiddenCount > 0 && rows.length > 0 && ( + + + {' '} + {t('…and {{hidden}} more (showing first {{shown}})', { + hidden: String(hiddenCount), + shown: String(rows.length), + })} + + + )} + + ); +}; + +interface DiffRowProps { + row: DiffRenderRow; + addWidth: number; + remWidth: number; + statColumnWidth: number; +} + +const DiffRow: React.FC = ({ + row, + addWidth, + remWidth, + statColumnWidth, +}) => { + if (row.isBinary) { + const marker = padRight('~', statColumnWidth); + const suffix = row.isUntracked ? t('(binary, new)') : t('(binary)'); + return ( + + + {' '} + {marker} + {' '} + {row.filename} + {suffix} + + + ); + } + const added = String(row.added ?? 0).padStart(addWidth); + const removed = String(row.removed ?? 0).padStart(remWidth); + let suffix: string | null = null; + if (row.isUntracked) { + suffix = row.truncated ? t('(new, partial)') : t('(new)'); + } + return ( + + + {' '} + +{added} + + -{removed} + {' '} + {row.filename} + {suffix && {suffix}} + + + ); +}; + +function padRight(s: string, width: number): string { + return s.length >= width ? s : s + ' '.repeat(width - s.length); +} diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 775537da9..8bf1fae56 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -175,6 +175,38 @@ export type HistoryItemStats = HistoryItemBase & { duration: string; }; +/** + * Structured payload rendered by `/diff`. Kept as plain data (not React nodes) + * so the same model can feed both the Ink-based interactive display and the + * plain-text non-interactive / ACP output. + */ +export interface DiffRenderRow { + filename: string; + /** `undefined` for binary files; a line count (lower bound if `truncated`) + * otherwise. */ + added?: number; + /** `undefined` for binary and untracked files. */ + removed?: number; + isBinary: boolean; + isUntracked: boolean; + /** Only set for untracked text files that exceeded the read cap. */ + truncated: boolean; +} + +export interface DiffRenderModel { + filesCount: number; + linesAdded: number; + linesRemoved: number; + rows: DiffRenderRow[]; + /** `filesCount - rows.length` when the per-file cap truncated the listing. */ + hiddenCount: number; +} + +export type HistoryItemDiffStats = HistoryItemBase & { + type: 'diff_stats'; + model: DiffRenderModel; +}; + export type HistoryItemModelStats = HistoryItemBase & { type: 'model_stats'; }; @@ -492,7 +524,8 @@ export type HistoryItemWithoutId = | HistoryItemUserPromptSubmitBlocked | HistoryItemStopHookLoop | HistoryItemStopHookSystemMessage - | HistoryItemDoctor; + | HistoryItemDoctor + | HistoryItemDiffStats; export type HistoryItem = HistoryItemWithoutId & { id: number }; @@ -521,6 +554,7 @@ export enum MessageType { ARENA_SESSION_COMPLETE = 'arena_session_complete', INSIGHT_PROGRESS = 'insight_progress', BTW = 'btw', + DIFF_STATS = 'diff_stats', } export interface InsightProgressProps { diff --git a/scripts/unused-keys-only-in-locales.json b/scripts/unused-keys-only-in-locales.json index 48a310882..7fbdb5f98 100644 --- a/scripts/unused-keys-only-in-locales.json +++ b/scripts/unused-keys-only-in-locales.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-04-24T09:04:13.440Z", + "generatedAt": "2026-04-24T09:44:54.528Z", "keys": [ " Models: Qwen latest models\n", " qwen auth qwen-oauth - Authenticate with Qwen OAuth (discontinued)",