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