Add highlights for input /commands and @file/paths (#7165)

This commit is contained in:
Miguel Solorio
2025-09-02 09:21:55 -07:00
committed by GitHub
parent 93820f833f
commit c29e44848b
4 changed files with 319 additions and 37 deletions

View File

@@ -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([
{

View File

@@ -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>
);
})

View 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' },
]);
});
});

View 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;
}