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%
14.8 KB · 494 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: src/tui/composer/composer.ts4 * Description: The composer — multiline editing, history, paste collapsing, slash/mention palettes, shell mode, queueing.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import {11  EMPTY_EDITOR,12  adjustSpans,13  backspace,14  deleteForward,15  deleteWordBack,16  deleteWordForward,17  expandSpans,18  insertText,19  killToLineStart,20  moveDown,21  moveLeft,22  moveLineEnd,23  moveLineStart,24  moveRight,25  moveUp,26  wordLeft,27  wordRight,28} from "./editor.js";29import type { EditorState, Span } from "./editor.js";30import {31  createPalette,32  paletteMove,33  paletteSelection,34  paletteSetQuery,35} from "./palette.js";36import type { PaletteItem, PaletteState } from "./palette.js";37import { renderComposerBox } from "../components/composer-box.js";38import type { ComposerBoxOptions } from "../components/composer-box.js";39import type { KeyEvent } from "../renderer/input.js";40import type { Theme } from "../theme.js";4142/** Injected sources — the repository index plugs in here later (Phase 7). */43export interface ComposerProviders {44  slashCommands(): PaletteItem[];45  mentions(query: string): PaletteItem[];46}4748export type ComposerEffect =49  | { type: "submit"; text: string; shell: boolean }50  | { type: "run-command"; id: string }51  | { type: "discard-queued" };5253export interface ComposerRender {54  lines: string[];55  caretRow: number;56  caretCol: number;57}5859interface UndoEntry {60  editor: EditorState;61  spans: Span[];62}6364const PASTE_COLLAPSE_LINES = 3;65const PASTE_COLLAPSE_CHARS = 150;66const HISTORY_CAP = 50;67const UNDO_CAP = 200;6869export class Composer {70  private ed: EditorState = EMPTY_EDITOR;71  private spans: Span[] = [];72  private undoStack: UndoEntry[] = [];73  private history: string[] = [];74  private historyIndex: number | null = null;75  private historyDraft = "";76  private palette: PaletteState | null = null;77  private mentionStart: number | null = null;78  private readonly providers: ComposerProviders;7980  constructor(providers: ComposerProviders, historyEntries: string[] = []) {81    this.providers = providers;82    this.history = historyEntries.slice(-HISTORY_CAP);83  }8485  text(): string {86    return this.ed.text;87  }8889  isEmpty(): boolean {90    return this.ed.text === "";91  }9293  isShellMode(): boolean {94    return this.ed.text.startsWith("!");95  }9697  paletteState(): PaletteState | null {98    return this.palette;99  }100101  hasPalette(): boolean {102    return this.palette !== null;103  }104105  closePalette(): void {106    this.palette = null;107    this.mentionStart = null;108  }109110  reset(): void {111    this.ed = EMPTY_EDITOR;112    this.spans = [];113    this.undoStack = [];114    this.historyIndex = null;115    this.closePalette();116  }117118  /**119   * Handle one key. Returns an effect for the app to act on, or null when120   * fully consumed. Precedence: palette (when open) > composer bindings.121   */122  handleKey(key: KeyEvent): ComposerEffect | null {123    if (this.palette !== null) {124      const consumed = this.handlePaletteKey(key);125      if (consumed !== undefined) return consumed;126      // not consumed → close and fall through to normal handling127      this.closePalette();128    }129130    switch (key.type) {131      case "char":132        this.pushUndo();133        this.applyEdit((s) => insertText(s, key.ch), key.ch.length);134        this.maybeOpenPalette(key.ch);135        return null;136      case "paste":137        this.pushUndo();138        this.insertPaste(key.text.replace(/\r\n?/g, "\n"));139        return null;140      case "enter":141        return this.submitOrContinue();142      case "shift-enter":143        this.pushUndo();144        this.applyEdit((s) => insertText(s, "\n"), 1);145        return null;146      case "backspace":147        if (this.ed.cursor > 0) {148          this.pushUndo();149          const before = this.ed.cursor;150          this.ed = backspace(this.ed);151          this.spans = adjustSpans(this.spans, this.ed.cursor, before - this.ed.cursor, 0);152        }153        return null;154      case "delete":155        this.pushUndo();156        this.ed = deleteForward(this.ed);157        this.spans = adjustSpans(this.spans, this.ed.cursor, 1, 0);158        return null;159      case "arrow":160        return this.handleArrow(key.key, key.alt, key.ctrl);161      case "home":162        this.ed = moveLineStart(this.ed);163        return null;164      case "end":165        this.ed = moveLineEnd(this.ed);166        return null;167      case "tab":168        return null; // completion is palette-scoped169      case "alt":170        if (key.ch === "b") this.ed = wordLeft(this.ed);171        else if (key.ch === "f") this.ed = wordRight(this.ed);172        else if (key.ch === "d") {173          this.pushUndo();174          const end = wordRight(this.ed).cursor;175          this.ed = deleteWordForward(this.ed);176          this.spans = adjustSpans(this.spans, this.ed.cursor, end - this.ed.cursor, 0);177        }178        return null;179      case "alt-backspace":180        this.pushUndo();181        return this.doDeleteWordBack();182      case "ctrl":183        return this.handleCtrl(key.ch);184      default:185        return null;186    }187  }188189  private handleCtrl(ch: string): ComposerEffect | null {190    switch (ch) {191      case "a":192        this.ed = moveLineStart(this.ed);193        return null;194      case "e":195        this.ed = moveLineEnd(this.ed);196        return null;197      case "b":198        this.ed = moveLeft(this.ed);199        return null;200      case "f":201        this.ed = moveRight(this.ed);202        return null;203      case "j": {204        this.pushUndo();205        this.applyEdit((s) => insertText(s, "\n"), 1);206        return null;207      }208      case "w":209        this.pushUndo();210        return this.doDeleteWordBack();211      case "u": {212        if (this.ed.text === "") return { type: "discard-queued" };213        this.pushUndo();214        const before = this.ed.cursor;215        this.ed = killToLineStart(this.ed);216        this.spans = adjustSpans(this.spans, this.ed.cursor, before - this.ed.cursor, 0);217        return null;218      }219      case "h":220        if (this.ed.cursor > 0) {221          this.pushUndo();222          const before = this.ed.cursor;223          this.ed = backspace(this.ed);224          this.spans = adjustSpans(this.spans, this.ed.cursor, before - this.ed.cursor, 0);225        }226        return null;227      case "_": {228        const entry = this.undoStack.pop();229        if (entry) {230          this.ed = entry.editor;231          this.spans = entry.spans;232        }233        return null;234      }235      default:236        return null;237    }238  }239240  private doDeleteWordBack(): null {241    const before = this.ed.cursor;242    this.ed = deleteWordBack(this.ed);243    this.spans = adjustSpans(this.spans, this.ed.cursor, before - this.ed.cursor, 0);244    return null;245  }246247  private handleArrow(key: "up" | "down" | "left" | "right", alt: boolean, ctrl: boolean): null {248    switch (key) {249      case "left":250        this.ed = alt || ctrl ? wordLeft(this.ed) : moveLeft(this.ed);251        return null;252      case "right":253        this.ed = alt || ctrl ? wordRight(this.ed) : moveRight(this.ed);254        return null;255      case "up": {256        const moved = moveUp(this.ed);257        if (moved !== null) this.ed = moved;258        else this.historyUp();259        return null;260      }261      case "down": {262        const moved = moveDown(this.ed);263        if (moved !== null) this.ed = moved;264        else this.historyDown();265        return null;266      }267    }268  }269270  // ───────────────────────── palettes ─────────────────────────271272  /** Returns an effect / null when consumed; undefined when NOT consumed. */273  private handlePaletteKey(key: KeyEvent): ComposerEffect | null | undefined {274    const palette = this.palette as PaletteState;275    if (key.type === "arrow" && key.key === "up") {276      this.palette = paletteMove(palette, -1);277      return null;278    }279    if (key.type === "arrow" && key.key === "down") {280      this.palette = paletteMove(palette, 1);281      return null;282    }283    if (key.type === "ctrl" && key.ch === "p") {284      this.palette = paletteMove(palette, -1);285      return null;286    }287    if (key.type === "ctrl" && key.ch === "n") {288      this.palette = paletteMove(palette, 1);289      return null;290    }291    if (key.type === "esc") {292      this.closePalette();293      return null;294    }295    if (key.type === "enter") {296      const item = paletteSelection(palette);297      if (item === null) {298        this.closePalette();299        return null;300      }301      if (palette.kind === "slash") {302        this.reset();303        return { type: "run-command", id: item.id };304      }305      this.insertMention(item); // uses mentionStart — must precede closePalette306      this.closePalette();307      return null;308    }309    if (key.type === "tab") {310      const item = paletteSelection(palette);311      if (item === null) return null;312      if (palette.kind === "slash") {313        // Complete without executing.314        this.pushUndo();315        this.ed = { text: item.label, cursor: item.label.length };316        this.spans = [];317        this.palette = paletteSetQuery(palette, item.label.slice(1));318        return null;319      }320      this.insertMention(item);321      this.closePalette();322      return null;323    }324    if (key.type === "char") {325      this.pushUndo();326      this.applyEdit((s) => insertText(s, key.ch), key.ch.length);327      this.refreshPaletteQuery();328      return null;329    }330    if (key.type === "backspace") {331      if (this.ed.cursor === 0) {332        this.closePalette();333        return null;334      }335      this.pushUndo();336      const before = this.ed.cursor;337      this.ed = backspace(this.ed);338      this.spans = adjustSpans(this.spans, this.ed.cursor, before - this.ed.cursor, 0);339      this.refreshPaletteQuery();340      return null;341    }342    return undefined; // any other key: close the palette, process normally343  }344345  private maybeOpenPalette(ch: string): void {346    if (ch === "/" && this.ed.text === "/") {347      this.palette = createPalette("slash", this.providers.slashCommands(), "");348      return;349    }350    if (ch === "@") {351      this.mentionStart = this.ed.cursor - 1;352      this.palette = createPalette("mention", this.providers.mentions(""), "");353    }354  }355356  private refreshPaletteQuery(): void {357    const palette = this.palette;358    if (palette === null) return;359    if (palette.kind === "slash") {360      if (!this.ed.text.startsWith("/")) {361        this.closePalette();362        return;363      }364      this.palette = paletteSetQuery(palette, this.ed.text.slice(1));365      return;366    }367    const start = this.mentionStart;368    if (start === null || start >= this.ed.cursor || this.ed.text[start] !== "@") {369      this.closePalette();370      return;371    }372    const query = this.ed.text.slice(start + 1, this.ed.cursor);373    if (/\s/.test(query)) {374      this.closePalette();375      return;376    }377    this.palette = {378      ...paletteSetQuery(379        { ...palette, items: this.providers.mentions(query) },380        query,381      ),382    };383  }384385  /** Replace `@query` with a structured file-reference span (`@path`). */386  private insertMention(item: PaletteItem): void {387    const start = this.mentionStart;388    if (start === null) return;389    this.pushUndo();390    const display = `@${item.id}`;391    const removed = this.ed.cursor - start;392    this.ed = {393      text: this.ed.text.slice(0, start) + display + " " + this.ed.text.slice(this.ed.cursor),394      cursor: start + display.length + 1,395    };396    this.spans = adjustSpans(this.spans, start, removed, display.length + 1);397    this.spans.push({ start, end: start + display.length, kind: "mention", payload: item.id });398    this.mentionStart = null;399  }400401  // ───────────────────────── paste / submit / history ─────────────────────────402403  private insertPaste(text: string): void {404    const lineCount = text.split("\n").length;405    if (lineCount >= PASTE_COLLAPSE_LINES || text.length > PASTE_COLLAPSE_CHARS) {406      const display = `⧉ pasted ${lineCount} lines`;407      const start = this.ed.cursor;408      this.applyEdit((s) => insertText(s, display), display.length);409      this.spans.push({ start, end: start + display.length, kind: "paste", payload: text });410      return;411    }412    this.applyEdit((s) => insertText(s, text), text.length);413  }414415  private submitOrContinue(): ComposerEffect | null {416    if (this.palette !== null) return null;417    const raw = this.ed.text;418    if (raw.trim() === "") return null;419    if (raw.endsWith("\\")) {420      // trailing `\` + Enter continues on the next line (§3.7)421      this.pushUndo();422      this.ed = { text: raw.slice(0, -1) + "\n", cursor: raw.length };423      return null;424    }425    const expanded = expandSpans(raw, this.spans);426    const shell = raw.startsWith("!");427    const text = shell ? expanded.slice(1).trim() : expanded;428    if (this.history[this.history.length - 1] !== raw) {429      this.history.push(raw);430      if (this.history.length > HISTORY_CAP) this.history.shift();431    }432    this.reset();433    return { type: "submit", text, shell };434  }435436  private historyUp(): void {437    if (this.history.length === 0) return;438    if (this.historyIndex === null) {439      this.historyDraft = this.ed.text;440      this.historyIndex = this.history.length - 1;441    } else if (this.historyIndex > 0) {442      this.historyIndex -= 1;443    } else {444      return;445    }446    const entry = this.history[this.historyIndex] as string;447    this.ed = { text: entry, cursor: entry.length };448    this.spans = [];449  }450451  private historyDown(): void {452    if (this.historyIndex === null) return;453    if (this.historyIndex < this.history.length - 1) {454      this.historyIndex += 1;455      const entry = this.history[this.historyIndex] as string;456      this.ed = { text: entry, cursor: entry.length };457    } else {458      this.historyIndex = null;459      this.ed = { text: this.historyDraft, cursor: this.historyDraft.length };460    }461    this.spans = [];462  }463464  private applyEdit(fn: (s: EditorState) => EditorState, inserted: number): void {465    const pos = this.ed.cursor;466    this.ed = fn(this.ed);467    this.spans = adjustSpans(this.spans, pos, 0, inserted);468  }469470  private pushUndo(): void {471    this.undoStack.push({ editor: this.ed, spans: this.spans });472    if (this.undoStack.length > UNDO_CAP) this.undoStack.shift();473  }474475  // ───────────────────────── rendering ─────────────────────────476477  /**478   * Render the composer as the bordered box (components/composer-box.ts):479   * `❯` prompt glyph (accent; dim while the agent works — queued input480   * allowed, the composer never locks), `!` (warning) in shell mode, growth481   * to `maxRows` content lines with internal scroll, simple-line fallback on482   * narrow terminals.483   */484  render(width: number, maxRows: number, theme: Theme, opts: ComposerBoxOptions): ComposerRender {485    return renderComposerBox(486      { text: this.ed.text, cursor: this.ed.cursor, shell: this.isShellMode() },487      width,488      maxRows,489      theme,490      opts,491    );492  }493}494