/** * Pure composer editor model with Codex textarea semantics: a grapheme * cursor over a column-safe multiline layout, word/piece motion, single-entry * kill + yank, and the shell-recall boundary gate that keeps Up/Down usable * inside a multiline draft. * * The model is intentionally string-offset based (UTF-16 indices clamped to * grapheme boundaries) so the React state stays two primitives * (value, cursor) and every operation here stays pure and testable. * * Word motion follows Codex's piece semantics: whitespace separates runs, * punctuation runs stay atomic, and each Han grapheme is its own boundary. * * @module @deepseek-ai/dsh-code/render/editor */ /** One grapheme cluster with its source span and display width in cells. */ export interface GraphemeSpan { text: string; start: number; end: number; width: number; } /** * Split text into grapheme clusters. Falls back to code points when * Intl.Segmenter is unavailable; the fallback still keeps surrogate pairs * (emoji) atomic so the cursor can never split one. */ export declare function splitGraphemes(text: string): readonly GraphemeSpan[]; /** Clamp a cursor offset to the nearest grapheme boundary (surrogates, ZWJ, marks stay whole). */ export declare function clampCursor(value: string, offset: number): number; /** * Delete the final grapheme cluster (append-only drafts without a cursor). * Surrogate pairs and multi-codepoint emoji stay whole instead of leaving a * lone trailing code unit behind. */ export declare function deleteLastGrapheme(text: string): string; /** * Step the cursor by whole graphemes (negative steps left). The cursor is * assumed to sit on a boundary; any drift is clamped first. */ export declare function moveCursorBy(value: string, offset: number, delta: number): number; /** * Normalize text entering the draft: CRLF/CR become LF, tabs become two * spaces (terminal tab stops are contextual and cannot join a deterministic * row budget), and every other C0 control byte plus DEL is REMOVED — the * draft is data, so a stray ESC (Windows Terminal file drops) disappears * instead of rendering as literal backslash-x-1-b text. Newlines survive. */ export declare function sanitizeDraftText(text: string): string; /** One physical editor row: wrapped text plus its boundary map. */ export interface EditorRowModel { /** Display text of the row (never contains `\n`; sanitized upstream). */ readonly text: string; /** Source offset of the first grapheme on the row. */ readonly start: number; /** Source offset just past the last grapheme on the row (before its newline). */ readonly end: number; /** Boundary offsets on the row, start to end inclusive. */ readonly offsets: readonly number[]; /** Display column of each boundary; `columns[i]` pairs with `offsets[i]`. */ readonly columns: readonly number[]; /** Code-unit cut in `text` at each boundary; `cuts[i]` pairs with `offsets[i]`. */ readonly cuts: readonly number[]; } /** The wrapped physical-row model of one draft. */ export interface EditorModel { readonly rows: readonly EditorRowModel[]; readonly length: number; } /** * Hard-wrap the draft into column-safe physical rows. Wide graphemes never * split across rows (a grapheme that does not fit flushes the row first) and * explicit newlines end their row without occupying a cell. */ export declare function editorModel(value: string, columns: number): EditorModel; /** Where a cursor offset renders: the physical row and its display column. */ export interface CaretSite { row: number; column: number; } /** Text slices for rendering one physical row with at most one caret. */ export interface EditorRowParts { readonly before: string; readonly caret: string; readonly after: string; readonly hasCaret: boolean; } /** Split one row around the authoritative caret; every other row stays whole. */ export declare function editorRowParts(row: EditorRowModel, rowIndex: number, caretRow: number, cursor: number, caretEnabled?: boolean): EditorRowParts; /** Map a cursor offset to its caret site on the wrapped rows. */ export declare function caretSite(model: EditorModel, offset: number): CaretSite; /** * Move the caret across physical rows keeping a preferred display column * (Codex `preferred_col`): horizontal moves reset the preference, vertical * moves reuse it, clamped to each row's width. */ export declare function moveCursorVertically(model: EditorModel, offset: number, preferredColumn: number, delta: number): number; /** The start/end offsets of the logical line containing the cursor. */ export declare function lineBounds(value: string, offset: number): { start: number; end: number; }; /** * Codex `beginning_of_previous_word`: skip whitespace left, then land on the * START of the trailing non-space piece (extending over separator pieces). */ export declare function moveWordLeft(value: string, offset: number): number; /** * Codex `end_of_next_word`: skip whitespace right, then land on the END of * the leading non-space piece (extending over separator pieces). */ export declare function moveWordRight(value: string, offset: number): number; /** One edit outcome: the next draft value, cursor, and killed span (if any). */ export interface EditResult { value: string; cursor: number; /** Text removed into the kill buffer; undefined when nothing was killed. */ killed: string | undefined; } /** Delete the grapheme cluster before the cursor. */ export declare function deleteBackward(value: string, cursor: number): EditResult; /** Delete the grapheme cluster at the cursor. */ export declare function deleteForward(value: string, cursor: number): EditResult; /** Delete back to the start of the previous word (fills the kill buffer). */ export declare function deleteWordBackward(value: string, cursor: number): EditResult; /** Delete forward to the end of the next word (fills the kill buffer). */ export declare function deleteWordForward(value: string, cursor: number): EditResult; /** Ctrl+U: kill from the line start to the cursor; at BOL, kill the newline. */ export declare function killToLineStart(value: string, cursor: number): EditResult; /** Ctrl+K: kill from the cursor to the line end; at EOL, kill the newline. */ export declare function killToLineEnd(value: string, cursor: number): EditResult; /** Insert sanitized text at the cursor. */ export declare function insertText(value: string, cursor: number, text: string): EditResult; /** Ctrl+A: current logical line start, then the previous line start on repeat. */ export declare function moveToLineStart(value: string, cursor: number, crossOnRepeat: boolean): number; /** Ctrl+E: current logical line end, then the next line end on repeat. */ export declare function moveToLineEnd(value: string, cursor: number, crossOnRepeat: boolean): number; /** One stable text range captured before an asynchronous draft operation. */ export interface DraftRange { readonly start: number; readonly end: number; } /** * Remap a captured range when all intervening edits are wholly before or * wholly after it. An edit overlapping either boundary invalidates the * anchor instead of guessing and inserting content at a surprising place. */ export declare function remapStableRange(original: string, current: string, range: DraftRange): DraftRange | undefined; /** Replace a current range while preserving a cursor moved after capture. */ export declare function replaceRangePreservingCursor(value: string, cursor: number, range: DraftRange, replacement: string): EditResult; /** * Composer editor row budget: the editor itself never grows past this many * physical rows; deeper drafts scroll internally to keep the caret visible. * Short terminals collapse toward one row so the live transcript keeps room. */ export declare function composerMaxRows(terminalRows: number): number; /** * History navigation starts with Up on an empty draft, or continues from an * unchanged recalled entry whenever the caret sits on either text edge * (start or end) - moving the caret into the interior returns the keys to * ordinary editing until an edge is reached again. */ export declare function shouldRecallNavigate(value: string, cursor: number, lastRecalled: string | null, direction: -1 | 1): boolean;