dsh-oauth-login 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/INSTALL.md +59 -0
  2. package/INSTALL.zh.md +45 -0
  3. package/LICENSE +201 -0
  4. package/README.en.md +104 -0
  5. package/README.md +104 -0
  6. package/README.zh.md +3 -0
  7. package/SECURITY.md +33 -0
  8. package/cordis.patch.yml +5 -0
  9. package/docs/network-proxy.md +45 -0
  10. package/docs/openrouter-sync.md +84 -0
  11. package/docs/screenshots/01-providers-signed-out.png +0 -0
  12. package/docs/screenshots/03-copilot-signing-in.png +0 -0
  13. package/docs/screenshots/04-mixed-live-states.png +0 -0
  14. package/docs/screenshots/05-copilot-sign-in.gif +0 -0
  15. package/docs/screenshots/06-cli-status-store.png +0 -0
  16. package/lib/bin.d.ts +5 -0
  17. package/lib/bin.js +2132 -0
  18. package/lib/client.js +1915 -0
  19. package/lib/client.js.map +1 -0
  20. package/lib/index.d.ts +482 -0
  21. package/lib/index.js +3105 -0
  22. package/lib/invariant.d.ts +7 -0
  23. package/lib/invariant.js +8 -0
  24. package/package.json +180 -0
  25. package/src/adapter.ts +207 -0
  26. package/src/auth-routes.ts +654 -0
  27. package/src/auth.ts +76 -0
  28. package/src/bin.ts +185 -0
  29. package/src/catalog.ts +129 -0
  30. package/src/client/OpenRouterSyncStatus.tsx +66 -0
  31. package/src/client/PiLoginSettings.tsx +470 -0
  32. package/src/client/ProxySettings.tsx +123 -0
  33. package/src/client/SearchableModelSelect.tsx +595 -0
  34. package/src/client/draft-input.ts +12 -0
  35. package/src/client/index.tsx +71 -0
  36. package/src/client/locales.ts +176 -0
  37. package/src/client/login-input-copy.ts +34 -0
  38. package/src/client/login-window.ts +24 -0
  39. package/src/client/model-filter.ts +45 -0
  40. package/src/client/openrouter-store.ts +122 -0
  41. package/src/client/proxy-draft.ts +57 -0
  42. package/src/extra-models.ts +201 -0
  43. package/src/hosted-capture.ts +41 -0
  44. package/src/hosted-images.ts +154 -0
  45. package/src/http.ts +241 -0
  46. package/src/ids.ts +17 -0
  47. package/src/index.ts +183 -0
  48. package/src/invariant.ts +12 -0
  49. package/src/model-error-hint.ts +98 -0
  50. package/src/native-tools.ts +442 -0
  51. package/src/oauth-refresh.ts +49 -0
  52. package/src/openrouter-catalog.ts +245 -0
  53. package/src/openrouter-free.ts +76 -0
  54. package/src/openrouter-models.ts +155 -0
  55. package/src/openrouter-types.ts +40 -0
  56. package/src/plugin-config.ts +30 -0
  57. package/src/provider.ts +137 -0
  58. package/src/proxy-config.ts +61 -0
  59. package/src/proxy-store.ts +45 -0
  60. package/src/proxy-transport.ts +220 -0
  61. package/src/redact.ts +22 -0
  62. package/src/responses-tap.ts +97 -0
  63. package/src/session.ts +104 -0
  64. package/src/store.ts +234 -0
@@ -0,0 +1,595 @@
1
+ /**
2
+ * SearchableModelSelect: plugin-owned shadow of the composer model seat
3
+ * (`conversation.input.model`, registered at priority -1 so it renders over
4
+ * the shipped ModelSelect). Behavior mirrors the official two-level menu —
5
+ * root rows drill into the model list and the effort list over the SAME
6
+ * shared per-session directory — and adds one thing the shipped seat lacks:
7
+ * a local search box on the model pane that filters provider groups by model
8
+ * name/id or provider name while typing. Selection still submits through the
9
+ * injected `select` face, so the host stays the single fact source.
10
+ */
11
+ import {
12
+ useEffect, useId, useMemo, useRef, useState, useSyncExternalStore,
13
+ type KeyboardEvent, type FocusEvent,
14
+ } from 'react'
15
+ import type { ModelSelection } from '@deepseek-ai/dsh-api-session-controller/types'
16
+ import type { ModelDirectoryState as DirectoryState, ModelSelectInjected } from '@deepseek-ai/dsh-client-ui-model-selection/client'
17
+ import type { InjectFace, PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
18
+ import { filterGroups } from './model-filter.ts'
19
+ import type { FilterGroup } from './model-filter.ts'
20
+ import type { OpenRouterCatalogClient } from './openrouter-store.ts'
21
+ import { OpenRouterSyncStatus } from './OpenRouterSyncStatus.tsx'
22
+ import { OPENROUTER_ROUTE } from '../openrouter-types.ts'
23
+
24
+ /** Which pane the dropdown shows. */
25
+ type Pane = 'root' | 'model' | 'effort'
26
+
27
+ /** One effort row; undefined effort preserves the provider default. */
28
+ interface EffortChoice {
29
+ key: string
30
+ effort: string | undefined
31
+ label: string
32
+ description?: string
33
+ }
34
+
35
+ /** Component props: owner share + injected face + both translators. */
36
+ export interface SearchableModelSelectInjected extends ModelSelectInjected {
37
+ catalog: OpenRouterCatalogClient
38
+ /** Plugin `model-search` namespace translate (search box copy). */
39
+ ts: TranslateNS<'model-search'>
40
+ }
41
+
42
+ export type SearchableModelSelectProps = PropsRuntime<'conversation.input.model'>
43
+ & PropsLocale<'model'>
44
+ & InjectFace<SearchableModelSelectInjected>
45
+
46
+ const STYLE_ID = 'dsh-oauth-model-search-theme'
47
+
48
+ /** Theme-token styles mirroring the shipped seat's menu vocabulary. */
49
+ const SEARCH_CSS = `
50
+ .dsh-oauth-ms-root { position:relative; min-width:0; }
51
+ .dsh-oauth-ms-trigger {
52
+ display:flex; align-items:center; gap:4px; min-width:0;
53
+ max-width:min(360px,45cqw); height:28px; padding:0 4px 0 8px;
54
+ border:none; border-radius:24px; outline:none; background:transparent;
55
+ color:var(--dsw-alias-label-secondary); font-size:13px; line-height:20px;
56
+ font-weight:500; cursor:pointer;
57
+ }
58
+ .dsh-oauth-ms-trigger:hover:not(:disabled) { background:var(--dsw-alias-interactive-bg-hover); }
59
+ .dsh-oauth-ms-trigger:focus-visible { box-shadow:0 0 0 2px var(--dsw-alias-border-l3); }
60
+ .dsh-oauth-ms-trigger:disabled { color:var(--dsw-alias-label-dimmed); cursor:default; }
61
+ .dsh-oauth-ms-triggerLabel { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
62
+ .dsh-oauth-ms-triggerEffort { flex:0 0 auto; color:var(--dsw-alias-label-caption); }
63
+ .dsh-oauth-ms-chevron { flex:0 0 auto; color:var(--dsw-alias-label-caption); transition:transform 120ms ease; }
64
+ .dsh-oauth-ms-chevronOpen { transform:rotate(180deg); }
65
+ @media (prefers-reduced-motion: reduce) { .dsh-oauth-ms-chevron { transition:none; } }
66
+ .dsh-oauth-ms-menu {
67
+ position:absolute; right:0; bottom:calc(100% + 8px); z-index:20;
68
+ display:flex; flex-direction:column;
69
+ width:max-content; min-width:min(240px,calc(100vw - 32px));
70
+ max-width:min(420px,calc(100vw - 32px));
71
+ max-height:min(360px,calc(100vh - 96px)); overflow:hidden; padding:4px;
72
+ border:1px solid var(--dsw-alias-border-inverted); border-radius:12px;
73
+ background:var(--dsw-specific-menu); box-shadow:var(--dsw-shadow-lv3);
74
+ color:var(--dsw-alias-label-primary);
75
+ --dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);
76
+ --dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2);
77
+ }
78
+ .dsh-oauth-ms-status,.dsh-oauth-ms-empty { padding:10px; color:var(--dsw-alias-label-tertiary); font-size:13px; line-height:20px; }
79
+ .dsh-oauth-ms-errorStrip {
80
+ display:flex; align-items:flex-start; justify-content:space-between; gap:8px;
81
+ margin-bottom:4px; padding:7px 8px; border-radius:8px;
82
+ background:var(--dsw-alias-interactive-bg-hover-danger);
83
+ color:var(--dsw-alias-state-error-primary); font-size:12px; line-height:18px;
84
+ }
85
+ .dsh-oauth-ms-warning {
86
+ display:flex; align-items:flex-start; justify-content:space-between; gap:8px;
87
+ margin-bottom:4px; padding:7px 8px; border-radius:8px;
88
+ background:var(--dsw-alias-bg-module-platform);
89
+ color:var(--dsw-alias-state-warn-label); font-size:12px; line-height:18px;
90
+ }
91
+ .dsh-oauth-ms-retry { flex:0 0 auto; padding:0; border:none; background:transparent; color:inherit; font:inherit; font-weight:600; cursor:pointer; }
92
+ .dsh-oauth-ms-search {
93
+ margin:2px 2px 4px; padding:6px 8px; border:1px solid var(--dsw-alias-border-inverted);
94
+ border-radius:8px; background:transparent; font-size:13px;
95
+ color:var(--dsw-alias-label-primary); outline:none;
96
+ }
97
+ .dsh-oauth-ms-search:focus-visible { box-shadow:0 0 0 2px var(--dsw-alias-border-l3); }
98
+ .dsh-oauth-ms-groups { min-height:0; overflow-y:auto; }
99
+ .dsh-oauth-ms-group + .dsh-oauth-ms-group { margin-top:4px; }
100
+ .dsh-oauth-ms-groupTitle {
101
+ position:sticky; top:0; z-index:1; padding:5px 8px 3px;
102
+ background:var(--dsw-specific-menu); color:var(--dsw-alias-label-tertiary);
103
+ font-size:12px; line-height:18px; font-weight:500;
104
+ }
105
+ .dsh-oauth-ms-option {
106
+ box-sizing:border-box; display:flex; align-items:center; gap:8px;
107
+ width:auto; min-width:100%; min-height:38px; padding:6px 8px;
108
+ border:none; border-radius:10px; outline:none; background:transparent;
109
+ color:inherit; text-align:left; cursor:pointer;
110
+ }
111
+ .dsh-oauth-ms-option:hover:not(:disabled),.dsh-oauth-ms-option:focus-visible { background:var(--dsw-alias-interactive-bg-hover); }
112
+ .dsh-oauth-ms-option:disabled { color:var(--dsw-alias-label-dimmed); cursor:default; }
113
+ .dsh-oauth-ms-optionCopy { display:flex; flex:1; flex-direction:column; min-width:0; }
114
+ .dsh-oauth-ms-modelName { overflow:hidden; color:inherit; font-size:14px; line-height:20px; font-weight:500; text-overflow:ellipsis; white-space:nowrap; }
115
+ .dsh-oauth-ms-description { overflow:hidden; color:var(--dsw-alias-label-tertiary); font-size:12px; line-height:18px; text-overflow:ellipsis; white-space:nowrap; }
116
+ .dsh-oauth-ms-check { display:grid; place-items:center; flex:0 0 18px; color:var(--dsw-alias-label-primary); }
117
+ .dsh-oauth-ms-cell {
118
+ box-sizing:border-box; display:flex; align-items:center; gap:8px;
119
+ width:auto; min-width:100%; height:40px; padding:0 10px;
120
+ border:none; border-radius:10px; background:transparent;
121
+ color:var(--dsw-alias-label-primary); font-size:14px; line-height:22px;
122
+ cursor:pointer; text-align:left;
123
+ }
124
+ .dsh-oauth-ms-cell:hover { background:var(--dsw-alias-interactive-bg-hover); }
125
+ .dsh-oauth-ms-cellLabel { flex:0 0 auto; white-space:nowrap; }
126
+ .dsh-oauth-ms-cellValue { flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:right; color:var(--dsw-alias-label-tertiary); }
127
+ .dsh-oauth-ms-cellChevron { flex:0 0 auto; color:var(--dsw-alias-label-tertiary); }
128
+ .dsh-oauth-ms-filters { display:flex;align-items:center;flex-wrap:wrap;gap:6px;margin:2px 4px 4px; }
129
+ .dsh-oauth-ms-filter { min-height:28px;padding:3px 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:16px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;cursor:pointer; }
130
+ .dsh-oauth-ms-filter[aria-pressed="true"] { background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3); }
131
+ .dsh-oauth-ms-filter:focus-visible { outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px; }
132
+ .dsh-oauth-ms-meta { display:flex;align-items:center;flex-wrap:wrap;gap:4px 8px;margin-top:3px;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px; }
133
+ .dsh-oauth-ms-free { padding:1px 6px;border-radius:4px;color:color-mix(in srgb,var(--dsw-alias-state-success-primary) 50%,var(--dsw-alias-label-primary));background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 10%,transparent); }
134
+ .dsh-oauth-ms-cached { color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover); }
135
+ .dsh-oauth-ms-ended { color:color-mix(in srgb,var(--dsw-alias-state-warn-label) 50%,var(--dsw-alias-label-primary));white-space:normal; }
136
+ .dsh-oauth-ms-help { padding:4px 8px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary);white-space:normal; }
137
+ @media (max-width:480px) { .dsh-oauth-ms-menu { width:calc(100vw - 32px);max-height:min(520px,calc(100dvh - 120px)); } .dsh-oauth-ms-option { min-height:44px; } }
138
+ `
139
+
140
+ function ensureStyles(): void {
141
+ if (typeof document === 'undefined') return
142
+ const existing = document.getElementById(STYLE_ID)
143
+ if (existing !== null) {
144
+ existing.textContent = SEARCH_CSS
145
+ return
146
+ }
147
+ const style = document.createElement('style')
148
+ style.id = STYLE_ID
149
+ style.textContent = SEARCH_CSS
150
+ document.head.appendChild(style)
151
+ }
152
+
153
+ /** Small inline chevron-down glyph (avoids importing host primitives). */
154
+ function Chevron({ className }: { className: string }) {
155
+ return (
156
+ <svg className={className} width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
157
+ <path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
158
+ </svg>
159
+ )
160
+ }
161
+
162
+ /** Small inline chevron-right glyph. */
163
+ function ChevronRight({ className }: { className: string }) {
164
+ return (
165
+ <svg className={className} width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
166
+ <path d="M6 4l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
167
+ </svg>
168
+ )
169
+ }
170
+
171
+ /**
172
+ * Render the searchable composer model seat.
173
+ * @param props - see {@link SearchableModelSelectProps}.
174
+ * @returns the trigger and, while open, the search-enabled two-level menu.
175
+ */
176
+ export function SearchableModelSelect(
177
+ { locked, available, directory, catalog, load, select, t, ts }: SearchableModelSelectProps,
178
+ ) {
179
+ const state = useSyncExternalStore(
180
+ fn => directory.subscribe(fn),
181
+ () => directory.getSnapshot(),
182
+ ) as DirectoryState
183
+ const catalogState = useSyncExternalStore(catalog.subscribe, catalog.getSnapshot)
184
+ const metadata = useMemo(() => new Map(catalogState.data?.models.map(model => [model.id, model]) ?? []), [catalogState.data])
185
+ const freeModels = useMemo(() => new Set([...metadata.values()].filter(model => model.priceStatus === 'free').map(model => model.id)), [metadata])
186
+ const [freeOnly, setFreeOnly] = useState(false)
187
+ const [open, setOpen] = useState(false)
188
+ const [pane, setPane] = useState<Pane>('root')
189
+ const [query, setQuery] = useState('')
190
+ const lastActionRef = useRef<'load' | 'select'>('load')
191
+ const [notice, setNotice] = useState<{ seq: number; text: string } | null>(null)
192
+ const noticeSeq = useRef(0)
193
+ const noticeTimer = useRef<number | undefined>(undefined)
194
+ const rootRef = useRef<HTMLDivElement | null>(null)
195
+ const triggerRef = useRef<HTMLButtonElement | null>(null)
196
+ const searchRef = useRef<HTMLInputElement | null>(null)
197
+ const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
198
+ const id = useId()
199
+
200
+ useEffect(() => { ensureStyles() }, [])
201
+ useEffect(() => {
202
+ if (open && pane === 'model') searchRef.current?.focus()
203
+ }, [open, pane])
204
+ useEffect(() => {
205
+ if (catalogState.data?.connected === false) setFreeOnly(false)
206
+ }, [catalogState.data?.connected])
207
+ useEffect(() => {
208
+ if (available && catalogState.data?.lastUpdatedAt != null) load()
209
+ }, [available, catalogState.data?.lastUpdatedAt, load])
210
+ useEffect(() => () => { window.clearTimeout(noticeTimer.current) }, [])
211
+
212
+ const choices = useMemo(() => state.groups.flatMap(group =>
213
+ group.models.map(model => ({
214
+ group,
215
+ model,
216
+ selection: {
217
+ provider: group.id,
218
+ model: model.id,
219
+ ...model.reasoning?.defaultEffort === undefined
220
+ ? {}
221
+ : { reasoningEffort: model.reasoning.defaultEffort },
222
+ } satisfies ModelSelection,
223
+ }))), [state.groups])
224
+ const current = state.current
225
+ const selectedIndex = current === null
226
+ ? -1
227
+ : choices.findIndex(c => c.selection.provider === current.provider && c.selection.model === current.model)
228
+ const currentChoice = choices[selectedIndex]
229
+ const reasoning = currentChoice?.model.reasoning
230
+ const effectiveEffort = state.current?.reasoningEffort ?? reasoning?.defaultEffort
231
+ const effortLabel = reasoning === undefined
232
+ ? undefined
233
+ : effectiveEffort === undefined
234
+ ? t('effort.providerDefault')
235
+ : reasoning.efforts.find(level => level.id === effectiveEffort)?.name ?? effectiveEffort
236
+ const effortChoices = useMemo<readonly EffortChoice[]>(() => reasoning === undefined
237
+ ? []
238
+ : [
239
+ ...reasoning.defaultEffort === undefined
240
+ ? [{ key: 'provider-default', effort: undefined, label: t('effort.providerDefault') }]
241
+ : [],
242
+ ...reasoning.efforts.map(effort => ({
243
+ key: `effort:${effort.id}`,
244
+ effort: effort.id,
245
+ label: effort.name,
246
+ ...effort.description === undefined ? {} : { description: effort.description },
247
+ })),
248
+ ], [reasoning, t])
249
+ const busy = state.status === 'selecting'
250
+
251
+ const filteredGroups = useMemo<readonly FilterGroup[]>(
252
+ () => filterGroups(state.groups, query, freeOnly ? freeModels : undefined),
253
+ [state.groups, query, freeOnly, freeModels])
254
+ const filteredCount = useMemo(
255
+ () => filteredGroups.reduce((sum, group) => sum + group.models.length, 0),
256
+ [filteredGroups])
257
+
258
+ const reload = (): void => {
259
+ lastActionRef.current = 'load'
260
+ load()
261
+ void catalog.load()
262
+ }
263
+
264
+ // Mount-time load resolves the trigger label; every open refreshes.
265
+ useEffect(() => {
266
+ if (available) {
267
+ lastActionRef.current = 'load'
268
+ load()
269
+ }
270
+ }, [available, load])
271
+
272
+ useEffect(() => {
273
+ if (!open) return
274
+ const closeOutside = (event: MouseEvent): void => {
275
+ if (!rootRef.current?.contains(event.target as Node)) setOpen(false)
276
+ }
277
+ document.addEventListener('mousedown', closeOutside)
278
+ return () => { document.removeEventListener('mousedown', closeOutside) }
279
+ }, [open])
280
+
281
+ if (!available) return null
282
+
283
+ const show = (): void => {
284
+ setPane('root')
285
+ setQuery('')
286
+ setNotice(null)
287
+ setOpen(true)
288
+ reload()
289
+ }
290
+
291
+ const close = (restoreFocus = false): void => {
292
+ setOpen(false)
293
+ setPane('root')
294
+ setQuery('')
295
+ if (restoreFocus) queueMicrotask(() => { triggerRef.current?.focus() })
296
+ }
297
+
298
+ const moveFocus = (offset: number): void => {
299
+ const items = itemRefs.current.filter(item => item !== null && !item.disabled)
300
+ if (items.length === 0) return
301
+ const active = items.findIndex(item => item === document.activeElement)
302
+ const next = (Math.max(active, 0) + offset + items.length) % items.length
303
+ items[next]?.focus()
304
+ }
305
+
306
+ const onRootKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
307
+ if (event.key === 'Escape' && open) {
308
+ event.preventDefault()
309
+ if (pane !== 'root') setPane('root')
310
+ else close(true)
311
+ return
312
+ }
313
+ if (!open) return
314
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
315
+ event.preventDefault()
316
+ moveFocus(event.key === 'ArrowDown' ? 1 : -1)
317
+ }
318
+ }
319
+
320
+ const onBlur = (event: FocusEvent<HTMLDivElement>): void => {
321
+ if (event.relatedTarget instanceof Node && rootRef.current?.contains(event.relatedTarget)) return
322
+ close()
323
+ }
324
+
325
+ const settleSelection = (accepted: boolean): void => {
326
+ if (accepted) {
327
+ if (rootRef.current !== null) close(true)
328
+ return
329
+ }
330
+ const message = directory.getSnapshot().error
331
+ if (message !== null) {
332
+ noticeSeq.current += 1
333
+ window.clearTimeout(noticeTimer.current)
334
+ setNotice({ seq: noticeSeq.current, text: t('error.action', { message }) })
335
+ noticeTimer.current = window.setTimeout(() => { setNotice(null) }, 4000)
336
+ }
337
+ }
338
+
339
+ const choose = (selection: ModelSelection): void => {
340
+ const info = selection.provider === OPENROUTER_ROUTE ? metadata.get(selection.model) : undefined
341
+ if (info?.freeOnly && info.priceStatus !== 'free') return
342
+ const current = state.current
343
+ if (current?.provider === selection.provider && current.model === selection.model) {
344
+ close(true)
345
+ return
346
+ }
347
+ lastActionRef.current = 'select'
348
+ void select(selection).then(settleSelection)
349
+ }
350
+
351
+ const chooseEffort = (effort: string | undefined): void => {
352
+ if (state.current === null) return
353
+ if (effectiveEffort === effort) {
354
+ close(true)
355
+ return
356
+ }
357
+ const selection: ModelSelection = {
358
+ provider: state.current.provider,
359
+ model: state.current.model,
360
+ ...effort === undefined ? {} : { reasoningEffort: effort },
361
+ }
362
+ lastActionRef.current = 'select'
363
+ void select(selection).then(settleSelection)
364
+ }
365
+
366
+ /** Search-input keyboard: ↓ enters the list, Enter picks the first match, Escape clears then backs out. */
367
+ const onSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
368
+ if (event.key === 'ArrowDown') {
369
+ event.preventDefault()
370
+ event.stopPropagation()
371
+ itemRefs.current.find(item => item !== null)?.focus()
372
+ return
373
+ }
374
+ if (event.key === 'Enter') {
375
+ event.preventDefault()
376
+ const first = filteredGroups[0]?.models[0]
377
+ if (first !== undefined) choose({ provider: filteredGroups[0].id, model: first.id })
378
+ return
379
+ }
380
+ if (event.key === 'Escape') {
381
+ event.stopPropagation()
382
+ if (query !== '') setQuery('')
383
+ // Non-Escape propagation lets the root handler back out of the pane.
384
+ }
385
+ }
386
+
387
+ const modelLabel = currentChoice?.model.name ?? state.current?.model ?? t('trigger.fallback')
388
+ const triggerLabel = effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}`
389
+ const triggerAria = currentChoice === undefined
390
+ ? t('trigger.selectAria')
391
+ : effortLabel === undefined
392
+ ? t('trigger.aria', { model: modelLabel })
393
+ : t('trigger.ariaEffort', { model: modelLabel, effort: effortLabel })
394
+ itemRefs.current = []
395
+ let itemIndex = 0
396
+ const itemRef = () => {
397
+ const at = itemIndex++
398
+ return (node: HTMLButtonElement | null) => { itemRefs.current[at] = node }
399
+ }
400
+
401
+ return (
402
+ <div ref={rootRef} className="dsh-oauth-ms-root" onKeyDown={onRootKeyDown} onBlur={onBlur}>
403
+ <button
404
+ ref={triggerRef}
405
+ type="button"
406
+ className="dsh-oauth-ms-trigger"
407
+ aria-label={triggerAria}
408
+ aria-haspopup="menu"
409
+ aria-expanded={open}
410
+ aria-controls={open ? `${id}-menu` : undefined}
411
+ title={triggerLabel}
412
+ disabled={locked}
413
+ onClick={() => {
414
+ if (open) close()
415
+ else show()
416
+ }}
417
+ >
418
+ <span className="dsh-oauth-ms-triggerLabel">{modelLabel}</span>
419
+ {effortLabel !== undefined && <span className="dsh-oauth-ms-triggerEffort">{effortLabel}</span>}
420
+ <Chevron className={`dsh-oauth-ms-chevron${open ? ' dsh-oauth-ms-chevronOpen' : ''}`} />
421
+ </button>
422
+
423
+ {open && (
424
+ <div
425
+ id={`${id}-menu`}
426
+ className="dsh-oauth-ms-menu"
427
+ role="menu"
428
+ aria-label={t('menu.aria')}
429
+ aria-busy={state.status === 'loading' || busy}
430
+ >
431
+ {pane === 'root' && (
432
+ <>
433
+ <button ref={itemRef()} type="button" role="menuitem" className="dsh-oauth-ms-cell" onClick={() => { setPane('model') }}>
434
+ <span className="dsh-oauth-ms-cellLabel">{t('menu.model')}</span>
435
+ <span className="dsh-oauth-ms-cellValue">{modelLabel}</span>
436
+ <ChevronRight className="dsh-oauth-ms-cellChevron" />
437
+ </button>
438
+ {reasoning !== undefined && (
439
+ <button ref={itemRef()} type="button" role="menuitem" className="dsh-oauth-ms-cell" onClick={() => { setPane('effort') }}>
440
+ <span className="dsh-oauth-ms-cellLabel">{t('menu.effort')}</span>
441
+ <span className="dsh-oauth-ms-cellValue">{effortLabel}</span>
442
+ <ChevronRight className="dsh-oauth-ms-cellChevron" />
443
+ </button>
444
+ )}
445
+ </>
446
+ )}
447
+
448
+ {pane === 'model' && (
449
+ <>
450
+ <input
451
+ ref={searchRef}
452
+ type="text"
453
+ className="dsh-oauth-ms-search"
454
+ placeholder={ts('placeholder')}
455
+ aria-label={ts('aria')}
456
+ value={query}
457
+ autoComplete="off"
458
+ spellCheck={false}
459
+ onChange={event => { setQuery(event.target.value) }}
460
+ onKeyDown={onSearchKeyDown}
461
+ />
462
+ {(catalogState.data?.connected || state.groups.some(group => group.id === OPENROUTER_ROUTE)) && <>
463
+ <div className="dsh-oauth-ms-filters" role="group" aria-label={ts('freeFilterAria')}>
464
+ <button type="button" className="dsh-oauth-ms-filter" aria-pressed={!freeOnly} onClick={() => { setFreeOnly(false) }}>{ts('all')}</button>
465
+ <button type="button" className="dsh-oauth-ms-filter" aria-pressed={freeOnly} onClick={() => { setFreeOnly(true) }}>{ts('freeOnly')} · {freeModels.size}</button>
466
+ </div>
467
+ <OpenRouterSyncStatus catalog={catalog} ts={ts} />
468
+ </>}
469
+ {state.status === 'loading' && <div className="dsh-oauth-ms-status">{t('status.loading')}</div>}
470
+ {state.error !== null && lastActionRef.current === 'load' && (
471
+ <div className="dsh-oauth-ms-errorStrip">
472
+ <span>{t('error.action', { message: state.error })}</span>
473
+ <button type="button" className="dsh-oauth-ms-retry" onClick={reload}>{t('retry')}</button>
474
+ </div>
475
+ )}
476
+ {state.failures.map(failure => (
477
+ <div className="dsh-oauth-ms-warning" key={failure.id}>
478
+ <span>{t('warning.groupLoad', { name: failure.name, message: failure.message })}</span>
479
+ <button type="button" className="dsh-oauth-ms-retry" onClick={reload}>{t('retry')}</button>
480
+ </div>
481
+ ))}
482
+ <div className="dsh-oauth-ms-groups">
483
+ {filteredGroups.map((group) => {
484
+ const headingId = `${id}-${group.id}`
485
+ return (
486
+ <section role="group" aria-labelledby={headingId} className="dsh-oauth-ms-group" key={group.id}>
487
+ <div className="dsh-oauth-ms-groupTitle" id={headingId}>{group.name}</div>
488
+ {group.models.map(model => {
489
+ const selected = state.current?.provider === group.id && state.current.model === model.id
490
+ const info = group.id === OPENROUTER_ROUTE ? metadata.get(model.id) : undefined
491
+ const blocked = info?.freeOnly === true && info.priceStatus !== 'free'
492
+ const tokens = info === undefined ? '' : new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 }).format(info.contextWindow)
493
+ return (
494
+ <button
495
+ ref={itemRef()}
496
+ type="button"
497
+ role="menuitemradio"
498
+ aria-checked={selected}
499
+ className="dsh-oauth-ms-option"
500
+ key={model.id}
501
+ title={model.name}
502
+ disabled={busy || blocked}
503
+ onClick={() => { choose({ provider: group.id, model: model.id }) }}
504
+ >
505
+ <span className="dsh-oauth-ms-optionCopy">
506
+ <span className="dsh-oauth-ms-modelName">{model.name}</span>
507
+ {model.description !== undefined && (
508
+ <span className="dsh-oauth-ms-description">{model.description}</span>
509
+ )}
510
+ {info !== undefined && (
511
+ <span className="dsh-oauth-ms-meta">
512
+ {info.priceStatus === 'free' && <span className={'dsh-oauth-ms-free' + (catalogState.data?.stale ? ' dsh-oauth-ms-cached' : '')}>
513
+ {ts(catalogState.data?.stale ? 'freeCached' : 'free')}
514
+ </span>}
515
+ {blocked && <span className="dsh-oauth-ms-ended">{ts(info.priceStatus === 'paid' ? 'freeEnded' : 'freeUnknown')}</span>}
516
+ <span>{ts(info.tools ? 'tools' : 'chatOnly')}</span>
517
+ <span>{ts('context', { tokens })}</span>
518
+ </span>
519
+ )}
520
+ </span>
521
+ <span className="dsh-oauth-ms-check">
522
+ {selected
523
+ ? (
524
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
525
+ <path d="M3 8.5l3.2 3L13 5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
526
+ </svg>
527
+ )
528
+ : null}
529
+ </span>
530
+ </button>
531
+ )
532
+ })}
533
+ </section>
534
+ )
535
+ })}
536
+ </div>
537
+ {state.status === 'ready' && state.groups.length > 0 && filteredCount === 0 && (
538
+ <div className="dsh-oauth-ms-empty">{freeOnly ? ts('emptyFree') : ts('empty', { query: query.trim() })}</div>
539
+ )}
540
+ {state.status === 'ready' && state.groups.length === 0 && (
541
+ <div className="dsh-oauth-ms-empty">{t('empty.models')}</div>
542
+ )}
543
+ {freeOnly && <div className="dsh-oauth-ms-help">{ts('freeHelp')}</div>}
544
+ </>
545
+ )}
546
+
547
+ {pane === 'effort' && (
548
+ <>
549
+ {state.error !== null && lastActionRef.current === 'load' && (
550
+ <div className="dsh-oauth-ms-errorStrip">
551
+ <span>{t('error.action', { message: state.error })}</span>
552
+ <button type="button" className="dsh-oauth-ms-retry" onClick={reload}>{t('action.reload')}</button>
553
+ </div>
554
+ )}
555
+ {effortChoices.length === 0
556
+ ? <div className="dsh-oauth-ms-empty">{t('empty.efforts')}</div>
557
+ : effortChoices.map(level => (
558
+ <button
559
+ ref={itemRef()}
560
+ type="button"
561
+ role="menuitemradio"
562
+ aria-checked={effectiveEffort === level.effort}
563
+ className="dsh-oauth-ms-option"
564
+ key={level.key}
565
+ disabled={busy}
566
+ onClick={() => { chooseEffort(level.effort) }}
567
+ >
568
+ <span className="dsh-oauth-ms-optionCopy">
569
+ <span className="dsh-oauth-ms-modelName">{level.label}</span>
570
+ {level.description !== undefined && (
571
+ <span className="dsh-oauth-ms-description">{level.description}</span>
572
+ )}
573
+ </span>
574
+ <span className="dsh-oauth-ms-check">
575
+ {effectiveEffort === level.effort
576
+ ? (
577
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
578
+ <path d="M3 8.5l3.2 3L13 5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
579
+ </svg>
580
+ )
581
+ : null}
582
+ </span>
583
+ </button>
584
+ ))}
585
+ </>
586
+ )}
587
+
588
+ {notice !== null && (
589
+ <div role="alert" className="dsh-oauth-ms-errorStrip" key={notice.seq}>{notice.text}</div>
590
+ )}
591
+ </div>
592
+ )}
593
+ </div>
594
+ )
595
+ }
@@ -0,0 +1,12 @@
1
+ export type Drafts = Record<string, string>
2
+ export type DraftUpdater = (current: Drafts) => Drafts
3
+ export type DraftSetter = (updater: DraftUpdater) => void
4
+
5
+ export function applyDraftChange(
6
+ providerId: string,
7
+ event: { currentTarget: { value: string } },
8
+ setDrafts: DraftSetter,
9
+ ): void {
10
+ const value = event.currentTarget.value
11
+ setDrafts(current => ({ ...current, [providerId]: value }))
12
+ }
@@ -0,0 +1,71 @@
1
+ /** Browser half: Pi login settings page + searchable composer model seat. */
2
+
3
+ import type { Context as ClientContext } from '@deepseek-ai/cordis'
4
+ import type { ModelSelection } from '@deepseek-ai/dsh-api-session-controller/types'
5
+ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
6
+ import type {} from '@deepseek-ai/dsh-client-ui-slots'
7
+ import type {} from '@deepseek-ai/dsh-client-locale/client'
8
+ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
9
+ import type {} from '@deepseek-ai/dsh-client-ui-model-selection/client'
10
+ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
11
+ import type {} from '@deepseek-ai/dsh-client-ui-session/client'
12
+ import { PiLoginSettings } from './PiLoginSettings.tsx'
13
+ import type { PiLoginSettingsInjected } from './PiLoginSettings.tsx'
14
+ import { SearchableModelSelect } from './SearchableModelSelect.tsx'
15
+ import { en, zh, searchEn, searchZh } from './locales.ts'
16
+ import type { PiLoginKey, ModelSearchKey } from './locales.ts'
17
+ import { OpenRouterCatalogClient } from './openrouter-store.ts'
18
+
19
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
20
+ interface LocaleNamespaceMap {
21
+ 'settings.pi-login': PiLoginKey
22
+ 'model-search': ModelSearchKey
23
+ }
24
+ }
25
+
26
+ export const name = 'dsh-oauth-login-client'
27
+ // directoryFor() resolves remote.session through the calling plugin's context.
28
+ export const inject = ['slots', 'locale', 'sessions', 'modelDirectories', 'remote', 'remote.session']
29
+
30
+ export function apply(ctx: ClientContext): void {
31
+ const catalog = new OpenRouterCatalogClient()
32
+ ctx.effect(() => () => catalog.dispose(), 'dsh-oauth-login: catalog metadata')
33
+ const searchNs = 'model-search'
34
+ ctx.effect(() => ctx.locale.register(searchNs, { zh: searchZh, en: searchEn }), 'dsh-oauth-login: model-search copy')
35
+ const ts = ctx.locale.bind(searchNs)
36
+ const namespace = 'settings.pi-login'
37
+ ctx.effect(() => ctx.locale.register(namespace, { zh, en }), 'dsh-oauth-login: settings copy')
38
+ const t = ctx.locale.bind(namespace) as PiLoginSettingsInjected['t']
39
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
40
+ name: 'settings.section',
41
+ id: 'pi-login',
42
+ order: 17,
43
+ label: () => t('nav'),
44
+ inject: (): PiLoginSettingsInjected => ({ t, ts, catalog }),
45
+ }, PiLoginSettings))
46
+
47
+ // Searchable shadow of the composer model seat. Priority -1 renders over
48
+ // the shipped ModelSelect (priority 0, lowest live entry wins); removing
49
+ // this plugin restores the stock selector with no host restart.
50
+ ctx.slots.inject('conversation.input.model', () => ctx.slots.register({
51
+ name: 'conversation.input.model',
52
+ locale: 'model',
53
+ priority: -1,
54
+ inject: (sessionId) => {
55
+ const directory = ctx.modelDirectories.directoryFor(sessionId)
56
+ const available = ctx.sessions.subagentAddress(sessionId) === undefined
57
+ return {
58
+ available,
59
+ catalog,
60
+ directory: directory.store,
61
+ load: () => {
62
+ if (available) directory.load().catch(() => { /* surfaced on the store */ })
63
+ },
64
+ select: (selection: ModelSelection) => available
65
+ ? directory.select(selection).then(() => true, () => false)
66
+ : Promise.resolve(false),
67
+ ts,
68
+ }
69
+ },
70
+ }, SearchableModelSelect))
71
+ }