termux-dev 1.1.1 → 1.2.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.
@@ -1,11 +1,14 @@
1
1
  import pc from 'picocolors';
2
2
  import { scanProjectFiles } from './files.js';
3
3
  import { saveClipboardImage, processPastedFilePath } from './clipboard.js';
4
+ import { getCurrentTheme, listThemes } from './theme.js';
4
5
  export const SLASH_COMMANDS = [
5
6
  { cmd: '/new', desc: 'Start a new clean chat session' },
6
7
  { cmd: '/resume', desc: 'Resume a previous chat session' },
7
8
  { cmd: '/session', desc: 'Show active session ID, stats, and info' },
8
9
  { cmd: '/session del', desc: 'Select and delete saved sessions' },
10
+ { cmd: '/theme', desc: 'Switch UI theme (Cyan, Purple, Matrix, Amber, etc.)' },
11
+ { cmd: '/doctor', desc: 'Run system & environment health diagnostics' },
9
12
  { cmd: '/settings', desc: 'Configure permissions & auto-approval' },
10
13
  { cmd: '/update', desc: 'Check and install updates from GitHub' },
11
14
  { cmd: '/model', desc: 'Switch model for current provider' },
@@ -47,8 +50,9 @@ export function askPrompt(opts = {}) {
47
50
  const pastes = [];
48
51
  const imageAttachments = [];
49
52
  const usedImageNames = new Set();
53
+ const theme = getCurrentTheme();
50
54
  // Header printed once
51
- console.log(pc.cyan('◆') + ' ' + pc.bold(msg));
55
+ console.log(theme.colorFn('◆') + ' ' + pc.bold(msg));
52
56
  if (process.stdin.isTTY) {
53
57
  process.stdin.setRawMode(true);
54
58
  }
@@ -56,6 +60,33 @@ export function askPrompt(opts = {}) {
56
60
  process.stdout.write('\x1b[?2004h');
57
61
  }
58
62
  function getDropdownItems() {
63
+ if (input.startsWith('/theme ') || input.startsWith('/themes ') || input === '/theme') {
64
+ const afterCmd = input.replace(/^\/(?:theme|themes)\s*/i, '').trim().toLowerCase();
65
+ const themes = listThemes();
66
+ const matched = themes.filter(t => !afterCmd ||
67
+ t.id.toLowerCase().startsWith(afterCmd) ||
68
+ t.name.toLowerCase().includes(afterCmd));
69
+ const list = [];
70
+ if (!afterCmd || '/theme'.startsWith(input.trim().toLowerCase())) {
71
+ list.push({
72
+ label: '/theme',
73
+ desc: 'Interactive UI theme picker menu',
74
+ replacement: '/theme',
75
+ replaceStart: 0,
76
+ replaceLen: input.length
77
+ });
78
+ }
79
+ for (const t of matched) {
80
+ list.push({
81
+ label: `/theme ${t.id}`,
82
+ desc: `${t.emoji} ${t.name} (${t.desc})`,
83
+ replacement: `/theme ${t.id}`,
84
+ replaceStart: 0,
85
+ replaceLen: input.length
86
+ });
87
+ }
88
+ return list;
89
+ }
59
90
  if (input.startsWith('/')) {
60
91
  const q = input.trim().toLowerCase();
61
92
  const filtered = SLASH_COMMANDS.filter(c => c.cmd.toLowerCase().startsWith(q) || q === '/');
@@ -146,9 +177,9 @@ export function askPrompt(opts = {}) {
146
177
  const labelStr = item.label.length > 20 ? item.label.slice(0, 19) + '…' : item.label.padEnd(20);
147
178
  const maxDescLen = Math.max(6, boxWidth - 25);
148
179
  const descStr = item.desc.length > maxDescLen ? item.desc.slice(0, maxDescLen - 3) + '...' : item.desc.padEnd(maxDescLen);
149
- let row = ` ${isSelected ? pc.cyan('›') : ' '} ${isSelected ? pc.bold(pc.cyan(labelStr)) : pc.white(labelStr)} ${pc.gray(descStr)} `;
180
+ let row = ` ${isSelected ? theme.colorFn('›') : ' '} ${isSelected ? theme.boldFn(labelStr) : pc.white(labelStr)} ${pc.gray(descStr)} `;
150
181
  if (isSelected) {
151
- row = pc.bgCyan(pc.black(` ${labelStr} ${descStr} `));
182
+ row = theme.badgeFn(`› ${labelStr} ${descStr}`);
152
183
  }
153
184
  dropdownLines.push(pc.dim('│') + ' ' + pc.dim('│') + row + pc.dim('│'));
154
185
  }
@@ -4,12 +4,17 @@ import fsSync from 'fs';
4
4
  import path from 'path';
5
5
  import os from 'os';
6
6
  import { spawn } from 'child_process';
7
+ import pc from 'picocolors';
8
+ import qrcode from 'qrcode-terminal';
9
+ import { getTheme } from './theme.js';
7
10
  const MIME_TYPES = {
8
11
  '.html': 'text/html; charset=utf-8',
9
12
  '.htm': 'text/html; charset=utf-8',
10
13
  '.css': 'text/css; charset=utf-8',
11
14
  '.js': 'application/javascript; charset=utf-8',
12
15
  '.mjs': 'application/javascript; charset=utf-8',
16
+ '.ts': 'text/plain; charset=utf-8',
17
+ '.tsx': 'text/plain; charset=utf-8',
13
18
  '.json': 'application/json; charset=utf-8',
14
19
  '.png': 'image/png',
15
20
  '.jpg': 'image/jpeg',
@@ -22,6 +27,8 @@ const MIME_TYPES = {
22
27
  '.mp3': 'audio/mpeg',
23
28
  '.ogg': 'audio/ogg',
24
29
  '.wasm': 'application/wasm',
30
+ '.pdf': 'application/pdf',
31
+ '.md': 'text/markdown; charset=utf-8',
25
32
  '.txt': 'text/plain; charset=utf-8'
26
33
  };
27
34
  let activeServer = null;
@@ -45,12 +52,98 @@ export function getServerPort() {
45
52
  }
46
53
  export function stopServer() {
47
54
  if (activeServer) {
48
- activeServer.close();
55
+ try {
56
+ activeServer.close();
57
+ }
58
+ catch { }
49
59
  activeServer = null;
50
60
  return true;
51
61
  }
52
62
  return false;
53
63
  }
64
+ function renderDirectoryHtml(dirPath, relPath, files, port) {
65
+ const currentRel = relPath === '/' ? '' : relPath;
66
+ const items = files
67
+ .filter(f => !f.name.startsWith('.') && f.name !== 'node_modules')
68
+ .sort((a, b) => {
69
+ if (a.isDirectory() && !b.isDirectory())
70
+ return -1;
71
+ if (!a.isDirectory() && b.isDirectory())
72
+ return 1;
73
+ return a.name.localeCompare(b.name);
74
+ })
75
+ .map(f => {
76
+ const isDir = f.isDirectory();
77
+ const href = `${currentRel}/${encodeURIComponent(f.name)}${isDir ? '/' : ''}`;
78
+ const ext = path.extname(f.name).toLowerCase();
79
+ let icon = isDir ? '📁' : '📄';
80
+ let badge = '';
81
+ if (ext === '.html' || ext === '.htm') {
82
+ icon = '🌐';
83
+ badge = '<span class="badge">HTML</span>';
84
+ }
85
+ else if (['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp'].includes(ext)) {
86
+ icon = '🖼️';
87
+ }
88
+ else if (['.js', '.ts', '.tsx', '.jsx', '.json'].includes(ext)) {
89
+ icon = '⚡';
90
+ }
91
+ else if (ext === '.css') {
92
+ icon = '🎨';
93
+ }
94
+ return `
95
+ <li>
96
+ <a class="file-item" href="${href}">
97
+ <span class="icon">${icon}</span>
98
+ <span class="name">${f.name}</span>
99
+ ${badge}
100
+ <span class="type">${isDir ? 'Directory' : ext || 'File'}</span>
101
+ </a>
102
+ </li>
103
+ `;
104
+ })
105
+ .join('');
106
+ const parentLink = currentRel && currentRel !== '/'
107
+ ? `<li><a class="file-item" href="${path.dirname(currentRel) === '/' ? '/' : path.dirname(currentRel) + '/'}"><span class="icon">⬆️</span><span class="name">.. (Parent Directory)</span></a></li>`
108
+ : '';
109
+ return `<!DOCTYPE html>
110
+ <html lang="en">
111
+ <head>
112
+ <meta charset="utf-8"/>
113
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
114
+ <title>devx Live Preview &bull; ${relPath}</title>
115
+ <style>
116
+ * { box-sizing: border-box; }
117
+ body { background: #0a0a0c; color: #e1e4e8; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, monospace; margin: 0; padding: 24px 16px; }
118
+ .card { background: #111116; border: 1px solid #22222c; border-radius: 12px; max-width: 800px; margin: 0 auto; padding: 24px; box-shadow: 0 8px 30px rgba(0,0,0,0.6); }
119
+ h1 { color: #00f2fe; margin: 0 0 8px 0; font-size: 22px; display: flex; align-items: center; gap: 8px; }
120
+ .info { color: #8b949e; font-size: 14px; margin-bottom: 20px; word-break: break-all; }
121
+ .info code { background: #1a1a24; padding: 2px 6px; border-radius: 4px; color: #f0f6fc; }
122
+ ul { list-style: none; padding: 0; margin: 0; }
123
+ .file-item { display: flex; align-items: center; padding: 10px 14px; border-bottom: 1px solid #1a1a22; text-decoration: none; color: #f0f6fc; border-radius: 6px; transition: all 0.15s; }
124
+ .file-item:hover { background: #1a1a26; transform: translateX(3px); }
125
+ .icon { margin-right: 12px; font-size: 18px; }
126
+ .name { flex: 1; font-weight: 500; font-size: 14px; word-break: break-all; }
127
+ .type { color: #6e7681; font-size: 12px; margin-left: 12px; }
128
+ .badge { background: rgba(0, 242, 254, 0.15); color: #00f2fe; border: 1px solid rgba(0, 242, 254, 0.3); padding: 2px 8px; border-radius: 12px; font-size: 11px; margin-left: 8px; font-weight: bold; }
129
+ .footer { margin-top: 24px; text-align: center; color: #484f58; font-size: 12px; }
130
+ </style>
131
+ </head>
132
+ <body>
133
+ <div class="card">
134
+ <h1>⚡ devx Live Preview</h1>
135
+ <div class="info">
136
+ Path: <code>${relPath}</code> &bull; Port: <code>${port}</code>
137
+ </div>
138
+ <ul>
139
+ ${parentLink}
140
+ ${items || '<li style="padding: 20px; text-align: center; color: #6e7681;">No visible files in this directory</li>'}
141
+ </ul>
142
+ <div class="footer">devx v1.2.0 &bull; Terminal-Native AI Assistant</div>
143
+ </div>
144
+ </body>
145
+ </html>`;
146
+ }
54
147
  export async function startServer(preferredPort = 3000) {
55
148
  if (activeServer) {
56
149
  stopServer();
@@ -59,75 +152,122 @@ export async function startServer(preferredPort = 3000) {
59
152
  return new Promise((resolve, reject) => {
60
153
  const server = http.createServer(async (req, res) => {
61
154
  try {
62
- let reqPath = decodeURIComponent(req.url?.split('?')[0] || '/');
63
- if (reqPath === '/') {
64
- reqPath = '/index.html';
65
- }
66
- const filePath = path.join(process.cwd(), reqPath);
155
+ let rawPath = req.url?.split('?')[0] || '/';
156
+ let reqPath = decodeURIComponent(rawPath);
157
+ const cwd = process.cwd();
158
+ let targetPath = path.resolve(cwd, '.' + reqPath);
67
159
  // Security check: ensure path is within cwd
68
- if (!filePath.startsWith(process.cwd())) {
160
+ const rel = path.relative(cwd, targetPath);
161
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
69
162
  res.writeHead(403, { 'Content-Type': 'text/plain' });
70
163
  res.end('Forbidden');
71
164
  return;
72
165
  }
73
- if (!fsSync.existsSync(filePath)) {
74
- // If html file requested without extension
75
- const withHtml = `${filePath}.html`;
76
- if (fsSync.existsSync(withHtml)) {
77
- const content = await fs.readFile(withHtml);
78
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
79
- res.end(content);
80
- return;
81
- }
82
- res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
83
- res.end(`404 Not Found: ${reqPath}`);
166
+ // Security check: block dotfiles, hidden directories (.env, .git, etc.) and node_modules
167
+ const segments = reqPath.split(/[\/\\]/).filter(Boolean);
168
+ if (segments.some(seg => (seg.startsWith('.') && seg !== '.') || seg === 'node_modules')) {
169
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
170
+ res.end('Forbidden');
84
171
  return;
85
172
  }
86
- const stat = await fs.stat(filePath);
87
- if (stat.isDirectory()) {
88
- const indexPath = path.join(filePath, 'index.html');
89
- if (fsSync.existsSync(indexPath)) {
90
- const content = await fs.readFile(indexPath);
91
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
92
- res.end(content);
173
+ // 1. Root / Directory Request Handling
174
+ if (fsSync.existsSync(targetPath)) {
175
+ const stat = await fs.stat(targetPath);
176
+ if (stat.isDirectory()) {
177
+ // Check for index.html in directory
178
+ const possibleIndexes = [
179
+ path.join(targetPath, 'index.html'),
180
+ path.join(targetPath, 'index.htm'),
181
+ path.join(targetPath, 'public/index.html'),
182
+ path.join(targetPath, 'dist/index.html'),
183
+ path.join(targetPath, 'build/index.html')
184
+ ];
185
+ for (const idxPath of possibleIndexes) {
186
+ if (fsSync.existsSync(idxPath)) {
187
+ const content = await fs.readFile(idxPath);
188
+ res.writeHead(200, {
189
+ 'Content-Type': 'text/html; charset=utf-8',
190
+ 'Access-Control-Allow-Origin': '*'
191
+ });
192
+ res.end(content);
193
+ return;
194
+ }
195
+ }
196
+ // If no index.html, render sleek directory explorer
197
+ const dirents = await fs.readdir(targetPath, { withFileTypes: true });
198
+ const dirHtml = renderDirectoryHtml(targetPath, reqPath, dirents, activePort);
199
+ res.writeHead(200, {
200
+ 'Content-Type': 'text/html; charset=utf-8',
201
+ 'Access-Control-Allow-Origin': '*'
202
+ });
203
+ res.end(dirHtml);
93
204
  return;
94
205
  }
95
- res.writeHead(403, { 'Content-Type': 'text/plain' });
96
- res.end('Directory listing disabled');
206
+ }
207
+ // 2. Fallback check for missing .html extension
208
+ if (!fsSync.existsSync(targetPath)) {
209
+ const withHtml = `${targetPath}.html`;
210
+ if (fsSync.existsSync(withHtml)) {
211
+ targetPath = withHtml;
212
+ }
213
+ }
214
+ // 3. Serve File
215
+ if (fsSync.existsSync(targetPath)) {
216
+ const ext = path.extname(targetPath).toLowerCase();
217
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
218
+ const content = await fs.readFile(targetPath);
219
+ res.writeHead(200, {
220
+ 'Content-Type': contentType,
221
+ 'Access-Control-Allow-Origin': '*'
222
+ });
223
+ res.end(content);
97
224
  return;
98
225
  }
99
- const ext = path.extname(filePath).toLowerCase();
100
- const contentType = MIME_TYPES[ext] || 'application/octet-stream';
101
- const content = await fs.readFile(filePath);
102
- res.writeHead(200, {
103
- 'Content-Type': contentType,
104
- 'Access-Control-Allow-Origin': '*'
105
- });
106
- res.end(content);
226
+ // 4. SPA Fallback: check if root index.html exists
227
+ const rootIndex = path.join(cwd, 'index.html');
228
+ if (fsSync.existsSync(rootIndex)) {
229
+ const content = await fs.readFile(rootIndex);
230
+ res.writeHead(200, {
231
+ 'Content-Type': 'text/html; charset=utf-8',
232
+ 'Access-Control-Allow-Origin': '*'
233
+ });
234
+ res.end(content);
235
+ return;
236
+ }
237
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
238
+ res.end(`404 Not Found: ${reqPath}`);
107
239
  }
108
240
  catch (err) {
109
- res.writeHead(500, { 'Content-Type': 'text/plain' });
241
+ res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
110
242
  res.end(`Internal Server Error: ${err.message}`);
111
243
  }
112
244
  });
245
+ let retryCount = 0;
113
246
  server.on('error', (err) => {
114
247
  if (err.code === 'EADDRINUSE') {
115
- server.listen(activePort + 1);
248
+ retryCount++;
249
+ if (retryCount > 20) {
250
+ reject(new Error(`Could not find an open port after 20 attempts (started at ${preferredPort})`));
251
+ return;
252
+ }
116
253
  activePort++;
254
+ server.listen(activePort, '0.0.0.0');
117
255
  }
118
256
  else {
119
257
  reject(err);
120
258
  }
121
259
  });
122
- server.listen(activePort, () => {
260
+ server.listen(activePort, '0.0.0.0', () => {
123
261
  activeServer = server;
124
262
  const localIp = getLocalIp();
125
263
  const localUrl = `http://localhost:${activePort}`;
126
264
  const networkUrl = `http://${localIp}:${activePort}`;
127
- // If in Android Termux, try to open in browser
265
+ // If on Android Termux, attempt to open browser
128
266
  if (process.env.PREFIX?.includes('com.termux')) {
129
267
  try {
130
- spawn('termux-open-url', [localUrl], { stdio: 'ignore' });
268
+ const opener = spawn('termux-open-url', [localUrl], { stdio: 'ignore', detached: true });
269
+ opener.on('error', () => { });
270
+ opener.unref();
131
271
  }
132
272
  catch { }
133
273
  }
@@ -135,3 +275,41 @@ export async function startServer(preferredPort = 3000) {
135
275
  });
136
276
  });
137
277
  }
278
+ export function getQrCodeString(text) {
279
+ return new Promise((resolve) => {
280
+ try {
281
+ qrcode.generate(text, { small: true }, (qr) => {
282
+ resolve(qr);
283
+ });
284
+ }
285
+ catch {
286
+ resolve('');
287
+ }
288
+ });
289
+ }
290
+ export async function displayServerBanner(localUrl, networkUrl) {
291
+ const th = getTheme();
292
+ const qr = await getQrCodeString(networkUrl);
293
+ const cols = Math.min(process.stdout.columns || 80, 80);
294
+ const cardWidth = Math.max(36, Math.min(cols - 4, 66));
295
+ const innerWidth = cardWidth - 2;
296
+ const title = ' 🌐 Live Web Preview ';
297
+ const topFill = Math.max(2, cardWidth - 3 - title.length);
298
+ console.log('\n' + th.colorFn('┌─') + pc.bold(title) + th.colorFn('─'.repeat(topFill) + '┐'));
299
+ const printRow = (content) => {
300
+ const visibleLength = content.replace(/\u001b\[[0-9;]*m/g, '').length;
301
+ const padding = Math.max(0, innerWidth - visibleLength);
302
+ console.log(th.colorFn('│') + content + ' '.repeat(padding) + th.colorFn('│'));
303
+ };
304
+ printRow(` ${pc.bold('Local:')} ${pc.cyan(localUrl)}`);
305
+ printRow(` ${pc.bold('Network:')} ${pc.green(networkUrl)}`);
306
+ printRow(' ');
307
+ printRow(` ${pc.bold('📱 Mobile QR:')}`);
308
+ if (qr) {
309
+ const qrLines = qr.trim().split('\n');
310
+ for (const ql of qrLines) {
311
+ printRow(` ${ql}`);
312
+ }
313
+ }
314
+ console.log(th.colorFn('└' + '─'.repeat(innerWidth) + '┘\n'));
315
+ }
@@ -0,0 +1,140 @@
1
+ import pc from 'picocolors';
2
+ export const THEMES = {
3
+ cyan: {
4
+ id: 'cyan',
5
+ name: 'Cyan Cyber',
6
+ desc: 'Electric neon cyan & obsidian (Default)',
7
+ emoji: '⚡',
8
+ colorFn: (s) => pc.cyan(s),
9
+ boldFn: (s) => pc.bold(pc.cyan(s)),
10
+ accentFn: (s) => pc.blue(s),
11
+ badgeFn: (s) => pc.bgCyan(pc.black(` ${s} `)),
12
+ diffAddBg: (s) => pc.bgCyan(pc.black(s)),
13
+ diffRemoveBg: (s) => pc.bgBlue(pc.white(s)),
14
+ hex: '#00f2fe'
15
+ },
16
+ purple: {
17
+ id: 'purple',
18
+ name: 'Synthwave Purple',
19
+ desc: 'Vibrant neon magenta & violet retro',
20
+ emoji: '🟣',
21
+ colorFn: (s) => pc.magenta(s),
22
+ boldFn: (s) => pc.bold(pc.magenta(s)),
23
+ accentFn: (s) => pc.blue(s),
24
+ badgeFn: (s) => pc.bgMagenta(pc.black(` ${s} `)),
25
+ diffAddBg: (s) => pc.bgMagenta(pc.black(s)),
26
+ diffRemoveBg: (s) => pc.bgBlue(pc.white(s)),
27
+ hex: '#d946ef'
28
+ },
29
+ matrix: {
30
+ id: 'matrix',
31
+ name: 'Matrix Hacker',
32
+ desc: 'Classic bright phosphor green terminal',
33
+ emoji: '🟢',
34
+ colorFn: (s) => pc.green(s),
35
+ boldFn: (s) => pc.bold(pc.green(s)),
36
+ accentFn: (s) => pc.cyan(s),
37
+ badgeFn: (s) => pc.bgGreen(pc.black(` ${s} `)),
38
+ diffAddBg: (s) => pc.bgGreen(pc.black(s)),
39
+ diffRemoveBg: (s) => pc.bgRed(pc.white(s)),
40
+ hex: '#22c55e'
41
+ },
42
+ amber: {
43
+ id: 'amber',
44
+ name: 'Solar Amber',
45
+ desc: 'Warm vintage CRT amber gold',
46
+ emoji: '🟡',
47
+ colorFn: (s) => pc.yellow(s),
48
+ boldFn: (s) => pc.bold(pc.yellow(s)),
49
+ accentFn: (s) => pc.red(s),
50
+ badgeFn: (s) => pc.bgYellow(pc.black(` ${s} `)),
51
+ diffAddBg: (s) => pc.bgYellow(pc.black(s)),
52
+ diffRemoveBg: (s) => pc.bgRed(pc.white(s)),
53
+ hex: '#f59e0b'
54
+ },
55
+ crimson: {
56
+ id: 'crimson',
57
+ name: 'Ruby Crimson',
58
+ desc: 'Aggressive cyberpunk scarlet red',
59
+ emoji: '🔴',
60
+ colorFn: (s) => pc.red(s),
61
+ boldFn: (s) => pc.bold(pc.red(s)),
62
+ accentFn: (s) => pc.magenta(s),
63
+ badgeFn: (s) => pc.bgRed(pc.white(` ${s} `)),
64
+ diffAddBg: (s) => pc.bgRed(pc.white(s)),
65
+ diffRemoveBg: (s) => pc.bgMagenta(pc.white(s)),
66
+ hex: '#ef4444'
67
+ },
68
+ monochrome: {
69
+ id: 'monochrome',
70
+ name: 'Pure Monochrome',
71
+ desc: 'Crisp minimal pure white & gray',
72
+ emoji: '⚪',
73
+ colorFn: (s) => pc.white(s),
74
+ boldFn: (s) => pc.bold(pc.white(s)),
75
+ accentFn: (s) => pc.dim(s),
76
+ badgeFn: (s) => pc.bgWhite(pc.black(` ${s} `)),
77
+ diffAddBg: (s) => pc.bgWhite(pc.black(s)),
78
+ diffRemoveBg: (s) => pc.bgBlack(pc.white(s)),
79
+ hex: '#ffffff'
80
+ }
81
+ };
82
+ const THEME_ALIASES = {
83
+ '1': 'cyan',
84
+ '2': 'purple',
85
+ '3': 'matrix',
86
+ '4': 'amber',
87
+ '5': 'crimson',
88
+ '6': 'monochrome',
89
+ 'blue': 'cyan',
90
+ 'neon': 'cyan',
91
+ 'cyber': 'cyan',
92
+ 'magenta': 'purple',
93
+ 'violet': 'purple',
94
+ 'pink': 'purple',
95
+ 'synthwave': 'purple',
96
+ 'green': 'matrix',
97
+ 'hacker': 'matrix',
98
+ 'terminal': 'matrix',
99
+ 'yellow': 'amber',
100
+ 'gold': 'amber',
101
+ 'solar': 'amber',
102
+ 'orange': 'amber',
103
+ 'red': 'crimson',
104
+ 'ruby': 'crimson',
105
+ 'cyberpunk': 'crimson',
106
+ 'white': 'monochrome',
107
+ 'mono': 'monochrome',
108
+ 'gray': 'monochrome',
109
+ 'grey': 'monochrome'
110
+ };
111
+ let activeThemeId = 'cyan';
112
+ export function getTheme(themeId) {
113
+ const id = (themeId || activeThemeId).toLowerCase().trim();
114
+ return THEMES[id] || (THEME_ALIASES[id] && THEMES[THEME_ALIASES[id]]) || THEMES.cyan;
115
+ }
116
+ export function findTheme(query) {
117
+ const q = (query || '').toLowerCase().trim();
118
+ if (!q)
119
+ return null;
120
+ if (THEMES[q])
121
+ return THEMES[q];
122
+ if (THEME_ALIASES[q] && THEMES[THEME_ALIASES[q]])
123
+ return THEMES[THEME_ALIASES[q]];
124
+ const all = listThemes();
125
+ const found = all.find(t => t.id.toLowerCase() === q ||
126
+ t.id.toLowerCase().startsWith(q) ||
127
+ t.name.toLowerCase().includes(q));
128
+ return found || null;
129
+ }
130
+ export function setActiveTheme(themeId) {
131
+ const theme = getTheme(themeId);
132
+ activeThemeId = theme.id;
133
+ return theme;
134
+ }
135
+ export function getCurrentTheme() {
136
+ return THEMES[activeThemeId] || THEMES.cyan;
137
+ }
138
+ export function listThemes() {
139
+ return Object.values(THEMES);
140
+ }
package/dist/core/loop.js CHANGED
@@ -88,7 +88,7 @@ export class Agent {
88
88
  yield { type: 'text_delta', delta: chunk.delta };
89
89
  }
90
90
  else if (chunk.type === 'tool_generating') {
91
- yield { type: 'tool_generating', name: chunk.name, bytes: chunk.bytes };
91
+ yield { type: 'tool_generating', name: chunk.name, bytes: chunk.bytes, targetHint: chunk.targetHint };
92
92
  }
93
93
  else if (chunk.type === 'done') {
94
94
  response = chunk.response;
@@ -0,0 +1,40 @@
1
+ import { spawn } from 'child_process';
2
+ export function isTermux() {
3
+ return !!process.env.PREFIX?.includes('com.termux');
4
+ }
5
+ /**
6
+ * Sends a notification and haptic vibration to the device.
7
+ * On Termux: uses termux-notification and termux-vibrate via termux-api.
8
+ * On Desktop: emits terminal bell \u0007.
9
+ */
10
+ export function notifyDevice(title, message, options = { vibrate: true, sound: true }) {
11
+ if (isTermux()) {
12
+ try {
13
+ // 1. Android notification via Termux API
14
+ const notif = spawn('termux-notification', [
15
+ '--title', title,
16
+ '--content', message,
17
+ '--id', 'devx_task_notif',
18
+ '--priority', 'high'
19
+ ], { stdio: 'ignore', detached: true });
20
+ notif.on('error', () => { });
21
+ notif.unref();
22
+ // 2. Haptic vibration (150ms)
23
+ if (options.vibrate !== false) {
24
+ const vib = spawn('termux-vibrate', ['-d', '150'], { stdio: 'ignore', detached: true });
25
+ vib.on('error', () => { });
26
+ vib.unref();
27
+ }
28
+ }
29
+ catch { }
30
+ }
31
+ else {
32
+ // Desktop terminal bell
33
+ if (options.sound !== false) {
34
+ try {
35
+ process.stdout.write('\u0007');
36
+ }
37
+ catch { }
38
+ }
39
+ }
40
+ }
@@ -36,12 +36,8 @@ export class SnapshotManager {
36
36
  });
37
37
  }
38
38
  }
39
- catch {
40
- this.currentTurn.files.set(resolved, {
41
- filePath: resolved,
42
- existed: false,
43
- content: null
44
- });
39
+ catch (err) {
40
+ throw new Error(`Cannot safely snapshot file ${resolved} before edit: ${err.message}`);
45
41
  }
46
42
  }
47
43
  finishTurn() {
@@ -41,8 +41,9 @@ export async function buildSystemPrompt(planMode) {
41
41
  prompt += `- Whenever you modify or create files, use the 'diagnose_code' tool to check for any syntax or type errors.\n`;
42
42
  prompt += `- If any error is found, automatically fix it with 'edit_file' until all diagnostics pass cleanly.\n`;
43
43
  prompt += `- If you need dependencies, use 'install_package' to install them cleanly.\n`;
44
+ prompt += `- Whenever you create or modify web applications, sites, HTML/CSS/JS, canvas games, or React/Vite frontend apps, automatically call the 'serve_preview' tool with action='start' to start the local preview server and display the mobile QR-code for the user!\n`;
44
45
  prompt += `- Use 'save_memory' to remember important architectural decisions, user preferences, or project rules.\n`;
45
- prompt += `Always explain your actions briefly before using tools.\n`;
46
+ prompt += `- Always explain your actions briefly before using tools.\n`;
46
47
  }
47
48
  // Load Project Memory Bank
48
49
  try {
@@ -261,10 +261,32 @@ export class OpenAIProvider {
261
261
  if (tc.function?.arguments)
262
262
  existing.argsStr += tc.function.arguments;
263
263
  toolMap.set(idx, existing);
264
+ // Extract live target hint (e.g. path or command being generated)
265
+ let targetHint = undefined;
266
+ const args = existing.argsStr;
267
+ if (args) {
268
+ const pathMatch = args.match(/"(?:path|filePath|targetFile)"\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/);
269
+ if (pathMatch && pathMatch[1]) {
270
+ targetHint = pathMatch[1];
271
+ }
272
+ else {
273
+ const cmdMatch = args.match(/"(?:command|cmd)"\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/);
274
+ if (cmdMatch && cmdMatch[1]) {
275
+ targetHint = cmdMatch[1].length > 30 ? cmdMatch[1].slice(0, 27) + '...' : cmdMatch[1];
276
+ }
277
+ else {
278
+ const qMatch = args.match(/"(?:query|pattern)"\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/);
279
+ if (qMatch && qMatch[1]) {
280
+ targetHint = `"${qMatch[1]}"`;
281
+ }
282
+ }
283
+ }
284
+ }
264
285
  yield {
265
286
  type: 'tool_generating',
266
287
  name: existing.name || 'tool',
267
- bytes: existing.argsStr.length
288
+ bytes: existing.argsStr.length,
289
+ targetHint
268
290
  };
269
291
  }
270
292
  }