termux-dev 1.8.2 → 1.9.1
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 +16 -6
- 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 +10 -2
- package/dist/cli/headless.js +3 -2
- package/dist/cli/index.js +131 -75
- package/dist/cli/keys.js +12 -3
- package/dist/cli/liveblock.js +47 -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/update-screen.js +383 -0
- package/dist/cli/updater.js +119 -145
- 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 +69 -7
- package/dist/core/locales/en.js +69 -7
- package/dist/core/locales/es.js +69 -7
- package/dist/core/locales/fr.js +69 -7
- package/dist/core/locales/ja.js +69 -7
- package/dist/core/locales/pt.js +69 -7
- package/dist/core/locales/ru.js +69 -7
- package/dist/core/locales/uk.js +69 -7
- package/dist/core/locales/zh.js +69 -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;
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { frame } from './finder.js';
|
|
2
|
+
import { LiveBlock } from './liveblock.js';
|
|
3
|
+
import { runPicker } from './picker.js';
|
|
4
|
+
import { palette, renderNotice } from './ui.js';
|
|
5
|
+
import { displayWidth, truncateWidth } from './width.js';
|
|
6
|
+
import { getLanguage, t } from '../core/i18n.js';
|
|
7
|
+
import { UpdateError, buildUpdateSteps, checkForUpdates, detectInstallMethod, getCurrentVersion, restartDevx, runUpdateSteps, } from './updater.js';
|
|
8
|
+
/**
|
|
9
|
+
* The update screens, in the style of the rest of devx:
|
|
10
|
+
*
|
|
11
|
+
* checking one line, the text with a bright band sweeping across it
|
|
12
|
+
* card what is new (from the notes of the release), how it will be installed
|
|
13
|
+
* choice Update now / Later / Skip this version
|
|
14
|
+
* progress the steps with a spinner and the time of each, and a bar that fills as they go
|
|
15
|
+
* result installed (the bar drains while devx restarts) or what failed and how to do it by hand
|
|
16
|
+
*
|
|
17
|
+
* Every screen is a pure function of its state and the animation tick, so it can be tested without
|
|
18
|
+
* a terminal; `presentUpdate` only adds the timer, the keyboard and the install.
|
|
19
|
+
*/
|
|
20
|
+
export const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
21
|
+
const TICK_MS = 80;
|
|
22
|
+
// ───────────────────────── animation pieces ─────────────────────────
|
|
23
|
+
/** The text with a bright band moving across it; `tick` is the frame of the animation. */
|
|
24
|
+
export function shimmer(text, tick) {
|
|
25
|
+
const chars = Array.from(text);
|
|
26
|
+
const position = ((tick * 1.2) % (chars.length + 10)) - 5;
|
|
27
|
+
return chars
|
|
28
|
+
.map((ch, i) => {
|
|
29
|
+
if (ch === ' ')
|
|
30
|
+
return ch;
|
|
31
|
+
const d = Math.abs(i - position);
|
|
32
|
+
if (d < 1.2)
|
|
33
|
+
return palette.bold(palette.text(ch));
|
|
34
|
+
if (d < 3.5)
|
|
35
|
+
return palette.accent(ch);
|
|
36
|
+
return palette.muted(ch);
|
|
37
|
+
})
|
|
38
|
+
.join('');
|
|
39
|
+
}
|
|
40
|
+
/** A bar of exactly `width` cells. While busy a bright spot runs along the filled part. */
|
|
41
|
+
export function renderBar(width, progress, tick, tone = 'busy') {
|
|
42
|
+
const w = Math.max(4, width);
|
|
43
|
+
const filled = Math.max(0, Math.min(w, Math.round(progress * w)));
|
|
44
|
+
const color = tone === 'done' ? palette.ok : tone === 'failed' ? palette.err : palette.accent;
|
|
45
|
+
const spot = tone === 'busy' && filled > 0 ? ((tick * 1.5) % (filled + 6)) - 3 : -99;
|
|
46
|
+
let out = '';
|
|
47
|
+
for (let i = 0; i < w; i++) {
|
|
48
|
+
if (i < filled)
|
|
49
|
+
out += Math.abs(i - spot) < 1.5 ? palette.bold(palette.text('━')) : color('━');
|
|
50
|
+
else if (i === filled && tone === 'busy')
|
|
51
|
+
out += color('╸');
|
|
52
|
+
else
|
|
53
|
+
out += palette.rule('─');
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
/** How long each step weighs in the bar: the install is nearly all of the wait. */
|
|
58
|
+
const WEIGHT = { registry: 0.1, install: 0.75, verify: 0.15, pull: 0.15, build: 0.75, link: 0.1 };
|
|
59
|
+
/**
|
|
60
|
+
* How full the bar is. The real progress of an install is not known, so the step that is running
|
|
61
|
+
* fills asymptotically: it keeps moving, and it never reaches the end before the step is done.
|
|
62
|
+
*/
|
|
63
|
+
export function estimateProgress(ids, doneCount, runningMs, expectedMs = 15000) {
|
|
64
|
+
const total = ids.reduce((sum, id) => sum + WEIGHT[id], 0);
|
|
65
|
+
if (doneCount >= ids.length)
|
|
66
|
+
return 1;
|
|
67
|
+
const done = ids.slice(0, doneCount).reduce((sum, id) => sum + WEIGHT[id], 0);
|
|
68
|
+
const running = WEIGHT[ids[doneCount]] * (1 - Math.exp(-runningMs / expectedMs)) * 0.92;
|
|
69
|
+
return Math.min(0.99, (done + running) / total);
|
|
70
|
+
}
|
|
71
|
+
const seconds = (ms) => `${(ms / 1000).toFixed(1)}s`;
|
|
72
|
+
// ───────────────────────── what is new ─────────────────────────
|
|
73
|
+
const SECTION_NAMES = {
|
|
74
|
+
en: /english/i, ru: /русск/i, uk: /україн/i, de: /deutsch/i, es: /español/i, fr: /français/i, pt: /português/i, zh: /中文/, ja: /日本語/,
|
|
75
|
+
};
|
|
76
|
+
function cleanMarkdown(text) {
|
|
77
|
+
return text
|
|
78
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
79
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
80
|
+
.replace(/[`*_]/g, '')
|
|
81
|
+
.replace(/\s+/g, ' ')
|
|
82
|
+
.trim();
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The highlights of a release: the top-level bullets under its first `###` heading, in the language
|
|
86
|
+
* of the interface when the notes have a section for it (`## Русский`), else the first section.
|
|
87
|
+
*/
|
|
88
|
+
export function extractHighlights(body, language, max = 4) {
|
|
89
|
+
if (!body)
|
|
90
|
+
return [];
|
|
91
|
+
const lines = body.replace(/\r/g, '').split('\n');
|
|
92
|
+
const sections = [];
|
|
93
|
+
for (const line of lines) {
|
|
94
|
+
const heading = /^##\s+(.+?)\s*$/.exec(line);
|
|
95
|
+
if (heading)
|
|
96
|
+
sections.push({ name: heading[1], lines: [] });
|
|
97
|
+
else
|
|
98
|
+
sections[sections.length - 1]?.lines.push(line);
|
|
99
|
+
}
|
|
100
|
+
const pool = sections.length > 0 ? sections : [{ name: '', lines }];
|
|
101
|
+
const own = SECTION_NAMES[language];
|
|
102
|
+
const chosen = (own && pool.find((s) => own.test(s.name))) || pool.find((s) => SECTION_NAMES.en.test(s.name)) || pool[0];
|
|
103
|
+
const out = [];
|
|
104
|
+
let seenHeading = false;
|
|
105
|
+
for (const line of chosen.lines) {
|
|
106
|
+
if (/^###\s/.test(line)) {
|
|
107
|
+
// The bullets of the first block only ("Highlights"): the next heading ends them.
|
|
108
|
+
if (seenHeading && out.length > 0)
|
|
109
|
+
break;
|
|
110
|
+
seenHeading = true;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const bullet = /^-\s+(.+)$/.exec(line);
|
|
114
|
+
if (bullet) {
|
|
115
|
+
const text = cleanMarkdown(bullet[1]);
|
|
116
|
+
if (text)
|
|
117
|
+
out.push(text);
|
|
118
|
+
if (out.length >= max)
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
/** `Your own providers. A name, an address…` -> the short head and the rest. */
|
|
125
|
+
function splitHighlight(text) {
|
|
126
|
+
const at = text.search(/[.:] /);
|
|
127
|
+
if (at > 0 && at <= 50)
|
|
128
|
+
return { head: text.slice(0, at + 1), rest: text.slice(at + 2) };
|
|
129
|
+
return { head: text, rest: '' };
|
|
130
|
+
}
|
|
131
|
+
const versions = (current, latest) => `v${current} → v${latest}`;
|
|
132
|
+
/** The line while the check runs. */
|
|
133
|
+
export function renderChecking(cols, tick, current) {
|
|
134
|
+
const limit = Math.max(10, cols - 1);
|
|
135
|
+
const spin = palette.accent(SPINNER_FRAMES[tick % SPINNER_FRAMES.length]);
|
|
136
|
+
// Two cells of indent, the spinner and a space come first; the text is cut to what is left.
|
|
137
|
+
const text = truncateWidth(t('update.checking'), Math.max(4, limit - 4));
|
|
138
|
+
const label = shimmer(text, tick);
|
|
139
|
+
const withTag = ` ${spin} ${label} ${palette.faint(`v${current}`)}`;
|
|
140
|
+
return displayWidth(withTag) <= limit ? withTag : ` ${spin} ${label}`;
|
|
141
|
+
}
|
|
142
|
+
/** The card that says a new version is out and what is in it. */
|
|
143
|
+
export function renderUpdateCard(info, cols) {
|
|
144
|
+
const how = info.method === 'npm' ? t('update.viaNpm') : t('update.viaGit');
|
|
145
|
+
return frame(t('update.title'), versions(info.current, info.latest), cols, (iw, box) => {
|
|
146
|
+
const lines = [];
|
|
147
|
+
lines.push(box(' ' + palette.bold(palette.accent(truncateWidth(t('update.available', { version: info.latest }), iw - 2)))));
|
|
148
|
+
const date = info.publishedAt ? info.publishedAt.slice(0, 10) : '';
|
|
149
|
+
const meta = [t('update.have', { version: info.current }), date ? t('update.released', { date }) : ''].filter(Boolean).join(' · ');
|
|
150
|
+
lines.push(box(' ' + palette.muted(truncateWidth(meta, iw - 2))));
|
|
151
|
+
if (info.highlights.length > 0) {
|
|
152
|
+
lines.push(box(''));
|
|
153
|
+
lines.push(box(' ' + palette.faint(truncateWidth(t('update.whatsNew').toUpperCase(), iw - 2))));
|
|
154
|
+
for (const text of info.highlights) {
|
|
155
|
+
const { head, rest } = splitHighlight(text);
|
|
156
|
+
const room = iw - 4;
|
|
157
|
+
const headText = truncateWidth(head, room);
|
|
158
|
+
const restRoom = room - displayWidth(headText) - 1;
|
|
159
|
+
const tailText = rest && restRoom >= 8 ? ' ' + palette.muted(truncateWidth(rest, restRoom)) : '';
|
|
160
|
+
lines.push(box(' ' + palette.accent('●') + ' ' + palette.bold(palette.text(headText)) + tailText));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
lines.push(box(''));
|
|
164
|
+
return lines;
|
|
165
|
+
}, palette.faint(truncateWidth(how, Math.max(10, cols - 3))));
|
|
166
|
+
}
|
|
167
|
+
/** The steps with a spinner on the one that runs, and the bar. */
|
|
168
|
+
export function renderProgressCard(state, cols, tick) {
|
|
169
|
+
const done = state.tone === 'done';
|
|
170
|
+
return frame(done ? t('update.titleDone') : t('update.titleBusy'), versions(state.current, state.latest), cols, (iw, box) => {
|
|
171
|
+
const lines = [];
|
|
172
|
+
for (const step of state.steps) {
|
|
173
|
+
const icon = step.status === 'ok' ? palette.ok('✓')
|
|
174
|
+
: step.status === 'run' ? palette.accent(SPINNER_FRAMES[tick % SPINNER_FRAMES.length])
|
|
175
|
+
: step.status === 'fail' ? palette.err('✗')
|
|
176
|
+
: palette.faint('○');
|
|
177
|
+
const time = step.ms !== undefined ? palette.faint(seconds(step.ms)) : '';
|
|
178
|
+
const room = Math.max(6, iw - 4 - displayWidth(time) - 1);
|
|
179
|
+
const text = truncateWidth(step.label, room);
|
|
180
|
+
const label = step.status === 'run' ? palette.bold(palette.text(text))
|
|
181
|
+
: step.status === 'wait' ? palette.faint(text)
|
|
182
|
+
: step.status === 'fail' ? palette.err(text)
|
|
183
|
+
: palette.muted(text);
|
|
184
|
+
const left = ' ' + icon + ' ' + label;
|
|
185
|
+
lines.push(box(left + ' '.repeat(Math.max(1, iw - displayWidth(left) - displayWidth(time) - 1)) + time));
|
|
186
|
+
}
|
|
187
|
+
lines.push(box(''));
|
|
188
|
+
lines.push(box(' ' + renderBar(iw - 2, state.progress, tick, state.tone)));
|
|
189
|
+
if (state.restarting)
|
|
190
|
+
lines.push(box(' ' + palette.muted(truncateWidth(t('update.restarting'), iw - 2))));
|
|
191
|
+
lines.push(box(''));
|
|
192
|
+
return lines;
|
|
193
|
+
}, palette.faint(truncateWidth(done ? t('update.installed', { version: state.latest }) : t('update.keepOpen'), Math.max(10, cols - 3))));
|
|
194
|
+
}
|
|
195
|
+
/** The command that does the update by hand. */
|
|
196
|
+
export const manualCommand = (method) => (method === 'npm' ? 'npm install -g termux-dev@latest' : 'git pull && npm install && npm run build');
|
|
197
|
+
/** What failed, and how to do it by hand. devx goes on with the version it has. */
|
|
198
|
+
export function renderFailureCard(info, cols) {
|
|
199
|
+
return frame(t('update.titleFailed'), versions(info.current, info.latest), cols, (iw, box) => {
|
|
200
|
+
const lines = [];
|
|
201
|
+
lines.push(box(' ' + palette.err('✗') + ' ' + palette.err(truncateWidth(t('update.failedStep', { step: info.step }), iw - 4))));
|
|
202
|
+
const detail = info.notPublished
|
|
203
|
+
? [t('update.notPublished', { version: info.latest })]
|
|
204
|
+
: info.message.split('\n').map((l) => l.trim()).filter(Boolean).slice(-4);
|
|
205
|
+
for (const line of detail)
|
|
206
|
+
lines.push(box(' ' + palette.muted(truncateWidth(line, iw - 4))));
|
|
207
|
+
lines.push(box(''));
|
|
208
|
+
lines.push(box(' ' + palette.text(truncateWidth(t('update.keepsRunning', { version: info.current }), iw - 2))));
|
|
209
|
+
lines.push(box(''));
|
|
210
|
+
lines.push(box(' ' + palette.faint(truncateWidth(t('update.byHand'), iw - 2))));
|
|
211
|
+
lines.push(box(' ' + palette.bold(palette.text(truncateWidth(manualCommand(info.method), iw - 4)))));
|
|
212
|
+
lines.push(box(''));
|
|
213
|
+
return lines;
|
|
214
|
+
}, '');
|
|
215
|
+
}
|
|
216
|
+
const columnsOf = (out) => out.columns || process.stdout.columns || 80;
|
|
217
|
+
const write = (out, lines) => out.write(lines.join('\n') + '\n');
|
|
218
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
219
|
+
/**
|
|
220
|
+
* Shows the card for a new version, asks what to do and, when told to, installs it on a live
|
|
221
|
+
* screen and restarts devx. `offerSkip` adds "Skip this version" (the start-up check, not /update);
|
|
222
|
+
* `save` is told which version was skipped.
|
|
223
|
+
*/
|
|
224
|
+
export async function presentUpdate(info, opts, deps = {}) {
|
|
225
|
+
const out = deps.output ?? process.stdout;
|
|
226
|
+
const now = deps.now ?? Date.now;
|
|
227
|
+
const install = deps.install ?? (await detectInstallMethod());
|
|
228
|
+
const cols = columnsOf(out);
|
|
229
|
+
write(out, [
|
|
230
|
+
'',
|
|
231
|
+
...renderUpdateCard({ current: info.currentVersion, latest: info.latestVersion, publishedAt: info.publishedAt, highlights: extractHighlights(info.releaseNotes, getLanguage()), method: install.method }, cols),
|
|
232
|
+
'',
|
|
233
|
+
]);
|
|
234
|
+
// Nobody to ask (a pipe): the card is all there is.
|
|
235
|
+
if (!out.isTTY)
|
|
236
|
+
return 'later';
|
|
237
|
+
const picker = deps.picker ?? runPicker;
|
|
238
|
+
const labels = [t('update.now'), t('update.later'), t('update.skip')];
|
|
239
|
+
const choice = await picker({
|
|
240
|
+
title: t('update.title'),
|
|
241
|
+
subtitle: versions(info.currentVersion, info.latestVersion),
|
|
242
|
+
items: [
|
|
243
|
+
{ value: 'now', label: t('update.now'), detail: t('update.nowDetail', { version: info.latestVersion }) },
|
|
244
|
+
{ value: 'later', label: t('update.later'), detail: t('update.laterDetail') },
|
|
245
|
+
...(opts.offerSkip ? [{ value: 'skip', label: t('update.skip'), detail: t('update.skipDetail', { version: info.latestVersion }) }] : []),
|
|
246
|
+
],
|
|
247
|
+
current: 'now',
|
|
248
|
+
labelWidth: Math.max(...labels.slice(0, opts.offerSkip ? 3 : 2).map((l) => displayWidth(l))) + 2,
|
|
249
|
+
});
|
|
250
|
+
if (choice === 'skip') {
|
|
251
|
+
await opts.save?.(info.latestVersion);
|
|
252
|
+
out.write(renderNotice('info', t('update.skipped', { version: info.latestVersion }), cols) + '\n\n');
|
|
253
|
+
return 'skipped';
|
|
254
|
+
}
|
|
255
|
+
if (choice !== 'now')
|
|
256
|
+
return 'later';
|
|
257
|
+
// ── the install, on a screen that is redrawn in place
|
|
258
|
+
const steps = buildUpdateSteps(install.method, install.root, info.latestVersion, deps.run);
|
|
259
|
+
const ids = steps.map((step) => step.id);
|
|
260
|
+
const state = {
|
|
261
|
+
current: info.currentVersion,
|
|
262
|
+
latest: info.latestVersion,
|
|
263
|
+
steps: ids.map((id, i) => ({ label: t(`update.step.${id}`, { version: info.latestVersion }), status: i === 0 ? 'run' : 'wait' })),
|
|
264
|
+
progress: 0,
|
|
265
|
+
tone: 'busy',
|
|
266
|
+
};
|
|
267
|
+
const block = new LiveBlock(out);
|
|
268
|
+
let tick = 0;
|
|
269
|
+
let runningSince = now();
|
|
270
|
+
let restartStart = 0;
|
|
271
|
+
const restartMs = deps.restartMs ?? 1500;
|
|
272
|
+
const draw = () => {
|
|
273
|
+
if (state.restarting)
|
|
274
|
+
state.progress = Math.max(0, 1 - (now() - restartStart) / restartMs);
|
|
275
|
+
else {
|
|
276
|
+
const doneCount = state.steps.filter((s) => s.status === 'ok').length;
|
|
277
|
+
state.progress = state.tone === 'failed' ? state.progress : estimateProgress(ids, doneCount, now() - runningSince);
|
|
278
|
+
}
|
|
279
|
+
block.draw(renderProgressCard(state, columnsOf(out), tick++));
|
|
280
|
+
};
|
|
281
|
+
draw();
|
|
282
|
+
const timer = setInterval(draw, deps.tickMs ?? TICK_MS);
|
|
283
|
+
timer.unref?.();
|
|
284
|
+
const stopWatching = block.watchResize(draw);
|
|
285
|
+
const outcome = await runUpdateSteps(steps, {
|
|
286
|
+
onStart: (i) => {
|
|
287
|
+
state.steps[i].status = 'run';
|
|
288
|
+
runningSince = now();
|
|
289
|
+
},
|
|
290
|
+
onDone: (i, ms) => {
|
|
291
|
+
state.steps[i].status = 'ok';
|
|
292
|
+
state.steps[i].ms = ms;
|
|
293
|
+
if (state.steps[i + 1])
|
|
294
|
+
state.steps[i + 1].status = 'run';
|
|
295
|
+
},
|
|
296
|
+
}, now);
|
|
297
|
+
if (!outcome.ok) {
|
|
298
|
+
clearInterval(timer);
|
|
299
|
+
stopWatching();
|
|
300
|
+
state.steps[outcome.index].status = 'fail';
|
|
301
|
+
state.tone = 'failed';
|
|
302
|
+
block.erase();
|
|
303
|
+
write(out, renderFailureCard({ current: info.currentVersion, latest: info.latestVersion, step: state.steps[outcome.index].label, message: outcome.error.message, notPublished: outcome.error.kind === 'not-published', method: install.method }, columnsOf(out)));
|
|
304
|
+
out.write('\x1b[?25h\n');
|
|
305
|
+
return 'failed';
|
|
306
|
+
}
|
|
307
|
+
// Installed: the bar turns green and drains while devx restarts.
|
|
308
|
+
state.tone = 'done';
|
|
309
|
+
state.restarting = true;
|
|
310
|
+
restartStart = now();
|
|
311
|
+
await sleep(restartMs);
|
|
312
|
+
clearInterval(timer);
|
|
313
|
+
stopWatching();
|
|
314
|
+
state.progress = 0;
|
|
315
|
+
block.draw(renderProgressCard(state, columnsOf(out), tick));
|
|
316
|
+
block.release();
|
|
317
|
+
(deps.restart ?? restartDevx)();
|
|
318
|
+
return 'updated';
|
|
319
|
+
}
|
|
320
|
+
// ───────────────────────── the check ─────────────────────────
|
|
321
|
+
/** Runs `work` while the "checking" line plays, and takes the line away when it is done. */
|
|
322
|
+
async function withCheckingLine(out, current, work, tickMs = TICK_MS) {
|
|
323
|
+
if (!out.isTTY)
|
|
324
|
+
return work();
|
|
325
|
+
const block = new LiveBlock(out);
|
|
326
|
+
let tick = 0;
|
|
327
|
+
const draw = () => block.draw([renderChecking(columnsOf(out), tick++, current)]);
|
|
328
|
+
draw();
|
|
329
|
+
const timer = setInterval(draw, tickMs);
|
|
330
|
+
timer.unref?.();
|
|
331
|
+
try {
|
|
332
|
+
return await work();
|
|
333
|
+
}
|
|
334
|
+
finally {
|
|
335
|
+
clearInterval(timer);
|
|
336
|
+
block.erase();
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
/** At start-up: look for a new version; say so only when there is one (and it was not skipped). */
|
|
340
|
+
export async function runStartupUpdateCheck(config, deps = {}) {
|
|
341
|
+
if (config.checkUpdates === false)
|
|
342
|
+
return;
|
|
343
|
+
const out = deps.output ?? process.stdout;
|
|
344
|
+
if (!out.isTTY)
|
|
345
|
+
return;
|
|
346
|
+
const current = await getCurrentVersion();
|
|
347
|
+
const result = await withCheckingLine(out, current, () => (deps.check ?? checkForUpdates)(6000));
|
|
348
|
+
if (result.updateAvailable) {
|
|
349
|
+
if (config.skippedUpdate === result.latestVersion)
|
|
350
|
+
return;
|
|
351
|
+
await presentUpdate(result, {
|
|
352
|
+
offerSkip: true,
|
|
353
|
+
save: async (version) => {
|
|
354
|
+
config.skippedUpdate = version;
|
|
355
|
+
await deps.save?.(config);
|
|
356
|
+
},
|
|
357
|
+
}, deps);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (result.error)
|
|
361
|
+
return; // offline at start-up is not worth a word
|
|
362
|
+
// Up to date: a short line that goes away by itself.
|
|
363
|
+
out.write(` ${palette.ok('✓')} ${palette.muted(t('update.upToDate', { version: result.currentVersion }))}\n`);
|
|
364
|
+
await sleep(deps.tickMs !== undefined ? 0 : 450);
|
|
365
|
+
out.write('\x1b[1A\x1b[2K');
|
|
366
|
+
}
|
|
367
|
+
/** `/update`: the same, but it always answers (also "up to date" and "could not check"). */
|
|
368
|
+
export async function runManualUpdate(deps = {}) {
|
|
369
|
+
const out = deps.output ?? process.stdout;
|
|
370
|
+
const current = await getCurrentVersion();
|
|
371
|
+
const result = await withCheckingLine(out, current, () => (deps.check ?? checkForUpdates)(10000));
|
|
372
|
+
const cols = columnsOf(out);
|
|
373
|
+
if (result.updateAvailable) {
|
|
374
|
+
await presentUpdate(result, { offerSkip: false }, deps);
|
|
375
|
+
}
|
|
376
|
+
else if (result.error) {
|
|
377
|
+
out.write(renderNotice('warn', t('update.offline', { reason: result.error }), cols) + '\n');
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
out.write(renderNotice('ok', t('update.upToDate', { version: result.currentVersion }), cols) + '\n');
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
export { UpdateError };
|