pi-cliproxyapi 0.1.1 → 0.2.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 -6
- package/index.ts +1 -1
- package/package.json +1 -1
- package/src/apply.ts +85 -10
- package/src/commands.ts +1 -28
- package/src/ui-frame.ts +118 -0
- package/src/ui-overlay.ts +18 -75
- package/src/ui-picker/catalog.ts +52 -0
- package/src/ui-picker/index.ts +35 -0
- package/src/ui-picker/mutate.ts +133 -0
- package/src/ui-picker/picker-component.ts +432 -0
- package/src/ui-picker/picker.ts +247 -0
- package/src/ui-picker/prompt-confirm.ts +71 -0
- package/src/ui-picker/prompt-name.ts +90 -0
- package/src/ui-picker/providers.ts +68 -0
- package/src/ui-picker/render-text.ts +39 -0
- package/src/ui-picker/rows.ts +151 -0
- package/src/ui-picker/types.ts +56 -0
- package/src/ui-setup.ts +20 -47
- package/src/ui-picker.ts +0 -842
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// State mutations + read helpers shared by the picker UI.
|
|
2
|
+
// Nothing in this module touches the rendering layer.
|
|
3
|
+
|
|
4
|
+
import type { Api } from "@earendil-works/pi-ai";
|
|
5
|
+
|
|
6
|
+
import type { CustomProviderModelConfig, ProxyConfig } from "../config.ts";
|
|
7
|
+
import type { CatalogIndex, ModelEntry, ProviderEntry } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
/** model id \u2192 custom-provider slug that has claimed it (single owner). */
|
|
10
|
+
export function claimedBy(cfg: ProxyConfig): Map<string, string> {
|
|
11
|
+
const m = new Map<string, string>();
|
|
12
|
+
for (const [slug, p] of Object.entries(cfg.customProviders)) {
|
|
13
|
+
for (const mm of p.models) m.set(mm.id, slug);
|
|
14
|
+
}
|
|
15
|
+
return m;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function assignedIdsFor(
|
|
19
|
+
cfg: ProxyConfig,
|
|
20
|
+
prov: ProviderEntry,
|
|
21
|
+
): string[] {
|
|
22
|
+
if (prov.kind === "builtin") {
|
|
23
|
+
return [...(cfg.builtinProviders[prov.name]?.models ?? [])];
|
|
24
|
+
}
|
|
25
|
+
return cfg.customProviders[prov.name]?.models.map((m) => m.id) ?? [];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function poolFor(
|
|
29
|
+
cfg: ProxyConfig,
|
|
30
|
+
prov: ProviderEntry,
|
|
31
|
+
catalog: CatalogIndex,
|
|
32
|
+
): string[] {
|
|
33
|
+
if (prov.kind === "builtin") {
|
|
34
|
+
const ids = catalog.builtinModelIds.get(prov.name) ?? [];
|
|
35
|
+
const assigned = new Set(assignedIdsFor(cfg, prov));
|
|
36
|
+
return ids.filter((id) => !assigned.has(id));
|
|
37
|
+
}
|
|
38
|
+
const claim = claimedBy(cfg);
|
|
39
|
+
const assigned = new Set(assignedIdsFor(cfg, prov));
|
|
40
|
+
return catalog.customPoolIds.filter((id) => {
|
|
41
|
+
if (assigned.has(id)) return false;
|
|
42
|
+
const owner = claim.get(id);
|
|
43
|
+
return owner === undefined || owner === prov.name;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function toEntry(m: ModelEntry): CustomProviderModelConfig {
|
|
48
|
+
return {
|
|
49
|
+
id: m.id,
|
|
50
|
+
name: m.name,
|
|
51
|
+
contextWindow: m.contextWindow,
|
|
52
|
+
maxTokens: m.maxTokens,
|
|
53
|
+
reasoning: m.reasoning,
|
|
54
|
+
cost: m.cost,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function attachModel(
|
|
59
|
+
cfg: ProxyConfig,
|
|
60
|
+
prov: ProviderEntry,
|
|
61
|
+
model: ModelEntry,
|
|
62
|
+
): void {
|
|
63
|
+
if (prov.kind === "builtin") {
|
|
64
|
+
const cur = cfg.builtinProviders[prov.name] ?? {
|
|
65
|
+
enabled: true,
|
|
66
|
+
models: [],
|
|
67
|
+
};
|
|
68
|
+
const set = new Set(cur.models);
|
|
69
|
+
set.add(model.id);
|
|
70
|
+
cfg.builtinProviders[prov.name] = {
|
|
71
|
+
enabled: true,
|
|
72
|
+
apiOverride: cur.apiOverride ?? null,
|
|
73
|
+
models: Array.from(set).sort(),
|
|
74
|
+
};
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// custom \u2014 exclusive: remove from any other custom group first.
|
|
78
|
+
for (const [slug, p] of Object.entries(cfg.customProviders)) {
|
|
79
|
+
if (slug === prov.name) continue;
|
|
80
|
+
const i = p.models.findIndex((mm) => mm.id === model.id);
|
|
81
|
+
if (i >= 0) p.models.splice(i, 1);
|
|
82
|
+
}
|
|
83
|
+
const cur = cfg.customProviders[prov.name] ?? { api: prov.api, models: [] };
|
|
84
|
+
if (!cur.models.some((mm) => mm.id === model.id)) {
|
|
85
|
+
cur.models.push(toEntry(model));
|
|
86
|
+
}
|
|
87
|
+
cfg.customProviders[prov.name] = cur;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function detachModel(
|
|
91
|
+
cfg: ProxyConfig,
|
|
92
|
+
prov: ProviderEntry,
|
|
93
|
+
modelId: string,
|
|
94
|
+
): void {
|
|
95
|
+
if (prov.kind === "builtin") {
|
|
96
|
+
const cur = cfg.builtinProviders[prov.name];
|
|
97
|
+
if (!cur) return;
|
|
98
|
+
cur.models = cur.models.filter((id) => id !== modelId);
|
|
99
|
+
cur.enabled = cur.models.length > 0;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const cur = cfg.customProviders[prov.name];
|
|
103
|
+
if (!cur) return;
|
|
104
|
+
cur.models = cur.models.filter((mm) => mm.id !== modelId);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function apiCompatible(provApi: Api, modelApi: Api): boolean {
|
|
108
|
+
if (provApi === modelApi) return true;
|
|
109
|
+
// openai-completions and openai-responses are siblings \u2014 most models work
|
|
110
|
+
// with both, so don't warn when they differ.
|
|
111
|
+
const openaiFamily: Api[] = ["openai-completions", "openai-responses"];
|
|
112
|
+
if (openaiFamily.includes(provApi) && openaiFamily.includes(modelApi))
|
|
113
|
+
return true;
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Group pool model ids by `ownedBy` (fallback: `origin`, then "misc"). */
|
|
118
|
+
export function groupPoolByOwnedBy(
|
|
119
|
+
ids: string[],
|
|
120
|
+
catalog: CatalogIndex,
|
|
121
|
+
): Array<{ label: string; ids: string[] }> {
|
|
122
|
+
const groups = new Map<string, string[]>();
|
|
123
|
+
for (const id of ids) {
|
|
124
|
+
const m = catalog.byId.get(id);
|
|
125
|
+
const key = m?.ownedBy || m?.origin || "misc";
|
|
126
|
+
const arr = groups.get(key) ?? [];
|
|
127
|
+
arr.push(id);
|
|
128
|
+
groups.set(key, arr);
|
|
129
|
+
}
|
|
130
|
+
return Array.from(groups.entries())
|
|
131
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
132
|
+
.map(([label, arr]) => ({ label, ids: arr }));
|
|
133
|
+
}
|
|
@@ -0,0 +1,432 @@
|
|
|
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
|
+
}
|