spb/khaelor Public
KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.
TypeScript 82.9%
HTML 14.9%
CSS 1.1%
JavaScript 0.7%
1/**2 * KHAELOR3 * File: src/tui/composer/editor.ts4 * Description: Pure multiline editor state machine — insert/delete, char/word/line navigation, spans, wrap-with-caret.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910export interface EditorState {11 text: string;12 /** Cursor as an offset into `text` (0 … text.length). */13 cursor: number;14}1516export const EMPTY_EDITOR: EditorState = { text: "", cursor: 0 };1718const WORD_CHAR = /[A-Za-z0-9_]/;1920export function insertText(state: EditorState, s: string): EditorState {21 return {22 text: state.text.slice(0, state.cursor) + s + state.text.slice(state.cursor),23 cursor: state.cursor + s.length,24 };25}2627export function backspace(state: EditorState): EditorState {28 if (state.cursor === 0) return state;29 // Delete one code point, not one UTF-16 unit.30 const before = state.text.slice(0, state.cursor);31 const cp = [...before].pop() as string;32 return {33 text: state.text.slice(0, state.cursor - cp.length) + state.text.slice(state.cursor),34 cursor: state.cursor - cp.length,35 };36}3738export function deleteForward(state: EditorState): EditorState {39 if (state.cursor >= state.text.length) return state;40 const cp = String.fromCodePoint(state.text.codePointAt(state.cursor) as number);41 return {42 text: state.text.slice(0, state.cursor) + state.text.slice(state.cursor + cp.length),43 cursor: state.cursor,44 };45}4647export function moveLeft(state: EditorState): EditorState {48 if (state.cursor === 0) return state;49 const cp = [...state.text.slice(0, state.cursor)].pop() as string;50 return { ...state, cursor: state.cursor - cp.length };51}5253export function moveRight(state: EditorState): EditorState {54 if (state.cursor >= state.text.length) return state;55 const cp = String.fromCodePoint(state.text.codePointAt(state.cursor) as number);56 return { ...state, cursor: state.cursor + cp.length };57}5859export function wordLeft(state: EditorState): EditorState {60 let i = state.cursor;61 while (i > 0 && !WORD_CHAR.test(state.text[i - 1] as string)) i -= 1;62 while (i > 0 && WORD_CHAR.test(state.text[i - 1] as string)) i -= 1;63 return { ...state, cursor: i };64}6566export function wordRight(state: EditorState): EditorState {67 let i = state.cursor;68 const n = state.text.length;69 while (i < n && !WORD_CHAR.test(state.text[i] as string)) i += 1;70 while (i < n && WORD_CHAR.test(state.text[i] as string)) i += 1;71 return { ...state, cursor: i };72}7374export function deleteWordBack(state: EditorState): EditorState {75 const target = wordLeft(state).cursor;76 return {77 text: state.text.slice(0, target) + state.text.slice(state.cursor),78 cursor: target,79 };80}8182export function deleteWordForward(state: EditorState): EditorState {83 const target = wordRight(state).cursor;84 return {85 text: state.text.slice(0, state.cursor) + state.text.slice(target),86 cursor: state.cursor,87 };88}8990function lineStartOffset(text: string, cursor: number): number {91 const idx = text.lastIndexOf("\n", cursor - 1);92 return idx === -1 ? 0 : idx + 1;93}9495function lineEndOffset(text: string, cursor: number): number {96 const idx = text.indexOf("\n", cursor);97 return idx === -1 ? text.length : idx;98}99100export function moveLineStart(state: EditorState): EditorState {101 return { ...state, cursor: lineStartOffset(state.text, state.cursor) };102}103104export function moveLineEnd(state: EditorState): EditorState {105 return { ...state, cursor: lineEndOffset(state.text, state.cursor) };106}107108/** Kill to line start (Ctrl+U). Returns unchanged state at line start. */109export function killToLineStart(state: EditorState): EditorState {110 const start = lineStartOffset(state.text, state.cursor);111 if (start === state.cursor) return state;112 return {113 text: state.text.slice(0, start) + state.text.slice(state.cursor),114 cursor: start,115 };116}117118export function insertNewline(state: EditorState): EditorState {119 return insertText(state, "\n");120}121122/**123 * Move up one visual line, preserving the column when possible. Returns null124 * when the cursor is on the first line — the caller navigates history125 * (cursor-position-aware history, TUI_DESIGN §3.2).126 */127export function moveUp(state: EditorState): EditorState | null {128 const start = lineStartOffset(state.text, state.cursor);129 if (start === 0) return null;130 const col = state.cursor - start;131 const prevStart = lineStartOffset(state.text, start - 1);132 const prevLen = start - 1 - prevStart;133 return { ...state, cursor: prevStart + Math.min(col, prevLen) };134}135136/** Move down one line; null on the last line (history-down boundary). */137export function moveDown(state: EditorState): EditorState | null {138 const end = lineEndOffset(state.text, state.cursor);139 if (end === state.text.length) return null;140 const start = lineStartOffset(state.text, state.cursor);141 const col = state.cursor - start;142 const nextStart = end + 1;143 const nextEnd = lineEndOffset(state.text, nextStart);144 return { ...state, cursor: nextStart + Math.min(col, nextEnd - nextStart) };145}146147// ───────────────────────────── spans ─────────────────────────────148149/**150 * A structured region of the buffer (TUI_DESIGN §3.2): a collapsed paste or a151 * file mention. `start`/`end` are offsets into the display text; `payload`152 * is what submission expands to (paste content) or resolves (mention path).153 */154export interface Span {155 start: number;156 end: number;157 kind: "paste" | "mention";158 payload: string;159}160161/**162 * Re-anchor spans after an edit at `pos` that removed `removed` chars and163 * inserted `inserted` chars. Spans strictly after the edit shift; spans the164 * edit intersects are DROPPED — their display text degrades to plain literal165 * text (simple, safe, never mangles hidden payloads).166 */167export function adjustSpans(168 spans: readonly Span[],169 pos: number,170 removed: number,171 inserted: number,172): Span[] {173 const delta = inserted - removed;174 const editEnd = pos + removed;175 const out: Span[] = [];176 for (const span of spans) {177 if (span.end <= pos) {178 out.push(span);179 } else if (span.start >= editEnd) {180 out.push({ ...span, start: span.start + delta, end: span.end + delta });181 }182 // else: intersected — dropped (degrades to plain text)183 }184 return out;185}186187/** Expand intact spans into their payloads for submission (right-to-left, offsets stay valid). */188export function expandSpans(text: string, spans: readonly Span[]): string {189 const sorted = [...spans].sort((a, b) => b.start - a.start);190 let out = text;191 for (const span of sorted) {192 if (span.kind === "paste") {193 out = out.slice(0, span.start) + span.payload + out.slice(span.end);194 }195 // Mentions keep their display form (`@path`) — the Context Engine resolves them.196 }197 return out;198}199200// ───────────────────────── wrap with caret mapping ─────────────────────────201202export interface WrappedEditor {203 rows: string[];204 caretRow: number;205 caretCol: number;206}207208/**209 * Character-wrap the buffer at `width` columns (editor-style hard wrap) and210 * map the cursor offset to a (row, col) position. Pure — the composer view211 * renders these rows with the prompt/continuation prefixes.212 */213export function wrapWithCursor(text: string, cursor: number, width: number): WrappedEditor {214 const w = Math.max(1, width);215 const rows: string[] = [];216 let caretRow = 0;217 let caretCol = 0;218 let offset = 0;219220 const logical = text.split("\n");221 for (let li = 0; li < logical.length; li++) {222 const line = logical[li] as string;223 const cps = [...line];224 const chunkCount = Math.max(1, Math.ceil(cps.length / w));225 for (let c = 0; c < chunkCount; c++) {226 const chunk = cps.slice(c * w, (c + 1) * w);227 const rowIndex = rows.length;228 rows.push(chunk.join(""));229 // Caret on this chunk?230 const chunkStartOffset = offset + cps.slice(0, c * w).join("").length;231 const chunkText = chunk.join("");232 const isLastChunk = c === chunkCount - 1;233 const upper = chunkStartOffset + chunkText.length;234 if (cursor >= chunkStartOffset && (cursor < upper || (isLastChunk && cursor === upper))) {235 caretRow = rowIndex;236 caretCol = [...text.slice(chunkStartOffset, cursor)].length;237 }238 }239 offset += line.length + 1; // + "\n"240 if (cursor === offset - 1 && li < logical.length - 1) {241 // Cursor exactly on the newline → end of this logical line.242 caretRow = rows.length - 1;243 caretCol = [...(rows[rows.length - 1] as string)].length;244 }245 }246 if (rows.length === 0) rows.push("");247 return { rows, caretRow, caretCol };248}249