mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-15 16:41:11 -07:00
feat: terse transformations of image paths in text buffer (#4924)
Co-authored-by: Jacob Richman <jacob314@gmail.com> Co-authored-by: owenofbrien <86964623+owenofbrien@users.noreply.github.com>
This commit is contained in:
@@ -134,3 +134,103 @@ describe('parseInputForHighlighting', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseInputForHighlighting with Transformations', () => {
|
||||
const transformations = [
|
||||
{
|
||||
logStart: 10,
|
||||
logEnd: 19,
|
||||
logicalText: '@test.png',
|
||||
collapsedText: '[Image test.png]',
|
||||
},
|
||||
];
|
||||
|
||||
it('should show collapsed transformation when cursor is not on it', () => {
|
||||
const line = 'Check out @test.png';
|
||||
const result = parseInputForHighlighting(
|
||||
line,
|
||||
0, // line index
|
||||
transformations,
|
||||
0, // cursor not on transformation
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ text: 'Check out ', type: 'default' },
|
||||
{ text: '[Image test.png]', type: 'file' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should show expanded transformation when cursor is on it', () => {
|
||||
const line = 'Check out @test.png';
|
||||
const result = parseInputForHighlighting(
|
||||
line,
|
||||
0, // line index
|
||||
transformations,
|
||||
11, // cursor on transformation
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ text: 'Check out ', type: 'default' },
|
||||
{ text: '@test.png', type: 'file' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle multiple transformations in a line', () => {
|
||||
const line = 'Images: @test1.png and @test2.png';
|
||||
const multiTransformations = [
|
||||
{
|
||||
logStart: 8,
|
||||
logEnd: 18,
|
||||
logicalText: '@test1.png',
|
||||
collapsedText: '[Image test1.png]',
|
||||
},
|
||||
{
|
||||
logStart: 23,
|
||||
logEnd: 33,
|
||||
logicalText: '@test2.png',
|
||||
collapsedText: '[Image test2.png]',
|
||||
},
|
||||
];
|
||||
|
||||
// Cursor not on any transformation
|
||||
let result = parseInputForHighlighting(line, 0, multiTransformations, 0);
|
||||
expect(result).toEqual([
|
||||
{ text: 'Images: ', type: 'default' },
|
||||
{ text: '[Image test1.png]', type: 'file' },
|
||||
{ text: ' and ', type: 'default' },
|
||||
{ text: '[Image test2.png]', type: 'file' },
|
||||
]);
|
||||
|
||||
// Cursor on first transformation
|
||||
result = parseInputForHighlighting(line, 0, multiTransformations, 10);
|
||||
expect(result).toEqual([
|
||||
{ text: 'Images: ', type: 'default' },
|
||||
{ text: '@test1.png', type: 'file' },
|
||||
{ text: ' and ', type: 'default' },
|
||||
{ text: '[Image test2.png]', type: 'file' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle empty transformations array', () => {
|
||||
const line = 'Check out @test.png';
|
||||
const result = parseInputForHighlighting(line, 0, [], 0);
|
||||
|
||||
// Should fall back to default highlighting
|
||||
expect(result).toEqual([
|
||||
{ text: 'Check out ', type: 'default' },
|
||||
{ text: '@test.png', type: 'file' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle cursor at transformation boundaries', () => {
|
||||
const line = 'Check out @test.png';
|
||||
const result = parseInputForHighlighting(
|
||||
line,
|
||||
0,
|
||||
transformations,
|
||||
10, // cursor at start of transformation
|
||||
);
|
||||
|
||||
expect(result[1]).toEqual({ text: '@test.png', type: 'file' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Transformation } from '../components/shared/text-buffer.js';
|
||||
import { cpLen, cpSlice } from './textUtils.js';
|
||||
|
||||
export type HighlightToken = {
|
||||
@@ -20,57 +21,81 @@ const HIGHLIGHT_REGEX = /(^\/[a-zA-Z0-9_-]+|@(?:\\ |[^,\s;!?()[\]{}])+)/g;
|
||||
export function parseInputForHighlighting(
|
||||
text: string,
|
||||
index: number,
|
||||
transformations: Transformation[] = [],
|
||||
cursorCol?: number,
|
||||
): readonly HighlightToken[] {
|
||||
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' : 'file';
|
||||
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 lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = HIGHLIGHT_REGEX.exec(text)) !== null) {
|
||||
const [fullMatch] = match;
|
||||
const matchIndex = match.index;
|
||||
let column = 0;
|
||||
const sortedTransformations = (transformations ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.logStart - b.logStart);
|
||||
|
||||
// Add the text before the match as a default token
|
||||
if (matchIndex > lastIndex) {
|
||||
tokens.push({
|
||||
text: text.slice(lastIndex, matchIndex),
|
||||
type: 'default',
|
||||
});
|
||||
}
|
||||
for (const transformation of sortedTransformations) {
|
||||
const textBeforeTransformation = cpSlice(
|
||||
text,
|
||||
column,
|
||||
transformation.logStart,
|
||||
);
|
||||
tokens.push(...parseUntransformedInput(textBeforeTransformation));
|
||||
|
||||
// Add the matched token
|
||||
const type = fullMatch.startsWith('/') ? 'command' : 'file';
|
||||
// Only highlight slash commands if the index is 0.
|
||||
if (type === 'command' && index !== 0) {
|
||||
tokens.push({
|
||||
text: fullMatch,
|
||||
type: 'default',
|
||||
});
|
||||
} else {
|
||||
tokens.push({
|
||||
text: fullMatch,
|
||||
type,
|
||||
});
|
||||
}
|
||||
const isCursorInside =
|
||||
typeof cursorCol === 'number' &&
|
||||
cursorCol >= transformation.logStart &&
|
||||
cursorCol <= transformation.logEnd;
|
||||
const transformationText = isCursorInside
|
||||
? transformation.logicalText
|
||||
: transformation.collapsedText;
|
||||
tokens.push({ text: transformationText, type: 'file' });
|
||||
|
||||
lastIndex = matchIndex + fullMatch.length;
|
||||
}
|
||||
|
||||
// Add any remaining text after the last match
|
||||
if (lastIndex < text.length) {
|
||||
tokens.push({
|
||||
text: text.slice(lastIndex),
|
||||
type: 'default',
|
||||
});
|
||||
column = transformation.logEnd;
|
||||
}
|
||||
|
||||
const textAfterFinalTransformation = cpSlice(text, column);
|
||||
tokens.push(...parseUntransformedInput(textAfterFinalTransformation));
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export function buildSegmentsForVisualSlice(
|
||||
export function parseSegmentsFromTokens(
|
||||
tokens: readonly HighlightToken[],
|
||||
sliceStart: number,
|
||||
sliceEnd: number,
|
||||
@@ -102,6 +127,5 @@ export function buildSegmentsForVisualSlice(
|
||||
|
||||
tokenCpStart += tokenLen;
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user