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.
Files changed (55) hide show
  1. package/README.md +17 -4
  2. package/assets/banner.svg +1 -1
  3. package/assets/preview.svg +1 -1
  4. package/dist/cli/background-ui.js +116 -0
  5. package/dist/cli/files.js +2 -12
  6. package/dist/cli/finder.js +4 -0
  7. package/dist/cli/headless.js +6 -3
  8. package/dist/cli/index.js +259 -74
  9. package/dist/cli/keys.js +12 -3
  10. package/dist/cli/liveblock.js +38 -9
  11. package/dist/cli/mcp-panel.js +2 -0
  12. package/dist/cli/picker.js +2 -0
  13. package/dist/cli/prompt.js +93 -8
  14. package/dist/cli/provider-manager.js +197 -0
  15. package/dist/cli/providers.js +0 -5
  16. package/dist/cli/settings.js +38 -17
  17. package/dist/cli/typeahead.js +10 -17
  18. package/dist/cli/ui.js +189 -32
  19. package/dist/cli/updater.js +10 -8
  20. package/dist/cli/usage-panel.js +17 -4
  21. package/dist/cli/width.js +18 -0
  22. package/dist/core/background.js +390 -0
  23. package/dist/core/config-merge.js +67 -0
  24. package/dist/core/context-stats.js +1 -0
  25. package/dist/core/cost.js +25 -11
  26. package/dist/core/custom-providers.js +161 -0
  27. package/dist/core/images.js +46 -0
  28. package/dist/core/local-config.js +3 -1
  29. package/dist/core/locales/de.js +36 -7
  30. package/dist/core/locales/en.js +36 -7
  31. package/dist/core/locales/es.js +36 -7
  32. package/dist/core/locales/fr.js +36 -7
  33. package/dist/core/locales/ja.js +36 -7
  34. package/dist/core/locales/pt.js +36 -7
  35. package/dist/core/locales/ru.js +36 -7
  36. package/dist/core/locales/uk.js +36 -7
  37. package/dist/core/locales/zh.js +36 -7
  38. package/dist/core/loop.js +50 -2
  39. package/dist/core/prices.js +8 -4
  40. package/dist/core/session-media.js +115 -0
  41. package/dist/core/session.js +17 -2
  42. package/dist/core/title.js +19 -2
  43. package/dist/core/types.js +1 -1
  44. package/dist/core/usage.js +41 -4
  45. package/dist/permissions/guard.js +6 -1
  46. package/dist/prompts/builder.js +2 -1
  47. package/dist/providers/anthropic.js +67 -13
  48. package/dist/providers/index.js +16 -3
  49. package/dist/providers/openai.js +99 -41
  50. package/dist/providers/shared.js +83 -0
  51. package/dist/tools/background.js +118 -0
  52. package/dist/tools/bash.js +23 -1
  53. package/dist/tools/fs.js +32 -2
  54. package/dist/tools/index.js +2 -1
  55. 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
- async function saveConfig(config) {
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 persisted = persistedConfig(config);
89
- // The file holds API keys: owner-only permissions (0600).
90
- await writeSecureFile(CONFIG_PATH, JSON.stringify(persisted, null, 2));
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
- const apiKeys = currentConfig.apiKeys || {};
210
- const baseUrls = currentConfig.baseUrls || {};
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: ALL_PROVIDERS.map((pr) => ({
230
- value: pr.value,
231
- label: pr.label,
232
- detail: `${apiKeys[pr.value] ? maskApiKey(apiKeys[pr.value]) + ' ' : ''}${hostOf(pr.baseUrl || '')}`.trim(),
233
- keywords: `${pr.value} ${hostOf(pr.baseUrl || '')}`,
234
- pinned: pr.value === 'custom',
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 = providerId === 'custom' || !baseUrl ? 'url' : 'key';
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 = ALL_PROVIDERS.find(pr => pr.value === providerId)?.label || providerId;
262
- const back = providerId === 'custom' || !(ALL_PROVIDERS.find(pr => pr.value === providerId)?.baseUrl) ? 'url' : 'provider';
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
- if (options.yolo) {
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
- // The conversation only: the system prompt and tool schemas are fixed overhead that every
915
- // chat has, so an empty chat shows 0%. /usage shows the full picture.
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.total - ctxNow.system - ctxNow.tools;
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
- currentDraft = '';
1015
- if (inputStr === '__CANCEL__') {
1016
- const confirmExit = await askConfirm({ title: t('confirm.exitTitle'), question: t('confirm.exit'), record: false });
1017
- if (confirmExit) {
1018
- resetTerminalTheme();
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 = createProvider(config);
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 = createProvider(config);
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(createProvider(config), firstUser.split('\n\n--- Attached Files ---')[0], reply);
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
- const { text: cleanAnswer, attachments, images, warnings: attachWarnings } = await resolveAtMentions(answer);
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
- transcript.push({ kind: 'user', text: answer });
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
- // Ctrl+Enter: what the user typed goes to the agent right now, replacing the task it was on.
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.total - ctx.system - ctx.tools;
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, sendQueuedNow);
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(false, t('cost.stopped', { limit: formatCost(limit) }));
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 (interruptPrompt) {
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(createProvider(config), firstUser.split('\n\n--- Attached Files ---')[0], reply);
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)) {