@semalt-ai/code 1.7.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/.claude/settings.local.json +8 -0
- package/ARCHITECTURE.md +99 -0
- package/CLAUDE.md +349 -0
- package/index.js +69 -7
- package/lib/agent.js +577 -39
- package/lib/api.js +285 -79
- package/lib/args.js +31 -0
- package/lib/audit.js +31 -0
- package/lib/commands.js +1006 -307
- package/lib/config.js +51 -5
- package/lib/constants.js +72 -0
- package/lib/context.js +2 -6
- package/lib/metrics.js +94 -0
- package/lib/permissions.js +180 -49
- package/lib/prompts.js +96 -13
- package/lib/storage.js +96 -0
- package/lib/tools.js +1009 -35
- package/lib/ui/ansi.js +65 -0
- package/lib/ui/chat-history.js +217 -0
- package/lib/ui/create-ui.js +474 -0
- package/lib/ui/diff.js +243 -0
- package/lib/ui/input-field.js +1176 -0
- package/lib/ui/layout.js +53 -0
- package/lib/ui/legacy.js +130 -0
- package/lib/ui/status-bar.js +131 -0
- package/lib/ui/stream.js +158 -0
- package/lib/ui/utils.js +45 -0
- package/lib/ui.js +42 -598
- package/package.json +1 -1
- package/path +1 -0
package/lib/commands.js
CHANGED
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
|
-
const readline = require('readline');
|
|
5
4
|
|
|
6
|
-
const { CONFIG_PATH, DEFAULT_API_TIMEOUT_MS } = require('./constants');
|
|
7
|
-
const {
|
|
5
|
+
const { CONFIG_PATH, DEFAULT_API_TIMEOUT_MS, TAG_REGISTRY } = require('./constants');
|
|
6
|
+
const { configShow } = require('./config');
|
|
7
|
+
const { SYSTEM_PROMPT } = require('./prompts');
|
|
8
|
+
const { SessionStorage } = require('./storage');
|
|
9
|
+
const { getSkippedOps, setUIActive } = require('./tools');
|
|
10
|
+
const { AUDIT_LOG } = require('./audit');
|
|
11
|
+
|
|
12
|
+
function formatTimeAgo(ts) {
|
|
13
|
+
const diffMs = Date.now() - ts;
|
|
14
|
+
const diffMin = Math.floor(diffMs / 60000);
|
|
15
|
+
if (diffMin < 1) return 'just now';
|
|
16
|
+
if (diffMin < 60) return `${diffMin}m ago`;
|
|
17
|
+
const diffHr = Math.floor(diffMin / 60);
|
|
18
|
+
if (diffHr < 24) return `${diffHr}h ago`;
|
|
19
|
+
return `${Math.floor(diffHr / 24)}d ago`;
|
|
20
|
+
}
|
|
8
21
|
|
|
9
22
|
function createCommands({
|
|
10
23
|
getConfig,
|
|
@@ -18,6 +31,7 @@ function createCommands({
|
|
|
18
31
|
}) {
|
|
19
32
|
const {
|
|
20
33
|
BOLD,
|
|
34
|
+
BG_SELECTED,
|
|
21
35
|
FG_BLUE,
|
|
22
36
|
FG_CYAN,
|
|
23
37
|
FG_DARK,
|
|
@@ -27,20 +41,24 @@ function createCommands({
|
|
|
27
41
|
FG_TEAL,
|
|
28
42
|
FG_YELLOW,
|
|
29
43
|
RST,
|
|
44
|
+
StatusBar,
|
|
30
45
|
getCols,
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
46
|
+
hr,
|
|
47
|
+
boxLine,
|
|
48
|
+
interactiveSelect,
|
|
49
|
+
createUI,
|
|
35
50
|
} = ui;
|
|
36
51
|
const {
|
|
37
52
|
chatStream,
|
|
38
53
|
chatSync,
|
|
54
|
+
dashboardCreateChat,
|
|
55
|
+
dashboardGetChat,
|
|
39
56
|
dashboardGetModelForCli,
|
|
57
|
+
dashboardListChats,
|
|
40
58
|
dashboardListModels,
|
|
41
59
|
dashboardLogout,
|
|
60
|
+
dashboardSaveMessages,
|
|
42
61
|
dashboardWhoAmI,
|
|
43
|
-
describeModelProfile,
|
|
44
62
|
estimateTokens,
|
|
45
63
|
getCliLoginStatus,
|
|
46
64
|
requestCliLogin,
|
|
@@ -54,201 +72,1004 @@ function createCommands({
|
|
|
54
72
|
return ` ${FG_CYAN}${label}:${RST} ${FG_GRAY}${value}${RST}`;
|
|
55
73
|
}
|
|
56
74
|
|
|
75
|
+
async function resolveTokenLimit(model) {
|
|
76
|
+
const config = getConfig();
|
|
77
|
+
if (config.auth_token && config.dashboard_model_id) {
|
|
78
|
+
try {
|
|
79
|
+
const resp = await dashboardGetModelForCli(config.dashboard_model_id);
|
|
80
|
+
const m = resp && resp.model ? resp.model : null;
|
|
81
|
+
if (m) {
|
|
82
|
+
const limit = (Number.isInteger(m.context_length) && m.context_length > 0 ? m.context_length : null)
|
|
83
|
+
|| (Number.isInteger(m.max_tokens) && m.max_tokens > 0 ? m.max_tokens : null);
|
|
84
|
+
if (limit) {
|
|
85
|
+
// Persist so chatStream's proactive trimming can use it without an extra API call.
|
|
86
|
+
if (config.context_length !== limit) {
|
|
87
|
+
setConfig({ ...config, context_length: limit });
|
|
88
|
+
}
|
|
89
|
+
return limit;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} catch {}
|
|
93
|
+
}
|
|
94
|
+
const localModels = Array.isArray(config.models) ? config.models : [];
|
|
95
|
+
const match = localModels.find(
|
|
96
|
+
(m) => m.model === model || (m.api_base === config.api_base && m.model === config.default_model)
|
|
97
|
+
);
|
|
98
|
+
if (match && Number.isInteger(match.context_length) && match.context_length > 0) return match.context_length;
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function printBanner() {
|
|
103
|
+
const w = Math.min(getCols() - 4, 60);
|
|
104
|
+
console.log();
|
|
105
|
+
console.log(` ${FG_DARK}╭${'─'.repeat(w + 1)}╮${RST}`);
|
|
106
|
+
console.log(boxLine('', w));
|
|
107
|
+
console.log(boxLine(`${FG_TEAL}${BOLD}◆ Semalt.AI${RST}`, w));
|
|
108
|
+
console.log(boxLine(`${FG_GRAY}Self-hosted AI coding assistant${RST}`, w));
|
|
109
|
+
console.log(boxLine('', w));
|
|
110
|
+
console.log(` ${FG_DARK}╰${'─'.repeat(w + 1)}╯${RST}`);
|
|
111
|
+
console.log();
|
|
112
|
+
}
|
|
113
|
+
|
|
57
114
|
async function cmdChat(opts) {
|
|
58
|
-
|
|
115
|
+
const { chatHistory, statusBar, inputField, layout, destroy, redrawFixed } = createUI({
|
|
116
|
+
showThink: opts.showThink || false,
|
|
117
|
+
onInterrupt: (destroyFn) => {
|
|
118
|
+
saveSession();
|
|
119
|
+
destroyFn();
|
|
120
|
+
if (sessionMetrics) console.log('\n' + sessionMetrics.summary());
|
|
121
|
+
if (currentChatId !== null) {
|
|
122
|
+
console.log(` ${FG_DARK}Resume this chat: ${FG_CYAN}semalt-code --resume ${currentChatId}${RST}`);
|
|
123
|
+
}
|
|
124
|
+
console.log(`\n ${FG_GRAY}Goodbye!${RST}\n`);
|
|
125
|
+
process.exit(0);
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
setUIActive(true);
|
|
130
|
+
|
|
131
|
+
permissionManager.setUICallbacks({
|
|
132
|
+
onAddMessage: (msg) => chatHistory.addMessage(msg),
|
|
133
|
+
onRerenderMessage: (id) => chatHistory.rerenderById(id),
|
|
134
|
+
onCollapseMessage: (id) => chatHistory.collapseById(id),
|
|
135
|
+
onRemoveMessage: (id) => chatHistory.removeById(id),
|
|
136
|
+
onCaptureNavigation: (handler) => {
|
|
137
|
+
inputField.captureNavigation(handler);
|
|
138
|
+
return () => inputField.releaseNavigation();
|
|
139
|
+
},
|
|
140
|
+
captureSelect: (menu) => inputField.captureSelect(menu),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
inputField.on('expand', () => chatHistory.toggleLastExpand());
|
|
144
|
+
|
|
59
145
|
const cwd = process.cwd();
|
|
60
146
|
let currentModel = opts.model || getConfig().default_model;
|
|
61
|
-
let
|
|
147
|
+
let resolvedTokenLimit = await resolveTokenLimit(currentModel);
|
|
148
|
+
statusBar.setModel(currentModel);
|
|
149
|
+
let sessionMetrics = null;
|
|
150
|
+
// system prompt is prepended fresh on every API call in agent.js — never stored in history
|
|
151
|
+
let messages = [];
|
|
152
|
+
let currentChatId = null;
|
|
153
|
+
let savedUpTo = 0;
|
|
154
|
+
let debugMode = !!opts.debug;
|
|
155
|
+
|
|
156
|
+
// Resolve system prompt override from --system-prompt file if provided
|
|
157
|
+
let resolvedSystemPrompt = null;
|
|
158
|
+
if (opts.systemPromptFile) {
|
|
159
|
+
try {
|
|
160
|
+
resolvedSystemPrompt = fs.readFileSync(opts.systemPromptFile, 'utf8');
|
|
161
|
+
} catch (err) {
|
|
162
|
+
// will be shown after UI initializes
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const storage = new SessionStorage();
|
|
166
|
+
const sessionStart = Date.now();
|
|
167
|
+
let session = {
|
|
168
|
+
id: storage.generateId(),
|
|
169
|
+
created_at: sessionStart,
|
|
170
|
+
model: currentModel,
|
|
171
|
+
messages: [],
|
|
172
|
+
stats: { total_tokens: 0, duration_sec: 0 },
|
|
173
|
+
};
|
|
62
174
|
|
|
63
|
-
|
|
64
|
-
|
|
175
|
+
// Seed Ctrl+R search with local session summaries
|
|
176
|
+
function refreshInputSearchItems(extraItems) {
|
|
177
|
+
const sessions = storage.list();
|
|
178
|
+
const items = sessions.map(s => ({
|
|
179
|
+
type: 'session',
|
|
180
|
+
text: (() => {
|
|
181
|
+
const date = new Date(s.created_at).toISOString().slice(0, 16).replace('T', ' ');
|
|
182
|
+
return `${date} ${s.model || ''} (${s.message_count} msgs)`;
|
|
183
|
+
})(),
|
|
184
|
+
}));
|
|
185
|
+
if (extraItems) items.push(...extraItems);
|
|
186
|
+
inputField.setSearchItems(items);
|
|
187
|
+
}
|
|
188
|
+
refreshInputSearchItems();
|
|
189
|
+
|
|
190
|
+
// Banner — write at row 1, then compact the layout so the fixed panels sit
|
|
191
|
+
// immediately below the banner with no blank gap. The layout grows as
|
|
192
|
+
// messages are added (dynamic layout mode) until it reaches full-screen.
|
|
193
|
+
if (layout) {
|
|
194
|
+
const BANNER_LINES = 8; // blank + top-border + empty + title + desc + empty + bottom-border + blank
|
|
195
|
+
const w = Math.min(getCols() - 4, 60);
|
|
196
|
+
process.stdout.write('\x1b[1;1H');
|
|
197
|
+
process.stdout.write([
|
|
198
|
+
``,
|
|
199
|
+
` ${FG_DARK}╭${'─'.repeat(w + 1)}╮${RST}`,
|
|
200
|
+
boxLine('', w),
|
|
201
|
+
boxLine(`${FG_TEAL}${BOLD}◆ Semalt.AI${RST}`, w),
|
|
202
|
+
boxLine(`${FG_GRAY}Self-hosted AI coding assistant${RST}`, w),
|
|
203
|
+
boxLine('', w),
|
|
204
|
+
` ${FG_DARK}╰${'─'.repeat(w + 1)}╯${RST}`,
|
|
205
|
+
``,
|
|
206
|
+
].join('\n') + '\n');
|
|
207
|
+
|
|
208
|
+
// Keep historyStart = 1 so the banner is inside the scroll region.
|
|
209
|
+
// Growing mode uses _contentLines to position the first message below the
|
|
210
|
+
// banner (at row BANNER_LINES + 1). When the terminal fills up the banner
|
|
211
|
+
// scrolls naturally into the terminal scrollback — nothing disappears behind
|
|
212
|
+
// a fixed header.
|
|
213
|
+
layout._contentLines = BANNER_LINES;
|
|
214
|
+
layout.rows = BANNER_LINES + 1 + layout.inputHeight + 3;
|
|
215
|
+
|
|
216
|
+
// Erase the stale full-screen panels createUI drew before we compacted.
|
|
217
|
+
process.stdout.write(`\x1b[${layout.rows + 1};1H\x1b[J`);
|
|
218
|
+
|
|
219
|
+
redrawFixed();
|
|
220
|
+
}
|
|
65
221
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
222
|
+
// Welcome message
|
|
223
|
+
chatHistory.addMessage({
|
|
224
|
+
role: 'system',
|
|
225
|
+
content: `◆ Semalt.AI · ${currentModel} · ${cwd}\nType /help for commands.`,
|
|
226
|
+
});
|
|
69
227
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
});
|
|
228
|
+
if (opts.systemPromptFile && resolvedSystemPrompt === null) {
|
|
229
|
+
chatHistory.addMessage({ role: 'system', content: `✗ Could not read system prompt file: ${opts.systemPromptFile}`, isError: true });
|
|
230
|
+
} else if (opts.systemPromptFile && resolvedSystemPrompt !== null) {
|
|
231
|
+
chatHistory.addMessage({ role: 'system', content: `✓ Using system prompt from: ${opts.systemPromptFile}` });
|
|
232
|
+
}
|
|
76
233
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
234
|
+
function saveSession() {
|
|
235
|
+
session.model = currentModel;
|
|
236
|
+
session.messages = messages;
|
|
237
|
+
session.stats.duration_sec = Math.round((Date.now() - sessionStart) / 1000);
|
|
238
|
+
session.stats.total_tokens = messages.reduce((s, m) => s + Math.round((m.content || '').length / 4), 0);
|
|
239
|
+
try { storage.save(session); } catch {}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function createChatIfNeeded(firstUserText) {
|
|
243
|
+
const config = getConfig();
|
|
244
|
+
if (currentChatId !== null || !config.auth_token || !config.dashboard_model_id) return;
|
|
245
|
+
try {
|
|
246
|
+
const title = firstUserText.length > 60 ? firstUserText.slice(0, 57) + '...' : firstUserText;
|
|
247
|
+
const resp = await dashboardCreateChat(title, config.dashboard_model_id);
|
|
248
|
+
if (resp && resp.chat && resp.chat.id) currentChatId = resp.chat.id;
|
|
249
|
+
} catch {}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function saveTurnToDashboard() {
|
|
253
|
+
if (currentChatId === null) return;
|
|
254
|
+
const newMessages = messages.slice(savedUpTo).filter((m) => m.role !== 'system');
|
|
255
|
+
if (!newMessages.length) return;
|
|
256
|
+
try { await dashboardSaveMessages(currentChatId, newMessages); savedUpTo = messages.length; } catch {}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const HISTORY_DISPLAY_TURNS = 3; // user+assistant pairs to show on load
|
|
260
|
+
|
|
261
|
+
function displayLoadedMessages(loadedMessages) {
|
|
262
|
+
chatHistory.clearMessages();
|
|
263
|
+
const visible = loadedMessages.filter(
|
|
264
|
+
(m) => (m.role === 'user' || m.role === 'assistant') &&
|
|
265
|
+
(typeof m.content === 'string' ? m.content : '').trim()
|
|
266
|
+
);
|
|
267
|
+
const skip = Math.max(0, visible.length - HISTORY_DISPLAY_TURNS * 2);
|
|
268
|
+
if (skip > 0) {
|
|
269
|
+
chatHistory.addMessage({ role: 'system', content: `… ${skip} earlier messages not shown` });
|
|
80
270
|
}
|
|
271
|
+
for (const m of visible.slice(skip)) {
|
|
272
|
+
chatHistory.addMessage({
|
|
273
|
+
role: m.role,
|
|
274
|
+
content: typeof m.content === 'string' ? m.content : '',
|
|
275
|
+
ts: m.created_at ? new Date(m.created_at) : (m.ts ? new Date(m.ts) : new Date()),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
81
279
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
280
|
+
// --resume: load previous chat
|
|
281
|
+
if (opts.resume) {
|
|
282
|
+
const resumeId = parseInt(opts.resume, 10);
|
|
283
|
+
if (!isNaN(resumeId)) {
|
|
284
|
+
try {
|
|
285
|
+
const chatData = await dashboardGetChat(resumeId);
|
|
286
|
+
const loaded = chatData && chatData.messages ? chatData.messages : [];
|
|
287
|
+
for (const m of loaded) messages.push({ role: m.role, content: m.content });
|
|
288
|
+
currentChatId = resumeId;
|
|
289
|
+
savedUpTo = messages.length;
|
|
290
|
+
const title = chatData.chat && chatData.chat.title ? chatData.chat.title : `#${resumeId}`;
|
|
291
|
+
displayLoadedMessages(loaded);
|
|
292
|
+
chatHistory.addMessage({ role: 'system', content: `✓ Resumed: ${title} (${loaded.length} messages)` });
|
|
293
|
+
} catch (error) {
|
|
294
|
+
chatHistory.addMessage({ role: 'system', content: `✗ Could not resume chat: ${error.message}`, isError: true });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Pending selection state (for in-chat /history, /models, /chats)
|
|
300
|
+
let pendingAction = null;
|
|
301
|
+
const PAGE_SIZE = 5;
|
|
302
|
+
let listMsg = null;
|
|
303
|
+
|
|
304
|
+
// In-place progress indicator for chunked HTTP fetches (http_get + http_get_next)
|
|
305
|
+
let httpFetchMsg = null;
|
|
306
|
+
|
|
307
|
+
function showHttpFetchProgress(url, part, total) {
|
|
308
|
+
const maxUrl = Math.max(20, getCols() - 35);
|
|
309
|
+
const shortUrl = url.length > maxUrl ? url.slice(0, maxUrl - 1) + '…' : url;
|
|
310
|
+
const content = `Fetching URL · ${shortUrl} · Part ${part}/${total}`;
|
|
311
|
+
if (!httpFetchMsg) {
|
|
312
|
+
httpFetchMsg = { role: 'tool', tag: 'http_get', content, id: `http-fetch-${Date.now()}` };
|
|
313
|
+
chatHistory.addMessage(httpFetchMsg);
|
|
314
|
+
} else {
|
|
315
|
+
httpFetchMsg.content = content;
|
|
316
|
+
chatHistory.rerenderById(httpFetchMsg.id);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function finalizeHttpFetch() {
|
|
321
|
+
if (!httpFetchMsg) return;
|
|
322
|
+
chatHistory.removeById(httpFetchMsg.id);
|
|
323
|
+
httpFetchMsg = null;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function getNavSearchText(type, item) {
|
|
327
|
+
if (type === 'history') {
|
|
328
|
+
const date = new Date(item.created_at).toISOString().slice(0, 16);
|
|
329
|
+
return `${date} ${item.model || ''} ${item.message_count || ''}`;
|
|
330
|
+
} else if (type === 'chats') {
|
|
331
|
+
return `${item.title || ''} ${item.model_name || ''}`;
|
|
332
|
+
} else if (type === 'models') {
|
|
333
|
+
return `${item.name || ''} ${item.model_id || ''}`;
|
|
334
|
+
}
|
|
335
|
+
return '';
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function buildItemDetail(type, item) {
|
|
339
|
+
const cfg = getConfig();
|
|
340
|
+
const maxDetail = Math.max(20, getCols() - 12);
|
|
341
|
+
let detail = '';
|
|
342
|
+
if (type === 'history') {
|
|
343
|
+
const date = new Date(item.created_at).toISOString().slice(0, 16).replace('T', ' ');
|
|
344
|
+
detail = `${date} ${(item.model || '').slice(0, 20)} (${item.message_count} msgs)`;
|
|
345
|
+
} else if (type === 'chats') {
|
|
346
|
+
const date = item.updated_at ? String(item.updated_at).slice(0, 10) : '';
|
|
347
|
+
detail = `${item.title} · ${item.model_name || ''} · ${date}`;
|
|
348
|
+
} else if (type === 'models') {
|
|
349
|
+
const active = item.base_url === cfg.api_base && item.model_id === cfg.default_model;
|
|
350
|
+
detail = `${active ? '●' : ' '} ${item.name} · ${item.model_id}`;
|
|
351
|
+
}
|
|
352
|
+
if (detail.length > maxDetail) detail = detail.slice(0, maxDetail - 1) + '…';
|
|
353
|
+
return detail;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function buildListContent() {
|
|
357
|
+
if (!pendingAction) return '';
|
|
358
|
+
const { type, items, displayItems: di, stepIdx, searchQuery } = pendingAction;
|
|
359
|
+
const items2 = di || items;
|
|
360
|
+
const page = Math.floor(stepIdx / PAGE_SIZE);
|
|
361
|
+
const pageCount = Math.ceil(items2.length / PAGE_SIZE);
|
|
362
|
+
const pageStart = page * PAGE_SIZE;
|
|
363
|
+
const pageItems = items2.slice(pageStart, pageStart + PAGE_SIZE);
|
|
364
|
+
const localIdx = stepIdx - pageStart;
|
|
365
|
+
const titleMap = { history: 'Sessions', chats: 'Chats', models: 'Models' };
|
|
366
|
+
const pageLabel = pageCount > 1 ? ` · Page ${page + 1}/${pageCount}` : '';
|
|
367
|
+
const countLabel = items2.length > 0 ? `[${stepIdx + 1}/${items2.length}]` : '[0 results]';
|
|
368
|
+
const searchLabel = searchQuery ? ` · filter: '${searchQuery}'` : '';
|
|
369
|
+
const parts = [`${titleMap[type] || type} ${countLabel}${pageLabel}${searchLabel}`, ''];
|
|
370
|
+
for (let i = 0; i < pageItems.length; i++) {
|
|
371
|
+
const item = pageItems[i];
|
|
372
|
+
const sel = i === localIdx;
|
|
373
|
+
const detail = buildItemDetail(type, item);
|
|
374
|
+
parts.push(sel ? `\x1b[1m\x1b[36m ► ${detail}` : ` ${detail}`);
|
|
375
|
+
}
|
|
376
|
+
// Pad to a fixed height so rerenderById always clears the same number of rows,
|
|
377
|
+
// regardless of how many items the current page has (last page may have fewer).
|
|
378
|
+
while (parts.length < PAGE_SIZE + 2) parts.push('');
|
|
379
|
+
return parts.join('\n');
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function collapseListMsg(type, item) {
|
|
383
|
+
if (!listMsg) return;
|
|
384
|
+
const titleMap = { history: 'Sessions', chats: 'Chats', models: 'Models' };
|
|
385
|
+
listMsg.content = `${titleMap[type] || type} · ${buildItemDetail(type, item)}`;
|
|
386
|
+
chatHistory.collapseById(listMsg.id);
|
|
387
|
+
listMsg = null;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function showPendingStep() {
|
|
391
|
+
if (!pendingAction) return;
|
|
392
|
+
const content = buildListContent();
|
|
393
|
+
if (!listMsg) {
|
|
394
|
+
listMsg = { role: 'system', content, id: `list-${Date.now()}` };
|
|
395
|
+
chatHistory.addMessage(listMsg);
|
|
396
|
+
} else {
|
|
397
|
+
listMsg.content = content;
|
|
398
|
+
chatHistory.rerenderById(listMsg.id);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function finalizeListMsg() {
|
|
403
|
+
if (listMsg) {
|
|
404
|
+
chatHistory.removeById(listMsg.id);
|
|
405
|
+
listMsg = null;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function activateNavCapture() {
|
|
410
|
+
inputField.captureNavigation(async (action) => {
|
|
411
|
+
if (!pendingAction) { inputField.releaseNavigation(); return; }
|
|
412
|
+
const { items, displayItems: di, stepIdx } = pendingAction;
|
|
413
|
+
const activeItems = di || items;
|
|
414
|
+
|
|
415
|
+
if (action.startsWith('search:')) {
|
|
416
|
+
const query = action.slice(7);
|
|
417
|
+
if (!query) {
|
|
418
|
+
pendingAction = { ...pendingAction, displayItems: null, searchQuery: '', stepIdx: 0 };
|
|
419
|
+
} else {
|
|
420
|
+
const filtered = items.filter(item => getNavSearchText(pendingAction.type, item).toLowerCase().includes(query.toLowerCase()));
|
|
421
|
+
pendingAction = { ...pendingAction, displayItems: filtered, searchQuery: query, stepIdx: 0 };
|
|
422
|
+
}
|
|
423
|
+
showPendingStep();
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (action === 'next') {
|
|
428
|
+
pendingAction = { ...pendingAction, stepIdx: activeItems.length ? (stepIdx + 1) % activeItems.length : 0 };
|
|
429
|
+
showPendingStep();
|
|
430
|
+
} else if (action === 'prev') {
|
|
431
|
+
pendingAction = { ...pendingAction, stepIdx: activeItems.length ? (stepIdx - 1 + activeItems.length) % activeItems.length : 0 };
|
|
432
|
+
showPendingStep();
|
|
433
|
+
} else if (action === 'select') {
|
|
434
|
+
if (!activeItems.length) return;
|
|
435
|
+
inputField.releaseNavigation();
|
|
436
|
+
const si = pendingAction.stepIdx;
|
|
437
|
+
collapseListMsg(pendingAction.type, activeItems[si]);
|
|
438
|
+
statusBar.update('idle');
|
|
439
|
+
await handlePendingSelection(si);
|
|
440
|
+
inputField.setDisabled(false);
|
|
441
|
+
} else if (action === 'cancel') {
|
|
442
|
+
inputField.releaseNavigation();
|
|
443
|
+
finalizeListMsg();
|
|
444
|
+
chatHistory.addMessage({ role: 'system', content: 'Cancelled.' });
|
|
445
|
+
pendingAction = null;
|
|
446
|
+
statusBar.update('idle');
|
|
447
|
+
inputField.setDisabled(false);
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function handlePendingSelection(idx) {
|
|
453
|
+
if (!pendingAction) return;
|
|
454
|
+
const { type, items, displayItems: di } = pendingAction;
|
|
455
|
+
const activeItems = di || items;
|
|
456
|
+
pendingAction = null;
|
|
457
|
+
|
|
458
|
+
if (type === 'history') {
|
|
459
|
+
const loaded = storage.load(activeItems[idx].id);
|
|
460
|
+
if (loaded) {
|
|
461
|
+
messages = (loaded.messages || []).filter((m) => m.role !== 'system');
|
|
462
|
+
session = { id: loaded.id, created_at: loaded.created_at, model: loaded.model, messages, stats: loaded.stats || { total_tokens: 0, duration_sec: 0 } };
|
|
463
|
+
currentChatId = null; savedUpTo = 0;
|
|
464
|
+
if (loaded.model && loaded.model !== currentModel) {
|
|
465
|
+
currentModel = loaded.model;
|
|
466
|
+
resolvedTokenLimit = await resolveTokenLimit(currentModel);
|
|
467
|
+
statusBar.setModel(currentModel);
|
|
468
|
+
}
|
|
469
|
+
displayLoadedMessages(messages);
|
|
470
|
+
chatHistory.addMessage({ role: 'system', content: `✓ Session loaded. Model → ${currentModel}` });
|
|
471
|
+
}
|
|
472
|
+
} else if (type === 'chats') {
|
|
473
|
+
const selectedChat = activeItems[idx];
|
|
474
|
+
try {
|
|
475
|
+
const chatData = await dashboardGetChat(selectedChat.id);
|
|
476
|
+
const loaded = chatData && chatData.messages ? chatData.messages : [];
|
|
477
|
+
messages = loaded.map((m) => ({ role: m.role, content: m.content }));
|
|
478
|
+
currentChatId = selectedChat.id; savedUpTo = messages.length;
|
|
479
|
+
displayLoadedMessages(loaded);
|
|
480
|
+
chatHistory.addMessage({ role: 'system', content: `✓ Resumed: ${selectedChat.title} (${loaded.length} messages)` });
|
|
481
|
+
} catch (err) {
|
|
482
|
+
chatHistory.addMessage({ role: 'system', content: `✗ ${err.message}`, isError: true });
|
|
483
|
+
}
|
|
484
|
+
} else if (type === 'models') {
|
|
485
|
+
const selectedModel = activeItems[idx];
|
|
486
|
+
try {
|
|
487
|
+
const credResp = await dashboardGetModelForCli(selectedModel.id);
|
|
488
|
+
const model = credResp && credResp.model ? credResp.model : null;
|
|
489
|
+
if (!model) { chatHistory.addMessage({ role: 'system', content: '✗ Unable to load model.', isError: true }); return; }
|
|
490
|
+
const contextLength = (Number.isInteger(model.context_length) && model.context_length > 0 ? model.context_length : null)
|
|
491
|
+
|| (Number.isInteger(model.max_tokens) && model.max_tokens > 0 ? model.max_tokens : null);
|
|
492
|
+
const config = getConfig();
|
|
493
|
+
const updated = { ...config, api_base: model.base_url, api_key: model.api_key, default_model: model.model_id, dashboard_model_id: model.id };
|
|
494
|
+
if (contextLength !== null) updated.context_length = contextLength;
|
|
495
|
+
setConfig(updated);
|
|
496
|
+
currentModel = model.model_id;
|
|
497
|
+
resolvedTokenLimit = await resolveTokenLimit(currentModel);
|
|
498
|
+
statusBar.setModel(currentModel);
|
|
499
|
+
currentChatId = null;
|
|
500
|
+
chatHistory.addMessage({ role: 'system', content: `✓ Model → ${model.name} (${model.model_id})` });
|
|
501
|
+
statusBar.update('idle');
|
|
502
|
+
} catch (err) {
|
|
503
|
+
chatHistory.addMessage({ role: 'system', content: `✗ ${err.message}`, isError: true });
|
|
85
504
|
}
|
|
86
|
-
continue;
|
|
87
505
|
}
|
|
506
|
+
}
|
|
88
507
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
508
|
+
let resolveExit;
|
|
509
|
+
const exitPromise = new Promise((r) => { resolveExit = r; });
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
statusBar.update('idle');
|
|
513
|
+
|
|
514
|
+
inputField.onSubmit(async (text) => {
|
|
515
|
+
// Handle pending selection (text fallback for non-TTY; TTY uses captureNavigation)
|
|
516
|
+
if (pendingAction) {
|
|
517
|
+
inputField.releaseNavigation();
|
|
518
|
+
const t = text.trim().toLowerCase();
|
|
519
|
+
const { items, displayItems: di, stepIdx, type } = pendingAction;
|
|
520
|
+
const activeItems = di || items;
|
|
521
|
+
if (t === 's' || t === 'select' || t === 'y' || t === 'yes') {
|
|
522
|
+
collapseListMsg(type, activeItems[stepIdx]);
|
|
523
|
+
statusBar.update('idle');
|
|
524
|
+
await handlePendingSelection(stepIdx);
|
|
525
|
+
inputField.setDisabled(false);
|
|
526
|
+
return;
|
|
527
|
+
} else if (t === 'n' || t === 'next') {
|
|
528
|
+
pendingAction = { ...pendingAction, stepIdx: (stepIdx + 1) % items.length };
|
|
529
|
+
showPendingStep();
|
|
530
|
+
activateNavCapture();
|
|
531
|
+
return;
|
|
532
|
+
} else if (t === 'p' || t === 'prev') {
|
|
533
|
+
pendingAction = { ...pendingAction, stepIdx: (stepIdx - 1 + items.length) % items.length };
|
|
534
|
+
showPendingStep();
|
|
535
|
+
activateNavCapture();
|
|
536
|
+
return;
|
|
537
|
+
} else if (t === 'c' || t === 'cancel') {
|
|
538
|
+
finalizeListMsg();
|
|
539
|
+
chatHistory.addMessage({ role: 'system', content: 'Cancelled.' });
|
|
540
|
+
pendingAction = null;
|
|
541
|
+
statusBar.update('idle');
|
|
542
|
+
inputField.setDisabled(false);
|
|
543
|
+
return;
|
|
544
|
+
} else {
|
|
545
|
+
// Not a nav key: close nav silently and let the message go to AI
|
|
546
|
+
finalizeListMsg();
|
|
547
|
+
pendingAction = null;
|
|
548
|
+
statusBar.update('idle');
|
|
549
|
+
// fall through to AI processing below
|
|
550
|
+
}
|
|
551
|
+
}
|
|
92
552
|
|
|
553
|
+
// Exit
|
|
93
554
|
if (['exit', 'quit', '/exit', '/quit'].includes(text.toLowerCase())) {
|
|
94
|
-
|
|
555
|
+
saveSession();
|
|
556
|
+
destroy();
|
|
557
|
+
resolveExit();
|
|
95
558
|
return;
|
|
96
559
|
}
|
|
97
560
|
|
|
98
561
|
if (text === '/help') {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
562
|
+
chatHistory.addMessage({
|
|
563
|
+
role: 'system',
|
|
564
|
+
content: [
|
|
565
|
+
'Commands:',
|
|
566
|
+
' /file <path> Load file or dir into context',
|
|
567
|
+
' /history Browse local sessions',
|
|
568
|
+
' /chats Browse saved dashboard chats',
|
|
569
|
+
' /new Start fresh conversation',
|
|
570
|
+
' /login Authorize via browser',
|
|
571
|
+
' /whoami Show current user',
|
|
572
|
+
' /logout Clear CLI login',
|
|
573
|
+
' /model Show current model',
|
|
574
|
+
' /model <name> Switch model manually',
|
|
575
|
+
' /models Choose from dashboard models',
|
|
576
|
+
' /clear Clear conversation',
|
|
577
|
+
' /compact Show token usage',
|
|
578
|
+
' /shell <cmd> Run shell command',
|
|
579
|
+
' !<cmd> Run shell command',
|
|
580
|
+
' /approve Toggle auto-approve',
|
|
581
|
+
' /debug [off] Enable debug output + show last 5 audit entries',
|
|
582
|
+
' /config Show config',
|
|
583
|
+
' exit Quit',
|
|
584
|
+
].join('\n'),
|
|
585
|
+
});
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (text === '/history') {
|
|
590
|
+
const sessions = storage.list();
|
|
591
|
+
if (!sessions.length) { chatHistory.addMessage({ role: 'system', content: 'No saved sessions.' }); return; }
|
|
592
|
+
refreshInputSearchItems();
|
|
593
|
+
chatHistory.addMessage({ role: 'system', content: '/history' });
|
|
594
|
+
pendingAction = { type: 'history', items: sessions, stepIdx: 0 };
|
|
595
|
+
showPendingStep();
|
|
596
|
+
statusBar.update('waiting', 'Select session...');
|
|
597
|
+
activateNavCapture();
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (text === '/chats') {
|
|
602
|
+
const config = getConfig();
|
|
603
|
+
if (!config.auth_token) { chatHistory.addMessage({ role: 'system', content: 'Not logged in. Run /login first.' }); return; }
|
|
604
|
+
inputField.setDisabled(true);
|
|
605
|
+
statusBar.update('thinking', 'Loading chats...');
|
|
606
|
+
try {
|
|
607
|
+
const response = await dashboardListChats();
|
|
608
|
+
const chats = Array.isArray(response && response.chats) ? response.chats : [];
|
|
609
|
+
if (!chats.length) { chatHistory.addMessage({ role: 'system', content: 'No saved chats found.' }); statusBar.update('idle'); }
|
|
610
|
+
else {
|
|
611
|
+
refreshInputSearchItems(chats.map(c => ({ type: 'chat', text: c.title || `chat #${c.id}` })));
|
|
612
|
+
chatHistory.addMessage({ role: 'system', content: '/chats' });
|
|
613
|
+
pendingAction = { type: 'chats', items: chats, stepIdx: 0 };
|
|
614
|
+
showPendingStep();
|
|
615
|
+
statusBar.update('waiting', 'Select chat...');
|
|
616
|
+
activateNavCapture();
|
|
617
|
+
}
|
|
618
|
+
} catch (err) {
|
|
619
|
+
chatHistory.addMessage({ role: 'system', content: `✗ ${err.message}`, isError: true });
|
|
620
|
+
statusBar.update('idle');
|
|
621
|
+
}
|
|
622
|
+
inputField.setDisabled(false);
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (text === '/new') {
|
|
627
|
+
messages = [];
|
|
628
|
+
currentChatId = null; savedUpTo = 0;
|
|
629
|
+
permissionManager.clear();
|
|
630
|
+
chatHistory.addMessage({ role: 'system', content: '✓ Started new conversation.' });
|
|
631
|
+
return;
|
|
119
632
|
}
|
|
120
633
|
|
|
121
634
|
if (text === '/login') {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
635
|
+
inputField.setDisabled(true);
|
|
636
|
+
statusBar.update('thinking', 'Starting login...');
|
|
637
|
+
await _loginFlow(chatHistory, statusBar);
|
|
638
|
+
statusBar.update('idle');
|
|
639
|
+
inputField.setDisabled(false);
|
|
640
|
+
return;
|
|
125
641
|
}
|
|
126
642
|
|
|
127
643
|
if (text === '/whoami') {
|
|
128
|
-
|
|
129
|
-
|
|
644
|
+
inputField.setDisabled(true);
|
|
645
|
+
statusBar.update('thinking', 'Loading...');
|
|
646
|
+
try {
|
|
647
|
+
const response = await dashboardWhoAmI();
|
|
648
|
+
const user = response && response.user ? response.user : null;
|
|
649
|
+
if (!user) { chatHistory.addMessage({ role: 'system', content: '✗ Unable to load current user.', isError: true }); }
|
|
650
|
+
else {
|
|
651
|
+
chatHistory.addMessage({ role: 'system', content: `Current User:\n ID: ${user.id}\n Email: ${user.email || '-'}\n Name: ${user.name || '-'}\n Provider: ${user.provider || '-'}` });
|
|
652
|
+
}
|
|
653
|
+
} catch (err) {
|
|
654
|
+
chatHistory.addMessage({ role: 'system', content: `✗ ${err.message}`, isError: true });
|
|
655
|
+
}
|
|
656
|
+
statusBar.update('idle');
|
|
657
|
+
inputField.setDisabled(false);
|
|
658
|
+
return;
|
|
130
659
|
}
|
|
131
660
|
|
|
132
661
|
if (text === '/logout') {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
662
|
+
const config = getConfig();
|
|
663
|
+
if (!config.auth_token) { chatHistory.addMessage({ role: 'system', content: '✗ Not logged in.' }); return; }
|
|
664
|
+
inputField.setDisabled(true);
|
|
665
|
+
statusBar.update('thinking', 'Logging out...');
|
|
666
|
+
try { await dashboardLogout(); } catch (err) { if (err.statusCode !== 401) { chatHistory.addMessage({ role: 'system', content: `✗ ${err.message}`, isError: true }); statusBar.update('idle'); inputField.setDisabled(false); return; } }
|
|
667
|
+
setConfig({ ...config, auth_token: '' });
|
|
668
|
+
chatHistory.addMessage({ role: 'system', content: '✓ Logged out and cleared local CLI token.' });
|
|
669
|
+
statusBar.update('idle');
|
|
670
|
+
inputField.setDisabled(false);
|
|
671
|
+
return;
|
|
136
672
|
}
|
|
137
673
|
|
|
138
674
|
if (text.startsWith('/file ')) {
|
|
139
675
|
const fp = text.slice(6).trim();
|
|
140
|
-
const ctx = readFileContext([fp]
|
|
141
|
-
if (ctx)
|
|
142
|
-
|
|
676
|
+
const ctx = readFileContext([fp]);
|
|
677
|
+
if (ctx) {
|
|
678
|
+
messages.push({ role: 'user', content: `Here is the file context:\n${ctx}` });
|
|
679
|
+
chatHistory.addMessage({ role: 'system', content: `✓ Loaded: ${fp}` });
|
|
680
|
+
} else {
|
|
681
|
+
chatHistory.addMessage({ role: 'system', content: `✗ Could not load: ${fp}`, isError: true });
|
|
682
|
+
}
|
|
683
|
+
return;
|
|
143
684
|
}
|
|
144
685
|
|
|
145
686
|
if (text === '/models') {
|
|
146
|
-
|
|
147
|
-
|
|
687
|
+
inputField.setDisabled(true);
|
|
688
|
+
statusBar.update('thinking', 'Loading models...');
|
|
689
|
+
try {
|
|
690
|
+
const response = await dashboardListModels();
|
|
691
|
+
const models = Array.isArray(response && response.models) ? response.models : [];
|
|
692
|
+
if (!models.length) { chatHistory.addMessage({ role: 'system', content: '✗ No models available.' }); statusBar.update('idle'); }
|
|
693
|
+
else {
|
|
694
|
+
chatHistory.addMessage({ role: 'system', content: '/models' });
|
|
695
|
+
pendingAction = { type: 'models', items: models, stepIdx: 0 };
|
|
696
|
+
showPendingStep();
|
|
697
|
+
statusBar.update('waiting', 'Select model...');
|
|
698
|
+
activateNavCapture();
|
|
699
|
+
}
|
|
700
|
+
} catch (err) {
|
|
701
|
+
chatHistory.addMessage({ role: 'system', content: `✗ ${err.message}`, isError: true });
|
|
702
|
+
statusBar.update('idle');
|
|
703
|
+
}
|
|
704
|
+
inputField.setDisabled(false);
|
|
705
|
+
return;
|
|
148
706
|
}
|
|
149
707
|
|
|
150
708
|
if (text === '/model') {
|
|
151
|
-
|
|
152
|
-
|
|
709
|
+
chatHistory.addMessage({ role: 'system', content: `Current model: ${currentModel}` });
|
|
710
|
+
return;
|
|
153
711
|
}
|
|
154
712
|
|
|
155
713
|
if (text.startsWith('/model ')) {
|
|
156
714
|
currentModel = text.slice(7).trim();
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
715
|
+
resolvedTokenLimit = await resolveTokenLimit(currentModel);
|
|
716
|
+
statusBar.setModel(currentModel);
|
|
717
|
+
chatHistory.addMessage({ role: 'system', content: `✓ Model → ${currentModel}` });
|
|
718
|
+
return;
|
|
160
719
|
}
|
|
161
720
|
|
|
162
721
|
if (text === '/clear') {
|
|
163
|
-
messages = [
|
|
722
|
+
messages = [];
|
|
723
|
+
currentChatId = null; savedUpTo = 0;
|
|
164
724
|
permissionManager.clear();
|
|
165
|
-
|
|
166
|
-
|
|
725
|
+
chatHistory.addMessage({ role: 'system', content: '✓ Conversation and approvals cleared.' });
|
|
726
|
+
return;
|
|
167
727
|
}
|
|
168
728
|
|
|
169
729
|
if (text === '/compact' || text === '/cost') {
|
|
170
|
-
const total = messages.reduce((
|
|
171
|
-
|
|
172
|
-
|
|
730
|
+
const total = messages.reduce((s, m) => s + estimateTokens(m.content), 0);
|
|
731
|
+
let msg = `${messages.length} messages · ~${total} tokens`;
|
|
732
|
+
if (sessionMetrics) msg += '\n' + sessionMetrics.summary();
|
|
733
|
+
chatHistory.addMessage({ role: 'system', content: msg });
|
|
734
|
+
return;
|
|
173
735
|
}
|
|
174
736
|
|
|
175
737
|
if (text === '/config') {
|
|
176
|
-
|
|
177
|
-
|
|
738
|
+
chatHistory.addMessage({ role: 'system', content: configShow(opts.systemPromptFile || null) });
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
if (text === '/prompt') {
|
|
743
|
+
const activePrompt = resolvedSystemPrompt !== null ? resolvedSystemPrompt : SYSTEM_PROMPT;
|
|
744
|
+
const src = resolvedSystemPrompt !== null ? `file: ${opts.systemPromptFile}` : 'built-in';
|
|
745
|
+
const mode = getConfig().system_prompt_mode || 'system_role';
|
|
746
|
+
chatHistory.addMessage({
|
|
747
|
+
role: 'system',
|
|
748
|
+
content: `System prompt (${src}, mode: ${mode}):\n\n${activePrompt}`,
|
|
749
|
+
});
|
|
750
|
+
return;
|
|
178
751
|
}
|
|
179
752
|
|
|
180
753
|
if (text === '/approve') {
|
|
181
754
|
const enabled = permissionManager.toggleAll();
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
755
|
+
chatHistory.addMessage({ role: 'system', content: `Auto-approve: ${enabled ? 'ON' : 'OFF'}` });
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
if (text === '/debug' || text.startsWith('/debug ')) {
|
|
760
|
+
const arg = text === '/debug' ? '' : text.slice(7).trim().toLowerCase();
|
|
761
|
+
if (arg === 'off' || arg === 'false' || arg === '0') debugMode = false;
|
|
762
|
+
else debugMode = true;
|
|
763
|
+
|
|
764
|
+
let tail = '';
|
|
765
|
+
try {
|
|
766
|
+
const content = fs.readFileSync(AUDIT_LOG, 'utf8');
|
|
767
|
+
const lines = content.trim().split('\n').filter((l) => l.trim()).slice(-5);
|
|
768
|
+
if (lines.length) {
|
|
769
|
+
const formatted = lines.map((line) => {
|
|
770
|
+
try {
|
|
771
|
+
const entry = JSON.parse(line);
|
|
772
|
+
const mark = entry.approved ? '✓' : '✗';
|
|
773
|
+
return ` ${mark} ${entry.ts} ${entry.tag} ${entry.input} → ${entry.result}`;
|
|
774
|
+
} catch {
|
|
775
|
+
return ` ${line}`;
|
|
776
|
+
}
|
|
777
|
+
});
|
|
778
|
+
tail = '\nLast 5 audit entries:\n' + formatted.join('\n');
|
|
779
|
+
} else {
|
|
780
|
+
tail = '\nAudit log is empty.';
|
|
781
|
+
}
|
|
782
|
+
} catch {
|
|
783
|
+
tail = '\nNo audit log found.';
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
chatHistory.addMessage({
|
|
787
|
+
role: 'system',
|
|
788
|
+
content: `Debug output: ${debugMode ? 'ON' : 'OFF'} (raw messages, raw AI responses, raw HTTP errors → stderr)${tail}`,
|
|
789
|
+
});
|
|
790
|
+
return;
|
|
186
791
|
}
|
|
187
792
|
|
|
188
793
|
if (text.startsWith('/shell ') || text.startsWith('!')) {
|
|
189
794
|
const cmd = text.startsWith('/shell ') ? text.slice(7).trim() : text.slice(1).trim();
|
|
190
|
-
|
|
191
|
-
|
|
795
|
+
inputField.setDisabled(true);
|
|
796
|
+
statusBar.update('tool', cmd);
|
|
797
|
+
try {
|
|
798
|
+
const shellResult = await agentExecShell(cmd);
|
|
799
|
+
let output = shellResult.stdout || '';
|
|
800
|
+
if (shellResult.stderr && shellResult.stderr !== 'Permission denied by user') {
|
|
801
|
+
output += (output ? '\n' : '') + `STDERR: ${shellResult.stderr}`;
|
|
802
|
+
}
|
|
803
|
+
const exitSuffix = shellResult.exit_code !== 0 ? ` [exit ${shellResult.exit_code}]` : '';
|
|
804
|
+
const display = output.trim() ? output.trim() + exitSuffix : `(no output)${exitSuffix}`;
|
|
805
|
+
chatHistory.addMessage({ role: 'shell', cmd, content: display, ts: new Date() });
|
|
806
|
+
} catch (err) {
|
|
807
|
+
chatHistory.addMessage({ role: 'system', content: `✗ Shell error: ${err.message}`, isError: true });
|
|
808
|
+
}
|
|
809
|
+
statusBar.update('idle');
|
|
810
|
+
inputField.setDisabled(false);
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// Block unauthenticated users from running the agent
|
|
815
|
+
if (!getConfig().auth_token) {
|
|
816
|
+
chatHistory.addMessage({ role: 'system', content: '✗ Not logged in. Run /login first.', isError: true });
|
|
817
|
+
return;
|
|
192
818
|
}
|
|
193
819
|
|
|
820
|
+
// Normal message → run agent
|
|
821
|
+
inputField.setDisabled(true);
|
|
822
|
+
chatHistory.addMessage({ role: 'user', content: text });
|
|
823
|
+
statusBar.update('thinking', 'Thinking...');
|
|
824
|
+
await createChatIfNeeded(text);
|
|
194
825
|
messages.push({ role: 'user', content: text });
|
|
195
|
-
console.log(` ${FG_DARK}${'─'.repeat(Math.min(cols, 70) - 4)}${RST}`);
|
|
196
826
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
827
|
+
// Per-turn state: buffer tokens until we know if the model is in an implicit
|
|
828
|
+
// think block (Qwen3-style: plain text followed by </think>, no opening tag).
|
|
829
|
+
let implicitThinkPhase = !opts.showThink;
|
|
830
|
+
let implicitThinkBuffer = '';
|
|
831
|
+
|
|
832
|
+
const callbacks = {
|
|
833
|
+
onThinking: () => statusBar.update('thinking', 'Thinking...'),
|
|
834
|
+
onRequestSent: () => {
|
|
835
|
+
statusBar.update('thinking', 'Thinking...');
|
|
836
|
+
// Reset think-phase detection for each new agent iteration.
|
|
837
|
+
implicitThinkPhase = !opts.showThink;
|
|
838
|
+
implicitThinkBuffer = '';
|
|
839
|
+
},
|
|
840
|
+
onStreamStart: () => {
|
|
841
|
+
// If showThink is on, switch to streaming immediately.
|
|
842
|
+
// Otherwise keep "Thinking…" until </think> is resolved.
|
|
843
|
+
if (opts.showThink) statusBar.update('streaming', 'Streaming response');
|
|
844
|
+
},
|
|
845
|
+
onTagOpen: (tag, attrs) => {
|
|
846
|
+
const entry = TAG_REGISTRY[tag];
|
|
847
|
+
if (entry?.type === 'tool') {
|
|
848
|
+
const actionLabel = entry.label || tag;
|
|
849
|
+
const detail = attrs.path || attrs.url || attrs.key || attrs.src || '';
|
|
850
|
+
const isDownload = tag === 'download' || tag === 'http_get' || tag === 'http_get_next';
|
|
851
|
+
const barState = isDownload ? 'waiting_download' : 'tool';
|
|
852
|
+
const label = isDownload
|
|
853
|
+
? `Waiting for download${detail ? ': ' + detail : ''}`
|
|
854
|
+
: `${actionLabel}${detail ? ': ' + detail : ''}`;
|
|
855
|
+
statusBar.update(barState, label);
|
|
856
|
+
if (!opts.showThink) chatHistory.clearStreamingContent();
|
|
857
|
+
}
|
|
858
|
+
if (entry?.display === 'think_bubble') {
|
|
859
|
+
statusBar.update('thinking', 'Reasoning...');
|
|
860
|
+
}
|
|
861
|
+
},
|
|
862
|
+
onThinkEnd: (content) => {
|
|
863
|
+
chatHistory.addMessage({ role: 'think', content });
|
|
864
|
+
statusBar.update('streaming', 'Streaming response');
|
|
865
|
+
},
|
|
866
|
+
onToolStart: (tag, input, attrs) => {
|
|
867
|
+
const actionLabel = TAG_REGISTRY[tag]?.label || tag;
|
|
868
|
+
const short = input.length > 40 ? input.slice(0, 40) + '…' : input;
|
|
869
|
+
const isDownload = tag === 'download' || tag === 'http_get' || tag === 'http_get_next';
|
|
870
|
+
if (isDownload) {
|
|
871
|
+
statusBar.update('waiting_download', `Waiting for download: ${short}`);
|
|
872
|
+
} else {
|
|
873
|
+
statusBar.update('tool', `${actionLabel}: ${short}`);
|
|
874
|
+
}
|
|
875
|
+
},
|
|
876
|
+
onToolEnd: (tag, result, durationMs) => {
|
|
877
|
+
const isError = typeof result === 'string' && result.startsWith('Error');
|
|
878
|
+
if (isError) {
|
|
879
|
+
finalizeHttpFetch();
|
|
880
|
+
chatHistory.addMessage({
|
|
881
|
+
role: 'tool',
|
|
882
|
+
tag,
|
|
883
|
+
content: `${tag} ✕ [${durationMs}ms]`,
|
|
884
|
+
output: typeof result === 'string' && result.trim() ? result : null,
|
|
885
|
+
});
|
|
886
|
+
statusBar.update('streaming', 'Streaming response');
|
|
887
|
+
} else if (tag === 'http_get') {
|
|
888
|
+
const chunkedMatch = typeof result === 'string' && result.match(/^HTTP GET (.+?) \(\d+\) \[Part 1\/(\d+)\]/);
|
|
889
|
+
if (chunkedMatch) {
|
|
890
|
+
showHttpFetchProgress(chunkedMatch[1], 1, parseInt(chunkedMatch[2], 10));
|
|
891
|
+
} else {
|
|
892
|
+
finalizeHttpFetch();
|
|
893
|
+
statusBar.update('tool', `✓ ${TAG_REGISTRY[tag]?.label || tag} [${durationMs}ms]`);
|
|
894
|
+
}
|
|
895
|
+
} else if (tag === 'http_get_next') {
|
|
896
|
+
const partMatch = typeof result === 'string' && result.match(/^HTTP content "(.+?)" \[Part (\d+)\/(\d+)\]/);
|
|
897
|
+
if (partMatch) {
|
|
898
|
+
const part = parseInt(partMatch[2], 10);
|
|
899
|
+
const total = parseInt(partMatch[3], 10);
|
|
900
|
+
showHttpFetchProgress(partMatch[1], part, total);
|
|
901
|
+
if (part === total) finalizeHttpFetch();
|
|
902
|
+
} else {
|
|
903
|
+
finalizeHttpFetch();
|
|
904
|
+
}
|
|
905
|
+
} else {
|
|
906
|
+
const actionLabel = TAG_REGISTRY[tag]?.label || tag;
|
|
907
|
+
statusBar.update('tool', `✓ ${actionLabel} [${durationMs}ms]`);
|
|
908
|
+
}
|
|
909
|
+
},
|
|
910
|
+
onToken: (token) => {
|
|
911
|
+
if (!opts.showThink && implicitThinkPhase) {
|
|
912
|
+
// Check if this token is the closing think tag (Qwen3-style implicit think).
|
|
913
|
+
if (/^<\/(think|reasoning|reflection)>$/i.test(token.trim())) {
|
|
914
|
+
// Thinking phase is over — discard buffered reasoning, start streaming.
|
|
915
|
+
implicitThinkPhase = false;
|
|
916
|
+
implicitThinkBuffer = '';
|
|
917
|
+
statusBar.update('streaming', 'Streaming response');
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
// Buffer the token; keep the thinking animation visible.
|
|
921
|
+
implicitThinkBuffer += token;
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
chatHistory.streamToken(token);
|
|
925
|
+
statusBar.onToken();
|
|
926
|
+
},
|
|
927
|
+
onAssistantMessage: (cleanContent) => {
|
|
928
|
+
// If </think> was never seen, the model had no implicit think block —
|
|
929
|
+
// flush whatever was buffered as normal streaming content.
|
|
930
|
+
if (implicitThinkPhase && implicitThinkBuffer) {
|
|
931
|
+
implicitThinkPhase = false;
|
|
932
|
+
implicitThinkBuffer = '';
|
|
933
|
+
}
|
|
934
|
+
chatHistory.finalizeLastMessage(cleanContent);
|
|
935
|
+
},
|
|
936
|
+
onMetricsUpdate: (data) => statusBar.updateMetrics(data),
|
|
937
|
+
onRetry: (attempt, max) => {
|
|
938
|
+
statusBar.update('thinking', `Retrying (${attempt}/${max})...`);
|
|
939
|
+
},
|
|
940
|
+
onError: (err) => {
|
|
941
|
+
if (err && err.isWarning) {
|
|
942
|
+
chatHistory.addMessage({ role: 'system', content: err.message || String(err) });
|
|
943
|
+
} else {
|
|
944
|
+
const msg = (err && err.message) || String(err);
|
|
945
|
+
statusBar.update('error', msg);
|
|
946
|
+
chatHistory.addMessage({ role: 'system', content: `✗ ${msg}`, isError: true });
|
|
947
|
+
}
|
|
948
|
+
},
|
|
949
|
+
};
|
|
950
|
+
|
|
951
|
+
let _agentAborted = false;
|
|
952
|
+
const _onAbort = () => {
|
|
953
|
+
if (!_agentAborted) {
|
|
954
|
+
_agentAborted = true;
|
|
955
|
+
chatHistory.addMessage({ role: 'system', content: '⏹ Interrupted.' });
|
|
956
|
+
}
|
|
957
|
+
};
|
|
958
|
+
inputField.on('abort', _onAbort);
|
|
200
959
|
|
|
201
|
-
|
|
202
|
-
|
|
960
|
+
try {
|
|
961
|
+
const agentResult = await runAgentLoop(messages, currentModel, undefined, resolvedTokenLimit, {
|
|
962
|
+
showThink: opts.showThink || false,
|
|
963
|
+
debug: debugMode,
|
|
964
|
+
callbacks,
|
|
965
|
+
systemPrompt: resolvedSystemPrompt,
|
|
966
|
+
systemPromptMode: getConfig().system_prompt_mode || 'system_role',
|
|
967
|
+
getAbortFlag: () => _agentAborted,
|
|
968
|
+
});
|
|
969
|
+
messages = agentResult.messages;
|
|
970
|
+
sessionMetrics = agentResult.metrics;
|
|
971
|
+
} catch (err) {
|
|
972
|
+
statusBar.update('error', err.message || 'Agent error');
|
|
973
|
+
chatHistory.addMessage({ role: 'system', content: err.message || 'Agent error', isError: true });
|
|
974
|
+
} finally {
|
|
975
|
+
inputField.removeListener('abort', _onAbort);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
statusBar.update('idle');
|
|
979
|
+
inputField.setDisabled(false);
|
|
980
|
+
await saveTurnToDashboard();
|
|
981
|
+
saveSession();
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
// Wait until user exits
|
|
985
|
+
await exitPromise;
|
|
986
|
+
setUIActive(false);
|
|
987
|
+
saveSession();
|
|
988
|
+
if (sessionMetrics) {
|
|
989
|
+
// Show summary in terminal after destroy
|
|
990
|
+
console.log('\n' + sessionMetrics.summary());
|
|
203
991
|
}
|
|
992
|
+
if (currentChatId !== null) {
|
|
993
|
+
console.log(` ${FG_DARK}Resume this chat: ${FG_CYAN}semalt-code --resume ${currentChatId}${RST}`);
|
|
994
|
+
}
|
|
995
|
+
console.log(`\n ${FG_GRAY}Goodbye!${RST}\n`);
|
|
204
996
|
}
|
|
205
997
|
|
|
206
|
-
async function
|
|
207
|
-
|
|
208
|
-
|
|
998
|
+
async function _loginFlow(chatHistory, statusBar) {
|
|
999
|
+
let loginRequest;
|
|
1000
|
+
try { loginRequest = await requestCliLogin(); }
|
|
1001
|
+
catch (err) {
|
|
1002
|
+
chatHistory.addMessage({ role: 'system', content: `✗ Login failed: ${err.message}`, isError: true });
|
|
209
1003
|
return;
|
|
210
1004
|
}
|
|
1005
|
+
chatHistory.addMessage({ role: 'system', content: `Open this URL to authorize:\n ${loginRequest.verification_url}\n\nWaiting for confirmation...` });
|
|
1006
|
+
statusBar.update('waiting', 'Waiting for browser auth...');
|
|
1007
|
+
const startedAt = Date.now();
|
|
1008
|
+
while (Date.now() - startedAt < LOGIN_TIMEOUT_MS) {
|
|
1009
|
+
await new Promise((r) => setTimeout(r, LOGIN_POLL_INTERVAL_MS));
|
|
1010
|
+
let status;
|
|
1011
|
+
try { status = await getCliLoginStatus(loginRequest.id, loginRequest.hash); }
|
|
1012
|
+
catch (err) {
|
|
1013
|
+
if (err.statusCode === 404 || err.statusCode === 410) { chatHistory.addMessage({ role: 'system', content: '✗ Login token is no longer valid.', isError: true }); return; }
|
|
1014
|
+
continue;
|
|
1015
|
+
}
|
|
1016
|
+
if (status.status === 'authorized') {
|
|
1017
|
+
const config = getConfig();
|
|
1018
|
+
setConfig({ ...config, dashboard_url: config.dashboard_url, auth_token: loginRequest.token });
|
|
1019
|
+
chatHistory.addMessage({ role: 'system', content: `✓ CLI token saved to ${CONFIG_PATH}` });
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
if (status.status === 'expired') {
|
|
1023
|
+
chatHistory.addMessage({ role: 'system', content: '✗ Login token expired. Run /login again.', isError: true });
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
chatHistory.addMessage({ role: 'system', content: '⚠ Login timed out.' });
|
|
1028
|
+
}
|
|
211
1029
|
|
|
1030
|
+
async function cmdCode(opts, promptArgs) {
|
|
1031
|
+
if (!promptArgs.length) { console.log(` ${FG_RED}Usage: semalt-code code <prompt>${RST}`); return; }
|
|
1032
|
+
if (!getConfig().auth_token) { console.log(` ${FG_RED}✗${RST} ${FG_GRAY}Not logged in. Run semalt-code login first.${RST}\n`); return; }
|
|
1033
|
+
const model = opts.model || getConfig().default_model;
|
|
212
1034
|
const userPrompt = promptArgs.join(' ');
|
|
213
|
-
const context = opts.file ? readFileContext(opts.file
|
|
1035
|
+
const context = opts.file ? readFileContext(opts.file) : '';
|
|
214
1036
|
const fullPrompt = context ? `Context files:\n${context}\n\nTask: ${userPrompt}` : userPrompt;
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
{
|
|
218
|
-
|
|
219
|
-
];
|
|
220
|
-
|
|
1037
|
+
let resolvedSystemPrompt = null;
|
|
1038
|
+
if (opts.systemPromptFile) {
|
|
1039
|
+
try { resolvedSystemPrompt = fs.readFileSync(opts.systemPromptFile, 'utf8'); } catch {}
|
|
1040
|
+
}
|
|
1041
|
+
let messages = [{ role: 'user', content: fullPrompt }];
|
|
1042
|
+
const statusBar = new StatusBar();
|
|
1043
|
+
statusBar.update({ model, status: 'thinking' });
|
|
1044
|
+
const codeResult = await runAgentLoop(messages, model, undefined, null, {
|
|
1045
|
+
debug: opts.debug || false,
|
|
1046
|
+
systemPrompt: resolvedSystemPrompt,
|
|
1047
|
+
systemPromptMode: getConfig().system_prompt_mode || 'system_role',
|
|
1048
|
+
});
|
|
1049
|
+
messages = codeResult.messages;
|
|
1050
|
+
statusBar.destroy();
|
|
221
1051
|
console.log();
|
|
1052
|
+
if (codeResult.metrics) console.log(codeResult.metrics.summary());
|
|
1053
|
+
if (opts.dryRun) printDryRunSummary();
|
|
222
1054
|
}
|
|
223
1055
|
|
|
224
1056
|
async function cmdEdit(opts, filePath, instructionArgs) {
|
|
225
|
-
if (!filePath) {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
}
|
|
229
|
-
if (!fs.existsSync(filePath)) {
|
|
230
|
-
console.log(` ${FG_RED}✗ File not found: ${filePath}${RST}`);
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
233
|
-
|
|
1057
|
+
if (!filePath) { console.log(` ${FG_RED}Usage: semalt-code edit <file> <instruction>${RST}`); return; }
|
|
1058
|
+
if (!getConfig().auth_token) { console.log(` ${FG_RED}✗${RST} ${FG_GRAY}Not logged in. Run semalt-code login first.${RST}\n`); return; }
|
|
1059
|
+
if (!fs.existsSync(filePath)) { console.log(` ${FG_RED}✗ File not found: ${filePath}${RST}`); return; }
|
|
234
1060
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
235
1061
|
const instruction = instructionArgs.join(' ');
|
|
236
|
-
|
|
237
1062
|
const messages = [
|
|
238
1063
|
{ role: 'system', content: 'You are Semalt.AI. Output ONLY the modified file. No explanations, no fences.' },
|
|
239
1064
|
{ role: 'user', content: `File: ${filePath}\n\n\`\`\`\n${content}\n\`\`\`\n\nInstruction: ${instruction}` },
|
|
240
1065
|
];
|
|
241
|
-
|
|
242
1066
|
console.log(` ${FG_GRAY}Editing ${filePath}...${RST}`);
|
|
1067
|
+
const editStatusBar = new StatusBar();
|
|
1068
|
+
editStatusBar.update({ model: opts.model || getConfig().default_model, status: 'editing' });
|
|
243
1069
|
let result = await chatSync(messages, { model: opts.model });
|
|
244
|
-
|
|
1070
|
+
editStatusBar.destroy();
|
|
245
1071
|
if (result && !opts.dryRun) {
|
|
246
|
-
if (result.startsWith('```')) {
|
|
247
|
-
const lines = result.split('\n');
|
|
248
|
-
result = lines.at(-1).trim() === '```'
|
|
249
|
-
? lines.slice(1, -1).join('\n')
|
|
250
|
-
: lines.slice(1).join('\n');
|
|
251
|
-
}
|
|
1072
|
+
if (result.startsWith('```')) { const lines = result.split('\n'); result = lines.at(-1).trim() === '```' ? lines.slice(1, -1).join('\n') : lines.slice(1).join('\n'); }
|
|
252
1073
|
fs.writeFileSync(filePath, result);
|
|
253
1074
|
console.log(` ${FG_GREEN}✓ Saved: ${filePath}${RST}`);
|
|
254
1075
|
} else if (opts.dryRun) {
|
|
@@ -258,23 +1079,21 @@ function createCommands({
|
|
|
258
1079
|
|
|
259
1080
|
async function cmdShell(opts, commandArgs) {
|
|
260
1081
|
const command = commandArgs.join(' ');
|
|
261
|
-
if (!command) {
|
|
262
|
-
console.log(` ${FG_RED}Usage: semalt-code shell <command>${RST}`);
|
|
263
|
-
return;
|
|
264
|
-
}
|
|
265
|
-
|
|
1082
|
+
if (!command) { console.log(` ${FG_RED}Usage: semalt-code shell <command>${RST}`); return; }
|
|
266
1083
|
const result = await agentExecShell(command);
|
|
267
|
-
|
|
268
1084
|
if (opts.analyze) {
|
|
1085
|
+
if (!getConfig().auth_token) { console.log(` ${FG_RED}✗${RST} ${FG_GRAY}Not logged in. Run semalt-code login first.${RST}\n`); return; }
|
|
269
1086
|
const messages = [
|
|
270
1087
|
{ role: 'system', content: 'You are Semalt.AI. Analyze the command output concisely.' },
|
|
271
1088
|
{ role: 'user', content: `Command: ${command}\nExit: ${result.exit_code}\nStdout:\n${result.stdout}\nStderr:\n${result.stderr}` },
|
|
272
1089
|
];
|
|
273
|
-
console.log();
|
|
274
|
-
console.log(` ${FG_TEAL}${BOLD}◆ Semalt.AI${RST}`);
|
|
275
|
-
console.log();
|
|
1090
|
+
console.log(); console.log(` ${FG_TEAL}${BOLD}◆ Semalt.AI${RST}`); console.log();
|
|
276
1091
|
process.stdout.write(' ');
|
|
277
|
-
|
|
1092
|
+
try {
|
|
1093
|
+
await chatStream(messages, { model: opts.model });
|
|
1094
|
+
} catch (err) {
|
|
1095
|
+
console.log(`\n ${FG_RED}✗ ${err.message}${RST}`);
|
|
1096
|
+
}
|
|
278
1097
|
console.log();
|
|
279
1098
|
}
|
|
280
1099
|
}
|
|
@@ -282,109 +1101,32 @@ function createCommands({
|
|
|
282
1101
|
async function cmdModels() {
|
|
283
1102
|
const config = getConfig();
|
|
284
1103
|
let response;
|
|
285
|
-
try {
|
|
286
|
-
|
|
287
|
-
} catch (error) {
|
|
288
|
-
console.log(` ${FG_RED}✗${RST} ${FG_GRAY}${error.message}${RST}\n`);
|
|
289
|
-
return config.default_model;
|
|
290
|
-
}
|
|
291
|
-
|
|
1104
|
+
try { response = await dashboardListModels(); }
|
|
1105
|
+
catch (err) { console.log(` ${FG_RED}✗${RST} ${FG_GRAY}${err.message}${RST}\n`); return { model: config.default_model, dbId: config.dashboard_model_id }; }
|
|
292
1106
|
const models = Array.isArray(response && response.models) ? response.models : [];
|
|
293
|
-
if (!models.length) {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
console.log();
|
|
299
|
-
console.log(` ${FG_TEAL}${BOLD}◆ Your Models${RST}`);
|
|
300
|
-
console.log(` ${FG_DARK}${'─'.repeat(60)}${RST}`);
|
|
301
|
-
models.forEach((model, index) => {
|
|
1107
|
+
if (!models.length) { console.log(` ${FG_RED}✗${RST} ${FG_GRAY}No models available.${RST}\n`); return { model: config.default_model, dbId: config.dashboard_model_id }; }
|
|
1108
|
+
console.log(); console.log(` ${FG_TEAL}${BOLD}◆ Your Models${RST}`); console.log(` ${FG_DARK}${'─'.repeat(60)}${RST}`);
|
|
1109
|
+
const activeIndex = models.findIndex((m) => m.base_url === config.api_base && m.model_id === config.default_model);
|
|
1110
|
+
const selectedIndex = await interactiveSelect(models, (model, isSelected, isFinal) => {
|
|
302
1111
|
const active = model.base_url === config.api_base && model.model_id === config.default_model;
|
|
303
1112
|
const marker = active ? `${FG_GREEN}●${RST}` : `${FG_DARK}○${RST}`;
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
output: process.stdout,
|
|
311
|
-
terminal: true,
|
|
312
|
-
});
|
|
313
|
-
|
|
314
|
-
const selectedIndex = await new Promise((resolve) => {
|
|
315
|
-
rl.question(` ${FG_TEAL}${BOLD}Select model>${RST} `, (answer) => {
|
|
316
|
-
rl.close();
|
|
317
|
-
resolve(Number((answer || '').trim()));
|
|
318
|
-
});
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
if (!Number.isInteger(selectedIndex) || selectedIndex < 1 || selectedIndex > models.length) {
|
|
322
|
-
console.log(` ${FG_RED}✗${RST} ${FG_GRAY}Invalid selection${RST}\n`);
|
|
323
|
-
return config.default_model;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
const selectedModel = models[selectedIndex - 1];
|
|
1113
|
+
const cursor = isSelected ? `${FG_TEAL}❯${RST}` : ' ';
|
|
1114
|
+
const nameStyle = isSelected && !isFinal ? `${BG_SELECTED}${FG_CYAN}` : (isSelected ? FG_CYAN : FG_GRAY);
|
|
1115
|
+
return ` ${marker} ${cursor} ${nameStyle}${model.name} · ${model.model_id} @ ${model.base_url}${RST}`;
|
|
1116
|
+
}, { initialIndex: Math.max(0, activeIndex) });
|
|
1117
|
+
if (selectedIndex === null) { console.log(` ${FG_DARK}Cancelled${RST}\n`); return { model: config.default_model, dbId: config.dashboard_model_id }; }
|
|
1118
|
+
const selectedModel = models[selectedIndex];
|
|
327
1119
|
let credentialsResponse;
|
|
328
|
-
try {
|
|
329
|
-
|
|
330
|
-
} catch (error) {
|
|
331
|
-
console.log(` ${FG_RED}✗${RST} ${FG_GRAY}${error.message}${RST}\n`);
|
|
332
|
-
return config.default_model;
|
|
333
|
-
}
|
|
334
|
-
|
|
1120
|
+
try { credentialsResponse = await dashboardGetModelForCli(selectedModel.id); }
|
|
1121
|
+
catch (err) { console.log(` ${FG_RED}✗${RST} ${FG_GRAY}${err.message}${RST}\n`); return { model: config.default_model, dbId: config.dashboard_model_id }; }
|
|
335
1122
|
const model = credentialsResponse && credentialsResponse.model ? credentialsResponse.model : null;
|
|
336
|
-
if (!model) {
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
setConfig({
|
|
342
|
-
...config,
|
|
343
|
-
api_base: model.base_url,
|
|
344
|
-
api_key: model.api_key,
|
|
345
|
-
default_model: model.model_id,
|
|
346
|
-
});
|
|
1123
|
+
if (!model) { console.log(` ${FG_RED}✗${RST} ${FG_GRAY}Unable to load selected model.${RST}\n`); return { model: config.default_model, dbId: config.dashboard_model_id }; }
|
|
1124
|
+
const contextLength = (Number.isInteger(model.context_length) && model.context_length > 0 ? model.context_length : null) || (Number.isInteger(model.max_tokens) && model.max_tokens > 0 ? model.max_tokens : null);
|
|
1125
|
+
const updatedConfig = { ...config, api_base: model.base_url, api_key: model.api_key, default_model: model.model_id, dashboard_model_id: model.id };
|
|
1126
|
+
if (contextLength !== null) updatedConfig.context_length = contextLength;
|
|
1127
|
+
setConfig(updatedConfig);
|
|
347
1128
|
console.log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Current model → ${model.name} (${model.model_id})${RST}\n`);
|
|
348
|
-
return model.model_id;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
async function cmdModelsAdd() {
|
|
352
|
-
const rl = readline.createInterface({
|
|
353
|
-
input: process.stdin,
|
|
354
|
-
output: process.stdout,
|
|
355
|
-
terminal: true,
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
function ask(question) {
|
|
359
|
-
return new Promise((resolve) => {
|
|
360
|
-
rl.question(question, (answer) => resolve((answer || '').trim()));
|
|
361
|
-
});
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
console.log();
|
|
365
|
-
console.log(` ${FG_TEAL}${BOLD}◆ Add Model Profile${RST}`);
|
|
366
|
-
console.log(` ${FG_DARK}${'─'.repeat(40)}${RST}`);
|
|
367
|
-
|
|
368
|
-
const apiBase = await ask(` ${FG_CYAN}API Base URL:${RST} `);
|
|
369
|
-
const apiKey = await ask(` ${FG_CYAN}API Key:${RST} `);
|
|
370
|
-
const modelId = await ask(` ${FG_CYAN}Model ID:${RST} `);
|
|
371
|
-
rl.close();
|
|
372
|
-
|
|
373
|
-
if (!apiBase || !modelId) {
|
|
374
|
-
console.log(`\n ${FG_RED}✗${RST} ${FG_GRAY}API Base URL and Model ID are required.${RST}\n`);
|
|
375
|
-
return;
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
const config = getConfig();
|
|
379
|
-
const profile = {
|
|
380
|
-
api_base: apiBase,
|
|
381
|
-
api_key: apiKey || 'any',
|
|
382
|
-
model: modelId,
|
|
383
|
-
};
|
|
384
|
-
|
|
385
|
-
config.models.push(profile);
|
|
386
|
-
setActiveModelProfile(profile);
|
|
387
|
-
console.log(`\n ${FG_GREEN}✓${RST} Saved model profile: ${describeModelProfile(profile)}\n`);
|
|
1129
|
+
return { model: model.model_id, dbId: model.id };
|
|
388
1130
|
}
|
|
389
1131
|
|
|
390
1132
|
function cmdInit(opts) {
|
|
@@ -406,108 +1148,66 @@ function createCommands({
|
|
|
406
1148
|
}
|
|
407
1149
|
|
|
408
1150
|
async function cmdLogin() {
|
|
409
|
-
console.log();
|
|
410
|
-
console.log(` ${FG_TEAL}${BOLD}◆ CLI Login${RST}`);
|
|
411
|
-
console.log(` ${FG_DARK}${'─'.repeat(40)}${RST}`);
|
|
412
|
-
|
|
1151
|
+
console.log(); console.log(` ${FG_TEAL}${BOLD}◆ CLI Login${RST}`); console.log(` ${FG_DARK}${'─'.repeat(40)}${RST}`);
|
|
413
1152
|
let loginRequest;
|
|
414
|
-
try {
|
|
415
|
-
|
|
416
|
-
} catch (error) {
|
|
417
|
-
console.log(` ${FG_RED}✗${RST} ${FG_GRAY}Unable to start login via ${getConfig().dashboard_url}: ${error.message}${RST}\n`);
|
|
418
|
-
return;
|
|
419
|
-
}
|
|
420
|
-
|
|
1153
|
+
try { loginRequest = await requestCliLogin(); }
|
|
1154
|
+
catch (err) { console.log(` ${FG_RED}✗${RST} ${FG_GRAY}Unable to start login via ${getConfig().dashboard_url}: ${err.message}${RST}\n`); return; }
|
|
421
1155
|
console.log(` ${FG_GRAY}Open this URL in your browser and confirm the login:${RST}`);
|
|
422
1156
|
console.log(` ${FG_CYAN}${loginRequest.verification_url}${RST}`);
|
|
423
1157
|
console.log(` ${FG_DARK}Waiting for confirmation...${RST}`);
|
|
424
|
-
|
|
425
1158
|
const startedAt = Date.now();
|
|
426
1159
|
while (Date.now() - startedAt < LOGIN_TIMEOUT_MS) {
|
|
427
|
-
await new Promise((
|
|
428
|
-
|
|
1160
|
+
await new Promise((r) => setTimeout(r, LOGIN_POLL_INTERVAL_MS));
|
|
429
1161
|
let status;
|
|
430
|
-
try {
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
console.log(` ${FG_RED}✗${RST} ${FG_GRAY}Login token is no longer valid.${RST}\n`);
|
|
435
|
-
return;
|
|
436
|
-
}
|
|
437
|
-
continue;
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
if (status.status === 'authorized') {
|
|
441
|
-
const config = getConfig();
|
|
442
|
-
setConfig({
|
|
443
|
-
...config,
|
|
444
|
-
dashboard_url: config.dashboard_url,
|
|
445
|
-
auth_token: loginRequest.token,
|
|
446
|
-
});
|
|
447
|
-
console.log(` ${FG_GREEN}✓${RST} ${FG_GRAY}CLI token saved to ${CONFIG_PATH}${RST}\n`);
|
|
448
|
-
return;
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
if (status.status === 'expired') {
|
|
452
|
-
console.log(` ${FG_RED}✗${RST} ${FG_GRAY}Login token expired. Run semalt login again.${RST}\n`);
|
|
453
|
-
return;
|
|
454
|
-
}
|
|
1162
|
+
try { status = await getCliLoginStatus(loginRequest.id, loginRequest.hash); }
|
|
1163
|
+
catch (err) { if (err.statusCode === 404 || err.statusCode === 410) { console.log(` ${FG_RED}✗${RST} ${FG_GRAY}Login token is no longer valid.${RST}\n`); return; } continue; }
|
|
1164
|
+
if (status.status === 'authorized') { const config = getConfig(); setConfig({ ...config, dashboard_url: config.dashboard_url, auth_token: loginRequest.token }); console.log(` ${FG_GREEN}✓${RST} ${FG_GRAY}CLI token saved to ${CONFIG_PATH}${RST}\n`); return; }
|
|
1165
|
+
if (status.status === 'expired') { console.log(` ${FG_RED}✗${RST} ${FG_GRAY}Login token expired.${RST}\n`); return; }
|
|
455
1166
|
}
|
|
456
|
-
|
|
457
|
-
console.log(` ${FG_YELLOW}⚠${RST} ${FG_GRAY}Login timed out. The URL may still work for a short time.${RST}\n`);
|
|
1167
|
+
console.log(` ${FG_YELLOW}⚠${RST} ${FG_GRAY}Login timed out.${RST}\n`);
|
|
458
1168
|
}
|
|
459
1169
|
|
|
460
1170
|
async function cmdWhoAmI() {
|
|
461
1171
|
let response;
|
|
462
|
-
try {
|
|
463
|
-
response = await dashboardWhoAmI();
|
|
464
|
-
} catch (error) {
|
|
465
|
-
console.log(` ${FG_RED}✗${RST} ${FG_GRAY}${error.message}${RST}\n`);
|
|
466
|
-
return;
|
|
467
|
-
}
|
|
468
|
-
|
|
1172
|
+
try { response = await dashboardWhoAmI(); } catch (err) { console.log(` ${FG_RED}✗${RST} ${FG_GRAY}${err.message}${RST}\n`); return; }
|
|
469
1173
|
const user = response && response.user ? response.user : null;
|
|
470
|
-
if (!user) {
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
console.log();
|
|
476
|
-
console.log(` ${FG_TEAL}${BOLD}◆ Current User${RST}`);
|
|
477
|
-
console.log(` ${FG_DARK}${'─'.repeat(40)}${RST}`);
|
|
478
|
-
console.log(formatUserLine('ID', user.id));
|
|
479
|
-
console.log(formatUserLine('Email', user.email || '-'));
|
|
480
|
-
console.log(formatUserLine('Name', user.name || '-'));
|
|
481
|
-
console.log(formatUserLine('Provider', user.provider || '-'));
|
|
482
|
-
if (user.avatar_url) {
|
|
483
|
-
console.log(formatUserLine('Avatar', user.avatar_url));
|
|
484
|
-
}
|
|
1174
|
+
if (!user) { console.log(` ${FG_RED}✗${RST} ${FG_GRAY}Unable to load current user.${RST}\n`); return; }
|
|
1175
|
+
console.log(); console.log(` ${FG_TEAL}${BOLD}◆ Current User${RST}`); console.log(` ${FG_DARK}${'─'.repeat(40)}${RST}`);
|
|
1176
|
+
console.log(formatUserLine('ID', user.id)); console.log(formatUserLine('Email', user.email || '-'));
|
|
1177
|
+
console.log(formatUserLine('Name', user.name || '-')); console.log(formatUserLine('Provider', user.provider || '-'));
|
|
1178
|
+
if (user.avatar_url) console.log(formatUserLine('Avatar', user.avatar_url));
|
|
485
1179
|
console.log();
|
|
486
1180
|
}
|
|
487
1181
|
|
|
488
1182
|
async function cmdLogout() {
|
|
489
1183
|
const config = getConfig();
|
|
490
|
-
if (!config.auth_token) {
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
try {
|
|
496
|
-
await dashboardLogout();
|
|
497
|
-
} catch (error) {
|
|
498
|
-
if (error.statusCode !== 401) {
|
|
499
|
-
console.log(` ${FG_RED}✗${RST} ${FG_GRAY}${error.message}${RST}\n`);
|
|
500
|
-
return;
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
setConfig({
|
|
505
|
-
...config,
|
|
506
|
-
auth_token: '',
|
|
507
|
-
});
|
|
1184
|
+
if (!config.auth_token) { console.log(` ${FG_RED}✗${RST} ${FG_GRAY}Not logged in.${RST}\n`); return; }
|
|
1185
|
+
try { await dashboardLogout(); } catch (err) { if (err.statusCode !== 401) { console.log(` ${FG_RED}✗${RST} ${FG_GRAY}${err.message}${RST}\n`); return; } }
|
|
1186
|
+
setConfig({ ...config, auth_token: '' });
|
|
508
1187
|
console.log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Logged out and cleared local CLI token.${RST}\n`);
|
|
509
1188
|
}
|
|
510
1189
|
|
|
1190
|
+
function printDryRunSummary() {
|
|
1191
|
+
const ops = getSkippedOps();
|
|
1192
|
+
const files = ops.filter((o) => o.category === 'file');
|
|
1193
|
+
const cmds = ops.filter((o) => o.category === 'cmd');
|
|
1194
|
+
const nets = ops.filter((o) => o.category === 'net');
|
|
1195
|
+
const BOX_W = 40, INNER = BOX_W - 2;
|
|
1196
|
+
const isTTY = process.stdout.isTTY;
|
|
1197
|
+
const stripA = (s) => s.replace(/\x1b\[[^m]*m/g, '');
|
|
1198
|
+
const row = (content) => { const visible = stripA(content).length; const pad = ' '.repeat(Math.max(0, INNER - visible)); return isTTY ? `${FG_TEAL}║${RST}${content}${pad}${FG_TEAL}║${RST}` : `║${stripA(content)}${pad}║`; };
|
|
1199
|
+
const hr40 = (tl, fill, tr) => { const line = tl + fill.repeat(INNER) + tr; return isTTY ? `${FG_TEAL}${line}${RST}` : line; };
|
|
1200
|
+
console.log(); console.log(hr40('╔','═','╗'));
|
|
1201
|
+
console.log(row(` ${isTTY ? BOLD : ''}DRY-RUN SUMMARY${isTTY ? RST : ''}`));
|
|
1202
|
+
console.log(hr40('╠','═','╣'));
|
|
1203
|
+
console.log(row(` ✎ Files that would change: ${files.length} `));
|
|
1204
|
+
console.log(row(` ▶ Commands that would run: ${cmds.length} `));
|
|
1205
|
+
console.log(row(` ↓ Network calls: ${nets.length} `));
|
|
1206
|
+
console.log(hr40('╚','═','╝'));
|
|
1207
|
+
if (ops.length > 0) { console.log(); for (const op of ops) { if (isTTY) console.log(` ${op.symbol} ${FG_GRAY}${op.desc}${RST}`); else console.log(` ${op.symbol} ${op.desc}`); } }
|
|
1208
|
+
console.log();
|
|
1209
|
+
}
|
|
1210
|
+
|
|
511
1211
|
return {
|
|
512
1212
|
cmdChat,
|
|
513
1213
|
cmdCode,
|
|
@@ -515,7 +1215,6 @@ function createCommands({
|
|
|
515
1215
|
cmdInit,
|
|
516
1216
|
cmdLogin,
|
|
517
1217
|
cmdModels,
|
|
518
|
-
cmdModelsAdd,
|
|
519
1218
|
cmdShell,
|
|
520
1219
|
cmdLogout,
|
|
521
1220
|
cmdWhoAmI,
|