termux-dev 1.5.0 → 1.8.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 (98) hide show
  1. package/README.md +215 -163
  2. package/assets/banner.svg +23 -28
  3. package/assets/preview.png +0 -0
  4. package/assets/preview.svg +94 -0
  5. package/dist/cli/banner.js +7 -3
  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/headless.js +29 -14
  15. package/dist/cli/index.js +1395 -474
  16. package/dist/cli/keys.js +69 -0
  17. package/dist/cli/liveblock.js +45 -0
  18. package/dist/cli/log.js +19 -0
  19. package/dist/cli/markdown.js +158 -36
  20. package/dist/cli/mcp-panel.js +432 -0
  21. package/dist/cli/permission.js +84 -0
  22. package/dist/cli/picker.js +135 -0
  23. package/dist/cli/prompt.js +338 -121
  24. package/dist/cli/prompt_palette.js +6 -0
  25. package/dist/cli/resume.js +15 -0
  26. package/dist/cli/rewind.js +51 -0
  27. package/dist/cli/runtime_errors.js +6 -0
  28. package/dist/cli/server.js +85 -24
  29. package/dist/cli/settings.js +307 -0
  30. package/dist/cli/statusline.js +27 -1
  31. package/dist/cli/theme.js +126 -108
  32. package/dist/cli/transcript.js +114 -0
  33. package/dist/cli/typeahead.js +371 -0
  34. package/dist/cli/ui.js +1021 -0
  35. package/dist/cli/updater.js +23 -21
  36. package/dist/cli/usage-panel.js +242 -0
  37. package/dist/cli/width.js +178 -0
  38. package/dist/core/capabilities.js +433 -0
  39. package/dist/core/catalog-util.js +27 -0
  40. package/dist/core/checkpoint.js +90 -0
  41. package/dist/core/context-stats.js +29 -0
  42. package/dist/core/cost.js +191 -0
  43. package/dist/core/formats.js +199 -0
  44. package/dist/core/history.js +51 -1
  45. package/dist/core/hooks.js +79 -0
  46. package/dist/core/i18n.js +42 -0
  47. package/dist/core/local-config.js +69 -0
  48. package/dist/core/locales/de.js +210 -0
  49. package/dist/core/locales/en.js +221 -0
  50. package/dist/core/locales/es.js +210 -0
  51. package/dist/core/locales/fr.js +210 -0
  52. package/dist/core/locales/ja.js +210 -0
  53. package/dist/core/locales/pt.js +210 -0
  54. package/dist/core/locales/ru.js +210 -0
  55. package/dist/core/locales/uk.js +210 -0
  56. package/dist/core/locales/zh.js +210 -0
  57. package/dist/core/loop.js +125 -49
  58. package/dist/core/memory.js +39 -12
  59. package/dist/core/models.js +33 -20
  60. package/dist/core/network.js +12 -11
  61. package/dist/core/notify.js +54 -0
  62. package/dist/core/planfile.js +43 -0
  63. package/dist/core/price-catalog.json +1 -0
  64. package/dist/core/prices.js +249 -0
  65. package/dist/core/reasoning-catalog.json +1 -0
  66. package/dist/core/reasoning.js +393 -0
  67. package/dist/core/redact.js +41 -0
  68. package/dist/core/secure-file.js +22 -0
  69. package/dist/core/session.js +16 -5
  70. package/dist/core/snapshot.js +14 -2
  71. package/dist/core/title.js +61 -0
  72. package/dist/core/types.js +1 -1
  73. package/dist/core/usage.js +43 -7
  74. package/dist/core/vision-catalog.json +1 -0
  75. package/dist/core/vision.js +123 -0
  76. package/dist/mcp/client.js +36 -5
  77. package/dist/mcp/http-client.js +174 -0
  78. package/dist/mcp/manager.js +330 -93
  79. package/dist/permissions/guard.js +385 -117
  80. package/dist/prompts/builder.js +46 -38
  81. package/dist/providers/anthropic.js +517 -0
  82. package/dist/providers/index.js +18 -1
  83. package/dist/providers/openai.js +138 -125
  84. package/dist/providers/shared.js +116 -0
  85. package/dist/providers/toolguard.js +101 -0
  86. package/dist/tools/bash.js +141 -51
  87. package/dist/tools/diagnostics.js +76 -48
  88. package/dist/tools/fs.js +77 -5
  89. package/dist/tools/index.js +2 -1
  90. package/dist/tools/mcp.js +72 -0
  91. package/dist/tools/packages.js +16 -1
  92. package/dist/tools/plan.js +8 -8
  93. package/dist/tools/questions.js +50 -54
  94. package/dist/tools/server.js +6 -0
  95. package/dist/tools/todo.js +20 -18
  96. package/dist/tools/web.js +143 -8
  97. package/package.json +4 -2
  98. package/assets/preview_work1.png +0 -0
@@ -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 & redraw banner' },
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' },
@@ -43,19 +50,25 @@ function stripAnsi(str) {
43
50
  export function askPrompt(opts = {}) {
44
51
  return new Promise((resolve) => {
45
52
  const theme = getCurrentTheme();
46
- if (opts.ui?.modeBorder) {
47
- const header = formatPromptHeader(!!opts.planMode, !!opts.isYolo, opts.message);
48
- console.log(header);
49
- }
50
- else {
51
- const msg = opts.message
52
- ? `${opts.message} ${pc.dim(`(Tab = ${opts.planMode ? 'AGENT' : 'PLAN'})`)}`
53
- : `Ask anything... ${pc.dim(`(Tab = ${opts.planMode ? 'AGENT' : 'PLAN'})`)}`;
54
- console.log(theme.colorFn('◆') + ' ' + pc.bold(msg));
53
+ const cleanLayout = opts.ui?.layout !== 'classic';
54
+ const promptPrefix = cleanLayout ? pc.cyan('›') + ' ' : pc.dim('│') + ' ';
55
+ const promptPrefixWidth = cleanLayout ? 2 : 3;
56
+ if (!cleanLayout) {
57
+ if (opts.ui?.modeBorder) {
58
+ const header = formatPromptHeader(!!opts.planMode, !!opts.isYolo, opts.message);
59
+ console.log(header);
60
+ }
61
+ else {
62
+ const msg = opts.message
63
+ ? `${opts.message} ${pc.dim(`(Tab = ${opts.planMode ? 'AGENT' : 'PLAN'})`)}`
64
+ : `Ask anything... ${pc.dim(`(Tab = ${opts.planMode ? 'AGENT' : 'PLAN'})`)}`;
65
+ console.log(theme.colorFn('◆') + ' ' + pc.bold(msg));
66
+ }
55
67
  }
56
68
  let availableFiles = [];
57
69
  let customCommandsList = [];
58
70
  scanProjectFiles().then((f) => (availableFiles = f)).catch(() => { });
71
+ void pruneMedia().catch(() => { });
59
72
  CustomCommandManager.listCommands()
60
73
  .then((c) => (customCommandsList = c.map((x) => ({ cmd: x.cmd, desc: x.desc }))))
61
74
  .catch(() => { });
@@ -77,19 +90,99 @@ export function askPrompt(opts = {}) {
77
90
  let nextPasteId = 1;
78
91
  let inBracketedPaste = false;
79
92
  let bracketedBuffer = '';
80
- 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';
81
94
  let disposed = false;
82
95
  process.stdin.resume();
83
96
  if (process.stdin.isTTY) {
84
97
  process.stdin.setRawMode(true);
85
98
  process.stdout.write('\x1b[?2004h'); // Enable bracketed paste mode
86
99
  }
87
- const onResize = () => { if (!disposed)
88
- 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
+ };
89
182
  process.stdout.on('resize', onResize);
90
183
  function getTagSpans() {
91
184
  const spans = [];
92
- 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');
93
186
  let match;
94
187
  while ((match = regex.exec(input)) !== null) {
95
188
  spans.push({ start: match.index, end: match.index + match[0].length });
@@ -97,7 +190,16 @@ export function askPrompt(opts = {}) {
97
190
  return spans;
98
191
  }
99
192
  function formatInputWithBadges(str) {
100
- return str.replace(/(\[(?:Pasted text #\d+ \+\d+ lines|\d+\.png \d+kb)\])/g, (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;
101
203
  }
102
204
  function expandPastes(text) {
103
205
  let expanded = text;
@@ -105,39 +207,90 @@ export function askPrompt(opts = {}) {
105
207
  expanded = expanded.split(block.tag).join(block.text);
106
208
  }
107
209
  for (const img of imageAttachments) {
108
- 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}" `);
109
212
  }
110
213
  return expanded;
111
214
  }
112
- 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)+$/, '');
113
269
  const trimmed = text.trim();
114
- const lines = trimmed.split(/\r\n|\r|\n/);
115
- if (lines.length <= 1 && text.length < 80) {
116
- input = input.slice(0, cursorPos) + text + input.slice(cursorPos);
117
- cursorPos += text.length;
118
- render();
270
+ // Some terminals send an empty paste when the clipboard holds only an image.
271
+ if (!trimmed) {
272
+ void pasteFromClipboard();
119
273
  return;
120
274
  }
121
- 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)) {
122
278
  processPastedFilePath(trimmed, usedImageNames)
123
279
  .then((imgRes) => {
124
- if (imgRes && !disposed) {
125
- const tag = `[${imgRes.fileName} ${imgRes.sizeStr}]`;
126
- imageAttachments.push({
127
- tag,
128
- fileName: imgRes.fileName,
129
- filePath: imgRes.filePath,
130
- sizeStr: imgRes.sizeStr
131
- });
132
- usedImageNames.add(imgRes.fileName);
133
- input = input.slice(0, cursorPos) + tag + ' ' + input.slice(cursorPos);
134
- cursorPos += tag.length + 1;
135
- render();
136
- }
280
+ if (disposed)
281
+ return;
282
+ if (imgRes)
283
+ attachImage(imgRes);
284
+ else
285
+ insertText(text);
137
286
  })
138
287
  .catch(() => { });
139
288
  return;
140
289
  }
290
+ if (lines.length <= 1 && text.length < 80) {
291
+ insertText(text);
292
+ return;
293
+ }
141
294
  const id = nextPasteId++;
142
295
  const tag = `[Pasted text #${id} +${lines.length} lines]`;
143
296
  pastedBlocks.push({ id, lines: lines.length, text, tag });
@@ -145,10 +298,15 @@ export function askPrompt(opts = {}) {
145
298
  cursorPos += tag.length;
146
299
  render();
147
300
  }
301
+ function insertText(text) {
302
+ input = input.slice(0, cursorPos) + text + input.slice(cursorPos);
303
+ cursorPos += text.length;
304
+ render();
305
+ }
148
306
  function getDropdownItems() {
149
307
  if (input.startsWith('/')) {
150
308
  const q = input.trim().toLowerCase();
151
- if (opts.ui?.commandPalette) {
309
+ if (cleanLayout || opts.ui?.commandPalette) {
152
310
  const baseList = [
153
311
  ...SLASH_COMMANDS.map((c) => ({ cmd: c.cmd, desc: c.desc })),
154
312
  ...customCommandsList.map((c) => ({ cmd: c.cmd, desc: `(custom) ${c.desc}` }))
@@ -199,6 +357,10 @@ export function askPrompt(opts = {}) {
199
357
  function render() {
200
358
  if (disposed)
201
359
  return;
360
+ if (cleanLayout) {
361
+ drawFrame();
362
+ return;
363
+ }
202
364
  const cols = Math.max(20, process.stdout.columns || 40);
203
365
  const rows = Math.max(8, process.stdout.rows || 20);
204
366
  const items = getDropdownItems();
@@ -211,8 +373,6 @@ export function askPrompt(opts = {}) {
211
373
  // Limit dropdown height gracefully so it always fits comfortably
212
374
  const maxDropdownRows = Math.max(2, Math.min(rows - 3, 5));
213
375
  const pageSize = Math.min(4, Math.max(2, Math.min(maxDropdownRows - 2, Math.floor(rows / 4))));
214
- // Strict boundary: left margin (2) + box borders (2) + inner width <= cols - 4
215
- // This mathematically prevents terminal auto-wrapping on narrow mobile screens
216
376
  const boxWidth = Math.max(12, Math.min(cols - 6, 46));
217
377
  const innerWidth = Math.max(8, boxWidth - 2);
218
378
  const total = items.length;
@@ -223,72 +383,86 @@ export function askPrompt(opts = {}) {
223
383
  }
224
384
  }
225
385
  const endIndex = Math.min(startIndex + pageSize, total);
226
- const hasMoreUp = startIndex > 0;
227
- const hasMoreDown = endIndex < total;
228
- let topBorderStr = '─'.repeat(innerWidth);
229
- if (hasMoreUp) {
230
- const mid = Math.max(0, Math.floor(innerWidth / 2) - 2);
231
- topBorderStr = '─'.repeat(mid) + ' ▲ ' + '─'.repeat(Math.max(0, innerWidth - mid - 3));
232
- }
233
- let botBorderStr = '─'.repeat(innerWidth);
234
- if (hasMoreDown) {
235
- const mid = Math.max(0, Math.floor(innerWidth / 2) - 2);
236
- botBorderStr = '─'.repeat(mid) + ' ▼ ' + '─'.repeat(Math.max(0, innerWidth - mid - 3));
237
- }
238
- dropdownLines.push(pc.dim('│ ') + pc.dim('╭' + topBorderStr + '╮') + '\x1b[0m');
239
- for (let i = startIndex; i < endIndex; i++) {
240
- const item = items[i];
241
- const isSelected = i === selectedIndex;
242
- const pointer = isSelected ? '› ' : ' ';
243
- const avail = Math.max(4, innerWidth - pointer.length);
244
- let rowText = '';
245
- if (avail < 16) {
246
- const labelStr = item.label.length > avail
247
- ? item.label.slice(0, avail - 1) + '…'
248
- : item.label.padEnd(avail, ' ');
249
- rowText = pointer + (isSelected ? theme.boldFn(labelStr) : labelStr);
386
+ if (cleanLayout) {
387
+ for (let i = startIndex; i < endIndex; i++) {
388
+ const item = items[i];
389
+ const selected = i === selectedIndex;
390
+ const labelWidth = Math.min(20, cols - 5);
391
+ const label = item.label.length > labelWidth ? `${item.label.slice(0, labelWidth - 1)}…` : item.label;
392
+ const descWidth = cols - label.length - 7;
393
+ const desc = descWidth >= 5
394
+ ? item.desc.slice(0, descWidth)
395
+ : '';
396
+ dropdownLines.push(` ${selected ? pc.cyan('›') : ' '} ${selected ? pc.bold(label) : label}${desc ? ` ${pc.dim(desc)}` : ''}`);
250
397
  }
251
- else {
252
- const labelMax = Math.min(12, Math.max(6, Math.floor(avail * 0.38)));
253
- const labelStr = item.label.length > labelMax
254
- ? item.label.slice(0, labelMax - 1) + '…'
255
- : item.label.padEnd(labelMax, ' ');
256
- const descMax = Math.max(3, avail - labelMax - 1);
257
- const descStr = item.desc.length > descMax
258
- ? item.desc.slice(0, descMax - 1) + '…'
259
- : item.desc.padEnd(descMax, ' ');
260
- rowText = pointer + (isSelected ? theme.boldFn(labelStr) : labelStr) + ' ' + pc.gray(descStr);
398
+ }
399
+ else {
400
+ const hasMoreUp = startIndex > 0;
401
+ const hasMoreDown = endIndex < total;
402
+ let topBorderStr = '─'.repeat(innerWidth);
403
+ if (hasMoreUp) {
404
+ const mid = Math.max(0, Math.floor(innerWidth / 2) - 2);
405
+ topBorderStr = '─'.repeat(mid) + ' ▲ ' + '─'.repeat(Math.max(0, innerWidth - mid - 3));
261
406
  }
262
- // Calculate visible plain length without ANSI
263
- const plainLen = rowText.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '').length;
264
- const padCount = Math.max(0, innerWidth - plainLen);
265
- const fullRow = rowText + ' '.repeat(padCount);
266
- if (isSelected) {
267
- const cleanFull = fullRow.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
268
- dropdownLines.push(pc.dim('│ ') + pc.dim('│') + theme.badgeFn(cleanFull) + pc.dim('│') + '\x1b[0m');
407
+ let botBorderStr = '─'.repeat(innerWidth);
408
+ if (hasMoreDown) {
409
+ const mid = Math.max(0, Math.floor(innerWidth / 2) - 2);
410
+ botBorderStr = '─'.repeat(mid) + ' ▼ ' + '─'.repeat(Math.max(0, innerWidth - mid - 3));
269
411
  }
270
- else {
271
- dropdownLines.push(pc.dim('│ ') + pc.dim('│') + fullRow + pc.dim('│') + '\x1b[0m');
412
+ dropdownLines.push(pc.dim('│ ') + pc.dim('╭' + topBorderStr + '╮') + '\x1b[0m');
413
+ for (let i = startIndex; i < endIndex; i++) {
414
+ const item = items[i];
415
+ const isSelected = i === selectedIndex;
416
+ const pointer = isSelected ? '› ' : ' ';
417
+ const avail = Math.max(4, innerWidth - pointer.length);
418
+ let rowText = '';
419
+ if (avail < 16) {
420
+ const labelStr = item.label.length > avail
421
+ ? item.label.slice(0, avail - 1) + '…'
422
+ : item.label.padEnd(avail, ' ');
423
+ rowText = pointer + (isSelected ? theme.boldFn(labelStr) : labelStr);
424
+ }
425
+ else {
426
+ const labelMax = Math.min(12, Math.max(6, Math.floor(avail * 0.38)));
427
+ const labelStr = item.label.length > labelMax
428
+ ? item.label.slice(0, labelMax - 1) + '…'
429
+ : item.label.padEnd(labelMax, ' ');
430
+ const descMax = Math.max(3, avail - labelMax - 1);
431
+ const descStr = item.desc.length > descMax
432
+ ? item.desc.slice(0, descMax - 1) + '…'
433
+ : item.desc.padEnd(descMax, ' ');
434
+ rowText = pointer + (isSelected ? theme.boldFn(labelStr) : labelStr) + ' ' + pc.gray(descStr);
435
+ }
436
+ const plainLen = rowText.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '').length;
437
+ const padCount = Math.max(0, innerWidth - plainLen);
438
+ const fullRow = rowText + ' '.repeat(padCount);
439
+ if (isSelected) {
440
+ const cleanFull = fullRow.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
441
+ dropdownLines.push(pc.dim('│ ') + pc.dim('│') + theme.badgeFn(cleanFull) + pc.dim('│') + '\x1b[0m');
442
+ }
443
+ else {
444
+ dropdownLines.push(pc.dim('│ ') + pc.dim('│') + fullRow + pc.dim('│') + '\x1b[0m');
445
+ }
272
446
  }
447
+ dropdownLines.push(pc.dim('│ ') + pc.dim('╰' + botBorderStr + '╯') + '\x1b[0m');
273
448
  }
274
- dropdownLines.push(pc.dim('│ ') + pc.dim('╰' + botBorderStr + '╯') + '\x1b[0m');
275
449
  }
276
450
  // 1. Format and write the Prompt line safely
277
- let inputDisplay = pc.dim('│') + ' ';
278
- let renderCursorCol = 3;
451
+ let inputDisplay = promptPrefix;
452
+ let renderCursorCol = promptPrefixWidth;
279
453
  if (input.length === 0) {
280
- const maxPlace = Math.max(10, cols - 6);
454
+ const maxPlace = Math.max(10, cols - promptPrefixWidth - 2);
281
455
  const displayPlace = placeholder.length > maxPlace ? placeholder.slice(0, maxPlace - 1) + '…' : placeholder;
282
456
  inputDisplay += pc.dim(displayPlace) + '\x1b[0m';
283
- renderCursorCol = 3;
457
+ renderCursorCol = promptPrefixWidth;
284
458
  }
285
459
  else {
286
- const tokenBadge = opts.ui?.inputTokenCounter ? formatPromptTokenBadge(estimatePromptTokens(input), 40) : '';
460
+ const tokenBadge = !cleanLayout && opts.ui?.inputTokenCounter ? formatPromptTokenBadge(estimatePromptTokens(input), 40) : '';
287
461
  const badgeSuffix = tokenBadge ? ` ${tokenBadge}` : '';
288
- const maxInputLen = Math.max(10, cols - 6 - (tokenBadge ? 12 : 0));
462
+ const maxInputLen = Math.max(10, cols - promptPrefixWidth - 3 - (tokenBadge ? 12 : 0));
289
463
  if (input.length <= maxInputLen) {
290
464
  inputDisplay += formatInputWithBadges(input) + badgeSuffix + '\x1b[0m';
291
- renderCursorCol = 3 + cursorPos;
465
+ renderCursorCol = promptPrefixWidth + cursorPos;
292
466
  }
293
467
  else {
294
468
  // Horizontal scrolling to prevent auto-wrapping
@@ -297,7 +471,7 @@ export function askPrompt(opts = {}) {
297
471
  const prefix = start > 0 ? '…' : '';
298
472
  const suffix = start + maxInputLen < input.length ? '…' : '';
299
473
  inputDisplay += pc.dim(prefix) + formatInputWithBadges(visibleChunk) + pc.dim(suffix) + badgeSuffix + '\x1b[0m';
300
- renderCursorCol = 3 + (prefix ? 1 : 0) + (cursorPos - start);
474
+ renderCursorCol = promptPrefixWidth + (prefix ? 1 : 0) + (cursorPos - start);
301
475
  }
302
476
  }
303
477
  // Write prompt line
@@ -342,6 +516,20 @@ export function askPrompt(opts = {}) {
342
516
  process.stdin.pause();
343
517
  }
344
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
+ }
345
533
  if (lastRenderedDropdownLines > 0) {
346
534
  for (let i = 0; i < lastRenderedDropdownLines; i++) {
347
535
  process.stdout.write(`\x1b[1B\r\x1b[0m\x1b[2K`);
@@ -356,7 +544,7 @@ export function askPrompt(opts = {}) {
356
544
  GLOBAL_PROMPT_HISTORY.push(fullText.trim());
357
545
  }
358
546
  }
359
- process.stdout.write(`\x1b[0m\r\x1b[2K${pc.dim('│')} ${formatInputWithBadges(finalInput)}\x1b[0m\n\n`);
547
+ process.stdout.write(`\x1b[0m\r\x1b[2K${promptPrefix}${formatInputWithBadges(finalInput)}\x1b[0m\n\n`);
360
548
  cleanup();
361
549
  resolve(fullText);
362
550
  }
@@ -393,6 +581,19 @@ export function askPrompt(opts = {}) {
393
581
  return;
394
582
  }
395
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
+ }
396
597
  // Check for raw multi-character paste with newlines or large size
397
598
  if (str.length > 1 && (str.includes('\n') || str.includes('\r') || str.length > 80)) {
398
599
  handlePaste(str);
@@ -404,6 +605,12 @@ export function askPrompt(opts = {}) {
404
605
  }
405
606
  // Ctrl+C / SIGINT
406
607
  if (str === '\x03') {
608
+ if (cleanLayout) {
609
+ eraseFrame();
610
+ cleanup();
611
+ resolve('__CANCEL__');
612
+ return;
613
+ }
407
614
  if (lastRenderedDropdownLines > 0) {
408
615
  for (let i = 0; i < lastRenderedDropdownLines; i++) {
409
616
  process.stdout.write(`\x1b[1B\r\x1b[0m\x1b[2K`);
@@ -427,29 +634,9 @@ export function askPrompt(opts = {}) {
427
634
  selectedIndex >= 0 &&
428
635
  selectedIndex < items.length &&
429
636
  items[selectedIndex].label === '/image')) {
430
- saveClipboardImage('image.png', usedImageNames)
431
- .then((imgRes) => {
432
- if (imgRes) {
433
- const tag = `[${imgRes.fileName} ${imgRes.sizeStr}]`;
434
- imageAttachments.push({
435
- tag,
436
- fileName: imgRes.fileName,
437
- filePath: imgRes.filePath,
438
- sizeStr: imgRes.sizeStr
439
- });
440
- usedImageNames.add(imgRes.fileName);
441
- input = `${tag} `;
442
- cursorPos = input.length;
443
- render();
444
- }
445
- else {
446
- process.stdout.write(`\x1b[0m\r\x1b[2K${pc.yellow('⚠️ No image in clipboard. Take a screenshot first (Win+Shift+S)\n')}`);
447
- input = '';
448
- cursorPos = 0;
449
- render();
450
- }
451
- })
452
- .catch(() => { });
637
+ input = '';
638
+ cursorPos = 0;
639
+ void pasteFromClipboard();
453
640
  return;
454
641
  }
455
642
  if (items.length > 0 && selectedIndex >= 0 && selectedIndex < items.length) {
@@ -494,7 +681,10 @@ export function askPrompt(opts = {}) {
494
681
  }
495
682
  }
496
683
  else {
497
- process.stdout.write(`\x1b[0m\r\x1b[2K`);
684
+ if (cleanLayout)
685
+ eraseFrame();
686
+ else
687
+ process.stdout.write(`\x1b[0m\r\x1b[2K`);
498
688
  cleanup();
499
689
  resolve(`__TOGGLE_MODE__:${input}`);
500
690
  }
@@ -503,7 +693,7 @@ export function askPrompt(opts = {}) {
503
693
  // Up Arrow
504
694
  if (str === '\x1b[A' || str === '\x1bOA') {
505
695
  if (items.length > 0) {
506
- selectedIndex = (selectedIndex - 1 + items.length) % items.length;
696
+ selectedIndex = Math.max(0, selectedIndex - 1); // stops at the first item
507
697
  render();
508
698
  }
509
699
  else {
@@ -519,7 +709,7 @@ export function askPrompt(opts = {}) {
519
709
  // Down Arrow
520
710
  if (str === '\x1b[B' || str === '\x1bOB') {
521
711
  if (items.length > 0) {
522
- selectedIndex = (selectedIndex + 1) % items.length;
712
+ selectedIndex = Math.min(items.length - 1, selectedIndex + 1); // stops at the last item
523
713
  render();
524
714
  }
525
715
  else {
@@ -596,6 +786,33 @@ export function askPrompt(opts = {}) {
596
786
  }
597
787
  return;
598
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
+ }
599
816
  // Backspace / DEL handling
600
817
  if (str.includes('\x08') || str.includes('\x7f')) {
601
818
  for (let i = 0; i < str.length; i++) {