termux-dev 1.8.2 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +16 -6
  2. package/assets/banner.svg +1 -1
  3. package/assets/preview.svg +1 -1
  4. package/dist/cli/background-ui.js +8 -8
  5. package/dist/cli/files.js +2 -12
  6. package/dist/cli/finder.js +10 -2
  7. package/dist/cli/headless.js +3 -2
  8. package/dist/cli/index.js +131 -75
  9. package/dist/cli/keys.js +12 -3
  10. package/dist/cli/liveblock.js +47 -9
  11. package/dist/cli/mcp-panel.js +2 -0
  12. package/dist/cli/picker.js +2 -0
  13. package/dist/cli/prompt.js +54 -7
  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 +33 -13
  19. package/dist/cli/update-screen.js +383 -0
  20. package/dist/cli/updater.js +119 -145
  21. package/dist/cli/usage-panel.js +17 -4
  22. package/dist/cli/width.js +18 -0
  23. package/dist/core/background.js +14 -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 +69 -7
  30. package/dist/core/locales/en.js +69 -7
  31. package/dist/core/locales/es.js +69 -7
  32. package/dist/core/locales/fr.js +69 -7
  33. package/dist/core/locales/ja.js +69 -7
  34. package/dist/core/locales/pt.js +69 -7
  35. package/dist/core/locales/ru.js +69 -7
  36. package/dist/core/locales/uk.js +69 -7
  37. package/dist/core/locales/zh.js +69 -7
  38. package/dist/core/loop.js +32 -1
  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 +3 -0
  46. package/dist/providers/anthropic.js +67 -13
  47. package/dist/providers/index.js +16 -3
  48. package/dist/providers/openai.js +99 -41
  49. package/dist/providers/shared.js +83 -0
  50. package/dist/tools/fs.js +32 -2
  51. package/package.json +1 -1
package/dist/cli/index.js CHANGED
@@ -26,13 +26,15 @@ 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';
32
34
  import { SmoothStreamer } from './smooth.js';
33
35
  import { askPrompt } from './prompt.js';
34
36
  import { resolveAtMentions } from './files.js';
35
- import { runStartupUpdateCheck, checkForUpdates, performSelfUpdate } from './updater.js';
37
+ import { runManualUpdate, runStartupUpdateCheck } from './update-screen.js';
36
38
  import { setActiveTheme, getCurrentTheme, listThemes, findTheme } from './theme.js';
37
39
  import { runHeadlessMode } from './headless.js';
38
40
  import { CustomCommandManager } from '../core/commands.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
- const apiKeys = currentConfig.apiKeys || {};
252
- 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 || {});
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: ALL_PROVIDERS.map((pr) => ({
272
- value: pr.value,
273
- label: pr.label,
274
- detail: `${apiKeys[pr.value] ? maskApiKey(apiKeys[pr.value]) + ' ' : ''}${hostOf(pr.baseUrl || '')}`.trim(),
275
- keywords: `${pr.value} ${hostOf(pr.baseUrl || '')}`,
276
- pinned: pr.value === 'custom',
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 = providerId === 'custom' || !baseUrl ? 'url' : 'key';
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 = ALL_PROVIDERS.find(pr => pr.value === providerId)?.label || providerId;
304
- 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';
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
  };
@@ -884,11 +934,36 @@ export async function main() {
884
934
  history.addMessage({ role: 'system', content: sysPrompt });
885
935
  const sessionManager = new SessionManager(config.model, planMode);
886
936
  drawLogo(config.banner || 'full');
887
- await runStartupUpdateCheck(config);
937
+ await runStartupUpdateCheck(config, { save: saveConfig });
888
938
  await MCPManager.getInstance().init(undefined, config.projectMcpTrusted === true);
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 seenBgFailures = new Set();
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
- // The conversation only: the system prompt and tool schemas are fixed overhead that every
968
- // 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.
969
1042
  const ctxNow = currentContext();
970
- const currentTokens = ctxNow.total - ctxNow.system - ctxNow.tools;
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(),
@@ -1201,20 +1275,7 @@ export async function main() {
1201
1275
  continue;
1202
1276
  }
1203
1277
  if (cmd === '/update') {
1204
- const s = new UiSpinner();
1205
- s.start('Checking for updates from https://github.com/apvcode/Termux-Dev...');
1206
- const res = await checkForUpdates(10000);
1207
- s.stop();
1208
- if (res.updateAvailable) {
1209
- log.info(pc.bold(pc.yellow(`Update available: v${res.currentVersion} → v${res.latestVersion}`)));
1210
- const doUpdate = await askConfirm({ title: 'update', question: t('confirm.update', { version: res.latestVersion }), initial: true });
1211
- if (doUpdate) {
1212
- await performSelfUpdate(res.latestVersion);
1213
- }
1214
- }
1215
- else {
1216
- log.success(pc.green(`devx is up to date! (v${res.currentVersion})`));
1217
- }
1278
+ await runManualUpdate();
1218
1279
  continue;
1219
1280
  }
1220
1281
  if (cmd === '/help') {
@@ -1283,6 +1344,8 @@ export async function main() {
1283
1344
  planMode = loaded.planMode;
1284
1345
  }
1285
1346
  sessionCost.restore(loaded.totalCost || 0, loaded.costEstimated === true);
1347
+ // /usage starts from the same total as the frame, and not from the chat that was open before.
1348
+ UsageTracker.getInstance().restoreCost(loaded.totalCost || 0, loaded.costEstimated === true);
1286
1349
  drawLogo(config.banner || 'full');
1287
1350
  const nonSysMessages = loaded.messages.filter(m => m.role !== 'system');
1288
1351
  const totalMsgs = nonSysMessages.length;
@@ -1440,7 +1503,7 @@ export async function main() {
1440
1503
  const s = new UiSpinner();
1441
1504
  s.start('Generating conventional commit message with AI...');
1442
1505
  await refreshFormats(config).catch(() => null);
1443
- const provider = createProvider(config);
1506
+ const provider = sideProvider();
1444
1507
  const promptReq = {
1445
1508
  messages: [
1446
1509
  {
@@ -1482,7 +1545,7 @@ export async function main() {
1482
1545
  s.start('Compacting conversation context with AI...');
1483
1546
  try {
1484
1547
  await refreshFormats(config).catch(() => null);
1485
- const provider = createProvider(config);
1548
+ const provider = sideProvider();
1486
1549
  // The request itself must fit the window: shorten a copy of the history first.
1487
1550
  const forSummary = new History();
1488
1551
  for (const m of msgs)
@@ -1552,7 +1615,7 @@ export async function main() {
1552
1615
  continue;
1553
1616
  }
1554
1617
  if (cmd === '/tasks' || cmd === '/bg' || cmd === '/jobs') {
1555
- const rows = await runTasksCommand(parts.slice(1).filter(Boolean), bg, process.stdout.columns || 80, (ids) => ids.forEach((id) => seenBgFailures.add(id)));
1618
+ const rows = await runTasksCommand(parts.slice(1).filter(Boolean), bg, process.stdout.columns || 80);
1556
1619
  console.log(rows.join('\n'));
1557
1620
  console.log();
1558
1621
  continue;
@@ -1677,7 +1740,7 @@ export async function main() {
1677
1740
  log.warn(t('rename.usage'));
1678
1741
  }
1679
1742
  else {
1680
- const generated = await generateChatTitle(createProvider(config), firstUser.split('\n\n--- Attached Files ---')[0], reply);
1743
+ const generated = await generateChatTitle(sideProvider(), firstUser.split('\n\n--- Attached Files ---')[0], reply);
1681
1744
  if (!generated)
1682
1745
  log.error(t('rename.failed'));
1683
1746
  else {
@@ -1938,7 +2001,7 @@ export async function main() {
1938
2001
  if (newConfig) {
1939
2002
  config = newConfig;
1940
2003
  await refreshEndpoint(config).catch(() => null);
1941
- announce(`provider ${config.provider || 'custom'} · ${config.model}${providerFormat(config) === 'anthropic' ? ' (Anthropic format)' : ''}`);
2004
+ announce(`provider ${providerDisplayName(config, config.provider, ALL_PROVIDERS) || config.provider || 'custom'} · ${config.model}${providerFormat(config) === 'anthropic' ? ' (Anthropic format)' : ''}`);
1942
2005
  }
1943
2006
  else {
1944
2007
  log.info('Provider selection cancelled.');
@@ -2046,6 +2109,7 @@ export async function main() {
2046
2109
  const provider = createProvider(config);
2047
2110
  const tools = getTools(planMode);
2048
2111
  const guard = new CLIConsoleGuard(config.autoApprove, [...(config.bashAllowlist || [])], {
2112
+ extra: !!config.autoApproveAll,
2049
2113
  memory: permissionMemory,
2050
2114
  // "Always": kept in the config for the next sessions (`/permissions` lists and clears them).
2051
2115
  onAlways: (r) => {
@@ -2279,9 +2343,7 @@ export async function main() {
2279
2343
  pendingRedraw = true;
2280
2344
  }
2281
2345
  };
2282
- // Ctrl+Enter: what the user typed goes to the agent right now, replacing the task it was on.
2283
- let interruptPrompt = '';
2284
- const stopGeneration = (quiet = false, reason) => {
2346
+ const stopGeneration = (reason) => {
2285
2347
  if (aborted)
2286
2348
  return;
2287
2349
  aborted = true;
@@ -2299,14 +2361,6 @@ export async function main() {
2299
2361
  console.log(renderNotice('warn', reason));
2300
2362
  console.log();
2301
2363
  }
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
2364
  else if (cleanUi) {
2311
2365
  console.log(renderNotice('warn', isConnecting ? t('stop.connection') : t('stop.stopped')));
2312
2366
  console.log();
@@ -2321,13 +2375,6 @@ export async function main() {
2321
2375
  const onSigInt = () => {
2322
2376
  stopGeneration();
2323
2377
  };
2324
- const sendQueuedNow = () => {
2325
- if (aborted)
2326
- return;
2327
- interruptPrompt = composer.takeQueued().join('\n');
2328
- if (interruptPrompt)
2329
- stopGeneration(true);
2330
- };
2331
2378
  // The input line at the bottom while the agent works: what is typed there is queued and
2332
2379
  // reaches the model at its next step (see TurnComposer).
2333
2380
  // The working line (time and tokens) stays above the frame for the whole turn.
@@ -2343,10 +2390,10 @@ export async function main() {
2343
2390
  });
2344
2391
  const composer = new TurnComposer(process.stdout, () => {
2345
2392
  const ctx = currentContext();
2346
- const used = ctx.total - ctx.system - ctx.tools;
2393
+ const used = ctx.inUse;
2347
2394
  const git = GitStatusCache.getStatus();
2348
2395
  return {
2349
- mode: resolveMode(planMode, !!config.autoApprove),
2396
+ mode: resolveMode(planMode, !!config.autoApprove, !!config.autoApproveAll),
2350
2397
  model: modelLabel(config),
2351
2398
  cwd: process.cwd(),
2352
2399
  branch: git?.branch,
@@ -2356,7 +2403,7 @@ export async function main() {
2356
2403
  costState: sessionCost.state(),
2357
2404
  background: bgSummary(),
2358
2405
  };
2359
- }, undefined, statusLine, sendQueuedNow);
2406
+ }, undefined, statusLine);
2360
2407
  // A task started or ended during the turn: the frame shows the new count.
2361
2408
  const onBgChange = () => composer.refresh();
2362
2409
  bg.on('change', onBgChange);
@@ -2380,6 +2427,16 @@ export async function main() {
2380
2427
  stopGeneration();
2381
2428
  return;
2382
2429
  }
2430
+ // Esc stops the answer and whatever it started (a command that runs), like Ctrl+C. Not while a
2431
+ // card has the keyboard: there Esc answers the card. A lone Esc comes in a chunk of its own;
2432
+ // Esc followed by a character in one chunk is Alt+that character.
2433
+ if (!composer.isSuspended) {
2434
+ const keys = splitKeys(text);
2435
+ if (keys.length === 1 && normalizeKey(keys[0]) === '\x1b') {
2436
+ stopGeneration();
2437
+ return;
2438
+ }
2439
+ }
2383
2440
  composer.handleData(text);
2384
2441
  };
2385
2442
  const onResize = () => composer.onResize();
@@ -2419,8 +2476,14 @@ export async function main() {
2419
2476
  takeNotes: () => bg.takeNotes(),
2420
2477
  hasNotes: () => bg.hasNotes(),
2421
2478
  })) {
2422
- if (aborted)
2479
+ if (aborted) {
2480
+ // The request that was cancelled is still billed by the provider: its estimate counts.
2481
+ if (event.type === 'usage') {
2482
+ sessionCost.add(event.usage);
2483
+ turnInputTokens += event.usage.promptTokens || 0;
2484
+ }
2423
2485
  break;
2486
+ }
2424
2487
  if (event.type === 'reasoning_delta') {
2425
2488
  isConnecting = false;
2426
2489
  telemetry.setPhase('thinking');
@@ -2624,7 +2687,7 @@ export async function main() {
2624
2687
  const limit = costLimit();
2625
2688
  if (limit > 0 && !aborted) {
2626
2689
  if (sessionCost.total >= limit) {
2627
- stopGeneration(false, t('cost.stopped', { limit: formatCost(limit) }));
2690
+ stopGeneration(t('cost.stopped', { limit: formatCost(limit) }));
2628
2691
  }
2629
2692
  else if (!costWarned && sessionCost.total >= limit * 0.8) {
2630
2693
  costWarned = true;
@@ -2735,12 +2798,7 @@ export async function main() {
2735
2798
  // a message that arrived too late for the agent starts the next turn by itself.
2736
2799
  bg.off('change', onBgChange);
2737
2800
  const leftover = composer.stop();
2738
- if (interruptPrompt) {
2739
- autoTriggerPrompt = interruptPrompt;
2740
- if (leftover.draft)
2741
- currentDraft = leftover.draft;
2742
- }
2743
- else if (leftover.queued.length > 0) {
2801
+ if (leftover.queued.length > 0) {
2744
2802
  if (aborted)
2745
2803
  currentDraft = [...leftover.queued, leftover.draft].filter(Boolean).join(' ');
2746
2804
  else
@@ -2795,7 +2853,7 @@ export async function main() {
2795
2853
  const forSession = sessionManager.getSession().id;
2796
2854
  void (async () => {
2797
2855
  try {
2798
- const title = await generateChatTitle(createProvider(config), firstUser.split('\n\n--- Attached Files ---')[0], reply);
2856
+ const title = await generateChatTitle(sideProvider(), firstUser.split('\n\n--- Attached Files ---')[0], reply);
2799
2857
  // The user may have started another chat meanwhile.
2800
2858
  if (!title || sessionManager.getSession().id !== forSession)
2801
2859
  return;
@@ -2863,8 +2921,6 @@ export async function main() {
2863
2921
  const unsent = composer.stop();
2864
2922
  if (process.stdout.isTTY)
2865
2923
  process.stdout.write('\x1b[?1004l');
2866
- if (interruptPrompt && !autoTriggerPrompt)
2867
- autoTriggerPrompt = interruptPrompt;
2868
2924
  releasePauseHook();
2869
2925
  process.stdout.removeListener('resize', onResize);
2870
2926
  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
- /** What a terminal sends for Ctrl+Enter once the extended keyboard modes below are on. */
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 the same
20
- * byte for both). Two protocols are requested, a terminal answers the one it knows:
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`.
@@ -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,78 @@
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
- rows = 0;
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.rows;
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 (this.rows > 1)
21
- buf += `\x1b[${this.rows - 1}A`;
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 = lines.length; k < this.rows; 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.rows = lines.length;
52
+ this.widths = widths;
35
53
  }
36
54
  /** Removes the block from the screen and shows the cursor again. */
37
55
  erase() {
38
- if (this.rows === 0) {
56
+ const rows = this.rowsOf(this.widths);
57
+ if (rows === 0) {
58
+ this.out.write('\x1b[?25h');
59
+ return;
60
+ }
61
+ this.out.write(`\r${rows > 1 ? `\x1b[${rows - 1}A` : ''}\x1b[J\x1b[?25h`);
62
+ this.widths = [];
63
+ }
64
+ /** Leaves what is drawn on the screen as it is: the cursor comes back under it and the next draw starts a new block. */
65
+ release() {
66
+ if (this.widths.length === 0) {
39
67
  this.out.write('\x1b[?25h');
40
68
  return;
41
69
  }
42
- this.out.write(`\r${this.rows > 1 ? `\x1b[${this.rows - 1}A` : ''}\x1b[J\x1b[?25h`);
43
- this.rows = 0;
70
+ this.out.write('\n\x1b[?25h');
71
+ this.widths = [];
72
+ }
73
+ /**
74
+ * Calls `redraw` when the window is resized, so the block is drawn again at the new width.
75
+ * Returns the function that stops watching (call it when the panel closes).
76
+ */
77
+ watchResize(redraw) {
78
+ this.out.on?.('resize', redraw);
79
+ return () => {
80
+ this.out.off?.('resize', redraw);
81
+ };
44
82
  }
45
83
  }
@@ -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);
@@ -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);