jinzd-ai-cli 0.4.53 → 0.4.55

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.
@@ -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,77 @@ 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);
458
-
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}`;
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
+ }
462
475
  }
463
476
 
464
- // Update sidebar active state
465
- sessionListEl.querySelectorAll('.session-item').forEach(el => {
466
- el.classList.toggle('active', el.dataset.sessionId === msg.sessionId);
467
- });
468
-
469
- // Persist active session for this tab (for page reload restore)
470
- if (msg.sessionId) {
471
- sessionStorage.setItem('aicli-active-session', msg.sessionId);
477
+ if (targetIdx < 0) {
478
+ // Stale response — the tab that asked for it was closed before the
479
+ // server responded. Drop silently.
480
+ return;
472
481
  }
473
482
 
474
- // Update multi-tab state
475
- updateActiveTabFromStatus(msg);
483
+ const targetTab = sessionTabs[targetIdx];
484
+ const isActiveTarget = targetIdx === activeTabIdx;
485
+
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);
490
+ }
491
+ if (msg.tokenUsage) targetTab.tokenUsage = msg.tokenUsage;
492
+
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
+ const u = msg.tokenUsage;
500
+ const cacheRead = u.cacheReadTokens || 0;
501
+ let line = `📊 in: ${u.inputTokens.toLocaleString()} out: ${u.outputTokens.toLocaleString()}`;
502
+ if (cacheRead > 0) line += ` cache: ${cacheRead.toLocaleString()}`;
503
+ if (msg.costUsd != null) {
504
+ const cost = msg.costUsd;
505
+ const costStr = cost === 0 ? '$0' : cost < 0.01 ? `$${cost.toFixed(4)}` : cost < 1 ? `$${cost.toFixed(3)}` : `$${cost.toFixed(2)}`;
506
+ line += ` 💰 ${costStr}`;
507
+ }
508
+ statusTokens.textContent = line;
509
+ }
510
+ sessionListEl.querySelectorAll('.session-item').forEach(el => {
511
+ el.classList.toggle('active', el.dataset.sessionId === msg.sessionId);
512
+ });
513
+ if (msg.sessionId) {
514
+ sessionStorage.setItem('aicli-active-session', msg.sessionId);
515
+ }
516
+ targetTab.isProcessing = processing;
517
+ const title = msg.sessionTitle || msg.sessionId?.slice(0, 8) || 'New Session';
518
+ document.title = `ai-cli — ${title}`;
519
+ }
476
520
 
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}`;
521
+ renderTabBar();
522
+ saveTabState();
480
523
  }
481
524
 
482
525
  // ── Response helpers ───────────────────────────────────────────────
@@ -795,6 +838,7 @@ function loadSessionInTab(sessionId, title) {
795
838
  // Load in current active tab
796
839
  if (activeTabIdx >= 0 && activeTabIdx < sessionTabs.length) {
797
840
  sessionTabs[activeTabIdx].sessionId = sessionId;
841
+ sessionTabs[activeTabIdx].pendingBind = false; // explicit load cancels any pending bind
798
842
  if (title) sessionTabs[activeTabIdx].title = title;
799
843
  renderTabBar();
800
844
  saveTabState();
@@ -965,18 +1009,97 @@ function updateBatchBar() {
965
1009
  }
966
1010
  }
967
1011
 
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);
1012
+ /**
1013
+ * Render the `session_messages` payload for a session.
1014
+ * Routes to the correct UI tab based on payload.sessionId so that fast
1015
+ * tab-switching does not cause content to be applied to the wrong tab.
1016
+ *
1017
+ * msg = { type: 'session_messages', sessionId, messages: [...] }
1018
+ *
1019
+ * If the matched tab is active → render directly to the live DOM (and
1020
+ * snapshot into the tab cache). Otherwise build the HTML off-DOM and
1021
+ * write it into the tab's `messagesHtml` cache; the live DOM is left
1022
+ * untouched so the active tab's content never flashes wrong data.
1023
+ */
1024
+ function renderSessionMessages(msg) {
1025
+ // Back-compat: if called with a bare array (legacy), treat as active-tab apply
1026
+ const messages = Array.isArray(msg) ? msg : msg.messages;
1027
+ const sessionId = Array.isArray(msg) ? null : msg.sessionId;
1028
+
1029
+ // Locate the target tab
1030
+ let targetIdx = -1;
1031
+ if (sessionId) {
1032
+ targetIdx = sessionTabs.findIndex(t => t.sessionId === sessionId);
1033
+ }
1034
+ if (targetIdx < 0) {
1035
+ // No explicit match — fall back to active tab (legacy behavior)
1036
+ targetIdx = activeTabIdx;
1037
+ }
1038
+ if (targetIdx < 0 || targetIdx >= sessionTabs.length) return;
1039
+
1040
+ const isActiveTarget = targetIdx === activeTabIdx;
1041
+
1042
+ if (isActiveTarget) {
1043
+ // Active tab: paint directly into the live DOM (preserves any in-flight
1044
+ // streaming helpers that rely on messagesEl)
1045
+ messagesEl.innerHTML = '';
1046
+ for (const m of messages) {
1047
+ if (m.role === 'user') {
1048
+ addUserMessage(m.content);
1049
+ } else if (m.role === 'assistant') {
1050
+ const el = createAssistantMessage();
1051
+ renderMarkdown(el, m.content);
1052
+ }
977
1053
  }
1054
+ scrollToBottom();
1055
+ // Snapshot into cache so subsequent tab-snapshots see the latest content
1056
+ sessionTabs[targetIdx].messagesHtml = messagesEl.innerHTML;
1057
+ } else {
1058
+ // Inactive tab: build HTML off-DOM, store in tab cache, do NOT touch
1059
+ // the live DOM. When the user eventually switches to this tab,
1060
+ // restoreTab() will paint the cached HTML.
1061
+ sessionTabs[targetIdx].messagesHtml = buildMessagesHtmlOffDom(messages);
1062
+ }
1063
+ }
1064
+
1065
+ /**
1066
+ * Build the HTML string for a list of messages without affecting the
1067
+ * currently visible DOM. Uses a save-clear-render-capture-restore cycle
1068
+ * on the existing messagesEl so we can re-use addUserMessage /
1069
+ * createAssistantMessage (both of which reference messagesEl directly).
1070
+ * The round-trip is synchronous so the user never sees it.
1071
+ */
1072
+ function buildMessagesHtmlOffDom(messages) {
1073
+ const savedHtml = messagesEl.innerHTML;
1074
+ const savedScroll = chatArea.scrollTop;
1075
+ // Save streaming pointers so we don't orphan them
1076
+ const savedAssistantEl = currentAssistantEl;
1077
+ const savedAssistantContent = currentAssistantContent;
1078
+ const savedThinkingEl = currentThinkingEl;
1079
+ const savedThinkingContent = currentThinkingContent;
1080
+ try {
1081
+ messagesEl.innerHTML = '';
1082
+ currentAssistantEl = null;
1083
+ currentAssistantContent = '';
1084
+ currentThinkingEl = null;
1085
+ currentThinkingContent = '';
1086
+ for (const m of messages) {
1087
+ if (m.role === 'user') {
1088
+ addUserMessage(m.content);
1089
+ } else if (m.role === 'assistant') {
1090
+ const el = createAssistantMessage();
1091
+ renderMarkdown(el, m.content);
1092
+ }
1093
+ }
1094
+ return messagesEl.innerHTML;
1095
+ } finally {
1096
+ messagesEl.innerHTML = savedHtml;
1097
+ chatArea.scrollTop = savedScroll;
1098
+ currentAssistantEl = savedAssistantEl;
1099
+ currentAssistantContent = savedAssistantContent;
1100
+ currentThinkingEl = savedThinkingEl;
1101
+ currentThinkingContent = savedThinkingContent;
978
1102
  }
979
- scrollToBottom();
980
1103
  }
981
1104
 
982
1105
  // New session button — opens in a new tab
@@ -1867,6 +1990,10 @@ function addTab(sessionId, title) {
1867
1990
  scrollPos: 0,
1868
1991
  tokenUsage: { inputTokens: 0, outputTokens: 0 },
1869
1992
  isProcessing: false,
1993
+ // pendingBind: this tab is waiting for the server to assign a sessionId.
1994
+ // Set when `session new` is dispatched; cleared once `status` arrives with
1995
+ // the assigned id. Prevents duplicate `session new` requests on fast re-switch.
1996
+ pendingBind: !sessionId,
1870
1997
  _currentAssistantContent: '',
1871
1998
  };
1872
1999
  sessionTabs.push(tab);
@@ -1908,7 +2035,11 @@ function switchToTab(index) {
1908
2035
  // Tell server to switch session
1909
2036
  if (tab.sessionId) {
1910
2037
  send({ type: 'command', name: 'session', args: ['load', tab.sessionId] });
1911
- } else {
2038
+ } else if (!tab.pendingBind) {
2039
+ // No id yet AND no outstanding `session new` in flight — request one.
2040
+ // If pendingBind is true, the original `session new` response is still
2041
+ // inbound; don't send another request or we'd create a dangling session.
2042
+ tab.pendingBind = true;
1912
2043
  send({ type: 'command', name: 'session', args: ['new'] });
1913
2044
  }
1914
2045
 
@@ -1942,19 +2073,6 @@ function findTabBySessionId(sessionId) {
1942
2073
  return sessionTabs.findIndex(t => t.sessionId === sessionId);
1943
2074
  }
1944
2075
 
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
2076
  /** Persist tab state to sessionStorage for page reload */
1959
2077
  function saveTabState() {
1960
2078
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jinzd-ai-cli",
3
- "version": "0.4.53",
3
+ "version": "0.4.55",
4
4
  "description": "Cross-platform REPL-style AI CLI with multi-provider support",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",