termux-dev 1.6.0 → 1.8.1

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 (99) hide show
  1. package/README.md +214 -166
  2. package/assets/banner.svg +23 -28
  3. package/assets/preview.png +0 -0
  4. package/assets/preview.svg +101 -10
  5. package/dist/cli/banner.js +3 -4
  6. package/dist/cli/clipboard-write.js +55 -0
  7. package/dist/cli/clipboard.js +182 -106
  8. package/dist/cli/colors.js +67 -0
  9. package/dist/cli/confirm.js +50 -0
  10. package/dist/cli/doctor.js +4 -4
  11. package/dist/cli/effort.js +80 -0
  12. package/dist/cli/export.js +3 -3
  13. package/dist/cli/files.js +19 -5
  14. package/dist/cli/finder.js +241 -0
  15. package/dist/cli/headless.js +23 -11
  16. package/dist/cli/index.js +1557 -744
  17. package/dist/cli/keys.js +69 -0
  18. package/dist/cli/liveblock.js +45 -0
  19. package/dist/cli/log.js +19 -0
  20. package/dist/cli/logo.js +30 -0
  21. package/dist/cli/markdown.js +174 -37
  22. package/dist/cli/mcp-panel.js +432 -0
  23. package/dist/cli/permission.js +84 -0
  24. package/dist/cli/picker.js +143 -0
  25. package/dist/cli/prompt.js +256 -56
  26. package/dist/cli/resume.js +15 -0
  27. package/dist/cli/rewind.js +51 -0
  28. package/dist/cli/runtime_errors.js +6 -0
  29. package/dist/cli/server.js +85 -24
  30. package/dist/cli/settings.js +307 -0
  31. package/dist/cli/statusline.js +1 -1
  32. package/dist/cli/theme.js +126 -108
  33. package/dist/cli/transcript.js +134 -0
  34. package/dist/cli/typeahead.js +371 -0
  35. package/dist/cli/ui.js +1022 -0
  36. package/dist/cli/updater.js +23 -21
  37. package/dist/cli/usage-panel.js +242 -0
  38. package/dist/cli/welcome.js +56 -0
  39. package/dist/cli/width.js +178 -0
  40. package/dist/core/capabilities.js +433 -0
  41. package/dist/core/catalog-util.js +27 -0
  42. package/dist/core/checkpoint.js +90 -0
  43. package/dist/core/context-stats.js +29 -0
  44. package/dist/core/cost.js +191 -0
  45. package/dist/core/formats.js +199 -0
  46. package/dist/core/history.js +51 -1
  47. package/dist/core/hooks.js +79 -0
  48. package/dist/core/i18n.js +42 -0
  49. package/dist/core/local-config.js +69 -0
  50. package/dist/core/locales/de.js +265 -0
  51. package/dist/core/locales/en.js +276 -0
  52. package/dist/core/locales/es.js +265 -0
  53. package/dist/core/locales/fr.js +265 -0
  54. package/dist/core/locales/ja.js +265 -0
  55. package/dist/core/locales/pt.js +265 -0
  56. package/dist/core/locales/ru.js +265 -0
  57. package/dist/core/locales/uk.js +265 -0
  58. package/dist/core/locales/zh.js +265 -0
  59. package/dist/core/loop.js +125 -49
  60. package/dist/core/memory.js +39 -12
  61. package/dist/core/models.js +33 -20
  62. package/dist/core/network.js +12 -11
  63. package/dist/core/notify.js +54 -0
  64. package/dist/core/planfile.js +43 -0
  65. package/dist/core/price-catalog.json +1 -0
  66. package/dist/core/prices.js +249 -0
  67. package/dist/core/reasoning-catalog.json +1 -0
  68. package/dist/core/reasoning.js +393 -0
  69. package/dist/core/redact.js +41 -0
  70. package/dist/core/secure-file.js +22 -0
  71. package/dist/core/session.js +16 -5
  72. package/dist/core/snapshot.js +14 -2
  73. package/dist/core/title.js +61 -0
  74. package/dist/core/types.js +1 -1
  75. package/dist/core/usage.js +43 -7
  76. package/dist/core/vision-catalog.json +1 -0
  77. package/dist/core/vision.js +123 -0
  78. package/dist/mcp/client.js +36 -5
  79. package/dist/mcp/http-client.js +174 -0
  80. package/dist/mcp/manager.js +330 -93
  81. package/dist/permissions/guard.js +385 -117
  82. package/dist/prompts/builder.js +46 -38
  83. package/dist/providers/anthropic.js +517 -0
  84. package/dist/providers/index.js +18 -1
  85. package/dist/providers/openai.js +138 -125
  86. package/dist/providers/shared.js +116 -0
  87. package/dist/providers/toolguard.js +101 -0
  88. package/dist/tools/bash.js +141 -51
  89. package/dist/tools/diagnostics.js +76 -48
  90. package/dist/tools/fs.js +77 -5
  91. package/dist/tools/index.js +2 -1
  92. package/dist/tools/mcp.js +72 -0
  93. package/dist/tools/packages.js +16 -1
  94. package/dist/tools/plan.js +8 -8
  95. package/dist/tools/questions.js +50 -54
  96. package/dist/tools/server.js +6 -0
  97. package/dist/tools/todo.js +20 -18
  98. package/dist/tools/web.js +143 -8
  99. package/package.json +1 -1
@@ -0,0 +1,371 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { CTRL_ENTER, EXTENDED_KEYS_OFF, EXTENDED_KEYS_ON, normalizeKey, splitKeys } from './keys.js';
5
+ import { buildPromptFrame, palette } from './ui.js';
6
+ import { displayWidth, truncateWidth } from './width.js';
7
+ import { t } from '../core/i18n.js';
8
+ /** The down arrow in every form a terminal may send it: `ESC [ B`, `ESC O B` (application mode), `ESC [ 1 ; mods B` (with modifier fields). */
9
+ const DOWN_ARROW = /^\x1b(?:\[|O)(?:1;[\d:]+)?B$/;
10
+ /** DEVX_DEBUG_KEYS=1: what the terminal sends is written to ~/.devx/keys.log, to find out why a key does nothing. */
11
+ function logKeys(chunk, queued, draft) {
12
+ try {
13
+ const file = path.join(os.homedir(), '.devx', 'keys.log');
14
+ fs.mkdirSync(path.dirname(file), { recursive: true });
15
+ fs.appendFileSync(file, JSON.stringify({ keys: chunk, queued, draftEmpty: draft === '', at: new Date().toISOString() }) + '\n');
16
+ }
17
+ catch { }
18
+ }
19
+ const chars = (text) => Array.from(text);
20
+ const isWordChar = (c) => /[\p{L}\p{N}_]/u.test(c);
21
+ /** One key press applied to the draft. The input state is never modified. */
22
+ export function applyKey(state, key) {
23
+ const cs = chars(state.text);
24
+ const at = Math.max(0, Math.min(cs.length, state.cursor));
25
+ const set = (next, cursor) => ({ state: { text: next.join(''), cursor } });
26
+ const wordBackspace = (chars, pos) => {
27
+ let start = pos;
28
+ while (start > 0 && !isWordChar(chars[start - 1]))
29
+ start--;
30
+ while (start > 0 && isWordChar(chars[start - 1]))
31
+ start--;
32
+ return start === pos ? { state } : set([...chars.slice(0, start), ...chars.slice(pos)], start);
33
+ };
34
+ 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
+ case '\r':
40
+ case '\n': {
41
+ const text = state.text.trim();
42
+ return text ? { state: { text: '', cursor: 0 }, submit: text } : { state };
43
+ }
44
+ case '\b': // Ctrl+Backspace in most terminals (plain Backspace is 0x7f): erase a word
45
+ return wordBackspace(cs, at);
46
+ case '\x7f':
47
+ return at > 0 ? set([...cs.slice(0, at - 1), ...cs.slice(at)], at - 1) : { state };
48
+ case '\x1b[3~':
49
+ return at < cs.length ? set([...cs.slice(0, at), ...cs.slice(at + 1)], at) : { state };
50
+ case '\x1b[D':
51
+ case '\x1bOD':
52
+ case '\x02':
53
+ return { state: { text: state.text, cursor: Math.max(0, at - 1) } };
54
+ case '\x1b[C':
55
+ case '\x1bOC':
56
+ case '\x06':
57
+ return { state: { text: state.text, cursor: Math.min(cs.length, at + 1) } };
58
+ case '\x1b[H':
59
+ case '\x1b[1~':
60
+ case '\x1bOH':
61
+ case '\x01':
62
+ return { state: { text: state.text, cursor: 0 } };
63
+ case '\x1b[F':
64
+ case '\x1b[4~':
65
+ case '\x1bOF':
66
+ case '\x05':
67
+ return { state: { text: state.text, cursor: cs.length } };
68
+ case '\x15': // Ctrl+U: everything before the cursor
69
+ return set(cs.slice(at), 0);
70
+ case '\x0b': // Ctrl+K: everything after the cursor
71
+ return set(cs.slice(0, at), at);
72
+ case '\x17': // Ctrl+W, Alt+Backspace: the word before the cursor
73
+ return wordBackspace(cs, at);
74
+ case '\x1b': // Esc: throw the line away
75
+ return state.text ? { state: { text: '', cursor: 0 } } : { state };
76
+ default:
77
+ break;
78
+ }
79
+ // Any other escape sequence or control character is not text.
80
+ if (key.startsWith('\x1b') || key.length === 0)
81
+ return { state };
82
+ const code = key.codePointAt(0);
83
+ if (code < 32 || code === 127)
84
+ return { state };
85
+ const typed = chars(key);
86
+ return set([...cs.slice(0, at), ...typed, ...cs.slice(at)], at + typed.length);
87
+ }
88
+ // A stand-in for the cursor while the frame is laid out; it is swapped for a highlighted cell.
89
+ const CURSOR_MARK = '\uE000';
90
+ const CURSOR_CELL = '\x1b[7m \x1b[27m';
91
+ /**
92
+ * The prompt frame with the line being typed, and under it a row that says what Enter does
93
+ * or what is already queued. The real cursor is busy marking where the output goes, so the
94
+ * cursor of the line is drawn as a highlighted cell.
95
+ */
96
+ export function renderComposer({ state, queued, cols, base }) {
97
+ const cs = Array.from(state.text);
98
+ const at = Math.max(0, Math.min(cs.length, state.cursor));
99
+ const withMark = state.text === '' ? '' : cs.slice(0, at).join('') + CURSOR_MARK + cs.slice(at).join('');
100
+ let note;
101
+ const room = Math.max(8, cols - 5);
102
+ if (queued.length > 0) {
103
+ const last = queued[queued.length - 1].replace(/\s+/g, ' ');
104
+ const label = queued.length === 1 ? t('composer.queued') : t('composer.queuedMany', { n: queued.length });
105
+ 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.
107
+ const hint = t('composer.queuedHint');
108
+ const suffixWidth = displayWidth(' · ' + hint);
109
+ const withHint = room - displayWidth(prefix) - suffixWidth >= 12;
110
+ const message = truncateWidth(last, Math.max(4, room - displayWidth(prefix) - (withHint ? suffixWidth : 0)));
111
+ note = prefix + palette.muted(message) + (withHint ? palette.faint(' · ') + palette.text(hint) : '');
112
+ }
113
+ else {
114
+ const variants = [t('composer.hint1'), t('composer.hint2'), t('composer.hint3'), t('composer.hint4')];
115
+ const text = variants.find((v) => displayWidth(v) <= room) ?? '';
116
+ note = palette.faint(text);
117
+ }
118
+ const frame = buildPromptFrame({
119
+ ...base,
120
+ cols,
121
+ input: withMark,
122
+ cursorPos: withMark === '' ? 0 : at,
123
+ placeholder: CURSOR_MARK + t('composer.placeholder'),
124
+ below: [note],
125
+ });
126
+ return frame.lines.map((line) => line.split(CURSOR_MARK).join(CURSOR_CELL));
127
+ }
128
+ const MIN_ROWS = 10;
129
+ const MIN_COLS = 30;
130
+ /**
131
+ * Keeps the prompt frame under the output while a turn runs.
132
+ *
133
+ * Every write to the terminal goes through here for the length of the turn: the frame is wiped,
134
+ * the text is written, the frame is drawn again below the new last line, all in one write, so
135
+ * the screen does not flicker. The frame needs no fixed place: it is always "the rows under
136
+ * the cursor", and the cursor is put back where the text goes on. Rows are made with ESC D
137
+ * (index), which scrolls the screen when the cursor is near the bottom and keeps the column.
138
+ *
139
+ * Safe to use anywhere: on a terminal that is too small, or not a terminal at all, it does
140
+ * nothing and `start()` returns false.
141
+ */
142
+ export class TurnComposer {
143
+ out;
144
+ frameBase;
145
+ onQueue;
146
+ status;
147
+ onSendNow;
148
+ active = false;
149
+ suspended = false;
150
+ shown = false;
151
+ state = { text: '', cursor: 0 };
152
+ queue = [];
153
+ lines = [];
154
+ original = null;
155
+ timer = null;
156
+ /** Called on ctrl+p (thinking) and ctrl+o (tool output). */
157
+ onToggle;
158
+ /** The line of live reasoning shown above the working line while thinking is collapsed; empty for none. */
159
+ live;
160
+ constructor(out = process.stdout, frameBase, onQueue,
161
+ /** 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) {
165
+ this.out = out;
166
+ this.frameBase = frameBase;
167
+ this.onQueue = onQueue;
168
+ this.status = status;
169
+ this.onSendNow = onSendNow;
170
+ }
171
+ get enabled() {
172
+ return this.active;
173
+ }
174
+ /** An approval card or a question has the keyboard. */
175
+ get isSuspended() {
176
+ return this.suspended;
177
+ }
178
+ get draft() {
179
+ return this.state.text;
180
+ }
181
+ get queued() {
182
+ return [...this.queue];
183
+ }
184
+ hasQueued() {
185
+ return this.queue.length > 0;
186
+ }
187
+ /** Hands over the queued messages (oldest first) and empties the queue. */
188
+ takeQueued() {
189
+ const taken = this.queue;
190
+ this.queue = [];
191
+ // The "queued" note is part of the frame: it has to be built again, or it stays on screen.
192
+ this.lines = this.render();
193
+ this.redraw();
194
+ return taken;
195
+ }
196
+ /** Shows the frame under the cursor and takes over the terminal output. Returns false when that is not possible here. */
197
+ start(initialDraft = '') {
198
+ const rows = this.out.rows ?? 0;
199
+ const cols = this.out.columns ?? 0;
200
+ if (!this.out.isTTY || rows < MIN_ROWS || cols < MIN_COLS)
201
+ return false;
202
+ this.state = { text: initialDraft, cursor: Array.from(initialDraft).length };
203
+ this.queue = [];
204
+ this.lines = this.render();
205
+ // A frame that takes half the screen is not worth it.
206
+ if (this.lines.length > Math.floor(rows / 2))
207
+ return false;
208
+ this.active = true;
209
+ this.suspended = false;
210
+ this.shown = false;
211
+ this.original = this.out.write.bind(this.out);
212
+ this.out.write = ((chunk, encoding, cb) => this.through(chunk, encoding, cb));
213
+ this.write(EXTENDED_KEYS_ON + this.drawSeq());
214
+ // The working line animates: its time and counter are redrawn while the output is quiet.
215
+ if (this.status) {
216
+ this.timer = setInterval(() => this.tick(), 60);
217
+ this.timer.unref?.();
218
+ }
219
+ return true;
220
+ }
221
+ /** Gives the terminal back and returns what was left unsent. */
222
+ stop() {
223
+ const result = { draft: this.state.text, queued: this.queue };
224
+ if (this.timer) {
225
+ clearInterval(this.timer);
226
+ this.timer = null;
227
+ }
228
+ if (this.active) {
229
+ this.write(this.eraseSeq() + EXTENDED_KEYS_OFF + '\x1b[?25h');
230
+ if (this.original)
231
+ this.out.write = this.original;
232
+ this.original = null;
233
+ }
234
+ this.active = false;
235
+ this.suspended = false;
236
+ this.shown = false;
237
+ this.queue = [];
238
+ return result;
239
+ }
240
+ /** Hides the frame while something else (an approval card, a question) needs the keyboard and the screen. */
241
+ suspend() {
242
+ if (!this.active || this.suspended)
243
+ return;
244
+ // The card or question that takes over reads plain keys: the extended encodings go off for it.
245
+ this.write(this.eraseSeq() + EXTENDED_KEYS_OFF);
246
+ this.suspended = true;
247
+ }
248
+ resume() {
249
+ if (!this.active || !this.suspended)
250
+ return;
251
+ this.suspended = false;
252
+ this.write(EXTENDED_KEYS_ON + this.drawSeq());
253
+ }
254
+ /** Builds the frame again (the context percentage or the cost changed) and draws it. */
255
+ refresh() {
256
+ this.lines = this.render();
257
+ this.redraw();
258
+ }
259
+ /** The terminal changed size. */
260
+ onResize() {
261
+ this.refresh();
262
+ }
263
+ /** Raw terminal input. Ignored while suspended. */
264
+ handleData(chunk) {
265
+ if (!this.active || this.suspended)
266
+ return;
267
+ if (process.env.DEVX_DEBUG_KEYS)
268
+ logKeys(chunk, this.queue.length, this.state.text);
269
+ let sendNow = false;
270
+ for (const raw of splitKeys(chunk)) {
271
+ const key = normalizeKey(raw);
272
+ // Ctrl+C is the app's (it stops the generation); Ctrl+D does nothing.
273
+ if (key === '\x03' || key === '\x04')
274
+ continue;
275
+ // Down arrow with nothing typed: the message that waits comes back into the line to be edited.
276
+ if (DOWN_ARROW.test(key) && this.queue.length > 0 && this.state.text === '') {
277
+ const back = this.queue.pop();
278
+ this.state = { text: back, cursor: Array.from(back).length };
279
+ continue;
280
+ }
281
+ if (key === '\x10') {
282
+ this.onToggle?.('thinking');
283
+ continue;
284
+ } // ctrl+p
285
+ if (key === '\x0f') {
286
+ this.onToggle?.('output');
287
+ continue;
288
+ } // ctrl+o
289
+ const result = applyKey(this.state, key);
290
+ this.state = result.state;
291
+ if (result.submit) {
292
+ this.queue.push(result.submit);
293
+ this.onQueue?.(result.submit);
294
+ }
295
+ if (result.sendNow && this.queue.length > 0)
296
+ sendNow = true;
297
+ }
298
+ this.lines = this.render();
299
+ this.redraw();
300
+ // After the redraw: the caller takes the queue and stops the turn.
301
+ if (sendNow)
302
+ this.onSendNow?.();
303
+ }
304
+ // ── terminal output
305
+ /** The write that replaces the terminal's own for the length of the turn. */
306
+ through(chunk, encoding, cb) {
307
+ const raw = this.original;
308
+ if (!this.active || this.suspended)
309
+ return raw(chunk, encoding, cb);
310
+ if (typeof chunk === 'string') {
311
+ // The callback form `write(chunk, cb)` has the callback in the second place.
312
+ return raw(this.eraseSeq() + chunk + this.drawSeq(), encoding, cb);
313
+ }
314
+ raw(this.eraseSeq());
315
+ const result = raw(chunk, encoding, cb);
316
+ raw(this.drawSeq());
317
+ return result;
318
+ }
319
+ write(text) {
320
+ if (text)
321
+ (this.original ?? this.out.write.bind(this.out))(text);
322
+ }
323
+ redraw() {
324
+ if (!this.active || this.suspended)
325
+ return;
326
+ this.write(this.eraseSeq() + this.drawSeq());
327
+ }
328
+ /** Redraws the working line only (the frame does not change between ticks). */
329
+ tick() {
330
+ if (!this.active || this.suspended || this.lines.length < 3)
331
+ return;
332
+ this.lines[1] = this.oneRow(this.live?.() ?? '');
333
+ this.lines[2] = this.oneRow(this.status?.() ?? '');
334
+ this.redraw();
335
+ }
336
+ /**
337
+ * The rows under the output: an empty row, the working line, an empty row and the frame.
338
+ * Without a working line only one empty row separates the frame from the output.
339
+ */
340
+ render() {
341
+ const frame = renderComposer({ state: this.state, queued: this.queue, cols: this.out.columns ?? 80, base: this.frameBase() });
342
+ // the live reasoning, then the working line right above the frame (always in the same place), and a blank row
343
+ return this.status ? ['', this.oneRow(this.live?.() ?? ''), this.oneRow(this.status()), '', ...frame] : ['', ...frame];
344
+ }
345
+ /** The drawing code moves down one row per line: a line with a line break inside would shift the whole frame. */
346
+ oneRow(line) {
347
+ return line.replace(/[\r\n\x00-\x08\x0b-\x1a\x1c-\x1f]+/g, ' ');
348
+ }
349
+ /** Wipes the frame: everything under the cursor row. The cursor stays on its text. */
350
+ eraseSeq() {
351
+ if (!this.shown)
352
+ return '';
353
+ this.shown = false;
354
+ return '\x1b7\x1b[1B\r\x1b[J\x1b8';
355
+ }
356
+ /** Draws the frame under the cursor row and puts the cursor back where the text goes on. */
357
+ drawSeq() {
358
+ const lines = this.lines;
359
+ if (lines.length === 0)
360
+ return '';
361
+ this.shown = true;
362
+ const n = lines.length;
363
+ // The real cursor only marks where the output goes on. Whatever showed it in the meantime
364
+ // (an approval card, a spinner that stopped) must not leave it blinking under the output.
365
+ // ESC D moves down (scrolling at the bottom edge) and keeps the column; then back up.
366
+ let seq = '\x1b[?25l' + '\x1bD'.repeat(n) + `\x1b[${n}A` + '\x1b7';
367
+ for (const line of lines)
368
+ seq += `\x1b[1B\r\x1b[2K${line}\x1b[0m`;
369
+ return seq + '\x1b8';
370
+ }
371
+ }