@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
|
@@ -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,489 @@
|
|
|
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 (err) {
|
|
49
|
+
// Surface (don't swallow) a creation failure: a silent failure here leaves
|
|
50
|
+
// currentChatId null, so saveTurnToDashboard early-returns with no warning —
|
|
51
|
+
// quiet data loss. Non-fatal to the turn; the local session save still runs.
|
|
52
|
+
msgs.sysWarn(`could not create dashboard chat: ${err && err.message ? err.message : String(err)}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function saveTurnToDashboard() {
|
|
57
|
+
if (ctx.currentChatId === null) return;
|
|
58
|
+
const newMessages = ctx.messages.slice(ctx.savedUpTo).filter((m) => m.role !== 'system');
|
|
59
|
+
if (!newMessages.length) return;
|
|
60
|
+
try {
|
|
61
|
+
const resp = await dashboardSaveMessages(ctx.currentChatId, newMessages);
|
|
62
|
+
ctx.savedUpTo = ctx.messages.length;
|
|
63
|
+
if (resp && typeof resp.skipped_count === 'number' && resp.skipped_count > 0) {
|
|
64
|
+
msgs.sysWarn(`history save: ${resp.skipped_count} message(s) skipped by server`);
|
|
65
|
+
}
|
|
66
|
+
} catch (err) {
|
|
67
|
+
// A 404 means the chat id is stale for the CURRENT token — the chat belongs
|
|
68
|
+
// to another principal (relogin) or was deleted on the dashboard. Self-heal
|
|
69
|
+
// ONCE per turn: recreate a fresh chat under the current token and re-save
|
|
70
|
+
// the SAME pending slice. Scoped strictly to 404 — a transient network/5xx
|
|
71
|
+
// must NOT recreate the chat (that would spawn duplicates); leave savedUpTo
|
|
72
|
+
// unadvanced so a later turn retries the same slice naturally.
|
|
73
|
+
if (err && err.statusCode === 404) {
|
|
74
|
+
ctx.currentChatId = null;
|
|
75
|
+
const firstUser = ctx.messages.find((m) => m.role === 'user');
|
|
76
|
+
const title = firstUser && typeof firstUser.content === 'string' && firstUser.content
|
|
77
|
+
? firstUser.content : 'Untitled chat';
|
|
78
|
+
await createChatIfNeeded(title); // warns on its own failure (see above)
|
|
79
|
+
if (ctx.currentChatId === null) return; // recreation failed; already warned
|
|
80
|
+
try {
|
|
81
|
+
const resp = await dashboardSaveMessages(ctx.currentChatId, newMessages);
|
|
82
|
+
ctx.savedUpTo = ctx.messages.length; // advance ONLY after re-save succeeds
|
|
83
|
+
if (resp && typeof resp.skipped_count === 'number' && resp.skipped_count > 0) {
|
|
84
|
+
msgs.sysWarn(`history save: ${resp.skipped_count} message(s) skipped by server`);
|
|
85
|
+
}
|
|
86
|
+
} catch (err2) {
|
|
87
|
+
msgs.sysWarn(`history save failed: ${err2 && err2.message ? err2.message : String(err2)}`);
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
msgs.sysWarn(`history save failed: ${err && err.message ? err.message : String(err)}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function displayLoadedMessages(loadedMessages) {
|
|
96
|
+
chatHistory.clearMessages();
|
|
97
|
+
const cfg = getConfig() || {};
|
|
98
|
+
const { descriptorFromStored } = require('../ui/tool-operation');
|
|
99
|
+
const { isWebCore, aggregateWebOps, formatWebSummaryLine } = require('../ui/web-activity');
|
|
100
|
+
const {
|
|
101
|
+
isGroupableFileCore, fileSummaryState, formatFileSummaryLine,
|
|
102
|
+
} = require('../ui/file-activity');
|
|
103
|
+
|
|
104
|
+
// Phase 6c-ii — replayed web activity renders as the aggregated `✓ web · …`
|
|
105
|
+
// committed summary, byte-identical to the live committed line. A web GROUP
|
|
106
|
+
// is a maximal consecutive run of web ops, so the buffer is LOOP-LEVEL (not
|
|
107
|
+
// blob-/message-local): a group spans iterations, and those iterations live
|
|
108
|
+
// in separate {role:'tool'} messages (native rail) or separate
|
|
109
|
+
// {role:'user'} feedback blobs (XML rail). The live flush triggers
|
|
110
|
+
// (chat-turn.js) are mirrored below: a non-web tool starting, a terminal
|
|
111
|
+
// assistant message with content, and turn end (the trailing flushWeb()).
|
|
112
|
+
// flushWeb() calls ONLY the pure aggregateWebOps/formatWebSummaryLine — it
|
|
113
|
+
// never instantiates createWebActivityTracker or touches the live region.
|
|
114
|
+
let webBuf = [];
|
|
115
|
+
function flushWeb() {
|
|
116
|
+
if (!webBuf.length) return;
|
|
117
|
+
const line = formatWebSummaryLine(aggregateWebOps(webBuf), { pending: false });
|
|
118
|
+
chatHistory.addRawLine(line);
|
|
119
|
+
webBuf = [];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Parallel re-grouping for consecutive file ops (read_file/list_dir),
|
|
123
|
+
// mirroring flushWeb but with the live tracker's THRESHOLD applied at the
|
|
124
|
+
// REPLAY terminal width: a buffered run of ≥3 commits one aggregated summary
|
|
125
|
+
// (formatFileSummaryLine reads getCols() at flush, so a 200-col session
|
|
126
|
+
// re-groups correctly in an 80-col terminal); a run of 1–2 commits each op as
|
|
127
|
+
// its own per-op line via the SAME `_display` render the live path uses —
|
|
128
|
+
// byte-identical to a fresh per-op commit. read_file and list_dir share ONE
|
|
129
|
+
// group (mirroring the live merged key): a mixed run re-groups into the same
|
|
130
|
+
// single summary, with fileSummaryState picking the homogeneous-vs-mixed verb.
|
|
131
|
+
let fileBuf = []; // [{ core, ts }]
|
|
132
|
+
function flushFile() {
|
|
133
|
+
if (!fileBuf.length) return;
|
|
134
|
+
const buf = fileBuf;
|
|
135
|
+
fileBuf = [];
|
|
136
|
+
if (buf.length >= 3) {
|
|
137
|
+
const line = formatFileSummaryLine(fileSummaryState(buf.map((e) => e.core)), { pending: false });
|
|
138
|
+
chatHistory.addRawLine(line);
|
|
139
|
+
} else {
|
|
140
|
+
for (const { core, ts } of buf) {
|
|
141
|
+
chatHistory.addMessage({
|
|
142
|
+
role: 'tool', tag: 'tool', content: '', ts,
|
|
143
|
+
_display: core,
|
|
144
|
+
diffMaxLines: cfg.diff_max_lines,
|
|
145
|
+
previewLines: cfg.shell_preview_lines || 5,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// pushFile is only reached for groupable file cores (the caller gates on
|
|
151
|
+
// isGroupableFileCore), and read_file + list_dir now share one group, so every
|
|
152
|
+
// buffered op belongs to the same run — no key split. A non-groupable op
|
|
153
|
+
// flushes the buffer via the flushFile() calls on the other branches.
|
|
154
|
+
function pushFile(core, ts) {
|
|
155
|
+
fileBuf.push({ core, ts });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// A message that carries tool activity for an in-flight iteration: a native
|
|
159
|
+
// {role:'tool'} result, or an XML {role:'user'} feedback blob. The replay
|
|
160
|
+
// analogue of "an assistant iteration had tool calls" (live: cleanContent==='')
|
|
161
|
+
// is "the assistant is immediately followed by tool activity" — rail-agnostic,
|
|
162
|
+
// and independent of whether `tool_calls` survived the storage round-trip.
|
|
163
|
+
const isToolActivity = (msg) => !!msg && (
|
|
164
|
+
msg.role === 'tool' ||
|
|
165
|
+
(msg.role === 'user' && typeof msg.content === 'string' && msg.content.startsWith('Tool execution results:'))
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const relevant = loadedMessages.filter(
|
|
169
|
+
(m) => m.role === 'user' || m.role === 'assistant' || m.role === 'tool',
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
for (let ri = 0; ri < relevant.length; ri++) {
|
|
173
|
+
const m = relevant[ri];
|
|
174
|
+
const raw = typeof m.content === 'string' ? m.content : '';
|
|
175
|
+
const ts = m.created_at ? new Date(m.created_at) : (m.ts ? new Date(m.ts) : new Date());
|
|
176
|
+
|
|
177
|
+
if (m.role === 'tool') {
|
|
178
|
+
// Phase 6c-ii — a native web op persists a {v:1,kind:'web',…} core in
|
|
179
|
+
// `_display`. Buffer it into the current web group (no per-op line) and
|
|
180
|
+
// continue; the aggregated summary commits when the group flushes. A
|
|
181
|
+
// non-web tool first ENDS any open web run so its summary lands ABOVE this
|
|
182
|
+
// line (mirrors chat-turn.js:222), then renders via the 6a path.
|
|
183
|
+
if (isWebCore(m._display)) { flushFile(); webBuf.push(m._display); continue; }
|
|
184
|
+
// A groupable file core (read_file/list_dir) buffers into the file group
|
|
185
|
+
// (flushing any open web run first so its summary lands above); the
|
|
186
|
+
// aggregated/per-op commit happens when the file group flushes.
|
|
187
|
+
if (isGroupableFileCore(m._display)) { flushWeb(); pushFile(m._display, ts); continue; }
|
|
188
|
+
flushWeb(); flushFile();
|
|
189
|
+
// Phase 6a — forward the persisted display descriptor (native rail) so
|
|
190
|
+
// chat-history can replay it with full fidelity. `_display` absent →
|
|
191
|
+
// legacy summarizeToolResult fallback. The budgets match the live path
|
|
192
|
+
// (diff_max_lines for an edit diff; shell_preview_lines for an output
|
|
193
|
+
// preview) so a replayed line is byte-identical to a fresh render.
|
|
194
|
+
chatHistory.addMessage({
|
|
195
|
+
role: 'tool', tag: 'tool', content: raw, ts,
|
|
196
|
+
_display: m._display,
|
|
197
|
+
diffMaxLines: cfg.diff_max_lines,
|
|
198
|
+
previewLines: cfg.shell_preview_lines || 5,
|
|
199
|
+
});
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (m.role === 'user' && raw.startsWith('Tool execution results:')) {
|
|
204
|
+
// Phase 6b — XML rail per-call replay parity. The feedback blob folds all
|
|
205
|
+
// tool results of a turn into one {role:'user'} message; it cannot be
|
|
206
|
+
// split back by parsing (the \n\n separator appears freely inside result
|
|
207
|
+
// bodies). When the persisted aligned `_display[]` array is present AND
|
|
208
|
+
// EVERY slot is a valid core — a normal descriptor core OR (Phase 6c-ii)
|
|
209
|
+
// a web-op core — replay each in order. The gate stays fail-safe: a single
|
|
210
|
+
// `null` slot or an unknown core drops the WHOLE blob to the legacy
|
|
211
|
+
// whole-blob summary below (no partial render), so a web op's activity
|
|
212
|
+
// never silently vanishes on replay.
|
|
213
|
+
const displays = Array.isArray(m._display) ? m._display : null;
|
|
214
|
+
// Phase 6c-ii — flip the 6c-i gate: a web-op core ({v:1,kind:'web',…}) is
|
|
215
|
+
// now a VALID slot (it aggregates into the web summary) instead of failing
|
|
216
|
+
// the gate. Normal slots must still pass descriptorFromStored; web slots
|
|
217
|
+
// must be v:1 web-cores. Any other slot (null / unknown) still fails.
|
|
218
|
+
if (displays && displays.length > 0 && displays.every(
|
|
219
|
+
(el) => el && ((isWebCore(el) && el.v === 1) || (!isWebCore(el) && descriptorFromStored(el))),
|
|
220
|
+
)) {
|
|
221
|
+
for (const el of displays) {
|
|
222
|
+
// A web slot buffers into the open web group; a groupable file slot
|
|
223
|
+
// buffers into the file group; a normal slot first flushes any
|
|
224
|
+
// preceding grouped run (mirrors a non-grouped tool starting), then
|
|
225
|
+
// renders.
|
|
226
|
+
if (isWebCore(el)) { flushFile(); webBuf.push(el); continue; }
|
|
227
|
+
if (isGroupableFileCore(el)) { flushWeb(); pushFile(el, ts); continue; }
|
|
228
|
+
flushWeb(); flushFile();
|
|
229
|
+
chatHistory.addMessage({
|
|
230
|
+
role: 'tool', tag: 'tool', content: '', ts,
|
|
231
|
+
_display: el,
|
|
232
|
+
diffMaxLines: cfg.diff_max_lines,
|
|
233
|
+
previewLines: cfg.shell_preview_lines || 5,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
// Gate failed — a preceding grouped run (if any) ends here, then the legacy
|
|
239
|
+
// whole-blob summary renders (6b/6c-i fail-safe preserved).
|
|
240
|
+
flushWeb(); flushFile();
|
|
241
|
+
const body = raw
|
|
242
|
+
.replace(/^Tool execution results[^\n]*\n+/, '')
|
|
243
|
+
.replace(/\n+Continue with the task\.[\s\S]*$/, '')
|
|
244
|
+
.trim();
|
|
245
|
+
chatHistory.addMessage({ role: 'tool', tag: 'tool', content: body || raw, ts });
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Plain user / assistant message. Empty-content messages never flush (an
|
|
250
|
+
// intermediate, tool-call-only assistant message between two web iterations
|
|
251
|
+
// carries empty display content — flushing there would split a cross-
|
|
252
|
+
// iteration group into two summaries) and are not rendered.
|
|
253
|
+
if (!raw.trim()) continue;
|
|
254
|
+
if (m.role === 'assistant') {
|
|
255
|
+
// Flush ONLY on a TERMINAL assistant message — one not immediately
|
|
256
|
+
// followed by tool activity (the replay analogue of live cleanContent!==''
|
|
257
|
+
// at chat-turn.js:389-391). An intermediate assistant with content is
|
|
258
|
+
// still rendered, but does NOT flush, so the open web group survives.
|
|
259
|
+
if (!isToolActivity(relevant[ri + 1])) { flushWeb(); flushFile(); }
|
|
260
|
+
} else {
|
|
261
|
+
// A plain user message starts a NEW turn — close any grouped run left open
|
|
262
|
+
// by the prior turn (the live per-turn `finally` flush, chat-turn.js).
|
|
263
|
+
flushWeb(); flushFile();
|
|
264
|
+
}
|
|
265
|
+
chatHistory.addMessage({ role: m.role, content: raw, ts });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Turn-end safety net: a trailing grouped run (turn ended/was interrupted
|
|
269
|
+
// right after a web or file op, with no terminal assistant) commits here —
|
|
270
|
+
// mirrors the live per-turn `finally` flush in chat-turn.js.
|
|
271
|
+
flushWeb();
|
|
272
|
+
flushFile();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function seedContextFromMessages() {
|
|
276
|
+
let total = 0;
|
|
277
|
+
for (const m of ctx.messages) {
|
|
278
|
+
if (typeof m.content === 'string') total += approxTokens(m.content);
|
|
279
|
+
}
|
|
280
|
+
statusBar.updateMetrics({ contextTokens: total });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function emitCleanupWarning(cleanup) {
|
|
284
|
+
if (cleanup.droppedTool === 0 && cleanup.droppedAssistantCalls === 0 && cleanup.droppedAssistantMsgs === 0) return;
|
|
285
|
+
const parts = [];
|
|
286
|
+
if (cleanup.droppedTool > 0) parts.push(`${cleanup.droppedTool} orphaned tool result(s)`);
|
|
287
|
+
if (cleanup.droppedAssistantCalls > 0) parts.push(`${cleanup.droppedAssistantCalls} dangling tool_call(s)`);
|
|
288
|
+
if (cleanup.droppedAssistantMsgs > 0) parts.push(`${cleanup.droppedAssistantMsgs} empty assistant message(s)`);
|
|
289
|
+
chatHistory.addMessage({
|
|
290
|
+
role: 'system',
|
|
291
|
+
content: `⚠ Loaded chat had ${parts.join(', ')}, cleaned up. The chat may be missing some context.`,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ── In-chat picker (history / chats / models) ────────────────────────────
|
|
296
|
+
|
|
297
|
+
function getNavSearchText(type, item) {
|
|
298
|
+
if (type === 'history') {
|
|
299
|
+
const date = new Date(item.created_at).toISOString().slice(0, 16);
|
|
300
|
+
return `${date} ${item.model || ''} ${item.message_count || ''}`;
|
|
301
|
+
} else if (type === 'chats') {
|
|
302
|
+
return `${item.title || ''} ${item.model_name || ''}`;
|
|
303
|
+
} else if (type === 'models') {
|
|
304
|
+
return `${item.name || ''} ${item.model_id || ''}`;
|
|
305
|
+
}
|
|
306
|
+
return '';
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function buildItemDetail(type, item) {
|
|
310
|
+
const cfg = getConfig();
|
|
311
|
+
const maxDetail = Math.max(20, getCols() - 12);
|
|
312
|
+
let detail = '';
|
|
313
|
+
if (type === 'history') {
|
|
314
|
+
const date = new Date(item.created_at).toISOString().slice(0, 16).replace('T', ' ');
|
|
315
|
+
detail = `${date} ${(item.model || '').slice(0, 20)} (${item.message_count} msgs)`;
|
|
316
|
+
} else if (type === 'chats') {
|
|
317
|
+
const date = item.updated_at ? String(item.updated_at).slice(0, 10) : '';
|
|
318
|
+
detail = `${item.title} · ${item.model_name || ''} · ${date}`;
|
|
319
|
+
} else if (type === 'models') {
|
|
320
|
+
const active = item.base_url === cfg.api_base && item.model_id === cfg.default_model;
|
|
321
|
+
detail = `${active ? '●' : ' '} ${item.name} · ${item.model_id}`;
|
|
322
|
+
}
|
|
323
|
+
if (detail.length > maxDetail) detail = detail.slice(0, maxDetail - 1) + '…';
|
|
324
|
+
return detail;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function buildListContent() {
|
|
328
|
+
if (!ctx.pendingAction) return '';
|
|
329
|
+
const { type, items, displayItems: di, stepIdx, searchQuery } = ctx.pendingAction;
|
|
330
|
+
const items2 = di || items;
|
|
331
|
+
const page = Math.floor(stepIdx / PAGE_SIZE);
|
|
332
|
+
const pageCount = Math.ceil(items2.length / PAGE_SIZE);
|
|
333
|
+
const pageStart = page * PAGE_SIZE;
|
|
334
|
+
const pageItems = items2.slice(pageStart, pageStart + PAGE_SIZE);
|
|
335
|
+
const localIdx = stepIdx - pageStart;
|
|
336
|
+
const titleMap = { history: 'Sessions', chats: 'Chats', models: 'Models' };
|
|
337
|
+
const pageLabel = pageCount > 1 ? ` · Page ${page + 1}/${pageCount}` : '';
|
|
338
|
+
const countLabel = items2.length > 0 ? `[${stepIdx + 1}/${items2.length}]` : '[0 results]';
|
|
339
|
+
const searchLabel = searchQuery ? ` · filter: '${searchQuery}'` : '';
|
|
340
|
+
const parts = [`${titleMap[type] || type} ${countLabel}${pageLabel}${searchLabel}`, ''];
|
|
341
|
+
for (let i = 0; i < pageItems.length; i++) {
|
|
342
|
+
const item = pageItems[i];
|
|
343
|
+
const sel = i === localIdx;
|
|
344
|
+
const detail = buildItemDetail(type, item);
|
|
345
|
+
parts.push(sel ? `\x1b[1m\x1b[36m ► ${detail}` : ` ${detail}`);
|
|
346
|
+
}
|
|
347
|
+
// Pad to a fixed height so rerenderById always clears the same number of rows,
|
|
348
|
+
// regardless of how many items the current page has (last page may have fewer).
|
|
349
|
+
while (parts.length < PAGE_SIZE + 2) parts.push('');
|
|
350
|
+
return parts.join('\n');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function collapseListMsg(_type, _item) {
|
|
354
|
+
// Modal is transient — clearing it removes the picker from view; the
|
|
355
|
+
// selection's success line is emitted to scrollback by handlePendingSelection.
|
|
356
|
+
writer.clearModal();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function showPendingStep() {
|
|
360
|
+
if (!ctx.pendingAction) return;
|
|
361
|
+
const lines = buildListContent().split('\n');
|
|
362
|
+
const modalLines = lines.length > 0
|
|
363
|
+
? [` ${FG_GRAY}●${RST} ${FG_GRAY}${lines[0]}${RST}`].concat(lines.slice(1).map((l) => ` ${l}`))
|
|
364
|
+
: [];
|
|
365
|
+
writer.setModal(modalLines);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function finalizeListMsg() {
|
|
369
|
+
writer.clearModal();
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function activateNavCapture() {
|
|
373
|
+
inputField.captureNavigation(async (action) => {
|
|
374
|
+
if (!ctx.pendingAction) { inputField.releaseNavigation(); return; }
|
|
375
|
+
const { items, displayItems: di, stepIdx } = ctx.pendingAction;
|
|
376
|
+
const activeItems = di || items;
|
|
377
|
+
|
|
378
|
+
if (action.startsWith('search:')) {
|
|
379
|
+
const query = action.slice(7);
|
|
380
|
+
if (!query) {
|
|
381
|
+
ctx.pendingAction = { ...ctx.pendingAction, displayItems: null, searchQuery: '', stepIdx: 0 };
|
|
382
|
+
} else {
|
|
383
|
+
const filtered = items.filter(item => getNavSearchText(ctx.pendingAction.type, item).toLowerCase().includes(query.toLowerCase()));
|
|
384
|
+
ctx.pendingAction = { ...ctx.pendingAction, displayItems: filtered, searchQuery: query, stepIdx: 0 };
|
|
385
|
+
}
|
|
386
|
+
showPendingStep();
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (action === 'next') {
|
|
391
|
+
ctx.pendingAction = { ...ctx.pendingAction, stepIdx: activeItems.length ? (stepIdx + 1) % activeItems.length : 0 };
|
|
392
|
+
showPendingStep();
|
|
393
|
+
} else if (action === 'prev') {
|
|
394
|
+
ctx.pendingAction = { ...ctx.pendingAction, stepIdx: activeItems.length ? (stepIdx - 1 + activeItems.length) % activeItems.length : 0 };
|
|
395
|
+
showPendingStep();
|
|
396
|
+
} else if (action === 'select') {
|
|
397
|
+
if (!activeItems.length) return;
|
|
398
|
+
inputField.releaseNavigation();
|
|
399
|
+
const si = ctx.pendingAction.stepIdx;
|
|
400
|
+
collapseListMsg(ctx.pendingAction.type, activeItems[si]);
|
|
401
|
+
statusBar.update('idle');
|
|
402
|
+
await handlePendingSelection(si);
|
|
403
|
+
inputField.setDisabled(false);
|
|
404
|
+
} else if (action === 'cancel') {
|
|
405
|
+
inputField.releaseNavigation();
|
|
406
|
+
finalizeListMsg();
|
|
407
|
+
chatHistory.addMessage({ role: 'system', content: 'Cancelled.' });
|
|
408
|
+
ctx.pendingAction = null;
|
|
409
|
+
statusBar.update('idle');
|
|
410
|
+
inputField.setDisabled(false);
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function handlePendingSelection(idx) {
|
|
416
|
+
if (!ctx.pendingAction) return;
|
|
417
|
+
const { type, items, displayItems: di } = ctx.pendingAction;
|
|
418
|
+
const activeItems = di || items;
|
|
419
|
+
ctx.pendingAction = null;
|
|
420
|
+
|
|
421
|
+
if (type === 'history') {
|
|
422
|
+
const loaded = storage.load(activeItems[idx].id);
|
|
423
|
+
if (loaded) {
|
|
424
|
+
const filtered = (loaded.messages || []).filter((m) => m.role !== 'system');
|
|
425
|
+
const cleanup = cleanOrphanedToolMessages(filtered);
|
|
426
|
+
ctx.messages = cleanup.messages;
|
|
427
|
+
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 } };
|
|
428
|
+
ctx.currentChatId = null; ctx.savedUpTo = 0;
|
|
429
|
+
if (loaded.model && loaded.model !== ctx.currentModel) {
|
|
430
|
+
ctx.currentModel = loaded.model;
|
|
431
|
+
ctx.resolvedTokenLimit = await resolveTokenLimit(ctx.currentModel);
|
|
432
|
+
statusBar.setModel(ctx.currentModel);
|
|
433
|
+
statusBar.setContextLimit(ctx.resolvedTokenLimit);
|
|
434
|
+
}
|
|
435
|
+
displayLoadedMessages(ctx.messages);
|
|
436
|
+
chatHistory.addMessage({ role: 'system', content: `✓ Session loaded. Model → ${ctx.currentModel}` });
|
|
437
|
+
emitCleanupWarning(cleanup);
|
|
438
|
+
seedContextFromMessages();
|
|
439
|
+
}
|
|
440
|
+
} else if (type === 'chats') {
|
|
441
|
+
const selectedChat = activeItems[idx];
|
|
442
|
+
try {
|
|
443
|
+
const chatData = await dashboardGetChat(selectedChat.id);
|
|
444
|
+
const loaded = chatData && chatData.messages ? chatData.messages : [];
|
|
445
|
+
const reconstructed = loaded.map(reconstructLoadedMessage);
|
|
446
|
+
const cleanup = cleanOrphanedToolMessages(reconstructed);
|
|
447
|
+
ctx.messages = cleanup.messages;
|
|
448
|
+
ctx.currentChatId = selectedChat.id; ctx.savedUpTo = ctx.messages.length;
|
|
449
|
+
displayLoadedMessages(loaded);
|
|
450
|
+
chatHistory.addMessage({ role: 'system', content: `✓ Resumed: ${selectedChat.title} (${loaded.length} messages)` });
|
|
451
|
+
emitCleanupWarning(cleanup);
|
|
452
|
+
seedContextFromMessages();
|
|
453
|
+
} catch (err) {
|
|
454
|
+
chatHistory.addMessage({ role: 'system', content: `✗ ${err.message}`, isError: true });
|
|
455
|
+
}
|
|
456
|
+
} else if (type === 'models') {
|
|
457
|
+
const selectedModel = activeItems[idx];
|
|
458
|
+
try {
|
|
459
|
+
const credResp = await dashboardGetModelForCli(selectedModel.id);
|
|
460
|
+
const model = credResp && credResp.model ? credResp.model : null;
|
|
461
|
+
if (!model) { chatHistory.addMessage({ role: 'system', content: '✗ Unable to load model.', isError: true }); return; }
|
|
462
|
+
const contextLength = (Number.isInteger(model.context_length) && model.context_length > 0 ? model.context_length : null)
|
|
463
|
+
|| (Number.isInteger(model.max_tokens) && model.max_tokens > 0 ? model.max_tokens : null);
|
|
464
|
+
const config = getConfig();
|
|
465
|
+
const updated = { ...config, api_base: model.base_url, api_key: model.api_key, default_model: model.model_id, dashboard_model_id: model.id };
|
|
466
|
+
if (contextLength !== null) updated.context_length = contextLength;
|
|
467
|
+
setConfig(updated);
|
|
468
|
+
ctx.currentModel = model.model_id;
|
|
469
|
+
ctx.resolvedTokenLimit = await resolveTokenLimit(ctx.currentModel);
|
|
470
|
+
statusBar.setModel(ctx.currentModel);
|
|
471
|
+
statusBar.setContextLimit(ctx.resolvedTokenLimit);
|
|
472
|
+
ctx.currentChatId = null;
|
|
473
|
+
chatHistory.addMessage({ role: 'system', content: `✓ Model → ${model.name} (${model.model_id})` });
|
|
474
|
+
statusBar.update('idle');
|
|
475
|
+
} catch (err) {
|
|
476
|
+
chatHistory.addMessage({ role: 'system', content: `✗ ${err.message}`, isError: true });
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return {
|
|
482
|
+
refreshInputSearchItems, saveSession, createChatIfNeeded, saveTurnToDashboard,
|
|
483
|
+
displayLoadedMessages, seedContextFromMessages, emitCleanupWarning,
|
|
484
|
+
getNavSearchText, buildItemDetail, buildListContent, collapseListMsg,
|
|
485
|
+
showPendingStep, finalizeListMsg, activateNavCapture, handlePendingSelection,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
module.exports = { createChatSession };
|