78fe857a3bc92460a9194e5edc4228eba4ebaf1b
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/src/pi/index.ts b/src/pi/index.ts
2index 5b6298009b8c9676fb23aa348ae7339e5d831ec7..e5a0202b9a4498445c2b82a50b6d97391ecf26dd 100644
3--- a/src/pi/index.ts
4+++ b/src/pi/index.ts
5@@ -8,6 +8,7 @@ import { createPolicyReviewer } from "../core/review"
6 import type { PolicyContext, PolicyEvaluation, PolicyRequest } from "../core/types"
7 import { splitBashCommand, type BashSplitResult } from "./bash-split"
8 import { loadPiPolicyConfig, piPolicyPaths } from "./config"
9+import { PolicyApprovalDialog } from "./policy-approval"
10 import { capturePiCredentials, resolvePiOpenAIKey } from "./credentials"
11 import {
12 createSessionBashAllowOverride,
13@@ -177,10 +178,14 @@ export default function policyEngine(pi: ExtensionAPI) {
14 pi.events.emit("herdr:blocked", { active: true, label: "Policy approval required" })
15 let approved: boolean
16 try {
17- approved = await ctx.ui.confirm(
18- "Policy approval required",
19- `${event.toolName}: ${inputSummary(event.toolName, input)}\n\n${result.decision.reason}`,
20- )
21+ const title = "Policy approval required"
22+ const message = `${event.toolName}: ${inputSummary(event.toolName, input)}\n\n${result.decision.reason}`
23+ approved = ctx.mode === "tui"
24+ ? await ctx.ui.custom(
25+ (tui, theme, keybindings, done) => new PolicyApprovalDialog(tui, theme, keybindings, title, message, done),
26+ { overlay: true, overlayOptions: { width: "90%", margin: 1 } },
27+ )
28+ : await ctx.ui.confirm(title, message)
29 } finally {
30 pi.events.emit("herdr:blocked", { active: false })
31 }
32diff --git a/src/pi/policy-approval.ts b/src/pi/policy-approval.ts
33new file mode 100644
34index 0000000000000000000000000000000000000000..85c8f05c8e60bdd4427e1d9d03cdd375fc0260ea
35--- /dev/null
36+++ b/src/pi/policy-approval.ts
37@@ -0,0 +1,137 @@
38+import type { KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent"
39+import { Key, matchesKey, truncateToWidth, type TUI, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui"
40+
41+function escapeTerminalControls(value: string): string {
42+ return [...value].map((character) => {
43+ if (character === "\n") return character
44+ const codePoint = character.codePointAt(0)!
45+ if (codePoint < 0x20 || (codePoint >= 0x7F && codePoint < 0xA0)) {
46+ return `\\x${codePoint.toString(16).padStart(2, "0")}`
47+ }
48+ return character
49+ }).join("")
50+}
51+
52+export class PolicyApprovalDialog {
53+ private selectedApproval = true
54+ private scrollTop = 0
55+ private visibleBodyLines = 1
56+ private bodyLines: string[] = []
57+ private readonly message: string
58+
59+ constructor(
60+ private readonly tui: TUI,
61+ private readonly theme: Theme,
62+ private readonly keybindings: KeybindingsManager,
63+ private readonly title: string,
64+ message: string,
65+ private readonly done: (approved: boolean) => void,
66+ ) {
67+ this.message = escapeTerminalControls(message)
68+ }
69+
70+ handleInput(data: string): void {
71+ if (this.keybindings.matches(data, "tui.select.cancel")) {
72+ this.done(false)
73+ return
74+ }
75+ if (this.keybindings.matches(data, "tui.select.confirm")) {
76+ this.done(this.selectedApproval)
77+ return
78+ }
79+ if (this.keybindings.matches(data, "tui.input.tab") || matchesKey(data, Key.left) || matchesKey(data, Key.right)) {
80+ this.selectedApproval = !this.selectedApproval
81+ this.tui.requestRender()
82+ return
83+ }
84+ if (this.keybindings.matches(data, "tui.select.up")) {
85+ this.scroll(-1)
86+ return
87+ }
88+ if (this.keybindings.matches(data, "tui.select.down")) {
89+ this.scroll(1)
90+ return
91+ }
92+ if (this.keybindings.matches(data, "tui.select.pageUp")) {
93+ this.scroll(-this.visibleBodyLines)
94+ return
95+ }
96+ if (this.keybindings.matches(data, "tui.select.pageDown")) {
97+ this.scroll(this.visibleBodyLines)
98+ return
99+ }
100+ if (matchesKey(data, Key.home)) {
101+ this.scrollTo(0)
102+ return
103+ }
104+ if (matchesKey(data, Key.end)) this.scrollTo(this.maxScrollTop())
105+ }
106+
107+ render(width: number): string[] {
108+ const safeWidth = Math.max(1, width)
109+ const messageWidth = Math.max(1, safeWidth - 4)
110+ this.bodyLines = wrapTextWithAnsi(this.message, messageWidth)
111+ this.visibleBodyLines = Math.max(1, this.tui.terminal.rows - 8)
112+ this.scrollTop = Math.min(this.scrollTop, this.maxScrollTop())
113+
114+ const end = Math.min(this.scrollTop + this.visibleBodyLines, this.bodyLines.length)
115+ const body = this.bodyLines.slice(this.scrollTop, end).map((line) => this.frameLine(` ${line}`, safeWidth))
116+ const position = `${this.scrollTop + 1}-${end} of ${this.bodyLines.length}`
117+ const approve = this.selectedApproval
118+ ? this.theme.bg("selectedBg", this.theme.fg("accent", " Yes "))
119+ : this.theme.fg("dim", " Yes ")
120+ const deny = this.selectedApproval
121+ ? this.theme.fg("dim", " No ")
122+ : this.theme.bg("selectedBg", this.theme.fg("accent", " No "))
123+ const confirmKeys = this.keybindings.getKeys("tui.select.confirm").join("/")
124+ const cancelKeys = this.keybindings.getKeys("tui.select.cancel").join("/")
125+
126+ return [
127+ this.topBorder(safeWidth),
128+ ...body,
129+ this.frameLine(this.theme.fg("dim", ` ↑↓ scroll · ${position}`), safeWidth),
130+ this.frameLine(` ${approve} ${deny}`, safeWidth),
131+ this.frameLine(this.theme.fg("dim", ` ←→ select · ${confirmKeys} confirm · ${cancelKeys} deny`), safeWidth),
132+ this.bottomBorder(safeWidth),
133+ ]
134+ }
135+
136+ invalidate(): void {}
137diff --git a/test/pi/extension.test.ts b/test/pi/extension.test.ts
138index 954f1633d697264748ae8ad26756030b899d63e5..fa2b4649c5ac0343ae42a4fb8553d6927cf88629 100644
139--- a/test/pi/extension.test.ts
140+++ b/test/pi/extension.test.ts
141@@ -99,6 +99,30 @@ describe("Pi policy extension", () => {
142 .resolves.toMatchObject({ block: true })
143 })
144
145+ test("uses a scrollable approval dialog in the TUI", async () => {
146+ const { toolCall } = loadExtension()
147+ let opened = false
148+ let options: unknown
149+ const ui = {
150+ confirm: async () => {
151+ throw new Error("confirm should not be used in the TUI")
152+ },
153+ custom: async (_factory: unknown, value: unknown) => {
154+ opened = true
155+ options = value
156+ return true
157+ },
158+ notify() {},
159+ }
160+
161+ await expect(toolCall(
162+ { toolName: "bash", input: { command: "sudo apt install foo" } },
163+ { ...context(true), mode: "tui", ui },
164+ )).resolves.toBeUndefined()
165+ expect(opened).toBe(true)
166+ expect(options).toEqual({ overlay: true, overlayOptions: { width: "90%", margin: 1 } })
167+ })
168+
169 test("allows a user-approved command", async () => {
170 const emittedEvents: EmittedEvent[] = []
171 const { toolCall } = loadExtension(emittedEvents)
172@@ -107,8 +131,10 @@ describe("Pi policy extension", () => {
173 prompted = true
174 return true
175 }
176- await expect(toolCall({ toolName: "bash", input: { command: "sudo apt install foo" } }, context(true, confirm)))
177- .resolves.toBeUndefined()
178+ await expect(toolCall(
179+ { toolName: "bash", input: { command: "sudo apt install foo" } },
180+ { ...context(true, confirm), mode: "rpc" },
181+ )).resolves.toBeUndefined()
182 expect(prompted).toBe(true)
183 expect(emittedEvents).toEqual([
184 { name: "herdr:blocked", data: { active: true, label: "Policy approval required" } },
185diff --git a/test/pi/policy-approval.test.ts b/test/pi/policy-approval.test.ts
186new file mode 100644
187index 0000000000000000000000000000000000000000..8a3db431a1023afeca0b37b695596e91ea1beaa0
188--- /dev/null
189+++ b/test/pi/policy-approval.test.ts
190@@ -0,0 +1,81 @@
191+import { describe, expect, test } from "bun:test"
192+import { visibleWidth } from "@earendil-works/pi-tui"
193+import { PolicyApprovalDialog } from "../../src/pi/policy-approval"
194+
195+const theme = {
196+ fg: (_color: string, text: string) => text,
197+ bg: (color: string, text: string) => `[${color}]${text}`,
198+ bold: (text: string) => text,
199+} as any
200+
201+function renderDialog(message: string) {
202+ let renderRequests = 0
203+ let approved: boolean | undefined
204+ const tui = {
205+ terminal: { rows: 12 },
206+ requestRender() {
207+ renderRequests++
208+ },
209+ } as any
210+ const keybindings = {
211+ matches(data: string, action: string) {
212+ return (action === "tui.select.down" && data === "\x1b[B")
213+ || (action === "tui.input.tab" && data === "\t")
214+ || (action === "tui.select.confirm" && data === "\n")
215+ },
216+ getKeys(action: string) {
217+ if (action === "tui.select.confirm") return ["ctrl+enter"]
218+ if (action === "tui.select.cancel") return ["ctrl+q"]
219+ return []
220+ },
221+ } as any
222+ const dialog = new PolicyApprovalDialog(tui, theme, keybindings, "Policy approval required", message, (result) => {
223+ approved = result
224+ })
225+ return {
226+ dialog,
227+ result: () => approved,
228+ renderRequests: () => renderRequests,
229+ }
230+}
231+
232+describe("PolicyApprovalDialog", () => {
233+ test("scrolls a long prompt while retaining the selected action", () => {
234+ const { dialog, result, renderRequests } = renderDialog(
235+ Array.from({ length: 20 }, (_, index) => `line ${index + 1}`).join("\n"),
236+ )
237+
238+ const initial = dialog.render(40)
239+ expect(initial).toHaveLength(9)
240+ expect(initial.every((line) => visibleWidth(line) <= 40)).toBe(true)
241+ expect(initial[0]).toMatch(/^╭─ Policy approval required ─+╮$/)
242+ expect(initial.at(-1)).toBe("╰──────────────────────────────────────╯")
243+ expect(initial.join("\n")).toContain("│ line 1")
244+ expect(initial.join("\n")).toContain("│ line 4")
245+ dialog.handleInput("\x1b[B")
246+ expect(dialog.render(40).join("\n")).toContain("│ line 2")
247+ expect(dialog.render(40).join("\n")).toContain("│ line 5")
248+
249+ dialog.handleInput("\t")
250+ expect(dialog.render(40).join("\n")).toContain(" Yes [selectedBg] No ")
251+ dialog.handleInput("\n")
252+
253+ expect(renderRequests()).toBeGreaterThan(0)
254+ expect(result()).toBe(false)
255+ })
256+
257+ test("shows terminal control characters without executing them", () => {
258+ const { dialog } = renderDialog("printf safe \x1b]0; printf hidden-danger \x07")
259+ const output = dialog.render(120).join("\n")
260+
261+ expect(output).toContain("printf safe \\x1b]0; printf hidden-danger \\x07")
262+ expect(output).not.toContain("\x1b")
263+ expect(output).not.toContain("\x07")
264+ })
265+
266+ test("shows configured confirmation keys", () => {
267+ const { dialog } = renderDialog("sudo apt install foo")
268+
269+ expect(dialog.render(80).join("\n")).toContain("ctrl+enter confirm · ctrl+q deny")
270+ })
271+})