termux-dev 1.4.16 → 1.4.17
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/assets/banner.svg +1 -1
- package/dist/cli/doctor.js +1 -1
- package/dist/cli/headless.js +1 -1
- package/dist/cli/index (# Edit conflict 2026-08-29 pe1jzcC #).js +1966 -0
- package/dist/cli/index.js +5 -5
- package/dist/cli/prompt.js +37 -122
- package/dist/cli/server.js +1 -1
- package/dist/mcp/client.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1966 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import * as p from '@clack/prompts';
|
|
3
|
+
import { search, password, input, select } from '@inquirer/prompts';
|
|
4
|
+
import pc from 'picocolors';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import fs from 'fs/promises';
|
|
8
|
+
import fsSync from 'fs';
|
|
9
|
+
import { execSync, execFileSync } from 'child_process';
|
|
10
|
+
import { History } from '../core/history.js';
|
|
11
|
+
import { Agent } from '../core/loop.js';
|
|
12
|
+
import { buildSystemPrompt } from '../prompts/builder.js';
|
|
13
|
+
import { createProvider } from '../providers/index.js';
|
|
14
|
+
import { getTools, lastPlanReady, resetPlanReady } from '../tools/index.js';
|
|
15
|
+
import { CLIConsoleGuard } from '../permissions/guard.js';
|
|
16
|
+
import { globalSnapshotManager } from '../core/snapshot.js';
|
|
17
|
+
import { MemoryManager } from '../core/memory.js';
|
|
18
|
+
import { startServer, stopServer, displayServerBanner } from './server.js';
|
|
19
|
+
import { notifyDevice, acquireWakeLock } from '../core/notify.js';
|
|
20
|
+
import { runDoctor } from './doctor.js';
|
|
21
|
+
import { ALL_PROVIDERS } from './providers.js';
|
|
22
|
+
import { getModelContextLimit } from '../core/models.js';
|
|
23
|
+
import { SessionManager } from '../core/session.js';
|
|
24
|
+
import { MarkdownStreamer, renderMarkdown } from './markdown.js';
|
|
25
|
+
import { SmoothStreamer } from './smooth.js';
|
|
26
|
+
import { askPrompt } from './prompt.js';
|
|
27
|
+
import { resolveAtMentions } from './files.js';
|
|
28
|
+
import { runStartupUpdateCheck, checkForUpdates, performSelfUpdate } from './updater.js';
|
|
29
|
+
import { setActiveTheme, getCurrentTheme, listThemes, findTheme } from './theme.js';
|
|
30
|
+
import { runHeadlessMode } from './headless.js';
|
|
31
|
+
import { CustomCommandManager } from '../core/commands.js';
|
|
32
|
+
import { UsageTracker } from '../core/usage.js';
|
|
33
|
+
import { SessionExporter } from './export.js';
|
|
34
|
+
import { MCPManager } from '../mcp/manager.js';
|
|
35
|
+
const CONFIG_PATH = path.join(os.homedir(), '.devxrc.json');
|
|
36
|
+
function maskApiKey(key) {
|
|
37
|
+
if (!key)
|
|
38
|
+
return '';
|
|
39
|
+
if (key.length <= 8)
|
|
40
|
+
return '****';
|
|
41
|
+
return `${key.slice(0, 4)}...${key.slice(-4)}`;
|
|
42
|
+
}
|
|
43
|
+
async function saveConfig(config) {
|
|
44
|
+
config.apiKeys = config.apiKeys || {};
|
|
45
|
+
config.baseUrls = config.baseUrls || {};
|
|
46
|
+
if (config.provider && config.apiKey) {
|
|
47
|
+
config.apiKeys[config.provider] = config.apiKey;
|
|
48
|
+
}
|
|
49
|
+
if (config.provider && config.baseUrl) {
|
|
50
|
+
config.baseUrls[config.provider] = config.baseUrl;
|
|
51
|
+
}
|
|
52
|
+
await fs.writeFile(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8');
|
|
53
|
+
}
|
|
54
|
+
async function fetchModels(baseUrl, apiKey) {
|
|
55
|
+
try {
|
|
56
|
+
const url = baseUrl.endsWith('/') ? `${baseUrl}models` : `${baseUrl}/models`;
|
|
57
|
+
const headers = {};
|
|
58
|
+
if (apiKey && apiKey !== 'ollama')
|
|
59
|
+
headers['Authorization'] = `Bearer ${apiKey}`;
|
|
60
|
+
const res = await fetch(url, { headers });
|
|
61
|
+
if (!res.ok) {
|
|
62
|
+
if (res.status === 401 || res.status === 403) {
|
|
63
|
+
return { ok: false, status: res.status, models: [], error: 'Invalid API Key / Unauthorized' };
|
|
64
|
+
}
|
|
65
|
+
return { ok: false, status: res.status, models: [], error: `HTTP ${res.status} ${res.statusText}` };
|
|
66
|
+
}
|
|
67
|
+
const data = await res.json();
|
|
68
|
+
if (data && data.data && Array.isArray(data.data)) {
|
|
69
|
+
const list = data.data.map((m) => ({
|
|
70
|
+
id: m.id,
|
|
71
|
+
name: m.name || m.id,
|
|
72
|
+
contextLength: m.context_length || m.max_context_length || m.limit?.context || m.limit?.input
|
|
73
|
+
}));
|
|
74
|
+
return { ok: true, status: res.status, models: list };
|
|
75
|
+
}
|
|
76
|
+
return { ok: true, status: res.status, models: [] };
|
|
77
|
+
}
|
|
78
|
+
catch (e) {
|
|
79
|
+
return { ok: false, status: 0, models: [], error: e.message || 'Network error' };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const POPULAR_PROVIDER_MODELS = {
|
|
83
|
+
google: ['gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-1.5-flash', 'gemini-1.5-pro'],
|
|
84
|
+
openai: ['gpt-4o', 'gpt-4o-mini', 'o3-mini', 'o1', 'gpt-4-turbo'],
|
|
85
|
+
openrouter: ['google/gemini-2.5-flash', 'anthropic/claude-3.7-sonnet', 'openai/gpt-4o', 'deepseek/deepseek-r1', 'meta-llama/llama-3.3-70b-instruct'],
|
|
86
|
+
anthropic: ['claude-3-7-sonnet-20250219', 'claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022'],
|
|
87
|
+
deepseek: ['deepseek-chat', 'deepseek-reasoner'],
|
|
88
|
+
groq: ['llama-3.3-70b-versatile', 'llama-3.1-8b-instant', 'mixtral-8x7b-32768'],
|
|
89
|
+
mistral: ['mistral-large-latest', 'mistral-small-latest', 'codestral-latest'],
|
|
90
|
+
};
|
|
91
|
+
async function selectModel(baseUrl, apiKey, currentModel, providerId = '', allowCancel = true) {
|
|
92
|
+
const s = p.spinner();
|
|
93
|
+
s.start('Fetching available models from provider...');
|
|
94
|
+
const result = await fetchModels(baseUrl, apiKey);
|
|
95
|
+
s.stop();
|
|
96
|
+
let models = result.models;
|
|
97
|
+
if (models.length === 0 && providerId && POPULAR_PROVIDER_MODELS[providerId]) {
|
|
98
|
+
models = POPULAR_PROVIDER_MODELS[providerId].map(id => ({
|
|
99
|
+
id,
|
|
100
|
+
name: id,
|
|
101
|
+
contextLength: getModelContextLimit(id)
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
let modelChoice;
|
|
105
|
+
if (models.length > 0) {
|
|
106
|
+
try {
|
|
107
|
+
modelChoice = await search({
|
|
108
|
+
message: `Select a model (${models.length} available, type to filter):`,
|
|
109
|
+
source: async (term) => {
|
|
110
|
+
const q = (term || '').trim().toLowerCase();
|
|
111
|
+
const list = [];
|
|
112
|
+
if (allowCancel) {
|
|
113
|
+
list.push({ name: pc.yellow('⬅️ Back / Cancel'), value: '__cancel__', description: 'Keep current model' });
|
|
114
|
+
}
|
|
115
|
+
list.push({ name: pc.yellow('✏️ Type a custom model name...'), value: 'custom_input', description: 'Enter any model ID manually' });
|
|
116
|
+
for (const m of models) {
|
|
117
|
+
const isCurrent = m.id === currentModel;
|
|
118
|
+
const prefix = isCurrent ? pc.cyan('› ') : ' ';
|
|
119
|
+
const nameStr = isCurrent ? pc.bold(pc.cyan(m.id + ' (current)')) : m.id;
|
|
120
|
+
const ctxStr = m.contextLength ? `${m.contextLength >= 1_000_000 ? (m.contextLength / 1_000_000).toFixed(1) + 'M' : Math.round(m.contextLength / 1000) + 'k'} context` : '';
|
|
121
|
+
list.push({
|
|
122
|
+
name: prefix + nameStr,
|
|
123
|
+
value: m.id,
|
|
124
|
+
description: ctxStr
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
if (!q)
|
|
128
|
+
return list;
|
|
129
|
+
return list.filter(item => item.value.toLowerCase().includes(q) || item.name.toLowerCase().includes(q));
|
|
130
|
+
},
|
|
131
|
+
pageSize: 10
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
if (modelChoice === '__cancel__') {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
if (modelChoice === 'custom_input') {
|
|
141
|
+
try {
|
|
142
|
+
const customModel = await input({
|
|
143
|
+
message: 'Type the model name (e.g. gpt-4o or claude-3-7-sonnet):',
|
|
144
|
+
});
|
|
145
|
+
if (!customModel || !customModel.trim())
|
|
146
|
+
return null;
|
|
147
|
+
const name = customModel.trim();
|
|
148
|
+
return { model: name, contextLimit: getModelContextLimit(name) };
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const found = models.find(m => m.id === modelChoice);
|
|
155
|
+
const limit = (found && found.contextLength) || getModelContextLimit(modelChoice);
|
|
156
|
+
return { model: modelChoice, contextLimit: limit };
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
p.log.warn('Could not fetch models automatically.');
|
|
160
|
+
try {
|
|
161
|
+
const typedModel = await input({
|
|
162
|
+
message: 'Type the model name you want to use (e.g. gpt-4o):',
|
|
163
|
+
default: currentModel || ''
|
|
164
|
+
});
|
|
165
|
+
if (!typedModel || !typedModel.trim())
|
|
166
|
+
return null;
|
|
167
|
+
const name = typedModel.trim();
|
|
168
|
+
return { model: name, contextLimit: getModelContextLimit(name) };
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async function selectProvider(currentConfig, allowCancel = true) {
|
|
176
|
+
const apiKeys = currentConfig.apiKeys || {};
|
|
177
|
+
const baseUrls = currentConfig.baseUrls || {};
|
|
178
|
+
if (currentConfig.provider && currentConfig.apiKey) {
|
|
179
|
+
apiKeys[currentConfig.provider] = currentConfig.apiKey;
|
|
180
|
+
}
|
|
181
|
+
let providerId;
|
|
182
|
+
try {
|
|
183
|
+
providerId = await search({
|
|
184
|
+
message: 'Select AI Provider (type to filter, Esc to cancel):',
|
|
185
|
+
source: async (term) => {
|
|
186
|
+
const q = (term || '').trim().toLowerCase();
|
|
187
|
+
const filtered = q
|
|
188
|
+
? ALL_PROVIDERS.filter(pr => pr.label.toLowerCase().includes(q) || pr.value.toLowerCase().includes(q))
|
|
189
|
+
: ALL_PROVIDERS;
|
|
190
|
+
const list = filtered.map(pr => {
|
|
191
|
+
const isCurrent = pr.value === currentConfig.provider;
|
|
192
|
+
const labelStr = isCurrent ? pc.bold(pc.cyan(pr.label + ' (current)')) : pr.label;
|
|
193
|
+
const hasKey = apiKeys[pr.value] ? `[Key: ${maskApiKey(apiKeys[pr.value])}] ` : '';
|
|
194
|
+
return {
|
|
195
|
+
name: `${isCurrent ? pc.cyan('› ') : ' '}${labelStr}`,
|
|
196
|
+
value: pr.value,
|
|
197
|
+
description: `${hasKey}${pr.baseUrl || 'Custom endpoint'}`
|
|
198
|
+
};
|
|
199
|
+
});
|
|
200
|
+
if (allowCancel) {
|
|
201
|
+
list.unshift({
|
|
202
|
+
name: pc.yellow('⬅️ Back / Cancel'),
|
|
203
|
+
value: '__cancel__',
|
|
204
|
+
description: 'Keep current provider'
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
return list;
|
|
208
|
+
},
|
|
209
|
+
pageSize: 10
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
if (providerId === '__cancel__') {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
const selectedProvider = ALL_PROVIDERS.find(pr => pr.value === providerId) || { baseUrl: '', label: providerId };
|
|
219
|
+
let baseUrl = baseUrls[providerId] || selectedProvider.baseUrl;
|
|
220
|
+
if (providerId === 'custom' || !baseUrl) {
|
|
221
|
+
try {
|
|
222
|
+
const customUrl = await input({
|
|
223
|
+
message: 'Enter Base URL (e.g. https://api.openai.com/v1):',
|
|
224
|
+
default: baseUrl || ''
|
|
225
|
+
});
|
|
226
|
+
if (!customUrl || !customUrl.trim())
|
|
227
|
+
return null;
|
|
228
|
+
baseUrl = customUrl.trim();
|
|
229
|
+
baseUrls[providerId] = baseUrl;
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
let apiKey = '';
|
|
236
|
+
if (providerId === 'ollama') {
|
|
237
|
+
apiKey = 'ollama';
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
const savedKey = apiKeys[providerId];
|
|
241
|
+
if (savedKey) {
|
|
242
|
+
let keyChoice;
|
|
243
|
+
try {
|
|
244
|
+
keyChoice = await search({
|
|
245
|
+
message: `API key found for ${selectedProvider.label} (${maskApiKey(savedKey)}):`,
|
|
246
|
+
source: async () => [
|
|
247
|
+
{ name: `🟢 Continue with saved API key (${maskApiKey(savedKey)})`, value: 'use_saved' },
|
|
248
|
+
{ name: '✏️ Enter a new API key', value: 'new_key' },
|
|
249
|
+
{ name: pc.yellow('⬅️ Cancel'), value: '__cancel__' }
|
|
250
|
+
],
|
|
251
|
+
pageSize: 5
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
if (keyChoice === '__cancel__')
|
|
258
|
+
return null;
|
|
259
|
+
if (keyChoice === 'use_saved') {
|
|
260
|
+
apiKey = savedKey;
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
try {
|
|
264
|
+
const key = await password({
|
|
265
|
+
message: `Enter new API Key for ${selectedProvider.label}:`,
|
|
266
|
+
mask: '•'
|
|
267
|
+
});
|
|
268
|
+
if (!key || !key.trim())
|
|
269
|
+
return null;
|
|
270
|
+
apiKey = key.trim();
|
|
271
|
+
apiKeys[providerId] = apiKey;
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
try {
|
|
280
|
+
const key = await password({
|
|
281
|
+
message: `Enter API Key for ${selectedProvider.label} (or press Esc to cancel):`,
|
|
282
|
+
mask: '•'
|
|
283
|
+
});
|
|
284
|
+
if (!key || !key.trim())
|
|
285
|
+
return null;
|
|
286
|
+
apiKey = key.trim();
|
|
287
|
+
apiKeys[providerId] = apiKey;
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
// Pick a model for this provider
|
|
295
|
+
const modelRes = await selectModel(baseUrl, apiKey, currentConfig.model || '', providerId, allowCancel);
|
|
296
|
+
if (!modelRes) {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
const updatedConfig = {
|
|
300
|
+
...currentConfig,
|
|
301
|
+
provider: providerId,
|
|
302
|
+
baseUrl,
|
|
303
|
+
apiKey,
|
|
304
|
+
model: modelRes.model,
|
|
305
|
+
maxContextTokens: modelRes.contextLimit,
|
|
306
|
+
maxIterations: currentConfig.maxIterations || 30,
|
|
307
|
+
apiKeys,
|
|
308
|
+
baseUrls
|
|
309
|
+
};
|
|
310
|
+
await saveConfig(updatedConfig);
|
|
311
|
+
return updatedConfig;
|
|
312
|
+
}
|
|
313
|
+
async function runOnboarding() {
|
|
314
|
+
console.clear();
|
|
315
|
+
p.intro(pc.bgCyan(pc.black(' Welcome to devx ')));
|
|
316
|
+
p.note('No configuration found. Let\'s set up your AI provider.', 'Setup');
|
|
317
|
+
const res = await selectProvider({ apiKeys: {}, baseUrls: {} }, false);
|
|
318
|
+
if (!res) {
|
|
319
|
+
process.exit(0);
|
|
320
|
+
}
|
|
321
|
+
p.outro(pc.green('Setup complete! Configuration saved to ~/.devxrc.json'));
|
|
322
|
+
return res;
|
|
323
|
+
}
|
|
324
|
+
async function loadConfig(interactive = true) {
|
|
325
|
+
let globalConfig = {};
|
|
326
|
+
try {
|
|
327
|
+
const data = await fs.readFile(CONFIG_PATH, 'utf8');
|
|
328
|
+
globalConfig = JSON.parse(data);
|
|
329
|
+
if (!globalConfig.model || !globalConfig.baseUrl)
|
|
330
|
+
throw new Error('Invalid config');
|
|
331
|
+
globalConfig.maxContextTokens = globalConfig.maxContextTokens || getModelContextLimit(globalConfig.model);
|
|
332
|
+
globalConfig.apiKeys = globalConfig.apiKeys || {};
|
|
333
|
+
globalConfig.baseUrls = globalConfig.baseUrls || {};
|
|
334
|
+
globalConfig.trustedProjects = globalConfig.trustedProjects || {};
|
|
335
|
+
if (globalConfig.provider && globalConfig.apiKey) {
|
|
336
|
+
globalConfig.apiKeys[globalConfig.provider] = globalConfig.apiKey;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
if (interactive) {
|
|
341
|
+
globalConfig = await runOnboarding();
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
throw new Error('No configuration found in ~/.devxrc.json. Please run devx interactively first to set up your AI provider.');
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
// Check for local project config (.devx.json or .devxrc.json)
|
|
348
|
+
const localConfigPaths = [
|
|
349
|
+
path.join(process.cwd(), '.devx.json'),
|
|
350
|
+
path.join(process.cwd(), '.devxrc.json')
|
|
351
|
+
];
|
|
352
|
+
let localConfig = null;
|
|
353
|
+
for (const lp of localConfigPaths) {
|
|
354
|
+
try {
|
|
355
|
+
if (fsSync.existsSync(lp)) {
|
|
356
|
+
const raw = await fs.readFile(lp, 'utf8');
|
|
357
|
+
localConfig = JSON.parse(raw);
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
catch { }
|
|
362
|
+
}
|
|
363
|
+
const effectiveConfig = { ...globalConfig };
|
|
364
|
+
const cwd = process.cwd();
|
|
365
|
+
let isTrusted = globalConfig.trustedProjects?.[cwd];
|
|
366
|
+
// Workspace & Project Trust Check on first time opening this directory
|
|
367
|
+
if (isTrusted === undefined) {
|
|
368
|
+
if (interactive) {
|
|
369
|
+
console.log('');
|
|
370
|
+
p.log.warn(pc.bold(pc.yellow(`🛡️ [WORKSPACE TRUST] First time opening this workspace:`)));
|
|
371
|
+
console.log(pc.dim(` Directory: ${cwd}`));
|
|
372
|
+
if (localConfig) {
|
|
373
|
+
if (localConfig.autoApprove === true) {
|
|
374
|
+
console.log(pc.yellow(` • Local .devx.json requests Auto-approval (YOLO mode)`));
|
|
375
|
+
}
|
|
376
|
+
if (localConfig.bashAllowlist) {
|
|
377
|
+
console.log(pc.yellow(` • Local .devx.json requests Bash allowlist: ${localConfig.bashAllowlist.join(', ')}`));
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
const answer = await p.confirm({
|
|
381
|
+
message: 'Do you trust this workspace and allow agent operations?',
|
|
382
|
+
initialValue: true
|
|
383
|
+
});
|
|
384
|
+
if (p.isCancel(answer) || answer !== true) {
|
|
385
|
+
resetTerminalTheme();
|
|
386
|
+
p.outro(pc.yellow('Workspace not trusted. Exiting devx.'));
|
|
387
|
+
process.exit(0);
|
|
388
|
+
}
|
|
389
|
+
globalConfig.trustedProjects = globalConfig.trustedProjects || {};
|
|
390
|
+
globalConfig.trustedProjects[cwd] = true;
|
|
391
|
+
await saveConfig(globalConfig);
|
|
392
|
+
p.log.success(pc.green('Workspace marked as trusted. Full capabilities enabled.'));
|
|
393
|
+
}
|
|
394
|
+
else {
|
|
395
|
+
isTrusted = false; // Never auto-trust in non-interactive headless mode
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (localConfig && typeof localConfig === 'object') {
|
|
399
|
+
// Apply safe configuration overrides
|
|
400
|
+
if (localConfig.model)
|
|
401
|
+
effectiveConfig.model = localConfig.model;
|
|
402
|
+
if (localConfig.provider)
|
|
403
|
+
effectiveConfig.provider = localConfig.provider;
|
|
404
|
+
if (localConfig.apiKey)
|
|
405
|
+
effectiveConfig.apiKey = localConfig.apiKey;
|
|
406
|
+
if (localConfig.baseUrl)
|
|
407
|
+
effectiveConfig.baseUrl = localConfig.baseUrl;
|
|
408
|
+
if (localConfig.maxIterations)
|
|
409
|
+
effectiveConfig.maxIterations = localConfig.maxIterations;
|
|
410
|
+
if (localConfig.maxContextTokens)
|
|
411
|
+
effectiveConfig.maxContextTokens = localConfig.maxContextTokens;
|
|
412
|
+
if (localConfig.dataSaverLimitMB)
|
|
413
|
+
effectiveConfig.dataSaverLimitMB = localConfig.dataSaverLimitMB;
|
|
414
|
+
if (localConfig.pureBlackTheme !== undefined)
|
|
415
|
+
effectiveConfig.pureBlackTheme = localConfig.pureBlackTheme;
|
|
416
|
+
// Apply elevated permissions only if explicitly trusted
|
|
417
|
+
if (isTrusted) {
|
|
418
|
+
if (localConfig.autoApprove !== undefined)
|
|
419
|
+
effectiveConfig.autoApprove = localConfig.autoApprove;
|
|
420
|
+
if (Array.isArray(localConfig.bashAllowlist))
|
|
421
|
+
effectiveConfig.bashAllowlist = localConfig.bashAllowlist;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
// Configure Data Saver threshold
|
|
425
|
+
if (effectiveConfig.dataSaverLimitMB) {
|
|
426
|
+
UsageTracker.getInstance().setLimit(effectiveConfig.dataSaverLimitMB);
|
|
427
|
+
}
|
|
428
|
+
return effectiveConfig;
|
|
429
|
+
}
|
|
430
|
+
function enableDarkTheme(enabled = true) {
|
|
431
|
+
if (!enabled)
|
|
432
|
+
return;
|
|
433
|
+
if (process.stdout.isTTY) {
|
|
434
|
+
// Deep OLED / Obsidian Black background (#0a0a0c) and bright crisp foreground (#f0f6fc)
|
|
435
|
+
process.stdout.write('\x1b]11;#0a0a0c\x07');
|
|
436
|
+
process.stdout.write('\x1b]10;#f0f6fc\x07');
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
function resetTerminalTheme() {
|
|
440
|
+
if (process.stdout.isTTY) {
|
|
441
|
+
process.stdout.write('\x1b]111\x07');
|
|
442
|
+
process.stdout.write('\x1b]110\x07');
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
let activeAbortHandler = null;
|
|
446
|
+
process.on('exit', () => {
|
|
447
|
+
if (process.stdin.isTTY) {
|
|
448
|
+
process.stdin.setRawMode(false);
|
|
449
|
+
}
|
|
450
|
+
process.stdout.write('\x1B[?25h');
|
|
451
|
+
resetTerminalTheme();
|
|
452
|
+
});
|
|
453
|
+
process.on('SIGINT', () => {
|
|
454
|
+
if (activeAbortHandler) {
|
|
455
|
+
activeAbortHandler();
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
if (process.stdin.isTTY) {
|
|
459
|
+
process.stdin.setRawMode(false);
|
|
460
|
+
process.stdin.pause();
|
|
461
|
+
}
|
|
462
|
+
process.stdout.write('\x1B[?25h'); // restore cursor
|
|
463
|
+
resetTerminalTheme();
|
|
464
|
+
process.exit(0);
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
function clearTerminalScreen() {
|
|
468
|
+
if (process.stdout.isTTY) {
|
|
469
|
+
// \x1b[2J: clear screen, \x1b[3J: clear scrollback buffer (crucial for Termux/xterm), \x1b[H: cursor to home
|
|
470
|
+
process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
console.clear();
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
function drawLogo() {
|
|
477
|
+
const cols = process.stdout.columns || 80;
|
|
478
|
+
clearTerminalScreen();
|
|
479
|
+
const theme = getCurrentTheme();
|
|
480
|
+
if (cols < 56) {
|
|
481
|
+
// Ultra-clean compact ASCII for small mobile screens (width: ~26 chars)
|
|
482
|
+
const logo = [
|
|
483
|
+
'',
|
|
484
|
+
theme.colorFn(' █▀▀▄ █▀▀▀ █ █ █ █'),
|
|
485
|
+
theme.colorFn(' █ █ █▀▀▀ ▀▄▀ ▀▄▀ '),
|
|
486
|
+
theme.colorFn(' █▄▄▀ █▄▄▄ ▀ ▀ ▀ '),
|
|
487
|
+
' ' + theme.boldFn('v1.4.17'),
|
|
488
|
+
''
|
|
489
|
+
];
|
|
490
|
+
for (const line of logo) {
|
|
491
|
+
console.log(line);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
else {
|
|
495
|
+
// Full TERMUX-DEV banner (width: 53 chars)
|
|
496
|
+
const indent = cols < 68 ? ' ' : ' ';
|
|
497
|
+
const logo = [
|
|
498
|
+
'',
|
|
499
|
+
indent + theme.colorFn('▀▀▀█▀▀▀ █▀▀▀ █▀▀█ █▄ ▄█ █ █ ▀▄ ▄▀ █▀▀▄ █▀▀▀ █ █'),
|
|
500
|
+
indent + theme.colorFn(' █ █▀▀▀ █▄▄▀ █ █ █ █ █ █ ▀▀ █ █ █▀▀▀ █ █'),
|
|
501
|
+
indent + theme.colorFn(' █ █▄▄▄ █ ▀▄ █ █ ▀▄▄▀ ▄▀ ▀▄ █▄▄▀ █▄▄▄ ▀▄▀ '),
|
|
502
|
+
indent + theme.boldFn('v1.4.17'),
|
|
503
|
+
''
|
|
504
|
+
];
|
|
505
|
+
for (const line of logo) {
|
|
506
|
+
console.log(line);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
console.log();
|
|
510
|
+
}
|
|
511
|
+
function formatSessionTime(timestamp) {
|
|
512
|
+
const diff = Date.now() - timestamp;
|
|
513
|
+
const mins = Math.floor(diff / 60000);
|
|
514
|
+
if (mins < 1)
|
|
515
|
+
return 'just now';
|
|
516
|
+
if (mins < 60)
|
|
517
|
+
return `${mins}m ago`;
|
|
518
|
+
const hours = Math.floor(mins / 60);
|
|
519
|
+
if (hours < 24)
|
|
520
|
+
return `${hours}h ago`;
|
|
521
|
+
const days = Math.floor(hours / 24);
|
|
522
|
+
return `${days}d ago`;
|
|
523
|
+
}
|
|
524
|
+
function formatToolGeneratingLabel(name, targetHint) {
|
|
525
|
+
const t = name.toLowerCase();
|
|
526
|
+
const target = targetHint ? ` ${targetHint}` : '';
|
|
527
|
+
if (t === 'read_file' || t === 'read')
|
|
528
|
+
return `→ Read${target || '...'}`;
|
|
529
|
+
if (t === 'write_file' || t === 'write')
|
|
530
|
+
return `→ Write${target || '...'}`;
|
|
531
|
+
if (t === 'edit_file' || t === 'patch')
|
|
532
|
+
return `→ Edit${target || '...'}`;
|
|
533
|
+
if (t === 'delete_file' || t === 'remove_file' || t === 'rm')
|
|
534
|
+
return `→ Delete${target || '...'}`;
|
|
535
|
+
if (t === 'bash' || t === 'run_command' || t === 'exec')
|
|
536
|
+
return `→ Run:${target || '...'}`;
|
|
537
|
+
if (t === 'search' || t === 'grep_search')
|
|
538
|
+
return `→ Search${target || '...'}`;
|
|
539
|
+
if (t === 'list_dir' || t === 'ls')
|
|
540
|
+
return `→ List${target || '...'}`;
|
|
541
|
+
if (t === 'todo_list' || t === 'update_todos')
|
|
542
|
+
return `→ Update Plan & Tasks...`;
|
|
543
|
+
if (t === 'plan_ready')
|
|
544
|
+
return `→ Finalize Plan...`;
|
|
545
|
+
if (t === 'diagnose_code')
|
|
546
|
+
return `→ Diagnosing code...`;
|
|
547
|
+
if (t === 'ask_questions')
|
|
548
|
+
return `→ Clarifying questions...`;
|
|
549
|
+
return `→ ${name}${target}...`;
|
|
550
|
+
}
|
|
551
|
+
async function handleSessionDelete() {
|
|
552
|
+
while (true) {
|
|
553
|
+
const sessions = await SessionManager.listSessions();
|
|
554
|
+
if (sessions.length === 0) {
|
|
555
|
+
drawLogo();
|
|
556
|
+
p.log.warn('No saved sessions found.');
|
|
557
|
+
break;
|
|
558
|
+
}
|
|
559
|
+
let chosenSessionId;
|
|
560
|
+
try {
|
|
561
|
+
chosenSessionId = await search({
|
|
562
|
+
message: `Select session to DELETE (${sessions.length} sessions, Esc to finish):`,
|
|
563
|
+
source: async (term) => {
|
|
564
|
+
const q = (term || '').trim().toLowerCase();
|
|
565
|
+
const list = [
|
|
566
|
+
{ name: pc.yellow('⬅️ Done / Cancel'), value: '__cancel__', description: 'Return to chat' },
|
|
567
|
+
{ name: pc.red('🗑️ Delete ALL sessions'), value: '__all__', description: `Remove all ${sessions.length} saved sessions` }
|
|
568
|
+
];
|
|
569
|
+
for (const s of sessions) {
|
|
570
|
+
const timeStr = formatSessionTime(s.updatedAt);
|
|
571
|
+
const msgCount = s.messages.filter(m => m.role !== 'system').length;
|
|
572
|
+
const shortId = s.id.split('_').pop();
|
|
573
|
+
list.push({
|
|
574
|
+
name: `${pc.red('🗑️ ')} ${pc.dim(`[#${shortId}]`)} ${s.title}`,
|
|
575
|
+
value: s.id,
|
|
576
|
+
description: `${timeStr} • ${msgCount} msgs • ${s.model || 'default'}`
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
if (!q)
|
|
580
|
+
return list;
|
|
581
|
+
return list.filter(item => item.name.toLowerCase().includes(q) || item.value.toLowerCase().includes(q));
|
|
582
|
+
},
|
|
583
|
+
pageSize: 10
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
catch {
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
589
|
+
if (!chosenSessionId || chosenSessionId === '__cancel__') {
|
|
590
|
+
drawLogo();
|
|
591
|
+
p.log.info('Session deletion finished.');
|
|
592
|
+
break;
|
|
593
|
+
}
|
|
594
|
+
if (chosenSessionId === '__all__') {
|
|
595
|
+
const confirmAll = await p.confirm({
|
|
596
|
+
message: `Are you sure you want to delete ALL ${sessions.length} saved sessions?`,
|
|
597
|
+
initialValue: false
|
|
598
|
+
});
|
|
599
|
+
if (!p.isCancel(confirmAll) && confirmAll) {
|
|
600
|
+
const count = await SessionManager.deleteAllSessions();
|
|
601
|
+
drawLogo();
|
|
602
|
+
p.log.success(`Deleted all ${count} sessions.`);
|
|
603
|
+
break;
|
|
604
|
+
}
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
const target = sessions.find(s => s.id === chosenSessionId);
|
|
608
|
+
const title = target ? target.title : chosenSessionId;
|
|
609
|
+
const ok = await SessionManager.deleteSession(chosenSessionId);
|
|
610
|
+
drawLogo();
|
|
611
|
+
if (ok) {
|
|
612
|
+
p.log.success(`Deleted session: "${title}"`);
|
|
613
|
+
}
|
|
614
|
+
else {
|
|
615
|
+
p.log.error(`Failed to delete session: "${title}"`);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
async function handleThemeSelect(config) {
|
|
620
|
+
const currentTh = getCurrentTheme();
|
|
621
|
+
const themeChoices = listThemes().map(t => ({
|
|
622
|
+
name: `${t.emoji} ${t.boldFn(t.name.padEnd(18))} ${pc.dim(t.desc)} ${t.id === currentTh.id ? pc.green('(Active)') : ''}`,
|
|
623
|
+
value: t.id,
|
|
624
|
+
description: `Apply ${t.name} color palette (${t.hex}) to banners, prompts, and actions`
|
|
625
|
+
}));
|
|
626
|
+
try {
|
|
627
|
+
const selected = await select({
|
|
628
|
+
message: `${pc.bold('🎨 Select UI Theme / Выберите цветовую тему:')}`,
|
|
629
|
+
choices: themeChoices
|
|
630
|
+
});
|
|
631
|
+
if (selected) {
|
|
632
|
+
config.theme = selected;
|
|
633
|
+
await saveConfig(config);
|
|
634
|
+
const th = setActiveTheme(selected);
|
|
635
|
+
drawLogo();
|
|
636
|
+
p.log.success(th.boldFn(`🎨 Theme switched to ${th.emoji} ${th.name}!`));
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
catch { }
|
|
640
|
+
}
|
|
641
|
+
async function handleSettings(config) {
|
|
642
|
+
while (true) {
|
|
643
|
+
try {
|
|
644
|
+
const maxIter = config.maxIterations || 100;
|
|
645
|
+
const maxIterLabel = maxIter >= 9999 ? 'Unlimited' : `${maxIter} steps`;
|
|
646
|
+
const currentTh = getCurrentTheme();
|
|
647
|
+
const choice = await select({
|
|
648
|
+
message: `${pc.bold('⚙️ Settings')} ${pc.dim(`(devx v1.4.17 • theme: ${currentTh.name})`)}`,
|
|
649
|
+
choices: [
|
|
650
|
+
{
|
|
651
|
+
name: `🎨 Color Theme: ${currentTh.emoji} ${currentTh.name}`,
|
|
652
|
+
value: 'change_theme',
|
|
653
|
+
description: `Switch UI accent colors (${currentTh.desc})`
|
|
654
|
+
},
|
|
655
|
+
{
|
|
656
|
+
name: `${config.pureBlackTheme !== false ? pc.green('🖤 Pure Black Background: ON') : pc.yellow('🖤 Pure Black Background: OFF')}`,
|
|
657
|
+
value: 'toggle_black_theme',
|
|
658
|
+
description: config.pureBlackTheme !== false
|
|
659
|
+
? 'Apply deep OLED obsidian black background (#0a0a0c) like OpenCode'
|
|
660
|
+
: 'Use standard system terminal background color'
|
|
661
|
+
},
|
|
662
|
+
{
|
|
663
|
+
name: `${config.autoApprove ? pc.green('⚡ Auto-Approve (YOLO Mode): ON') : pc.yellow('🛡️ Auto-Approve (YOLO Mode): OFF')}`,
|
|
664
|
+
value: 'toggle_auto_approve',
|
|
665
|
+
description: config.autoApprove
|
|
666
|
+
? 'Permissions are automatically granted (no confirmation prompts for commands/files)'
|
|
667
|
+
: 'Agent asks for confirmation before executing bash commands or writing files'
|
|
668
|
+
},
|
|
669
|
+
{
|
|
670
|
+
name: `${config.enableMemory !== false ? pc.green('🧠 Project Memory Bank: ON') : pc.yellow('🧠 Project Memory Bank: OFF')}`,
|
|
671
|
+
value: 'toggle_memory',
|
|
672
|
+
description: config.enableMemory !== false
|
|
673
|
+
? 'Load persistent project rules and preferences from .devx/memory.md into AI context'
|
|
674
|
+
: 'Start sessions with a clean state without loading project memory'
|
|
675
|
+
},
|
|
676
|
+
{
|
|
677
|
+
name: `${config.checkUpdates !== false ? pc.green('🔔 Check for Updates on Startup: ON') : pc.yellow('🔔 Check for Updates on Startup: OFF')}`,
|
|
678
|
+
value: 'toggle_check_updates',
|
|
679
|
+
description: config.checkUpdates !== false
|
|
680
|
+
? 'Automatically check for updates from GitHub repository when launching devx'
|
|
681
|
+
: 'Disable update checking on startup (run /update manually instead)'
|
|
682
|
+
},
|
|
683
|
+
{
|
|
684
|
+
name: `🔄 Max Agent Iterations: ${currentTh.colorFn(maxIterLabel)}`,
|
|
685
|
+
value: 'change_max_iterations',
|
|
686
|
+
description: 'Limit how many tool steps (file edits, terminal commands) agent can do per request'
|
|
687
|
+
},
|
|
688
|
+
{
|
|
689
|
+
name: `${currentTh.colorFn('✨ About devx')} ${pc.dim('(v1.4.17 by ApvCode)')}`,
|
|
690
|
+
value: 'about',
|
|
691
|
+
description: 'Terminal-Native AI Coding Agent created by ApvCode (https://github.com/apvcode/Termux-Dev)'
|
|
692
|
+
},
|
|
693
|
+
{
|
|
694
|
+
name: '⬅️ Back / Save',
|
|
695
|
+
value: 'back',
|
|
696
|
+
description: 'Return to chat'
|
|
697
|
+
}
|
|
698
|
+
]
|
|
699
|
+
});
|
|
700
|
+
if (choice === 'change_theme') {
|
|
701
|
+
await handleThemeSelect(config);
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
if (choice === 'about') {
|
|
705
|
+
p.note(`⚡ devx v1.4.17 — Terminal-Native AI Coding Agent\n` +
|
|
706
|
+
`🎨 Theme: ${currentTh.emoji} ${currentTh.name}\n` +
|
|
707
|
+
`👤 Author: ApvCode (https://github.com/apvcode)\n` +
|
|
708
|
+
`🌟 Repository: https://github.com/apvcode/Termux-Dev\n` +
|
|
709
|
+
`📜 License: MIT License (2026)\n` +
|
|
710
|
+
`Built for Android Termux, Windows, macOS, and Linux.`, 'About devx');
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
if (choice === 'toggle_black_theme') {
|
|
714
|
+
config.pureBlackTheme = config.pureBlackTheme === false ? true : false;
|
|
715
|
+
await saveConfig(config);
|
|
716
|
+
if (config.pureBlackTheme) {
|
|
717
|
+
enableDarkTheme(true);
|
|
718
|
+
}
|
|
719
|
+
else {
|
|
720
|
+
resetTerminalTheme();
|
|
721
|
+
}
|
|
722
|
+
drawLogo();
|
|
723
|
+
p.log.success(`Pure Black background: ${config.pureBlackTheme ? pc.bold(pc.green('ON (Deep Black)')) : pc.bold(pc.yellow('OFF (System Default)'))}`);
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
if (choice === 'toggle_auto_approve') {
|
|
727
|
+
config.autoApprove = !config.autoApprove;
|
|
728
|
+
await saveConfig(config);
|
|
729
|
+
p.log.success(`Auto-approve permissions: ${config.autoApprove ? pc.bold(pc.green('ON (Automatic Yes)')) : pc.bold(pc.yellow('OFF (Ask every time)'))}`);
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
if (choice === 'toggle_memory') {
|
|
733
|
+
config.enableMemory = config.enableMemory === false ? true : false;
|
|
734
|
+
await saveConfig(config);
|
|
735
|
+
p.log.success(`Project memory bank: ${config.enableMemory !== false ? pc.bold(pc.green('ON (Persistent .devx/memory.md)')) : pc.bold(pc.yellow('OFF'))}`);
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
if (choice === 'toggle_check_updates') {
|
|
739
|
+
config.checkUpdates = config.checkUpdates === false ? true : false;
|
|
740
|
+
await saveConfig(config);
|
|
741
|
+
p.log.success(`Check for updates: ${config.checkUpdates !== false ? pc.bold(pc.green('ON (Checked on startup)')) : pc.bold(pc.yellow('OFF (Manual only)'))}`);
|
|
742
|
+
continue;
|
|
743
|
+
}
|
|
744
|
+
if (choice === 'change_max_iterations') {
|
|
745
|
+
const val = await select({
|
|
746
|
+
message: 'Select maximum iterations limit per prompt:',
|
|
747
|
+
choices: [
|
|
748
|
+
{ name: '30 steps (Strict / Safe)', value: 30 },
|
|
749
|
+
{ name: '50 steps (Moderate)', value: 50 },
|
|
750
|
+
{ name: '100 steps (Recommended / Default)', value: 100 },
|
|
751
|
+
{ name: '200 steps (Very large refactors)', value: 200 },
|
|
752
|
+
{ name: 'Unlimited (No limit)', value: 9999 }
|
|
753
|
+
]
|
|
754
|
+
});
|
|
755
|
+
config.maxIterations = val;
|
|
756
|
+
await saveConfig(config);
|
|
757
|
+
p.log.success(`Max iterations updated to: ${pc.bold(val >= 9999 ? 'Unlimited' : `${val} steps`)}`);
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
break;
|
|
761
|
+
}
|
|
762
|
+
catch {
|
|
763
|
+
break;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
process.stdin.resume();
|
|
767
|
+
return config;
|
|
768
|
+
}
|
|
769
|
+
export async function main() {
|
|
770
|
+
// === Termux / Android Stability: Global Signal Handlers ===
|
|
771
|
+
// Ignore SIGHUP so the process survives when Termux is minimized
|
|
772
|
+
if (process.platform !== 'win32') {
|
|
773
|
+
process.on('SIGHUP', () => {
|
|
774
|
+
// Termux minimized or window lost focus — keep process alive
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
// Graceful shutdown on SIGTERM (closing Termux tab / kill command)
|
|
778
|
+
process.on('SIGTERM', () => {
|
|
779
|
+
try {
|
|
780
|
+
resetTerminalTheme();
|
|
781
|
+
MCPManager.getInstance().stopAll();
|
|
782
|
+
}
|
|
783
|
+
catch { }
|
|
784
|
+
process.exit(0);
|
|
785
|
+
});
|
|
786
|
+
// Protect against crash on background I/O errors (EIO/EPIPE/ERR_STREAM_DESTROYED)
|
|
787
|
+
process.on('uncaughtException', (err) => {
|
|
788
|
+
const isIgnorableIo = err.code === 'EIO' || err.code === 'EPIPE' || err.code === 'ERR_STREAM_DESTROYED';
|
|
789
|
+
if (isIgnorableIo)
|
|
790
|
+
return;
|
|
791
|
+
console.error('\n[devx runtime error]:', err.message);
|
|
792
|
+
});
|
|
793
|
+
process.on('unhandledRejection', (reason) => {
|
|
794
|
+
// Prevent Node.js crash from unhandled network promise rejections
|
|
795
|
+
});
|
|
796
|
+
acquireWakeLock();
|
|
797
|
+
const program = new Command();
|
|
798
|
+
program
|
|
799
|
+
.name('devx')
|
|
800
|
+
.description('Terminal-native AI coding assistant and vibe-coding agent')
|
|
801
|
+
.version('1.4.17')
|
|
802
|
+
.option('-p, --prompt <task>', 'Run one-shot task non-interactively (headless mode)')
|
|
803
|
+
.option('-y, --yolo', 'Automatically approve all tool executions without confirmation')
|
|
804
|
+
.option('-m, --model <model>', 'Specify AI model to use for this execution')
|
|
805
|
+
.option('-q, --quiet', 'Quiet output in headless mode (suppress banners and tool logs)')
|
|
806
|
+
.option('--json', 'Output structured JSON in headless mode')
|
|
807
|
+
.option('--plan', 'Start in plan mode (architect & planner)')
|
|
808
|
+
.parse(process.argv);
|
|
809
|
+
const options = program.opts();
|
|
810
|
+
const planModeInitial = !!options.plan;
|
|
811
|
+
const isHeadless = !!options.prompt;
|
|
812
|
+
let config = await loadConfig(!isHeadless);
|
|
813
|
+
if (options.model) {
|
|
814
|
+
config.model = options.model;
|
|
815
|
+
config.maxContextTokens = getModelContextLimit(options.model);
|
|
816
|
+
}
|
|
817
|
+
if (options.yolo) {
|
|
818
|
+
config.autoApprove = true;
|
|
819
|
+
}
|
|
820
|
+
// If -p / --prompt is provided, execute one-shot headless mode and exit!
|
|
821
|
+
if (isHeadless) {
|
|
822
|
+
const exitCode = await runHeadlessMode(options.prompt, config, {
|
|
823
|
+
planMode: planModeInitial,
|
|
824
|
+
yolo: !!options.yolo,
|
|
825
|
+
quiet: !!options.quiet,
|
|
826
|
+
json: !!options.json
|
|
827
|
+
});
|
|
828
|
+
process.exit(exitCode);
|
|
829
|
+
}
|
|
830
|
+
let planMode = planModeInitial;
|
|
831
|
+
if (!config.onboarded) {
|
|
832
|
+
const cols = Math.min(process.stdout.columns || 40, 42);
|
|
833
|
+
const fill = Math.max(2, cols - 16);
|
|
834
|
+
const topBorder = '┌─ preview.ts ' + '─'.repeat(fill) + '┐';
|
|
835
|
+
const bottomBorder = '└' + '─'.repeat(cols - 2) + '┘';
|
|
836
|
+
const themeChoices = listThemes().map(t => {
|
|
837
|
+
const addLine = t.diffAddBg(` + 1 | const theme = "${t.id}";`.padEnd(cols - 2));
|
|
838
|
+
const remLine = t.diffRemoveBg(` - 2 | const theme = "none";`.padEnd(cols - 2));
|
|
839
|
+
const preview = `${t.colorFn(topBorder)}\n${addLine}\n${remLine}\n${t.colorFn(bottomBorder)}\n\n${pc.dim('💡 You can change this anytime with /theme')}`;
|
|
840
|
+
return {
|
|
841
|
+
name: `${t.emoji} ${t.boldFn(t.name.padEnd(18))} ${pc.dim(t.desc)}`,
|
|
842
|
+
value: t.id,
|
|
843
|
+
description: preview
|
|
844
|
+
};
|
|
845
|
+
});
|
|
846
|
+
try {
|
|
847
|
+
clearTerminalScreen();
|
|
848
|
+
const selected = await search({
|
|
849
|
+
message: `${pc.bold('🎨 Pick a theme to personalize your workspace:')}`,
|
|
850
|
+
source: async () => themeChoices,
|
|
851
|
+
pageSize: 6
|
|
852
|
+
});
|
|
853
|
+
if (selected) {
|
|
854
|
+
config.theme = selected;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
catch { }
|
|
858
|
+
config.onboarded = true;
|
|
859
|
+
await saveConfig(config);
|
|
860
|
+
}
|
|
861
|
+
if (config.theme) {
|
|
862
|
+
setActiveTheme(config.theme);
|
|
863
|
+
}
|
|
864
|
+
enableDarkTheme(config.pureBlackTheme !== false);
|
|
865
|
+
let history = new History();
|
|
866
|
+
const sysPrompt = await buildSystemPrompt(planMode);
|
|
867
|
+
history.addMessage({ role: 'system', content: sysPrompt });
|
|
868
|
+
const sessionManager = new SessionManager(config.model, planMode);
|
|
869
|
+
drawLogo();
|
|
870
|
+
await runStartupUpdateCheck(config);
|
|
871
|
+
await MCPManager.getInstance().init();
|
|
872
|
+
let totalSessionCost = 0;
|
|
873
|
+
let currentDraft = '';
|
|
874
|
+
let autoTriggerPrompt = '';
|
|
875
|
+
while (true) {
|
|
876
|
+
const currentTokens = history.getConversationTokens();
|
|
877
|
+
const maxTokens = config.maxContextTokens || getModelContextLimit(config.model);
|
|
878
|
+
config.maxContextTokens = maxTokens;
|
|
879
|
+
const usagePercent = Math.min(100, Math.round((currentTokens / maxTokens) * 100));
|
|
880
|
+
const formatTokens = (n) => {
|
|
881
|
+
if (n >= 1_000_000)
|
|
882
|
+
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
883
|
+
if (n >= 1000)
|
|
884
|
+
return `${(n / 1000).toFixed(1)}k`;
|
|
885
|
+
return `${n}`;
|
|
886
|
+
};
|
|
887
|
+
const costStr = totalSessionCost > 0 ? `Cost: $${totalSessionCost.toFixed(4)}` : 'Cost: $0.0000';
|
|
888
|
+
const tokenStats = `Context: ${formatTokens(currentTokens)} / ${formatTokens(maxTokens)} (${usagePercent}%) • ${costStr}`;
|
|
889
|
+
const cols = process.stdout.columns || 80;
|
|
890
|
+
const modeName = planMode ? 'PLAN' : 'AGENT';
|
|
891
|
+
const theme = getCurrentTheme();
|
|
892
|
+
// Shorten model name if too long on narrow mobile screens
|
|
893
|
+
let displayModel = config.model;
|
|
894
|
+
if (cols < 75 && displayModel.length > 20) {
|
|
895
|
+
const parts = displayModel.split('/');
|
|
896
|
+
displayModel = parts.length > 1 ? parts.slice(1).join('/') : displayModel;
|
|
897
|
+
if (displayModel.length > 20) {
|
|
898
|
+
displayModel = displayModel.slice(0, 17) + '...';
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
const badge = theme.badgeFn(`devx | ${modeName} | ${displayModel}`);
|
|
902
|
+
if (cols < 75) {
|
|
903
|
+
// 2-line layout for mobile screens: perfectly aligned with clack box borders
|
|
904
|
+
p.intro(`${badge}\n${pc.dim('│')} ${pc.dim(tokenStats)}`);
|
|
905
|
+
}
|
|
906
|
+
else {
|
|
907
|
+
// 1-line layout for wider desktop screens
|
|
908
|
+
p.intro(`${badge} ${pc.dim(tokenStats)}`);
|
|
909
|
+
}
|
|
910
|
+
let answer = '';
|
|
911
|
+
if (autoTriggerPrompt) {
|
|
912
|
+
answer = autoTriggerPrompt;
|
|
913
|
+
autoTriggerPrompt = '';
|
|
914
|
+
console.log(theme.colorFn('◆') + ' ' + pc.bold(pc.white(answer)));
|
|
915
|
+
}
|
|
916
|
+
else {
|
|
917
|
+
const inputStr = await askPrompt({
|
|
918
|
+
message: 'Ask anything...',
|
|
919
|
+
placeholder: 'Fix a TODO, type /help, or press Tab to switch mode',
|
|
920
|
+
initialValue: currentDraft,
|
|
921
|
+
planMode
|
|
922
|
+
});
|
|
923
|
+
if (inputStr.startsWith('__TOGGLE_MODE__:')) {
|
|
924
|
+
currentDraft = inputStr.slice('__TOGGLE_MODE__:'.length);
|
|
925
|
+
planMode = !planMode;
|
|
926
|
+
const newSys = await buildSystemPrompt(planMode);
|
|
927
|
+
history.updateSystemPrompt(newSys);
|
|
928
|
+
p.log.info(planMode
|
|
929
|
+
? pc.bold(pc.cyan('🔄 Mode: PLAN (Architect & Planner) — Press Tab to switch to AGENT'))
|
|
930
|
+
: pc.bold(pc.green('🔄 Mode: AGENT (Coder & Executor) — Press Tab to switch to PLAN')));
|
|
931
|
+
continue;
|
|
932
|
+
}
|
|
933
|
+
currentDraft = '';
|
|
934
|
+
if (inputStr === '__CANCEL__') {
|
|
935
|
+
const confirmExit = await p.confirm({
|
|
936
|
+
message: 'Are you sure you want to exit devx?',
|
|
937
|
+
initialValue: false
|
|
938
|
+
});
|
|
939
|
+
if (p.isCancel(confirmExit) || confirmExit) {
|
|
940
|
+
resetTerminalTheme();
|
|
941
|
+
p.outro('Goodbye!');
|
|
942
|
+
break;
|
|
943
|
+
}
|
|
944
|
+
else {
|
|
945
|
+
continue;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
answer = inputStr.trim();
|
|
949
|
+
}
|
|
950
|
+
if (!answer)
|
|
951
|
+
continue;
|
|
952
|
+
if (answer.startsWith('/')) {
|
|
953
|
+
const parts = answer.split(' ');
|
|
954
|
+
let cmd = parts[0];
|
|
955
|
+
// 1. Check for custom slash commands (.devx/commands/*.md)
|
|
956
|
+
const customCmd = await CustomCommandManager.findCommand(cmd);
|
|
957
|
+
if (customCmd) {
|
|
958
|
+
const cmdArgs = answer.slice(cmd.length).trim();
|
|
959
|
+
const expandedPrompt = CustomCommandManager.expandTemplate(customCmd.promptTemplate, cmdArgs);
|
|
960
|
+
answer = expandedPrompt;
|
|
961
|
+
p.log.info(pc.cyan(`⚡ Executing custom command: ${pc.bold(customCmd.cmd)} (${customCmd.desc})`));
|
|
962
|
+
}
|
|
963
|
+
else {
|
|
964
|
+
const VALID_COMMANDS = [
|
|
965
|
+
'/new', '/reset', '/resume', '/session', '/sessions', '/history',
|
|
966
|
+
'/theme', '/themes', '/usage', '/export', '/mcp',
|
|
967
|
+
'/settings', '/update', '/model', '/provider', '/providers',
|
|
968
|
+
'/plan', '/agent', '/image', '/serve', '/memory', '/undo',
|
|
969
|
+
'/diff', '/commit', '/status', '/compact', '/init', '/doctor',
|
|
970
|
+
'/config', '/clear', '/exit', '/quit', '/help'
|
|
971
|
+
];
|
|
972
|
+
if (!VALID_COMMANDS.includes(cmd)) {
|
|
973
|
+
const customList = await CustomCommandManager.listCommands();
|
|
974
|
+
const customSlashItems = customList.map(c => ({
|
|
975
|
+
name: `${c.cmd.padEnd(14)} - ${c.desc}`,
|
|
976
|
+
value: c.cmd
|
|
977
|
+
}));
|
|
978
|
+
const SLASH_COMMANDS = [
|
|
979
|
+
{ name: '/new - Start a new clean chat session', value: '/new' },
|
|
980
|
+
{ name: '/resume - Resume a previous chat session', value: '/resume' },
|
|
981
|
+
{ name: '/session del - Select and delete saved sessions', value: '/session del' },
|
|
982
|
+
{ name: '/usage - Show network bandwidth, data saver & token cost', value: '/usage' },
|
|
983
|
+
{ name: '/export - Export session conversation to Markdown', value: '/export' },
|
|
984
|
+
{ name: '/mcp - Manage Model Context Protocol (MCP) servers & tools', value: '/mcp' },
|
|
985
|
+
{ name: '/theme - Switch UI color theme', value: '/theme' },
|
|
986
|
+
{ name: '/doctor - Run system & environment health diagnostics', value: '/doctor' },
|
|
987
|
+
{ name: '/settings - Configure permissions & auto-approval', value: '/settings' },
|
|
988
|
+
{ name: '/update - Check and install updates from GitHub', value: '/update' },
|
|
989
|
+
{ name: '/model - Switch model for current provider', value: '/model' },
|
|
990
|
+
{ name: '/provider - Change AI provider (Google, OpenRouter...)', value: '/provider' },
|
|
991
|
+
{ name: '/plan - Switch to PLAN mode (architect)', value: '/plan' },
|
|
992
|
+
{ name: '/agent - Switch to AGENT mode (coder)', value: '/agent' },
|
|
993
|
+
{ name: '/serve - Start local web server for web preview', value: '/serve' },
|
|
994
|
+
{ name: '/memory - View or edit project memory bank', value: '/memory' },
|
|
995
|
+
{ name: '/undo - Revert last file changes made by AI', value: '/undo' },
|
|
996
|
+
{ name: '/diff - Show git diff of modified files', value: '/diff' },
|
|
997
|
+
{ name: '/commit - AI-generated git commit message', value: '/commit' },
|
|
998
|
+
{ name: '/status - Show git repository status', value: '/status' },
|
|
999
|
+
{ name: '/compact - Compact conversation context', value: '/compact' },
|
|
1000
|
+
{ name: '/config - View current configuration', value: '/config' },
|
|
1001
|
+
{ name: '/clear - Clear message history', value: '/clear' },
|
|
1002
|
+
{ name: '/help - Show commands overview', value: '/help' },
|
|
1003
|
+
{ name: '/exit - Exit devx', value: '/exit' },
|
|
1004
|
+
...customSlashItems
|
|
1005
|
+
];
|
|
1006
|
+
try {
|
|
1007
|
+
const picked = await search({
|
|
1008
|
+
message: 'Commands (type to search or select):',
|
|
1009
|
+
source: async (term) => {
|
|
1010
|
+
const q = (term || '').trim().toLowerCase();
|
|
1011
|
+
const list = [
|
|
1012
|
+
{ name: pc.yellow('⬅️ Cancel'), value: '__cancel__' },
|
|
1013
|
+
...SLASH_COMMANDS
|
|
1014
|
+
];
|
|
1015
|
+
if (!q)
|
|
1016
|
+
return list;
|
|
1017
|
+
return list.filter(item => item.name.toLowerCase().includes(q) || item.value.toLowerCase().includes(q));
|
|
1018
|
+
},
|
|
1019
|
+
pageSize: 12
|
|
1020
|
+
});
|
|
1021
|
+
if (!picked || picked === '__cancel__') {
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
if (picked === '/session del') {
|
|
1025
|
+
await handleSessionDelete();
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
cmd = picked;
|
|
1029
|
+
}
|
|
1030
|
+
catch {
|
|
1031
|
+
continue;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
if (cmd === '/settings') {
|
|
1036
|
+
config = await handleSettings(config);
|
|
1037
|
+
drawLogo();
|
|
1038
|
+
continue;
|
|
1039
|
+
}
|
|
1040
|
+
if (cmd === '/update') {
|
|
1041
|
+
const s = p.spinner();
|
|
1042
|
+
s.start('Checking for updates from https://github.com/apvcode/Termux-Dev...');
|
|
1043
|
+
const res = await checkForUpdates(10000);
|
|
1044
|
+
s.stop();
|
|
1045
|
+
if (res.updateAvailable) {
|
|
1046
|
+
p.log.info(pc.bold(pc.yellow(`🚀 Update available: v${res.currentVersion} ➔ v${res.latestVersion}`)));
|
|
1047
|
+
const doUpdate = await p.confirm({
|
|
1048
|
+
message: `Do you want to update devx to v${res.latestVersion} now?`,
|
|
1049
|
+
initialValue: true
|
|
1050
|
+
});
|
|
1051
|
+
if (!p.isCancel(doUpdate) && doUpdate) {
|
|
1052
|
+
await performSelfUpdate(res.latestVersion);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
else {
|
|
1056
|
+
p.log.success(pc.green(`devx is up to date! (v${res.currentVersion})`));
|
|
1057
|
+
}
|
|
1058
|
+
continue;
|
|
1059
|
+
}
|
|
1060
|
+
if (cmd === '/help') {
|
|
1061
|
+
p.note('/settings - Configure permissions (Auto-Approve / Ask every time)\n/resume - Resume previous chat session\n/session del - Select and delete saved sessions\n/new - Start a new chat session (or /new <prompt>)\n/model - Switch model for current provider\n/provider - Switch AI provider (with saved keys)\n/config - View configuration\n/plan - Switch to plan mode\n/agent - Switch to agent mode\n/clear - Clear history\n/exit - Exit', 'Commands');
|
|
1062
|
+
continue;
|
|
1063
|
+
}
|
|
1064
|
+
if (cmd === '/exit') {
|
|
1065
|
+
p.outro('Goodbye!');
|
|
1066
|
+
break;
|
|
1067
|
+
}
|
|
1068
|
+
if (cmd === '/clear') {
|
|
1069
|
+
history.clear();
|
|
1070
|
+
history.addMessage({ role: 'system', content: await buildSystemPrompt(planMode) });
|
|
1071
|
+
p.log.success('History cleared.');
|
|
1072
|
+
continue;
|
|
1073
|
+
}
|
|
1074
|
+
if (cmd === '/new' || cmd === '/reset') {
|
|
1075
|
+
sessionManager.startNewSession(config.model, planMode);
|
|
1076
|
+
history = new History();
|
|
1077
|
+
history.addMessage({ role: 'system', content: await buildSystemPrompt(planMode) });
|
|
1078
|
+
totalSessionCost = 0;
|
|
1079
|
+
drawLogo();
|
|
1080
|
+
const initialPrompt = parts.slice(1).join(' ').trim();
|
|
1081
|
+
if (initialPrompt) {
|
|
1082
|
+
p.log.success('Started new session with prompt.');
|
|
1083
|
+
answer = initialPrompt;
|
|
1084
|
+
}
|
|
1085
|
+
else {
|
|
1086
|
+
p.log.success('Started a new chat session.');
|
|
1087
|
+
continue;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
else if (cmd === '/session' || cmd === '/sessions' || cmd === '/resume' || cmd === '/history') {
|
|
1091
|
+
const sub = (parts[1] || '').toLowerCase();
|
|
1092
|
+
if (sub === 'del' || sub === 'delete' || sub === 'rm') {
|
|
1093
|
+
await handleSessionDelete();
|
|
1094
|
+
continue;
|
|
1095
|
+
}
|
|
1096
|
+
const sessions = await SessionManager.listSessions();
|
|
1097
|
+
if (sessions.length === 0) {
|
|
1098
|
+
p.log.warn('No saved sessions found.');
|
|
1099
|
+
continue;
|
|
1100
|
+
}
|
|
1101
|
+
let chosenSessionId;
|
|
1102
|
+
try {
|
|
1103
|
+
chosenSessionId = await search({
|
|
1104
|
+
message: 'Select a session to resume (type to filter, Esc to cancel):',
|
|
1105
|
+
source: async (term) => {
|
|
1106
|
+
const q = (term || '').trim().toLowerCase();
|
|
1107
|
+
const list = [
|
|
1108
|
+
{ name: pc.yellow('⬅️ Back / Cancel'), value: '__cancel__', description: 'Return to chat' },
|
|
1109
|
+
{ name: pc.red('🗑️ Delete sessions... (/session del)'), value: '__delete_mode__', description: 'Selectively delete saved sessions' }
|
|
1110
|
+
];
|
|
1111
|
+
for (const s of sessions) {
|
|
1112
|
+
const timeStr = formatSessionTime(s.updatedAt);
|
|
1113
|
+
const msgCount = s.messages.filter(m => m.role !== 'system').length;
|
|
1114
|
+
const shortId = s.id.split('_').pop();
|
|
1115
|
+
list.push({
|
|
1116
|
+
name: `${pc.dim(`[#${shortId}]`)} ${s.title}`,
|
|
1117
|
+
value: s.id,
|
|
1118
|
+
description: `${timeStr} • ${msgCount} msgs • ${s.model || 'default'}`
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
if (!q)
|
|
1122
|
+
return list;
|
|
1123
|
+
return list.filter(item => item.name.toLowerCase().includes(q) || item.value.toLowerCase().includes(q));
|
|
1124
|
+
},
|
|
1125
|
+
pageSize: 10
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
catch {
|
|
1129
|
+
continue;
|
|
1130
|
+
}
|
|
1131
|
+
if (chosenSessionId === '__cancel__') {
|
|
1132
|
+
continue;
|
|
1133
|
+
}
|
|
1134
|
+
if (chosenSessionId === '__delete_mode__') {
|
|
1135
|
+
await handleSessionDelete();
|
|
1136
|
+
continue;
|
|
1137
|
+
}
|
|
1138
|
+
const loaded = await SessionManager.loadSession(chosenSessionId);
|
|
1139
|
+
if (loaded) {
|
|
1140
|
+
sessionManager.setLoadedSession(loaded);
|
|
1141
|
+
history = new History();
|
|
1142
|
+
for (const m of loaded.messages) {
|
|
1143
|
+
history.addMessage(m);
|
|
1144
|
+
}
|
|
1145
|
+
if (loaded.model) {
|
|
1146
|
+
config.model = loaded.model;
|
|
1147
|
+
config.maxContextTokens = getModelContextLimit(loaded.model);
|
|
1148
|
+
}
|
|
1149
|
+
if (loaded.planMode !== undefined) {
|
|
1150
|
+
planMode = loaded.planMode;
|
|
1151
|
+
}
|
|
1152
|
+
totalSessionCost = loaded.totalCost || 0;
|
|
1153
|
+
drawLogo();
|
|
1154
|
+
const nonSysMessages = loaded.messages.filter(m => m.role !== 'system');
|
|
1155
|
+
const totalMsgs = nonSysMessages.length;
|
|
1156
|
+
const displayLimit = 20;
|
|
1157
|
+
const recentMessages = nonSysMessages.slice(-displayLimit);
|
|
1158
|
+
const shownCount = recentMessages.length;
|
|
1159
|
+
// Header banner showing messages loaded
|
|
1160
|
+
const countInfo = totalMsgs > displayLimit
|
|
1161
|
+
? `Loaded recent ${shownCount} messages of ${totalMsgs}`
|
|
1162
|
+
: `Loaded all ${totalMsgs} messages`;
|
|
1163
|
+
console.log('\n' + pc.bold(pc.cyan(`─── 📜 ${countInfo} (Session: "${loaded.title}") ───`)) + '\n');
|
|
1164
|
+
// Render each message in chronological order
|
|
1165
|
+
for (const msg of recentMessages) {
|
|
1166
|
+
if (msg.role === 'user') {
|
|
1167
|
+
console.log(pc.cyan('◆') + ' ' + pc.bold(pc.white(msg.content)));
|
|
1168
|
+
if (msg.images && msg.images.length > 0) {
|
|
1169
|
+
console.log(pc.magenta(` [🖼️ ${msg.images.map(i => i.path).join(', ')}]`));
|
|
1170
|
+
}
|
|
1171
|
+
console.log();
|
|
1172
|
+
}
|
|
1173
|
+
else if (msg.role === 'assistant') {
|
|
1174
|
+
if (msg.content) {
|
|
1175
|
+
console.log(renderMarkdown(msg.content));
|
|
1176
|
+
}
|
|
1177
|
+
if (msg.tool_calls && msg.tool_calls.length > 0) {
|
|
1178
|
+
for (const tc of msg.tool_calls) {
|
|
1179
|
+
console.log(pc.dim(` ⚡ ${tc.name}`));
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
console.log();
|
|
1183
|
+
}
|
|
1184
|
+
else if (msg.role === 'tool') {
|
|
1185
|
+
if (msg.content) {
|
|
1186
|
+
const firstLine = msg.content.trim().split('\n')[0];
|
|
1187
|
+
if (firstLine.includes('+') || firstLine.includes('lines') || firstLine.includes('Successfully')) {
|
|
1188
|
+
console.log(pc.green(` └─ ${firstLine}`));
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
console.log(pc.bold(pc.cyan(`────────────────────────────────────────────────────────────────────────\n`)));
|
|
1194
|
+
p.log.success(`Resumed session: "${loaded.title}" (${totalMsgs} total messages in history)`);
|
|
1195
|
+
}
|
|
1196
|
+
else {
|
|
1197
|
+
p.log.error('Failed to load session.');
|
|
1198
|
+
}
|
|
1199
|
+
continue;
|
|
1200
|
+
}
|
|
1201
|
+
if (cmd === '/plan') {
|
|
1202
|
+
planMode = true;
|
|
1203
|
+
const newSys = await buildSystemPrompt(true);
|
|
1204
|
+
history.updateSystemPrompt(newSys);
|
|
1205
|
+
drawLogo();
|
|
1206
|
+
p.log.success(pc.cyan('Switched to PLAN mode (Architect & Planner). Modifying tools are disabled.'));
|
|
1207
|
+
continue;
|
|
1208
|
+
}
|
|
1209
|
+
if (cmd === '/agent') {
|
|
1210
|
+
planMode = false;
|
|
1211
|
+
const newSys = await buildSystemPrompt(false);
|
|
1212
|
+
history.updateSystemPrompt(newSys);
|
|
1213
|
+
drawLogo();
|
|
1214
|
+
p.log.success(pc.green('Switched to AGENT mode (Coder & Executor). Full tools enabled.'));
|
|
1215
|
+
continue;
|
|
1216
|
+
}
|
|
1217
|
+
if (cmd === '/undo') {
|
|
1218
|
+
const { revertedFiles, count } = await globalSnapshotManager.undoLastTurn();
|
|
1219
|
+
if (count === 0) {
|
|
1220
|
+
p.log.warn('No changes to undo.');
|
|
1221
|
+
}
|
|
1222
|
+
else {
|
|
1223
|
+
history.popLastTurn();
|
|
1224
|
+
p.log.success(pc.bold(pc.green(`⏪ Successfully reverted changes in ${count} file(s):`)));
|
|
1225
|
+
for (const f of revertedFiles) {
|
|
1226
|
+
console.log(pc.cyan(` • ${f}`));
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
continue;
|
|
1230
|
+
}
|
|
1231
|
+
if (cmd === '/diff') {
|
|
1232
|
+
try {
|
|
1233
|
+
const diffOutput = execSync('git diff', { encoding: 'utf8' });
|
|
1234
|
+
if (!diffOutput.trim()) {
|
|
1235
|
+
p.log.info('No uncommitted changes in git repository.');
|
|
1236
|
+
}
|
|
1237
|
+
else {
|
|
1238
|
+
console.log('\n' + pc.bold(pc.cyan('─── Git Diff ─────────────────────────')));
|
|
1239
|
+
for (const line of diffOutput.split('\n')) {
|
|
1240
|
+
if (line.startsWith('+') && !line.startsWith('+++')) {
|
|
1241
|
+
console.log(pc.green(line));
|
|
1242
|
+
}
|
|
1243
|
+
else if (line.startsWith('-') && !line.startsWith('---')) {
|
|
1244
|
+
console.log(pc.red(line));
|
|
1245
|
+
}
|
|
1246
|
+
else if (line.startsWith('@@')) {
|
|
1247
|
+
console.log(pc.magenta(line));
|
|
1248
|
+
}
|
|
1249
|
+
else {
|
|
1250
|
+
console.log(line);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
console.log(pc.bold(pc.cyan('──────────────────────────────────────\n')));
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
catch {
|
|
1257
|
+
p.log.warn('Git is not available or current directory is not a git repo.');
|
|
1258
|
+
}
|
|
1259
|
+
continue;
|
|
1260
|
+
}
|
|
1261
|
+
if (cmd === '/status') {
|
|
1262
|
+
try {
|
|
1263
|
+
const statusOutput = execSync('git status --short', { encoding: 'utf8' });
|
|
1264
|
+
if (!statusOutput.trim()) {
|
|
1265
|
+
p.log.success('Working directory clean, no modified files.');
|
|
1266
|
+
}
|
|
1267
|
+
else {
|
|
1268
|
+
console.log('\n' + pc.bold(pc.cyan('─── Git Status ───────────────────────')));
|
|
1269
|
+
for (const line of statusOutput.split('\n')) {
|
|
1270
|
+
if (!line.trim())
|
|
1271
|
+
continue;
|
|
1272
|
+
if (line.startsWith(' M') || line.startsWith('M ')) {
|
|
1273
|
+
console.log(pc.yellow(` ${line}`));
|
|
1274
|
+
}
|
|
1275
|
+
else if (line.startsWith('??')) {
|
|
1276
|
+
console.log(pc.green(` ${line}`));
|
|
1277
|
+
}
|
|
1278
|
+
else if (line.startsWith(' D')) {
|
|
1279
|
+
console.log(pc.red(` ${line}`));
|
|
1280
|
+
}
|
|
1281
|
+
else {
|
|
1282
|
+
console.log(` ${line}`);
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
console.log(pc.bold(pc.cyan('──────────────────────────────────────\n')));
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
catch {
|
|
1289
|
+
p.log.warn('Git is not available or current directory is not a git repo.');
|
|
1290
|
+
}
|
|
1291
|
+
continue;
|
|
1292
|
+
}
|
|
1293
|
+
if (cmd === '/commit') {
|
|
1294
|
+
try {
|
|
1295
|
+
const status = execSync('git status --short', { encoding: 'utf8' });
|
|
1296
|
+
if (!status.trim()) {
|
|
1297
|
+
p.log.info('No changes to commit.');
|
|
1298
|
+
continue;
|
|
1299
|
+
}
|
|
1300
|
+
const diff = execSync('git diff', { encoding: 'utf8' });
|
|
1301
|
+
const userMsg = parts.slice(1).join(' ').trim();
|
|
1302
|
+
let commitMsg = userMsg;
|
|
1303
|
+
if (!commitMsg) {
|
|
1304
|
+
const s = p.spinner();
|
|
1305
|
+
s.start('Generating conventional commit message with AI...');
|
|
1306
|
+
const provider = createProvider(config);
|
|
1307
|
+
const promptReq = {
|
|
1308
|
+
messages: [
|
|
1309
|
+
{
|
|
1310
|
+
role: 'system',
|
|
1311
|
+
content: 'You generate single-line conventional git commit messages (e.g. feat(auth): add token validation). Output ONLY the commit message line and nothing else.'
|
|
1312
|
+
},
|
|
1313
|
+
{
|
|
1314
|
+
role: 'user',
|
|
1315
|
+
content: `Git status:\n${status}\n\nGit diff snippet:\n${diff.slice(0, 3000)}`
|
|
1316
|
+
}
|
|
1317
|
+
]
|
|
1318
|
+
};
|
|
1319
|
+
const genRes = await provider.chat(promptReq);
|
|
1320
|
+
s.stop();
|
|
1321
|
+
commitMsg = (genRes.content || 'chore: update project files').trim().replace(/^["'`]|["'`]$/g, '').split('\n')[0];
|
|
1322
|
+
}
|
|
1323
|
+
const confirmed = await p.confirm({
|
|
1324
|
+
message: `Commit changes with message:\n"${pc.bold(pc.green(commitMsg))}"?`,
|
|
1325
|
+
initialValue: true
|
|
1326
|
+
});
|
|
1327
|
+
if (!p.isCancel(confirmed) && confirmed) {
|
|
1328
|
+
execFileSync('git', ['add', '-A'], { stdio: 'ignore' });
|
|
1329
|
+
execFileSync('git', ['commit', '-m', commitMsg], { stdio: 'ignore' });
|
|
1330
|
+
p.log.success(pc.bold(pc.green(`✅ Committed: ${commitMsg}`)));
|
|
1331
|
+
}
|
|
1332
|
+
else {
|
|
1333
|
+
p.log.info('Commit cancelled.');
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
catch (err) {
|
|
1337
|
+
p.log.error(`Git commit failed: ${err.message}`);
|
|
1338
|
+
}
|
|
1339
|
+
continue;
|
|
1340
|
+
}
|
|
1341
|
+
if (cmd === '/compact') {
|
|
1342
|
+
const msgs = history.getMessages();
|
|
1343
|
+
if (msgs.length <= 2) {
|
|
1344
|
+
p.log.info('Conversation is already short, no compaction needed.');
|
|
1345
|
+
continue;
|
|
1346
|
+
}
|
|
1347
|
+
const s = p.spinner();
|
|
1348
|
+
s.start('Compacting conversation context with AI...');
|
|
1349
|
+
try {
|
|
1350
|
+
const provider = createProvider(config);
|
|
1351
|
+
const compactPrompt = {
|
|
1352
|
+
messages: [
|
|
1353
|
+
...msgs,
|
|
1354
|
+
{
|
|
1355
|
+
role: 'user',
|
|
1356
|
+
content: 'Please create a clear, comprehensive, and structured summary of our conversation so far, including all established requirements, decisions, modified files, and remaining tasks. Be concise and precise.'
|
|
1357
|
+
}
|
|
1358
|
+
]
|
|
1359
|
+
};
|
|
1360
|
+
const res = await provider.chat(compactPrompt);
|
|
1361
|
+
s.stop();
|
|
1362
|
+
const sysPrompt = await buildSystemPrompt(planMode);
|
|
1363
|
+
history.clear();
|
|
1364
|
+
history.addMessage({ role: 'system', content: sysPrompt });
|
|
1365
|
+
history.addMessage({
|
|
1366
|
+
role: 'assistant',
|
|
1367
|
+
content: `[CONVERSATION COMPACTED]\n${res.content || ''}`
|
|
1368
|
+
});
|
|
1369
|
+
p.log.success(pc.bold(pc.green('🗜️ Context successfully compacted! Freed up tokens while retaining task summary.')));
|
|
1370
|
+
}
|
|
1371
|
+
catch (err) {
|
|
1372
|
+
s.stop();
|
|
1373
|
+
p.log.error(`Compaction failed: ${err.message}`);
|
|
1374
|
+
}
|
|
1375
|
+
continue;
|
|
1376
|
+
}
|
|
1377
|
+
if (cmd === '/clear') {
|
|
1378
|
+
console.clear();
|
|
1379
|
+
drawLogo();
|
|
1380
|
+
continue;
|
|
1381
|
+
}
|
|
1382
|
+
if (cmd === '/session' && parts[1]?.toLowerCase() !== 'del') {
|
|
1383
|
+
const cur = sessionManager.getSession();
|
|
1384
|
+
const nonSys = history.getMessages().filter(m => m.role !== 'system');
|
|
1385
|
+
const shortId = cur.id.split('_').pop();
|
|
1386
|
+
console.log('\n' + pc.bold(pc.cyan('─── 🆔 Active Session Information ───')));
|
|
1387
|
+
console.log(` ${pc.bold('ID:')} ${pc.cyan(cur.id)} ${pc.dim(`(#${shortId})`)}`);
|
|
1388
|
+
console.log(` ${pc.bold('Title:')} "${cur.title}"`);
|
|
1389
|
+
console.log(` ${pc.bold('Model:')} ${config.model}`);
|
|
1390
|
+
console.log(` ${pc.bold('Mode:')} ${planMode ? pc.cyan('PLAN') : pc.green('AGENT')}`);
|
|
1391
|
+
console.log(` ${pc.bold('Messages:')} ${nonSys.length} messages`);
|
|
1392
|
+
console.log(` ${pc.bold('Cost:')} $${totalSessionCost.toFixed(4)}`);
|
|
1393
|
+
console.log(` ${pc.bold('File:')} ~/.devx/sessions/${cur.id}.json`);
|
|
1394
|
+
console.log(pc.bold(pc.cyan('──────────────────────────────────────\n')));
|
|
1395
|
+
continue;
|
|
1396
|
+
}
|
|
1397
|
+
if (cmd === '/init') {
|
|
1398
|
+
const agentsPath = path.join(process.cwd(), 'AGENTS.md');
|
|
1399
|
+
if (fsSync.existsSync(agentsPath)) {
|
|
1400
|
+
p.log.warn('AGENTS.md already exists in current workspace.');
|
|
1401
|
+
}
|
|
1402
|
+
else {
|
|
1403
|
+
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`;
|
|
1404
|
+
await fs.writeFile(agentsPath, template, 'utf8');
|
|
1405
|
+
p.log.success(`Created ${pc.bold('AGENTS.md')} in project root! Customize it to give devx persistent instructions.`);
|
|
1406
|
+
}
|
|
1407
|
+
continue;
|
|
1408
|
+
}
|
|
1409
|
+
if (cmd === '/doctor') {
|
|
1410
|
+
await runDoctor(config);
|
|
1411
|
+
continue;
|
|
1412
|
+
}
|
|
1413
|
+
if (cmd === '/serve') {
|
|
1414
|
+
const sub = parts[1]?.toLowerCase();
|
|
1415
|
+
if (sub === 'stop') {
|
|
1416
|
+
if (stopServer()) {
|
|
1417
|
+
p.log.success('Web server stopped.');
|
|
1418
|
+
}
|
|
1419
|
+
else {
|
|
1420
|
+
p.log.info('No web server is currently running.');
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
else {
|
|
1424
|
+
const customPort = parseInt(parts[1], 10) || 3000;
|
|
1425
|
+
try {
|
|
1426
|
+
const { port, localUrl, networkUrl } = await startServer(customPort);
|
|
1427
|
+
await displayServerBanner(localUrl, networkUrl);
|
|
1428
|
+
}
|
|
1429
|
+
catch (err) {
|
|
1430
|
+
p.log.error(`Failed to start web server: ${err.message}`);
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
continue;
|
|
1434
|
+
}
|
|
1435
|
+
if (cmd === '/theme' || cmd === '/themes') {
|
|
1436
|
+
const themeArg = answer.replace(/^\/(?:theme|themes)\s*/i, '').trim();
|
|
1437
|
+
if (themeArg) {
|
|
1438
|
+
const matched = findTheme(themeArg);
|
|
1439
|
+
if (matched) {
|
|
1440
|
+
config.theme = matched.id;
|
|
1441
|
+
await saveConfig(config);
|
|
1442
|
+
const th = setActiveTheme(matched.id);
|
|
1443
|
+
drawLogo();
|
|
1444
|
+
p.log.success(th.boldFn(`🎨 Theme switched to ${th.emoji} ${th.name}!`));
|
|
1445
|
+
}
|
|
1446
|
+
else {
|
|
1447
|
+
const themes = listThemes();
|
|
1448
|
+
p.log.warn(`Unknown theme: "${themeArg}". Available themes: ${themes.map(t => `${t.emoji} ${t.id}`).join(', ')}`);
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
else {
|
|
1452
|
+
await handleThemeSelect(config);
|
|
1453
|
+
}
|
|
1454
|
+
continue;
|
|
1455
|
+
}
|
|
1456
|
+
if (cmd === '/memory') {
|
|
1457
|
+
const sub = parts[1]?.toLowerCase();
|
|
1458
|
+
if (sub === 'clear') {
|
|
1459
|
+
await MemoryManager.clearMemory();
|
|
1460
|
+
p.log.success('Cleared project memory bank (.devx/memory.md).');
|
|
1461
|
+
}
|
|
1462
|
+
else if (sub === 'add') {
|
|
1463
|
+
const fact = parts.slice(2).join(' ').trim();
|
|
1464
|
+
if (!fact) {
|
|
1465
|
+
p.log.warn('Usage: /memory add <fact or rule to remember>');
|
|
1466
|
+
}
|
|
1467
|
+
else {
|
|
1468
|
+
await MemoryManager.addFact(fact);
|
|
1469
|
+
p.log.success(`Saved to memory: "${fact}"`);
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
else {
|
|
1473
|
+
const mem = await MemoryManager.loadMemory();
|
|
1474
|
+
if (!mem.trim()) {
|
|
1475
|
+
p.log.info('Project memory is empty. AI will remember facts as you work, or use /memory add <fact>.');
|
|
1476
|
+
}
|
|
1477
|
+
else {
|
|
1478
|
+
p.note(mem, '🧠 Project Memory Bank (.devx/memory.md)');
|
|
1479
|
+
console.log(pc.dim('Commands: /memory add <text> | /memory clear\n'));
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
continue;
|
|
1483
|
+
}
|
|
1484
|
+
if (cmd === '/usage') {
|
|
1485
|
+
const card = UsageTracker.getInstance().renderUsageCard();
|
|
1486
|
+
console.log('\n' + card);
|
|
1487
|
+
continue;
|
|
1488
|
+
}
|
|
1489
|
+
if (cmd === '/export') {
|
|
1490
|
+
const targetName = parts.slice(1).join(' ').trim();
|
|
1491
|
+
const res = await SessionExporter.exportToMarkdown(history.getMessages(), config.model, targetName || undefined);
|
|
1492
|
+
if (res.success) {
|
|
1493
|
+
p.log.success(pc.bold(pc.green(`📄 Session exported to: ${res.filePath}`)));
|
|
1494
|
+
}
|
|
1495
|
+
else {
|
|
1496
|
+
p.log.error(`Failed to export session: ${res.error}`);
|
|
1497
|
+
}
|
|
1498
|
+
continue;
|
|
1499
|
+
}
|
|
1500
|
+
if (cmd === '/mcp') {
|
|
1501
|
+
const sub = (parts[1] || '').toLowerCase();
|
|
1502
|
+
if (sub === 'reload' || sub === 'restart' || sub === 'r') {
|
|
1503
|
+
const s = p.spinner();
|
|
1504
|
+
s.start('Reloading MCP servers...');
|
|
1505
|
+
await MCPManager.getInstance().reload();
|
|
1506
|
+
s.stop();
|
|
1507
|
+
console.log('\n' + MCPManager.getInstance().renderStatusCard());
|
|
1508
|
+
}
|
|
1509
|
+
else {
|
|
1510
|
+
console.log('\n' + MCPManager.getInstance().renderStatusCard());
|
|
1511
|
+
}
|
|
1512
|
+
continue;
|
|
1513
|
+
}
|
|
1514
|
+
if (cmd === '/config') {
|
|
1515
|
+
const masked = { ...config };
|
|
1516
|
+
if (masked.apiKey)
|
|
1517
|
+
masked.apiKey = maskApiKey(masked.apiKey);
|
|
1518
|
+
if (masked.apiKeys) {
|
|
1519
|
+
masked.apiKeys = { ...masked.apiKeys };
|
|
1520
|
+
for (const k of Object.keys(masked.apiKeys)) {
|
|
1521
|
+
masked.apiKeys[k] = maskApiKey(masked.apiKeys[k]);
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
p.note(JSON.stringify(masked, null, 2), 'Configuration (Secrets Masked)');
|
|
1525
|
+
continue;
|
|
1526
|
+
}
|
|
1527
|
+
if (cmd === '/model') {
|
|
1528
|
+
const res = await selectModel(config.baseUrl, config.apiKey, config.model, config.provider || '', true);
|
|
1529
|
+
drawLogo();
|
|
1530
|
+
if (res) {
|
|
1531
|
+
config.model = res.model;
|
|
1532
|
+
config.maxContextTokens = res.contextLimit;
|
|
1533
|
+
await saveConfig(config);
|
|
1534
|
+
p.log.success(`Switched model to: ${pc.bold(config.model)}`);
|
|
1535
|
+
}
|
|
1536
|
+
else {
|
|
1537
|
+
p.log.info('Model selection cancelled.');
|
|
1538
|
+
}
|
|
1539
|
+
continue;
|
|
1540
|
+
}
|
|
1541
|
+
if (cmd === '/provider' || cmd === '/providers') {
|
|
1542
|
+
const newConfig = await selectProvider(config, true);
|
|
1543
|
+
drawLogo();
|
|
1544
|
+
if (newConfig) {
|
|
1545
|
+
config = newConfig;
|
|
1546
|
+
p.log.success(`Switched provider to: ${pc.bold(config.provider || 'custom')} (${config.model})`);
|
|
1547
|
+
}
|
|
1548
|
+
else {
|
|
1549
|
+
p.log.info('Provider selection cancelled.');
|
|
1550
|
+
}
|
|
1551
|
+
continue;
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
// Auto-switch to AGENT mode ONLY when a plan has been presented and user approves it
|
|
1555
|
+
if (planMode) {
|
|
1556
|
+
const msgs = history.getMessages();
|
|
1557
|
+
const lastAssistantMsg = [...msgs].reverse().find(m => m.role === 'assistant');
|
|
1558
|
+
const assistantContent = (lastAssistantMsg?.content || '').toLowerCase();
|
|
1559
|
+
// Has the AI already presented a plan in the conversation?
|
|
1560
|
+
const planWasPresented = assistantContent.includes('план') ||
|
|
1561
|
+
assistantContent.includes('plan') ||
|
|
1562
|
+
assistantContent.includes('архитектур') ||
|
|
1563
|
+
assistantContent.includes('структур') ||
|
|
1564
|
+
assistantContent.includes('приступать') ||
|
|
1565
|
+
assistantContent.includes('выполняй') ||
|
|
1566
|
+
assistantContent.includes('готово к');
|
|
1567
|
+
if (planWasPresented && !answer.includes('?')) {
|
|
1568
|
+
const norm = answer.toLowerCase().trim().replace(/[!.,?;:()«»""'']/g, ' ');
|
|
1569
|
+
const words = norm.split(/\s+/).filter(Boolean);
|
|
1570
|
+
// Ensure user is not asking a question
|
|
1571
|
+
const isQuestion = words.some(w => ['поможешь', 'помоги', 'как', 'почему', 'зачем', 'что', 'можешь', 'сможешь'].includes(w));
|
|
1572
|
+
if (!isQuestion) {
|
|
1573
|
+
const actionRoots = [
|
|
1574
|
+
'дела', 'сдела', 'выполн', 'приступ', 'начин', 'начн', 'реализ',
|
|
1575
|
+
'создав', 'создай', 'погнал', 'старту', 'утвержд', 'одобря', 'соглас',
|
|
1576
|
+
'start', 'proceed', 'execute', 'apply'
|
|
1577
|
+
];
|
|
1578
|
+
const isApproval = words.some(w => actionRoots.some(root => w.startsWith(root))) ||
|
|
1579
|
+
words.includes('давай') ||
|
|
1580
|
+
(words.length <= 2 && (words.includes('да') || words.includes('yes') || words.includes('ок') || words.includes('ok') || words.includes('go')));
|
|
1581
|
+
if (isApproval) {
|
|
1582
|
+
planMode = false;
|
|
1583
|
+
const newSys = await buildSystemPrompt(false);
|
|
1584
|
+
history.updateSystemPrompt(newSys);
|
|
1585
|
+
p.log.success(pc.bold(pc.green('⚡ Plan approved! Auto-switched to AGENT mode. Starting execution...')));
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
// Resolve @file and @image mentions
|
|
1591
|
+
const { text: cleanAnswer, attachments, images } = await resolveAtMentions(answer);
|
|
1592
|
+
let finalContent = cleanAnswer;
|
|
1593
|
+
if (attachments.length > 0) {
|
|
1594
|
+
const attachBlocks = attachments.map(a => `[Attached file: ${a.path}]\n\`\`\`\n${a.content}\n\`\`\``).join('\n\n');
|
|
1595
|
+
finalContent = `${cleanAnswer}\n\n--- Attached Files ---\n${attachBlocks}`;
|
|
1596
|
+
p.log.info(pc.cyan(`📎 Attached ${attachments.length} file(s): ${attachments.map(a => a.path).join(', ')}`));
|
|
1597
|
+
}
|
|
1598
|
+
if (images.length > 0) {
|
|
1599
|
+
p.log.info(pc.cyan(`🖼️ Attached ${images.length} image(s): ${images.map(i => i.path).join(', ')}`));
|
|
1600
|
+
}
|
|
1601
|
+
globalSnapshotManager.beginTurn();
|
|
1602
|
+
history.addMessage({
|
|
1603
|
+
role: 'user',
|
|
1604
|
+
content: finalContent,
|
|
1605
|
+
images: images.length > 0 ? images : undefined
|
|
1606
|
+
});
|
|
1607
|
+
const provider = createProvider(config);
|
|
1608
|
+
const tools = getTools(planMode);
|
|
1609
|
+
const guard = new CLIConsoleGuard(config.autoApprove, config.bashAllowlist || []);
|
|
1610
|
+
const agentConfig = {
|
|
1611
|
+
maxContextTokens: config.maxContextTokens || 100000,
|
|
1612
|
+
maxIterations: config.maxIterations || 100,
|
|
1613
|
+
autoApprove: config.autoApprove,
|
|
1614
|
+
bashAllowlist: config.bashAllowlist
|
|
1615
|
+
};
|
|
1616
|
+
const agent = new Agent(agentConfig, provider, tools, history, guard);
|
|
1617
|
+
const taskStartTime = Date.now();
|
|
1618
|
+
const s = p.spinner();
|
|
1619
|
+
s.start('Connecting...');
|
|
1620
|
+
let spinnerActive = true;
|
|
1621
|
+
let thinkingActive = false;
|
|
1622
|
+
let thinkingStartTime = 0;
|
|
1623
|
+
let textActive = false;
|
|
1624
|
+
const streamer = new MarkdownStreamer();
|
|
1625
|
+
const thoughtStreamer = new SmoothStreamer((chunk) => {
|
|
1626
|
+
process.stdout.write(pc.dim(chunk));
|
|
1627
|
+
});
|
|
1628
|
+
const finishThinking = (extraNewline = true) => {
|
|
1629
|
+
if (!thinkingActive)
|
|
1630
|
+
return;
|
|
1631
|
+
thoughtStreamer.flush();
|
|
1632
|
+
const elapsedSec = thinkingStartTime > 0 ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : '0.0';
|
|
1633
|
+
const width = Math.min(process.stdout.columns || 40, 52);
|
|
1634
|
+
const label = `─── Thought for ${elapsedSec}s `;
|
|
1635
|
+
const fillLen = Math.max(2, width - label.length);
|
|
1636
|
+
process.stdout.write('\n\n' + pc.dim(label + '─'.repeat(fillLen)) + (extraNewline ? '\n\n' : '\n'));
|
|
1637
|
+
thinkingActive = false;
|
|
1638
|
+
thinkingStartTime = 0;
|
|
1639
|
+
};
|
|
1640
|
+
const abortController = new AbortController();
|
|
1641
|
+
let aborted = false;
|
|
1642
|
+
const stopGeneration = () => {
|
|
1643
|
+
if (aborted)
|
|
1644
|
+
return;
|
|
1645
|
+
aborted = true;
|
|
1646
|
+
abortController.abort();
|
|
1647
|
+
if (spinnerActive) {
|
|
1648
|
+
s.stop();
|
|
1649
|
+
spinnerActive = false;
|
|
1650
|
+
}
|
|
1651
|
+
thoughtStreamer.reset();
|
|
1652
|
+
finishThinking(true);
|
|
1653
|
+
streamer.finish();
|
|
1654
|
+
if (textActive) {
|
|
1655
|
+
process.stdout.write('\n');
|
|
1656
|
+
}
|
|
1657
|
+
console.log(pc.yellow('\n⏹ Generation stopped / Ответ остановлен (Ctrl+C).'));
|
|
1658
|
+
};
|
|
1659
|
+
const onSigInt = () => {
|
|
1660
|
+
stopGeneration();
|
|
1661
|
+
};
|
|
1662
|
+
const onRawData = (data) => {
|
|
1663
|
+
const buf = Buffer.isBuffer(data) ? data : Buffer.from(String(data));
|
|
1664
|
+
if (buf.includes(3) || buf.includes(0x03) || data === '\x03') {
|
|
1665
|
+
stopGeneration();
|
|
1666
|
+
}
|
|
1667
|
+
};
|
|
1668
|
+
activeAbortHandler = stopGeneration;
|
|
1669
|
+
process.on('SIGINT', onSigInt);
|
|
1670
|
+
if (process.stdin.isTTY) {
|
|
1671
|
+
try {
|
|
1672
|
+
process.stdin.resume();
|
|
1673
|
+
process.stdin.setRawMode(true);
|
|
1674
|
+
process.stdin.on('data', onRawData);
|
|
1675
|
+
}
|
|
1676
|
+
catch { }
|
|
1677
|
+
}
|
|
1678
|
+
const turnStartTime = Date.now();
|
|
1679
|
+
let toolsExecutedCount = 0;
|
|
1680
|
+
try {
|
|
1681
|
+
for await (const event of agent.run(abortController.signal)) {
|
|
1682
|
+
if (aborted)
|
|
1683
|
+
break;
|
|
1684
|
+
if (event.type === 'reasoning_delta') {
|
|
1685
|
+
if (spinnerActive) {
|
|
1686
|
+
s.stop();
|
|
1687
|
+
spinnerActive = false;
|
|
1688
|
+
}
|
|
1689
|
+
if (!thinkingActive) {
|
|
1690
|
+
thinkingStartTime = Date.now();
|
|
1691
|
+
process.stdout.write('\n' + pc.bold(pc.white('› Thought:')) + '\n');
|
|
1692
|
+
thinkingActive = true;
|
|
1693
|
+
}
|
|
1694
|
+
thoughtStreamer.push(event.delta);
|
|
1695
|
+
}
|
|
1696
|
+
else if (event.type === 'text_delta') {
|
|
1697
|
+
if (spinnerActive) {
|
|
1698
|
+
s.stop();
|
|
1699
|
+
spinnerActive = false;
|
|
1700
|
+
}
|
|
1701
|
+
finishThinking(true);
|
|
1702
|
+
if (!textActive) {
|
|
1703
|
+
textActive = true;
|
|
1704
|
+
}
|
|
1705
|
+
streamer.push(event.delta);
|
|
1706
|
+
}
|
|
1707
|
+
else if (event.type === 'text') {
|
|
1708
|
+
if (spinnerActive) {
|
|
1709
|
+
s.stop();
|
|
1710
|
+
spinnerActive = false;
|
|
1711
|
+
}
|
|
1712
|
+
finishThinking(true);
|
|
1713
|
+
if (!textActive) {
|
|
1714
|
+
console.log('\n' + renderMarkdown(event.content) + '\n');
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
else if (event.type === 'tool_generating') {
|
|
1718
|
+
const theme = getCurrentTheme();
|
|
1719
|
+
const label = formatToolGeneratingLabel(event.name, event.targetHint);
|
|
1720
|
+
if (!spinnerActive) {
|
|
1721
|
+
streamer.finish();
|
|
1722
|
+
finishThinking(false);
|
|
1723
|
+
s.start(theme.boldFn(label));
|
|
1724
|
+
spinnerActive = true;
|
|
1725
|
+
}
|
|
1726
|
+
else {
|
|
1727
|
+
s.message(theme.boldFn(label));
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
else if (event.type === 'tool_start') {
|
|
1731
|
+
if (spinnerActive) {
|
|
1732
|
+
s.stop();
|
|
1733
|
+
spinnerActive = false;
|
|
1734
|
+
}
|
|
1735
|
+
streamer.finish();
|
|
1736
|
+
finishThinking(false);
|
|
1737
|
+
if (textActive) {
|
|
1738
|
+
process.stdout.write('\n');
|
|
1739
|
+
textActive = false;
|
|
1740
|
+
}
|
|
1741
|
+
console.log('\n' + pc.bold(pc.white(event.actionDesc)));
|
|
1742
|
+
if (event.name !== 'ask_questions' && event.name !== 'todo_list') {
|
|
1743
|
+
s.start('Executing...');
|
|
1744
|
+
spinnerActive = true;
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
else if (event.type === 'tool_end') {
|
|
1748
|
+
toolsExecutedCount++;
|
|
1749
|
+
if (spinnerActive) {
|
|
1750
|
+
s.stop();
|
|
1751
|
+
spinnerActive = false;
|
|
1752
|
+
}
|
|
1753
|
+
if (event.result) {
|
|
1754
|
+
try {
|
|
1755
|
+
const parsed = JSON.parse(event.result);
|
|
1756
|
+
if (parsed.type === 'todo_list' && parsed.displayCard) {
|
|
1757
|
+
process.stdout.write(parsed.displayCard);
|
|
1758
|
+
}
|
|
1759
|
+
else if (parsed.action === 'edit' && parsed.diffLines) {
|
|
1760
|
+
const theme = getCurrentTheme();
|
|
1761
|
+
const cols = Math.min(process.stdout.columns || 80, 80);
|
|
1762
|
+
const boxWidth = Math.max(30, Math.min(cols - 4, 76));
|
|
1763
|
+
const fileName = typeof parsed.path === 'string' ? parsed.path.split(/[\/\\]/).pop() || 'file' : 'file';
|
|
1764
|
+
const fillCount = Math.max(2, boxWidth - 5 - fileName.length);
|
|
1765
|
+
console.log(theme.colorFn('┌─ ') + pc.bold(fileName) + ' ' + theme.colorFn('─'.repeat(fillCount) + '┐'));
|
|
1766
|
+
const maxShown = Math.min(parsed.diffLines.length, 30);
|
|
1767
|
+
for (let i = 0; i < maxShown; i++) {
|
|
1768
|
+
const line = parsed.diffLines[i];
|
|
1769
|
+
const match = line.match(/^(\d+)(\s+[+\- ]\s+)(.*)$/);
|
|
1770
|
+
if (match) {
|
|
1771
|
+
const lineNum = match[1].padStart(4, ' ');
|
|
1772
|
+
const symbol = match[2];
|
|
1773
|
+
const contentStr = match[3] || '';
|
|
1774
|
+
const prefix = symbol.includes('-') ? '-' : symbol.includes('+') ? '+' : ' ';
|
|
1775
|
+
const maxCodeLen = Math.max(10, boxWidth - 11);
|
|
1776
|
+
const paddedCode = contentStr.length > maxCodeLen
|
|
1777
|
+
? contentStr.substring(0, maxCodeLen - 1) + '…'
|
|
1778
|
+
: contentStr.padEnd(maxCodeLen, ' ');
|
|
1779
|
+
const innerRow = ` ${lineNum} ${prefix} ${paddedCode} `;
|
|
1780
|
+
if (prefix === '-') {
|
|
1781
|
+
console.log(theme.colorFn('│') + theme.diffRemoveBg(innerRow) + theme.colorFn('│'));
|
|
1782
|
+
}
|
|
1783
|
+
else if (prefix === '+') {
|
|
1784
|
+
console.log(theme.colorFn('│') + theme.diffAddBg(innerRow) + theme.colorFn('│'));
|
|
1785
|
+
}
|
|
1786
|
+
else {
|
|
1787
|
+
console.log(theme.colorFn('│') + pc.dim(innerRow) + theme.colorFn('│'));
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
else {
|
|
1791
|
+
const maxLen = Math.max(10, boxWidth - 4);
|
|
1792
|
+
const padded = line.length > maxLen ? line.substring(0, maxLen - 1) + '…' : line.padEnd(maxLen, ' ');
|
|
1793
|
+
console.log(theme.colorFn('│ ') + pc.white(padded) + theme.colorFn(' │'));
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
if (parsed.diffLines.length > maxShown) {
|
|
1797
|
+
const dots = `... +${parsed.diffLines.length - maxShown} more lines`;
|
|
1798
|
+
const maxLen = Math.max(10, boxWidth - 4);
|
|
1799
|
+
const paddedDots = dots.length > maxLen ? dots.substring(0, maxLen - 1) + '…' : dots.padEnd(maxLen, ' ');
|
|
1800
|
+
console.log(theme.colorFn('│ ') + pc.dim(paddedDots) + theme.colorFn(' │'));
|
|
1801
|
+
}
|
|
1802
|
+
console.log(theme.colorFn('└' + '─'.repeat(boxWidth - 2) + '┘'));
|
|
1803
|
+
console.log(theme.boldFn(` └─ ${parsed.summary}`));
|
|
1804
|
+
}
|
|
1805
|
+
else if (parsed.summary) {
|
|
1806
|
+
console.log(pc.green(` └─ ${parsed.summary}`));
|
|
1807
|
+
}
|
|
1808
|
+
else {
|
|
1809
|
+
const firstLine = event.result.trim().split('\n')[0];
|
|
1810
|
+
if (firstLine.includes('+') || firstLine.includes('lines') || firstLine.includes('Successfully')) {
|
|
1811
|
+
console.log(pc.green(` └─ ${firstLine}`));
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
catch {
|
|
1816
|
+
const firstLine = event.result.trim().split('\n')[0];
|
|
1817
|
+
if (firstLine.includes('+') || firstLine.includes('lines') || firstLine.includes('Successfully')) {
|
|
1818
|
+
console.log(pc.green(` └─ ${firstLine}`));
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
else if (event.type === 'usage') {
|
|
1824
|
+
totalSessionCost += event.usage.cost || 0;
|
|
1825
|
+
}
|
|
1826
|
+
else if (event.type === 'reconnecting') {
|
|
1827
|
+
if (spinnerActive) {
|
|
1828
|
+
s.stop();
|
|
1829
|
+
spinnerActive = false;
|
|
1830
|
+
}
|
|
1831
|
+
if (thinkingActive) {
|
|
1832
|
+
finishThinking(false);
|
|
1833
|
+
}
|
|
1834
|
+
if (textActive) {
|
|
1835
|
+
process.stdout.write('\n');
|
|
1836
|
+
textActive = false;
|
|
1837
|
+
}
|
|
1838
|
+
streamer.reset();
|
|
1839
|
+
console.log();
|
|
1840
|
+
console.log(pc.bold(pc.yellow(`⚠️ Интернет-соединение нестабильно (${event.reason})`)));
|
|
1841
|
+
console.log(pc.cyan(`🔄 Попытка переподключения [${event.attempt}/${event.maxAttempts}] (пауза ${(event.delayMs / 1000).toFixed(1)}с)...`));
|
|
1842
|
+
s.start(pc.cyan(`Ожидание сети [${event.attempt}/${event.maxAttempts}]...`));
|
|
1843
|
+
spinnerActive = true;
|
|
1844
|
+
}
|
|
1845
|
+
else if (event.type === 'reconnected') {
|
|
1846
|
+
if (spinnerActive) {
|
|
1847
|
+
s.stop();
|
|
1848
|
+
spinnerActive = false;
|
|
1849
|
+
}
|
|
1850
|
+
console.log(pc.bold(pc.green(`✔ Соединение восстановлено! Возобновляем генерацию...`)));
|
|
1851
|
+
console.log();
|
|
1852
|
+
s.start('Connecting...');
|
|
1853
|
+
spinnerActive = true;
|
|
1854
|
+
}
|
|
1855
|
+
else if (event.type === 'error') {
|
|
1856
|
+
if (spinnerActive) {
|
|
1857
|
+
s.stop();
|
|
1858
|
+
spinnerActive = false;
|
|
1859
|
+
}
|
|
1860
|
+
finishThinking(true);
|
|
1861
|
+
streamer.finish();
|
|
1862
|
+
p.log.error(pc.bold(pc.red(`❌ ${event.message}`)));
|
|
1863
|
+
}
|
|
1864
|
+
else if (event.type === 'system') {
|
|
1865
|
+
if (spinnerActive) {
|
|
1866
|
+
s.stop();
|
|
1867
|
+
spinnerActive = false;
|
|
1868
|
+
}
|
|
1869
|
+
p.log.info(event.message);
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
finishThinking(true);
|
|
1873
|
+
streamer.finish();
|
|
1874
|
+
if (spinnerActive) {
|
|
1875
|
+
s.stop();
|
|
1876
|
+
spinnerActive = false;
|
|
1877
|
+
}
|
|
1878
|
+
if (textActive) {
|
|
1879
|
+
process.stdout.write('\n');
|
|
1880
|
+
}
|
|
1881
|
+
globalSnapshotManager.finishTurn();
|
|
1882
|
+
const turnElapsedMs = Date.now() - turnStartTime;
|
|
1883
|
+
const shouldNotify = (turnElapsedMs >= 15000) || (toolsExecutedCount > 0) || (planMode && lastPlanReady !== null);
|
|
1884
|
+
if (shouldNotify && !aborted) {
|
|
1885
|
+
const secs = Math.round(turnElapsedMs / 1000);
|
|
1886
|
+
notifyDevice('devx', `Task completed in ${secs}s!`);
|
|
1887
|
+
}
|
|
1888
|
+
// Auto-save session
|
|
1889
|
+
await sessionManager.save(history.getMessages(), totalSessionCost, config.model, planMode);
|
|
1890
|
+
// Check if plan was finalized in PLAN mode
|
|
1891
|
+
if (planMode && !aborted) {
|
|
1892
|
+
const lastMsg = history.getMessages().slice(-1)[0];
|
|
1893
|
+
const hasPlanReadyTool = lastPlanReady !== null;
|
|
1894
|
+
const hasPlanText = lastMsg && lastMsg.role === 'assistant' && (lastMsg.content.includes('🎯 Цель') ||
|
|
1895
|
+
lastMsg.content.includes('📁 Пошаговый') ||
|
|
1896
|
+
lastMsg.content.includes('FINAL PLAN') ||
|
|
1897
|
+
lastMsg.content.includes('План готов') ||
|
|
1898
|
+
lastMsg.content.includes('Архитектурный план') ||
|
|
1899
|
+
lastMsg.content.includes('план готов'));
|
|
1900
|
+
if (hasPlanReadyTool || hasPlanText) {
|
|
1901
|
+
resetPlanReady();
|
|
1902
|
+
console.log();
|
|
1903
|
+
let choice = '';
|
|
1904
|
+
try {
|
|
1905
|
+
choice = await select({
|
|
1906
|
+
message: pc.bold(pc.cyan('📋 Plan is ready! What would you like to do?')),
|
|
1907
|
+
choices: [
|
|
1908
|
+
{
|
|
1909
|
+
name: pc.bold(pc.green('🚀 Go (Switch to AGENT mode and start execution)')),
|
|
1910
|
+
value: 'go',
|
|
1911
|
+
description: 'Automatically switch to AGENT mode and start implementing the plan immediately'
|
|
1912
|
+
},
|
|
1913
|
+
{
|
|
1914
|
+
name: pc.bold(pc.yellow('✏️ Other (Modify, add details, or ask questions)')),
|
|
1915
|
+
value: 'other',
|
|
1916
|
+
description: 'Stay in PLAN mode and open prompt to type adjustments or additions'
|
|
1917
|
+
}
|
|
1918
|
+
]
|
|
1919
|
+
});
|
|
1920
|
+
}
|
|
1921
|
+
catch { }
|
|
1922
|
+
if (choice === 'go') {
|
|
1923
|
+
planMode = false;
|
|
1924
|
+
const newSys = await buildSystemPrompt(false);
|
|
1925
|
+
history.updateSystemPrompt(newSys);
|
|
1926
|
+
p.log.success(pc.bold(pc.green('🚀 Switched to AGENT mode! Starting execution of approved plan...')));
|
|
1927
|
+
currentDraft = '';
|
|
1928
|
+
autoTriggerPrompt = 'Go!';
|
|
1929
|
+
continue;
|
|
1930
|
+
}
|
|
1931
|
+
else {
|
|
1932
|
+
currentDraft = '';
|
|
1933
|
+
continue;
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
catch (err) {
|
|
1939
|
+
finishThinking(true);
|
|
1940
|
+
streamer.finish();
|
|
1941
|
+
if (spinnerActive) {
|
|
1942
|
+
s.stop();
|
|
1943
|
+
spinnerActive = false;
|
|
1944
|
+
}
|
|
1945
|
+
if (!aborted && err.name !== 'AbortError' && !err.message?.includes('aborted')) {
|
|
1946
|
+
p.log.error(pc.bold(pc.red(`❌ ${err.message}`)));
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
finally {
|
|
1950
|
+
activeAbortHandler = null;
|
|
1951
|
+
process.removeListener('SIGINT', onSigInt);
|
|
1952
|
+
if (process.stdin.isTTY) {
|
|
1953
|
+
process.stdin.removeListener('data', onRawData);
|
|
1954
|
+
process.stdin.setRawMode(false);
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
MCPManager.getInstance().stopAll();
|
|
1959
|
+
}
|
|
1960
|
+
process.on('exit', () => {
|
|
1961
|
+
try {
|
|
1962
|
+
MCPManager.getInstance().stopAll();
|
|
1963
|
+
}
|
|
1964
|
+
catch { }
|
|
1965
|
+
});
|
|
1966
|
+
main().catch(console.error);
|