/** * KHAELOR * File: src/tui/composer/editor.ts * Description: Pure multiline editor state machine — insert/delete, char/word/line navigation, spans, wrap-with-caret. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ export interface EditorState { text: string; /** Cursor as an offset into `text` (0 … text.length). */ cursor: number; } export const EMPTY_EDITOR: EditorState = { text: "", cursor: 0 }; const WORD_CHAR = /[A-Za-z0-9_]/; export function insertText(state: EditorState, s: string): EditorState { return { text: state.text.slice(0, state.cursor) + s + state.text.slice(state.cursor), cursor: state.cursor + s.length, }; } export function backspace(state: EditorState): EditorState { if (state.cursor === 0) return state; // Delete one code point, not one UTF-16 unit. const before = state.text.slice(0, state.cursor); const cp = [...before].pop() as string; return { text: state.text.slice(0, state.cursor - cp.length) + state.text.slice(state.cursor), cursor: state.cursor - cp.length, }; } export function deleteForward(state: EditorState): EditorState { if (state.cursor >= state.text.length) return state; const cp = String.fromCodePoint(state.text.codePointAt(state.cursor) as number); return { text: state.text.slice(0, state.cursor) + state.text.slice(state.cursor + cp.length), cursor: state.cursor, }; } export function moveLeft(state: EditorState): EditorState { if (state.cursor === 0) return state; const cp = [...state.text.slice(0, state.cursor)].pop() as string; return { ...state, cursor: state.cursor - cp.length }; } export function moveRight(state: EditorState): EditorState { if (state.cursor >= state.text.length) return state; const cp = String.fromCodePoint(state.text.codePointAt(state.cursor) as number); return { ...state, cursor: state.cursor + cp.length }; } export function wordLeft(state: EditorState): EditorState { let i = state.cursor; while (i > 0 && !WORD_CHAR.test(state.text[i - 1] as string)) i -= 1; while (i > 0 && WORD_CHAR.test(state.text[i - 1] as string)) i -= 1; return { ...state, cursor: i }; } export function wordRight(state: EditorState): EditorState { let i = state.cursor; const n = state.text.length; while (i < n && !WORD_CHAR.test(state.text[i] as string)) i += 1; while (i < n && WORD_CHAR.test(state.text[i] as string)) i += 1; return { ...state, cursor: i }; } export function deleteWordBack(state: EditorState): EditorState { const target = wordLeft(state).cursor; return { text: state.text.slice(0, target) + state.text.slice(state.cursor), cursor: target, }; } export function deleteWordForward(state: EditorState): EditorState { const target = wordRight(state).cursor; return { text: state.text.slice(0, state.cursor) + state.text.slice(target), cursor: state.cursor, }; } function lineStartOffset(text: string, cursor: number): number { const idx = text.lastIndexOf("\n", cursor - 1); return idx === -1 ? 0 : idx + 1; } function lineEndOffset(text: string, cursor: number): number { const idx = text.indexOf("\n", cursor); return idx === -1 ? text.length : idx; } export function moveLineStart(state: EditorState): EditorState { return { ...state, cursor: lineStartOffset(state.text, state.cursor) }; } export function moveLineEnd(state: EditorState): EditorState { return { ...state, cursor: lineEndOffset(state.text, state.cursor) }; } /** Kill to line start (Ctrl+U). Returns unchanged state at line start. */ export function killToLineStart(state: EditorState): EditorState { const start = lineStartOffset(state.text, state.cursor); if (start === state.cursor) return state; return { text: state.text.slice(0, start) + state.text.slice(state.cursor), cursor: start, }; } export function insertNewline(state: EditorState): EditorState { return insertText(state, "\n"); } /** * Move up one visual line, preserving the column when possible. Returns null * when the cursor is on the first line — the caller navigates history * (cursor-position-aware history, TUI_DESIGN §3.2). */ export function moveUp(state: EditorState): EditorState | null { const start = lineStartOffset(state.text, state.cursor); if (start === 0) return null; const col = state.cursor - start; const prevStart = lineStartOffset(state.text, start - 1); const prevLen = start - 1 - prevStart; return { ...state, cursor: prevStart + Math.min(col, prevLen) }; } /** Move down one line; null on the last line (history-down boundary). */ export function moveDown(state: EditorState): EditorState | null { const end = lineEndOffset(state.text, state.cursor); if (end === state.text.length) return null; const start = lineStartOffset(state.text, state.cursor); const col = state.cursor - start; const nextStart = end + 1; const nextEnd = lineEndOffset(state.text, nextStart); return { ...state, cursor: nextStart + Math.min(col, nextEnd - nextStart) }; } // ───────────────────────────── spans ───────────────────────────── /** * A structured region of the buffer (TUI_DESIGN §3.2): a collapsed paste or a * file mention. `start`/`end` are offsets into the display text; `payload` * is what submission expands to (paste content) or resolves (mention path). */ export interface Span { start: number; end: number; kind: "paste" | "mention"; payload: string; } /** * Re-anchor spans after an edit at `pos` that removed `removed` chars and * inserted `inserted` chars. Spans strictly after the edit shift; spans the * edit intersects are DROPPED — their display text degrades to plain literal * text (simple, safe, never mangles hidden payloads). */ export function adjustSpans( spans: readonly Span[], pos: number, removed: number, inserted: number, ): Span[] { const delta = inserted - removed; const editEnd = pos + removed; const out: Span[] = []; for (const span of spans) { if (span.end <= pos) { out.push(span); } else if (span.start >= editEnd) { out.push({ ...span, start: span.start + delta, end: span.end + delta }); } // else: intersected — dropped (degrades to plain text) } return out; } /** Expand intact spans into their payloads for submission (right-to-left, offsets stay valid). */ export function expandSpans(text: string, spans: readonly Span[]): string { const sorted = [...spans].sort((a, b) => b.start - a.start); let out = text; for (const span of sorted) { if (span.kind === "paste") { out = out.slice(0, span.start) + span.payload + out.slice(span.end); } // Mentions keep their display form (`@path`) — the Context Engine resolves them. } return out; } // ───────────────────────── wrap with caret mapping ───────────────────────── export interface WrappedEditor { rows: string[]; caretRow: number; caretCol: number; } /** * Character-wrap the buffer at `width` columns (editor-style hard wrap) and * map the cursor offset to a (row, col) position. Pure — the composer view * renders these rows with the prompt/continuation prefixes. */ export function wrapWithCursor(text: string, cursor: number, width: number): WrappedEditor { const w = Math.max(1, width); const rows: string[] = []; let caretRow = 0; let caretCol = 0; let offset = 0; const logical = text.split("\n"); for (let li = 0; li < logical.length; li++) { const line = logical[li] as string; const cps = [...line]; const chunkCount = Math.max(1, Math.ceil(cps.length / w)); for (let c = 0; c < chunkCount; c++) { const chunk = cps.slice(c * w, (c + 1) * w); const rowIndex = rows.length; rows.push(chunk.join("")); // Caret on this chunk? const chunkStartOffset = offset + cps.slice(0, c * w).join("").length; const chunkText = chunk.join(""); const isLastChunk = c === chunkCount - 1; const upper = chunkStartOffset + chunkText.length; if (cursor >= chunkStartOffset && (cursor < upper || (isLastChunk && cursor === upper))) { caretRow = rowIndex; caretCol = [...text.slice(chunkStartOffset, cursor)].length; } } offset += line.length + 1; // + "\n" if (cursor === offset - 1 && li < logical.length - 1) { // Cursor exactly on the newline → end of this logical line. caretRow = rows.length - 1; caretCol = [...(rows[rows.length - 1] as string)].length; } } if (rows.length === 0) rows.push(""); return { rows, caretRow, caretCol }; }