@semalt-ai/code 1.8.3 → 1.8.5
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 +3 -1
- package/CLAUDE.md +4 -1
- package/TECHNICAL_DEBT.md +66 -0
- package/index.js +23 -9
- package/lib/agent.js +407 -129
- package/lib/api.js +105 -39
- package/lib/args.js +22 -0
- package/lib/commands.js +367 -132
- package/lib/config.js +14 -0
- package/lib/constants.js +1 -1
- package/lib/debug.js +106 -0
- package/lib/permissions.js +9 -8
- package/lib/proc.js +96 -0
- package/lib/prompts.js +8 -10
- package/lib/tool_specs.js +14 -7
- package/lib/tools.js +299 -118
- package/lib/ui/chat-history.js +37 -8
- package/lib/ui/create-ui.js +63 -38
- package/lib/ui/diff.js +4 -3
- package/lib/ui/format.js +321 -0
- package/lib/ui/input-field.js +134 -59
- package/lib/ui/layout.js +0 -2
- package/lib/ui/messages.js +44 -0
- package/lib/ui/select.js +114 -0
- package/lib/ui/status-bar.js +135 -28
- package/lib/ui/stream.js +8 -12
- package/lib/ui/terminal.js +12 -4
- package/lib/ui/theme.js +25 -4
- package/lib/ui/utils.js +94 -27
- package/lib/ui/writer.js +391 -45
- package/lib/ui.js +6 -6
- package/package.json +1 -1
- package/lib/ui/legacy.js +0 -130
package/lib/commands.js
CHANGED
|
@@ -3,11 +3,72 @@
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
|
|
5
5
|
const { CONFIG_PATH, DEFAULT_API_TIMEOUT_MS, TAG_REGISTRY } = require('./constants');
|
|
6
|
-
const { configShow } = require('./config');
|
|
6
|
+
const { configShow, isNativeToolsActive } = require('./config');
|
|
7
7
|
const { getSystemPrompt } = require('./prompts');
|
|
8
8
|
const { SessionStorage } = require('./storage');
|
|
9
9
|
const { getSkippedOps, setUIActive } = require('./tools');
|
|
10
10
|
const { AUDIT_LOG } = require('./audit');
|
|
11
|
+
const { formatToolLine } = require('./ui/format');
|
|
12
|
+
const writerModule = require('./ui/writer');
|
|
13
|
+
const writer = writerModule;
|
|
14
|
+
const msgs = require('./ui/messages');
|
|
15
|
+
const dbg = require('./debug');
|
|
16
|
+
|
|
17
|
+
// Drop assistant.tool_calls and role:tool messages whose ids don't pair up.
|
|
18
|
+
// A loaded chat may contain role:tool with empty/missing tool_call_id (legacy
|
|
19
|
+
// rows, dropped fields in transit) or assistant.tool_calls without a matching
|
|
20
|
+
// tool response (truncated turn). Either side without its partner produces a
|
|
21
|
+
// 400 from strict providers like MiniMax — the validator in api.js will throw
|
|
22
|
+
// — so we strip both sides of the orphan pair before sending.
|
|
23
|
+
function cleanOrphanedToolMessages(msgs) {
|
|
24
|
+
const calledIds = new Set();
|
|
25
|
+
const respondedIds = new Set();
|
|
26
|
+
for (const m of msgs) {
|
|
27
|
+
if (m.role === 'assistant' && Array.isArray(m.tool_calls)) {
|
|
28
|
+
for (const tc of m.tool_calls) {
|
|
29
|
+
if (tc && tc.id) calledIds.add(tc.id);
|
|
30
|
+
}
|
|
31
|
+
} else if (m.role === 'tool' && m.tool_call_id) {
|
|
32
|
+
respondedIds.add(m.tool_call_id);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const paired = new Set();
|
|
36
|
+
for (const id of calledIds) if (respondedIds.has(id)) paired.add(id);
|
|
37
|
+
|
|
38
|
+
let droppedTool = 0;
|
|
39
|
+
let droppedAssistantCalls = 0;
|
|
40
|
+
let droppedAssistantMsgs = 0;
|
|
41
|
+
const out = [];
|
|
42
|
+
for (const m of msgs) {
|
|
43
|
+
if (m.role === 'tool') {
|
|
44
|
+
if (!m.tool_call_id || !paired.has(m.tool_call_id)) { droppedTool++; continue; }
|
|
45
|
+
out.push(m);
|
|
46
|
+
} else if (m.role === 'assistant' && Array.isArray(m.tool_calls)) {
|
|
47
|
+
const kept = m.tool_calls.filter((tc) => tc && tc.id && paired.has(tc.id));
|
|
48
|
+
droppedAssistantCalls += m.tool_calls.length - kept.length;
|
|
49
|
+
const hasContent = typeof m.content === 'string' && m.content.trim().length > 0;
|
|
50
|
+
if (kept.length === 0 && !hasContent) { droppedAssistantMsgs++; continue; }
|
|
51
|
+
const next = { ...m };
|
|
52
|
+
if (kept.length > 0) next.tool_calls = kept;
|
|
53
|
+
else delete next.tool_calls;
|
|
54
|
+
out.push(next);
|
|
55
|
+
} else {
|
|
56
|
+
out.push(m);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { messages: out, droppedTool, droppedAssistantCalls, droppedAssistantMsgs };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function reconstructLoadedMessage(m) {
|
|
63
|
+
const msg = { role: m.role, content: m.content };
|
|
64
|
+
if (m.tool_call_id !== undefined && m.tool_call_id !== null && m.tool_call_id !== '') {
|
|
65
|
+
msg.tool_call_id = m.tool_call_id;
|
|
66
|
+
}
|
|
67
|
+
if (Array.isArray(m.tool_calls) && m.tool_calls.length > 0) {
|
|
68
|
+
msg.tool_calls = m.tool_calls;
|
|
69
|
+
}
|
|
70
|
+
return msg;
|
|
71
|
+
}
|
|
11
72
|
|
|
12
73
|
function formatTimeAgo(ts) {
|
|
13
74
|
const diffMs = Date.now() - ts;
|
|
@@ -41,9 +102,8 @@ function createCommands({
|
|
|
41
102
|
FG_TEAL,
|
|
42
103
|
FG_YELLOW,
|
|
43
104
|
RST,
|
|
44
|
-
|
|
105
|
+
approxTokens,
|
|
45
106
|
getCols,
|
|
46
|
-
hr,
|
|
47
107
|
boxLine,
|
|
48
108
|
interactiveSelect,
|
|
49
109
|
createUI,
|
|
@@ -100,29 +160,54 @@ function createCommands({
|
|
|
100
160
|
return null;
|
|
101
161
|
}
|
|
102
162
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
163
|
+
// Pick the first dashboard model when the user is authenticated but has
|
|
164
|
+
// not selected one yet. Persists credentials to config and returns
|
|
165
|
+
// { name, modelId } on success; null otherwise (not logged in, already
|
|
166
|
+
// selected, empty list, or API error).
|
|
167
|
+
async function ensureDefaultModel() {
|
|
168
|
+
const config = getConfig();
|
|
169
|
+
if (!config.auth_token) return null;
|
|
170
|
+
if (config.default_model && config.dashboard_model_id) return null;
|
|
171
|
+
let response;
|
|
172
|
+
try { response = await dashboardListModels(); } catch { return null; }
|
|
173
|
+
const models = Array.isArray(response && response.models) ? response.models : [];
|
|
174
|
+
if (!models.length) return null;
|
|
175
|
+
const first = models[0];
|
|
176
|
+
let credResp;
|
|
177
|
+
try { credResp = await dashboardGetModelForCli(first.id); } catch { return null; }
|
|
178
|
+
const model = credResp && credResp.model ? credResp.model : null;
|
|
179
|
+
if (!model) return null;
|
|
180
|
+
const contextLength = (Number.isInteger(model.context_length) && model.context_length > 0 ? model.context_length : null)
|
|
181
|
+
|| (Number.isInteger(model.max_tokens) && model.max_tokens > 0 ? model.max_tokens : null);
|
|
182
|
+
const updated = { ...config, api_base: model.base_url, api_key: model.api_key, default_model: model.model_id, dashboard_model_id: model.id };
|
|
183
|
+
if (contextLength !== null) updated.context_length = contextLength;
|
|
184
|
+
setConfig(updated);
|
|
185
|
+
return { name: model.name, modelId: model.model_id };
|
|
113
186
|
}
|
|
114
187
|
|
|
115
188
|
async function cmdChat(opts) {
|
|
189
|
+
await ensureDefaultModel();
|
|
190
|
+
|
|
191
|
+
// Build the three end-of-session artifacts that teardown emits as
|
|
192
|
+
// scrollback. Returning them as a plain object lets both exit paths
|
|
193
|
+
// (/exit submit and Ctrl+C onInterrupt) route through writer.teardown,
|
|
194
|
+
// which is the only place that can append them below the erased live
|
|
195
|
+
// region in a single atomic write.
|
|
196
|
+
function buildExitArtifacts() {
|
|
197
|
+
return {
|
|
198
|
+
summary: sessionMetrics ? sessionMetrics.summary() : '',
|
|
199
|
+
resumeHint: currentChatId !== null
|
|
200
|
+
? ` ${FG_DARK}Resume this chat: ${FG_CYAN}semalt-code --resume ${currentChatId}${RST}`
|
|
201
|
+
: '',
|
|
202
|
+
goodbye: ` ${FG_GRAY}Goodbye!${RST}`,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
116
206
|
const { chatHistory, statusBar, inputField, layout, destroy, redrawFixed } = createUI({
|
|
117
207
|
showThink: opts.showThink || false,
|
|
118
208
|
onInterrupt: (destroyFn) => {
|
|
119
209
|
saveSession();
|
|
120
|
-
destroyFn();
|
|
121
|
-
if (sessionMetrics) console.log('\n' + sessionMetrics.summary());
|
|
122
|
-
if (currentChatId !== null) {
|
|
123
|
-
console.log(` ${FG_DARK}Resume this chat: ${FG_CYAN}semalt-code --resume ${currentChatId}${RST}`);
|
|
124
|
-
}
|
|
125
|
-
console.log(`\n ${FG_GRAY}Goodbye!${RST}\n`);
|
|
210
|
+
destroyFn(buildExitArtifacts());
|
|
126
211
|
process.exit(0);
|
|
127
212
|
},
|
|
128
213
|
});
|
|
@@ -157,12 +242,20 @@ function createCommands({
|
|
|
157
242
|
let currentModel = opts.model || getConfig().default_model;
|
|
158
243
|
let resolvedTokenLimit = await resolveTokenLimit(currentModel);
|
|
159
244
|
statusBar.setModel(currentModel);
|
|
245
|
+
// Seed the context indicator with the profile's limit up-front so it
|
|
246
|
+
// renders "0 / 200,000 tok (0%)" before the first API response, instead
|
|
247
|
+
// of appearing out of thin air once a turn completes.
|
|
248
|
+
statusBar.setContextLimit(resolvedTokenLimit);
|
|
160
249
|
let sessionMetrics = null;
|
|
161
250
|
// system prompt is prepended fresh on every API call in agent.js — never stored in history
|
|
162
251
|
let messages = [];
|
|
163
252
|
let currentChatId = null;
|
|
164
253
|
let savedUpTo = 0;
|
|
165
|
-
|
|
254
|
+
// The agent loop's per-iteration `formatDebugBlock` runs whenever any
|
|
255
|
+
// debug mode is active. In simple mode the block is rendered as a TUI
|
|
256
|
+
// chat bubble (cb.onDebug → addMessage). In file mode emitDebug routes
|
|
257
|
+
// the block to the debug file instead, keeping the TUI clean.
|
|
258
|
+
let debugMode = dbg.isActive();
|
|
166
259
|
|
|
167
260
|
// Resolve system prompt override from --system-prompt file if provided
|
|
168
261
|
let resolvedSystemPrompt = null;
|
|
@@ -252,7 +345,15 @@ function createCommands({
|
|
|
252
345
|
if (currentChatId === null) return;
|
|
253
346
|
const newMessages = messages.slice(savedUpTo).filter((m) => m.role !== 'system');
|
|
254
347
|
if (!newMessages.length) return;
|
|
255
|
-
try {
|
|
348
|
+
try {
|
|
349
|
+
const resp = await dashboardSaveMessages(currentChatId, newMessages);
|
|
350
|
+
savedUpTo = messages.length;
|
|
351
|
+
if (resp && typeof resp.skipped_count === 'number' && resp.skipped_count > 0) {
|
|
352
|
+
msgs.sysWarn(`history save: ${resp.skipped_count} message(s) skipped by server`);
|
|
353
|
+
}
|
|
354
|
+
} catch (err) {
|
|
355
|
+
msgs.sysWarn(`history save failed: ${err && err.message ? err.message : String(err)}`);
|
|
356
|
+
}
|
|
256
357
|
}
|
|
257
358
|
|
|
258
359
|
function displayLoadedMessages(loadedMessages) {
|
|
@@ -263,7 +364,7 @@ function createCommands({
|
|
|
263
364
|
const ts = m.created_at ? new Date(m.created_at) : (m.ts ? new Date(m.ts) : new Date());
|
|
264
365
|
|
|
265
366
|
if (m.role === 'tool') {
|
|
266
|
-
chatHistory.addMessage({ role: 'tool', tag: 'tool', content:
|
|
367
|
+
chatHistory.addMessage({ role: 'tool', tag: 'tool', content: raw, ts });
|
|
267
368
|
continue;
|
|
268
369
|
}
|
|
269
370
|
|
|
@@ -272,7 +373,7 @@ function createCommands({
|
|
|
272
373
|
.replace(/^Tool execution results[^\n]*\n+/, '')
|
|
273
374
|
.replace(/\n+Continue with the task\.[\s\S]*$/, '')
|
|
274
375
|
.trim();
|
|
275
|
-
chatHistory.addMessage({ role: 'tool', tag: 'tool', content:
|
|
376
|
+
chatHistory.addMessage({ role: 'tool', tag: 'tool', content: body || raw, ts });
|
|
276
377
|
continue;
|
|
277
378
|
}
|
|
278
379
|
|
|
@@ -281,6 +382,34 @@ function createCommands({
|
|
|
281
382
|
}
|
|
282
383
|
}
|
|
283
384
|
|
|
385
|
+
// After loading a saved chat (via --resume, /history, or /chats), the
|
|
386
|
+
// status bar has no API-reported prompt_tokens to display until the next
|
|
387
|
+
// turn completes — the indicator would sit at 0 until the user sends a
|
|
388
|
+
// follow-up. Seed it with a client-side estimate of the loaded messages
|
|
389
|
+
// using the same approxTokens estimator the live `addPendingTokens` path
|
|
390
|
+
// uses, then push it through `updateMetrics({contextTokens})` — the same
|
|
391
|
+
// setter agent.js wires up via cb.onMetricsUpdate. The next real turn
|
|
392
|
+
// overwrites this with the API's authoritative prompt_tokens.
|
|
393
|
+
function seedContextFromMessages() {
|
|
394
|
+
let total = 0;
|
|
395
|
+
for (const m of messages) {
|
|
396
|
+
if (typeof m.content === 'string') total += approxTokens(m.content);
|
|
397
|
+
}
|
|
398
|
+
statusBar.updateMetrics({ contextTokens: total });
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function emitCleanupWarning(cleanup) {
|
|
402
|
+
if (cleanup.droppedTool === 0 && cleanup.droppedAssistantCalls === 0 && cleanup.droppedAssistantMsgs === 0) return;
|
|
403
|
+
const parts = [];
|
|
404
|
+
if (cleanup.droppedTool > 0) parts.push(`${cleanup.droppedTool} orphaned tool result(s)`);
|
|
405
|
+
if (cleanup.droppedAssistantCalls > 0) parts.push(`${cleanup.droppedAssistantCalls} dangling tool_call(s)`);
|
|
406
|
+
if (cleanup.droppedAssistantMsgs > 0) parts.push(`${cleanup.droppedAssistantMsgs} empty assistant message(s)`);
|
|
407
|
+
chatHistory.addMessage({
|
|
408
|
+
role: 'system',
|
|
409
|
+
content: `⚠ Loaded chat had ${parts.join(', ')}, cleaned up. The chat may be missing some context.`,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
284
413
|
// --resume: load previous chat
|
|
285
414
|
if (opts.resume) {
|
|
286
415
|
const resumeId = parseInt(opts.resume, 10);
|
|
@@ -288,22 +417,28 @@ function createCommands({
|
|
|
288
417
|
try {
|
|
289
418
|
const chatData = await dashboardGetChat(resumeId);
|
|
290
419
|
const loaded = chatData && chatData.messages ? chatData.messages : [];
|
|
291
|
-
for (const m of loaded) messages.push(
|
|
420
|
+
for (const m of loaded) messages.push(reconstructLoadedMessage(m));
|
|
421
|
+
const cleanup = cleanOrphanedToolMessages(messages);
|
|
422
|
+
messages = cleanup.messages;
|
|
292
423
|
currentChatId = resumeId;
|
|
293
424
|
savedUpTo = messages.length;
|
|
294
425
|
const title = chatData.chat && chatData.chat.title ? chatData.chat.title : `#${resumeId}`;
|
|
295
426
|
displayLoadedMessages(loaded);
|
|
296
427
|
chatHistory.addMessage({ role: 'system', content: `✓ Resumed: ${title} (${loaded.length} messages)` });
|
|
428
|
+
emitCleanupWarning(cleanup);
|
|
429
|
+
seedContextFromMessages();
|
|
297
430
|
} catch (error) {
|
|
298
431
|
chatHistory.addMessage({ role: 'system', content: `✗ Could not resume chat: ${error.message}`, isError: true });
|
|
299
432
|
}
|
|
300
433
|
}
|
|
301
434
|
}
|
|
302
435
|
|
|
303
|
-
// Pending selection state (for in-chat /history, /models, /chats)
|
|
436
|
+
// Pending selection state (for in-chat /history, /models, /chats).
|
|
437
|
+
// The picker renders into the writer's modal region — same band as the
|
|
438
|
+
// permission picker — so navigation redraws in place and only the final
|
|
439
|
+
// selection (or cancellation) leaves a line in scrollback.
|
|
304
440
|
let pendingAction = null;
|
|
305
441
|
const PAGE_SIZE = 5;
|
|
306
|
-
let listMsg = null;
|
|
307
442
|
|
|
308
443
|
function getNavSearchText(type, item) {
|
|
309
444
|
if (type === 'history') {
|
|
@@ -361,31 +496,27 @@ function createCommands({
|
|
|
361
496
|
return parts.join('\n');
|
|
362
497
|
}
|
|
363
498
|
|
|
364
|
-
function collapseListMsg(
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
listMsg = null;
|
|
499
|
+
function collapseListMsg(_type, _item) {
|
|
500
|
+
// Modal is transient — clearing it removes the picker from view; the
|
|
501
|
+
// selection's success line is emitted to scrollback by
|
|
502
|
+
// handlePendingSelection.
|
|
503
|
+
writer.clearModal();
|
|
370
504
|
}
|
|
371
505
|
|
|
372
506
|
function showPendingStep() {
|
|
373
507
|
if (!pendingAction) return;
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
508
|
+
const lines = buildListContent().split('\n');
|
|
509
|
+
// Match the system-message bubble look so the modal reads as part of
|
|
510
|
+
// the same chat block: muted bullet on the title row, indented
|
|
511
|
+
// continuations underneath.
|
|
512
|
+
const modalLines = lines.length > 0
|
|
513
|
+
? [` ${FG_GRAY}●${RST} ${FG_GRAY}${lines[0]}${RST}`].concat(lines.slice(1).map((l) => ` ${l}`))
|
|
514
|
+
: [];
|
|
515
|
+
writer.setModal(modalLines);
|
|
382
516
|
}
|
|
383
517
|
|
|
384
518
|
function finalizeListMsg() {
|
|
385
|
-
|
|
386
|
-
chatHistory.removeById(listMsg.id);
|
|
387
|
-
listMsg = null;
|
|
388
|
-
}
|
|
519
|
+
writer.clearModal();
|
|
389
520
|
}
|
|
390
521
|
|
|
391
522
|
function activateNavCapture() {
|
|
@@ -440,26 +571,35 @@ function createCommands({
|
|
|
440
571
|
if (type === 'history') {
|
|
441
572
|
const loaded = storage.load(activeItems[idx].id);
|
|
442
573
|
if (loaded) {
|
|
443
|
-
|
|
574
|
+
const filtered = (loaded.messages || []).filter((m) => m.role !== 'system');
|
|
575
|
+
const cleanup = cleanOrphanedToolMessages(filtered);
|
|
576
|
+
messages = cleanup.messages;
|
|
444
577
|
session = { id: loaded.id, created_at: loaded.created_at, model: loaded.model, messages, stats: loaded.stats || { total_tokens: 0, duration_sec: 0 } };
|
|
445
578
|
currentChatId = null; savedUpTo = 0;
|
|
446
579
|
if (loaded.model && loaded.model !== currentModel) {
|
|
447
580
|
currentModel = loaded.model;
|
|
448
581
|
resolvedTokenLimit = await resolveTokenLimit(currentModel);
|
|
449
582
|
statusBar.setModel(currentModel);
|
|
583
|
+
statusBar.setContextLimit(resolvedTokenLimit);
|
|
450
584
|
}
|
|
451
585
|
displayLoadedMessages(messages);
|
|
452
586
|
chatHistory.addMessage({ role: 'system', content: `✓ Session loaded. Model → ${currentModel}` });
|
|
587
|
+
emitCleanupWarning(cleanup);
|
|
588
|
+
seedContextFromMessages();
|
|
453
589
|
}
|
|
454
590
|
} else if (type === 'chats') {
|
|
455
591
|
const selectedChat = activeItems[idx];
|
|
456
592
|
try {
|
|
457
593
|
const chatData = await dashboardGetChat(selectedChat.id);
|
|
458
594
|
const loaded = chatData && chatData.messages ? chatData.messages : [];
|
|
459
|
-
|
|
595
|
+
const reconstructed = loaded.map(reconstructLoadedMessage);
|
|
596
|
+
const cleanup = cleanOrphanedToolMessages(reconstructed);
|
|
597
|
+
messages = cleanup.messages;
|
|
460
598
|
currentChatId = selectedChat.id; savedUpTo = messages.length;
|
|
461
599
|
displayLoadedMessages(loaded);
|
|
462
600
|
chatHistory.addMessage({ role: 'system', content: `✓ Resumed: ${selectedChat.title} (${loaded.length} messages)` });
|
|
601
|
+
emitCleanupWarning(cleanup);
|
|
602
|
+
seedContextFromMessages();
|
|
463
603
|
} catch (err) {
|
|
464
604
|
chatHistory.addMessage({ role: 'system', content: `✗ ${err.message}`, isError: true });
|
|
465
605
|
}
|
|
@@ -478,6 +618,7 @@ function createCommands({
|
|
|
478
618
|
currentModel = model.model_id;
|
|
479
619
|
resolvedTokenLimit = await resolveTokenLimit(currentModel);
|
|
480
620
|
statusBar.setModel(currentModel);
|
|
621
|
+
statusBar.setContextLimit(resolvedTokenLimit);
|
|
481
622
|
currentChatId = null;
|
|
482
623
|
chatHistory.addMessage({ role: 'system', content: `✓ Model → ${model.name} (${model.model_id})` });
|
|
483
624
|
statusBar.update('idle');
|
|
@@ -535,7 +676,7 @@ function createCommands({
|
|
|
535
676
|
// Exit
|
|
536
677
|
if (['exit', 'quit', '/exit', '/quit'].includes(text.toLowerCase())) {
|
|
537
678
|
saveSession();
|
|
538
|
-
destroy();
|
|
679
|
+
destroy(buildExitArtifacts());
|
|
539
680
|
resolveExit();
|
|
540
681
|
return;
|
|
541
682
|
}
|
|
@@ -569,6 +710,7 @@ function createCommands({
|
|
|
569
710
|
}
|
|
570
711
|
|
|
571
712
|
if (text === '/history') {
|
|
713
|
+
if (!getConfig().auth_token) { chatHistory.addMessage({ role: 'system', content: 'Not logged in. Run /login first.' }); return; }
|
|
572
714
|
const sessions = storage.list();
|
|
573
715
|
if (!sessions.length) { chatHistory.addMessage({ role: 'system', content: 'No saved sessions.' }); return; }
|
|
574
716
|
refreshInputSearchItems();
|
|
@@ -617,6 +759,14 @@ function createCommands({
|
|
|
617
759
|
inputField.setDisabled(true);
|
|
618
760
|
statusBar.update('thinking', 'Starting login...');
|
|
619
761
|
await _loginFlow(chatHistory, statusBar);
|
|
762
|
+
const picked = await ensureDefaultModel();
|
|
763
|
+
if (picked) {
|
|
764
|
+
currentModel = picked.modelId;
|
|
765
|
+
resolvedTokenLimit = await resolveTokenLimit(currentModel);
|
|
766
|
+
statusBar.setModel(currentModel);
|
|
767
|
+
statusBar.setContextLimit(resolvedTokenLimit);
|
|
768
|
+
chatHistory.addMessage({ role: 'system', content: `✓ Model → ${picked.name} (${picked.modelId})` });
|
|
769
|
+
}
|
|
620
770
|
statusBar.update('idle');
|
|
621
771
|
inputField.setDisabled(false);
|
|
622
772
|
return;
|
|
@@ -666,6 +816,7 @@ function createCommands({
|
|
|
666
816
|
}
|
|
667
817
|
|
|
668
818
|
if (text === '/models') {
|
|
819
|
+
if (!getConfig().auth_token) { chatHistory.addMessage({ role: 'system', content: 'Not logged in. Run /login first.' }); return; }
|
|
669
820
|
inputField.setDisabled(true);
|
|
670
821
|
statusBar.update('thinking', 'Loading models...');
|
|
671
822
|
try {
|
|
@@ -688,14 +839,17 @@ function createCommands({
|
|
|
688
839
|
}
|
|
689
840
|
|
|
690
841
|
if (text === '/model') {
|
|
842
|
+
if (!getConfig().auth_token) { chatHistory.addMessage({ role: 'system', content: 'Not logged in. Run /login first.' }); return; }
|
|
691
843
|
chatHistory.addMessage({ role: 'system', content: `Current model: ${currentModel}` });
|
|
692
844
|
return;
|
|
693
845
|
}
|
|
694
846
|
|
|
695
847
|
if (text.startsWith('/model ')) {
|
|
848
|
+
if (!getConfig().auth_token) { chatHistory.addMessage({ role: 'system', content: 'Not logged in. Run /login first.' }); return; }
|
|
696
849
|
currentModel = text.slice(7).trim();
|
|
697
850
|
resolvedTokenLimit = await resolveTokenLimit(currentModel);
|
|
698
851
|
statusBar.setModel(currentModel);
|
|
852
|
+
statusBar.setContextLimit(resolvedTokenLimit);
|
|
699
853
|
chatHistory.addMessage({ role: 'system', content: `✓ Model → ${currentModel}` });
|
|
700
854
|
return;
|
|
701
855
|
}
|
|
@@ -722,7 +876,8 @@ function createCommands({
|
|
|
722
876
|
}
|
|
723
877
|
|
|
724
878
|
if (text === '/prompt') {
|
|
725
|
-
const
|
|
879
|
+
const nativeTools = isNativeToolsActive(currentModel);
|
|
880
|
+
const activePrompt = resolvedSystemPrompt !== null ? resolvedSystemPrompt : getSystemPrompt(nativeTools);
|
|
726
881
|
const src = resolvedSystemPrompt !== null ? `file: ${opts.systemPromptFile}` : 'built-in';
|
|
727
882
|
const mode = getConfig().system_prompt_mode || 'system_role';
|
|
728
883
|
chatHistory.addMessage({
|
|
@@ -765,9 +920,12 @@ function createCommands({
|
|
|
765
920
|
tail = '\nNo audit log found.';
|
|
766
921
|
}
|
|
767
922
|
|
|
923
|
+
const sink = dbg.isFile()
|
|
924
|
+
? `file (${dbg.getMode()} mode)`
|
|
925
|
+
: 'inline chat history';
|
|
768
926
|
chatHistory.addMessage({
|
|
769
927
|
role: 'system',
|
|
770
|
-
content: `Debug output: ${debugMode ? 'ON' : 'OFF'}
|
|
928
|
+
content: `Debug output: ${debugMode ? 'ON' : 'OFF'} → ${sink}${tail}`,
|
|
771
929
|
});
|
|
772
930
|
return;
|
|
773
931
|
}
|
|
@@ -803,6 +961,11 @@ function createCommands({
|
|
|
803
961
|
inputField.setDisabled(true);
|
|
804
962
|
chatHistory.addMessage({ role: 'user', content: text });
|
|
805
963
|
statusBar.update('thinking', 'Thinking...');
|
|
964
|
+
// Bump the context-size indicator with this user message's approximate
|
|
965
|
+
// token count. It'll be overwritten with the exact prompt_tokens from
|
|
966
|
+
// the API response when the first turn completes — this just keeps the
|
|
967
|
+
// indicator reactive in the gap before that.
|
|
968
|
+
statusBar.addPendingTokens(approxTokens(text));
|
|
806
969
|
await createChatIfNeeded(text);
|
|
807
970
|
messages.push({ role: 'user', content: text });
|
|
808
971
|
|
|
@@ -845,9 +1008,14 @@ function createCommands({
|
|
|
845
1008
|
chatHistory.addMessage({ role: 'think', content });
|
|
846
1009
|
statusBar.update('streaming', 'Streaming response');
|
|
847
1010
|
},
|
|
848
|
-
|
|
1011
|
+
onPermissionAsk: (tag, input) => {
|
|
1012
|
+
// Status-bar update fires while the permission picker is open so
|
|
1013
|
+
// the user can see what's pending in the side label, not just
|
|
1014
|
+
// inside the modal. Mirrors the labels onToolStart uses post-grant
|
|
1015
|
+
// — the next streaming/idle state will overwrite this when the
|
|
1016
|
+
// picker closes (whether granted or denied).
|
|
849
1017
|
const actionLabel = TAG_REGISTRY[tag]?.label || tag;
|
|
850
|
-
const short = input.length > 40 ? input.slice(0, 40) + '…' : input;
|
|
1018
|
+
const short = input && input.length > 40 ? input.slice(0, 40) + '…' : (input || '');
|
|
851
1019
|
const isDownload = tag === 'download' || tag === 'http_get';
|
|
852
1020
|
if (isDownload) {
|
|
853
1021
|
statusBar.update('waiting_download', `Waiting for download: ${short}`);
|
|
@@ -855,19 +1023,79 @@ function createCommands({
|
|
|
855
1023
|
statusBar.update('tool', `${actionLabel}: ${short}`);
|
|
856
1024
|
}
|
|
857
1025
|
},
|
|
858
|
-
|
|
859
|
-
const
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
content: `${tag} ✕ [${durationMs}ms]`,
|
|
865
|
-
output: typeof result === 'string' && result.trim() ? result : null,
|
|
866
|
-
});
|
|
867
|
-
statusBar.update('streaming', 'Streaming response');
|
|
1026
|
+
onToolStart: (tag, input, ctx) => {
|
|
1027
|
+
const actionLabel = TAG_REGISTRY[tag]?.label || tag;
|
|
1028
|
+
const short = input && input.length > 40 ? input.slice(0, 40) + '…' : (input || '');
|
|
1029
|
+
const isDownload = tag === 'download' || tag === 'http_get';
|
|
1030
|
+
if (isDownload) {
|
|
1031
|
+
statusBar.update('waiting_download', `Waiting for download: ${short}`);
|
|
868
1032
|
} else {
|
|
869
|
-
|
|
870
|
-
|
|
1033
|
+
statusBar.update('tool', `${actionLabel}: ${short}`);
|
|
1034
|
+
}
|
|
1035
|
+
// Register the invocation with the writer's activity region.
|
|
1036
|
+
// The render function is re-invoked by the writer on every
|
|
1037
|
+
// redraw so the pending line's elapsed time stays current with
|
|
1038
|
+
// the ticker cadence without an explicit refresh timer.
|
|
1039
|
+
//
|
|
1040
|
+
// ask_user is the only currently-blocking tool — it pauses the
|
|
1041
|
+
// agent until the user responds via the modal. A ticking
|
|
1042
|
+
// elapsed-time meter on a paused tool is misleading ("13s"
|
|
1043
|
+
// suggests work is happening), and the per-tick redraw
|
|
1044
|
+
// interacts badly with the open modal (see TECHNICAL_DEBT.md).
|
|
1045
|
+
// Render once with no duration meta and freeze. Replace this
|
|
1046
|
+
// name check with a category flag (e.g. blocking: true on the
|
|
1047
|
+
// tool spec) if more blocking tools appear.
|
|
1048
|
+
if (ctx && ctx.id) {
|
|
1049
|
+
if (tag === 'ask_user') {
|
|
1050
|
+
const staticLine = formatToolLine({
|
|
1051
|
+
status: 'pending',
|
|
1052
|
+
tag,
|
|
1053
|
+
arg: input,
|
|
1054
|
+
attrs: ctx.attrs,
|
|
1055
|
+
noDuration: true,
|
|
1056
|
+
});
|
|
1057
|
+
writerModule.startActivity(ctx.id, () => staticLine);
|
|
1058
|
+
} else {
|
|
1059
|
+
writerModule.startActivity(ctx.id, (elapsedMs) => formatToolLine({
|
|
1060
|
+
status: 'pending',
|
|
1061
|
+
tag,
|
|
1062
|
+
arg: input,
|
|
1063
|
+
attrs: ctx.attrs,
|
|
1064
|
+
durationMs: elapsedMs,
|
|
1065
|
+
}));
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
},
|
|
1069
|
+
onToolEnd: (tag, result, durationMs, ctx) => {
|
|
1070
|
+
const hasError = !!(ctx && ctx.error);
|
|
1071
|
+
const isBlocking = tag === 'ask_user';
|
|
1072
|
+
const finalLine = formatToolLine({
|
|
1073
|
+
status: hasError ? 'failure' : 'success',
|
|
1074
|
+
tag,
|
|
1075
|
+
arg: ctx && ctx.attrs ? (ctx.attrs.command || ctx.attrs.path || ctx.attrs.url || ctx.attrs.src || ctx.attrs.key || ctx.attrs.name || ctx.attrs.pattern) : '',
|
|
1076
|
+
attrs: ctx ? ctx.attrs : null,
|
|
1077
|
+
durationMs,
|
|
1078
|
+
meta: ctx ? ctx.meta : null,
|
|
1079
|
+
error: ctx ? ctx.error : null,
|
|
1080
|
+
noDuration: isBlocking,
|
|
1081
|
+
});
|
|
1082
|
+
if (ctx && ctx.id) {
|
|
1083
|
+
writerModule.endActivity(ctx.id, finalLine);
|
|
1084
|
+
} else {
|
|
1085
|
+
// No invocation id means the agent-loop wasn't upgraded to pass
|
|
1086
|
+
// structured context (shouldn't happen in practice). Fall back
|
|
1087
|
+
// to a direct scrollback line so the tool still leaves a trace.
|
|
1088
|
+
writerModule.scrollback(finalLine);
|
|
1089
|
+
}
|
|
1090
|
+
if (hasError) {
|
|
1091
|
+
// Preserve the expandable error body as a follow-up tool
|
|
1092
|
+
// bubble. Empty content suppresses its header so the scrollback
|
|
1093
|
+
// line above (written by endActivity) isn't duplicated.
|
|
1094
|
+
const body = typeof result === 'string' && result.trim() ? result : null;
|
|
1095
|
+
if (body) {
|
|
1096
|
+
chatHistory.addMessage({ role: 'tool', tag, content: '', output: body, isError: true });
|
|
1097
|
+
}
|
|
1098
|
+
statusBar.update('streaming', 'Streaming response');
|
|
871
1099
|
}
|
|
872
1100
|
},
|
|
873
1101
|
onToken: (token) => {
|
|
@@ -958,18 +1186,13 @@ function createCommands({
|
|
|
958
1186
|
saveSession();
|
|
959
1187
|
});
|
|
960
1188
|
|
|
961
|
-
// Wait until user exits
|
|
1189
|
+
// Wait until user exits. The /exit submit handler already ran
|
|
1190
|
+
// destroy(buildExitArtifacts()), so the session summary, resume hint,
|
|
1191
|
+
// and goodbye have been emitted as scrollback inside teardown's
|
|
1192
|
+
// single atomic write. Nothing more to print here.
|
|
962
1193
|
await exitPromise;
|
|
963
1194
|
setUIActive(false);
|
|
964
1195
|
saveSession();
|
|
965
|
-
if (sessionMetrics) {
|
|
966
|
-
// Show summary in terminal after destroy
|
|
967
|
-
console.log('\n' + sessionMetrics.summary());
|
|
968
|
-
}
|
|
969
|
-
if (currentChatId !== null) {
|
|
970
|
-
console.log(` ${FG_DARK}Resume this chat: ${FG_CYAN}semalt-code --resume ${currentChatId}${RST}`);
|
|
971
|
-
}
|
|
972
|
-
console.log(`\n ${FG_GRAY}Goodbye!${RST}\n`);
|
|
973
1196
|
}
|
|
974
1197
|
|
|
975
1198
|
async function _loginFlow(chatHistory, statusBar) {
|
|
@@ -1005,8 +1228,9 @@ function createCommands({
|
|
|
1005
1228
|
}
|
|
1006
1229
|
|
|
1007
1230
|
async function cmdCode(opts, promptArgs) {
|
|
1008
|
-
if (!promptArgs.length) {
|
|
1009
|
-
if (!getConfig().auth_token) {
|
|
1231
|
+
if (!promptArgs.length) { writer.scrollback(` ${FG_RED}Usage: semalt-code code <prompt>${RST}`); return; }
|
|
1232
|
+
if (!getConfig().auth_token) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}Not logged in. Run semalt-code login first.${RST}\n`); return; }
|
|
1233
|
+
await ensureDefaultModel();
|
|
1010
1234
|
const model = opts.model || getConfig().default_model;
|
|
1011
1235
|
const userPrompt = promptArgs.join(' ');
|
|
1012
1236
|
const context = opts.file ? readFileContext(opts.file) : '';
|
|
@@ -1016,62 +1240,60 @@ function createCommands({
|
|
|
1016
1240
|
try { resolvedSystemPrompt = fs.readFileSync(opts.systemPromptFile, 'utf8'); } catch {}
|
|
1017
1241
|
}
|
|
1018
1242
|
let messages = [{ role: 'user', content: fullPrompt }];
|
|
1019
|
-
|
|
1020
|
-
statusBar.update({ model, status: 'thinking' });
|
|
1243
|
+
writer.scrollback(` ${FG_GRAY}◆ ${model}${RST}`);
|
|
1021
1244
|
const codeResult = await runAgentLoop(messages, model, undefined, null, {
|
|
1022
|
-
debug:
|
|
1245
|
+
debug: dbg.isActive(),
|
|
1023
1246
|
systemPrompt: resolvedSystemPrompt,
|
|
1024
1247
|
systemPromptMode: getConfig().system_prompt_mode || 'system_role',
|
|
1025
1248
|
});
|
|
1026
1249
|
messages = codeResult.messages;
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
if (codeResult.metrics) console.log(codeResult.metrics.summary());
|
|
1250
|
+
writer.scrollback('\n');
|
|
1251
|
+
if (codeResult.metrics) writer.scrollback(codeResult.metrics.summary());
|
|
1030
1252
|
if (opts.dryRun) printDryRunSummary();
|
|
1031
1253
|
}
|
|
1032
1254
|
|
|
1033
1255
|
async function cmdEdit(opts, filePath, instructionArgs) {
|
|
1034
|
-
if (!filePath) {
|
|
1035
|
-
if (!getConfig().auth_token) {
|
|
1036
|
-
if (!fs.existsSync(filePath)) {
|
|
1256
|
+
if (!filePath) { writer.scrollback(` ${FG_RED}Usage: semalt-code edit <file> <instruction>${RST}`); return; }
|
|
1257
|
+
if (!getConfig().auth_token) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}Not logged in. Run semalt-code login first.${RST}\n`); return; }
|
|
1258
|
+
if (!fs.existsSync(filePath)) { writer.scrollback(` ${FG_RED}✗ File not found: ${filePath}${RST}`); return; }
|
|
1259
|
+
await ensureDefaultModel();
|
|
1037
1260
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
1038
1261
|
const instruction = instructionArgs.join(' ');
|
|
1039
1262
|
const messages = [
|
|
1040
1263
|
{ role: 'system', content: 'You are Semalt.AI. Output ONLY the modified file. No explanations, no fences.' },
|
|
1041
1264
|
{ role: 'user', content: `File: ${filePath}\n\n\`\`\`\n${content}\n\`\`\`\n\nInstruction: ${instruction}` },
|
|
1042
1265
|
];
|
|
1043
|
-
|
|
1044
|
-
const editStatusBar = new StatusBar();
|
|
1045
|
-
editStatusBar.update({ model: opts.model || getConfig().default_model, status: 'editing' });
|
|
1266
|
+
writer.scrollback(` ${FG_GRAY}Editing ${filePath}...${RST}`);
|
|
1046
1267
|
let result = await chatSync(messages, { model: opts.model });
|
|
1047
|
-
editStatusBar.destroy();
|
|
1048
1268
|
if (result && !opts.dryRun) {
|
|
1049
1269
|
if (result.startsWith('```')) { const lines = result.split('\n'); result = lines.at(-1).trim() === '```' ? lines.slice(1, -1).join('\n') : lines.slice(1).join('\n'); }
|
|
1050
1270
|
fs.writeFileSync(filePath, result);
|
|
1051
|
-
|
|
1271
|
+
writer.scrollback(` ${FG_GREEN}✓ Saved: ${filePath}${RST}`);
|
|
1052
1272
|
} else if (opts.dryRun) {
|
|
1053
|
-
|
|
1273
|
+
writer.scrollback(` ${FG_YELLOW}⚠ Dry run — not modified${RST}`);
|
|
1054
1274
|
}
|
|
1055
1275
|
}
|
|
1056
1276
|
|
|
1057
1277
|
async function cmdShell(opts, commandArgs) {
|
|
1058
1278
|
const command = commandArgs.join(' ');
|
|
1059
|
-
if (!command) {
|
|
1279
|
+
if (!command) { writer.scrollback(` ${FG_RED}Usage: semalt-code shell <command>${RST}`); return; }
|
|
1060
1280
|
const result = await agentExecShell(command);
|
|
1061
1281
|
if (opts.analyze) {
|
|
1062
|
-
if (!getConfig().auth_token) {
|
|
1282
|
+
if (!getConfig().auth_token) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}Not logged in. Run semalt-code login first.${RST}\n`); return; }
|
|
1283
|
+
await ensureDefaultModel();
|
|
1063
1284
|
const messages = [
|
|
1064
1285
|
{ role: 'system', content: 'You are Semalt.AI. Analyze the command output concisely.' },
|
|
1065
1286
|
{ role: 'user', content: `Command: ${command}\nExit: ${result.exit_code}\nStdout:\n${result.stdout}\nStderr:\n${result.stderr}` },
|
|
1066
1287
|
];
|
|
1067
|
-
|
|
1288
|
+
writer.scrollback(`\n ${FG_TEAL}${BOLD}◆ Semalt.AI${RST}\n`);
|
|
1289
|
+
// audit: allowed — non-TUI streaming prefix, must precede StreamRenderer sync writes.
|
|
1068
1290
|
process.stdout.write(' ');
|
|
1069
1291
|
try {
|
|
1070
1292
|
await chatStream(messages, { model: opts.model });
|
|
1071
1293
|
} catch (err) {
|
|
1072
|
-
|
|
1294
|
+
msgs.netError(err.message);
|
|
1073
1295
|
}
|
|
1074
|
-
|
|
1296
|
+
writer.scrollback('\n');
|
|
1075
1297
|
}
|
|
1076
1298
|
}
|
|
1077
1299
|
|
|
@@ -1079,10 +1301,10 @@ function createCommands({
|
|
|
1079
1301
|
const config = getConfig();
|
|
1080
1302
|
let response;
|
|
1081
1303
|
try { response = await dashboardListModels(); }
|
|
1082
|
-
catch (err) {
|
|
1304
|
+
catch (err) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}${err.message}${RST}\n`); return { model: config.default_model, dbId: config.dashboard_model_id }; }
|
|
1083
1305
|
const models = Array.isArray(response && response.models) ? response.models : [];
|
|
1084
|
-
if (!models.length) {
|
|
1085
|
-
|
|
1306
|
+
if (!models.length) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}No models available.${RST}\n`); return { model: config.default_model, dbId: config.dashboard_model_id }; }
|
|
1307
|
+
writer.scrollback(`\n ${FG_TEAL}${BOLD}◆ Your Models${RST}\n ${FG_DARK}${'─'.repeat(60)}${RST}`);
|
|
1086
1308
|
const activeIndex = models.findIndex((m) => m.base_url === config.api_base && m.model_id === config.default_model);
|
|
1087
1309
|
const selectedIndex = await interactiveSelect(models, (model, isSelected, isFinal) => {
|
|
1088
1310
|
const active = model.base_url === config.api_base && model.model_id === config.default_model;
|
|
@@ -1091,18 +1313,18 @@ function createCommands({
|
|
|
1091
1313
|
const nameStyle = isSelected && !isFinal ? `${BG_SELECTED}${FG_CYAN}` : (isSelected ? FG_CYAN : FG_GRAY);
|
|
1092
1314
|
return ` ${marker} ${cursor} ${nameStyle}${model.name} · ${model.model_id} @ ${model.base_url}${RST}`;
|
|
1093
1315
|
}, { initialIndex: Math.max(0, activeIndex) });
|
|
1094
|
-
if (selectedIndex === null) {
|
|
1316
|
+
if (selectedIndex === null) { writer.scrollback(` ${FG_DARK}Cancelled${RST}\n`); return { model: config.default_model, dbId: config.dashboard_model_id }; }
|
|
1095
1317
|
const selectedModel = models[selectedIndex];
|
|
1096
1318
|
let credentialsResponse;
|
|
1097
1319
|
try { credentialsResponse = await dashboardGetModelForCli(selectedModel.id); }
|
|
1098
|
-
catch (err) {
|
|
1320
|
+
catch (err) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}${err.message}${RST}\n`); return { model: config.default_model, dbId: config.dashboard_model_id }; }
|
|
1099
1321
|
const model = credentialsResponse && credentialsResponse.model ? credentialsResponse.model : null;
|
|
1100
|
-
if (!model) {
|
|
1322
|
+
if (!model) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}Unable to load selected model.${RST}\n`); return { model: config.default_model, dbId: config.dashboard_model_id }; }
|
|
1101
1323
|
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);
|
|
1102
1324
|
const updatedConfig = { ...config, api_base: model.base_url, api_key: model.api_key, default_model: model.model_id, dashboard_model_id: model.id };
|
|
1103
1325
|
if (contextLength !== null) updatedConfig.context_length = contextLength;
|
|
1104
1326
|
setConfig(updatedConfig);
|
|
1105
|
-
|
|
1327
|
+
writer.scrollback(` ${FG_GREEN}✓${RST} ${FG_GRAY}Current model → ${model.name} (${model.model_id})${RST}\n`);
|
|
1106
1328
|
return { model: model.model_id, dbId: model.id };
|
|
1107
1329
|
}
|
|
1108
1330
|
|
|
@@ -1113,55 +1335,59 @@ function createCommands({
|
|
|
1113
1335
|
api_key: opts.apiKey || 'any',
|
|
1114
1336
|
dashboard_url: opts.dashboardUrl || current.dashboard_url,
|
|
1115
1337
|
auth_token: current.auth_token || '',
|
|
1116
|
-
default_model: opts.defaultModel || '
|
|
1338
|
+
default_model: opts.defaultModel || '',
|
|
1117
1339
|
temperature: 0.7,
|
|
1118
1340
|
request_timeout_ms: DEFAULT_API_TIMEOUT_MS,
|
|
1119
1341
|
stream: true,
|
|
1120
1342
|
models: current.models,
|
|
1121
1343
|
};
|
|
1122
1344
|
setConfig(cfg);
|
|
1123
|
-
|
|
1124
|
-
console.log(` ${FG_GRAY}${JSON.stringify(cfg, null, 2)}${RST}\n`);
|
|
1345
|
+
writer.scrollback(`\n ${FG_GREEN}✓${RST} Config saved to ${CONFIG_PATH}\n ${FG_GRAY}${JSON.stringify(cfg, null, 2)}${RST}\n`);
|
|
1125
1346
|
}
|
|
1126
1347
|
|
|
1127
1348
|
async function cmdLogin() {
|
|
1128
|
-
|
|
1349
|
+
writer.scrollback(`\n ${FG_TEAL}${BOLD}◆ CLI Login${RST}\n ${FG_DARK}${'─'.repeat(40)}${RST}`);
|
|
1129
1350
|
let loginRequest;
|
|
1130
1351
|
try { loginRequest = await requestCliLogin(); }
|
|
1131
|
-
catch (err) {
|
|
1132
|
-
|
|
1133
|
-
console.log(` ${FG_CYAN}${loginRequest.verification_url}${RST}`);
|
|
1134
|
-
console.log(` ${FG_DARK}Waiting for confirmation...${RST}`);
|
|
1352
|
+
catch (err) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}Unable to start login via ${getConfig().dashboard_url}: ${err.message}${RST}\n`); return; }
|
|
1353
|
+
writer.scrollback(` ${FG_GRAY}Open this URL in your browser and confirm the login:${RST}\n ${FG_CYAN}${loginRequest.verification_url}${RST}\n ${FG_DARK}Waiting for confirmation...${RST}`);
|
|
1135
1354
|
const startedAt = Date.now();
|
|
1136
1355
|
while (Date.now() - startedAt < LOGIN_TIMEOUT_MS) {
|
|
1137
1356
|
await new Promise((r) => setTimeout(r, LOGIN_POLL_INTERVAL_MS));
|
|
1138
1357
|
let status;
|
|
1139
1358
|
try { status = await getCliLoginStatus(loginRequest.id, loginRequest.hash); }
|
|
1140
|
-
catch (err) { if (err.statusCode === 404 || err.statusCode === 410) {
|
|
1141
|
-
if (status.status === 'authorized') { const config = getConfig(); setConfig({ ...config, dashboard_url: config.dashboard_url, auth_token: loginRequest.token });
|
|
1142
|
-
if (status.status === 'expired') {
|
|
1359
|
+
catch (err) { if (err.statusCode === 404 || err.statusCode === 410) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}Login token is no longer valid.${RST}\n`); return; } continue; }
|
|
1360
|
+
if (status.status === 'authorized') { const config = getConfig(); setConfig({ ...config, dashboard_url: config.dashboard_url, auth_token: loginRequest.token }); writer.scrollback(` ${FG_GREEN}✓${RST} ${FG_GRAY}CLI token saved to ${CONFIG_PATH}${RST}\n`); return; }
|
|
1361
|
+
if (status.status === 'expired') { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}Login token expired.${RST}\n`); return; }
|
|
1143
1362
|
}
|
|
1144
|
-
|
|
1363
|
+
writer.scrollback(` ${FG_YELLOW}⚠${RST} ${FG_GRAY}Login timed out.${RST}\n`);
|
|
1145
1364
|
}
|
|
1146
1365
|
|
|
1147
1366
|
async function cmdWhoAmI() {
|
|
1148
1367
|
let response;
|
|
1149
|
-
try { response = await dashboardWhoAmI(); } catch (err) {
|
|
1368
|
+
try { response = await dashboardWhoAmI(); } catch (err) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}${err.message}${RST}\n`); return; }
|
|
1150
1369
|
const user = response && response.user ? response.user : null;
|
|
1151
|
-
if (!user) {
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1370
|
+
if (!user) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}Unable to load current user.${RST}\n`); return; }
|
|
1371
|
+
const lines = [
|
|
1372
|
+
'',
|
|
1373
|
+
` ${FG_TEAL}${BOLD}◆ Current User${RST}`,
|
|
1374
|
+
` ${FG_DARK}${'─'.repeat(40)}${RST}`,
|
|
1375
|
+
formatUserLine('ID', user.id),
|
|
1376
|
+
formatUserLine('Email', user.email || '-'),
|
|
1377
|
+
formatUserLine('Name', user.name || '-'),
|
|
1378
|
+
formatUserLine('Provider', user.provider || '-'),
|
|
1379
|
+
];
|
|
1380
|
+
if (user.avatar_url) lines.push(formatUserLine('Avatar', user.avatar_url));
|
|
1381
|
+
lines.push('');
|
|
1382
|
+
writer.scrollback(lines.join('\n'));
|
|
1157
1383
|
}
|
|
1158
1384
|
|
|
1159
1385
|
async function cmdLogout() {
|
|
1160
1386
|
const config = getConfig();
|
|
1161
|
-
if (!config.auth_token) {
|
|
1162
|
-
try { await dashboardLogout(); } catch (err) { if (err.statusCode !== 401) {
|
|
1387
|
+
if (!config.auth_token) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}Not logged in.${RST}\n`); return; }
|
|
1388
|
+
try { await dashboardLogout(); } catch (err) { if (err.statusCode !== 401) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}${err.message}${RST}\n`); return; } }
|
|
1163
1389
|
setConfig({ ...config, auth_token: '' });
|
|
1164
|
-
|
|
1390
|
+
writer.scrollback(` ${FG_GREEN}✓${RST} ${FG_GRAY}Logged out and cleared local CLI token.${RST}\n`);
|
|
1165
1391
|
}
|
|
1166
1392
|
|
|
1167
1393
|
function printDryRunSummary() {
|
|
@@ -1174,15 +1400,24 @@ function createCommands({
|
|
|
1174
1400
|
const stripA = (s) => s.replace(/\x1b\[[^m]*m/g, '');
|
|
1175
1401
|
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}║`; };
|
|
1176
1402
|
const hr40 = (tl, fill, tr) => { const line = tl + fill.repeat(INNER) + tr; return isTTY ? `${FG_TEAL}${line}${RST}` : line; };
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1403
|
+
const out = [
|
|
1404
|
+
'',
|
|
1405
|
+
hr40('╔','═','╗'),
|
|
1406
|
+
row(` ${isTTY ? BOLD : ''}DRY-RUN SUMMARY${isTTY ? RST : ''}`),
|
|
1407
|
+
hr40('╠','═','╣'),
|
|
1408
|
+
row(` ✎ Files that would change: ${files.length} `),
|
|
1409
|
+
row(` ▶ Commands that would run: ${cmds.length} `),
|
|
1410
|
+
row(` ↓ Network calls: ${nets.length} `),
|
|
1411
|
+
hr40('╚','═','╝'),
|
|
1412
|
+
];
|
|
1413
|
+
if (ops.length > 0) {
|
|
1414
|
+
out.push('');
|
|
1415
|
+
for (const op of ops) {
|
|
1416
|
+
out.push(isTTY ? ` ${op.symbol} ${FG_GRAY}${op.desc}${RST}` : ` ${op.symbol} ${op.desc}`);
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
out.push('');
|
|
1420
|
+
writer.scrollback(out.join('\n'));
|
|
1186
1421
|
}
|
|
1187
1422
|
|
|
1188
1423
|
return {
|