diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx
index 421b8b3312..0b48366d08 100644
--- a/packages/cli/src/ui/components/InputPrompt.test.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.test.tsx
@@ -10,6 +10,10 @@ import { act } from 'react';
import type { InputPromptProps } from './InputPrompt.js';
import { InputPrompt } from './InputPrompt.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 { ApprovalMode } from '@google/gemini-cli-core';
import * as path from 'node:path';
@@ -158,6 +162,8 @@ describe('InputPrompt', () => {
deleteWordLeft: vi.fn(),
deleteWordRight: vi.fn(),
visualToLogicalMap: [[0, 0]],
+ visualToTransformedMap: [0],
+ transformationsByLine: [],
getOffset: vi.fn().mockReturnValue(0),
} 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ await waitFor(() => {
+ expect(stdout.lastFrame()).toMatchSnapshot();
+ });
+ unmount();
+ });
+ });
});
function clean(str: string | undefined): string {
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
index 9ad465f8e6..5aeb0078ab 100644
--- a/packages/cli/src/ui/components/InputPrompt.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -27,7 +27,7 @@ import type { Config } from '@google/gemini-cli-core';
import { ApprovalMode } from '@google/gemini-cli-core';
import {
parseInputForHighlighting,
- buildSegmentsForVisualSlice,
+ parseSegmentsFromTokens,
} from '../utils/highlight.js';
import { useKittyKeyboardProtocol } from '../hooks/useKittyKeyboardProtocol.js';
import {
@@ -1101,21 +1101,27 @@ export const InputPrompt: React.FC = ({
const renderedLine: React.ReactNode[] = [];
- const [logicalLineIdx, logicalStartCol] = mapEntry;
+ const [logicalLineIdx] = mapEntry;
const logicalLine = buffer.lines[logicalLineIdx] || '';
+ const transformations =
+ buffer.transformationsByLine[logicalLineIdx] ?? [];
const tokens = parseInputForHighlighting(
logicalLine,
logicalLineIdx,
+ transformations,
+ ...(focus && buffer.cursor[0] === logicalLineIdx
+ ? [buffer.cursor[1]]
+ : []),
);
-
- const visualStart = logicalStartCol;
- const visualEnd = logicalStartCol + cpLen(lineText);
- const segments = buildSegmentsForVisualSlice(
+ const startColInTransformed =
+ buffer.visualToTransformedMap[absoluteVisualIdx] ?? 0;
+ const visualStartCol = startColInTransformed;
+ const visualEndCol = visualStartCol + cpLen(lineText);
+ const segments = parseSegmentsFromTokens(
tokens,
- visualStart,
- visualEnd,
+ visualStartCol,
+ visualEndCol,
);
-
let charCount = 0;
segments.forEach((seg, segIdx) => {
const segLen = cpLen(seg.text);
@@ -1131,7 +1137,7 @@ export const InputPrompt: React.FC = ({
relativeVisualColForHighlight < segEnd
) {
const charToHighlight = cpSlice(
- seg.text,
+ display,
relativeVisualColForHighlight - segStart,
relativeVisualColForHighlight - segStart + 1,
);
@@ -1140,17 +1146,20 @@ export const InputPrompt: React.FC = ({
: charToHighlight;
display =
cpSlice(
- seg.text,
+ display,
0,
relativeVisualColForHighlight - segStart,
) +
highlighted +
cpSlice(
- seg.text,
+ display,
relativeVisualColForHighlight - segStart + 1,
);
}
charCount = segEnd;
+ } else {
+ // Advance the running counter even when not on cursor line
+ charCount += segLen;
}
const color =
diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap
index 8391f25e88..9766c88b7d 100644
--- a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap
@@ -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"
`;
+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`] = `
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ > Type your message or @path/to/file │
diff --git a/packages/cli/src/ui/components/shared/text-buffer.test.ts b/packages/cli/src/ui/components/shared/text-buffer.test.ts
index f26608aabe..f127f28051 100644
--- a/packages/cli/src/ui/components/shared/text-buffer.test.ts
+++ b/packages/cli/src/ui/components/shared/text-buffer.test.ts
@@ -24,6 +24,10 @@ import {
findWordEndInLine,
findNextWordStartInLine,
isWordCharStrict,
+ calculateTransformationsForLine,
+ calculateTransformedLine,
+ getTransformUnderCursor,
+ getTransformedImagePath,
} from './text-buffer.js';
import { cpLen } from '../../utils/textUtils.js';
@@ -31,6 +35,8 @@ const defaultVisualLayout: VisualLayout = {
visualLines: [''],
logicalToVisualMap: [[[0, 0]]],
visualToLogicalMap: [[0, 0]],
+ transformedToLogicalMaps: [[]],
+ visualToTransformedMap: [],
};
const initialState: TextBufferState = {
@@ -44,6 +50,7 @@ const initialState: TextBufferState = {
selectionAnchor: null,
viewportWidth: 80,
viewportHeight: 24,
+ transformationsByLine: [[]],
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
+ });
+ });
+});
diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts
index 3c897d5351..637a684899 100644
--- a/packages/cli/src/ui/components/shared/text-buffer.ts
+++ b/packages/cli/src/ui/components/shared/text-buffer.ts
@@ -8,8 +8,9 @@ import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import pathMod from 'node:path';
+import * as path from 'node:path';
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 {
toCodePoints,
cpLen,
@@ -669,13 +670,182 @@ export function logicalPosToOffset(
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 {
visualLines: string[];
// For each logical line, an array of [visualLineIndex, startColInLogical]
logicalToVisualMap: Array>;
// For each visual line, its [logicalLineIndex, startColInLogical]
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.
@@ -683,22 +853,34 @@ export interface VisualLayout {
function calculateLayout(
logicalLines: string[],
viewportWidth: number,
+ logicalCursor: [number, number],
): VisualLayout {
const visualLines: string[] = [];
const logicalToVisualMap: Array> = [];
const visualToLogicalMap: Array<[number, number]> = [];
+ const transformedToLogicalMaps: number[][] = [];
+ const visualToTransformedMap: number[] = [];
logicalLines.forEach((logLine, 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
logicalToVisualMap[logIndex].push([visualLines.length, 0]);
visualToLogicalMap.push([logIndex, 0]);
+ visualToTransformedMap.push(0);
visualLines.push('');
} else {
// Non-empty logical line
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) {
let currentChunk = '';
@@ -787,11 +969,13 @@ function calculateLayout(
numCodePointsInChunk = 1;
}
+ const logicalStartCol = transformedToLogMap[currentPosInLogLine] ?? 0;
logicalToVisualMap[logIndex].push([
visualLines.length,
- currentPosInLogLine,
+ logicalStartCol,
]);
- visualToLogicalMap.push([logIndex, currentPosInLogLine]);
+ visualToLogicalMap.push([logIndex, logicalStartCol]);
+ visualToTransformedMap.push(currentPosInLogLine);
visualLines.push(currentChunk);
const logicalStartOfThisChunk = currentPosInLogLine;
@@ -822,6 +1006,7 @@ function calculateLayout(
if (!logicalToVisualMap[0]) logicalToVisualMap[0] = [];
logicalToVisualMap[0].push([0, 0]);
visualToLogicalMap.push([0, 0]);
+ visualToTransformedMap.push(0);
}
}
@@ -829,6 +1014,8 @@ function calculateLayout(
visualLines,
logicalToVisualMap,
visualToLogicalMap,
+ transformedToLogicalMaps,
+ visualToTransformedMap,
};
}
@@ -838,7 +1025,7 @@ function calculateVisualCursorFromLayout(
layout: VisualLayout,
logicalCursor: [number, number],
): [number, number] {
- const { logicalToVisualMap, visualLines } = layout;
+ const { logicalToVisualMap, visualLines, transformedToLogicalMaps } = layout;
const [logicalRow, logicalCol] = logicalCursor;
const segmentsForLogicalLine = logicalToVisualMap[logicalRow];
@@ -873,14 +1060,35 @@ function calculateVisualCursorFromLayout(
const [visualRow, startColInLogical] =
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(
- visualCol,
+ Math.max(visualCol, 0),
cpLen(visualLines[visualRow] ?? ''),
);
-
return [visualRow, clampedVisualCol];
}
@@ -890,6 +1098,7 @@ export interface TextBufferState {
lines: string[];
cursorRow: number;
cursorCol: number;
+ transformationsByLine: Transformation[][];
preferredCol: number | null; // This is the logical character offset in the visual line
undoStack: UndoHistoryEntry[];
redoStack: UndoHistoryEntry[];
@@ -1210,15 +1419,27 @@ function textBufferReducerLogic(
}
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 {
...state,
cursorRow: logRow,
- cursorCol: clamp(
- logStartCol + newVisualCol,
- 0,
- cpLen(lines[logRow] ?? ''),
- ),
+ cursorCol: newLogicalCol,
preferredCol: newPreferredCol,
};
}
@@ -1543,13 +1764,43 @@ export function textBufferReducer(
): TextBufferState {
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 (
newState.lines !== state.lines ||
- newState.viewportWidth !== state.viewportWidth
+ newState.viewportWidth !== state.viewportWidth ||
+ oldInside !== newInside ||
+ movedBetweenTransforms
) {
+ const shouldResetPreferred =
+ oldInside !== newInside || movedBetweenTransforms;
return {
...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,
initialCursorOffset,
);
+ const transformationsByLine = calculateTransformations(
+ lines.length === 0 ? [''] : lines,
+ );
const visualLayout = calculateLayout(
lines.length === 0 ? [''] : lines,
viewport.width,
+ [initialCursorRow, initialCursorCol],
);
return {
lines: lines.length === 0 ? [''] : lines,
cursorRow: initialCursorRow,
cursorCol: initialCursorCol,
+ transformationsByLine,
preferredCol: null,
undoStack: [],
redoStack: [],
@@ -1607,6 +1863,7 @@ export function useTextBuffer({
preferredCol,
selectionAnchor,
visualLayout,
+ transformationsByLine,
} = state;
const text = useMemo(() => lines.join('\n'), [lines]);
@@ -1616,7 +1873,12 @@ export function useTextBuffer({
[visualLayout, cursorRow, cursorCol],
);
- const { visualLines, visualToLogicalMap } = visualLayout;
+ const {
+ visualLines,
+ visualToLogicalMap,
+ transformedToLogicalMaps,
+ visualToTransformedMap,
+ } = visualLayout;
const [visualScrollRow, setVisualScrollRow] = useState(0);
@@ -2035,7 +2297,12 @@ export function useTextBuffer({
const moveToVisualPosition = useCallback(
(visRow: number, visCol: number): void => {
- const { visualLines, visualToLogicalMap } = visualLayout;
+ const {
+ visualLines,
+ visualToLogicalMap,
+ transformedToLogicalMaps,
+ visualToTransformedMap,
+ } = visualLayout;
// Clamp visRow to valid range
const clampedVisRow = Math.max(
0,
@@ -2044,8 +2311,15 @@ export function useTextBuffer({
const visualLine = visualLines[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);
let currentVisX = 0;
let charOffset = 0;
@@ -2067,8 +2341,15 @@ export function useTextBuffer({
// Clamp charOffset to 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 newCursorCol = logStartCol + charOffset;
+ const newCursorCol =
+ transformedToLogicalMap[transformedCol] ?? cpLen(lines[logRow] ?? '');
dispatch({
type: 'set_cursor',
@@ -2080,7 +2361,7 @@ export function useTextBuffer({
});
}
},
- [visualLayout],
+ [visualLayout, lines],
);
const getOffset = useCallback(
@@ -2101,7 +2382,9 @@ export function useTextBuffer({
visualCursor,
visualScrollRow,
visualToLogicalMap,
-
+ transformedToLogicalMaps,
+ visualToTransformedMap,
+ transformationsByLine,
setText,
insert,
newline,
@@ -2167,6 +2450,10 @@ export function useTextBuffer({
renderedVisualLines,
visualCursor,
visualScrollRow,
+ visualToLogicalMap,
+ transformedToLogicalMaps,
+ visualToTransformedMap,
+ transformationsByLine,
setText,
insert,
newline,
@@ -2218,7 +2505,6 @@ export function useTextBuffer({
vimMoveToLastLine,
vimMoveToLine,
vimEscapeInsertMode,
- visualToLogicalMap,
],
);
return returnValue;
@@ -2249,6 +2535,18 @@ export interface TextBuffer {
* begins within the logical buffer. Indices are code-point based.
*/
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
diff --git a/packages/cli/src/ui/components/shared/vim-buffer-actions.test.ts b/packages/cli/src/ui/components/shared/vim-buffer-actions.test.ts
index 9d163b079b..d6778c16a3 100644
--- a/packages/cli/src/ui/components/shared/vim-buffer-actions.test.ts
+++ b/packages/cli/src/ui/components/shared/vim-buffer-actions.test.ts
@@ -12,6 +12,8 @@ const defaultVisualLayout: VisualLayout = {
visualLines: [''],
logicalToVisualMap: [[[0, 0]]],
visualToLogicalMap: [[0, 0]],
+ transformedToLogicalMaps: [[]],
+ visualToTransformedMap: [],
};
// Helper to create test state
@@ -30,6 +32,7 @@ const createTestState = (
selectionAnchor: null,
viewportWidth: 80,
viewportHeight: 24,
+ transformationsByLine: [[]],
visualLayout: defaultVisualLayout,
});
diff --git a/packages/cli/src/ui/hooks/vim.test.tsx b/packages/cli/src/ui/hooks/vim.test.tsx
index 3f6ae1f90b..a7931f0733 100644
--- a/packages/cli/src/ui/hooks/vim.test.tsx
+++ b/packages/cli/src/ui/hooks/vim.test.tsx
@@ -59,15 +59,17 @@ const createMockTextBufferState = (
selectionAnchor: null,
viewportWidth: 80,
viewportHeight: 24,
+ transformationsByLine: lines.map(() => []),
visualLayout: {
visualLines: lines,
logicalToVisualMap: lines.map((_, i) => [[i, 0]]),
visualToLogicalMap: lines.map((_, i) => [i, 0]),
+ transformedToLogicalMaps: lines.map(() => []),
+ visualToTransformedMap: [],
},
...partial,
};
};
-
// Test constants
const TEST_SEQUENCES = {
ESCAPE: createKey({ sequence: '\u001b', name: 'escape' }),
@@ -174,6 +176,10 @@ describe('useVim hook', () => {
cursorState.pos = [row, col - 1];
}
}),
+ // Additional properties for transformations
+ transformedToLogicalMaps: lines.map(() => []),
+ visualToTransformedMap: [],
+ transformationsByLine: lines.map(() => []),
};
};
diff --git a/packages/cli/src/ui/utils/highlight.test.ts b/packages/cli/src/ui/utils/highlight.test.ts
index 8d4c5ce620..ba2cc09eea 100644
--- a/packages/cli/src/ui/utils/highlight.test.ts
+++ b/packages/cli/src/ui/utils/highlight.test.ts
@@ -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' });
+ });
+});
diff --git a/packages/cli/src/ui/utils/highlight.ts b/packages/cli/src/ui/utils/highlight.ts
index 19866b330a..475854a3dd 100644
--- a/packages/cli/src/ui/utils/highlight.ts
+++ b/packages/cli/src/ui/utils/highlight.ts
@@ -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;
}