SPB Git

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%
8.6 KB · 249 lines typescript
Raw Blame History
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