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/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 row: │ ❯ text │
417
+ // ── input rows: │ ❯ text │, the text wraps onto further rows instead of scrolling sideways
299
418
  const contentCells = W - 6;
300
- const win = visibleWindow(f.input, f.cursorPos, contentCells);
301
- const content = f.input
302
- ? (win.leftEllipsis ? palette.faint('…') : '') +
303
- formatBadges(win.text) +
304
- (win.rightEllipsis ? palette.faint('…') : '')
305
- : palette.muted(truncateWidth(f.placeholder, contentCells));
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
- const leftRoom = bottomFree - displayWidth(bottomRight) - 2;
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, inputRow, bottom, ...footer], cursorRow: 1, cursorCol };
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 statText = `${pct}%`;
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 withStat = W - 4 - statWidth >= 14;
352
- const cells = W - 4 - (withStat ? statWidth : 0);
353
- const win = visibleWindow(f.input, f.cursorPos, cells);
354
- const content = f.input
355
- ? (win.leftEllipsis ? palette.faint('…') : '') +
356
- formatBadges(win.text) +
357
- (win.rightEllipsis ? palette.faint('…') : '')
358
- : palette.muted(truncateWidth(f.placeholder, cells));
359
- const row = prefix + padEndWidth(content, cells) + (withStat ? ' ' + statColor(statText) : '');
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: [row, ...below],
363
- cursorRow: 0,
364
- cursorCol: 4 + (f.input ? (win.leftEllipsis ? 1 : 0) + win.cursorCells : 0),
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());
@@ -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
- const frame = pc.cyan(frames[fIdx++ % frames.length]);
212
- process.stdout.write(`\r ${frame} ${pc.dim(`Checking for updates from`)} ${pc.cyan('apvcode/Termux-Dev')} ${pc.dim(`(v${currentVersion})...`)} `);
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(pc.bold(pc.cyan(' ┌───────────────────────────────────────────────────────────┐')));
222
- console.log(pc.bold(pc.cyan(' │')) + pc.bold(pc.yellow(` A new update is available: `)) + pc.dim(`v${result.currentVersion}`) + pc.bold(pc.green(` → v${result.latestVersion}`)) + ' '.repeat(Math.max(1, 23 - result.currentVersion.length - result.latestVersion.length)) + pc.bold(pc.cyan('│')));
223
- console.log(pc.bold(pc.cyan(' │')) + pc.dim(` Repository: https://github.com/apvcode/Termux-Dev `) + pc.bold(pc.cyan('│')));
224
- console.log(pc.bold(pc.cyan(' └───────────────────────────────────────────────────────────┘')));
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: pc.bold('Do you want to update devx now?'),
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(` ${pc.green('✓')} ${pc.dim(`devx is up to date (v${result.currentVersion})`)}\n`);
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');