/** * KHAELOR * File: src/tui/composer/composer.ts * Description: The composer — multiline editing, history, paste collapsing, slash/mention palettes, shell mode, queueing. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { EMPTY_EDITOR, adjustSpans, backspace, deleteForward, deleteWordBack, deleteWordForward, expandSpans, insertText, killToLineStart, moveDown, moveLeft, moveLineEnd, moveLineStart, moveRight, moveUp, wordLeft, wordRight, } from "./editor.js"; import type { EditorState, Span } from "./editor.js"; import { createPalette, paletteMove, paletteSelection, paletteSetQuery, } from "./palette.js"; import type { PaletteItem, PaletteState } from "./palette.js"; import { renderComposerBox } from "../components/composer-box.js"; import type { ComposerBoxOptions } from "../components/composer-box.js"; import type { KeyEvent } from "../renderer/input.js"; import type { Theme } from "../theme.js"; /** Injected sources — the repository index plugs in here later (Phase 7). */ export interface ComposerProviders { slashCommands(): PaletteItem[]; mentions(query: string): PaletteItem[]; } export type ComposerEffect = | { type: "submit"; text: string; shell: boolean } | { type: "run-command"; id: string } | { type: "discard-queued" }; export interface ComposerRender { lines: string[]; caretRow: number; caretCol: number; } interface UndoEntry { editor: EditorState; spans: Span[]; } const PASTE_COLLAPSE_LINES = 3; const PASTE_COLLAPSE_CHARS = 150; const HISTORY_CAP = 50; const UNDO_CAP = 200; export class Composer { private ed: EditorState = EMPTY_EDITOR; private spans: Span[] = []; private undoStack: UndoEntry[] = []; private history: string[] = []; private historyIndex: number | null = null; private historyDraft = ""; private palette: PaletteState | null = null; private mentionStart: number | null = null; private readonly providers: ComposerProviders; constructor(providers: ComposerProviders, historyEntries: string[] = []) { this.providers = providers; this.history = historyEntries.slice(-HISTORY_CAP); } text(): string { return this.ed.text; } isEmpty(): boolean { return this.ed.text === ""; } isShellMode(): boolean { return this.ed.text.startsWith("!"); } paletteState(): PaletteState | null { return this.palette; } hasPalette(): boolean { return this.palette !== null; } closePalette(): void { this.palette = null; this.mentionStart = null; } reset(): void { this.ed = EMPTY_EDITOR; this.spans = []; this.undoStack = []; this.historyIndex = null; this.closePalette(); } /** * Handle one key. Returns an effect for the app to act on, or null when * fully consumed. Precedence: palette (when open) > composer bindings. */ handleKey(key: KeyEvent): ComposerEffect | null { if (this.palette !== null) { const consumed = this.handlePaletteKey(key); if (consumed !== undefined) return consumed; // not consumed → close and fall through to normal handling this.closePalette(); } switch (key.type) { case "char": this.pushUndo(); this.applyEdit((s) => insertText(s, key.ch), key.ch.length); this.maybeOpenPalette(key.ch); return null; case "paste": this.pushUndo(); this.insertPaste(key.text.replace(/\r\n?/g, "\n")); return null; case "enter": return this.submitOrContinue(); case "shift-enter": this.pushUndo(); this.applyEdit((s) => insertText(s, "\n"), 1); return null; case "backspace": if (this.ed.cursor > 0) { this.pushUndo(); const before = this.ed.cursor; this.ed = backspace(this.ed); this.spans = adjustSpans(this.spans, this.ed.cursor, before - this.ed.cursor, 0); } return null; case "delete": this.pushUndo(); this.ed = deleteForward(this.ed); this.spans = adjustSpans(this.spans, this.ed.cursor, 1, 0); return null; case "arrow": return this.handleArrow(key.key, key.alt, key.ctrl); case "home": this.ed = moveLineStart(this.ed); return null; case "end": this.ed = moveLineEnd(this.ed); return null; case "tab": return null; // completion is palette-scoped case "alt": if (key.ch === "b") this.ed = wordLeft(this.ed); else if (key.ch === "f") this.ed = wordRight(this.ed); else if (key.ch === "d") { this.pushUndo(); const end = wordRight(this.ed).cursor; this.ed = deleteWordForward(this.ed); this.spans = adjustSpans(this.spans, this.ed.cursor, end - this.ed.cursor, 0); } return null; case "alt-backspace": this.pushUndo(); return this.doDeleteWordBack(); case "ctrl": return this.handleCtrl(key.ch); default: return null; } } private handleCtrl(ch: string): ComposerEffect | null { switch (ch) { case "a": this.ed = moveLineStart(this.ed); return null; case "e": this.ed = moveLineEnd(this.ed); return null; case "b": this.ed = moveLeft(this.ed); return null; case "f": this.ed = moveRight(this.ed); return null; case "j": { this.pushUndo(); this.applyEdit((s) => insertText(s, "\n"), 1); return null; } case "w": this.pushUndo(); return this.doDeleteWordBack(); case "u": { if (this.ed.text === "") return { type: "discard-queued" }; this.pushUndo(); const before = this.ed.cursor; this.ed = killToLineStart(this.ed); this.spans = adjustSpans(this.spans, this.ed.cursor, before - this.ed.cursor, 0); return null; } case "h": if (this.ed.cursor > 0) { this.pushUndo(); const before = this.ed.cursor; this.ed = backspace(this.ed); this.spans = adjustSpans(this.spans, this.ed.cursor, before - this.ed.cursor, 0); } return null; case "_": { const entry = this.undoStack.pop(); if (entry) { this.ed = entry.editor; this.spans = entry.spans; } return null; } default: return null; } } private doDeleteWordBack(): null { const before = this.ed.cursor; this.ed = deleteWordBack(this.ed); this.spans = adjustSpans(this.spans, this.ed.cursor, before - this.ed.cursor, 0); return null; } private handleArrow(key: "up" | "down" | "left" | "right", alt: boolean, ctrl: boolean): null { switch (key) { case "left": this.ed = alt || ctrl ? wordLeft(this.ed) : moveLeft(this.ed); return null; case "right": this.ed = alt || ctrl ? wordRight(this.ed) : moveRight(this.ed); return null; case "up": { const moved = moveUp(this.ed); if (moved !== null) this.ed = moved; else this.historyUp(); return null; } case "down": { const moved = moveDown(this.ed); if (moved !== null) this.ed = moved; else this.historyDown(); return null; } } } // ───────────────────────── palettes ───────────────────────── /** Returns an effect / null when consumed; undefined when NOT consumed. */ private handlePaletteKey(key: KeyEvent): ComposerEffect | null | undefined { const palette = this.palette as PaletteState; if (key.type === "arrow" && key.key === "up") { this.palette = paletteMove(palette, -1); return null; } if (key.type === "arrow" && key.key === "down") { this.palette = paletteMove(palette, 1); return null; } if (key.type === "ctrl" && key.ch === "p") { this.palette = paletteMove(palette, -1); return null; } if (key.type === "ctrl" && key.ch === "n") { this.palette = paletteMove(palette, 1); return null; } if (key.type === "esc") { this.closePalette(); return null; } if (key.type === "enter") { const item = paletteSelection(palette); if (item === null) { this.closePalette(); return null; } if (palette.kind === "slash") { this.reset(); return { type: "run-command", id: item.id }; } this.insertMention(item); // uses mentionStart — must precede closePalette this.closePalette(); return null; } if (key.type === "tab") { const item = paletteSelection(palette); if (item === null) return null; if (palette.kind === "slash") { // Complete without executing. this.pushUndo(); this.ed = { text: item.label, cursor: item.label.length }; this.spans = []; this.palette = paletteSetQuery(palette, item.label.slice(1)); return null; } this.insertMention(item); this.closePalette(); return null; } if (key.type === "char") { this.pushUndo(); this.applyEdit((s) => insertText(s, key.ch), key.ch.length); this.refreshPaletteQuery(); return null; } if (key.type === "backspace") { if (this.ed.cursor === 0) { this.closePalette(); return null; } this.pushUndo(); const before = this.ed.cursor; this.ed = backspace(this.ed); this.spans = adjustSpans(this.spans, this.ed.cursor, before - this.ed.cursor, 0); this.refreshPaletteQuery(); return null; } return undefined; // any other key: close the palette, process normally } private maybeOpenPalette(ch: string): void { if (ch === "/" && this.ed.text === "/") { this.palette = createPalette("slash", this.providers.slashCommands(), ""); return; } if (ch === "@") { this.mentionStart = this.ed.cursor - 1; this.palette = createPalette("mention", this.providers.mentions(""), ""); } } private refreshPaletteQuery(): void { const palette = this.palette; if (palette === null) return; if (palette.kind === "slash") { if (!this.ed.text.startsWith("/")) { this.closePalette(); return; } this.palette = paletteSetQuery(palette, this.ed.text.slice(1)); return; } const start = this.mentionStart; if (start === null || start >= this.ed.cursor || this.ed.text[start] !== "@") { this.closePalette(); return; } const query = this.ed.text.slice(start + 1, this.ed.cursor); if (/\s/.test(query)) { this.closePalette(); return; } this.palette = { ...paletteSetQuery( { ...palette, items: this.providers.mentions(query) }, query, ), }; } /** Replace `@query` with a structured file-reference span (`@path`). */ private insertMention(item: PaletteItem): void { const start = this.mentionStart; if (start === null) return; this.pushUndo(); const display = `@${item.id}`; const removed = this.ed.cursor - start; this.ed = { text: this.ed.text.slice(0, start) + display + " " + this.ed.text.slice(this.ed.cursor), cursor: start + display.length + 1, }; this.spans = adjustSpans(this.spans, start, removed, display.length + 1); this.spans.push({ start, end: start + display.length, kind: "mention", payload: item.id }); this.mentionStart = null; } // ───────────────────────── paste / submit / history ───────────────────────── private insertPaste(text: string): void { const lineCount = text.split("\n").length; if (lineCount >= PASTE_COLLAPSE_LINES || text.length > PASTE_COLLAPSE_CHARS) { const display = `⧉ pasted ${lineCount} lines`; const start = this.ed.cursor; this.applyEdit((s) => insertText(s, display), display.length); this.spans.push({ start, end: start + display.length, kind: "paste", payload: text }); return; } this.applyEdit((s) => insertText(s, text), text.length); } private submitOrContinue(): ComposerEffect | null { if (this.palette !== null) return null; const raw = this.ed.text; if (raw.trim() === "") return null; if (raw.endsWith("\\")) { // trailing `\` + Enter continues on the next line (§3.7) this.pushUndo(); this.ed = { text: raw.slice(0, -1) + "\n", cursor: raw.length }; return null; } const expanded = expandSpans(raw, this.spans); const shell = raw.startsWith("!"); const text = shell ? expanded.slice(1).trim() : expanded; if (this.history[this.history.length - 1] !== raw) { this.history.push(raw); if (this.history.length > HISTORY_CAP) this.history.shift(); } this.reset(); return { type: "submit", text, shell }; } private historyUp(): void { if (this.history.length === 0) return; if (this.historyIndex === null) { this.historyDraft = this.ed.text; this.historyIndex = this.history.length - 1; } else if (this.historyIndex > 0) { this.historyIndex -= 1; } else { return; } const entry = this.history[this.historyIndex] as string; this.ed = { text: entry, cursor: entry.length }; this.spans = []; } private historyDown(): void { if (this.historyIndex === null) return; if (this.historyIndex < this.history.length - 1) { this.historyIndex += 1; const entry = this.history[this.historyIndex] as string; this.ed = { text: entry, cursor: entry.length }; } else { this.historyIndex = null; this.ed = { text: this.historyDraft, cursor: this.historyDraft.length }; } this.spans = []; } private applyEdit(fn: (s: EditorState) => EditorState, inserted: number): void { const pos = this.ed.cursor; this.ed = fn(this.ed); this.spans = adjustSpans(this.spans, pos, 0, inserted); } private pushUndo(): void { this.undoStack.push({ editor: this.ed, spans: this.spans }); if (this.undoStack.length > UNDO_CAP) this.undoStack.shift(); } // ───────────────────────── rendering ───────────────────────── /** * Render the composer as the bordered box (components/composer-box.ts): * `❯` prompt glyph (accent; dim while the agent works — queued input * allowed, the composer never locks), `!` (warning) in shell mode, growth * to `maxRows` content lines with internal scroll, simple-line fallback on * narrow terminals. */ render(width: number, maxRows: number, theme: Theme, opts: ComposerBoxOptions): ComposerRender { return renderComposerBox( { text: this.ed.text, cursor: this.ed.cursor, shell: this.isShellMode() }, width, maxRows, theme, opts, ); } }