@co0ontty/wand 1.0.1 → 1.1.1

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.
@@ -21,9 +21,12 @@
21
21
  });
22
22
 
23
23
  // Auto-reload when a new service worker takes control (e.g. after update)
24
+ // But skip reload during initial page load to avoid breaking initialization
24
25
  var reloading = false;
26
+ var pageReady = false;
27
+ setTimeout(function() { pageReady = true; }, 3000);
25
28
  navigator.serviceWorker.addEventListener('controllerchange', function() {
26
- if (reloading) return;
29
+ if (reloading || !pageReady) return;
27
30
  reloading = true;
28
31
  location.reload();
29
32
  });
@@ -254,6 +257,23 @@
254
257
  })
255
258
  .catch(function() {
256
259
  state.loginChecked = true;
260
+ // If offline (no network), show a friendly offline message instead of login
261
+ if (!navigator.onLine) {
262
+ var app = document.getElementById("app");
263
+ if (app) {
264
+ app.innerHTML =
265
+ '<div class="boot-loading">' +
266
+ '<div class="boot-loading-card">' +
267
+ '<div class="boot-loading-text" style="font-size:1.3em;margin-bottom:12px">📡 无法连接到服务器</div>' +
268
+ '<div class="boot-loading-text" style="opacity:0.7;font-size:0.95em">请检查网络连接或确认 Wand 服务正在运行。</div>' +
269
+ '<button onclick="location.reload()" style="margin-top:18px;padding:8px 24px;border-radius:8px;border:1px solid rgba(150,118,85,0.3);background:rgba(255,255,255,0.8);cursor:pointer;font-size:0.95em">重试</button>' +
270
+ '</div>' +
271
+ '</div>';
272
+ }
273
+ // Retry when network comes back
274
+ window.addEventListener('online', function() { location.reload(); }, { once: true });
275
+ return;
276
+ }
257
277
  render();
258
278
  });
259
279
  }
@@ -419,6 +439,9 @@
419
439
  '<button id="drawer-new-session-button" class="btn btn-primary btn-block"><span>+</span> 新会话</button>' +
420
440
  '<div class="sidebar-footer-actions">' +
421
441
  '<button id="file-panel-toggle-btn" class="btn btn-ghost btn-sm' + (state.filePanelOpen ? " active" : "") + '" type="button" title="查看文件">📁 文件</button>' +
442
+ '<button id="settings-button" class="btn btn-ghost btn-sm" type="button" title="设置">' +
443
+ '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg> 设置' +
444
+ '</button>' +
422
445
  '<button id="pwa-install-button" class="btn btn-ghost btn-sm hidden" title="安装应用">' +
423
446
  '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> 安装' +
424
447
  '</button>' +
@@ -562,23 +585,113 @@
562
585
 
563
586
  function renderSettingsModal() {
564
587
  return '<section id="settings-modal" class="modal-backdrop hidden">' +
565
- '<div class="modal">' +
588
+ '<div class="modal settings-modal">' +
566
589
  '<div class="modal-header">' +
567
590
  '<h2 class="modal-title">设置</h2>' +
568
591
  '<button id="close-settings-button" class="btn btn-ghost btn-icon">×</button>' +
569
592
  '</div>' +
570
593
  '<div class="modal-body">' +
571
- '<div class="field">' +
572
- '<label class="field-label" for="new-password">新密码</label>' +
573
- '<input id="new-password" type="password" class="field-input" placeholder="输入新密码(至少 6 个字符)" autocomplete="new-password" />' +
594
+ // Tabs
595
+ '<div class="settings-tabs">' +
596
+ '<button class="settings-tab active" data-tab="about">关于</button>' +
597
+ '<button class="settings-tab" data-tab="general">基本配置</button>' +
598
+ '<button class="settings-tab" data-tab="security">安全</button>' +
599
+ '<button class="settings-tab" data-tab="presets">命令预设</button>' +
574
600
  '</div>' +
575
- '<div class="field">' +
576
- '<label class="field-label" for="confirm-password">确认密码</label>' +
577
- '<input id="confirm-password" type="password" class="field-input" placeholder="再次输入新密码" autocomplete="new-password" />' +
601
+
602
+ // About tab
603
+ '<div class="settings-panel active" id="settings-tab-about">' +
604
+ '<div class="settings-about-info">' +
605
+ '<div class="settings-about-row"><span class="settings-label">包名</span><span class="settings-value" id="settings-pkg-name">-</span></div>' +
606
+ '<div class="settings-about-row"><span class="settings-label">当前版本</span><span class="settings-value" id="settings-version">-</span></div>' +
607
+ '<div class="settings-about-row"><span class="settings-label">Node.js 要求</span><span class="settings-value" id="settings-node-req">-</span></div>' +
608
+ '<div class="settings-about-row"><span class="settings-label">仓库地址</span><span class="settings-value" id="settings-repo-url"><a href="#" target="_blank" rel="noopener">-</a></span></div>' +
609
+ '</div>' +
610
+ '<div class="settings-update-section">' +
611
+ '<div class="settings-about-row">' +
612
+ '<span class="settings-label">最新版本</span>' +
613
+ '<span class="settings-value" id="settings-latest-version">-</span>' +
614
+ '</div>' +
615
+ '<div class="settings-update-actions">' +
616
+ '<button id="check-update-button" class="btn btn-ghost btn-sm">检查更新</button>' +
617
+ '<button id="do-update-button" class="btn btn-primary btn-sm hidden">更新到最新版</button>' +
618
+ '</div>' +
619
+ '<p id="update-message" class="hint hidden"></p>' +
620
+ '</div>' +
621
+ '</div>' +
622
+
623
+ // General config tab
624
+ '<div class="settings-panel" id="settings-tab-general">' +
625
+ '<div class="field">' +
626
+ '<label class="field-label" for="cfg-host">监听地址 (host)</label>' +
627
+ '<input id="cfg-host" type="text" class="field-input" placeholder="127.0.0.1" />' +
628
+ '</div>' +
629
+ '<div class="field">' +
630
+ '<label class="field-label" for="cfg-port">端口 (port)</label>' +
631
+ '<input id="cfg-port" type="number" class="field-input" placeholder="8443" min="1" max="65535" />' +
632
+ '</div>' +
633
+ '<div class="field field-inline">' +
634
+ '<label class="field-label" for="cfg-https">启用 HTTPS</label>' +
635
+ '<input id="cfg-https" type="checkbox" class="field-checkbox" />' +
636
+ '</div>' +
637
+ '<div class="field">' +
638
+ '<label class="field-label" for="cfg-mode">默认执行模式</label>' +
639
+ '<select id="cfg-mode" class="field-input">' +
640
+ '<option value="default">default</option>' +
641
+ '<option value="assist">assist</option>' +
642
+ '<option value="agent">agent</option>' +
643
+ '<option value="agent-max">agent-max</option>' +
644
+ '<option value="auto-edit">auto-edit</option>' +
645
+ '<option value="full-access">full-access</option>' +
646
+ '<option value="native">native</option>' +
647
+ '<option value="managed">managed</option>' +
648
+ '</select>' +
649
+ '</div>' +
650
+ '<div class="field">' +
651
+ '<label class="field-label" for="cfg-cwd">默认工作目录</label>' +
652
+ '<input id="cfg-cwd" type="text" class="field-input" placeholder="/home/user" />' +
653
+ '</div>' +
654
+ '<div class="field">' +
655
+ '<label class="field-label" for="cfg-shell">Shell</label>' +
656
+ '<input id="cfg-shell" type="text" class="field-input" placeholder="/bin/bash" />' +
657
+ '</div>' +
658
+ '<button id="save-config-button" class="btn btn-primary btn-block">保存配置</button>' +
659
+ '<p id="config-message" class="hint hidden"></p>' +
660
+ '</div>' +
661
+
662
+ // Security tab
663
+ '<div class="settings-panel" id="settings-tab-security">' +
664
+ '<h3 class="settings-section-title">修改密码</h3>' +
665
+ '<div class="field">' +
666
+ '<label class="field-label" for="new-password">新密码</label>' +
667
+ '<input id="new-password" type="password" class="field-input" placeholder="输入新密码(至少 6 个字符)" autocomplete="new-password" />' +
668
+ '</div>' +
669
+ '<div class="field">' +
670
+ '<label class="field-label" for="confirm-password">确认密码</label>' +
671
+ '<input id="confirm-password" type="password" class="field-input" placeholder="再次输入新密码" autocomplete="new-password" />' +
672
+ '</div>' +
673
+ '<button id="save-password-button" class="btn btn-primary btn-block">保存密码</button>' +
674
+ '<p id="settings-error" class="error-message hidden"></p>' +
675
+ '<p id="settings-success" class="hint hidden" style="color: var(--success);"></p>' +
676
+ '<hr class="settings-divider" />' +
677
+ '<h3 class="settings-section-title">SSL 证书</h3>' +
678
+ '<p class="settings-hint" id="cert-status">加载中...</p>' +
679
+ '<div class="field">' +
680
+ '<label class="field-label" for="cert-key-file">私钥文件 (.key)</label>' +
681
+ '<input id="cert-key-file" type="file" class="field-input field-file" accept=".key,.pem" />' +
682
+ '</div>' +
683
+ '<div class="field">' +
684
+ '<label class="field-label" for="cert-cert-file">证书文件 (.crt/.pem)</label>' +
685
+ '<input id="cert-cert-file" type="file" class="field-input field-file" accept=".crt,.pem,.cert" />' +
686
+ '</div>' +
687
+ '<button id="upload-cert-button" class="btn btn-primary btn-block">上传证书</button>' +
688
+ '<p id="cert-message" class="hint hidden"></p>' +
689
+ '</div>' +
690
+
691
+ // Command presets tab
692
+ '<div class="settings-panel" id="settings-tab-presets">' +
693
+ '<div id="presets-list" class="presets-list"></div>' +
578
694
  '</div>' +
579
- '<button id="save-password-button" class="btn btn-primary btn-block">保存密码</button>' +
580
- '<p id="settings-error" class="error-message hidden"></p>' +
581
- '<p id="settings-success" class="hint hidden" style="color: var(--success);"></p>' +
582
695
  '</div>' +
583
696
  '</div>' +
584
697
  '</section>';
@@ -1748,6 +1861,21 @@
1748
1861
  });
1749
1862
  var savePassBtn = document.getElementById("save-password-button");
1750
1863
  if (savePassBtn) savePassBtn.addEventListener("click", savePassword);
1864
+ // Settings tab clicks
1865
+ var settingsTabs = document.querySelectorAll(".settings-tab");
1866
+ for (var ti = 0; ti < settingsTabs.length; ti++) {
1867
+ settingsTabs[ti].addEventListener("click", function(e) {
1868
+ switchSettingsTab(e.target.getAttribute("data-tab"));
1869
+ });
1870
+ }
1871
+ var saveConfigBtn = document.getElementById("save-config-button");
1872
+ if (saveConfigBtn) saveConfigBtn.addEventListener("click", saveConfigSettings);
1873
+ var uploadCertBtn = document.getElementById("upload-cert-button");
1874
+ if (uploadCertBtn) uploadCertBtn.addEventListener("click", uploadCertificates);
1875
+ var checkUpdateBtn = document.getElementById("check-update-button");
1876
+ if (checkUpdateBtn) checkUpdateBtn.addEventListener("click", checkForUpdate);
1877
+ var doUpdateBtn = document.getElementById("do-update-button");
1878
+ if (doUpdateBtn) doUpdateBtn.addEventListener("click", performUpdate);
1751
1879
  var newSessBtn = document.getElementById("topbar-new-session-button");
1752
1880
  if (newSessBtn) newSessBtn.addEventListener("click", openSessionModal);
1753
1881
  var drawerNewSessBtn = document.getElementById("drawer-new-session-button");
@@ -2508,6 +2636,186 @@
2508
2636
  updateTerminalJumpToBottomButton();
2509
2637
  }
2510
2638
 
2639
+ // ===== Custom terminal scrollbar =====
2640
+ function initTerminalScrollbar(container) {
2641
+ var scrollbar = document.createElement("div");
2642
+ scrollbar.className = "terminal-scrollbar";
2643
+ var track = document.createElement("div");
2644
+ track.className = "terminal-scrollbar-track";
2645
+ var thumb = document.createElement("div");
2646
+ thumb.className = "terminal-scrollbar-thumb";
2647
+ track.appendChild(thumb);
2648
+ scrollbar.appendChild(track);
2649
+ container.appendChild(scrollbar);
2650
+
2651
+ state.terminalScrollbarEl = scrollbar;
2652
+ state.terminalScrollbarThumbEl = thumb;
2653
+ state.terminalScrollbarHideTimer = null;
2654
+ state.terminalScrollbarDragging = false;
2655
+ state.terminalScrollbarRafPending = false;
2656
+
2657
+ // Show/hide logic
2658
+ function showScrollbar() {
2659
+ if (state.terminalScrollbarHideTimer) {
2660
+ clearTimeout(state.terminalScrollbarHideTimer);
2661
+ state.terminalScrollbarHideTimer = null;
2662
+ }
2663
+ scrollbar.classList.add("visible");
2664
+ }
2665
+
2666
+ function scheduleHideScrollbar() {
2667
+ if (state.terminalScrollbarDragging) return;
2668
+ if (state.terminalScrollbarHideTimer) clearTimeout(state.terminalScrollbarHideTimer);
2669
+ state.terminalScrollbarHideTimer = setTimeout(function() {
2670
+ state.terminalScrollbarHideTimer = null;
2671
+ if (!state.terminalScrollbarDragging) {
2672
+ scrollbar.classList.remove("visible");
2673
+ }
2674
+ }, 1500);
2675
+ }
2676
+
2677
+ // Sync thumb position/size from viewport
2678
+ function syncScrollbarThumb() {
2679
+ state.terminalScrollbarRafPending = false;
2680
+ var viewport = getTerminalViewport();
2681
+ if (!viewport) return;
2682
+ var sh = viewport.scrollHeight;
2683
+ var ch = viewport.clientHeight;
2684
+ if (sh <= ch) {
2685
+ scrollbar.classList.remove("visible");
2686
+ return;
2687
+ }
2688
+ var trackH = track.clientHeight;
2689
+ var thumbH = Math.max(28, (ch / sh) * trackH);
2690
+ var maxScroll = sh - ch;
2691
+ var scrollRatio = viewport.scrollTop / maxScroll;
2692
+ var thumbTop = scrollRatio * (trackH - thumbH);
2693
+ thumb.style.height = thumbH + "px";
2694
+ thumb.style.top = thumbTop + "px";
2695
+ }
2696
+
2697
+ function requestSyncScrollbar() {
2698
+ if (state.terminalScrollbarRafPending) return;
2699
+ state.terminalScrollbarRafPending = true;
2700
+ requestAnimationFrame(syncScrollbarThumb);
2701
+ }
2702
+
2703
+ // Listen to viewport scroll
2704
+ var viewport = getTerminalViewport();
2705
+ if (viewport) {
2706
+ viewport.addEventListener("scroll", function() {
2707
+ showScrollbar();
2708
+ requestSyncScrollbar();
2709
+ scheduleHideScrollbar();
2710
+ }, { passive: true });
2711
+ }
2712
+
2713
+ // Track click → jump to position
2714
+ track.addEventListener("mousedown", function(e) {
2715
+ if (e.target === thumb) return;
2716
+ e.preventDefault();
2717
+ var viewport = getTerminalViewport();
2718
+ if (!viewport) return;
2719
+ var rect = track.getBoundingClientRect();
2720
+ var clickRatio = (e.clientY - rect.top) / rect.height;
2721
+ var maxScroll = viewport.scrollHeight - viewport.clientHeight;
2722
+ viewport.scrollTop = clickRatio * maxScroll;
2723
+ });
2724
+
2725
+ // Thumb drag — mouse
2726
+ var dragStartY = 0;
2727
+ var dragStartScrollTop = 0;
2728
+
2729
+ thumb.addEventListener("mousedown", function(e) {
2730
+ e.preventDefault();
2731
+ e.stopPropagation();
2732
+ state.terminalScrollbarDragging = true;
2733
+ thumb.classList.add("dragging");
2734
+ dragStartY = e.clientY;
2735
+ var viewport = getTerminalViewport();
2736
+ dragStartScrollTop = viewport ? viewport.scrollTop : 0;
2737
+ document.addEventListener("mousemove", onDragMove);
2738
+ document.addEventListener("mouseup", onDragEnd);
2739
+ });
2740
+
2741
+ function onDragMove(e) {
2742
+ e.preventDefault();
2743
+ var viewport = getTerminalViewport();
2744
+ if (!viewport) return;
2745
+ var trackH = track.clientHeight;
2746
+ var sh = viewport.scrollHeight;
2747
+ var ch = viewport.clientHeight;
2748
+ var maxScroll = sh - ch;
2749
+ if (maxScroll <= 0) return;
2750
+ var thumbH = Math.max(28, (ch / sh) * trackH);
2751
+ var scrollableTrack = trackH - thumbH;
2752
+ if (scrollableTrack <= 0) return;
2753
+ var deltaY = e.clientY - dragStartY;
2754
+ var scrollDelta = (deltaY / scrollableTrack) * maxScroll;
2755
+ viewport.scrollTop = dragStartScrollTop + scrollDelta;
2756
+ }
2757
+
2758
+ function onDragEnd() {
2759
+ state.terminalScrollbarDragging = false;
2760
+ thumb.classList.remove("dragging");
2761
+ document.removeEventListener("mousemove", onDragMove);
2762
+ document.removeEventListener("mouseup", onDragEnd);
2763
+ scheduleHideScrollbar();
2764
+ }
2765
+
2766
+ // Thumb drag — touch
2767
+ thumb.addEventListener("touchstart", function(e) {
2768
+ if (e.touches.length !== 1) return;
2769
+ e.stopPropagation();
2770
+ state.terminalScrollbarDragging = true;
2771
+ thumb.classList.add("dragging");
2772
+ dragStartY = e.touches[0].clientY;
2773
+ var viewport = getTerminalViewport();
2774
+ dragStartScrollTop = viewport ? viewport.scrollTop : 0;
2775
+ document.addEventListener("touchmove", onTouchDragMove, { passive: false });
2776
+ document.addEventListener("touchend", onTouchDragEnd);
2777
+ document.addEventListener("touchcancel", onTouchDragEnd);
2778
+ }, { passive: false });
2779
+
2780
+ function onTouchDragMove(e) {
2781
+ if (e.touches.length !== 1) return;
2782
+ e.preventDefault();
2783
+ var viewport = getTerminalViewport();
2784
+ if (!viewport) return;
2785
+ var trackH = track.clientHeight;
2786
+ var sh = viewport.scrollHeight;
2787
+ var ch = viewport.clientHeight;
2788
+ var maxScroll = sh - ch;
2789
+ if (maxScroll <= 0) return;
2790
+ var thumbH = Math.max(28, (ch / sh) * trackH);
2791
+ var scrollableTrack = trackH - thumbH;
2792
+ if (scrollableTrack <= 0) return;
2793
+ var deltaY = e.touches[0].clientY - dragStartY;
2794
+ var scrollDelta = (deltaY / scrollableTrack) * maxScroll;
2795
+ viewport.scrollTop = dragStartScrollTop + scrollDelta;
2796
+ }
2797
+
2798
+ function onTouchDragEnd() {
2799
+ state.terminalScrollbarDragging = false;
2800
+ thumb.classList.remove("dragging");
2801
+ document.removeEventListener("touchmove", onTouchDragMove);
2802
+ document.removeEventListener("touchend", onTouchDragEnd);
2803
+ document.removeEventListener("touchcancel", onTouchDragEnd);
2804
+ scheduleHideScrollbar();
2805
+ }
2806
+
2807
+ // Hover on scrollbar area shows it
2808
+ scrollbar.addEventListener("mouseenter", function() {
2809
+ showScrollbar();
2810
+ });
2811
+ scrollbar.addEventListener("mouseleave", function() {
2812
+ if (!state.terminalScrollbarDragging) scheduleHideScrollbar();
2813
+ });
2814
+
2815
+ // Initial sync
2816
+ requestSyncScrollbar();
2817
+ }
2818
+
2511
2819
  function syncTerminalBuffer(sessionId, output, options) {
2512
2820
  if (!state.terminal) return false;
2513
2821
  var normalizedOutput = normalizeTerminalOutput(output || "");
@@ -2590,9 +2898,15 @@
2590
2898
  var container = document.getElementById("output");
2591
2899
  if (!container || state.terminal) return;
2592
2900
  if (typeof Terminal === "undefined") {
2593
- // xterm.js failed to load - terminal features unavailable
2901
+ // xterm.js not yet loaded retry after a short delay
2902
+ if (!state.terminalInitRetries) state.terminalInitRetries = 0;
2903
+ if (state.terminalInitRetries < 10) {
2904
+ state.terminalInitRetries++;
2905
+ setTimeout(initTerminal, 200);
2906
+ }
2594
2907
  return;
2595
2908
  }
2909
+ state.terminalInitRetries = 0;
2596
2910
 
2597
2911
  state.terminal = new Terminal({
2598
2912
  cols: 120,
@@ -2682,6 +2996,9 @@
2682
2996
  e.stopPropagation();
2683
2997
  }, { passive: true });
2684
2998
 
2999
+ // Create custom scrollbar overlay
3000
+ initTerminalScrollbar(container);
3001
+
2685
3002
  if (state.selectedId) {
2686
3003
  var session = state.sessions.find(function(s) { return s.id === state.selectedId; });
2687
3004
  if (session) {
@@ -2974,6 +3291,9 @@
2974
3291
  }
2975
3292
  }
2976
3293
  updateShellChrome();
3294
+ })
3295
+ .catch(function(e) {
3296
+ console.error("[wand] loadSessions failed:", e);
2977
3297
  });
2978
3298
  }
2979
3299
 
@@ -3231,10 +3551,16 @@
3231
3551
  if (modal) {
3232
3552
  modal.classList.remove("hidden");
3233
3553
  lastFocusedElement = document.activeElement;
3234
- document.getElementById("new-password").value = "";
3235
- document.getElementById("confirm-password").value = "";
3554
+ var passEl = document.getElementById("new-password");
3555
+ var confirmEl = document.getElementById("confirm-password");
3556
+ if (passEl) passEl.value = "";
3557
+ if (confirmEl) confirmEl.value = "";
3236
3558
  hideSettingsMessages();
3237
3559
  setupFocusTrap(modal);
3560
+ // Activate first tab
3561
+ switchSettingsTab("about");
3562
+ // Load settings data
3563
+ loadSettingsData();
3238
3564
  }
3239
3565
  }
3240
3566
 
@@ -3305,6 +3631,247 @@
3305
3631
  });
3306
3632
  }
3307
3633
 
3634
+ // ── Settings tab/panel logic ──
3635
+
3636
+ function switchSettingsTab(tabName) {
3637
+ var tabs = document.querySelectorAll(".settings-tab");
3638
+ var panels = document.querySelectorAll(".settings-panel");
3639
+ for (var i = 0; i < tabs.length; i++) {
3640
+ if (tabs[i].getAttribute("data-tab") === tabName) {
3641
+ tabs[i].classList.add("active");
3642
+ } else {
3643
+ tabs[i].classList.remove("active");
3644
+ }
3645
+ }
3646
+ for (var j = 0; j < panels.length; j++) {
3647
+ if (panels[j].id === "settings-tab-" + tabName) {
3648
+ panels[j].classList.add("active");
3649
+ } else {
3650
+ panels[j].classList.remove("active");
3651
+ }
3652
+ }
3653
+ }
3654
+
3655
+ function loadSettingsData() {
3656
+ fetch("/api/settings", { credentials: "same-origin" })
3657
+ .then(function(res) { return res.json(); })
3658
+ .then(function(data) {
3659
+ // About
3660
+ var nameEl = document.getElementById("settings-pkg-name");
3661
+ var verEl = document.getElementById("settings-version");
3662
+ var nodeEl = document.getElementById("settings-node-req");
3663
+ var repoEl = document.getElementById("settings-repo-url");
3664
+ if (nameEl) nameEl.textContent = data.packageName || "-";
3665
+ if (verEl) verEl.textContent = data.version || "-";
3666
+ if (nodeEl) nodeEl.textContent = data.nodeVersion || "-";
3667
+ if (repoEl && data.repoUrl) {
3668
+ repoEl.innerHTML = '<a href="' + escapeHtml(data.repoUrl) + '" target="_blank" rel="noopener">' + escapeHtml(data.repoUrl) + '</a>';
3669
+ }
3670
+
3671
+ // Config fields
3672
+ var cfg = data.config || {};
3673
+ var hostEl = document.getElementById("cfg-host");
3674
+ var portEl = document.getElementById("cfg-port");
3675
+ var httpsEl = document.getElementById("cfg-https");
3676
+ var modeEl = document.getElementById("cfg-mode");
3677
+ var cwdEl = document.getElementById("cfg-cwd");
3678
+ var shellEl = document.getElementById("cfg-shell");
3679
+ if (hostEl) hostEl.value = cfg.host || "";
3680
+ if (portEl) portEl.value = cfg.port || "";
3681
+ if (httpsEl) httpsEl.checked = cfg.https === true;
3682
+ if (modeEl) modeEl.value = cfg.defaultMode || "default";
3683
+ if (cwdEl) cwdEl.value = cfg.defaultCwd || "";
3684
+ if (shellEl) shellEl.value = cfg.shell || "";
3685
+
3686
+ // Cert status
3687
+ var certStatus = document.getElementById("cert-status");
3688
+ if (certStatus) {
3689
+ certStatus.textContent = data.hasCert ? "已安装 SSL 证书" : "未安装证书(使用自签名或 HTTP)";
3690
+ certStatus.style.color = data.hasCert ? "var(--success)" : "var(--text-secondary)";
3691
+ }
3692
+
3693
+ // Presets
3694
+ var presetsList = document.getElementById("presets-list");
3695
+ if (presetsList && cfg.commandPresets) {
3696
+ var html = "";
3697
+ for (var i = 0; i < cfg.commandPresets.length; i++) {
3698
+ var p = cfg.commandPresets[i];
3699
+ html += '<div class="preset-item">' +
3700
+ '<span class="preset-label">' + escapeHtml(p.label) + '</span>' +
3701
+ '<span class="preset-detail">' + escapeHtml(p.command) + (p.mode ? ' (' + escapeHtml(p.mode) + ')' : '') + '</span>' +
3702
+ '</div>';
3703
+ }
3704
+ if (!html) html = '<p class="settings-hint">没有命令预设。</p>';
3705
+ presetsList.innerHTML = html;
3706
+ }
3707
+ })
3708
+ .catch(function() {});
3709
+ }
3710
+
3711
+ function saveConfigSettings() {
3712
+ var msgEl = document.getElementById("config-message");
3713
+ if (msgEl) { msgEl.classList.add("hidden"); msgEl.textContent = ""; }
3714
+
3715
+ var body = {
3716
+ host: (document.getElementById("cfg-host") || {}).value,
3717
+ port: Number((document.getElementById("cfg-port") || {}).value),
3718
+ https: (document.getElementById("cfg-https") || {}).checked,
3719
+ defaultMode: (document.getElementById("cfg-mode") || {}).value,
3720
+ defaultCwd: (document.getElementById("cfg-cwd") || {}).value,
3721
+ shell: (document.getElementById("cfg-shell") || {}).value,
3722
+ };
3723
+
3724
+ fetch("/api/settings/config", {
3725
+ method: "POST",
3726
+ headers: { "Content-Type": "application/json" },
3727
+ credentials: "same-origin",
3728
+ body: JSON.stringify(body)
3729
+ })
3730
+ .then(function(res) { return res.json(); })
3731
+ .then(function(data) {
3732
+ if (msgEl) {
3733
+ if (data.error) {
3734
+ msgEl.textContent = data.error;
3735
+ msgEl.style.color = "var(--error)";
3736
+ } else {
3737
+ msgEl.textContent = "配置已保存,部分更改需要重启后生效。";
3738
+ msgEl.style.color = "var(--success)";
3739
+ }
3740
+ msgEl.classList.remove("hidden");
3741
+ }
3742
+ })
3743
+ .catch(function() {
3744
+ if (msgEl) {
3745
+ msgEl.textContent = "保存失败。";
3746
+ msgEl.style.color = "var(--error)";
3747
+ msgEl.classList.remove("hidden");
3748
+ }
3749
+ });
3750
+ }
3751
+
3752
+ function uploadCertificates() {
3753
+ var keyFile = document.getElementById("cert-key-file");
3754
+ var certFile = document.getElementById("cert-cert-file");
3755
+ var msgEl = document.getElementById("cert-message");
3756
+ if (msgEl) { msgEl.classList.add("hidden"); msgEl.textContent = ""; }
3757
+
3758
+ if (!keyFile || !keyFile.files[0] || !certFile || !certFile.files[0]) {
3759
+ if (msgEl) {
3760
+ msgEl.textContent = "请选择私钥和证书文件。";
3761
+ msgEl.style.color = "var(--error)";
3762
+ msgEl.classList.remove("hidden");
3763
+ }
3764
+ return;
3765
+ }
3766
+
3767
+ var keyReader = new FileReader();
3768
+ keyReader.onload = function() {
3769
+ var keyContent = keyReader.result;
3770
+ var certReader = new FileReader();
3771
+ certReader.onload = function() {
3772
+ var certContent = certReader.result;
3773
+ fetch("/api/settings/upload-cert", {
3774
+ method: "POST",
3775
+ headers: { "Content-Type": "application/json" },
3776
+ credentials: "same-origin",
3777
+ body: JSON.stringify({ key: keyContent, cert: certContent })
3778
+ })
3779
+ .then(function(res) { return res.json(); })
3780
+ .then(function(data) {
3781
+ if (msgEl) {
3782
+ if (data.error) {
3783
+ msgEl.textContent = data.error;
3784
+ msgEl.style.color = "var(--error)";
3785
+ } else {
3786
+ msgEl.textContent = "证书已上传,重启后生效。";
3787
+ msgEl.style.color = "var(--success)";
3788
+ // Update cert status
3789
+ var certStatus = document.getElementById("cert-status");
3790
+ if (certStatus) {
3791
+ certStatus.textContent = "已安装 SSL 证书";
3792
+ certStatus.style.color = "var(--success)";
3793
+ }
3794
+ }
3795
+ msgEl.classList.remove("hidden");
3796
+ }
3797
+ })
3798
+ .catch(function() {
3799
+ if (msgEl) {
3800
+ msgEl.textContent = "上传失败。";
3801
+ msgEl.style.color = "var(--error)";
3802
+ msgEl.classList.remove("hidden");
3803
+ }
3804
+ });
3805
+ };
3806
+ certReader.readAsText(certFile.files[0]);
3807
+ };
3808
+ keyReader.readAsText(keyFile.files[0]);
3809
+ }
3810
+
3811
+ function checkForUpdate() {
3812
+ var latestEl = document.getElementById("settings-latest-version");
3813
+ var updateBtn = document.getElementById("do-update-button");
3814
+ var msgEl = document.getElementById("update-message");
3815
+ if (latestEl) latestEl.textContent = "检查中...";
3816
+ if (msgEl) msgEl.classList.add("hidden");
3817
+ if (updateBtn) updateBtn.classList.add("hidden");
3818
+
3819
+ fetch("/api/check-update", { credentials: "same-origin" })
3820
+ .then(function(res) { return res.json(); })
3821
+ .then(function(data) {
3822
+ if (data.error) {
3823
+ if (latestEl) latestEl.textContent = "检查失败";
3824
+ return;
3825
+ }
3826
+ if (latestEl) latestEl.textContent = data.latest;
3827
+ if (data.updateAvailable && updateBtn) {
3828
+ updateBtn.classList.remove("hidden");
3829
+ }
3830
+ if (!data.updateAvailable && msgEl) {
3831
+ msgEl.textContent = "已是最新版本。";
3832
+ msgEl.style.color = "var(--success)";
3833
+ msgEl.classList.remove("hidden");
3834
+ }
3835
+ })
3836
+ .catch(function() {
3837
+ if (latestEl) latestEl.textContent = "检查失败";
3838
+ });
3839
+ }
3840
+
3841
+ function performUpdate() {
3842
+ var msgEl = document.getElementById("update-message");
3843
+ var updateBtn = document.getElementById("do-update-button");
3844
+ if (updateBtn) updateBtn.disabled = true;
3845
+ if (msgEl) {
3846
+ msgEl.textContent = "正在更新,请稍候...";
3847
+ msgEl.style.color = "var(--text-secondary)";
3848
+ msgEl.classList.remove("hidden");
3849
+ }
3850
+
3851
+ fetch("/api/update", {
3852
+ method: "POST",
3853
+ headers: { "Content-Type": "application/json" },
3854
+ credentials: "same-origin"
3855
+ })
3856
+ .then(function(res) { return res.json(); })
3857
+ .then(function(data) {
3858
+ if (msgEl) {
3859
+ msgEl.textContent = data.message || data.error || "更新请求已发送。";
3860
+ msgEl.style.color = data.error ? "var(--error)" : "var(--success)";
3861
+ msgEl.classList.remove("hidden");
3862
+ }
3863
+ if (updateBtn) updateBtn.disabled = false;
3864
+ })
3865
+ .catch(function() {
3866
+ if (msgEl) {
3867
+ msgEl.textContent = "更新失败。";
3868
+ msgEl.style.color = "var(--error)";
3869
+ msgEl.classList.remove("hidden");
3870
+ }
3871
+ if (updateBtn) updateBtn.disabled = false;
3872
+ });
3873
+ }
3874
+
3308
3875
  function quickStartSession() {
3309
3876
  var command = getPreferredTool();
3310
3877
  var defaultCwd = state.workingDir || (state.config && state.config.defaultCwd ? state.config.defaultCwd : "");
@@ -3524,7 +4091,7 @@
3524
4091
 
3525
4092
  if (event.key === "Escape") {
3526
4093
  event.preventDefault();
3527
- queueDirectInput(getControlInput("escape"));
4094
+ queueDirectInput(getControlInput("escape"), "escape");
3528
4095
  return;
3529
4096
  }
3530
4097
 
@@ -3536,7 +4103,7 @@
3536
4103
  return; // Let browser handle copy
3537
4104
  }
3538
4105
  event.preventDefault();
3539
- queueDirectInput(getControlInput("ctrl_c"));
4106
+ queueDirectInput(getControlInput("ctrl_c"), "ctrl_c");
3540
4107
  return;
3541
4108
  }
3542
4109
 
@@ -3548,13 +4115,13 @@
3548
4115
  return; // Let browser handle copy
3549
4116
  }
3550
4117
  event.preventDefault();
3551
- queueDirectInput(getControlInput("ctrl_d"));
4118
+ queueDirectInput(getControlInput("ctrl_d"), "ctrl_d");
3552
4119
  return;
3553
4120
  }
3554
4121
 
3555
4122
  if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "l") {
3556
4123
  event.preventDefault();
3557
- queueDirectInput(getControlInput("ctrl_l"));
4124
+ queueDirectInput(getControlInput("ctrl_l"), "ctrl_l");
3558
4125
  return;
3559
4126
  }
3560
4127
 
@@ -3580,7 +4147,7 @@
3580
4147
  }
3581
4148
  // No selection: send Ctrl+X to terminal (rare case)
3582
4149
  event.preventDefault();
3583
- queueDirectInput(String.fromCharCode(24)); // Ctrl+X = 0x18
4150
+ queueDirectInput(String.fromCharCode(24), "ctrl_x"); // Ctrl+X = 0x18
3584
4151
  return;
3585
4152
  }
3586
4153
 
@@ -3877,7 +4444,7 @@
3877
4444
  showToast("会话未就绪,将稍后重试。", "info");
3878
4445
  return null;
3879
4446
  }
3880
- return queueDirectInput(combinedInput).then(function() {
4447
+ return queueDirectInput(combinedInput, "enter_text").then(function() {
3881
4448
  // Clear input only after the send succeeds
3882
4449
  if (inputBox && inputBox.value === value) {
3883
4450
  inputBox.value = "";
@@ -3972,12 +4539,12 @@
3972
4539
  });
3973
4540
  }
3974
4541
 
3975
- function queueDirectInput(input) {
4542
+ function queueDirectInput(input, shortcutKey) {
3976
4543
  if (!input || !state.selectedId) return Promise.resolve();
3977
4544
  state.messageQueue.push(input);
3978
4545
  updateQueueCounter();
3979
4546
  state.inputQueue = state.inputQueue.then(function() {
3980
- return postInput(input).finally(function() {
4547
+ return postInput(input, shortcutKey).finally(function() {
3981
4548
  var idx = state.messageQueue.indexOf(input);
3982
4549
  if (idx > -1) state.messageQueue.splice(idx, 1);
3983
4550
  updateQueueCounter();
@@ -3986,7 +4553,7 @@
3986
4553
  return state.inputQueue;
3987
4554
  }
3988
4555
 
3989
- function postInput(input) {
4556
+ function postInput(input, shortcutKey) {
3990
4557
  if (!state.selectedId) return Promise.resolve();
3991
4558
 
3992
4559
  // Pre-check: don't send if session is not running
@@ -4034,7 +4601,7 @@
4034
4601
  method: "POST",
4035
4602
  headers: { "Content-Type": "application/json" },
4036
4603
  credentials: "same-origin",
4037
- body: JSON.stringify({ input: input, view: state.currentView })
4604
+ body: JSON.stringify({ input: input, view: state.currentView, shortcutKey: shortcutKey || undefined })
4038
4605
  })
4039
4606
  .then(function(res) {
4040
4607
  if (!res.ok) {
@@ -4169,9 +4736,9 @@
4169
4736
  };
4170
4737
  }
4171
4738
 
4172
- function sendTerminalSequence(sequence) {
4739
+ function sendTerminalSequence(sequence, shortcutKey) {
4173
4740
  if (!sequence) return;
4174
- queueDirectInput(sequence).catch(function() {});
4741
+ queueDirectInput(sequence, shortcutKey).catch(function() {});
4175
4742
  }
4176
4743
 
4177
4744
  function focusTerminalInteractionTarget() {
@@ -4265,7 +4832,7 @@
4265
4832
  var mods = getModifierStateFromEvent(event, key);
4266
4833
  if (isModifierKey(key)) return;
4267
4834
  var sequence = buildPtySequence(key, mods);
4268
- if (sequence) sendTerminalSequence(sequence);
4835
+ if (sequence) sendTerminalSequence(sequence, key);
4269
4836
  }
4270
4837
 
4271
4838
  function handleMiniKeyboardClick(event) {
@@ -4280,7 +4847,7 @@
4280
4847
  return;
4281
4848
  }
4282
4849
  var sequence = buildPtySequence(key, { ctrl: state.modifiers.ctrl, alt: state.modifiers.alt, shift: state.modifiers.shift });
4283
- if (sequence) sendTerminalSequence(sequence);
4850
+ if (sequence) sendTerminalSequence(sequence, key);
4284
4851
  clearModifiers();
4285
4852
  }
4286
4853
 
@@ -4297,11 +4864,11 @@
4297
4864
  }
4298
4865
  if (key === "ctrl_enter") {
4299
4866
  var sequence = buildPtySequence("enter", { ctrl: true, alt: false, shift: false });
4300
- if (sequence) sendTerminalSequence(sequence);
4867
+ if (sequence) sendTerminalSequence(sequence, "ctrl_enter");
4301
4868
  return;
4302
4869
  }
4303
4870
  var sequence = buildPtySequence(key, { ctrl: state.modifiers.ctrl, alt: state.modifiers.alt, shift: false });
4304
- if (sequence) sendTerminalSequence(sequence);
4871
+ if (sequence) sendTerminalSequence(sequence, key);
4305
4872
  clearModifiers();
4306
4873
  updateKeyboardPopupUI();
4307
4874
  }
@@ -4905,6 +5472,11 @@
4905
5472
 
4906
5473
  function handleInputBoxBlur() {
4907
5474
  resetInputPanelViewportSpacing();
5475
+ // Restore app container height when keyboard closes
5476
+ var appContainer = document.querySelector('.app-container');
5477
+ if (appContainer) {
5478
+ appContainer.style.height = '';
5479
+ }
4908
5480
  }
4909
5481
 
4910
5482
  function adjustInputBoxSelection(inputBox) {
@@ -5610,17 +6182,27 @@
5610
6182
  if (!('visualViewport' in window)) return;
5611
6183
 
5612
6184
  var vv = window.visualViewport;
5613
- var inputPanel = document.querySelector('.input-panel');
5614
6185
  var lastHeight = vv.height;
5615
6186
  var keyboardOpen = false;
5616
6187
 
5617
6188
  function updateViewport() {
5618
- if (!inputPanel || !vv) return;
6189
+ if (!vv) return;
5619
6190
  var inputBox = document.getElementById('input-box');
5620
6191
  var offsetBottom = window.innerHeight - vv.height - vv.offsetTop;
5621
6192
  var isKeyboardOpen = offsetBottom > 50;
5622
6193
  var heightChanged = Math.abs(vv.height - lastHeight) > 8;
5623
6194
 
6195
+ // In PWA standalone mode, dynamically resize the app container
6196
+ // because 100dvh does NOT shrink when keyboard appears in standalone PWA
6197
+ var appContainer = document.querySelector('.app-container');
6198
+ if (appContainer) {
6199
+ if (isKeyboardOpen) {
6200
+ appContainer.style.height = vv.height + 'px';
6201
+ } else {
6202
+ appContainer.style.height = '';
6203
+ }
6204
+ }
6205
+
5624
6206
  if (isKeyboardOpen && (!keyboardOpen || heightChanged) && shouldAdjustForKeyboard(vv, inputBox)) {
5625
6207
  scrollLatestMessageIntoView();
5626
6208
  syncInputBoxScroll(inputBox);