Parent directory

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}