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