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/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