mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-19 16:28:28 +00:00
feat(cli): colorize /diff output via a themed Ink component
The /diff stats used to come back as a plain-text MessageActionReturn. Pipes and ACP still get that, but in interactive terminals we now dispatch a structured history item so the numbers can carry theme colors. - packages/cli/src/ui/types.ts — new DiffRenderRow / DiffRenderModel / HistoryItemDiffStats, MessageType.DIFF_STATS. - packages/cli/src/ui/components/messages/DiffStatsDisplay.tsx — renders +N in theme.status.success (green), -M in theme.status.error (red), and the (new) / (binary) / (new, partial) markers in theme.text.secondary (dim). Column alignment matches the plain-text fallback. - packages/cli/src/ui/components/HistoryItemDisplay.tsx — routes the new item type. - packages/cli/src/ui/commands/diffCommand.ts — builds a DiffRenderModel once and fans out: interactive calls context.ui.addItem; other modes fall through to renderDiffModelText() for the plain-text path. Error and "clean tree" branches keep the existing info/error MessageActionReturn in every mode. - Tests: existing diffCommand suite moved to an explicit non_interactive context (it was asserting text content); new interactive suite covers addItem dispatch and model shape; DiffStatsDisplay component tests cover the four row variants and the "…and N more" note.
This commit is contained in:
parent
54a0b2de16
commit
e9a27210d7
9 changed files with 537 additions and 55 deletions
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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)': '(二进制)',
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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<MessageActionReturn> {
|
||||
): Promise<MessageActionReturn | void> {
|
||||
const { config } = context.services;
|
||||
if (!config) {
|
||||
return {
|
||||
|
|
@ -34,7 +44,7 @@ async function diffAction(
|
|||
};
|
||||
}
|
||||
|
||||
let result: Awaited<ReturnType<typeof fetchGitDiff>>;
|
||||
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<HistoryItemDiffStats, 'id'> = {
|
||||
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, PerFileStats>): 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 = {
|
||||
|
|
|
|||
|
|
@ -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<HistoryItemDisplayProps> = ({
|
|||
{itemForDisplay.type === 'stats' && (
|
||||
<StatsDisplay duration={itemForDisplay.duration} width={boxWidth} />
|
||||
)}
|
||||
{itemForDisplay.type === 'diff_stats' && (
|
||||
<DiffStatsDisplay model={itemForDisplay.model} />
|
||||
)}
|
||||
{itemForDisplay.type === 'model_stats' && (
|
||||
<ModelStatsDisplay width={boxWidth} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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(<DiffStatsDisplay model={model} />);
|
||||
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(<DiffStatsDisplay model={model} />);
|
||||
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(<DiffStatsDisplay model={model} />).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(<DiffStatsDisplay model={model} />).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(<DiffStatsDisplay model={model} />).lastFrame() ?? '',
|
||||
);
|
||||
expect(visible).toContain('60 files changed');
|
||||
expect(visible).toMatch(/59 more/);
|
||||
});
|
||||
});
|
||||
130
packages/cli/src/ui/components/messages/DiffStatsDisplay.tsx
Normal file
130
packages/cli/src/ui/components/messages/DiffStatsDisplay.tsx
Normal file
|
|
@ -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<DiffStatsDisplayProps> = ({
|
||||
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 (
|
||||
<Box flexDirection="column">
|
||||
<Text>
|
||||
<Text color={theme.text.primary}>{headerLabel}</Text>
|
||||
<Text color={theme.text.secondary}>, </Text>
|
||||
<Text color={theme.status.success}>+{linesAdded}</Text>
|
||||
<Text color={theme.text.secondary}> / </Text>
|
||||
<Text color={theme.status.error}>-{linesRemoved}</Text>
|
||||
</Text>
|
||||
{rows.map((row, i) => (
|
||||
<DiffRow
|
||||
key={`${i}-${row.filename}`}
|
||||
row={row}
|
||||
addWidth={addWidth}
|
||||
remWidth={remWidth}
|
||||
statColumnWidth={statColumnWidth}
|
||||
/>
|
||||
))}
|
||||
{hiddenCount > 0 && rows.length > 0 && (
|
||||
<Box>
|
||||
<Text color={theme.text.secondary}>
|
||||
{' '}
|
||||
{t('…and {{hidden}} more (showing first {{shown}})', {
|
||||
hidden: String(hiddenCount),
|
||||
shown: String(rows.length),
|
||||
})}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
interface DiffRowProps {
|
||||
row: DiffRenderRow;
|
||||
addWidth: number;
|
||||
remWidth: number;
|
||||
statColumnWidth: number;
|
||||
}
|
||||
|
||||
const DiffRow: React.FC<DiffRowProps> = ({
|
||||
row,
|
||||
addWidth,
|
||||
remWidth,
|
||||
statColumnWidth,
|
||||
}) => {
|
||||
if (row.isBinary) {
|
||||
const marker = padRight('~', statColumnWidth);
|
||||
const suffix = row.isUntracked ? t('(binary, new)') : t('(binary)');
|
||||
return (
|
||||
<Box>
|
||||
<Text>
|
||||
<Text color={theme.text.primary}>{' '}</Text>
|
||||
<Text color={theme.text.secondary}>{marker}</Text>
|
||||
<Text color={theme.text.primary}>{' '}</Text>
|
||||
<Text color={theme.text.primary}>{row.filename}</Text>
|
||||
<Text color={theme.text.secondary}> {suffix}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<Box>
|
||||
<Text>
|
||||
<Text color={theme.text.primary}>{' '}</Text>
|
||||
<Text color={theme.status.success}>+{added}</Text>
|
||||
<Text color={theme.text.primary}> </Text>
|
||||
<Text color={theme.status.error}>-{removed}</Text>
|
||||
<Text color={theme.text.primary}>{' '}</Text>
|
||||
<Text color={theme.text.primary}>{row.filename}</Text>
|
||||
{suffix && <Text color={theme.text.secondary}> {suffix}</Text>}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
function padRight(s: string, width: number): string {
|
||||
return s.length >= width ? s : s + ' '.repeat(width - s.length);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue