@oharness/server 0.2.1 → 0.4.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/i18n.js +40 -10
- package/dist/i18n.js.map +1 -1
- package/dist/icons.d.ts +25 -0
- package/dist/icons.js +143 -4
- package/dist/icons.js.map +1 -1
- package/dist/page.js +983 -48
- package/dist/page.js.map +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.js +59 -4
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
package/dist/page.js
CHANGED
|
@@ -26,6 +26,11 @@ const STYLE = String.raw `
|
|
|
26
26
|
--add:#065f46; --add-bg:#ecfdf5; --del:#991b1b; --del-bg:#fef2f2;
|
|
27
27
|
--hover:#00000008; --sel:#00000012; --focus:#111827; --ring:#1118271a;
|
|
28
28
|
--field:#00000006; --field-focus:#0000000d; --track:#0000001f;
|
|
29
|
+
/* What separates the mention chip from the field behind it. A shadow on
|
|
30
|
+
paper; on the dark theme a shadow falls on something already black, so it
|
|
31
|
+
is the faintest of light edges instead. Neither is a border: a hard ring
|
|
32
|
+
made each mention read as a small input field inside a sentence. */
|
|
33
|
+
--mention-lift:0 1px 2px #0000000f, 0 0 0 1px #0000000a;
|
|
29
34
|
--kw:#7c3aed; --str:#047857; --com:#94a3b8; --num:#b45309;
|
|
30
35
|
}
|
|
31
36
|
/* The dark palette, reached two ways: the system asked for it and nothing
|
|
@@ -40,6 +45,7 @@ const STYLE = String.raw `
|
|
|
40
45
|
--add:#6ee7b7; --add-bg:#052e22; --del:#fca5a5; --del-bg:#3b1214;
|
|
41
46
|
--hover:#ffffff0a; --sel:#ffffff1a; --focus:#6b7280; --ring:#ffffff14;
|
|
42
47
|
--field:#ffffff08; --field-focus:#ffffff12; --track:#ffffff2e;
|
|
48
|
+
--mention-lift:0 0 0 1px #ffffff1f, 0 1px 3px #00000073;
|
|
43
49
|
--kw:#c4b5fd; --str:#6ee7b7; --com:#64748b; --num:#fcd34d;
|
|
44
50
|
}
|
|
45
51
|
}
|
|
@@ -323,6 +329,20 @@ main{flex:1;display:flex;flex-direction:column;min-width:0}
|
|
|
323
329
|
padding:0 10px;font:inherit;font-size:12.5px;
|
|
324
330
|
}
|
|
325
331
|
.menu-search input:focus{outline:none;background:var(--field-focus)}
|
|
332
|
+
/* Eighty-one skills with no way to narrow them is a list nobody reads to the
|
|
333
|
+
end. Matches the name and the description both, because the name is terse
|
|
334
|
+
and chosen by whoever wrote the skill. */
|
|
335
|
+
/* Quiet: it is a provenance note on a row whose subject is the name. */
|
|
336
|
+
.mk-tag{
|
|
337
|
+
margin-left:7px;padding:1px 5px;border-radius:4px;vertical-align:1px;
|
|
338
|
+
background:var(--sel);color:var(--dim);font-size:10px;font-weight:500;
|
|
339
|
+
letter-spacing:.02em;text-transform:uppercase;
|
|
340
|
+
}
|
|
341
|
+
.mk-search{
|
|
342
|
+
width:100%;height:32px;margin:10px 0 2px;border:0;border-radius:8px;
|
|
343
|
+
background:var(--field);color:var(--fg);padding:0 11px;font:inherit;font-size:13px;
|
|
344
|
+
}
|
|
345
|
+
.mk-search:focus{outline:none;background:var(--field-focus)}
|
|
326
346
|
.menu-body{overflow-y:auto;max-height:min(46vh,340px)}
|
|
327
347
|
.menu-footer{padding:5px 3px 3px;box-shadow:0 -1px 0 var(--line)}
|
|
328
348
|
.menu-item.cursor{background:var(--hover)}
|
|
@@ -339,6 +359,77 @@ main{flex:1;display:flex;flex-direction:column;min-width:0}
|
|
|
339
359
|
.menu-item.on .tick{opacity:1}
|
|
340
360
|
.menu-item .menu-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
|
|
341
361
|
.menu-item .sub{margin-left:auto;font-size:11px;color:var(--dim);flex:0 0 auto}
|
|
362
|
+
/* The vendor's mark, on every row of the model menu and on the bar itself.
|
|
363
|
+
*
|
|
364
|
+
* A picker holding four hundred models across two dozen vendors is read by
|
|
365
|
+
* shape before it is read by name: half the ids in it are the vendor's name
|
|
366
|
+
* repeated, and the rest are strings like gemini-3.6-flash:batch that say
|
|
367
|
+
* nothing about who answers them. The mark is what makes the list scannable,
|
|
368
|
+
* and on the composer it is what tells someone, without reading, that the
|
|
369
|
+
* session is pointed at the official plan rather than at a key of their own.
|
|
370
|
+
*
|
|
371
|
+
* Same two shapes as the connector list: the vendor's own icon when the
|
|
372
|
+
* server has one, and a lettered tile when it does not. Sized to the row —
|
|
373
|
+
* the type is 13px, so the mark is 15px and not the 28px tile a card gets. */
|
|
374
|
+
/* One block per vendor, whatever the vendor ships.
|
|
375
|
+
*
|
|
376
|
+
* A column of these is scanned by its left edge, and the edge has to be the
|
|
377
|
+
* same one every time. Left alone, it is not: a vendor shipping a tile fills
|
|
378
|
+
* its square, a vendor shipping bare ink covers half of it, and one shipping
|
|
379
|
+
* a thin outline covers less than that — the eye finds no common edge and
|
|
380
|
+
* reads the lighter rows as hanging off the line, even where the boxes are
|
|
381
|
+
* centred on the text to the pixel.
|
|
382
|
+
*
|
|
383
|
+
* So a mark that did not bring a background is given one: the same faint
|
|
384
|
+
* fill, the same radius, the same inset. A mark that did bring one keeps it
|
|
385
|
+
* and fills the box, because drawing a second shape around a shape is the
|
|
386
|
+
* thing this is trying to avoid. Either way the block is identical and the
|
|
387
|
+
* ink inside it is the vendor's business. */
|
|
388
|
+
.vmark{width:15px;height:15px;flex:0 0 auto;border-radius:4px;object-fit:contain;
|
|
389
|
+
padding:1.5px;box-sizing:border-box;background:var(--sel)}
|
|
390
|
+
.vmark.filled{padding:0;background:none}
|
|
391
|
+
/* The fallback, and the reason a missing icon is not a broken row: a lettered
|
|
392
|
+
tile in a colour of its own still tells two vendors apart at a glance. */
|
|
393
|
+
.vmono{
|
|
394
|
+
width:15px;height:15px;flex:0 0 auto;border-radius:4px;box-sizing:border-box;
|
|
395
|
+
display:grid;place-items:center;color:#fff;
|
|
396
|
+
font-size:9px;font-weight:600;letter-spacing:0;text-transform:uppercase;
|
|
397
|
+
}
|
|
398
|
+
/* Our own glyph is drawn rather than fetched: the official plan is the one
|
|
399
|
+
row that must have a mark on a machine that has never been online. */
|
|
400
|
+
/* Typed as svg.vglyph so it outranks the .ico-sm every drawn icon carries:
|
|
401
|
+
.6 is right for a chrome glyph and too faint for a vendor's mark. Ours is
|
|
402
|
+
bare strokes, so it takes the same block as any other bare mark. */
|
|
403
|
+
svg.vglyph{width:15px;height:15px;opacity:1;
|
|
404
|
+
padding:1.5px;box-sizing:border-box;border-radius:4px;background:var(--sel)}
|
|
405
|
+
/* Black-on-transparent is what a good half of these vendors ship, and in the
|
|
406
|
+
dark theme that is a black square on a black row. Inverted only where the
|
|
407
|
+
bytes were measured to be exactly that. */
|
|
408
|
+
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]) .vmark.invert-dark{filter:invert(1)}}
|
|
409
|
+
:root[data-theme="dark"] .vmark.invert-dark{filter:invert(1)}
|
|
410
|
+
/* Smaller in the group header, which is 10.5px uppercase, and given the gap
|
|
411
|
+
the composer bar's 2px does not provide. */
|
|
412
|
+
.menu-group .vmark,.menu-group .vmono,.menu-group .vglyph{width:13px;height:13px;font-size:8px}
|
|
413
|
+
.picker-btn .vmark,.picker-btn .vmono,.picker-btn .vglyph{width:14px;height:14px;margin-right:5px}
|
|
414
|
+
/* On a card the row is a title over a line of description, so the mark is
|
|
415
|
+
sized against the block rather than against the type, and sits on the
|
|
416
|
+
title's line instead of floating in the middle of the two. */
|
|
417
|
+
/* Centred on the naming block, not on the first line of it. A 20px tile
|
|
418
|
+
* beside a title over a description is read as the row's own mark, and a
|
|
419
|
+
* mark that sits on the title alone leaves the row bottom-heavy — the same
|
|
420
|
+
* complaint as the margin above, one level in. */
|
|
421
|
+
details.provider>summary .vmark,details.provider>summary .vmono,details.provider>summary .vglyph,
|
|
422
|
+
.pp-item .vmark,.pp-item .vmono,.pp-item .vglyph{
|
|
423
|
+
width:20px;height:20px;border-radius:5px;font-size:11px;
|
|
424
|
+
}
|
|
425
|
+
details.provider>summary .vmark,.pp-item .vmark,
|
|
426
|
+
details.provider>summary .vglyph,.pp-item .vglyph{padding:2.5px}
|
|
427
|
+
details.provider>summary .vmark.filled,.pp-item .vmark.filled{padding:0}
|
|
428
|
+
.menu-head{
|
|
429
|
+
padding:8px 10px 4px;font-size:11px;font-weight:600;letter-spacing:.04em;
|
|
430
|
+
text-transform:uppercase;color:var(--dim);
|
|
431
|
+
}
|
|
432
|
+
.menu-head:first-child{padding-top:4px}
|
|
342
433
|
.menu-group{
|
|
343
434
|
display:flex;align-items:center;gap:7px;width:100%;border:0;background:transparent;
|
|
344
435
|
font:inherit;font-size:10.5px;color:var(--dim);padding:7px 8px 5px;cursor:pointer;
|
|
@@ -864,8 +955,17 @@ details.setting>summary:focus-visible{outline:2px solid var(--accent);outline-of
|
|
|
864
955
|
.section{background:var(--panel);border-radius:12px;overflow:hidden}
|
|
865
956
|
.section details.provider + details.provider{box-shadow:0 -1px 0 var(--line)}
|
|
866
957
|
details.provider{padding:0}
|
|
867
|
-
|
|
868
|
-
|
|
958
|
+
/* Padding on all four sides and no margin, because the row is measured
|
|
959
|
+
* between the dividers and not around the box that draws the hover.
|
|
960
|
+
*
|
|
961
|
+
* The .panel .row-head rule carries a 14px bottom margin for the single
|
|
962
|
+
* heading row it was written for, and a provider card's summary is a
|
|
963
|
+
* .row-head too. It
|
|
964
|
+
* landed outside the summary and inside the divider, so every card had 12px
|
|
965
|
+
* of air above its content and 26px below — the whole row reading as pinned
|
|
966
|
+
* to its top edge, which is what it was. */
|
|
967
|
+
.panel details.provider>summary,details.provider>summary{
|
|
968
|
+
padding:14px;cursor:pointer;list-style:none;margin:0;gap:10px;
|
|
869
969
|
}
|
|
870
970
|
details.provider>summary::-webkit-details-marker{display:none}
|
|
871
971
|
.naming{flex:1;min-width:0}
|
|
@@ -1054,9 +1154,16 @@ button.pw-plan:disabled{opacity:.4;cursor:default}
|
|
|
1054
1154
|
wrong for a list that wants two columns. */
|
|
1055
1155
|
#market{width:min(860px,94vw);max-width:min(860px,94vw);max-height:76vh}
|
|
1056
1156
|
#market[open]{display:flex;flex-direction:column}
|
|
1057
|
-
.mk-head{padding:18px 20px 10px}
|
|
1058
|
-
#mk-list{overflow-y:auto;padding:2px 12px
|
|
1059
|
-
.
|
|
1157
|
+
.mk-head{padding:18px 20px 10px;position:relative}
|
|
1158
|
+
#mk-list{overflow-y:auto;padding:2px 12px 14px;flex:1}
|
|
1159
|
+
/* Where every other window puts it. A "Done" under the list meant scrolling
|
|
1160
|
+
past eighty rows to leave, for a dialog that changes nothing on the way out. */
|
|
1161
|
+
.mk-close{
|
|
1162
|
+
position:absolute;top:14px;right:14px;width:28px;height:28px;border-radius:8px;
|
|
1163
|
+
border:0;background:transparent;color:var(--dim);display:grid;place-items:center;cursor:pointer;
|
|
1164
|
+
}
|
|
1165
|
+
.mk-close:hover{background:var(--hover);color:var(--fg)}
|
|
1166
|
+
.mk-close .ico-sm{width:14px;height:14px;opacity:1}
|
|
1060
1167
|
.mk-group{
|
|
1061
1168
|
font-size:10.5px;color:var(--dim);letter-spacing:.04em;text-transform:uppercase;
|
|
1062
1169
|
padding:12px 8px 6px;
|
|
@@ -1440,8 +1547,66 @@ textarea{
|
|
|
1440
1547
|
}
|
|
1441
1548
|
textarea:focus{outline:none}
|
|
1442
1549
|
textarea::placeholder{color:var(--dim)}
|
|
1443
|
-
|
|
1444
|
-
|
|
1550
|
+
.input-wrap{position:relative}
|
|
1551
|
+
/* Block, not the inline-block a textarea is by default: inline leaves room
|
|
1552
|
+
under the baseline for a descender, so the wrapper ends up a few pixels
|
|
1553
|
+
taller than the field inside it and the two scroll by different amounts. */
|
|
1554
|
+
/* Above the backdrop, and that needs saying: the mirror is absolutely
|
|
1555
|
+
positioned, which puts it in front of a static sibling however the two are
|
|
1556
|
+
ordered in the markup. Without this the chip paints over the words it is
|
|
1557
|
+
drawn for. */
|
|
1558
|
+
.input-wrap textarea{display:block;position:relative;z-index:1}
|
|
1559
|
+
/* The field draws the caret and the selection; the mirror draws the words.
|
|
1560
|
+
With the glyphs in the mirror a mention can carry something a textarea
|
|
1561
|
+
never could — an icon in the slot the @ sign would have taken — because
|
|
1562
|
+
the field only has to agree on where each glyph lands, not on what is
|
|
1563
|
+
drawn there. The selection is translucent so the words under it stay
|
|
1564
|
+
readable; an opaque highlight would sit on top of the mirror. */
|
|
1565
|
+
.input-wrap textarea{color:transparent;caret-color:var(--fg)}
|
|
1566
|
+
.input-wrap textarea::selection{background:#3b82f640}
|
|
1567
|
+
/* Every metric that decides where a glyph lands has to match the field: font,
|
|
1568
|
+
line-height, wrapping, and the padding, which is zero on this textarea.
|
|
1569
|
+
Six pixels wider on each side with the text pushed back in by six: the
|
|
1570
|
+
glyphs land where the textarea puts them, and the chip's background — drawn
|
|
1571
|
+
outside its own advance width — has somewhere to go instead of being cut off
|
|
1572
|
+
at the start of a line. */
|
|
1573
|
+
#input-mirror{
|
|
1574
|
+
position:absolute;top:0;bottom:0;left:-6px;right:-6px;padding:0 6px;border:0;
|
|
1575
|
+
font:inherit;line-height:1.6;white-space:pre-wrap;overflow-wrap:break-word;
|
|
1576
|
+
color:var(--fg);pointer-events:none;overflow:hidden;
|
|
1577
|
+
}
|
|
1578
|
+
/**
|
|
1579
|
+
* The floating chip's surface, inline. No remove button — Backspace takes the
|
|
1580
|
+
* whole mention instead — and the icon lives in the slot the @ sign vacated.
|
|
1581
|
+
*
|
|
1582
|
+
* White on the field, dark on the dark field — the same two tokens the chip
|
|
1583
|
+
* above the box uses. Vertical padding does not enter the inline advance and
|
|
1584
|
+
* the line-height leaves room for it. Horizontal padding is given back as
|
|
1585
|
+
* negative margin so no glyph moves: the wash reaches into the whitespace on
|
|
1586
|
+
* either side of the mention. A space is four pixels wide, and the menu
|
|
1587
|
+
* inserts two of them on each side (see roomBefore), which is what allows
|
|
1588
|
+
* four of padding and still four of clearance to the next word.
|
|
1589
|
+
*
|
|
1590
|
+
* The shadow is a lift, not an outline. A hard ring made each mention read as
|
|
1591
|
+
* a small input field dropped into the middle of a sentence.
|
|
1592
|
+
*/
|
|
1593
|
+
#input-mirror mark{
|
|
1594
|
+
background:var(--bg);color:var(--fg);border-radius:6px;
|
|
1595
|
+
padding:3px 4px;margin:0 -4px;
|
|
1596
|
+
box-shadow:var(--mention-lift);
|
|
1597
|
+
box-decoration-break:clone;-webkit-box-decoration-break:clone;
|
|
1598
|
+
}
|
|
1599
|
+
/* The @ sign's own advance width, measured on every paint, with the icon of
|
|
1600
|
+
what was mentioned drawn in it instead. The width is the field's, so the
|
|
1601
|
+
name after it lands exactly where the field puts it. */
|
|
1602
|
+
#input-mirror mark .at{
|
|
1603
|
+
display:inline-block;width:var(--at-w,13.5px);vertical-align:-1.5px;
|
|
1604
|
+
color:var(--dim);
|
|
1605
|
+
}
|
|
1606
|
+
#input-mirror mark .at svg{width:11px;height:11px;display:block;margin-left:.5px}
|
|
1607
|
+
/* The mirror sits exactly under the textarea, so every metric that decides
|
|
1608
|
+
where a glyph lands has to match: font, line-height, wrapping, and the
|
|
1609
|
+
padding, which is zero on the composer's textarea and must be zero here. */
|
|
1445
1610
|
textarea.boxed{
|
|
1446
1611
|
border:1px solid var(--line);background:var(--field);border-radius:6px;
|
|
1447
1612
|
padding:7px 9px;resize:vertical;min-height:0;font:inherit;font-size:12px;
|
|
@@ -1478,8 +1643,32 @@ textarea.boxed:focus{border-color:var(--dim)}
|
|
|
1478
1643
|
/* Wider than the model picker's: these rows carry a name and the folder it
|
|
1479
1644
|
sits in, and truncating the folder away leaves three files called index.ts. */
|
|
1480
1645
|
#attach-picker .menu{min-width:min(380px,86vw)}
|
|
1481
|
-
#attach-picker .menu-item{cursor:pointer}
|
|
1646
|
+
#attach-picker .menu-item{cursor:pointer;gap:9px;padding-left:9px;padding-right:9px}
|
|
1647
|
+
#attach-picker .menu-item .tick{width:auto;opacity:.55;margin-left:auto;padding-left:8px}
|
|
1648
|
+
#attach-picker .menu-item.on .tick{opacity:.85}
|
|
1649
|
+
/* A step up from hover, not a slab of ink. The keyboard cursor and the pointer
|
|
1650
|
+
should not look identical — one of them means Enter takes this row — but the
|
|
1651
|
+
menu is a quiet surface and the difference is worth one tone, not two. */
|
|
1652
|
+
#attach-picker .menu-item.cursor{background:var(--sel)}
|
|
1653
|
+
#attach-picker .menu-item.cursor .ico-sm{opacity:.8}
|
|
1482
1654
|
#attach-picker .menu-item .ico-sm{opacity:.55}
|
|
1655
|
+
/* The stacked rows carry a name over a sentence, so they are already two lines
|
|
1656
|
+
tall; the mark beside them is a label for the kind, not an illustration, and
|
|
1657
|
+
at 16px it was the widest thing in a row whose job is the words. */
|
|
1658
|
+
/* Centred on the name, not on the row. A stacked row is a name over a
|
|
1659
|
+
sentence, so its box centre sits somewhere in the gap between the two lines
|
|
1660
|
+
and a mark aligned to it reads as sagging. The name's line box is 22px at
|
|
1661
|
+
this size and the mark is 13px, so half the difference puts one centre on
|
|
1662
|
+
the other. The rule above this one sets the 1px the flush-left tick needed
|
|
1663
|
+
when every row was a single line; on these it is the same 1px of sag. */
|
|
1664
|
+
#attach-picker .menu-item.stacked .ico-sm{width:13px;height:13px;margin-top:4.5px}
|
|
1665
|
+
/* The vendor mark at the row's glyph size, the mono tile lettered to match. */
|
|
1666
|
+
#attach-picker .menu-item .mk-logo,#attach-picker .menu-item .mk-mono{
|
|
1667
|
+
width:16px;height:16px;border-radius:4px;margin-top:3px;font-size:9px;
|
|
1668
|
+
}
|
|
1669
|
+
#attach-picker .menu-item.stacked .tick{margin-top:0}
|
|
1670
|
+
#attach-picker .menu-item.stacked{padding-top:5px;padding-bottom:5px}
|
|
1671
|
+
#attach-picker .menu-item.stacked .stack{gap:1px}
|
|
1483
1672
|
#attach-picker .menu-item.on .ico-sm{opacity:.9}
|
|
1484
1673
|
.menu-hint{padding:6px 8px 8px;font-size:11.5px;color:var(--dim)}
|
|
1485
1674
|
/* A row that has something to say about itself: the name on one line and a
|
|
@@ -1707,6 +1896,21 @@ const ICON_PATHS = {
|
|
|
1707
1896
|
// Stacked plates, because a plugin arrives as a bundle of contributions
|
|
1708
1897
|
// rather than as one thing.
|
|
1709
1898
|
plugin: ["M8 1.33 1.33 4.67 8 8l6.67-3.33Z", "M1.33 11.33 8 14.67l6.67-3.34", "M1.33 8 8 11.33 14.67 8"],
|
|
1899
|
+
/* A bookmark: one path, and the only shape in this menu that is neither a
|
|
1900
|
+
sheet nor a folder. The open book it replaced was two mirrored paths
|
|
1901
|
+
meeting at a spine, which at the 13px these rows use closed into a blob —
|
|
1902
|
+
two strokes a pixel apart read as one thick one. */
|
|
1903
|
+
skill: ["M4.5 2.5h7v11l-3.5-2.6-3.5 2.6Z"],
|
|
1904
|
+
/* A prompt caret: what a command line looks like anywhere else. */
|
|
1905
|
+
command: ["M4 5.5 6.5 8 4 10.5", "M8.5 10.5H12"],
|
|
1906
|
+
/* A bare chevron. The other back arrow has a shaft, which is right on a
|
|
1907
|
+
page header and heavy on a menu row sitting one line above the thing it
|
|
1908
|
+
returns to. */
|
|
1909
|
+
chevronLeft: ["M9.5 4.5 6 8l3.5 3.5"],
|
|
1910
|
+
/* Two strokes, for the corner of a dialog. */
|
|
1911
|
+
close: ["M4.5 4.5l7 7", "M11.5 4.5l-7 7"],
|
|
1912
|
+
search: ["M11.5 11.5 14 14"],
|
|
1913
|
+
searchCircles: [[7.3, 7.3, 4.6]],
|
|
1710
1914
|
// A shopfront: an awning over a counter, for the list of what is on offer.
|
|
1711
1915
|
market: ["M2 6.67h12", "M3.33 6.67 4.67 2.67h6.66l1.34 4", "M2.67 6.67v6.66h10.67V6.67"],
|
|
1712
1916
|
// An arrow pointing back the way the menu came in.
|
|
@@ -1729,6 +1933,12 @@ const ICON_PATHS = {
|
|
|
1729
1933
|
"M10.2 3.2 7.6 5a2.7 2.7 0 0 1-1.7.5l-1.6-.1a.6.6 0 0 0-.45 1.03l5.72 5.72a.6.6 0 0 0 1.03-.45l-.1-1.6A2.7 2.7 0 0 1 11 8.4l1.8-2.6",
|
|
1730
1934
|
"M6.2 9.8 2.8 13.2",
|
|
1731
1935
|
],
|
|
1936
|
+
// The product's own mark: two open arcs around a centre, the same glyph the
|
|
1937
|
+
// application icon draws, at half its scale. Drawn rather than fetched,
|
|
1938
|
+
// because the official plan's rows have to have a mark on a machine that
|
|
1939
|
+
// has never reached the network.
|
|
1940
|
+
oharness: ["M10 3H6a3 3 0 0 0-3 3v4", "M6 13h4a3 3 0 0 0 3-3V6"],
|
|
1941
|
+
oharnessDots: [[8, 8]],
|
|
1732
1942
|
// A speech bubble with a tail: what a session is, before it has a title.
|
|
1733
1943
|
chat: [
|
|
1734
1944
|
"M14 10a1.33 1.33 0 0 1-1.33 1.33H4.67L2 14V3.33A1.33 1.33 0 0 1 3.33 2h9.34" +
|
|
@@ -1920,6 +2130,120 @@ let atBottom = true;
|
|
|
1920
2130
|
* chosen value. It exists because a native select draws its popup and its
|
|
1921
2131
|
* focus ring with the operating system's chrome, which no stylesheet reaches.
|
|
1922
2132
|
*/
|
|
2133
|
+
/**
|
|
2134
|
+
* The mark for one model vendor, for the rows of the model menu.
|
|
2135
|
+
*
|
|
2136
|
+
* The same arrangement the connector list uses, for the same reason: the
|
|
2137
|
+
* vendor's own icon when there is one, a lettered tile when there is not, and
|
|
2138
|
+
* no third-party request from this page either way — the server fetches the
|
|
2139
|
+
* icon once and serves it from this origin, so opening the menu does not tell
|
|
2140
|
+
* two dozen vendors that this machine looked at their name.
|
|
2141
|
+
*
|
|
2142
|
+
* The resolution happens once per vendor rather than once per row. A vendor
|
|
2143
|
+
* with four hundred models would otherwise decode the same image four hundred
|
|
2144
|
+
* times and measure it four hundred times, on every repaint of the list.
|
|
2145
|
+
*/
|
|
2146
|
+
const providerMarks = new Map();
|
|
2147
|
+
|
|
2148
|
+
const providerIconUrl = (id) => q("/provider-icon?id=" + encodeURIComponent(id));
|
|
2149
|
+
|
|
2150
|
+
/** What is known about a vendor's icon, and who is waiting to hear. */
|
|
2151
|
+
function providerIconState(id) {
|
|
2152
|
+
let state = providerMarks.get(id);
|
|
2153
|
+
if (state) return state;
|
|
2154
|
+
state = { status: "pending", invert: false, filled: false, waiting: [] };
|
|
2155
|
+
providerMarks.set(id, state);
|
|
2156
|
+
|
|
2157
|
+
const probe = new Image();
|
|
2158
|
+
probe.addEventListener("load", () => {
|
|
2159
|
+
state.status = "ok";
|
|
2160
|
+
const profile = markProfile(probe);
|
|
2161
|
+
state.invert = profile.invert;
|
|
2162
|
+
state.filled = profile.filled;
|
|
2163
|
+
const waiting = state.waiting.splice(0);
|
|
2164
|
+
for (const swap of waiting) swap();
|
|
2165
|
+
});
|
|
2166
|
+
// A vendor with no icon is ordinary: local servers have no site to ask, and
|
|
2167
|
+
// plenty of sites answer nothing usable. The tile is a complete answer.
|
|
2168
|
+
probe.addEventListener("error", () => {
|
|
2169
|
+
state.status = "none";
|
|
2170
|
+
state.waiting.length = 0;
|
|
2171
|
+
});
|
|
2172
|
+
probe.src = providerIconUrl(id);
|
|
2173
|
+
return state;
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
/** A stable hue per vendor, so a tile is the same colour on every repaint. */
|
|
2177
|
+
function providerMono(id, label, extra) {
|
|
2178
|
+
let hash = 0;
|
|
2179
|
+
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) % 360;
|
|
2180
|
+
const mono = el("span", "vmono" + (extra ? " " + extra : ""), (label || id || "?").trim().charAt(0));
|
|
2181
|
+
mono.style.background = "hsl(" + hash + " 42% 42%)";
|
|
2182
|
+
return mono;
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
function providerLogo(id, state, extra) {
|
|
2186
|
+
const img = new Image();
|
|
2187
|
+
// A mark that owns its whole box keeps it; one that is bare ink is inset,
|
|
2188
|
+
// so a wide glyph with no margin of its own does not read as bigger and
|
|
2189
|
+
// off-line next to a tile that fills the same square.
|
|
2190
|
+
img.className =
|
|
2191
|
+
"vmark" +
|
|
2192
|
+
(state.filled ? " filled" : "") +
|
|
2193
|
+
(state.invert ? " invert-dark" : "") +
|
|
2194
|
+
(extra ? " " + extra : "");
|
|
2195
|
+
img.alt = "";
|
|
2196
|
+
img.src = providerIconUrl(id);
|
|
2197
|
+
return img;
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
/**
|
|
2201
|
+
* A vendor's mark, ready to put in a row.
|
|
2202
|
+
*
|
|
2203
|
+
* Returns something to draw now, not a promise: the menu repaints on every
|
|
2204
|
+
* keystroke and cannot wait on the network. A tile goes in, and if the icon
|
|
2205
|
+
* turns out to exist it takes the tile's place — but only if that tile is
|
|
2206
|
+
* still on the page, since the row it was made for may already be gone.
|
|
2207
|
+
*/
|
|
2208
|
+
function providerMark(id, label, extra) {
|
|
2209
|
+
// Ours is drawn from the same paths as the application icon rather than
|
|
2210
|
+
// fetched from our own site: it is the one mark that has to be there
|
|
2211
|
+
// offline, on the row that is being recommended.
|
|
2212
|
+
if (id === "official") return svgIcon("oharness", "vglyph" + (extra ? " " + extra : ""));
|
|
2213
|
+
|
|
2214
|
+
const state = providerIconState(id);
|
|
2215
|
+
if (state.status === "ok") return providerLogo(id, state, extra);
|
|
2216
|
+
|
|
2217
|
+
const mono = providerMono(id, label, extra);
|
|
2218
|
+
if (state.status === "pending") {
|
|
2219
|
+
state.waiting.push(() => {
|
|
2220
|
+
if (mono.isConnected) mono.replaceWith(providerLogo(id, state, extra));
|
|
2221
|
+
});
|
|
2222
|
+
}
|
|
2223
|
+
return mono;
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
/**
|
|
2227
|
+
* The model id, shortened to what the bar has room for: everything after the
|
|
2228
|
+
* last slash.
|
|
2229
|
+
*
|
|
2230
|
+
* The rule used to be a length test — over 28 characters keep the tail, under
|
|
2231
|
+
* it keep the whole thing — which is how "~deepseek/deepseek-v4-flash" reached
|
|
2232
|
+
* the bar at 27 characters, naming its vendor twice and then being cut
|
|
2233
|
+
* mid-word by the ellipsis anyway. Length was the wrong question: what to drop
|
|
2234
|
+
* does not depend on how long the string happens to be.
|
|
2235
|
+
*
|
|
2236
|
+
* What to drop is the namespace, every time. The vendor's mark is drawn
|
|
2237
|
+
* immediately to the left of this, so the prefix is a picture repeated in
|
|
2238
|
+
* words — and an aggregator's id nests, which makes the prefix the longer half
|
|
2239
|
+
* of a name whose distinguishing half is at the end. The whole id stays in the
|
|
2240
|
+
* title attribute and in the menu.
|
|
2241
|
+
*/
|
|
2242
|
+
function shortModel(full) {
|
|
2243
|
+
const cut = full.lastIndexOf("/");
|
|
2244
|
+
return cut < 0 ? full : full.slice(cut + 1);
|
|
2245
|
+
}
|
|
2246
|
+
|
|
1923
2247
|
function makePicker(id, onPick, extra) {
|
|
1924
2248
|
const root = document.getElementById(id);
|
|
1925
2249
|
const button = root.querySelector(".picker-btn");
|
|
@@ -1995,6 +2319,10 @@ function makePicker(id, onPick, extra) {
|
|
|
1995
2319
|
const header = el("button", "menu-group" + (collapsed.has(group) && !searching ? "" : " open"));
|
|
1996
2320
|
header.type = "button";
|
|
1997
2321
|
header.appendChild(el("span", "chev"));
|
|
2322
|
+
// The vendor's mark leads its header, so a folded list reads as a
|
|
2323
|
+
// column of vendors rather than as a column of lowercase words.
|
|
2324
|
+
const groupMark = api.groupMark && api.groupMark(group);
|
|
2325
|
+
if (groupMark) header.appendChild(groupMark);
|
|
1998
2326
|
header.appendChild(el("span", "g-name", group));
|
|
1999
2327
|
// A vendor may be worth marking out — the official plan is the one
|
|
2000
2328
|
// this project stands behind, and a group header is where someone
|
|
@@ -2020,6 +2348,10 @@ function makePicker(id, onPick, extra) {
|
|
|
2020
2348
|
);
|
|
2021
2349
|
item.type = "button";
|
|
2022
2350
|
item.appendChild(el("span", "tick", "✓"));
|
|
2351
|
+
// Between the tick and the name: the row is "is this the one" then
|
|
2352
|
+
// "whose is it" then "which model", which is the order it is read in.
|
|
2353
|
+
const mark = option.mark && option.mark();
|
|
2354
|
+
if (mark) item.appendChild(mark);
|
|
2023
2355
|
item.appendChild(el("span", "menu-label", option.label));
|
|
2024
2356
|
if (option.sub) item.appendChild(el("span", "sub", option.sub));
|
|
2025
2357
|
item.addEventListener("click", () => {
|
|
@@ -2111,14 +2443,22 @@ function makePicker(id, onPick, extra) {
|
|
|
2111
2443
|
const api = {
|
|
2112
2444
|
/** Optional: a badge for a group header, e.g. the recommended vendor. */
|
|
2113
2445
|
groupTag: null,
|
|
2446
|
+
/** Optional: a mark for a group header, e.g. the vendor's own icon. */
|
|
2447
|
+
groupMark: null,
|
|
2114
2448
|
set(list, current) {
|
|
2115
2449
|
options = list;
|
|
2116
2450
|
value = current;
|
|
2117
2451
|
const match = list.find((option) => option.value === current);
|
|
2118
|
-
//
|
|
2119
|
-
//
|
|
2452
|
+
// The mark travels with the choice onto the bar. The label there is a
|
|
2453
|
+
// truncated id — the tail of google/gemini-3.6-flash:batch says nothing
|
|
2454
|
+
// about who is being paid for it, and that is the half of the answer
|
|
2455
|
+
// somebody glances down to check.
|
|
2456
|
+
const stale = button.querySelector(".picker-mark");
|
|
2457
|
+
if (stale) stale.remove();
|
|
2458
|
+
const mark = match && match.mark && match.mark("picker-mark");
|
|
2459
|
+
if (mark) button.insertBefore(mark, label);
|
|
2120
2460
|
const full = match ? match.label : current || "—";
|
|
2121
|
-
label.textContent =
|
|
2461
|
+
label.textContent = shortModel(full);
|
|
2122
2462
|
label.title = full;
|
|
2123
2463
|
},
|
|
2124
2464
|
get value() {
|
|
@@ -3487,10 +3827,23 @@ async function loadModels() {
|
|
|
3487
3827
|
(p.recommended ? 0 : (populated.has(p.id) ? 0 : 2000) + (p.configured ? 0 : 1000)) + i,
|
|
3488
3828
|
]),
|
|
3489
3829
|
);
|
|
3830
|
+
// One closure per vendor, not one per model: four hundred rows repaint on
|
|
3831
|
+
// every keystroke in the search field.
|
|
3832
|
+
const vendorLabels = new Map(providers.map((p) => [p.id, p.label || p.id]));
|
|
3833
|
+
const markers = new Map(
|
|
3834
|
+
providers.map((p) => [
|
|
3835
|
+
p.id,
|
|
3836
|
+
(extra) => providerMark(p.id, vendorLabels.get(p.id) || p.id, extra),
|
|
3837
|
+
]),
|
|
3838
|
+
);
|
|
3839
|
+
const markFor = (id) =>
|
|
3840
|
+
markers.get(id) || ((extra) => providerMark(id, id, extra));
|
|
3841
|
+
|
|
3490
3842
|
const listed = models.map((m) => ({
|
|
3491
3843
|
value: m.id,
|
|
3492
3844
|
label: m.id,
|
|
3493
3845
|
group: m.provider,
|
|
3846
|
+
mark: markFor(m.provider),
|
|
3494
3847
|
sub: m.contextWindow ? Math.round(m.contextWindow / 1000) + "k" : "",
|
|
3495
3848
|
}));
|
|
3496
3849
|
|
|
@@ -3505,6 +3858,7 @@ async function loadModels() {
|
|
|
3505
3858
|
value: "setup:" + p.id,
|
|
3506
3859
|
label: t(p.configured ? "menu.discover" : "menu.setUp"),
|
|
3507
3860
|
group: p.id,
|
|
3861
|
+
mark: markFor(p.id),
|
|
3508
3862
|
sub: "",
|
|
3509
3863
|
}));
|
|
3510
3864
|
|
|
@@ -3521,6 +3875,7 @@ async function loadModels() {
|
|
|
3521
3875
|
const current = models.find((m) => m.current);
|
|
3522
3876
|
const recommended = new Set(providers.filter((p) => p.recommended).map((p) => p.id));
|
|
3523
3877
|
modelPicker.groupTag = (group) => (recommended.has(group) ? t("models.recommended") : null);
|
|
3878
|
+
modelPicker.groupMark = (group) => markFor(group)();
|
|
3524
3879
|
modelPicker.options = modelOptions;
|
|
3525
3880
|
modelPicker.set(modelOptions, current ? current.id : null);
|
|
3526
3881
|
}
|
|
@@ -3880,7 +4235,7 @@ events.onmessage = (message) => {
|
|
|
3880
4235
|
case "state": applyState(event.state); break;
|
|
3881
4236
|
case "connectors_changed":
|
|
3882
4237
|
if (authorizing === event.connector) authorizing = null;
|
|
3883
|
-
offer = { connectors: event.connectors, plugins: event.plugins || [] };
|
|
4238
|
+
offer = { connectors: event.connectors, plugins: event.plugins || [], skills: event.skills || [] };
|
|
3884
4239
|
// The failure already said what went wrong; this only announces the
|
|
3885
4240
|
// half that is worth celebrating.
|
|
3886
4241
|
if (event.authorized) addNotice(t("market.authorized", { name: event.connector }));
|
|
@@ -4175,7 +4530,7 @@ const sentPreviews = new Map();
|
|
|
4175
4530
|
* server turns the list into a short block above the prompt.
|
|
4176
4531
|
*/
|
|
4177
4532
|
let refs = [];
|
|
4178
|
-
const REF_ICON = { file: "file", folder: "folder", connector: "plug", plugin: "plugin" };
|
|
4533
|
+
const REF_ICON = { file: "file", folder: "folder", connector: "plug", plugin: "plugin", skill: "skill" };
|
|
4179
4534
|
const refKey = (ref) => ref.kind + ":" + ref.value;
|
|
4180
4535
|
|
|
4181
4536
|
function renderAttachments() {
|
|
@@ -4304,8 +4659,10 @@ attachMenu.replaceChildren(attachBody);
|
|
|
4304
4659
|
let attachView = "root";
|
|
4305
4660
|
/** The last answer from /files, for the mention list. */
|
|
4306
4661
|
let fileList = { entries: [] };
|
|
4662
|
+
/** Whether the context endpoint has answered once, for the mention list. */
|
|
4663
|
+
let offerLoaded = false;
|
|
4307
4664
|
/** Connectors and plugins, refreshed each time the menu opens. */
|
|
4308
|
-
let offer = { connectors: [], plugins: [] };
|
|
4665
|
+
let offer = { connectors: [], plugins: [], skills: [] };
|
|
4309
4666
|
/** The connectors on offer rather than the ones already here. */
|
|
4310
4667
|
let market = [];
|
|
4311
4668
|
/** The connector whose sign-in is open in a browser tab, if any. */
|
|
@@ -4342,18 +4699,174 @@ function addRef(kind, value) {
|
|
|
4342
4699
|
|
|
4343
4700
|
/** The rows on screen, in the order they are painted — what a key can land on. */
|
|
4344
4701
|
function attachRows() {
|
|
4702
|
+
/**
|
|
4703
|
+
* What this project can reach, three or four deep, over the full catalogue.
|
|
4704
|
+
*
|
|
4705
|
+
* The menu used to hand every one of these straight to a dialog, which is
|
|
4706
|
+
* the right home for eighty skills and the wrong one for the two somebody
|
|
4707
|
+
* actually attaches. So the menu answers the common question itself — what
|
|
4708
|
+
* is here, attach one — and the dialog keeps the uncommon ones: read all of
|
|
4709
|
+
* them, install something new, take something off.
|
|
4710
|
+
*/
|
|
4711
|
+
if (attachView === "skills" || attachView === "plugins" || attachView === "connectors") {
|
|
4712
|
+
const kind = attachView;
|
|
4713
|
+
const items =
|
|
4714
|
+
kind === "skills" ? offer.skills || []
|
|
4715
|
+
: kind === "plugins" ? offer.plugins || []
|
|
4716
|
+
: offer.connectors || [];
|
|
4717
|
+
const row = (entry) => ({
|
|
4718
|
+
type: "offer-ref",
|
|
4719
|
+
icon: kind === "skills" ? "skill" : kind === "plugins" ? "plugin" : "plug",
|
|
4720
|
+
// A connector is known by its vendor's mark, the same one the dialog
|
|
4721
|
+
// draws; a plug in front of three names is three rows told apart by
|
|
4722
|
+
// reading.
|
|
4723
|
+
mark: kind === "connectors" ? entry.name : undefined,
|
|
4724
|
+
label: entry.name,
|
|
4725
|
+
note: entry.description || "",
|
|
4726
|
+
ref: {
|
|
4727
|
+
kind: kind === "skills" ? "skill" : kind === "plugins" ? "plugin" : "connector",
|
|
4728
|
+
value: entry.name,
|
|
4729
|
+
label: entry.name,
|
|
4730
|
+
},
|
|
4731
|
+
});
|
|
4732
|
+
/**
|
|
4733
|
+
* One list, with the project's own first.
|
|
4734
|
+
*
|
|
4735
|
+
* A skill checked into the repository is likelier to be the one wanted
|
|
4736
|
+
* here, so it is not left to sort alphabetically among eighty that happen
|
|
4737
|
+
* to be installed. That is an ordering, not a division: both are usable in
|
|
4738
|
+
* this session and a heading between them would be drawing a line where
|
|
4739
|
+
* the user does not have to act on one.
|
|
4740
|
+
*/
|
|
4741
|
+
const local = kind === "connectors" ? [] : items.filter((entry) => entry.source === "project");
|
|
4742
|
+
const shown = [...local, ...items.filter((entry) => !local.includes(entry))].slice(0, 6);
|
|
4743
|
+
const rows = shown.map(row);
|
|
4744
|
+
const empty = { skills: "add.noSkills", plugins: "add.noPlugins", connectors: "add.noConnectors" };
|
|
4745
|
+
const hidden = items.length - shown.length;
|
|
4746
|
+
return [
|
|
4747
|
+
{ type: "up", icon: "chevronLeft", label: t("add.back") },
|
|
4748
|
+
...rows,
|
|
4749
|
+
...(!items.length ? [{ type: "hint", label: t(empty[kind]) }] : []),
|
|
4750
|
+
...(hidden > 0 ? [{ type: "hint", label: t("add.andMore", { n: hidden }) }] : []),
|
|
4751
|
+
// Browse is only a different question where there is somewhere to browse:
|
|
4752
|
+
// connectors have a registry of ones not installed, skills and plugins
|
|
4753
|
+
// are whatever is on this machine already. "More" only when the menu
|
|
4754
|
+
// is holding some back; with everything in view it is the catalogue.
|
|
4755
|
+
...(kind === "connectors"
|
|
4756
|
+
? [{ type: "market", kind, icon: "search", label: t(hidden > 0 ? "add.browse" : "add.browseAll") }]
|
|
4757
|
+
: []),
|
|
4758
|
+
{ type: "manage", kind, icon: "settings", label: t("add.manage") },
|
|
4759
|
+
];
|
|
4760
|
+
}
|
|
4761
|
+
|
|
4762
|
+
if (attachView === "command") {
|
|
4763
|
+
return COMMANDS.filter((one) => !attachQuery || one.name.startsWith(attachQuery)).map((one) => ({
|
|
4764
|
+
type: "command",
|
|
4765
|
+
icon: "command",
|
|
4766
|
+
label: "/" + one.name + (one.arg ? " <" + one.arg + ">" : ""),
|
|
4767
|
+
note: one.hint(),
|
|
4768
|
+
command: one.name,
|
|
4769
|
+
}));
|
|
4770
|
+
}
|
|
4345
4771
|
if (attachView === "mention") {
|
|
4346
|
-
|
|
4772
|
+
/**
|
|
4773
|
+
* Skills first, then files.
|
|
4774
|
+
*
|
|
4775
|
+
* The @ began as a way to name a file and files are still most of what it
|
|
4776
|
+
* names, but a skill is the other thing worth pointing at from inside a
|
|
4777
|
+
* sentence — and the only one where naming it does something: the model is
|
|
4778
|
+
* told which skills exist, so naming one asks for it directly instead of
|
|
4779
|
+
* leaving the choice to whether the task happened to match a description.
|
|
4780
|
+
* There are a handful of them against thousands of files, so they go above
|
|
4781
|
+
* rather than competing on rank.
|
|
4782
|
+
*/
|
|
4783
|
+
const query = attachQuery.toLowerCase();
|
|
4784
|
+
/**
|
|
4785
|
+
* A skill matches on what it is for, not only on what it is called.
|
|
4786
|
+
*
|
|
4787
|
+
* Names are terse and chosen by whoever wrote the skill; the sentence
|
|
4788
|
+
* under the name is the one a person would think of. Someone after the
|
|
4789
|
+
* video tools types "video", and only the description contains it.
|
|
4790
|
+
*/
|
|
4791
|
+
const skills = (offer.skills || [])
|
|
4792
|
+
.filter((skill) =>
|
|
4793
|
+
!query ||
|
|
4794
|
+
skill.name.toLowerCase().includes(query) ||
|
|
4795
|
+
(skill.description || "").toLowerCase().includes(query),
|
|
4796
|
+
)
|
|
4797
|
+
/**
|
|
4798
|
+
* A few, not all of them.
|
|
4799
|
+
*
|
|
4800
|
+
* This list is mostly files and should stay that way: eighty skills
|
|
4801
|
+
* answering a one-letter query push every file below a fold nobody
|
|
4802
|
+
* scrolls, and @ was a way to name a file first. Four, because a skill
|
|
4803
|
+
* row is two lines — six of them filled the menu exactly and the files
|
|
4804
|
+
* began again just past its bottom edge, which is the same problem with
|
|
4805
|
+
* a smaller number on it. The query narrows them as fast as anyone
|
|
4806
|
+
* types, and the whole catalogue is a click away under the + menu.
|
|
4807
|
+
*/
|
|
4808
|
+
.slice(0, 4)
|
|
4809
|
+
.map((skill) => ({
|
|
4810
|
+
type: "skill",
|
|
4811
|
+
icon: "skill",
|
|
4812
|
+
label: skill.name,
|
|
4813
|
+
// Under the name rather than beside it. A file's sub is a folder and
|
|
4814
|
+
// fits the right-hand slot; a skill's is the sentence it is chosen on,
|
|
4815
|
+
// and in that slot it pushes the name it belongs to off the row.
|
|
4816
|
+
note: skill.description || "",
|
|
4817
|
+
ref: { kind: "skill", value: skill.name, label: skill.name },
|
|
4818
|
+
}));
|
|
4819
|
+
/**
|
|
4820
|
+
* Plugins, on the same terms as skills and after them.
|
|
4821
|
+
*
|
|
4822
|
+
* A plugin is a bundle rather than a thing the model loads, so naming one
|
|
4823
|
+
* is a weaker instruction than naming a skill — "use the ottoport plugin"
|
|
4824
|
+
* steers which tools get reached for rather than pulling in a page of
|
|
4825
|
+
* instructions. Worth offering, not worth offering first.
|
|
4826
|
+
*/
|
|
4827
|
+
const plugins = (offer.plugins || [])
|
|
4828
|
+
.filter((plugin) =>
|
|
4829
|
+
!query ||
|
|
4830
|
+
plugin.name.toLowerCase().includes(query) ||
|
|
4831
|
+
(plugin.description || "").toLowerCase().includes(query),
|
|
4832
|
+
)
|
|
4833
|
+
.slice(0, 4)
|
|
4834
|
+
.map((plugin) => ({
|
|
4835
|
+
type: "plugin",
|
|
4836
|
+
icon: "plugin",
|
|
4837
|
+
label: plugin.name,
|
|
4838
|
+
note: plugin.description || "",
|
|
4839
|
+
ref: { kind: "plugin", value: plugin.name, label: plugin.name },
|
|
4840
|
+
}));
|
|
4841
|
+
const files = (fileList.entries || []).map((entry) => ({
|
|
4347
4842
|
type: entry.dir ? "folder" : "file",
|
|
4348
4843
|
icon: entry.dir ? "folder" : "file",
|
|
4349
4844
|
label: entry.name,
|
|
4350
4845
|
sub: folderOf(entry.path),
|
|
4351
4846
|
ref: { kind: entry.dir ? "folder" : "file", value: entry.path, label: entry.name },
|
|
4352
4847
|
}));
|
|
4848
|
+
/**
|
|
4849
|
+
* Headed, but only where a heading distinguishes something.
|
|
4850
|
+
*
|
|
4851
|
+
* Two kinds of thing share this list and they are not interchangeable —
|
|
4852
|
+
* one is a file to read, the other is a way of working the model loads.
|
|
4853
|
+
* A heading each says so. With only one kind present the heading is a
|
|
4854
|
+
* label on a list of one kind, which is noise.
|
|
4855
|
+
*/
|
|
4856
|
+
const sections = [
|
|
4857
|
+
[t("add.skills"), skills],
|
|
4858
|
+
[t("add.plugins"), plugins],
|
|
4859
|
+
[t("mention.files"), files],
|
|
4860
|
+
].filter(([, rows]) => rows.length);
|
|
4861
|
+
// One kind present is a list of one kind, and a heading over it labels
|
|
4862
|
+
// nothing. Headings appear when there is something to tell apart.
|
|
4863
|
+
if (sections.length < 2) return sections.flatMap(([, rows]) => rows);
|
|
4864
|
+
return sections.flatMap(([label, rows]) => [{ type: "group", label }, ...rows]);
|
|
4353
4865
|
}
|
|
4354
4866
|
|
|
4355
4867
|
const connectors = (offer.connectors || []).length;
|
|
4356
4868
|
const plugins = (offer.plugins || []).length;
|
|
4869
|
+
const skills = (offer.skills || []).length;
|
|
4357
4870
|
return [
|
|
4358
4871
|
// Two rows, because the dialogs behind them are two: only macOS has a
|
|
4359
4872
|
// panel that takes a file and a folder in one pass, and everywhere else a
|
|
@@ -4366,7 +4879,7 @@ function attachRows() {
|
|
|
4366
4879
|
// word in the slot that otherwise holds a number, and it reads as a value
|
|
4367
4880
|
// rather than as an absence.
|
|
4368
4881
|
{
|
|
4369
|
-
type: "
|
|
4882
|
+
type: "into",
|
|
4370
4883
|
kind: "connectors",
|
|
4371
4884
|
icon: "plug",
|
|
4372
4885
|
label: t("add.connectors"),
|
|
@@ -4374,13 +4887,21 @@ function attachRows() {
|
|
|
4374
4887
|
into: true,
|
|
4375
4888
|
},
|
|
4376
4889
|
{
|
|
4377
|
-
type: "
|
|
4890
|
+
type: "into",
|
|
4378
4891
|
kind: "plugins",
|
|
4379
4892
|
icon: "plugin",
|
|
4380
4893
|
label: t("add.plugins"),
|
|
4381
4894
|
sub: plugins ? String(plugins) : "",
|
|
4382
4895
|
into: true,
|
|
4383
4896
|
},
|
|
4897
|
+
{
|
|
4898
|
+
type: "into",
|
|
4899
|
+
kind: "skills",
|
|
4900
|
+
icon: "skill",
|
|
4901
|
+
label: t("add.skills"),
|
|
4902
|
+
sub: skills ? String(skills) : "",
|
|
4903
|
+
into: true,
|
|
4904
|
+
},
|
|
4384
4905
|
{ type: "upload", icon: "upload", label: t("add.upload") },
|
|
4385
4906
|
];
|
|
4386
4907
|
}
|
|
@@ -4388,16 +4909,28 @@ function attachRows() {
|
|
|
4388
4909
|
function paintAttachMenu() {
|
|
4389
4910
|
const rows = attachRows();
|
|
4390
4911
|
if (attachCursor >= rows.length) attachCursor = Math.max(0, rows.length - 1);
|
|
4912
|
+
// The list opens with the cursor at 0, and 0 is a heading whenever the list
|
|
4913
|
+
// is grouped. Nudged here rather than at every call site that sets it.
|
|
4914
|
+
while (rows[attachCursor] && rows[attachCursor].type === "group" && attachCursor < rows.length - 1) {
|
|
4915
|
+
attachCursor++;
|
|
4916
|
+
}
|
|
4391
4917
|
attachBody.replaceChildren();
|
|
4392
4918
|
|
|
4393
4919
|
rows.forEach((row, i) => {
|
|
4920
|
+
if (row.type === "group") {
|
|
4921
|
+
attachBody.appendChild(el("div", "menu-head", row.label));
|
|
4922
|
+
return;
|
|
4923
|
+
}
|
|
4924
|
+
if (row.type === "hint") {
|
|
4925
|
+
attachBody.appendChild(el("div", "menu-hint", row.label));
|
|
4926
|
+
return;
|
|
4927
|
+
}
|
|
4394
4928
|
const item = el(
|
|
4395
4929
|
"div",
|
|
4396
4930
|
"menu-item" + (row.ref && referenced(row.ref) ? " on" : "") + (i === attachCursor ? " cursor" : ""),
|
|
4397
4931
|
);
|
|
4398
4932
|
item.setAttribute("role", "button");
|
|
4399
|
-
item.appendChild(
|
|
4400
|
-
item.appendChild(svgIcon(row.icon));
|
|
4933
|
+
item.appendChild(row.mark ? connectorMark(row.mark, row.label) : svgIcon(row.icon));
|
|
4401
4934
|
if (row.note) {
|
|
4402
4935
|
item.classList.add("stacked");
|
|
4403
4936
|
const stack = el("span", "stack");
|
|
@@ -4408,6 +4941,11 @@ function paintAttachMenu() {
|
|
|
4408
4941
|
item.appendChild(el("span", "menu-label", row.label));
|
|
4409
4942
|
}
|
|
4410
4943
|
if (row.sub) item.appendChild(el("span", "sub", row.sub));
|
|
4944
|
+
// At the end rather than the start. A tick that is invisible until a row
|
|
4945
|
+
// is chosen still held a column open in front of every row that was not,
|
|
4946
|
+
// which put the icons a third of the way into the menu and the words
|
|
4947
|
+
// further still. On the right it costs nothing until there is one.
|
|
4948
|
+
if (row.ref && referenced(row.ref)) item.appendChild(el("span", "tick", "✓"));
|
|
4411
4949
|
if (row.into) item.appendChild(el("span", "chev"));
|
|
4412
4950
|
// An installed connector is removed from the row that says it is
|
|
4413
4951
|
// installed, which is the row someone looking to remove it goes to.
|
|
@@ -4440,6 +4978,26 @@ function paintAttachMenu() {
|
|
|
4440
4978
|
}
|
|
4441
4979
|
|
|
4442
4980
|
function chooseAttachRow(row) {
|
|
4981
|
+
if (row.type === "hint") return;
|
|
4982
|
+
if (row.type === "up") return void showAttachView("root");
|
|
4983
|
+
if (row.type === "into") return void showAttachView(row.kind);
|
|
4984
|
+
if (row.type === "offer-ref") {
|
|
4985
|
+
/**
|
|
4986
|
+
* The same thing, named the same way, whichever menu it was picked from.
|
|
4987
|
+
*
|
|
4988
|
+
* A skill picked with @ lands in the sentence; picked from here it used to
|
|
4989
|
+
* become a chip floating above the box instead — two representations of
|
|
4990
|
+
* one idea, and the user has to learn which menu produces which. A
|
|
4991
|
+
* connector keeps the chip: it is not something anybody writes into a
|
|
4992
|
+
* sentence, it is a capability this message is sent with.
|
|
4993
|
+
*/
|
|
4994
|
+
if (row.ref.kind === "skill" || row.ref.kind === "plugin") return void mentionAtCaret(row.ref.value);
|
|
4995
|
+
return void toggleRef(row.ref);
|
|
4996
|
+
}
|
|
4997
|
+
if (row.type === "manage") {
|
|
4998
|
+
closeAttachMenu();
|
|
4999
|
+
return void openMarket(row.kind);
|
|
5000
|
+
}
|
|
4443
5001
|
if (row.type === "pick") return void pickFromDisk(row.kind);
|
|
4444
5002
|
if (row.type === "market") {
|
|
4445
5003
|
closeAttachMenu();
|
|
@@ -4449,7 +5007,10 @@ function chooseAttachRow(row) {
|
|
|
4449
5007
|
closeAttachMenu();
|
|
4450
5008
|
return void fileInput.click();
|
|
4451
5009
|
}
|
|
4452
|
-
if (
|
|
5010
|
+
if (row.type === "command") return void insertCommand(row.command);
|
|
5011
|
+
if (attachView === "mention" && row.ref) {
|
|
5012
|
+
return void insertMention(row.ref.value);
|
|
5013
|
+
}
|
|
4453
5014
|
toggleRef(row.ref);
|
|
4454
5015
|
}
|
|
4455
5016
|
|
|
@@ -4548,14 +5109,19 @@ const marketDialog = document.getElementById("market");
|
|
|
4548
5109
|
const marketList = document.getElementById("mk-list");
|
|
4549
5110
|
let marketKind = "connectors";
|
|
4550
5111
|
|
|
5112
|
+
const marketSearch = document.getElementById("mk-search");
|
|
5113
|
+
marketSearch.addEventListener("input", () => paintMarket());
|
|
5114
|
+
|
|
4551
5115
|
async function openMarket(kind) {
|
|
4552
5116
|
marketKind = kind;
|
|
4553
|
-
|
|
4554
|
-
|
|
4555
|
-
|
|
4556
|
-
|
|
4557
|
-
|
|
4558
|
-
|
|
5117
|
+
// Cleared on open: a query left over from the last screen would hide most of
|
|
5118
|
+
// this one and look like an empty catalogue.
|
|
5119
|
+
marketSearch.value = "";
|
|
5120
|
+
marketSearch.placeholder = t("menu.search");
|
|
5121
|
+
const titles = { plugins: "add.plugins", skills: "add.skills", connectors: "add.connectors" };
|
|
5122
|
+
const notes = { plugins: "market.pluginsNote", skills: "market.skillsNote", connectors: "market.connectorsNote" };
|
|
5123
|
+
document.getElementById("mk-title").textContent = t(titles[kind] || titles.connectors);
|
|
5124
|
+
document.getElementById("mk-note").textContent = t(notes[kind] || notes.connectors);
|
|
4559
5125
|
marketList.replaceChildren(el("div", "mk-desc", t("market.loading")));
|
|
4560
5126
|
marketDialog.showModal();
|
|
4561
5127
|
|
|
@@ -4570,7 +5136,17 @@ async function openMarket(kind) {
|
|
|
4570
5136
|
|
|
4571
5137
|
function paintMarket() {
|
|
4572
5138
|
marketList.replaceChildren();
|
|
4573
|
-
const
|
|
5139
|
+
const query = (marketSearch.value || "").trim().toLowerCase();
|
|
5140
|
+
const hit = (entry) =>
|
|
5141
|
+
!query ||
|
|
5142
|
+
(entry.name || "").toLowerCase().includes(query) ||
|
|
5143
|
+
(entry.description || "").toLowerCase().includes(query) ||
|
|
5144
|
+
(entry.label || "").toLowerCase().includes(query);
|
|
5145
|
+
const installed = (
|
|
5146
|
+
marketKind === "plugins" ? offer.plugins || []
|
|
5147
|
+
: marketKind === "skills" ? offer.skills || []
|
|
5148
|
+
: offer.connectors || []
|
|
5149
|
+
).filter(hit);
|
|
4574
5150
|
|
|
4575
5151
|
if (installed.length) {
|
|
4576
5152
|
marketList.appendChild(el("div", "mk-group", t("market.installed")));
|
|
@@ -4581,7 +5157,7 @@ function paintMarket() {
|
|
|
4581
5157
|
|
|
4582
5158
|
// Only connectors have somewhere to come from yet. Saying so beats an
|
|
4583
5159
|
// "Available" heading over an empty space that reads as a failed load.
|
|
4584
|
-
const available = marketKind === "connectors" ? market.filter((one) => !one.installed) : [];
|
|
5160
|
+
const available = marketKind === "connectors" ? market.filter((one) => !one.installed && hit(one)) : [];
|
|
4585
5161
|
if (available.length) {
|
|
4586
5162
|
marketList.appendChild(el("div", "mk-group", t("market.available")));
|
|
4587
5163
|
const grid = el("div", "mk-grid");
|
|
@@ -4621,14 +5197,20 @@ function connectorMark(id, label) {
|
|
|
4621
5197
|
}
|
|
4622
5198
|
|
|
4623
5199
|
/**
|
|
4624
|
-
*
|
|
5200
|
+
* What kind of mark an image is.
|
|
4625
5201
|
*
|
|
4626
5202
|
* Measured rather than guessed, because there is no way to tell from a URL or
|
|
4627
5203
|
* a content type which logos are the black-on-transparent kind. The image is
|
|
4628
5204
|
* already decoded and comes from this origin, so a 32px sample of it is a few
|
|
4629
5205
|
* hundred microseconds and taints nothing.
|
|
5206
|
+
*
|
|
5207
|
+
* Two answers come out of the one sample. Filled is whether the mark brought
|
|
5208
|
+
* its own background — a tile with a glyph punched out of it, which owns every
|
|
5209
|
+
* pixel of its box. Invert is whether it is a dark shape on nothing, which in
|
|
5210
|
+
* the dark theme is a black shape on a black row.
|
|
4630
5211
|
*/
|
|
4631
|
-
function
|
|
5212
|
+
function markProfile(img) {
|
|
5213
|
+
const unknown = { filled: false, invert: false };
|
|
4632
5214
|
try {
|
|
4633
5215
|
const size = 32;
|
|
4634
5216
|
const canvas = document.createElement("canvas");
|
|
@@ -4648,27 +5230,57 @@ function isDarkFlatGlyph(img) {
|
|
|
4648
5230
|
if (hi - lo > 40) coloured++;
|
|
4649
5231
|
if (hi < 110) dark++;
|
|
4650
5232
|
}
|
|
4651
|
-
if (!opaque) return
|
|
5233
|
+
if (!opaque) return unknown;
|
|
4652
5234
|
|
|
4653
5235
|
// A mark that fills its whole box brought its own background, and whatever
|
|
4654
5236
|
// it drew on that background is its business.
|
|
4655
|
-
|
|
4656
|
-
|
|
5237
|
+
const filled = opaque / (size * size) > 0.75;
|
|
5238
|
+
if (filled) return { filled: true, invert: false };
|
|
5239
|
+
return { filled: false, invert: dark / opaque > 0.7 && coloured / opaque < 0.15 };
|
|
4657
5240
|
} catch {
|
|
4658
5241
|
// A canvas that will not read is not worth a broken icon.
|
|
4659
|
-
return
|
|
5242
|
+
return unknown;
|
|
4660
5243
|
}
|
|
4661
5244
|
}
|
|
4662
5245
|
|
|
5246
|
+
/** The half of the profile the connector list asks for. */
|
|
5247
|
+
function isDarkFlatGlyph(img) {
|
|
5248
|
+
return markProfile(img).invert;
|
|
5249
|
+
}
|
|
5250
|
+
|
|
4663
5251
|
/** One thing this harness already has: attachable, and removable if we added it. */
|
|
4664
5252
|
function installedRow(entry) {
|
|
4665
5253
|
const row = el("div", "mk-item");
|
|
4666
5254
|
const isPlugin = marketKind === "plugins";
|
|
4667
|
-
|
|
5255
|
+
const isSkill = marketKind === "skills";
|
|
5256
|
+
row.appendChild(isPlugin ? svgIcon("plugin") : isSkill ? svgIcon("skill") : connectorMark(entry.name, entry.name));
|
|
4668
5257
|
|
|
4669
5258
|
const naming = el("div", "mk-naming");
|
|
4670
|
-
|
|
4671
|
-
|
|
5259
|
+
const heading = el("div", "mk-name", entry.name);
|
|
5260
|
+
/**
|
|
5261
|
+
* Where it came from, when that is not "this machine".
|
|
5262
|
+
*
|
|
5263
|
+
* A list of eighty skills is mostly things somebody installed, and the four
|
|
5264
|
+
* that ship here look exactly like them — which matters the moment one
|
|
5265
|
+
* misbehaves and the question is whose it is. Runtime plugins get the same
|
|
5266
|
+
* treatment for the same reason: memory and media are ours, and anything
|
|
5267
|
+
* beside them was configured.
|
|
5268
|
+
*/
|
|
5269
|
+
// A runtime plugin says so in its source; a skill carries its own flag. Reading
|
|
5270
|
+
// only one of them is how the two that ship here — memory and the media
|
|
5271
|
+
// plugin — ended up as the only rows in the panel with no provenance at all.
|
|
5272
|
+
const tag =
|
|
5273
|
+
entry.builtin || entry.source === "builtin" ? "built-in"
|
|
5274
|
+
: entry.source === "runtime" ? "runtime"
|
|
5275
|
+
: "";
|
|
5276
|
+
if (tag) heading.appendChild(el("span", "mk-tag", tag));
|
|
5277
|
+
naming.appendChild(heading);
|
|
5278
|
+
// A skill has nothing to connect to and nothing to be offline from, so the
|
|
5279
|
+
// slot that carries a connector's state carries what the skill is for —
|
|
5280
|
+
// which is also the sentence the model decides on.
|
|
5281
|
+
const detail = isSkill
|
|
5282
|
+
? entry.description || entry.source
|
|
5283
|
+
: isPlugin
|
|
4672
5284
|
? entry.description || entry.source
|
|
4673
5285
|
: entry.connected
|
|
4674
5286
|
? t("add.tools", { n: entry.tools })
|
|
@@ -4690,7 +5302,7 @@ function installedRow(entry) {
|
|
|
4690
5302
|
const said = idle === 1 ? t("market.serverIdle") : t("market.serversIdle", { n: idle });
|
|
4691
5303
|
acts.appendChild(el("span", "mk-state", said));
|
|
4692
5304
|
}
|
|
4693
|
-
const ref = { kind: isPlugin ? "plugin" : "connector", value: entry.name, label: entry.name };
|
|
5305
|
+
const ref = { kind: isPlugin ? "plugin" : isSkill ? "skill" : "connector", value: entry.name, label: entry.name };
|
|
4694
5306
|
const attach = el("button", "mk-icon-btn", referenced(ref) ? "✓" : "+");
|
|
4695
5307
|
attach.type = "button";
|
|
4696
5308
|
attach.title = t("market.attach");
|
|
@@ -4702,7 +5314,7 @@ function installedRow(entry) {
|
|
|
4702
5314
|
|
|
4703
5315
|
// A connector that asked to be signed in to gets the one button that moves
|
|
4704
5316
|
// it forward, in front of the one that removes it.
|
|
4705
|
-
if (!isPlugin && entry.needsAuth) {
|
|
5317
|
+
if (!isPlugin && !isSkill && entry.needsAuth) {
|
|
4706
5318
|
const go = el(
|
|
4707
5319
|
"button",
|
|
4708
5320
|
"mk-btn go",
|
|
@@ -4714,7 +5326,9 @@ function installedRow(entry) {
|
|
|
4714
5326
|
acts.appendChild(go);
|
|
4715
5327
|
}
|
|
4716
5328
|
|
|
4717
|
-
|
|
5329
|
+
// Removal is a connector's alone: a plugin is a directory on disk and a
|
|
5330
|
+
// skill is a file in one, and neither is undone from a composer menu.
|
|
5331
|
+
if (!isPlugin && !isSkill) {
|
|
4718
5332
|
const drop = el("button", "mk-icon-btn drop", "×");
|
|
4719
5333
|
drop.type = "button";
|
|
4720
5334
|
drop.title = t("add.remove");
|
|
@@ -4766,7 +5380,7 @@ async function installConnector(id, remove) {
|
|
|
4766
5380
|
addNotice(body.error, "err");
|
|
4767
5381
|
return void paintMarket();
|
|
4768
5382
|
}
|
|
4769
|
-
if (body.connectors) offer = { connectors: body.connectors, plugins: body.plugins || [] };
|
|
5383
|
+
if (body.connectors) offer = { connectors: body.connectors, plugins: body.plugins || [], skills: body.skills || [] };
|
|
4770
5384
|
for (const entry of market) if (entry.id === id) entry.installed = remove !== true;
|
|
4771
5385
|
paintMarket();
|
|
4772
5386
|
}
|
|
@@ -4795,6 +5409,8 @@ async function authorizeConnector(id) {
|
|
|
4795
5409
|
addNotice(t("market.authOpened", { name: id }));
|
|
4796
5410
|
}
|
|
4797
5411
|
|
|
5412
|
+
document.getElementById("mk-close").appendChild(svgIcon("close"));
|
|
5413
|
+
document.getElementById("mk-close").setAttribute("aria-label", t("market.close"));
|
|
4798
5414
|
document.getElementById("mk-close").addEventListener("click", () => marketDialog.close());
|
|
4799
5415
|
|
|
4800
5416
|
async function loadMentionFiles() {
|
|
@@ -4848,12 +5464,33 @@ function openAttachMenu() {
|
|
|
4848
5464
|
// projects changes what is connected, and a connector can drop between two
|
|
4849
5465
|
// messages.
|
|
4850
5466
|
void getJson("/context").then((body) => {
|
|
4851
|
-
if (body.error
|
|
5467
|
+
if (body.error) return;
|
|
5468
|
+
offerLoaded = true;
|
|
5469
|
+
if (attachMenu.hidden) return;
|
|
4852
5470
|
offer = body;
|
|
4853
5471
|
paintAttachMenu();
|
|
4854
5472
|
});
|
|
4855
5473
|
}
|
|
4856
5474
|
|
|
5475
|
+
/**
|
|
5476
|
+
* The same list, for a mention that was never opened from the + button.
|
|
5477
|
+
*
|
|
5478
|
+
* The + menu refreshes on every open because a connector can drop between two
|
|
5479
|
+
* messages. A mention cannot do that: it reopens on every keystroke, and a
|
|
5480
|
+
* fetch per character is a fetch per character. So it asks once, and a skill
|
|
5481
|
+
* list that is a few minutes stale is the right trade against a skill list
|
|
5482
|
+
* that is empty because nobody happened to click the +.
|
|
5483
|
+
*/
|
|
5484
|
+
function ensureOffer() {
|
|
5485
|
+
if (offerLoaded) return;
|
|
5486
|
+
offerLoaded = true;
|
|
5487
|
+
void getJson("/context").then((body) => {
|
|
5488
|
+
if (body.error) return;
|
|
5489
|
+
offer = body;
|
|
5490
|
+
if (attachView === "mention") paintAttachMenu();
|
|
5491
|
+
});
|
|
5492
|
+
}
|
|
5493
|
+
|
|
4857
5494
|
/**
|
|
4858
5495
|
* Arrow keys and Enter over the rows, shared by the menu and the mention list.
|
|
4859
5496
|
*
|
|
@@ -4865,13 +5502,21 @@ function moveAttachCursor(e) {
|
|
|
4865
5502
|
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
4866
5503
|
e.preventDefault();
|
|
4867
5504
|
if (!rows.length) return true;
|
|
4868
|
-
|
|
5505
|
+
const step = e.key === "ArrowDown" ? 1 : -1;
|
|
5506
|
+
// Past the headings rather than onto them: a heading is not a choice, and
|
|
5507
|
+
// a cursor resting on one is a keypress that appears to do nothing.
|
|
5508
|
+
let next = attachCursor;
|
|
5509
|
+
for (let i = 0; i < rows.length; i++) {
|
|
5510
|
+
next = (next + step + rows.length) % rows.length;
|
|
5511
|
+
if (rows[next].type !== "group") break;
|
|
5512
|
+
}
|
|
5513
|
+
attachCursor = next;
|
|
4869
5514
|
paintAttachMenu();
|
|
4870
5515
|
const active = attachBody.querySelector(".cursor");
|
|
4871
5516
|
if (active) active.scrollIntoView({ block: "nearest" });
|
|
4872
5517
|
return true;
|
|
4873
5518
|
}
|
|
4874
|
-
if (e.key === "Enter" || (e.key === "Tab" && attachView === "mention")) {
|
|
5519
|
+
if (e.key === "Enter" || (e.key === "Tab" && (attachView === "mention" || attachView === "command"))) {
|
|
4875
5520
|
e.preventDefault();
|
|
4876
5521
|
const row = rows[attachCursor];
|
|
4877
5522
|
if (row) chooseAttachRow(row);
|
|
@@ -4880,6 +5525,231 @@ function moveAttachCursor(e) {
|
|
|
4880
5525
|
return false;
|
|
4881
5526
|
}
|
|
4882
5527
|
|
|
5528
|
+
// --- the chip behind a mention ---------------------------------------------
|
|
5529
|
+
|
|
5530
|
+
const inputMirror = document.getElementById("input-mirror");
|
|
5531
|
+
|
|
5532
|
+
/** Every name an @ can stand for. */
|
|
5533
|
+
function mentionNames() {
|
|
5534
|
+
return new Map([
|
|
5535
|
+
...(offer.skills || []).map((skill) => [skill.name, "skill"]),
|
|
5536
|
+
...(offer.plugins || []).map((plugin) => [plugin.name, "plugin"]),
|
|
5537
|
+
]);
|
|
5538
|
+
}
|
|
5539
|
+
|
|
5540
|
+
/**
|
|
5541
|
+
* Backspace takes the whole mention, not a letter of it.
|
|
5542
|
+
*
|
|
5543
|
+
* The chip is drawn as one object and behaves as one everywhere else — it is
|
|
5544
|
+
* inserted whole and sent whole — so deleting it a character at a time leaves
|
|
5545
|
+
* a trail of half-names that are no longer anything, each of them still
|
|
5546
|
+
* looking like a word the harness understood until the wash disappears. Only
|
|
5547
|
+
* when the caret is against it and nothing is selected: a selection is the
|
|
5548
|
+
* user being specific, and editing inside a name is allowed, it just is not
|
|
5549
|
+
* what a keystroke at the edge means.
|
|
5550
|
+
*/
|
|
5551
|
+
input.addEventListener("keydown", (e) => {
|
|
5552
|
+
if (e.key !== "Backspace" && e.key !== "Delete") return;
|
|
5553
|
+
if (input.selectionStart !== input.selectionEnd) return;
|
|
5554
|
+
const caret = input.selectionStart;
|
|
5555
|
+
const names = mentionNames();
|
|
5556
|
+
if (!names.size) return;
|
|
5557
|
+
|
|
5558
|
+
let from = caret;
|
|
5559
|
+
let to = caret;
|
|
5560
|
+
if (e.key === "Backspace") {
|
|
5561
|
+
const hit = /(^|\s)@([^\s@]+)$/.exec(input.value.slice(0, caret));
|
|
5562
|
+
if (!hit || !names.has(hit[2])) return;
|
|
5563
|
+
from = caret - hit[2].length - 1;
|
|
5564
|
+
} else {
|
|
5565
|
+
const after = input.value.slice(caret);
|
|
5566
|
+
const hit = /^@([^\s@]+)/.exec(after);
|
|
5567
|
+
// Forward-delete only from the start of one: mid-name it is an ordinary
|
|
5568
|
+
// character, and the caret is visibly inside a word.
|
|
5569
|
+
const start = caret === 0 || /\s/.test(input.value[caret - 1] ?? "");
|
|
5570
|
+
if (!hit || !start || !names.has(hit[1])) return;
|
|
5571
|
+
to = caret + hit[1].length + 1;
|
|
5572
|
+
}
|
|
5573
|
+
|
|
5574
|
+
e.preventDefault();
|
|
5575
|
+
input.value = input.value.slice(0, from) + input.value.slice(to);
|
|
5576
|
+
input.setSelectionRange(from, from);
|
|
5577
|
+
paintMirror();
|
|
5578
|
+
// The box may now fit in fewer lines, and the grower only runs on input.
|
|
5579
|
+
input.style.height = "auto";
|
|
5580
|
+
input.style.height = Math.min(Math.max(input.scrollHeight, 56), window.innerHeight * 0.32) + "px";
|
|
5581
|
+
});
|
|
5582
|
+
|
|
5583
|
+
/**
|
|
5584
|
+
* Paint the backdrop: the same text, with the mentions in it marked.
|
|
5585
|
+
*
|
|
5586
|
+
* Only names that are actually a skill or a plugin are marked. Highlighting
|
|
5587
|
+
* any @word would promise something about an email address and a typo, and
|
|
5588
|
+
* the promise this makes — that the harness knows what was named — is exactly
|
|
5589
|
+
* what a reader is checking when they glance at it.
|
|
5590
|
+
*/
|
|
5591
|
+
function paintMirror() {
|
|
5592
|
+
if (!inputMirror) return;
|
|
5593
|
+
// Typing an @ asks for the list, but text can also arrive whole — pasted, or
|
|
5594
|
+
// restored as a draft — and that text has mentions in it too. Guarded, so
|
|
5595
|
+
// this is one request in the life of the page rather than one per keystroke.
|
|
5596
|
+
ensureOffer();
|
|
5597
|
+
const names = mentionNames();
|
|
5598
|
+
inputMirror.replaceChildren();
|
|
5599
|
+
if (!names.size) return void (inputMirror.textContent = input.value);
|
|
5600
|
+
|
|
5601
|
+
const text = input.value;
|
|
5602
|
+
let at = 0;
|
|
5603
|
+
// Word-initial, matching what the mention parser accepts, so what lights up
|
|
5604
|
+
// is what the menu would have offered.
|
|
5605
|
+
const pattern = /(^|\s)@([^\s@]+)/g;
|
|
5606
|
+
for (let hit = pattern.exec(text); hit; hit = pattern.exec(text)) {
|
|
5607
|
+
const start = hit.index + hit[1].length;
|
|
5608
|
+
if (!names.has(hit[2])) continue;
|
|
5609
|
+
inputMirror.appendChild(document.createTextNode(text.slice(at, start)));
|
|
5610
|
+
const mark = el("mark", "");
|
|
5611
|
+
const slot = el("span", "at");
|
|
5612
|
+
slot.appendChild(svgIcon(names.get(hit[2])));
|
|
5613
|
+
mark.appendChild(slot);
|
|
5614
|
+
mark.appendChild(document.createTextNode(hit[2]));
|
|
5615
|
+
inputMirror.appendChild(mark);
|
|
5616
|
+
at = start + hit[2].length + 1;
|
|
5617
|
+
}
|
|
5618
|
+
inputMirror.appendChild(document.createTextNode(text.slice(at)));
|
|
5619
|
+
if (inputMirror.querySelector("mark")) inputMirror.style.setProperty("--at-w", atWidth() + "px");
|
|
5620
|
+
}
|
|
5621
|
+
|
|
5622
|
+
/** How wide the field draws an @ sign: the slot the icon has to fit in. */
|
|
5623
|
+
function atWidth() {
|
|
5624
|
+
const probe = document.createElement("canvas").getContext("2d");
|
|
5625
|
+
probe.font = getComputedStyle(input).font;
|
|
5626
|
+
return probe.measureText("@").width;
|
|
5627
|
+
}
|
|
5628
|
+
|
|
5629
|
+
// The mirror has no scrollbar of its own; the text it holds is the text the
|
|
5630
|
+
// textarea is scrolling.
|
|
5631
|
+
input.addEventListener("scroll", () => {
|
|
5632
|
+
if (inputMirror) inputMirror.scrollTop = input.scrollTop;
|
|
5633
|
+
});
|
|
5634
|
+
|
|
5635
|
+
// --- / commands ------------------------------------------------------------
|
|
5636
|
+
|
|
5637
|
+
/**
|
|
5638
|
+
* What a slash does that the sidebar does not.
|
|
5639
|
+
*
|
|
5640
|
+
* The palette on Cmd-K is navigation: go somewhere, open something. These are
|
|
5641
|
+
* the other half — act on the session in front of you — and they are here
|
|
5642
|
+
* because that half had no surface at all. A command that only opens a panel
|
|
5643
|
+
* belongs in the palette, not in this list; two ways to reach the same screen
|
|
5644
|
+
* is how a user ends up unsure which one has the thing they want.
|
|
5645
|
+
*
|
|
5646
|
+
* Each takes the rest of the line as its argument, which is why the menu
|
|
5647
|
+
* inserts a trailing space: the next thing typed is the argument, not a
|
|
5648
|
+
* search for another command.
|
|
5649
|
+
*/
|
|
5650
|
+
const COMMANDS = [
|
|
5651
|
+
{
|
|
5652
|
+
name: "model",
|
|
5653
|
+
arg: "id",
|
|
5654
|
+
hint: () => t("cmd.model"),
|
|
5655
|
+
run: (arg) => {
|
|
5656
|
+
if (!arg) return void document.querySelector("#model-picker .picker-btn")?.click();
|
|
5657
|
+
return void api("/model", { model: arg }).then(pushNotice);
|
|
5658
|
+
},
|
|
5659
|
+
},
|
|
5660
|
+
{
|
|
5661
|
+
name: "mode",
|
|
5662
|
+
arg: "mode",
|
|
5663
|
+
hint: () => t("cmd.mode"),
|
|
5664
|
+
run: (arg) => {
|
|
5665
|
+
if (!arg) return void document.querySelector("#mode-picker .picker-btn")?.click();
|
|
5666
|
+
return void api("/mode", { mode: arg }).then(pushNotice);
|
|
5667
|
+
},
|
|
5668
|
+
},
|
|
5669
|
+
{
|
|
5670
|
+
name: "rename",
|
|
5671
|
+
arg: "title",
|
|
5672
|
+
hint: () => t("cmd.rename"),
|
|
5673
|
+
run: (arg) => {
|
|
5674
|
+
if (!arg) return void addNotice(t("cmd.needsTitle"), "notice err");
|
|
5675
|
+
return void api("/rename", { id: activeId, title: arg }).then(pushNotice);
|
|
5676
|
+
},
|
|
5677
|
+
},
|
|
5678
|
+
{
|
|
5679
|
+
name: "schedule",
|
|
5680
|
+
arg: "name",
|
|
5681
|
+
hint: () => t("cmd.schedule"),
|
|
5682
|
+
run: (arg) => {
|
|
5683
|
+
// The panel manages routines; this runs one without leaving the
|
|
5684
|
+
// composer. Creating one from here would need somewhere to keep it,
|
|
5685
|
+
// which is the panel's job and not a line of text's.
|
|
5686
|
+
if (!arg) return void document.getElementById("nav-routines")?.click();
|
|
5687
|
+
return void api("/routine", { name: arg }).then(pushNotice);
|
|
5688
|
+
},
|
|
5689
|
+
},
|
|
5690
|
+
{
|
|
5691
|
+
name: "new",
|
|
5692
|
+
arg: "",
|
|
5693
|
+
hint: () => t("cmd.new"),
|
|
5694
|
+
run: () => void switchTo({ action: "new" }),
|
|
5695
|
+
},
|
|
5696
|
+
];
|
|
5697
|
+
|
|
5698
|
+
function pushNotice(body) {
|
|
5699
|
+
if (body && body.error) addNotice(body.error, "notice err");
|
|
5700
|
+
}
|
|
5701
|
+
|
|
5702
|
+
/** The /word being typed, or null. Only ever the first word of the box. */
|
|
5703
|
+
function commandUnderCaret() {
|
|
5704
|
+
// Anchored at the start, and only while the first word is still being
|
|
5705
|
+
// typed: a path is full of slashes and "and/or" is a word, and neither is
|
|
5706
|
+
// an attempt to run something.
|
|
5707
|
+
const before = input.value.slice(0, input.selectionStart);
|
|
5708
|
+
const match = /^\/([a-z-]*)$/i.exec(before);
|
|
5709
|
+
return match ? { query: match[1] } : null;
|
|
5710
|
+
}
|
|
5711
|
+
|
|
5712
|
+
function openCommands(command) {
|
|
5713
|
+
ensureOffer();
|
|
5714
|
+
attachView = "command";
|
|
5715
|
+
attachQuery = command.query.trim().toLowerCase();
|
|
5716
|
+
attachCursor = 0;
|
|
5717
|
+
attachMenu.hidden = false;
|
|
5718
|
+
attachPicker.classList.add("open");
|
|
5719
|
+
const bar = attachPicker.getBoundingClientRect().top;
|
|
5720
|
+
const box = composerBox.getBoundingClientRect().top;
|
|
5721
|
+
attachMenu.style.bottom = "calc(100% + " + Math.round(bar - box + 14) + "px)";
|
|
5722
|
+
paintAttachMenu();
|
|
5723
|
+
}
|
|
5724
|
+
|
|
5725
|
+
/** Insert the command, with the space its argument starts after. */
|
|
5726
|
+
function insertCommand(name) {
|
|
5727
|
+
const rest = input.value.slice(input.selectionStart);
|
|
5728
|
+
input.value = "/" + name + " " + rest.replace(/^\s+/, "");
|
|
5729
|
+
paintMirror();
|
|
5730
|
+
closeAttachMenu();
|
|
5731
|
+
input.focus();
|
|
5732
|
+
const at = name.length + 2;
|
|
5733
|
+
input.setSelectionRange(at, at);
|
|
5734
|
+
}
|
|
5735
|
+
|
|
5736
|
+
/**
|
|
5737
|
+
* Run what was typed, or say it is not a command.
|
|
5738
|
+
*
|
|
5739
|
+
* Returns whether the line was handled, so the submit path can tell a command
|
|
5740
|
+
* from a prompt. An unknown slash word is a prompt: people write /etc and
|
|
5741
|
+
* and/or, and refusing to send those would be the feature getting in the way
|
|
5742
|
+
* of the thing it was added to.
|
|
5743
|
+
*/
|
|
5744
|
+
function runCommand(text) {
|
|
5745
|
+
const match = /^\/([a-z-]+)(?:\s+([\s\S]*))?$/i.exec(text);
|
|
5746
|
+
if (!match) return false;
|
|
5747
|
+
const command = COMMANDS.find((one) => one.name === match[1].toLowerCase());
|
|
5748
|
+
if (!command) return false;
|
|
5749
|
+
command.run((match[2] || "").trim());
|
|
5750
|
+
return true;
|
|
5751
|
+
}
|
|
5752
|
+
|
|
4883
5753
|
// --- @ mentions ------------------------------------------------------------
|
|
4884
5754
|
|
|
4885
5755
|
/** The @word being typed at the caret, or null. */
|
|
@@ -4893,6 +5763,7 @@ function mentionUnderCaret() {
|
|
|
4893
5763
|
}
|
|
4894
5764
|
|
|
4895
5765
|
function openMention(mention) {
|
|
5766
|
+
ensureOffer();
|
|
4896
5767
|
mentionAt = mention.at;
|
|
4897
5768
|
attachView = "mention";
|
|
4898
5769
|
attachQuery = mention.query.trim();
|
|
@@ -4909,10 +5780,45 @@ function openMention(mention) {
|
|
|
4909
5780
|
searchMentionFiles();
|
|
4910
5781
|
}
|
|
4911
5782
|
|
|
5783
|
+
/** Write an @name into the box at the caret, from a menu that is not the @ one. */
|
|
5784
|
+
function mentionAtCaret(name) {
|
|
5785
|
+
const caret = input.selectionStart ?? input.value.length;
|
|
5786
|
+
const before = input.value.slice(0, caret);
|
|
5787
|
+
const head = roomBefore(before) + "@" + name + " ";
|
|
5788
|
+
input.value = head + input.value.slice(caret);
|
|
5789
|
+
closeAttachMenu();
|
|
5790
|
+
input.focus();
|
|
5791
|
+
input.setSelectionRange(head.length, head.length);
|
|
5792
|
+
paintMirror();
|
|
5793
|
+
}
|
|
5794
|
+
|
|
5795
|
+
/**
|
|
5796
|
+
* Two spaces on either side of a mention, not one.
|
|
5797
|
+
*
|
|
5798
|
+
* The chip's horizontal padding is taken out of the whitespace next to it,
|
|
5799
|
+
* because the field decides where every glyph lands and the mirror cannot add
|
|
5800
|
+
* width of its own. One space is four pixels: enough for the wash to clear
|
|
5801
|
+
* the letters, not enough for the chip to read as an object with room around
|
|
5802
|
+
* it. A second space gives four pixels of padding and four more between the
|
|
5803
|
+
* chip and the next word; three between two chips, which each take four of
|
|
5804
|
+
* them. The model sees the extra spaces, which it reads the same as one.
|
|
5805
|
+
*/
|
|
5806
|
+
function roomBefore(before) {
|
|
5807
|
+
if (!before || /\n$/.test(before)) return before;
|
|
5808
|
+
const bare = before.replace(/ +$/, "");
|
|
5809
|
+
// Two chips in a row each take four pixels of the gap between them, so
|
|
5810
|
+
// two spaces would leave them touching. A third keeps the same four pixels
|
|
5811
|
+
// of daylight a chip has from a word.
|
|
5812
|
+
const last = /@([^\s@]+)$/.exec(bare);
|
|
5813
|
+
const afterChip = last && mentionNames().has(last[1]);
|
|
5814
|
+
return bare + (afterChip ? " " : " ");
|
|
5815
|
+
}
|
|
5816
|
+
|
|
4912
5817
|
function insertMention(path) {
|
|
4913
5818
|
const caret = input.selectionStart;
|
|
4914
|
-
const head = input.value.slice(0, mentionAt) + "@" + path + "
|
|
5819
|
+
const head = roomBefore(input.value.slice(0, mentionAt)) + "@" + path + " ";
|
|
4915
5820
|
input.value = head + input.value.slice(caret);
|
|
5821
|
+
paintMirror();
|
|
4916
5822
|
closeAttachMenu();
|
|
4917
5823
|
input.focus();
|
|
4918
5824
|
input.setSelectionRange(head.length, head.length);
|
|
@@ -4922,9 +5828,11 @@ function insertMention(path) {
|
|
|
4922
5828
|
}
|
|
4923
5829
|
|
|
4924
5830
|
input.addEventListener("input", () => {
|
|
5831
|
+
const command = commandUnderCaret();
|
|
5832
|
+
if (command) return void openCommands(command);
|
|
4925
5833
|
const mention = mentionUnderCaret();
|
|
4926
5834
|
if (mention) return void openMention(mention);
|
|
4927
|
-
if (attachView === "mention") closeAttachMenu();
|
|
5835
|
+
if (attachView === "mention" || attachView === "command") closeAttachMenu();
|
|
4928
5836
|
});
|
|
4929
5837
|
|
|
4930
5838
|
// Before the composer's own Enter handling, which would otherwise send the
|
|
@@ -4960,6 +5868,14 @@ document.addEventListener("keydown", (e) => {
|
|
|
4960
5868
|
document.getElementById("composer").addEventListener("submit", (e) => {
|
|
4961
5869
|
e.preventDefault();
|
|
4962
5870
|
const text = input.value.trim();
|
|
5871
|
+
// A command runs instead of being sent. Unknown slash words fall through and
|
|
5872
|
+
// are sent as prose, because /etc and and/or are things people write.
|
|
5873
|
+
if (runCommand(text)) {
|
|
5874
|
+
input.value = "";
|
|
5875
|
+
input.style.height = "auto";
|
|
5876
|
+
closeAttachMenu();
|
|
5877
|
+
return;
|
|
5878
|
+
}
|
|
4963
5879
|
if (!text && !staged.length && !refs.length) return;
|
|
4964
5880
|
const ids = staged.map((file) => file.id);
|
|
4965
5881
|
const shots = staged
|
|
@@ -4969,6 +5885,7 @@ document.getElementById("composer").addEventListener("submit", (e) => {
|
|
|
4969
5885
|
const referenced = refs.map((ref) => ({ kind: ref.kind, value: ref.value }));
|
|
4970
5886
|
input.value = "";
|
|
4971
5887
|
input.style.height = "auto";
|
|
5888
|
+
paintMirror();
|
|
4972
5889
|
staged = [];
|
|
4973
5890
|
refs = [];
|
|
4974
5891
|
renderAttachments();
|
|
@@ -4995,8 +5912,12 @@ input.addEventListener("input", () => {
|
|
|
4995
5912
|
input.style.height = "auto";
|
|
4996
5913
|
const wanted = Math.max(input.scrollHeight, 56);
|
|
4997
5914
|
input.style.height = Math.min(wanted, window.innerHeight * 0.32) + "px";
|
|
5915
|
+
paintMirror();
|
|
4998
5916
|
});
|
|
4999
5917
|
|
|
5918
|
+
// --- the mention chip under the text ------------------------------------
|
|
5919
|
+
|
|
5920
|
+
|
|
5000
5921
|
stopBtn.addEventListener("click", () => void api("/interrupt", { session: activeId }));
|
|
5001
5922
|
// Straight into a session, in the tree already open. Where it runs is asked on
|
|
5002
5923
|
// the composer instead of on the way in: the project on screen is the answer
|
|
@@ -5860,6 +6781,10 @@ async function showModels() {
|
|
|
5860
6781
|
|
|
5861
6782
|
const top = el("summary", "row-head");
|
|
5862
6783
|
top.appendChild(el("span", "chev"));
|
|
6784
|
+
// The same mark the model menu puts on every row. This panel is the other
|
|
6785
|
+
// place the full list of vendors is read, and reading it by name alone is
|
|
6786
|
+
// the thing the mark was added to fix.
|
|
6787
|
+
top.appendChild(providerMark(provider.id, provider.label || provider.id));
|
|
5863
6788
|
|
|
5864
6789
|
const naming = el("div", "naming");
|
|
5865
6790
|
const line = el("div", "name-line");
|
|
@@ -7269,6 +8194,7 @@ function openProviderPicker(rest, byProvider) {
|
|
|
7269
8194
|
|
|
7270
8195
|
const head = el("summary", "pp-item");
|
|
7271
8196
|
head.appendChild(el("span", "chev"));
|
|
8197
|
+
head.appendChild(providerMark(provider.id, provider.label || provider.id));
|
|
7272
8198
|
const naming = el("div", "naming");
|
|
7273
8199
|
naming.appendChild(el("div", "pp-name", provider.label || provider.id));
|
|
7274
8200
|
if (provider.description) naming.appendChild(el("div", "row-desc", provider.description));
|
|
@@ -9180,6 +10106,7 @@ void (async () => {
|
|
|
9180
10106
|
for (const button of document.querySelectorAll("#starters button")) {
|
|
9181
10107
|
button.addEventListener("click", () => {
|
|
9182
10108
|
input.value = button.dataset.p;
|
|
10109
|
+
paintMirror();
|
|
9183
10110
|
document.getElementById("composer").requestSubmit();
|
|
9184
10111
|
});
|
|
9185
10112
|
}
|
|
@@ -9306,7 +10233,14 @@ ${ANALYTICS_STUB}
|
|
|
9306
10233
|
<form id="composer">
|
|
9307
10234
|
<div class="composer" id="composer-box">
|
|
9308
10235
|
<div id="attachments"></div>
|
|
9309
|
-
|
|
10236
|
+
<!-- A textarea draws its own glyphs and cannot style a range of them, so
|
|
10237
|
+
the chip behind a mention is painted on a layer underneath that
|
|
10238
|
+
mirrors the same text in the same metrics. aria-hidden: it is the
|
|
10239
|
+
same string twice, and a screen reader should hear it once. -->
|
|
10240
|
+
<div class="input-wrap">
|
|
10241
|
+
<div id="input-mirror" aria-hidden="true"></div>
|
|
10242
|
+
<textarea id="input" rows="1" data-t-placeholder="composer.placeholder"></textarea>
|
|
10243
|
+
</div>
|
|
9310
10244
|
<div class="composer-bar">
|
|
9311
10245
|
<div class="picker" id="attach-picker">
|
|
9312
10246
|
<button id="attach" class="icon-btn" type="button" data-t-title="composer.attach"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M8 3.2v9.6M3.2 8h9.6"/></svg></button>
|
|
@@ -9393,9 +10327,10 @@ ${ANALYTICS_STUB}
|
|
|
9393
10327
|
<div class="mk-head">
|
|
9394
10328
|
<div id="mk-title" class="cn-title"></div>
|
|
9395
10329
|
<div id="mk-note" class="row-desc"></div>
|
|
10330
|
+
<input id="mk-search" class="mk-search" type="text" autocomplete="off" spellcheck="false">
|
|
10331
|
+
<button id="mk-close" class="mk-close" type="button"></button>
|
|
9396
10332
|
</div>
|
|
9397
10333
|
<div id="mk-list"></div>
|
|
9398
|
-
<div class="mk-foot"><button id="mk-close" class="ghost" type="button" data-t="market.close"></button></div>
|
|
9399
10334
|
</dialog>
|
|
9400
10335
|
|
|
9401
10336
|
<dialog id="provider-picker">
|