Compare commits
1 Commits
main
...
38a2d26bb7
| Author | SHA1 | Date | |
|---|---|---|---|
| 38a2d26bb7 |
@@ -81,4 +81,5 @@ The domain model (Host, Module, Skeleton, Auto-loader, Enable convention, overla
|
|||||||
- Flake-managed Pi extension, prompt, and skill directories may still be written directly for throwaway development or local experiments.
|
- Flake-managed Pi extension, prompt, and skill directories may still be written directly for throwaway development or local experiments.
|
||||||
The risk is that a later Home Manager activation can overwrite or hide those unmanaged files, so finished work must be promoted into the dotfiles module before it counts as deployed.
|
The risk is that a later Home Manager activation can overwrite or hide those unmanaged files, so finished work must be promoted into the dotfiles module before it counts as deployed.
|
||||||
- Pi's tool discovery checks `~/.pi/agent/bin` before `PATH`, and downloaded generic Linux binaries there can be unusable on NixOS with the stub-ld error.
|
- Pi's tool discovery checks `~/.pi/agent/bin` before `PATH`, and downloaded generic Linux binaries there can be unusable on NixOS with the stub-ld error.
|
||||||
A copied or patched Pi launcher that only prepends Nix `fd`/`rg` to `PATH` may still break `@` autocomplete unless the local tool path is removed or Pi validates the local binary before using it.
|
This flake patches Pi to validate local tool binaries before selecting them, so it falls back to usable `fd`/`rg` from `PATH` instead.
|
||||||
|
Stale unpatched launchers are the remaining failure mode for broken `@` autocomplete.
|
||||||
|
|||||||
169
modules/agents/pi/patches/pi-flex-spacer.patch
Normal file
169
modules/agents/pi/patches/pi-flex-spacer.patch
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts
|
||||||
|
--- a/packages/tui/src/tui.ts 2026-08-02 00:16:00.000000000 -0400
|
||||||
|
+++ b/packages/tui/src/tui.ts 2026-08-02 00:16:00.000000000 -0400
|
||||||
|
@@ -310,7 +310,7 @@ export class TUI extends Container {
|
||||||
|
private cursorRow = 0; // Logical cursor row (end of rendered content)
|
||||||
|
private hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
|
||||||
|
private showHardwareCursor = process.env.PI_HARDWARE_CURSOR === "1";
|
||||||
|
- private clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"; // Clear empty rows when content shrinks (default: off)
|
||||||
|
+ private clearOnShrink = process.env.PI_CLEAR_ON_SHRINK !== "0"; // Clear empty rows when content shrinks (default: on)
|
||||||
|
private maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)
|
||||||
|
private previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
|
||||||
|
private fullRedrawCount = 0;
|
||||||
|
|
||||||
|
diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts
|
||||||
|
--- a/packages/coding-agent/src/core/settings-manager.ts 2026-08-02 00:34:00.000000000 -0400
|
||||||
|
+++ b/packages/coding-agent/src/core/settings-manager.ts 2026-08-02 00:34:00.000000000 -0400
|
||||||
|
@@ -1093,11 +1093,11 @@ export class SettingsManager {
|
||||||
|
}
|
||||||
|
|
||||||
|
getClearOnShrink(): boolean {
|
||||||
|
- // Settings takes precedence, then env var, then default false
|
||||||
|
+ // Settings takes precedence, then env var, then default true
|
||||||
|
if (this.settings.terminal?.clearOnShrink !== undefined) {
|
||||||
|
return this.settings.terminal.clearOnShrink;
|
||||||
|
}
|
||||||
|
- return process.env.PI_CLEAR_ON_SHRINK === "1";
|
||||||
|
+ return process.env.PI_CLEAR_ON_SHRINK !== "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
setClearOnShrink(enabled: boolean): void {
|
||||||
|
|
||||||
|
diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts
|
||||||
|
--- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts 2026-08-01 18:41:36.963495957 -0400
|
||||||
|
+++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts 2026-08-01 18:43:04.876341236 -0400
|
||||||
|
@@ -210,6 +210,45 @@
|
||||||
|
return code !== undefined && DEAD_TERMINAL_ERROR_CODES.has(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
+class FlexSpacerBottomLayout implements Component {
|
||||||
|
+ private readonly ui: TUI;
|
||||||
|
+ private readonly flowChildren: Component[];
|
||||||
|
+ private readonly pinnedChildren: Component[];
|
||||||
|
+
|
||||||
|
+ constructor(ui: TUI, flowChildren: Component[], pinnedChildren: Component[]) {
|
||||||
|
+ this.ui = ui;
|
||||||
|
+ this.flowChildren = flowChildren;
|
||||||
|
+ this.pinnedChildren = pinnedChildren;
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ invalidate(): void {
|
||||||
|
+ for (const child of [...this.flowChildren, ...this.pinnedChildren]) {
|
||||||
|
+ child.invalidate();
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ private renderGroup(children: Component[], width: number): string[] {
|
||||||
|
+ const lines: string[] = [];
|
||||||
|
+ for (const child of children) {
|
||||||
|
+ lines.push(...child.render(width));
|
||||||
|
+ }
|
||||||
|
+ return lines;
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ render(width: number): string[] {
|
||||||
|
+ const flowLines = this.renderGroup(this.flowChildren, width);
|
||||||
|
+ const pinnedLines = this.renderGroup(this.pinnedChildren, width);
|
||||||
|
+ const terminalRows = this.ui.terminal.rows;
|
||||||
|
+ const spacerRows = Math.max(0, terminalRows - flowLines.length - pinnedLines.length);
|
||||||
|
+
|
||||||
|
+ return [
|
||||||
|
+ ...flowLines,
|
||||||
|
+ ...Array.from({ length: spacerRows }, () => ""),
|
||||||
|
+ ...pinnedLines,
|
||||||
|
+ ];
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
const ANTHROPIC_SUBSCRIPTION_AUTH_WARNING =
|
||||||
|
"Anthropic subscription auth is active. Third-party harness usage draws from extra usage and is billed per token, not your Claude plan limits. Manage extra usage at https://claude.ai/settings/usage. Disable this warning in /settings.";
|
||||||
|
|
||||||
|
@@ -335,6 +374,7 @@
|
||||||
|
private fdPath: string | undefined;
|
||||||
|
private editorContainer: Container;
|
||||||
|
private footer: FooterComponent;
|
||||||
|
+ private footerContainer: Container;
|
||||||
|
private footerDataProvider: FooterDataProvider;
|
||||||
|
// Stored so the same manager can be injected into custom editors, selectors, and extension UI.
|
||||||
|
private keybindings: KeybindingsManager;
|
||||||
|
@@ -477,7 +517,9 @@
|
||||||
|
this.editorContainer = new Container();
|
||||||
|
this.editorContainer.addChild(this.editor as Component);
|
||||||
|
this.footerDataProvider = new FooterDataProvider(this.sessionManager.getCwd());
|
||||||
|
+ this.footerContainer = new Container();
|
||||||
|
this.footer = new FooterComponent(this.session, this.footerDataProvider);
|
||||||
|
+ this.footerContainer.addChild(this.footer);
|
||||||
|
this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled);
|
||||||
|
|
||||||
|
// Load hide thinking block setting
|
||||||
|
@@ -704,19 +746,25 @@
|
||||||
|
console.log(theme.fg("dim", `Model scope: ${modelList}${cycleHint}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
- // Add header container as first child. Populate it after applying theme settings.
|
||||||
|
- // Keep loaded resources before chat so restored session messages never precede them.
|
||||||
|
- this.ui.addChild(this.headerContainer);
|
||||||
|
- this.ui.addChild(this.loadedResourcesContainer);
|
||||||
|
-
|
||||||
|
- this.ui.addChild(this.chatContainer);
|
||||||
|
- this.ui.addChild(this.pendingMessagesContainer);
|
||||||
|
- this.ui.addChild(this.statusContainer);
|
||||||
|
this.renderWidgets(); // Initialize with default spacer
|
||||||
|
- this.ui.addChild(this.widgetContainerAbove);
|
||||||
|
- this.ui.addChild(this.editorContainer);
|
||||||
|
- this.ui.addChild(this.widgetContainerBelow);
|
||||||
|
- this.ui.addChild(this.footer);
|
||||||
|
+ this.ui.addChild(
|
||||||
|
+ new FlexSpacerBottomLayout(
|
||||||
|
+ this.ui,
|
||||||
|
+ [
|
||||||
|
+ this.headerContainer,
|
||||||
|
+ this.loadedResourcesContainer,
|
||||||
|
+ this.chatContainer,
|
||||||
|
+ ],
|
||||||
|
+ [
|
||||||
|
+ this.pendingMessagesContainer,
|
||||||
|
+ this.statusContainer,
|
||||||
|
+ this.widgetContainerAbove,
|
||||||
|
+ this.editorContainer,
|
||||||
|
+ this.widgetContainerBelow,
|
||||||
|
+ this.footerContainer,
|
||||||
|
+ ],
|
||||||
|
+ ),
|
||||||
|
+ );
|
||||||
|
this.ui.setFocus(this.editor);
|
||||||
|
|
||||||
|
this.setupKeyHandlers();
|
||||||
|
@@ -2033,25 +2081,25 @@
|
||||||
|
| ((tui: TUI, thm: Theme, footerData: ReadonlyFooterDataProvider) => Component & { dispose?(): void })
|
||||||
|
| undefined,
|
||||||
|
): void {
|
||||||
|
- // Dispose existing custom footer
|
||||||
|
+ // Dispose existing custom footer
|
||||||
|
if (this.customFooter?.dispose) {
|
||||||
|
this.customFooter.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
- // Remove current footer from UI
|
||||||
|
+ // Remove current footer from its pinned layout slot.
|
||||||
|
if (this.customFooter) {
|
||||||
|
- this.ui.removeChild(this.customFooter);
|
||||||
|
+ this.footerContainer.removeChild(this.customFooter);
|
||||||
|
} else {
|
||||||
|
- this.ui.removeChild(this.footer);
|
||||||
|
+ this.footerContainer.removeChild(this.footer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (factory) {
|
||||||
|
// Create and add custom footer, passing the data provider
|
||||||
|
this.customFooter = factory(this.ui, theme, this.footerDataProvider);
|
||||||
|
- this.ui.addChild(this.customFooter);
|
||||||
|
+ this.footerContainer.addChild(this.customFooter);
|
||||||
|
} else {
|
||||||
|
// Restore built-in footer
|
||||||
|
this.customFooter = undefined;
|
||||||
|
- this.ui.addChild(this.footer);
|
||||||
|
+ this.footerContainer.addChild(this.footer);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ui.requestRender();
|
||||||
22
modules/agents/pi/patches/pi-tool-lookup-validation.patch
Normal file
22
modules/agents/pi/patches/pi-tool-lookup-validation.patch
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
diff --git a/packages/coding-agent/src/utils/tools-manager.ts b/packages/coding-agent/src/utils/tools-manager.ts
|
||||||
|
--- a/packages/coding-agent/src/utils/tools-manager.ts 2026-08-01 18:41:36.970496010 -0400
|
||||||
|
+++ b/packages/coding-agent/src/utils/tools-manager.ts 2026-08-01 18:41:37.028186009 -0400
|
||||||
|
@@ -74,8 +74,7 @@
|
||||||
|
function commandExists(cmd: string): boolean {
|
||||||
|
try {
|
||||||
|
const result = spawnSync(cmd, ["--version"], { stdio: "pipe" });
|
||||||
|
- // Check for ENOENT error (command not found)
|
||||||
|
- return result.error === undefined || result.error === null;
|
||||||
|
+ return (result.error === undefined || result.error === null) && result.status === 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
@@ -88,7 +87,7 @@
|
||||||
|
|
||||||
|
// Check our tools directory first
|
||||||
|
const localPath = join(TOOLS_DIR, config.binaryName + (platform() === "win32" ? ".exe" : ""));
|
||||||
|
- if (existsSync(localPath)) {
|
||||||
|
+ if (existsSync(localPath) && commandExists(localPath)) {
|
||||||
|
return localPath;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -11,6 +11,12 @@ let
|
|||||||
cfg = config.modules.agents.pi;
|
cfg = config.modules.agents.pi;
|
||||||
user = config.user.name;
|
user = config.user.name;
|
||||||
piDir = "${config.users.users.${user}.home}/.pi/agent";
|
piDir = "${config.users.users.${user}.home}/.pi/agent";
|
||||||
|
patchedPi = pkgs.pi-coding-agent.overrideAttrs (old: {
|
||||||
|
patches = (old.patches or [ ]) ++ [
|
||||||
|
./patches/pi-flex-spacer.patch
|
||||||
|
./patches/pi-tool-lookup-validation.patch
|
||||||
|
];
|
||||||
|
});
|
||||||
herdrPiIntegration = pkgs.stdenvNoCC.mkDerivation {
|
herdrPiIntegration = pkgs.stdenvNoCC.mkDerivation {
|
||||||
name = "herdr-pi-integration";
|
name = "herdr-pi-integration";
|
||||||
nativeBuildInputs = [ pkgs.herdr ];
|
nativeBuildInputs = [ pkgs.herdr ];
|
||||||
@@ -40,6 +46,7 @@ in
|
|||||||
home-manager.users.${user} = {
|
home-manager.users.${user} = {
|
||||||
programs.pi-coding-agent = {
|
programs.pi-coding-agent = {
|
||||||
enable = true;
|
enable = true;
|
||||||
|
package = patchedPi;
|
||||||
|
|
||||||
settings = {
|
settings = {
|
||||||
defaultProvider = "openai-codex";
|
defaultProvider = "openai-codex";
|
||||||
|
|||||||
240
modules/agents/pi/tests/autocomplete-bottom-alignment.test.sh
Executable file
240
modules/agents/pi/tests/autocomplete-bottom-alignment.test.sh
Executable file
@@ -0,0 +1,240 @@
|
|||||||
|
#!/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