termux-dev 1.8.2 → 1.9.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 +15 -5
- 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 +4 -0
- package/dist/cli/headless.js +3 -2
- package/dist/cli/index.js +128 -59
- package/dist/cli/keys.js +12 -3
- package/dist/cli/liveblock.js +38 -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/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 +36 -7
- package/dist/core/locales/en.js +36 -7
- package/dist/core/locales/es.js +36 -7
- package/dist/core/locales/fr.js +36 -7
- package/dist/core/locales/ja.js +36 -7
- package/dist/core/locales/pt.js +36 -7
- package/dist/core/locales/ru.js +36 -7
- package/dist/core/locales/uk.js +36 -7
- package/dist/core/locales/zh.js +36 -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/index.js
CHANGED
|
@@ -26,6 +26,8 @@ import { savePlanFile } from '../core/planfile.js';
|
|
|
26
26
|
import { BASE_TITLE, RESTORE_TERMINAL_TITLE, SAVE_TERMINAL_TITLE, cleanTitle, generateChatTitle, provisionalTitle, terminalTitleSequence } from '../core/title.js';
|
|
27
27
|
import { runDoctor } from './doctor.js';
|
|
28
28
|
import { ALL_PROVIDERS } from './providers.js';
|
|
29
|
+
import { addProviderFlow, manageProviderFlow } from './provider-manager.js';
|
|
30
|
+
import { NO_KEY, hostOfUrl, isCustomProvider, keyHint, listCustomProviders, migrateLegacyCustom, providerDisplayName } from '../core/custom-providers.js';
|
|
29
31
|
import { getModelContextInfo, getModelContextLimit } from '../core/models.js';
|
|
30
32
|
import { SessionManager } from '../core/session.js';
|
|
31
33
|
import { MarkdownStreamer, renderMarkdown } from './markdown.js';
|
|
@@ -141,7 +143,7 @@ async function fetchModels(baseUrl, apiKey) {
|
|
|
141
143
|
if (apiKey)
|
|
142
144
|
headers['x-api-key'] = apiKey;
|
|
143
145
|
}
|
|
144
|
-
else if (apiKey && apiKey !== 'ollama') {
|
|
146
|
+
else if (apiKey && apiKey !== 'ollama' && apiKey !== NO_KEY) {
|
|
145
147
|
headers['Authorization'] = `Bearer ${apiKey}`;
|
|
146
148
|
}
|
|
147
149
|
const res = await fetch(url, { headers });
|
|
@@ -248,12 +250,21 @@ async function selectModel(baseUrl, apiKey, currentModel, providerId = '', _allo
|
|
|
248
250
|
/** The host of an address, for the detail column: `api.openai.com`. */
|
|
249
251
|
const hostOf = (url) => url.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
|
|
250
252
|
async function selectProvider(currentConfig, allowCancel = true) {
|
|
251
|
-
|
|
252
|
-
const
|
|
253
|
+
// The maps are shared with the config, so what the provider screens change is seen by both.
|
|
254
|
+
const apiKeys = (currentConfig.apiKeys = currentConfig.apiKeys || {});
|
|
255
|
+
const baseUrls = (currentConfig.baseUrls = currentConfig.baseUrls || {});
|
|
253
256
|
if (currentConfig.provider && currentConfig.apiKey) {
|
|
254
257
|
apiKeys[currentConfig.provider] = currentConfig.apiKey;
|
|
255
258
|
}
|
|
259
|
+
// The single "Custom" of older versions becomes a saved provider with a name of its own.
|
|
260
|
+
migrateLegacyCustom(currentConfig);
|
|
256
261
|
const firstRun = !allowCancel;
|
|
262
|
+
const providerFlow = {
|
|
263
|
+
// The first-run setup saves everything once, at its end.
|
|
264
|
+
save: firstRun ? undefined : saveConfig,
|
|
265
|
+
listedIds: ALL_PROVIDERS.map((pr) => pr.value),
|
|
266
|
+
};
|
|
267
|
+
const ADD = '__add__';
|
|
257
268
|
const stepOf = (n) => (firstRun ? `${n}/5` : undefined);
|
|
258
269
|
let providerId = '';
|
|
259
270
|
let baseUrl = '';
|
|
@@ -268,20 +279,44 @@ async function selectProvider(currentConfig, allowCancel = true) {
|
|
|
268
279
|
placeholder: t('onboard.provider.placeholder'),
|
|
269
280
|
current: currentConfig.provider,
|
|
270
281
|
notes: firstRun ? [t('onboard.provider.note')] : undefined,
|
|
271
|
-
items:
|
|
272
|
-
value:
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
282
|
+
items: [
|
|
283
|
+
{ value: ADD, label: t('provider.add'), detail: t('provider.addDetail'), pinned: true },
|
|
284
|
+
// The providers the user saved come first, then the long list of known ones.
|
|
285
|
+
...listCustomProviders(currentConfig).map((c) => ({
|
|
286
|
+
value: c.id,
|
|
287
|
+
label: c.name,
|
|
288
|
+
detail: `${t('provider.customTag')} · ${[keyHint(c.apiKey), hostOfUrl(c.baseUrl)].filter(Boolean).join(' ')}`,
|
|
289
|
+
keywords: `${c.id} ${hostOfUrl(c.baseUrl)} ${t('provider.customTag')}`,
|
|
290
|
+
})),
|
|
291
|
+
...ALL_PROVIDERS.map((pr) => ({
|
|
292
|
+
value: pr.value,
|
|
293
|
+
label: pr.label,
|
|
294
|
+
detail: `${apiKeys[pr.value] ? maskApiKey(apiKeys[pr.value]) + ' ' : ''}${hostOf(pr.baseUrl || '')}`.trim(),
|
|
295
|
+
keywords: `${pr.value} ${hostOf(pr.baseUrl || '')}`,
|
|
296
|
+
})),
|
|
297
|
+
],
|
|
278
298
|
});
|
|
279
299
|
if (choice === undefined)
|
|
280
300
|
return null; // Esc on the first step leaves the whole setup
|
|
301
|
+
if (choice === ADD || isCustomProvider(currentConfig, choice)) {
|
|
302
|
+
// A provider of the user's own: add one, or use / change / delete a saved one.
|
|
303
|
+
let id = choice;
|
|
304
|
+
if (choice === ADD)
|
|
305
|
+
id = await addProviderFlow(currentConfig, providerFlow);
|
|
306
|
+
else if ((await manageProviderFlow(currentConfig, choice, providerFlow)) !== 'use')
|
|
307
|
+
id = null;
|
|
308
|
+
if (!id)
|
|
309
|
+
continue; // back to the list (it shows the changes)
|
|
310
|
+
providerId = id;
|
|
311
|
+
baseUrl = currentConfig.customProviders[id].baseUrl;
|
|
312
|
+
apiKey = apiKeys[id] ?? '';
|
|
313
|
+
stage = apiKey ? 'model' : 'key';
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
281
316
|
providerId = choice;
|
|
282
317
|
const known = ALL_PROVIDERS.find(pr => pr.value === providerId);
|
|
283
318
|
baseUrl = baseUrls[providerId] || known?.baseUrl || '';
|
|
284
|
-
stage =
|
|
319
|
+
stage = !baseUrl ? 'url' : 'key';
|
|
285
320
|
}
|
|
286
321
|
else if (stage === 'url') {
|
|
287
322
|
const url = await runTextInput({
|
|
@@ -300,8 +335,8 @@ async function selectProvider(currentConfig, allowCancel = true) {
|
|
|
300
335
|
stage = 'key';
|
|
301
336
|
}
|
|
302
337
|
else if (stage === 'key') {
|
|
303
|
-
const label =
|
|
304
|
-
const back = providerId
|
|
338
|
+
const label = providerDisplayName(currentConfig, providerId, ALL_PROVIDERS);
|
|
339
|
+
const back = isCustomProvider(currentConfig, providerId) ? 'provider' : !(ALL_PROVIDERS.find(pr => pr.value === providerId)?.baseUrl) ? 'url' : 'provider';
|
|
305
340
|
if (providerId === 'ollama') {
|
|
306
341
|
apiKey = 'ollama';
|
|
307
342
|
stage = 'model';
|
|
@@ -348,7 +383,7 @@ async function selectProvider(currentConfig, allowCancel = true) {
|
|
|
348
383
|
else {
|
|
349
384
|
modelRes = await selectModel(baseUrl, apiKey, currentConfig.model || '', providerId, allowCancel, stepOf(5));
|
|
350
385
|
if (!modelRes)
|
|
351
|
-
stage = apiKey === 'ollama' ? 'provider' : 'key';
|
|
386
|
+
stage = apiKey === 'ollama' || isCustomProvider(currentConfig, providerId) ? 'provider' : 'key';
|
|
352
387
|
}
|
|
353
388
|
}
|
|
354
389
|
const updatedConfig = {
|
|
@@ -715,6 +750,7 @@ async function handleSettings(config) {
|
|
|
715
750
|
layout: config.ui?.layout === 'classic' ? 'classic' : 'clean',
|
|
716
751
|
pureBlack: config.pureBlackTheme !== false,
|
|
717
752
|
autoApprove: !!config.autoApprove,
|
|
753
|
+
extra: !!config.autoApprove && !!config.autoApproveAll,
|
|
718
754
|
maxIterations: config.maxIterations || 100,
|
|
719
755
|
memory: config.enableMemory !== false,
|
|
720
756
|
updates: config.checkUpdates !== false,
|
|
@@ -745,12 +781,20 @@ async function handleSettings(config) {
|
|
|
745
781
|
config.banner = final.layout === 'clean' ? (config.banner === 'off' ? 'off' : 'clean') : 'full';
|
|
746
782
|
config.pureBlackTheme = final.pureBlack;
|
|
747
783
|
config.autoApprove = final.autoApprove;
|
|
784
|
+
// Extra goes with YOLO and, like YOLO, is never saved: it is off again at the next start.
|
|
785
|
+
const extraWasOn = !!initial.extra;
|
|
786
|
+
config.autoApproveAll = final.autoApprove && !!final.extra;
|
|
748
787
|
config.maxIterations = final.maxIterations;
|
|
749
788
|
config.enableMemory = final.memory;
|
|
750
789
|
config.checkUpdates = final.updates;
|
|
751
790
|
config.language = final.language;
|
|
752
791
|
config.maxCostPerSession = final.costLimit;
|
|
753
792
|
await saveConfig(config);
|
|
793
|
+
// Said once more where it cannot be missed, when the panel is closed with Extra newly switched on.
|
|
794
|
+
if (config.autoApproveAll && !extraWasOn) {
|
|
795
|
+
console.log(renderNotice('warn', t('settings.extraEnabled')));
|
|
796
|
+
console.log();
|
|
797
|
+
}
|
|
754
798
|
process.stdin.resume();
|
|
755
799
|
return config;
|
|
756
800
|
}
|
|
@@ -789,6 +833,7 @@ export async function main() {
|
|
|
789
833
|
.version(DEVX_VERSION)
|
|
790
834
|
.option('-p, --prompt <task>', 'Run one-shot task non-interactively (headless mode)')
|
|
791
835
|
.option('-y, --yolo', 'Automatically approve all tool executions without confirmation')
|
|
836
|
+
.option('--extra', 'Like --yolo, but nothing is asked at all: dangerous commands and credential files too (use only where you can afford to lose everything)')
|
|
792
837
|
.option('-m, --model <model>', 'Specify AI model to use for this execution')
|
|
793
838
|
.option('-q, --quiet', 'Quiet output in headless mode (suppress banners and tool logs)')
|
|
794
839
|
.option('--json', 'Output structured JSON in headless mode')
|
|
@@ -812,9 +857,12 @@ export async function main() {
|
|
|
812
857
|
}
|
|
813
858
|
// -m and the project config are for this run: only what changes from here on is saved.
|
|
814
859
|
markConfigBaseline(config);
|
|
815
|
-
if (options.yolo) {
|
|
860
|
+
if (options.yolo || options.extra) {
|
|
816
861
|
config.autoApprove = true;
|
|
817
862
|
}
|
|
863
|
+
if (options.extra) {
|
|
864
|
+
config.autoApproveAll = true;
|
|
865
|
+
}
|
|
818
866
|
if (options.effort !== undefined) {
|
|
819
867
|
// One run only (never saved). Anything the model does not offer is ignored with a warning.
|
|
820
868
|
// A custom endpoint is asked about the model first (cached, a few seconds at most).
|
|
@@ -844,6 +892,7 @@ export async function main() {
|
|
|
844
892
|
const exitCode = await runHeadlessMode(options.prompt, config, {
|
|
845
893
|
planMode: planModeInitial,
|
|
846
894
|
yolo: !!options.yolo,
|
|
895
|
+
extra: !!options.extra,
|
|
847
896
|
quiet: !!options.quiet,
|
|
848
897
|
json: !!options.json,
|
|
849
898
|
signal: abortController.signal
|
|
@@ -857,11 +906,12 @@ export async function main() {
|
|
|
857
906
|
return {
|
|
858
907
|
version: DEVX_VERSION,
|
|
859
908
|
model: modelLabel(config),
|
|
860
|
-
provider: config.provider,
|
|
909
|
+
provider: providerDisplayName(config, config.provider, ALL_PROVIDERS),
|
|
861
910
|
cwd: process.cwd(),
|
|
862
911
|
branch: git?.branch,
|
|
863
912
|
dirty: git?.isDirty,
|
|
864
913
|
yolo: !!config.autoApprove,
|
|
914
|
+
extra: !!config.autoApprove && !!config.autoApproveAll,
|
|
865
915
|
tip: pickTip(),
|
|
866
916
|
};
|
|
867
917
|
};
|
|
@@ -889,6 +939,31 @@ export async function main() {
|
|
|
889
939
|
// Ask a custom endpoint what the current model can do (cached for a day; never blocks the prompt).
|
|
890
940
|
void refreshEndpoint(config).catch(() => null);
|
|
891
941
|
const sessionCost = new CostLedger();
|
|
942
|
+
// For the small side jobs (chat title, commit message, summary): the lightest reasoning the model
|
|
943
|
+
// offers, and what they cost goes into the session total like everything else.
|
|
944
|
+
const sideProvider = () => {
|
|
945
|
+
const inner = createProvider(config, { light: true });
|
|
946
|
+
return {
|
|
947
|
+
id: inner.id,
|
|
948
|
+
chat: async (request) => {
|
|
949
|
+
try {
|
|
950
|
+
const res = await inner.chat(request);
|
|
951
|
+
if (res.usage)
|
|
952
|
+
sessionCost.add(res.usage);
|
|
953
|
+
return res;
|
|
954
|
+
}
|
|
955
|
+
catch (err) {
|
|
956
|
+
// Timed out or cut off after the request went out: still billed, so it counts as an estimate.
|
|
957
|
+
if (err?.status === undefined || err.status === 504 || err.status === 524) {
|
|
958
|
+
const lost = inner.lostRequestUsage?.(new History().estimateTokens(request.messages), 0);
|
|
959
|
+
if (lost)
|
|
960
|
+
sessionCost.add(lost);
|
|
961
|
+
}
|
|
962
|
+
throw err;
|
|
963
|
+
}
|
|
964
|
+
},
|
|
965
|
+
};
|
|
966
|
+
};
|
|
892
967
|
const currentContext = () => contextBreakdown(history.getMessages(), getTools(planMode).map(t => t.definition), config.maxContextTokens || getModelContextLimit(config.model));
|
|
893
968
|
let currentDraft = '';
|
|
894
969
|
let autoTriggerPrompt = '';
|
|
@@ -903,8 +978,7 @@ export async function main() {
|
|
|
903
978
|
const permissionMemory = newPermissionMemory();
|
|
904
979
|
// Commands the agent keeps running in the background (servers, watchers): shown in the prompt frame, listed by /tasks.
|
|
905
980
|
const bg = getBackgroundManager();
|
|
906
|
-
const
|
|
907
|
-
const bgSummary = () => backgroundSummary(bg, seenBgFailures);
|
|
981
|
+
const bgSummary = () => backgroundSummary(bg);
|
|
908
982
|
// The most a session may cost (USD, 0 = no limit); "continue" at the limit raises it for this session only.
|
|
909
983
|
let costLimitRaised = null;
|
|
910
984
|
let costWarned = false;
|
|
@@ -937,7 +1011,6 @@ export async function main() {
|
|
|
937
1011
|
await bg.stopAll().catch(() => 0);
|
|
938
1012
|
bg.clearFinished();
|
|
939
1013
|
bg.takeNotes();
|
|
940
|
-
seenBgFailures.clear();
|
|
941
1014
|
transcript.length = 0;
|
|
942
1015
|
checkpoints.length = 0;
|
|
943
1016
|
windowTitle = '';
|
|
@@ -964,10 +1037,10 @@ export async function main() {
|
|
|
964
1037
|
while (true) {
|
|
965
1038
|
const maxTokens = config.maxContextTokens || getModelContextLimit(config.model);
|
|
966
1039
|
config.maxContextTokens = maxTokens;
|
|
967
|
-
//
|
|
968
|
-
//
|
|
1040
|
+
// An empty chat shows 0; from the first message on, everything the next request carries
|
|
1041
|
+
// is counted: the system prompt and the tool schemas too.
|
|
969
1042
|
const ctxNow = currentContext();
|
|
970
|
-
const currentTokens = ctxNow.
|
|
1043
|
+
const currentTokens = ctxNow.inUse;
|
|
971
1044
|
const usagePercent = Math.min(100, Math.round((currentTokens / maxTokens) * 100));
|
|
972
1045
|
const formatTokens = (n) => {
|
|
973
1046
|
if (n >= 1_000_000)
|
|
@@ -1044,6 +1117,7 @@ export async function main() {
|
|
|
1044
1117
|
},
|
|
1045
1118
|
planMode,
|
|
1046
1119
|
isYolo: !!config.autoApprove,
|
|
1120
|
+
isExtra: !!config.autoApprove && !!config.autoApproveAll,
|
|
1047
1121
|
ui: config.ui,
|
|
1048
1122
|
model: modelLabel(config),
|
|
1049
1123
|
cwd: process.cwd(),
|
|
@@ -1283,6 +1357,8 @@ export async function main() {
|
|
|
1283
1357
|
planMode = loaded.planMode;
|
|
1284
1358
|
}
|
|
1285
1359
|
sessionCost.restore(loaded.totalCost || 0, loaded.costEstimated === true);
|
|
1360
|
+
// /usage starts from the same total as the frame, and not from the chat that was open before.
|
|
1361
|
+
UsageTracker.getInstance().restoreCost(loaded.totalCost || 0, loaded.costEstimated === true);
|
|
1286
1362
|
drawLogo(config.banner || 'full');
|
|
1287
1363
|
const nonSysMessages = loaded.messages.filter(m => m.role !== 'system');
|
|
1288
1364
|
const totalMsgs = nonSysMessages.length;
|
|
@@ -1440,7 +1516,7 @@ export async function main() {
|
|
|
1440
1516
|
const s = new UiSpinner();
|
|
1441
1517
|
s.start('Generating conventional commit message with AI...');
|
|
1442
1518
|
await refreshFormats(config).catch(() => null);
|
|
1443
|
-
const provider =
|
|
1519
|
+
const provider = sideProvider();
|
|
1444
1520
|
const promptReq = {
|
|
1445
1521
|
messages: [
|
|
1446
1522
|
{
|
|
@@ -1482,7 +1558,7 @@ export async function main() {
|
|
|
1482
1558
|
s.start('Compacting conversation context with AI...');
|
|
1483
1559
|
try {
|
|
1484
1560
|
await refreshFormats(config).catch(() => null);
|
|
1485
|
-
const provider =
|
|
1561
|
+
const provider = sideProvider();
|
|
1486
1562
|
// The request itself must fit the window: shorten a copy of the history first.
|
|
1487
1563
|
const forSummary = new History();
|
|
1488
1564
|
for (const m of msgs)
|
|
@@ -1552,7 +1628,7 @@ export async function main() {
|
|
|
1552
1628
|
continue;
|
|
1553
1629
|
}
|
|
1554
1630
|
if (cmd === '/tasks' || cmd === '/bg' || cmd === '/jobs') {
|
|
1555
|
-
const rows = await runTasksCommand(parts.slice(1).filter(Boolean), bg, process.stdout.columns || 80
|
|
1631
|
+
const rows = await runTasksCommand(parts.slice(1).filter(Boolean), bg, process.stdout.columns || 80);
|
|
1556
1632
|
console.log(rows.join('\n'));
|
|
1557
1633
|
console.log();
|
|
1558
1634
|
continue;
|
|
@@ -1677,7 +1753,7 @@ export async function main() {
|
|
|
1677
1753
|
log.warn(t('rename.usage'));
|
|
1678
1754
|
}
|
|
1679
1755
|
else {
|
|
1680
|
-
const generated = await generateChatTitle(
|
|
1756
|
+
const generated = await generateChatTitle(sideProvider(), firstUser.split('\n\n--- Attached Files ---')[0], reply);
|
|
1681
1757
|
if (!generated)
|
|
1682
1758
|
log.error(t('rename.failed'));
|
|
1683
1759
|
else {
|
|
@@ -1938,7 +2014,7 @@ export async function main() {
|
|
|
1938
2014
|
if (newConfig) {
|
|
1939
2015
|
config = newConfig;
|
|
1940
2016
|
await refreshEndpoint(config).catch(() => null);
|
|
1941
|
-
announce(`provider ${config.provider || 'custom'} · ${config.model}${providerFormat(config) === 'anthropic' ? ' (Anthropic format)' : ''}`);
|
|
2017
|
+
announce(`provider ${providerDisplayName(config, config.provider, ALL_PROVIDERS) || config.provider || 'custom'} · ${config.model}${providerFormat(config) === 'anthropic' ? ' (Anthropic format)' : ''}`);
|
|
1942
2018
|
}
|
|
1943
2019
|
else {
|
|
1944
2020
|
log.info('Provider selection cancelled.');
|
|
@@ -2046,6 +2122,7 @@ export async function main() {
|
|
|
2046
2122
|
const provider = createProvider(config);
|
|
2047
2123
|
const tools = getTools(planMode);
|
|
2048
2124
|
const guard = new CLIConsoleGuard(config.autoApprove, [...(config.bashAllowlist || [])], {
|
|
2125
|
+
extra: !!config.autoApproveAll,
|
|
2049
2126
|
memory: permissionMemory,
|
|
2050
2127
|
// "Always": kept in the config for the next sessions (`/permissions` lists and clears them).
|
|
2051
2128
|
onAlways: (r) => {
|
|
@@ -2279,9 +2356,7 @@ export async function main() {
|
|
|
2279
2356
|
pendingRedraw = true;
|
|
2280
2357
|
}
|
|
2281
2358
|
};
|
|
2282
|
-
|
|
2283
|
-
let interruptPrompt = '';
|
|
2284
|
-
const stopGeneration = (quiet = false, reason) => {
|
|
2359
|
+
const stopGeneration = (reason) => {
|
|
2285
2360
|
if (aborted)
|
|
2286
2361
|
return;
|
|
2287
2362
|
aborted = true;
|
|
@@ -2299,14 +2374,6 @@ export async function main() {
|
|
|
2299
2374
|
console.log(renderNotice('warn', reason));
|
|
2300
2375
|
console.log();
|
|
2301
2376
|
}
|
|
2302
|
-
else if (quiet) {
|
|
2303
|
-
// The message that caused the stop is echoed by the next turn: only a quiet separator here.
|
|
2304
|
-
if (cleanUi)
|
|
2305
|
-
console.log(renderNotice('info', t('stop.interrupted')));
|
|
2306
|
-
else
|
|
2307
|
-
console.log(pc.yellow('\n' + t('stop.classicInterrupted')));
|
|
2308
|
-
console.log();
|
|
2309
|
-
}
|
|
2310
2377
|
else if (cleanUi) {
|
|
2311
2378
|
console.log(renderNotice('warn', isConnecting ? t('stop.connection') : t('stop.stopped')));
|
|
2312
2379
|
console.log();
|
|
@@ -2321,13 +2388,6 @@ export async function main() {
|
|
|
2321
2388
|
const onSigInt = () => {
|
|
2322
2389
|
stopGeneration();
|
|
2323
2390
|
};
|
|
2324
|
-
const sendQueuedNow = () => {
|
|
2325
|
-
if (aborted)
|
|
2326
|
-
return;
|
|
2327
|
-
interruptPrompt = composer.takeQueued().join('\n');
|
|
2328
|
-
if (interruptPrompt)
|
|
2329
|
-
stopGeneration(true);
|
|
2330
|
-
};
|
|
2331
2391
|
// The input line at the bottom while the agent works: what is typed there is queued and
|
|
2332
2392
|
// reaches the model at its next step (see TurnComposer).
|
|
2333
2393
|
// The working line (time and tokens) stays above the frame for the whole turn.
|
|
@@ -2343,10 +2403,10 @@ export async function main() {
|
|
|
2343
2403
|
});
|
|
2344
2404
|
const composer = new TurnComposer(process.stdout, () => {
|
|
2345
2405
|
const ctx = currentContext();
|
|
2346
|
-
const used = ctx.
|
|
2406
|
+
const used = ctx.inUse;
|
|
2347
2407
|
const git = GitStatusCache.getStatus();
|
|
2348
2408
|
return {
|
|
2349
|
-
mode: resolveMode(planMode, !!config.autoApprove),
|
|
2409
|
+
mode: resolveMode(planMode, !!config.autoApprove, !!config.autoApproveAll),
|
|
2350
2410
|
model: modelLabel(config),
|
|
2351
2411
|
cwd: process.cwd(),
|
|
2352
2412
|
branch: git?.branch,
|
|
@@ -2356,7 +2416,7 @@ export async function main() {
|
|
|
2356
2416
|
costState: sessionCost.state(),
|
|
2357
2417
|
background: bgSummary(),
|
|
2358
2418
|
};
|
|
2359
|
-
}, undefined, statusLine
|
|
2419
|
+
}, undefined, statusLine);
|
|
2360
2420
|
// A task started or ended during the turn: the frame shows the new count.
|
|
2361
2421
|
const onBgChange = () => composer.refresh();
|
|
2362
2422
|
bg.on('change', onBgChange);
|
|
@@ -2380,6 +2440,16 @@ export async function main() {
|
|
|
2380
2440
|
stopGeneration();
|
|
2381
2441
|
return;
|
|
2382
2442
|
}
|
|
2443
|
+
// Esc stops the answer and whatever it started (a command that runs), like Ctrl+C. Not while a
|
|
2444
|
+
// card has the keyboard: there Esc answers the card. A lone Esc comes in a chunk of its own;
|
|
2445
|
+
// Esc followed by a character in one chunk is Alt+that character.
|
|
2446
|
+
if (!composer.isSuspended) {
|
|
2447
|
+
const keys = splitKeys(text);
|
|
2448
|
+
if (keys.length === 1 && normalizeKey(keys[0]) === '\x1b') {
|
|
2449
|
+
stopGeneration();
|
|
2450
|
+
return;
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
2383
2453
|
composer.handleData(text);
|
|
2384
2454
|
};
|
|
2385
2455
|
const onResize = () => composer.onResize();
|
|
@@ -2419,8 +2489,14 @@ export async function main() {
|
|
|
2419
2489
|
takeNotes: () => bg.takeNotes(),
|
|
2420
2490
|
hasNotes: () => bg.hasNotes(),
|
|
2421
2491
|
})) {
|
|
2422
|
-
if (aborted)
|
|
2492
|
+
if (aborted) {
|
|
2493
|
+
// The request that was cancelled is still billed by the provider: its estimate counts.
|
|
2494
|
+
if (event.type === 'usage') {
|
|
2495
|
+
sessionCost.add(event.usage);
|
|
2496
|
+
turnInputTokens += event.usage.promptTokens || 0;
|
|
2497
|
+
}
|
|
2423
2498
|
break;
|
|
2499
|
+
}
|
|
2424
2500
|
if (event.type === 'reasoning_delta') {
|
|
2425
2501
|
isConnecting = false;
|
|
2426
2502
|
telemetry.setPhase('thinking');
|
|
@@ -2624,7 +2700,7 @@ export async function main() {
|
|
|
2624
2700
|
const limit = costLimit();
|
|
2625
2701
|
if (limit > 0 && !aborted) {
|
|
2626
2702
|
if (sessionCost.total >= limit) {
|
|
2627
|
-
stopGeneration(
|
|
2703
|
+
stopGeneration(t('cost.stopped', { limit: formatCost(limit) }));
|
|
2628
2704
|
}
|
|
2629
2705
|
else if (!costWarned && sessionCost.total >= limit * 0.8) {
|
|
2630
2706
|
costWarned = true;
|
|
@@ -2735,12 +2811,7 @@ export async function main() {
|
|
|
2735
2811
|
// a message that arrived too late for the agent starts the next turn by itself.
|
|
2736
2812
|
bg.off('change', onBgChange);
|
|
2737
2813
|
const leftover = composer.stop();
|
|
2738
|
-
if (
|
|
2739
|
-
autoTriggerPrompt = interruptPrompt;
|
|
2740
|
-
if (leftover.draft)
|
|
2741
|
-
currentDraft = leftover.draft;
|
|
2742
|
-
}
|
|
2743
|
-
else if (leftover.queued.length > 0) {
|
|
2814
|
+
if (leftover.queued.length > 0) {
|
|
2744
2815
|
if (aborted)
|
|
2745
2816
|
currentDraft = [...leftover.queued, leftover.draft].filter(Boolean).join(' ');
|
|
2746
2817
|
else
|
|
@@ -2795,7 +2866,7 @@ export async function main() {
|
|
|
2795
2866
|
const forSession = sessionManager.getSession().id;
|
|
2796
2867
|
void (async () => {
|
|
2797
2868
|
try {
|
|
2798
|
-
const title = await generateChatTitle(
|
|
2869
|
+
const title = await generateChatTitle(sideProvider(), firstUser.split('\n\n--- Attached Files ---')[0], reply);
|
|
2799
2870
|
// The user may have started another chat meanwhile.
|
|
2800
2871
|
if (!title || sessionManager.getSession().id !== forSession)
|
|
2801
2872
|
return;
|
|
@@ -2863,8 +2934,6 @@ export async function main() {
|
|
|
2863
2934
|
const unsent = composer.stop();
|
|
2864
2935
|
if (process.stdout.isTTY)
|
|
2865
2936
|
process.stdout.write('\x1b[?1004l');
|
|
2866
|
-
if (interruptPrompt && !autoTriggerPrompt)
|
|
2867
|
-
autoTriggerPrompt = interruptPrompt;
|
|
2868
2937
|
releasePauseHook();
|
|
2869
2938
|
process.stdout.removeListener('resize', onResize);
|
|
2870
2939
|
if (!currentDraft && (unsent.draft || unsent.queued.length > 0)) {
|
package/dist/cli/keys.js
CHANGED
|
@@ -13,11 +13,20 @@ export function splitKeys(chunk) {
|
|
|
13
13
|
}
|
|
14
14
|
/** Erases the word before the cursor (Ctrl+W in a shell); Ctrl+Backspace and Alt+Backspace are mapped to it. */
|
|
15
15
|
export const WORD_BACKSPACE = '\x17';
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* What a terminal sends for Ctrl+Enter once the extended keyboard modes below are on. Ctrl+Enter
|
|
18
|
+
* and Alt+Enter break the line (a new paragraph) instead of sending it, like Shift+Enter.
|
|
19
|
+
*/
|
|
17
20
|
export const CTRL_ENTER = '\x1b[13;5u';
|
|
21
|
+
/** Shift+Enter in the two extended encodings (a plain terminal sends the same byte as for Enter). */
|
|
22
|
+
const SHIFT_ENTER = /^\x1b\[(?:13;2u|27;2;13~)$/;
|
|
23
|
+
/** Does this key break the line instead of sending it? Ctrl+Enter, Alt+Enter and Shift+Enter, as far as the terminal tells them from Enter. */
|
|
24
|
+
export function isNewlineKey(key) {
|
|
25
|
+
return normalizeKey(key) === CTRL_ENTER || SHIFT_ENTER.test(key);
|
|
26
|
+
}
|
|
18
27
|
/**
|
|
19
|
-
* Asks the terminal to tell Ctrl+Enter apart from Enter (a plain terminal sends
|
|
20
|
-
* byte for
|
|
28
|
+
* Asks the terminal to tell Ctrl+Enter and Shift+Enter apart from Enter (a plain terminal sends
|
|
29
|
+
* the same byte for all of them). Two protocols are requested, a terminal answers the one it knows:
|
|
21
30
|
* the kitty keyboard protocol (kitty, foot, ghostty, wezterm, alacritty, ...) and xterm's
|
|
22
31
|
* modifyOtherKeys. Terminals that know neither ignore both, and Alt+Enter still works.
|
|
23
32
|
* While on, the keys arrive as `CSI … u`: run them through `normalizeKey`.
|
package/dist/cli/liveblock.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { displayWidth } from './width.js';
|
|
1
2
|
/**
|
|
2
3
|
* A block of terminal rows that is redrawn in place.
|
|
3
4
|
*
|
|
@@ -5,41 +6,69 @@
|
|
|
5
6
|
* rewrites every row and wipes the rows left over from a taller previous version, so the
|
|
6
7
|
* screen never grows however often the content changes. The cursor is hidden while the
|
|
7
8
|
* block is on screen.
|
|
9
|
+
*
|
|
10
|
+
* The way back up is counted in rows of the screen, not in lines of the block: when the window
|
|
11
|
+
* gets narrower the terminal wraps the lines it already drew onto more rows, and going up by the
|
|
12
|
+
* number of lines would leave the top of the old block on screen (the block would show up twice).
|
|
8
13
|
*/
|
|
9
14
|
export class LiveBlock {
|
|
10
15
|
out;
|
|
11
|
-
|
|
16
|
+
/** Visible width of each line of the block as it was drawn. */
|
|
17
|
+
widths = [];
|
|
12
18
|
constructor(out) {
|
|
13
19
|
this.out = out;
|
|
14
20
|
}
|
|
21
|
+
/** Number of lines of the block on screen. */
|
|
15
22
|
get height() {
|
|
16
|
-
return this.
|
|
23
|
+
return this.widths.length;
|
|
24
|
+
}
|
|
25
|
+
columns() {
|
|
26
|
+
return Math.max(1, this.out.columns || process.stdout.columns || 80);
|
|
27
|
+
}
|
|
28
|
+
/** Rows of the screen the given lines take at the current width. */
|
|
29
|
+
rowsOf(widths) {
|
|
30
|
+
const cols = this.columns();
|
|
31
|
+
return widths.reduce((n, w) => n + Math.max(1, Math.ceil(w / cols)), 0);
|
|
17
32
|
}
|
|
18
33
|
draw(lines) {
|
|
34
|
+
const before = this.rowsOf(this.widths);
|
|
19
35
|
let buf = '\x1b[?25l';
|
|
20
|
-
if (
|
|
21
|
-
buf += `\x1b[${
|
|
36
|
+
if (before > 1)
|
|
37
|
+
buf += `\x1b[${before - 1}A`;
|
|
22
38
|
buf += '\r';
|
|
23
39
|
lines.forEach((line, i) => {
|
|
24
40
|
buf += '\x1b[2K' + line + '\x1b[0m' + (i < lines.length - 1 ? '\n' : '');
|
|
25
41
|
});
|
|
42
|
+
const widths = lines.map((line) => displayWidth(line));
|
|
43
|
+
const now = this.rowsOf(widths);
|
|
26
44
|
let extra = 0;
|
|
27
|
-
for (let k =
|
|
45
|
+
for (let k = now; k < before; k++) {
|
|
28
46
|
buf += '\n\x1b[2K';
|
|
29
47
|
extra++;
|
|
30
48
|
}
|
|
31
49
|
if (extra > 0)
|
|
32
50
|
buf += `\x1b[${extra}A`;
|
|
33
51
|
this.out.write(buf);
|
|
34
|
-
this.
|
|
52
|
+
this.widths = widths;
|
|
35
53
|
}
|
|
36
54
|
/** Removes the block from the screen and shows the cursor again. */
|
|
37
55
|
erase() {
|
|
38
|
-
|
|
56
|
+
const rows = this.rowsOf(this.widths);
|
|
57
|
+
if (rows === 0) {
|
|
39
58
|
this.out.write('\x1b[?25h');
|
|
40
59
|
return;
|
|
41
60
|
}
|
|
42
|
-
this.out.write(`\r${
|
|
43
|
-
this.
|
|
61
|
+
this.out.write(`\r${rows > 1 ? `\x1b[${rows - 1}A` : ''}\x1b[J\x1b[?25h`);
|
|
62
|
+
this.widths = [];
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Calls `redraw` when the window is resized, so the block is drawn again at the new width.
|
|
66
|
+
* Returns the function that stops watching (call it when the panel closes).
|
|
67
|
+
*/
|
|
68
|
+
watchResize(redraw) {
|
|
69
|
+
this.out.on?.('resize', redraw);
|
|
70
|
+
return () => {
|
|
71
|
+
this.out.off?.('resize', redraw);
|
|
72
|
+
};
|
|
44
73
|
}
|
|
45
74
|
}
|
package/dist/cli/mcp-panel.js
CHANGED
|
@@ -314,11 +314,13 @@ export function runMcpPanel(controller, options = {}) {
|
|
|
314
314
|
const wasRaw = !!input.isRaw;
|
|
315
315
|
input.setRawMode?.(true);
|
|
316
316
|
input.resume();
|
|
317
|
+
const stopWatching = block.watchResize(draw);
|
|
317
318
|
const close = () => {
|
|
318
319
|
if (closed)
|
|
319
320
|
return;
|
|
320
321
|
closed = true;
|
|
321
322
|
input.off('data', onData);
|
|
323
|
+
stopWatching();
|
|
322
324
|
block.erase();
|
|
323
325
|
if (!wasRaw)
|
|
324
326
|
input.setRawMode?.(false);
|
package/dist/cli/picker.js
CHANGED
|
@@ -99,9 +99,11 @@ export function runPicker(options, io = { input: process.stdin, output: process.
|
|
|
99
99
|
const wasRaw = !!input.isRaw;
|
|
100
100
|
input.setRawMode?.(true);
|
|
101
101
|
input.resume();
|
|
102
|
+
const stopWatching = block.watchResize(draw);
|
|
102
103
|
const finish = (value) => {
|
|
103
104
|
done = true;
|
|
104
105
|
input.off('data', onData);
|
|
106
|
+
stopWatching();
|
|
105
107
|
block.erase();
|
|
106
108
|
if (!wasRaw)
|
|
107
109
|
input.setRawMode?.(false);
|