@co0ontty/wand 1.3.0 → 1.3.4

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,6 +21,8 @@ interface PermissionState {
21
21
  lastTarget: string | null;
22
22
  /** Timestamp of last auto-confirm to prevent rapid repeats */
23
23
  lastAutoConfirmAt: number;
24
+ /** Timer for delayed auto-approve (gives CLI time to be ready) */
25
+ pendingAutoApproveTimer: ReturnType<typeof setTimeout> | null;
24
26
  }
25
27
  /** Permission resolution result */
26
28
  export type PermissionResolution = "approve_once" | "approve_turn" | "deny";
@@ -118,6 +120,12 @@ export declare class ClaudePtyBridge extends EventEmitter {
118
120
  private isRealChatInput;
119
121
  private captureSessionId;
120
122
  private detectPermission;
123
+ /**
124
+ * Schedule a delayed auto-approve. The delay gives the Claude CLI's interactive
125
+ * selection prompt time to fully render and enter its input loop before we send \r.
126
+ */
127
+ private scheduleAutoApprove;
128
+ private cancelPendingAutoApprove;
121
129
  private isPermissionPromptDetected;
122
130
  private extractPromptText;
123
131
  private extractPermissionTarget;
@@ -11,7 +11,8 @@ import { stripAnsi, isNoiseLine, appendWindow, normalizePromptText, hasExplicitC
11
11
  // ── Constants ──
12
12
  const OUTPUT_MAX_SIZE = 120000;
13
13
  const SESSION_ID_WINDOW_SIZE = 16384;
14
- const PERMISSION_WINDOW_SIZE = 800;
14
+ const PERMISSION_WINDOW_SIZE = 2000;
15
+ const AUTO_APPROVE_DELAY_MS = 150;
15
16
  const UUID_PATTERN = "([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})";
16
17
  const CLAUDE_SESSION_ID_PATTERNS = [
17
18
  new RegExp(`"session_id"\\s*:\\s*"${UUID_PATTERN}"`, "i"),
@@ -85,6 +86,7 @@ export class ClaudePtyBridge extends EventEmitter {
85
86
  lastScope: null,
86
87
  lastTarget: null,
87
88
  lastAutoConfirmAt: 0,
89
+ pendingAutoApproveTimer: null,
88
90
  };
89
91
  this.sessionIdWindow = "";
90
92
  }
@@ -178,6 +180,7 @@ export class ClaudePtyBridge extends EventEmitter {
178
180
  this.taskDebounceTimer = null;
179
181
  }
180
182
  // Clear permission state — prevents stale blocked state after exit
183
+ this.cancelPendingAutoApprove();
181
184
  this.permissionState.isBlocked = false;
182
185
  this.permissionState.lastPrompt = null;
183
186
  this.permissionState.lastScope = null;
@@ -234,6 +237,8 @@ export class ClaudePtyBridge extends EventEmitter {
234
237
  this.rememberedTargets.add(this.permissionState.lastTarget);
235
238
  }
236
239
  }
240
+ // Cancel any pending auto-approve timer (user resolved manually)
241
+ this.cancelPendingAutoApprove();
237
242
  // Send approval/denial to PTY
238
243
  if (this.ptyWrite) {
239
244
  if (resolution === "deny") {
@@ -277,6 +282,7 @@ export class ClaudePtyBridge extends EventEmitter {
277
282
  * Clear permission blocked state (called when permission is resolved externally).
278
283
  */
279
284
  clearPermissionBlocked() {
285
+ this.cancelPendingAutoApprove();
280
286
  this.permissionState.isBlocked = false;
281
287
  this.permissionState.window = "";
282
288
  this.permissionState.lastPrompt = null;
@@ -342,25 +348,7 @@ export class ClaudePtyBridge extends EventEmitter {
342
348
  this.permissionState.lastTarget = target ?? null;
343
349
  const shouldAutoApprove = this.autoApprove || this.shouldAutoApprove(scope, target);
344
350
  if (shouldAutoApprove) {
345
- const now = Date.now();
346
- if (now - this.permissionState.lastAutoConfirmAt < 500)
347
- return;
348
- this.permissionState.lastAutoConfirmAt = now;
349
- process.stderr.write(`[wand] Auto-confirming permission for ${scope}${target ? `: ${target}` : ""}\n`);
350
- if (this.ptyWrite) {
351
- this.ptyWrite("\r");
352
- }
353
- this.permissionState.isBlocked = false;
354
- this.permissionState.window = "";
355
- this.permissionState.lastPrompt = null;
356
- this.permissionState.lastScope = null;
357
- this.permissionState.lastTarget = null;
358
- this.emitEvent({
359
- type: "permission.resolved",
360
- sessionId: this.sessionId,
361
- timestamp: Date.now(),
362
- data: { resolution: "approve_once", autoApproved: true },
363
- });
351
+ this.scheduleAutoApprove(scope, target);
364
352
  }
365
353
  else {
366
354
  this.emitEvent({
@@ -385,28 +373,7 @@ export class ClaudePtyBridge extends EventEmitter {
385
373
  // Check if we should auto-approve
386
374
  const shouldAutoApprove = this.autoApprove || this.shouldAutoApprove(scope, target);
387
375
  if (shouldAutoApprove) {
388
- // Debounce auto-confirm to avoid rapid repeats
389
- const now = Date.now();
390
- if (now - this.permissionState.lastAutoConfirmAt < 500)
391
- return;
392
- this.permissionState.lastAutoConfirmAt = now;
393
- process.stderr.write(`[wand] Auto-confirming permission for ${scope}${target ? `: ${target}` : ""}\n`);
394
- // Send approval to PTY
395
- if (this.ptyWrite) {
396
- this.ptyWrite("\r");
397
- }
398
- // Clear blocked state immediately
399
- this.permissionState.isBlocked = false;
400
- this.permissionState.window = "";
401
- this.permissionState.lastPrompt = null;
402
- this.permissionState.lastScope = null;
403
- this.permissionState.lastTarget = null;
404
- this.emitEvent({
405
- type: "permission.resolved",
406
- sessionId: this.sessionId,
407
- timestamp: Date.now(),
408
- data: { resolution: "approve_once", autoApproved: true },
409
- });
376
+ this.scheduleAutoApprove(scope, target);
410
377
  }
411
378
  else {
412
379
  // Emit permission prompt event for UI to handle
@@ -430,6 +397,46 @@ export class ClaudePtyBridge extends EventEmitter {
430
397
  });
431
398
  }
432
399
  }
400
+ /**
401
+ * Schedule a delayed auto-approve. The delay gives the Claude CLI's interactive
402
+ * selection prompt time to fully render and enter its input loop before we send \r.
403
+ */
404
+ scheduleAutoApprove(scope, target) {
405
+ // Debounce: skip if another auto-approve was recently sent or is pending
406
+ const now = Date.now();
407
+ if (now - this.permissionState.lastAutoConfirmAt < 500)
408
+ return;
409
+ if (this.permissionState.pendingAutoApproveTimer)
410
+ return;
411
+ this.permissionState.lastAutoConfirmAt = now;
412
+ process.stderr.write(`[wand] Scheduling auto-confirm for ${scope}${target ? `: ${target}` : ""} (${AUTO_APPROVE_DELAY_MS}ms)\n`);
413
+ this.permissionState.pendingAutoApproveTimer = setTimeout(() => {
414
+ this.permissionState.pendingAutoApproveTimer = null;
415
+ if (this._exited)
416
+ return;
417
+ process.stderr.write(`[wand] Auto-confirming permission for ${scope}${target ? `: ${target}` : ""}\n`);
418
+ if (this.ptyWrite) {
419
+ this.ptyWrite("\r");
420
+ }
421
+ this.permissionState.isBlocked = false;
422
+ this.permissionState.window = "";
423
+ this.permissionState.lastPrompt = null;
424
+ this.permissionState.lastScope = null;
425
+ this.permissionState.lastTarget = null;
426
+ this.emitEvent({
427
+ type: "permission.resolved",
428
+ sessionId: this.sessionId,
429
+ timestamp: Date.now(),
430
+ data: { resolution: "approve_once", autoApproved: true },
431
+ });
432
+ }, AUTO_APPROVE_DELAY_MS);
433
+ }
434
+ cancelPendingAutoApprove() {
435
+ if (this.permissionState.pendingAutoApproveTimer) {
436
+ clearTimeout(this.permissionState.pendingAutoApproveTimer);
437
+ this.permissionState.pendingAutoApproveTimer = null;
438
+ }
439
+ }
433
440
  isPermissionPromptDetected(normalized) {
434
441
  const hasIntent = /\bdo you want to\b/i.test(normalized)
435
442
  || /\bwould you like to\b/i.test(normalized)
@@ -438,11 +445,16 @@ export class ClaudePtyBridge extends EventEmitter {
438
445
  || /\bhaven't granted\b/i.test(normalized);
439
446
  const hasConfirmSyntax = hasExplicitConfirmSyntax(normalized);
440
447
  const hasActionCtx = hasPermissionActionContext(normalized);
448
+ // For numbered selection prompts (Claude CLI v2+), require the readiness marker
449
+ // "Esc to cancel" / "Tab to amend" which appears only after the full menu is rendered
450
+ // and the input handler is active
451
+ const hasReadyMarker = /\besc\b.*\bcancel\b/i.test(normalized)
452
+ || /\btab\b.*\bamend\b/i.test(normalized);
441
453
  // Intent phrase + explicit confirm syntax (e.g. "Do you want to proceed? (yes/no)")
442
454
  if (hasIntent && hasConfirmSyntax)
443
455
  return true;
444
- // Intent phrase + action keyword (e.g. "Do you want to execute this command?")
445
- if (hasIntent && hasActionCtx)
456
+ // Intent phrase + action keyword + readiness marker (numbered selection prompts)
457
+ if (hasIntent && hasActionCtx && hasReadyMarker)
446
458
  return true;
447
459
  // Standalone confirm syntax + action keyword (e.g. "[y/n] Allow bash command")
448
460
  if (hasConfirmSyntax && hasActionCtx)
package/dist/config.js CHANGED
@@ -15,6 +15,7 @@ export const defaultConfig = () => ({
15
15
  startupCommands: [],
16
16
  allowedCommandPrefixes: [],
17
17
  shortcutLogMaxBytes: 10 * 1024 * 1024,
18
+ experimentalDomTerminal: false,
18
19
  commandPresets: [
19
20
  {
20
21
  label: "Claude",
@@ -499,7 +499,7 @@ function shouldBackfillClaudeSessionId(record) {
499
499
  function snapshotMessages(record) {
500
500
  return record.ptyBridge?.getMessages() ?? record.messages;
501
501
  }
502
- const MAX_SESSIONS = 50;
502
+ const MAX_SESSIONS = 200;
503
503
  const ARCHIVE_AFTER_MS = 1000 * 60 * 60 * 24;
504
504
  const CONFIRM_WINDOW_SIZE = 800;
505
505
  // Claude 会话 ID 格式:UUID v4
@@ -651,24 +651,33 @@ export class ProcessManager extends EventEmitter {
651
651
  this.emit("process", event);
652
652
  }
653
653
  cleanupOldSessions() {
654
- // Remove oldest finished sessions if we're at the limit
654
+ // Only clean up when well over the limit
655
655
  if (this.sessions.size < MAX_SESSIONS)
656
656
  return;
657
- const finishedIds = [];
657
+ const now = Date.now();
658
+ const STALE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
659
+ const removable = [];
658
660
  for (const [id, record] of this.sessions) {
659
- if (record.status !== "running") {
660
- finishedIds.push(id);
661
+ // Only remove archived, non-running sessions older than 7 days
662
+ if (record.status === "running")
663
+ continue;
664
+ if (!record.archived)
665
+ continue;
666
+ const ref = record.endedAt ?? record.startedAt;
667
+ const refMs = Date.parse(ref);
668
+ if (Number.isFinite(refMs) && now - refMs > STALE_MS) {
669
+ removable.push(id);
661
670
  }
662
671
  }
663
- // Remove oldest finished sessions first
664
- finishedIds
672
+ // Sort oldest first and remove enough to get back under the limit
673
+ const toRemove = removable
665
674
  .sort((a, b) => {
666
675
  const ra = this.sessions.get(a);
667
676
  const rb = this.sessions.get(b);
668
677
  return (ra?.endedAt || "").localeCompare(rb?.endedAt || "");
669
678
  })
670
- .slice(0, this.sessions.size - MAX_SESSIONS + 1)
671
- .forEach((id) => {
679
+ .slice(0, this.sessions.size - MAX_SESSIONS + 1);
680
+ for (const id of toRemove) {
672
681
  const record = this.sessions.get(id);
673
682
  if (record) {
674
683
  this.logger.deleteSession(id);
@@ -677,7 +686,7 @@ export class ProcessManager extends EventEmitter {
677
686
  this.sessions.delete(id);
678
687
  this.lastPersistedMessageCount.delete(id);
679
688
  this.storage.deleteSession(id);
680
- });
689
+ }
681
690
  }
682
691
  start(command, cwd, mode, initialInput, opts) {
683
692
  this.assertCommandAllowed(command);
@@ -989,8 +998,10 @@ export class ProcessManager extends EventEmitter {
989
998
  get(id) {
990
999
  this.archiveExpiredSessions();
991
1000
  const record = this.sessions.get(id);
992
- if (!record)
993
- return null;
1001
+ if (!record) {
1002
+ // Fallback: check SQLite for sessions that were evicted from memory
1003
+ return this.storage.getSession(id) ?? null;
1004
+ }
994
1005
  // For sessions loaded from storage on startup, in-memory output starts empty.
995
1006
  // Prefer in-memory output (live PTY data), fall back to stored output.
996
1007
  if (!record.output && record.storedOutput) {
package/dist/pwa.js CHANGED
@@ -48,7 +48,8 @@ const STATIC_ASSETS = [
48
48
  '/icon-512.png',
49
49
  '/vendor/xterm/css/xterm.css',
50
50
  '/vendor/xterm/lib/xterm.js',
51
- '/vendor/xterm-addon-fit/lib/addon-fit.js'
51
+ '/vendor/xterm-addon-fit/lib/addon-fit.js',
52
+ '/vendor/xterm-addon-serialize/lib/xterm-addon-serialize.js'
52
53
  ];
53
54
 
54
55
  self.addEventListener('install', (event) => {
@@ -130,7 +130,7 @@ export function registerSessionRoutes(app, processes, storage, defaultMode) {
130
130
  const newMode = body.mode ? normalizeMode(body.mode, defaultMode) : normalizeMode(existingSession.mode, defaultMode);
131
131
  const resumeCommand = `${command} --resume ${claudeSessionId}`;
132
132
  const newSnapshot = processes.start(resumeCommand, existingSession.cwd, newMode, undefined, { resumedFromSessionId: sessionId });
133
- storage.saveSession({ ...existingSession, resumedToSessionId: newSnapshot.id });
133
+ storage.saveSession({ ...existingSession, resumedToSessionId: newSnapshot.id, archived: true });
134
134
  res.status(201).json({ resumedFromSessionId: sessionId, ...newSnapshot });
135
135
  }
136
136
  catch (error) {
@@ -159,7 +159,7 @@ export function registerSessionRoutes(app, processes, storage, defaultMode) {
159
159
  const newMode = body.mode ? normalizeMode(body.mode, defaultMode) : normalizeMode(existingSession.mode, defaultMode);
160
160
  const resumeCommand = `${command} --resume ${claudeSessionId}`;
161
161
  const newSnapshot = processes.start(resumeCommand, existingSession.cwd, newMode, undefined, { resumedFromSessionId: existingSession.id });
162
- storage.saveSession({ ...existingSession, resumedToSessionId: newSnapshot.id });
162
+ storage.saveSession({ ...existingSession, resumedToSessionId: newSnapshot.id, archived: true });
163
163
  res.status(201).json({ resumedFromSessionId: existingSession.id, resumedClaudeSessionId: claudeSessionId, ...newSnapshot });
164
164
  }
165
165
  else {
package/dist/server.js CHANGED
@@ -279,6 +279,7 @@ export async function startServer(config, configPath) {
279
279
  app.use(express.json({ limit: "1mb" }));
280
280
  app.use("/vendor/xterm", express.static(path.join(nodeModulesDir, "xterm")));
281
281
  app.use("/vendor/xterm-addon-fit", express.static(path.join(nodeModulesDir, "@xterm", "addon-fit")));
282
+ app.use("/vendor/xterm-addon-serialize", express.static(path.join(nodeModulesDir, "xterm-addon-serialize")));
282
283
  // ── Web UI and PWA endpoints ──
283
284
  app.get("/", (_req, res) => {
284
285
  res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
@@ -357,6 +358,7 @@ export async function startServer(config, configPath) {
357
358
  defaultMode: config.defaultMode,
358
359
  defaultCwd: config.defaultCwd,
359
360
  commandPresets: config.commandPresets,
361
+ experimentalDomTerminal: config.experimentalDomTerminal ?? false,
360
362
  updateAvailable: cachedUpdateInfo?.updateAvailable ?? false,
361
363
  latestVersion: cachedUpdateInfo?.latest ?? null,
362
364
  currentVersion: PKG_VERSION,
@@ -380,7 +382,7 @@ export async function startServer(config, configPath) {
380
382
  });
381
383
  app.post("/api/settings/config", async (req, res) => {
382
384
  const body = req.body;
383
- const allowedFields = ["host", "port", "https", "defaultMode", "defaultCwd", "shell"];
385
+ const allowedFields = ["host", "port", "https", "defaultMode", "defaultCwd", "shell", "experimentalDomTerminal"];
384
386
  let changed = false;
385
387
  for (const field of allowedFields) {
386
388
  if (field in body && body[field] !== undefined) {
@@ -411,6 +413,9 @@ export async function startServer(config, configPath) {
411
413
  else if (field === "shell") {
412
414
  config.shell = String(body.shell);
413
415
  }
416
+ else if (field === "experimentalDomTerminal") {
417
+ config.experimentalDomTerminal = body.experimentalDomTerminal === true;
418
+ }
414
419
  changed = true;
415
420
  }
416
421
  }
package/dist/types.d.ts CHANGED
@@ -42,6 +42,8 @@ export interface WandConfig {
42
42
  commandPresets: CommandPreset[];
43
43
  /** Max total size (bytes) for shortcut interaction logs per session (default: 10 MB). Set 0 to disable logging. */
44
44
  shortcutLogMaxBytes?: number;
45
+ /** Experimental: use DOM-based terminal rendering on mobile for native text selection (default: false) */
46
+ experimentalDomTerminal?: boolean;
45
47
  }
46
48
  export interface CommandRequest {
47
49
  command: string;
@@ -69,6 +69,10 @@
69
69
  suggestionTimer: null,
70
70
  terminal: null,
71
71
  fitAddon: null,
72
+ serializeAddon: null,
73
+ terminalDomView: null,
74
+ terminalDomUpdateTimer: null,
75
+ _lastDomHtml: "",
72
76
  terminalSessionId: null,
73
77
  terminalOutput: "",
74
78
  terminalViewportSize: { width: 0, height: 0 },
@@ -704,6 +708,11 @@
704
708
  '<label class="field-label" for="cfg-shell">Shell</label>' +
705
709
  '<input id="cfg-shell" type="text" class="field-input" placeholder="/bin/bash" />' +
706
710
  '</div>' +
711
+ '<div class="field field-inline">' +
712
+ '<label class="field-label" for="cfg-dom-terminal">终端 DOM 渲染 <span style="font-size:0.7em;color:var(--warning);font-weight:600;">实验性</span></label>' +
713
+ '<input id="cfg-dom-terminal" type="checkbox" class="field-checkbox" />' +
714
+ '</div>' +
715
+ '<p class="hint" style="margin-top:-8px;margin-bottom:8px;">移动端使用 DOM 渲染终端,支持原生文本选择与复制。保存后刷新页面生效。</p>' +
707
716
  '<button id="save-config-button" class="btn btn-primary btn-block">保存配置</button>' +
708
717
  '<p id="config-message" class="hint hidden"></p>' +
709
718
  '</div>' +
@@ -747,7 +756,7 @@
747
756
  }
748
757
 
749
758
  function renderSessions() {
750
- var activeSessions = state.sessions.filter(function(session) { return !session.archived; });
759
+ var activeSessions = state.sessions.filter(function(session) { return !session.archived && !session.resumedToSessionId; });
751
760
  var archivedSessions = state.sessions.filter(function(session) { return session.archived; });
752
761
  var groups = [];
753
762
  groups.push(renderSessionManageBar());
@@ -1176,6 +1185,10 @@
1176
1185
  if (!state.terminal) return;
1177
1186
  state.terminal.options.fontSize = state.terminalBaseFontSize * state.terminalScale;
1178
1187
  state.terminal.refresh(0, state.terminal.rows - 1);
1188
+ // Apply to DOM terminal view as well
1189
+ if (state.terminalDomView) {
1190
+ state.terminalDomView.style.fontSize = (state.terminalBaseFontSize * state.terminalScale) + "px";
1191
+ }
1179
1192
  }
1180
1193
 
1181
1194
  function updateScaleLabel() {
@@ -1630,10 +1643,6 @@
1630
1643
 
1631
1644
  if (session.autoRecovered) {
1632
1645
  recoveryHint = '<span class="session-id" title="自动恢复的会话">自动恢复</span>';
1633
- } else if (session.resumedToSessionId) {
1634
- recoveryHint = '<span class="session-id" title="已恢复到新会话">已恢复</span>';
1635
- } else if (session.resumedFromSessionId) {
1636
- recoveryHint = '<span class="session-id" title="从旧会话恢复而来">续接</span>';
1637
1646
  }
1638
1647
 
1639
1648
  var deleteButton = state.sessionsManageMode ? '' : '<button class="session-action-btn delete-btn" data-action="delete-session" data-session-id="' + session.id + '" type="button" aria-label="删除会话" title="删除此会话"><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="M3 6h18"/><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/></svg></button>';
@@ -1644,7 +1653,7 @@
1644
1653
  '<div class="session-item-row">' +
1645
1654
  checkbox +
1646
1655
  '<div class="session-main">' +
1647
- '<div class="session-command">' + escapeHtml(session.command) + '</div>' +
1656
+ '<div class="session-command">' + escapeHtml(session.resumedFromSessionId ? session.command.replace(/\s+--resume\s+\S+/, '') : session.command) + '</div>' +
1648
1657
  '<div class="session-meta">' +
1649
1658
  '<span>' + escapeHtml(modeName) + '</span>' +
1650
1659
  '<span class="session-status ' + metaStatusClass + '">' + escapeHtml(metaStatus) + '</span>' +
@@ -2672,6 +2681,11 @@
2672
2681
  }
2673
2682
 
2674
2683
  function isTerminalNearBottom() {
2684
+ // On mobile, check DOM view scroll position
2685
+ if (state.terminalDomView) {
2686
+ var d = state.terminalDomView.scrollHeight - state.terminalDomView.clientHeight - state.terminalDomView.scrollTop;
2687
+ return d <= state.terminalScrollThreshold;
2688
+ }
2675
2689
  var viewport = getTerminalViewport();
2676
2690
  if (!viewport) return true;
2677
2691
  var distance = viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop;
@@ -2680,6 +2694,14 @@
2680
2694
 
2681
2695
  function scrollTerminalToBottom(smooth) {
2682
2696
  if (!state.terminal) return;
2697
+ // Also scroll mobile DOM view
2698
+ if (state.terminalDomView) {
2699
+ if (smooth) {
2700
+ state.terminalDomView.scrollTo({ top: state.terminalDomView.scrollHeight, behavior: "smooth" });
2701
+ } else {
2702
+ state.terminalDomView.scrollTop = state.terminalDomView.scrollHeight;
2703
+ }
2704
+ }
2683
2705
  if (smooth) {
2684
2706
  var viewport = getTerminalViewport();
2685
2707
  if (viewport) {
@@ -2967,6 +2989,7 @@
2967
2989
 
2968
2990
  state.terminalSessionId = nextSessionId;
2969
2991
  state.terminalOutput = normalizedOutput;
2992
+ scheduleMobileDomUpdate();
2970
2993
  if (shouldScroll && (wrote || sessionChanged || mode === "replace")) {
2971
2994
  maybeScrollTerminalToBottom(sessionChanged || mode === "replace" ? "force" : "output");
2972
2995
  } else {
@@ -3050,6 +3073,12 @@
3050
3073
  console.error("[wand] xterm fit addon failed to load; continuing without fit support.");
3051
3074
  }
3052
3075
 
3076
+ // Load serialize addon for mobile DOM rendering
3077
+ if (typeof SerializeAddon !== "undefined" && SerializeAddon && typeof SerializeAddon.SerializeAddon === "function") {
3078
+ state.serializeAddon = new SerializeAddon.SerializeAddon();
3079
+ state.terminal.loadAddon(state.serializeAddon);
3080
+ }
3081
+
3053
3082
  state.terminal.open(container);
3054
3083
  applyTerminalScale();
3055
3084
  state.terminalViewportSize = { width: 0, height: 0 };
@@ -3088,6 +3117,9 @@
3088
3117
  // Create custom scrollbar overlay
3089
3118
  initTerminalScrollbar(container);
3090
3119
 
3120
+ // Terminal copy button for mobile
3121
+ initMobileDomTerminal(container);
3122
+
3091
3123
  if (state.selectedId) {
3092
3124
  var session = state.sessions.find(function(s) { return s.id === state.selectedId; });
3093
3125
  if (session) {
@@ -3529,6 +3561,15 @@
3529
3561
  return fetch("/api/sessions/" + id, { credentials: "same-origin" })
3530
3562
  .then(function(res) { return res.json(); })
3531
3563
  .then(function(data) {
3564
+ if (data.error) {
3565
+ // Session no longer exists — deselect and refresh list
3566
+ if (state.selectedId === id) {
3567
+ state.selectedId = null;
3568
+ persistSelectedId();
3569
+ }
3570
+ loadSessions();
3571
+ return;
3572
+ }
3532
3573
  updateSessionSnapshot(data);
3533
3574
  updateShellChrome();
3534
3575
 
@@ -3821,6 +3862,8 @@
3821
3862
  if (modeEl) modeEl.value = cfg.defaultMode || "default";
3822
3863
  if (cwdEl) cwdEl.value = cfg.defaultCwd || "";
3823
3864
  if (shellEl) shellEl.value = cfg.shell || "";
3865
+ var domTermEl = document.getElementById("cfg-dom-terminal");
3866
+ if (domTermEl) domTermEl.checked = cfg.experimentalDomTerminal === true;
3824
3867
 
3825
3868
  // Cert status
3826
3869
  var certStatus = document.getElementById("cert-status");
@@ -3858,6 +3901,7 @@
3858
3901
  defaultMode: (document.getElementById("cfg-mode") || {}).value,
3859
3902
  defaultCwd: (document.getElementById("cfg-cwd") || {}).value,
3860
3903
  shell: (document.getElementById("cfg-shell") || {}).value,
3904
+ experimentalDomTerminal: (document.getElementById("cfg-dom-terminal") || {}).checked,
3861
3905
  };
3862
3906
 
3863
3907
  fetch("/api/settings/config", {
@@ -4549,12 +4593,11 @@
4549
4593
  el.scrollTop = 0;
4550
4594
  return;
4551
4595
  }
4552
- // Force synchronous reflow so scrollHeight reflects current content
4553
- void el.offsetHeight;
4554
- // Temporarily collapse to measure true content height
4555
- el.style.height = "0";
4556
- el.style.minHeight = "0";
4557
- void el.offsetHeight;
4596
+ // Measure content height by temporarily setting height to minHeight
4597
+ // and reading scrollHeight. Avoid collapsing to 0 which causes layout jumps.
4598
+ var prevOverflow = el.style.overflowY;
4599
+ el.style.overflowY = "hidden";
4600
+ el.style.height = minHeight + "px";
4558
4601
  var contentHeight = el.scrollHeight;
4559
4602
  var newHeight = Math.max(minHeight, Math.min(contentHeight, maxHeight));
4560
4603
  var shouldScrollInside = contentHeight > maxHeight;
@@ -4867,6 +4910,7 @@
4867
4910
  var idx = state.messageQueue.indexOf(input);
4868
4911
  if (idx > -1) state.messageQueue.splice(idx, 1);
4869
4912
  updateQueueCounter();
4913
+ scheduleMobileDomUpdate();
4870
4914
  });
4871
4915
  });
4872
4916
  return state.inputQueue;
@@ -5728,9 +5772,10 @@
5728
5772
  function scrollLatestMessageIntoView() {
5729
5773
  var chatMessages = document.querySelector('.chat-messages');
5730
5774
  if (!chatMessages) return;
5731
- var firstMsg = chatMessages.querySelector(".chat-message");
5732
- if (!firstMsg) return;
5733
- firstMsg.scrollIntoView({ block: "end", inline: "nearest", behavior: isTouchDevice() ? "auto" : "smooth" });
5775
+ // column-reverse: scrollTop=0 is the visual bottom.
5776
+ // Use direct scrollTop instead of scrollIntoView() to avoid
5777
+ // shifting ancestor containers and causing the input box to jump.
5778
+ chatMessages.scrollTop = 0;
5734
5779
  }
5735
5780
 
5736
5781
  function updateInputPanelViewportSpacing() {
@@ -5789,11 +5834,26 @@
5789
5834
 
5790
5835
  function handleInputBoxBlur() {
5791
5836
  resetInputPanelViewportSpacing();
5792
- // Restore app container height when keyboard closes
5793
- var appContainer = document.querySelector('.app-container');
5794
- if (appContainer) {
5795
- appContainer.style.height = '';
5796
- }
5837
+ // Restore app container height when keyboard closes.
5838
+ // Use a short delay because on iOS the visualViewport may not
5839
+ // have updated yet at the moment blur fires.
5840
+ setTimeout(function() {
5841
+ var appContainer = document.querySelector('.app-container');
5842
+ if (appContainer) {
5843
+ // Only clear if keyboard is actually closed now
5844
+ var vv = window.visualViewport;
5845
+ if (vv) {
5846
+ var offsetBottom = window.innerHeight - vv.height - vv.offsetTop;
5847
+ if (offsetBottom <= 50) {
5848
+ appContainer.style.height = '';
5849
+ }
5850
+ } else {
5851
+ appContainer.style.height = '';
5852
+ }
5853
+ }
5854
+ // Scroll the window back to top to fix any residual offset
5855
+ window.scrollTo(0, 0);
5856
+ }, 100);
5797
5857
  }
5798
5858
 
5799
5859
  function adjustInputBoxSelection(inputBox) {
@@ -6469,9 +6529,6 @@
6469
6529
  var rect = vk.boundingRect;
6470
6530
  var kbHeight = rect ? rect.height : 0;
6471
6531
  inputPanel.style.paddingBottom = kbHeight > 0 ? kbHeight + 'px' : '';
6472
- if (kbHeight > 0 && document.activeElement === document.getElementById('input-box')) {
6473
- scrollLatestMessageIntoView();
6474
- }
6475
6532
  });
6476
6533
  }
6477
6534
 
@@ -6509,19 +6566,21 @@
6509
6566
  var isKeyboardOpen = offsetBottom > 50;
6510
6567
  var heightChanged = Math.abs(vv.height - lastHeight) > 8;
6511
6568
 
6512
- // In PWA standalone mode, dynamically resize the app container
6513
- // because 100dvh does NOT shrink when keyboard appears in standalone PWA
6569
+ // Dynamically resize the app container to match visible viewport.
6570
+ // This is needed because 100dvh does NOT shrink when the keyboard
6571
+ // appears in PWA standalone mode, and on some browsers the layout
6572
+ // viewport doesn't update on keyboard dismiss without this.
6514
6573
  var appContainer = document.querySelector('.app-container');
6515
6574
  if (appContainer) {
6516
6575
  if (isKeyboardOpen) {
6517
6576
  appContainer.style.height = vv.height + 'px';
6518
- } else {
6577
+ } else if (keyboardOpen) {
6578
+ // Keyboard just closed — clear forced height
6519
6579
  appContainer.style.height = '';
6520
6580
  }
6521
6581
  }
6522
6582
 
6523
6583
  if (isKeyboardOpen && (!keyboardOpen || heightChanged) && shouldAdjustForKeyboard(vv, inputBox)) {
6524
- scrollLatestMessageIntoView();
6525
6584
  syncInputBoxScroll(inputBox);
6526
6585
  }
6527
6586
 
@@ -6539,6 +6598,8 @@
6539
6598
  }
6540
6599
 
6541
6600
  vv.addEventListener('resize', debouncedUpdate);
6601
+ // Also listen to scroll — on iOS, keyboard dismiss sometimes only
6602
+ // fires a scroll event (viewport scrolls back) without a resize event.
6542
6603
  vv.addEventListener('scroll', debouncedUpdate);
6543
6604
 
6544
6605
  updateViewport();
@@ -6668,6 +6729,16 @@
6668
6729
  state.terminal = null;
6669
6730
  }
6670
6731
  state.fitAddon = null;
6732
+ state.serializeAddon = null;
6733
+ if (state.terminalDomView && state.terminalDomView.parentNode) {
6734
+ state.terminalDomView.parentNode.removeChild(state.terminalDomView);
6735
+ }
6736
+ state.terminalDomView = null;
6737
+ state._lastDomHtml = "";
6738
+ if (state.terminalDomUpdateTimer) {
6739
+ clearTimeout(state.terminalDomUpdateTimer);
6740
+ state.terminalDomUpdateTimer = null;
6741
+ }
6671
6742
  state.terminalSessionId = null;
6672
6743
  state.terminalOutput = "";
6673
6744
  state.terminalViewportSize = { width: 0, height: 0 };
@@ -6845,6 +6916,7 @@
6845
6916
  }
6846
6917
  maybeScrollTerminalToBottom("output");
6847
6918
  updateTerminalJumpToBottomButton();
6919
+ scheduleMobileDomUpdate();
6848
6920
  } else if (Object.prototype.hasOwnProperty.call(msg.data, "output")) {
6849
6921
  // Fallback: no chunk available, use full-output comparison
6850
6922
  syncTerminalBuffer(msg.sessionId, msg.data.output || "", { mode: "append" });
@@ -7675,6 +7747,247 @@
7675
7747
  }
7676
7748
  });
7677
7749
  });
7750
+ // Attach message-level copy buttons for touch devices
7751
+ attachMessageCopyButtons(container);
7752
+ }
7753
+
7754
+ // ===== Mobile message copy (long-press or tap copy button) =====
7755
+ var _msgCopyState = { timer: null, activeBtn: null };
7756
+
7757
+ function attachMessageCopyButtons(container) {
7758
+ var isTouch = window.matchMedia("(pointer: coarse)").matches;
7759
+ if (!isTouch) return;
7760
+ container.querySelectorAll(".chat-message").forEach(function(msgEl) {
7761
+ if (msgEl.querySelector(".msg-copy-btn")) return; // already attached
7762
+ var bubble = msgEl.querySelector(".chat-message-bubble");
7763
+ if (!bubble) return;
7764
+ var btn = document.createElement("button");
7765
+ btn.className = "msg-copy-btn";
7766
+ btn.textContent = "复制";
7767
+ btn.addEventListener("click", function(e) {
7768
+ e.stopPropagation();
7769
+ var text = bubble.innerText || bubble.textContent || "";
7770
+ navigator.clipboard.writeText(text.trim()).then(function() {
7771
+ btn.textContent = "已复制";
7772
+ btn.classList.add("copied");
7773
+ setTimeout(function() {
7774
+ btn.textContent = "复制";
7775
+ btn.classList.remove("copied");
7776
+ btn.classList.remove("visible");
7777
+ }, 1500);
7778
+ });
7779
+ });
7780
+ msgEl.appendChild(btn);
7781
+ });
7782
+ }
7783
+
7784
+ // Long-press to show copy button on chat messages
7785
+ (function initMobileCopyLongPress() {
7786
+ var isTouch = window.matchMedia("(pointer: coarse)").matches;
7787
+ if (!isTouch) return;
7788
+
7789
+ var longPressTimer = null;
7790
+ var touchStartY = 0;
7791
+
7792
+ document.addEventListener("touchstart", function(e) {
7793
+ var msgEl = e.target.closest(".chat-message");
7794
+ if (!msgEl) return;
7795
+ var bubble = msgEl.querySelector(".chat-message-bubble");
7796
+ if (!bubble) return;
7797
+ touchStartY = e.touches[0].clientY;
7798
+ longPressTimer = setTimeout(function() {
7799
+ var btn = msgEl.querySelector(".msg-copy-btn");
7800
+ if (btn) {
7801
+ // Hide any other visible copy buttons
7802
+ document.querySelectorAll(".msg-copy-btn.visible").forEach(function(b) {
7803
+ b.classList.remove("visible");
7804
+ });
7805
+ btn.classList.add("visible");
7806
+ }
7807
+ }, 500);
7808
+ }, { passive: true });
7809
+
7810
+ document.addEventListener("touchmove", function(e) {
7811
+ if (longPressTimer && Math.abs(e.touches[0].clientY - touchStartY) > 10) {
7812
+ clearTimeout(longPressTimer);
7813
+ longPressTimer = null;
7814
+ }
7815
+ }, { passive: true });
7816
+
7817
+ document.addEventListener("touchend", function() {
7818
+ if (longPressTimer) {
7819
+ clearTimeout(longPressTimer);
7820
+ longPressTimer = null;
7821
+ }
7822
+ }, { passive: true });
7823
+
7824
+ // Dismiss copy buttons when tapping elsewhere
7825
+ document.addEventListener("click", function(e) {
7826
+ if (!e.target.closest(".msg-copy-btn")) {
7827
+ document.querySelectorAll(".msg-copy-btn.visible").forEach(function(b) {
7828
+ b.classList.remove("visible");
7829
+ });
7830
+ }
7831
+ });
7832
+ })();
7833
+
7834
+ // ===== Terminal copy button for mobile =====
7835
+ // ===== Mobile DOM terminal view =====
7836
+ function initMobileDomTerminal(container) {
7837
+ var isTouch = window.matchMedia("(pointer: coarse)").matches;
7838
+ if (!isTouch) return;
7839
+ // Gated by experimental config flag
7840
+ if (!state.config || !state.config.experimentalDomTerminal) return;
7841
+
7842
+ // Create DOM view container
7843
+ var domView = document.createElement("div");
7844
+ domView.className = "terminal-dom-view active";
7845
+ container.appendChild(domView);
7846
+
7847
+ // Always set font-size explicitly to match xterm
7848
+ domView.style.fontSize = (state.terminalBaseFontSize * state.terminalScale) + "px";
7849
+
7850
+ // Hide xterm canvas but keep it in layout for FitAddon sizing.
7851
+ // Use opacity:0 + pointer-events:none so the element still occupies
7852
+ // space in the flex container and fit() can compute cols/rows correctly.
7853
+ setTimeout(function() {
7854
+ var xtermEl = container.querySelector(".xterm");
7855
+ if (xtermEl) {
7856
+ xtermEl.style.opacity = "0";
7857
+ xtermEl.style.pointerEvents = "none";
7858
+ }
7859
+ }, 100);
7860
+
7861
+ // Save reference
7862
+ state.terminalDomView = domView;
7863
+ state.terminalDomUpdateTimer = null;
7864
+
7865
+ // Scroll events for auto-follow
7866
+ domView.addEventListener("scroll", function() {
7867
+ var distance = domView.scrollHeight - domView.clientHeight - domView.scrollTop;
7868
+ if (distance <= state.terminalScrollThreshold) {
7869
+ state.terminalAutoFollow = true;
7870
+ clearTerminalScrollIdleTimer();
7871
+ updateTerminalJumpToBottomButton();
7872
+ } else {
7873
+ setTerminalManualScrollActive();
7874
+ }
7875
+ }, { passive: true });
7876
+
7877
+ domView.addEventListener("touchmove", function() {
7878
+ setTerminalManualScrollActive();
7879
+ }, { passive: true });
7880
+
7881
+ // Trigger initial render
7882
+ scheduleMobileDomUpdate();
7883
+ }
7884
+
7885
+ function updateMobileDomView() {
7886
+ if (!state.terminalDomView || !state.serializeAddon) return;
7887
+
7888
+ try {
7889
+ // Serialize the entire buffer including scrollback history
7890
+ var buf = state.terminal.buffer.active;
7891
+ var totalRows = buf.length;
7892
+ var html = state.serializeAddon.serializeAsHTML({
7893
+ includeGlobalBackground: true,
7894
+ range: { start: 0, end: totalRows }
7895
+ });
7896
+
7897
+ // Extract the <pre>...</pre> portion
7898
+ var match = html.match(/<pre[\s\S]*<\/pre>/);
7899
+ var preHtml = match ? match[0] : "";
7900
+
7901
+ // Strip inline font-size/font-family from the serialized HTML
7902
+ // so our CSS controls sizing and font consistently
7903
+ preHtml = preHtml.replace(/font-size:\s*[^;"']+;?/g, "");
7904
+ preHtml = preHtml.replace(/font-family:\s*[^;"']+;?/g, "");
7905
+
7906
+ // Fix colors for dark background
7907
+ preHtml = fixDarkTerminalColors(preHtml);
7908
+
7909
+ // Skip update if content unchanged
7910
+ if (preHtml === state._lastDomHtml) return;
7911
+ state._lastDomHtml = preHtml;
7912
+
7913
+ // Preserve scroll position for non-auto-follow mode
7914
+ var wasAtBottom = state.terminalAutoFollow;
7915
+ var scrollTop = state.terminalDomView.scrollTop;
7916
+
7917
+ state.terminalDomView.innerHTML = preHtml;
7918
+
7919
+ if (wasAtBottom) {
7920
+ state.terminalDomView.scrollTop = state.terminalDomView.scrollHeight;
7921
+ } else {
7922
+ state.terminalDomView.scrollTop = scrollTop;
7923
+ }
7924
+ } catch (e) {
7925
+ // Fallback: plain text if serialize fails
7926
+ if (state.terminal && state.terminal.buffer) {
7927
+ var buf = state.terminal.buffer.active;
7928
+ var lines = [];
7929
+ for (var i = 0; i < buf.length; i++) {
7930
+ var line = buf.getLine(i);
7931
+ if (line) lines.push(line.translateToString(true));
7932
+ }
7933
+ var text = lines.join("\n").replace(/\n+$/, "");
7934
+ state.terminalDomView.innerHTML = '<pre><div style="padding:8px 12px">' + escapeHtml(text) + '</div></pre>';
7935
+ if (state.terminalAutoFollow) {
7936
+ state.terminalDomView.scrollTop = state.terminalDomView.scrollHeight;
7937
+ }
7938
+ }
7939
+ }
7940
+ }
7941
+
7942
+ // Fix serialize addon's color issues on dark terminal background
7943
+ function fixDarkTerminalColors(html) {
7944
+ // Theme reference: bg=#1f1b17, fg=#f5eadc, black=#1f1b17, brightBlack=#625347
7945
+ // 1. Hardcoded inverse: black text on gray → theme fg on theme brightBlack bg
7946
+ html = html.replace(
7947
+ /color:\s*#000000;\s*background-color:\s*#BFBFBF/g,
7948
+ "color: #f5eadc; background-color: #625347"
7949
+ );
7950
+ // 2. Fix foreground colors that are too dark to read on #1f1b17 background.
7951
+ // Process each style attribute: split into declarations, fix only "color:" (not "background-color:").
7952
+ html = html.replace(/style='([^']*)'/g, function(_match, styles) {
7953
+ var parts = styles.split(";");
7954
+ for (var i = 0; i < parts.length; i++) {
7955
+ var decl = parts[i].trim();
7956
+ // Skip background-color declarations
7957
+ if (/^background-color\s*:/.test(decl)) continue;
7958
+ // Match standalone color declaration
7959
+ if (/^color\s*:/.test(decl)) {
7960
+ var hexMatch = decl.match(/#([0-9a-fA-F]{6})\b/);
7961
+ if (hexMatch && isColorTooDark("#" + hexMatch[1])) {
7962
+ parts[i] = parts[i].replace(/#[0-9a-fA-F]{6}/, "#625347");
7963
+ }
7964
+ }
7965
+ }
7966
+ return "style='" + parts.join(";") + "'";
7967
+ });
7968
+ return html;
7969
+ }
7970
+
7971
+ function isColorTooDark(hex) {
7972
+ // Parse hex color and check relative luminance
7973
+ var r = parseInt(hex.substring(1, 3), 16);
7974
+ var g = parseInt(hex.substring(3, 5), 16);
7975
+ var b = parseInt(hex.substring(5, 7), 16);
7976
+ // Simple perceived brightness: if below threshold, it's too dark for #1f1b17 bg
7977
+ var brightness = (r * 299 + g * 587 + b * 114) / 1000;
7978
+ return brightness < 45; // #1f1b17 has brightness ~22, threshold catches colors close to it
7979
+ }
7980
+
7981
+ function scheduleMobileDomUpdate() {
7982
+ if (!state.terminalDomView) return;
7983
+ // Trailing-edge debounce: reset timer on each call to batch rapid updates
7984
+ if (state.terminalDomUpdateTimer) {
7985
+ clearTimeout(state.terminalDomUpdateTimer);
7986
+ }
7987
+ state.terminalDomUpdateTimer = setTimeout(function() {
7988
+ state.terminalDomUpdateTimer = null;
7989
+ updateMobileDomView();
7990
+ }, 150);
7678
7991
  }
7679
7992
 
7680
7993
  function parseMessages(output, command) {
@@ -2211,6 +2211,18 @@
2211
2211
  .chat-message:hover {
2212
2212
  transform: translateY(-1px);
2213
2213
  }
2214
+ @media (hover: none) {
2215
+ .chat-message:hover {
2216
+ transform: none;
2217
+ }
2218
+ .chat-message:hover .chat-message-bubble {
2219
+ box-shadow: var(--shadow-sm);
2220
+ }
2221
+ .chat-message.assistant:hover .chat-message-bubble {
2222
+ border-color: var(--border-subtle);
2223
+ box-shadow: var(--shadow-sm);
2224
+ }
2225
+ }
2214
2226
 
2215
2227
  .chat-message.user {
2216
2228
  align-self: flex-end;
@@ -7177,4 +7189,102 @@
7177
7189
  100% { background-position: -200% 0; }
7178
7190
  }
7179
7191
 
7192
+ /* ===== 移动端文本选择与复制优化 ===== */
7193
+ .chat-message-bubble {
7194
+ -webkit-user-select: text;
7195
+ user-select: text;
7196
+ }
7197
+
7198
+ /* 消息气泡复制按钮 */
7199
+ .msg-copy-btn {
7200
+ display: none;
7201
+ position: absolute;
7202
+ top: -32px;
7203
+ right: 4px;
7204
+ padding: 4px 10px;
7205
+ font-size: 0.75rem;
7206
+ background: var(--bg-elevated);
7207
+ color: var(--text-secondary);
7208
+ border: 1px solid var(--border-default);
7209
+ border-radius: var(--radius-sm);
7210
+ box-shadow: var(--shadow-md);
7211
+ cursor: pointer;
7212
+ user-select: none;
7213
+ -webkit-user-select: none;
7214
+ z-index: 10;
7215
+ white-space: nowrap;
7216
+ -webkit-tap-highlight-color: transparent;
7217
+ touch-action: manipulation;
7218
+ }
7219
+ .msg-copy-btn.visible {
7220
+ display: block;
7221
+ }
7222
+ .msg-copy-btn.copied {
7223
+ background: var(--success-muted);
7224
+ color: var(--success);
7225
+ border-color: var(--success);
7226
+ }
7227
+ .chat-message {
7228
+ position: relative;
7229
+ }
7230
+
7231
+ /* ===== 移动端 DOM 终端视图 ===== */
7232
+ .terminal-dom-view {
7233
+ display: none;
7234
+ position: absolute;
7235
+ top: 0;
7236
+ left: 0;
7237
+ right: 0;
7238
+ bottom: 0;
7239
+ overflow-y: auto;
7240
+ overflow-x: hidden;
7241
+ -webkit-overflow-scrolling: touch;
7242
+ background: var(--bg-terminal);
7243
+ z-index: 1;
7244
+ font-size: 13px;
7245
+ }
7246
+
7247
+ .terminal-dom-view.active {
7248
+ display: block;
7249
+ }
7250
+
7251
+ .terminal-dom-view pre {
7252
+ margin: 0;
7253
+ padding: 0;
7254
+ background: transparent !important;
7255
+ font-family: "Geist Mono", "SF Mono", monospace !important;
7256
+ font-size: inherit !important;
7257
+ line-height: 1.5 !important;
7258
+ white-space: pre-wrap;
7259
+ overflow-wrap: break-word;
7260
+ word-break: normal;
7261
+ -webkit-user-select: text;
7262
+ user-select: text;
7263
+ }
7264
+
7265
+ /* serializeAsHTML 外层 div 携带主题色,直接继承 */
7266
+ .terminal-dom-view pre > div {
7267
+ font-family: "Geist Mono", "SF Mono", monospace !important;
7268
+ font-size: inherit !important;
7269
+ line-height: 1.5 !important;
7270
+ padding: 8px 12px;
7271
+ }
7272
+
7273
+ /* serializeAsHTML 每行 div */
7274
+ .terminal-dom-view pre > div > div {
7275
+ min-height: 1.5em;
7276
+ }
7277
+
7278
+ /* span 继承终端字体 */
7279
+ .terminal-dom-view span {
7280
+ font-family: inherit !important;
7281
+ font-size: inherit !important;
7282
+ line-height: inherit !important;
7283
+ }
7284
+
7285
+ /* 确保按钮在 DOM 视图之上 */
7286
+ .terminal-jump-bottom {
7287
+ z-index: 20;
7288
+ }
7289
+
7180
7290
  /* 结束标记 */
@@ -12,7 +12,7 @@ export function renderApp(configPath) {
12
12
  <html lang="zh-CN">
13
13
  <head>
14
14
  <meta charset="utf-8" />
15
- <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content, user-scalable=no" />
15
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
16
16
  <title>Wand Console</title>
17
17
  <meta name="description" content="Local CLI Console for Vibe Coding - Manage terminal sessions from your browser" />
18
18
  <meta name="theme-color" content="#f6f1e8" media="(prefers-color-scheme: light)" />
@@ -37,6 +37,7 @@ ${cssStyles}
37
37
  <div id="app"></div>
38
38
  ${scriptOpen} src="/vendor/xterm/lib/xterm.js">${scriptClose}
39
39
  ${scriptOpen} src="/vendor/xterm-addon-fit/lib/addon-fit.js">${scriptClose}
40
+ ${scriptOpen} src="/vendor/xterm-addon-serialize/lib/xterm-addon-serialize.js">${scriptClose}
40
41
  ${scriptOpen}>
41
42
  ${scriptContent}
42
43
  ${scriptClose}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@co0ontty/wand",
3
- "version": "1.3.0",
3
+ "version": "1.3.4",
4
4
  "description": "A web terminal for local CLI tools like Claude.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -38,7 +38,8 @@
38
38
  "express": "^4.21.2",
39
39
  "node-pty": "^1.1.0",
40
40
  "ws": "^8.19.0",
41
- "xterm": "^5.3.0"
41
+ "xterm": "^5.3.0",
42
+ "xterm-addon-serialize": "^0.11.0"
42
43
  },
43
44
  "devDependencies": {
44
45
  "@types/express": "^4.17.21",