termux-dev 1.8.2 → 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 +15 -5
- package/assets/banner.svg +1 -1
- package/assets/preview.svg +1 -1
- package/dist/cli/background-ui.js +8 -8
- package/dist/cli/files.js +2 -12
- package/dist/cli/finder.js +4 -0
- package/dist/cli/headless.js +3 -2
- package/dist/cli/index.js +128 -59
- 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 +54 -7
- 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 +33 -13
- package/dist/cli/usage-panel.js +17 -4
- package/dist/cli/width.js +18 -0
- package/dist/core/background.js +14 -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 +32 -1
- 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 +3 -0
- 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/fs.js +32 -2
- package/package.json +1 -1
package/dist/cli/ui.js
CHANGED
|
@@ -36,9 +36,12 @@ export const palette = {
|
|
|
36
36
|
accent: (s) => tokens().accent(s),
|
|
37
37
|
bold: (s) => (colorOn() ? `\x1b[1m${s}\x1b[22m` : s),
|
|
38
38
|
};
|
|
39
|
-
|
|
39
|
+
/** `extra` is YOLO without the safety net (nothing is asked); it only counts while YOLO is on. */
|
|
40
|
+
export function resolveMode(planMode, yolo, extra = false) {
|
|
40
41
|
if (planMode)
|
|
41
42
|
return 'plan';
|
|
43
|
+
if (yolo && extra)
|
|
44
|
+
return 'extra';
|
|
42
45
|
return yolo ? 'yolo' : 'agent';
|
|
43
46
|
}
|
|
44
47
|
export function modeStyle(mode) {
|
|
@@ -47,6 +50,8 @@ export function modeStyle(mode) {
|
|
|
47
50
|
return { label: 'plan', glyph: '◆', color: palette.plan };
|
|
48
51
|
case 'yolo':
|
|
49
52
|
return { label: 'yolo', glyph: '▲', color: palette.yolo };
|
|
53
|
+
case 'extra':
|
|
54
|
+
return { label: 'extra', glyph: '▲', color: palette.err };
|
|
50
55
|
default:
|
|
51
56
|
return { label: 'agent', glyph: '●', color: palette.accent };
|
|
52
57
|
}
|
|
@@ -136,7 +141,7 @@ function brand(version) {
|
|
|
136
141
|
}
|
|
137
142
|
export function renderHeader(info, cols) {
|
|
138
143
|
const W = contentWidth(cols);
|
|
139
|
-
const modePill = info.yolo ? palette.yolo(palette.bold('▲ auto-approve')) : '';
|
|
144
|
+
const modePill = info.extra ? palette.err(palette.bold('▲ extra: nothing asked')) : info.yolo ? palette.yolo(palette.bold('▲ auto-approve')) : '';
|
|
140
145
|
const branch = info.branch
|
|
141
146
|
? `${palette.faint('git:')}${palette.muted(info.branch)}${info.dirty ? palette.warn('*') : ''}`
|
|
142
147
|
: '';
|
|
@@ -146,7 +151,7 @@ export function renderHeader(info, cols) {
|
|
|
146
151
|
const limit = Math.max(8, cols - 1);
|
|
147
152
|
const room = Math.max(6, cols - 3);
|
|
148
153
|
const titleOptions = info.yolo
|
|
149
|
-
? [`${brand(info.version)} ${modePill}`, `${brand(info.version)} ${palette.yolo('▲ yolo')}`, `${brand(info.version)} ${palette.yolo('▲')}`, brand(info.version)]
|
|
154
|
+
? [`${brand(info.version)} ${modePill}`, `${brand(info.version)} ${info.extra ? palette.err('▲ extra') : palette.yolo('▲ yolo')}`, `${brand(info.version)} ${info.extra ? palette.err('▲') : palette.yolo('▲')}`, brand(info.version)]
|
|
150
155
|
: [brand(info.version)];
|
|
151
156
|
lines.push(titleOptions.find((t) => displayWidth(t) <= limit) ?? truncateWidth(stripAnsi(brand(info.version)), limit));
|
|
152
157
|
lines.push(` ${palette.text(truncateWidth(info.model, room))}`);
|
|
@@ -258,6 +263,8 @@ export function inputCells(cols) {
|
|
|
258
263
|
const W = frameWidth(cols);
|
|
259
264
|
return Math.max(4, isCompact(cols) ? W - 4 : W - 6);
|
|
260
265
|
}
|
|
266
|
+
/** Control characters but the line break (`\n` is a real break in the input, it is laid out, not shown). */
|
|
267
|
+
const CONTROL_EXCEPT_NEWLINE = /[\x00-\x09\x0b-\x1f\x7f]/g;
|
|
261
268
|
/** Rows the input may take before it scrolls: a quarter of the terminal, at most 8. */
|
|
262
269
|
export function maxInputRows(termRows = process.stdout.rows || 24) {
|
|
263
270
|
return Math.max(1, Math.min(8, Math.floor(termRows / 4)));
|
|
@@ -291,7 +298,14 @@ export function wrapInput(input, cells) {
|
|
|
291
298
|
const start = i;
|
|
292
299
|
let used = 0;
|
|
293
300
|
let afterSpace = -1;
|
|
301
|
+
let hardBreak = false;
|
|
294
302
|
while (i < cps.length) {
|
|
303
|
+
// A line break ends the row; it stays at the end of that row, so the cursor behind it is on the next one.
|
|
304
|
+
if (cps[i] === '\n') {
|
|
305
|
+
i++;
|
|
306
|
+
hardBreak = true;
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
295
309
|
let next = atoms.get(i) ?? i + 1;
|
|
296
310
|
let w = widths.slice(i, next).reduce((a, b) => a + b, 0);
|
|
297
311
|
if (next > i + 1 && w > cells) {
|
|
@@ -305,12 +319,17 @@ export function wrapInput(input, cells) {
|
|
|
305
319
|
if (cps[i - 1] === ' ')
|
|
306
320
|
afterSpace = i;
|
|
307
321
|
}
|
|
308
|
-
if (
|
|
309
|
-
i
|
|
310
|
-
|
|
311
|
-
i
|
|
322
|
+
if (!hardBreak) {
|
|
323
|
+
if (i < cps.length && cps[i] !== ' ' && afterSpace > start)
|
|
324
|
+
i = afterSpace; // break between words
|
|
325
|
+
if (i === start)
|
|
326
|
+
i = start + 1; // a cell wider than the row
|
|
327
|
+
}
|
|
312
328
|
rows.push({ start, end: i });
|
|
313
329
|
}
|
|
330
|
+
// Text that ends with a line break: the cursor waits on a new, empty row.
|
|
331
|
+
if (cps[cps.length - 1] === '\n')
|
|
332
|
+
rows.push({ start: cps.length, end: cps.length });
|
|
314
333
|
return rows;
|
|
315
334
|
}
|
|
316
335
|
function layoutInput(input, cursorPos, cells) {
|
|
@@ -335,7 +354,7 @@ function layoutInput(input, cursorPos, cells) {
|
|
|
335
354
|
* (the arrow keys then walk the prompt history as before). Returns a UTF-16 index.
|
|
336
355
|
*/
|
|
337
356
|
export function moveCursorRow(input, cursorPos, cols, dir) {
|
|
338
|
-
const clean = input.replace(
|
|
357
|
+
const clean = input.replace(CONTROL_EXCEPT_NEWLINE, ' ');
|
|
339
358
|
const { cps, rows, widths, cursorRow, cursorCell } = layoutInput(clean, cursorPos, inputCells(cols));
|
|
340
359
|
const target = rows[cursorRow + dir];
|
|
341
360
|
if (!target)
|
|
@@ -358,7 +377,7 @@ function inputRows(f, cells) {
|
|
|
358
377
|
const { cps, rows, cursorRow, cursorCell } = layoutInput(f.input, f.cursorPos, cells);
|
|
359
378
|
const limit = f.maxInputRows ?? maxInputRows();
|
|
360
379
|
const firstRow = rows.length <= limit ? 0 : Math.max(0, Math.min(cursorRow - limit + 1, rows.length - limit));
|
|
361
|
-
const shown = rows.slice(firstRow, firstRow + limit).map((r) => formatBadges(cps.slice(r.start, r.end).join('')));
|
|
380
|
+
const shown = rows.slice(firstRow, firstRow + limit).map((r) => formatBadges(cps.slice(r.start, r.end).join('').replace(/\n$/, '')));
|
|
362
381
|
return { rows: shown, firstRow, cursorRow: cursorRow - firstRow, cursorCol: cursorCell };
|
|
363
382
|
}
|
|
364
383
|
function keyHints(cols, mode) {
|
|
@@ -398,8 +417,9 @@ export function fitModelLabel(label, max) {
|
|
|
398
417
|
}
|
|
399
418
|
export function buildPromptFrame(input) {
|
|
400
419
|
// Control characters would move the cursor and tear the frame apart. They are shown as
|
|
401
|
-
// spaces; one character stays one character, so cursor positions keep matching.
|
|
402
|
-
|
|
420
|
+
// spaces; one character stays one character, so cursor positions keep matching. A line break
|
|
421
|
+
// is kept: it starts a new row of the input.
|
|
422
|
+
const f = { ...input, input: input.input.replace(CONTROL_EXCEPT_NEWLINE, ' ') };
|
|
403
423
|
if (isCompact(f.cols))
|
|
404
424
|
return buildCompactFrame(f);
|
|
405
425
|
const W = frameWidth(f.cols);
|
|
@@ -997,7 +1017,7 @@ export function easeToward(current, target, dtMs, tauMs = 400) {
|
|
|
997
1017
|
return current + distance * (1 - Math.exp(-dtMs / tauMs));
|
|
998
1018
|
}
|
|
999
1019
|
/**
|
|
1000
|
-
* The working line: ` ⠹ Read src/x.ts (12s · ↓ 364 tokens ·
|
|
1020
|
+
* The working line: ` ⠹ Read src/x.ts (12s · ↓ 364 tokens · esc to stop)`.
|
|
1001
1021
|
* On a narrow screen the details are dropped step by step, the label is cut last.
|
|
1002
1022
|
*/
|
|
1003
1023
|
export function renderStatusLine({ label: rawLabel, elapsedMs, meta = '', frame, columns }) {
|
|
@@ -1026,7 +1046,7 @@ export function renderStatusLine({ label: rawLabel, elapsedMs, meta = '', frame,
|
|
|
1026
1046
|
return ` ${glyph} ${palette.text(text)}${' '.repeat(GAP)}${suffix}`;
|
|
1027
1047
|
}
|
|
1028
1048
|
/**
|
|
1029
|
-
* Animated "working" line: ` ⠹ Read src/x.ts (12s · 1.2k tok ·
|
|
1049
|
+
* Animated "working" line: ` ⠹ Read src/x.ts (12s · 1.2k tok · esc to stop)`.
|
|
1030
1050
|
* Same start / message / stop API as the clack spinner it replaces.
|
|
1031
1051
|
*/
|
|
1032
1052
|
let activeSpinner = null;
|
package/dist/cli/usage-panel.js
CHANGED
|
@@ -128,10 +128,23 @@ function buildRows(d, iw, detail) {
|
|
|
128
128
|
const note = costNote(s);
|
|
129
129
|
if (note && detail === 'full')
|
|
130
130
|
line(' ' + palette.faint(truncateWidth(note, iw - 2)));
|
|
131
|
+
if (s.restoredCost && detail === 'full') {
|
|
132
|
+
line(' ' + palette.faint(truncateWidth(`Includes ${formatMoney(s.restoredCost, { estimated: s.costState.estimated })} from before this chat was resumed; the lines below count only since devx started.`, iw - 2)));
|
|
133
|
+
}
|
|
131
134
|
const tokens = iw >= 40 ? `${fmtTokens(s.promptTokens)} in · ${fmtTokens(s.completionTokens)} out` : `${fmtTokens(s.promptTokens)}/${fmtTokens(s.completionTokens)}`;
|
|
132
135
|
pair('Tokens', palette.text(tokens));
|
|
133
|
-
if (
|
|
134
|
-
|
|
136
|
+
if (detail !== 'min') {
|
|
137
|
+
// What the money was paid for. Every request resends the whole conversation, so the input
|
|
138
|
+
// is usually the biggest part, and the cache is what makes it cheaper.
|
|
139
|
+
const money = (v) => s.costParts && v !== undefined && iw >= 40 ? palette.faint(' ' + formatMoney(v, { estimated: s.costState.estimated })) : '';
|
|
140
|
+
const paid = (label, tokens, v) => pair(label, palette.text(fmtTokens(tokens)) + money(v));
|
|
141
|
+
paid(' Input', s.uncachedTokens, s.costParts?.input);
|
|
142
|
+
if (s.cacheReadTokens > 0)
|
|
143
|
+
paid(' Cached input', s.cacheReadTokens, s.costParts?.cacheRead);
|
|
144
|
+
if (s.cacheWriteTokens > 0)
|
|
145
|
+
paid(' Cache writes', s.cacheWriteTokens, s.costParts?.cacheWrite);
|
|
146
|
+
paid(' Output', s.completionTokens, s.costParts?.output);
|
|
147
|
+
}
|
|
135
148
|
pair('Requests', palette.text(String(s.requestsCount)));
|
|
136
149
|
// ── traffic
|
|
137
150
|
if (detail === 'full') {
|
|
@@ -221,7 +234,7 @@ export function runUsagePanel(data, options = {}) {
|
|
|
221
234
|
return;
|
|
222
235
|
closed = true;
|
|
223
236
|
input.off('data', onData);
|
|
224
|
-
|
|
237
|
+
stopWatching();
|
|
225
238
|
block.erase();
|
|
226
239
|
if (!wasRaw)
|
|
227
240
|
input.setRawMode?.(false);
|
|
@@ -236,7 +249,7 @@ export function runUsagePanel(data, options = {}) {
|
|
|
236
249
|
}
|
|
237
250
|
};
|
|
238
251
|
input.on('data', onData);
|
|
239
|
-
|
|
252
|
+
const stopWatching = block.watchResize(draw);
|
|
240
253
|
draw();
|
|
241
254
|
});
|
|
242
255
|
}
|
package/dist/cli/width.js
CHANGED
|
@@ -176,3 +176,21 @@ export function wrapText(text, width) {
|
|
|
176
176
|
const unbreakable = text.split(/\s+/).some((word) => displayWidth(word) > width);
|
|
177
177
|
return unbreakable ? wrapWidth(text, width) : wrapWords(text, width);
|
|
178
178
|
}
|
|
179
|
+
/**
|
|
180
|
+
* How many rows up the top of a block is from the cursor, once the terminal is `cols` wide.
|
|
181
|
+
*
|
|
182
|
+
* A block drawn at one width is re-wrapped by the terminal when the window is resized: a line that
|
|
183
|
+
* was as wide as the old window now takes several rows, so counting the lines of the block is not
|
|
184
|
+
* enough to get back to its top (the top stays on screen and the block shows up twice).
|
|
185
|
+
*
|
|
186
|
+
* @param lineWidths visible width of each line of the block as it was drawn
|
|
187
|
+
* @param cursorRow the line the cursor is on
|
|
188
|
+
* @param cursorCol the column of the cursor in that line
|
|
189
|
+
*/
|
|
190
|
+
export function rowsToTop(lineWidths, cursorRow, cursorCol, cols) {
|
|
191
|
+
const width = Math.max(1, cols);
|
|
192
|
+
let rows = 0;
|
|
193
|
+
for (let i = 0; i < cursorRow && i < lineWidths.length; i++)
|
|
194
|
+
rows += Math.max(1, Math.ceil(lineWidths[i] / width));
|
|
195
|
+
return rows + Math.floor(Math.max(0, cursorCol) / width);
|
|
196
|
+
}
|
package/dist/core/background.js
CHANGED
|
@@ -236,8 +236,22 @@ export class BackgroundManager extends EventEmitter {
|
|
|
236
236
|
return '';
|
|
237
237
|
const text = this.notes.map((n) => n.text).join('\n\n');
|
|
238
238
|
this.notes = [];
|
|
239
|
+
// What the prompt frame shows about unreported news is gone now: let it redraw.
|
|
240
|
+
this.emit('change');
|
|
239
241
|
return `[devx · background tasks]\n${text}`;
|
|
240
242
|
}
|
|
243
|
+
/**
|
|
244
|
+
* Tasks that ended badly and whose news the model has not received yet. The prompt frame shows
|
|
245
|
+
* this number: it goes away once devx has told the model about the failure.
|
|
246
|
+
*/
|
|
247
|
+
unreportedFailures() {
|
|
248
|
+
const ids = new Set();
|
|
249
|
+
for (const note of this.notes) {
|
|
250
|
+
if (note.kind === 'exit' && this.tasks.get(note.taskId)?.info.status === 'failed')
|
|
251
|
+
ids.add(note.taskId);
|
|
252
|
+
}
|
|
253
|
+
return ids.size;
|
|
254
|
+
}
|
|
241
255
|
// ───────────────────────── internals ─────────────────────────
|
|
242
256
|
must(id) {
|
|
243
257
|
const task = this.tasks.get(String(id));
|
|
@@ -23,6 +23,7 @@ export function contextBreakdown(messages, definitions, limit) {
|
|
|
23
23
|
user, assistant, toolResults,
|
|
24
24
|
images, imageCount,
|
|
25
25
|
total,
|
|
26
|
+
inUse: messages.some((m) => m.role !== 'system') ? total : 0,
|
|
26
27
|
compactAt: Math.floor(limit * CONTEXT_USAGE_RATIO),
|
|
27
28
|
free: Math.max(0, limit - total),
|
|
28
29
|
};
|
package/dist/core/cost.js
CHANGED
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
*/
|
|
13
13
|
const PER_MILLION = 1_000_000;
|
|
14
14
|
const finite = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
|
15
|
-
/** The cost of the tokens at the given rates. */
|
|
16
|
-
export function
|
|
15
|
+
/** The cost of the tokens at the given rates, split by what was paid for. */
|
|
16
|
+
export function costParts(rates, usage) {
|
|
17
17
|
const prompt = Math.max(0, usage.promptTokens || 0);
|
|
18
18
|
const tier = rates.tier && prompt > rates.tier.over ? rates.tier : null;
|
|
19
19
|
const input = tier?.input ?? rates.input;
|
|
@@ -23,17 +23,31 @@ export function computeCost(rates, usage) {
|
|
|
23
23
|
const read = Math.min(prompt, Math.max(0, usage.cacheReadTokens || 0));
|
|
24
24
|
const written = Math.min(prompt - read, Math.max(0, usage.cacheWriteTokens || 0));
|
|
25
25
|
const plain = prompt - read - written;
|
|
26
|
-
// Without a cache price the cached tokens cost as much as ordinary input.
|
|
27
|
-
let cost = plain * input + read * (cacheRead ?? input) + written * (cacheWrite ?? input);
|
|
28
26
|
const completion = Math.max(0, usage.completionTokens || 0);
|
|
29
27
|
const reasoning = Math.min(completion, Math.max(0, usage.reasoningTokens || 0));
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
28
|
+
const out = rates.reasoning !== undefined && reasoning > 0
|
|
29
|
+
? (completion - reasoning) * output + reasoning * rates.reasoning
|
|
30
|
+
: completion * output;
|
|
31
|
+
// Without a cache price the cached tokens cost as much as ordinary input.
|
|
32
|
+
return {
|
|
33
|
+
input: (plain * input) / PER_MILLION,
|
|
34
|
+
cacheRead: (read * (cacheRead ?? input)) / PER_MILLION,
|
|
35
|
+
cacheWrite: (written * (cacheWrite ?? input)) / PER_MILLION,
|
|
36
|
+
output: out / PER_MILLION,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** The cost of the tokens at the given rates. */
|
|
40
|
+
export function computeCost(rates, usage) {
|
|
41
|
+
const p = costParts(rates, usage);
|
|
42
|
+
return p.input + p.cacheRead + p.cacheWrite + p.output;
|
|
43
|
+
}
|
|
44
|
+
/** The same parts scaled so that they add up to `total` (the cost the provider reported). */
|
|
45
|
+
export function scaleParts(parts, total) {
|
|
46
|
+
const sum = parts.input + parts.cacheRead + parts.cacheWrite + parts.output;
|
|
47
|
+
if (!(sum > 0) || !Number.isFinite(total))
|
|
48
|
+
return undefined;
|
|
49
|
+
const k = total / sum;
|
|
50
|
+
return { input: parts.input * k, cacheRead: parts.cacheRead * k, cacheWrite: parts.cacheWrite * k, output: parts.output * k };
|
|
37
51
|
}
|
|
38
52
|
/** A price as the OpenRouter list writes it: USD per token, often a string. */
|
|
39
53
|
function perToken(value) {
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { isLocalBaseUrl } from './cost.js';
|
|
2
|
+
/** The key stored for a server that needs none (a local one): the OpenAI client wants some value and falls back to $OPENAI_API_KEY for an empty one. */
|
|
3
|
+
export const NO_KEY = 'no-key';
|
|
4
|
+
/** The id the old single "Custom" entry of the provider list had. */
|
|
5
|
+
const LEGACY_ID = 'custom';
|
|
6
|
+
const MAX_NAME = 40;
|
|
7
|
+
export function customProviders(config) {
|
|
8
|
+
const own = config.customProviders;
|
|
9
|
+
return own && typeof own === 'object' && !Array.isArray(own) ? own : {};
|
|
10
|
+
}
|
|
11
|
+
/** The saved providers, in the order they were added. */
|
|
12
|
+
export function listCustomProviders(config) {
|
|
13
|
+
return Object.entries(customProviders(config))
|
|
14
|
+
.filter(([, p]) => p && typeof p.baseUrl === 'string')
|
|
15
|
+
.map(([id, p]) => ({ id, name: String(p.name || id), baseUrl: p.baseUrl, apiKey: config.apiKeys?.[id] ?? '' }));
|
|
16
|
+
}
|
|
17
|
+
export function isCustomProvider(config, id) {
|
|
18
|
+
return !!id && !!customProviders(config)[id];
|
|
19
|
+
}
|
|
20
|
+
/** The name to show for a provider id: the user's name for a custom one, the label of a listed one, else the id. */
|
|
21
|
+
export function providerDisplayName(config, id, listed = []) {
|
|
22
|
+
if (!id)
|
|
23
|
+
return '';
|
|
24
|
+
const own = customProviders(config)[id];
|
|
25
|
+
if (own?.name)
|
|
26
|
+
return own.name;
|
|
27
|
+
return listed.find((p) => p.value === id)?.label ?? id;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The single "Custom" entry older versions had becomes a saved provider named Custom, so that
|
|
31
|
+
* whoever used it finds it in the list (with its address and key). Returns true when it did.
|
|
32
|
+
*/
|
|
33
|
+
export function migrateLegacyCustom(config) {
|
|
34
|
+
if (customProviders(config)[LEGACY_ID])
|
|
35
|
+
return false;
|
|
36
|
+
const url = config.baseUrls?.[LEGACY_ID] || (config.provider === LEGACY_ID ? config.baseUrl : '');
|
|
37
|
+
if (!url)
|
|
38
|
+
return false;
|
|
39
|
+
config.customProviders = { ...customProviders(config), [LEGACY_ID]: { name: 'Custom', baseUrl: url } };
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
/** Is this name usable? A name must be unique (without regard to case), but a provider may keep its own. */
|
|
43
|
+
export function nameProblem(config, name, exceptId) {
|
|
44
|
+
const clean = name.trim();
|
|
45
|
+
if (!clean)
|
|
46
|
+
return 'empty';
|
|
47
|
+
if (Array.from(clean).length > MAX_NAME)
|
|
48
|
+
return 'long';
|
|
49
|
+
const taken = listCustomProviders(config).some((p) => p.id !== exceptId && p.name.trim().toLowerCase() === clean.toLowerCase());
|
|
50
|
+
return taken ? 'taken' : null;
|
|
51
|
+
}
|
|
52
|
+
/** An address of an API: http(s), with a host. The path stays as typed (`/v1`), only a trailing slash goes. */
|
|
53
|
+
export function checkBaseUrl(input) {
|
|
54
|
+
const text = input.trim();
|
|
55
|
+
if (!text)
|
|
56
|
+
return { ok: false, reason: 'empty' };
|
|
57
|
+
let url;
|
|
58
|
+
try {
|
|
59
|
+
url = new URL(text);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return { ok: false, reason: 'invalid' };
|
|
63
|
+
}
|
|
64
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
65
|
+
return { ok: false, reason: 'scheme' };
|
|
66
|
+
if (!url.hostname)
|
|
67
|
+
return { ok: false, reason: 'invalid' };
|
|
68
|
+
return { ok: true, url: text.replace(/\/+$/, '') };
|
|
69
|
+
}
|
|
70
|
+
/** A server on this machine or on the home network usually needs no key. */
|
|
71
|
+
export function usuallyNeedsNoKey(baseUrl) {
|
|
72
|
+
return isLocalBaseUrl(baseUrl);
|
|
73
|
+
}
|
|
74
|
+
/** `api.example.com` for the list. */
|
|
75
|
+
export const hostOfUrl = (url) => url.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
|
|
76
|
+
/** The key as the list shows it. */
|
|
77
|
+
export function keyHint(key) {
|
|
78
|
+
if (!key)
|
|
79
|
+
return '';
|
|
80
|
+
if (key === NO_KEY)
|
|
81
|
+
return 'no key';
|
|
82
|
+
if (key.length <= 8)
|
|
83
|
+
return '****';
|
|
84
|
+
return `${key.slice(0, 4)}...${key.slice(-4)}`;
|
|
85
|
+
}
|
|
86
|
+
// ───────────────────────── changing the saved providers ─────────────────────────
|
|
87
|
+
/** An id from the name that no listed and no saved provider has: `custom-my-gateway`, `custom-my-gateway-2`. */
|
|
88
|
+
export function newProviderId(config, name, listedIds = []) {
|
|
89
|
+
const slug = name
|
|
90
|
+
.toLowerCase()
|
|
91
|
+
.normalize('NFC')
|
|
92
|
+
.replace(/[^\p{L}\p{N}]+/gu, '-')
|
|
93
|
+
.replace(/^-+|-+$/g, '')
|
|
94
|
+
.slice(0, 30) || 'provider';
|
|
95
|
+
const taken = new Set([...listedIds, ...Object.keys(customProviders(config))]);
|
|
96
|
+
let id = `custom-${slug}`;
|
|
97
|
+
for (let n = 2; taken.has(id); n++)
|
|
98
|
+
id = `custom-${slug}-${n}`;
|
|
99
|
+
return id;
|
|
100
|
+
}
|
|
101
|
+
function ensureMaps(config) {
|
|
102
|
+
const own = config.customProviders;
|
|
103
|
+
if (!own || typeof own !== 'object' || Array.isArray(own))
|
|
104
|
+
config.customProviders = {};
|
|
105
|
+
config.apiKeys = config.apiKeys || {};
|
|
106
|
+
config.baseUrls = config.baseUrls || {};
|
|
107
|
+
}
|
|
108
|
+
/** Adds a provider and returns its id. The name and address are expected to have been checked. */
|
|
109
|
+
export function addCustomProvider(config, input, listedIds = []) {
|
|
110
|
+
ensureMaps(config);
|
|
111
|
+
const id = newProviderId(config, input.name, listedIds);
|
|
112
|
+
config.customProviders[id] = { name: input.name.trim(), baseUrl: input.baseUrl };
|
|
113
|
+
config.baseUrls[id] = input.baseUrl;
|
|
114
|
+
config.apiKeys[id] = input.apiKey;
|
|
115
|
+
return id;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Changes the name, the address or the key of a saved provider. When it is the provider in use,
|
|
119
|
+
* the settings of the running session follow at once.
|
|
120
|
+
*/
|
|
121
|
+
export function updateCustomProvider(config, id, patch) {
|
|
122
|
+
const own = customProviders(config)[id];
|
|
123
|
+
if (!own)
|
|
124
|
+
return false;
|
|
125
|
+
ensureMaps(config);
|
|
126
|
+
if (patch.name !== undefined)
|
|
127
|
+
config.customProviders[id] = { ...config.customProviders[id], name: patch.name.trim() };
|
|
128
|
+
if (patch.baseUrl !== undefined) {
|
|
129
|
+
config.customProviders[id] = { ...config.customProviders[id], baseUrl: patch.baseUrl };
|
|
130
|
+
config.baseUrls[id] = patch.baseUrl;
|
|
131
|
+
if (config.provider === id)
|
|
132
|
+
config.baseUrl = patch.baseUrl;
|
|
133
|
+
}
|
|
134
|
+
if (patch.apiKey !== undefined) {
|
|
135
|
+
config.apiKeys[id] = patch.apiKey;
|
|
136
|
+
if (config.provider === id)
|
|
137
|
+
config.apiKey = patch.apiKey;
|
|
138
|
+
}
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Forgets a saved provider with its key. The provider in use keeps working until another is chosen
|
|
143
|
+
* (its address and key stay in the running settings) but it is no longer a provider of the list:
|
|
144
|
+
* the config forgets its id, so that saving does not write the key and the address back;
|
|
145
|
+
* `wasActive` tells the caller to say so.
|
|
146
|
+
*/
|
|
147
|
+
export function removeCustomProvider(config, id) {
|
|
148
|
+
if (!customProviders(config)[id])
|
|
149
|
+
return { removed: false, wasActive: false };
|
|
150
|
+
const rest = { ...config.customProviders };
|
|
151
|
+
delete rest[id];
|
|
152
|
+
config.customProviders = rest;
|
|
153
|
+
if (config.apiKeys)
|
|
154
|
+
delete config.apiKeys[id];
|
|
155
|
+
if (config.baseUrls)
|
|
156
|
+
delete config.baseUrls[id];
|
|
157
|
+
const wasActive = config.provider === id;
|
|
158
|
+
if (wasActive)
|
|
159
|
+
delete config.provider;
|
|
160
|
+
return { removed: true, wasActive };
|
|
161
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Images the model can be shown: which files count as images, how big they may be, and a check
|
|
3
|
+
* that the bytes really are an image. A file the API refuses (a text file named .png, an
|
|
4
|
+
* oversized photo) would stay in the history and make every following request fail, so it is
|
|
5
|
+
* checked before it is sent.
|
|
6
|
+
*/
|
|
7
|
+
// Formats the image APIs accept (SVG is text, not an image for a model).
|
|
8
|
+
export const IMAGE_EXTS = {
|
|
9
|
+
'.png': 'image/png',
|
|
10
|
+
'.jpg': 'image/jpeg',
|
|
11
|
+
'.jpeg': 'image/jpeg',
|
|
12
|
+
'.webp': 'image/webp',
|
|
13
|
+
'.gif': 'image/gif'
|
|
14
|
+
};
|
|
15
|
+
/** Above this an API usually refuses the image (Anthropic: 5 MB). */
|
|
16
|
+
export const IMAGE_WARN_BYTES = 5 * 1024 * 1024;
|
|
17
|
+
/** Above this the image is not sent at all. */
|
|
18
|
+
export const IMAGE_MAX_BYTES = 20 * 1024 * 1024;
|
|
19
|
+
/** The media type for a file name, when its extension is one of an image. */
|
|
20
|
+
export function imageMimeForPath(file) {
|
|
21
|
+
const dot = file.lastIndexOf('.');
|
|
22
|
+
if (dot === -1)
|
|
23
|
+
return undefined;
|
|
24
|
+
return IMAGE_EXTS[file.slice(dot).toLowerCase()];
|
|
25
|
+
}
|
|
26
|
+
/** The media type the first bytes say, or undefined when they are not PNG, JPEG, GIF or WebP. */
|
|
27
|
+
export function sniffImageMime(buf) {
|
|
28
|
+
const at = (i) => buf[i];
|
|
29
|
+
if (buf.length >= 8 && at(0) === 0x89 && at(1) === 0x50 && at(2) === 0x4e && at(3) === 0x47 && at(4) === 0x0d && at(5) === 0x0a && at(6) === 0x1a && at(7) === 0x0a) {
|
|
30
|
+
return 'image/png';
|
|
31
|
+
}
|
|
32
|
+
if (buf.length >= 3 && at(0) === 0xff && at(1) === 0xd8 && at(2) === 0xff)
|
|
33
|
+
return 'image/jpeg';
|
|
34
|
+
if (buf.length >= 6) {
|
|
35
|
+
const head = String.fromCharCode(at(0), at(1), at(2), at(3), at(4), at(5));
|
|
36
|
+
if (head === 'GIF87a' || head === 'GIF89a')
|
|
37
|
+
return 'image/gif';
|
|
38
|
+
}
|
|
39
|
+
if (buf.length >= 12) {
|
|
40
|
+
const riff = String.fromCharCode(at(0), at(1), at(2), at(3));
|
|
41
|
+
const webp = String.fromCharCode(at(8), at(9), at(10), at(11));
|
|
42
|
+
if (riff === 'RIFF' && webp === 'WEBP')
|
|
43
|
+
return 'image/webp';
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
@@ -51,9 +51,10 @@ export function applyLocalConfig(target, local, trusted) {
|
|
|
51
51
|
* Settings that last for one run only and are never written to ~/.devxrc.json.
|
|
52
52
|
* - `autoApprove` (YOLO): switched on in the settings or with `--yolo`, it is off again the
|
|
53
53
|
* next time devx starts. A mode that skips confirmations should not stay on by accident.
|
|
54
|
+
* - `autoApproveAll` (Extra): YOLO without the safety net, the same.
|
|
54
55
|
* - `projectMcpTrusted`, `effortOverride`: runtime flags.
|
|
55
56
|
*/
|
|
56
|
-
export const SESSION_ONLY_SETTINGS = ['autoApprove', 'projectMcpTrusted', 'effortOverride'];
|
|
57
|
+
export const SESSION_ONLY_SETTINGS = ['autoApprove', 'autoApproveAll', 'projectMcpTrusted', 'effortOverride'];
|
|
57
58
|
/** The config as it goes to disk: without the settings that last for one run only. */
|
|
58
59
|
export function persistedConfig(config) {
|
|
59
60
|
const out = { ...config };
|
|
@@ -65,5 +66,6 @@ export function persistedConfig(config) {
|
|
|
65
66
|
export function withoutSessionOnlySettings(config) {
|
|
66
67
|
const out = { ...config };
|
|
67
68
|
delete out.autoApprove;
|
|
69
|
+
delete out.autoApproveAll;
|
|
68
70
|
return out;
|
|
69
71
|
}
|
package/dist/core/locales/de.js
CHANGED
|
@@ -20,25 +20,23 @@ export const de = {
|
|
|
20
20
|
'conn.classicRestored': '✓ Verbindung wiederhergestellt! Die Generierung wird fortgesetzt...',
|
|
21
21
|
'stop.connection': 'Verbindung abgebrochen',
|
|
22
22
|
'stop.stopped': 'gestoppt',
|
|
23
|
-
'stop.interrupted': 'unterbrochen, deine Nachricht wird gesendet',
|
|
24
23
|
'stop.classicConnection': 'Verbindung abgebrochen (Strg+C).',
|
|
25
24
|
'stop.classicStopped': 'Generierung gestoppt (Strg+C).',
|
|
26
|
-
'stop.classicInterrupted': 'Unterbrochen: deine Nachricht wird gesendet.',
|
|
27
25
|
'status.waiting': 'Warte auf das Modell',
|
|
28
26
|
'status.thinking': 'Denkt nach',
|
|
29
27
|
'status.writing': 'Schreibt',
|
|
30
28
|
'status.running': 'Läuft',
|
|
31
29
|
'status.reconnecting': 'Neuverbindung',
|
|
32
|
-
'status.stop': '
|
|
30
|
+
'status.stop': 'esc zum Stoppen',
|
|
33
31
|
'status.thinkingLine': 'denkt nach',
|
|
34
32
|
'status.thoughtFor': 'nachgedacht: {sec} s',
|
|
35
33
|
'composer.placeholder': 'Aufgabe beschreiben oder etwas fragen…',
|
|
36
34
|
'composer.queued': 'in der Warteschlange',
|
|
37
35
|
'composer.queuedMany': '{n} in der Warteschlange',
|
|
38
|
-
'composer.queuedHint': '
|
|
39
|
-
'composer.hint1': 'enter reiht ein ·
|
|
36
|
+
'composer.queuedHint': '↓ bearbeiten',
|
|
37
|
+
'composer.hint1': 'enter reiht ein · esc stoppt',
|
|
40
38
|
'composer.hint2': 'enter reiht ein',
|
|
41
|
-
'composer.hint3': 'enter reiht ein ·
|
|
39
|
+
'composer.hint3': 'enter reiht ein · esc stoppt',
|
|
42
40
|
'composer.hint4': 'enter reiht ein',
|
|
43
41
|
'summary.toolOne': '{n} Werkzeug',
|
|
44
42
|
'summary.toolMany': '{n} Werkzeuge',
|
|
@@ -54,6 +52,7 @@ export const de = {
|
|
|
54
52
|
'settings.row.layout': 'Oberfläche',
|
|
55
53
|
'settings.row.pureBlack': 'Schwarzer Hintergrund',
|
|
56
54
|
'settings.row.autoApprove': 'Auto-Freigabe (YOLO)',
|
|
55
|
+
'settings.row.extra': 'Extra (fragt nichts)',
|
|
57
56
|
'settings.row.maxIterations': 'Max. Schritte',
|
|
58
57
|
'settings.row.memory': 'Projektgedächtnis',
|
|
59
58
|
'settings.row.updates': 'Updateprüfung',
|
|
@@ -63,6 +62,7 @@ export const de = {
|
|
|
63
62
|
'settings.short.layout': 'Oberfläche',
|
|
64
63
|
'settings.short.pureBlack': 'Schwarz',
|
|
65
64
|
'settings.short.autoApprove': 'YOLO',
|
|
65
|
+
'settings.short.extra': 'Extra',
|
|
66
66
|
'settings.short.maxIterations': 'Schritte',
|
|
67
67
|
'settings.short.memory': 'Gedächtnis',
|
|
68
68
|
'settings.short.updates': 'Updates',
|
|
@@ -78,12 +78,15 @@ export const de = {
|
|
|
78
78
|
'settings.hint.layout': 'Schlicht: Eingabe im Rahmen, Karten und ruhige Werkzeugzeilen. Klassisch: das ursprüngliche ASCII-Layout.',
|
|
79
79
|
'settings.hint.pureBlack': 'Setzt den Terminalhintergrund auf tiefes OLED-Schwarz (#0a0a0c). Aus behält deinen eigenen Hintergrund.',
|
|
80
80
|
'settings.hint.autoApprove': 'Überspringt übliche Rückfragen. Gefährliche Befehle und Dateien mit Zugangsdaten fragen weiterhin nach. Gilt nur für diese Sitzung: Beim nächsten Start von devx ist es wieder aus.',
|
|
81
|
+
'settings.hint.extra': 'Führt JEDEN Befehl ohne Rückfrage aus: auch zerstörerische (rm -rf, sudo, git push --force), heruntergeladene Skripte, das Lesen von Schlüsseln und Geheimnissen, neue MCP-Server. Hier gibt es keine Sandbox. Gilt nur für diese Sitzung.',
|
|
81
82
|
'settings.hint.maxIterations': 'Wie viele Werkzeugschritte (Dateiänderungen, Befehle) der Agent pro Anfrage ausführen darf.',
|
|
82
83
|
'settings.hint.memory': 'Lädt die Regeln aus .devx/memory.md in jede Sitzung.',
|
|
83
84
|
'settings.hint.updates': 'Sucht beim Start auf GitHub nach einer neuen devx-Version. /update funktioniert in jedem Fall.',
|
|
84
85
|
'settings.hint.language': 'Sprache der Oberfläche: Meldungen, Einstellungen und Hinweise. Code und Werkzeugnamen bleiben englisch.',
|
|
85
86
|
'settings.hint.about': 'KI-Coding-Agent für das Terminal von ApvCode. MIT-Lizenz. github.com/apvcode/Termux-Dev',
|
|
86
87
|
'settings.warnYolo': 'An: Der Agent führt Befehle aus und ändert Dateien ohne Rückfrage.',
|
|
88
|
+
'settings.warnExtra': 'Achtung: nichts wird gefragt. Nur wo alles verloren sein darf.',
|
|
89
|
+
'settings.extraEnabled': 'Extra ist an: Der Agent führt jeden Befehl ohne Rückfrage aus, auch gefährliche. Beim nächsten Start von devx ist es wieder aus.',
|
|
87
90
|
'settings.keys.move': 'wählen',
|
|
88
91
|
'settings.keys.change': 'ändern',
|
|
89
92
|
'settings.keys.toggle': 'umschalten',
|
|
@@ -219,9 +222,35 @@ export const de = {
|
|
|
219
222
|
'onboard.language.note': 'Du kannst sie später in /settings ändern.',
|
|
220
223
|
'onboard.provider.title': 'anbieter',
|
|
221
224
|
'onboard.provider.placeholder': 'tippen, um einen Anbieter zu finden',
|
|
222
|
-
'onboard.provider.note': 'Der Dienst, der dir die Modelle
|
|
225
|
+
'onboard.provider.note': 'Der Dienst, der dir die Modelle gibt. Nicht in der Liste? Füge deinen eigenen mit „Anbieter hinzufügen“ hinzu.',
|
|
223
226
|
'onboard.custom': 'Eigene Adresse',
|
|
224
227
|
'onboard.customDetail': 'Jede OpenAI-kompatible Adresse',
|
|
228
|
+
'provider.add': 'Anbieter hinzufügen',
|
|
229
|
+
'provider.addDetail': 'Jede OpenAI- oder Anthropic-kompatible Adresse',
|
|
230
|
+
'provider.customTag': 'eigen',
|
|
231
|
+
'provider.new': 'neuer Anbieter',
|
|
232
|
+
'provider.name.label': 'Name des Anbieters',
|
|
233
|
+
'provider.name.placeholder': 'Mein Gateway',
|
|
234
|
+
'provider.name.empty': 'Gib einen Namen ein.',
|
|
235
|
+
'provider.name.long': 'Der Name ist zu lang (höchstens 40 Zeichen).',
|
|
236
|
+
'provider.name.taken': 'Ein Anbieter mit diesem Namen gibt es schon.',
|
|
237
|
+
'provider.url.note': 'Die Adresse bis /chat/completions oder /messages, zum Beispiel https://api.example.com/v1.',
|
|
238
|
+
'provider.url.invalid': 'Das ist keine Adresse. Beispiel: https://api.example.com/v1',
|
|
239
|
+
'provider.url.scheme': 'Die Adresse muss mit http:// oder https:// beginnen.',
|
|
240
|
+
'provider.key.none': 'Kein Schlüssel nötig',
|
|
241
|
+
'provider.key.noneDetail': 'Ein Server auf diesem Rechner oder in diesem Netz',
|
|
242
|
+
'provider.key.enter': 'Schlüssel eingeben',
|
|
243
|
+
'provider.menu.use': 'Diesen Anbieter verwenden',
|
|
244
|
+
'provider.menu.rename': 'Namen ändern',
|
|
245
|
+
'provider.menu.url': 'Adresse ändern',
|
|
246
|
+
'provider.menu.key': 'Schlüssel ändern',
|
|
247
|
+
'provider.menu.delete': 'Löschen',
|
|
248
|
+
'provider.menu.deleteDetail': 'Entfernt ihn samt Schlüssel',
|
|
249
|
+
'provider.delete.question': 'Anbieter {name} und seinen Schlüssel löschen?',
|
|
250
|
+
'provider.delete.active': 'Das ist der aktuelle Anbieter: Er arbeitet weiter, bis du einen anderen wählst.',
|
|
251
|
+
'provider.saved': 'Anbieter {name} gespeichert',
|
|
252
|
+
'provider.updated': 'Anbieter {name} aktualisiert',
|
|
253
|
+
'provider.deleted': 'Anbieter {name} gelöscht',
|
|
225
254
|
'onboard.baseUrl.title': 'adresse',
|
|
226
255
|
'onboard.baseUrl.label': 'Basis-URL der API',
|
|
227
256
|
'onboard.baseUrl.placeholder': 'https://api.example.com/v1',
|