@oharness/server 0.3.0 → 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.js +46 -0
- package/dist/icons.js.map +1 -1
- package/dist/page.js +767 -43
- package/dist/page.js.map +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.js +28 -3
- 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)}
|
|
@@ -394,13 +414,22 @@ svg.vglyph{width:15px;height:15px;opacity:1;
|
|
|
394
414
|
/* On a card the row is a title over a line of description, so the mark is
|
|
395
415
|
sized against the block rather than against the type, and sits on the
|
|
396
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. */
|
|
397
421
|
details.provider>summary .vmark,details.provider>summary .vmono,details.provider>summary .vglyph,
|
|
398
422
|
.pp-item .vmark,.pp-item .vmono,.pp-item .vglyph{
|
|
399
|
-
width:20px;height:20px;border-radius:5px;font-size:11px;
|
|
423
|
+
width:20px;height:20px;border-radius:5px;font-size:11px;
|
|
400
424
|
}
|
|
401
425
|
details.provider>summary .vmark,.pp-item .vmark,
|
|
402
426
|
details.provider>summary .vglyph,.pp-item .vglyph{padding:2.5px}
|
|
403
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}
|
|
404
433
|
.menu-group{
|
|
405
434
|
display:flex;align-items:center;gap:7px;width:100%;border:0;background:transparent;
|
|
406
435
|
font:inherit;font-size:10.5px;color:var(--dim);padding:7px 8px 5px;cursor:pointer;
|
|
@@ -926,8 +955,17 @@ details.setting>summary:focus-visible{outline:2px solid var(--accent);outline-of
|
|
|
926
955
|
.section{background:var(--panel);border-radius:12px;overflow:hidden}
|
|
927
956
|
.section details.provider + details.provider{box-shadow:0 -1px 0 var(--line)}
|
|
928
957
|
details.provider{padding:0}
|
|
929
|
-
|
|
930
|
-
|
|
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;
|
|
931
969
|
}
|
|
932
970
|
details.provider>summary::-webkit-details-marker{display:none}
|
|
933
971
|
.naming{flex:1;min-width:0}
|
|
@@ -1116,9 +1154,16 @@ button.pw-plan:disabled{opacity:.4;cursor:default}
|
|
|
1116
1154
|
wrong for a list that wants two columns. */
|
|
1117
1155
|
#market{width:min(860px,94vw);max-width:min(860px,94vw);max-height:76vh}
|
|
1118
1156
|
#market[open]{display:flex;flex-direction:column}
|
|
1119
|
-
.mk-head{padding:18px 20px 10px}
|
|
1120
|
-
#mk-list{overflow-y:auto;padding:2px 12px
|
|
1121
|
-
.
|
|
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}
|
|
1122
1167
|
.mk-group{
|
|
1123
1168
|
font-size:10.5px;color:var(--dim);letter-spacing:.04em;text-transform:uppercase;
|
|
1124
1169
|
padding:12px 8px 6px;
|
|
@@ -1502,8 +1547,66 @@ textarea{
|
|
|
1502
1547
|
}
|
|
1503
1548
|
textarea:focus{outline:none}
|
|
1504
1549
|
textarea::placeholder{color:var(--dim)}
|
|
1505
|
-
|
|
1506
|
-
|
|
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. */
|
|
1507
1610
|
textarea.boxed{
|
|
1508
1611
|
border:1px solid var(--line);background:var(--field);border-radius:6px;
|
|
1509
1612
|
padding:7px 9px;resize:vertical;min-height:0;font:inherit;font-size:12px;
|
|
@@ -1540,8 +1643,32 @@ textarea.boxed:focus{border-color:var(--dim)}
|
|
|
1540
1643
|
/* Wider than the model picker's: these rows carry a name and the folder it
|
|
1541
1644
|
sits in, and truncating the folder away leaves three files called index.ts. */
|
|
1542
1645
|
#attach-picker .menu{min-width:min(380px,86vw)}
|
|
1543
|
-
#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}
|
|
1544
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}
|
|
1545
1672
|
#attach-picker .menu-item.on .ico-sm{opacity:.9}
|
|
1546
1673
|
.menu-hint{padding:6px 8px 8px;font-size:11.5px;color:var(--dim)}
|
|
1547
1674
|
/* A row that has something to say about itself: the name on one line and a
|
|
@@ -1769,6 +1896,21 @@ const ICON_PATHS = {
|
|
|
1769
1896
|
// Stacked plates, because a plugin arrives as a bundle of contributions
|
|
1770
1897
|
// rather than as one thing.
|
|
1771
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]],
|
|
1772
1914
|
// A shopfront: an awning over a counter, for the list of what is on offer.
|
|
1773
1915
|
market: ["M2 6.67h12", "M3.33 6.67 4.67 2.67h6.66l1.34 4", "M2.67 6.67v6.66h10.67V6.67"],
|
|
1774
1916
|
// An arrow pointing back the way the menu came in.
|
|
@@ -2081,6 +2223,27 @@ function providerMark(id, label, extra) {
|
|
|
2081
2223
|
return mono;
|
|
2082
2224
|
}
|
|
2083
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
|
+
|
|
2084
2247
|
function makePicker(id, onPick, extra) {
|
|
2085
2248
|
const root = document.getElementById(id);
|
|
2086
2249
|
const button = root.querySelector(".picker-btn");
|
|
@@ -2294,10 +2457,8 @@ function makePicker(id, onPick, extra) {
|
|
|
2294
2457
|
if (stale) stale.remove();
|
|
2295
2458
|
const mark = match && match.mark && match.mark("picker-mark");
|
|
2296
2459
|
if (mark) button.insertBefore(mark, label);
|
|
2297
|
-
// Discovered ids are namespaced and long; the tail is the part that
|
|
2298
|
-
// identifies the model, and the full id is still in the menu.
|
|
2299
2460
|
const full = match ? match.label : current || "—";
|
|
2300
|
-
label.textContent =
|
|
2461
|
+
label.textContent = shortModel(full);
|
|
2301
2462
|
label.title = full;
|
|
2302
2463
|
},
|
|
2303
2464
|
get value() {
|
|
@@ -4074,7 +4235,7 @@ events.onmessage = (message) => {
|
|
|
4074
4235
|
case "state": applyState(event.state); break;
|
|
4075
4236
|
case "connectors_changed":
|
|
4076
4237
|
if (authorizing === event.connector) authorizing = null;
|
|
4077
|
-
offer = { connectors: event.connectors, plugins: event.plugins || [] };
|
|
4238
|
+
offer = { connectors: event.connectors, plugins: event.plugins || [], skills: event.skills || [] };
|
|
4078
4239
|
// The failure already said what went wrong; this only announces the
|
|
4079
4240
|
// half that is worth celebrating.
|
|
4080
4241
|
if (event.authorized) addNotice(t("market.authorized", { name: event.connector }));
|
|
@@ -4369,7 +4530,7 @@ const sentPreviews = new Map();
|
|
|
4369
4530
|
* server turns the list into a short block above the prompt.
|
|
4370
4531
|
*/
|
|
4371
4532
|
let refs = [];
|
|
4372
|
-
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" };
|
|
4373
4534
|
const refKey = (ref) => ref.kind + ":" + ref.value;
|
|
4374
4535
|
|
|
4375
4536
|
function renderAttachments() {
|
|
@@ -4498,8 +4659,10 @@ attachMenu.replaceChildren(attachBody);
|
|
|
4498
4659
|
let attachView = "root";
|
|
4499
4660
|
/** The last answer from /files, for the mention list. */
|
|
4500
4661
|
let fileList = { entries: [] };
|
|
4662
|
+
/** Whether the context endpoint has answered once, for the mention list. */
|
|
4663
|
+
let offerLoaded = false;
|
|
4501
4664
|
/** Connectors and plugins, refreshed each time the menu opens. */
|
|
4502
|
-
let offer = { connectors: [], plugins: [] };
|
|
4665
|
+
let offer = { connectors: [], plugins: [], skills: [] };
|
|
4503
4666
|
/** The connectors on offer rather than the ones already here. */
|
|
4504
4667
|
let market = [];
|
|
4505
4668
|
/** The connector whose sign-in is open in a browser tab, if any. */
|
|
@@ -4536,18 +4699,174 @@ function addRef(kind, value) {
|
|
|
4536
4699
|
|
|
4537
4700
|
/** The rows on screen, in the order they are painted — what a key can land on. */
|
|
4538
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
|
+
}
|
|
4539
4771
|
if (attachView === "mention") {
|
|
4540
|
-
|
|
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) => ({
|
|
4541
4842
|
type: entry.dir ? "folder" : "file",
|
|
4542
4843
|
icon: entry.dir ? "folder" : "file",
|
|
4543
4844
|
label: entry.name,
|
|
4544
4845
|
sub: folderOf(entry.path),
|
|
4545
4846
|
ref: { kind: entry.dir ? "folder" : "file", value: entry.path, label: entry.name },
|
|
4546
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]);
|
|
4547
4865
|
}
|
|
4548
4866
|
|
|
4549
4867
|
const connectors = (offer.connectors || []).length;
|
|
4550
4868
|
const plugins = (offer.plugins || []).length;
|
|
4869
|
+
const skills = (offer.skills || []).length;
|
|
4551
4870
|
return [
|
|
4552
4871
|
// Two rows, because the dialogs behind them are two: only macOS has a
|
|
4553
4872
|
// panel that takes a file and a folder in one pass, and everywhere else a
|
|
@@ -4560,7 +4879,7 @@ function attachRows() {
|
|
|
4560
4879
|
// word in the slot that otherwise holds a number, and it reads as a value
|
|
4561
4880
|
// rather than as an absence.
|
|
4562
4881
|
{
|
|
4563
|
-
type: "
|
|
4882
|
+
type: "into",
|
|
4564
4883
|
kind: "connectors",
|
|
4565
4884
|
icon: "plug",
|
|
4566
4885
|
label: t("add.connectors"),
|
|
@@ -4568,13 +4887,21 @@ function attachRows() {
|
|
|
4568
4887
|
into: true,
|
|
4569
4888
|
},
|
|
4570
4889
|
{
|
|
4571
|
-
type: "
|
|
4890
|
+
type: "into",
|
|
4572
4891
|
kind: "plugins",
|
|
4573
4892
|
icon: "plugin",
|
|
4574
4893
|
label: t("add.plugins"),
|
|
4575
4894
|
sub: plugins ? String(plugins) : "",
|
|
4576
4895
|
into: true,
|
|
4577
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
|
+
},
|
|
4578
4905
|
{ type: "upload", icon: "upload", label: t("add.upload") },
|
|
4579
4906
|
];
|
|
4580
4907
|
}
|
|
@@ -4582,16 +4909,28 @@ function attachRows() {
|
|
|
4582
4909
|
function paintAttachMenu() {
|
|
4583
4910
|
const rows = attachRows();
|
|
4584
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
|
+
}
|
|
4585
4917
|
attachBody.replaceChildren();
|
|
4586
4918
|
|
|
4587
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
|
+
}
|
|
4588
4928
|
const item = el(
|
|
4589
4929
|
"div",
|
|
4590
4930
|
"menu-item" + (row.ref && referenced(row.ref) ? " on" : "") + (i === attachCursor ? " cursor" : ""),
|
|
4591
4931
|
);
|
|
4592
4932
|
item.setAttribute("role", "button");
|
|
4593
|
-
item.appendChild(
|
|
4594
|
-
item.appendChild(svgIcon(row.icon));
|
|
4933
|
+
item.appendChild(row.mark ? connectorMark(row.mark, row.label) : svgIcon(row.icon));
|
|
4595
4934
|
if (row.note) {
|
|
4596
4935
|
item.classList.add("stacked");
|
|
4597
4936
|
const stack = el("span", "stack");
|
|
@@ -4602,6 +4941,11 @@ function paintAttachMenu() {
|
|
|
4602
4941
|
item.appendChild(el("span", "menu-label", row.label));
|
|
4603
4942
|
}
|
|
4604
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", "✓"));
|
|
4605
4949
|
if (row.into) item.appendChild(el("span", "chev"));
|
|
4606
4950
|
// An installed connector is removed from the row that says it is
|
|
4607
4951
|
// installed, which is the row someone looking to remove it goes to.
|
|
@@ -4634,6 +4978,26 @@ function paintAttachMenu() {
|
|
|
4634
4978
|
}
|
|
4635
4979
|
|
|
4636
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
|
+
}
|
|
4637
5001
|
if (row.type === "pick") return void pickFromDisk(row.kind);
|
|
4638
5002
|
if (row.type === "market") {
|
|
4639
5003
|
closeAttachMenu();
|
|
@@ -4643,7 +5007,10 @@ function chooseAttachRow(row) {
|
|
|
4643
5007
|
closeAttachMenu();
|
|
4644
5008
|
return void fileInput.click();
|
|
4645
5009
|
}
|
|
4646
|
-
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
|
+
}
|
|
4647
5014
|
toggleRef(row.ref);
|
|
4648
5015
|
}
|
|
4649
5016
|
|
|
@@ -4742,14 +5109,19 @@ const marketDialog = document.getElementById("market");
|
|
|
4742
5109
|
const marketList = document.getElementById("mk-list");
|
|
4743
5110
|
let marketKind = "connectors";
|
|
4744
5111
|
|
|
5112
|
+
const marketSearch = document.getElementById("mk-search");
|
|
5113
|
+
marketSearch.addEventListener("input", () => paintMarket());
|
|
5114
|
+
|
|
4745
5115
|
async function openMarket(kind) {
|
|
4746
5116
|
marketKind = kind;
|
|
4747
|
-
|
|
4748
|
-
|
|
4749
|
-
|
|
4750
|
-
|
|
4751
|
-
|
|
4752
|
-
|
|
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);
|
|
4753
5125
|
marketList.replaceChildren(el("div", "mk-desc", t("market.loading")));
|
|
4754
5126
|
marketDialog.showModal();
|
|
4755
5127
|
|
|
@@ -4764,7 +5136,17 @@ async function openMarket(kind) {
|
|
|
4764
5136
|
|
|
4765
5137
|
function paintMarket() {
|
|
4766
5138
|
marketList.replaceChildren();
|
|
4767
|
-
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);
|
|
4768
5150
|
|
|
4769
5151
|
if (installed.length) {
|
|
4770
5152
|
marketList.appendChild(el("div", "mk-group", t("market.installed")));
|
|
@@ -4775,7 +5157,7 @@ function paintMarket() {
|
|
|
4775
5157
|
|
|
4776
5158
|
// Only connectors have somewhere to come from yet. Saying so beats an
|
|
4777
5159
|
// "Available" heading over an empty space that reads as a failed load.
|
|
4778
|
-
const available = marketKind === "connectors" ? market.filter((one) => !one.installed) : [];
|
|
5160
|
+
const available = marketKind === "connectors" ? market.filter((one) => !one.installed && hit(one)) : [];
|
|
4779
5161
|
if (available.length) {
|
|
4780
5162
|
marketList.appendChild(el("div", "mk-group", t("market.available")));
|
|
4781
5163
|
const grid = el("div", "mk-grid");
|
|
@@ -4870,11 +5252,35 @@ function isDarkFlatGlyph(img) {
|
|
|
4870
5252
|
function installedRow(entry) {
|
|
4871
5253
|
const row = el("div", "mk-item");
|
|
4872
5254
|
const isPlugin = marketKind === "plugins";
|
|
4873
|
-
|
|
5255
|
+
const isSkill = marketKind === "skills";
|
|
5256
|
+
row.appendChild(isPlugin ? svgIcon("plugin") : isSkill ? svgIcon("skill") : connectorMark(entry.name, entry.name));
|
|
4874
5257
|
|
|
4875
5258
|
const naming = el("div", "mk-naming");
|
|
4876
|
-
|
|
4877
|
-
|
|
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
|
|
4878
5284
|
? entry.description || entry.source
|
|
4879
5285
|
: entry.connected
|
|
4880
5286
|
? t("add.tools", { n: entry.tools })
|
|
@@ -4896,7 +5302,7 @@ function installedRow(entry) {
|
|
|
4896
5302
|
const said = idle === 1 ? t("market.serverIdle") : t("market.serversIdle", { n: idle });
|
|
4897
5303
|
acts.appendChild(el("span", "mk-state", said));
|
|
4898
5304
|
}
|
|
4899
|
-
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 };
|
|
4900
5306
|
const attach = el("button", "mk-icon-btn", referenced(ref) ? "✓" : "+");
|
|
4901
5307
|
attach.type = "button";
|
|
4902
5308
|
attach.title = t("market.attach");
|
|
@@ -4908,7 +5314,7 @@ function installedRow(entry) {
|
|
|
4908
5314
|
|
|
4909
5315
|
// A connector that asked to be signed in to gets the one button that moves
|
|
4910
5316
|
// it forward, in front of the one that removes it.
|
|
4911
|
-
if (!isPlugin && entry.needsAuth) {
|
|
5317
|
+
if (!isPlugin && !isSkill && entry.needsAuth) {
|
|
4912
5318
|
const go = el(
|
|
4913
5319
|
"button",
|
|
4914
5320
|
"mk-btn go",
|
|
@@ -4920,7 +5326,9 @@ function installedRow(entry) {
|
|
|
4920
5326
|
acts.appendChild(go);
|
|
4921
5327
|
}
|
|
4922
5328
|
|
|
4923
|
-
|
|
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) {
|
|
4924
5332
|
const drop = el("button", "mk-icon-btn drop", "×");
|
|
4925
5333
|
drop.type = "button";
|
|
4926
5334
|
drop.title = t("add.remove");
|
|
@@ -4972,7 +5380,7 @@ async function installConnector(id, remove) {
|
|
|
4972
5380
|
addNotice(body.error, "err");
|
|
4973
5381
|
return void paintMarket();
|
|
4974
5382
|
}
|
|
4975
|
-
if (body.connectors) offer = { connectors: body.connectors, plugins: body.plugins || [] };
|
|
5383
|
+
if (body.connectors) offer = { connectors: body.connectors, plugins: body.plugins || [], skills: body.skills || [] };
|
|
4976
5384
|
for (const entry of market) if (entry.id === id) entry.installed = remove !== true;
|
|
4977
5385
|
paintMarket();
|
|
4978
5386
|
}
|
|
@@ -5001,6 +5409,8 @@ async function authorizeConnector(id) {
|
|
|
5001
5409
|
addNotice(t("market.authOpened", { name: id }));
|
|
5002
5410
|
}
|
|
5003
5411
|
|
|
5412
|
+
document.getElementById("mk-close").appendChild(svgIcon("close"));
|
|
5413
|
+
document.getElementById("mk-close").setAttribute("aria-label", t("market.close"));
|
|
5004
5414
|
document.getElementById("mk-close").addEventListener("click", () => marketDialog.close());
|
|
5005
5415
|
|
|
5006
5416
|
async function loadMentionFiles() {
|
|
@@ -5054,12 +5464,33 @@ function openAttachMenu() {
|
|
|
5054
5464
|
// projects changes what is connected, and a connector can drop between two
|
|
5055
5465
|
// messages.
|
|
5056
5466
|
void getJson("/context").then((body) => {
|
|
5057
|
-
if (body.error
|
|
5467
|
+
if (body.error) return;
|
|
5468
|
+
offerLoaded = true;
|
|
5469
|
+
if (attachMenu.hidden) return;
|
|
5058
5470
|
offer = body;
|
|
5059
5471
|
paintAttachMenu();
|
|
5060
5472
|
});
|
|
5061
5473
|
}
|
|
5062
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
|
+
|
|
5063
5494
|
/**
|
|
5064
5495
|
* Arrow keys and Enter over the rows, shared by the menu and the mention list.
|
|
5065
5496
|
*
|
|
@@ -5071,13 +5502,21 @@ function moveAttachCursor(e) {
|
|
|
5071
5502
|
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
5072
5503
|
e.preventDefault();
|
|
5073
5504
|
if (!rows.length) return true;
|
|
5074
|
-
|
|
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;
|
|
5075
5514
|
paintAttachMenu();
|
|
5076
5515
|
const active = attachBody.querySelector(".cursor");
|
|
5077
5516
|
if (active) active.scrollIntoView({ block: "nearest" });
|
|
5078
5517
|
return true;
|
|
5079
5518
|
}
|
|
5080
|
-
if (e.key === "Enter" || (e.key === "Tab" && attachView === "mention")) {
|
|
5519
|
+
if (e.key === "Enter" || (e.key === "Tab" && (attachView === "mention" || attachView === "command"))) {
|
|
5081
5520
|
e.preventDefault();
|
|
5082
5521
|
const row = rows[attachCursor];
|
|
5083
5522
|
if (row) chooseAttachRow(row);
|
|
@@ -5086,6 +5525,231 @@ function moveAttachCursor(e) {
|
|
|
5086
5525
|
return false;
|
|
5087
5526
|
}
|
|
5088
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
|
+
|
|
5089
5753
|
// --- @ mentions ------------------------------------------------------------
|
|
5090
5754
|
|
|
5091
5755
|
/** The @word being typed at the caret, or null. */
|
|
@@ -5099,6 +5763,7 @@ function mentionUnderCaret() {
|
|
|
5099
5763
|
}
|
|
5100
5764
|
|
|
5101
5765
|
function openMention(mention) {
|
|
5766
|
+
ensureOffer();
|
|
5102
5767
|
mentionAt = mention.at;
|
|
5103
5768
|
attachView = "mention";
|
|
5104
5769
|
attachQuery = mention.query.trim();
|
|
@@ -5115,10 +5780,45 @@ function openMention(mention) {
|
|
|
5115
5780
|
searchMentionFiles();
|
|
5116
5781
|
}
|
|
5117
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
|
+
|
|
5118
5817
|
function insertMention(path) {
|
|
5119
5818
|
const caret = input.selectionStart;
|
|
5120
|
-
const head = input.value.slice(0, mentionAt) + "@" + path + "
|
|
5819
|
+
const head = roomBefore(input.value.slice(0, mentionAt)) + "@" + path + " ";
|
|
5121
5820
|
input.value = head + input.value.slice(caret);
|
|
5821
|
+
paintMirror();
|
|
5122
5822
|
closeAttachMenu();
|
|
5123
5823
|
input.focus();
|
|
5124
5824
|
input.setSelectionRange(head.length, head.length);
|
|
@@ -5128,9 +5828,11 @@ function insertMention(path) {
|
|
|
5128
5828
|
}
|
|
5129
5829
|
|
|
5130
5830
|
input.addEventListener("input", () => {
|
|
5831
|
+
const command = commandUnderCaret();
|
|
5832
|
+
if (command) return void openCommands(command);
|
|
5131
5833
|
const mention = mentionUnderCaret();
|
|
5132
5834
|
if (mention) return void openMention(mention);
|
|
5133
|
-
if (attachView === "mention") closeAttachMenu();
|
|
5835
|
+
if (attachView === "mention" || attachView === "command") closeAttachMenu();
|
|
5134
5836
|
});
|
|
5135
5837
|
|
|
5136
5838
|
// Before the composer's own Enter handling, which would otherwise send the
|
|
@@ -5166,6 +5868,14 @@ document.addEventListener("keydown", (e) => {
|
|
|
5166
5868
|
document.getElementById("composer").addEventListener("submit", (e) => {
|
|
5167
5869
|
e.preventDefault();
|
|
5168
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
|
+
}
|
|
5169
5879
|
if (!text && !staged.length && !refs.length) return;
|
|
5170
5880
|
const ids = staged.map((file) => file.id);
|
|
5171
5881
|
const shots = staged
|
|
@@ -5175,6 +5885,7 @@ document.getElementById("composer").addEventListener("submit", (e) => {
|
|
|
5175
5885
|
const referenced = refs.map((ref) => ({ kind: ref.kind, value: ref.value }));
|
|
5176
5886
|
input.value = "";
|
|
5177
5887
|
input.style.height = "auto";
|
|
5888
|
+
paintMirror();
|
|
5178
5889
|
staged = [];
|
|
5179
5890
|
refs = [];
|
|
5180
5891
|
renderAttachments();
|
|
@@ -5201,8 +5912,12 @@ input.addEventListener("input", () => {
|
|
|
5201
5912
|
input.style.height = "auto";
|
|
5202
5913
|
const wanted = Math.max(input.scrollHeight, 56);
|
|
5203
5914
|
input.style.height = Math.min(wanted, window.innerHeight * 0.32) + "px";
|
|
5915
|
+
paintMirror();
|
|
5204
5916
|
});
|
|
5205
5917
|
|
|
5918
|
+
// --- the mention chip under the text ------------------------------------
|
|
5919
|
+
|
|
5920
|
+
|
|
5206
5921
|
stopBtn.addEventListener("click", () => void api("/interrupt", { session: activeId }));
|
|
5207
5922
|
// Straight into a session, in the tree already open. Where it runs is asked on
|
|
5208
5923
|
// the composer instead of on the way in: the project on screen is the answer
|
|
@@ -9391,6 +10106,7 @@ void (async () => {
|
|
|
9391
10106
|
for (const button of document.querySelectorAll("#starters button")) {
|
|
9392
10107
|
button.addEventListener("click", () => {
|
|
9393
10108
|
input.value = button.dataset.p;
|
|
10109
|
+
paintMirror();
|
|
9394
10110
|
document.getElementById("composer").requestSubmit();
|
|
9395
10111
|
});
|
|
9396
10112
|
}
|
|
@@ -9517,7 +10233,14 @@ ${ANALYTICS_STUB}
|
|
|
9517
10233
|
<form id="composer">
|
|
9518
10234
|
<div class="composer" id="composer-box">
|
|
9519
10235
|
<div id="attachments"></div>
|
|
9520
|
-
|
|
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>
|
|
9521
10244
|
<div class="composer-bar">
|
|
9522
10245
|
<div class="picker" id="attach-picker">
|
|
9523
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>
|
|
@@ -9604,9 +10327,10 @@ ${ANALYTICS_STUB}
|
|
|
9604
10327
|
<div class="mk-head">
|
|
9605
10328
|
<div id="mk-title" class="cn-title"></div>
|
|
9606
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>
|
|
9607
10332
|
</div>
|
|
9608
10333
|
<div id="mk-list"></div>
|
|
9609
|
-
<div class="mk-foot"><button id="mk-close" class="ghost" type="button" data-t="market.close"></button></div>
|
|
9610
10334
|
</dialog>
|
|
9611
10335
|
|
|
9612
10336
|
<dialog id="provider-picker">
|