feat: add double-click to expand/collapse large paste placeholders (#17471)

This commit is contained in:
Jack Wotherspoon
2026-01-26 21:59:09 -05:00
committed by GitHub
parent 5cf06503c8
commit a79051d9f8
11 changed files with 1024 additions and 61 deletions

View File

@@ -586,6 +586,7 @@ interface UndoHistoryEntry {
cursorRow: number;
cursorCol: number;
pastedContent: Record<string, string>;
expandedPasteInfo: Map<string, ExpandedPasteInfo>;
}
function calculateInitialCursorPosition(
@@ -814,6 +815,110 @@ export function getTransformUnderCursor(
return null;
}
export interface ExpandedPasteInfo {
startLine: number;
lineCount: number;
prefix: string;
suffix: string;
}
/**
* Check if a line index falls within an expanded paste region.
* Returns the paste placeholder ID if found, null otherwise.
*/
export function getExpandedPasteAtLine(
lineIndex: number,
expandedPasteInfo: Map<string, ExpandedPasteInfo>,
): string | null {
for (const [id, info] of expandedPasteInfo) {
if (
lineIndex >= info.startLine &&
lineIndex < info.startLine + info.lineCount
) {
return id;
}
}
return null;
}
/**
* Surgery for expanded paste regions when lines are added or removed.
* Adjusts startLine indices and detaches any region that is partially or fully deleted.
*/
export function shiftExpandedRegions(
expandedPasteInfo: Map<string, ExpandedPasteInfo>,
changeStartLine: number,
lineDelta: number,
changeEndLine?: number, // Inclusive
): {
newInfo: Map<string, ExpandedPasteInfo>;
detachedIds: Set<string>;
} {
const newInfo = new Map<string, ExpandedPasteInfo>();
const detachedIds = new Set<string>();
if (expandedPasteInfo.size === 0) return { newInfo, detachedIds };
const effectiveEndLine = changeEndLine ?? changeStartLine;
for (const [id, info] of expandedPasteInfo) {
const infoEndLine = info.startLine + info.lineCount - 1;
// 1. Check for overlap/intersection with the changed range
const isOverlapping =
changeStartLine <= infoEndLine && effectiveEndLine >= info.startLine;
if (isOverlapping) {
// If the change is a deletion (lineDelta < 0) that touches this region, we detach.
// If it's an insertion, we only detach if it's a multi-line insertion (lineDelta > 0)
// that isn't at the very start of the region (which would shift it).
// Regular character typing (lineDelta === 0) does NOT detach.
if (
lineDelta < 0 ||
(lineDelta > 0 &&
changeStartLine > info.startLine &&
changeStartLine <= infoEndLine)
) {
detachedIds.add(id);
continue; // Detach by not adding to newInfo
}
}
// 2. Shift regions that start at or after the change point
if (info.startLine >= changeStartLine) {
newInfo.set(id, {
...info,
startLine: info.startLine + lineDelta,
});
} else {
newInfo.set(id, info);
}
}
return { newInfo, detachedIds };
}
/**
* Detach any expanded paste region if the cursor is within it.
* This converts the expanded content to regular text that can no longer be collapsed.
* Returns the state unchanged if cursor is not in an expanded region.
*/
export function detachExpandedPaste(state: TextBufferState): TextBufferState {
const expandedId = getExpandedPasteAtLine(
state.cursorRow,
state.expandedPasteInfo,
);
if (!expandedId) return state;
const newExpandedInfo = new Map(state.expandedPasteInfo);
newExpandedInfo.delete(expandedId);
const { [expandedId]: _, ...newPastedContent } = state.pastedContent;
return {
...state,
expandedPasteInfo: newExpandedInfo,
pastedContent: newPastedContent,
};
}
/**
* Represents an atomic placeholder that should be deleted as a unit.
* Extensible to support future placeholder types.
@@ -1272,16 +1377,18 @@ export interface TextBufferState {
viewportHeight: number;
visualLayout: VisualLayout;
pastedContent: Record<string, string>;
expandedPasteInfo: Map<string, ExpandedPasteInfo>;
}
const historyLimit = 100;
export const pushUndo = (currentState: TextBufferState): TextBufferState => {
const snapshot = {
const snapshot: UndoHistoryEntry = {
lines: [...currentState.lines],
cursorRow: currentState.cursorRow,
cursorCol: currentState.cursorCol,
pastedContent: { ...currentState.pastedContent },
expandedPasteInfo: new Map(currentState.expandedPasteInfo),
};
const newStack = [...currentState.undoStack, snapshot];
if (newStack.length > historyLimit) {
@@ -1383,7 +1490,8 @@ export type TextBufferAction =
| { type: 'vim_move_to_first_line' }
| { type: 'vim_move_to_last_line' }
| { type: 'vim_move_to_line'; payload: { lineNumber: number } }
| { type: 'vim_escape_insert_mode' };
| { type: 'vim_escape_insert_mode' }
| { type: 'toggle_paste_expansion'; payload: { id: string } };
export interface TextBufferOptions {
inputFilter?: (text: string) => string;
@@ -1422,7 +1530,7 @@ function textBufferReducerLogic(
}
case 'insert': {
const nextState = pushUndoLocal(state);
const nextState = detachExpandedPaste(pushUndoLocal(state));
const newLines = [...nextState.lines];
let newCursorRow = nextState.cursorRow;
let newCursorCol = nextState.cursorCol;
@@ -1468,6 +1576,7 @@ function textBufferReducerLogic(
const before = cpSlice(lineContent, 0, newCursorCol);
const after = cpSlice(lineContent, newCursorCol);
let lineDelta = 0;
if (parts.length > 1) {
newLines[newCursorRow] = before + parts[0];
const remainingParts = parts.slice(1);
@@ -1478,6 +1587,7 @@ function textBufferReducerLogic(
0,
lastPartOriginal + after,
);
lineDelta = parts.length - 1;
newCursorRow = newCursorRow + parts.length - 1;
newCursorCol = cpLen(lastPartOriginal);
} else {
@@ -1485,6 +1595,16 @@ function textBufferReducerLogic(
newCursorCol = cpLen(before) + cpLen(parts[0]);
}
const { newInfo: newExpandedInfo, detachedIds } = shiftExpandedRegions(
nextState.expandedPasteInfo,
nextState.cursorRow,
lineDelta,
);
for (const id of detachedIds) {
delete newPastedContent[id];
}
return {
...nextState,
lines: newLines,
@@ -1492,6 +1612,7 @@ function textBufferReducerLogic(
cursorCol: newCursorCol,
preferredCol: null,
pastedContent: newPastedContent,
expandedPasteInfo: newExpandedInfo,
};
}
@@ -1507,10 +1628,13 @@ function textBufferReducerLogic(
}
case 'backspace': {
const { cursorRow, cursorCol, lines, transformationsByLine } = state;
const stateWithUndo = pushUndoLocal(state);
const currentState = detachExpandedPaste(stateWithUndo);
const { cursorRow, cursorCol, lines, transformationsByLine } =
currentState;
// Early return if at start of buffer
if (cursorCol === 0 && cursorRow === 0) return state;
if (cursorCol === 0 && cursorRow === 0) return currentState;
// Check if cursor is at end of an atomic placeholder
const transformations = transformationsByLine[cursorRow] ?? [];
@@ -1521,7 +1645,7 @@ function textBufferReducerLogic(
);
if (placeholder) {
const nextState = pushUndoLocal(state);
const nextState = currentState;
const newLines = [...nextState.lines];
newLines[cursorRow] =
cpSlice(newLines[cursorRow], 0, placeholder.start) +
@@ -1551,13 +1675,14 @@ function textBufferReducerLogic(
}
// Standard backspace logic
const nextState = pushUndoLocal(state);
const nextState = currentState;
const newLines = [...nextState.lines];
let newCursorRow = nextState.cursorRow;
let newCursorCol = nextState.cursorCol;
const currentLine = (r: number) => newLines[r] ?? '';
let lineDelta = 0;
if (newCursorCol > 0) {
const lineContent = currentLine(newCursorRow);
newLines[newCursorRow] =
@@ -1570,16 +1695,31 @@ function textBufferReducerLogic(
const newCol = cpLen(prevLineContent);
newLines[newCursorRow - 1] = prevLineContent + currentLineContentVal;
newLines.splice(newCursorRow, 1);
lineDelta = -1;
newCursorRow--;
newCursorCol = newCol;
}
const { newInfo: newExpandedInfo, detachedIds } = shiftExpandedRegions(
nextState.expandedPasteInfo,
nextState.cursorRow + lineDelta, // shift based on the line that was removed
lineDelta,
nextState.cursorRow,
);
const newPastedContent = { ...nextState.pastedContent };
for (const id of detachedIds) {
delete newPastedContent[id];
}
return {
...nextState,
lines: newLines,
cursorRow: newCursorRow,
cursorCol: newCursorCol,
preferredCol: null,
pastedContent: newPastedContent,
expandedPasteInfo: newExpandedInfo,
};
}
@@ -1767,7 +1907,10 @@ function textBufferReducerLogic(
}
case 'delete': {
const { cursorRow, cursorCol, lines, transformationsByLine } = state;
const stateWithUndo = pushUndoLocal(state);
const currentState = detachExpandedPaste(stateWithUndo);
const { cursorRow, cursorCol, lines, transformationsByLine } =
currentState;
// Check if cursor is at start of an atomic placeholder
const transformations = transformationsByLine[cursorRow] ?? [];
@@ -1778,7 +1921,7 @@ function textBufferReducerLogic(
);
if (placeholder) {
const nextState = pushUndoLocal(state);
const nextState = currentState;
const newLines = [...nextState.lines];
newLines[cursorRow] =
cpSlice(newLines[cursorRow], 0, placeholder.start) +
@@ -1809,37 +1952,51 @@ function textBufferReducerLogic(
// Standard delete logic
const lineContent = currentLine(cursorRow);
let lineDelta = 0;
const nextState = currentState;
const newLines = [...nextState.lines];
if (cursorCol < currentLineLen(cursorRow)) {
const nextState = pushUndoLocal(state);
const newLines = [...nextState.lines];
newLines[cursorRow] =
cpSlice(lineContent, 0, cursorCol) +
cpSlice(lineContent, cursorCol + 1);
return {
...nextState,
lines: newLines,
preferredCol: null,
};
} else if (cursorRow < lines.length - 1) {
const nextState = pushUndoLocal(state);
const nextLineContent = currentLine(cursorRow + 1);
const newLines = [...nextState.lines];
newLines[cursorRow] = lineContent + nextLineContent;
newLines.splice(cursorRow + 1, 1);
return {
...nextState,
lines: newLines,
preferredCol: null,
};
lineDelta = -1;
} else {
return currentState;
}
return state;
const { newInfo: newExpandedInfo, detachedIds } = shiftExpandedRegions(
nextState.expandedPasteInfo,
nextState.cursorRow,
lineDelta,
nextState.cursorRow + (lineDelta < 0 ? 1 : 0),
);
const newPastedContent = { ...nextState.pastedContent };
for (const id of detachedIds) {
delete newPastedContent[id];
}
return {
...nextState,
lines: newLines,
preferredCol: null,
pastedContent: newPastedContent,
expandedPasteInfo: newExpandedInfo,
};
}
case 'delete_word_left': {
const { cursorRow, cursorCol } = state;
if (cursorCol === 0 && cursorRow === 0) return state;
const stateWithUndo = pushUndoLocal(state);
const currentState = detachExpandedPaste(stateWithUndo);
const { cursorRow, cursorCol } = currentState;
if (cursorCol === 0 && cursorRow === 0) return currentState;
const nextState = pushUndoLocal(state);
const nextState = currentState;
const newLines = [...nextState.lines];
let newCursorRow = cursorRow;
let newCursorCol = cursorCol;
@@ -1875,15 +2032,17 @@ function textBufferReducerLogic(
}
case 'delete_word_right': {
const { cursorRow, cursorCol, lines } = state;
const stateWithUndo = pushUndoLocal(state);
const currentState = detachExpandedPaste(stateWithUndo);
const { cursorRow, cursorCol, lines } = currentState;
const lineContent = currentLine(cursorRow);
const lineLen = cpLen(lineContent);
if (cursorCol >= lineLen && cursorRow === lines.length - 1) {
return state;
return currentState;
}
const nextState = pushUndoLocal(state);
const nextState = currentState;
const newLines = [...nextState.lines];
if (cursorCol >= lineLen) {
@@ -1906,10 +2065,12 @@ function textBufferReducerLogic(
}
case 'kill_line_right': {
const { cursorRow, cursorCol, lines } = state;
const stateWithUndo = pushUndoLocal(state);
const currentState = detachExpandedPaste(stateWithUndo);
const { cursorRow, cursorCol, lines } = currentState;
const lineContent = currentLine(cursorRow);
if (cursorCol < currentLineLen(cursorRow)) {
const nextState = pushUndoLocal(state);
const nextState = currentState;
const newLines = [...nextState.lines];
newLines[cursorRow] = cpSlice(lineContent, 0, cursorCol);
return {
@@ -1918,7 +2079,7 @@ function textBufferReducerLogic(
};
} else if (cursorRow < lines.length - 1) {
// Act as a delete
const nextState = pushUndoLocal(state);
const nextState = currentState;
const nextLineContent = currentLine(cursorRow + 1);
const newLines = [...nextState.lines];
newLines[cursorRow] = lineContent + nextLineContent;
@@ -1929,13 +2090,15 @@ function textBufferReducerLogic(
preferredCol: null,
};
}
return state;
return currentState;
}
case 'kill_line_left': {
const { cursorRow, cursorCol } = state;
const stateWithUndo = pushUndoLocal(state);
const currentState = detachExpandedPaste(stateWithUndo);
const { cursorRow, cursorCol } = currentState;
if (cursorCol > 0) {
const nextState = pushUndoLocal(state);
const nextState = currentState;
const lineContent = currentLine(cursorRow);
const newLines = [...nextState.lines];
newLines[cursorRow] = cpSlice(lineContent, cursorCol);
@@ -1946,18 +2109,19 @@ function textBufferReducerLogic(
preferredCol: null,
};
}
return state;
return currentState;
}
case 'undo': {
const stateToRestore = state.undoStack[state.undoStack.length - 1];
if (!stateToRestore) return state;
const currentSnapshot = {
const currentSnapshot: UndoHistoryEntry = {
lines: [...state.lines],
cursorRow: state.cursorRow,
cursorCol: state.cursorCol,
pastedContent: { ...state.pastedContent },
expandedPasteInfo: new Map(state.expandedPasteInfo),
};
return {
...state,
@@ -1971,11 +2135,12 @@ function textBufferReducerLogic(
const stateToRestore = state.redoStack[state.redoStack.length - 1];
if (!stateToRestore) return state;
const currentSnapshot = {
const currentSnapshot: UndoHistoryEntry = {
lines: [...state.lines],
cursorRow: state.cursorRow,
cursorCol: state.cursorCol,
pastedContent: { ...state.pastedContent },
expandedPasteInfo: new Map(state.expandedPasteInfo),
};
return {
...state,
@@ -1988,7 +2153,7 @@ function textBufferReducerLogic(
case 'replace_range': {
const { startRow, startCol, endRow, endCol, text } = action.payload;
const nextState = pushUndoLocal(state);
return replaceRangeInternal(
const newState = replaceRangeInternal(
nextState,
startRow,
startCol,
@@ -1996,6 +2161,29 @@ function textBufferReducerLogic(
endCol,
text,
);
const oldLineCount = endRow - startRow + 1;
const newLineCount =
newState.lines.length - (nextState.lines.length - oldLineCount);
const lineDelta = newLineCount - oldLineCount;
const { newInfo: newExpandedInfo, detachedIds } = shiftExpandedRegions(
nextState.expandedPasteInfo,
startRow,
lineDelta,
endRow,
);
const newPastedContent = { ...newState.pastedContent };
for (const id of detachedIds) {
delete newPastedContent[id];
}
return {
...newState,
pastedContent: newPastedContent,
expandedPasteInfo: newExpandedInfo,
};
}
case 'move_to_offset': {
@@ -2051,6 +2239,133 @@ function textBufferReducerLogic(
case 'vim_escape_insert_mode':
return handleVimAction(state, action as VimAction);
case 'toggle_paste_expansion': {
const { id } = action.payload;
const info = state.expandedPasteInfo.get(id);
if (info) {
const nextState = pushUndoLocal(state);
// COLLAPSE: Restore original line with placeholder
const newLines = [...nextState.lines];
newLines.splice(
info.startLine,
info.lineCount,
info.prefix + id + info.suffix,
);
const lineDelta = 1 - info.lineCount;
const { newInfo: newExpandedInfo, detachedIds } = shiftExpandedRegions(
nextState.expandedPasteInfo,
info.startLine,
lineDelta,
info.startLine + info.lineCount - 1,
);
newExpandedInfo.delete(id); // Already shifted, now remove self
const newPastedContent = { ...nextState.pastedContent };
for (const detachedId of detachedIds) {
if (detachedId !== id) {
delete newPastedContent[detachedId];
}
}
// Move cursor to end of collapsed placeholder
const newCursorRow = info.startLine;
const newCursorCol = cpLen(info.prefix) + cpLen(id);
return {
...nextState,
lines: newLines,
cursorRow: newCursorRow,
cursorCol: newCursorCol,
preferredCol: null,
pastedContent: newPastedContent,
expandedPasteInfo: newExpandedInfo,
};
} else {
// EXPAND: Replace placeholder with content
const content = state.pastedContent[id];
if (!content) return state;
// Find line and position containing exactly this placeholder
let lineIndex = -1;
let placeholderStart = -1;
for (let i = 0; i < state.lines.length; i++) {
const transforms = state.transformationsByLine[i] ?? [];
const transform = transforms.find(
(t) => t.type === 'paste' && t.id === id,
);
if (transform) {
lineIndex = i;
placeholderStart = transform.logStart;
break;
}
}
if (lineIndex === -1) return state;
const nextState = pushUndoLocal(state);
const line = nextState.lines[lineIndex];
const prefix = cpSlice(line, 0, placeholderStart);
const suffix = cpSlice(line, placeholderStart + cpLen(id));
// Split content into lines
const contentLines = content.split('\n');
const newLines = [...nextState.lines];
let expandedLines: string[];
if (contentLines.length === 1) {
// Single-line content
expandedLines = [prefix + contentLines[0] + suffix];
} else {
// Multi-line content
expandedLines = [
prefix + contentLines[0],
...contentLines.slice(1, -1),
contentLines[contentLines.length - 1] + suffix,
];
}
newLines.splice(lineIndex, 1, ...expandedLines);
const lineDelta = expandedLines.length - 1;
const { newInfo: newExpandedInfo, detachedIds } = shiftExpandedRegions(
nextState.expandedPasteInfo,
lineIndex,
lineDelta,
lineIndex,
);
const newPastedContent = { ...nextState.pastedContent };
for (const detachedId of detachedIds) {
delete newPastedContent[detachedId];
}
newExpandedInfo.set(id, {
startLine: lineIndex,
lineCount: expandedLines.length,
prefix,
suffix,
});
// Move cursor to end of expanded content (before suffix)
const newCursorRow = lineIndex + expandedLines.length - 1;
const lastExpandedLine = expandedLines[expandedLines.length - 1];
const newCursorCol = cpLen(lastExpandedLine) - cpLen(suffix);
return {
...nextState,
lines: newLines,
cursorRow: newCursorRow,
cursorCol: newCursorCol,
preferredCol: null,
pastedContent: newPastedContent,
expandedPasteInfo: newExpandedInfo,
};
}
}
default: {
const exhaustiveCheck: never = action;
debugLogger.error(`Unknown action encountered: ${exhaustiveCheck}`);
@@ -2095,6 +2410,7 @@ export function textBufferReducer(
) {
const shouldResetPreferred =
oldInside !== newInside || movedBetweenTransforms;
return {
...newState,
preferredCol: shouldResetPreferred ? null : newState.preferredCol,
@@ -2152,6 +2468,7 @@ export function useTextBuffer({
viewportHeight: viewport.height,
visualLayout,
pastedContent: {},
expandedPasteInfo: new Map(),
};
}, [initialText, initialCursorOffset, viewport.width, viewport.height]);
@@ -2169,6 +2486,7 @@ export function useTextBuffer({
visualLayout,
transformationsByLine,
pastedContent,
expandedPasteInfo,
} = state;
const text = useMemo(() => lines.join('\n'), [lines]);
@@ -2454,7 +2772,12 @@ export function useTextBuffer({
const openInExternalEditor = useCallback(async (): Promise<void> => {
const tmpDir = fs.mkdtempSync(pathMod.join(os.tmpdir(), 'gemini-edit-'));
const filePath = pathMod.join(tmpDir, 'buffer.txt');
fs.writeFileSync(filePath, text, 'utf8');
// Expand paste placeholders so user sees full content in editor
const expandedText = text.replace(
PASTED_TEXT_PLACEHOLDER_REGEX,
(match) => pastedContent[match] || match,
);
fs.writeFileSync(filePath, expandedText, 'utf8');
let command: string | undefined = undefined;
const args = [filePath];
@@ -2488,6 +2811,17 @@ export function useTextBuffer({
let newText = fs.readFileSync(filePath, 'utf8');
newText = newText.replace(/\r\n?/g, '\n');
// Attempt to re-collapse unchanged pasted content back into placeholders
const sortedPlaceholders = Object.entries(pastedContent).sort(
(a, b) => b[1].length - a[1].length,
);
for (const [id, content] of sortedPlaceholders) {
if (newText.includes(content)) {
newText = newText.replace(content, id);
}
}
dispatch({ type: 'set_text', payload: newText, pushToUndo: false });
} catch (err) {
coreEvents.emitFeedback(
@@ -2509,7 +2843,7 @@ export function useTextBuffer({
/* ignore */
}
}
}, [text, stdin, setRawMode, getPreferredEditor]);
}, [text, pastedContent, stdin, setRawMode, getPreferredEditor]);
const handleInput = useCallback(
(key: Key): void => {
@@ -2650,11 +2984,81 @@ export function useTextBuffer({
[visualLayout, lines],
);
const getLogicalPositionFromVisual = useCallback(
(visRow: number, visCol: number): { row: number; col: number } | null => {
const {
visualLines,
visualToLogicalMap,
transformedToLogicalMaps,
visualToTransformedMap,
} = visualLayout;
// Clamp visRow to valid range
const clampedVisRow = Math.max(
0,
Math.min(visRow, visualLines.length - 1),
);
const visualLine = visualLines[clampedVisRow] || '';
if (!visualToLogicalMap[clampedVisRow]) {
return null;
}
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;
for (const char of codePoints) {
const charWidth = getCachedStringWidth(char);
if (visCol < currentVisX + charWidth) {
if (charWidth > 1 && visCol >= currentVisX + charWidth / 2) {
charOffset++;
}
break;
}
currentVisX += charWidth;
charOffset++;
}
charOffset = Math.min(charOffset, codePoints.length);
const transformedCol = Math.min(
startColInTransformed + charOffset,
Math.max(0, transformedToLogicalMap.length - 1),
);
const row = logRow;
const col =
transformedToLogicalMap[transformedCol] ?? cpLen(lines[logRow] ?? '');
return { row, col };
},
[visualLayout, lines],
);
const getOffset = useCallback(
(): number => logicalPosToOffset(lines, cursorRow, cursorCol),
[lines, cursorRow, cursorCol],
);
const togglePasteExpansion = useCallback((id: string): void => {
dispatch({ type: 'toggle_paste_expansion', payload: { id } });
}, []);
const getExpandedPasteAtLineCallback = useCallback(
(lineIndex: number): string | null =>
getExpandedPasteAtLine(lineIndex, expandedPasteInfo),
[expandedPasteInfo],
);
const returnValue: TextBuffer = useMemo(
() => ({
lines,
@@ -2686,6 +3090,10 @@ export function useTextBuffer({
moveToOffset,
getOffset,
moveToVisualPosition,
getLogicalPositionFromVisual,
getExpandedPasteAtLine: getExpandedPasteAtLineCallback,
togglePasteExpansion,
expandedPasteInfo,
deleteWordLeft,
deleteWordRight,
@@ -2757,6 +3165,10 @@ export function useTextBuffer({
moveToOffset,
getOffset,
moveToVisualPosition,
getLogicalPositionFromVisual,
getExpandedPasteAtLineCallback,
togglePasteExpansion,
expandedPasteInfo,
deleteWordLeft,
deleteWordRight,
killLineRight,
@@ -2926,6 +3338,29 @@ export interface TextBuffer {
getOffset: () => number;
moveToOffset(offset: number): void;
moveToVisualPosition(visualRow: number, visualCol: number): void;
/**
* Convert visual coordinates to logical position without moving cursor.
* Returns null if the position is out of bounds.
*/
getLogicalPositionFromVisual(
visualRow: number,
visualCol: number,
): { row: number; col: number } | null;
/**
* Check if a line index falls within an expanded paste region.
* Returns the paste placeholder ID if found, null otherwise.
*/
getExpandedPasteAtLine(lineIndex: number): string | null;
/**
* Toggle expansion state for a paste placeholder.
* If collapsed, expands to show full content inline.
* If expanded, collapses back to placeholder.
*/
togglePasteExpansion(id: string): void;
/**
* The current expanded paste info map (read-only).
*/
expandedPasteInfo: Map<string, ExpandedPasteInfo>;
// Vim-specific operations
/**