termux-dev 1.8.0 → 1.8.2

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/dist/cli/index.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import { Command } from 'commander';
2
2
  import * as p from '@clack/prompts';
3
- import { search, password, input } from '@inquirer/prompts';
4
3
  import pc from 'picocolors';
5
4
  import os from 'os';
6
5
  import path from 'path';
@@ -21,6 +20,8 @@ import { notifyDevice, acquireWakeLock, isTermux, notificationBody, sendDesktopN
21
20
  import { copyToClipboard } from './clipboard-write.js';
22
21
  import { REWIND_CURRENT, messageDraft, rewindItems, userEntries } from './rewind.js';
23
22
  import { runPicker } from './picker.js';
23
+ import { runFinder, runTextInput } from './finder.js';
24
+ import { renderWelcome } from './welcome.js';
24
25
  import { savePlanFile } from '../core/planfile.js';
25
26
  import { BASE_TITLE, RESTORE_TERMINAL_TITLE, SAVE_TERMINAL_TITLE, cleanTitle, generateChatTitle, provisionalTitle, terminalTitleSequence } from '../core/title.js';
26
27
  import { runDoctor } from './doctor.js';
@@ -32,7 +33,7 @@ import { SmoothStreamer } from './smooth.js';
32
33
  import { askPrompt } from './prompt.js';
33
34
  import { resolveAtMentions } from './files.js';
34
35
  import { runStartupUpdateCheck, checkForUpdates, performSelfUpdate } from './updater.js';
35
- import { setActiveTheme, getCurrentTheme, listThemes, findTheme, renderSwatch, renderThemePreview } from './theme.js';
36
+ import { setActiveTheme, getCurrentTheme, listThemes, findTheme } from './theme.js';
36
37
  import { runHeadlessMode } from './headless.js';
37
38
  import { CustomCommandManager } from '../core/commands.js';
38
39
  import { UsageTracker } from '../core/usage.js';
@@ -41,6 +42,9 @@ import { SessionExporter } from './export.js';
41
42
  import { MCPManager, discoverProjectMcpServers, hashProjectMcpServers, readClaudeServers, removeUserServer, saveUserServers } from '../mcp/manager.js';
42
43
  import { writeSecureFile } from '../core/secure-file.js';
43
44
  import { applyLocalConfig, persistedConfig, withoutSessionOnlySettings } from '../core/local-config.js';
45
+ import { cloneConfig, mergeConfigChanges } from '../core/config-merge.js';
46
+ import { getBackgroundManager } from '../core/background.js';
47
+ import { backgroundSummary, noteRows, runTasksCommand } from './background-ui.js';
44
48
  import { LiveTelemetryTracker } from './telemetry.js';
45
49
  import { renderPowerlineStatus, GitStatusCache } from './statusline.js';
46
50
  import { formatDiffBox } from './diff.js';
@@ -54,7 +58,7 @@ import { askConfirm, askPlanDecision, printFarewell } from './confirm.js';
54
58
  import { createCheckpoint, diffCheckpoint, restoreCheckpoint } from '../core/checkpoint.js';
55
59
  import { log, note } from './log.js';
56
60
  import { displayWidth, tailWidth } from './width.js';
57
- import { collapsedThoughtLine, messagesToItems, renderTranscript, toolResultRows } from './transcript.js';
61
+ import { COMPACT_MARK, collapsedThoughtLine, renderItem, messagesToItems, renderTranscript, toolResultRows } from './transcript.js';
58
62
  import { chooseEffort, effortCommand } from './effort.js';
59
63
  import { effectiveReasoning, parseEffortArg, detectRoute, thinkLabel } from '../core/reasoning.js';
60
64
  import { catalogInfo, installCapabilities, refreshCapabilities, refreshCatalog } from '../core/capabilities.js';
@@ -63,7 +67,7 @@ import { contextBreakdown } from '../core/context-stats.js';
63
67
  import { runUsagePanel } from './usage-panel.js';
64
68
  import { TurnComposer } from './typeahead.js';
65
69
  import { EXTENDED_KEYS_OFF, normalizeKey, splitKeys } from './keys.js';
66
- import { getLanguage, setLanguage, t } from '../core/i18n.js';
70
+ import { LANGUAGES, getLanguage, setLanguage, t } from '../core/i18n.js';
67
71
  import { anthropicOnlyModels, formatFromBaseUrl, refreshFormats } from '../core/formats.js';
68
72
  import { providerFormat } from '../providers/index.js';
69
73
  const CONFIG_PATH = path.join(os.homedir(), '.devxrc.json');
@@ -74,7 +78,18 @@ function maskApiKey(key) {
74
78
  return '****';
75
79
  return `${key.slice(0, 4)}...${key.slice(-4)}`;
76
80
  }
77
- async function saveConfig(config) {
81
+ /**
82
+ * What this process last knew of the config file (its own settings after the start-up overrides,
83
+ * or what it wrote last). A save writes only the difference to it, see core/config-merge.ts.
84
+ */
85
+ let configBaseline = null;
86
+ let saveChain = Promise.resolve();
87
+ /** Call once the start-up overrides (-m, project config) are applied: those are not saved by accident. */
88
+ function markConfigBaseline(config) {
89
+ syncKeyMaps(config);
90
+ configBaseline = cloneConfig(persistedConfig(config));
91
+ }
92
+ function syncKeyMaps(config) {
78
93
  config.apiKeys = config.apiKeys || {};
79
94
  config.baseUrls = config.baseUrls || {};
80
95
  if (config.provider && config.apiKey) {
@@ -83,10 +98,38 @@ async function saveConfig(config) {
83
98
  if (config.provider && config.baseUrl) {
84
99
  config.baseUrls[config.provider] = config.baseUrl;
85
100
  }
101
+ }
102
+ async function writeConfigFile(config) {
103
+ syncKeyMaps(config);
86
104
  // Runtime-only flags and YOLO (one run only) must not end up in the user's config file.
87
- const persisted = persistedConfig(config);
88
- // The file holds API keys: owner-only permissions (0600).
89
- await writeSecureFile(CONFIG_PATH, JSON.stringify(persisted, null, 2));
105
+ const mine = cloneConfig(persistedConfig(config));
106
+ // Another devx may have saved since we looked: write only our own changes on top of the file.
107
+ let disk = null;
108
+ try {
109
+ const parsed = JSON.parse(await fs.readFile(CONFIG_PATH, 'utf8'));
110
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
111
+ disk = withoutSessionOnlySettings(parsed);
112
+ }
113
+ catch { }
114
+ const merged = disk ? mergeConfigChanges(configBaseline ?? {}, mine, disk) : mine;
115
+ // The file holds API keys: owner-only permissions (0600). Written aside and renamed, so a second
116
+ // process never reads half a file.
117
+ const tmp = `${CONFIG_PATH}.${process.pid}.tmp`;
118
+ try {
119
+ await writeSecureFile(tmp, JSON.stringify(merged, null, 2));
120
+ await fs.rename(tmp, CONFIG_PATH);
121
+ }
122
+ catch (err) {
123
+ await fs.rm(tmp, { force: true }).catch(() => null);
124
+ throw err;
125
+ }
126
+ configBaseline = mine;
127
+ }
128
+ async function saveConfig(config) {
129
+ // One save at a time inside this process; the read-merge-write of the next one sees the previous.
130
+ const run = saveChain.then(() => writeConfigFile(config));
131
+ saveChain = run.catch(() => undefined);
132
+ return run;
90
133
  }
91
134
  async function fetchModels(baseUrl, apiKey) {
92
135
  try {
@@ -138,9 +181,11 @@ const POPULAR_PROVIDER_MODELS = {
138
181
  groq: ['llama-3.3-70b-versatile', 'llama-3.1-8b-instant', 'mixtral-8x7b-32768'],
139
182
  mistral: ['mistral-large-latest', 'mistral-small-latest', 'codestral-latest'],
140
183
  };
141
- async function selectModel(baseUrl, apiKey, currentModel, providerId = '', allowCancel = true) {
184
+ const formatContext = (n) => (n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : `${Math.round(n / 1000)}k`);
185
+ /** `step` is "3/4" during the first run, so the card says where in the setup you are. */
186
+ async function selectModel(baseUrl, apiKey, currentModel, providerId = '', _allowCancel = true, step) {
142
187
  const s = new UiSpinner();
143
- s.start('Fetching available models from provider...');
188
+ s.start(t('onboard.model.fetching'));
144
189
  const result = await fetchModels(baseUrl, apiKey);
145
190
  // The same gateway may list more models (Claude) in its Anthropic format, with the same key.
146
191
  if (result.ok && probeEndpointsAllowed) {
@@ -160,201 +205,152 @@ async function selectModel(baseUrl, apiKey, currentModel, providerId = '', allow
160
205
  contextLength: getModelContextLimit(id)
161
206
  }));
162
207
  }
163
- let modelChoice;
164
- if (models.length > 0) {
165
- try {
166
- modelChoice = await search({
167
- message: `Select a model (${models.length} available, type to filter):`,
168
- source: async (term) => {
169
- const q = (term || '').trim().toLowerCase();
170
- const list = [];
171
- if (allowCancel) {
172
- list.push({ name: pc.yellow('← Back / Cancel'), value: '__cancel__', description: 'Keep current model' });
173
- }
174
- list.push({ name: pc.yellow('+ Type a custom model name...'), value: 'custom_input', description: 'Enter any model ID manually' });
175
- for (const m of models) {
176
- const isCurrent = m.id === currentModel;
177
- const prefix = isCurrent ? pc.cyan('› ') : ' ';
178
- const nameStr = isCurrent ? pc.bold(pc.cyan(m.id + ' (current)')) : m.id;
179
- const ctxStr = (m.viaAnthropic ? 'Anthropic format' + (m.contextLength ? ', ' : '') : '') + (m.contextLength ? `${m.contextLength >= 1_000_000 ? (m.contextLength / 1_000_000).toFixed(1) + 'M' : Math.round(m.contextLength / 1000) + 'k'} context` : '');
180
- list.push({
181
- name: prefix + nameStr,
182
- value: m.id,
183
- description: ctxStr
184
- });
185
- }
186
- if (!q)
187
- return list;
188
- return list.filter(item => item.value.toLowerCase().includes(q) || item.name.toLowerCase().includes(q));
189
- },
190
- pageSize: 10
191
- });
192
- }
193
- catch {
194
- return null;
195
- }
196
- if (modelChoice === '__cancel__') {
197
- return null;
198
- }
199
- if (modelChoice === 'custom_input') {
200
- try {
201
- const customModel = await input({
202
- message: 'Type the model name (e.g. gpt-4o or claude-3-7-sonnet):',
203
- });
204
- if (!customModel || !customModel.trim())
205
- return null;
206
- const name = customModel.trim();
207
- return { model: name, contextLimit: getModelContextLimit(name) };
208
- }
209
- catch {
210
- return null;
211
- }
212
- }
213
- const found = models.find(m => m.id === modelChoice);
214
- const limit = (found && found.contextLength) || getModelContextLimit(modelChoice);
215
- return { model: modelChoice, contextLimit: limit };
216
- }
217
- else {
218
- log.warn('Could not fetch models automatically.');
219
- try {
220
- const typedModel = await input({
221
- message: 'Type the model name you want to use (e.g. gpt-4o):',
222
- default: currentModel || ''
223
- });
224
- if (!typedModel || !typedModel.trim())
225
- return null;
226
- const name = typedModel.trim();
227
- return { model: name, contextLimit: getModelContextLimit(name) };
228
- }
229
- catch {
208
+ const typeName = async (note) => {
209
+ const name = await runTextInput({
210
+ title: t('onboard.model.title'),
211
+ subtitle: step,
212
+ label: t('onboard.model.label'),
213
+ initial: models.length === 0 ? currentModel : '',
214
+ notes: note ? [note] : undefined,
215
+ });
216
+ return name ? { model: name, contextLimit: getModelContextLimit(name) } : null;
217
+ };
218
+ if (models.length === 0)
219
+ return typeName(t('onboard.model.manual'));
220
+ const CUSTOM = '__custom__';
221
+ for (;;) {
222
+ const choice = await runFinder({
223
+ title: t('onboard.model.title'),
224
+ subtitle: step ?? `${models.length}`,
225
+ placeholder: t('onboard.model.placeholder'),
226
+ current: currentModel,
227
+ items: [
228
+ { value: CUSTOM, label: t('onboard.model.custom'), detail: t('onboard.model.customDetail'), pinned: true },
229
+ ...models.map((m) => ({
230
+ value: m.id,
231
+ label: m.id,
232
+ detail: [m.viaAnthropic ? t('onboard.model.anthropic') : '', m.contextLength ? t('onboard.model.context', { n: formatContext(m.contextLength) }) : ''].filter(Boolean).join(', '),
233
+ })),
234
+ ],
235
+ });
236
+ if (choice === undefined)
230
237
  return null;
238
+ if (choice === CUSTOM) {
239
+ const typed = await typeName();
240
+ if (typed)
241
+ return typed;
242
+ continue; // Esc in the field: back to the list
231
243
  }
244
+ const found = models.find(m => m.id === choice);
245
+ return { model: choice, contextLimit: (found && found.contextLength) || getModelContextLimit(choice) };
232
246
  }
233
247
  }
248
+ /** The host of an address, for the detail column: `api.openai.com`. */
249
+ const hostOf = (url) => url.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
234
250
  async function selectProvider(currentConfig, allowCancel = true) {
235
251
  const apiKeys = currentConfig.apiKeys || {};
236
252
  const baseUrls = currentConfig.baseUrls || {};
237
253
  if (currentConfig.provider && currentConfig.apiKey) {
238
254
  apiKeys[currentConfig.provider] = currentConfig.apiKey;
239
255
  }
240
- let providerId;
241
- try {
242
- providerId = await search({
243
- message: 'Select AI Provider (type to filter, Esc to cancel):',
244
- source: async (term) => {
245
- const q = (term || '').trim().toLowerCase();
246
- const filtered = q
247
- ? ALL_PROVIDERS.filter(pr => pr.label.toLowerCase().includes(q) || pr.value.toLowerCase().includes(q))
248
- : ALL_PROVIDERS;
249
- const list = filtered.map(pr => {
250
- const isCurrent = pr.value === currentConfig.provider;
251
- const labelStr = isCurrent ? pc.bold(pc.cyan(pr.label + ' (current)')) : pr.label;
252
- const hasKey = apiKeys[pr.value] ? `[Key: ${maskApiKey(apiKeys[pr.value])}] ` : '';
253
- return {
254
- name: `${isCurrent ? pc.cyan('› ') : ' '}${labelStr}`,
255
- value: pr.value,
256
- description: `${hasKey}${pr.baseUrl || 'Custom endpoint'}`
257
- };
258
- });
259
- if (allowCancel) {
260
- list.unshift({
261
- name: pc.yellow('← Back / Cancel'),
262
- value: '__cancel__',
263
- description: 'Keep current provider'
264
- });
265
- }
266
- return list;
267
- },
268
- pageSize: 10
269
- });
270
- }
271
- catch {
272
- return null;
273
- }
274
- if (providerId === '__cancel__') {
275
- return null;
276
- }
277
- const selectedProvider = ALL_PROVIDERS.find(pr => pr.value === providerId) || { baseUrl: '', label: providerId };
278
- let baseUrl = baseUrls[providerId] || selectedProvider.baseUrl;
279
- if (providerId === 'custom' || !baseUrl) {
280
- try {
281
- const customUrl = await input({
282
- message: 'Enter Base URL (e.g. https://api.openai.com/v1):',
283
- default: baseUrl || ''
256
+ const firstRun = !allowCancel;
257
+ const stepOf = (n) => (firstRun ? `${n}/5` : undefined);
258
+ let providerId = '';
259
+ let baseUrl = '';
260
+ let apiKey = '';
261
+ let stage = 'provider';
262
+ let modelRes = null;
263
+ while (!modelRes) {
264
+ if (stage === 'provider') {
265
+ const choice = await runFinder({
266
+ title: t('onboard.provider.title'),
267
+ subtitle: stepOf(3),
268
+ placeholder: t('onboard.provider.placeholder'),
269
+ current: currentConfig.provider,
270
+ 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
+ })),
284
278
  });
285
- if (!customUrl || !customUrl.trim())
286
- return null;
287
- baseUrl = customUrl.trim();
288
- baseUrls[providerId] = baseUrl;
279
+ if (choice === undefined)
280
+ return null; // Esc on the first step leaves the whole setup
281
+ providerId = choice;
282
+ const known = ALL_PROVIDERS.find(pr => pr.value === providerId);
283
+ baseUrl = baseUrls[providerId] || known?.baseUrl || '';
284
+ stage = providerId === 'custom' || !baseUrl ? 'url' : 'key';
289
285
  }
290
- catch {
291
- return null;
286
+ else if (stage === 'url') {
287
+ const url = await runTextInput({
288
+ title: t('onboard.baseUrl.title'),
289
+ subtitle: stepOf(3),
290
+ label: t('onboard.baseUrl.label'),
291
+ initial: baseUrl,
292
+ placeholder: t('onboard.baseUrl.placeholder'),
293
+ });
294
+ if (url === undefined) {
295
+ stage = 'provider';
296
+ continue;
297
+ }
298
+ baseUrl = url;
299
+ baseUrls[providerId] = baseUrl;
300
+ stage = 'key';
292
301
  }
293
- }
294
- let apiKey = '';
295
- if (providerId === 'ollama') {
296
- apiKey = 'ollama';
297
- }
298
- else {
299
- const savedKey = apiKeys[providerId];
300
- if (savedKey) {
301
- let keyChoice;
302
- try {
303
- keyChoice = await search({
304
- message: `API key found for ${selectedProvider.label} (${maskApiKey(savedKey)}):`,
305
- source: async () => [
306
- { name: `● Continue with saved API key (${maskApiKey(savedKey)})`, value: 'use_saved' },
307
- { name: '+ Enter a new API key', value: 'new_key' },
308
- { name: pc.yellow('← Cancel'), value: '__cancel__' }
302
+ 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';
305
+ if (providerId === 'ollama') {
306
+ apiKey = 'ollama';
307
+ stage = 'model';
308
+ continue;
309
+ }
310
+ const saved = apiKeys[providerId];
311
+ let useSaved = false;
312
+ if (saved) {
313
+ const choice = await runPicker({
314
+ title: t('onboard.key.title'),
315
+ subtitle: label,
316
+ items: [
317
+ { value: 'saved', label: t('onboard.key.useSaved'), detail: maskApiKey(saved) },
318
+ { value: 'new', label: t('onboard.key.enterNew') },
309
319
  ],
310
- pageSize: 5
320
+ notes: [t('onboard.key.savedTitle', { provider: label })],
311
321
  });
322
+ if (choice === undefined) {
323
+ stage = back;
324
+ continue;
325
+ }
326
+ useSaved = choice === 'saved';
312
327
  }
313
- catch {
314
- return null;
315
- }
316
- if (keyChoice === '__cancel__')
317
- return null;
318
- if (keyChoice === 'use_saved') {
319
- apiKey = savedKey;
328
+ if (useSaved) {
329
+ apiKey = saved;
320
330
  }
321
331
  else {
322
- try {
323
- const key = await password({
324
- message: `Enter new API Key for ${selectedProvider.label}:`,
325
- mask: '•'
326
- });
327
- if (!key || !key.trim())
328
- return null;
329
- apiKey = key.trim();
330
- apiKeys[providerId] = apiKey;
331
- }
332
- catch {
333
- return null;
332
+ const key = await runTextInput({
333
+ title: t('onboard.key.title'),
334
+ subtitle: stepOf(4) ?? label,
335
+ label: t('onboard.key.label', { provider: label }),
336
+ secret: true,
337
+ notes: [t('onboard.key.note')],
338
+ });
339
+ if (key === undefined) {
340
+ stage = back;
341
+ continue;
334
342
  }
343
+ apiKey = key;
344
+ apiKeys[providerId] = apiKey;
335
345
  }
346
+ stage = 'model';
336
347
  }
337
348
  else {
338
- try {
339
- const key = await password({
340
- message: `Enter API Key for ${selectedProvider.label} (or press Esc to cancel):`,
341
- mask: '•'
342
- });
343
- if (!key || !key.trim())
344
- return null;
345
- apiKey = key.trim();
346
- apiKeys[providerId] = apiKey;
347
- }
348
- catch {
349
- return null;
350
- }
349
+ modelRes = await selectModel(baseUrl, apiKey, currentConfig.model || '', providerId, allowCancel, stepOf(5));
350
+ if (!modelRes)
351
+ stage = apiKey === 'ollama' ? 'provider' : 'key';
351
352
  }
352
353
  }
353
- // Pick a model for this provider
354
- const modelRes = await selectModel(baseUrl, apiKey, currentConfig.model || '', providerId, allowCancel);
355
- if (!modelRes) {
356
- return null;
357
- }
358
354
  const updatedConfig = {
359
355
  ...currentConfig,
360
356
  provider: providerId,
@@ -369,15 +365,54 @@ async function selectProvider(currentConfig, allowCancel = true) {
369
365
  await saveConfig(updatedConfig);
370
366
  return updatedConfig;
371
367
  }
368
+ /** The themes as a list; the interface takes the colours of the focused one at once, so you see what you pick. */
369
+ async function chooseTheme(current, step) {
370
+ const before = current ?? getCurrentTheme().id;
371
+ const picked = await runPicker({
372
+ title: t('onboard.theme.title'),
373
+ subtitle: step,
374
+ items: listThemes().map((th) => ({ value: th.id, label: th.name, detail: th.desc })),
375
+ current: before,
376
+ labelWidth: 20,
377
+ notes: [t('onboard.theme.note')],
378
+ onFocus: (id) => { setActiveTheme(id); },
379
+ });
380
+ setActiveTheme(picked ?? before);
381
+ return picked;
382
+ }
383
+ /** First run: the welcome card, the language, then provider, key and model, each on its own small card. */
372
384
  async function runOnboarding() {
373
- console.clear();
374
- p.intro(pc.bgCyan(pc.black(' Welcome to devx ')));
375
- p.note('No configuration found. Let\'s set up your AI provider.', 'Setup');
376
- const res = await selectProvider({ apiKeys: {}, baseUrls: {} }, false);
385
+ const cols = () => process.stdout.columns || 80;
386
+ const show = (done) => {
387
+ process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
388
+ console.log();
389
+ for (const line of renderWelcome(DEVX_VERSION, cols(), done))
390
+ console.log(line);
391
+ console.log();
392
+ };
393
+ show(0);
394
+ const language = await runPicker({
395
+ title: t('onboard.language.title'),
396
+ subtitle: '1/5',
397
+ items: LANGUAGES.map((l) => ({ value: l.id, label: l.name })),
398
+ current: getLanguage(),
399
+ notes: [t('onboard.language.note')],
400
+ labelWidth: 14,
401
+ });
402
+ if (language === undefined) {
403
+ process.exit(0);
404
+ }
405
+ setLanguage(language);
406
+ show(1);
407
+ const theme = await chooseTheme(undefined, '2/5');
408
+ show(2);
409
+ const res = await selectProvider({ apiKeys: {}, baseUrls: {}, language, ...(theme ? { theme } : {}), onboarded: true }, false);
377
410
  if (!res) {
378
411
  process.exit(0);
379
412
  }
380
- p.outro(pc.green('Setup complete! Configuration saved to ~/.devxrc.json'));
413
+ show(5);
414
+ console.log(renderNotice('ok', t('onboard.done')));
415
+ console.log();
381
416
  return res;
382
417
  }
383
418
  /**
@@ -609,6 +644,11 @@ function formatToolGeneratingLabel(name, targetHint) {
609
644
  return `→ Clarifying questions...`;
610
645
  return `→ ${name}${target}...`;
611
646
  }
647
+ /** `#ab526 · 2h ago · 12 msgs`: what tells two sessions with the same title apart. */
648
+ function sessionDetail(s) {
649
+ const count = s.messages.filter((m) => m.role !== 'system').length;
650
+ return `#${s.id.split('_').pop()} · ${formatSessionTime(s.updatedAt)} · ${t('resume.msgs', { n: count })}`;
651
+ }
612
652
  async function handleSessionDelete(bannerMode) {
613
653
  while (true) {
614
654
  const sessions = await SessionManager.listSessions();
@@ -617,36 +657,18 @@ async function handleSessionDelete(bannerMode) {
617
657
  log.warn('No saved sessions found.');
618
658
  break;
619
659
  }
620
- let chosenSessionId;
621
- try {
622
- chosenSessionId = await search({
623
- message: `Select session to DELETE (${sessions.length} sessions, Esc to finish):`,
624
- source: async (term) => {
625
- const q = (term || '').trim().toLowerCase();
626
- const list = [
627
- { name: pc.yellow('← Done / Cancel'), value: '__cancel__', description: 'Return to chat' },
628
- { name: pc.red('× Delete ALL sessions'), value: '__all__', description: `Remove all ${sessions.length} saved sessions` }
629
- ];
630
- for (const s of sessions) {
631
- const timeStr = formatSessionTime(s.updatedAt);
632
- const msgCount = s.messages.filter(m => m.role !== 'system').length;
633
- const shortId = s.id.split('_').pop();
634
- list.push({
635
- name: `${pc.red('× ')} ${pc.dim(`[#${shortId}]`)} ${s.title}`,
636
- value: s.id,
637
- description: `${timeStr} • ${msgCount} msgs • ${s.model || 'default'}`
638
- });
639
- }
640
- if (!q)
641
- return list;
642
- return list.filter(item => item.name.toLowerCase().includes(q) || item.value.toLowerCase().includes(q));
643
- },
644
- pageSize: 10
645
- });
646
- }
647
- catch {
648
- break;
649
- }
660
+ const DELETE_ALL = '__all__';
661
+ const chosen = await runFinder({
662
+ title: t('sessions.deleteTitle'),
663
+ subtitle: `${sessions.length}`,
664
+ placeholder: t('resume.placeholder'),
665
+ labelWidth: 36,
666
+ items: [
667
+ { value: DELETE_ALL, label: t('sessions.deleteAll', { n: sessions.length }), pinned: true },
668
+ ...sessions.map((s) => ({ value: s.id, label: s.title, detail: sessionDetail(s), keywords: `${s.id} ${s.model || ''}` })),
669
+ ],
670
+ });
671
+ const chosenSessionId = chosen ?? '__cancel__';
650
672
  if (!chosenSessionId || chosenSessionId === '__cancel__') {
651
673
  drawLogo(bannerMode);
652
674
  log.info('Session deletion finished.');
@@ -675,38 +697,17 @@ async function handleSessionDelete(bannerMode) {
675
697
  }
676
698
  }
677
699
  async function handleThemeSelect(config) {
678
- const currentTh = getCurrentTheme();
679
- const themeChoices = listThemes().map(t => ({
680
- theme: t,
681
- choice: {
682
- name: `${renderSwatch(t)} ${t.boldFn(t.name.padEnd(18))} ${pc.dim(t.desc)} ${t.id === currentTh.id ? pc.green('(Active)') : ''}`,
683
- value: t.id,
684
- description: `${renderThemePreview(t)}\n\n${pc.dim(`${t.name} ${t.hex}`)}`
685
- }
686
- }));
687
- try {
688
- const selected = await search({
689
- message: `${pc.bold('Select UI Theme / Выберите цветовую тему')} ${pc.dim(`(${themeChoices.length} themes, type to filter):`)}`,
690
- pageSize: 8,
691
- source: async (term) => {
692
- const q = (term || '').toLowerCase().trim();
693
- return themeChoices
694
- .filter(({ theme: t }) => !q || `${t.id} ${t.name} ${t.desc}`.toLowerCase().includes(q))
695
- .map(({ choice }) => choice);
696
- }
697
- });
698
- if (selected) {
699
- config.theme = selected;
700
- await saveConfig(config);
701
- const th = setActiveTheme(selected);
702
- // No redraw of the screen: that would wipe the conversation. New output uses the new colours.
703
- if (config.ui?.layout === 'classic')
704
- log.success(th.boldFn(`Theme switched to ${th.colorFn(th.symbol)} ${th.name}!`));
705
- else
706
- console.log(renderNotice('ok', `theme ${th.name}`));
707
- }
700
+ const selected = await chooseTheme(config.theme);
701
+ if (selected) {
702
+ config.theme = selected;
703
+ await saveConfig(config);
704
+ const th = setActiveTheme(selected);
705
+ // No redraw of the screen: that would wipe the conversation. New output uses the new colours.
706
+ if (config.ui?.layout === 'classic')
707
+ log.success(th.boldFn(`Theme switched to ${th.colorFn(th.symbol)} ${th.name}!`));
708
+ else
709
+ console.log(renderNotice('ok', `theme ${th.name}`));
708
710
  }
709
- catch { }
710
711
  }
711
712
  async function handleSettings(config) {
712
713
  const initial = {
@@ -809,6 +810,8 @@ export async function main() {
809
810
  config.model = options.model;
810
811
  config.maxContextTokens = resolveContextLimit({ ...config, maxContextTokens: 0 }).limit;
811
812
  }
813
+ // -m and the project config are for this run: only what changes from here on is saved.
814
+ markConfigBaseline(config);
812
815
  if (options.yolo) {
813
816
  config.autoApprove = true;
814
817
  }
@@ -863,31 +866,11 @@ export async function main() {
863
866
  };
864
867
  };
865
868
  if (!config.onboarded) {
866
- const themeChoices = listThemes().map(t => ({
867
- theme: t,
868
- choice: {
869
- name: `${renderSwatch(t)} ${t.boldFn(t.name.padEnd(18))} ${pc.dim(t.desc)}`,
870
- value: t.id,
871
- description: `${renderThemePreview(t)}\n\n${pc.dim('You can change this anytime with /theme')}`
872
- }
873
- }));
874
- try {
875
- clearTerminalScreen();
876
- const selected = await search({
877
- message: `${pc.bold('Pick a theme to personalize your workspace:')}`,
878
- source: async (term) => {
879
- const q = (term || '').toLowerCase().trim();
880
- return themeChoices
881
- .filter(({ theme: t }) => !q || `${t.id} ${t.name} ${t.desc}`.toLowerCase().includes(q))
882
- .map(({ choice }) => choice);
883
- },
884
- pageSize: 8
885
- });
886
- if (selected) {
887
- config.theme = selected;
888
- }
889
- }
890
- catch { }
869
+ // A config from before the first-run setup had a theme step: ask it once.
870
+ clearTerminalScreen();
871
+ const selected = await chooseTheme(config.theme);
872
+ if (selected)
873
+ config.theme = selected;
891
874
  config.onboarded = true;
892
875
  await saveConfig(config);
893
876
  }
@@ -918,6 +901,10 @@ export async function main() {
918
901
  const checkpoints = [];
919
902
  // What the user allowed on the approval card for this session ("Session"); "Always" goes to the config.
920
903
  const permissionMemory = newPermissionMemory();
904
+ // Commands the agent keeps running in the background (servers, watchers): shown in the prompt frame, listed by /tasks.
905
+ const bg = getBackgroundManager();
906
+ const seenBgFailures = new Set();
907
+ const bgSummary = () => backgroundSummary(bg, seenBgFailures);
921
908
  // The most a session may cost (USD, 0 = no limit); "continue" at the limit raises it for this session only.
922
909
  let costLimitRaised = null;
923
910
  let costWarned = false;
@@ -946,6 +933,11 @@ export async function main() {
946
933
  // and start from a clean slate: history, cost, usage counters, undo stack, screen.
947
934
  const startFreshSession = async () => {
948
935
  sessionManager.startNewSession(config.model, planMode);
936
+ // The tasks belonged to the conversation that is being left.
937
+ await bg.stopAll().catch(() => 0);
938
+ bg.clearFinished();
939
+ bg.takeNotes();
940
+ seenBgFailures.clear();
949
941
  transcript.length = 0;
950
942
  checkpoints.length = 0;
951
943
  windowTitle = '';
@@ -1026,6 +1018,8 @@ export async function main() {
1026
1018
  }
1027
1019
  }
1028
1020
  let answer = '';
1021
+ // Set when this turn starts by itself, from the news of a background task (not from the user).
1022
+ let wakeNotes = '';
1029
1023
  if (autoTriggerPrompt) {
1030
1024
  answer = autoTriggerPrompt;
1031
1025
  autoTriggerPrompt = '';
@@ -1057,7 +1051,19 @@ export async function main() {
1057
1051
  dirty: GitStatusCache.getStatus()?.isDirty,
1058
1052
  contextPercent: usagePercent,
1059
1053
  cost: sessionCost.total,
1060
- costState: sessionCost.state()
1054
+ costState: sessionCost.state(),
1055
+ background: bgSummary,
1056
+ subscribe: (signal) => {
1057
+ const onChange = () => signal('redraw');
1058
+ // A task ended or printed what the agent asked to hear about: the agent takes it up by itself.
1059
+ const onNote = () => signal(config.backgroundAutoWake === false ? 'redraw' : 'wake');
1060
+ bg.on('change', onChange);
1061
+ bg.on('note', onNote);
1062
+ return () => {
1063
+ bg.off('change', onChange);
1064
+ bg.off('note', onNote);
1065
+ };
1066
+ },
1061
1067
  });
1062
1068
  if (inputStr.startsWith('__TOGGLE_MODE__:')) {
1063
1069
  currentDraft = inputStr.slice('__TOGGLE_MODE__:'.length);
@@ -1072,19 +1078,31 @@ export async function main() {
1072
1078
  }
1073
1079
  continue;
1074
1080
  }
1075
- currentDraft = '';
1076
- if (inputStr === '__CANCEL__') {
1077
- const confirmExit = await askConfirm({ title: t('confirm.exitTitle'), question: t('confirm.exit'), record: false });
1078
- if (confirmExit) {
1079
- resetTerminalTheme();
1080
- printFarewell(t('confirm.goodbye'));
1081
- break;
1082
- }
1083
- else {
1081
+ if (inputStr.startsWith('__WAKE__:')) {
1082
+ // The prompt was closed by news from a background task: the agent answers it. What was typed stays.
1083
+ currentDraft = inputStr.slice('__WAKE__:'.length);
1084
+ wakeNotes = bg.takeNotes();
1085
+ if (!wakeNotes)
1084
1086
  continue;
1087
+ console.log(noteRows(wakeNotes).map((r) => renderNotice('info', r)).join('\n'));
1088
+ console.log();
1089
+ answer = wakeNotes;
1090
+ }
1091
+ else {
1092
+ currentDraft = '';
1093
+ if (inputStr === '__CANCEL__') {
1094
+ const confirmExit = await askConfirm({ title: t('confirm.exitTitle'), question: t('confirm.exit'), record: false });
1095
+ if (confirmExit) {
1096
+ resetTerminalTheme();
1097
+ printFarewell(t('confirm.goodbye'));
1098
+ break;
1099
+ }
1100
+ else {
1101
+ continue;
1102
+ }
1085
1103
  }
1104
+ answer = inputStr.trim();
1086
1105
  }
1087
- answer = inputStr.trim();
1088
1106
  }
1089
1107
  if (!answer)
1090
1108
  continue;
@@ -1106,7 +1124,7 @@ export async function main() {
1106
1124
  '/settings', '/update', '/model', '/provider', '/providers',
1107
1125
  '/plan', '/agent', '/image', '/serve', '/memory', '/permissions', '/copy', '/rename', '/title', '/rewind', '/undo',
1108
1126
  '/diff', '/commit', '/status', '/compact', '/init', '/doctor',
1109
- '/config', '/clear', '/exit', '/quit', '/help', '/effort'
1127
+ '/config', '/clear', '/exit', '/quit', '/help', '/effort', '/tasks', '/bg', '/jobs'
1110
1128
  ];
1111
1129
  if (!VALID_COMMANDS.includes(cmd)) {
1112
1130
  const customList = await CustomCommandManager.listCommands();
@@ -1131,6 +1149,7 @@ export async function main() {
1131
1149
  { name: '/plan - Switch to PLAN mode (architect)', value: '/plan' },
1132
1150
  { name: '/agent - Switch to AGENT mode (coder)', value: '/agent' },
1133
1151
  { name: '/serve - Start local web server for web preview', value: '/serve' },
1152
+ { name: '/tasks - Background tasks (list, output, stop)', value: '/tasks' },
1134
1153
  { name: '/memory - View or edit project memory bank', value: '/memory' },
1135
1154
  { name: '/permissions - What is allowed without asking', value: '/permissions' },
1136
1155
  { name: '/copy - Copy the last answer to the clipboard', value: '/copy' },
@@ -1148,21 +1167,17 @@ export async function main() {
1148
1167
  ...customSlashItems
1149
1168
  ];
1150
1169
  try {
1151
- const picked = await search({
1152
- message: 'Commands (type to search or select):',
1153
- source: async (term) => {
1154
- const q = (term || '').trim().toLowerCase();
1155
- const list = [
1156
- { name: pc.yellow('← Cancel'), value: '__cancel__' },
1157
- ...SLASH_COMMANDS
1158
- ];
1159
- if (!q)
1160
- return list;
1161
- return list.filter(item => item.name.toLowerCase().includes(q) || item.value.toLowerCase().includes(q));
1162
- },
1163
- pageSize: 12
1170
+ const picked = await runFinder({
1171
+ title: t('commands.title'),
1172
+ subtitle: `${SLASH_COMMANDS.length}`,
1173
+ placeholder: t('commands.placeholder'),
1174
+ labelWidth: 14,
1175
+ items: SLASH_COMMANDS.map((c) => {
1176
+ const [name, ...rest] = c.name.split(/\s+-\s+/);
1177
+ return { value: c.value, label: name.trim(), detail: rest.join(' - ') };
1178
+ }),
1164
1179
  });
1165
- if (!picked || picked === '__cancel__') {
1180
+ if (!picked) {
1166
1181
  continue;
1167
1182
  }
1168
1183
  if (picked === '/session del') {
@@ -1238,43 +1253,21 @@ export async function main() {
1238
1253
  log.warn('No saved sessions found.');
1239
1254
  continue;
1240
1255
  }
1241
- let chosenSessionId;
1242
- try {
1243
- chosenSessionId = await search({
1244
- message: 'Select a session to resume (type to filter, Esc to cancel):',
1245
- source: async (term) => {
1246
- const q = (term || '').trim().toLowerCase();
1247
- const list = [
1248
- { name: pc.yellow('← Back / Cancel'), value: '__cancel__', description: 'Return to chat' },
1249
- { name: pc.red('× Delete sessions... (/session del)'), value: '__delete_mode__', description: 'Selectively delete saved sessions' }
1250
- ];
1251
- for (const s of sessions) {
1252
- const timeStr = formatSessionTime(s.updatedAt);
1253
- const msgCount = s.messages.filter(m => m.role !== 'system').length;
1254
- const shortId = s.id.split('_').pop();
1255
- list.push({
1256
- name: `${pc.dim(`[#${shortId}]`)} ${s.title}`,
1257
- value: s.id,
1258
- description: `${timeStr} • ${msgCount} msgs • ${s.model || 'default'}`
1259
- });
1260
- }
1261
- if (!q)
1262
- return list;
1263
- return list.filter(item => item.name.toLowerCase().includes(q) || item.value.toLowerCase().includes(q));
1264
- },
1265
- pageSize: 10
1266
- });
1267
- }
1268
- catch {
1269
- continue;
1270
- }
1271
- if (chosenSessionId === '__cancel__') {
1272
- continue;
1273
- }
1274
- if (chosenSessionId === '__delete_mode__') {
1275
- await handleSessionDelete(config.banner || 'full');
1256
+ const picked = await runFinder({
1257
+ title: t('resume.title'),
1258
+ subtitle: `${sessions.length}`,
1259
+ placeholder: t('resume.placeholder'),
1260
+ labelWidth: 36,
1261
+ items: sessions.map((s) => ({
1262
+ value: s.id,
1263
+ label: s.title,
1264
+ detail: sessionDetail(s),
1265
+ keywords: `${s.id} ${s.model || ''}`,
1266
+ })),
1267
+ });
1268
+ if (picked === undefined)
1276
1269
  continue;
1277
- }
1270
+ const chosenSessionId = picked;
1278
1271
  const loaded = await SessionManager.loadSession(chosenSessionId);
1279
1272
  if (loaded) {
1280
1273
  sessionManager.setLoadedSession(loaded);
@@ -1514,9 +1507,12 @@ export async function main() {
1514
1507
  // A user message, not an assistant one: the Anthropic format cannot start with the assistant.
1515
1508
  history.addMessage({
1516
1509
  role: 'user',
1517
- content: `[CONVERSATION COMPACTED] Summary of our conversation so far:\n${res.content}`
1510
+ content: `${COMPACT_MARK} Summary of our conversation so far:\n${res.content}`
1518
1511
  });
1519
- log.success(pc.bold(pc.green('Context successfully compacted! Freed up tokens while retaining task summary.')));
1512
+ const item = { kind: 'compact', summary: res.content };
1513
+ transcript.push(item);
1514
+ console.log();
1515
+ console.log(renderItem(item, { cols: process.stdout.columns || 80, thinkingExpanded, outputExpanded }).join('\n'));
1520
1516
  }
1521
1517
  catch (err) {
1522
1518
  s.stop();
@@ -1555,6 +1551,12 @@ export async function main() {
1555
1551
  await runDoctor(config);
1556
1552
  continue;
1557
1553
  }
1554
+ 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)));
1556
+ console.log(rows.join('\n'));
1557
+ console.log();
1558
+ continue;
1559
+ }
1558
1560
  if (cmd === '/serve') {
1559
1561
  const sub = parts[1]?.toLowerCase();
1560
1562
  if (sub === 'stop') {
@@ -1981,7 +1983,10 @@ export async function main() {
1981
1983
  }
1982
1984
  }
1983
1985
  // Resolve @file and @image mentions
1984
- const { text: cleanAnswer, attachments, images, warnings: attachWarnings } = await resolveAtMentions(answer);
1986
+ // News from a background task is output of a program, not a message of the user: no @file in it is read.
1987
+ const { text: cleanAnswer, attachments, images, warnings: attachWarnings } = wakeNotes
1988
+ ? { text: answer, attachments: [], images: [], warnings: [] }
1989
+ : await resolveAtMentions(answer);
1985
1990
  for (const warning of attachWarnings)
1986
1991
  log.warn(warning);
1987
1992
  let finalContent = cleanAnswer;
@@ -2022,9 +2027,12 @@ export async function main() {
2022
2027
  if (turnCheckpoint)
2023
2028
  checkpoints.push(turnCheckpoint);
2024
2029
  }
2025
- transcript.push({ kind: 'user', text: answer });
2030
+ if (wakeNotes)
2031
+ transcript.push({ kind: 'raw', rows: noteRows(wakeNotes).map((r) => renderNotice('info', r)) });
2032
+ else
2033
+ transcript.push({ kind: 'user', text: answer });
2026
2034
  // Until the model has named the chat, the start of the first message stands in for the name.
2027
- if (!windowTitle && !sessionManager.hasGeneratedTitle)
2035
+ if (!wakeNotes && !windowTitle && !sessionManager.hasGeneratedTitle)
2028
2036
  setWindowTitle(provisionalTitle(answer));
2029
2037
  history.addMessage({
2030
2038
  role: 'user',
@@ -2346,8 +2354,12 @@ export async function main() {
2346
2354
  contextPercent: Math.min(100, Math.round((used / (config.maxContextTokens || getModelContextLimit(config.model))) * 100)),
2347
2355
  cost: sessionCost.total,
2348
2356
  costState: sessionCost.state(),
2357
+ background: bgSummary(),
2349
2358
  };
2350
2359
  }, undefined, statusLine, sendQueuedNow);
2360
+ // A task started or ended during the turn: the frame shows the new count.
2361
+ const onBgChange = () => composer.refresh();
2362
+ bg.on('change', onBgChange);
2351
2363
  composer.live = liveThought;
2352
2364
  composer.onToggle = toggleView;
2353
2365
  const releasePauseHook = registerPauseHook(() => {
@@ -2404,6 +2416,8 @@ export async function main() {
2404
2416
  for await (const event of agent.run(abortController.signal, {
2405
2417
  takePending: async () => Promise.all(composer.takeQueued().map(queuedToMessage)),
2406
2418
  hasPending: () => composer.hasQueued(),
2419
+ takeNotes: () => bg.takeNotes(),
2420
+ hasNotes: () => bg.hasNotes(),
2407
2421
  })) {
2408
2422
  if (aborted)
2409
2423
  break;
@@ -2693,6 +2707,22 @@ export async function main() {
2693
2707
  else
2694
2708
  log.info(event.message);
2695
2709
  }
2710
+ else if (event.type === 'background_note') {
2711
+ // A background task ended or printed what the agent asked to hear about.
2712
+ if (spinnerActive) {
2713
+ s.stop();
2714
+ spinnerActive = false;
2715
+ }
2716
+ finishThinking(true);
2717
+ streamer.finish();
2718
+ endText();
2719
+ const rows = noteRows(event.content);
2720
+ transcript.push({ kind: 'raw', rows: rows.map((r) => renderNotice('info', r)) });
2721
+ for (const row of rows)
2722
+ console.log(cleanUi ? renderNotice('info', row) : pc.dim(row));
2723
+ s.start(cleanUi ? t('status.waiting') : 'Connecting...');
2724
+ spinnerActive = true;
2725
+ }
2696
2726
  }
2697
2727
  finishThinking(true);
2698
2728
  streamer.finish();
@@ -2703,6 +2733,7 @@ export async function main() {
2703
2733
  endText();
2704
2734
  // Hand the bottom of the screen back. Whatever was typed but not sent waits in the next prompt;
2705
2735
  // a message that arrived too late for the agent starts the next turn by itself.
2736
+ bg.off('change', onBgChange);
2706
2737
  const leftover = composer.stop();
2707
2738
  if (interruptPrompt) {
2708
2739
  autoTriggerPrompt = interruptPrompt;
@@ -2828,6 +2859,7 @@ export async function main() {
2828
2859
  }
2829
2860
  }
2830
2861
  finally {
2862
+ bg.off('change', onBgChange);
2831
2863
  const unsent = composer.stop();
2832
2864
  if (process.stdout.isTTY)
2833
2865
  process.stdout.write('\x1b[?1004l');