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