dsh-ntr 0.1.1 → 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.
@@ -230,6 +230,47 @@ export function useAcpMenu() {
230
230
  return () => { menuListeners.delete(cb); };
231
231
  }, () => menuState);
232
232
  }
233
+ // ---- The NTR settings dialog ----
234
+ /**
235
+ * Open state of the frame-wide NTR settings dialog (`settings-dialog.tsx`).
236
+ *
237
+ * Module-level because the two ends live in unrelated slot trees: the trigger
238
+ * is the composer's agent base (a session-scoped model-seat entry) and the
239
+ * dialog itself rides the root `shell.overlay` layer. A React context cannot
240
+ * span that boundary, so this is the same tiny external-store seam the menu
241
+ * uses above.
242
+ */
243
+ let settingsDialogOpen = false;
244
+ const settingsDialogListeners = new Set();
245
+ function emitSettingsDialog() {
246
+ for (const listener of [...settingsDialogListeners])
247
+ listener();
248
+ }
249
+ /** Open the NTR settings dialog (the empty agent roster's way forward). */
250
+ export function openAcpSettingsDialog() {
251
+ if (settingsDialogOpen)
252
+ return;
253
+ settingsDialogOpen = true;
254
+ emitSettingsDialog();
255
+ }
256
+ /** Close the NTR settings dialog (close button, mask click, Escape). */
257
+ export function closeAcpSettingsDialog() {
258
+ if (!settingsDialogOpen)
259
+ return;
260
+ settingsDialogOpen = false;
261
+ emitSettingsDialog();
262
+ }
263
+ /** Current dialog state, for tests and non-React readers. */
264
+ export function acpSettingsDialogIsOpen() {
265
+ return settingsDialogOpen;
266
+ }
267
+ /** Subscribe to the dialog state (the root overlay entry reads it). */
268
+ export function useAcpSettingsDialogOpen() {
269
+ return useSyncExternalStore(cb => {
270
+ settingsDialogListeners.add(cb);
271
+ return () => { settingsDialogListeners.delete(cb); };
272
+ }, acpSettingsDialogIsOpen);
273
+ }
233
274
  /**
234
275
  * Pure popup placement, viewport coordinates: the menu docks with its right
235
276
  * edge under the trigger's, `gap`px above it — and only then clamps to the
@@ -527,6 +568,26 @@ const STYLE = `
527
568
  .ntr-acp-empty-hint { color: var(--dsw-alias-text-secondary, #6b7280); font-size: 12px; }
528
569
  .ntr-acp-seat-diag { font-family: var(--dsw-alias-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); opacity: 0.72; word-break: break-word; }
529
570
  .ntr-acp-empty-actions { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; }
571
+ /* ---- NTR settings dialog (shell.overlay) ---- */
572
+ /* Frame-wide surface opened from the agent base's empty-roster menu. The
573
+ overlay layer itself is click-through, so the layer opts back in and blocks
574
+ the app while it is up. */
575
+ .ntr-acp-dialog-layer { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; pointer-events: auto; }
576
+ .ntr-acp-dialog-mask { position: absolute; inset: 0; background: var(--dsw-alias-bg-mask-1, rgba(0, 0, 0, 0.4)); }
577
+ .ntr-acp-dialog {
578
+ position: relative; display: flex; flex-direction: column; overflow: hidden;
579
+ width: min(920px, calc(100vw - 48px)); height: min(820px, calc(100vh - 48px));
580
+ background: var(--dsw-alias-bg-layer-2, #fff); color: inherit;
581
+ border-radius: 24px; box-shadow: var(--dsw-elevation-prominent, 0 24px 60px rgba(0, 0, 0, 0.25));
582
+ }
583
+ .ntr-acp-dialog-head { flex: none; display: flex; align-items: center; gap: 8px; padding: 16px 16px 8px 24px; }
584
+ .ntr-acp-dialog-title { flex: 1; font-size: 16px; font-weight: 600; }
585
+ .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; }
586
+ .ntr-acp-dialog-close:hover { background: var(--dsw-alias-interactive-bg-active, #0000000d); }
587
+ .ntr-acp-dialog-body { flex: 1; min-height: 0; overflow: auto; }
588
+ /* The section brings its own page scroll for the settings shell's content
589
+ column; the dialog body is the scroll owner here. */
590
+ .ntr-acp-dialog-body > .dwa-root { max-width: none; overflow: visible; }
530
591
  `;
531
592
  let styleMounted = false;
532
593
  /** Mount the shared stylesheet once (same pattern as the settings panel's). */
@@ -10,6 +10,7 @@ import { AcpSettingsSection } from "./panel.js";
10
10
  import { AcpComposerMode } from "./composer-mode.js";
11
11
  import { AcpComposerAgentTag, AcpSessionControls, AcpSessionMenu } from "./session-controls.js";
12
12
  import { AcpConversationView } from "./acp-view.js";
13
+ import { AcpSettingsDialog } from "./settings-dialog.js";
13
14
  import { createModelSeatClaim, createPermissionSeatClaim, MODEL_SEAT_KEY, PERMISSION_SEAT_KEY, } from "./model-seat.js";
14
15
  import { acpModeFromPresetEvent, rowAgentPreset, stagedIsAcpMode, stagedSessionOf, } from "./acp-ui.js";
15
16
  import { createSlashIsolation, slashAcpVerdict } from "./slash-isolation.js";
@@ -108,6 +109,18 @@ export function apply(ctx) {
108
109
  label: () => 'NTR',
109
110
  inject: () => ({ acp: store }),
110
111
  }, AcpSettingsSection)), 'dsh-ntr: settings section');
112
+ // The same NTR settings, opened from the composer when the agent roster is
113
+ // empty (the agent base's picker action). It rides the frame's overlay layer
114
+ // because the composer seat and the dialog live in unrelated slot trees; the
115
+ // host settings shell has no programmatic open seam, so the dialog mounts the
116
+ // same section the shell mounts (see settings-dialog.tsx). Renders nothing
117
+ // while closed.
118
+ ctx.effect(() => ctx.slots.inject('shell.overlay', () => ctx.slots.register({
119
+ name: 'shell.overlay',
120
+ id: 'acp-settings-dialog',
121
+ order: 30,
122
+ inject: () => ({ acp: store }),
123
+ }, AcpSettingsDialog)), 'dsh-ntr: NTR settings dialog');
111
124
  // The agent's OWN modes sit in the composer tool row — the seat the slot
112
125
  // contract reserves for exactly this ("entries sit beside that chrome, never
113
126
  // replace it"). They are a different layer from everything the host controls:
@@ -138,16 +138,18 @@ const STYLE = `
138
138
  .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); }
139
139
  .dwa-inline-setting .dwa-switch { flex-shrink: 0; margin-top: 1px; }
140
140
  .dwa-setting-copy { color: var(--ntr-muted); font-size: var(--ntr-font-xs); }
141
- /* Panes stay flush (equal height) whatever the two list lengths are; the
142
- shorter list's list body grows to fill, so its leftover is scrolling room
143
- inside the list rather than a detached block under the last row. */
144
- .dwa-model-transfer { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; align-items: stretch; }
145
- /* Tall enough for a comfortable selection (about ten rows) before scrolling:
146
- both panes share this ceiling, so equal heights cost at most one row of
147
- slack on the shorter side instead of a whole empty half. */
141
+ /* Stacked, not side by side: the rows carry name, id and credit, and a
142
+ half-width pane truncated them at every realistic settings width. Each list
143
+ gets the whole column instead. */
144
+ .dwa-model-transfer { display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; }
145
+ /* Tall enough for a comfortable selection (about ten rows) before scrolling;
146
+ the cap keeps the two stacked panes from pushing the rest of the card away. */
148
147
  .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; }
149
148
  .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); }
150
149
  .dwa-model-pane-head .dwa-btn { margin-left: auto; min-height: 26px; padding: 2px 8px; font-size: var(--ntr-font-xs); }
150
+ .dwa-model-pane-head .dwa-caret-btn { flex: none; width: 22px; height: 22px; background: transparent; }
151
+ .dwa-model-pane-head .dwa-caret-btn:hover { background: var(--dsw-alias-interactive-bg-active, #e5e7eb); }
152
+ .dwa-model-pane.is-collapsed .dwa-model-list { display: none; }
151
153
  .dwa-model-list { display: flex; flex-direction: column; gap: 2px; padding: 5px; flex: 1 1 auto; min-height: 0; overflow-y: auto; }
152
154
  .dwa-model-section { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; font-size: 12px; }
153
155
  .dwa-model-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
@@ -164,7 +166,19 @@ const STYLE = `
164
166
  .dwa-model-row.is-on { background: #3b82f614; color: #2563eb; }
165
167
  .dwa-model-row-mark { width: 12px; flex: none; text-align: center; opacity: 0.75; }
166
168
  .dwa-model-row-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
167
- .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; }
169
+ /* Name and credit keep their room; the id is the only cell that collapses. */
170
+ .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; }
171
+ /* The trailing 14px is reserved for the fade mask: while the id fits, the
172
+ gradient only covers empty padding; once it overflows, its tail dissolves
173
+ instead of ending in a hard ellipsis. armModelIdMarquee reads the same
174
+ padding to scroll the full text under the pointer. */
175
+ .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); }
176
+ .dwa-model-row-value-text { display: inline-block; white-space: nowrap; }
177
+ .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; }
178
+ @keyframes dwa-model-id-marquee {
179
+ from { transform: translateX(0); }
180
+ to { transform: translateX(var(--dwa-id-shift, 0px)); }
181
+ }
168
182
  .dwa-model-row-remove { flex: none; opacity: 0.5; padding: 0 2px; }
169
183
  .dwa-model-row:hover .dwa-model-row-remove { opacity: 1; }
170
184
  .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; }
@@ -181,7 +195,6 @@ const STYLE = `
181
195
  .dwa-page-header { flex-direction: column; }
182
196
  .dwa-page-actions { width: 100%; }
183
197
  .dwa-page-actions .dwa-btn { width: 100%; }
184
- .dwa-model-transfer { grid-template-columns: 1fr; }
185
198
  .dwa-model-toolbar-actions { width: 100%; }
186
199
  .dwa-model-search { flex: 1 1 auto; width: auto; }
187
200
  .dwa-subsection-head, .dwa-preference-row { align-items: flex-start; flex-direction: column; }
@@ -189,6 +202,7 @@ const STYLE = `
189
202
  }
190
203
  @media (prefers-reduced-motion: reduce) {
191
204
  .dwa-root *, .dwa-root *::before, .dwa-root *::after { transition: none !important; }
205
+ .dwa-model-row:hover .dwa-model-row-value-text { animation: none !important; }
192
206
  }
193
207
  `;
194
208
  let styleMounted = false;
@@ -678,6 +692,23 @@ function RowMenu({ acp, canStop, onStop, onRemove, }) {
678
692
  void onRemove();
679
693
  }, children: acp.label('确认移除?', 'Confirm remove?') }), _jsx("button", { type: "button", className: "dwa-btn", onClick: () => setConfirming(false), children: acp.label('取消', 'Cancel') })] })) : (_jsx("button", { type: "button", role: "menuitem", className: "dwa-btn dwa-menu-item dwa-menu-item-danger", onClick: () => setConfirming(true), children: acp.label('移除配置', 'Remove') }))] }))] }));
680
694
  }
695
+ /**
696
+ * Measure the id cell on row hover so its CSS marquee can reach the end of the
697
+ * text. The id is the only cell that yields width, and the panel is resizable,
698
+ * so the overflow is re-read on every entry rather than cached. The animation
699
+ * itself lives in CSS and only runs while the row is hovered; `title` on the
700
+ * row stays the fallback for reduced-motion users.
701
+ */
702
+ function armModelIdMarquee(row) {
703
+ const view = row.querySelector('.dwa-model-row-value');
704
+ const text = view?.querySelector('.dwa-model-row-value-text');
705
+ if (view === null || view === undefined || text === null || text === undefined)
706
+ return;
707
+ const fade = Number.parseFloat(getComputedStyle(view).paddingRight) || 0;
708
+ const shift = Math.max(0, text.offsetWidth - (view.clientWidth - fade));
709
+ row.style.setProperty('--dwa-id-shift', `${String(-shift)}px`);
710
+ row.style.setProperty('--dwa-id-duration', `${String(Math.min(4, Math.max(1.2, shift / 30)))}s`);
711
+ }
681
712
  /**
682
713
  * Model / thought-level catalog display plus the native-selector visibility
683
714
  * filter, as a two-pane transfer list: every known model on the left, the
@@ -718,6 +749,11 @@ function ModelCatalogBlock({ agentId, detected, acp, active, exposed, onExposedC
718
749
  const modelChoices = result?.catalog.model?.choices ?? [];
719
750
  /** Result of the last click on 「检测可用模型」 (what the refresh changed). */
720
751
  const [detectNote, setDetectNote] = useState(undefined);
752
+ // Each list folds on its own so a long selection can be parked while the
753
+ // other pane keeps the full column. The list stays mounted (display-toggled)
754
+ // to keep its scroll position.
755
+ const [allCollapsed, setAllCollapsed] = useState(false);
756
+ const [selectedCollapsed, setSelectedCollapsed] = useState(false);
721
757
  // Display read, not a probe: the host answers from its persisted catalog
722
758
  // cache, so the panes already list the known models the first time the card
723
759
  // opens. Gated on `active` because every configured agent's body stays
@@ -860,7 +896,7 @@ function ModelCatalogBlock({ agentId, detected, acp, active, exposed, onExposedC
860
896
  : acp.label('该 Agent 未暴露模型选择器(category: model)。', 'This agent exposes no model selector (category: model).');
861
897
  return (_jsxs("div", { children: [!isDefault && (_jsx("div", { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }, children: _jsx("button", { className: "dwa-btn", onClick: () => void acp.setDefaultAgent(agentId), children: acp.label('设为默认智能体', 'Set as default agent') }) })), isDefault && (
862
898
  // This default belongs to the plugin, not the deployment model route.
863
- _jsx("div", { className: "dwa-hint", style: { marginTop: 8 }, children: acp.label('当前为默认智能体:新的 NTR 会话由它服务,不影响其他模式的默认模型。', "This is the default agent for new NTR sessions; other modes' default models are untouched.") })), _jsxs("div", { className: "dwa-inline-setting", style: { marginTop: 12 }, children: [_jsxs("label", { className: "dwa-switch", title: acp.label('Agent 新发布的模型会自动加入右侧已选列表;首次检测到的目录只作为基线,不会一次性全选。', 'Models the agent newly publishes are added to the selected list; the first observed catalog is only a baseline and is not selected wholesale.'), children: [_jsx("input", { type: "checkbox", checked: autoDiscover, onChange: e => void toggleAutoDiscover(e.target.checked), style: { margin: 0, accentColor: '#2563eb' } }), acp.label('自动发现新模型', 'Auto-discover new models')] }), _jsx("span", { className: "dwa-setting-copy", children: acp.label('新增模型自动加入已选列表,现有勾选不受影响。', 'New models join the selected list automatically; existing picks are untouched.') })] }), saveError !== undefined && _jsx("div", { className: "dwa-error", style: { marginTop: 4 }, children: saveError }), probeError !== undefined && (_jsxs("div", { className: "dwa-error", style: { marginTop: 8, wordBreak: 'break-word' }, children: ["\u26A0 ", acp.label('模型检测失败:', 'Model detection failed: '), probeError] })), _jsxs("div", { className: "dwa-model-section", children: [_jsxs("div", { className: "dwa-model-toolbar", children: [_jsxs("span", { className: "dwa-form-label", children: [acp.label('对话中可见的模型', 'Models offered in the conversation'), _jsx("span", { className: "dwa-hint", style: { marginLeft: 6, fontWeight: 400 }, title: acp.label('NTR 模式下的对话只列出这里勾选的模型;未勾选的仍可用——正在生效的那个也会以「· 当前」列出来,其余不列出。', 'The NTR surfaces only list what is checked here; unchecked models still work, and the one currently in force is still listed with a "· current" row — the rest are not offered.'), children: acp.label('(NTR 座位 / 芯片的下拉列表)', '(the NTR seat and chip pickers)') })] }), _jsx("span", { className: "dwa-badge dwa-badge-info", children: selectionBadge }), catalogStatus !== undefined && _jsx("span", { className: "dwa-hint dwa-model-status", children: catalogStatus }), _jsxs("div", { className: "dwa-model-toolbar-actions", children: [_jsx("button", { type: "button", className: "dwa-btn dwa-btn-secondary", disabled: probing, onClick: () => void detect(), title: acp.label('重新读取 Agent 的模型目录:新增的加入左侧,已下架的从左侧移除;已选列表不受影响。', 'Re-read the agent catalog: newly published models join the left list, withdrawn ones drop out. The selection is untouched.'), children: probing ? acp.label('模型检测中...', 'Probing models...') : acp.label('检测可用模型', 'Detect available models') }), _jsx("input", { className: "dwa-input dwa-model-search", value: search, onChange: e => setSearch(e.target.value), placeholder: acp.label('搜索模型…', 'Search models…') })] })] }), _jsxs("div", { className: "dwa-model-transfer", children: [_jsxs("div", { className: "dwa-model-pane", children: [_jsxs("div", { className: "dwa-model-pane-head", children: [_jsx("span", { children: acp.label('全部模型', 'All models') }), result !== undefined && _jsx("span", { className: "dwa-badge", children: modelChoices.length }), _jsx("button", { type: "button", className: "dwa-btn dwa-btn-secondary", disabled: visibleChoices.length === 0, onClick: addVisible, title: acp.label('把当前搜索范围内的模型全部加入右侧', 'Add every model matching the current search'), children: acp.label('全部加入', 'Add all') })] }), _jsxs("div", { className: "dwa-model-list", children: [visibleChoices.map(choice => {
899
+ _jsx("div", { className: "dwa-hint", style: { marginTop: 8 }, children: acp.label('当前为默认智能体:新的 NTR 会话由它服务,不影响其他模式的默认模型。', "This is the default agent for new NTR sessions; other modes' default models are untouched.") })), _jsxs("div", { className: "dwa-inline-setting", style: { marginTop: 12 }, children: [_jsxs("label", { className: "dwa-switch", title: acp.label('Agent 新发布的模型会自动加入右侧已选列表;首次检测到的目录只作为基线,不会一次性全选。', 'Models the agent newly publishes are added to the selected list; the first observed catalog is only a baseline and is not selected wholesale.'), children: [_jsx("input", { type: "checkbox", checked: autoDiscover, onChange: e => void toggleAutoDiscover(e.target.checked), style: { margin: 0, accentColor: '#2563eb' } }), acp.label('自动发现新模型', 'Auto-discover new models')] }), _jsx("span", { className: "dwa-setting-copy", children: acp.label('新增模型自动加入已选列表,现有勾选不受影响。', 'New models join the selected list automatically; existing picks are untouched.') })] }), saveError !== undefined && _jsx("div", { className: "dwa-error", style: { marginTop: 4 }, children: saveError }), probeError !== undefined && (_jsxs("div", { className: "dwa-error", style: { marginTop: 8, wordBreak: 'break-word' }, children: ["\u26A0 ", acp.label('模型检测失败:', 'Model detection failed: '), probeError] })), _jsxs("div", { className: "dwa-model-section", children: [_jsxs("div", { className: "dwa-model-toolbar", children: [_jsxs("span", { className: "dwa-form-label", children: [acp.label('对话中可见的模型', 'Models offered in the conversation'), _jsx("span", { className: "dwa-hint", style: { marginLeft: 6, fontWeight: 400 }, title: acp.label('NTR 模式下的对话只列出这里勾选的模型;未勾选的仍可用——正在生效的那个也会以「· 当前」列出来,其余不列出。', 'The NTR surfaces only list what is checked here; unchecked models still work, and the one currently in force is still listed with a "· current" row — the rest are not offered.'), children: acp.label('(NTR 座位 / 芯片的下拉列表)', '(the NTR seat and chip pickers)') })] }), _jsx("span", { className: "dwa-badge dwa-badge-info", children: selectionBadge }), catalogStatus !== undefined && _jsx("span", { className: "dwa-hint dwa-model-status", children: catalogStatus }), _jsxs("div", { className: "dwa-model-toolbar-actions", children: [_jsx("button", { type: "button", className: "dwa-btn dwa-btn-secondary", disabled: probing, onClick: () => void detect(), title: acp.label('重新读取 Agent 的模型目录:新增的加入左侧,已下架的从左侧移除;已选列表不受影响。', 'Re-read the agent catalog: newly published models join the left list, withdrawn ones drop out. The selection is untouched.'), children: probing ? acp.label('模型检测中...', 'Probing models...') : acp.label('检测可用模型', 'Detect available models') }), _jsx("input", { className: "dwa-input dwa-model-search", value: search, onChange: e => setSearch(e.target.value), placeholder: acp.label('搜索模型…', 'Search models…') })] })] }), _jsxs("div", { className: "dwa-model-transfer", children: [_jsxs("div", { className: `dwa-model-pane${allCollapsed ? ' is-collapsed' : ''}`, children: [_jsxs("div", { className: "dwa-model-pane-head", children: [_jsx("span", { children: acp.label('全部模型', 'All models') }), result !== undefined && _jsx("span", { className: "dwa-badge", children: modelChoices.length }), _jsx("button", { type: "button", className: "dwa-btn dwa-btn-secondary", disabled: visibleChoices.length === 0, onClick: addVisible, title: acp.label('把当前搜索范围内的模型全部加入右侧', 'Add every model matching the current search'), children: acp.label('全部加入', 'Add all') }), _jsx("button", { type: "button", className: "dwa-caret-btn", "aria-expanded": !allCollapsed, onClick: () => setAllCollapsed(value => !value), title: allCollapsed ? acp.label('展开全部模型', 'Expand all models') : acp.label('收起全部模型', 'Collapse all models'), children: _jsx("span", { className: "dwa-caret", "aria-hidden": true, children: allCollapsed ? '▸' : '▾' }) })] }), _jsxs("div", { className: "dwa-model-list", children: [visibleChoices.map(choice => {
864
900
  const checked = selected.has(choice.value);
865
901
  // group 前缀仅来自 agent 返回的 SessionConfigSelectGroup(见 impl.ts
866
902
  // pushCatalogChoice);供应商可见性兜底靠原样展示 value,严禁从
@@ -868,6 +904,6 @@ function ModelCatalogBlock({ agentId, detected, acp, active, exposed, onExposedC
868
904
  const title = choice.description === undefined
869
905
  ? choice.value
870
906
  : `${choice.value}\n${choice.description}`;
871
- return (_jsxs("button", { type: "button", className: `dwa-model-row${checked ? ' is-on' : ''}`, title: title, onClick: () => toggleChoice(choice.value), children: [_jsx("span", { className: "dwa-model-row-mark", children: checked ? '✓' : '+' }), _jsxs("span", { className: "dwa-model-row-name", children: [choice.group !== undefined && _jsxs("span", { style: { opacity: 0.7 }, children: [choice.group, " \u00B7 "] }), choice.name] }), choice.value !== choice.name && (_jsx("span", { className: "dwa-model-row-value", children: choice.value }))] }, choice.value));
872
- }), leftEmptyCopy !== undefined && _jsx("span", { className: "dwa-hint dwa-model-empty", children: leftEmptyCopy })] })] }), _jsxs("div", { className: "dwa-model-pane", children: [_jsxs("div", { className: "dwa-model-pane-head", children: [_jsx("span", { children: acp.label('已选模型', 'Selected models') }), _jsx("span", { className: "dwa-badge dwa-badge-info", children: basis.length }), _jsx("button", { type: "button", className: "dwa-btn dwa-btn-secondary", disabled: basis.length === 0, onClick: clearAll, children: acp.label('清空', 'Clear') })] }), _jsxs("div", { className: "dwa-model-list", children: [selectedChoices.length === 0 && (_jsx("span", { className: "dwa-hint dwa-model-empty", children: acp.label('还没有选择模型(点左侧的模型加入)', 'Nothing selected yet — pick models on the left') })), selectedChoices.map(choice => (_jsxs("button", { type: "button", className: "dwa-model-row is-on", title: choice.description === undefined ? choice.value : `${choice.value}\n${choice.description}`, onClick: () => toggleChoice(choice.value), children: [_jsxs("span", { className: "dwa-model-row-name", children: [choice.group !== undefined && _jsxs("span", { style: { opacity: 0.7 }, children: [choice.group, " \u00B7 "] }), choice.name] }), choice.value !== choice.name && (_jsx("span", { className: "dwa-model-row-value", children: choice.value })), _jsx("span", { className: "dwa-model-row-remove", "aria-hidden": "true", children: "\u00D7" })] }, choice.value)))] })] })] }), result?.catalog.thought !== undefined && (_jsxs("div", { style: { display: 'flex', gap: 4, flexWrap: 'wrap', alignItems: 'center' }, children: [_jsx("span", { className: "dwa-form-label", children: acp.label('思考等级', 'Thought Levels') }), result.catalog.thought.choices.map(choice => (_jsx("span", { className: "dwa-badge", title: choice.description, children: choice.name }, choice.value)))] }))] })] }));
907
+ return (_jsxs("button", { type: "button", className: `dwa-model-row${checked ? ' is-on' : ''}`, title: title, onClick: () => toggleChoice(choice.value), onPointerEnter: event => { armModelIdMarquee(event.currentTarget); }, children: [_jsx("span", { className: "dwa-model-row-mark", children: checked ? '✓' : '+' }), _jsxs("span", { className: "dwa-model-row-name", children: [choice.group !== undefined && _jsxs("span", { style: { opacity: 0.7 }, children: [choice.group, " \u00B7 "] }), choice.name] }), choice.value !== choice.name && (_jsx("span", { className: "dwa-model-row-value", children: _jsx("span", { className: "dwa-model-row-value-text", children: choice.value }) })), choice.description !== undefined && choice.description !== choice.group && (_jsx("span", { className: "dwa-model-row-desc", children: choice.description }))] }, choice.value));
908
+ }), leftEmptyCopy !== undefined && _jsx("span", { className: "dwa-hint dwa-model-empty", children: leftEmptyCopy })] })] }), _jsxs("div", { className: `dwa-model-pane${selectedCollapsed ? ' is-collapsed' : ''}`, children: [_jsxs("div", { className: "dwa-model-pane-head", children: [_jsx("span", { children: acp.label('已选模型', 'Selected models') }), _jsx("span", { className: "dwa-badge dwa-badge-info", children: basis.length }), _jsx("button", { type: "button", className: "dwa-btn dwa-btn-secondary", disabled: basis.length === 0, onClick: clearAll, children: acp.label('清空', 'Clear') }), _jsx("button", { type: "button", className: "dwa-caret-btn", "aria-expanded": !selectedCollapsed, onClick: () => setSelectedCollapsed(value => !value), title: selectedCollapsed ? acp.label('展开已选模型', 'Expand selected models') : acp.label('收起已选模型', 'Collapse selected models'), children: _jsx("span", { className: "dwa-caret", "aria-hidden": true, children: selectedCollapsed ? '▸' : '▾' }) })] }), _jsxs("div", { className: "dwa-model-list", children: [selectedChoices.length === 0 && (_jsx("span", { className: "dwa-hint dwa-model-empty", children: acp.label('还没有选择模型(点左侧的模型加入)', 'Nothing selected yet — pick models on the left') })), selectedChoices.map(choice => (_jsxs("button", { type: "button", className: "dwa-model-row is-on", title: choice.description === undefined ? choice.value : `${choice.value}\n${choice.description}`, onClick: () => toggleChoice(choice.value), onPointerEnter: event => { armModelIdMarquee(event.currentTarget); }, children: [_jsxs("span", { className: "dwa-model-row-name", children: [choice.group !== undefined && _jsxs("span", { style: { opacity: 0.7 }, children: [choice.group, " \u00B7 "] }), choice.name] }), choice.value !== choice.name && (_jsx("span", { className: "dwa-model-row-value", children: _jsx("span", { className: "dwa-model-row-value-text", children: choice.value }) })), choice.description !== undefined && choice.description !== choice.group && (_jsx("span", { className: "dwa-model-row-desc", children: choice.description })), _jsx("span", { className: "dwa-model-row-remove", "aria-hidden": "true", children: "\u00D7" })] }, choice.value)))] })] })] }), result?.catalog.thought !== undefined && (_jsxs("div", { style: { display: 'flex', gap: 4, flexWrap: 'wrap', alignItems: 'center' }, children: [_jsx("span", { className: "dwa-form-label", children: acp.label('思考等级', 'Thought Levels') }), result.catalog.thought.choices.map(choice => (_jsx("span", { className: "dwa-badge", title: choice.description, children: choice.name }, choice.value)))] }))] })] }));
873
909
  }
@@ -50,13 +50,15 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
50
50
  * different slots, so they share that module's phase clock rather than trying
51
51
  * to animate across slot boundaries.
52
52
  *
53
- * It is deliberately quiet: the base renders NOTHING for a conversation no ACP
54
- * agent has served, so an ordinary chat is untouched even before the gate
55
- * stands down. The ACP view tab is the entry point for those (and for re-entry
56
- * after a detour through another provider).
53
+ * It is deliberately quiet: outside ACP mode the gate never mounts it, and
54
+ * before the host has answered the agent roster it renders nothing. Once the
55
+ * roster is KNOWN to be empty the base stays and its picker carries the
56
+ * "open NTR settings" action — an NTR conversation with nothing configured
57
+ * still has a way forward instead of an empty cell. The ACP view tab remains
58
+ * the entry point for conversations no agent has served.
57
59
  */
58
60
  import { useEffect, useState } from 'react';
59
- import { AcpChip, AcpMenu, AcpShatterParts, buildAcpModelId, closeAcpMenu, ensureAcpStyle, openAcpMenu, setAcpMenuError, splitAcpModelId, useAcpMenu, useUi, } from "./acp-ui.js";
61
+ import { AcpChip, AcpMenu, AcpShatterParts, buildAcpModelId, closeAcpMenu, ensureAcpStyle, openAcpMenu, openAcpSettingsDialog, setAcpMenuError, splitAcpModelId, useAcpMenu, useUi, } from "./acp-ui.js";
60
62
  import { playAgentLockAnim, useAgentLockPhase } from "./agent-lock-anim.js";
61
63
  const STATE_COLORS = {
62
64
  ready: '#22c55e',
@@ -105,6 +107,18 @@ function routeAgentId(boundAgentId, bound, agents, defaultAgent) {
105
107
  return defaultAgent;
106
108
  return undefined;
107
109
  }
110
+ /**
111
+ * Whether the agent base has anything honest to show.
112
+ *
113
+ * An empty roster has two meanings, and they must not be conflated: before the
114
+ * host answers `agents/list` it means "not read yet" (rendering a setup entry
115
+ * there would accuse a correctly configured deployment on every page load),
116
+ * while after the answer it means "nothing is configured" — exactly the state
117
+ * that must keep the base and offer the way into NTR settings.
118
+ */
119
+ export function agentBaseHasData(bound, agents, rosterLoaded) {
120
+ return bound !== undefined || agents.length > 0 || rosterLoaded;
121
+ }
108
122
  /**
109
123
  * The agent this conversation is LOCKED to, if its first ACP turn has run.
110
124
  *
@@ -294,14 +308,18 @@ export function advancedOptions(catalog, choice) {
294
308
  * The tooltip claims only what the menu behind the chip does: switch the agent.
295
309
  * The agent's models and thought levels have chips of their own (user decision,
296
310
  * 2026-10-17), so the previous wording — "switch the agent, its model and the
297
- * thought level" — promised a menu that holds the roster alone.
311
+ * thought level" — promised a menu that holds the roster alone. An EMPTY
312
+ * roster is its own case: the menu cannot switch to anything, it offers the
313
+ * way into NTR settings, so the tooltip says that instead of promising a pick.
298
314
  */
299
- export function agentBaseChip(route, routed, acp) {
315
+ export function agentBaseChip(route, routed, acp, rosterEmpty = false) {
300
316
  const label = route.agentTitle === undefined
301
317
  ? (routed ? 'NTR' : acp.label('选择 Agent', 'Choose agent'))
302
318
  : `NTR · ${route.agentTitle}`;
303
319
  const title = route.agentId === undefined
304
- ? acp.label('这条会话当前没有 NTR agent 服务;点开可选择 agent', 'No NTR agent is serving this conversation right now — open to choose one')
320
+ ? rosterEmpty
321
+ ? acp.label('这条会话当前没有 NTR agent;点开可前往 NTR 设置添加', 'No NTR agent is serving this conversation — open to set one up in NTR settings')
322
+ : acp.label('这条会话当前没有 NTR agent 服务;点开可选择 agent', 'No NTR agent is serving this conversation right now — open to choose one')
305
323
  : acp.label('这条会话由外接 NTR agent 服务;点开可切换 agent', 'This conversation is served by an external NTR agent — open to switch the agent');
306
324
  return { label, title };
307
325
  }
@@ -424,9 +442,12 @@ export function AcpSessionControls(props) {
424
442
  const route = describeRoute(agents, bound,
425
443
  // A locked conversation has no default fallback: the lock is the answer.
426
444
  lockedTo === undefined ? ui.defaultAgent : undefined, catalog, lockedTo ?? ui.chatBoundAgent[sessionId], choice);
427
- // A conversation with no ACP story to tell gets no chrome: the ACP view tab
428
- // owns the entry point for starting one.
429
- if (bound === undefined && agents.length === 0)
445
+ // An empty roster is the SETUP case, not "no story": the base stays and its
446
+ // picker carries the way into NTR settings. Only the window before the host
447
+ // has answered `agents/list` stays blank — `agents: []` there means "not read
448
+ // yet", and the setup entry would accuse a configured deployment (see
449
+ // `agentBaseHasData`).
450
+ if (!agentBaseHasData(bound, agents, ui.agentsLoaded))
430
451
  return null;
431
452
  // Set up = an agent serves this conversation: a live session, a persisted
432
453
  // pick, the host's optimistic binding, OR the plugin default the guard's
@@ -471,7 +492,7 @@ export function AcpSessionControls(props) {
471
492
  // agent's extra options never push the primary axes around. Once the first
472
493
  // turn locks the conversation the base retires (2026-10-20) and the
473
494
  // model / thought pair inherits the right edge.
474
- return (_jsxs("span", { className: "ntr-acp-cluster", children: [showAdvanced && (_jsx(AcpChip, { label: `${acp.label('高级', 'Advanced')} · ${String(extras.length)}`, title: acp.label(`agent 的额外配置项:${extras.map(option => option.name).join('、')}`, `extra agent config options: ${extras.map(option => option.name).join(', ')}`), active: menu?.sessionId === sessionId && menu.kind === 'advanced', onClick: (trigger) => { open('advanced', trigger); } })), showModel && modelLabel !== undefined && (_jsx(AcpChip, { label: modelLabel, title: acp.label('agent 自己的模型目录', "the agent's own model directory"), active: menu?.sessionId === sessionId && menu.kind === 'model', onClick: (trigger) => { open('model', trigger); } })), showThought && (_jsx(AcpChip, { label: `${acp.label('思考', 'think')} · ${route.effortLabel ?? acp.label('默认', 'default')}`, title: acp.label('思考等级(agent 的 thought_level)', "Thought level (the agent's thought_level)"), active: menu?.sessionId === sessionId && menu.kind === 'thought', onClick: (trigger) => { open('thought', trigger); } })), showAgentBase && (_jsx(AcpChip, { ...agentBaseChip(route, routed, acp), active: routed, className: shattering ? 'ntr-acp-shatter' : undefined, onClick: shattering ? undefined : (trigger) => { open('agent', trigger); }, children: shattering && _jsx(AcpShatterParts, { mode: "out" }) }))] }));
495
+ return (_jsxs("span", { className: "ntr-acp-cluster", children: [showAdvanced && (_jsx(AcpChip, { label: `${acp.label('高级', 'Advanced')} · ${String(extras.length)}`, title: acp.label(`agent 的额外配置项:${extras.map(option => option.name).join('、')}`, `extra agent config options: ${extras.map(option => option.name).join(', ')}`), active: menu?.sessionId === sessionId && menu.kind === 'advanced', onClick: (trigger) => { open('advanced', trigger); } })), showModel && modelLabel !== undefined && (_jsx(AcpChip, { label: modelLabel, title: acp.label('agent 自己的模型目录', "the agent's own model directory"), active: menu?.sessionId === sessionId && menu.kind === 'model', onClick: (trigger) => { open('model', trigger); } })), showThought && (_jsx(AcpChip, { label: `${acp.label('思考', 'think')} · ${route.effortLabel ?? acp.label('默认', 'default')}`, title: acp.label('思考等级(agent 的 thought_level)', "Thought level (the agent's thought_level)"), active: menu?.sessionId === sessionId && menu.kind === 'thought', onClick: (trigger) => { open('thought', trigger); } })), showAgentBase && (_jsx(AcpChip, { ...agentBaseChip(route, routed, acp, agents.length === 0), active: routed, className: shattering ? 'ntr-acp-shatter' : undefined, onClick: shattering ? undefined : (trigger) => { open('agent', trigger); }, children: shattering && _jsx(AcpShatterParts, { mode: "out" }) }))] }));
475
496
  }
476
497
  /**
477
498
  * The model picker's explicit refresh row. It is an ACTION, not a model: the
@@ -481,6 +502,13 @@ export function AcpSessionControls(props) {
481
502
  * `agentId@value`.
482
503
  */
483
504
  export const REFRESH_CATALOG_ITEM = '__refresh-catalog__';
505
+ /**
506
+ * The empty roster's setup action. Like the model picker's refresh row it is an
507
+ * ACTION inside the picker, not an agent: agent ids match
508
+ * `[a-z0-9][a-z0-9._-]{0,63}` (no leading underscore), so no real id can
509
+ * collide with it.
510
+ */
511
+ export const OPEN_SETTINGS_ITEM = '__open-settings__';
484
512
  /** The agent roster rows: one row per configured ACP agent, current one marked. */
485
513
  function agentSection(context) {
486
514
  const { acp, route } = context;
@@ -492,12 +520,21 @@ function agentSection(context) {
492
520
  active: route.agentId === agent.id,
493
521
  }));
494
522
  if (items.length === 0) {
523
+ // The base stays on an empty roster (see `agentBaseHasData`), so this menu
524
+ // is the setup surface: a status row that says what is missing, then an
525
+ // ACTION row that opens the NTR settings. The action is a row of its own
526
+ // because a disabled status row cannot be the way forward.
495
527
  items.push({
496
528
  id: 'none',
497
529
  label: acp.label('还没有配置 NTR agent', 'No NTR agent configured yet'),
498
- detail: acp.label('设置 → NTR', 'Settings → NTR'),
530
+ detail: acp.label('添加并启用一个 agent 后即可开始对话', 'Add and enable an agent to start'),
499
531
  disabled: true,
500
532
  });
533
+ items.push({
534
+ id: OPEN_SETTINGS_ITEM,
535
+ label: acp.label('打开 NTR 设置', 'Open NTR settings'),
536
+ detail: acp.label('添加或启用 agent', 'Add or enable an agent'),
537
+ });
501
538
  }
502
539
  return {
503
540
  id: 'agent',
@@ -787,6 +824,15 @@ export function AcpSessionMenu(props) {
787
824
  await acp.probeModels(agentId);
788
825
  return;
789
826
  }
827
+ if (sectionId === 'agent' && itemId === OPEN_SETTINGS_ITEM) {
828
+ // The empty roster's way out (see `agentSection`): there is no agent to
829
+ // bind, so this row opens the NTR settings surface instead. Close the
830
+ // picker first — the dialog is a frame overlay and the menu must not
831
+ // linger behind it.
832
+ closeAcpMenu();
833
+ openAcpSettingsDialog();
834
+ return;
835
+ }
790
836
  if (sectionId === 'agent') {
791
837
  await acp.bindChatAgent(sessionId, itemId);
792
838
  }
@@ -0,0 +1,43 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * The NTR settings dialog, opened from the agent base's empty-roster menu.
4
+ *
5
+ * WHY A DIALOG OF OUR OWN: the host settings shell owns its open state and
6
+ * exposes no programmatic way in. The sidebar seat that receives `openSettings`
7
+ * (`settings.launcher`) is a single-occupant slot already held by the account
8
+ * menu, and `openSection` is only handed to `settings.onboarding` registrants —
9
+ * so a conversation surface cannot drive the shell's modal. This dialog mounts
10
+ * the SAME `AcpSettingsSection` the shell mounts, on the frame's overlay layer
11
+ * (`shell.overlay`): one settings surface, two entrances, no duplicated
12
+ * configuration logic.
13
+ *
14
+ * The trigger (composer model seat) and this dialog (root overlay) live in
15
+ * unrelated slot trees, so the open state is the module-level store in
16
+ * `acp-ui.tsx` (`openAcpSettingsDialog`).
17
+ */
18
+ import { useEffect } from 'react';
19
+ import { AcpSettingsSection } from "./panel.js";
20
+ import { closeAcpSettingsDialog, ensureAcpStyle, useAcpSettingsDialogOpen, } from "./acp-ui.js";
21
+ /** Frame-wide NTR settings surface; renders nothing while closed. */
22
+ export function AcpSettingsDialog(props) {
23
+ ensureAcpStyle();
24
+ const { acp } = props;
25
+ const open = useAcpSettingsDialogOpen();
26
+ useEffect(() => {
27
+ if (!open)
28
+ return;
29
+ const onKey = (event) => {
30
+ if (event.key !== 'Escape')
31
+ return;
32
+ event.preventDefault();
33
+ event.stopPropagation();
34
+ closeAcpSettingsDialog();
35
+ };
36
+ document.addEventListener('keydown', onKey, true);
37
+ return () => { document.removeEventListener('keydown', onKey, true); };
38
+ }, [open]);
39
+ if (!open)
40
+ return null;
41
+ const title = acp.label('NTR 设置', 'NTR settings');
42
+ return (_jsxs("div", { className: "ntr-acp-dialog-layer", children: [_jsx("div", { className: "ntr-acp-dialog-mask", "aria-hidden": true, onClick: closeAcpSettingsDialog }), _jsxs("div", { className: "ntr-acp-dialog", role: "dialog", "aria-modal": "true", "aria-label": title, children: [_jsxs("div", { className: "ntr-acp-dialog-head", children: [_jsx("span", { className: "ntr-acp-dialog-title", children: title }), _jsx("button", { type: "button", className: "ntr-acp-dialog-close", title: acp.label('关闭', 'Close'), "aria-label": acp.label('关闭 NTR 设置', 'Close NTR settings'), autoFocus: true, onClick: closeAcpSettingsDialog, children: "\u2715" })] }), _jsx("div", { className: "ntr-acp-dialog-body", children: _jsx(AcpSettingsSection, { acp: acp }) })] })] }));
43
+ }
@@ -287,6 +287,7 @@ export class AcpStore {
287
287
  localeRev: 0,
288
288
  stream: 'init',
289
289
  agents: [],
290
+ agentsLoaded: false,
290
291
  sessions: [],
291
292
  blocks: [],
292
293
  permissions: [],
@@ -517,6 +518,7 @@ export class AcpStore {
517
518
  // Absent on an older host: keep the settings-panel card (degraded path).
518
519
  this.mutate({
519
520
  agents,
521
+ agentsLoaded: true,
520
522
  sessions,
521
523
  defaultAgent: defaultAgentInfo?.agentId,
522
524
  detectedAgents,