@oharness/server 0.7.1 → 0.9.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/dist/browser-pane.d.ts +48 -0
- package/dist/browser-pane.js +185 -0
- package/dist/browser-pane.js.map +1 -0
- package/dist/files.d.ts +40 -1
- package/dist/files.js +94 -3
- package/dist/files.js.map +1 -1
- package/dist/i18n.js +190 -0
- package/dist/i18n.js.map +1 -1
- package/dist/main.js +20 -0
- package/dist/main.js.map +1 -1
- package/dist/page/client/account.d.ts +8 -0
- package/dist/page/client/account.js +1895 -0
- package/dist/page/client/account.js.map +1 -0
- package/dist/page/client/artifacts.d.ts +7 -0
- package/dist/page/client/artifacts.js +767 -0
- package/dist/page/client/artifacts.js.map +1 -0
- package/dist/page/client/base.d.ts +8 -0
- package/dist/page/client/base.js +404 -0
- package/dist/page/client/base.js.map +1 -0
- package/dist/page/client/browser.d.ts +21 -0
- package/dist/page/client/browser.js +906 -0
- package/dist/page/client/browser.js.map +1 -0
- package/dist/page/client/composer.d.ts +8 -0
- package/dist/page/client/composer.js +1714 -0
- package/dist/page/client/composer.js.map +1 -0
- package/dist/page/client/dialogs.d.ts +7 -0
- package/dist/page/client/dialogs.js +724 -0
- package/dist/page/client/dialogs.js.map +1 -0
- package/dist/page/client/events.d.ts +7 -0
- package/dist/page/client/events.js +288 -0
- package/dist/page/client/events.js.map +1 -0
- package/dist/page/client/files.d.ts +7 -0
- package/dist/page/client/files.js +412 -0
- package/dist/page/client/files.js.map +1 -0
- package/dist/page/client/index.d.ts +16 -0
- package/dist/page/client/index.js +50 -0
- package/dist/page/client/index.js.map +1 -0
- package/dist/page/client/panels.d.ts +7 -0
- package/dist/page/client/panels.js +659 -0
- package/dist/page/client/panels.js.map +1 -0
- package/dist/page/client/pickers.d.ts +7 -0
- package/dist/page/client/pickers.js +624 -0
- package/dist/page/client/pickers.js.map +1 -0
- package/dist/page/client/routines.d.ts +6 -0
- package/dist/page/client/routines.js +928 -0
- package/dist/page/client/routines.js.map +1 -0
- package/dist/page/client/shell.d.ts +6 -0
- package/dist/page/client/shell.js +700 -0
- package/dist/page/client/shell.js.map +1 -0
- package/dist/page/client/state.d.ts +7 -0
- package/dist/page/client/state.js +1283 -0
- package/dist/page/client/state.js.map +1 -0
- package/dist/page/client/terminal.d.ts +7 -0
- package/dist/page/client/terminal.js +347 -0
- package/dist/page/client/terminal.js.map +1 -0
- package/dist/page/client/transcript.d.ts +8 -0
- package/dist/page/client/transcript.js +866 -0
- package/dist/page/client/transcript.js.map +1 -0
- package/dist/page/client/usage.d.ts +7 -0
- package/dist/page/client/usage.js +954 -0
- package/dist/page/client/usage.js.map +1 -0
- package/dist/page/style.d.ts +1 -0
- package/dist/page/style.js +3016 -0
- package/dist/page/style.js.map +1 -0
- package/dist/page.js +96 -13871
- package/dist/page.js.map +1 -1
- package/dist/server.d.ts +24 -0
- package/dist/server.js +492 -23
- package/dist/server.js.map +1 -1
- package/dist/terminal.d.ts +34 -0
- package/dist/terminal.js +211 -0
- package/dist/terminal.js.map +1 -0
- package/package.json +9 -8
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The menus that pick something: provider marks and logos, and the model,
|
|
3
|
+
* mode, effort and workspace pickers built on one shared picker.
|
|
4
|
+
*
|
|
5
|
+
* One part of the client script; see ./index.ts for how the parts join.
|
|
6
|
+
*/
|
|
7
|
+
export declare const PICKERS: string;
|
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The menus that pick something: provider marks and logos, and the model,
|
|
3
|
+
* mode, effort and workspace pickers built on one shared picker.
|
|
4
|
+
*
|
|
5
|
+
* One part of the client script; see ./index.ts for how the parts join.
|
|
6
|
+
*/
|
|
7
|
+
export const PICKERS = String.raw `
|
|
8
|
+
// --- pickers ---------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A dropdown built from ordinary elements.
|
|
12
|
+
*
|
|
13
|
+
* One open at a time, closes on outside click and on Escape, and reports the
|
|
14
|
+
* chosen value. It exists because a native select draws its popup and its
|
|
15
|
+
* focus ring with the operating system's chrome, which no stylesheet reaches.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* The mark for one model vendor, for the rows of the model menu.
|
|
19
|
+
*
|
|
20
|
+
* The same arrangement the connector list uses, for the same reason: the
|
|
21
|
+
* vendor's own icon when there is one, a lettered tile when there is not, and
|
|
22
|
+
* no third-party request from this page either way — the server fetches the
|
|
23
|
+
* icon once and serves it from this origin, so opening the menu does not tell
|
|
24
|
+
* two dozen vendors that this machine looked at their name.
|
|
25
|
+
*
|
|
26
|
+
* The resolution happens once per vendor rather than once per row. A vendor
|
|
27
|
+
* with four hundred models would otherwise decode the same image four hundred
|
|
28
|
+
* times and measure it four hundred times, on every repaint of the list.
|
|
29
|
+
*/
|
|
30
|
+
const providerMarks = new Map();
|
|
31
|
+
|
|
32
|
+
const providerIconUrl = (id) => q("/provider-icon?id=" + encodeURIComponent(id));
|
|
33
|
+
|
|
34
|
+
/** What is known about a vendor's icon, and who is waiting to hear. */
|
|
35
|
+
function providerIconState(id) {
|
|
36
|
+
let state = providerMarks.get(id);
|
|
37
|
+
if (state) return state;
|
|
38
|
+
state = { status: "pending", invert: false, filled: false, waiting: [] };
|
|
39
|
+
providerMarks.set(id, state);
|
|
40
|
+
|
|
41
|
+
const probe = new Image();
|
|
42
|
+
probe.addEventListener("load", () => {
|
|
43
|
+
state.status = "ok";
|
|
44
|
+
const profile = markProfile(probe);
|
|
45
|
+
state.invert = profile.invert;
|
|
46
|
+
state.filled = profile.filled;
|
|
47
|
+
const waiting = state.waiting.splice(0);
|
|
48
|
+
for (const swap of waiting) swap();
|
|
49
|
+
});
|
|
50
|
+
// A vendor with no icon is ordinary: local servers have no site to ask, and
|
|
51
|
+
// plenty of sites answer nothing usable. The tile is a complete answer.
|
|
52
|
+
probe.addEventListener("error", () => {
|
|
53
|
+
state.status = "none";
|
|
54
|
+
state.waiting.length = 0;
|
|
55
|
+
});
|
|
56
|
+
probe.src = providerIconUrl(id);
|
|
57
|
+
return state;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A stable hue per vendor, so a tile is the same colour on every repaint. */
|
|
61
|
+
function providerMono(id, label, extra) {
|
|
62
|
+
let hash = 0;
|
|
63
|
+
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) % 360;
|
|
64
|
+
const mono = el("span", "vmono" + (extra ? " " + extra : ""), (label || id || "?").trim().charAt(0));
|
|
65
|
+
mono.style.background = "hsl(" + hash + " 42% 42%)";
|
|
66
|
+
return mono;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function providerLogo(id, state, extra) {
|
|
70
|
+
const img = new Image();
|
|
71
|
+
// A mark that owns its whole box keeps it; one that is bare ink is inset,
|
|
72
|
+
// so a wide glyph with no margin of its own does not read as bigger and
|
|
73
|
+
// off-line next to a tile that fills the same square.
|
|
74
|
+
img.className =
|
|
75
|
+
"vmark" +
|
|
76
|
+
(state.filled ? " filled" : "") +
|
|
77
|
+
(state.invert ? " invert-dark" : "") +
|
|
78
|
+
(extra ? " " + extra : "");
|
|
79
|
+
img.alt = "";
|
|
80
|
+
img.src = providerIconUrl(id);
|
|
81
|
+
return img;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A vendor's mark, ready to put in a row.
|
|
86
|
+
*
|
|
87
|
+
* Returns something to draw now, not a promise: the menu repaints on every
|
|
88
|
+
* keystroke and cannot wait on the network. A tile goes in, and if the icon
|
|
89
|
+
* turns out to exist it takes the tile's place — but only if that tile is
|
|
90
|
+
* still on the page, since the row it was made for may already be gone.
|
|
91
|
+
*/
|
|
92
|
+
function providerMark(id, label, extra) {
|
|
93
|
+
// Ours is drawn from the same paths as the application icon rather than
|
|
94
|
+
// fetched from our own site: it is the one mark that has to be there
|
|
95
|
+
// offline, on the row that is being recommended.
|
|
96
|
+
if (id === "official") return svgIcon("oharness", "vglyph" + (extra ? " " + extra : ""));
|
|
97
|
+
|
|
98
|
+
const state = providerIconState(id);
|
|
99
|
+
if (state.status === "ok") return providerLogo(id, state, extra);
|
|
100
|
+
|
|
101
|
+
const mono = providerMono(id, label, extra);
|
|
102
|
+
if (state.status === "pending") {
|
|
103
|
+
state.waiting.push(() => {
|
|
104
|
+
if (mono.isConnected) mono.replaceWith(providerLogo(id, state, extra));
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return mono;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The model id, shortened to what the bar has room for: everything after the
|
|
112
|
+
* last slash.
|
|
113
|
+
*
|
|
114
|
+
* The rule used to be a length test — over 28 characters keep the tail, under
|
|
115
|
+
* it keep the whole thing — which is how "~deepseek/deepseek-v4-flash" reached
|
|
116
|
+
* the bar at 27 characters, naming its vendor twice and then being cut
|
|
117
|
+
* mid-word by the ellipsis anyway. Length was the wrong question: what to drop
|
|
118
|
+
* does not depend on how long the string happens to be.
|
|
119
|
+
*
|
|
120
|
+
* What to drop is the namespace, every time. The vendor's mark is drawn
|
|
121
|
+
* immediately to the left of this, so the prefix is a picture repeated in
|
|
122
|
+
* words — and an aggregator's id nests, which makes the prefix the longer half
|
|
123
|
+
* of a name whose distinguishing half is at the end. The whole id stays in the
|
|
124
|
+
* title attribute and in the menu.
|
|
125
|
+
*/
|
|
126
|
+
function shortModel(full) {
|
|
127
|
+
const cut = full.lastIndexOf("/");
|
|
128
|
+
return cut < 0 ? full : full.slice(cut + 1);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function makePicker(id, onPick, extra, opts = {}) {
|
|
132
|
+
const root = document.getElementById(id);
|
|
133
|
+
const button = root.querySelector(".picker-btn");
|
|
134
|
+
const label = root.querySelector(".picker-value");
|
|
135
|
+
const menu = root.querySelector(".menu");
|
|
136
|
+
|
|
137
|
+
// The menu owns a search box and a scrolling body. Built once here rather
|
|
138
|
+
// than in the markup so every picker gets the same behaviour.
|
|
139
|
+
const searchWrap = el("div", "menu-search");
|
|
140
|
+
const search = document.createElement("input");
|
|
141
|
+
search.type = "text";
|
|
142
|
+
search.placeholder = t("menu.search");
|
|
143
|
+
search.autocomplete = "off";
|
|
144
|
+
search.spellcheck = false;
|
|
145
|
+
searchWrap.appendChild(search);
|
|
146
|
+
const body = el("div", "menu-body");
|
|
147
|
+
// A fixed footer, not the end of the scrolling list: appended to the body it
|
|
148
|
+
// sat below four hundred models and could never be reached.
|
|
149
|
+
const footer = el("div", "menu-footer");
|
|
150
|
+
menu.replaceChildren(searchWrap, body, footer);
|
|
151
|
+
|
|
152
|
+
let options = [];
|
|
153
|
+
let value = null;
|
|
154
|
+
|
|
155
|
+
let query = "";
|
|
156
|
+
let cursor = 0;
|
|
157
|
+
/** Groups the user has folded away; survives repainting. */
|
|
158
|
+
const collapsed = new Set();
|
|
159
|
+
/**
|
|
160
|
+
* Groups folded while searching. Kept apart from the browsing folds: a
|
|
161
|
+
* search opens every vendor, because a match behind a closed header looks
|
|
162
|
+
* like no result — but a header that then refuses to close is a control
|
|
163
|
+
* that does nothing, and OpenRouter's twenty matches can bury the one vendor
|
|
164
|
+
* being looked for. Cleared when the query changes, so each search starts
|
|
165
|
+
* with everything it found on screen.
|
|
166
|
+
*/
|
|
167
|
+
const searchFolded = new Set();
|
|
168
|
+
const folded = (group) => (query ? searchFolded.has(group) : collapsed.has(group));
|
|
169
|
+
|
|
170
|
+
/** The options a keystroke can land on: what is actually on screen. */
|
|
171
|
+
const visible = () => matches().filter((option) => !option.group || !folded(option.group));
|
|
172
|
+
|
|
173
|
+
const close = () => {
|
|
174
|
+
menu.hidden = true;
|
|
175
|
+
root.classList.remove("open");
|
|
176
|
+
query = "";
|
|
177
|
+
searchFolded.clear();
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const matches = () => options.filter((option) => matchesQuery(option, query));
|
|
181
|
+
|
|
182
|
+
const paint = () => {
|
|
183
|
+
const list = matches();
|
|
184
|
+
|
|
185
|
+
const shown = list.filter((option) => !option.group || !folded(option.group));
|
|
186
|
+
if (cursor >= shown.length) cursor = Math.max(0, shown.length - 1);
|
|
187
|
+
|
|
188
|
+
// Only the body is rebuilt: replacing the menu itself would take the
|
|
189
|
+
// search field out from under the caret on the first keystroke.
|
|
190
|
+
body.replaceChildren();
|
|
191
|
+
|
|
192
|
+
const counts = new Map();
|
|
193
|
+
for (const option of list) {
|
|
194
|
+
if (option.group) counts.set(option.group, (counts.get(option.group) || 0) + 1);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let lastGroup = null;
|
|
198
|
+
let visibleIndex = 0;
|
|
199
|
+
for (const option of list) {
|
|
200
|
+
// A flat list of four hundred models across a dozen vendors is
|
|
201
|
+
// unreadable; the vendor is the first thing anyone narrows by.
|
|
202
|
+
if (option.group && option.group !== lastGroup) {
|
|
203
|
+
lastGroup = option.group;
|
|
204
|
+
const group = option.group;
|
|
205
|
+
const header = el("button", "menu-group" + (folded(group) ? "" : " open"));
|
|
206
|
+
header.type = "button";
|
|
207
|
+
header.appendChild(el("span", "chev"));
|
|
208
|
+
// The vendor's mark leads its header, so a folded list reads as a
|
|
209
|
+
// column of vendors rather than as a column of lowercase words.
|
|
210
|
+
const groupMark = api.groupMark && api.groupMark(group);
|
|
211
|
+
if (groupMark) header.appendChild(groupMark);
|
|
212
|
+
header.appendChild(el("span", "g-name", group));
|
|
213
|
+
// A vendor may be worth marking out — the official plan is the one
|
|
214
|
+
// this project stands behind, and a group header is where someone
|
|
215
|
+
// scanning the list actually looks.
|
|
216
|
+
const tag = api.groupTag && api.groupTag(group);
|
|
217
|
+
if (tag) header.appendChild(el("span", "g-tag", tag));
|
|
218
|
+
header.appendChild(el("span", "g-count", String(counts.get(group) || 0)));
|
|
219
|
+
header.addEventListener("click", (e) => {
|
|
220
|
+
e.stopPropagation();
|
|
221
|
+
const set = query ? searchFolded : collapsed;
|
|
222
|
+
if (set.has(group)) set.delete(group);
|
|
223
|
+
else set.add(group);
|
|
224
|
+
cursor = 0;
|
|
225
|
+
paint();
|
|
226
|
+
});
|
|
227
|
+
body.appendChild(header);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (option.group && folded(option.group)) continue;
|
|
231
|
+
const i = visibleIndex++;
|
|
232
|
+
const item = el(
|
|
233
|
+
"button",
|
|
234
|
+
"menu-item" + (option.value === value ? " on" : "") + (i === cursor ? " cursor" : ""),
|
|
235
|
+
);
|
|
236
|
+
item.type = "button";
|
|
237
|
+
item.appendChild(el("span", "tick", "✓"));
|
|
238
|
+
// Between the tick and the name: the row is "is this the one" then
|
|
239
|
+
// "whose is it" then "which model", which is the order it is read in.
|
|
240
|
+
const mark = option.mark && option.mark();
|
|
241
|
+
if (mark) item.appendChild(mark);
|
|
242
|
+
item.appendChild(el("span", "menu-label", option.label));
|
|
243
|
+
// Beside the name rather than out at the right margin where sub sits:
|
|
244
|
+
// this qualifies the model it is attached to, and a badge floated away
|
|
245
|
+
// from its name is a badge the eye has to pair up again on every row.
|
|
246
|
+
if (option.tag) item.appendChild(el("span", "m-tag", option.tag));
|
|
247
|
+
if (option.sub) item.appendChild(el("span", "sub", option.sub));
|
|
248
|
+
item.addEventListener("click", () => {
|
|
249
|
+
close();
|
|
250
|
+
if (option.value !== value) onPick(option.value);
|
|
251
|
+
});
|
|
252
|
+
body.appendChild(item);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (!list.length) body.appendChild(el("div", "menu-empty", t("menu.none")));
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const open = () => {
|
|
259
|
+
for (const other of document.querySelectorAll(".picker.open")) {
|
|
260
|
+
if (other !== root) {
|
|
261
|
+
other.classList.remove("open");
|
|
262
|
+
other.querySelector(".menu").hidden = true;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
query = "";
|
|
266
|
+
search.value = "";
|
|
267
|
+
|
|
268
|
+
// Everything folded except the vendor holding the current model: opening
|
|
269
|
+
// onto a dozen closed headers hides the one answer the user already has.
|
|
270
|
+
// A picker may name groups that always open as well — the model picker's
|
|
271
|
+
// own plan, which is where someone looking for a model should land first.
|
|
272
|
+
collapsed.clear();
|
|
273
|
+
const current = options.find((option) => option.value === value);
|
|
274
|
+
const alwaysOpen = new Set(opts.openGroups || []);
|
|
275
|
+
for (const option of options) {
|
|
276
|
+
if (!option.group || alwaysOpen.has(option.group)) continue;
|
|
277
|
+
if (option.group !== (current && current.group)) collapsed.add(option.group);
|
|
278
|
+
}
|
|
279
|
+
cursor = Math.max(0, visible().findIndex((option) => option.value === value));
|
|
280
|
+
// Searching costs a row of chrome and is pointless below a handful of
|
|
281
|
+
// entries, so the five-item mode picker does not get one.
|
|
282
|
+
searchWrap.hidden = options.length < 8;
|
|
283
|
+
footer.replaceChildren();
|
|
284
|
+
if (extra) footer.appendChild(extra());
|
|
285
|
+
footer.hidden = !extra;
|
|
286
|
+
paint();
|
|
287
|
+
menu.hidden = false;
|
|
288
|
+
root.classList.add("open");
|
|
289
|
+
// Opened onto the model in use rather than onto the top of a vendor with
|
|
290
|
+
// four hundred entries.
|
|
291
|
+
body.scrollTop = 0;
|
|
292
|
+
const on = body.querySelector(".menu-item.on");
|
|
293
|
+
if (on) on.scrollIntoView({ block: "nearest" });
|
|
294
|
+
if (!searchWrap.hidden) search.focus();
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
search.addEventListener("input", () => {
|
|
298
|
+
if (search.value !== query) searchFolded.clear();
|
|
299
|
+
query = search.value;
|
|
300
|
+
cursor = 0;
|
|
301
|
+
paint();
|
|
302
|
+
// The body keeps its scroll offset across a repaint. Searching from
|
|
303
|
+
// halfway down a long vendor therefore left the first results — often the
|
|
304
|
+
// vendor whose name was just typed — above the visible window, which reads
|
|
305
|
+
// as "that vendor has no models" when its two rows are simply out of view.
|
|
306
|
+
body.scrollTop = 0;
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
search.addEventListener("click", (e) => e.stopPropagation());
|
|
310
|
+
|
|
311
|
+
search.addEventListener("keydown", (e) => {
|
|
312
|
+
const list = visible();
|
|
313
|
+
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
314
|
+
e.preventDefault();
|
|
315
|
+
if (!list.length) return;
|
|
316
|
+
cursor = (cursor + (e.key === "ArrowDown" ? 1 : -1) + list.length) % list.length;
|
|
317
|
+
paint();
|
|
318
|
+
const active = body.querySelector(".cursor");
|
|
319
|
+
if (active) active.scrollIntoView({ block: "nearest" });
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (e.key === "Enter") {
|
|
323
|
+
e.preventDefault();
|
|
324
|
+
const picked = list[cursor];
|
|
325
|
+
close();
|
|
326
|
+
if (picked && picked.value !== value) onPick(picked.value);
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
button.addEventListener("click", (e) => {
|
|
331
|
+
e.stopPropagation();
|
|
332
|
+
menu.hidden ? open() : close();
|
|
333
|
+
});
|
|
334
|
+
document.addEventListener("click", close);
|
|
335
|
+
document.addEventListener("keydown", (e) => {
|
|
336
|
+
if (e.key === "Escape") close();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
const api = {
|
|
340
|
+
/** Optional: a badge for a group header, e.g. the recommended vendor. */
|
|
341
|
+
groupTag: null,
|
|
342
|
+
/** Optional: a mark for a group header, e.g. the vendor's own icon. */
|
|
343
|
+
groupMark: null,
|
|
344
|
+
set(list, current) {
|
|
345
|
+
options = list;
|
|
346
|
+
value = current;
|
|
347
|
+
const match = list.find((option) => option.value === current);
|
|
348
|
+
// The mark travels with the choice onto the bar. The label there is a
|
|
349
|
+
// truncated id — the tail of google/gemini-3.6-flash:batch says nothing
|
|
350
|
+
// about who is being paid for it, and that is the half of the answer
|
|
351
|
+
// somebody glances down to check.
|
|
352
|
+
const stale = button.querySelector(".picker-mark");
|
|
353
|
+
if (stale) stale.remove();
|
|
354
|
+
const mark = match && match.mark && match.mark("picker-mark");
|
|
355
|
+
if (mark) button.insertBefore(mark, label);
|
|
356
|
+
const full = match ? match.label : current || "—";
|
|
357
|
+
label.textContent = shortModel(full);
|
|
358
|
+
label.title = full;
|
|
359
|
+
},
|
|
360
|
+
get value() {
|
|
361
|
+
return value;
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
return api;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Drawn glyphs rather than vendor tiles: a mode is chrome, not a party being
|
|
368
|
+
// paid, so it gets the bare stroke every other menu icon has.
|
|
369
|
+
const modeMark = (icon) => (extra) => svgIcon(icon, "mode-mark" + (extra ? " " + extra : ""));
|
|
370
|
+
|
|
371
|
+
const MODES = [
|
|
372
|
+
{ value: "default", label: "Manual", sub: "ask first", mark: modeMark("modeAsk") },
|
|
373
|
+
{ value: "acceptEdits", label: "Accept edits", sub: "edits pass", mark: modeMark("modeEdits") },
|
|
374
|
+
{ value: "plan", label: "Plan", sub: "read-only", mark: modeMark("modePlan") },
|
|
375
|
+
{ value: "auto", label: "Auto", sub: "routine passes", mark: modeMark("modeAuto") },
|
|
376
|
+
{ value: "bypassPermissions", label: "Bypass", sub: "all pass", mark: modeMark("modeBypass") },
|
|
377
|
+
];
|
|
378
|
+
|
|
379
|
+
const modelPicker = makePicker(
|
|
380
|
+
"model-picker",
|
|
381
|
+
(id) => {
|
|
382
|
+
// A vendor row rather than a model: there is nothing to switch to until it
|
|
383
|
+
// has credentials or a discovery pass. Both are one action, so both happen
|
|
384
|
+
// here rather than in a panel the user then has to find the vendor in
|
|
385
|
+
// again — a key gets a dialog over the menu, and a vendor that already has
|
|
386
|
+
// credentials just gets asked for its list.
|
|
387
|
+
if (String(id).indexOf("setup:") === 0) {
|
|
388
|
+
const providerId = String(id).slice(6);
|
|
389
|
+
const provider = providers.find((p) => p.id === providerId);
|
|
390
|
+
if (provider && provider.configured) return void discoverNow(providerId);
|
|
391
|
+
return void openConnect(providerId);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// A model whose vendor has no credentials would 401 on the first turn,
|
|
395
|
+
// and the picker is where the choice was made — so the key is asked for
|
|
396
|
+
// here and the switch happens once it lands.
|
|
397
|
+
const owner = providers.find((p) => p.id === (modelOptions.find((o) => o.value === id) || {}).group);
|
|
398
|
+
if (owner && !owner.configured) return void openConnect(owner.id, id);
|
|
399
|
+
|
|
400
|
+
// The whole claim of the project is that this switch is cheap, so how
|
|
401
|
+
// often it is actually made — and to what — is the one number worth having.
|
|
402
|
+
track("model_switched", { model: id, via: "picker" });
|
|
403
|
+
void api("/model", { session: activeId, id }).then(applyState);
|
|
404
|
+
},
|
|
405
|
+
() => {
|
|
406
|
+
// The catalog ships providers, not model names — vendors rename faster
|
|
407
|
+
// than this package publishes. So the picker offers the way to go ask.
|
|
408
|
+
const link = el("button", "menu-more");
|
|
409
|
+
link.type = "button";
|
|
410
|
+
// Drawn at the same weight and size as every other icon; the Unicode gear
|
|
411
|
+
// arrived from whatever font had it and sat small next to the label.
|
|
412
|
+
link.appendChild(svgIcon("settings"));
|
|
413
|
+
link.appendChild(el("span", null, t("menu.manage")));
|
|
414
|
+
link.addEventListener("click", () => {
|
|
415
|
+
setNav("nav-models");
|
|
416
|
+
// Opened for the list itself, so nothing is singled out.
|
|
417
|
+
focusProvider = null;
|
|
418
|
+
void showModels();
|
|
419
|
+
});
|
|
420
|
+
return link;
|
|
421
|
+
},
|
|
422
|
+
{ openGroups: ["official"] },
|
|
423
|
+
);
|
|
424
|
+
const modePicker = makePicker("mode-picker", (mode) => void api("/mode", { session: activeId, mode }).then(applyState));
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Reasoning effort for the session on screen.
|
|
428
|
+
*
|
|
429
|
+
* Not built on makePicker: that draws a searchable list, and effort is one
|
|
430
|
+
* quantity on a scale. The stops are whatever the current model accepts, plus
|
|
431
|
+
* "off" where the model can be asked not to think, so a tier the model would
|
|
432
|
+
* clamp away is never offered in the first place.
|
|
433
|
+
*
|
|
434
|
+
* What the server reports is the tier the next request will carry. A pick is
|
|
435
|
+
* kept as picked on the session, so moving to a model without "max" shows the
|
|
436
|
+
* tier it lands on, and moving back shows "max" again.
|
|
437
|
+
*/
|
|
438
|
+
const effortPicker = (() => {
|
|
439
|
+
const root = document.getElementById("effort-picker");
|
|
440
|
+
const button = root.querySelector(".picker-btn");
|
|
441
|
+
const label = root.querySelector(".picker-value");
|
|
442
|
+
const menu = root.querySelector(".menu");
|
|
443
|
+
let stops = [];
|
|
444
|
+
let value = null;
|
|
445
|
+
|
|
446
|
+
const name = (stop) => t("effort." + stop);
|
|
447
|
+
const close = () => {
|
|
448
|
+
root.classList.remove("open");
|
|
449
|
+
menu.hidden = true;
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
const pick = (next) => {
|
|
453
|
+
if (!next || next === value) return;
|
|
454
|
+
value = next;
|
|
455
|
+
paint(true);
|
|
456
|
+
track("effort_changed", { effort: next });
|
|
457
|
+
void api("/effort", { session: activeId, effort: next }).then(applyState);
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
function paint(refocus) {
|
|
461
|
+
const index = Math.max(0, stops.indexOf(value));
|
|
462
|
+
label.textContent = value ? t("effort.short." + value) : "…";
|
|
463
|
+
button.title = t("effort.tooltip") + ": " + (value ? name(value) : "");
|
|
464
|
+
if (menu.hidden && !refocus) return;
|
|
465
|
+
|
|
466
|
+
menu.replaceChildren();
|
|
467
|
+
const head = el("div", "effort-head");
|
|
468
|
+
head.appendChild(el("span", "effort-title", t("effort.title")));
|
|
469
|
+
head.appendChild(el("span", "effort-current", value ? name(value) : ""));
|
|
470
|
+
menu.appendChild(head);
|
|
471
|
+
menu.appendChild(el("div", "effort-desc", value ? t("effort.desc." + value) : ""));
|
|
472
|
+
|
|
473
|
+
const ends = el("div", "effort-ends");
|
|
474
|
+
ends.appendChild(el("span", null, t("effort.faster")));
|
|
475
|
+
ends.appendChild(el("span", null, t("effort.smarter")));
|
|
476
|
+
menu.appendChild(ends);
|
|
477
|
+
|
|
478
|
+
const rail = el("div", "effort-rail");
|
|
479
|
+
rail.setAttribute("role", "radiogroup");
|
|
480
|
+
rail.setAttribute("aria-label", t("effort.tooltip"));
|
|
481
|
+
const fill = el("span", "effort-fill");
|
|
482
|
+
const share = stops.length > 1 ? index / (stops.length - 1) : 0;
|
|
483
|
+
// From the rail's left edge to the far side of the chosen stop.
|
|
484
|
+
fill.style.width = "calc((100% - var(--stop)) * " + share + " + var(--stop))";
|
|
485
|
+
rail.appendChild(fill);
|
|
486
|
+
|
|
487
|
+
const names = el("div", "effort-names");
|
|
488
|
+
stops.forEach((stop, i) => {
|
|
489
|
+
const on = i === index;
|
|
490
|
+
const dot = el("button", "effort-stop" + (on ? " on" : ""));
|
|
491
|
+
dot.type = "button";
|
|
492
|
+
dot.setAttribute("role", "radio");
|
|
493
|
+
dot.setAttribute("aria-checked", on ? "true" : "false");
|
|
494
|
+
dot.setAttribute("aria-label", name(stop));
|
|
495
|
+
dot.tabIndex = on ? 0 : -1;
|
|
496
|
+
dot.title = name(stop);
|
|
497
|
+
dot.appendChild(el("span", on ? "effort-knob" : "effort-dot"));
|
|
498
|
+
dot.addEventListener("click", () => pick(stop));
|
|
499
|
+
dot.addEventListener("keydown", (e) => {
|
|
500
|
+
const step = e.key === "ArrowLeft" || e.key === "ArrowDown" ? -1 : e.key === "ArrowRight" || e.key === "ArrowUp" ? 1 : 0;
|
|
501
|
+
if (!step) return;
|
|
502
|
+
e.preventDefault();
|
|
503
|
+
pick(stops[Math.min(stops.length - 1, Math.max(0, index + step))]);
|
|
504
|
+
});
|
|
505
|
+
rail.appendChild(dot);
|
|
506
|
+
names.appendChild(el("span", "effort-name" + (on ? " on" : ""), t("effort.short." + stop)));
|
|
507
|
+
});
|
|
508
|
+
menu.appendChild(rail);
|
|
509
|
+
menu.appendChild(names);
|
|
510
|
+
|
|
511
|
+
if (refocus) {
|
|
512
|
+
const active = rail.querySelector(".effort-stop.on");
|
|
513
|
+
if (active) active.focus();
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const open = () => {
|
|
518
|
+
for (const other of document.querySelectorAll(".picker.open")) {
|
|
519
|
+
if (other !== root) {
|
|
520
|
+
other.classList.remove("open");
|
|
521
|
+
other.querySelector(".menu").hidden = true;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
root.classList.add("open");
|
|
525
|
+
menu.hidden = false;
|
|
526
|
+
paint(true);
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
button.addEventListener("click", (e) => {
|
|
530
|
+
e.stopPropagation();
|
|
531
|
+
menu.hidden ? open() : close();
|
|
532
|
+
});
|
|
533
|
+
menu.addEventListener("click", (e) => e.stopPropagation());
|
|
534
|
+
document.addEventListener("click", close);
|
|
535
|
+
document.addEventListener("keydown", (e) => {
|
|
536
|
+
if (e.key === "Escape" && !menu.hidden) {
|
|
537
|
+
close();
|
|
538
|
+
button.focus();
|
|
539
|
+
}
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
return {
|
|
543
|
+
set(state) {
|
|
544
|
+
const levels = Array.isArray(state.effortLevels) ? state.effortLevels : [];
|
|
545
|
+
// A model with no tiers has nothing to set; hiding beats a control that
|
|
546
|
+
// does nothing.
|
|
547
|
+
root.hidden = levels.length === 0 || !state.effort;
|
|
548
|
+
if (root.hidden) {
|
|
549
|
+
close();
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
stops = (state.effortOff ? ["off"] : []).concat(levels);
|
|
553
|
+
value = state.effort;
|
|
554
|
+
paint(false);
|
|
555
|
+
},
|
|
556
|
+
};
|
|
557
|
+
})();
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Which tree the session about to start will run in.
|
|
561
|
+
*
|
|
562
|
+
* Offered on the composer rather than in a dialog on the way in: the answer is
|
|
563
|
+
* usually the project already open, and the one time it is not, it is a
|
|
564
|
+
* property of the task being typed. Every directory opened here is on the
|
|
565
|
+
* list, and the last row goes and finds one that is not.
|
|
566
|
+
*/
|
|
567
|
+
const workspacePicker = makePicker(
|
|
568
|
+
"workspace-picker",
|
|
569
|
+
(path) => void reroot(path),
|
|
570
|
+
() => {
|
|
571
|
+
const link = el("button", "menu-more");
|
|
572
|
+
link.type = "button";
|
|
573
|
+
link.appendChild(svgIcon("folder"));
|
|
574
|
+
link.appendChild(el("span", null, t("projects.other")));
|
|
575
|
+
link.addEventListener("click", () => void pickFolder((path) => reroot(path)));
|
|
576
|
+
return link;
|
|
577
|
+
},
|
|
578
|
+
);
|
|
579
|
+
|
|
580
|
+
const workspaceRow = document.getElementById("workspace-picker");
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Move an unstarted session to another tree.
|
|
584
|
+
*
|
|
585
|
+
* A transcript belongs to the directory it was recorded in, so this is not a
|
|
586
|
+
* session changing address: it is a new one in the right place, and the empty
|
|
587
|
+
* one it replaces is thrown away rather than filed in the sidebar for every
|
|
588
|
+
* change of mind.
|
|
589
|
+
*/
|
|
590
|
+
async function reroot(path) {
|
|
591
|
+
if (!path || path === currentCwd) return;
|
|
592
|
+
const leaving = activeId;
|
|
593
|
+
const record = panes.get(leaving);
|
|
594
|
+
const bare = record && (record.node.childElementCount === 0 || record.node.contains(emptyEl));
|
|
595
|
+
|
|
596
|
+
await switchTo({ action: "new", cwd: path });
|
|
597
|
+
if (bare && leaving && leaving !== activeId) {
|
|
598
|
+
const gone = panes.get(leaving);
|
|
599
|
+
if (gone) gone.node.remove();
|
|
600
|
+
panes.delete(leaving);
|
|
601
|
+
await api("/session", { action: "delete", id: leaving });
|
|
602
|
+
paintTabs();
|
|
603
|
+
}
|
|
604
|
+
await loadProjects();
|
|
605
|
+
await loadSessions();
|
|
606
|
+
input.focus();
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* A folder from this machine, by whatever means this machine has.
|
|
611
|
+
*
|
|
612
|
+
* The system dialog is what "choose a folder" means, and the browser cannot
|
|
613
|
+
* open one that reports a path. A host without a dialog — headless, remote, a
|
|
614
|
+
* stripped-down image — browses the server's own directories instead.
|
|
615
|
+
*/
|
|
616
|
+
async function pickFolder(onPick) {
|
|
617
|
+
const picked = await api("/pick", {}, 5 * 60_000);
|
|
618
|
+
if (picked && picked.path) return void onPick(picked.path);
|
|
619
|
+
if (picked && picked.cancelled) return;
|
|
620
|
+
addNotice(t("projects.pickFailed"));
|
|
621
|
+
openBrowser(undefined, { onPick });
|
|
622
|
+
}
|
|
623
|
+
`;
|
|
624
|
+
//# sourceMappingURL=pickers.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pickers.js","sourceRoot":"","sources":["../../../src/page/client/pickers.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,CAAC,MAAM,OAAO,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAwmBhC,CAAC"}
|