mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 12:54:07 -07:00
Add highlights for input /commands and @file/paths (#7165)
This commit is contained in:
@@ -22,6 +22,7 @@ import type { UseInputHistoryReturn } from '../hooks/useInputHistory.js';
|
|||||||
import { useInputHistory } from '../hooks/useInputHistory.js';
|
import { useInputHistory } from '../hooks/useInputHistory.js';
|
||||||
import * as clipboardUtils from '../utils/clipboardUtils.js';
|
import * as clipboardUtils from '../utils/clipboardUtils.js';
|
||||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
|
||||||
vi.mock('../hooks/useShellHistory.js');
|
vi.mock('../hooks/useShellHistory.js');
|
||||||
vi.mock('../hooks/useCommandCompletion.js');
|
vi.mock('../hooks/useCommandCompletion.js');
|
||||||
@@ -1208,6 +1209,108 @@ describe('InputPrompt', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Highlighting and Cursor Display', () => {
|
||||||
|
it('should display cursor mid-word by highlighting the character', async () => {
|
||||||
|
mockBuffer.text = 'hello world';
|
||||||
|
mockBuffer.lines = ['hello world'];
|
||||||
|
mockBuffer.viewportVisualLines = ['hello world'];
|
||||||
|
mockBuffer.visualCursor = [0, 3]; // cursor on the second 'l'
|
||||||
|
|
||||||
|
const { stdout, unmount } = renderWithProviders(
|
||||||
|
<InputPrompt {...props} />,
|
||||||
|
);
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
const frame = stdout.lastFrame();
|
||||||
|
// The component will render the text with the character at the cursor inverted.
|
||||||
|
expect(frame).toContain(`hel${chalk.inverse('l')}o world`);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display cursor at the beginning of the line', async () => {
|
||||||
|
mockBuffer.text = 'hello';
|
||||||
|
mockBuffer.lines = ['hello'];
|
||||||
|
mockBuffer.viewportVisualLines = ['hello'];
|
||||||
|
mockBuffer.visualCursor = [0, 0]; // cursor on 'h'
|
||||||
|
|
||||||
|
const { stdout, unmount } = renderWithProviders(
|
||||||
|
<InputPrompt {...props} />,
|
||||||
|
);
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
const frame = stdout.lastFrame();
|
||||||
|
expect(frame).toContain(`${chalk.inverse('h')}ello`);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display cursor at the end of the line as an inverted space', async () => {
|
||||||
|
mockBuffer.text = 'hello';
|
||||||
|
mockBuffer.lines = ['hello'];
|
||||||
|
mockBuffer.viewportVisualLines = ['hello'];
|
||||||
|
mockBuffer.visualCursor = [0, 5]; // cursor after 'o'
|
||||||
|
|
||||||
|
const { stdout, unmount } = renderWithProviders(
|
||||||
|
<InputPrompt {...props} />,
|
||||||
|
);
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
const frame = stdout.lastFrame();
|
||||||
|
expect(frame).toContain(`hello${chalk.inverse(' ')}`);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display cursor correctly on a highlighted token', async () => {
|
||||||
|
mockBuffer.text = 'run @path/to/file';
|
||||||
|
mockBuffer.lines = ['run @path/to/file'];
|
||||||
|
mockBuffer.viewportVisualLines = ['run @path/to/file'];
|
||||||
|
mockBuffer.visualCursor = [0, 9]; // cursor on 't' in 'to'
|
||||||
|
|
||||||
|
const { stdout, unmount } = renderWithProviders(
|
||||||
|
<InputPrompt {...props} />,
|
||||||
|
);
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
const frame = stdout.lastFrame();
|
||||||
|
// The token '@path/to/file' is colored, and the cursor highlights one char inside it.
|
||||||
|
expect(frame).toContain(`@path/${chalk.inverse('t')}o/file`);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display cursor correctly for multi-byte unicode characters', async () => {
|
||||||
|
const text = 'hello 👍 world';
|
||||||
|
mockBuffer.text = text;
|
||||||
|
mockBuffer.lines = [text];
|
||||||
|
mockBuffer.viewportVisualLines = [text];
|
||||||
|
mockBuffer.visualCursor = [0, 6]; // cursor on '👍'
|
||||||
|
|
||||||
|
const { stdout, unmount } = renderWithProviders(
|
||||||
|
<InputPrompt {...props} />,
|
||||||
|
);
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
const frame = stdout.lastFrame();
|
||||||
|
expect(frame).toContain(`hello ${chalk.inverse('👍')} world`);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display cursor at the end of a line with unicode characters', async () => {
|
||||||
|
const text = 'hello 👍';
|
||||||
|
mockBuffer.text = text;
|
||||||
|
mockBuffer.lines = [text];
|
||||||
|
mockBuffer.viewportVisualLines = [text];
|
||||||
|
mockBuffer.visualCursor = [0, 8]; // cursor after '👍' (length is 6 + 2 for emoji)
|
||||||
|
|
||||||
|
const { stdout, unmount } = renderWithProviders(
|
||||||
|
<InputPrompt {...props} />,
|
||||||
|
);
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
const frame = stdout.lastFrame();
|
||||||
|
expect(frame).toContain(`hello 👍${chalk.inverse(' ')}`);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('multiline paste', () => {
|
describe('multiline paste', () => {
|
||||||
it.each([
|
it.each([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { useKeypress } from '../hooks/useKeypress.js';
|
|||||||
import { keyMatchers, Command } from '../keyMatchers.js';
|
import { keyMatchers, Command } from '../keyMatchers.js';
|
||||||
import type { CommandContext, SlashCommand } from '../commands/types.js';
|
import type { CommandContext, SlashCommand } from '../commands/types.js';
|
||||||
import type { Config } from '@google/gemini-cli-core';
|
import type { Config } from '@google/gemini-cli-core';
|
||||||
|
import { parseInputForHighlighting } from '../utils/highlight.js';
|
||||||
import {
|
import {
|
||||||
clipboardHasImage,
|
clipboardHasImage,
|
||||||
saveClipboardImage,
|
saveClipboardImage,
|
||||||
@@ -717,69 +718,87 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
) : (
|
) : (
|
||||||
linesToRender
|
linesToRender
|
||||||
.map((lineText, visualIdxInRenderedSet) => {
|
.map((lineText, visualIdxInRenderedSet) => {
|
||||||
|
const tokens = parseInputForHighlighting(lineText);
|
||||||
const cursorVisualRow =
|
const cursorVisualRow =
|
||||||
cursorVisualRowAbsolute - scrollVisualRow;
|
cursorVisualRowAbsolute - scrollVisualRow;
|
||||||
let display = cpSlice(lineText, 0, inputWidth);
|
|
||||||
|
|
||||||
const isOnCursorLine =
|
const isOnCursorLine =
|
||||||
focus && visualIdxInRenderedSet === cursorVisualRow;
|
focus && visualIdxInRenderedSet === cursorVisualRow;
|
||||||
const currentLineGhost = isOnCursorLine ? inlineGhost : '';
|
|
||||||
|
|
||||||
const ghostWidth = stringWidth(currentLineGhost);
|
const renderedLine: React.ReactNode[] = [];
|
||||||
|
let charCount = 0;
|
||||||
|
|
||||||
if (focus && visualIdxInRenderedSet === cursorVisualRow) {
|
tokens.forEach((token, tokenIdx) => {
|
||||||
const relativeVisualColForHighlight = cursorVisualColAbsolute;
|
let display = token.text;
|
||||||
|
if (isOnCursorLine) {
|
||||||
|
const relativeVisualColForHighlight =
|
||||||
|
cursorVisualColAbsolute;
|
||||||
|
const tokenStart = charCount;
|
||||||
|
const tokenEnd = tokenStart + cpLen(token.text);
|
||||||
|
|
||||||
if (relativeVisualColForHighlight >= 0) {
|
if (
|
||||||
if (relativeVisualColForHighlight < cpLen(display)) {
|
relativeVisualColForHighlight >= tokenStart &&
|
||||||
const charToHighlight =
|
relativeVisualColForHighlight < tokenEnd
|
||||||
cpSlice(
|
) {
|
||||||
display,
|
const charToHighlight = cpSlice(
|
||||||
relativeVisualColForHighlight,
|
token.text,
|
||||||
relativeVisualColForHighlight + 1,
|
relativeVisualColForHighlight - tokenStart,
|
||||||
) || ' ';
|
relativeVisualColForHighlight - tokenStart + 1,
|
||||||
|
);
|
||||||
const highlighted = chalk.inverse(charToHighlight);
|
const highlighted = chalk.inverse(charToHighlight);
|
||||||
display =
|
display =
|
||||||
cpSlice(display, 0, relativeVisualColForHighlight) +
|
cpSlice(
|
||||||
|
token.text,
|
||||||
|
0,
|
||||||
|
relativeVisualColForHighlight - tokenStart,
|
||||||
|
) +
|
||||||
highlighted +
|
highlighted +
|
||||||
cpSlice(display, relativeVisualColForHighlight + 1);
|
cpSlice(
|
||||||
} else if (
|
token.text,
|
||||||
relativeVisualColForHighlight === cpLen(display)
|
relativeVisualColForHighlight - tokenStart + 1,
|
||||||
) {
|
);
|
||||||
if (!currentLineGhost) {
|
|
||||||
display = display + chalk.inverse(' ');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
charCount = tokenEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
const color =
|
||||||
|
token.type === 'command' || token.type === 'file'
|
||||||
|
? theme.text.accent
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
renderedLine.push(
|
||||||
|
<Text key={`token-${tokenIdx}`} color={color}>
|
||||||
|
{display}
|
||||||
|
</Text>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const currentLineGhost = isOnCursorLine ? inlineGhost : '';
|
||||||
|
|
||||||
|
if (
|
||||||
|
isOnCursorLine &&
|
||||||
|
cursorVisualColAbsolute === cpLen(lineText)
|
||||||
|
) {
|
||||||
|
if (!currentLineGhost) {
|
||||||
|
renderedLine.push(
|
||||||
|
<Text key="cursor-end">{chalk.inverse(' ')}</Text>,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const showCursorBeforeGhost =
|
const showCursorBeforeGhost =
|
||||||
focus &&
|
focus &&
|
||||||
visualIdxInRenderedSet === cursorVisualRow &&
|
isOnCursorLine &&
|
||||||
cursorVisualColAbsolute ===
|
cursorVisualColAbsolute === cpLen(lineText) &&
|
||||||
// eslint-disable-next-line no-control-regex
|
|
||||||
cpLen(display.replace(/\x1b\[[0-9;]*m/g, '')) &&
|
|
||||||
currentLineGhost;
|
currentLineGhost;
|
||||||
|
|
||||||
const actualDisplayWidth = stringWidth(display);
|
|
||||||
const cursorWidth = showCursorBeforeGhost ? 1 : 0;
|
|
||||||
const totalContentWidth =
|
|
||||||
actualDisplayWidth + cursorWidth + ghostWidth;
|
|
||||||
const trailingPadding = Math.max(
|
|
||||||
0,
|
|
||||||
inputWidth - totalContentWidth,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Text key={`line-${visualIdxInRenderedSet}`}>
|
<Text key={`line-${visualIdxInRenderedSet}`}>
|
||||||
{display}
|
{renderedLine}
|
||||||
{showCursorBeforeGhost && chalk.inverse(' ')}
|
{showCursorBeforeGhost && chalk.inverse(' ')}
|
||||||
{currentLineGhost && (
|
{currentLineGhost && (
|
||||||
<Text color={theme.text.secondary}>
|
<Text color={theme.text.secondary}>
|
||||||
{currentLineGhost}
|
{currentLineGhost}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{trailingPadding > 0 && ' '.repeat(trailingPadding)}
|
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { parseInputForHighlighting } from './highlight.js';
|
||||||
|
|
||||||
|
describe('parseInputForHighlighting', () => {
|
||||||
|
it('should handle an empty string', () => {
|
||||||
|
expect(parseInputForHighlighting('')).toEqual([
|
||||||
|
{ text: '', type: 'default' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle text with no commands or files', () => {
|
||||||
|
const text = 'this is a normal sentence';
|
||||||
|
expect(parseInputForHighlighting(text)).toEqual([
|
||||||
|
{ text, type: 'default' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should highlight a single command at the beginning', () => {
|
||||||
|
const text = '/help me';
|
||||||
|
expect(parseInputForHighlighting(text)).toEqual([
|
||||||
|
{ text: '/help', type: 'command' },
|
||||||
|
{ 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([
|
||||||
|
{ text: '@path/to/file.txt', type: 'file' },
|
||||||
|
{ text: ' please', type: 'default' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should 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' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should highlight a file path in the middle', () => {
|
||||||
|
const text = 'Please check @path/to/file.txt for details';
|
||||||
|
expect(parseInputForHighlighting(text)).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', () => {
|
||||||
|
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' },
|
||||||
|
{ text: '@file.js', type: 'file' },
|
||||||
|
{ text: ' and also ', type: 'default' },
|
||||||
|
{ text: '/format', type: 'command' },
|
||||||
|
{ text: ' ', type: 'default' },
|
||||||
|
{ text: '@another/file.ts', type: 'file' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle adjacent highlights', () => {
|
||||||
|
const text = '/run@file.js';
|
||||||
|
expect(parseInputForHighlighting(text)).toEqual([
|
||||||
|
{ text: '/run', type: 'command' },
|
||||||
|
{ text: '@file.js', type: 'file' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle highlights 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' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle file paths with dots and dashes', () => {
|
||||||
|
const text = 'Check @./path-to/file-name.v2.txt';
|
||||||
|
expect(parseInputForHighlighting(text)).toEqual([
|
||||||
|
{ text: 'Check ', type: 'default' },
|
||||||
|
{ text: '@./path-to/file-name.v2.txt', type: 'file' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle commands with dashes and numbers', () => {
|
||||||
|
const text = 'Run /command-123 now';
|
||||||
|
expect(parseInputForHighlighting(text)).toEqual([
|
||||||
|
{ text: 'Run ', type: 'default' },
|
||||||
|
{ text: '/command-123', type: 'command' },
|
||||||
|
{ text: ' now', type: 'default' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type HighlightToken = {
|
||||||
|
text: string;
|
||||||
|
type: 'default' | 'command' | 'file';
|
||||||
|
};
|
||||||
|
|
||||||
|
const HIGHLIGHT_REGEX = /(\/[a-zA-Z0-9_-]+|@[a-zA-Z0-9_./-]+)/g;
|
||||||
|
|
||||||
|
export function parseInputForHighlighting(
|
||||||
|
text: string,
|
||||||
|
): readonly HighlightToken[] {
|
||||||
|
if (!text) {
|
||||||
|
return [{ text: '', type: 'default' }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens: HighlightToken[] = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = HIGHLIGHT_REGEX.exec(text)) !== null) {
|
||||||
|
const [fullMatch] = match;
|
||||||
|
const matchIndex = match.index;
|
||||||
|
|
||||||
|
// Add the text before the match as a default token
|
||||||
|
if (matchIndex > lastIndex) {
|
||||||
|
tokens.push({
|
||||||
|
text: text.slice(lastIndex, matchIndex),
|
||||||
|
type: 'default',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the matched token
|
||||||
|
const type = fullMatch.startsWith('/') ? 'command' : 'file';
|
||||||
|
tokens.push({
|
||||||
|
text: fullMatch,
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
|
||||||
|
lastIndex = matchIndex + fullMatch.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add any remaining text after the last match
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
tokens.push({
|
||||||
|
text: text.slice(lastIndex),
|
||||||
|
type: 'default',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user