Merge branch 'mochi/fix-issue' into feat/image-attachment

This commit is contained in:
LaZzyMan 2026-01-28 10:27:25 +08:00
commit f51ace3a85
108 changed files with 6815 additions and 816 deletions

View file

@ -12,6 +12,7 @@ import { FolderTrustDialog } from './FolderTrustDialog.js';
import { ShellConfirmationDialog } from './ShellConfirmationDialog.js';
import { ConsentPrompt } from './ConsentPrompt.js';
import { SettingInputPrompt } from './SettingInputPrompt.js';
import { PluginChoicePrompt } from './PluginChoicePrompt.js';
import { ThemeDialog } from './ThemeDialog.js';
import { SettingsDialog } from './SettingsDialog.js';
import { QwenOAuthProgress } from './QwenOAuthProgress.js';
@ -147,6 +148,19 @@ export const DialogManager = ({
/>
);
}
if (uiState.pluginChoiceRequests.length > 0) {
const request = uiState.pluginChoiceRequests[0];
return (
<PluginChoicePrompt
key={request.marketplaceName}
marketplaceName={request.marketplaceName}
plugins={request.plugins}
onSelect={request.onSelect}
onCancel={request.onCancel}
terminalWidth={terminalWidth}
/>
);
}
if (uiState.isThemeDialogOpen) {
return (
<Box flexDirection="column">

View file

@ -36,6 +36,11 @@ vi.mock('../utils/clipboardUtils.js');
vi.mock('../contexts/UIStateContext.js', () => ({
useUIState: vi.fn(() => ({ isFeedbackDialogOpen: false })),
}));
vi.mock('../contexts/UIActionsContext.js', () => ({
useUIActions: vi.fn(() => ({
temporaryCloseFeedbackDialog: vi.fn(),
})),
}));
const mockSlashCommands: SlashCommand[] = [
{

View file

@ -37,6 +37,7 @@ import * as path from 'node:path';
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { FEEDBACK_DIALOG_KEYS } from '../FeedbackDialog.js';
/**
@ -119,6 +120,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}) => {
const isShellFocused = useShellFocusState();
const uiState = useUIState();
const uiActions = useUIActions();
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
const [escPressCount, setEscPressCount] = useState(0);
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
@ -367,12 +369,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
// Intercept feedback dialog option keys (1, 2) when dialog is open
if (
uiState.isFeedbackDialogOpen &&
(FEEDBACK_DIALOG_KEYS as readonly string[]).includes(key.name)
) {
return;
// Handle feedback dialog keyboard interactions when dialog is open
if (uiState.isFeedbackDialogOpen) {
// If it's one of the feedback option keys (1-4), let FeedbackDialog handle it
if ((FEEDBACK_DIALOG_KEYS as readonly string[]).includes(key.name)) {
return;
} else {
// For any other key, close feedback dialog temporarily and continue with normal processing
uiActions.temporaryCloseFeedbackDialog();
// Continue processing the key for normal input handling
}
}
// Reset ESC count and hide prompt on any non-ESC key
@ -795,6 +801,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
attachments,
selectedAttachmentIndex,
handleAttachmentDelete,
uiActions,
],
);

View file

@ -0,0 +1,243 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render } from 'ink-testing-library';
import { PluginChoicePrompt } from './PluginChoicePrompt.js';
import { useKeypress } from '../hooks/useKeypress.js';
vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
const mockedUseKeypress = vi.mocked(useKeypress);
describe('PluginChoicePrompt', () => {
const onSelect = vi.fn();
const onCancel = vi.fn();
const terminalWidth = 80;
beforeEach(() => {
vi.clearAllMocks();
});
describe('rendering', () => {
it('renders marketplace name in title', () => {
const { lastFrame } = render(
<PluginChoicePrompt
marketplaceName="test-marketplace"
plugins={[{ name: 'plugin1' }]}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
expect(lastFrame()).toContain('test-marketplace');
});
it('renders plugin names', () => {
const { lastFrame } = render(
<PluginChoicePrompt
marketplaceName="test"
plugins={[
{ name: 'plugin1', description: 'First plugin' },
{ name: 'plugin2', description: 'Second plugin' },
]}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
expect(lastFrame()).toContain('plugin1');
expect(lastFrame()).toContain('plugin2');
});
it('renders description for selected plugin only', () => {
const { lastFrame } = render(
<PluginChoicePrompt
marketplaceName="test"
plugins={[
{ name: 'plugin1', description: 'First plugin description' },
{ name: 'plugin2', description: 'Second plugin description' },
]}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
// First plugin is selected by default, should show its description
expect(lastFrame()).toContain('First plugin description');
});
it('renders help text', () => {
const { lastFrame } = render(
<PluginChoicePrompt
marketplaceName="test"
plugins={[{ name: 'plugin1' }]}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
expect(lastFrame()).toContain('↑↓');
expect(lastFrame()).toContain('Enter');
expect(lastFrame()).toContain('Escape');
});
});
describe('scrolling behavior', () => {
it('does not show scroll indicators for small lists', () => {
const { lastFrame } = render(
<PluginChoicePrompt
marketplaceName="test"
plugins={[
{ name: 'plugin1' },
{ name: 'plugin2' },
{ name: 'plugin3' },
]}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
expect(lastFrame()).not.toContain('more above');
expect(lastFrame()).not.toContain('more below');
});
it('shows "more below" indicator for long lists', () => {
const plugins = Array.from({ length: 15 }, (_, i) => ({
name: `plugin${i + 1}`,
}));
const { lastFrame } = render(
<PluginChoicePrompt
marketplaceName="test"
plugins={plugins}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
// At the beginning, should show "more below" but not "more above"
expect(lastFrame()).not.toContain('more above');
expect(lastFrame()).toContain('more below');
});
it('shows progress indicator for long lists', () => {
const plugins = Array.from({ length: 15 }, (_, i) => ({
name: `plugin${i + 1}`,
}));
const { lastFrame } = render(
<PluginChoicePrompt
marketplaceName="test"
plugins={plugins}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
// Should show progress like "(1/15)"
expect(lastFrame()).toContain('(1/15)');
});
});
describe('keyboard navigation', () => {
it('registers keypress handler', () => {
render(
<PluginChoicePrompt
marketplaceName="test"
plugins={[{ name: 'plugin1' }]}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
expect(mockedUseKeypress).toHaveBeenCalledWith(expect.any(Function), {
isActive: true,
});
});
it('calls onCancel when escape is pressed', () => {
render(
<PluginChoicePrompt
marketplaceName="test"
plugins={[{ name: 'plugin1' }]}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({ name: 'escape', sequence: '\x1b' } as never);
expect(onCancel).toHaveBeenCalled();
});
it('calls onSelect with plugin name when enter is pressed', () => {
render(
<PluginChoicePrompt
marketplaceName="test"
plugins={[{ name: 'test-plugin' }]}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({ name: 'return', sequence: '\r' } as never);
expect(onSelect).toHaveBeenCalledWith('test-plugin');
});
it('calls onSelect with correct plugin when number key 1-9 is pressed', () => {
render(
<PluginChoicePrompt
marketplaceName="test"
plugins={[
{ name: 'plugin1' },
{ name: 'plugin2' },
{ name: 'plugin3' },
]}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({ name: '2', sequence: '2' } as never);
expect(onSelect).toHaveBeenCalledWith('plugin2');
});
});
describe('selection indicator', () => {
it('shows selection indicator for first plugin by default', () => {
const { lastFrame } = render(
<PluginChoicePrompt
marketplaceName="test"
plugins={[{ name: 'plugin1' }, { name: 'plugin2' }]}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
expect(lastFrame()).toContain('');
});
});
});

View file

@ -0,0 +1,195 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { useState, useCallback, useMemo } from 'react';
import { theme } from '../semantic-colors.js';
import { t } from '../../i18n/index.js';
import { useKeypress, type Key } from '../hooks/useKeypress.js';
interface PluginChoice {
name: string;
description?: string;
}
type PluginChoicePromptProps = {
marketplaceName: string;
plugins: PluginChoice[];
onSelect: (pluginName: string) => void;
onCancel: () => void;
terminalWidth: number;
};
// Maximum number of visible items in the list
const MAX_VISIBLE_ITEMS = 8;
export const PluginChoicePrompt = (props: PluginChoicePromptProps) => {
const { marketplaceName, plugins, onSelect, onCancel } = props;
const [selectedIndex, setSelectedIndex] = useState(0);
const prefixWidth = 2; // " " or " "
const handleKeypress = useCallback(
(key: Key) => {
const { name, sequence } = key;
if (name === 'escape') {
onCancel();
return;
}
if (name === 'return') {
const plugin = plugins[selectedIndex];
if (plugin) {
onSelect(plugin.name);
}
return;
}
// Navigate up
if (name === 'up' || sequence === 'k') {
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : plugins.length - 1));
return;
}
// Navigate down
if (name === 'down' || sequence === 'j') {
setSelectedIndex((prev) => (prev < plugins.length - 1 ? prev + 1 : 0));
return;
}
// Number shortcuts (1-9)
const num = parseInt(sequence || '', 10);
if (!isNaN(num) && num >= 1 && num <= plugins.length && num <= 9) {
setSelectedIndex(num - 1);
const plugin = plugins[num - 1];
if (plugin) {
onSelect(plugin.name);
}
}
},
[plugins, selectedIndex, onSelect, onCancel],
);
useKeypress(handleKeypress, { isActive: true });
// Calculate visible range for scrolling
const { visiblePlugins, startIndex, hasMore, hasLess } = useMemo(() => {
const total = plugins.length;
if (total <= MAX_VISIBLE_ITEMS) {
return {
visiblePlugins: plugins,
startIndex: 0,
hasMore: false,
hasLess: false,
};
}
// Calculate window position to keep selected item visible
let start = 0;
const halfWindow = Math.floor(MAX_VISIBLE_ITEMS / 2);
if (selectedIndex <= halfWindow) {
// Near the beginning
start = 0;
} else if (selectedIndex >= total - halfWindow) {
// Near the end
start = total - MAX_VISIBLE_ITEMS;
} else {
// In the middle - center on selected
start = selectedIndex - halfWindow;
}
const end = Math.min(start + MAX_VISIBLE_ITEMS, total);
return {
visiblePlugins: plugins.slice(start, end),
startIndex: start,
hasLess: start > 0,
hasMore: end < total,
};
}, [plugins, selectedIndex]);
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
paddingY={1}
paddingX={2}
width="100%"
>
<Text bold color={theme.text.accent}>
{t('Select a plugin from "{{name}}"', { name: marketplaceName })}
</Text>
<Box marginTop={1} flexDirection="column">
{/* Show "more items above" indicator */}
{hasLess && (
<Box>
<Text dimColor>
{' '}
{t('{{count}} more above', { count: String(startIndex) })}
</Text>
</Box>
)}
{visiblePlugins.map((plugin, visibleIndex) => {
const actualIndex = startIndex + visibleIndex;
const isSelected = actualIndex === selectedIndex;
const prefix = isSelected ? ' ' : ' ';
return (
<Box key={plugin.name} flexDirection="column">
<Box flexDirection="row">
<Text color={isSelected ? theme.text.accent : undefined}>
{prefix}
</Text>
<Text
bold={isSelected}
color={isSelected ? theme.text.accent : undefined}
>
{plugin.name}
</Text>
</Box>
{/* Show full description only for selected item */}
{isSelected && plugin.description && (
<Box marginLeft={prefixWidth}>
<Text color={theme.text.accent}>{plugin.description}</Text>
</Box>
)}
</Box>
);
})}
{/* Show "more items below" indicator */}
{hasMore && (
<Box>
<Text dimColor>
{' '}
{' '}
{t('{{count}} more below', {
count: String(plugins.length - startIndex - MAX_VISIBLE_ITEMS),
})}
</Text>
</Box>
)}
</Box>
<Box marginTop={1} flexDirection="row" gap={2}>
<Text dimColor>
{t('Use ↑↓ or j/k to navigate, Enter to select, Escape to cancel')}
</Text>
{plugins.length > MAX_VISIBLE_ITEMS && (
<Text dimColor>
({selectedIndex + 1}/{plugins.length})
</Text>
)}
</Box>
</Box>
);
};

View file

@ -1368,7 +1368,7 @@ describe('SettingsDialog', () => {
enabled: true,
},
context: {
loadMemoryFromIncludeDirectories: true,
loadFromIncludeDirectories: true,
fileFiltering: {
respectGitIgnore: true,
respectQwenIgnore: true,
@ -1540,7 +1540,7 @@ describe('SettingsDialog', () => {
enableRecursiveFileSearch: false,
disableFuzzySearch: true,
},
loadMemoryFromIncludeDirectories: true,
loadFromIncludeDirectories: true,
},
});
const onSelect = vi.fn();
@ -1605,7 +1605,7 @@ describe('SettingsDialog', () => {
enabled: false,
},
context: {
loadMemoryFromIncludeDirectories: false,
loadFromIncludeDirectories: false,
fileFiltering: {
respectGitIgnore: false,
respectQwenIgnore: false,

View file

@ -260,6 +260,7 @@ def fibonacci(n):
availableTerminalHeight={diffHeight}
contentWidth={colorizeCodeWidth}
theme={previewTheme}
settings={settings}
/>
</Box>
);

View file

@ -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('<OverflowProvider><DiffRenderer /></OverflowProvider>', () => {
const mockColorizeCode = vi.spyOn(CodeColorizer, 'colorizeCode');
@ -17,8 +26,8 @@ describe('<OverflowProvider><DiffRenderer /></OverflowProvider>', () => {
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}
/>
</OverflowProvider>,
);
@ -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}
/>
</OverflowProvider>,
);
@ -73,6 +85,7 @@ index 0000000..e69de29
undefined,
80,
undefined,
mockSettings,
);
});
@ -88,7 +101,11 @@ index 0000000..e69de29
`;
render(
<OverflowProvider>
<DiffRenderer diffContent={newFileDiffContent} contentWidth={80} />
<DiffRenderer
diffContent={newFileDiffContent}
contentWidth={80}
settings={mockSettings}
/>
</OverflowProvider>,
);
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}
/>
</OverflowProvider>,
);
@ -146,6 +165,7 @@ index 1234567..1234567 100644
diffContent={noChangeDiff}
filename="file.txt"
contentWidth={80}
settings={mockSettings}
/>
</OverflowProvider>,
);
@ -156,7 +176,11 @@ index 1234567..1234567 100644
it('should handle empty diff content', () => {
const { lastFrame } = render(
<OverflowProvider>
<DiffRenderer diffContent="" contentWidth={80} />
<DiffRenderer
diffContent=""
contentWidth={80}
settings={mockSettings}
/>
</OverflowProvider>,
);
expect(lastFrame()).toContain('No diff content');
@ -183,6 +207,7 @@ index 123..456 100644
diffContent={diffWithGap}
filename="file.txt"
contentWidth={80}
settings={mockSettings}
/>
</OverflowProvider>,
);
@ -220,6 +245,7 @@ index abc..def 100644
diffContent={diffWithSmallGap}
filename="file.txt"
contentWidth={80}
settings={mockSettings}
/>
</OverflowProvider>,
);
@ -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(
<OverflowProvider>
<DiffRenderer
diffContent={diffWithMultipleHunks}
filename="multi.js"
contentWidth={terminalWidth}
contentWidth={contentWidth}
availableTerminalHeight={height}
settings={mockSettings}
/>
</OverflowProvider>,
);
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}
/>
</OverflowProvider>,
);
@ -354,6 +382,7 @@ fileDiff Index: Dockerfile
diffContent={newFileDiff}
filename="Dockerfile"
contentWidth={80}
settings={mockSettings}
/>
</OverflowProvider>,
);
@ -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(
<OverflowProvider>
<DiffRenderer
diffContent={diffContent}
filename="test.txt"
contentWidth={80}
/>
</OverflowProvider>,
);
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(
<OverflowProvider>
<DiffRenderer
diffContent={diffContent}
filename="test.txt"
contentWidth={80}
settings={mockSettings}
/>
</OverflowProvider>,
);
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(
<OverflowProvider>
<DiffRenderer
diffContent={diffContent}
filename="test.txt"
contentWidth={80}
settings={mockSettings}
/>
</OverflowProvider>,
);
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');
});
});
});

View file

@ -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<DiffRendererProps> = ({
availableTerminalHeight,
contentWidth,
theme,
settings,
}) => {
const screenReaderEnabled = useIsScreenReaderEnabled();
if (!diffContent || typeof diffContent !== 'string') {
@ -157,6 +160,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
availableTerminalHeight,
contentWidth,
theme,
settings,
);
} else {
renderedOutput = renderDiffContent(
@ -165,6 +169,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
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(
<Box key={lineKey} flexDirection="row">
<Text
color={semanticTheme.text.secondary}
backgroundColor={
line.type === 'add'
? semanticTheme.background.diff.added
: line.type === 'del'
? semanticTheme.background.diff.removed
: undefined
}
>
{gutterNumStr.padStart(gutterWidth)}{' '}
</Text>
{showLineNumbers && (
<Text
color={semanticTheme.text.secondary}
backgroundColor={
line.type === 'add'
? semanticTheme.background.diff.added
: line.type === 'del'
? semanticTheme.background.diff.removed
: undefined
}
>
{gutterNumStr.padStart(gutterWidth)}{' '}
</Text>
)}
{line.type === 'context' ? (
<>
<Text>{prefixSymbol} </Text>

View file

@ -226,6 +226,7 @@ export const ToolConfirmationMessage: React.FC<
filename={confirmationDetails.fileName}
availableTerminalHeight={availableBodyContentHeight()}
contentWidth={contentWidth}
settings={settings}
/>
);
} else if (confirmationDetails.type === 'exec') {

View file

@ -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 <Text>MockDiff:{diffContent}</Text>;
return (
<Text>
MockDiff:{diffContent}
{settings ? ':withSettings' : ''}
</Text>
);
},
}));
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(
<StreamingContext.Provider value={contextValue}>
{ui}
</StreamingContext.Provider>,
<SettingsContext.Provider value={mockSettings}>
<StreamingContext.Provider value={contextValue}>
{ui}
</StreamingContext.Provider>
</SettingsContext.Provider>,
);
};

View file

@ -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 }) => (
<DiffRenderer
diffContent={data.fileDiff}
filename={data.fileName}
availableTerminalHeight={availableHeight}
contentWidth={childWidth}
settings={settings}
/>
);
@ -243,6 +247,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
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<ToolMessageProps> = ({
data={displayRenderer.data}
availableHeight={availableHeight}
childWidth={innerWidth}
settings={settings}
/>
)}
{displayRenderer.type === 'ansi' && (