@oharness/server 0.10.0 → 0.12.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.
Files changed (74) hide show
  1. package/dist/analytics.d.ts +32 -29
  2. package/dist/analytics.js +225 -278
  3. package/dist/analytics.js.map +1 -1
  4. package/dist/i18n.js +242 -12
  5. package/dist/i18n.js.map +1 -1
  6. package/dist/icons.d.ts +3 -0
  7. package/dist/icons.js +69 -3
  8. package/dist/icons.js.map +1 -1
  9. package/dist/model-order.js +1 -0
  10. package/dist/model-order.js.map +1 -1
  11. package/dist/page/client/account.js +140 -4
  12. package/dist/page/client/account.js.map +1 -1
  13. package/dist/page/client/base.js +42 -16
  14. package/dist/page/client/base.js.map +1 -1
  15. package/dist/page/client/browser.js +1 -1
  16. package/dist/page/client/composer.js +641 -47
  17. package/dist/page/client/composer.js.map +1 -1
  18. package/dist/page/client/dialogs.js +75 -0
  19. package/dist/page/client/dialogs.js.map +1 -1
  20. package/dist/page/client/events.js +25 -2
  21. package/dist/page/client/events.js.map +1 -1
  22. package/dist/page/client/index.js +4 -0
  23. package/dist/page/client/index.js.map +1 -1
  24. package/dist/page/client/panels.js +4 -4
  25. package/dist/page/client/ripple.d.ts +13 -0
  26. package/dist/page/client/ripple.js +247 -0
  27. package/dist/page/client/ripple.js.map +1 -0
  28. package/dist/page/client/routines.js +173 -41
  29. package/dist/page/client/routines.js.map +1 -1
  30. package/dist/page/client/shell.js +200 -54
  31. package/dist/page/client/shell.js.map +1 -1
  32. package/dist/page/client/state.js +7 -2
  33. package/dist/page/client/state.js.map +1 -1
  34. package/dist/page/client/terminal.js +34 -7
  35. package/dist/page/client/terminal.js.map +1 -1
  36. package/dist/page/client/theme.d.ts +17 -0
  37. package/dist/page/client/theme.js +365 -0
  38. package/dist/page/client/theme.js.map +1 -0
  39. package/dist/page/client/transcript.js +63 -7
  40. package/dist/page/client/transcript.js.map +1 -1
  41. package/dist/page/style.js +273 -22
  42. package/dist/page/style.js.map +1 -1
  43. package/dist/page.js +5 -5
  44. package/dist/page.js.map +1 -1
  45. package/dist/prefs.d.ts +17 -0
  46. package/dist/prefs.js +5 -0
  47. package/dist/prefs.js.map +1 -1
  48. package/dist/server.js +710 -45
  49. package/dist/server.js.map +1 -1
  50. package/dist/suggest.d.ts +42 -0
  51. package/dist/suggest.js +215 -0
  52. package/dist/suggest.js.map +1 -0
  53. package/dist/terminal.d.ts +35 -2
  54. package/dist/terminal.js +60 -4
  55. package/dist/terminal.js.map +1 -1
  56. package/dist/theme-assets.d.ts +39 -0
  57. package/dist/theme-assets.js +109 -0
  58. package/dist/theme-assets.js.map +1 -0
  59. package/dist/theme-store.d.ts +50 -0
  60. package/dist/theme-store.js +124 -0
  61. package/dist/theme-store.js.map +1 -0
  62. package/dist/theme-tools.d.ts +25 -0
  63. package/dist/theme-tools.js +243 -0
  64. package/dist/theme-tools.js.map +1 -0
  65. package/dist/tool-icons.d.ts +12 -0
  66. package/dist/tool-icons.js +16 -0
  67. package/dist/tool-icons.js.map +1 -0
  68. package/dist/turn-shots.d.ts +18 -2
  69. package/dist/turn-shots.js +10 -1
  70. package/dist/turn-shots.js.map +1 -1
  71. package/dist/user-theme.d.ts +143 -0
  72. package/dist/user-theme.js +510 -0
  73. package/dist/user-theme.js.map +1 -0
  74. package/package.json +2 -2
@@ -50,13 +50,11 @@ const inputPlaceholder = () =>
50
50
 
51
51
  function setGoalMode(on) {
52
52
  goalMode = on;
53
- input.placeholder = inputPlaceholder();
54
53
  renderAttachments();
55
54
  }
56
55
 
57
56
  function setWorktreeMode(on) {
58
57
  worktreeMode = on;
59
- input.placeholder = inputPlaceholder();
60
58
  renderAttachments();
61
59
  }
62
60
 
@@ -251,6 +249,9 @@ function renderQueued() {
251
249
 
252
250
  function renderAttachments() {
253
251
  attachEl.replaceChildren();
252
+ // What is staged decides whether the guess still fits, and the goal and
253
+ // worktree modes repaint through here with their own placeholder.
254
+ paintSuggestion();
254
255
 
255
256
  if (goalMode) {
256
257
  const chip = el("div", "att goal");
@@ -553,13 +554,21 @@ let fileList = { entries: [] };
553
554
  /** Whether the context endpoint has answered once, for the mention list. */
554
555
  let offerLoaded = false;
555
556
  /** Connectors and plugins, refreshed each time the menu opens. */
556
- let offer = { connectors: [], plugins: [], skills: [] };
557
+ let offer = { connectors: [], plugins: [], skills: [], agents: [] };
557
558
  /** The connectors on offer rather than the ones already here. */
558
559
  let market = [];
560
+ /** Servers found in Claude Code's and Codex's config, offered beside them. */
561
+ let foreignMarket = [];
559
562
  /** The connector whose sign-in is open in a browser tab, if any. */
560
563
  let authorizing = null;
561
564
  /** The connector being added right now, so its row can say so. */
562
565
  let adding = null;
566
+ /** The plugins on offer, from our own list; fetched when that screen opens. */
567
+ let pluginMarket = [];
568
+ /** The plugin whose install was asked for once and is waiting to be confirmed. */
569
+ let confirming = null;
570
+ /** The plugin downloading right now. */
571
+ let installing = null;
563
572
  let attachQuery = "";
564
573
  let attachCursor = 0;
565
574
  /** Guards against a slow answer to an old keystroke landing after a new one. */
@@ -602,7 +611,7 @@ function attachRows() {
602
611
  if (attachView === "skills" || attachView === "plugins" || attachView === "connectors") {
603
612
  const kind = attachView;
604
613
  const items =
605
- kind === "skills" ? offer.skills || []
614
+ kind === "skills" ? usableSkills()
606
615
  : kind === "plugins" ? offer.plugins || []
607
616
  : offer.connectors || [];
608
617
  const row = (entry) => ({
@@ -640,11 +649,11 @@ function attachRows() {
640
649
  ...(!items.length ? [{ type: "hint", label: t(empty[kind]) }] : []),
641
650
  ...(hidden > 0 ? [{ type: "hint", label: t("add.andMore", { n: hidden }) }] : []),
642
651
  // Browse is only a different question where there is somewhere to browse:
643
- // connectors have a registry of ones not installed, skills and plugins
644
- // are whatever is on this machine already. "More" only when the menu
645
- // is holding some back; with everything in view it is the catalogue.
646
- ...(kind === "connectors"
647
- ? [{ type: "market", kind, icon: "search", label: t(hidden > 0 ? "add.browse" : "add.browseAll") }]
652
+ // connectors have a registry of ones not installed and plugins a market,
653
+ // skills are whatever is on this machine already. "More" only when the
654
+ // menu is holding some back; with everything in view it is the catalogue.
655
+ ...(kind === "connectors" || kind === "plugins"
656
+ ? [{ type: "market", kind, icon: "search", label: t(hidden > 0 ? "add.browse" : "add.browseAll." + kind) }]
648
657
  : []),
649
658
  { type: "manage", kind, icon: "settings", label: t("add.manage") },
650
659
  ];
@@ -679,7 +688,7 @@ function attachRows() {
679
688
  * under the name is the one a person would think of. Someone after the
680
689
  * video tools types "video", and only the description contains it.
681
690
  */
682
- const skills = (offer.skills || [])
691
+ const skills = usableSkills()
683
692
  .filter((skill) =>
684
693
  !query ||
685
694
  skill.name.toLowerCase().includes(query) ||
@@ -730,6 +739,25 @@ function attachRows() {
730
739
  note: plugin.description || "",
731
740
  ref: { kind: "plugin", value: plugin.name, label: plugin.name },
732
741
  }));
742
+ /**
743
+ * External agents first: naming one at the start of a message hands the
744
+ * whole message to it — Claude Code on the user's own account — so it is
745
+ * the one mention that changes who answers, and worth seeing before the
746
+ * rest. Offered only while the @ opens the message, the one place it
747
+ * routes; mid-sentence it would only be a word.
748
+ */
749
+ const atStart = mentionAt >= 0 && !input.value.slice(0, mentionAt).trim();
750
+ const agents = atStart
751
+ ? (offer.agents || [])
752
+ .filter((agent) => !query || agent.name.toLowerCase().includes(query))
753
+ .map((agent) => ({
754
+ type: "agent",
755
+ icon: "command",
756
+ label: "@" + agent.name,
757
+ note: t("mention.agentNote"),
758
+ ref: { kind: "agent", value: agent.name, label: agent.name },
759
+ }))
760
+ : [];
733
761
  const files = (fileList.entries || []).map((entry) => ({
734
762
  type: entry.dir ? "folder" : "file",
735
763
  icon: entry.dir ? "folder" : "file",
@@ -746,6 +774,7 @@ function attachRows() {
746
774
  * label on a list of one kind, which is noise.
747
775
  */
748
776
  const sections = [
777
+ [t("mention.agents"), agents],
749
778
  [t("add.skills"), skills],
750
779
  [t("add.plugins"), plugins],
751
780
  [t("mention.files"), files],
@@ -758,7 +787,7 @@ function attachRows() {
758
787
 
759
788
  const connectors = (offer.connectors || []).length;
760
789
  const plugins = (offer.plugins || []).length;
761
- const skills = (offer.skills || []).length;
790
+ const skills = usableSkills().length;
762
791
  return [
763
792
  // What goes with the message: from this machine, first. Two rows for
764
793
  // files and folders, because the dialogs behind them are two: only macOS
@@ -767,7 +796,7 @@ function attachRows() {
767
796
  // which one opens before it opens is also how someone reaching for a
768
797
  // screenshot stops reading at the first row.
769
798
  { type: "group", label: t("add.sectionAttach") },
770
- { type: "pick", kind: "files", icon: "file", label: t("add.attachFiles") },
799
+ { type: "pick", kind: "files", icon: "attach", label: t("add.attachFiles") },
771
800
  { type: "pick", kind: "folder", icon: "folder", label: t("add.attachFolders") },
772
801
  { type: "group", label: t("add.sectionMode") },
773
802
  // The permission mode of the same name, from the place people look for a
@@ -813,6 +842,21 @@ function attachRows() {
813
842
  sub: skills ? String(skills) : "",
814
843
  into: true,
815
844
  },
845
+ // What this person already set up in Claude Code or Codex and has not
846
+ // brought over yet. At the top level, one click from the +: a section
847
+ // two screens in is a section nobody goes looking for. Gone once there is
848
+ // nothing left to add.
849
+ ...(foreignWaiting() > 0
850
+ ? [{
851
+ type: "market",
852
+ kind: "imports",
853
+ icon: "import",
854
+ // Each tool's own mark: the row is about where these come from.
855
+ marks: ["claude", "codex"],
856
+ label: t("add.importFrom"),
857
+ sub: String(foreignWaiting()),
858
+ }]
859
+ : []),
816
860
  ];
817
861
  }
818
862
 
@@ -821,7 +865,45 @@ function notChoice(row) {
821
865
  return row.type === "group" || row.type === "sep";
822
866
  }
823
867
 
868
+ /**
869
+ * Repaint the menu, gliding from the height it had to the one it needs.
870
+ *
871
+ * Every repaint goes through here — a new screen, a list that arrived from
872
+ * the server after the menu opened, a filter narrowing it — because any one
873
+ * of them that changed the height in a single frame was the jolt. A glide
874
+ * already under way is interrupted from wherever it got to, not restarted
875
+ * from where it began.
876
+ */
877
+ let attachResize = null;
824
878
  function paintAttachMenu() {
879
+ const visible = !attachMenu.hidden;
880
+ const before = visible ? attachMenu.offsetHeight : 0;
881
+ if (attachResize) {
882
+ attachResize.cancel();
883
+ attachResize = null;
884
+ }
885
+ paintAttachRows();
886
+ if (!visible || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
887
+ const after = attachMenu.offsetHeight;
888
+ if (Math.abs(after - before) < 2) return;
889
+ attachMenu.classList.add("resizing");
890
+ const glide = attachMenu.animate([{ height: before + "px" }, { height: after + "px" }], {
891
+ duration: 200,
892
+ easing: "cubic-bezier(.2,.8,.2,1)",
893
+ });
894
+ attachResize = glide;
895
+ const done = () => {
896
+ if (attachResize !== glide) return;
897
+ attachResize = null;
898
+ attachMenu.classList.remove("resizing");
899
+ };
900
+ glide.onfinish = done;
901
+ glide.oncancel = () => {
902
+ if (attachResize === glide || !attachResize) attachMenu.classList.remove("resizing");
903
+ };
904
+ }
905
+
906
+ function paintAttachRows() {
825
907
  const rows = attachRows();
826
908
  if (attachCursor >= rows.length) attachCursor = Math.max(0, rows.length - 1);
827
909
  // The list opens with the cursor at 0, and 0 is a heading whenever the list
@@ -849,7 +931,13 @@ function paintAttachMenu() {
849
931
  "menu-item" + ((row.ref && referenced(row.ref)) || row.on ? " on" : "") + (i === attachCursor ? " cursor" : ""),
850
932
  );
851
933
  item.setAttribute("role", "button");
852
- item.appendChild(row.mark ? connectorMark(row.mark, row.label) : svgIcon(row.icon));
934
+ if (row.marks) {
935
+ const pair = el("span", "mark-pair");
936
+ for (const source of row.marks) pair.appendChild(vendorMark(source));
937
+ item.appendChild(pair);
938
+ } else {
939
+ item.appendChild(row.mark ? connectorMark(row.mark, row.label) : svgIcon(row.icon));
940
+ }
853
941
  if (row.note) {
854
942
  item.classList.add("stacked");
855
943
  const stack = el("span", "stack");
@@ -1028,15 +1116,27 @@ async function withPreview(file) {
1028
1116
  }
1029
1117
 
1030
1118
  function showAttachView(view) {
1119
+ const from = attachView;
1120
+ const before = attachMenu.hidden ? 0 : attachMenu.offsetHeight;
1031
1121
  attachView = view;
1032
1122
  // No row is under the keyboard until a key moves there. Opening onto a
1033
1123
  // highlighted first row, with the pointer highlighting a second, made the
1034
1124
  // menu look like it had two selections and no rhythm.
1035
1125
  attachCursor = -1;
1036
1126
  paintAttachMenu();
1127
+ // Into a list slides in from the right, back out from the left, so the
1128
+ // menu reads as a stack of screens rather than one that changed its mind.
1129
+ if (before && from !== view) slideAttachBody(view === "root" ? "slide-back" : "slide-in");
1037
1130
  attachMenu.focus();
1038
1131
  }
1039
1132
 
1133
+ function slideAttachBody(name) {
1134
+ attachBody.classList.remove("slide-in", "slide-back");
1135
+ void attachBody.offsetWidth; // restart the animation
1136
+ attachBody.classList.add(name);
1137
+ }
1138
+
1139
+
1040
1140
  // --- browsing connectors and plugins ---------------------------------------
1041
1141
  /**
1042
1142
  * What is on offer, in a dialog rather than in the menu.
@@ -1055,37 +1155,100 @@ let marketKind = "connectors";
1055
1155
  const marketSearch = document.getElementById("mk-search");
1056
1156
  marketSearch.addEventListener("input", () => paintMarket());
1057
1157
 
1158
+ /**
1159
+ * Which list the import tab sits beside. Bringing things in from Claude Code
1160
+ * and Codex is its own tab, not a section of the catalogue: those rows are
1161
+ * this person's own setup, and a shelf of them between "in this session" and
1162
+ * seventy vendors read as one more vendor list.
1163
+ */
1164
+ let marketHome = "connectors";
1165
+ const marketTabs = document.getElementById("mk-tabs");
1166
+
1167
+ function paintMarketTabs() {
1168
+ const tabbed = marketKind === "connectors" || marketKind === "plugins" || marketKind === "imports";
1169
+ marketTabs.hidden = !tabbed;
1170
+ if (!tabbed) return;
1171
+ marketTabs.replaceChildren();
1172
+ const tab = (kind, build) => {
1173
+ const button = el("button", "mk-tab" + (marketKind === kind ? " on" : ""));
1174
+ button.type = "button";
1175
+ button.setAttribute("role", "tab");
1176
+ button.setAttribute("aria-selected", String(marketKind === kind));
1177
+ build(button);
1178
+ button.addEventListener("click", () => {
1179
+ if (marketKind !== kind) void openMarket(kind);
1180
+ });
1181
+ marketTabs.appendChild(button);
1182
+ };
1183
+ tab(marketHome, (button) => button.appendChild(document.createTextNode(t(marketHome === "plugins" ? "add.plugins" : "add.connectors"))));
1184
+ tab("imports", (button) => {
1185
+ button.appendChild(vendorMark("claude"));
1186
+ button.appendChild(vendorMark("codex"));
1187
+ button.appendChild(document.createTextNode(t("market.tab.import")));
1188
+ const waiting = foreignWaiting();
1189
+ if (waiting) button.appendChild(el("span", "mk-tab-count", String(waiting)));
1190
+ });
1191
+ }
1192
+
1058
1193
  async function openMarket(kind) {
1194
+ if (kind === "connectors" || kind === "plugins") marketHome = kind;
1059
1195
  marketKind = kind;
1060
1196
  // Cleared on open: a query left over from the last screen would hide most of
1061
1197
  // this one and look like an empty catalogue.
1062
1198
  marketSearch.value = "";
1063
1199
  marketSearch.placeholder = t("menu.search");
1064
- const titles = { plugins: "add.plugins", skills: "add.skills", connectors: "add.connectors" };
1065
- const notes = { plugins: "market.pluginsNote", skills: "market.skillsNote", connectors: "market.connectorsNote" };
1066
- document.getElementById("mk-title").textContent = t(titles[kind] || titles.connectors);
1200
+ const titles = { plugins: "add.plugins", skills: "add.skills", connectors: "add.connectors", imports: "add.importFrom" };
1201
+ const notes = { plugins: "market.pluginsNote", skills: "market.skillsNote", connectors: "market.connectorsNote", imports: "market.importsNote" };
1202
+ // The import tab belongs to the list it sits beside, so the dialog keeps
1203
+ // that list's name and only the sentence under it changes.
1204
+ document.getElementById("mk-title").textContent = t(titles[kind === "imports" ? marketHome : kind] || titles.connectors);
1205
+ paintMarketTabs();
1067
1206
  document.getElementById("mk-note").textContent = t(notes[kind] || notes.connectors);
1068
- marketList.replaceChildren(el("div", "mk-desc", t("market.loading")));
1069
- marketDialog.showModal();
1207
+ // A tab switch keeps the old list up until the new one is ready: a
1208
+ // "Loading…" line in between collapses the dialog for a frame.
1209
+ const switching = marketDialog.open;
1210
+ if (!switching) marketList.replaceChildren(el("div", "mk-desc", t("market.loading")));
1211
+ marketDialog.classList.remove("closing");
1212
+ // Rows play their entrance once per opening, not on every repaint.
1213
+ marketDialog.classList.add("fresh");
1214
+ // Already open when one list links to another — the plugins screen
1215
+ // pointing at the import screen — and showModal on an open dialog throws.
1216
+ if (!marketDialog.open) marketDialog.showModal();
1070
1217
 
1071
1218
  const context = await getJson("/context");
1072
1219
  if (!context.error) offer = context;
1073
- if (kind === "connectors") {
1220
+ if (kind === "connectors" || kind === "imports" || kind === "plugins") {
1074
1221
  const body = await getJson("/registry");
1075
1222
  market = body.error ? [] : body.connectors || [];
1223
+ foreignMarket = body.error ? [] : body.imports || [];
1224
+ }
1225
+ if (kind === "plugins") {
1226
+ confirming = null;
1227
+ const body = await getJson("/plugin-market");
1228
+ pluginMarket = body.error ? [] : body.plugins || [];
1076
1229
  }
1230
+ paintMarketTabs();
1077
1231
  paintMarket();
1232
+ if (switching) {
1233
+ marketList.classList.remove("swap");
1234
+ void marketList.offsetWidth;
1235
+ marketList.classList.add("swap");
1236
+ marketList.scrollTop = 0;
1237
+ }
1238
+ setTimeout(() => marketDialog.classList.remove("fresh"), 400);
1078
1239
  }
1079
1240
 
1080
1241
  function paintMarket() {
1081
1242
  marketList.replaceChildren();
1082
1243
  const query = (marketSearch.value || "").trim().toLowerCase();
1244
+ if (marketKind === "imports") return paintImports(query);
1083
1245
  const hit = (entry) =>
1084
1246
  !query ||
1085
1247
  (entry.name || "").toLowerCase().includes(query) ||
1086
1248
  (entry.title || "").toLowerCase().includes(query) ||
1087
1249
  (entry.description || "").toLowerCase().includes(query) ||
1088
- (entry.label || "").toLowerCase().includes(query);
1250
+ (entry.label || "").toLowerCase().includes(query) ||
1251
+ (entry.publisher || "").toLowerCase().includes(query);
1089
1252
  const installed = (
1090
1253
  marketKind === "plugins" ? offer.plugins || []
1091
1254
  : marketKind === "skills" ? offer.skills || []
@@ -1099,10 +1262,28 @@ function paintMarket() {
1099
1262
  marketList.appendChild(grid);
1100
1263
  }
1101
1264
 
1102
- // Only connectors have somewhere to come from yet. Saying so beats an
1103
- // "Available" heading over an empty space that reads as a failed load.
1104
- const available = marketKind === "connectors" ? market.filter((one) => !one.installed && hit(one)) : [];
1105
- if (available.length) {
1265
+
1266
+ // Skills have nowhere to come from yet. Saying so beats an "Available"
1267
+ // heading over an empty space that reads as a failed load.
1268
+ const available =
1269
+ marketKind === "connectors" ? market.filter((one) => !one.installed && hit(one))
1270
+ : marketKind === "plugins" ? pluginMarket.filter((one) => !one.installed && hit(one))
1271
+ : [];
1272
+ if ((marketKind === "plugins" || marketKind === "connectors") && available.length) {
1273
+ // Seventy rows under one heading is a wall; under seven it is a shelf.
1274
+ // Connectors share the plugins' shelves, so a vendor sits on the same one
1275
+ // in both dialogs, plus one of their own for the servers run locally.
1276
+ const shelves = marketKind === "connectors" ? ["local", ...PLUGIN_CATEGORIES] : PLUGIN_CATEGORIES;
1277
+ const paint = marketKind === "connectors" ? offerRow : pluginOfferRow;
1278
+ for (const category of shelves) {
1279
+ const rows = available.filter((one) => (shelves.includes(one.category) ? one.category : "other") === category);
1280
+ if (!rows.length) continue;
1281
+ marketList.appendChild(el("div", "mk-group", t("market.cat." + category)));
1282
+ const grid = el("div", "mk-grid");
1283
+ for (const entry of rows) grid.appendChild(paint(entry));
1284
+ marketList.appendChild(grid);
1285
+ }
1286
+ } else if (available.length) {
1106
1287
  marketList.appendChild(el("div", "mk-group", t("market.available")));
1107
1288
  const grid = el("div", "mk-grid");
1108
1289
  for (const entry of available) grid.appendChild(offerRow(entry));
@@ -1120,14 +1301,16 @@ function paintMarket() {
1120
1301
  * browser's own placeholder, which is uglier than the fallback and arrives at
1121
1302
  * a different moment in every browser.
1122
1303
  */
1123
- function connectorMark(id, label) {
1304
+ function connectorMark(id, label, route = "/connector-icon", fallback) {
1124
1305
  const letter = (label || id || "?").trim().charAt(0).toUpperCase();
1125
1306
  // A stable hue per connector, so the tile is the same colour every time
1126
1307
  // rather than a different one each render.
1127
1308
  let hash = 0;
1128
1309
  for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) % 360;
1129
- const mono = el("span", "mk-mono", letter);
1130
- mono.style.background = "hsl(" + hash + " 42% 42%)";
1310
+ // A caller with a better placeholder than a letter — a plugin with no site
1311
+ // keeps the plugin glyph it always had — passes it in.
1312
+ const mono = fallback || el("span", "mk-mono", letter);
1313
+ if (!fallback) mono.style.background = "hsl(" + hash + " 42% 42%)";
1131
1314
 
1132
1315
  const img = new Image();
1133
1316
  img.className = "mk-logo";
@@ -1136,7 +1319,7 @@ function connectorMark(id, label) {
1136
1319
  if (isDarkFlatGlyph(img)) img.classList.add("invert-dark");
1137
1320
  mono.replaceWith(img);
1138
1321
  });
1139
- img.src = q("/connector-icon?id=" + encodeURIComponent(id));
1322
+ img.src = q(route + "?id=" + encodeURIComponent(id));
1140
1323
  return mono;
1141
1324
  }
1142
1325
 
@@ -1197,7 +1380,11 @@ function installedRow(entry) {
1197
1380
  const row = el("div", "mk-item");
1198
1381
  const isPlugin = marketKind === "plugins";
1199
1382
  const isSkill = marketKind === "skills";
1200
- row.appendChild(isPlugin ? svgIcon("plugin") : isSkill ? svgIcon("skill") : connectorMark(entry.name, entry.name));
1383
+ row.appendChild(
1384
+ isPlugin ? connectorMark(entry.name, entry.title || entry.name, "/plugin-icon", svgIcon("plugin"))
1385
+ : isSkill ? svgIcon("skill")
1386
+ : connectorMark(entry.name, entry.name),
1387
+ );
1201
1388
 
1202
1389
  const naming = el("div", "mk-naming");
1203
1390
  // A plugin may carry a display title over its id; the id is still what a
@@ -1215,11 +1402,18 @@ function installedRow(entry) {
1215
1402
  // A runtime plugin says so in its source; a skill carries its own flag. Reading
1216
1403
  // only one of them is how the two that ship here — memory and the media
1217
1404
  // plugin — ended up as the only rows in the panel with no provenance at all.
1405
+ // Claude Code's and Codex's are named for the tool that put them there:
1406
+ // that is where one gets fixed, and where removing it would take it from.
1218
1407
  const tag =
1219
1408
  entry.builtin || entry.source === "builtin" ? "built-in"
1220
1409
  : entry.source === "runtime" ? "runtime"
1221
1410
  : "";
1222
1411
  if (tag) heading.appendChild(el("span", "mk-tag", tag));
1412
+ if (entry.source === "claude" || entry.source === "codex") heading.appendChild(vendorChip(entry.source));
1413
+ if (isSkill && entry.disabled) {
1414
+ row.classList.add("off");
1415
+ heading.appendChild(el("span", "mk-tag", t("market.off")));
1416
+ }
1223
1417
  naming.appendChild(heading);
1224
1418
  // A skill has nothing to connect to and nothing to be offline from, so the
1225
1419
  // slot that carries a connector's state carries what the skill is for —
@@ -1233,7 +1427,11 @@ function installedRow(entry) {
1233
1427
  : entry.needsAuth
1234
1428
  ? t("add.needsAuth")
1235
1429
  : entry.error || t("add.offline");
1236
- if (detail) naming.appendChild(el("div", "mk-desc", detail));
1430
+ if (detail) {
1431
+ const line = el("div", "mk-desc", detail);
1432
+ line.title = detail;
1433
+ naming.appendChild(line);
1434
+ }
1237
1435
  row.appendChild(naming);
1238
1436
 
1239
1437
  const acts = el("div", "mk-acts");
@@ -1241,22 +1439,42 @@ function installedRow(entry) {
1241
1439
  // and repeating it on every row distinguishes nothing. What is worth saying
1242
1440
  // per row is the part that would otherwise surprise someone: this plugin
1243
1441
  // declares servers, and they are not running.
1244
- const idle = isPlugin && entry.source === "claude" ? (entry.servers || []).length : 0;
1442
+ const here = new Set((offer.connectors || []).map((one) => one.name));
1443
+ const idle = isPlugin && (entry.source === "claude" || entry.source === "codex")
1444
+ ? (entry.servers || []).filter((name) => !here.has(name)).length
1445
+ : 0;
1245
1446
  // Two keys rather than a plural engine: this is the only place in the page
1246
1447
  // where the count is usually one, and "1 servers" is worth the extra string.
1448
+ // A button, because the next question is always "then how do I start it".
1247
1449
  if (idle) {
1248
1450
  const said = idle === 1 ? t("market.serverIdle") : t("market.serversIdle", { n: idle });
1249
- acts.appendChild(el("span", "mk-state", said));
1451
+ const go = el("button", "mk-btn", said);
1452
+ go.type = "button";
1453
+ go.title = t("add.importFrom");
1454
+ go.addEventListener("click", () => void openMarket("imports"));
1455
+ acts.appendChild(go);
1250
1456
  }
1251
1457
  const ref = { kind: isPlugin ? "plugin" : isSkill ? "skill" : "connector", value: entry.name, label: entry.name };
1252
- const attach = el("button", "mk-icon-btn", referenced(ref) ? "✓" : "+");
1253
- attach.type = "button";
1254
- attach.title = t("market.attach");
1255
- attach.addEventListener("click", () => {
1256
- toggleRef(ref);
1257
- attach.textContent = referenced(ref) ? "✓" : "+";
1258
- });
1259
- acts.appendChild(attach);
1458
+ // A skill that is off cannot be loaded, so it cannot be attached either.
1459
+ if (!(isSkill && entry.disabled)) {
1460
+ const attach = el("button", "mk-icon-btn", referenced(ref) ? "✓" : "+");
1461
+ attach.type = "button";
1462
+ attach.title = t("market.attach");
1463
+ attach.addEventListener("click", () => {
1464
+ toggleRef(ref);
1465
+ attach.textContent = referenced(ref) ? "✓" : "+";
1466
+ });
1467
+ acts.appendChild(attach);
1468
+ }
1469
+
1470
+ // Off rather than removed: most of these are files another tool put on
1471
+ // disk, and deleting one to hide it here would take it from that tool too.
1472
+ if (isSkill) {
1473
+ const toggle = el("button", "mk-btn", t(entry.disabled ? "market.enable" : "market.disable"));
1474
+ toggle.type = "button";
1475
+ toggle.addEventListener("click", () => void setSkill(entry.name, Boolean(entry.disabled)));
1476
+ acts.appendChild(toggle);
1477
+ }
1260
1478
 
1261
1479
  // A connector that asked to be signed in to gets the one button that moves
1262
1480
  // it forward, in front of the one that removes it.
@@ -1272,8 +1490,17 @@ function installedRow(entry) {
1272
1490
  acts.appendChild(go);
1273
1491
  }
1274
1492
 
1275
- // Removal is a connector's alone: a plugin is a directory on disk and a
1276
- // skill is a file in one, and neither is undone from a composer menu.
1493
+ // A plugin this menu installed can be taken away by it. Any other plugin is
1494
+ // a directory somebody put on disk, and a skill is a file in one; neither is
1495
+ // undone from a composer menu.
1496
+ if (isPlugin && entry.market) {
1497
+ const drop = el("button", "mk-icon-btn drop", "×");
1498
+ drop.type = "button";
1499
+ drop.title = t("add.remove");
1500
+ drop.disabled = Boolean(installing);
1501
+ drop.addEventListener("click", () => void installPlugin(entry.market, true));
1502
+ acts.appendChild(drop);
1503
+ }
1277
1504
  if (!isPlugin && !isSkill) {
1278
1505
  const drop = el("button", "mk-icon-btn drop", "×");
1279
1506
  drop.type = "button";
@@ -1309,6 +1536,250 @@ function offerRow(entry) {
1309
1536
  return row;
1310
1537
  }
1311
1538
 
1539
+ /** Claude Code's or Codex's app icon; see tool-icons.ts. */
1540
+ function vendorMark(source, size) {
1541
+ const mark = el("span", "vendor-mark");
1542
+ mark.setAttribute("aria-hidden", "true");
1543
+ if (size) mark.style.width = mark.style.height = size + "px";
1544
+ const img = document.createElement("img");
1545
+ img.alt = "";
1546
+ img.decoding = "async";
1547
+ img.src = q("/tool-icon?id=" + (source === "codex" ? "codex" : "claude"));
1548
+ mark.appendChild(img);
1549
+ return mark;
1550
+ }
1551
+
1552
+ /** "Claude Code" or "Codex" with its mark, where a row says whose it is. */
1553
+ function vendorChip(source) {
1554
+ const chip = el("span", "vendor-chip");
1555
+ chip.appendChild(vendorMark(source));
1556
+ chip.appendChild(document.createTextNode(source === "codex" ? "Codex" : "Claude Code"));
1557
+ return chip;
1558
+ }
1559
+
1560
+ /**
1561
+ * One row per server name. The same server set up in both tools — or twice
1562
+ * in one — is one thing to add, so it is listed once, under both tools'
1563
+ * marks, and the declaration that would work is the one the button adds.
1564
+ */
1565
+ function mergeForeign(list) {
1566
+ const usable = (one) => !one.reason && !(one.missingEnv || []).length;
1567
+ const byName = new Map();
1568
+ for (const one of list) {
1569
+ const key = one.scope === "plugin" ? "plugin:" + one.plugin + ":" + one.name : one.name;
1570
+ const group = byName.get(key) || [];
1571
+ group.push(one);
1572
+ byName.set(key, group);
1573
+ }
1574
+ return [...byName.values()].map((group) => {
1575
+ const best = group.find(usable) || group[0];
1576
+ return { ...best, sources: [...new Set(group.map((one) => one.source))] };
1577
+ });
1578
+ }
1579
+
1580
+ /**
1581
+ * Everything worth bringing over from Claude Code and Codex, on one screen.
1582
+ *
1583
+ * Skills, agents and plugins are read where they lie and need nothing done,
1584
+ * so the only rows are servers: those in the two tools' own config files
1585
+ * first, then those inside their plugins, under the plugin that brought each.
1586
+ */
1587
+ function paintImports(query) {
1588
+ const hit = (one) =>
1589
+ !query ||
1590
+ one.name.toLowerCase().includes(query) ||
1591
+ (one.pluginTitle || one.plugin || "").toLowerCase().includes(query) ||
1592
+ (one.target || "").toLowerCase().includes(query);
1593
+ const here = new Set((offer.connectors || []).map((one) => one.name));
1594
+ const waiting = mergeForeign(foreignMarket.filter((one) => !one.installed && !here.has(one.name) && hit(one)));
1595
+
1596
+ const files = waiting.filter((one) => one.scope !== "plugin");
1597
+ if (files.length) {
1598
+ marketList.appendChild(el("div", "mk-group", t("market.fromConfig")));
1599
+ const grid = el("div", "mk-grid");
1600
+ for (const entry of files) grid.appendChild(foreignRow(entry));
1601
+ marketList.appendChild(grid);
1602
+ }
1603
+ const bundled = waiting.filter((one) => one.scope === "plugin");
1604
+ if (bundled.length) {
1605
+ marketList.appendChild(el("div", "mk-group", t("market.fromPlugins")));
1606
+ const grid = el("div", "mk-grid");
1607
+ for (const entry of bundled) grid.appendChild(foreignRow(entry));
1608
+ marketList.appendChild(grid);
1609
+ }
1610
+ if (!files.length && !bundled.length) {
1611
+ marketList.appendChild(el("div", "mk-desc mk-empty", t(query ? "market.empty" : "market.allImported")));
1612
+ }
1613
+ }
1614
+
1615
+ /**
1616
+ * One server from Claude Code's or Codex's config.
1617
+ *
1618
+ * Offered, never started on its own: it is a process, and often a key, the
1619
+ * person set up for another program. The row says where it was found and
1620
+ * what it reaches, and — when the file has a key written out in it — that
1621
+ * adding it copies that key, because that is the one consequence of the
1622
+ * click a reader would not guess.
1623
+ */
1624
+ function foreignRow(entry) {
1625
+ const row = el("div", "mk-item");
1626
+ row.appendChild(connectorMark(entry.name, entry.name));
1627
+
1628
+ const naming = el("div", "mk-naming");
1629
+ const heading = el("div", "mk-name", entry.name);
1630
+ for (const source of entry.sources || [entry.source]) heading.appendChild(vendorChip(source));
1631
+ naming.appendChild(heading);
1632
+ const missing = entry.missingEnv || [];
1633
+ const problem = entry.reason
1634
+ ? t("market.reason." + entry.reason)
1635
+ : missing.length
1636
+ ? t("market.needsEnv", { names: missing.join(", ") })
1637
+ : "";
1638
+ const where = [
1639
+ entry.scope === "plugin" ? t("market.scope.inPlugin", { name: entry.pluginTitle || entry.plugin }) : t("market.scope." + entry.scope),
1640
+ entry.target,
1641
+ ].filter(Boolean).join(" · ");
1642
+ naming.appendChild(el("div", problem ? "mk-desc warn" : "mk-desc", problem || where));
1643
+ if (!problem && entry.inlineSecret) naming.appendChild(el("div", "mk-desc", t("market.inlineSecret")));
1644
+ row.title = entry.file;
1645
+ row.appendChild(naming);
1646
+
1647
+ const acts = el("div", "mk-acts");
1648
+ const key = foreignKey(entry);
1649
+ // A row that cannot be added says why in its text; the button steps back
1650
+ // rather than sitting there as a bright block that does nothing.
1651
+ const add = el("button", problem ? "mk-btn" : "mk-btn go", t(key === adding ? "market.importing" : "market.import"));
1652
+ add.type = "button";
1653
+ add.disabled = Boolean(adding) || Boolean(problem);
1654
+ add.addEventListener("click", () => void importConnector(entry));
1655
+ acts.appendChild(add);
1656
+ row.appendChild(acts);
1657
+ return row;
1658
+ }
1659
+
1660
+ /** Add one found in Claude Code's or Codex's config, by where it was found. */
1661
+ function foreignKey(entry) {
1662
+ return [entry.source, entry.scope, entry.plugin || "", entry.name].join(":");
1663
+ }
1664
+
1665
+ async function importConnector(entry) {
1666
+ if (adding) return;
1667
+ adding = foreignKey(entry);
1668
+ paintMarket();
1669
+ const body = await api(
1670
+ "/connector",
1671
+ { import: { source: entry.source, scope: entry.scope, name: entry.name, ...(entry.plugin ? { plugin: entry.plugin } : {}) } },
1672
+ 3 * 60_000,
1673
+ );
1674
+ adding = null;
1675
+ if (body.error) {
1676
+ addNotice(body.error, "err");
1677
+ return void paintMarket();
1678
+ }
1679
+ if (body.connectors) offer = { connectors: body.connectors, plugins: body.plugins || [], skills: body.skills || [], agents: body.agents || [] };
1680
+ // By name: the same server is often in both tools, and once one is here
1681
+ // the other would be a second copy of it.
1682
+ for (const one of foreignMarket) if (one.name === entry.name) one.installed = true;
1683
+ for (const one of market) if (one.id === entry.name) one.installed = true;
1684
+ paintMarketTabs();
1685
+ paintMarket();
1686
+ }
1687
+
1688
+ /** Switch a skill off or back on. */
1689
+ async function setSkill(name, enabled) {
1690
+ const body = await api("/skill", { name: name, enabled: enabled });
1691
+ if (body.error) return void addNotice(body.error, "err");
1692
+ if (body.skills) offer = { connectors: body.connectors || [], plugins: body.plugins || [], skills: body.skills, agents: body.agents || [] };
1693
+ paintMarket();
1694
+ }
1695
+
1696
+ /** The shelves the plugin list is cut into, in the order they are shown. */
1697
+ const PLUGIN_CATEGORIES = ["build", "data", "monitor", "dev", "web", "design", "work", "other"];
1698
+
1699
+ /**
1700
+ * One plugin on offer.
1701
+ *
1702
+ * Installing downloads somebody else's repository and, for a plugin with
1703
+ * servers, connects to them — so the first click says exactly that, and only
1704
+ * the second one does it. A dialog would say the same thing further from the
1705
+ * row it is about.
1706
+ */
1707
+ function pluginOfferRow(entry) {
1708
+ const row = el("div", "mk-item");
1709
+ row.appendChild(connectorMark(entry.id, entry.label, "/plugin-icon", svgIcon("plugin")));
1710
+
1711
+ const naming = el("div", "mk-naming");
1712
+ const heading = el("div", "mk-name", entry.label);
1713
+ if (entry.publisher && entry.publisher !== entry.label) heading.appendChild(el("span", "mk-tag", entry.publisher));
1714
+ naming.appendChild(heading);
1715
+ const asking = confirming === entry.id;
1716
+ naming.appendChild(el("div", asking ? "mk-desc warn" : "mk-desc", asking ? consent(entry) : entry.description));
1717
+ if (!asking) naming.appendChild(el("div", "mk-desc", provides(entry.provides || {})));
1718
+ row.appendChild(naming);
1719
+
1720
+ const acts = el("div", "mk-acts");
1721
+ const label = installing === entry.id ? "market.installing" : asking ? "market.confirm" : "market.install";
1722
+ const add = el("button", "mk-btn go", t(label));
1723
+ add.type = "button";
1724
+ add.disabled = Boolean(installing);
1725
+ add.addEventListener("click", () => {
1726
+ if (!asking) {
1727
+ confirming = entry.id;
1728
+ return void paintMarket();
1729
+ }
1730
+ void installPlugin(entry.id, false);
1731
+ });
1732
+ acts.appendChild(add);
1733
+ if (asking && !installing) {
1734
+ const cancel = el("button", "mk-btn", t("market.cancel"));
1735
+ cancel.type = "button";
1736
+ cancel.addEventListener("click", () => {
1737
+ confirming = null;
1738
+ paintMarket();
1739
+ });
1740
+ acts.appendChild(cancel);
1741
+ }
1742
+ row.appendChild(acts);
1743
+ return row;
1744
+ }
1745
+
1746
+ /** "37 skills · 3 agents · 1 connector", from what the list says the plugin holds. */
1747
+ function provides(what) {
1748
+ const parts = [];
1749
+ if (what.skills) parts.push(t(what.skills === 1 ? "market.oneSkill" : "market.skills", { n: what.skills }));
1750
+ if (what.agents) parts.push(t(what.agents === 1 ? "market.oneAgent" : "market.agents", { n: what.agents }));
1751
+ const servers = (what.servers || []).length;
1752
+ if (servers) parts.push(t(servers === 1 ? "market.oneServer" : "market.servers", { n: servers }));
1753
+ return parts.join(" · ");
1754
+ }
1755
+
1756
+ /** What the second click agrees to, said before it is made. */
1757
+ function consent(entry) {
1758
+ const servers = (entry.provides && entry.provides.servers) || [];
1759
+ return t(servers.length ? "market.consentServers" : "market.consent", {
1760
+ publisher: entry.publisher || entry.label,
1761
+ servers: servers.join(", "),
1762
+ });
1763
+ }
1764
+
1765
+ /** Install a plugin from the list, or remove one it installed. */
1766
+ async function installPlugin(id, remove) {
1767
+ if (installing) return;
1768
+ installing = remove ? null : id;
1769
+ confirming = null;
1770
+ paintMarket();
1771
+ const body = await api("/plugin-market", { id: id, remove: remove === true }, 5 * 60_000);
1772
+ installing = null;
1773
+ if (body.error) {
1774
+ addNotice(body.error, "err");
1775
+ return void paintMarket();
1776
+ }
1777
+ if (body.plugins) offer = { connectors: body.connectors || [], plugins: body.plugins, skills: body.skills || [], agents: body.agents || [] };
1778
+ for (const entry of pluginMarket) if (entry.id === id) entry.installed = remove !== true;
1779
+ if (!remove) addNotice(t("market.installedNotice", { name: (pluginMarket.find((one) => one.id === id) || { label: id }).label }));
1780
+ paintMarket();
1781
+ }
1782
+
1312
1783
  /**
1313
1784
  * Add a connector, or take one away.
1314
1785
  *
@@ -1326,7 +1797,7 @@ async function installConnector(id, remove) {
1326
1797
  addNotice(body.error, "err");
1327
1798
  return void paintMarket();
1328
1799
  }
1329
- if (body.connectors) offer = { connectors: body.connectors, plugins: body.plugins || [], skills: body.skills || [] };
1800
+ if (body.connectors) offer = { connectors: body.connectors, plugins: body.plugins || [], skills: body.skills || [], agents: body.agents || [] };
1330
1801
  for (const entry of market) if (entry.id === id) entry.installed = remove !== true;
1331
1802
  paintMarket();
1332
1803
  }
@@ -1357,7 +1828,23 @@ async function authorizeConnector(id) {
1357
1828
 
1358
1829
  document.getElementById("mk-close").appendChild(svgIcon("close"));
1359
1830
  document.getElementById("mk-close").setAttribute("aria-label", t("market.close"));
1360
- document.getElementById("mk-close").addEventListener("click", () => marketDialog.close());
1831
+ document.getElementById("mk-close").addEventListener("click", () => closeMarket());
1832
+ // Escape closes it too, and should leave the same way the button does.
1833
+ marketDialog.addEventListener("cancel", (e) => {
1834
+ e.preventDefault();
1835
+ closeMarket();
1836
+ });
1837
+
1838
+ /** Fade the dialog out before it goes, rather than vanishing mid-frame. */
1839
+ function closeMarket() {
1840
+ if (!marketDialog.open || marketDialog.classList.contains("closing")) return;
1841
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return void marketDialog.close();
1842
+ marketDialog.classList.add("closing");
1843
+ setTimeout(() => {
1844
+ marketDialog.classList.remove("closing");
1845
+ marketDialog.close();
1846
+ }, 140);
1847
+ }
1361
1848
 
1362
1849
  async function loadMentionFiles() {
1363
1850
  const seq = ++attachSeq;
@@ -1416,6 +1903,22 @@ function openAttachMenu() {
1416
1903
  offer = body;
1417
1904
  paintAttachMenu();
1418
1905
  });
1906
+ // For the import row's count; the dialog fetches its own. Kept between
1907
+ // opens, so after the first the row is there from the first frame.
1908
+ void getJson("/registry").then((body) => {
1909
+ if (body.error) return;
1910
+ foreignMarket = body.imports || [];
1911
+ if (!attachMenu.hidden && attachView === "root") paintAttachMenu();
1912
+ });
1913
+ }
1914
+
1915
+ /**
1916
+ * How many servers from Claude Code and Codex could be added now: not here
1917
+ * already, usable, and counted once when both tools declare the same one.
1918
+ */
1919
+ function foreignWaiting() {
1920
+ const here = new Set((offer.connectors || []).map((one) => one.name));
1921
+ return new Set(foreignMarket.filter((one) => !one.installed && !one.reason && !here.has(one.name)).map((one) => one.name)).size;
1419
1922
  }
1420
1923
 
1421
1924
  /**
@@ -1475,10 +1978,18 @@ function moveAttachCursor(e) {
1475
1978
 
1476
1979
  const inputMirror = document.getElementById("input-mirror");
1477
1980
 
1981
+ /**
1982
+ * The skills the model can load. The offer also carries the ones switched
1983
+ * off, for the list that switches them back on; nothing else should see them.
1984
+ */
1985
+ function usableSkills() {
1986
+ return (offer.skills || []).filter((skill) => !skill.disabled);
1987
+ }
1988
+
1478
1989
  /** Every name an @ can stand for. */
1479
1990
  function mentionNames() {
1480
1991
  return new Map([
1481
- ...(offer.skills || []).map((skill) => [skill.name, "skill"]),
1992
+ ...usableSkills().map((skill) => [skill.name, "skill"]),
1482
1993
  ...(offer.plugins || []).map((plugin) => [plugin.name, "plugin"]),
1483
1994
  ]);
1484
1995
  }
@@ -1549,7 +2060,10 @@ function paintMarks() {
1549
2060
  ensureOffer();
1550
2061
  const names = mentionNames();
1551
2062
  inputMirror.replaceChildren();
1552
- if (!names.size) return void (inputMirror.textContent = input.value);
2063
+ if (!names.size) {
2064
+ inputMirror.textContent = input.value;
2065
+ return void paintGhost();
2066
+ }
1553
2067
 
1554
2068
  const text = input.value;
1555
2069
  let at = 0;
@@ -1570,6 +2084,7 @@ function paintMarks() {
1570
2084
  }
1571
2085
  inputMirror.appendChild(document.createTextNode(text.slice(at)));
1572
2086
  if (inputMirror.querySelector("mark")) inputMirror.style.setProperty("--at-w", atWidth() + "px");
2087
+ paintGhost();
1573
2088
  }
1574
2089
 
1575
2090
  // --- the caret ------------------------------------------------------------
@@ -1632,7 +2147,10 @@ for (const event of ["input", "focus", "blur", "scroll"]) {
1632
2147
  input.addEventListener(event, placeCaret);
1633
2148
  }
1634
2149
  document.addEventListener("selectionchange", () => {
1635
- if (document.activeElement === input) placeCaret();
2150
+ if (document.activeElement !== input) return;
2151
+ placeCaret();
2152
+ // The rest of a guess is only offered from the end of the text.
2153
+ paintGhost();
1636
2154
  });
1637
2155
  window.addEventListener("resize", placeCaret);
1638
2156
 
@@ -2029,6 +2547,82 @@ input.addEventListener("keydown", (e) => {
2029
2547
  input.addEventListener("input", () => {
2030
2548
  autosize();
2031
2549
  paintMirror();
2550
+ paintSuggestion();
2551
+ });
2552
+
2553
+ // --- the next-message guess -----------------------------------------------
2554
+
2555
+ /**
2556
+ * The server's guess at what this conversation's user types next, drawn as
2557
+ * the empty box's placeholder and taken whole with Tab.
2558
+ *
2559
+ * Held per pane, so a guess written for a background tab waits there rather
2560
+ * than showing on the one on screen. Shown only where it can be acted on: an
2561
+ * idle conversation, nothing typed, and no goal or worktree mode, whose own
2562
+ * placeholder says what the next message will be sent as.
2563
+ */
2564
+ function suggestionNow() {
2565
+ const here = panes.get(activeId);
2566
+ if (!here || here.busy || staged.length || refs.length || goalMode || worktreeMode) return "";
2567
+ return here.suggestion || "";
2568
+ }
2569
+
2570
+ /** The whole guess, while the box is empty. */
2571
+ function suggestionHere() {
2572
+ return input.value ? "" : suggestionNow();
2573
+ }
2574
+
2575
+ /**
2576
+ * What is left of the guess once some of it has been typed: the text so far
2577
+ * has to be its start, letter for letter, and the caret at the end with
2578
+ * nothing selected — anywhere else, Tab finishing a sentence the caret is not
2579
+ * at would put words somewhere the user is not looking.
2580
+ */
2581
+ function ghostTail() {
2582
+ const typed = input.value;
2583
+ const guess = suggestionNow();
2584
+ if (!typed || !guess || guess.length <= typed.length || !guess.startsWith(typed)) return "";
2585
+ if (input.selectionStart !== typed.length || input.selectionEnd !== typed.length) return "";
2586
+ return guess.slice(typed.length);
2587
+ }
2588
+
2589
+ /**
2590
+ * Draw the rest of the guess after the text, on the mirror under the field.
2591
+ * The mirror lays out exactly as the field does, so the tail starts where the
2592
+ * last typed glyph ends.
2593
+ */
2594
+ function paintGhost() {
2595
+ if (!inputMirror) return;
2596
+ const old = inputMirror.querySelector(".ghost");
2597
+ if (old) old.remove();
2598
+ const tail = ghostTail();
2599
+ if (tail) inputMirror.appendChild(el("span", "ghost", tail));
2600
+ }
2601
+
2602
+ function paintSuggestion() {
2603
+ const guess = suggestionHere();
2604
+ input.placeholder = guess || inputPlaceholder();
2605
+ input.classList.toggle("suggesting", !!guess);
2606
+ paintGhost();
2607
+ }
2608
+
2609
+ input.addEventListener("keydown", (e) => {
2610
+ if ((e.key !== "Tab" && e.key !== "Escape") || e.shiftKey || e.altKey || e.metaKey || e.ctrlKey || e.isComposing) return;
2611
+ const tail = ghostTail();
2612
+ const guess = tail ? input.value + tail : suggestionHere();
2613
+ if (!guess) return;
2614
+ e.preventDefault();
2615
+ e.stopImmediatePropagation();
2616
+ if (e.key === "Escape") {
2617
+ panes.get(activeId).suggestion = null;
2618
+ paintGhost();
2619
+ return void paintSuggestion();
2620
+ }
2621
+ input.value = guess;
2622
+ input.setSelectionRange(guess.length, guess.length);
2623
+ autosize();
2624
+ paintMirror();
2625
+ paintSuggestion();
2032
2626
  });
2033
2627
 
2034
2628
  // --- the field's height ---------------------------------------------------