mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-16 09:01:17 -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 * as clipboardUtils from '../utils/clipboardUtils.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import chalk from 'chalk';
|
||||
|
||||
vi.mock('../hooks/useShellHistory.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', () => {
|
||||
it.each([
|
||||
{
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { keyMatchers, Command } from '../keyMatchers.js';
|
||||
import type { CommandContext, SlashCommand } from '../commands/types.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import { parseInputForHighlighting } from '../utils/highlight.js';
|
||||
import {
|
||||
clipboardHasImage,
|
||||
saveClipboardImage,
|
||||
@@ -717,69 +718,87 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
) : (
|
||||
linesToRender
|
||||
.map((lineText, visualIdxInRenderedSet) => {
|
||||
const tokens = parseInputForHighlighting(lineText);
|
||||
const cursorVisualRow =
|
||||
cursorVisualRowAbsolute - scrollVisualRow;
|
||||
let display = cpSlice(lineText, 0, inputWidth);
|
||||
|
||||
const isOnCursorLine =
|
||||
focus && visualIdxInRenderedSet === cursorVisualRow;
|
||||
const currentLineGhost = isOnCursorLine ? inlineGhost : '';
|
||||
|
||||
const ghostWidth = stringWidth(currentLineGhost);
|
||||
const renderedLine: React.ReactNode[] = [];
|
||||
let charCount = 0;
|
||||
|
||||
if (focus && visualIdxInRenderedSet === cursorVisualRow) {
|
||||
const relativeVisualColForHighlight = cursorVisualColAbsolute;
|
||||
tokens.forEach((token, tokenIdx) => {
|
||||
let display = token.text;
|
||||
if (isOnCursorLine) {
|
||||
const relativeVisualColForHighlight =
|
||||
cursorVisualColAbsolute;
|
||||
const tokenStart = charCount;
|
||||
const tokenEnd = tokenStart + cpLen(token.text);
|
||||
|
||||
if (relativeVisualColForHighlight >= 0) {
|
||||
if (relativeVisualColForHighlight < cpLen(display)) {
|
||||
const charToHighlight =
|
||||
cpSlice(
|
||||
display,
|
||||
relativeVisualColForHighlight,
|
||||
relativeVisualColForHighlight + 1,
|
||||
) || ' ';
|
||||
if (
|
||||
relativeVisualColForHighlight >= tokenStart &&
|
||||
relativeVisualColForHighlight < tokenEnd
|
||||
) {
|
||||
const charToHighlight = cpSlice(
|
||||
token.text,
|
||||
relativeVisualColForHighlight - tokenStart,
|
||||
relativeVisualColForHighlight - tokenStart + 1,
|
||||
);
|
||||
const highlighted = chalk.inverse(charToHighlight);
|
||||
display =
|
||||
cpSlice(display, 0, relativeVisualColForHighlight) +
|
||||
cpSlice(
|
||||
token.text,
|
||||
0,
|
||||
relativeVisualColForHighlight - tokenStart,
|
||||
) +
|
||||
highlighted +
|
||||
cpSlice(display, relativeVisualColForHighlight + 1);
|
||||
} else if (
|
||||
relativeVisualColForHighlight === cpLen(display)
|
||||
) {
|
||||
if (!currentLineGhost) {
|
||||
display = display + chalk.inverse(' ');
|
||||
}
|
||||
cpSlice(
|
||||
token.text,
|
||||
relativeVisualColForHighlight - tokenStart + 1,
|
||||
);
|
||||
}
|
||||
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 =
|
||||
focus &&
|
||||
visualIdxInRenderedSet === cursorVisualRow &&
|
||||
cursorVisualColAbsolute ===
|
||||
// eslint-disable-next-line no-control-regex
|
||||
cpLen(display.replace(/\x1b\[[0-9;]*m/g, '')) &&
|
||||
isOnCursorLine &&
|
||||
cursorVisualColAbsolute === cpLen(lineText) &&
|
||||
currentLineGhost;
|
||||
|
||||
const actualDisplayWidth = stringWidth(display);
|
||||
const cursorWidth = showCursorBeforeGhost ? 1 : 0;
|
||||
const totalContentWidth =
|
||||
actualDisplayWidth + cursorWidth + ghostWidth;
|
||||
const trailingPadding = Math.max(
|
||||
0,
|
||||
inputWidth - totalContentWidth,
|
||||
);
|
||||
|
||||
return (
|
||||
<Text key={`line-${visualIdxInRenderedSet}`}>
|
||||
{display}
|
||||
{renderedLine}
|
||||
{showCursorBeforeGhost && chalk.inverse(' ')}
|
||||
{currentLineGhost && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{currentLineGhost}
|
||||
</Text>
|
||||
)}
|
||||
{trailingPadding > 0 && ' '.repeat(trailingPadding)}
|
||||
</Text>
|
||||
);
|
||||
})
|
||||
|
||||
104
packages/cli/src/ui/utils/highlight.test.ts
Normal file
104
packages/cli/src/ui/utils/highlight.test.ts
Normal file
@@ -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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
56
packages/cli/src/ui/utils/highlight.ts
Normal file
56
packages/cli/src/ui/utils/highlight.ts
Normal file
@@ -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