termux-dev 1.8.2 → 1.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -6
- package/assets/banner.svg +1 -1
- package/assets/preview.svg +1 -1
- package/dist/cli/background-ui.js +8 -8
- package/dist/cli/files.js +2 -12
- package/dist/cli/finder.js +10 -2
- package/dist/cli/headless.js +3 -2
- package/dist/cli/index.js +131 -75
- package/dist/cli/keys.js +12 -3
- package/dist/cli/liveblock.js +47 -9
- package/dist/cli/mcp-panel.js +2 -0
- package/dist/cli/picker.js +2 -0
- package/dist/cli/prompt.js +54 -7
- package/dist/cli/provider-manager.js +197 -0
- package/dist/cli/providers.js +0 -5
- package/dist/cli/settings.js +38 -17
- package/dist/cli/typeahead.js +10 -17
- package/dist/cli/ui.js +33 -13
- package/dist/cli/update-screen.js +383 -0
- package/dist/cli/updater.js +119 -145
- package/dist/cli/usage-panel.js +17 -4
- package/dist/cli/width.js +18 -0
- package/dist/core/background.js +14 -0
- package/dist/core/context-stats.js +1 -0
- package/dist/core/cost.js +25 -11
- package/dist/core/custom-providers.js +161 -0
- package/dist/core/images.js +46 -0
- package/dist/core/local-config.js +3 -1
- package/dist/core/locales/de.js +69 -7
- package/dist/core/locales/en.js +69 -7
- package/dist/core/locales/es.js +69 -7
- package/dist/core/locales/fr.js +69 -7
- package/dist/core/locales/ja.js +69 -7
- package/dist/core/locales/pt.js +69 -7
- package/dist/core/locales/ru.js +69 -7
- package/dist/core/locales/uk.js +69 -7
- package/dist/core/locales/zh.js +69 -7
- package/dist/core/loop.js +32 -1
- package/dist/core/prices.js +8 -4
- package/dist/core/session-media.js +115 -0
- package/dist/core/session.js +17 -2
- package/dist/core/title.js +19 -2
- package/dist/core/types.js +1 -1
- package/dist/core/usage.js +41 -4
- package/dist/permissions/guard.js +3 -0
- package/dist/providers/anthropic.js +67 -13
- package/dist/providers/index.js +16 -3
- package/dist/providers/openai.js +99 -41
- package/dist/providers/shared.js +83 -0
- package/dist/tools/fs.js +32 -2
- package/package.json +1 -1
package/dist/cli/updater.js
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import fs from 'fs/promises';
|
|
4
|
-
import pc from 'picocolors';
|
|
5
|
-
import { select } from '@inquirer/prompts';
|
|
6
|
-
import { palette } from './ui.js';
|
|
7
4
|
import { fileURLToPath } from 'url';
|
|
8
5
|
const GITHUB_REPO_URL = 'https://github.com/apvcode/Termux-Dev';
|
|
9
|
-
|
|
10
|
-
const
|
|
6
|
+
// Where to look for the newest version. Overridable (a mirror, a test server).
|
|
7
|
+
const REMOTE_PKG_URL = process.env.DEVX_UPDATE_PKG_URL || 'https://raw.githubusercontent.com/apvcode/Termux-Dev/main/package.json';
|
|
8
|
+
const REMOTE_RELEASE_URL = process.env.DEVX_UPDATE_RELEASE_URL || 'https://api.github.com/repos/apvcode/Termux-Dev/releases/latest';
|
|
11
9
|
// Compare semantic versions (e.g. 1.1.0 > 1.0.0)
|
|
12
10
|
export function isNewerVersion(remote, current) {
|
|
13
11
|
const clean = (v) => v.replace(/^v/, '').trim();
|
|
@@ -54,6 +52,7 @@ export async function checkForUpdates(timeoutMs = 10000) {
|
|
|
54
52
|
});
|
|
55
53
|
let latestVersion = '';
|
|
56
54
|
let releaseNotes = '';
|
|
55
|
+
let publishedAt = '';
|
|
57
56
|
if (res.ok) {
|
|
58
57
|
const pkg = await res.json();
|
|
59
58
|
latestVersion = (pkg.version || '').replace(/^v/, '').trim();
|
|
@@ -71,6 +70,7 @@ export async function checkForUpdates(timeoutMs = 10000) {
|
|
|
71
70
|
latestVersion = relTag;
|
|
72
71
|
}
|
|
73
72
|
releaseNotes = relData.body || '';
|
|
73
|
+
publishedAt = relData.published_at || '';
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
catch { }
|
|
@@ -79,7 +79,8 @@ export async function checkForUpdates(timeoutMs = 10000) {
|
|
|
79
79
|
updateAvailable: true,
|
|
80
80
|
currentVersion,
|
|
81
81
|
latestVersion,
|
|
82
|
-
releaseNotes
|
|
82
|
+
releaseNotes,
|
|
83
|
+
publishedAt: publishedAt || undefined
|
|
83
84
|
};
|
|
84
85
|
}
|
|
85
86
|
return {
|
|
@@ -98,163 +99,136 @@ export async function checkForUpdates(timeoutMs = 10000) {
|
|
|
98
99
|
};
|
|
99
100
|
}
|
|
100
101
|
}
|
|
101
|
-
|
|
102
|
+
/** How this devx was installed: from the npm package, or from a git clone (a developer's copy). */
|
|
103
|
+
export async function detectInstallMethod() {
|
|
104
|
+
const here = fileURLToPath(import.meta.url);
|
|
105
|
+
const root = path.resolve(path.dirname(here), '../../');
|
|
106
|
+
// DEVX_UPDATE_METHOD forces one of them (a copy in a git folder that is installed with npm, a test).
|
|
107
|
+
const forced = (process.env.DEVX_UPDATE_METHOD || '').toLowerCase();
|
|
108
|
+
if (forced === 'npm' || forced === 'git')
|
|
109
|
+
return { method: forced, root };
|
|
102
110
|
try {
|
|
103
|
-
await fs.stat(path.join(
|
|
104
|
-
return
|
|
111
|
+
await fs.stat(path.join(root, '.git'));
|
|
112
|
+
return { method: 'git', root };
|
|
105
113
|
}
|
|
106
114
|
catch {
|
|
107
|
-
return
|
|
115
|
+
return { method: 'npm', root };
|
|
108
116
|
}
|
|
109
117
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
process.stdout.write(` ${pc.cyan('›')} [1/1] ${pc.white(`Updating termux-dev via npm (v${latestVersion})...`)}\n`);
|
|
118
|
-
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
119
|
-
let fIdx = 0;
|
|
120
|
-
const interval = setInterval(() => {
|
|
121
|
-
process.stdout.write(`\r ${pc.cyan(frames[fIdx++ % frames.length])} ${pc.dim(`npm install -g termux-dev@latest`)} `);
|
|
122
|
-
}, 80);
|
|
123
|
-
try {
|
|
124
|
-
execSync('npm install -g termux-dev@latest --prefer-online', { stdio: 'pipe' });
|
|
125
|
-
clearInterval(interval);
|
|
126
|
-
process.stdout.write(`\r ${pc.green('✓')} ${pc.bold(pc.white(`Successfully updated termux-dev to v${latestVersion} via npm!`))}\n`);
|
|
127
|
-
}
|
|
128
|
-
catch (err) {
|
|
129
|
-
clearInterval(interval);
|
|
130
|
-
process.stdout.write(`\r ${pc.red('✖')} ${pc.red(`Update failed: ${err.message}`)}\n`);
|
|
131
|
-
console.log(pc.yellow(`\nTo update manually, run: ${pc.bold('npm install -g termux-dev@latest')}`));
|
|
132
|
-
return false;
|
|
133
|
-
}
|
|
118
|
+
/** What a step of the update says when it fails. `not-published`: the version is on GitHub but not in npm yet. */
|
|
119
|
+
export class UpdateError extends Error {
|
|
120
|
+
kind;
|
|
121
|
+
constructor(message, kind = 'failed') {
|
|
122
|
+
super(message);
|
|
123
|
+
this.kind = kind;
|
|
124
|
+
this.name = 'UpdateError';
|
|
134
125
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
126
|
+
}
|
|
127
|
+
const OUTPUT_KEEP = 4000;
|
|
128
|
+
export const runCommand = (command, args, options = {}) => new Promise((resolve) => {
|
|
129
|
+
let output = '';
|
|
130
|
+
const keep = (chunk) => {
|
|
131
|
+
output = (output + chunk.toString('utf8')).slice(-OUTPUT_KEEP);
|
|
132
|
+
};
|
|
133
|
+
// On Windows npm is a .cmd file, which only a shell can start.
|
|
134
|
+
const child = spawn(command, args, { cwd: options.cwd, shell: process.platform === 'win32', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
135
|
+
child.stdout?.on('data', keep);
|
|
136
|
+
child.stderr?.on('data', keep);
|
|
137
|
+
child.on('error', (err) => resolve({ code: -1, output: output + String(err.message) }));
|
|
138
|
+
child.on('close', (code) => resolve({ code: code ?? -1, output }));
|
|
139
|
+
});
|
|
140
|
+
/** The last lines of a command's output, for the message of a failure. */
|
|
141
|
+
export function tail(output, lines = 4) {
|
|
142
|
+
return output
|
|
143
|
+
.split('\n')
|
|
144
|
+
.map((l) => l.trim())
|
|
145
|
+
.filter(Boolean)
|
|
146
|
+
.slice(-lines)
|
|
147
|
+
.join('\n');
|
|
148
|
+
}
|
|
149
|
+
function must(result, what) {
|
|
150
|
+
if (result.code !== 0)
|
|
151
|
+
throw new UpdateError(tail(result.output) || `${what} failed (exit code ${result.code})`);
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* The steps of an update, in order. Nothing is run until `run()` of a step is called.
|
|
156
|
+
*
|
|
157
|
+
* npm: ask the registry whether the version is there (GitHub knows a release before npm has the
|
|
158
|
+
* package: installing `latest` then would "update" to the old version), install exactly that
|
|
159
|
+
* version, and read back which version is installed.
|
|
160
|
+
* git: pull, install and build, link the command.
|
|
161
|
+
*/
|
|
162
|
+
export function buildUpdateSteps(method, root, version, run = runCommand) {
|
|
163
|
+
if (method === 'npm') {
|
|
164
|
+
return [
|
|
138
165
|
{
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
166
|
+
id: 'registry',
|
|
167
|
+
run: async () => {
|
|
168
|
+
const res = await run('npm', ['view', `termux-dev@${version}`, 'version', '--prefer-online']);
|
|
169
|
+
if (res.code !== 0) {
|
|
170
|
+
// A version that is not in the registry is an E404, not an older answer.
|
|
171
|
+
if (/E404|No match found/i.test(res.output))
|
|
172
|
+
throw new UpdateError(`termux-dev@${version} is not in the npm registry yet`, 'not-published');
|
|
173
|
+
throw new UpdateError(tail(res.output) || `npm view failed (exit code ${res.code})`);
|
|
174
|
+
}
|
|
175
|
+
if (res.output.trim().split('\n').pop()?.trim() !== version) {
|
|
176
|
+
throw new UpdateError(`termux-dev@${version} is not in the npm registry yet`, 'not-published');
|
|
177
|
+
}
|
|
178
|
+
},
|
|
143
179
|
},
|
|
144
180
|
{
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}
|
|
181
|
+
id: 'install',
|
|
182
|
+
run: async () => {
|
|
183
|
+
must(await run('npm', ['install', '-g', `termux-dev@${version}`, '--prefer-online']), 'npm install');
|
|
184
|
+
},
|
|
150
185
|
},
|
|
151
186
|
{
|
|
152
|
-
|
|
153
|
-
|
|
187
|
+
id: 'verify',
|
|
188
|
+
run: async () => {
|
|
189
|
+
const res = await run('npm', ['ls', '-g', 'termux-dev', '--depth=0', '--json']);
|
|
190
|
+
let installed = '';
|
|
154
191
|
try {
|
|
155
|
-
|
|
192
|
+
installed = JSON.parse(res.output.slice(res.output.indexOf('{')))?.dependencies?.['termux-dev']?.version ?? '';
|
|
156
193
|
}
|
|
157
194
|
catch { }
|
|
158
|
-
|
|
159
|
-
|
|
195
|
+
if (installed !== version)
|
|
196
|
+
throw new UpdateError(`npm reports ${installed || 'no version'} instead of ${version}`);
|
|
197
|
+
},
|
|
198
|
+
},
|
|
160
199
|
];
|
|
161
|
-
for (let i = 0; i < steps.length; i++) {
|
|
162
|
-
const step = steps[i];
|
|
163
|
-
process.stdout.write(` ${pc.cyan('›')} [${i + 1}/${steps.length}] ${pc.white(step.title)}\n`);
|
|
164
|
-
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
165
|
-
let fIdx = 0;
|
|
166
|
-
const interval = setInterval(() => {
|
|
167
|
-
process.stdout.write(`\r ${pc.cyan(frames[fIdx++ % frames.length])} ${pc.dim(step.title)} `);
|
|
168
|
-
}, 80);
|
|
169
|
-
try {
|
|
170
|
-
await new Promise(resolve => setTimeout(resolve, 200));
|
|
171
|
-
step.action();
|
|
172
|
-
clearInterval(interval);
|
|
173
|
-
process.stdout.write(`\r ${pc.green('✓')} ${pc.bold(pc.white(step.title))}\n`);
|
|
174
|
-
}
|
|
175
|
-
catch (err) {
|
|
176
|
-
clearInterval(interval);
|
|
177
|
-
process.stdout.write(`\r ${pc.red('✖')} ${pc.red(step.title)} - ${err.message}\n`);
|
|
178
|
-
console.log(pc.red(`\nUpdate failed during step ${i + 1}: ${err.message}`));
|
|
179
|
-
console.log(pc.yellow(`\nTo update manually, run: ${pc.bold('npm install -g termux-dev')}`));
|
|
180
|
-
return false;
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
console.log(pc.bold(pc.green(`\nSuccessfully updated to v${latestVersion}! Restarting devx...\n`)));
|
|
185
|
-
console.log(pc.bold(pc.cyan('──────────────────────────────────────────────────────\n')));
|
|
186
|
-
await new Promise(r => setTimeout(r, 1000));
|
|
187
|
-
try {
|
|
188
|
-
const child = spawn(process.argv[0], process.argv.slice(1), {
|
|
189
|
-
stdio: 'inherit',
|
|
190
|
-
detached: true
|
|
191
|
-
});
|
|
192
|
-
child.unref();
|
|
193
|
-
process.exit(0);
|
|
194
|
-
}
|
|
195
|
-
catch {
|
|
196
|
-
process.exit(0);
|
|
197
200
|
}
|
|
198
|
-
return
|
|
201
|
+
return [
|
|
202
|
+
{ id: 'pull', run: async () => void must(await run('git', ['pull', '--ff-only', 'origin', 'main'], { cwd: root }), 'git pull') },
|
|
203
|
+
{
|
|
204
|
+
id: 'build',
|
|
205
|
+
run: async () => {
|
|
206
|
+
must(await run('npm', ['install'], { cwd: root }), 'npm install');
|
|
207
|
+
must(await run('npm', ['run', 'build'], { cwd: root }), 'npm run build');
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
// Linking is a convenience: a failure here does not undo the update.
|
|
211
|
+
{ id: 'link', run: async () => void (await run('npm', ['link'], { cwd: root })) },
|
|
212
|
+
];
|
|
199
213
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const currentVersion = await getCurrentVersion();
|
|
206
|
-
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
207
|
-
let fIdx = 0;
|
|
208
|
-
let isDone = false;
|
|
209
|
-
const spinner = setInterval(() => {
|
|
210
|
-
if (isDone)
|
|
211
|
-
return;
|
|
212
|
-
// The colours of the theme, like the rest of the interface.
|
|
213
|
-
const frame = palette.accent(frames[fIdx++ % frames.length]);
|
|
214
|
-
process.stdout.write(`\r ${frame} ${palette.muted('Checking for updates from')} ${palette.accent('apvcode/Termux-Dev')} ${palette.faint(`(v${currentVersion})...`)} `);
|
|
215
|
-
}, 75);
|
|
216
|
-
const result = await checkForUpdates(10000);
|
|
217
|
-
isDone = true;
|
|
218
|
-
clearInterval(spinner);
|
|
219
|
-
// Clear spinner line
|
|
220
|
-
process.stdout.write('\r\x1b[K');
|
|
221
|
-
if (result.updateAvailable) {
|
|
222
|
-
console.log();
|
|
223
|
-
console.log(palette.rule(' ┌───────────────────────────────────────────────────────────┐'));
|
|
224
|
-
console.log(palette.rule(' │') + palette.bold(palette.accent(` A new update is available: `)) + palette.muted(`v${result.currentVersion}`) + palette.bold(palette.ok(` → v${result.latestVersion}`)) + ' '.repeat(Math.max(1, 23 - result.currentVersion.length - result.latestVersion.length)) + palette.rule('│'));
|
|
225
|
-
console.log(palette.rule(' │') + palette.faint(` Repository: https://github.com/apvcode/Termux-Dev `) + palette.rule('│'));
|
|
226
|
-
console.log(palette.rule(' └───────────────────────────────────────────────────────────┘'));
|
|
227
|
-
console.log();
|
|
214
|
+
/** Runs the steps one after the other and stops at the first that fails. */
|
|
215
|
+
export async function runUpdateSteps(steps, events = {}, now = Date.now) {
|
|
216
|
+
for (let index = 0; index < steps.length; index++) {
|
|
217
|
+
events.onStart?.(index);
|
|
218
|
+
const started = now();
|
|
228
219
|
try {
|
|
229
|
-
|
|
230
|
-
message: palette.bold('Do you want to update devx now?'),
|
|
231
|
-
loop: false,
|
|
232
|
-
choices: [
|
|
233
|
-
{
|
|
234
|
-
name: `Update now (Pull v${result.latestVersion}, rebuild & restart)`,
|
|
235
|
-
value: 'update',
|
|
236
|
-
description: 'Download latest code, compile TypeScript, and restart devx'
|
|
237
|
-
},
|
|
238
|
-
{
|
|
239
|
-
name: 'Skip for now (Continue to chat)',
|
|
240
|
-
value: 'skip',
|
|
241
|
-
description: 'Keep current version and start session'
|
|
242
|
-
}
|
|
243
|
-
]
|
|
244
|
-
});
|
|
245
|
-
if (choice === 'update') {
|
|
246
|
-
await performSelfUpdate(result.latestVersion);
|
|
247
|
-
}
|
|
220
|
+
await steps[index].run();
|
|
248
221
|
}
|
|
249
|
-
catch {
|
|
250
|
-
|
|
222
|
+
catch (err) {
|
|
223
|
+
return { ok: false, index, error: err instanceof UpdateError ? err : new UpdateError(String(err?.message || err)) };
|
|
251
224
|
}
|
|
225
|
+
events.onDone?.(index, now() - started);
|
|
252
226
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
227
|
+
return { ok: true };
|
|
228
|
+
}
|
|
229
|
+
/** Starts devx again, with the same arguments, and ends this process when the new one ends. */
|
|
230
|
+
export function restartDevx() {
|
|
231
|
+
const child = spawn(process.argv[0], process.argv.slice(1), { stdio: 'inherit' });
|
|
232
|
+
child.on('exit', (code) => process.exit(code ?? 0));
|
|
233
|
+
child.on('error', () => process.exit(0));
|
|
260
234
|
}
|
package/dist/cli/usage-panel.js
CHANGED
|
@@ -128,10 +128,23 @@ function buildRows(d, iw, detail) {
|
|
|
128
128
|
const note = costNote(s);
|
|
129
129
|
if (note && detail === 'full')
|
|
130
130
|
line(' ' + palette.faint(truncateWidth(note, iw - 2)));
|
|
131
|
+
if (s.restoredCost && detail === 'full') {
|
|
132
|
+
line(' ' + palette.faint(truncateWidth(`Includes ${formatMoney(s.restoredCost, { estimated: s.costState.estimated })} from before this chat was resumed; the lines below count only since devx started.`, iw - 2)));
|
|
133
|
+
}
|
|
131
134
|
const tokens = iw >= 40 ? `${fmtTokens(s.promptTokens)} in · ${fmtTokens(s.completionTokens)} out` : `${fmtTokens(s.promptTokens)}/${fmtTokens(s.completionTokens)}`;
|
|
132
135
|
pair('Tokens', palette.text(tokens));
|
|
133
|
-
if (
|
|
134
|
-
|
|
136
|
+
if (detail !== 'min') {
|
|
137
|
+
// What the money was paid for. Every request resends the whole conversation, so the input
|
|
138
|
+
// is usually the biggest part, and the cache is what makes it cheaper.
|
|
139
|
+
const money = (v) => s.costParts && v !== undefined && iw >= 40 ? palette.faint(' ' + formatMoney(v, { estimated: s.costState.estimated })) : '';
|
|
140
|
+
const paid = (label, tokens, v) => pair(label, palette.text(fmtTokens(tokens)) + money(v));
|
|
141
|
+
paid(' Input', s.uncachedTokens, s.costParts?.input);
|
|
142
|
+
if (s.cacheReadTokens > 0)
|
|
143
|
+
paid(' Cached input', s.cacheReadTokens, s.costParts?.cacheRead);
|
|
144
|
+
if (s.cacheWriteTokens > 0)
|
|
145
|
+
paid(' Cache writes', s.cacheWriteTokens, s.costParts?.cacheWrite);
|
|
146
|
+
paid(' Output', s.completionTokens, s.costParts?.output);
|
|
147
|
+
}
|
|
135
148
|
pair('Requests', palette.text(String(s.requestsCount)));
|
|
136
149
|
// ── traffic
|
|
137
150
|
if (detail === 'full') {
|
|
@@ -221,7 +234,7 @@ export function runUsagePanel(data, options = {}) {
|
|
|
221
234
|
return;
|
|
222
235
|
closed = true;
|
|
223
236
|
input.off('data', onData);
|
|
224
|
-
|
|
237
|
+
stopWatching();
|
|
225
238
|
block.erase();
|
|
226
239
|
if (!wasRaw)
|
|
227
240
|
input.setRawMode?.(false);
|
|
@@ -236,7 +249,7 @@ export function runUsagePanel(data, options = {}) {
|
|
|
236
249
|
}
|
|
237
250
|
};
|
|
238
251
|
input.on('data', onData);
|
|
239
|
-
|
|
252
|
+
const stopWatching = block.watchResize(draw);
|
|
240
253
|
draw();
|
|
241
254
|
});
|
|
242
255
|
}
|
package/dist/cli/width.js
CHANGED
|
@@ -176,3 +176,21 @@ export function wrapText(text, width) {
|
|
|
176
176
|
const unbreakable = text.split(/\s+/).some((word) => displayWidth(word) > width);
|
|
177
177
|
return unbreakable ? wrapWidth(text, width) : wrapWords(text, width);
|
|
178
178
|
}
|
|
179
|
+
/**
|
|
180
|
+
* How many rows up the top of a block is from the cursor, once the terminal is `cols` wide.
|
|
181
|
+
*
|
|
182
|
+
* A block drawn at one width is re-wrapped by the terminal when the window is resized: a line that
|
|
183
|
+
* was as wide as the old window now takes several rows, so counting the lines of the block is not
|
|
184
|
+
* enough to get back to its top (the top stays on screen and the block shows up twice).
|
|
185
|
+
*
|
|
186
|
+
* @param lineWidths visible width of each line of the block as it was drawn
|
|
187
|
+
* @param cursorRow the line the cursor is on
|
|
188
|
+
* @param cursorCol the column of the cursor in that line
|
|
189
|
+
*/
|
|
190
|
+
export function rowsToTop(lineWidths, cursorRow, cursorCol, cols) {
|
|
191
|
+
const width = Math.max(1, cols);
|
|
192
|
+
let rows = 0;
|
|
193
|
+
for (let i = 0; i < cursorRow && i < lineWidths.length; i++)
|
|
194
|
+
rows += Math.max(1, Math.ceil(lineWidths[i] / width));
|
|
195
|
+
return rows + Math.floor(Math.max(0, cursorCol) / width);
|
|
196
|
+
}
|
package/dist/core/background.js
CHANGED
|
@@ -236,8 +236,22 @@ export class BackgroundManager extends EventEmitter {
|
|
|
236
236
|
return '';
|
|
237
237
|
const text = this.notes.map((n) => n.text).join('\n\n');
|
|
238
238
|
this.notes = [];
|
|
239
|
+
// What the prompt frame shows about unreported news is gone now: let it redraw.
|
|
240
|
+
this.emit('change');
|
|
239
241
|
return `[devx · background tasks]\n${text}`;
|
|
240
242
|
}
|
|
243
|
+
/**
|
|
244
|
+
* Tasks that ended badly and whose news the model has not received yet. The prompt frame shows
|
|
245
|
+
* this number: it goes away once devx has told the model about the failure.
|
|
246
|
+
*/
|
|
247
|
+
unreportedFailures() {
|
|
248
|
+
const ids = new Set();
|
|
249
|
+
for (const note of this.notes) {
|
|
250
|
+
if (note.kind === 'exit' && this.tasks.get(note.taskId)?.info.status === 'failed')
|
|
251
|
+
ids.add(note.taskId);
|
|
252
|
+
}
|
|
253
|
+
return ids.size;
|
|
254
|
+
}
|
|
241
255
|
// ───────────────────────── internals ─────────────────────────
|
|
242
256
|
must(id) {
|
|
243
257
|
const task = this.tasks.get(String(id));
|
|
@@ -23,6 +23,7 @@ export function contextBreakdown(messages, definitions, limit) {
|
|
|
23
23
|
user, assistant, toolResults,
|
|
24
24
|
images, imageCount,
|
|
25
25
|
total,
|
|
26
|
+
inUse: messages.some((m) => m.role !== 'system') ? total : 0,
|
|
26
27
|
compactAt: Math.floor(limit * CONTEXT_USAGE_RATIO),
|
|
27
28
|
free: Math.max(0, limit - total),
|
|
28
29
|
};
|
package/dist/core/cost.js
CHANGED
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
*/
|
|
13
13
|
const PER_MILLION = 1_000_000;
|
|
14
14
|
const finite = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
|
15
|
-
/** The cost of the tokens at the given rates. */
|
|
16
|
-
export function
|
|
15
|
+
/** The cost of the tokens at the given rates, split by what was paid for. */
|
|
16
|
+
export function costParts(rates, usage) {
|
|
17
17
|
const prompt = Math.max(0, usage.promptTokens || 0);
|
|
18
18
|
const tier = rates.tier && prompt > rates.tier.over ? rates.tier : null;
|
|
19
19
|
const input = tier?.input ?? rates.input;
|
|
@@ -23,17 +23,31 @@ export function computeCost(rates, usage) {
|
|
|
23
23
|
const read = Math.min(prompt, Math.max(0, usage.cacheReadTokens || 0));
|
|
24
24
|
const written = Math.min(prompt - read, Math.max(0, usage.cacheWriteTokens || 0));
|
|
25
25
|
const plain = prompt - read - written;
|
|
26
|
-
// Without a cache price the cached tokens cost as much as ordinary input.
|
|
27
|
-
let cost = plain * input + read * (cacheRead ?? input) + written * (cacheWrite ?? input);
|
|
28
26
|
const completion = Math.max(0, usage.completionTokens || 0);
|
|
29
27
|
const reasoning = Math.min(completion, Math.max(0, usage.reasoningTokens || 0));
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
28
|
+
const out = rates.reasoning !== undefined && reasoning > 0
|
|
29
|
+
? (completion - reasoning) * output + reasoning * rates.reasoning
|
|
30
|
+
: completion * output;
|
|
31
|
+
// Without a cache price the cached tokens cost as much as ordinary input.
|
|
32
|
+
return {
|
|
33
|
+
input: (plain * input) / PER_MILLION,
|
|
34
|
+
cacheRead: (read * (cacheRead ?? input)) / PER_MILLION,
|
|
35
|
+
cacheWrite: (written * (cacheWrite ?? input)) / PER_MILLION,
|
|
36
|
+
output: out / PER_MILLION,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** The cost of the tokens at the given rates. */
|
|
40
|
+
export function computeCost(rates, usage) {
|
|
41
|
+
const p = costParts(rates, usage);
|
|
42
|
+
return p.input + p.cacheRead + p.cacheWrite + p.output;
|
|
43
|
+
}
|
|
44
|
+
/** The same parts scaled so that they add up to `total` (the cost the provider reported). */
|
|
45
|
+
export function scaleParts(parts, total) {
|
|
46
|
+
const sum = parts.input + parts.cacheRead + parts.cacheWrite + parts.output;
|
|
47
|
+
if (!(sum > 0) || !Number.isFinite(total))
|
|
48
|
+
return undefined;
|
|
49
|
+
const k = total / sum;
|
|
50
|
+
return { input: parts.input * k, cacheRead: parts.cacheRead * k, cacheWrite: parts.cacheWrite * k, output: parts.output * k };
|
|
37
51
|
}
|
|
38
52
|
/** A price as the OpenRouter list writes it: USD per token, often a string. */
|
|
39
53
|
function perToken(value) {
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { isLocalBaseUrl } from './cost.js';
|
|
2
|
+
/** The key stored for a server that needs none (a local one): the OpenAI client wants some value and falls back to $OPENAI_API_KEY for an empty one. */
|
|
3
|
+
export const NO_KEY = 'no-key';
|
|
4
|
+
/** The id the old single "Custom" entry of the provider list had. */
|
|
5
|
+
const LEGACY_ID = 'custom';
|
|
6
|
+
const MAX_NAME = 40;
|
|
7
|
+
export function customProviders(config) {
|
|
8
|
+
const own = config.customProviders;
|
|
9
|
+
return own && typeof own === 'object' && !Array.isArray(own) ? own : {};
|
|
10
|
+
}
|
|
11
|
+
/** The saved providers, in the order they were added. */
|
|
12
|
+
export function listCustomProviders(config) {
|
|
13
|
+
return Object.entries(customProviders(config))
|
|
14
|
+
.filter(([, p]) => p && typeof p.baseUrl === 'string')
|
|
15
|
+
.map(([id, p]) => ({ id, name: String(p.name || id), baseUrl: p.baseUrl, apiKey: config.apiKeys?.[id] ?? '' }));
|
|
16
|
+
}
|
|
17
|
+
export function isCustomProvider(config, id) {
|
|
18
|
+
return !!id && !!customProviders(config)[id];
|
|
19
|
+
}
|
|
20
|
+
/** The name to show for a provider id: the user's name for a custom one, the label of a listed one, else the id. */
|
|
21
|
+
export function providerDisplayName(config, id, listed = []) {
|
|
22
|
+
if (!id)
|
|
23
|
+
return '';
|
|
24
|
+
const own = customProviders(config)[id];
|
|
25
|
+
if (own?.name)
|
|
26
|
+
return own.name;
|
|
27
|
+
return listed.find((p) => p.value === id)?.label ?? id;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The single "Custom" entry older versions had becomes a saved provider named Custom, so that
|
|
31
|
+
* whoever used it finds it in the list (with its address and key). Returns true when it did.
|
|
32
|
+
*/
|
|
33
|
+
export function migrateLegacyCustom(config) {
|
|
34
|
+
if (customProviders(config)[LEGACY_ID])
|
|
35
|
+
return false;
|
|
36
|
+
const url = config.baseUrls?.[LEGACY_ID] || (config.provider === LEGACY_ID ? config.baseUrl : '');
|
|
37
|
+
if (!url)
|
|
38
|
+
return false;
|
|
39
|
+
config.customProviders = { ...customProviders(config), [LEGACY_ID]: { name: 'Custom', baseUrl: url } };
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
/** Is this name usable? A name must be unique (without regard to case), but a provider may keep its own. */
|
|
43
|
+
export function nameProblem(config, name, exceptId) {
|
|
44
|
+
const clean = name.trim();
|
|
45
|
+
if (!clean)
|
|
46
|
+
return 'empty';
|
|
47
|
+
if (Array.from(clean).length > MAX_NAME)
|
|
48
|
+
return 'long';
|
|
49
|
+
const taken = listCustomProviders(config).some((p) => p.id !== exceptId && p.name.trim().toLowerCase() === clean.toLowerCase());
|
|
50
|
+
return taken ? 'taken' : null;
|
|
51
|
+
}
|
|
52
|
+
/** An address of an API: http(s), with a host. The path stays as typed (`/v1`), only a trailing slash goes. */
|
|
53
|
+
export function checkBaseUrl(input) {
|
|
54
|
+
const text = input.trim();
|
|
55
|
+
if (!text)
|
|
56
|
+
return { ok: false, reason: 'empty' };
|
|
57
|
+
let url;
|
|
58
|
+
try {
|
|
59
|
+
url = new URL(text);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return { ok: false, reason: 'invalid' };
|
|
63
|
+
}
|
|
64
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
65
|
+
return { ok: false, reason: 'scheme' };
|
|
66
|
+
if (!url.hostname)
|
|
67
|
+
return { ok: false, reason: 'invalid' };
|
|
68
|
+
return { ok: true, url: text.replace(/\/+$/, '') };
|
|
69
|
+
}
|
|
70
|
+
/** A server on this machine or on the home network usually needs no key. */
|
|
71
|
+
export function usuallyNeedsNoKey(baseUrl) {
|
|
72
|
+
return isLocalBaseUrl(baseUrl);
|
|
73
|
+
}
|
|
74
|
+
/** `api.example.com` for the list. */
|
|
75
|
+
export const hostOfUrl = (url) => url.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
|
|
76
|
+
/** The key as the list shows it. */
|
|
77
|
+
export function keyHint(key) {
|
|
78
|
+
if (!key)
|
|
79
|
+
return '';
|
|
80
|
+
if (key === NO_KEY)
|
|
81
|
+
return 'no key';
|
|
82
|
+
if (key.length <= 8)
|
|
83
|
+
return '****';
|
|
84
|
+
return `${key.slice(0, 4)}...${key.slice(-4)}`;
|
|
85
|
+
}
|
|
86
|
+
// ───────────────────────── changing the saved providers ─────────────────────────
|
|
87
|
+
/** An id from the name that no listed and no saved provider has: `custom-my-gateway`, `custom-my-gateway-2`. */
|
|
88
|
+
export function newProviderId(config, name, listedIds = []) {
|
|
89
|
+
const slug = name
|
|
90
|
+
.toLowerCase()
|
|
91
|
+
.normalize('NFC')
|
|
92
|
+
.replace(/[^\p{L}\p{N}]+/gu, '-')
|
|
93
|
+
.replace(/^-+|-+$/g, '')
|
|
94
|
+
.slice(0, 30) || 'provider';
|
|
95
|
+
const taken = new Set([...listedIds, ...Object.keys(customProviders(config))]);
|
|
96
|
+
let id = `custom-${slug}`;
|
|
97
|
+
for (let n = 2; taken.has(id); n++)
|
|
98
|
+
id = `custom-${slug}-${n}`;
|
|
99
|
+
return id;
|
|
100
|
+
}
|
|
101
|
+
function ensureMaps(config) {
|
|
102
|
+
const own = config.customProviders;
|
|
103
|
+
if (!own || typeof own !== 'object' || Array.isArray(own))
|
|
104
|
+
config.customProviders = {};
|
|
105
|
+
config.apiKeys = config.apiKeys || {};
|
|
106
|
+
config.baseUrls = config.baseUrls || {};
|
|
107
|
+
}
|
|
108
|
+
/** Adds a provider and returns its id. The name and address are expected to have been checked. */
|
|
109
|
+
export function addCustomProvider(config, input, listedIds = []) {
|
|
110
|
+
ensureMaps(config);
|
|
111
|
+
const id = newProviderId(config, input.name, listedIds);
|
|
112
|
+
config.customProviders[id] = { name: input.name.trim(), baseUrl: input.baseUrl };
|
|
113
|
+
config.baseUrls[id] = input.baseUrl;
|
|
114
|
+
config.apiKeys[id] = input.apiKey;
|
|
115
|
+
return id;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Changes the name, the address or the key of a saved provider. When it is the provider in use,
|
|
119
|
+
* the settings of the running session follow at once.
|
|
120
|
+
*/
|
|
121
|
+
export function updateCustomProvider(config, id, patch) {
|
|
122
|
+
const own = customProviders(config)[id];
|
|
123
|
+
if (!own)
|
|
124
|
+
return false;
|
|
125
|
+
ensureMaps(config);
|
|
126
|
+
if (patch.name !== undefined)
|
|
127
|
+
config.customProviders[id] = { ...config.customProviders[id], name: patch.name.trim() };
|
|
128
|
+
if (patch.baseUrl !== undefined) {
|
|
129
|
+
config.customProviders[id] = { ...config.customProviders[id], baseUrl: patch.baseUrl };
|
|
130
|
+
config.baseUrls[id] = patch.baseUrl;
|
|
131
|
+
if (config.provider === id)
|
|
132
|
+
config.baseUrl = patch.baseUrl;
|
|
133
|
+
}
|
|
134
|
+
if (patch.apiKey !== undefined) {
|
|
135
|
+
config.apiKeys[id] = patch.apiKey;
|
|
136
|
+
if (config.provider === id)
|
|
137
|
+
config.apiKey = patch.apiKey;
|
|
138
|
+
}
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Forgets a saved provider with its key. The provider in use keeps working until another is chosen
|
|
143
|
+
* (its address and key stay in the running settings) but it is no longer a provider of the list:
|
|
144
|
+
* the config forgets its id, so that saving does not write the key and the address back;
|
|
145
|
+
* `wasActive` tells the caller to say so.
|
|
146
|
+
*/
|
|
147
|
+
export function removeCustomProvider(config, id) {
|
|
148
|
+
if (!customProviders(config)[id])
|
|
149
|
+
return { removed: false, wasActive: false };
|
|
150
|
+
const rest = { ...config.customProviders };
|
|
151
|
+
delete rest[id];
|
|
152
|
+
config.customProviders = rest;
|
|
153
|
+
if (config.apiKeys)
|
|
154
|
+
delete config.apiKeys[id];
|
|
155
|
+
if (config.baseUrls)
|
|
156
|
+
delete config.baseUrls[id];
|
|
157
|
+
const wasActive = config.provider === id;
|
|
158
|
+
if (wasActive)
|
|
159
|
+
delete config.provider;
|
|
160
|
+
return { removed: true, wasActive };
|
|
161
|
+
}
|