pi-cliproxyapi 0.1.2 → 0.3.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.
package/src/ui-setup.ts CHANGED
@@ -14,7 +14,6 @@ import {
14
14
  getKeybindings,
15
15
  Input,
16
16
  matchesKey,
17
- visibleWidth,
18
17
  } from "@earendil-works/pi-tui";
19
18
 
20
19
  import {
@@ -23,6 +22,7 @@ import {
23
22
  resolveConfigValue,
24
23
  saveConfig,
25
24
  } from "./config.ts";
25
+ import { frame, frameInner } from "./ui-frame.ts";
26
26
 
27
27
  interface Theme {
28
28
  fg(name: string, s: string): string;
@@ -66,7 +66,7 @@ const STEPS: WizardStep[] = [
66
66
  },
67
67
  {
68
68
  label: "usageKey",
69
- hint: "Optional X-Plugin-Key for /api/usage. Leave blank to skip /cliproxy-usage",
69
+ hint: "Optional X-Plugin-Key for /api/usage. Leave blank to skip the Usage tab",
70
70
  required: false,
71
71
  },
72
72
  ];
@@ -104,13 +104,9 @@ export async function runSetup(
104
104
  const prefill = forceAll
105
105
  ? (values[step.label] ?? step.initialValue ?? "")
106
106
  : (values[step.label] ?? "");
107
- // Skip already-filled non-empty fields when called for first-run setup
108
- // (we still want to ask for missing pieces, but not re-prompt for
109
- // what's already saved).
110
107
  if (!forceAll && prefill) {
111
108
  continue;
112
109
  }
113
-
114
110
  const result = await promptStep(
115
111
  ctx,
116
112
  step,
@@ -123,7 +119,7 @@ export async function runSetup(
123
119
  const trimmed = result.trim();
124
120
  if (!trimmed) {
125
121
  if (step.required) {
126
- ctx.ui.notify(`${step.label} is required aborted`, "warning");
122
+ ctx.ui.notify(`${step.label} is required \u2014 aborted`, "warning");
127
123
  cancelled = true;
128
124
  break;
129
125
  }
@@ -153,10 +149,6 @@ export async function runSetup(
153
149
  return true;
154
150
  }
155
151
 
156
- /**
157
- * If the user typed a bare ~/path or /abs/path, wrap it in `!cat <path>`
158
- * so resolveConfigValue executes it. Otherwise return as-is.
159
- */
160
152
  function normalizeValue(raw: string): string {
161
153
  if (raw.startsWith("!") || raw.startsWith("$")) return raw;
162
154
  if (raw.startsWith("~/")) {
@@ -189,10 +181,7 @@ async function promptStep(
189
181
  prefill,
190
182
  done,
191
183
  ),
192
- {
193
- overlay: true,
194
- overlayOptions: { width: 100, maxHeight: "60%" },
195
- },
184
+ { overlay: true, overlayOptions: { width: 100, maxHeight: "60%" } },
196
185
  );
197
186
  }
198
187
 
@@ -220,8 +209,6 @@ function buildStepOverlay(
220
209
  }
221
210
  if (step.validate) {
222
211
  const trimmed = raw.trim();
223
- // Only validate the literal form. Indirect ("!", "$") values are
224
- // validated at apply time, not here.
225
212
  if (trimmed && !trimmed.startsWith("!") && !trimmed.startsWith("$")) {
226
213
  const err = step.validate(trimmed);
227
214
  if (err) {
@@ -239,38 +226,30 @@ function buildStepOverlay(
239
226
 
240
227
  return {
241
228
  render(width: number): string[] {
242
- const inner = Math.max(40, width - 2);
243
- const top = theme.fg(
244
- "borderAccent",
245
- `\u256d\u2500 ${theme.bold(theme.fg("accent", `setup: ${step.label}`))} ${"\u2500".repeat(Math.max(0, inner - visibleWidth(`setup: ${step.label}`) - 4))}\u256e`,
246
- );
247
- const hintLine = pad(theme.fg("dim", step.hint), inner - 2);
229
+ const inner = frameInner(width);
248
230
  const inputLines = input.render(inner - 4);
249
- const errLine = error
250
- ? pad(theme.fg("error", `! ${error}`), inner - 2)
251
- : pad(theme.fg("dim", "enter = save · esc = cancel"), inner - 2);
252
- const side = theme.fg("borderAccent", "\u2502");
253
- const out: string[] = [top];
254
- out.push(`${side} ${hintLine} ${side}`);
255
- out.push(`${side} ${pad("", inner - 2)} ${side}`);
256
- for (const ln of inputLines) {
257
- out.push(
258
- `${side} ${pad(theme.fg("accent", `> ${ln}`), inner - 2)} ${side}`,
259
- );
260
- }
261
- out.push(`${side} ${pad("", inner - 2)} ${side}`);
262
- out.push(`${side} ${errLine} ${side}`);
263
- out.push(
264
- theme.fg("borderAccent", `\u2570${"\u2500".repeat(inner)}\u256f`),
265
- );
266
- return out;
231
+ const lines: string[] = [
232
+ "",
233
+ ` ${theme.fg("dim", step.hint)}`,
234
+ "",
235
+ ...inputLines.map((ln) => ` ${theme.fg("accent", `\u276f ${ln}`)}`),
236
+ "",
237
+ error
238
+ ? ` ${theme.fg("error", `! ${error}`)}`
239
+ : ` ${theme.fg("dim", "enter = save \u00b7 esc = cancel")}`,
240
+ ];
241
+ return frame(theme, {
242
+ width,
243
+ title: ` setup: ${step.label} `,
244
+ lines,
245
+ footer: { hint: " enter = save \u00b7 esc = cancel " },
246
+ });
267
247
  },
268
248
  invalidate(): void {
269
249
  input.invalidate();
270
250
  },
271
251
  handleInput(data: string): void {
272
252
  const kb = getKeybindings();
273
- // Plain Esc cancels even if Input doesn't dispatch it.
274
253
  if (kb.matches(data, "tui.select.cancel") || matchesKey(data, "escape")) {
275
254
  done(undefined);
276
255
  return;
@@ -281,9 +260,3 @@ function buildStepOverlay(
281
260
  },
282
261
  };
283
262
  }
284
-
285
- function pad(s: string, width: number): string {
286
- const w = visibleWidth(s);
287
- if (w >= width) return s;
288
- return s + " ".repeat(width - w);
289
- }
package/src/ui-usage.ts CHANGED
@@ -1,4 +1,4 @@
1
- // Renders /api/usage as multi-line ANSI text for /cliproxy-usage overlay.
1
+ // Renders /api/usage as multi-line ANSI text for the hub Usage tab.
2
2
  //
3
3
  // Lines are wrapped pre-render so they never reflow inside the overlay box —
4
4
  // long errors get truncated with an ellipsis on the same line. Progress bars
package/src/ui-overlay.ts DELETED
@@ -1,292 +0,0 @@
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
- }