@semalt-ai/code 1.8.5 → 1.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +7 -1
- package/.github/workflows/ci.yml +69 -0
- package/ARCHITECTURE.md +6 -95
- package/CLAUDE.md +196 -316
- package/README.md +148 -4
- package/docs/ARCHITECTURE.md +1321 -0
- package/docs/CONFIG.md +340 -0
- package/docs/HISTORY.md +245 -0
- package/examples/embed.js +74 -0
- package/index.js +251 -10
- package/lib/agent.js +856 -120
- package/lib/api.js +239 -50
- package/lib/args.js +74 -2
- package/lib/audit.js +23 -1
- package/lib/background.js +584 -0
- package/lib/checkpoints.js +757 -0
- package/lib/commands/auth.js +94 -0
- package/lib/commands/chat-session.js +489 -0
- package/lib/commands/chat-slash.js +415 -0
- package/lib/commands/chat-turn.js +669 -0
- package/lib/commands/chat.js +407 -0
- package/lib/commands/custom.js +157 -0
- package/lib/commands/history-utils.js +66 -0
- package/lib/commands/index.js +268 -0
- package/lib/commands/mcp.js +113 -0
- package/lib/commands/oneshot.js +193 -0
- package/lib/commands/registry.js +269 -0
- package/lib/commands/tasks.js +89 -0
- package/lib/compact.js +87 -0
- package/lib/config.js +360 -11
- package/lib/constants.js +401 -3
- package/lib/deny.js +199 -0
- package/lib/doctor.js +160 -0
- package/lib/headless.js +202 -0
- package/lib/hooks.js +286 -0
- package/lib/images.js +270 -0
- package/lib/internals.js +49 -0
- package/lib/mcp/boundary.js +131 -0
- package/lib/mcp/client.js +270 -0
- package/lib/mcp/oauth.js +134 -0
- package/lib/memory.js +209 -0
- package/lib/metrics.js +37 -2
- package/lib/payload.js +54 -0
- package/lib/permission-rules.js +401 -0
- package/lib/permissions.js +123 -26
- package/lib/pricing.js +67 -0
- package/lib/proc.js +62 -0
- package/lib/prompts.js +99 -8
- package/lib/sandbox.js +568 -0
- package/lib/sdk.js +328 -0
- package/lib/secrets.js +211 -0
- package/lib/skills.js +223 -0
- package/lib/subagents.js +516 -0
- package/lib/tool_registry.js +2862 -0
- package/lib/tool_specs.js +263 -9
- package/lib/tools.js +352 -1039
- package/lib/ui/anim.js +86 -0
- package/lib/ui/ansi.js +17 -27
- package/lib/ui/chat-history.js +253 -71
- package/lib/ui/create-ui.js +67 -24
- package/lib/ui/diff.js +90 -25
- package/lib/ui/file-activity.js +236 -0
- package/lib/ui/format.js +195 -29
- package/lib/ui/input-field.js +21 -11
- package/lib/ui/md-stream.js +234 -0
- package/lib/ui/render-operation.js +113 -0
- package/lib/ui/select.js +1 -4
- package/lib/ui/status-bar.js +146 -36
- package/lib/ui/stream.js +20 -13
- package/lib/ui/theme.js +190 -44
- package/lib/ui/tool-operation.js +190 -0
- package/lib/ui/utils.js +9 -5
- package/lib/ui/web-activity.js +270 -0
- package/lib/ui/writer.js +159 -45
- package/lib/ui.js +1 -1
- package/lib/verify.js +229 -0
- package/lib/web-extract.js +213 -0
- package/lib/web-summarize.js +68 -0
- package/package.json +19 -4
- package/scripts/lint.js +57 -0
- package/test/agent-loop.test.js +389 -0
- package/test/anim-driver.test.js +153 -0
- package/test/ask-user-display.test.js +226 -0
- package/test/ask-user-gate.test.js +231 -0
- package/test/background.test.js +414 -0
- package/test/chat-history-nocolor.test.js +155 -0
- package/test/chat-relogin.test.js +207 -0
- package/test/chat.test.js +114 -0
- package/test/checkpoints-agent.test.js +181 -0
- package/test/checkpoints.test.js +650 -0
- package/test/command-registry.test.js +160 -0
- package/test/compact.test.js +116 -0
- package/test/completion-lazy.test.js +52 -0
- package/test/config-merge.test.js +324 -0
- package/test/config-quarantine.test.js +128 -0
- package/test/config-write-guard-allow-anywhere.test.js +56 -0
- package/test/config-write-guard-skip.test.js +46 -0
- package/test/config-write-guard.test.js +153 -0
- package/test/context-split.test.js +215 -0
- package/test/cost-doctor.test.js +142 -0
- package/test/custom-commands-chat.test.js +106 -0
- package/test/custom-commands.test.js +230 -0
- package/test/defer-detail-band.test.js +403 -0
- package/test/deny-windows.test.js +120 -0
- package/test/deny.test.js +83 -0
- package/test/detail-band-tab-flatten.test.js +242 -0
- package/test/download-allow-anywhere.test.js +66 -0
- package/test/download-confine.test.js +153 -0
- package/test/exec-diff.test.js +268 -0
- package/test/executors.test.js +599 -0
- package/test/extract-tool-calls.test.js +349 -0
- package/test/fetch-url-validation.test.js +219 -0
- package/test/file-activity.test.js +522 -0
- package/test/fixtures/tool-calls.js +57 -0
- package/test/fixtures/web-page.js +91 -0
- package/test/git-tools.test.js +384 -0
- package/test/grep-glob-serialize.test.js +242 -0
- package/test/grep-glob.test.js +268 -0
- package/test/grep-path-target.test.js +227 -0
- package/test/harness/README.md +57 -0
- package/test/harness/chat-harness.js +143 -0
- package/test/harness/memwarn-headless-child.js +65 -0
- package/test/harness/mock-llm.js +120 -0
- package/test/harness/mock-mcp-server.js +142 -0
- package/test/harness/sse-server.js +69 -0
- package/test/headless.test.js +348 -0
- package/test/history-utils.test.js +88 -0
- package/test/hooks-agent.test.js +238 -0
- package/test/hooks-verify-sandbox.test.js +232 -0
- package/test/hooks.test.js +216 -0
- package/test/http-get-user-agent.test.js +142 -0
- package/test/images-api.test.js +208 -0
- package/test/images.test.js +238 -0
- package/test/input-field-ctrl-o.test.js +37 -0
- package/test/live-height-physical.test.js +281 -0
- package/test/max-iterations.test.js +218 -0
- package/test/mcp-boundary.test.js +57 -0
- package/test/mcp-client.test.js +267 -0
- package/test/mcp-oauth.test.js +86 -0
- package/test/md-stream.test.js +183 -0
- package/test/memory-truncation-warning.test.js +222 -0
- package/test/memory.test.js +198 -0
- package/test/native-dispatch.test.js +409 -0
- package/test/native-live-narration.test.js +254 -0
- package/test/output-chokepoint.test.js +188 -0
- package/test/output-heredoc-leak.test.js +195 -0
- package/test/output-preview.test.js +245 -0
- package/test/path-guards.test.js +134 -0
- package/test/payload.test.js +99 -0
- package/test/permission-rules-agent.test.js +210 -0
- package/test/permission-rules.test.js +297 -0
- package/test/permissions.test.js +362 -0
- package/test/plan-mode.test.js +167 -0
- package/test/read-paginate.test.js +275 -0
- package/test/readonly-tools.test.js +177 -0
- package/test/render-operation.test.js +317 -0
- package/test/replay-descriptor-xml.test.js +216 -0
- package/test/replay-descriptor.test.js +189 -0
- package/test/replay-web-aggregate.test.js +291 -0
- package/test/replay-web-persist.test.js +241 -0
- package/test/result-cap.test.js +233 -0
- package/test/running-glyph-anim.test.js +111 -0
- package/test/sandbox-agent.test.js +147 -0
- package/test/sandbox-integration.test.js +216 -0
- package/test/sandbox.test.js +408 -0
- package/test/sdk.test.js +234 -0
- package/test/shell-output-cap.test.js +181 -0
- package/test/skills-chat.test.js +110 -0
- package/test/skills.test.js +295 -0
- package/test/smoke.test.js +68 -0
- package/test/status-bar-driver.test.js +93 -0
- package/test/status-bar-pause.test.js +164 -0
- package/test/status-bar-resync.test.js +188 -0
- package/test/stream-parser.test.js +171 -0
- package/test/subagents-agent.test.js +178 -0
- package/test/subagents.test.js +222 -0
- package/test/theme-palette.test.js +166 -0
- package/test/tool-registry.test.js +85 -0
- package/test/trim-budget.test.js +101 -0
- package/test/truncate-visible.test.js +78 -0
- package/test/verify-agent.test.js +317 -0
- package/test/verify.test.js +141 -0
- package/test/view-image.test.js +199 -0
- package/test/web-activity-ordering.test.js +203 -0
- package/test/web-activity.test.js +207 -0
- package/test/web-data-extraction-guidance.test.js +71 -0
- package/test/web-extract.test.js +185 -0
- package/test/web-fetch-agent.test.js +291 -0
- package/test/web-fetch-mode.test.js +193 -0
- package/test/web-search.test.js +380 -0
- package/lib/commands.js +0 -1438
- package/path +0 -1
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
|
-
};
|