char-slop/ai-dots

ai dotfiles

git clone https://git.t4t.associates/char-slop/ai-dots

Charlotte Somcompact-confirm: update for new pi version38d12cd

main
6.5 KiB230 linesraw
1import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2import {
3  type Component,
4  CURSOR_MARKER,
5  decodeKittyPrintable,
6  type Focusable,
7  type KeybindingsManager,
8  visibleWidth,
9  wrapTextWithAnsi,
10} from "@earendil-works/pi-tui";
11
12function decodePrintableKey(data: string): string | undefined {
13  const kittyPrintable = decodeKittyPrintable(data);
14  if (kittyPrintable !== undefined) return kittyPrintable;
15
16  const match = data.match(/^\x1b\[27;(\d+);(\d+)~$/);
17  if (!match) return undefined;
18
19  const modifier = Number.parseInt(match[1], 10) - 1;
20  const codepoint = Number.parseInt(match[2], 10);
21  const lockMask = 64 | 128;
22  if (((modifier & ~lockMask) & ~1) !== 0 || codepoint < 32) return undefined;
23
24  try {
25    return String.fromCodePoint(codepoint);
26  } catch {
27    return undefined;
28  }
29}
30
31type UICustomCarrier = {
32  ui: Pick<ExtensionContext["ui"], "custom">;
33};
34
35export interface CompactConfirmOptions {
36  /** Optional abort signal. If aborted before resolution, resolves to false. */
37  signal?: AbortSignal;
38}
39
40/**
41 * Show a single-line yes/no prompt.
42 *
43 * The user must type the literal word "yes" or "no" (case-insensitive,
44 * whitespace-trimmed) and press Enter. Esc or Ctrl-C cancels and resolves
45 * `false` — deny is always the safe default.
46 */
47export async function compactConfirm(
48  ctx: UICustomCarrier,
49  heading: string,
50  summary?: string,
51  opts: CompactConfirmOptions = {},
52): Promise<boolean> {
53  if (opts.signal?.aborted) return false;
54
55  return ctx.ui.custom<boolean>(
56    (_tui, theme, keybindings, done) =>
57      new CompactConfirmComponent(
58        heading,
59        summary,
60        theme,
61        keybindings,
62        opts.signal,
63        done,
64      ),
65  );
66}
67
68const VALID_RESPONSES = new Set(["yes", "no"]);
69
70class CompactConfirmComponent implements Component, Focusable {
71  focused = true;
72
73  private heading: string;
74  private summary: string | undefined;
75  private theme: Theme;
76  private keybindings: KeybindingsManager;
77  private done: (result: boolean) => void;
78  private buffer = "";
79  private settled = false;
80  private cachedWidth?: number;
81  private cachedLines?: string[];
82  private cachedBuffer?: string;
83  private abortCleanup?: () => void;
84
85  constructor(
86    heading: string,
87    summary: string | undefined,
88    theme: Theme,
89    keybindings: KeybindingsManager,
90    signal: AbortSignal | undefined,
91    done: (result: boolean) => void,
92  ) {
93    this.heading = heading;
94    this.summary = summary;
95    this.theme = theme;
96    this.keybindings = keybindings;
97    this.done = done;
98
99    if (signal) {
100      const onAbort = () => this.resolve(false);
101      signal.addEventListener("abort", onAbort, { once: true });
102      this.abortCleanup = () => signal.removeEventListener("abort", onAbort);
103    }
104  }
105
106  handleInput(data: string): void {
107    if (this.settled) return;
108
109    // Bracketed paste: strip the markers and treat the content as a single
110    // typed chunk, with newlines/tabs removed so a pasted "yes\n" cannot
111    // auto-submit.
112    if (data.startsWith("\x1b[200~")) {
113      const end = data.indexOf("\x1b[201~");
114      if (end !== -1) {
115        const pasted = data.slice("\x1b[200~".length, end).replace(/[\r\n\t]/g, "");
116        this.appendPrintable(pasted);
117        this.invalidate();
118        return;
119      }
120    }
121
122    if (this.keybindings.matches(data, "tui.select.cancel")) {
123      return this.resolve(false);
124    }
125
126    if (this.keybindings.matches(data, "tui.input.submit") || data === "\n" || data === "\r") {
127      const value = this.buffer.trim().toLowerCase();
128      if (value === "yes") return this.resolve(true);
129      if (value === "no") return this.resolve(false);
130      // Otherwise ignore — force the user to type a recognised response.
131      return;
132    }
133
134    if (this.keybindings.matches(data, "tui.editor.deleteCharBackward")) {
135      if (this.buffer.length > 0) {
136        this.buffer = this.buffer.slice(0, -1);
137        this.invalidate();
138      }
139      return;
140    }
141
142    if (this.keybindings.matches(data, "tui.editor.deleteWordBackward")) {
143      if (this.buffer.length > 0) {
144        this.buffer = "";
145        this.invalidate();
146      }
147      return;
148    }
149
150    const printable = decodePrintableKey(data) ?? (this.isPrintable(data) ? data : undefined);
151    if (printable !== undefined) {
152      this.appendPrintable(printable);
153      this.invalidate();
154    }
155  }
156
157  invalidate(): void {
158    this.cachedLines = undefined;
159    this.cachedWidth = undefined;
160    this.cachedBuffer = undefined;
161  }
162
163  render(width: number): string[] {
164    if (
165      this.cachedLines &&
166      this.cachedWidth === width &&
167      this.cachedBuffer === this.buffer
168    ) {
169      return this.cachedLines;
170    }
171
172    const t = this.theme;
173    const value = this.buffer.trim().toLowerCase();
174    const isValid = VALID_RESPONSES.has(value);
175
176    const headingPart = t.fg("muted", this.heading);
177    const summaryPart = this.summary ? ` ${t.bold(t.fg("accent", this.summary))}` : "";
178    const hintLabel = isValid ? "" : ' ("yes" or "no")';
179    const hintPart = t.fg("dim", `${hintLabel}: `);
180    const bufferPart = isValid ? t.fg("accent", this.buffer) : this.buffer;
181    const cursorPart = this.focused ? `${CURSOR_MARKER}\x1b[7m \x1b[27m` : "";
182
183    const line = `${headingPart}${summaryPart}${hintPart}${bufferPart}${cursorPart}`;
184
185    const padX = 1;
186    const contentWidth = Math.max(1, width - padX * 2);
187    const margin = " ".repeat(padX);
188    const wrapped = wrapTextWithAnsi(line, contentWidth).map((row) => {
189      const padded = margin + row + margin;
190      const pad = Math.max(0, width - visibleWidth(padded));
191      return padded + " ".repeat(pad);
192    });
193
194    const lines = wrapped.length > 0 ? wrapped : [" ".repeat(width)];
195
196    this.cachedLines = lines;
197    this.cachedWidth = width;
198    this.cachedBuffer = this.buffer;
199    return lines;
200  }
201
202  dispose(): void {
203    this.abortCleanup?.();
204  }
205
206  private appendPrintable(text: string): void {
207    // Don't let the buffer grow without bound — five chars is enough for
208    // "yes"/"no" with a typo or two of headroom.
209    const room = Math.max(0, 16 - this.buffer.length);
210    if (room === 0) return;
211    this.buffer += text.slice(0, room);
212  }
213
214  private isPrintable(data: string): boolean {
215    if (data.length === 0) return false;
216    for (const ch of data) {
217      const code = ch.charCodeAt(0);
218      if (code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f)) {
219        return false;
220      }
221    }
222    return true;
223  }
224
225  private resolve(value: boolean): void {
226    if (this.settled) return;
227    this.settled = true;
228    this.done(value);
229  }
230}