diff --git a/AGENTS.md b/AGENTS.md index cc8307e..3a61264 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. 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. - 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. diff --git a/modules/agents/pi/patches/pi-flex-spacer.patch b/modules/agents/pi/patches/pi-flex-spacer.patch new file mode 100644 index 0000000..1c2d733 --- /dev/null +++ b/modules/agents/pi/patches/pi-flex-spacer.patch @@ -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(); diff --git a/modules/agents/pi/patches/pi-tool-lookup-validation.patch b/modules/agents/pi/patches/pi-tool-lookup-validation.patch new file mode 100644 index 0000000..9364a7e --- /dev/null +++ b/modules/agents/pi/patches/pi-tool-lookup-validation.patch @@ -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; + } + diff --git a/modules/agents/pi/pi.nix b/modules/agents/pi/pi.nix index a880d0c..293d74f 100644 --- a/modules/agents/pi/pi.nix +++ b/modules/agents/pi/pi.nix @@ -11,6 +11,12 @@ let cfg = config.modules.agents.pi; user = config.user.name; 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 { name = "herdr-pi-integration"; nativeBuildInputs = [ pkgs.herdr ]; @@ -40,6 +46,7 @@ in home-manager.users.${user} = { programs.pi-coding-agent = { enable = true; + package = patchedPi; settings = { defaultProvider = "openai-codex"; diff --git a/modules/agents/pi/tests/autocomplete-bottom-alignment.test.sh b/modules/agents/pi/tests/autocomplete-bottom-alignment.test.sh new file mode 100755 index 0000000..734c5cf --- /dev/null +++ b/modules/agents/pi/tests/autocomplete-bottom-alignment.test.sh @@ -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"