@semalt-ai/code 1.6.0 → 1.8.0

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