termux-dev 1.8.1 → 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 +17 -4
- package/assets/banner.svg +1 -1
- package/assets/preview.svg +1 -1
- package/dist/cli/background-ui.js +116 -0
- package/dist/cli/files.js +2 -12
- package/dist/cli/finder.js +4 -0
- package/dist/cli/headless.js +6 -3
- package/dist/cli/index.js +259 -74
- 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 +93 -8
- 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 +189 -32
- package/dist/cli/updater.js +10 -8
- package/dist/cli/usage-panel.js +17 -4
- package/dist/cli/width.js +18 -0
- package/dist/core/background.js +390 -0
- package/dist/core/config-merge.js +67 -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 +50 -2
- 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 +6 -1
- package/dist/prompts/builder.js +2 -1
- 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/background.js +118 -0
- package/dist/tools/bash.js +23 -1
- package/dist/tools/fs.js +32 -2
- package/dist/tools/index.js +2 -1
- 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';
|
|
@@ -42,6 +44,9 @@ import { SessionExporter } from './export.js';
|
|
|
42
44
|
import { MCPManager, discoverProjectMcpServers, hashProjectMcpServers, readClaudeServers, removeUserServer, saveUserServers } from '../mcp/manager.js';
|
|
43
45
|
import { writeSecureFile } from '../core/secure-file.js';
|
|
44
46
|
import { applyLocalConfig, persistedConfig, withoutSessionOnlySettings } from '../core/local-config.js';
|
|
47
|
+
import { cloneConfig, mergeConfigChanges } from '../core/config-merge.js';
|
|
48
|
+
import { getBackgroundManager } from '../core/background.js';
|
|
49
|
+
import { backgroundSummary, noteRows, runTasksCommand } from './background-ui.js';
|
|
45
50
|
import { LiveTelemetryTracker } from './telemetry.js';
|
|
46
51
|
import { renderPowerlineStatus, GitStatusCache } from './statusline.js';
|
|
47
52
|
import { formatDiffBox } from './diff.js';
|
|
@@ -75,7 +80,18 @@ function maskApiKey(key) {
|
|
|
75
80
|
return '****';
|
|
76
81
|
return `${key.slice(0, 4)}...${key.slice(-4)}`;
|
|
77
82
|
}
|
|
78
|
-
|
|
83
|
+
/**
|
|
84
|
+
* What this process last knew of the config file (its own settings after the start-up overrides,
|
|
85
|
+
* or what it wrote last). A save writes only the difference to it, see core/config-merge.ts.
|
|
86
|
+
*/
|
|
87
|
+
let configBaseline = null;
|
|
88
|
+
let saveChain = Promise.resolve();
|
|
89
|
+
/** Call once the start-up overrides (-m, project config) are applied: those are not saved by accident. */
|
|
90
|
+
function markConfigBaseline(config) {
|
|
91
|
+
syncKeyMaps(config);
|
|
92
|
+
configBaseline = cloneConfig(persistedConfig(config));
|
|
93
|
+
}
|
|
94
|
+
function syncKeyMaps(config) {
|
|
79
95
|
config.apiKeys = config.apiKeys || {};
|
|
80
96
|
config.baseUrls = config.baseUrls || {};
|
|
81
97
|
if (config.provider && config.apiKey) {
|
|
@@ -84,10 +100,38 @@ async function saveConfig(config) {
|
|
|
84
100
|
if (config.provider && config.baseUrl) {
|
|
85
101
|
config.baseUrls[config.provider] = config.baseUrl;
|
|
86
102
|
}
|
|
103
|
+
}
|
|
104
|
+
async function writeConfigFile(config) {
|
|
105
|
+
syncKeyMaps(config);
|
|
87
106
|
// Runtime-only flags and YOLO (one run only) must not end up in the user's config file.
|
|
88
|
-
const
|
|
89
|
-
//
|
|
90
|
-
|
|
107
|
+
const mine = cloneConfig(persistedConfig(config));
|
|
108
|
+
// Another devx may have saved since we looked: write only our own changes on top of the file.
|
|
109
|
+
let disk = null;
|
|
110
|
+
try {
|
|
111
|
+
const parsed = JSON.parse(await fs.readFile(CONFIG_PATH, 'utf8'));
|
|
112
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
|
|
113
|
+
disk = withoutSessionOnlySettings(parsed);
|
|
114
|
+
}
|
|
115
|
+
catch { }
|
|
116
|
+
const merged = disk ? mergeConfigChanges(configBaseline ?? {}, mine, disk) : mine;
|
|
117
|
+
// The file holds API keys: owner-only permissions (0600). Written aside and renamed, so a second
|
|
118
|
+
// process never reads half a file.
|
|
119
|
+
const tmp = `${CONFIG_PATH}.${process.pid}.tmp`;
|
|
120
|
+
try {
|
|
121
|
+
await writeSecureFile(tmp, JSON.stringify(merged, null, 2));
|
|
122
|
+
await fs.rename(tmp, CONFIG_PATH);
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
await fs.rm(tmp, { force: true }).catch(() => null);
|
|
126
|
+
throw err;
|
|
127
|
+
}
|
|
128
|
+
configBaseline = mine;
|
|
129
|
+
}
|
|
130
|
+
async function saveConfig(config) {
|
|
131
|
+
// One save at a time inside this process; the read-merge-write of the next one sees the previous.
|
|
132
|
+
const run = saveChain.then(() => writeConfigFile(config));
|
|
133
|
+
saveChain = run.catch(() => undefined);
|
|
134
|
+
return run;
|
|
91
135
|
}
|
|
92
136
|
async function fetchModels(baseUrl, apiKey) {
|
|
93
137
|
try {
|
|
@@ -99,7 +143,7 @@ async function fetchModels(baseUrl, apiKey) {
|
|
|
99
143
|
if (apiKey)
|
|
100
144
|
headers['x-api-key'] = apiKey;
|
|
101
145
|
}
|
|
102
|
-
else if (apiKey && apiKey !== 'ollama') {
|
|
146
|
+
else if (apiKey && apiKey !== 'ollama' && apiKey !== NO_KEY) {
|
|
103
147
|
headers['Authorization'] = `Bearer ${apiKey}`;
|
|
104
148
|
}
|
|
105
149
|
const res = await fetch(url, { headers });
|
|
@@ -206,12 +250,21 @@ async function selectModel(baseUrl, apiKey, currentModel, providerId = '', _allo
|
|
|
206
250
|
/** The host of an address, for the detail column: `api.openai.com`. */
|
|
207
251
|
const hostOf = (url) => url.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
|
|
208
252
|
async function selectProvider(currentConfig, allowCancel = true) {
|
|
209
|
-
|
|
210
|
-
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 || {});
|
|
211
256
|
if (currentConfig.provider && currentConfig.apiKey) {
|
|
212
257
|
apiKeys[currentConfig.provider] = currentConfig.apiKey;
|
|
213
258
|
}
|
|
259
|
+
// The single "Custom" of older versions becomes a saved provider with a name of its own.
|
|
260
|
+
migrateLegacyCustom(currentConfig);
|
|
214
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__';
|
|
215
268
|
const stepOf = (n) => (firstRun ? `${n}/5` : undefined);
|
|
216
269
|
let providerId = '';
|
|
217
270
|
let baseUrl = '';
|
|
@@ -226,20 +279,44 @@ async function selectProvider(currentConfig, allowCancel = true) {
|
|
|
226
279
|
placeholder: t('onboard.provider.placeholder'),
|
|
227
280
|
current: currentConfig.provider,
|
|
228
281
|
notes: firstRun ? [t('onboard.provider.note')] : undefined,
|
|
229
|
-
items:
|
|
230
|
-
value:
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
+
],
|
|
236
298
|
});
|
|
237
299
|
if (choice === undefined)
|
|
238
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
|
+
}
|
|
239
316
|
providerId = choice;
|
|
240
317
|
const known = ALL_PROVIDERS.find(pr => pr.value === providerId);
|
|
241
318
|
baseUrl = baseUrls[providerId] || known?.baseUrl || '';
|
|
242
|
-
stage =
|
|
319
|
+
stage = !baseUrl ? 'url' : 'key';
|
|
243
320
|
}
|
|
244
321
|
else if (stage === 'url') {
|
|
245
322
|
const url = await runTextInput({
|
|
@@ -258,8 +335,8 @@ async function selectProvider(currentConfig, allowCancel = true) {
|
|
|
258
335
|
stage = 'key';
|
|
259
336
|
}
|
|
260
337
|
else if (stage === 'key') {
|
|
261
|
-
const label =
|
|
262
|
-
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';
|
|
263
340
|
if (providerId === 'ollama') {
|
|
264
341
|
apiKey = 'ollama';
|
|
265
342
|
stage = 'model';
|
|
@@ -306,7 +383,7 @@ async function selectProvider(currentConfig, allowCancel = true) {
|
|
|
306
383
|
else {
|
|
307
384
|
modelRes = await selectModel(baseUrl, apiKey, currentConfig.model || '', providerId, allowCancel, stepOf(5));
|
|
308
385
|
if (!modelRes)
|
|
309
|
-
stage = apiKey === 'ollama' ? 'provider' : 'key';
|
|
386
|
+
stage = apiKey === 'ollama' || isCustomProvider(currentConfig, providerId) ? 'provider' : 'key';
|
|
310
387
|
}
|
|
311
388
|
}
|
|
312
389
|
const updatedConfig = {
|
|
@@ -673,6 +750,7 @@ async function handleSettings(config) {
|
|
|
673
750
|
layout: config.ui?.layout === 'classic' ? 'classic' : 'clean',
|
|
674
751
|
pureBlack: config.pureBlackTheme !== false,
|
|
675
752
|
autoApprove: !!config.autoApprove,
|
|
753
|
+
extra: !!config.autoApprove && !!config.autoApproveAll,
|
|
676
754
|
maxIterations: config.maxIterations || 100,
|
|
677
755
|
memory: config.enableMemory !== false,
|
|
678
756
|
updates: config.checkUpdates !== false,
|
|
@@ -703,12 +781,20 @@ async function handleSettings(config) {
|
|
|
703
781
|
config.banner = final.layout === 'clean' ? (config.banner === 'off' ? 'off' : 'clean') : 'full';
|
|
704
782
|
config.pureBlackTheme = final.pureBlack;
|
|
705
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;
|
|
706
787
|
config.maxIterations = final.maxIterations;
|
|
707
788
|
config.enableMemory = final.memory;
|
|
708
789
|
config.checkUpdates = final.updates;
|
|
709
790
|
config.language = final.language;
|
|
710
791
|
config.maxCostPerSession = final.costLimit;
|
|
711
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
|
+
}
|
|
712
798
|
process.stdin.resume();
|
|
713
799
|
return config;
|
|
714
800
|
}
|
|
@@ -747,6 +833,7 @@ export async function main() {
|
|
|
747
833
|
.version(DEVX_VERSION)
|
|
748
834
|
.option('-p, --prompt <task>', 'Run one-shot task non-interactively (headless mode)')
|
|
749
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)')
|
|
750
837
|
.option('-m, --model <model>', 'Specify AI model to use for this execution')
|
|
751
838
|
.option('-q, --quiet', 'Quiet output in headless mode (suppress banners and tool logs)')
|
|
752
839
|
.option('--json', 'Output structured JSON in headless mode')
|
|
@@ -768,9 +855,14 @@ export async function main() {
|
|
|
768
855
|
config.model = options.model;
|
|
769
856
|
config.maxContextTokens = resolveContextLimit({ ...config, maxContextTokens: 0 }).limit;
|
|
770
857
|
}
|
|
771
|
-
|
|
858
|
+
// -m and the project config are for this run: only what changes from here on is saved.
|
|
859
|
+
markConfigBaseline(config);
|
|
860
|
+
if (options.yolo || options.extra) {
|
|
772
861
|
config.autoApprove = true;
|
|
773
862
|
}
|
|
863
|
+
if (options.extra) {
|
|
864
|
+
config.autoApproveAll = true;
|
|
865
|
+
}
|
|
774
866
|
if (options.effort !== undefined) {
|
|
775
867
|
// One run only (never saved). Anything the model does not offer is ignored with a warning.
|
|
776
868
|
// A custom endpoint is asked about the model first (cached, a few seconds at most).
|
|
@@ -800,6 +892,7 @@ export async function main() {
|
|
|
800
892
|
const exitCode = await runHeadlessMode(options.prompt, config, {
|
|
801
893
|
planMode: planModeInitial,
|
|
802
894
|
yolo: !!options.yolo,
|
|
895
|
+
extra: !!options.extra,
|
|
803
896
|
quiet: !!options.quiet,
|
|
804
897
|
json: !!options.json,
|
|
805
898
|
signal: abortController.signal
|
|
@@ -813,11 +906,12 @@ export async function main() {
|
|
|
813
906
|
return {
|
|
814
907
|
version: DEVX_VERSION,
|
|
815
908
|
model: modelLabel(config),
|
|
816
|
-
provider: config.provider,
|
|
909
|
+
provider: providerDisplayName(config, config.provider, ALL_PROVIDERS),
|
|
817
910
|
cwd: process.cwd(),
|
|
818
911
|
branch: git?.branch,
|
|
819
912
|
dirty: git?.isDirty,
|
|
820
913
|
yolo: !!config.autoApprove,
|
|
914
|
+
extra: !!config.autoApprove && !!config.autoApproveAll,
|
|
821
915
|
tip: pickTip(),
|
|
822
916
|
};
|
|
823
917
|
};
|
|
@@ -845,6 +939,31 @@ export async function main() {
|
|
|
845
939
|
// Ask a custom endpoint what the current model can do (cached for a day; never blocks the prompt).
|
|
846
940
|
void refreshEndpoint(config).catch(() => null);
|
|
847
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
|
+
};
|
|
848
967
|
const currentContext = () => contextBreakdown(history.getMessages(), getTools(planMode).map(t => t.definition), config.maxContextTokens || getModelContextLimit(config.model));
|
|
849
968
|
let currentDraft = '';
|
|
850
969
|
let autoTriggerPrompt = '';
|
|
@@ -857,6 +976,9 @@ export async function main() {
|
|
|
857
976
|
const checkpoints = [];
|
|
858
977
|
// What the user allowed on the approval card for this session ("Session"); "Always" goes to the config.
|
|
859
978
|
const permissionMemory = newPermissionMemory();
|
|
979
|
+
// Commands the agent keeps running in the background (servers, watchers): shown in the prompt frame, listed by /tasks.
|
|
980
|
+
const bg = getBackgroundManager();
|
|
981
|
+
const bgSummary = () => backgroundSummary(bg);
|
|
860
982
|
// The most a session may cost (USD, 0 = no limit); "continue" at the limit raises it for this session only.
|
|
861
983
|
let costLimitRaised = null;
|
|
862
984
|
let costWarned = false;
|
|
@@ -885,6 +1007,10 @@ export async function main() {
|
|
|
885
1007
|
// and start from a clean slate: history, cost, usage counters, undo stack, screen.
|
|
886
1008
|
const startFreshSession = async () => {
|
|
887
1009
|
sessionManager.startNewSession(config.model, planMode);
|
|
1010
|
+
// The tasks belonged to the conversation that is being left.
|
|
1011
|
+
await bg.stopAll().catch(() => 0);
|
|
1012
|
+
bg.clearFinished();
|
|
1013
|
+
bg.takeNotes();
|
|
888
1014
|
transcript.length = 0;
|
|
889
1015
|
checkpoints.length = 0;
|
|
890
1016
|
windowTitle = '';
|
|
@@ -911,10 +1037,10 @@ export async function main() {
|
|
|
911
1037
|
while (true) {
|
|
912
1038
|
const maxTokens = config.maxContextTokens || getModelContextLimit(config.model);
|
|
913
1039
|
config.maxContextTokens = maxTokens;
|
|
914
|
-
//
|
|
915
|
-
//
|
|
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.
|
|
916
1042
|
const ctxNow = currentContext();
|
|
917
|
-
const currentTokens = ctxNow.
|
|
1043
|
+
const currentTokens = ctxNow.inUse;
|
|
918
1044
|
const usagePercent = Math.min(100, Math.round((currentTokens / maxTokens) * 100));
|
|
919
1045
|
const formatTokens = (n) => {
|
|
920
1046
|
if (n >= 1_000_000)
|
|
@@ -965,6 +1091,8 @@ export async function main() {
|
|
|
965
1091
|
}
|
|
966
1092
|
}
|
|
967
1093
|
let answer = '';
|
|
1094
|
+
// Set when this turn starts by itself, from the news of a background task (not from the user).
|
|
1095
|
+
let wakeNotes = '';
|
|
968
1096
|
if (autoTriggerPrompt) {
|
|
969
1097
|
answer = autoTriggerPrompt;
|
|
970
1098
|
autoTriggerPrompt = '';
|
|
@@ -989,6 +1117,7 @@ export async function main() {
|
|
|
989
1117
|
},
|
|
990
1118
|
planMode,
|
|
991
1119
|
isYolo: !!config.autoApprove,
|
|
1120
|
+
isExtra: !!config.autoApprove && !!config.autoApproveAll,
|
|
992
1121
|
ui: config.ui,
|
|
993
1122
|
model: modelLabel(config),
|
|
994
1123
|
cwd: process.cwd(),
|
|
@@ -996,7 +1125,19 @@ export async function main() {
|
|
|
996
1125
|
dirty: GitStatusCache.getStatus()?.isDirty,
|
|
997
1126
|
contextPercent: usagePercent,
|
|
998
1127
|
cost: sessionCost.total,
|
|
999
|
-
costState: sessionCost.state()
|
|
1128
|
+
costState: sessionCost.state(),
|
|
1129
|
+
background: bgSummary,
|
|
1130
|
+
subscribe: (signal) => {
|
|
1131
|
+
const onChange = () => signal('redraw');
|
|
1132
|
+
// A task ended or printed what the agent asked to hear about: the agent takes it up by itself.
|
|
1133
|
+
const onNote = () => signal(config.backgroundAutoWake === false ? 'redraw' : 'wake');
|
|
1134
|
+
bg.on('change', onChange);
|
|
1135
|
+
bg.on('note', onNote);
|
|
1136
|
+
return () => {
|
|
1137
|
+
bg.off('change', onChange);
|
|
1138
|
+
bg.off('note', onNote);
|
|
1139
|
+
};
|
|
1140
|
+
},
|
|
1000
1141
|
});
|
|
1001
1142
|
if (inputStr.startsWith('__TOGGLE_MODE__:')) {
|
|
1002
1143
|
currentDraft = inputStr.slice('__TOGGLE_MODE__:'.length);
|
|
@@ -1011,19 +1152,31 @@ export async function main() {
|
|
|
1011
1152
|
}
|
|
1012
1153
|
continue;
|
|
1013
1154
|
}
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
printFarewell(t('confirm.goodbye'));
|
|
1020
|
-
break;
|
|
1021
|
-
}
|
|
1022
|
-
else {
|
|
1155
|
+
if (inputStr.startsWith('__WAKE__:')) {
|
|
1156
|
+
// The prompt was closed by news from a background task: the agent answers it. What was typed stays.
|
|
1157
|
+
currentDraft = inputStr.slice('__WAKE__:'.length);
|
|
1158
|
+
wakeNotes = bg.takeNotes();
|
|
1159
|
+
if (!wakeNotes)
|
|
1023
1160
|
continue;
|
|
1161
|
+
console.log(noteRows(wakeNotes).map((r) => renderNotice('info', r)).join('\n'));
|
|
1162
|
+
console.log();
|
|
1163
|
+
answer = wakeNotes;
|
|
1164
|
+
}
|
|
1165
|
+
else {
|
|
1166
|
+
currentDraft = '';
|
|
1167
|
+
if (inputStr === '__CANCEL__') {
|
|
1168
|
+
const confirmExit = await askConfirm({ title: t('confirm.exitTitle'), question: t('confirm.exit'), record: false });
|
|
1169
|
+
if (confirmExit) {
|
|
1170
|
+
resetTerminalTheme();
|
|
1171
|
+
printFarewell(t('confirm.goodbye'));
|
|
1172
|
+
break;
|
|
1173
|
+
}
|
|
1174
|
+
else {
|
|
1175
|
+
continue;
|
|
1176
|
+
}
|
|
1024
1177
|
}
|
|
1178
|
+
answer = inputStr.trim();
|
|
1025
1179
|
}
|
|
1026
|
-
answer = inputStr.trim();
|
|
1027
1180
|
}
|
|
1028
1181
|
if (!answer)
|
|
1029
1182
|
continue;
|
|
@@ -1045,7 +1198,7 @@ export async function main() {
|
|
|
1045
1198
|
'/settings', '/update', '/model', '/provider', '/providers',
|
|
1046
1199
|
'/plan', '/agent', '/image', '/serve', '/memory', '/permissions', '/copy', '/rename', '/title', '/rewind', '/undo',
|
|
1047
1200
|
'/diff', '/commit', '/status', '/compact', '/init', '/doctor',
|
|
1048
|
-
'/config', '/clear', '/exit', '/quit', '/help', '/effort'
|
|
1201
|
+
'/config', '/clear', '/exit', '/quit', '/help', '/effort', '/tasks', '/bg', '/jobs'
|
|
1049
1202
|
];
|
|
1050
1203
|
if (!VALID_COMMANDS.includes(cmd)) {
|
|
1051
1204
|
const customList = await CustomCommandManager.listCommands();
|
|
@@ -1070,6 +1223,7 @@ export async function main() {
|
|
|
1070
1223
|
{ name: '/plan - Switch to PLAN mode (architect)', value: '/plan' },
|
|
1071
1224
|
{ name: '/agent - Switch to AGENT mode (coder)', value: '/agent' },
|
|
1072
1225
|
{ name: '/serve - Start local web server for web preview', value: '/serve' },
|
|
1226
|
+
{ name: '/tasks - Background tasks (list, output, stop)', value: '/tasks' },
|
|
1073
1227
|
{ name: '/memory - View or edit project memory bank', value: '/memory' },
|
|
1074
1228
|
{ name: '/permissions - What is allowed without asking', value: '/permissions' },
|
|
1075
1229
|
{ name: '/copy - Copy the last answer to the clipboard', value: '/copy' },
|
|
@@ -1203,6 +1357,8 @@ export async function main() {
|
|
|
1203
1357
|
planMode = loaded.planMode;
|
|
1204
1358
|
}
|
|
1205
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);
|
|
1206
1362
|
drawLogo(config.banner || 'full');
|
|
1207
1363
|
const nonSysMessages = loaded.messages.filter(m => m.role !== 'system');
|
|
1208
1364
|
const totalMsgs = nonSysMessages.length;
|
|
@@ -1360,7 +1516,7 @@ export async function main() {
|
|
|
1360
1516
|
const s = new UiSpinner();
|
|
1361
1517
|
s.start('Generating conventional commit message with AI...');
|
|
1362
1518
|
await refreshFormats(config).catch(() => null);
|
|
1363
|
-
const provider =
|
|
1519
|
+
const provider = sideProvider();
|
|
1364
1520
|
const promptReq = {
|
|
1365
1521
|
messages: [
|
|
1366
1522
|
{
|
|
@@ -1402,7 +1558,7 @@ export async function main() {
|
|
|
1402
1558
|
s.start('Compacting conversation context with AI...');
|
|
1403
1559
|
try {
|
|
1404
1560
|
await refreshFormats(config).catch(() => null);
|
|
1405
|
-
const provider =
|
|
1561
|
+
const provider = sideProvider();
|
|
1406
1562
|
// The request itself must fit the window: shorten a copy of the history first.
|
|
1407
1563
|
const forSummary = new History();
|
|
1408
1564
|
for (const m of msgs)
|
|
@@ -1471,6 +1627,12 @@ export async function main() {
|
|
|
1471
1627
|
await runDoctor(config);
|
|
1472
1628
|
continue;
|
|
1473
1629
|
}
|
|
1630
|
+
if (cmd === '/tasks' || cmd === '/bg' || cmd === '/jobs') {
|
|
1631
|
+
const rows = await runTasksCommand(parts.slice(1).filter(Boolean), bg, process.stdout.columns || 80);
|
|
1632
|
+
console.log(rows.join('\n'));
|
|
1633
|
+
console.log();
|
|
1634
|
+
continue;
|
|
1635
|
+
}
|
|
1474
1636
|
if (cmd === '/serve') {
|
|
1475
1637
|
const sub = parts[1]?.toLowerCase();
|
|
1476
1638
|
if (sub === 'stop') {
|
|
@@ -1591,7 +1753,7 @@ export async function main() {
|
|
|
1591
1753
|
log.warn(t('rename.usage'));
|
|
1592
1754
|
}
|
|
1593
1755
|
else {
|
|
1594
|
-
const generated = await generateChatTitle(
|
|
1756
|
+
const generated = await generateChatTitle(sideProvider(), firstUser.split('\n\n--- Attached Files ---')[0], reply);
|
|
1595
1757
|
if (!generated)
|
|
1596
1758
|
log.error(t('rename.failed'));
|
|
1597
1759
|
else {
|
|
@@ -1852,7 +2014,7 @@ export async function main() {
|
|
|
1852
2014
|
if (newConfig) {
|
|
1853
2015
|
config = newConfig;
|
|
1854
2016
|
await refreshEndpoint(config).catch(() => null);
|
|
1855
|
-
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)' : ''}`);
|
|
1856
2018
|
}
|
|
1857
2019
|
else {
|
|
1858
2020
|
log.info('Provider selection cancelled.');
|
|
@@ -1897,7 +2059,10 @@ export async function main() {
|
|
|
1897
2059
|
}
|
|
1898
2060
|
}
|
|
1899
2061
|
// Resolve @file and @image mentions
|
|
1900
|
-
|
|
2062
|
+
// News from a background task is output of a program, not a message of the user: no @file in it is read.
|
|
2063
|
+
const { text: cleanAnswer, attachments, images, warnings: attachWarnings } = wakeNotes
|
|
2064
|
+
? { text: answer, attachments: [], images: [], warnings: [] }
|
|
2065
|
+
: await resolveAtMentions(answer);
|
|
1901
2066
|
for (const warning of attachWarnings)
|
|
1902
2067
|
log.warn(warning);
|
|
1903
2068
|
let finalContent = cleanAnswer;
|
|
@@ -1938,9 +2103,12 @@ export async function main() {
|
|
|
1938
2103
|
if (turnCheckpoint)
|
|
1939
2104
|
checkpoints.push(turnCheckpoint);
|
|
1940
2105
|
}
|
|
1941
|
-
|
|
2106
|
+
if (wakeNotes)
|
|
2107
|
+
transcript.push({ kind: 'raw', rows: noteRows(wakeNotes).map((r) => renderNotice('info', r)) });
|
|
2108
|
+
else
|
|
2109
|
+
transcript.push({ kind: 'user', text: answer });
|
|
1942
2110
|
// Until the model has named the chat, the start of the first message stands in for the name.
|
|
1943
|
-
if (!windowTitle && !sessionManager.hasGeneratedTitle)
|
|
2111
|
+
if (!wakeNotes && !windowTitle && !sessionManager.hasGeneratedTitle)
|
|
1944
2112
|
setWindowTitle(provisionalTitle(answer));
|
|
1945
2113
|
history.addMessage({
|
|
1946
2114
|
role: 'user',
|
|
@@ -1954,6 +2122,7 @@ export async function main() {
|
|
|
1954
2122
|
const provider = createProvider(config);
|
|
1955
2123
|
const tools = getTools(planMode);
|
|
1956
2124
|
const guard = new CLIConsoleGuard(config.autoApprove, [...(config.bashAllowlist || [])], {
|
|
2125
|
+
extra: !!config.autoApproveAll,
|
|
1957
2126
|
memory: permissionMemory,
|
|
1958
2127
|
// "Always": kept in the config for the next sessions (`/permissions` lists and clears them).
|
|
1959
2128
|
onAlways: (r) => {
|
|
@@ -2187,9 +2356,7 @@ export async function main() {
|
|
|
2187
2356
|
pendingRedraw = true;
|
|
2188
2357
|
}
|
|
2189
2358
|
};
|
|
2190
|
-
|
|
2191
|
-
let interruptPrompt = '';
|
|
2192
|
-
const stopGeneration = (quiet = false, reason) => {
|
|
2359
|
+
const stopGeneration = (reason) => {
|
|
2193
2360
|
if (aborted)
|
|
2194
2361
|
return;
|
|
2195
2362
|
aborted = true;
|
|
@@ -2207,14 +2374,6 @@ export async function main() {
|
|
|
2207
2374
|
console.log(renderNotice('warn', reason));
|
|
2208
2375
|
console.log();
|
|
2209
2376
|
}
|
|
2210
|
-
else if (quiet) {
|
|
2211
|
-
// The message that caused the stop is echoed by the next turn: only a quiet separator here.
|
|
2212
|
-
if (cleanUi)
|
|
2213
|
-
console.log(renderNotice('info', t('stop.interrupted')));
|
|
2214
|
-
else
|
|
2215
|
-
console.log(pc.yellow('\n' + t('stop.classicInterrupted')));
|
|
2216
|
-
console.log();
|
|
2217
|
-
}
|
|
2218
2377
|
else if (cleanUi) {
|
|
2219
2378
|
console.log(renderNotice('warn', isConnecting ? t('stop.connection') : t('stop.stopped')));
|
|
2220
2379
|
console.log();
|
|
@@ -2229,13 +2388,6 @@ export async function main() {
|
|
|
2229
2388
|
const onSigInt = () => {
|
|
2230
2389
|
stopGeneration();
|
|
2231
2390
|
};
|
|
2232
|
-
const sendQueuedNow = () => {
|
|
2233
|
-
if (aborted)
|
|
2234
|
-
return;
|
|
2235
|
-
interruptPrompt = composer.takeQueued().join('\n');
|
|
2236
|
-
if (interruptPrompt)
|
|
2237
|
-
stopGeneration(true);
|
|
2238
|
-
};
|
|
2239
2391
|
// The input line at the bottom while the agent works: what is typed there is queued and
|
|
2240
2392
|
// reaches the model at its next step (see TurnComposer).
|
|
2241
2393
|
// The working line (time and tokens) stays above the frame for the whole turn.
|
|
@@ -2251,10 +2403,10 @@ export async function main() {
|
|
|
2251
2403
|
});
|
|
2252
2404
|
const composer = new TurnComposer(process.stdout, () => {
|
|
2253
2405
|
const ctx = currentContext();
|
|
2254
|
-
const used = ctx.
|
|
2406
|
+
const used = ctx.inUse;
|
|
2255
2407
|
const git = GitStatusCache.getStatus();
|
|
2256
2408
|
return {
|
|
2257
|
-
mode: resolveMode(planMode, !!config.autoApprove),
|
|
2409
|
+
mode: resolveMode(planMode, !!config.autoApprove, !!config.autoApproveAll),
|
|
2258
2410
|
model: modelLabel(config),
|
|
2259
2411
|
cwd: process.cwd(),
|
|
2260
2412
|
branch: git?.branch,
|
|
@@ -2262,8 +2414,12 @@ export async function main() {
|
|
|
2262
2414
|
contextPercent: Math.min(100, Math.round((used / (config.maxContextTokens || getModelContextLimit(config.model))) * 100)),
|
|
2263
2415
|
cost: sessionCost.total,
|
|
2264
2416
|
costState: sessionCost.state(),
|
|
2417
|
+
background: bgSummary(),
|
|
2265
2418
|
};
|
|
2266
|
-
}, undefined, statusLine
|
|
2419
|
+
}, undefined, statusLine);
|
|
2420
|
+
// A task started or ended during the turn: the frame shows the new count.
|
|
2421
|
+
const onBgChange = () => composer.refresh();
|
|
2422
|
+
bg.on('change', onBgChange);
|
|
2267
2423
|
composer.live = liveThought;
|
|
2268
2424
|
composer.onToggle = toggleView;
|
|
2269
2425
|
const releasePauseHook = registerPauseHook(() => {
|
|
@@ -2284,6 +2440,16 @@ export async function main() {
|
|
|
2284
2440
|
stopGeneration();
|
|
2285
2441
|
return;
|
|
2286
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
|
+
}
|
|
2287
2453
|
composer.handleData(text);
|
|
2288
2454
|
};
|
|
2289
2455
|
const onResize = () => composer.onResize();
|
|
@@ -2320,9 +2486,17 @@ export async function main() {
|
|
|
2320
2486
|
for await (const event of agent.run(abortController.signal, {
|
|
2321
2487
|
takePending: async () => Promise.all(composer.takeQueued().map(queuedToMessage)),
|
|
2322
2488
|
hasPending: () => composer.hasQueued(),
|
|
2489
|
+
takeNotes: () => bg.takeNotes(),
|
|
2490
|
+
hasNotes: () => bg.hasNotes(),
|
|
2323
2491
|
})) {
|
|
2324
|
-
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
|
+
}
|
|
2325
2498
|
break;
|
|
2499
|
+
}
|
|
2326
2500
|
if (event.type === 'reasoning_delta') {
|
|
2327
2501
|
isConnecting = false;
|
|
2328
2502
|
telemetry.setPhase('thinking');
|
|
@@ -2526,7 +2700,7 @@ export async function main() {
|
|
|
2526
2700
|
const limit = costLimit();
|
|
2527
2701
|
if (limit > 0 && !aborted) {
|
|
2528
2702
|
if (sessionCost.total >= limit) {
|
|
2529
|
-
stopGeneration(
|
|
2703
|
+
stopGeneration(t('cost.stopped', { limit: formatCost(limit) }));
|
|
2530
2704
|
}
|
|
2531
2705
|
else if (!costWarned && sessionCost.total >= limit * 0.8) {
|
|
2532
2706
|
costWarned = true;
|
|
@@ -2609,6 +2783,22 @@ export async function main() {
|
|
|
2609
2783
|
else
|
|
2610
2784
|
log.info(event.message);
|
|
2611
2785
|
}
|
|
2786
|
+
else if (event.type === 'background_note') {
|
|
2787
|
+
// A background task ended or printed what the agent asked to hear about.
|
|
2788
|
+
if (spinnerActive) {
|
|
2789
|
+
s.stop();
|
|
2790
|
+
spinnerActive = false;
|
|
2791
|
+
}
|
|
2792
|
+
finishThinking(true);
|
|
2793
|
+
streamer.finish();
|
|
2794
|
+
endText();
|
|
2795
|
+
const rows = noteRows(event.content);
|
|
2796
|
+
transcript.push({ kind: 'raw', rows: rows.map((r) => renderNotice('info', r)) });
|
|
2797
|
+
for (const row of rows)
|
|
2798
|
+
console.log(cleanUi ? renderNotice('info', row) : pc.dim(row));
|
|
2799
|
+
s.start(cleanUi ? t('status.waiting') : 'Connecting...');
|
|
2800
|
+
spinnerActive = true;
|
|
2801
|
+
}
|
|
2612
2802
|
}
|
|
2613
2803
|
finishThinking(true);
|
|
2614
2804
|
streamer.finish();
|
|
@@ -2619,13 +2809,9 @@ export async function main() {
|
|
|
2619
2809
|
endText();
|
|
2620
2810
|
// Hand the bottom of the screen back. Whatever was typed but not sent waits in the next prompt;
|
|
2621
2811
|
// a message that arrived too late for the agent starts the next turn by itself.
|
|
2812
|
+
bg.off('change', onBgChange);
|
|
2622
2813
|
const leftover = composer.stop();
|
|
2623
|
-
if (
|
|
2624
|
-
autoTriggerPrompt = interruptPrompt;
|
|
2625
|
-
if (leftover.draft)
|
|
2626
|
-
currentDraft = leftover.draft;
|
|
2627
|
-
}
|
|
2628
|
-
else if (leftover.queued.length > 0) {
|
|
2814
|
+
if (leftover.queued.length > 0) {
|
|
2629
2815
|
if (aborted)
|
|
2630
2816
|
currentDraft = [...leftover.queued, leftover.draft].filter(Boolean).join(' ');
|
|
2631
2817
|
else
|
|
@@ -2680,7 +2866,7 @@ export async function main() {
|
|
|
2680
2866
|
const forSession = sessionManager.getSession().id;
|
|
2681
2867
|
void (async () => {
|
|
2682
2868
|
try {
|
|
2683
|
-
const title = await generateChatTitle(
|
|
2869
|
+
const title = await generateChatTitle(sideProvider(), firstUser.split('\n\n--- Attached Files ---')[0], reply);
|
|
2684
2870
|
// The user may have started another chat meanwhile.
|
|
2685
2871
|
if (!title || sessionManager.getSession().id !== forSession)
|
|
2686
2872
|
return;
|
|
@@ -2744,11 +2930,10 @@ export async function main() {
|
|
|
2744
2930
|
}
|
|
2745
2931
|
}
|
|
2746
2932
|
finally {
|
|
2933
|
+
bg.off('change', onBgChange);
|
|
2747
2934
|
const unsent = composer.stop();
|
|
2748
2935
|
if (process.stdout.isTTY)
|
|
2749
2936
|
process.stdout.write('\x1b[?1004l');
|
|
2750
|
-
if (interruptPrompt && !autoTriggerPrompt)
|
|
2751
|
-
autoTriggerPrompt = interruptPrompt;
|
|
2752
2937
|
releasePauseHook();
|
|
2753
2938
|
process.stdout.removeListener('resize', onResize);
|
|
2754
2939
|
if (!currentDraft && (unsent.draft || unsent.queued.length > 0)) {
|