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:
Pyush Sinha
2025-12-23 12:46:09 -08:00
committed by GitHub
parent 02a36afc38
commit 873d10df42
9 changed files with 772 additions and 75 deletions
@@ -10,6 +10,10 @@ import { act } from 'react';
import type { InputPromptProps } from './InputPrompt.js'; import type { InputPromptProps } from './InputPrompt.js';
import { InputPrompt } from './InputPrompt.js'; import { InputPrompt } from './InputPrompt.js';
import type { TextBuffer } from './shared/text-buffer.js'; import type { TextBuffer } from './shared/text-buffer.js';
import {
calculateTransformationsForLine,
calculateTransformedLine,
} from './shared/text-buffer.js';
import type { Config } from '@google/gemini-cli-core'; import type { Config } from '@google/gemini-cli-core';
import { ApprovalMode } from '@google/gemini-cli-core'; import { ApprovalMode } from '@google/gemini-cli-core';
import * as path from 'node:path'; import * as path from 'node:path';
@@ -158,6 +162,8 @@ describe('InputPrompt', () => {
deleteWordLeft: vi.fn(), deleteWordLeft: vi.fn(),
deleteWordRight: vi.fn(), deleteWordRight: vi.fn(),
visualToLogicalMap: [[0, 0]], visualToLogicalMap: [[0, 0]],
visualToTransformedMap: [0],
transformationsByLine: [],
getOffset: vi.fn().mockReturnValue(0), getOffset: vi.fn().mockReturnValue(0),
} as unknown as TextBuffer; } as unknown as TextBuffer;
@@ -2740,6 +2746,59 @@ describe('InputPrompt', () => {
}, },
); );
}); });
describe('image path transformation snapshots', () => {
const logicalLine = '@/path/to/screenshots/screenshot2x.png';
const transformations = calculateTransformationsForLine(logicalLine);
const applyVisualState = (visualLine: string, cursorCol: number): void => {
mockBuffer.text = logicalLine;
mockBuffer.lines = [logicalLine];
mockBuffer.viewportVisualLines = [visualLine];
mockBuffer.allVisualLines = [visualLine];
mockBuffer.visualToLogicalMap = [[0, 0]];
mockBuffer.visualToTransformedMap = [0];
mockBuffer.transformationsByLine = [transformations];
mockBuffer.cursor = [0, cursorCol];
mockBuffer.visualCursor = [0, 0];
};
it('should snapshot collapsed image path', async () => {
const { transformedLine } = calculateTransformedLine(
logicalLine,
0,
[0, transformations[0].logEnd + 5],
transformations,
);
applyVisualState(transformedLine, transformations[0].logEnd + 5);
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await waitFor(() => {
expect(stdout.lastFrame()).toMatchSnapshot();
});
unmount();
});
it('should snapshot expanded image path when cursor is on it', async () => {
const { transformedLine } = calculateTransformedLine(
logicalLine,
0,
[0, transformations[0].logStart + 1],
transformations,
);
applyVisualState(transformedLine, transformations[0].logStart + 1);
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await waitFor(() => {
expect(stdout.lastFrame()).toMatchSnapshot();
});
unmount();
});
});
}); });
function clean(str: string | undefined): string { function clean(str: string | undefined): string {
+21 -12
View File
@@ -27,7 +27,7 @@ import type { Config } from '@google/gemini-cli-core';
import { ApprovalMode } from '@google/gemini-cli-core'; import { ApprovalMode } from '@google/gemini-cli-core';
import { import {
parseInputForHighlighting, parseInputForHighlighting,
buildSegmentsForVisualSlice, parseSegmentsFromTokens,
} from '../utils/highlight.js'; } from '../utils/highlight.js';
import { useKittyKeyboardProtocol } from '../hooks/useKittyKeyboardProtocol.js'; import { useKittyKeyboardProtocol } from '../hooks/useKittyKeyboardProtocol.js';
import { import {
@@ -1101,21 +1101,27 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const renderedLine: React.ReactNode[] = []; const renderedLine: React.ReactNode[] = [];
const [logicalLineIdx, logicalStartCol] = mapEntry; const [logicalLineIdx] = mapEntry;
const logicalLine = buffer.lines[logicalLineIdx] || ''; const logicalLine = buffer.lines[logicalLineIdx] || '';
const transformations =
buffer.transformationsByLine[logicalLineIdx] ?? [];
const tokens = parseInputForHighlighting( const tokens = parseInputForHighlighting(
logicalLine, logicalLine,
logicalLineIdx, logicalLineIdx,
transformations,
...(focus && buffer.cursor[0] === logicalLineIdx
? [buffer.cursor[1]]
: []),
); );
const startColInTransformed =
const visualStart = logicalStartCol; buffer.visualToTransformedMap[absoluteVisualIdx] ?? 0;
const visualEnd = logicalStartCol + cpLen(lineText); const visualStartCol = startColInTransformed;
const segments = buildSegmentsForVisualSlice( const visualEndCol = visualStartCol + cpLen(lineText);
const segments = parseSegmentsFromTokens(
tokens, tokens,
visualStart, visualStartCol,
visualEnd, visualEndCol,
); );
let charCount = 0; let charCount = 0;
segments.forEach((seg, segIdx) => { segments.forEach((seg, segIdx) => {
const segLen = cpLen(seg.text); const segLen = cpLen(seg.text);
@@ -1131,7 +1137,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
relativeVisualColForHighlight < segEnd relativeVisualColForHighlight < segEnd
) { ) {
const charToHighlight = cpSlice( const charToHighlight = cpSlice(
seg.text, display,
relativeVisualColForHighlight - segStart, relativeVisualColForHighlight - segStart,
relativeVisualColForHighlight - segStart + 1, relativeVisualColForHighlight - segStart + 1,
); );
@@ -1140,17 +1146,20 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
: charToHighlight; : charToHighlight;
display = display =
cpSlice( cpSlice(
seg.text, display,
0, 0,
relativeVisualColForHighlight - segStart, relativeVisualColForHighlight - segStart,
) + ) +
highlighted + highlighted +
cpSlice( cpSlice(
seg.text, display,
relativeVisualColForHighlight - segStart + 1, relativeVisualColForHighlight - segStart + 1,
); );
} }
charCount = segEnd; charCount = segEnd;
} else {
// Advance the running counter even when not on cursor line
charCount += segLen;
} }
const color = const color =
@@ -32,6 +32,18 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match
git commit -m "feat: add search" in src/app" git commit -m "feat: add search" in src/app"
`; `;
exports[`InputPrompt > image path transformation snapshots > should snapshot collapsed image path 1`] = `
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ > [Image ...reenshot2x.png] │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`InputPrompt > image path transformation snapshots > should snapshot expanded image path when cursor is on it 1`] = `
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ > @/path/to/screenshots/screenshot2x.png │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = ` exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ "╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ > Type your message or @path/to/file │ │ > Type your message or @path/to/file │
@@ -24,6 +24,10 @@ import {
findWordEndInLine, findWordEndInLine,
findNextWordStartInLine, findNextWordStartInLine,
isWordCharStrict, isWordCharStrict,
calculateTransformationsForLine,
calculateTransformedLine,
getTransformUnderCursor,
getTransformedImagePath,
} from './text-buffer.js'; } from './text-buffer.js';
import { cpLen } from '../../utils/textUtils.js'; import { cpLen } from '../../utils/textUtils.js';
@@ -31,6 +35,8 @@ const defaultVisualLayout: VisualLayout = {
visualLines: [''], visualLines: [''],
logicalToVisualMap: [[[0, 0]]], logicalToVisualMap: [[[0, 0]]],
visualToLogicalMap: [[0, 0]], visualToLogicalMap: [[0, 0]],
transformedToLogicalMaps: [[]],
visualToTransformedMap: [],
}; };
const initialState: TextBufferState = { const initialState: TextBufferState = {
@@ -44,6 +50,7 @@ const initialState: TextBufferState = {
selectionAnchor: null, selectionAnchor: null,
viewportWidth: 80, viewportWidth: 80,
viewportHeight: 24, viewportHeight: 24,
transformationsByLine: [[]],
visualLayout: defaultVisualLayout, visualLayout: defaultVisualLayout,
}; };
@@ -2381,3 +2388,182 @@ describe('Unicode helper functions', () => {
}); });
}); });
}); });
describe('Transformation Utilities', () => {
describe('getTransformedImagePath', () => {
it('should transform a simple image path', () => {
expect(getTransformedImagePath('@test.png')).toBe('[Image test.png]');
});
it('should handle paths with directories', () => {
expect(getTransformedImagePath('@path/to/image.jpg')).toBe(
'[Image image.jpg]',
);
});
it('should truncate long filenames', () => {
expect(getTransformedImagePath('@verylongfilename1234567890.png')).toBe(
'[Image ...1234567890.png]',
);
});
it('should handle different image extensions', () => {
expect(getTransformedImagePath('@test.jpg')).toBe('[Image test.jpg]');
expect(getTransformedImagePath('@test.jpeg')).toBe('[Image test.jpeg]');
expect(getTransformedImagePath('@test.gif')).toBe('[Image test.gif]');
expect(getTransformedImagePath('@test.webp')).toBe('[Image test.webp]');
expect(getTransformedImagePath('@test.svg')).toBe('[Image test.svg]');
expect(getTransformedImagePath('@test.bmp')).toBe('[Image test.bmp]');
});
it('should handle POSIX-style forward-slash paths on any platform', () => {
const input = '@C:/Users/foo/screenshots/image2x.png';
expect(getTransformedImagePath(input)).toBe('[Image image2x.png]');
});
it('should handle Windows-style backslash paths on any platform', () => {
const input = '@C:\\Users\\foo\\screenshots\\image2x.png';
expect(getTransformedImagePath(input)).toBe('[Image image2x.png]');
});
it('should handle escaped spaces in paths', () => {
const input = '@path/to/my\\ file.png';
expect(getTransformedImagePath(input)).toBe('[Image my file.png]');
});
});
describe('getTransformationsForLine', () => {
it('should find transformations in a line', () => {
const line = 'Check out @test.png and @another.jpg';
const result = calculateTransformationsForLine(line);
expect(result).toHaveLength(2);
expect(result[0]).toMatchObject({
logicalText: '@test.png',
collapsedText: '[Image test.png]',
});
expect(result[1]).toMatchObject({
logicalText: '@another.jpg',
collapsedText: '[Image another.jpg]',
});
});
it('should handle no transformations', () => {
const line = 'Just some regular text';
const result = calculateTransformationsForLine(line);
expect(result).toEqual([]);
});
it('should handle empty line', () => {
const result = calculateTransformationsForLine('');
expect(result).toEqual([]);
});
it('should keep adjacent image paths as separate transformations', () => {
const line = '@a.png@b.png@c.png';
const result = calculateTransformationsForLine(line);
expect(result).toHaveLength(3);
expect(result[0].logicalText).toBe('@a.png');
expect(result[1].logicalText).toBe('@b.png');
expect(result[2].logicalText).toBe('@c.png');
});
it('should handle multiple transformations in a row', () => {
const line = '@a.png @b.png @c.png';
const result = calculateTransformationsForLine(line);
expect(result).toHaveLength(3);
});
});
describe('getTransformUnderCursor', () => {
const transformations = [
{
logStart: 5,
logEnd: 14,
logicalText: '@test.png',
collapsedText: '[Image @test.png]',
},
{
logStart: 20,
logEnd: 31,
logicalText: '@another.jpg',
collapsedText: '[Image @another.jpg]',
},
];
it('should find transformation when cursor is inside it', () => {
const result = getTransformUnderCursor(0, 7, [transformations]);
expect(result).toEqual(transformations[0]);
});
it('should find transformation when cursor is at start', () => {
const result = getTransformUnderCursor(0, 5, [transformations]);
expect(result).toEqual(transformations[0]);
});
it('should find transformation when cursor is at end', () => {
const result = getTransformUnderCursor(0, 14, [transformations]);
expect(result).toEqual(transformations[0]);
});
it('should return null when cursor is not on a transformation', () => {
const result = getTransformUnderCursor(0, 2, [transformations]);
expect(result).toBeNull();
});
it('should handle empty transformations array', () => {
const result = getTransformUnderCursor(0, 5, []);
expect(result).toBeNull();
});
});
describe('calculateTransformedLine', () => {
it('should transform a line with one transformation', () => {
const line = 'Check out @test.png';
const transformations = calculateTransformationsForLine(line);
const result = calculateTransformedLine(line, 0, [0, 0], transformations);
expect(result.transformedLine).toBe('Check out [Image test.png]');
expect(result.transformedToLogMap).toHaveLength(27); // Length includes all characters in the transformed line
// Test that we have proper mappings
expect(result.transformedToLogMap[0]).toBe(0); // 'C'
expect(result.transformedToLogMap[9]).toBe(9); // ' ' before transformation
});
it('should handle cursor inside transformation', () => {
const line = 'Check out @test.png';
const transformations = calculateTransformationsForLine(line);
// Cursor at '@' (position 10 in the line)
const result = calculateTransformedLine(
line,
0,
[0, 10],
transformations,
);
// Should show full path when cursor is on it
expect(result.transformedLine).toBe('Check out @test.png');
// When expanded, each character maps to itself
expect(result.transformedToLogMap[10]).toBe(10); // '@'
});
it('should handle line with no transformations', () => {
const line = 'Just some text';
const result = calculateTransformedLine(line, 0, [0, 0], []);
expect(result.transformedLine).toBe(line);
// Each visual position should map directly to logical position + trailing
expect(result.transformedToLogMap).toHaveLength(15); // 14 chars + 1 trailing
expect(result.transformedToLogMap[0]).toBe(0);
expect(result.transformedToLogMap[13]).toBe(13);
expect(result.transformedToLogMap[14]).toBe(14); // Trailing position
});
it('should handle empty line', () => {
const result = calculateTransformedLine('', 0, [0, 0], []);
expect(result.transformedLine).toBe('');
expect(result.transformedToLogMap).toEqual([0]); // Just the trailing position
});
});
});
@@ -8,8 +8,9 @@ import { spawnSync } from 'node:child_process';
import fs from 'node:fs'; import fs from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import pathMod from 'node:path'; import pathMod from 'node:path';
import * as path from 'node:path';
import { useState, useCallback, useEffect, useMemo, useReducer } from 'react'; import { useState, useCallback, useEffect, useMemo, useReducer } from 'react';
import { coreEvents, CoreEvent } from '@google/gemini-cli-core'; import { coreEvents, CoreEvent, unescapePath } from '@google/gemini-cli-core';
import { import {
toCodePoints, toCodePoints,
cpLen, cpLen,
@@ -669,13 +670,182 @@ export function logicalPosToOffset(
return offset; return offset;
} }
/**
* Transformations allow for the CLI to render terse representations of things like file paths
* (e.g., "@some/path/to/an/image.png" to "[Image image.png]")
* When the cursor enters a transformed representation, it expands to reveal the logical representation.
* (e.g., "[Image image.png]" to "@some/path/to/an/image.png")
*/
export interface Transformation {
logStart: number;
logEnd: number;
logicalText: string;
collapsedText: string;
}
export const imagePathRegex =
/@((?:\\.|[^\s\r\n\\])+?\.(?:png|jpg|jpeg|gif|webp|svg|bmp))\b/gi;
export function getTransformedImagePath(filePath: string): string {
const raw = filePath;
// Ignore leading @ when stripping directories, but keep it for simple '@file.png'
const withoutAt = raw.startsWith('@') ? raw.slice(1) : raw;
// Unescape the path to handle escaped spaces and other characters
const unescaped = unescapePath(withoutAt);
// Find last directory separator, supporting both POSIX and Windows styles
const lastSepIndex = Math.max(
unescaped.lastIndexOf('/'),
unescaped.lastIndexOf('\\'),
);
// If we saw a separator, take the segment after it; otherwise fall back to the unescaped string
const fileName =
lastSepIndex >= 0 ? unescaped.slice(lastSepIndex + 1) : unescaped;
const extension = path.extname(fileName);
const baseName = path.basename(fileName, extension);
const maxBaseLength = 10;
const truncatedBase =
baseName.length > maxBaseLength
? `...${baseName.slice(-maxBaseLength)}`
: baseName;
return `[Image ${truncatedBase}${extension}]`;
}
export function calculateTransformationsForLine(
line: string,
): Transformation[] {
const transformations: Transformation[] = [];
let match: RegExpExecArray | null;
// Reset regex state to ensure clean matching from start of line
imagePathRegex.lastIndex = 0;
while ((match = imagePathRegex.exec(line)) !== null) {
const logicalText = match[0];
const logStart = cpLen(line.substring(0, match.index));
const logEnd = logStart + cpLen(logicalText);
transformations.push({
logStart,
logEnd,
logicalText,
collapsedText: getTransformedImagePath(logicalText),
});
}
return transformations;
}
export function calculateTransformations(lines: string[]): Transformation[][] {
return lines.map((ln) => calculateTransformationsForLine(ln));
}
export function getTransformUnderCursor(
row: number,
col: number,
spansByLine: Transformation[][],
): Transformation | null {
const spans = spansByLine[row];
if (!spans || spans.length === 0) return null;
for (const span of spans) {
if (col >= span.logStart && col <= span.logEnd) {
return span;
}
if (col < span.logStart) break;
}
return null;
}
export function calculateTransformedLine(
logLine: string,
logIndex: number,
logicalCursor: [number, number],
transformations: Transformation[],
): { transformedLine: string; transformedToLogMap: number[] } {
let transformedLine = '';
const transformedToLogMap: number[] = [];
let lastLogPos = 0;
const cursorIsOnThisLine = logIndex === logicalCursor[0];
const cursorCol = logicalCursor[1];
for (const transform of transformations) {
const textBeforeTransformation = cpSlice(
logLine,
lastLogPos,
transform.logStart,
);
transformedLine += textBeforeTransformation;
for (let i = 0; i < cpLen(textBeforeTransformation); i++) {
transformedToLogMap.push(lastLogPos + i);
}
const isExpanded =
cursorIsOnThisLine &&
cursorCol >= transform.logStart &&
cursorCol <= transform.logEnd;
const transformedText = isExpanded
? transform.logicalText
: transform.collapsedText;
transformedLine += transformedText;
// Map transformed characters back to logical characters
const transformedLen = cpLen(transformedText);
if (isExpanded) {
for (let i = 0; i < transformedLen; i++) {
transformedToLogMap.push(transform.logStart + i);
}
} else {
// Collapsed: distribute transformed positions monotonically across the raw span.
// This preserves ordering across wrapped slices so logicalToVisualMap has
// increasing startColInLogical and visual cursor mapping remains consistent.
const logicalLength = Math.max(0, transform.logEnd - transform.logStart);
for (let i = 0; i < transformedLen; i++) {
// Map the i-th transformed code point into [logStart, logEnd)
const transformationToLogicalOffset =
logicalLength === 0
? 0
: Math.floor((i * logicalLength) / transformedLen);
const transformationToLogicalIndex =
transform.logStart +
Math.min(
transformationToLogicalOffset,
Math.max(logicalLength - 1, 0),
);
transformedToLogMap.push(transformationToLogicalIndex);
}
}
lastLogPos = transform.logEnd;
}
// Append text after last transform
const remainingUntransformedText = cpSlice(logLine, lastLogPos);
transformedLine += remainingUntransformedText;
for (let i = 0; i < cpLen(remainingUntransformedText); i++) {
transformedToLogMap.push(lastLogPos + i);
}
// For a cursor at the very end of the transformed line
transformedToLogMap.push(cpLen(logLine));
return { transformedLine, transformedToLogMap };
}
export interface VisualLayout { export interface VisualLayout {
visualLines: string[]; visualLines: string[];
// For each logical line, an array of [visualLineIndex, startColInLogical] // For each logical line, an array of [visualLineIndex, startColInLogical]
logicalToVisualMap: Array<Array<[number, number]>>; logicalToVisualMap: Array<Array<[number, number]>>;
// For each visual line, its [logicalLineIndex, startColInLogical] // For each visual line, its [logicalLineIndex, startColInLogical]
visualToLogicalMap: Array<[number, number]>; visualToLogicalMap: Array<[number, number]>;
// Image paths are transformed (e.g., "@some/path/to/an/image.png" to "[Image image.png]")
// For each logical line, an array that maps each transformedCol to a logicalCol
transformedToLogicalMaps: number[][];
// For each visual line, its [startColInTransformed]
visualToTransformedMap: number[];
} }
// Calculates the visual wrapping of lines and the mapping between logical and visual coordinates. // Calculates the visual wrapping of lines and the mapping between logical and visual coordinates.
@@ -683,22 +853,34 @@ export interface VisualLayout {
function calculateLayout( function calculateLayout(
logicalLines: string[], logicalLines: string[],
viewportWidth: number, viewportWidth: number,
logicalCursor: [number, number],
): VisualLayout { ): VisualLayout {
const visualLines: string[] = []; const visualLines: string[] = [];
const logicalToVisualMap: Array<Array<[number, number]>> = []; const logicalToVisualMap: Array<Array<[number, number]>> = [];
const visualToLogicalMap: Array<[number, number]> = []; const visualToLogicalMap: Array<[number, number]> = [];
const transformedToLogicalMaps: number[][] = [];
const visualToTransformedMap: number[] = [];
logicalLines.forEach((logLine, logIndex) => { logicalLines.forEach((logLine, logIndex) => {
logicalToVisualMap[logIndex] = []; logicalToVisualMap[logIndex] = [];
if (logLine.length === 0) { const transformations = calculateTransformationsForLine(logLine);
const { transformedLine, transformedToLogMap } = calculateTransformedLine(
logLine,
logIndex,
logicalCursor,
transformations,
);
transformedToLogicalMaps[logIndex] = transformedToLogMap;
if (transformedLine.length === 0) {
// Handle empty logical line // Handle empty logical line
logicalToVisualMap[logIndex].push([visualLines.length, 0]); logicalToVisualMap[logIndex].push([visualLines.length, 0]);
visualToLogicalMap.push([logIndex, 0]); visualToLogicalMap.push([logIndex, 0]);
visualToTransformedMap.push(0);
visualLines.push(''); visualLines.push('');
} else { } else {
// Non-empty logical line // Non-empty logical line
let currentPosInLogLine = 0; // Tracks position within the current logical line (code point index) let currentPosInLogLine = 0; // Tracks position within the current logical line (code point index)
const codePointsInLogLine = toCodePoints(logLine); const codePointsInLogLine = toCodePoints(transformedLine);
while (currentPosInLogLine < codePointsInLogLine.length) { while (currentPosInLogLine < codePointsInLogLine.length) {
let currentChunk = ''; let currentChunk = '';
@@ -787,11 +969,13 @@ function calculateLayout(
numCodePointsInChunk = 1; numCodePointsInChunk = 1;
} }
const logicalStartCol = transformedToLogMap[currentPosInLogLine] ?? 0;
logicalToVisualMap[logIndex].push([ logicalToVisualMap[logIndex].push([
visualLines.length, visualLines.length,
currentPosInLogLine, logicalStartCol,
]); ]);
visualToLogicalMap.push([logIndex, currentPosInLogLine]); visualToLogicalMap.push([logIndex, logicalStartCol]);
visualToTransformedMap.push(currentPosInLogLine);
visualLines.push(currentChunk); visualLines.push(currentChunk);
const logicalStartOfThisChunk = currentPosInLogLine; const logicalStartOfThisChunk = currentPosInLogLine;
@@ -822,6 +1006,7 @@ function calculateLayout(
if (!logicalToVisualMap[0]) logicalToVisualMap[0] = []; if (!logicalToVisualMap[0]) logicalToVisualMap[0] = [];
logicalToVisualMap[0].push([0, 0]); logicalToVisualMap[0].push([0, 0]);
visualToLogicalMap.push([0, 0]); visualToLogicalMap.push([0, 0]);
visualToTransformedMap.push(0);
} }
} }
@@ -829,6 +1014,8 @@ function calculateLayout(
visualLines, visualLines,
logicalToVisualMap, logicalToVisualMap,
visualToLogicalMap, visualToLogicalMap,
transformedToLogicalMaps,
visualToTransformedMap,
}; };
} }
@@ -838,7 +1025,7 @@ function calculateVisualCursorFromLayout(
layout: VisualLayout, layout: VisualLayout,
logicalCursor: [number, number], logicalCursor: [number, number],
): [number, number] { ): [number, number] {
const { logicalToVisualMap, visualLines } = layout; const { logicalToVisualMap, visualLines, transformedToLogicalMaps } = layout;
const [logicalRow, logicalCol] = logicalCursor; const [logicalRow, logicalCol] = logicalCursor;
const segmentsForLogicalLine = logicalToVisualMap[logicalRow]; const segmentsForLogicalLine = logicalToVisualMap[logicalRow];
@@ -873,14 +1060,35 @@ function calculateVisualCursorFromLayout(
const [visualRow, startColInLogical] = const [visualRow, startColInLogical] =
segmentsForLogicalLine[targetSegmentIndex]; segmentsForLogicalLine[targetSegmentIndex];
const visualCol = logicalCol - startColInLogical;
// The visual column should not exceed the length of the visual line. // Find the coordinates in transformed space in order to conver to visual
const transformedToLogicalMap = transformedToLogicalMaps[logicalRow] ?? [];
let transformedCol = 0;
for (let i = 0; i < transformedToLogicalMap.length; i++) {
if (transformedToLogicalMap[i] > logicalCol) {
transformedCol = Math.max(0, i - 1);
break;
}
if (i === transformedToLogicalMap.length - 1) {
transformedCol = transformedToLogicalMap.length - 1;
}
}
let startColInTransformed = 0;
while (
startColInTransformed < transformedToLogicalMap.length &&
transformedToLogicalMap[startColInTransformed] < startColInLogical
) {
startColInTransformed++;
}
const clampedTransformedCol = Math.min(
transformedCol,
Math.max(0, transformedToLogicalMap.length - 1),
);
const visualCol = clampedTransformedCol - startColInTransformed;
const clampedVisualCol = Math.min( const clampedVisualCol = Math.min(
visualCol, Math.max(visualCol, 0),
cpLen(visualLines[visualRow] ?? ''), cpLen(visualLines[visualRow] ?? ''),
); );
return [visualRow, clampedVisualCol]; return [visualRow, clampedVisualCol];
} }
@@ -890,6 +1098,7 @@ export interface TextBufferState {
lines: string[]; lines: string[];
cursorRow: number; cursorRow: number;
cursorCol: number; cursorCol: number;
transformationsByLine: Transformation[][];
preferredCol: number | null; // This is the logical character offset in the visual line preferredCol: number | null; // This is the logical character offset in the visual line
undoStack: UndoHistoryEntry[]; undoStack: UndoHistoryEntry[];
redoStack: UndoHistoryEntry[]; redoStack: UndoHistoryEntry[];
@@ -1210,15 +1419,27 @@ function textBufferReducerLogic(
} }
if (visualToLogicalMap[newVisualRow]) { if (visualToLogicalMap[newVisualRow]) {
const [logRow, logStartCol] = visualToLogicalMap[newVisualRow]; const [logRow, logicalStartCol] = visualToLogicalMap[newVisualRow];
const transformedToLogicalMap =
visualLayout.transformedToLogicalMaps?.[logRow] ?? [];
let transformedStartCol = 0;
while (
transformedStartCol < transformedToLogicalMap.length &&
transformedToLogicalMap[transformedStartCol] < logicalStartCol
) {
transformedStartCol++;
}
const clampedTransformedCol = Math.min(
transformedStartCol + newVisualCol,
Math.max(0, transformedToLogicalMap.length - 1),
);
const newLogicalCol =
transformedToLogicalMap[clampedTransformedCol] ??
cpLen(lines[logRow] ?? '');
return { return {
...state, ...state,
cursorRow: logRow, cursorRow: logRow,
cursorCol: clamp( cursorCol: newLogicalCol,
logStartCol + newVisualCol,
0,
cpLen(lines[logRow] ?? ''),
),
preferredCol: newPreferredCol, preferredCol: newPreferredCol,
}; };
} }
@@ -1543,13 +1764,43 @@ export function textBufferReducer(
): TextBufferState { ): TextBufferState {
const newState = textBufferReducerLogic(state, action, options); const newState = textBufferReducerLogic(state, action, options);
const newTransformedLines =
newState.lines !== state.lines
? calculateTransformations(newState.lines)
: state.transformationsByLine;
const oldTransform = getTransformUnderCursor(
state.cursorRow,
state.cursorCol,
state.transformationsByLine,
);
const newTransform = getTransformUnderCursor(
newState.cursorRow,
newState.cursorCol,
newTransformedLines,
);
const oldInside = oldTransform !== null;
const newInside = newTransform !== null;
const movedBetweenTransforms =
oldTransform !== newTransform &&
(oldTransform !== null || newTransform !== null);
if ( if (
newState.lines !== state.lines || newState.lines !== state.lines ||
newState.viewportWidth !== state.viewportWidth newState.viewportWidth !== state.viewportWidth ||
oldInside !== newInside ||
movedBetweenTransforms
) { ) {
const shouldResetPreferred =
oldInside !== newInside || movedBetweenTransforms;
return { return {
...newState, ...newState,
visualLayout: calculateLayout(newState.lines, newState.viewportWidth), preferredCol: shouldResetPreferred ? null : newState.preferredCol,
visualLayout: calculateLayout(newState.lines, newState.viewportWidth, [
newState.cursorRow,
newState.cursorCol,
]),
transformationsByLine: newTransformedLines,
}; };
} }
@@ -1576,14 +1827,19 @@ export function useTextBuffer({
lines.length === 0 ? [''] : lines, lines.length === 0 ? [''] : lines,
initialCursorOffset, initialCursorOffset,
); );
const transformationsByLine = calculateTransformations(
lines.length === 0 ? [''] : lines,
);
const visualLayout = calculateLayout( const visualLayout = calculateLayout(
lines.length === 0 ? [''] : lines, lines.length === 0 ? [''] : lines,
viewport.width, viewport.width,
[initialCursorRow, initialCursorCol],
); );
return { return {
lines: lines.length === 0 ? [''] : lines, lines: lines.length === 0 ? [''] : lines,
cursorRow: initialCursorRow, cursorRow: initialCursorRow,
cursorCol: initialCursorCol, cursorCol: initialCursorCol,
transformationsByLine,
preferredCol: null, preferredCol: null,
undoStack: [], undoStack: [],
redoStack: [], redoStack: [],
@@ -1607,6 +1863,7 @@ export function useTextBuffer({
preferredCol, preferredCol,
selectionAnchor, selectionAnchor,
visualLayout, visualLayout,
transformationsByLine,
} = state; } = state;
const text = useMemo(() => lines.join('\n'), [lines]); const text = useMemo(() => lines.join('\n'), [lines]);
@@ -1616,7 +1873,12 @@ export function useTextBuffer({
[visualLayout, cursorRow, cursorCol], [visualLayout, cursorRow, cursorCol],
); );
const { visualLines, visualToLogicalMap } = visualLayout; const {
visualLines,
visualToLogicalMap,
transformedToLogicalMaps,
visualToTransformedMap,
} = visualLayout;
const [visualScrollRow, setVisualScrollRow] = useState<number>(0); const [visualScrollRow, setVisualScrollRow] = useState<number>(0);
@@ -2035,7 +2297,12 @@ export function useTextBuffer({
const moveToVisualPosition = useCallback( const moveToVisualPosition = useCallback(
(visRow: number, visCol: number): void => { (visRow: number, visCol: number): void => {
const { visualLines, visualToLogicalMap } = visualLayout; const {
visualLines,
visualToLogicalMap,
transformedToLogicalMaps,
visualToTransformedMap,
} = visualLayout;
// Clamp visRow to valid range // Clamp visRow to valid range
const clampedVisRow = Math.max( const clampedVisRow = Math.max(
0, 0,
@@ -2044,8 +2311,15 @@ export function useTextBuffer({
const visualLine = visualLines[clampedVisRow] || ''; const visualLine = visualLines[clampedVisRow] || '';
if (visualToLogicalMap[clampedVisRow]) { if (visualToLogicalMap[clampedVisRow]) {
const [logRow, logStartCol] = visualToLogicalMap[clampedVisRow]; const [logRow] = visualToLogicalMap[clampedVisRow];
const transformedToLogicalMap =
transformedToLogicalMaps?.[logRow] ?? [];
// Where does this visual line begin within the transformed line?
const startColInTransformed =
visualToTransformedMap?.[clampedVisRow] ?? 0;
// Handle wide characters: convert visual X position to character offset
const codePoints = toCodePoints(visualLine); const codePoints = toCodePoints(visualLine);
let currentVisX = 0; let currentVisX = 0;
let charOffset = 0; let charOffset = 0;
@@ -2067,8 +2341,15 @@ export function useTextBuffer({
// Clamp charOffset to length // Clamp charOffset to length
charOffset = Math.min(charOffset, codePoints.length); charOffset = Math.min(charOffset, codePoints.length);
// Map character offset through transformations to get logical position
const transformedCol = Math.min(
startColInTransformed + charOffset,
Math.max(0, transformedToLogicalMap.length - 1),
);
const newCursorRow = logRow; const newCursorRow = logRow;
const newCursorCol = logStartCol + charOffset; const newCursorCol =
transformedToLogicalMap[transformedCol] ?? cpLen(lines[logRow] ?? '');
dispatch({ dispatch({
type: 'set_cursor', type: 'set_cursor',
@@ -2080,7 +2361,7 @@ export function useTextBuffer({
}); });
} }
}, },
[visualLayout], [visualLayout, lines],
); );
const getOffset = useCallback( const getOffset = useCallback(
@@ -2101,7 +2382,9 @@ export function useTextBuffer({
visualCursor, visualCursor,
visualScrollRow, visualScrollRow,
visualToLogicalMap, visualToLogicalMap,
transformedToLogicalMaps,
visualToTransformedMap,
transformationsByLine,
setText, setText,
insert, insert,
newline, newline,
@@ -2167,6 +2450,10 @@ export function useTextBuffer({
renderedVisualLines, renderedVisualLines,
visualCursor, visualCursor,
visualScrollRow, visualScrollRow,
visualToLogicalMap,
transformedToLogicalMaps,
visualToTransformedMap,
transformationsByLine,
setText, setText,
insert, insert,
newline, newline,
@@ -2218,7 +2505,6 @@ export function useTextBuffer({
vimMoveToLastLine, vimMoveToLastLine,
vimMoveToLine, vimMoveToLine,
vimEscapeInsertMode, vimEscapeInsertMode,
visualToLogicalMap,
], ],
); );
return returnValue; return returnValue;
@@ -2249,6 +2535,18 @@ export interface TextBuffer {
* begins within the logical buffer. Indices are code-point based. * begins within the logical buffer. Indices are code-point based.
*/ */
visualToLogicalMap: Array<[number, number]>; visualToLogicalMap: Array<[number, number]>;
/**
* For each logical line, an array mapping transformed positions (in the transformed
* line) back to logical column indices.
*/
transformedToLogicalMaps: number[][];
/**
* For each visual line (absolute index across all visual lines), the start index
* within that logical line's transformed content.
*/
visualToTransformedMap: number[];
/** Cached transformations per logical line */
transformationsByLine: Transformation[][];
// Actions // Actions
@@ -12,6 +12,8 @@ const defaultVisualLayout: VisualLayout = {
visualLines: [''], visualLines: [''],
logicalToVisualMap: [[[0, 0]]], logicalToVisualMap: [[[0, 0]]],
visualToLogicalMap: [[0, 0]], visualToLogicalMap: [[0, 0]],
transformedToLogicalMaps: [[]],
visualToTransformedMap: [],
}; };
// Helper to create test state // Helper to create test state
@@ -30,6 +32,7 @@ const createTestState = (
selectionAnchor: null, selectionAnchor: null,
viewportWidth: 80, viewportWidth: 80,
viewportHeight: 24, viewportHeight: 24,
transformationsByLine: [[]],
visualLayout: defaultVisualLayout, visualLayout: defaultVisualLayout,
}); });
+7 -1
View File
@@ -59,15 +59,17 @@ const createMockTextBufferState = (
selectionAnchor: null, selectionAnchor: null,
viewportWidth: 80, viewportWidth: 80,
viewportHeight: 24, viewportHeight: 24,
transformationsByLine: lines.map(() => []),
visualLayout: { visualLayout: {
visualLines: lines, visualLines: lines,
logicalToVisualMap: lines.map((_, i) => [[i, 0]]), logicalToVisualMap: lines.map((_, i) => [[i, 0]]),
visualToLogicalMap: lines.map((_, i) => [i, 0]), visualToLogicalMap: lines.map((_, i) => [i, 0]),
transformedToLogicalMaps: lines.map(() => []),
visualToTransformedMap: [],
}, },
...partial, ...partial,
}; };
}; };
// Test constants // Test constants
const TEST_SEQUENCES = { const TEST_SEQUENCES = {
ESCAPE: createKey({ sequence: '\u001b', name: 'escape' }), ESCAPE: createKey({ sequence: '\u001b', name: 'escape' }),
@@ -174,6 +176,10 @@ describe('useVim hook', () => {
cursorState.pos = [row, col - 1]; cursorState.pos = [row, col - 1];
} }
}), }),
// Additional properties for transformations
transformedToLogicalMaps: lines.map(() => []),
visualToTransformedMap: [],
transformationsByLine: lines.map(() => []),
}; };
}; };
+100
View File
@@ -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' });
});
});
+51 -27
View File
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import type { Transformation } from '../components/shared/text-buffer.js';
import { cpLen, cpSlice } from './textUtils.js'; import { cpLen, cpSlice } from './textUtils.js';
export type HighlightToken = { export type HighlightToken = {
@@ -20,57 +21,81 @@ const HIGHLIGHT_REGEX = /(^\/[a-zA-Z0-9_-]+|@(?:\\ |[^,\s;!?()[\]{}])+)/g;
export function parseInputForHighlighting( export function parseInputForHighlighting(
text: string, text: string,
index: number, index: number,
transformations: Transformation[] = [],
cursorCol?: number,
): readonly HighlightToken[] { ): readonly HighlightToken[] {
HIGHLIGHT_REGEX.lastIndex = 0;
if (!text) { if (!text) {
return [{ text: '', type: 'default' }]; return [{ text: '', type: 'default' }];
} }
const parseUntransformedInput = (text: string): HighlightToken[] => {
const tokens: HighlightToken[] = []; const tokens: HighlightToken[] = [];
let lastIndex = 0; if (!text) return tokens;
let match;
HIGHLIGHT_REGEX.lastIndex = 0;
let last = 0;
let match: RegExpExecArray | null;
while ((match = HIGHLIGHT_REGEX.exec(text)) !== null) { while ((match = HIGHLIGHT_REGEX.exec(text)) !== null) {
const [fullMatch] = match; const [fullMatch] = match;
const matchIndex = match.index; const matchIndex = match.index;
// Add the text before the match as a default token if (matchIndex > last) {
if (matchIndex > lastIndex) { tokens.push({ text: text.slice(last, matchIndex), type: 'default' });
tokens.push({
text: text.slice(lastIndex, matchIndex),
type: 'default',
});
} }
// Add the matched token
const type = fullMatch.startsWith('/') ? 'command' : 'file'; const type = fullMatch.startsWith('/') ? 'command' : 'file';
// Only highlight slash commands if the index is 0.
if (type === 'command' && index !== 0) { if (type === 'command' && index !== 0) {
tokens.push({ tokens.push({ text: fullMatch, type: 'default' });
text: fullMatch,
type: 'default',
});
} else { } else {
tokens.push({ tokens.push({ text: fullMatch, type });
text: fullMatch,
type,
});
} }
lastIndex = matchIndex + fullMatch.length; last = matchIndex + fullMatch.length;
} }
// Add any remaining text after the last match if (last < text.length) {
if (lastIndex < text.length) { tokens.push({ text: text.slice(last), type: 'default' });
tokens.push({
text: text.slice(lastIndex),
type: 'default',
});
} }
return tokens; 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 =
typeof cursorCol === 'number' &&
cursorCol >= transformation.logStart &&
cursorCol <= transformation.logEnd;
const transformationText = isCursorInside
? transformation.logicalText
: transformation.collapsedText;
tokens.push({ text: transformationText, type: 'file' });
column = transformation.logEnd;
} }
export function buildSegmentsForVisualSlice( const textAfterFinalTransformation = cpSlice(text, column);
tokens.push(...parseUntransformedInput(textAfterFinalTransformation));
return tokens;
}
export function parseSegmentsFromTokens(
tokens: readonly HighlightToken[], tokens: readonly HighlightToken[],
sliceStart: number, sliceStart: number,
sliceEnd: number, sliceEnd: number,
@@ -102,6 +127,5 @@ export function buildSegmentsForVisualSlice(
tokenCpStart += tokenLen; tokenCpStart += tokenLen;
} }
return segments; return segments;
} }