policy-approval.ts
4980 bytes
1import type { KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
2import { Key, matchesKey, truncateToWidth, type TUI, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
4function escapeTerminalControls(value: string): string {
5 return [...value]
6 .map((character) => {
7 if (character === "\n") return character;
8 const codePoint = character.codePointAt(0)!;
9 if (codePoint < 0x20 || (codePoint >= 0x7f && codePoint < 0xa0)) {
10 return `\\x${codePoint.toString(16).padStart(2, "0")}`;
11 }
12 return character;
13 })
14 .join("");
15}
16
17export class PolicyApprovalDialog {
18 private selectedApproval = true;
19 private scrollTop = 0;
20 private visibleBodyLines = 1;
21 private bodyLines: string[] = [];
22 private readonly message: string;
23
24 constructor(
25 private readonly tui: TUI,
26 private readonly theme: Theme,
27 private readonly keybindings: KeybindingsManager,
28 private readonly title: string,
29 message: string,
30 private readonly done: (approved: boolean) => void,
31 ) {
32 this.message = escapeTerminalControls(message);
33 }
34
35 handleInput(data: string): void {
36 if (this.keybindings.matches(data, "tui.select.cancel")) {
37 this.done(false);
38 return;
39 }
40 if (this.keybindings.matches(data, "tui.select.confirm")) {
41 this.done(this.selectedApproval);
42 return;
43 }
44 if (this.keybindings.matches(data, "tui.input.tab") || matchesKey(data, Key.left) || matchesKey(data, Key.right)) {
45 this.selectedApproval = !this.selectedApproval;
46 this.tui.requestRender();
47 return;
48 }
49 if (this.keybindings.matches(data, "tui.select.up")) {
50 this.scroll(-1);
51 return;
52 }
53 if (this.keybindings.matches(data, "tui.select.down")) {
54 this.scroll(1);
55 return;
56 }
57 if (this.keybindings.matches(data, "tui.select.pageUp")) {
58 this.scroll(-this.visibleBodyLines);
59 return;
60 }
61 if (this.keybindings.matches(data, "tui.select.pageDown")) {
62 this.scroll(this.visibleBodyLines);
63 return;
64 }
65 if (matchesKey(data, Key.home)) {
66 this.scrollTo(0);
67 return;
68 }
69 if (matchesKey(data, Key.end)) this.scrollTo(this.maxScrollTop());
70 }
71
72 render(width: number): string[] {
73 const safeWidth = Math.max(1, width);
74 const messageWidth = Math.max(1, safeWidth - 4);
75 this.bodyLines = wrapTextWithAnsi(this.message, messageWidth);
76 this.visibleBodyLines = Math.max(1, this.tui.terminal.rows - 8);
77 this.scrollTop = Math.min(this.scrollTop, this.maxScrollTop());
78
79 const end = Math.min(this.scrollTop + this.visibleBodyLines, this.bodyLines.length);
80 const body = this.bodyLines.slice(this.scrollTop, end).map((line) => this.frameLine(` ${line}`, safeWidth));
81 const position = `${this.scrollTop + 1}-${end} of ${this.bodyLines.length}`;
82 const approve = this.selectedApproval
83 ? this.theme.bg("selectedBg", this.theme.fg("accent", " Yes "))
84 : this.theme.fg("dim", " Yes ");
85 const deny = this.selectedApproval
86 ? this.theme.fg("dim", " No ")
87 : this.theme.bg("selectedBg", this.theme.fg("accent", " No "));
88 const confirmKeys = this.keybindings.getKeys("tui.select.confirm").join("/");
89 const cancelKeys = this.keybindings.getKeys("tui.select.cancel").join("/");
90
91 return [
92 this.topBorder(safeWidth),
93 ...body,
94 this.frameLine(this.theme.fg("dim", ` ↑↓ scroll · ${position}`), safeWidth),
95 this.frameLine(` ${approve} ${deny}`, safeWidth),
96 this.frameLine(this.theme.fg("dim", ` ←→ select · ${confirmKeys} confirm · ${cancelKeys} deny`), safeWidth),
97 this.bottomBorder(safeWidth),
98 ];
99 }
100
101 invalidate(): void {}
102
103 private scroll(lines: number): void {
104 this.scrollTo(this.scrollTop + lines);
105 }
106
107 private scrollTo(offset: number): void {
108 const next = Math.max(0, Math.min(this.maxScrollTop(), offset));
109 if (next === this.scrollTop) return;
110 this.scrollTop = next;
111 this.tui.requestRender();
112 }
113
114 private maxScrollTop(): number {
115 return Math.max(0, this.bodyLines.length - this.visibleBodyLines);
116 }
117
118 private topBorder(width: number): string {
119 if (width < 3) return this.line(this.title, width);
120 const title = truncateToWidth(`─ ${this.theme.bold(this.title)} `, width - 2, "");
121 const fill = "─".repeat(Math.max(0, width - 2 - visibleWidth(title)));
122 return this.theme.fg("accent", `╭${title}${fill}╮`);
123 }
124
125 private bottomBorder(width: number): string {
126 if (width < 3) return this.line("", width);
127 return this.theme.fg("accent", `╰${"─".repeat(width - 2)}╯`);
128 }
129
130 private frameLine(text: string, width: number): string {
131 if (width < 3) return this.line(text, width);
132 const content = this.line(text, width - 2);
133 return `${this.theme.fg("accent", "│")}${content}${this.theme.fg("accent", "│")}`;
134 }
135
136 private line(text: string, width: number): string {
137 return truncateToWidth(text, width, "", true);
138 }
139}