termux-dev 1.8.1 → 1.9.0

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.
Files changed (55) hide show
  1. package/README.md +17 -4
  2. package/assets/banner.svg +1 -1
  3. package/assets/preview.svg +1 -1
  4. package/dist/cli/background-ui.js +116 -0
  5. package/dist/cli/files.js +2 -12
  6. package/dist/cli/finder.js +4 -0
  7. package/dist/cli/headless.js +6 -3
  8. package/dist/cli/index.js +259 -74
  9. package/dist/cli/keys.js +12 -3
  10. package/dist/cli/liveblock.js +38 -9
  11. package/dist/cli/mcp-panel.js +2 -0
  12. package/dist/cli/picker.js +2 -0
  13. package/dist/cli/prompt.js +93 -8
  14. package/dist/cli/provider-manager.js +197 -0
  15. package/dist/cli/providers.js +0 -5
  16. package/dist/cli/settings.js +38 -17
  17. package/dist/cli/typeahead.js +10 -17
  18. package/dist/cli/ui.js +189 -32
  19. package/dist/cli/updater.js +10 -8
  20. package/dist/cli/usage-panel.js +17 -4
  21. package/dist/cli/width.js +18 -0
  22. package/dist/core/background.js +390 -0
  23. package/dist/core/config-merge.js +67 -0
  24. package/dist/core/context-stats.js +1 -0
  25. package/dist/core/cost.js +25 -11
  26. package/dist/core/custom-providers.js +161 -0
  27. package/dist/core/images.js +46 -0
  28. package/dist/core/local-config.js +3 -1
  29. package/dist/core/locales/de.js +36 -7
  30. package/dist/core/locales/en.js +36 -7
  31. package/dist/core/locales/es.js +36 -7
  32. package/dist/core/locales/fr.js +36 -7
  33. package/dist/core/locales/ja.js +36 -7
  34. package/dist/core/locales/pt.js +36 -7
  35. package/dist/core/locales/ru.js +36 -7
  36. package/dist/core/locales/uk.js +36 -7
  37. package/dist/core/locales/zh.js +36 -7
  38. package/dist/core/loop.js +50 -2
  39. package/dist/core/prices.js +8 -4
  40. package/dist/core/session-media.js +115 -0
  41. package/dist/core/session.js +17 -2
  42. package/dist/core/title.js +19 -2
  43. package/dist/core/types.js +1 -1
  44. package/dist/core/usage.js +41 -4
  45. package/dist/permissions/guard.js +6 -1
  46. package/dist/prompts/builder.js +2 -1
  47. package/dist/providers/anthropic.js +67 -13
  48. package/dist/providers/index.js +16 -3
  49. package/dist/providers/openai.js +99 -41
  50. package/dist/providers/shared.js +83 -0
  51. package/dist/tools/background.js +118 -0
  52. package/dist/tools/bash.js +23 -1
  53. package/dist/tools/fs.js +32 -2
  54. package/dist/tools/index.js +2 -1
  55. package/package.json +1 -1
@@ -30,6 +30,7 @@ export const ROWS = [
30
30
  row('layout', 'appearance', 'choice'),
31
31
  row('pureBlack', 'appearance', 'toggle'),
32
32
  row('autoApprove', 'agent', 'toggle'),
33
+ row('extra', 'agent', 'toggle'),
33
34
  row('maxIterations', 'agent', 'choice'),
34
35
  row('memory', 'agent', 'toggle'),
35
36
  row('updates', 'agent', 'toggle'),
@@ -37,6 +38,10 @@ export const ROWS = [
37
38
  row('language', 'general', 'choice'),
38
39
  row('about', 'about', 'info'),
39
40
  ];
41
+ /** The rows on screen: Extra is there only while YOLO is on. */
42
+ export function visibleRows(state) {
43
+ return ROWS.filter((r) => r.key !== 'extra' || state.autoApprove);
44
+ }
40
45
  const hintFor = (key) => t(`settings.hint.${key}`);
41
46
  function cycle(items, current, direction) {
42
47
  const index = items.indexOf(current);
@@ -65,7 +70,10 @@ export function changeSetting(state, key, direction) {
65
70
  case 'pureBlack':
66
71
  return { ...state, pureBlack: !state.pureBlack };
67
72
  case 'autoApprove':
68
- return { ...state, autoApprove: !state.autoApprove };
73
+ // Extra goes with YOLO: without it there is nothing to take the questions away from.
74
+ return { ...state, autoApprove: !state.autoApprove, extra: state.autoApprove ? false : !!state.extra };
75
+ case 'extra':
76
+ return state.autoApprove ? { ...state, extra: !state.extra } : state;
69
77
  case 'memory':
70
78
  return { ...state, memory: !state.memory };
71
79
  case 'updates':
@@ -81,8 +89,8 @@ export function changeSetting(state, key, direction) {
81
89
  }
82
90
  }
83
91
  /** Moves the focus one row. It stops at the first and the last row instead of wrapping. */
84
- export function moveFocus(focus, delta) {
85
- return Math.max(0, Math.min(ROWS.length - 1, focus + delta));
92
+ export function moveFocus(focus, delta, count = ROWS.length) {
93
+ return Math.max(0, Math.min(count - 1, focus + delta));
86
94
  }
87
95
  // ───────────────────────── rendering ─────────────────────────
88
96
  const LABEL_WIDTH = 21;
@@ -110,6 +118,8 @@ function valueText(key, state, version, room, withSwatch) {
110
118
  return onOff(state.pureBlack);
111
119
  case 'autoApprove':
112
120
  return onOff(state.autoApprove, palette.yolo, '▲');
121
+ case 'extra':
122
+ return onOff(!!state.extra, palette.err, '▲');
113
123
  case 'maxIterations':
114
124
  return palette.text(formatIterations(state.maxIterations));
115
125
  case 'memory':
@@ -136,7 +146,8 @@ function detailLines(state, key, width, maxLines, version) {
136
146
  }
137
147
  const hint = key === 'about' ? `devx v${version}. ${hintFor('about')}` : hintFor(key);
138
148
  // A warning is never the line that gets cut off: it keeps its row, the explanation gives way.
139
- const warning = key === 'autoApprove' && state.autoApprove ? t('settings.warnYolo') : '';
149
+ const warning = key === 'autoApprove' && state.autoApprove ? t('settings.warnYolo')
150
+ : key === 'extra' && state.extra ? t('settings.warnExtra') : '';
140
151
  const hintRows = Math.max(1, maxLines - (warning ? 1 : 0));
141
152
  let wrapped = wrapText(hint, width);
142
153
  if (wrapped.length > hintRows) {
@@ -145,7 +156,7 @@ function detailLines(state, key, width, maxLines, version) {
145
156
  }
146
157
  const out = wrapped.map((line) => palette.muted(line));
147
158
  if (warning && out.length < maxLines)
148
- out.push(palette.yolo(truncateWidth(warning, width)));
159
+ out.push((key === 'extra' ? palette.err : palette.yolo)(truncateWidth(warning, width)));
149
160
  return out;
150
161
  }
151
162
  /** The key hints, shortened step by step until they fit `width` cells. */
@@ -177,8 +188,9 @@ function renderCard(state, focus, cols, density, version) {
177
188
  if (density === 'roomy')
178
189
  lines.push(box(''));
179
190
  const labelWidth = labelColumn((r) => r.label, LABEL_WIDTH, Math.floor(iw / 2));
191
+ const shown = visibleRows(state);
180
192
  let section = '';
181
- ROWS.forEach((row, index) => {
193
+ shown.forEach((row, index) => {
182
194
  if (row.sectionKey !== section) {
183
195
  if (section !== '' && density === 'roomy')
184
196
  lines.push(box(''));
@@ -199,7 +211,7 @@ function renderCard(state, focus, cols, density, version) {
199
211
  lines.push(box(''));
200
212
  lines.push(rule('├' + '─'.repeat(W - 2) + '┤'));
201
213
  // The detail area always has the same height, so the panel does not jump while you move.
202
- const detail = detailLines(state, ROWS[focus].key, iw - 2, maxDetail, version);
214
+ const detail = detailLines(state, shown[Math.min(focus, shown.length - 1)].key, iw - 2, maxDetail, version);
203
215
  for (let i = 0; i < maxDetail; i++)
204
216
  lines.push(box(detail[i] ? ' ' + detail[i] : ''));
205
217
  lines.push(rule('╰' + '─'.repeat(W - 2) + '╯'));
@@ -211,7 +223,8 @@ function renderCompact(state, focus, cols, rows, version) {
211
223
  const lines = [];
212
224
  const shortWidth = labelColumn((r) => r.short, SHORT_LABEL_WIDTH, Math.floor(width / 2));
213
225
  lines.push(' ' + palette.bold(palette.accent(t('settings.title'))) + palette.faint(` · v${version}`));
214
- ROWS.forEach((row, index) => {
226
+ const shown = visibleRows(state);
227
+ shown.forEach((row, index) => {
215
228
  const focused = index === focus;
216
229
  const mark = focused ? palette.accent('❯') : ' ';
217
230
  const label = focused ? palette.bold(palette.text(row.short)) : palette.muted(row.short);
@@ -221,8 +234,8 @@ function renderCompact(state, focus, cols, rows, version) {
221
234
  lines.push(focused ? palette.shade(padEndWidth(content, width)) : content);
222
235
  });
223
236
  // The title, the rows and the key hints come first; the help text takes what is left of the screen.
224
- const detailCount = Math.max(0, Math.min(rows >= 16 ? 2 : 1, rows - 1 - (ROWS.length + 2)));
225
- const detail = detailLines(state, ROWS[focus].key, width - 2, detailCount, version);
237
+ const detailCount = Math.max(0, Math.min(rows >= 16 ? 2 : 1, rows - 1 - (shown.length + 2)));
238
+ const detail = detailLines(state, shown[Math.min(focus, shown.length - 1)].key, width - 2, detailCount, version);
226
239
  for (let i = 0; i < detailCount; i++)
227
240
  lines.push(detail[i] ? ' ' + detail[i] : '');
228
241
  lines.push(' ' + keyHints(width - 1));
@@ -233,9 +246,11 @@ function renderCompact(state, focus, cols, rows, version) {
233
246
  * depending on the height), narrow ones a plain list.
234
247
  */
235
248
  export function renderSettings(state, focus, cols, rows, version) {
236
- if (cols < 60 || rows < 23)
249
+ // The card needs a line for each row of the list: it is lower when Extra is there.
250
+ const count = visibleRows(state).length;
251
+ if (cols < 60 || rows < count + 13)
237
252
  return renderCompact(state, focus, cols, rows, version);
238
- return renderCard(state, focus, cols, rows >= 29 ? 'roomy' : 'tight', version);
253
+ return renderCard(state, focus, cols, rows >= count + 19 ? 'roomy' : 'tight', version);
239
254
  }
240
255
  /**
241
256
  * Shows the panel and resolves with the final state when it is closed (Esc, q or Ctrl+C).
@@ -251,16 +266,22 @@ export function runSettings(initial, options) {
251
266
  const wasRaw = !!input.isRaw;
252
267
  input.setRawMode?.(true);
253
268
  input.resume();
269
+ const stopWatching = block.watchResize(draw);
270
+ const rowsNow = () => visibleRows(state);
254
271
  const change = (direction) => {
255
- const next = changeSetting(state, ROWS[focus].key, direction);
272
+ const key = rowsNow()[Math.min(focus, rowsNow().length - 1)].key;
273
+ const next = changeSetting(state, key, direction);
256
274
  if (next === state)
257
275
  return;
258
276
  state = next;
259
- options.onChange?.(ROWS[focus].key, state);
277
+ // The Extra row goes away with YOLO: the focus must not be left behind the end of the list.
278
+ focus = Math.min(focus, rowsNow().length - 1);
279
+ options.onChange?.(key, state);
260
280
  draw();
261
281
  };
262
282
  const close = () => {
263
283
  input.off('data', onData);
284
+ stopWatching();
264
285
  block.erase();
265
286
  if (!wasRaw)
266
287
  input.setRawMode?.(false);
@@ -269,7 +290,7 @@ export function runSettings(initial, options) {
269
290
  let closed = false;
270
291
  const handleKey = (key) => {
271
292
  const move = (delta) => {
272
- const next = moveFocus(focus, delta);
293
+ const next = moveFocus(focus, delta, rowsNow().length);
273
294
  if (next !== focus) {
274
295
  focus = next;
275
296
  draw();
@@ -281,9 +302,9 @@ export function runSettings(initial, options) {
281
302
  return move(1);
282
303
  // Home / End are sent differently by different terminals.
283
304
  if (key === '\x1b[H' || key === '\x1b[1~' || key === '\x1b[7~' || key === '\x1bOH' || key === 'g')
284
- return move(-ROWS.length);
305
+ return move(-rowsNow().length);
285
306
  if (key === '\x1b[F' || key === '\x1b[4~' || key === '\x1b[8~' || key === '\x1bOF' || key === 'G')
286
- return move(ROWS.length);
307
+ return move(rowsNow().length);
287
308
  if (key === '\x1b[C' || key === '\x1bOC' || key === 'l' || key === ' ' || key === '\r' || key === '\n')
288
309
  return change(1);
289
310
  if (key === '\x1b[D' || key === '\x1bOD' || key === 'h')
@@ -1,7 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import os from 'os';
3
3
  import path from 'path';
4
- import { CTRL_ENTER, EXTENDED_KEYS_OFF, EXTENDED_KEYS_ON, normalizeKey, splitKeys } from './keys.js';
4
+ import { EXTENDED_KEYS_OFF, EXTENDED_KEYS_ON, isNewlineKey, normalizeKey, splitKeys } from './keys.js';
5
5
  import { buildPromptFrame, palette } from './ui.js';
6
6
  import { displayWidth, truncateWidth } from './width.js';
7
7
  import { t } from '../core/i18n.js';
@@ -31,13 +31,16 @@ export function applyKey(state, key) {
31
31
  start--;
32
32
  return start === pos ? { state } : set([...chars.slice(0, start), ...chars.slice(pos)], start);
33
33
  };
34
+ // Ctrl+Enter, Alt+Enter, Shift+Enter: a new paragraph, the line is not sent.
35
+ if (isNewlineKey(key))
36
+ return set([...cs.slice(0, at), '\n', ...cs.slice(at)], at + 1);
34
37
  switch (key) {
35
- case CTRL_ENTER: {
36
- const text = state.text.trim();
37
- return { state: text ? { text: '', cursor: 0 } : state, submit: text || undefined, sendNow: true };
38
- }
39
38
  case '\r':
40
39
  case '\n': {
40
+ // A backslash before the cursor makes Enter a line break instead (for terminals that cannot
41
+ // tell Ctrl+Enter from Enter, a phone keyboard for one).
42
+ if (at > 0 && cs[at - 1] === '\\')
43
+ return set([...cs.slice(0, at - 1), '\n', ...cs.slice(at)], at);
41
44
  const text = state.text.trim();
42
45
  return text ? { state: { text: '', cursor: 0 }, submit: text } : { state };
43
46
  }
@@ -103,7 +106,7 @@ export function renderComposer({ state, queued, cols, base }) {
103
106
  const last = queued[queued.length - 1].replace(/\s+/g, ' ');
104
107
  const label = queued.length === 1 ? t('composer.queued') : t('composer.queuedMany', { n: queued.length });
105
108
  const prefix = palette.warn('↳ ' + label) + palette.faint(': ');
106
- // What can be done with it: ctrl+enter sends it now, the down arrow takes it back to edit.
109
+ // What can be done with it: the down arrow takes it back to edit.
107
110
  const hint = t('composer.queuedHint');
108
111
  const suffixWidth = displayWidth(' · ' + hint);
109
112
  const withHint = room - displayWidth(prefix) - suffixWidth >= 12;
@@ -144,7 +147,6 @@ export class TurnComposer {
144
147
  frameBase;
145
148
  onQueue;
146
149
  status;
147
- onSendNow;
148
150
  active = false;
149
151
  suspended = false;
150
152
  shown = false;
@@ -159,14 +161,11 @@ export class TurnComposer {
159
161
  live;
160
162
  constructor(out = process.stdout, frameBase, onQueue,
161
163
  /** The working line shown above the frame for the whole turn; called on every animation tick. */
162
- status,
163
- /** Ctrl+Enter: the user wants the queued messages to reach the agent right now. */
164
- onSendNow) {
164
+ status) {
165
165
  this.out = out;
166
166
  this.frameBase = frameBase;
167
167
  this.onQueue = onQueue;
168
168
  this.status = status;
169
- this.onSendNow = onSendNow;
170
169
  }
171
170
  get enabled() {
172
171
  return this.active;
@@ -266,7 +265,6 @@ export class TurnComposer {
266
265
  return;
267
266
  if (process.env.DEVX_DEBUG_KEYS)
268
267
  logKeys(chunk, this.queue.length, this.state.text);
269
- let sendNow = false;
270
268
  for (const raw of splitKeys(chunk)) {
271
269
  const key = normalizeKey(raw);
272
270
  // Ctrl+C is the app's (it stops the generation); Ctrl+D does nothing.
@@ -292,14 +290,9 @@ export class TurnComposer {
292
290
  this.queue.push(result.submit);
293
291
  this.onQueue?.(result.submit);
294
292
  }
295
- if (result.sendNow && this.queue.length > 0)
296
- sendNow = true;
297
293
  }
298
294
  this.lines = this.render();
299
295
  this.redraw();
300
- // After the redraw: the caller takes the queue and stops the turn.
301
- if (sendNow)
302
- this.onSendNow?.();
303
296
  }
304
297
  // ── terminal output
305
298
  /** The write that replaces the terminal's own for the length of the turn. */
package/dist/cli/ui.js CHANGED
@@ -36,9 +36,12 @@ export const palette = {
36
36
  accent: (s) => tokens().accent(s),
37
37
  bold: (s) => (colorOn() ? `\x1b[1m${s}\x1b[22m` : s),
38
38
  };
39
- export function resolveMode(planMode, yolo) {
39
+ /** `extra` is YOLO without the safety net (nothing is asked); it only counts while YOLO is on. */
40
+ export function resolveMode(planMode, yolo, extra = false) {
40
41
  if (planMode)
41
42
  return 'plan';
43
+ if (yolo && extra)
44
+ return 'extra';
42
45
  return yolo ? 'yolo' : 'agent';
43
46
  }
44
47
  export function modeStyle(mode) {
@@ -47,6 +50,8 @@ export function modeStyle(mode) {
47
50
  return { label: 'plan', glyph: '◆', color: palette.plan };
48
51
  case 'yolo':
49
52
  return { label: 'yolo', glyph: '▲', color: palette.yolo };
53
+ case 'extra':
54
+ return { label: 'extra', glyph: '▲', color: palette.err };
50
55
  default:
51
56
  return { label: 'agent', glyph: '●', color: palette.accent };
52
57
  }
@@ -136,7 +141,7 @@ function brand(version) {
136
141
  }
137
142
  export function renderHeader(info, cols) {
138
143
  const W = contentWidth(cols);
139
- const modePill = info.yolo ? palette.yolo(palette.bold('▲ auto-approve')) : '';
144
+ const modePill = info.extra ? palette.err(palette.bold('▲ extra: nothing asked')) : info.yolo ? palette.yolo(palette.bold('▲ auto-approve')) : '';
140
145
  const branch = info.branch
141
146
  ? `${palette.faint('git:')}${palette.muted(info.branch)}${info.dirty ? palette.warn('*') : ''}`
142
147
  : '';
@@ -146,7 +151,7 @@ export function renderHeader(info, cols) {
146
151
  const limit = Math.max(8, cols - 1);
147
152
  const room = Math.max(6, cols - 3);
148
153
  const titleOptions = info.yolo
149
- ? [`${brand(info.version)} ${modePill}`, `${brand(info.version)} ${palette.yolo('▲ yolo')}`, `${brand(info.version)} ${palette.yolo('▲')}`, brand(info.version)]
154
+ ? [`${brand(info.version)} ${modePill}`, `${brand(info.version)} ${info.extra ? palette.err('▲ extra') : palette.yolo('▲ yolo')}`, `${brand(info.version)} ${info.extra ? palette.err('▲') : palette.yolo('▲')}`, brand(info.version)]
150
155
  : [brand(info.version)];
151
156
  lines.push(titleOptions.find((t) => displayWidth(t) <= limit) ?? truncateWidth(stripAnsi(brand(info.version)), limit));
152
157
  lines.push(` ${palette.text(truncateWidth(info.model, room))}`);
@@ -181,6 +186,17 @@ export function renderHeader(info, cols) {
181
186
  }
182
187
  return lines;
183
188
  }
189
+ // ───────────────────────── prompt frame ─────────────────────────
190
+ /** `◈ 2 bg` (running tasks); a failed one turns it red: `◈ 2 bg · 1 failed`. Empty when there is nothing to say. */
191
+ export function backgroundBadge(bg, short = false) {
192
+ if (!bg || (bg.running <= 0 && !(bg.failed && bg.failed > 0)))
193
+ return '';
194
+ const failed = bg.failed && bg.failed > 0 ? bg.failed : 0;
195
+ const text = short
196
+ ? `◈${bg.running}${failed ? `!${failed}` : ''}`
197
+ : `◈ ${bg.running} bg${failed ? ` · ${failed} failed` : ''}`;
198
+ return failed ? palette.err(text) : palette.accent(text);
199
+ }
184
200
  /** The part of the input that fits into `cells`, scrolled so that the cursor stays visible. */
185
201
  export function visibleWindow(input, cursorPos, cells) {
186
202
  const cps = Array.from(input);
@@ -242,6 +258,128 @@ export function visibleWindow(input, cursorPos, cells) {
242
258
  cursorCells: cellsBefore(Math.min(cursorCp, win.end), win.start),
243
259
  };
244
260
  }
261
+ /** Cells of text per row inside the frame (the compact phone row has no side borders). */
262
+ export function inputCells(cols) {
263
+ const W = frameWidth(cols);
264
+ return Math.max(4, isCompact(cols) ? W - 4 : W - 6);
265
+ }
266
+ /** Control characters but the line break (`\n` is a real break in the input, it is laid out, not shown). */
267
+ const CONTROL_EXCEPT_NEWLINE = /[\x00-\x09\x0b-\x1f\x7f]/g;
268
+ /** Rows the input may take before it scrolls: a quarter of the terminal, at most 8. */
269
+ export function maxInputRows(termRows = process.stdout.rows || 24) {
270
+ return Math.max(1, Math.min(8, Math.floor(termRows / 4)));
271
+ }
272
+ /**
273
+ * Cuts the input into rows of `cells` cells. A line breaks after a space when it can; an
274
+ * attachment tag such as `[Pasted text #1 +22 lines]` is never split unless it is wider than a row.
275
+ */
276
+ export function wrapInput(input, cells) {
277
+ const cps = Array.from(input);
278
+ if (cps.length === 0)
279
+ return [{ start: 0, end: 0 }];
280
+ const widths = cps.map((c) => codePointWidth(c.codePointAt(0)));
281
+ // Where a tag starts (in code points) -> where it ends.
282
+ const offsets = [];
283
+ let unit = 0;
284
+ for (const c of cps) {
285
+ offsets.push(unit);
286
+ unit += c.length;
287
+ }
288
+ const atoms = new Map();
289
+ for (const m of input.matchAll(BADGE)) {
290
+ const from = offsets.indexOf(m.index);
291
+ const to = offsets.indexOf(m.index + m[0].length);
292
+ if (from >= 0)
293
+ atoms.set(from, to >= 0 ? to : cps.length);
294
+ }
295
+ const rows = [];
296
+ let i = 0;
297
+ while (i < cps.length) {
298
+ const start = i;
299
+ let used = 0;
300
+ let afterSpace = -1;
301
+ let hardBreak = false;
302
+ while (i < cps.length) {
303
+ // A line break ends the row; it stays at the end of that row, so the cursor behind it is on the next one.
304
+ if (cps[i] === '\n') {
305
+ i++;
306
+ hardBreak = true;
307
+ break;
308
+ }
309
+ let next = atoms.get(i) ?? i + 1;
310
+ let w = widths.slice(i, next).reduce((a, b) => a + b, 0);
311
+ if (next > i + 1 && w > cells) {
312
+ next = i + 1;
313
+ w = widths[i];
314
+ } // a tag wider than a row is split
315
+ if (used + w > cells)
316
+ break;
317
+ used += w;
318
+ i = next;
319
+ if (cps[i - 1] === ' ')
320
+ afterSpace = i;
321
+ }
322
+ if (!hardBreak) {
323
+ if (i < cps.length && cps[i] !== ' ' && afterSpace > start)
324
+ i = afterSpace; // break between words
325
+ if (i === start)
326
+ i = start + 1; // a cell wider than the row
327
+ }
328
+ rows.push({ start, end: i });
329
+ }
330
+ // Text that ends with a line break: the cursor waits on a new, empty row.
331
+ if (cps[cps.length - 1] === '\n')
332
+ rows.push({ start: cps.length, end: cps.length });
333
+ return rows;
334
+ }
335
+ function layoutInput(input, cursorPos, cells) {
336
+ const cps = Array.from(input);
337
+ const widths = cps.map((c) => codePointWidth(c.codePointAt(0)));
338
+ let cursorCp = 0;
339
+ let units = 0;
340
+ while (cursorCp < cps.length && units < cursorPos) {
341
+ units += cps[cursorCp].length;
342
+ cursorCp++;
343
+ }
344
+ const rows = wrapInput(input, cells);
345
+ let cursorRow = rows.findIndex((r) => cursorCp < r.end);
346
+ if (cursorRow < 0)
347
+ cursorRow = rows.length - 1;
348
+ const row = rows[cursorRow];
349
+ const cursorCell = widths.slice(row.start, Math.min(cursorCp, row.end)).reduce((a, b) => a + b, 0);
350
+ return { cps, rows, widths, cursorRow, cursorCell };
351
+ }
352
+ /**
353
+ * The cursor one row up or down inside a wrapped input, or null when there is no such row
354
+ * (the arrow keys then walk the prompt history as before). Returns a UTF-16 index.
355
+ */
356
+ export function moveCursorRow(input, cursorPos, cols, dir) {
357
+ const clean = input.replace(CONTROL_EXCEPT_NEWLINE, ' ');
358
+ const { cps, rows, widths, cursorRow, cursorCell } = layoutInput(clean, cursorPos, inputCells(cols));
359
+ const target = rows[cursorRow + dir];
360
+ if (!target)
361
+ return null;
362
+ // The last cell of a row that is not the last one belongs to the next row, so stop before it.
363
+ const limit = cursorRow + dir === rows.length - 1 ? target.end : Math.max(target.start, target.end - 1);
364
+ let at = target.start;
365
+ let used = 0;
366
+ while (at < limit && used + widths[at] <= cursorCell) {
367
+ used += widths[at];
368
+ at++;
369
+ }
370
+ return cps.slice(0, at).join('').length;
371
+ }
372
+ /** The rows of the input as drawn, with the cursor; scrolled so that the cursor row is visible. */
373
+ function inputRows(f, cells) {
374
+ if (!f.input) {
375
+ return { rows: [palette.muted(truncateWidth(f.placeholder, cells))], firstRow: 0, cursorRow: 0, cursorCol: 0 };
376
+ }
377
+ const { cps, rows, cursorRow, cursorCell } = layoutInput(f.input, f.cursorPos, cells);
378
+ const limit = f.maxInputRows ?? maxInputRows();
379
+ const firstRow = rows.length <= limit ? 0 : Math.max(0, Math.min(cursorRow - limit + 1, rows.length - limit));
380
+ const shown = rows.slice(firstRow, firstRow + limit).map((r) => formatBadges(cps.slice(r.start, r.end).join('').replace(/\n$/, '')));
381
+ return { rows: shown, firstRow, cursorRow: cursorRow - firstRow, cursorCol: cursorCell };
382
+ }
245
383
  function keyHints(cols, mode) {
246
384
  const next = mode === 'plan' ? 'agent' : 'plan';
247
385
  const all = [
@@ -279,8 +417,9 @@ export function fitModelLabel(label, max) {
279
417
  }
280
418
  export function buildPromptFrame(input) {
281
419
  // Control characters would move the cursor and tear the frame apart. They are shown as
282
- // spaces; one character stays one character, so cursor positions keep matching.
283
- const f = { ...input, input: input.input.replace(/[\x00-\x1f\x7f]/g, ' ') };
420
+ // spaces; one character stays one character, so cursor positions keep matching. A line break
421
+ // is kept: it starts a new row of the input.
422
+ const f = { ...input, input: input.input.replace(CONTROL_EXCEPT_NEWLINE, ' ') };
284
423
  if (isCompact(f.cols))
285
424
  return buildCompactFrame(f);
286
425
  const W = frameWidth(f.cols);
@@ -295,16 +434,14 @@ export function buildPromptFrame(input) {
295
434
  topRight = ' ' + palette.muted(fitModelLabel(f.model, Math.min(48, topFree - 4))) + ' ';
296
435
  }
297
436
  const top = rule('╭─') + pill + rule('─'.repeat(Math.max(0, topFree - displayWidth(topRight)))) + topRight + rule('─╮');
298
- // ── input row: │ ❯ text │
437
+ // ── input rows: │ ❯ text │, the text wraps onto further rows instead of scrolling sideways
299
438
  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);
439
+ const body = inputRows(f, contentCells);
440
+ const inputLines = body.rows.map((content, i) => {
441
+ const glyph = i === 0 ? (body.firstRow > 0 ? palette.faint('…') : palette.accent('❯')) : ' ';
442
+ return rule('│') + ' ' + glyph + ' ' + padEndWidth(content, contentCells) + ' ' + rule('│');
443
+ });
444
+ const cursorCol = 4 + body.cursorCol;
308
445
  // ── bottom border: ╰─ ~/project git:main* ──── ━━━━──── 12% · $0.0123 ─╯
309
446
  const bottomFree = W - 4;
310
447
  const pct = Math.max(0, Math.min(100, Math.round(f.contextPercent)));
@@ -316,7 +453,10 @@ export function buildPromptFrame(input) {
316
453
  : bottomFree >= displayWidth(stat) + 4
317
454
  ? ' ' + palette.muted(stat) + ' '
318
455
  : '';
319
- const leftRoom = bottomFree - displayWidth(bottomRight) - 2;
456
+ // ◈ 2 bg: tasks that keep running while the conversation goes on (/tasks lists them)
457
+ const bgBadge = backgroundBadge(f.background);
458
+ const bgWidth = bgBadge ? displayWidth(stripAnsi(bgBadge)) + 2 : 0;
459
+ const leftRoom = bottomFree - displayWidth(bottomRight) - 2 - bgWidth;
320
460
  let bottomLeft = '';
321
461
  if (f.cwd && leftRoom >= 10) {
322
462
  const branch = f.branch ? `${palette.faint('git:')}${palette.muted(f.branch)}${f.dirty ? palette.warn('*') : ''}` : '';
@@ -325,6 +465,9 @@ export function buildPromptFrame(input) {
325
465
  const pathRoom = leftRoom - (showBranch ? branchW : 0);
326
466
  bottomLeft = ' ' + palette.muted(shortenPath(f.cwd, pathRoom)) + (showBranch ? ' ' + branch : '') + ' ';
327
467
  }
468
+ if (bgBadge && bottomFree - displayWidth(bottomLeft) - displayWidth(bottomRight) - 2 >= bgWidth) {
469
+ bottomLeft += (bottomLeft ? '' : ' ') + bgBadge + ' ';
470
+ }
328
471
  const bottom = rule('╰─') +
329
472
  bottomLeft +
330
473
  rule('─'.repeat(Math.max(0, bottomFree - displayWidth(bottomLeft) - displayWidth(bottomRight)))) +
@@ -332,7 +475,7 @@ export function buildPromptFrame(input) {
332
475
  rule('─╯');
333
476
  // ── footer: key hints, or the suggestion list while one is open
334
477
  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 };
478
+ return { lines: [top, ...inputLines, bottom, ...footer], cursorRow: 1 + body.cursorRow, cursorCol };
336
479
  }
337
480
  /**
338
481
  * Phone layout: one row, no borders, no hints.
@@ -345,23 +488,26 @@ function buildCompactFrame(f) {
345
488
  const style = modeStyle(f.mode);
346
489
  const prefix = style.color(style.glyph) + ' ' + palette.accent('❯') + ' ';
347
490
  const pct = Math.max(0, Math.min(100, Math.round(f.contextPercent)));
348
- const statText = `${pct}%`;
491
+ const bgShort = backgroundBadge(f.background, true);
492
+ const statText = `${bgShort ? stripAnsi(bgShort) + ' ' : ''}${pct}%`;
349
493
  const statColor = pct >= 90 ? palette.err : pct >= 75 ? palette.warn : palette.faint;
350
494
  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) : '');
495
+ const cells = W - 4;
496
+ // The count sits at the end of the last row, and only when the text there leaves it room.
497
+ const withStat = cells - statWidth >= 14;
498
+ const body = inputRows(f, cells);
499
+ const statShown = bgShort && withStat ? bgShort + ' ' + statColor(`${pct}%`) : statColor(statText);
500
+ const rows = body.rows.map((content, i) => {
501
+ const lead = i === 0 ? (body.firstRow > 0 ? style.color(style.glyph) + ' ' + palette.faint('…') + ' ' : prefix) : ' ';
502
+ const room = cells - displayWidth(stripAnsi(content));
503
+ const stat = i === body.rows.length - 1 && withStat && room >= statWidth ? ' '.repeat(room - statWidth + 1) + statShown : '';
504
+ return lead + (stat ? content + stat : padEndWidth(content, cells));
505
+ });
360
506
  const below = (f.below ?? []).map((l) => ' ' + l);
361
507
  return {
362
- lines: [row, ...below],
363
- cursorRow: 0,
364
- cursorCol: 4 + (f.input ? (win.leftEllipsis ? 1 : 0) + win.cursorCells : 0),
508
+ lines: [...rows, ...below],
509
+ cursorRow: body.cursorRow,
510
+ cursorCol: 4 + body.cursorCol,
365
511
  };
366
512
  }
367
513
  function hintsRow(cols, mode) {
@@ -614,7 +760,7 @@ export function summarizeToolResult(name, result, cols = 80, expanded = false) {
614
760
  }
615
761
  if (expanded && t !== 'bash' && !(t === 'edit_file' || t === 'write_file')) {
616
762
  // 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';
763
+ const own = t.startsWith('mcp__') || t === 'web_search' || t === 'fetch_url' || t === 'background';
618
764
  const first = own ? [] : summarizeToolResult(name, result, cols, false);
619
765
  return [...first, ...fullRows(text, cols)];
620
766
  }
@@ -648,6 +794,17 @@ export function summarizeToolResult(name, result, cols = 80, expanded = false) {
648
794
  }
649
795
  catch { }
650
796
  }
797
+ // Background tasks: what happened on the first line (started / failed / matched), the rest folded.
798
+ if (t === 'background') {
799
+ const nonEmpty = outputRows(text).filter((l) => l.trim());
800
+ if (nonEmpty.length === 0)
801
+ return [renderResultLine('(no output)', 'muted', cols)];
802
+ const bad = /^(Error:|No background task|.* failed \(exit|.*ended right after)/.test(nonEmpty[0]);
803
+ const good = /^(Started bg|Stopped\.)/.test(nonEmpty[0]);
804
+ const rest = nonEmpty.length - 1;
805
+ const row = renderResultLine(nonEmpty[0].trim(), bad ? 'error' : good ? 'ok' : 'muted', cols);
806
+ return rest > 0 ? [row, moreHint(rest)] : [row];
807
+ }
651
808
  // Output of an MCP tool or a web fetch: the first line and how many more there are.
652
809
  if (t.startsWith('mcp__') || t === 'web_search' || t === 'fetch_url') {
653
810
  const nonEmpty = outputRows(text).filter((l) => l.trim());
@@ -860,7 +1017,7 @@ export function easeToward(current, target, dtMs, tauMs = 400) {
860
1017
  return current + distance * (1 - Math.exp(-dtMs / tauMs));
861
1018
  }
862
1019
  /**
863
- * The working line: ` ⠹ Read src/x.ts (12s · ↓ 364 tokens · ctrl+c to stop)`.
1020
+ * The working line: ` ⠹ Read src/x.ts (12s · ↓ 364 tokens · esc to stop)`.
864
1021
  * On a narrow screen the details are dropped step by step, the label is cut last.
865
1022
  */
866
1023
  export function renderStatusLine({ label: rawLabel, elapsedMs, meta = '', frame, columns }) {
@@ -889,7 +1046,7 @@ export function renderStatusLine({ label: rawLabel, elapsedMs, meta = '', frame,
889
1046
  return ` ${glyph} ${palette.text(text)}${' '.repeat(GAP)}${suffix}`;
890
1047
  }
891
1048
  /**
892
- * Animated "working" line: ` ⠹ Read src/x.ts (12s · 1.2k tok · ctrl+c to stop)`.
1049
+ * Animated "working" line: ` ⠹ Read src/x.ts (12s · 1.2k tok · esc to stop)`.
893
1050
  * Same start / message / stop API as the clack spinner it replaces.
894
1051
  */
895
1052
  let activeSpinner = null;
@@ -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');