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.
@@ -281,6 +281,14 @@ export declare function closeAcpMenu(): void;
281
281
  export declare function setAcpMenuError(error?: string): void;
282
282
  /** Subscribe to the open-picker state (both the chips and the overlay read it). */
283
283
  export declare function useAcpMenu(): MenuState | null;
284
+ /** Open the NTR settings dialog (the empty agent roster's way forward). */
285
+ export declare function openAcpSettingsDialog(): void;
286
+ /** Close the NTR settings dialog (close button, mask click, Escape). */
287
+ export declare function closeAcpSettingsDialog(): void;
288
+ /** Current dialog state, for tests and non-React readers. */
289
+ export declare function acpSettingsDialogIsOpen(): boolean;
290
+ /** Subscribe to the dialog state (the root overlay entry reads it). */
291
+ export declare function useAcpSettingsDialogOpen(): boolean;
284
292
  /** One selectable row of a picker. */
285
293
  export interface AcpMenuItem {
286
294
  id: string;
@@ -49,10 +49,12 @@
49
49
  * different slots, so they share that module's phase clock rather than trying
50
50
  * to animate across slot boundaries.
51
51
  *
52
- * It is deliberately quiet: the base renders NOTHING for a conversation no ACP
53
- * agent has served, so an ordinary chat is untouched even before the gate
54
- * stands down. The ACP view tab is the entry point for those (and for re-entry
55
- * after a detour through another provider).
52
+ * It is deliberately quiet: outside ACP mode the gate never mounts it, and
53
+ * before the host has answered the agent roster it renders nothing. Once the
54
+ * roster is KNOWN to be empty the base stays and its picker carries the
55
+ * "open NTR settings" action — an NTR conversation with nothing configured
56
+ * still has a way forward instead of an empty cell. The ACP view tab remains
57
+ * the entry point for conversations no agent has served.
56
58
  */
57
59
  import type { CSSProperties } from 'react';
58
60
  import type { AcpChatInjected, AcpMenuKind, AcpMenuSection, AcpSessionHook } from './acp-ui.tsx';
@@ -88,6 +90,16 @@ interface RouteInfo {
88
90
  effectiveEffort?: string;
89
91
  effortLabel?: string;
90
92
  }
93
+ /**
94
+ * Whether the agent base has anything honest to show.
95
+ *
96
+ * An empty roster has two meanings, and they must not be conflated: before the
97
+ * host answers `agents/list` it means "not read yet" (rendering a setup entry
98
+ * there would accuse a correctly configured deployment on every page load),
99
+ * while after the answer it means "nothing is configured" — exactly the state
100
+ * that must keep the base and offer the way into NTR settings.
101
+ */
102
+ export declare function agentBaseHasData(bound: ChatSessionView | undefined, agents: readonly AgentView[], rosterLoaded: boolean): boolean;
91
103
  /**
92
104
  * The agent this conversation is LOCKED to, if its first ACP turn has run.
93
105
  *
@@ -202,9 +214,11 @@ export declare function advancedOptions(catalog: AgentCatalogResult | undefined,
202
214
  * The tooltip claims only what the menu behind the chip does: switch the agent.
203
215
  * The agent's models and thought levels have chips of their own (user decision,
204
216
  * 2026-10-17), so the previous wording — "switch the agent, its model and the
205
- * thought level" — promised a menu that holds the roster alone.
217
+ * thought level" — promised a menu that holds the roster alone. An EMPTY
218
+ * roster is its own case: the menu cannot switch to anything, it offers the
219
+ * way into NTR settings, so the tooltip says that instead of promising a pick.
206
220
  */
207
- export declare function agentBaseChip(route: RouteInfo, routed: boolean, acp: AcpChatInjected['acp']): {
221
+ export declare function agentBaseChip(route: RouteInfo, routed: boolean, acp: AcpChatInjected['acp'], rosterEmpty?: boolean): {
208
222
  label: string;
209
223
  title: string;
210
224
  };
@@ -269,6 +283,13 @@ export interface MenuContext {
269
283
  * `agentId@value`.
270
284
  */
271
285
  export declare const REFRESH_CATALOG_ITEM = "__refresh-catalog__";
286
+ /**
287
+ * The empty roster's setup action. Like the model picker's refresh row it is an
288
+ * ACTION inside the picker, not an agent: agent ids match
289
+ * `[a-z0-9][a-z0-9._-]{0,63}` (no leading underscore), so no real id can
290
+ * collide with it.
291
+ */
292
+ export declare const OPEN_SETTINGS_ITEM = "__open-settings__";
272
293
  /**
273
294
  * REMOVED on purpose (2026-10-17): the agent's own `session/set_mode` list used
274
295
  * to ride inside the agent base's menu. It has its own control
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The NTR settings dialog, opened from the agent base's empty-roster menu.
3
+ *
4
+ * WHY A DIALOG OF OUR OWN: the host settings shell owns its open state and
5
+ * exposes no programmatic way in. The sidebar seat that receives `openSettings`
6
+ * (`settings.launcher`) is a single-occupant slot already held by the account
7
+ * menu, and `openSection` is only handed to `settings.onboarding` registrants —
8
+ * so a conversation surface cannot drive the shell's modal. This dialog mounts
9
+ * the SAME `AcpSettingsSection` the shell mounts, on the frame's overlay layer
10
+ * (`shell.overlay`): one settings surface, two entrances, no duplicated
11
+ * configuration logic.
12
+ *
13
+ * The trigger (composer model seat) and this dialog (root overlay) live in
14
+ * unrelated slot trees, so the open state is the module-level store in
15
+ * `acp-ui.tsx` (`openAcpSettingsDialog`).
16
+ */
17
+ import type { AcpStore } from './store.ts';
18
+ /** The root overlay entry's inject face. */
19
+ export interface AcpSettingsDialogProps {
20
+ acp: AcpStore;
21
+ }
22
+ /** Frame-wide NTR settings surface; renders nothing while closed. */
23
+ export declare function AcpSettingsDialog(props: AcpSettingsDialogProps): import("react").JSX.Element | null;
@@ -118,6 +118,12 @@ export interface UiState {
118
118
  localeRev: number;
119
119
  stream: 'init' | 'live' | 'down';
120
120
  agents: AgentView[];
121
+ /**
122
+ * Whether the host has answered `agents/list` at least once in this page.
123
+ * `agents: []` alone cannot tell "no agent configured" from "not read yet";
124
+ * a surface that offers a setup entry on an empty roster must wait for this.
125
+ */
126
+ agentsLoaded: boolean;
121
127
  sessions: SessionView[];
122
128
  activeSessionId?: string;
123
129
  blocks: Block[];
package/locale/en.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "meta": {
3
- "description": "Interactive Agent Client Protocol (ACP) client for the DSH web UI: attach any ACP-capable agent, stream its session, answer permission requests as a human."
3
+ "description": "Interactive Agent Client Protocol (ACP) client for the DSH web UI: attach any ACP-capable agent and stream its session."
4
4
  }
5
5
  }
package/locale/zh.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "meta": {
3
- "description": "面向 DSH Web 界面的 ACP(Agent Client Protocol)客户端:接入任意支持 ACP 的 agent,实时展示它的会话,权限请求由你亲自答复。"
3
+ "description": "面向 DSH Web 界面的 ACP(Agent Client Protocol)客户端:接入任意支持 ACP 的 agent,实时展示它的会话。"
4
4
  }
5
5
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-ntr",
3
- "version": "0.1.1",
4
- "description": "Interactive Agent Client Protocol (ACP) client for the DSH web UI: attach any ACP-capable agent, stream its session, answer permission requests as a human.",
3
+ "version": "0.1.2",
4
+ "description": "Interactive Agent Client Protocol (ACP) client for the DSH web UI: attach any ACP-capable agent and stream its session.",
5
5
  "repository": "https://github.com/GDWhisper/dsh-ntr.git",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
@@ -434,6 +434,54 @@ export function useAcpMenu(): MenuState | null {
434
434
  )
435
435
  }
436
436
 
437
+ // ---- The NTR settings dialog ----
438
+
439
+ /**
440
+ * Open state of the frame-wide NTR settings dialog (`settings-dialog.tsx`).
441
+ *
442
+ * Module-level because the two ends live in unrelated slot trees: the trigger
443
+ * is the composer's agent base (a session-scoped model-seat entry) and the
444
+ * dialog itself rides the root `shell.overlay` layer. A React context cannot
445
+ * span that boundary, so this is the same tiny external-store seam the menu
446
+ * uses above.
447
+ */
448
+ let settingsDialogOpen = false
449
+ const settingsDialogListeners = new Set<() => void>()
450
+
451
+ function emitSettingsDialog(): void {
452
+ for (const listener of [...settingsDialogListeners]) listener()
453
+ }
454
+
455
+ /** Open the NTR settings dialog (the empty agent roster's way forward). */
456
+ export function openAcpSettingsDialog(): void {
457
+ if (settingsDialogOpen) return
458
+ settingsDialogOpen = true
459
+ emitSettingsDialog()
460
+ }
461
+
462
+ /** Close the NTR settings dialog (close button, mask click, Escape). */
463
+ export function closeAcpSettingsDialog(): void {
464
+ if (!settingsDialogOpen) return
465
+ settingsDialogOpen = false
466
+ emitSettingsDialog()
467
+ }
468
+
469
+ /** Current dialog state, for tests and non-React readers. */
470
+ export function acpSettingsDialogIsOpen(): boolean {
471
+ return settingsDialogOpen
472
+ }
473
+
474
+ /** Subscribe to the dialog state (the root overlay entry reads it). */
475
+ export function useAcpSettingsDialogOpen(): boolean {
476
+ return useSyncExternalStore(
477
+ cb => {
478
+ settingsDialogListeners.add(cb)
479
+ return () => { settingsDialogListeners.delete(cb) }
480
+ },
481
+ acpSettingsDialogIsOpen,
482
+ )
483
+ }
484
+
437
485
  // ---- Menu presentation ----
438
486
 
439
487
  /** One selectable row of a picker. */
@@ -883,6 +931,26 @@ const STYLE = `
883
931
  .ntr-acp-empty-hint { color: var(--dsw-alias-text-secondary, #6b7280); font-size: 12px; }
884
932
  .ntr-acp-seat-diag { font-family: var(--dsw-alias-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); opacity: 0.72; word-break: break-word; }
885
933
  .ntr-acp-empty-actions { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; }
934
+ /* ---- NTR settings dialog (shell.overlay) ---- */
935
+ /* Frame-wide surface opened from the agent base's empty-roster menu. The
936
+ overlay layer itself is click-through, so the layer opts back in and blocks
937
+ the app while it is up. */
938
+ .ntr-acp-dialog-layer { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; pointer-events: auto; }
939
+ .ntr-acp-dialog-mask { position: absolute; inset: 0; background: var(--dsw-alias-bg-mask-1, rgba(0, 0, 0, 0.4)); }
940
+ .ntr-acp-dialog {
941
+ position: relative; display: flex; flex-direction: column; overflow: hidden;
942
+ width: min(920px, calc(100vw - 48px)); height: min(820px, calc(100vh - 48px));
943
+ background: var(--dsw-alias-bg-layer-2, #fff); color: inherit;
944
+ border-radius: 24px; box-shadow: var(--dsw-elevation-prominent, 0 24px 60px rgba(0, 0, 0, 0.25));
945
+ }
946
+ .ntr-acp-dialog-head { flex: none; display: flex; align-items: center; gap: 8px; padding: 16px 16px 8px 24px; }
947
+ .ntr-acp-dialog-title { flex: 1; font-size: 16px; font-weight: 600; }
948
+ .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; }
949
+ .ntr-acp-dialog-close:hover { background: var(--dsw-alias-interactive-bg-active, #0000000d); }
950
+ .ntr-acp-dialog-body { flex: 1; min-height: 0; overflow: auto; }
951
+ /* The section brings its own page scroll for the settings shell's content
952
+ column; the dialog body is the scroll owner here. */
953
+ .ntr-acp-dialog-body > .dwa-root { max-width: none; overflow: visible; }
886
954
  `
887
955
 
888
956
  let styleMounted = false
@@ -18,6 +18,7 @@ import { AcpSettingsSection } from './panel.tsx'
18
18
  import { AcpComposerMode } from './composer-mode.tsx'
19
19
  import { AcpComposerAgentTag, AcpSessionControls, AcpSessionMenu } from './session-controls.tsx'
20
20
  import { AcpConversationView } from './acp-view.tsx'
21
+ import { AcpSettingsDialog } from './settings-dialog.tsx'
21
22
  import {
22
23
  createModelSeatClaim,
23
24
  createPermissionSeatClaim,
@@ -173,6 +174,19 @@ export function apply(ctx: Context): void {
173
174
  inject: () => ({ acp: store }),
174
175
  }, AcpSettingsSection)), 'dsh-ntr: settings section')
175
176
 
177
+ // The same NTR settings, opened from the composer when the agent roster is
178
+ // empty (the agent base's picker action). It rides the frame's overlay layer
179
+ // because the composer seat and the dialog live in unrelated slot trees; the
180
+ // host settings shell has no programmatic open seam, so the dialog mounts the
181
+ // same section the shell mounts (see settings-dialog.tsx). Renders nothing
182
+ // while closed.
183
+ ctx.effect(() => ctx.slots.inject('shell.overlay', () => ctx.slots.register({
184
+ name: 'shell.overlay',
185
+ id: 'acp-settings-dialog',
186
+ order: 30,
187
+ inject: () => ({ acp: store }),
188
+ }, AcpSettingsDialog as unknown as SlotComponent<any>)), 'dsh-ntr: NTR settings dialog')
189
+
176
190
  // The agent's OWN modes sit in the composer tool row — the seat the slot
177
191
  // contract reserves for exactly this ("entries sit beside that chrome, never
178
192
  // replace it"). They are a different layer from everything the host controls:
@@ -147,16 +147,18 @@ const STYLE = `
147
147
  .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); }
148
148
  .dwa-inline-setting .dwa-switch { flex-shrink: 0; margin-top: 1px; }
149
149
  .dwa-setting-copy { color: var(--ntr-muted); font-size: var(--ntr-font-xs); }
150
- /* Panes stay flush (equal height) whatever the two list lengths are; the
151
- shorter list's list body grows to fill, so its leftover is scrolling room
152
- inside the list rather than a detached block under the last row. */
153
- .dwa-model-transfer { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; align-items: stretch; }
154
- /* Tall enough for a comfortable selection (about ten rows) before scrolling:
155
- both panes share this ceiling, so equal heights cost at most one row of
156
- slack on the shorter side instead of a whole empty half. */
150
+ /* Stacked, not side by side: the rows carry name, id and credit, and a
151
+ half-width pane truncated them at every realistic settings width. Each list
152
+ gets the whole column instead. */
153
+ .dwa-model-transfer { display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; }
154
+ /* Tall enough for a comfortable selection (about ten rows) before scrolling;
155
+ the cap keeps the two stacked panes from pushing the rest of the card away. */
157
156
  .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; }
158
157
  .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); }
159
158
  .dwa-model-pane-head .dwa-btn { margin-left: auto; min-height: 26px; padding: 2px 8px; font-size: var(--ntr-font-xs); }
159
+ .dwa-model-pane-head .dwa-caret-btn { flex: none; width: 22px; height: 22px; background: transparent; }
160
+ .dwa-model-pane-head .dwa-caret-btn:hover { background: var(--dsw-alias-interactive-bg-active, #e5e7eb); }
161
+ .dwa-model-pane.is-collapsed .dwa-model-list { display: none; }
160
162
  .dwa-model-list { display: flex; flex-direction: column; gap: 2px; padding: 5px; flex: 1 1 auto; min-height: 0; overflow-y: auto; }
161
163
  .dwa-model-section { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; font-size: 12px; }
162
164
  .dwa-model-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
@@ -173,7 +175,19 @@ const STYLE = `
173
175
  .dwa-model-row.is-on { background: #3b82f614; color: #2563eb; }
174
176
  .dwa-model-row-mark { width: 12px; flex: none; text-align: center; opacity: 0.75; }
175
177
  .dwa-model-row-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
176
- .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; }
178
+ /* Name and credit keep their room; the id is the only cell that collapses. */
179
+ .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; }
180
+ /* The trailing 14px is reserved for the fade mask: while the id fits, the
181
+ gradient only covers empty padding; once it overflows, its tail dissolves
182
+ instead of ending in a hard ellipsis. armModelIdMarquee reads the same
183
+ padding to scroll the full text under the pointer. */
184
+ .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); }
185
+ .dwa-model-row-value-text { display: inline-block; white-space: nowrap; }
186
+ .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; }
187
+ @keyframes dwa-model-id-marquee {
188
+ from { transform: translateX(0); }
189
+ to { transform: translateX(var(--dwa-id-shift, 0px)); }
190
+ }
177
191
  .dwa-model-row-remove { flex: none; opacity: 0.5; padding: 0 2px; }
178
192
  .dwa-model-row:hover .dwa-model-row-remove { opacity: 1; }
179
193
  .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; }
@@ -190,7 +204,6 @@ const STYLE = `
190
204
  .dwa-page-header { flex-direction: column; }
191
205
  .dwa-page-actions { width: 100%; }
192
206
  .dwa-page-actions .dwa-btn { width: 100%; }
193
- .dwa-model-transfer { grid-template-columns: 1fr; }
194
207
  .dwa-model-toolbar-actions { width: 100%; }
195
208
  .dwa-model-search { flex: 1 1 auto; width: auto; }
196
209
  .dwa-subsection-head, .dwa-preference-row { align-items: flex-start; flex-direction: column; }
@@ -198,6 +211,7 @@ const STYLE = `
198
211
  }
199
212
  @media (prefers-reduced-motion: reduce) {
200
213
  .dwa-root *, .dwa-root *::before, .dwa-root *::after { transition: none !important; }
214
+ .dwa-model-row:hover .dwa-model-row-value-text { animation: none !important; }
201
215
  }
202
216
  `
203
217
 
@@ -1535,6 +1549,23 @@ function RowMenu({
1535
1549
  )
1536
1550
  }
1537
1551
 
1552
+ /**
1553
+ * Measure the id cell on row hover so its CSS marquee can reach the end of the
1554
+ * text. The id is the only cell that yields width, and the panel is resizable,
1555
+ * so the overflow is re-read on every entry rather than cached. The animation
1556
+ * itself lives in CSS and only runs while the row is hovered; `title` on the
1557
+ * row stays the fallback for reduced-motion users.
1558
+ */
1559
+ function armModelIdMarquee(row: HTMLElement): void {
1560
+ const view = row.querySelector<HTMLElement>('.dwa-model-row-value')
1561
+ const text = view?.querySelector<HTMLElement>('.dwa-model-row-value-text')
1562
+ if (view === null || view === undefined || text === null || text === undefined) return
1563
+ const fade = Number.parseFloat(getComputedStyle(view).paddingRight) || 0
1564
+ const shift = Math.max(0, text.offsetWidth - (view.clientWidth - fade))
1565
+ row.style.setProperty('--dwa-id-shift', `${String(-shift)}px`)
1566
+ row.style.setProperty('--dwa-id-duration', `${String(Math.min(4, Math.max(1.2, shift / 30)))}s`)
1567
+ }
1568
+
1538
1569
  /**
1539
1570
  * Model / thought-level catalog display plus the native-selector visibility
1540
1571
  * filter, as a two-pane transfer list: every known model on the left, the
@@ -1597,6 +1628,11 @@ function ModelCatalogBlock({
1597
1628
  const modelChoices = result?.catalog.model?.choices ?? []
1598
1629
  /** Result of the last click on 「检测可用模型」 (what the refresh changed). */
1599
1630
  const [detectNote, setDetectNote] = useState<string | undefined>(undefined)
1631
+ // Each list folds on its own so a long selection can be parked while the
1632
+ // other pane keeps the full column. The list stays mounted (display-toggled)
1633
+ // to keep its scroll position.
1634
+ const [allCollapsed, setAllCollapsed] = useState(false)
1635
+ const [selectedCollapsed, setSelectedCollapsed] = useState(false)
1600
1636
 
1601
1637
  // Display read, not a probe: the host answers from its persisted catalog
1602
1638
  // cache, so the panes already list the known models the first time the card
@@ -1846,7 +1882,7 @@ function ModelCatalogBlock({
1846
1882
  </div>
1847
1883
 
1848
1884
  <div className="dwa-model-transfer">
1849
- <div className="dwa-model-pane">
1885
+ <div className={`dwa-model-pane${allCollapsed ? ' is-collapsed' : ''}`}>
1850
1886
  <div className="dwa-model-pane-head">
1851
1887
  <span>{acp.label('全部模型', 'All models')}</span>
1852
1888
  {result !== undefined && <span className="dwa-badge">{modelChoices.length}</span>}
@@ -1859,6 +1895,15 @@ function ModelCatalogBlock({
1859
1895
  >
1860
1896
  {acp.label('全部加入', 'Add all')}
1861
1897
  </button>
1898
+ <button
1899
+ type="button"
1900
+ className="dwa-caret-btn"
1901
+ aria-expanded={!allCollapsed}
1902
+ onClick={() => setAllCollapsed(value => !value)}
1903
+ title={allCollapsed ? acp.label('展开全部模型', 'Expand all models') : acp.label('收起全部模型', 'Collapse all models')}
1904
+ >
1905
+ <span className="dwa-caret" aria-hidden>{allCollapsed ? '▸' : '▾'}</span>
1906
+ </button>
1862
1907
  </div>
1863
1908
  <div className="dwa-model-list">
1864
1909
  {visibleChoices.map(choice => {
@@ -1876,6 +1921,7 @@ function ModelCatalogBlock({
1876
1921
  className={`dwa-model-row${checked ? ' is-on' : ''}`}
1877
1922
  title={title}
1878
1923
  onClick={() => toggleChoice(choice.value)}
1924
+ onPointerEnter={event => { armModelIdMarquee(event.currentTarget) }}
1879
1925
  >
1880
1926
  <span className="dwa-model-row-mark">{checked ? '✓' : '+'}</span>
1881
1927
  <span className="dwa-model-row-name">
@@ -1883,7 +1929,17 @@ function ModelCatalogBlock({
1883
1929
  {choice.name}
1884
1930
  </span>
1885
1931
  {choice.value !== choice.name && (
1886
- <span className="dwa-model-row-value">{choice.value}</span>
1932
+ <span className="dwa-model-row-value">
1933
+ <span className="dwa-model-row-value-text">{choice.value}</span>
1934
+ </span>
1935
+ )}
1936
+ {/* Credit / capability notes belong on the row itself: the
1937
+ list exists to compare models, and a hover-only price
1938
+ cannot be scanned. Grouped choices reuse the group label
1939
+ as their fallback description — that one is already
1940
+ printed before the name. */}
1941
+ {choice.description !== undefined && choice.description !== choice.group && (
1942
+ <span className="dwa-model-row-desc">{choice.description}</span>
1887
1943
  )}
1888
1944
  </button>
1889
1945
  )
@@ -1892,7 +1948,7 @@ function ModelCatalogBlock({
1892
1948
  </div>
1893
1949
  </div>
1894
1950
 
1895
- <div className="dwa-model-pane">
1951
+ <div className={`dwa-model-pane${selectedCollapsed ? ' is-collapsed' : ''}`}>
1896
1952
  <div className="dwa-model-pane-head">
1897
1953
  <span>{acp.label('已选模型', 'Selected models')}</span>
1898
1954
  <span className="dwa-badge dwa-badge-info">{basis.length}</span>
@@ -1904,6 +1960,15 @@ function ModelCatalogBlock({
1904
1960
  >
1905
1961
  {acp.label('清空', 'Clear')}
1906
1962
  </button>
1963
+ <button
1964
+ type="button"
1965
+ className="dwa-caret-btn"
1966
+ aria-expanded={!selectedCollapsed}
1967
+ onClick={() => setSelectedCollapsed(value => !value)}
1968
+ title={selectedCollapsed ? acp.label('展开已选模型', 'Expand selected models') : acp.label('收起已选模型', 'Collapse selected models')}
1969
+ >
1970
+ <span className="dwa-caret" aria-hidden>{selectedCollapsed ? '▸' : '▾'}</span>
1971
+ </button>
1907
1972
  </div>
1908
1973
  <div className="dwa-model-list">
1909
1974
  {selectedChoices.length === 0 && (
@@ -1918,13 +1983,19 @@ function ModelCatalogBlock({
1918
1983
  className="dwa-model-row is-on"
1919
1984
  title={choice.description === undefined ? choice.value : `${choice.value}\n${choice.description}`}
1920
1985
  onClick={() => toggleChoice(choice.value)}
1986
+ onPointerEnter={event => { armModelIdMarquee(event.currentTarget) }}
1921
1987
  >
1922
1988
  <span className="dwa-model-row-name">
1923
1989
  {choice.group !== undefined && <span style={{ opacity: 0.7 }}>{choice.group} · </span>}
1924
1990
  {choice.name}
1925
1991
  </span>
1926
1992
  {choice.value !== choice.name && (
1927
- <span className="dwa-model-row-value">{choice.value}</span>
1993
+ <span className="dwa-model-row-value">
1994
+ <span className="dwa-model-row-value-text">{choice.value}</span>
1995
+ </span>
1996
+ )}
1997
+ {choice.description !== undefined && choice.description !== choice.group && (
1998
+ <span className="dwa-model-row-desc">{choice.description}</span>
1928
1999
  )}
1929
2000
  <span className="dwa-model-row-remove" aria-hidden="true">×</span>
1930
2001
  </button>
@@ -49,10 +49,12 @@
49
49
  * different slots, so they share that module's phase clock rather than trying
50
50
  * to animate across slot boundaries.
51
51
  *
52
- * It is deliberately quiet: the base renders NOTHING for a conversation no ACP
53
- * agent has served, so an ordinary chat is untouched even before the gate
54
- * stands down. The ACP view tab is the entry point for those (and for re-entry
55
- * after a detour through another provider).
52
+ * It is deliberately quiet: outside ACP mode the gate never mounts it, and
53
+ * before the host has answered the agent roster it renders nothing. Once the
54
+ * roster is KNOWN to be empty the base stays and its picker carries the
55
+ * "open NTR settings" action — an NTR conversation with nothing configured
56
+ * still has a way forward instead of an empty cell. The ACP view tab remains
57
+ * the entry point for conversations no agent has served.
56
58
  */
57
59
 
58
60
  import { useEffect, useState } from 'react'
@@ -61,7 +63,7 @@ import type { CSSProperties } from 'react'
61
63
  import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
62
64
  import {
63
65
  AcpChip, AcpMenu, AcpShatterParts, buildAcpModelId, closeAcpMenu, ensureAcpStyle,
64
- openAcpMenu, setAcpMenuError, splitAcpModelId, useAcpMenu, useUi,
66
+ openAcpMenu, openAcpSettingsDialog, setAcpMenuError, splitAcpModelId, useAcpMenu, useUi,
65
67
  } from './acp-ui.tsx'
66
68
  import { playAgentLockAnim, useAgentLockPhase } from './agent-lock-anim.ts'
67
69
  import type {
@@ -150,6 +152,23 @@ function routeAgentId(
150
152
  return undefined
151
153
  }
152
154
 
155
+ /**
156
+ * Whether the agent base has anything honest to show.
157
+ *
158
+ * An empty roster has two meanings, and they must not be conflated: before the
159
+ * host answers `agents/list` it means "not read yet" (rendering a setup entry
160
+ * there would accuse a correctly configured deployment on every page load),
161
+ * while after the answer it means "nothing is configured" — exactly the state
162
+ * that must keep the base and offer the way into NTR settings.
163
+ */
164
+ export function agentBaseHasData(
165
+ bound: ChatSessionView | undefined,
166
+ agents: readonly AgentView[],
167
+ rosterLoaded: boolean,
168
+ ): boolean {
169
+ return bound !== undefined || agents.length > 0 || rosterLoaded
170
+ }
171
+
153
172
  /**
154
173
  * The agent this conversation is LOCKED to, if its first ACP turn has run.
155
174
  *
@@ -374,21 +393,29 @@ export function advancedOptions(
374
393
  * The tooltip claims only what the menu behind the chip does: switch the agent.
375
394
  * The agent's models and thought levels have chips of their own (user decision,
376
395
  * 2026-10-17), so the previous wording — "switch the agent, its model and the
377
- * thought level" — promised a menu that holds the roster alone.
396
+ * thought level" — promised a menu that holds the roster alone. An EMPTY
397
+ * roster is its own case: the menu cannot switch to anything, it offers the
398
+ * way into NTR settings, so the tooltip says that instead of promising a pick.
378
399
  */
379
400
  export function agentBaseChip(
380
401
  route: RouteInfo,
381
402
  routed: boolean,
382
403
  acp: AcpChatInjected['acp'],
404
+ rosterEmpty = false,
383
405
  ): { label: string; title: string } {
384
406
  const label = route.agentTitle === undefined
385
407
  ? (routed ? 'NTR' : acp.label('选择 Agent', 'Choose agent'))
386
408
  : `NTR · ${route.agentTitle}`
387
409
  const title = route.agentId === undefined
388
- ? acp.label(
389
- '这条会话当前没有 NTR agent 服务;点开可选择 agent',
390
- 'No NTR agent is serving this conversation right now — open to choose one',
391
- )
410
+ ? rosterEmpty
411
+ ? acp.label(
412
+ '这条会话当前没有 NTR agent;点开可前往 NTR 设置添加',
413
+ 'No NTR agent is serving this conversation — open to set one up in NTR settings',
414
+ )
415
+ : acp.label(
416
+ '这条会话当前没有 NTR agent 服务;点开可选择 agent',
417
+ 'No NTR agent is serving this conversation right now — open to choose one',
418
+ )
392
419
  : acp.label(
393
420
  '这条会话由外接 NTR agent 服务;点开可切换 agent',
394
421
  'This conversation is served by an external NTR agent — open to switch the agent',
@@ -530,9 +557,12 @@ export function AcpSessionControls(props: AcpClusterProps) {
530
557
  lockedTo ?? ui.chatBoundAgent[sessionId],
531
558
  choice,
532
559
  )
533
- // A conversation with no ACP story to tell gets no chrome: the ACP view tab
534
- // owns the entry point for starting one.
535
- if (bound === undefined && agents.length === 0) return null
560
+ // An empty roster is the SETUP case, not "no story": the base stays and its
561
+ // picker carries the way into NTR settings. Only the window before the host
562
+ // has answered `agents/list` stays blank — `agents: []` there means "not read
563
+ // yet", and the setup entry would accuse a configured deployment (see
564
+ // `agentBaseHasData`).
565
+ if (!agentBaseHasData(bound, agents, ui.agentsLoaded)) return null
536
566
  // Set up = an agent serves this conversation: a live session, a persisted
537
567
  // pick, the host's optimistic binding, OR the plugin default the guard's
538
568
  // route rewrite falls back to. The last one is what makes a brand-new
@@ -613,7 +643,7 @@ export function AcpSessionControls(props: AcpClusterProps) {
613
643
  itself is the shatter played here (see `shattering`). */}
614
644
  {showAgentBase && (
615
645
  <AcpChip
616
- {...agentBaseChip(route, routed, acp)}
646
+ {...agentBaseChip(route, routed, acp, agents.length === 0)}
617
647
  active={routed}
618
648
  className={shattering ? 'ntr-acp-shatter' : undefined}
619
649
  onClick={shattering ? undefined : (trigger) => { open('agent', trigger) }}
@@ -648,6 +678,14 @@ export interface MenuContext {
648
678
  */
649
679
  export const REFRESH_CATALOG_ITEM = '__refresh-catalog__'
650
680
 
681
+ /**
682
+ * The empty roster's setup action. Like the model picker's refresh row it is an
683
+ * ACTION inside the picker, not an agent: agent ids match
684
+ * `[a-z0-9][a-z0-9._-]{0,63}` (no leading underscore), so no real id can
685
+ * collide with it.
686
+ */
687
+ export const OPEN_SETTINGS_ITEM = '__open-settings__'
688
+
651
689
  /** The agent roster rows: one row per configured ACP agent, current one marked. */
652
690
  function agentSection(context: MenuContext): AcpMenuSection {
653
691
  const { acp, route } = context
@@ -659,12 +697,21 @@ function agentSection(context: MenuContext): AcpMenuSection {
659
697
  active: route.agentId === agent.id,
660
698
  }))
661
699
  if (items.length === 0) {
700
+ // The base stays on an empty roster (see `agentBaseHasData`), so this menu
701
+ // is the setup surface: a status row that says what is missing, then an
702
+ // ACTION row that opens the NTR settings. The action is a row of its own
703
+ // because a disabled status row cannot be the way forward.
662
704
  items.push({
663
705
  id: 'none',
664
706
  label: acp.label('还没有配置 NTR agent', 'No NTR agent configured yet'),
665
- detail: acp.label('设置 → NTR', 'Settings → NTR'),
707
+ detail: acp.label('添加并启用一个 agent 后即可开始对话', 'Add and enable an agent to start'),
666
708
  disabled: true,
667
709
  })
710
+ items.push({
711
+ id: OPEN_SETTINGS_ITEM,
712
+ label: acp.label('打开 NTR 设置', 'Open NTR settings'),
713
+ detail: acp.label('添加或启用 agent', 'Add or enable an agent'),
714
+ })
668
715
  }
669
716
  return {
670
717
  id: 'agent',
@@ -987,6 +1034,15 @@ export function AcpSessionMenu(props: AcpClusterProps) {
987
1034
  if (agentId !== undefined) await acp.probeModels(agentId)
988
1035
  return
989
1036
  }
1037
+ if (sectionId === 'agent' && itemId === OPEN_SETTINGS_ITEM) {
1038
+ // The empty roster's way out (see `agentSection`): there is no agent to
1039
+ // bind, so this row opens the NTR settings surface instead. Close the
1040
+ // picker first — the dialog is a frame overlay and the menu must not
1041
+ // linger behind it.
1042
+ closeAcpMenu()
1043
+ openAcpSettingsDialog()
1044
+ return
1045
+ }
990
1046
  if (sectionId === 'agent') {
991
1047
  await acp.bindChatAgent(sessionId, itemId)
992
1048
  } else if (sectionId === 'model') {