dsh-code 1.2.0 → 1.4.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 (102) hide show
  1. package/README.en.md +5 -5
  2. package/README.md +5 -5
  3. package/lib/index.mjs +6679 -5232
  4. package/lib/startup.mjs +1 -1
  5. package/lib/{theme-7u5Qo3dF.mjs → theme-B3orFUYz.mjs} +8 -0
  6. package/lib/types/app.d.ts +32 -45
  7. package/lib/types/attachments.d.ts +16 -7
  8. package/lib/types/completion.d.ts +29 -0
  9. package/lib/types/composer.d.ts +150 -0
  10. package/lib/types/git-workflow.d.ts +6 -0
  11. package/lib/types/index.d.ts +6 -188
  12. package/lib/types/locales/en.d.ts +65 -5
  13. package/lib/types/{authorization-panel.d.ts → panels/authorization-panel.d.ts} +6 -1
  14. package/lib/types/panels/completion-panel.d.ts +13 -0
  15. package/lib/types/panels/interaction-bars.d.ts +38 -0
  16. package/lib/types/{kernel-panels.d.ts → panels/kernel-panels.d.ts} +19 -38
  17. package/lib/types/{language-panel.d.ts → panels/language-panel.d.ts} +1 -1
  18. package/lib/types/panels/model-panels.d.ts +86 -0
  19. package/lib/types/{theme-panel.d.ts → panels/theme-panel.d.ts} +1 -1
  20. package/lib/types/{update-panel.d.ts → panels/update-panel.d.ts} +22 -1
  21. package/lib/types/provider-settings.d.ts +11 -0
  22. package/lib/types/render/inspector.d.ts +8 -0
  23. package/lib/types/render/status.d.ts +4 -1
  24. package/lib/types/render/text.d.ts +4 -0
  25. package/lib/types/runner/harness-gate.d.ts +83 -0
  26. package/lib/types/runner/input-history.d.ts +31 -0
  27. package/lib/types/runner/mode-cycle.d.ts +44 -0
  28. package/lib/types/runner/preferences.d.ts +40 -0
  29. package/lib/types/runner/quit.d.ts +27 -0
  30. package/lib/types/runner/search-rows.d.ts +38 -0
  31. package/lib/types/runner/session-io.d.ts +46 -0
  32. package/lib/types/runner/session-target.d.ts +42 -0
  33. package/lib/types/runner/startup-config.d.ts +33 -0
  34. package/lib/types/runner/submissions.d.ts +87 -0
  35. package/lib/types/session/attach.d.ts +39 -0
  36. package/lib/types/{history.d.ts → session/history.d.ts} +10 -0
  37. package/lib/types/{session-directory.d.ts → session/session-directory.d.ts} +25 -1
  38. package/lib/types/{session-switch.d.ts → session/session-switch.d.ts} +8 -0
  39. package/lib/types/{store.d.ts → session/store.d.ts} +1 -1
  40. package/lib/types/{subagents.d.ts → session/subagents.d.ts} +8 -1
  41. package/lib/types/settings-file.d.ts +10 -0
  42. package/lib/types/{panel-accent.d.ts → ui/panel-accent.d.ts} +1 -1
  43. package/lib/types/ui/panel-gap.d.ts +6 -0
  44. package/lib/types/ui/query-editor.d.ts +10 -0
  45. package/lib/types/ui/styled-rows.d.ts +8 -0
  46. package/lib/types/{terminal-title.d.ts → ui/terminal-title.d.ts} +1 -1
  47. package/lib/types/ui/ui-contract.d.ts +12 -0
  48. package/lib/types/ui/use-frames.d.ts +6 -0
  49. package/lib/types/ui/use-stable-input.d.ts +7 -0
  50. package/lib/types/version.d.ts +2 -0
  51. package/package.json +7 -5
  52. package/src/app.ts +707 -4108
  53. package/src/attachments.ts +65 -19
  54. package/src/completion.ts +117 -0
  55. package/src/composer.ts +1956 -0
  56. package/src/git-workflow.ts +18 -0
  57. package/src/index.ts +164 -645
  58. package/src/input-split.ts +24 -4
  59. package/src/internals.ts +1 -1
  60. package/src/locales/en.ts +66 -5
  61. package/src/locales/zh.ts +66 -5
  62. package/src/{authorization-panel.ts → panels/authorization-panel.ts} +30 -8
  63. package/src/panels/completion-panel.ts +79 -0
  64. package/src/panels/interaction-bars.ts +567 -0
  65. package/src/{kernel-panels.ts → panels/kernel-panels.ts} +142 -90
  66. package/src/{language-panel.ts → panels/language-panel.ts} +4 -4
  67. package/src/panels/model-panels.ts +1021 -0
  68. package/src/{theme-panel.ts → panels/theme-panel.ts} +5 -5
  69. package/src/{update-panel.ts → panels/update-panel.ts} +117 -10
  70. package/src/provider-settings.ts +38 -0
  71. package/src/rainbow.ts +13 -3
  72. package/src/render/inspector.ts +23 -0
  73. package/src/render/status.ts +80 -29
  74. package/src/render/text.ts +10 -1
  75. package/src/runner/harness-gate.ts +168 -0
  76. package/src/runner/input-history.ts +77 -0
  77. package/src/runner/mode-cycle.ts +49 -0
  78. package/src/runner/preferences.ts +67 -0
  79. package/src/runner/quit.ts +53 -0
  80. package/src/runner/search-rows.ts +55 -0
  81. package/src/runner/session-io.ts +206 -0
  82. package/src/runner/session-target.ts +81 -0
  83. package/src/runner/startup-config.ts +54 -0
  84. package/src/runner/submissions.ts +157 -0
  85. package/src/session/attach.ts +87 -0
  86. package/src/{fork.ts → session/fork.ts} +11 -7
  87. package/src/{history.ts → session/history.ts} +14 -0
  88. package/src/{session-directory.ts → session/session-directory.ts} +83 -4
  89. package/src/{session-switch.ts → session/session-switch.ts} +14 -0
  90. package/src/{store.ts → session/store.ts} +20 -2
  91. package/src/{subagents.ts → session/subagents.ts} +12 -1
  92. package/src/settings-file.ts +19 -1
  93. package/src/{panel-accent.ts → ui/panel-accent.ts} +1 -1
  94. package/src/ui/panel-gap.ts +9 -0
  95. package/src/ui/query-editor.ts +16 -0
  96. package/src/ui/styled-rows.ts +124 -0
  97. package/src/{terminal-title.ts → ui/terminal-title.ts} +1 -1
  98. package/src/ui/ui-contract.ts +10 -0
  99. package/src/ui/use-frames.ts +23 -0
  100. package/src/ui/use-stable-input.ts +17 -0
  101. package/src/version.ts +5 -0
  102. /package/lib/types/{fork.d.ts → session/fork.d.ts} +0 -0
@@ -0,0 +1,1021 @@
1
+ /** Model selection and provider-management panels for the terminal app. */
2
+
3
+ import { createElement, useEffect, useMemo, useRef, useState, type ReactElement } from 'react'
4
+ import { Box, Text, useInput, useStdout } from 'ink'
5
+ import type { ModelDirectory, ModelRow } from '../models.ts'
6
+ import {
7
+ isDeclaredReasoningEfforts,
8
+ parseReasoningEffortsDraft,
9
+ serializeReasoningEfforts,
10
+ type DiscoveredModelView,
11
+ type ProviderConfiguration,
12
+ type ProviderModelSettings,
13
+ type ProviderSettingsDirectory,
14
+ type ProviderTargetView,
15
+ type ReasoningEffortsValue,
16
+ } from '../provider-settings.ts'
17
+ import {
18
+ authorizationForProvider,
19
+ providerAuthorizationStatus,
20
+ type ProviderAuthorizationDirectory,
21
+ type ProviderAuthorizationRow,
22
+ } from '../authorization.ts'
23
+ import { editQuery } from '../ui/query-editor.ts'
24
+ import { stripPasteMarkers } from '../keyboard.ts'
25
+ import { PanelGap } from '../ui/panel-gap.ts'
26
+ import { panelAccent } from '../ui/panel-accent.ts'
27
+ import { deleteLastGrapheme } from '../render/editor.ts'
28
+ import { panelViewport, selectionWindow } from '../render/inspector.ts'
29
+ import { displayText, padColumns, singleLineText, stringWidth, truncateColumns } from '../render/text.ts'
30
+ import { dim, getPalette, inkColor } from '../theme.ts'
31
+ import { t } from '../i18n.ts'
32
+ import { useStableInput } from '../ui/use-stable-input.ts'
33
+
34
+ export function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry, onClose }: {
35
+ directory: ModelDirectory | undefined
36
+ error: string | undefined
37
+ /** `provider/model` label of the applied model: the cursor lands on it once. */
38
+ current?: string
39
+ onSelect: (row: ModelRow) => void
40
+ onProviders?: () => void
41
+ onRetry: () => void
42
+ onClose: () => void
43
+ }): ReactElement {
44
+ const [query, setQuery] = useState('')
45
+ const [cursor, setCursor] = useState(0)
46
+ const stdout = useStdout().stdout
47
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
48
+ const rows = useMemo(() => directory?.rows ?? [], [directory])
49
+ // Direct-typing filter over provider and model names (the /mode contract):
50
+ // printable keys edit the query, so a long directory is searchable without
51
+ // a separate search mode. With a query active, q/r/g/G stop acting as
52
+ // commands and become query text instead.
53
+ const filtered = useMemo(() => {
54
+ if (query === '') return rows
55
+ const needle = query.toLowerCase()
56
+ return rows.filter(row => `${row.provider} ${row.providerName ?? ''} ${row.model} ${row.modelName}`.toLowerCase().includes(needle))
57
+ }, [rows, query])
58
+ const positioned = useRef(false)
59
+ // Latest-value ref: Ink re-subscribes useInput when its effect flushes,
60
+ // which can lag a committed render (a directory that just landed paints
61
+ // before the subscription swaps). A keystroke in that window would meet a
62
+ // stale closure — Enter died as an empty-filter no-op right after "2 of 4
63
+ // match" painted. The handler reads render-fresh values through the ref.
64
+ const liveRef = useRef({ filtered, query, cursor })
65
+ liveRef.current = { filtered, query, cursor }
66
+
67
+ useEffect(() => {
68
+ // Open ON the applied model (Codex resumes the previous pick): the first
69
+ // non-empty directory positions the cursor once, never on later refreshes.
70
+ if (positioned.current || rows.length === 0 || current === undefined) {
71
+ if (filtered.length === 0) {
72
+ if (cursor !== 0) setCursor(0)
73
+ return
74
+ }
75
+ if (cursor >= filtered.length) setCursor(filtered.length - 1)
76
+ return
77
+ }
78
+ const index = rows.findIndex(row => `${row.provider}/${row.model}` === current)
79
+ if (index >= 0) {
80
+ positioned.current = true
81
+ // Position within the ACTIVE filter: the full-row index means nothing
82
+ // when the query already narrowed the list while the directory loaded
83
+ // (a late resolve must not place the cursor outside `filtered`).
84
+ const filteredIndex = filtered.indexOf(rows[index])
85
+ setCursor(filteredIndex >= 0 ? filteredIndex : 0)
86
+ } else if (cursor >= filtered.length) {
87
+ setCursor(Math.max(0, filtered.length - 1))
88
+ }
89
+ }, [rows, filtered, cursor, current])
90
+
91
+ useInput((input, key) => {
92
+ const { filtered: list, query: text, cursor: at } = liveRef.current
93
+ if (key.escape || (input === 'q' && text === '')) {
94
+ onClose()
95
+ return
96
+ }
97
+ if (input === 'r' && text === '') {
98
+ onRetry()
99
+ return
100
+ }
101
+ if (key.tab && onProviders !== undefined) {
102
+ onProviders()
103
+ return
104
+ }
105
+ // Ctrl+C leaves the whole model configuration flow from any stage.
106
+ if (key.ctrl && input === 'c') {
107
+ onClose()
108
+ return
109
+ }
110
+ const next = editQuery(text, input, key)
111
+ if (next !== undefined) {
112
+ setQuery(next)
113
+ setCursor(0)
114
+ return
115
+ }
116
+ if (list.length === 0) return
117
+ if (key.upArrow) {
118
+ setCursor(at > 0 ? at - 1 : list.length - 1)
119
+ return
120
+ }
121
+ if (key.downArrow) {
122
+ setCursor(at < list.length - 1 ? at + 1 : 0)
123
+ return
124
+ }
125
+ if (key.pageUp) {
126
+ setCursor(current => Math.max(0, current - Math.max(1, viewport.bodyRows - 1)))
127
+ return
128
+ }
129
+ if (key.pageDown) {
130
+ setCursor(current => Math.min(list.length - 1, current + Math.max(1, viewport.bodyRows - 1)))
131
+ return
132
+ }
133
+ if (key.return && list[at] !== undefined) {
134
+ onSelect(list[at])
135
+ }
136
+ })
137
+
138
+ if (viewport.maxHeight === 0 || viewport.compact) {
139
+ const providers = onProviders === undefined ? '' : ' · tab providers'
140
+ const state = filtered.length === 0
141
+ ? directory === undefined && error === undefined
142
+ ? t('panel.model.loading')
143
+ : error !== undefined
144
+ ? t('panel.model.error')
145
+ : query === '' ? t('panel.model.noModels') : t('panel.model.compactNoMatch', { query: singleLineText(query) })
146
+ : `❯ ${filtered[cursor]?.modelName ?? filtered[cursor]?.model ?? ''}`
147
+ const tail = query === ''
148
+ ? t('panel.model.footer.filter')
149
+ : t('panel.model.footer.filtered')
150
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('panel.model.compact', { state, providers, tail }), viewport.contentColumns))
151
+ }
152
+
153
+ const stateRows: ReactElement[] = directory === undefined && error === undefined
154
+ ? [createElement(Text, { key: 'loading', dimColor: true, wrap: 'truncate-end' }, ` ${t('panel.model.loading')}`)]
155
+ : error !== undefined
156
+ ? [createElement(
157
+ Text,
158
+ { key: 'error', color: inkColor(getPalette().error), wrap: 'truncate-end' },
159
+ truncateColumns(` ${singleLineText(error)}`, viewport.contentColumns),
160
+ )]
161
+ : [
162
+ ...(directory?.failures.length === 0
163
+ ? []
164
+ : [createElement(
165
+ Text,
166
+ { key: 'failures', color: inkColor(getPalette().warn), wrap: 'truncate-end' },
167
+ truncateColumns(` ${t('panel.provider.failure', { providers: directory?.failures.join(', ') ?? '' })}`, viewport.contentColumns),
168
+ )]),
169
+ ...(rows.length === 0
170
+ ? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, ` ${t('panel.model.noModels')}`)]
171
+ : filtered.length === 0
172
+ ? [createElement(Text, { key: 'no-match', dimColor: true, wrap: 'truncate-end' }, truncateColumns(` ${t('panel.model.noMatch', { query: singleLineText(query) })}`, viewport.contentColumns))]
173
+ : []),
174
+ ]
175
+ // Measurement and rendering share the same physical-row budget: state
176
+ // messages consume body rows before selectable entries, as in Codex's
177
+ // list-selection views.
178
+ const visibleStateRows = stateRows.slice(0, viewport.bodyRows)
179
+ const rowBudget = Math.max(0, viewport.bodyRows - visibleStateRows.length)
180
+ const first = selectionWindow(cursor, filtered.length, rowBudget)
181
+ const visible = rowBudget === 0 ? [] : filtered.slice(first, first + rowBudget)
182
+ // Column width spans the filtered list, so the alignment holds while the
183
+ // cursor scrolls within one filter result.
184
+ const providerWidth = Math.max(0, ...filtered.map(row => stringWidth(displayText(row.providerName))))
185
+ const accent = panelAccent('model', getPalette().brand)
186
+ return createElement(
187
+ Box,
188
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(accent.border) },
189
+ createElement(Text, { color: inkColor(accent.title), bold: true, wrap: 'truncate-end' }, truncateColumns(query === ''
190
+ ? rows.length === 0 ? t('panel.model.title') : t('panel.model.titleCount', { index: cursor + 1, total: rows.length })
191
+ : t('panel.model.titleMatches', { filtered: filtered.length, total: rows.length, query: singleLineText(query) }), viewport.contentColumns)),
192
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
193
+ ...visibleStateRows,
194
+ ...visible.map((row) => {
195
+ const index = filtered.indexOf(row)
196
+ const capability = row.inputModalities?.includes('image') === true ? ' · image' : ''
197
+ // Table layout: the provider column pads to the filtered list's widest
198
+ // name so every model lines up behind it.
199
+ const label = padColumns(displayText(row.providerName), providerWidth) + ' ' + displayText(row.modelName) + capability
200
+ return createElement(
201
+ Text,
202
+ {
203
+ key: `${row.provider}/${row.model}`,
204
+ color: index === cursor ? inkColor(getPalette().brandBright) : inkColor(getPalette().dim),
205
+ wrap: 'truncate-end',
206
+ },
207
+ truncateColumns(`${index === cursor ? '❯ ' : ' '}${label}`, viewport.contentColumns),
208
+ )
209
+ }),
210
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
211
+ createElement(Text, { wrap: 'truncate-end' }, dim(truncateColumns(query === ''
212
+ ? t('panel.model.footer.select', { providers: onProviders === undefined ? '' : ` · ${t('panel.model.providers')}` })
213
+ : t('panel.model.footer.filteredSelect'), viewport.contentColumns))),
214
+ )
215
+ }
216
+
217
+ /**
218
+ * The provider-state label split into its table columns: the route state
219
+ * (active/dormant) and the credential state (key facts, auth-configured, or
220
+ * missing). The words are the same ones the previous joined label carried;
221
+ * the table layout pads them into aligned columns instead.
222
+ */
223
+ function providerStateParts(row: ProviderTargetView): { route: string; credential: string } {
224
+ const route = row.active ? t('panel.provider.active') : t('panel.provider.dormant')
225
+ const credential = row.credential
226
+ if (credential?.kind === 'error') return { route, credential: t('panel.provider.keyStatusUnavailable') }
227
+ if (credential?.kind === 'facts') {
228
+ if (!credential.configured) return { route, credential: t('panel.provider.noKey') }
229
+ const source = credential.source === undefined ? t('panel.provider.configured') : singleLineText(credential.source)
230
+ return { route, credential: `${t('panel.provider.key', { value: source })}${credential.writable ? '' : ` · ${t('panel.provider.readOnly')}`}` }
231
+ }
232
+ return { route, credential: row.configured ? t('panel.provider.authConfigured') : t('panel.provider.noLogin') }
233
+ }
234
+
235
+ /** One provider row as table cells: identity, route, credential, login, marker. */
236
+ function providerRowCells(
237
+ row: ProviderTargetView,
238
+ authorization: ProviderAuthorizationRow | undefined,
239
+ ): readonly [string, string, string, string, string] {
240
+ const identity = row.displayName === row.provider ? row.provider : row.displayName + ' (' + row.provider + ')'
241
+ const parts = providerStateParts(row)
242
+ const manualKeyConfigured = row.credential?.kind === 'facts' && row.credential.configured
243
+ const showAuthorization = !manualKeyConfigured || authorization?.record.configured === true || authorization?.inFlight === true
244
+ return [
245
+ identity,
246
+ parts.route,
247
+ parts.credential,
248
+ showAuthorization ? providerAuthorizationStatus(authorization) : '',
249
+ row.removable ? 'custom' : '',
250
+ ]
251
+ }
252
+
253
+ /** The provider-management stage reached from /model with `a`. */
254
+ export function ProviderPanel({ directory, error, authorizations, authorizationError, onConfigure, onUnset, onRemove, onRetry, onBack, onExit }: {
255
+ directory: ProviderSettingsDirectory | undefined
256
+ error: string | undefined
257
+ authorizations: ProviderAuthorizationDirectory | undefined
258
+ authorizationError: string | undefined
259
+ onConfigure: (target: ProviderTargetView) => void
260
+ onUnset: (target: ProviderTargetView) => void
261
+ onRemove: (target: ProviderTargetView) => void
262
+ onRetry: () => void
263
+ onBack: () => void
264
+ /** Leave the whole /model flow (Ctrl+C), not just this stage. */
265
+ onExit: () => void
266
+ }): ReactElement {
267
+ const stdout = useStdout().stdout
268
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
269
+ const rows = directory?.rows ?? []
270
+ // Configured providers float to the top so a long dormant tail never buries
271
+ // the ones in use; a dim separator labels the boundary between groups.
272
+ const sorted = [...rows].sort((left, right) =>
273
+ (left.configured ? 0 : 1) - (right.configured ? 0 : 1))
274
+ const configuredCount = sorted.filter(row => row.configured).length
275
+ const hasSeparator = configuredCount > 0 && configuredCount < sorted.length
276
+ const [cursor, setCursor] = useState(0)
277
+ const [actionError, setActionError] = useState<string | undefined>(undefined)
278
+
279
+ useEffect(() => {
280
+ if (rows.length === 0) {
281
+ if (cursor !== 0) setCursor(0)
282
+ return
283
+ }
284
+ if (cursor >= rows.length) setCursor(rows.length - 1)
285
+ }, [rows.length, cursor])
286
+
287
+ useStableInput((input, key) => {
288
+ if (key.escape || input === 'q') {
289
+ onBack()
290
+ return
291
+ }
292
+ if (key.ctrl && input === 'c') {
293
+ onExit()
294
+ return
295
+ }
296
+ if (input === 'r') {
297
+ setActionError(undefined)
298
+ onRetry()
299
+ return
300
+ }
301
+ if (rows.length === 0) return
302
+ if (key.upArrow) {
303
+ setActionError(undefined)
304
+ setCursor(cursor > 0 ? cursor - 1 : rows.length - 1)
305
+ return
306
+ }
307
+ if (key.downArrow) {
308
+ setActionError(undefined)
309
+ setCursor(cursor < rows.length - 1 ? cursor + 1 : 0)
310
+ return
311
+ }
312
+ if (key.pageUp) {
313
+ setActionError(undefined)
314
+ setCursor(current => Math.max(0, current - Math.max(1, viewport.bodyRows - 1)))
315
+ return
316
+ }
317
+ if (key.pageDown) {
318
+ setActionError(undefined)
319
+ setCursor(current => Math.min(rows.length - 1, current + Math.max(1, viewport.bodyRows - 1)))
320
+ return
321
+ }
322
+ const target = sorted[cursor]
323
+ if (target === undefined) return
324
+ if (input === 'd') {
325
+ const facts = target.credential
326
+ if (facts?.kind !== 'facts' || !facts.configured) {
327
+ setActionError(t('panel.provider.noConfiguredKey'))
328
+ } else if (!facts.writable) {
329
+ setActionError(t('panel.provider.readOnlyKey'))
330
+ } else {
331
+ onUnset(target)
332
+ }
333
+ return
334
+ }
335
+ if (input === 'x') {
336
+ if (!target.removable) {
337
+ setActionError(t('panel.provider.notRemovable'))
338
+ } else {
339
+ onRemove(target)
340
+ }
341
+ return
342
+ }
343
+ // Enter opens the unified setup page (key, endpoint, models, discovery,
344
+ // and the subscription channel when the provider offers one):
345
+ // the old split — Enter for the key alone, Tab for the deep menu — hid
346
+ // the configuration surface behind an undiscoverable chord.
347
+ if (key.return) {
348
+ if (target.settingsNs.length === 0) {
349
+ setActionError(t('panel.provider.notManaged'))
350
+ } else {
351
+ onConfigure(target)
352
+ }
353
+ }
354
+ }, true)
355
+
356
+ if (viewport.maxHeight === 0 || viewport.compact) {
357
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('panel.providers.compact'), viewport.contentColumns))
358
+ }
359
+ const stateRows: ReactElement[] = directory === undefined && error === undefined
360
+ ? [createElement(Text, { key: 'loading', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, ` ${t('panel.provider.loading')}`)]
361
+ : error !== undefined
362
+ ? [createElement(Text, { key: 'error', color: inkColor(getPalette().error), wrap: 'truncate-end' }, truncateColumns(` ${singleLineText(error)}`, viewport.contentColumns))]
363
+ : [
364
+ ...(actionError === undefined
365
+ ? []
366
+ : [createElement(Text, { key: 'action-error', color: inkColor(getPalette().error), wrap: 'truncate-end' }, truncateColumns(` ${actionError}`, viewport.contentColumns))]),
367
+ ...(directory?.failures ?? []).map((failure, index) => createElement(
368
+ Text,
369
+ { key: `failure-${index}`, color: inkColor(getPalette().warn), wrap: 'truncate-end' },
370
+ truncateColumns(` ${singleLineText(failure)}`, viewport.contentColumns),
371
+ )),
372
+ ...(authorizationError === undefined
373
+ ? []
374
+ : [createElement(Text, { key: 'authorization-error', color: inkColor(getPalette().warn), wrap: 'truncate-end' }, truncateColumns(` ${t('panel.provider.loginStatusUnavailable', { message: singleLineText(authorizationError) })}`, viewport.contentColumns))]),
375
+ ...(authorizations?.failures ?? []).map((failure, index) => createElement(
376
+ Text,
377
+ { key: `authorization-failure-${index}`, color: inkColor(getPalette().warn), wrap: 'truncate-end' },
378
+ truncateColumns(` ${singleLineText(failure)}`, viewport.contentColumns),
379
+ )),
380
+ ...(rows.length === 0
381
+ ? [createElement(Text, { key: 'empty', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, ` ${t('panel.provider.empty')}`)]
382
+ : []),
383
+ ]
384
+ const visibleStateRows = stateRows.slice(0, viewport.bodyRows)
385
+ const rowBudget = Math.max(0, viewport.bodyRows - visibleStateRows.length)
386
+ const displayLength = sorted.length + (hasSeparator ? 1 : 0)
387
+ const displayCursor = cursor + (hasSeparator && cursor >= configuredCount ? 1 : 0)
388
+ const first = selectionWindow(displayCursor, displayLength, rowBudget)
389
+ // Column widths span the whole list, so alignment holds while scrolling.
390
+ const widths = [0, 0, 0, 0] as [number, number, number, number]
391
+ for (const row of sorted) {
392
+ const cells = providerRowCells(row, authorizationForProvider(authorizations, row.provider))
393
+ for (let column = 0; column < 4; column += 1) {
394
+ widths[column] = Math.max(widths[column], stringWidth(cells[column]))
395
+ }
396
+ }
397
+ const itemRows: ReactElement[] = []
398
+ for (let display = first; display < first + rowBudget && display < displayLength; display += 1) {
399
+ if (hasSeparator && display === configuredCount) {
400
+ itemRows.push(createElement(Text, { key: 'separator', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(` ${t('panel.provider.notConfiguredDivider')}`, viewport.contentColumns)))
401
+ continue
402
+ }
403
+ const index = hasSeparator && display > configuredCount ? display - 1 : display
404
+ const row = sorted[index]
405
+ if (row === undefined) continue
406
+ // Table layout: every cell aligns in its own column so a long name or
407
+ // key source cannot push the next field out of line. Widths come from
408
+ // the whole list (stable while scrolling); the marker column is last and
409
+ // unpadded, and the diagnostic rides after it unclipped.
410
+ const authorization = authorizationForProvider(authorizations, row.provider)
411
+ const cells = providerRowCells(row, authorization)
412
+ const label = padColumns(cells[0], widths[0]) + ' '
413
+ + padColumns(cells[1], widths[1]) + ' '
414
+ + padColumns(cells[2], widths[2]) + ' '
415
+ + padColumns(cells[3], widths[3]) + ' '
416
+ + cells[4]
417
+ + (row.diagnostic === undefined ? '' : ' ! ' + singleLineText(row.diagnostic))
418
+ // Configured rows render in the intermediate brand blue so the in-use
419
+ // group reads at a glance; the dormant tail keeps the dim caption gray.
420
+ const idleColor = row.configured ? inkColor(getPalette().brandMid) : inkColor(getPalette().dim)
421
+ itemRows.push(createElement(
422
+ Text,
423
+ { key: row.provider, color: index === cursor ? inkColor(getPalette().brandBright) : idleColor, wrap: 'truncate-end' },
424
+ truncateColumns((index === cursor ? '❯ ' : ' ') + displayText(label), viewport.contentColumns),
425
+ ))
426
+ }
427
+ const accent = panelAccent('model-providers', getPalette().brand)
428
+ return createElement(
429
+ Box,
430
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(accent.border) },
431
+ createElement(Text, { color: inkColor(accent.title), bold: true, wrap: 'truncate-end' }, truncateColumns(rows.length === 0 ? t('panel.provider.title') : t('panel.provider.titleCount', { index: cursor + 1, total: rows.length }), viewport.contentColumns)),
432
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
433
+ ...visibleStateRows,
434
+ ...itemRows,
435
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
436
+ createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(t('panel.providers.footer'), viewport.contentColumns)),
437
+ )
438
+ }
439
+
440
+ /** Provider configuration editor: only explicit models are written to settings. */
441
+ /**
442
+ * The unified provider setup page: API key, endpoint, and the explicit model
443
+ * list (with per-model context/output capacities) on ONE screen — the deep
444
+ * Tab menu and the separate key panel merged into a single discoverable
445
+ * surface. A saved key rides the same Enter as the endpoint and models; an
446
+ * empty endpoint keeps the provider's official default. Tab moves to the
447
+ * discovery page, which interrogates the real endpoint and returns checkable
448
+ * models for adoption; the last row also accepts hand-typed model ids.
449
+ */
450
+ /** One declarable donor the setup page can copy reasoning efforts from verbatim. */
451
+ export interface EffortDonor {
452
+ /** Provider route the declaration lives on. */
453
+ readonly provider: string
454
+ /** Model id the declaration belongs to. */
455
+ readonly id: string
456
+ /** The stored display-level to wire-value map, copied verbatim. */
457
+ readonly efforts: Record<string, string | null>
458
+ }
459
+
460
+ export function ProviderSetupPanel({ target, authorization, onSubscribe, save, saveCredential, discover, effortDonors, done, back, onExit }: {
461
+ target: ProviderTargetView
462
+ /**
463
+ * The provider's sign-in flow, when it offers a web (OAuth) channel: its
464
+ * presence adds the subscription/key mode layer at the top of the page.
465
+ */
466
+ authorization: ProviderAuthorizationRow | undefined
467
+ /** Open the subscription channel: sign in when logged out, confirm sign-out when logged in. */
468
+ onSubscribe: (() => void) | undefined
469
+ /** Models with declared efforts (settings first, catalog-advertised after) a model row can copy from. */
470
+ effortDonors: readonly EffortDonor[]
471
+ save: (target: ProviderTargetView, configuration: ProviderConfiguration) => Promise<void>
472
+ saveCredential: ((target: ProviderTargetView, key: string) => Promise<void>) | undefined
473
+ discover: (target: ProviderTargetView, request: { readonly apiKey?: string; readonly baseURL?: string }, signal?: AbortSignal) => Promise<readonly DiscoveredModelView[]>
474
+ /** Report a successful save so the surface can notice the key rotation. */
475
+ done: (result: { readonly key: boolean }) => void
476
+ back: () => void
477
+ /** Leave the whole /model flow (Ctrl+C), not just this page. */
478
+ onExit: () => void
479
+ }): ReactElement {
480
+ const stdout = useStdout().stdout
481
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
482
+ const [page, setPage] = useState<'setup' | 'discover' | 'donor'>('setup')
483
+ /** Whether the provider offers a web (OAuth) sign-in channel. */
484
+ const hasSubscription = authorization !== undefined
485
+ && authorization.methods.some(method => method.id === 'oauth')
486
+ && onSubscribe !== undefined
487
+ const [keyDraft, setKeyDraft] = useState('')
488
+ const [baseURL, setBaseURL] = useState(target.configuration.baseURL ?? '')
489
+ const [models, setModels] = useState<readonly ProviderModelSettings[]>(target.configuration.models)
490
+ const [cursor, setCursor] = useState(0)
491
+ const [zone, setZone] = useState<'mode' | 'key' | 'url' | 'models'>(hasSubscription ? 'mode' : 'key')
492
+ const [field, setField] = useState<'none' | 'ctx' | 'out'>('none')
493
+ const [addDraft, setAddDraft] = useState('')
494
+ /** Micro-editor for the selected model's reasoningEfforts declaration. */
495
+ const [effEditing, setEffEditing] = useState(false)
496
+ const [effDraft, setEffDraft] = useState('')
497
+ const [donorCursor, setDonorCursor] = useState(0)
498
+ const [busy, setBusy] = useState(false)
499
+ const [error, setError] = useState<string | undefined>(undefined)
500
+ const credential = target.credential
501
+ /** The mode layer's selection: 0 = subscription channel, 1 = key channel. */
502
+ const [modeSel, setModeSel] = useState(
503
+ authorization?.record.configured === true ? 0
504
+ : credential?.kind === 'facts' && credential.configured ? 1
505
+ : 0,
506
+ )
507
+ const keyStatus = saveCredential === undefined
508
+ ? 'key storage unavailable'
509
+ : credential?.kind === 'error'
510
+ ? 'key status unavailable'
511
+ : credential?.kind === 'facts' && credential.configured
512
+ ? 'key saved' + (credential.source === undefined ? '' : ' · ' + credential.source)
513
+ : 'no key set'
514
+ // A dormant route (no resolved profile yet, so no credential facts) may
515
+ // still receive a key: the save path materializes the apiKeyEnv reference
516
+ // itself. Only a known-unwritable or indescribable credential blocks.
517
+ const keyEditable = saveCredential !== undefined
518
+ && (credential === undefined || (credential.kind === 'facts' && credential.writable))
519
+ const onAddRow = cursor >= models.length
520
+ const selected = onAddRow ? undefined : models[cursor]
521
+
522
+ const updateSelected = (change: Partial<ProviderModelSettings>): void => {
523
+ if (selected === undefined) return
524
+ setModels(current => current.map((model, index) => index === cursor ? { ...model, ...change } : model))
525
+ }
526
+
527
+ const commitAddDraft = (): void => {
528
+ const id = addDraft.trim()
529
+ if (id === '') return
530
+ if (models.some(model => model.id === id)) {
531
+ setError('model "' + id + '" is already in the list')
532
+ return
533
+ }
534
+ setError(undefined)
535
+ setModels([...models, { id }])
536
+ setCursor(models.length)
537
+ setAddDraft('')
538
+ }
539
+
540
+ /** Compact declaration summary for the row label: count, off, or inherit. */
541
+ const effortsSummary = (model: ProviderModelSettings): string => {
542
+ const raw = (model.extras)?.reasoningEfforts
543
+ if (raw === false) return 'off'
544
+ if (isDeclaredReasoningEfforts(raw)) return String(Object.keys(raw).length)
545
+ return '~'
546
+ }
547
+
548
+ /** Write (or clear) the selected model's declaration through extras. */
549
+ const applyDeclaration = (value: ReasoningEffortsValue): void => {
550
+ setModels(current => current.map((model, index) => {
551
+ if (index !== cursor) return model
552
+ const extras: Record<string, unknown> = { ...model.extras }
553
+ if (value === undefined) delete extras.reasoningEfforts
554
+ else extras.reasoningEfforts = value
555
+ return { ...model, ...Object.keys(extras).length === 0 ? {} : { extras } }
556
+ }))
557
+ }
558
+
559
+ /** Donors excluding the row being edited (copying from itself is a no-op). */
560
+ const donorRows = selected === undefined
561
+ ? []
562
+ : effortDonors.filter(donor => !(donor.provider === target.provider && donor.id === selected.id))
563
+ const donorIndex = Math.min(donorCursor, Math.max(0, donorRows.length - 1))
564
+ const donorRow = donorRows[donorIndex]
565
+
566
+ const submit = (): void => {
567
+ if (busy) return
568
+ const key = keyDraft.trim()
569
+ // A typed key must never vanish silently: when it cannot be written here
570
+ // (read-only env supply, or credential status failed to describe), refuse
571
+ // the whole save with one actionable line instead of saving the endpoint
572
+ // and models while dropping the key the user believes was stored.
573
+ if (key !== '' && !keyEditable) {
574
+ setError('this API key cannot be written here (read-only or status unavailable); clear the key field to save the endpoint and models alone')
575
+ return
576
+ }
577
+ setBusy(true)
578
+ setError(undefined)
579
+ const keySave = key !== '' ? saveCredential : undefined
580
+ void (async () => {
581
+ if (keySave !== undefined) await keySave(target, key)
582
+ await save(target, { ...(baseURL.trim() === '' ? {} : { baseURL }), models })
583
+ return keySave !== undefined
584
+ })().then(keySaved => done({ key: keySaved }), (reason: unknown) => {
585
+ setBusy(false)
586
+ setError(singleLineText(reason instanceof Error ? reason.message : String(reason)))
587
+ })
588
+ }
589
+
590
+ useStableInput((input, key) => {
591
+ if (busy) return
592
+ // Ctrl+C leaves the whole model configuration flow from any stage.
593
+ if (key.ctrl && input === 'c') {
594
+ onExit()
595
+ return
596
+ }
597
+ // Efforts micro-editor: consumes every key while open (space is the
598
+ // pair separator, so the composer-style remove must not fire here).
599
+ if (effEditing) {
600
+ if (key.escape) { setEffEditing(false); setEffDraft(''); return }
601
+ if (key.return) {
602
+ const parsed = parseReasoningEffortsDraft(effDraft)
603
+ if (!parsed.ok) { setError(parsed.error); return }
604
+ setError(undefined)
605
+ applyDeclaration(parsed.value)
606
+ setEffEditing(false)
607
+ setEffDraft('')
608
+ return
609
+ }
610
+ if (key.backspace || key.delete) { setError(undefined); setEffDraft(current => deleteLastGrapheme(current)); return }
611
+ if (key.ctrl && input === 'u') { setError(undefined); setEffDraft(''); return }
612
+ if (key.ctrl || key.meta || input.length === 0) return
613
+ if (effDraft.length > 200) { setError('efforts draft is too long'); return }
614
+ setError(undefined)
615
+ setEffDraft(current => current + stripPasteMarkers(input))
616
+ return
617
+ }
618
+ // Donor picker: one page, up/down move, enter copies verbatim.
619
+ if (page === 'donor') {
620
+ if (key.escape || input === 'q') { setPage('setup'); return }
621
+ if (donorRows.length === 0) return
622
+ if (key.upArrow) { setDonorCursor(current => current > 0 ? current - 1 : donorRows.length - 1); return }
623
+ if (key.downArrow) { setDonorCursor(current => current < donorRows.length - 1 ? current + 1 : 0); return }
624
+ if (key.return && donorRow !== undefined) {
625
+ applyDeclaration(donorRow.efforts)
626
+ setError(undefined)
627
+ setPage('setup')
628
+ }
629
+ return
630
+ }
631
+ if (key.escape || input === 'q') { back(); return }
632
+ // Tab serves the selected channel: the subscription channel signs in
633
+ // through the browser, the key channel interrogates the endpoint.
634
+ if (key.tab) {
635
+ if (hasSubscription && modeSel === 0) { onSubscribe(); return }
636
+ setPage('discover'); return
637
+ }
638
+ if (key.return && !(zone === 'mode' && modeSel === 0 && hasSubscription)) { submit(); return }
639
+ if (zone === 'mode' && hasSubscription) {
640
+ if (key.upArrow || key.downArrow) { setModeSel(current => (current + 1) % 2); return }
641
+ if (key.return) { onSubscribe(); return }
642
+ return
643
+ }
644
+ if (zone === 'key') {
645
+ // Typing stays available even when the key cannot be written here (a
646
+ // read-only env supply, or a describe failure): the draft is local, and
647
+ // Enter refuses the save with one actionable line instead of silently
648
+ // dropping what the user typed.
649
+ if (key.upArrow && hasSubscription) { setZone('mode'); return }
650
+ if (key.downArrow) { setZone('url'); return }
651
+ if (key.backspace || key.delete) { setError(undefined); setKeyDraft(current => [...current].slice(0, -1).join('')); return }
652
+ if (key.ctrl && input === 'u') { setError(undefined); setKeyDraft(''); return }
653
+ if (key.ctrl || key.meta || input.length === 0) return
654
+ const next = keyDraft + stripPasteMarkers(input)
655
+ if (next.length > 4096) { setError('API key input is too long'); return }
656
+ setError(undefined)
657
+ setKeyDraft(next)
658
+ return
659
+ }
660
+ if (zone === 'url') {
661
+ if (key.upArrow) { setZone('key'); return }
662
+ if (key.downArrow) { setZone('models'); return }
663
+ if (key.backspace || key.delete) setBaseURL(current => deleteLastGrapheme(current))
664
+ else if (!key.ctrl && !key.meta && input !== '') setBaseURL(current => current + stripPasteMarkers(input))
665
+ return
666
+ }
667
+ // Models zone: the explicit list plus the hand-add row below it.
668
+ if (key.upArrow) {
669
+ setError(undefined)
670
+ if (cursor === 0) setZone('url')
671
+ else { setCursor(current => current - 1); setField('none') }
672
+ return
673
+ }
674
+ if (key.downArrow) {
675
+ setError(undefined)
676
+ if (!onAddRow) { setCursor(current => current + 1); setField('none') }
677
+ return
678
+ }
679
+ if (key.leftArrow || key.rightArrow) {
680
+ if (selected === undefined) return
681
+ const cycle = key.rightArrow
682
+ ? (current: 'none' | 'ctx' | 'out') => current === 'none' ? 'ctx' : current === 'ctx' ? 'out' : 'none'
683
+ : (current: 'none' | 'ctx' | 'out') => current === 'none' ? 'out' : current === 'out' ? 'ctx' : 'none'
684
+ setField(current => cycle(current))
685
+ return
686
+ }
687
+ if (input === ' ') {
688
+ if (selected === undefined) commitAddDraft()
689
+ else {
690
+ setError(undefined)
691
+ setField('none')
692
+ setModels(current => current.filter((_model, index) => index !== cursor))
693
+ setCursor(current => Math.min(current, Math.max(0, models.length - 1)))
694
+ }
695
+ return
696
+ }
697
+ if (onAddRow) {
698
+ if (key.backspace || key.delete) { setError(undefined); setAddDraft(current => deleteLastGrapheme(current)); return }
699
+ if (key.ctrl || key.meta || input.length === 0) return
700
+ setError(undefined)
701
+ setAddDraft(current => current + stripPasteMarkers(input))
702
+ return
703
+ }
704
+ if (input === 'e' && selected !== undefined) {
705
+ setError(undefined)
706
+ setEffDraft(serializeReasoningEfforts((selected.extras)?.reasoningEfforts))
707
+ setEffEditing(true)
708
+ return
709
+ }
710
+ if ((input === 'c' || input === 'C') && !key.ctrl && selected !== undefined) {
711
+ setError(undefined)
712
+ setDonorCursor(0)
713
+ setPage('donor')
714
+ return
715
+ }
716
+ if (field !== 'none' && selected !== undefined) {
717
+ const name = field === 'ctx' ? 'contextWindow' : 'maxTokens'
718
+ const current = String(selected[name] ?? '')
719
+ if (key.backspace || key.delete) {
720
+ const next = current.slice(0, -1)
721
+ updateSelected({ [name]: next === '' ? undefined : Number(next) })
722
+ } else {
723
+ // A pasted number arrives as one multi-character chunk; accept the
724
+ // whole digit run instead of the single-character path only.
725
+ const digits = stripPasteMarkers(input)
726
+ if (/^[0-9]+$/u.test(digits)) updateSelected({ [name]: Number(current + digits) })
727
+ }
728
+ }
729
+ }, page !== 'discover')
730
+ if (viewport.maxHeight === 0 || viewport.bodyRows < 3) {
731
+ // Never hide a live input surface: one visible row keeps the escape
732
+ // route honest on extremely short terminals (the three fixed rows - key,
733
+ // url, add-by-id - cannot fit below a three-row body).
734
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('panel.setup.compact'), viewport.contentColumns))
735
+ }
736
+ if (page === 'donor') {
737
+ const stateRow = donorRows.length === 0
738
+ ? createElement(Text, { key: 'empty', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(` ${t('panel.setup.noDonors')}`, viewport.contentColumns))
739
+ : createElement(Text, { key: 'hint', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(` ${t('panel.setup.copyInto', { id: displayText(selected?.id ?? '') })}`, viewport.contentColumns))
740
+ const donorBudget = Math.max(0, viewport.bodyRows - 2)
741
+ const donorFirst = selectionWindow(donorIndex, donorRows.length, donorBudget)
742
+ const donorVisible = donorRows.slice(donorFirst, donorFirst + donorBudget)
743
+ const accent = panelAccent('model-efforts', getPalette().brand)
744
+ return createElement(
745
+ Box,
746
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(accent.border) },
747
+ createElement(Text, { color: inkColor(accent.title), bold: true, wrap: 'truncate-end' }, truncateColumns(t('panel.setup.copyTitle'), viewport.contentColumns)),
748
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
749
+ stateRow,
750
+ ...donorVisible.map((donor, index) => {
751
+ const active = donorFirst + index === donorIndex
752
+ const label = (active ? '>' : ' ') + ' ' + donor.provider + '/' + displayText(donor.id) + ' · ' + serializeReasoningEfforts(donor.efforts)
753
+ return createElement(Text, { key: donor.provider + '/' + donor.id, color: active ? inkColor(getPalette().brandBright) : inkColor(getPalette().text), wrap: 'truncate-end' }, truncateColumns(label, viewport.contentColumns))
754
+ }),
755
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
756
+ createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(t('panel.setup.copyFooter'), viewport.contentColumns)),
757
+ )
758
+ }
759
+ if (page === 'discover') {
760
+ return createElement(ProviderDiscoveryPanel, {
761
+ target,
762
+ baseURL,
763
+ apiKey: keyDraft,
764
+ configured: models.map(model => model.id),
765
+ discover,
766
+ onAdopt: adopted => {
767
+ const existing = new Set(models.map(model => model.id))
768
+ const fresh = adopted.filter(model => !existing.has(model.id))
769
+ if (fresh.length > 0) {
770
+ setModels([...models, ...fresh.map(model => ({
771
+ id: model.id,
772
+ ...model.name === undefined ? {} : { name: model.name },
773
+ ...model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow },
774
+ ...model.maxTokens === undefined ? {} : { maxTokens: model.maxTokens },
775
+ }))])
776
+ setCursor(models.length)
777
+ }
778
+ setPage('setup')
779
+ },
780
+ back: () => setPage('setup'),
781
+ onExit,
782
+ })
783
+ }
784
+ const stateRows = error === undefined ? [] : [createElement(Text, { key: 'error', color: inkColor(getPalette().error), wrap: 'truncate-end' }, truncateColumns(' ' + error, viewport.contentColumns))]
785
+ const keyBullets = '•'.repeat(Math.min([...keyDraft].length, Math.max(1, viewport.contentColumns - 14)))
786
+ const keyRow = createElement(Text, { key: 'key', color: zone === 'key' ? inkColor(getPalette().brandBright) : inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns((' ' + (zone === 'key' ? '>' : ' ') + ' key ' + keyBullets + (zone === 'key' && !busy ? '▏' : '') + (keyDraft === '' ? ' (' + keyStatus + ')' : busy ? ' saving…' : '')).replace(/ +$/u, ''), viewport.contentColumns))
787
+ const urlRow = createElement(Text, { key: 'url', color: zone === 'url' ? inkColor(getPalette().brandBright) : inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(' ' + (zone === 'url' ? '>' : ' ') + ' url ' + (baseURL === '' ? '(official default)' : baseURL) + (zone === 'url' ? '▏' : ''), viewport.contentColumns))
788
+ // The fixed diagnostic row (present only with an adapter error) joins the
789
+ // same height budget as the state rows — it must never overflow the panel.
790
+ const rowBudget = Math.max(0, viewport.bodyRows - stateRows.length - (target.diagnostic === undefined ? 0 : 1) - 3)
791
+ const first = selectionWindow(cursor, models.length + 1, rowBudget)
792
+ const modelRows: ReactElement[] = []
793
+ // Table columns: the id, the context window, and the output window each
794
+ // pad to the list's widest value so every row's fields line up. The active
795
+ // field's editing brackets occupy the padding, so marking never shifts the
796
+ // columns beside it.
797
+ const idWidth = Math.max(0, ...models.map(model => stringWidth(model.id)))
798
+ const contextWidth = Math.max(0, ...models.map(model => stringWidth(model.contextWindow === undefined ? '-' : String(model.contextWindow))))
799
+ const outputWidth = Math.max(0, ...models.map(model => stringWidth(model.maxTokens === undefined ? '-' : String(model.maxTokens))))
800
+ for (let index = first; index < first + Math.max(0, Math.min(models.length + 1 - first, rowBudget)); index += 1) {
801
+ if (index >= models.length) {
802
+ modelRows.push(createElement(Text, { key: 'add', color: cursor === index ? inkColor(getPalette().brandBright) : inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(' ' + (cursor === index ? '>' : ' ') + ' + add by id' + (addDraft === '' ? '' : ' ' + addDraft + '▏'), viewport.contentColumns)))
803
+ continue
804
+ }
805
+ const model = models[index]
806
+ const active = index === cursor
807
+ const context = model.contextWindow === undefined ? '-' : String(model.contextWindow)
808
+ const output = model.maxTokens === undefined ? '-' : String(model.maxTokens)
809
+ const contextCell = active && field === 'ctx'
810
+ ? padColumns('[' + context + ']', contextWidth + 2)
811
+ : padColumns(context, contextWidth + 2)
812
+ const outputCell = active && field === 'out'
813
+ ? padColumns('[' + output + ']', outputWidth + 2)
814
+ : padColumns(output, outputWidth + 2)
815
+ const efforts = active && effEditing
816
+ ? 'eff:' + effDraft + '▏'
817
+ : 'eff:' + effortsSummary(model)
818
+ modelRows.push(createElement(Text, { key: model.id, color: active ? inkColor(getPalette().brandBright) : inkColor(getPalette().success), wrap: 'truncate-end' }, truncateColumns(' ' + (active ? '>' : ' ') + ' [x] ' + padColumns(displayText(model.id), idWidth) + ' in:' + contextCell + ' out:' + outputCell + ' ' + efforts, viewport.contentColumns)))
819
+ }
820
+ const accent = panelAccent('model-configure', getPalette().brand)
821
+ return createElement(
822
+ Box,
823
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(accent.border) },
824
+ createElement(Text, { color: inkColor(accent.title), bold: true, wrap: 'truncate-end' }, truncateColumns(t('panel.setup.title', { provider: target.displayName }), viewport.contentColumns)),
825
+ // The adapter's configuration diagnostic heads the editor: the provider
826
+ // is here precisely because it stayed listed for repair.
827
+ ...(target.diagnostic === undefined ? [] : [createElement(
828
+ Text,
829
+ { key: 'diagnostic', color: inkColor(getPalette().warn), wrap: 'truncate-end' },
830
+ truncateColumns('! ' + displayText(singleLineText(target.diagnostic)), viewport.contentColumns),
831
+ )]),
832
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
833
+ ...(hasSubscription
834
+ ? [
835
+ createElement(Text, { key: 'mode-sub', color: zone === 'mode' && modeSel === 0 ? inkColor(getPalette().brandBright) : inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(' ' + (zone === 'mode' && modeSel === 0 ? '>' : ' ') + ' ' + t('panel.setup.mode.subscribe') + ' ' + (authorization.inFlight ? t('panel.setup.mode.login.running') : authorization.record.configured ? t('panel.setup.mode.login.plan') : t('panel.setup.mode.login.none')), viewport.contentColumns)),
836
+ createElement(Text, { key: 'mode-key', color: zone === 'mode' && modeSel === 1 ? inkColor(getPalette().brandBright) : inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(' ' + (zone === 'mode' && modeSel === 1 ? '>' : ' ') + ' ' + t('panel.setup.mode.key') + ' ' + keyStatus, viewport.contentColumns)),
837
+ ...(authorization.record.configured && credential?.kind === 'facts' && credential.configured
838
+ ? [createElement(Text, { key: 'mode-override', color: inkColor(getPalette().warn), wrap: 'truncate-end' }, truncateColumns(' ' + t('panel.setup.mode.overridden'), viewport.contentColumns))]
839
+ : []),
840
+ createElement(Text, { key: 'mode-divider', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(' ' + '─'.repeat(Math.max(0, viewport.contentColumns - 2)), viewport.contentColumns)),
841
+ ]
842
+ : []),
843
+ keyRow,
844
+ urlRow,
845
+ ...stateRows,
846
+ ...modelRows,
847
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
848
+ createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(t('panel.setup.footer'), viewport.contentColumns)),
849
+ )
850
+ }
851
+
852
+ /**
853
+ * The discovery stage of the provider setup page: interrogates the endpoint
854
+ * the drafts describe (typed key wins over the stored credential) and offers
855
+ * the advertised models as a checkable list. Already-configured ids render
856
+ * verified but untoggleable; Enter adopts every checked model back into the
857
+ * setup page's list — selective adoption, never a bulk import.
858
+ */
859
+ function ProviderDiscoveryPanel({ target, baseURL, apiKey, configured, discover, onAdopt, back, onExit }: {
860
+ target: ProviderTargetView
861
+ baseURL: string
862
+ apiKey: string
863
+ configured: readonly string[]
864
+ discover: (target: ProviderTargetView, request: { readonly apiKey?: string; readonly baseURL?: string }, signal?: AbortSignal) => Promise<readonly DiscoveredModelView[]>
865
+ onAdopt: (models: readonly DiscoveredModelView[]) => void
866
+ back: () => void
867
+ /** Leave the whole /model flow (Ctrl+C). */
868
+ onExit: () => void
869
+ }): ReactElement {
870
+ const stdout = useStdout().stdout
871
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
872
+ const [epoch, setEpoch] = useState(0)
873
+ const [loading, setLoading] = useState(true)
874
+ const [error, setError] = useState<string | undefined>(undefined)
875
+ const [rows, setRows] = useState<readonly DiscoveredModelView[]>([])
876
+ const [checked, setChecked] = useState<ReadonlySet<string>>(new Set())
877
+ const [cursor, setCursor] = useState(0)
878
+ useEffect(() => {
879
+ // One probe per epoch (mount and explicit 'f'); leaving the page aborts.
880
+ // The drafts are captured when the page opened — adopting unmounts this
881
+ // stage, so re-renders must not re-interrogate the endpoint.
882
+ const controller = new AbortController()
883
+ setLoading(true)
884
+ setError(undefined)
885
+ discover(target, {
886
+ ...(baseURL.trim() === '' ? {} : { baseURL: baseURL.trim() }),
887
+ ...(apiKey.trim() === '' ? {} : { apiKey: apiKey.trim() }),
888
+ }, controller.signal).then(discovered => {
889
+ if (controller.signal.aborted) return
890
+ setRows(discovered)
891
+ const alreadyKnown = new Set(configured)
892
+ const firstNew = discovered.findIndex(model => !alreadyKnown.has(model.id))
893
+ setCursor(firstNew < 0 ? 0 : firstNew)
894
+ setLoading(false)
895
+ }, (reason: unknown) => {
896
+ if (controller.signal.aborted) return
897
+ setError(singleLineText(reason instanceof Error ? reason.message : String(reason)))
898
+ setLoading(false)
899
+ })
900
+ return () => { controller.abort() }
901
+ // eslint-disable-next-line react-hooks/exhaustive-deps
902
+ }, [epoch])
903
+ const known = new Set(configured)
904
+ useStableInput((input, key) => {
905
+ if (key.escape || input === 'q') { back(); return }
906
+ if (key.ctrl && input === 'c') { onExit(); return }
907
+ if (input === 'f') { setChecked(new Set()); setEpoch(current => current + 1); return }
908
+ if (loading || error !== undefined) return
909
+ if (rows.length === 0) return
910
+ if (key.upArrow) { setCursor(current => current > 0 ? current - 1 : rows.length - 1); return }
911
+ if (key.downArrow) { setCursor(current => current < rows.length - 1 ? current + 1 : 0); return }
912
+ const row = rows[cursor]
913
+ if (row === undefined) return
914
+ if (input === ' ') {
915
+ if (known.has(row.id)) return
916
+ setChecked(current => {
917
+ const next = new Set(current)
918
+ if (next.has(row.id)) next.delete(row.id)
919
+ else next.add(row.id)
920
+ return next
921
+ })
922
+ return
923
+ }
924
+ if (key.return) {
925
+ onAdopt(rows.filter(model => checked.has(model.id)))
926
+ }
927
+ }, true)
928
+ if (viewport.maxHeight === 0) {
929
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('panel.discovery.compact'), viewport.contentColumns))
930
+ }
931
+ const stateRows = loading
932
+ ? [createElement(Text, { key: 'loading', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(` ${t('panel.discovery.loading')}`, viewport.contentColumns))]
933
+ : error !== undefined
934
+ ? [createElement(Text, { key: 'error', color: inkColor(getPalette().error), wrap: 'truncate-end' }, truncateColumns(' ' + error, viewport.contentColumns))]
935
+ : rows.length === 0
936
+ ? [createElement(Text, { key: 'empty', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(` ${t('panel.discovery.empty')}`, viewport.contentColumns))]
937
+ : [createElement(Text, { key: 'summary', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(` ${t('panel.discovery.summary', { advertised: rows.length, newCount: rows.filter(model => !known.has(model.id)).length, checked: checked.size })}`, viewport.contentColumns))]
938
+ // One spare row keeps the panel strictly below maxHeight even with the
939
+ // gap collapsed (the at-equality regime makes Ink rewrite Static).
940
+ const rowBudget = Math.max(0, viewport.bodyRows - stateRows.length - 1)
941
+ const first = selectionWindow(cursor, rows.length, rowBudget)
942
+ const visible = rows.slice(first, first + rowBudget)
943
+ const accent = panelAccent('model-discover', getPalette().brand)
944
+ return createElement(
945
+ Box,
946
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(accent.border) },
947
+ createElement(Text, { color: inkColor(accent.title), bold: true, wrap: 'truncate-end' }, truncateColumns(t('panel.discovery.title', { provider: target.displayName }), viewport.contentColumns)),
948
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
949
+ ...stateRows,
950
+ ...visible.map((model, index) => {
951
+ const absolute = first + index
952
+ const active = absolute === cursor
953
+ const added = known.has(model.id)
954
+ const mark = added ? '✓' : checked.has(model.id) ? '☑' : '☐'
955
+ const label = (active ? '>' : ' ') + ' ' + mark + ' ' + displayText(model.id) + (model.name === undefined || model.name === model.id ? '' : ' · ' + displayText(model.name))
956
+ return createElement(Text, { key: model.id, color: added ? inkColor(getPalette().dim) : active ? inkColor(getPalette().brandBright) : inkColor(getPalette().text), wrap: 'truncate-end' }, truncateColumns(label, viewport.contentColumns))
957
+ }),
958
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
959
+ createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(t('panel.discovery.footer'), viewport.contentColumns)),
960
+ )
961
+ }
962
+
963
+ /** Bounded destructive-action confirmation for credential or provider removal. */
964
+ export function ProviderConfirmPanel({ target, kind, confirm, done, back }: {
965
+ target: ProviderTargetView
966
+ kind: 'credential' | 'provider'
967
+ confirm: (target: ProviderTargetView) => Promise<void>
968
+ done: () => void
969
+ back: () => void
970
+ }): ReactElement {
971
+ const stdout = useStdout().stdout
972
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
973
+ const [busy, setBusy] = useState(false)
974
+ const [error, setError] = useState<string | undefined>(undefined)
975
+ const run = (): void => {
976
+ if (busy) return
977
+ setBusy(true)
978
+ setError(undefined)
979
+ Promise.resolve().then(() => confirm(target)).then(done, (reason: unknown) => {
980
+ setError(singleLineText(reason instanceof Error ? reason.message : String(reason)))
981
+ setBusy(false)
982
+ })
983
+ }
984
+ useStableInput((input, key) => {
985
+ if (busy) return
986
+ if (key.escape || input === 'n') {
987
+ back()
988
+ return
989
+ }
990
+ if (input === 'y') run()
991
+ }, true)
992
+
993
+ const action = kind === 'credential' ? 'remove API key' : 'remove provider'
994
+ if (viewport.maxHeight === 0 || viewport.compact) {
995
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`${action} ${target.displayName}? · y confirm · n/esc back`, viewport.contentColumns))
996
+ }
997
+ const identity = target.displayName === target.provider ? target.provider : `${target.displayName} (${target.provider})`
998
+ const identityRow = createElement(Text, { key: 'identity', wrap: 'truncate-end' }, truncateColumns(` ${displayText(identity)}`, viewport.contentColumns))
999
+ const descriptionRow = createElement(Text, { key: 'description', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(kind === 'credential' ? ' the provider profile and selected model stay available' : ' the user settings profile and its managed key will be removed', viewport.contentColumns))
1000
+ const errorRow = error === undefined
1001
+ ? undefined
1002
+ : createElement(Text, { key: 'error', color: inkColor(getPalette().error), wrap: 'truncate-end' }, truncateColumns(` ${error}`, viewport.contentColumns))
1003
+ const bodyRows = errorRow === undefined
1004
+ ? [identityRow, descriptionRow].slice(0, viewport.bodyRows)
1005
+ : [identityRow, errorRow].slice(-viewport.bodyRows)
1006
+ return createElement(
1007
+ Box,
1008
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().warn) },
1009
+ createElement(Text, { color: inkColor(getPalette().warn), bold: true, wrap: 'truncate-end' }, truncateColumns(`/model — ${action}`, viewport.contentColumns)),
1010
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1011
+ ...bodyRows,
1012
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1013
+ createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(busy ? 'working…' : 'y confirm · n/esc back', viewport.contentColumns)),
1014
+ )
1015
+ }
1016
+
1017
+ /**
1018
+ * The /help overlay: one scrolling card with the keyboard map, the TUI-local
1019
+ * commands, the live registry commands, and the user-invocable skills — the
1020
+ * real command surface, replacing the one-line notice.
1021
+ */