termux-dev 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +290 -0
- package/assets/banner.svg +33 -0
- package/assets/preview.png +0 -0
- package/bin/devx.js +2 -0
- package/dist/cli/clipboard.js +136 -0
- package/dist/cli/files.js +93 -0
- package/dist/cli/index.js +1506 -0
- package/dist/cli/markdown.js +147 -0
- package/dist/cli/prompt.js +553 -0
- package/dist/cli/providers.js +892 -0
- package/dist/cli/server.js +137 -0
- package/dist/cli/updater.js +245 -0
- package/dist/core/history.js +121 -0
- package/dist/core/loop.js +164 -0
- package/dist/core/memory.js +68 -0
- package/dist/core/models.js +72 -0
- package/dist/core/pricing.js +65 -0
- package/dist/core/session.js +129 -0
- package/dist/core/snapshot.js +88 -0
- package/dist/core/types.js +1 -0
- package/dist/permissions/guard.js +104 -0
- package/dist/prompts/builder.js +69 -0
- package/dist/providers/index.js +7 -0
- package/dist/providers/openai.js +318 -0
- package/dist/tools/bash.js +51 -0
- package/dist/tools/diagnostics.js +63 -0
- package/dist/tools/fs.js +185 -0
- package/dist/tools/index.js +17 -0
- package/dist/tools/packages.js +80 -0
- package/dist/tools/plan.js +52 -0
- package/dist/tools/questions.js +101 -0
- package/dist/tools/search.js +90 -0
- package/dist/tools/web.js +155 -0
- package/package.json +64 -0
|
@@ -0,0 +1,1506 @@
|
|
|
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 } 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 } from './server.js';
|
|
19
|
+
import { ALL_PROVIDERS } from './providers.js';
|
|
20
|
+
import { getModelContextLimit } from '../core/models.js';
|
|
21
|
+
import { SessionManager } from '../core/session.js';
|
|
22
|
+
import { MarkdownStreamer, renderMarkdown } from './markdown.js';
|
|
23
|
+
import { askPrompt } from './prompt.js';
|
|
24
|
+
import { resolveAtMentions } from './files.js';
|
|
25
|
+
import { runStartupUpdateCheck, checkForUpdates, performSelfUpdate } from './updater.js';
|
|
26
|
+
const CONFIG_PATH = path.join(os.homedir(), '.devxrc.json');
|
|
27
|
+
function maskApiKey(key) {
|
|
28
|
+
if (!key)
|
|
29
|
+
return '';
|
|
30
|
+
if (key.length <= 8)
|
|
31
|
+
return '****';
|
|
32
|
+
return `${key.slice(0, 4)}...${key.slice(-4)}`;
|
|
33
|
+
}
|
|
34
|
+
async function saveConfig(config) {
|
|
35
|
+
config.apiKeys = config.apiKeys || {};
|
|
36
|
+
config.baseUrls = config.baseUrls || {};
|
|
37
|
+
if (config.provider && config.apiKey) {
|
|
38
|
+
config.apiKeys[config.provider] = config.apiKey;
|
|
39
|
+
}
|
|
40
|
+
if (config.provider && config.baseUrl) {
|
|
41
|
+
config.baseUrls[config.provider] = config.baseUrl;
|
|
42
|
+
}
|
|
43
|
+
await fs.writeFile(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8');
|
|
44
|
+
}
|
|
45
|
+
async function fetchModels(baseUrl, apiKey) {
|
|
46
|
+
try {
|
|
47
|
+
const url = baseUrl.endsWith('/') ? `${baseUrl}models` : `${baseUrl}/models`;
|
|
48
|
+
const headers = {};
|
|
49
|
+
if (apiKey && apiKey !== 'ollama')
|
|
50
|
+
headers['Authorization'] = `Bearer ${apiKey}`;
|
|
51
|
+
const res = await fetch(url, { headers });
|
|
52
|
+
if (!res.ok) {
|
|
53
|
+
if (res.status === 401 || res.status === 403) {
|
|
54
|
+
return { ok: false, status: res.status, models: [], error: 'Invalid API Key / Unauthorized' };
|
|
55
|
+
}
|
|
56
|
+
return { ok: false, status: res.status, models: [], error: `HTTP ${res.status} ${res.statusText}` };
|
|
57
|
+
}
|
|
58
|
+
const data = await res.json();
|
|
59
|
+
if (data && data.data && Array.isArray(data.data)) {
|
|
60
|
+
const list = data.data.map((m) => ({
|
|
61
|
+
id: m.id,
|
|
62
|
+
name: m.name || m.id,
|
|
63
|
+
contextLength: m.context_length || m.max_context_length || m.limit?.context || m.limit?.input
|
|
64
|
+
}));
|
|
65
|
+
return { ok: true, status: res.status, models: list };
|
|
66
|
+
}
|
|
67
|
+
return { ok: true, status: res.status, models: [] };
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
return { ok: false, status: 0, models: [], error: e.message || 'Network error' };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const POPULAR_PROVIDER_MODELS = {
|
|
74
|
+
google: ['gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-1.5-flash', 'gemini-1.5-pro'],
|
|
75
|
+
openai: ['gpt-4o', 'gpt-4o-mini', 'o3-mini', 'o1', 'gpt-4-turbo'],
|
|
76
|
+
openrouter: ['google/gemini-2.5-flash', 'anthropic/claude-3.7-sonnet', 'openai/gpt-4o', 'deepseek/deepseek-r1', 'meta-llama/llama-3.3-70b-instruct'],
|
|
77
|
+
anthropic: ['claude-3-7-sonnet-20250219', 'claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022'],
|
|
78
|
+
deepseek: ['deepseek-chat', 'deepseek-reasoner'],
|
|
79
|
+
groq: ['llama-3.3-70b-versatile', 'llama-3.1-8b-instant', 'mixtral-8x7b-32768'],
|
|
80
|
+
mistral: ['mistral-large-latest', 'mistral-small-latest', 'codestral-latest'],
|
|
81
|
+
};
|
|
82
|
+
async function selectModel(baseUrl, apiKey, currentModel, providerId = '', allowCancel = true) {
|
|
83
|
+
const s = p.spinner();
|
|
84
|
+
s.start('Fetching available models from provider...');
|
|
85
|
+
const result = await fetchModels(baseUrl, apiKey);
|
|
86
|
+
s.stop();
|
|
87
|
+
let models = result.models;
|
|
88
|
+
if (models.length === 0 && providerId && POPULAR_PROVIDER_MODELS[providerId]) {
|
|
89
|
+
models = POPULAR_PROVIDER_MODELS[providerId].map(id => ({
|
|
90
|
+
id,
|
|
91
|
+
name: id,
|
|
92
|
+
contextLength: getModelContextLimit(id)
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
let modelChoice;
|
|
96
|
+
if (models.length > 0) {
|
|
97
|
+
try {
|
|
98
|
+
modelChoice = await search({
|
|
99
|
+
message: `Select a model (${models.length} available, type to filter):`,
|
|
100
|
+
source: async (term) => {
|
|
101
|
+
const q = (term || '').trim().toLowerCase();
|
|
102
|
+
const list = [];
|
|
103
|
+
if (allowCancel) {
|
|
104
|
+
list.push({ name: pc.yellow('⬅️ Back / Cancel'), value: '__cancel__', description: 'Keep current model' });
|
|
105
|
+
}
|
|
106
|
+
list.push({ name: pc.yellow('✏️ Type a custom model name...'), value: 'custom_input', description: 'Enter any model ID manually' });
|
|
107
|
+
for (const m of models) {
|
|
108
|
+
const isCurrent = m.id === currentModel;
|
|
109
|
+
const prefix = isCurrent ? pc.cyan('› ') : ' ';
|
|
110
|
+
const nameStr = isCurrent ? pc.bold(pc.cyan(m.id + ' (current)')) : m.id;
|
|
111
|
+
const ctxStr = m.contextLength ? `${m.contextLength >= 1_000_000 ? (m.contextLength / 1_000_000).toFixed(1) + 'M' : Math.round(m.contextLength / 1000) + 'k'} context` : '';
|
|
112
|
+
list.push({
|
|
113
|
+
name: prefix + nameStr,
|
|
114
|
+
value: m.id,
|
|
115
|
+
description: ctxStr
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
if (!q)
|
|
119
|
+
return list;
|
|
120
|
+
return list.filter(item => item.value.toLowerCase().includes(q) || item.name.toLowerCase().includes(q));
|
|
121
|
+
},
|
|
122
|
+
pageSize: 10
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
if (modelChoice === '__cancel__') {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
if (modelChoice === 'custom_input') {
|
|
132
|
+
try {
|
|
133
|
+
const customModel = await input({
|
|
134
|
+
message: 'Type the model name (e.g. gpt-4o or claude-3-7-sonnet):',
|
|
135
|
+
});
|
|
136
|
+
if (!customModel || !customModel.trim())
|
|
137
|
+
return null;
|
|
138
|
+
const name = customModel.trim();
|
|
139
|
+
return { model: name, contextLimit: getModelContextLimit(name) };
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const found = models.find(m => m.id === modelChoice);
|
|
146
|
+
const limit = (found && found.contextLength) || getModelContextLimit(modelChoice);
|
|
147
|
+
return { model: modelChoice, contextLimit: limit };
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
p.log.warn('Could not fetch models automatically.');
|
|
151
|
+
try {
|
|
152
|
+
const typedModel = await input({
|
|
153
|
+
message: 'Type the model name you want to use (e.g. gpt-4o):',
|
|
154
|
+
default: currentModel || ''
|
|
155
|
+
});
|
|
156
|
+
if (!typedModel || !typedModel.trim())
|
|
157
|
+
return null;
|
|
158
|
+
const name = typedModel.trim();
|
|
159
|
+
return { model: name, contextLimit: getModelContextLimit(name) };
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async function selectProvider(currentConfig, allowCancel = true) {
|
|
167
|
+
const apiKeys = currentConfig.apiKeys || {};
|
|
168
|
+
const baseUrls = currentConfig.baseUrls || {};
|
|
169
|
+
if (currentConfig.provider && currentConfig.apiKey) {
|
|
170
|
+
apiKeys[currentConfig.provider] = currentConfig.apiKey;
|
|
171
|
+
}
|
|
172
|
+
let providerId;
|
|
173
|
+
try {
|
|
174
|
+
providerId = await search({
|
|
175
|
+
message: 'Select AI Provider (type to filter, Esc to cancel):',
|
|
176
|
+
source: async (term) => {
|
|
177
|
+
const q = (term || '').trim().toLowerCase();
|
|
178
|
+
const filtered = q
|
|
179
|
+
? ALL_PROVIDERS.filter(pr => pr.label.toLowerCase().includes(q) || pr.value.toLowerCase().includes(q))
|
|
180
|
+
: ALL_PROVIDERS;
|
|
181
|
+
const list = filtered.map(pr => {
|
|
182
|
+
const isCurrent = pr.value === currentConfig.provider;
|
|
183
|
+
const labelStr = isCurrent ? pc.bold(pc.cyan(pr.label + ' (current)')) : pr.label;
|
|
184
|
+
const hasKey = apiKeys[pr.value] ? `[Key: ${maskApiKey(apiKeys[pr.value])}] ` : '';
|
|
185
|
+
return {
|
|
186
|
+
name: `${isCurrent ? pc.cyan('› ') : ' '}${labelStr}`,
|
|
187
|
+
value: pr.value,
|
|
188
|
+
description: `${hasKey}${pr.baseUrl || 'Custom endpoint'}`
|
|
189
|
+
};
|
|
190
|
+
});
|
|
191
|
+
if (allowCancel) {
|
|
192
|
+
list.unshift({
|
|
193
|
+
name: pc.yellow('⬅️ Back / Cancel'),
|
|
194
|
+
value: '__cancel__',
|
|
195
|
+
description: 'Keep current provider'
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
return list;
|
|
199
|
+
},
|
|
200
|
+
pageSize: 10
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
if (providerId === '__cancel__') {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
const selectedProvider = ALL_PROVIDERS.find(pr => pr.value === providerId) || { baseUrl: '', label: providerId };
|
|
210
|
+
let baseUrl = baseUrls[providerId] || selectedProvider.baseUrl;
|
|
211
|
+
if (providerId === 'custom' || !baseUrl) {
|
|
212
|
+
try {
|
|
213
|
+
const customUrl = await input({
|
|
214
|
+
message: 'Enter Base URL (e.g. https://api.openai.com/v1):',
|
|
215
|
+
default: baseUrl || ''
|
|
216
|
+
});
|
|
217
|
+
if (!customUrl || !customUrl.trim())
|
|
218
|
+
return null;
|
|
219
|
+
baseUrl = customUrl.trim();
|
|
220
|
+
baseUrls[providerId] = baseUrl;
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
let apiKey = '';
|
|
227
|
+
if (providerId === 'ollama') {
|
|
228
|
+
apiKey = 'ollama';
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
const savedKey = apiKeys[providerId];
|
|
232
|
+
if (savedKey) {
|
|
233
|
+
let keyChoice;
|
|
234
|
+
try {
|
|
235
|
+
keyChoice = await search({
|
|
236
|
+
message: `API key found for ${selectedProvider.label} (${maskApiKey(savedKey)}):`,
|
|
237
|
+
source: async () => [
|
|
238
|
+
{ name: `🟢 Continue with saved API key (${maskApiKey(savedKey)})`, value: 'use_saved' },
|
|
239
|
+
{ name: '✏️ Enter a new API key', value: 'new_key' },
|
|
240
|
+
{ name: pc.yellow('⬅️ Cancel'), value: '__cancel__' }
|
|
241
|
+
],
|
|
242
|
+
pageSize: 5
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
if (keyChoice === '__cancel__')
|
|
249
|
+
return null;
|
|
250
|
+
if (keyChoice === 'use_saved') {
|
|
251
|
+
apiKey = savedKey;
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
try {
|
|
255
|
+
const key = await password({
|
|
256
|
+
message: `Enter new API Key for ${selectedProvider.label}:`,
|
|
257
|
+
mask: '•'
|
|
258
|
+
});
|
|
259
|
+
if (!key || !key.trim())
|
|
260
|
+
return null;
|
|
261
|
+
apiKey = key.trim();
|
|
262
|
+
apiKeys[providerId] = apiKey;
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
try {
|
|
271
|
+
const key = await password({
|
|
272
|
+
message: `Enter API Key for ${selectedProvider.label} (or press Esc to cancel):`,
|
|
273
|
+
mask: '•'
|
|
274
|
+
});
|
|
275
|
+
if (!key || !key.trim())
|
|
276
|
+
return null;
|
|
277
|
+
apiKey = key.trim();
|
|
278
|
+
apiKeys[providerId] = apiKey;
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// Pick a model for this provider
|
|
286
|
+
const modelRes = await selectModel(baseUrl, apiKey, currentConfig.model || '', providerId, allowCancel);
|
|
287
|
+
if (!modelRes) {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
const updatedConfig = {
|
|
291
|
+
...currentConfig,
|
|
292
|
+
provider: providerId,
|
|
293
|
+
baseUrl,
|
|
294
|
+
apiKey,
|
|
295
|
+
model: modelRes.model,
|
|
296
|
+
maxContextTokens: modelRes.contextLimit,
|
|
297
|
+
maxIterations: currentConfig.maxIterations || 30,
|
|
298
|
+
apiKeys,
|
|
299
|
+
baseUrls
|
|
300
|
+
};
|
|
301
|
+
await saveConfig(updatedConfig);
|
|
302
|
+
return updatedConfig;
|
|
303
|
+
}
|
|
304
|
+
async function runOnboarding() {
|
|
305
|
+
console.clear();
|
|
306
|
+
p.intro(pc.bgCyan(pc.black(' Welcome to devx ')));
|
|
307
|
+
p.note('No configuration found. Let\'s set up your AI provider.', 'Setup');
|
|
308
|
+
const res = await selectProvider({ apiKeys: {}, baseUrls: {} }, false);
|
|
309
|
+
if (!res) {
|
|
310
|
+
process.exit(0);
|
|
311
|
+
}
|
|
312
|
+
p.outro(pc.green('Setup complete! Configuration saved to ~/.devxrc.json'));
|
|
313
|
+
return res;
|
|
314
|
+
}
|
|
315
|
+
async function loadConfig() {
|
|
316
|
+
try {
|
|
317
|
+
const data = await fs.readFile(CONFIG_PATH, 'utf8');
|
|
318
|
+
const parsed = JSON.parse(data);
|
|
319
|
+
if (!parsed.model || !parsed.baseUrl)
|
|
320
|
+
throw new Error('Invalid config');
|
|
321
|
+
parsed.maxContextTokens = parsed.maxContextTokens || getModelContextLimit(parsed.model);
|
|
322
|
+
parsed.apiKeys = parsed.apiKeys || {};
|
|
323
|
+
parsed.baseUrls = parsed.baseUrls || {};
|
|
324
|
+
if (parsed.provider && parsed.apiKey) {
|
|
325
|
+
parsed.apiKeys[parsed.provider] = parsed.apiKey;
|
|
326
|
+
}
|
|
327
|
+
return parsed;
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
return await runOnboarding();
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
function enableDarkTheme(enabled = true) {
|
|
334
|
+
if (!enabled)
|
|
335
|
+
return;
|
|
336
|
+
if (process.stdout.isTTY) {
|
|
337
|
+
// Deep OLED / Obsidian Black background (#0a0a0c) and bright crisp foreground (#f0f6fc)
|
|
338
|
+
process.stdout.write('\x1b]11;#0a0a0c\x07');
|
|
339
|
+
process.stdout.write('\x1b]10;#f0f6fc\x07');
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
function resetTerminalTheme() {
|
|
343
|
+
if (process.stdout.isTTY) {
|
|
344
|
+
process.stdout.write('\x1b]111\x07');
|
|
345
|
+
process.stdout.write('\x1b]110\x07');
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
let activeAbortHandler = null;
|
|
349
|
+
process.on('exit', () => {
|
|
350
|
+
resetTerminalTheme();
|
|
351
|
+
});
|
|
352
|
+
process.on('SIGINT', () => {
|
|
353
|
+
if (activeAbortHandler) {
|
|
354
|
+
activeAbortHandler();
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
resetTerminalTheme();
|
|
358
|
+
process.exit(0);
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
function clearTerminalScreen() {
|
|
362
|
+
if (process.stdout.isTTY) {
|
|
363
|
+
// \x1b[2J: clear screen, \x1b[3J: clear scrollback buffer (crucial for Termux/xterm), \x1b[H: cursor to home
|
|
364
|
+
process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
console.clear();
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
function drawLogo() {
|
|
371
|
+
const cols = process.stdout.columns || 80;
|
|
372
|
+
clearTerminalScreen();
|
|
373
|
+
if (cols < 56) {
|
|
374
|
+
// Ultra-clean compact ASCII for small mobile screens (width: ~26 chars)
|
|
375
|
+
const logo = [
|
|
376
|
+
'',
|
|
377
|
+
pc.cyan(' █▀▀▄ █▀▀▀ █ █ █ █'),
|
|
378
|
+
pc.cyan(' █ █ █▀▀▀ ▀▄▀ ▀▄▀ '),
|
|
379
|
+
pc.cyan(' █▄▄▀ █▄▄▄ ▀ ▀ ▀ '),
|
|
380
|
+
' ' + pc.cyan(pc.bold('v1.0.2')),
|
|
381
|
+
''
|
|
382
|
+
];
|
|
383
|
+
for (const line of logo) {
|
|
384
|
+
console.log(line);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
// Full TERMUX-DEV banner (width: 53 chars)
|
|
389
|
+
const indent = cols < 68 ? ' ' : ' ';
|
|
390
|
+
const logo = [
|
|
391
|
+
'',
|
|
392
|
+
indent + pc.cyan('▀▀▀█▀▀▀ █▀▀▀ █▀▀█ █▄ ▄█ █ █ ▀▄ ▄▀ █▀▀▄ █▀▀▀ █ █'),
|
|
393
|
+
indent + pc.cyan(' █ █▀▀▀ █▄▄▀ █ █ █ █ █ █ ▀▀ █ █ █▀▀▀ █ █'),
|
|
394
|
+
indent + pc.cyan(' █ █▄▄▄ █ ▀▄ █ █ ▀▄▄▀ ▄▀ ▀▄ █▄▄▀ █▄▄▄ ▀▄▀ '),
|
|
395
|
+
indent + pc.cyan(pc.bold('v1.0.2')),
|
|
396
|
+
''
|
|
397
|
+
];
|
|
398
|
+
for (const line of logo) {
|
|
399
|
+
console.log(line);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
console.log();
|
|
403
|
+
}
|
|
404
|
+
function formatSessionTime(timestamp) {
|
|
405
|
+
const diff = Date.now() - timestamp;
|
|
406
|
+
const mins = Math.floor(diff / 60000);
|
|
407
|
+
if (mins < 1)
|
|
408
|
+
return 'just now';
|
|
409
|
+
if (mins < 60)
|
|
410
|
+
return `${mins}m ago`;
|
|
411
|
+
const hours = Math.floor(mins / 60);
|
|
412
|
+
if (hours < 24)
|
|
413
|
+
return `${hours}h ago`;
|
|
414
|
+
const days = Math.floor(hours / 24);
|
|
415
|
+
return `${days}d ago`;
|
|
416
|
+
}
|
|
417
|
+
async function handleSessionDelete() {
|
|
418
|
+
while (true) {
|
|
419
|
+
const sessions = await SessionManager.listSessions();
|
|
420
|
+
if (sessions.length === 0) {
|
|
421
|
+
drawLogo();
|
|
422
|
+
p.log.warn('No saved sessions found.');
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
let chosenSessionId;
|
|
426
|
+
try {
|
|
427
|
+
chosenSessionId = await search({
|
|
428
|
+
message: `Select session to DELETE (${sessions.length} sessions, Esc to finish):`,
|
|
429
|
+
source: async (term) => {
|
|
430
|
+
const q = (term || '').trim().toLowerCase();
|
|
431
|
+
const list = [
|
|
432
|
+
{ name: pc.yellow('⬅️ Done / Cancel'), value: '__cancel__', description: 'Return to chat' },
|
|
433
|
+
{ name: pc.red('🗑️ Delete ALL sessions'), value: '__all__', description: `Remove all ${sessions.length} saved sessions` }
|
|
434
|
+
];
|
|
435
|
+
for (const s of sessions) {
|
|
436
|
+
const timeStr = formatSessionTime(s.updatedAt);
|
|
437
|
+
const msgCount = s.messages.filter(m => m.role !== 'system').length;
|
|
438
|
+
const shortId = s.id.split('_').pop();
|
|
439
|
+
list.push({
|
|
440
|
+
name: `${pc.red('🗑️ ')} ${pc.dim(`[#${shortId}]`)} ${s.title}`,
|
|
441
|
+
value: s.id,
|
|
442
|
+
description: `${timeStr} • ${msgCount} msgs • ${s.model || 'default'}`
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
if (!q)
|
|
446
|
+
return list;
|
|
447
|
+
return list.filter(item => item.name.toLowerCase().includes(q) || item.value.toLowerCase().includes(q));
|
|
448
|
+
},
|
|
449
|
+
pageSize: 10
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
break;
|
|
454
|
+
}
|
|
455
|
+
if (!chosenSessionId || chosenSessionId === '__cancel__') {
|
|
456
|
+
drawLogo();
|
|
457
|
+
p.log.info('Session deletion finished.');
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
if (chosenSessionId === '__all__') {
|
|
461
|
+
const confirmAll = await p.confirm({
|
|
462
|
+
message: `Are you sure you want to delete ALL ${sessions.length} saved sessions?`,
|
|
463
|
+
initialValue: false
|
|
464
|
+
});
|
|
465
|
+
if (!p.isCancel(confirmAll) && confirmAll) {
|
|
466
|
+
const count = await SessionManager.deleteAllSessions();
|
|
467
|
+
drawLogo();
|
|
468
|
+
p.log.success(`Deleted all ${count} sessions.`);
|
|
469
|
+
break;
|
|
470
|
+
}
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
const target = sessions.find(s => s.id === chosenSessionId);
|
|
474
|
+
const title = target ? target.title : chosenSessionId;
|
|
475
|
+
const ok = await SessionManager.deleteSession(chosenSessionId);
|
|
476
|
+
drawLogo();
|
|
477
|
+
if (ok) {
|
|
478
|
+
p.log.success(`Deleted session: "${title}"`);
|
|
479
|
+
}
|
|
480
|
+
else {
|
|
481
|
+
p.log.error(`Failed to delete session: "${title}"`);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
export async function main() {
|
|
486
|
+
const program = new Command();
|
|
487
|
+
program
|
|
488
|
+
.name('devx')
|
|
489
|
+
.description('CLI tool for vibe-coding')
|
|
490
|
+
.option('--plan', 'Start in plan mode')
|
|
491
|
+
.parse(process.argv);
|
|
492
|
+
const options = program.opts();
|
|
493
|
+
let planMode = !!options.plan;
|
|
494
|
+
let config = await loadConfig();
|
|
495
|
+
enableDarkTheme(config.pureBlackTheme !== false);
|
|
496
|
+
let history = new History();
|
|
497
|
+
const sysPrompt = await buildSystemPrompt(planMode);
|
|
498
|
+
history.addMessage({ role: 'system', content: sysPrompt });
|
|
499
|
+
const sessionManager = new SessionManager(config.model, planMode);
|
|
500
|
+
drawLogo();
|
|
501
|
+
await runStartupUpdateCheck(config);
|
|
502
|
+
let totalSessionCost = 0;
|
|
503
|
+
let currentDraft = '';
|
|
504
|
+
let autoTriggerPrompt = '';
|
|
505
|
+
while (true) {
|
|
506
|
+
const currentTokens = history.getConversationTokens();
|
|
507
|
+
const maxTokens = config.maxContextTokens || getModelContextLimit(config.model);
|
|
508
|
+
config.maxContextTokens = maxTokens;
|
|
509
|
+
const usagePercent = Math.min(100, Math.round((currentTokens / maxTokens) * 100));
|
|
510
|
+
const formatTokens = (n) => {
|
|
511
|
+
if (n >= 1_000_000)
|
|
512
|
+
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
513
|
+
if (n >= 1000)
|
|
514
|
+
return `${(n / 1000).toFixed(1)}k`;
|
|
515
|
+
return `${n}`;
|
|
516
|
+
};
|
|
517
|
+
const costStr = totalSessionCost > 0 ? `Cost: $${totalSessionCost.toFixed(4)}` : 'Cost: $0.0000';
|
|
518
|
+
const tokenStats = `Context: ${formatTokens(currentTokens)} / ${formatTokens(maxTokens)} (${usagePercent}%) • ${costStr}`;
|
|
519
|
+
const cols = process.stdout.columns || 80;
|
|
520
|
+
const modeName = planMode ? 'PLAN' : 'AGENT';
|
|
521
|
+
// Shorten model name if too long on narrow mobile screens
|
|
522
|
+
let displayModel = config.model;
|
|
523
|
+
if (cols < 75 && displayModel.length > 20) {
|
|
524
|
+
const parts = displayModel.split('/');
|
|
525
|
+
displayModel = parts.length > 1 ? parts.slice(1).join('/') : displayModel;
|
|
526
|
+
if (displayModel.length > 20) {
|
|
527
|
+
displayModel = displayModel.slice(0, 17) + '...';
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
const badge = pc.bgCyan(pc.black(` devx | ${modeName} | ${displayModel} `));
|
|
531
|
+
if (cols < 75) {
|
|
532
|
+
// 2-line layout for mobile screens: perfectly aligned with clack box borders
|
|
533
|
+
p.intro(`${badge}\n${pc.dim('│')} ${pc.dim(tokenStats)}`);
|
|
534
|
+
}
|
|
535
|
+
else {
|
|
536
|
+
// 1-line layout for wider desktop screens
|
|
537
|
+
p.intro(`${badge} ${pc.dim(tokenStats)}`);
|
|
538
|
+
}
|
|
539
|
+
let answer = '';
|
|
540
|
+
if (autoTriggerPrompt) {
|
|
541
|
+
answer = autoTriggerPrompt;
|
|
542
|
+
autoTriggerPrompt = '';
|
|
543
|
+
console.log(pc.cyan('◆') + ' ' + pc.bold(pc.white(answer)));
|
|
544
|
+
}
|
|
545
|
+
else {
|
|
546
|
+
const inputStr = await askPrompt({
|
|
547
|
+
message: 'Ask anything...',
|
|
548
|
+
placeholder: 'Fix a TODO, type /help, or press Tab to switch mode',
|
|
549
|
+
initialValue: currentDraft,
|
|
550
|
+
planMode
|
|
551
|
+
});
|
|
552
|
+
if (inputStr.startsWith('__TOGGLE_MODE__:')) {
|
|
553
|
+
currentDraft = inputStr.slice('__TOGGLE_MODE__:'.length);
|
|
554
|
+
planMode = !planMode;
|
|
555
|
+
const newSys = await buildSystemPrompt(planMode);
|
|
556
|
+
history.updateSystemPrompt(newSys);
|
|
557
|
+
p.log.info(planMode
|
|
558
|
+
? pc.bold(pc.cyan('🔄 Mode: PLAN (Architect & Planner) — Press Tab to switch to AGENT'))
|
|
559
|
+
: pc.bold(pc.green('🔄 Mode: AGENT (Coder & Executor) — Press Tab to switch to PLAN')));
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
currentDraft = '';
|
|
563
|
+
if (inputStr === '__CANCEL__') {
|
|
564
|
+
const confirmExit = await p.confirm({
|
|
565
|
+
message: 'Are you sure you want to exit devx?',
|
|
566
|
+
initialValue: false
|
|
567
|
+
});
|
|
568
|
+
if (p.isCancel(confirmExit) || confirmExit) {
|
|
569
|
+
resetTerminalTheme();
|
|
570
|
+
p.outro('Goodbye!');
|
|
571
|
+
break;
|
|
572
|
+
}
|
|
573
|
+
else {
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
answer = inputStr.trim();
|
|
578
|
+
}
|
|
579
|
+
if (!answer)
|
|
580
|
+
continue;
|
|
581
|
+
if (answer.startsWith('/')) {
|
|
582
|
+
const parts = answer.split(' ');
|
|
583
|
+
let cmd = parts[0];
|
|
584
|
+
async function handleSettings(config) {
|
|
585
|
+
while (true) {
|
|
586
|
+
try {
|
|
587
|
+
const maxIter = config.maxIterations || 100;
|
|
588
|
+
const maxIterLabel = maxIter >= 9999 ? 'Unlimited' : `${maxIter} steps`;
|
|
589
|
+
const choice = await select({
|
|
590
|
+
message: `${pc.bold('⚙️ Settings')} ${pc.dim('(devx v1.0.2 • by ApvCode)')}`,
|
|
591
|
+
choices: [
|
|
592
|
+
{
|
|
593
|
+
name: `${config.pureBlackTheme !== false ? pc.green('🎨 Pure Black Background: ON') : pc.yellow('🎨 Pure Black Background: OFF')}`,
|
|
594
|
+
value: 'toggle_black_theme',
|
|
595
|
+
description: config.pureBlackTheme !== false
|
|
596
|
+
? 'Apply deep OLED obsidian black background (#0a0a0c) like OpenCode'
|
|
597
|
+
: 'Use standard system terminal background color'
|
|
598
|
+
},
|
|
599
|
+
{
|
|
600
|
+
name: `${config.autoApprove ? pc.green('⚡ Auto-Approve (YOLO Mode): ON') : pc.yellow('🛡️ Auto-Approve (YOLO Mode): OFF')}`,
|
|
601
|
+
value: 'toggle_auto_approve',
|
|
602
|
+
description: config.autoApprove
|
|
603
|
+
? 'Permissions are automatically granted (no confirmation prompts for commands/files)'
|
|
604
|
+
: 'Agent asks for confirmation before executing bash commands or writing files'
|
|
605
|
+
},
|
|
606
|
+
{
|
|
607
|
+
name: `${config.enableMemory !== false ? pc.green('🧠 Project Memory Bank: ON') : pc.yellow('🧠 Project Memory Bank: OFF')}`,
|
|
608
|
+
value: 'toggle_memory',
|
|
609
|
+
description: config.enableMemory !== false
|
|
610
|
+
? 'Load persistent project rules and preferences from .devx/memory.md into AI context'
|
|
611
|
+
: 'Start sessions with a clean state without loading project memory'
|
|
612
|
+
},
|
|
613
|
+
{
|
|
614
|
+
name: `${config.checkUpdates !== false ? pc.green('🔔 Check for Updates on Startup: ON') : pc.yellow('🔔 Check for Updates on Startup: OFF')}`,
|
|
615
|
+
value: 'toggle_check_updates',
|
|
616
|
+
description: config.checkUpdates !== false
|
|
617
|
+
? 'Automatically check for updates from GitHub repository when launching devx'
|
|
618
|
+
: 'Disable update checking on startup (run /update manually instead)'
|
|
619
|
+
},
|
|
620
|
+
{
|
|
621
|
+
name: `🔄 Max Agent Iterations: ${pc.cyan(maxIterLabel)}`,
|
|
622
|
+
value: 'change_max_iterations',
|
|
623
|
+
description: 'Limit how many tool steps (file edits, terminal commands) agent can do per request'
|
|
624
|
+
},
|
|
625
|
+
{
|
|
626
|
+
name: `${pc.cyan('✨ About devx')} ${pc.dim('(v1.0.2 by ApvCode)')}`,
|
|
627
|
+
value: 'about',
|
|
628
|
+
description: 'Terminal-Native AI Coding Agent created by ApvCode (https://github.com/apvcode/Termux-Dev)'
|
|
629
|
+
},
|
|
630
|
+
{
|
|
631
|
+
name: '⬅️ Back / Save',
|
|
632
|
+
value: 'back',
|
|
633
|
+
description: 'Return to chat'
|
|
634
|
+
}
|
|
635
|
+
]
|
|
636
|
+
});
|
|
637
|
+
if (choice === 'about') {
|
|
638
|
+
p.note(`⚡ devx v1.0.2 — Terminal-Native AI Coding Agent\n` +
|
|
639
|
+
`👤 Author: ApvCode (https://github.com/apvcode)\n` +
|
|
640
|
+
`🌟 Repository: https://github.com/apvcode/Termux-Dev\n` +
|
|
641
|
+
`📜 License: MIT License (2026)\n` +
|
|
642
|
+
`Built for Android Termux, Windows, macOS, and Linux.`, 'About devx');
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
if (choice === 'toggle_black_theme') {
|
|
646
|
+
config.pureBlackTheme = config.pureBlackTheme === false ? true : false;
|
|
647
|
+
await saveConfig(config);
|
|
648
|
+
if (config.pureBlackTheme) {
|
|
649
|
+
enableDarkTheme(true);
|
|
650
|
+
}
|
|
651
|
+
else {
|
|
652
|
+
resetTerminalTheme();
|
|
653
|
+
}
|
|
654
|
+
drawLogo();
|
|
655
|
+
p.log.success(`Pure Black background: ${config.pureBlackTheme ? pc.bold(pc.green('ON (Deep Black)')) : pc.bold(pc.yellow('OFF (System Default)'))}`);
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
if (choice === 'toggle_auto_approve') {
|
|
659
|
+
config.autoApprove = !config.autoApprove;
|
|
660
|
+
await saveConfig(config);
|
|
661
|
+
p.log.success(`Auto-approve permissions: ${config.autoApprove ? pc.bold(pc.green('ON (Automatic Yes)')) : pc.bold(pc.yellow('OFF (Ask every time)'))}`);
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
if (choice === 'toggle_memory') {
|
|
665
|
+
config.enableMemory = config.enableMemory === false ? true : false;
|
|
666
|
+
await saveConfig(config);
|
|
667
|
+
p.log.success(`Project memory: ${config.enableMemory ? pc.bold(pc.green('ON (Enabled)')) : pc.bold(pc.yellow('OFF (Disabled)'))}`);
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
if (choice === 'toggle_check_updates') {
|
|
671
|
+
config.checkUpdates = config.checkUpdates === false ? true : false;
|
|
672
|
+
await saveConfig(config);
|
|
673
|
+
p.log.success(`Check for updates on startup: ${config.checkUpdates ? pc.bold(pc.green('ON (Enabled)')) : pc.bold(pc.yellow('OFF (Disabled)'))}`);
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
if (choice === 'change_max_iterations') {
|
|
677
|
+
const val = await select({
|
|
678
|
+
message: 'Select maximum iterations limit per prompt:',
|
|
679
|
+
choices: [
|
|
680
|
+
{ name: '30 steps (Strict / Safe)', value: 30 },
|
|
681
|
+
{ name: '50 steps (Moderate)', value: 50 },
|
|
682
|
+
{ name: '100 steps (Recommended / Default)', value: 100 },
|
|
683
|
+
{ name: '200 steps (Very large refactors)', value: 200 },
|
|
684
|
+
{ name: 'Unlimited (No limit)', value: 9999 }
|
|
685
|
+
]
|
|
686
|
+
});
|
|
687
|
+
config.maxIterations = val;
|
|
688
|
+
await saveConfig(config);
|
|
689
|
+
p.log.success(`Max iterations updated to: ${pc.bold(val >= 9999 ? 'Unlimited' : `${val} steps`)}`);
|
|
690
|
+
continue;
|
|
691
|
+
}
|
|
692
|
+
break;
|
|
693
|
+
}
|
|
694
|
+
catch {
|
|
695
|
+
break;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
process.stdin.resume();
|
|
699
|
+
return config;
|
|
700
|
+
}
|
|
701
|
+
const VALID_COMMANDS = ['/new', '/reset', '/resume', '/session', '/sessions', '/history', '/settings', '/update', '/model', '/provider', '/providers', '/plan', '/agent', '/config', '/clear', '/exit', '/help'];
|
|
702
|
+
if (!VALID_COMMANDS.includes(cmd)) {
|
|
703
|
+
const SLASH_COMMANDS = [
|
|
704
|
+
{ name: '/new - Start a new clean chat session', value: '/new' },
|
|
705
|
+
{ name: '/resume - Resume a previous chat session', value: '/resume' },
|
|
706
|
+
{ name: '/session del - Select and delete saved sessions', value: '/session del' },
|
|
707
|
+
{ name: '/settings - Configure permissions & auto-approval', value: '/settings' },
|
|
708
|
+
{ name: '/update - Check and install updates from GitHub', value: '/update' },
|
|
709
|
+
{ name: '/model - Switch model for current provider', value: '/model' },
|
|
710
|
+
{ name: '/provider - Change AI provider (Google, OpenRouter...)', value: '/provider' },
|
|
711
|
+
{ name: '/plan - Switch to PLAN mode (architect)', value: '/plan' },
|
|
712
|
+
{ name: '/agent - Switch to AGENT mode (coder)', value: '/agent' },
|
|
713
|
+
{ name: '/config - View current configuration', value: '/config' },
|
|
714
|
+
{ name: '/clear - Clear message history', value: '/clear' },
|
|
715
|
+
{ name: '/help - Show commands overview', value: '/help' },
|
|
716
|
+
{ name: '/exit - Exit devx', value: '/exit' },
|
|
717
|
+
];
|
|
718
|
+
try {
|
|
719
|
+
const picked = await search({
|
|
720
|
+
message: 'Commands (type to search or select):',
|
|
721
|
+
source: async (term) => {
|
|
722
|
+
const q = (term || '').trim().toLowerCase();
|
|
723
|
+
const list = [
|
|
724
|
+
{ name: pc.yellow('⬅️ Cancel'), value: '__cancel__' },
|
|
725
|
+
...SLASH_COMMANDS
|
|
726
|
+
];
|
|
727
|
+
if (!q)
|
|
728
|
+
return list;
|
|
729
|
+
return list.filter(item => item.name.toLowerCase().includes(q) || item.value.toLowerCase().includes(q));
|
|
730
|
+
},
|
|
731
|
+
pageSize: 12
|
|
732
|
+
});
|
|
733
|
+
if (!picked || picked === '__cancel__') {
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
if (picked === '/session del') {
|
|
737
|
+
await handleSessionDelete();
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
cmd = picked;
|
|
741
|
+
}
|
|
742
|
+
catch {
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
if (cmd === '/settings') {
|
|
747
|
+
config = await handleSettings(config);
|
|
748
|
+
drawLogo();
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
if (cmd === '/update') {
|
|
752
|
+
const s = p.spinner();
|
|
753
|
+
s.start('Checking for updates from https://github.com/apvcode/Termux-Dev...');
|
|
754
|
+
const res = await checkForUpdates(10000);
|
|
755
|
+
s.stop();
|
|
756
|
+
if (res.updateAvailable) {
|
|
757
|
+
p.log.info(pc.bold(pc.yellow(`🚀 Update available: v${res.currentVersion} ➔ v${res.latestVersion}`)));
|
|
758
|
+
const doUpdate = await p.confirm({
|
|
759
|
+
message: `Do you want to update devx to v${res.latestVersion} now?`,
|
|
760
|
+
initialValue: true
|
|
761
|
+
});
|
|
762
|
+
if (!p.isCancel(doUpdate) && doUpdate) {
|
|
763
|
+
await performSelfUpdate(res.latestVersion);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
else {
|
|
767
|
+
p.log.success(pc.green(`devx is up to date! (v${res.currentVersion})`));
|
|
768
|
+
}
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
if (cmd === '/help') {
|
|
772
|
+
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');
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
if (cmd === '/exit') {
|
|
776
|
+
p.outro('Goodbye!');
|
|
777
|
+
break;
|
|
778
|
+
}
|
|
779
|
+
if (cmd === '/clear') {
|
|
780
|
+
history.clear();
|
|
781
|
+
history.addMessage({ role: 'system', content: await buildSystemPrompt(planMode) });
|
|
782
|
+
p.log.success('History cleared.');
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
if (cmd === '/new' || cmd === '/reset') {
|
|
786
|
+
sessionManager.startNewSession(config.model, planMode);
|
|
787
|
+
history = new History();
|
|
788
|
+
history.addMessage({ role: 'system', content: await buildSystemPrompt(planMode) });
|
|
789
|
+
totalSessionCost = 0;
|
|
790
|
+
drawLogo();
|
|
791
|
+
const initialPrompt = parts.slice(1).join(' ').trim();
|
|
792
|
+
if (initialPrompt) {
|
|
793
|
+
p.log.success('Started new session with prompt.');
|
|
794
|
+
answer = initialPrompt;
|
|
795
|
+
}
|
|
796
|
+
else {
|
|
797
|
+
p.log.success('Started a new chat session.');
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
else if (cmd === '/session' || cmd === '/sessions' || cmd === '/resume' || cmd === '/history') {
|
|
802
|
+
const sub = (parts[1] || '').toLowerCase();
|
|
803
|
+
if (sub === 'del' || sub === 'delete' || sub === 'rm') {
|
|
804
|
+
await handleSessionDelete();
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
const sessions = await SessionManager.listSessions();
|
|
808
|
+
if (sessions.length === 0) {
|
|
809
|
+
p.log.warn('No saved sessions found.');
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
let chosenSessionId;
|
|
813
|
+
try {
|
|
814
|
+
chosenSessionId = await search({
|
|
815
|
+
message: 'Select a session to resume (type to filter, Esc to cancel):',
|
|
816
|
+
source: async (term) => {
|
|
817
|
+
const q = (term || '').trim().toLowerCase();
|
|
818
|
+
const list = [
|
|
819
|
+
{ name: pc.yellow('⬅️ Back / Cancel'), value: '__cancel__', description: 'Return to chat' },
|
|
820
|
+
{ name: pc.red('🗑️ Delete sessions... (/session del)'), value: '__delete_mode__', description: 'Selectively delete saved sessions' }
|
|
821
|
+
];
|
|
822
|
+
for (const s of sessions) {
|
|
823
|
+
const timeStr = formatSessionTime(s.updatedAt);
|
|
824
|
+
const msgCount = s.messages.filter(m => m.role !== 'system').length;
|
|
825
|
+
const shortId = s.id.split('_').pop();
|
|
826
|
+
list.push({
|
|
827
|
+
name: `${pc.dim(`[#${shortId}]`)} ${s.title}`,
|
|
828
|
+
value: s.id,
|
|
829
|
+
description: `${timeStr} • ${msgCount} msgs • ${s.model || 'default'}`
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
if (!q)
|
|
833
|
+
return list;
|
|
834
|
+
return list.filter(item => item.name.toLowerCase().includes(q) || item.value.toLowerCase().includes(q));
|
|
835
|
+
},
|
|
836
|
+
pageSize: 10
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
catch {
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
if (chosenSessionId === '__cancel__') {
|
|
843
|
+
continue;
|
|
844
|
+
}
|
|
845
|
+
if (chosenSessionId === '__delete_mode__') {
|
|
846
|
+
await handleSessionDelete();
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
const loaded = await SessionManager.loadSession(chosenSessionId);
|
|
850
|
+
if (loaded) {
|
|
851
|
+
sessionManager.setLoadedSession(loaded);
|
|
852
|
+
history = new History();
|
|
853
|
+
for (const m of loaded.messages) {
|
|
854
|
+
history.addMessage(m);
|
|
855
|
+
}
|
|
856
|
+
if (loaded.model) {
|
|
857
|
+
config.model = loaded.model;
|
|
858
|
+
config.maxContextTokens = getModelContextLimit(loaded.model);
|
|
859
|
+
}
|
|
860
|
+
if (loaded.planMode !== undefined) {
|
|
861
|
+
planMode = loaded.planMode;
|
|
862
|
+
}
|
|
863
|
+
totalSessionCost = loaded.totalCost || 0;
|
|
864
|
+
drawLogo();
|
|
865
|
+
const nonSysMessages = loaded.messages.filter(m => m.role !== 'system');
|
|
866
|
+
const totalMsgs = nonSysMessages.length;
|
|
867
|
+
const displayLimit = 20;
|
|
868
|
+
const recentMessages = nonSysMessages.slice(-displayLimit);
|
|
869
|
+
const shownCount = recentMessages.length;
|
|
870
|
+
// Header banner showing messages loaded
|
|
871
|
+
const countInfo = totalMsgs > displayLimit
|
|
872
|
+
? `Loaded recent ${shownCount} messages of ${totalMsgs}`
|
|
873
|
+
: `Loaded all ${totalMsgs} messages`;
|
|
874
|
+
console.log('\n' + pc.bold(pc.cyan(`─── 📜 ${countInfo} (Session: "${loaded.title}") ───`)) + '\n');
|
|
875
|
+
// Render each message in chronological order
|
|
876
|
+
for (const msg of recentMessages) {
|
|
877
|
+
if (msg.role === 'user') {
|
|
878
|
+
console.log(pc.cyan('◆') + ' ' + pc.bold(pc.white(msg.content)));
|
|
879
|
+
if (msg.images && msg.images.length > 0) {
|
|
880
|
+
console.log(pc.magenta(` [🖼️ ${msg.images.map(i => i.path).join(', ')}]`));
|
|
881
|
+
}
|
|
882
|
+
console.log();
|
|
883
|
+
}
|
|
884
|
+
else if (msg.role === 'assistant') {
|
|
885
|
+
if (msg.content) {
|
|
886
|
+
console.log(renderMarkdown(msg.content));
|
|
887
|
+
}
|
|
888
|
+
if (msg.tool_calls && msg.tool_calls.length > 0) {
|
|
889
|
+
for (const tc of msg.tool_calls) {
|
|
890
|
+
console.log(pc.dim(` ⚡ ${tc.name}`));
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
console.log();
|
|
894
|
+
}
|
|
895
|
+
else if (msg.role === 'tool') {
|
|
896
|
+
if (msg.content) {
|
|
897
|
+
const firstLine = msg.content.trim().split('\n')[0];
|
|
898
|
+
if (firstLine.includes('+') || firstLine.includes('lines') || firstLine.includes('Successfully')) {
|
|
899
|
+
console.log(pc.green(` └─ ${firstLine}`));
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
console.log(pc.bold(pc.cyan(`────────────────────────────────────────────────────────────────────────\n`)));
|
|
905
|
+
p.log.success(`Resumed session: "${loaded.title}" (${totalMsgs} total messages in history)`);
|
|
906
|
+
}
|
|
907
|
+
else {
|
|
908
|
+
p.log.error('Failed to load session.');
|
|
909
|
+
}
|
|
910
|
+
continue;
|
|
911
|
+
}
|
|
912
|
+
if (cmd === '/plan') {
|
|
913
|
+
planMode = true;
|
|
914
|
+
const newSys = await buildSystemPrompt(true);
|
|
915
|
+
history.updateSystemPrompt(newSys);
|
|
916
|
+
drawLogo();
|
|
917
|
+
p.log.success(pc.cyan('Switched to PLAN mode (Architect & Planner). Modifying tools are disabled.'));
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
if (cmd === '/agent') {
|
|
921
|
+
planMode = false;
|
|
922
|
+
const newSys = await buildSystemPrompt(false);
|
|
923
|
+
history.updateSystemPrompt(newSys);
|
|
924
|
+
drawLogo();
|
|
925
|
+
p.log.success(pc.green('Switched to AGENT mode (Coder & Executor). Full tools enabled.'));
|
|
926
|
+
continue;
|
|
927
|
+
}
|
|
928
|
+
if (cmd === '/undo') {
|
|
929
|
+
const { revertedFiles, count } = await globalSnapshotManager.undoLastTurn();
|
|
930
|
+
if (count === 0) {
|
|
931
|
+
p.log.warn('No changes to undo.');
|
|
932
|
+
}
|
|
933
|
+
else {
|
|
934
|
+
const msgs = history.getMessages();
|
|
935
|
+
while (msgs.length > 1 && msgs[msgs.length - 1].role !== 'user') {
|
|
936
|
+
msgs.pop();
|
|
937
|
+
}
|
|
938
|
+
if (msgs.length > 1 && msgs[msgs.length - 1].role === 'user') {
|
|
939
|
+
msgs.pop();
|
|
940
|
+
}
|
|
941
|
+
p.log.success(pc.bold(pc.green(`⏪ Successfully reverted changes in ${count} file(s):`)));
|
|
942
|
+
for (const f of revertedFiles) {
|
|
943
|
+
console.log(pc.cyan(` • ${f}`));
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
continue;
|
|
947
|
+
}
|
|
948
|
+
if (cmd === '/diff') {
|
|
949
|
+
try {
|
|
950
|
+
const diffOutput = execSync('git diff', { encoding: 'utf8' });
|
|
951
|
+
if (!diffOutput.trim()) {
|
|
952
|
+
p.log.info('No uncommitted changes in git repository.');
|
|
953
|
+
}
|
|
954
|
+
else {
|
|
955
|
+
console.log('\n' + pc.bold(pc.cyan('─── Git Diff ─────────────────────────')));
|
|
956
|
+
for (const line of diffOutput.split('\n')) {
|
|
957
|
+
if (line.startsWith('+') && !line.startsWith('+++')) {
|
|
958
|
+
console.log(pc.green(line));
|
|
959
|
+
}
|
|
960
|
+
else if (line.startsWith('-') && !line.startsWith('---')) {
|
|
961
|
+
console.log(pc.red(line));
|
|
962
|
+
}
|
|
963
|
+
else if (line.startsWith('@@')) {
|
|
964
|
+
console.log(pc.magenta(line));
|
|
965
|
+
}
|
|
966
|
+
else {
|
|
967
|
+
console.log(line);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
console.log(pc.bold(pc.cyan('──────────────────────────────────────\n')));
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
catch {
|
|
974
|
+
p.log.warn('Git is not available or current directory is not a git repo.');
|
|
975
|
+
}
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
if (cmd === '/status') {
|
|
979
|
+
try {
|
|
980
|
+
const statusOutput = execSync('git status --short', { encoding: 'utf8' });
|
|
981
|
+
if (!statusOutput.trim()) {
|
|
982
|
+
p.log.success('Working directory clean, no modified files.');
|
|
983
|
+
}
|
|
984
|
+
else {
|
|
985
|
+
console.log('\n' + pc.bold(pc.cyan('─── Git Status ───────────────────────')));
|
|
986
|
+
for (const line of statusOutput.split('\n')) {
|
|
987
|
+
if (!line.trim())
|
|
988
|
+
continue;
|
|
989
|
+
if (line.startsWith(' M') || line.startsWith('M ')) {
|
|
990
|
+
console.log(pc.yellow(` ${line}`));
|
|
991
|
+
}
|
|
992
|
+
else if (line.startsWith('??')) {
|
|
993
|
+
console.log(pc.green(` ${line}`));
|
|
994
|
+
}
|
|
995
|
+
else if (line.startsWith(' D')) {
|
|
996
|
+
console.log(pc.red(` ${line}`));
|
|
997
|
+
}
|
|
998
|
+
else {
|
|
999
|
+
console.log(` ${line}`);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
console.log(pc.bold(pc.cyan('──────────────────────────────────────\n')));
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
catch {
|
|
1006
|
+
p.log.warn('Git is not available or current directory is not a git repo.');
|
|
1007
|
+
}
|
|
1008
|
+
continue;
|
|
1009
|
+
}
|
|
1010
|
+
if (cmd === '/commit') {
|
|
1011
|
+
try {
|
|
1012
|
+
const status = execSync('git status --short', { encoding: 'utf8' });
|
|
1013
|
+
if (!status.trim()) {
|
|
1014
|
+
p.log.info('No changes to commit.');
|
|
1015
|
+
continue;
|
|
1016
|
+
}
|
|
1017
|
+
const diff = execSync('git diff', { encoding: 'utf8' });
|
|
1018
|
+
const userMsg = parts.slice(1).join(' ').trim();
|
|
1019
|
+
let commitMsg = userMsg;
|
|
1020
|
+
if (!commitMsg) {
|
|
1021
|
+
const s = p.spinner();
|
|
1022
|
+
s.start('Generating conventional commit message with AI...');
|
|
1023
|
+
const provider = createProvider(config);
|
|
1024
|
+
const promptReq = {
|
|
1025
|
+
messages: [
|
|
1026
|
+
{
|
|
1027
|
+
role: 'system',
|
|
1028
|
+
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.'
|
|
1029
|
+
},
|
|
1030
|
+
{
|
|
1031
|
+
role: 'user',
|
|
1032
|
+
content: `Git status:\n${status}\n\nGit diff snippet:\n${diff.slice(0, 3000)}`
|
|
1033
|
+
}
|
|
1034
|
+
]
|
|
1035
|
+
};
|
|
1036
|
+
const genRes = await provider.chat(promptReq);
|
|
1037
|
+
s.stop();
|
|
1038
|
+
commitMsg = (genRes.content || 'chore: update project files').trim().replace(/^["'`]|["'`]$/g, '').split('\n')[0];
|
|
1039
|
+
}
|
|
1040
|
+
const confirmed = await p.confirm({
|
|
1041
|
+
message: `Commit changes with message:\n"${pc.bold(pc.green(commitMsg))}"?`,
|
|
1042
|
+
initialValue: true
|
|
1043
|
+
});
|
|
1044
|
+
if (!p.isCancel(confirmed) && confirmed) {
|
|
1045
|
+
execSync('git add -A', { stdio: 'ignore' });
|
|
1046
|
+
execSync(`git commit -m "${commitMsg.replace(/"/g, '\\"')}"`, { stdio: 'ignore' });
|
|
1047
|
+
p.log.success(pc.bold(pc.green(`✅ Committed: ${commitMsg}`)));
|
|
1048
|
+
}
|
|
1049
|
+
else {
|
|
1050
|
+
p.log.info('Commit cancelled.');
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
catch (err) {
|
|
1054
|
+
p.log.error(`Git commit failed: ${err.message}`);
|
|
1055
|
+
}
|
|
1056
|
+
continue;
|
|
1057
|
+
}
|
|
1058
|
+
if (cmd === '/compact') {
|
|
1059
|
+
const msgs = history.getMessages();
|
|
1060
|
+
if (msgs.length <= 2) {
|
|
1061
|
+
p.log.info('Conversation is already short, no compaction needed.');
|
|
1062
|
+
continue;
|
|
1063
|
+
}
|
|
1064
|
+
const s = p.spinner();
|
|
1065
|
+
s.start('Compacting conversation context with AI...');
|
|
1066
|
+
try {
|
|
1067
|
+
const provider = createProvider(config);
|
|
1068
|
+
const compactPrompt = {
|
|
1069
|
+
messages: [
|
|
1070
|
+
...msgs,
|
|
1071
|
+
{
|
|
1072
|
+
role: 'user',
|
|
1073
|
+
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.'
|
|
1074
|
+
}
|
|
1075
|
+
]
|
|
1076
|
+
};
|
|
1077
|
+
const res = await provider.chat(compactPrompt);
|
|
1078
|
+
s.stop();
|
|
1079
|
+
const sysPrompt = await buildSystemPrompt(planMode);
|
|
1080
|
+
history.clear();
|
|
1081
|
+
history.addMessage({ role: 'system', content: sysPrompt });
|
|
1082
|
+
history.addMessage({
|
|
1083
|
+
role: 'assistant',
|
|
1084
|
+
content: `[CONVERSATION COMPACTED]\n${res.content || ''}`
|
|
1085
|
+
});
|
|
1086
|
+
p.log.success(pc.bold(pc.green('🗜️ Context successfully compacted! Freed up tokens while retaining task summary.')));
|
|
1087
|
+
}
|
|
1088
|
+
catch (err) {
|
|
1089
|
+
s.stop();
|
|
1090
|
+
p.log.error(`Compaction failed: ${err.message}`);
|
|
1091
|
+
}
|
|
1092
|
+
continue;
|
|
1093
|
+
}
|
|
1094
|
+
if (cmd === '/clear') {
|
|
1095
|
+
console.clear();
|
|
1096
|
+
drawLogo();
|
|
1097
|
+
continue;
|
|
1098
|
+
}
|
|
1099
|
+
if (cmd === '/session' && parts[1]?.toLowerCase() !== 'del') {
|
|
1100
|
+
const cur = sessionManager.getSession();
|
|
1101
|
+
const nonSys = history.getMessages().filter(m => m.role !== 'system');
|
|
1102
|
+
const shortId = cur.id.split('_').pop();
|
|
1103
|
+
console.log('\n' + pc.bold(pc.cyan('─── 🆔 Active Session Information ───')));
|
|
1104
|
+
console.log(` ${pc.bold('ID:')} ${pc.cyan(cur.id)} ${pc.dim(`(#${shortId})`)}`);
|
|
1105
|
+
console.log(` ${pc.bold('Title:')} "${cur.title}"`);
|
|
1106
|
+
console.log(` ${pc.bold('Model:')} ${config.model}`);
|
|
1107
|
+
console.log(` ${pc.bold('Mode:')} ${planMode ? pc.cyan('PLAN') : pc.green('AGENT')}`);
|
|
1108
|
+
console.log(` ${pc.bold('Messages:')} ${nonSys.length} messages`);
|
|
1109
|
+
console.log(` ${pc.bold('Cost:')} $${totalSessionCost.toFixed(4)}`);
|
|
1110
|
+
console.log(` ${pc.bold('File:')} ~/.devx/sessions/${cur.id}.json`);
|
|
1111
|
+
console.log(pc.bold(pc.cyan('──────────────────────────────────────\n')));
|
|
1112
|
+
continue;
|
|
1113
|
+
}
|
|
1114
|
+
if (cmd === '/init') {
|
|
1115
|
+
const agentsPath = path.join(process.cwd(), 'AGENTS.md');
|
|
1116
|
+
if (fsSync.existsSync(agentsPath)) {
|
|
1117
|
+
p.log.warn('AGENTS.md already exists in current workspace.');
|
|
1118
|
+
}
|
|
1119
|
+
else {
|
|
1120
|
+
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`;
|
|
1121
|
+
await fs.writeFile(agentsPath, template, 'utf8');
|
|
1122
|
+
p.log.success(`Created ${pc.bold('AGENTS.md')} in project root! Customize it to give devx persistent instructions.`);
|
|
1123
|
+
}
|
|
1124
|
+
continue;
|
|
1125
|
+
}
|
|
1126
|
+
if (cmd === '/serve') {
|
|
1127
|
+
const sub = parts[1]?.toLowerCase();
|
|
1128
|
+
if (sub === 'stop') {
|
|
1129
|
+
if (stopServer()) {
|
|
1130
|
+
p.log.success('Web server stopped.');
|
|
1131
|
+
}
|
|
1132
|
+
else {
|
|
1133
|
+
p.log.info('No web server is currently running.');
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
else {
|
|
1137
|
+
const customPort = parseInt(parts[1], 10) || 3000;
|
|
1138
|
+
try {
|
|
1139
|
+
const { port, localUrl, networkUrl } = await startServer(customPort);
|
|
1140
|
+
p.log.success(pc.bold(pc.green(`🌐 Web Server running on port ${port}!`)));
|
|
1141
|
+
console.log(pc.cyan(` • Local: ${localUrl}`));
|
|
1142
|
+
console.log(pc.cyan(` • Network: ${networkUrl}`));
|
|
1143
|
+
console.log(pc.dim(' (Use /serve stop to stop the server)\n'));
|
|
1144
|
+
}
|
|
1145
|
+
catch (err) {
|
|
1146
|
+
p.log.error(`Failed to start web server: ${err.message}`);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
continue;
|
|
1150
|
+
}
|
|
1151
|
+
if (cmd === '/memory') {
|
|
1152
|
+
const sub = parts[1]?.toLowerCase();
|
|
1153
|
+
if (sub === 'clear') {
|
|
1154
|
+
await MemoryManager.clearMemory();
|
|
1155
|
+
p.log.success('Cleared project memory bank (.devx/memory.md).');
|
|
1156
|
+
}
|
|
1157
|
+
else if (sub === 'add') {
|
|
1158
|
+
const fact = parts.slice(2).join(' ').trim();
|
|
1159
|
+
if (!fact) {
|
|
1160
|
+
p.log.warn('Usage: /memory add <fact or rule to remember>');
|
|
1161
|
+
}
|
|
1162
|
+
else {
|
|
1163
|
+
await MemoryManager.addFact(fact);
|
|
1164
|
+
p.log.success(`Saved to memory: "${fact}"`);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
else {
|
|
1168
|
+
const mem = await MemoryManager.loadMemory();
|
|
1169
|
+
if (!mem.trim()) {
|
|
1170
|
+
p.log.info('Project memory is empty. AI will remember facts as you work, or use /memory add <fact>.');
|
|
1171
|
+
}
|
|
1172
|
+
else {
|
|
1173
|
+
p.note(mem, '🧠 Project Memory Bank (.devx/memory.md)');
|
|
1174
|
+
console.log(pc.dim('Commands: /memory add <text> | /memory clear\n'));
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
continue;
|
|
1178
|
+
}
|
|
1179
|
+
if (cmd === '/config') {
|
|
1180
|
+
p.note(JSON.stringify(config, null, 2), 'Configuration');
|
|
1181
|
+
continue;
|
|
1182
|
+
}
|
|
1183
|
+
if (cmd === '/model') {
|
|
1184
|
+
const res = await selectModel(config.baseUrl, config.apiKey, config.model, config.provider || '', true);
|
|
1185
|
+
drawLogo();
|
|
1186
|
+
if (res) {
|
|
1187
|
+
config.model = res.model;
|
|
1188
|
+
config.maxContextTokens = res.contextLimit;
|
|
1189
|
+
await saveConfig(config);
|
|
1190
|
+
p.log.success(`Switched model to: ${pc.bold(config.model)}`);
|
|
1191
|
+
}
|
|
1192
|
+
else {
|
|
1193
|
+
p.log.info('Model selection cancelled.');
|
|
1194
|
+
}
|
|
1195
|
+
continue;
|
|
1196
|
+
}
|
|
1197
|
+
if (cmd === '/provider' || cmd === '/providers') {
|
|
1198
|
+
const newConfig = await selectProvider(config, true);
|
|
1199
|
+
drawLogo();
|
|
1200
|
+
if (newConfig) {
|
|
1201
|
+
config = newConfig;
|
|
1202
|
+
p.log.success(`Switched provider to: ${pc.bold(config.provider || 'custom')} (${config.model})`);
|
|
1203
|
+
}
|
|
1204
|
+
else {
|
|
1205
|
+
p.log.info('Provider selection cancelled.');
|
|
1206
|
+
}
|
|
1207
|
+
continue;
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
// Auto-switch to AGENT mode ONLY when a plan has been presented and user approves it
|
|
1211
|
+
if (planMode) {
|
|
1212
|
+
const msgs = history.getMessages();
|
|
1213
|
+
const lastAssistantMsg = [...msgs].reverse().find(m => m.role === 'assistant');
|
|
1214
|
+
const assistantContent = (lastAssistantMsg?.content || '').toLowerCase();
|
|
1215
|
+
// Has the AI already presented a plan in the conversation?
|
|
1216
|
+
const planWasPresented = assistantContent.includes('план') ||
|
|
1217
|
+
assistantContent.includes('plan') ||
|
|
1218
|
+
assistantContent.includes('архитектур') ||
|
|
1219
|
+
assistantContent.includes('структур') ||
|
|
1220
|
+
assistantContent.includes('приступать') ||
|
|
1221
|
+
assistantContent.includes('выполняй') ||
|
|
1222
|
+
assistantContent.includes('готово к');
|
|
1223
|
+
if (planWasPresented && !answer.includes('?')) {
|
|
1224
|
+
const norm = answer.toLowerCase().trim().replace(/[!.,?;:()«»""'']/g, ' ');
|
|
1225
|
+
const words = norm.split(/\s+/).filter(Boolean);
|
|
1226
|
+
// Ensure user is not asking a question
|
|
1227
|
+
const isQuestion = words.some(w => ['поможешь', 'помоги', 'как', 'почему', 'зачем', 'что', 'можешь', 'сможешь'].includes(w));
|
|
1228
|
+
if (!isQuestion) {
|
|
1229
|
+
const actionRoots = [
|
|
1230
|
+
'дела', 'сдела', 'выполн', 'приступ', 'начин', 'начн', 'реализ',
|
|
1231
|
+
'создав', 'создай', 'погнал', 'старту', 'утвержд', 'одобря', 'соглас',
|
|
1232
|
+
'start', 'proceed', 'execute', 'apply'
|
|
1233
|
+
];
|
|
1234
|
+
const isApproval = words.some(w => actionRoots.some(root => w.startsWith(root))) ||
|
|
1235
|
+
words.includes('давай') ||
|
|
1236
|
+
(words.length <= 2 && (words.includes('да') || words.includes('yes') || words.includes('ок') || words.includes('ok') || words.includes('go')));
|
|
1237
|
+
if (isApproval) {
|
|
1238
|
+
planMode = false;
|
|
1239
|
+
const newSys = await buildSystemPrompt(false);
|
|
1240
|
+
history.updateSystemPrompt(newSys);
|
|
1241
|
+
p.log.success(pc.bold(pc.green('⚡ Plan approved! Auto-switched to AGENT mode. Starting execution...')));
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
// Resolve @file and @image mentions
|
|
1247
|
+
const { text: cleanAnswer, attachments, images } = await resolveAtMentions(answer);
|
|
1248
|
+
let finalContent = cleanAnswer;
|
|
1249
|
+
if (attachments.length > 0) {
|
|
1250
|
+
const attachBlocks = attachments.map(a => `[Attached file: ${a.path}]\n\`\`\`\n${a.content}\n\`\`\``).join('\n\n');
|
|
1251
|
+
finalContent = `${cleanAnswer}\n\n--- Attached Files ---\n${attachBlocks}`;
|
|
1252
|
+
p.log.info(pc.cyan(`📎 Attached ${attachments.length} file(s): ${attachments.map(a => a.path).join(', ')}`));
|
|
1253
|
+
}
|
|
1254
|
+
if (images.length > 0) {
|
|
1255
|
+
p.log.info(pc.cyan(`🖼️ Attached ${images.length} image(s): ${images.map(i => i.path).join(', ')}`));
|
|
1256
|
+
}
|
|
1257
|
+
globalSnapshotManager.beginTurn();
|
|
1258
|
+
history.addMessage({
|
|
1259
|
+
role: 'user',
|
|
1260
|
+
content: finalContent,
|
|
1261
|
+
images: images.length > 0 ? images : undefined
|
|
1262
|
+
});
|
|
1263
|
+
const provider = createProvider(config);
|
|
1264
|
+
const tools = getTools(planMode);
|
|
1265
|
+
const guard = new CLIConsoleGuard(config.autoApprove);
|
|
1266
|
+
const agentConfig = {
|
|
1267
|
+
maxContextTokens: config.maxContextTokens || 100000,
|
|
1268
|
+
maxIterations: config.maxIterations || 100
|
|
1269
|
+
};
|
|
1270
|
+
const agent = new Agent(agentConfig, provider, tools, history, guard);
|
|
1271
|
+
const taskStartTime = Date.now();
|
|
1272
|
+
const s = p.spinner();
|
|
1273
|
+
s.start('Connecting...');
|
|
1274
|
+
let spinnerActive = true;
|
|
1275
|
+
let thinkingActive = false;
|
|
1276
|
+
let thinkingStartTime = 0;
|
|
1277
|
+
let textActive = false;
|
|
1278
|
+
const streamer = new MarkdownStreamer();
|
|
1279
|
+
const finishThinking = (extraNewline = true) => {
|
|
1280
|
+
if (!thinkingActive)
|
|
1281
|
+
return;
|
|
1282
|
+
const elapsedSec = thinkingStartTime > 0 ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : '0.0';
|
|
1283
|
+
const width = Math.min(process.stdout.columns || 40, 52);
|
|
1284
|
+
const label = `─── Thought for ${elapsedSec}s `;
|
|
1285
|
+
const fillLen = Math.max(2, width - label.length);
|
|
1286
|
+
process.stdout.write('\n\n' + pc.dim(label + '─'.repeat(fillLen)) + (extraNewline ? '\n\n' : '\n'));
|
|
1287
|
+
thinkingActive = false;
|
|
1288
|
+
thinkingStartTime = 0;
|
|
1289
|
+
};
|
|
1290
|
+
const abortController = new AbortController();
|
|
1291
|
+
let aborted = false;
|
|
1292
|
+
const stopGeneration = () => {
|
|
1293
|
+
if (aborted)
|
|
1294
|
+
return;
|
|
1295
|
+
aborted = true;
|
|
1296
|
+
abortController.abort();
|
|
1297
|
+
if (spinnerActive) {
|
|
1298
|
+
s.stop();
|
|
1299
|
+
spinnerActive = false;
|
|
1300
|
+
}
|
|
1301
|
+
finishThinking(true);
|
|
1302
|
+
streamer.finish();
|
|
1303
|
+
if (textActive) {
|
|
1304
|
+
process.stdout.write('\n');
|
|
1305
|
+
}
|
|
1306
|
+
console.log(pc.yellow('\n⏹ Generation stopped / Ответ остановлен (Ctrl+C).'));
|
|
1307
|
+
};
|
|
1308
|
+
const onSigInt = () => {
|
|
1309
|
+
stopGeneration();
|
|
1310
|
+
};
|
|
1311
|
+
const onRawData = (data) => {
|
|
1312
|
+
const buf = Buffer.isBuffer(data) ? data : Buffer.from(String(data));
|
|
1313
|
+
if (buf.includes(3) || buf.includes(0x03) || data === '\x03') {
|
|
1314
|
+
stopGeneration();
|
|
1315
|
+
}
|
|
1316
|
+
};
|
|
1317
|
+
activeAbortHandler = stopGeneration;
|
|
1318
|
+
process.on('SIGINT', onSigInt);
|
|
1319
|
+
if (process.stdin.isTTY) {
|
|
1320
|
+
try {
|
|
1321
|
+
process.stdin.resume();
|
|
1322
|
+
process.stdin.setRawMode(true);
|
|
1323
|
+
process.stdin.on('data', onRawData);
|
|
1324
|
+
}
|
|
1325
|
+
catch { }
|
|
1326
|
+
}
|
|
1327
|
+
try {
|
|
1328
|
+
for await (const event of agent.run(abortController.signal)) {
|
|
1329
|
+
if (aborted)
|
|
1330
|
+
break;
|
|
1331
|
+
if (event.type === 'reasoning_delta') {
|
|
1332
|
+
if (spinnerActive) {
|
|
1333
|
+
s.stop();
|
|
1334
|
+
spinnerActive = false;
|
|
1335
|
+
}
|
|
1336
|
+
if (!thinkingActive) {
|
|
1337
|
+
thinkingStartTime = Date.now();
|
|
1338
|
+
process.stdout.write('\n' + pc.bold(pc.white('› Thought:')) + '\n');
|
|
1339
|
+
thinkingActive = true;
|
|
1340
|
+
}
|
|
1341
|
+
process.stdout.write(pc.dim(event.delta));
|
|
1342
|
+
}
|
|
1343
|
+
else if (event.type === 'text_delta') {
|
|
1344
|
+
if (spinnerActive) {
|
|
1345
|
+
s.stop();
|
|
1346
|
+
spinnerActive = false;
|
|
1347
|
+
}
|
|
1348
|
+
finishThinking(true);
|
|
1349
|
+
if (!textActive) {
|
|
1350
|
+
textActive = true;
|
|
1351
|
+
}
|
|
1352
|
+
streamer.push(event.delta);
|
|
1353
|
+
}
|
|
1354
|
+
else if (event.type === 'text') {
|
|
1355
|
+
if (spinnerActive) {
|
|
1356
|
+
s.stop();
|
|
1357
|
+
spinnerActive = false;
|
|
1358
|
+
}
|
|
1359
|
+
finishThinking(true);
|
|
1360
|
+
if (!textActive) {
|
|
1361
|
+
console.log('\n' + renderMarkdown(event.content) + '\n');
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
else if (event.type === 'tool_generating') {
|
|
1365
|
+
if (!spinnerActive) {
|
|
1366
|
+
streamer.finish();
|
|
1367
|
+
finishThinking(false);
|
|
1368
|
+
s.start(pc.cyan(`⚡ Calling ${event.name}...`));
|
|
1369
|
+
spinnerActive = true;
|
|
1370
|
+
}
|
|
1371
|
+
else {
|
|
1372
|
+
const chars = event.bytes > 1000 ? `${(event.bytes / 1000).toFixed(1)}k chars` : `${event.bytes} chars`;
|
|
1373
|
+
s.message(pc.cyan(`⚡ Generating ${event.name} (${chars})...`));
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
else if (event.type === 'tool_start') {
|
|
1377
|
+
if (spinnerActive) {
|
|
1378
|
+
s.stop();
|
|
1379
|
+
spinnerActive = false;
|
|
1380
|
+
}
|
|
1381
|
+
streamer.finish();
|
|
1382
|
+
finishThinking(false);
|
|
1383
|
+
if (textActive) {
|
|
1384
|
+
process.stdout.write('\n');
|
|
1385
|
+
textActive = false;
|
|
1386
|
+
}
|
|
1387
|
+
console.log(pc.cyan(`\n${event.actionDesc}`));
|
|
1388
|
+
if (event.name !== 'ask_questions') {
|
|
1389
|
+
s.start('Executing...');
|
|
1390
|
+
spinnerActive = true;
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
else if (event.type === 'tool_end') {
|
|
1394
|
+
if (spinnerActive) {
|
|
1395
|
+
s.stop();
|
|
1396
|
+
spinnerActive = false;
|
|
1397
|
+
}
|
|
1398
|
+
if (event.result) {
|
|
1399
|
+
const firstLine = event.result.trim().split('\n')[0];
|
|
1400
|
+
if (firstLine.includes('+') || firstLine.includes('lines') || firstLine.includes('Successfully')) {
|
|
1401
|
+
console.log(pc.green(` └─ ${firstLine}`));
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
else if (event.type === 'usage') {
|
|
1406
|
+
totalSessionCost += event.usage.cost || 0;
|
|
1407
|
+
}
|
|
1408
|
+
else if (event.type === 'error') {
|
|
1409
|
+
if (spinnerActive) {
|
|
1410
|
+
s.stop();
|
|
1411
|
+
spinnerActive = false;
|
|
1412
|
+
}
|
|
1413
|
+
finishThinking(true);
|
|
1414
|
+
streamer.finish();
|
|
1415
|
+
p.log.error(pc.bold(pc.red(`❌ ${event.message}`)));
|
|
1416
|
+
}
|
|
1417
|
+
else if (event.type === 'system') {
|
|
1418
|
+
if (spinnerActive) {
|
|
1419
|
+
s.stop();
|
|
1420
|
+
spinnerActive = false;
|
|
1421
|
+
}
|
|
1422
|
+
p.log.info(event.message);
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
finishThinking(true);
|
|
1426
|
+
streamer.finish();
|
|
1427
|
+
if (spinnerActive) {
|
|
1428
|
+
s.stop();
|
|
1429
|
+
spinnerActive = false;
|
|
1430
|
+
}
|
|
1431
|
+
if (textActive) {
|
|
1432
|
+
process.stdout.write('\n');
|
|
1433
|
+
}
|
|
1434
|
+
globalSnapshotManager.finishTurn();
|
|
1435
|
+
// Auto-save session
|
|
1436
|
+
await sessionManager.save(history.getMessages(), totalSessionCost, config.model, planMode);
|
|
1437
|
+
// Check if plan was finalized in PLAN mode
|
|
1438
|
+
if (planMode && !aborted) {
|
|
1439
|
+
const lastMsg = history.getMessages().slice(-1)[0];
|
|
1440
|
+
const hasPlanReadyTool = lastPlanReady !== null;
|
|
1441
|
+
const hasPlanText = lastMsg && lastMsg.role === 'assistant' && (lastMsg.content.includes('🎯 Цель') ||
|
|
1442
|
+
lastMsg.content.includes('📁 Пошаговый') ||
|
|
1443
|
+
lastMsg.content.includes('FINAL PLAN') ||
|
|
1444
|
+
lastMsg.content.includes('План готов') ||
|
|
1445
|
+
lastMsg.content.includes('Архитектурный план') ||
|
|
1446
|
+
lastMsg.content.includes('план готов'));
|
|
1447
|
+
if (hasPlanReadyTool || hasPlanText) {
|
|
1448
|
+
resetPlanReady();
|
|
1449
|
+
console.log();
|
|
1450
|
+
let choice = '';
|
|
1451
|
+
try {
|
|
1452
|
+
choice = await select({
|
|
1453
|
+
message: pc.bold(pc.cyan('📋 Plan is ready! What would you like to do?')),
|
|
1454
|
+
choices: [
|
|
1455
|
+
{
|
|
1456
|
+
name: pc.bold(pc.green('🚀 Go (Switch to AGENT mode and start execution)')),
|
|
1457
|
+
value: 'go',
|
|
1458
|
+
description: 'Automatically switch to AGENT mode and start implementing the plan immediately'
|
|
1459
|
+
},
|
|
1460
|
+
{
|
|
1461
|
+
name: pc.bold(pc.yellow('✏️ Other (Modify, add details, or ask questions)')),
|
|
1462
|
+
value: 'other',
|
|
1463
|
+
description: 'Stay in PLAN mode and open prompt to type adjustments or additions'
|
|
1464
|
+
}
|
|
1465
|
+
]
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1468
|
+
catch { }
|
|
1469
|
+
if (choice === 'go') {
|
|
1470
|
+
planMode = false;
|
|
1471
|
+
const newSys = await buildSystemPrompt(false);
|
|
1472
|
+
history.updateSystemPrompt(newSys);
|
|
1473
|
+
p.log.success(pc.bold(pc.green('🚀 Switched to AGENT mode! Starting execution of approved plan...')));
|
|
1474
|
+
currentDraft = '';
|
|
1475
|
+
autoTriggerPrompt = 'Go!';
|
|
1476
|
+
continue;
|
|
1477
|
+
}
|
|
1478
|
+
else {
|
|
1479
|
+
currentDraft = '';
|
|
1480
|
+
continue;
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
catch (err) {
|
|
1486
|
+
finishThinking(true);
|
|
1487
|
+
streamer.finish();
|
|
1488
|
+
if (spinnerActive) {
|
|
1489
|
+
s.stop();
|
|
1490
|
+
spinnerActive = false;
|
|
1491
|
+
}
|
|
1492
|
+
if (!aborted && err.name !== 'AbortError' && !err.message?.includes('aborted')) {
|
|
1493
|
+
p.log.error(pc.bold(pc.red(`❌ ${err.message}`)));
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
finally {
|
|
1497
|
+
activeAbortHandler = null;
|
|
1498
|
+
process.removeListener('SIGINT', onSigInt);
|
|
1499
|
+
if (process.stdin.isTTY) {
|
|
1500
|
+
process.stdin.removeListener('data', onRawData);
|
|
1501
|
+
process.stdin.setRawMode(false);
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
main().catch(console.error);
|