Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion packages/pi-gate/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.7.2] - 2026-10-25

### Changed

- Show approval prompts in Pi’s normal dialog area, using the terminal background and Pi theme accents.
- Highlight matched command text and clarify agent guidance for timeouts, denials, and policy blocks.

## [0.7.1] - 2026-10-25

### Changed
Expand Down Expand Up @@ -105,7 +112,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- support `prompt`, `block`, and `allow` actions, with longest-pattern-wins resolution
- block rather than auto-approve in non-UI modes

[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.7.1...HEAD
[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.7.2...HEAD
[0.7.2]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.7.1...pi-gate-v0.7.2
[0.7.1]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.7.0...pi-gate-v0.7.1
[0.7.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.6.1...pi-gate-v0.7.0
[0.6.1]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.6.0...pi-gate-v0.6.1
Expand Down
2 changes: 1 addition & 1 deletion packages/pi-gate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ The built-in `bash` tool is gated, and `write`/`edit` calls targeting the gate c

Approval dialogs are serialized for concurrent tool calls; cancellation, shutdown, reload, session replacement, or a policy change while a dialog is open denies the pending call. Calls queued before shutdown or session replacement are also denied.

Long commands are truncated in approval dialogs, but an approved command executes in full. Denied, dismissed, blocked, and timed-out calls stop the turn; other allowed calls in a parallel batch may still run. In non-interactive modes (`-p`, JSON), commands requiring approval are denied.
The matched command text is highlighted in the TUI approval dialog. Long commands are truncated in approval dialogs, but an approved command executes in full. Denials, policy blocks, dismissals, and timeouts stop the turn; the agent is told to ask before retrying a denial or proceeding past a block, while a timeout asks whether to retry approval. Other allowed calls in a parallel batch may still run. In non-interactive modes (`-p`, JSON), commands requiring approval are denied.

In Herdr-managed panes, install Herdr's Pi integration with `herdr integration install pi` to report pending approvals as `blocked`. Herdr is optional.

Expand Down
6 changes: 4 additions & 2 deletions packages/pi-gate/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zeldrisho/pi-gate",
"version": "0.7.1",
"version": "0.7.2",
"description": "Pi extension that gates bash commands and protects its configuration from tool edits",
"keywords": [
"command-blocking",
Expand Down Expand Up @@ -42,13 +42,15 @@
},
"devDependencies": {
"@earendil-works/pi-coding-agent": "catalog:",
"@earendil-works/pi-tui": "catalog:",
"typebox": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vite-plus": "catalog:"
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*"
"@earendil-works/pi-coding-agent": "*",
"@earendil-works/pi-tui": "*"
},
"engines": {
"node": ">=24"
Expand Down
140 changes: 140 additions & 0 deletions packages/pi-gate/src/approval-prompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import type { Component } from "@earendil-works/pi-tui";
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";

const ACTIONS = ["Allow", "Deny"] as const;

type Action = (typeof ACTIONS)[number];

type Foreground = "text" | "muted" | "accent" | "toolOutput" | "border";

export interface ApprovalPromptTheme {
style(text: string, options: { fg?: Foreground; bold?: boolean }): string;
bold(text: string): string;
}

export interface ApprovalPromptKeybindings {
matches(
data: string,
action: "tui.select.up" | "tui.select.down" | "tui.select.confirm" | "tui.select.cancel",
): boolean;
}

export interface ApprovalPromptTui {
requestRender(): void;
}

/** Theme-aware approval dialog used by the interactive TUI. */
export function createApprovalPrompt(
tui: ApprovalPromptTui,
theme: ApprovalPromptTheme,
keybindings: ApprovalPromptKeybindings,
done: (result: Action | undefined) => void,
command: string,
rule: string,
): Component {
let selectedIndex = 0;

const invalidate = () => {};

const innerWidth = (width: number) => Math.max(1, width - 4);

const panelLine = (
text: string,
width: number,
color: "text" | "muted" | "accent" | "toolOutput" = "text",
) => {
const contentWidth = innerWidth(width);
const content = truncateToWidth(text, contentWidth, "…");
const padding = " ".repeat(Math.max(0, contentWidth - visibleWidth(content)));

const styled = theme.style(`${content}${padding}`, { fg: color });

return ` ${styled} `;
};

const component: Component = {
render(width: number): string[] {
const available = innerWidth(width);
const border = theme.style("─".repeat(available + 4), { fg: "border" });

const lines = [
border,
panelLine(theme.bold("pi-gate: allow this command?"), width, "accent"),
panelLine("Review the requested command or file change:", width, "muted"),
panelLine("", width),
...command.split("\n").map((line) => {
const contentWidth = innerWidth(width);
const parts = line.split(/([»«])/u);
Comment thread
zeldrisho marked this conversation as resolved.
let matched = false;

const rendered = parts
.map((part) => {
if (part === "»") {
matched = true;

return "";
}

if (part === "«") {
matched = false;

return "";
}

return theme.style(part, { fg: matched ? "accent" : "toolOutput", bold: matched });
})
.join("");

const visible = line.replace(/[»«]/gu, "");
const content = truncateToWidth(visible, contentWidth, "…");
const padding = " ".repeat(Math.max(0, contentWidth - visibleWidth(content)));

return ` ${rendered}${theme.style(padding, { fg: "toolOutput" })} `;
}),
panelLine("", width),
panelLine(`Matched rule: ${rule}`, width, "muted"),
panelLine("", width),
...ACTIONS.map((action, index) => {
const selected = index === selectedIndex;
const label = `${selected ? "→ " : " "}${action}`;
const row = truncateToWidth(label, available);
const padded = `${row}${" ".repeat(Math.max(0, available - visibleWidth(row)))}`;

return theme.style(` ${padded} `, {
fg: selected ? "accent" : "text",
});
}),
panelLine("", width),
panelLine("↑↓ move · enter confirm · esc cancel", width, "muted"),
border,
];

return lines.map((line) => truncateToWidth(line, width));
},
handleInput(data: string): void {
if (keybindings.matches(data, "tui.select.up") || data === "k") {
selectedIndex = Math.max(0, selectedIndex - 1);
} else if (keybindings.matches(data, "tui.select.down") || data === "j") {
selectedIndex = Math.min(ACTIONS.length - 1, selectedIndex + 1);
} else if (keybindings.matches(data, "tui.select.confirm")) {
done(ACTIONS[selectedIndex]);

return;
} else if (keybindings.matches(data, "tui.select.cancel")) {
done(undefined);

return;
} else {
return;
}

component.invalidate();
tui.requestRender();
},
invalidate,
};

return component;
}

export type ApprovalAction = Action;
86 changes: 77 additions & 9 deletions packages/pi-gate/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@
import {
isToolCallEventType,
type ExtensionAPI,
type ExtensionContext,
type ToolCallEventResult,
} from "@earendil-works/pi-coding-agent";
import { createApprovalPrompt, type ApprovalAction } from "./approval-prompt";
import { configPath, ensureConfig, loadConfigResult } from "./config";
import {
bashTouchesConfig,
Expand All @@ -44,6 +46,76 @@ import { formatCommandForDisplay, formatPromptCommand, formatRule } from "./disp

const HERDR_BLOCKED_LABEL = "pi-gate: approval required";

/** Uses a themed terminal component in TUI mode and Pi's standard selector elsewhere. */
async function selectApproval(
ctx: ExtensionContext,
timeoutMs: number,
signal: AbortSignal,
command: string,
rule: string,
showMatchHint: boolean,
): Promise<string | undefined> {
if (ctx.mode !== "tui" || !ctx.ui.custom) {
return ctx.ui.select(
`pi-gate: allow this command?\n\n${command}\n\nMatched rule: ${rule}${showMatchHint ? "\nMatched command text is wrapped in »…«" : ""}`,
["Allow", "Deny"],
{ timeout: timeoutMs, signal },
);
}

let uiDone: ((value: ApprovalAction | undefined) => void) | undefined;
let settled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
let removeAbortListener: (() => void) | undefined;

return new Promise<ApprovalAction | undefined>((resolve, reject) => {
const finish = (value: ApprovalAction | undefined) => {
if (settled) return;
settled = true;

if (timer !== undefined) clearTimeout(timer);
removeAbortListener?.();
resolve(value);
uiDone?.(value);
};

const onAbort = () => finish(undefined);

try {
timer = setTimeout(() => finish(undefined), timeoutMs);

const uiPromise = ctx.ui.custom<ApprovalAction | undefined>(
(tui, theme, keybindings, done) => {
uiDone = done;

return createApprovalPrompt(tui, theme, keybindings, done, command, rule);
},
);

uiPromise
.then(finish)
.catch(reject)
.finally(() => {
if (timer !== undefined) clearTimeout(timer);
removeAbortListener?.();
});

if (signal) {
signal.addEventListener("abort", onAbort, { once: true });
removeAbortListener = () => signal.removeEventListener("abort", onAbort);

if (signal.aborted) onAbort();
}
} catch (error) {
settled = true;

if (timer !== undefined) clearTimeout(timer);
removeAbortListener?.();
reject(error);
}
});
}

const MAX_REASON_RULE_NAME_LENGTH = 27;

/** Escapes a rule pattern for terminal display and bounds its length in denial messages. */
Expand All @@ -57,7 +129,7 @@ function reasonRuleName(pattern: string): string {

/** Formats an approval timeout with the matched rule, elapsed limit in seconds, and retry guidance. */
function timeoutReason(pattern: string, timeoutMs: number): string {
return `pi-gate: no response to rule "${reasonRuleName(pattern)}" within ${timeoutMs / 1_000}s; command not run. Ask the user whether to retry.`;
return `pi-gate: no response to rule "${reasonRuleName(pattern)}" within ${timeoutMs / 1_000}s; command not run. Ask the user whether to retry approval.`;
}

function reportHerdrBlocked(pi: ExtensionAPI, ctx: { mode?: string }, active: boolean): void {
Expand Down Expand Up @@ -218,7 +290,7 @@ export default function piGate(pi: ExtensionAPI): void {
const rule = configChange ? CONFIG_CHANGE_REASON : formatRule(match);

if (match.action === "block") {
const reason = `pi-gate: blocked by rule "${reasonRuleName(match.pattern)}". Do not retry or use equivalent commands; ask the user.`;
const reason = `pi-gate: command blocked by rule "${reasonRuleName(match.pattern)}". Ask the user before proceeding.`;

if (ctx.hasUI) ctx.ui.notify(reason, "warning");

Expand All @@ -230,7 +302,7 @@ export default function piGate(pi: ExtensionAPI): void {
block: true,
reason: configChange
? CONFIG_CHANGE_REASON
: `pi-gate: rule "${reasonRuleName(match.pattern)}" needs approval but no UI is available. Do not retry; ask the user.`,
: `pi-gate: rule "${reasonRuleName(match.pattern)}" needs approval but no UI is available. Ask the user before proceeding.`,
terminate: true,
};
}
Expand All @@ -248,11 +320,7 @@ export default function piGate(pi: ExtensionAPI): void {

try {
const promptStartedAt = Date.now();
choice = await ctx.ui.select(
`pi-gate: allow this command?\n\n${promptCommand}\n\nMatched rule: ${rule}${configChange ? "" : "\nMatched command text is wrapped in »…«"}`,
["Allow", "Deny"],
{ timeout: timeoutMs, signal },
);
choice = await selectApproval(ctx, timeoutMs, signal, promptCommand, rule, !configChange);
// Pi returns undefined for both dismissal and timeout; elapsed time separates the timeout case.
timedOut = choice === undefined && Date.now() - promptStartedAt >= timeoutMs;
} finally {
Expand All @@ -277,7 +345,7 @@ export default function piGate(pi: ExtensionAPI): void {
? timeoutReason(match.pattern, timeoutMs)
: configChange
? CONFIG_CHANGE_REASON
: `pi-gate: denied by rule "${reasonRuleName(match.pattern)}". Do not retry or use equivalent commands; ask the user.`,
: `pi-gate: command not run because approval was denied. Ask the user before retrying (rule "${reasonRuleName(match.pattern)}").`,
terminate: true,
};
}
Expand Down
Loading
Loading