termux-dev 1.8.1 → 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 +1 -1
- package/dist/cli/background-ui.js +116 -0
- package/dist/cli/headless.js +3 -1
- package/dist/cli/index.js +135 -19
- package/dist/cli/prompt.js +39 -1
- package/dist/cli/ui.js +162 -25
- package/dist/cli/updater.js +10 -8
- package/dist/core/background.js +376 -0
- package/dist/core/config-merge.js +67 -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/ui.js
CHANGED
|
@@ -181,6 +181,17 @@ export function renderHeader(info, cols) {
|
|
|
181
181
|
}
|
|
182
182
|
return lines;
|
|
183
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
|
+
}
|
|
184
195
|
/** The part of the input that fits into `cells`, scrolled so that the cursor stays visible. */
|
|
185
196
|
export function visibleWindow(input, cursorPos, cells) {
|
|
186
197
|
const cps = Array.from(input);
|
|
@@ -242,6 +253,114 @@ export function visibleWindow(input, cursorPos, cells) {
|
|
|
242
253
|
cursorCells: cellsBefore(Math.min(cursorCp, win.end), win.start),
|
|
243
254
|
};
|
|
244
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
|
+
}
|
|
245
364
|
function keyHints(cols, mode) {
|
|
246
365
|
const next = mode === 'plan' ? 'agent' : 'plan';
|
|
247
366
|
const all = [
|
|
@@ -295,16 +414,14 @@ export function buildPromptFrame(input) {
|
|
|
295
414
|
topRight = ' ' + palette.muted(fitModelLabel(f.model, Math.min(48, topFree - 4))) + ' ';
|
|
296
415
|
}
|
|
297
416
|
const top = rule('╭─') + pill + rule('─'.repeat(Math.max(0, topFree - displayWidth(topRight)))) + topRight + rule('─╮');
|
|
298
|
-
// ── input
|
|
417
|
+
// ── input rows: │ ❯ text │, the text wraps onto further rows instead of scrolling sideways
|
|
299
418
|
const contentCells = W - 6;
|
|
300
|
-
const
|
|
301
|
-
const
|
|
302
|
-
? (
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
const inputRow = rule('│') + ' ' + palette.accent('❯') + ' ' + padEndWidth(content, contentCells) + ' ' + rule('│');
|
|
307
|
-
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;
|
|
308
425
|
// ── bottom border: ╰─ ~/project git:main* ──── ━━━━──── 12% · $0.0123 ─╯
|
|
309
426
|
const bottomFree = W - 4;
|
|
310
427
|
const pct = Math.max(0, Math.min(100, Math.round(f.contextPercent)));
|
|
@@ -316,7 +433,10 @@ export function buildPromptFrame(input) {
|
|
|
316
433
|
: bottomFree >= displayWidth(stat) + 4
|
|
317
434
|
? ' ' + palette.muted(stat) + ' '
|
|
318
435
|
: '';
|
|
319
|
-
|
|
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;
|
|
320
440
|
let bottomLeft = '';
|
|
321
441
|
if (f.cwd && leftRoom >= 10) {
|
|
322
442
|
const branch = f.branch ? `${palette.faint('git:')}${palette.muted(f.branch)}${f.dirty ? palette.warn('*') : ''}` : '';
|
|
@@ -325,6 +445,9 @@ export function buildPromptFrame(input) {
|
|
|
325
445
|
const pathRoom = leftRoom - (showBranch ? branchW : 0);
|
|
326
446
|
bottomLeft = ' ' + palette.muted(shortenPath(f.cwd, pathRoom)) + (showBranch ? ' ' + branch : '') + ' ';
|
|
327
447
|
}
|
|
448
|
+
if (bgBadge && bottomFree - displayWidth(bottomLeft) - displayWidth(bottomRight) - 2 >= bgWidth) {
|
|
449
|
+
bottomLeft += (bottomLeft ? '' : ' ') + bgBadge + ' ';
|
|
450
|
+
}
|
|
328
451
|
const bottom = rule('╰─') +
|
|
329
452
|
bottomLeft +
|
|
330
453
|
rule('─'.repeat(Math.max(0, bottomFree - displayWidth(bottomLeft) - displayWidth(bottomRight)))) +
|
|
@@ -332,7 +455,7 @@ export function buildPromptFrame(input) {
|
|
|
332
455
|
rule('─╯');
|
|
333
456
|
// ── footer: key hints, or the suggestion list while one is open
|
|
334
457
|
const footer = f.below && f.below.length > 0 ? f.below.map((l) => ' ' + l) : [hintsRow(f.cols, f.mode)];
|
|
335
|
-
return { lines: [top,
|
|
458
|
+
return { lines: [top, ...inputLines, bottom, ...footer], cursorRow: 1 + body.cursorRow, cursorCol };
|
|
336
459
|
}
|
|
337
460
|
/**
|
|
338
461
|
* Phone layout: one row, no borders, no hints.
|
|
@@ -345,23 +468,26 @@ function buildCompactFrame(f) {
|
|
|
345
468
|
const style = modeStyle(f.mode);
|
|
346
469
|
const prefix = style.color(style.glyph) + ' ' + palette.accent('❯') + ' ';
|
|
347
470
|
const pct = Math.max(0, Math.min(100, Math.round(f.contextPercent)));
|
|
348
|
-
const
|
|
471
|
+
const bgShort = backgroundBadge(f.background, true);
|
|
472
|
+
const statText = `${bgShort ? stripAnsi(bgShort) + ' ' : ''}${pct}%`;
|
|
349
473
|
const statColor = pct >= 90 ? palette.err : pct >= 75 ? palette.warn : palette.faint;
|
|
350
474
|
const statWidth = displayWidth(statText) + 1;
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
const
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
+
});
|
|
360
486
|
const below = (f.below ?? []).map((l) => ' ' + l);
|
|
361
487
|
return {
|
|
362
|
-
lines: [
|
|
363
|
-
cursorRow:
|
|
364
|
-
cursorCol: 4 +
|
|
488
|
+
lines: [...rows, ...below],
|
|
489
|
+
cursorRow: body.cursorRow,
|
|
490
|
+
cursorCol: 4 + body.cursorCol,
|
|
365
491
|
};
|
|
366
492
|
}
|
|
367
493
|
function hintsRow(cols, mode) {
|
|
@@ -614,7 +740,7 @@ export function summarizeToolResult(name, result, cols = 80, expanded = false) {
|
|
|
614
740
|
}
|
|
615
741
|
if (expanded && t !== 'bash' && !(t === 'edit_file' || t === 'write_file')) {
|
|
616
742
|
// For these the first line is part of the output, not a summary of it.
|
|
617
|
-
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';
|
|
618
744
|
const first = own ? [] : summarizeToolResult(name, result, cols, false);
|
|
619
745
|
return [...first, ...fullRows(text, cols)];
|
|
620
746
|
}
|
|
@@ -648,6 +774,17 @@ export function summarizeToolResult(name, result, cols = 80, expanded = false) {
|
|
|
648
774
|
}
|
|
649
775
|
catch { }
|
|
650
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
|
+
}
|
|
651
788
|
// Output of an MCP tool or a web fetch: the first line and how many more there are.
|
|
652
789
|
if (t.startsWith('mcp__') || t === 'web_search' || t === 'fetch_url') {
|
|
653
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');
|