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
@@ -7,6 +7,12 @@ export function filterCommandPalette(query, commands) {
7
7
  return commands.filter((c) => {
8
8
  const name = c.cmd.toLowerCase().replace(/^\//, '');
9
9
  return name.includes(q) || c.desc.toLowerCase().includes(q);
10
+ }).sort((a, b) => {
11
+ const rank = (cmd) => {
12
+ const name = cmd.toLowerCase().replace(/^\//, '');
13
+ return name.startsWith(q) ? 0 : name.includes(q) ? 1 : 2;
14
+ };
15
+ return rank(a.cmd) - rank(b.cmd);
10
16
  });
11
17
  }
12
18
  export function estimatePromptTokens(text) {
@@ -0,0 +1,15 @@
1
+ import { palette, renderNotice } from './ui.js';
2
+ import { displayWidth, truncateWidth } from './width.js';
3
+ import { messagesToItems, renderTranscript } from './transcript.js';
4
+ export function renderResumedMessages(messages, info) {
5
+ const { cols } = info;
6
+ const label = info.title.replace(/\s+/g, ' ').trim();
7
+ const count = info.total > info.shown ? `${info.shown} / ${info.total}` : String(info.total);
8
+ const head = ` ${palette.faint('╌')} ${palette.muted(truncateWidth(label, Math.max(10, cols - 14 - displayWidth(count))))} ${palette.faint(count)}`;
9
+ const body = renderTranscript(messagesToItems(messages), {
10
+ cols,
11
+ thinkingExpanded: !!info.thinkingExpanded,
12
+ outputExpanded: !!info.outputExpanded,
13
+ });
14
+ return ['', head, '', ...body, '', renderNotice('ok', `resumed · ${info.total} messages`, cols), ''];
15
+ }
@@ -0,0 +1,51 @@
1
+ import { truncateWidth } from './width.js';
2
+ import { t } from '../core/i18n.js';
3
+ /**
4
+ * /rewind: go back to one of your own messages. The conversation after it is removed (the model
5
+ * forgets it too), and the message comes back into the input line to be edited.
6
+ */
7
+ export const REWIND_CURRENT = -1;
8
+ /** "5 minutes ago" in the chosen language; empty when the message has no time (an old session). */
9
+ export function timeAgo(at, now = Date.now()) {
10
+ if (!at || !Number.isFinite(at))
11
+ return '';
12
+ const seconds = Math.max(0, Math.round((now - at) / 1000));
13
+ if (seconds < 60)
14
+ return t('rewind.ago.now');
15
+ const minutes = Math.round(seconds / 60);
16
+ if (minutes < 60)
17
+ return t('rewind.ago.min', { n: minutes });
18
+ const hours = Math.round(minutes / 60);
19
+ if (hours < 24)
20
+ return t('rewind.ago.hour', { n: hours });
21
+ return t('rewind.ago.day', { n: Math.round(hours / 24) });
22
+ }
23
+ /** What of a user message is shown: its first line, without the files that were attached to it. */
24
+ export function messageLabel(content, max = 52) {
25
+ const typed = content.split('\n\n--- Attached Files ---')[0];
26
+ const first = typed.split('\n').map((l) => l.trim()).find((l) => l.length > 0) ?? '';
27
+ return truncateWidth(first.replace(/\s+/g, ' '), max);
28
+ }
29
+ /** The text that goes back into the input line. */
30
+ export function messageDraft(content) {
31
+ return content.split('\n\n--- Attached Files ---')[0].trim();
32
+ }
33
+ export function userEntries(messages) {
34
+ const out = [];
35
+ messages.forEach((message, index) => {
36
+ if (message.role === 'user')
37
+ out.push({ index, message });
38
+ });
39
+ return out;
40
+ }
41
+ /** The items of the menu: your messages, oldest first, and "(current)" last, where the cursor starts. */
42
+ export function rewindItems(messages, now = Date.now()) {
43
+ const entries = userEntries(messages);
44
+ const items = entries.map((e, n) => {
45
+ const later = entries.length - 1 - n;
46
+ const parts = [timeAgo(e.message.at, now), later > 0 ? t('rewind.later', { n: later }) : ''].filter(Boolean);
47
+ return { value: e.index, label: messageLabel(e.message.content), detail: parts.join(' · ') };
48
+ });
49
+ items.push({ value: REWIND_CURRENT, label: t('rewind.current'), detail: '' });
50
+ return items;
51
+ }
@@ -0,0 +1,6 @@
1
+ export function reportRuntimeError(error, abort = null) {
2
+ const details = error instanceof Error ? error.stack || error.message : String(error);
3
+ console.error('\n[devx runtime error]:', details);
4
+ process.exitCode = 1;
5
+ abort?.();
6
+ }
@@ -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, '&amp;')
69
+ .replace(/</g, '&lt;')
70
+ .replace(/>/g, '&gt;')
71
+ .replace(/"/g, '&quot;')
72
+ .replace(/'/g, '&#39;');
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 => !f.name.startsWith('.') && f.name !== 'node_modules')
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">⬆️</span><span class="name">.. (Parent Directory)</span></a></li>`
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 &bull; ${relPath}</title>
163
+ <title>devx Live Preview &bull; ${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>⚡ devx Live Preview</h1>
183
+ <h1>devx Live Preview</h1>
136
184
  <div class="info">
137
- Path: <code>${relPath}</code> &bull; Port: <code>${port}</code>
185
+ Path: <code>${safeRelPath}</code> &bull; 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 node_modules
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(seg => (seg.startsWith('.') && seg !== '.') || seg === 'node_modules')) {
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 = ' 🌐 Live Web Preview ';
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('📱 Mobile QR:')}`);
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
+ }