@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/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;align-self:flex-start;margin-top:1px;
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
- details.provider>summary{
930
- 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;
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 6px;flex:1}
1121
- .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}
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
- /* The rule above is the composer's: no border, no padding, transparent. A
1506
- 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. */
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 = full.length > 28 ? full.slice(full.indexOf("/") + 1) : full;
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
- 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) => ({
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: "market",
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: "market",
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(el("span", "tick", "✓"));
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 (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
+ }
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
- document.getElementById("mk-title").textContent = t(
4748
- kind === "plugins" ? "add.plugins" : "add.connectors",
4749
- );
4750
- document.getElementById("mk-note").textContent = t(
4751
- kind === "plugins" ? "market.pluginsNote" : "market.connectorsNote",
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 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);
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
- 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));
4874
5257
 
4875
5258
  const naming = el("div", "mk-naming");
4876
- naming.appendChild(el("div", "mk-name", entry.name));
4877
- 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
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
- 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) {
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 || attachMenu.hidden) return;
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
- 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;
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
- <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>
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">