diff --git a/packages/cli/src/ui/components/ThemeDialog.tsx b/packages/cli/src/ui/components/ThemeDialog.tsx index 3f93c84d7..a2ade610b 100644 --- a/packages/cli/src/ui/components/ThemeDialog.tsx +++ b/packages/cli/src/ui/components/ThemeDialog.tsx @@ -260,6 +260,7 @@ def fibonacci(n): availableTerminalHeight={diffHeight} contentWidth={colorizeCodeWidth} theme={previewTheme} + settings={settings} /> ); diff --git a/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx b/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx index 6cc0fe61f..a725f5e64 100644 --- a/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx +++ b/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx @@ -9,6 +9,15 @@ import { render } from 'ink-testing-library'; import { DiffRenderer } from './DiffRenderer.js'; import * as CodeColorizer from '../../utils/CodeColorizer.js'; import { vi } from 'vitest'; +import type { LoadedSettings } from '../../../config/settings.js'; + +const mockSettings: LoadedSettings = { + merged: { + ui: { + showLineNumbers: true, + }, + }, +} as LoadedSettings; describe('', () => { const mockColorizeCode = vi.spyOn(CodeColorizer, 'colorizeCode'); @@ -17,8 +26,8 @@ describe('', () => { mockColorizeCode.mockClear(); }); - const sanitizeOutput = (output: string | undefined, terminalWidth: number) => - output?.replace(/GAP_INDICATOR/g, '═'.repeat(terminalWidth)); + const sanitizeOutput = (output: string | undefined, contentWidth: number) => + output?.replace(/GAP_INDICATOR/g, '═'.repeat(contentWidth)); it('should call colorizeCode with correct language for new file with known extension', () => { const newFileDiffContent = ` @@ -36,6 +45,7 @@ index 0000000..e69de29 diffContent={newFileDiffContent} filename="test.py" contentWidth={80} + settings={mockSettings} /> , ); @@ -45,6 +55,7 @@ index 0000000..e69de29 undefined, 80, undefined, + mockSettings, ); }); @@ -64,6 +75,7 @@ index 0000000..e69de29 diffContent={newFileDiffContent} filename="test.unknown" contentWidth={80} + settings={mockSettings} /> , ); @@ -73,6 +85,7 @@ index 0000000..e69de29 undefined, 80, undefined, + mockSettings, ); }); @@ -88,7 +101,11 @@ index 0000000..e69de29 `; render( - + , ); expect(mockColorizeCode).toHaveBeenCalledWith( @@ -97,6 +114,7 @@ index 0000000..e69de29 undefined, 80, undefined, + mockSettings, ); }); @@ -116,6 +134,7 @@ index 0000001..0000002 100644 diffContent={existingFileDiffContent} filename="test.txt" contentWidth={80} + settings={mockSettings} /> , ); @@ -146,6 +165,7 @@ index 1234567..1234567 100644 diffContent={noChangeDiff} filename="file.txt" contentWidth={80} + settings={mockSettings} /> , ); @@ -156,7 +176,11 @@ index 1234567..1234567 100644 it('should handle empty diff content', () => { const { lastFrame } = render( - + , ); expect(lastFrame()).toContain('No diff content'); @@ -183,6 +207,7 @@ index 123..456 100644 diffContent={diffWithGap} filename="file.txt" contentWidth={80} + settings={mockSettings} /> , ); @@ -220,6 +245,7 @@ index abc..def 100644 diffContent={diffWithSmallGap} filename="file.txt" contentWidth={80} + settings={mockSettings} /> , ); @@ -251,7 +277,7 @@ index 123..789 100644 it.each([ { - terminalWidth: 80, + contentWidth: 80, height: undefined, expected: ` 1 console.log('first hunk'); 2 - const oldVar = 1; @@ -264,7 +290,7 @@ index 123..789 100644 22 console.log('end of second hunk');`, }, { - terminalWidth: 80, + contentWidth: 80, height: 6, expected: `... first 4 lines hidden ... ════════════════════════════════════════════════════════════════════════════════ @@ -274,7 +300,7 @@ index 123..789 100644 22 console.log('end of second hunk');`, }, { - terminalWidth: 30, + contentWidth: 30, height: 6, expected: `... first 10 lines hidden ... ; @@ -284,20 +310,21 @@ index 123..789 100644 second hunk');`, }, ])( - 'with terminalWidth $terminalWidth and height $height', - ({ terminalWidth, height, expected }) => { + 'with contentWidth $contentWidth and height $height', + ({ contentWidth, height, expected }) => { const { lastFrame } = render( , ); const output = lastFrame(); - expect(sanitizeOutput(output, terminalWidth)).toEqual(expected); + expect(sanitizeOutput(output, contentWidth)).toEqual(expected); }, ); }); @@ -324,6 +351,7 @@ fileDiff Index: file.txt diffContent={newFileDiff} filename="TEST" contentWidth={80} + settings={mockSettings} /> , ); @@ -354,6 +382,7 @@ fileDiff Index: Dockerfile diffContent={newFileDiff} filename="Dockerfile" contentWidth={80} + settings={mockSettings} /> , ); @@ -362,4 +391,86 @@ fileDiff Index: Dockerfile 2 RUN npm install 3 RUN npm run build`); }); + + describe('showLineNumbers setting', () => { + const diffContent = ` +diff --git a/test.txt b/test.txt +index 0000001..0000002 100644 +--- a/test.txt ++++ b/test.txt +@@ -1,2 +1,2 @@ +-old line 1 ++new line 1 + context line 2 +`; + + it('should show line numbers by default when settings is undefined', () => { + const { lastFrame } = render( + + + , + ); + const output = lastFrame(); + expect(output).toContain('1 -'); + expect(output).toContain('1 +'); + expect(output).toContain('2 '); + }); + + it('should show line numbers when showLineNumbers is true', () => { + const mockSettings = { + merged: { + ui: { + showLineNumbers: true, + }, + }, + } as unknown as LoadedSettings; + + const { lastFrame } = render( + + + , + ); + const output = lastFrame(); + expect(output).toContain('1 -'); + expect(output).toContain('1 +'); + expect(output).toContain('2 '); + }); + + it('should hide line numbers when showLineNumbers is false', () => { + const mockSettings = { + merged: { + ui: { + showLineNumbers: false, + }, + }, + } as unknown as LoadedSettings; + + const { lastFrame } = render( + + + , + ); + const output = lastFrame(); + // Line numbers should not be present + expect(output).not.toMatch(/^\s*\d+\s*[-+]/m); + // But the content should still be there + expect(output).toContain('old line 1'); + expect(output).toContain('new line 1'); + expect(output).toContain('context line 2'); + }); + }); }); diff --git a/packages/cli/src/ui/components/messages/DiffRenderer.tsx b/packages/cli/src/ui/components/messages/DiffRenderer.tsx index 444bf8048..3670be34b 100644 --- a/packages/cli/src/ui/components/messages/DiffRenderer.tsx +++ b/packages/cli/src/ui/components/messages/DiffRenderer.tsx @@ -11,6 +11,7 @@ import { colorizeCode, colorizeLine } from '../../utils/CodeColorizer.js'; import { MaxSizedBox } from '../shared/MaxSizedBox.js'; import { theme as semanticTheme } from '../../semantic-colors.js'; import type { Theme } from '../../themes/theme.js'; +import type { LoadedSettings } from '../../../config/settings.js'; interface DiffLine { type: 'add' | 'del' | 'context' | 'hunk' | 'other'; @@ -86,6 +87,7 @@ interface DiffRendererProps { availableTerminalHeight?: number; contentWidth: number; theme?: Theme; + settings?: LoadedSettings; } const DEFAULT_TAB_WIDTH = 4; // Spaces per tab for normalization @@ -97,6 +99,7 @@ export const DiffRenderer: React.FC = ({ availableTerminalHeight, contentWidth, theme, + settings, }) => { const screenReaderEnabled = useIsScreenReaderEnabled(); if (!diffContent || typeof diffContent !== 'string') { @@ -157,6 +160,7 @@ export const DiffRenderer: React.FC = ({ availableTerminalHeight, contentWidth, theme, + settings, ); } else { renderedOutput = renderDiffContent( @@ -165,6 +169,7 @@ export const DiffRenderer: React.FC = ({ tabWidth, availableTerminalHeight, contentWidth, + settings, ); } @@ -177,6 +182,7 @@ const renderDiffContent = ( tabWidth = DEFAULT_TAB_WIDTH, availableTerminalHeight: number | undefined, contentWidth: number, + settings?: LoadedSettings, ) => { // 1. Normalize whitespace (replace tabs with spaces) *before* further processing const normalizedLines = parsedLines.map((line) => ({ @@ -201,6 +207,8 @@ const renderDiffContent = ( ); } + const showLineNumbers = settings?.merged.ui?.showLineNumbers ?? true; + const maxLineNumber = Math.max( 0, ...displayableLines.map((l) => l.oldLine ?? 0), @@ -299,18 +307,20 @@ const renderDiffContent = ( acc.push( - - {gutterNumStr.padStart(gutterWidth)}{' '} - + {showLineNumbers && ( + + {gutterNumStr.padStart(gutterWidth)}{' '} + + )} {line.type === 'context' ? ( <> {prefixSymbol} diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index d8ded72a6..7bfe9a962 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -226,6 +226,7 @@ export const ToolConfirmationMessage: React.FC< filename={confirmationDetails.fileName} availableTerminalHeight={availableBodyContentHeight()} contentWidth={contentWidth} + settings={settings} /> ); } else if (confirmationDetails.type === 'exec') { diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx index 3e2aeb585..0c44a8ed9 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx @@ -11,11 +11,13 @@ import { ToolMessage } from './ToolMessage.js'; import { StreamingState, ToolCallStatus } from '../../types.js'; import { Text } from 'ink'; import { StreamingContext } from '../../contexts/StreamingContext.js'; +import { SettingsContext } from '../../contexts/SettingsContext.js'; import type { AnsiOutput, AnsiOutputDisplay, Config, } from '@qwen-code/qwen-code-core'; +import type { LoadedSettings } from '../../../config/settings.js'; vi.mock('../TerminalOutput.js', () => ({ TerminalOutput: function MockTerminalOutput({ @@ -58,10 +60,17 @@ vi.mock('../GeminiRespondingSpinner.js', () => ({ vi.mock('./DiffRenderer.js', () => ({ DiffRenderer: function MockDiffRenderer({ diffContent, + settings, }: { diffContent: string; + settings?: unknown; }) { - return MockDiff:{diffContent}; + return ( + + MockDiff:{diffContent} + {settings ? ':withSettings' : ''} + + ); }, })); vi.mock('../../utils/MarkdownDisplay.js', () => ({ @@ -83,6 +92,15 @@ vi.mock('../subagents/index.js', () => ({ }, })); +// Mock settings +const mockSettings: LoadedSettings = { + merged: { + ui: { + showLineNumbers: true, + }, + }, +} as LoadedSettings; + // Helper to render with context const renderWithContext = ( ui: React.ReactElement, @@ -90,9 +108,11 @@ const renderWithContext = ( ) => { const contextValue: StreamingState = streamingState; return render( - - {ui} - , + + + {ui} + + , ); }; diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index 40232387d..afc16317c 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -30,6 +30,8 @@ import { TOOL_STATUS, } from '../../constants.js'; import { theme } from '../../semantic-colors.js'; +import { useSettings } from '../../contexts/SettingsContext.js'; +import type { LoadedSettings } from '../../../config/settings.js'; const STATIC_HEIGHT = 1; const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc. @@ -210,12 +212,14 @@ const DiffResultRenderer: React.FC<{ data: { fileDiff: string; fileName: string }; availableHeight?: number; childWidth: number; -}> = ({ data, availableHeight, childWidth }) => ( + settings?: LoadedSettings; +}> = ({ data, availableHeight, childWidth, settings }) => ( ); @@ -243,6 +247,7 @@ export const ToolMessage: React.FC = ({ ptyId, config, }) => { + const settings = useSettings(); const isThisShellFocused = (name === SHELL_COMMAND_NAME || name === 'Shell') && status === ToolCallStatus.Executing && @@ -348,6 +353,7 @@ export const ToolMessage: React.FC = ({ data={displayRenderer.data} availableHeight={availableHeight} childWidth={innerWidth} + settings={settings} /> )} {displayRenderer.type === 'ansi' && (