pi-cliproxyapi 0.2.0 → 0.3.1
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/README.md +67 -31
- package/index.ts +3 -3
- package/package.json +1 -1
- package/src/commands.ts +12 -143
- package/src/fetch-models.ts +2 -5
- package/src/log.ts +17 -4
- package/src/ui-hub/hub.ts +264 -0
- package/src/ui-hub/index.ts +50 -0
- package/src/ui-hub/shell.ts +119 -0
- package/src/ui-hub/types.ts +16 -0
- package/src/ui-hub/view-diagnostics.ts +108 -0
- package/src/ui-hub/view-models.ts +515 -0
- package/src/ui-hub/view-usage.ts +131 -0
- package/src/ui-picker/mutate.ts +34 -0
- package/src/ui-setup.ts +1 -1
- package/src/ui-usage.ts +1 -1
- package/src/ui-overlay.ts +0 -235
- package/src/ui-picker/index.ts +0 -35
- package/src/ui-picker/picker-component.ts +0 -432
- package/src/ui-picker/picker.ts +0 -247
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// /cliproxy hub — one overlay with Models / Usage / Diagnostics tabs and a
|
|
2
|
+
// shared action bar. Replaces the old per-command overlays (-refresh, -usage,
|
|
3
|
+
// -doctor) with global keys inside a single surface.
|
|
4
|
+
//
|
|
5
|
+
// global keys: [ ] / 1 2 3 switch tab \u00b7 r refresh \u00b7 e setup \u00b7 s save \u00b7 q close
|
|
6
|
+
// per-view keys: see each view's footerHint()
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
ExtensionAPI,
|
|
10
|
+
ExtensionCommandContext,
|
|
11
|
+
} from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import {
|
|
13
|
+
type Component,
|
|
14
|
+
getKeybindings,
|
|
15
|
+
matchesKey,
|
|
16
|
+
} from "@earendil-works/pi-tui";
|
|
17
|
+
|
|
18
|
+
import { applyAll } from "../apply.ts";
|
|
19
|
+
import type { ProxyConfig } from "../config.ts";
|
|
20
|
+
import { loadConfig, resolveConfigValue, saveConfig } from "../config.ts";
|
|
21
|
+
import type { Discovery } from "../fetch-models.ts";
|
|
22
|
+
import { fetchDiscovery } from "../fetch-models.ts";
|
|
23
|
+
import { clearUsageCache } from "../fetch-usage.ts";
|
|
24
|
+
import { frame } from "../ui-frame.ts";
|
|
25
|
+
import type { OverlayTui, Theme } from "../ui-picker/types.ts";
|
|
26
|
+
import { runSetup } from "../ui-setup.ts";
|
|
27
|
+
import { ruleLine, statusHeader, tabBar } from "./shell.ts";
|
|
28
|
+
import type { HubView } from "./types.ts";
|
|
29
|
+
import { buildDiagnosticsView } from "./view-diagnostics.ts";
|
|
30
|
+
import { buildModelsView, type ModelsView } from "./view-models.ts";
|
|
31
|
+
import { buildUsageView, type UsageView } from "./view-usage.ts";
|
|
32
|
+
|
|
33
|
+
export interface HubDeps {
|
|
34
|
+
pi: ExtensionAPI;
|
|
35
|
+
ctx: ExtensionCommandContext;
|
|
36
|
+
tui: OverlayTui;
|
|
37
|
+
theme: Theme;
|
|
38
|
+
cfg: ProxyConfig; // mutable draft owned by the hub
|
|
39
|
+
discovery: Discovery;
|
|
40
|
+
done: () => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function buildHub(
|
|
44
|
+
deps: HubDeps,
|
|
45
|
+
): Component & { handleInput(data: string): void } {
|
|
46
|
+
const { pi, ctx, tui, theme, cfg, done } = deps;
|
|
47
|
+
let discovery = deps.discovery;
|
|
48
|
+
let dirty = false; // unsaved config changes
|
|
49
|
+
let flash = ""; // transient status message (save/refresh/setup feedback)
|
|
50
|
+
|
|
51
|
+
const models: ModelsView = buildModelsView({
|
|
52
|
+
tui,
|
|
53
|
+
theme,
|
|
54
|
+
ctx,
|
|
55
|
+
cfg,
|
|
56
|
+
getDiscovery: () => discovery,
|
|
57
|
+
onChange: () => {
|
|
58
|
+
dirty = true;
|
|
59
|
+
flash = "";
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
const usage: UsageView = buildUsageView({ tui, theme, cfg });
|
|
63
|
+
const diagnostics: HubView = buildDiagnosticsView({
|
|
64
|
+
theme,
|
|
65
|
+
cfg,
|
|
66
|
+
getDiscovery: () => discovery,
|
|
67
|
+
});
|
|
68
|
+
const views: HubView[] = [models, usage, diagnostics];
|
|
69
|
+
let activeIdx = 0;
|
|
70
|
+
const active = (): HubView => views[activeIdx]!;
|
|
71
|
+
|
|
72
|
+
const switchTab = (idx: number): void => {
|
|
73
|
+
if (idx < 0 || idx >= views.length || idx === activeIdx) return;
|
|
74
|
+
activeIdx = idx;
|
|
75
|
+
active().onActivate?.();
|
|
76
|
+
tui.requestRender();
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const save = (): void => {
|
|
80
|
+
flash = theme.fg("dim", "saving\u2026");
|
|
81
|
+
saveConfig(cfg);
|
|
82
|
+
tui.requestRender();
|
|
83
|
+
void applyAll(pi, cfg, discovery)
|
|
84
|
+
.then((rep) => {
|
|
85
|
+
dirty = false;
|
|
86
|
+
flash = theme.fg(
|
|
87
|
+
"success",
|
|
88
|
+
`\u2713 settings saved \u00b7 ${rep.registered.length} registered, ${rep.skipped.length} skipped`,
|
|
89
|
+
);
|
|
90
|
+
tui.requestRender();
|
|
91
|
+
})
|
|
92
|
+
.catch((e: unknown) => {
|
|
93
|
+
flash = theme.fg("error", `save failed: ${(e as Error).message}`);
|
|
94
|
+
tui.requestRender();
|
|
95
|
+
});
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const refresh = async (): Promise<void> => {
|
|
99
|
+
flash = theme.fg("dim", "refreshing\u2026");
|
|
100
|
+
tui.requestRender();
|
|
101
|
+
try {
|
|
102
|
+
const key = resolveConfigValue(cfg.proxy.apiKey);
|
|
103
|
+
discovery = await fetchDiscovery(cfg, key);
|
|
104
|
+
clearUsageCache();
|
|
105
|
+
models.rebuild();
|
|
106
|
+
usage.reload();
|
|
107
|
+
const rep = await applyAll(pi, cfg, discovery);
|
|
108
|
+
flash = theme.fg(
|
|
109
|
+
"success",
|
|
110
|
+
`\u2713 refreshed \u00b7 ${rep.registered.length} providers (${discovery.source})`,
|
|
111
|
+
);
|
|
112
|
+
} catch (e) {
|
|
113
|
+
flash = theme.fg("error", `refresh failed: ${(e as Error).message}`);
|
|
114
|
+
}
|
|
115
|
+
tui.requestRender();
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const setup = async (): Promise<void> => {
|
|
119
|
+
const ok = await runSetup(ctx, true);
|
|
120
|
+
if (!ok) {
|
|
121
|
+
tui.requestRender();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
// Pull fresh credentials but keep the user's in-hub model edits.
|
|
125
|
+
const reloaded = loadConfig();
|
|
126
|
+
cfg.proxy = { ...cfg.proxy, ...reloaded.proxy };
|
|
127
|
+
await refresh();
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// ----- chrome ------------------------------------------------------------
|
|
131
|
+
const statusParts = (): string[] => {
|
|
132
|
+
const ep = cfg.proxy.endpoint || "(unset)";
|
|
133
|
+
const epShort = ep.length > 40 ? `\u2026${ep.slice(-39)}` : ep;
|
|
134
|
+
const keyOk = Boolean(resolveConfigValue(cfg.proxy.apiKey));
|
|
135
|
+
let provCount = 0;
|
|
136
|
+
let modelCount = 0;
|
|
137
|
+
for (const p of Object.values(cfg.builtinProviders)) {
|
|
138
|
+
if (p.enabled && p.models.length > 0) provCount++;
|
|
139
|
+
modelCount += p.models.length;
|
|
140
|
+
}
|
|
141
|
+
for (const p of Object.values(cfg.customProviders)) {
|
|
142
|
+
if (p.models.length > 0) provCount++;
|
|
143
|
+
modelCount += p.models.length;
|
|
144
|
+
}
|
|
145
|
+
const state = flash
|
|
146
|
+
? flash
|
|
147
|
+
: dirty
|
|
148
|
+
? theme.fg("warning", "\u25cf unsaved \u2014 press s to save")
|
|
149
|
+
: theme.fg("dim", "\u2713 saved");
|
|
150
|
+
return [
|
|
151
|
+
`${theme.fg("dim", "endpoint")} ${epShort}`,
|
|
152
|
+
`${theme.fg("dim", "key")} ${keyOk ? theme.fg("success", "\u2713") : theme.fg("error", "\u2717")}`,
|
|
153
|
+
`${theme.fg("dim", "providers")} ${provCount}`,
|
|
154
|
+
`${theme.fg("dim", "models")} ${modelCount}`,
|
|
155
|
+
state,
|
|
156
|
+
];
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const render = (width: number): string[] => {
|
|
160
|
+
const totalRows = tui.rows ?? 40;
|
|
161
|
+
// frame() adds a top + bottom border, so the lines we hand it must total
|
|
162
|
+
// `frameTotal - 2`. Keep frameTotal within the overlay's 94% budget
|
|
163
|
+
// (proven range 16\u201338) so the box never overflows and gets clipped
|
|
164
|
+
// \u2014 clipping is what made the top unreachable and shifted the window.
|
|
165
|
+
const frameTotal = Math.max(16, Math.min(totalRows - 6, 38));
|
|
166
|
+
const bodyRows = frameTotal - 2;
|
|
167
|
+
const inner = Math.max(72, width - 2);
|
|
168
|
+
const viewBodyH = Math.max(6, bodyRows - 3); // minus status + tab + rule
|
|
169
|
+
|
|
170
|
+
const lines: string[] = [];
|
|
171
|
+
lines.push(statusHeader(theme, statusParts(), inner));
|
|
172
|
+
lines.push(
|
|
173
|
+
tabBar(
|
|
174
|
+
theme,
|
|
175
|
+
views.map((v) => ({ id: v.id, label: v.label })),
|
|
176
|
+
activeIdx,
|
|
177
|
+
inner,
|
|
178
|
+
),
|
|
179
|
+
);
|
|
180
|
+
lines.push(ruleLine(theme, inner));
|
|
181
|
+
lines.push(...active().render(inner, viewBodyH));
|
|
182
|
+
|
|
183
|
+
return frame(theme, {
|
|
184
|
+
width,
|
|
185
|
+
title: " /cliproxy ",
|
|
186
|
+
lines,
|
|
187
|
+
footer: {
|
|
188
|
+
hint: active().footerHint(),
|
|
189
|
+
badge:
|
|
190
|
+
" [ ] tab \u00b7 r refresh \u00b7 e setup \u00b7 s save \u00b7 q close ",
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// ----- input -------------------------------------------------------------
|
|
196
|
+
const globalInput = (data: string): boolean | Promise<boolean> => {
|
|
197
|
+
const kb = getKeybindings();
|
|
198
|
+
if (
|
|
199
|
+
kb.matches(data, "tui.select.cancel") ||
|
|
200
|
+
matchesKey(data, "q") ||
|
|
201
|
+
matchesKey(data, "escape")
|
|
202
|
+
) {
|
|
203
|
+
done();
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
if (matchesKey(data, "]") || matchesKey(data, "tab")) {
|
|
207
|
+
switchTab((activeIdx + 1) % views.length);
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
if (matchesKey(data, "[") || matchesKey(data, "shift+tab")) {
|
|
211
|
+
switchTab((activeIdx - 1 + views.length) % views.length);
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
if (matchesKey(data, "1")) {
|
|
215
|
+
switchTab(0);
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
if (matchesKey(data, "2")) {
|
|
219
|
+
switchTab(1);
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
if (matchesKey(data, "3")) {
|
|
223
|
+
switchTab(2);
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
if (matchesKey(data, "r")) return refresh().then(() => true);
|
|
227
|
+
if (matchesKey(data, "e")) return setup().then(() => true);
|
|
228
|
+
if (matchesKey(data, "s")) {
|
|
229
|
+
save();
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
return false;
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
render,
|
|
237
|
+
invalidate(): void {
|
|
238
|
+
/* stateless */
|
|
239
|
+
},
|
|
240
|
+
handleInput(data: string): void {
|
|
241
|
+
const handled = active().handleInput(data);
|
|
242
|
+
if (handled instanceof Promise) {
|
|
243
|
+
flash = "";
|
|
244
|
+
void handled.then((h) => {
|
|
245
|
+
if (h) tui.requestRender();
|
|
246
|
+
});
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (handled) {
|
|
250
|
+
flash = ""; // editing/navigating clears stale save feedback
|
|
251
|
+
tui.requestRender();
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const g = globalInput(data);
|
|
255
|
+
if (g instanceof Promise) {
|
|
256
|
+
void g.then((h) => {
|
|
257
|
+
if (h) tui.requestRender();
|
|
258
|
+
});
|
|
259
|
+
} else if (g) {
|
|
260
|
+
tui.requestRender();
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
}
|
|
@@ -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
|
+
}
|