@co0ontty/wand 1.3.0 → 1.3.3

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",
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) {
@@ -3821,6 +3853,8 @@
3821
3853
  if (modeEl) modeEl.value = cfg.defaultMode || "default";
3822
3854
  if (cwdEl) cwdEl.value = cfg.defaultCwd || "";
3823
3855
  if (shellEl) shellEl.value = cfg.shell || "";
3856
+ var domTermEl = document.getElementById("cfg-dom-terminal");
3857
+ if (domTermEl) domTermEl.checked = cfg.experimentalDomTerminal === true;
3824
3858
 
3825
3859
  // Cert status
3826
3860
  var certStatus = document.getElementById("cert-status");
@@ -3858,6 +3892,7 @@
3858
3892
  defaultMode: (document.getElementById("cfg-mode") || {}).value,
3859
3893
  defaultCwd: (document.getElementById("cfg-cwd") || {}).value,
3860
3894
  shell: (document.getElementById("cfg-shell") || {}).value,
3895
+ experimentalDomTerminal: (document.getElementById("cfg-dom-terminal") || {}).checked,
3861
3896
  };
3862
3897
 
3863
3898
  fetch("/api/settings/config", {
@@ -4549,12 +4584,11 @@
4549
4584
  el.scrollTop = 0;
4550
4585
  return;
4551
4586
  }
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;
4587
+ // Measure content height by temporarily setting height to minHeight
4588
+ // and reading scrollHeight. Avoid collapsing to 0 which causes layout jumps.
4589
+ var prevOverflow = el.style.overflowY;
4590
+ el.style.overflowY = "hidden";
4591
+ el.style.height = minHeight + "px";
4558
4592
  var contentHeight = el.scrollHeight;
4559
4593
  var newHeight = Math.max(minHeight, Math.min(contentHeight, maxHeight));
4560
4594
  var shouldScrollInside = contentHeight > maxHeight;
@@ -4867,6 +4901,7 @@
4867
4901
  var idx = state.messageQueue.indexOf(input);
4868
4902
  if (idx > -1) state.messageQueue.splice(idx, 1);
4869
4903
  updateQueueCounter();
4904
+ scheduleMobileDomUpdate();
4870
4905
  });
4871
4906
  });
4872
4907
  return state.inputQueue;
@@ -5728,9 +5763,10 @@
5728
5763
  function scrollLatestMessageIntoView() {
5729
5764
  var chatMessages = document.querySelector('.chat-messages');
5730
5765
  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" });
5766
+ // column-reverse: scrollTop=0 is the visual bottom.
5767
+ // Use direct scrollTop instead of scrollIntoView() to avoid
5768
+ // shifting ancestor containers and causing the input box to jump.
5769
+ chatMessages.scrollTop = 0;
5734
5770
  }
5735
5771
 
5736
5772
  function updateInputPanelViewportSpacing() {
@@ -6469,9 +6505,6 @@
6469
6505
  var rect = vk.boundingRect;
6470
6506
  var kbHeight = rect ? rect.height : 0;
6471
6507
  inputPanel.style.paddingBottom = kbHeight > 0 ? kbHeight + 'px' : '';
6472
- if (kbHeight > 0 && document.activeElement === document.getElementById('input-box')) {
6473
- scrollLatestMessageIntoView();
6474
- }
6475
6508
  });
6476
6509
  }
6477
6510
 
@@ -6521,7 +6554,6 @@
6521
6554
  }
6522
6555
 
6523
6556
  if (isKeyboardOpen && (!keyboardOpen || heightChanged) && shouldAdjustForKeyboard(vv, inputBox)) {
6524
- scrollLatestMessageIntoView();
6525
6557
  syncInputBoxScroll(inputBox);
6526
6558
  }
6527
6559
 
@@ -6539,7 +6571,6 @@
6539
6571
  }
6540
6572
 
6541
6573
  vv.addEventListener('resize', debouncedUpdate);
6542
- vv.addEventListener('scroll', debouncedUpdate);
6543
6574
 
6544
6575
  updateViewport();
6545
6576
  }
@@ -6668,6 +6699,16 @@
6668
6699
  state.terminal = null;
6669
6700
  }
6670
6701
  state.fitAddon = null;
6702
+ state.serializeAddon = null;
6703
+ if (state.terminalDomView && state.terminalDomView.parentNode) {
6704
+ state.terminalDomView.parentNode.removeChild(state.terminalDomView);
6705
+ }
6706
+ state.terminalDomView = null;
6707
+ state._lastDomHtml = "";
6708
+ if (state.terminalDomUpdateTimer) {
6709
+ clearTimeout(state.terminalDomUpdateTimer);
6710
+ state.terminalDomUpdateTimer = null;
6711
+ }
6671
6712
  state.terminalSessionId = null;
6672
6713
  state.terminalOutput = "";
6673
6714
  state.terminalViewportSize = { width: 0, height: 0 };
@@ -6845,6 +6886,7 @@
6845
6886
  }
6846
6887
  maybeScrollTerminalToBottom("output");
6847
6888
  updateTerminalJumpToBottomButton();
6889
+ scheduleMobileDomUpdate();
6848
6890
  } else if (Object.prototype.hasOwnProperty.call(msg.data, "output")) {
6849
6891
  // Fallback: no chunk available, use full-output comparison
6850
6892
  syncTerminalBuffer(msg.sessionId, msg.data.output || "", { mode: "append" });
@@ -7675,6 +7717,243 @@
7675
7717
  }
7676
7718
  });
7677
7719
  });
7720
+ // Attach message-level copy buttons for touch devices
7721
+ attachMessageCopyButtons(container);
7722
+ }
7723
+
7724
+ // ===== Mobile message copy (long-press or tap copy button) =====
7725
+ var _msgCopyState = { timer: null, activeBtn: null };
7726
+
7727
+ function attachMessageCopyButtons(container) {
7728
+ var isTouch = window.matchMedia("(pointer: coarse)").matches;
7729
+ if (!isTouch) return;
7730
+ container.querySelectorAll(".chat-message").forEach(function(msgEl) {
7731
+ if (msgEl.querySelector(".msg-copy-btn")) return; // already attached
7732
+ var bubble = msgEl.querySelector(".chat-message-bubble");
7733
+ if (!bubble) return;
7734
+ var btn = document.createElement("button");
7735
+ btn.className = "msg-copy-btn";
7736
+ btn.textContent = "复制";
7737
+ btn.addEventListener("click", function(e) {
7738
+ e.stopPropagation();
7739
+ var text = bubble.innerText || bubble.textContent || "";
7740
+ navigator.clipboard.writeText(text.trim()).then(function() {
7741
+ btn.textContent = "已复制";
7742
+ btn.classList.add("copied");
7743
+ setTimeout(function() {
7744
+ btn.textContent = "复制";
7745
+ btn.classList.remove("copied");
7746
+ btn.classList.remove("visible");
7747
+ }, 1500);
7748
+ });
7749
+ });
7750
+ msgEl.appendChild(btn);
7751
+ });
7752
+ }
7753
+
7754
+ // Long-press to show copy button on chat messages
7755
+ (function initMobileCopyLongPress() {
7756
+ var isTouch = window.matchMedia("(pointer: coarse)").matches;
7757
+ if (!isTouch) return;
7758
+
7759
+ var longPressTimer = null;
7760
+ var touchStartY = 0;
7761
+
7762
+ document.addEventListener("touchstart", function(e) {
7763
+ var msgEl = e.target.closest(".chat-message");
7764
+ if (!msgEl) return;
7765
+ var bubble = msgEl.querySelector(".chat-message-bubble");
7766
+ if (!bubble) return;
7767
+ touchStartY = e.touches[0].clientY;
7768
+ longPressTimer = setTimeout(function() {
7769
+ var btn = msgEl.querySelector(".msg-copy-btn");
7770
+ if (btn) {
7771
+ // Hide any other visible copy buttons
7772
+ document.querySelectorAll(".msg-copy-btn.visible").forEach(function(b) {
7773
+ b.classList.remove("visible");
7774
+ });
7775
+ btn.classList.add("visible");
7776
+ }
7777
+ }, 500);
7778
+ }, { passive: true });
7779
+
7780
+ document.addEventListener("touchmove", function(e) {
7781
+ if (longPressTimer && Math.abs(e.touches[0].clientY - touchStartY) > 10) {
7782
+ clearTimeout(longPressTimer);
7783
+ longPressTimer = null;
7784
+ }
7785
+ }, { passive: true });
7786
+
7787
+ document.addEventListener("touchend", function() {
7788
+ if (longPressTimer) {
7789
+ clearTimeout(longPressTimer);
7790
+ longPressTimer = null;
7791
+ }
7792
+ }, { passive: true });
7793
+
7794
+ // Dismiss copy buttons when tapping elsewhere
7795
+ document.addEventListener("click", function(e) {
7796
+ if (!e.target.closest(".msg-copy-btn")) {
7797
+ document.querySelectorAll(".msg-copy-btn.visible").forEach(function(b) {
7798
+ b.classList.remove("visible");
7799
+ });
7800
+ }
7801
+ });
7802
+ })();
7803
+
7804
+ // ===== Terminal copy button for mobile =====
7805
+ // ===== Mobile DOM terminal view =====
7806
+ function initMobileDomTerminal(container) {
7807
+ var isTouch = window.matchMedia("(pointer: coarse)").matches;
7808
+ if (!isTouch) return;
7809
+ // Gated by experimental config flag
7810
+ if (!state.config || !state.config.experimentalDomTerminal) return;
7811
+
7812
+ // Create DOM view container
7813
+ var domView = document.createElement("div");
7814
+ domView.className = "terminal-dom-view active";
7815
+ container.appendChild(domView);
7816
+
7817
+ // Always set font-size explicitly to match xterm
7818
+ domView.style.fontSize = (state.terminalBaseFontSize * state.terminalScale) + "px";
7819
+
7820
+ // Hide xterm canvas but keep it in layout for FitAddon sizing.
7821
+ // Use opacity:0 + pointer-events:none so the element still occupies
7822
+ // space in the flex container and fit() can compute cols/rows correctly.
7823
+ setTimeout(function() {
7824
+ var xtermEl = container.querySelector(".xterm");
7825
+ if (xtermEl) {
7826
+ xtermEl.style.opacity = "0";
7827
+ xtermEl.style.pointerEvents = "none";
7828
+ }
7829
+ }, 100);
7830
+
7831
+ // Save reference
7832
+ state.terminalDomView = domView;
7833
+ state.terminalDomUpdateTimer = null;
7834
+
7835
+ // Scroll events for auto-follow
7836
+ domView.addEventListener("scroll", function() {
7837
+ var distance = domView.scrollHeight - domView.clientHeight - domView.scrollTop;
7838
+ if (distance <= state.terminalScrollThreshold) {
7839
+ state.terminalAutoFollow = true;
7840
+ clearTerminalScrollIdleTimer();
7841
+ updateTerminalJumpToBottomButton();
7842
+ } else {
7843
+ setTerminalManualScrollActive();
7844
+ }
7845
+ }, { passive: true });
7846
+
7847
+ domView.addEventListener("touchmove", function() {
7848
+ setTerminalManualScrollActive();
7849
+ }, { passive: true });
7850
+
7851
+ // Trigger initial render
7852
+ scheduleMobileDomUpdate();
7853
+ }
7854
+
7855
+ function updateMobileDomView() {
7856
+ if (!state.terminalDomView || !state.serializeAddon) return;
7857
+
7858
+ try {
7859
+ var html = state.serializeAddon.serializeAsHTML({
7860
+ includeGlobalBackground: true
7861
+ });
7862
+
7863
+ // Extract the <pre>...</pre> portion
7864
+ var match = html.match(/<pre[\s\S]*<\/pre>/);
7865
+ var preHtml = match ? match[0] : "";
7866
+
7867
+ // Strip inline font-size/font-family from the serialized HTML
7868
+ // so our CSS controls sizing and font consistently
7869
+ preHtml = preHtml.replace(/font-size:\s*[^;"']+;?/g, "");
7870
+ preHtml = preHtml.replace(/font-family:\s*[^;"']+;?/g, "");
7871
+
7872
+ // Fix colors for dark background
7873
+ preHtml = fixDarkTerminalColors(preHtml);
7874
+
7875
+ // Skip update if content unchanged
7876
+ if (preHtml === state._lastDomHtml) return;
7877
+ state._lastDomHtml = preHtml;
7878
+
7879
+ // Preserve scroll position for non-auto-follow mode
7880
+ var wasAtBottom = state.terminalAutoFollow;
7881
+ var scrollTop = state.terminalDomView.scrollTop;
7882
+
7883
+ state.terminalDomView.innerHTML = preHtml;
7884
+
7885
+ if (wasAtBottom) {
7886
+ state.terminalDomView.scrollTop = state.terminalDomView.scrollHeight;
7887
+ } else {
7888
+ state.terminalDomView.scrollTop = scrollTop;
7889
+ }
7890
+ } catch (e) {
7891
+ // Fallback: plain text if serialize fails
7892
+ if (state.terminal && state.terminal.buffer) {
7893
+ var buf = state.terminal.buffer.active;
7894
+ var lines = [];
7895
+ for (var i = 0; i < buf.length; i++) {
7896
+ var line = buf.getLine(i);
7897
+ if (line) lines.push(line.translateToString(true));
7898
+ }
7899
+ var text = lines.join("\n").replace(/\n+$/, "");
7900
+ state.terminalDomView.innerHTML = '<pre><div style="padding:8px 12px">' + escapeHtml(text) + '</div></pre>';
7901
+ if (state.terminalAutoFollow) {
7902
+ state.terminalDomView.scrollTop = state.terminalDomView.scrollHeight;
7903
+ }
7904
+ }
7905
+ }
7906
+ }
7907
+
7908
+ // Fix serialize addon's color issues on dark terminal background
7909
+ function fixDarkTerminalColors(html) {
7910
+ // Theme reference: bg=#1f1b17, fg=#f5eadc, black=#1f1b17, brightBlack=#625347
7911
+ // 1. Hardcoded inverse: black text on gray → theme fg on theme brightBlack bg
7912
+ html = html.replace(
7913
+ /color:\s*#000000;\s*background-color:\s*#BFBFBF/g,
7914
+ "color: #f5eadc; background-color: #625347"
7915
+ );
7916
+ // 2. Fix foreground colors that are too dark to read on #1f1b17 background.
7917
+ // Process each style attribute: split into declarations, fix only "color:" (not "background-color:").
7918
+ html = html.replace(/style='([^']*)'/g, function(_match, styles) {
7919
+ var parts = styles.split(";");
7920
+ for (var i = 0; i < parts.length; i++) {
7921
+ var decl = parts[i].trim();
7922
+ // Skip background-color declarations
7923
+ if (/^background-color\s*:/.test(decl)) continue;
7924
+ // Match standalone color declaration
7925
+ if (/^color\s*:/.test(decl)) {
7926
+ var hexMatch = decl.match(/#([0-9a-fA-F]{6})\b/);
7927
+ if (hexMatch && isColorTooDark("#" + hexMatch[1])) {
7928
+ parts[i] = parts[i].replace(/#[0-9a-fA-F]{6}/, "#625347");
7929
+ }
7930
+ }
7931
+ }
7932
+ return "style='" + parts.join(";") + "'";
7933
+ });
7934
+ return html;
7935
+ }
7936
+
7937
+ function isColorTooDark(hex) {
7938
+ // Parse hex color and check relative luminance
7939
+ var r = parseInt(hex.substring(1, 3), 16);
7940
+ var g = parseInt(hex.substring(3, 5), 16);
7941
+ var b = parseInt(hex.substring(5, 7), 16);
7942
+ // Simple perceived brightness: if below threshold, it's too dark for #1f1b17 bg
7943
+ var brightness = (r * 299 + g * 587 + b * 114) / 1000;
7944
+ return brightness < 45; // #1f1b17 has brightness ~22, threshold catches colors close to it
7945
+ }
7946
+
7947
+ function scheduleMobileDomUpdate() {
7948
+ if (!state.terminalDomView) return;
7949
+ // Trailing-edge debounce: reset timer on each call to batch rapid updates
7950
+ if (state.terminalDomUpdateTimer) {
7951
+ clearTimeout(state.terminalDomUpdateTimer);
7952
+ }
7953
+ state.terminalDomUpdateTimer = setTimeout(function() {
7954
+ state.terminalDomUpdateTimer = null;
7955
+ updateMobileDomView();
7956
+ }, 150);
7678
7957
  }
7679
7958
 
7680
7959
  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.3",
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",