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.
- package/README.md +215 -163
- package/assets/banner.svg +23 -28
- package/assets/preview.png +0 -0
- package/assets/preview.svg +94 -0
- package/dist/cli/banner.js +7 -3
- 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/headless.js +29 -14
- package/dist/cli/index.js +1395 -474
- package/dist/cli/keys.js +69 -0
- package/dist/cli/liveblock.js +45 -0
- package/dist/cli/log.js +19 -0
- package/dist/cli/markdown.js +158 -36
- package/dist/cli/mcp-panel.js +432 -0
- package/dist/cli/permission.js +84 -0
- package/dist/cli/picker.js +135 -0
- package/dist/cli/prompt.js +338 -121
- package/dist/cli/prompt_palette.js +6 -0
- 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 +27 -1
- package/dist/cli/theme.js +126 -108
- package/dist/cli/transcript.js +114 -0
- package/dist/cli/typeahead.js +371 -0
- package/dist/cli/ui.js +1021 -0
- package/dist/cli/updater.js +23 -21
- package/dist/cli/usage-panel.js +242 -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 +210 -0
- package/dist/core/locales/en.js +221 -0
- package/dist/core/locales/es.js +210 -0
- package/dist/core/locales/fr.js +210 -0
- package/dist/core/locales/ja.js +210 -0
- package/dist/core/locales/pt.js +210 -0
- package/dist/core/locales/ru.js +210 -0
- package/dist/core/locales/uk.js +210 -0
- package/dist/core/locales/zh.js +210 -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 +4 -2
- package/assets/preview_work1.png +0 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 940 658" width="100%" role="img" aria-label="The devx terminal interface">
|
|
2
|
+
<title>devx terminal interface</title>
|
|
3
|
+
<defs>
|
|
4
|
+
<clipPath id="clip"><rect x="8" y="8" width="924" height="642" rx="14"/></clipPath>
|
|
5
|
+
</defs>
|
|
6
|
+
<style>
|
|
7
|
+
.t { font-family: 'JetBrains Mono','Fira Code','SF Mono',ui-monospace,Menlo,Consolas,'DejaVu Sans Mono',monospace; font-size: 14px; white-space: pre; }
|
|
8
|
+
.b { font-weight: 700; }
|
|
9
|
+
.i { font-style: italic; }
|
|
10
|
+
.cur { animation: blink 1.1s steps(1) infinite; }
|
|
11
|
+
@keyframes blink { 0%,55% { opacity: 1 } 56%,100% { opacity: 0 } }
|
|
12
|
+
.spin { animation: pulse 1.6s ease-in-out infinite; }
|
|
13
|
+
@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: .35 } }
|
|
14
|
+
@media (prefers-reduced-motion: reduce) { .cur, .spin { animation: none } }
|
|
15
|
+
</style>
|
|
16
|
+
<rect x="8" y="8" width="924" height="642" rx="14" fill="#000000"/>
|
|
17
|
+
<g clip-path="url(#clip)">
|
|
18
|
+
<rect x="8" y="8" width="924" height="34" fill="#0b0b0d"/>
|
|
19
|
+
<rect x="8" y="41" width="924" height="1" fill="#1a1a1f"/>
|
|
20
|
+
</g>
|
|
21
|
+
<rect x="8.5" y="8.5" width="923" height="641" rx="14" fill="none" stroke="#23252c"/>
|
|
22
|
+
<circle cx="30" cy="25" r="6" fill="#ff5f57"/><circle cx="50" cy="25" r="6" fill="#febc2e"/><circle cx="70" cy="25" r="6" fill="#28c840"/>
|
|
23
|
+
<text x="470.0" y="30" text-anchor="middle" class="t" fill="#6b7686" style="font-size:12.5px">devx ~/Projects/shop</text>
|
|
24
|
+
<rect x="28.0" y="45.0" width="453.6" height="93.0" rx="7" fill="none" stroke="#3b4456" stroke-width="1.3"/>
|
|
25
|
+
<rect x="28.0" y="170.0" width="882.0" height="22.0" rx="3" fill="#101014"/>
|
|
26
|
+
<rect x="61.6" y="279.0" width="436.8" height="93.0" rx="7" fill="none" stroke="#3b4456" stroke-width="1.3"/>
|
|
27
|
+
<rect x="66.0" y="273.0" width="125.6" height="19" fill="#000000"/>
|
|
28
|
+
<rect x="28.0" y="538.0" width="882.0" height="45.0" rx="9" fill="none" stroke="#3b4456" stroke-width="1.3"/>
|
|
29
|
+
<rect x="32.4" y="530.0" width="83.6" height="19" fill="#000000"/>
|
|
30
|
+
<rect x="603.6" y="530.0" width="293.6" height="19" fill="#000000"/>
|
|
31
|
+
<rect x="32.4" y="574.0" width="150.8" height="19" fill="#000000"/>
|
|
32
|
+
<rect x="595.2" y="574.0" width="243.2" height="19" fill="#000000"/>
|
|
33
|
+
<text x="44.8" y="62.0" class="t" fill="#b794f6">▌</text>
|
|
34
|
+
<text x="61.6" y="62.0" class="t b" fill="#f4f7fb">devx</text>
|
|
35
|
+
<text x="112.0" y="62.0" class="t" fill="#5f6b7a">v1.8.0</text>
|
|
36
|
+
<text x="61.6" y="106.0" class="t" fill="#5f6b7a">model</text>
|
|
37
|
+
<text x="128.8" y="106.0" class="t" fill="#e5eaf1">deepseek-v4.1-flash · think high</text>
|
|
38
|
+
<text x="406.0" y="106.0" class="t" fill="#5f6b7a">/model</text>
|
|
39
|
+
<text x="61.6" y="128.0" class="t" fill="#5f6b7a">cwd</text>
|
|
40
|
+
<text x="128.8" y="128.0" class="t" fill="#e5eaf1">~/Projects/shop</text>
|
|
41
|
+
<text x="36.4" y="156.0" class="t b" fill="#b794f6">tip</text>
|
|
42
|
+
<text x="78.4" y="156.0" class="t" fill="#9aa6b4">/rewind goes back to one of your messages</text>
|
|
43
|
+
<text x="36.4" y="186.0" class="t b" fill="#b794f6">❯</text>
|
|
44
|
+
<text x="53.2" y="186.0" class="t" fill="#e5eaf1">add a dark mode toggle to the settings page</text>
|
|
45
|
+
<text x="44.8" y="214.0" class="t" fill="#5f6b7a">∴</text>
|
|
46
|
+
<text x="61.6" y="214.0" class="t" fill="#5f6b7a">thought for 2.1s · 148 tok · 71 tok/s</text>
|
|
47
|
+
<text x="380.8" y="214.0" class="t i" fill="#5f6b7a">(ctrl+p to expand)</text>
|
|
48
|
+
<text x="36.4" y="244.0" class="t" fill="#5ed3e0">●</text>
|
|
49
|
+
<text x="53.2" y="244.0" class="t" fill="#f4f7fb">I'll check how settings are stored, then add the toggle.</text>
|
|
50
|
+
<text x="53.2" y="274.0" class="t" fill="#b794f6">◆</text>
|
|
51
|
+
<text x="70.0" y="274.0" class="t b" fill="#f4f7fb">Update</text>
|
|
52
|
+
<text x="128.8" y="274.0" class="t" fill="#9aa6b4">Plan & Tasks</text>
|
|
53
|
+
<text x="78.4" y="296.0" class="t b" fill="#5ed3e0">Plan & Tasks</text>
|
|
54
|
+
<text x="448.0" y="296.0" class="t" fill="#9aa6b4">1/3</text>
|
|
55
|
+
<text x="78.4" y="318.0" class="t" fill="#4ade80">[✓]</text>
|
|
56
|
+
<text x="112.0" y="318.0" class="t" fill="#5f6b7a">read the settings module</text>
|
|
57
|
+
<text x="78.4" y="340.0" class="t b" fill="#b794f6">[•]</text>
|
|
58
|
+
<text x="112.0" y="340.0" class="t b" fill="#f4f7fb">add the toggle and persist it</text>
|
|
59
|
+
<text x="78.4" y="362.0" class="t" fill="#5f6b7a">[ ]</text>
|
|
60
|
+
<text x="112.0" y="362.0" class="t" fill="#e5eaf1">run the tests</text>
|
|
61
|
+
<text x="53.2" y="392.0" class="t" fill="#b794f6">◆</text>
|
|
62
|
+
<text x="70.0" y="392.0" class="t b" fill="#f4f7fb">Read</text>
|
|
63
|
+
<text x="120.4" y="392.0" class="t" fill="#e5eaf1">src/settings.ts</text>
|
|
64
|
+
<text x="70.0" y="414.0" class="t" fill="#5f6b7a">╰</text>
|
|
65
|
+
<text x="86.8" y="414.0" class="t" fill="#9aa6b4">84 lines</text>
|
|
66
|
+
<text x="53.2" y="436.0" class="t" fill="#b794f6">◆</text>
|
|
67
|
+
<text x="70.0" y="436.0" class="t b" fill="#f4f7fb">Run</text>
|
|
68
|
+
<text x="112.0" y="436.0" class="t" fill="#e5eaf1">npm test</text>
|
|
69
|
+
<text x="70.0" y="458.0" class="t" fill="#5f6b7a">╰</text>
|
|
70
|
+
<text x="86.8" y="458.0" class="t" fill="#9aa6b4">24 passed</text>
|
|
71
|
+
<text x="86.8" y="480.0" class="t" fill="#5f6b7a">… +3 lines (ctrl+o to expand)</text>
|
|
72
|
+
<text x="44.8" y="512.0" class="t spin" fill="#5ed3e0">⠹</text>
|
|
73
|
+
<text x="61.6" y="512.0" class="t" fill="#e5eaf1">Writing</text>
|
|
74
|
+
<text x="145.6" y="512.0" class="t" fill="#5f6b7a">(12s · ↓ 1.4k tokens · ctrl+c to stop)</text>
|
|
75
|
+
<text x="44.8" y="544.0" class="t" fill="#5ed3e0">●</text>
|
|
76
|
+
<text x="61.6" y="544.0" class="t b" fill="#5ed3e0">agent</text>
|
|
77
|
+
<text x="607.6" y="544.0" class="t" fill="#9aa6b4">deepseek-v4.1-flash · think high</text>
|
|
78
|
+
<text x="44.8" y="566.0" class="t b" fill="#b794f6">❯</text>
|
|
79
|
+
<text x="70.0" y="566.0" class="t" fill="#5f6b7a">Ask or describe a task…</text>
|
|
80
|
+
<text x="44.8" y="588.0" class="t" fill="#9aa6b4">~/Projects/shop</text>
|
|
81
|
+
<text x="607.6" y="588.0" class="t" fill="#5f6b7a">ctx</text>
|
|
82
|
+
<text x="649.6" y="588.0" class="t" fill="#5ed3e0">━━━━</text>
|
|
83
|
+
<text x="683.2" y="588.0" class="t" fill="#3b4456">────</text>
|
|
84
|
+
<text x="733.6" y="588.0" class="t" fill="#9aa6b4">4% · $0.0123</text>
|
|
85
|
+
<text x="36.4" y="610.0" class="t" fill="#e5eaf1">tab</text>
|
|
86
|
+
<text x="70.0" y="610.0" class="t" fill="#5f6b7a">plan mode</text>
|
|
87
|
+
<text x="162.4" y="610.0" class="t" fill="#e5eaf1">/</text>
|
|
88
|
+
<text x="179.2" y="610.0" class="t" fill="#5f6b7a">commands</text>
|
|
89
|
+
<text x="271.6" y="610.0" class="t" fill="#e5eaf1">@</text>
|
|
90
|
+
<text x="288.4" y="610.0" class="t" fill="#5f6b7a">files</text>
|
|
91
|
+
<text x="355.6" y="610.0" class="t" fill="#e5eaf1">ctrl+c</text>
|
|
92
|
+
<text x="414.4" y="610.0" class="t" fill="#5f6b7a">stop</text>
|
|
93
|
+
<rect class="cur" x="61.6" y="553.0" width="8.4" height="17" fill="#5ed3e0"/>
|
|
94
|
+
</svg>
|
package/dist/cli/banner.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import pc from 'picocolors';
|
|
2
2
|
import { getCurrentTheme } from './theme.js';
|
|
3
|
-
|
|
3
|
+
import { DEVX_VERSION } from '../core/types.js';
|
|
4
|
+
export function renderBannerLines(mode = 'full', version = DEVX_VERSION, cols = 80) {
|
|
4
5
|
if (mode === 'off') {
|
|
5
6
|
return [];
|
|
6
7
|
}
|
|
7
8
|
const theme = getCurrentTheme();
|
|
9
|
+
if (mode === 'clean') {
|
|
10
|
+
return [pc.bold('devx') + pc.dim(` v${version}`)];
|
|
11
|
+
}
|
|
8
12
|
if (mode === 'minimal') {
|
|
9
13
|
return [
|
|
10
|
-
theme.
|
|
14
|
+
theme.boldFn('TERMUX·DEV') + ' ' + pc.dim(`v${version}`) + pc.dim(' · Type /help for commands')
|
|
11
15
|
];
|
|
12
16
|
}
|
|
13
17
|
// Full ASCII Banner
|
|
@@ -29,7 +33,7 @@ export function renderBannerLines(mode = 'full', version = '1.5.0', cols = 80) {
|
|
|
29
33
|
indent + theme.boldFn(`v${version}`)
|
|
30
34
|
];
|
|
31
35
|
}
|
|
32
|
-
export function drawBanner(mode = 'full', version =
|
|
36
|
+
export function drawBanner(mode = 'full', version = DEVX_VERSION, cols = 80) {
|
|
33
37
|
const lines = renderBannerLines(mode, version, cols);
|
|
34
38
|
if (lines.length > 0) {
|
|
35
39
|
console.log();
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
export const runWriter = (command, args, input) => new Promise((resolve) => {
|
|
4
|
+
try {
|
|
5
|
+
const child = spawn(command, args, { stdio: ['pipe', 'ignore', 'ignore'] });
|
|
6
|
+
let settled = false;
|
|
7
|
+
const done = (ok) => { if (!settled) {
|
|
8
|
+
settled = true;
|
|
9
|
+
resolve(ok);
|
|
10
|
+
} };
|
|
11
|
+
const timer = setTimeout(() => { try {
|
|
12
|
+
child.kill();
|
|
13
|
+
}
|
|
14
|
+
catch { } done(false); }, 5000);
|
|
15
|
+
child.on('error', () => { clearTimeout(timer); done(false); });
|
|
16
|
+
child.on('close', (code) => { clearTimeout(timer); done(code === 0); });
|
|
17
|
+
child.stdin.on('error', () => { });
|
|
18
|
+
child.stdin.end(input);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
resolve(false);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
/** The OSC 52 sequence for a text: the terminal puts it on the clipboard. */
|
|
25
|
+
export function osc52Sequence(text) {
|
|
26
|
+
return `\x1b]52;c;${Buffer.from(text, 'utf8').toString('base64')}\x07`;
|
|
27
|
+
}
|
|
28
|
+
const OSC52_MAX_BYTES = 74000; // many terminals refuse more than ~100 KB of base64
|
|
29
|
+
export async function copyToClipboard(text, ctx = {}) {
|
|
30
|
+
const platform = ctx.platform ?? os.platform();
|
|
31
|
+
const env = ctx.env ?? process.env;
|
|
32
|
+
const run = ctx.run ?? runWriter;
|
|
33
|
+
const write = ctx.write ?? ((t) => process.stdout.write(t));
|
|
34
|
+
const candidates = [];
|
|
35
|
+
if (env.TERMUX_VERSION || env.PREFIX?.includes('com.termux'))
|
|
36
|
+
candidates.push(['termux', 'termux-clipboard-set', []]);
|
|
37
|
+
if (platform === 'darwin')
|
|
38
|
+
candidates.push(['pbcopy', 'pbcopy', []]);
|
|
39
|
+
else if (platform === 'win32')
|
|
40
|
+
candidates.push(['powershell', 'powershell', ['-NoProfile', '-Command', 'Set-Clipboard -Value ([Console]::In.ReadToEnd())']]);
|
|
41
|
+
else {
|
|
42
|
+
if (env.WAYLAND_DISPLAY)
|
|
43
|
+
candidates.push(['wl-copy', 'wl-copy', []]);
|
|
44
|
+
candidates.push(['xclip', 'xclip', ['-selection', 'clipboard']], ['xsel', 'xsel', ['--clipboard', '--input']]);
|
|
45
|
+
}
|
|
46
|
+
for (const [method, command, args] of candidates) {
|
|
47
|
+
if (await run(command, args, text))
|
|
48
|
+
return method;
|
|
49
|
+
}
|
|
50
|
+
if (Buffer.byteLength(text, 'utf8') <= OSC52_MAX_BYTES) {
|
|
51
|
+
write(osc52Sequence(text));
|
|
52
|
+
return 'osc52';
|
|
53
|
+
}
|
|
54
|
+
return 'none';
|
|
55
|
+
}
|
package/dist/cli/clipboard.js
CHANGED
|
@@ -2,8 +2,20 @@ import fs from 'fs/promises';
|
|
|
2
2
|
import fsSync from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import os from 'os';
|
|
5
|
-
import {
|
|
6
|
-
|
|
5
|
+
import { execFile } from 'child_process';
|
|
6
|
+
/**
|
|
7
|
+
* Images and text from the system clipboard, and image files pasted as paths.
|
|
8
|
+
*
|
|
9
|
+
* Pasted images are stored in `~/.devx/media` (not in the project folder, so nothing is left
|
|
10
|
+
* behind in the user's repository). The prompt shows them as `[Image #1 203kb]` and the
|
|
11
|
+
* message carries the file path, which is what resolveAtMentions turns into a real image part.
|
|
12
|
+
*/
|
|
13
|
+
export const MEDIA_DIR = path.join(os.homedir(), '.devx', 'media');
|
|
14
|
+
/** Formats the image APIs accept. */
|
|
15
|
+
export const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']);
|
|
16
|
+
/** The text that stands for an image in the prompt, and how to find it again. */
|
|
17
|
+
export const IMAGE_TAG_SOURCE = '\\[Image #\\d+ [^\\]\\n]{1,12}\\]';
|
|
18
|
+
export const imageTag = (index, sizeStr) => `[Image #${index} ${sizeStr}]`;
|
|
7
19
|
export function expandHomePath(p) {
|
|
8
20
|
if (!p)
|
|
9
21
|
return p;
|
|
@@ -22,124 +34,188 @@ export function formatFileSize(bytes) {
|
|
|
22
34
|
const mb = (bytes / (1024 * 1024)).toFixed(1);
|
|
23
35
|
return `${mb}mb`;
|
|
24
36
|
}
|
|
37
|
+
/** The format of an image by its first bytes; the clipboard's own type name is not trusted. */
|
|
38
|
+
export function sniffImage(buf) {
|
|
39
|
+
if (buf.length < 12)
|
|
40
|
+
return null;
|
|
41
|
+
if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47)
|
|
42
|
+
return { ext: 'png', mime: 'image/png' };
|
|
43
|
+
if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff)
|
|
44
|
+
return { ext: 'jpg', mime: 'image/jpeg' };
|
|
45
|
+
if (buf.toString('latin1', 0, 4) === 'GIF8')
|
|
46
|
+
return { ext: 'gif', mime: 'image/gif' };
|
|
47
|
+
if (buf.toString('latin1', 0, 4) === 'RIFF' && buf.toString('latin1', 8, 12) === 'WEBP')
|
|
48
|
+
return { ext: 'webp', mime: 'image/webp' };
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
/** A name that is safe in a path typed after "@" and unique across sessions. */
|
|
52
|
+
function newImageName(ext, taken) {
|
|
53
|
+
const d = new Date();
|
|
54
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
55
|
+
const stamp = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
|
56
|
+
let name = `image-${stamp}.${ext}`;
|
|
57
|
+
for (let i = 2; taken.has(name) || fsSync.existsSync(path.join(MEDIA_DIR, name)); i++)
|
|
58
|
+
name = `image-${stamp}-${i}.${ext}`;
|
|
59
|
+
return name;
|
|
60
|
+
}
|
|
61
|
+
/** Letters, digits, dot, dash and underscore only: such a name always survives an "@mention". */
|
|
62
|
+
export function safeFileName(name) {
|
|
63
|
+
const cleaned = name.replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^\.+/, '');
|
|
64
|
+
return cleaned || 'image.png';
|
|
65
|
+
}
|
|
25
66
|
export function getUniqueImageName(baseName, existingNames) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const ext = path.extname(
|
|
30
|
-
const
|
|
31
|
-
let
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
? `${nameWithoutExt}(copy)${ext}`
|
|
35
|
-
: `${nameWithoutExt}(copy ${copyIndex})${ext}`;
|
|
36
|
-
if (!existingNames.has(candidate)) {
|
|
67
|
+
const safe = safeFileName(baseName);
|
|
68
|
+
if (!existingNames.has(safe) && !fsSync.existsSync(path.join(MEDIA_DIR, safe)))
|
|
69
|
+
return safe;
|
|
70
|
+
const ext = path.extname(safe);
|
|
71
|
+
const stem = path.basename(safe, ext);
|
|
72
|
+
for (let i = 2;; i++) {
|
|
73
|
+
const candidate = `${stem}-${i}${ext}`;
|
|
74
|
+
if (!existingNames.has(candidate) && !fsSync.existsSync(path.join(MEDIA_DIR, candidate)))
|
|
37
75
|
return candidate;
|
|
38
|
-
}
|
|
39
|
-
copyIndex++;
|
|
40
76
|
}
|
|
41
77
|
}
|
|
42
|
-
export
|
|
43
|
-
|
|
44
|
-
|
|
78
|
+
export const runTool = (command, args) => new Promise((resolve) => {
|
|
79
|
+
try {
|
|
80
|
+
execFile(command, args, { encoding: 'buffer', maxBuffer: 64 * 1024 * 1024, timeout: 8000 }, (err, stdout) => {
|
|
81
|
+
resolve(err ? null : stdout);
|
|
82
|
+
});
|
|
45
83
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
Write-Output "SAVED"
|
|
59
|
-
} else {
|
|
60
|
-
Write-Output "NO_IMAGE"
|
|
84
|
+
catch {
|
|
85
|
+
resolve(null);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
const IMAGE_PREFERENCE = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'];
|
|
89
|
+
function pickImageType(types) {
|
|
90
|
+
const lower = types.map((t) => t.trim().toLowerCase());
|
|
91
|
+
for (const wanted of IMAGE_PREFERENCE)
|
|
92
|
+
if (lower.includes(wanted))
|
|
93
|
+
return wanted;
|
|
94
|
+
// image/jpg, image/x-png and similar spellings
|
|
95
|
+
return lower.find((t) => /^image\/(png|jpe?g|webp|gif)/.test(t)) ?? null;
|
|
61
96
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
else {
|
|
96
|
-
// Linux / Termux: try xclip / wl-paste
|
|
97
|
-
exec(`xclip -selection clipboard -t image/png -o > "${targetPath}" 2>/dev/null || wl-paste -t image/png > "${targetPath}" 2>/dev/null`, () => {
|
|
98
|
-
if (fsSync.existsSync(targetPath)) {
|
|
99
|
-
const stat = fsSync.statSync(targetPath);
|
|
100
|
-
if (stat.size > 100) {
|
|
101
|
-
return resolve({
|
|
102
|
-
fileName: uniqueName,
|
|
103
|
-
filePath: targetPath,
|
|
104
|
-
size: stat.size,
|
|
105
|
-
sizeStr: formatFileSize(stat.size)
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
try {
|
|
109
|
-
fsSync.unlinkSync(targetPath);
|
|
110
|
-
}
|
|
111
|
-
catch { }
|
|
112
|
-
}
|
|
113
|
-
resolve(null);
|
|
114
|
-
});
|
|
115
|
-
}
|
|
97
|
+
/** The raw bytes of the image on the clipboard, or null when there is none (or no tool to read it). */
|
|
98
|
+
export async function readClipboardImageBytes(run = runTool, ctx = { platform: os.platform(), env: process.env }) {
|
|
99
|
+
const { platform, env } = ctx;
|
|
100
|
+
if (platform === 'win32') {
|
|
101
|
+
const script = [
|
|
102
|
+
'Add-Type -AssemblyName System.Windows.Forms',
|
|
103
|
+
'Add-Type -AssemblyName System.Drawing',
|
|
104
|
+
'$img = [System.Windows.Forms.Clipboard]::GetImage()',
|
|
105
|
+
'if ($img -ne $null) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [Console]::OpenStandardOutput().Write($ms.ToArray(), 0, $ms.Length) }',
|
|
106
|
+
].join('; ');
|
|
107
|
+
return run('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')]);
|
|
108
|
+
}
|
|
109
|
+
if (platform === 'darwin') {
|
|
110
|
+
return run('pngpaste', ['-']);
|
|
111
|
+
}
|
|
112
|
+
// Termux has no way to read an image from the clipboard.
|
|
113
|
+
if (env.PREFIX?.includes('com.termux'))
|
|
114
|
+
return null;
|
|
115
|
+
const wayland = !!env.WAYLAND_DISPLAY;
|
|
116
|
+
const attempts = [];
|
|
117
|
+
if (wayland) {
|
|
118
|
+
attempts.push(async () => {
|
|
119
|
+
const list = await run('wl-paste', ['--list-types']);
|
|
120
|
+
const type = list ? pickImageType(list.toString('utf8').split('\n')) : null;
|
|
121
|
+
return type ? run('wl-paste', ['--no-newline', '--type', type]) : null;
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
attempts.push(async () => {
|
|
125
|
+
const list = await run('xclip', ['-selection', 'clipboard', '-t', 'TARGETS', '-o']);
|
|
126
|
+
const type = list ? pickImageType(list.toString('utf8').split('\n')) : null;
|
|
127
|
+
return type ? run('xclip', ['-selection', 'clipboard', '-t', type, '-o']) : null;
|
|
116
128
|
});
|
|
129
|
+
if (!wayland) {
|
|
130
|
+
attempts.push(async () => {
|
|
131
|
+
const list = await run('wl-paste', ['--list-types']);
|
|
132
|
+
const type = list ? pickImageType(list.toString('utf8').split('\n')) : null;
|
|
133
|
+
return type ? run('wl-paste', ['--no-newline', '--type', type]) : null;
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
for (const attempt of attempts) {
|
|
137
|
+
const bytes = await attempt();
|
|
138
|
+
if (bytes && bytes.length > 0)
|
|
139
|
+
return bytes;
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
/** The text on the clipboard, or null. Used when Ctrl+V arrives as a key and the terminal did not paste. */
|
|
144
|
+
export async function readClipboardText(run = runTool, ctx = { platform: os.platform(), env: process.env }) {
|
|
145
|
+
const { platform, env } = ctx;
|
|
146
|
+
let out = null;
|
|
147
|
+
if (platform === 'win32')
|
|
148
|
+
out = await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', 'Get-Clipboard -Raw']);
|
|
149
|
+
else if (platform === 'darwin')
|
|
150
|
+
out = await run('pbpaste', []);
|
|
151
|
+
else if (env.PREFIX?.includes('com.termux'))
|
|
152
|
+
out = await run('termux-clipboard-get', []);
|
|
153
|
+
else {
|
|
154
|
+
if (env.WAYLAND_DISPLAY)
|
|
155
|
+
out = await run('wl-paste', ['--no-newline', '--type', 'text']);
|
|
156
|
+
out ??= await run('xclip', ['-selection', 'clipboard', '-o']);
|
|
157
|
+
out ??= await run('xsel', ['--clipboard', '--output']);
|
|
158
|
+
if (!env.WAYLAND_DISPLAY)
|
|
159
|
+
out ??= await run('wl-paste', ['--no-newline', '--type', 'text']);
|
|
160
|
+
}
|
|
161
|
+
const text = out?.toString('utf8') ?? '';
|
|
162
|
+
return text.length > 0 ? text : null;
|
|
163
|
+
}
|
|
164
|
+
async function store(bytes, name) {
|
|
165
|
+
await fs.mkdir(MEDIA_DIR, { recursive: true, mode: 0o700 });
|
|
166
|
+
const filePath = path.join(MEDIA_DIR, name);
|
|
167
|
+
await fs.writeFile(filePath, bytes, { mode: 0o600 });
|
|
168
|
+
return { fileName: name, filePath, size: bytes.length, sizeStr: formatFileSize(bytes.length) };
|
|
117
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* Saves the image on the clipboard to ~/.devx/media.
|
|
172
|
+
* `_preferredName` is kept for older callers; the file is named by the time and its real format.
|
|
173
|
+
*/
|
|
174
|
+
export async function saveClipboardImage(_preferredName, existingNames, run = runTool, ctx) {
|
|
175
|
+
const bytes = await readClipboardImageBytes(run, ctx);
|
|
176
|
+
if (!bytes)
|
|
177
|
+
return null;
|
|
178
|
+
const kind = sniffImage(bytes);
|
|
179
|
+
if (!kind)
|
|
180
|
+
return null;
|
|
181
|
+
return store(bytes, newImageName(kind.ext, existingNames));
|
|
182
|
+
}
|
|
183
|
+
/** Copies an image file (a pasted or dropped path) into ~/.devx/media under a safe name. */
|
|
118
184
|
export async function processPastedFilePath(rawPath, existingNames) {
|
|
119
185
|
const cleanPath = expandHomePath(rawPath).trim().replace(/^['"&]+|['"]+$/g, '').trim();
|
|
120
186
|
const resolved = path.resolve(process.cwd(), cleanPath);
|
|
121
|
-
const imageExts = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.svg', '.bmp']);
|
|
122
187
|
const ext = path.extname(resolved).toLowerCase();
|
|
123
|
-
if (
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
188
|
+
if (!IMAGE_EXTENSIONS.has(ext))
|
|
189
|
+
return null;
|
|
190
|
+
try {
|
|
191
|
+
const stat = await fs.stat(resolved);
|
|
192
|
+
if (!stat.isFile())
|
|
193
|
+
return null;
|
|
194
|
+
await fs.mkdir(MEDIA_DIR, { recursive: true, mode: 0o700 });
|
|
195
|
+
const name = getUniqueImageName(path.basename(resolved), existingNames);
|
|
196
|
+
await fs.copyFile(resolved, path.join(MEDIA_DIR, name));
|
|
197
|
+
return { fileName: name, filePath: path.join(MEDIA_DIR, name), size: stat.size, sizeStr: formatFileSize(stat.size) };
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/** Deletes pasted images older than `days` days. Never throws. */
|
|
204
|
+
export async function pruneMedia(days = 14, now = Date.now()) {
|
|
205
|
+
let removed = 0;
|
|
206
|
+
try {
|
|
207
|
+
for (const entry of await fs.readdir(MEDIA_DIR)) {
|
|
208
|
+
const file = path.join(MEDIA_DIR, entry);
|
|
209
|
+
try {
|
|
210
|
+
const stat = await fs.stat(file);
|
|
211
|
+
if (stat.isFile() && now - stat.mtimeMs > days * 86_400_000) {
|
|
212
|
+
await fs.unlink(file);
|
|
213
|
+
removed++;
|
|
131
214
|
}
|
|
132
|
-
const targetPath = path.join(MEDIA_DIR, uniqueName);
|
|
133
|
-
await fs.copyFile(resolved, targetPath);
|
|
134
|
-
return {
|
|
135
|
-
fileName: uniqueName,
|
|
136
|
-
filePath: targetPath,
|
|
137
|
-
size: stat.size,
|
|
138
|
-
sizeStr: formatFileSize(stat.size)
|
|
139
|
-
};
|
|
140
215
|
}
|
|
216
|
+
catch { }
|
|
141
217
|
}
|
|
142
|
-
catch { }
|
|
143
218
|
}
|
|
144
|
-
|
|
219
|
+
catch { }
|
|
220
|
+
return removed;
|
|
145
221
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
/**
|
|
3
|
+
* Colour helpers for themes.
|
|
4
|
+
*
|
|
5
|
+
* Themes are defined as hex values. On a truecolor terminal they are used exactly; on a
|
|
6
|
+
* 256-colour terminal (older Termux builds, ssh into a plain xterm) each colour is mapped to
|
|
7
|
+
* the closest entry of the xterm palette. With colours switched off everything is plain text.
|
|
8
|
+
*/
|
|
9
|
+
export function hexToRgb(hex) {
|
|
10
|
+
const h = hex.replace('#', '');
|
|
11
|
+
const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h;
|
|
12
|
+
return [parseInt(full.slice(0, 2), 16), parseInt(full.slice(2, 4), 16), parseInt(full.slice(4, 6), 16)];
|
|
13
|
+
}
|
|
14
|
+
const CUBE_LEVELS = [0, 95, 135, 175, 215, 255];
|
|
15
|
+
const distance = (a, b) => (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2;
|
|
16
|
+
/** Closest xterm-256 palette index for an RGB colour (colour cube or grayscale ramp). */
|
|
17
|
+
export function rgbTo256(r, g, b) {
|
|
18
|
+
const nearestLevel = (v) => CUBE_LEVELS.reduce((best, level, i) => (Math.abs(level - v) < Math.abs(CUBE_LEVELS[best] - v) ? i : best), 0);
|
|
19
|
+
const ri = nearestLevel(r);
|
|
20
|
+
const gi = nearestLevel(g);
|
|
21
|
+
const bi = nearestLevel(b);
|
|
22
|
+
const cubeIndex = 16 + 36 * ri + 6 * gi + bi;
|
|
23
|
+
const cubeRgb = [CUBE_LEVELS[ri], CUBE_LEVELS[gi], CUBE_LEVELS[bi]];
|
|
24
|
+
const average = (r + g + b) / 3;
|
|
25
|
+
const grayStep = Math.max(0, Math.min(23, Math.round((average - 8) / 10)));
|
|
26
|
+
const grayValue = 8 + 10 * grayStep;
|
|
27
|
+
const grayIndex = 232 + grayStep;
|
|
28
|
+
const target = [r, g, b];
|
|
29
|
+
return distance(target, [grayValue, grayValue, grayValue]) < distance(target, cubeRgb) ? grayIndex : cubeIndex;
|
|
30
|
+
}
|
|
31
|
+
export function supportsTruecolor(env = process.env) {
|
|
32
|
+
const forced = (env.DEVX_COLOR || '').toLowerCase();
|
|
33
|
+
if (forced === '256')
|
|
34
|
+
return false;
|
|
35
|
+
if (forced === 'truecolor' || forced === '24bit')
|
|
36
|
+
return true;
|
|
37
|
+
const colorterm = (env.COLORTERM || '').toLowerCase();
|
|
38
|
+
return colorterm.includes('truecolor') || colorterm.includes('24bit');
|
|
39
|
+
}
|
|
40
|
+
function sgr(hex, base, opts) {
|
|
41
|
+
const [r, g, b] = hexToRgb(hex);
|
|
42
|
+
const truecolor = opts.truecolor ?? supportsTruecolor();
|
|
43
|
+
return truecolor ? `\x1b[${base};2;${r};${g};${b}m` : `\x1b[${base};5;${rgbTo256(r, g, b)}m`;
|
|
44
|
+
}
|
|
45
|
+
/** Foreground colour from a hex value. */
|
|
46
|
+
export function fgHex(hex, opts = {}) {
|
|
47
|
+
return (text) => ((opts.enabled ?? pc.isColorSupported) ? `${sgr(hex, 38, opts)}${text}\x1b[39m` : text);
|
|
48
|
+
}
|
|
49
|
+
/** Background colour from a hex value. */
|
|
50
|
+
export function bgHex(hex, opts = {}) {
|
|
51
|
+
return (text) => ((opts.enabled ?? pc.isColorSupported) ? `${sgr(hex, 48, opts)}${text}\x1b[49m` : text);
|
|
52
|
+
}
|
|
53
|
+
/** WCAG relative luminance of a hex colour. */
|
|
54
|
+
export function relativeLuminance(hex) {
|
|
55
|
+
const channel = (v) => {
|
|
56
|
+
const c = v / 255;
|
|
57
|
+
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
58
|
+
};
|
|
59
|
+
const [r, g, b] = hexToRgb(hex);
|
|
60
|
+
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
|
61
|
+
}
|
|
62
|
+
/** WCAG contrast ratio between two hex colours (1 to 21). */
|
|
63
|
+
export function contrastRatio(a, b) {
|
|
64
|
+
const la = relativeLuminance(a);
|
|
65
|
+
const lb = relativeLuminance(b);
|
|
66
|
+
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
|
67
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { runPicker } from './picker.js';
|
|
2
|
+
import { palette, renderNotice } from './ui.js';
|
|
3
|
+
import { wrapWords } from './width.js';
|
|
4
|
+
import { renderAnswerRow } from '../tools/questions.js';
|
|
5
|
+
import { t } from '../core/i18n.js';
|
|
6
|
+
/** A yes / no question in the style of the rest of devx: the question, a small card with two choices. Esc means "no". */
|
|
7
|
+
export async function askConfirm(options) {
|
|
8
|
+
const cols = process.stdout.columns || 80;
|
|
9
|
+
console.log();
|
|
10
|
+
for (const line of wrapWords(options.question, Math.max(20, cols - 8)))
|
|
11
|
+
console.log(' ' + palette.bold(palette.text(line)));
|
|
12
|
+
for (const line of options.details ?? [])
|
|
13
|
+
console.log(' ' + palette.faint(line));
|
|
14
|
+
const yes = options.yes ?? t('confirm.yes');
|
|
15
|
+
const no = options.no ?? t('confirm.no');
|
|
16
|
+
const answer = await runPicker({
|
|
17
|
+
title: options.title,
|
|
18
|
+
items: [
|
|
19
|
+
{ value: true, label: yes },
|
|
20
|
+
{ value: false, label: no },
|
|
21
|
+
],
|
|
22
|
+
current: options.initial === true,
|
|
23
|
+
});
|
|
24
|
+
const result = answer === true;
|
|
25
|
+
if (options.record !== false)
|
|
26
|
+
console.log(renderAnswerRow(options.question, result ? yes : no, false, cols));
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
/** A closing line such as "Goodbye": quiet, in the same style as the other notices. */
|
|
30
|
+
export function printFarewell(message, kind = 'info') {
|
|
31
|
+
console.log();
|
|
32
|
+
console.log(renderNotice(kind, message));
|
|
33
|
+
console.log();
|
|
34
|
+
}
|
|
35
|
+
/** The plan is ready: start on it (Go) or change it. Esc means "change": nothing starts by accident. */
|
|
36
|
+
export async function askPlanDecision() {
|
|
37
|
+
const cols = process.stdout.columns || 80;
|
|
38
|
+
console.log();
|
|
39
|
+
for (const line of wrapWords(t('plan.ready'), Math.max(20, cols - 8)))
|
|
40
|
+
console.log(' ' + palette.bold(palette.text(line)));
|
|
41
|
+
const answer = await runPicker({
|
|
42
|
+
title: 'plan',
|
|
43
|
+
items: [
|
|
44
|
+
{ value: 'go', label: t('plan.go') },
|
|
45
|
+
{ value: 'edit', label: t('plan.edit') },
|
|
46
|
+
],
|
|
47
|
+
current: 'go',
|
|
48
|
+
});
|
|
49
|
+
return answer === 'go' ? 'go' : 'edit';
|
|
50
|
+
}
|