@zeldrisho/pi-gate 0.7.2 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.4] - 2026-11-01
11
+
12
+ ### Changed
13
+
14
+ - Shorten approval timeout and cancellation notices.
15
+ - Remove the package's Node.js engine restriction; the current Node.js LTS release is the tested runtime.
16
+
17
+ ## [0.7.3] - 2026-10-28
18
+
19
+ ### Changed
20
+
21
+ - Show the approval timeout and configured confirmation keys, and allow horizontal scrolling of long command lines.
22
+
10
23
  ## [0.7.2] - 2026-10-25
11
24
 
12
25
  ### Changed
@@ -112,7 +125,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
112
125
  - support `prompt`, `block`, and `allow` actions, with longest-pattern-wins resolution
113
126
  - block rather than auto-approve in non-UI modes
114
127
 
115
- [Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.7.2...HEAD
128
+ [Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.7.4...HEAD
129
+ [0.7.4]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.7.3...pi-gate-v0.7.4
130
+ [0.7.3]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.7.2...pi-gate-v0.7.3
116
131
  [0.7.2]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.7.1...pi-gate-v0.7.2
117
132
  [0.7.1]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.7.0...pi-gate-v0.7.1
118
133
  [0.7.0]: https://github.com/zeldrisho/pi-packages/compare/pi-gate-v0.6.1...pi-gate-v0.7.0
package/README.md CHANGED
@@ -46,9 +46,7 @@ pi-gate is a guard rail, not a sandbox. Complex shell syntax, pipelines, redirec
46
46
 
47
47
  The built-in `bash` tool is gated, and `write`/`edit` calls targeting the gate config require approval. Prompt dialogs offer `Allow` and `Deny`; approvals are never remembered. To stop repeated prompts, add a specific `allow` rule to `gate.json`.
48
48
 
49
- 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.
50
-
51
- 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.
49
+ The approval dialog highlights the matched command text. Long commands are truncated in the dialog but execute in full if approved. Denials, blocks, dismissals, and timeouts stop the turn; other allowed calls in a parallel batch may still run. In non-interactive modes (`-p`, JSON), commands requiring approval are denied.
52
50
 
53
51
  In Herdr-managed panes, install Herdr's Pi integration with `herdr integration install pi` to report pending approvals as `blocked`. Herdr is optional.
54
52
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeldrisho/pi-gate",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
4
4
  "description": "Pi extension that gates bash commands and protects its configuration from tool edits",
5
5
  "keywords": [
6
6
  "command-blocking",
@@ -36,16 +36,13 @@
36
36
  "@earendil-works/pi-tui": "^1.4.0",
37
37
  "typebox": "^1.4.2",
38
38
  "typescript": "^7.0.2",
39
- "vite": "npm:@voidzero-dev/vite-plus-core@1.3.0",
40
- "vite-plus": "1.3.0"
39
+ "vite": "npm:@voidzero-dev/vite-plus-core@1.4.0",
40
+ "vite-plus": "1.4.0"
41
41
  },
42
42
  "peerDependencies": {
43
43
  "@earendil-works/pi-coding-agent": "*",
44
44
  "@earendil-works/pi-tui": "*"
45
45
  },
46
- "engines": {
47
- "node": ">=24"
48
- },
49
46
  "pi": {
50
47
  "extensions": [
51
48
  "./src/index.ts"
@@ -1,5 +1,13 @@
1
+ import { keyHint, rawKeyHint } from "@earendil-works/pi-coding-agent";
1
2
  import type { Component } from "@earendil-works/pi-tui";
2
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
+ import {
4
+ Key,
5
+ matchesKey,
6
+ sliceByColumn,
7
+ truncateToWidth,
8
+ visibleWidth,
9
+ wrapTextWithAnsi,
10
+ } from "@earendil-works/pi-tui";
3
11
 
4
12
  const ACTIONS = ["Allow", "Deny"] as const;
5
13
 
@@ -31,8 +39,12 @@ export function createApprovalPrompt(
31
39
  done: (result: Action | undefined) => void,
32
40
  command: string,
33
41
  rule: string,
42
+ deadline?: number,
34
43
  ): Component {
35
44
  let selectedIndex = 0;
45
+ let horizontalOffset = 0;
46
+ let maxHorizontalOffset = 0;
47
+ let pageWidth = 1;
36
48
 
37
49
  const invalidate = () => {};
38
50
 
@@ -55,11 +67,26 @@ export function createApprovalPrompt(
55
67
  const component: Component = {
56
68
  render(width: number): string[] {
57
69
  const available = innerWidth(width);
70
+
71
+ const longestLine = Math.max(
72
+ 0,
73
+ ...command.split("\n").map((line) => visibleWidth(line.replace(/[»«]/gu, ""))),
74
+ );
75
+
76
+ pageWidth = longestLine > available ? Math.max(1, available - 2) : available;
77
+ maxHorizontalOffset = longestLine > available ? longestLine - pageWidth : 0;
78
+ horizontalOffset = Math.min(horizontalOffset, maxHorizontalOffset);
58
79
  const border = theme.style("─".repeat(available + 4), { fg: "border" });
59
80
 
60
81
  const lines = [
61
82
  border,
62
- panelLine(theme.bold("pi-gate: allow this command?"), width, "accent"),
83
+ panelLine(
84
+ theme.bold(
85
+ `${deadline === undefined ? "" : `(${Math.max(0, Math.ceil((deadline - Date.now()) / 1_000))}s) `}pi-gate: allow this command?`,
86
+ ),
87
+ width,
88
+ "accent",
89
+ ),
63
90
  panelLine("Review the requested command or file change:", width, "muted"),
64
91
  panelLine("", width),
65
92
  ...command.split("\n").map((line) => {
@@ -86,10 +113,15 @@ export function createApprovalPrompt(
86
113
  .join("");
87
114
 
88
115
  const visible = line.replace(/[»«]/gu, "");
89
- const content = truncateToWidth(visible, contentWidth, "…");
90
- const padding = " ".repeat(Math.max(0, contentWidth - visibleWidth(content)));
116
+ const content = sliceByColumn(rendered, horizontalOffset, pageWidth, true);
117
+ const prefix = horizontalOffset > 0 && visibleWidth(visible) > 0 ? "…" : "";
118
+ const suffix = visibleWidth(visible) > horizontalOffset + pageWidth ? "…" : "";
119
+
120
+ const padding = " ".repeat(
121
+ Math.max(0, contentWidth - visibleWidth(prefix + content + suffix)),
122
+ );
91
123
 
92
- return ` ${rendered}${theme.style(padding, { fg: "toolOutput" })} `;
124
+ return ` ${theme.style(prefix, { fg: "toolOutput" })}${content}${theme.style(suffix + padding, { fg: "toolOutput" })} `;
93
125
  }),
94
126
  panelLine("", width),
95
127
  panelLine(`Matched rule: ${rule}`, width, "muted"),
@@ -105,14 +137,21 @@ export function createApprovalPrompt(
105
137
  });
106
138
  }),
107
139
  panelLine("", width),
108
- panelLine("↑↓ move · enter confirm · esc cancel", width, "muted"),
140
+ ...wrapTextWithAnsi(
141
+ `${rawKeyHint("↑↓", "navigate")} ${maxHorizontalOffset > 0 ? `${rawKeyHint("←→", "scroll command")} ` : ""}${keyHint("tui.select.confirm", "select")} ${keyHint("tui.select.cancel", "cancel")}`,
142
+ available,
143
+ ).map((line) => ` ${line} `),
109
144
  border,
110
145
  ];
111
146
 
112
147
  return lines.map((line) => truncateToWidth(line, width));
113
148
  },
114
149
  handleInput(data: string): void {
115
- if (keybindings.matches(data, "tui.select.up") || data === "k") {
150
+ if (matchesKey(data, Key.left)) {
151
+ horizontalOffset = Math.max(0, horizontalOffset - pageWidth);
152
+ } else if (matchesKey(data, Key.right)) {
153
+ horizontalOffset = Math.min(maxHorizontalOffset, horizontalOffset + pageWidth);
154
+ } else if (keybindings.matches(data, "tui.select.up") || data === "k") {
116
155
  selectedIndex = Math.max(0, selectedIndex - 1);
117
156
  } else if (keybindings.matches(data, "tui.select.down") || data === "j") {
118
157
  selectedIndex = Math.min(ACTIONS.length - 1, selectedIndex + 1);
package/src/index.ts CHANGED
@@ -66,6 +66,7 @@ async function selectApproval(
66
66
  let uiDone: ((value: ApprovalAction | undefined) => void) | undefined;
67
67
  let settled = false;
68
68
  let timer: ReturnType<typeof setTimeout> | undefined;
69
+ let countdown: ReturnType<typeof setInterval> | undefined;
69
70
  let removeAbortListener: (() => void) | undefined;
70
71
 
71
72
  return new Promise<ApprovalAction | undefined>((resolve, reject) => {
@@ -74,6 +75,8 @@ async function selectApproval(
74
75
  settled = true;
75
76
 
76
77
  if (timer !== undefined) clearTimeout(timer);
78
+
79
+ if (countdown !== undefined) clearInterval(countdown);
77
80
  removeAbortListener?.();
78
81
  resolve(value);
79
82
  uiDone?.(value);
@@ -82,13 +85,15 @@ async function selectApproval(
82
85
  const onAbort = () => finish(undefined);
83
86
 
84
87
  try {
88
+ const deadline = Date.now() + timeoutMs;
85
89
  timer = setTimeout(() => finish(undefined), timeoutMs);
86
90
 
87
91
  const uiPromise = ctx.ui.custom<ApprovalAction | undefined>(
88
92
  (tui, theme, keybindings, done) => {
89
93
  uiDone = done;
94
+ countdown = setInterval(() => tui.requestRender(), 1_000);
90
95
 
91
- return createApprovalPrompt(tui, theme, keybindings, done, command, rule);
96
+ return createApprovalPrompt(tui, theme, keybindings, done, command, rule, deadline);
92
97
  },
93
98
  );
94
99
 
@@ -97,6 +102,8 @@ async function selectApproval(
97
102
  .catch(reject)
98
103
  .finally(() => {
99
104
  if (timer !== undefined) clearTimeout(timer);
105
+
106
+ if (countdown !== undefined) clearInterval(countdown);
100
107
  removeAbortListener?.();
101
108
  });
102
109
 
@@ -110,6 +117,8 @@ async function selectApproval(
110
117
  settled = true;
111
118
 
112
119
  if (timer !== undefined) clearTimeout(timer);
120
+
121
+ if (countdown !== undefined) clearInterval(countdown);
113
122
  removeAbortListener?.();
114
123
  reject(error);
115
124
  }
@@ -129,7 +138,7 @@ function reasonRuleName(pattern: string): string {
129
138
 
130
139
  /** Formats an approval timeout with the matched rule, elapsed limit in seconds, and retry guidance. */
131
140
  function timeoutReason(pattern: string, timeoutMs: number): string {
132
- return `pi-gate: no response to rule "${reasonRuleName(pattern)}" within ${timeoutMs / 1_000}s; command not run. Ask the user whether to retry approval.`;
141
+ return `pi-gate: approval timed out after ${timeoutMs / 1_000}s (rule "${reasonRuleName(pattern)}"); command not run. Ask the user before retrying.`;
133
142
  }
134
143
 
135
144
  function reportHerdrBlocked(pi: ExtensionAPI, ctx: { mode?: string }, active: boolean): void {
@@ -333,7 +342,7 @@ export default function piGate(pi: ExtensionAPI): void {
333
342
  return {
334
343
  block: true,
335
344
  reason:
336
- "pi-gate: approval cancelled or policy changed; command not run. Ask the user whether to retry.",
345
+ "pi-gate: approval cancelled or policy changed; command not run. Ask the user before retrying.",
337
346
  terminate: true,
338
347
  };
339
348
  }