pi-cliproxyapi 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,292 @@
1
+ // Read-only scrollable overlay used by /cliproxy-list, /cliproxy-usage, /cliproxy-doctor.
2
+ //
3
+ // Pure presentational — never touches the agent session or context. The
4
+ // caller may supply boolean "toggles" (one keystroke each) that re-render the
5
+ // body when flipped. The overlay shell handles scrolling and dismissal.
6
+ //
7
+ // Built-in keys:
8
+ // ↑ / k scroll up one line (held = repeat via Kitty kbd protocol)
9
+ // ↓ / j scroll down one line
10
+ // PageUp / b scroll up one page
11
+ // PageDown / f scroll down one page (also Space)
12
+ // Home / g jump to top
13
+ // End / G jump to bottom
14
+ // Esc / q / Enter / Ctrl+C close
15
+ //
16
+ // Plus any caller-defined toggles.
17
+
18
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
19
+ import {
20
+ type Component,
21
+ getKeybindings,
22
+ matchesKey,
23
+ visibleWidth,
24
+ } from "@earendil-works/pi-tui";
25
+
26
+ interface Theme {
27
+ fg(name: string, s: string): string;
28
+ bold(s: string): string;
29
+ }
30
+
31
+ interface OverlayTui {
32
+ requestRender(): void;
33
+ rows?: number;
34
+ cols?: number;
35
+ }
36
+
37
+ export interface OverlayToggle {
38
+ /** Single key id passed to matchesKey() (e.g. "d", "v"). */
39
+ key: string;
40
+ /** Short hint shown in the footer (e.g. "d disabled"). */
41
+ hint: string;
42
+ /** Initial state. Defaults to false. */
43
+ initial?: boolean;
44
+ }
45
+
46
+ export interface OverlayOptions {
47
+ title: string;
48
+ /** Re-rendered every time a toggle flips. Receives current toggle state. */
49
+ render: (state: Record<string, boolean>) => string;
50
+ toggles?: OverlayToggle[];
51
+ }
52
+
53
+ interface BuildProps extends OverlayOptions {
54
+ done: (value: void) => void;
55
+ theme: Theme;
56
+ }
57
+
58
+ function buildOverlay(
59
+ tui: OverlayTui,
60
+ props: BuildProps,
61
+ ): Component & { handleInput(data: string): void } {
62
+ const state: Record<string, boolean> = {};
63
+ for (const t of props.toggles ?? []) state[t.key] = t.initial ?? false;
64
+ let lines: string[] = props.render(state).split("\n");
65
+ let offset = 0;
66
+ let lastRenderHeight = 20;
67
+ let lastRenderWidth = 80;
68
+
69
+ const t = props.theme;
70
+
71
+ const recompute = (): void => {
72
+ lines = props.render(state).split("\n");
73
+ const visible = Math.max(1, lastRenderHeight - 2);
74
+ const max = Math.max(0, lines.length - visible);
75
+ if (offset > max) offset = max;
76
+ };
77
+
78
+ const renderFrame = (width: number, height: number): string[] => {
79
+ const inner = Math.max(10, width - 2);
80
+ const visible = Math.max(1, height - 2);
81
+ const total = lines.length;
82
+ const maxOffset = Math.max(0, total - visible);
83
+ if (offset > maxOffset) offset = maxOffset;
84
+ const slice = lines.slice(offset, offset + visible);
85
+ while (slice.length < visible) slice.push("");
86
+ const pct =
87
+ total > visible
88
+ ? Math.min(100, Math.round(((offset + visible) / total) * 100))
89
+ : 100;
90
+ const titleBar = formatTitleBar(t, props.title, inner);
91
+ const footerBar = formatFooterBar(t, {
92
+ from: offset + 1,
93
+ to: Math.min(total, offset + visible),
94
+ total,
95
+ pct,
96
+ width: inner,
97
+ toggles: (props.toggles ?? []).map((tg) => ({
98
+ hint: tg.hint,
99
+ active: state[tg.key] === true,
100
+ })),
101
+ });
102
+ const sideL = t.fg("borderAccent", "│");
103
+ const sideR = t.fg("borderAccent", "│");
104
+ const out: string[] = [titleBar];
105
+ for (const ln of slice) {
106
+ out.push(`${sideL} ${padRight(ln, inner - 2)} ${sideR}`);
107
+ }
108
+ out.push(footerBar);
109
+ return out;
110
+ };
111
+
112
+ return {
113
+ render(width: number): string[] {
114
+ lastRenderWidth = width;
115
+ lastRenderHeight = Math.max(
116
+ 10,
117
+ Math.min(lines.length + 2, (tui.rows ?? 40) - 6),
118
+ );
119
+ return renderFrame(lastRenderWidth, lastRenderHeight);
120
+ },
121
+ invalidate(): void {
122
+ /* stateless render */
123
+ },
124
+ handleInput(data: string): void {
125
+ const visible = Math.max(1, lastRenderHeight - 2);
126
+ const max = Math.max(0, lines.length - visible);
127
+ const kb = getKeybindings();
128
+
129
+ // Caller toggles run first so they shadow letter-key scroll bindings.
130
+ for (const tg of props.toggles ?? []) {
131
+ if (matchesKey(data, tg.key as never)) {
132
+ state[tg.key] = !state[tg.key];
133
+ recompute();
134
+ tui.requestRender();
135
+ return;
136
+ }
137
+ }
138
+
139
+ if (
140
+ kb.matches(data, "tui.select.cancel") ||
141
+ matchesKey(data, "q") ||
142
+ matchesKey(data, "shift+q") ||
143
+ matchesKey(data, "enter") ||
144
+ matchesKey(data, "return")
145
+ ) {
146
+ props.done();
147
+ return;
148
+ }
149
+ let next = offset;
150
+ if (matchesKey(data, "up") || matchesKey(data, "k")) {
151
+ next = Math.max(0, offset - 1);
152
+ } else if (matchesKey(data, "down") || matchesKey(data, "j")) {
153
+ next = Math.min(max, offset + 1);
154
+ } else if (matchesKey(data, "pageUp") || matchesKey(data, "b")) {
155
+ next = Math.max(0, offset - visible);
156
+ } else if (
157
+ matchesKey(data, "pageDown") ||
158
+ matchesKey(data, "f") ||
159
+ matchesKey(data, "space")
160
+ ) {
161
+ next = Math.min(max, offset + visible);
162
+ } else if (matchesKey(data, "home") || matchesKey(data, "g")) {
163
+ next = 0;
164
+ } else if (matchesKey(data, "end") || matchesKey(data, "shift+g")) {
165
+ next = max;
166
+ } else {
167
+ return;
168
+ }
169
+ if (next !== offset) {
170
+ offset = next;
171
+ tui.requestRender();
172
+ }
173
+ },
174
+ };
175
+ }
176
+
177
+ // --------------------------------------------------------------------------- chrome helpers
178
+
179
+ function formatTitleBar(theme: Theme, title: string, inner: number): string {
180
+ const label = ` ${title} `;
181
+ const leftSep = "╭─";
182
+ const rightFill = inner - visibleWidth(label) - visibleWidth(leftSep) - 2;
183
+ const fill = "─".repeat(Math.max(0, rightFill));
184
+ return theme.fg(
185
+ "borderAccent",
186
+ `${leftSep}${theme.bold(theme.fg("accent", label))}${theme.fg("borderAccent", fill)}╮`,
187
+ );
188
+ }
189
+
190
+ function formatFooterBar(
191
+ theme: Theme,
192
+ opts: {
193
+ from: number;
194
+ to: number;
195
+ total: number;
196
+ pct: number;
197
+ width: number;
198
+ toggles: Array<{ hint: string; active: boolean }>;
199
+ },
200
+ ): string {
201
+ const hintBase = "↑↓ · pgUp/pgDn · g/G";
202
+ const togglesText = opts.toggles
203
+ .map((tg) =>
204
+ tg.active
205
+ ? theme.fg("success", `[${tg.hint}]`)
206
+ : theme.fg("dim", tg.hint),
207
+ )
208
+ .join(" · ");
209
+ const left = ` ${hintBase}${togglesText ? " " + togglesText : ""} `;
210
+ const right = ` ${opts.from}–${opts.to} of ${opts.total} ${opts.pct}% `;
211
+ const leftSep = "╰─";
212
+ const rightSep = "╯";
213
+ const used =
214
+ visibleWidth(leftSep) +
215
+ visibleWidth(rightSep) +
216
+ visibleWidth(left) +
217
+ visibleWidth(right);
218
+ const filler = "─".repeat(Math.max(0, opts.width - used));
219
+ return theme.fg(
220
+ "borderAccent",
221
+ `${leftSep}${theme.fg("dim", left)}${filler}${theme.fg("muted", right)}${rightSep}`,
222
+ );
223
+ }
224
+
225
+ function padRight(s: string, width: number): string {
226
+ const w = visibleWidth(s);
227
+ if (w >= width) return s;
228
+ return s + " ".repeat(width - w);
229
+ }
230
+
231
+ // --------------------------------------------------------------------------- public API
232
+
233
+ /**
234
+ * Show a scrollable overlay with optional toggles.
235
+ *
236
+ * If you don't need toggles, pass a string for the body and we'll wrap it.
237
+ */
238
+ export async function showOverlay(
239
+ ctx: ExtensionCommandContext,
240
+ title: string,
241
+ bodyOrOptions: string | Omit<OverlayOptions, "title">,
242
+ ): Promise<void> {
243
+ const opts: OverlayOptions =
244
+ typeof bodyOrOptions === "string"
245
+ ? { title, render: () => bodyOrOptions }
246
+ : { title, ...bodyOrOptions };
247
+
248
+ if (!ctx.hasUI) {
249
+ ctx.ui.notify(`${title}\n\n${opts.render({})}`, "info");
250
+ return;
251
+ }
252
+
253
+ // Probe widest line across all toggle combinations so the overlay doesn't
254
+ // resize as the user flips switches. With N toggles we check 2^N states,
255
+ // but in practice we only ever pass 0-2 toggles.
256
+ const probedWidth = probeMaxWidth(opts);
257
+ const desiredCols = Math.min(140, Math.max(72, probedWidth + 6));
258
+
259
+ await ctx.ui.custom<void>(
260
+ (tui, theme, _kb, done) =>
261
+ buildOverlay(tui as unknown as OverlayTui, {
262
+ ...opts,
263
+ done,
264
+ theme: theme as unknown as Theme,
265
+ }),
266
+ {
267
+ overlay: true,
268
+ overlayOptions: {
269
+ width: desiredCols,
270
+ maxHeight: "80%",
271
+ },
272
+ },
273
+ );
274
+ }
275
+
276
+ function probeMaxWidth(opts: OverlayOptions): number {
277
+ const toggles = opts.toggles ?? [];
278
+ const combos = 1 << toggles.length;
279
+ let max = 0;
280
+ for (let i = 0; i < combos; i++) {
281
+ const state: Record<string, boolean> = {};
282
+ toggles.forEach((tg, j) => {
283
+ state[tg.key] = (i & (1 << j)) !== 0;
284
+ });
285
+ const body = opts.render(state);
286
+ for (const ln of body.split("\n")) {
287
+ const w = visibleWidth(ln);
288
+ if (w > max) max = w;
289
+ }
290
+ }
291
+ return max;
292
+ }