dsh-code 1.3.0 → 1.5.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.
- package/README.en.md +10 -8
- package/README.md +10 -8
- package/lib/index.mjs +6980 -5942
- package/lib/session-query.mjs +18 -1
- package/lib/types/app.d.ts +38 -65
- package/lib/types/completion.d.ts +29 -0
- package/lib/types/composer.d.ts +150 -0
- package/lib/types/git-workflow.d.ts +6 -0
- package/lib/types/index.d.ts +6 -188
- package/lib/types/locales/en.d.ts +20 -8
- package/lib/types/{authorization-panel.d.ts → panels/authorization-panel.d.ts} +6 -1
- package/lib/types/panels/completion-panel.d.ts +13 -0
- package/lib/types/panels/interaction-bars.d.ts +38 -0
- package/lib/types/{kernel-panels.d.ts → panels/kernel-panels.d.ts} +16 -37
- package/lib/types/{language-panel.d.ts → panels/language-panel.d.ts} +1 -1
- package/lib/types/panels/model-panels.d.ts +86 -0
- package/lib/types/{theme-panel.d.ts → panels/theme-panel.d.ts} +1 -1
- package/lib/types/{update-panel.d.ts → panels/update-panel.d.ts} +1 -1
- package/lib/types/provider-settings.d.ts +11 -0
- package/lib/types/render/inspector.d.ts +16 -0
- package/lib/types/render/projection-events.d.ts +33 -0
- package/lib/types/render/text.d.ts +4 -0
- package/lib/types/render/transcript-viewport.d.ts +32 -0
- package/lib/types/runner/harness-gate.d.ts +83 -0
- package/lib/types/runner/input-history.d.ts +31 -0
- package/lib/types/runner/mode-cycle.d.ts +44 -0
- package/lib/types/runner/preferences.d.ts +40 -0
- package/lib/types/runner/quit.d.ts +27 -0
- package/lib/types/runner/search-rows.d.ts +38 -0
- package/lib/types/runner/session-io.d.ts +57 -0
- package/lib/types/runner/session-target.d.ts +42 -0
- package/lib/types/runner/startup-config.d.ts +33 -0
- package/lib/types/runner/submissions.d.ts +87 -0
- package/lib/types/session/attach.d.ts +39 -0
- package/lib/types/{store.d.ts → session/store.d.ts} +1 -1
- package/lib/types/{subagents.d.ts → session/subagents.d.ts} +8 -1
- package/lib/types/session-query.d.ts +11 -0
- package/lib/types/settings-file.d.ts +10 -0
- package/lib/types/{panel-accent.d.ts → ui/panel-accent.d.ts} +1 -1
- package/lib/types/ui/panel-gap.d.ts +6 -0
- package/lib/types/ui/query-editor.d.ts +10 -0
- package/lib/types/ui/styled-rows.d.ts +8 -0
- package/lib/types/{terminal-title.d.ts → ui/terminal-title.d.ts} +1 -1
- package/lib/types/ui/ui-contract.d.ts +12 -0
- package/lib/types/ui/use-frames.d.ts +6 -0
- package/lib/types/ui/use-stable-input.d.ts +7 -0
- package/package.json +3 -2
- package/src/app.ts +802 -4162
- package/src/completion.ts +117 -0
- package/src/composer.ts +1956 -0
- package/src/git-workflow.ts +18 -0
- package/src/index.ts +113 -633
- package/src/internals.ts +1 -1
- package/src/locales/en.ts +20 -8
- package/src/locales/zh.ts +20 -8
- package/src/{authorization-panel.ts → panels/authorization-panel.ts} +25 -6
- package/src/panels/completion-panel.ts +79 -0
- package/src/panels/interaction-bars.ts +567 -0
- package/src/{kernel-panels.ts → panels/kernel-panels.ts} +84 -65
- package/src/{language-panel.ts → panels/language-panel.ts} +4 -4
- package/src/panels/model-panels.ts +1021 -0
- package/src/{theme-panel.ts → panels/theme-panel.ts} +5 -5
- package/src/{update-panel.ts → panels/update-panel.ts} +6 -6
- package/src/provider-settings.ts +38 -0
- package/src/render/inspector.ts +35 -0
- package/src/render/lines.ts +45 -18
- package/src/render/projection-events.ts +109 -0
- package/src/render/projection.ts +11 -2
- package/src/render/text.ts +8 -0
- package/src/render/tool-preview.ts +35 -2
- package/src/render/transcript-viewport.ts +61 -0
- package/src/render/usage.ts +1 -2
- package/src/runner/harness-gate.ts +168 -0
- package/src/runner/input-history.ts +77 -0
- package/src/runner/mode-cycle.ts +49 -0
- package/src/runner/preferences.ts +67 -0
- package/src/runner/quit.ts +53 -0
- package/src/runner/search-rows.ts +55 -0
- package/src/runner/session-io.ts +245 -0
- package/src/runner/session-target.ts +81 -0
- package/src/runner/startup-config.ts +54 -0
- package/src/runner/submissions.ts +157 -0
- package/src/session/attach.ts +87 -0
- package/src/{session-directory.ts → session/session-directory.ts} +1 -1
- package/src/{store.ts → session/store.ts} +1 -1
- package/src/{subagents.ts → session/subagents.ts} +12 -1
- package/src/session-query.ts +22 -0
- package/src/settings-file.ts +19 -1
- package/src/{panel-accent.ts → ui/panel-accent.ts} +1 -1
- package/src/ui/panel-gap.ts +9 -0
- package/src/ui/query-editor.ts +16 -0
- package/src/ui/styled-rows.ts +124 -0
- package/src/{terminal-title.ts → ui/terminal-title.ts} +1 -1
- package/src/ui/ui-contract.ts +10 -0
- package/src/ui/use-frames.ts +23 -0
- package/src/ui/use-stable-input.ts +17 -0
- /package/lib/types/{fork.d.ts → session/fork.d.ts} +0 -0
- /package/lib/types/{history.d.ts → session/history.d.ts} +0 -0
- /package/lib/types/{session-directory.d.ts → session/session-directory.d.ts} +0 -0
- /package/lib/types/{session-switch.d.ts → session/session-switch.d.ts} +0 -0
- /package/src/{fork.ts → session/fork.ts} +0 -0
- /package/src/{history.ts → session/history.ts} +0 -0
- /package/src/{session-switch.ts → session/session-switch.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
|
+
*/
|