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.
- package/README.md +34 -23
- 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
package/src/ui-overlay.ts
DELETED
|
@@ -1,235 +0,0 @@
|
|
|
1
|
-
// Read-only scrollable overlay used by /cliproxy-usage and /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
|
-
import { frame } from "./ui-frame.ts";
|
|
27
|
-
|
|
28
|
-
interface Theme {
|
|
29
|
-
fg(name: string, s: string): string;
|
|
30
|
-
bold(s: string): string;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
interface OverlayTui {
|
|
34
|
-
requestRender(): void;
|
|
35
|
-
rows?: number;
|
|
36
|
-
cols?: number;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export interface OverlayToggle {
|
|
40
|
-
/** Single key id passed to matchesKey() (e.g. "d", "v"). */
|
|
41
|
-
key: string;
|
|
42
|
-
/** Short hint shown in the footer (e.g. "d disabled"). */
|
|
43
|
-
hint: string;
|
|
44
|
-
/** Initial state. Defaults to false. */
|
|
45
|
-
initial?: boolean;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export interface OverlayOptions {
|
|
49
|
-
title: string;
|
|
50
|
-
/** Re-rendered every time a toggle flips. Receives current toggle state. */
|
|
51
|
-
render: (state: Record<string, boolean>) => string;
|
|
52
|
-
toggles?: OverlayToggle[];
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
interface BuildProps extends OverlayOptions {
|
|
56
|
-
done: (value: void) => void;
|
|
57
|
-
theme: Theme;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function buildOverlay(
|
|
61
|
-
tui: OverlayTui,
|
|
62
|
-
props: BuildProps,
|
|
63
|
-
): Component & { handleInput(data: string): void } {
|
|
64
|
-
const state: Record<string, boolean> = {};
|
|
65
|
-
for (const t of props.toggles ?? []) state[t.key] = t.initial ?? false;
|
|
66
|
-
let lines: string[] = props.render(state).split("\n");
|
|
67
|
-
let offset = 0;
|
|
68
|
-
let lastRenderHeight = 20;
|
|
69
|
-
let lastRenderWidth = 80;
|
|
70
|
-
|
|
71
|
-
const t = props.theme;
|
|
72
|
-
|
|
73
|
-
const recompute = (): void => {
|
|
74
|
-
lines = props.render(state).split("\n");
|
|
75
|
-
const visible = Math.max(1, lastRenderHeight - 2);
|
|
76
|
-
const max = Math.max(0, lines.length - visible);
|
|
77
|
-
if (offset > max) offset = max;
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
const renderFrame = (width: number, height: number): string[] => {
|
|
81
|
-
const visible = Math.max(1, height - 2);
|
|
82
|
-
const total = lines.length;
|
|
83
|
-
const maxOffset = Math.max(0, total - visible);
|
|
84
|
-
if (offset > maxOffset) offset = maxOffset;
|
|
85
|
-
const slice = lines.slice(offset, offset + visible);
|
|
86
|
-
while (slice.length < visible) slice.push("");
|
|
87
|
-
const pct =
|
|
88
|
-
total > visible
|
|
89
|
-
? Math.min(100, Math.round(((offset + visible) / total) * 100))
|
|
90
|
-
: 100;
|
|
91
|
-
const hintBase = "\u2191\u2193 \u00b7 pgUp/pgDn \u00b7 g/G";
|
|
92
|
-
const togglesText = (props.toggles ?? [])
|
|
93
|
-
.map((tg) =>
|
|
94
|
-
state[tg.key] === true
|
|
95
|
-
? t.fg("success", `[${tg.hint}]`)
|
|
96
|
-
: t.fg("dim", tg.hint),
|
|
97
|
-
)
|
|
98
|
-
.join(" \u00b7 ");
|
|
99
|
-
const hint = ` ${hintBase}${togglesText ? " " + togglesText : ""} `;
|
|
100
|
-
const badge = ` ${offset + 1}\u2013${Math.min(total, offset + visible)} of ${total} ${pct}% `;
|
|
101
|
-
return frame(t, {
|
|
102
|
-
width,
|
|
103
|
-
title: ` ${props.title} `,
|
|
104
|
-
lines: slice.map((ln) => ` ${ln}`),
|
|
105
|
-
footer: { hint, badge },
|
|
106
|
-
});
|
|
107
|
-
};
|
|
108
|
-
|
|
109
|
-
return {
|
|
110
|
-
render(width: number): string[] {
|
|
111
|
-
lastRenderWidth = width;
|
|
112
|
-
lastRenderHeight = Math.max(
|
|
113
|
-
10,
|
|
114
|
-
Math.min(lines.length + 2, (tui.rows ?? 40) - 6),
|
|
115
|
-
);
|
|
116
|
-
return renderFrame(lastRenderWidth, lastRenderHeight);
|
|
117
|
-
},
|
|
118
|
-
invalidate(): void {
|
|
119
|
-
/* stateless render */
|
|
120
|
-
},
|
|
121
|
-
handleInput(data: string): void {
|
|
122
|
-
const visible = Math.max(1, lastRenderHeight - 2);
|
|
123
|
-
const max = Math.max(0, lines.length - visible);
|
|
124
|
-
const kb = getKeybindings();
|
|
125
|
-
|
|
126
|
-
// Caller toggles run first so they shadow letter-key scroll bindings.
|
|
127
|
-
for (const tg of props.toggles ?? []) {
|
|
128
|
-
if (matchesKey(data, tg.key as never)) {
|
|
129
|
-
state[tg.key] = !state[tg.key];
|
|
130
|
-
recompute();
|
|
131
|
-
tui.requestRender();
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
if (
|
|
137
|
-
kb.matches(data, "tui.select.cancel") ||
|
|
138
|
-
matchesKey(data, "q") ||
|
|
139
|
-
matchesKey(data, "shift+q") ||
|
|
140
|
-
matchesKey(data, "enter") ||
|
|
141
|
-
matchesKey(data, "return")
|
|
142
|
-
) {
|
|
143
|
-
props.done();
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
let next = offset;
|
|
147
|
-
if (matchesKey(data, "up") || matchesKey(data, "k")) {
|
|
148
|
-
next = Math.max(0, offset - 1);
|
|
149
|
-
} else if (matchesKey(data, "down") || matchesKey(data, "j")) {
|
|
150
|
-
next = Math.min(max, offset + 1);
|
|
151
|
-
} else if (matchesKey(data, "pageUp") || matchesKey(data, "b")) {
|
|
152
|
-
next = Math.max(0, offset - visible);
|
|
153
|
-
} else if (
|
|
154
|
-
matchesKey(data, "pageDown") ||
|
|
155
|
-
matchesKey(data, "f") ||
|
|
156
|
-
matchesKey(data, "space")
|
|
157
|
-
) {
|
|
158
|
-
next = Math.min(max, offset + visible);
|
|
159
|
-
} else if (matchesKey(data, "home") || matchesKey(data, "g")) {
|
|
160
|
-
next = 0;
|
|
161
|
-
} else if (matchesKey(data, "end") || matchesKey(data, "shift+g")) {
|
|
162
|
-
next = max;
|
|
163
|
-
} else {
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
|
-
if (next !== offset) {
|
|
167
|
-
offset = next;
|
|
168
|
-
tui.requestRender();
|
|
169
|
-
}
|
|
170
|
-
},
|
|
171
|
-
};
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// --------------------------------------------------------------------------- public API
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* Show a scrollable overlay with optional toggles.
|
|
178
|
-
*
|
|
179
|
-
* If you don't need toggles, pass a string for the body and we'll wrap it.
|
|
180
|
-
*/
|
|
181
|
-
export async function showOverlay(
|
|
182
|
-
ctx: ExtensionCommandContext,
|
|
183
|
-
title: string,
|
|
184
|
-
bodyOrOptions: string | Omit<OverlayOptions, "title">,
|
|
185
|
-
): Promise<void> {
|
|
186
|
-
const opts: OverlayOptions =
|
|
187
|
-
typeof bodyOrOptions === "string"
|
|
188
|
-
? { title, render: () => bodyOrOptions }
|
|
189
|
-
: { title, ...bodyOrOptions };
|
|
190
|
-
|
|
191
|
-
if (!ctx.hasUI) {
|
|
192
|
-
ctx.ui.notify(`${title}\n\n${opts.render({})}`, "info");
|
|
193
|
-
return;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// Probe widest line across all toggle combinations so the overlay doesn't
|
|
197
|
-
// resize as the user flips switches. With N toggles we check 2^N states,
|
|
198
|
-
// but in practice we only ever pass 0-2 toggles.
|
|
199
|
-
const probedWidth = probeMaxWidth(opts);
|
|
200
|
-
const desiredCols = Math.min(140, Math.max(72, probedWidth + 6));
|
|
201
|
-
|
|
202
|
-
await ctx.ui.custom<void>(
|
|
203
|
-
(tui, theme, _kb, done) =>
|
|
204
|
-
buildOverlay(tui as unknown as OverlayTui, {
|
|
205
|
-
...opts,
|
|
206
|
-
done,
|
|
207
|
-
theme: theme as unknown as Theme,
|
|
208
|
-
}),
|
|
209
|
-
{
|
|
210
|
-
overlay: true,
|
|
211
|
-
overlayOptions: {
|
|
212
|
-
width: desiredCols,
|
|
213
|
-
maxHeight: "80%",
|
|
214
|
-
},
|
|
215
|
-
},
|
|
216
|
-
);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function probeMaxWidth(opts: OverlayOptions): number {
|
|
220
|
-
const toggles = opts.toggles ?? [];
|
|
221
|
-
const combos = 1 << toggles.length;
|
|
222
|
-
let max = 0;
|
|
223
|
-
for (let i = 0; i < combos; i++) {
|
|
224
|
-
const state: Record<string, boolean> = {};
|
|
225
|
-
toggles.forEach((tg, j) => {
|
|
226
|
-
state[tg.key] = (i & (1 << j)) !== 0;
|
|
227
|
-
});
|
|
228
|
-
const body = opts.render(state);
|
|
229
|
-
for (const ln of body.split("\n")) {
|
|
230
|
-
const w = visibleWidth(ln);
|
|
231
|
-
if (w > max) max = w;
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
return max;
|
|
235
|
-
}
|
package/src/ui-picker/index.ts
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
// Public entry for the /cliproxy three-panel picker.
|
|
2
|
-
|
|
3
|
-
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
|
|
5
|
-
import type { ProxyConfig } from "../config.ts";
|
|
6
|
-
import type { Discovery } from "../fetch-models.ts";
|
|
7
|
-
import { buildPicker } from "./picker.ts";
|
|
8
|
-
import type { OverlayTui, Theme } from "./types.ts";
|
|
9
|
-
|
|
10
|
-
export async function runPicker(
|
|
11
|
-
ctx: ExtensionCommandContext,
|
|
12
|
-
cfg: ProxyConfig,
|
|
13
|
-
discovery: Discovery,
|
|
14
|
-
): Promise<ProxyConfig | null> {
|
|
15
|
-
if (!ctx.hasUI) {
|
|
16
|
-
ctx.ui.notify("interactive UI required for /cliproxy", "warning");
|
|
17
|
-
return null;
|
|
18
|
-
}
|
|
19
|
-
const draft: ProxyConfig = structuredClone(cfg);
|
|
20
|
-
return ctx.ui.custom<ProxyConfig | null>(
|
|
21
|
-
(tui, theme, _kb, done) =>
|
|
22
|
-
buildPicker(
|
|
23
|
-
tui as unknown as OverlayTui,
|
|
24
|
-
theme as unknown as Theme,
|
|
25
|
-
draft,
|
|
26
|
-
discovery,
|
|
27
|
-
ctx,
|
|
28
|
-
done,
|
|
29
|
-
),
|
|
30
|
-
{
|
|
31
|
-
overlay: true,
|
|
32
|
-
overlayOptions: { width: 170, maxHeight: "94%" },
|
|
33
|
-
},
|
|
34
|
-
);
|
|
35
|
-
}
|
|
@@ -1,432 +0,0 @@
|
|
|
1
|
-
// Render + input dispatch for the three-panel picker. Kept separate from
|
|
2
|
-
// picker.ts so each file stays under ~300 lines.
|
|
3
|
-
|
|
4
|
-
import type { Component } from "@earendil-works/pi-tui";
|
|
5
|
-
|
|
6
|
-
import type { ProxyConfig } from "../config.ts";
|
|
7
|
-
import { frame, type FrameFooter } from "../ui-frame.ts";
|
|
8
|
-
import { pad } from "./render-text.ts";
|
|
9
|
-
import {
|
|
10
|
-
renderEmpty,
|
|
11
|
-
renderModelRow,
|
|
12
|
-
renderNewProviderRow,
|
|
13
|
-
renderPanelHeader,
|
|
14
|
-
renderProviderRow,
|
|
15
|
-
renderSubheader,
|
|
16
|
-
} from "./rows.ts";
|
|
17
|
-
import type {
|
|
18
|
-
CatalogIndex,
|
|
19
|
-
OverlayTui,
|
|
20
|
-
PanelId,
|
|
21
|
-
ProviderEntry,
|
|
22
|
-
Theme,
|
|
23
|
-
} from "./types.ts";
|
|
24
|
-
import {
|
|
25
|
-
visibleWidth,
|
|
26
|
-
matchesKey,
|
|
27
|
-
getKeybindings,
|
|
28
|
-
} from "@earendil-works/pi-tui";
|
|
29
|
-
|
|
30
|
-
export interface AssembleArgs {
|
|
31
|
-
tui: OverlayTui;
|
|
32
|
-
theme: Theme;
|
|
33
|
-
cfg: ProxyConfig;
|
|
34
|
-
readOnly?: never;
|
|
35
|
-
opts?: never;
|
|
36
|
-
catalog: CatalogIndex;
|
|
37
|
-
getProviders: () => ProviderEntry[];
|
|
38
|
-
getFocus: () => PanelId;
|
|
39
|
-
setFocus: (f: PanelId) => void;
|
|
40
|
-
getProviderCursor: () => number;
|
|
41
|
-
getAssignedCursor: () => number;
|
|
42
|
-
getPoolCursor: () => number;
|
|
43
|
-
getProviderScroll: () => number;
|
|
44
|
-
setProviderScroll: (v: number) => void;
|
|
45
|
-
getAssignedScroll: () => number;
|
|
46
|
-
setAssignedScroll: (v: number) => void;
|
|
47
|
-
getPoolScroll: () => number;
|
|
48
|
-
setPoolScroll: (v: number) => void;
|
|
49
|
-
selectedProvider: () => ProviderEntry | null;
|
|
50
|
-
moveCursor: (delta: number) => void;
|
|
51
|
-
onTab: (back: boolean) => void;
|
|
52
|
-
onActivate: () => Promise<void>;
|
|
53
|
-
onDelete: () => Promise<void>;
|
|
54
|
-
ensureVisible: (cursor: number, scroll: number, visible: number) => number;
|
|
55
|
-
finish: (result: ProxyConfig | null) => void;
|
|
56
|
-
poolGrouper: (ids: string[]) => Array<{ label: string; ids: string[] }>;
|
|
57
|
-
assignedIdsFor: (p: ProviderEntry) => string[];
|
|
58
|
-
poolFor: (p: ProviderEntry) => string[];
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function assembleComponent(
|
|
62
|
-
a: AssembleArgs,
|
|
63
|
-
): Component & { handleInput(data: string): void } {
|
|
64
|
-
const render = (width: number): string[] => render3Panel(a, width);
|
|
65
|
-
return {
|
|
66
|
-
render(width: number): string[] {
|
|
67
|
-
return render(width);
|
|
68
|
-
},
|
|
69
|
-
invalidate(): void {
|
|
70
|
-
/* stateless */
|
|
71
|
-
},
|
|
72
|
-
handleInput(data: string): void {
|
|
73
|
-
handleInput(a, data);
|
|
74
|
-
},
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// --------------------------------------------------------------------------- render
|
|
79
|
-
|
|
80
|
-
function render3Panel(a: AssembleArgs, width: number): string[] {
|
|
81
|
-
const { theme, tui, cfg, catalog } = a;
|
|
82
|
-
const totalRows = tui.rows ?? 40;
|
|
83
|
-
const height = Math.max(16, Math.min(totalRows - 6, 38));
|
|
84
|
-
// frame() adds its own outer "│ … │" walls and accounts for `width` cells
|
|
85
|
-
// total. So our body lines must be exactly `width - 2` cells wide.
|
|
86
|
-
const inner = Math.max(70, width - 2);
|
|
87
|
-
const leftW = Math.min(56, Math.max(34, Math.floor(inner * 0.4)));
|
|
88
|
-
const rightW = inner - leftW - 1; // -1 for the vertical splitter
|
|
89
|
-
const upperH = Math.max(5, Math.floor((height - 2) / 2));
|
|
90
|
-
const lowerH = height - 2 - upperH;
|
|
91
|
-
|
|
92
|
-
const title = " /cliproxy ";
|
|
93
|
-
|
|
94
|
-
const prov = a.selectedProvider();
|
|
95
|
-
const focus = a.getFocus();
|
|
96
|
-
|
|
97
|
-
// Track each panel's cursor line + the run of context lines (subheaders /
|
|
98
|
-
// panel-headers / blank empties) immediately above it. The context block
|
|
99
|
-
// must stay visible together with the cursor so groups don't "jump" past
|
|
100
|
-
// invisible owned_by labels.
|
|
101
|
-
let leftCursorLine = 0;
|
|
102
|
-
let leftCursorTop = 0;
|
|
103
|
-
let assignedCursorLine = 0;
|
|
104
|
-
let assignedCursorTop = 0;
|
|
105
|
-
let poolCursorLine = 0;
|
|
106
|
-
let poolCursorTop = 0;
|
|
107
|
-
|
|
108
|
-
// LEFT
|
|
109
|
-
const leftLines: string[] = [];
|
|
110
|
-
leftLines.push(
|
|
111
|
-
panelHeaderBar(theme, " providers ", leftW, focus === "providers"),
|
|
112
|
-
);
|
|
113
|
-
const providers = a.getProviders();
|
|
114
|
-
for (let i = 0; i < providers.length; i++) {
|
|
115
|
-
const p = providers[i]!;
|
|
116
|
-
const isCursor = focus === "providers" && i === a.getProviderCursor();
|
|
117
|
-
if (isCursor) {
|
|
118
|
-
leftCursorLine = leftLines.length;
|
|
119
|
-
leftCursorTop = leftCursorLine;
|
|
120
|
-
}
|
|
121
|
-
leftLines.push(
|
|
122
|
-
renderProviderRow(p, cfg, {
|
|
123
|
-
theme,
|
|
124
|
-
width: leftW,
|
|
125
|
-
isCursor,
|
|
126
|
-
isFocused: focus === "providers",
|
|
127
|
-
}),
|
|
128
|
-
);
|
|
129
|
-
}
|
|
130
|
-
{
|
|
131
|
-
const isCursor =
|
|
132
|
-
focus === "providers" && a.getProviderCursor() === providers.length;
|
|
133
|
-
if (isCursor) {
|
|
134
|
-
leftCursorLine = leftLines.length;
|
|
135
|
-
leftCursorTop = leftCursorLine;
|
|
136
|
-
}
|
|
137
|
-
leftLines.push(
|
|
138
|
-
renderNewProviderRow(theme, isCursor, focus === "providers", leftW),
|
|
139
|
-
);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// RIGHT TOP (assigned)
|
|
143
|
-
const assignedLines: string[] = [];
|
|
144
|
-
const assignedHeader = prov
|
|
145
|
-
? `assigned to ${theme.bold(prov.name)} ${theme.fg("dim", `\u00b7 ${prov.api}`)}`
|
|
146
|
-
: theme.fg("dim", "no provider selected");
|
|
147
|
-
assignedLines.push(
|
|
148
|
-
panelHeaderBar(theme, " assigned ", rightW, focus === "assigned"),
|
|
149
|
-
);
|
|
150
|
-
assignedLines.push(
|
|
151
|
-
renderPanelHeader(theme, assignedHeader, rightW, focus === "assigned"),
|
|
152
|
-
);
|
|
153
|
-
if (prov) {
|
|
154
|
-
const ids = a.assignedIdsFor(prov);
|
|
155
|
-
if (ids.length === 0) {
|
|
156
|
-
assignedLines.push(renderEmpty(theme, "(nothing assigned yet)", rightW));
|
|
157
|
-
}
|
|
158
|
-
for (let i = 0; i < ids.length; i++) {
|
|
159
|
-
const id = ids[i]!;
|
|
160
|
-
const m = catalog.byId.get(id);
|
|
161
|
-
const isCursor = focus === "assigned" && i === a.getAssignedCursor();
|
|
162
|
-
if (isCursor) {
|
|
163
|
-
assignedCursorLine = assignedLines.length;
|
|
164
|
-
assignedCursorTop = assignedCursorLine;
|
|
165
|
-
}
|
|
166
|
-
const compatWarn = m ? !a.apiCompatible(prov.api, m.suggestedApi) : false;
|
|
167
|
-
assignedLines.push(
|
|
168
|
-
renderModelRow(id, m, "assigned", compatWarn, {
|
|
169
|
-
theme,
|
|
170
|
-
width: rightW,
|
|
171
|
-
isCursor,
|
|
172
|
-
isFocused: focus === "assigned",
|
|
173
|
-
}),
|
|
174
|
-
);
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// RIGHT BOTTOM (pool, grouped by ownedBy)
|
|
179
|
-
const poolLines: string[] = [];
|
|
180
|
-
poolLines.push(
|
|
181
|
-
panelHeaderBar(theme, " available pool ", rightW, focus === "pool"),
|
|
182
|
-
);
|
|
183
|
-
if (prov) {
|
|
184
|
-
const ids = a.poolFor(prov);
|
|
185
|
-
if (ids.length === 0) {
|
|
186
|
-
poolLines.push(renderEmpty(theme, "(no models available)", rightW));
|
|
187
|
-
} else {
|
|
188
|
-
let cursorIdx = 0;
|
|
189
|
-
for (const grp of a.poolGrouper(ids)) {
|
|
190
|
-
const groupHeaderLine = poolLines.length;
|
|
191
|
-
poolLines.push(renderSubheader(theme, grp.label, rightW));
|
|
192
|
-
for (let gi = 0; gi < grp.ids.length; gi++) {
|
|
193
|
-
const id = grp.ids[gi]!;
|
|
194
|
-
const m = catalog.byId.get(id);
|
|
195
|
-
const isCursor = focus === "pool" && cursorIdx === a.getPoolCursor();
|
|
196
|
-
if (isCursor) {
|
|
197
|
-
poolCursorLine = poolLines.length;
|
|
198
|
-
// First model of a group — pin the owned_by subheader too.
|
|
199
|
-
poolCursorTop = gi === 0 ? groupHeaderLine : poolCursorLine;
|
|
200
|
-
}
|
|
201
|
-
const compatWarn = m
|
|
202
|
-
? !a.apiCompatible(prov.api, m.suggestedApi)
|
|
203
|
-
: false;
|
|
204
|
-
poolLines.push(
|
|
205
|
-
renderModelRow(id, m, "pool", compatWarn, {
|
|
206
|
-
theme,
|
|
207
|
-
width: rightW,
|
|
208
|
-
isCursor,
|
|
209
|
-
isFocused: focus === "pool",
|
|
210
|
-
}),
|
|
211
|
-
);
|
|
212
|
-
cursorIdx++;
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// Scroll math. Each panel has a sticky panel-header at index 0; assigned
|
|
219
|
-
// has a second sticky line for "assigned to <prov>". We keep those locked
|
|
220
|
-
// at the top and only scroll the body rows below them. scrollFor() also
|
|
221
|
-
// clamps so we never push the cursor past the visible area or leave a gap
|
|
222
|
-
// when scrolling back to the top of a long list.
|
|
223
|
-
const poolUsableH = Math.max(1, lowerH - 1); // -1 for the horizontal divider
|
|
224
|
-
const leftScroll = scrollFor(
|
|
225
|
-
leftCursorTop,
|
|
226
|
-
leftCursorLine,
|
|
227
|
-
a.getProviderScroll(),
|
|
228
|
-
height - 2,
|
|
229
|
-
leftLines.length,
|
|
230
|
-
1,
|
|
231
|
-
);
|
|
232
|
-
const aScroll = scrollFor(
|
|
233
|
-
focus === "assigned" ? assignedCursorTop : 0,
|
|
234
|
-
focus === "assigned" ? assignedCursorLine : 0,
|
|
235
|
-
a.getAssignedScroll(),
|
|
236
|
-
upperH,
|
|
237
|
-
assignedLines.length,
|
|
238
|
-
2,
|
|
239
|
-
);
|
|
240
|
-
const pScroll = scrollFor(
|
|
241
|
-
focus === "pool" ? poolCursorTop : 0,
|
|
242
|
-
focus === "pool" ? poolCursorLine : 0,
|
|
243
|
-
a.getPoolScroll(),
|
|
244
|
-
poolUsableH,
|
|
245
|
-
poolLines.length,
|
|
246
|
-
1,
|
|
247
|
-
);
|
|
248
|
-
a.setProviderScroll(leftScroll);
|
|
249
|
-
a.setAssignedScroll(aScroll);
|
|
250
|
-
a.setPoolScroll(pScroll);
|
|
251
|
-
|
|
252
|
-
const leftSlice = takeSlice(leftLines, leftScroll, height - 2);
|
|
253
|
-
const aSlice = takeSlice(assignedLines, aScroll, upperH);
|
|
254
|
-
const pSlice = takeSlice(poolLines, pScroll, lowerH);
|
|
255
|
-
|
|
256
|
-
const vsplit = theme.fg("borderAccent", "\u2502");
|
|
257
|
-
const bodyLines: string[] = [];
|
|
258
|
-
for (let i = 0; i < height - 2; i++) {
|
|
259
|
-
const l = pad(leftSlice[i] ?? "", leftW);
|
|
260
|
-
const rRaw = i < upperH ? aSlice[i]! : pSlice[i - upperH]!;
|
|
261
|
-
const r = pad(rRaw, rightW);
|
|
262
|
-
bodyLines.push(`${l}${vsplit}${r}`);
|
|
263
|
-
}
|
|
264
|
-
const divIdx = upperH;
|
|
265
|
-
if (divIdx > 0 && divIdx < height - 2) {
|
|
266
|
-
const left = pad(leftSlice[divIdx] ?? "", leftW);
|
|
267
|
-
const horiz = theme.fg("borderAccent", "\u2500".repeat(rightW));
|
|
268
|
-
bodyLines[divIdx] = `${left}${vsplit}${horiz}`;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
return frame(theme, {
|
|
272
|
-
width,
|
|
273
|
-
title,
|
|
274
|
-
lines: bodyLines,
|
|
275
|
-
footer: footerFor(focus),
|
|
276
|
-
});
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
function takeSlice(lines: string[], scroll: number, count: number): string[] {
|
|
280
|
-
const s = lines.slice(scroll, scroll + count);
|
|
281
|
-
while (s.length < count) s.push("");
|
|
282
|
-
return s;
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
/**
|
|
286
|
-
* Scroll a list so the cursor line stays visible inside `visible` rows.
|
|
287
|
-
*
|
|
288
|
-
* Two cursor coords:
|
|
289
|
-
* - cursorTop: the highest line that must remain visible (usually a group
|
|
290
|
-
* subheader sitting right above the cursor on the first row of a group).
|
|
291
|
-
* - cursorBottom: the cursor row itself.
|
|
292
|
-
*
|
|
293
|
-
* Constraints:
|
|
294
|
-
* - Lines [0..stickyCount) are panel headers we want pinned at the top, so
|
|
295
|
-
* `scroll` is at most `total - visible` AND at most `cursorTop -
|
|
296
|
-
* stickyCount` when the cursor block is below them.
|
|
297
|
-
* - When the block fits entirely inside the visible window we keep `prev`
|
|
298
|
-
* to avoid jitter.
|
|
299
|
-
* - We never leave a gap of empty rows above (the "3 missing rows when
|
|
300
|
-
* scrolling back up" bug) or push the cursor below the panel (the
|
|
301
|
-
* "cursor goes 3 lines below" bug).
|
|
302
|
-
*/
|
|
303
|
-
function scrollFor(
|
|
304
|
-
cursorTop: number,
|
|
305
|
-
cursorBottom: number,
|
|
306
|
-
prev: number,
|
|
307
|
-
visible: number,
|
|
308
|
-
total: number,
|
|
309
|
-
stickyCount: number,
|
|
310
|
-
): number {
|
|
311
|
-
if (total <= visible) return 0;
|
|
312
|
-
const maxScroll = Math.max(0, total - visible);
|
|
313
|
-
if (cursorBottom < stickyCount) return 0;
|
|
314
|
-
let scroll = Math.max(0, Math.min(prev, maxScroll));
|
|
315
|
-
const topRow = scroll + stickyCount;
|
|
316
|
-
if (cursorTop < topRow) {
|
|
317
|
-
scroll = Math.max(0, cursorTop - stickyCount);
|
|
318
|
-
}
|
|
319
|
-
const bottomRow = scroll + visible - 1;
|
|
320
|
-
if (cursorBottom > bottomRow) {
|
|
321
|
-
scroll = Math.min(maxScroll, cursorBottom - (visible - 1));
|
|
322
|
-
}
|
|
323
|
-
return scroll;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
function panelHeaderBar(
|
|
327
|
-
theme: Theme,
|
|
328
|
-
label: string,
|
|
329
|
-
width: number,
|
|
330
|
-
isFocused: boolean,
|
|
331
|
-
): string {
|
|
332
|
-
const bar = isFocused
|
|
333
|
-
? theme.bold(theme.fg("accent", label))
|
|
334
|
-
: theme.fg("muted", label);
|
|
335
|
-
const fill = theme.fg(
|
|
336
|
-
"borderAccent",
|
|
337
|
-
"\u2500".repeat(Math.max(0, width - visibleWidth(label) - 1)),
|
|
338
|
-
);
|
|
339
|
-
return pad(`${bar}${fill}`, width);
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
function footerFor(focus: PanelId): FrameFooter {
|
|
343
|
-
const hint =
|
|
344
|
-
" tab \u2194 panel \u2191\u2193 nav \u21b5/space move d remove group s save q cancel ";
|
|
345
|
-
return { hint, badge: ` focus: ${focus} ` };
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
// --------------------------------------------------------------------------- input
|
|
349
|
-
|
|
350
|
-
function handleInput(a: AssembleArgs, data: string): void {
|
|
351
|
-
const { tui, finish, cfg } = a;
|
|
352
|
-
const kb = getKeybindings();
|
|
353
|
-
if (
|
|
354
|
-
kb.matches(data, "tui.select.cancel") ||
|
|
355
|
-
matchesKey(data, "q") ||
|
|
356
|
-
matchesKey(data, "shift+q") ||
|
|
357
|
-
matchesKey(data, "escape")
|
|
358
|
-
) {
|
|
359
|
-
finish(null);
|
|
360
|
-
return;
|
|
361
|
-
}
|
|
362
|
-
if (matchesKey(data, "tab")) {
|
|
363
|
-
a.onTab(false);
|
|
364
|
-
tui.requestRender();
|
|
365
|
-
return;
|
|
366
|
-
}
|
|
367
|
-
if (matchesKey(data, "shift+tab")) {
|
|
368
|
-
a.onTab(true);
|
|
369
|
-
tui.requestRender();
|
|
370
|
-
return;
|
|
371
|
-
}
|
|
372
|
-
if (matchesKey(data, "s")) {
|
|
373
|
-
finish(cfg);
|
|
374
|
-
return;
|
|
375
|
-
}
|
|
376
|
-
if (
|
|
377
|
-
matchesKey(data, "enter") ||
|
|
378
|
-
matchesKey(data, "return") ||
|
|
379
|
-
matchesKey(data, "space")
|
|
380
|
-
) {
|
|
381
|
-
void a.onActivate().then(() => tui.requestRender());
|
|
382
|
-
return;
|
|
383
|
-
}
|
|
384
|
-
if (
|
|
385
|
-
matchesKey(data, "d") ||
|
|
386
|
-
matchesKey(data, "delete") ||
|
|
387
|
-
matchesKey(data, "backspace")
|
|
388
|
-
) {
|
|
389
|
-
void a.onDelete();
|
|
390
|
-
return;
|
|
391
|
-
}
|
|
392
|
-
if (matchesKey(data, "up") || matchesKey(data, "k")) {
|
|
393
|
-
a.moveCursor(-1);
|
|
394
|
-
tui.requestRender();
|
|
395
|
-
return;
|
|
396
|
-
}
|
|
397
|
-
if (matchesKey(data, "down") || matchesKey(data, "j")) {
|
|
398
|
-
a.moveCursor(1);
|
|
399
|
-
tui.requestRender();
|
|
400
|
-
return;
|
|
401
|
-
}
|
|
402
|
-
if (matchesKey(data, "pageUp") || matchesKey(data, "b")) {
|
|
403
|
-
a.moveCursor(-8);
|
|
404
|
-
tui.requestRender();
|
|
405
|
-
return;
|
|
406
|
-
}
|
|
407
|
-
if (matchesKey(data, "pageDown") || matchesKey(data, "f")) {
|
|
408
|
-
a.moveCursor(8);
|
|
409
|
-
tui.requestRender();
|
|
410
|
-
return;
|
|
411
|
-
}
|
|
412
|
-
if (matchesKey(data, "left") || matchesKey(data, "h")) {
|
|
413
|
-
a.setFocus("providers");
|
|
414
|
-
tui.requestRender();
|
|
415
|
-
return;
|
|
416
|
-
}
|
|
417
|
-
if (matchesKey(data, "right") || matchesKey(data, "l")) {
|
|
418
|
-
const f = a.getFocus();
|
|
419
|
-
if (f === "providers") a.setFocus("assigned");
|
|
420
|
-
else if (f === "assigned") a.setFocus("pool");
|
|
421
|
-
tui.requestRender();
|
|
422
|
-
return;
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
// Provide a tiny apiCompatible binding so we can call it from render.
|
|
427
|
-
// The grouping/api functions are passed in directly via AssembleArgs.
|
|
428
|
-
declare module "./picker-component.ts" {
|
|
429
|
-
interface AssembleArgs {
|
|
430
|
-
apiCompatible: (provApi: string, modelApi: string) => boolean;
|
|
431
|
-
}
|
|
432
|
-
}
|