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.
@@ -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();
@@ -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
- return marked.parse(unfenceMarkdown(content)).trim();
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
- if (lastSpaceIdx > 0) {
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));
@@ -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
- const draw = () => block.draw(renderPicker(options, focus, output.columns || 80, output.rows || 40));
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();
@@ -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) {
@@ -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
- let s = p;
78
- if (s === home)
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(home + path.sep))
81
- s = '~' + s.slice(home.length);
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 row: │ ❯ text │
417
+ // ── input rows: │ ❯ text │, the text wraps onto further rows instead of scrolling sideways
298
418
  const contentCells = W - 6;
299
- const win = visibleWindow(f.input, f.cursorPos, contentCells);
300
- const content = f.input
301
- ? (win.leftEllipsis ? palette.faint('…') : '') +
302
- formatBadges(win.text) +
303
- (win.rightEllipsis ? palette.faint('…') : '')
304
- : palette.muted(truncateWidth(f.placeholder, contentCells));
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
- 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;
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, inputRow, bottom, ...footer], cursorRow: 1, cursorCol };
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 statText = `${pct}%`;
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 withStat = W - 4 - statWidth >= 14;
351
- const cells = W - 4 - (withStat ? statWidth : 0);
352
- const win = visibleWindow(f.input, f.cursorPos, cells);
353
- const content = f.input
354
- ? (win.leftEllipsis ? palette.faint('…') : '') +
355
- formatBadges(win.text) +
356
- (win.rightEllipsis ? palette.faint('…') : '')
357
- : palette.muted(truncateWidth(f.placeholder, cells));
358
- 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
+ });
359
486
  const below = (f.below ?? []).map((l) => ' ' + l);
360
487
  return {
361
- lines: [row, ...below],
362
- cursorRow: 0,
363
- cursorCol: 4 + (f.input ? (win.leftEllipsis ? 1 : 0) + win.cursorCells : 0),
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());
@@ -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');
@@ -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
+ }