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
@@ -1,11 +1,13 @@
1
1
  import pc from 'picocolors';
2
2
  import { scanProjectFiles } from './files.js';
3
- import { saveClipboardImage, processPastedFilePath } from './clipboard.js';
3
+ import { IMAGE_TAG_SOURCE, imageTag, pruneMedia, processPastedFilePath, readClipboardText, saveClipboardImage } from './clipboard.js';
4
4
  import { getCurrentTheme } from './theme.js';
5
5
  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';
10
+ import { padEndWidth, truncateWidth } from './width.js';
9
11
  export const SLASH_COMMANDS = [
10
12
  { cmd: '/new', desc: 'Start a new clean chat session' },
11
13
  { cmd: '/resume', desc: 'Resume a previous chat session' },
@@ -14,23 +16,28 @@ export const SLASH_COMMANDS = [
14
16
  { cmd: '/usage', desc: 'Show network bandwidth, data saver & token cost' },
15
17
  { cmd: '/export', desc: 'Export session conversation to Markdown' },
16
18
  { cmd: '/mcp', desc: 'Manage Model Context Protocol (MCP) servers & tools' },
17
- { cmd: '/theme', desc: 'Switch UI theme (Cyan, Purple, Matrix, Amber, etc.)' },
19
+ { cmd: '/theme', desc: 'Switch UI theme (18 palettes: Nord, Dracula, Tokyo Night...)' },
18
20
  { cmd: '/doctor', desc: 'Run system & environment health diagnostics' },
19
21
  { cmd: '/settings', desc: 'Configure permissions & auto-approval' },
20
22
  { cmd: '/update', desc: 'Check and install updates from GitHub' },
21
23
  { cmd: '/model', desc: 'Switch model for current provider' },
24
+ { cmd: '/effort', desc: 'Set the reasoning effort for the current model' },
22
25
  { cmd: '/provider', desc: 'Switch AI provider (OpenRouter, Google, etc.)' },
23
26
  { cmd: '/plan', desc: 'Switch to PLAN mode (architect & planner)' },
24
27
  { cmd: '/agent', desc: 'Switch to AGENT mode (coder & executor)' },
25
- { cmd: '/image', desc: 'Paste image from clipboard as [1.png 203kb]' },
28
+ { cmd: '/image', desc: 'Paste the image from the clipboard (same as Ctrl+V or Alt+V)' },
26
29
  { cmd: '/serve', desc: 'Start local web server for web/game preview' },
30
+ { cmd: '/copy', desc: 'Copy the last answer of the AI to the clipboard' },
31
+ { cmd: '/rewind', desc: 'Go back to one of your messages: the chat after it is removed' },
32
+ { cmd: '/rename', desc: 'Rename the chat (/rename <name>), or let the AI name it again' },
27
33
  { cmd: '/memory', desc: 'View, add, or clear project memory bank' },
34
+ { cmd: '/permissions', desc: 'What is allowed without asking; /permissions clear resets it' },
28
35
  { cmd: '/undo', desc: 'Revert last file changes made by AI' },
29
36
  { cmd: '/diff', desc: 'Show git diff of modified project files' },
30
37
  { cmd: '/commit', desc: 'AI-generated git commit message & commit' },
31
38
  { cmd: '/status', desc: 'Show git repository file status' },
32
39
  { cmd: '/compact', desc: 'Compact & summarize chat context' },
33
- { cmd: '/clear', desc: 'Clear screen' },
40
+ { cmd: '/clear', desc: 'Clear context and start a new session' },
34
41
  { cmd: '/init', desc: 'Generate AGENTS.md project instructions' },
35
42
  { cmd: '/config', desc: 'View current configuration' },
36
43
  { cmd: '/help', desc: 'Show all available commands' },
@@ -61,6 +68,7 @@ export function askPrompt(opts = {}) {
61
68
  let availableFiles = [];
62
69
  let customCommandsList = [];
63
70
  scanProjectFiles().then((f) => (availableFiles = f)).catch(() => { });
71
+ void pruneMedia().catch(() => { });
64
72
  CustomCommandManager.listCommands()
65
73
  .then((c) => (customCommandsList = c.map((x) => ({ cmd: x.cmd, desc: x.desc }))))
66
74
  .catch(() => { });
@@ -82,19 +90,99 @@ export function askPrompt(opts = {}) {
82
90
  let nextPasteId = 1;
83
91
  let inBracketedPaste = false;
84
92
  let bracketedBuffer = '';
85
- const placeholder = opts.placeholder || 'Describe a task, @file, /help, paste image, or press Tab to switch mode';
93
+ const placeholder = opts.placeholder || 'Describe a task, @file, /help, Ctrl+V pastes an image, or press Tab to switch mode';
86
94
  let disposed = false;
87
95
  process.stdin.resume();
88
96
  if (process.stdin.isTTY) {
89
97
  process.stdin.setRawMode(true);
90
98
  process.stdout.write('\x1b[?2004h'); // Enable bracketed paste mode
91
99
  }
92
- const onResize = () => { if (!disposed)
93
- render(); };
100
+ // ── framed prompt (clean layout) ──
101
+ // The frame is a block of rows; the cursor rests on the input row. Redrawing moves up
102
+ // to the top of the block, rewrites every row and returns to the cursor position.
103
+ let frameRowsDrawn = 0;
104
+ let frameCursorRow = 0;
105
+ function cleanDropdownRows(items, cols, rows) {
106
+ if (items.length === 0)
107
+ return [];
108
+ if (selectedIndex >= items.length)
109
+ selectedIndex = items.length - 1;
110
+ if (selectedIndex < 0)
111
+ selectedIndex = 0;
112
+ const pageSize = Math.max(2, Math.min(6, rows - 7));
113
+ let startIndex = 0;
114
+ if (items.length > pageSize && selectedIndex >= pageSize) {
115
+ startIndex = Math.min(selectedIndex - pageSize + 1, items.length - pageSize);
116
+ }
117
+ const endIndex = Math.min(startIndex + pageSize, items.length);
118
+ const labelWidth = Math.min(22, Math.max(8, Math.floor(cols * 0.4)));
119
+ const descRoom = cols - 1 - 2 - 2 - labelWidth - 2;
120
+ const out = [];
121
+ for (let i = startIndex; i < endIndex; i++) {
122
+ const item = items[i];
123
+ const selected = i === selectedIndex;
124
+ const label = truncateWidth(item.label, labelWidth);
125
+ const labelCell = padEndWidth(selected ? palette.bold(palette.accent(label)) : palette.text(label), labelWidth);
126
+ const desc = descRoom >= 6 ? truncateWidth(item.desc, descRoom) : '';
127
+ out.push(`${selected ? palette.accent('›') : ' '} ${labelCell}${desc ? ' ' + palette.faint(desc) : ''}`);
128
+ }
129
+ return out;
130
+ }
131
+ function drawFrame() {
132
+ const cols = process.stdout.columns || 80;
133
+ const rows = process.stdout.rows || 24;
134
+ const frame = buildPromptFrame({
135
+ cols,
136
+ mode: resolveMode(!!opts.planMode, !!opts.isYolo),
137
+ model: opts.model,
138
+ cwd: opts.cwd,
139
+ branch: opts.branch,
140
+ dirty: opts.dirty,
141
+ contextPercent: opts.contextPercent ?? 0,
142
+ cost: opts.cost ?? 0,
143
+ costState: opts.costState,
144
+ input,
145
+ cursorPos,
146
+ placeholder,
147
+ below: cleanDropdownRows(getDropdownItems(), cols, rows),
148
+ });
149
+ let buf = '\x1b[0m';
150
+ if (frameRowsDrawn > 0 && frameCursorRow > 0)
151
+ buf += `\x1b[${frameCursorRow}A`;
152
+ buf += '\r';
153
+ frame.lines.forEach((line, i) => {
154
+ buf += '\x1b[2K' + line + '\x1b[0m' + (i < frame.lines.length - 1 ? '\n' : '');
155
+ });
156
+ let rowNow = frame.lines.length - 1;
157
+ for (let k = frame.lines.length; k < frameRowsDrawn; k++) {
158
+ buf += '\n\x1b[2K'; // wipe rows left over from a taller previous frame
159
+ rowNow++;
160
+ }
161
+ if (rowNow > frame.cursorRow)
162
+ buf += `\x1b[${rowNow - frame.cursorRow}A`;
163
+ buf += '\r' + (frame.cursorCol > 0 ? `\x1b[${frame.cursorCol}C` : '');
164
+ process.stdout.write(buf);
165
+ frameRowsDrawn = frame.lines.length;
166
+ frameCursorRow = frame.cursorRow;
167
+ }
168
+ function eraseFrame() {
169
+ if (frameRowsDrawn === 0)
170
+ return;
171
+ process.stdout.write(`\x1b[0m${frameCursorRow > 0 ? `\x1b[${frameCursorRow}A` : ''}\r\x1b[J`);
172
+ frameRowsDrawn = 0;
173
+ frameCursorRow = 0;
174
+ }
175
+ const onResize = () => {
176
+ if (disposed)
177
+ return;
178
+ if (cleanLayout)
179
+ eraseFrame();
180
+ render();
181
+ };
94
182
  process.stdout.on('resize', onResize);
95
183
  function getTagSpans() {
96
184
  const spans = [];
97
- const regex = /\[(?:Pasted text #\d+ \+\d+ lines|\d+\.png \d+kb)\]/g;
185
+ const regex = new RegExp(`\\[Pasted text #\\d+ \\+\\d+ lines\\]|${IMAGE_TAG_SOURCE}`, 'g');
98
186
  let match;
99
187
  while ((match = regex.exec(input)) !== null) {
100
188
  spans.push({ start: match.index, end: match.index + match[0].length });
@@ -102,7 +190,16 @@ export function askPrompt(opts = {}) {
102
190
  return spans;
103
191
  }
104
192
  function formatInputWithBadges(str) {
105
- return str.replace(/(\[(?:Pasted text #\d+ \+\d+ lines|\d+\.png \d+kb)\])/g, (match) => (cleanLayout ? pc.dim(match) : theme.badgeFn(` ${match.slice(1, -1)} `)) + '\x1b[0m');
193
+ return str.replace(new RegExp(`(\\[Pasted text #\\d+ \\+\\d+ lines\\]|${IMAGE_TAG_SOURCE})`, 'g'), (match) => (cleanLayout ? pc.dim(match) : theme.badgeFn(` ${match.slice(1, -1)} `)) + '\x1b[0m');
194
+ }
195
+ /** What the echo of the sent message shows: pasted text in full (images stay as their badges). */
196
+ function echoText(text) {
197
+ let shown = text;
198
+ for (const block of pastedBlocks)
199
+ shown = shown.split(block.tag).join(block.text);
200
+ const lines = shown.replace(/\t/g, ' ').split('\n');
201
+ const MAX = 300; // a huge paste must not flood the screen: the model still gets all of it
202
+ return lines.length > MAX ? [...lines.slice(0, MAX), `… +${lines.length - MAX} lines`].join('\n') : shown;
106
203
  }
107
204
  function expandPastes(text) {
108
205
  let expanded = text;
@@ -110,39 +207,90 @@ export function askPrompt(opts = {}) {
110
207
  expanded = expanded.split(block.tag).join(block.text);
111
208
  }
112
209
  for (const img of imageAttachments) {
113
- expanded = expanded.split(img.tag).join(`@${img.filePath.replace(/\\/g, '/')} `);
210
+ const p = img.filePath.replace(/\\/g, '/');
211
+ expanded = expanded.split(img.tag).join(/^[A-Za-z0-9_\-./\u0400-\u04FF]+$/.test(p) ? `@${p} ` : `@"${p}" `);
114
212
  }
115
213
  return expanded;
116
214
  }
117
- function handlePaste(text) {
215
+ let imageCounter = 0;
216
+ let pasting = false;
217
+ /** Puts an image into the input as a badge; the file path goes into the message when it is sent. */
218
+ function attachImage(img) {
219
+ const tag = imageTag(++imageCounter, img.sizeStr);
220
+ imageAttachments.push({ tag, fileName: img.fileName, filePath: img.filePath, sizeStr: img.sizeStr });
221
+ usedImageNames.add(img.fileName);
222
+ input = input.slice(0, cursorPos) + tag + ' ' + input.slice(cursorPos);
223
+ cursorPos += tag.length + 1;
224
+ render();
225
+ }
226
+ /** A one-line message above the prompt (it stays in the scrollback, like other notices). */
227
+ function say(text) {
228
+ if (cleanLayout)
229
+ eraseFrame();
230
+ process.stdout.write(`\x1b[0m\r\x1b[2K${pc.yellow(text)}\n`);
231
+ render();
232
+ }
233
+ /**
234
+ * Ctrl+V, Alt+V and the /image command: the image on the clipboard if there is one,
235
+ * otherwise the text. (A terminal only pastes text by itself; an image needs this.)
236
+ */
237
+ async function pasteFromClipboard() {
238
+ if (pasting)
239
+ return;
240
+ pasting = true;
241
+ try {
242
+ const img = await saveClipboardImage('image.png', usedImageNames);
243
+ if (disposed)
244
+ return;
245
+ if (img) {
246
+ attachImage(img);
247
+ return;
248
+ }
249
+ const text = await readClipboardText();
250
+ if (disposed)
251
+ return;
252
+ if (text) {
253
+ handlePaste(text);
254
+ return;
255
+ }
256
+ const termux = !!process.env.PREFIX?.includes('com.termux');
257
+ say(termux
258
+ ? 'Nothing to paste. On Termux an image cannot be read from the clipboard: give the file path instead (@image.png).'
259
+ : 'Nothing to paste: the clipboard has no image or text. (Wayland needs wl-clipboard, X11 needs xclip.)');
260
+ }
261
+ finally {
262
+ pasting = false;
263
+ }
264
+ }
265
+ function handlePaste(rawText) {
266
+ // A short single-line chunk often arrives together with its Enter (fast typing, IME):
267
+ // a stray CR / LF must never end up inside the input line.
268
+ const text = rawText.replace(/(?:\r\n|\r|\n)+$/, '');
118
269
  const trimmed = text.trim();
119
- const lines = trimmed.split(/\r\n|\r|\n/);
120
- if (lines.length <= 1 && text.length < 80) {
121
- input = input.slice(0, cursorPos) + text + input.slice(cursorPos);
122
- cursorPos += text.length;
123
- render();
270
+ // Some terminals send an empty paste when the clipboard holds only an image.
271
+ if (!trimmed) {
272
+ void pasteFromClipboard();
124
273
  return;
125
274
  }
126
- if (trimmed.match(/\.(png|jpe?g|webp|gif|bmp)$/i)) {
275
+ const lines = trimmed.split(/\r\n|\r|\n/);
276
+ // A path to an image (dragged into the terminal or copied from a file manager).
277
+ if (lines.length === 1 && /\.(png|jpe?g|webp|gif)['"]?$/i.test(trimmed)) {
127
278
  processPastedFilePath(trimmed, usedImageNames)
128
279
  .then((imgRes) => {
129
- if (imgRes && !disposed) {
130
- const tag = `[${imgRes.fileName} ${imgRes.sizeStr}]`;
131
- imageAttachments.push({
132
- tag,
133
- fileName: imgRes.fileName,
134
- filePath: imgRes.filePath,
135
- sizeStr: imgRes.sizeStr
136
- });
137
- usedImageNames.add(imgRes.fileName);
138
- input = input.slice(0, cursorPos) + tag + ' ' + input.slice(cursorPos);
139
- cursorPos += tag.length + 1;
140
- render();
141
- }
280
+ if (disposed)
281
+ return;
282
+ if (imgRes)
283
+ attachImage(imgRes);
284
+ else
285
+ insertText(text);
142
286
  })
143
287
  .catch(() => { });
144
288
  return;
145
289
  }
290
+ if (lines.length <= 1 && text.length < 80) {
291
+ insertText(text);
292
+ return;
293
+ }
146
294
  const id = nextPasteId++;
147
295
  const tag = `[Pasted text #${id} +${lines.length} lines]`;
148
296
  pastedBlocks.push({ id, lines: lines.length, text, tag });
@@ -150,6 +298,11 @@ export function askPrompt(opts = {}) {
150
298
  cursorPos += tag.length;
151
299
  render();
152
300
  }
301
+ function insertText(text) {
302
+ input = input.slice(0, cursorPos) + text + input.slice(cursorPos);
303
+ cursorPos += text.length;
304
+ render();
305
+ }
153
306
  function getDropdownItems() {
154
307
  if (input.startsWith('/')) {
155
308
  const q = input.trim().toLowerCase();
@@ -204,6 +357,10 @@ export function askPrompt(opts = {}) {
204
357
  function render() {
205
358
  if (disposed)
206
359
  return;
360
+ if (cleanLayout) {
361
+ drawFrame();
362
+ return;
363
+ }
207
364
  const cols = Math.max(20, process.stdout.columns || 40);
208
365
  const rows = Math.max(8, process.stdout.rows || 20);
209
366
  const items = getDropdownItems();
@@ -359,6 +516,20 @@ export function askPrompt(opts = {}) {
359
516
  process.stdin.pause();
360
517
  }
361
518
  function clearBoxAndExit(finalInput) {
519
+ if (cleanLayout) {
520
+ eraseFrame();
521
+ const fullTextClean = expandPastes(finalInput);
522
+ if (fullTextClean.trim()) {
523
+ if (GLOBAL_PROMPT_HISTORY.length === 0 ||
524
+ GLOBAL_PROMPT_HISTORY[GLOBAL_PROMPT_HISTORY.length - 1] !== fullTextClean.trim()) {
525
+ GLOBAL_PROMPT_HISTORY.push(fullTextClean.trim());
526
+ }
527
+ }
528
+ process.stdout.write(renderUserEcho(echoText(finalInput), process.stdout.columns || 80).join('\n') + '\n');
529
+ cleanup();
530
+ resolve(fullTextClean);
531
+ return;
532
+ }
362
533
  if (lastRenderedDropdownLines > 0) {
363
534
  for (let i = 0; i < lastRenderedDropdownLines; i++) {
364
535
  process.stdout.write(`\x1b[1B\r\x1b[0m\x1b[2K`);
@@ -410,6 +581,19 @@ export function askPrompt(opts = {}) {
410
581
  return;
411
582
  }
412
583
  }
584
+ // Ctrl+P and Ctrl+O: fold or unfold the reasoning / the tool output of the whole chat.
585
+ const viewKey = str === '\x10' || str === '\x1b[112;5u' ? 'thinking' : str === '\x0f' || str === '\x1b[111;5u' ? 'output' : null;
586
+ if (viewKey && opts.onViewToggle && cleanLayout) {
587
+ eraseFrame();
588
+ opts.onViewToggle(viewKey);
589
+ render();
590
+ return;
591
+ }
592
+ // Ctrl+V and Alt+V (also as the sequences of terminals with an enhanced keyboard mode).
593
+ if (str === '\x16' || str === '\x1bv' || str === '\x1bV' || str === '\x1b[118;5u' || str === '\x1b[118;3u') {
594
+ void pasteFromClipboard();
595
+ return;
596
+ }
413
597
  // Check for raw multi-character paste with newlines or large size
414
598
  if (str.length > 1 && (str.includes('\n') || str.includes('\r') || str.length > 80)) {
415
599
  handlePaste(str);
@@ -421,6 +605,12 @@ export function askPrompt(opts = {}) {
421
605
  }
422
606
  // Ctrl+C / SIGINT
423
607
  if (str === '\x03') {
608
+ if (cleanLayout) {
609
+ eraseFrame();
610
+ cleanup();
611
+ resolve('__CANCEL__');
612
+ return;
613
+ }
424
614
  if (lastRenderedDropdownLines > 0) {
425
615
  for (let i = 0; i < lastRenderedDropdownLines; i++) {
426
616
  process.stdout.write(`\x1b[1B\r\x1b[0m\x1b[2K`);
@@ -444,29 +634,9 @@ export function askPrompt(opts = {}) {
444
634
  selectedIndex >= 0 &&
445
635
  selectedIndex < items.length &&
446
636
  items[selectedIndex].label === '/image')) {
447
- saveClipboardImage('image.png', usedImageNames)
448
- .then((imgRes) => {
449
- if (imgRes) {
450
- const tag = `[${imgRes.fileName} ${imgRes.sizeStr}]`;
451
- imageAttachments.push({
452
- tag,
453
- fileName: imgRes.fileName,
454
- filePath: imgRes.filePath,
455
- sizeStr: imgRes.sizeStr
456
- });
457
- usedImageNames.add(imgRes.fileName);
458
- input = `${tag} `;
459
- cursorPos = input.length;
460
- render();
461
- }
462
- else {
463
- process.stdout.write(`\x1b[0m\r\x1b[2K${pc.yellow('⚠️ No image in clipboard. Take a screenshot first (Win+Shift+S)\n')}`);
464
- input = '';
465
- cursorPos = 0;
466
- render();
467
- }
468
- })
469
- .catch(() => { });
637
+ input = '';
638
+ cursorPos = 0;
639
+ void pasteFromClipboard();
470
640
  return;
471
641
  }
472
642
  if (items.length > 0 && selectedIndex >= 0 && selectedIndex < items.length) {
@@ -511,7 +681,10 @@ export function askPrompt(opts = {}) {
511
681
  }
512
682
  }
513
683
  else {
514
- process.stdout.write(`\x1b[0m\r\x1b[2K`);
684
+ if (cleanLayout)
685
+ eraseFrame();
686
+ else
687
+ process.stdout.write(`\x1b[0m\r\x1b[2K`);
515
688
  cleanup();
516
689
  resolve(`__TOGGLE_MODE__:${input}`);
517
690
  }
@@ -520,7 +693,7 @@ export function askPrompt(opts = {}) {
520
693
  // Up Arrow
521
694
  if (str === '\x1b[A' || str === '\x1bOA') {
522
695
  if (items.length > 0) {
523
- selectedIndex = (selectedIndex - 1 + items.length) % items.length;
696
+ selectedIndex = Math.max(0, selectedIndex - 1); // stops at the first item
524
697
  render();
525
698
  }
526
699
  else {
@@ -536,7 +709,7 @@ export function askPrompt(opts = {}) {
536
709
  // Down Arrow
537
710
  if (str === '\x1b[B' || str === '\x1bOB') {
538
711
  if (items.length > 0) {
539
- selectedIndex = (selectedIndex + 1) % items.length;
712
+ selectedIndex = Math.min(items.length - 1, selectedIndex + 1); // stops at the last item
540
713
  render();
541
714
  }
542
715
  else {
@@ -613,6 +786,33 @@ export function askPrompt(opts = {}) {
613
786
  }
614
787
  return;
615
788
  }
789
+ // Ctrl+Backspace (0x08 in most terminals), Alt+Backspace (ESC DEL) and Ctrl+W: erase a word.
790
+ // A pasted-text badge counts as one word.
791
+ if (str === '\x08' || str === '\x1b\x7f' || str === '\x17') {
792
+ if (cursorPos > 0) {
793
+ const spans = getTagSpans();
794
+ const endingSpan = spans.find((s) => s.end === cursorPos);
795
+ let start = cursorPos;
796
+ if (endingSpan) {
797
+ start = endingSpan.start;
798
+ }
799
+ else {
800
+ const isWordChar = (c) => /[\p{L}\p{N}_]/u.test(c);
801
+ while (start > 0 && !isWordChar(input[start - 1]))
802
+ start--;
803
+ while (start > 0 && isWordChar(input[start - 1]))
804
+ start--;
805
+ const inside = spans.find((s) => start > s.start && start < s.end);
806
+ if (inside)
807
+ start = inside.end;
808
+ }
809
+ input = input.slice(0, start) + input.slice(cursorPos);
810
+ cursorPos = start;
811
+ selectedIndex = 0;
812
+ render();
813
+ }
814
+ return;
815
+ }
616
816
  // Backspace / DEL handling
617
817
  if (str.includes('\x08') || str.includes('\x7f')) {
618
818
  for (let i = 0; i < str.length; i++) {
@@ -0,0 +1,15 @@
1
+ import { palette, renderNotice } from './ui.js';
2
+ import { displayWidth, truncateWidth } from './width.js';
3
+ import { messagesToItems, renderTranscript } from './transcript.js';
4
+ export function renderResumedMessages(messages, info) {
5
+ const { cols } = info;
6
+ const label = info.title.replace(/\s+/g, ' ').trim();
7
+ const count = info.total > info.shown ? `${info.shown} / ${info.total}` : String(info.total);
8
+ const head = ` ${palette.faint('╌')} ${palette.muted(truncateWidth(label, Math.max(10, cols - 14 - displayWidth(count))))} ${palette.faint(count)}`;
9
+ const body = renderTranscript(messagesToItems(messages), {
10
+ cols,
11
+ thinkingExpanded: !!info.thinkingExpanded,
12
+ outputExpanded: !!info.outputExpanded,
13
+ });
14
+ return ['', head, '', ...body, '', renderNotice('ok', `resumed · ${info.total} messages`, cols), ''];
15
+ }
@@ -0,0 +1,51 @@
1
+ import { truncateWidth } from './width.js';
2
+ import { t } from '../core/i18n.js';
3
+ /**
4
+ * /rewind: go back to one of your own messages. The conversation after it is removed (the model
5
+ * forgets it too), and the message comes back into the input line to be edited.
6
+ */
7
+ export const REWIND_CURRENT = -1;
8
+ /** "5 minutes ago" in the chosen language; empty when the message has no time (an old session). */
9
+ export function timeAgo(at, now = Date.now()) {
10
+ if (!at || !Number.isFinite(at))
11
+ return '';
12
+ const seconds = Math.max(0, Math.round((now - at) / 1000));
13
+ if (seconds < 60)
14
+ return t('rewind.ago.now');
15
+ const minutes = Math.round(seconds / 60);
16
+ if (minutes < 60)
17
+ return t('rewind.ago.min', { n: minutes });
18
+ const hours = Math.round(minutes / 60);
19
+ if (hours < 24)
20
+ return t('rewind.ago.hour', { n: hours });
21
+ return t('rewind.ago.day', { n: Math.round(hours / 24) });
22
+ }
23
+ /** What of a user message is shown: its first line, without the files that were attached to it. */
24
+ export function messageLabel(content, max = 52) {
25
+ const typed = content.split('\n\n--- Attached Files ---')[0];
26
+ const first = typed.split('\n').map((l) => l.trim()).find((l) => l.length > 0) ?? '';
27
+ return truncateWidth(first.replace(/\s+/g, ' '), max);
28
+ }
29
+ /** The text that goes back into the input line. */
30
+ export function messageDraft(content) {
31
+ return content.split('\n\n--- Attached Files ---')[0].trim();
32
+ }
33
+ export function userEntries(messages) {
34
+ const out = [];
35
+ messages.forEach((message, index) => {
36
+ if (message.role === 'user')
37
+ out.push({ index, message });
38
+ });
39
+ return out;
40
+ }
41
+ /** The items of the menu: your messages, oldest first, and "(current)" last, where the cursor starts. */
42
+ export function rewindItems(messages, now = Date.now()) {
43
+ const entries = userEntries(messages);
44
+ const items = entries.map((e, n) => {
45
+ const later = entries.length - 1 - n;
46
+ const parts = [timeAgo(e.message.at, now), later > 0 ? t('rewind.later', { n: later }) : ''].filter(Boolean);
47
+ return { value: e.index, label: messageLabel(e.message.content), detail: parts.join(' · ') };
48
+ });
49
+ items.push({ value: REWIND_CURRENT, label: t('rewind.current'), detail: '' });
50
+ return items;
51
+ }
@@ -0,0 +1,6 @@
1
+ export function reportRuntimeError(error, abort = null) {
2
+ const details = error instanceof Error ? error.stack || error.message : String(error);
3
+ console.error('\n[devx runtime error]:', details);
4
+ process.exitCode = 1;
5
+ abort?.();
6
+ }