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.
- package/README.md +17 -4
- package/assets/banner.svg +1 -1
- package/assets/preview.svg +1 -1
- package/dist/cli/background-ui.js +116 -0
- package/dist/cli/files.js +2 -12
- package/dist/cli/finder.js +4 -0
- package/dist/cli/headless.js +6 -3
- package/dist/cli/index.js +259 -74
- package/dist/cli/keys.js +12 -3
- package/dist/cli/liveblock.js +38 -9
- package/dist/cli/mcp-panel.js +2 -0
- package/dist/cli/picker.js +2 -0
- package/dist/cli/prompt.js +93 -8
- package/dist/cli/provider-manager.js +197 -0
- package/dist/cli/providers.js +0 -5
- package/dist/cli/settings.js +38 -17
- package/dist/cli/typeahead.js +10 -17
- package/dist/cli/ui.js +189 -32
- package/dist/cli/updater.js +10 -8
- package/dist/cli/usage-panel.js +17 -4
- package/dist/cli/width.js +18 -0
- package/dist/core/background.js +390 -0
- package/dist/core/config-merge.js +67 -0
- package/dist/core/context-stats.js +1 -0
- package/dist/core/cost.js +25 -11
- package/dist/core/custom-providers.js +161 -0
- package/dist/core/images.js +46 -0
- package/dist/core/local-config.js +3 -1
- package/dist/core/locales/de.js +36 -7
- package/dist/core/locales/en.js +36 -7
- package/dist/core/locales/es.js +36 -7
- package/dist/core/locales/fr.js +36 -7
- package/dist/core/locales/ja.js +36 -7
- package/dist/core/locales/pt.js +36 -7
- package/dist/core/locales/ru.js +36 -7
- package/dist/core/locales/uk.js +36 -7
- package/dist/core/locales/zh.js +36 -7
- package/dist/core/loop.js +50 -2
- package/dist/core/prices.js +8 -4
- package/dist/core/session-media.js +115 -0
- package/dist/core/session.js +17 -2
- package/dist/core/title.js +19 -2
- package/dist/core/types.js +1 -1
- package/dist/core/usage.js +41 -4
- package/dist/permissions/guard.js +6 -1
- package/dist/prompts/builder.js +2 -1
- package/dist/providers/anthropic.js +67 -13
- package/dist/providers/index.js +16 -3
- package/dist/providers/openai.js +99 -41
- package/dist/providers/shared.js +83 -0
- package/dist/tools/background.js +118 -0
- package/dist/tools/bash.js +23 -1
- package/dist/tools/fs.js +32 -2
- package/dist/tools/index.js +2 -1
- 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
|
-
/**
|
|
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
|
|
20
|
-
* byte for
|
|
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`.
|
package/dist/cli/liveblock.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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 (
|
|
21
|
-
buf += `\x1b[${
|
|
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 =
|
|
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.
|
|
52
|
+
this.widths = widths;
|
|
35
53
|
}
|
|
36
54
|
/** Removes the block from the screen and shows the cursor again. */
|
|
37
55
|
erase() {
|
|
38
|
-
|
|
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${
|
|
43
|
-
this.
|
|
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
|
}
|
package/dist/cli/mcp-panel.js
CHANGED
|
@@ -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);
|
package/dist/cli/picker.js
CHANGED
|
@@ -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);
|
package/dist/cli/prompt.js
CHANGED
|
@@ -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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/dist/cli/providers.js
CHANGED
|
@@ -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
|
];
|