mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-20 18:14:29 -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>
|
||||
);
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user