blob: d9cab35fe074d8cae634dccc41b80045670bf46e [file]
import type { Component, Editor } from '@earendil-works/pi-tui';
import { stripAnsi } from './tui-ansi.js';
// The pi-tui Editor renders its autocomplete menu at the tail of its render
// output, i.e. below the input box. The Maka TUI pins the input at the bottom
// of the screen, so those suggestions must appear *above* the editor instead.
//
// Editor exposes no way to render the menu on its own (only isShowingAutocomplete()),
// so this module post-processes the lines Editor.render() returns: it locates the
// editor's chrome borders and moves the trailing suggestion block above them.
//
// Fragile by necessity: isEditorChromeLine pattern-matches the editor's border
// lines (all-dash) and scroll hint ("--- UP/DOWN N more ---"). If pi-tui changes
// those shapes, this split silently misfires (suggestions vanish or land wrong)
// with no compile error. Re-verify these patterns whenever pi-tui is upgraded.
export interface MakaAutocompleteArrangementInput {
lines: string[];
autocompleteShowing: boolean;
autocompleteSlotRows: number;
}
export interface MakaAutocompleteArrangementResult {
lines: string[];
autocompleteSlotRows: number;
}
export function arrangeAutocompleteAboveEditor(
input: MakaAutocompleteArrangementInput,
): MakaAutocompleteArrangementResult {
if (!input.autocompleteShowing) {
return { lines: input.lines, autocompleteSlotRows: 0 };
}
const sections = splitTrailingAutocomplete(input.lines);
if (sections.autocompleteLines.length === 0) {
return { lines: sections.editorLines, autocompleteSlotRows: 0 };
}
const autocompleteSlotRows = Math.max(
input.autocompleteSlotRows,
sections.autocompleteLines.length,
);
return {
lines: [
...Array.from({ length: autocompleteSlotRows - sections.autocompleteLines.length }, () => ''),
...sections.autocompleteLines,
...sections.editorLines,
],
autocompleteSlotRows,
};
}
export class MakaAutocompleteAboveEditorComponent implements Component {
private autocompleteSlotRows = 0;
constructor(private readonly editor: Editor) {}
get focused(): boolean {
return this.editor.focused;
}
set focused(value: boolean) {
this.editor.focused = value;
}
invalidate(): void {
this.editor.invalidate();
}
handleInput(data: string): void {
this.editor.handleInput(data);
}
render(width: number): string[] {
const lines = this.editor.render(width);
const result = arrangeAutocompleteAboveEditor({
lines,
autocompleteShowing: this.editor.isShowingAutocomplete(),
autocompleteSlotRows: this.autocompleteSlotRows,
});
this.autocompleteSlotRows = result.autocompleteSlotRows;
return result.lines;
}
}
interface MakaAutocompleteSections {
autocompleteLines: string[];
editorLines: string[];
}
function splitTrailingAutocomplete(lines: string[]): MakaAutocompleteSections {
const bottomBorderIndex = findLastIndex(lines, isEditorChromeLine);
if (bottomBorderIndex < 1 || bottomBorderIndex === lines.length - 1) {
return { autocompleteLines: [], editorLines: lines };
}
const topBorderIndex = findLastIndex(lines.slice(0, bottomBorderIndex), isEditorChromeLine);
if (topBorderIndex < 0) return { autocompleteLines: [], editorLines: lines };
return {
autocompleteLines: lines.slice(bottomBorderIndex + 1),
editorLines: lines.slice(0, bottomBorderIndex + 1),
};
}
function isEditorChromeLine(line: string): boolean {
const text = stripAnsi(line);
return /^─+$/.test(text) || /^─── [↑↓] \d+ more ─*$/.test(text);
}
function findLastIndex<T>(items: readonly T[], predicate: (item: T) => boolean): number {
for (let index = items.length - 1; index >= 0; index -= 1) {
if (predicate(items[index]!)) return index;
}
return -1;
}