pi-cliproxyapi 0.2.0 → 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.
@@ -0,0 +1,50 @@
1
+ // Public entry for the /cliproxy hub overlay.
2
+
3
+ import type {
4
+ ExtensionAPI,
5
+ ExtensionCommandContext,
6
+ } from "@earendil-works/pi-coding-agent";
7
+
8
+ import type { ProxyConfig } from "../config.ts";
9
+ import type { Discovery } from "../fetch-models.ts";
10
+ import { setLogQuiet } from "../log.ts";
11
+ import type { OverlayTui, Theme } from "../ui-picker/types.ts";
12
+ import { buildHub } from "./hub.ts";
13
+
14
+ export async function runHub(
15
+ pi: ExtensionAPI,
16
+ ctx: ExtensionCommandContext,
17
+ cfg: ProxyConfig,
18
+ discovery: Discovery,
19
+ ): Promise<void> {
20
+ if (!ctx.hasUI) {
21
+ ctx.ui.notify("interactive UI required for /cliproxy", "warning");
22
+ return;
23
+ }
24
+ // The hub mutates a draft; callers persist via the in-hub save action.
25
+ const draft: ProxyConfig = structuredClone(cfg);
26
+ // Mute console logging while the overlay is open: any stdout write prints
27
+ // over the box and shifts it down by exactly one line. Results are shown
28
+ // inside the hub (status flash) instead.
29
+ setLogQuiet(true);
30
+ try {
31
+ await ctx.ui.custom<void>(
32
+ (tui, theme, _kb, done) =>
33
+ buildHub({
34
+ pi,
35
+ ctx,
36
+ tui: tui as unknown as OverlayTui,
37
+ theme: theme as unknown as Theme,
38
+ cfg: draft,
39
+ discovery,
40
+ done,
41
+ }),
42
+ {
43
+ overlay: true,
44
+ overlayOptions: { width: 170, maxHeight: "94%" },
45
+ },
46
+ );
47
+ } finally {
48
+ setLogQuiet(false);
49
+ }
50
+ }
@@ -0,0 +1,119 @@
1
+ // Shared TUI primitives for the /cliproxy hub.
2
+ //
3
+ // One home for the scroll math + chrome (tab bar, status header) that used to
4
+ // be copy-pasted across the picker, the read-only overlay, and the setup
5
+ // wizard. Everything here is pure and ANSI-aware.
6
+
7
+ import { visibleWidth } from "@earendil-works/pi-tui";
8
+
9
+ import { pad } from "../ui-picker/render-text.ts";
10
+ import type { Theme } from "../ui-picker/types.ts";
11
+
12
+ export interface TabSpec {
13
+ id: string;
14
+ label: string;
15
+ }
16
+
17
+ /**
18
+ * Render the hub tab bar as a single `width`-cell line.
19
+ * Models \u2502 Usage \u2502 Diagnostics
20
+ * The active tab is bold/accent; the rest are dimmed.
21
+ */
22
+ export function tabBar(
23
+ theme: Theme,
24
+ tabs: TabSpec[],
25
+ activeIdx: number,
26
+ width: number,
27
+ ): string {
28
+ const sep = theme.fg("borderAccent", "\u2502");
29
+ const cells = tabs.map((t, i) => {
30
+ const label = `${i + 1} ${t.label}`;
31
+ return i === activeIdx
32
+ ? theme.bold(theme.fg("accent", ` ${label} `))
33
+ : theme.fg("dim", ` ${label} `);
34
+ });
35
+ return pad(` ${cells.join(sep)}`, width);
36
+ }
37
+
38
+ /**
39
+ * Render a status header from labelled parts, joined with a dim gap and
40
+ * clipped to `width`.
41
+ */
42
+ export function statusHeader(
43
+ theme: Theme,
44
+ parts: string[],
45
+ width: number,
46
+ ): string {
47
+ const joined = parts.join(theme.fg("dim", " "));
48
+ return pad(` ${joined}`, width);
49
+ }
50
+
51
+ /** A full-width horizontal rule used to divide chrome from the view body. */
52
+ export function ruleLine(theme: Theme, width: number): string {
53
+ return theme.fg("borderAccent", "\u2500".repeat(Math.max(0, width)));
54
+ }
55
+
56
+ /**
57
+ * Slice `lines` to exactly `count` rows starting at `scroll`, padding the tail
58
+ * with empty strings so the caller always gets a fixed-height block.
59
+ */
60
+ export function takeSlice(
61
+ lines: string[],
62
+ scroll: number,
63
+ count: number,
64
+ ): string[] {
65
+ const s = lines.slice(scroll, scroll + count);
66
+ while (s.length < count) s.push("");
67
+ return s;
68
+ }
69
+
70
+ /**
71
+ * Compute a scroll offset that keeps a cursor visible inside `visible` rows.
72
+ *
73
+ * Two cursor coords:
74
+ * - cursorTop: the highest line that must stay visible (often a group
75
+ * subheader pinned right above the cursor on a group's first row).
76
+ * - cursorBottom: the cursor row itself.
77
+ *
78
+ * `stickyCount` rows at the top (panel headers) are pinned and never scroll
79
+ * out from under the cursor block. Returns a clamped offset that never leaves
80
+ * a gap above nor pushes the cursor below the window.
81
+ */
82
+ export function clampScroll(
83
+ cursorTop: number,
84
+ cursorBottom: number,
85
+ prev: number,
86
+ visible: number,
87
+ total: number,
88
+ stickyCount: number,
89
+ ): number {
90
+ if (total <= visible) return 0;
91
+ const maxScroll = Math.max(0, total - visible);
92
+ if (cursorBottom < stickyCount) return 0;
93
+ let scroll = Math.max(0, Math.min(prev, maxScroll));
94
+ const topRow = scroll + stickyCount;
95
+ if (cursorTop < topRow) {
96
+ scroll = Math.max(0, cursorTop - stickyCount);
97
+ }
98
+ const bottomRow = scroll + visible - 1;
99
+ if (cursorBottom > bottomRow) {
100
+ scroll = Math.min(maxScroll, cursorBottom - (visible - 1));
101
+ }
102
+ return scroll;
103
+ }
104
+
105
+ /**
106
+ * Simple offset-based clamp for flat (un-sticky) lists like usage/diagnostics.
107
+ * Returns an offset within [0, total-visible].
108
+ */
109
+ export function clampOffset(
110
+ offset: number,
111
+ visible: number,
112
+ total: number,
113
+ ): number {
114
+ const max = Math.max(0, total - visible);
115
+ return Math.max(0, Math.min(offset, max));
116
+ }
117
+
118
+ /** Number of visible cells in a string (re-export for view code). */
119
+ export { visibleWidth };
@@ -0,0 +1,16 @@
1
+ // Hub view contract. Each tab is a body-only renderer + input handler; the hub
2
+ // shell draws the frame, status header, tab bar, and footer around it.
3
+
4
+ export interface HubView {
5
+ id: string;
6
+ /** Tab label shown in the bar. */
7
+ label: string;
8
+ /** Render exactly `height` body lines, each `width` cells wide. */
9
+ render(width: number, height: number): string[];
10
+ /** Return true if the key was consumed (hub then re-renders + stops). */
11
+ handleInput(data: string): boolean | Promise<boolean>;
12
+ /** Contextual footer hint for this view. */
13
+ footerHint(): string;
14
+ /** Called when the tab becomes active (e.g. to kick a lazy fetch). */
15
+ onActivate?(): void;
16
+ }
@@ -0,0 +1,108 @@
1
+ // Diagnostics view — connectivity, key resolution, and discovery shape.
2
+ // Built synchronously from the config + the discovery already fetched by the
3
+ // hub, plus a read-only conflict scan.
4
+
5
+ import { matchesKey } from "@earendil-works/pi-tui";
6
+
7
+ import type { ProxyConfig } from "../config.ts";
8
+ import { resolveConfigValue } from "../config.ts";
9
+ import { detectConflicts } from "../conflicts.ts";
10
+ import type { Discovery } from "../fetch-models.ts";
11
+ import { PLUGIN_USER_AGENT } from "../fetch-models.ts";
12
+ import { pad } from "../ui-picker/render-text.ts";
13
+ import type { Theme } from "../ui-picker/types.ts";
14
+ import { clampOffset, takeSlice } from "./shell.ts";
15
+ import type { HubView } from "./types.ts";
16
+
17
+ export interface DiagnosticsViewDeps {
18
+ theme: Theme;
19
+ cfg: ProxyConfig;
20
+ getDiscovery: () => Discovery;
21
+ }
22
+
23
+ export function buildDiagnosticsView(deps: DiagnosticsViewDeps): HubView {
24
+ const { theme, cfg } = deps;
25
+ let offset = 0;
26
+
27
+ const buildLines = (): string[] => {
28
+ const d = deps.getDiscovery();
29
+ const ok = (s: string) => theme.fg("success", s);
30
+ const bad = (s: string) => theme.fg("error", s);
31
+ const dim = (s: string) => theme.fg("dim", s);
32
+ const lines: string[] = [];
33
+
34
+ lines.push(
35
+ `${dim("endpoint")} ${cfg.proxy.endpoint || bad("(unset)")}`,
36
+ );
37
+ lines.push(
38
+ `${dim("apiKey")} ${resolveConfigValue(cfg.proxy.apiKey) ? ok("resolves") : bad("empty after resolution")}`,
39
+ );
40
+ lines.push(
41
+ `${dim("usageKey")} ${
42
+ cfg.proxy.usageKey
43
+ ? resolveConfigValue(cfg.proxy.usageKey)
44
+ ? ok("resolves")
45
+ : bad("set but empty")
46
+ : dim("not configured")
47
+ }`,
48
+ );
49
+ lines.push(`${dim("user-agent")} ${PLUGIN_USER_AGENT}`);
50
+ lines.push("");
51
+ lines.push(`${dim("discovery")} source=${d.source}`);
52
+ lines.push(
53
+ `${dim("upstream")} v=${d.upstreamVersion ?? "(unknown)"} \u00b7 ${d.upstreamTotal} ids`,
54
+ );
55
+ const builtins =
56
+ d.builtinProviders
57
+ .map((p) => `${p.name}=${p.models.length}`)
58
+ .join(", ") || dim("(none)");
59
+ lines.push(`${dim("built-in")} ${builtins}`);
60
+ lines.push(`${dim("custom pool")} ${d.customPool.length} models`);
61
+
62
+ const conflicts = detectConflicts(cfg);
63
+ lines.push("");
64
+ if (conflicts.length === 0) {
65
+ lines.push(`${dim("conflicts")} ${ok("none")}`);
66
+ } else {
67
+ lines.push(`${dim("conflicts")}`);
68
+ for (const c of conflicts)
69
+ lines.push(` ${theme.fg("warning", `[${c.kind}]`)} ${c.detail}`);
70
+ }
71
+ return lines;
72
+ };
73
+
74
+ const render = (width: number, height: number): string[] => {
75
+ const body = buildLines();
76
+ offset = clampOffset(offset, height, body.length);
77
+ return takeSlice(body, offset, height).map((ln) => pad(` ${ln}`, width));
78
+ };
79
+
80
+ const handleInput = (data: string): boolean => {
81
+ const total = buildLines().length;
82
+ if (matchesKey(data, "up") || matchesKey(data, "k")) {
83
+ offset = Math.max(0, offset - 1);
84
+ return true;
85
+ }
86
+ if (matchesKey(data, "down") || matchesKey(data, "j")) {
87
+ offset = Math.min(Math.max(0, total - 1), offset + 1);
88
+ return true;
89
+ }
90
+ if (matchesKey(data, "pageUp") || matchesKey(data, "b")) {
91
+ offset = Math.max(0, offset - 10);
92
+ return true;
93
+ }
94
+ if (matchesKey(data, "pageDown") || matchesKey(data, "f")) {
95
+ offset = Math.min(Math.max(0, total - 1), offset + 10);
96
+ return true;
97
+ }
98
+ return false;
99
+ };
100
+
101
+ return {
102
+ id: "diagnostics",
103
+ label: "Diagnostics",
104
+ render,
105
+ handleInput,
106
+ footerHint: () => " \u2191\u2193 scroll \u00b7 r refresh discovery ",
107
+ };
108
+ }