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.
- package/README.md +214 -166
- package/assets/banner.svg +23 -28
- package/assets/preview.png +0 -0
- package/assets/preview.svg +101 -10
- package/dist/cli/banner.js +3 -4
- package/dist/cli/clipboard-write.js +55 -0
- package/dist/cli/clipboard.js +182 -106
- package/dist/cli/colors.js +67 -0
- package/dist/cli/confirm.js +50 -0
- package/dist/cli/doctor.js +4 -4
- package/dist/cli/effort.js +80 -0
- package/dist/cli/export.js +3 -3
- package/dist/cli/files.js +19 -5
- package/dist/cli/finder.js +241 -0
- package/dist/cli/headless.js +23 -11
- package/dist/cli/index.js +1557 -744
- package/dist/cli/keys.js +69 -0
- package/dist/cli/liveblock.js +45 -0
- package/dist/cli/log.js +19 -0
- package/dist/cli/logo.js +30 -0
- package/dist/cli/markdown.js +174 -37
- package/dist/cli/mcp-panel.js +432 -0
- package/dist/cli/permission.js +84 -0
- package/dist/cli/picker.js +143 -0
- package/dist/cli/prompt.js +256 -56
- package/dist/cli/resume.js +15 -0
- package/dist/cli/rewind.js +51 -0
- package/dist/cli/runtime_errors.js +6 -0
- package/dist/cli/server.js +85 -24
- package/dist/cli/settings.js +307 -0
- package/dist/cli/statusline.js +1 -1
- package/dist/cli/theme.js +126 -108
- package/dist/cli/transcript.js +134 -0
- package/dist/cli/typeahead.js +371 -0
- package/dist/cli/ui.js +1022 -0
- package/dist/cli/updater.js +23 -21
- package/dist/cli/usage-panel.js +242 -0
- package/dist/cli/welcome.js +56 -0
- package/dist/cli/width.js +178 -0
- package/dist/core/capabilities.js +433 -0
- package/dist/core/catalog-util.js +27 -0
- package/dist/core/checkpoint.js +90 -0
- package/dist/core/context-stats.js +29 -0
- package/dist/core/cost.js +191 -0
- package/dist/core/formats.js +199 -0
- package/dist/core/history.js +51 -1
- package/dist/core/hooks.js +79 -0
- package/dist/core/i18n.js +42 -0
- package/dist/core/local-config.js +69 -0
- package/dist/core/locales/de.js +265 -0
- package/dist/core/locales/en.js +276 -0
- package/dist/core/locales/es.js +265 -0
- package/dist/core/locales/fr.js +265 -0
- package/dist/core/locales/ja.js +265 -0
- package/dist/core/locales/pt.js +265 -0
- package/dist/core/locales/ru.js +265 -0
- package/dist/core/locales/uk.js +265 -0
- package/dist/core/locales/zh.js +265 -0
- package/dist/core/loop.js +125 -49
- package/dist/core/memory.js +39 -12
- package/dist/core/models.js +33 -20
- package/dist/core/network.js +12 -11
- package/dist/core/notify.js +54 -0
- package/dist/core/planfile.js +43 -0
- package/dist/core/price-catalog.json +1 -0
- package/dist/core/prices.js +249 -0
- package/dist/core/reasoning-catalog.json +1 -0
- package/dist/core/reasoning.js +393 -0
- package/dist/core/redact.js +41 -0
- package/dist/core/secure-file.js +22 -0
- package/dist/core/session.js +16 -5
- package/dist/core/snapshot.js +14 -2
- package/dist/core/title.js +61 -0
- package/dist/core/types.js +1 -1
- package/dist/core/usage.js +43 -7
- package/dist/core/vision-catalog.json +1 -0
- package/dist/core/vision.js +123 -0
- package/dist/mcp/client.js +36 -5
- package/dist/mcp/http-client.js +174 -0
- package/dist/mcp/manager.js +330 -93
- package/dist/permissions/guard.js +385 -117
- package/dist/prompts/builder.js +46 -38
- package/dist/providers/anthropic.js +517 -0
- package/dist/providers/index.js +18 -1
- package/dist/providers/openai.js +138 -125
- package/dist/providers/shared.js +116 -0
- package/dist/providers/toolguard.js +101 -0
- package/dist/tools/bash.js +141 -51
- package/dist/tools/diagnostics.js +76 -48
- package/dist/tools/fs.js +77 -5
- package/dist/tools/index.js +2 -1
- package/dist/tools/mcp.js +72 -0
- package/dist/tools/packages.js +16 -1
- package/dist/tools/plan.js +8 -8
- package/dist/tools/questions.js +50 -54
- package/dist/tools/server.js +6 -0
- package/dist/tools/todo.js +20 -18
- package/dist/tools/web.js +143 -8
- package/package.json +1 -1
package/dist/cli/prompt.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import pc from 'picocolors';
|
|
2
2
|
import { scanProjectFiles } from './files.js';
|
|
3
|
-
import {
|
|
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 (
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
93
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
if (
|
|
121
|
-
|
|
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
|
-
|
|
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 (
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
+
}
|