@semalt-ai/code 1.8.5 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/.claude/settings.local.json +6 -1
  2. package/.github/workflows/ci.yml +69 -0
  3. package/CLAUDE.md +1584 -26
  4. package/README.md +147 -3
  5. package/examples/embed.js +74 -0
  6. package/index.js +251 -10
  7. package/lib/agent.js +711 -104
  8. package/lib/api.js +213 -49
  9. package/lib/args.js +74 -2
  10. package/lib/audit.js +23 -1
  11. package/lib/background.js +584 -0
  12. package/lib/checkpoints.js +757 -0
  13. package/lib/commands/auth.js +94 -0
  14. package/lib/commands/chat-session.js +306 -0
  15. package/lib/commands/chat-slash.js +399 -0
  16. package/lib/commands/chat-turn.js +446 -0
  17. package/lib/commands/chat.js +403 -0
  18. package/lib/commands/custom.js +157 -0
  19. package/lib/commands/history-utils.js +66 -0
  20. package/lib/commands/index.js +268 -0
  21. package/lib/commands/mcp.js +113 -0
  22. package/lib/commands/oneshot.js +193 -0
  23. package/lib/commands/registry.js +269 -0
  24. package/lib/commands/tasks.js +89 -0
  25. package/lib/compact.js +87 -0
  26. package/lib/config.js +333 -11
  27. package/lib/constants.js +372 -3
  28. package/lib/deny.js +199 -0
  29. package/lib/doctor.js +160 -0
  30. package/lib/headless.js +167 -0
  31. package/lib/hooks.js +286 -0
  32. package/lib/images.js +264 -0
  33. package/lib/internals.js +49 -0
  34. package/lib/mcp/boundary.js +131 -0
  35. package/lib/mcp/client.js +270 -0
  36. package/lib/mcp/oauth.js +134 -0
  37. package/lib/memory.js +209 -0
  38. package/lib/metrics.js +37 -2
  39. package/lib/payload.js +54 -0
  40. package/lib/permission-rules.js +401 -0
  41. package/lib/permissions.js +100 -10
  42. package/lib/pricing.js +67 -0
  43. package/lib/proc.js +62 -0
  44. package/lib/prompts.js +84 -5
  45. package/lib/sandbox.js +568 -0
  46. package/lib/sdk.js +328 -0
  47. package/lib/secrets.js +211 -0
  48. package/lib/skills.js +223 -0
  49. package/lib/subagents.js +516 -0
  50. package/lib/tool_registry.js +2558 -0
  51. package/lib/tool_specs.js +222 -2
  52. package/lib/tools.js +272 -1020
  53. package/lib/ui/format.js +22 -1
  54. package/lib/ui/input-field.js +16 -7
  55. package/lib/ui/status-bar.js +79 -11
  56. package/lib/ui/theme.js +1 -0
  57. package/lib/ui/web-activity.js +218 -0
  58. package/lib/verify.js +229 -0
  59. package/lib/web-extract.js +213 -0
  60. package/lib/web-summarize.js +68 -0
  61. package/package.json +19 -4
  62. package/scripts/lint.js +57 -0
  63. package/test/agent-loop.test.js +389 -0
  64. package/test/background.test.js +414 -0
  65. package/test/chat.test.js +114 -0
  66. package/test/checkpoints-agent.test.js +181 -0
  67. package/test/checkpoints.test.js +650 -0
  68. package/test/command-registry.test.js +160 -0
  69. package/test/compact.test.js +116 -0
  70. package/test/completion-lazy.test.js +52 -0
  71. package/test/config-merge.test.js +324 -0
  72. package/test/config-quarantine.test.js +128 -0
  73. package/test/config-write-guard-allow-anywhere.test.js +56 -0
  74. package/test/config-write-guard-skip.test.js +46 -0
  75. package/test/config-write-guard.test.js +153 -0
  76. package/test/context-split.test.js +215 -0
  77. package/test/cost-doctor.test.js +142 -0
  78. package/test/custom-commands-chat.test.js +106 -0
  79. package/test/custom-commands.test.js +230 -0
  80. package/test/deny-windows.test.js +120 -0
  81. package/test/deny.test.js +83 -0
  82. package/test/download-allow-anywhere.test.js +66 -0
  83. package/test/download-confine.test.js +153 -0
  84. package/test/executors.test.js +362 -0
  85. package/test/extract-tool-calls.test.js +315 -0
  86. package/test/fetch-url-validation.test.js +219 -0
  87. package/test/fixtures/tool-calls.js +57 -0
  88. package/test/fixtures/web-page.js +91 -0
  89. package/test/git-tools.test.js +384 -0
  90. package/test/grep-glob-serialize.test.js +242 -0
  91. package/test/grep-glob.test.js +268 -0
  92. package/test/harness/README.md +57 -0
  93. package/test/harness/chat-harness.js +142 -0
  94. package/test/harness/memwarn-headless-child.js +65 -0
  95. package/test/harness/mock-llm.js +120 -0
  96. package/test/harness/mock-mcp-server.js +142 -0
  97. package/test/harness/sse-server.js +69 -0
  98. package/test/headless.test.js +203 -0
  99. package/test/history-utils.test.js +88 -0
  100. package/test/hooks-agent.test.js +238 -0
  101. package/test/hooks-verify-sandbox.test.js +232 -0
  102. package/test/hooks.test.js +216 -0
  103. package/test/http-get-user-agent.test.js +142 -0
  104. package/test/images-api.test.js +208 -0
  105. package/test/images.test.js +238 -0
  106. package/test/max-iterations.test.js +216 -0
  107. package/test/mcp-boundary.test.js +57 -0
  108. package/test/mcp-client.test.js +267 -0
  109. package/test/mcp-oauth.test.js +86 -0
  110. package/test/memory-truncation-warning.test.js +222 -0
  111. package/test/memory.test.js +198 -0
  112. package/test/native-dispatch.test.js +356 -0
  113. package/test/output-chokepoint.test.js +188 -0
  114. package/test/path-guards.test.js +134 -0
  115. package/test/payload.test.js +99 -0
  116. package/test/permission-rules-agent.test.js +210 -0
  117. package/test/permission-rules.test.js +297 -0
  118. package/test/permissions.test.js +163 -0
  119. package/test/plan-mode.test.js +167 -0
  120. package/test/read-paginate.test.js +275 -0
  121. package/test/readonly-tools.test.js +177 -0
  122. package/test/result-cap.test.js +233 -0
  123. package/test/sandbox-agent.test.js +147 -0
  124. package/test/sandbox-integration.test.js +216 -0
  125. package/test/sandbox.test.js +408 -0
  126. package/test/sdk.test.js +234 -0
  127. package/test/shell-output-cap.test.js +181 -0
  128. package/test/skills-chat.test.js +110 -0
  129. package/test/skills.test.js +295 -0
  130. package/test/smoke.test.js +68 -0
  131. package/test/status-bar-pause.test.js +164 -0
  132. package/test/stream-parser.test.js +147 -0
  133. package/test/subagents-agent.test.js +178 -0
  134. package/test/subagents.test.js +222 -0
  135. package/test/tool-registry.test.js +85 -0
  136. package/test/trim-budget.test.js +101 -0
  137. package/test/verify-agent.test.js +317 -0
  138. package/test/verify.test.js +141 -0
  139. package/test/web-activity-ordering.test.js +194 -0
  140. package/test/web-activity.test.js +207 -0
  141. package/test/web-data-extraction-guidance.test.js +71 -0
  142. package/test/web-extract.test.js +185 -0
  143. package/test/web-fetch-agent.test.js +291 -0
  144. package/test/web-fetch-mode.test.js +193 -0
  145. package/test/web-search.test.js +380 -0
  146. package/lib/commands.js +0 -1438
@@ -0,0 +1,94 @@
1
+ 'use strict';
2
+
3
+ // Authentication / account CLI commands, extracted from lib/commands.js in
4
+ // Task 1.5. `shared` is the dependency bag built by createCommands; it is
5
+ // destructured once here so the command bodies below are unchanged.
6
+
7
+ function createAuthCommands(shared) {
8
+ const {
9
+ writer, getConfig, setConfig,
10
+ requestCliLogin, getCliLoginStatus, dashboardWhoAmI, dashboardLogout,
11
+ formatUserLine, keychainSet, ENV_VAR, CONFIG_PATH,
12
+ LOGIN_POLL_INTERVAL_MS, LOGIN_TIMEOUT_MS,
13
+ FG_RED, FG_GRAY, FG_TEAL, FG_DARK, FG_GREEN, FG_CYAN, FG_YELLOW, BOLD, RST,
14
+ } = shared;
15
+
16
+ async function cmdLogin() {
17
+ writer.scrollback(`\n ${FG_TEAL}${BOLD}◆ CLI Login${RST}\n ${FG_DARK}${'─'.repeat(40)}${RST}`);
18
+ let loginRequest;
19
+ try { loginRequest = await requestCliLogin(); }
20
+ catch (err) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}Unable to start login via ${getConfig().dashboard_url}: ${err.message}${RST}\n`); return; }
21
+ 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}`);
22
+ const startedAt = Date.now();
23
+ while (Date.now() - startedAt < LOGIN_TIMEOUT_MS) {
24
+ await new Promise((r) => setTimeout(r, LOGIN_POLL_INTERVAL_MS));
25
+ let status;
26
+ try { status = await getCliLoginStatus(loginRequest.id, loginRequest.hash); }
27
+ 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; }
28
+ 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; }
29
+ if (status.status === 'expired') { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}Login token expired.${RST}\n`); return; }
30
+ }
31
+ writer.scrollback(` ${FG_YELLOW}⚠${RST} ${FG_GRAY}Login timed out.${RST}\n`);
32
+ }
33
+
34
+ async function cmdWhoAmI() {
35
+ let response;
36
+ try { response = await dashboardWhoAmI(); } catch (err) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}${err.message}${RST}\n`); return; }
37
+ const user = response && response.user ? response.user : null;
38
+ if (!user) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}Unable to load current user.${RST}\n`); return; }
39
+ const lines = [
40
+ '',
41
+ ` ${FG_TEAL}${BOLD}◆ Current User${RST}`,
42
+ ` ${FG_DARK}${'─'.repeat(40)}${RST}`,
43
+ formatUserLine('ID', user.id),
44
+ formatUserLine('Email', user.email || '-'),
45
+ formatUserLine('Name', user.name || '-'),
46
+ formatUserLine('Provider', user.provider || '-'),
47
+ ];
48
+ if (user.avatar_url) lines.push(formatUserLine('Avatar', user.avatar_url));
49
+ lines.push('');
50
+ writer.scrollback(lines.join('\n'));
51
+ }
52
+
53
+ async function cmdLogout() {
54
+ const config = getConfig();
55
+ if (!config.auth_token) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}Not logged in.${RST}\n`); return; }
56
+ try { await dashboardLogout(); } catch (err) { if (err.statusCode !== 401) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}${err.message}${RST}\n`); return; } }
57
+ setConfig({ ...config, auth_token: '' });
58
+ writer.scrollback(` ${FG_GREEN}✓${RST} ${FG_GRAY}Logged out and cleared local CLI token.${RST}\n`);
59
+ }
60
+
61
+ // `semalt-code auth set-key [key]` — store the API key in the OS keychain so
62
+ // it is no longer kept in plaintext in config.json. If [key] is omitted, read
63
+ // it from stdin (so it does not land in shell history / the process table).
64
+ async function cmdAuthSetKey(keyArg) {
65
+ let key = typeof keyArg === 'string' ? keyArg.trim() : '';
66
+ if (!key) {
67
+ if (process.stdin.isTTY) {
68
+ process.stdout.write(` ${FG_GRAY}Paste API key (input hidden is not available; press Enter when done):${RST}\n > `);
69
+ }
70
+ key = await new Promise((resolve) => {
71
+ let buf = '';
72
+ const onData = (d) => { buf += d; if (buf.includes('\n')) { process.stdin.pause(); process.stdin.off('data', onData); resolve(buf.split('\n')[0]); } };
73
+ try { process.stdin.resume(); process.stdin.setEncoding('utf8'); process.stdin.on('data', onData); } catch { resolve(''); }
74
+ });
75
+ key = (key || '').trim();
76
+ }
77
+ if (!key) {
78
+ writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}No key provided.${RST}\n`);
79
+ return;
80
+ }
81
+ const ok = keychainSet(key);
82
+ if (ok) {
83
+ writer.scrollback(` ${FG_GREEN}✓${RST} ${FG_GRAY}API key stored in the OS keychain. It will be used automatically (env var still takes precedence).${RST}\n`);
84
+ writer.scrollback(` ${FG_DARK}It is NOT written to config.json. Verify with 'semalt-code config' → api_key_source: keychain.${RST}\n`);
85
+ } else {
86
+ writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}Could not store the key in the OS keychain (native tool missing or unavailable on this platform).${RST}\n`);
87
+ writer.scrollback(` ${FG_DARK}Alternative: set the ${ENV_VAR} environment variable instead.${RST}\n`);
88
+ }
89
+ }
90
+
91
+ return { cmdLogin, cmdWhoAmI, cmdLogout, cmdAuthSetKey };
92
+ }
93
+
94
+ module.exports = { createAuthCommands };
@@ -0,0 +1,306 @@
1
+ 'use strict';
2
+
3
+ // Chat session/state management extracted from cmdChat in Task 1.5: local- and
4
+ // dashboard-history sync plus the in-chat picker (history / chats / models).
5
+ // Bodies are unchanged except that mutable session fields are read/written
6
+ // through ctx (the getter/setter hub built in cmdChat) instead of as closure
7
+ // locals; stable collaborators are destructured from ctx once below. The
8
+ // functions call each other by bare name (same module scope); other chat
9
+ // modules reach them via ctx, onto which createCommands' cmdChat assigns them.
10
+
11
+ function createChatSession(ctx) {
12
+ const {
13
+ storage, inputField, chatHistory, statusBar, writer, msgs,
14
+ getConfig, setConfig, getCols, approxTokens, PAGE_SIZE, sessionStart,
15
+ dashboardCreateChat, dashboardSaveMessages, dashboardGetChat, dashboardGetModelForCli,
16
+ cleanOrphanedToolMessages, reconstructLoadedMessage, resolveTokenLimit,
17
+ FG_GRAY, RST,
18
+ } = ctx;
19
+
20
+ function refreshInputSearchItems(extraItems) {
21
+ const sessions = storage.list();
22
+ const items = sessions.map(s => ({
23
+ type: 'session',
24
+ text: (() => {
25
+ const date = new Date(s.created_at).toISOString().slice(0, 16).replace('T', ' ');
26
+ return `${date} ${s.model || ''} (${s.message_count} msgs)`;
27
+ })(),
28
+ }));
29
+ if (extraItems) items.push(...extraItems);
30
+ inputField.setSearchItems(items);
31
+ }
32
+
33
+ function saveSession() {
34
+ ctx.session.model = ctx.currentModel;
35
+ ctx.session.messages = ctx.messages;
36
+ ctx.session.stats.duration_sec = Math.round((Date.now() - sessionStart) / 1000);
37
+ ctx.session.stats.total_tokens = ctx.messages.reduce((s, m) => s + Math.round((m.content || '').length / 4), 0);
38
+ try { storage.save(ctx.session); } catch {}
39
+ }
40
+
41
+ async function createChatIfNeeded(firstUserText) {
42
+ const config = getConfig();
43
+ if (ctx.currentChatId !== null || !config.auth_token || !config.dashboard_model_id) return;
44
+ try {
45
+ const title = firstUserText.length > 60 ? firstUserText.slice(0, 57) + '...' : firstUserText;
46
+ const resp = await dashboardCreateChat(title, config.dashboard_model_id);
47
+ if (resp && resp.chat && resp.chat.id) ctx.currentChatId = resp.chat.id;
48
+ } catch {}
49
+ }
50
+
51
+ async function saveTurnToDashboard() {
52
+ if (ctx.currentChatId === null) return;
53
+ const newMessages = ctx.messages.slice(ctx.savedUpTo).filter((m) => m.role !== 'system');
54
+ if (!newMessages.length) return;
55
+ try {
56
+ const resp = await dashboardSaveMessages(ctx.currentChatId, newMessages);
57
+ ctx.savedUpTo = ctx.messages.length;
58
+ if (resp && typeof resp.skipped_count === 'number' && resp.skipped_count > 0) {
59
+ msgs.sysWarn(`history save: ${resp.skipped_count} message(s) skipped by server`);
60
+ }
61
+ } catch (err) {
62
+ msgs.sysWarn(`history save failed: ${err && err.message ? err.message : String(err)}`);
63
+ }
64
+ }
65
+
66
+ function displayLoadedMessages(loadedMessages) {
67
+ chatHistory.clearMessages();
68
+ for (const m of loadedMessages) {
69
+ if (m.role !== 'user' && m.role !== 'assistant' && m.role !== 'tool') continue;
70
+ const raw = typeof m.content === 'string' ? m.content : '';
71
+ const ts = m.created_at ? new Date(m.created_at) : (m.ts ? new Date(m.ts) : new Date());
72
+
73
+ if (m.role === 'tool') {
74
+ chatHistory.addMessage({ role: 'tool', tag: 'tool', content: raw, ts });
75
+ continue;
76
+ }
77
+
78
+ if (m.role === 'user' && raw.startsWith('Tool execution results:')) {
79
+ const body = raw
80
+ .replace(/^Tool execution results[^\n]*\n+/, '')
81
+ .replace(/\n+Continue with the task\.[\s\S]*$/, '')
82
+ .trim();
83
+ chatHistory.addMessage({ role: 'tool', tag: 'tool', content: body || raw, ts });
84
+ continue;
85
+ }
86
+
87
+ if (!raw.trim()) continue;
88
+ chatHistory.addMessage({ role: m.role, content: raw, ts });
89
+ }
90
+ }
91
+
92
+ function seedContextFromMessages() {
93
+ let total = 0;
94
+ for (const m of ctx.messages) {
95
+ if (typeof m.content === 'string') total += approxTokens(m.content);
96
+ }
97
+ statusBar.updateMetrics({ contextTokens: total });
98
+ }
99
+
100
+ function emitCleanupWarning(cleanup) {
101
+ if (cleanup.droppedTool === 0 && cleanup.droppedAssistantCalls === 0 && cleanup.droppedAssistantMsgs === 0) return;
102
+ const parts = [];
103
+ if (cleanup.droppedTool > 0) parts.push(`${cleanup.droppedTool} orphaned tool result(s)`);
104
+ if (cleanup.droppedAssistantCalls > 0) parts.push(`${cleanup.droppedAssistantCalls} dangling tool_call(s)`);
105
+ if (cleanup.droppedAssistantMsgs > 0) parts.push(`${cleanup.droppedAssistantMsgs} empty assistant message(s)`);
106
+ chatHistory.addMessage({
107
+ role: 'system',
108
+ content: `⚠ Loaded chat had ${parts.join(', ')}, cleaned up. The chat may be missing some context.`,
109
+ });
110
+ }
111
+
112
+ // ── In-chat picker (history / chats / models) ────────────────────────────
113
+
114
+ function getNavSearchText(type, item) {
115
+ if (type === 'history') {
116
+ const date = new Date(item.created_at).toISOString().slice(0, 16);
117
+ return `${date} ${item.model || ''} ${item.message_count || ''}`;
118
+ } else if (type === 'chats') {
119
+ return `${item.title || ''} ${item.model_name || ''}`;
120
+ } else if (type === 'models') {
121
+ return `${item.name || ''} ${item.model_id || ''}`;
122
+ }
123
+ return '';
124
+ }
125
+
126
+ function buildItemDetail(type, item) {
127
+ const cfg = getConfig();
128
+ const maxDetail = Math.max(20, getCols() - 12);
129
+ let detail = '';
130
+ if (type === 'history') {
131
+ const date = new Date(item.created_at).toISOString().slice(0, 16).replace('T', ' ');
132
+ detail = `${date} ${(item.model || '').slice(0, 20)} (${item.message_count} msgs)`;
133
+ } else if (type === 'chats') {
134
+ const date = item.updated_at ? String(item.updated_at).slice(0, 10) : '';
135
+ detail = `${item.title} · ${item.model_name || ''} · ${date}`;
136
+ } else if (type === 'models') {
137
+ const active = item.base_url === cfg.api_base && item.model_id === cfg.default_model;
138
+ detail = `${active ? '●' : ' '} ${item.name} · ${item.model_id}`;
139
+ }
140
+ if (detail.length > maxDetail) detail = detail.slice(0, maxDetail - 1) + '…';
141
+ return detail;
142
+ }
143
+
144
+ function buildListContent() {
145
+ if (!ctx.pendingAction) return '';
146
+ const { type, items, displayItems: di, stepIdx, searchQuery } = ctx.pendingAction;
147
+ const items2 = di || items;
148
+ const page = Math.floor(stepIdx / PAGE_SIZE);
149
+ const pageCount = Math.ceil(items2.length / PAGE_SIZE);
150
+ const pageStart = page * PAGE_SIZE;
151
+ const pageItems = items2.slice(pageStart, pageStart + PAGE_SIZE);
152
+ const localIdx = stepIdx - pageStart;
153
+ const titleMap = { history: 'Sessions', chats: 'Chats', models: 'Models' };
154
+ const pageLabel = pageCount > 1 ? ` · Page ${page + 1}/${pageCount}` : '';
155
+ const countLabel = items2.length > 0 ? `[${stepIdx + 1}/${items2.length}]` : '[0 results]';
156
+ const searchLabel = searchQuery ? ` · filter: '${searchQuery}'` : '';
157
+ const parts = [`${titleMap[type] || type} ${countLabel}${pageLabel}${searchLabel}`, ''];
158
+ for (let i = 0; i < pageItems.length; i++) {
159
+ const item = pageItems[i];
160
+ const sel = i === localIdx;
161
+ const detail = buildItemDetail(type, item);
162
+ parts.push(sel ? `\x1b[1m\x1b[36m ► ${detail}` : ` ${detail}`);
163
+ }
164
+ // Pad to a fixed height so rerenderById always clears the same number of rows,
165
+ // regardless of how many items the current page has (last page may have fewer).
166
+ while (parts.length < PAGE_SIZE + 2) parts.push('');
167
+ return parts.join('\n');
168
+ }
169
+
170
+ function collapseListMsg(_type, _item) {
171
+ // Modal is transient — clearing it removes the picker from view; the
172
+ // selection's success line is emitted to scrollback by handlePendingSelection.
173
+ writer.clearModal();
174
+ }
175
+
176
+ function showPendingStep() {
177
+ if (!ctx.pendingAction) return;
178
+ const lines = buildListContent().split('\n');
179
+ const modalLines = lines.length > 0
180
+ ? [` ${FG_GRAY}●${RST} ${FG_GRAY}${lines[0]}${RST}`].concat(lines.slice(1).map((l) => ` ${l}`))
181
+ : [];
182
+ writer.setModal(modalLines);
183
+ }
184
+
185
+ function finalizeListMsg() {
186
+ writer.clearModal();
187
+ }
188
+
189
+ function activateNavCapture() {
190
+ inputField.captureNavigation(async (action) => {
191
+ if (!ctx.pendingAction) { inputField.releaseNavigation(); return; }
192
+ const { items, displayItems: di, stepIdx } = ctx.pendingAction;
193
+ const activeItems = di || items;
194
+
195
+ if (action.startsWith('search:')) {
196
+ const query = action.slice(7);
197
+ if (!query) {
198
+ ctx.pendingAction = { ...ctx.pendingAction, displayItems: null, searchQuery: '', stepIdx: 0 };
199
+ } else {
200
+ const filtered = items.filter(item => getNavSearchText(ctx.pendingAction.type, item).toLowerCase().includes(query.toLowerCase()));
201
+ ctx.pendingAction = { ...ctx.pendingAction, displayItems: filtered, searchQuery: query, stepIdx: 0 };
202
+ }
203
+ showPendingStep();
204
+ return;
205
+ }
206
+
207
+ if (action === 'next') {
208
+ ctx.pendingAction = { ...ctx.pendingAction, stepIdx: activeItems.length ? (stepIdx + 1) % activeItems.length : 0 };
209
+ showPendingStep();
210
+ } else if (action === 'prev') {
211
+ ctx.pendingAction = { ...ctx.pendingAction, stepIdx: activeItems.length ? (stepIdx - 1 + activeItems.length) % activeItems.length : 0 };
212
+ showPendingStep();
213
+ } else if (action === 'select') {
214
+ if (!activeItems.length) return;
215
+ inputField.releaseNavigation();
216
+ const si = ctx.pendingAction.stepIdx;
217
+ collapseListMsg(ctx.pendingAction.type, activeItems[si]);
218
+ statusBar.update('idle');
219
+ await handlePendingSelection(si);
220
+ inputField.setDisabled(false);
221
+ } else if (action === 'cancel') {
222
+ inputField.releaseNavigation();
223
+ finalizeListMsg();
224
+ chatHistory.addMessage({ role: 'system', content: 'Cancelled.' });
225
+ ctx.pendingAction = null;
226
+ statusBar.update('idle');
227
+ inputField.setDisabled(false);
228
+ }
229
+ });
230
+ }
231
+
232
+ async function handlePendingSelection(idx) {
233
+ if (!ctx.pendingAction) return;
234
+ const { type, items, displayItems: di } = ctx.pendingAction;
235
+ const activeItems = di || items;
236
+ ctx.pendingAction = null;
237
+
238
+ if (type === 'history') {
239
+ const loaded = storage.load(activeItems[idx].id);
240
+ if (loaded) {
241
+ const filtered = (loaded.messages || []).filter((m) => m.role !== 'system');
242
+ const cleanup = cleanOrphanedToolMessages(filtered);
243
+ ctx.messages = cleanup.messages;
244
+ ctx.session = { id: loaded.id, created_at: loaded.created_at, model: loaded.model, messages: ctx.messages, stats: loaded.stats || { total_tokens: 0, duration_sec: 0 } };
245
+ ctx.currentChatId = null; ctx.savedUpTo = 0;
246
+ if (loaded.model && loaded.model !== ctx.currentModel) {
247
+ ctx.currentModel = loaded.model;
248
+ ctx.resolvedTokenLimit = await resolveTokenLimit(ctx.currentModel);
249
+ statusBar.setModel(ctx.currentModel);
250
+ statusBar.setContextLimit(ctx.resolvedTokenLimit);
251
+ }
252
+ displayLoadedMessages(ctx.messages);
253
+ chatHistory.addMessage({ role: 'system', content: `✓ Session loaded. Model → ${ctx.currentModel}` });
254
+ emitCleanupWarning(cleanup);
255
+ seedContextFromMessages();
256
+ }
257
+ } else if (type === 'chats') {
258
+ const selectedChat = activeItems[idx];
259
+ try {
260
+ const chatData = await dashboardGetChat(selectedChat.id);
261
+ const loaded = chatData && chatData.messages ? chatData.messages : [];
262
+ const reconstructed = loaded.map(reconstructLoadedMessage);
263
+ const cleanup = cleanOrphanedToolMessages(reconstructed);
264
+ ctx.messages = cleanup.messages;
265
+ ctx.currentChatId = selectedChat.id; ctx.savedUpTo = ctx.messages.length;
266
+ displayLoadedMessages(loaded);
267
+ chatHistory.addMessage({ role: 'system', content: `✓ Resumed: ${selectedChat.title} (${loaded.length} messages)` });
268
+ emitCleanupWarning(cleanup);
269
+ seedContextFromMessages();
270
+ } catch (err) {
271
+ chatHistory.addMessage({ role: 'system', content: `✗ ${err.message}`, isError: true });
272
+ }
273
+ } else if (type === 'models') {
274
+ const selectedModel = activeItems[idx];
275
+ try {
276
+ const credResp = await dashboardGetModelForCli(selectedModel.id);
277
+ const model = credResp && credResp.model ? credResp.model : null;
278
+ if (!model) { chatHistory.addMessage({ role: 'system', content: '✗ Unable to load model.', isError: true }); return; }
279
+ const contextLength = (Number.isInteger(model.context_length) && model.context_length > 0 ? model.context_length : null)
280
+ || (Number.isInteger(model.max_tokens) && model.max_tokens > 0 ? model.max_tokens : null);
281
+ const config = getConfig();
282
+ const updated = { ...config, api_base: model.base_url, api_key: model.api_key, default_model: model.model_id, dashboard_model_id: model.id };
283
+ if (contextLength !== null) updated.context_length = contextLength;
284
+ setConfig(updated);
285
+ ctx.currentModel = model.model_id;
286
+ ctx.resolvedTokenLimit = await resolveTokenLimit(ctx.currentModel);
287
+ statusBar.setModel(ctx.currentModel);
288
+ statusBar.setContextLimit(ctx.resolvedTokenLimit);
289
+ ctx.currentChatId = null;
290
+ chatHistory.addMessage({ role: 'system', content: `✓ Model → ${model.name} (${model.model_id})` });
291
+ statusBar.update('idle');
292
+ } catch (err) {
293
+ chatHistory.addMessage({ role: 'system', content: `✗ ${err.message}`, isError: true });
294
+ }
295
+ }
296
+ }
297
+
298
+ return {
299
+ refreshInputSearchItems, saveSession, createChatIfNeeded, saveTurnToDashboard,
300
+ displayLoadedMessages, seedContextFromMessages, emitCleanupWarning,
301
+ getNavSearchText, buildItemDetail, buildListContent, collapseListMsg,
302
+ showPendingStep, finalizeListMsg, activateNavCapture, handlePendingSelection,
303
+ };
304
+ }
305
+
306
+ module.exports = { createChatSession };