termux-dev 1.8.1 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +17 -4
  2. package/assets/banner.svg +1 -1
  3. package/assets/preview.svg +1 -1
  4. package/dist/cli/background-ui.js +116 -0
  5. package/dist/cli/files.js +2 -12
  6. package/dist/cli/finder.js +4 -0
  7. package/dist/cli/headless.js +6 -3
  8. package/dist/cli/index.js +259 -74
  9. package/dist/cli/keys.js +12 -3
  10. package/dist/cli/liveblock.js +38 -9
  11. package/dist/cli/mcp-panel.js +2 -0
  12. package/dist/cli/picker.js +2 -0
  13. package/dist/cli/prompt.js +93 -8
  14. package/dist/cli/provider-manager.js +197 -0
  15. package/dist/cli/providers.js +0 -5
  16. package/dist/cli/settings.js +38 -17
  17. package/dist/cli/typeahead.js +10 -17
  18. package/dist/cli/ui.js +189 -32
  19. package/dist/cli/updater.js +10 -8
  20. package/dist/cli/usage-panel.js +17 -4
  21. package/dist/cli/width.js +18 -0
  22. package/dist/core/background.js +390 -0
  23. package/dist/core/config-merge.js +67 -0
  24. package/dist/core/context-stats.js +1 -0
  25. package/dist/core/cost.js +25 -11
  26. package/dist/core/custom-providers.js +161 -0
  27. package/dist/core/images.js +46 -0
  28. package/dist/core/local-config.js +3 -1
  29. package/dist/core/locales/de.js +36 -7
  30. package/dist/core/locales/en.js +36 -7
  31. package/dist/core/locales/es.js +36 -7
  32. package/dist/core/locales/fr.js +36 -7
  33. package/dist/core/locales/ja.js +36 -7
  34. package/dist/core/locales/pt.js +36 -7
  35. package/dist/core/locales/ru.js +36 -7
  36. package/dist/core/locales/uk.js +36 -7
  37. package/dist/core/locales/zh.js +36 -7
  38. package/dist/core/loop.js +50 -2
  39. package/dist/core/prices.js +8 -4
  40. package/dist/core/session-media.js +115 -0
  41. package/dist/core/session.js +17 -2
  42. package/dist/core/title.js +19 -2
  43. package/dist/core/types.js +1 -1
  44. package/dist/core/usage.js +41 -4
  45. package/dist/permissions/guard.js +6 -1
  46. package/dist/prompts/builder.js +2 -1
  47. package/dist/providers/anthropic.js +67 -13
  48. package/dist/providers/index.js +16 -3
  49. package/dist/providers/openai.js +99 -41
  50. package/dist/providers/shared.js +83 -0
  51. package/dist/tools/background.js +118 -0
  52. package/dist/tools/bash.js +23 -1
  53. package/dist/tools/fs.js +32 -2
  54. package/dist/tools/index.js +2 -1
  55. package/package.json +1 -1
package/dist/cli/keys.js CHANGED
@@ -13,11 +13,20 @@ export function splitKeys(chunk) {
13
13
  }
14
14
  /** Erases the word before the cursor (Ctrl+W in a shell); Ctrl+Backspace and Alt+Backspace are mapped to it. */
15
15
  export const WORD_BACKSPACE = '\x17';
16
- /** What a terminal sends for Ctrl+Enter once the extended keyboard modes below are on. */
16
+ /**
17
+ * What a terminal sends for Ctrl+Enter once the extended keyboard modes below are on. Ctrl+Enter
18
+ * and Alt+Enter break the line (a new paragraph) instead of sending it, like Shift+Enter.
19
+ */
17
20
  export const CTRL_ENTER = '\x1b[13;5u';
21
+ /** Shift+Enter in the two extended encodings (a plain terminal sends the same byte as for Enter). */
22
+ const SHIFT_ENTER = /^\x1b\[(?:13;2u|27;2;13~)$/;
23
+ /** Does this key break the line instead of sending it? Ctrl+Enter, Alt+Enter and Shift+Enter, as far as the terminal tells them from Enter. */
24
+ export function isNewlineKey(key) {
25
+ return normalizeKey(key) === CTRL_ENTER || SHIFT_ENTER.test(key);
26
+ }
18
27
  /**
19
- * Asks the terminal to tell Ctrl+Enter apart from Enter (a plain terminal sends the same
20
- * byte for both). Two protocols are requested, a terminal answers the one it knows:
28
+ * Asks the terminal to tell Ctrl+Enter and Shift+Enter apart from Enter (a plain terminal sends
29
+ * the same byte for all of them). Two protocols are requested, a terminal answers the one it knows:
21
30
  * the kitty keyboard protocol (kitty, foot, ghostty, wezterm, alacritty, ...) and xterm's
22
31
  * modifyOtherKeys. Terminals that know neither ignore both, and Alt+Enter still works.
23
32
  * While on, the keys arrive as `CSI … u`: run them through `normalizeKey`.
@@ -1,3 +1,4 @@
1
+ import { displayWidth } from './width.js';
1
2
  /**
2
3
  * A block of terminal rows that is redrawn in place.
3
4
  *
@@ -5,41 +6,69 @@
5
6
  * rewrites every row and wipes the rows left over from a taller previous version, so the
6
7
  * screen never grows however often the content changes. The cursor is hidden while the
7
8
  * block is on screen.
9
+ *
10
+ * The way back up is counted in rows of the screen, not in lines of the block: when the window
11
+ * gets narrower the terminal wraps the lines it already drew onto more rows, and going up by the
12
+ * number of lines would leave the top of the old block on screen (the block would show up twice).
8
13
  */
9
14
  export class LiveBlock {
10
15
  out;
11
- rows = 0;
16
+ /** Visible width of each line of the block as it was drawn. */
17
+ widths = [];
12
18
  constructor(out) {
13
19
  this.out = out;
14
20
  }
21
+ /** Number of lines of the block on screen. */
15
22
  get height() {
16
- return this.rows;
23
+ return this.widths.length;
24
+ }
25
+ columns() {
26
+ return Math.max(1, this.out.columns || process.stdout.columns || 80);
27
+ }
28
+ /** Rows of the screen the given lines take at the current width. */
29
+ rowsOf(widths) {
30
+ const cols = this.columns();
31
+ return widths.reduce((n, w) => n + Math.max(1, Math.ceil(w / cols)), 0);
17
32
  }
18
33
  draw(lines) {
34
+ const before = this.rowsOf(this.widths);
19
35
  let buf = '\x1b[?25l';
20
- if (this.rows > 1)
21
- buf += `\x1b[${this.rows - 1}A`;
36
+ if (before > 1)
37
+ buf += `\x1b[${before - 1}A`;
22
38
  buf += '\r';
23
39
  lines.forEach((line, i) => {
24
40
  buf += '\x1b[2K' + line + '\x1b[0m' + (i < lines.length - 1 ? '\n' : '');
25
41
  });
42
+ const widths = lines.map((line) => displayWidth(line));
43
+ const now = this.rowsOf(widths);
26
44
  let extra = 0;
27
- for (let k = lines.length; k < this.rows; k++) {
45
+ for (let k = now; k < before; k++) {
28
46
  buf += '\n\x1b[2K';
29
47
  extra++;
30
48
  }
31
49
  if (extra > 0)
32
50
  buf += `\x1b[${extra}A`;
33
51
  this.out.write(buf);
34
- this.rows = lines.length;
52
+ this.widths = widths;
35
53
  }
36
54
  /** Removes the block from the screen and shows the cursor again. */
37
55
  erase() {
38
- if (this.rows === 0) {
56
+ const rows = this.rowsOf(this.widths);
57
+ if (rows === 0) {
39
58
  this.out.write('\x1b[?25h');
40
59
  return;
41
60
  }
42
- this.out.write(`\r${this.rows > 1 ? `\x1b[${this.rows - 1}A` : ''}\x1b[J\x1b[?25h`);
43
- this.rows = 0;
61
+ this.out.write(`\r${rows > 1 ? `\x1b[${rows - 1}A` : ''}\x1b[J\x1b[?25h`);
62
+ this.widths = [];
63
+ }
64
+ /**
65
+ * Calls `redraw` when the window is resized, so the block is drawn again at the new width.
66
+ * Returns the function that stops watching (call it when the panel closes).
67
+ */
68
+ watchResize(redraw) {
69
+ this.out.on?.('resize', redraw);
70
+ return () => {
71
+ this.out.off?.('resize', redraw);
72
+ };
44
73
  }
45
74
  }
@@ -314,11 +314,13 @@ export function runMcpPanel(controller, options = {}) {
314
314
  const wasRaw = !!input.isRaw;
315
315
  input.setRawMode?.(true);
316
316
  input.resume();
317
+ const stopWatching = block.watchResize(draw);
317
318
  const close = () => {
318
319
  if (closed)
319
320
  return;
320
321
  closed = true;
321
322
  input.off('data', onData);
323
+ stopWatching();
322
324
  block.erase();
323
325
  if (!wasRaw)
324
326
  input.setRawMode?.(false);
@@ -99,9 +99,11 @@ export function runPicker(options, io = { input: process.stdin, output: process.
99
99
  const wasRaw = !!input.isRaw;
100
100
  input.setRawMode?.(true);
101
101
  input.resume();
102
+ const stopWatching = block.watchResize(draw);
102
103
  const finish = (value) => {
103
104
  done = true;
104
105
  input.off('data', onData);
106
+ stopWatching();
105
107
  block.erase();
106
108
  if (!wasRaw)
107
109
  input.setRawMode?.(false);
@@ -6,8 +6,9 @@ import { CustomCommandManager } from '../core/commands.js';
6
6
  import { formatPromptHeader } from './modeborder.js';
7
7
  import { filterCommandPalette, estimatePromptTokens, formatPromptTokenBadge } from './prompt_palette.js';
8
8
  import { PromptHistoryNavigator } from './history_search.js';
9
- import { buildPromptFrame, palette, renderUserEcho, resolveMode } from './ui.js';
10
- import { padEndWidth, truncateWidth } from './width.js';
9
+ import { buildPromptFrame, moveCursorRow, palette, renderUserEcho, resolveMode } from './ui.js';
10
+ import { displayWidth, padEndWidth, rowsToTop, truncateWidth } from './width.js';
11
+ import { EXTENDED_KEYS_OFF, EXTENDED_KEYS_ON, isNewlineKey, normalizeKey } from './keys.js';
11
12
  export const SLASH_COMMANDS = [
12
13
  { cmd: '/new', desc: 'Start a new clean chat session' },
13
14
  { cmd: '/resume', desc: 'Resume a previous chat session' },
@@ -27,6 +28,7 @@ export const SLASH_COMMANDS = [
27
28
  { cmd: '/agent', desc: 'Switch to AGENT mode (coder & executor)' },
28
29
  { cmd: '/image', desc: 'Paste the image from the clipboard (same as Ctrl+V or Alt+V)' },
29
30
  { cmd: '/serve', desc: 'Start local web server for web/game preview' },
31
+ { cmd: '/tasks', desc: 'Background tasks: list, /tasks <id> output, /tasks stop <id|all>' },
30
32
  { cmd: '/copy', desc: 'Copy the last answer of the AI to the clipboard' },
31
33
  { cmd: '/rewind', desc: 'Go back to one of your messages: the chat after it is removed' },
32
34
  { cmd: '/rename', desc: 'Rename the chat (/rename <name>), or let the AI name it again' },
@@ -96,12 +98,19 @@ export function askPrompt(opts = {}) {
96
98
  if (process.stdin.isTTY) {
97
99
  process.stdin.setRawMode(true);
98
100
  process.stdout.write('\x1b[?2004h'); // Enable bracketed paste mode
101
+ // Lets the terminal tell Ctrl+Enter and Shift+Enter (a new paragraph) from Enter (send).
102
+ process.stdout.write(EXTENDED_KEYS_ON);
99
103
  }
100
104
  // ── framed prompt (clean layout) ──
101
105
  // The frame is a block of rows; the cursor rests on the input row. Redrawing moves up
102
106
  // to the top of the block, rewrites every row and returns to the cursor position.
103
107
  let frameRowsDrawn = 0;
104
108
  let frameCursorRow = 0;
109
+ // The geometry of what is on screen, to find the top of the frame again after a resize
110
+ // (the terminal re-wraps the rows, so the number of lines is not the number of rows).
111
+ let frameLineWidths = [];
112
+ let frameCursorCol = 0;
113
+ const rowsUpToFrameTop = () => rowsToTop(frameLineWidths, frameCursorRow, frameCursorCol, process.stdout.columns || 80);
105
114
  function cleanDropdownRows(items, cols, rows) {
106
115
  if (items.length === 0)
107
116
  return [];
@@ -133,7 +142,7 @@ export function askPrompt(opts = {}) {
133
142
  const rows = process.stdout.rows || 24;
134
143
  const frame = buildPromptFrame({
135
144
  cols,
136
- mode: resolveMode(!!opts.planMode, !!opts.isYolo),
145
+ mode: resolveMode(!!opts.planMode, !!opts.isYolo, !!opts.isExtra),
137
146
  model: opts.model,
138
147
  cwd: opts.cwd,
139
148
  branch: opts.branch,
@@ -141,14 +150,16 @@ export function askPrompt(opts = {}) {
141
150
  contextPercent: opts.contextPercent ?? 0,
142
151
  cost: opts.cost ?? 0,
143
152
  costState: opts.costState,
153
+ background: opts.background?.(),
144
154
  input,
145
155
  cursorPos,
146
156
  placeholder,
147
157
  below: cleanDropdownRows(getDropdownItems(), cols, rows),
148
158
  });
149
159
  let buf = '\x1b[0m';
150
- if (frameRowsDrawn > 0 && frameCursorRow > 0)
151
- buf += `\x1b[${frameCursorRow}A`;
160
+ const up = frameRowsDrawn > 0 ? rowsUpToFrameTop() : 0;
161
+ if (up > 0)
162
+ buf += `\x1b[${up}A`;
152
163
  buf += '\r';
153
164
  frame.lines.forEach((line, i) => {
154
165
  buf += '\x1b[2K' + line + '\x1b[0m' + (i < frame.lines.length - 1 ? '\n' : '');
@@ -164,13 +175,18 @@ export function askPrompt(opts = {}) {
164
175
  process.stdout.write(buf);
165
176
  frameRowsDrawn = frame.lines.length;
166
177
  frameCursorRow = frame.cursorRow;
178
+ frameCursorCol = frame.cursorCol;
179
+ frameLineWidths = frame.lines.map((line) => displayWidth(line));
167
180
  }
168
181
  function eraseFrame() {
169
182
  if (frameRowsDrawn === 0)
170
183
  return;
171
- process.stdout.write(`\x1b[0m${frameCursorRow > 0 ? `\x1b[${frameCursorRow}A` : ''}\r\x1b[J`);
184
+ const up = rowsUpToFrameTop();
185
+ process.stdout.write(`\x1b[0m${up > 0 ? `\x1b[${up}A` : ''}\r\x1b[J`);
172
186
  frameRowsDrawn = 0;
173
187
  frameCursorRow = 0;
188
+ frameCursorCol = 0;
189
+ frameLineWidths = [];
174
190
  }
175
191
  const onResize = () => {
176
192
  if (disposed)
@@ -180,6 +196,35 @@ export function askPrompt(opts = {}) {
180
196
  render();
181
197
  };
182
198
  process.stdout.on('resize', onResize);
199
+ const unsubscribeOwner = opts.subscribe?.((kind) => {
200
+ if (disposed)
201
+ return;
202
+ if (kind === 'redraw') {
203
+ render();
204
+ return;
205
+ }
206
+ // News for the model: close the prompt, the owner starts a turn. What was typed is kept.
207
+ if (cleanLayout)
208
+ eraseFrame();
209
+ else
210
+ process.stdout.write(`\x1b[0m\r\x1b[2K`);
211
+ cleanup();
212
+ resolve(`__WAKE__:${input}`);
213
+ });
214
+ /** Up / down inside a text that wraps onto several rows; false when there is no row to go to (history takes over). */
215
+ function moveInInput(dir) {
216
+ if (!cleanLayout)
217
+ return false;
218
+ let to = moveCursorRow(input, cursorPos, process.stdout.columns || 80, dir);
219
+ if (to === null)
220
+ return false;
221
+ // Never land inside an attachment tag: go to its start.
222
+ const inside = getTagSpans().find((sp) => to > sp.start && to < sp.end);
223
+ if (inside)
224
+ to = inside.start;
225
+ cursorPos = to;
226
+ return true;
227
+ }
183
228
  function getTagSpans() {
184
229
  const spans = [];
185
230
  const regex = new RegExp(`\\[Pasted text #\\d+ \\+\\d+ lines\\]|${IMAGE_TAG_SOURCE}`, 'g');
@@ -505,8 +550,9 @@ export function askPrompt(opts = {}) {
505
550
  render();
506
551
  function cleanup() {
507
552
  disposed = true;
553
+ unsubscribeOwner?.();
508
554
  if (process.stdout.isTTY) {
509
- process.stdout.write('\x1b[?2004l');
555
+ process.stdout.write('\x1b[?2004l' + EXTENDED_KEYS_OFF);
510
556
  }
511
557
  process.stdout.removeListener('resize', onResize);
512
558
  process.stdin.removeListener('data', onData);
@@ -548,10 +594,27 @@ export function askPrompt(opts = {}) {
548
594
  cleanup();
549
595
  resolve(fullText);
550
596
  }
597
+ /** Breaks the line at the cursor (a new paragraph). A badge such as [Pasted text #1] is never cut in two. */
598
+ function insertNewline() {
599
+ const inside = getTagSpans().find((s) => cursorPos > s.start && cursorPos < s.end);
600
+ if (inside)
601
+ cursorPos = inside.end;
602
+ input = input.slice(0, cursorPos) + '\n' + input.slice(cursorPos);
603
+ cursorPos++;
604
+ selectedIndex = 0;
605
+ render();
606
+ }
551
607
  function onData(data) {
552
608
  if (disposed)
553
609
  return;
554
- const str = data.toString('utf-8');
610
+ const raw = data.toString('utf-8');
611
+ // Ctrl+Enter, Alt+Enter and Shift+Enter break the line instead of sending it.
612
+ if (cleanLayout && isNewlineKey(raw)) {
613
+ insertNewline();
614
+ return;
615
+ }
616
+ // With the extended keyboard mode on, Ctrl+C, Esc, Ctrl+V... arrive as `CSI … u`: the usual keys again.
617
+ const str = normalizeKey(raw);
555
618
  // Bracketed paste mode handling
556
619
  if (str.includes('\x1b[200~')) {
557
620
  inBracketedPaste = true;
@@ -624,10 +687,26 @@ export function askPrompt(opts = {}) {
624
687
  return;
625
688
  }
626
689
  const items = getDropdownItems();
690
+ // Esc with the command list (or the file list of an @) open: that entry is cancelled, the line is cleared.
691
+ if (str === '\x1b' && cleanLayout && items.length > 0) {
692
+ input = '';
693
+ cursorPos = 0;
694
+ selectedIndex = 0;
695
+ render();
696
+ return;
697
+ }
627
698
  // Enter / Return
628
699
  if (str === '\r' ||
629
700
  str === '\n' ||
630
701
  (str.length === 1 && (str.charCodeAt(0) === 13 || str.charCodeAt(0) === 10))) {
702
+ // A backslash right before the cursor turns Enter into a line break (a terminal that cannot
703
+ // tell Ctrl+Enter from Enter, a phone keyboard for one).
704
+ if (cleanLayout && items.length === 0 && cursorPos > 0 && input[cursorPos - 1] === '\\') {
705
+ input = input.slice(0, cursorPos - 1) + input.slice(cursorPos);
706
+ cursorPos--;
707
+ insertNewline();
708
+ return;
709
+ }
631
710
  // Direct /image command
632
711
  if (input.trim() === '/image' ||
633
712
  (items.length > 0 &&
@@ -696,6 +775,9 @@ export function askPrompt(opts = {}) {
696
775
  selectedIndex = Math.max(0, selectedIndex - 1); // stops at the first item
697
776
  render();
698
777
  }
778
+ else if (moveInInput(-1)) {
779
+ render();
780
+ }
699
781
  else {
700
782
  const prev = historyNav.navigateUp(input);
701
783
  if (prev !== null) {
@@ -712,6 +794,9 @@ export function askPrompt(opts = {}) {
712
794
  selectedIndex = Math.min(items.length - 1, selectedIndex + 1); // stops at the last item
713
795
  render();
714
796
  }
797
+ else if (moveInInput(1)) {
798
+ render();
799
+ }
715
800
  else {
716
801
  const next = historyNav.navigateDown();
717
802
  if (next !== null) {
@@ -0,0 +1,197 @@
1
+ import { runPicker } from './picker.js';
2
+ import { runTextInput } from './finder.js';
3
+ import { askConfirm } from './confirm.js';
4
+ import { renderNotice } from './ui.js';
5
+ import { displayWidth } from './width.js';
6
+ import { t } from '../core/i18n.js';
7
+ import { NO_KEY, addCustomProvider, checkBaseUrl, customProviders, hostOfUrl, keyHint, nameProblem, removeCustomProvider, updateCustomProvider, usuallyNeedsNoKey, } from '../core/custom-providers.js';
8
+ const realUi = { text: runTextInput, picker: runPicker, confirm: askConfirm };
9
+ const defaultSay = (kind, text) => console.log(renderNotice(kind, text));
10
+ const problemText = {
11
+ empty: () => t('provider.name.empty'),
12
+ long: () => t('provider.name.long'),
13
+ taken: () => t('provider.name.taken'),
14
+ };
15
+ /** The name of the provider; asked again with the reason when it cannot be used. undefined: Esc. */
16
+ export async function askName(config, ui, opts = {}) {
17
+ let value = opts.initial ?? '';
18
+ let warning = '';
19
+ for (;;) {
20
+ const typed = await ui.text({
21
+ title: t('provider.new'),
22
+ subtitle: opts.subtitle,
23
+ label: t('provider.name.label'),
24
+ initial: value,
25
+ placeholder: t('provider.name.placeholder'),
26
+ notes: warning ? [warning] : undefined,
27
+ });
28
+ if (typed === undefined)
29
+ return undefined;
30
+ const problem = nameProblem(config, typed, opts.exceptId);
31
+ if (!problem)
32
+ return typed.trim();
33
+ value = typed;
34
+ warning = problemText[problem]();
35
+ }
36
+ }
37
+ /** The address of the API; asked again with the reason when it is not one. undefined: Esc. */
38
+ export async function askUrl(ui, opts = {}) {
39
+ let value = opts.initial ?? '';
40
+ let warning = '';
41
+ for (;;) {
42
+ const typed = await ui.text({
43
+ title: t('onboard.baseUrl.title'),
44
+ subtitle: opts.subtitle,
45
+ label: t('onboard.baseUrl.label'),
46
+ initial: value,
47
+ placeholder: t('onboard.baseUrl.placeholder'),
48
+ notes: [...(warning ? [warning] : []), t('provider.url.note')],
49
+ });
50
+ if (typed === undefined)
51
+ return undefined;
52
+ const checked = checkBaseUrl(typed);
53
+ if (checked.ok)
54
+ return checked.url;
55
+ value = typed;
56
+ warning = checked.reason === 'scheme' ? t('provider.url.scheme') : t('provider.url.invalid');
57
+ }
58
+ }
59
+ /**
60
+ * The key of the provider. A server on this machine or network may need none: that is offered
61
+ * first. Returns the key, NO_KEY for "none", or undefined for Esc.
62
+ */
63
+ export async function askKey(ui, opts) {
64
+ if (usuallyNeedsNoKey(opts.baseUrl)) {
65
+ const choice = await ui.picker({
66
+ title: t('onboard.key.title'),
67
+ subtitle: opts.name,
68
+ items: [
69
+ { value: 'none', label: t('provider.key.none'), detail: t('provider.key.noneDetail') },
70
+ { value: 'key', label: t('provider.key.enter') },
71
+ ],
72
+ current: 'none',
73
+ });
74
+ if (choice === undefined)
75
+ return undefined;
76
+ if (choice === 'none')
77
+ return NO_KEY;
78
+ }
79
+ const key = await ui.text({
80
+ title: t('onboard.key.title'),
81
+ subtitle: opts.subtitle ?? opts.name,
82
+ label: t('onboard.key.label', { provider: opts.name }),
83
+ secret: true,
84
+ notes: [t('onboard.key.note')],
85
+ });
86
+ return key === undefined ? undefined : key.trim();
87
+ }
88
+ /** Adds a provider: name, address, key. Returns its id, or null when the user went back out of the first step. */
89
+ export async function addProviderFlow(config, deps = {}) {
90
+ const ui = deps.ui ?? realUi;
91
+ const say = deps.say ?? defaultSay;
92
+ let stage = 'name';
93
+ let name = '';
94
+ let baseUrl = '';
95
+ for (;;) {
96
+ if (stage === 'name') {
97
+ const got = await askName(config, ui, { initial: name, subtitle: '1/3' });
98
+ if (got === undefined)
99
+ return null;
100
+ name = got;
101
+ stage = 'url';
102
+ }
103
+ else if (stage === 'url') {
104
+ const got = await askUrl(ui, { initial: baseUrl, subtitle: '2/3' });
105
+ if (got === undefined) {
106
+ stage = 'name';
107
+ continue;
108
+ }
109
+ baseUrl = got;
110
+ stage = 'key';
111
+ }
112
+ else {
113
+ const got = await askKey(ui, { name, baseUrl, subtitle: '3/3' });
114
+ if (got === undefined) {
115
+ stage = 'url';
116
+ continue;
117
+ }
118
+ const id = addCustomProvider(config, { name, baseUrl, apiKey: got }, deps.listedIds);
119
+ await deps.save?.(config);
120
+ say('ok', t('provider.saved', { name }));
121
+ return id;
122
+ }
123
+ }
124
+ }
125
+ /**
126
+ * The card of one saved provider: use it, change its name, address or key, delete it.
127
+ * Returns `use` when the user wants to work with it, `back` for Esc or after a delete.
128
+ */
129
+ export async function manageProviderFlow(config, id, deps = {}) {
130
+ const ui = deps.ui ?? realUi;
131
+ const say = deps.say ?? defaultSay;
132
+ for (;;) {
133
+ const own = customProviders(config)[id];
134
+ if (!own)
135
+ return 'back';
136
+ const key = config.apiKeys?.[id] ?? '';
137
+ const items = [
138
+ { value: 'use', label: t('provider.menu.use'), detail: hostOfUrl(own.baseUrl) },
139
+ { value: 'rename', label: t('provider.menu.rename'), detail: own.name },
140
+ { value: 'url', label: t('provider.menu.url'), detail: own.baseUrl },
141
+ { value: 'key', label: t('provider.menu.key'), detail: keyHint(key) },
142
+ { value: 'delete', label: t('provider.menu.delete'), detail: t('provider.menu.deleteDetail') },
143
+ ];
144
+ const action = await ui.picker({
145
+ title: t('onboard.provider.title'),
146
+ subtitle: own.name,
147
+ items,
148
+ current: 'use',
149
+ // The labels differ from language to language: the column is as wide as the longest one.
150
+ labelWidth: Math.max(...items.map((i) => displayWidth(i.label))) + 2,
151
+ });
152
+ if (action === undefined)
153
+ return 'back';
154
+ if (action === 'use')
155
+ return 'use';
156
+ if (action === 'rename') {
157
+ const name = await askName(config, ui, { initial: own.name, exceptId: id });
158
+ if (name === undefined || name === own.name)
159
+ continue;
160
+ updateCustomProvider(config, id, { name });
161
+ await deps.save?.(config);
162
+ say('ok', t('provider.updated', { name }));
163
+ }
164
+ else if (action === 'url') {
165
+ const url = await askUrl(ui, { initial: own.baseUrl });
166
+ if (url === undefined || url === own.baseUrl)
167
+ continue;
168
+ updateCustomProvider(config, id, { baseUrl: url });
169
+ await deps.save?.(config);
170
+ say('ok', t('provider.updated', { name: own.name }));
171
+ }
172
+ else if (action === 'key') {
173
+ const got = await askKey(ui, { name: own.name, baseUrl: own.baseUrl });
174
+ if (got === undefined)
175
+ continue;
176
+ updateCustomProvider(config, id, { apiKey: got });
177
+ await deps.save?.(config);
178
+ say('ok', t('provider.updated', { name: own.name }));
179
+ }
180
+ else {
181
+ const active = config.provider === id;
182
+ const yes = await ui.confirm({
183
+ title: t('onboard.provider.title'),
184
+ question: t('provider.delete.question', { name: own.name }),
185
+ details: [hostOfUrl(own.baseUrl), ...(active ? [t('provider.delete.active')] : [])],
186
+ yes: t('provider.menu.delete'),
187
+ initial: false,
188
+ });
189
+ if (!yes)
190
+ continue;
191
+ removeCustomProvider(config, id);
192
+ await deps.save?.(config);
193
+ say('ok', t('provider.deleted', { name: own.name }));
194
+ return 'back';
195
+ }
196
+ }
197
+ }
@@ -883,10 +883,5 @@ export const ALL_PROVIDERS = [
883
883
  "value": "zhipuai-coding-plan",
884
884
  "label": "Zhipu AI Coding Plan",
885
885
  "baseUrl": "https://open.bigmodel.cn/api/coding/paas/v4"
886
- },
887
- {
888
- "value": "custom",
889
- "label": "Custom (Any OpenAI-compatible API)",
890
- "baseUrl": ""
891
886
  }
892
887
  ];