@bpmnkit/cli 0.0.16 → 0.0.18

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/tui.js CHANGED
@@ -1,7 +1,10 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { renderBpmnAscii } from "@bpmnkit/ascii";
3
- import { appendAuditEntry, getAuditLog, getSettings, saveSettings } from "@bpmnkit/profiles";
3
+ import { appendAuditEntry, deleteProfile, getActiveName, getAuditLog, getProfile, getSettings, listProfiles, saveSettings, useProfile, } from "@bpmnkit/profiles";
4
4
  import { runAskQuery } from "./commands/ask.js";
5
+ import { pluginGroup, searchNpmRegistry } from "./commands/plugin.js";
6
+ import { profileGroup } from "./commands/profile.js";
7
+ import { readInstalledPlugins } from "./plugin-loader.js";
5
8
  // ─── ANSI helpers ─────────────────────────────────────────────────────────────
6
9
  const CSI = "\x1b[";
7
10
  const HIDE = `${CSI}?25l`;
@@ -83,12 +86,13 @@ const SKIP_FLAGS = new Set(["output", "profile", "help", "no-color", "debug"]);
83
86
  function buildFields(cmd) {
84
87
  const fields = [];
85
88
  for (const arg of cmd.args ?? []) {
89
+ const defaultVal = arg.default ?? "";
86
90
  fields.push({
87
91
  kind: "arg",
88
92
  label: arg.name,
89
93
  hint: arg.required ? "required" : "optional",
90
- value: "",
91
- cursor: 0,
94
+ value: defaultVal,
95
+ cursor: defaultVal.length,
92
96
  required: arg.required ?? false,
93
97
  argSpec: arg,
94
98
  });
@@ -189,8 +193,9 @@ function entriesToJson(entries) {
189
193
  }
190
194
  /**
191
195
  * Build initial entries for the JSON editor.
192
- * If fieldSpecs are provided, pre-populate with all known fields (in spec order),
193
- * merging in any existing values. Extra keys from existing JSON are appended at the end.
196
+ * If fieldSpecs are provided, pre-populate only required fields (in spec order),
197
+ * merging in any existing values. Optional fields are available via the add-row picker.
198
+ * Extra keys from existing JSON that are not in the spec are appended at the end.
194
199
  */
195
200
  function buildInitialEntries(existingJson, fieldSpecs) {
196
201
  const existing = {};
@@ -209,7 +214,10 @@ function buildInitialEntries(existingJson, fieldSpecs) {
209
214
  return parseJsonToEntries(existingJson);
210
215
  const seenKeys = new Set();
211
216
  const entries = [];
217
+ // Only pre-populate required fields; optional ones are added on demand
212
218
  for (const spec of fieldSpecs) {
219
+ if (!spec.required && !(spec.name in existing))
220
+ continue;
213
221
  seenKeys.add(spec.name);
214
222
  const val = existing[spec.name] ?? "";
215
223
  entries.push({ key: spec.name, keyCursor: spec.name.length, val, valCursor: val.length });
@@ -226,6 +234,57 @@ function buildInitialEntries(existingJson, fieldSpecs) {
226
234
  function getFieldSpec(key, fieldSpecs) {
227
235
  return fieldSpecs?.find((s) => s.name === key);
228
236
  }
237
+ /**
238
+ * Return specs not already used as keys by OTHER entries (not the one at currentIndex).
239
+ * Pass null for currentIndex when adding a brand-new entry.
240
+ */
241
+ function getAvailableFieldSpecs(currentIndex, entries, fieldSpecs) {
242
+ const usedKeys = new Set(entries
243
+ .filter((_, i) => i !== currentIndex)
244
+ .map((e) => e.key)
245
+ .filter(Boolean));
246
+ return fieldSpecs.filter((s) => !usedKeys.has(s.name));
247
+ }
248
+ /** Build profile info entries (key/value pairs with secrets redacted). */
249
+ function buildProfileInfoEntries(profileName) {
250
+ const p = getProfile(profileName);
251
+ if (!p)
252
+ return [{ key: "status", value: "profile not found" }];
253
+ const info = [
254
+ { key: "name", value: p.name },
255
+ { key: "apiType", value: p.apiType },
256
+ { key: "baseUrl", value: p.config.baseUrl ?? "(default)" },
257
+ ];
258
+ const auth = p.config.auth;
259
+ if (auth) {
260
+ info.push({ key: "auth.type", value: auth.type });
261
+ if (auth.type === "bearer") {
262
+ info.push({ key: "auth.token", value: "***" });
263
+ }
264
+ else if (auth.type === "oauth2") {
265
+ info.push({ key: "auth.clientId", value: auth.clientId });
266
+ info.push({ key: "auth.clientSecret", value: "***" });
267
+ info.push({ key: "auth.tokenUrl", value: auth.tokenUrl });
268
+ }
269
+ else if (auth.type === "basic") {
270
+ info.push({ key: "auth.username", value: auth.username });
271
+ info.push({ key: "auth.password", value: "***" });
272
+ }
273
+ }
274
+ return info;
275
+ }
276
+ /** Rebuild profile list items after use/delete. */
277
+ function rebuildProfileListItems() {
278
+ const profiles = listProfiles();
279
+ const active = getActiveName();
280
+ return profiles.map((p) => ({
281
+ active: p.name === active ? "●" : " ",
282
+ name: p.name,
283
+ apiType: p.apiType,
284
+ baseUrl: p.config.baseUrl ?? "(from env/file)",
285
+ authType: p.config.auth?.type ?? "—",
286
+ }));
287
+ }
229
288
  // ─── Table helpers ────────────────────────────────────────────────────────────
230
289
  function getCellStr(item, col) {
231
290
  let val = item;
@@ -376,8 +435,12 @@ function renderMain(state, screen) {
376
435
  const desc = dim(fit(g.description, cols - nameW - 8));
377
436
  const line = ` ${name} ${desc}`;
378
437
  lines.push(isCursor ? inv(line.padEnd(cols - 1)) : line);
379
- // Add separator after the pinned top section (ask, settings, profile)
380
- if (!searching && g.name === "profile" && i < groups.length - 1) {
438
+ // Separator after pinned section (after "worker", before plugins or api groups)
439
+ if (!searching && g.name === "worker" && i < groups.length - 1) {
440
+ lines.push(` ${dim("─".repeat(cols - 4))}`);
441
+ }
442
+ // Separator after the last plugin group (before api groups), only when plugins exist
443
+ if (!searching && g._plugin && !groups[i + 1]?._plugin && i < groups.length - 1) {
381
444
  lines.push(` ${dim("─".repeat(cols - 4))}`);
382
445
  }
383
446
  }
@@ -507,6 +570,22 @@ function renderInput(state, screen) {
507
570
  lines.push("");
508
571
  if (screen.error) {
509
572
  lines.push(` ${red("error:")} ${screen.error}`);
573
+ if (screen.errorRaw) {
574
+ const raw = screen.errorRaw;
575
+ lines.push(` ${dim(`${raw.method} ${raw.url}`)}`);
576
+ lines.push(` ${red(`HTTP ${raw.status}`)}`);
577
+ if (raw.body) {
578
+ try {
579
+ const parsed = JSON.parse(raw.body);
580
+ for (const l of JSON.stringify(parsed, null, 2).split("\n")) {
581
+ lines.push(` ${dim(l)}`);
582
+ }
583
+ }
584
+ catch {
585
+ lines.push(` ${dim(raw.body)}`);
586
+ }
587
+ }
588
+ }
510
589
  }
511
590
  else if (screen.running) {
512
591
  lines.push(` ${dim("running…")}`);
@@ -699,7 +778,12 @@ function renderResults(state, screen) {
699
778
  lines.push(` ${dim(`${screen.scroll + 1}–${hi} of ${out.total}`)}`);
700
779
  }
701
780
  const followupHint = screen.cmd.relations ? ` ${cyan("f")} follow-up` : "";
702
- lines.push(` ${dim("↑↓")} navigate ${cyan("enter")} detail${followupHint} ${dim("pgup/pgdn")} page${rawToggle}${curlToggle} ${cyan("m")} main menu ${cyan("esc")} back ${cyan("q")} quit`);
781
+ const profileListHints = screen.group.name === "profile" && screen.cmd.name === "list"
782
+ ? ` ${cyan("u")} use ${cyan("s")} show ${cyan("d")} delete`
783
+ : "";
784
+ if (screen.message)
785
+ lines.push(` ${green(screen.message)}`);
786
+ lines.push(` ${dim("↑↓")} navigate ${cyan("enter")} detail${followupHint}${profileListHints} ${dim("pgup/pgdn")} page${rawToggle}${curlToggle} ${cyan("m")} main menu ${cyan("esc")} back ${cyan("q")} quit`);
703
787
  }
704
788
  else if (out.type === "item") {
705
789
  lines.push(renderHeader([screen.group.name, screen.cmd.name], cols, state.profile));
@@ -836,7 +920,7 @@ function renderProfile(state, screen) {
836
920
  const hi = Math.min(screen.scroll + viewH, state.profileInfo.length);
837
921
  lines.push(` ${dim(`${screen.scroll + 1}–${hi} of ${state.profileInfo.length}`)}`);
838
922
  }
839
- lines.push(` ${dim("↑↓")} scroll ${cyan("esc/p")} close ${cyan("q")} quit`);
923
+ lines.push(` ${dim("↑↓")} scroll ${cyan("esc")} close ${cyan("q")} quit`);
840
924
  return lines;
841
925
  }
842
926
  const SETTINGS_ROWS = [
@@ -854,6 +938,24 @@ const SETTINGS_ROWS = [
854
938
  label: "Show Audit Log",
855
939
  description: "View recent commands executed for the active profile",
856
940
  },
941
+ {
942
+ kind: "nav",
943
+ id: "active-profile",
944
+ label: "Active Profile",
945
+ description: "View details for the current connection profile",
946
+ },
947
+ {
948
+ kind: "nav",
949
+ id: "profiles",
950
+ label: "Profiles",
951
+ description: "Manage and switch connection profiles",
952
+ },
953
+ {
954
+ kind: "nav",
955
+ id: "plugins",
956
+ label: "Plugins",
957
+ description: "Browse and manage CLI plugins",
958
+ },
857
959
  ];
858
960
  function renderSettings(state, screen) {
859
961
  const { cols } = termSize();
@@ -936,7 +1038,7 @@ function renderWorker(state, screen) {
936
1038
  : screen.status === "stopping"
937
1039
  ? cyan("◌ STOPPING")
938
1040
  : dim("■ STOPPED");
939
- const lines = [renderHeader(["job", "worker"], cols, state.profile), ""];
1041
+ const lines = [renderHeader(["worker"], cols, state.profile), ""];
940
1042
  lines.push(` ${padEnd(dim("type"), 8)} ${cyan(screen.jobType)} ${dim("status")} ${statusStr}`);
941
1043
  lines.push(` ${dim("─".repeat(cols - 4))}`);
942
1044
  const { activated, completed, failed } = screen.stats;
@@ -1006,12 +1108,15 @@ function renderJsonEditor(state, screen) {
1006
1108
  const editKey = isCursor && screen.col === "key" && screen.editing;
1007
1109
  const editVal = isCursor && screen.col === "val" && screen.editing;
1008
1110
  const keyStr = renderText(entry.key, entry.keyCursor, keyW - (isKnown ? 0 : 0), editKey);
1009
- // For known enum fields in nav mode, show cycling hint instead of raw value
1111
+ // For known enum fields in nav mode, show cycling hint instead of raw value.
1112
+ // For non-enum known fields, show a type placeholder when value is empty.
1010
1113
  const valDisplay = !editVal && hasEnum && entry.val
1011
1114
  ? `${entry.val} ${dim("↑↓")}`
1012
1115
  : !editVal && hasEnum && !entry.val
1013
1116
  ? dim("<pick ↑↓>")
1014
- : renderText(entry.val, entry.valCursor, valW - 4, editVal);
1117
+ : !editVal && !hasEnum && spec && !entry.val
1118
+ ? dim(`<${spec.type}>`)
1119
+ : renderText(entry.val, entry.valCursor, valW - 4, editVal);
1015
1120
  const valStr = valDisplay;
1016
1121
  const keyPart = isCursor && screen.col === "key" && !editKey
1017
1122
  ? cyan(padEnd(keyStr, keyW))
@@ -1053,7 +1158,9 @@ function renderJsonEditor(state, screen) {
1053
1158
  : undefined;
1054
1159
  const editHint = editSpec?.enum
1055
1160
  ? `${dim("↑↓")} pick value ${cyan("enter")} confirm ${cyan("esc")} cancel`
1056
- : `${dim("←→")} cursor ${cyan("tab")} switch col ${cyan("enter")} confirm ${cyan("esc")} cancel`;
1161
+ : screen.col === "key" && screen.fieldSpecs && screen.fieldSpecs.length > 0
1162
+ ? `${dim("↑↓")} pick field ${dim("←→")} cursor ${cyan("tab")} switch col ${cyan("enter")} confirm ${cyan("esc")} cancel`
1163
+ : `${dim("←→")} cursor ${cyan("tab")} switch col ${cyan("enter")} confirm ${cyan("esc")} cancel`;
1057
1164
  lines.push(` ${editHint}`);
1058
1165
  }
1059
1166
  else {
@@ -1114,7 +1221,6 @@ async function runWorkerLoop(ws, state, variables, jobTimeout, maxJobs) {
1114
1221
  worker: "casen-worker",
1115
1222
  timeout: jobTimeout,
1116
1223
  maxJobsToActivate: maxJobs,
1117
- requestTimeout: 20000,
1118
1224
  }));
1119
1225
  }
1120
1226
  catch (err) {
@@ -1128,20 +1234,56 @@ async function runWorkerLoop(ws, state, variables, jobTimeout, maxJobs) {
1128
1234
  for (const job of jobs) {
1129
1235
  if (!running)
1130
1236
  break;
1237
+ // Some Zeebe-compatible engines return `key` instead of `jobKey`
1238
+ const jobKey = job.jobKey ?? job.key;
1239
+ if (!jobKey) {
1240
+ addWorkerLog(ws, "err", "Activated job has no key — skipping");
1241
+ continue;
1242
+ }
1131
1243
  ws.stats.activated++;
1132
- addWorkerLog(ws, "info", `Activated ${job.jobKey} process=${job.processDefinitionId} element=${job.elementId}`);
1244
+ addWorkerLog(ws, "info", `Activated ${jobKey} process=${job.processDefinitionId} element=${job.elementId}`);
1245
+ let jobResult = { outcome: "complete", variables };
1246
+ const processJob = ws.cmd._worker?.processJob;
1247
+ if (processJob) {
1248
+ try {
1249
+ jobResult = await processJob({ ...job, jobKey });
1250
+ }
1251
+ catch (err) {
1252
+ addWorkerLog(ws, "err", `Handler error: ${err instanceof Error ? err.message : String(err)} — using defaults`);
1253
+ }
1254
+ }
1133
1255
  try {
1134
- await client.job.completeJob(job.jobKey, { variables });
1135
- ws.stats.completed++;
1136
- addWorkerLog(ws, "ok", `Completed ${job.jobKey}`);
1256
+ if (jobResult.outcome === "complete") {
1257
+ await client.job.completeJob(jobKey, { variables: jobResult.variables });
1258
+ ws.stats.completed++;
1259
+ addWorkerLog(ws, "ok", `Completed ${jobKey}`);
1260
+ }
1261
+ else if (jobResult.outcome === "fail") {
1262
+ await client.job.failJob(jobKey, {
1263
+ errorMessage: jobResult.errorMessage,
1264
+ retries: jobResult.retries,
1265
+ retryBackOff: jobResult.retryBackOff,
1266
+ });
1267
+ ws.stats.failed++;
1268
+ addWorkerLog(ws, "err", `Failed ${jobKey}: ${jobResult.errorMessage}`);
1269
+ }
1270
+ else {
1271
+ await client.job.throwJobError(jobKey, {
1272
+ errorCode: jobResult.errorCode,
1273
+ errorMessage: jobResult.errorMessage,
1274
+ variables: jobResult.variables,
1275
+ });
1276
+ ws.stats.failed++;
1277
+ addWorkerLog(ws, "err", `Error ${jobKey} [${jobResult.errorCode}]: ${jobResult.errorMessage ?? ""}`);
1278
+ }
1137
1279
  }
1138
1280
  catch (err) {
1139
1281
  ws.stats.failed++;
1140
- addWorkerLog(ws, "err", `Failed to complete ${job.jobKey}: ${err instanceof Error ? err.message : String(err)}`);
1282
+ addWorkerLog(ws, "err", `Failed to settle ${jobKey}: ${err instanceof Error ? err.message : String(err)}`);
1141
1283
  }
1142
1284
  }
1143
- if (jobs.length > 0)
1144
- await new Promise((r) => setTimeout(r, 100));
1285
+ // Sleep between polls: brief yield after processing jobs, longer pause when idle
1286
+ await new Promise((r) => setTimeout(r, jobs.length > 0 ? 100 : 2000));
1145
1287
  }
1146
1288
  ws.status = "stopped";
1147
1289
  addWorkerLog(ws, "info", `Stopped. Activated: ${ws.stats.activated} Completed: ${ws.stats.completed} Failed: ${ws.stats.failed}`);
@@ -1265,6 +1407,7 @@ async function runAskInTui(screen, state) {
1265
1407
  },
1266
1408
  raw: null,
1267
1409
  rawView: false,
1410
+ message: "",
1268
1411
  curlView: false,
1269
1412
  altView: false,
1270
1413
  cursor: 0,
@@ -1403,6 +1546,9 @@ function render(state) {
1403
1546
  case "json-editor":
1404
1547
  lines = renderJsonEditor(state, screen);
1405
1548
  break;
1549
+ case "plugins":
1550
+ lines = renderPlugins(state, screen);
1551
+ break;
1406
1552
  }
1407
1553
  process.stdout.write(`${CLEAR}${lines.join("\n")}\n`);
1408
1554
  }
@@ -1485,6 +1631,25 @@ function handleMainKey(key, screen, state, done) {
1485
1631
  _timer: null,
1486
1632
  });
1487
1633
  }
1634
+ else if (group &&
1635
+ group.commands.length === 1 &&
1636
+ group.commands[0] &&
1637
+ "_worker" in group.commands[0]) {
1638
+ // Single-command worker group: skip commands list, go straight to input
1639
+ const cmd = group.commands[0];
1640
+ state.stack.push({
1641
+ kind: "input",
1642
+ group,
1643
+ cmd,
1644
+ fields: buildFields(cmd),
1645
+ cursor: 0,
1646
+ scroll: 0,
1647
+ editing: false,
1648
+ error: "",
1649
+ errorRaw: null,
1650
+ running: false,
1651
+ });
1652
+ }
1488
1653
  else if (group) {
1489
1654
  state.stack.push({ kind: "commands", group, cursor: 0, search: "" });
1490
1655
  }
@@ -1549,6 +1714,7 @@ function handleCommandsKey(key, screen, state, done) {
1549
1714
  scroll: 0,
1550
1715
  editing: false,
1551
1716
  error: "",
1717
+ errorRaw: null,
1552
1718
  running: false,
1553
1719
  });
1554
1720
  }
@@ -1578,6 +1744,7 @@ async function executeCommand(screen, state) {
1578
1744
  }
1579
1745
  screen.running = true;
1580
1746
  screen.error = "";
1747
+ screen.errorRaw = null;
1581
1748
  render(state);
1582
1749
  const { writer, get } = makeCapturingWriter();
1583
1750
  // Wrap client factories to capture the last raw HTTP response
@@ -1636,6 +1803,7 @@ async function executeCommand(screen, state) {
1636
1803
  altView: false,
1637
1804
  cursor: 0,
1638
1805
  scroll: 0,
1806
+ message: "",
1639
1807
  });
1640
1808
  }
1641
1809
  catch (err) {
@@ -1649,6 +1817,7 @@ async function executeCommand(screen, state) {
1649
1817
  error: msg,
1650
1818
  });
1651
1819
  screen.error = msg;
1820
+ screen.errorRaw = rawCapture;
1652
1821
  }
1653
1822
  finally {
1654
1823
  screen.running = false;
@@ -1794,7 +1963,7 @@ async function handleInputKey(key, screen, state, done) {
1794
1963
  if (!field)
1795
1964
  break;
1796
1965
  if (field.kind === "run") {
1797
- if (screen.cmd.name === "worker") {
1966
+ if ("_worker" in screen.cmd) {
1798
1967
  launchWorkerView(screen, state);
1799
1968
  }
1800
1969
  else {
@@ -1864,6 +2033,60 @@ async function handleInputKey(key, screen, state, done) {
1864
2033
  function handleResultsKey(key, screen, state, done) {
1865
2034
  const { rows } = termSize();
1866
2035
  const viewH = Math.max(3, rows - 10);
2036
+ // Profile list inline shortcuts: u=use, s=show, d=delete
2037
+ if (screen.group.name === "profile" &&
2038
+ screen.cmd.name === "list" &&
2039
+ screen.output.type === "list") {
2040
+ // Clear stale message on any key in this context
2041
+ screen.message = "";
2042
+ if (key === "u" || key === "U") {
2043
+ const item = screen.output.items[screen.cursor];
2044
+ const name = item?.name;
2045
+ if (name && useProfile(name)) {
2046
+ state.profile = name;
2047
+ state.profileInfo = buildProfileInfoEntries(name);
2048
+ const items = rebuildProfileListItems();
2049
+ screen.output = { type: "list", items, columns: screen.output.columns, total: items.length };
2050
+ screen.message = `✓ Active profile: ${name}`;
2051
+ }
2052
+ else if (name) {
2053
+ screen.message = `Profile "${name}" not found`;
2054
+ }
2055
+ render(state);
2056
+ return;
2057
+ }
2058
+ if (key === "s" || key === "S") {
2059
+ const item = screen.output.items[screen.cursor];
2060
+ if (item) {
2061
+ state.stack.push({
2062
+ kind: "detail",
2063
+ group: screen.group,
2064
+ cmd: screen.cmd,
2065
+ item,
2066
+ label: String(item.name ?? "profile"),
2067
+ cursor: 0,
2068
+ scroll: 0,
2069
+ });
2070
+ }
2071
+ render(state);
2072
+ return;
2073
+ }
2074
+ if (key === "d" || key === "D") {
2075
+ const item = screen.output.items[screen.cursor];
2076
+ const name = item?.name;
2077
+ if (name && deleteProfile(name)) {
2078
+ const items = rebuildProfileListItems();
2079
+ screen.output = { type: "list", items, columns: screen.output.columns, total: items.length };
2080
+ screen.cursor = Math.min(screen.cursor, Math.max(0, items.length - 1));
2081
+ screen.message = `✓ Deleted profile "${name}"`;
2082
+ }
2083
+ else if (name) {
2084
+ screen.message = `Cannot delete "${name}" (modeler profiles are read-only)`;
2085
+ }
2086
+ render(state);
2087
+ return;
2088
+ }
2089
+ }
1867
2090
  // r/R toggles raw view; u/U toggles curl view — mutually exclusive
1868
2091
  if (key === "r" || key === "R") {
1869
2092
  screen.rawView = !screen.rawView;
@@ -2304,6 +2527,7 @@ function handleFollowupKey(key, screen, state, done) {
2304
2527
  scroll: 0,
2305
2528
  editing: false,
2306
2529
  error: "",
2530
+ errorRaw: null,
2307
2531
  running: false,
2308
2532
  });
2309
2533
  }
@@ -2354,6 +2578,29 @@ function handleJsonEditorKey(key, screen, state, done) {
2354
2578
  entry.valCursor = cur;
2355
2579
  }
2356
2580
  };
2581
+ // Key cycling for key column when fieldSpecs are available
2582
+ if (activeIsKey && screen.fieldSpecs && screen.fieldSpecs.length > 0) {
2583
+ const available = getAvailableFieldSpecs(screen.cursor, screen.entries, screen.fieldSpecs);
2584
+ if (available.length > 0) {
2585
+ if (key === "\x1b[A" || key === "\x1b[B") {
2586
+ const curIdx = available.findIndex((s) => s.name === entry.key);
2587
+ let nextIdx;
2588
+ if (key === "\x1b[A") {
2589
+ nextIdx = curIdx <= 0 ? available.length - 1 : curIdx - 1;
2590
+ }
2591
+ else {
2592
+ nextIdx = curIdx < 0 || curIdx >= available.length - 1 ? 0 : curIdx + 1;
2593
+ }
2594
+ const newSpec = available[nextIdx];
2595
+ if (newSpec) {
2596
+ entry.key = newSpec.name;
2597
+ entry.keyCursor = entry.key.length;
2598
+ }
2599
+ render(state);
2600
+ return;
2601
+ }
2602
+ }
2603
+ }
2357
2604
  // Enum cycling for value column of a known enum field
2358
2605
  const valSpec = !activeIsKey ? getFieldSpec(entry.key, screen.fieldSpecs) : undefined;
2359
2606
  const enumVals = valSpec?.enum;
@@ -2476,8 +2723,17 @@ function handleJsonEditorKey(key, screen, state, done) {
2476
2723
  case "\r":
2477
2724
  case "\n":
2478
2725
  if (isAddRow) {
2479
- // Add a new entry and start editing its key
2480
- screen.entries.push({ key: "", keyCursor: 0, val: "", valCursor: 0 });
2726
+ // Add a new entry; pre-seed key from first available field spec if available
2727
+ const newEntry = { key: "", keyCursor: 0, val: "", valCursor: 0 };
2728
+ if (screen.fieldSpecs && screen.fieldSpecs.length > 0) {
2729
+ const available = getAvailableFieldSpecs(null, screen.entries, screen.fieldSpecs);
2730
+ const firstSpec = available[0];
2731
+ if (firstSpec) {
2732
+ newEntry.key = firstSpec.name;
2733
+ newEntry.keyCursor = firstSpec.name.length;
2734
+ }
2735
+ }
2736
+ screen.entries.push(newEntry);
2481
2737
  screen.cursor = screen.entries.length - 1;
2482
2738
  screen.col = "key";
2483
2739
  screen.editing = true;
@@ -2495,18 +2751,23 @@ function handleJsonEditorKey(key, screen, state, done) {
2495
2751
  }
2496
2752
  break;
2497
2753
  case "a":
2498
- case "A":
2499
- // Insert new entry after cursor and start editing
2500
- screen.entries.splice(screen.cursor + 1, 0, {
2501
- key: "",
2502
- keyCursor: 0,
2503
- val: "",
2504
- valCursor: 0,
2505
- });
2754
+ case "A": {
2755
+ // Insert new entry after cursor; pre-seed key from first available field spec
2756
+ const addEntry = { key: "", keyCursor: 0, val: "", valCursor: 0 };
2757
+ if (screen.fieldSpecs && screen.fieldSpecs.length > 0) {
2758
+ const available = getAvailableFieldSpecs(null, screen.entries, screen.fieldSpecs);
2759
+ const firstSpec = available[0];
2760
+ if (firstSpec) {
2761
+ addEntry.key = firstSpec.name;
2762
+ addEntry.keyCursor = firstSpec.name.length;
2763
+ }
2764
+ }
2765
+ screen.entries.splice(screen.cursor + 1, 0, addEntry);
2506
2766
  screen.cursor = Math.min(screen.cursor + 1, screen.entries.length - 1);
2507
2767
  screen.col = "key";
2508
2768
  screen.editing = true;
2509
2769
  break;
2770
+ }
2510
2771
  case "d":
2511
2772
  case "D":
2512
2773
  if (!isAddRow && screen.entries.length > 0) {
@@ -2600,6 +2861,15 @@ function handleSettingsKey(key, screen, state, done) {
2600
2861
  else if (row?.kind === "action" && row.id === "show-audit-log") {
2601
2862
  state.stack.push({ kind: "audit-log", scroll: 0 });
2602
2863
  }
2864
+ else if (row?.kind === "nav" && row.id === "active-profile") {
2865
+ state.stack.push({ kind: "profile", scroll: 0 });
2866
+ }
2867
+ else if (row?.kind === "nav" && row.id === "profiles") {
2868
+ state.stack.push({ kind: "commands", group: profileGroup, cursor: 0, search: "" });
2869
+ }
2870
+ else if (row?.kind === "nav" && row.id === "plugins") {
2871
+ state.stack.push(newPluginsScreen());
2872
+ }
2603
2873
  break;
2604
2874
  }
2605
2875
  case "\x1b":
@@ -2693,6 +2963,499 @@ function handleWorkerKey(key, screen, state, done) {
2693
2963
  }
2694
2964
  render(state);
2695
2965
  }
2966
+ // ─── Plugins screen ───────────────────────────────────────────────────────────
2967
+ const PLUGIN_MENU = [
2968
+ { id: "search", label: "search", description: "Search the npm registry for plugins" },
2969
+ { id: "list", label: "list", description: "Show installed plugins" },
2970
+ { id: "install", label: "install", description: "Install a plugin from npm or local path" },
2971
+ {
2972
+ id: "update",
2973
+ label: "update",
2974
+ description: "Update installed plugins to their latest versions",
2975
+ },
2976
+ { id: "remove", label: "remove", description: "Remove a plugin" },
2977
+ ];
2978
+ function newPluginsScreen() {
2979
+ return {
2980
+ kind: "plugins",
2981
+ subview: "menu",
2982
+ menuCursor: 0,
2983
+ query: "",
2984
+ queryCursor: 0,
2985
+ resultKind: "search",
2986
+ results: [],
2987
+ resultCursor: 0,
2988
+ resultScroll: 0,
2989
+ promptLabel: "",
2990
+ promptValue: "",
2991
+ promptCursor: 0,
2992
+ promptAction: "install",
2993
+ message: "",
2994
+ error: "",
2995
+ running: false,
2996
+ };
2997
+ }
2998
+ function renderPlugins(state, screen) {
2999
+ const { cols, rows } = termSize();
3000
+ if (screen.subview === "menu") {
3001
+ const nameW = PLUGIN_MENU.reduce((m, item) => Math.max(m, item.label.length), 0) + 2;
3002
+ const lines = [renderHeader(["settings", "plugins"], cols, state.profile), ""];
3003
+ for (let i = 0; i < PLUGIN_MENU.length; i++) {
3004
+ const item = PLUGIN_MENU[i];
3005
+ if (!item)
3006
+ continue;
3007
+ const isCursor = i === screen.menuCursor;
3008
+ const name = padEnd(isCursor ? cyan(item.label) : item.label, nameW);
3009
+ const desc = dim(fit(item.description, cols - nameW - 6));
3010
+ const line = ` ${name} ${desc}`;
3011
+ lines.push(isCursor ? inv(line.padEnd(cols - 1)) : line);
3012
+ }
3013
+ lines.push("");
3014
+ lines.push(` ${dim("↑↓")} navigate ${cyan("enter")} open ${cyan("esc")} back ${cyan("q")} quit`);
3015
+ return lines;
3016
+ }
3017
+ if (screen.subview === "search") {
3018
+ const lines = [renderHeader(["settings", "plugins", "search"], cols, state.profile), ""];
3019
+ lines.push(` ${dim("Search the npm registry — leave empty to browse all plugins")}`);
3020
+ lines.push("");
3021
+ lines.push(` > ${renderText(screen.query, screen.queryCursor, cols - 6, true)}`);
3022
+ if (screen.running) {
3023
+ lines.push("");
3024
+ lines.push(` ${dim("Searching…")}`);
3025
+ }
3026
+ else if (screen.error) {
3027
+ lines.push("");
3028
+ lines.push(` ${red("error:")} ${screen.error}`);
3029
+ }
3030
+ lines.push("");
3031
+ lines.push(` ${dim("type query")} ${cyan("enter")} search ${cyan("esc")} back ${cyan("^C")} quit`);
3032
+ return lines;
3033
+ }
3034
+ if (screen.subview === "results") {
3035
+ const viewH = Math.max(3, rows - 10);
3036
+ const isSearch = screen.resultKind === "search";
3037
+ const title = isSearch ? "search results" : "installed";
3038
+ const lines = [renderHeader(["settings", "plugins", title], cols, state.profile), ""];
3039
+ if (screen.results.length === 0) {
3040
+ lines.push(` ${dim(isSearch ? "No plugins found." : "No plugins installed.")}`);
3041
+ }
3042
+ else {
3043
+ const nameW = Math.min(32, screen.results.reduce((m, r) => Math.max(m, r.name.length), 0)) + 2;
3044
+ // Official badge shown for @bpmnkit/* packages (2 visible chars: "◆ ")
3045
+ const BADGE = `${cyan("◆")} `;
3046
+ const BADGE_W = 2;
3047
+ const visible = screen.results.slice(screen.resultScroll, screen.resultScroll + viewH);
3048
+ for (let vi = 0; vi < visible.length; vi++) {
3049
+ const r = visible[vi];
3050
+ if (!r)
3051
+ continue;
3052
+ const ri = screen.resultScroll + vi;
3053
+ const isCursor = ri === screen.resultCursor;
3054
+ const isOfficial = r.name.startsWith("@bpmnkit/");
3055
+ const badge = isOfficial ? BADGE : " ".repeat(BADGE_W);
3056
+ const name = padEnd(isCursor ? cyan(r.name) : r.name, nameW);
3057
+ const verStr = r.version.padEnd(10);
3058
+ const extra = isSearch
3059
+ ? dim(fit(r.description, cols - nameW - BADGE_W - 14))
3060
+ : dim(r.installedAt.slice(0, 10));
3061
+ const line = ` ${badge}${name} ${verStr} ${extra}`;
3062
+ lines.push(isCursor ? inv(line.padEnd(cols - 1)) : line);
3063
+ }
3064
+ }
3065
+ if (screen.message) {
3066
+ lines.push("");
3067
+ lines.push(` ${screen.message}`);
3068
+ }
3069
+ lines.push("");
3070
+ const actions = isSearch
3071
+ ? `${cyan("enter/i")} install ${cyan("esc")} back ${cyan("q")} quit`
3072
+ : `${cyan("enter/r")} remove ${cyan("esc")} back ${cyan("q")} quit`;
3073
+ lines.push(` ${dim("↑↓")} navigate ${actions}`);
3074
+ lines.push(` ${cyan("◆")} ${dim("= official @bpmnkit plugin")}`);
3075
+ return lines;
3076
+ }
3077
+ if (screen.subview === "prompt") {
3078
+ const lines = [
3079
+ renderHeader(["settings", "plugins", screen.promptAction], cols, state.profile),
3080
+ "",
3081
+ ];
3082
+ lines.push(` ${screen.promptLabel}`);
3083
+ lines.push("");
3084
+ lines.push(` > ${renderText(screen.promptValue, screen.promptCursor, cols - 6, true)}`);
3085
+ if (screen.running) {
3086
+ lines.push("");
3087
+ lines.push(` ${dim("Running…")}`);
3088
+ }
3089
+ else if (screen.error) {
3090
+ lines.push("");
3091
+ lines.push(` ${red("error:")} ${screen.error}`);
3092
+ }
3093
+ lines.push("");
3094
+ lines.push(` ${dim("type name")} ${cyan("enter")} confirm ${cyan("esc")} back ${cyan("^C")} quit`);
3095
+ return lines;
3096
+ }
3097
+ // "done"
3098
+ const lines = [renderHeader(["settings", "plugins"], cols, state.profile), "", ""];
3099
+ if (screen.error) {
3100
+ lines.push(` ${red("error:")} ${screen.error}`);
3101
+ }
3102
+ else {
3103
+ for (const line of screen.message.split("\n")) {
3104
+ lines.push(` ${line}`);
3105
+ }
3106
+ }
3107
+ lines.push("");
3108
+ lines.push(` ${cyan("esc")} back ${cyan("q")} quit`);
3109
+ return lines;
3110
+ }
3111
+ /** Temporarily restore the normal terminal, run fn (which may spawn processes), then re-enter. */
3112
+ async function runWithTerminal(fn) {
3113
+ process.stdout.write(`${ALT_OFF}${SHOW}`);
3114
+ if (process.stdin.isTTY)
3115
+ process.stdin.setRawMode(false);
3116
+ try {
3117
+ await fn();
3118
+ }
3119
+ finally {
3120
+ if (process.stdin.isTTY)
3121
+ process.stdin.setRawMode(true);
3122
+ process.stdout.write(`${ALT_ON}${HIDE}${CLEAR}`);
3123
+ }
3124
+ }
3125
+ async function handlePluginsKey(key, screen, state, done) {
3126
+ if (key === "\x03") {
3127
+ done();
3128
+ return;
3129
+ }
3130
+ // ── Menu subview ──────────────────────────────────────────────────────────
3131
+ if (screen.subview === "menu") {
3132
+ switch (key) {
3133
+ case "\x1b[A":
3134
+ if (screen.menuCursor > 0)
3135
+ screen.menuCursor--;
3136
+ break;
3137
+ case "\x1b[B":
3138
+ if (screen.menuCursor < PLUGIN_MENU.length - 1)
3139
+ screen.menuCursor++;
3140
+ break;
3141
+ case "\r":
3142
+ case "\n": {
3143
+ const item = PLUGIN_MENU[screen.menuCursor];
3144
+ if (item?.id === "search") {
3145
+ screen.subview = "search";
3146
+ screen.query = "";
3147
+ screen.queryCursor = 0;
3148
+ screen.error = "";
3149
+ }
3150
+ else if (item?.id === "list") {
3151
+ screen.running = true;
3152
+ screen.error = "";
3153
+ render(state);
3154
+ try {
3155
+ const plugins = await readInstalledPlugins();
3156
+ screen.resultKind = "installed";
3157
+ screen.results = plugins.map((p) => ({
3158
+ name: p.package,
3159
+ version: p.version,
3160
+ description: "",
3161
+ publisher: "",
3162
+ score: "",
3163
+ installedAt: p.installedAt,
3164
+ }));
3165
+ screen.resultCursor = 0;
3166
+ screen.resultScroll = 0;
3167
+ screen.message = "";
3168
+ screen.subview = "results";
3169
+ }
3170
+ catch (err) {
3171
+ screen.error = err instanceof Error ? err.message : String(err);
3172
+ }
3173
+ finally {
3174
+ screen.running = false;
3175
+ }
3176
+ }
3177
+ else if (item?.id === "install") {
3178
+ screen.subview = "prompt";
3179
+ screen.promptLabel = "Package name (e.g. casen-deploy or ./local-path):";
3180
+ screen.promptValue = "";
3181
+ screen.promptCursor = 0;
3182
+ screen.promptAction = "install";
3183
+ screen.error = "";
3184
+ }
3185
+ else if (item?.id === "update") {
3186
+ await runWithTerminal(async () => {
3187
+ const cmd = pluginGroup.commands.find((c) => c.name === "update");
3188
+ if (!cmd)
3189
+ return;
3190
+ const { writer } = makeCapturingWriter();
3191
+ await cmd.run({
3192
+ positional: [],
3193
+ flags: {},
3194
+ output: writer,
3195
+ getClient: state.getClient,
3196
+ getAdminClient: state.getAdminClient,
3197
+ });
3198
+ });
3199
+ screen.subview = "done";
3200
+ screen.message = green("✓ Update complete. Restart casen to activate changes.");
3201
+ screen.error = "";
3202
+ }
3203
+ else if (item?.id === "remove") {
3204
+ screen.subview = "prompt";
3205
+ screen.promptLabel = "Plugin package name to remove:";
3206
+ screen.promptValue = "";
3207
+ screen.promptCursor = 0;
3208
+ screen.promptAction = "remove";
3209
+ screen.error = "";
3210
+ }
3211
+ break;
3212
+ }
3213
+ case "\x1b":
3214
+ state.stack.pop();
3215
+ break;
3216
+ case "q":
3217
+ case "Q":
3218
+ done();
3219
+ return;
3220
+ }
3221
+ render(state);
3222
+ return;
3223
+ }
3224
+ // ── Search subview ────────────────────────────────────────────────────────
3225
+ if (screen.subview === "search") {
3226
+ if (key === "\x1b") {
3227
+ screen.subview = "menu";
3228
+ render(state);
3229
+ return;
3230
+ }
3231
+ if (key === "\r" || key === "\n") {
3232
+ screen.running = true;
3233
+ screen.error = "";
3234
+ render(state);
3235
+ try {
3236
+ const raw = await searchNpmRegistry(screen.query);
3237
+ screen.results = raw.map((r) => ({
3238
+ name: r.package.name,
3239
+ version: r.package.version,
3240
+ description: r.package.description ?? "",
3241
+ publisher: r.package.publisher?.username ?? "",
3242
+ score: r.score.final.toFixed(2),
3243
+ installedAt: "",
3244
+ }));
3245
+ screen.resultKind = "search";
3246
+ screen.resultCursor = 0;
3247
+ screen.resultScroll = 0;
3248
+ screen.message = "";
3249
+ screen.subview = "results";
3250
+ }
3251
+ catch (err) {
3252
+ screen.error = err instanceof Error ? err.message : String(err);
3253
+ }
3254
+ finally {
3255
+ screen.running = false;
3256
+ }
3257
+ render(state);
3258
+ return;
3259
+ }
3260
+ if (key === "\x7f" || key === "\x08") {
3261
+ if (screen.queryCursor > 0) {
3262
+ screen.query =
3263
+ screen.query.slice(0, screen.queryCursor - 1) + screen.query.slice(screen.queryCursor);
3264
+ screen.queryCursor--;
3265
+ }
3266
+ }
3267
+ else if (key === "\x1b[D") {
3268
+ if (screen.queryCursor > 0)
3269
+ screen.queryCursor--;
3270
+ }
3271
+ else if (key === "\x1b[C") {
3272
+ if (screen.queryCursor < screen.query.length)
3273
+ screen.queryCursor++;
3274
+ }
3275
+ else if (key === "\x01" || key === "\x1b[H") {
3276
+ screen.queryCursor = 0;
3277
+ }
3278
+ else if (key === "\x05" || key === "\x1b[F") {
3279
+ screen.queryCursor = screen.query.length;
3280
+ }
3281
+ else if (key.length === 1 && key >= " ") {
3282
+ screen.query =
3283
+ screen.query.slice(0, screen.queryCursor) + key + screen.query.slice(screen.queryCursor);
3284
+ screen.queryCursor++;
3285
+ }
3286
+ render(state);
3287
+ return;
3288
+ }
3289
+ // ── Results subview ───────────────────────────────────────────────────────
3290
+ if (screen.subview === "results") {
3291
+ const { rows } = termSize();
3292
+ const viewH = Math.max(3, rows - 10);
3293
+ switch (key) {
3294
+ case "\x1b[A":
3295
+ if (screen.resultCursor > 0) {
3296
+ screen.resultCursor--;
3297
+ if (screen.resultCursor < screen.resultScroll)
3298
+ screen.resultScroll--;
3299
+ }
3300
+ break;
3301
+ case "\x1b[B":
3302
+ if (screen.resultCursor < screen.results.length - 1) {
3303
+ screen.resultCursor++;
3304
+ if (screen.resultCursor >= screen.resultScroll + viewH)
3305
+ screen.resultScroll++;
3306
+ }
3307
+ break;
3308
+ case "\r":
3309
+ case "\n":
3310
+ case "i":
3311
+ case "I":
3312
+ case "r":
3313
+ case "R": {
3314
+ const result = screen.results[screen.resultCursor];
3315
+ if (!result)
3316
+ break;
3317
+ if (screen.resultKind === "installed" && (key === "i" || key === "I"))
3318
+ break;
3319
+ const action = screen.resultKind === "installed" ? "remove" : "install";
3320
+ const pkgName = result.name;
3321
+ screen.message = dim(`${action === "install" ? "Installing" : "Removing"} ${pkgName}…`);
3322
+ render(state);
3323
+ const cmd = pluginGroup.commands.find((c) => c.name === action);
3324
+ if (!cmd)
3325
+ break;
3326
+ let opError = "";
3327
+ let opMessage = "";
3328
+ await runWithTerminal(async () => {
3329
+ const { writer, get } = makeCapturingWriter();
3330
+ try {
3331
+ await cmd.run({
3332
+ positional: [pkgName],
3333
+ flags: {},
3334
+ output: writer,
3335
+ getClient: state.getClient,
3336
+ getAdminClient: state.getAdminClient,
3337
+ });
3338
+ const out = get();
3339
+ if (out.type === "messages")
3340
+ opMessage = out.lines.join("\n");
3341
+ else
3342
+ opMessage = green(`✓ ${action === "install" ? "Installed" : "Removed"} ${pkgName}`);
3343
+ }
3344
+ catch (err) {
3345
+ opError = err instanceof Error ? err.message : String(err);
3346
+ }
3347
+ });
3348
+ screen.subview = "done";
3349
+ screen.message = opMessage;
3350
+ screen.error = opError;
3351
+ break;
3352
+ }
3353
+ case "\x1b":
3354
+ // Go back to search input if came from search, else back to menu
3355
+ if (screen.resultKind === "search") {
3356
+ screen.subview = "search";
3357
+ }
3358
+ else {
3359
+ screen.subview = "menu";
3360
+ }
3361
+ break;
3362
+ case "q":
3363
+ case "Q":
3364
+ done();
3365
+ return;
3366
+ }
3367
+ render(state);
3368
+ return;
3369
+ }
3370
+ // ── Prompt subview ────────────────────────────────────────────────────────
3371
+ if (screen.subview === "prompt") {
3372
+ if (key === "\x1b") {
3373
+ screen.subview = "menu";
3374
+ render(state);
3375
+ return;
3376
+ }
3377
+ if (key === "\r" || key === "\n") {
3378
+ const pkgName = screen.promptValue.trim();
3379
+ if (!pkgName) {
3380
+ screen.error = "Package name is required";
3381
+ render(state);
3382
+ return;
3383
+ }
3384
+ const cmd = pluginGroup.commands.find((c) => c.name === screen.promptAction);
3385
+ if (!cmd)
3386
+ return;
3387
+ let opError = "";
3388
+ let opMessage = "";
3389
+ await runWithTerminal(async () => {
3390
+ const { writer, get } = makeCapturingWriter();
3391
+ try {
3392
+ await cmd.run({
3393
+ positional: [pkgName],
3394
+ flags: {},
3395
+ output: writer,
3396
+ getClient: state.getClient,
3397
+ getAdminClient: state.getAdminClient,
3398
+ });
3399
+ const out = get();
3400
+ if (out.type === "messages")
3401
+ opMessage = out.lines.join("\n");
3402
+ else
3403
+ opMessage = green("✓ Done");
3404
+ }
3405
+ catch (err) {
3406
+ opError = err instanceof Error ? err.message : String(err);
3407
+ }
3408
+ });
3409
+ screen.subview = "done";
3410
+ screen.message = opMessage;
3411
+ screen.error = opError;
3412
+ render(state);
3413
+ return;
3414
+ }
3415
+ if (key === "\x7f" || key === "\x08") {
3416
+ if (screen.promptCursor > 0) {
3417
+ screen.promptValue =
3418
+ screen.promptValue.slice(0, screen.promptCursor - 1) +
3419
+ screen.promptValue.slice(screen.promptCursor);
3420
+ screen.promptCursor--;
3421
+ }
3422
+ }
3423
+ else if (key === "\x1b[D") {
3424
+ if (screen.promptCursor > 0)
3425
+ screen.promptCursor--;
3426
+ }
3427
+ else if (key === "\x1b[C") {
3428
+ if (screen.promptCursor < screen.promptValue.length)
3429
+ screen.promptCursor++;
3430
+ }
3431
+ else if (key === "\x01" || key === "\x1b[H") {
3432
+ screen.promptCursor = 0;
3433
+ }
3434
+ else if (key === "\x05" || key === "\x1b[F") {
3435
+ screen.promptCursor = screen.promptValue.length;
3436
+ }
3437
+ else if (key.length === 1 && key >= " ") {
3438
+ screen.promptValue =
3439
+ screen.promptValue.slice(0, screen.promptCursor) +
3440
+ key +
3441
+ screen.promptValue.slice(screen.promptCursor);
3442
+ screen.promptCursor++;
3443
+ }
3444
+ render(state);
3445
+ return;
3446
+ }
3447
+ // ── Done subview ──────────────────────────────────────────────────────────
3448
+ switch (key) {
3449
+ case "\x1b":
3450
+ screen.subview = "menu";
3451
+ break;
3452
+ case "q":
3453
+ case "Q":
3454
+ done();
3455
+ return;
3456
+ }
3457
+ render(state);
3458
+ }
2696
3459
  async function handleKey(key, state, done) {
2697
3460
  if (state.quitting)
2698
3461
  return;
@@ -2703,18 +3466,6 @@ async function handleKey(key, state, done) {
2703
3466
  const screen = state.stack[state.stack.length - 1];
2704
3467
  if (!screen)
2705
3468
  return;
2706
- // Global p/P: open profile view (skip when actively editing text)
2707
- if (key === "p" || key === "P") {
2708
- const isEditing = (screen.kind === "input" && screen.editing) ||
2709
- (screen.kind === "json-editor" && screen.editing) ||
2710
- (screen.kind === "settings" && screen.editing);
2711
- const isProfile = screen.kind === "profile";
2712
- if (!isEditing && !isProfile) {
2713
- state.stack.push({ kind: "profile", scroll: 0 });
2714
- render(state);
2715
- return;
2716
- }
2717
- }
2718
3469
  switch (screen.kind) {
2719
3470
  case "main":
2720
3471
  handleMainKey(key, screen, state, done);
@@ -2752,6 +3503,9 @@ async function handleKey(key, state, done) {
2752
3503
  case "json-editor":
2753
3504
  handleJsonEditorKey(key, screen, state, done);
2754
3505
  break;
3506
+ case "plugins":
3507
+ await handlePluginsKey(key, screen, state, done);
3508
+ break;
2755
3509
  }
2756
3510
  }
2757
3511
  // ─── Shared TUI runner ────────────────────────────────────────────────────────