dsh-oc-tui 0.2.0-pre.13 → 0.2.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.md +13 -6
- package/docs//347/224/250/346/210/267/346/211/213/345/206/214.md +39 -6
- package/dsh-plugin.json +5 -1
- package/lib/diagnostics.js +120 -0
- package/lib/git-branch.js +157 -0
- package/lib/image.js +5 -1
- package/lib/index.js +904 -19
- package/lib/locales/ar.js +637 -560
- package/lib/locales/de.js +606 -529
- package/lib/locales/en.js +79 -0
- package/lib/locales/es.js +606 -529
- package/lib/locales/fr.js +606 -529
- package/lib/locales/id.js +608 -531
- package/lib/locales/it.js +606 -529
- package/lib/locales/ja.js +606 -529
- package/lib/locales/ko.js +606 -529
- package/lib/locales/nl.js +606 -529
- package/lib/locales/pl.js +620 -543
- package/lib/locales/pt-BR.js +606 -529
- package/lib/locales/ru.js +620 -543
- package/lib/locales/tr.js +606 -529
- package/lib/locales/vi.js +608 -531
- package/lib/locales/zh-CN.js +607 -530
- package/lib/locales/zh-TW.js +607 -530
- package/lib/mermaid-ascii.js +20 -1
- package/lib/mermaid.js +39 -20
- package/lib/providers.js +712 -0
- package/lib/splash-opencode.js +27 -9
- package/lib/std/command-list.js +2 -1
- package/lib/term.js +13 -1
- package/lib/ui.js +326 -20
- package/lib/util.js +49 -0
- package/lib/web-settings.js +182 -142
- package/package.json +5 -4
package/lib/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import { contentHasImage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
|
9
9
|
import { SessionId } from '@deepseek-ai/dsh-session'
|
|
10
10
|
import { parseCommand } from '@deepseek-ai/dsh-commands'
|
|
11
11
|
import { Terminal } from './term.js'
|
|
12
|
+
import { captureDiagnostics } from './diagnostics.js'
|
|
12
13
|
import { App, noteFromContext, cursorAtVisual } from './ui.js'
|
|
13
14
|
import { probeCapabilities } from './caps.js'
|
|
14
15
|
import { renderImage, sha16 } from './image.js'
|
|
@@ -16,9 +17,31 @@ import { createMermaidRenderer } from './mermaid.js'
|
|
|
16
17
|
import { SessionMetrics, mergeSessionStats } from './metrics.js'
|
|
17
18
|
import { InterruptState } from './interrupt.js'
|
|
18
19
|
import { SETTINGS_MENU, applyLocalePreference, buildMcpFormItems, describeLocale, installWebSettingSchemas, loadMcpSettings, loadModelSettings, loadProviderModels, loadWebSettings, readTuiSetting, saveWebSetting } from './web-settings.js'
|
|
20
|
+
import {
|
|
21
|
+
adoptDiscovered,
|
|
22
|
+
buildCreateFormItems,
|
|
23
|
+
buildModelFormItems,
|
|
24
|
+
createDraft,
|
|
25
|
+
declareNamespace,
|
|
26
|
+
deriveRoute,
|
|
27
|
+
discoverDraftModels,
|
|
28
|
+
draftModelItems,
|
|
29
|
+
mergeModelSelection,
|
|
30
|
+
modelDraftFrom,
|
|
31
|
+
modelEntryId,
|
|
32
|
+
modelFromDraft,
|
|
33
|
+
protocolChoices,
|
|
34
|
+
providerDirectory,
|
|
35
|
+
providerPickerItems,
|
|
36
|
+
removeModelEntry,
|
|
37
|
+
saveCreateDraft,
|
|
38
|
+
saveModelEntry,
|
|
39
|
+
validateCreateDraft,
|
|
40
|
+
validateModelDraft,
|
|
41
|
+
} from './providers.js'
|
|
19
42
|
import { McpManager, collectServers, createToolProbe, draftFromEntry, emptyDraft, pathConfigPath, removeServer, saveDraft, userConfigPath, validateDraft } from './mcp.js'
|
|
20
43
|
import { registerMcpSkill } from './mcp-skill.js'
|
|
21
|
-
import { contentImages, contentText, decodeDataUrl, detectImageMediaType, formatError, localImagePath, timeString, userContentBlocks } from './util.js'
|
|
44
|
+
import { contentImages, contentText, decodeDataUrl, detectImageMediaType, fieldPasteText, formatError, localImagePath, shortenPath, timeString, userContentBlocks } from './util.js'
|
|
22
45
|
import { DSH_PACKAGE, TUI_PACKAGE, buildVersionItems, cachedTarballVersions, clearStagedInstall, detectDshInstall, detectDshLocks, dshManifestPath, dshProcessArgs, dshShimPath, formatDownloadProgress, installMarkerPath, loadUpdateView, registryInfo, restartPlan, spawnApplyUpdater, stageInstall, tarballCachePath, tuiInstallIsLocal, tuiInstalledManifestPath, tuiInstalledVersion, updateStatus } from './updates.js'
|
|
23
46
|
import {
|
|
24
47
|
backupRootFor,
|
|
@@ -32,6 +55,7 @@ import {
|
|
|
32
55
|
restoreGitPaths,
|
|
33
56
|
rewindCut,
|
|
34
57
|
} from './rewind.js'
|
|
58
|
+
import { listGitBranches, pullGitUpdates, switchGitBranch } from './git-branch.js'
|
|
35
59
|
import { registerLiveTui } from './bridge.js'
|
|
36
60
|
import { notificationLevel, approvalOutcome, copyTextOptions, deadlineDelay, toTuiQuestions, fromTuiAnswers } from './std/adapt.js'
|
|
37
61
|
// Peer-free on purpose: lib/std/commands.js statically imports the optional
|
|
@@ -181,6 +205,20 @@ export function apply(ctx, config) {
|
|
|
181
205
|
}
|
|
182
206
|
term.start()
|
|
183
207
|
const app = new App(term)
|
|
208
|
+
// From here until the alt screen is released, anything the host or a plugin
|
|
209
|
+
// writes to the process streams would land on top of the frame. Divert it to
|
|
210
|
+
// a log instead, and say so once — silently swallowing an adapter's refusal
|
|
211
|
+
// would make a broken provider look like a broken TUI.
|
|
212
|
+
const diagnosticsLog = join(dshHome(), 'tui-diagnostics.log')
|
|
213
|
+
const diagnostics = captureDiagnostics({
|
|
214
|
+
logPath: diagnosticsLog,
|
|
215
|
+
onFirst: () => {
|
|
216
|
+
app.showToast(t('diagnostics.notice', { path: shortenPath(diagnosticsLog, 48) }), 'warn')
|
|
217
|
+
// Deferred, not direct: the capture can fire while this startup scope is
|
|
218
|
+
// still being evaluated, before `paint` below is initialized.
|
|
219
|
+
setTimeout(() => { if (term.started) paint() }, 0)
|
|
220
|
+
},
|
|
221
|
+
})
|
|
184
222
|
// Session history is available from Settings -> Manage sessions.
|
|
185
223
|
app.sidebarVisible = false
|
|
186
224
|
// The status rail's footer names the running builds: this plugin's own
|
|
@@ -195,7 +233,14 @@ export function apply(ctx, config) {
|
|
|
195
233
|
// remote URLs. A protocol render also registers its payload with the
|
|
196
234
|
// terminal so paint() can transmit/place it.
|
|
197
235
|
app.onImageRequest = (src) => { void resolveImage(src) }
|
|
236
|
+
// One render per key and geometry at a time. A resize during a decode asks
|
|
237
|
+
// again under a new signature; without this the old decode keeps running and
|
|
238
|
+
// the same image is encoded twice for one frame.
|
|
239
|
+
const imageRendersInFlight = new Map()
|
|
198
240
|
async function resolveImage(src) {
|
|
241
|
+
const sig = app.imageRenderSignature()
|
|
242
|
+
if (imageRendersInFlight.get(src.key) === sig) return
|
|
243
|
+
imageRendersInFlight.set(src.key, sig)
|
|
199
244
|
try {
|
|
200
245
|
const bytes = await resolveImageBytes(src)
|
|
201
246
|
// cropCells arrives on a source the view re-requested because the
|
|
@@ -216,8 +261,10 @@ export function apply(ctx, config) {
|
|
|
216
261
|
}
|
|
217
262
|
} catch (error) {
|
|
218
263
|
app.setImageResult(src.key, { state: 'error', error: formatError(error) })
|
|
264
|
+
} finally {
|
|
265
|
+
if (imageRendersInFlight.get(src.key) === sig) imageRendersInFlight.delete(src.key)
|
|
266
|
+
paintSoon()
|
|
219
267
|
}
|
|
220
|
-
paintSoon()
|
|
221
268
|
}
|
|
222
269
|
async function resolveImageBytes(src) {
|
|
223
270
|
if (src.kind === 'bytes') {
|
|
@@ -437,7 +484,11 @@ export function apply(ctx, config) {
|
|
|
437
484
|
}
|
|
438
485
|
paint()
|
|
439
486
|
}
|
|
440
|
-
|
|
487
|
+
// Coalesced, not immediate: dragging a window edge fires one resize per
|
|
488
|
+
// pixel column, and each immediate paint rebuilds the whole screen — and,
|
|
489
|
+
// since the image renders key on the grid, can queue a re-render per column.
|
|
490
|
+
// 40ms is below what a resize is perceived at.
|
|
491
|
+
term.on('resize', () => paintSoon())
|
|
441
492
|
// The language is a live setting. A host that writes it through its own
|
|
442
493
|
// settings surface — or a profile patch edited by hand while the TUI runs —
|
|
443
494
|
// pushes the new value into the volatile Config reference instead of
|
|
@@ -1119,6 +1170,157 @@ export function apply(ctx, config) {
|
|
|
1119
1170
|
paint()
|
|
1120
1171
|
}
|
|
1121
1172
|
|
|
1173
|
+
// ---- git branch switching (empty page only) ------------------------------
|
|
1174
|
+
// Checking out a branch rewrites the working tree that every session in
|
|
1175
|
+
// this process is rooted in, so the window is offered on the title screen
|
|
1176
|
+
// only. Inside a session both the shortcut and the command answer with a
|
|
1177
|
+
// pointer instead of opening it.
|
|
1178
|
+
|
|
1179
|
+
function gitBranchWindowBlocked() {
|
|
1180
|
+
if (!currentAgent) return false
|
|
1181
|
+
app.showToast(t('git.onlyTitleScreen'), 'warn')
|
|
1182
|
+
paint()
|
|
1183
|
+
return true
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
async function openGitBranchOverlay() {
|
|
1187
|
+
if (gitBranchWindowBlocked()) return
|
|
1188
|
+
let info
|
|
1189
|
+
try {
|
|
1190
|
+
info = await listGitBranches(process.cwd())
|
|
1191
|
+
} catch (error) {
|
|
1192
|
+
app.showToast(t('git.listFailed', { error: formatError(error) }), 'warn')
|
|
1193
|
+
paint()
|
|
1194
|
+
return
|
|
1195
|
+
}
|
|
1196
|
+
app.openGitBranches({
|
|
1197
|
+
cwd: process.cwd(),
|
|
1198
|
+
root: info.root,
|
|
1199
|
+
current: info.current,
|
|
1200
|
+
local: info.local,
|
|
1201
|
+
remote: info.remote,
|
|
1202
|
+
})
|
|
1203
|
+
paint()
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// Re-read the refs after anything that can change them (a switch, a pull),
|
|
1207
|
+
// so the current marker and the remote set never go stale in an open
|
|
1208
|
+
// window. A failure here is only worth reporting when the refresh is the
|
|
1209
|
+
// action the user asked for — after a successful switch the window keeps
|
|
1210
|
+
// its outcome line instead of being overwritten by a listing error.
|
|
1211
|
+
async function refreshGitBranchList({ reportError = true } = {}) {
|
|
1212
|
+
const picker = app.gitBranches
|
|
1213
|
+
if (!picker) return
|
|
1214
|
+
try {
|
|
1215
|
+
const info = await listGitBranches(picker.cwd)
|
|
1216
|
+
app.setGitBranchList(info)
|
|
1217
|
+
} catch (error) {
|
|
1218
|
+
if (reportError) throw new Error(t('git.listFailed', { error: formatError(error) }))
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// The title screen's `git: <branch>` line is resolved once at boot; a
|
|
1223
|
+
// switch has to re-resolve it or the splash keeps naming the old branch.
|
|
1224
|
+
async function refreshTitleGitBranch() {
|
|
1225
|
+
const cwd = process.cwd()
|
|
1226
|
+
let gitBranch = ''
|
|
1227
|
+
try { gitBranch = await gitBranchAt(cwd) } catch { /* the splash omits the line */ }
|
|
1228
|
+
app.setWelcome({ workingDirectory: cwd, gitBranch })
|
|
1229
|
+
paint()
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
// Every window action runs through here so the busy flag, the report reset
|
|
1233
|
+
// and the repaint happen exactly once, whatever the action does. The catch
|
|
1234
|
+
// is a safety net: the actions below already localize their own failures.
|
|
1235
|
+
async function withGitBranchBusy(action) {
|
|
1236
|
+
const picker = app.gitBranches
|
|
1237
|
+
if (!picker || picker.busy) return
|
|
1238
|
+
picker.busy = true
|
|
1239
|
+
picker.error = ''
|
|
1240
|
+
picker.status = ''
|
|
1241
|
+
paint()
|
|
1242
|
+
try {
|
|
1243
|
+
await action()
|
|
1244
|
+
} catch (error) {
|
|
1245
|
+
picker.error = formatError(error)
|
|
1246
|
+
}
|
|
1247
|
+
picker.busy = false
|
|
1248
|
+
paint()
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
async function confirmGitBranch() {
|
|
1252
|
+
const picker = app.gitBranches
|
|
1253
|
+
const row = picker?.items[picker.selected]
|
|
1254
|
+
if (!row || row.kind !== 'branch') return
|
|
1255
|
+
try {
|
|
1256
|
+
const result = await switchGitBranch(picker.cwd, row.ref, { remote: row.remote })
|
|
1257
|
+
await refreshGitBranchList({ reportError: false })
|
|
1258
|
+
picker.status = t('git.switched', { branch: result.branch })
|
|
1259
|
+
void refreshTitleGitBranch()
|
|
1260
|
+
} catch (error) {
|
|
1261
|
+
throw new Error(t('git.switchFailed', { branch: row.ref, error: formatError(error) }))
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
async function pullGitBranches() {
|
|
1266
|
+
const picker = app.gitBranches
|
|
1267
|
+
try {
|
|
1268
|
+
const result = await pullGitUpdates(picker.cwd)
|
|
1269
|
+
await refreshGitBranchList()
|
|
1270
|
+
picker.status = result.summary || t('git.pullDone')
|
|
1271
|
+
} catch (error) {
|
|
1272
|
+
throw new Error(t('git.pullFailed', { error: formatError(error) }))
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
function handleGitBranchKey(key) {
|
|
1277
|
+
const picker = app.gitBranches
|
|
1278
|
+
if (!picker) return
|
|
1279
|
+
if (key.ctrl && (key.name === 'c' || key.name === 'd')) {
|
|
1280
|
+
handleCtrlKey(key.name)
|
|
1281
|
+
return
|
|
1282
|
+
}
|
|
1283
|
+
if (picker.busy) return
|
|
1284
|
+
if (key.name === 'escape') {
|
|
1285
|
+
app.closeGitBranches()
|
|
1286
|
+
paint()
|
|
1287
|
+
return
|
|
1288
|
+
}
|
|
1289
|
+
if (key.name === 'return' || key.name === 'enter') {
|
|
1290
|
+
void withGitBranchBusy(confirmGitBranch)
|
|
1291
|
+
return
|
|
1292
|
+
}
|
|
1293
|
+
const letter = String(key.name ?? key.text ?? '').toLowerCase()
|
|
1294
|
+
if (key.name === 'up' || letter === 'k' || (key.ctrl && letter === 'p')) {
|
|
1295
|
+
app.moveGitBranches(-1)
|
|
1296
|
+
paint()
|
|
1297
|
+
return
|
|
1298
|
+
}
|
|
1299
|
+
if (key.name === 'down' || letter === 'j' || (key.ctrl && letter === 'n')) {
|
|
1300
|
+
app.moveGitBranches(1)
|
|
1301
|
+
paint()
|
|
1302
|
+
return
|
|
1303
|
+
}
|
|
1304
|
+
if (key.name === 'pageup') {
|
|
1305
|
+
app.moveGitBranches(-7)
|
|
1306
|
+
paint()
|
|
1307
|
+
return
|
|
1308
|
+
}
|
|
1309
|
+
if (key.name === 'pagedown') {
|
|
1310
|
+
app.moveGitBranches(7)
|
|
1311
|
+
paint()
|
|
1312
|
+
return
|
|
1313
|
+
}
|
|
1314
|
+
if (letter === 'p') {
|
|
1315
|
+
void withGitBranchBusy(pullGitBranches)
|
|
1316
|
+
return
|
|
1317
|
+
}
|
|
1318
|
+
if (letter === 'r') {
|
|
1319
|
+
void withGitBranchBusy(() => refreshGitBranchList())
|
|
1320
|
+
return
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1122
1324
|
async function applyRewindChoice(item, mode) {
|
|
1123
1325
|
if (!currentAgent) throw new Error(t('session.startFirst'))
|
|
1124
1326
|
if (item?.current || item?.seq == null) {
|
|
@@ -1917,8 +2119,67 @@ export function apply(ctx, config) {
|
|
|
1917
2119
|
requestClipboardImage()
|
|
1918
2120
|
}
|
|
1919
2121
|
|
|
1920
|
-
|
|
1921
|
-
|
|
2122
|
+
// A paste or clipboard reply that arrived while a dialog is open. The composer
|
|
2123
|
+
// is behind the dialog, so nothing here may reach it: the reply is either the
|
|
2124
|
+
// field being edited or nothing at all.
|
|
2125
|
+
function handleOverlayPaste(key) {
|
|
2126
|
+
if (key.name === 'clipboard') {
|
|
2127
|
+
// An OSC 52 reply answers a read the composer asked for before the dialog
|
|
2128
|
+
// opened. Nothing can use it now, and leaving the flag set would let a
|
|
2129
|
+
// later unsolicited reply be routed to the composer instead.
|
|
2130
|
+
clipboardRequested = false
|
|
2131
|
+
return
|
|
2132
|
+
}
|
|
2133
|
+
if (app.overlay === 'settings') pasteIntoSettingsField(key.data)
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
// The settings rows that hold one line of typed text, so a paste onto the
|
|
2137
|
+
// selected row can open its editor. A choice row is excluded: there is nothing
|
|
2138
|
+
// to paste into a list of options.
|
|
2139
|
+
const PASTEABLE_ROWS = new Set(['text', 'path', 'secret', 'provider-field', 'model-field', 'mcp-field', 'provider-model-add', 'provider-search'])
|
|
2140
|
+
|
|
2141
|
+
function pasteableRow(item) {
|
|
2142
|
+
if (!item || item.disabled === true || item.selectable === false) return false
|
|
2143
|
+
if (Array.isArray(item.options) && item.options.length > 0) return false
|
|
2144
|
+
if (!PASTEABLE_ROWS.has(item.kind)) return false
|
|
2145
|
+
// The Models row on the create form opens a window; it is not a text field.
|
|
2146
|
+
return item.providerModels !== true
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
// Append a paste to the field the settings dialog is editing — or, when no
|
|
2150
|
+
// field is open, open the selected row's editor around the pasted text. That
|
|
2151
|
+
// is what pressing Ctrl+V on a highlighted "Provider API key" row means, and
|
|
2152
|
+
// it beats pasting into a composer the dialog is hiding.
|
|
2153
|
+
//
|
|
2154
|
+
// Everything the dialog edits is one line wide, so `fieldPasteText` folds the
|
|
2155
|
+
// newlines away; an image paste yields nothing, because a text field has no
|
|
2156
|
+
// place to put it and the composer that would have is not on screen.
|
|
2157
|
+
function pasteIntoSettingsField(data) {
|
|
2158
|
+
if (app.settingsEditing !== null) {
|
|
2159
|
+
const open = fieldPasteText(data, { secret: app.settingsSecret })
|
|
2160
|
+
if (open === null) return
|
|
2161
|
+
app.settingsDraft += open
|
|
2162
|
+
// The picker's filter row narrows the list as it is typed; a paste into it
|
|
2163
|
+
// has to land the same way a keystroke does.
|
|
2164
|
+
if (settingsView.kind === 'provider-picker' && app.settingsEditing === 0) rebuildPicker(app.settingsDraft)
|
|
2165
|
+
paint()
|
|
2166
|
+
return
|
|
2167
|
+
}
|
|
2168
|
+
const item = app.settingsItems[app.settingsSelection]
|
|
2169
|
+
if (!pasteableRow(item)) return
|
|
2170
|
+
const secret = item.secret === true || item.kind === 'secret'
|
|
2171
|
+
const text = fieldPasteText(data, { secret })
|
|
2172
|
+
if (text === null) return
|
|
2173
|
+
app.settingsEditing = app.settingsSelection
|
|
2174
|
+
app.settingsSecret = secret
|
|
2175
|
+
// A secret row masks what it shows, so the editor starts from the pasted
|
|
2176
|
+
// value; the row's previous value was never on screen to append to anyway.
|
|
2177
|
+
app.settingsDraft = text
|
|
2178
|
+
if (settingsView.kind === 'provider-picker' && app.settingsEditing === 0) rebuildPicker(app.settingsDraft)
|
|
2179
|
+
paint()
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
async function attachFromText(text) { const trimmed = text.trim()
|
|
1922
2183
|
const dataUrl = decodeDataUrl(trimmed)
|
|
1923
2184
|
if (dataUrl) {
|
|
1924
2185
|
const detected = detectImageMediaType(dataUrl.data)
|
|
@@ -2036,6 +2297,8 @@ export function apply(ctx, config) {
|
|
|
2036
2297
|
}
|
|
2037
2298
|
if (app.overlay === 'rewind') {
|
|
2038
2299
|
app.moveRewind(direction)
|
|
2300
|
+
} else if (app.overlay === 'git-branches') {
|
|
2301
|
+
app.moveGitBranches(direction)
|
|
2039
2302
|
} else if (app.overlay === 'settings') {
|
|
2040
2303
|
// Scroll the settings content window instead of moving focus
|
|
2041
2304
|
app.scrollSettingsWindow(direction * 3)
|
|
@@ -2087,6 +2350,26 @@ export function apply(ctx, config) {
|
|
|
2087
2350
|
void confirmRewindPicker()
|
|
2088
2351
|
return
|
|
2089
2352
|
}
|
|
2353
|
+
if (app.overlay === 'git-branches') {
|
|
2354
|
+
const region = app.hitTest(mouse.x, mouse.y, ['git-branch-item'])
|
|
2355
|
+
const picker = app.gitBranches
|
|
2356
|
+
if (!region || !picker) {
|
|
2357
|
+
app.closeGitBranches()
|
|
2358
|
+
paint()
|
|
2359
|
+
return
|
|
2360
|
+
}
|
|
2361
|
+
// A click on an unselected row only moves the selection; switching a
|
|
2362
|
+
// branch is the second click on the row that already has it, so a
|
|
2363
|
+
// stray click cannot rewrite the working tree.
|
|
2364
|
+
if (picker.selected === region.branchIndex) {
|
|
2365
|
+
if (!picker.busy) void withGitBranchBusy(confirmGitBranch)
|
|
2366
|
+
} else {
|
|
2367
|
+
picker.selected = region.branchIndex
|
|
2368
|
+
app._clampGitBranchesScroll()
|
|
2369
|
+
}
|
|
2370
|
+
paint()
|
|
2371
|
+
return
|
|
2372
|
+
}
|
|
2090
2373
|
if (app.pendingQuestions) {
|
|
2091
2374
|
const option = app.hitTest(mouse.x, mouse.y, ['question-option'])
|
|
2092
2375
|
if (option) {
|
|
@@ -2188,14 +2471,22 @@ export function apply(ctx, config) {
|
|
|
2188
2471
|
clipboardRequested = false
|
|
2189
2472
|
return
|
|
2190
2473
|
}
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2474
|
+
if (key.name === 'mouse') {
|
|
2475
|
+
handleMouse(key.mouse)
|
|
2476
|
+
return
|
|
2477
|
+
}
|
|
2478
|
+
// A paste belongs to whatever holds focus. Behind a dialog the composer is
|
|
2479
|
+
// not on screen, so routing there fills a row the user cannot see — one
|
|
2480
|
+
// Enter away from being sent as a message, which is exactly what an API key
|
|
2481
|
+
// must never become. Only the settings dialog has a field to give it to.
|
|
2482
|
+
if (app.overlay !== null && (key.name === 'paste' || key.name === 'clipboard')) {
|
|
2483
|
+
handleOverlayPaste(key)
|
|
2484
|
+
return
|
|
2485
|
+
}
|
|
2486
|
+
if (key.name === 'paste') {
|
|
2487
|
+
void handlePaste(key.data)
|
|
2488
|
+
return
|
|
2489
|
+
}
|
|
2199
2490
|
if (key.name === 'clipboard') {
|
|
2200
2491
|
handleClipboard(key.data)
|
|
2201
2492
|
return
|
|
@@ -2287,6 +2578,14 @@ export function apply(ctx, config) {
|
|
|
2287
2578
|
handleRewindKey(key)
|
|
2288
2579
|
return
|
|
2289
2580
|
}
|
|
2581
|
+
if (app.overlay === 'git-branches') {
|
|
2582
|
+
if (key.ctrl && (key.name === 'c' || key.name === 'd')) {
|
|
2583
|
+
handleCtrlKey(key.name)
|
|
2584
|
+
return
|
|
2585
|
+
}
|
|
2586
|
+
handleGitBranchKey(key)
|
|
2587
|
+
return
|
|
2588
|
+
}
|
|
2290
2589
|
if (app.overlay === 'settings') {
|
|
2291
2590
|
handleSettingsKey(key)
|
|
2292
2591
|
return
|
|
@@ -2586,15 +2885,419 @@ export function apply(ctx, config) {
|
|
|
2586
2885
|
}
|
|
2587
2886
|
|
|
2588
2887
|
async function refreshSettings(selection = 0) {
|
|
2589
|
-
if (settingsView.kind === 'provider-models')
|
|
2888
|
+
if (settingsView.kind === 'provider-models') {
|
|
2889
|
+
if (settingsView.draft === true) renderDraftModelWindow(settingsView.selection ?? 0)
|
|
2890
|
+
else await openProviderModels(settingsView.provider, selection)
|
|
2891
|
+
}
|
|
2590
2892
|
else if (settingsView.kind === 'sessions') await openSessionManager(selection)
|
|
2591
2893
|
else if (settingsView.kind === 'update') await openSettingsTab('update', selection)
|
|
2592
2894
|
else if (settingsView.kind === 'model') await openSettingsTab('model', selection)
|
|
2593
2895
|
else if (settingsView.kind === 'mcp') await openSettingsTab('mcp', selection)
|
|
2594
2896
|
else if (settingsView.kind === 'mcp-form') renderMcpForm(mcpFormDraft, mcpFormIsEdit, selection)
|
|
2897
|
+
else if (settingsView.kind === 'provider-picker') openProviderPicker(selection, settingsView.query ?? '')
|
|
2898
|
+
else if (settingsView.kind === 'provider-form') renderProviderForm(providerDraft, providerFormMeta, selection)
|
|
2899
|
+
else if (settingsView.kind === 'model-form') renderModelForm(modelDraft, modelFormTarget, selection)
|
|
2595
2900
|
else await openSettingsTab('main', selection)
|
|
2596
2901
|
}
|
|
2597
2902
|
|
|
2903
|
+
// ---- add-provider flow ---------------------------------------------------
|
|
2904
|
+
// The WebUI's add card, split into the two views a terminal can render: a
|
|
2905
|
+
// picker over the routes still addable (the ones whose profile does not
|
|
2906
|
+
// resolve yet), then a form for the chosen one. The form is a draft — nothing
|
|
2907
|
+
// is written until Save — because a hand-declared route needs several fields
|
|
2908
|
+
// that are only valid together.
|
|
2909
|
+
let providerDraft = null
|
|
2910
|
+
let providerFormMeta = null
|
|
2911
|
+
// The endpoint's answer for the open draft, kept so toggling a model costs no
|
|
2912
|
+
// round trip and the list cannot shift under the cursor.
|
|
2913
|
+
let providerDiscovery = null
|
|
2914
|
+
// The directory the open picker was built from. Kept so typing a filter costs
|
|
2915
|
+
// no settings round trip per keystroke.
|
|
2916
|
+
let pickerDirectory = null
|
|
2917
|
+
|
|
2918
|
+
function openProviderPicker(selection = 0, query = '') {
|
|
2919
|
+
const directory = providerDirectory(ctx)
|
|
2920
|
+
if (!directory) {
|
|
2921
|
+
app.showToast(t('settings.error', { error: t('common.unavailable') }), 'error')
|
|
2922
|
+
void openSettingsTab('model', 0)
|
|
2923
|
+
return
|
|
2924
|
+
}
|
|
2925
|
+
pickerDirectory = directory
|
|
2926
|
+
settingsView = { kind: 'provider-picker', query }
|
|
2927
|
+
app.openSettings(providerPickerItems(directory, { query }), {
|
|
2928
|
+
title: t('provider.add'),
|
|
2929
|
+
subtitle: t('provider.pickSubtitle'),
|
|
2930
|
+
})
|
|
2931
|
+
app.setSettingsSelection(selection)
|
|
2932
|
+
paint()
|
|
2933
|
+
}
|
|
2934
|
+
|
|
2935
|
+
// Narrow the picker as the query is typed. The editor stays on the filter row
|
|
2936
|
+
// (index 0) and the list beneath it is what changes, so the cursor never
|
|
2937
|
+
// leaves the field being typed into. A commit passes `editing: false`: the
|
|
2938
|
+
// commit path has already closed the editor and must not reopen it.
|
|
2939
|
+
function rebuildPicker(query, { editing = true } = {}) {
|
|
2940
|
+
if (!pickerDirectory) return
|
|
2941
|
+
settingsView = { kind: 'provider-picker', query }
|
|
2942
|
+
app.settingsItems = providerPickerItems(pickerDirectory, { query })
|
|
2943
|
+
app.settingsSelection = 0
|
|
2944
|
+
app.settingsScrollOffset = 0
|
|
2945
|
+
if (editing) app.settingsEditing = 0
|
|
2946
|
+
}
|
|
2947
|
+
|
|
2948
|
+
// The query the picker was filtering with, so backing out of a form returns to
|
|
2949
|
+
// the narrowed list rather than to 40 unfiltered routes.
|
|
2950
|
+
function pickerQuery() {
|
|
2951
|
+
return settingsView.kind === 'provider-picker' ? String(settingsView.query ?? '') : ''
|
|
2952
|
+
}
|
|
2953
|
+
|
|
2954
|
+
// The routes already in use, so declaring one cannot shadow an existing
|
|
2955
|
+
// profile (the picker's own rows come from the addable half only).
|
|
2956
|
+
function takenRoutes(directory) {
|
|
2957
|
+
return directory.configured.map((row) => row.entry.provider)
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
function openProviderForm(draft, meta, selection) {
|
|
2961
|
+
const items = buildCreateFormItems(draft, { protocols: meta.protocols, title: meta.title })
|
|
2962
|
+
const start = items.findIndex((item) => item.kind === 'provider-field' && !item.disabled)
|
|
2963
|
+
renderProviderForm(draft, meta, selection ?? Math.max(0, start))
|
|
2964
|
+
}
|
|
2965
|
+
|
|
2966
|
+
// Re-render the form around the same cursor position: a field commit rebuilds
|
|
2967
|
+
// the list (a picked protocol can change which rows exist), and the scroll
|
|
2968
|
+
// offset the view was at has to survive that.
|
|
2969
|
+
function renderProviderForm(draft, meta, selection = app.settingsSelection) {
|
|
2970
|
+
const scroll = app.settingsScrollOffset
|
|
2971
|
+
providerDraft = draft
|
|
2972
|
+
providerFormMeta = meta
|
|
2973
|
+
settingsView = { kind: 'provider-form' }
|
|
2974
|
+
app.openSettings(buildCreateFormItems(draft, { protocols: meta.protocols, title: meta.title }), {
|
|
2975
|
+
title: draft.mode === 'custom' ? t('provider.form.custom') : t('provider.add'),
|
|
2976
|
+
subtitle: t('provider.formSubtitle'),
|
|
2977
|
+
})
|
|
2978
|
+
app.setSettingsSelection(Math.min(selection, Math.max(0, app.settingsItems.length - 1)))
|
|
2979
|
+
app.settingsScrollOffset = scroll
|
|
2980
|
+
paint()
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
// Adopt a route the installed catalog already describes. Its profile inherits
|
|
2984
|
+
// the endpoint, protocol, and model catalog, so the form only offers the
|
|
2985
|
+
// fields a user actually overrides.
|
|
2986
|
+
function openCatalogProviderForm(providerId) {
|
|
2987
|
+
const directory = providerDirectory(ctx)
|
|
2988
|
+
const row = directory?.addable.find((candidate) => candidate.entry.provider === providerId)
|
|
2989
|
+
if (!directory || !row) {
|
|
2990
|
+
app.showToast(t('model.providerUnavailable'), 'error')
|
|
2991
|
+
openProviderPicker(0)
|
|
2992
|
+
return
|
|
2993
|
+
}
|
|
2994
|
+
// No protocol is preset: a catalog route keeps the one its own catalog
|
|
2995
|
+
// entry names, and forcing the adapter's first choice here would override
|
|
2996
|
+
// it (anthropic would be served over an OpenAI-shaped client).
|
|
2997
|
+
const draft = createDraft('catalog', providerId)
|
|
2998
|
+
openProviderForm(draft, {
|
|
2999
|
+
settingsNs: row.entry.settingsNs,
|
|
3000
|
+
revision: row.descriptor.revision,
|
|
3001
|
+
protocols: protocolChoices(row.descriptor),
|
|
3002
|
+
title: row.entry.displayName,
|
|
3003
|
+
taken: takenRoutes(directory),
|
|
3004
|
+
from: 'picker',
|
|
3005
|
+
query: pickerQuery(),
|
|
3006
|
+
})
|
|
3007
|
+
}
|
|
3008
|
+
|
|
3009
|
+
// Declare a route the catalog does not ship: endpoint, protocol, and models
|
|
3010
|
+
// are the three fields nothing else can supply.
|
|
3011
|
+
function openCustomProviderForm() {
|
|
3012
|
+
const directory = providerDirectory(ctx)
|
|
3013
|
+
const target = directory ? declareNamespace(directory) : undefined
|
|
3014
|
+
if (!directory || !target) {
|
|
3015
|
+
app.showToast(t('model.namespaceUnavailable'), 'error')
|
|
3016
|
+
openProviderPicker(0)
|
|
3017
|
+
return
|
|
3018
|
+
}
|
|
3019
|
+
const draft = createDraft('custom')
|
|
3020
|
+
draft.api = target.protocols[0] ?? ''
|
|
3021
|
+
openProviderForm(draft, {
|
|
3022
|
+
settingsNs: target.settingsNs,
|
|
3023
|
+
revision: target.revision,
|
|
3024
|
+
protocols: target.protocols,
|
|
3025
|
+
from: 'picker',
|
|
3026
|
+
query: pickerQuery(),
|
|
3027
|
+
taken: takenRoutes(directory),
|
|
3028
|
+
})
|
|
3029
|
+
}
|
|
3030
|
+
|
|
3031
|
+
function commitProviderField(item, value, selection) {
|
|
3032
|
+
const draft = { ...providerDraft }
|
|
3033
|
+
if (item.field === 'addModel') {
|
|
3034
|
+
const id = value.trim()
|
|
3035
|
+
if (id !== '' && !draft.models.some((model) => modelEntryId(model) === id)) draft.models = [...draft.models, { id }]
|
|
3036
|
+
} else if (item.field === 'key') {
|
|
3037
|
+
draft.key = value.trim()
|
|
3038
|
+
} else {
|
|
3039
|
+
draft[item.field] = value.trim()
|
|
3040
|
+
// The route id is a settings key the user has not thought about yet; the
|
|
3041
|
+
// display name is the one they have. Filling it from the name is visible
|
|
3042
|
+
// (the row repaints with it) and only ever happens while it is empty.
|
|
3043
|
+
if (item.field === 'displayName' && draft.mode === 'custom' && !draft.route) {
|
|
3044
|
+
const derived = deriveRoute(value)
|
|
3045
|
+
if (derived !== '') draft.route = derived
|
|
3046
|
+
}
|
|
3047
|
+
}
|
|
3048
|
+
renderProviderForm(draft, providerFormMeta, selection)
|
|
3049
|
+
}
|
|
3050
|
+
|
|
3051
|
+
async function saveProviderForm() {
|
|
3052
|
+
const draft = providerDraft
|
|
3053
|
+
const meta = providerFormMeta
|
|
3054
|
+
if (!draft || !meta) return
|
|
3055
|
+
const failure = validateCreateDraft(draft, { taken: meta.taken })
|
|
3056
|
+
if (failure !== undefined) {
|
|
3057
|
+
app.showToast(t('settings.error', { error: failure }), 'error')
|
|
3058
|
+
paint()
|
|
3059
|
+
return
|
|
3060
|
+
}
|
|
3061
|
+
try {
|
|
3062
|
+
await saveCreateDraft(ctx, sharedSettings, draft, { settingsNs: meta.settingsNs, revision: meta.revision })
|
|
3063
|
+
providerDraft = null
|
|
3064
|
+
providerFormMeta = null
|
|
3065
|
+
providerDiscovery = null
|
|
3066
|
+
app.showToast(t('provider.added'), 'info')
|
|
3067
|
+
await openSettingsTab('model')
|
|
3068
|
+
} catch (error) {
|
|
3069
|
+
const conflict = error?.code === 'SETTINGS_CONFLICT'
|
|
3070
|
+
if (conflict) {
|
|
3071
|
+
// The revision this form opened at no longer matches, so a retry would
|
|
3072
|
+
// be refused forever. Take the revision the namespace stands at now:
|
|
3073
|
+
// the profile write is a single path op, which is safe to repeat.
|
|
3074
|
+
const fresh = providerDirectory(ctx)?.descriptors.get(meta.settingsNs)
|
|
3075
|
+
if (fresh) providerFormMeta = { ...meta, revision: fresh.revision }
|
|
3076
|
+
}
|
|
3077
|
+
app.showToast(t('settings.error', { error: conflict ? t('settings.conflict') : formatError(error) }), 'error')
|
|
3078
|
+
paint()
|
|
3079
|
+
}
|
|
3080
|
+
}
|
|
3081
|
+
|
|
3082
|
+
// The model window for an unsaved draft: the same rows the saved-provider
|
|
3083
|
+
// window shows, but a toggle edits the draft instead of storing anything.
|
|
3084
|
+
async function openDraftModelWindow(selection = 0) {
|
|
3085
|
+
const draft = providerDraft
|
|
3086
|
+
const meta = providerFormMeta
|
|
3087
|
+
if (!draft || !meta) return
|
|
3088
|
+
const version = ++settingsLoadVersion
|
|
3089
|
+
settingsView = { kind: 'provider-models', draft: true, parentSelection: app.settingsSelection, selection }
|
|
3090
|
+
app.openSettings([{ label: t('model.fetching'), value: '', disabled: true }], {
|
|
3091
|
+
title: (meta.title ?? draft.route) + ' · ' + t('model.models').toLowerCase(),
|
|
3092
|
+
subtitle: t('model.autoFetching'),
|
|
3093
|
+
})
|
|
3094
|
+
paint()
|
|
3095
|
+
let answer
|
|
3096
|
+
try {
|
|
3097
|
+
answer = await discoverDraftModels(ctx, draft, { settingsNs: meta.settingsNs, providerId: draft.route })
|
|
3098
|
+
} catch (error) {
|
|
3099
|
+
answer = { models: [], failed: true, error: formatError(error) }
|
|
3100
|
+
}
|
|
3101
|
+
if (version !== settingsLoadVersion || settingsView.kind !== 'provider-models' || settingsView.draft !== true) return
|
|
3102
|
+
providerDiscovery = { models: answer.models, failed: answer.failed, error: answer.error }
|
|
3103
|
+
// A fetch that lands is a selection — for a hand-declared route, whose
|
|
3104
|
+
// profile has no catalog to inherit. A catalog route is deliberately left
|
|
3105
|
+
// unselected: an empty `models` means "serve the catalog", which keeps
|
|
3106
|
+
// working after the adapter updates it, while a copied list freezes today's.
|
|
3107
|
+
// Nothing is written until Save provider; the select-all row adds them all.
|
|
3108
|
+
if (providerDraft.mode === 'custom' && answer.models.length > 0 && providerDraft.models.length === 0) {
|
|
3109
|
+
providerDraft = { ...providerDraft, models: mergeModelSelection([], answer.models) }
|
|
3110
|
+
}
|
|
3111
|
+
renderDraftModelWindow(selection)
|
|
3112
|
+
}
|
|
3113
|
+
|
|
3114
|
+
function renderDraftModelWindow(selection = app.settingsSelection) {
|
|
3115
|
+
const draft = providerDraft
|
|
3116
|
+
const meta = providerFormMeta
|
|
3117
|
+
if (!draft || !meta) return
|
|
3118
|
+
const discovered = providerDiscovery ?? { models: [], failed: true }
|
|
3119
|
+
settingsView = { kind: 'provider-models', draft: true, parentSelection: settingsView.parentSelection ?? 0, selection }
|
|
3120
|
+
app.openSettings(draftModelItems(draft, discovered.models, {
|
|
3121
|
+
failed: discovered.failed,
|
|
3122
|
+
error: discovered.error,
|
|
3123
|
+
providerId: draft.route,
|
|
3124
|
+
}), {
|
|
3125
|
+
title: (meta.title ?? draft.route) + ' · ' + t('model.models').toLowerCase(),
|
|
3126
|
+
subtitle: (discovered.failed ? t('model.fetchUnavailable') : t('model.fetchOk')) + ' · ' + t('model.editHint'),
|
|
3127
|
+
})
|
|
3128
|
+
app.setSettingsSelection(Math.min(selection, Math.max(0, app.settingsItems.length - 1)))
|
|
3129
|
+
paint()
|
|
3130
|
+
}
|
|
3131
|
+
|
|
3132
|
+
function toggleDraftModel(item) {
|
|
3133
|
+
const draft = { ...providerDraft }
|
|
3134
|
+
if (item.checked) {
|
|
3135
|
+
draft.models = draft.models.filter((model) => modelEntryId(model) !== item.modelId)
|
|
3136
|
+
} else {
|
|
3137
|
+
const discovered = providerDiscovery?.models?.find((model) => modelEntryId(model) === item.modelId)
|
|
3138
|
+
const row = discovered ? adoptDiscovered(discovered) : undefined
|
|
3139
|
+
draft.models = [...draft.models, row ?? { id: item.modelId }]
|
|
3140
|
+
}
|
|
3141
|
+
providerDraft = draft
|
|
3142
|
+
const rows = draftModelItems(draft, providerDiscovery?.models ?? [], {
|
|
3143
|
+
failed: providerDiscovery?.failed === true,
|
|
3144
|
+
error: providerDiscovery?.error,
|
|
3145
|
+
providerId: draft.route,
|
|
3146
|
+
})
|
|
3147
|
+
// Keep the cursor on the row that was just toggled.
|
|
3148
|
+
const at = rows.findIndex((candidate) => candidate.modelId === item.modelId)
|
|
3149
|
+
renderDraftModelWindow(at >= 0 ? at : 0)
|
|
3150
|
+
}
|
|
3151
|
+
|
|
3152
|
+
// ---- one model's entry ---------------------------------------------------
|
|
3153
|
+
// The WebUI's ModelListEditor edits a row's id, capacities and input
|
|
3154
|
+
// modalities in place; a terminal has one row of each, so the same fields live
|
|
3155
|
+
// in a form of their own. `target.kind` decides where a save lands: the
|
|
3156
|
+
// provider's saved list, or the create form's unsaved draft.
|
|
3157
|
+
let modelDraft = null
|
|
3158
|
+
let modelFormTarget = null
|
|
3159
|
+
|
|
3160
|
+
function openModelForm(target, draft, selection = 0) {
|
|
3161
|
+
modelDraft = draft
|
|
3162
|
+
modelFormTarget = target
|
|
3163
|
+
renderModelForm(draft, target, selection)
|
|
3164
|
+
}
|
|
3165
|
+
|
|
3166
|
+
function renderModelForm(draft, target, selection = app.settingsSelection) {
|
|
3167
|
+
const scroll = app.settingsScrollOffset
|
|
3168
|
+
modelDraft = draft
|
|
3169
|
+
modelFormTarget = target
|
|
3170
|
+
settingsView = { kind: 'model-form' }
|
|
3171
|
+
app.openSettings(buildModelFormItems(draft, { isNew: target.index < 0 }), {
|
|
3172
|
+
title: target.index < 0 ? t('model.form.add') : t('model.edit'),
|
|
3173
|
+
subtitle: t('provider.formSubtitle'),
|
|
3174
|
+
})
|
|
3175
|
+
app.setSettingsSelection(Math.min(selection, Math.max(0, app.settingsItems.length - 1)))
|
|
3176
|
+
app.settingsScrollOffset = scroll
|
|
3177
|
+
paint()
|
|
3178
|
+
}
|
|
3179
|
+
|
|
3180
|
+
function commitModelField(item, value, selection) {
|
|
3181
|
+
const draft = { ...modelDraft }
|
|
3182
|
+
if (item.field === 'input') {
|
|
3183
|
+
draft.input = String(value).trim() === 'text + image' ? ['text', 'image'] : ['text']
|
|
3184
|
+
} else {
|
|
3185
|
+
draft[item.field] = value
|
|
3186
|
+
}
|
|
3187
|
+
renderModelForm(draft, modelFormTarget, selection)
|
|
3188
|
+
}
|
|
3189
|
+
|
|
3190
|
+
// Open the editor for one row of the model window. A row that is not saved
|
|
3191
|
+
// yet opens in create mode, prefilled with what the endpoint advertised — so
|
|
3192
|
+
// "add this model, but with the capacities I know it has" is one pass.
|
|
3193
|
+
function editModelRow(item) {
|
|
3194
|
+
if (settingsView.kind === 'provider-models' && settingsView.draft === true) {
|
|
3195
|
+
const index = providerDraft.models.findIndex((model) => modelEntryId(model) === item.modelId)
|
|
3196
|
+
const discovered = providerDiscovery?.models?.find((model) => modelEntryId(model) === item.modelId)
|
|
3197
|
+
const seed = index >= 0 ? providerDraft.models[index] : adoptDiscovered(discovered) ?? { id: item.modelId }
|
|
3198
|
+
openModelForm({ kind: 'draft', index, providerId: providerDraft.route }, modelDraftFrom(seed), 0)
|
|
3199
|
+
return
|
|
3200
|
+
}
|
|
3201
|
+
const target = {
|
|
3202
|
+
kind: 'stored',
|
|
3203
|
+
settingsNs: item.settingsNs,
|
|
3204
|
+
providerPath: item.providerPath,
|
|
3205
|
+
storedModels: item.storedModels ?? [],
|
|
3206
|
+
revision: item.revision,
|
|
3207
|
+
index: typeof item.storedIndex === 'number' ? item.storedIndex : -1,
|
|
3208
|
+
providerId: item.providerId,
|
|
3209
|
+
}
|
|
3210
|
+
const seed = target.index >= 0 ? target.storedModels[target.index] : adoptDiscovered(item.candidate) ?? { id: item.modelId }
|
|
3211
|
+
openModelForm(target, modelDraftFrom(seed), 0)
|
|
3212
|
+
}
|
|
3213
|
+
|
|
3214
|
+
// Back to the window the editor was opened from, re-reading the provider so
|
|
3215
|
+
// the rows reflect what was just written.
|
|
3216
|
+
async function returnToModelWindow(target) {
|
|
3217
|
+
if (!target) return
|
|
3218
|
+
if (target.kind === 'draft') {
|
|
3219
|
+
renderDraftModelWindow(0)
|
|
3220
|
+
return
|
|
3221
|
+
}
|
|
3222
|
+
await openProviderModels(target.providerId, 0)
|
|
3223
|
+
}
|
|
3224
|
+
|
|
3225
|
+
async function saveModelForm() {
|
|
3226
|
+
const draft = modelDraft
|
|
3227
|
+
const target = modelFormTarget
|
|
3228
|
+
if (!draft || !target) return
|
|
3229
|
+
const existing = target.kind === 'draft'
|
|
3230
|
+
? providerDraft.models.filter((_model, at) => at !== target.index)
|
|
3231
|
+
: target.storedModels.filter((_model, at) => at !== target.index)
|
|
3232
|
+
const failure = validateModelDraft(draft, { existing })
|
|
3233
|
+
if (failure !== undefined) {
|
|
3234
|
+
app.showToast(t('settings.error', { error: failure }), 'error')
|
|
3235
|
+
paint()
|
|
3236
|
+
return
|
|
3237
|
+
}
|
|
3238
|
+
try {
|
|
3239
|
+
if (target.kind === 'draft') {
|
|
3240
|
+
const entry = modelFromDraft(draft)
|
|
3241
|
+
const next = [...providerDraft.models]
|
|
3242
|
+
if (target.index >= 0) next[target.index] = entry
|
|
3243
|
+
else next.push(entry)
|
|
3244
|
+
providerDraft = { ...providerDraft, models: next }
|
|
3245
|
+
app.showToast(t('settings.saved'))
|
|
3246
|
+
await returnToModelWindow(target)
|
|
3247
|
+
return
|
|
3248
|
+
}
|
|
3249
|
+
await saveModelEntry(sharedSettings, target, draft)
|
|
3250
|
+
app.showToast(t('settings.saved'))
|
|
3251
|
+
await returnToModelWindow(target)
|
|
3252
|
+
} catch (error) {
|
|
3253
|
+
const conflict = error?.code === 'SETTINGS_CONFLICT'
|
|
3254
|
+
app.showToast(t('settings.error', { error: conflict ? t('settings.conflict') : formatError(error) }), 'error')
|
|
3255
|
+
paint()
|
|
3256
|
+
}
|
|
3257
|
+
}
|
|
3258
|
+
|
|
3259
|
+
async function removeModelForm() {
|
|
3260
|
+
const target = modelFormTarget
|
|
3261
|
+
if (!target || target.index < 0) return
|
|
3262
|
+
try {
|
|
3263
|
+
if (target.kind === 'draft') {
|
|
3264
|
+
providerDraft = { ...providerDraft, models: providerDraft.models.filter((_model, at) => at !== target.index) }
|
|
3265
|
+
} else {
|
|
3266
|
+
await removeModelEntry(sharedSettings, target)
|
|
3267
|
+
}
|
|
3268
|
+
app.showToast(t('model.removed'), 'info')
|
|
3269
|
+
await returnToModelWindow(target)
|
|
3270
|
+
} catch (error) {
|
|
3271
|
+
app.showToast(t('settings.error', { error: formatError(error) }), 'error')
|
|
3272
|
+
paint()
|
|
3273
|
+
}
|
|
3274
|
+
}
|
|
3275
|
+
|
|
3276
|
+
// Delete inside the model window: same writes as the editor's remove action,
|
|
3277
|
+
// without opening it first.
|
|
3278
|
+
async function removeModelInWindow({ item, draft }) {
|
|
3279
|
+
if (draft === true) {
|
|
3280
|
+
providerDraft = { ...providerDraft, models: providerDraft.models.filter((model) => modelEntryId(model) !== item.modelId) }
|
|
3281
|
+
app.showToast(t('model.removed'), 'info')
|
|
3282
|
+
renderDraftModelWindow(Math.max(0, app.settingsSelection - 1))
|
|
3283
|
+
return
|
|
3284
|
+
}
|
|
3285
|
+
try {
|
|
3286
|
+
await removeModelEntry(sharedSettings, {
|
|
3287
|
+
settingsNs: item.settingsNs,
|
|
3288
|
+
providerPath: item.providerPath,
|
|
3289
|
+
storedModels: item.storedModels ?? [],
|
|
3290
|
+
revision: item.revision,
|
|
3291
|
+
index: item.storedIndex,
|
|
3292
|
+
})
|
|
3293
|
+
app.showToast(t('model.removed'), 'info')
|
|
3294
|
+
await openProviderModels(item.providerId, Math.max(0, app.settingsSelection - 1))
|
|
3295
|
+
} catch (error) {
|
|
3296
|
+
app.showToast(t('settings.error', { error: formatError(error) }), 'error')
|
|
3297
|
+
paint()
|
|
3298
|
+
}
|
|
3299
|
+
}
|
|
3300
|
+
|
|
2598
3301
|
// ---- MCP settings tab ----------------------------------------------------
|
|
2599
3302
|
// The config files are re-read for every render, so a server added, edited or
|
|
2600
3303
|
// removed by another process — or by the model through the bundled skill
|
|
@@ -3071,9 +3774,31 @@ export function apply(ctx, config) {
|
|
|
3071
3774
|
paint()
|
|
3072
3775
|
return
|
|
3073
3776
|
}
|
|
3074
|
-
//
|
|
3075
|
-
|
|
3777
|
+
// The model window is a list of entries, not of settings fields: `e` opens
|
|
3778
|
+
// the highlighted one's editor and Delete drops it from the provider. Both
|
|
3779
|
+
// act on the entry, which is why they are keys of their own rather than
|
|
3780
|
+
// extra rows in a list that is already rows of models.
|
|
3781
|
+
if (settingsView.kind === 'provider-models') {
|
|
3076
3782
|
const item = app.settingsItems[app.settingsSelection]
|
|
3783
|
+
if (item?.kind === 'model-toggle' && (key.name === 'e' || key.text === 'e')) {
|
|
3784
|
+
editModelRow(item)
|
|
3785
|
+
return
|
|
3786
|
+
}
|
|
3787
|
+
if (item?.kind === 'model-toggle' && (key.name === 'delete' || key.name === 'backspace')) {
|
|
3788
|
+
if (settingsView.draft === true) {
|
|
3789
|
+
app.settingsConfirm = { item, value: '', text: t('model.form.removeConfirm'), removeModel: { item, draft: true } }
|
|
3790
|
+
} else if (typeof item.storedIndex === 'number' && item.storedIndex >= 0) {
|
|
3791
|
+
app.settingsConfirm = { item, value: '', text: t('model.form.removeConfirm'), removeModel: { item, draft: false } }
|
|
3792
|
+
} else {
|
|
3793
|
+
// Nothing stored to remove: the row is an offer, not a saved entry.
|
|
3794
|
+
app.showToast(t('model.notSaved'), 'warn')
|
|
3795
|
+
}
|
|
3796
|
+
paint()
|
|
3797
|
+
return
|
|
3798
|
+
}
|
|
3799
|
+
}
|
|
3800
|
+
// Delete on a server row uninstalls it without opening the form first.
|
|
3801
|
+
if (settingsView.kind === 'mcp' && (key.name === 'delete' || key.name === 'backspace')) { const item = app.settingsItems[app.settingsSelection]
|
|
3077
3802
|
if (item?.kind === 'mcp-server' && item.record?.name) {
|
|
3078
3803
|
if (app.settingsConfirm?.item === item) {
|
|
3079
3804
|
app.settingsConfirm = null
|
|
@@ -3113,7 +3838,9 @@ export function apply(ctx, config) {
|
|
|
3113
3838
|
}
|
|
3114
3839
|
} else if (key.name === 'y' || key.text?.toLowerCase() === 'y' || key.name === 'return' || key.name === 'enter') {
|
|
3115
3840
|
app.settingsConfirm = null
|
|
3116
|
-
if (pending.
|
|
3841
|
+
if (pending.removeModel?.form === true) void removeModelForm()
|
|
3842
|
+
else if (pending.removeModel) void removeModelInWindow(pending.removeModel)
|
|
3843
|
+
else if (pending.updateInstall) void startUpdateInstall(pending.updateInstall.pkg, pending.updateInstall.version)
|
|
3117
3844
|
else if (pending.updateRestart) void restartToApply(pending.updateRestart)
|
|
3118
3845
|
else void commitSetting(pending.value, pending.commitItem ?? pending.item)
|
|
3119
3846
|
} else if (key.name === 'n' || key.text?.toLowerCase() === 'n' || key.name === 'escape') {
|
|
@@ -3136,6 +3863,9 @@ export function apply(ctx, config) {
|
|
|
3136
3863
|
} else if (key.text !== undefined && !key.ctrl && !key.alt) {
|
|
3137
3864
|
app.settingsDraft += key.text
|
|
3138
3865
|
}
|
|
3866
|
+
// Typing into the picker's filter narrows the list under it immediately —
|
|
3867
|
+
// a 40-route catalog is only scannable if the filter bites per keystroke.
|
|
3868
|
+
if (settingsView.kind === 'provider-picker' && app.settingsEditing === 0) rebuildPicker(app.settingsDraft)
|
|
3139
3869
|
paint()
|
|
3140
3870
|
return
|
|
3141
3871
|
}
|
|
@@ -3144,9 +3874,35 @@ export function apply(ctx, config) {
|
|
|
3144
3874
|
if (settingsView.kind === 'choice' && settingsView.parent === 'mcp-form') {
|
|
3145
3875
|
// Backing out of the transport/enabled list keeps the half-filled form.
|
|
3146
3876
|
renderMcpForm(mcpFormDraft, mcpFormIsEdit, settingsView.parentSelection)
|
|
3877
|
+
} else if (settingsView.kind === 'choice' && settingsView.parent === 'provider-form') {
|
|
3878
|
+
// Same for a protocol pick inside the add-provider form.
|
|
3879
|
+
renderProviderForm(providerDraft, providerFormMeta, settingsView.parentSelection)
|
|
3880
|
+
} else if (settingsView.kind === 'choice' && settingsView.parent === 'model-form') {
|
|
3881
|
+
// ...and for the input-modalities pick inside the model editor.
|
|
3882
|
+
renderModelForm(modelDraft, modelFormTarget, settingsView.parentSelection)
|
|
3147
3883
|
} else if (settingsView.kind === 'choice') void openSettingsTab(settingsView.parent, settingsView.parentSelection)
|
|
3148
3884
|
else if (settingsView.kind === 'mcp-form') void openSettingsTab('mcp', menuSelections.mcp ?? 0)
|
|
3149
|
-
else if (settingsView.kind === '
|
|
3885
|
+
else if (settingsView.kind === 'model-form') {
|
|
3886
|
+
// Esc drops the edit and returns to the row list, which is where the
|
|
3887
|
+
// user came from.
|
|
3888
|
+
modelDraft = null
|
|
3889
|
+
void returnToModelWindow(modelFormTarget)
|
|
3890
|
+
} else if (settingsView.kind === 'provider-models' && settingsView.draft === true) {
|
|
3891
|
+
// The unsaved draft's model window belongs to the form, not the tab.
|
|
3892
|
+
renderProviderForm(providerDraft, providerFormMeta, settingsView.parentSelection ?? 0)
|
|
3893
|
+
} else if (settingsView.kind === 'provider-models') void openSettingsTab('model', settingsView.parentSelection ?? 0)
|
|
3894
|
+
else if (settingsView.kind === 'provider-form') {
|
|
3895
|
+
// Esc drops the draft. A draft that came from the picker goes back
|
|
3896
|
+
// there (another route is one Enter away, and its filter is kept); one
|
|
3897
|
+
// opened from the Model tab goes back to the tab.
|
|
3898
|
+
const fromPicker = providerFormMeta?.from === 'picker'
|
|
3899
|
+
const query = providerFormMeta?.query ?? ''
|
|
3900
|
+
providerDraft = null
|
|
3901
|
+
providerFormMeta = null
|
|
3902
|
+
providerDiscovery = null
|
|
3903
|
+
if (fromPicker) openProviderPicker(0, query)
|
|
3904
|
+
else void openSettingsTab('model')
|
|
3905
|
+
} else if (settingsView.kind === 'provider-picker') void openSettingsTab('model')
|
|
3150
3906
|
else if (settingsView.kind === 'sessions') void openSettingsTab('main')
|
|
3151
3907
|
else if (settingsView.kind === 'update-versions') void openSettingsTab('update', settingsView.parentSelection ?? 0)
|
|
3152
3908
|
else if (settingsView.kind === 'update-download') {
|
|
@@ -3240,6 +3996,78 @@ export function apply(ctx, config) {
|
|
|
3240
3996
|
void commitSetting(item.optionValue, item.settingItem)
|
|
3241
3997
|
return
|
|
3242
3998
|
}
|
|
3999
|
+
if (item.kind === 'provider-add') {
|
|
4000
|
+
openProviderPicker(0)
|
|
4001
|
+
return
|
|
4002
|
+
}
|
|
4003
|
+
if (item.kind === 'provider-pick') {
|
|
4004
|
+
openCatalogProviderForm(item.providerId)
|
|
4005
|
+
return
|
|
4006
|
+
}
|
|
4007
|
+
if (item.kind === 'provider-pick-custom') {
|
|
4008
|
+
openCustomProviderForm()
|
|
4009
|
+
return
|
|
4010
|
+
}
|
|
4011
|
+
if (item.kind === 'provider-save') {
|
|
4012
|
+
void saveProviderForm()
|
|
4013
|
+
return
|
|
4014
|
+
}
|
|
4015
|
+
if (item.kind === 'provider-search') {
|
|
4016
|
+
// The filter row is a normal text edit; `rebuildPicker` narrows the list
|
|
4017
|
+
// under it as each character lands.
|
|
4018
|
+
app.settingsEditing = app.settingsSelection
|
|
4019
|
+
app.settingsSecret = false
|
|
4020
|
+
app.settingsDraft = String(settingsView.query ?? '')
|
|
4021
|
+
paint()
|
|
4022
|
+
return
|
|
4023
|
+
}
|
|
4024
|
+
if (item.kind === 'model-select-all') {
|
|
4025
|
+
if (settingsView.kind === 'provider-models' && settingsView.draft === true) {
|
|
4026
|
+
// The draft holds no selection yet, so "all" is exactly the endpoint's
|
|
4027
|
+
// answer; "none" clears what the fetch pre-picked.
|
|
4028
|
+
const draft = { ...providerDraft }
|
|
4029
|
+
draft.models = item.allPicked ? [] : mergeModelSelection([], providerDiscovery?.models ?? [])
|
|
4030
|
+
providerDraft = draft
|
|
4031
|
+
renderDraftModelWindow(0)
|
|
4032
|
+
} else {
|
|
4033
|
+
// The saved window writes through: one mutate for the whole list.
|
|
4034
|
+
void commitSetting(item.allPicked ? 'off' : 'on', item)
|
|
4035
|
+
}
|
|
4036
|
+
return
|
|
4037
|
+
}
|
|
4038
|
+
if (item.kind === 'model-add') {
|
|
4039
|
+
openModelForm({
|
|
4040
|
+
kind: settingsView.draft === true ? 'draft' : 'stored',
|
|
4041
|
+
providerId: item.providerId,
|
|
4042
|
+
settingsNs: item.settingsNs,
|
|
4043
|
+
providerPath: item.providerPath,
|
|
4044
|
+
storedModels: settingsView.draft === true ? providerDraft.models : (item.storedModels ?? []),
|
|
4045
|
+
revision: item.revision,
|
|
4046
|
+
index: -1,
|
|
4047
|
+
}, modelDraftFrom({ id: '' }), 0)
|
|
4048
|
+
return
|
|
4049
|
+
}
|
|
4050
|
+
if (item.kind === 'model-refresh') {
|
|
4051
|
+
// Ask the endpoint again — the reason a catalog can be empty the first
|
|
4052
|
+
// time is usually that it was not reachable yet.
|
|
4053
|
+
if (settingsView.kind === 'provider-models' && settingsView.draft === true) void openDraftModelWindow(0)
|
|
4054
|
+
else void openProviderModels(item.providerId, 0)
|
|
4055
|
+
return
|
|
4056
|
+
}
|
|
4057
|
+
if (item.kind === 'model-save') {
|
|
4058
|
+
void saveModelForm()
|
|
4059
|
+
return
|
|
4060
|
+
}
|
|
4061
|
+
if (item.kind === 'model-remove') {
|
|
4062
|
+
if (app.settingsConfirm?.item === item) {
|
|
4063
|
+
app.settingsConfirm = null
|
|
4064
|
+
void removeModelForm()
|
|
4065
|
+
} else {
|
|
4066
|
+
app.settingsConfirm = { item, value: '', text: item.confirmText ?? t('model.form.removeConfirm'), removeModel: { form: true } }
|
|
4067
|
+
}
|
|
4068
|
+
paint()
|
|
4069
|
+
return
|
|
4070
|
+
}
|
|
3243
4071
|
if (item.kind === 'provider-models') {
|
|
3244
4072
|
// Auto-fetch the provider's model catalog and open the selection
|
|
3245
4073
|
// window.
|
|
@@ -3252,7 +4080,37 @@ export function apply(ctx, config) {
|
|
|
3252
4080
|
return
|
|
3253
4081
|
}
|
|
3254
4082
|
if (item.kind === 'model-toggle') {
|
|
3255
|
-
|
|
4083
|
+
// A toggle in the unsaved draft's window edits the draft; one in the
|
|
4084
|
+
// saved provider's window writes straight through.
|
|
4085
|
+
if (settingsView.kind === 'provider-models' && settingsView.draft === true) toggleDraftModel(item)
|
|
4086
|
+
else void commitSetting(item.checked ? 'off' : 'on', item)
|
|
4087
|
+
return
|
|
4088
|
+
}
|
|
4089
|
+
// The add-provider form is a draft like the MCP form: a field commit lands
|
|
4090
|
+
// in the draft and re-renders, and only Save writes.
|
|
4091
|
+
if (settingsView.kind === 'provider-form' && item.kind === 'provider-field') {
|
|
4092
|
+
if (item.providerModels === true) {
|
|
4093
|
+
void openDraftModelWindow(0)
|
|
4094
|
+
return
|
|
4095
|
+
}
|
|
4096
|
+
if (!item.disabled && item.options?.length > 0) openSettingChoices(item, app.settingsSelection)
|
|
4097
|
+
else if (!item.disabled) {
|
|
4098
|
+
app.settingsEditing = app.settingsSelection
|
|
4099
|
+
app.settingsSecret = item.secret === true
|
|
4100
|
+
app.settingsDraft = item.secret === true ? '' : String(item.value ?? '')
|
|
4101
|
+
}
|
|
4102
|
+
paint()
|
|
4103
|
+
return
|
|
4104
|
+
}
|
|
4105
|
+
// The model editor is the same shape of draft.
|
|
4106
|
+
if (settingsView.kind === 'model-form' && item.kind === 'model-field') {
|
|
4107
|
+
if (!item.disabled && item.options?.length > 0) openSettingChoices(item, app.settingsSelection)
|
|
4108
|
+
else if (!item.disabled) {
|
|
4109
|
+
app.settingsEditing = app.settingsSelection
|
|
4110
|
+
app.settingsSecret = false
|
|
4111
|
+
app.settingsDraft = String(item.value ?? '')
|
|
4112
|
+
}
|
|
4113
|
+
paint()
|
|
3256
4114
|
return
|
|
3257
4115
|
}
|
|
3258
4116
|
// Every choice item opens its option list - there is no inline
|
|
@@ -3300,6 +4158,23 @@ export function apply(ctx, config) {
|
|
|
3300
4158
|
paint()
|
|
3301
4159
|
return
|
|
3302
4160
|
}
|
|
4161
|
+
// The add-provider form holds a draft, so its fields commit there and only
|
|
4162
|
+
// "Save provider" reaches the settings service.
|
|
4163
|
+
if (settingsView.kind === 'provider-form' && item?.kind === 'provider-field') {
|
|
4164
|
+
commitProviderField(item, value, selection)
|
|
4165
|
+
return
|
|
4166
|
+
}
|
|
4167
|
+
// The picker's filter row writes nothing either: it narrows the list it sits
|
|
4168
|
+
// above, and the query is kept on the view.
|
|
4169
|
+
if (settingsView.kind === 'provider-picker' && item?.kind === 'provider-search') {
|
|
4170
|
+
rebuildPicker(value, { editing: false })
|
|
4171
|
+
paint()
|
|
4172
|
+
return
|
|
4173
|
+
}
|
|
4174
|
+
if (settingsView.kind === 'model-form' && item?.kind === 'model-field') {
|
|
4175
|
+
commitModelField(item, value, selection)
|
|
4176
|
+
return
|
|
4177
|
+
}
|
|
3303
4178
|
if (!sharedSettings || !item || (value.trim() === '' && !['path', 'enable-provider', 'remove-provider'].includes(item.kind))) {
|
|
3304
4179
|
paint()
|
|
3305
4180
|
return
|
|
@@ -3377,6 +4252,7 @@ export function apply(ctx, config) {
|
|
|
3377
4252
|
break
|
|
3378
4253
|
}
|
|
3379
4254
|
case 'p': openSettings(); break
|
|
4255
|
+
case 'g': void openGitBranchOverlay(); break
|
|
3380
4256
|
case 'd': quit(); break
|
|
3381
4257
|
case 'n': {
|
|
3382
4258
|
void showTitleScreen()
|
|
@@ -3690,6 +4566,12 @@ export function apply(ctx, config) {
|
|
|
3690
4566
|
})()
|
|
3691
4567
|
break
|
|
3692
4568
|
}
|
|
4569
|
+
case 'branch': {
|
|
4570
|
+
// The window is the title screen's; inside a session the call answers
|
|
4571
|
+
// with the same toast the Ctrl+G shortcut does, and opens nothing.
|
|
4572
|
+
void openGitBranchOverlay()
|
|
4573
|
+
break
|
|
4574
|
+
}
|
|
3693
4575
|
default:
|
|
3694
4576
|
feedback(t('session.unknownCommand', { name: parsed.name }), 'warn')
|
|
3695
4577
|
}
|
|
@@ -3908,6 +4790,9 @@ export function apply(ctx, config) {
|
|
|
3908
4790
|
ctx.effect(() => {
|
|
3909
4791
|
const restore = () => {
|
|
3910
4792
|
term.stop()
|
|
4793
|
+
// After the alt screen is released: the process streams go back to the
|
|
4794
|
+
// host, and whatever was diverted is replayed where it can be read.
|
|
4795
|
+
diagnostics.restore()
|
|
3911
4796
|
}
|
|
3912
4797
|
const onExit = () => restore()
|
|
3913
4798
|
const onSigint = () => handleCtrlKey('c')
|