@co0ontty/wand 1.6.1 → 1.7.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.
@@ -58,6 +58,8 @@
58
58
 
59
59
  (function() {
60
60
  var configPath = "${escapeHtml(configPath)}";
61
+ var CHAT_EXPAND_STATE_STORAGE_KEY = "wand-chat-expand-state-v1";
62
+ var CHAT_AUTO_FOLLOW_STORAGE_KEY = "wand-chat-auto-follow";
61
63
 
62
64
  var state = {
63
65
  selectedId: (function() {
@@ -75,6 +77,7 @@
75
77
  _lastDomHtml: "",
76
78
  terminalSessionId: null,
77
79
  terminalOutput: "",
80
+ terminalLiveStreamSessions: {},
78
81
  terminalViewportSize: { width: 0, height: 0 },
79
82
  terminalAutoFollow: true,
80
83
  terminalScrollIdleTimer: null,
@@ -90,12 +93,21 @@
90
93
  inputQueue: Promise.resolve(),
91
94
  pendingMessages: [], // WebSocket 断线期间的消息队列
92
95
  messageQueue: [], // 用户消息排队等待发送
93
- crossSessionQueue: [], // 跨会话排队消息 [{ id, text, cwd, mode, tool }]
96
+ crossSessionQueue: (function() {
97
+ try {
98
+ var saved = localStorage.getItem("wand-cross-session-queue");
99
+ var parsed = saved ? JSON.parse(saved) : [];
100
+ return Array.isArray(parsed) ? parsed : [];
101
+ } catch (e) {
102
+ return [];
103
+ }
104
+ })(), // 跨会话排队消息 [{ id, text, cwd, mode, tool }]
94
105
  structuredInputQueue: [], // 结构化会话同会话排队消息
95
106
  drafts: {},
96
107
  isSyncingInputBox: false,
97
108
  loginPending: false,
98
109
  loginChecked: false,
110
+ bootstrapping: true,
99
111
  sessionsDrawerOpen: false,
100
112
  modalOpen: false,
101
113
  presetValue: "",
@@ -103,6 +115,7 @@
103
115
  modeValue: "managed",
104
116
  chatMode: "managed",
105
117
  sessionCreateKind: "structured",
118
+ sessionCreateWorktree: false,
106
119
  sessionTool: "claude",
107
120
  preferredCommand: "claude",
108
121
  structuredRunner: "claude-cli-print",
@@ -130,6 +143,21 @@
130
143
  return false;
131
144
  }
132
145
  })(),
146
+ chatAutoFollow: (function() {
147
+ try {
148
+ var saved = localStorage.getItem(CHAT_AUTO_FOLLOW_STORAGE_KEY);
149
+ return saved === null ? true : saved === "true";
150
+ } catch (e) {
151
+ return true;
152
+ }
153
+ })(),
154
+ showChatJumpToBottom: false,
155
+ chatScrollThreshold: 200,
156
+ chatIsProgrammaticScroll: false,
157
+ chatScrollElement: null,
158
+ chatScrollHandler: null,
159
+ lastForegroundSyncAt: 0,
160
+ foregroundSyncTimer: null,
133
161
  currentMessages: [],
134
162
  lastRenderedHash: 0,
135
163
  lastRenderedMsgCount: 0,
@@ -244,6 +272,120 @@
244
272
  }
245
273
  }
246
274
 
275
+ function persistChatAutoFollow() {
276
+ try {
277
+ localStorage.setItem(CHAT_AUTO_FOLLOW_STORAGE_KEY, state.chatAutoFollow ? "true" : "false");
278
+ } catch (e) {
279
+ // Ignore localStorage errors
280
+ }
281
+ }
282
+
283
+ function getChatScrollElement() {
284
+ var chatOutput = document.getElementById("chat-output");
285
+ if (!chatOutput) {
286
+ state.chatScrollElement = null;
287
+ return null;
288
+ }
289
+ var chatMessages = chatOutput.querySelector(".chat-messages");
290
+ if (chatMessages) {
291
+ state.chatScrollElement = chatMessages;
292
+ return chatMessages;
293
+ }
294
+ state.chatScrollElement = null;
295
+ return null;
296
+ }
297
+
298
+ function isChatNearBottom(chatMsgs) {
299
+ var el = chatMsgs || getChatScrollElement();
300
+ if (!el) return true;
301
+ return el.scrollTop < state.chatScrollThreshold;
302
+ }
303
+
304
+ function updateChatFollowToggleButton() {
305
+ var button = document.getElementById("chat-follow-toggle");
306
+ if (!button) return;
307
+ var enabled = !!state.chatAutoFollow;
308
+ button.classList.toggle("active", enabled);
309
+ button.setAttribute("aria-pressed", enabled ? "true" : "false");
310
+ button.setAttribute("title", enabled ? "追踪底部:开启" : "追踪底部:已暂停");
311
+ button.textContent = enabled ? "追底" : "暂停";
312
+ }
313
+
314
+ function updateChatJumpToBottomButton() {
315
+ var button = document.getElementById("chat-jump-bottom");
316
+ var selectedSession = state.sessions.find(function(s) { return s.id === state.selectedId; });
317
+ var shouldShow = !!selectedSession
318
+ && state.currentView === "chat"
319
+ && !state.chatAutoFollow
320
+ && !isChatNearBottom();
321
+ state.showChatJumpToBottom = shouldShow;
322
+ if (button) {
323
+ button.classList.toggle("visible", shouldShow);
324
+ }
325
+ }
326
+
327
+ function scrollChatToBottom(smooth) {
328
+ var chatMsgs = getChatScrollElement();
329
+ if (!chatMsgs || !chatMsgs.isConnected) return;
330
+ state.chatIsProgrammaticScroll = true;
331
+ if (smooth && typeof chatMsgs.scrollTo === "function") {
332
+ chatMsgs.scrollTo({ top: 0, behavior: "smooth" });
333
+ setTimeout(function() {
334
+ state.chatIsProgrammaticScroll = false;
335
+ updateChatJumpToBottomButton();
336
+ }, 220);
337
+ return;
338
+ }
339
+ chatMsgs.scrollTop = 0;
340
+ requestAnimationFrame(function() {
341
+ state.chatIsProgrammaticScroll = false;
342
+ updateChatJumpToBottomButton();
343
+ });
344
+ }
345
+
346
+ function setChatAutoFollow(enabled, options) {
347
+ options = options || {};
348
+ state.chatAutoFollow = !!enabled;
349
+ persistChatAutoFollow();
350
+ updateChatFollowToggleButton();
351
+ if (state.chatAutoFollow && options.scrollNow !== false) {
352
+ scrollChatToBottom(!!options.smooth);
353
+ } else {
354
+ updateChatJumpToBottomButton();
355
+ }
356
+ }
357
+
358
+ function bindChatScrollListener() {
359
+ var chatMsgs = getChatScrollElement();
360
+ if (!chatMsgs || !chatMsgs.isConnected) return;
361
+ if (state.chatScrollElement === chatMsgs && state.chatScrollHandler) {
362
+ updateChatJumpToBottomButton();
363
+ return;
364
+ }
365
+ if (state.chatScrollElement && state.chatScrollHandler) {
366
+ state.chatScrollElement.removeEventListener("scroll", state.chatScrollHandler);
367
+ }
368
+ state.chatScrollElement = chatMsgs;
369
+ state.chatScrollHandler = function() {
370
+ if (!chatMsgs.isConnected) return;
371
+ if (state.chatIsProgrammaticScroll) {
372
+ updateChatJumpToBottomButton();
373
+ return;
374
+ }
375
+ if (!isChatNearBottom(chatMsgs)) {
376
+ if (state.chatAutoFollow) {
377
+ setChatAutoFollow(false, { scrollNow: false });
378
+ } else {
379
+ updateChatJumpToBottomButton();
380
+ }
381
+ return;
382
+ }
383
+ updateChatJumpToBottomButton();
384
+ };
385
+ chatMsgs.addEventListener("scroll", state.chatScrollHandler, { passive: true });
386
+ updateChatJumpToBottomButton();
387
+ }
388
+
247
389
  // Helper function to persist selected session ID to localStorage
248
390
  function persistSelectedId() {
249
391
  try {
@@ -257,14 +399,302 @@
257
399
  }
258
400
  }
259
401
 
402
+ function getStructuredQueuedInputs(session) {
403
+ if (session && Array.isArray(session.queuedMessages)) {
404
+ return session.queuedMessages;
405
+ }
406
+ return state.structuredInputQueue;
407
+ }
408
+
409
+ function getSelectedStructuredQueuedInputs() {
410
+ var session = state.sessions.find(function(s) { return s.id === state.selectedId; });
411
+ return getStructuredQueuedInputs(session);
412
+ }
413
+
414
+ function syncStructuredQueueFromSession(session) {
415
+ var queued = getStructuredQueuedInputs(session);
416
+ state.structuredInputQueue = Array.isArray(queued) ? queued.slice() : [];
417
+ }
418
+
419
+ function hasRenderOnlyStructuredBlock(message, marker) {
420
+ return !!(message && Array.isArray(message.content) && message.content.some(function(block) {
421
+ return block && typeof block === "object" && block[marker];
422
+ }));
423
+ }
424
+
425
+ function isQueuedStructuredMessage(message) {
426
+ return !!(message && message.role === "user" && hasRenderOnlyStructuredBlock(message, "__queued"));
427
+ }
428
+
429
+ function isProcessingStructuredMessage(message) {
430
+ return !!(message && message.role === "assistant" && hasRenderOnlyStructuredBlock(message, "__processing"));
431
+ }
432
+
433
+ function stripRenderOnlyStructuredMessages(messages) {
434
+ if (!Array.isArray(messages)) return [];
435
+ var removed = false;
436
+ var filtered = [];
437
+ for (var i = 0; i < messages.length; i++) {
438
+ var message = messages[i];
439
+ if (isQueuedStructuredMessage(message) || isProcessingStructuredMessage(message)) {
440
+ removed = true;
441
+ continue;
442
+ }
443
+ filtered.push(message);
444
+ }
445
+ return removed ? filtered : messages;
446
+ }
447
+
448
+ function normalizeStructuredSnapshot(snapshot, existingSession) {
449
+ if (!snapshot || !Array.isArray(snapshot.messages)) {
450
+ return snapshot;
451
+ }
452
+ var sessionKind = snapshot.sessionKind || (existingSession && existingSession.sessionKind);
453
+ if (sessionKind !== "structured") {
454
+ return snapshot;
455
+ }
456
+ var sanitizedMessages = stripRenderOnlyStructuredMessages(snapshot.messages);
457
+ if (sanitizedMessages === snapshot.messages) {
458
+ return snapshot;
459
+ }
460
+ return Object.assign({}, snapshot, { messages: sanitizedMessages });
461
+ }
462
+
463
+ function saveStructuredQueue() {
464
+ try {
465
+ var queued = getSelectedStructuredQueuedInputs();
466
+ if (!state.selectedId || queued.length === 0) {
467
+ return;
468
+ }
469
+ localStorage.setItem("wand-structured-queue", JSON.stringify({
470
+ sessionId: state.selectedId,
471
+ items: queued
472
+ }));
473
+ } catch (e) {
474
+ // Ignore localStorage errors
475
+ }
476
+ }
477
+
478
+ function clearStructuredQueuePersistence(sessionId) {
479
+ try {
480
+ var saved = localStorage.getItem("wand-structured-queue");
481
+ if (!saved) return;
482
+ var parsed = JSON.parse(saved);
483
+ if (!sessionId || !parsed || parsed.sessionId === sessionId) {
484
+ localStorage.removeItem("wand-structured-queue");
485
+ }
486
+ } catch (e) {
487
+ localStorage.removeItem("wand-structured-queue");
488
+ }
489
+ }
490
+
491
+ function restoreStructuredQueue() {
492
+ var selectedSession = state.sessions.find(function(s) { return s.id === state.selectedId; });
493
+ if (selectedSession && Array.isArray(selectedSession.queuedMessages)) {
494
+ syncStructuredQueueFromSession(selectedSession);
495
+ saveStructuredQueue();
496
+ return;
497
+ }
498
+ try {
499
+ var saved = localStorage.getItem("wand-structured-queue");
500
+ if (!saved) return;
501
+ var parsed = JSON.parse(saved);
502
+ if (!parsed || parsed.sessionId !== state.selectedId || !Array.isArray(parsed.items)) {
503
+ return;
504
+ }
505
+ state.structuredInputQueue = parsed.items.slice(0, 10);
506
+ } catch (e) {
507
+ state.structuredInputQueue = [];
508
+ }
509
+ }
510
+
511
+ function persistCrossSessionQueue() {
512
+ try {
513
+ if (state.crossSessionQueue.length === 0) {
514
+ localStorage.removeItem("wand-cross-session-queue");
515
+ return;
516
+ }
517
+ localStorage.setItem("wand-cross-session-queue", JSON.stringify(state.crossSessionQueue));
518
+ } catch (e) {
519
+ // Ignore localStorage errors
520
+ }
521
+ }
522
+
260
523
  function getConfigCwd() {
261
524
  return (state.config && state.config.defaultCwd) || "/tmp";
262
525
  }
263
526
 
527
+ function loadChatExpandStateMap() {
528
+ try {
529
+ var saved = localStorage.getItem(CHAT_EXPAND_STATE_STORAGE_KEY);
530
+ if (!saved) return {};
531
+ var parsed = JSON.parse(saved);
532
+ return parsed && typeof parsed === "object" ? parsed : {};
533
+ } catch (e) {
534
+ return {};
535
+ }
536
+ }
537
+
538
+ function saveChatExpandStateMap(map) {
539
+ try {
540
+ if (!map || Object.keys(map).length === 0) {
541
+ localStorage.removeItem(CHAT_EXPAND_STATE_STORAGE_KEY);
542
+ return;
543
+ }
544
+ localStorage.setItem(CHAT_EXPAND_STATE_STORAGE_KEY, JSON.stringify(map));
545
+ } catch (e) {
546
+ // Ignore localStorage errors
547
+ }
548
+ }
549
+
550
+ function getCurrentChatExpandState() {
551
+ var sessionId = state.selectedId;
552
+ if (!sessionId) return {};
553
+ var map = loadChatExpandStateMap();
554
+ var sessionState = map[sessionId];
555
+ return sessionState && typeof sessionState === "object" ? sessionState : {};
556
+ }
557
+
558
+ function getPersistedExpandState(itemKey) {
559
+ if (!itemKey || !state.selectedId) return null;
560
+ var sessionState = getCurrentChatExpandState();
561
+ return typeof sessionState[itemKey] === "boolean" ? sessionState[itemKey] : null;
562
+ }
563
+
564
+ function setPersistedExpandState(itemKey, expanded) {
565
+ if (!itemKey || !state.selectedId) return;
566
+ var map = loadChatExpandStateMap();
567
+ var sessionId = state.selectedId;
568
+ var sessionState = map[sessionId];
569
+ if (!sessionState || typeof sessionState !== "object") {
570
+ sessionState = {};
571
+ }
572
+ sessionState[itemKey] = !!expanded;
573
+ map[sessionId] = sessionState;
574
+ saveChatExpandStateMap(map);
575
+ }
576
+
577
+ function getMessageKey(msg, fallbackIndex) {
578
+ if (!msg) {
579
+ return "msg:unknown-" + (typeof fallbackIndex === "number" ? fallbackIndex : 0);
580
+ }
581
+ if (msg.uuid) return "msg:" + msg.uuid;
582
+ if (msg.id) return "msg:" + msg.id;
583
+ if (msg.messageId) return "msg:" + msg.messageId;
584
+ if (msg.turnId) return "msg:" + msg.turnId;
585
+ return "msg:" + (typeof fallbackIndex === "number" ? fallbackIndex : 0);
586
+ }
587
+
588
+ function buildExpandKey(kind, parts) {
589
+ var filtered = [];
590
+ for (var i = 0; i < parts.length; i++) {
591
+ var part = parts[i];
592
+ if (part === undefined || part === null || part === "") continue;
593
+ filtered.push(String(part));
594
+ }
595
+ return kind + ":" + filtered.join(":");
596
+ }
597
+
598
+ function getElementExpandKey(el) {
599
+ if (!el || !el.dataset) return "";
600
+ return el.dataset.expandKey || "";
601
+ }
602
+
603
+ function isElementExpanded(el, kind) {
604
+ if (!el) return false;
605
+ switch (kind) {
606
+ case "tool-card":
607
+ return !el.classList.contains("collapsed");
608
+ case "thinking":
609
+ return el.classList.contains("expanded") && !el.classList.contains("collapsed");
610
+ case "inline-tool":
611
+ return el.classList.contains("inline-tool-open");
612
+ case "terminal": {
613
+ var body = el.querySelector(".term-body");
614
+ if (body) return body.style.display !== "none";
615
+ return el.dataset.expanded === "true";
616
+ }
617
+ case "tool-group":
618
+ return el.getAttribute("data-expanded") === "true";
619
+ default:
620
+ return false;
621
+ }
622
+ }
623
+
624
+ function applyExpandedState(el, kind, expanded) {
625
+ if (!el) return;
626
+ switch (kind) {
627
+ case "tool-card": {
628
+ el.classList.toggle("collapsed", !expanded);
629
+ break;
630
+ }
631
+ case "thinking": {
632
+ el.classList.toggle("collapsed", !expanded);
633
+ el.classList.toggle("expanded", !!expanded);
634
+ var previewEl = el.querySelector(".thinking-inline-preview");
635
+ if (previewEl) {
636
+ var fullText = el.dataset.thinking || "";
637
+ var preview = fullText.slice(0, 57) + (fullText.length > 60 ? "…" : "");
638
+ previewEl.textContent = expanded ? fullText : preview;
639
+ }
640
+ var actionEl = el.querySelector(".thinking-inline-action");
641
+ if (actionEl) actionEl.textContent = expanded ? "收起" : "展开";
642
+ break;
643
+ }
644
+ case "inline-tool": {
645
+ el.classList.toggle("inline-tool-open", !!expanded);
646
+ var inlineBody = el.querySelector(".inline-tool-expanded");
647
+ if (inlineBody) inlineBody.style.display = expanded ? "block" : "none";
648
+ break;
649
+ }
650
+ case "terminal": {
651
+ var body = el.querySelector(".term-body");
652
+ if (body) body.style.display = expanded ? "block" : "none";
653
+ el.dataset.expanded = expanded ? "true" : "false";
654
+ var toggleIcon = el.querySelector(".term-toggle-icon");
655
+ if (toggleIcon) toggleIcon.textContent = expanded ? "▼" : "▶";
656
+ break;
657
+ }
658
+ case "tool-group": {
659
+ el.setAttribute("data-expanded", expanded ? "true" : "false");
660
+ var groupBody = el.querySelector(".tool-group-body");
661
+ if (groupBody) groupBody.style.display = expanded ? "block" : "none";
662
+ var chevron = el.querySelector(".tool-group-chevron");
663
+ if (chevron) chevron.style.transform = expanded ? "rotate(180deg)" : "";
664
+ break;
665
+ }
666
+ }
667
+ }
668
+
669
+ function persistElementExpandState(el, kind) {
670
+ var itemKey = getElementExpandKey(el);
671
+ if (!itemKey) return;
672
+ setPersistedExpandState(itemKey, isElementExpanded(el, kind));
673
+ }
674
+
675
+ function applyPersistedExpandState(container) {
676
+ if (!container || !state.selectedId) return;
677
+ container.querySelectorAll("[data-expand-key]").forEach(function(el) {
678
+ var itemKey = getElementExpandKey(el);
679
+ var kind = el.dataset.expandKind || "";
680
+ var persisted = getPersistedExpandState(itemKey);
681
+ if (persisted === null || !kind) return;
682
+ applyExpandedState(el, kind, persisted);
683
+ });
684
+ }
685
+
264
686
  function resetChatRenderCache() {
265
687
  state.lastRenderedHash = 0;
266
688
  state.lastRenderedMsgCount = 0;
267
689
  state.lastRenderedEmpty = null;
690
+ state.renderPending = false;
691
+ if (state.chatScrollElement && state.chatScrollHandler) {
692
+ state.chatScrollElement.removeEventListener("scroll", state.chatScrollHandler);
693
+ }
694
+ state.chatScrollElement = null;
695
+ state.chatScrollHandler = null;
696
+ state.showChatJumpToBottom = false;
697
+ state.chatIsProgrammaticScroll = false;
268
698
  }
269
699
 
270
700
  function getEffectiveCwd() {
@@ -314,9 +744,6 @@
314
744
  }
315
745
  }
316
746
 
317
- renderBootLoading();
318
- restoreLoginSession();
319
-
320
747
  function renderBootLoading() {
321
748
  var app = document.getElementById("app");
322
749
  if (!app) return;
@@ -324,11 +751,65 @@
324
751
  '<div class="boot-loading">' +
325
752
  '<div class="boot-loading-card">' +
326
753
  '<div class="boot-loading-spinner"></div>' +
327
- '<div class="boot-loading-text">正在恢复会话…</div>' +
754
+ '<div class="boot-loading-text">正在连接 Wand…</div>' +
328
755
  '</div>' +
329
756
  '</div>';
330
757
  }
331
758
 
759
+ function scheduleForegroundSync(reason) {
760
+ if (!state.config) return;
761
+ if (document.hidden) return;
762
+ var now = Date.now();
763
+ if (now - state.lastForegroundSyncAt < 1500) return;
764
+ state.lastForegroundSyncAt = now;
765
+ if (state.foregroundSyncTimer) {
766
+ clearTimeout(state.foregroundSyncTimer);
767
+ }
768
+ state.foregroundSyncTimer = setTimeout(function() {
769
+ state.foregroundSyncTimer = null;
770
+ syncOnForeground(reason);
771
+ }, 80);
772
+ }
773
+
774
+ function syncOnForeground(reason) {
775
+ if (!state.config) return Promise.resolve();
776
+ if (document.hidden) return Promise.resolve();
777
+ if (!state.ws || (state.ws.readyState !== WebSocket.OPEN && state.ws.readyState !== WebSocket.CONNECTING)) {
778
+ initWebSocket();
779
+ }
780
+ return loadSessions({ skipSelectedOutputReload: true }).then(function() {
781
+ if (state.selectedId) {
782
+ return loadOutput(state.selectedId);
783
+ }
784
+ scheduleChatRender(true);
785
+ }).catch(function(e) {
786
+ console.error("[wand] foreground sync failed:", reason, e);
787
+ });
788
+ }
789
+
790
+ function bindForegroundSyncListeners() {
791
+ if (window.__wandForegroundSyncBound) return;
792
+ window.__wandForegroundSyncBound = true;
793
+
794
+ document.addEventListener("visibilitychange", function() {
795
+ if (!document.hidden) {
796
+ scheduleForegroundSync("visibility");
797
+ }
798
+ });
799
+
800
+ window.addEventListener("focus", function() {
801
+ scheduleForegroundSync("focus");
802
+ });
803
+
804
+ window.addEventListener("pageshow", function() {
805
+ scheduleForegroundSync("pageshow");
806
+ });
807
+
808
+ window.addEventListener("resume", function() {
809
+ scheduleForegroundSync("resume");
810
+ });
811
+ }
812
+
332
813
  function restoreLoginSession() {
333
814
  fetch("/api/config", { credentials: "same-origin" })
334
815
  .then(function(res) {
@@ -344,20 +825,16 @@
344
825
  state.config = config;
345
826
  state.loginChecked = true;
346
827
  requestAnimationFrame(function() {
347
- // Render the app shell first, THEN load session data into it.
348
- // Skip updateShellChrome() here — sessions aren't loaded yet.
349
- // refreshAll() will call updateShellChrome() after sessions arrive.
350
828
  try {
351
829
  render({ skipShellChrome: true });
352
830
  } catch (_e) {
353
831
  // render() may fail if external scripts (xterm.js) failed to load;
354
832
  // continue with polling and session loading so the app remains functional
355
833
  }
834
+ bindForegroundSyncListeners();
356
835
  startPolling();
357
836
  refreshAll();
358
- // Request browser notification permission after login
359
837
  requestNotificationPermission();
360
- // Show update bubble if server reports an available update
361
838
  if (config.updateAvailable && config.latestVersion) {
362
839
  showNotificationBubble({
363
840
  title: "\u53d1\u73b0\u65b0\u7248\u672c",
@@ -373,7 +850,6 @@
373
850
  });
374
851
  sendBrowserNotification("Wand \u53d1\u73b0\u65b0\u7248\u672c", "\u5f53\u524d " + (config.currentVersion || "-") + " \u2192 \u6700\u65b0 " + config.latestVersion, { tag: "wand-update" });
375
852
  }
376
- // Auto-load claude history since section defaults to expanded
377
853
  if (state.claudeHistoryExpanded && !state.claudeHistoryLoaded) {
378
854
  loadClaudeHistory();
379
855
  }
@@ -381,7 +857,6 @@
381
857
  })
382
858
  .catch(function() {
383
859
  state.loginChecked = true;
384
- // If offline (no network), show a friendly offline message instead of login
385
860
  if (!navigator.onLine) {
386
861
  var app = document.getElementById("app");
387
862
  if (app) {
@@ -394,7 +869,6 @@
394
869
  '</div>' +
395
870
  '</div>';
396
871
  }
397
- // Retry when network comes back
398
872
  window.addEventListener('online', function() { location.reload(); }, { once: true });
399
873
  return;
400
874
  }
@@ -402,6 +876,9 @@
402
876
  });
403
877
  }
404
878
 
879
+ renderBootLoading();
880
+ restoreLoginSession();
881
+
405
882
  function render(options) {
406
883
  var skipShellChrome = options && options.skipShellChrome;
407
884
  var app = document.getElementById("app");
@@ -613,7 +1090,10 @@
613
1090
  '</aside>' +
614
1091
  '<main class="main-content">' +
615
1092
  '<span class="current-task hidden" id="current-task"></span>' +
616
- '' +
1093
+ '<div class="view-toggle-bar' + (state.selectedId ? '' : ' hidden') + '" id="view-toggle-bar">' +
1094
+ '<button id="view-terminal-btn" class="topbar-btn' + (state.currentView === "terminal" ? ' active' : '') + '" type="button" title="查看原始终端输出">终端</button>' +
1095
+ '<button id="view-chat-btn" class="topbar-btn' + (state.currentView !== "terminal" ? ' active' : '') + '" type="button" title="查看聊天解析视图">聊天</button>' +
1096
+ '</div>' +
617
1097
  // File panel backdrop (mobile)
618
1098
  '<div id="file-panel-backdrop" class="file-panel-backdrop' + (state.filePanelOpen ? " open" : "") + '"></div>' +
619
1099
  // File side panel
@@ -644,7 +1124,12 @@
644
1124
  '</div>' +
645
1125
  '<button id="terminal-jump-bottom" class="terminal-jump-bottom' + (state.showTerminalJumpToBottom ? ' visible' : '') + '" type="button" title="回到底部">↓ 最新</button>' +
646
1126
  '</div>' +
647
- '<div id="chat-output" class="chat-container hidden"></div>' +
1127
+ '<div id="chat-output" class="chat-container hidden">' +
1128
+ '<div class="chat-overlay-controls">' +
1129
+ '<button id="chat-follow-toggle" class="chat-follow-toggle topbar-btn' + (state.chatAutoFollow ? ' active' : '') + '" type="button" aria-pressed="' + (state.chatAutoFollow ? 'true' : 'false') + '" title="' + (state.chatAutoFollow ? '追踪底部:开启' : '追踪底部:已暂停') + '">' + (state.chatAutoFollow ? '追底' : '暂停') + '</button>' +
1130
+ '</div>' +
1131
+ '<button id="chat-jump-bottom" class="chat-jump-bottom' + (state.showChatJumpToBottom ? ' visible' : '') + '" type="button" title="回到底部并继续追底">↓ 最新</button>' +
1132
+ '</div>' +
648
1133
  '<div id="blank-chat" class="blank-chat' + (state.selectedId ? " hidden" : "") + '">' +
649
1134
  '<div class="blank-chat-inner">' +
650
1135
  '<div class="blank-chat-logo">W</div>' +
@@ -654,6 +1139,9 @@
654
1139
  '<button class="blank-chat-tool-btn" id="welcome-tool-claude" type="button">' +
655
1140
  '<span class="tool-icon">🤖</span>新建终端会话' +
656
1141
  '</button>' +
1142
+ '<button class="blank-chat-tool-btn" id="welcome-tool-codex" type="button">' +
1143
+ '<span class="tool-icon">⌘</span>新建 Codex 会话' +
1144
+ '</button>' +
657
1145
  '<button class="blank-chat-tool-btn" id="welcome-tool-structured" type="button">' +
658
1146
  '<span class="tool-icon">💬</span>新建结构化会话' +
659
1147
  '</button>' +
@@ -683,7 +1171,7 @@
683
1171
  '</div>' +
684
1172
  '</div>' +
685
1173
  '<div class="input-composer">' +
686
- '<textarea id="input-box" class="input-textarea" placeholder="' + (state.terminalInteractive ? "终端交互模式开启中,请直接在终端中输入" : "输入消息...") + '" rows="1">' + escapeHtml(currentDraft) + '</textarea>' +
1174
+ '<textarea id="input-box" class="input-textarea" placeholder="' + getComposerPlaceholder(selectedSession, state.terminalInteractive) + '" rows="1">' + escapeHtml(currentDraft) + '</textarea>' +
687
1175
  '<div class="input-composer-bar">' +
688
1176
  '<div class="input-composer-left">' +
689
1177
  '<select id="chat-mode-select" class="chat-mode-select" title="仅对新建会话生效">' +
@@ -721,7 +1209,7 @@
721
1209
  '<span id="session-kind-display" class="session-kind-display">' + (selectedSession ? getSessionKindLabel(selectedSession) : '终端') + '</span>' +
722
1210
  '<span class="session-info-separator">|</span>' +
723
1211
  '<span id="session-status-display" class="session-status-display">' + (selectedSession ? getSessionStatusLabel(selectedSession) : '-') + '</span>' +
724
- (selectedSession && selectedSession.claudeSessionId ? '<span class="session-info-separator">|</span><span id="claude-session-id-badge" class="claude-session-id-badge" data-claude-id="' + escapeHtml(selectedSession.claudeSessionId) + '" title="点击复制 Claude 会话 ID">☁ ' + escapeHtml(selectedSession.claudeSessionId.slice(0, 8)) + '</span>' : '') +
1212
+ (selectedSession && selectedSession.provider === "claude" && selectedSession.claudeSessionId ? '<span class="session-info-separator">|</span><span id="claude-session-id-badge" class="claude-session-id-badge" data-claude-id="' + escapeHtml(selectedSession.claudeSessionId) + '" title="点击复制 Claude 会话 ID">☁ ' + escapeHtml(selectedSession.claudeSessionId.slice(0, 8)) + '</span>' : '') +
725
1213
  (selectedSession && !isStructuredSession(selectedSession) ? '<span class="session-info-separator">|</span><span id="session-exit-display" class="session-exit-display">退出码=' + (selectedSession.exitCode !== undefined ? selectedSession.exitCode : 'n/a') + '</span>' : '') +
726
1214
  '</div>' +
727
1215
  '</div>' +
@@ -1796,7 +2284,7 @@
1796
2284
  var recoveryHint = "";
1797
2285
  var checkbox = renderManageCheckbox("sessions", session.id, "选择会话 " + session.command);
1798
2286
 
1799
- if (session.claudeSessionId) {
2287
+ if (session.provider === "claude" && session.claudeSessionId) {
1800
2288
  var shortId = session.claudeSessionId.slice(0, 8);
1801
2289
  sessionIdDisplay = '<span class="session-id" title="' + escapeHtml(session.claudeSessionId) + '">' + escapeHtml(shortId) + '</span>';
1802
2290
  if (session.status !== "running" && !state.sessionsManageMode && !isStructuredSession(session)) {
@@ -1834,12 +2322,20 @@
1834
2322
  '</div>';
1835
2323
  }
1836
2324
 
2325
+ function renderWorktreeBadge(session) {
2326
+ if (!session || !session.worktreeEnabled) return "";
2327
+ var title = session.worktree && session.worktree.branch
2328
+ ? ' title="' + escapeHtml('Worktree: ' + session.worktree.branch) + '"'
2329
+ : '';
2330
+ return '<span class="session-kind-badge worktree"' + title + '>Worktree</span>';
2331
+ }
2332
+
1837
2333
  function renderSessionKindBadge(session) {
1838
2334
  if (!session) return "";
1839
- if (isStructuredSession(session)) {
1840
- return '<span class="session-kind-badge structured">Structured</span>';
1841
- }
1842
- return '<span class="session-kind-badge pty">PTY</span>';
2335
+ var primary = isStructuredSession(session)
2336
+ ? '<span class="session-kind-badge structured">Structured</span>'
2337
+ : '<span class="session-kind-badge pty">PTY</span>';
2338
+ return primary + renderWorktreeBadge(session);
1843
2339
  }
1844
2340
 
1845
2341
  function renderModeCards(selectedMode) {
@@ -1859,6 +2355,20 @@
1859
2355
  }).join("");
1860
2356
  }
1861
2357
 
2358
+ function renderProviderOptions(selectedTool) {
2359
+ var tools = [
2360
+ { id: "claude", label: "Claude", desc: "完整 Claude 会话能力" },
2361
+ { id: "codex", label: "Codex", desc: "PTY 透传,全权限启动" }
2362
+ ];
2363
+ return tools.map(function(tool) {
2364
+ var active = tool.id === selectedTool ? " active" : "";
2365
+ return '<button type="button" class="mode-card provider-card' + active + '" data-provider="' + tool.id + '">' +
2366
+ '<span class="mode-card-label">' + tool.label + '</span>' +
2367
+ '<span class="mode-card-desc">' + tool.desc + '</span>' +
2368
+ '</button>';
2369
+ }).join("");
2370
+ }
2371
+
1862
2372
  function renderSessionKindOptions(selectedKind) {
1863
2373
  var kinds = [
1864
2374
  { id: "structured", label: "结构化", desc: "智能对话模式" },
@@ -1866,17 +2376,29 @@
1866
2376
  ];
1867
2377
  return kinds.map(function(kind) {
1868
2378
  var active = kind.id === selectedKind ? " active" : "";
1869
- return '<button type="button" class="mode-card session-kind-card' + active + '" data-session-kind="' + kind.id + '">' +
2379
+ var disabled = (state.sessionTool === "codex" && kind.id === "structured") ? " disabled" : "";
2380
+ return '<button type="button" class="mode-card session-kind-card' + active + disabled + '" data-session-kind="' + kind.id + '">' +
1870
2381
  '<span class="mode-card-label">' + kind.label + '</span>' +
1871
2382
  '<span class="mode-card-desc">' + kind.desc + '</span>' +
1872
2383
  '</button>';
1873
2384
  }).join("");
1874
2385
  }
1875
2386
 
2387
+ function renderWorktreeToggle(enabled) {
2388
+ return '<label class="session-inline-toggle" for="session-worktree-toggle">' +
2389
+ '<input id="session-worktree-toggle" type="checkbox" class="field-checkbox"' + (enabled ? ' checked' : '') + ' />' +
2390
+ '<span class="session-inline-toggle-label">Worktree 模式</span>' +
2391
+ '</label>';
2392
+ }
2393
+
1876
2394
  function getSessionKindHint(kind) {
2395
+ var tool = state.sessionTool || "claude";
1877
2396
  if (kind === "structured") {
1878
2397
  return "结构化聊天界面,支持多轮对话、流式输出和工具调用展示。";
1879
2398
  }
2399
+ if (tool === "codex") {
2400
+ return "Codex 仅支持 PTY;terminal 是原始输出,chat 是解析后的阅读视图。";
2401
+ }
1880
2402
  return "原始 PTY 终端会话,支持持续交互、终端视图和权限流。";
1881
2403
  }
1882
2404
 
@@ -1884,22 +2406,32 @@
1884
2406
  var modalTool = getPreferredTool();
1885
2407
  var modalMode = getSafeModeForTool(modalTool, state.modeValue || state.chatMode || "default");
1886
2408
  var sessionKind = state.sessionCreateKind || "structured";
2409
+ var worktreeEnabled = state.sessionCreateWorktree === true;
1887
2410
  return '<section id="session-modal" class="modal-backdrop hidden">' +
1888
2411
  '<div class="modal session-modal">' +
1889
2412
  '<div class="modal-header">' +
1890
2413
  '<div>' +
1891
2414
  '<h2 class="modal-title">新对话</h2>' +
1892
- '<p class="modal-subtitle">启动 Claude 会话,选择会话类型、模式和工作目录。</p>' +
2415
+ '<p class="modal-subtitle">启动 Claude 或 Codex 会话,选择 provider、会话类型、模式和工作目录。</p>' +
1893
2416
  '</div>' +
1894
2417
  '<button id="close-modal-button" class="btn btn-ghost btn-icon">&times;</button>' +
1895
2418
  '</div>' +
1896
2419
  '<div class="modal-body">' +
2420
+ '<div class="field">' +
2421
+ '<label class="field-label">Provider</label>' +
2422
+ '<div id="provider-cards" class="mode-cards">' +
2423
+ renderProviderOptions(modalTool) +
2424
+ '</div>' +
2425
+ '</div>' +
1897
2426
  '<div class="field">' +
1898
2427
  '<label class="field-label">会话类型</label>' +
1899
2428
  '<div id="session-kind-cards" class="mode-cards">' +
1900
2429
  renderSessionKindOptions(sessionKind) +
1901
2430
  '</div>' +
1902
- '<p id="session-kind-description" class="field-hint">' + escapeHtml(getSessionKindHint(sessionKind)) + '</p>' +
2431
+ '<div class="field-hint session-kind-hint-row">' +
2432
+ '<span id="session-kind-description">' + escapeHtml(getSessionKindHint(sessionKind)) + '</span>' +
2433
+ renderWorktreeToggle(worktreeEnabled) +
2434
+ '</div>' +
1903
2435
  '</div>' +
1904
2436
  '<div class="field">' +
1905
2437
  '<label class="field-label">模式</label>' +
@@ -1927,7 +2459,10 @@
1927
2459
  // Global toggle function for tool card headers — called via onclick attribute
1928
2460
  window.__tcToggle = function(e, headerEl) {
1929
2461
  var card = headerEl.closest(".tool-use-card");
1930
- if (card) card.classList.toggle("collapsed");
2462
+ if (card) {
2463
+ card.classList.toggle("collapsed");
2464
+ persistElementExpandState(card, "tool-card");
2465
+ }
1931
2466
  if (e) { e.preventDefault(); e.stopPropagation(); }
1932
2467
  };
1933
2468
  // Toggle function for inline thinking blocks — called via onclick attribute
@@ -1947,6 +2482,7 @@
1947
2482
  var action = el.querySelector(".thinking-inline-action");
1948
2483
  if (action) action.textContent = "展开";
1949
2484
  }
2485
+ persistElementExpandState(el, "thinking");
1950
2486
  };
1951
2487
  // Toggle function for inline tool rows (Read, Glob, Grep, etc.)
1952
2488
  window.__inlineToolToggle = function(el) {
@@ -1964,6 +2500,7 @@
1964
2500
  statusSpan.textContent = "✓";
1965
2501
  }
1966
2502
  }
2503
+ persistElementExpandState(el, "inline-tool");
1967
2504
  };
1968
2505
  // Toggle function for terminal tool blocks
1969
2506
  window.__terminalExpand = function(el) {
@@ -1976,6 +2513,7 @@
1976
2513
  container.dataset.expanded = isHidden ? "true" : "false";
1977
2514
  var toggleIcon = el.querySelector(".term-toggle-icon");
1978
2515
  if (toggleIcon) toggleIcon.textContent = isHidden ? "▼" : "▶";
2516
+ persistElementExpandState(container, "terminal");
1979
2517
  }
1980
2518
  };
1981
2519
  // Update streaming thinking content (called from WebSocket handler)
@@ -2075,6 +2613,15 @@
2075
2613
  quickStartSession();
2076
2614
  });
2077
2615
  }
2616
+ var welcomeCodexBtn = document.getElementById("welcome-tool-codex");
2617
+ if (welcomeCodexBtn) {
2618
+ welcomeCodexBtn.addEventListener("click", function() {
2619
+ state.sessionTool = "codex";
2620
+ state.preferredCommand = "codex";
2621
+ state.modeValue = "full-access";
2622
+ quickStartSession();
2623
+ });
2624
+ }
2078
2625
  var welcomeStructuredBtn = document.getElementById("welcome-tool-structured");
2079
2626
  if (welcomeStructuredBtn) {
2080
2627
  welcomeStructuredBtn.addEventListener("click", function() {
@@ -2097,10 +2644,26 @@
2097
2644
  // Claude session ID badge click-to-copy (event delegation on document)
2098
2645
  document.addEventListener("click", handleClaudeIdCopy);
2099
2646
 
2647
+ var providerCardsEl = document.getElementById("provider-cards");
2648
+ if (providerCardsEl) providerCardsEl.addEventListener("click", function(e) {
2649
+ var card = e.target.closest(".provider-card");
2650
+ if (!card || card.classList.contains("disabled")) return;
2651
+ var provider = card.getAttribute("data-provider");
2652
+ if (provider) {
2653
+ state.sessionTool = provider;
2654
+ state.preferredCommand = provider;
2655
+ if (provider === "codex") {
2656
+ state.sessionCreateKind = "pty";
2657
+ state.modeValue = "full-access";
2658
+ }
2659
+ syncSessionModalUI();
2660
+ }
2661
+ });
2662
+
2100
2663
  var kindCardsEl = document.getElementById("session-kind-cards");
2101
2664
  if (kindCardsEl) kindCardsEl.addEventListener("click", function(e) {
2102
2665
  var card = e.target.closest(".session-kind-card");
2103
- if (!card) return;
2666
+ if (!card || card.classList.contains("disabled")) return;
2104
2667
  var kind = card.getAttribute("data-session-kind");
2105
2668
  if (kind) {
2106
2669
  state.sessionCreateKind = kind;
@@ -2118,6 +2681,10 @@
2118
2681
  syncSessionModalUI();
2119
2682
  }
2120
2683
  });
2684
+ var worktreeToggleEl = document.getElementById("session-worktree-toggle");
2685
+ if (worktreeToggleEl) worktreeToggleEl.addEventListener("change", function() {
2686
+ state.sessionCreateWorktree = this.checked;
2687
+ });
2121
2688
  var cwdEl = document.getElementById("cwd");
2122
2689
  if (cwdEl) {
2123
2690
  cwdEl.addEventListener("input", function() { state.cwdValue = this.value; });
@@ -2219,6 +2786,9 @@
2219
2786
  inputBox.addEventListener("keydown", handleInputBoxKeydown);
2220
2787
  inputBox.addEventListener("paste", handleInputPaste);
2221
2788
  inputBox.addEventListener("input", function() {
2789
+ if (handleInteractiveTextInput(inputBox)) {
2790
+ return;
2791
+ }
2222
2792
  refreshInputBoxState(inputBox);
2223
2793
  setDraftValue(inputBox.value, true);
2224
2794
  });
@@ -2233,6 +2803,8 @@
2233
2803
  // View toggle handlers
2234
2804
  var viewTermBtn = document.getElementById("view-terminal-btn");
2235
2805
  if (viewTermBtn) viewTermBtn.addEventListener("click", function() { setView("terminal"); });
2806
+ var viewChatBtn = document.getElementById("view-chat-btn");
2807
+ if (viewChatBtn) viewChatBtn.addEventListener("click", function() { setView("chat"); });
2236
2808
  // Terminal interactive toggle (both topbar and terminal-header)
2237
2809
  var terminalInteractiveToggles = ["terminal-interactive-toggle-top"];
2238
2810
  terminalInteractiveToggles.forEach(function(id) {
@@ -2312,8 +2884,18 @@
2312
2884
  if (jumpBottomBtn) jumpBottomBtn.addEventListener("click", function() {
2313
2885
  maybeScrollTerminalToBottom("force");
2314
2886
  });
2315
-
2316
- // File explorer
2887
+ var chatFollowToggle = document.getElementById("chat-follow-toggle");
2888
+ if (chatFollowToggle) chatFollowToggle.addEventListener("click", function() {
2889
+ if (state.chatAutoFollow) {
2890
+ setChatAutoFollow(false, { scrollNow: false });
2891
+ } else {
2892
+ setChatAutoFollow(true, { scrollNow: true, smooth: false });
2893
+ }
2894
+ });
2895
+ var chatJumpBottomBtn = document.getElementById("chat-jump-bottom");
2896
+ if (chatJumpBottomBtn) chatJumpBottomBtn.addEventListener("click", function() {
2897
+ setChatAutoFollow(true, { scrollNow: true, smooth: true });
2898
+ });
2317
2899
  var fileRefresh = document.getElementById("file-explorer-refresh");
2318
2900
  if (fileRefresh) fileRefresh.addEventListener("click", refreshFileExplorer);
2319
2901
 
@@ -2959,8 +3541,8 @@
2959
3541
 
2960
3542
  function setTerminalManualScrollActive() {
2961
3543
  state.terminalAutoFollow = false;
3544
+ clearTerminalScrollIdleTimer();
2962
3545
  updateTerminalJumpToBottomButton();
2963
- scheduleTerminalResumeFollow();
2964
3546
  }
2965
3547
 
2966
3548
  function maybeScrollTerminalToBottom(reason) {
@@ -3171,6 +3753,7 @@
3171
3753
  var shouldScroll = opts.scroll !== false;
3172
3754
  var sessionChanged = state.terminalSessionId !== nextSessionId;
3173
3755
  var currentOutput = state.terminalOutput || "";
3756
+ var liveChunkStream = !!(nextSessionId && state.terminalLiveStreamSessions[nextSessionId]);
3174
3757
  var wrote = false;
3175
3758
 
3176
3759
  if (normalizedOutput === currentOutput && !sessionChanged) {
@@ -3199,6 +3782,10 @@
3199
3782
  } else if (normalizedOutput.length < currentOutput.length && !sessionChanged) {
3200
3783
  // Ignore regressive snapshots for the active session; wait for an explicit replace.
3201
3784
  return false;
3785
+ } else if (liveChunkStream && !sessionChanged && mode !== "replace" && currentOutput && !normalizedOutput.startsWith(currentOutput)) {
3786
+ // When a session is already streaming live chunks, do not let polled snapshots
3787
+ // rewrite the terminal unless they are strict appends of what we've rendered.
3788
+ return false;
3202
3789
  } else if (normalizedOutput.startsWith(currentOutput)) {
3203
3790
  var delta = normalizedOutput.slice(currentOutput.length);
3204
3791
  if (delta) {
@@ -3352,7 +3939,7 @@
3352
3939
  if (state.selectedId) {
3353
3940
  var session = state.sessions.find(function(s) { return s.id === state.selectedId; });
3354
3941
  if (session) {
3355
- syncTerminalBuffer(session.id, session.output || "", { mode: "replace", scroll: false });
3942
+ syncTerminalBuffer(session.id, session.output || "", { mode: "append", scroll: false });
3356
3943
  }
3357
3944
  } else {
3358
3945
  state.terminal.writeln("点击上方「新对话」开始你的第一次对话。");
@@ -3467,14 +4054,38 @@
3467
4054
  }
3468
4055
 
3469
4056
  function getPreferredTool() {
3470
- return "claude";
4057
+ return state.sessionTool || state.preferredCommand || "claude";
3471
4058
  }
3472
4059
 
3473
4060
  function getComposerTool() {
3474
- return "claude";
4061
+ var selected = state.sessions.find(function(s) { return s.id === state.selectedId; });
4062
+ return (selected && selected.provider) || state.preferredCommand || "claude";
4063
+ }
4064
+
4065
+ function getComposerPlaceholder(session, terminalInteractive) {
4066
+ if (terminalInteractive) {
4067
+ return "终端交互模式开启中,请直接在终端中输入";
4068
+ }
4069
+ if (session && session.provider === "codex") {
4070
+ if (session.status !== "running") {
4071
+ return "Codex 会话已结束,无法继续发送";
4072
+ }
4073
+ return state.currentView === "terminal"
4074
+ ? "向 Codex 发送输入;terminal 为原始 TUI 输出"
4075
+ : "向 Codex 发送输入;chat 为解析后的阅读视图";
4076
+ }
4077
+ if (session && !isStructuredSession(session) && session.status !== "running") {
4078
+ return "会话已结束,无法继续发送";
4079
+ }
4080
+ return session && isStructuredSession(session) && session.structuredState && session.structuredState.inFlight
4081
+ ? "思考中,可继续发送,消息会自动排队"
4082
+ : "输入消息...";
3475
4083
  }
3476
4084
 
3477
4085
  function getToolModeHint(tool, mode) {
4086
+ if (tool === "codex") {
4087
+ return "Codex 当前仅支持 PTY 透传,并固定以 full-access 启动。";
4088
+ }
3478
4089
  if (mode === "full-access") {
3479
4090
  return "自动确认权限请求与高权限操作,适合你确认环境安全后的连续修改。";
3480
4091
  }
@@ -3491,6 +4102,9 @@
3491
4102
  }
3492
4103
 
3493
4104
  function getSupportedModes(tool) {
4105
+ if (tool === "codex") {
4106
+ return ["full-access"];
4107
+ }
3494
4108
  return ["default", "full-access", "auto-edit", "native", "managed"];
3495
4109
  }
3496
4110
 
@@ -3523,13 +4137,21 @@
3523
4137
  }
3524
4138
 
3525
4139
  function getSessionKindLabel(session) {
3526
- return isStructuredSession(session) ? "结构化" : "终端";
4140
+ var provider = session && session.provider ? session.provider : "claude";
4141
+ return (isStructuredSession(session) ? "结构化" : "终端") + " · " + provider;
3527
4142
  }
3528
4143
 
3529
4144
  function getSessionKindDescription(session) {
3530
4145
  return isStructuredSession(session)
3531
4146
  ? "结构化 · 块级记录"
3532
- : "终端 · PTY 会话";
4147
+ : (session && session.provider === "codex"
4148
+ ? "终端 · Codex PTY(chat 为解析视图)"
4149
+ : "终端 · PTY 会话");
4150
+ }
4151
+
4152
+ function shouldRequestChatFormat(session) {
4153
+ if (!session) return false;
4154
+ return isStructuredSession(session) || session.provider === "codex";
3533
4155
  }
3534
4156
 
3535
4157
  function isRecoverableToolError(toolResult, nextResult) {
@@ -3546,9 +4168,7 @@
3546
4168
  }
3547
4169
 
3548
4170
  function isStructuredSession(session) {
3549
- var result = !!session && (session.sessionKind === "structured" || session.runner === "claude-cli-print");
3550
- if (session) console.log("[WAND] isStructuredSession id:", session.id, "sessionKind:", session.sessionKind, "runner:", session.runner, "=>", result);
3551
- return result;
4171
+ return !!session && (session.sessionKind === "structured" || session.runner === "claude-cli-print");
3552
4172
  }
3553
4173
 
3554
4174
  function syncComposerModeSelect() {
@@ -3561,12 +4181,13 @@
3561
4181
  if (modeHint) modeHint.textContent = getModeHint(state.chatMode);
3562
4182
  }
3563
4183
 
3564
- function createStructuredSession(prompt, cwdOverride, modeOverride) {
4184
+ function createStructuredSession(prompt, cwdOverride, modeOverride, worktreeEnabled) {
3565
4185
  var payload = {
3566
4186
  cwd: cwdOverride || getEffectiveCwd(),
3567
4187
  mode: modeOverride || state.chatMode || (state.config && state.config.defaultMode) || "default",
3568
4188
  runner: state.structuredRunner || "claude-cli-print",
3569
- prompt: prompt || undefined
4189
+ prompt: prompt || undefined,
4190
+ worktreeEnabled: worktreeEnabled === true
3570
4191
  };
3571
4192
  console.log("[WAND] createStructuredSession payload:", JSON.stringify(payload));
3572
4193
  return fetch("/api/structured-sessions", {
@@ -3599,24 +4220,31 @@
3599
4220
  function applyCurrentView() {
3600
4221
  var hasSession = !!state.selectedId;
3601
4222
  var terminalBtn = document.getElementById("view-terminal-btn");
4223
+ var chatBtn = document.getElementById("view-chat-btn");
4224
+ var toggleBar = document.getElementById("view-toggle-bar");
3602
4225
  var terminalContainer = document.getElementById("output");
3603
4226
  var chatContainer = document.getElementById("chat-output");
3604
4227
  var selectedSession = state.sessions.find(function(s) { return s.id === state.selectedId; });
3605
4228
  var structured = isStructuredSession(selectedSession);
3606
4229
  var showTerminal = hasSession && !structured && state.currentView === "terminal";
3607
4230
  var showChat = hasSession && (structured || state.currentView !== "terminal");
3608
- console.log("[WAND] applyCurrentView hasSession:", hasSession, "structured:", structured, "currentView:", state.currentView, "showTerminal:", showTerminal, "showChat:", showChat, "sessionKind:", selectedSession && selectedSession.sessionKind, "runner:", selectedSession && selectedSession.runner);
3609
-
3610
4231
  if (structured) {
3611
4232
  state.currentView = "chat";
3612
4233
  } else if (!hasSession) {
3613
4234
  state.currentView = "terminal";
3614
4235
  }
3615
4236
 
4237
+ if (toggleBar) {
4238
+ toggleBar.classList.toggle("hidden", !hasSession);
4239
+ }
3616
4240
  if (terminalBtn) {
3617
4241
  terminalBtn.classList.toggle("hidden", structured || !hasSession);
3618
4242
  terminalBtn.classList.toggle("active", showTerminal);
3619
4243
  }
4244
+ if (chatBtn) {
4245
+ chatBtn.classList.toggle("hidden", !hasSession);
4246
+ chatBtn.classList.toggle("active", showChat);
4247
+ }
3620
4248
  if (terminalContainer) {
3621
4249
  terminalContainer.classList.toggle("active", showTerminal);
3622
4250
  terminalContainer.classList.toggle("hidden", !showTerminal);
@@ -3625,22 +4253,45 @@
3625
4253
  chatContainer.classList.toggle("active", showChat);
3626
4254
  chatContainer.classList.toggle("hidden", !showChat);
3627
4255
  }
4256
+ if (chatContainer && showChat) {
4257
+ ensureChatMessagesContainer(chatContainer);
4258
+ }
4259
+ bindChatScrollListener();
4260
+ updateChatFollowToggleButton();
4261
+ updateChatJumpToBottomButton();
3628
4262
  updateInteractiveControls();
3629
4263
  }
3630
4264
 
3631
4265
  function syncSessionModalUI() {
3632
4266
  var modeHint = document.getElementById("mode-description");
3633
4267
  var kindHint = document.getElementById("session-kind-description");
3634
- var tool = "claude";
4268
+ var tool = state.sessionTool || "claude";
3635
4269
  var sessionKind = state.sessionCreateKind || "structured";
3636
4270
 
4271
+ if (tool === "codex" && sessionKind === "structured") {
4272
+ sessionKind = "pty";
4273
+ state.sessionCreateKind = "pty";
4274
+ }
4275
+
3637
4276
  state.sessionTool = tool;
3638
4277
  state.modeValue = getSafeModeForTool(tool, state.modeValue || state.chatMode || "default");
3639
4278
 
4279
+ var providerCards = document.querySelectorAll("#provider-cards .provider-card");
4280
+ if (providerCards.length) {
4281
+ providerCards.forEach(function(card) {
4282
+ var provider = card.getAttribute("data-provider");
4283
+ card.classList.toggle("active", provider === tool);
4284
+ card.classList.remove("disabled");
4285
+ });
4286
+ }
4287
+
3640
4288
  var kindCards = document.querySelectorAll("#session-kind-cards .session-kind-card");
3641
4289
  if (kindCards.length) {
3642
4290
  kindCards.forEach(function(card) {
3643
- card.classList.toggle("active", card.getAttribute("data-session-kind") === sessionKind);
4291
+ var kind = card.getAttribute("data-session-kind");
4292
+ var disabled = tool === "codex" && kind === "structured";
4293
+ card.classList.toggle("active", kind === sessionKind);
4294
+ card.classList.toggle("disabled", disabled);
3644
4295
  });
3645
4296
  }
3646
4297
 
@@ -3657,33 +4308,31 @@
3657
4308
 
3658
4309
  function updateSessionSnapshot(snapshot) {
3659
4310
  if (!snapshot || !snapshot.id) return;
3660
- if (snapshot.id === state.selectedId || (snapshot.sessionKind === "structured") || snapshot.structuredState) {
3661
- console.log("[WAND] updateSessionSnapshot", snapshot.id, JSON.stringify({
3662
- status: snapshot.status,
3663
- exitCode: snapshot.exitCode,
3664
- sessionKind: snapshot.sessionKind,
3665
- runner: snapshot.runner,
3666
- inFlight: snapshot.structuredState && snapshot.structuredState.inFlight,
3667
- msgCount: snapshot.messages && snapshot.messages.length
3668
- }));
3669
- }
4311
+ var currentSession = state.sessions.find(function(session) { return session.id === snapshot.id; }) || null;
4312
+ var normalizedSnapshot = normalizeStructuredSnapshot(snapshot, currentSession);
3670
4313
  var updated = false;
3671
4314
  var prevSession = null;
3672
4315
  state.sessions = state.sessions.map(function(session) {
3673
- if (session.id !== snapshot.id) return session;
4316
+ if (session.id !== normalizedSnapshot.id) return session;
3674
4317
  prevSession = session;
3675
4318
  updated = true;
3676
- return Object.assign({}, session, snapshot);
4319
+ return Object.assign({}, session, normalizedSnapshot);
3677
4320
  });
3678
4321
  if (!updated) {
3679
- state.sessions.unshift(snapshot);
4322
+ state.sessions.unshift(normalizedSnapshot);
4323
+ }
4324
+ var updatedSession = state.sessions.find(function(session) { return session.id === normalizedSnapshot.id; }) || normalizedSnapshot;
4325
+ if (updatedSession && Array.isArray(updatedSession.queuedMessages) && normalizedSnapshot.id === state.selectedId) {
4326
+ syncStructuredQueueFromSession(updatedSession);
4327
+ saveStructuredQueue();
4328
+ updateStructuredQueueCounter();
3680
4329
  }
3681
- if (snapshot.id === state.selectedId) {
4330
+ if (normalizedSnapshot.id === state.selectedId) {
3682
4331
  reconcileInteractiveState();
3683
4332
  updateTaskDisplay();
3684
4333
  }
3685
4334
  // When a session transitions to a non-running state, try flushing cross-session queue
3686
- if (snapshot.status && snapshot.status !== "running" && state.crossSessionQueue.length > 0) {
4335
+ if (normalizedSnapshot.status && normalizedSnapshot.status !== "running" && state.crossSessionQueue.length > 0) {
3687
4336
  // Use setTimeout(0) to let the current event processing complete first
3688
4337
  setTimeout(flushCrossSessionQueue, 0);
3689
4338
  }
@@ -3703,6 +4352,7 @@
3703
4352
  var keepLocalOutput = localOutput.length > serverOutput.length;
3704
4353
  var localStructuredState = localSession.structuredState || null;
3705
4354
  var serverStructuredState = serverSession.structuredState || null;
4355
+ var structuredSession = (localSession.sessionKind === "structured") || (serverSession.sessionKind === "structured");
3706
4356
  var localHasPendingAssistant = !!(localSession.messages && localSession.messages.length && (function() {
3707
4357
  var last = localSession.messages[localSession.messages.length - 1];
3708
4358
  return last && last.role === "assistant" && Array.isArray(last.content) && last.content.some(function(block) {
@@ -3716,6 +4366,16 @@
3716
4366
  && localHasPendingAssistant
3717
4367
  && !!localStructuredState.activeRequestId
3718
4368
  && (!serverStructuredState || !serverStructuredState.activeRequestId || serverStructuredState.activeRequestId === localStructuredState.activeRequestId);
4369
+ var localMessages = Array.isArray(localSession.messages)
4370
+ ? (structuredSession ? stripRenderOnlyStructuredMessages(localSession.messages) : localSession.messages)
4371
+ : [];
4372
+ var serverMessages = Array.isArray(serverSession.messages)
4373
+ ? (structuredSession ? stripRenderOnlyStructuredMessages(serverSession.messages) : serverSession.messages)
4374
+ : [];
4375
+ var preserveLocalMessages = localMessages.length > serverMessages.length
4376
+ || (localMessages.length > 0 && serverMessages.length > 0
4377
+ && JSON.stringify(localMessages[localMessages.length - 1]) !== JSON.stringify(serverMessages[serverMessages.length - 1])
4378
+ && JSON.stringify(localMessages).length > JSON.stringify(serverMessages).length);
3719
4379
 
3720
4380
  if (keepLocalOutput) {
3721
4381
  merged.output = localOutput;
@@ -3727,6 +4387,10 @@
3727
4387
  merged.messages = localSession.messages;
3728
4388
  }
3729
4389
 
4390
+ if (preserveLocalMessages) {
4391
+ merged.messages = localMessages;
4392
+ }
4393
+
3730
4394
  if (localSession.id === state.selectedId) {
3731
4395
  if (localSession.permissionBlocked && serverSession.permissionBlocked === false) {
3732
4396
  } else if (localSession.permissionBlocked && !serverSession.permissionBlocked) {
@@ -3749,6 +4413,9 @@
3749
4413
  if (session && session.messages && session.messages.length > 0) {
3750
4414
  return session.messages;
3751
4415
  }
4416
+ if (session && session.sessionKind === "structured") {
4417
+ return [];
4418
+ }
3752
4419
  if (!allowFallback) {
3753
4420
  return [];
3754
4421
  }
@@ -3774,7 +4441,8 @@
3774
4441
  return recent ? recent.id : sessions[0].id;
3775
4442
  }
3776
4443
 
3777
- function loadSessions() {
4444
+ function loadSessions(options) {
4445
+ var opts = options || {};
3778
4446
  return fetch("/api/sessions", { credentials: "same-origin" })
3779
4447
  .then(function(res) {
3780
4448
  if (res.status === 401) {
@@ -3800,6 +4468,9 @@
3800
4468
  if (preferredSessionId !== undefined) {
3801
4469
  state.selectedId = preferredSessionId;
3802
4470
  }
4471
+ restoreStructuredQueue();
4472
+ updateStructuredQueueCounter();
4473
+ state.bootstrapping = false;
3803
4474
  persistSelectedId();
3804
4475
  if (state.modalOpen) {
3805
4476
  updateSessionsList();
@@ -3817,23 +4488,34 @@
3817
4488
  }
3818
4489
  updateShellChrome();
3819
4490
 
3820
- // For structured sessions, loadOutput is needed to fetch messages
3821
- // (the sessions list endpoint doesn't include them).
3822
- // On page refresh this is the only place that can trigger it.
3823
- if (state.selectedId) {
4491
+ var reloadPromise = Promise.resolve();
4492
+ if (!opts.skipSelectedOutputReload && state.selectedId) {
4493
+ reloadPromise = loadOutput(state.selectedId);
4494
+ } else if (state.selectedId) {
3824
4495
  var sel = state.sessions.find(function(s) { return s.id === state.selectedId; });
3825
4496
  if (isStructuredSession(sel)) {
3826
- loadOutput(state.selectedId);
4497
+ resetChatRenderCache();
4498
+ scheduleChatRender(true);
3827
4499
  }
3828
4500
  }
3829
4501
 
3830
- // Try to flush cross-session queue on every session list refresh
3831
- if (state.crossSessionQueue.length > 0) {
3832
- flushCrossSessionQueue();
3833
- }
4502
+ return reloadPromise.then(function() {
4503
+ if (state.crossSessionQueue.length > 0) {
4504
+ flushCrossSessionQueue();
4505
+ }
4506
+ renderCrossSessionQueue();
4507
+ });
3834
4508
  })
3835
4509
  .catch(function(e) {
3836
- console.error("[wand] loadSessions failed:", e);
4510
+ var message = (e && e.message) || "";
4511
+ var isTransientAbort =
4512
+ message === "Failed to fetch" ||
4513
+ message === "NetworkError when attempting to fetch resource." ||
4514
+ message === "Load failed" ||
4515
+ /aborted|aborterror|networkerror|failed to fetch/i.test(message);
4516
+ if (!isTransientAbort) {
4517
+ console.error("[wand] loadSessions failed:", e);
4518
+ }
3837
4519
  });
3838
4520
  }
3839
4521
 
@@ -3896,7 +4578,7 @@
3896
4578
  }
3897
4579
 
3898
4580
  if (selectedSession && state.terminal) {
3899
- syncTerminalBuffer(selectedSession.id, selectedSession.output || "", { mode: "replace" });
4581
+ syncTerminalBuffer(selectedSession.id, selectedSession.output || "", { mode: "append", scroll: false });
3900
4582
  } else if (!selectedSession) {
3901
4583
  state.terminalSessionId = null;
3902
4584
  state.terminalOutput = "";
@@ -3933,7 +4615,7 @@
3933
4615
  }
3934
4616
  var sess = state.sessions.find(function(s) { return s.id === id; });
3935
4617
  var url = "/api/sessions/" + id;
3936
- if (isStructuredSession(sess)) {
4618
+ if (shouldRequestChatFormat(sess)) {
3937
4619
  url += "?format=chat";
3938
4620
  }
3939
4621
  return fetch(url, { credentials: "same-origin" })
@@ -3952,14 +4634,7 @@
3952
4634
  updateShellChrome();
3953
4635
 
3954
4636
  var selectedSession = state.sessions.find(function(s) { return s.id === id; });
3955
- state.currentMessages = getPreferredMessages(selectedSession, data.output, false);
3956
- if (selectedSession && selectedSession.sessionKind === "structured") {
3957
- appendQueuedPlaceholders(state.currentMessages);
3958
- }
3959
-
3960
- if (state.terminal) {
3961
- syncTerminalBuffer(id, data.output || "", { mode: "replace" });
3962
- }
4637
+ state.currentMessages = buildMessagesForRender(selectedSession, getPreferredMessages(selectedSession, data.output, false));
3963
4638
 
3964
4639
  renderChat(false);
3965
4640
  });
@@ -3967,9 +4642,7 @@
3967
4642
 
3968
4643
  function selectSession(id) {
3969
4644
  var foundSession = state.sessions.find(function(item) { return item.id === id; });
3970
- console.log("[WAND] selectSession id:", id, "found:", !!foundSession, "sessionKind:", foundSession && foundSession.sessionKind, "runner:", foundSession && foundSession.runner, "isStructured:", isStructuredSession(foundSession));
3971
4645
  if (!foundSession) {
3972
- console.warn("[WAND] selectSession: session not found, skipping", id);
3973
4646
  return;
3974
4647
  }
3975
4648
  state.selectedId = id;
@@ -3977,7 +4650,8 @@
3977
4650
  // Clear queued inputs from the previous session to prevent cross-session leaks
3978
4651
  state.messageQueue = [];
3979
4652
  state.pendingMessages = [];
3980
- state.structuredInputQueue = [];
4653
+ syncStructuredQueueFromSession(foundSession);
4654
+ restoreStructuredQueue();
3981
4655
  updateStructuredQueueCounter();
3982
4656
  resetChatRenderCache();
3983
4657
  state.currentMessages = [];
@@ -4054,7 +4728,9 @@
4054
4728
  modal.classList.remove("hidden");
4055
4729
  lastFocusedElement = document.activeElement;
4056
4730
  state.sessionTool = getPreferredTool();
4057
- state.sessionCreateKind = "structured";
4731
+ state.preferredCommand = state.sessionTool;
4732
+ state.sessionCreateKind = state.sessionTool === "codex" ? "pty" : "structured";
4733
+ state.sessionCreateWorktree = false;
4058
4734
  state.modeValue = getSafeModeForTool(state.sessionTool, state.modeValue || state.chatMode);
4059
4735
  syncSessionModalUI();
4060
4736
  loadRecentPathBubbles();
@@ -4553,14 +5229,14 @@
4553
5229
  function quickStartSession() {
4554
5230
  var command = getPreferredTool();
4555
5231
  var defaultCwd = getEffectiveCwd();
4556
- var defaultMode = (state.config && state.config.defaultMode) ? state.config.defaultMode : "default";
5232
+ var defaultMode = getSafeModeForTool(command, (state.config && state.config.defaultMode) ? state.config.defaultMode : "default");
4557
5233
  state.preferredCommand = command;
4558
5234
  state.chatMode = getSafeModeForTool(command, state.chatMode);
4559
5235
  fetch("/api/commands", {
4560
5236
  method: "POST",
4561
5237
  headers: { "Content-Type": "application/json" },
4562
5238
  credentials: "same-origin",
4563
- body: JSON.stringify({ command: command, cwd: defaultCwd, mode: defaultMode })
5239
+ body: JSON.stringify({ command: command, provider: command, cwd: defaultCwd, mode: defaultMode })
4564
5240
  })
4565
5241
  .then(function(res) { return res.json(); })
4566
5242
  .then(function(data) {
@@ -4588,6 +5264,7 @@
4588
5264
  var errorEl = document.getElementById("modal-error");
4589
5265
  var command = getPreferredTool();
4590
5266
  var sessionKind = state.sessionCreateKind || "structured";
5267
+ var worktreeEnabled = state.sessionCreateWorktree === true;
4591
5268
 
4592
5269
  hideError(errorEl);
4593
5270
 
@@ -4596,22 +5273,22 @@
4596
5273
  var selectedMode = getSafeModeForTool(command, state.modeValue);
4597
5274
 
4598
5275
  if (sessionKind === "structured") {
4599
- startStructuredSessionFromModal(cwd, selectedMode, errorEl);
5276
+ startStructuredSessionFromModal(cwd, selectedMode, worktreeEnabled, errorEl);
4600
5277
  return;
4601
5278
  }
4602
5279
 
4603
- runPtyCommandFromModal(command, cwd, selectedMode, errorEl);
5280
+ runPtyCommandFromModal(command, cwd, selectedMode, worktreeEnabled, errorEl);
4604
5281
  }
4605
5282
 
4606
- function startStructuredSessionFromModal(cwd, mode, errorEl) {
4607
- console.log("[WAND] startStructuredSessionFromModal cwd:", cwd, "mode:", mode);
5283
+ function startStructuredSessionFromModal(cwd, mode, worktreeEnabled, errorEl) {
5284
+ console.log("[WAND] startStructuredSessionFromModal cwd:", cwd, "mode:", mode, "worktreeEnabled:", worktreeEnabled);
4608
5285
  _sessionCreating = true;
4609
5286
  state.modeValue = mode;
4610
5287
  state.chatMode = mode;
4611
5288
  state.sessionTool = "claude";
4612
5289
  state.preferredCommand = "claude";
4613
5290
  syncComposerModeSelect();
4614
- return createStructuredSession(undefined, cwd, mode)
5291
+ return createStructuredSession(undefined, cwd, mode, worktreeEnabled)
4615
5292
  .then(function(data) {
4616
5293
  saveWorkingDir(cwd);
4617
5294
  closeSessionModal();
@@ -4625,8 +5302,8 @@
4625
5302
  .finally(function() { _sessionCreating = false; });
4626
5303
  }
4627
5304
 
4628
- function runPtyCommandFromModal(command, cwd, mode, errorEl) {
4629
- console.log("[WAND] runPtyCommandFromModal command:", command, "cwd:", cwd, "mode:", mode);
5305
+ function runPtyCommandFromModal(command, cwd, mode, worktreeEnabled, errorEl) {
5306
+ console.log("[WAND] runPtyCommandFromModal command:", command, "cwd:", cwd, "mode:", mode, "worktreeEnabled:", worktreeEnabled);
4630
5307
  _sessionCreating = true;
4631
5308
  state.modeValue = mode;
4632
5309
  state.chatMode = mode;
@@ -4640,8 +5317,10 @@
4640
5317
  credentials: "same-origin",
4641
5318
  body: JSON.stringify({
4642
5319
  command: command,
5320
+ provider: command,
4643
5321
  cwd: cwd,
4644
- mode: mode
5322
+ mode: mode,
5323
+ worktreeEnabled: worktreeEnabled
4645
5324
  })
4646
5325
  })
4647
5326
  .then(function(res) { return res.json(); })
@@ -4669,7 +5348,9 @@
4669
5348
  }
4670
5349
  })
4671
5350
  .catch(function() {
4672
- showError(errorEl, "无法启动会话,请确认 Claude 已正确安装。");
5351
+ showError(errorEl, command === "codex"
5352
+ ? "无法启动 Codex 会话,请确认 codex 已正确安装并可在终端中执行。"
5353
+ : "无法启动 Claude 会话,请确认 Claude 已正确安装。");
4673
5354
  })
4674
5355
  .finally(function() { _sessionCreating = false; });
4675
5356
  }
@@ -4957,10 +5638,25 @@
4957
5638
  }
4958
5639
  }
4959
5640
 
5641
+ function handleInteractiveTextInput(inputBox) {
5642
+ if (!state.terminalInteractive || !inputBox) return false;
5643
+ var value = inputBox.value || "";
5644
+ if (!value) return false;
5645
+ queueDirectInput(value, "interactive_text").catch(function() {});
5646
+ inputBox.value = "";
5647
+ autoResizeInput(inputBox);
5648
+ setDraftValue("", true);
5649
+ return true;
5650
+ }
5651
+
4960
5652
  function handleInputPaste(event) {
4961
5653
  var pasted = event.clipboardData && event.clipboardData.getData("text");
4962
5654
  if (!pasted) return;
4963
5655
  event.preventDefault();
5656
+ if (state.terminalInteractive) {
5657
+ queueDirectInput(pasted, "paste").catch(function() {});
5658
+ return;
5659
+ }
4964
5660
  var inputBox = document.getElementById("input-box");
4965
5661
  if (inputBox) {
4966
5662
  var start = inputBox.selectionStart || 0;
@@ -5065,6 +5761,7 @@
5065
5761
  tool: getPreferredTool(),
5066
5762
  queuedAt: Date.now()
5067
5763
  });
5764
+ persistCrossSessionQueue();
5068
5765
  renderCrossSessionQueue();
5069
5766
  }
5070
5767
 
@@ -5089,6 +5786,7 @@
5089
5786
  showToast(data.error, "error");
5090
5787
  // 失败回填队首,不丢消息
5091
5788
  state.crossSessionQueue.unshift(item);
5789
+ persistCrossSessionQueue();
5092
5790
  renderCrossSessionQueue();
5093
5791
  return null;
5094
5792
  }
@@ -5098,6 +5796,7 @@
5098
5796
  _queueLaunching = false;
5099
5797
  showToast((error && error.message) || "无法启动排队会话。", "error");
5100
5798
  state.crossSessionQueue.unshift(item);
5799
+ persistCrossSessionQueue();
5101
5800
  renderCrossSessionQueue();
5102
5801
  });
5103
5802
  }
@@ -5106,6 +5805,7 @@
5106
5805
  var idx = state.crossSessionQueue.findIndex(function(q) { return q.id === queueId; });
5107
5806
  if (idx < 0) return;
5108
5807
  var item = state.crossSessionQueue.splice(idx, 1)[0];
5808
+ persistCrossSessionQueue();
5109
5809
  renderCrossSessionQueue();
5110
5810
  // 立即发送不受 _queueLaunching 限制
5111
5811
  fetch("/api/commands", {
@@ -5123,12 +5823,18 @@
5123
5823
  .then(function(data) {
5124
5824
  if (data.error) {
5125
5825
  showToast(data.error, "error");
5826
+ state.crossSessionQueue.splice(idx, 0, item);
5827
+ persistCrossSessionQueue();
5828
+ renderCrossSessionQueue();
5126
5829
  return null;
5127
5830
  }
5128
5831
  return activateSession(data);
5129
5832
  })
5130
5833
  .catch(function(error) {
5131
5834
  showToast((error && error.message) || "无法启动排队会话。", "error");
5835
+ state.crossSessionQueue.splice(idx, 0, item);
5836
+ persistCrossSessionQueue();
5837
+ renderCrossSessionQueue();
5132
5838
  });
5133
5839
  }
5134
5840
 
@@ -5136,6 +5842,7 @@
5136
5842
  var idx = state.crossSessionQueue.findIndex(function(q) { return q.id === queueId; });
5137
5843
  if (idx < 0) return;
5138
5844
  state.crossSessionQueue.splice(idx, 1);
5845
+ persistCrossSessionQueue();
5139
5846
  renderCrossSessionQueue();
5140
5847
  if (state.crossSessionQueue.length === 0) {
5141
5848
  showToast("排队已清空。", "info");
@@ -5168,6 +5875,7 @@
5168
5875
 
5169
5876
  if (state.crossSessionQueue.length === 0) {
5170
5877
  if (container) container.remove();
5878
+ persistCrossSessionQueue();
5171
5879
  return;
5172
5880
  }
5173
5881
 
@@ -5239,6 +5947,7 @@
5239
5947
  if (e.target.closest("#queue-clear-all")) {
5240
5948
  e.preventDefault();
5241
5949
  state.crossSessionQueue = [];
5950
+ persistCrossSessionQueue();
5242
5951
  renderCrossSessionQueue();
5243
5952
  showToast("排队已清空。", "info");
5244
5953
  return;
@@ -5286,6 +5995,7 @@
5286
5995
  credentials: "same-origin",
5287
5996
  body: JSON.stringify({
5288
5997
  command: preferredTool,
5998
+ provider: preferredTool,
5289
5999
  cwd: defaultCwd,
5290
6000
  mode: mode,
5291
6001
  initialInput: value
@@ -5314,7 +6024,9 @@
5314
6024
  });
5315
6025
  })
5316
6026
  .catch(function(error) {
5317
- showToast((error && error.message) || "无法启动会话。", "error");
6027
+ showToast((error && error.message) || (preferredTool === "codex"
6028
+ ? "无法启动 Codex 会话。"
6029
+ : "无法启动 Claude 会话。"), "error");
5318
6030
  welcomeInput.placeholder = "输入你的问题,按 Enter 发送...";
5319
6031
  welcomeInput.disabled = false;
5320
6032
  });
@@ -5353,6 +6065,7 @@
5353
6065
  credentials: "same-origin",
5354
6066
  body: JSON.stringify({
5355
6067
  command: preferredTool,
6068
+ provider: preferredTool,
5356
6069
  cwd: defaultCwd,
5357
6070
  mode: mode,
5358
6071
  initialInput: value || undefined
@@ -5378,7 +6091,9 @@
5378
6091
  return loadOutput(data.id);
5379
6092
  })
5380
6093
  .catch(function(error) {
5381
- showToast((error && error.message) || "无法启动会话。", "error");
6094
+ showToast((error && error.message) || (preferredTool === "codex"
6095
+ ? "无法启动 Codex 会话。"
6096
+ : "无法启动 Claude 会话。"), "error");
5382
6097
  });
5383
6098
  }
5384
6099
 
@@ -5434,7 +6149,7 @@
5434
6149
 
5435
6150
  var inputBox = document.getElementById("input-box");
5436
6151
  var value = inputBox ? inputBox.value : "";
5437
- var selectedSession = state.sessions.find(function(session) { return session.id === state.selectedId; }) || null;
6152
+ var selectedSession = getSelectedSession();
5438
6153
  if (value) {
5439
6154
  console.log("[WAND] sendInputFromBox", {
5440
6155
  sessionId: state.selectedId,
@@ -5455,16 +6170,12 @@
5455
6170
  return postStructuredInput(value, inputBox, selectedSession);
5456
6171
  }
5457
6172
 
5458
- // Send text + Enter as a single call to avoid race conditions
5459
- var combinedInput = value + getControlInput("enter");
6173
+ var submitChunks = getTerminalSubmitChunks(selectedSession, value);
5460
6174
  var isOffline = !state.wsConnected;
5461
6175
 
5462
6176
  if (isOffline) {
5463
6177
  // Offline: queue for flush on reconnect, clear input immediately
5464
- if (state.pendingMessages.length >= 100) {
5465
- state.pendingMessages.shift();
5466
- }
5467
- state.pendingMessages.push(combinedInput);
6178
+ queueOfflineTerminalChunks(submitChunks);
5468
6179
  if (inputBox) {
5469
6180
  inputBox.value = "";
5470
6181
  autoResizeInput(inputBox);
@@ -5479,7 +6190,11 @@
5479
6190
  showToast("会话未就绪,将稍后重试。", "info");
5480
6191
  return null;
5481
6192
  }
5482
- return queueDirectInput(combinedInput, "enter_text").then(function() {
6193
+ var submitView = state.currentView;
6194
+ if (readySession && readySession.provider === "codex" && state.selectedId !== readySession.id) {
6195
+ throw new Error("Codex session changed before input send.");
6196
+ }
6197
+ return sendTerminalChunks(submitChunks, "enter_text", 0, submitView).then(function() {
5483
6198
  // Clear input only after the send succeeds
5484
6199
  if (inputBox && inputBox.value === value) {
5485
6200
  inputBox.value = "";
@@ -5502,58 +6217,32 @@
5502
6217
  showToast("会话不存在,请重新选择或新建会话。", "error");
5503
6218
  return Promise.resolve();
5504
6219
  }
5505
- if (session.structuredState && session.structuredState.inFlight && session.status === "running") {
5506
- // Queue the message for sending after current processing completes
5507
- if (state.structuredInputQueue.length >= 10) {
5508
- showToast("排队消息已满(最多 10 条),请等待当前消息处理完成。", "warning");
5509
- return Promise.resolve();
5510
- }
5511
- state.structuredInputQueue.push(input);
5512
- if (inputBox) {
5513
- inputBox.value = "";
5514
- autoResizeInput(inputBox);
5515
- }
5516
- setDraftValue("");
5517
- // Show the queued message in chat view with a "queued" marker
5518
- var queuedTurn = { role: "user", content: [{ type: "text", text: input, __queued: true }] };
5519
- var curMsgs = Array.isArray(state.currentMessages) ? state.currentMessages.slice() : [];
5520
- curMsgs.push(queuedTurn);
5521
- state.currentMessages = curMsgs;
5522
- // Also update session.messages so the queued turn survives WS updates
5523
- session.messages = curMsgs;
6220
+
6221
+ var isQueueing = !!(session.structuredState && session.structuredState.inFlight && session.status === "running");
6222
+ if (!isQueueing) {
6223
+ // Immediately render user message with thinking indicator
6224
+ var userTurn = { role: "user", content: [{ type: "text", text: input }] };
6225
+ var userMsgs = stripRenderOnlyStructuredMessages(Array.isArray(session.messages) ? session.messages.slice() : []);
6226
+ userMsgs.push(userTurn);
6227
+ var optimisticStructuredState = Object.assign({}, session.structuredState || {}, { inFlight: true });
6228
+ updateSessionSnapshot({
6229
+ id: session.id,
6230
+ status: "running",
6231
+ structuredState: optimisticStructuredState,
6232
+ });
6233
+ state.currentMessages = buildMessagesForRender(Object.assign({}, session, {
6234
+ status: "running",
6235
+ structuredState: optimisticStructuredState,
6236
+ }), userMsgs);
6237
+ updateInputHint("思考中…");
5524
6238
  renderChat(true);
5525
- showToast("已排队(第 " + state.structuredInputQueue.length + " 条),将在当前消息处理完成后自动发送。", "info");
5526
- updateStructuredQueueCounter();
5527
- return Promise.resolve();
5528
6239
  }
5529
6240
 
5530
- // Immediately render user message with thinking indicator
5531
- var userTurn = { role: "user", content: [{ type: "text", text: input }] };
5532
- var thinkingTurn = { role: "assistant", content: [{ type: "text", text: "", __processing: true }] };
5533
- var userMsgs = Array.isArray(session.messages) ? session.messages.slice() : [];
5534
- // Filter out __queued placeholders — they'll be re-appended after the new turns
5535
- userMsgs = userMsgs.filter(function(m) {
5536
- return !(m.role === "user" && m.content && m.content.some(function(b) { return b.__queued; }));
5537
- });
5538
- userMsgs.push(userTurn);
5539
- userMsgs.push(thinkingTurn);
5540
- // Re-append remaining queued messages after the current send
5541
- appendQueuedPlaceholders(userMsgs);
5542
- session.messages = userMsgs;
5543
- state.currentMessages = userMsgs;
5544
- // Mark inFlight optimistically to prevent double-send via WS updates
5545
- if (session.structuredState) {
5546
- session.structuredState.inFlight = true;
5547
- }
5548
- session.status = "running";
5549
6241
  if (inputBox) {
5550
6242
  inputBox.value = "";
5551
6243
  autoResizeInput(inputBox);
5552
6244
  }
5553
- // Keep send button enabled so user can queue more messages
5554
- updateInputHint("思考中…");
5555
6245
  setDraftValue("");
5556
- renderChat(true);
5557
6246
 
5558
6247
  return fetch("/api/structured-sessions/" + state.selectedId + "/messages", {
5559
6248
  method: "POST",
@@ -5568,27 +6257,29 @@
5568
6257
  }
5569
6258
  if (snapshot && snapshot.id) {
5570
6259
  updateSessionSnapshot(snapshot);
5571
- if (snapshot.messages && snapshot.messages.length > 0) {
5572
- state.currentMessages = snapshot.messages;
5573
- // Re-append queued user messages
5574
- appendQueuedPlaceholders(state.currentMessages);
5575
- }
6260
+ var refreshedSession = state.sessions.find(function(s) { return s.id === snapshot.id; }) || snapshot;
6261
+ state.currentMessages = buildMessagesForRender(refreshedSession, getPreferredMessages(refreshedSession, snapshot.output, false));
5576
6262
  renderChat(true);
5577
- updateInputHint("Enter 发送 · Shift+Enter 换行");
6263
+ if (isQueueing) {
6264
+ var queuedCount = getStructuredQueuedInputs(refreshedSession).length;
6265
+ showToast("已排队(第 " + queuedCount + " 条),将在当前消息处理完成后自动发送。", "info");
6266
+ } else {
6267
+ updateInputHint("Enter 发送 · Shift+Enter 换行");
6268
+ }
5578
6269
  }
5579
6270
  })
5580
6271
  .catch(function(error) {
5581
- // Reset inFlight so user can send again
5582
- if (session.structuredState) {
5583
- session.structuredState.inFlight = false;
5584
- }
5585
- // Clear remaining queued messages since the session is likely broken
5586
- if (state.structuredInputQueue.length > 0) {
5587
- var dropped = state.structuredInputQueue.length;
5588
- state.structuredInputQueue = [];
5589
- updateStructuredQueueCounter();
5590
- showToast("发送失败,已清空 " + dropped + " 条排队消息。", "error");
5591
- } else {
6272
+ updateSessionSnapshot({
6273
+ id: session.id,
6274
+ structuredState: Object.assign({}, session.structuredState || {}, { inFlight: false }),
6275
+ });
6276
+ var message = (error && error.message) || "";
6277
+ var isTransientAbort =
6278
+ message === "Failed to fetch" ||
6279
+ message === "NetworkError when attempting to fetch resource." ||
6280
+ message === "Load failed" ||
6281
+ /aborted|aborterror|networkerror|failed to fetch/i.test(message);
6282
+ if (!isTransientAbort) {
5592
6283
  showToast((error && error.message) || "无法发送结构化消息。", "error");
5593
6284
  }
5594
6285
  updateInputHint("Enter 发送 · Shift+Enter 换行");
@@ -5602,7 +6293,7 @@
5602
6293
 
5603
6294
  function updateStructuredQueueCounter() {
5604
6295
  var counter = document.getElementById("queue-counter");
5605
- var count = state.structuredInputQueue.length;
6296
+ var count = getSelectedStructuredQueuedInputs().length;
5606
6297
  if (counter) {
5607
6298
  counter.textContent = "队列: " + count;
5608
6299
  if (count > 0) {
@@ -5615,56 +6306,48 @@
5615
6306
 
5616
6307
  // Append queued user message placeholders to currentMessages so they
5617
6308
  // remain visible across WS updates and re-renders.
5618
- function appendQueuedPlaceholders(messages) {
5619
- if (state.structuredInputQueue.length === 0) return messages;
5620
- for (var qi = 0; qi < state.structuredInputQueue.length; qi++) {
5621
- messages.push({ role: "user", content: [{ type: "text", text: state.structuredInputQueue[qi], __queued: true }] });
6309
+ function buildMessagesForRender(session, messages) {
6310
+ var sanitized = Array.isArray(messages) ? stripRenderOnlyStructuredMessages(messages) : [];
6311
+ var base = Array.isArray(sanitized) ? sanitized.slice() : [];
6312
+ if (!session || session.sessionKind !== "structured") {
6313
+ return base;
5622
6314
  }
5623
- return messages;
6315
+ var queued = getStructuredQueuedInputs(session);
6316
+ if (queued && queued.length > 0) {
6317
+ for (var qi = 0; qi < queued.length; qi++) {
6318
+ base.push({ role: "user", content: [{ type: "text", text: queued[qi], __queued: true }] });
6319
+ }
6320
+ }
6321
+ if (session.structuredState && session.structuredState.inFlight) {
6322
+ var last = base[base.length - 1];
6323
+ if (!last || last.role !== "assistant") {
6324
+ base.push({ role: "assistant", content: [{ type: "text", text: "", __processing: true }] });
6325
+ }
6326
+ }
6327
+ return base;
5624
6328
  }
5625
6329
 
6330
+
5626
6331
  function flushStructuredInputQueue() {
5627
- if (state.structuredInputQueue.length === 0) return;
5628
6332
  var session = state.sessions.find(function(s) { return s.id === state.selectedId; });
5629
- if (!session || session.sessionKind !== "structured") {
5630
- state.structuredInputQueue = [];
5631
- updateStructuredQueueCounter();
5632
- return;
5633
- }
5634
- // Only flush if not inFlight
5635
- if (session.structuredState && session.structuredState.inFlight) return;
5636
- var nextInput = state.structuredInputQueue.shift();
6333
+ syncStructuredQueueFromSession(session);
5637
6334
  updateStructuredQueueCounter();
5638
- if (nextInput) {
5639
- // Remove __queued marker from the matching user turn already in chat.
5640
- // postStructuredInput will find it's not inFlight now and do the
5641
- // normal send path, which re-adds the user turn + thinking turn.
5642
- // So we need to remove the queued placeholder first to avoid duplicates.
5643
- var msgs = Array.isArray(state.currentMessages) ? state.currentMessages : [];
5644
- for (var qi = msgs.length - 1; qi >= 0; qi--) {
5645
- var qm = msgs[qi];
5646
- if (qm.role === "user" && qm.content && qm.content.some(function(b) {
5647
- return b.__queued && b.text === nextInput;
5648
- })) {
5649
- msgs.splice(qi, 1);
5650
- break;
5651
- }
5652
- }
5653
- state.currentMessages = msgs;
5654
- if (session.messages) session.messages = msgs;
5655
- // Pass null for inputBox to avoid clearing user's current typing
5656
- postStructuredInput(nextInput, null, session);
5657
- }
5658
6335
  }
5659
6336
 
5660
6337
  function getInputErrorMessage(error) {
6338
+ var selectedSession = getSelectedSession();
6339
+ var isCodex = selectedSession && selectedSession.provider === "codex";
5661
6340
  if (error && (error.errorCode === "SESSION_NOT_RUNNING" || error.errorCode === "SESSION_NO_PTY")) {
5662
- return "会话已结束;若存在 Claude 历史会话,将在你下次发送消息时自动恢复。";
6341
+ return isCodex
6342
+ ? "Codex 会话已结束,请新建会话后继续。"
6343
+ : "会话已结束;若存在 Claude 历史会话,将在你下次发送消息时自动恢复。";
5663
6344
  }
5664
6345
  if (error && error.errorCode === "SESSION_NOT_FOUND") {
5665
6346
  return "会话不存在,请重新选择或新建会话。";
5666
6347
  }
5667
- return (error && error.message) || "会话暂不可用;若存在 Claude 历史会话,将自动尝试恢复。";
6348
+ return (error && error.message) || (isCodex
6349
+ ? "Codex 会话暂不可用,请检查终端视图或新建会话。"
6350
+ : "会话暂不可用;若存在 Claude 历史会话,将自动尝试恢复。");
5668
6351
  }
5669
6352
 
5670
6353
  function buildInputError(payload) {
@@ -5704,7 +6387,7 @@
5704
6387
  }
5705
6388
 
5706
6389
  function canAutoResumeSession(session) {
5707
- return !!(session && session.status === "exited" && session.claudeSessionId && hasRealConversationHistory(session));
6390
+ return !!(session && session.provider === "claude" && session.status === "exited" && session.claudeSessionId && hasRealConversationHistory(session));
5708
6391
  }
5709
6392
 
5710
6393
  function ensureSessionReadyForInput(session, errorEl) {
@@ -5735,11 +6418,48 @@
5735
6418
  });
5736
6419
  }
5737
6420
 
5738
- function queueDirectInput(input, shortcutKey) {
6421
+ function getTerminalSubmitChunks(session, text) {
6422
+ if (session && session.provider === "codex") {
6423
+ return [text, String.fromCharCode(13)];
6424
+ }
6425
+ return [text + String.fromCharCode(13)];
6426
+ }
6427
+
6428
+ function sendTerminalChunks(chunks, shortcutKey, delayMs, viewOverride) {
6429
+ var sequence = Array.isArray(chunks) ? chunks.filter(function(chunk) { return !!chunk; }) : [];
6430
+ if (sequence.length === 0) {
6431
+ return Promise.resolve();
6432
+ }
6433
+ var delay = typeof delayMs === "number" ? delayMs : 0;
6434
+ return sequence.reduce(function(promise, chunk, index) {
6435
+ return promise.then(function() {
6436
+ if (index > 0 && delay > 0) {
6437
+ return new Promise(function(resolve) {
6438
+ setTimeout(resolve, delay);
6439
+ }).then(function() {
6440
+ return queueDirectInput(chunk, index === sequence.length - 1 ? shortcutKey : undefined, viewOverride);
6441
+ });
6442
+ }
6443
+ return queueDirectInput(chunk, index === sequence.length - 1 ? shortcutKey : undefined, viewOverride);
6444
+ });
6445
+ }, Promise.resolve());
6446
+ }
6447
+
6448
+ function queueOfflineTerminalChunks(chunks) {
6449
+ var sequence = Array.isArray(chunks) ? chunks.filter(function(chunk) { return !!chunk; }) : [];
6450
+ sequence.forEach(function(chunk) {
6451
+ if (state.pendingMessages.length >= 100) {
6452
+ state.pendingMessages.shift();
6453
+ }
6454
+ state.pendingMessages.push(chunk);
6455
+ });
6456
+ }
6457
+
6458
+ function queueDirectInput(input, shortcutKey, viewOverride) {
5739
6459
  if (!input || !state.selectedId) return Promise.resolve();
5740
6460
  state.messageQueue.push(input);
5741
6461
  state.inputQueue = state.inputQueue.then(function() {
5742
- return postInput(input, shortcutKey).finally(function() {
6462
+ return postInput(input, shortcutKey, viewOverride).finally(function() {
5743
6463
  var idx = state.messageQueue.indexOf(input);
5744
6464
  if (idx > -1) state.messageQueue.splice(idx, 1);
5745
6465
  scheduleMobileDomUpdate();
@@ -5748,8 +6468,9 @@
5748
6468
  return state.inputQueue;
5749
6469
  }
5750
6470
 
5751
- function postInput(input, shortcutKey) {
6471
+ function postInput(input, shortcutKey, viewOverride) {
5752
6472
  if (!state.selectedId) return Promise.resolve();
6473
+ var effectiveView = viewOverride || state.currentView;
5753
6474
 
5754
6475
  // Pre-check: don't send if session is not running
5755
6476
  if (!isSelectedSessionRunning()) {
@@ -5788,7 +6509,7 @@
5788
6509
  console.log("[wand] postInput: sending", {
5789
6510
  sessionId: state.selectedId,
5790
6511
  inputLength: input.length,
5791
- view: state.currentView,
6512
+ view: effectiveView,
5792
6513
  wsConnected: state.wsConnected
5793
6514
  });
5794
6515
 
@@ -5796,7 +6517,7 @@
5796
6517
  method: "POST",
5797
6518
  headers: { "Content-Type": "application/json" },
5798
6519
  credentials: "same-origin",
5799
- body: JSON.stringify({ input: input, view: state.currentView, shortcutKey: shortcutKey || undefined })
6520
+ body: JSON.stringify({ input: input, view: effectiveView, shortcutKey: shortcutKey || undefined })
5800
6521
  })
5801
6522
  .then(function(res) {
5802
6523
  if (!res.ok) {
@@ -5834,6 +6555,14 @@
5834
6555
  return queueDirectInput(input);
5835
6556
  }
5836
6557
 
6558
+ function getSelectedSession() {
6559
+ return state.sessions.find(function(session) { return session.id === state.selectedId; }) || null;
6560
+ }
6561
+
6562
+ function getTerminalSubmitSequence(session) {
6563
+ return session && session.provider === "codex" ? "\n" : String.fromCharCode(13);
6564
+ }
6565
+
5837
6566
  function isTerminalInteractionAvailable() {
5838
6567
  return !!state.selectedId && state.currentView === "terminal";
5839
6568
  }
@@ -6004,6 +6733,9 @@
6004
6733
  function updateInteractiveControls() {
6005
6734
  var selectedSession = state.sessions.find(function(session) { return session.id === state.selectedId; });
6006
6735
  var structured = isStructuredSession(selectedSession);
6736
+ var isCodex = selectedSession && selectedSession.provider === "codex";
6737
+ var isRunning = !!selectedSession && selectedSession.status === "running";
6738
+ var composer = document.getElementById("input-box");
6007
6739
  // Update both toggle buttons (topbar and terminal-header)
6008
6740
  var toggles = ["terminal-interactive-toggle-top"];
6009
6741
  toggles.forEach(function(id) {
@@ -6019,7 +6751,27 @@
6019
6751
  var expandedRow = document.querySelector(".inline-shortcuts-expanded-row");
6020
6752
  if (expandedRow) expandedRow.classList.toggle("hidden", structured || state.currentView !== "terminal");
6021
6753
  var inputHint = document.querySelector(".input-hint");
6022
- if (inputHint) inputHint.classList.toggle("hidden", structured ? true : state.currentView === "terminal");
6754
+ if (inputHint) {
6755
+ inputHint.classList.toggle("hidden", structured ? true : state.currentView === "terminal");
6756
+ if (!structured && selectedSession) {
6757
+ inputHint.textContent = isCodex
6758
+ ? "Enter 发送 · chat 为解析视图,terminal 为原始输出"
6759
+ : "Enter 发送 · Shift+Enter 换行";
6760
+ }
6761
+ }
6762
+ var disableStructuredInput = !!selectedSession && structured && isCodex && !isRunning;
6763
+ if (composer) {
6764
+ composer.placeholder = getComposerPlaceholder(selectedSession, state.terminalInteractive);
6765
+ composer.disabled = structured ? disableStructuredInput : (!!selectedSession && !isRunning);
6766
+ composer.setAttribute("aria-disabled", composer.disabled ? "true" : "false");
6767
+ }
6768
+ var sendBtn = document.getElementById("send-input-button");
6769
+ if (sendBtn) {
6770
+ sendBtn.disabled = structured ? disableStructuredInput : (!!selectedSession && !isRunning);
6771
+ sendBtn.setAttribute("title", isCodex
6772
+ ? (isRunning ? "发送给 Codex" : "Codex 会话已结束")
6773
+ : (structured ? "发送" : (!selectedSession || isRunning ? "发送" : "会话已结束")));
6774
+ }
6023
6775
  var container = document.getElementById("output");
6024
6776
  if (container) container.classList.toggle("interactive", !structured && state.terminalInteractive);
6025
6777
  }
@@ -7345,6 +8097,10 @@
7345
8097
  }
7346
8098
 
7347
8099
  function focusInputFromTap() {
8100
+ if (state.terminalInteractive) {
8101
+ focusTerminalContainer();
8102
+ return;
8103
+ }
7348
8104
  var inputBox = document.getElementById('input-box');
7349
8105
  if (!inputBox || !state.selectedId || document.activeElement === inputBox) return;
7350
8106
  focusInputWithSelection(inputBox);
@@ -7355,6 +8111,10 @@
7355
8111
  if (!output) return;
7356
8112
  output.setAttribute("tabindex", "0");
7357
8113
  output.focus();
8114
+ var terminalTextarea = output.querySelector(".xterm-helper-textarea");
8115
+ if (terminalTextarea && typeof terminalTextarea.focus === "function") {
8116
+ terminalTextarea.focus();
8117
+ }
7358
8118
  }
7359
8119
 
7360
8120
  // Mobile keyboard handling
@@ -7450,7 +8210,7 @@
7450
8210
 
7451
8211
  function initTerminalResizeHandle() {
7452
8212
  // 终端容器拖动调整大小功能
7453
- var container = document.getElementById("terminal-container");
8213
+ var container = document.getElementById("output");
7454
8214
  if (!container) return;
7455
8215
 
7456
8216
  // 创建拖动手柄
@@ -7747,27 +8507,19 @@
7747
8507
  if (msg.data.messages) {
7748
8508
  snapshot.messages = msg.data.messages;
7749
8509
  }
8510
+ if (msg.data.queuedMessages) {
8511
+ snapshot.queuedMessages = msg.data.queuedMessages;
8512
+ }
8513
+ if (msg.data.structuredState) {
8514
+ snapshot.structuredState = msg.data.structuredState;
8515
+ }
7750
8516
  updateSessionSnapshot(snapshot);
7751
8517
  if (msg.sessionId === state.selectedId) {
7752
- state.currentMessages = getPreferredMessages(snapshot, msg.data.output, false);
7753
- // Re-append queued user messages that haven't been sent yet
7754
- if (msg.data.sessionKind === 'structured') {
7755
- appendQueuedPlaceholders(state.currentMessages);
7756
- }
7757
- // Structured session with inFlight: keep __processing placeholder
7758
- // so the loading indicator stays visible until assistant content arrives
7759
- if (msg.data.sessionKind === 'structured') {
7760
- var outSession = state.sessions.find(function(s) { return s.id === msg.sessionId; });
7761
- if (outSession && outSession.structuredState && outSession.structuredState.inFlight) {
7762
- var lastCur = state.currentMessages[state.currentMessages.length - 1];
7763
- if (!lastCur || lastCur.role !== 'assistant') {
7764
- state.currentMessages.push({ role: "assistant", content: [{ type: "text", text: "", __processing: true }] });
7765
- }
7766
- }
7767
- }
8518
+ var updatedSession = state.sessions.find(function(s) { return s.id === msg.sessionId; }) || snapshot;
8519
+ state.currentMessages = buildMessagesForRender(updatedSession, getPreferredMessages(updatedSession, msg.data.output, false));
7768
8520
  updateTaskDisplay();
7769
8521
  // Structured sessions: render immediately for responsiveness
7770
- if (msg.data.sessionKind === 'structured') {
8522
+ if (updatedSession.sessionKind === 'structured' || msg.data.sessionKind === 'structured') {
7771
8523
  renderChat();
7772
8524
  } else {
7773
8525
  scheduleChatRender();
@@ -7780,10 +8532,13 @@
7780
8532
  if (msg.data.chunk && (!state.terminalSessionId || state.terminalSessionId === msg.sessionId)) {
7781
8533
  // Fast path: write chunk directly to avoid full-output comparison
7782
8534
  // which can trigger terminal.reset() and cause screen flicker.
8535
+ state.terminalLiveStreamSessions[msg.sessionId] = true;
7783
8536
  state.terminal.write(msg.data.chunk);
7784
8537
  state.terminalSessionId = msg.sessionId;
7785
8538
  if (msg.data.output) {
7786
8539
  state.terminalOutput = normalizeTerminalOutput(msg.data.output);
8540
+ } else {
8541
+ state.terminalOutput = normalizeTerminalOutput((state.terminalOutput || "") + msg.data.chunk);
7787
8542
  }
7788
8543
  maybeScrollTerminalToBottom("output");
7789
8544
  updateTerminalJumpToBottomButton();
@@ -7811,6 +8566,9 @@
7811
8566
  if (msg.data && msg.data.structuredState) {
7812
8567
  endedSnapshot.structuredState = msg.data.structuredState;
7813
8568
  }
8569
+ if (msg.data && msg.data.queuedMessages) {
8570
+ endedSnapshot.queuedMessages = msg.data.queuedMessages;
8571
+ }
7814
8572
  updateSessionSnapshot(endedSnapshot);
7815
8573
 
7816
8574
  if (msg.sessionId === state.selectedId) {
@@ -7846,22 +8604,24 @@
7846
8604
  }
7847
8605
 
7848
8606
  // Clear stale queued inputs for PTY sessions.
7849
- // For structured sessions, each "ended" means one turn completed (not
7850
- // the session terminated), so we must NOT clear the structured queue —
7851
- // instead, flush the next queued message.
8607
+ // For structured sessions, the queue is now managed by the server snapshot.
7852
8608
  state.messageQueue = [];
7853
8609
  state.pendingMessages = [];
7854
8610
 
7855
8611
  var endedSessionObj = state.sessions.find(function(s) { return s.id === msg.sessionId; });
7856
- var isStructuredEnded = endedSessionObj && endedSessionObj.sessionKind === "structured";
8612
+ var selectedSessionObj = msg.sessionId === state.selectedId
8613
+ ? state.sessions.find(function(s) { return s.id === state.selectedId; })
8614
+ : null;
8615
+ var isStructuredEnded = !!(
8616
+ (endedSessionObj && endedSessionObj.sessionKind === "structured") ||
8617
+ (selectedSessionObj && selectedSessionObj.sessionKind === "structured")
8618
+ );
7857
8619
 
7858
- if (isStructuredEnded && msg.sessionId === state.selectedId &&
7859
- state.structuredInputQueue.length > 0) {
7860
- // Structured session turn completed — flush next queued message
7861
- setTimeout(flushStructuredInputQueue, 50);
8620
+ if (isStructuredEnded && msg.sessionId === state.selectedId) {
8621
+ flushStructuredInputQueue();
7862
8622
  } else if (!isStructuredEnded) {
7863
- // PTY session ended — clear structured queue too
7864
8623
  state.structuredInputQueue = [];
8624
+ clearStructuredQueuePersistence(state.selectedId);
7865
8625
  updateStructuredQueueCounter();
7866
8626
  }
7867
8627
 
@@ -7896,7 +8656,13 @@
7896
8656
  // Initial state for subscribed session (after reconnect or subscription)
7897
8657
  if (msg.sessionId === state.selectedId && msg.data) {
7898
8658
  if (chatRenderTimer) { clearTimeout(chatRenderTimer); chatRenderTimer = null; }
7899
- updateTerminalOutput(msg.data.output || "", msg.sessionId, "replace");
8659
+ updateSessionSnapshot(msg.data);
8660
+ var initSession = state.sessions.find(function(s) { return s.id === msg.sessionId; });
8661
+ state.currentMessages = buildMessagesForRender(initSession || msg.data, getPreferredMessages(initSession || msg.data, msg.data.output, false));
8662
+ renderChat(true);
8663
+ updateTaskDisplay();
8664
+ updateApprovalStats();
8665
+ updateTerminalOutput(msg.data.output || "", msg.sessionId, "append");
7900
8666
  // Ensure terminal is properly fitted after receiving initial data
7901
8667
  scheduleTerminalResize(true);
7902
8668
  }
@@ -7913,7 +8679,6 @@
7913
8679
  break;
7914
8680
  case 'status':
7915
8681
  if (msg.sessionId && msg.data) {
7916
- console.log('[WAND] ws status', msg.sessionId, JSON.stringify(msg.data));
7917
8682
  var statusUpdate = { id: msg.sessionId };
7918
8683
  if (Object.prototype.hasOwnProperty.call(msg.data, 'status')) {
7919
8684
  statusUpdate.status = msg.data.status;
@@ -8026,8 +8791,14 @@
8026
8791
  var permissionLabel = document.getElementById("permission-actions-label");
8027
8792
  if (!taskEl) return;
8028
8793
  var selectedSession = state.sessions.find(function(s) { return s.id === state.selectedId; });
8794
+ if (selectedSession && selectedSession.provider === "codex") {
8795
+ if (permissionActionsEl) permissionActionsEl.classList.add("hidden");
8796
+ taskEl.classList.remove("permission-blocked");
8797
+ }
8029
8798
  var pendingEscalation = selectedSession && selectedSession.pendingEscalation ? selectedSession.pendingEscalation : null;
8030
- var isBlocked = pendingEscalation || (selectedSession && selectedSession.permissionBlocked);
8799
+ var isBlocked = selectedSession && selectedSession.provider !== "codex"
8800
+ ? (pendingEscalation || selectedSession.permissionBlocked)
8801
+ : false;
8031
8802
 
8032
8803
  if (isBlocked) {
8033
8804
  var isAutoApprove = selectedSession && selectedSession.autoApprovePermissions;
@@ -8161,6 +8932,11 @@
8161
8932
 
8162
8933
  function toggleAutoApprove() {
8163
8934
  if (!state.selectedId) return;
8935
+ var selectedSession = state.sessions.find(function(s) { return s.id === state.selectedId; });
8936
+ if (selectedSession && selectedSession.provider === "codex") {
8937
+ showToast("Codex 会话固定以 full-access PTY 启动,不支持切换自动批准。", "info");
8938
+ return;
8939
+ }
8164
8940
  var toggle = document.getElementById("auto-approve-toggle");
8165
8941
  if (toggle) toggle.style.opacity = "0.5";
8166
8942
  fetch("/api/sessions/" + encodeURIComponent(state.selectedId) + "/toggle-auto-approve", {
@@ -8190,6 +8966,12 @@
8190
8966
  var toggle = document.getElementById("auto-approve-toggle");
8191
8967
  if (!toggle) return;
8192
8968
  var selectedSession = state.sessions.find(function(s) { return s.id === state.selectedId; });
8969
+ if (selectedSession && selectedSession.provider === "codex") {
8970
+ toggle.className = "auto-approve-indicator active";
8971
+ toggle.title = "Codex 固定以 full-access PTY 启动,不支持切换自动批准";
8972
+ toggle.textContent = "🛡 Codex 固定全权限";
8973
+ return;
8974
+ }
8193
8975
  var enabled = selectedSession && selectedSession.autoApprovePermissions;
8194
8976
  if (enabled) {
8195
8977
  toggle.className = "auto-approve-indicator active";
@@ -8222,6 +9004,10 @@
8222
9004
  }
8223
9005
  applyCurrentView();
8224
9006
  reconcileInteractiveState();
9007
+ var selectedSession = getSelectedSession();
9008
+ if (selectedSession) {
9009
+ state.currentMessages = buildMessagesForRender(selectedSession, getPreferredMessages(selectedSession, selectedSession.output, true));
9010
+ }
8225
9011
  updateTerminalJumpToBottomButton();
8226
9012
  if (state.currentView === "terminal") {
8227
9013
  state.terminalViewportSize = { width: 0, height: 0 };
@@ -8260,10 +9046,7 @@
8260
9046
  // Re-parse messages from the latest session output (fallback for edge cases)
8261
9047
  var selectedSession = state.sessions.find(function(s) { return s.id === state.selectedId; });
8262
9048
  if (selectedSession) {
8263
- state.currentMessages = getPreferredMessages(selectedSession, selectedSession.output, true);
8264
- if (selectedSession.sessionKind === "structured") {
8265
- appendQueuedPlaceholders(state.currentMessages);
8266
- }
9049
+ state.currentMessages = buildMessagesForRender(selectedSession, getPreferredMessages(selectedSession, selectedSession.output, true));
8267
9050
  }
8268
9051
  renderChat();
8269
9052
  }, 30);
@@ -8353,6 +9136,26 @@
8353
9136
  return systemInfo;
8354
9137
  }
8355
9138
 
9139
+ function ensureChatMessagesContainer(chatOutput) {
9140
+ if (!chatOutput) return null;
9141
+ var chatMessages = chatOutput.querySelector(".chat-messages");
9142
+ if (chatMessages) return chatMessages;
9143
+ chatMessages = document.createElement("div");
9144
+ chatMessages.className = "chat-messages";
9145
+ chatOutput.appendChild(chatMessages);
9146
+ return chatMessages;
9147
+ }
9148
+
9149
+ function renderChatEmptyState(chatOutput, html) {
9150
+ var chatMessages = ensureChatMessagesContainer(chatOutput);
9151
+ if (!chatMessages) return null;
9152
+ chatMessages.innerHTML = html;
9153
+ bindChatScrollListener();
9154
+ updateChatFollowToggleButton();
9155
+ updateChatJumpToBottomButton();
9156
+ return chatMessages;
9157
+ }
9158
+
8356
9159
  function doRenderChat(forceFullRender) {
8357
9160
  var chatOutput = document.getElementById("chat-output");
8358
9161
  if (!chatOutput) return;
@@ -8360,7 +9163,7 @@
8360
9163
  var selectedSession = state.sessions.find(function(s) { return s.id === state.selectedId; });
8361
9164
  if (!selectedSession) {
8362
9165
  if (state.lastRenderedEmpty !== "none") {
8363
- chatOutput.innerHTML = '<div class="empty-state"><strong>未选择会话</strong><br>点击上方「新对话」开始你的第一次对话。</div>';
9166
+ renderChatEmptyState(chatOutput, '<div class="empty-state"><strong>未选择会话</strong><br>点击上方「新对话」开始你的第一次对话。</div>');
8364
9167
  state.lastRenderedEmpty = "none";
8365
9168
  state.lastRenderedMsgCount = 0;
8366
9169
  }
@@ -8371,7 +9174,7 @@
8371
9174
 
8372
9175
  if (messages.length === 0) {
8373
9176
  if (state.lastRenderedEmpty !== "empty") {
8374
- chatOutput.innerHTML = '<div class="empty-state"><strong>对话已开始</strong><br>在下方输入框发送消息,Claude 会自动回复。</div>';
9177
+ renderChatEmptyState(chatOutput, '<div class="empty-state"><strong>对话已开始</strong><br>在下方输入框发送消息,Claude 会自动回复。</div>');
8375
9178
  state.lastRenderedEmpty = "empty";
8376
9179
  state.lastRenderedMsgCount = 0;
8377
9180
  }
@@ -8424,12 +9227,8 @@
8424
9227
  state.lastRenderedMsgCount = msgCount;
8425
9228
  state.lastRenderedHash = outputHash;
8426
9229
 
8427
- var chatMessages = chatOutput.querySelector(".chat-messages");
8428
- if (!chatMessages) {
8429
- // First render - create container
8430
- chatOutput.innerHTML = '<div class="chat-messages"></div>';
8431
- chatMessages = chatOutput.querySelector(".chat-messages");
8432
- }
9230
+ var chatMessages = ensureChatMessagesContainer(chatOutput);
9231
+ if (!chatMessages) return;
8433
9232
 
8434
9233
  var existingCount = chatMessages.querySelectorAll(".chat-message").length;
8435
9234
  // Full render when: forced, no existing messages, or message count decreased/changed
@@ -8447,7 +9246,7 @@
8447
9246
  for (var i = 0; i < reversedMessages.length; i++) {
8448
9247
  var msg = reversedMessages[i];
8449
9248
  var originalIndex = msgCount - 1 - i; // Original index in messages array
8450
-
9249
+
8451
9250
  // Find system info for this message position
8452
9251
  var sysInfo = null;
8453
9252
  for (var j = 0; j < systemInfo.length; j++) {
@@ -8456,7 +9255,7 @@
8456
9255
  break;
8457
9256
  }
8458
9257
  }
8459
-
9258
+
8460
9259
  // Render system info card if exists
8461
9260
  if (sysInfo) {
8462
9261
  html += '<div class="chat-message system-info">' +
@@ -8466,21 +9265,30 @@
8466
9265
  '</div>' +
8467
9266
  '</div>';
8468
9267
  }
8469
-
9268
+
8470
9269
  // Render message
8471
- html += renderChatMessage(msg, roundUsageByIndex[originalIndex] || null);
9270
+ html += renderChatMessage(msg, roundUsageByIndex[originalIndex] || null, originalIndex);
8472
9271
  }
8473
-
9272
+
8474
9273
  chatMessages.innerHTML = html;
8475
9274
  attachAllCopyHandlers(chatMessages);
9275
+ bindChatScrollListener();
9276
+ applyPersistedExpandState(chatMessages);
8476
9277
  // Only expand the single newest tool card (first chat-message = newest due to column-reverse)
8477
9278
  var firstMsg = chatMessages.querySelector(".chat-message:not(.system-info)");
8478
9279
  if (firstMsg) {
8479
9280
  var cards = firstMsg.querySelectorAll(".tool-use-card");
8480
9281
  if (cards.length > 0) {
8481
- cards[0].classList.remove("collapsed");
9282
+ var firstCard = cards[0];
9283
+ var firstCardKey = getElementExpandKey(firstCard);
9284
+ if (getPersistedExpandState(firstCardKey) === null) {
9285
+ firstCard.classList.remove("collapsed");
9286
+ }
8482
9287
  for (var ci = 1; ci < cards.length; ci++) {
8483
- cards[ci].classList.add("collapsed");
9288
+ var cardKey = getElementExpandKey(cards[ci]);
9289
+ if (getPersistedExpandState(cardKey) === null) {
9290
+ cards[ci].classList.add("collapsed");
9291
+ }
8484
9292
  }
8485
9293
  }
8486
9294
  }
@@ -8495,6 +9303,8 @@
8495
9303
  function collapseOldToolCards(container, newEls) {
8496
9304
  var allCards = container.querySelectorAll(".tool-use-card");
8497
9305
  allCards.forEach(function(c) {
9306
+ var cardKey = getElementExpandKey(c);
9307
+ if (getPersistedExpandState(cardKey) !== null) return;
8498
9308
  // Keep expanded if this card is inside a newly added message
8499
9309
  if (newEls) {
8500
9310
  for (var i = 0; i < newEls.length; i++) {
@@ -8558,7 +9368,7 @@
8558
9368
  for (var i = 0; i < newMessages.length; i++) {
8559
9369
  var div = document.createElement("div");
8560
9370
  var nmOrigIdx = existingCount + (newMessages.length - 1 - i);
8561
- div.innerHTML = renderChatMessage(newMessages[i], roundUsageByIndex[nmOrigIdx] || null);
9371
+ div.innerHTML = renderChatMessage(newMessages[i], roundUsageByIndex[nmOrigIdx] || null, nmOrigIdx);
8562
9372
  var el = div.firstElementChild;
8563
9373
  if (el) {
8564
9374
  el.classList.add("animate-in");
@@ -8567,7 +9377,9 @@
8567
9377
  }
8568
9378
  }
8569
9379
  chatMessages.insertBefore(fragment, chatMessages.firstChild);
9380
+ bindChatScrollListener();
8570
9381
  attachAllCopyHandlers(chatMessages);
9382
+ applyPersistedExpandState(chatMessages);
8571
9383
  // Collapse all existing cards; new cards (with animate-in) stay expanded
8572
9384
  collapseOldToolCards(chatMessages, insertedEls);
8573
9385
  // Scroll to bottom (newest message) - column-reverse: scrollTop=0 is visual bottom
@@ -8588,7 +9400,7 @@
8588
9400
  var currentEl = existingEls[mi];
8589
9401
  var tmpWrap = document.createElement("div");
8590
9402
  var srOrigIdx = reversedMessages.length - 1 - mi;
8591
- tmpWrap.innerHTML = renderChatMessage(reversedMessages[mi], roundUsageByIndex[srOrigIdx] || null);
9403
+ tmpWrap.innerHTML = renderChatMessage(reversedMessages[mi], roundUsageByIndex[srOrigIdx] || null, srOrigIdx);
8592
9404
  var replacementEl = tmpWrap.firstElementChild;
8593
9405
  if (!replacementEl) continue;
8594
9406
  if (currentEl.innerHTML !== replacementEl.innerHTML || currentEl.className !== replacementEl.className) {
@@ -8606,6 +9418,8 @@
8606
9418
  fullRenderChat();
8607
9419
  }
8608
9420
  if (replacedAny) {
9421
+ bindChatScrollListener();
9422
+ applyPersistedExpandState(chatMessages);
8609
9423
  requestAnimationFrame(function() {
8610
9424
  smartScrollToBottom(chatMessages);
8611
9425
  });
@@ -8613,6 +9427,8 @@
8613
9427
  var allCards = chatMessages.querySelectorAll(".tool-use-card");
8614
9428
  var newestCard = null;
8615
9429
  allCards.forEach(function(c) {
9430
+ var cardKey = getElementExpandKey(c);
9431
+ if (getPersistedExpandState(cardKey) !== null) return;
8616
9432
  if (newestMsgEl && newestMsgEl.contains(c)) {
8617
9433
  if (!newestCard) newestCard = c;
8618
9434
  else c.classList.add("collapsed");
@@ -8635,14 +9451,15 @@
8635
9451
  // Smart scroll: only auto-scroll if user is near bottom
8636
9452
  // column-reverse: scrollTop near 0 = visual bottom (newest messages)
8637
9453
  function smartScrollToBottom(container) {
8638
- var chatMsgs = container.querySelector ? container.querySelector(".chat-messages") : container;
8639
- if (!chatMsgs) chatMsgs = container;
8640
- var threshold = 200;
8641
- // column-reverse: scrollTop=0 is the visual bottom; positive = scrolled up
8642
- var isNearBottom = chatMsgs.scrollTop < threshold;
8643
- if (isNearBottom) {
8644
- chatMsgs.scrollTop = 0;
9454
+ if (!state.chatAutoFollow) {
9455
+ updateChatJumpToBottomButton();
9456
+ return;
8645
9457
  }
9458
+ var chatMsgs = container && container.classList && container.classList.contains("chat-messages")
9459
+ ? container
9460
+ : getChatScrollElement();
9461
+ if (!chatMsgs || !chatMsgs.isConnected) return;
9462
+ scrollChatToBottom(false);
8646
9463
  }
8647
9464
 
8648
9465
  // --- Todo progress bar ---
@@ -9026,6 +9843,74 @@
9026
9843
  }, 150);
9027
9844
  }
9028
9845
 
9846
+ function isNoiseLine(line) {
9847
+ if (!line) return false;
9848
+ var trimmed = String(line).trim();
9849
+ if (!trimmed) return false;
9850
+ if (trimmed.indexOf("────") === 0) return true;
9851
+ if (trimmed === "❯" || trimmed === "›") return true;
9852
+ if (/^[╭╰│┌└┐┘├┤┬┴┼─═]{2,}$/.test(trimmed)) return true;
9853
+ if (/^[▁▂▃▄▅▆▇█▔▕▏▐]+$/.test(trimmed)) return true;
9854
+ if (trimmed.indexOf("esc to interrupt") !== -1) return true;
9855
+ if (trimmed.indexOf("Claude Code v") !== -1) return true;
9856
+ if (/^Sonnet\b/.test(trimmed)) return true;
9857
+ if (trimmed.indexOf("Failed to install Anthropic") !== -1) return true;
9858
+ if (trimmed.indexOf("Claude Code has switched") !== -1) return true;
9859
+ if (trimmed.indexOf("? for shortcuts") !== -1) return true;
9860
+ if (trimmed.indexOf("Claude is waiting") !== -1) return true;
9861
+ if (trimmed.indexOf("[wand]") !== -1) return true;
9862
+ if (trimmed.indexOf("0;") === 0 || trimmed.indexOf("9;") === 0) return true;
9863
+ if (trimmed.indexOf("ctrl+g") !== -1) return true;
9864
+ if (trimmed.indexOf("/effort") !== -1) return true;
9865
+ if (/^Using .* for .* session/.test(trimmed)) return true;
9866
+ if (trimmed.indexOf("Press ") === 0 && trimmed.indexOf(" for") !== -1) return true;
9867
+ if (trimmed.indexOf("type ") === 0 && trimmed.indexOf(" to ") !== -1) return true;
9868
+ if (trimmed.indexOf("auto mode is unavailable") !== -1) return true;
9869
+ if (/MCP server.*failed/i.test(trimmed)) return true;
9870
+ if (trimmed.indexOf("Germinating") !== -1 || trimmed.indexOf("Doodling") !== -1 || trimmed.indexOf("Brewing") !== -1) return true;
9871
+ if (trimmed.indexOf("Permissions") !== -1 && trimmed.indexOf("mode") !== -1) return true;
9872
+ if (trimmed.indexOf("●") === 0 && trimmed.indexOf("·") !== -1) return true;
9873
+ if (trimmed.indexOf("[>") === 0 || trimmed.indexOf("[<") === 0) return true;
9874
+ if (trimmed.indexOf("Captured Claude session ID") !== -1) return true;
9875
+ if (/^>_\s*OpenAI Codex\b/.test(trimmed)) return true;
9876
+ if (/^OpenAI Codex\b/i.test(trimmed)) return true;
9877
+ if (/^(model|directory):\s+/i.test(trimmed)) return true;
9878
+ if (/^(tip|context):\s+/i.test(trimmed)) return true;
9879
+ if (/^work(tree|space):\s+/i.test(trimmed)) return true;
9880
+ if (/^(approvals?|sandbox|provider|session id):\s+/i.test(trimmed)) return true;
9881
+ if (/^(thinking|working)(\.\.\.|…)?$/i.test(trimmed)) return true;
9882
+ if (/^[•◦·]\s+Working\b/i.test(trimmed)) return true;
9883
+ if (/^[•◦·]\s+(Running|Planning|Applying|Reading|Searching)\b/i.test(trimmed)) return true;
9884
+ if (/^[•◦·]\s+(Inspecting|Reviewing|Summarizing|Editing|Updating|Writing)\b/i.test(trimmed)) return true;
9885
+ if (/^[•◦·]\s+Completed\b/i.test(trimmed)) return true;
9886
+ if (/^(ctrl|enter|tab|shift|esc|alt)\+/i.test(trimmed)) return true;
9887
+ if (/\b(open|close|toggle) (chat|terminal)\b/i.test(trimmed)) return true;
9888
+ if (/\b(approve|deny)\b.*\b(permission|approval)\b/i.test(trimmed)) return true;
9889
+ if (/^(use|press) .* (to|for) .*/i.test(trimmed)) return true;
9890
+ if (/^(?:token|context window|remaining context|conversation):\s+/i.test(trimmed)) return true;
9891
+ if (/^(?:cwd|path):\s+\//i.test(trimmed)) return true;
9892
+ if (/^[<>│┆╎].*[<>│┆╎]$/.test(trimmed) && trimmed.length < 8) return true;
9893
+ return false;
9894
+ }
9895
+
9896
+ function stripAnsi(text) {
9897
+ return String(text || "")
9898
+ .replace(/\x1b\][^\x07]*(\x07|\x1b\\)/g, "")
9899
+ .replace(/\x1b\[(\d+)C/g, function(_match, count) { return " ".repeat(Number(count) || 1); })
9900
+ .replace(/\x1b\[[0-9;?]*[AB]/g, "\n")
9901
+ .replace(/\x1b\[[0-9;?]*[su]/g, "")
9902
+ .replace(/\x1b\[[0-9;?]*[HfJKr]/g, "\n")
9903
+ .replace(/\x1bM/g, "\n")
9904
+ .replace(/\x1b\[[0-9;?]*[ST]/g, "\n")
9905
+ .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "")
9906
+ .replace(/\x1b[><=ePX^_]/g, "")
9907
+ .replace(/[\u00a0\u200b-\u200d\ufeff]/g, " ")
9908
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "")
9909
+ .replace(/\r\n?/g, "\n")
9910
+ .replace(/[ \t]+\n/g, "\n")
9911
+ .replace(/\n{3,}/g, "\n\n");
9912
+ }
9913
+
9029
9914
  function parseMessages(output, command) {
9030
9915
  var messages = [];
9031
9916
  if (!output) return messages;
@@ -9035,6 +9920,269 @@
9035
9920
  var carriageReturn = String.fromCharCode(13);
9036
9921
  var esc = String.fromCharCode(27);
9037
9922
 
9923
+ if (/^codex\b/.test(String(command || "").trim())) {
9924
+ var codexFooterRe = /\bgpt-\d+(?:\.\d+)?(?:\s+[a-z0-9.-]+)?\s+·\s+\d+%\s+left\s+·\s+(?:\/|~\/).+/i;
9925
+ var codexActivityRe = /^(?:thinking|working|running|planning|applying|reading|searching|inspecting|reviewing|summarizing|editing|updating|writing|completed)\b/i;
9926
+
9927
+ function stripCodexSegment(raw) {
9928
+ return String(raw || "")
9929
+ .replace(/\x1b\][^\x07]*(\x07|\x1b\\)/g, "")
9930
+ .replace(/\x1b\[(\d+)C/g, function(_match, count) { return " ".repeat(Number(count) || 1); })
9931
+ .replace(/\x1b\[[0-9;?]*[AB]/g, newline)
9932
+ .replace(/\x1b\[[0-9;?]*[su]/g, "")
9933
+ .replace(/\x1b\[[0-9;?]*[HfJKr]/g, newline)
9934
+ .replace(/\x1bM/g, newline)
9935
+ .replace(/\x1b\[[0-9;?]*[ST]/g, newline)
9936
+ .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "")
9937
+ .replace(/\x1b[><=ePX^_]/g, "")
9938
+ .replace(/[\u00a0\u200b-\u200d\ufeff]/g, " ")
9939
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "")
9940
+ .replace(/[ \t]+\n/g, newline);
9941
+ }
9942
+
9943
+ function normalizeCodexText(value) {
9944
+ return String(value || "")
9945
+ .replace(/\s+/g, " ")
9946
+ .replace(/[M]+$/g, "")
9947
+ .trim();
9948
+ }
9949
+
9950
+ function normalizeCodexPromptLine(line) {
9951
+ return String(line || "")
9952
+ .replace(/^›\s*/, "")
9953
+ .replace(/^>\s*/, "")
9954
+ .trim();
9955
+ }
9956
+
9957
+ function shouldIgnoreCodexLine(line) {
9958
+ var trimmed = String(line || "").trim();
9959
+ if (!trimmed) return true;
9960
+ if (isNoiseLine(trimmed)) return true;
9961
+ if (codexFooterRe.test(trimmed)) return true;
9962
+ if (/^[╭╰│┌└┐┘├┤┬┴┼─═]/.test(trimmed)) return true;
9963
+ if (/^\[>[0-9;?]*u$/i.test(trimmed)) return true;
9964
+ if (/^M+$/i.test(trimmed)) return true;
9965
+ if (/^(?:OpenAI Codex|Codex)\b/i.test(trimmed)) return true;
9966
+ if (/^(?:tokens?|context window|remaining context|approvals?|sandbox|provider|session id):\s*/i.test(trimmed)) return true;
9967
+ if (/^(?:thinking|working)\s*(?:\.\.\.|…)?$/i.test(trimmed)) return true;
9968
+ if (/^[•◦·]\s+(?:thinking|working|running|planning|applying|reading|searching|inspecting|reviewing|summarizing|editing|updating|writing|completed)\b/i.test(trimmed)) return true;
9969
+ if (/^(?:model|directory|tip|context|cwd|path):\s+/i.test(trimmed)) return true;
9970
+ return false;
9971
+ }
9972
+
9973
+ function extractCodexPromptCandidate(line) {
9974
+ var trimmed = String(line || "").trim();
9975
+ if (!/^›(?:\s|$)/.test(trimmed)) return null;
9976
+ if (codexFooterRe.test(trimmed)) return null;
9977
+ var prompt = normalizeCodexText(normalizeCodexPromptLine(trimmed));
9978
+ if (!prompt || shouldIgnoreCodexLine(prompt)) return null;
9979
+ return prompt;
9980
+ }
9981
+
9982
+ function extractCodexAssistantCandidate(line) {
9983
+ var trimmed = String(line || "").trim();
9984
+ if (!/^[•◦·⏺]/.test(trimmed)) return null;
9985
+
9986
+ var assistant = trimmed
9987
+ .replace(/^[•◦·]\s*/, "")
9988
+ .replace(/^⏺\s+/, "")
9989
+ .replace(/^│\s*/, "")
9990
+ .trim();
9991
+ if (!assistant || /^[•◦·⏺]$/.test(assistant)) return null;
9992
+
9993
+ assistant = assistant
9994
+ .replace(/\s*\(\d+[smh]?\s*•\s*esc to interrupt\)[\s\S]*$/i, "")
9995
+ .replace(/(?:[a-z]{1,6})?›[\s\S]*$/, "")
9996
+ .replace(/\s{2,}gpt-\d[\s\S]*$/i, "")
9997
+ .replace(/\b(?:OpenAI Codex|model:|directory:|Tip:)\b[\s\S]*$/i, "");
9998
+ assistant = normalizeCodexText(assistant);
9999
+
10000
+ if (!assistant || assistant.length < 2 || codexActivityRe.test(assistant) || shouldIgnoreCodexLine(assistant)) {
10001
+ return null;
10002
+ }
10003
+ return assistant;
10004
+ }
10005
+
10006
+ function extractCodexEchoCandidate(line) {
10007
+ var trimmed = normalizeCodexText(line);
10008
+ if (!trimmed || shouldIgnoreCodexLine(trimmed)) return null;
10009
+ if (/^[•◦·⏺›]/.test(trimmed)) return null;
10010
+ if (/^[\[\]<>0-9;?]+u?$/i.test(trimmed)) return null;
10011
+ if (/^[╭╰│┌└┐┘├┤┬┴┼─═]/.test(trimmed)) return null;
10012
+ if (trimmed.length > 500) return null;
10013
+ return trimmed;
10014
+ }
10015
+
10016
+ function isLikelyAssistantTailArtifact(longer, shorter) {
10017
+ if (longer.indexOf(shorter) !== 0) return false;
10018
+ var suffix = longer.slice(shorter.length);
10019
+ return /^[a-z]{1,4}$/i.test(suffix);
10020
+ }
10021
+
10022
+ function coalesceAssistantLines(lines) {
10023
+ var collected = [];
10024
+ for (var i = 0; i < lines.length; i++) {
10025
+ var normalized = normalizeCodexText(lines[i]);
10026
+ if (!normalized || normalized.length < 2 || shouldIgnoreCodexLine(normalized)) continue;
10027
+
10028
+ var previous = collected[collected.length - 1];
10029
+ if (!previous) {
10030
+ collected.push(normalized);
10031
+ continue;
10032
+ }
10033
+ if (normalized === previous) continue;
10034
+ if (normalized.indexOf(previous) === 0) {
10035
+ collected[collected.length - 1] = normalized;
10036
+ continue;
10037
+ }
10038
+ if (previous.indexOf(normalized) === 0) {
10039
+ if (isLikelyAssistantTailArtifact(previous, normalized)) {
10040
+ collected[collected.length - 1] = normalized;
10041
+ }
10042
+ continue;
10043
+ }
10044
+ collected.push(normalized);
10045
+ }
10046
+ return collected.join(newline).trim();
10047
+ }
10048
+
10049
+ function extractVisiblePrompt(lines) {
10050
+ for (var i = 0; i < lines.length; i++) {
10051
+ var line = String(lines[i] || "").trim();
10052
+ if (!line) continue;
10053
+
10054
+ var inlinePrompt = extractCodexPromptCandidate(line);
10055
+ if (inlinePrompt) return inlinePrompt;
10056
+
10057
+ if (line === "›") {
10058
+ for (var j = i + 1; j < lines.length; j++) {
10059
+ var nextLine = normalizeCodexText(lines[j]);
10060
+ if (!nextLine || codexFooterRe.test(nextLine) || shouldIgnoreCodexLine(nextLine)) continue;
10061
+ return nextLine;
10062
+ }
10063
+ }
10064
+ }
10065
+ return null;
10066
+ }
10067
+
10068
+ function extractVisibleAssistantLines(lines) {
10069
+ var assistantLines = [];
10070
+ var collecting = false;
10071
+
10072
+ for (var i = 0; i < lines.length; i++) {
10073
+ var line = String(lines[i] || "").trim();
10074
+ if (!line) {
10075
+ if (collecting) break;
10076
+ continue;
10077
+ }
10078
+
10079
+ var assistant = extractCodexAssistantCandidate(line);
10080
+ if (assistant) {
10081
+ assistantLines.push(assistant);
10082
+ collecting = true;
10083
+ continue;
10084
+ }
10085
+
10086
+ if (collecting) {
10087
+ if (line === "›" || /^›(?:\s|$)/.test(line) || codexFooterRe.test(line) || shouldIgnoreCodexLine(line)) {
10088
+ break;
10089
+ }
10090
+ assistantLines.push(normalizeCodexText(line));
10091
+ }
10092
+ }
10093
+
10094
+ return assistantLines;
10095
+ }
10096
+
10097
+ var rawCandidates = [];
10098
+ var candidateOrder = 0;
10099
+ var rawSegments = text.replace(/\r\n?/g, newline).split(newline);
10100
+ for (var rs = 0; rs < rawSegments.length; rs++) {
10101
+ var cleanedSegment = stripCodexSegment(rawSegments[rs]);
10102
+ var pieces = cleanedSegment.split(newline);
10103
+ for (var pi = 0; pi < pieces.length; pi++) {
10104
+ var piece = String(pieces[pi] || "").trim();
10105
+ if (!piece) continue;
10106
+
10107
+ var promptCandidate = extractCodexPromptCandidate(piece);
10108
+ if (promptCandidate) {
10109
+ rawCandidates.push({ kind: "user", order: candidateOrder++, text: promptCandidate });
10110
+ continue;
10111
+ }
10112
+
10113
+ var assistantCandidate = extractCodexAssistantCandidate(piece);
10114
+ if (assistantCandidate) {
10115
+ rawCandidates.push({ kind: "assistant", order: candidateOrder++, text: assistantCandidate });
10116
+ continue;
10117
+ }
10118
+
10119
+ var echoCandidate = extractCodexEchoCandidate(piece);
10120
+ if (echoCandidate) {
10121
+ rawCandidates.push({ kind: "echo", order: candidateOrder++, text: echoCandidate });
10122
+ }
10123
+ }
10124
+ }
10125
+
10126
+ var candidates = rawCandidates.filter(function(candidate, index, list) {
10127
+ var previous = list[index - 1];
10128
+ return !previous || previous.kind !== candidate.kind || previous.text !== candidate.text;
10129
+ });
10130
+
10131
+ var explicitUsers = candidates.filter(function(candidate) { return candidate.kind === "user"; });
10132
+ var assistantCandidates = candidates.filter(function(candidate) { return candidate.kind === "assistant"; });
10133
+ var echoCandidates = candidates.filter(function(candidate) { return candidate.kind === "echo"; });
10134
+ var strippedOutput = stripAnsi(text);
10135
+ var strippedLines = strippedOutput.split(newline).map(function(line) { return String(line || "").trimEnd(); });
10136
+ var visiblePrompt = extractVisiblePrompt(strippedLines);
10137
+ var latestExplicitUser = explicitUsers.length ? explicitUsers[explicitUsers.length - 1] : null;
10138
+ var echoedUserCandidates = echoCandidates
10139
+ .map(function(candidate) { return candidate.text; })
10140
+ .filter(function(value) { return value.length >= 3; });
10141
+ var latestEchoUser = null;
10142
+ for (var eu = echoedUserCandidates.length - 1; eu >= 0; eu--) {
10143
+ if (echoedUserCandidates[eu] !== visiblePrompt) {
10144
+ latestEchoUser = echoedUserCandidates[eu];
10145
+ break;
10146
+ }
10147
+ }
10148
+ if (!latestEchoUser && echoedUserCandidates.length) {
10149
+ latestEchoUser = echoedUserCandidates[echoedUserCandidates.length - 1];
10150
+ }
10151
+
10152
+ var currentUser = latestExplicitUser ? latestExplicitUser.text : latestEchoUser;
10153
+ var rawAssistantLines = assistantCandidates
10154
+ .filter(function(candidate) { return !latestExplicitUser || candidate.order > latestExplicitUser.order; })
10155
+ .map(function(candidate) { return candidate.text; });
10156
+ var visibleAssistantFallback = [];
10157
+ var bulletMatches = strippedOutput.match(/^[ \t]*[•◦·⏺][ \t]*(.+)$/gm) || [];
10158
+ for (var bm = 0; bm < bulletMatches.length; bm++) {
10159
+ var bulletContent = normalizeCodexText(bulletMatches[bm].replace(/^[ \t]*[•◦·⏺][ \t]*/, ""));
10160
+ if (!bulletContent) continue;
10161
+ if (codexActivityRe.test(bulletContent)) continue;
10162
+ if (codexFooterRe.test(bulletContent)) continue;
10163
+ if (/\b(?:OpenAI Codex|model:|directory:|Tip:|esc to interrupt)\b/i.test(bulletContent)) continue;
10164
+ visibleAssistantFallback.push(bulletContent);
10165
+ }
10166
+
10167
+ var assistantText = coalesceAssistantLines(rawAssistantLines)
10168
+ || coalesceAssistantLines(extractVisibleAssistantLines(strippedLines))
10169
+ || (visibleAssistantFallback.length ? visibleAssistantFallback[visibleAssistantFallback.length - 1] : null);
10170
+
10171
+ if (currentUser) {
10172
+ messages.push({ role: "user", content: currentUser });
10173
+ }
10174
+ if (assistantText) {
10175
+ messages.push({ role: "assistant", content: assistantText });
10176
+ }
10177
+ if (!messages.length && latestExplicitUser) {
10178
+ messages.push({ role: "user", content: latestExplicitUser.text });
10179
+ } else if (!messages.length && latestEchoUser) {
10180
+ messages.push({ role: "user", content: latestEchoUser });
10181
+ }
10182
+
10183
+ return messages;
10184
+ }
10185
+
9038
10186
  // Optimized ANSI escape sequence stripping
9039
10187
  // Handles: CSI sequences, OSC sequences, single-character escapes, control chars
9040
10188
  var nul = String.fromCharCode(0);
@@ -9318,24 +10466,64 @@
9318
10466
  };
9319
10467
  })();
9320
10468
 
10469
+ var DEFAULT_CHAT_PERSONA = {
10470
+ user: {
10471
+ name: "赛博虎妞",
10472
+ avatarSvg: PIXEL_AVATAR.user
10473
+ },
10474
+ assistant: {
10475
+ name: "勤劳初二",
10476
+ avatarSvg: PIXEL_AVATAR.assistant
10477
+ }
10478
+ };
10479
+
10480
+ function getStructuredChatPersona(role) {
10481
+ var configPersona = state.config && state.config.structuredChatPersona;
10482
+ var roleConfig = configPersona && configPersona[role] ? configPersona[role] : null;
10483
+ var defaults = DEFAULT_CHAT_PERSONA[role] || DEFAULT_CHAT_PERSONA.assistant;
10484
+ return {
10485
+ name: roleConfig && typeof roleConfig.name === "string" && roleConfig.name.trim()
10486
+ ? roleConfig.name.trim()
10487
+ : defaults.name,
10488
+ avatar: roleConfig && typeof roleConfig.avatar === "string" && roleConfig.avatar.trim()
10489
+ ? roleConfig.avatar.trim()
10490
+ : null,
10491
+ avatarSvg: defaults.avatarSvg
10492
+ };
10493
+ }
10494
+
10495
+ function renderAvatarFallback(svg) {
10496
+ return '<div class="pixel-avatar">' + svg + '</div>';
10497
+ }
10498
+
10499
+ function handleChatAvatarImageError(img, role) {
10500
+ if (!img || !img.parentNode) return;
10501
+ var persona = getStructuredChatPersona(role === "user" ? "user" : "assistant");
10502
+ img.outerHTML = renderAvatarFallback(persona.avatarSvg);
10503
+ }
10504
+
9321
10505
  function chatAvatar(role) {
9322
- var isUser = role === "user";
9323
- var svg = isUser ? PIXEL_AVATAR.user : PIXEL_AVATAR.assistant;
9324
- var name = isUser ? "赛博虎妞" : "勤劳初二";
10506
+ var personaRole = role === "user" ? "user" : "assistant";
10507
+ var persona = getStructuredChatPersona(personaRole);
10508
+ var avatarInner = persona.avatar
10509
+ ? '<img class="pixel-avatar-image" src="' + escapeHtml(persona.avatar) + '" alt="' + escapeHtml(persona.name) + '" onerror="handleChatAvatarImageError(this, ' + JSON.stringify(personaRole) + ')" />'
10510
+ : renderAvatarFallback(persona.avatarSvg);
9325
10511
  return '<div class="chat-message-avatar ' + role + '">' +
9326
- '<div class="pixel-avatar">' + svg + '</div>' +
9327
- '<span class="avatar-name">' + name + '</span>' +
10512
+ avatarInner +
10513
+ '<span class="avatar-name">' + escapeHtml(persona.name) + '</span>' +
9328
10514
  '</div>';
9329
10515
  }
9330
10516
 
9331
- function renderChatMessage(msg, roundUsage) {
10517
+ function renderChatMessage(msg, roundUsage, messageIndex) {
9332
10518
  // Thinking card (deep thought) — from PTY parsing
9333
10519
  if (msg.role === "thinking") {
10520
+ var thinkingKey = buildExpandKey("thinking", [getMessageKey(msg, messageIndex), "pty"]);
10521
+ var thinkingExpanded = getPersistedExpandState(thinkingKey) === true;
9334
10522
  return '<div class="chat-message thinking">' +
9335
- '<div class="thinking-inline thinking-pty collapsed" data-thinking="" onclick="__thinkingToggle(this)">' +
10523
+ '<div class="thinking-inline thinking-pty ' + (thinkingExpanded ? 'expanded' : 'collapsed') + '" data-expand-kind="thinking" data-expand-key="' + escapeHtml(thinkingKey) + '" data-thinking="" onclick="__thinkingToggle(this)">' +
9336
10524
  '<span class="thinking-inline-icon">⦿</span>' +
9337
10525
  '<span class="thinking-inline-preview">' + escapeHtml(msg.content) + '</span>' +
9338
- '<span class="thinking-inline-action">展开</span>' +
10526
+ '<span class="thinking-inline-action">' + (thinkingExpanded ? '收起' : '展开') + '</span>' +
9339
10527
  '</div>' +
9340
10528
  '</div>';
9341
10529
  }
@@ -9352,7 +10540,7 @@
9352
10540
 
9353
10541
  // Structured content blocks (from JSON chat mode)
9354
10542
  if (Array.isArray(msg.content)) {
9355
- return renderStructuredMessage(msg, roundUsage);
10543
+ return renderStructuredMessage(msg, roundUsage, messageIndex);
9356
10544
  }
9357
10545
 
9358
10546
  // Legacy string content (from PTY parsing)
@@ -9442,7 +10630,7 @@
9442
10630
 
9443
10631
  var TOOL_GROUP_LABELS = { Read: "读取", Glob: "搜索", Grep: "搜索", WebFetch: "抓取", WebSearch: "搜索", TodoRead: "待办" };
9444
10632
 
9445
- function renderToolGroup(items, role, toolResults) {
10633
+ function renderToolGroup(items, role, toolResults, messageKey) {
9446
10634
  // Count by tool name
9447
10635
  var counts = {};
9448
10636
  for (var k = 0; k < items.length; k++) {
@@ -9466,28 +10654,32 @@
9466
10654
  parts.push(counts[name] + " " + (TOOL_GROUP_LABELS[name] || name));
9467
10655
  }
9468
10656
  var summaryText = parts.join(" · ");
10657
+ var groupKey = buildExpandKey("tool-group", [messageKey, items[0] && items[0].index, items.length]);
10658
+ var persistedExpanded = getPersistedExpandState(groupKey);
10659
+ var shouldExpand = persistedExpanded === null ? false : persistedExpanded;
9469
10660
 
9470
10661
  // Render each item's inline-tool card
9471
10662
  var innerHtml = "";
9472
10663
  for (var k = 0; k < items.length; k++) {
9473
10664
  try {
9474
- innerHtml += renderContentBlock(items[k].block, role, toolResults, items[k].index);
10665
+ innerHtml += renderContentBlock(items[k].block, role, toolResults, items[k].index, messageKey);
9475
10666
  } catch (e) {
9476
10667
  innerHtml += '<div class="render-error">工具渲染失败</div>';
9477
10668
  }
9478
10669
  }
9479
10670
 
9480
- return '<div class="tool-group" data-expanded="false" data-status="' + statusClass + '">' +
10671
+ return '<div class="tool-group" data-expand-kind="tool-group" data-expand-key="' + escapeHtml(groupKey) + '" data-expanded="' + (shouldExpand ? 'true' : 'false') + '" data-status="' + statusClass + '">' +
9481
10672
  '<div class="tool-group-summary" onclick="__toolGroupToggle(this.parentNode)">' +
9482
10673
  '<span class="tool-group-status">' + statusIcon + '</span>' +
9483
10674
  '<span class="tool-group-text">' + escapeHtml(summaryText) + '</span>' +
9484
10675
  '<span class="tool-group-count">' + items.length + ' 个调用</span>' +
9485
- '<svg class="tool-group-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>' +
10676
+ '<svg class="tool-group-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="transform:' + (shouldExpand ? 'rotate(180deg)' : '') + '"><polyline points="6 9 12 15 18 9"/></svg>' +
9486
10677
  '</div>' +
9487
- '<div class="tool-group-body">' + innerHtml + '</div>' +
10678
+ '<div class="tool-group-body" style="display:' + (shouldExpand ? 'block' : 'none') + ';">' + innerHtml + '</div>' +
9488
10679
  '</div>';
9489
10680
  }
9490
10681
 
10682
+
9491
10683
  // global toggle
9492
10684
  window.__toolGroupToggle = function(el) {
9493
10685
  if (!el) return;
@@ -9497,11 +10689,13 @@
9497
10689
  if (body) body.style.display = expanded ? "none" : "block";
9498
10690
  var chevron = el.querySelector(".tool-group-chevron");
9499
10691
  if (chevron) chevron.style.transform = expanded ? "" : "rotate(180deg)";
10692
+ persistElementExpandState(el, "tool-group");
9500
10693
  };
9501
10694
 
9502
- function renderStructuredMessage(msg, roundUsage) {
10695
+ function renderStructuredMessage(msg, roundUsage, messageIndex) {
9503
10696
  var role = msg.role;
9504
10697
  var avatar = chatAvatar(role);
10698
+ var messageKey = getMessageKey(msg, messageIndex);
9505
10699
 
9506
10700
  // Check if this is a queued user message
9507
10701
  var isQueued = role === "user" && msg.content && msg.content.some(function(b) { return b.__queued; });
@@ -9525,9 +10719,9 @@
9525
10719
  var grp = groups[g];
9526
10720
  try {
9527
10721
  if (grp.type === "group") {
9528
- blocksHtml += renderToolGroup(grp.items, role, toolResults);
10722
+ blocksHtml += renderToolGroup(grp.items, role, toolResults, messageKey);
9529
10723
  } else {
9530
- blocksHtml += renderContentBlock(grp.block, role, toolResults, grp.index);
10724
+ blocksHtml += renderContentBlock(grp.block, role, toolResults, grp.index, messageKey);
9531
10725
  }
9532
10726
  } catch (e) {
9533
10727
  blocksHtml += '<div class="render-error">消息块渲染失败</div>';
@@ -9544,13 +10738,13 @@
9544
10738
  var queuedClass = isQueued ? " queued" : "";
9545
10739
  var queuedBadge = isQueued ? '<span class="queued-badge">排队中</span>' : "";
9546
10740
 
9547
- return '<div class="chat-message ' + role + queuedClass + '">' +
10741
+ return '<div class="chat-message ' + role + queuedClass + '" data-message-key="' + escapeHtml(messageKey) + '">' +
9548
10742
  avatar +
9549
10743
  '<div class="chat-message-content">' + blocksHtml + queuedBadge + '</div>' +
9550
10744
  usageHtml +
9551
10745
  '</div>';
9552
10746
  }
9553
- function renderContentBlock(block, role, toolResults, index) {
10747
+ function renderContentBlock(block, role, toolResults, index, messageKey) {
9554
10748
  if (!block || !block.type) return "";
9555
10749
 
9556
10750
  switch (block.type) {
@@ -9572,15 +10766,17 @@
9572
10766
  '</div>' +
9573
10767
  '</div>';
9574
10768
  }
9575
- return '<div class="thinking-inline collapsed" data-thinking="' + escapeHtml(thinkingText) + '" onclick="__thinkingToggle(this)">' +
10769
+ var thinkingKey = buildExpandKey("thinking", [messageKey, index]);
10770
+ var thinkingExpanded = getPersistedExpandState(thinkingKey) === true;
10771
+ return '<div class="thinking-inline ' + (thinkingExpanded ? 'expanded' : 'collapsed') + '" data-expand-kind="thinking" data-expand-key="' + escapeHtml(thinkingKey) + '" data-thinking="' + escapeHtml(thinkingText) + '" onclick="__thinkingToggle(this)">' +
9576
10772
  '<span class="thinking-inline-icon">⦿</span>' +
9577
- '<span class="thinking-inline-preview">' + escapeHtml(preview) + '</span>' +
9578
- '<span class="thinking-inline-action">展开</span>' +
10773
+ '<span class="thinking-inline-preview">' + escapeHtml(thinkingExpanded ? thinkingText : preview) + '</span>' +
10774
+ '<span class="thinking-inline-action">' + (thinkingExpanded ? '收起' : '展开') + '</span>' +
9579
10775
  '</div>';
9580
10776
 
9581
10777
  case "tool_use":
9582
10778
  var toolResult = pickToolResultForDisplay(toolResults, block.id);
9583
- var rendered = renderToolUseCard(block, toolResult, index);
10779
+ var rendered = renderToolUseCard(block, toolResult, index, messageKey);
9584
10780
  if (hasRecoveredToolNoise(toolResults, block.id)) {
9585
10781
  rendered = renderRecoveredToolHint(block.name || "工具") + rendered;
9586
10782
  }
@@ -9594,8 +10790,10 @@
9594
10790
  }
9595
10791
  }
9596
10792
 
9597
- function renderInlineTool(block, toolResult, toolName, fileInfo, extraInfo) {
10793
+ function renderInlineTool(block, toolResult, toolName, fileInfo, extraInfo, messageKey, index) {
9598
10794
  var toolId = block.id || "tool-" + toolName;
10795
+ var expandKey = buildExpandKey("inline-tool", [messageKey, toolId || index, index]);
10796
+ var persistedExpanded = getPersistedExpandState(expandKey);
9599
10797
  var inputData = block.input || {};
9600
10798
  var resultContent = extractToolResultText(toolResult && toolResult.content);
9601
10799
 
@@ -9666,16 +10864,16 @@
9666
10864
  var fullResult = resultContent;
9667
10865
 
9668
10866
  var expandedHtml = "";
9669
- var shouldExpand = false; // All inline tools collapsed by default
10867
+ var shouldExpand = persistedExpanded === null ? false : persistedExpanded;
9670
10868
  if (hasResult) {
9671
10869
  expandedHtml = '<div class="inline-tool-expanded" style="display: ' + (shouldExpand ? 'block' : 'none') + ';">' +
9672
10870
  '<div class="inline-tool-result">' + formatInlineResult(resultContent, toolName) + '</div>' +
9673
10871
  '</div>';
9674
10872
  } else if (isError) {
9675
- expandedHtml = '<div class="inline-tool-expanded" style="display: none;"><div class="inline-tool-result inline-tool-error">' +
10873
+ expandedHtml = '<div class="inline-tool-expanded" style="display: ' + (shouldExpand ? 'block' : 'none') + ';"><div class="inline-tool-result inline-tool-error">' +
9676
10874
  escapeHtml(resultContent || "操作失败") + '</div></div>';
9677
10875
  } else if (!toolResult) {
9678
- expandedHtml = '<div class="inline-tool-expanded" style="display: none;"><div class="inline-tool-loading">等待响应…</div></div>';
10876
+ expandedHtml = '<div class="inline-tool-expanded" style="display: ' + (shouldExpand ? 'block' : 'none') + ';"><div class="inline-tool-loading">等待响应…</div></div>';
9679
10877
  }
9680
10878
 
9681
10879
  var extraInfoHtml = meta ? '<span class="inline-tool-meta">' + escapeHtml(meta) + '</span>' : '';
@@ -9683,6 +10881,8 @@
9683
10881
  if (shouldExpand) extraClass += ' inline-tool-open';
9684
10882
 
9685
10883
  return '<div class="inline-tool ' + extraClass + '" ' +
10884
+ 'data-expand-kind="inline-tool" ' +
10885
+ 'data-expand-key="' + escapeHtml(expandKey) + '" ' +
9686
10886
  'data-result="' + escapeHtml(fullResult) + '" ' +
9687
10887
  'data-preview="' + previewDataAttr + '" ' +
9688
10888
  'data-status="' + (isError ? 'error' : (hasResult ? 'done' : 'pending')) + '" ' +
@@ -9698,10 +10898,13 @@
9698
10898
  }
9699
10899
 
9700
10900
  // Terminal-style display for Bash commands
9701
- function renderTerminalTool(block, toolResult, toolName) {
10901
+ function renderTerminalTool(block, toolResult, toolName, messageKey, index) {
9702
10902
  var inputData = block.input || {};
9703
10903
  var command = inputData.command || inputData.cmd || "";
9704
10904
  var resultContent = extractToolResultText(toolResult && toolResult.content);
10905
+ var toolId = block.id || "tool-" + toolName;
10906
+ var expandKey = buildExpandKey("terminal", [messageKey, toolId || index, index]);
10907
+ var persistedExpanded = getPersistedExpandState(expandKey);
9705
10908
 
9706
10909
  var isError = toolResult && toolResult.is_error;
9707
10910
  var exitCode = inputData.exitCode;
@@ -9739,22 +10942,21 @@
9739
10942
 
9740
10943
  // Show command preview in header (truncate long commands)
9741
10944
  var cmdPreview = command.length > 80 ? command.slice(0, 77) + "…" : command;
10945
+ var shouldExpand = persistedExpanded === null ? false : persistedExpanded;
9742
10946
 
9743
- return '<div class="inline-terminal" data-expanded="false">' +
10947
+ return '<div class="inline-terminal" data-expand-kind="terminal" data-expand-key="' + escapeHtml(expandKey) + '" data-expanded="' + (shouldExpand ? 'true' : 'false') + '">' +
9744
10948
  '<div class="term-header" onclick="__terminalExpand(this)">' +
9745
10949
  statusDot +
9746
10950
  '<span class="term-cmd-preview"><span class="term-prompt">$</span> ' + escapeHtml(cmdPreview) + '</span>' +
9747
- '<span class="term-toggle-icon">▶</span>' +
10951
+ '<span class="term-toggle-icon">' + (shouldExpand ? '▼' : '▶') + '</span>' +
9748
10952
  '</div>' +
9749
- '<div class="term-body" style="display:none;">' +
10953
+ '<div class="term-body" style="display:' + (shouldExpand ? 'block' : 'none') + ';">' +
9750
10954
  '<div class="term-command"><span class="term-prompt">$</span> ' + cmdDisplay + '</div>' +
9751
10955
  (outputHtml ? '<div class="term-output">' + outputHtml + '</div>' : '') +
9752
10956
  exitCodeHtml +
9753
10957
  '</div>' +
9754
10958
  '</div>';
9755
10959
  }
9756
-
9757
- // GitHub-style diff display for Edit/Write/MultiEdit
9758
10960
  function extractToolResultText(content) {
9759
10961
  if (!content) return "";
9760
10962
  if (typeof content === "string") return content;
@@ -9845,7 +11047,7 @@
9845
11047
  return '<pre class="inline-tool-result-text" style="max-height: 300px; overflow-y: auto;">' + escapeHtml(content) + '</pre>';
9846
11048
  }
9847
11049
 
9848
- function renderToolUseCard(block, toolResult, index) {
11050
+ function renderToolUseCard(block, toolResult, index, messageKey) {
9849
11051
  var toolName = block.name || "unknown";
9850
11052
  var toolId = block.id || "tool-" + toolName + "-" + (typeof index === "number" ? index : 0);
9851
11053
  var fileInfo = extractFileInfo(toolName, block.input);
@@ -9853,12 +11055,12 @@
9853
11055
  // ── Lightweight inline tools: Read, Glob, Grep, WebFetch, WebSearch, TodoRead
9854
11056
  if (toolName === "Read" || toolName === "Glob" || toolName === "Grep" ||
9855
11057
  toolName === "WebFetch" || toolName === "WebSearch" || toolName === "TodoRead") {
9856
- return renderInlineTool(block, toolResult, toolName, fileInfo, "");
11058
+ return renderInlineTool(block, toolResult, toolName, fileInfo, "", messageKey, index);
9857
11059
  }
9858
11060
 
9859
11061
  // ── Terminal-style: Bash
9860
11062
  if (toolName === "Bash") {
9861
- return renderTerminalTool(block, toolResult, toolName);
11063
+ return renderTerminalTool(block, toolResult, toolName, messageKey, index);
9862
11064
  }
9863
11065
 
9864
11066
  // ── Diff-style: Edit, Write, MultiEdit
@@ -9937,9 +11139,12 @@
9937
11139
  headerIcon = getToolIcon(toolName);
9938
11140
  }
9939
11141
 
9940
- var collapsedClass = statusClass !== "loading" ? " collapsed" : "";
11142
+ var expandKey = buildExpandKey("tool-card", [messageKey, toolId]);
11143
+ var persistedExpanded = getPersistedExpandState(expandKey);
11144
+ var shouldExpand = persistedExpanded === null ? statusClass === "loading" : persistedExpanded;
11145
+ var collapsedClass = shouldExpand ? "" : " collapsed";
9941
11146
  var toggleHtml = '<span class="tool-use-toggle">▼</span>';
9942
- return '<div class="tool-use-card ' + statusClass + collapsedClass + '" data-tool-use-id="' + escapeHtml(toolId) + '">' +
11147
+ return '<div class="tool-use-card ' + statusClass + collapsedClass + '" data-expand-kind="tool-card" data-expand-key="' + escapeHtml(expandKey) + '" data-tool-use-id="' + escapeHtml(toolId) + '">' +
9943
11148
  '<div class="tool-use-header" data-tool-toggle onclick="__tcToggle(event,this)">' +
9944
11149
  '<span class="tool-use-icon">' + headerIcon + '</span>' +
9945
11150
  '<span class="tool-use-name">' + escapeHtml(titleText) + '</span>' +