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/server.js
CHANGED
|
@@ -3,6 +3,7 @@ import fs from 'fs/promises';
|
|
|
3
3
|
import fsSync from 'fs';
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import os from 'os';
|
|
6
|
+
import net from 'net';
|
|
6
7
|
import { spawn } from 'child_process';
|
|
7
8
|
import pc from 'picocolors';
|
|
8
9
|
import qrcode from 'qrcode-terminal';
|
|
@@ -62,10 +63,57 @@ export function stopServer() {
|
|
|
62
63
|
}
|
|
63
64
|
return false;
|
|
64
65
|
}
|
|
66
|
+
function escapeHtml(value) {
|
|
67
|
+
return value
|
|
68
|
+
.replace(/&/g, '&')
|
|
69
|
+
.replace(/</g, '<')
|
|
70
|
+
.replace(/>/g, '>')
|
|
71
|
+
.replace(/"/g, '"')
|
|
72
|
+
.replace(/'/g, ''');
|
|
73
|
+
}
|
|
74
|
+
const BLOCKED_EXTENSIONS = new Set(['.pem', '.key', '.p12', '.pfx', '.keystore', '.jks']);
|
|
75
|
+
/** Names that must never be served: dotfiles, dependencies, keys and obvious secret stores. */
|
|
76
|
+
export function isBlockedName(name) {
|
|
77
|
+
if (!name || name === '.')
|
|
78
|
+
return false;
|
|
79
|
+
const lower = name.toLowerCase();
|
|
80
|
+
return (lower.startsWith('.') ||
|
|
81
|
+
lower === 'node_modules' ||
|
|
82
|
+
BLOCKED_EXTENSIONS.has(path.extname(lower)) ||
|
|
83
|
+
/^id_(rsa|dsa|ecdsa|ed25519)/.test(lower) ||
|
|
84
|
+
/^(secrets?|credentials?)(\.[a-z0-9]+)*$/.test(lower));
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Only answer requests addressed to an IP, localhost, a single-label host name or *.local.
|
|
88
|
+
* A public domain name in the Host header means a DNS-rebinding attempt from a web page.
|
|
89
|
+
*/
|
|
90
|
+
export function isAllowedHost(hostHeader) {
|
|
91
|
+
if (!hostHeader)
|
|
92
|
+
return false;
|
|
93
|
+
const host = hostHeader.startsWith('[')
|
|
94
|
+
? hostHeader.slice(1, hostHeader.indexOf(']'))
|
|
95
|
+
: hostHeader.split(':')[0];
|
|
96
|
+
const lower = host.toLowerCase();
|
|
97
|
+
return net.isIP(lower) !== 0 || !lower.includes('.') || lower.endsWith('.local');
|
|
98
|
+
}
|
|
99
|
+
/** The real (symlink-resolved) location must stay inside the project and must not be a blocked file. */
|
|
100
|
+
async function isServable(targetPath, cwd) {
|
|
101
|
+
try {
|
|
102
|
+
const [realTarget, realBase] = await Promise.all([fs.realpath(targetPath), fs.realpath(cwd)]);
|
|
103
|
+
const rel = path.relative(realBase, realTarget);
|
|
104
|
+
if (rel === '..' || rel.startsWith('..' + path.sep) || path.isAbsolute(rel))
|
|
105
|
+
return false;
|
|
106
|
+
return !rel.split(path.sep).some(isBlockedName);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
65
112
|
function renderDirectoryHtml(dirPath, relPath, files, port) {
|
|
66
113
|
const currentRel = relPath === '/' ? '' : relPath;
|
|
114
|
+
const safeRelPath = escapeHtml(relPath);
|
|
67
115
|
const items = files
|
|
68
|
-
.filter(f => !
|
|
116
|
+
.filter(f => !isBlockedName(f.name))
|
|
69
117
|
.sort((a, b) => {
|
|
70
118
|
if (a.isDirectory() && !b.isDirectory())
|
|
71
119
|
return -1;
|
|
@@ -75,44 +123,44 @@ function renderDirectoryHtml(dirPath, relPath, files, port) {
|
|
|
75
123
|
})
|
|
76
124
|
.map(f => {
|
|
77
125
|
const isDir = f.isDirectory();
|
|
78
|
-
const href = `${currentRel}/${encodeURIComponent(f.name)}${isDir ? '/' : ''}
|
|
126
|
+
const href = escapeHtml(`${currentRel.split('/').map(encodeURIComponent).join('/')}/${encodeURIComponent(f.name)}${isDir ? '/' : ''}`);
|
|
79
127
|
const ext = path.extname(f.name).toLowerCase();
|
|
80
|
-
let icon = isDir ? '
|
|
128
|
+
let icon = isDir ? '▸' : '·';
|
|
81
129
|
let badge = '';
|
|
82
130
|
if (ext === '.html' || ext === '.htm') {
|
|
83
|
-
icon = '
|
|
131
|
+
icon = '◆';
|
|
84
132
|
badge = '<span class="badge">HTML</span>';
|
|
85
133
|
}
|
|
86
134
|
else if (['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp'].includes(ext)) {
|
|
87
|
-
icon = '
|
|
135
|
+
icon = '·';
|
|
88
136
|
}
|
|
89
137
|
else if (['.js', '.ts', '.tsx', '.jsx', '.json'].includes(ext)) {
|
|
90
|
-
icon = '
|
|
138
|
+
icon = '·';
|
|
91
139
|
}
|
|
92
140
|
else if (ext === '.css') {
|
|
93
|
-
icon = '
|
|
141
|
+
icon = '·';
|
|
94
142
|
}
|
|
95
143
|
return `
|
|
96
144
|
<li>
|
|
97
145
|
<a class="file-item" href="${href}">
|
|
98
146
|
<span class="icon">${icon}</span>
|
|
99
|
-
<span class="name">${f.name}</span>
|
|
147
|
+
<span class="name">${escapeHtml(f.name)}</span>
|
|
100
148
|
${badge}
|
|
101
|
-
<span class="type">${isDir ? 'Directory' : ext || 'File'}</span>
|
|
149
|
+
<span class="type">${isDir ? 'Directory' : escapeHtml(ext) || 'File'}</span>
|
|
102
150
|
</a>
|
|
103
151
|
</li>
|
|
104
152
|
`;
|
|
105
153
|
})
|
|
106
154
|
.join('');
|
|
107
155
|
const parentLink = currentRel && currentRel !== '/'
|
|
108
|
-
? `<li><a class="file-item" href="${path.dirname(currentRel) === '/' ? '/' : path.dirname(currentRel) + '/'}"><span class="icon"
|
|
156
|
+
? `<li><a class="file-item" href="${escapeHtml((path.posix.dirname(currentRel) === '/' ? '/' : path.posix.dirname(currentRel) + '/').split('/').map(encodeURIComponent).join('/'))}"><span class="icon">↑</span><span class="name">.. (Parent Directory)</span></a></li>`
|
|
109
157
|
: '';
|
|
110
158
|
return `<!DOCTYPE html>
|
|
111
159
|
<html lang="en">
|
|
112
160
|
<head>
|
|
113
161
|
<meta charset="utf-8"/>
|
|
114
162
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
115
|
-
<title>devx Live Preview • ${
|
|
163
|
+
<title>devx Live Preview • ${safeRelPath}</title>
|
|
116
164
|
<style>
|
|
117
165
|
* { box-sizing: border-box; }
|
|
118
166
|
body { background: #0a0a0c; color: #e1e4e8; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, monospace; margin: 0; padding: 24px 16px; }
|
|
@@ -132,9 +180,9 @@ function renderDirectoryHtml(dirPath, relPath, files, port) {
|
|
|
132
180
|
</head>
|
|
133
181
|
<body>
|
|
134
182
|
<div class="card">
|
|
135
|
-
<h1
|
|
183
|
+
<h1>devx Live Preview</h1>
|
|
136
184
|
<div class="info">
|
|
137
|
-
Path: <code>${
|
|
185
|
+
Path: <code>${safeRelPath}</code> • Port: <code>${port}</code>
|
|
138
186
|
</div>
|
|
139
187
|
<ul>
|
|
140
188
|
${parentLink}
|
|
@@ -153,20 +201,32 @@ export async function startServer(preferredPort = 3000) {
|
|
|
153
201
|
return new Promise((resolve, reject) => {
|
|
154
202
|
const server = http.createServer(async (req, res) => {
|
|
155
203
|
try {
|
|
204
|
+
// Security check: a public domain name here is a DNS-rebinding attempt.
|
|
205
|
+
if (!isAllowedHost(req.headers.host)) {
|
|
206
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
207
|
+
res.end('Forbidden: unexpected Host header');
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
156
210
|
let rawPath = req.url?.split('?')[0] || '/';
|
|
157
211
|
let reqPath = decodeURIComponent(rawPath);
|
|
158
212
|
const cwd = process.cwd();
|
|
159
213
|
let targetPath = path.resolve(cwd, '.' + reqPath);
|
|
160
214
|
// Security check: ensure path is within cwd
|
|
161
215
|
const rel = path.relative(cwd, targetPath);
|
|
162
|
-
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
216
|
+
if (rel === '..' || rel.startsWith('..' + path.sep) || path.isAbsolute(rel)) {
|
|
163
217
|
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
164
218
|
res.end('Forbidden');
|
|
165
219
|
return;
|
|
166
220
|
}
|
|
167
|
-
// Security check: block dotfiles, hidden directories (.env, .git, etc.) and
|
|
221
|
+
// Security check: block dotfiles, hidden directories (.env, .git, etc.), node_modules, keys and secret stores
|
|
168
222
|
const segments = reqPath.split(/[\/\\]/).filter(Boolean);
|
|
169
|
-
if (segments.some(
|
|
223
|
+
if (segments.some(isBlockedName)) {
|
|
224
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
225
|
+
res.end('Forbidden');
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
// Security check: symlinks must not lead out of the project or to a blocked file
|
|
229
|
+
if (fsSync.existsSync(targetPath) && !(await isServable(targetPath, cwd))) {
|
|
170
230
|
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
171
231
|
res.end('Forbidden');
|
|
172
232
|
return;
|
|
@@ -184,11 +244,10 @@ export async function startServer(preferredPort = 3000) {
|
|
|
184
244
|
path.join(targetPath, 'build/index.html')
|
|
185
245
|
];
|
|
186
246
|
for (const idxPath of possibleIndexes) {
|
|
187
|
-
if (fsSync.existsSync(idxPath)) {
|
|
247
|
+
if (fsSync.existsSync(idxPath) && await isServable(idxPath, cwd)) {
|
|
188
248
|
const content = await fs.readFile(idxPath);
|
|
189
249
|
res.writeHead(200, {
|
|
190
250
|
'Content-Type': 'text/html; charset=utf-8',
|
|
191
|
-
'Access-Control-Allow-Origin': '*'
|
|
192
251
|
});
|
|
193
252
|
res.end(content);
|
|
194
253
|
return;
|
|
@@ -199,7 +258,6 @@ export async function startServer(preferredPort = 3000) {
|
|
|
199
258
|
const dirHtml = renderDirectoryHtml(targetPath, reqPath, dirents, activePort);
|
|
200
259
|
res.writeHead(200, {
|
|
201
260
|
'Content-Type': 'text/html; charset=utf-8',
|
|
202
|
-
'Access-Control-Allow-Origin': '*'
|
|
203
261
|
});
|
|
204
262
|
res.end(dirHtml);
|
|
205
263
|
return;
|
|
@@ -209,6 +267,11 @@ export async function startServer(preferredPort = 3000) {
|
|
|
209
267
|
if (!fsSync.existsSync(targetPath)) {
|
|
210
268
|
const withHtml = `${targetPath}.html`;
|
|
211
269
|
if (fsSync.existsSync(withHtml)) {
|
|
270
|
+
if (!(await isServable(withHtml, cwd))) {
|
|
271
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
272
|
+
res.end('Forbidden');
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
212
275
|
targetPath = withHtml;
|
|
213
276
|
}
|
|
214
277
|
}
|
|
@@ -219,7 +282,6 @@ export async function startServer(preferredPort = 3000) {
|
|
|
219
282
|
const content = await fs.readFile(targetPath);
|
|
220
283
|
res.writeHead(200, {
|
|
221
284
|
'Content-Type': contentType,
|
|
222
|
-
'Access-Control-Allow-Origin': '*'
|
|
223
285
|
});
|
|
224
286
|
res.end(content);
|
|
225
287
|
return;
|
|
@@ -228,11 +290,10 @@ export async function startServer(preferredPort = 3000) {
|
|
|
228
290
|
const hasAssetExt = path.extname(reqPath).length > 0;
|
|
229
291
|
if (!hasAssetExt) {
|
|
230
292
|
const rootIndex = path.join(cwd, 'index.html');
|
|
231
|
-
if (fsSync.existsSync(rootIndex)) {
|
|
293
|
+
if (fsSync.existsSync(rootIndex) && await isServable(rootIndex, cwd)) {
|
|
232
294
|
const content = await fs.readFile(rootIndex);
|
|
233
295
|
res.writeHead(200, {
|
|
234
296
|
'Content-Type': 'text/html; charset=utf-8',
|
|
235
|
-
'Access-Control-Allow-Origin': '*'
|
|
236
297
|
});
|
|
237
298
|
res.end(content);
|
|
238
299
|
return;
|
|
@@ -297,7 +358,7 @@ export async function displayServerBanner(localUrl, networkUrl) {
|
|
|
297
358
|
const cols = Math.min(process.stdout.columns || 80, 80);
|
|
298
359
|
const cardWidth = Math.max(36, Math.min(cols - 4, 66));
|
|
299
360
|
const innerWidth = cardWidth - 2;
|
|
300
|
-
const title = '
|
|
361
|
+
const title = ' Live Web Preview ';
|
|
301
362
|
const topFill = Math.max(2, cardWidth - 3 - title.length);
|
|
302
363
|
console.log('\n' + th.colorFn('┌─') + pc.bold(title) + th.colorFn('─'.repeat(topFill) + '┐'));
|
|
303
364
|
const printRow = (content) => {
|
|
@@ -308,7 +369,7 @@ export async function displayServerBanner(localUrl, networkUrl) {
|
|
|
308
369
|
printRow(` ${pc.bold('Local:')} ${pc.cyan(localUrl)}`);
|
|
309
370
|
printRow(` ${pc.bold('Network:')} ${pc.green(networkUrl)}`);
|
|
310
371
|
printRow(' ');
|
|
311
|
-
printRow(` ${pc.bold('
|
|
372
|
+
printRow(` ${pc.bold('Mobile QR:')}`);
|
|
312
373
|
if (qr) {
|
|
313
374
|
const qrLines = qr.trim().split('\n');
|
|
314
375
|
for (const ql of qrLines) {
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { LiveBlock } from './liveblock.js';
|
|
2
|
+
import { splitKeys } from './keys.js';
|
|
3
|
+
import { THEME_IDS, getTheme, renderSwatch, renderThemePreview } from './theme.js';
|
|
4
|
+
import { palette } from './ui.js';
|
|
5
|
+
import { displayWidth, padEndWidth, stripAnsi, truncateWidth, wrapText, wrapWords } from './width.js';
|
|
6
|
+
import { LANGUAGE_IDS, languageName, t } from '../core/i18n.js';
|
|
7
|
+
export { wrapWords };
|
|
8
|
+
/**
|
|
9
|
+
* The settings screen: one panel that is redrawn in place (nothing piles up in the
|
|
10
|
+
* scrollback), moves with the arrow keys without wrapping around at the ends, and applies
|
|
11
|
+
* every change immediately, so a theme can be judged on the panel itself.
|
|
12
|
+
*
|
|
13
|
+
* Rendering is a pure function of the state; `runSettings` only adds the keyboard.
|
|
14
|
+
*/
|
|
15
|
+
export const ITERATION_STEPS = [10, 20, 30, 50, 100, 200, 500, 9999];
|
|
16
|
+
/** Limits for the cost of a session, in USD; 0 is no limit. */
|
|
17
|
+
export const COST_LIMIT_STEPS = [0, 0.5, 1, 2, 5, 10, 20, 50];
|
|
18
|
+
function row(key, sectionKey, kind) {
|
|
19
|
+
return {
|
|
20
|
+
key,
|
|
21
|
+
sectionKey,
|
|
22
|
+
kind,
|
|
23
|
+
get section() { return t(`settings.section.${sectionKey}`); },
|
|
24
|
+
get label() { return t(`settings.row.${key}`); },
|
|
25
|
+
get short() { return t(`settings.short.${key}`); },
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export const ROWS = [
|
|
29
|
+
row('theme', 'appearance', 'choice'),
|
|
30
|
+
row('layout', 'appearance', 'choice'),
|
|
31
|
+
row('pureBlack', 'appearance', 'toggle'),
|
|
32
|
+
row('autoApprove', 'agent', 'toggle'),
|
|
33
|
+
row('maxIterations', 'agent', 'choice'),
|
|
34
|
+
row('memory', 'agent', 'toggle'),
|
|
35
|
+
row('updates', 'agent', 'toggle'),
|
|
36
|
+
row('costLimit', 'agent', 'choice'),
|
|
37
|
+
row('language', 'general', 'choice'),
|
|
38
|
+
row('about', 'about', 'info'),
|
|
39
|
+
];
|
|
40
|
+
const hintFor = (key) => t(`settings.hint.${key}`);
|
|
41
|
+
function cycle(items, current, direction) {
|
|
42
|
+
const index = items.indexOf(current);
|
|
43
|
+
if (index === -1)
|
|
44
|
+
return items[direction === 1 ? 0 : items.length - 1];
|
|
45
|
+
return items[(index + direction + items.length) % items.length];
|
|
46
|
+
}
|
|
47
|
+
/** Closest step of the iteration list, for values set by hand in the config file. */
|
|
48
|
+
function nearestStep(value) {
|
|
49
|
+
return ITERATION_STEPS.reduce((best, step) => (Math.abs(step - value) < Math.abs(best - value) ? step : best), ITERATION_STEPS[0]);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Applies one key press to a setting. Left/right move through the values of a choice;
|
|
53
|
+
* toggles flip whichever way you press. The input state is never modified.
|
|
54
|
+
*/
|
|
55
|
+
export function changeSetting(state, key, direction) {
|
|
56
|
+
switch (key) {
|
|
57
|
+
case 'theme':
|
|
58
|
+
return { ...state, themeId: cycle(THEME_IDS, getTheme(state.themeId).id, direction) };
|
|
59
|
+
case 'layout':
|
|
60
|
+
return { ...state, layout: state.layout === 'clean' ? 'classic' : 'clean' };
|
|
61
|
+
case 'maxIterations': {
|
|
62
|
+
const current = ITERATION_STEPS.includes(state.maxIterations) ? state.maxIterations : nearestStep(state.maxIterations);
|
|
63
|
+
return { ...state, maxIterations: cycle(ITERATION_STEPS, current, direction) };
|
|
64
|
+
}
|
|
65
|
+
case 'pureBlack':
|
|
66
|
+
return { ...state, pureBlack: !state.pureBlack };
|
|
67
|
+
case 'autoApprove':
|
|
68
|
+
return { ...state, autoApprove: !state.autoApprove };
|
|
69
|
+
case 'memory':
|
|
70
|
+
return { ...state, memory: !state.memory };
|
|
71
|
+
case 'updates':
|
|
72
|
+
return { ...state, updates: !state.updates };
|
|
73
|
+
case 'costLimit': {
|
|
74
|
+
const current = COST_LIMIT_STEPS.reduce((best, step) => (Math.abs(step - state.costLimit) < Math.abs(best - state.costLimit) ? step : best), COST_LIMIT_STEPS[0]);
|
|
75
|
+
return { ...state, costLimit: cycle(COST_LIMIT_STEPS, current, direction) };
|
|
76
|
+
}
|
|
77
|
+
case 'language':
|
|
78
|
+
return { ...state, language: cycle(LANGUAGE_IDS, LANGUAGE_IDS.includes(state.language) ? state.language : LANGUAGE_IDS[0], direction) };
|
|
79
|
+
default:
|
|
80
|
+
return state;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Moves the focus one row. It stops at the first and the last row instead of wrapping. */
|
|
84
|
+
export function moveFocus(focus, delta) {
|
|
85
|
+
return Math.max(0, Math.min(ROWS.length - 1, focus + delta));
|
|
86
|
+
}
|
|
87
|
+
// ───────────────────────── rendering ─────────────────────────
|
|
88
|
+
const LABEL_WIDTH = 21;
|
|
89
|
+
const SHORT_LABEL_WIDTH = 11;
|
|
90
|
+
/** A column wide enough for the longest label of the current language (never narrower than the English one). */
|
|
91
|
+
const labelColumn = (pick, least, most) => Math.min(most, Math.max(least, ...ROWS.map((r) => displayWidth(pick(r)))));
|
|
92
|
+
function formatIterations(n) {
|
|
93
|
+
return n >= 9999 ? t('settings.value.unlimited') : t('settings.value.steps', { n });
|
|
94
|
+
}
|
|
95
|
+
function onOff(on, onColor = palette.ok, glyph = '●') {
|
|
96
|
+
return on ? onColor(`${glyph} ${t('settings.value.on')}`) : palette.faint(`○ ${t('settings.value.off')}`);
|
|
97
|
+
}
|
|
98
|
+
/** The current value of a row, styled; `room` is the number of cells available. */
|
|
99
|
+
function valueText(key, state, version, room, withSwatch) {
|
|
100
|
+
switch (key) {
|
|
101
|
+
case 'theme': {
|
|
102
|
+
const theme = getTheme(state.themeId);
|
|
103
|
+
const swatch = withSwatch ? renderSwatch(theme) + ' ' : theme.tokens.accent(theme.symbol) + ' ';
|
|
104
|
+
const used = withSwatch ? 9 : 2;
|
|
105
|
+
return swatch + palette.text(truncateWidth(theme.name, Math.max(4, room - used)));
|
|
106
|
+
}
|
|
107
|
+
case 'layout':
|
|
108
|
+
return palette.text(state.layout === 'clean' ? t('settings.value.clean') : t('settings.value.classic'));
|
|
109
|
+
case 'pureBlack':
|
|
110
|
+
return onOff(state.pureBlack);
|
|
111
|
+
case 'autoApprove':
|
|
112
|
+
return onOff(state.autoApprove, palette.yolo, '▲');
|
|
113
|
+
case 'maxIterations':
|
|
114
|
+
return palette.text(formatIterations(state.maxIterations));
|
|
115
|
+
case 'memory':
|
|
116
|
+
return onOff(state.memory);
|
|
117
|
+
case 'updates':
|
|
118
|
+
return onOff(state.updates);
|
|
119
|
+
case 'costLimit':
|
|
120
|
+
return state.costLimit > 0 ? palette.text(`$${state.costLimit}`) : palette.faint(`○ ${t('settings.value.off')}`);
|
|
121
|
+
case 'language':
|
|
122
|
+
return palette.text(truncateWidth(languageName(state.language), Math.max(4, room)));
|
|
123
|
+
case 'about':
|
|
124
|
+
return palette.muted(`v${version}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** Help text under the list: what the focused setting does, plus a live preview for themes. */
|
|
128
|
+
function detailLines(state, key, width, maxLines, version) {
|
|
129
|
+
if (maxLines <= 0)
|
|
130
|
+
return [];
|
|
131
|
+
if (key === 'theme') {
|
|
132
|
+
const theme = getTheme(state.themeId);
|
|
133
|
+
// The sample lines are styled; on a very narrow screen they are cut (and lose their colour).
|
|
134
|
+
const preview = renderThemePreview(theme).split('\n').map((line) => (displayWidth(line) <= width ? line : truncateWidth(stripAnsi(line), width)));
|
|
135
|
+
return [palette.muted(truncateWidth(`${theme.name}: ${theme.desc}`, width)), ...preview].slice(0, maxLines);
|
|
136
|
+
}
|
|
137
|
+
const hint = key === 'about' ? `devx v${version}. ${hintFor('about')}` : hintFor(key);
|
|
138
|
+
// A warning is never the line that gets cut off: it keeps its row, the explanation gives way.
|
|
139
|
+
const warning = key === 'autoApprove' && state.autoApprove ? t('settings.warnYolo') : '';
|
|
140
|
+
const hintRows = Math.max(1, maxLines - (warning ? 1 : 0));
|
|
141
|
+
let wrapped = wrapText(hint, width);
|
|
142
|
+
if (wrapped.length > hintRows) {
|
|
143
|
+
wrapped = wrapped.slice(0, hintRows);
|
|
144
|
+
wrapped[hintRows - 1] = truncateWidth(wrapped[hintRows - 1], width - 1).replace(/…$/, '') + '…';
|
|
145
|
+
}
|
|
146
|
+
const out = wrapped.map((line) => palette.muted(line));
|
|
147
|
+
if (warning && out.length < maxLines)
|
|
148
|
+
out.push(palette.yolo(truncateWidth(warning, width)));
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
/** The key hints, shortened step by step until they fit `width` cells. */
|
|
152
|
+
function keyHints(width) {
|
|
153
|
+
const variants = [
|
|
154
|
+
[['↑↓', t('settings.keys.move')], ['←→', t('settings.keys.change')], ['enter', t('settings.keys.toggle')], ['esc', t('settings.keys.close')]],
|
|
155
|
+
[['↑↓', t('settings.keys.move')], ['←→', t('settings.keys.change')], ['esc', t('settings.keys.close')]],
|
|
156
|
+
[['↑↓', ''], ['←→', ''], ['esc', '']],
|
|
157
|
+
[['esc', '']],
|
|
158
|
+
];
|
|
159
|
+
for (const items of variants) {
|
|
160
|
+
const text = items.map(([k, label]) => palette.text(k) + (label ? ' ' + palette.faint(label) : '')).join(palette.faint(items[0][1] ? ' · ' : ' '));
|
|
161
|
+
if (displayWidth(text) <= width)
|
|
162
|
+
return text;
|
|
163
|
+
}
|
|
164
|
+
return '';
|
|
165
|
+
}
|
|
166
|
+
function renderCard(state, focus, cols, density, version) {
|
|
167
|
+
const W = Math.min(cols - 1, 76);
|
|
168
|
+
const iw = W - 4;
|
|
169
|
+
const rule = palette.rule;
|
|
170
|
+
const maxDetail = density === 'roomy' ? 5 : 4;
|
|
171
|
+
const lines = [];
|
|
172
|
+
const box = (content, shaded = false) => rule('│') + (shaded ? palette.shade(' ' + padEndWidth(content, iw) + ' ') : ' ' + padEndWidth(content, iw) + ' ') + rule('│');
|
|
173
|
+
const title = ' ' + palette.bold(palette.accent(t('settings.title'))) + ' ';
|
|
174
|
+
const tag = ' ' + palette.faint(`devx v${version}`) + ' ';
|
|
175
|
+
const fill = Math.max(0, W - 4 - displayWidth(title) - displayWidth(tag));
|
|
176
|
+
lines.push(rule('╭─') + title + rule('─'.repeat(fill)) + tag + rule('─╮'));
|
|
177
|
+
if (density === 'roomy')
|
|
178
|
+
lines.push(box(''));
|
|
179
|
+
const labelWidth = labelColumn((r) => r.label, LABEL_WIDTH, Math.floor(iw / 2));
|
|
180
|
+
let section = '';
|
|
181
|
+
ROWS.forEach((row, index) => {
|
|
182
|
+
if (row.sectionKey !== section) {
|
|
183
|
+
if (section !== '' && density === 'roomy')
|
|
184
|
+
lines.push(box(''));
|
|
185
|
+
section = row.sectionKey;
|
|
186
|
+
lines.push(box(' ' + palette.faint(row.section.toUpperCase())));
|
|
187
|
+
}
|
|
188
|
+
const focused = index === focus;
|
|
189
|
+
const mark = focused ? palette.accent('❯') : ' ';
|
|
190
|
+
const label = focused ? palette.bold(palette.text(row.label)) : palette.muted(row.label);
|
|
191
|
+
const control = row.kind === 'choice' ? (focused ? palette.accent('‹ ›') : palette.faint('‹ ›')) : '';
|
|
192
|
+
const prefix = ` ${mark} ${padEndWidth(label, labelWidth)}`;
|
|
193
|
+
const room = iw - displayWidth(prefix) - 4;
|
|
194
|
+
const left = prefix + valueText(row.key, state, version, room, true);
|
|
195
|
+
const gap = Math.max(1, iw - displayWidth(left) - displayWidth(control) - 1);
|
|
196
|
+
lines.push(box(left + ' '.repeat(gap) + control, focused));
|
|
197
|
+
});
|
|
198
|
+
if (density === 'roomy')
|
|
199
|
+
lines.push(box(''));
|
|
200
|
+
lines.push(rule('├' + '─'.repeat(W - 2) + '┤'));
|
|
201
|
+
// The detail area always has the same height, so the panel does not jump while you move.
|
|
202
|
+
const detail = detailLines(state, ROWS[focus].key, iw - 2, maxDetail, version);
|
|
203
|
+
for (let i = 0; i < maxDetail; i++)
|
|
204
|
+
lines.push(box(detail[i] ? ' ' + detail[i] : ''));
|
|
205
|
+
lines.push(rule('╰' + '─'.repeat(W - 2) + '╯'));
|
|
206
|
+
lines.push(' ' + keyHints(cols - 2));
|
|
207
|
+
return lines;
|
|
208
|
+
}
|
|
209
|
+
function renderCompact(state, focus, cols, rows, version) {
|
|
210
|
+
const width = Math.max(20, cols - 1);
|
|
211
|
+
const lines = [];
|
|
212
|
+
const shortWidth = labelColumn((r) => r.short, SHORT_LABEL_WIDTH, Math.floor(width / 2));
|
|
213
|
+
lines.push(' ' + palette.bold(palette.accent(t('settings.title'))) + palette.faint(` · v${version}`));
|
|
214
|
+
ROWS.forEach((row, index) => {
|
|
215
|
+
const focused = index === focus;
|
|
216
|
+
const mark = focused ? palette.accent('❯') : ' ';
|
|
217
|
+
const label = focused ? palette.bold(palette.text(row.short)) : palette.muted(row.short);
|
|
218
|
+
const prefix = ` ${mark} ${padEndWidth(label, shortWidth)}`;
|
|
219
|
+
const room = Math.max(4, width - displayWidth(prefix) - 1);
|
|
220
|
+
const content = prefix + valueText(row.key, state, version, room, false);
|
|
221
|
+
lines.push(focused ? palette.shade(padEndWidth(content, width)) : content);
|
|
222
|
+
});
|
|
223
|
+
// The title, the rows and the key hints come first; the help text takes what is left of the screen.
|
|
224
|
+
const detailCount = Math.max(0, Math.min(rows >= 16 ? 2 : 1, rows - 1 - (ROWS.length + 2)));
|
|
225
|
+
const detail = detailLines(state, ROWS[focus].key, width - 2, detailCount, version);
|
|
226
|
+
for (let i = 0; i < detailCount; i++)
|
|
227
|
+
lines.push(detail[i] ? ' ' + detail[i] : '');
|
|
228
|
+
lines.push(' ' + keyHints(width - 1));
|
|
229
|
+
return lines;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* The whole panel as terminal rows. Wide terminals get a framed card (roomy or tight
|
|
233
|
+
* depending on the height), narrow ones a plain list.
|
|
234
|
+
*/
|
|
235
|
+
export function renderSettings(state, focus, cols, rows, version) {
|
|
236
|
+
if (cols < 60 || rows < 23)
|
|
237
|
+
return renderCompact(state, focus, cols, rows, version);
|
|
238
|
+
return renderCard(state, focus, cols, rows >= 29 ? 'roomy' : 'tight', version);
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Shows the panel and resolves with the final state when it is closed (Esc, q or Ctrl+C).
|
|
242
|
+
* The panel is erased on close.
|
|
243
|
+
*/
|
|
244
|
+
export function runSettings(initial, options) {
|
|
245
|
+
return new Promise((resolve) => {
|
|
246
|
+
const { input, output } = options.io ?? { input: process.stdin, output: process.stdout };
|
|
247
|
+
const block = new LiveBlock(output);
|
|
248
|
+
let state = { ...initial };
|
|
249
|
+
let focus = 0;
|
|
250
|
+
const draw = () => block.draw(renderSettings(state, focus, output.columns || 80, output.rows || 30, options.version));
|
|
251
|
+
const wasRaw = !!input.isRaw;
|
|
252
|
+
input.setRawMode?.(true);
|
|
253
|
+
input.resume();
|
|
254
|
+
const change = (direction) => {
|
|
255
|
+
const next = changeSetting(state, ROWS[focus].key, direction);
|
|
256
|
+
if (next === state)
|
|
257
|
+
return;
|
|
258
|
+
state = next;
|
|
259
|
+
options.onChange?.(ROWS[focus].key, state);
|
|
260
|
+
draw();
|
|
261
|
+
};
|
|
262
|
+
const close = () => {
|
|
263
|
+
input.off('data', onData);
|
|
264
|
+
block.erase();
|
|
265
|
+
if (!wasRaw)
|
|
266
|
+
input.setRawMode?.(false);
|
|
267
|
+
resolve(state);
|
|
268
|
+
};
|
|
269
|
+
let closed = false;
|
|
270
|
+
const handleKey = (key) => {
|
|
271
|
+
const move = (delta) => {
|
|
272
|
+
const next = moveFocus(focus, delta);
|
|
273
|
+
if (next !== focus) {
|
|
274
|
+
focus = next;
|
|
275
|
+
draw();
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
if (key === '\x1b[A' || key === '\x1bOA' || key === 'k')
|
|
279
|
+
return move(-1);
|
|
280
|
+
if (key === '\x1b[B' || key === '\x1bOB' || key === 'j')
|
|
281
|
+
return move(1);
|
|
282
|
+
// Home / End are sent differently by different terminals.
|
|
283
|
+
if (key === '\x1b[H' || key === '\x1b[1~' || key === '\x1b[7~' || key === '\x1bOH' || key === 'g')
|
|
284
|
+
return move(-ROWS.length);
|
|
285
|
+
if (key === '\x1b[F' || key === '\x1b[4~' || key === '\x1b[8~' || key === '\x1bOF' || key === 'G')
|
|
286
|
+
return move(ROWS.length);
|
|
287
|
+
if (key === '\x1b[C' || key === '\x1bOC' || key === 'l' || key === ' ' || key === '\r' || key === '\n')
|
|
288
|
+
return change(1);
|
|
289
|
+
if (key === '\x1b[D' || key === '\x1bOD' || key === 'h')
|
|
290
|
+
return change(-1);
|
|
291
|
+
if (key === '\x1b' || key === 'q' || key === '\x03') {
|
|
292
|
+
closed = true;
|
|
293
|
+
return close();
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
// Several key presses can arrive in one chunk; handle them one by one.
|
|
297
|
+
const onData = (chunk) => {
|
|
298
|
+
for (const key of splitKeys(chunk.toString())) {
|
|
299
|
+
if (closed)
|
|
300
|
+
return;
|
|
301
|
+
handleKey(key);
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
input.on('data', onData);
|
|
305
|
+
draw();
|
|
306
|
+
});
|
|
307
|
+
}
|
package/dist/cli/statusline.js
CHANGED
|
@@ -116,7 +116,7 @@ export function renderPowerlineStatus(opts) {
|
|
|
116
116
|
}
|
|
117
117
|
// Git info
|
|
118
118
|
const git = GitStatusCache.getStatus();
|
|
119
|
-
const gitStr = git ? ` · ${pc.magenta(git.branch)}${git.isDirty ? pc.yellow('
|
|
119
|
+
const gitStr = git ? ` · ${pc.magenta(git.branch)}${git.isDirty ? pc.yellow('*') : ''}` : '';
|
|
120
120
|
// Context progress
|
|
121
121
|
const usagePercent = Math.min(100, Math.round((opts.currentTokens / Math.max(1, opts.maxTokens)) * 100));
|
|
122
122
|
const barWidth = cols < 60 ? 6 : 10;
|