mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 22:21:22 -07:00
Fix syntax highlighting and rendering issues. (#7759)
Co-authored-by: Miguel Solorio <miguelsolorio@google.com>
This commit is contained in:
@@ -1311,6 +1311,33 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiline rendering', () => {
|
||||
it('should correctly render multiline input including blank lines', async () => {
|
||||
const text = 'hello\n\nworld';
|
||||
mockBuffer.text = text;
|
||||
mockBuffer.lines = text.split('\n');
|
||||
mockBuffer.viewportVisualLines = text.split('\n');
|
||||
mockBuffer.allVisualLines = text.split('\n');
|
||||
mockBuffer.visualCursor = [2, 5]; // cursor at the end of "world"
|
||||
|
||||
const { stdout, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
);
|
||||
await wait();
|
||||
|
||||
const frame = stdout.lastFrame();
|
||||
// Check that all lines, including the empty one, are rendered.
|
||||
// This implicitly tests that the Box wrapper provides height for the empty line.
|
||||
expect(frame).toContain('hello');
|
||||
expect(frame).toContain(`world${chalk.inverse(' ')}`);
|
||||
|
||||
const outputLines = frame!.split('\n');
|
||||
// The number of lines should be 2 for the border plus 3 for the content.
|
||||
expect(outputLines.length).toBe(5);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiline paste', () => {
|
||||
it.each([
|
||||
{
|
||||
|
||||
@@ -723,7 +723,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
) : (
|
||||
linesToRender
|
||||
.map((lineText, visualIdxInRenderedSet) => {
|
||||
const tokens = parseInputForHighlighting(lineText);
|
||||
const tokens = parseInputForHighlighting(
|
||||
lineText,
|
||||
visualIdxInRenderedSet,
|
||||
);
|
||||
const cursorVisualRow =
|
||||
cursorVisualRowAbsolute - scrollVisualRow;
|
||||
const isOnCursorLine =
|
||||
@@ -784,7 +787,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
) {
|
||||
if (!currentLineGhost) {
|
||||
renderedLine.push(
|
||||
<Text key="cursor-end">{chalk.inverse(' ')}</Text>,
|
||||
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
|
||||
{chalk.inverse(' ')}
|
||||
</Text>,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -796,15 +801,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
currentLineGhost;
|
||||
|
||||
return (
|
||||
<Text key={`line-${visualIdxInRenderedSet}`}>
|
||||
{renderedLine}
|
||||
{showCursorBeforeGhost && chalk.inverse(' ')}
|
||||
{currentLineGhost && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{currentLineGhost}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
<Box key={`line-${visualIdxInRenderedSet}`} height={1}>
|
||||
<Text>
|
||||
{renderedLine}
|
||||
{showCursorBeforeGhost && chalk.inverse(' ')}
|
||||
{currentLineGhost && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{currentLineGhost}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
.concat(
|
||||
|
||||
@@ -9,96 +9,128 @@ import { parseInputForHighlighting } from './highlight.js';
|
||||
|
||||
describe('parseInputForHighlighting', () => {
|
||||
it('should handle an empty string', () => {
|
||||
expect(parseInputForHighlighting('')).toEqual([
|
||||
expect(parseInputForHighlighting('', 0)).toEqual([
|
||||
{ text: '', type: 'default' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle text with no commands or files', () => {
|
||||
const text = 'this is a normal sentence';
|
||||
expect(parseInputForHighlighting(text)).toEqual([
|
||||
expect(parseInputForHighlighting(text, 0)).toEqual([
|
||||
{ text, type: 'default' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should highlight a single command at the beginning', () => {
|
||||
it('should highlight a single command at the beginning when index is 0', () => {
|
||||
const text = '/help me';
|
||||
expect(parseInputForHighlighting(text)).toEqual([
|
||||
expect(parseInputForHighlighting(text, 0)).toEqual([
|
||||
{ text: '/help', type: 'command' },
|
||||
{ text: ' me', type: 'default' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should NOT highlight a command at the beginning when index is not 0', () => {
|
||||
const text = '/help me';
|
||||
expect(parseInputForHighlighting(text, 1)).toEqual([
|
||||
{ text: '/help', type: 'default' },
|
||||
{ text: ' me', type: 'default' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should highlight a single file path at the beginning', () => {
|
||||
const text = '@path/to/file.txt please';
|
||||
expect(parseInputForHighlighting(text)).toEqual([
|
||||
expect(parseInputForHighlighting(text, 0)).toEqual([
|
||||
{ text: '@path/to/file.txt', type: 'file' },
|
||||
{ text: ' please', type: 'default' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should highlight a command in the middle', () => {
|
||||
it('should not highlight a command in the middle', () => {
|
||||
const text = 'I need /help with this';
|
||||
expect(parseInputForHighlighting(text)).toEqual([
|
||||
{ text: 'I need ', type: 'default' },
|
||||
{ text: '/help', type: 'command' },
|
||||
{ text: ' with this', type: 'default' },
|
||||
expect(parseInputForHighlighting(text, 0)).toEqual([
|
||||
{ text: 'I need /help with this', type: 'default' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should highlight a file path in the middle', () => {
|
||||
const text = 'Please check @path/to/file.txt for details';
|
||||
expect(parseInputForHighlighting(text)).toEqual([
|
||||
expect(parseInputForHighlighting(text, 0)).toEqual([
|
||||
{ text: 'Please check ', type: 'default' },
|
||||
{ text: '@path/to/file.txt', type: 'file' },
|
||||
{ text: ' for details', type: 'default' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should highlight multiple commands and files', () => {
|
||||
it('should highlight files but not commands not at the start', () => {
|
||||
const text = 'Use /run with @file.js and also /format @another/file.ts';
|
||||
expect(parseInputForHighlighting(text)).toEqual([
|
||||
{ text: 'Use ', type: 'default' },
|
||||
{ text: '/run', type: 'command' },
|
||||
{ text: ' with ', type: 'default' },
|
||||
expect(parseInputForHighlighting(text, 0)).toEqual([
|
||||
{ text: 'Use /run with ', type: 'default' },
|
||||
{ text: '@file.js', type: 'file' },
|
||||
{ text: ' and also ', type: 'default' },
|
||||
{ text: '/format', type: 'command' },
|
||||
{ text: ' ', type: 'default' },
|
||||
{ text: ' and also /format ', type: 'default' },
|
||||
{ text: '@another/file.ts', type: 'file' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle adjacent highlights', () => {
|
||||
it('should handle adjacent highlights at start', () => {
|
||||
const text = '/run@file.js';
|
||||
expect(parseInputForHighlighting(text)).toEqual([
|
||||
expect(parseInputForHighlighting(text, 0)).toEqual([
|
||||
{ text: '/run', type: 'command' },
|
||||
{ text: '@file.js', type: 'file' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle highlights at the end of the string', () => {
|
||||
it('should not highlight command at the end of the string', () => {
|
||||
const text = 'Get help with /help';
|
||||
expect(parseInputForHighlighting(text)).toEqual([
|
||||
{ text: 'Get help with ', type: 'default' },
|
||||
{ text: '/help', type: 'command' },
|
||||
expect(parseInputForHighlighting(text, 0)).toEqual([
|
||||
{ text: 'Get help with /help', type: 'default' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle file paths with dots and dashes', () => {
|
||||
const text = 'Check @./path-to/file-name.v2.txt';
|
||||
expect(parseInputForHighlighting(text)).toEqual([
|
||||
expect(parseInputForHighlighting(text, 0)).toEqual([
|
||||
{ text: 'Check ', type: 'default' },
|
||||
{ text: '@./path-to/file-name.v2.txt', type: 'file' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle commands with dashes and numbers', () => {
|
||||
it('should not highlight command with dashes and numbers not at start', () => {
|
||||
const text = 'Run /command-123 now';
|
||||
expect(parseInputForHighlighting(text)).toEqual([
|
||||
{ text: 'Run ', type: 'default' },
|
||||
expect(parseInputForHighlighting(text, 0)).toEqual([
|
||||
{ text: 'Run /command-123 now', type: 'default' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should highlight command with dashes and numbers at start', () => {
|
||||
const text = '/command-123 now';
|
||||
expect(parseInputForHighlighting(text, 0)).toEqual([
|
||||
{ text: '/command-123', type: 'command' },
|
||||
{ text: ' now', type: 'default' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should still highlight a file path on a non-zero line', () => {
|
||||
const text = 'some text @path/to/file.txt';
|
||||
expect(parseInputForHighlighting(text, 1)).toEqual([
|
||||
{ text: 'some text ', type: 'default' },
|
||||
{ text: '@path/to/file.txt', type: 'file' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not highlight command but highlight file on a non-zero line', () => {
|
||||
const text = '/cmd @file.txt';
|
||||
expect(parseInputForHighlighting(text, 2)).toEqual([
|
||||
{ text: '/cmd', type: 'default' },
|
||||
{ text: ' ', type: 'default' },
|
||||
{ text: '@file.txt', type: 'file' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should highlight a file path with escaped spaces', () => {
|
||||
const text = 'cat @/my\\ path/file.txt';
|
||||
expect(parseInputForHighlighting(text, 0)).toEqual([
|
||||
{ text: 'cat ', type: 'default' },
|
||||
{ text: '@/my\\ path/file.txt', type: 'file' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,10 +9,11 @@ export type HighlightToken = {
|
||||
type: 'default' | 'command' | 'file';
|
||||
};
|
||||
|
||||
const HIGHLIGHT_REGEX = /(\/[a-zA-Z0-9_-]+|@[a-zA-Z0-9_./-]+)/g;
|
||||
const HIGHLIGHT_REGEX = /(^\/[a-zA-Z0-9_-]+|@(?:\\ |[a-zA-Z0-9_./-])+)/g;
|
||||
|
||||
export function parseInputForHighlighting(
|
||||
text: string,
|
||||
index: number,
|
||||
): readonly HighlightToken[] {
|
||||
if (!text) {
|
||||
return [{ text: '', type: 'default' }];
|
||||
@@ -36,10 +37,18 @@ export function parseInputForHighlighting(
|
||||
|
||||
// Add the matched token
|
||||
const type = fullMatch.startsWith('/') ? 'command' : 'file';
|
||||
tokens.push({
|
||||
text: fullMatch,
|
||||
type,
|
||||
});
|
||||
// Only highlight slash commands if the index is 0.
|
||||
if (type === 'command' && index !== 0) {
|
||||
tokens.push({
|
||||
text: fullMatch,
|
||||
type: 'default',
|
||||
});
|
||||
} else {
|
||||
tokens.push({
|
||||
text: fullMatch,
|
||||
type,
|
||||
});
|
||||
}
|
||||
|
||||
lastIndex = matchIndex + fullMatch.length;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user