termux-dev 1.6.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 +214 -166
- package/assets/banner.svg +23 -28
- package/assets/preview.png +0 -0
- package/assets/preview.svg +92 -10
- package/dist/cli/banner.js +1 -1
- 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 +23 -11
- package/dist/cli/index.js +1377 -480
- 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 +256 -56
- package/dist/cli/resume.js +15 -0
- package/dist/cli/rewind.js +51 -0
- package/dist/cli/runtime_errors.js +6 -0
- package/dist/cli/server.js +85 -24
- package/dist/cli/settings.js +307 -0
- package/dist/cli/statusline.js +1 -1
- package/dist/cli/theme.js +126 -108
- package/dist/cli/transcript.js +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 +1 -1
package/dist/cli/updater.js
CHANGED
|
@@ -37,20 +37,20 @@ export async function getCurrentVersion() {
|
|
|
37
37
|
}
|
|
38
38
|
export async function checkForUpdates(timeoutMs = 10000) {
|
|
39
39
|
const currentVersion = await getCurrentVersion();
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
// One deadline for the whole check. The signal is passed to EVERY request: on a flaky
|
|
41
|
+
// mobile connection a stalled second request used to block devx at startup forever.
|
|
42
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
42
43
|
try {
|
|
43
44
|
// 1. Try fetching latest package.json with cache-busting timestamp
|
|
44
45
|
const cacheBusterUrl = `${REMOTE_PKG_URL}?t=${Date.now()}`;
|
|
45
46
|
const res = await fetch(cacheBusterUrl, {
|
|
46
|
-
signal
|
|
47
|
+
signal,
|
|
47
48
|
headers: {
|
|
48
49
|
'User-Agent': 'devx-updater',
|
|
49
50
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
50
51
|
'Pragma': 'no-cache'
|
|
51
52
|
}
|
|
52
53
|
});
|
|
53
|
-
clearTimeout(timer);
|
|
54
54
|
let latestVersion = '';
|
|
55
55
|
let releaseNotes = '';
|
|
56
56
|
if (res.ok) {
|
|
@@ -60,6 +60,7 @@ export async function checkForUpdates(timeoutMs = 10000) {
|
|
|
60
60
|
// 2. Also check latest GitHub release tag
|
|
61
61
|
try {
|
|
62
62
|
const relRes = await fetch(`${REMOTE_RELEASE_URL}?t=${Date.now()}`, {
|
|
63
|
+
signal,
|
|
63
64
|
headers: { 'User-Agent': 'devx-updater' }
|
|
64
65
|
});
|
|
65
66
|
if (relRes.ok) {
|
|
@@ -87,12 +88,12 @@ export async function checkForUpdates(timeoutMs = 10000) {
|
|
|
87
88
|
};
|
|
88
89
|
}
|
|
89
90
|
catch (err) {
|
|
90
|
-
|
|
91
|
+
const timedOut = err.name === 'AbortError' || err.name === 'TimeoutError';
|
|
91
92
|
return {
|
|
92
93
|
updateAvailable: false,
|
|
93
94
|
currentVersion,
|
|
94
95
|
latestVersion: currentVersion,
|
|
95
|
-
error:
|
|
96
|
+
error: timedOut ? `Timeout (${Math.round(timeoutMs / 1000)}s)` : err.message
|
|
96
97
|
};
|
|
97
98
|
}
|
|
98
99
|
}
|
|
@@ -109,10 +110,10 @@ export async function performSelfUpdate(latestVersion) {
|
|
|
109
110
|
const __filename = fileURLToPath(import.meta.url);
|
|
110
111
|
const projectRoot = path.resolve(path.dirname(__filename), '../../');
|
|
111
112
|
const isGit = await isGitRepository(projectRoot);
|
|
112
|
-
console.log('\n' + pc.bold(pc.cyan('───
|
|
113
|
+
console.log('\n' + pc.bold(pc.cyan('─── Starting devx Update ──────────────────────────')));
|
|
113
114
|
if (!isGit) {
|
|
114
115
|
// 1. Installed via global npm package: termux-dev
|
|
115
|
-
process.stdout.write(` ${pc.cyan('
|
|
116
|
+
process.stdout.write(` ${pc.cyan('›')} [1/1] ${pc.white(`Updating termux-dev via npm (v${latestVersion})...`)}\n`);
|
|
116
117
|
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
117
118
|
let fIdx = 0;
|
|
118
119
|
const interval = setInterval(() => {
|
|
@@ -121,12 +122,12 @@ export async function performSelfUpdate(latestVersion) {
|
|
|
121
122
|
try {
|
|
122
123
|
execSync('npm install -g termux-dev@latest --prefer-online', { stdio: 'pipe' });
|
|
123
124
|
clearInterval(interval);
|
|
124
|
-
process.stdout.write(`\r ${pc.green('
|
|
125
|
+
process.stdout.write(`\r ${pc.green('✓')} ${pc.bold(pc.white(`Successfully updated termux-dev to v${latestVersion} via npm!`))}\n`);
|
|
125
126
|
}
|
|
126
127
|
catch (err) {
|
|
127
128
|
clearInterval(interval);
|
|
128
129
|
process.stdout.write(`\r ${pc.red('✖')} ${pc.red(`Update failed: ${err.message}`)}\n`);
|
|
129
|
-
console.log(pc.yellow(`\
|
|
130
|
+
console.log(pc.yellow(`\nTo update manually, run: ${pc.bold('npm install -g termux-dev@latest')}`));
|
|
130
131
|
return false;
|
|
131
132
|
}
|
|
132
133
|
}
|
|
@@ -134,20 +135,20 @@ export async function performSelfUpdate(latestVersion) {
|
|
|
134
135
|
// 2. Installed via git clone (Developer mode)
|
|
135
136
|
const steps = [
|
|
136
137
|
{
|
|
137
|
-
title: '
|
|
138
|
+
title: 'Pulling latest updates from GitHub repository...',
|
|
138
139
|
action: () => {
|
|
139
140
|
execSync('git pull origin main', { cwd: projectRoot, stdio: 'pipe' });
|
|
140
141
|
}
|
|
141
142
|
},
|
|
142
143
|
{
|
|
143
|
-
title: '
|
|
144
|
+
title: 'Building and compiling TypeScript sources...',
|
|
144
145
|
action: () => {
|
|
145
146
|
execSync('npm install', { cwd: projectRoot, stdio: 'pipe' });
|
|
146
147
|
execSync('npm run build', { cwd: projectRoot, stdio: 'pipe' });
|
|
147
148
|
}
|
|
148
149
|
},
|
|
149
150
|
{
|
|
150
|
-
title: '
|
|
151
|
+
title: 'Ensuring binary execution permissions & linking...',
|
|
151
152
|
action: () => {
|
|
152
153
|
try {
|
|
153
154
|
execSync('npm link', { cwd: projectRoot, stdio: 'pipe' });
|
|
@@ -158,7 +159,7 @@ export async function performSelfUpdate(latestVersion) {
|
|
|
158
159
|
];
|
|
159
160
|
for (let i = 0; i < steps.length; i++) {
|
|
160
161
|
const step = steps[i];
|
|
161
|
-
process.stdout.write(` ${pc.cyan('
|
|
162
|
+
process.stdout.write(` ${pc.cyan('›')} [${i + 1}/${steps.length}] ${pc.white(step.title)}\n`);
|
|
162
163
|
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
163
164
|
let fIdx = 0;
|
|
164
165
|
const interval = setInterval(() => {
|
|
@@ -168,18 +169,18 @@ export async function performSelfUpdate(latestVersion) {
|
|
|
168
169
|
await new Promise(resolve => setTimeout(resolve, 200));
|
|
169
170
|
step.action();
|
|
170
171
|
clearInterval(interval);
|
|
171
|
-
process.stdout.write(`\r ${pc.green('
|
|
172
|
+
process.stdout.write(`\r ${pc.green('✓')} ${pc.bold(pc.white(step.title))}\n`);
|
|
172
173
|
}
|
|
173
174
|
catch (err) {
|
|
174
175
|
clearInterval(interval);
|
|
175
176
|
process.stdout.write(`\r ${pc.red('✖')} ${pc.red(step.title)} - ${err.message}\n`);
|
|
176
177
|
console.log(pc.red(`\nUpdate failed during step ${i + 1}: ${err.message}`));
|
|
177
|
-
console.log(pc.yellow(`\
|
|
178
|
+
console.log(pc.yellow(`\nTo update manually, run: ${pc.bold('npm install -g termux-dev')}`));
|
|
178
179
|
return false;
|
|
179
180
|
}
|
|
180
181
|
}
|
|
181
182
|
}
|
|
182
|
-
console.log(pc.bold(pc.green(`\
|
|
183
|
+
console.log(pc.bold(pc.green(`\nSuccessfully updated to v${latestVersion}! Restarting devx...\n`)));
|
|
183
184
|
console.log(pc.bold(pc.cyan('──────────────────────────────────────────────────────\n')));
|
|
184
185
|
await new Promise(r => setTimeout(r, 1000));
|
|
185
186
|
try {
|
|
@@ -218,21 +219,22 @@ export async function runStartupUpdateCheck(config) {
|
|
|
218
219
|
if (result.updateAvailable) {
|
|
219
220
|
console.log();
|
|
220
221
|
console.log(pc.bold(pc.cyan(' ┌───────────────────────────────────────────────────────────┐')));
|
|
221
|
-
console.log(pc.bold(pc.cyan(' │')) + pc.bold(pc.yellow(`
|
|
222
|
+
console.log(pc.bold(pc.cyan(' │')) + pc.bold(pc.yellow(` A new update is available: `)) + pc.dim(`v${result.currentVersion}`) + pc.bold(pc.green(` → v${result.latestVersion}`)) + ' '.repeat(Math.max(1, 23 - result.currentVersion.length - result.latestVersion.length)) + pc.bold(pc.cyan('│')));
|
|
222
223
|
console.log(pc.bold(pc.cyan(' │')) + pc.dim(` Repository: https://github.com/apvcode/Termux-Dev `) + pc.bold(pc.cyan('│')));
|
|
223
224
|
console.log(pc.bold(pc.cyan(' └───────────────────────────────────────────────────────────┘')));
|
|
224
225
|
console.log();
|
|
225
226
|
try {
|
|
226
227
|
const choice = await select({
|
|
227
228
|
message: pc.bold('Do you want to update devx now?'),
|
|
229
|
+
loop: false,
|
|
228
230
|
choices: [
|
|
229
231
|
{
|
|
230
|
-
name:
|
|
232
|
+
name: `Update now (Pull v${result.latestVersion}, rebuild & restart)`,
|
|
231
233
|
value: 'update',
|
|
232
234
|
description: 'Download latest code, compile TypeScript, and restart devx'
|
|
233
235
|
},
|
|
234
236
|
{
|
|
235
|
-
name: '
|
|
237
|
+
name: 'Skip for now (Continue to chat)',
|
|
236
238
|
value: 'skip',
|
|
237
239
|
description: 'Keep current version and start session'
|
|
238
240
|
}
|
|
@@ -248,7 +250,7 @@ export async function runStartupUpdateCheck(config) {
|
|
|
248
250
|
}
|
|
249
251
|
else {
|
|
250
252
|
// Briefly show up to date message
|
|
251
|
-
process.stdout.write(` ${pc.green('
|
|
253
|
+
process.stdout.write(` ${pc.green('✓')} ${pc.dim(`devx is up to date (v${result.currentVersion})`)}\n`);
|
|
252
254
|
await new Promise(r => setTimeout(r, 450));
|
|
253
255
|
// Clean up line
|
|
254
256
|
process.stdout.write('\x1b[1A\x1b[2K');
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { LiveBlock } from './liveblock.js';
|
|
2
|
+
import { splitKeys } from './keys.js';
|
|
3
|
+
import { palette, formatTokens } from './ui.js';
|
|
4
|
+
import { displayWidth, padEndWidth, truncateWidth } from './width.js';
|
|
5
|
+
import { formatMoney } from '../core/cost.js';
|
|
6
|
+
const fmtTokens = (n) => (n < 1000 ? `${Math.round(n)}` : formatTokens(n));
|
|
7
|
+
function formatBytes(bytes) {
|
|
8
|
+
if (bytes < 1024)
|
|
9
|
+
return `${bytes} B`;
|
|
10
|
+
if (bytes < 1024 * 1024)
|
|
11
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
12
|
+
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
13
|
+
}
|
|
14
|
+
const pct = (part, whole) => (whole > 0 ? (part / whole) * 100 : 0);
|
|
15
|
+
function formatPct(value) {
|
|
16
|
+
if (value === 0)
|
|
17
|
+
return '0%';
|
|
18
|
+
if (value < 1)
|
|
19
|
+
return '<1%';
|
|
20
|
+
return `${Math.round(value)}%`;
|
|
21
|
+
}
|
|
22
|
+
function segmentsOf(c) {
|
|
23
|
+
return [
|
|
24
|
+
{ key: 'system', label: 'System prompt', tokens: c.system, color: palette.accent },
|
|
25
|
+
{ key: 'tools', label: `Tool schemas (${c.toolCount})`, tokens: c.tools, color: palette.plan },
|
|
26
|
+
{ key: 'user', label: 'Your messages', tokens: c.user, color: palette.ok },
|
|
27
|
+
{ key: 'assistant', label: 'Model replies', tokens: c.assistant, color: palette.text },
|
|
28
|
+
{ key: 'tool', label: 'Tool results', tokens: c.toolResults, color: palette.warn },
|
|
29
|
+
{ key: 'images', label: c.imageCount > 0 ? `Images (${c.imageCount})` : 'Images', tokens: c.images, color: palette.yolo },
|
|
30
|
+
];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A bar of `width` cells, one coloured run per kind of content. Every non-empty kind gets at
|
|
34
|
+
* least one cell, so a small share does not disappear.
|
|
35
|
+
*/
|
|
36
|
+
export function renderBar(segments, limit, width) {
|
|
37
|
+
const cells = segments.map((s) => (s.tokens > 0 ? Math.max(1, Math.round((s.tokens / Math.max(1, limit)) * width)) : 0));
|
|
38
|
+
let used = cells.reduce((a, b) => a + b, 0);
|
|
39
|
+
// Rounding up the small ones can overshoot: take the cells back from the biggest.
|
|
40
|
+
while (used > width) {
|
|
41
|
+
const biggest = cells.indexOf(Math.max(...cells));
|
|
42
|
+
if (cells[biggest] <= 1)
|
|
43
|
+
break;
|
|
44
|
+
cells[biggest]--;
|
|
45
|
+
used--;
|
|
46
|
+
}
|
|
47
|
+
let bar = '';
|
|
48
|
+
segments.forEach((s, i) => {
|
|
49
|
+
if (cells[i] > 0)
|
|
50
|
+
bar += s.color('█'.repeat(cells[i]));
|
|
51
|
+
});
|
|
52
|
+
return bar + palette.faint('░'.repeat(Math.max(0, width - used)));
|
|
53
|
+
}
|
|
54
|
+
/** How the cost number is to be read, in a few words. */
|
|
55
|
+
function costNote(session) {
|
|
56
|
+
const st = session.costState;
|
|
57
|
+
if ((st.unpriced ?? 0) > 0 && (st.priced ?? 0) === 0 && session.totalCost === 0)
|
|
58
|
+
return 'no price known for this model';
|
|
59
|
+
const parts = [];
|
|
60
|
+
if (st.estimated)
|
|
61
|
+
parts.push('catalogue prices, an estimate');
|
|
62
|
+
if ((st.unpriced ?? 0) > 0)
|
|
63
|
+
parts.push(`${st.unpriced} request${st.unpriced === 1 ? '' : 's'} not priced`);
|
|
64
|
+
return parts.join(', ');
|
|
65
|
+
}
|
|
66
|
+
/** The content as rows of text, without the frame. `iw` is the number of cells available. */
|
|
67
|
+
function buildRows(d, iw, detail) {
|
|
68
|
+
const rows = [];
|
|
69
|
+
const line = (text) => rows.push({ kind: 'line', text });
|
|
70
|
+
const title = (text) => rows.push({ kind: 'title', text });
|
|
71
|
+
const blank = () => rows.push({ kind: 'blank', text: '' });
|
|
72
|
+
const pair = (label, value, labelWidth = 16) => {
|
|
73
|
+
const room = Math.max(4, Math.min(labelWidth, iw - 3 - displayWidth(value)));
|
|
74
|
+
const left = ' ' + palette.muted(padEndWidth(truncateWidth(label, room), room));
|
|
75
|
+
const gap = Math.max(1, iw - displayWidth(left) - displayWidth(value));
|
|
76
|
+
line(left + ' '.repeat(gap) + value);
|
|
77
|
+
};
|
|
78
|
+
const c = d.context;
|
|
79
|
+
const s = d.session;
|
|
80
|
+
const usedPct = pct(c.total, c.limit);
|
|
81
|
+
const usedColor = usedPct >= 85 ? palette.err : usedPct >= 70 ? palette.warn : palette.text;
|
|
82
|
+
// ── context
|
|
83
|
+
title('CONTEXT');
|
|
84
|
+
const summary = `${formatPct(usedPct)} ${fmtTokens(c.total)} / ${fmtTokens(c.limit)}`;
|
|
85
|
+
const barWidth = Math.max(4, iw - 4 - displayWidth(summary) - 2);
|
|
86
|
+
line(' ' + renderBar(segmentsOf(c), c.limit, barWidth) + ' ' + usedColor(summary));
|
|
87
|
+
if (detail === 'full') {
|
|
88
|
+
const labelWidth = Math.max(6, Math.min(22, iw - 19));
|
|
89
|
+
for (const seg of segmentsOf(c)) {
|
|
90
|
+
if (seg.tokens === 0 && seg.key === 'images')
|
|
91
|
+
continue;
|
|
92
|
+
const left = ' ' + seg.color('■') + ' ' + (seg.tokens > 0 ? palette.text(padEndWidth(truncateWidth(seg.label, labelWidth), labelWidth)) : palette.faint(padEndWidth(truncateWidth(seg.label, labelWidth), labelWidth)));
|
|
93
|
+
const right = palette.text(fmtTokens(seg.tokens).padStart(6)) + palette.faint((' ' + formatPct(pct(seg.tokens, c.limit))).padStart(6));
|
|
94
|
+
line(left + ' '.repeat(Math.max(1, iw - displayWidth(left) - displayWidth(right))) + right);
|
|
95
|
+
}
|
|
96
|
+
const freeLeft = ' ' + palette.faint('□') + ' ' + palette.faint(padEndWidth('Free', labelWidth));
|
|
97
|
+
const freeRight = palette.faint(fmtTokens(c.free).padStart(6) + (' ' + formatPct(pct(c.free, c.limit))).padStart(6));
|
|
98
|
+
line(freeLeft + ' '.repeat(Math.max(1, iw - displayWidth(freeLeft) - displayWidth(freeRight))) + freeRight);
|
|
99
|
+
}
|
|
100
|
+
if (detail === 'full' && d.limitSource) {
|
|
101
|
+
const text = {
|
|
102
|
+
catalogue: 'Window size: from the models.dev catalogue.',
|
|
103
|
+
table: "Window size: from devx's table of models.",
|
|
104
|
+
rule: 'Window size: guessed from the model name.',
|
|
105
|
+
default: 'Window size: assumed, the model is unknown. Set contextLimits in the config.',
|
|
106
|
+
config: 'Window size: from contextLimits in your config.',
|
|
107
|
+
saved: 'Window size: saved when you chose the model.',
|
|
108
|
+
};
|
|
109
|
+
const warn = d.limitSource === 'default' || d.limitSource === 'rule';
|
|
110
|
+
line(' ' + (warn ? palette.warn : palette.faint)(truncateWidth(text[d.limitSource], iw - 2)));
|
|
111
|
+
}
|
|
112
|
+
if (detail !== 'min') {
|
|
113
|
+
line(' ' + palette.faint(truncateWidth(`Old messages are dropped above ${fmtTokens(c.compactAt)} (85%).`, iw - 2)));
|
|
114
|
+
if (d.compactions > 0) {
|
|
115
|
+
line(' ' + palette.faint(truncateWidth(`Happened ${d.compactions}x, ${d.dropped} message${d.dropped === 1 ? '' : 's'} dropped so far.`, iw - 2)));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (detail === 'full' && s.lastPromptTokens > 0) {
|
|
119
|
+
line(' ' + palette.faint(truncateWidth(`Last request: the provider counted ${fmtTokens(s.lastPromptTokens)} input.`, iw - 2)));
|
|
120
|
+
}
|
|
121
|
+
if (d.noVision && c.imageCount > 0 && detail !== 'min')
|
|
122
|
+
line(' ' + palette.warn(truncateWidth('This model cannot read images: they are not sent.', iw - 2)));
|
|
123
|
+
// ── session
|
|
124
|
+
if (detail !== 'min')
|
|
125
|
+
blank();
|
|
126
|
+
title('SESSION');
|
|
127
|
+
pair('Cost', palette.bold(formatMoney(s.totalCost, s.costState)));
|
|
128
|
+
const note = costNote(s);
|
|
129
|
+
if (note && detail === 'full')
|
|
130
|
+
line(' ' + palette.faint(truncateWidth(note, iw - 2)));
|
|
131
|
+
const tokens = iw >= 40 ? `${fmtTokens(s.promptTokens)} in · ${fmtTokens(s.completionTokens)} out` : `${fmtTokens(s.promptTokens)}/${fmtTokens(s.completionTokens)}`;
|
|
132
|
+
pair('Tokens', palette.text(tokens));
|
|
133
|
+
if (s.cacheReadTokens > 0 && detail !== 'min')
|
|
134
|
+
pair('Cached input', palette.text(fmtTokens(s.cacheReadTokens)));
|
|
135
|
+
pair('Requests', palette.text(String(s.requestsCount)));
|
|
136
|
+
// ── traffic
|
|
137
|
+
if (detail === 'full') {
|
|
138
|
+
blank();
|
|
139
|
+
title('TRAFFIC');
|
|
140
|
+
pair('Sent / received', palette.text(iw >= 40 ? `${formatBytes(s.bytesSent)} / ${formatBytes(s.bytesReceived)}` : `${formatBytes(s.bytesSent)}/${formatBytes(s.bytesReceived)}`));
|
|
141
|
+
if (s.dataSaverLimitMB) {
|
|
142
|
+
const used = s.totalBytes / (1024 * 1024);
|
|
143
|
+
const share = Math.min(100, Math.round((used / s.dataSaverLimitMB) * 100));
|
|
144
|
+
const text = `${used.toFixed(1)} / ${s.dataSaverLimitMB} MB (${share}%)`;
|
|
145
|
+
pair('Data saver', share >= 90 ? palette.err(text) : palette.text(text));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return rows;
|
|
149
|
+
}
|
|
150
|
+
function keyHints(width) {
|
|
151
|
+
const variants = ['esc close', 'esc'];
|
|
152
|
+
for (const text of variants) {
|
|
153
|
+
const [key, ...rest] = text.split(' ');
|
|
154
|
+
const out = palette.text(key) + (rest.length ? ' ' + palette.faint(rest.join(' ')) : '');
|
|
155
|
+
if (displayWidth(out) <= width)
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
return '';
|
|
159
|
+
}
|
|
160
|
+
function renderCard(d, cols, rows) {
|
|
161
|
+
const W = Math.min(cols - 1, 76);
|
|
162
|
+
const iw = W - 4;
|
|
163
|
+
const rule = palette.rule;
|
|
164
|
+
const lines = [];
|
|
165
|
+
const box = (content) => rule('│') + ' ' + padEndWidth(content, iw) + ' ' + rule('│');
|
|
166
|
+
const heading = ' ' + palette.bold(palette.accent('usage')) + ' ';
|
|
167
|
+
const fmt = d.format === 'anthropic' ? 'anthropic' : 'openai';
|
|
168
|
+
const modelText = truncateWidth(`${d.model}${d.effort ? ` · ${d.effort}` : ''} · ${fmt}`, Math.max(8, W - 4 - displayWidth(heading) - 4));
|
|
169
|
+
const tag = ' ' + palette.faint(modelText) + ' ';
|
|
170
|
+
const fill = Math.max(0, W - 4 - displayWidth(heading) - displayWidth(tag));
|
|
171
|
+
lines.push(rule('╭─') + heading + rule('─'.repeat(fill)) + tag + rule('─╮'));
|
|
172
|
+
const body = buildRows(d, iw, 'full');
|
|
173
|
+
lines.push(box(''));
|
|
174
|
+
for (const row of body) {
|
|
175
|
+
if (row.kind === 'title')
|
|
176
|
+
lines.push(box(' ' + palette.faint(row.text)));
|
|
177
|
+
else if (row.kind === 'blank')
|
|
178
|
+
lines.push(box(''));
|
|
179
|
+
else
|
|
180
|
+
lines.push(box(row.text));
|
|
181
|
+
}
|
|
182
|
+
lines.push(box(''));
|
|
183
|
+
lines.push(rule('╰' + '─'.repeat(W - 2) + '╯'));
|
|
184
|
+
lines.push(' ' + keyHints(cols - 2));
|
|
185
|
+
return lines.length <= rows - 1 ? lines : null;
|
|
186
|
+
}
|
|
187
|
+
function renderPlain(d, cols, rows) {
|
|
188
|
+
const width = Math.max(20, cols - 1);
|
|
189
|
+
const head = ' ' + palette.bold(palette.accent('usage')) + palette.faint(' · ' + truncateWidth(d.model, Math.max(6, width - 10)));
|
|
190
|
+
for (const detail of ['full', 'brief', 'min']) {
|
|
191
|
+
const body = buildRows(d, width - 1, detail);
|
|
192
|
+
const lines = [head, ...body.map((r) => (r.kind === 'title' ? ' ' + palette.faint(r.text) : r.kind === 'blank' ? '' : ' ' + r.text)), ' ' + keyHints(width - 1)];
|
|
193
|
+
if (lines.length <= rows - 1 || detail === 'min') {
|
|
194
|
+
// On a very short screen drop the blank rows too.
|
|
195
|
+
return lines.length <= rows - 1 ? lines : lines.filter((l) => l !== '');
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return [head];
|
|
199
|
+
}
|
|
200
|
+
/** The whole panel as terminal rows: a framed card on a wide screen, a plain list on a narrow or short one. */
|
|
201
|
+
export function renderUsagePanel(d, cols, rows) {
|
|
202
|
+
if (cols >= 60) {
|
|
203
|
+
const card = renderCard(d, cols, rows);
|
|
204
|
+
if (card)
|
|
205
|
+
return card;
|
|
206
|
+
}
|
|
207
|
+
return renderPlain(d, cols, rows);
|
|
208
|
+
}
|
|
209
|
+
/** Shows the panel until Esc (or q, Enter, Ctrl+C) is pressed. The panel is erased on close. */
|
|
210
|
+
export function runUsagePanel(data, options = {}) {
|
|
211
|
+
return new Promise((resolve) => {
|
|
212
|
+
const { input, output } = options.io ?? { input: process.stdin, output: process.stdout };
|
|
213
|
+
const block = new LiveBlock(output);
|
|
214
|
+
const draw = () => block.draw(renderUsagePanel(data, output.columns || 80, output.rows || 30));
|
|
215
|
+
const wasRaw = !!input.isRaw;
|
|
216
|
+
input.setRawMode?.(true);
|
|
217
|
+
input.resume();
|
|
218
|
+
let closed = false;
|
|
219
|
+
const close = () => {
|
|
220
|
+
if (closed)
|
|
221
|
+
return;
|
|
222
|
+
closed = true;
|
|
223
|
+
input.off('data', onData);
|
|
224
|
+
output.off?.('resize', draw);
|
|
225
|
+
block.erase();
|
|
226
|
+
if (!wasRaw)
|
|
227
|
+
input.setRawMode?.(false);
|
|
228
|
+
resolve();
|
|
229
|
+
};
|
|
230
|
+
const onData = (chunk) => {
|
|
231
|
+
for (const key of splitKeys(chunk.toString())) {
|
|
232
|
+
if (closed)
|
|
233
|
+
return;
|
|
234
|
+
if (key === '\x1b' || key === 'q' || key === '\r' || key === '\n' || key === ' ' || key === '\x03')
|
|
235
|
+
return close();
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
input.on('data', onData);
|
|
239
|
+
output.on?.('resize', draw);
|
|
240
|
+
draw();
|
|
241
|
+
});
|
|
242
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal display width helpers.
|
|
3
|
+
*
|
|
4
|
+
* `string.length` counts UTF-16 units, but a terminal counts cells: CJK characters and
|
|
5
|
+
* emoji take two cells, combining marks take none. Anything drawn inside a box
|
|
6
|
+
* (borders, padding, right-aligned labels) must be measured in cells, otherwise a single
|
|
7
|
+
* emoji in the input shifts the right border and wraps the whole line.
|
|
8
|
+
*/
|
|
9
|
+
const ANSI = /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
|
10
|
+
export function stripAnsi(text) {
|
|
11
|
+
return text.replace(ANSI, '');
|
|
12
|
+
}
|
|
13
|
+
/** Width in terminal cells of a single code point. */
|
|
14
|
+
export function codePointWidth(cp) {
|
|
15
|
+
if (cp === 0)
|
|
16
|
+
return 0;
|
|
17
|
+
if (cp < 32 || (cp >= 0x7f && cp < 0xa0))
|
|
18
|
+
return 0; // control characters
|
|
19
|
+
if ((cp >= 0x0300 && cp <= 0x036f) || // combining diacritics
|
|
20
|
+
(cp >= 0x200b && cp <= 0x200f) || // zero width space / joiners / marks
|
|
21
|
+
(cp >= 0x2060 && cp <= 0x2064) ||
|
|
22
|
+
(cp >= 0xfe00 && cp <= 0xfe0f) || // variation selectors
|
|
23
|
+
cp === 0xfeff ||
|
|
24
|
+
(cp >= 0xe0100 && cp <= 0xe01ef)) {
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
if ((cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
|
|
28
|
+
(cp >= 0x2e80 && cp <= 0x303e) || // CJK radicals, punctuation
|
|
29
|
+
(cp >= 0x3041 && cp <= 0x33ff) || // Hiragana, Katakana, CJK symbols
|
|
30
|
+
(cp >= 0x3400 && cp <= 0x4dbf) || // CJK extension A
|
|
31
|
+
(cp >= 0x4e00 && cp <= 0xa4cf) || // CJK unified, Yi
|
|
32
|
+
(cp >= 0xa960 && cp <= 0xa97f) ||
|
|
33
|
+
(cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables
|
|
34
|
+
(cp >= 0xf900 && cp <= 0xfaff) || // CJK compatibility
|
|
35
|
+
(cp >= 0xfe30 && cp <= 0xfe6f) ||
|
|
36
|
+
(cp >= 0xff00 && cp <= 0xff60) || // fullwidth forms
|
|
37
|
+
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
38
|
+
(cp >= 0x1f300 && cp <= 0x1f64f) || // emoji: symbols, emoticons
|
|
39
|
+
(cp >= 0x1f680 && cp <= 0x1f6ff) || // transport
|
|
40
|
+
(cp >= 0x1f900 && cp <= 0x1f9ff) || // supplemental symbols
|
|
41
|
+
(cp >= 0x1fa70 && cp <= 0x1faff) ||
|
|
42
|
+
(cp >= 0x20000 && cp <= 0x3fffd) // CJK extension B+
|
|
43
|
+
) {
|
|
44
|
+
return 2;
|
|
45
|
+
}
|
|
46
|
+
return 1;
|
|
47
|
+
}
|
|
48
|
+
/** Display width of a string in terminal cells (ANSI escape sequences are ignored). */
|
|
49
|
+
export function displayWidth(text) {
|
|
50
|
+
let width = 0;
|
|
51
|
+
for (const ch of stripAnsi(text)) {
|
|
52
|
+
width += codePointWidth(ch.codePointAt(0));
|
|
53
|
+
}
|
|
54
|
+
return width;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Cuts plain text (no ANSI) to at most `max` cells, never splitting a character.
|
|
58
|
+
* When it has to cut, the result ends with an ellipsis and still fits in `max` cells.
|
|
59
|
+
*/
|
|
60
|
+
export function truncateWidth(text, max) {
|
|
61
|
+
if (max <= 0)
|
|
62
|
+
return '';
|
|
63
|
+
if (displayWidth(text) <= max)
|
|
64
|
+
return text;
|
|
65
|
+
const budget = max - 1; // room for the ellipsis
|
|
66
|
+
let out = '';
|
|
67
|
+
let width = 0;
|
|
68
|
+
for (const ch of text) {
|
|
69
|
+
const w = codePointWidth(ch.codePointAt(0));
|
|
70
|
+
if (width + w > budget)
|
|
71
|
+
break;
|
|
72
|
+
out += ch;
|
|
73
|
+
width += w;
|
|
74
|
+
}
|
|
75
|
+
return out + '…';
|
|
76
|
+
}
|
|
77
|
+
/** Pads a (possibly colored) string with spaces on the right up to `width` cells. */
|
|
78
|
+
export function padEndWidth(text, width) {
|
|
79
|
+
const missing = width - displayWidth(text);
|
|
80
|
+
return missing > 0 ? text + ' '.repeat(missing) : text;
|
|
81
|
+
}
|
|
82
|
+
/** Pads a (possibly colored) string with spaces on the left up to `width` cells. */
|
|
83
|
+
export function padStartWidth(text, width) {
|
|
84
|
+
const missing = width - displayWidth(text);
|
|
85
|
+
return missing > 0 ? ' '.repeat(missing) + text : text;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Returns the slice of plain `text` that starts at character index `start` and fits
|
|
89
|
+
* into `maxCells`, together with how many characters were taken.
|
|
90
|
+
*/
|
|
91
|
+
export function sliceByWidth(text, start, maxCells) {
|
|
92
|
+
let out = '';
|
|
93
|
+
let cells = 0;
|
|
94
|
+
let length = 0;
|
|
95
|
+
let index = 0;
|
|
96
|
+
for (const ch of text) {
|
|
97
|
+
const units = ch.length;
|
|
98
|
+
if (index >= start) {
|
|
99
|
+
const w = codePointWidth(ch.codePointAt(0));
|
|
100
|
+
if (cells + w > maxCells)
|
|
101
|
+
break;
|
|
102
|
+
out += ch;
|
|
103
|
+
cells += w;
|
|
104
|
+
length += units;
|
|
105
|
+
}
|
|
106
|
+
index += units;
|
|
107
|
+
}
|
|
108
|
+
return { text: out, length, cells };
|
|
109
|
+
}
|
|
110
|
+
/** Hard-wraps plain text to lines of at most `width` cells (explicit newlines are kept). */
|
|
111
|
+
export function wrapWidth(text, width) {
|
|
112
|
+
const lines = [];
|
|
113
|
+
for (const rawLine of text.split(/\r\n|\r|\n/)) {
|
|
114
|
+
if (rawLine === '') {
|
|
115
|
+
lines.push('');
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
let current = '';
|
|
119
|
+
let cells = 0;
|
|
120
|
+
for (const ch of rawLine) {
|
|
121
|
+
const w = codePointWidth(ch.codePointAt(0));
|
|
122
|
+
if (cells + w > width && current !== '') {
|
|
123
|
+
lines.push(current);
|
|
124
|
+
current = '';
|
|
125
|
+
cells = 0;
|
|
126
|
+
}
|
|
127
|
+
current += ch;
|
|
128
|
+
cells += w;
|
|
129
|
+
}
|
|
130
|
+
lines.push(current);
|
|
131
|
+
}
|
|
132
|
+
return lines;
|
|
133
|
+
}
|
|
134
|
+
/** Wraps text at word boundaries (a word longer than the line is cut). */
|
|
135
|
+
export function wrapWords(text, width) {
|
|
136
|
+
const lines = [];
|
|
137
|
+
let current = '';
|
|
138
|
+
for (const word of text.split(/\s+/).filter(Boolean)) {
|
|
139
|
+
if (displayWidth(word) > width) {
|
|
140
|
+
if (current)
|
|
141
|
+
lines.push(current);
|
|
142
|
+
lines.push(truncateWidth(word, width));
|
|
143
|
+
current = '';
|
|
144
|
+
}
|
|
145
|
+
else if (current === '') {
|
|
146
|
+
current = word;
|
|
147
|
+
}
|
|
148
|
+
else if (displayWidth(current) + 1 + displayWidth(word) <= width) {
|
|
149
|
+
current += ' ' + word;
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
lines.push(current);
|
|
153
|
+
current = word;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (current)
|
|
157
|
+
lines.push(current);
|
|
158
|
+
return lines;
|
|
159
|
+
}
|
|
160
|
+
/** The last `maxCells` cells of a text (what a running line shows: the newest words). */
|
|
161
|
+
export function tailWidth(text, maxCells) {
|
|
162
|
+
const chars = Array.from(text);
|
|
163
|
+
let cells = 0;
|
|
164
|
+
let start = chars.length;
|
|
165
|
+
while (start > 0) {
|
|
166
|
+
const w = codePointWidth(chars[start - 1].codePointAt(0));
|
|
167
|
+
if (cells + w > maxCells)
|
|
168
|
+
break;
|
|
169
|
+
cells += w;
|
|
170
|
+
start--;
|
|
171
|
+
}
|
|
172
|
+
return chars.slice(start).join('');
|
|
173
|
+
}
|
|
174
|
+
/** Wraps at word boundaries; text without spaces (Chinese, Japanese) is wrapped anywhere. */
|
|
175
|
+
export function wrapText(text, width) {
|
|
176
|
+
const unbreakable = text.split(/\s+/).some((word) => displayWidth(word) > width);
|
|
177
|
+
return unbreakable ? wrapWidth(text, width) : wrapWords(text, width);
|
|
178
|
+
}
|