@semalt-ai/code 1.8.5 → 1.19.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.
Files changed (146) hide show
  1. package/.claude/settings.local.json +6 -1
  2. package/.github/workflows/ci.yml +69 -0
  3. package/CLAUDE.md +1584 -26
  4. package/README.md +147 -3
  5. package/examples/embed.js +74 -0
  6. package/index.js +251 -10
  7. package/lib/agent.js +711 -104
  8. package/lib/api.js +213 -49
  9. package/lib/args.js +74 -2
  10. package/lib/audit.js +23 -1
  11. package/lib/background.js +584 -0
  12. package/lib/checkpoints.js +757 -0
  13. package/lib/commands/auth.js +94 -0
  14. package/lib/commands/chat-session.js +306 -0
  15. package/lib/commands/chat-slash.js +399 -0
  16. package/lib/commands/chat-turn.js +446 -0
  17. package/lib/commands/chat.js +403 -0
  18. package/lib/commands/custom.js +157 -0
  19. package/lib/commands/history-utils.js +66 -0
  20. package/lib/commands/index.js +268 -0
  21. package/lib/commands/mcp.js +113 -0
  22. package/lib/commands/oneshot.js +193 -0
  23. package/lib/commands/registry.js +269 -0
  24. package/lib/commands/tasks.js +89 -0
  25. package/lib/compact.js +87 -0
  26. package/lib/config.js +333 -11
  27. package/lib/constants.js +372 -3
  28. package/lib/deny.js +199 -0
  29. package/lib/doctor.js +160 -0
  30. package/lib/headless.js +167 -0
  31. package/lib/hooks.js +286 -0
  32. package/lib/images.js +264 -0
  33. package/lib/internals.js +49 -0
  34. package/lib/mcp/boundary.js +131 -0
  35. package/lib/mcp/client.js +270 -0
  36. package/lib/mcp/oauth.js +134 -0
  37. package/lib/memory.js +209 -0
  38. package/lib/metrics.js +37 -2
  39. package/lib/payload.js +54 -0
  40. package/lib/permission-rules.js +401 -0
  41. package/lib/permissions.js +100 -10
  42. package/lib/pricing.js +67 -0
  43. package/lib/proc.js +62 -0
  44. package/lib/prompts.js +84 -5
  45. package/lib/sandbox.js +568 -0
  46. package/lib/sdk.js +328 -0
  47. package/lib/secrets.js +211 -0
  48. package/lib/skills.js +223 -0
  49. package/lib/subagents.js +516 -0
  50. package/lib/tool_registry.js +2558 -0
  51. package/lib/tool_specs.js +222 -2
  52. package/lib/tools.js +272 -1020
  53. package/lib/ui/format.js +22 -1
  54. package/lib/ui/input-field.js +16 -7
  55. package/lib/ui/status-bar.js +79 -11
  56. package/lib/ui/theme.js +1 -0
  57. package/lib/ui/web-activity.js +218 -0
  58. package/lib/verify.js +229 -0
  59. package/lib/web-extract.js +213 -0
  60. package/lib/web-summarize.js +68 -0
  61. package/package.json +19 -4
  62. package/scripts/lint.js +57 -0
  63. package/test/agent-loop.test.js +389 -0
  64. package/test/background.test.js +414 -0
  65. package/test/chat.test.js +114 -0
  66. package/test/checkpoints-agent.test.js +181 -0
  67. package/test/checkpoints.test.js +650 -0
  68. package/test/command-registry.test.js +160 -0
  69. package/test/compact.test.js +116 -0
  70. package/test/completion-lazy.test.js +52 -0
  71. package/test/config-merge.test.js +324 -0
  72. package/test/config-quarantine.test.js +128 -0
  73. package/test/config-write-guard-allow-anywhere.test.js +56 -0
  74. package/test/config-write-guard-skip.test.js +46 -0
  75. package/test/config-write-guard.test.js +153 -0
  76. package/test/context-split.test.js +215 -0
  77. package/test/cost-doctor.test.js +142 -0
  78. package/test/custom-commands-chat.test.js +106 -0
  79. package/test/custom-commands.test.js +230 -0
  80. package/test/deny-windows.test.js +120 -0
  81. package/test/deny.test.js +83 -0
  82. package/test/download-allow-anywhere.test.js +66 -0
  83. package/test/download-confine.test.js +153 -0
  84. package/test/executors.test.js +362 -0
  85. package/test/extract-tool-calls.test.js +315 -0
  86. package/test/fetch-url-validation.test.js +219 -0
  87. package/test/fixtures/tool-calls.js +57 -0
  88. package/test/fixtures/web-page.js +91 -0
  89. package/test/git-tools.test.js +384 -0
  90. package/test/grep-glob-serialize.test.js +242 -0
  91. package/test/grep-glob.test.js +268 -0
  92. package/test/harness/README.md +57 -0
  93. package/test/harness/chat-harness.js +142 -0
  94. package/test/harness/memwarn-headless-child.js +65 -0
  95. package/test/harness/mock-llm.js +120 -0
  96. package/test/harness/mock-mcp-server.js +142 -0
  97. package/test/harness/sse-server.js +69 -0
  98. package/test/headless.test.js +203 -0
  99. package/test/history-utils.test.js +88 -0
  100. package/test/hooks-agent.test.js +238 -0
  101. package/test/hooks-verify-sandbox.test.js +232 -0
  102. package/test/hooks.test.js +216 -0
  103. package/test/http-get-user-agent.test.js +142 -0
  104. package/test/images-api.test.js +208 -0
  105. package/test/images.test.js +238 -0
  106. package/test/max-iterations.test.js +216 -0
  107. package/test/mcp-boundary.test.js +57 -0
  108. package/test/mcp-client.test.js +267 -0
  109. package/test/mcp-oauth.test.js +86 -0
  110. package/test/memory-truncation-warning.test.js +222 -0
  111. package/test/memory.test.js +198 -0
  112. package/test/native-dispatch.test.js +356 -0
  113. package/test/output-chokepoint.test.js +188 -0
  114. package/test/path-guards.test.js +134 -0
  115. package/test/payload.test.js +99 -0
  116. package/test/permission-rules-agent.test.js +210 -0
  117. package/test/permission-rules.test.js +297 -0
  118. package/test/permissions.test.js +163 -0
  119. package/test/plan-mode.test.js +167 -0
  120. package/test/read-paginate.test.js +275 -0
  121. package/test/readonly-tools.test.js +177 -0
  122. package/test/result-cap.test.js +233 -0
  123. package/test/sandbox-agent.test.js +147 -0
  124. package/test/sandbox-integration.test.js +216 -0
  125. package/test/sandbox.test.js +408 -0
  126. package/test/sdk.test.js +234 -0
  127. package/test/shell-output-cap.test.js +181 -0
  128. package/test/skills-chat.test.js +110 -0
  129. package/test/skills.test.js +295 -0
  130. package/test/smoke.test.js +68 -0
  131. package/test/status-bar-pause.test.js +164 -0
  132. package/test/stream-parser.test.js +147 -0
  133. package/test/subagents-agent.test.js +178 -0
  134. package/test/subagents.test.js +222 -0
  135. package/test/tool-registry.test.js +85 -0
  136. package/test/trim-budget.test.js +101 -0
  137. package/test/verify-agent.test.js +317 -0
  138. package/test/verify.test.js +141 -0
  139. package/test/web-activity-ordering.test.js +194 -0
  140. package/test/web-activity.test.js +207 -0
  141. package/test/web-data-extraction-guidance.test.js +71 -0
  142. package/test/web-extract.test.js +185 -0
  143. package/test/web-fetch-agent.test.js +291 -0
  144. package/test/web-fetch-mode.test.js +193 -0
  145. package/test/web-search.test.js +380 -0
  146. package/lib/commands.js +0 -1438
package/lib/commands.js DELETED
@@ -1,1438 +0,0 @@
1
- 'use strict';
2
-
3
- const fs = require('fs');
4
-
5
- const { CONFIG_PATH, DEFAULT_API_TIMEOUT_MS, TAG_REGISTRY } = require('./constants');
6
- const { configShow, isNativeToolsActive } = require('./config');
7
- const { getSystemPrompt } = require('./prompts');
8
- const { SessionStorage } = require('./storage');
9
- const { getSkippedOps, setUIActive } = require('./tools');
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
- }
72
-
73
- function formatTimeAgo(ts) {
74
- const diffMs = Date.now() - ts;
75
- const diffMin = Math.floor(diffMs / 60000);
76
- if (diffMin < 1) return 'just now';
77
- if (diffMin < 60) return `${diffMin}m ago`;
78
- const diffHr = Math.floor(diffMin / 60);
79
- if (diffHr < 24) return `${diffHr}h ago`;
80
- return `${Math.floor(diffHr / 24)}d ago`;
81
- }
82
-
83
- function createCommands({
84
- getConfig,
85
- setConfig,
86
- permissionManager,
87
- ui,
88
- apiClient,
89
- runAgentLoop,
90
- readFileContext,
91
- agentExecShell,
92
- }) {
93
- const {
94
- BOLD,
95
- BG_SELECTED,
96
- FG_BLUE,
97
- FG_CYAN,
98
- FG_DARK,
99
- FG_GRAY,
100
- FG_GREEN,
101
- FG_RED,
102
- FG_TEAL,
103
- FG_YELLOW,
104
- RST,
105
- approxTokens,
106
- getCols,
107
- boxLine,
108
- interactiveSelect,
109
- createUI,
110
- } = ui;
111
- const {
112
- chatStream,
113
- chatSync,
114
- dashboardCreateChat,
115
- dashboardGetChat,
116
- dashboardGetModelForCli,
117
- dashboardListChats,
118
- dashboardListModels,
119
- dashboardLogout,
120
- dashboardSaveMessages,
121
- dashboardWhoAmI,
122
- estimateTokens,
123
- getCliLoginStatus,
124
- requestCliLogin,
125
- setActiveModelProfile,
126
- } = apiClient;
127
-
128
- const LOGIN_POLL_INTERVAL_MS = 2000;
129
- const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
130
-
131
- function formatUserLine(label, value) {
132
- return ` ${FG_CYAN}${label}:${RST} ${FG_GRAY}${value}${RST}`;
133
- }
134
-
135
- async function resolveTokenLimit(model) {
136
- const config = getConfig();
137
- if (config.auth_token && config.dashboard_model_id) {
138
- try {
139
- const resp = await dashboardGetModelForCli(config.dashboard_model_id);
140
- const m = resp && resp.model ? resp.model : null;
141
- if (m) {
142
- const limit = (Number.isInteger(m.context_length) && m.context_length > 0 ? m.context_length : null)
143
- || (Number.isInteger(m.max_tokens) && m.max_tokens > 0 ? m.max_tokens : null);
144
- if (limit) {
145
- // Persist so chatStream's proactive trimming can use it without an extra API call.
146
- if (config.context_length !== limit) {
147
- setConfig({ ...config, context_length: limit });
148
- }
149
- return limit;
150
- }
151
- }
152
- } catch {}
153
- }
154
- const localModels = Array.isArray(config.models) ? config.models : [];
155
- const match = localModels.find(
156
- (m) => m.model === model || (m.api_base === config.api_base && m.model === config.default_model)
157
- );
158
- if (match && Number.isInteger(match.context_length) && match.context_length > 0) return match.context_length;
159
- if (Number.isInteger(config.context_length) && config.context_length > 0) return config.context_length;
160
- return null;
161
- }
162
-
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 };
186
- }
187
-
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
-
206
- const { chatHistory, statusBar, inputField, layout, destroy, redrawFixed } = createUI({
207
- showThink: opts.showThink || false,
208
- onInterrupt: (destroyFn) => {
209
- saveSession();
210
- destroyFn(buildExitArtifacts());
211
- process.exit(0);
212
- },
213
- });
214
-
215
- setUIActive(true);
216
-
217
- const writer = require('./ui/writer');
218
- permissionManager.setUICallbacks({
219
- onAddMessage: (msg) => chatHistory.addMessage(msg),
220
- onRerenderMessage: (id) => chatHistory.rerenderById(id),
221
- onCollapseMessage: (id) => chatHistory.collapseById(id),
222
- onRemoveMessage: (id) => chatHistory.removeById(id),
223
- // Modal-region API: setModal replaces the modal live band above the
224
- // status region; clearModal drops it. Arrow-key redraws go through
225
- // setModal only — no scrollback churn. When the picker resolves we
226
- // clear the modal and push a single summary line to scrollback.
227
- onShowModal: (lines) => writer.setModal(lines),
228
- onCloseModal: (summary) => {
229
- writer.clearModal();
230
- if (summary) chatHistory.addMessage({ role: 'system', content: summary });
231
- },
232
- onCaptureNavigation: (handler) => {
233
- inputField.captureNavigation(handler);
234
- return () => inputField.releaseNavigation();
235
- },
236
- captureSelect: (menu) => inputField.captureSelect(menu),
237
- });
238
-
239
- inputField.on('expand', () => chatHistory.toggleLastExpand());
240
-
241
- const cwd = process.cwd();
242
- let currentModel = opts.model || getConfig().default_model;
243
- let resolvedTokenLimit = await resolveTokenLimit(currentModel);
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);
249
- let sessionMetrics = null;
250
- // system prompt is prepended fresh on every API call in agent.js — never stored in history
251
- let messages = [];
252
- let currentChatId = null;
253
- let savedUpTo = 0;
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();
259
-
260
- // Resolve system prompt override from --system-prompt file if provided
261
- let resolvedSystemPrompt = null;
262
- if (opts.systemPromptFile) {
263
- try {
264
- resolvedSystemPrompt = fs.readFileSync(opts.systemPromptFile, 'utf8');
265
- } catch (err) {
266
- // will be shown after UI initializes
267
- }
268
- }
269
- const storage = new SessionStorage();
270
- const sessionStart = Date.now();
271
- let session = {
272
- id: storage.generateId(),
273
- created_at: sessionStart,
274
- model: currentModel,
275
- messages: [],
276
- stats: { total_tokens: 0, duration_sec: 0 },
277
- };
278
-
279
- // Seed Ctrl+R search with local session summaries
280
- function refreshInputSearchItems(extraItems) {
281
- const sessions = storage.list();
282
- const items = sessions.map(s => ({
283
- type: 'session',
284
- text: (() => {
285
- const date = new Date(s.created_at).toISOString().slice(0, 16).replace('T', ' ');
286
- return `${date} ${s.model || ''} (${s.message_count} msgs)`;
287
- })(),
288
- }));
289
- if (extraItems) items.push(...extraItems);
290
- inputField.setSearchItems(items);
291
- }
292
- refreshInputSearchItems();
293
-
294
- // Banner — emit once as scrollback above the live region. In the
295
- // bottom-anchored live-region TUI, scrollback flows into terminal
296
- // scrollback naturally, so no absolute positioning or scroll-region
297
- // trickery is needed here.
298
- if (layout) {
299
- const w = Math.min(getCols() - 4, 60);
300
- const banner = [
301
- ``,
302
- ` ${FG_DARK}╭${'─'.repeat(w + 1)}╮${RST}`,
303
- boxLine('', w),
304
- boxLine(`${FG_TEAL}${BOLD}◆ Semalt.AI${RST}`, w),
305
- boxLine(`${FG_GRAY}Self-hosted AI coding assistant${RST}`, w),
306
- boxLine('', w),
307
- ` ${FG_DARK}╰${'─'.repeat(w + 1)}╯${RST}`,
308
- ``,
309
- ].join('\n');
310
- writer.scrollback(banner);
311
- redrawFixed();
312
- }
313
-
314
- // Welcome message
315
- chatHistory.addMessage({
316
- role: 'system',
317
- content: `◆ Semalt.AI · ${currentModel} · ${cwd}\nType /help for commands.`,
318
- });
319
-
320
- if (opts.systemPromptFile && resolvedSystemPrompt === null) {
321
- chatHistory.addMessage({ role: 'system', content: `✗ Could not read system prompt file: ${opts.systemPromptFile}`, isError: true });
322
- } else if (opts.systemPromptFile && resolvedSystemPrompt !== null) {
323
- chatHistory.addMessage({ role: 'system', content: `✓ Using system prompt from: ${opts.systemPromptFile}` });
324
- }
325
-
326
- function saveSession() {
327
- session.model = currentModel;
328
- session.messages = messages;
329
- session.stats.duration_sec = Math.round((Date.now() - sessionStart) / 1000);
330
- session.stats.total_tokens = messages.reduce((s, m) => s + Math.round((m.content || '').length / 4), 0);
331
- try { storage.save(session); } catch {}
332
- }
333
-
334
- async function createChatIfNeeded(firstUserText) {
335
- const config = getConfig();
336
- if (currentChatId !== null || !config.auth_token || !config.dashboard_model_id) return;
337
- try {
338
- const title = firstUserText.length > 60 ? firstUserText.slice(0, 57) + '...' : firstUserText;
339
- const resp = await dashboardCreateChat(title, config.dashboard_model_id);
340
- if (resp && resp.chat && resp.chat.id) currentChatId = resp.chat.id;
341
- } catch {}
342
- }
343
-
344
- async function saveTurnToDashboard() {
345
- if (currentChatId === null) return;
346
- const newMessages = messages.slice(savedUpTo).filter((m) => m.role !== 'system');
347
- if (!newMessages.length) return;
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
- }
357
- }
358
-
359
- function displayLoadedMessages(loadedMessages) {
360
- chatHistory.clearMessages();
361
- for (const m of loadedMessages) {
362
- if (m.role !== 'user' && m.role !== 'assistant' && m.role !== 'tool') continue;
363
- const raw = typeof m.content === 'string' ? m.content : '';
364
- const ts = m.created_at ? new Date(m.created_at) : (m.ts ? new Date(m.ts) : new Date());
365
-
366
- if (m.role === 'tool') {
367
- chatHistory.addMessage({ role: 'tool', tag: 'tool', content: raw, ts });
368
- continue;
369
- }
370
-
371
- if (m.role === 'user' && raw.startsWith('Tool execution results:')) {
372
- const body = raw
373
- .replace(/^Tool execution results[^\n]*\n+/, '')
374
- .replace(/\n+Continue with the task\.[\s\S]*$/, '')
375
- .trim();
376
- chatHistory.addMessage({ role: 'tool', tag: 'tool', content: body || raw, ts });
377
- continue;
378
- }
379
-
380
- if (!raw.trim()) continue;
381
- chatHistory.addMessage({ role: m.role, content: raw, ts });
382
- }
383
- }
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
-
413
- // --resume: load previous chat
414
- if (opts.resume) {
415
- const resumeId = parseInt(opts.resume, 10);
416
- if (!isNaN(resumeId)) {
417
- try {
418
- const chatData = await dashboardGetChat(resumeId);
419
- const loaded = chatData && chatData.messages ? chatData.messages : [];
420
- for (const m of loaded) messages.push(reconstructLoadedMessage(m));
421
- const cleanup = cleanOrphanedToolMessages(messages);
422
- messages = cleanup.messages;
423
- currentChatId = resumeId;
424
- savedUpTo = messages.length;
425
- const title = chatData.chat && chatData.chat.title ? chatData.chat.title : `#${resumeId}`;
426
- displayLoadedMessages(loaded);
427
- chatHistory.addMessage({ role: 'system', content: `✓ Resumed: ${title} (${loaded.length} messages)` });
428
- emitCleanupWarning(cleanup);
429
- seedContextFromMessages();
430
- } catch (error) {
431
- chatHistory.addMessage({ role: 'system', content: `✗ Could not resume chat: ${error.message}`, isError: true });
432
- }
433
- }
434
- }
435
-
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.
440
- let pendingAction = null;
441
- const PAGE_SIZE = 5;
442
-
443
- function getNavSearchText(type, item) {
444
- if (type === 'history') {
445
- const date = new Date(item.created_at).toISOString().slice(0, 16);
446
- return `${date} ${item.model || ''} ${item.message_count || ''}`;
447
- } else if (type === 'chats') {
448
- return `${item.title || ''} ${item.model_name || ''}`;
449
- } else if (type === 'models') {
450
- return `${item.name || ''} ${item.model_id || ''}`;
451
- }
452
- return '';
453
- }
454
-
455
- function buildItemDetail(type, item) {
456
- const cfg = getConfig();
457
- const maxDetail = Math.max(20, getCols() - 12);
458
- let detail = '';
459
- if (type === 'history') {
460
- const date = new Date(item.created_at).toISOString().slice(0, 16).replace('T', ' ');
461
- detail = `${date} ${(item.model || '').slice(0, 20)} (${item.message_count} msgs)`;
462
- } else if (type === 'chats') {
463
- const date = item.updated_at ? String(item.updated_at).slice(0, 10) : '';
464
- detail = `${item.title} · ${item.model_name || ''} · ${date}`;
465
- } else if (type === 'models') {
466
- const active = item.base_url === cfg.api_base && item.model_id === cfg.default_model;
467
- detail = `${active ? '●' : ' '} ${item.name} · ${item.model_id}`;
468
- }
469
- if (detail.length > maxDetail) detail = detail.slice(0, maxDetail - 1) + '…';
470
- return detail;
471
- }
472
-
473
- function buildListContent() {
474
- if (!pendingAction) return '';
475
- const { type, items, displayItems: di, stepIdx, searchQuery } = pendingAction;
476
- const items2 = di || items;
477
- const page = Math.floor(stepIdx / PAGE_SIZE);
478
- const pageCount = Math.ceil(items2.length / PAGE_SIZE);
479
- const pageStart = page * PAGE_SIZE;
480
- const pageItems = items2.slice(pageStart, pageStart + PAGE_SIZE);
481
- const localIdx = stepIdx - pageStart;
482
- const titleMap = { history: 'Sessions', chats: 'Chats', models: 'Models' };
483
- const pageLabel = pageCount > 1 ? ` · Page ${page + 1}/${pageCount}` : '';
484
- const countLabel = items2.length > 0 ? `[${stepIdx + 1}/${items2.length}]` : '[0 results]';
485
- const searchLabel = searchQuery ? ` · filter: '${searchQuery}'` : '';
486
- const parts = [`${titleMap[type] || type} ${countLabel}${pageLabel}${searchLabel}`, ''];
487
- for (let i = 0; i < pageItems.length; i++) {
488
- const item = pageItems[i];
489
- const sel = i === localIdx;
490
- const detail = buildItemDetail(type, item);
491
- parts.push(sel ? `\x1b[1m\x1b[36m ► ${detail}` : ` ${detail}`);
492
- }
493
- // Pad to a fixed height so rerenderById always clears the same number of rows,
494
- // regardless of how many items the current page has (last page may have fewer).
495
- while (parts.length < PAGE_SIZE + 2) parts.push('');
496
- return parts.join('\n');
497
- }
498
-
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();
504
- }
505
-
506
- function showPendingStep() {
507
- if (!pendingAction) return;
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);
516
- }
517
-
518
- function finalizeListMsg() {
519
- writer.clearModal();
520
- }
521
-
522
- function activateNavCapture() {
523
- inputField.captureNavigation(async (action) => {
524
- if (!pendingAction) { inputField.releaseNavigation(); return; }
525
- const { items, displayItems: di, stepIdx } = pendingAction;
526
- const activeItems = di || items;
527
-
528
- if (action.startsWith('search:')) {
529
- const query = action.slice(7);
530
- if (!query) {
531
- pendingAction = { ...pendingAction, displayItems: null, searchQuery: '', stepIdx: 0 };
532
- } else {
533
- const filtered = items.filter(item => getNavSearchText(pendingAction.type, item).toLowerCase().includes(query.toLowerCase()));
534
- pendingAction = { ...pendingAction, displayItems: filtered, searchQuery: query, stepIdx: 0 };
535
- }
536
- showPendingStep();
537
- return;
538
- }
539
-
540
- if (action === 'next') {
541
- pendingAction = { ...pendingAction, stepIdx: activeItems.length ? (stepIdx + 1) % activeItems.length : 0 };
542
- showPendingStep();
543
- } else if (action === 'prev') {
544
- pendingAction = { ...pendingAction, stepIdx: activeItems.length ? (stepIdx - 1 + activeItems.length) % activeItems.length : 0 };
545
- showPendingStep();
546
- } else if (action === 'select') {
547
- if (!activeItems.length) return;
548
- inputField.releaseNavigation();
549
- const si = pendingAction.stepIdx;
550
- collapseListMsg(pendingAction.type, activeItems[si]);
551
- statusBar.update('idle');
552
- await handlePendingSelection(si);
553
- inputField.setDisabled(false);
554
- } else if (action === 'cancel') {
555
- inputField.releaseNavigation();
556
- finalizeListMsg();
557
- chatHistory.addMessage({ role: 'system', content: 'Cancelled.' });
558
- pendingAction = null;
559
- statusBar.update('idle');
560
- inputField.setDisabled(false);
561
- }
562
- });
563
- }
564
-
565
- async function handlePendingSelection(idx) {
566
- if (!pendingAction) return;
567
- const { type, items, displayItems: di } = pendingAction;
568
- const activeItems = di || items;
569
- pendingAction = null;
570
-
571
- if (type === 'history') {
572
- const loaded = storage.load(activeItems[idx].id);
573
- if (loaded) {
574
- const filtered = (loaded.messages || []).filter((m) => m.role !== 'system');
575
- const cleanup = cleanOrphanedToolMessages(filtered);
576
- messages = cleanup.messages;
577
- session = { id: loaded.id, created_at: loaded.created_at, model: loaded.model, messages, stats: loaded.stats || { total_tokens: 0, duration_sec: 0 } };
578
- currentChatId = null; savedUpTo = 0;
579
- if (loaded.model && loaded.model !== currentModel) {
580
- currentModel = loaded.model;
581
- resolvedTokenLimit = await resolveTokenLimit(currentModel);
582
- statusBar.setModel(currentModel);
583
- statusBar.setContextLimit(resolvedTokenLimit);
584
- }
585
- displayLoadedMessages(messages);
586
- chatHistory.addMessage({ role: 'system', content: `✓ Session loaded. Model → ${currentModel}` });
587
- emitCleanupWarning(cleanup);
588
- seedContextFromMessages();
589
- }
590
- } else if (type === 'chats') {
591
- const selectedChat = activeItems[idx];
592
- try {
593
- const chatData = await dashboardGetChat(selectedChat.id);
594
- const loaded = chatData && chatData.messages ? chatData.messages : [];
595
- const reconstructed = loaded.map(reconstructLoadedMessage);
596
- const cleanup = cleanOrphanedToolMessages(reconstructed);
597
- messages = cleanup.messages;
598
- currentChatId = selectedChat.id; savedUpTo = messages.length;
599
- displayLoadedMessages(loaded);
600
- chatHistory.addMessage({ role: 'system', content: `✓ Resumed: ${selectedChat.title} (${loaded.length} messages)` });
601
- emitCleanupWarning(cleanup);
602
- seedContextFromMessages();
603
- } catch (err) {
604
- chatHistory.addMessage({ role: 'system', content: `✗ ${err.message}`, isError: true });
605
- }
606
- } else if (type === 'models') {
607
- const selectedModel = activeItems[idx];
608
- try {
609
- const credResp = await dashboardGetModelForCli(selectedModel.id);
610
- const model = credResp && credResp.model ? credResp.model : null;
611
- if (!model) { chatHistory.addMessage({ role: 'system', content: '✗ Unable to load model.', isError: true }); return; }
612
- const contextLength = (Number.isInteger(model.context_length) && model.context_length > 0 ? model.context_length : null)
613
- || (Number.isInteger(model.max_tokens) && model.max_tokens > 0 ? model.max_tokens : null);
614
- const config = getConfig();
615
- const updated = { ...config, api_base: model.base_url, api_key: model.api_key, default_model: model.model_id, dashboard_model_id: model.id };
616
- if (contextLength !== null) updated.context_length = contextLength;
617
- setConfig(updated);
618
- currentModel = model.model_id;
619
- resolvedTokenLimit = await resolveTokenLimit(currentModel);
620
- statusBar.setModel(currentModel);
621
- statusBar.setContextLimit(resolvedTokenLimit);
622
- currentChatId = null;
623
- chatHistory.addMessage({ role: 'system', content: `✓ Model → ${model.name} (${model.model_id})` });
624
- statusBar.update('idle');
625
- } catch (err) {
626
- chatHistory.addMessage({ role: 'system', content: `✗ ${err.message}`, isError: true });
627
- }
628
- }
629
- }
630
-
631
- let resolveExit;
632
- const exitPromise = new Promise((r) => { resolveExit = r; });
633
-
634
-
635
- statusBar.update('idle');
636
-
637
- inputField.onSubmit(async (text) => {
638
- // Handle pending selection (text fallback for non-TTY; TTY uses captureNavigation)
639
- if (pendingAction) {
640
- inputField.releaseNavigation();
641
- const t = text.trim().toLowerCase();
642
- const { items, displayItems: di, stepIdx, type } = pendingAction;
643
- const activeItems = di || items;
644
- if (t === 's' || t === 'select' || t === 'y' || t === 'yes') {
645
- collapseListMsg(type, activeItems[stepIdx]);
646
- statusBar.update('idle');
647
- await handlePendingSelection(stepIdx);
648
- inputField.setDisabled(false);
649
- return;
650
- } else if (t === 'n' || t === 'next') {
651
- pendingAction = { ...pendingAction, stepIdx: (stepIdx + 1) % items.length };
652
- showPendingStep();
653
- activateNavCapture();
654
- return;
655
- } else if (t === 'p' || t === 'prev') {
656
- pendingAction = { ...pendingAction, stepIdx: (stepIdx - 1 + items.length) % items.length };
657
- showPendingStep();
658
- activateNavCapture();
659
- return;
660
- } else if (t === 'c' || t === 'cancel') {
661
- finalizeListMsg();
662
- chatHistory.addMessage({ role: 'system', content: 'Cancelled.' });
663
- pendingAction = null;
664
- statusBar.update('idle');
665
- inputField.setDisabled(false);
666
- return;
667
- } else {
668
- // Not a nav key: close nav silently and let the message go to AI
669
- finalizeListMsg();
670
- pendingAction = null;
671
- statusBar.update('idle');
672
- // fall through to AI processing below
673
- }
674
- }
675
-
676
- // Exit
677
- if (['exit', 'quit', '/exit', '/quit'].includes(text.toLowerCase())) {
678
- saveSession();
679
- destroy(buildExitArtifacts());
680
- resolveExit();
681
- return;
682
- }
683
-
684
- if (text === '/help') {
685
- chatHistory.addMessage({
686
- role: 'system',
687
- content: [
688
- 'Commands:',
689
- ' /file <path> Load file or dir into context',
690
- ' /history Browse local sessions',
691
- ' /chats Browse saved dashboard chats',
692
- ' /new Start fresh conversation',
693
- ' /login Authorize via browser',
694
- ' /whoami Show current user',
695
- ' /logout Clear CLI login',
696
- ' /model Show current model',
697
- ' /model <name> Switch model manually',
698
- ' /models Choose from dashboard models',
699
- ' /clear Clear conversation',
700
- ' /compact Show token usage',
701
- ' /shell <cmd> Run shell command',
702
- ' !<cmd> Run shell command',
703
- ' /approve Toggle auto-approve',
704
- ' /debug [off] Enable debug output + show last 5 audit entries',
705
- ' /config Show config',
706
- ' exit Quit',
707
- ].join('\n'),
708
- });
709
- return;
710
- }
711
-
712
- if (text === '/history') {
713
- if (!getConfig().auth_token) { chatHistory.addMessage({ role: 'system', content: 'Not logged in. Run /login first.' }); return; }
714
- const sessions = storage.list();
715
- if (!sessions.length) { chatHistory.addMessage({ role: 'system', content: 'No saved sessions.' }); return; }
716
- refreshInputSearchItems();
717
- chatHistory.addMessage({ role: 'system', content: '/history' });
718
- pendingAction = { type: 'history', items: sessions, stepIdx: 0 };
719
- showPendingStep();
720
- statusBar.update('waiting', 'Select session...');
721
- activateNavCapture();
722
- return;
723
- }
724
-
725
- if (text === '/chats') {
726
- const config = getConfig();
727
- if (!config.auth_token) { chatHistory.addMessage({ role: 'system', content: 'Not logged in. Run /login first.' }); return; }
728
- inputField.setDisabled(true);
729
- statusBar.update('thinking', 'Loading chats...');
730
- try {
731
- const response = await dashboardListChats();
732
- const chats = Array.isArray(response && response.chats) ? response.chats : [];
733
- if (!chats.length) { chatHistory.addMessage({ role: 'system', content: 'No saved chats found.' }); statusBar.update('idle'); }
734
- else {
735
- refreshInputSearchItems(chats.map(c => ({ type: 'chat', text: c.title || `chat #${c.id}` })));
736
- chatHistory.addMessage({ role: 'system', content: '/chats' });
737
- pendingAction = { type: 'chats', items: chats, stepIdx: 0 };
738
- showPendingStep();
739
- statusBar.update('waiting', 'Select chat...');
740
- activateNavCapture();
741
- }
742
- } catch (err) {
743
- chatHistory.addMessage({ role: 'system', content: `✗ ${err.message}`, isError: true });
744
- statusBar.update('idle');
745
- }
746
- inputField.setDisabled(false);
747
- return;
748
- }
749
-
750
- if (text === '/new') {
751
- messages = [];
752
- currentChatId = null; savedUpTo = 0;
753
- permissionManager.clear();
754
- chatHistory.addMessage({ role: 'system', content: '✓ Started new conversation.' });
755
- return;
756
- }
757
-
758
- if (text === '/login') {
759
- inputField.setDisabled(true);
760
- statusBar.update('thinking', 'Starting login...');
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
- }
770
- statusBar.update('idle');
771
- inputField.setDisabled(false);
772
- return;
773
- }
774
-
775
- if (text === '/whoami') {
776
- inputField.setDisabled(true);
777
- statusBar.update('thinking', 'Loading...');
778
- try {
779
- const response = await dashboardWhoAmI();
780
- const user = response && response.user ? response.user : null;
781
- if (!user) { chatHistory.addMessage({ role: 'system', content: '✗ Unable to load current user.', isError: true }); }
782
- else {
783
- chatHistory.addMessage({ role: 'system', content: `Current User:\n ID: ${user.id}\n Email: ${user.email || '-'}\n Name: ${user.name || '-'}\n Provider: ${user.provider || '-'}` });
784
- }
785
- } catch (err) {
786
- chatHistory.addMessage({ role: 'system', content: `✗ ${err.message}`, isError: true });
787
- }
788
- statusBar.update('idle');
789
- inputField.setDisabled(false);
790
- return;
791
- }
792
-
793
- if (text === '/logout') {
794
- const config = getConfig();
795
- if (!config.auth_token) { chatHistory.addMessage({ role: 'system', content: '✗ Not logged in.' }); return; }
796
- inputField.setDisabled(true);
797
- statusBar.update('thinking', 'Logging out...');
798
- 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; } }
799
- setConfig({ ...config, auth_token: '' });
800
- chatHistory.addMessage({ role: 'system', content: '✓ Logged out and cleared local CLI token.' });
801
- statusBar.update('idle');
802
- inputField.setDisabled(false);
803
- return;
804
- }
805
-
806
- if (text.startsWith('/file ')) {
807
- const fp = text.slice(6).trim();
808
- const ctx = readFileContext([fp]);
809
- if (ctx) {
810
- messages.push({ role: 'user', content: `Here is the file context:\n${ctx}` });
811
- chatHistory.addMessage({ role: 'system', content: `✓ Loaded: ${fp}` });
812
- } else {
813
- chatHistory.addMessage({ role: 'system', content: `✗ Could not load: ${fp}`, isError: true });
814
- }
815
- return;
816
- }
817
-
818
- if (text === '/models') {
819
- if (!getConfig().auth_token) { chatHistory.addMessage({ role: 'system', content: 'Not logged in. Run /login first.' }); return; }
820
- inputField.setDisabled(true);
821
- statusBar.update('thinking', 'Loading models...');
822
- try {
823
- const response = await dashboardListModels();
824
- const models = Array.isArray(response && response.models) ? response.models : [];
825
- if (!models.length) { chatHistory.addMessage({ role: 'system', content: '✗ No models available.' }); statusBar.update('idle'); }
826
- else {
827
- chatHistory.addMessage({ role: 'system', content: '/models' });
828
- pendingAction = { type: 'models', items: models, stepIdx: 0 };
829
- showPendingStep();
830
- statusBar.update('waiting', 'Select model...');
831
- activateNavCapture();
832
- }
833
- } catch (err) {
834
- chatHistory.addMessage({ role: 'system', content: `✗ ${err.message}`, isError: true });
835
- statusBar.update('idle');
836
- }
837
- inputField.setDisabled(false);
838
- return;
839
- }
840
-
841
- if (text === '/model') {
842
- if (!getConfig().auth_token) { chatHistory.addMessage({ role: 'system', content: 'Not logged in. Run /login first.' }); return; }
843
- chatHistory.addMessage({ role: 'system', content: `Current model: ${currentModel}` });
844
- return;
845
- }
846
-
847
- if (text.startsWith('/model ')) {
848
- if (!getConfig().auth_token) { chatHistory.addMessage({ role: 'system', content: 'Not logged in. Run /login first.' }); return; }
849
- currentModel = text.slice(7).trim();
850
- resolvedTokenLimit = await resolveTokenLimit(currentModel);
851
- statusBar.setModel(currentModel);
852
- statusBar.setContextLimit(resolvedTokenLimit);
853
- chatHistory.addMessage({ role: 'system', content: `✓ Model → ${currentModel}` });
854
- return;
855
- }
856
-
857
- if (text === '/clear') {
858
- messages = [];
859
- currentChatId = null; savedUpTo = 0;
860
- permissionManager.clear();
861
- chatHistory.addMessage({ role: 'system', content: '✓ Conversation and approvals cleared.' });
862
- return;
863
- }
864
-
865
- if (text === '/compact' || text === '/cost') {
866
- const total = messages.reduce((s, m) => s + estimateTokens(m.content), 0);
867
- let msg = `${messages.length} messages · ~${total} tokens`;
868
- if (sessionMetrics) msg += '\n' + sessionMetrics.summary();
869
- chatHistory.addMessage({ role: 'system', content: msg });
870
- return;
871
- }
872
-
873
- if (text === '/config') {
874
- chatHistory.addMessage({ role: 'system', content: configShow(opts.systemPromptFile || null) });
875
- return;
876
- }
877
-
878
- if (text === '/prompt') {
879
- const nativeTools = isNativeToolsActive(currentModel);
880
- const activePrompt = resolvedSystemPrompt !== null ? resolvedSystemPrompt : getSystemPrompt(nativeTools);
881
- const src = resolvedSystemPrompt !== null ? `file: ${opts.systemPromptFile}` : 'built-in';
882
- const mode = getConfig().system_prompt_mode || 'system_role';
883
- chatHistory.addMessage({
884
- role: 'system',
885
- content: `System prompt (${src}, mode: ${mode}):\n\n${activePrompt}`,
886
- });
887
- return;
888
- }
889
-
890
- if (text === '/approve') {
891
- const enabled = permissionManager.toggleAll();
892
- chatHistory.addMessage({ role: 'system', content: `Auto-approve: ${enabled ? 'ON' : 'OFF'}` });
893
- return;
894
- }
895
-
896
- if (text === '/debug' || text.startsWith('/debug ')) {
897
- const arg = text === '/debug' ? '' : text.slice(7).trim().toLowerCase();
898
- if (arg === 'off' || arg === 'false' || arg === '0') debugMode = false;
899
- else debugMode = true;
900
-
901
- let tail = '';
902
- try {
903
- const content = fs.readFileSync(AUDIT_LOG, 'utf8');
904
- const lines = content.trim().split('\n').filter((l) => l.trim()).slice(-5);
905
- if (lines.length) {
906
- const formatted = lines.map((line) => {
907
- try {
908
- const entry = JSON.parse(line);
909
- const mark = entry.approved ? '✓' : '✗';
910
- return ` ${mark} ${entry.ts} ${entry.tag} ${entry.input} → ${entry.result}`;
911
- } catch {
912
- return ` ${line}`;
913
- }
914
- });
915
- tail = '\nLast 5 audit entries:\n' + formatted.join('\n');
916
- } else {
917
- tail = '\nAudit log is empty.';
918
- }
919
- } catch {
920
- tail = '\nNo audit log found.';
921
- }
922
-
923
- const sink = dbg.isFile()
924
- ? `file (${dbg.getMode()} mode)`
925
- : 'inline chat history';
926
- chatHistory.addMessage({
927
- role: 'system',
928
- content: `Debug output: ${debugMode ? 'ON' : 'OFF'} → ${sink}${tail}`,
929
- });
930
- return;
931
- }
932
-
933
- if (text.startsWith('/shell ') || text.startsWith('!')) {
934
- const cmd = text.startsWith('/shell ') ? text.slice(7).trim() : text.slice(1).trim();
935
- inputField.setDisabled(true);
936
- statusBar.update('tool', cmd);
937
- try {
938
- const shellResult = await agentExecShell(cmd);
939
- let output = shellResult.stdout || '';
940
- if (shellResult.stderr && shellResult.stderr !== 'Permission denied by user') {
941
- output += (output ? '\n' : '') + `STDERR: ${shellResult.stderr}`;
942
- }
943
- const exitSuffix = shellResult.exit_code !== 0 ? ` [exit ${shellResult.exit_code}]` : '';
944
- const display = output.trim() ? output.trim() + exitSuffix : `(no output)${exitSuffix}`;
945
- chatHistory.addMessage({ role: 'shell', cmd, content: display, ts: new Date() });
946
- } catch (err) {
947
- chatHistory.addMessage({ role: 'system', content: `✗ Shell error: ${err.message}`, isError: true });
948
- }
949
- statusBar.update('idle');
950
- inputField.setDisabled(false);
951
- return;
952
- }
953
-
954
- // Block unauthenticated users from running the agent
955
- if (!getConfig().auth_token) {
956
- chatHistory.addMessage({ role: 'system', content: '✗ Not logged in. Run /login first.', isError: true });
957
- return;
958
- }
959
-
960
- // Normal message → run agent
961
- inputField.setDisabled(true);
962
- chatHistory.addMessage({ role: 'user', content: text });
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));
969
- await createChatIfNeeded(text);
970
- messages.push({ role: 'user', content: text });
971
-
972
- // Per-turn state: buffer tokens until we know if the model is in an implicit
973
- // think block (Qwen3-style: plain text followed by </think>, no opening tag).
974
- let implicitThinkPhase = !opts.showThink;
975
- let implicitThinkBuffer = '';
976
-
977
- const callbacks = {
978
- onThinking: () => statusBar.update('thinking', 'Thinking...'),
979
- onRequestSent: () => {
980
- statusBar.update('thinking', 'Thinking...');
981
- // Reset think-phase detection for each new agent iteration.
982
- implicitThinkPhase = !opts.showThink;
983
- implicitThinkBuffer = '';
984
- },
985
- onStreamStart: () => {
986
- // If showThink is on, switch to streaming immediately.
987
- // Otherwise keep "Thinking…" until </think> is resolved.
988
- if (opts.showThink) statusBar.update('streaming', 'Streaming response');
989
- },
990
- onTagOpen: (tag, attrs) => {
991
- const entry = TAG_REGISTRY[tag];
992
- if (entry?.type === 'tool') {
993
- const actionLabel = entry.label || tag;
994
- const detail = attrs.path || attrs.url || attrs.key || attrs.src || '';
995
- const isDownload = tag === 'download' || tag === 'http_get';
996
- const barState = isDownload ? 'waiting_download' : 'tool';
997
- const label = isDownload
998
- ? `Waiting for download${detail ? ': ' + detail : ''}`
999
- : `${actionLabel}${detail ? ': ' + detail : ''}`;
1000
- statusBar.update(barState, label);
1001
- if (!opts.showThink) chatHistory.clearStreamingContent();
1002
- }
1003
- if (entry?.display === 'think_bubble') {
1004
- statusBar.update('thinking', 'Reasoning...');
1005
- }
1006
- },
1007
- onThinkEnd: (content) => {
1008
- chatHistory.addMessage({ role: 'think', content });
1009
- statusBar.update('streaming', 'Streaming response');
1010
- },
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).
1017
- const actionLabel = TAG_REGISTRY[tag]?.label || tag;
1018
- const short = input && input.length > 40 ? input.slice(0, 40) + '…' : (input || '');
1019
- const isDownload = tag === 'download' || tag === 'http_get';
1020
- if (isDownload) {
1021
- statusBar.update('waiting_download', `Waiting for download: ${short}`);
1022
- } else {
1023
- statusBar.update('tool', `${actionLabel}: ${short}`);
1024
- }
1025
- },
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}`);
1032
- } else {
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');
1099
- }
1100
- },
1101
- onToken: (token) => {
1102
- if (!opts.showThink && implicitThinkPhase) {
1103
- // Check if this token is the closing think tag (Qwen3-style implicit think).
1104
- if (/^<\/(think|reasoning|reflection)>$/i.test(token.trim())) {
1105
- // Thinking phase is over — discard buffered reasoning, start streaming.
1106
- implicitThinkPhase = false;
1107
- implicitThinkBuffer = '';
1108
- statusBar.update('streaming', 'Streaming response');
1109
- return;
1110
- }
1111
- // Buffer the token; keep the thinking animation visible.
1112
- implicitThinkBuffer += token;
1113
- return;
1114
- }
1115
- chatHistory.streamToken(token);
1116
- statusBar.onToken();
1117
- },
1118
- onAssistantMessage: (cleanContent) => {
1119
- // If </think> was never seen, the model had no implicit think block —
1120
- // flush whatever was buffered as normal streaming content.
1121
- if (implicitThinkPhase && implicitThinkBuffer) {
1122
- implicitThinkPhase = false;
1123
- implicitThinkBuffer = '';
1124
- }
1125
- chatHistory.finalizeLastMessage(cleanContent);
1126
- },
1127
- onMetricsUpdate: (data) => statusBar.updateMetrics(data),
1128
- onRetry: (attempt, max) => {
1129
- statusBar.update('thinking', `Retrying (${attempt}/${max})...`);
1130
- },
1131
- onDebug: (block) => {
1132
- // Render in-history as a tool-style bubble so ctrl+O expand works and
1133
- // the RAW RESPONSE text survives TUI redraws (stderr would be clobbered).
1134
- chatHistory.addMessage({ role: 'tool', tag: 'debug', content: 'DEBUG', output: block });
1135
- },
1136
- onError: (err) => {
1137
- if (err && err.isWarning) {
1138
- chatHistory.addMessage({ role: 'system', content: err.message || String(err) });
1139
- } else {
1140
- const msg = (err && err.message) || String(err);
1141
- statusBar.update('error', msg);
1142
- chatHistory.addMessage({ role: 'system', content: `✗ ${msg}`, isError: true });
1143
- }
1144
- },
1145
- };
1146
-
1147
- let _agentAborted = false;
1148
- const _onAbort = () => {
1149
- if (!_agentAborted) {
1150
- _agentAborted = true;
1151
- chatHistory.addMessage({ role: 'system', content: '⏹ Interrupted.' });
1152
- }
1153
- };
1154
- inputField.on('abort', _onAbort);
1155
-
1156
- // Refresh in case a prior turn's 400 overflow persisted a learned
1157
- // context_length to config after this chat started.
1158
- if (resolvedTokenLimit == null) {
1159
- const cfg = getConfig();
1160
- if (Number.isInteger(cfg.context_length) && cfg.context_length > 0) {
1161
- resolvedTokenLimit = cfg.context_length;
1162
- }
1163
- }
1164
-
1165
- try {
1166
- const agentResult = await runAgentLoop(messages, currentModel, undefined, resolvedTokenLimit, {
1167
- showThink: opts.showThink || false,
1168
- debug: debugMode,
1169
- callbacks,
1170
- systemPrompt: resolvedSystemPrompt,
1171
- systemPromptMode: getConfig().system_prompt_mode || 'system_role',
1172
- getAbortFlag: () => _agentAborted,
1173
- });
1174
- messages = agentResult.messages;
1175
- sessionMetrics = agentResult.metrics;
1176
- } catch (err) {
1177
- statusBar.update('error', err.message || 'Agent error');
1178
- chatHistory.addMessage({ role: 'system', content: err.message || 'Agent error', isError: true });
1179
- } finally {
1180
- inputField.removeListener('abort', _onAbort);
1181
- }
1182
-
1183
- statusBar.update('idle');
1184
- inputField.setDisabled(false);
1185
- await saveTurnToDashboard();
1186
- saveSession();
1187
- });
1188
-
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.
1193
- await exitPromise;
1194
- setUIActive(false);
1195
- saveSession();
1196
- }
1197
-
1198
- async function _loginFlow(chatHistory, statusBar) {
1199
- let loginRequest;
1200
- try { loginRequest = await requestCliLogin(); }
1201
- catch (err) {
1202
- chatHistory.addMessage({ role: 'system', content: `✗ Login failed: ${err.message}`, isError: true });
1203
- return;
1204
- }
1205
- chatHistory.addMessage({ role: 'system', content: `Open this URL to authorize:\n ${loginRequest.verification_url}\n\nWaiting for confirmation...` });
1206
- statusBar.update('waiting', 'Waiting for browser auth...');
1207
- const startedAt = Date.now();
1208
- while (Date.now() - startedAt < LOGIN_TIMEOUT_MS) {
1209
- await new Promise((r) => setTimeout(r, LOGIN_POLL_INTERVAL_MS));
1210
- let status;
1211
- try { status = await getCliLoginStatus(loginRequest.id, loginRequest.hash); }
1212
- catch (err) {
1213
- if (err.statusCode === 404 || err.statusCode === 410) { chatHistory.addMessage({ role: 'system', content: '✗ Login token is no longer valid.', isError: true }); return; }
1214
- continue;
1215
- }
1216
- if (status.status === 'authorized') {
1217
- const config = getConfig();
1218
- setConfig({ ...config, dashboard_url: config.dashboard_url, auth_token: loginRequest.token });
1219
- chatHistory.addMessage({ role: 'system', content: `✓ CLI token saved to ${CONFIG_PATH}` });
1220
- return;
1221
- }
1222
- if (status.status === 'expired') {
1223
- chatHistory.addMessage({ role: 'system', content: '✗ Login token expired. Run /login again.', isError: true });
1224
- return;
1225
- }
1226
- }
1227
- chatHistory.addMessage({ role: 'system', content: '⚠ Login timed out.' });
1228
- }
1229
-
1230
- async function cmdCode(opts, promptArgs) {
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();
1234
- const model = opts.model || getConfig().default_model;
1235
- const userPrompt = promptArgs.join(' ');
1236
- const context = opts.file ? readFileContext(opts.file) : '';
1237
- const fullPrompt = context ? `Context files:\n${context}\n\nTask: ${userPrompt}` : userPrompt;
1238
- let resolvedSystemPrompt = null;
1239
- if (opts.systemPromptFile) {
1240
- try { resolvedSystemPrompt = fs.readFileSync(opts.systemPromptFile, 'utf8'); } catch {}
1241
- }
1242
- let messages = [{ role: 'user', content: fullPrompt }];
1243
- writer.scrollback(` ${FG_GRAY}◆ ${model}${RST}`);
1244
- const codeResult = await runAgentLoop(messages, model, undefined, null, {
1245
- debug: dbg.isActive(),
1246
- systemPrompt: resolvedSystemPrompt,
1247
- systemPromptMode: getConfig().system_prompt_mode || 'system_role',
1248
- });
1249
- messages = codeResult.messages;
1250
- writer.scrollback('\n');
1251
- if (codeResult.metrics) writer.scrollback(codeResult.metrics.summary());
1252
- if (opts.dryRun) printDryRunSummary();
1253
- }
1254
-
1255
- async function cmdEdit(opts, filePath, instructionArgs) {
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();
1260
- const content = fs.readFileSync(filePath, 'utf8');
1261
- const instruction = instructionArgs.join(' ');
1262
- const messages = [
1263
- { role: 'system', content: 'You are Semalt.AI. Output ONLY the modified file. No explanations, no fences.' },
1264
- { role: 'user', content: `File: ${filePath}\n\n\`\`\`\n${content}\n\`\`\`\n\nInstruction: ${instruction}` },
1265
- ];
1266
- writer.scrollback(` ${FG_GRAY}Editing ${filePath}...${RST}`);
1267
- let result = await chatSync(messages, { model: opts.model });
1268
- if (result && !opts.dryRun) {
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'); }
1270
- fs.writeFileSync(filePath, result);
1271
- writer.scrollback(` ${FG_GREEN}✓ Saved: ${filePath}${RST}`);
1272
- } else if (opts.dryRun) {
1273
- writer.scrollback(` ${FG_YELLOW}⚠ Dry run — not modified${RST}`);
1274
- }
1275
- }
1276
-
1277
- async function cmdShell(opts, commandArgs) {
1278
- const command = commandArgs.join(' ');
1279
- if (!command) { writer.scrollback(` ${FG_RED}Usage: semalt-code shell <command>${RST}`); return; }
1280
- const result = await agentExecShell(command);
1281
- if (opts.analyze) {
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();
1284
- const messages = [
1285
- { role: 'system', content: 'You are Semalt.AI. Analyze the command output concisely.' },
1286
- { role: 'user', content: `Command: ${command}\nExit: ${result.exit_code}\nStdout:\n${result.stdout}\nStderr:\n${result.stderr}` },
1287
- ];
1288
- writer.scrollback(`\n ${FG_TEAL}${BOLD}◆ Semalt.AI${RST}\n`);
1289
- // audit: allowed — non-TUI streaming prefix, must precede StreamRenderer sync writes.
1290
- process.stdout.write(' ');
1291
- try {
1292
- await chatStream(messages, { model: opts.model });
1293
- } catch (err) {
1294
- msgs.netError(err.message);
1295
- }
1296
- writer.scrollback('\n');
1297
- }
1298
- }
1299
-
1300
- async function cmdModels() {
1301
- const config = getConfig();
1302
- let response;
1303
- try { response = await dashboardListModels(); }
1304
- catch (err) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}${err.message}${RST}\n`); return { model: config.default_model, dbId: config.dashboard_model_id }; }
1305
- const models = Array.isArray(response && response.models) ? response.models : [];
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}`);
1308
- const activeIndex = models.findIndex((m) => m.base_url === config.api_base && m.model_id === config.default_model);
1309
- const selectedIndex = await interactiveSelect(models, (model, isSelected, isFinal) => {
1310
- const active = model.base_url === config.api_base && model.model_id === config.default_model;
1311
- const marker = active ? `${FG_GREEN}●${RST}` : `${FG_DARK}○${RST}`;
1312
- const cursor = isSelected ? `${FG_TEAL}❯${RST}` : ' ';
1313
- const nameStyle = isSelected && !isFinal ? `${BG_SELECTED}${FG_CYAN}` : (isSelected ? FG_CYAN : FG_GRAY);
1314
- return ` ${marker} ${cursor} ${nameStyle}${model.name} · ${model.model_id} @ ${model.base_url}${RST}`;
1315
- }, { initialIndex: Math.max(0, activeIndex) });
1316
- if (selectedIndex === null) { writer.scrollback(` ${FG_DARK}Cancelled${RST}\n`); return { model: config.default_model, dbId: config.dashboard_model_id }; }
1317
- const selectedModel = models[selectedIndex];
1318
- let credentialsResponse;
1319
- try { credentialsResponse = await dashboardGetModelForCli(selectedModel.id); }
1320
- catch (err) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}${err.message}${RST}\n`); return { model: config.default_model, dbId: config.dashboard_model_id }; }
1321
- const model = credentialsResponse && credentialsResponse.model ? credentialsResponse.model : null;
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 }; }
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);
1324
- const updatedConfig = { ...config, api_base: model.base_url, api_key: model.api_key, default_model: model.model_id, dashboard_model_id: model.id };
1325
- if (contextLength !== null) updatedConfig.context_length = contextLength;
1326
- setConfig(updatedConfig);
1327
- writer.scrollback(` ${FG_GREEN}✓${RST} ${FG_GRAY}Current model → ${model.name} (${model.model_id})${RST}\n`);
1328
- return { model: model.model_id, dbId: model.id };
1329
- }
1330
-
1331
- function cmdInit(opts) {
1332
- const current = getConfig();
1333
- const cfg = {
1334
- api_base: opts.apiBase || 'http://127.0.0.1:8800',
1335
- api_key: opts.apiKey || 'any',
1336
- dashboard_url: opts.dashboardUrl || current.dashboard_url,
1337
- auth_token: current.auth_token || '',
1338
- default_model: opts.defaultModel || '',
1339
- temperature: 0.7,
1340
- request_timeout_ms: DEFAULT_API_TIMEOUT_MS,
1341
- stream: true,
1342
- models: current.models,
1343
- };
1344
- setConfig(cfg);
1345
- writer.scrollback(`\n ${FG_GREEN}✓${RST} Config saved to ${CONFIG_PATH}\n ${FG_GRAY}${JSON.stringify(cfg, null, 2)}${RST}\n`);
1346
- }
1347
-
1348
- async function cmdLogin() {
1349
- writer.scrollback(`\n ${FG_TEAL}${BOLD}◆ CLI Login${RST}\n ${FG_DARK}${'─'.repeat(40)}${RST}`);
1350
- let loginRequest;
1351
- try { loginRequest = await requestCliLogin(); }
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}`);
1354
- const startedAt = Date.now();
1355
- while (Date.now() - startedAt < LOGIN_TIMEOUT_MS) {
1356
- await new Promise((r) => setTimeout(r, LOGIN_POLL_INTERVAL_MS));
1357
- let status;
1358
- try { status = await getCliLoginStatus(loginRequest.id, loginRequest.hash); }
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; }
1362
- }
1363
- writer.scrollback(` ${FG_YELLOW}⚠${RST} ${FG_GRAY}Login timed out.${RST}\n`);
1364
- }
1365
-
1366
- async function cmdWhoAmI() {
1367
- let response;
1368
- try { response = await dashboardWhoAmI(); } catch (err) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}${err.message}${RST}\n`); return; }
1369
- const user = response && response.user ? response.user : null;
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'));
1383
- }
1384
-
1385
- async function cmdLogout() {
1386
- const config = getConfig();
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; } }
1389
- setConfig({ ...config, auth_token: '' });
1390
- writer.scrollback(` ${FG_GREEN}✓${RST} ${FG_GRAY}Logged out and cleared local CLI token.${RST}\n`);
1391
- }
1392
-
1393
- function printDryRunSummary() {
1394
- const ops = getSkippedOps();
1395
- const files = ops.filter((o) => o.category === 'file');
1396
- const cmds = ops.filter((o) => o.category === 'cmd');
1397
- const nets = ops.filter((o) => o.category === 'net');
1398
- const BOX_W = 40, INNER = BOX_W - 2;
1399
- const isTTY = process.stdout.isTTY;
1400
- const stripA = (s) => s.replace(/\x1b\[[^m]*m/g, '');
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}║`; };
1402
- const hr40 = (tl, fill, tr) => { const line = tl + fill.repeat(INNER) + tr; return isTTY ? `${FG_TEAL}${line}${RST}` : line; };
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'));
1421
- }
1422
-
1423
- return {
1424
- cmdChat,
1425
- cmdCode,
1426
- cmdEdit,
1427
- cmdInit,
1428
- cmdLogin,
1429
- cmdModels,
1430
- cmdShell,
1431
- cmdLogout,
1432
- cmdWhoAmI,
1433
- };
1434
- }
1435
-
1436
- module.exports = {
1437
- createCommands,
1438
- };