jinzd-ai-cli 0.4.52 → 0.4.54

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.
@@ -7,7 +7,7 @@ import {
7
7
  ProviderNotFoundError,
8
8
  RateLimitError,
9
9
  schemaToJsonSchema
10
- } from "./chunk-EAZN6HMU.js";
10
+ } from "./chunk-TAR67QTH.js";
11
11
  import {
12
12
  APP_NAME,
13
13
  CONFIG_DIR_NAME,
@@ -20,7 +20,7 @@ import {
20
20
  MCP_TOOL_PREFIX,
21
21
  PLUGINS_DIR_NAME,
22
22
  VERSION
23
- } from "./chunk-7NX7PDUO.js";
23
+ } from "./chunk-NP5KZVP6.js";
24
24
 
25
25
  // src/config/config-manager.ts
26
26
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
@@ -2537,6 +2537,19 @@ var SessionManager = class {
2537
2537
  this._current = session;
2538
2538
  return session;
2539
2539
  }
2540
+ /**
2541
+ * 直接设置当前会话(用于从内存缓存恢复未保存的会话)。
2542
+ * 与 `loadSession` 不同,此方法不读取磁盘,也不抛出错误。
2543
+ * Web 多 Tab 场景下,SessionHandler 会维护一份未保存会话的内存缓存,
2544
+ * 切换 Tab 时通过此方法将缓存中的会话设为当前会话,避免"Session not found"。
2545
+ */
2546
+ setCurrent(session) {
2547
+ this._current = session;
2548
+ }
2549
+ /** 清除当前会话引用(下次访问将触发 lazy 创建)。 */
2550
+ clearCurrent() {
2551
+ this._current = null;
2552
+ }
2540
2553
  async save() {
2541
2554
  if (!this._current) return;
2542
2555
  mkdirSync2(this.historyDir, { recursive: true });
@@ -6,7 +6,7 @@ import { platform } from "os";
6
6
  import chalk from "chalk";
7
7
 
8
8
  // src/core/constants.ts
9
- var VERSION = "0.4.52";
9
+ var VERSION = "0.4.54";
10
10
  var APP_NAME = "ai-cli";
11
11
  var CONFIG_DIR_NAME = ".aicli";
12
12
  var CONFIG_FILE_NAME = "config.json";
@@ -8,7 +8,7 @@ import { platform } from "os";
8
8
  import chalk from "chalk";
9
9
 
10
10
  // src/core/constants.ts
11
- var VERSION = "0.4.52";
11
+ var VERSION = "0.4.54";
12
12
  var APP_NAME = "ai-cli";
13
13
  var CONFIG_DIR_NAME = ".aicli";
14
14
  var CONFIG_FILE_NAME = "config.json";
@@ -10,7 +10,7 @@ import {
10
10
  SUBAGENT_DEFAULT_MAX_ROUNDS,
11
11
  SUBAGENT_MAX_ROUNDS_LIMIT,
12
12
  runTestsTool
13
- } from "./chunk-7NX7PDUO.js";
13
+ } from "./chunk-NP5KZVP6.js";
14
14
 
15
15
  // src/tools/builtin/bash.ts
16
16
  import { execSync } from "child_process";
@@ -385,7 +385,7 @@ ${content}`);
385
385
  }
386
386
  }
387
387
  async function runTaskMode(config, providers, configManager, topic) {
388
- const { TaskOrchestrator } = await import("./task-orchestrator-YMLVD3Z2.js");
388
+ const { TaskOrchestrator } = await import("./task-orchestrator-TSY7CJE6.js");
389
389
  const orchestrator = new TaskOrchestrator(config, providers, configManager);
390
390
  let interrupted = false;
391
391
  const onSigint = () => {
package/dist/index.js CHANGED
@@ -24,7 +24,7 @@ import {
24
24
  saveDevState,
25
25
  sessionHasMeaningfulContent,
26
26
  setupProxy
27
- } from "./chunk-XROZQELF.js";
27
+ } from "./chunk-6FYFVPVE.js";
28
28
  import {
29
29
  ToolExecutor,
30
30
  ToolRegistry,
@@ -38,7 +38,7 @@ import {
38
38
  spawnAgentContext,
39
39
  theme,
40
40
  undoStack
41
- } from "./chunk-EAZN6HMU.js";
41
+ } from "./chunk-TAR67QTH.js";
42
42
  import {
43
43
  fileCheckpoints
44
44
  } from "./chunk-4BKXL7SM.js";
@@ -63,7 +63,7 @@ import {
63
63
  SKILLS_DIR_NAME,
64
64
  VERSION,
65
65
  buildUserIdentityPrompt
66
- } from "./chunk-7NX7PDUO.js";
66
+ } from "./chunk-NP5KZVP6.js";
67
67
 
68
68
  // src/index.ts
69
69
  import { program } from "commander";
@@ -2106,7 +2106,7 @@ ${hint}` : "")
2106
2106
  usage: "/test [command|filter]",
2107
2107
  async execute(args, ctx) {
2108
2108
  try {
2109
- const { executeTests } = await import("./run-tests-BPGUDFTV.js");
2109
+ const { executeTests } = await import("./run-tests-P53FNUJY.js");
2110
2110
  const argStr = args.join(" ").trim();
2111
2111
  let testArgs = {};
2112
2112
  if (argStr) {
@@ -5493,7 +5493,7 @@ program.command("web").description("Start Web UI server with browser-based chat
5493
5493
  console.error("Error: Invalid port number. Must be between 1 and 65535.");
5494
5494
  process.exit(1);
5495
5495
  }
5496
- const { startWebServer } = await import("./server-BB7AJLGF.js");
5496
+ const { startWebServer } = await import("./server-BQHIMEBH.js");
5497
5497
  await startWebServer({ port, host: options.host });
5498
5498
  });
5499
5499
  program.command("user [action] [username]").description("Manage Web UI users (list | create <name> | delete <name> | reset-password <name> | migrate <name>)").action(async (action, username) => {
@@ -5726,7 +5726,7 @@ program.command("hub [topic]").description("Start multi-agent hub (discuss / bra
5726
5726
  }),
5727
5727
  config.get("customProviders")
5728
5728
  );
5729
- const { startHub } = await import("./hub-OXCBN2E6.js");
5729
+ const { startHub } = await import("./hub-6V54V4O3.js");
5730
5730
  await startHub(
5731
5731
  {
5732
5732
  topic: topic ?? "",
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  executeTests,
3
3
  runTestsTool
4
- } from "./chunk-SEVTJVMK.js";
4
+ } from "./chunk-FOFQAEU6.js";
5
5
  export {
6
6
  executeTests,
7
7
  runTestsTool
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  executeTests,
4
4
  runTestsTool
5
- } from "./chunk-7NX7PDUO.js";
5
+ } from "./chunk-NP5KZVP6.js";
6
6
  export {
7
7
  executeTests,
8
8
  runTestsTool
@@ -15,7 +15,7 @@ import {
15
15
  hadPreviousWriteToolCalls,
16
16
  loadDevState,
17
17
  setupProxy
18
- } from "./chunk-XROZQELF.js";
18
+ } from "./chunk-6FYFVPVE.js";
19
19
  import {
20
20
  AuthManager
21
21
  } from "./chunk-BYNY5JPB.js";
@@ -34,7 +34,7 @@ import {
34
34
  spawnAgentContext,
35
35
  truncateOutput,
36
36
  undoStack
37
- } from "./chunk-EAZN6HMU.js";
37
+ } from "./chunk-TAR67QTH.js";
38
38
  import "./chunk-4BKXL7SM.js";
39
39
  import {
40
40
  AGENTIC_BEHAVIOR_GUIDELINE,
@@ -54,7 +54,7 @@ import {
54
54
  SKILLS_DIR_NAME,
55
55
  VERSION,
56
56
  buildUserIdentityPrompt
57
- } from "./chunk-7NX7PDUO.js";
57
+ } from "./chunk-NP5KZVP6.js";
58
58
 
59
59
  // src/web/server.ts
60
60
  import express from "express";
@@ -493,6 +493,15 @@ var SessionHandler = class _SessionHandler {
493
493
  activeSystemPrompt;
494
494
  /** Directories added via /add-dir */
495
495
  addedDirs = /* @__PURE__ */ new Set();
496
+ /**
497
+ * 未保存会话的内存缓存(per-handler)。
498
+ * 当客户端通过 `session new` 创建新会话时,会话仅存在于 SessionManager._current 中,
499
+ * 未写入磁盘(saveIfNeeded 跳过空会话)。Web UI 多 Tab 场景下每个 Tab 都会创建自己的
500
+ * 空会话,切换 Tab 时 `session load <id>` 会读取磁盘并报"Session not found"。
501
+ * 此 Map 在本 SessionHandler 生命周期内持有这些未保存会话,load 时优先查询。
502
+ * 会话一旦保存到磁盘(有消息后),会从此 Map 中移除。
503
+ */
504
+ unsavedSessions = /* @__PURE__ */ new Map();
496
505
  constructor(ws, shared) {
497
506
  this.ws = ws;
498
507
  this.config = shared.config;
@@ -610,13 +619,16 @@ var SessionHandler = class _SessionHandler {
610
619
  /** Save session only if it exists and has messages (never persist empty "Untitled" sessions). */
611
620
  saveIfNeeded() {
612
621
  if (this.sessions.current && this.sessions.current.messages.length > 0) {
622
+ const id = this.sessions.current.id;
613
623
  this.sessions.save();
624
+ this.unsavedSessions.delete(id);
614
625
  }
615
626
  }
616
627
  /** Lazily create a session if none exists yet (deferred from constructor). */
617
628
  ensureSession() {
618
629
  if (!this.sessions.current) {
619
- this.sessions.createSession(this.currentProvider, this.currentModel);
630
+ const created = this.sessions.createSession(this.currentProvider, this.currentModel);
631
+ this.unsavedSessions.set(created.id, created);
620
632
  }
621
633
  }
622
634
  // ── Chat handling ────────────────────────────────────────────────
@@ -1069,7 +1081,8 @@ Tokens: in=${this.sessionTokenUsage.inputTokens} out=${this.sessionTokenUsage.ou
1069
1081
  const sub = args[0];
1070
1082
  if (sub === "new") {
1071
1083
  this.saveIfNeeded();
1072
- this.sessions.createSession(this.currentProvider, this.currentModel);
1084
+ const created = this.sessions.createSession(this.currentProvider, this.currentModel);
1085
+ this.unsavedSessions.set(created.id, created);
1073
1086
  this.sessionTokenUsage = { inputTokens: 0, outputTokens: 0 };
1074
1087
  this.send({ type: "info", message: "New session created." });
1075
1088
  this.sendStatus();
@@ -1077,6 +1090,20 @@ Tokens: in=${this.sessionTokenUsage.inputTokens} out=${this.sessionTokenUsage.ou
1077
1090
  } else if (sub === "load" && args[1]) {
1078
1091
  const targetId = args[1];
1079
1092
  this.saveIfNeeded();
1093
+ const cachedExact = this.unsavedSessions.get(targetId);
1094
+ const cached = cachedExact ?? [...this.unsavedSessions.values()].find((s) => s.id.startsWith(targetId));
1095
+ if (cached) {
1096
+ this.sessions.setCurrent(cached);
1097
+ this.sessionTokenUsage = { inputTokens: 0, outputTokens: 0 };
1098
+ this.send({
1099
+ type: "info",
1100
+ message: `Loaded session: ${cached.id.slice(0, 8)} "${cached.title ?? ""}" (${cached.messages.length} messages)`
1101
+ });
1102
+ this.sendSessionMessages();
1103
+ this.sendStatus();
1104
+ this.sendSessionList();
1105
+ break;
1106
+ }
1080
1107
  const list = this.sessions.listSessions();
1081
1108
  const found = list.find((s) => s.id.startsWith(targetId));
1082
1109
  if (found) {
@@ -1087,12 +1114,30 @@ Tokens: in=${this.sessionTokenUsage.inputTokens} out=${this.sessionTokenUsage.ou
1087
1114
  this.sendStatus();
1088
1115
  this.sendSessionList();
1089
1116
  } else {
1090
- this.send({ type: "error", message: `Session not found: ${targetId}` });
1117
+ const recreated = this.sessions.createSession(this.currentProvider, this.currentModel);
1118
+ this.unsavedSessions.set(recreated.id, recreated);
1119
+ this.sessionTokenUsage = { inputTokens: 0, outputTokens: 0 };
1120
+ this.send({
1121
+ type: "info",
1122
+ message: `Previous session (${targetId.slice(0, 8)}) is no longer available \u2014 started a new one.`
1123
+ });
1124
+ this.sendStatus();
1125
+ this.sendSessionList();
1091
1126
  }
1092
1127
  } else if (sub === "list") {
1093
1128
  this.sendSessionList();
1094
1129
  } else if (sub === "delete" && args[1]) {
1095
1130
  const targetId = args[1];
1131
+ const cachedKey = this.unsavedSessions.has(targetId) ? targetId : [...this.unsavedSessions.keys()].find((k) => k.startsWith(targetId));
1132
+ if (cachedKey) {
1133
+ this.unsavedSessions.delete(cachedKey);
1134
+ if (this.sessions.current?.id === cachedKey) {
1135
+ this.sessions.clearCurrent();
1136
+ }
1137
+ this.send({ type: "info", message: `Deleted session: ${cachedKey.slice(0, 8)}` });
1138
+ this.sendSessionList();
1139
+ break;
1140
+ }
1096
1141
  const list = this.sessions.listSessions();
1097
1142
  const found = list.find((s) => s.id.startsWith(targetId));
1098
1143
  if (found) {
@@ -1111,6 +1156,12 @@ Tokens: in=${this.sessionTokenUsage.inputTokens} out=${this.sessionTokenUsage.ou
1111
1156
  const list = this.sessions.listSessions();
1112
1157
  let deleted = 0;
1113
1158
  for (const targetId of ids) {
1159
+ const cachedKey = this.unsavedSessions.has(targetId) ? targetId : [...this.unsavedSessions.keys()].find((k) => k.startsWith(targetId));
1160
+ if (cachedKey) {
1161
+ this.unsavedSessions.delete(cachedKey);
1162
+ deleted++;
1163
+ continue;
1164
+ }
1114
1165
  const found = list.find((s) => s.id.startsWith(targetId));
1115
1166
  if (found) {
1116
1167
  this.sessions.deleteSession(found.id);
@@ -1126,6 +1177,14 @@ Tokens: in=${this.sessionTokenUsage.inputTokens} out=${this.sessionTokenUsage.ou
1126
1177
  this.send({ type: "error", message: "Title cannot be empty." });
1127
1178
  break;
1128
1179
  }
1180
+ const cachedKey = this.unsavedSessions.has(targetId) ? targetId : [...this.unsavedSessions.keys()].find((k) => k.startsWith(targetId));
1181
+ if (cachedKey) {
1182
+ const session = this.unsavedSessions.get(cachedKey);
1183
+ session.title = newTitle;
1184
+ this.send({ type: "info", message: `Renamed session: "${newTitle}"` });
1185
+ this.sendSessionList();
1186
+ break;
1187
+ }
1129
1188
  const list = this.sessions.listSessions();
1130
1189
  const found = list.find((s) => s.id.startsWith(targetId));
1131
1190
  if (found) {
@@ -1632,7 +1691,7 @@ ${undoResults.map((r) => ` \u2022 ${r}`).join("\n")}` });
1632
1691
  case "test": {
1633
1692
  this.send({ type: "info", message: "\u{1F9EA} Running tests..." });
1634
1693
  try {
1635
- const { executeTests } = await import("./run-tests-BPGUDFTV.js");
1694
+ const { executeTests } = await import("./run-tests-P53FNUJY.js");
1636
1695
  const argStr = args.join(" ").trim();
1637
1696
  let testArgs = {};
1638
1697
  if (argStr) {
@@ -2156,6 +2215,7 @@ Add .md files to create commands.` });
2156
2215
  }));
2157
2216
  this.send({
2158
2217
  type: "session_messages",
2218
+ sessionId: session.id,
2159
2219
  messages
2160
2220
  });
2161
2221
  }
@@ -4,11 +4,11 @@ import {
4
4
  getDangerLevel,
5
5
  googleSearchContext,
6
6
  truncateOutput
7
- } from "./chunk-EAZN6HMU.js";
7
+ } from "./chunk-TAR67QTH.js";
8
8
  import "./chunk-4BKXL7SM.js";
9
9
  import {
10
10
  SUBAGENT_ALLOWED_TOOLS
11
- } from "./chunk-7NX7PDUO.js";
11
+ } from "./chunk-NP5KZVP6.js";
12
12
 
13
13
  // src/hub/task-orchestrator.ts
14
14
  import { createInterface } from "readline";
@@ -182,7 +182,7 @@ function handleServerMessage(msg) {
182
182
  case 'todo_update': handleTodoUpdate(msg.todos); break;
183
183
  case 'status': handleStatus(msg); break;
184
184
  case 'session_list': renderSessionList(msg.sessions); break;
185
- case 'session_messages':renderSessionMessages(msg.messages); break;
185
+ case 'session_messages':renderSessionMessages(msg); break;
186
186
  case 'tools_list': renderToolsList(msg); switchSidebarTab('tools'); break;
187
187
  case 'export_data': handleExportData(msg); break;
188
188
  case 'memory_content': handleMemoryContent(msg); break;
@@ -449,34 +449,68 @@ function handleTodoUpdate(todos) {
449
449
  }
450
450
 
451
451
  function handleStatus(msg) {
452
+ // Global UI (provider list, toolbar toggles) always reflects the latest
453
+ // server state regardless of which Tab the status belongs to — these are
454
+ // handler-scoped, not session-scoped.
452
455
  providers = msg.providers || [];
453
456
  updateProviderSelect(msg.provider);
454
457
  updateModelSelect(msg.provider, msg.model);
455
458
 
456
- btnThink.classList.toggle('btn-active-toggle', msg.thinkingMode);
457
- btnPlan.classList.toggle('btn-active-toggle', msg.planMode);
459
+ // ── Route by sessionId ────────────────────────────────────────────
460
+ // Find the Tab this status payload belongs to. If the active Tab matches,
461
+ // we apply the full UI update. Otherwise we just refresh that Tab's stored
462
+ // metadata (title, token usage) without touching the live DOM — prevents
463
+ // "wrong content flashing on wrong tab" during fast tab switches.
464
+ let targetIdx = sessionTabs.findIndex(t => t.sessionId && t.sessionId === msg.sessionId);
465
+
466
+ // Fallback: if no tab owns this sessionId yet, bind it to the first tab
467
+ // that is still waiting for a `session new` response (FIFO matches the
468
+ // serial order of server command processing).
469
+ if (targetIdx < 0 && msg.sessionId) {
470
+ targetIdx = sessionTabs.findIndex(t => t.pendingBind);
471
+ if (targetIdx >= 0) {
472
+ sessionTabs[targetIdx].sessionId = msg.sessionId;
473
+ sessionTabs[targetIdx].pendingBind = false;
474
+ }
475
+ }
458
476
 
459
- statusSession.textContent = `📋 ${msg.sessionId?.slice(0, 8) || '—'} (${msg.messageCount} msgs)`;
460
- if (msg.tokenUsage) {
461
- statusTokens.textContent = `📊 in: ${msg.tokenUsage.inputTokens} out: ${msg.tokenUsage.outputTokens}`;
477
+ if (targetIdx < 0) {
478
+ // Stale response — the tab that asked for it was closed before the
479
+ // server responded. Drop silently.
480
+ return;
462
481
  }
463
482
 
464
- // Update sidebar active state
465
- sessionListEl.querySelectorAll('.session-item').forEach(el => {
466
- el.classList.toggle('active', el.dataset.sessionId === msg.sessionId);
467
- });
483
+ const targetTab = sessionTabs[targetIdx];
484
+ const isActiveTarget = targetIdx === activeTabIdx;
468
485
 
469
- // Persist active session for this tab (for page reload restore)
470
- if (msg.sessionId) {
471
- sessionStorage.setItem('aicli-active-session', msg.sessionId);
486
+ // Update tab-owned metadata (applies whether active or not)
487
+ if (msg.sessionTitle) targetTab.title = msg.sessionTitle;
488
+ else if (msg.sessionId && (!targetTab.title || targetTab.title === 'New Chat')) {
489
+ targetTab.title = msg.sessionId.slice(0, 8);
472
490
  }
491
+ if (msg.tokenUsage) targetTab.tokenUsage = msg.tokenUsage;
473
492
 
474
- // Update multi-tab state
475
- updateActiveTabFromStatus(msg);
493
+ if (isActiveTarget) {
494
+ // Active tab: full UI reflection
495
+ btnThink.classList.toggle('btn-active-toggle', msg.thinkingMode);
496
+ btnPlan.classList.toggle('btn-active-toggle', msg.planMode);
497
+ statusSession.textContent = `📋 ${msg.sessionId?.slice(0, 8) || '—'} (${msg.messageCount} msgs)`;
498
+ if (msg.tokenUsage) {
499
+ statusTokens.textContent = `📊 in: ${msg.tokenUsage.inputTokens} out: ${msg.tokenUsage.outputTokens}`;
500
+ }
501
+ sessionListEl.querySelectorAll('.session-item').forEach(el => {
502
+ el.classList.toggle('active', el.dataset.sessionId === msg.sessionId);
503
+ });
504
+ if (msg.sessionId) {
505
+ sessionStorage.setItem('aicli-active-session', msg.sessionId);
506
+ }
507
+ targetTab.isProcessing = processing;
508
+ const title = msg.sessionTitle || msg.sessionId?.slice(0, 8) || 'New Session';
509
+ document.title = `ai-cli — ${title}`;
510
+ }
476
511
 
477
- // Update browser tab title to reflect current session
478
- const title = msg.sessionTitle || msg.sessionId?.slice(0, 8) || 'New Session';
479
- document.title = `ai-cli — ${title}`;
512
+ renderTabBar();
513
+ saveTabState();
480
514
  }
481
515
 
482
516
  // ── Response helpers ───────────────────────────────────────────────
@@ -795,6 +829,7 @@ function loadSessionInTab(sessionId, title) {
795
829
  // Load in current active tab
796
830
  if (activeTabIdx >= 0 && activeTabIdx < sessionTabs.length) {
797
831
  sessionTabs[activeTabIdx].sessionId = sessionId;
832
+ sessionTabs[activeTabIdx].pendingBind = false; // explicit load cancels any pending bind
798
833
  if (title) sessionTabs[activeTabIdx].title = title;
799
834
  renderTabBar();
800
835
  saveTabState();
@@ -965,18 +1000,97 @@ function updateBatchBar() {
965
1000
  }
966
1001
  }
967
1002
 
968
- function renderSessionMessages(messages) {
969
- // Clear chat and re-render all messages from loaded session
970
- messagesEl.innerHTML = '';
971
- for (const msg of messages) {
972
- if (msg.role === 'user') {
973
- addUserMessage(msg.content);
974
- } else if (msg.role === 'assistant') {
975
- const el = createAssistantMessage();
976
- renderMarkdown(el, msg.content);
1003
+ /**
1004
+ * Render the `session_messages` payload for a session.
1005
+ * Routes to the correct UI tab based on payload.sessionId so that fast
1006
+ * tab-switching does not cause content to be applied to the wrong tab.
1007
+ *
1008
+ * msg = { type: 'session_messages', sessionId, messages: [...] }
1009
+ *
1010
+ * If the matched tab is active → render directly to the live DOM (and
1011
+ * snapshot into the tab cache). Otherwise build the HTML off-DOM and
1012
+ * write it into the tab's `messagesHtml` cache; the live DOM is left
1013
+ * untouched so the active tab's content never flashes wrong data.
1014
+ */
1015
+ function renderSessionMessages(msg) {
1016
+ // Back-compat: if called with a bare array (legacy), treat as active-tab apply
1017
+ const messages = Array.isArray(msg) ? msg : msg.messages;
1018
+ const sessionId = Array.isArray(msg) ? null : msg.sessionId;
1019
+
1020
+ // Locate the target tab
1021
+ let targetIdx = -1;
1022
+ if (sessionId) {
1023
+ targetIdx = sessionTabs.findIndex(t => t.sessionId === sessionId);
1024
+ }
1025
+ if (targetIdx < 0) {
1026
+ // No explicit match — fall back to active tab (legacy behavior)
1027
+ targetIdx = activeTabIdx;
1028
+ }
1029
+ if (targetIdx < 0 || targetIdx >= sessionTabs.length) return;
1030
+
1031
+ const isActiveTarget = targetIdx === activeTabIdx;
1032
+
1033
+ if (isActiveTarget) {
1034
+ // Active tab: paint directly into the live DOM (preserves any in-flight
1035
+ // streaming helpers that rely on messagesEl)
1036
+ messagesEl.innerHTML = '';
1037
+ for (const m of messages) {
1038
+ if (m.role === 'user') {
1039
+ addUserMessage(m.content);
1040
+ } else if (m.role === 'assistant') {
1041
+ const el = createAssistantMessage();
1042
+ renderMarkdown(el, m.content);
1043
+ }
977
1044
  }
1045
+ scrollToBottom();
1046
+ // Snapshot into cache so subsequent tab-snapshots see the latest content
1047
+ sessionTabs[targetIdx].messagesHtml = messagesEl.innerHTML;
1048
+ } else {
1049
+ // Inactive tab: build HTML off-DOM, store in tab cache, do NOT touch
1050
+ // the live DOM. When the user eventually switches to this tab,
1051
+ // restoreTab() will paint the cached HTML.
1052
+ sessionTabs[targetIdx].messagesHtml = buildMessagesHtmlOffDom(messages);
1053
+ }
1054
+ }
1055
+
1056
+ /**
1057
+ * Build the HTML string for a list of messages without affecting the
1058
+ * currently visible DOM. Uses a save-clear-render-capture-restore cycle
1059
+ * on the existing messagesEl so we can re-use addUserMessage /
1060
+ * createAssistantMessage (both of which reference messagesEl directly).
1061
+ * The round-trip is synchronous so the user never sees it.
1062
+ */
1063
+ function buildMessagesHtmlOffDom(messages) {
1064
+ const savedHtml = messagesEl.innerHTML;
1065
+ const savedScroll = chatArea.scrollTop;
1066
+ // Save streaming pointers so we don't orphan them
1067
+ const savedAssistantEl = currentAssistantEl;
1068
+ const savedAssistantContent = currentAssistantContent;
1069
+ const savedThinkingEl = currentThinkingEl;
1070
+ const savedThinkingContent = currentThinkingContent;
1071
+ try {
1072
+ messagesEl.innerHTML = '';
1073
+ currentAssistantEl = null;
1074
+ currentAssistantContent = '';
1075
+ currentThinkingEl = null;
1076
+ currentThinkingContent = '';
1077
+ for (const m of messages) {
1078
+ if (m.role === 'user') {
1079
+ addUserMessage(m.content);
1080
+ } else if (m.role === 'assistant') {
1081
+ const el = createAssistantMessage();
1082
+ renderMarkdown(el, m.content);
1083
+ }
1084
+ }
1085
+ return messagesEl.innerHTML;
1086
+ } finally {
1087
+ messagesEl.innerHTML = savedHtml;
1088
+ chatArea.scrollTop = savedScroll;
1089
+ currentAssistantEl = savedAssistantEl;
1090
+ currentAssistantContent = savedAssistantContent;
1091
+ currentThinkingEl = savedThinkingEl;
1092
+ currentThinkingContent = savedThinkingContent;
978
1093
  }
979
- scrollToBottom();
980
1094
  }
981
1095
 
982
1096
  // New session button — opens in a new tab
@@ -1867,6 +1981,10 @@ function addTab(sessionId, title) {
1867
1981
  scrollPos: 0,
1868
1982
  tokenUsage: { inputTokens: 0, outputTokens: 0 },
1869
1983
  isProcessing: false,
1984
+ // pendingBind: this tab is waiting for the server to assign a sessionId.
1985
+ // Set when `session new` is dispatched; cleared once `status` arrives with
1986
+ // the assigned id. Prevents duplicate `session new` requests on fast re-switch.
1987
+ pendingBind: !sessionId,
1870
1988
  _currentAssistantContent: '',
1871
1989
  };
1872
1990
  sessionTabs.push(tab);
@@ -1908,7 +2026,11 @@ function switchToTab(index) {
1908
2026
  // Tell server to switch session
1909
2027
  if (tab.sessionId) {
1910
2028
  send({ type: 'command', name: 'session', args: ['load', tab.sessionId] });
1911
- } else {
2029
+ } else if (!tab.pendingBind) {
2030
+ // No id yet AND no outstanding `session new` in flight — request one.
2031
+ // If pendingBind is true, the original `session new` response is still
2032
+ // inbound; don't send another request or we'd create a dangling session.
2033
+ tab.pendingBind = true;
1912
2034
  send({ type: 'command', name: 'session', args: ['new'] });
1913
2035
  }
1914
2036
 
@@ -1942,19 +2064,6 @@ function findTabBySessionId(sessionId) {
1942
2064
  return sessionTabs.findIndex(t => t.sessionId === sessionId);
1943
2065
  }
1944
2066
 
1945
- /** Update the active tab's metadata from a status message */
1946
- function updateActiveTabFromStatus(msg) {
1947
- if (activeTabIdx < 0 || activeTabIdx >= sessionTabs.length) return;
1948
- const tab = sessionTabs[activeTabIdx];
1949
- if (msg.sessionId) tab.sessionId = msg.sessionId;
1950
- if (msg.sessionTitle) tab.title = msg.sessionTitle;
1951
- else if (msg.sessionId && !tab.title) tab.title = msg.sessionId.slice(0, 8);
1952
- tab.isProcessing = processing;
1953
- if (msg.tokenUsage) tab.tokenUsage = msg.tokenUsage;
1954
- renderTabBar();
1955
- saveTabState();
1956
- }
1957
-
1958
2067
  /** Persist tab state to sessionStorage for page reload */
1959
2068
  function saveTabState() {
1960
2069
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jinzd-ai-cli",
3
- "version": "0.4.52",
3
+ "version": "0.4.54",
4
4
  "description": "Cross-platform REPL-style AI CLI with multi-provider support",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",