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.
Files changed (49) hide show
  1. package/README.md +15 -5
  2. package/assets/banner.svg +1 -1
  3. package/assets/preview.svg +1 -1
  4. package/dist/cli/background-ui.js +8 -8
  5. package/dist/cli/files.js +2 -12
  6. package/dist/cli/finder.js +4 -0
  7. package/dist/cli/headless.js +3 -2
  8. package/dist/cli/index.js +128 -59
  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 +54 -7
  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 +33 -13
  19. package/dist/cli/usage-panel.js +17 -4
  20. package/dist/cli/width.js +18 -0
  21. package/dist/core/background.js +14 -0
  22. package/dist/core/context-stats.js +1 -0
  23. package/dist/core/cost.js +25 -11
  24. package/dist/core/custom-providers.js +161 -0
  25. package/dist/core/images.js +46 -0
  26. package/dist/core/local-config.js +3 -1
  27. package/dist/core/locales/de.js +36 -7
  28. package/dist/core/locales/en.js +36 -7
  29. package/dist/core/locales/es.js +36 -7
  30. package/dist/core/locales/fr.js +36 -7
  31. package/dist/core/locales/ja.js +36 -7
  32. package/dist/core/locales/pt.js +36 -7
  33. package/dist/core/locales/ru.js +36 -7
  34. package/dist/core/locales/uk.js +36 -7
  35. package/dist/core/locales/zh.js +36 -7
  36. package/dist/core/loop.js +32 -1
  37. package/dist/core/prices.js +8 -4
  38. package/dist/core/session-media.js +115 -0
  39. package/dist/core/session.js +17 -2
  40. package/dist/core/title.js +19 -2
  41. package/dist/core/types.js +1 -1
  42. package/dist/core/usage.js +41 -4
  43. package/dist/permissions/guard.js +3 -0
  44. package/dist/providers/anthropic.js +67 -13
  45. package/dist/providers/index.js +16 -3
  46. package/dist/providers/openai.js +99 -41
  47. package/dist/providers/shared.js +83 -0
  48. package/dist/tools/fs.js +32 -2
  49. 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
- export function resolveMode(planMode, yolo) {
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 (i < cps.length && cps[i] !== ' ' && afterSpace > start)
309
- i = afterSpace; // break between words
310
- if (i === start)
311
- i = start + 1; // a cell wider than the row
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(/[\x00-\x1f\x7f]/g, ' ');
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
- const f = { ...input, input: input.input.replace(/[\x00-\x1f\x7f]/g, ' ') };
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 · ctrl+c to stop)`.
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 · ctrl+c to stop)`.
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;
@@ -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 (s.cacheReadTokens > 0 && detail !== 'min')
134
- pair('Cached input', palette.text(fmtTokens(s.cacheReadTokens)));
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
- output.off?.('resize', draw);
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
- output.on?.('resize', draw);
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
+ }
@@ -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 computeCost(rates, usage) {
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
- if (rates.reasoning !== undefined && reasoning > 0) {
31
- cost += (completion - reasoning) * output + reasoning * rates.reasoning;
32
- }
33
- else {
34
- cost += completion * output;
35
- }
36
- return cost / PER_MILLION;
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
  }
@@ -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': 'ctrl+c zum Stoppen',
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': 'ctrl+enter sendet sofort · ↓ bearbeiten',
39
- 'composer.hint1': 'enter reiht ein · ctrl+c stoppt',
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 · ctrl+c stoppt',
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 liefert. Nicht in der Liste? Wähle eigene Adresse.',
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',