@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.
- package/dist/claude-pty-bridge.d.ts +8 -0
- package/dist/claude-pty-bridge.js +56 -44
- package/dist/config.js +1 -0
- package/dist/process-manager.js +23 -12
- package/dist/pwa.js +2 -1
- package/dist/server-session-routes.js +2 -2
- package/dist/server.js +6 -1
- package/dist/types.d.ts +2 -0
- package/dist/web-ui/content/scripts.js +340 -27
- package/dist/web-ui/content/styles.css +110 -0
- package/dist/web-ui/index.js +2 -1
- package/package.json +3 -2
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
package/dist/process-manager.js
CHANGED
|
@@ -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 =
|
|
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
|
-
//
|
|
654
|
+
// Only clean up when well over the limit
|
|
655
655
|
if (this.sessions.size < MAX_SESSIONS)
|
|
656
656
|
return;
|
|
657
|
-
const
|
|
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
|
-
|
|
660
|
-
|
|
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
|
-
//
|
|
664
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
4553
|
-
|
|
4554
|
-
|
|
4555
|
-
el.style.
|
|
4556
|
-
el.style.
|
|
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
|
-
|
|
5732
|
-
|
|
5733
|
-
|
|
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
|
-
|
|
5794
|
-
|
|
5795
|
-
|
|
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
|
-
//
|
|
6513
|
-
// because 100dvh does NOT shrink when keyboard
|
|
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
|
/* 结束标记 */
|
package/dist/web-ui/index.js
CHANGED
|
@@ -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
|
|
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.
|
|
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",
|