tabminal 2.0.12 → 2.0.13
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/package.json +4 -2
- package/public/app.js +68 -19
- package/src/persistence.mjs +34 -0
- package/src/terminal-manager.mjs +33 -4
- package/src/terminal-session.mjs +177 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tabminal",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.13",
|
|
4
4
|
"description": "A modern, persistent web terminal with multi-tab support and real-time system monitoring.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -43,7 +43,9 @@
|
|
|
43
43
|
"node-pty": "^1.1.0",
|
|
44
44
|
"openai": "^6.32.0",
|
|
45
45
|
"utilitas": "^2001.1.135",
|
|
46
|
-
"ws": "^8.19.0"
|
|
46
|
+
"ws": "^8.19.0",
|
|
47
|
+
"xterm-addon-serialize": "^0.11.0",
|
|
48
|
+
"xterm-headless": "^5.3.0"
|
|
47
49
|
},
|
|
48
50
|
"repository": {
|
|
49
51
|
"type": "git",
|
package/public/app.js
CHANGED
|
@@ -977,8 +977,13 @@ class Session {
|
|
|
977
977
|
this.layoutState = {
|
|
978
978
|
editorFlex: '2 1 0%'
|
|
979
979
|
};
|
|
980
|
+
this.wrapperElement = null;
|
|
981
|
+
this._createTerminals();
|
|
980
982
|
|
|
981
|
-
|
|
983
|
+
this.connect();
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
_createTerminals() {
|
|
982
987
|
this.previewTerm = new Terminal({
|
|
983
988
|
disableStdin: true,
|
|
984
989
|
cursorBlink: false,
|
|
@@ -986,17 +991,18 @@ class Session {
|
|
|
986
991
|
fontSize: 10,
|
|
987
992
|
rows: this.rows,
|
|
988
993
|
cols: this.cols,
|
|
989
|
-
theme: {
|
|
994
|
+
theme: {
|
|
995
|
+
background: '#002b36',
|
|
996
|
+
foreground: '#839496',
|
|
997
|
+
cursor: 'transparent',
|
|
998
|
+
selectionBackground: 'transparent'
|
|
999
|
+
}
|
|
990
1000
|
});
|
|
991
|
-
|
|
992
|
-
// Only load CanvasAddon on Desktop to save GPU memory
|
|
1001
|
+
|
|
993
1002
|
if (window.innerWidth >= 768) {
|
|
994
1003
|
this.previewTerm.loadAddon(new CanvasAddon());
|
|
995
1004
|
}
|
|
996
|
-
|
|
997
|
-
this.wrapperElement = null;
|
|
998
1005
|
|
|
999
|
-
// Main Terminal
|
|
1000
1006
|
this.mainTerm = new Terminal({
|
|
1001
1007
|
allowTransparency: true,
|
|
1002
1008
|
convertEol: true,
|
|
@@ -1005,7 +1011,13 @@ class Session {
|
|
|
1005
1011
|
fontSize: IS_MOBILE ? 14 : 12,
|
|
1006
1012
|
rows: this.rows,
|
|
1007
1013
|
cols: this.cols,
|
|
1008
|
-
theme: {
|
|
1014
|
+
theme: {
|
|
1015
|
+
background: '#002b36',
|
|
1016
|
+
foreground: '#839496',
|
|
1017
|
+
cursor: '#93a1a1',
|
|
1018
|
+
cursorAccent: '#002b36',
|
|
1019
|
+
selectionBackground: '#073642'
|
|
1020
|
+
}
|
|
1009
1021
|
});
|
|
1010
1022
|
this.mainFitAddon = new FitAddon();
|
|
1011
1023
|
this.mainLinksAddon = new WebLinksAddon();
|
|
@@ -1015,21 +1027,54 @@ class Session {
|
|
|
1015
1027
|
this.mainTerm.loadAddon(this.searchAddon);
|
|
1016
1028
|
this.mainTerm.loadAddon(new CanvasAddon());
|
|
1017
1029
|
|
|
1018
|
-
|
|
1019
|
-
this.mainTerm.onData(data => {
|
|
1030
|
+
this.mainTerm.onData((data) => {
|
|
1020
1031
|
if (this.isRestoring) return;
|
|
1021
1032
|
this.send({ type: 'input', data });
|
|
1022
1033
|
});
|
|
1023
1034
|
|
|
1024
|
-
this.mainTerm.onResize(size => {
|
|
1035
|
+
this.mainTerm.onResize((size) => {
|
|
1025
1036
|
this.previewTerm.resize(size.cols, size.rows);
|
|
1026
1037
|
this.updatePreviewScale();
|
|
1027
|
-
|
|
1038
|
+
|
|
1028
1039
|
const pending = getPendingSession(this.key);
|
|
1029
1040
|
pending.resize = { cols: size.cols, rows: size.rows };
|
|
1030
1041
|
});
|
|
1042
|
+
}
|
|
1031
1043
|
|
|
1032
|
-
|
|
1044
|
+
recreateTerminals() {
|
|
1045
|
+
const wasActive = state.activeSessionKey === this.key;
|
|
1046
|
+
const previewWrapper = this.wrapperElement;
|
|
1047
|
+
|
|
1048
|
+
try {
|
|
1049
|
+
this.previewTerm?.dispose();
|
|
1050
|
+
} catch (e) {
|
|
1051
|
+
if (!e.message?.includes('onRequestRedraw')) {
|
|
1052
|
+
console.warn('Error disposing preview terminal:', e);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
try {
|
|
1057
|
+
this.mainTerm?.dispose();
|
|
1058
|
+
} catch (e) {
|
|
1059
|
+
if (!e.message?.includes('onRequestRedraw')) {
|
|
1060
|
+
console.warn('Error disposing main terminal:', e);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
this._createTerminals();
|
|
1065
|
+
|
|
1066
|
+
if (previewWrapper && window.innerWidth >= 768) {
|
|
1067
|
+
previewWrapper.innerHTML = '';
|
|
1068
|
+
this.previewTerm.open(previewWrapper);
|
|
1069
|
+
this.updatePreviewScale();
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
if (wasActive && terminalEl) {
|
|
1073
|
+
terminalEl.innerHTML = '';
|
|
1074
|
+
this.mainTerm.open(terminalEl);
|
|
1075
|
+
this.mainFitAddon.fit();
|
|
1076
|
+
this.mainTerm.focus();
|
|
1077
|
+
}
|
|
1033
1078
|
}
|
|
1034
1079
|
|
|
1035
1080
|
update(data) {
|
|
@@ -1187,15 +1232,19 @@ class Session {
|
|
|
1187
1232
|
handleMessage(message) {
|
|
1188
1233
|
switch (message.type) {
|
|
1189
1234
|
case 'snapshot':
|
|
1190
|
-
if (this.previewTerm) this.previewTerm.reset();
|
|
1191
|
-
this.mainTerm.reset();
|
|
1192
|
-
this.history = message.data || '';
|
|
1193
1235
|
this.isRestoring = true;
|
|
1194
|
-
|
|
1195
|
-
|
|
1236
|
+
this.recreateTerminals();
|
|
1237
|
+
if (this.previewTerm) this.previewTerm.write(message.data || '');
|
|
1238
|
+
this.mainTerm.write(message.data || '', () => {
|
|
1239
|
+
this.isRestoring = false;
|
|
1240
|
+
if (state.activeSessionKey === this.key) {
|
|
1241
|
+
this.mainFitAddon.fit();
|
|
1242
|
+
this.mainTerm.focus();
|
|
1243
|
+
this.reportResize();
|
|
1244
|
+
}
|
|
1245
|
+
});
|
|
1196
1246
|
break;
|
|
1197
1247
|
case 'output':
|
|
1198
|
-
this.history += message.data;
|
|
1199
1248
|
this.writeToTerminals(message.data);
|
|
1200
1249
|
break;
|
|
1201
1250
|
case 'meta':
|
package/src/persistence.mjs
CHANGED
|
@@ -7,6 +7,7 @@ const BASE_DIR = path.join(HOME_DIR, '.tabminal');
|
|
|
7
7
|
const SESSIONS_DIR = path.join(BASE_DIR, 'sessions');
|
|
8
8
|
const MEMORY_FILE = path.join(BASE_DIR, 'memory.json');
|
|
9
9
|
const CLUSTER_FILE = path.join(BASE_DIR, 'cluster.json');
|
|
10
|
+
const getSessionSnapshotPath = (id) => path.join(SESSIONS_DIR, `${id}.snapshot`);
|
|
10
11
|
|
|
11
12
|
// Ensure directories exist
|
|
12
13
|
const init = async () => {
|
|
@@ -45,6 +46,7 @@ export const saveSession = async (id, data) => {
|
|
|
45
46
|
export const deleteSession = async (id) => {
|
|
46
47
|
const jsonPath = path.join(SESSIONS_DIR, `${id}.json`);
|
|
47
48
|
const logPath = path.join(SESSIONS_DIR, `${id}.log`);
|
|
49
|
+
const snapshotPath = getSessionSnapshotPath(id);
|
|
48
50
|
|
|
49
51
|
try {
|
|
50
52
|
await fs.unlink(jsonPath);
|
|
@@ -57,6 +59,12 @@ export const deleteSession = async (id) => {
|
|
|
57
59
|
} catch (e) {
|
|
58
60
|
if (e.code !== 'ENOENT') console.error(`[Persistence] Failed to delete session log ${id}:`, e);
|
|
59
61
|
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
await fs.unlink(snapshotPath);
|
|
65
|
+
} catch (e) {
|
|
66
|
+
if (e.code !== 'ENOENT') console.error(`[Persistence] Failed to delete session snapshot ${id}:`, e);
|
|
67
|
+
}
|
|
60
68
|
};
|
|
61
69
|
|
|
62
70
|
export const loadSessions = async () => {
|
|
@@ -197,6 +205,16 @@ export const appendSessionLog = async (id, chunk) => {
|
|
|
197
205
|
}
|
|
198
206
|
};
|
|
199
207
|
|
|
208
|
+
export const saveSessionSnapshot = async (id, snapshot) => {
|
|
209
|
+
await init();
|
|
210
|
+
const filePath = getSessionSnapshotPath(id);
|
|
211
|
+
try {
|
|
212
|
+
await fs.writeFile(filePath, snapshot, 'utf8');
|
|
213
|
+
} catch (e) {
|
|
214
|
+
console.error(`[Persistence] Failed to save snapshot for ${id}:`, e);
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
200
218
|
export const loadSessionLog = async (id, limit = 1024 * 1024) => {
|
|
201
219
|
const filePath = path.join(SESSIONS_DIR, `${id}.log`);
|
|
202
220
|
try {
|
|
@@ -218,3 +236,19 @@ export const loadSessionLog = async (id, limit = 1024 * 1024) => {
|
|
|
218
236
|
return '';
|
|
219
237
|
}
|
|
220
238
|
};
|
|
239
|
+
|
|
240
|
+
export const loadSessionSnapshot = async (id) => {
|
|
241
|
+
const filePath = getSessionSnapshotPath(id);
|
|
242
|
+
try {
|
|
243
|
+
return await fs.readFile(filePath, 'utf8');
|
|
244
|
+
} catch (e) {
|
|
245
|
+
if (e.code !== 'ENOENT') {
|
|
246
|
+
console.error(
|
|
247
|
+
`[Persistence] Failed to load snapshot for ${id}:`,
|
|
248
|
+
e
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return loadSessionLog(id);
|
|
254
|
+
};
|
package/src/terminal-manager.mjs
CHANGED
|
@@ -59,6 +59,7 @@ function buildBashBootstrap({
|
|
|
59
59
|
export class TerminalManager {
|
|
60
60
|
constructor() {
|
|
61
61
|
this.sessions = new Map();
|
|
62
|
+
this.snapshotPersistTimers = new Map();
|
|
62
63
|
this.lastCols = initialCols;
|
|
63
64
|
this.lastRows = initialRows;
|
|
64
65
|
this.disposing = false;
|
|
@@ -192,8 +193,10 @@ precmd_functions+=(_tabminal_zsh_apply_prompt_marker)
|
|
|
192
193
|
});
|
|
193
194
|
|
|
194
195
|
if (restoredData) {
|
|
195
|
-
persistence.
|
|
196
|
-
if (
|
|
196
|
+
persistence.loadSessionSnapshot(id).then(async (snapshot) => {
|
|
197
|
+
if (!snapshot) return;
|
|
198
|
+
await session.restoreSnapshot(snapshot);
|
|
199
|
+
this.scheduleSnapshotPersist(id);
|
|
197
200
|
});
|
|
198
201
|
}
|
|
199
202
|
|
|
@@ -238,8 +241,25 @@ precmd_functions+=(_tabminal_zsh_apply_prompt_marker)
|
|
|
238
241
|
}
|
|
239
242
|
}
|
|
240
243
|
|
|
241
|
-
|
|
242
|
-
|
|
244
|
+
scheduleSnapshotPersist(id) {
|
|
245
|
+
const session = this.sessions.get(id);
|
|
246
|
+
if (!session) return;
|
|
247
|
+
|
|
248
|
+
const existing = this.snapshotPersistTimers.get(id);
|
|
249
|
+
if (existing) {
|
|
250
|
+
clearTimeout(existing);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const timer = setTimeout(async () => {
|
|
254
|
+
this.snapshotPersistTimers.delete(id);
|
|
255
|
+
const currentSession = this.sessions.get(id);
|
|
256
|
+
if (!currentSession) return;
|
|
257
|
+
|
|
258
|
+
const snapshot = await currentSession.serializeSnapshot();
|
|
259
|
+
await persistence.saveSessionSnapshot(id, snapshot);
|
|
260
|
+
}, 250);
|
|
261
|
+
|
|
262
|
+
this.snapshotPersistTimers.set(id, timer);
|
|
243
263
|
}
|
|
244
264
|
|
|
245
265
|
getSession(id) {
|
|
@@ -263,6 +283,11 @@ precmd_functions+=(_tabminal_zsh_apply_prompt_marker)
|
|
|
263
283
|
removeSession(id) {
|
|
264
284
|
const session = this.sessions.get(id);
|
|
265
285
|
if (session) {
|
|
286
|
+
const timer = this.snapshotPersistTimers.get(id);
|
|
287
|
+
if (timer) {
|
|
288
|
+
clearTimeout(timer);
|
|
289
|
+
this.snapshotPersistTimers.delete(id);
|
|
290
|
+
}
|
|
266
291
|
session.dispose();
|
|
267
292
|
this.sessions.delete(id);
|
|
268
293
|
persistence.deleteSession(id);
|
|
@@ -289,6 +314,10 @@ precmd_functions+=(_tabminal_zsh_apply_prompt_marker)
|
|
|
289
314
|
dispose() {
|
|
290
315
|
debugLog('[Manager] Disposing all sessions.');
|
|
291
316
|
this.disposing = true;
|
|
317
|
+
for (const timer of this.snapshotPersistTimers.values()) {
|
|
318
|
+
clearTimeout(timer);
|
|
319
|
+
}
|
|
320
|
+
this.snapshotPersistTimers.clear();
|
|
292
321
|
for (const session of this.sessions.values()) {
|
|
293
322
|
try {
|
|
294
323
|
if (process.platform === 'win32') {
|
package/src/terminal-session.mjs
CHANGED
|
@@ -6,10 +6,15 @@ import { alan } from 'utilitas';
|
|
|
6
6
|
import { config } from './config.mjs';
|
|
7
7
|
|
|
8
8
|
const execAsync = promisify(exec);
|
|
9
|
+
const {
|
|
10
|
+
HeadlessTerminal,
|
|
11
|
+
SerializeAddon
|
|
12
|
+
} = await loadHeadlessXtermPackages();
|
|
9
13
|
const WS_STATE_OPEN = 1;
|
|
10
14
|
const DEFAULT_HISTORY_LIMIT = 512 * 1024; // chars
|
|
11
15
|
const OSC_SEQUENCE_REGEX =
|
|
12
16
|
/\u001b\]1337;(ExitCode=(\d+);CommandB64=([a-zA-Z0-9+/=]+)|TabminalPrompt)\u0007/g;
|
|
17
|
+
const EXTRA_PRIVATE_MODE_REGEX = /\u001b\[\?(1005|1006|1015)([hl])/g;
|
|
13
18
|
const CSI_SEQUENCE_REGEX = /\u001b\[[0-9;?]*[ -\/]*[@-~]/g;
|
|
14
19
|
const OSC_STRIP_REGEX = /\u001b\][\s\S]*?(?:\u0007|\u001b\\)/g;
|
|
15
20
|
const DCS_SEQUENCE_REGEX = /\u001bP[\s\S]*?(?:\u0007|\u001b\\)/g;
|
|
@@ -26,6 +31,39 @@ const IGNORED_COMMANDS = [
|
|
|
26
31
|
|
|
27
32
|
const PROMPT_PREFIX = "You are now operating as an AI terminal assistant. Your name is `Tabminal`. You will assist users in resolving terminal or coding issues and answering other inquiries. When troubleshooting terminal errors, you will be provided with the execution history to understand the context. However, please focus primarily on the most recent runtime errors and the user's latest questions. Keep your answers concise and accurate. Resolve the issue clearly and provide the reasoning while avoiding lengthy elaborations. Most user terminal variable keys are normal under typical circumstances and do not need to be treated as security risks.\n\n";
|
|
28
33
|
|
|
34
|
+
async function loadHeadlessXtermPackages() {
|
|
35
|
+
const hadNavigator = Object.prototype.hasOwnProperty.call(
|
|
36
|
+
globalThis,
|
|
37
|
+
'navigator'
|
|
38
|
+
);
|
|
39
|
+
const navigatorDescriptor = hadNavigator
|
|
40
|
+
? Object.getOwnPropertyDescriptor(globalThis, 'navigator')
|
|
41
|
+
: null;
|
|
42
|
+
|
|
43
|
+
if (hadNavigator) {
|
|
44
|
+
delete globalThis.navigator;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const headlessPackage = await import('xterm-headless');
|
|
49
|
+
const serializePackage = await import('xterm-addon-serialize');
|
|
50
|
+
const { Terminal } = headlessPackage.default ?? headlessPackage;
|
|
51
|
+
const { SerializeAddon } = serializePackage.default ?? serializePackage;
|
|
52
|
+
return {
|
|
53
|
+
HeadlessTerminal: Terminal,
|
|
54
|
+
SerializeAddon
|
|
55
|
+
};
|
|
56
|
+
} finally {
|
|
57
|
+
if (hadNavigator && navigatorDescriptor) {
|
|
58
|
+
Object.defineProperty(
|
|
59
|
+
globalThis,
|
|
60
|
+
'navigator',
|
|
61
|
+
navigatorDescriptor
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
29
67
|
function splitTrailingPartialSequence(chunk) {
|
|
30
68
|
if (!chunk) {
|
|
31
69
|
return { complete: '', partial: '' };
|
|
@@ -45,6 +83,13 @@ function splitTrailingPartialSequence(chunk) {
|
|
|
45
83
|
return { complete: chunk, partial: '' };
|
|
46
84
|
}
|
|
47
85
|
|
|
86
|
+
function estimateSnapshotScrollback(cols, rows, historyLimit) {
|
|
87
|
+
const safeCols = Math.max(1, cols || 80);
|
|
88
|
+
const safeRows = Math.max(24, rows || 24);
|
|
89
|
+
const estimatedRows = Math.ceil(historyLimit / safeCols);
|
|
90
|
+
return Math.max(safeRows, Math.min(50000, estimatedRows));
|
|
91
|
+
}
|
|
92
|
+
|
|
48
93
|
export class TerminalSession {
|
|
49
94
|
constructor(pty, options = {}) {
|
|
50
95
|
this.pty = pty;
|
|
@@ -69,6 +114,7 @@ export class TerminalSession {
|
|
|
69
114
|
this.historyLimit = Math.max(1, options.historyLimit ?? DEFAULT_HISTORY_LIMIT);
|
|
70
115
|
this.history = '';
|
|
71
116
|
this.clients = new Set();
|
|
117
|
+
this.pendingClients = new Map();
|
|
72
118
|
this.closed = false;
|
|
73
119
|
this.pollingInterval = null;
|
|
74
120
|
this.captureBuffer = '';
|
|
@@ -76,6 +122,27 @@ export class TerminalSession {
|
|
|
76
122
|
this.lastExecution = null;
|
|
77
123
|
this.skipNextShellLog = false;
|
|
78
124
|
this.partialSequenceBuffer = '';
|
|
125
|
+
this.snapshotScrollback = estimateSnapshotScrollback(
|
|
126
|
+
this.pty.cols,
|
|
127
|
+
this.pty.rows,
|
|
128
|
+
this.historyLimit
|
|
129
|
+
);
|
|
130
|
+
this.snapshotTerminal = new HeadlessTerminal({
|
|
131
|
+
cols: this.pty.cols,
|
|
132
|
+
rows: this.pty.rows,
|
|
133
|
+
scrollback: this.snapshotScrollback,
|
|
134
|
+
allowProposedApi: true,
|
|
135
|
+
convertEol: true,
|
|
136
|
+
logLevel: 'off'
|
|
137
|
+
});
|
|
138
|
+
this.snapshotSerializeAddon = new SerializeAddon();
|
|
139
|
+
this.snapshotTerminal.loadAddon(this.snapshotSerializeAddon);
|
|
140
|
+
this.snapshotWritePromise = Promise.resolve();
|
|
141
|
+
this.extraPrivateModes = {
|
|
142
|
+
1005: false,
|
|
143
|
+
1006: false,
|
|
144
|
+
1015: false
|
|
145
|
+
};
|
|
79
146
|
|
|
80
147
|
this.ansiParser = new AnsiParser({
|
|
81
148
|
inst_o: (s) => {
|
|
@@ -112,10 +179,6 @@ export class TerminalSession {
|
|
|
112
179
|
chunk = split.complete;
|
|
113
180
|
this.partialSequenceBuffer = split.partial;
|
|
114
181
|
|
|
115
|
-
if (this.manager) {
|
|
116
|
-
this.manager.appendLog(this.id, chunk);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
182
|
let cleaned = '';
|
|
120
183
|
let lastIndex = 0;
|
|
121
184
|
OSC_SEQUENCE_REGEX.lastIndex = 0;
|
|
@@ -148,8 +211,12 @@ export class TerminalSession {
|
|
|
148
211
|
|
|
149
212
|
if (!cleaned) return;
|
|
150
213
|
|
|
214
|
+
this._appendSnapshotData(cleaned);
|
|
151
215
|
this._appendHistory(cleaned);
|
|
152
216
|
this.ansiParser.parse(cleaned);
|
|
217
|
+
if (this.manager?.scheduleSnapshotPersist) {
|
|
218
|
+
this.manager.scheduleSnapshotPersist(this.id);
|
|
219
|
+
}
|
|
153
220
|
this._broadcast({ type: 'output', data: cleaned });
|
|
154
221
|
};
|
|
155
222
|
|
|
@@ -272,36 +339,57 @@ export class TerminalSession {
|
|
|
272
339
|
|
|
273
340
|
attach(ws) {
|
|
274
341
|
if (!ws) throw new Error('WebSocket instance required');
|
|
275
|
-
this.
|
|
276
|
-
ws.once('close', () =>
|
|
342
|
+
this.pendingClients.set(ws, []);
|
|
343
|
+
ws.once('close', () => {
|
|
344
|
+
this.clients.delete(ws);
|
|
345
|
+
this.pendingClients.delete(ws);
|
|
346
|
+
});
|
|
277
347
|
ws.on('message', (raw) => this._routeIncoming(raw, ws));
|
|
278
348
|
ws.on('error', () => ws.close());
|
|
279
349
|
|
|
280
|
-
this.
|
|
281
|
-
this._send(ws, { type: 'meta', title: this.title, cwd: this.cwd, env: this.env, cols: this.pty.cols, rows: this.pty.rows });
|
|
282
|
-
if (this.closed) {
|
|
283
|
-
this._send(ws, { type: 'status', status: 'terminated' });
|
|
284
|
-
} else {
|
|
285
|
-
this._send(ws, { type: 'status', status: 'ready' });
|
|
286
|
-
}
|
|
350
|
+
void this._sendInitialState(ws);
|
|
287
351
|
}
|
|
288
352
|
|
|
289
353
|
dispose() {
|
|
290
354
|
this.stopTitlePolling();
|
|
291
355
|
this.clients.clear();
|
|
356
|
+
this.pendingClients.clear();
|
|
292
357
|
this.dataSubscription?.dispose?.();
|
|
293
358
|
this.exitSubscription?.dispose?.();
|
|
359
|
+
this.snapshotTerminal?.dispose?.();
|
|
294
360
|
}
|
|
295
361
|
|
|
296
362
|
resize(cols, rows) {
|
|
297
363
|
if (this.closed) return;
|
|
298
364
|
this.pty.resize(cols, rows);
|
|
365
|
+
this._queueSnapshotMutation(() => {
|
|
366
|
+
this.snapshotTerminal.resize(cols, rows);
|
|
367
|
+
});
|
|
368
|
+
if (this.manager?.scheduleSnapshotPersist) {
|
|
369
|
+
this.manager.scheduleSnapshotPersist(this.id);
|
|
370
|
+
}
|
|
299
371
|
this._broadcast({ type: 'meta', title: this.title, cwd: this.cwd, env: this.env, cols, rows });
|
|
300
372
|
if (this.manager && this.manager.saveSessionState) {
|
|
301
373
|
this.manager.saveSessionState(this);
|
|
302
374
|
}
|
|
303
375
|
}
|
|
304
376
|
|
|
377
|
+
async restoreSnapshot(snapshot) {
|
|
378
|
+
if (typeof snapshot !== 'string' || !snapshot) return;
|
|
379
|
+
this.history = snapshot;
|
|
380
|
+
this._appendSnapshotData(snapshot);
|
|
381
|
+
await this.snapshotWritePromise;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async serializeSnapshot() {
|
|
385
|
+
await this.snapshotWritePromise;
|
|
386
|
+
let snapshot = this.snapshotSerializeAddon.serialize({
|
|
387
|
+
scrollback: this.snapshotScrollback
|
|
388
|
+
});
|
|
389
|
+
snapshot += this._serializeExtraPrivateModes();
|
|
390
|
+
return snapshot;
|
|
391
|
+
}
|
|
392
|
+
|
|
305
393
|
_routeIncoming(raw, ws) {
|
|
306
394
|
let payload;
|
|
307
395
|
try {
|
|
@@ -920,6 +1008,9 @@ export class TerminalSession {
|
|
|
920
1008
|
for (const client of this.clients) {
|
|
921
1009
|
this._send(client, message, data);
|
|
922
1010
|
}
|
|
1011
|
+
for (const pending of this.pendingClients.values()) {
|
|
1012
|
+
pending.push(data);
|
|
1013
|
+
}
|
|
923
1014
|
}
|
|
924
1015
|
|
|
925
1016
|
_send(ws, message, preEncoded) {
|
|
@@ -929,12 +1020,82 @@ export class TerminalSession {
|
|
|
929
1020
|
|
|
930
1021
|
_writeToLogAndBroadcast(text) {
|
|
931
1022
|
if (!text) return;
|
|
932
|
-
|
|
933
|
-
this.manager.appendLog(this.id, text);
|
|
934
|
-
}
|
|
1023
|
+
this._appendSnapshotData(text);
|
|
935
1024
|
this._appendHistory(text);
|
|
1025
|
+
if (this.manager?.scheduleSnapshotPersist) {
|
|
1026
|
+
this.manager.scheduleSnapshotPersist(this.id);
|
|
1027
|
+
}
|
|
936
1028
|
this._broadcast({ type: 'output', data: text });
|
|
937
1029
|
}
|
|
1030
|
+
|
|
1031
|
+
_queueSnapshotMutation(mutate) {
|
|
1032
|
+
const next = this.snapshotWritePromise.then(() => mutate());
|
|
1033
|
+
this.snapshotWritePromise = next.catch(() => {});
|
|
1034
|
+
return next;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
_appendSnapshotData(text) {
|
|
1038
|
+
if (!text) return;
|
|
1039
|
+
this._trackExtraPrivateModes(text);
|
|
1040
|
+
this._queueSnapshotMutation(() => new Promise((resolve) => {
|
|
1041
|
+
this.snapshotTerminal.write(text, resolve);
|
|
1042
|
+
}));
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
_trackExtraPrivateModes(text) {
|
|
1046
|
+
EXTRA_PRIVATE_MODE_REGEX.lastIndex = 0;
|
|
1047
|
+
let match;
|
|
1048
|
+
while ((match = EXTRA_PRIVATE_MODE_REGEX.exec(text)) !== null) {
|
|
1049
|
+
this.extraPrivateModes[match[1]] = match[2] === 'h';
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
_serializeExtraPrivateModes() {
|
|
1054
|
+
let output = '';
|
|
1055
|
+
for (const mode of ['1005', '1006', '1015']) {
|
|
1056
|
+
if (this.extraPrivateModes[mode]) {
|
|
1057
|
+
output += `\u001b[?${mode}h`;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
return output;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
async _sendInitialState(ws) {
|
|
1064
|
+
const pending = this.pendingClients.get(ws);
|
|
1065
|
+
if (!pending) return;
|
|
1066
|
+
|
|
1067
|
+
await this._queueSnapshotMutation(() => undefined);
|
|
1068
|
+
if (ws.readyState !== WS_STATE_OPEN) {
|
|
1069
|
+
this.pendingClients.delete(ws);
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
const snapshot = await this.serializeSnapshot();
|
|
1074
|
+
this._send(ws, { type: 'snapshot', data: snapshot });
|
|
1075
|
+
this._send(ws, {
|
|
1076
|
+
type: 'meta',
|
|
1077
|
+
title: this.title,
|
|
1078
|
+
cwd: this.cwd,
|
|
1079
|
+
env: this.env,
|
|
1080
|
+
cols: this.pty.cols,
|
|
1081
|
+
rows: this.pty.rows
|
|
1082
|
+
});
|
|
1083
|
+
if (this.closed) {
|
|
1084
|
+
this._send(ws, { type: 'status', status: 'terminated' });
|
|
1085
|
+
} else {
|
|
1086
|
+
this._send(ws, { type: 'status', status: 'ready' });
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
for (const payload of pending) {
|
|
1090
|
+
if (ws.readyState !== WS_STATE_OPEN) break;
|
|
1091
|
+
ws.send(payload);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
this.pendingClients.delete(ws);
|
|
1095
|
+
if (ws.readyState === WS_STATE_OPEN) {
|
|
1096
|
+
this.clients.add(ws);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
938
1099
|
}
|
|
939
1100
|
|
|
940
1101
|
function clampDimension(value) {
|