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/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
- term.on('resize', () => paintNow())
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
- async function attachFromText(text) {
1921
- const trimmed = text.trim()
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
- if (key.name === 'mouse') {
2192
- handleMouse(key.mouse)
2193
- return
2194
- }
2195
- if (key.name === 'paste') {
2196
- void handlePaste(key.data)
2197
- return
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') await openProviderModels(settingsView.provider, selection)
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
- // Delete on a server row uninstalls it without opening the form first.
3075
- if (settingsView.kind === 'mcp' && (key.name === 'delete' || key.name === 'backspace')) {
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.updateInstall) void startUpdateInstall(pending.updateInstall.pkg, pending.updateInstall.version)
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 === 'provider-models') void openSettingsTab('model', settingsView.parentSelection ?? 0)
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
- void commitSetting(item.checked ? 'off' : 'on', item)
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')