buroventures-harald-code
Version:
Harald Code - AI-powered coding assistant CLI
1,176 lines • 56.2 kB
JavaScript
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import stripAnsi from 'strip-ansi';
import { spawnSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import pathMod from 'path';
import { useState, useCallback, useEffect, useMemo, useReducer } from 'react';
import stringWidth from 'string-width';
import { unescapePath } from 'buroventures-harald-code-core';
import { toCodePoints, cpLen, cpSlice } from '../../utils/textUtils.js';
import { handleVimAction } from './vim-buffer-actions.js';
// Simple helper for word‑wise ops.
function isWordChar(ch) {
if (ch === undefined) {
return false;
}
return !/[\s,.;!?]/.test(ch);
}
// Vim-specific word boundary functions
export const findNextWordStart = (text, currentOffset) => {
let i = currentOffset;
if (i >= text.length)
return i;
const currentChar = text[i];
// Skip current word/sequence based on character type
if (/\w/.test(currentChar)) {
// Skip current word characters
while (i < text.length && /\w/.test(text[i])) {
i++;
}
}
else if (!/\s/.test(currentChar)) {
// Skip current non-word, non-whitespace characters (like "/", ".", etc.)
while (i < text.length && !/\w/.test(text[i]) && !/\s/.test(text[i])) {
i++;
}
}
// Skip whitespace
while (i < text.length && /\s/.test(text[i])) {
i++;
}
// If we reached the end of text and there's no next word,
// vim behavior for dw is to delete to the end of the current word
if (i >= text.length) {
// Go back to find the end of the last word
let endOfLastWord = text.length - 1;
while (endOfLastWord >= 0 && /\s/.test(text[endOfLastWord])) {
endOfLastWord--;
}
// For dw on last word, return position AFTER the last character to delete entire word
return Math.max(currentOffset + 1, endOfLastWord + 1);
}
return i;
};
export const findPrevWordStart = (text, currentOffset) => {
let i = currentOffset;
// If at beginning of text, return current position
if (i <= 0) {
return currentOffset;
}
// Move back one character to start searching
i--;
// Skip whitespace moving backwards
while (i >= 0 && (text[i] === ' ' || text[i] === '\t' || text[i] === '\n')) {
i--;
}
if (i < 0) {
return 0; // Reached beginning of text
}
const charAtI = text[i];
if (/\w/.test(charAtI)) {
// We're in a word, move to its beginning
while (i >= 0 && /\w/.test(text[i])) {
i--;
}
return i + 1; // Return first character of word
}
else {
// We're in punctuation, move to its beginning
while (i >= 0 &&
!/\w/.test(text[i]) &&
text[i] !== ' ' &&
text[i] !== '\t' &&
text[i] !== '\n') {
i--;
}
return i + 1; // Return first character of punctuation sequence
}
};
export const findWordEnd = (text, currentOffset) => {
let i = currentOffset;
// If we're already at the end of a word, advance to next word
if (i < text.length &&
/\w/.test(text[i]) &&
(i + 1 >= text.length || !/\w/.test(text[i + 1]))) {
// We're at the end of a word, move forward to find next word
i++;
// Skip whitespace/punctuation to find next word
while (i < text.length && !/\w/.test(text[i])) {
i++;
}
}
// If we're not on a word character, find the next word
if (i < text.length && !/\w/.test(text[i])) {
while (i < text.length && !/\w/.test(text[i])) {
i++;
}
}
// Move to end of current word
while (i < text.length && /\w/.test(text[i])) {
i++;
}
// Move back one to be on the last character of the word
return Math.max(currentOffset, i - 1);
};
// Helper functions for vim operations
export const getOffsetFromPosition = (row, col, lines) => {
let offset = 0;
for (let i = 0; i < row; i++) {
offset += lines[i].length + 1; // +1 for newline
}
offset += col;
return offset;
};
export const getPositionFromOffsets = (startOffset, endOffset, lines) => {
let offset = 0;
let startRow = 0;
let startCol = 0;
let endRow = 0;
let endCol = 0;
// Find start position
for (let i = 0; i < lines.length; i++) {
const lineLength = lines[i].length + 1; // +1 for newline
if (offset + lineLength > startOffset) {
startRow = i;
startCol = startOffset - offset;
break;
}
offset += lineLength;
}
// Find end position
offset = 0;
for (let i = 0; i < lines.length; i++) {
const lineLength = lines[i].length + (i < lines.length - 1 ? 1 : 0); // +1 for newline except last line
if (offset + lineLength >= endOffset) {
endRow = i;
endCol = endOffset - offset;
break;
}
offset += lineLength;
}
return { startRow, startCol, endRow, endCol };
};
export const getLineRangeOffsets = (startRow, lineCount, lines) => {
let startOffset = 0;
// Calculate start offset
for (let i = 0; i < startRow; i++) {
startOffset += lines[i].length + 1; // +1 for newline
}
// Calculate end offset
let endOffset = startOffset;
for (let i = 0; i < lineCount; i++) {
const lineIndex = startRow + i;
if (lineIndex < lines.length) {
endOffset += lines[lineIndex].length;
if (lineIndex < lines.length - 1) {
endOffset += 1; // +1 for newline
}
}
}
return { startOffset, endOffset };
};
export const replaceRangeInternal = (state, startRow, startCol, endRow, endCol, text) => {
const currentLine = (row) => state.lines[row] || '';
const currentLineLen = (row) => cpLen(currentLine(row));
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
if (startRow > endRow ||
(startRow === endRow && startCol > endCol) ||
startRow < 0 ||
startCol < 0 ||
endRow >= state.lines.length ||
(endRow < state.lines.length && endCol > currentLineLen(endRow))) {
return state; // Invalid range
}
const newLines = [...state.lines];
const sCol = clamp(startCol, 0, currentLineLen(startRow));
const eCol = clamp(endCol, 0, currentLineLen(endRow));
const prefix = cpSlice(currentLine(startRow), 0, sCol);
const suffix = cpSlice(currentLine(endRow), eCol);
const normalisedReplacement = text
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n');
const replacementParts = normalisedReplacement.split('\n');
// The combined first line of the new text
const firstLine = prefix + replacementParts[0];
if (replacementParts.length === 1) {
// No newlines in replacement: combine prefix, replacement, and suffix on one line.
newLines.splice(startRow, endRow - startRow + 1, firstLine + suffix);
}
else {
// Newlines in replacement: create new lines.
const lastLine = replacementParts[replacementParts.length - 1] + suffix;
const middleLines = replacementParts.slice(1, -1);
newLines.splice(startRow, endRow - startRow + 1, firstLine, ...middleLines, lastLine);
}
const finalCursorRow = startRow + replacementParts.length - 1;
const finalCursorCol = (replacementParts.length > 1 ? 0 : sCol) +
cpLen(replacementParts[replacementParts.length - 1]);
return {
...state,
lines: newLines,
cursorRow: Math.min(Math.max(finalCursorRow, 0), newLines.length - 1),
cursorCol: Math.max(0, Math.min(finalCursorCol, cpLen(newLines[finalCursorRow] || ''))),
preferredCol: null,
};
};
/**
* Strip characters that can break terminal rendering.
*
* Strip ANSI escape codes and control characters except for line breaks.
* Control characters such as delete break terminal UI rendering.
*/
function stripUnsafeCharacters(str) {
const stripped = stripAnsi(str);
return toCodePoints(stripped)
.filter((char) => {
if (char.length > 1)
return false;
const code = char.codePointAt(0);
if (code === undefined) {
return false;
}
const isUnsafe = code === 127 || (code <= 31 && code !== 13 && code !== 10);
return !isUnsafe;
})
.join('');
}
function clamp(v, min, max) {
return v < min ? min : v > max ? max : v;
}
function calculateInitialCursorPosition(initialLines, offset) {
let remainingChars = offset;
let row = 0;
while (row < initialLines.length) {
const lineLength = cpLen(initialLines[row]);
// Add 1 for the newline character (except for the last line)
const totalCharsInLineAndNewline = lineLength + (row < initialLines.length - 1 ? 1 : 0);
if (remainingChars <= lineLength) {
// Cursor is on this line
return [row, remainingChars];
}
remainingChars -= totalCharsInLineAndNewline;
row++;
}
// Offset is beyond the text, place cursor at the end of the last line
if (initialLines.length > 0) {
const lastRow = initialLines.length - 1;
return [lastRow, cpLen(initialLines[lastRow])];
}
return [0, 0]; // Default for empty text
}
export function offsetToLogicalPos(text, offset) {
let row = 0;
let col = 0;
let currentOffset = 0;
if (offset === 0)
return [0, 0];
const lines = text.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineLength = cpLen(line);
const lineLengthWithNewline = lineLength + (i < lines.length - 1 ? 1 : 0);
if (offset <= currentOffset + lineLength) {
// Check against lineLength first
row = i;
col = offset - currentOffset;
return [row, col];
}
else if (offset <= currentOffset + lineLengthWithNewline) {
// Check if offset is the newline itself
row = i;
col = lineLength; // Position cursor at the end of the current line content
// If the offset IS the newline, and it's not the last line, advance to next line, col 0
if (offset === currentOffset + lineLengthWithNewline &&
i < lines.length - 1) {
return [i + 1, 0];
}
return [row, col]; // Otherwise, it's at the end of the current line content
}
currentOffset += lineLengthWithNewline;
}
// If offset is beyond the text length, place cursor at the end of the last line
// or [0,0] if text is empty
if (lines.length > 0) {
row = lines.length - 1;
col = cpLen(lines[row]);
}
else {
row = 0;
col = 0;
}
return [row, col];
}
/**
* Converts logical row/col position to absolute text offset
* Inverse operation of offsetToLogicalPos
*/
export function logicalPosToOffset(lines, row, col) {
let offset = 0;
// Clamp row to valid range
const actualRow = Math.min(row, lines.length - 1);
// Add lengths of all lines before the target row
for (let i = 0; i < actualRow; i++) {
offset += cpLen(lines[i]) + 1; // +1 for newline
}
// Add column offset within the target row
if (actualRow >= 0 && actualRow < lines.length) {
offset += Math.min(col, cpLen(lines[actualRow]));
}
return offset;
}
// Helper to calculate visual lines and map cursor positions
function calculateVisualLayout(logicalLines, logicalCursor, viewportWidth) {
const visualLines = [];
const logicalToVisualMap = [];
const visualToLogicalMap = [];
let currentVisualCursor = [0, 0];
logicalLines.forEach((logLine, logIndex) => {
logicalToVisualMap[logIndex] = [];
if (logLine.length === 0) {
// Handle empty logical line
logicalToVisualMap[logIndex].push([visualLines.length, 0]);
visualToLogicalMap.push([logIndex, 0]);
visualLines.push('');
if (logIndex === logicalCursor[0] && logicalCursor[1] === 0) {
currentVisualCursor = [visualLines.length - 1, 0];
}
}
else {
// Non-empty logical line
let currentPosInLogLine = 0; // Tracks position within the current logical line (code point index)
const codePointsInLogLine = toCodePoints(logLine);
while (currentPosInLogLine < codePointsInLogLine.length) {
let currentChunk = '';
let currentChunkVisualWidth = 0;
let numCodePointsInChunk = 0;
let lastWordBreakPoint = -1; // Index in codePointsInLogLine for word break
let numCodePointsAtLastWordBreak = 0;
// Iterate through code points to build the current visual line (chunk)
for (let i = currentPosInLogLine; i < codePointsInLogLine.length; i++) {
const char = codePointsInLogLine[i];
const charVisualWidth = stringWidth(char);
if (currentChunkVisualWidth + charVisualWidth > viewportWidth) {
// Character would exceed viewport width
if (lastWordBreakPoint !== -1 &&
numCodePointsAtLastWordBreak > 0 &&
currentPosInLogLine + numCodePointsAtLastWordBreak < i) {
// We have a valid word break point to use, and it's not the start of the current segment
currentChunk = codePointsInLogLine
.slice(currentPosInLogLine, currentPosInLogLine + numCodePointsAtLastWordBreak)
.join('');
numCodePointsInChunk = numCodePointsAtLastWordBreak;
}
else {
// No word break, or word break is at the start of this potential chunk, or word break leads to empty chunk.
// Hard break: take characters up to viewportWidth, or just the current char if it alone is too wide.
if (numCodePointsInChunk === 0 &&
charVisualWidth > viewportWidth) {
// Single character is wider than viewport, take it anyway
currentChunk = char;
numCodePointsInChunk = 1;
}
else if (numCodePointsInChunk === 0 &&
charVisualWidth <= viewportWidth) {
// This case should ideally be caught by the next iteration if the char fits.
// If it doesn't fit (because currentChunkVisualWidth was already > 0 from a previous char that filled the line),
// then numCodePointsInChunk would not be 0.
// This branch means the current char *itself* doesn't fit an empty line, which is handled by the above.
// If we are here, it means the loop should break and the current chunk (which is empty) is finalized.
}
}
break; // Break from inner loop to finalize this chunk
}
currentChunk += char;
currentChunkVisualWidth += charVisualWidth;
numCodePointsInChunk++;
// Check for word break opportunity (space)
if (char === ' ') {
lastWordBreakPoint = i; // Store code point index of the space
// Store the state *before* adding the space, if we decide to break here.
numCodePointsAtLastWordBreak = numCodePointsInChunk - 1; // Chars *before* the space
}
}
// If the inner loop completed without breaking (i.e., remaining text fits)
// or if the loop broke but numCodePointsInChunk is still 0 (e.g. first char too wide for empty line)
if (numCodePointsInChunk === 0 &&
currentPosInLogLine < codePointsInLogLine.length) {
// This can happen if the very first character considered for a new visual line is wider than the viewport.
// In this case, we take that single character.
const firstChar = codePointsInLogLine[currentPosInLogLine];
currentChunk = firstChar;
numCodePointsInChunk = 1; // Ensure we advance
}
// If after everything, numCodePointsInChunk is still 0 but we haven't processed the whole logical line,
// it implies an issue, like viewportWidth being 0 or less. Avoid infinite loop.
if (numCodePointsInChunk === 0 &&
currentPosInLogLine < codePointsInLogLine.length) {
// Force advance by one character to prevent infinite loop if something went wrong
currentChunk = codePointsInLogLine[currentPosInLogLine];
numCodePointsInChunk = 1;
}
logicalToVisualMap[logIndex].push([
visualLines.length,
currentPosInLogLine,
]);
visualToLogicalMap.push([logIndex, currentPosInLogLine]);
visualLines.push(currentChunk);
// Cursor mapping logic
// Note: currentPosInLogLine here is the start of the currentChunk within the logical line.
if (logIndex === logicalCursor[0]) {
const cursorLogCol = logicalCursor[1]; // This is a code point index
if (cursorLogCol >= currentPosInLogLine &&
cursorLogCol < currentPosInLogLine + numCodePointsInChunk // Cursor is within this chunk
) {
currentVisualCursor = [
visualLines.length - 1,
cursorLogCol - currentPosInLogLine, // Visual col is also code point index within visual line
];
}
else if (cursorLogCol === currentPosInLogLine + numCodePointsInChunk &&
numCodePointsInChunk > 0) {
// Cursor is exactly at the end of this non-empty chunk
currentVisualCursor = [
visualLines.length - 1,
numCodePointsInChunk,
];
}
}
const logicalStartOfThisChunk = currentPosInLogLine;
currentPosInLogLine += numCodePointsInChunk;
// If the chunk processed did not consume the entire logical line,
// and the character immediately following the chunk is a space,
// advance past this space as it acted as a delimiter for word wrapping.
if (logicalStartOfThisChunk + numCodePointsInChunk <
codePointsInLogLine.length &&
currentPosInLogLine < codePointsInLogLine.length && // Redundant if previous is true, but safe
codePointsInLogLine[currentPosInLogLine] === ' ') {
currentPosInLogLine++;
}
}
// After all chunks of a non-empty logical line are processed,
// if the cursor is at the very end of this logical line, update visual cursor.
if (logIndex === logicalCursor[0] &&
logicalCursor[1] === codePointsInLogLine.length // Cursor at end of logical line
) {
const lastVisualLineIdx = visualLines.length - 1;
if (lastVisualLineIdx >= 0 &&
visualLines[lastVisualLineIdx] !== undefined) {
currentVisualCursor = [
lastVisualLineIdx,
cpLen(visualLines[lastVisualLineIdx]), // Cursor at end of last visual line for this logical line
];
}
}
}
});
// If the entire logical text was empty, ensure there's one empty visual line.
if (logicalLines.length === 0 ||
(logicalLines.length === 1 && logicalLines[0] === '')) {
if (visualLines.length === 0) {
visualLines.push('');
if (!logicalToVisualMap[0])
logicalToVisualMap[0] = [];
logicalToVisualMap[0].push([0, 0]);
visualToLogicalMap.push([0, 0]);
}
currentVisualCursor = [0, 0];
}
// Handle cursor at the very end of the text (after all processing)
// This case might be covered by the loop end condition now, but kept for safety.
else if (logicalCursor[0] === logicalLines.length - 1 &&
logicalCursor[1] === cpLen(logicalLines[logicalLines.length - 1]) &&
visualLines.length > 0) {
const lastVisLineIdx = visualLines.length - 1;
currentVisualCursor = [lastVisLineIdx, cpLen(visualLines[lastVisLineIdx])];
}
return {
visualLines,
visualCursor: currentVisualCursor,
logicalToVisualMap,
visualToLogicalMap,
};
}
const historyLimit = 100;
export const pushUndo = (currentState) => {
const snapshot = {
lines: [...currentState.lines],
cursorRow: currentState.cursorRow,
cursorCol: currentState.cursorCol,
};
const newStack = [...currentState.undoStack, snapshot];
if (newStack.length > historyLimit) {
newStack.shift();
}
return { ...currentState, undoStack: newStack, redoStack: [] };
};
export function textBufferReducer(state, action) {
const pushUndoLocal = pushUndo;
const currentLine = (r) => state.lines[r] ?? '';
const currentLineLen = (r) => cpLen(currentLine(r));
switch (action.type) {
case 'set_text': {
let nextState = state;
if (action.pushToUndo !== false) {
nextState = pushUndoLocal(state);
}
const newContentLines = action.payload
.replace(/\r\n?/g, '\n')
.split('\n');
const lines = newContentLines.length === 0 ? [''] : newContentLines;
const lastNewLineIndex = lines.length - 1;
return {
...nextState,
lines,
cursorRow: lastNewLineIndex,
cursorCol: cpLen(lines[lastNewLineIndex] ?? ''),
preferredCol: null,
};
}
case 'insert': {
const nextState = pushUndoLocal(state);
const newLines = [...nextState.lines];
let newCursorRow = nextState.cursorRow;
let newCursorCol = nextState.cursorCol;
const currentLine = (r) => newLines[r] ?? '';
const str = stripUnsafeCharacters(action.payload.replace(/\r\n/g, '\n').replace(/\r/g, '\n'));
const parts = str.split('\n');
const lineContent = currentLine(newCursorRow);
const before = cpSlice(lineContent, 0, newCursorCol);
const after = cpSlice(lineContent, newCursorCol);
if (parts.length > 1) {
newLines[newCursorRow] = before + parts[0];
const remainingParts = parts.slice(1);
const lastPartOriginal = remainingParts.pop() ?? '';
newLines.splice(newCursorRow + 1, 0, ...remainingParts);
newLines.splice(newCursorRow + parts.length - 1, 0, lastPartOriginal + after);
newCursorRow = newCursorRow + parts.length - 1;
newCursorCol = cpLen(lastPartOriginal);
}
else {
newLines[newCursorRow] = before + parts[0] + after;
newCursorCol = cpLen(before) + cpLen(parts[0]);
}
return {
...nextState,
lines: newLines,
cursorRow: newCursorRow,
cursorCol: newCursorCol,
preferredCol: null,
};
}
case 'backspace': {
const nextState = pushUndoLocal(state);
const newLines = [...nextState.lines];
let newCursorRow = nextState.cursorRow;
let newCursorCol = nextState.cursorCol;
const currentLine = (r) => newLines[r] ?? '';
if (newCursorCol === 0 && newCursorRow === 0)
return state;
if (newCursorCol > 0) {
const lineContent = currentLine(newCursorRow);
newLines[newCursorRow] =
cpSlice(lineContent, 0, newCursorCol - 1) +
cpSlice(lineContent, newCursorCol);
newCursorCol--;
}
else if (newCursorRow > 0) {
const prevLineContent = currentLine(newCursorRow - 1);
const currentLineContentVal = currentLine(newCursorRow);
const newCol = cpLen(prevLineContent);
newLines[newCursorRow - 1] = prevLineContent + currentLineContentVal;
newLines.splice(newCursorRow, 1);
newCursorRow--;
newCursorCol = newCol;
}
return {
...nextState,
lines: newLines,
cursorRow: newCursorRow,
cursorCol: newCursorCol,
preferredCol: null,
};
}
case 'set_viewport_width': {
if (action.payload === state.viewportWidth) {
return state;
}
return { ...state, viewportWidth: action.payload };
}
case 'move': {
const { dir } = action.payload;
const { lines, cursorRow, cursorCol, viewportWidth } = state;
const visualLayout = calculateVisualLayout(lines, [cursorRow, cursorCol], viewportWidth);
const { visualLines, visualCursor, visualToLogicalMap } = visualLayout;
let newVisualRow = visualCursor[0];
let newVisualCol = visualCursor[1];
let newPreferredCol = state.preferredCol;
const currentVisLineLen = cpLen(visualLines[newVisualRow] ?? '');
switch (dir) {
case 'left':
newPreferredCol = null;
if (newVisualCol > 0) {
newVisualCol--;
}
else if (newVisualRow > 0) {
newVisualRow--;
newVisualCol = cpLen(visualLines[newVisualRow] ?? '');
}
break;
case 'right':
newPreferredCol = null;
if (newVisualCol < currentVisLineLen) {
newVisualCol++;
}
else if (newVisualRow < visualLines.length - 1) {
newVisualRow++;
newVisualCol = 0;
}
break;
case 'up':
if (newVisualRow > 0) {
if (newPreferredCol === null)
newPreferredCol = newVisualCol;
newVisualRow--;
newVisualCol = clamp(newPreferredCol, 0, cpLen(visualLines[newVisualRow] ?? ''));
}
break;
case 'down':
if (newVisualRow < visualLines.length - 1) {
if (newPreferredCol === null)
newPreferredCol = newVisualCol;
newVisualRow++;
newVisualCol = clamp(newPreferredCol, 0, cpLen(visualLines[newVisualRow] ?? ''));
}
break;
case 'home':
newPreferredCol = null;
newVisualCol = 0;
break;
case 'end':
newPreferredCol = null;
newVisualCol = currentVisLineLen;
break;
case 'wordLeft': {
const { cursorRow, cursorCol, lines } = state;
if (cursorCol === 0 && cursorRow === 0)
return state;
let newCursorRow = cursorRow;
let newCursorCol = cursorCol;
if (cursorCol === 0) {
newCursorRow--;
newCursorCol = cpLen(lines[newCursorRow] ?? '');
}
else {
const lineContent = lines[cursorRow];
const arr = toCodePoints(lineContent);
let start = cursorCol;
let onlySpaces = true;
for (let i = 0; i < start; i++) {
if (isWordChar(arr[i])) {
onlySpaces = false;
break;
}
}
if (onlySpaces && start > 0) {
start--;
}
else {
while (start > 0 && !isWordChar(arr[start - 1]))
start--;
while (start > 0 && isWordChar(arr[start - 1]))
start--;
}
newCursorCol = start;
}
return {
...state,
cursorRow: newCursorRow,
cursorCol: newCursorCol,
preferredCol: null,
};
}
case 'wordRight': {
const { cursorRow, cursorCol, lines } = state;
if (cursorRow === lines.length - 1 &&
cursorCol === cpLen(lines[cursorRow] ?? '')) {
return state;
}
let newCursorRow = cursorRow;
let newCursorCol = cursorCol;
const lineContent = lines[cursorRow] ?? '';
const arr = toCodePoints(lineContent);
if (cursorCol >= arr.length) {
newCursorRow++;
newCursorCol = 0;
}
else {
let end = cursorCol;
while (end < arr.length && !isWordChar(arr[end]))
end++;
while (end < arr.length && isWordChar(arr[end]))
end++;
newCursorCol = end;
}
return {
...state,
cursorRow: newCursorRow,
cursorCol: newCursorCol,
preferredCol: null,
};
}
default:
break;
}
if (visualToLogicalMap[newVisualRow]) {
const [logRow, logStartCol] = visualToLogicalMap[newVisualRow];
return {
...state,
cursorRow: logRow,
cursorCol: clamp(logStartCol + newVisualCol, 0, cpLen(state.lines[logRow] ?? '')),
preferredCol: newPreferredCol,
};
}
return state;
}
case 'delete': {
const { cursorRow, cursorCol, lines } = state;
const lineContent = currentLine(cursorRow);
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 };
}
return state;
}
case 'delete_word_left': {
const { cursorRow, cursorCol } = state;
if (cursorCol === 0 && cursorRow === 0)
return state;
if (cursorCol === 0) {
// Act as a backspace
const nextState = pushUndoLocal(state);
const prevLineContent = currentLine(cursorRow - 1);
const currentLineContentVal = currentLine(cursorRow);
const newCol = cpLen(prevLineContent);
const newLines = [...nextState.lines];
newLines[cursorRow - 1] = prevLineContent + currentLineContentVal;
newLines.splice(cursorRow, 1);
return {
...nextState,
lines: newLines,
cursorRow: cursorRow - 1,
cursorCol: newCol,
preferredCol: null,
};
}
const nextState = pushUndoLocal(state);
const lineContent = currentLine(cursorRow);
const arr = toCodePoints(lineContent);
let start = cursorCol;
let onlySpaces = true;
for (let i = 0; i < start; i++) {
if (isWordChar(arr[i])) {
onlySpaces = false;
break;
}
}
if (onlySpaces && start > 0) {
start--;
}
else {
while (start > 0 && !isWordChar(arr[start - 1]))
start--;
while (start > 0 && isWordChar(arr[start - 1]))
start--;
}
const newLines = [...nextState.lines];
newLines[cursorRow] =
cpSlice(lineContent, 0, start) + cpSlice(lineContent, cursorCol);
return {
...nextState,
lines: newLines,
cursorCol: start,
preferredCol: null,
};
}
case 'delete_word_right': {
const { cursorRow, cursorCol, lines } = state;
const lineContent = currentLine(cursorRow);
const arr = toCodePoints(lineContent);
if (cursorCol >= arr.length && cursorRow === lines.length - 1)
return state;
if (cursorCol >= arr.length) {
// Act as a delete
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 };
}
const nextState = pushUndoLocal(state);
let end = cursorCol;
while (end < arr.length && !isWordChar(arr[end]))
end++;
while (end < arr.length && isWordChar(arr[end]))
end++;
const newLines = [...nextState.lines];
newLines[cursorRow] =
cpSlice(lineContent, 0, cursorCol) + cpSlice(lineContent, end);
return { ...nextState, lines: newLines, preferredCol: null };
}
case 'kill_line_right': {
const { cursorRow, cursorCol, lines } = state;
const lineContent = currentLine(cursorRow);
if (cursorCol < currentLineLen(cursorRow)) {
const nextState = pushUndoLocal(state);
const newLines = [...nextState.lines];
newLines[cursorRow] = cpSlice(lineContent, 0, cursorCol);
return { ...nextState, lines: newLines };
}
else if (cursorRow < lines.length - 1) {
// Act as a delete
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 };
}
return state;
}
case 'kill_line_left': {
const { cursorRow, cursorCol } = state;
if (cursorCol > 0) {
const nextState = pushUndoLocal(state);
const lineContent = currentLine(cursorRow);
const newLines = [...nextState.lines];
newLines[cursorRow] = cpSlice(lineContent, cursorCol);
return {
...nextState,
lines: newLines,
cursorCol: 0,
preferredCol: null,
};
}
return state;
}
case 'undo': {
const stateToRestore = state.undoStack[state.undoStack.length - 1];
if (!stateToRestore)
return state;
const currentSnapshot = {
lines: [...state.lines],
cursorRow: state.cursorRow,
cursorCol: state.cursorCol,
};
return {
...state,
...stateToRestore,
undoStack: state.undoStack.slice(0, -1),
redoStack: [...state.redoStack, currentSnapshot],
};
}
case 'redo': {
const stateToRestore = state.redoStack[state.redoStack.length - 1];
if (!stateToRestore)
return state;
const currentSnapshot = {
lines: [...state.lines],
cursorRow: state.cursorRow,
cursorCol: state.cursorCol,
};
return {
...state,
...stateToRestore,
redoStack: state.redoStack.slice(0, -1),
undoStack: [...state.undoStack, currentSnapshot],
};
}
case 'replace_range': {
const { startRow, startCol, endRow, endCol, text } = action.payload;
const nextState = pushUndoLocal(state);
return replaceRangeInternal(nextState, startRow, startCol, endRow, endCol, text);
}
case 'move_to_offset': {
const { offset } = action.payload;
const [newRow, newCol] = offsetToLogicalPos(state.lines.join('\n'), offset);
return {
...state,
cursorRow: newRow,
cursorCol: newCol,
preferredCol: null,
};
}
case 'create_undo_snapshot': {
return pushUndoLocal(state);
}
// Vim-specific operations
case 'vim_delete_word_forward':
case 'vim_delete_word_backward':
case 'vim_delete_word_end':
case 'vim_change_word_forward':
case 'vim_change_word_backward':
case 'vim_change_word_end':
case 'vim_delete_line':
case 'vim_change_line':
case 'vim_delete_to_end_of_line':
case 'vim_change_to_end_of_line':
case 'vim_change_movement':
case 'vim_move_left':
case 'vim_move_right':
case 'vim_move_up':
case 'vim_move_down':
case 'vim_move_word_forward':
case 'vim_move_word_backward':
case 'vim_move_word_end':
case 'vim_delete_char':
case 'vim_insert_at_cursor':
case 'vim_append_at_cursor':
case 'vim_open_line_below':
case 'vim_open_line_above':
case 'vim_append_at_line_end':
case 'vim_insert_at_line_start':
case 'vim_move_to_line_start':
case 'vim_move_to_line_end':
case 'vim_move_to_first_nonwhitespace':
case 'vim_move_to_first_line':
case 'vim_move_to_last_line':
case 'vim_move_to_line':
case 'vim_escape_insert_mode':
return handleVimAction(state, action);
default: {
const exhaustiveCheck = action;
console.error(`Unknown action encountered: ${exhaustiveCheck}`);
return state;
}
}
}
// --- End of reducer logic ---
export function useTextBuffer({ initialText = '', initialCursorOffset = 0, viewport, stdin, setRawMode, onChange, isValidPath, shellModeActive = false, }) {
const initialState = useMemo(() => {
const lines = initialText.split('\n');
const [initialCursorRow, initialCursorCol] = calculateInitialCursorPosition(lines.length === 0 ? [''] : lines, initialCursorOffset);
return {
lines: lines.length === 0 ? [''] : lines,
cursorRow: initialCursorRow,
cursorCol: initialCursorCol,
preferredCol: null,
undoStack: [],
redoStack: [],
clipboard: null,
selectionAnchor: null,
viewportWidth: viewport.width,
};
}, [initialText, initialCursorOffset, viewport.width]);
const [state, dispatch] = useReducer(textBufferReducer, initialState);
const { lines, cursorRow, cursorCol, preferredCol, selectionAnchor } = state;
const text = useMemo(() => lines.join('\n'), [lines]);
const visualLayout = useMemo(() => calculateVisualLayout(lines, [cursorRow, cursorCol], state.viewportWidth), [lines, cursorRow, cursorCol, state.viewportWidth]);
const { visualLines, visualCursor } = visualLayout;
const [visualScrollRow, setVisualScrollRow] = useState(0);
useEffect(() => {
if (onChange) {
onChange(text);
}
}, [text, onChange]);
useEffect(() => {
dispatch({ type: 'set_viewport_width', payload: viewport.width });
}, [viewport.width]);
// Update visual scroll (vertical)
useEffect(() => {
const { height } = viewport;
let newVisualScrollRow = visualScrollRow;
if (visualCursor[0] < visualScrollRow) {
newVisualScrollRow = visualCursor[0];
}
else if (visualCursor[0] >= visualScrollRow + height) {
newVisualScrollRow = visualCursor[0] - height + 1;
}
if (newVisualScrollRow !== visualScrollRow) {
setVisualScrollRow(newVisualScrollRow);
}
}, [visualCursor, visualScrollRow, viewport]);
const insert = useCallback((ch, { paste = false } = {}) => {
if (/[\n\r]/.test(ch)) {
dispatch({ type: 'insert', payload: ch });
return;
}
const minLengthToInferAsDragDrop = 3;
if (ch.length >= minLengthToInferAsDragDrop &&
!shellModeActive &&
paste) {
let potentialPath = ch.trim();
const quoteMatch = potentialPath.match(/^'(.*)'$/);
if (quoteMatch) {
potentialPath = quoteMatch[1];
}
potentialPath = potentialPath.trim();
if (isValidPath(unescapePath(potentialPath))) {
ch = `@${potentialPath} `;
}
}
let currentText = '';
for (const char of toCodePoints(ch)) {
if (char.codePointAt(0) === 127) {
if (currentText.length > 0) {
dispatch({ type: 'insert', payload: currentText });
currentText = '';
}
dispatch({ type: 'backspace' });
}
else {
currentText += char;
}
}
if (currentText.length > 0) {
dispatch({ type: 'insert', payload: currentText });
}
}, [isValidPath, shellModeActive]);
const newline = useCallback(() => {
dispatch({ type: 'insert', payload: '\n' });
}, []);
const backspace = useCallback(() => {
dispatch({ type: 'backspace' });
}, []);
const del = useCallback(() => {
dispatch({ type: 'delete' });
}, []);
const move = useCallback((dir) => {
dispatch({ type: 'move', payload: { dir } });
}, []);
const undo = useCallback(() => {
dispatch({ type: 'undo' });
}, []);
const redo = useCallback(() => {
dispatch({ type: 'redo' });
}, []);
const setText = useCallback((newText) => {
dispatch({ type: 'set_text', payload: newText });
}, []);
const deleteWordLeft = useCallback(() => {
dispatch({ type: 'delete_word_left' });
}, []);
const deleteWordRight = useCallback(() => {
dispatch({ type: 'delete_word_right' });
}, []);
const killLineRight = useCallback(() => {
dispatch({ type: 'kill_line_right' });
}, []);
const killLineLeft = useCallback(() => {
dispatch({ type: 'kill_line_left' });
}, []);
// Vim-specific operations
const vimDeleteWordForward = useCallback((count) => {
dispatch({ type: 'vim_delete_word_forward', payload: { count } });
}, []);
const vimDeleteWordBackward = useCallback((count) => {
dispatch({ type: 'vim_delete_word_backward', payload: { count } });
}, []);
const vimDeleteWordEnd = useCallback((count) => {
dispatch({ type: 'vim_delete_word_end', payload: { count } });
}, []);
const vimChangeWordForward = useCallback((count) => {
dispatch({ type: 'vim_change_word_forward', payload: { count } });
}, []);
const vimChangeWordBackward = useCallback((count) => {
dispatch({ type: 'vim_change_word_backward', payload: { count } });
}, []);
const vimChangeWordEnd = useCallback((count) => {
dispatch({ type: 'vim_change_word_end', payload: { count } });
}, []);
const vimDeleteLine = useCallback((count) => {
dispatch({ type: 'vim_delete_line', payload: { count } });
}, []);
const vimChangeLine = useCallback((count) => {
dispatch({ type: 'vim_change_line', payload: { count } });
}, []);
const vimDeleteToEndOfLine = useCallback(() => {
dispatch({ type: 'vim_delete_to_end_of_line' });
}, []);
const vimChangeToEndOfLine = useCallback(() => {
dispatch({ type: 'vim_change_to_end_of_line' });
}, []);
const vimChangeMovement = useCallback((movement, count) => {
dispatch({ type: 'vim_change_movement', payload: { movement, count } });
}, []);
// New vim navigation and operation methods
const vimMoveLeft = useCallback((count) => {
dispatch({ type: 'vim_move_left', payload: { count } });
}, []);
const vimMoveRight = useCallback((count) => {
dispatch({ type: 'vim_move_right', payload: { count } });
}, []);
const vimMoveUp = useCallback((count) => {
dispatch({ type: 'vim_move_up', payload: { count } });
}, []);
const vimMoveDown = useCallback((count) => {
dispatch({ type: 'vim_move_down', payload: { count } });
}, []);
const vimMoveWordForward = useCallback((count) => {
dispatch({ type: 'vim_move_word_forward', payload: { count } });
}, []);
const vimMoveWordBackward = useCallback((count) => {
dispatch({ type: 'vim_move_word_backward', payload: { count } });
}, []);
const vimMoveWordEnd = useCallback((count) => {
dispatch({ type: 'vim_move_word_end', payload: { count } });
}, []);
const vimDeleteChar = useCallback((count) => {
dispatch({ type: 'vim_delete_char', payload: { count } });
}, []);
const vimInsertAtCursor = useCallback(() => {
dispatch({ type: 'vim_insert_at_cursor' });
}, []);
const vimAppendAtCursor = useCallback(() => {
dispatch({ type: 'vim_append_at_cursor' });
}, []);
const vimOpenLineBelow = useCallback(() => {
dispatch({ type: 'vim_open_line_below' });
}, []);
const vimOpenLineAbove = useCallback(() => {
dispatch({ type: 'vim_open_line_above' });
}, []);
const vimAppendAtLineEnd = useCallback(() => {
dispatch({ type: 'vim_append_at_line_end' });
}, []);
const vimInsertAtLineStart = useCallback(() => {
dispatch({ type: 'vim_insert_at_line_start' });
}, []);
const vimMoveToLineStart = useCallback(() => {
dispatch({ type: 'vim_move_to_line_start' });
}, []);
const vimMoveToLineEnd = useCallback(() => {
dispatch({ type: 'vim_move_to_line_end' });
}, []);
const vimMoveToFirstNonWhitespace = useCallback(() => {
dispatch({ type: 'vim_move_to_first_nonwhitespace' });
}, []);
const vimMoveToFirstLine = useCallback(() => {
dispatch({ type: 'vim_move_to_first_line' });
}, []);
const vimMoveToLastLine = useCallback(() => {
dispatch({ type: 'vim_move_to_last_line' });
}, []);
const vimMoveToLine = useCallback((lineNumber) => {
dispatch({ type: 'vim_move_to_line', payload: { lineNumber } });
}, []);
const vimEscapeInsertMode = useCallback(() => {
dispatch({ type: 'vim_escape_insert_mode' });
}, [