Compare commits
1 Commits
38a2d26bb7
...
5fd8de031d
| Author | SHA1 | Date | |
|---|---|---|---|
| 5fd8de031d |
@@ -1,240 +0,0 @@
|
|||||||
#!/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"
|
|
||||||
Reference in New Issue
Block a user