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