From c29e44848b3e087ba5e807ba4f5542d72356b32b Mon Sep 17 00:00:00 2001 From: Miguel Solorio Date: Tue, 2 Sep 2025 09:21:55 -0700 Subject: [PATCH] Add highlights for input /commands and @file/paths (#7165) --- .../src/ui/components/InputPrompt.test.tsx | 103 +++++++++++++++++ .../cli/src/ui/components/InputPrompt.tsx | 93 +++++++++------- packages/cli/src/ui/utils/highlight.test.ts | 104 ++++++++++++++++++ packages/cli/src/ui/utils/highlight.ts | 56 ++++++++++ 4 files changed, 319 insertions(+), 37 deletions(-) create mode 100644 packages/cli/src/ui/utils/highlight.test.ts create mode 100644 packages/cli/src/ui/utils/highlight.ts diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 820243f07d..9d072709e4 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + await wait(); + + const frame = stdout.lastFrame(); + expect(frame).toContain(`hello 👍${chalk.inverse(' ')}`); + unmount(); + }); + }); + describe('multiline paste', () => { it.each([ { diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 59516489b8..f348346e11 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -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 = ({ ) : ( 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( + + {display} + , + ); + }); + const currentLineGhost = isOnCursorLine ? inlineGhost : ''; + + if ( + isOnCursorLine && + cursorVisualColAbsolute === cpLen(lineText) + ) { + if (!currentLineGhost) { + renderedLine.push( + {chalk.inverse(' ')}, + ); } } 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 ( - {display} + {renderedLine} {showCursorBeforeGhost && chalk.inverse(' ')} {currentLineGhost && ( {currentLineGhost} )} - {trailingPadding > 0 && ' '.repeat(trailingPadding)} ); }) diff --git a/packages/cli/src/ui/utils/highlight.test.ts b/packages/cli/src/ui/utils/highlight.test.ts new file mode 100644 index 0000000000..9d03f21528 --- /dev/null +++ b/packages/cli/src/ui/utils/highlight.test.ts @@ -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' }, + ]); + }); +}); diff --git a/packages/cli/src/ui/utils/highlight.ts b/packages/cli/src/ui/utils/highlight.ts new file mode 100644 index 0000000000..2bba3e8d5f --- /dev/null +++ b/packages/cli/src/ui/utils/highlight.ts @@ -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; +}