termux-dev 1.6.0 → 1.8.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 (96) hide show
  1. package/README.md +214 -166
  2. package/assets/banner.svg +23 -28
  3. package/assets/preview.png +0 -0
  4. package/assets/preview.svg +92 -10
  5. package/dist/cli/banner.js +1 -1
  6. package/dist/cli/clipboard-write.js +55 -0
  7. package/dist/cli/clipboard.js +182 -106
  8. package/dist/cli/colors.js +67 -0
  9. package/dist/cli/confirm.js +50 -0
  10. package/dist/cli/doctor.js +4 -4
  11. package/dist/cli/effort.js +80 -0
  12. package/dist/cli/export.js +3 -3
  13. package/dist/cli/files.js +19 -5
  14. package/dist/cli/headless.js +23 -11
  15. package/dist/cli/index.js +1377 -480
  16. package/dist/cli/keys.js +69 -0
  17. package/dist/cli/liveblock.js +45 -0
  18. package/dist/cli/log.js +19 -0
  19. package/dist/cli/markdown.js +158 -36
  20. package/dist/cli/mcp-panel.js +432 -0
  21. package/dist/cli/permission.js +84 -0
  22. package/dist/cli/picker.js +135 -0
  23. package/dist/cli/prompt.js +256 -56
  24. package/dist/cli/resume.js +15 -0
  25. package/dist/cli/rewind.js +51 -0
  26. package/dist/cli/runtime_errors.js +6 -0
  27. package/dist/cli/server.js +85 -24
  28. package/dist/cli/settings.js +307 -0
  29. package/dist/cli/statusline.js +1 -1
  30. package/dist/cli/theme.js +126 -108
  31. package/dist/cli/transcript.js +114 -0
  32. package/dist/cli/typeahead.js +371 -0
  33. package/dist/cli/ui.js +1021 -0
  34. package/dist/cli/updater.js +23 -21
  35. package/dist/cli/usage-panel.js +242 -0
  36. package/dist/cli/width.js +178 -0
  37. package/dist/core/capabilities.js +433 -0
  38. package/dist/core/catalog-util.js +27 -0
  39. package/dist/core/checkpoint.js +90 -0
  40. package/dist/core/context-stats.js +29 -0
  41. package/dist/core/cost.js +191 -0
  42. package/dist/core/formats.js +199 -0
  43. package/dist/core/history.js +51 -1
  44. package/dist/core/hooks.js +79 -0
  45. package/dist/core/i18n.js +42 -0
  46. package/dist/core/local-config.js +69 -0
  47. package/dist/core/locales/de.js +210 -0
  48. package/dist/core/locales/en.js +221 -0
  49. package/dist/core/locales/es.js +210 -0
  50. package/dist/core/locales/fr.js +210 -0
  51. package/dist/core/locales/ja.js +210 -0
  52. package/dist/core/locales/pt.js +210 -0
  53. package/dist/core/locales/ru.js +210 -0
  54. package/dist/core/locales/uk.js +210 -0
  55. package/dist/core/locales/zh.js +210 -0
  56. package/dist/core/loop.js +125 -49
  57. package/dist/core/memory.js +39 -12
  58. package/dist/core/models.js +33 -20
  59. package/dist/core/network.js +12 -11
  60. package/dist/core/notify.js +54 -0
  61. package/dist/core/planfile.js +43 -0
  62. package/dist/core/price-catalog.json +1 -0
  63. package/dist/core/prices.js +249 -0
  64. package/dist/core/reasoning-catalog.json +1 -0
  65. package/dist/core/reasoning.js +393 -0
  66. package/dist/core/redact.js +41 -0
  67. package/dist/core/secure-file.js +22 -0
  68. package/dist/core/session.js +16 -5
  69. package/dist/core/snapshot.js +14 -2
  70. package/dist/core/title.js +61 -0
  71. package/dist/core/types.js +1 -1
  72. package/dist/core/usage.js +43 -7
  73. package/dist/core/vision-catalog.json +1 -0
  74. package/dist/core/vision.js +123 -0
  75. package/dist/mcp/client.js +36 -5
  76. package/dist/mcp/http-client.js +174 -0
  77. package/dist/mcp/manager.js +330 -93
  78. package/dist/permissions/guard.js +385 -117
  79. package/dist/prompts/builder.js +46 -38
  80. package/dist/providers/anthropic.js +517 -0
  81. package/dist/providers/index.js +18 -1
  82. package/dist/providers/openai.js +138 -125
  83. package/dist/providers/shared.js +116 -0
  84. package/dist/providers/toolguard.js +101 -0
  85. package/dist/tools/bash.js +141 -51
  86. package/dist/tools/diagnostics.js +76 -48
  87. package/dist/tools/fs.js +77 -5
  88. package/dist/tools/index.js +2 -1
  89. package/dist/tools/mcp.js +72 -0
  90. package/dist/tools/packages.js +16 -1
  91. package/dist/tools/plan.js +8 -8
  92. package/dist/tools/questions.js +50 -54
  93. package/dist/tools/server.js +6 -0
  94. package/dist/tools/todo.js +20 -18
  95. package/dist/tools/web.js +143 -8
  96. package/package.json +1 -1
package/dist/cli/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Command } from 'commander';
2
2
  import * as p from '@clack/prompts';
3
- import { search, password, input, select } from '@inquirer/prompts';
3
+ import { search, password, input } from '@inquirer/prompts';
4
4
  import pc from 'picocolors';
5
5
  import os from 'os';
6
6
  import path from 'path';
@@ -13,30 +13,59 @@ import { Agent } from '../core/loop.js';
13
13
  import { buildSystemPrompt } from '../prompts/builder.js';
14
14
  import { createProvider } from '../providers/index.js';
15
15
  import { getTools, lastPlanReady, resetPlanReady } from '../tools/index.js';
16
- import { CLIConsoleGuard } from '../permissions/guard.js';
16
+ import { CLIConsoleGuard, newPermissionMemory } from '../permissions/guard.js';
17
17
  import { globalSnapshotManager } from '../core/snapshot.js';
18
- import { MemoryManager } from '../core/memory.js';
18
+ import { MemoryManager, memoryForPrompt } from '../core/memory.js';
19
19
  import { startServer, stopServer, displayServerBanner } from './server.js';
20
- import { notifyDevice, acquireWakeLock } from '../core/notify.js';
20
+ import { notifyDevice, acquireWakeLock, isTermux, notificationBody, sendDesktopNotification } from '../core/notify.js';
21
+ import { copyToClipboard } from './clipboard-write.js';
22
+ import { REWIND_CURRENT, messageDraft, rewindItems, userEntries } from './rewind.js';
23
+ import { runPicker } from './picker.js';
24
+ import { savePlanFile } from '../core/planfile.js';
25
+ import { BASE_TITLE, RESTORE_TERMINAL_TITLE, SAVE_TERMINAL_TITLE, cleanTitle, generateChatTitle, provisionalTitle, terminalTitleSequence } from '../core/title.js';
21
26
  import { runDoctor } from './doctor.js';
22
27
  import { ALL_PROVIDERS } from './providers.js';
23
- import { getModelContextLimit } from '../core/models.js';
28
+ import { getModelContextInfo, getModelContextLimit } from '../core/models.js';
24
29
  import { SessionManager } from '../core/session.js';
25
30
  import { MarkdownStreamer, renderMarkdown } from './markdown.js';
26
31
  import { SmoothStreamer } from './smooth.js';
27
32
  import { askPrompt } from './prompt.js';
28
33
  import { resolveAtMentions } from './files.js';
29
34
  import { runStartupUpdateCheck, checkForUpdates, performSelfUpdate } from './updater.js';
30
- import { setActiveTheme, getCurrentTheme, listThemes, findTheme } from './theme.js';
35
+ import { setActiveTheme, getCurrentTheme, listThemes, findTheme, renderSwatch, renderThemePreview } from './theme.js';
31
36
  import { runHeadlessMode } from './headless.js';
32
37
  import { CustomCommandManager } from '../core/commands.js';
33
38
  import { UsageTracker } from '../core/usage.js';
39
+ import { CostLedger } from '../core/cost.js';
34
40
  import { SessionExporter } from './export.js';
35
- import { MCPManager } from '../mcp/manager.js';
41
+ import { MCPManager, discoverProjectMcpServers, hashProjectMcpServers, readClaudeServers, removeUserServer, saveUserServers } from '../mcp/manager.js';
42
+ import { writeSecureFile } from '../core/secure-file.js';
43
+ import { applyLocalConfig, persistedConfig, withoutSessionOnlySettings } from '../core/local-config.js';
36
44
  import { LiveTelemetryTracker } from './telemetry.js';
37
- import { renderCleanStatus, renderPowerlineStatus, GitStatusCache } from './statusline.js';
45
+ import { renderPowerlineStatus, GitStatusCache } from './statusline.js';
38
46
  import { formatDiffBox } from './diff.js';
39
47
  import { drawBanner } from './banner.js';
48
+ import { reportRuntimeError } from './runtime_errors.js';
49
+ import { GutterWriter, UiSpinner, formatCost, formatTokens as formatTokensShort, palette, pickTip, renderHeader, renderNotice, renderResultBlock, renderToolLine, renderTurnSummary, registerPauseHook, easeToward, renderStatusLine, renderUserEcho, resolveMode, } from './ui.js';
50
+ import { runSettings } from './settings.js';
51
+ import { runMcpPanel } from './mcp-panel.js';
52
+ import { renderResumedMessages } from './resume.js';
53
+ import { askConfirm, askPlanDecision, printFarewell } from './confirm.js';
54
+ import { createCheckpoint, diffCheckpoint, restoreCheckpoint } from '../core/checkpoint.js';
55
+ import { log, note } from './log.js';
56
+ import { displayWidth, tailWidth } from './width.js';
57
+ import { collapsedThoughtLine, messagesToItems, renderTranscript, toolResultRows } from './transcript.js';
58
+ import { chooseEffort, effortCommand } from './effort.js';
59
+ import { effectiveReasoning, parseEffortArg, detectRoute, thinkLabel } from '../core/reasoning.js';
60
+ import { catalogInfo, installCapabilities, refreshCapabilities, refreshCatalog } from '../core/capabilities.js';
61
+ import { modelAcceptsImages } from '../core/vision.js';
62
+ import { contextBreakdown } from '../core/context-stats.js';
63
+ import { runUsagePanel } from './usage-panel.js';
64
+ import { TurnComposer } from './typeahead.js';
65
+ import { EXTENDED_KEYS_OFF, normalizeKey, splitKeys } from './keys.js';
66
+ import { getLanguage, setLanguage, t } from '../core/i18n.js';
67
+ import { anthropicOnlyModels, formatFromBaseUrl, refreshFormats } from '../core/formats.js';
68
+ import { providerFormat } from '../providers/index.js';
40
69
  const CONFIG_PATH = path.join(os.homedir(), '.devxrc.json');
41
70
  function maskApiKey(key) {
42
71
  if (!key)
@@ -54,14 +83,24 @@ async function saveConfig(config) {
54
83
  if (config.provider && config.baseUrl) {
55
84
  config.baseUrls[config.provider] = config.baseUrl;
56
85
  }
57
- await fs.writeFile(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8');
86
+ // 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));
58
90
  }
59
91
  async function fetchModels(baseUrl, apiKey) {
60
92
  try {
61
93
  const url = baseUrl.endsWith('/') ? `${baseUrl}models` : `${baseUrl}/models`;
62
94
  const headers = {};
63
- if (apiKey && apiKey !== 'ollama')
95
+ if (formatFromBaseUrl(baseUrl) === 'anthropic') {
96
+ // Anthropic's own API wants x-api-key and a version, not a Bearer token.
97
+ headers['anthropic-version'] = '2023-06-01';
98
+ if (apiKey)
99
+ headers['x-api-key'] = apiKey;
100
+ }
101
+ else if (apiKey && apiKey !== 'ollama') {
64
102
  headers['Authorization'] = `Bearer ${apiKey}`;
103
+ }
65
104
  const res = await fetch(url, { headers });
66
105
  if (!res.ok) {
67
106
  if (res.status === 401 || res.status === 403) {
@@ -73,7 +112,7 @@ async function fetchModels(baseUrl, apiKey) {
73
112
  if (data && data.data && Array.isArray(data.data)) {
74
113
  const list = data.data.map((m) => ({
75
114
  id: m.id,
76
- name: m.name || m.id,
115
+ name: m.name || m.display_name || m.id,
77
116
  contextLength: m.context_length || m.max_context_length || m.limit?.context || m.limit?.input
78
117
  }));
79
118
  return { ok: true, status: res.status, models: list };
@@ -84,6 +123,12 @@ async function fetchModels(baseUrl, apiKey) {
84
123
  return { ok: false, status: 0, models: [], error: e.message || 'Network error' };
85
124
  }
86
125
  }
126
+ /** Asks the endpoint what the model can do and which wire formats it speaks (cached for a day). */
127
+ function refreshEndpoint(config, options) {
128
+ return Promise.all([refreshCapabilities(config, options), refreshFormats(config, options)]).then(([record]) => record);
129
+ }
130
+ /** `"probeEndpoints": false` in the config: devx asks nothing but the plain model list. */
131
+ let probeEndpointsAllowed = true;
87
132
  const POPULAR_PROVIDER_MODELS = {
88
133
  google: ['gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-1.5-flash', 'gemini-1.5-pro'],
89
134
  openai: ['gpt-4o', 'gpt-4o-mini', 'o3-mini', 'o1', 'gpt-4-turbo'],
@@ -94,9 +139,18 @@ const POPULAR_PROVIDER_MODELS = {
94
139
  mistral: ['mistral-large-latest', 'mistral-small-latest', 'codestral-latest'],
95
140
  };
96
141
  async function selectModel(baseUrl, apiKey, currentModel, providerId = '', allowCancel = true) {
97
- const s = p.spinner();
142
+ const s = new UiSpinner();
98
143
  s.start('Fetching available models from provider...');
99
144
  const result = await fetchModels(baseUrl, apiKey);
145
+ // The same gateway may list more models (Claude) in its Anthropic format, with the same key.
146
+ if (result.ok && probeEndpointsAllowed) {
147
+ await refreshFormats({ baseUrl, apiKey }, { force: true }).catch(() => null);
148
+ const known = new Set(result.models.map(m => m.id.toLowerCase()));
149
+ for (const id of anthropicOnlyModels(baseUrl)) {
150
+ if (!known.has(id.toLowerCase()))
151
+ result.models.push({ id, name: id, viaAnthropic: true, contextLength: getModelContextLimit(id) });
152
+ }
153
+ }
100
154
  s.stop();
101
155
  let models = result.models;
102
156
  if (models.length === 0 && providerId && POPULAR_PROVIDER_MODELS[providerId]) {
@@ -115,14 +169,14 @@ async function selectModel(baseUrl, apiKey, currentModel, providerId = '', allow
115
169
  const q = (term || '').trim().toLowerCase();
116
170
  const list = [];
117
171
  if (allowCancel) {
118
- list.push({ name: pc.yellow('⬅️ Back / Cancel'), value: '__cancel__', description: 'Keep current model' });
172
+ list.push({ name: pc.yellow('← Back / Cancel'), value: '__cancel__', description: 'Keep current model' });
119
173
  }
120
- list.push({ name: pc.yellow('✏️ Type a custom model name...'), value: 'custom_input', description: 'Enter any model ID manually' });
174
+ list.push({ name: pc.yellow('+ Type a custom model name...'), value: 'custom_input', description: 'Enter any model ID manually' });
121
175
  for (const m of models) {
122
176
  const isCurrent = m.id === currentModel;
123
177
  const prefix = isCurrent ? pc.cyan('› ') : ' ';
124
178
  const nameStr = isCurrent ? pc.bold(pc.cyan(m.id + ' (current)')) : m.id;
125
- const ctxStr = m.contextLength ? `${m.contextLength >= 1_000_000 ? (m.contextLength / 1_000_000).toFixed(1) + 'M' : Math.round(m.contextLength / 1000) + 'k'} context` : '';
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` : '');
126
180
  list.push({
127
181
  name: prefix + nameStr,
128
182
  value: m.id,
@@ -161,7 +215,7 @@ async function selectModel(baseUrl, apiKey, currentModel, providerId = '', allow
161
215
  return { model: modelChoice, contextLimit: limit };
162
216
  }
163
217
  else {
164
- p.log.warn('Could not fetch models automatically.');
218
+ log.warn('Could not fetch models automatically.');
165
219
  try {
166
220
  const typedModel = await input({
167
221
  message: 'Type the model name you want to use (e.g. gpt-4o):',
@@ -204,7 +258,7 @@ async function selectProvider(currentConfig, allowCancel = true) {
204
258
  });
205
259
  if (allowCancel) {
206
260
  list.unshift({
207
- name: pc.yellow('⬅️ Back / Cancel'),
261
+ name: pc.yellow('← Back / Cancel'),
208
262
  value: '__cancel__',
209
263
  description: 'Keep current provider'
210
264
  });
@@ -249,9 +303,9 @@ async function selectProvider(currentConfig, allowCancel = true) {
249
303
  keyChoice = await search({
250
304
  message: `API key found for ${selectedProvider.label} (${maskApiKey(savedKey)}):`,
251
305
  source: async () => [
252
- { name: `🟢 Continue with saved API key (${maskApiKey(savedKey)})`, value: 'use_saved' },
253
- { name: '✏️ Enter a new API key', value: 'new_key' },
254
- { name: pc.yellow('⬅️ Cancel'), value: '__cancel__' }
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__' }
255
309
  ],
256
310
  pageSize: 5
257
311
  });
@@ -326,14 +380,32 @@ async function runOnboarding() {
326
380
  p.outro(pc.green('Setup complete! Configuration saved to ~/.devxrc.json'));
327
381
  return res;
328
382
  }
383
+ /**
384
+ * The context window to use for the model of this config: a number the user wrote into
385
+ * `contextLimits`, else the catalogue's (the maker's own number), else what was saved when the
386
+ * model was chosen, else devx's table, rules and default.
387
+ */
388
+ function resolveContextLimit(config) {
389
+ const own = config.contextLimits?.[config.model];
390
+ if (typeof own === 'number' && own >= 1024)
391
+ return { limit: own, source: 'config' };
392
+ const info = getModelContextInfo(config.model);
393
+ if (info.source === 'catalogue')
394
+ return info;
395
+ if (typeof config.maxContextTokens === 'number' && config.maxContextTokens > 0) {
396
+ return config.maxContextTokens === info.limit ? info : { limit: config.maxContextTokens, source: 'saved' };
397
+ }
398
+ return info;
399
+ }
329
400
  async function loadConfig(interactive = true) {
330
401
  let globalConfig = {};
331
402
  try {
332
403
  const data = await fs.readFile(CONFIG_PATH, 'utf8');
333
- globalConfig = JSON.parse(data);
404
+ globalConfig = withoutSessionOnlySettings(JSON.parse(data));
405
+ setLanguage(globalConfig.language); // the questions below are asked in the chosen language
334
406
  if (!globalConfig.model || !globalConfig.baseUrl)
335
407
  throw new Error('Invalid config');
336
- globalConfig.maxContextTokens = globalConfig.maxContextTokens || getModelContextLimit(globalConfig.model);
408
+ globalConfig.maxContextTokens = resolveContextLimit(globalConfig).limit;
337
409
  globalConfig.apiKeys = globalConfig.apiKeys || {};
338
410
  globalConfig.baseUrls = globalConfig.baseUrls || {};
339
411
  globalConfig.trustedProjects = globalConfig.trustedProjects || {};
@@ -371,59 +443,57 @@ async function loadConfig(interactive = true) {
371
443
  // Workspace & Project Trust Check on first time opening this directory
372
444
  if (isTrusted === undefined) {
373
445
  if (interactive) {
374
- console.log('');
375
- p.log.warn(pc.bold(pc.yellow(`🛡️ [WORKSPACE TRUST] First time opening this workspace:`)));
376
- console.log(pc.dim(` Directory: ${cwd}`));
377
- if (localConfig) {
378
- if (localConfig.autoApprove === true) {
379
- console.log(pc.yellow(` • Local .devx.json requests Auto-approval (YOLO mode)`));
380
- }
381
- if (localConfig.bashAllowlist) {
382
- console.log(pc.yellow(` • Local .devx.json requests Bash allowlist: ${localConfig.bashAllowlist.join(', ')}`));
383
- }
384
- }
385
- const answer = await p.confirm({
386
- message: 'Do you trust this workspace and allow agent operations?',
387
- initialValue: true
446
+ const trusted = await askConfirm({
447
+ title: t('trust.title'),
448
+ question: t('trust.question'),
449
+ details: [cwd, ...(localConfig?.autoApprove === true ? [t('trust.wantsYolo')] : []), ...(localConfig?.bashAllowlist ? [t('trust.wantsAllowlist', { list: localConfig.bashAllowlist.join(', ') })] : [])],
450
+ yes: t('trust.yes'),
451
+ no: t('trust.no'),
388
452
  });
389
- if (p.isCancel(answer) || answer !== true) {
453
+ if (!trusted) {
390
454
  resetTerminalTheme();
391
- p.outro(pc.yellow('Workspace not trusted. Exiting devx.'));
455
+ printFarewell(t('trust.exiting'), 'warn');
392
456
  process.exit(0);
393
457
  }
394
458
  globalConfig.trustedProjects = globalConfig.trustedProjects || {};
395
459
  globalConfig.trustedProjects[cwd] = true;
396
460
  await saveConfig(globalConfig);
397
- p.log.success(pc.green('Workspace marked as trusted. Full capabilities enabled.'));
461
+ isTrusted = true;
462
+ log.success(pc.green('Workspace marked as trusted. Full capabilities enabled.'));
398
463
  }
399
464
  else {
400
465
  isTrusted = false; // Never auto-trust in non-interactive headless mode
401
466
  }
402
467
  }
403
- if (localConfig && typeof localConfig === 'object') {
404
- // Apply safe configuration overrides
405
- if (localConfig.model)
406
- effectiveConfig.model = localConfig.model;
407
- if (localConfig.provider)
408
- effectiveConfig.provider = localConfig.provider;
409
- if (localConfig.apiKey)
410
- effectiveConfig.apiKey = localConfig.apiKey;
411
- if (localConfig.baseUrl)
412
- effectiveConfig.baseUrl = localConfig.baseUrl;
413
- if (localConfig.maxIterations)
414
- effectiveConfig.maxIterations = localConfig.maxIterations;
415
- if (localConfig.maxContextTokens)
416
- effectiveConfig.maxContextTokens = localConfig.maxContextTokens;
417
- if (localConfig.dataSaverLimitMB)
418
- effectiveConfig.dataSaverLimitMB = localConfig.dataSaverLimitMB;
419
- if (localConfig.pureBlackTheme !== undefined)
420
- effectiveConfig.pureBlackTheme = localConfig.pureBlackTheme;
421
- // Apply elevated permissions only if explicitly trusted
422
- if (isTrusted) {
423
- if (localConfig.autoApprove !== undefined)
424
- effectiveConfig.autoApprove = localConfig.autoApprove;
425
- if (Array.isArray(localConfig.bashAllowlist))
426
- effectiveConfig.bashAllowlist = localConfig.bashAllowlist;
468
+ // Provider / API key / permission settings from the repository only count in a trusted workspace.
469
+ const ignoredLocalSettings = applyLocalConfig(effectiveConfig, localConfig, isTrusted === true);
470
+ if (ignoredLocalSettings.length > 0) {
471
+ console.error(pc.yellow(`Ignored ${ignoredLocalSettings.join(', ')} from the local project config: this workspace is not trusted.`));
472
+ }
473
+ // MCP servers declared by the project are arbitrary commands. They run only if the
474
+ // user approved exactly this set of commands (re-asked whenever the files change).
475
+ effectiveConfig.projectMcpTrusted = false;
476
+ const projectMcpServers = discoverProjectMcpServers(cwd);
477
+ if (projectMcpServers.length > 0) {
478
+ const fingerprint = hashProjectMcpServers(projectMcpServers);
479
+ globalConfig.trustedMcp = globalConfig.trustedMcp || {};
480
+ if (globalConfig.trustedMcp[cwd] === fingerprint) {
481
+ effectiveConfig.projectMcpTrusted = true;
482
+ }
483
+ else if (interactive) {
484
+ const listing = projectMcpServers.map((srv) => `${srv.name} (${srv.file}) ${`${srv.config.command} ${(srv.config.args || []).join(' ')}`.trim()}`);
485
+ const allow = await askConfirm({ title: 'mcp', question: t('trust.mcpQuestion'), details: listing });
486
+ if (allow) {
487
+ globalConfig.trustedMcp[cwd] = fingerprint;
488
+ await saveConfig(globalConfig);
489
+ effectiveConfig.projectMcpTrusted = true;
490
+ }
491
+ else {
492
+ console.log(renderNotice('info', 'Project MCP servers skipped. Your own servers still start.'));
493
+ }
494
+ }
495
+ else {
496
+ console.error(pc.yellow(`Skipped ${projectMcpServers.length} project MCP server(s): not approved yet. Run devx interactively in this folder once to review and approve them.`));
427
497
  }
428
498
  }
429
499
  // Configure Data Saver threshold
@@ -452,7 +522,8 @@ process.on('exit', () => {
452
522
  if (process.stdin.isTTY) {
453
523
  process.stdin.setRawMode(false);
454
524
  }
455
- process.stdout.write('\x1B[?25h');
525
+ if (process.stdout.isTTY)
526
+ process.stdout.write(EXTENDED_KEYS_OFF + '\x1B[?1004l' + RESTORE_TERMINAL_TITLE + '\x1B[?25h');
456
527
  resetTerminalTheme();
457
528
  });
458
529
  process.on('SIGINT', () => {
@@ -464,7 +535,7 @@ process.on('SIGINT', () => {
464
535
  process.stdin.setRawMode(false);
465
536
  process.stdin.pause();
466
537
  }
467
- process.stdout.write('\x1B[?25h'); // restore cursor
538
+ process.stdout.write(EXTENDED_KEYS_OFF + '\x1B[?1004l' + RESTORE_TERMINAL_TITLE + '\x1B[?25h'); // restore cursor
468
539
  resetTerminalTheme();
469
540
  process.exit(0);
470
541
  }
@@ -478,9 +549,24 @@ function clearTerminalScreen() {
478
549
  console.clear();
479
550
  }
480
551
  }
552
+ /** The model name and its reasoning setting: "o3 · think high", "mimo-v2.6-flash · think auto". */
553
+ function modelLabel(config) {
554
+ const think = thinkLabel(config);
555
+ return think ? `${config.model} · ${think}` : config.model;
556
+ }
557
+ // Registered by main() once the config exists; lets every screen redraw show the live
558
+ // model / mode / folder in the header card.
559
+ let headerProvider = null;
481
560
  function drawLogo(bannerMode = 'full') {
482
561
  const cols = process.stdout.columns || 80;
483
562
  clearTerminalScreen();
563
+ if (bannerMode === 'clean' && headerProvider) {
564
+ console.log();
565
+ for (const line of renderHeader(headerProvider(), cols))
566
+ console.log(line);
567
+ console.log();
568
+ return;
569
+ }
484
570
  drawBanner(bannerMode, DEVX_VERSION, cols);
485
571
  }
486
572
  function formatSessionTime(timestamp) {
@@ -528,7 +614,7 @@ async function handleSessionDelete(bannerMode) {
528
614
  const sessions = await SessionManager.listSessions();
529
615
  if (sessions.length === 0) {
530
616
  drawLogo(bannerMode);
531
- p.log.warn('No saved sessions found.');
617
+ log.warn('No saved sessions found.');
532
618
  break;
533
619
  }
534
620
  let chosenSessionId;
@@ -538,15 +624,15 @@ async function handleSessionDelete(bannerMode) {
538
624
  source: async (term) => {
539
625
  const q = (term || '').trim().toLowerCase();
540
626
  const list = [
541
- { name: pc.yellow('⬅️ Done / Cancel'), value: '__cancel__', description: 'Return to chat' },
542
- { name: pc.red('🗑️ Delete ALL sessions'), value: '__all__', description: `Remove all ${sessions.length} saved sessions` }
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` }
543
629
  ];
544
630
  for (const s of sessions) {
545
631
  const timeStr = formatSessionTime(s.updatedAt);
546
632
  const msgCount = s.messages.filter(m => m.role !== 'system').length;
547
633
  const shortId = s.id.split('_').pop();
548
634
  list.push({
549
- name: `${pc.red('🗑️ ')} ${pc.dim(`[#${shortId}]`)} ${s.title}`,
635
+ name: `${pc.red('× ')} ${pc.dim(`[#${shortId}]`)} ${s.title}`,
550
636
  value: s.id,
551
637
  description: `${timeStr} • ${msgCount} msgs • ${s.model || 'default'}`
552
638
  });
@@ -563,18 +649,15 @@ async function handleSessionDelete(bannerMode) {
563
649
  }
564
650
  if (!chosenSessionId || chosenSessionId === '__cancel__') {
565
651
  drawLogo(bannerMode);
566
- p.log.info('Session deletion finished.');
652
+ log.info('Session deletion finished.');
567
653
  break;
568
654
  }
569
655
  if (chosenSessionId === '__all__') {
570
- const confirmAll = await p.confirm({
571
- message: `Are you sure you want to delete ALL ${sessions.length} saved sessions?`,
572
- initialValue: false
573
- });
574
- if (!p.isCancel(confirmAll) && confirmAll) {
656
+ const confirmAll = await askConfirm({ title: 'sessions', question: t('confirm.deleteAll', { n: sessions.length }) });
657
+ if (confirmAll) {
575
658
  const count = await SessionManager.deleteAllSessions();
576
659
  drawLogo(bannerMode);
577
- p.log.success(`Deleted all ${count} sessions.`);
660
+ log.success(`Deleted all ${count} sessions.`);
578
661
  break;
579
662
  }
580
663
  continue;
@@ -584,173 +667,89 @@ async function handleSessionDelete(bannerMode) {
584
667
  const ok = await SessionManager.deleteSession(chosenSessionId);
585
668
  drawLogo(bannerMode);
586
669
  if (ok) {
587
- p.log.success(`Deleted session: "${title}"`);
670
+ log.success(`Deleted session: "${title}"`);
588
671
  }
589
672
  else {
590
- p.log.error(`Failed to delete session: "${title}"`);
673
+ log.error(`Failed to delete session: "${title}"`);
591
674
  }
592
675
  }
593
676
  }
594
677
  async function handleThemeSelect(config) {
595
678
  const currentTh = getCurrentTheme();
596
679
  const themeChoices = listThemes().map(t => ({
597
- name: `${t.emoji} ${t.boldFn(t.name.padEnd(18))} ${pc.dim(t.desc)} ${t.id === currentTh.id ? pc.green('(Active)') : ''}`,
598
- value: t.id,
599
- description: `Apply ${t.name} color palette (${t.hex}) to banners, prompts, and actions`
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
+ }
600
686
  }));
601
687
  try {
602
- const selected = await select({
603
- message: `${pc.bold('🎨 Select UI Theme / Выберите цветовую тему:')}`,
604
- choices: themeChoices
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
+ }
605
697
  });
606
698
  if (selected) {
607
699
  config.theme = selected;
608
700
  await saveConfig(config);
609
701
  const th = setActiveTheme(selected);
610
- drawLogo(config.banner || 'full');
611
- p.log.success(th.boldFn(`🎨 Theme switched to ${th.emoji} ${th.name}!`));
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}`));
612
707
  }
613
708
  }
614
709
  catch { }
615
710
  }
616
711
  async function handleSettings(config) {
617
- while (true) {
618
- try {
619
- const maxIter = config.maxIterations || 100;
620
- const maxIterLabel = maxIter >= 9999 ? 'Unlimited' : `${maxIter} steps`;
621
- const currentTh = getCurrentTheme();
622
- const choice = await select({
623
- message: `${pc.bold('⚙️ Settings')} ${pc.dim(`(devx v${DEVX_VERSION} • theme: ${currentTh.name})`)}`,
624
- choices: [
625
- {
626
- name: `🎨 Color Theme: ${currentTh.emoji} ${currentTh.name}`,
627
- value: 'change_theme',
628
- description: `Switch UI accent colors (${currentTh.desc})`
629
- },
630
- {
631
- name: `Interface: ${config.ui?.layout === 'classic' ? 'Classic' : 'Clean'}`,
632
- value: 'toggle_layout',
633
- description: 'Switch between the compact layout and the original terminal UI'
634
- },
635
- {
636
- name: `${config.pureBlackTheme !== false ? pc.green('🖤 Pure Black Background: ON') : pc.yellow('🖤 Pure Black Background: OFF')}`,
637
- value: 'toggle_black_theme',
638
- description: config.pureBlackTheme !== false
639
- ? 'Apply deep OLED obsidian black background (#0a0a0c) like OpenCode'
640
- : 'Use standard system terminal background color'
641
- },
642
- {
643
- name: `${config.autoApprove ? pc.green('⚡ Auto-Approve (YOLO Mode): ON') : pc.yellow('🛡️ Auto-Approve (YOLO Mode): OFF')}`,
644
- value: 'toggle_auto_approve',
645
- description: config.autoApprove
646
- ? 'Permissions are automatically granted (no confirmation prompts for commands/files)'
647
- : 'Agent asks for confirmation before executing bash commands or writing files'
648
- },
649
- {
650
- name: `${config.enableMemory !== false ? pc.green('🧠 Project Memory Bank: ON') : pc.yellow('🧠 Project Memory Bank: OFF')}`,
651
- value: 'toggle_memory',
652
- description: config.enableMemory !== false
653
- ? 'Load persistent project rules and preferences from .devx/memory.md into AI context'
654
- : 'Start sessions with a clean state without loading project memory'
655
- },
656
- {
657
- name: `${config.checkUpdates !== false ? pc.green('🔔 Check for Updates on Startup: ON') : pc.yellow('🔔 Check for Updates on Startup: OFF')}`,
658
- value: 'toggle_check_updates',
659
- description: config.checkUpdates !== false
660
- ? 'Automatically check for updates from GitHub repository when launching devx'
661
- : 'Disable update checking on startup (run /update manually instead)'
662
- },
663
- {
664
- name: `🔄 Max Agent Iterations: ${currentTh.colorFn(maxIterLabel)}`,
665
- value: 'change_max_iterations',
666
- description: 'Limit how many tool steps (file edits, terminal commands) agent can do per request'
667
- },
668
- {
669
- name: `${currentTh.colorFn('✨ About devx')} ${pc.dim(`(v${DEVX_VERSION} by ApvCode)`)}`,
670
- value: 'about',
671
- description: 'Terminal-Native AI Coding Agent created by ApvCode (https://github.com/apvcode/Termux-Dev)'
672
- },
673
- {
674
- name: '⬅️ Back / Save',
675
- value: 'back',
676
- description: 'Return to chat'
677
- }
678
- ]
679
- });
680
- if (choice === 'change_theme') {
681
- await handleThemeSelect(config);
682
- continue;
683
- }
684
- if (choice === 'toggle_layout') {
685
- config.ui = config.ui || {};
686
- config.ui.layout = config.ui.layout === 'classic' ? 'clean' : 'classic';
687
- config.banner = config.ui.layout === 'clean' ? 'clean' : 'full';
688
- await saveConfig(config);
689
- drawLogo(config.banner);
690
- continue;
691
- }
692
- if (choice === 'about') {
693
- p.note(`⚡ devx v${DEVX_VERSION} — Terminal-Native AI Coding Agent\n` +
694
- `🎨 Theme: ${currentTh.emoji} ${currentTh.name}\n` +
695
- `👤 Author: ApvCode (https://github.com/apvcode)\n` +
696
- `🌟 Repository: https://github.com/apvcode/Termux-Dev\n` +
697
- `📜 License: MIT License (2026)\n` +
698
- `Built for Android Termux, Windows, macOS, and Linux.`, 'About devx');
699
- continue;
700
- }
701
- if (choice === 'toggle_black_theme') {
702
- config.pureBlackTheme = config.pureBlackTheme === false ? true : false;
703
- await saveConfig(config);
704
- if (config.pureBlackTheme) {
712
+ const initial = {
713
+ themeId: getCurrentTheme().id,
714
+ layout: config.ui?.layout === 'classic' ? 'classic' : 'clean',
715
+ pureBlack: config.pureBlackTheme !== false,
716
+ autoApprove: !!config.autoApprove,
717
+ maxIterations: config.maxIterations || 100,
718
+ memory: config.enableMemory !== false,
719
+ updates: config.checkUpdates !== false,
720
+ costLimit: Number(config.maxCostPerSession) || 0,
721
+ language: getLanguage(),
722
+ };
723
+ // One panel, redrawn in place; every change is visible at once and saved when it closes.
724
+ const final = await runSettings(initial, {
725
+ version: DEVX_VERSION,
726
+ onChange: (key, state) => {
727
+ if (key === 'theme')
728
+ setActiveTheme(state.themeId);
729
+ if (key === 'language')
730
+ setLanguage(state.language);
731
+ if (key === 'memory')
732
+ MemoryManager.enabled = state.memory;
733
+ if (key === 'pureBlack') {
734
+ if (state.pureBlack)
705
735
  enableDarkTheme(true);
706
- }
707
- else {
736
+ else
708
737
  resetTerminalTheme();
709
- }
710
- drawLogo(config.banner || 'full');
711
- p.log.success(`Pure Black background: ${config.pureBlackTheme ? pc.bold(pc.green('ON (Deep Black)')) : pc.bold(pc.yellow('OFF (System Default)'))}`);
712
- continue;
713
- }
714
- if (choice === 'toggle_auto_approve') {
715
- config.autoApprove = !config.autoApprove;
716
- await saveConfig(config);
717
- p.log.success(`Auto-approve permissions: ${config.autoApprove ? pc.bold(pc.green('ON (Automatic Yes)')) : pc.bold(pc.yellow('OFF (Ask every time)'))}`);
718
- continue;
719
- }
720
- if (choice === 'toggle_memory') {
721
- config.enableMemory = config.enableMemory === false ? true : false;
722
- await saveConfig(config);
723
- p.log.success(`Project memory bank: ${config.enableMemory !== false ? pc.bold(pc.green('ON (Persistent .devx/memory.md)')) : pc.bold(pc.yellow('OFF'))}`);
724
- continue;
725
- }
726
- if (choice === 'toggle_check_updates') {
727
- config.checkUpdates = config.checkUpdates === false ? true : false;
728
- await saveConfig(config);
729
- p.log.success(`Check for updates: ${config.checkUpdates !== false ? pc.bold(pc.green('ON (Checked on startup)')) : pc.bold(pc.yellow('OFF (Manual only)'))}`);
730
- continue;
731
- }
732
- if (choice === 'change_max_iterations') {
733
- const val = await select({
734
- message: 'Select maximum iterations limit per prompt:',
735
- choices: [
736
- { name: '30 steps (Strict / Safe)', value: 30 },
737
- { name: '50 steps (Moderate)', value: 50 },
738
- { name: '100 steps (Recommended / Default)', value: 100 },
739
- { name: '200 steps (Very large refactors)', value: 200 },
740
- { name: 'Unlimited (No limit)', value: 9999 }
741
- ]
742
- });
743
- config.maxIterations = val;
744
- await saveConfig(config);
745
- p.log.success(`Max iterations updated to: ${pc.bold(val >= 9999 ? 'Unlimited' : `${val} steps`)}`);
746
- continue;
747
738
  }
748
- break;
749
- }
750
- catch {
751
- break;
752
- }
753
- }
739
+ },
740
+ });
741
+ config.theme = final.themeId;
742
+ config.ui = config.ui || {};
743
+ config.ui.layout = final.layout;
744
+ config.banner = final.layout === 'clean' ? (config.banner === 'off' ? 'off' : 'clean') : 'full';
745
+ config.pureBlackTheme = final.pureBlack;
746
+ config.autoApprove = final.autoApprove;
747
+ config.maxIterations = final.maxIterations;
748
+ config.enableMemory = final.memory;
749
+ config.checkUpdates = final.updates;
750
+ config.language = final.language;
751
+ config.maxCostPerSession = final.costLimit;
752
+ await saveConfig(config);
754
753
  process.stdin.resume();
755
754
  return config;
756
755
  }
@@ -776,10 +775,10 @@ export async function main() {
776
775
  const isIgnorableIo = err.code === 'EIO' || err.code === 'EPIPE' || err.code === 'ERR_STREAM_DESTROYED';
777
776
  if (isIgnorableIo)
778
777
  return;
779
- console.error('\n[devx runtime error]:', err.message);
778
+ reportRuntimeError(err, activeAbortHandler);
780
779
  });
781
780
  process.on('unhandledRejection', (reason) => {
782
- // Prevent Node.js crash from unhandled network promise rejections
781
+ reportRuntimeError(reason, activeAbortHandler);
783
782
  });
784
783
  acquireWakeLock();
785
784
  const program = new Command();
@@ -793,53 +792,96 @@ export async function main() {
793
792
  .option('-q, --quiet', 'Quiet output in headless mode (suppress banners and tool logs)')
794
793
  .option('--json', 'Output structured JSON in headless mode')
795
794
  .option('--plan', 'Start in plan mode (architect & planner)')
795
+ .option('-e, --effort <level>', 'Reasoning effort for this run: off, low, normal, high, xhigh, max, or auto')
796
796
  .parse(process.argv);
797
797
  const options = program.opts();
798
798
  const planModeInitial = !!options.plan;
799
799
  const isHeadless = !!options.prompt;
800
800
  let config = await loadConfig(!isHeadless);
801
+ setLanguage(config.language);
802
+ // What each model offers for reasoning: the stored answers of the endpoint and the catalogue.
803
+ installCapabilities();
804
+ probeEndpointsAllowed = config.probeEndpoints !== false;
801
805
  config.banner = config.ui?.layout === 'classic'
802
806
  ? config.banner || 'full'
803
807
  : config.banner === 'off' ? 'off' : 'clean';
804
808
  if (options.model) {
805
809
  config.model = options.model;
806
- config.maxContextTokens = getModelContextLimit(options.model);
810
+ config.maxContextTokens = resolveContextLimit({ ...config, maxContextTokens: 0 }).limit;
807
811
  }
808
812
  if (options.yolo) {
809
813
  config.autoApprove = true;
810
814
  }
815
+ if (options.effort !== undefined) {
816
+ // One run only (never saved). Anything the model does not offer is ignored with a warning.
817
+ // A custom endpoint is asked about the model first (cached, a few seconds at most).
818
+ await refreshEndpoint(config).catch(() => null);
819
+ const parsed = parseEffortArg(String(options.effort));
820
+ const { caps } = effectiveReasoning({ ...config, effortOverride: undefined });
821
+ if (parsed.kind === 'invalid') {
822
+ console.error(pc.yellow(`--effort: unknown level "${parsed.text}". Use off, low, normal, high, xhigh, max or auto.`));
823
+ }
824
+ else if (parsed.kind === 'auto') {
825
+ config.effortOverride = '';
826
+ }
827
+ else if (!caps.supported) {
828
+ console.error(pc.yellow(`--effort ignored: ${config.model} has no reasoning setting.`));
829
+ }
830
+ else if (!caps.levels.includes(parsed.level)) {
831
+ console.error(pc.yellow(`--effort ignored: ${config.model} does not offer "${parsed.level}" (it offers ${caps.levels.join(', ')}).`));
832
+ }
833
+ else {
834
+ config.effortOverride = parsed.level;
835
+ }
836
+ }
811
837
  // If -p / --prompt is provided, execute one-shot headless mode and exit!
812
838
  if (isHeadless) {
839
+ const abortController = new AbortController();
840
+ activeAbortHandler = () => abortController.abort();
813
841
  const exitCode = await runHeadlessMode(options.prompt, config, {
814
842
  planMode: planModeInitial,
815
843
  yolo: !!options.yolo,
816
844
  quiet: !!options.quiet,
817
- json: !!options.json
845
+ json: !!options.json,
846
+ signal: abortController.signal
818
847
  });
848
+ activeAbortHandler = null;
819
849
  process.exit(exitCode);
820
850
  }
821
851
  let planMode = planModeInitial;
852
+ headerProvider = () => {
853
+ const git = GitStatusCache.getStatus(true);
854
+ return {
855
+ version: DEVX_VERSION,
856
+ model: modelLabel(config),
857
+ provider: config.provider,
858
+ cwd: process.cwd(),
859
+ branch: git?.branch,
860
+ dirty: git?.isDirty,
861
+ yolo: !!config.autoApprove,
862
+ tip: pickTip(),
863
+ };
864
+ };
822
865
  if (!config.onboarded) {
823
- const cols = Math.min(process.stdout.columns || 40, 42);
824
- const fill = Math.max(2, cols - 16);
825
- const topBorder = '┌─ preview.ts ' + '─'.repeat(fill) + '┐';
826
- const bottomBorder = '└' + '─'.repeat(cols - 2) + '┘';
827
- const themeChoices = listThemes().map(t => {
828
- const addLine = t.diffAddBg(` + 1 | const theme = "${t.id}";`.padEnd(cols - 2));
829
- const remLine = t.diffRemoveBg(` - 2 | const theme = "none";`.padEnd(cols - 2));
830
- const preview = `${t.colorFn(topBorder)}\n${addLine}\n${remLine}\n${t.colorFn(bottomBorder)}\n\n${pc.dim('💡 You can change this anytime with /theme')}`;
831
- return {
832
- name: `${t.emoji} ${t.boldFn(t.name.padEnd(18))} ${pc.dim(t.desc)}`,
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)}`,
833
870
  value: t.id,
834
- description: preview
835
- };
836
- });
871
+ description: `${renderThemePreview(t)}\n\n${pc.dim('You can change this anytime with /theme')}`
872
+ }
873
+ }));
837
874
  try {
838
875
  clearTerminalScreen();
839
876
  const selected = await search({
840
- message: `${pc.bold('🎨 Pick a theme to personalize your workspace:')}`,
841
- source: async () => themeChoices,
842
- pageSize: 6
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
843
885
  });
844
886
  if (selected) {
845
887
  config.theme = selected;
@@ -854,19 +896,86 @@ export async function main() {
854
896
  }
855
897
  enableDarkTheme(config.pureBlackTheme !== false);
856
898
  let history = new History();
899
+ MemoryManager.enabled = config.enableMemory !== false;
857
900
  const sysPrompt = await buildSystemPrompt(planMode);
858
901
  history.addMessage({ role: 'system', content: sysPrompt });
859
902
  const sessionManager = new SessionManager(config.model, planMode);
860
903
  drawLogo(config.banner || 'full');
861
904
  await runStartupUpdateCheck(config);
862
- await MCPManager.getInstance().init();
863
- let totalSessionCost = 0;
905
+ await MCPManager.getInstance().init(undefined, config.projectMcpTrusted === true);
906
+ // Ask a custom endpoint what the current model can do (cached for a day; never blocks the prompt).
907
+ void refreshEndpoint(config).catch(() => null);
908
+ const sessionCost = new CostLedger();
909
+ const currentContext = () => contextBreakdown(history.getMessages(), getTools(planMode).map(t => t.definition), config.maxContextTokens || getModelContextLimit(config.model));
864
910
  let currentDraft = '';
865
911
  let autoTriggerPrompt = '';
912
+ // ctrl+p and ctrl+o while the agent works: reasoning and tool output, collapsed unless switched on.
913
+ let thinkingExpanded = false;
914
+ let outputExpanded = false;
915
+ // Everything said on screen so far, kept as items: ctrl+p and ctrl+o draw it again, folded or unfolded.
916
+ const transcript = [];
917
+ // Snapshots of the working folder before each turn of the agent (git): /undo puts back what changed, also by shell commands.
918
+ const checkpoints = [];
919
+ // What the user allowed on the approval card for this session ("Session"); "Always" goes to the config.
920
+ const permissionMemory = newPermissionMemory();
921
+ // The most a session may cost (USD, 0 = no limit); "continue" at the limit raises it for this session only.
922
+ let costLimitRaised = null;
923
+ let costWarned = false;
924
+ const costLimit = () => costLimitRaised ?? (Number(config.maxCostPerSession) || 0);
925
+ for (const name of (config.allowedTools || []))
926
+ permissionMemory.tools.add(name);
927
+ // The name of the chat: in the title of the terminal window (`◈ Short title`) and in the list of sessions.
928
+ let windowTitle = '';
929
+ let titleAttempts = 0;
930
+ let terminalFocused = true; // updated by the terminal's focus reports while a task runs
931
+ const setWindowTitle = (title) => {
932
+ windowTitle = title;
933
+ if (process.stdout.isTTY)
934
+ process.stdout.write(terminalTitleSequence(title));
935
+ };
936
+ if (process.stdout.isTTY)
937
+ process.stdout.write(SAVE_TERMINAL_TITLE);
938
+ /** Clears the screen and draws the whole conversation again, folded or unfolded as the switches say. */
939
+ const redrawChat = () => {
940
+ drawLogo(config.banner || 'full');
941
+ const rows = renderTranscript(transcript, { cols: process.stdout.columns || 80, thinkingExpanded, outputExpanded, diffColors: !!config.ui?.diffColors });
942
+ if (rows.length > 0)
943
+ console.log(rows.join('\n'));
944
+ };
945
+ // /clear and /new: leave the current conversation (it stays saved and can be resumed)
946
+ // and start from a clean slate: history, cost, usage counters, undo stack, screen.
947
+ const startFreshSession = async () => {
948
+ sessionManager.startNewSession(config.model, planMode);
949
+ transcript.length = 0;
950
+ checkpoints.length = 0;
951
+ windowTitle = '';
952
+ titleAttempts = 0;
953
+ if (process.stdout.isTTY)
954
+ process.stdout.write(RESTORE_TERMINAL_TITLE + SAVE_TERMINAL_TITLE);
955
+ history = new History();
956
+ history.addMessage({ role: 'system', content: await buildSystemPrompt(planMode) });
957
+ sessionCost.reset();
958
+ costLimitRaised = null;
959
+ costWarned = false;
960
+ currentDraft = '';
961
+ UsageTracker.getInstance().reset();
962
+ globalSnapshotManager.reset();
963
+ GitStatusCache.invalidate();
964
+ drawLogo(config.banner || 'full');
965
+ };
966
+ const announce = (message) => {
967
+ if (config.ui?.layout === 'classic')
968
+ log.success(message);
969
+ else
970
+ console.log(renderNotice('ok', message));
971
+ };
866
972
  while (true) {
867
- const currentTokens = history.getConversationTokens();
868
973
  const maxTokens = config.maxContextTokens || getModelContextLimit(config.model);
869
974
  config.maxContextTokens = maxTokens;
975
+ // The conversation only: the system prompt and tool schemas are fixed overhead that every
976
+ // chat has, so an empty chat shows 0%. /usage shows the full picture.
977
+ const ctxNow = currentContext();
978
+ const currentTokens = ctxNow.total - ctxNow.system - ctxNow.tools;
870
979
  const usagePercent = Math.min(100, Math.round((currentTokens / maxTokens) * 100));
871
980
  const formatTokens = (n) => {
872
981
  if (n >= 1_000_000)
@@ -875,21 +984,13 @@ export async function main() {
875
984
  return `${(n / 1000).toFixed(1)}k`;
876
985
  return `${n}`;
877
986
  };
878
- const costStr = totalSessionCost > 0 ? `Cost: $${totalSessionCost.toFixed(4)}` : 'Cost: $0.0000';
987
+ const costStr = `Cost: ${sessionCost.format()}`;
879
988
  const tokenStats = `Context: ${formatTokens(currentTokens)} / ${formatTokens(maxTokens)} (${usagePercent}%) • ${costStr}`;
880
989
  const cols = process.stdout.columns || 80;
881
990
  const modeName = planMode ? 'PLAN' : 'AGENT';
882
991
  const theme = getCurrentTheme();
883
992
  if (config.ui?.layout !== 'classic') {
884
- console.log(renderCleanStatus({
885
- mode: modeName,
886
- model: config.model,
887
- currentTokens,
888
- maxTokens,
889
- cost: totalSessionCost,
890
- cols,
891
- isYolo: !!config.autoApprove
892
- }) + '\n');
993
+ // Clean layout: mode, model, context and cost live in the borders of the prompt frame.
893
994
  }
894
995
  else if (config.ui?.powerlineStatus) {
895
996
  const statusLine = renderPowerlineStatus({
@@ -897,7 +998,7 @@ export async function main() {
897
998
  model: config.model,
898
999
  currentTokens,
899
1000
  maxTokens,
900
- cost: totalSessionCost,
1001
+ cost: sessionCost.total,
901
1002
  cols,
902
1003
  maxCostUSD: config.maxCostUSD,
903
1004
  isYolo: !!config.autoApprove
@@ -930,38 +1031,53 @@ export async function main() {
930
1031
  autoTriggerPrompt = '';
931
1032
  console.log(config.ui?.layout === 'classic'
932
1033
  ? theme.colorFn('◆') + ' ' + pc.bold(pc.white(answer))
933
- : pc.cyan('›') + ' ' + answer);
1034
+ : renderUserEcho(answer, cols).join('\n'));
934
1035
  }
935
1036
  else {
936
1037
  const inputStr = await askPrompt({
937
1038
  message: 'Ask anything...',
938
1039
  placeholder: config.ui?.layout === 'classic'
939
1040
  ? 'Fix a TODO, type /help, or press Tab to switch mode'
940
- : 'Ask or describe a task…',
1041
+ : t('composer.placeholder'),
941
1042
  initialValue: currentDraft,
1043
+ // Between turns the same keys work at the prompt: the chat above is drawn again.
1044
+ onViewToggle: (kind) => {
1045
+ if (kind === 'thinking')
1046
+ thinkingExpanded = !thinkingExpanded;
1047
+ else
1048
+ outputExpanded = !outputExpanded;
1049
+ redrawChat();
1050
+ },
942
1051
  planMode,
943
1052
  isYolo: !!config.autoApprove,
944
- ui: config.ui
1053
+ ui: config.ui,
1054
+ model: modelLabel(config),
1055
+ cwd: process.cwd(),
1056
+ branch: GitStatusCache.getStatus()?.branch,
1057
+ dirty: GitStatusCache.getStatus()?.isDirty,
1058
+ contextPercent: usagePercent,
1059
+ cost: sessionCost.total,
1060
+ costState: sessionCost.state()
945
1061
  });
946
1062
  if (inputStr.startsWith('__TOGGLE_MODE__:')) {
947
1063
  currentDraft = inputStr.slice('__TOGGLE_MODE__:'.length);
948
1064
  planMode = !planMode;
949
1065
  const newSys = await buildSystemPrompt(planMode);
950
1066
  history.updateSystemPrompt(newSys);
951
- p.log.info(planMode
952
- ? pc.bold(pc.cyan('🔄 Mode: PLAN (Architect & Planner) — Press Tab to switch to AGENT'))
953
- : pc.bold(pc.green('🔄 Mode: AGENT (Coder & Executor) — Press Tab to switch to PLAN')));
1067
+ // Clean layout: the prompt frame changes colour and label, no extra line needed.
1068
+ if (config.ui?.layout === 'classic') {
1069
+ log.info(planMode
1070
+ ? pc.bold(pc.cyan('Mode: PLAN (Architect & Planner) — Press Tab to switch to AGENT'))
1071
+ : pc.bold(pc.green('Mode: AGENT (Coder & Executor) — Press Tab to switch to PLAN')));
1072
+ }
954
1073
  continue;
955
1074
  }
956
1075
  currentDraft = '';
957
1076
  if (inputStr === '__CANCEL__') {
958
- const confirmExit = await p.confirm({
959
- message: 'Are you sure you want to exit devx?',
960
- initialValue: false
961
- });
962
- if (!p.isCancel(confirmExit) && confirmExit === true) {
1077
+ const confirmExit = await askConfirm({ title: t('confirm.exitTitle'), question: t('confirm.exit'), record: false });
1078
+ if (confirmExit) {
963
1079
  resetTerminalTheme();
964
- p.outro('Goodbye!');
1080
+ printFarewell(t('confirm.goodbye'));
965
1081
  break;
966
1082
  }
967
1083
  else {
@@ -981,16 +1097,16 @@ export async function main() {
981
1097
  const cmdArgs = answer.slice(cmd.length).trim();
982
1098
  const expandedPrompt = CustomCommandManager.expandTemplate(customCmd.promptTemplate, cmdArgs);
983
1099
  answer = expandedPrompt;
984
- p.log.info(pc.cyan(`⚡ Executing custom command: ${pc.bold(customCmd.cmd)} (${customCmd.desc})`));
1100
+ log.info(pc.cyan(`Executing custom command: ${pc.bold(customCmd.cmd)} (${customCmd.desc})`));
985
1101
  }
986
1102
  else {
987
1103
  const VALID_COMMANDS = [
988
1104
  '/new', '/reset', '/resume', '/session', '/sessions', '/history',
989
1105
  '/theme', '/themes', '/usage', '/export', '/mcp',
990
1106
  '/settings', '/update', '/model', '/provider', '/providers',
991
- '/plan', '/agent', '/image', '/serve', '/memory', '/undo',
1107
+ '/plan', '/agent', '/image', '/serve', '/memory', '/permissions', '/copy', '/rename', '/title', '/rewind', '/undo',
992
1108
  '/diff', '/commit', '/status', '/compact', '/init', '/doctor',
993
- '/config', '/clear', '/exit', '/quit', '/help'
1109
+ '/config', '/clear', '/exit', '/quit', '/help', '/effort'
994
1110
  ];
995
1111
  if (!VALID_COMMANDS.includes(cmd)) {
996
1112
  const customList = await CustomCommandManager.listCommands();
@@ -1010,18 +1126,23 @@ export async function main() {
1010
1126
  { name: '/settings - Configure permissions & auto-approval', value: '/settings' },
1011
1127
  { name: '/update - Check and install updates from GitHub', value: '/update' },
1012
1128
  { name: '/model - Switch model for current provider', value: '/model' },
1129
+ { name: '/effort - Set the reasoning effort for the current model', value: '/effort' },
1013
1130
  { name: '/provider - Change AI provider (Google, OpenRouter...)', value: '/provider' },
1014
1131
  { name: '/plan - Switch to PLAN mode (architect)', value: '/plan' },
1015
1132
  { name: '/agent - Switch to AGENT mode (coder)', value: '/agent' },
1016
1133
  { name: '/serve - Start local web server for web preview', value: '/serve' },
1017
1134
  { name: '/memory - View or edit project memory bank', value: '/memory' },
1135
+ { name: '/permissions - What is allowed without asking', value: '/permissions' },
1136
+ { name: '/copy - Copy the last answer to the clipboard', value: '/copy' },
1137
+ { name: '/rename - Rename the chat', value: '/rename' },
1138
+ { name: '/rewind - Go back to one of your messages', value: '/rewind' },
1018
1139
  { name: '/undo - Revert last file changes made by AI', value: '/undo' },
1019
1140
  { name: '/diff - Show git diff of modified files', value: '/diff' },
1020
1141
  { name: '/commit - AI-generated git commit message', value: '/commit' },
1021
1142
  { name: '/status - Show git repository status', value: '/status' },
1022
1143
  { name: '/compact - Compact conversation context', value: '/compact' },
1023
1144
  { name: '/config - View current configuration', value: '/config' },
1024
- { name: '/clear - Clear message history', value: '/clear' },
1145
+ { name: '/clear - Clear context and start a new session', value: '/clear' },
1025
1146
  { name: '/help - Show commands overview', value: '/help' },
1026
1147
  { name: '/exit - Exit devx', value: '/exit' },
1027
1148
  ...customSlashItems
@@ -1032,7 +1153,7 @@ export async function main() {
1032
1153
  source: async (term) => {
1033
1154
  const q = (term || '').trim().toLowerCase();
1034
1155
  const list = [
1035
- { name: pc.yellow('⬅️ Cancel'), value: '__cancel__' },
1156
+ { name: pc.yellow('← Cancel'), value: '__cancel__' },
1036
1157
  ...SLASH_COMMANDS
1037
1158
  ];
1038
1159
  if (!q)
@@ -1056,55 +1177,53 @@ export async function main() {
1056
1177
  }
1057
1178
  }
1058
1179
  if (cmd === '/settings') {
1180
+ // The panel erases itself; the conversation above it stays where it is.
1059
1181
  config = await handleSettings(config);
1060
- drawLogo(config.banner || 'full');
1182
+ costLimitRaised = null;
1183
+ costWarned = false;
1184
+ // The project memory switch changes what the system prompt contains.
1185
+ history.updateSystemPrompt(await buildSystemPrompt(planMode));
1061
1186
  continue;
1062
1187
  }
1063
1188
  if (cmd === '/update') {
1064
- const s = p.spinner();
1189
+ const s = new UiSpinner();
1065
1190
  s.start('Checking for updates from https://github.com/apvcode/Termux-Dev...');
1066
1191
  const res = await checkForUpdates(10000);
1067
1192
  s.stop();
1068
1193
  if (res.updateAvailable) {
1069
- p.log.info(pc.bold(pc.yellow(`🚀 Update available: v${res.currentVersion} ➔ v${res.latestVersion}`)));
1070
- const doUpdate = await p.confirm({
1071
- message: `Do you want to update devx to v${res.latestVersion} now?`,
1072
- initialValue: true
1073
- });
1074
- if (!p.isCancel(doUpdate) && doUpdate) {
1194
+ log.info(pc.bold(pc.yellow(`Update available: v${res.currentVersion} → v${res.latestVersion}`)));
1195
+ const doUpdate = await askConfirm({ title: 'update', question: t('confirm.update', { version: res.latestVersion }), initial: true });
1196
+ if (doUpdate) {
1075
1197
  await performSelfUpdate(res.latestVersion);
1076
1198
  }
1077
1199
  }
1078
1200
  else {
1079
- p.log.success(pc.green(`devx is up to date! (v${res.currentVersion})`));
1201
+ log.success(pc.green(`devx is up to date! (v${res.currentVersion})`));
1080
1202
  }
1081
1203
  continue;
1082
1204
  }
1083
1205
  if (cmd === '/help') {
1084
- p.note('/settings - Configure permissions (Auto-Approve / Ask every time)\n/resume - Resume previous chat session\n/session del - Select and delete saved sessions\n/new - Start a new chat session (or /new <prompt>)\n/model - Switch model for current provider\n/provider - Switch AI provider (with saved keys)\n/config - View configuration\n/plan - Switch to plan mode\n/agent - Switch to agent mode\n/clear - Clear history\n/exit - Exit', 'Commands');
1206
+ note('/settings - Configure permissions (Auto-Approve / Ask every time)\n/resume - Resume previous chat session\n/session del - Select and delete saved sessions\n/new - Start a new chat session (or /new <prompt>)\n/model - Switch model for current provider\n/effort - Set the reasoning effort (only levels the model supports)\n/provider - Switch AI provider (with saved keys)\n/config - View configuration\n/plan - Switch to plan mode\n/agent - Switch to agent mode\n/clear - Clear context and start a new session\n/exit - Exit', 'Commands');
1085
1207
  continue;
1086
1208
  }
1087
1209
  if (cmd === '/exit') {
1088
- p.outro('Goodbye!');
1210
+ printFarewell(t('confirm.goodbye'));
1089
1211
  break;
1090
1212
  }
1091
1213
  if (cmd === '/clear') {
1092
- drawLogo(config.banner || 'full');
1214
+ await startFreshSession();
1215
+ announce('new session · context cleared');
1093
1216
  continue;
1094
1217
  }
1095
1218
  if (cmd === '/new' || cmd === '/reset') {
1096
- sessionManager.startNewSession(config.model, planMode);
1097
- history = new History();
1098
- history.addMessage({ role: 'system', content: await buildSystemPrompt(planMode) });
1099
- totalSessionCost = 0;
1100
- drawLogo(config.banner || 'full');
1219
+ await startFreshSession();
1101
1220
  const initialPrompt = parts.slice(1).join(' ').trim();
1102
1221
  if (initialPrompt) {
1103
- p.log.success('Started new session with prompt.');
1222
+ announce('new session · starting with your prompt');
1104
1223
  answer = initialPrompt;
1105
1224
  }
1106
1225
  else {
1107
- p.log.success('Started a new chat session.');
1226
+ announce('new session started');
1108
1227
  continue;
1109
1228
  }
1110
1229
  }
@@ -1116,7 +1235,7 @@ export async function main() {
1116
1235
  }
1117
1236
  const sessions = await SessionManager.listSessions();
1118
1237
  if (sessions.length === 0) {
1119
- p.log.warn('No saved sessions found.');
1238
+ log.warn('No saved sessions found.');
1120
1239
  continue;
1121
1240
  }
1122
1241
  let chosenSessionId;
@@ -1126,8 +1245,8 @@ export async function main() {
1126
1245
  source: async (term) => {
1127
1246
  const q = (term || '').trim().toLowerCase();
1128
1247
  const list = [
1129
- { name: pc.yellow('⬅️ Back / Cancel'), value: '__cancel__', description: 'Return to chat' },
1130
- { name: pc.red('🗑️ Delete sessions... (/session del)'), value: '__delete_mode__', description: 'Selectively delete saved sessions' }
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' }
1131
1250
  ];
1132
1251
  for (const s of sessions) {
1133
1252
  const timeStr = formatSessionTime(s.updatedAt);
@@ -1165,57 +1284,34 @@ export async function main() {
1165
1284
  }
1166
1285
  if (loaded.model) {
1167
1286
  config.model = loaded.model;
1168
- config.maxContextTokens = getModelContextLimit(loaded.model);
1287
+ config.maxContextTokens = resolveContextLimit({ ...config, maxContextTokens: 0 }).limit;
1169
1288
  }
1170
1289
  if (loaded.planMode !== undefined) {
1171
1290
  planMode = loaded.planMode;
1172
1291
  }
1173
- totalSessionCost = loaded.totalCost || 0;
1292
+ sessionCost.restore(loaded.totalCost || 0, loaded.costEstimated === true);
1174
1293
  drawLogo(config.banner || 'full');
1175
1294
  const nonSysMessages = loaded.messages.filter(m => m.role !== 'system');
1176
1295
  const totalMsgs = nonSysMessages.length;
1177
1296
  const displayLimit = 20;
1178
1297
  const recentMessages = nonSysMessages.slice(-displayLimit);
1179
1298
  const shownCount = recentMessages.length;
1180
- // Header banner showing messages loaded
1181
- const countInfo = totalMsgs > displayLimit
1182
- ? `Loaded recent ${shownCount} messages of ${totalMsgs}`
1183
- : `Loaded all ${totalMsgs} messages`;
1184
- console.log('\n' + pc.bold(pc.cyan(`─── 📜 ${countInfo} (Session: "${loaded.title}") ───`)) + '\n');
1185
- // Render each message in chronological order
1186
- for (const msg of recentMessages) {
1187
- if (msg.role === 'user') {
1188
- console.log(pc.cyan('◆') + ' ' + pc.bold(pc.white(msg.content)));
1189
- if (msg.images && msg.images.length > 0) {
1190
- console.log(pc.magenta(` [🖼️ ${msg.images.map(i => i.path).join(', ')}]`));
1191
- }
1192
- console.log();
1193
- }
1194
- else if (msg.role === 'assistant') {
1195
- if (msg.content) {
1196
- console.log(renderMarkdown(msg.content));
1197
- }
1198
- if (msg.tool_calls && msg.tool_calls.length > 0) {
1199
- for (const tc of msg.tool_calls) {
1200
- console.log(pc.dim(` ⚡ ${tc.name}`));
1201
- }
1202
- }
1203
- console.log();
1204
- }
1205
- else if (msg.role === 'tool') {
1206
- if (msg.content) {
1207
- const firstLine = msg.content.trim().split('\n')[0];
1208
- if (firstLine.includes('+') || firstLine.includes('lines') || firstLine.includes('Successfully')) {
1209
- console.log(pc.green(` └─ ${firstLine}`));
1210
- }
1211
- }
1212
- }
1213
- }
1214
- console.log(pc.bold(pc.cyan(`────────────────────────────────────────────────────────────────────────\n`)));
1215
- p.log.success(`Resumed session: "${loaded.title}" (${totalMsgs} total messages in history)`);
1299
+ transcript.length = 0;
1300
+ titleAttempts = 0;
1301
+ if (loaded.title && loaded.title !== 'New Session')
1302
+ setWindowTitle(loaded.title);
1303
+ transcript.push(...messagesToItems(recentMessages));
1304
+ console.log(renderResumedMessages(recentMessages, {
1305
+ title: loaded.title,
1306
+ shown: shownCount,
1307
+ total: totalMsgs,
1308
+ cols: process.stdout.columns || 80,
1309
+ thinkingExpanded,
1310
+ outputExpanded,
1311
+ }).join('\n'));
1216
1312
  }
1217
1313
  else {
1218
- p.log.error('Failed to load session.');
1314
+ log.error('Failed to load session.');
1219
1315
  }
1220
1316
  continue;
1221
1317
  }
@@ -1223,37 +1319,63 @@ export async function main() {
1223
1319
  planMode = true;
1224
1320
  const newSys = await buildSystemPrompt(true);
1225
1321
  history.updateSystemPrompt(newSys);
1226
- drawLogo(config.banner || 'full');
1227
- p.log.success(pc.cyan('Switched to PLAN mode (Architect & Planner). Modifying tools are disabled.'));
1322
+ announce('plan mode · designs and asks questions, does not touch files');
1228
1323
  continue;
1229
1324
  }
1230
1325
  if (cmd === '/agent') {
1231
1326
  planMode = false;
1232
1327
  const newSys = await buildSystemPrompt(false);
1233
1328
  history.updateSystemPrompt(newSys);
1234
- drawLogo(config.banner || 'full');
1235
- p.log.success(pc.green('Switched to AGENT mode (Coder & Executor). Full tools enabled.'));
1329
+ announce('agent mode · edits files and runs commands');
1236
1330
  continue;
1237
1331
  }
1238
1332
  if (cmd === '/undo') {
1239
- const { revertedFiles, count } = await globalSnapshotManager.undoLastTurn();
1240
- if (count === 0) {
1241
- p.log.warn('No changes to undo.');
1333
+ // With git: the whole folder goes back to how it was before the last turn, shell commands included.
1334
+ const cp = checkpoints[checkpoints.length - 1];
1335
+ const diff = cp ? await diffCheckpoint(cp).catch(() => null) : null;
1336
+ if (cp && diff && diff.modified.length + diff.created.length + diff.deleted.length > 0) {
1337
+ const list = [
1338
+ ...diff.modified.map((f) => `changed ${f}`),
1339
+ ...diff.deleted.map((f) => `deleted ${f}`),
1340
+ ...diff.created.map((f) => `new ${f}`),
1341
+ ];
1342
+ const shown = list.slice(0, 12);
1343
+ if (list.length > shown.length)
1344
+ shown.push(`… +${list.length - shown.length} more`);
1345
+ const ok = await askConfirm({
1346
+ title: 'undo',
1347
+ question: t('undo.question', { n: list.length }),
1348
+ details: [...shown, t('undo.note')],
1349
+ initial: true,
1350
+ });
1351
+ if (ok) {
1352
+ await restoreCheckpoint(cp, diff);
1353
+ checkpoints.pop();
1354
+ await globalSnapshotManager.undoLastTurn().catch(() => null);
1355
+ history.popLastTurn();
1356
+ log.success(t('undo.done', { n: list.length }));
1357
+ }
1242
1358
  }
1243
1359
  else {
1244
- history.popLastTurn();
1245
- p.log.success(pc.bold(pc.green(`⏪ Successfully reverted changes in ${count} file(s):`)));
1246
- for (const f of revertedFiles) {
1247
- console.log(pc.cyan(` • ${f}`));
1360
+ const { revertedFiles, count } = await globalSnapshotManager.undoLastTurn();
1361
+ if (count === 0) {
1362
+ log.warn('No changes to undo.');
1363
+ }
1364
+ else {
1365
+ history.popLastTurn();
1366
+ log.success(`Reverted changes in ${count} file(s)`);
1367
+ for (const f of revertedFiles)
1368
+ console.log(pc.cyan(` • ${f}`));
1369
+ console.log(pc.dim(' Note: without git only edits made with the file tools are reverted, not those of shell commands.'));
1248
1370
  }
1249
1371
  }
1250
1372
  continue;
1251
1373
  }
1252
1374
  if (cmd === '/diff') {
1253
1375
  try {
1254
- const diffOutput = execSync('git diff', { encoding: 'utf8' });
1376
+ const diffOutput = execSync('git diff', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
1255
1377
  if (!diffOutput.trim()) {
1256
- p.log.info('No uncommitted changes in git repository.');
1378
+ log.info('No uncommitted changes in git repository.');
1257
1379
  }
1258
1380
  else {
1259
1381
  console.log('\n' + pc.bold(pc.cyan('─── Git Diff ─────────────────────────')));
@@ -1275,15 +1397,15 @@ export async function main() {
1275
1397
  }
1276
1398
  }
1277
1399
  catch {
1278
- p.log.warn('Git is not available or current directory is not a git repo.');
1400
+ log.warn('Git is not available or current directory is not a git repo.');
1279
1401
  }
1280
1402
  continue;
1281
1403
  }
1282
1404
  if (cmd === '/status') {
1283
1405
  try {
1284
- const statusOutput = execSync('git status --short', { encoding: 'utf8' });
1406
+ const statusOutput = execSync('git status --short', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
1285
1407
  if (!statusOutput.trim()) {
1286
- p.log.success('Working directory clean, no modified files.');
1408
+ log.success('Working directory clean, no modified files.');
1287
1409
  }
1288
1410
  else {
1289
1411
  console.log('\n' + pc.bold(pc.cyan('─── Git Status ───────────────────────')));
@@ -1307,23 +1429,24 @@ export async function main() {
1307
1429
  }
1308
1430
  }
1309
1431
  catch {
1310
- p.log.warn('Git is not available or current directory is not a git repo.');
1432
+ log.warn('Git is not available or current directory is not a git repo.');
1311
1433
  }
1312
1434
  continue;
1313
1435
  }
1314
1436
  if (cmd === '/commit') {
1315
1437
  try {
1316
- const status = execSync('git status --short', { encoding: 'utf8' });
1438
+ const status = execSync('git status --short', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
1317
1439
  if (!status.trim()) {
1318
- p.log.info('No changes to commit.');
1440
+ log.info('No changes to commit.');
1319
1441
  continue;
1320
1442
  }
1321
- const diff = execSync('git diff', { encoding: 'utf8' });
1443
+ const diff = execSync('git diff', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
1322
1444
  const userMsg = parts.slice(1).join(' ').trim();
1323
1445
  let commitMsg = userMsg;
1324
1446
  if (!commitMsg) {
1325
- const s = p.spinner();
1447
+ const s = new UiSpinner();
1326
1448
  s.start('Generating conventional commit message with AI...');
1449
+ await refreshFormats(config).catch(() => null);
1327
1450
  const provider = createProvider(config);
1328
1451
  const promptReq = {
1329
1452
  messages: [
@@ -1341,57 +1464,63 @@ export async function main() {
1341
1464
  s.stop();
1342
1465
  commitMsg = (genRes.content || 'chore: update project files').trim().replace(/^["'`]|["'`]$/g, '').split('\n')[0];
1343
1466
  }
1344
- const confirmed = await p.confirm({
1345
- message: `Commit changes with message:\n"${pc.bold(pc.green(commitMsg))}"?`,
1346
- initialValue: true
1347
- });
1348
- if (!p.isCancel(confirmed) && confirmed) {
1467
+ const confirmed = await askConfirm({ title: 'commit', question: t('confirm.commit'), details: [commitMsg], initial: true });
1468
+ if (confirmed) {
1349
1469
  execFileSync('git', ['add', '-A'], { stdio: 'ignore' });
1350
1470
  execFileSync('git', ['commit', '-m', commitMsg], { stdio: 'ignore' });
1351
- p.log.success(pc.bold(pc.green(`✅ Committed: ${commitMsg}`)));
1471
+ log.success(pc.bold(pc.green(`Committed: ${commitMsg}`)));
1352
1472
  }
1353
1473
  else {
1354
- p.log.info('Commit cancelled.');
1474
+ log.info('Commit cancelled.');
1355
1475
  }
1356
1476
  }
1357
1477
  catch (err) {
1358
- p.log.error(`Git commit failed: ${err.message}`);
1478
+ log.error(`Git commit failed: ${err.message}`);
1359
1479
  }
1360
1480
  continue;
1361
1481
  }
1362
1482
  if (cmd === '/compact') {
1363
1483
  const msgs = history.getMessages();
1364
1484
  if (msgs.length <= 2) {
1365
- p.log.info('Conversation is already short, no compaction needed.');
1485
+ log.info('Conversation is already short, no compaction needed.');
1366
1486
  continue;
1367
1487
  }
1368
- const s = p.spinner();
1488
+ const s = new UiSpinner();
1369
1489
  s.start('Compacting conversation context with AI...');
1370
1490
  try {
1491
+ await refreshFormats(config).catch(() => null);
1371
1492
  const provider = createProvider(config);
1493
+ // The request itself must fit the window: shorten a copy of the history first.
1494
+ const forSummary = new History();
1495
+ for (const m of msgs)
1496
+ forSummary.addMessage({ ...m, images: undefined });
1497
+ forSummary.pruneToLimit(Math.floor((config.maxContextTokens || 100000) * 0.7));
1372
1498
  const compactPrompt = {
1373
1499
  messages: [
1374
- ...msgs,
1500
+ ...forSummary.getMessages(),
1375
1501
  {
1376
1502
  role: 'user',
1377
- content: 'Please create a clear, comprehensive, and structured summary of our conversation so far, including all established requirements, decisions, modified files, and remaining tasks. Be concise and precise.'
1503
+ content: 'Write a short, structured summary of our conversation so far: what the user wants, decisions made, files created or changed, and what is left to do. Plain text, no filler.'
1378
1504
  }
1379
1505
  ]
1380
1506
  };
1381
1507
  const res = await provider.chat(compactPrompt);
1382
1508
  s.stop();
1509
+ if (!res.content || !res.content.trim())
1510
+ throw new Error('the model returned an empty summary');
1383
1511
  const sysPrompt = await buildSystemPrompt(planMode);
1384
1512
  history.clear();
1385
1513
  history.addMessage({ role: 'system', content: sysPrompt });
1514
+ // A user message, not an assistant one: the Anthropic format cannot start with the assistant.
1386
1515
  history.addMessage({
1387
- role: 'assistant',
1388
- content: `[CONVERSATION COMPACTED]\n${res.content || ''}`
1516
+ role: 'user',
1517
+ content: `[CONVERSATION COMPACTED] Summary of our conversation so far:\n${res.content}`
1389
1518
  });
1390
- p.log.success(pc.bold(pc.green('🗜️ Context successfully compacted! Freed up tokens while retaining task summary.')));
1519
+ log.success(pc.bold(pc.green('Context successfully compacted! Freed up tokens while retaining task summary.')));
1391
1520
  }
1392
1521
  catch (err) {
1393
1522
  s.stop();
1394
- p.log.error(`Compaction failed: ${err.message}`);
1523
+ log.error(`Compaction failed: ${err.message}`);
1395
1524
  }
1396
1525
  continue;
1397
1526
  }
@@ -1399,13 +1528,13 @@ export async function main() {
1399
1528
  const cur = sessionManager.getSession();
1400
1529
  const nonSys = history.getMessages().filter(m => m.role !== 'system');
1401
1530
  const shortId = cur.id.split('_').pop();
1402
- console.log('\n' + pc.bold(pc.cyan('─── 🆔 Active Session Information ───')));
1531
+ console.log('\n' + pc.bold(pc.cyan('─── Active Session Information ───')));
1403
1532
  console.log(` ${pc.bold('ID:')} ${pc.cyan(cur.id)} ${pc.dim(`(#${shortId})`)}`);
1404
1533
  console.log(` ${pc.bold('Title:')} "${cur.title}"`);
1405
1534
  console.log(` ${pc.bold('Model:')} ${config.model}`);
1406
1535
  console.log(` ${pc.bold('Mode:')} ${planMode ? pc.cyan('PLAN') : pc.green('AGENT')}`);
1407
1536
  console.log(` ${pc.bold('Messages:')} ${nonSys.length} messages`);
1408
- console.log(` ${pc.bold('Cost:')} $${totalSessionCost.toFixed(4)}`);
1537
+ console.log(` ${pc.bold('Cost:')} ${sessionCost.format()}`);
1409
1538
  console.log(` ${pc.bold('File:')} ~/.devx/sessions/${cur.id}.json`);
1410
1539
  console.log(pc.bold(pc.cyan('──────────────────────────────────────\n')));
1411
1540
  continue;
@@ -1413,12 +1542,12 @@ export async function main() {
1413
1542
  if (cmd === '/init') {
1414
1543
  const agentsPath = path.join(process.cwd(), 'AGENTS.md');
1415
1544
  if (fsSync.existsSync(agentsPath)) {
1416
- p.log.warn('AGENTS.md already exists in current workspace.');
1545
+ log.warn('AGENTS.md already exists in current workspace.');
1417
1546
  }
1418
1547
  else {
1419
1548
  const template = `# Project Developer Instructions (AGENTS.md)\n\n## Overview\nProject description and goals.\n\n## Tech Stack\n- TypeScript / Node.js\n\n## Coding Guidelines\n- Clean, modular code\n- Verify changes after editing\n`;
1420
1549
  await fs.writeFile(agentsPath, template, 'utf8');
1421
- p.log.success(`Created ${pc.bold('AGENTS.md')} in project root! Customize it to give devx persistent instructions.`);
1550
+ log.success(`Created ${pc.bold('AGENTS.md')} in project root! Customize it to give devx persistent instructions.`);
1422
1551
  }
1423
1552
  continue;
1424
1553
  }
@@ -1430,10 +1559,10 @@ export async function main() {
1430
1559
  const sub = parts[1]?.toLowerCase();
1431
1560
  if (sub === 'stop') {
1432
1561
  if (stopServer()) {
1433
- p.log.success('Web server stopped.');
1562
+ log.success('Web server stopped.');
1434
1563
  }
1435
1564
  else {
1436
- p.log.info('No web server is currently running.');
1565
+ log.info('No web server is currently running.');
1437
1566
  }
1438
1567
  }
1439
1568
  else {
@@ -1443,7 +1572,7 @@ export async function main() {
1443
1572
  await displayServerBanner(localUrl, networkUrl);
1444
1573
  }
1445
1574
  catch (err) {
1446
- p.log.error(`Failed to start web server: ${err.message}`);
1575
+ log.error(`Failed to start web server: ${err.message}`);
1447
1576
  }
1448
1577
  }
1449
1578
  continue;
@@ -1456,12 +1585,11 @@ export async function main() {
1456
1585
  config.theme = matched.id;
1457
1586
  await saveConfig(config);
1458
1587
  const th = setActiveTheme(matched.id);
1459
- drawLogo(config.banner || 'full');
1460
- p.log.success(th.boldFn(`🎨 Theme switched to ${th.emoji} ${th.name}!`));
1588
+ announce(`theme ${th.name}`);
1461
1589
  }
1462
1590
  else {
1463
1591
  const themes = listThemes();
1464
- p.log.warn(`Unknown theme: "${themeArg}". Available themes: ${themes.map(t => `${t.emoji} ${t.id}`).join(', ')}`);
1592
+ log.warn(`Unknown theme: "${themeArg}". Available themes: ${themes.map(t => `${t.colorFn(t.symbol)} ${t.id}`).join(', ')}`);
1465
1593
  }
1466
1594
  }
1467
1595
  else {
@@ -1469,59 +1597,250 @@ export async function main() {
1469
1597
  }
1470
1598
  continue;
1471
1599
  }
1600
+ if (cmd === '/rewind') {
1601
+ const msgs = history.getMessages();
1602
+ const entries = userEntries(msgs);
1603
+ if (entries.length === 0) {
1604
+ log.warn(t('rewind.none'));
1605
+ continue;
1606
+ }
1607
+ const chosen = await runPicker({
1608
+ title: t('rewind.title'),
1609
+ subtitle: t('rewind.count', { n: entries.length }),
1610
+ items: rewindItems(msgs),
1611
+ current: REWIND_CURRENT,
1612
+ labelWidth: 54,
1613
+ });
1614
+ if (chosen === undefined || chosen === REWIND_CURRENT)
1615
+ continue;
1616
+ const target = msgs[chosen];
1617
+ // The folder can go back too, to how it was before that message.
1618
+ const cpIndex = target.checkpoint ? checkpoints.findIndex((c) => c.sha === target.checkpoint) : -1;
1619
+ if (cpIndex >= 0) {
1620
+ const diff = await diffCheckpoint(checkpoints[cpIndex]).catch(() => null);
1621
+ const total = diff ? diff.modified.length + diff.created.length + diff.deleted.length : 0;
1622
+ if (diff && total > 0) {
1623
+ const list = [...diff.modified.map((f) => `changed ${f}`), ...diff.deleted.map((f) => `deleted ${f}`), ...diff.created.map((f) => `new ${f}`)];
1624
+ const shown = list.slice(0, 10);
1625
+ if (list.length > shown.length)
1626
+ shown.push(`… +${list.length - shown.length} more`);
1627
+ const restore = await askConfirm({ title: t('rewind.title'), question: t('rewind.filesQuestion', { n: total }), details: shown, yes: t('rewind.filesYes'), no: t('rewind.filesNo') });
1628
+ if (restore)
1629
+ await restoreCheckpoint(checkpoints[cpIndex], diff);
1630
+ }
1631
+ checkpoints.length = cpIndex;
1632
+ }
1633
+ else if (target.checkpoint) {
1634
+ checkpoints.length = 0;
1635
+ }
1636
+ // The conversation after the message is burned: the model forgets it as well.
1637
+ history.rewindBefore(chosen);
1638
+ const kept = history.getMessages().filter((m) => m.role !== 'system');
1639
+ transcript.length = 0;
1640
+ transcript.push(...messagesToItems(kept));
1641
+ await sessionManager.save(history.getMessages(), sessionCost.total, config.model, planMode, sessionCost.estimated);
1642
+ currentDraft = messageDraft(target.content);
1643
+ redrawChat();
1644
+ log.info(t((target.images && target.images.length > 0) ? 'rewind.doneImages' : 'rewind.done'));
1645
+ continue;
1646
+ }
1647
+ if (cmd === '/copy') {
1648
+ const last = [...history.getMessages()].reverse().find((m) => m.role === 'assistant' && m.content && m.content.trim());
1649
+ if (!last) {
1650
+ log.warn(t('copy.none'));
1651
+ }
1652
+ else {
1653
+ const method = await copyToClipboard(last.content);
1654
+ if (method === 'none')
1655
+ log.error(t('copy.failed'));
1656
+ else
1657
+ log.success(t('copy.done', { n: Array.from(last.content).length }));
1658
+ }
1659
+ continue;
1660
+ }
1661
+ if (cmd === '/rename' || cmd === '/title') {
1662
+ const name = cleanTitle(parts.slice(1).join(' '));
1663
+ if (name) {
1664
+ sessionManager.setTitle(name);
1665
+ setWindowTitle(name);
1666
+ await sessionManager.save(history.getMessages(), sessionCost.total, config.model, planMode, sessionCost.estimated);
1667
+ log.success(t('rename.done', { name }));
1668
+ }
1669
+ else {
1670
+ // Without a name the model names the chat again.
1671
+ const msgs = history.getMessages();
1672
+ const firstUser = msgs.find((m) => m.role === 'user')?.content ?? '';
1673
+ const reply = [...msgs].reverse().find((m) => m.role === 'assistant' && m.content && m.content.trim())?.content ?? '';
1674
+ if (!firstUser.trim() || !reply.trim()) {
1675
+ log.warn(t('rename.usage'));
1676
+ }
1677
+ else {
1678
+ const generated = await generateChatTitle(createProvider(config), firstUser.split('\n\n--- Attached Files ---')[0], reply);
1679
+ if (!generated)
1680
+ log.error(t('rename.failed'));
1681
+ else {
1682
+ sessionManager.setTitle(generated);
1683
+ setWindowTitle(generated);
1684
+ await sessionManager.save(history.getMessages(), sessionCost.total, config.model, planMode, sessionCost.estimated);
1685
+ log.success(t('rename.done', { name: generated }));
1686
+ }
1687
+ }
1688
+ }
1689
+ continue;
1690
+ }
1691
+ if (cmd === '/permissions') {
1692
+ if ((parts[1] || '').toLowerCase() === 'clear') {
1693
+ config.bashAllowlist = [];
1694
+ config.allowedTools = [];
1695
+ permissionMemory.bash.clear();
1696
+ permissionMemory.tools.clear();
1697
+ await saveConfig(config);
1698
+ log.success(t('permissions.cleared'));
1699
+ }
1700
+ else {
1701
+ const bash = Array.from(new Set([...(config.bashAllowlist || []), ...permissionMemory.bash]));
1702
+ const tools = Array.from(permissionMemory.tools);
1703
+ const rows = [
1704
+ ...bash.map((k) => `command ${k}`),
1705
+ ...tools.map((k) => `tool ${k}`),
1706
+ ];
1707
+ if (rows.length === 0)
1708
+ log.info(t('permissions.none'));
1709
+ else
1710
+ note(rows.join('\n'), t('permissions.title'));
1711
+ if (rows.length > 0)
1712
+ log.info(t('permissions.hint'));
1713
+ }
1714
+ continue;
1715
+ }
1472
1716
  if (cmd === '/memory') {
1473
1717
  const sub = parts[1]?.toLowerCase();
1474
1718
  if (sub === 'clear') {
1475
1719
  await MemoryManager.clearMemory();
1476
- p.log.success('Cleared project memory bank (.devx/memory.md).');
1720
+ history.updateSystemPrompt(await buildSystemPrompt(planMode));
1721
+ log.success('Cleared project memory bank (.devx/memory.md).');
1722
+ }
1723
+ else if (sub === 'remove' || sub === 'rm') {
1724
+ const name = parts[2];
1725
+ if (!name) {
1726
+ console.log(renderNotice('warn', t('mcp.removeUsage')));
1727
+ }
1728
+ else if (!removeUserServer(name)) {
1729
+ console.log(renderNotice('warn', t('mcp.removeNotFound', { name })));
1730
+ }
1731
+ else {
1732
+ const sp = new UiSpinner();
1733
+ sp.start('Reloading MCP servers...');
1734
+ await MCPManager.getInstance().reload();
1735
+ sp.stop();
1736
+ console.log(renderNotice('ok', t('mcp.removed', { name })));
1737
+ }
1477
1738
  }
1478
1739
  else if (sub === 'add') {
1479
1740
  const fact = parts.slice(2).join(' ').trim();
1480
1741
  if (!fact) {
1481
- p.log.warn('Usage: /memory add <fact or rule to remember>');
1742
+ log.warn('Usage: /memory add <fact or rule to remember>');
1482
1743
  }
1483
1744
  else {
1484
1745
  await MemoryManager.addFact(fact);
1485
- p.log.success(`Saved to memory: "${fact}"`);
1746
+ // The model sees the new rule from the next message on, not only after a restart.
1747
+ history.updateSystemPrompt(await buildSystemPrompt(planMode));
1748
+ log.success(`Saved to memory: "${fact}"`);
1486
1749
  }
1487
1750
  }
1488
1751
  else {
1489
1752
  const mem = await MemoryManager.loadMemory();
1490
1753
  if (!mem.trim()) {
1491
- p.log.info('Project memory is empty. AI will remember facts as you work, or use /memory add <fact>.');
1754
+ log.info('Project memory is empty. AI will remember facts as you work, or use /memory add <fact>.');
1492
1755
  }
1493
1756
  else {
1494
- p.note(mem, '🧠 Project Memory Bank (.devx/memory.md)');
1757
+ note(mem, 'Project Memory Bank (.devx/memory.md)');
1758
+ const capped = memoryForPrompt(mem);
1759
+ if (capped.omitted > 0)
1760
+ log.warn(t('memory.capped', { n: capped.omitted }));
1495
1761
  console.log(pc.dim('Commands: /memory add <text> | /memory clear\n'));
1496
1762
  }
1497
1763
  }
1498
1764
  continue;
1499
1765
  }
1500
1766
  if (cmd === '/usage') {
1501
- const card = UsageTracker.getInstance().renderUsageCard();
1502
- console.log('\n' + card);
1767
+ await runUsagePanel({
1768
+ model: config.model,
1769
+ format: providerFormat(config),
1770
+ effort: thinkLabel(config) || undefined,
1771
+ context: currentContext(),
1772
+ compactions: history.compactions,
1773
+ dropped: history.droppedMessages,
1774
+ session: UsageTracker.getInstance().getSummary(),
1775
+ noVision: modelAcceptsImages(config.model) === false,
1776
+ limitSource: resolveContextLimit(config).source,
1777
+ });
1778
+ process.stdin.resume();
1503
1779
  continue;
1504
1780
  }
1505
1781
  if (cmd === '/export') {
1506
1782
  const targetName = parts.slice(1).join(' ').trim();
1507
1783
  const res = await SessionExporter.exportToMarkdown(history.getMessages(), config.model, targetName || undefined);
1508
1784
  if (res.success) {
1509
- p.log.success(pc.bold(pc.green(`📄 Session exported to: ${res.filePath}`)));
1785
+ log.success(pc.bold(pc.green(`Session exported to: ${res.filePath}`)));
1510
1786
  }
1511
1787
  else {
1512
- p.log.error(`Failed to export session: ${res.error}`);
1788
+ log.error(`Failed to export session: ${res.error}`);
1513
1789
  }
1514
1790
  continue;
1515
1791
  }
1516
1792
  if (cmd === '/mcp') {
1517
1793
  const sub = (parts[1] || '').toLowerCase();
1518
1794
  if (sub === 'reload' || sub === 'restart' || sub === 'r') {
1519
- const s = p.spinner();
1795
+ const s = new UiSpinner();
1520
1796
  s.start('Reloading MCP servers...');
1521
1797
  await MCPManager.getInstance().reload();
1522
1798
  s.stop();
1523
1799
  console.log('\n' + MCPManager.getInstance().renderStatusCard());
1524
1800
  }
1801
+ else if (sub === 'import') {
1802
+ // The servers that Claude Code already knows (~/.claude.json) become devx servers.
1803
+ const found = readClaudeServers();
1804
+ if (Object.keys(found).length === 0) {
1805
+ console.log(renderNotice('warn', 'No servers found in ~/.claude.json'));
1806
+ }
1807
+ else {
1808
+ const added = saveUserServers(found);
1809
+ const skipped = Object.keys(found).filter((n) => !added.includes(n));
1810
+ const s = new UiSpinner();
1811
+ s.start('Starting MCP servers...');
1812
+ await MCPManager.getInstance().reload();
1813
+ s.stop();
1814
+ console.log(renderNotice('ok', added.length ? `imported: ${added.join(', ')} (saved to ~/.devx/mcp.json)` : 'nothing new to import'));
1815
+ if (skipped.length)
1816
+ console.log(renderNotice('info', `already in devx: ${skipped.join(', ')}`));
1817
+ console.log('\n' + MCPManager.getInstance().renderStatusCard());
1818
+ }
1819
+ }
1820
+ else if (sub === 'add') {
1821
+ // /mcp add <name> <command> [args...]
1822
+ const [name, command, ...rest] = parts.slice(2);
1823
+ if (!name || !command) {
1824
+ console.log(renderNotice('warn', 'Usage: /mcp add <name> <command|url> [args...] e.g. /mcp add notion npx -y mcp-remote https://mcp.notion.com/mcp'));
1825
+ }
1826
+ else {
1827
+ // An address is a remote server (over HTTP); anything else is a program to start.
1828
+ saveUserServers({ [name]: /^https?:\/\//i.test(command) ? { url: command } : { command, ...(rest.length ? { args: rest } : {}) } }, true);
1829
+ const s = new UiSpinner();
1830
+ s.start(`Starting ${name}...`);
1831
+ await MCPManager.getInstance().reload();
1832
+ s.stop();
1833
+ const st = MCPManager.getInstance().getStatus(name);
1834
+ console.log(st?.status === 'connected'
1835
+ ? renderNotice('ok', `${name} connected, ${st.toolsCount} tools (saved to ~/.devx/mcp.json)`)
1836
+ : renderNotice('error', `${name}: ${st?.error || st?.status || 'not started'}`));
1837
+ }
1838
+ }
1839
+ else if (process.stdin.isTTY && process.stdout.isTTY) {
1840
+ // The panel erases itself when it is closed; the conversation above it stays where it is.
1841
+ await runMcpPanel(MCPManager.getInstance());
1842
+ process.stdin.resume();
1843
+ }
1525
1844
  else {
1526
1845
  console.log('\n' + MCPManager.getInstance().renderStatusCard());
1527
1846
  }
@@ -1537,32 +1856,90 @@ export async function main() {
1537
1856
  masked.apiKeys[k] = maskApiKey(masked.apiKeys[k]);
1538
1857
  }
1539
1858
  }
1540
- p.note(JSON.stringify(masked, null, 2), 'Configuration (Secrets Masked)');
1859
+ note(JSON.stringify(masked, null, 2), 'Configuration (Secrets Masked)');
1541
1860
  continue;
1542
1861
  }
1543
1862
  if (cmd === '/model') {
1544
1863
  const res = await selectModel(config.baseUrl, config.apiKey, config.model, config.provider || '', true);
1545
- drawLogo(config.banner || 'full');
1546
1864
  if (res) {
1547
1865
  config.model = res.model;
1548
- config.maxContextTokens = res.contextLimit;
1866
+ config.maxContextTokens = config.contextLimits?.[config.model] ?? res.contextLimit;
1549
1867
  await saveConfig(config);
1550
- p.log.success(`Switched model to: ${pc.bold(config.model)}`);
1868
+ await refreshEndpoint(config).catch(() => null);
1869
+ announce(`model ${config.model}${providerFormat(config) === 'anthropic' ? ' (Anthropic format)' : ''}`);
1551
1870
  }
1552
1871
  else {
1553
- p.log.info('Model selection cancelled.');
1872
+ log.info('Model selection cancelled.');
1873
+ }
1874
+ continue;
1875
+ }
1876
+ if (cmd === '/effort') {
1877
+ const showSay = (say) => {
1878
+ if (config.ui?.layout === 'classic') {
1879
+ const text = say.detail ? `${say.text}\n${say.detail}` : say.text;
1880
+ if (say.kind === 'ok')
1881
+ log.success(text);
1882
+ else if (say.kind === 'warn')
1883
+ log.warn(text);
1884
+ else
1885
+ log.info(text);
1886
+ }
1887
+ else {
1888
+ console.log(renderNotice(say.kind, say.text));
1889
+ if (say.detail)
1890
+ console.log(renderResultBlock(say.detail, 'muted', process.stdout.columns || 80));
1891
+ }
1892
+ };
1893
+ const argText = parts.slice(1).join(' ').trim();
1894
+ if (/^(refresh|update)$/i.test(argText)) {
1895
+ showSay({ kind: 'info', text: 'asking models.dev and the endpoint...' });
1896
+ try {
1897
+ const result = await refreshCatalog();
1898
+ showSay({ kind: 'ok', text: `catalogue updated: ${result.models} models from ${result.providers} providers, ${result.priced} prices`, detail: `models.dev, ${result.generated}` });
1899
+ }
1900
+ catch (e) {
1901
+ const info = catalogInfo();
1902
+ showSay({ kind: 'warn', text: 'could not download the catalogue.', detail: `${String(e?.message || e)}. Using the ${info.downloaded ? 'downloaded' : 'built-in'} one (${info.models} models, ${info.generated}).` });
1903
+ }
1904
+ const route = detectRoute(config.baseUrl);
1905
+ if (route === 'generic' || route === 'openrouter') {
1906
+ const record = await refreshEndpoint(config, { force: true }).catch(() => null);
1907
+ showSay(record?.data
1908
+ ? { kind: 'ok', text: `the endpoint described ${config.model}` }
1909
+ : { kind: 'info', text: `the endpoint says nothing about ${config.model}`, detail: 'levels come from the catalogue or from the model name' });
1910
+ }
1911
+ continue;
1912
+ }
1913
+ // A custom endpoint is asked about this model first (cached, a few seconds at most).
1914
+ await refreshEndpoint(config).catch(() => null);
1915
+ const outcome = effortCommand(config, argText);
1916
+ if (outcome.type === 'say') {
1917
+ if (outcome.say.kind === 'ok')
1918
+ await saveConfig(config);
1919
+ showSay(outcome.say);
1920
+ }
1921
+ else {
1922
+ const choice = await runPicker(outcome.picker);
1923
+ if (choice === undefined) {
1924
+ showSay({ kind: 'info', text: 'effort unchanged' });
1925
+ }
1926
+ else {
1927
+ const say = chooseEffort(config, choice);
1928
+ await saveConfig(config);
1929
+ showSay(say);
1930
+ }
1554
1931
  }
1555
1932
  continue;
1556
1933
  }
1557
1934
  if (cmd === '/provider' || cmd === '/providers') {
1558
1935
  const newConfig = await selectProvider(config, true);
1559
- drawLogo(config.banner || 'full');
1560
1936
  if (newConfig) {
1561
1937
  config = newConfig;
1562
- p.log.success(`Switched provider to: ${pc.bold(config.provider || 'custom')} (${config.model})`);
1938
+ await refreshEndpoint(config).catch(() => null);
1939
+ announce(`provider ${config.provider || 'custom'} · ${config.model}${providerFormat(config) === 'anthropic' ? ' (Anthropic format)' : ''}`);
1563
1940
  }
1564
1941
  else {
1565
- p.log.info('Provider selection cancelled.');
1942
+ log.info('Provider selection cancelled.');
1566
1943
  }
1567
1944
  continue;
1568
1945
  }
@@ -1598,36 +1975,85 @@ export async function main() {
1598
1975
  planMode = false;
1599
1976
  const newSys = await buildSystemPrompt(false);
1600
1977
  history.updateSystemPrompt(newSys);
1601
- p.log.success(pc.bold(pc.green('⚡ Plan approved! Auto-switched to AGENT mode. Starting execution...')));
1978
+ log.success(pc.bold(pc.green('Plan approved! Auto-switched to AGENT mode. Starting execution...')));
1602
1979
  }
1603
1980
  }
1604
1981
  }
1605
1982
  }
1606
1983
  // Resolve @file and @image mentions
1607
- const { text: cleanAnswer, attachments, images } = await resolveAtMentions(answer);
1984
+ const { text: cleanAnswer, attachments, images, warnings: attachWarnings } = await resolveAtMentions(answer);
1985
+ for (const warning of attachWarnings)
1986
+ log.warn(warning);
1608
1987
  let finalContent = cleanAnswer;
1609
1988
  if (attachments.length > 0) {
1610
1989
  const attachBlocks = attachments.map(a => `[Attached file: ${a.path}]\n\`\`\`\n${a.content}\n\`\`\``).join('\n\n');
1611
1990
  finalContent = `${cleanAnswer}\n\n--- Attached Files ---\n${attachBlocks}`;
1612
- p.log.info(pc.cyan(`📎 Attached ${attachments.length} file(s): ${attachments.map(a => a.path).join(', ')}`));
1991
+ log.info(pc.cyan(`Attached ${attachments.length} file(s): ${attachments.map(a => a.path).join(', ')}`));
1613
1992
  }
1614
1993
  if (images.length > 0) {
1615
- p.log.info(pc.cyan(`🖼️ Attached ${images.length} image(s): ${images.map(i => i.path).join(', ')}`));
1994
+ if (modelAcceptsImages(config.model) === false) {
1995
+ log.warn(`${config.model} cannot read images: ${images.length === 1 ? 'the image' : 'the images'} will not be sent. Switch to a model with vision (/model).`);
1996
+ }
1997
+ else {
1998
+ log.info(pc.cyan(`Attached ${images.length} image(s): ${images.map(i => path.basename(i.path)).join(', ')}`));
1999
+ }
2000
+ }
2001
+ // The session is over its cost limit: ask before spending more.
2002
+ if (costLimit() > 0 && sessionCost.total >= costLimit()) {
2003
+ const proceed = await askConfirm({
2004
+ title: t('cost.title'),
2005
+ question: t('cost.reached', { spent: formatCost(sessionCost.total), limit: formatCost(costLimit()) }),
2006
+ details: [t('cost.raise', { limit: formatCost(costLimit() * 2) })],
2007
+ yes: t('cost.continue'),
2008
+ no: t('cost.cancel'),
2009
+ });
2010
+ if (!proceed) {
2011
+ log.info(t('cost.cancelled'));
2012
+ currentDraft = answer;
2013
+ continue;
2014
+ }
2015
+ costLimitRaised = costLimit() * 2;
2016
+ costWarned = false;
1616
2017
  }
1617
2018
  globalSnapshotManager.beginTurn();
2019
+ let turnCheckpoint = null;
2020
+ if (!planMode) {
2021
+ turnCheckpoint = await createCheckpoint(process.cwd(), checkpoints[checkpoints.length - 1]?.sha);
2022
+ if (turnCheckpoint)
2023
+ checkpoints.push(turnCheckpoint);
2024
+ }
2025
+ transcript.push({ kind: 'user', text: answer });
2026
+ // Until the model has named the chat, the start of the first message stands in for the name.
2027
+ if (!windowTitle && !sessionManager.hasGeneratedTitle)
2028
+ setWindowTitle(provisionalTitle(answer));
1618
2029
  history.addMessage({
1619
2030
  role: 'user',
1620
2031
  content: finalContent,
1621
- images: images.length > 0 ? images : undefined
2032
+ images: images.length > 0 ? images : undefined,
2033
+ at: Date.now(),
2034
+ checkpoint: turnCheckpoint?.sha,
1622
2035
  });
2036
+ // The format of the gateway is learned once (cached); the first request waits for it.
2037
+ await refreshFormats(config).catch(() => null);
1623
2038
  const provider = createProvider(config);
1624
2039
  const tools = getTools(planMode);
1625
- const guard = new CLIConsoleGuard(config.autoApprove, config.bashAllowlist || []);
2040
+ const guard = new CLIConsoleGuard(config.autoApprove, [...(config.bashAllowlist || [])], {
2041
+ memory: permissionMemory,
2042
+ // "Always": kept in the config for the next sessions (`/permissions` lists and clears them).
2043
+ onAlways: (r) => {
2044
+ if (r.kind === 'bash')
2045
+ config.bashAllowlist = Array.from(new Set([...(config.bashAllowlist || []), r.key]));
2046
+ else
2047
+ config.allowedTools = Array.from(new Set([...(config.allowedTools || []), r.key]));
2048
+ void saveConfig(config).catch(() => null);
2049
+ },
2050
+ });
1626
2051
  const agentConfig = {
1627
2052
  maxContextTokens: config.maxContextTokens || 100000,
1628
2053
  maxIterations: config.maxIterations || 100,
1629
2054
  autoApprove: config.autoApprove,
1630
- bashAllowlist: config.bashAllowlist
2055
+ bashAllowlist: config.bashAllowlist,
2056
+ hooks: config.hooks,
1631
2057
  };
1632
2058
  const agent = new Agent(agentConfig, provider, tools, history, guard);
1633
2059
  const taskStartTime = Date.now();
@@ -1637,30 +2063,217 @@ export async function main() {
1637
2063
  let spinnerActive = false;
1638
2064
  let thinkingActive = false;
1639
2065
  let thinkingStartTime = 0;
2066
+ // Thinking is collapsed by default: only a live line shows the newest words. ctrl+p shows it in full.
2067
+ let thinkingStreaming = false;
2068
+ let thoughtBuffer = '';
2069
+ let thoughtPrinted = 0;
2070
+ let liveShown = 0; // how much of the reasoning the live line has flowed through
2071
+ let liveAt = 0;
2072
+ // The same for commands and their output (ctrl+o).
1640
2073
  let textActive = false;
1641
- const s = p.spinner();
2074
+ let assistantBuf = '';
2075
+ // The last finished reasoning waits for the reply it belongs to, to be saved with it.
2076
+ let pendingReasoning = null;
2077
+ let pendingRedraw = false;
2078
+ let gapBeforeTool = false;
2079
+ const cleanUi = config.ui?.layout !== 'classic';
2080
+ const s = (cleanUi ? new UiSpinner() : p.spinner());
2081
+ const uiSpinner = cleanUi ? s : null;
2082
+ const assistantGutter = new GutterWriter(palette.accent('●') + ' ', ' ');
2083
+ const thoughtGutter = new GutterWriter(palette.faint(' │ '), palette.faint(' │ '));
1642
2084
  const streamer = new MarkdownStreamer();
2085
+ if (cleanUi)
2086
+ streamer.setSink((text) => assistantGutter.write(text));
1643
2087
  const thoughtStreamer = new SmoothStreamer((chunk) => {
1644
- process.stdout.write(pc.dim(chunk));
2088
+ if (cleanUi)
2089
+ thoughtGutter.write(palette.muted(chunk));
2090
+ else
2091
+ process.stdout.write(pc.dim(chunk));
1645
2092
  });
2093
+ let turnOutputTokens = 0;
2094
+ // Live token counter of this turn: exact numbers of finished requests plus an estimate for
2095
+ // the request in flight: what the model has written for the user's request so far (↓).
2096
+ let turnInputTokens = 0;
2097
+ let requestOutEstimate = 0;
2098
+ let toolArgsEstimate = 0;
2099
+ // The shown number eases toward the real one: it climbs fast, then slows down, instead of jumping.
2100
+ let shownOutput = 0;
2101
+ let shownAt = Date.now();
2102
+ const liveTokens = () => {
2103
+ const target = turnOutputTokens + requestOutEstimate + toolArgsEstimate;
2104
+ const now = Date.now();
2105
+ shownOutput = easeToward(shownOutput, target, now - shownAt);
2106
+ shownAt = now;
2107
+ return `↓ ${formatTokensShort(Math.round(shownOutput))} tokens`;
2108
+ };
2109
+ const costAtTurnStart = sessionCost.total;
2110
+ const turnCost = { estimated: false, unpriced: 0, priced: 0 };
2111
+ let spacerBeforeText = false;
2112
+ // Tool results in the clean layout: a quiet "╰ summary" line, a diff box for edits.
2113
+ const renderCleanToolResult = (name, result) => {
2114
+ const rows = toolResultRows(name, result, { cols: process.stdout.columns || 80, expanded: outputExpanded, diffColors: !!config.ui?.diffColors });
2115
+ if (rows.length > 0)
2116
+ console.log(rows.join('\n'));
2117
+ };
1646
2118
  const telemetry = new LiveTelemetryTracker();
1647
2119
  telemetry.start('connecting');
2120
+ uiSpinner?.setMetaProvider(liveTokens);
2121
+ // Only a text that is being written holds it back (its line cannot be cut in the middle).
2122
+ /** The reasoning goes into the saved session with the reply it came before. */
2123
+ const attachReasoning = () => {
2124
+ if (!pendingReasoning)
2125
+ return;
2126
+ const messages = history.getMessages();
2127
+ for (let i = messages.length - 1; i >= 0; i--) {
2128
+ if (messages[i].role === 'assistant') {
2129
+ if (!messages[i].reasoning) {
2130
+ messages[i].reasoning = pendingReasoning;
2131
+ pendingReasoning = null;
2132
+ }
2133
+ return;
2134
+ }
2135
+ if (messages[i].role === 'user')
2136
+ return; // the reply is not written yet
2137
+ }
2138
+ };
2139
+ const safeToRedraw = () => !textActive;
2140
+ /** Clears the screen and draws the whole conversation again, folded or unfolded as the switches say. */
2141
+ const redraw = () => {
2142
+ composer.suspend();
2143
+ // A reasoning that was being printed in full is part of the screen that is wiped: it starts again.
2144
+ if (thinkingActive) {
2145
+ thinkingActive = false;
2146
+ thoughtPrinted = 0;
2147
+ thoughtStreamer.reset();
2148
+ thoughtGutter.reset();
2149
+ }
2150
+ redrawChat();
2151
+ composer.resume();
2152
+ };
2153
+ const runPendingRedraw = () => {
2154
+ if (pendingRedraw && safeToRedraw()) {
2155
+ pendingRedraw = false;
2156
+ redraw();
2157
+ }
2158
+ };
2159
+ /** The assistant's text is complete: close its line and keep it in the conversation. */
2160
+ const endText = () => {
2161
+ if (textActive) {
2162
+ process.stdout.write('\n');
2163
+ textActive = false;
2164
+ }
2165
+ if (assistantBuf.trim())
2166
+ transcript.push({ kind: 'assistant', text: assistantBuf });
2167
+ assistantBuf = '';
2168
+ runPendingRedraw();
2169
+ };
1648
2170
  const finishThinking = (extraNewline = true) => {
1649
- if (!thinkingActive)
2171
+ if (!thinkingActive && !thinkingStreaming)
1650
2172
  return;
1651
- thoughtStreamer.flush();
2173
+ if (thinkingActive) {
2174
+ thoughtStreamer.flush();
2175
+ thoughtGutter.flush();
2176
+ }
1652
2177
  const elapsedSec = thinkingStartTime > 0 ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : '0.0';
1653
2178
  const teleStats = config.ui?.liveTelemetry && telemetry.getTotalTokens() > 0
1654
2179
  ? ` · ${LiveTelemetryTracker.formatTokenCount(telemetry.getTotalTokens())} tok · ${telemetry.getTokensPerSecond()} tok/s`
1655
2180
  : '';
1656
- const width = Math.min(process.stdout.columns || 40, 52);
1657
- const label = `─── Thought for ${elapsedSec}s${teleStats} `;
1658
- const fillLen = Math.max(2, width - label.length);
1659
- process.stdout.write('\n\n' + pc.dim(label + '─'.repeat(fillLen)) + (extraNewline ? '\n\n' : '\n'));
2181
+ if (cleanUi && !thinkingActive) {
2182
+ // Folded: one quiet line instead of the whole reasoning.
2183
+ if (spacerBeforeText)
2184
+ process.stdout.write('\n');
2185
+ process.stdout.write(collapsedThoughtLine(`${t('status.thoughtFor', { sec: elapsedSec })}${teleStats}`, !!thoughtBuffer.trim(), process.stdout.columns || 80) + '\n');
2186
+ spacerBeforeText = true;
2187
+ }
2188
+ else if (cleanUi) {
2189
+ process.stdout.write('\n' + palette.faint(` ╰ ${t('status.thoughtFor', { sec: elapsedSec })}${teleStats}`) + '\n');
2190
+ thoughtGutter.reset();
2191
+ spacerBeforeText = true;
2192
+ }
2193
+ else {
2194
+ const width = Math.min(process.stdout.columns || 40, 52);
2195
+ const label = `─── Thought for ${elapsedSec}s${teleStats} `;
2196
+ const fillLen = Math.max(2, width - label.length);
2197
+ process.stdout.write('\n\n' + pc.dim(label + '─'.repeat(fillLen)) + (extraNewline ? '\n\n' : '\n'));
2198
+ }
2199
+ if (cleanUi) {
2200
+ transcript.push({ kind: 'thought', text: thoughtBuffer, sec: elapsedSec, stats: teleStats });
2201
+ gapBeforeTool = true;
2202
+ }
2203
+ if (thoughtBuffer.trim())
2204
+ pendingReasoning = { text: thoughtBuffer.trim(), sec: elapsedSec, stats: teleStats };
2205
+ thoughtBuffer = '';
2206
+ thoughtPrinted = 0;
2207
+ liveShown = 0;
1660
2208
  thinkingActive = false;
2209
+ thinkingStreaming = false;
1661
2210
  thinkingStartTime = 0;
2211
+ runPendingRedraw();
2212
+ };
2213
+ /** Prints the header of the reasoning block (once) and what has not been written of it yet. */
2214
+ const showThought = () => {
2215
+ if (!thinkingActive) {
2216
+ process.stdout.write((cleanUi ? (spacerBeforeText ? '\n' : '') : '\n') + (cleanUi ? palette.muted(` ∴ ${t('status.thinkingLine')}`) : pc.bold(pc.white('› Thought:'))) + '\n');
2217
+ if (cleanUi)
2218
+ spacerBeforeText = false;
2219
+ thinkingActive = true;
2220
+ }
2221
+ if (thoughtBuffer.length > thoughtPrinted) {
2222
+ thoughtStreamer.push(thoughtBuffer.slice(thoughtPrinted));
2223
+ thoughtPrinted = thoughtBuffer.length;
2224
+ }
2225
+ };
2226
+ /**
2227
+ * The newest words of the reasoning on one line while it is folded: half the width of the
2228
+ * screen, the oldest words fading out on the left, an ellipsis at the end. The text flows
2229
+ * in a few characters at a time (faster the further it is behind) instead of jumping with
2230
+ * every chunk the model sends.
2231
+ */
2232
+ const liveThought = () => {
2233
+ if (!cleanUi || !thinkingStreaming || thinkingActive)
2234
+ return '';
2235
+ const text = thoughtBuffer.replace(/\s+/g, ' ').trimStart();
2236
+ const now = Date.now();
2237
+ const lag = text.length - liveShown;
2238
+ if (lag > 0)
2239
+ liveShown = Math.min(text.length, liveShown + Math.max(1, Math.ceil((now - liveAt) * Math.max(0.12, lag / 220))));
2240
+ else
2241
+ liveShown = text.length;
2242
+ liveAt = now;
2243
+ const cols = process.stdout.columns || 80;
2244
+ const hint = ` (${t('view.expandThought')})`;
2245
+ const width = Math.max(16, Math.min(Math.floor(cols / 2) - 2, cols - 8 - displayWidth(hint)));
2246
+ const tail = Array.from(tailWidth(text.slice(0, liveShown), width));
2247
+ if (tail.length === 0)
2248
+ return '';
2249
+ const third = Math.ceil(tail.length / 3);
2250
+ const old = tail.slice(0, third).join('');
2251
+ const mid = tail.slice(third, third * 2).join('');
2252
+ const fresh = tail.slice(third * 2).join('');
2253
+ return ' ' + palette.faint('∴') + ' ' + palette.faint(old) + palette.muted(mid) + palette.text(fresh) + palette.faint('…' + hint);
1662
2254
  };
1663
- const stopGeneration = () => {
2255
+ /** ctrl+p / ctrl+o: fold or unfold the reasoning / the tool output of the whole chat, also what is already above. */
2256
+ const toggleView = (kind) => {
2257
+ if (!cleanUi)
2258
+ return;
2259
+ if (kind === 'thinking')
2260
+ thinkingExpanded = !thinkingExpanded;
2261
+ else
2262
+ outputExpanded = !outputExpanded;
2263
+ composer.refresh();
2264
+ if (safeToRedraw()) {
2265
+ redraw();
2266
+ // A thought that is being written goes on in full from its beginning.
2267
+ if (thinkingExpanded && thinkingStreaming)
2268
+ showThought();
2269
+ }
2270
+ else {
2271
+ pendingRedraw = true;
2272
+ }
2273
+ };
2274
+ // Ctrl+Enter: what the user typed goes to the agent right now, replacing the task it was on.
2275
+ let interruptPrompt = '';
2276
+ const stopGeneration = (quiet = false, reason) => {
1664
2277
  if (aborted)
1665
2278
  return;
1666
2279
  aborted = true;
@@ -1673,24 +2286,100 @@ export async function main() {
1673
2286
  thoughtStreamer.reset();
1674
2287
  finishThinking(true);
1675
2288
  streamer.finish();
1676
- if (textActive) {
1677
- process.stdout.write('\n');
1678
- }
1679
- if (isConnecting) {
1680
- console.log(pc.yellow('\n⏹ Подключение отменено / Connection cancelled (Ctrl+C).'));
2289
+ endText();
2290
+ if (reason) {
2291
+ console.log(renderNotice('warn', reason));
2292
+ console.log();
2293
+ }
2294
+ else if (quiet) {
2295
+ // The message that caused the stop is echoed by the next turn: only a quiet separator here.
2296
+ if (cleanUi)
2297
+ console.log(renderNotice('info', t('stop.interrupted')));
2298
+ else
2299
+ console.log(pc.yellow('\n' + t('stop.classicInterrupted')));
2300
+ console.log();
2301
+ }
2302
+ else if (cleanUi) {
2303
+ console.log(renderNotice('warn', isConnecting ? t('stop.connection') : t('stop.stopped')));
2304
+ console.log();
2305
+ }
2306
+ else if (isConnecting) {
2307
+ console.log(pc.yellow('\n' + t('stop.classicConnection')));
1681
2308
  }
1682
2309
  else {
1683
- console.log(pc.yellow('\n⏹ Generation stopped / Ответ остановлен (Ctrl+C).'));
2310
+ console.log(pc.yellow('\n' + t('stop.classicStopped')));
1684
2311
  }
1685
2312
  };
1686
2313
  const onSigInt = () => {
1687
2314
  stopGeneration();
1688
2315
  };
2316
+ const sendQueuedNow = () => {
2317
+ if (aborted)
2318
+ return;
2319
+ interruptPrompt = composer.takeQueued().join('\n');
2320
+ if (interruptPrompt)
2321
+ stopGeneration(true);
2322
+ };
2323
+ // The input line at the bottom while the agent works: what is typed there is queued and
2324
+ // reaches the model at its next step (see TurnComposer).
2325
+ // The working line (time and tokens) stays above the frame for the whole turn.
2326
+ let statusLabel = t('status.waiting');
2327
+ let statusFrame = 0;
2328
+ const statusStartedAt = Date.now();
2329
+ const statusLine = () => renderStatusLine({
2330
+ label: statusLabel,
2331
+ elapsedMs: Date.now() - statusStartedAt,
2332
+ meta: liveTokens(),
2333
+ frame: statusFrame++,
2334
+ columns: process.stdout.columns || 80,
2335
+ });
2336
+ const composer = new TurnComposer(process.stdout, () => {
2337
+ const ctx = currentContext();
2338
+ const used = ctx.total - ctx.system - ctx.tools;
2339
+ const git = GitStatusCache.getStatus();
2340
+ return {
2341
+ mode: resolveMode(planMode, !!config.autoApprove),
2342
+ model: modelLabel(config),
2343
+ cwd: process.cwd(),
2344
+ branch: git?.branch,
2345
+ dirty: git?.isDirty,
2346
+ contextPercent: Math.min(100, Math.round((used / (config.maxContextTokens || getModelContextLimit(config.model))) * 100)),
2347
+ cost: sessionCost.total,
2348
+ costState: sessionCost.state(),
2349
+ };
2350
+ }, undefined, statusLine, sendQueuedNow);
2351
+ composer.live = liveThought;
2352
+ composer.onToggle = toggleView;
2353
+ const releasePauseHook = registerPauseHook(() => {
2354
+ composer.suspend();
2355
+ return () => composer.resume();
2356
+ });
1689
2357
  const onRawData = (data) => {
1690
- const buf = Buffer.isBuffer(data) ? data : Buffer.from(String(data));
1691
- if (buf.includes(3) || buf.includes(0x03) || data === '\x03') {
2358
+ let text = (Buffer.isBuffer(data) ? data : Buffer.from(String(data))).toString('utf8');
2359
+ // Focus reports: ESC [ I (got the focus) and ESC [ O (lost it). They are not keys.
2360
+ if (text.includes('\x1b[I') || text.includes('\x1b[O')) {
2361
+ terminalFocused = text.lastIndexOf('\x1b[I') > text.lastIndexOf('\x1b[O');
2362
+ text = text.replace(/\x1b\[[IO]/g, '');
2363
+ if (!text)
2364
+ return;
2365
+ }
2366
+ // Ctrl+C is 0x03, or `CSI 99;5u` while the extended keyboard modes are on.
2367
+ if (text.includes('\x03') || splitKeys(text).some((k) => normalizeKey(k) === '\x03')) {
1692
2368
  stopGeneration();
2369
+ return;
1693
2370
  }
2371
+ composer.handleData(text);
2372
+ };
2373
+ const onResize = () => composer.onResize();
2374
+ /** What a queued message becomes in the conversation: its @files and images are attached like in a normal prompt. */
2375
+ const queuedToMessage = async (text) => {
2376
+ const resolved = await resolveAtMentions(text);
2377
+ let content = resolved.text;
2378
+ if (resolved.attachments.length > 0) {
2379
+ const blocks = resolved.attachments.map(a => `[Attached file: ${a.path}]\n\`\`\`\n${a.content}\n\`\`\``).join('\n\n');
2380
+ content = `${resolved.text}\n\n--- Attached Files ---\n${blocks}`;
2381
+ }
2382
+ return { role: 'user', content, images: resolved.images.length > 0 ? resolved.images : undefined };
1694
2383
  };
1695
2384
  activeAbortHandler = stopGeneration;
1696
2385
  process.on('SIGINT', onSigInt);
@@ -1699,36 +2388,53 @@ export async function main() {
1699
2388
  process.stdin.resume();
1700
2389
  process.stdin.setRawMode(true);
1701
2390
  process.stdin.on('data', onRawData);
2391
+ if (cleanUi && config.ui?.typeAhead !== false && composer.start()) {
2392
+ process.stdout.on('resize', onResize);
2393
+ uiSpinner?.mute(true);
2394
+ process.stdout.write('\x1b[?1004h'); // the terminal tells when it loses or gets the focus
2395
+ }
1702
2396
  }
1703
2397
  catch { }
1704
2398
  }
1705
- s.start('Connecting...');
2399
+ s.start(cleanUi ? t('status.waiting') : 'Connecting...');
1706
2400
  spinnerActive = true;
1707
2401
  const turnStartTime = Date.now();
1708
2402
  let toolsExecutedCount = 0;
1709
2403
  try {
1710
- for await (const event of agent.run(abortController.signal)) {
2404
+ for await (const event of agent.run(abortController.signal, {
2405
+ takePending: async () => Promise.all(composer.takeQueued().map(queuedToMessage)),
2406
+ hasPending: () => composer.hasQueued(),
2407
+ })) {
1711
2408
  if (aborted)
1712
2409
  break;
1713
2410
  if (event.type === 'reasoning_delta') {
1714
2411
  isConnecting = false;
1715
2412
  telemetry.setPhase('thinking');
2413
+ statusLabel = t('status.thinking');
1716
2414
  telemetry.recordTokens(telemetry.estimateTokensFromText(event.delta));
2415
+ requestOutEstimate += telemetry.estimateTokensFromText(event.delta);
1717
2416
  if (spinnerActive) {
1718
2417
  s.stop();
1719
2418
  spinnerActive = false;
1720
2419
  }
1721
- if (!thinkingActive) {
2420
+ if (!thinkingStreaming) {
2421
+ thinkingStreaming = true;
1722
2422
  thinkingStartTime = Date.now();
1723
- process.stdout.write('\n' + pc.bold(pc.white('› Thought:')) + '\n');
1724
- thinkingActive = true;
2423
+ thoughtBuffer = '';
2424
+ thoughtPrinted = 0;
2425
+ liveShown = 0;
2426
+ liveAt = Date.now();
1725
2427
  }
1726
- thoughtStreamer.push(event.delta);
2428
+ thoughtBuffer += event.delta;
2429
+ if (!cleanUi || thinkingExpanded)
2430
+ showThought();
1727
2431
  }
1728
2432
  else if (event.type === 'text_delta') {
1729
2433
  isConnecting = false;
1730
2434
  telemetry.setPhase('generating');
2435
+ statusLabel = t('status.writing');
1731
2436
  telemetry.recordTokens(telemetry.estimateTokensFromText(event.delta));
2437
+ requestOutEstimate += telemetry.estimateTokensFromText(event.delta);
1732
2438
  if (spinnerActive) {
1733
2439
  s.stop();
1734
2440
  spinnerActive = false;
@@ -1736,7 +2442,15 @@ export async function main() {
1736
2442
  finishThinking(true);
1737
2443
  if (!textActive) {
1738
2444
  textActive = true;
2445
+ if (cleanUi) {
2446
+ if (spacerBeforeText)
2447
+ process.stdout.write('\n');
2448
+ spacerBeforeText = false;
2449
+ assistantGutter.reset();
2450
+ }
1739
2451
  }
2452
+ assistantBuf += event.delta;
2453
+ gapBeforeTool = false;
1740
2454
  streamer.push(event.delta);
1741
2455
  }
1742
2456
  else if (event.type === 'text') {
@@ -1747,7 +2461,18 @@ export async function main() {
1747
2461
  }
1748
2462
  finishThinking(true);
1749
2463
  if (!textActive) {
1750
- console.log('\n' + renderMarkdown(event.content) + '\n');
2464
+ if (cleanUi) {
2465
+ if (spacerBeforeText)
2466
+ console.log();
2467
+ spacerBeforeText = false;
2468
+ assistantGutter.reset();
2469
+ assistantGutter.write(renderMarkdown(event.content) + '\n');
2470
+ console.log();
2471
+ transcript.push({ kind: 'assistant', text: event.content });
2472
+ }
2473
+ else {
2474
+ console.log('\n' + renderMarkdown(event.content) + '\n');
2475
+ }
1751
2476
  }
1752
2477
  }
1753
2478
  else if (event.type === 'tool_generating') {
@@ -1755,47 +2480,102 @@ export async function main() {
1755
2480
  telemetry.setPhase('tool');
1756
2481
  const theme = getCurrentTheme();
1757
2482
  const label = formatToolGeneratingLabel(event.name, event.targetHint);
2483
+ statusLabel = label;
1758
2484
  const teleStats = telemetry.getTotalTokens() > 0
1759
2485
  ? ` · ${LiveTelemetryTracker.formatTokenCount(telemetry.getTotalTokens())} tok · ${telemetry.getTokensPerSecond()} tok/s`
1760
2486
  : '';
1761
2487
  const telemetrySuffix = config.ui?.liveTelemetry ? ` ${pc.dim(`(${telemetry.getElapsedSeconds()}s${teleStats})`)}` : '';
2488
+ // The model has started on a tool call: its text is complete. (A long write can take minutes, and
2489
+ // ctrl+p / ctrl+o must work meanwhile: they wait only for a text that is still being written.)
2490
+ if (textActive) {
2491
+ streamer.finish();
2492
+ endText();
2493
+ }
1762
2494
  if (!spinnerActive) {
1763
2495
  streamer.finish();
1764
2496
  finishThinking(false);
1765
- s.start(theme.boldFn(label) + telemetrySuffix);
2497
+ s.start(cleanUi ? label : theme.boldFn(label) + telemetrySuffix);
1766
2498
  spinnerActive = true;
1767
2499
  }
1768
2500
  else {
1769
- s.message(theme.boldFn(label) + telemetrySuffix);
2501
+ s.message(cleanUi ? label : theme.boldFn(label) + telemetrySuffix);
1770
2502
  }
2503
+ // The arguments written so far (about 3 bytes per token), on top of the text before them.
2504
+ toolArgsEstimate = Math.max(toolArgsEstimate, Math.round(event.bytes / 3));
2505
+ }
2506
+ else if (event.type === 'user_message') {
2507
+ // The message typed during the work reached the model: show it in the chat like any prompt.
2508
+ if (spinnerActive) {
2509
+ s.stop();
2510
+ spinnerActive = false;
2511
+ }
2512
+ finishThinking(true);
2513
+ streamer.finish();
2514
+ endText();
2515
+ statusLabel = t('status.waiting');
2516
+ const typed = event.content.split('\n\n--- Attached Files ---')[0];
2517
+ transcript.push({ kind: 'user', text: typed });
2518
+ console.log();
2519
+ console.log(renderUserEcho(typed, process.stdout.columns || 80).join('\n'));
2520
+ console.log();
2521
+ s.start(cleanUi ? t('status.waiting') : 'Connecting...');
2522
+ spinnerActive = true;
1771
2523
  }
1772
2524
  else if (event.type === 'tool_start') {
2525
+ attachReasoning();
1773
2526
  isConnecting = false;
1774
2527
  telemetry.reset();
2528
+ statusLabel = event.actionDesc || event.name;
2529
+ // A question to the user takes over the keyboard: the typing line steps aside.
2530
+ if (event.name === 'ask_questions')
2531
+ composer.suspend();
1775
2532
  if (spinnerActive) {
1776
2533
  s.stop();
1777
2534
  spinnerActive = false;
1778
2535
  }
1779
2536
  streamer.finish();
1780
2537
  finishThinking(false);
1781
- if (textActive) {
1782
- process.stdout.write('\n');
1783
- textActive = false;
2538
+ endText();
2539
+ if (cleanUi) {
2540
+ if (gapBeforeTool)
2541
+ console.log();
2542
+ gapBeforeTool = false;
2543
+ transcript.push({ kind: 'tool', name: event.name, desc: event.actionDesc || event.name });
2544
+ console.log(renderToolLine(event.actionDesc, process.stdout.columns || 80, outputExpanded));
2545
+ assistantGutter.reset();
2546
+ }
2547
+ else {
2548
+ console.log('\n' + pc.bold(pc.white(event.actionDesc)));
1784
2549
  }
1785
- console.log('\n' + pc.bold(pc.white(event.actionDesc)));
1786
2550
  if (event.name !== 'ask_questions' && event.name !== 'todo_list') {
1787
- s.start('Executing...');
2551
+ s.start(cleanUi ? t('status.running') : 'Executing...');
1788
2552
  spinnerActive = true;
1789
2553
  }
1790
2554
  }
1791
2555
  else if (event.type === 'tool_end') {
2556
+ for (let i = transcript.length - 1; i >= 0; i--) {
2557
+ const it = transcript[i];
2558
+ if (it.kind === 'tool' && it.result === undefined) {
2559
+ it.result = event.result || '';
2560
+ break;
2561
+ }
2562
+ }
2563
+ if (event.name === 'ask_questions')
2564
+ composer.resume();
2565
+ statusLabel = t('status.waiting');
2566
+ composer.refresh();
1792
2567
  toolsExecutedCount++;
1793
2568
  GitStatusCache.invalidate();
1794
2569
  if (spinnerActive) {
1795
2570
  s.stop();
1796
2571
  spinnerActive = false;
1797
2572
  }
1798
- if (event.result) {
2573
+ if (cleanUi) {
2574
+ if (event.result)
2575
+ renderCleanToolResult(event.name, event.result);
2576
+ spacerBeforeText = true;
2577
+ }
2578
+ else if (event.result) {
1799
2579
  try {
1800
2580
  const parsed = JSON.parse(event.result);
1801
2581
  if (parsed.type === 'todo_list' && parsed.displayCard) {
@@ -1824,36 +2604,71 @@ export async function main() {
1824
2604
  }
1825
2605
  }
1826
2606
  else if (event.type === 'usage') {
1827
- totalSessionCost += event.usage.cost || 0;
2607
+ attachReasoning();
2608
+ sessionCost.add(event.usage);
2609
+ // The cost limit: a warning at 80%, the task is stopped at 100%.
2610
+ const limit = costLimit();
2611
+ if (limit > 0 && !aborted) {
2612
+ if (sessionCost.total >= limit) {
2613
+ stopGeneration(false, t('cost.stopped', { limit: formatCost(limit) }));
2614
+ }
2615
+ else if (!costWarned && sessionCost.total >= limit * 0.8) {
2616
+ costWarned = true;
2617
+ console.log(renderNotice('warn', t('cost.warn', { spent: formatCost(sessionCost.total), limit: formatCost(limit) })));
2618
+ }
2619
+ }
2620
+ composer.refresh();
2621
+ if (typeof event.usage.cost === 'number') {
2622
+ turnCost.priced = (turnCost.priced ?? 0) + 1;
2623
+ if (event.usage.costSource === 'estimated')
2624
+ turnCost.estimated = true;
2625
+ }
2626
+ else {
2627
+ turnCost.unpriced = (turnCost.unpriced ?? 0) + 1;
2628
+ }
2629
+ turnOutputTokens += event.usage.completionTokens || 0;
2630
+ turnInputTokens += event.usage.promptTokens || 0;
2631
+ requestOutEstimate = 0;
2632
+ toolArgsEstimate = 0;
1828
2633
  }
1829
2634
  else if (event.type === 'reconnecting') {
2635
+ statusLabel = t('status.reconnecting');
1830
2636
  telemetry.reset();
1831
2637
  if (spinnerActive) {
1832
2638
  s.stop();
1833
2639
  spinnerActive = false;
1834
2640
  }
1835
- if (thinkingActive) {
2641
+ if (thinkingActive || thinkingStreaming) {
1836
2642
  finishThinking(false);
1837
2643
  }
1838
- if (textActive) {
1839
- process.stdout.write('\n');
1840
- textActive = false;
1841
- }
2644
+ endText();
1842
2645
  streamer.reset();
1843
- console.log();
1844
- console.log(pc.bold(pc.yellow(`⚠️ Интернет-соединение нестабильно (${event.reason})`)));
1845
- console.log(pc.cyan(`🔄 Попытка переподключения [${event.attempt}/${event.maxAttempts}] (пауза ${(event.delayMs / 1000).toFixed(1)}с)...`));
1846
- s.start(pc.cyan(`Ожидание сети [${event.attempt}/${event.maxAttempts}]...`));
2646
+ if (cleanUi) {
2647
+ console.log(renderNotice('warn', t('conn.unstable', { reason: event.reason })));
2648
+ s.start(t('conn.reconnecting', { attempt: event.attempt, max: event.maxAttempts, delay: (event.delayMs / 1000).toFixed(1) }));
2649
+ }
2650
+ else {
2651
+ console.log();
2652
+ console.log(pc.bold(pc.yellow(t('conn.classicUnstable', { reason: event.reason }))));
2653
+ console.log(pc.cyan(t('conn.classicRetry', { attempt: event.attempt, max: event.maxAttempts, delay: (event.delayMs / 1000).toFixed(1) })));
2654
+ s.start(pc.cyan(t('conn.classicWaiting', { attempt: event.attempt, max: event.maxAttempts })));
2655
+ }
1847
2656
  spinnerActive = true;
1848
2657
  }
1849
2658
  else if (event.type === 'reconnected') {
2659
+ statusLabel = t('status.waiting');
1850
2660
  if (spinnerActive) {
1851
2661
  s.stop();
1852
2662
  spinnerActive = false;
1853
2663
  }
1854
- console.log(pc.bold(pc.green(`✔ Соединение восстановлено! Возобновляем генерацию...`)));
1855
- console.log();
1856
- s.start('Connecting...');
2664
+ if (cleanUi) {
2665
+ console.log(renderNotice('ok', t('conn.restored')));
2666
+ }
2667
+ else {
2668
+ console.log(pc.bold(pc.green(t('conn.classicRestored'))));
2669
+ console.log();
2670
+ }
2671
+ s.start(cleanUi ? t('status.waiting') : 'Connecting...');
1857
2672
  spinnerActive = true;
1858
2673
  }
1859
2674
  else if (event.type === 'error') {
@@ -1863,14 +2678,20 @@ export async function main() {
1863
2678
  }
1864
2679
  finishThinking(true);
1865
2680
  streamer.finish();
1866
- p.log.error(pc.bold(pc.red(`❌ ${event.message}`)));
2681
+ if (cleanUi)
2682
+ console.log(renderNotice('error', event.message));
2683
+ else
2684
+ log.error(pc.bold(pc.red(`${event.message}`)));
1867
2685
  }
1868
2686
  else if (event.type === 'system') {
1869
2687
  if (spinnerActive) {
1870
2688
  s.stop();
1871
2689
  spinnerActive = false;
1872
2690
  }
1873
- p.log.info(event.message);
2691
+ if (cleanUi)
2692
+ console.log(renderNotice('info', event.message));
2693
+ else
2694
+ log.info(event.message);
1874
2695
  }
1875
2696
  }
1876
2697
  finishThinking(true);
@@ -1879,59 +2700,113 @@ export async function main() {
1879
2700
  s.stop();
1880
2701
  spinnerActive = false;
1881
2702
  }
1882
- if (textActive) {
1883
- process.stdout.write('\n');
2703
+ endText();
2704
+ // Hand the bottom of the screen back. Whatever was typed but not sent waits in the next prompt;
2705
+ // a message that arrived too late for the agent starts the next turn by itself.
2706
+ const leftover = composer.stop();
2707
+ if (interruptPrompt) {
2708
+ autoTriggerPrompt = interruptPrompt;
2709
+ if (leftover.draft)
2710
+ currentDraft = leftover.draft;
2711
+ }
2712
+ else if (leftover.queued.length > 0) {
2713
+ if (aborted)
2714
+ currentDraft = [...leftover.queued, leftover.draft].filter(Boolean).join(' ');
2715
+ else
2716
+ autoTriggerPrompt = leftover.queued.join('\n');
2717
+ }
2718
+ else if (leftover.draft) {
2719
+ currentDraft = leftover.draft;
1884
2720
  }
2721
+ attachReasoning();
1885
2722
  const turnElapsedMs = Date.now() - turnStartTime;
2723
+ if (cleanUi && !aborted) {
2724
+ const summaryRow = renderTurnSummary({
2725
+ ms: turnElapsedMs,
2726
+ tools: toolsExecutedCount,
2727
+ outputTokens: turnOutputTokens,
2728
+ inputTokens: turnInputTokens,
2729
+ cost: sessionCost.total - costAtTurnStart,
2730
+ costState: turnCost
2731
+ });
2732
+ console.log(summaryRow);
2733
+ console.log();
2734
+ transcript.push({ kind: 'raw', rows: [summaryRow] });
2735
+ }
1886
2736
  const shouldNotify = (turnElapsedMs >= 15000) || (toolsExecutedCount > 0) || (planMode && lastPlanReady !== null);
1887
- if (shouldNotify && !aborted) {
2737
+ if (!aborted) {
1888
2738
  const secs = Math.round(turnElapsedMs / 1000);
1889
- notifyDevice('devx', `Task completed in ${secs}s!`);
2739
+ const lastReply = [...history.getMessages()].reverse().find((m) => m.role === 'assistant' && m.content && m.content.trim());
2740
+ const preview = notificationBody(lastReply?.content ?? '');
2741
+ const heading = windowTitle || BASE_TITLE;
2742
+ if (isTermux()) {
2743
+ if (shouldNotify)
2744
+ notifyDevice(heading, preview || `Task completed in ${secs}s!`);
2745
+ }
2746
+ else if (!terminalFocused) {
2747
+ // The terminal is not in front: a notification with the start of the answer.
2748
+ if (sendDesktopNotification(heading, preview || `Task completed in ${secs}s`) === 'none' && shouldNotify)
2749
+ notifyDevice(heading, preview || `Task completed in ${secs}s!`);
2750
+ }
2751
+ else if (shouldNotify) {
2752
+ notifyDevice(heading, `Task completed in ${secs}s!`);
2753
+ }
1890
2754
  }
1891
2755
  // Auto-save session
1892
- await sessionManager.save(history.getMessages(), totalSessionCost, config.model, planMode);
2756
+ await sessionManager.save(history.getMessages(), sessionCost.total, config.model, planMode, sessionCost.estimated);
2757
+ // The first exchange names the chat. It runs in the background and never holds the prompt back.
2758
+ if (!aborted && !sessionManager.hasGeneratedTitle && titleAttempts < 2) {
2759
+ titleAttempts++;
2760
+ const msgs = history.getMessages();
2761
+ const firstUser = msgs.find((m) => m.role === 'user')?.content ?? '';
2762
+ const reply = [...msgs].reverse().find((m) => m.role === 'assistant' && m.content && m.content.trim())?.content ?? '';
2763
+ if (firstUser.trim() && reply.trim()) {
2764
+ const forSession = sessionManager.getSession().id;
2765
+ void (async () => {
2766
+ try {
2767
+ const title = await generateChatTitle(createProvider(config), firstUser.split('\n\n--- Attached Files ---')[0], reply);
2768
+ // The user may have started another chat meanwhile.
2769
+ if (!title || sessionManager.getSession().id !== forSession)
2770
+ return;
2771
+ sessionManager.setTitle(title);
2772
+ setWindowTitle(title);
2773
+ await sessionManager.save(history.getMessages(), sessionCost.total, config.model, planMode, sessionCost.estimated);
2774
+ }
2775
+ catch { }
2776
+ })();
2777
+ }
2778
+ }
1893
2779
  // Check if plan was finalized in PLAN mode
1894
2780
  if (planMode && !aborted) {
1895
2781
  const lastMsg = history.getMessages().slice(-1)[0];
1896
2782
  const hasPlanReadyTool = lastPlanReady !== null;
1897
- const hasPlanText = lastMsg && lastMsg.role === 'assistant' && (lastMsg.content.includes('🎯 Цель') ||
1898
- lastMsg.content.includes('📁 Пошаговый') ||
2783
+ const hasPlanText = lastMsg && lastMsg.role === 'assistant' && (lastMsg.content.includes('Цель и архитектурный обзор') ||
2784
+ lastMsg.content.includes('Goal and architecture') ||
2785
+ lastMsg.content.includes('Step-by-step changes') ||
2786
+ lastMsg.content.includes('Пошаговый список изменений') ||
1899
2787
  lastMsg.content.includes('FINAL PLAN') ||
1900
2788
  lastMsg.content.includes('План готов') ||
1901
2789
  lastMsg.content.includes('Архитектурный план') ||
1902
2790
  lastMsg.content.includes('план готов'));
1903
2791
  if (hasPlanReadyTool || hasPlanText) {
2792
+ const approvedPlan = lastPlanReady ? { summary: lastPlanReady.summary, files: lastPlanReady.files, steps: lastPlanReady.steps } : (hasPlanText && lastMsg ? lastMsg.content : null);
1904
2793
  resetPlanReady();
1905
2794
  console.log();
1906
- let choice = '';
1907
- try {
1908
- choice = await select({
1909
- message: pc.bold(pc.cyan('📋 Plan is ready! What would you like to do?')),
1910
- choices: [
1911
- {
1912
- name: pc.bold(pc.green('🚀 Go (Switch to AGENT mode and start execution)')),
1913
- value: 'go',
1914
- description: 'Automatically switch to AGENT mode and start implementing the plan immediately'
1915
- },
1916
- {
1917
- name: pc.bold(pc.yellow('✏️ Other (Modify, add details, or ask questions)')),
1918
- value: 'other',
1919
- description: 'Stay in PLAN mode and open prompt to type adjustments or additions'
1920
- }
1921
- ]
1922
- });
1923
- }
1924
- catch { }
2795
+ const choice = await askPlanDecision();
1925
2796
  if (choice === 'go') {
1926
2797
  planMode = false;
2798
+ // The approved plan is kept in the project: it survives /clear and a restart.
2799
+ if (approvedPlan)
2800
+ await savePlanFile(process.cwd(), approvedPlan).catch(() => null);
1927
2801
  const newSys = await buildSystemPrompt(false);
1928
2802
  history.updateSystemPrompt(newSys);
1929
- p.log.success(pc.bold(pc.green('🚀 Switched to AGENT mode! Starting execution of approved plan...')));
2803
+ log.success(t('plan.started'));
1930
2804
  currentDraft = '';
1931
2805
  autoTriggerPrompt = 'Go!';
1932
2806
  continue;
1933
2807
  }
1934
2808
  else {
2809
+ log.info(t('plan.editHint'));
1935
2810
  currentDraft = '';
1936
2811
  continue;
1937
2812
  }
@@ -1946,10 +2821,23 @@ export async function main() {
1946
2821
  spinnerActive = false;
1947
2822
  }
1948
2823
  if (!aborted && err.name !== 'AbortError' && !err.message?.includes('aborted')) {
1949
- p.log.error(pc.bold(pc.red(`❌ ${err.message}`)));
2824
+ if (cleanUi)
2825
+ console.log(renderNotice('error', err.message));
2826
+ else
2827
+ log.error(pc.bold(pc.red(`${err.message}`)));
1950
2828
  }
1951
2829
  }
1952
2830
  finally {
2831
+ const unsent = composer.stop();
2832
+ if (process.stdout.isTTY)
2833
+ process.stdout.write('\x1b[?1004l');
2834
+ if (interruptPrompt && !autoTriggerPrompt)
2835
+ autoTriggerPrompt = interruptPrompt;
2836
+ releasePauseHook();
2837
+ process.stdout.removeListener('resize', onResize);
2838
+ if (!currentDraft && (unsent.draft || unsent.queued.length > 0)) {
2839
+ currentDraft = [...unsent.queued, unsent.draft].filter(Boolean).join(' ');
2840
+ }
1953
2841
  globalSnapshotManager.finishTurn();
1954
2842
  activeAbortHandler = null;
1955
2843
  process.removeListener('SIGINT', onSigInt);
@@ -1960,6 +2848,15 @@ export async function main() {
1960
2848
  }
1961
2849
  }
1962
2850
  MCPManager.getInstance().stopAll();
2851
+ // The cards leave stdin resumed; nothing else keeps devx alive after the main loop, so end it here.
2852
+ if (process.stdin.isTTY) {
2853
+ try {
2854
+ process.stdin.setRawMode(false);
2855
+ }
2856
+ catch { }
2857
+ }
2858
+ process.stdin.pause();
2859
+ process.exit(0);
1963
2860
  }
1964
2861
  process.on('exit', () => {
1965
2862
  try {
@@ -1967,4 +2864,4 @@ process.on('exit', () => {
1967
2864
  }
1968
2865
  catch { }
1969
2866
  });
1970
- main().catch(console.error);
2867
+ main().catch((err) => reportRuntimeError(err, activeAbortHandler));