78fe857a3bc92460a9194e5edc4228eba4ebaf1b

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

Move approval into separate popup with scrolling

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+})