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
package/dist/cli/logo.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The devx wordmark, drawn from a small pixel font: every letter is 5 pixels wide and 6 tall, and two
|
|
3
|
+
* pixel rows share one terminal row with half blocks, so the pixels are square and the shapes stay clean.
|
|
4
|
+
*/
|
|
5
|
+
const FONT = {
|
|
6
|
+
D: ['████ ', '█ █', '█ █', '█ █', '█ █', '████ '],
|
|
7
|
+
E: ['█████', '█ ', '████ ', '█ ', '█ ', '█████'],
|
|
8
|
+
V: ['█ █', '█ █', '█ █', '█ █', ' █ █ ', ' █ '],
|
|
9
|
+
X: ['█ █', ' █ █ ', ' █ ', ' █ ', ' █ █ ', '█ █'],
|
|
10
|
+
};
|
|
11
|
+
export function wordmark(text = 'DEVX', gap = 1) {
|
|
12
|
+
const letters = Array.from(text).map((c) => FONT[c]);
|
|
13
|
+
const rows = letters[0].length;
|
|
14
|
+
const out = [];
|
|
15
|
+
for (let r = 0; r < rows; r += 2) {
|
|
16
|
+
let line = '';
|
|
17
|
+
letters.forEach((glyph, i) => {
|
|
18
|
+
if (i > 0)
|
|
19
|
+
line += ' '.repeat(gap);
|
|
20
|
+
for (let x = 0; x < glyph[r].length; x++) {
|
|
21
|
+
const top = glyph[r][x] !== ' ';
|
|
22
|
+
const bottom = glyph[r + 1][x] !== ' ';
|
|
23
|
+
line += top && bottom ? '█' : top ? '▀' : bottom ? '▄' : ' ';
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
out.push(line);
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
export const DEVX_LOGO = wordmark();
|
package/dist/cli/markdown.js
CHANGED
|
@@ -8,6 +8,18 @@ marked.use(markedTerminal({
|
|
|
8
8
|
showSectionPrefix: false,
|
|
9
9
|
tab: 2
|
|
10
10
|
}));
|
|
11
|
+
// marked-terminal prints the text of a list item as it was written (`code` and **bold** with their
|
|
12
|
+
// marks); the inline tokens of the item have to be rendered.
|
|
13
|
+
marked.use({
|
|
14
|
+
renderer: {
|
|
15
|
+
text(token) {
|
|
16
|
+
if (token && typeof token === 'object' && Array.isArray(token.tokens) && token.tokens.length > 0) {
|
|
17
|
+
return this.parser.parseInline(token.tokens);
|
|
18
|
+
}
|
|
19
|
+
return false;
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
});
|
|
11
23
|
/**
|
|
12
24
|
* Inline markdown (`code`, **bold**, *italic*) for text that arrives in pieces. The state of an open
|
|
13
25
|
* marker is kept between pieces, so a span that runs over several words (`**seven words in bold**`)
|
|
@@ -104,7 +116,9 @@ export function renderMarkdown(content) {
|
|
|
104
116
|
if (!content)
|
|
105
117
|
return '';
|
|
106
118
|
try {
|
|
107
|
-
|
|
119
|
+
const text = marked.parse(unfenceMarkdown(content)).trim();
|
|
120
|
+
// The list marker of marked-terminal is `*`: draw a bullet instead.
|
|
121
|
+
return text.replace(/^((?:\s|\x1b\[[0-9;]*m)*)\* /gm, '$1• ');
|
|
108
122
|
}
|
|
109
123
|
catch {
|
|
110
124
|
return content;
|
|
@@ -167,7 +181,8 @@ export class MarkdownStreamer {
|
|
|
167
181
|
trimmed === '***');
|
|
168
182
|
if (!isSpecialLine) {
|
|
169
183
|
const lastSpaceIdx = Math.max(this.buffer.lastIndexOf(' '), this.buffer.lastIndexOf('\t'));
|
|
170
|
-
|
|
184
|
+
// At the start of a line, a first word that is not finished yet (` *`, the marker of an indented item) waits.
|
|
185
|
+
if (lastSpaceIdx > 0 && (!this.atLineStart || /\S\s/.test(this.buffer))) {
|
|
171
186
|
const wordsToFlush = this.buffer.slice(0, lastSpaceIdx + 1);
|
|
172
187
|
this.buffer = this.buffer.slice(lastSpaceIdx + 1);
|
|
173
188
|
this.sink(this.formatInline(wordsToFlush));
|
package/dist/cli/picker.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { LiveBlock } from './liveblock.js';
|
|
2
2
|
import { splitKeys } from './keys.js';
|
|
3
3
|
import { palette } from './ui.js';
|
|
4
|
+
import { t } from '../core/i18n.js';
|
|
4
5
|
import { displayWidth, padEndWidth, truncateWidth, wrapWords } from './width.js';
|
|
5
6
|
function hints(width) {
|
|
6
7
|
const variants = [
|
|
7
|
-
[['↑↓', 'move'], ['enter', 'select'], ['esc', 'cancel']],
|
|
8
|
+
[['↑↓', t('picker.hint.move')], ['enter', t('picker.hint.select')], ['esc', t('picker.hint.cancel')]],
|
|
8
9
|
[['↑↓', ''], ['enter', ''], ['esc', '']],
|
|
9
10
|
[['enter', ''], ['esc', '']],
|
|
10
11
|
];
|
|
@@ -16,7 +17,7 @@ function hints(width) {
|
|
|
16
17
|
return '';
|
|
17
18
|
}
|
|
18
19
|
/** Of a long list only the rows around the focus are shown, with "↑ N more" / "↓ N more" at the cut edges. */
|
|
19
|
-
function windowItems(rows, focus, max, width) {
|
|
20
|
+
export function windowItems(rows, focus, max, width) {
|
|
20
21
|
if (rows.length <= max)
|
|
21
22
|
return rows;
|
|
22
23
|
const start = Math.max(0, Math.min(rows.length - max, focus - Math.floor(max / 2)));
|
|
@@ -87,7 +88,14 @@ export function runPicker(options, io = { input: process.stdin, output: process.
|
|
|
87
88
|
const count = options.items.length;
|
|
88
89
|
let focus = Math.max(0, options.items.findIndex((i) => options.current !== undefined && i.value === options.current));
|
|
89
90
|
let done = false;
|
|
90
|
-
|
|
91
|
+
let reported = -1;
|
|
92
|
+
const draw = () => {
|
|
93
|
+
if (options.onFocus && focus !== reported) {
|
|
94
|
+
reported = focus;
|
|
95
|
+
options.onFocus(options.items[focus].value);
|
|
96
|
+
}
|
|
97
|
+
block.draw(renderPicker(options, focus, output.columns || 80, output.rows || 40));
|
|
98
|
+
};
|
|
91
99
|
const wasRaw = !!input.isRaw;
|
|
92
100
|
input.setRawMode?.(true);
|
|
93
101
|
input.resume();
|
package/dist/cli/prompt.js
CHANGED
|
@@ -6,7 +6,7 @@ import { CustomCommandManager } from '../core/commands.js';
|
|
|
6
6
|
import { formatPromptHeader } from './modeborder.js';
|
|
7
7
|
import { filterCommandPalette, estimatePromptTokens, formatPromptTokenBadge } from './prompt_palette.js';
|
|
8
8
|
import { PromptHistoryNavigator } from './history_search.js';
|
|
9
|
-
import { buildPromptFrame, palette, renderUserEcho, resolveMode } from './ui.js';
|
|
9
|
+
import { buildPromptFrame, moveCursorRow, palette, renderUserEcho, resolveMode } from './ui.js';
|
|
10
10
|
import { padEndWidth, truncateWidth } from './width.js';
|
|
11
11
|
export const SLASH_COMMANDS = [
|
|
12
12
|
{ cmd: '/new', desc: 'Start a new clean chat session' },
|
|
@@ -27,6 +27,7 @@ export const SLASH_COMMANDS = [
|
|
|
27
27
|
{ cmd: '/agent', desc: 'Switch to AGENT mode (coder & executor)' },
|
|
28
28
|
{ cmd: '/image', desc: 'Paste the image from the clipboard (same as Ctrl+V or Alt+V)' },
|
|
29
29
|
{ cmd: '/serve', desc: 'Start local web server for web/game preview' },
|
|
30
|
+
{ cmd: '/tasks', desc: 'Background tasks: list, /tasks <id> output, /tasks stop <id|all>' },
|
|
30
31
|
{ cmd: '/copy', desc: 'Copy the last answer of the AI to the clipboard' },
|
|
31
32
|
{ cmd: '/rewind', desc: 'Go back to one of your messages: the chat after it is removed' },
|
|
32
33
|
{ cmd: '/rename', desc: 'Rename the chat (/rename <name>), or let the AI name it again' },
|
|
@@ -141,6 +142,7 @@ export function askPrompt(opts = {}) {
|
|
|
141
142
|
contextPercent: opts.contextPercent ?? 0,
|
|
142
143
|
cost: opts.cost ?? 0,
|
|
143
144
|
costState: opts.costState,
|
|
145
|
+
background: opts.background?.(),
|
|
144
146
|
input,
|
|
145
147
|
cursorPos,
|
|
146
148
|
placeholder,
|
|
@@ -180,6 +182,35 @@ export function askPrompt(opts = {}) {
|
|
|
180
182
|
render();
|
|
181
183
|
};
|
|
182
184
|
process.stdout.on('resize', onResize);
|
|
185
|
+
const unsubscribeOwner = opts.subscribe?.((kind) => {
|
|
186
|
+
if (disposed)
|
|
187
|
+
return;
|
|
188
|
+
if (kind === 'redraw') {
|
|
189
|
+
render();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
// News for the model: close the prompt, the owner starts a turn. What was typed is kept.
|
|
193
|
+
if (cleanLayout)
|
|
194
|
+
eraseFrame();
|
|
195
|
+
else
|
|
196
|
+
process.stdout.write(`\x1b[0m\r\x1b[2K`);
|
|
197
|
+
cleanup();
|
|
198
|
+
resolve(`__WAKE__:${input}`);
|
|
199
|
+
});
|
|
200
|
+
/** Up / down inside a text that wraps onto several rows; false when there is no row to go to (history takes over). */
|
|
201
|
+
function moveInInput(dir) {
|
|
202
|
+
if (!cleanLayout)
|
|
203
|
+
return false;
|
|
204
|
+
let to = moveCursorRow(input, cursorPos, process.stdout.columns || 80, dir);
|
|
205
|
+
if (to === null)
|
|
206
|
+
return false;
|
|
207
|
+
// Never land inside an attachment tag: go to its start.
|
|
208
|
+
const inside = getTagSpans().find((sp) => to > sp.start && to < sp.end);
|
|
209
|
+
if (inside)
|
|
210
|
+
to = inside.start;
|
|
211
|
+
cursorPos = to;
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
183
214
|
function getTagSpans() {
|
|
184
215
|
const spans = [];
|
|
185
216
|
const regex = new RegExp(`\\[Pasted text #\\d+ \\+\\d+ lines\\]|${IMAGE_TAG_SOURCE}`, 'g');
|
|
@@ -505,6 +536,7 @@ export function askPrompt(opts = {}) {
|
|
|
505
536
|
render();
|
|
506
537
|
function cleanup() {
|
|
507
538
|
disposed = true;
|
|
539
|
+
unsubscribeOwner?.();
|
|
508
540
|
if (process.stdout.isTTY) {
|
|
509
541
|
process.stdout.write('\x1b[?2004l');
|
|
510
542
|
}
|
|
@@ -696,6 +728,9 @@ export function askPrompt(opts = {}) {
|
|
|
696
728
|
selectedIndex = Math.max(0, selectedIndex - 1); // stops at the first item
|
|
697
729
|
render();
|
|
698
730
|
}
|
|
731
|
+
else if (moveInInput(-1)) {
|
|
732
|
+
render();
|
|
733
|
+
}
|
|
699
734
|
else {
|
|
700
735
|
const prev = historyNav.navigateUp(input);
|
|
701
736
|
if (prev !== null) {
|
|
@@ -712,6 +747,9 @@ export function askPrompt(opts = {}) {
|
|
|
712
747
|
selectedIndex = Math.min(items.length - 1, selectedIndex + 1); // stops at the last item
|
|
713
748
|
render();
|
|
714
749
|
}
|
|
750
|
+
else if (moveInInput(1)) {
|
|
751
|
+
render();
|
|
752
|
+
}
|
|
715
753
|
else {
|
|
716
754
|
const next = historyNav.navigateDown();
|
|
717
755
|
if (next !== null) {
|
package/dist/cli/transcript.js
CHANGED
|
@@ -5,6 +5,8 @@ import { palette, renderPlanCard, renderResultLine, renderTaskCard, renderToolLi
|
|
|
5
5
|
import { displayWidth, truncateWidth, wrapText } from './width.js';
|
|
6
6
|
import { t } from '../core/i18n.js';
|
|
7
7
|
const MAX_ECHO_LINES = 300;
|
|
8
|
+
/** What /compact puts in front of the summary it keeps as the first message. */
|
|
9
|
+
export const COMPACT_MARK = '[CONVERSATION COMPACTED]';
|
|
8
10
|
/** The rows under a tool line: a plan card, a diff, or the output (one row folded, all of it unfolded). */
|
|
9
11
|
export function toolResultRows(name, result, opts) {
|
|
10
12
|
const { cols } = opts;
|
|
@@ -49,6 +51,20 @@ function thoughtRows(item, opts) {
|
|
|
49
51
|
const body = item.text.trim().split('\n').flatMap((line) => (line.trim() === '' ? [''] : wrapText(line, Math.max(20, opts.cols - 8))));
|
|
50
52
|
return [palette.muted(` ∴ ${t('status.thinkingLine')}`), ...body.map((l) => bar + palette.muted(l)), palette.faint(` ╰ ${summary}`)];
|
|
51
53
|
}
|
|
54
|
+
/** The line that follows /compact: folded it says the summary can be opened, unfolded it shows what the model wrote. */
|
|
55
|
+
export function compactRows(summary, opts) {
|
|
56
|
+
const done = t('compact.done');
|
|
57
|
+
if (!opts.outputExpanded) {
|
|
58
|
+
const room = Math.max(10, opts.cols - 1 - 4);
|
|
59
|
+
const hint = ` (${t('view.expandOutput')})`;
|
|
60
|
+
const text = displayWidth(done + hint) <= room ? palette.text(done) + palette.faint(hint) : palette.text(truncateWidth(done, room));
|
|
61
|
+
return [' ' + palette.ok('✓') + ' ' + text];
|
|
62
|
+
}
|
|
63
|
+
const bar = palette.faint(' │ ');
|
|
64
|
+
const body = summary.trim().split('\n').flatMap((line) => (line.trim() === '' ? [''] : wrapText(line, Math.max(20, opts.cols - 8))));
|
|
65
|
+
const head = ' ' + palette.ok('✓') + ' ' + palette.text(truncateWidth(done, Math.max(10, opts.cols - 5)));
|
|
66
|
+
return [head, palette.muted(` ∴ ${truncateWidth(t('compact.summary'), Math.max(10, opts.cols - 6))}`), ...body.map((l) => bar + palette.muted(l)), palette.faint(' ╰')];
|
|
67
|
+
}
|
|
52
68
|
/** One item as terminal rows (without the blank row that separates it from its neighbour). */
|
|
53
69
|
export function renderItem(item, opts) {
|
|
54
70
|
switch (item.kind) {
|
|
@@ -67,6 +83,8 @@ export function renderItem(item, opts) {
|
|
|
67
83
|
rows.push(...toolResultRows(item.name, item.result, { cols: opts.cols, expanded: opts.outputExpanded, diffColors: opts.diffColors }));
|
|
68
84
|
return rows;
|
|
69
85
|
}
|
|
86
|
+
case 'compact':
|
|
87
|
+
return compactRows(item.summary, opts);
|
|
70
88
|
default:
|
|
71
89
|
return item.rows;
|
|
72
90
|
}
|
|
@@ -98,7 +116,9 @@ export function messagesToItems(messages) {
|
|
|
98
116
|
results.set(m.tool_call_id, m);
|
|
99
117
|
const items = [];
|
|
100
118
|
for (const m of messages) {
|
|
101
|
-
if (m.role === 'user')
|
|
119
|
+
if (m.role === 'user' && m.content.startsWith(COMPACT_MARK))
|
|
120
|
+
items.push({ kind: 'compact', summary: m.content.slice(m.content.indexOf('\n') + 1) });
|
|
121
|
+
else if (m.role === 'user')
|
|
102
122
|
items.push({ kind: 'user', text: m.content.split('\n\n--- Attached Files ---')[0] });
|
|
103
123
|
else if (m.role === 'assistant') {
|
|
104
124
|
if (m.reasoning && m.reasoning.text.trim())
|
package/dist/cli/ui.js
CHANGED
|
@@ -74,12 +74,13 @@ export function formatDuration(ms) {
|
|
|
74
74
|
}
|
|
75
75
|
/** `/home/me/projects/app` → `~/projects/app`, shortened from the left to fit `max` cells. */
|
|
76
76
|
export function shortenPath(p, max, home = os.homedir()) {
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
const slashed = (v) => v.split(path.sep).join('/').replace(/\\/g, '/');
|
|
78
|
+
let s = slashed(p);
|
|
79
|
+
const h = slashed(home).replace(/\/+$/, '');
|
|
80
|
+
if (s === h)
|
|
79
81
|
s = '~';
|
|
80
|
-
else if (s.startsWith(
|
|
81
|
-
s = '~' + s.slice(
|
|
82
|
-
s = s.split(path.sep).join('/');
|
|
82
|
+
else if (h && s.startsWith(h + '/'))
|
|
83
|
+
s = '~' + s.slice(h.length);
|
|
83
84
|
if (displayWidth(s) <= max)
|
|
84
85
|
return s;
|
|
85
86
|
const parts = s.split('/');
|
|
@@ -180,6 +181,17 @@ export function renderHeader(info, cols) {
|
|
|
180
181
|
}
|
|
181
182
|
return lines;
|
|
182
183
|
}
|
|
184
|
+
// ───────────────────────── prompt frame ─────────────────────────
|
|
185
|
+
/** `◈ 2 bg` (running tasks); a failed one turns it red: `◈ 2 bg · 1 failed`. Empty when there is nothing to say. */
|
|
186
|
+
export function backgroundBadge(bg, short = false) {
|
|
187
|
+
if (!bg || (bg.running <= 0 && !(bg.failed && bg.failed > 0)))
|
|
188
|
+
return '';
|
|
189
|
+
const failed = bg.failed && bg.failed > 0 ? bg.failed : 0;
|
|
190
|
+
const text = short
|
|
191
|
+
? `◈${bg.running}${failed ? `!${failed}` : ''}`
|
|
192
|
+
: `◈ ${bg.running} bg${failed ? ` · ${failed} failed` : ''}`;
|
|
193
|
+
return failed ? palette.err(text) : palette.accent(text);
|
|
194
|
+
}
|
|
183
195
|
/** The part of the input that fits into `cells`, scrolled so that the cursor stays visible. */
|
|
184
196
|
export function visibleWindow(input, cursorPos, cells) {
|
|
185
197
|
const cps = Array.from(input);
|
|
@@ -241,6 +253,114 @@ export function visibleWindow(input, cursorPos, cells) {
|
|
|
241
253
|
cursorCells: cellsBefore(Math.min(cursorCp, win.end), win.start),
|
|
242
254
|
};
|
|
243
255
|
}
|
|
256
|
+
/** Cells of text per row inside the frame (the compact phone row has no side borders). */
|
|
257
|
+
export function inputCells(cols) {
|
|
258
|
+
const W = frameWidth(cols);
|
|
259
|
+
return Math.max(4, isCompact(cols) ? W - 4 : W - 6);
|
|
260
|
+
}
|
|
261
|
+
/** Rows the input may take before it scrolls: a quarter of the terminal, at most 8. */
|
|
262
|
+
export function maxInputRows(termRows = process.stdout.rows || 24) {
|
|
263
|
+
return Math.max(1, Math.min(8, Math.floor(termRows / 4)));
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Cuts the input into rows of `cells` cells. A line breaks after a space when it can; an
|
|
267
|
+
* attachment tag such as `[Pasted text #1 +22 lines]` is never split unless it is wider than a row.
|
|
268
|
+
*/
|
|
269
|
+
export function wrapInput(input, cells) {
|
|
270
|
+
const cps = Array.from(input);
|
|
271
|
+
if (cps.length === 0)
|
|
272
|
+
return [{ start: 0, end: 0 }];
|
|
273
|
+
const widths = cps.map((c) => codePointWidth(c.codePointAt(0)));
|
|
274
|
+
// Where a tag starts (in code points) -> where it ends.
|
|
275
|
+
const offsets = [];
|
|
276
|
+
let unit = 0;
|
|
277
|
+
for (const c of cps) {
|
|
278
|
+
offsets.push(unit);
|
|
279
|
+
unit += c.length;
|
|
280
|
+
}
|
|
281
|
+
const atoms = new Map();
|
|
282
|
+
for (const m of input.matchAll(BADGE)) {
|
|
283
|
+
const from = offsets.indexOf(m.index);
|
|
284
|
+
const to = offsets.indexOf(m.index + m[0].length);
|
|
285
|
+
if (from >= 0)
|
|
286
|
+
atoms.set(from, to >= 0 ? to : cps.length);
|
|
287
|
+
}
|
|
288
|
+
const rows = [];
|
|
289
|
+
let i = 0;
|
|
290
|
+
while (i < cps.length) {
|
|
291
|
+
const start = i;
|
|
292
|
+
let used = 0;
|
|
293
|
+
let afterSpace = -1;
|
|
294
|
+
while (i < cps.length) {
|
|
295
|
+
let next = atoms.get(i) ?? i + 1;
|
|
296
|
+
let w = widths.slice(i, next).reduce((a, b) => a + b, 0);
|
|
297
|
+
if (next > i + 1 && w > cells) {
|
|
298
|
+
next = i + 1;
|
|
299
|
+
w = widths[i];
|
|
300
|
+
} // a tag wider than a row is split
|
|
301
|
+
if (used + w > cells)
|
|
302
|
+
break;
|
|
303
|
+
used += w;
|
|
304
|
+
i = next;
|
|
305
|
+
if (cps[i - 1] === ' ')
|
|
306
|
+
afterSpace = i;
|
|
307
|
+
}
|
|
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
|
|
312
|
+
rows.push({ start, end: i });
|
|
313
|
+
}
|
|
314
|
+
return rows;
|
|
315
|
+
}
|
|
316
|
+
function layoutInput(input, cursorPos, cells) {
|
|
317
|
+
const cps = Array.from(input);
|
|
318
|
+
const widths = cps.map((c) => codePointWidth(c.codePointAt(0)));
|
|
319
|
+
let cursorCp = 0;
|
|
320
|
+
let units = 0;
|
|
321
|
+
while (cursorCp < cps.length && units < cursorPos) {
|
|
322
|
+
units += cps[cursorCp].length;
|
|
323
|
+
cursorCp++;
|
|
324
|
+
}
|
|
325
|
+
const rows = wrapInput(input, cells);
|
|
326
|
+
let cursorRow = rows.findIndex((r) => cursorCp < r.end);
|
|
327
|
+
if (cursorRow < 0)
|
|
328
|
+
cursorRow = rows.length - 1;
|
|
329
|
+
const row = rows[cursorRow];
|
|
330
|
+
const cursorCell = widths.slice(row.start, Math.min(cursorCp, row.end)).reduce((a, b) => a + b, 0);
|
|
331
|
+
return { cps, rows, widths, cursorRow, cursorCell };
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* The cursor one row up or down inside a wrapped input, or null when there is no such row
|
|
335
|
+
* (the arrow keys then walk the prompt history as before). Returns a UTF-16 index.
|
|
336
|
+
*/
|
|
337
|
+
export function moveCursorRow(input, cursorPos, cols, dir) {
|
|
338
|
+
const clean = input.replace(/[\x00-\x1f\x7f]/g, ' ');
|
|
339
|
+
const { cps, rows, widths, cursorRow, cursorCell } = layoutInput(clean, cursorPos, inputCells(cols));
|
|
340
|
+
const target = rows[cursorRow + dir];
|
|
341
|
+
if (!target)
|
|
342
|
+
return null;
|
|
343
|
+
// The last cell of a row that is not the last one belongs to the next row, so stop before it.
|
|
344
|
+
const limit = cursorRow + dir === rows.length - 1 ? target.end : Math.max(target.start, target.end - 1);
|
|
345
|
+
let at = target.start;
|
|
346
|
+
let used = 0;
|
|
347
|
+
while (at < limit && used + widths[at] <= cursorCell) {
|
|
348
|
+
used += widths[at];
|
|
349
|
+
at++;
|
|
350
|
+
}
|
|
351
|
+
return cps.slice(0, at).join('').length;
|
|
352
|
+
}
|
|
353
|
+
/** The rows of the input as drawn, with the cursor; scrolled so that the cursor row is visible. */
|
|
354
|
+
function inputRows(f, cells) {
|
|
355
|
+
if (!f.input) {
|
|
356
|
+
return { rows: [palette.muted(truncateWidth(f.placeholder, cells))], firstRow: 0, cursorRow: 0, cursorCol: 0 };
|
|
357
|
+
}
|
|
358
|
+
const { cps, rows, cursorRow, cursorCell } = layoutInput(f.input, f.cursorPos, cells);
|
|
359
|
+
const limit = f.maxInputRows ?? maxInputRows();
|
|
360
|
+
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('')));
|
|
362
|
+
return { rows: shown, firstRow, cursorRow: cursorRow - firstRow, cursorCol: cursorCell };
|
|
363
|
+
}
|
|
244
364
|
function keyHints(cols, mode) {
|
|
245
365
|
const next = mode === 'plan' ? 'agent' : 'plan';
|
|
246
366
|
const all = [
|
|
@@ -294,16 +414,14 @@ export function buildPromptFrame(input) {
|
|
|
294
414
|
topRight = ' ' + palette.muted(fitModelLabel(f.model, Math.min(48, topFree - 4))) + ' ';
|
|
295
415
|
}
|
|
296
416
|
const top = rule('╭─') + pill + rule('─'.repeat(Math.max(0, topFree - displayWidth(topRight)))) + topRight + rule('─╮');
|
|
297
|
-
// ── input
|
|
417
|
+
// ── input rows: │ ❯ text │, the text wraps onto further rows instead of scrolling sideways
|
|
298
418
|
const contentCells = W - 6;
|
|
299
|
-
const
|
|
300
|
-
const
|
|
301
|
-
? (
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
const inputRow = rule('│') + ' ' + palette.accent('❯') + ' ' + padEndWidth(content, contentCells) + ' ' + rule('│');
|
|
306
|
-
const cursorCol = 4 + (f.input ? (win.leftEllipsis ? 1 : 0) + win.cursorCells : 0);
|
|
419
|
+
const body = inputRows(f, contentCells);
|
|
420
|
+
const inputLines = body.rows.map((content, i) => {
|
|
421
|
+
const glyph = i === 0 ? (body.firstRow > 0 ? palette.faint('…') : palette.accent('❯')) : ' ';
|
|
422
|
+
return rule('│') + ' ' + glyph + ' ' + padEndWidth(content, contentCells) + ' ' + rule('│');
|
|
423
|
+
});
|
|
424
|
+
const cursorCol = 4 + body.cursorCol;
|
|
307
425
|
// ── bottom border: ╰─ ~/project git:main* ──── ━━━━──── 12% · $0.0123 ─╯
|
|
308
426
|
const bottomFree = W - 4;
|
|
309
427
|
const pct = Math.max(0, Math.min(100, Math.round(f.contextPercent)));
|
|
@@ -315,7 +433,10 @@ export function buildPromptFrame(input) {
|
|
|
315
433
|
: bottomFree >= displayWidth(stat) + 4
|
|
316
434
|
? ' ' + palette.muted(stat) + ' '
|
|
317
435
|
: '';
|
|
318
|
-
|
|
436
|
+
// ◈ 2 bg: tasks that keep running while the conversation goes on (/tasks lists them)
|
|
437
|
+
const bgBadge = backgroundBadge(f.background);
|
|
438
|
+
const bgWidth = bgBadge ? displayWidth(stripAnsi(bgBadge)) + 2 : 0;
|
|
439
|
+
const leftRoom = bottomFree - displayWidth(bottomRight) - 2 - bgWidth;
|
|
319
440
|
let bottomLeft = '';
|
|
320
441
|
if (f.cwd && leftRoom >= 10) {
|
|
321
442
|
const branch = f.branch ? `${palette.faint('git:')}${palette.muted(f.branch)}${f.dirty ? palette.warn('*') : ''}` : '';
|
|
@@ -324,6 +445,9 @@ export function buildPromptFrame(input) {
|
|
|
324
445
|
const pathRoom = leftRoom - (showBranch ? branchW : 0);
|
|
325
446
|
bottomLeft = ' ' + palette.muted(shortenPath(f.cwd, pathRoom)) + (showBranch ? ' ' + branch : '') + ' ';
|
|
326
447
|
}
|
|
448
|
+
if (bgBadge && bottomFree - displayWidth(bottomLeft) - displayWidth(bottomRight) - 2 >= bgWidth) {
|
|
449
|
+
bottomLeft += (bottomLeft ? '' : ' ') + bgBadge + ' ';
|
|
450
|
+
}
|
|
327
451
|
const bottom = rule('╰─') +
|
|
328
452
|
bottomLeft +
|
|
329
453
|
rule('─'.repeat(Math.max(0, bottomFree - displayWidth(bottomLeft) - displayWidth(bottomRight)))) +
|
|
@@ -331,7 +455,7 @@ export function buildPromptFrame(input) {
|
|
|
331
455
|
rule('─╯');
|
|
332
456
|
// ── footer: key hints, or the suggestion list while one is open
|
|
333
457
|
const footer = f.below && f.below.length > 0 ? f.below.map((l) => ' ' + l) : [hintsRow(f.cols, f.mode)];
|
|
334
|
-
return { lines: [top,
|
|
458
|
+
return { lines: [top, ...inputLines, bottom, ...footer], cursorRow: 1 + body.cursorRow, cursorCol };
|
|
335
459
|
}
|
|
336
460
|
/**
|
|
337
461
|
* Phone layout: one row, no borders, no hints.
|
|
@@ -344,23 +468,26 @@ function buildCompactFrame(f) {
|
|
|
344
468
|
const style = modeStyle(f.mode);
|
|
345
469
|
const prefix = style.color(style.glyph) + ' ' + palette.accent('❯') + ' ';
|
|
346
470
|
const pct = Math.max(0, Math.min(100, Math.round(f.contextPercent)));
|
|
347
|
-
const
|
|
471
|
+
const bgShort = backgroundBadge(f.background, true);
|
|
472
|
+
const statText = `${bgShort ? stripAnsi(bgShort) + ' ' : ''}${pct}%`;
|
|
348
473
|
const statColor = pct >= 90 ? palette.err : pct >= 75 ? palette.warn : palette.faint;
|
|
349
474
|
const statWidth = displayWidth(statText) + 1;
|
|
350
|
-
const
|
|
351
|
-
|
|
352
|
-
const
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
475
|
+
const cells = W - 4;
|
|
476
|
+
// The count sits at the end of the last row, and only when the text there leaves it room.
|
|
477
|
+
const withStat = cells - statWidth >= 14;
|
|
478
|
+
const body = inputRows(f, cells);
|
|
479
|
+
const statShown = bgShort && withStat ? bgShort + ' ' + statColor(`${pct}%`) : statColor(statText);
|
|
480
|
+
const rows = body.rows.map((content, i) => {
|
|
481
|
+
const lead = i === 0 ? (body.firstRow > 0 ? style.color(style.glyph) + ' ' + palette.faint('…') + ' ' : prefix) : ' ';
|
|
482
|
+
const room = cells - displayWidth(stripAnsi(content));
|
|
483
|
+
const stat = i === body.rows.length - 1 && withStat && room >= statWidth ? ' '.repeat(room - statWidth + 1) + statShown : '';
|
|
484
|
+
return lead + (stat ? content + stat : padEndWidth(content, cells));
|
|
485
|
+
});
|
|
359
486
|
const below = (f.below ?? []).map((l) => ' ' + l);
|
|
360
487
|
return {
|
|
361
|
-
lines: [
|
|
362
|
-
cursorRow:
|
|
363
|
-
cursorCol: 4 +
|
|
488
|
+
lines: [...rows, ...below],
|
|
489
|
+
cursorRow: body.cursorRow,
|
|
490
|
+
cursorCol: 4 + body.cursorCol,
|
|
364
491
|
};
|
|
365
492
|
}
|
|
366
493
|
function hintsRow(cols, mode) {
|
|
@@ -613,7 +740,7 @@ export function summarizeToolResult(name, result, cols = 80, expanded = false) {
|
|
|
613
740
|
}
|
|
614
741
|
if (expanded && t !== 'bash' && !(t === 'edit_file' || t === 'write_file')) {
|
|
615
742
|
// For these the first line is part of the output, not a summary of it.
|
|
616
|
-
const own = t.startsWith('mcp__') || t === 'web_search' || t === 'fetch_url';
|
|
743
|
+
const own = t.startsWith('mcp__') || t === 'web_search' || t === 'fetch_url' || t === 'background';
|
|
617
744
|
const first = own ? [] : summarizeToolResult(name, result, cols, false);
|
|
618
745
|
return [...first, ...fullRows(text, cols)];
|
|
619
746
|
}
|
|
@@ -647,6 +774,17 @@ export function summarizeToolResult(name, result, cols = 80, expanded = false) {
|
|
|
647
774
|
}
|
|
648
775
|
catch { }
|
|
649
776
|
}
|
|
777
|
+
// Background tasks: what happened on the first line (started / failed / matched), the rest folded.
|
|
778
|
+
if (t === 'background') {
|
|
779
|
+
const nonEmpty = outputRows(text).filter((l) => l.trim());
|
|
780
|
+
if (nonEmpty.length === 0)
|
|
781
|
+
return [renderResultLine('(no output)', 'muted', cols)];
|
|
782
|
+
const bad = /^(Error:|No background task|.* failed \(exit|.*ended right after)/.test(nonEmpty[0]);
|
|
783
|
+
const good = /^(Started bg|Stopped\.)/.test(nonEmpty[0]);
|
|
784
|
+
const rest = nonEmpty.length - 1;
|
|
785
|
+
const row = renderResultLine(nonEmpty[0].trim(), bad ? 'error' : good ? 'ok' : 'muted', cols);
|
|
786
|
+
return rest > 0 ? [row, moreHint(rest)] : [row];
|
|
787
|
+
}
|
|
650
788
|
// Output of an MCP tool or a web fetch: the first line and how many more there are.
|
|
651
789
|
if (t.startsWith('mcp__') || t === 'web_search' || t === 'fetch_url') {
|
|
652
790
|
const nonEmpty = outputRows(text).filter((l) => l.trim());
|
package/dist/cli/updater.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from 'path';
|
|
|
3
3
|
import fs from 'fs/promises';
|
|
4
4
|
import pc from 'picocolors';
|
|
5
5
|
import { select } from '@inquirer/prompts';
|
|
6
|
+
import { palette } from './ui.js';
|
|
6
7
|
import { fileURLToPath } from 'url';
|
|
7
8
|
const GITHUB_REPO_URL = 'https://github.com/apvcode/Termux-Dev';
|
|
8
9
|
const REMOTE_PKG_URL = 'https://raw.githubusercontent.com/apvcode/Termux-Dev/main/package.json';
|
|
@@ -208,8 +209,9 @@ export async function runStartupUpdateCheck(config) {
|
|
|
208
209
|
const spinner = setInterval(() => {
|
|
209
210
|
if (isDone)
|
|
210
211
|
return;
|
|
211
|
-
|
|
212
|
-
|
|
212
|
+
// The colours of the theme, like the rest of the interface.
|
|
213
|
+
const frame = palette.accent(frames[fIdx++ % frames.length]);
|
|
214
|
+
process.stdout.write(`\r ${frame} ${palette.muted('Checking for updates from')} ${palette.accent('apvcode/Termux-Dev')} ${palette.faint(`(v${currentVersion})...`)} `);
|
|
213
215
|
}, 75);
|
|
214
216
|
const result = await checkForUpdates(10000);
|
|
215
217
|
isDone = true;
|
|
@@ -218,14 +220,14 @@ export async function runStartupUpdateCheck(config) {
|
|
|
218
220
|
process.stdout.write('\r\x1b[K');
|
|
219
221
|
if (result.updateAvailable) {
|
|
220
222
|
console.log();
|
|
221
|
-
console.log(
|
|
222
|
-
console.log(
|
|
223
|
-
console.log(
|
|
224
|
-
console.log(
|
|
223
|
+
console.log(palette.rule(' ┌───────────────────────────────────────────────────────────┐'));
|
|
224
|
+
console.log(palette.rule(' │') + palette.bold(palette.accent(` A new update is available: `)) + palette.muted(`v${result.currentVersion}`) + palette.bold(palette.ok(` → v${result.latestVersion}`)) + ' '.repeat(Math.max(1, 23 - result.currentVersion.length - result.latestVersion.length)) + palette.rule('│'));
|
|
225
|
+
console.log(palette.rule(' │') + palette.faint(` Repository: https://github.com/apvcode/Termux-Dev `) + palette.rule('│'));
|
|
226
|
+
console.log(palette.rule(' └───────────────────────────────────────────────────────────┘'));
|
|
225
227
|
console.log();
|
|
226
228
|
try {
|
|
227
229
|
const choice = await select({
|
|
228
|
-
message:
|
|
230
|
+
message: palette.bold('Do you want to update devx now?'),
|
|
229
231
|
loop: false,
|
|
230
232
|
choices: [
|
|
231
233
|
{
|
|
@@ -250,7 +252,7 @@ export async function runStartupUpdateCheck(config) {
|
|
|
250
252
|
}
|
|
251
253
|
else {
|
|
252
254
|
// Briefly show up to date message
|
|
253
|
-
process.stdout.write(` ${
|
|
255
|
+
process.stdout.write(` ${palette.ok('✓')} ${palette.muted(`devx is up to date (v${result.currentVersion})`)}\n`);
|
|
254
256
|
await new Promise(r => setTimeout(r, 450));
|
|
255
257
|
// Clean up line
|
|
256
258
|
process.stdout.write('\x1b[1A\x1b[2K');
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { DEVX_LOGO } from './logo.js';
|
|
2
|
+
import { palette } from './ui.js';
|
|
3
|
+
import { displayWidth, padEndWidth, truncateWidth, wrapWords } from './width.js';
|
|
4
|
+
import { t } from '../core/i18n.js';
|
|
5
|
+
const LOGO = DEVX_LOGO;
|
|
6
|
+
/** The steps of the first run, in the order they are asked. */
|
|
7
|
+
export const SETUP_STEPS = ['welcome.step.language', 'welcome.step.theme', 'welcome.step.provider', 'welcome.step.key', 'welcome.step.model'];
|
|
8
|
+
/** The card shown when devx is opened for the first time. `done` steps are ticked, the next one is highlighted. */
|
|
9
|
+
export function renderWelcome(version, cols, done = 0) {
|
|
10
|
+
const steps = SETUP_STEPS.map((key, i) => {
|
|
11
|
+
const label = t(key);
|
|
12
|
+
if (i < done)
|
|
13
|
+
return palette.ok('✓') + ' ' + palette.muted(label);
|
|
14
|
+
if (i === done)
|
|
15
|
+
return palette.accent(String(i + 1)) + ' ' + palette.bold(palette.text(label));
|
|
16
|
+
return palette.faint(String(i + 1)) + ' ' + palette.faint(label);
|
|
17
|
+
});
|
|
18
|
+
if (cols < 60) {
|
|
19
|
+
const width = Math.max(20, cols - 2);
|
|
20
|
+
const lines = ['', ...LOGO.map((l) => ' ' + palette.bold(palette.accent(l))), ' ' + palette.faint(`v${version}`), ''];
|
|
21
|
+
for (const line of wrapWords(t('welcome.tagline'), width))
|
|
22
|
+
lines.push(' ' + palette.text(line));
|
|
23
|
+
lines.push('');
|
|
24
|
+
for (const s of steps)
|
|
25
|
+
lines.push(' ' + s);
|
|
26
|
+
lines.push('');
|
|
27
|
+
return lines;
|
|
28
|
+
}
|
|
29
|
+
const W = Math.min(cols - 1, 76);
|
|
30
|
+
const iw = W - 4;
|
|
31
|
+
const rule = palette.rule;
|
|
32
|
+
const box = (content) => rule('│') + ' ' + padEndWidth(content, iw) + ' ' + rule('│');
|
|
33
|
+
const head = ' ' + palette.bold(palette.accent('devx')) + ' ';
|
|
34
|
+
const ver = ' ' + palette.faint(`v${version}`) + ' ';
|
|
35
|
+
const lines = [rule('╭─') + head + rule('─'.repeat(Math.max(0, W - 4 - displayWidth(head) - displayWidth(ver)))) + ver + rule('─╮'), box('')];
|
|
36
|
+
for (const l of LOGO)
|
|
37
|
+
lines.push(box(' ' + palette.bold(palette.accent(l))));
|
|
38
|
+
lines.push(box(''));
|
|
39
|
+
for (const line of wrapWords(t('welcome.tagline'), iw - 4))
|
|
40
|
+
lines.push(box(' ' + palette.bold(palette.text(line))));
|
|
41
|
+
for (const line of wrapWords(t('welcome.intro'), iw - 4))
|
|
42
|
+
lines.push(box(' ' + palette.muted(line)));
|
|
43
|
+
lines.push(box(''));
|
|
44
|
+
const row = (sep) => ' ' + steps.join(palette.faint(sep));
|
|
45
|
+
const sep = [row(' · '), row(' · ')].find((r) => displayWidth(r) <= iw);
|
|
46
|
+
if (sep)
|
|
47
|
+
lines.push(box(sep));
|
|
48
|
+
else
|
|
49
|
+
for (const step of steps)
|
|
50
|
+
lines.push(box(' ' + step));
|
|
51
|
+
lines.push(box(''));
|
|
52
|
+
for (const line of wrapWords(t('welcome.keyNote'), iw - 4))
|
|
53
|
+
lines.push(box(' ' + palette.faint(truncateWidth(line, iw - 4))));
|
|
54
|
+
lines.push(rule('╰' + '─'.repeat(W - 2) + '╯'));
|
|
55
|
+
return lines;
|
|
56
|
+
}
|