@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/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
- details.provider>summary{
868
- padding:12px 14px;cursor:pointer;list-style:none;margin:0;gap:10px;
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 6px;flex:1}
1059
- .mk-foot{padding:10px 16px 14px;display:flex;justify-content:flex-end;box-shadow:0 -1px 0 var(--line)}
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
- /* The rule above is the composer's: no border, no padding, transparent. A
1444
- textarea in a settings row needs to look like something you can type in. */
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
- // Discovered ids are namespaced and long; the tail is the part that
2119
- // identifies the model, and the full id is still in the menu.
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 = full.length > 28 ? full.slice(full.indexOf("/") + 1) : full;
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
- return (fileList.entries || []).map((entry) => ({
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: "market",
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: "market",
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(el("span", "tick", "✓"));
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 (attachView === "mention" && row.ref) return void insertMention(row.ref.value);
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
- document.getElementById("mk-title").textContent = t(
4554
- kind === "plugins" ? "add.plugins" : "add.connectors",
4555
- );
4556
- document.getElementById("mk-note").textContent = t(
4557
- kind === "plugins" ? "market.pluginsNote" : "market.connectorsNote",
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 installed = marketKind === "plugins" ? offer.plugins || [] : offer.connectors || [];
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
- * Whether a mark is a dark shape on nothing, rather than a picture.
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 isDarkFlatGlyph(img) {
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 false;
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
- if (opaque / (size * size) > 0.75) return false;
4656
- return dark / opaque > 0.7 && coloured / opaque < 0.15;
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 false;
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
- row.appendChild(isPlugin ? svgIcon("plugin") : connectorMark(entry.name, entry.name));
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
- naming.appendChild(el("div", "mk-name", entry.name));
4671
- const detail = isPlugin
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
- if (!isPlugin) {
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 || attachMenu.hidden) return;
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
- attachCursor = (attachCursor + (e.key === "ArrowDown" ? 1 : -1) + rows.length) % rows.length;
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
- <textarea id="input" rows="1" data-t-placeholder="composer.placeholder"></textarea>
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">