#!/usr/bin/env bash set -euo pipefail repo_root=$(git rev-parse --show-toplevel) cd "$repo_root" pi_package=$(nix build --no-link --print-out-paths .#nixosConfigurations.neogaia.config.home-manager.users.alexion.programs.pi-coding-agent.package) tmpdir=$(mktemp -d) trap 'rm -rf "$tmpdir"' EXIT cat > "$tmpdir/autocomplete-bottom-alignment.mjs" <<'JS' import assert from "node:assert/strict"; import { pathToFileURL } from "node:url"; const piPackage = process.env.PI_PACKAGE; const tuiModuleUrl = pathToFileURL( `${piPackage}/lib/node_modules/pi-monorepo/node_modules/@earendil-works/pi-tui/dist/index.js`, ).href; const settingsModuleUrl = pathToFileURL( `${piPackage}/lib/node_modules/pi-monorepo/dist/core/settings-manager.js`, ).href; const { Editor, TUI } = await import(tuiModuleUrl); const { SettingsManager } = await import(settingsModuleUrl); if (process.env.PI_CLEAR_ON_SHRINK !== "0") { assert.equal(SettingsManager.inMemory().getClearOnShrink(), true, "interactive sessions should enable shrink clearing by default"); } class VirtualTerminal { constructor(columns, rows) { this._columns = columns; this._rows = rows; this.cursorRow = 0; this.cursorCol = 0; this.screen = Array.from({ length: rows }, () => Array(columns).fill(" ")); } start(onInput, onResize) { this.inputHandler = onInput; this.resizeHandler = onResize; } async drainInput() {} stop() {} write(data) { this.applyOutput(data); } get columns() { return this._columns; } get rows() { return this._rows; } get kittyProtocolActive() { return false; } moveBy(lines) { this.moveCursor(lines, 0); } hideCursor() {} showCursor() {} clearLine() { this.clearLineFromCursor(); } setTitle() {} setProgress() {} sendInput(data) { this.inputHandler?.(data); } async waitForRender() { await new Promise((resolve) => process.nextTick(resolve)); await new Promise((resolve) => setTimeout(resolve, 30)); } getViewport() { return this.screen.map((line) => line.join("")); } applyOutput(data) { for (let i = 0; i < data.length; i += 1) { const char = data[i]; if (char === "\x1b") { i = this.consumeEscape(data, i); } else if (char === "\r") { this.cursorCol = 0; } else if (char === "\n") { this.newline(); } else if (char >= " ") { this.putChar(char); } } } consumeEscape(data, index) { const next = data[index + 1]; if (next === "[") { let end = index + 2; while (end < data.length && !/[A-Za-z]/.test(data[end])) end += 1; if (end < data.length) this.applyCsi(data.slice(index + 2, end), data[end]); return end; } if (next === "]" || next === "_") { const end = data.indexOf("\x07", index + 2); return end === -1 ? data.length - 1 : end; } return index + 1; } applyCsi(params, command) { const cleanParams = params.replace(/^\?/, ""); const values = cleanParams.length === 0 ? [] : cleanParams.split(";").map((value) => Number(value) || 0); const first = values[0] || 1; if (command === "A") this.moveCursor(-first, 0); else if (command === "B") this.moveCursor(first, 0); else if (command === "G") this.cursorCol = this.clamp(first - 1, 0, this.columns - 1); else if (command === "H") { this.cursorRow = this.clamp((values[0] || 1) - 1, 0, this.rows - 1); this.cursorCol = this.clamp((values[1] || 1) - 1, 0, this.columns - 1); } else if (command === "K") { if (values[0] === 2) this.screen[this.cursorRow].fill(" "); else this.clearLineFromCursor(); } else if (command === "J") { if (values[0] === 2 || values[0] === 3) this.clearScreen(); else this.clearFromCursor(); } } putChar(char) { this.screen[this.cursorRow][this.cursorCol] = char; if (this.cursorCol < this.columns - 1) this.cursorCol += 1; } newline() { if (this.cursorRow === this.rows - 1) { this.screen.shift(); this.screen.push(Array(this.columns).fill(" ")); } else { this.cursorRow += 1; } } moveCursor(rowDelta, colDelta) { this.cursorRow = this.clamp(this.cursorRow + rowDelta, 0, this.rows - 1); this.cursorCol = this.clamp(this.cursorCol + colDelta, 0, this.columns - 1); } clearLineFromCursor() { this.screen[this.cursorRow].fill(" ", this.cursorCol); } clearFromCursor() { this.clearLineFromCursor(); for (let row = this.cursorRow + 1; row < this.rows; row += 1) { this.screen[row].fill(" "); } } clearScreen() { for (const line of this.screen) line.fill(" "); this.cursorRow = 0; this.cursorCol = 0; } clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } } class Lines { constructor(lines) { this.lines = lines; } render() { return this.lines; } invalidate() {} } class BottomLayout { constructor(tui, flowChildren, pinnedChildren) { this.tui = tui; this.flowChildren = flowChildren; this.pinnedChildren = pinnedChildren; } invalidate() {} render(width) { const flowLines = this.flowChildren.flatMap((child) => child.render(width)); const pinnedLines = this.pinnedChildren.flatMap((child) => child.render(width)); const spacerRows = Math.max(0, this.tui.terminal.rows - flowLines.length - pinnedLines.length); return [...flowLines, ...Array.from({ length: spacerRows }, () => ""), ...pinnedLines]; } } const plain = (value) => value; const theme = { borderColor: plain, selectList: { selectedPrefix: plain, selectedText: plain, description: plain, scrollInfo: plain, noMatch: plain, }, }; const provider = { triggerCharacters: ["/"], async getSuggestions() { return { prefix: "/", items: Array.from({ length: 8 }, (_, index) => ({ value: `cmd${index}`, label: `/cmd${index}`, description: `description ${index}`, })), }; }, applyCompletion(_lines, _line, _col, item) { return { lines: [item.value], cursorLine: 0, cursorCol: item.value.length }; }, }; async function waitUntil(predicate, description) { const deadline = Date.now() + 1000; while (Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 10)); if (predicate()) return; } assert.fail(`timed out waiting for ${description}`); } const terminal = new VirtualTerminal(50, 10); const tui = new TUI(terminal); const editor = new Editor(tui, theme, { autocompleteMaxVisible: 5 }); editor.setAutocompleteProvider(provider); tui.addChild(new BottomLayout(tui, [new Lines(["chat"])], [editor, new Lines(["footer"])])); tui.setFocus(editor); tui.start(); await terminal.waitForRender(); terminal.sendInput("/"); await waitUntil(() => editor.autocompleteState !== null, "autocomplete to open"); await waitUntil(() => tui.previousLines.length === 11, "open autocomplete render"); await terminal.waitForRender(); terminal.sendInput("\x1b"); await waitUntil(() => editor.autocompleteState === null, "autocomplete to close"); await waitUntil(() => tui.previousLines.length === 10, "closed autocomplete render"); await terminal.waitForRender(); const viewport = terminal.getViewport(); tui.stop(); const trimmed = viewport.map((line) => line.trimEnd()); assert.equal(trimmed.at(-1), "footer", `footer should return to the bottom row after autocomplete closes\n${trimmed.map((line, index) => `${index}: ${JSON.stringify(line)}`).join("\n")}`); assert.equal(trimmed[0], "chat", `chat line should be visible at the top of the bottom-aligned layout\n${trimmed.map((line, index) => `${index}: ${JSON.stringify(line)}`).join("\n")}`); JS PI_PACKAGE="$pi_package" nix shell nixpkgs#nodejs_22 -c node "$tmpdir/autocomplete-bottom-alignment.mjs"