/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /** * Types and interfaces for the text buffer component. * This module contains all type definitions used across the text buffer system. */ import type { EditorType } from '@vybestack/llxprt-code-core'; import type { Key } from '../../contexts/KeypressContext.js'; /** * Direction for cursor movement within the text buffer. */ export type Direction = | 'left' | 'right' | 'up' | 'down' | 'wordLeft' | 'wordRight' | 'home' | 'end'; /** * Viewport dimensions for the text buffer display. */ export interface Viewport { height: number; width: number; } /** * Internal interface for undo/redo history entries. */ export interface UndoHistoryEntry { lines: string[]; cursorRow: number; cursorCol: number; } /** * Props for the useTextBuffer hook. */ export interface UseTextBufferProps { initialText?: string; initialCursorOffset?: number; viewport: Viewport; // Viewport dimensions needed for scrolling stdin?: NodeJS.ReadStream | null; // For external editor setRawMode?: (mode: boolean) => void; // For external editor onChange?: (text: string) => void; // Callback for when text changes isValidPath: (path: string) => boolean; shellModeActive?: boolean; // Whether the text buffer is in shell mode inputFilter?: (text: string) => string; // Optional filter for input text singleLine?: boolean; getPreferredEditor?: () => EditorType | undefined; } /** * 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; } /** * Visual layout information for rendering wrapped text. * Handles the mapping between logical lines and visual lines (after wrapping). */ 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[]; } /** * Internal state for the text buffer, managed by the reducer. */ 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[]; clipboard: string | null; selectionAnchor: [number, number] | null; viewportWidth: number; viewportHeight: number; visualLayout: VisualLayout; } /** * Maximum number of undo/redo history entries to keep. */ export const historyLimit = 100; /** * Action types for the text buffer reducer. */ export type TextBufferAction = | { type: 'set_text'; payload: string; pushToUndo?: boolean } | { type: 'insert'; payload: string } | { type: 'backspace' } | { type: 'move'; payload: { dir: Direction; }; } | { type: 'set_cursor'; payload: { cursorRow: number; cursorCol: number; preferredCol: number | null; }; } | { type: 'delete' } | { type: 'delete_word_left' } | { type: 'delete_word_right' } | { type: 'kill_line_right' } | { type: 'kill_line_left' } | { type: 'undo' } | { type: 'redo' } | { type: 'replace_range'; payload: { startRow: number; startCol: number; endRow: number; endCol: number; text: string; }; } | { type: 'move_to_offset'; payload: { offset: number } } | { type: 'create_undo_snapshot' } | { type: 'set_viewport'; payload: { width: number; height: number } } | { type: 'vim_delete_word_forward'; payload: { count: number } } | { type: 'vim_delete_word_backward'; payload: { count: number } } | { type: 'vim_delete_word_end'; payload: { count: number } } | { type: 'vim_change_word_forward'; payload: { count: number } } | { type: 'vim_change_word_backward'; payload: { count: number } } | { type: 'vim_change_word_end'; payload: { count: number } } | { type: 'vim_delete_line'; payload: { count: number } } | { type: 'vim_change_line'; payload: { count: number } } | { type: 'vim_delete_to_end_of_line' } | { type: 'vim_change_to_end_of_line' } | { type: 'vim_change_movement'; payload: { movement: 'h' | 'j' | 'k' | 'l'; count: number }; } // New vim actions for stateless command handling | { type: 'vim_move_left'; payload: { count: number } } | { type: 'vim_move_right'; payload: { count: number } } | { type: 'vim_move_up'; payload: { count: number } } | { type: 'vim_move_down'; payload: { count: number } } | { type: 'vim_move_word_forward'; payload: { count: number } } | { type: 'vim_move_word_backward'; payload: { count: number } } | { type: 'vim_move_word_end'; payload: { count: number } } | { type: 'vim_delete_char'; payload: { count: number } } | { type: 'vim_insert_at_cursor' } | { type: 'vim_append_at_cursor' } | { type: 'vim_open_line_below' } | { type: 'vim_open_line_above' } | { type: 'vim_append_at_line_end' } | { type: 'vim_insert_at_line_start' } | { type: 'vim_move_to_line_start' } | { type: 'vim_move_to_line_end' } | { type: 'vim_move_to_first_nonwhitespace' } | { 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' }; /** * Options for configuring text buffer behavior. */ export interface TextBufferOptions { inputFilter?: (text: string) => string; singleLine?: boolean; } /** * Public interface for the text buffer, returned by useTextBuffer hook. */ export interface TextBuffer { // State lines: string[]; // Logical lines text: string; cursor: [number, number]; // Logical cursor [row, col] /** * When the user moves the caret vertically we try to keep their original * horizontal column even when passing through shorter lines. We remember * that *preferred* column in this field while the user is still travelling * vertically. Any explicit horizontal movement resets the preference. */ preferredCol: number | null; // Preferred visual column selectionAnchor: [number, number] | null; // Logical selection anchor // Visual state (handles wrapping) allVisualLines: string[]; // All visual lines for the current text and viewport width. viewportVisualLines: string[]; // The subset of visual lines to be rendered based on visualScrollRow and viewport.height visualCursor: [number, number]; // Visual cursor [row, col] relative to the start of all visualLines visualScrollRow: number; // Scroll position for visual lines (index of the first visible visual line) /** * For each visual line (by absolute index in allVisualLines) provides a tuple * [logicalLineIndex, startColInLogical] that maps where that visual line * begins within the logical buffer. Indices are code-point based. */ visualToLogicalMap: Array<[number, number]>; /** * For each logical line, transformations that collapse/expand content (e.g., image paths). */ transformationsByLine: Transformation[][]; /** * For each visual line, the starting column in the transformed space. */ visualToTransformedMap: number[]; // Actions /** * Replaces the entire buffer content with the provided text. * The operation is undoable. */ setText: (text: string) => void; /** * Insert a single character or string without newlines. */ insert: (ch: string, opts?: { paste?: boolean }) => void; newline: () => void; backspace: () => void; del: () => void; move: (dir: Direction) => void; undo: () => void; redo: () => void; /** * Replaces the text within the specified range with new text. * Handles both single-line and multi-line ranges. * * @param startRow The starting row index (inclusive). * @param startCol The starting column index (inclusive, code-point based). * @param endRow The ending row index (inclusive). * @param endCol The ending column index (exclusive, code-point based). * @param text The new text to insert. */ replaceRange: ( startRow: number, startCol: number, endRow: number, endCol: number, text: string, ) => void; /** * Delete the word to the *left* of the caret, mirroring common * Ctrl/Alt+Backspace behaviour in editors & terminals. Both the adjacent * whitespace *and* the word characters immediately preceding the caret are * removed. If the caret is already at column‑0 this becomes a no-op. */ deleteWordLeft: () => void; /** * Delete the word to the *right* of the caret, akin to many editors' * Ctrl/Alt+Delete shortcut. Removes any whitespace/punctuation that * follows the caret and the next contiguous run of word characters. */ deleteWordRight: () => void; /** * Deletes text from the cursor to the end of the current line. */ killLineRight: () => void; /** * Deletes text from the start of the current line to the cursor. */ killLineLeft: () => void; /** * High level "handleInput" – receives what Ink gives us. */ handleInput: (key: Key) => void; /** * Opens the current buffer contents in the user's preferred terminal text * editor ($VISUAL or $EDITOR, falling back to "vi"). The method blocks * until the editor exits, then reloads the file and replaces the in‑memory * buffer with whatever the user saved. * * The operation is treated as a single undoable edit – we snapshot the * previous state *once* before launching the editor so one `undo()` will * revert the entire change set. * * Note: We purposefully rely on the *synchronous* spawn API so that the * calling process genuinely waits for the editor to close before * continuing. This mirrors Git's behaviour and simplifies downstream * control‑flow (callers can simply `await` the Promise). */ openInExternalEditor: () => Promise; replaceRangeByOffset: ( startOffset: number, endOffset: number, replacementText: string, ) => void; getOffset: () => number; moveToOffset(offset: number): void; moveToVisualPosition(visualRow: number, visualCol: number): void; // Vim-specific operations /** * Delete N words forward from cursor position (vim 'dw' command) */ vimDeleteWordForward: (count: number) => void; /** * Delete N words backward from cursor position (vim 'db' command) */ vimDeleteWordBackward: (count: number) => void; /** * Delete to end of N words from cursor position (vim 'de' command) */ vimDeleteWordEnd: (count: number) => void; /** * Change N words forward from cursor position (vim 'cw' command) */ vimChangeWordForward: (count: number) => void; /** * Change N words backward from cursor position (vim 'cb' command) */ vimChangeWordBackward: (count: number) => void; /** * Change to end of N words from cursor position (vim 'ce' command) */ vimChangeWordEnd: (count: number) => void; /** * Delete N lines from cursor position (vim 'dd' command) */ vimDeleteLine: (count: number) => void; /** * Change N lines from cursor position (vim 'cc' command) */ vimChangeLine: (count: number) => void; /** * Delete from cursor to end of line (vim 'D' command) */ vimDeleteToEndOfLine: () => void; /** * Change from cursor to end of line (vim 'C' command) */ vimChangeToEndOfLine: () => void; /** * Change movement operations (vim 'ch', 'cj', 'ck', 'cl' commands) */ vimChangeMovement: (movement: 'h' | 'j' | 'k' | 'l', count: number) => void; /** * Move cursor left N times (vim 'h' command) */ vimMoveLeft: (count: number) => void; /** * Move cursor right N times (vim 'l' command) */ vimMoveRight: (count: number) => void; /** * Move cursor up N times (vim 'k' command) */ vimMoveUp: (count: number) => void; /** * Move cursor down N times (vim 'j' command) */ vimMoveDown: (count: number) => void; /** * Move cursor forward N words (vim 'w' command) */ vimMoveWordForward: (count: number) => void; /** * Move cursor backward N words (vim 'b' command) */ vimMoveWordBackward: (count: number) => void; /** * Move cursor to end of Nth word (vim 'e' command) */ vimMoveWordEnd: (count: number) => void; /** * Delete N characters at cursor (vim 'x' command) */ vimDeleteChar: (count: number) => void; /** * Enter insert mode at cursor (vim 'i' command) */ vimInsertAtCursor: () => void; /** * Enter insert mode after cursor (vim 'a' command) */ vimAppendAtCursor: () => void; /** * Open new line below and enter insert mode (vim 'o' command) */ vimOpenLineBelow: () => void; /** * Open new line above and enter insert mode (vim 'O' command) */ vimOpenLineAbove: () => void; /** * Move to end of line and enter insert mode (vim 'A' command) */ vimAppendAtLineEnd: () => void; /** * Move to first non-whitespace and enter insert mode (vim 'I' command) */ vimInsertAtLineStart: () => void; /** * Move cursor to beginning of line (vim '0' command) */ vimMoveToLineStart: () => void; /** * Move cursor to end of line (vim '$' command) */ vimMoveToLineEnd: () => void; /** * Move cursor to first non-whitespace character (vim '^' command) */ vimMoveToFirstNonWhitespace: () => void; /** * Move cursor to first line (vim 'gg' command) */ vimMoveToFirstLine: () => void; /** * Move cursor to last line (vim 'G' command) */ vimMoveToLastLine: () => void; /** * Move cursor to specific line number (vim '[N]G' command) */ vimMoveToLine: (lineNumber: number) => void; /** * Handle escape from insert mode (moves cursor left if not at line start) */ vimEscapeInsertMode: () => void; }