@xp266/dshtui 0.1.0

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 (159) hide show
  1. package/bin/dshtui.js +65 -0
  2. package/cordis.patch.yml +81 -0
  3. package/lib/contract/index.d.mts +2 -0
  4. package/lib/contract/index.mjs +5 -0
  5. package/lib/dialog.d.mts +156 -0
  6. package/lib/dialog.mjs +3 -0
  7. package/lib/index-DqnaSXqP.d.mts +823 -0
  8. package/lib/index.d.mts +17 -0
  9. package/lib/index.mjs +11404 -0
  10. package/lib/prod-react-Dzof4qJx.mjs +4 -0
  11. package/lib/surface-DdxzIgIY.mjs +3126 -0
  12. package/lib/vendor.d.mts +3 -0
  13. package/lib/vendor.mjs +4 -0
  14. package/package.json +128 -0
  15. package/src/apply-theme.ts +8 -0
  16. package/src/boot-log.ts +35 -0
  17. package/src/chat/blocks.ts +41 -0
  18. package/src/chat/bridge.ts +1076 -0
  19. package/src/chat/builtin-tool-views.ts +53 -0
  20. package/src/chat/chat-face.ts +16 -0
  21. package/src/chat/chat-nodes.ts +53 -0
  22. package/src/chat/efforts.ts +4 -0
  23. package/src/chat/interactions.ts +329 -0
  24. package/src/chat/models.ts +263 -0
  25. package/src/chat/partial-json.ts +125 -0
  26. package/src/chat/presets.ts +26 -0
  27. package/src/chat/question-view.ts +106 -0
  28. package/src/chat/retry-status.ts +33 -0
  29. package/src/chat/session-groups.ts +34 -0
  30. package/src/chat/session-list.ts +311 -0
  31. package/src/chat/store.ts +636 -0
  32. package/src/chat/todo-view.ts +66 -0
  33. package/src/chat/tool-view.ts +311 -0
  34. package/src/chat/tool-views.ts +114 -0
  35. package/src/contract/index.ts +704 -0
  36. package/src/contract/upstream.ts +165 -0
  37. package/src/core/caret-nonce.ts +19 -0
  38. package/src/core/composer-layout.ts +65 -0
  39. package/src/core/edit.ts +152 -0
  40. package/src/core/field-view.ts +52 -0
  41. package/src/core/fields.ts +185 -0
  42. package/src/core/metrics.ts +26 -0
  43. package/src/core/paste.ts +149 -0
  44. package/src/core/segments.ts +112 -0
  45. package/src/core/text.ts +290 -0
  46. package/src/dialog.ts +11 -0
  47. package/src/env.ts +38 -0
  48. package/src/harness-home.ts +22 -0
  49. package/src/hot-theme.ts +56 -0
  50. package/src/index.tsx +185 -0
  51. package/src/kernel/registry.ts +102 -0
  52. package/src/kernel/surface.ts +33 -0
  53. package/src/log.ts +249 -0
  54. package/src/model/message.ts +89 -0
  55. package/src/model/selection.ts +63 -0
  56. package/src/performance-guard.ts +28 -0
  57. package/src/runtime/prod-react.ts +10 -0
  58. package/src/terminal/background.ts +66 -0
  59. package/src/terminal/capabilities.ts +54 -0
  60. package/src/terminal/clipboard-backends.ts +69 -0
  61. package/src/terminal/clipboard.ts +161 -0
  62. package/src/terminal/cursor-shape.ts +13 -0
  63. package/src/terminal/glyphs.ts +44 -0
  64. package/src/terminal/mouse.ts +154 -0
  65. package/src/terminal/probe.ts +61 -0
  66. package/src/terminal/screen.ts +349 -0
  67. package/src/theme-settings.ts +31 -0
  68. package/src/theme.ts +319 -0
  69. package/src/ui/app.tsx +454 -0
  70. package/src/ui/chrome/caps.tsx +9 -0
  71. package/src/ui/chrome/hint-service.ts +87 -0
  72. package/src/ui/chrome/input-status.ts +27 -0
  73. package/src/ui/chrome/key-gate.tsx +29 -0
  74. package/src/ui/chrome/status-bar.tsx +147 -0
  75. package/src/ui/contributions.ts +47 -0
  76. package/src/ui/dialog/defaults-dialog.tsx +152 -0
  77. package/src/ui/dialog/dialog-item.tsx +38 -0
  78. package/src/ui/dialog/dialog.tsx +386 -0
  79. package/src/ui/dialog/effort-dialog.tsx +45 -0
  80. package/src/ui/dialog/geometry.ts +131 -0
  81. package/src/ui/dialog/items.ts +124 -0
  82. package/src/ui/dialog/list-dialog.tsx +63 -0
  83. package/src/ui/dialog/models-dialog.tsx +203 -0
  84. package/src/ui/dialog/presets-dialog.tsx +46 -0
  85. package/src/ui/dialog/providers-dialog.tsx +313 -0
  86. package/src/ui/dialog/sessions-dialog.tsx +163 -0
  87. package/src/ui/dialog/sizes.ts +7 -0
  88. package/src/ui/dialog/status-lines.ts +11 -0
  89. package/src/ui/dialog/todo-dialog.tsx +29 -0
  90. package/src/ui/dialog/use-dialog-input.ts +186 -0
  91. package/src/ui/extension-point.ts +188 -0
  92. package/src/ui/home-logo.ts +132 -0
  93. package/src/ui/hooks/use-async-action.ts +25 -0
  94. package/src/ui/hooks/use-async-list.ts +71 -0
  95. package/src/ui/hooks/use-caret.ts +24 -0
  96. package/src/ui/hooks/use-chat-events.ts +153 -0
  97. package/src/ui/hooks/use-mouse-selection.ts +352 -0
  98. package/src/ui/hooks/use-scroll.ts +49 -0
  99. package/src/ui/hooks/use-terminal-size.ts +20 -0
  100. package/src/ui/input/commands.ts +118 -0
  101. package/src/ui/input/composer-bus.ts +14 -0
  102. package/src/ui/input/composer-fields.ts +111 -0
  103. package/src/ui/input/composer-keys.ts +21 -0
  104. package/src/ui/input/composer-paste.ts +20 -0
  105. package/src/ui/input/input-bar.tsx +279 -0
  106. package/src/ui/input/use-composer.ts +495 -0
  107. package/src/ui/key-arbiter.ts +37 -0
  108. package/src/ui/keymap.ts +15 -0
  109. package/src/ui/layout-service.ts +77 -0
  110. package/src/ui/message/layout.ts +763 -0
  111. package/src/ui/message/md/block.ts +247 -0
  112. package/src/ui/message/md/engine.ts +430 -0
  113. package/src/ui/message/md/extensions.ts +58 -0
  114. package/src/ui/message/md/highlight.ts +626 -0
  115. package/src/ui/message/md/index.ts +14 -0
  116. package/src/ui/message/md/inline.ts +97 -0
  117. package/src/ui/message/md/palette.ts +87 -0
  118. package/src/ui/message/message-list.tsx +69 -0
  119. package/src/ui/message/message-row.tsx +137 -0
  120. package/src/ui/message/message-views.ts +16 -0
  121. package/src/ui/message/renderers.ts +39 -0
  122. package/src/ui/message/tool-diff.ts +188 -0
  123. package/src/ui/message/warmup.ts +41 -0
  124. package/src/ui/message/wave.ts +98 -0
  125. package/src/ui/overlay.ts +22 -0
  126. package/src/ui/panels/approval-panel.tsx +193 -0
  127. package/src/ui/panels/exports.ts +3 -0
  128. package/src/ui/panels/question-model.ts +424 -0
  129. package/src/ui/panels/question-panel.tsx +154 -0
  130. package/src/ui/panels/surface.tsx +106 -0
  131. package/src/ui/panels-builtin.tsx +86 -0
  132. package/src/ui/pointer/builtins.ts +281 -0
  133. package/src/ui/pointer/registry.ts +25 -0
  134. package/src/ui/region.tsx +31 -0
  135. package/src/ui/selection/service.ts +92 -0
  136. package/src/ui/selection-registry.ts +88 -0
  137. package/src/ui/selection.tsx +148 -0
  138. package/src/ui/spinner-tick.tsx +24 -0
  139. package/src/ui/use-available-windows.ts +31 -0
  140. package/src/ui/widgets/actions.tsx +84 -0
  141. package/src/ui/widgets/button.tsx +57 -0
  142. package/src/ui/widgets/checkbox.tsx +58 -0
  143. package/src/ui/widgets/header.tsx +36 -0
  144. package/src/ui/widgets/index.ts +8 -0
  145. package/src/ui/widgets/input.tsx +76 -0
  146. package/src/ui/widgets/registry.ts +32 -0
  147. package/src/ui/widgets/search.tsx +59 -0
  148. package/src/ui/widgets/select.tsx +97 -0
  149. package/src/ui/widgets/static.tsx +37 -0
  150. package/src/ui/widgets/types.ts +1 -0
  151. package/src/ui/window-services-bridge.ts +71 -0
  152. package/src/ui/window-services.ts +20 -0
  153. package/src/ui/windows/models-window.tsx +15 -0
  154. package/src/ui/windows/providers-window.tsx +14 -0
  155. package/src/ui/windows/service-window.tsx +18 -0
  156. package/src/ui/windows/sessions-window.tsx +25 -0
  157. package/src/ui/windows-builtin.tsx +94 -0
  158. package/src/ui/windows.ts +18 -0
  159. package/src/vendor.ts +12 -0
@@ -0,0 +1,63 @@
1
+ import type { Ref } from 'react'
2
+ import { errorLine, loadingLine } from './status-lines.ts'
3
+ import { useAsyncList } from '../hooks/use-async-list.ts'
4
+ import { Dialog } from './dialog.tsx'
5
+ import type { DialogFooterLine, DialogHandle, DialogItem, DialogRow } from './dialog.tsx'
6
+
7
+ export interface ListDialogProps<T> {
8
+ ref?: Ref<DialogHandle>
9
+ title: string
10
+ width: number
11
+ maxHeight: number
12
+ load: () => Promise<T[]>
13
+ search?: boolean
14
+ searchRight?: boolean
15
+ labelOf(item: T): string
16
+ rightOf?(item: T): string | undefined
17
+ onSelect(item: T): void
18
+ onClose(): void
19
+ footerLines?: DialogFooterLine[]
20
+ errors?: DialogFooterLine[]
21
+ }
22
+
23
+ export function ListDialog<T>({
24
+ ref,
25
+ title,
26
+ width,
27
+ maxHeight,
28
+ load,
29
+ search = false,
30
+ searchRight = false,
31
+ labelOf,
32
+ rightOf,
33
+ onSelect,
34
+ onClose,
35
+ footerLines,
36
+ errors,
37
+ }: ListDialogProps<T>) {
38
+ const { items, loading, error } = useAsyncList(load)
39
+ const itemRows: DialogRow[] = items.map(item => {
40
+ const button: DialogItem = { type: 'button', label: labelOf(item), right: rightOf?.(item), onPress: () => onSelect(item) }
41
+ return { items: [button] }
42
+ })
43
+ const footer: DialogFooterLine[] = loading ? [loadingLine()] : footerLines ?? []
44
+ const errorLines: DialogFooterLine[] = [
45
+ ...(error !== null ? [errorLine(error)] : []),
46
+ ...(errors ?? []),
47
+ ]
48
+ return (
49
+ <Dialog
50
+ ref={ref}
51
+ width={width}
52
+ maxHeight={maxHeight}
53
+ title={title}
54
+ rows={itemRows}
55
+ footer={footer}
56
+ errors={errorLines}
57
+ onClose={onClose}
58
+ search={search}
59
+ searchRight={searchRight}
60
+ centerScroll
61
+ />
62
+ )
63
+ }
@@ -0,0 +1,203 @@
1
+ import { useLayoutEffect, useRef, useState } from 'react'
2
+ import type { Ref } from 'react'
3
+ import type { LlmDiscoveredModel } from '@deepseek-ai/dsh-llm'
4
+ import type { ConfiguredModel, CustomProviderForm, DescribedModel, ModelEntryConfig, OfficialProvider } from '../../chat/models.ts'
5
+ import { PI_AI_SETTINGS_NS } from '../../chat/models.ts'
6
+ import { COLORS } from '../../theme.ts'
7
+ import { glyphs } from '../../terminal/glyphs.ts'
8
+ import { errorLine, loadingLine } from './status-lines.ts'
9
+ import { useAsyncAction } from '../hooks/use-async-action.ts'
10
+ import { useAsyncList } from '../hooks/use-async-list.ts'
11
+ import { Dialog } from './dialog.tsx'
12
+ import type { DialogFooterLine, DialogHandle, DialogItem, DialogRow } from './dialog.tsx'
13
+ import { DIALOG_MAX_HEIGHT, DIALOG_WIDTH_MEDIUM } from './sizes.ts'
14
+
15
+ export interface ModelApi {
16
+ listModels(): Promise<ConfiguredModel[]>
17
+ selectModel(provider: string, model: string): Promise<void>
18
+ fetchCustomModels(form: CustomProviderForm): Promise<LlmDiscoveredModel[]>
19
+ saveCustomProvider(form: CustomProviderForm, models: LlmDiscoveredModel[]): Promise<void>
20
+ listProviderDirectory(): Promise<OfficialProvider[]>
21
+ fetchProviderModels(provider: OfficialProvider, apiKey: string): Promise<LlmDiscoveredModel[] | undefined>
22
+ saveProviderKey(provider: OfficialProvider, apiKey: string): Promise<void>
23
+ saveProviderModels(provider: OfficialProvider, models: LlmDiscoveredModel[]): Promise<void>
24
+ deleteProvider(provider: OfficialProvider): Promise<void>
25
+ readModelEntries(ns: string, provider: string): ModelEntryConfig[]
26
+ describeModel(provider: string, model: string): Promise<DescribedModel>
27
+ saveModelEntry(ns: string, provider: string, entry: ModelEntryConfig): Promise<void>
28
+ deleteModelEntry(ns: string, provider: string, modelId: string): Promise<void>
29
+ }
30
+
31
+ export interface ModelsDialogProps {
32
+ api: ModelApi
33
+ onClose: () => void
34
+ onModelSelected: (provider: string, model: string) => void
35
+ onAddProvider: () => void
36
+ ref?: Ref<DialogHandle>
37
+ }
38
+
39
+ type Window =
40
+ | { kind: 'list' }
41
+ | { kind: 'configure-model' }
42
+
43
+
44
+ export function ModelsDialog({ api, onClose, onModelSelected, onAddProvider, ref }: ModelsDialogProps) {
45
+ const [window, setWindow] = useState<Window>({ kind: 'list' })
46
+ const { error, clearError, run } = useAsyncAction()
47
+ const { items: models, loading, error: loadError, reload } = useAsyncList(api.listModels)
48
+ const modelItemRefs = useRef(new Map<DialogItem, ConfiguredModel>())
49
+ const modelItemMap = new Map<DialogItem, ConfiguredModel>()
50
+ useLayoutEffect(() => {
51
+ modelItemRefs.current = modelItemMap
52
+ })
53
+ const [configTarget, setConfigTarget] = useState<ConfiguredModel | null>(null)
54
+ const [configDraft, setConfigDraft] = useState<{ name: string; contextWindow: string; image: string }>({
55
+ name: '',
56
+ contextWindow: '',
57
+ image: '',
58
+ })
59
+ const configureRequestRef = useRef(0)
60
+ const selectModel = async (model: ConfiguredModel) => {
61
+ await run(async () => {
62
+ await api.selectModel(model.provider, model.id)
63
+ onModelSelected(model.provider, model.id)
64
+ onClose()
65
+ })
66
+ }
67
+ const openConfigure = (model: ConfiguredModel) => {
68
+ const settings = api.readModelEntries(PI_AI_SETTINGS_NS, model.provider).find(entry => entry.id === model.id)
69
+ setConfigTarget(model)
70
+ setConfigDraft({
71
+ name: settings?.name ?? '',
72
+ contextWindow: settings?.contextWindow === undefined ? '' : String(settings.contextWindow),
73
+ image: settings?.input === undefined ? '' : String(settings.input.includes('image')),
74
+ })
75
+ clearError()
76
+ setWindow({ kind: 'configure-model' })
77
+ const request = ++configureRequestRef.current
78
+ void api.describeModel(model.provider, model.id).then(info => {
79
+ if (configureRequestRef.current !== request) return
80
+ setConfigDraft(current => ({
81
+ name: current.name === '' ? info.name : current.name,
82
+ contextWindow: current.contextWindow === '' && info.contextWindow !== undefined ? String(info.contextWindow) : current.contextWindow,
83
+ image: current.image === '' ? String(info.image) : current.image,
84
+ }))
85
+ }).catch(() => {})
86
+ }
87
+ const submitConfigure = async () => {
88
+ if (configTarget === null) return
89
+ await run(async () => {
90
+ const name = configDraft.name.trim()
91
+ const contextWindow = configDraft.contextWindow.trim() === '' ? undefined : Number(configDraft.contextWindow)
92
+ if (contextWindow !== undefined && (!Number.isInteger(contextWindow) || contextWindow <= 0)) {
93
+ throw new Error('Context window must be a positive integer')
94
+ }
95
+ const image = configDraft.image.trim().toLowerCase()
96
+ let input: string[] | undefined
97
+ if (image !== '') {
98
+ if (image !== 'true' && image !== 'false') throw new Error('imageModality must be true or false')
99
+ input = image === 'true' ? ['text', 'image'] : ['text']
100
+ }
101
+ const existing = api.readModelEntries(PI_AI_SETTINGS_NS, configTarget.provider).find(entry => entry.id === configTarget.id)
102
+ const entry: ModelEntryConfig = { ...existing, id: configTarget.id }
103
+ if (name === '') delete entry.name
104
+ else entry.name = name
105
+ if (contextWindow === undefined) delete entry.contextWindow
106
+ else entry.contextWindow = contextWindow
107
+ if (input === undefined) delete entry.input
108
+ else entry.input = input
109
+ await api.saveModelEntry(PI_AI_SETTINGS_NS, configTarget.provider, entry)
110
+ setConfigTarget(null)
111
+ clearError()
112
+ setWindow({ kind: 'list' })
113
+ reload()
114
+ })
115
+ }
116
+ const handleCtrlE = (model: ConfiguredModel): boolean => {
117
+ openConfigure(model)
118
+ return true
119
+ }
120
+ const configureRows: DialogRow[] = configTarget === null ? [] : [
121
+ {
122
+ items: [
123
+ { type: 'input', label: 'name', value: configDraft.name, onChange: value => setConfigDraft(current => ({ ...current, name: value })) },
124
+ ],
125
+ },
126
+ {
127
+ items: [
128
+ { type: 'input', label: 'contextWindow', value: configDraft.contextWindow, onChange: value => setConfigDraft(current => ({ ...current, contextWindow: value })) },
129
+ ],
130
+ },
131
+ {
132
+ items: [
133
+ { type: 'input', label: 'imageModality', value: configDraft.image, onChange: value => setConfigDraft(current => ({ ...current, image: value })) },
134
+ ],
135
+ },
136
+ {
137
+ items: [
138
+ { type: 'actions', confirmLabel: 'Submit', cancelLabel: 'Cancel', onConfirm: () => void submitConfigure(), onCancel: () => setWindow({ kind: 'list' }) },
139
+ ],
140
+ },
141
+ ]
142
+ const footerLines: DialogFooterLine[] = error !== null ? [errorLine(error)] : []
143
+ if (window.kind === 'configure-model') {
144
+ return (
145
+ <Dialog
146
+ ref={ref}
147
+ key="configure-model"
148
+ width={DIALOG_WIDTH_MEDIUM}
149
+ maxHeight={DIALOG_MAX_HEIGHT}
150
+ title="Configure Model"
151
+ rows={configureRows}
152
+ errors={footerLines}
153
+ onClose={onClose}
154
+ />
155
+ )
156
+ }
157
+ const listRows: DialogRow[] = models.map(model => {
158
+ const item: DialogItem = {
159
+ type: 'button',
160
+ label: model.name,
161
+ right: model.providerName,
162
+ onPress: () => void selectModel(model),
163
+ }
164
+ modelItemMap.set(item, model)
165
+ return { items: [item] }
166
+ })
167
+ const listFooter: DialogFooterLine[] = [
168
+ {
169
+ text: `Ctrl+A add providers ${glyphs.separator} Ctrl+E configure`,
170
+ color: COLORS.dialogHintText,
171
+ },
172
+ ...(loading ? [loadingLine()] : []),
173
+ ]
174
+ const listErrors: DialogFooterLine[] = [
175
+ ...(loadError !== null ? [errorLine(loadError)] : []),
176
+ ...footerLines,
177
+ ]
178
+ const handleCtrlEItem = (focused: DialogItem | undefined): boolean => {
179
+ const model = focused === undefined ? undefined : modelItemRefs.current.get(focused)
180
+ return model === undefined ? false : handleCtrlE(model)
181
+ }
182
+ return (
183
+ <Dialog
184
+ key="list"
185
+ ref={ref}
186
+ width={DIALOG_WIDTH_MEDIUM}
187
+ maxHeight={DIALOG_MAX_HEIGHT}
188
+ title="models"
189
+ rows={listRows}
190
+ footer={listFooter}
191
+ errors={listErrors}
192
+ onClose={onClose}
193
+ search
194
+ searchRight
195
+ centerScroll
196
+ onCtrlA={(_focused) => {
197
+ onAddProvider()
198
+ return true
199
+ }}
200
+ onCtrlE={handleCtrlEItem}
201
+ />
202
+ )
203
+ }
@@ -0,0 +1,46 @@
1
+ import type { Ref } from 'react'
2
+ import { errorLine } from './status-lines.ts'
3
+ import type { PresetSummary } from '../../chat/presets.ts'
4
+ import { useAsyncAction } from '../hooks/use-async-action.ts'
5
+ import { ListDialog } from './list-dialog.tsx'
6
+ import { DIALOG_WIDTH_MEDIUM, DIALOG_MAX_HEIGHT } from './sizes.ts'
7
+ import type { DialogHandle, DialogFooterLine } from './dialog.tsx'
8
+
9
+ export interface PresetsApi {
10
+ listPresets(): Promise<PresetSummary[]>
11
+ currentPreset(): string
12
+ selectPreset(id: string): Promise<void>
13
+ }
14
+
15
+ export interface PresetsDialogProps {
16
+ api: PresetsApi
17
+ onClose: () => void
18
+ ref?: Ref<DialogHandle>
19
+ }
20
+
21
+
22
+ export function PresetsDialog({ api, onClose, ref }: PresetsDialogProps) {
23
+ const { error, run } = useAsyncAction()
24
+ const current = api.currentPreset()
25
+ const selectPreset = async (id: string) => {
26
+ const result = await run(() => api.selectPreset(id))
27
+ if (!result.ok) return
28
+ onClose()
29
+ }
30
+ const footerLines: DialogFooterLine[] = error === null ? [] : [errorLine(error)]
31
+ return (
32
+ <ListDialog
33
+ ref={ref}
34
+ title="preset"
35
+ width={DIALOG_WIDTH_MEDIUM}
36
+ maxHeight={DIALOG_MAX_HEIGHT}
37
+ load={api.listPresets}
38
+ search
39
+ labelOf={preset => preset.name}
40
+ rightOf={preset => (preset.id === current ? 'current' : undefined)}
41
+ onSelect={preset => void selectPreset(preset.id)}
42
+ onClose={onClose}
43
+ errors={footerLines}
44
+ />
45
+ )
46
+ }
@@ -0,0 +1,313 @@
1
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react'
2
+ import type { Ref } from 'react'
3
+ import type { LlmDiscoveredModel } from '@deepseek-ai/dsh-llm'
4
+ import type { CustomProviderForm, OfficialProvider } from '../../chat/models.ts'
5
+ import { API_PROTOCOLS, isProviderIdValid } from '../../chat/models.ts'
6
+ import { COLORS } from '../../theme.ts'
7
+ import { errorLine, loadingLine } from './status-lines.ts'
8
+ import { useAsyncAction } from '../hooks/use-async-action.ts'
9
+ import { useAsyncList } from '../hooks/use-async-list.ts'
10
+ import { Dialog } from './dialog.tsx'
11
+ import type { DialogFooterLine, DialogHandle, DialogItem, DialogRow } from './dialog.tsx'
12
+ import { DIALOG_MAX_HEIGHT, DIALOG_WIDTH_MEDIUM } from './sizes.ts'
13
+ import type { ModelApi } from './models-dialog.tsx'
14
+
15
+ export interface ProvidersDialogProps {
16
+ api: ModelApi
17
+ onClose: () => void
18
+ onModelSelected: (provider: string, model: string) => void
19
+ ref?: Ref<DialogHandle>
20
+ }
21
+
22
+ type Window =
23
+ | { kind: 'providers' }
24
+ | { kind: 'add-provider-key' }
25
+ | { kind: 'add-custom' }
26
+ | { kind: 'select-models' }
27
+
28
+ const ARM_TIMEOUT_MS = 3000
29
+
30
+ const DELETE_HINT: DialogFooterLine[] = [
31
+ { text: 'Ctrl+D to delete the custom provider', color: COLORS.dialogHintText },
32
+ ]
33
+
34
+
35
+ const EMPTY_FORM: CustomProviderForm = {
36
+ providerId: '',
37
+ displayName: '',
38
+ apiUrl: '',
39
+ apiProtocol: API_PROTOCOLS[0],
40
+ apiKey: '',
41
+ }
42
+
43
+ function sourceOf(entry: OfficialProvider): string {
44
+ return entry.declared === true ? 'custom' : entry.settingsNs
45
+ }
46
+
47
+ export function ProvidersDialog({ api, onClose, onModelSelected, ref }: ProvidersDialogProps) {
48
+ const [window, setWindow] = useState<Window>({ kind: 'providers' })
49
+ const { error, setError, clearError, run } = useAsyncAction()
50
+ const [form, setForm] = useState<CustomProviderForm>(EMPTY_FORM)
51
+ const [provider, setProvider] = useState<OfficialProvider | null>(null)
52
+ const [providerKey, setProviderKey] = useState('')
53
+ const [discovered, setDiscovered] = useState<LlmDiscoveredModel[]>([])
54
+ const [picked, setPicked] = useState<ReadonlySet<string>>(new Set())
55
+ const [fetching, setFetching] = useState(false)
56
+ const fetchingRef = useRef(false)
57
+ const [armedKey, setArmedKey] = useState<string | null>(null)
58
+ const armedRef = useRef<string | null>(null)
59
+ const armTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
60
+ const providerItemRefs = useRef(new Map<DialogItem, OfficialProvider>())
61
+ const providerItemMap = new Map<DialogItem, OfficialProvider>()
62
+ useLayoutEffect(() => {
63
+ providerItemRefs.current = providerItemMap
64
+ })
65
+ const { items: directory, loading, error: loadError, reload } = useAsyncList(api.listProviderDirectory)
66
+ const disarm = () => {
67
+ clearTimeout(armTimer.current)
68
+ if (armedRef.current !== null) {
69
+ armedRef.current = null
70
+ setArmedKey(null)
71
+ }
72
+ }
73
+ useEffect(() => () => clearTimeout(armTimer.current), [])
74
+ const deleteProvider = async (entry: OfficialProvider) => {
75
+ const result = await run(() => api.deleteProvider(entry))
76
+ if (!result.ok) return
77
+ clearError()
78
+ reload()
79
+ }
80
+ const handleCtrlD = (focused: DialogItem | undefined): boolean => {
81
+ if (focused?.type !== 'button') return false
82
+ const entry = providerItemRefs.current.get(focused)
83
+ if (entry === undefined || entry.declared !== true) return false
84
+ if (armedRef.current === entry.provider) {
85
+ disarm()
86
+ void deleteProvider(entry)
87
+ return true
88
+ }
89
+ clearTimeout(armTimer.current)
90
+ armedRef.current = entry.provider
91
+ setArmedKey(entry.provider)
92
+ armTimer.current = setTimeout(disarm, ARM_TIMEOUT_MS)
93
+ return true
94
+ }
95
+ const openCustom = () => {
96
+ clearError()
97
+ setWindow({ kind: 'add-custom' })
98
+ }
99
+ const selectDirectoryProvider = (entry: OfficialProvider) => {
100
+ clearError()
101
+ setProvider(entry)
102
+ setProviderKey('')
103
+ setWindow({ kind: 'add-provider-key' })
104
+ }
105
+ const resetProvider = () => {
106
+ setProvider(null)
107
+ setProviderKey('')
108
+ }
109
+ const setField = <K extends keyof CustomProviderForm>(key: K, value: CustomProviderForm[K]) => {
110
+ setForm(current => ({ ...current, [key]: value }))
111
+ }
112
+ const fetchIntoSelection = async (fetchModels: () => Promise<LlmDiscoveredModel[]>) => {
113
+ const found = await fetchModels()
114
+ if (found.length === 0) throw new Error('Failed to fetch models')
115
+ setDiscovered(found)
116
+ setPicked(new Set())
117
+ clearError()
118
+ setWindow({ kind: 'select-models' })
119
+ }
120
+ const submitCustom = async () => {
121
+ if (fetchingRef.current) return
122
+ clearError()
123
+ if (!isProviderIdValid(form.providerId.trim())) {
124
+ setError('Provider ID must start with a letter and use only letters, digits, _ and -')
125
+ return
126
+ }
127
+ fetchingRef.current = true
128
+ setFetching(true)
129
+ await run(() => fetchIntoSelection(() => api.fetchCustomModels({ ...form, providerId: form.providerId.trim() })))
130
+ fetchingRef.current = false
131
+ setFetching(false)
132
+ }
133
+ const submitProviderKey = async () => {
134
+ if (provider === null || fetchingRef.current) return
135
+ fetchingRef.current = true
136
+ setFetching(true)
137
+ await run(async () => {
138
+ if (providerKey.trim() !== '') await api.saveProviderKey(provider, providerKey)
139
+ const found = await api.fetchProviderModels(provider, providerKey)
140
+ if (found === undefined) {
141
+ resetProvider()
142
+ clearError()
143
+ setWindow({ kind: 'providers' })
144
+ reload()
145
+ return
146
+ }
147
+ if (found.length === 0) throw new Error('Failed to fetch models')
148
+ setDiscovered(found)
149
+ setPicked(new Set())
150
+ clearError()
151
+ setWindow({ kind: 'select-models' })
152
+ })
153
+ fetchingRef.current = false
154
+ setFetching(false)
155
+ }
156
+ const toggleModel = (id: string) => {
157
+ setPicked(current => {
158
+ const next = new Set(current)
159
+ if (next.has(id)) next.delete(id)
160
+ else next.add(id)
161
+ return next
162
+ })
163
+ }
164
+ const submitModels = async () => {
165
+ await run(async () => {
166
+ if (picked.size === 0) throw new Error('Select at least one model')
167
+ const chosen = discovered.filter(model => picked.has(model.id))
168
+ if (provider !== null) {
169
+ await api.saveProviderModels(provider, chosen)
170
+ resetProvider()
171
+ } else {
172
+ await api.saveCustomProvider(form, chosen)
173
+ setForm(EMPTY_FORM)
174
+ }
175
+ clearError()
176
+ setWindow({ kind: 'providers' })
177
+ reload()
178
+ })
179
+ }
180
+ const providerKeyRows: DialogRow[] = [
181
+ {
182
+ items: [
183
+ { type: 'input', label: 'API Key', value: providerKey, onChange: setProviderKey },
184
+ ],
185
+ },
186
+ {
187
+ items: [
188
+ { type: 'actions', confirmLabel: 'Submit', cancelLabel: 'Cancel', onConfirm: () => void submitProviderKey(), onCancel: onClose },
189
+ ],
190
+ },
191
+ ]
192
+ const customRows: DialogRow[] = [
193
+ { items: [{ type: 'input', label: 'Provider ID', value: form.providerId, onChange: value => setField('providerId', value) }] },
194
+ { items: [{ type: 'input', label: 'Display Name', value: form.displayName, onChange: value => setField('displayName', value) }] },
195
+ { items: [{ type: 'input', label: 'API URL', value: form.apiUrl, onChange: value => setField('apiUrl', value) }] },
196
+ {
197
+ items: [
198
+ {
199
+ type: 'input',
200
+ label: 'API Key',
201
+ value: form.apiKey,
202
+ onChange: value => setField('apiKey', value),
203
+ },
204
+ ],
205
+ },
206
+ {
207
+ items: [
208
+ {
209
+ type: 'select',
210
+ label: 'API Protocol',
211
+ value: form.apiProtocol,
212
+ options: API_PROTOCOLS,
213
+ onChange: value => setField('apiProtocol', value),
214
+ spaced: true,
215
+ },
216
+ ],
217
+ },
218
+ {
219
+ items: [
220
+ { type: 'actions', confirmLabel: 'Submit', cancelLabel: 'Cancel', onConfirm: () => void submitCustom(), onCancel: onClose },
221
+ ],
222
+ },
223
+ ]
224
+ const selectRows: DialogRow[] = discovered.map(model => ({
225
+ items: [
226
+ {
227
+ type: 'checkbox',
228
+ label: model.name ?? model.id,
229
+ checked: picked.has(model.id),
230
+ onToggle: () => toggleModel(model.id),
231
+ onConfirm: () => void submitModels(),
232
+ },
233
+ ],
234
+ }))
235
+ const statusLines: DialogFooterLine[] = [
236
+ ...(window.kind === 'select-models' ? [{ text: 'Press Space to toggle, Enter to confirm', color: COLORS.dialogHintText }] : []),
237
+ ...(fetching && (window.kind === 'add-provider-key' || window.kind === 'add-custom') ? [{ text: 'Fetching models...' }] : []),
238
+ ]
239
+ const errorLines: DialogFooterLine[] = error !== null ? [errorLine(error)] : []
240
+ if (window.kind !== 'providers') {
241
+ const title =
242
+ window.kind === 'add-provider-key'
243
+ ? `Add ${provider?.displayName ?? provider?.provider ?? ''}`
244
+ : window.kind === 'add-custom'
245
+ ? 'Add Custom Provider'
246
+ : 'Select Models'
247
+ const rows = window.kind === 'add-provider-key'
248
+ ? providerKeyRows
249
+ : window.kind === 'add-custom'
250
+ ? customRows
251
+ : selectRows
252
+ return (
253
+ <Dialog
254
+ ref={ref}
255
+ key={window.kind}
256
+ width={DIALOG_WIDTH_MEDIUM}
257
+ maxHeight={DIALOG_MAX_HEIGHT}
258
+ title={title}
259
+ rows={rows}
260
+ footer={statusLines}
261
+ errors={errorLines}
262
+ onClose={onClose}
263
+ search={window.kind === 'select-models'}
264
+ centerScroll={window.kind === 'select-models'}
265
+ />
266
+ )
267
+ }
268
+ const providerRows: DialogRow[] = [
269
+ ...directory.map(entry => {
270
+ const armed = armedKey === entry.provider
271
+ const item: DialogItem = {
272
+ type: 'button',
273
+ label: entry.displayName || entry.provider,
274
+ right: armed ? 'Press Ctrl+D again to delete' : sourceOf(entry),
275
+ onPress: () => selectDirectoryProvider(entry),
276
+ ...(armed ? { rightColor: COLORS.errorText } : {}),
277
+ }
278
+ providerItemMap.set(item, entry)
279
+ return { items: [item] }
280
+ }),
281
+ {
282
+ items: [
283
+ { type: 'button', label: 'Custom Provider', right: 'add manually', onPress: openCustom },
284
+ ],
285
+ },
286
+ ]
287
+ const providerFooter: DialogFooterLine[] = [
288
+ ...(loading ? [loadingLine()] : []),
289
+ ...DELETE_HINT,
290
+ ...statusLines,
291
+ ]
292
+ const providerErrors: DialogFooterLine[] = [
293
+ ...(loadError !== null ? [errorLine(loadError)] : []),
294
+ ...errorLines,
295
+ ]
296
+ return (
297
+ <Dialog
298
+ key="providers"
299
+ ref={ref}
300
+ width={DIALOG_WIDTH_MEDIUM}
301
+ maxHeight={DIALOG_MAX_HEIGHT}
302
+ title="providers"
303
+ rows={providerRows}
304
+ footer={providerFooter}
305
+ errors={providerErrors}
306
+ onClose={onClose}
307
+ search
308
+ centerScroll
309
+ onCtrlD={handleCtrlD}
310
+ onActivity={disarm}
311
+ />
312
+ )
313
+ }