termux-dev 1.5.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.
- package/README.md +215 -163
- package/assets/banner.svg +23 -28
- package/assets/preview.png +0 -0
- package/assets/preview.svg +94 -0
- package/dist/cli/banner.js +7 -3
- package/dist/cli/clipboard-write.js +55 -0
- package/dist/cli/clipboard.js +182 -106
- package/dist/cli/colors.js +67 -0
- package/dist/cli/confirm.js +50 -0
- package/dist/cli/doctor.js +4 -4
- package/dist/cli/effort.js +80 -0
- package/dist/cli/export.js +3 -3
- package/dist/cli/files.js +19 -5
- package/dist/cli/headless.js +29 -14
- package/dist/cli/index.js +1395 -474
- package/dist/cli/keys.js +69 -0
- package/dist/cli/liveblock.js +45 -0
- package/dist/cli/log.js +19 -0
- package/dist/cli/markdown.js +158 -36
- package/dist/cli/mcp-panel.js +432 -0
- package/dist/cli/permission.js +84 -0
- package/dist/cli/picker.js +135 -0
- package/dist/cli/prompt.js +338 -121
- package/dist/cli/prompt_palette.js +6 -0
- package/dist/cli/resume.js +15 -0
- package/dist/cli/rewind.js +51 -0
- package/dist/cli/runtime_errors.js +6 -0
- package/dist/cli/server.js +85 -24
- package/dist/cli/settings.js +307 -0
- package/dist/cli/statusline.js +27 -1
- package/dist/cli/theme.js +126 -108
- package/dist/cli/transcript.js +114 -0
- package/dist/cli/typeahead.js +371 -0
- package/dist/cli/ui.js +1021 -0
- package/dist/cli/updater.js +23 -21
- package/dist/cli/usage-panel.js +242 -0
- package/dist/cli/width.js +178 -0
- package/dist/core/capabilities.js +433 -0
- package/dist/core/catalog-util.js +27 -0
- package/dist/core/checkpoint.js +90 -0
- package/dist/core/context-stats.js +29 -0
- package/dist/core/cost.js +191 -0
- package/dist/core/formats.js +199 -0
- package/dist/core/history.js +51 -1
- package/dist/core/hooks.js +79 -0
- package/dist/core/i18n.js +42 -0
- package/dist/core/local-config.js +69 -0
- package/dist/core/locales/de.js +210 -0
- package/dist/core/locales/en.js +221 -0
- package/dist/core/locales/es.js +210 -0
- package/dist/core/locales/fr.js +210 -0
- package/dist/core/locales/ja.js +210 -0
- package/dist/core/locales/pt.js +210 -0
- package/dist/core/locales/ru.js +210 -0
- package/dist/core/locales/uk.js +210 -0
- package/dist/core/locales/zh.js +210 -0
- package/dist/core/loop.js +125 -49
- package/dist/core/memory.js +39 -12
- package/dist/core/models.js +33 -20
- package/dist/core/network.js +12 -11
- package/dist/core/notify.js +54 -0
- package/dist/core/planfile.js +43 -0
- package/dist/core/price-catalog.json +1 -0
- package/dist/core/prices.js +249 -0
- package/dist/core/reasoning-catalog.json +1 -0
- package/dist/core/reasoning.js +393 -0
- package/dist/core/redact.js +41 -0
- package/dist/core/secure-file.js +22 -0
- package/dist/core/session.js +16 -5
- package/dist/core/snapshot.js +14 -2
- package/dist/core/title.js +61 -0
- package/dist/core/types.js +1 -1
- package/dist/core/usage.js +43 -7
- package/dist/core/vision-catalog.json +1 -0
- package/dist/core/vision.js +123 -0
- package/dist/mcp/client.js +36 -5
- package/dist/mcp/http-client.js +174 -0
- package/dist/mcp/manager.js +330 -93
- package/dist/permissions/guard.js +385 -117
- package/dist/prompts/builder.js +46 -38
- package/dist/providers/anthropic.js +517 -0
- package/dist/providers/index.js +18 -1
- package/dist/providers/openai.js +138 -125
- package/dist/providers/shared.js +116 -0
- package/dist/providers/toolguard.js +101 -0
- package/dist/tools/bash.js +141 -51
- package/dist/tools/diagnostics.js +76 -48
- package/dist/tools/fs.js +77 -5
- package/dist/tools/index.js +2 -1
- package/dist/tools/mcp.js +72 -0
- package/dist/tools/packages.js +16 -1
- package/dist/tools/plan.js +8 -8
- package/dist/tools/questions.js +50 -54
- package/dist/tools/server.js +6 -0
- package/dist/tools/todo.js +20 -18
- package/dist/tools/web.js +143 -8
- package/package.json +4 -2
- package/assets/preview_work1.png +0 -0
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
|
|
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
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
|
-
|
|
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 (
|
|
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 =
|
|
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('
|
|
172
|
+
list.push({ name: pc.yellow('← Back / Cancel'), value: '__cancel__', description: 'Keep current model' });
|
|
119
173
|
}
|
|
120
|
-
list.push({ name: pc.yellow('
|
|
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
|
-
|
|
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('
|
|
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:
|
|
253
|
-
{ name: '
|
|
254
|
-
{ name: pc.yellow('
|
|
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 =
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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 (
|
|
453
|
+
if (!trusted) {
|
|
390
454
|
resetTerminalTheme();
|
|
391
|
-
|
|
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
|
-
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
if (
|
|
416
|
-
effectiveConfig.
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
effectiveConfig.
|
|
425
|
-
|
|
426
|
-
|
|
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.
|
|
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) {
|
|
@@ -523,12 +609,12 @@ function formatToolGeneratingLabel(name, targetHint) {
|
|
|
523
609
|
return `→ Clarifying questions...`;
|
|
524
610
|
return `→ ${name}${target}...`;
|
|
525
611
|
}
|
|
526
|
-
async function handleSessionDelete() {
|
|
612
|
+
async function handleSessionDelete(bannerMode) {
|
|
527
613
|
while (true) {
|
|
528
614
|
const sessions = await SessionManager.listSessions();
|
|
529
615
|
if (sessions.length === 0) {
|
|
530
|
-
drawLogo();
|
|
531
|
-
|
|
616
|
+
drawLogo(bannerMode);
|
|
617
|
+
log.warn('No saved sessions found.');
|
|
532
618
|
break;
|
|
533
619
|
}
|
|
534
620
|
let chosenSessionId;
|
|
@@ -538,15 +624,15 @@ async function handleSessionDelete() {
|
|
|
538
624
|
source: async (term) => {
|
|
539
625
|
const q = (term || '').trim().toLowerCase();
|
|
540
626
|
const list = [
|
|
541
|
-
{ name: pc.yellow('
|
|
542
|
-
{ name: pc.red('
|
|
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('
|
|
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
|
});
|
|
@@ -562,19 +648,16 @@ async function handleSessionDelete() {
|
|
|
562
648
|
break;
|
|
563
649
|
}
|
|
564
650
|
if (!chosenSessionId || chosenSessionId === '__cancel__') {
|
|
565
|
-
drawLogo();
|
|
566
|
-
|
|
651
|
+
drawLogo(bannerMode);
|
|
652
|
+
log.info('Session deletion finished.');
|
|
567
653
|
break;
|
|
568
654
|
}
|
|
569
655
|
if (chosenSessionId === '__all__') {
|
|
570
|
-
const confirmAll = await
|
|
571
|
-
|
|
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
|
-
drawLogo();
|
|
577
|
-
|
|
659
|
+
drawLogo(bannerMode);
|
|
660
|
+
log.success(`Deleted all ${count} sessions.`);
|
|
578
661
|
break;
|
|
579
662
|
}
|
|
580
663
|
continue;
|
|
@@ -582,162 +665,91 @@ async function handleSessionDelete() {
|
|
|
582
665
|
const target = sessions.find(s => s.id === chosenSessionId);
|
|
583
666
|
const title = target ? target.title : chosenSessionId;
|
|
584
667
|
const ok = await SessionManager.deleteSession(chosenSessionId);
|
|
585
|
-
drawLogo();
|
|
668
|
+
drawLogo(bannerMode);
|
|
586
669
|
if (ok) {
|
|
587
|
-
|
|
670
|
+
log.success(`Deleted session: "${title}"`);
|
|
588
671
|
}
|
|
589
672
|
else {
|
|
590
|
-
|
|
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
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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
|
|
603
|
-
message: `${pc.bold('
|
|
604
|
-
|
|
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
|
-
|
|
611
|
-
|
|
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
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
description: config.autoApprove
|
|
641
|
-
? 'Permissions are automatically granted (no confirmation prompts for commands/files)'
|
|
642
|
-
: 'Agent asks for confirmation before executing bash commands or writing files'
|
|
643
|
-
},
|
|
644
|
-
{
|
|
645
|
-
name: `${config.enableMemory !== false ? pc.green('🧠 Project Memory Bank: ON') : pc.yellow('🧠 Project Memory Bank: OFF')}`,
|
|
646
|
-
value: 'toggle_memory',
|
|
647
|
-
description: config.enableMemory !== false
|
|
648
|
-
? 'Load persistent project rules and preferences from .devx/memory.md into AI context'
|
|
649
|
-
: 'Start sessions with a clean state without loading project memory'
|
|
650
|
-
},
|
|
651
|
-
{
|
|
652
|
-
name: `${config.checkUpdates !== false ? pc.green('🔔 Check for Updates on Startup: ON') : pc.yellow('🔔 Check for Updates on Startup: OFF')}`,
|
|
653
|
-
value: 'toggle_check_updates',
|
|
654
|
-
description: config.checkUpdates !== false
|
|
655
|
-
? 'Automatically check for updates from GitHub repository when launching devx'
|
|
656
|
-
: 'Disable update checking on startup (run /update manually instead)'
|
|
657
|
-
},
|
|
658
|
-
{
|
|
659
|
-
name: `🔄 Max Agent Iterations: ${currentTh.colorFn(maxIterLabel)}`,
|
|
660
|
-
value: 'change_max_iterations',
|
|
661
|
-
description: 'Limit how many tool steps (file edits, terminal commands) agent can do per request'
|
|
662
|
-
},
|
|
663
|
-
{
|
|
664
|
-
name: `${currentTh.colorFn('✨ About devx')} ${pc.dim(`(v${DEVX_VERSION} by ApvCode)`)}`,
|
|
665
|
-
value: 'about',
|
|
666
|
-
description: 'Terminal-Native AI Coding Agent created by ApvCode (https://github.com/apvcode/Termux-Dev)'
|
|
667
|
-
},
|
|
668
|
-
{
|
|
669
|
-
name: '⬅️ Back / Save',
|
|
670
|
-
value: 'back',
|
|
671
|
-
description: 'Return to chat'
|
|
672
|
-
}
|
|
673
|
-
]
|
|
674
|
-
});
|
|
675
|
-
if (choice === 'change_theme') {
|
|
676
|
-
await handleThemeSelect(config);
|
|
677
|
-
continue;
|
|
678
|
-
}
|
|
679
|
-
if (choice === 'about') {
|
|
680
|
-
p.note(`⚡ devx v${DEVX_VERSION} — Terminal-Native AI Coding Agent\n` +
|
|
681
|
-
`🎨 Theme: ${currentTh.emoji} ${currentTh.name}\n` +
|
|
682
|
-
`👤 Author: ApvCode (https://github.com/apvcode)\n` +
|
|
683
|
-
`🌟 Repository: https://github.com/apvcode/Termux-Dev\n` +
|
|
684
|
-
`📜 License: MIT License (2026)\n` +
|
|
685
|
-
`Built for Android Termux, Windows, macOS, and Linux.`, 'About devx');
|
|
686
|
-
continue;
|
|
687
|
-
}
|
|
688
|
-
if (choice === 'toggle_black_theme') {
|
|
689
|
-
config.pureBlackTheme = config.pureBlackTheme === false ? true : false;
|
|
690
|
-
await saveConfig(config);
|
|
691
|
-
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)
|
|
692
735
|
enableDarkTheme(true);
|
|
693
|
-
|
|
694
|
-
else {
|
|
736
|
+
else
|
|
695
737
|
resetTerminalTheme();
|
|
696
|
-
}
|
|
697
|
-
drawLogo();
|
|
698
|
-
p.log.success(`Pure Black background: ${config.pureBlackTheme ? pc.bold(pc.green('ON (Deep Black)')) : pc.bold(pc.yellow('OFF (System Default)'))}`);
|
|
699
|
-
continue;
|
|
700
738
|
}
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
await saveConfig(config);
|
|
716
|
-
p.log.success(`Check for updates: ${config.checkUpdates !== false ? pc.bold(pc.green('ON (Checked on startup)')) : pc.bold(pc.yellow('OFF (Manual only)'))}`);
|
|
717
|
-
continue;
|
|
718
|
-
}
|
|
719
|
-
if (choice === 'change_max_iterations') {
|
|
720
|
-
const val = await select({
|
|
721
|
-
message: 'Select maximum iterations limit per prompt:',
|
|
722
|
-
choices: [
|
|
723
|
-
{ name: '30 steps (Strict / Safe)', value: 30 },
|
|
724
|
-
{ name: '50 steps (Moderate)', value: 50 },
|
|
725
|
-
{ name: '100 steps (Recommended / Default)', value: 100 },
|
|
726
|
-
{ name: '200 steps (Very large refactors)', value: 200 },
|
|
727
|
-
{ name: 'Unlimited (No limit)', value: 9999 }
|
|
728
|
-
]
|
|
729
|
-
});
|
|
730
|
-
config.maxIterations = val;
|
|
731
|
-
await saveConfig(config);
|
|
732
|
-
p.log.success(`Max iterations updated to: ${pc.bold(val >= 9999 ? 'Unlimited' : `${val} steps`)}`);
|
|
733
|
-
continue;
|
|
734
|
-
}
|
|
735
|
-
break;
|
|
736
|
-
}
|
|
737
|
-
catch {
|
|
738
|
-
break;
|
|
739
|
-
}
|
|
740
|
-
}
|
|
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);
|
|
741
753
|
process.stdin.resume();
|
|
742
754
|
return config;
|
|
743
755
|
}
|
|
@@ -763,10 +775,10 @@ export async function main() {
|
|
|
763
775
|
const isIgnorableIo = err.code === 'EIO' || err.code === 'EPIPE' || err.code === 'ERR_STREAM_DESTROYED';
|
|
764
776
|
if (isIgnorableIo)
|
|
765
777
|
return;
|
|
766
|
-
|
|
778
|
+
reportRuntimeError(err, activeAbortHandler);
|
|
767
779
|
});
|
|
768
780
|
process.on('unhandledRejection', (reason) => {
|
|
769
|
-
|
|
781
|
+
reportRuntimeError(reason, activeAbortHandler);
|
|
770
782
|
});
|
|
771
783
|
acquireWakeLock();
|
|
772
784
|
const program = new Command();
|
|
@@ -780,50 +792,96 @@ export async function main() {
|
|
|
780
792
|
.option('-q, --quiet', 'Quiet output in headless mode (suppress banners and tool logs)')
|
|
781
793
|
.option('--json', 'Output structured JSON in headless mode')
|
|
782
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')
|
|
783
796
|
.parse(process.argv);
|
|
784
797
|
const options = program.opts();
|
|
785
798
|
const planModeInitial = !!options.plan;
|
|
786
799
|
const isHeadless = !!options.prompt;
|
|
787
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;
|
|
805
|
+
config.banner = config.ui?.layout === 'classic'
|
|
806
|
+
? config.banner || 'full'
|
|
807
|
+
: config.banner === 'off' ? 'off' : 'clean';
|
|
788
808
|
if (options.model) {
|
|
789
809
|
config.model = options.model;
|
|
790
|
-
config.maxContextTokens =
|
|
810
|
+
config.maxContextTokens = resolveContextLimit({ ...config, maxContextTokens: 0 }).limit;
|
|
791
811
|
}
|
|
792
812
|
if (options.yolo) {
|
|
793
813
|
config.autoApprove = true;
|
|
794
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
|
+
}
|
|
795
837
|
// If -p / --prompt is provided, execute one-shot headless mode and exit!
|
|
796
838
|
if (isHeadless) {
|
|
839
|
+
const abortController = new AbortController();
|
|
840
|
+
activeAbortHandler = () => abortController.abort();
|
|
797
841
|
const exitCode = await runHeadlessMode(options.prompt, config, {
|
|
798
842
|
planMode: planModeInitial,
|
|
799
843
|
yolo: !!options.yolo,
|
|
800
844
|
quiet: !!options.quiet,
|
|
801
|
-
json: !!options.json
|
|
845
|
+
json: !!options.json,
|
|
846
|
+
signal: abortController.signal
|
|
802
847
|
});
|
|
848
|
+
activeAbortHandler = null;
|
|
803
849
|
process.exit(exitCode);
|
|
804
850
|
}
|
|
805
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
|
+
};
|
|
806
865
|
if (!config.onboarded) {
|
|
807
|
-
const
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
const themeChoices = listThemes().map(t => {
|
|
812
|
-
const addLine = t.diffAddBg(` + 1 | const theme = "${t.id}";`.padEnd(cols - 2));
|
|
813
|
-
const remLine = t.diffRemoveBg(` - 2 | const theme = "none";`.padEnd(cols - 2));
|
|
814
|
-
const preview = `${t.colorFn(topBorder)}\n${addLine}\n${remLine}\n${t.colorFn(bottomBorder)}\n\n${pc.dim('💡 You can change this anytime with /theme')}`;
|
|
815
|
-
return {
|
|
816
|
-
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)}`,
|
|
817
870
|
value: t.id,
|
|
818
|
-
description:
|
|
819
|
-
}
|
|
820
|
-
});
|
|
871
|
+
description: `${renderThemePreview(t)}\n\n${pc.dim('You can change this anytime with /theme')}`
|
|
872
|
+
}
|
|
873
|
+
}));
|
|
821
874
|
try {
|
|
822
875
|
clearTerminalScreen();
|
|
823
876
|
const selected = await search({
|
|
824
|
-
message: `${pc.bold('
|
|
825
|
-
source: async () =>
|
|
826
|
-
|
|
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
|
|
827
885
|
});
|
|
828
886
|
if (selected) {
|
|
829
887
|
config.theme = selected;
|
|
@@ -838,19 +896,86 @@ export async function main() {
|
|
|
838
896
|
}
|
|
839
897
|
enableDarkTheme(config.pureBlackTheme !== false);
|
|
840
898
|
let history = new History();
|
|
899
|
+
MemoryManager.enabled = config.enableMemory !== false;
|
|
841
900
|
const sysPrompt = await buildSystemPrompt(planMode);
|
|
842
901
|
history.addMessage({ role: 'system', content: sysPrompt });
|
|
843
902
|
const sessionManager = new SessionManager(config.model, planMode);
|
|
844
903
|
drawLogo(config.banner || 'full');
|
|
845
904
|
await runStartupUpdateCheck(config);
|
|
846
|
-
await MCPManager.getInstance().init();
|
|
847
|
-
|
|
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));
|
|
848
910
|
let currentDraft = '';
|
|
849
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
|
+
};
|
|
850
972
|
while (true) {
|
|
851
|
-
const currentTokens = history.getConversationTokens();
|
|
852
973
|
const maxTokens = config.maxContextTokens || getModelContextLimit(config.model);
|
|
853
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;
|
|
854
979
|
const usagePercent = Math.min(100, Math.round((currentTokens / maxTokens) * 100));
|
|
855
980
|
const formatTokens = (n) => {
|
|
856
981
|
if (n >= 1_000_000)
|
|
@@ -859,18 +984,21 @@ export async function main() {
|
|
|
859
984
|
return `${(n / 1000).toFixed(1)}k`;
|
|
860
985
|
return `${n}`;
|
|
861
986
|
};
|
|
862
|
-
const costStr =
|
|
987
|
+
const costStr = `Cost: ${sessionCost.format()}`;
|
|
863
988
|
const tokenStats = `Context: ${formatTokens(currentTokens)} / ${formatTokens(maxTokens)} (${usagePercent}%) • ${costStr}`;
|
|
864
989
|
const cols = process.stdout.columns || 80;
|
|
865
990
|
const modeName = planMode ? 'PLAN' : 'AGENT';
|
|
866
991
|
const theme = getCurrentTheme();
|
|
867
|
-
if (config.ui?.
|
|
992
|
+
if (config.ui?.layout !== 'classic') {
|
|
993
|
+
// Clean layout: mode, model, context and cost live in the borders of the prompt frame.
|
|
994
|
+
}
|
|
995
|
+
else if (config.ui?.powerlineStatus) {
|
|
868
996
|
const statusLine = renderPowerlineStatus({
|
|
869
997
|
mode: modeName,
|
|
870
998
|
model: config.model,
|
|
871
999
|
currentTokens,
|
|
872
1000
|
maxTokens,
|
|
873
|
-
cost:
|
|
1001
|
+
cost: sessionCost.total,
|
|
874
1002
|
cols,
|
|
875
1003
|
maxCostUSD: config.maxCostUSD,
|
|
876
1004
|
isYolo: !!config.autoApprove
|
|
@@ -901,36 +1029,55 @@ export async function main() {
|
|
|
901
1029
|
if (autoTriggerPrompt) {
|
|
902
1030
|
answer = autoTriggerPrompt;
|
|
903
1031
|
autoTriggerPrompt = '';
|
|
904
|
-
console.log(
|
|
1032
|
+
console.log(config.ui?.layout === 'classic'
|
|
1033
|
+
? theme.colorFn('◆') + ' ' + pc.bold(pc.white(answer))
|
|
1034
|
+
: renderUserEcho(answer, cols).join('\n'));
|
|
905
1035
|
}
|
|
906
1036
|
else {
|
|
907
1037
|
const inputStr = await askPrompt({
|
|
908
1038
|
message: 'Ask anything...',
|
|
909
|
-
placeholder:
|
|
1039
|
+
placeholder: config.ui?.layout === 'classic'
|
|
1040
|
+
? 'Fix a TODO, type /help, or press Tab to switch mode'
|
|
1041
|
+
: t('composer.placeholder'),
|
|
910
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
|
+
},
|
|
911
1051
|
planMode,
|
|
912
1052
|
isYolo: !!config.autoApprove,
|
|
913
|
-
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()
|
|
914
1061
|
});
|
|
915
1062
|
if (inputStr.startsWith('__TOGGLE_MODE__:')) {
|
|
916
1063
|
currentDraft = inputStr.slice('__TOGGLE_MODE__:'.length);
|
|
917
1064
|
planMode = !planMode;
|
|
918
1065
|
const newSys = await buildSystemPrompt(planMode);
|
|
919
1066
|
history.updateSystemPrompt(newSys);
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
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
|
+
}
|
|
923
1073
|
continue;
|
|
924
1074
|
}
|
|
925
1075
|
currentDraft = '';
|
|
926
1076
|
if (inputStr === '__CANCEL__') {
|
|
927
|
-
const confirmExit = await
|
|
928
|
-
|
|
929
|
-
initialValue: false
|
|
930
|
-
});
|
|
931
|
-
if (!p.isCancel(confirmExit) && confirmExit === true) {
|
|
1077
|
+
const confirmExit = await askConfirm({ title: t('confirm.exitTitle'), question: t('confirm.exit'), record: false });
|
|
1078
|
+
if (confirmExit) {
|
|
932
1079
|
resetTerminalTheme();
|
|
933
|
-
|
|
1080
|
+
printFarewell(t('confirm.goodbye'));
|
|
934
1081
|
break;
|
|
935
1082
|
}
|
|
936
1083
|
else {
|
|
@@ -950,16 +1097,16 @@ export async function main() {
|
|
|
950
1097
|
const cmdArgs = answer.slice(cmd.length).trim();
|
|
951
1098
|
const expandedPrompt = CustomCommandManager.expandTemplate(customCmd.promptTemplate, cmdArgs);
|
|
952
1099
|
answer = expandedPrompt;
|
|
953
|
-
|
|
1100
|
+
log.info(pc.cyan(`Executing custom command: ${pc.bold(customCmd.cmd)} (${customCmd.desc})`));
|
|
954
1101
|
}
|
|
955
1102
|
else {
|
|
956
1103
|
const VALID_COMMANDS = [
|
|
957
1104
|
'/new', '/reset', '/resume', '/session', '/sessions', '/history',
|
|
958
1105
|
'/theme', '/themes', '/usage', '/export', '/mcp',
|
|
959
1106
|
'/settings', '/update', '/model', '/provider', '/providers',
|
|
960
|
-
'/plan', '/agent', '/image', '/serve', '/memory', '/undo',
|
|
1107
|
+
'/plan', '/agent', '/image', '/serve', '/memory', '/permissions', '/copy', '/rename', '/title', '/rewind', '/undo',
|
|
961
1108
|
'/diff', '/commit', '/status', '/compact', '/init', '/doctor',
|
|
962
|
-
'/config', '/clear', '/exit', '/quit', '/help'
|
|
1109
|
+
'/config', '/clear', '/exit', '/quit', '/help', '/effort'
|
|
963
1110
|
];
|
|
964
1111
|
if (!VALID_COMMANDS.includes(cmd)) {
|
|
965
1112
|
const customList = await CustomCommandManager.listCommands();
|
|
@@ -979,18 +1126,23 @@ export async function main() {
|
|
|
979
1126
|
{ name: '/settings - Configure permissions & auto-approval', value: '/settings' },
|
|
980
1127
|
{ name: '/update - Check and install updates from GitHub', value: '/update' },
|
|
981
1128
|
{ name: '/model - Switch model for current provider', value: '/model' },
|
|
1129
|
+
{ name: '/effort - Set the reasoning effort for the current model', value: '/effort' },
|
|
982
1130
|
{ name: '/provider - Change AI provider (Google, OpenRouter...)', value: '/provider' },
|
|
983
1131
|
{ name: '/plan - Switch to PLAN mode (architect)', value: '/plan' },
|
|
984
1132
|
{ name: '/agent - Switch to AGENT mode (coder)', value: '/agent' },
|
|
985
1133
|
{ name: '/serve - Start local web server for web preview', value: '/serve' },
|
|
986
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' },
|
|
987
1139
|
{ name: '/undo - Revert last file changes made by AI', value: '/undo' },
|
|
988
1140
|
{ name: '/diff - Show git diff of modified files', value: '/diff' },
|
|
989
1141
|
{ name: '/commit - AI-generated git commit message', value: '/commit' },
|
|
990
1142
|
{ name: '/status - Show git repository status', value: '/status' },
|
|
991
1143
|
{ name: '/compact - Compact conversation context', value: '/compact' },
|
|
992
1144
|
{ name: '/config - View current configuration', value: '/config' },
|
|
993
|
-
{ name: '/clear - Clear
|
|
1145
|
+
{ name: '/clear - Clear context and start a new session', value: '/clear' },
|
|
994
1146
|
{ name: '/help - Show commands overview', value: '/help' },
|
|
995
1147
|
{ name: '/exit - Exit devx', value: '/exit' },
|
|
996
1148
|
...customSlashItems
|
|
@@ -1001,7 +1153,7 @@ export async function main() {
|
|
|
1001
1153
|
source: async (term) => {
|
|
1002
1154
|
const q = (term || '').trim().toLowerCase();
|
|
1003
1155
|
const list = [
|
|
1004
|
-
{ name: pc.yellow('
|
|
1156
|
+
{ name: pc.yellow('← Cancel'), value: '__cancel__' },
|
|
1005
1157
|
...SLASH_COMMANDS
|
|
1006
1158
|
];
|
|
1007
1159
|
if (!q)
|
|
@@ -1014,7 +1166,7 @@ export async function main() {
|
|
|
1014
1166
|
continue;
|
|
1015
1167
|
}
|
|
1016
1168
|
if (picked === '/session del') {
|
|
1017
|
-
await handleSessionDelete();
|
|
1169
|
+
await handleSessionDelete(config.banner || 'full');
|
|
1018
1170
|
continue;
|
|
1019
1171
|
}
|
|
1020
1172
|
cmd = picked;
|
|
@@ -1025,69 +1177,65 @@ export async function main() {
|
|
|
1025
1177
|
}
|
|
1026
1178
|
}
|
|
1027
1179
|
if (cmd === '/settings') {
|
|
1180
|
+
// The panel erases itself; the conversation above it stays where it is.
|
|
1028
1181
|
config = await handleSettings(config);
|
|
1029
|
-
|
|
1182
|
+
costLimitRaised = null;
|
|
1183
|
+
costWarned = false;
|
|
1184
|
+
// The project memory switch changes what the system prompt contains.
|
|
1185
|
+
history.updateSystemPrompt(await buildSystemPrompt(planMode));
|
|
1030
1186
|
continue;
|
|
1031
1187
|
}
|
|
1032
1188
|
if (cmd === '/update') {
|
|
1033
|
-
const s =
|
|
1189
|
+
const s = new UiSpinner();
|
|
1034
1190
|
s.start('Checking for updates from https://github.com/apvcode/Termux-Dev...');
|
|
1035
1191
|
const res = await checkForUpdates(10000);
|
|
1036
1192
|
s.stop();
|
|
1037
1193
|
if (res.updateAvailable) {
|
|
1038
|
-
|
|
1039
|
-
const doUpdate = await
|
|
1040
|
-
|
|
1041
|
-
initialValue: true
|
|
1042
|
-
});
|
|
1043
|
-
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) {
|
|
1044
1197
|
await performSelfUpdate(res.latestVersion);
|
|
1045
1198
|
}
|
|
1046
1199
|
}
|
|
1047
1200
|
else {
|
|
1048
|
-
|
|
1201
|
+
log.success(pc.green(`devx is up to date! (v${res.currentVersion})`));
|
|
1049
1202
|
}
|
|
1050
1203
|
continue;
|
|
1051
1204
|
}
|
|
1052
1205
|
if (cmd === '/help') {
|
|
1053
|
-
|
|
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');
|
|
1054
1207
|
continue;
|
|
1055
1208
|
}
|
|
1056
1209
|
if (cmd === '/exit') {
|
|
1057
|
-
|
|
1210
|
+
printFarewell(t('confirm.goodbye'));
|
|
1058
1211
|
break;
|
|
1059
1212
|
}
|
|
1060
1213
|
if (cmd === '/clear') {
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
p.log.success('History cleared.');
|
|
1214
|
+
await startFreshSession();
|
|
1215
|
+
announce('new session · context cleared');
|
|
1064
1216
|
continue;
|
|
1065
1217
|
}
|
|
1066
1218
|
if (cmd === '/new' || cmd === '/reset') {
|
|
1067
|
-
|
|
1068
|
-
history = new History();
|
|
1069
|
-
history.addMessage({ role: 'system', content: await buildSystemPrompt(planMode) });
|
|
1070
|
-
totalSessionCost = 0;
|
|
1071
|
-
drawLogo();
|
|
1219
|
+
await startFreshSession();
|
|
1072
1220
|
const initialPrompt = parts.slice(1).join(' ').trim();
|
|
1073
1221
|
if (initialPrompt) {
|
|
1074
|
-
|
|
1222
|
+
announce('new session · starting with your prompt');
|
|
1075
1223
|
answer = initialPrompt;
|
|
1076
1224
|
}
|
|
1077
1225
|
else {
|
|
1078
|
-
|
|
1226
|
+
announce('new session started');
|
|
1079
1227
|
continue;
|
|
1080
1228
|
}
|
|
1081
1229
|
}
|
|
1082
1230
|
else if (cmd === '/session' || cmd === '/sessions' || cmd === '/resume' || cmd === '/history') {
|
|
1083
1231
|
const sub = (parts[1] || '').toLowerCase();
|
|
1084
1232
|
if (sub === 'del' || sub === 'delete' || sub === 'rm') {
|
|
1085
|
-
await handleSessionDelete();
|
|
1233
|
+
await handleSessionDelete(config.banner || 'full');
|
|
1086
1234
|
continue;
|
|
1087
1235
|
}
|
|
1088
1236
|
const sessions = await SessionManager.listSessions();
|
|
1089
1237
|
if (sessions.length === 0) {
|
|
1090
|
-
|
|
1238
|
+
log.warn('No saved sessions found.');
|
|
1091
1239
|
continue;
|
|
1092
1240
|
}
|
|
1093
1241
|
let chosenSessionId;
|
|
@@ -1097,8 +1245,8 @@ export async function main() {
|
|
|
1097
1245
|
source: async (term) => {
|
|
1098
1246
|
const q = (term || '').trim().toLowerCase();
|
|
1099
1247
|
const list = [
|
|
1100
|
-
{ name: pc.yellow('
|
|
1101
|
-
{ name: pc.red('
|
|
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' }
|
|
1102
1250
|
];
|
|
1103
1251
|
for (const s of sessions) {
|
|
1104
1252
|
const timeStr = formatSessionTime(s.updatedAt);
|
|
@@ -1124,7 +1272,7 @@ export async function main() {
|
|
|
1124
1272
|
continue;
|
|
1125
1273
|
}
|
|
1126
1274
|
if (chosenSessionId === '__delete_mode__') {
|
|
1127
|
-
await handleSessionDelete();
|
|
1275
|
+
await handleSessionDelete(config.banner || 'full');
|
|
1128
1276
|
continue;
|
|
1129
1277
|
}
|
|
1130
1278
|
const loaded = await SessionManager.loadSession(chosenSessionId);
|
|
@@ -1136,57 +1284,34 @@ export async function main() {
|
|
|
1136
1284
|
}
|
|
1137
1285
|
if (loaded.model) {
|
|
1138
1286
|
config.model = loaded.model;
|
|
1139
|
-
config.maxContextTokens =
|
|
1287
|
+
config.maxContextTokens = resolveContextLimit({ ...config, maxContextTokens: 0 }).limit;
|
|
1140
1288
|
}
|
|
1141
1289
|
if (loaded.planMode !== undefined) {
|
|
1142
1290
|
planMode = loaded.planMode;
|
|
1143
1291
|
}
|
|
1144
|
-
|
|
1145
|
-
drawLogo();
|
|
1292
|
+
sessionCost.restore(loaded.totalCost || 0, loaded.costEstimated === true);
|
|
1293
|
+
drawLogo(config.banner || 'full');
|
|
1146
1294
|
const nonSysMessages = loaded.messages.filter(m => m.role !== 'system');
|
|
1147
1295
|
const totalMsgs = nonSysMessages.length;
|
|
1148
1296
|
const displayLimit = 20;
|
|
1149
1297
|
const recentMessages = nonSysMessages.slice(-displayLimit);
|
|
1150
1298
|
const shownCount = recentMessages.length;
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
}
|
|
1165
|
-
else if (msg.role === 'assistant') {
|
|
1166
|
-
if (msg.content) {
|
|
1167
|
-
console.log(renderMarkdown(msg.content));
|
|
1168
|
-
}
|
|
1169
|
-
if (msg.tool_calls && msg.tool_calls.length > 0) {
|
|
1170
|
-
for (const tc of msg.tool_calls) {
|
|
1171
|
-
console.log(pc.dim(` ⚡ ${tc.name}`));
|
|
1172
|
-
}
|
|
1173
|
-
}
|
|
1174
|
-
console.log();
|
|
1175
|
-
}
|
|
1176
|
-
else if (msg.role === 'tool') {
|
|
1177
|
-
if (msg.content) {
|
|
1178
|
-
const firstLine = msg.content.trim().split('\n')[0];
|
|
1179
|
-
if (firstLine.includes('+') || firstLine.includes('lines') || firstLine.includes('Successfully')) {
|
|
1180
|
-
console.log(pc.green(` └─ ${firstLine}`));
|
|
1181
|
-
}
|
|
1182
|
-
}
|
|
1183
|
-
}
|
|
1184
|
-
}
|
|
1185
|
-
console.log(pc.bold(pc.cyan(`────────────────────────────────────────────────────────────────────────\n`)));
|
|
1186
|
-
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'));
|
|
1187
1312
|
}
|
|
1188
1313
|
else {
|
|
1189
|
-
|
|
1314
|
+
log.error('Failed to load session.');
|
|
1190
1315
|
}
|
|
1191
1316
|
continue;
|
|
1192
1317
|
}
|
|
@@ -1194,37 +1319,63 @@ export async function main() {
|
|
|
1194
1319
|
planMode = true;
|
|
1195
1320
|
const newSys = await buildSystemPrompt(true);
|
|
1196
1321
|
history.updateSystemPrompt(newSys);
|
|
1197
|
-
|
|
1198
|
-
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');
|
|
1199
1323
|
continue;
|
|
1200
1324
|
}
|
|
1201
1325
|
if (cmd === '/agent') {
|
|
1202
1326
|
planMode = false;
|
|
1203
1327
|
const newSys = await buildSystemPrompt(false);
|
|
1204
1328
|
history.updateSystemPrompt(newSys);
|
|
1205
|
-
|
|
1206
|
-
p.log.success(pc.green('Switched to AGENT mode (Coder & Executor). Full tools enabled.'));
|
|
1329
|
+
announce('agent mode · edits files and runs commands');
|
|
1207
1330
|
continue;
|
|
1208
1331
|
}
|
|
1209
1332
|
if (cmd === '/undo') {
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
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
|
+
}
|
|
1213
1358
|
}
|
|
1214
1359
|
else {
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
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.'));
|
|
1219
1370
|
}
|
|
1220
1371
|
}
|
|
1221
1372
|
continue;
|
|
1222
1373
|
}
|
|
1223
1374
|
if (cmd === '/diff') {
|
|
1224
1375
|
try {
|
|
1225
|
-
const diffOutput = execSync('git diff', { encoding: 'utf8' });
|
|
1376
|
+
const diffOutput = execSync('git diff', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
1226
1377
|
if (!diffOutput.trim()) {
|
|
1227
|
-
|
|
1378
|
+
log.info('No uncommitted changes in git repository.');
|
|
1228
1379
|
}
|
|
1229
1380
|
else {
|
|
1230
1381
|
console.log('\n' + pc.bold(pc.cyan('─── Git Diff ─────────────────────────')));
|
|
@@ -1246,15 +1397,15 @@ export async function main() {
|
|
|
1246
1397
|
}
|
|
1247
1398
|
}
|
|
1248
1399
|
catch {
|
|
1249
|
-
|
|
1400
|
+
log.warn('Git is not available or current directory is not a git repo.');
|
|
1250
1401
|
}
|
|
1251
1402
|
continue;
|
|
1252
1403
|
}
|
|
1253
1404
|
if (cmd === '/status') {
|
|
1254
1405
|
try {
|
|
1255
|
-
const statusOutput = execSync('git status --short', { encoding: 'utf8' });
|
|
1406
|
+
const statusOutput = execSync('git status --short', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
1256
1407
|
if (!statusOutput.trim()) {
|
|
1257
|
-
|
|
1408
|
+
log.success('Working directory clean, no modified files.');
|
|
1258
1409
|
}
|
|
1259
1410
|
else {
|
|
1260
1411
|
console.log('\n' + pc.bold(pc.cyan('─── Git Status ───────────────────────')));
|
|
@@ -1278,23 +1429,24 @@ export async function main() {
|
|
|
1278
1429
|
}
|
|
1279
1430
|
}
|
|
1280
1431
|
catch {
|
|
1281
|
-
|
|
1432
|
+
log.warn('Git is not available or current directory is not a git repo.');
|
|
1282
1433
|
}
|
|
1283
1434
|
continue;
|
|
1284
1435
|
}
|
|
1285
1436
|
if (cmd === '/commit') {
|
|
1286
1437
|
try {
|
|
1287
|
-
const status = execSync('git status --short', { encoding: 'utf8' });
|
|
1438
|
+
const status = execSync('git status --short', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
1288
1439
|
if (!status.trim()) {
|
|
1289
|
-
|
|
1440
|
+
log.info('No changes to commit.');
|
|
1290
1441
|
continue;
|
|
1291
1442
|
}
|
|
1292
|
-
const diff = execSync('git diff', { encoding: 'utf8' });
|
|
1443
|
+
const diff = execSync('git diff', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
1293
1444
|
const userMsg = parts.slice(1).join(' ').trim();
|
|
1294
1445
|
let commitMsg = userMsg;
|
|
1295
1446
|
if (!commitMsg) {
|
|
1296
|
-
const s =
|
|
1447
|
+
const s = new UiSpinner();
|
|
1297
1448
|
s.start('Generating conventional commit message with AI...');
|
|
1449
|
+
await refreshFormats(config).catch(() => null);
|
|
1298
1450
|
const provider = createProvider(config);
|
|
1299
1451
|
const promptReq = {
|
|
1300
1452
|
messages: [
|
|
@@ -1312,76 +1464,77 @@ export async function main() {
|
|
|
1312
1464
|
s.stop();
|
|
1313
1465
|
commitMsg = (genRes.content || 'chore: update project files').trim().replace(/^["'`]|["'`]$/g, '').split('\n')[0];
|
|
1314
1466
|
}
|
|
1315
|
-
const confirmed = await
|
|
1316
|
-
|
|
1317
|
-
initialValue: true
|
|
1318
|
-
});
|
|
1319
|
-
if (!p.isCancel(confirmed) && confirmed) {
|
|
1467
|
+
const confirmed = await askConfirm({ title: 'commit', question: t('confirm.commit'), details: [commitMsg], initial: true });
|
|
1468
|
+
if (confirmed) {
|
|
1320
1469
|
execFileSync('git', ['add', '-A'], { stdio: 'ignore' });
|
|
1321
1470
|
execFileSync('git', ['commit', '-m', commitMsg], { stdio: 'ignore' });
|
|
1322
|
-
|
|
1471
|
+
log.success(pc.bold(pc.green(`Committed: ${commitMsg}`)));
|
|
1323
1472
|
}
|
|
1324
1473
|
else {
|
|
1325
|
-
|
|
1474
|
+
log.info('Commit cancelled.');
|
|
1326
1475
|
}
|
|
1327
1476
|
}
|
|
1328
1477
|
catch (err) {
|
|
1329
|
-
|
|
1478
|
+
log.error(`Git commit failed: ${err.message}`);
|
|
1330
1479
|
}
|
|
1331
1480
|
continue;
|
|
1332
1481
|
}
|
|
1333
1482
|
if (cmd === '/compact') {
|
|
1334
1483
|
const msgs = history.getMessages();
|
|
1335
1484
|
if (msgs.length <= 2) {
|
|
1336
|
-
|
|
1485
|
+
log.info('Conversation is already short, no compaction needed.');
|
|
1337
1486
|
continue;
|
|
1338
1487
|
}
|
|
1339
|
-
const s =
|
|
1488
|
+
const s = new UiSpinner();
|
|
1340
1489
|
s.start('Compacting conversation context with AI...');
|
|
1341
1490
|
try {
|
|
1491
|
+
await refreshFormats(config).catch(() => null);
|
|
1342
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));
|
|
1343
1498
|
const compactPrompt = {
|
|
1344
1499
|
messages: [
|
|
1345
|
-
...
|
|
1500
|
+
...forSummary.getMessages(),
|
|
1346
1501
|
{
|
|
1347
1502
|
role: 'user',
|
|
1348
|
-
content: '
|
|
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.'
|
|
1349
1504
|
}
|
|
1350
1505
|
]
|
|
1351
1506
|
};
|
|
1352
1507
|
const res = await provider.chat(compactPrompt);
|
|
1353
1508
|
s.stop();
|
|
1509
|
+
if (!res.content || !res.content.trim())
|
|
1510
|
+
throw new Error('the model returned an empty summary');
|
|
1354
1511
|
const sysPrompt = await buildSystemPrompt(planMode);
|
|
1355
1512
|
history.clear();
|
|
1356
1513
|
history.addMessage({ role: 'system', content: sysPrompt });
|
|
1514
|
+
// A user message, not an assistant one: the Anthropic format cannot start with the assistant.
|
|
1357
1515
|
history.addMessage({
|
|
1358
|
-
role: '
|
|
1359
|
-
content: `[CONVERSATION COMPACTED]
|
|
1516
|
+
role: 'user',
|
|
1517
|
+
content: `[CONVERSATION COMPACTED] Summary of our conversation so far:\n${res.content}`
|
|
1360
1518
|
});
|
|
1361
|
-
|
|
1519
|
+
log.success(pc.bold(pc.green('Context successfully compacted! Freed up tokens while retaining task summary.')));
|
|
1362
1520
|
}
|
|
1363
1521
|
catch (err) {
|
|
1364
1522
|
s.stop();
|
|
1365
|
-
|
|
1523
|
+
log.error(`Compaction failed: ${err.message}`);
|
|
1366
1524
|
}
|
|
1367
1525
|
continue;
|
|
1368
1526
|
}
|
|
1369
|
-
if (cmd === '/clear') {
|
|
1370
|
-
console.clear();
|
|
1371
|
-
drawLogo();
|
|
1372
|
-
continue;
|
|
1373
|
-
}
|
|
1374
1527
|
if (cmd === '/session' && parts[1]?.toLowerCase() !== 'del') {
|
|
1375
1528
|
const cur = sessionManager.getSession();
|
|
1376
1529
|
const nonSys = history.getMessages().filter(m => m.role !== 'system');
|
|
1377
1530
|
const shortId = cur.id.split('_').pop();
|
|
1378
|
-
console.log('\n' + pc.bold(pc.cyan('───
|
|
1531
|
+
console.log('\n' + pc.bold(pc.cyan('─── Active Session Information ───')));
|
|
1379
1532
|
console.log(` ${pc.bold('ID:')} ${pc.cyan(cur.id)} ${pc.dim(`(#${shortId})`)}`);
|
|
1380
1533
|
console.log(` ${pc.bold('Title:')} "${cur.title}"`);
|
|
1381
1534
|
console.log(` ${pc.bold('Model:')} ${config.model}`);
|
|
1382
1535
|
console.log(` ${pc.bold('Mode:')} ${planMode ? pc.cyan('PLAN') : pc.green('AGENT')}`);
|
|
1383
1536
|
console.log(` ${pc.bold('Messages:')} ${nonSys.length} messages`);
|
|
1384
|
-
console.log(` ${pc.bold('Cost:')}
|
|
1537
|
+
console.log(` ${pc.bold('Cost:')} ${sessionCost.format()}`);
|
|
1385
1538
|
console.log(` ${pc.bold('File:')} ~/.devx/sessions/${cur.id}.json`);
|
|
1386
1539
|
console.log(pc.bold(pc.cyan('──────────────────────────────────────\n')));
|
|
1387
1540
|
continue;
|
|
@@ -1389,12 +1542,12 @@ export async function main() {
|
|
|
1389
1542
|
if (cmd === '/init') {
|
|
1390
1543
|
const agentsPath = path.join(process.cwd(), 'AGENTS.md');
|
|
1391
1544
|
if (fsSync.existsSync(agentsPath)) {
|
|
1392
|
-
|
|
1545
|
+
log.warn('AGENTS.md already exists in current workspace.');
|
|
1393
1546
|
}
|
|
1394
1547
|
else {
|
|
1395
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`;
|
|
1396
1549
|
await fs.writeFile(agentsPath, template, 'utf8');
|
|
1397
|
-
|
|
1550
|
+
log.success(`Created ${pc.bold('AGENTS.md')} in project root! Customize it to give devx persistent instructions.`);
|
|
1398
1551
|
}
|
|
1399
1552
|
continue;
|
|
1400
1553
|
}
|
|
@@ -1406,10 +1559,10 @@ export async function main() {
|
|
|
1406
1559
|
const sub = parts[1]?.toLowerCase();
|
|
1407
1560
|
if (sub === 'stop') {
|
|
1408
1561
|
if (stopServer()) {
|
|
1409
|
-
|
|
1562
|
+
log.success('Web server stopped.');
|
|
1410
1563
|
}
|
|
1411
1564
|
else {
|
|
1412
|
-
|
|
1565
|
+
log.info('No web server is currently running.');
|
|
1413
1566
|
}
|
|
1414
1567
|
}
|
|
1415
1568
|
else {
|
|
@@ -1419,7 +1572,7 @@ export async function main() {
|
|
|
1419
1572
|
await displayServerBanner(localUrl, networkUrl);
|
|
1420
1573
|
}
|
|
1421
1574
|
catch (err) {
|
|
1422
|
-
|
|
1575
|
+
log.error(`Failed to start web server: ${err.message}`);
|
|
1423
1576
|
}
|
|
1424
1577
|
}
|
|
1425
1578
|
continue;
|
|
@@ -1432,12 +1585,11 @@ export async function main() {
|
|
|
1432
1585
|
config.theme = matched.id;
|
|
1433
1586
|
await saveConfig(config);
|
|
1434
1587
|
const th = setActiveTheme(matched.id);
|
|
1435
|
-
|
|
1436
|
-
p.log.success(th.boldFn(`🎨 Theme switched to ${th.emoji} ${th.name}!`));
|
|
1588
|
+
announce(`theme ${th.name}`);
|
|
1437
1589
|
}
|
|
1438
1590
|
else {
|
|
1439
1591
|
const themes = listThemes();
|
|
1440
|
-
|
|
1592
|
+
log.warn(`Unknown theme: "${themeArg}". Available themes: ${themes.map(t => `${t.colorFn(t.symbol)} ${t.id}`).join(', ')}`);
|
|
1441
1593
|
}
|
|
1442
1594
|
}
|
|
1443
1595
|
else {
|
|
@@ -1445,59 +1597,250 @@ export async function main() {
|
|
|
1445
1597
|
}
|
|
1446
1598
|
continue;
|
|
1447
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
|
+
}
|
|
1448
1716
|
if (cmd === '/memory') {
|
|
1449
1717
|
const sub = parts[1]?.toLowerCase();
|
|
1450
1718
|
if (sub === 'clear') {
|
|
1451
1719
|
await MemoryManager.clearMemory();
|
|
1452
|
-
|
|
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
|
+
}
|
|
1453
1738
|
}
|
|
1454
1739
|
else if (sub === 'add') {
|
|
1455
1740
|
const fact = parts.slice(2).join(' ').trim();
|
|
1456
1741
|
if (!fact) {
|
|
1457
|
-
|
|
1742
|
+
log.warn('Usage: /memory add <fact or rule to remember>');
|
|
1458
1743
|
}
|
|
1459
1744
|
else {
|
|
1460
1745
|
await MemoryManager.addFact(fact);
|
|
1461
|
-
|
|
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}"`);
|
|
1462
1749
|
}
|
|
1463
1750
|
}
|
|
1464
1751
|
else {
|
|
1465
1752
|
const mem = await MemoryManager.loadMemory();
|
|
1466
1753
|
if (!mem.trim()) {
|
|
1467
|
-
|
|
1754
|
+
log.info('Project memory is empty. AI will remember facts as you work, or use /memory add <fact>.');
|
|
1468
1755
|
}
|
|
1469
1756
|
else {
|
|
1470
|
-
|
|
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 }));
|
|
1471
1761
|
console.log(pc.dim('Commands: /memory add <text> | /memory clear\n'));
|
|
1472
1762
|
}
|
|
1473
1763
|
}
|
|
1474
1764
|
continue;
|
|
1475
1765
|
}
|
|
1476
1766
|
if (cmd === '/usage') {
|
|
1477
|
-
|
|
1478
|
-
|
|
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();
|
|
1479
1779
|
continue;
|
|
1480
1780
|
}
|
|
1481
1781
|
if (cmd === '/export') {
|
|
1482
1782
|
const targetName = parts.slice(1).join(' ').trim();
|
|
1483
1783
|
const res = await SessionExporter.exportToMarkdown(history.getMessages(), config.model, targetName || undefined);
|
|
1484
1784
|
if (res.success) {
|
|
1485
|
-
|
|
1785
|
+
log.success(pc.bold(pc.green(`Session exported to: ${res.filePath}`)));
|
|
1486
1786
|
}
|
|
1487
1787
|
else {
|
|
1488
|
-
|
|
1788
|
+
log.error(`Failed to export session: ${res.error}`);
|
|
1489
1789
|
}
|
|
1490
1790
|
continue;
|
|
1491
1791
|
}
|
|
1492
1792
|
if (cmd === '/mcp') {
|
|
1493
1793
|
const sub = (parts[1] || '').toLowerCase();
|
|
1494
1794
|
if (sub === 'reload' || sub === 'restart' || sub === 'r') {
|
|
1495
|
-
const s =
|
|
1795
|
+
const s = new UiSpinner();
|
|
1496
1796
|
s.start('Reloading MCP servers...');
|
|
1497
1797
|
await MCPManager.getInstance().reload();
|
|
1498
1798
|
s.stop();
|
|
1499
1799
|
console.log('\n' + MCPManager.getInstance().renderStatusCard());
|
|
1500
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
|
+
}
|
|
1501
1844
|
else {
|
|
1502
1845
|
console.log('\n' + MCPManager.getInstance().renderStatusCard());
|
|
1503
1846
|
}
|
|
@@ -1513,32 +1856,90 @@ export async function main() {
|
|
|
1513
1856
|
masked.apiKeys[k] = maskApiKey(masked.apiKeys[k]);
|
|
1514
1857
|
}
|
|
1515
1858
|
}
|
|
1516
|
-
|
|
1859
|
+
note(JSON.stringify(masked, null, 2), 'Configuration (Secrets Masked)');
|
|
1517
1860
|
continue;
|
|
1518
1861
|
}
|
|
1519
1862
|
if (cmd === '/model') {
|
|
1520
1863
|
const res = await selectModel(config.baseUrl, config.apiKey, config.model, config.provider || '', true);
|
|
1521
|
-
drawLogo();
|
|
1522
1864
|
if (res) {
|
|
1523
1865
|
config.model = res.model;
|
|
1524
|
-
config.maxContextTokens = res.contextLimit;
|
|
1866
|
+
config.maxContextTokens = config.contextLimits?.[config.model] ?? res.contextLimit;
|
|
1525
1867
|
await saveConfig(config);
|
|
1526
|
-
|
|
1868
|
+
await refreshEndpoint(config).catch(() => null);
|
|
1869
|
+
announce(`model ${config.model}${providerFormat(config) === 'anthropic' ? ' (Anthropic format)' : ''}`);
|
|
1870
|
+
}
|
|
1871
|
+
else {
|
|
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);
|
|
1527
1920
|
}
|
|
1528
1921
|
else {
|
|
1529
|
-
|
|
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
|
+
}
|
|
1530
1931
|
}
|
|
1531
1932
|
continue;
|
|
1532
1933
|
}
|
|
1533
1934
|
if (cmd === '/provider' || cmd === '/providers') {
|
|
1534
1935
|
const newConfig = await selectProvider(config, true);
|
|
1535
|
-
drawLogo();
|
|
1536
1936
|
if (newConfig) {
|
|
1537
1937
|
config = newConfig;
|
|
1538
|
-
|
|
1938
|
+
await refreshEndpoint(config).catch(() => null);
|
|
1939
|
+
announce(`provider ${config.provider || 'custom'} · ${config.model}${providerFormat(config) === 'anthropic' ? ' (Anthropic format)' : ''}`);
|
|
1539
1940
|
}
|
|
1540
1941
|
else {
|
|
1541
|
-
|
|
1942
|
+
log.info('Provider selection cancelled.');
|
|
1542
1943
|
}
|
|
1543
1944
|
continue;
|
|
1544
1945
|
}
|
|
@@ -1574,36 +1975,85 @@ export async function main() {
|
|
|
1574
1975
|
planMode = false;
|
|
1575
1976
|
const newSys = await buildSystemPrompt(false);
|
|
1576
1977
|
history.updateSystemPrompt(newSys);
|
|
1577
|
-
|
|
1978
|
+
log.success(pc.bold(pc.green('Plan approved! Auto-switched to AGENT mode. Starting execution...')));
|
|
1578
1979
|
}
|
|
1579
1980
|
}
|
|
1580
1981
|
}
|
|
1581
1982
|
}
|
|
1582
1983
|
// Resolve @file and @image mentions
|
|
1583
|
-
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);
|
|
1584
1987
|
let finalContent = cleanAnswer;
|
|
1585
1988
|
if (attachments.length > 0) {
|
|
1586
1989
|
const attachBlocks = attachments.map(a => `[Attached file: ${a.path}]\n\`\`\`\n${a.content}\n\`\`\``).join('\n\n');
|
|
1587
1990
|
finalContent = `${cleanAnswer}\n\n--- Attached Files ---\n${attachBlocks}`;
|
|
1588
|
-
|
|
1991
|
+
log.info(pc.cyan(`Attached ${attachments.length} file(s): ${attachments.map(a => a.path).join(', ')}`));
|
|
1589
1992
|
}
|
|
1590
1993
|
if (images.length > 0) {
|
|
1591
|
-
|
|
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;
|
|
1592
2017
|
}
|
|
1593
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));
|
|
1594
2029
|
history.addMessage({
|
|
1595
2030
|
role: 'user',
|
|
1596
2031
|
content: finalContent,
|
|
1597
|
-
images: images.length > 0 ? images : undefined
|
|
2032
|
+
images: images.length > 0 ? images : undefined,
|
|
2033
|
+
at: Date.now(),
|
|
2034
|
+
checkpoint: turnCheckpoint?.sha,
|
|
1598
2035
|
});
|
|
2036
|
+
// The format of the gateway is learned once (cached); the first request waits for it.
|
|
2037
|
+
await refreshFormats(config).catch(() => null);
|
|
1599
2038
|
const provider = createProvider(config);
|
|
1600
2039
|
const tools = getTools(planMode);
|
|
1601
|
-
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
|
+
});
|
|
1602
2051
|
const agentConfig = {
|
|
1603
2052
|
maxContextTokens: config.maxContextTokens || 100000,
|
|
1604
2053
|
maxIterations: config.maxIterations || 100,
|
|
1605
2054
|
autoApprove: config.autoApprove,
|
|
1606
|
-
bashAllowlist: config.bashAllowlist
|
|
2055
|
+
bashAllowlist: config.bashAllowlist,
|
|
2056
|
+
hooks: config.hooks,
|
|
1607
2057
|
};
|
|
1608
2058
|
const agent = new Agent(agentConfig, provider, tools, history, guard);
|
|
1609
2059
|
const taskStartTime = Date.now();
|
|
@@ -1613,30 +2063,217 @@ export async function main() {
|
|
|
1613
2063
|
let spinnerActive = false;
|
|
1614
2064
|
let thinkingActive = false;
|
|
1615
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).
|
|
1616
2073
|
let textActive = false;
|
|
1617
|
-
|
|
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(' │ '));
|
|
1618
2084
|
const streamer = new MarkdownStreamer();
|
|
2085
|
+
if (cleanUi)
|
|
2086
|
+
streamer.setSink((text) => assistantGutter.write(text));
|
|
1619
2087
|
const thoughtStreamer = new SmoothStreamer((chunk) => {
|
|
1620
|
-
|
|
2088
|
+
if (cleanUi)
|
|
2089
|
+
thoughtGutter.write(palette.muted(chunk));
|
|
2090
|
+
else
|
|
2091
|
+
process.stdout.write(pc.dim(chunk));
|
|
1621
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
|
+
};
|
|
1622
2118
|
const telemetry = new LiveTelemetryTracker();
|
|
1623
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
|
+
};
|
|
1624
2170
|
const finishThinking = (extraNewline = true) => {
|
|
1625
|
-
if (!thinkingActive)
|
|
2171
|
+
if (!thinkingActive && !thinkingStreaming)
|
|
1626
2172
|
return;
|
|
1627
|
-
|
|
2173
|
+
if (thinkingActive) {
|
|
2174
|
+
thoughtStreamer.flush();
|
|
2175
|
+
thoughtGutter.flush();
|
|
2176
|
+
}
|
|
1628
2177
|
const elapsedSec = thinkingStartTime > 0 ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : '0.0';
|
|
1629
2178
|
const teleStats = config.ui?.liveTelemetry && telemetry.getTotalTokens() > 0
|
|
1630
2179
|
? ` · ${LiveTelemetryTracker.formatTokenCount(telemetry.getTotalTokens())} tok · ${telemetry.getTokensPerSecond()} tok/s`
|
|
1631
2180
|
: '';
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
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;
|
|
1636
2208
|
thinkingActive = false;
|
|
2209
|
+
thinkingStreaming = false;
|
|
1637
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
|
+
}
|
|
1638
2225
|
};
|
|
1639
|
-
|
|
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);
|
|
2254
|
+
};
|
|
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) => {
|
|
1640
2277
|
if (aborted)
|
|
1641
2278
|
return;
|
|
1642
2279
|
aborted = true;
|
|
@@ -1649,24 +2286,100 @@ export async function main() {
|
|
|
1649
2286
|
thoughtStreamer.reset();
|
|
1650
2287
|
finishThinking(true);
|
|
1651
2288
|
streamer.finish();
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
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')));
|
|
1657
2308
|
}
|
|
1658
2309
|
else {
|
|
1659
|
-
console.log(pc.yellow('\n
|
|
2310
|
+
console.log(pc.yellow('\n' + t('stop.classicStopped')));
|
|
1660
2311
|
}
|
|
1661
2312
|
};
|
|
1662
2313
|
const onSigInt = () => {
|
|
1663
2314
|
stopGeneration();
|
|
1664
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
|
+
});
|
|
1665
2357
|
const onRawData = (data) => {
|
|
1666
|
-
|
|
1667
|
-
|
|
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')) {
|
|
1668
2368
|
stopGeneration();
|
|
2369
|
+
return;
|
|
1669
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 };
|
|
1670
2383
|
};
|
|
1671
2384
|
activeAbortHandler = stopGeneration;
|
|
1672
2385
|
process.on('SIGINT', onSigInt);
|
|
@@ -1675,36 +2388,53 @@ export async function main() {
|
|
|
1675
2388
|
process.stdin.resume();
|
|
1676
2389
|
process.stdin.setRawMode(true);
|
|
1677
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
|
+
}
|
|
1678
2396
|
}
|
|
1679
2397
|
catch { }
|
|
1680
2398
|
}
|
|
1681
|
-
s.start('Connecting...');
|
|
2399
|
+
s.start(cleanUi ? t('status.waiting') : 'Connecting...');
|
|
1682
2400
|
spinnerActive = true;
|
|
1683
2401
|
const turnStartTime = Date.now();
|
|
1684
2402
|
let toolsExecutedCount = 0;
|
|
1685
2403
|
try {
|
|
1686
|
-
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
|
+
})) {
|
|
1687
2408
|
if (aborted)
|
|
1688
2409
|
break;
|
|
1689
2410
|
if (event.type === 'reasoning_delta') {
|
|
1690
2411
|
isConnecting = false;
|
|
1691
2412
|
telemetry.setPhase('thinking');
|
|
2413
|
+
statusLabel = t('status.thinking');
|
|
1692
2414
|
telemetry.recordTokens(telemetry.estimateTokensFromText(event.delta));
|
|
2415
|
+
requestOutEstimate += telemetry.estimateTokensFromText(event.delta);
|
|
1693
2416
|
if (spinnerActive) {
|
|
1694
2417
|
s.stop();
|
|
1695
2418
|
spinnerActive = false;
|
|
1696
2419
|
}
|
|
1697
|
-
if (!
|
|
2420
|
+
if (!thinkingStreaming) {
|
|
2421
|
+
thinkingStreaming = true;
|
|
1698
2422
|
thinkingStartTime = Date.now();
|
|
1699
|
-
|
|
1700
|
-
|
|
2423
|
+
thoughtBuffer = '';
|
|
2424
|
+
thoughtPrinted = 0;
|
|
2425
|
+
liveShown = 0;
|
|
2426
|
+
liveAt = Date.now();
|
|
1701
2427
|
}
|
|
1702
|
-
|
|
2428
|
+
thoughtBuffer += event.delta;
|
|
2429
|
+
if (!cleanUi || thinkingExpanded)
|
|
2430
|
+
showThought();
|
|
1703
2431
|
}
|
|
1704
2432
|
else if (event.type === 'text_delta') {
|
|
1705
2433
|
isConnecting = false;
|
|
1706
2434
|
telemetry.setPhase('generating');
|
|
2435
|
+
statusLabel = t('status.writing');
|
|
1707
2436
|
telemetry.recordTokens(telemetry.estimateTokensFromText(event.delta));
|
|
2437
|
+
requestOutEstimate += telemetry.estimateTokensFromText(event.delta);
|
|
1708
2438
|
if (spinnerActive) {
|
|
1709
2439
|
s.stop();
|
|
1710
2440
|
spinnerActive = false;
|
|
@@ -1712,7 +2442,15 @@ export async function main() {
|
|
|
1712
2442
|
finishThinking(true);
|
|
1713
2443
|
if (!textActive) {
|
|
1714
2444
|
textActive = true;
|
|
2445
|
+
if (cleanUi) {
|
|
2446
|
+
if (spacerBeforeText)
|
|
2447
|
+
process.stdout.write('\n');
|
|
2448
|
+
spacerBeforeText = false;
|
|
2449
|
+
assistantGutter.reset();
|
|
2450
|
+
}
|
|
1715
2451
|
}
|
|
2452
|
+
assistantBuf += event.delta;
|
|
2453
|
+
gapBeforeTool = false;
|
|
1716
2454
|
streamer.push(event.delta);
|
|
1717
2455
|
}
|
|
1718
2456
|
else if (event.type === 'text') {
|
|
@@ -1723,7 +2461,18 @@ export async function main() {
|
|
|
1723
2461
|
}
|
|
1724
2462
|
finishThinking(true);
|
|
1725
2463
|
if (!textActive) {
|
|
1726
|
-
|
|
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
|
+
}
|
|
1727
2476
|
}
|
|
1728
2477
|
}
|
|
1729
2478
|
else if (event.type === 'tool_generating') {
|
|
@@ -1731,47 +2480,102 @@ export async function main() {
|
|
|
1731
2480
|
telemetry.setPhase('tool');
|
|
1732
2481
|
const theme = getCurrentTheme();
|
|
1733
2482
|
const label = formatToolGeneratingLabel(event.name, event.targetHint);
|
|
2483
|
+
statusLabel = label;
|
|
1734
2484
|
const teleStats = telemetry.getTotalTokens() > 0
|
|
1735
2485
|
? ` · ${LiveTelemetryTracker.formatTokenCount(telemetry.getTotalTokens())} tok · ${telemetry.getTokensPerSecond()} tok/s`
|
|
1736
2486
|
: '';
|
|
1737
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
|
+
}
|
|
1738
2494
|
if (!spinnerActive) {
|
|
1739
2495
|
streamer.finish();
|
|
1740
2496
|
finishThinking(false);
|
|
1741
|
-
s.start(theme.boldFn(label) + telemetrySuffix);
|
|
2497
|
+
s.start(cleanUi ? label : theme.boldFn(label) + telemetrySuffix);
|
|
1742
2498
|
spinnerActive = true;
|
|
1743
2499
|
}
|
|
1744
2500
|
else {
|
|
1745
|
-
s.message(theme.boldFn(label) + telemetrySuffix);
|
|
2501
|
+
s.message(cleanUi ? label : theme.boldFn(label) + telemetrySuffix);
|
|
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;
|
|
1746
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;
|
|
1747
2523
|
}
|
|
1748
2524
|
else if (event.type === 'tool_start') {
|
|
2525
|
+
attachReasoning();
|
|
1749
2526
|
isConnecting = false;
|
|
1750
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();
|
|
1751
2532
|
if (spinnerActive) {
|
|
1752
2533
|
s.stop();
|
|
1753
2534
|
spinnerActive = false;
|
|
1754
2535
|
}
|
|
1755
2536
|
streamer.finish();
|
|
1756
2537
|
finishThinking(false);
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
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)));
|
|
1760
2549
|
}
|
|
1761
|
-
console.log('\n' + pc.bold(pc.white(event.actionDesc)));
|
|
1762
2550
|
if (event.name !== 'ask_questions' && event.name !== 'todo_list') {
|
|
1763
|
-
s.start('Executing...');
|
|
2551
|
+
s.start(cleanUi ? t('status.running') : 'Executing...');
|
|
1764
2552
|
spinnerActive = true;
|
|
1765
2553
|
}
|
|
1766
2554
|
}
|
|
1767
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();
|
|
1768
2567
|
toolsExecutedCount++;
|
|
1769
2568
|
GitStatusCache.invalidate();
|
|
1770
2569
|
if (spinnerActive) {
|
|
1771
2570
|
s.stop();
|
|
1772
2571
|
spinnerActive = false;
|
|
1773
2572
|
}
|
|
1774
|
-
if (
|
|
2573
|
+
if (cleanUi) {
|
|
2574
|
+
if (event.result)
|
|
2575
|
+
renderCleanToolResult(event.name, event.result);
|
|
2576
|
+
spacerBeforeText = true;
|
|
2577
|
+
}
|
|
2578
|
+
else if (event.result) {
|
|
1775
2579
|
try {
|
|
1776
2580
|
const parsed = JSON.parse(event.result);
|
|
1777
2581
|
if (parsed.type === 'todo_list' && parsed.displayCard) {
|
|
@@ -1800,36 +2604,71 @@ export async function main() {
|
|
|
1800
2604
|
}
|
|
1801
2605
|
}
|
|
1802
2606
|
else if (event.type === 'usage') {
|
|
1803
|
-
|
|
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;
|
|
1804
2633
|
}
|
|
1805
2634
|
else if (event.type === 'reconnecting') {
|
|
2635
|
+
statusLabel = t('status.reconnecting');
|
|
1806
2636
|
telemetry.reset();
|
|
1807
2637
|
if (spinnerActive) {
|
|
1808
2638
|
s.stop();
|
|
1809
2639
|
spinnerActive = false;
|
|
1810
2640
|
}
|
|
1811
|
-
if (thinkingActive) {
|
|
2641
|
+
if (thinkingActive || thinkingStreaming) {
|
|
1812
2642
|
finishThinking(false);
|
|
1813
2643
|
}
|
|
1814
|
-
|
|
1815
|
-
process.stdout.write('\n');
|
|
1816
|
-
textActive = false;
|
|
1817
|
-
}
|
|
2644
|
+
endText();
|
|
1818
2645
|
streamer.reset();
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
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
|
+
}
|
|
1823
2656
|
spinnerActive = true;
|
|
1824
2657
|
}
|
|
1825
2658
|
else if (event.type === 'reconnected') {
|
|
2659
|
+
statusLabel = t('status.waiting');
|
|
1826
2660
|
if (spinnerActive) {
|
|
1827
2661
|
s.stop();
|
|
1828
2662
|
spinnerActive = false;
|
|
1829
2663
|
}
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
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...');
|
|
1833
2672
|
spinnerActive = true;
|
|
1834
2673
|
}
|
|
1835
2674
|
else if (event.type === 'error') {
|
|
@@ -1839,14 +2678,20 @@ export async function main() {
|
|
|
1839
2678
|
}
|
|
1840
2679
|
finishThinking(true);
|
|
1841
2680
|
streamer.finish();
|
|
1842
|
-
|
|
2681
|
+
if (cleanUi)
|
|
2682
|
+
console.log(renderNotice('error', event.message));
|
|
2683
|
+
else
|
|
2684
|
+
log.error(pc.bold(pc.red(`${event.message}`)));
|
|
1843
2685
|
}
|
|
1844
2686
|
else if (event.type === 'system') {
|
|
1845
2687
|
if (spinnerActive) {
|
|
1846
2688
|
s.stop();
|
|
1847
2689
|
spinnerActive = false;
|
|
1848
2690
|
}
|
|
1849
|
-
|
|
2691
|
+
if (cleanUi)
|
|
2692
|
+
console.log(renderNotice('info', event.message));
|
|
2693
|
+
else
|
|
2694
|
+
log.info(event.message);
|
|
1850
2695
|
}
|
|
1851
2696
|
}
|
|
1852
2697
|
finishThinking(true);
|
|
@@ -1855,59 +2700,113 @@ export async function main() {
|
|
|
1855
2700
|
s.stop();
|
|
1856
2701
|
spinnerActive = false;
|
|
1857
2702
|
}
|
|
1858
|
-
|
|
1859
|
-
|
|
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');
|
|
1860
2717
|
}
|
|
2718
|
+
else if (leftover.draft) {
|
|
2719
|
+
currentDraft = leftover.draft;
|
|
2720
|
+
}
|
|
2721
|
+
attachReasoning();
|
|
1861
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
|
+
}
|
|
1862
2736
|
const shouldNotify = (turnElapsedMs >= 15000) || (toolsExecutedCount > 0) || (planMode && lastPlanReady !== null);
|
|
1863
|
-
if (
|
|
2737
|
+
if (!aborted) {
|
|
1864
2738
|
const secs = Math.round(turnElapsedMs / 1000);
|
|
1865
|
-
|
|
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
|
+
}
|
|
1866
2754
|
}
|
|
1867
2755
|
// Auto-save session
|
|
1868
|
-
await sessionManager.save(history.getMessages(),
|
|
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
|
+
}
|
|
1869
2779
|
// Check if plan was finalized in PLAN mode
|
|
1870
2780
|
if (planMode && !aborted) {
|
|
1871
2781
|
const lastMsg = history.getMessages().slice(-1)[0];
|
|
1872
2782
|
const hasPlanReadyTool = lastPlanReady !== null;
|
|
1873
|
-
const hasPlanText = lastMsg && lastMsg.role === 'assistant' && (lastMsg.content.includes('
|
|
1874
|
-
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('Пошаговый список изменений') ||
|
|
1875
2787
|
lastMsg.content.includes('FINAL PLAN') ||
|
|
1876
2788
|
lastMsg.content.includes('План готов') ||
|
|
1877
2789
|
lastMsg.content.includes('Архитектурный план') ||
|
|
1878
2790
|
lastMsg.content.includes('план готов'));
|
|
1879
2791
|
if (hasPlanReadyTool || hasPlanText) {
|
|
2792
|
+
const approvedPlan = lastPlanReady ? { summary: lastPlanReady.summary, files: lastPlanReady.files, steps: lastPlanReady.steps } : (hasPlanText && lastMsg ? lastMsg.content : null);
|
|
1880
2793
|
resetPlanReady();
|
|
1881
2794
|
console.log();
|
|
1882
|
-
|
|
1883
|
-
try {
|
|
1884
|
-
choice = await select({
|
|
1885
|
-
message: pc.bold(pc.cyan('📋 Plan is ready! What would you like to do?')),
|
|
1886
|
-
choices: [
|
|
1887
|
-
{
|
|
1888
|
-
name: pc.bold(pc.green('🚀 Go (Switch to AGENT mode and start execution)')),
|
|
1889
|
-
value: 'go',
|
|
1890
|
-
description: 'Automatically switch to AGENT mode and start implementing the plan immediately'
|
|
1891
|
-
},
|
|
1892
|
-
{
|
|
1893
|
-
name: pc.bold(pc.yellow('✏️ Other (Modify, add details, or ask questions)')),
|
|
1894
|
-
value: 'other',
|
|
1895
|
-
description: 'Stay in PLAN mode and open prompt to type adjustments or additions'
|
|
1896
|
-
}
|
|
1897
|
-
]
|
|
1898
|
-
});
|
|
1899
|
-
}
|
|
1900
|
-
catch { }
|
|
2795
|
+
const choice = await askPlanDecision();
|
|
1901
2796
|
if (choice === 'go') {
|
|
1902
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);
|
|
1903
2801
|
const newSys = await buildSystemPrompt(false);
|
|
1904
2802
|
history.updateSystemPrompt(newSys);
|
|
1905
|
-
|
|
2803
|
+
log.success(t('plan.started'));
|
|
1906
2804
|
currentDraft = '';
|
|
1907
2805
|
autoTriggerPrompt = 'Go!';
|
|
1908
2806
|
continue;
|
|
1909
2807
|
}
|
|
1910
2808
|
else {
|
|
2809
|
+
log.info(t('plan.editHint'));
|
|
1911
2810
|
currentDraft = '';
|
|
1912
2811
|
continue;
|
|
1913
2812
|
}
|
|
@@ -1922,10 +2821,23 @@ export async function main() {
|
|
|
1922
2821
|
spinnerActive = false;
|
|
1923
2822
|
}
|
|
1924
2823
|
if (!aborted && err.name !== 'AbortError' && !err.message?.includes('aborted')) {
|
|
1925
|
-
|
|
2824
|
+
if (cleanUi)
|
|
2825
|
+
console.log(renderNotice('error', err.message));
|
|
2826
|
+
else
|
|
2827
|
+
log.error(pc.bold(pc.red(`${err.message}`)));
|
|
1926
2828
|
}
|
|
1927
2829
|
}
|
|
1928
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
|
+
}
|
|
1929
2841
|
globalSnapshotManager.finishTurn();
|
|
1930
2842
|
activeAbortHandler = null;
|
|
1931
2843
|
process.removeListener('SIGINT', onSigInt);
|
|
@@ -1936,6 +2848,15 @@ export async function main() {
|
|
|
1936
2848
|
}
|
|
1937
2849
|
}
|
|
1938
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);
|
|
1939
2860
|
}
|
|
1940
2861
|
process.on('exit', () => {
|
|
1941
2862
|
try {
|
|
@@ -1943,4 +2864,4 @@ process.on('exit', () => {
|
|
|
1943
2864
|
}
|
|
1944
2865
|
catch { }
|
|
1945
2866
|
});
|
|
1946
|
-
main().catch(
|
|
2867
|
+
main().catch((err) => reportRuntimeError(err, activeAbortHandler));
|