mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-13 15:40:57 -07:00
168 lines
4.8 KiB
TypeScript
168 lines
4.8 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import {
|
|
type Transformation,
|
|
PASTED_TEXT_PLACEHOLDER_REGEX,
|
|
} from '../components/shared/text-buffer.js';
|
|
import { LRUCache } from 'mnemonist';
|
|
import { cpLen, cpSlice } from './textUtils.js';
|
|
import { LRU_BUFFER_PERF_CACHE_LIMIT } from '../constants.js';
|
|
import { AT_COMMAND_PATH_REGEX_SOURCE } from '../hooks/atCommandProcessor.js';
|
|
|
|
export type HighlightToken = {
|
|
text: string;
|
|
type: 'default' | 'command' | 'file' | 'paste';
|
|
};
|
|
|
|
// Matches slash commands (e.g., /help), @ references (files or MCP resource URIs),
|
|
// and large paste placeholders (e.g., [Pasted Text: 6 lines]).
|
|
//
|
|
// The @ pattern uses the same source as the command processor to ensure consistency.
|
|
// It matches any character except strict delimiters (ASCII whitespace, comma, etc.).
|
|
// This supports URIs like `@file:///example.txt` and filenames with Unicode spaces (like NNBSP).
|
|
const HIGHLIGHT_REGEX = new RegExp(
|
|
`(^/[a-zA-Z0-9_-]+|@${AT_COMMAND_PATH_REGEX_SOURCE}|${PASTED_TEXT_PLACEHOLDER_REGEX.source})`,
|
|
'g',
|
|
);
|
|
|
|
const highlightCache = new LRUCache<string, readonly HighlightToken[]>(
|
|
LRU_BUFFER_PERF_CACHE_LIMIT,
|
|
);
|
|
|
|
export function parseInputForHighlighting(
|
|
text: string,
|
|
index: number,
|
|
transformations: Transformation[] = [],
|
|
cursorCol?: number,
|
|
): readonly HighlightToken[] {
|
|
let isCursorInsideTransform = false;
|
|
if (cursorCol !== undefined) {
|
|
for (const transform of transformations) {
|
|
if (cursorCol >= transform.logStart && cursorCol <= transform.logEnd) {
|
|
isCursorInsideTransform = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
const cacheKey = `${index === 0 ? 'F' : 'N'}:${isCursorInsideTransform ? cursorCol : 'NC'}:${text}`;
|
|
const cached = highlightCache.get(cacheKey);
|
|
if (cached !== undefined) return cached;
|
|
|
|
HIGHLIGHT_REGEX.lastIndex = 0;
|
|
|
|
if (!text) {
|
|
return [{ text: '', type: 'default' }];
|
|
}
|
|
|
|
const parseUntransformedInput = (text: string): HighlightToken[] => {
|
|
const tokens: HighlightToken[] = [];
|
|
if (!text) return tokens;
|
|
|
|
HIGHLIGHT_REGEX.lastIndex = 0;
|
|
let last = 0;
|
|
let match: RegExpExecArray | null;
|
|
|
|
while ((match = HIGHLIGHT_REGEX.exec(text)) !== null) {
|
|
const [fullMatch] = match;
|
|
const matchIndex = match.index;
|
|
|
|
if (matchIndex > last) {
|
|
tokens.push({ text: text.slice(last, matchIndex), type: 'default' });
|
|
}
|
|
|
|
const type = fullMatch.startsWith('/')
|
|
? 'command'
|
|
: fullMatch.startsWith('@')
|
|
? 'file'
|
|
: 'paste';
|
|
if (type === 'command' && index !== 0) {
|
|
tokens.push({ text: fullMatch, type: 'default' });
|
|
} else {
|
|
tokens.push({ text: fullMatch, type });
|
|
}
|
|
|
|
last = matchIndex + fullMatch.length;
|
|
}
|
|
|
|
if (last < text.length) {
|
|
tokens.push({ text: text.slice(last), type: 'default' });
|
|
}
|
|
|
|
return tokens;
|
|
};
|
|
|
|
const tokens: HighlightToken[] = [];
|
|
|
|
let column = 0;
|
|
const sortedTransformations = (transformations ?? [])
|
|
.slice()
|
|
.sort((a, b) => a.logStart - b.logStart);
|
|
|
|
for (const transformation of sortedTransformations) {
|
|
const textBeforeTransformation = cpSlice(
|
|
text,
|
|
column,
|
|
transformation.logStart,
|
|
);
|
|
tokens.push(...parseUntransformedInput(textBeforeTransformation));
|
|
|
|
const isCursorInside =
|
|
cursorCol !== undefined &&
|
|
cursorCol >= transformation.logStart &&
|
|
cursorCol <= transformation.logEnd;
|
|
const transformationText = isCursorInside
|
|
? transformation.logicalText
|
|
: transformation.collapsedText;
|
|
tokens.push({ text: transformationText, type: 'file' });
|
|
|
|
column = transformation.logEnd;
|
|
}
|
|
|
|
const textAfterFinalTransformation = cpSlice(text, column);
|
|
tokens.push(...parseUntransformedInput(textAfterFinalTransformation));
|
|
|
|
highlightCache.set(cacheKey, tokens);
|
|
|
|
return tokens;
|
|
}
|
|
|
|
export function parseSegmentsFromTokens(
|
|
tokens: readonly HighlightToken[],
|
|
sliceStart: number,
|
|
sliceEnd: number,
|
|
): readonly HighlightToken[] {
|
|
if (sliceStart >= sliceEnd) return [];
|
|
|
|
const segments: HighlightToken[] = [];
|
|
let tokenCpStart = 0;
|
|
|
|
for (const token of tokens) {
|
|
const tokenLen = cpLen(token.text);
|
|
const tokenStart = tokenCpStart;
|
|
const tokenEnd = tokenStart + tokenLen;
|
|
|
|
const overlapStart = Math.max(tokenStart, sliceStart);
|
|
const overlapEnd = Math.min(tokenEnd, sliceEnd);
|
|
if (overlapStart < overlapEnd) {
|
|
const sliceStartInToken = overlapStart - tokenStart;
|
|
const sliceEndInToken = overlapEnd - tokenStart;
|
|
const rawSlice = cpSlice(token.text, sliceStartInToken, sliceEndInToken);
|
|
|
|
const last = segments[segments.length - 1];
|
|
if (last && last.type === token.type) {
|
|
last.text += rawSlice;
|
|
} else {
|
|
segments.push({ type: token.type, text: rawSlice });
|
|
}
|
|
}
|
|
|
|
tokenCpStart += tokenLen;
|
|
}
|
|
return segments;
|
|
}
|