termux-dev 1.8.0 → 1.8.2
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 +3 -0
- package/assets/banner.svg +1 -1
- package/assets/preview.svg +14 -5
- package/dist/cli/background-ui.js +116 -0
- package/dist/cli/banner.js +2 -3
- package/dist/cli/finder.js +241 -0
- package/dist/cli/headless.js +3 -1
- package/dist/cli/index.js +371 -339
- package/dist/cli/logo.js +30 -0
- package/dist/cli/markdown.js +17 -2
- package/dist/cli/picker.js +11 -3
- package/dist/cli/prompt.js +39 -1
- package/dist/cli/transcript.js +21 -1
- package/dist/cli/ui.js +168 -30
- package/dist/cli/updater.js +10 -8
- package/dist/cli/welcome.js +56 -0
- package/dist/core/background.js +376 -0
- package/dist/core/config-merge.js +67 -0
- package/dist/core/i18n.js +2 -2
- package/dist/core/locales/de.js +55 -0
- package/dist/core/locales/en.js +55 -0
- package/dist/core/locales/es.js +55 -0
- package/dist/core/locales/fr.js +55 -0
- package/dist/core/locales/ja.js +55 -0
- package/dist/core/locales/pt.js +55 -0
- package/dist/core/locales/ru.js +55 -0
- package/dist/core/locales/uk.js +55 -0
- package/dist/core/locales/zh.js +55 -0
- package/dist/core/loop.js +18 -1
- package/dist/core/types.js +1 -1
- package/dist/permissions/guard.js +3 -1
- package/dist/prompts/builder.js +2 -1
- package/dist/tools/background.js +118 -0
- package/dist/tools/bash.js +23 -1
- package/dist/tools/index.js +2 -1
- package/package.json +1 -1
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { EventEmitter } from 'events';
|
|
3
|
+
import { StringDecoder } from 'string_decoder';
|
|
4
|
+
export const MAX_BACKGROUND_TASKS = 8;
|
|
5
|
+
/** What is kept of the output of one task; older text is dropped. */
|
|
6
|
+
export const MAX_BUFFER = 200_000;
|
|
7
|
+
/** The most a single read gives back to the model. */
|
|
8
|
+
export const MAX_READ_CHARS = 8000;
|
|
9
|
+
const MAX_NOTES_PER_TASK = 12;
|
|
10
|
+
const NOTE_TAIL_CHARS = 1500;
|
|
11
|
+
const MATCH_DEBOUNCE_MS = 400;
|
|
12
|
+
/** Strips colour codes and carriage-return redraws so the model reads text, not terminal noise. */
|
|
13
|
+
export function cleanOutput(text) {
|
|
14
|
+
return text
|
|
15
|
+
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
|
|
16
|
+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
|
|
17
|
+
.replace(/[^\n]*\r(?!\n)/g, '')
|
|
18
|
+
.replace(/\r/g, '');
|
|
19
|
+
}
|
|
20
|
+
function compileRegex(source) {
|
|
21
|
+
if (!source)
|
|
22
|
+
return undefined;
|
|
23
|
+
try {
|
|
24
|
+
return new RegExp(source, 'i');
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
throw new Error(`Invalid regular expression: ${source}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function formatDuration(ms) {
|
|
31
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
32
|
+
if (s < 60)
|
|
33
|
+
return `${s}s`;
|
|
34
|
+
const m = Math.floor(s / 60);
|
|
35
|
+
if (m < 60)
|
|
36
|
+
return `${m}m ${String(s % 60).padStart(2, '0')}s`;
|
|
37
|
+
return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`;
|
|
38
|
+
}
|
|
39
|
+
export class BackgroundManager extends EventEmitter {
|
|
40
|
+
tasks = new Map();
|
|
41
|
+
counter = 0;
|
|
42
|
+
notes = [];
|
|
43
|
+
// ───────────────────────── starting and stopping ─────────────────────────
|
|
44
|
+
start(command, options = {}) {
|
|
45
|
+
if (!command || !command.trim())
|
|
46
|
+
throw new Error('command is required');
|
|
47
|
+
if (this.running().length >= MAX_BACKGROUND_TASKS) {
|
|
48
|
+
throw new Error(`Too many background tasks running (${MAX_BACKGROUND_TASKS}). Stop one first (action "stop").`);
|
|
49
|
+
}
|
|
50
|
+
const watch = compileRegex(options.notifyOn);
|
|
51
|
+
this.installExitHook();
|
|
52
|
+
const isWin = process.platform === 'win32';
|
|
53
|
+
const cwd = options.cwd || process.cwd();
|
|
54
|
+
const proc = spawn(command, { shell: true, cwd, detached: !isWin, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
55
|
+
const id = `bg${++this.counter}`;
|
|
56
|
+
const info = {
|
|
57
|
+
id,
|
|
58
|
+
label: (options.label || command).replace(/\s+/g, ' ').trim().slice(0, 60),
|
|
59
|
+
command,
|
|
60
|
+
cwd,
|
|
61
|
+
pid: proc.pid,
|
|
62
|
+
status: 'running',
|
|
63
|
+
code: null,
|
|
64
|
+
startedAt: Date.now(),
|
|
65
|
+
outputChars: 0,
|
|
66
|
+
watching: options.notifyOn || undefined,
|
|
67
|
+
};
|
|
68
|
+
let finish;
|
|
69
|
+
const done = new Promise((resolve) => { finish = resolve; });
|
|
70
|
+
const task = {
|
|
71
|
+
info, proc, buffer: '', trimmed: 0, readCursor: 0, scanFrom: 0, pendingLine: '', watch,
|
|
72
|
+
notes: 0, matchedLines: [], stopRequested: false, timedOut: false, done,
|
|
73
|
+
};
|
|
74
|
+
this.tasks.set(id, task);
|
|
75
|
+
const decoders = { out: new StringDecoder('utf8'), err: new StringDecoder('utf8') };
|
|
76
|
+
proc.stdout.on('data', (d) => this.append(task, decoders.out.write(d)));
|
|
77
|
+
proc.stderr.on('data', (d) => this.append(task, decoders.err.write(d)));
|
|
78
|
+
let ended = false;
|
|
79
|
+
const end = (status, code) => {
|
|
80
|
+
if (ended)
|
|
81
|
+
return;
|
|
82
|
+
ended = true;
|
|
83
|
+
this.append(task, decoders.out.end() + decoders.err.end());
|
|
84
|
+
this.flushPendingLine(task);
|
|
85
|
+
clearTimeout(task.killTimer);
|
|
86
|
+
clearTimeout(task.runtimeTimer);
|
|
87
|
+
info.status = task.stopRequested ? 'stopped' : status;
|
|
88
|
+
info.code = code;
|
|
89
|
+
info.endedAt = Date.now();
|
|
90
|
+
this.flushMatches(task);
|
|
91
|
+
// A task the model stopped itself needs no note; anything else does.
|
|
92
|
+
if (!task.stopRequested || task.timedOut)
|
|
93
|
+
this.addNote(task, 'exit', this.exitNote(task));
|
|
94
|
+
this.emit('change', this.snapshot(task));
|
|
95
|
+
finish();
|
|
96
|
+
};
|
|
97
|
+
proc.on('error', () => end('failed', null));
|
|
98
|
+
proc.on('close', (code) => end(code === 0 ? 'exited' : 'failed', code));
|
|
99
|
+
// A child that got away can keep the pipes open for good: do not wait for them for long.
|
|
100
|
+
proc.on('exit', (code) => {
|
|
101
|
+
setTimeout(() => {
|
|
102
|
+
proc.stdout?.destroy();
|
|
103
|
+
proc.stderr?.destroy();
|
|
104
|
+
end(code === 0 ? 'exited' : 'failed', code);
|
|
105
|
+
}, 400).unref();
|
|
106
|
+
});
|
|
107
|
+
if (options.maxRuntimeSeconds && options.maxRuntimeSeconds > 0) {
|
|
108
|
+
task.runtimeTimer = setTimeout(() => { task.timedOut = true; void this.stop(id); }, options.maxRuntimeSeconds * 1000);
|
|
109
|
+
task.runtimeTimer.unref();
|
|
110
|
+
}
|
|
111
|
+
this.emit('change', this.snapshot(task));
|
|
112
|
+
return this.snapshot(task);
|
|
113
|
+
}
|
|
114
|
+
/** Stops a task (SIGTERM, then SIGKILL after a second). Resolves when it is gone. */
|
|
115
|
+
async stop(id) {
|
|
116
|
+
const task = this.must(id);
|
|
117
|
+
if (task.info.status !== 'running')
|
|
118
|
+
return this.snapshot(task);
|
|
119
|
+
task.stopRequested = true;
|
|
120
|
+
this.killTree(task, false);
|
|
121
|
+
task.killTimer = setTimeout(() => this.killTree(task, true), 1000);
|
|
122
|
+
task.killTimer.unref();
|
|
123
|
+
await Promise.race([task.done, new Promise((r) => setTimeout(r, 2500).unref())]);
|
|
124
|
+
return this.snapshot(task);
|
|
125
|
+
}
|
|
126
|
+
async stopAll() {
|
|
127
|
+
const running = this.running();
|
|
128
|
+
await Promise.all(running.map((t) => this.stop(t.id).catch(() => null)));
|
|
129
|
+
return running.length;
|
|
130
|
+
}
|
|
131
|
+
/** Forgets finished tasks (their output is gone with them). */
|
|
132
|
+
clearFinished() {
|
|
133
|
+
let n = 0;
|
|
134
|
+
for (const [id, task] of this.tasks) {
|
|
135
|
+
if (task.info.status !== 'running') {
|
|
136
|
+
this.tasks.delete(id);
|
|
137
|
+
n++;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (n)
|
|
141
|
+
this.emit('change');
|
|
142
|
+
return n;
|
|
143
|
+
}
|
|
144
|
+
// ───────────────────────── looking at tasks ─────────────────────────
|
|
145
|
+
list() {
|
|
146
|
+
return [...this.tasks.values()].map((t) => this.snapshot(t));
|
|
147
|
+
}
|
|
148
|
+
running() {
|
|
149
|
+
return this.list().filter((t) => t.status === 'running');
|
|
150
|
+
}
|
|
151
|
+
get(id) {
|
|
152
|
+
const task = this.tasks.get(id);
|
|
153
|
+
return task ? this.snapshot(task) : undefined;
|
|
154
|
+
}
|
|
155
|
+
/** The output of a task as text for the model or the screen. */
|
|
156
|
+
read(id, options = {}) {
|
|
157
|
+
const task = this.must(id);
|
|
158
|
+
const grep = compileRegex(options.grep);
|
|
159
|
+
let from;
|
|
160
|
+
if (options.mode === 'tail')
|
|
161
|
+
from = task.trimmed;
|
|
162
|
+
else
|
|
163
|
+
from = Math.max(task.readCursor, task.trimmed);
|
|
164
|
+
let text = task.buffer.slice(from - task.trimmed);
|
|
165
|
+
if (options.mode !== 'tail')
|
|
166
|
+
task.readCursor = task.trimmed + task.buffer.length;
|
|
167
|
+
let lines = cleanOutput(text).split('\n');
|
|
168
|
+
if (lines.length && lines[lines.length - 1] === '')
|
|
169
|
+
lines.pop();
|
|
170
|
+
if (grep)
|
|
171
|
+
lines = lines.filter((l) => grep.test(l));
|
|
172
|
+
const want = Math.max(1, Math.min(Math.floor(options.lines ?? (options.mode === 'tail' ? 40 : 200)), 1000));
|
|
173
|
+
let omitted = 0;
|
|
174
|
+
if (lines.length > want) {
|
|
175
|
+
omitted = lines.length - want;
|
|
176
|
+
lines = lines.slice(-want);
|
|
177
|
+
}
|
|
178
|
+
text = lines.join('\n');
|
|
179
|
+
if (text.length > MAX_READ_CHARS) {
|
|
180
|
+
omitted += text.slice(0, text.length - MAX_READ_CHARS).split('\n').length - 1;
|
|
181
|
+
text = text.slice(-MAX_READ_CHARS).replace(/^[^\n]*\n/, '');
|
|
182
|
+
}
|
|
183
|
+
return { text, omitted };
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Waits until the task ends, a line matches `pattern`, the time is up or the turn is cancelled.
|
|
187
|
+
* Lines the model has already read do not count; lines it has not read yet do.
|
|
188
|
+
*/
|
|
189
|
+
wait(id, options = {}) {
|
|
190
|
+
const task = this.must(id);
|
|
191
|
+
const pattern = compileRegex(options.pattern);
|
|
192
|
+
const timeoutMs = Math.min(Math.max(options.timeoutSeconds ?? 60, 1), 600) * 1000;
|
|
193
|
+
return new Promise((resolve) => {
|
|
194
|
+
let timer;
|
|
195
|
+
// Output nobody has read yet counts: a line that came while the model was busy elsewhere is not lost.
|
|
196
|
+
let scan = Math.max(task.readCursor, task.trimmed);
|
|
197
|
+
const finish = (result) => {
|
|
198
|
+
clearTimeout(timer);
|
|
199
|
+
options.signal?.removeEventListener('abort', onAbort);
|
|
200
|
+
this.removeListener(`data:${id}`, onData);
|
|
201
|
+
resolve(result);
|
|
202
|
+
};
|
|
203
|
+
const onAbort = () => finish({ reason: 'aborted' });
|
|
204
|
+
const onClose = () => finish({ reason: 'exit' });
|
|
205
|
+
void task.done.then(onClose);
|
|
206
|
+
const onData = () => {
|
|
207
|
+
if (!pattern)
|
|
208
|
+
return;
|
|
209
|
+
const start = Math.max(scan, task.trimmed);
|
|
210
|
+
const chunk = cleanOutput(task.buffer.slice(start - task.trimmed));
|
|
211
|
+
const lastNl = chunk.lastIndexOf('\n');
|
|
212
|
+
if (lastNl < 0)
|
|
213
|
+
return;
|
|
214
|
+
scan = start + lastNl + 1;
|
|
215
|
+
const hit = chunk.slice(0, lastNl).split('\n').find((l) => pattern.test(l));
|
|
216
|
+
if (hit !== undefined)
|
|
217
|
+
finish({ reason: 'match', line: hit.trim() });
|
|
218
|
+
};
|
|
219
|
+
if (task.info.status !== 'running')
|
|
220
|
+
return resolve({ reason: 'exit' });
|
|
221
|
+
if (options.signal?.aborted)
|
|
222
|
+
return resolve({ reason: 'aborted' });
|
|
223
|
+
timer = setTimeout(() => finish({ reason: 'timeout' }), timeoutMs);
|
|
224
|
+
options.signal?.addEventListener('abort', onAbort, { once: true });
|
|
225
|
+
this.on(`data:${id}`, onData);
|
|
226
|
+
onData();
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
// ───────────────────────── notes for the model ─────────────────────────
|
|
230
|
+
hasNotes() {
|
|
231
|
+
return this.notes.length > 0;
|
|
232
|
+
}
|
|
233
|
+
/** The notes since the last call, as one message for the model (or '' when there are none). */
|
|
234
|
+
takeNotes() {
|
|
235
|
+
if (this.notes.length === 0)
|
|
236
|
+
return '';
|
|
237
|
+
const text = this.notes.map((n) => n.text).join('\n\n');
|
|
238
|
+
this.notes = [];
|
|
239
|
+
return `[devx · background tasks]\n${text}`;
|
|
240
|
+
}
|
|
241
|
+
// ───────────────────────── internals ─────────────────────────
|
|
242
|
+
must(id) {
|
|
243
|
+
const task = this.tasks.get(String(id));
|
|
244
|
+
if (!task) {
|
|
245
|
+
const known = [...this.tasks.keys()].join(', ');
|
|
246
|
+
throw new Error(`No background task "${id}"${known ? ` (known: ${known})` : ' (none started)'}`);
|
|
247
|
+
}
|
|
248
|
+
return task;
|
|
249
|
+
}
|
|
250
|
+
snapshot(task) {
|
|
251
|
+
return { ...task.info, outputChars: task.trimmed + task.buffer.length };
|
|
252
|
+
}
|
|
253
|
+
append(task, text) {
|
|
254
|
+
if (!text)
|
|
255
|
+
return;
|
|
256
|
+
task.buffer += text;
|
|
257
|
+
if (task.buffer.length > MAX_BUFFER * 1.5) {
|
|
258
|
+
const cut = task.buffer.length - MAX_BUFFER;
|
|
259
|
+
task.buffer = task.buffer.slice(cut);
|
|
260
|
+
task.trimmed += cut;
|
|
261
|
+
}
|
|
262
|
+
if (task.watch)
|
|
263
|
+
this.scanLines(task, text);
|
|
264
|
+
this.emit(`data:${task.info.id}`);
|
|
265
|
+
this.emit('output', task.info.id);
|
|
266
|
+
}
|
|
267
|
+
scanLines(task, text) {
|
|
268
|
+
const parts = cleanOutput(task.pendingLine + text).split('\n');
|
|
269
|
+
task.pendingLine = parts.pop() ?? '';
|
|
270
|
+
for (const line of parts) {
|
|
271
|
+
if (task.watch.test(line))
|
|
272
|
+
this.matched(task, line);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
flushPendingLine(task) {
|
|
276
|
+
if (task.watch && task.pendingLine && task.watch.test(task.pendingLine))
|
|
277
|
+
this.matched(task, task.pendingLine);
|
|
278
|
+
task.pendingLine = '';
|
|
279
|
+
}
|
|
280
|
+
matched(task, line) {
|
|
281
|
+
task.matchedLines.push(line.trim().slice(0, 300));
|
|
282
|
+
if (task.matchedLines.length > 20)
|
|
283
|
+
task.matchedLines.shift();
|
|
284
|
+
clearTimeout(task.matchTimer);
|
|
285
|
+
task.matchTimer = setTimeout(() => this.flushMatches(task), MATCH_DEBOUNCE_MS);
|
|
286
|
+
task.matchTimer.unref();
|
|
287
|
+
}
|
|
288
|
+
flushMatches(task) {
|
|
289
|
+
clearTimeout(task.matchTimer);
|
|
290
|
+
if (task.matchedLines.length === 0)
|
|
291
|
+
return;
|
|
292
|
+
const lines = task.matchedLines.splice(0);
|
|
293
|
+
const shown = lines.slice(-5).map((l) => ` ${l}`).join('\n');
|
|
294
|
+
this.addNote(task, 'match', {
|
|
295
|
+
summary: `${task.info.id} · ${lines[lines.length - 1]}`,
|
|
296
|
+
text: `${task.info.id} "${task.info.label}" printed ${lines.length} line${lines.length === 1 ? '' : 's'} matching /${task.info.watching}/:\n${shown}`,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
exitNote(task) {
|
|
300
|
+
const { id, label, code, startedAt, endedAt } = task.info;
|
|
301
|
+
const how = task.timedOut ? 'was stopped: its time limit ran out' : code === 0 ? 'finished (exit code 0)' : code === null ? 'stopped unexpectedly' : `failed (exit code ${code})`;
|
|
302
|
+
const after = formatDuration((endedAt ?? Date.now()) - startedAt);
|
|
303
|
+
const tail = this.tailForNote(task);
|
|
304
|
+
return {
|
|
305
|
+
summary: `${id} ${code === 0 ? 'finished' : code === null ? 'stopped' : `failed · exit ${code}`} · ${after}`,
|
|
306
|
+
text: `${id} "${label}" ${how} after ${after}.${tail ? `\nLast output:\n${tail}` : ''}`,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
tailForNote(task) {
|
|
310
|
+
const text = cleanOutput(task.buffer).trimEnd();
|
|
311
|
+
return text.length > NOTE_TAIL_CHARS ? '…' + text.slice(-NOTE_TAIL_CHARS).replace(/^[^\n]*\n/, '') : text;
|
|
312
|
+
}
|
|
313
|
+
addNote(task, kind, body) {
|
|
314
|
+
task.notes++;
|
|
315
|
+
// A chatty watcher must not flood the conversation: after a dozen notes only the end is reported.
|
|
316
|
+
if (kind === 'match' && task.notes > MAX_NOTES_PER_TASK)
|
|
317
|
+
return;
|
|
318
|
+
const note = { taskId: task.info.id, kind, ...body };
|
|
319
|
+
this.notes.push(note);
|
|
320
|
+
this.emit('note', note);
|
|
321
|
+
}
|
|
322
|
+
killTree(task, force) {
|
|
323
|
+
const pid = task.proc.pid;
|
|
324
|
+
if (!pid || task.info.status !== 'running')
|
|
325
|
+
return;
|
|
326
|
+
if (process.platform === 'win32') {
|
|
327
|
+
const killer = spawn('taskkill', ['/pid', String(pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
|
|
328
|
+
killer.on('error', () => task.proc.kill());
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
try {
|
|
332
|
+
process.kill(-pid, force ? 'SIGKILL' : 'SIGTERM');
|
|
333
|
+
}
|
|
334
|
+
catch (err) {
|
|
335
|
+
if (err.code !== 'ESRCH') {
|
|
336
|
+
try {
|
|
337
|
+
task.proc.kill(force ? 'SIGKILL' : 'SIGTERM');
|
|
338
|
+
}
|
|
339
|
+
catch { }
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
/** Nothing started by devx may outlive it. */
|
|
344
|
+
installExitHook() {
|
|
345
|
+
liveManagers.add(this);
|
|
346
|
+
if (exitHookInstalled)
|
|
347
|
+
return;
|
|
348
|
+
exitHookInstalled = true;
|
|
349
|
+
process.on('exit', () => {
|
|
350
|
+
for (const manager of liveManagers)
|
|
351
|
+
manager.killAllNow();
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
/** Synchronous: used when the process is exiting. */
|
|
355
|
+
killAllNow() {
|
|
356
|
+
for (const task of this.tasks.values()) {
|
|
357
|
+
if (task.info.status === 'running')
|
|
358
|
+
this.killTree(task, true);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
const liveManagers = new Set();
|
|
363
|
+
let exitHookInstalled = false;
|
|
364
|
+
let instance = null;
|
|
365
|
+
export function getBackgroundManager() {
|
|
366
|
+
if (!instance) {
|
|
367
|
+
instance = new BackgroundManager();
|
|
368
|
+
instance.setMaxListeners(50);
|
|
369
|
+
}
|
|
370
|
+
return instance;
|
|
371
|
+
}
|
|
372
|
+
/** For tests: a fresh manager. */
|
|
373
|
+
export function resetBackgroundManager() {
|
|
374
|
+
void instance?.stopAll();
|
|
375
|
+
instance = null;
|
|
376
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Saving the user's config when several devx processes run at once.
|
|
3
|
+
*
|
|
4
|
+
* Every process keeps its own copy of ~/.devxrc.json in memory. Writing that copy back whole would
|
|
5
|
+
* let the process that saves last overwrite what the others changed meanwhile (theme, API keys,
|
|
6
|
+
* "Always" permissions, the default model...). So a save writes only what THIS process changed
|
|
7
|
+
* since it last looked at the file (`base` -> `mine`) on top of what is on disk now (`disk`).
|
|
8
|
+
*/
|
|
9
|
+
const isPlain = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
10
|
+
function isPrimitiveList(v) {
|
|
11
|
+
return Array.isArray(v) && v.every((x) => x === null || ['string', 'number', 'boolean'].includes(typeof x));
|
|
12
|
+
}
|
|
13
|
+
/** Structural equality for JSON-like values. */
|
|
14
|
+
export function sameValue(a, b) {
|
|
15
|
+
if (a === b)
|
|
16
|
+
return true;
|
|
17
|
+
if (Array.isArray(a) && Array.isArray(b))
|
|
18
|
+
return a.length === b.length && a.every((x, i) => sameValue(x, b[i]));
|
|
19
|
+
if (isPlain(a) && isPlain(b)) {
|
|
20
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
21
|
+
for (const k of keys)
|
|
22
|
+
if (!sameValue(a[k], b[k]))
|
|
23
|
+
return false;
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
/** A deep copy that is safe to keep as a baseline. */
|
|
29
|
+
export function cloneConfig(value) {
|
|
30
|
+
return value === undefined ? value : JSON.parse(JSON.stringify(value));
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* `disk` with the changes that `base` -> `mine` made applied to it.
|
|
34
|
+
* - a value that did not change is left as it is on disk (another process may have changed it);
|
|
35
|
+
* - objects (apiKeys, trustedProjects...) are merged key by key;
|
|
36
|
+
* - lists of plain values (the bash allowlist) get what this process added and lose what it removed.
|
|
37
|
+
*/
|
|
38
|
+
export function mergeConfigChanges(base, mine, disk) {
|
|
39
|
+
const result = { ...disk };
|
|
40
|
+
const keys = new Set([...Object.keys(base), ...Object.keys(mine)]);
|
|
41
|
+
for (const key of keys) {
|
|
42
|
+
const b = base[key];
|
|
43
|
+
const m = mine[key];
|
|
44
|
+
if (sameValue(b, m))
|
|
45
|
+
continue;
|
|
46
|
+
if (m === undefined) {
|
|
47
|
+
delete result[key];
|
|
48
|
+
}
|
|
49
|
+
else if (isPlain(m) && (b === undefined || isPlain(b)) && (disk[key] === undefined || isPlain(disk[key]))) {
|
|
50
|
+
result[key] = mergeConfigChanges(isPlain(b) ? b : {}, m, isPlain(disk[key]) ? disk[key] : {});
|
|
51
|
+
}
|
|
52
|
+
else if (isPrimitiveList(m) && (b === undefined || isPrimitiveList(b)) && (disk[key] === undefined || isPrimitiveList(disk[key]))) {
|
|
53
|
+
const old = b ?? [];
|
|
54
|
+
const now = m;
|
|
55
|
+
const added = now.filter((x) => !old.includes(x));
|
|
56
|
+
const removed = old.filter((x) => !now.includes(x));
|
|
57
|
+
// Emptying a list ("/permissions clear") really empties it.
|
|
58
|
+
result[key] = m.length === 0
|
|
59
|
+
? []
|
|
60
|
+
: [...new Set([...(disk[key] ?? []).filter((x) => !removed.includes(x)), ...added])];
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
result[key] = m;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return result;
|
|
67
|
+
}
|
package/dist/core/i18n.js
CHANGED
|
@@ -15,8 +15,8 @@ export const LANGUAGES = [
|
|
|
15
15
|
{ id: 'es', name: 'Español' },
|
|
16
16
|
{ id: 'fr', name: 'Français' },
|
|
17
17
|
{ id: 'pt', name: 'Português' },
|
|
18
|
-
{ id: 'zh', name: '
|
|
19
|
-
{ id: 'ja', name: '
|
|
18
|
+
{ id: 'zh', name: 'Chinese' },
|
|
19
|
+
{ id: 'ja', name: 'Japanese' },
|
|
20
20
|
];
|
|
21
21
|
export const LANGUAGE_IDS = LANGUAGES.map((l) => l.id);
|
|
22
22
|
export const DEFAULT_LANGUAGE = 'en';
|
package/dist/core/locales/de.js
CHANGED
|
@@ -207,4 +207,59 @@ export const de = {
|
|
|
207
207
|
'rewind.filesNo': 'Nein, nur das Gespräch',
|
|
208
208
|
'rewind.done': 'Zurück bei deiner Nachricht. Was danach kam, ist weg, auch für das Modell. Ändere die Nachricht und sende sie.',
|
|
209
209
|
'rewind.doneImages': 'Zurück bei deiner Nachricht (ihre Bilder blieben nicht erhalten, hänge sie erneut an). Was danach kam, ist weg, auch für das Modell.',
|
|
210
|
+
// ── first run and the prompts around it
|
|
211
|
+
'welcome.tagline': 'Ein KI-Coding-Agent für das Terminal',
|
|
212
|
+
'welcome.intro': 'Richten wir alles ein. Das dauert etwa eine Minute.',
|
|
213
|
+
'welcome.step.language': 'Sprache',
|
|
214
|
+
'welcome.step.provider': 'Anbieter',
|
|
215
|
+
'welcome.step.key': 'API-Schlüssel',
|
|
216
|
+
'welcome.step.model': 'Modell',
|
|
217
|
+
'welcome.keyNote': 'Dein Schlüssel bleibt auf diesem Computer, in ~/.devxrc.json.',
|
|
218
|
+
'onboard.language.title': 'sprache',
|
|
219
|
+
'onboard.language.note': 'Du kannst sie später in /settings ändern.',
|
|
220
|
+
'onboard.provider.title': 'anbieter',
|
|
221
|
+
'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.',
|
|
223
|
+
'onboard.custom': 'Eigene Adresse',
|
|
224
|
+
'onboard.customDetail': 'Jede OpenAI-kompatible Adresse',
|
|
225
|
+
'onboard.baseUrl.title': 'adresse',
|
|
226
|
+
'onboard.baseUrl.label': 'Basis-URL der API',
|
|
227
|
+
'onboard.baseUrl.placeholder': 'https://api.example.com/v1',
|
|
228
|
+
'onboard.key.title': 'api-schlüssel',
|
|
229
|
+
'onboard.key.label': 'Füge deinen API-Schlüssel für {provider} ein',
|
|
230
|
+
'onboard.key.note': 'Die Eingabe bleibt verborgen. Esc geht zurück.',
|
|
231
|
+
'onboard.key.useSaved': 'Gespeicherten Schlüssel verwenden',
|
|
232
|
+
'onboard.key.enterNew': 'Neuen Schlüssel eingeben',
|
|
233
|
+
'onboard.key.savedTitle': 'für {provider} ist ein Schlüssel gespeichert',
|
|
234
|
+
'onboard.model.title': 'modell',
|
|
235
|
+
'onboard.model.placeholder': 'tippen, um ein Modell zu finden',
|
|
236
|
+
'onboard.model.custom': 'Modellnamen eingeben',
|
|
237
|
+
'onboard.model.customDetail': 'Jede Modell-ID',
|
|
238
|
+
'onboard.model.label': 'Name des Modells (zum Beispiel gpt-4o)',
|
|
239
|
+
'onboard.model.fetching': 'Modellliste wird geladen...',
|
|
240
|
+
'onboard.model.manual': 'Die Modellliste konnte nicht geladen werden, gib den Modellnamen ein.',
|
|
241
|
+
'onboard.model.context': '{n} Kontext',
|
|
242
|
+
'onboard.model.anthropic': 'Anthropic-Format',
|
|
243
|
+
'onboard.back': 'Zurück',
|
|
244
|
+
'onboard.back.detail': 'Aktuelle Einstellung behalten',
|
|
245
|
+
'onboard.done': 'Fertig. Deine Auswahl ist in ~/.devxrc.json gespeichert.',
|
|
246
|
+
'finder.placeholder': 'tippen zum Filtern',
|
|
247
|
+
'finder.empty': 'Keine Treffer',
|
|
248
|
+
'finder.hint.filter': 'filtern',
|
|
249
|
+
'picker.hint.move': 'bewegen',
|
|
250
|
+
'picker.hint.select': 'wählen',
|
|
251
|
+
'picker.hint.cancel': 'abbrechen',
|
|
252
|
+
'picker.hint.confirm': 'bestätigen',
|
|
253
|
+
'welcome.step.theme': 'Design',
|
|
254
|
+
'onboard.theme.title': 'design',
|
|
255
|
+
'onboard.theme.note': 'Die Farben der Oberfläche. Mit /theme jederzeit änderbar.',
|
|
256
|
+
'resume.title': 'fortsetzen',
|
|
257
|
+
'resume.placeholder': 'tippen, um Sitzungen zu filtern',
|
|
258
|
+
'resume.msgs': '{n} Nachr.',
|
|
259
|
+
'sessions.deleteTitle': 'Sitzungen löschen',
|
|
260
|
+
'sessions.deleteAll': 'Alle {n} Sitzungen löschen',
|
|
261
|
+
'commands.title': 'Befehle',
|
|
262
|
+
'commands.placeholder': 'tippen, um einen Befehl zu finden',
|
|
263
|
+
'compact.done': 'Kontext verdichtet, Zusammenfassung behalten',
|
|
264
|
+
'compact.summary': 'was das Modell geschrieben hat',
|
|
210
265
|
};
|
package/dist/core/locales/en.js
CHANGED
|
@@ -218,4 +218,59 @@ export const en = {
|
|
|
218
218
|
'rewind.filesNo': 'No, only the conversation',
|
|
219
219
|
'rewind.done': 'Back at your message. What came after it is gone, for the model too. Edit the message and send it.',
|
|
220
220
|
'rewind.doneImages': 'Back at your message (its images were not kept, attach them again). What came after it is gone, for the model too.',
|
|
221
|
+
// ── first run and the prompts around it
|
|
222
|
+
'welcome.tagline': 'A terminal AI coding agent',
|
|
223
|
+
'welcome.intro': 'Let us set things up. It takes about a minute.',
|
|
224
|
+
'welcome.step.language': 'Language',
|
|
225
|
+
'welcome.step.provider': 'Provider',
|
|
226
|
+
'welcome.step.key': 'API key',
|
|
227
|
+
'welcome.step.model': 'Model',
|
|
228
|
+
'welcome.keyNote': 'Your key stays on this computer, in ~/.devxrc.json.',
|
|
229
|
+
'onboard.language.title': 'language',
|
|
230
|
+
'onboard.language.note': 'You can change it later in /settings.',
|
|
231
|
+
'onboard.provider.title': 'provider',
|
|
232
|
+
'onboard.provider.placeholder': 'type to find a provider',
|
|
233
|
+
'onboard.provider.note': 'The service that gives you the models. Not in the list? Choose Custom endpoint.',
|
|
234
|
+
'onboard.custom': 'Custom endpoint',
|
|
235
|
+
'onboard.customDetail': 'Any OpenAI-compatible address',
|
|
236
|
+
'onboard.baseUrl.title': 'address',
|
|
237
|
+
'onboard.baseUrl.label': 'Base URL of the API',
|
|
238
|
+
'onboard.baseUrl.placeholder': 'https://api.example.com/v1',
|
|
239
|
+
'onboard.key.title': 'api key',
|
|
240
|
+
'onboard.key.label': 'Paste your API key for {provider}',
|
|
241
|
+
'onboard.key.note': 'What you type is hidden. Esc goes back.',
|
|
242
|
+
'onboard.key.useSaved': 'Use the saved key',
|
|
243
|
+
'onboard.key.enterNew': 'Enter a new key',
|
|
244
|
+
'onboard.key.savedTitle': 'a key is saved for {provider}',
|
|
245
|
+
'onboard.model.title': 'model',
|
|
246
|
+
'onboard.model.placeholder': 'type to find a model',
|
|
247
|
+
'onboard.model.custom': 'Type a model name',
|
|
248
|
+
'onboard.model.customDetail': 'Any model id',
|
|
249
|
+
'onboard.model.label': 'Name of the model (for example gpt-4o)',
|
|
250
|
+
'onboard.model.fetching': 'Fetching the list of models...',
|
|
251
|
+
'onboard.model.manual': 'The list of models could not be fetched, so type the name of the model.',
|
|
252
|
+
'onboard.model.context': '{n} context',
|
|
253
|
+
'onboard.model.anthropic': 'Anthropic format',
|
|
254
|
+
'onboard.back': 'Back',
|
|
255
|
+
'onboard.back.detail': 'Keep what is set now',
|
|
256
|
+
'onboard.done': 'All set. Your choices are saved in ~/.devxrc.json.',
|
|
257
|
+
'finder.placeholder': 'type to filter',
|
|
258
|
+
'finder.empty': 'Nothing matches',
|
|
259
|
+
'finder.hint.filter': 'filter',
|
|
260
|
+
'picker.hint.move': 'move',
|
|
261
|
+
'picker.hint.select': 'select',
|
|
262
|
+
'picker.hint.cancel': 'cancel',
|
|
263
|
+
'picker.hint.confirm': 'confirm',
|
|
264
|
+
'welcome.step.theme': 'Theme',
|
|
265
|
+
'onboard.theme.title': 'theme',
|
|
266
|
+
'onboard.theme.note': 'The colours of the interface. Change them any time with /theme.',
|
|
267
|
+
'resume.title': 'resume',
|
|
268
|
+
'resume.placeholder': 'type to filter sessions',
|
|
269
|
+
'resume.msgs': '{n} msgs',
|
|
270
|
+
'sessions.deleteTitle': 'delete sessions',
|
|
271
|
+
'sessions.deleteAll': 'Delete all {n} sessions',
|
|
272
|
+
'commands.title': 'commands',
|
|
273
|
+
'commands.placeholder': 'type to find a command',
|
|
274
|
+
'compact.done': 'Context compacted, summary kept',
|
|
275
|
+
'compact.summary': 'what the model wrote',
|
|
221
276
|
};
|
package/dist/core/locales/es.js
CHANGED
|
@@ -207,4 +207,59 @@ export const es = {
|
|
|
207
207
|
'rewind.filesNo': 'No, solo la conversación',
|
|
208
208
|
'rewind.done': 'De vuelta en tu mensaje. Lo que vino después desapareció, también para el modelo. Edita el mensaje y envíalo.',
|
|
209
209
|
'rewind.doneImages': 'De vuelta en tu mensaje (sus imágenes no se conservaron, adjúntalas de nuevo). Lo que vino después desapareció, también para el modelo.',
|
|
210
|
+
// ── first run and the prompts around it
|
|
211
|
+
'welcome.tagline': 'Un agente de programación con IA para la terminal',
|
|
212
|
+
'welcome.intro': 'Vamos a configurarlo todo. Lleva cerca de un minuto.',
|
|
213
|
+
'welcome.step.language': 'Idioma',
|
|
214
|
+
'welcome.step.provider': 'Proveedor',
|
|
215
|
+
'welcome.step.key': 'Clave de API',
|
|
216
|
+
'welcome.step.model': 'Modelo',
|
|
217
|
+
'welcome.keyNote': 'Tu clave se queda en este equipo, en ~/.devxrc.json.',
|
|
218
|
+
'onboard.language.title': 'idioma',
|
|
219
|
+
'onboard.language.note': 'Puedes cambiarlo más tarde en /settings.',
|
|
220
|
+
'onboard.provider.title': 'proveedor',
|
|
221
|
+
'onboard.provider.placeholder': 'escribe para buscar un proveedor',
|
|
222
|
+
'onboard.provider.note': 'El servicio que te da los modelos. ¿No está en la lista? Elige Dirección propia.',
|
|
223
|
+
'onboard.custom': 'Dirección propia',
|
|
224
|
+
'onboard.customDetail': 'Cualquier dirección compatible con OpenAI',
|
|
225
|
+
'onboard.baseUrl.title': 'dirección',
|
|
226
|
+
'onboard.baseUrl.label': 'URL base de la API',
|
|
227
|
+
'onboard.baseUrl.placeholder': 'https://api.example.com/v1',
|
|
228
|
+
'onboard.key.title': 'clave de api',
|
|
229
|
+
'onboard.key.label': 'Pega tu clave de API para {provider}',
|
|
230
|
+
'onboard.key.note': 'Lo que escribes está oculto. Esc vuelve atrás.',
|
|
231
|
+
'onboard.key.useSaved': 'Usar la clave guardada',
|
|
232
|
+
'onboard.key.enterNew': 'Introducir una clave nueva',
|
|
233
|
+
'onboard.key.savedTitle': 'hay una clave guardada para {provider}',
|
|
234
|
+
'onboard.model.title': 'modelo',
|
|
235
|
+
'onboard.model.placeholder': 'escribe para buscar un modelo',
|
|
236
|
+
'onboard.model.custom': 'Escribir el nombre de un modelo',
|
|
237
|
+
'onboard.model.customDetail': 'Cualquier id de modelo',
|
|
238
|
+
'onboard.model.label': 'Nombre del modelo (por ejemplo gpt-4o)',
|
|
239
|
+
'onboard.model.fetching': 'Obteniendo la lista de modelos...',
|
|
240
|
+
'onboard.model.manual': 'No se pudo obtener la lista de modelos, escribe el nombre del modelo.',
|
|
241
|
+
'onboard.model.context': 'contexto de {n}',
|
|
242
|
+
'onboard.model.anthropic': 'formato Anthropic',
|
|
243
|
+
'onboard.back': 'Atrás',
|
|
244
|
+
'onboard.back.detail': 'Mantener lo actual',
|
|
245
|
+
'onboard.done': 'Listo. Tu elección está guardada en ~/.devxrc.json.',
|
|
246
|
+
'finder.placeholder': 'escribe para filtrar',
|
|
247
|
+
'finder.empty': 'Nada coincide',
|
|
248
|
+
'finder.hint.filter': 'filtrar',
|
|
249
|
+
'picker.hint.move': 'mover',
|
|
250
|
+
'picker.hint.select': 'elegir',
|
|
251
|
+
'picker.hint.cancel': 'cancelar',
|
|
252
|
+
'picker.hint.confirm': 'confirmar',
|
|
253
|
+
'welcome.step.theme': 'Tema',
|
|
254
|
+
'onboard.theme.title': 'tema',
|
|
255
|
+
'onboard.theme.note': 'Los colores de la interfaz. Puedes cambiarlos cuando quieras con /theme.',
|
|
256
|
+
'resume.title': 'reanudar',
|
|
257
|
+
'resume.placeholder': 'escribe para filtrar sesiones',
|
|
258
|
+
'resume.msgs': '{n} msjs',
|
|
259
|
+
'sessions.deleteTitle': 'borrar sesiones',
|
|
260
|
+
'sessions.deleteAll': 'Borrar las {n} sesiones',
|
|
261
|
+
'commands.title': 'comandos',
|
|
262
|
+
'commands.placeholder': 'escribe para buscar un comando',
|
|
263
|
+
'compact.done': 'Contexto compactado, resumen guardado',
|
|
264
|
+
'compact.summary': 'lo que escribió el modelo',
|
|
210
265
|
};
|