dsh-ntr 0.1.0 → 0.1.2

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/lib/client.js CHANGED
@@ -270,8 +270,10 @@ window.__ModuleLoader__.load({
270
270
  locale;
271
271
  state = {
272
272
  open: false,
273
+ localeRev: 0,
273
274
  stream: "init",
274
275
  agents: [],
276
+ agentsLoaded: false,
275
277
  sessions: [],
276
278
  blocks: [],
277
279
  permissions: [],
@@ -343,6 +345,8 @@ window.__ModuleLoader__.load({
343
345
  seatCatalogReads = /* @__PURE__ */ new Set();
344
346
  /** In-flight `agent/cached-models` reads, keyed by agent id. */
345
347
  cachedModelReads = /* @__PURE__ */ new Set();
348
+ /** Counts behind the last successful explicit re-detect, so a locale switch can re-render its summary. */
349
+ detectCounts;
346
350
  constructor(rpc, locale) {
347
351
  this.rpc = rpc;
348
352
  this.locale = locale;
@@ -476,6 +480,7 @@ window.__ModuleLoader__.load({
476
480
  const detectedAgents = detectResult && detectResult.agents.length > 0 ? detectResult.agents : this.state.detectedAgents.length > 0 ? this.state.detectedAgents : [...DEFAULT_KNOWN_PRESETS];
477
481
  this.mutate({
478
482
  agents,
483
+ agentsLoaded: true,
479
484
  sessions,
480
485
  defaultAgent: defaultAgentInfo?.agentId,
481
486
  detectedAgents,
@@ -495,14 +500,19 @@ window.__ModuleLoader__.load({
495
500
  const result = await this.call("agents/detect");
496
501
  const agents = result.agents.length > 0 ? result.agents : [...DEFAULT_KNOWN_PRESETS];
497
502
  const installed = agents.filter((agent) => agent.detected).length;
503
+ this.detectCounts = {
504
+ total: agents.length,
505
+ installed
506
+ };
498
507
  this.mutate({
499
508
  detectedAgents: agents,
500
509
  detecting: false,
501
- detectSummary: this.locale(`已检测 ${String(agents.length)} 个预设:${String(installed)} 个已安装,其余可从「添加智能体」启用。`, `Detected ${String(agents.length)} presets: ${String(installed)} installed, enable the rest via "Add agent".`)
510
+ detectSummary: this.detectSummaryOf(agents.length, installed)
502
511
  });
503
512
  } catch (error) {
504
513
  const msg = error instanceof Error ? error.message : String(error);
505
514
  const notice = msg.includes("unknown endpoint agents/detect") ? this.locale("宿主服务运行着旧版本代码,请在终端重启 dsh web 服务以激活主机检测功能。", "Host running older version. Please restart dsh web to enable agent detection.") : msg;
515
+ this.detectCounts = void 0;
506
516
  this.mutate({
507
517
  detecting: false,
508
518
  notice,
@@ -511,6 +521,9 @@ window.__ModuleLoader__.load({
511
521
  });
512
522
  }
513
523
  }
524
+ detectSummaryOf(total, installed) {
525
+ return this.locale(`已检测 ${String(total)} 个预设:${String(installed)} 个已安装,其余可从「添加智能体」启用。`, `Detected ${String(total)} presets: ${String(installed)} installed, enable the rest via "Add agent".`);
526
+ }
514
527
  async saveAgent(payload) {
515
528
  try {
516
529
  await this.call("agent/save", payload);
@@ -1254,6 +1267,19 @@ window.__ModuleLoader__.load({
1254
1267
  * renders or every row re-renders on every store notification.
1255
1268
  */
1256
1269
  label = (zh, en) => this.locale(zh, en);
1270
+ /**
1271
+ * Republish the snapshot after a locale switch. Every `label()` resolves
1272
+ * against the current language while rendering, so a notification is all
1273
+ * that is needed — except the detect summary, which the store owns as an
1274
+ * already-resolved string and rebuilds from its counts here.
1275
+ */
1276
+ refreshLocale() {
1277
+ const counts = this.detectCounts;
1278
+ this.mutate({
1279
+ localeRev: this.state.localeRev + 1,
1280
+ ...counts === void 0 ? {} : { detectSummary: this.detectSummaryOf(counts.total, counts.installed) }
1281
+ });
1282
+ }
1257
1283
  clearNotice() {
1258
1284
  this.mutate({ notice: void 0 });
1259
1285
  }
@@ -1812,6 +1838,45 @@ window.__ModuleLoader__.load({
1812
1838
  }, () => menuState);
1813
1839
  }
1814
1840
  /**
1841
+ * Open state of the frame-wide NTR settings dialog (`settings-dialog.tsx`).
1842
+ *
1843
+ * Module-level because the two ends live in unrelated slot trees: the trigger
1844
+ * is the composer's agent base (a session-scoped model-seat entry) and the
1845
+ * dialog itself rides the root `shell.overlay` layer. A React context cannot
1846
+ * span that boundary, so this is the same tiny external-store seam the menu
1847
+ * uses above.
1848
+ */
1849
+ let settingsDialogOpen = false;
1850
+ const settingsDialogListeners = /* @__PURE__ */ new Set();
1851
+ function emitSettingsDialog() {
1852
+ for (const listener of [...settingsDialogListeners]) listener();
1853
+ }
1854
+ /** Open the NTR settings dialog (the empty agent roster's way forward). */
1855
+ function openAcpSettingsDialog() {
1856
+ if (settingsDialogOpen) return;
1857
+ settingsDialogOpen = true;
1858
+ emitSettingsDialog();
1859
+ }
1860
+ /** Close the NTR settings dialog (close button, mask click, Escape). */
1861
+ function closeAcpSettingsDialog() {
1862
+ if (!settingsDialogOpen) return;
1863
+ settingsDialogOpen = false;
1864
+ emitSettingsDialog();
1865
+ }
1866
+ /** Current dialog state, for tests and non-React readers. */
1867
+ function acpSettingsDialogIsOpen() {
1868
+ return settingsDialogOpen;
1869
+ }
1870
+ /** Subscribe to the dialog state (the root overlay entry reads it). */
1871
+ function useAcpSettingsDialogOpen() {
1872
+ return (0, react.useSyncExternalStore)((cb) => {
1873
+ settingsDialogListeners.add(cb);
1874
+ return () => {
1875
+ settingsDialogListeners.delete(cb);
1876
+ };
1877
+ }, acpSettingsDialogIsOpen);
1878
+ }
1879
+ /**
1815
1880
  * Pure popup placement, viewport coordinates: the menu docks with its right
1816
1881
  * edge under the trigger's, `gap`px above it — and only then clamps to the
1817
1882
  * viewport (flip below when the top has no room, slide left at the right edge).
@@ -2344,6 +2409,26 @@ window.__ModuleLoader__.load({
2344
2409
  .ntr-acp-empty-hint { color: var(--dsw-alias-text-secondary, #6b7280); font-size: 12px; }
2345
2410
  .ntr-acp-seat-diag { font-family: var(--dsw-alias-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); opacity: 0.72; word-break: break-word; }
2346
2411
  .ntr-acp-empty-actions { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; }
2412
+ /* ---- NTR settings dialog (shell.overlay) ---- */
2413
+ /* Frame-wide surface opened from the agent base's empty-roster menu. The
2414
+ overlay layer itself is click-through, so the layer opts back in and blocks
2415
+ the app while it is up. */
2416
+ .ntr-acp-dialog-layer { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; pointer-events: auto; }
2417
+ .ntr-acp-dialog-mask { position: absolute; inset: 0; background: var(--dsw-alias-bg-mask-1, rgba(0, 0, 0, 0.4)); }
2418
+ .ntr-acp-dialog {
2419
+ position: relative; display: flex; flex-direction: column; overflow: hidden;
2420
+ width: min(920px, calc(100vw - 48px)); height: min(820px, calc(100vh - 48px));
2421
+ background: var(--dsw-alias-bg-layer-2, #fff); color: inherit;
2422
+ border-radius: 24px; box-shadow: var(--dsw-elevation-prominent, 0 24px 60px rgba(0, 0, 0, 0.25));
2423
+ }
2424
+ .ntr-acp-dialog-head { flex: none; display: flex; align-items: center; gap: 8px; padding: 16px 16px 8px 24px; }
2425
+ .ntr-acp-dialog-title { flex: 1; font-size: 16px; font-weight: 600; }
2426
+ .ntr-acp-dialog-close { font: inherit; font-size: 14px; line-height: 1; width: 30px; height: 30px; padding: 0; border: none; border-radius: 999px; background: none; color: inherit; cursor: pointer; }
2427
+ .ntr-acp-dialog-close:hover { background: var(--dsw-alias-interactive-bg-active, #0000000d); }
2428
+ .ntr-acp-dialog-body { flex: 1; min-height: 0; overflow: auto; }
2429
+ /* The section brings its own page scroll for the settings shell's content
2430
+ column; the dialog body is the scroll owner here. */
2431
+ .ntr-acp-dialog-body > .dwa-root { max-width: none; overflow: visible; }
2347
2432
  `;
2348
2433
  let styleMounted$1 = false;
2349
2434
  /** Mount the shared stylesheet once (same pattern as the settings panel's). */
@@ -2492,16 +2577,18 @@ window.__ModuleLoader__.load({
2492
2577
  .dwa-inline-setting { display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px; border-radius: var(--ntr-radius-sm); background: var(--dsw-alias-bg-layer-2, #f9fafb); }
2493
2578
  .dwa-inline-setting .dwa-switch { flex-shrink: 0; margin-top: 1px; }
2494
2579
  .dwa-setting-copy { color: var(--ntr-muted); font-size: var(--ntr-font-xs); }
2495
- /* Panes stay flush (equal height) whatever the two list lengths are; the
2496
- shorter list's list body grows to fill, so its leftover is scrolling room
2497
- inside the list rather than a detached block under the last row. */
2498
- .dwa-model-transfer { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; align-items: stretch; }
2499
- /* Tall enough for a comfortable selection (about ten rows) before scrolling:
2500
- both panes share this ceiling, so equal heights cost at most one row of
2501
- slack on the shorter side instead of a whole empty half. */
2580
+ /* Stacked, not side by side: the rows carry name, id and credit, and a
2581
+ half-width pane truncated them at every realistic settings width. Each list
2582
+ gets the whole column instead. */
2583
+ .dwa-model-transfer { display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; }
2584
+ /* Tall enough for a comfortable selection (about ten rows) before scrolling;
2585
+ the cap keeps the two stacked panes from pushing the rest of the card away. */
2502
2586
  .dwa-model-pane { min-width: 0; max-height: 360px; display: flex; flex-direction: column; border: var(--ntr-line); border-radius: var(--ntr-radius-sm); background: var(--dsw-alias-bg-layer-1, #fff); overflow: hidden; }
2503
2587
  .dwa-model-pane-head { display: flex; align-items: center; gap: 6px; padding: 7px 9px; border-bottom: var(--ntr-line); font-size: var(--ntr-font-xs); font-weight: 600; color: var(--dsw-alias-text-secondary, #4b5563); }
2504
2588
  .dwa-model-pane-head .dwa-btn { margin-left: auto; min-height: 26px; padding: 2px 8px; font-size: var(--ntr-font-xs); }
2589
+ .dwa-model-pane-head .dwa-caret-btn { flex: none; width: 22px; height: 22px; background: transparent; }
2590
+ .dwa-model-pane-head .dwa-caret-btn:hover { background: var(--dsw-alias-interactive-bg-active, #e5e7eb); }
2591
+ .dwa-model-pane.is-collapsed .dwa-model-list { display: none; }
2505
2592
  .dwa-model-list { display: flex; flex-direction: column; gap: 2px; padding: 5px; flex: 1 1 auto; min-height: 0; overflow-y: auto; }
2506
2593
  .dwa-model-section { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; font-size: 12px; }
2507
2594
  .dwa-model-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
@@ -2518,7 +2605,19 @@ window.__ModuleLoader__.load({
2518
2605
  .dwa-model-row.is-on { background: #3b82f614; color: #2563eb; }
2519
2606
  .dwa-model-row-mark { width: 12px; flex: none; text-align: center; opacity: 0.75; }
2520
2607
  .dwa-model-row-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2521
- .dwa-model-row-value { flex: 0 1 auto; max-width: 45%; opacity: 0.55; font-size: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2608
+ /* Name and credit keep their room; the id is the only cell that collapses. */
2609
+ .dwa-model-row-desc { flex: 0 0 auto; max-width: 45%; opacity: 0.75; font-size: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2610
+ /* The trailing 14px is reserved for the fade mask: while the id fits, the
2611
+ gradient only covers empty padding; once it overflows, its tail dissolves
2612
+ instead of ending in a hard ellipsis. armModelIdMarquee reads the same
2613
+ padding to scroll the full text under the pointer. */
2614
+ .dwa-model-row-value { flex: 0 1 auto; flex-shrink: 1000; box-sizing: border-box; min-width: 0; max-width: 45%; padding-right: 14px; opacity: 0.55; font-size: 10px; overflow: hidden; white-space: nowrap; mask-image: linear-gradient(to right, #000 calc(100% - 14px), transparent); -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 14px), transparent); }
2615
+ .dwa-model-row-value-text { display: inline-block; white-space: nowrap; }
2616
+ .dwa-model-row:hover .dwa-model-row-value-text { animation: dwa-model-id-marquee var(--dwa-id-duration, 1.4s) ease-in-out 0.2s infinite alternate; }
2617
+ @keyframes dwa-model-id-marquee {
2618
+ from { transform: translateX(0); }
2619
+ to { transform: translateX(var(--dwa-id-shift, 0px)); }
2620
+ }
2522
2621
  .dwa-model-row-remove { flex: none; opacity: 0.5; padding: 0 2px; }
2523
2622
  .dwa-model-row:hover .dwa-model-row-remove { opacity: 1; }
2524
2623
  .dwa-seg { display: inline-flex; border-radius: var(--ntr-radius-sm); border: 1px solid var(--dsw-alias-border-l2, #d1d5db); overflow: hidden; vertical-align: middle; }
@@ -2535,7 +2634,6 @@ window.__ModuleLoader__.load({
2535
2634
  .dwa-page-header { flex-direction: column; }
2536
2635
  .dwa-page-actions { width: 100%; }
2537
2636
  .dwa-page-actions .dwa-btn { width: 100%; }
2538
- .dwa-model-transfer { grid-template-columns: 1fr; }
2539
2637
  .dwa-model-toolbar-actions { width: 100%; }
2540
2638
  .dwa-model-search { flex: 1 1 auto; width: auto; }
2541
2639
  .dwa-subsection-head, .dwa-preference-row { align-items: flex-start; flex-direction: column; }
@@ -2543,6 +2641,7 @@ window.__ModuleLoader__.load({
2543
2641
  }
2544
2642
  @media (prefers-reduced-motion: reduce) {
2545
2643
  .dwa-root *, .dwa-root *::before, .dwa-root *::after { transition: none !important; }
2644
+ .dwa-model-row:hover .dwa-model-row-value-text { animation: none !important; }
2546
2645
  }
2547
2646
  `;
2548
2647
  let styleMounted = false;
@@ -3837,6 +3936,22 @@ window.__ModuleLoader__.load({
3837
3936
  });
3838
3937
  }
3839
3938
  /**
3939
+ * Measure the id cell on row hover so its CSS marquee can reach the end of the
3940
+ * text. The id is the only cell that yields width, and the panel is resizable,
3941
+ * so the overflow is re-read on every entry rather than cached. The animation
3942
+ * itself lives in CSS and only runs while the row is hovered; `title` on the
3943
+ * row stays the fallback for reduced-motion users.
3944
+ */
3945
+ function armModelIdMarquee(row) {
3946
+ const view = row.querySelector(".dwa-model-row-value");
3947
+ const text = view?.querySelector(".dwa-model-row-value-text");
3948
+ if (view === null || view === void 0 || text === null || text === void 0) return;
3949
+ const fade = Number.parseFloat(getComputedStyle(view).paddingRight) || 0;
3950
+ const shift = Math.max(0, text.offsetWidth - (view.clientWidth - fade));
3951
+ row.style.setProperty("--dwa-id-shift", `${String(-shift)}px`);
3952
+ row.style.setProperty("--dwa-id-duration", `${String(Math.min(4, Math.max(1.2, shift / 30)))}s`);
3953
+ }
3954
+ /**
3840
3955
  * Model / thought-level catalog display plus the native-selector visibility
3841
3956
  * filter, as a two-pane transfer list: every known model on the left, the
3842
3957
  * selection that reaches the NTR surfaces on the right. Variants are still
@@ -3872,6 +3987,8 @@ window.__ModuleLoader__.load({
3872
3987
  const modelChoices = result?.catalog.model?.choices ?? [];
3873
3988
  /** Result of the last click on 「检测可用模型」 (what the refresh changed). */
3874
3989
  const [detectNote, setDetectNote] = (0, react.useState)(void 0);
3990
+ const [allCollapsed, setAllCollapsed] = (0, react.useState)(false);
3991
+ const [selectedCollapsed, setSelectedCollapsed] = (0, react.useState)(false);
3875
3992
  (0, react.useEffect)(() => {
3876
3993
  if (!active) return;
3877
3994
  acp.loadCachedModels(agentId);
@@ -4075,7 +4192,7 @@ window.__ModuleLoader__.load({
4075
4192
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4076
4193
  className: "dwa-model-transfer",
4077
4194
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4078
- className: "dwa-model-pane",
4195
+ className: `dwa-model-pane${allCollapsed ? " is-collapsed" : ""}`,
4079
4196
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4080
4197
  className: "dwa-model-pane-head",
4081
4198
  children: [
@@ -4091,6 +4208,18 @@ window.__ModuleLoader__.load({
4091
4208
  onClick: addVisible,
4092
4209
  title: acp.label("把当前搜索范围内的模型全部加入右侧", "Add every model matching the current search"),
4093
4210
  children: acp.label("全部加入", "Add all")
4211
+ }),
4212
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4213
+ type: "button",
4214
+ className: "dwa-caret-btn",
4215
+ "aria-expanded": !allCollapsed,
4216
+ onClick: () => setAllCollapsed((value) => !value),
4217
+ title: allCollapsed ? acp.label("展开全部模型", "Expand all models") : acp.label("收起全部模型", "Collapse all models"),
4218
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4219
+ className: "dwa-caret",
4220
+ "aria-hidden": true,
4221
+ children: allCollapsed ? "▸" : "▾"
4222
+ })
4094
4223
  })
4095
4224
  ]
4096
4225
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -4103,6 +4232,9 @@ window.__ModuleLoader__.load({
4103
4232
  className: `dwa-model-row${checked ? " is-on" : ""}`,
4104
4233
  title,
4105
4234
  onClick: () => toggleChoice(choice.value),
4235
+ onPointerEnter: (event) => {
4236
+ armModelIdMarquee(event.currentTarget);
4237
+ },
4106
4238
  children: [
4107
4239
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4108
4240
  className: "dwa-model-row-mark",
@@ -4117,7 +4249,14 @@ window.__ModuleLoader__.load({
4117
4249
  }),
4118
4250
  choice.value !== choice.name && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4119
4251
  className: "dwa-model-row-value",
4120
- children: choice.value
4252
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4253
+ className: "dwa-model-row-value-text",
4254
+ children: choice.value
4255
+ })
4256
+ }),
4257
+ choice.description !== void 0 && choice.description !== choice.group && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4258
+ className: "dwa-model-row-desc",
4259
+ children: choice.description
4121
4260
  })
4122
4261
  ]
4123
4262
  }, choice.value);
@@ -4127,7 +4266,7 @@ window.__ModuleLoader__.load({
4127
4266
  })]
4128
4267
  })]
4129
4268
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4130
- className: "dwa-model-pane",
4269
+ className: `dwa-model-pane${selectedCollapsed ? " is-collapsed" : ""}`,
4131
4270
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4132
4271
  className: "dwa-model-pane-head",
4133
4272
  children: [
@@ -4142,6 +4281,18 @@ window.__ModuleLoader__.load({
4142
4281
  disabled: basis.length === 0,
4143
4282
  onClick: clearAll,
4144
4283
  children: acp.label("清空", "Clear")
4284
+ }),
4285
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4286
+ type: "button",
4287
+ className: "dwa-caret-btn",
4288
+ "aria-expanded": !selectedCollapsed,
4289
+ onClick: () => setSelectedCollapsed((value) => !value),
4290
+ title: selectedCollapsed ? acp.label("展开已选模型", "Expand selected models") : acp.label("收起已选模型", "Collapse selected models"),
4291
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4292
+ className: "dwa-caret",
4293
+ "aria-hidden": true,
4294
+ children: selectedCollapsed ? "▸" : "▾"
4295
+ })
4145
4296
  })
4146
4297
  ]
4147
4298
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -4154,6 +4305,9 @@ window.__ModuleLoader__.load({
4154
4305
  className: "dwa-model-row is-on",
4155
4306
  title: choice.description === void 0 ? choice.value : `${choice.value}\n${choice.description}`,
4156
4307
  onClick: () => toggleChoice(choice.value),
4308
+ onPointerEnter: (event) => {
4309
+ armModelIdMarquee(event.currentTarget);
4310
+ },
4157
4311
  children: [
4158
4312
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
4159
4313
  className: "dwa-model-row-name",
@@ -4164,7 +4318,14 @@ window.__ModuleLoader__.load({
4164
4318
  }),
4165
4319
  choice.value !== choice.name && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4166
4320
  className: "dwa-model-row-value",
4167
- children: choice.value
4321
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4322
+ className: "dwa-model-row-value-text",
4323
+ children: choice.value
4324
+ })
4325
+ }),
4326
+ choice.description !== void 0 && choice.description !== choice.group && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4327
+ className: "dwa-model-row-desc",
4328
+ children: choice.description
4168
4329
  }),
4169
4330
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4170
4331
  className: "dwa-model-row-remove",
@@ -4429,10 +4590,12 @@ window.__ModuleLoader__.load({
4429
4590
  * different slots, so they share that module's phase clock rather than trying
4430
4591
  * to animate across slot boundaries.
4431
4592
  *
4432
- * It is deliberately quiet: the base renders NOTHING for a conversation no ACP
4433
- * agent has served, so an ordinary chat is untouched even before the gate
4434
- * stands down. The ACP view tab is the entry point for those (and for re-entry
4435
- * after a detour through another provider).
4593
+ * It is deliberately quiet: outside ACP mode the gate never mounts it, and
4594
+ * before the host has answered the agent roster it renders nothing. Once the
4595
+ * roster is KNOWN to be empty the base stays and its picker carries the
4596
+ * "open NTR settings" action — an NTR conversation with nothing configured
4597
+ * still has a way forward instead of an empty cell. The ACP view tab remains
4598
+ * the entry point for conversations no agent has served.
4436
4599
  */
4437
4600
  const STATE_COLORS = {
4438
4601
  ready: "#22c55e",
@@ -4477,6 +4640,18 @@ window.__ModuleLoader__.load({
4477
4640
  if (defaultAgent !== void 0 && agents.some((agent) => agent.id === defaultAgent)) return defaultAgent;
4478
4641
  }
4479
4642
  /**
4643
+ * Whether the agent base has anything honest to show.
4644
+ *
4645
+ * An empty roster has two meanings, and they must not be conflated: before the
4646
+ * host answers `agents/list` it means "not read yet" (rendering a setup entry
4647
+ * there would accuse a correctly configured deployment on every page load),
4648
+ * while after the answer it means "nothing is configured" — exactly the state
4649
+ * that must keep the base and offer the way into NTR settings.
4650
+ */
4651
+ function agentBaseHasData(bound, agents, rosterLoaded) {
4652
+ return bound !== void 0 || agents.length > 0 || rosterLoaded;
4653
+ }
4654
+ /**
4480
4655
  * The agent this conversation is LOCKED to, if its first ACP turn has run.
4481
4656
  *
4482
4657
  * The host's `lockedAgentId` is the durable answer: it is written when the
@@ -4641,12 +4816,14 @@ window.__ModuleLoader__.load({
4641
4816
  * The tooltip claims only what the menu behind the chip does: switch the agent.
4642
4817
  * The agent's models and thought levels have chips of their own (user decision,
4643
4818
  * 2026-10-17), so the previous wording — "switch the agent, its model and the
4644
- * thought level" — promised a menu that holds the roster alone.
4819
+ * thought level" — promised a menu that holds the roster alone. An EMPTY
4820
+ * roster is its own case: the menu cannot switch to anything, it offers the
4821
+ * way into NTR settings, so the tooltip says that instead of promising a pick.
4645
4822
  */
4646
- function agentBaseChip(route, routed, acp) {
4823
+ function agentBaseChip(route, routed, acp, rosterEmpty = false) {
4647
4824
  return {
4648
4825
  label: route.agentTitle === void 0 ? routed ? "NTR" : acp.label("选择 Agent", "Choose agent") : `NTR · ${route.agentTitle}`,
4649
- title: route.agentId === void 0 ? acp.label("这条会话当前没有 NTR agent 服务;点开可选择 agent", "No NTR agent is serving this conversation right now — open to choose one") : acp.label("这条会话由外接 NTR agent 服务;点开可切换 agent", "This conversation is served by an external NTR agent — open to switch the agent")
4826
+ title: route.agentId === void 0 ? rosterEmpty ? acp.label("这条会话当前没有 NTR agent;点开可前往 NTR 设置添加", "No NTR agent is serving this conversation — open to set one up in NTR settings") : acp.label("这条会话当前没有 NTR agent 服务;点开可选择 agent", "No NTR agent is serving this conversation right now — open to choose one") : acp.label("这条会话由外接 NTR agent 服务;点开可切换 agent", "This conversation is served by an external NTR agent — open to switch the agent")
4650
4827
  };
4651
4828
  }
4652
4829
  /**
@@ -4757,7 +4934,7 @@ window.__ModuleLoader__.load({
4757
4934
  agentId
4758
4935
  ]);
4759
4936
  const route = describeRoute(agents, bound, lockedTo === void 0 ? ui.defaultAgent : void 0, catalog, lockedTo ?? ui.chatBoundAgent[sessionId], choice);
4760
- if (bound === void 0 && agents.length === 0) return null;
4937
+ if (!agentBaseHasData(bound, agents, ui.agentsLoaded)) return null;
4761
4938
  const routed = route.agentId !== void 0;
4762
4939
  (0, react.useEffect)(() => {
4763
4940
  if (lockedTo === void 0 || menu === null) return;
@@ -4807,7 +4984,7 @@ window.__ModuleLoader__.load({
4807
4984
  }
4808
4985
  }),
4809
4986
  showAgentBase && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AcpChip, {
4810
- ...agentBaseChip(route, routed, acp),
4987
+ ...agentBaseChip(route, routed, acp, agents.length === 0),
4811
4988
  active: routed,
4812
4989
  className: shattering ? "ntr-acp-shatter" : void 0,
4813
4990
  onClick: shattering ? void 0 : (trigger) => {
@@ -4826,6 +5003,13 @@ window.__ModuleLoader__.load({
4826
5003
  * `agentId@value`.
4827
5004
  */
4828
5005
  const REFRESH_CATALOG_ITEM = "__refresh-catalog__";
5006
+ /**
5007
+ * The empty roster's setup action. Like the model picker's refresh row it is an
5008
+ * ACTION inside the picker, not an agent: agent ids match
5009
+ * `[a-z0-9][a-z0-9._-]{0,63}` (no leading underscore), so no real id can
5010
+ * collide with it.
5011
+ */
5012
+ const OPEN_SETTINGS_ITEM = "__open-settings__";
4829
5013
  /** The agent roster rows: one row per configured ACP agent, current one marked. */
4830
5014
  function agentSection(context) {
4831
5015
  const { acp, route } = context;
@@ -4836,12 +5020,19 @@ window.__ModuleLoader__.load({
4836
5020
  dot: STATE_COLORS[agent.state],
4837
5021
  active: route.agentId === agent.id
4838
5022
  }));
4839
- if (items.length === 0) items.push({
4840
- id: "none",
4841
- label: acp.label("还没有配置 NTR agent", "No NTR agent configured yet"),
4842
- detail: acp.label("设置 → NTR", "Settings → NTR"),
4843
- disabled: true
4844
- });
5023
+ if (items.length === 0) {
5024
+ items.push({
5025
+ id: "none",
5026
+ label: acp.label("还没有配置 NTR agent", "No NTR agent configured yet"),
5027
+ detail: acp.label("添加并启用一个 agent 后即可开始对话", "Add and enable an agent to start"),
5028
+ disabled: true
5029
+ });
5030
+ items.push({
5031
+ id: OPEN_SETTINGS_ITEM,
5032
+ label: acp.label("打开 NTR 设置", "Open NTR settings"),
5033
+ detail: acp.label("添加或启用 agent", "Add or enable an agent")
5034
+ });
5035
+ }
4845
5036
  return {
4846
5037
  id: "agent",
4847
5038
  items
@@ -5072,6 +5263,11 @@ window.__ModuleLoader__.load({
5072
5263
  if (agentId !== void 0) await acp.probeModels(agentId);
5073
5264
  return;
5074
5265
  }
5266
+ if (sectionId === "agent" && itemId === "__open-settings__") {
5267
+ closeAcpMenu();
5268
+ openAcpSettingsDialog();
5269
+ return;
5270
+ }
5075
5271
  if (sectionId === "agent") await acp.bindChatAgent(sessionId, itemId);
5076
5272
  else if (sectionId === "model") await acp.setChatSessionModel(sessionId, itemId, agentId);
5077
5273
  else if (sectionId === "thought") await acp.setChatSessionEffort(sessionId, itemId === "provider-default" ? void 0 : itemId, agentId);
@@ -5436,9 +5632,10 @@ window.__ModuleLoader__.load({
5436
5632
  });
5437
5633
  }
5438
5634
  /**
5439
- * One stream block, dispatched by kind. Memoized on its props — `block` and
5440
- * the store's instance-bound `label` — so a folded batch re-renders only the
5441
- * rows whose block object changed (`foldUpdate` replaces blocks immutably)
5635
+ * One stream block, dispatched by kind. Memoized on its props — `block`, the
5636
+ * store's instance-bound `label`, and `localeRev` (it moves on a language
5637
+ * switch, so translated copy re-renders) — so a folded batch re-renders only
5638
+ * the rows whose block object changed (`foldUpdate` replaces blocks immutably)
5442
5639
  * instead of the whole transcript on every store notification. `key` stays the
5443
5640
  * index on purpose: blocks fold append-only, and identity is the block object.
5444
5641
  */
@@ -5685,12 +5882,83 @@ window.__ModuleLoader__.load({
5685
5882
  children: t("还没有消息:在下面的输入框里发给它。", "Nothing yet — send it a message below.")
5686
5883
  }) : transcript.blocks.map((block, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BlockView, {
5687
5884
  block,
5688
- label: acp.label
5885
+ label: acp.label,
5886
+ localeRev: ui.localeRev
5689
5887
  }, index))
5690
5888
  })]
5691
5889
  });
5692
5890
  }
5693
5891
  //#endregion
5892
+ //#region src/client/settings-dialog.tsx
5893
+ /**
5894
+ * The NTR settings dialog, opened from the agent base's empty-roster menu.
5895
+ *
5896
+ * WHY A DIALOG OF OUR OWN: the host settings shell owns its open state and
5897
+ * exposes no programmatic way in. The sidebar seat that receives `openSettings`
5898
+ * (`settings.launcher`) is a single-occupant slot already held by the account
5899
+ * menu, and `openSection` is only handed to `settings.onboarding` registrants —
5900
+ * so a conversation surface cannot drive the shell's modal. This dialog mounts
5901
+ * the SAME `AcpSettingsSection` the shell mounts, on the frame's overlay layer
5902
+ * (`shell.overlay`): one settings surface, two entrances, no duplicated
5903
+ * configuration logic.
5904
+ *
5905
+ * The trigger (composer model seat) and this dialog (root overlay) live in
5906
+ * unrelated slot trees, so the open state is the module-level store in
5907
+ * `acp-ui.tsx` (`openAcpSettingsDialog`).
5908
+ */
5909
+ /** Frame-wide NTR settings surface; renders nothing while closed. */
5910
+ function AcpSettingsDialog(props) {
5911
+ ensureAcpStyle();
5912
+ const { acp } = props;
5913
+ const open = useAcpSettingsDialogOpen();
5914
+ (0, react.useEffect)(() => {
5915
+ if (!open) return;
5916
+ const onKey = (event) => {
5917
+ if (event.key !== "Escape") return;
5918
+ event.preventDefault();
5919
+ event.stopPropagation();
5920
+ closeAcpSettingsDialog();
5921
+ };
5922
+ document.addEventListener("keydown", onKey, true);
5923
+ return () => {
5924
+ document.removeEventListener("keydown", onKey, true);
5925
+ };
5926
+ }, [open]);
5927
+ if (!open) return null;
5928
+ const title = acp.label("NTR 设置", "NTR settings");
5929
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5930
+ className: "ntr-acp-dialog-layer",
5931
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5932
+ className: "ntr-acp-dialog-mask",
5933
+ "aria-hidden": true,
5934
+ onClick: closeAcpSettingsDialog
5935
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5936
+ className: "ntr-acp-dialog",
5937
+ role: "dialog",
5938
+ "aria-modal": "true",
5939
+ "aria-label": title,
5940
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5941
+ className: "ntr-acp-dialog-head",
5942
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5943
+ className: "ntr-acp-dialog-title",
5944
+ children: title
5945
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5946
+ type: "button",
5947
+ className: "ntr-acp-dialog-close",
5948
+ title: acp.label("关闭", "Close"),
5949
+ "aria-label": acp.label("关闭 NTR 设置", "Close NTR settings"),
5950
+ autoFocus: true,
5951
+ onClick: closeAcpSettingsDialog,
5952
+ children: "✕"
5953
+ })]
5954
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5955
+ className: "ntr-acp-dialog-body",
5956
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AcpSettingsSection, { acp })
5957
+ })]
5958
+ })]
5959
+ });
5960
+ }
5961
+ //#endregion
5694
5962
  //#region src/client/model-seat.tsx
5695
5963
  /** Exact key of the composer's model seat (a `single`, session-scoped slot). */
5696
5964
  const MODEL_SEAT_KEY = "conversation.input.model";
@@ -6237,7 +6505,7 @@ window.__ModuleLoader__.load({
6237
6505
  //#region src/client/index.tsx
6238
6506
  const name = "dsh-ntr-client";
6239
6507
  const inject = ["slots", "connection"];
6240
- const zh = typeof navigator !== "undefined" && /^zh\b/i.test(navigator.language);
6508
+ const browserPrefersZh = typeof navigator !== "undefined" && /^zh\b/i.test(navigator.language);
6241
6509
  /**
6242
6510
  * How long a host verdict may stand as the gate's ONLY fact source before the
6243
6511
  * gate asks the host again.
@@ -6269,8 +6537,6 @@ window.__ModuleLoader__.load({
6269
6537
  const rpc = ctx.get("connection")?.rpc;
6270
6538
  if (rpc === void 0) throw new Error("dsh-ntr: browser connection.rpc is unavailable");
6271
6539
  const call = (channel, endpoint, payload) => rpc.call(channel, endpoint, payload);
6272
- const store = new AcpStore(call, (zhText, enText) => zh ? zhText : enText);
6273
- store.start();
6274
6540
  /**
6275
6541
  * Resolve one optional service without injecting it. A plain `ctx.get` at
6276
6542
  * call time keeps the plugin loadable when the service is absent AND picks it
@@ -6278,6 +6544,36 @@ window.__ModuleLoader__.load({
6278
6544
  * also making its absence fatal.
6279
6545
  */
6280
6546
  const service = (name) => ctx.get(name);
6547
+ /**
6548
+ * Resolve a Chinese/English pair against the shell's active locale. Read per
6549
+ * call, so a language switch reaches every render-time `label()` call without
6550
+ * rebuilding the store. A host without the locale service keeps the
6551
+ * browser-derived choice the pairs shipped with.
6552
+ */
6553
+ const label = (zhText, enText) => {
6554
+ const resolved = service("locale")?.resolveText?.({
6555
+ zh: zhText,
6556
+ en: enText
6557
+ });
6558
+ return typeof resolved === "string" && resolved !== "" ? resolved : browserPrefersZh ? zhText : enText;
6559
+ };
6560
+ const store = new AcpStore(call, label);
6561
+ store.start();
6562
+ ctx.effect(() => {
6563
+ let stop;
6564
+ const attach = () => {
6565
+ if (stop !== void 0) return;
6566
+ stop = service("locale")?.subscribe?.(() => {
6567
+ store.refreshLocale();
6568
+ });
6569
+ };
6570
+ attach();
6571
+ const stopService = ctx.on("internal/service", attach);
6572
+ return () => {
6573
+ stopService();
6574
+ stop?.();
6575
+ };
6576
+ }, "dsh-ntr: locale switch re-render");
6281
6577
  /** This session's command/projection face (`ctx.sessions`); `undefined` before it materializes. */
6282
6578
  const faceFor = (sessionId) => {
6283
6579
  try {
@@ -6299,6 +6595,12 @@ window.__ModuleLoader__.load({
6299
6595
  label: () => "NTR",
6300
6596
  inject: () => ({ acp: store })
6301
6597
  }, AcpSettingsSection)), "dsh-ntr: settings section");
6598
+ ctx.effect(() => ctx.slots.inject("shell.overlay", () => ctx.slots.register({
6599
+ name: "shell.overlay",
6600
+ id: "acp-settings-dialog",
6601
+ order: 30,
6602
+ inject: () => ({ acp: store })
6603
+ }, AcpSettingsDialog)), "dsh-ntr: NTR settings dialog");
6302
6604
  ctx.effect(() => ctx.slots.inject("conversation.input.left", () => ctx.slots.register({
6303
6605
  name: "conversation.input.left",
6304
6606
  id: "acp-mode",