tabminal 2.0.12 → 2.0.14
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 +6 -4
- package/public/app.js +68 -19
- package/src/persistence.mjs +34 -0
- package/src/server.mjs +2 -2
- package/src/terminal-manager.mjs +76 -12
- package/src/terminal-session.mjs +279 -23
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tabminal",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.14",
|
|
4
4
|
"description": "A modern, persistent web terminal with multi-tab support and real-time system monitoring.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"@fontsource/monaspace-neon": "^5.2.5",
|
|
36
36
|
"@koa/router": "^15.4.0",
|
|
37
37
|
"@mozilla/readability": "^0.6.0",
|
|
38
|
-
"jsdom": "^29.0.
|
|
38
|
+
"jsdom": "^29.0.1",
|
|
39
39
|
"koa": "^3.1.2",
|
|
40
40
|
"koa-bodyparser": "^4.4.1",
|
|
41
41
|
"koa-static": "^5.0.0",
|
|
@@ -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.
|
|
46
|
+
"ws": "^8.20.0",
|
|
47
|
+
"xterm-addon-serialize": "^0.11.0",
|
|
48
|
+
"xterm-headless": "^5.3.0"
|
|
47
49
|
},
|
|
48
50
|
"repository": {
|
|
49
51
|
"type": "git",
|
|
@@ -54,6 +56,6 @@
|
|
|
54
56
|
},
|
|
55
57
|
"homepage": "https://github.com/leask/tabminal#readme",
|
|
56
58
|
"devDependencies": {
|
|
57
|
-
"eslint": "^10.0
|
|
59
|
+
"eslint": "^10.1.0"
|
|
58
60
|
}
|
|
59
61
|
}
|
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/server.mjs
CHANGED
|
@@ -199,9 +199,9 @@ router.post('/api/sessions', (ctx) => {
|
|
|
199
199
|
};
|
|
200
200
|
});
|
|
201
201
|
|
|
202
|
-
router.delete('/api/sessions/:id', (ctx) => {
|
|
202
|
+
router.delete('/api/sessions/:id', async (ctx) => {
|
|
203
203
|
const { id } = ctx.params;
|
|
204
|
-
terminalManager.removeSession(id);
|
|
204
|
+
await terminalManager.removeSession(id);
|
|
205
205
|
ctx.status = 204;
|
|
206
206
|
});
|
|
207
207
|
|
package/src/terminal-manager.mjs
CHANGED
|
@@ -59,11 +59,30 @@ function buildBashBootstrap({
|
|
|
59
59
|
export class TerminalManager {
|
|
60
60
|
constructor() {
|
|
61
61
|
this.sessions = new Map();
|
|
62
|
+
this.snapshotPersistTimers = new Map();
|
|
63
|
+
this.sessionPersistenceChains = new Map();
|
|
62
64
|
this.lastCols = initialCols;
|
|
63
65
|
this.lastRows = initialRows;
|
|
64
66
|
this.disposing = false;
|
|
65
67
|
}
|
|
66
68
|
|
|
69
|
+
queueSessionPersistence(id, operation) {
|
|
70
|
+
const previous = this.sessionPersistenceChains.get(id)
|
|
71
|
+
|| Promise.resolve();
|
|
72
|
+
const next = previous
|
|
73
|
+
.catch(() => {})
|
|
74
|
+
.then(operation);
|
|
75
|
+
|
|
76
|
+
this.sessionPersistenceChains.set(id, next);
|
|
77
|
+
next.finally(() => {
|
|
78
|
+
if (this.sessionPersistenceChains.get(id) === next) {
|
|
79
|
+
this.sessionPersistenceChains.delete(id);
|
|
80
|
+
}
|
|
81
|
+
}).catch(() => {});
|
|
82
|
+
|
|
83
|
+
return next;
|
|
84
|
+
}
|
|
85
|
+
|
|
67
86
|
createSession(restoredData = null) {
|
|
68
87
|
// Use ID from options if present, otherwise generate new
|
|
69
88
|
const id = (restoredData && restoredData.id) ? restoredData.id : crypto.randomUUID();
|
|
@@ -192,29 +211,35 @@ precmd_functions+=(_tabminal_zsh_apply_prompt_marker)
|
|
|
192
211
|
});
|
|
193
212
|
|
|
194
213
|
if (restoredData) {
|
|
195
|
-
persistence.
|
|
196
|
-
if (
|
|
214
|
+
persistence.loadSessionSnapshot(id).then(async (snapshot) => {
|
|
215
|
+
if (!snapshot) return;
|
|
216
|
+
await session.restoreSnapshot(snapshot);
|
|
217
|
+
this.scheduleSnapshotPersist(id);
|
|
197
218
|
});
|
|
198
219
|
}
|
|
199
220
|
|
|
221
|
+
this.sessions.set(id, session);
|
|
222
|
+
|
|
200
223
|
// Initial save
|
|
201
|
-
this.saveSessionState(session);
|
|
224
|
+
void this.saveSessionState(session);
|
|
202
225
|
|
|
203
226
|
ptyProcess.onExit(() => {
|
|
204
|
-
this.removeSession(id);
|
|
227
|
+
void this.removeSession(id);
|
|
205
228
|
// Cleanup temp files
|
|
206
229
|
try {
|
|
207
230
|
if (initDirPath && fs.existsSync(initDirPath)) fs.rmSync(initDirPath, { recursive: true, force: true });
|
|
208
231
|
} catch { /* ignore cleanup errors */ }
|
|
209
232
|
});
|
|
210
|
-
|
|
211
|
-
this.sessions.set(id, session);
|
|
212
233
|
debugLog(`[Manager] Created session ${id}`);
|
|
213
234
|
return session;
|
|
214
235
|
}
|
|
215
236
|
|
|
216
237
|
saveSessionState(session) {
|
|
217
|
-
|
|
238
|
+
if (this.sessions.get(session.id) !== session) {
|
|
239
|
+
return Promise.resolve();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return this.queueSessionPersistence(session.id, () => persistence.saveSession(session.id, {
|
|
218
243
|
id: session.id,
|
|
219
244
|
title: session.title,
|
|
220
245
|
cwd: session.cwd,
|
|
@@ -224,7 +249,7 @@ precmd_functions+=(_tabminal_zsh_apply_prompt_marker)
|
|
|
224
249
|
createdAt: session.createdAt,
|
|
225
250
|
editorState: session.editorState,
|
|
226
251
|
executions: session.executions
|
|
227
|
-
});
|
|
252
|
+
}));
|
|
228
253
|
}
|
|
229
254
|
|
|
230
255
|
updateSessionState(id, data) {
|
|
@@ -238,8 +263,29 @@ precmd_functions+=(_tabminal_zsh_apply_prompt_marker)
|
|
|
238
263
|
}
|
|
239
264
|
}
|
|
240
265
|
|
|
241
|
-
|
|
242
|
-
|
|
266
|
+
scheduleSnapshotPersist(id) {
|
|
267
|
+
const session = this.sessions.get(id);
|
|
268
|
+
if (!session) return;
|
|
269
|
+
|
|
270
|
+
const existing = this.snapshotPersistTimers.get(id);
|
|
271
|
+
if (existing) {
|
|
272
|
+
clearTimeout(existing);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const timer = setTimeout(() => {
|
|
276
|
+
this.snapshotPersistTimers.delete(id);
|
|
277
|
+
const currentSession = this.sessions.get(id);
|
|
278
|
+
if (!currentSession) return;
|
|
279
|
+
|
|
280
|
+
void this.queueSessionPersistence(id, async () => {
|
|
281
|
+
if (this.sessions.get(id) !== currentSession) return;
|
|
282
|
+
const snapshot = await currentSession.serializeSnapshot();
|
|
283
|
+
if (this.sessions.get(id) !== currentSession) return;
|
|
284
|
+
await persistence.saveSessionSnapshot(id, snapshot);
|
|
285
|
+
});
|
|
286
|
+
}, 250);
|
|
287
|
+
|
|
288
|
+
this.snapshotPersistTimers.set(id, timer);
|
|
243
289
|
}
|
|
244
290
|
|
|
245
291
|
getSession(id) {
|
|
@@ -260,12 +306,26 @@ precmd_functions+=(_tabminal_zsh_apply_prompt_marker)
|
|
|
260
306
|
this.lastRows = rows;
|
|
261
307
|
}
|
|
262
308
|
|
|
263
|
-
removeSession(id) {
|
|
309
|
+
async removeSession(id) {
|
|
264
310
|
const session = this.sessions.get(id);
|
|
265
311
|
if (session) {
|
|
312
|
+
const timer = this.snapshotPersistTimers.get(id);
|
|
313
|
+
if (timer) {
|
|
314
|
+
clearTimeout(timer);
|
|
315
|
+
this.snapshotPersistTimers.delete(id);
|
|
316
|
+
}
|
|
317
|
+
try {
|
|
318
|
+
if (process.platform === 'win32') {
|
|
319
|
+
session.pty.kill();
|
|
320
|
+
} else {
|
|
321
|
+
session.pty.kill('SIGHUP');
|
|
322
|
+
}
|
|
323
|
+
} catch {
|
|
324
|
+
// ignore
|
|
325
|
+
}
|
|
266
326
|
session.dispose();
|
|
267
327
|
this.sessions.delete(id);
|
|
268
|
-
persistence.deleteSession(id);
|
|
328
|
+
await this.queueSessionPersistence(id, () => persistence.deleteSession(id));
|
|
269
329
|
debugLog(`[Manager] Removed session ${id}`);
|
|
270
330
|
}
|
|
271
331
|
}
|
|
@@ -289,6 +349,10 @@ precmd_functions+=(_tabminal_zsh_apply_prompt_marker)
|
|
|
289
349
|
dispose() {
|
|
290
350
|
debugLog('[Manager] Disposing all sessions.');
|
|
291
351
|
this.disposing = true;
|
|
352
|
+
for (const timer of this.snapshotPersistTimers.values()) {
|
|
353
|
+
clearTimeout(timer);
|
|
354
|
+
}
|
|
355
|
+
this.snapshotPersistTimers.clear();
|
|
292
356
|
for (const session of this.sessions.values()) {
|
|
293
357
|
try {
|
|
294
358
|
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,13 +114,37 @@ 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 = '';
|
|
75
121
|
this.captureStartedAt = null;
|
|
76
122
|
this.lastExecution = null;
|
|
77
123
|
this.skipNextShellLog = false;
|
|
124
|
+
this.skipNextShellLogResetTimer = null;
|
|
78
125
|
this.partialSequenceBuffer = '';
|
|
126
|
+
this.activeAiRun = null;
|
|
127
|
+
this.snapshotScrollback = estimateSnapshotScrollback(
|
|
128
|
+
this.pty.cols,
|
|
129
|
+
this.pty.rows,
|
|
130
|
+
this.historyLimit
|
|
131
|
+
);
|
|
132
|
+
this.snapshotTerminal = new HeadlessTerminal({
|
|
133
|
+
cols: this.pty.cols,
|
|
134
|
+
rows: this.pty.rows,
|
|
135
|
+
scrollback: this.snapshotScrollback,
|
|
136
|
+
allowProposedApi: true,
|
|
137
|
+
convertEol: true,
|
|
138
|
+
logLevel: 'off'
|
|
139
|
+
});
|
|
140
|
+
this.snapshotSerializeAddon = new SerializeAddon();
|
|
141
|
+
this.snapshotTerminal.loadAddon(this.snapshotSerializeAddon);
|
|
142
|
+
this.snapshotWritePromise = Promise.resolve();
|
|
143
|
+
this.extraPrivateModes = {
|
|
144
|
+
1005: false,
|
|
145
|
+
1006: false,
|
|
146
|
+
1015: false
|
|
147
|
+
};
|
|
79
148
|
|
|
80
149
|
this.ansiParser = new AnsiParser({
|
|
81
150
|
inst_o: (s) => {
|
|
@@ -112,10 +181,6 @@ export class TerminalSession {
|
|
|
112
181
|
chunk = split.complete;
|
|
113
182
|
this.partialSequenceBuffer = split.partial;
|
|
114
183
|
|
|
115
|
-
if (this.manager) {
|
|
116
|
-
this.manager.appendLog(this.id, chunk);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
184
|
let cleaned = '';
|
|
120
185
|
let lastIndex = 0;
|
|
121
186
|
OSC_SEQUENCE_REGEX.lastIndex = 0;
|
|
@@ -148,8 +213,12 @@ export class TerminalSession {
|
|
|
148
213
|
|
|
149
214
|
if (!cleaned) return;
|
|
150
215
|
|
|
216
|
+
this._appendSnapshotData(cleaned);
|
|
151
217
|
this._appendHistory(cleaned);
|
|
152
218
|
this.ansiParser.parse(cleaned);
|
|
219
|
+
if (this.manager?.scheduleSnapshotPersist) {
|
|
220
|
+
this.manager.scheduleSnapshotPersist(this.id);
|
|
221
|
+
}
|
|
153
222
|
this._broadcast({ type: 'output', data: cleaned });
|
|
154
223
|
};
|
|
155
224
|
|
|
@@ -272,36 +341,58 @@ export class TerminalSession {
|
|
|
272
341
|
|
|
273
342
|
attach(ws) {
|
|
274
343
|
if (!ws) throw new Error('WebSocket instance required');
|
|
275
|
-
this.
|
|
276
|
-
ws.once('close', () =>
|
|
344
|
+
this.pendingClients.set(ws, []);
|
|
345
|
+
ws.once('close', () => {
|
|
346
|
+
this.clients.delete(ws);
|
|
347
|
+
this.pendingClients.delete(ws);
|
|
348
|
+
});
|
|
277
349
|
ws.on('message', (raw) => this._routeIncoming(raw, ws));
|
|
278
350
|
ws.on('error', () => ws.close());
|
|
279
351
|
|
|
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
|
-
}
|
|
352
|
+
void this._sendInitialState(ws);
|
|
287
353
|
}
|
|
288
354
|
|
|
289
355
|
dispose() {
|
|
290
356
|
this.stopTitlePolling();
|
|
357
|
+
this._clearSkipNextShellLogResetTimer();
|
|
291
358
|
this.clients.clear();
|
|
359
|
+
this.pendingClients.clear();
|
|
292
360
|
this.dataSubscription?.dispose?.();
|
|
293
361
|
this.exitSubscription?.dispose?.();
|
|
362
|
+
this.snapshotTerminal?.dispose?.();
|
|
294
363
|
}
|
|
295
364
|
|
|
296
365
|
resize(cols, rows) {
|
|
297
366
|
if (this.closed) return;
|
|
298
367
|
this.pty.resize(cols, rows);
|
|
368
|
+
this._queueSnapshotMutation(() => {
|
|
369
|
+
this.snapshotTerminal.resize(cols, rows);
|
|
370
|
+
});
|
|
371
|
+
if (this.manager?.scheduleSnapshotPersist) {
|
|
372
|
+
this.manager.scheduleSnapshotPersist(this.id);
|
|
373
|
+
}
|
|
299
374
|
this._broadcast({ type: 'meta', title: this.title, cwd: this.cwd, env: this.env, cols, rows });
|
|
300
375
|
if (this.manager && this.manager.saveSessionState) {
|
|
301
376
|
this.manager.saveSessionState(this);
|
|
302
377
|
}
|
|
303
378
|
}
|
|
304
379
|
|
|
380
|
+
async restoreSnapshot(snapshot) {
|
|
381
|
+
if (typeof snapshot !== 'string' || !snapshot) return;
|
|
382
|
+
this.history = snapshot;
|
|
383
|
+
this._appendSnapshotData(snapshot);
|
|
384
|
+
await this.snapshotWritePromise;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async serializeSnapshot() {
|
|
388
|
+
await this.snapshotWritePromise;
|
|
389
|
+
let snapshot = this.snapshotSerializeAddon.serialize({
|
|
390
|
+
scrollback: this.snapshotScrollback
|
|
391
|
+
});
|
|
392
|
+
snapshot += this._serializeExtraPrivateModes();
|
|
393
|
+
return snapshot;
|
|
394
|
+
}
|
|
395
|
+
|
|
305
396
|
_routeIncoming(raw, ws) {
|
|
306
397
|
let payload;
|
|
307
398
|
try {
|
|
@@ -328,6 +419,11 @@ export class TerminalSession {
|
|
|
328
419
|
}
|
|
329
420
|
|
|
330
421
|
write(data) {
|
|
422
|
+
if (this.activeAiRun && typeof data === 'string') {
|
|
423
|
+
this._handleInputDuringAi(data);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
331
427
|
if (typeof data === 'string' && data.startsWith('\x1b')) {
|
|
332
428
|
this.pty.write(data);
|
|
333
429
|
this.inputBuffer = '';
|
|
@@ -415,6 +511,19 @@ export class TerminalSession {
|
|
|
415
511
|
}
|
|
416
512
|
}
|
|
417
513
|
|
|
514
|
+
_handleInputDuringAi(data) {
|
|
515
|
+
for (const char of data) {
|
|
516
|
+
if (char === '\x03') {
|
|
517
|
+
this._cancelActiveAiRun('ctrl_c');
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
if (char === '\x04') {
|
|
521
|
+
this._cancelActiveAiRun('ctrl_d');
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
418
527
|
_buildAiContext(history) {
|
|
419
528
|
let pendingShellHistory = '';
|
|
420
529
|
const conversationHistory = [];
|
|
@@ -438,9 +547,67 @@ export class TerminalSession {
|
|
|
438
547
|
return { conversationHistory, pendingShellHistory };
|
|
439
548
|
}
|
|
440
549
|
|
|
550
|
+
_promptAi(prompt, options) {
|
|
551
|
+
return alan.prompt(prompt, options);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
_clearSkipNextShellLogResetTimer() {
|
|
555
|
+
if (this.skipNextShellLogResetTimer) {
|
|
556
|
+
clearTimeout(this.skipNextShellLogResetTimer);
|
|
557
|
+
this.skipNextShellLogResetTimer = null;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
_scheduleSkipNextShellLogReset() {
|
|
562
|
+
this._clearSkipNextShellLogResetTimer();
|
|
563
|
+
this.skipNextShellLogResetTimer = setTimeout(() => {
|
|
564
|
+
this.skipNextShellLog = false;
|
|
565
|
+
this.skipNextShellLogResetTimer = null;
|
|
566
|
+
}, 500);
|
|
567
|
+
this.skipNextShellLogResetTimer.unref?.();
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
_finishAiInteraction(mode = 'normal') {
|
|
571
|
+
this.suppressPtyOutput = false;
|
|
572
|
+
this._scheduleSkipNextShellLogReset();
|
|
573
|
+
|
|
574
|
+
if (mode === 'ctrl_c') {
|
|
575
|
+
this._writeToLogAndBroadcast('\x1b[0m');
|
|
576
|
+
this.pty.write('\x03');
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
this._writeToLogAndBroadcast('\x1b[0m\r\n');
|
|
581
|
+
if (mode === 'ctrl_d') {
|
|
582
|
+
this.pty.write('\x15');
|
|
583
|
+
}
|
|
584
|
+
this.pty.write('\r');
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
_cancelActiveAiRun(reason) {
|
|
588
|
+
const run = this.activeAiRun;
|
|
589
|
+
if (!run || run.cancelled) {
|
|
590
|
+
return false;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
run.cancelled = true;
|
|
594
|
+
this.activeAiRun = null;
|
|
595
|
+
this._logCommandExecution({
|
|
596
|
+
command: 'ai',
|
|
597
|
+
exitCode: 130,
|
|
598
|
+
input: run.prompt,
|
|
599
|
+
output: 'AI generation cancelled.',
|
|
600
|
+
startedAt: run.startedAt,
|
|
601
|
+
completedAt: new Date()
|
|
602
|
+
});
|
|
603
|
+
this._finishAiInteraction(reason);
|
|
604
|
+
return true;
|
|
605
|
+
}
|
|
606
|
+
|
|
441
607
|
async _handleAiCommand(prompt) {
|
|
442
608
|
// Prevent duplicate logging from shell integration
|
|
443
609
|
this.skipNextShellLog = true;
|
|
610
|
+
this._clearSkipNextShellLogResetTimer();
|
|
444
611
|
// Ensure clean line start and set Cyan color (No prefix yet)
|
|
445
612
|
this._writeToLogAndBroadcast('\r\x1b[K\x1b[36m');
|
|
446
613
|
// Gather Context (Current Session Only)
|
|
@@ -458,8 +625,17 @@ export class TerminalSession {
|
|
|
458
625
|
const startTime = new Date();
|
|
459
626
|
let fullResponse = '';
|
|
460
627
|
let isFirstChunk = true;
|
|
628
|
+
const run = {
|
|
629
|
+
prompt,
|
|
630
|
+
startedAt: startTime,
|
|
631
|
+
cancelled: false
|
|
632
|
+
};
|
|
633
|
+
this.activeAiRun = run;
|
|
461
634
|
try {
|
|
462
635
|
const streamCallback = (chunk) => {
|
|
636
|
+
if (this.activeAiRun !== run || run.cancelled) {
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
463
639
|
// console.log('Chunk Received:');
|
|
464
640
|
// console.log(chunk);
|
|
465
641
|
if (chunk && chunk.text) {
|
|
@@ -475,13 +651,17 @@ export class TerminalSession {
|
|
|
475
651
|
}
|
|
476
652
|
};
|
|
477
653
|
// console.log('Start AI Prompt...');
|
|
478
|
-
const result = await
|
|
654
|
+
const result = await this._promptAi(finalPrompt, {
|
|
479
655
|
stream: streamCallback,
|
|
480
656
|
delta: true,
|
|
481
657
|
messages: conversationHistory,
|
|
482
658
|
trimBeginning: true
|
|
483
659
|
});
|
|
484
660
|
|
|
661
|
+
if (this.activeAiRun !== run || run.cancelled) {
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
|
|
485
665
|
if (result && result.text) {
|
|
486
666
|
fullResponse = result.text;
|
|
487
667
|
}
|
|
@@ -500,6 +680,9 @@ export class TerminalSession {
|
|
|
500
680
|
});
|
|
501
681
|
|
|
502
682
|
} catch (e) {
|
|
683
|
+
if (this.activeAiRun !== run || run.cancelled) {
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
503
686
|
this._writeToLogAndBroadcast(`\x1b[31mAI Error: ${e.message}\x1b[0m\r\n`);
|
|
504
687
|
|
|
505
688
|
this._logCommandExecution({
|
|
@@ -510,13 +693,12 @@ export class TerminalSession {
|
|
|
510
693
|
startedAt: startTime,
|
|
511
694
|
completedAt: new Date()
|
|
512
695
|
});
|
|
696
|
+
} finally {
|
|
697
|
+
if (this.activeAiRun === run) {
|
|
698
|
+
this.activeAiRun = null;
|
|
699
|
+
this._finishAiInteraction('normal');
|
|
700
|
+
}
|
|
513
701
|
}
|
|
514
|
-
|
|
515
|
-
// Resume PTY output
|
|
516
|
-
this.suppressPtyOutput = false;
|
|
517
|
-
|
|
518
|
-
// Restore prompt by sending \r to pty (empty command)
|
|
519
|
-
this.pty.write('\r');
|
|
520
702
|
}
|
|
521
703
|
|
|
522
704
|
_handleResize(cols, rows) {
|
|
@@ -551,6 +733,7 @@ export class TerminalSession {
|
|
|
551
733
|
_handleExitCodeSequence(exitCodeStr, cmdB64) {
|
|
552
734
|
if (this.skipNextShellLog) {
|
|
553
735
|
this.skipNextShellLog = false;
|
|
736
|
+
this._clearSkipNextShellLogResetTimer();
|
|
554
737
|
this.captureBuffer = '';
|
|
555
738
|
this.captureStartedAt = null;
|
|
556
739
|
return;
|
|
@@ -920,6 +1103,9 @@ export class TerminalSession {
|
|
|
920
1103
|
for (const client of this.clients) {
|
|
921
1104
|
this._send(client, message, data);
|
|
922
1105
|
}
|
|
1106
|
+
for (const pending of this.pendingClients.values()) {
|
|
1107
|
+
pending.push(data);
|
|
1108
|
+
}
|
|
923
1109
|
}
|
|
924
1110
|
|
|
925
1111
|
_send(ws, message, preEncoded) {
|
|
@@ -929,12 +1115,82 @@ export class TerminalSession {
|
|
|
929
1115
|
|
|
930
1116
|
_writeToLogAndBroadcast(text) {
|
|
931
1117
|
if (!text) return;
|
|
932
|
-
|
|
933
|
-
this.manager.appendLog(this.id, text);
|
|
934
|
-
}
|
|
1118
|
+
this._appendSnapshotData(text);
|
|
935
1119
|
this._appendHistory(text);
|
|
1120
|
+
if (this.manager?.scheduleSnapshotPersist) {
|
|
1121
|
+
this.manager.scheduleSnapshotPersist(this.id);
|
|
1122
|
+
}
|
|
936
1123
|
this._broadcast({ type: 'output', data: text });
|
|
937
1124
|
}
|
|
1125
|
+
|
|
1126
|
+
_queueSnapshotMutation(mutate) {
|
|
1127
|
+
const next = this.snapshotWritePromise.then(() => mutate());
|
|
1128
|
+
this.snapshotWritePromise = next.catch(() => {});
|
|
1129
|
+
return next;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
_appendSnapshotData(text) {
|
|
1133
|
+
if (!text) return;
|
|
1134
|
+
this._trackExtraPrivateModes(text);
|
|
1135
|
+
this._queueSnapshotMutation(() => new Promise((resolve) => {
|
|
1136
|
+
this.snapshotTerminal.write(text, resolve);
|
|
1137
|
+
}));
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
_trackExtraPrivateModes(text) {
|
|
1141
|
+
EXTRA_PRIVATE_MODE_REGEX.lastIndex = 0;
|
|
1142
|
+
let match;
|
|
1143
|
+
while ((match = EXTRA_PRIVATE_MODE_REGEX.exec(text)) !== null) {
|
|
1144
|
+
this.extraPrivateModes[match[1]] = match[2] === 'h';
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
_serializeExtraPrivateModes() {
|
|
1149
|
+
let output = '';
|
|
1150
|
+
for (const mode of ['1005', '1006', '1015']) {
|
|
1151
|
+
if (this.extraPrivateModes[mode]) {
|
|
1152
|
+
output += `\u001b[?${mode}h`;
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
return output;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
async _sendInitialState(ws) {
|
|
1159
|
+
const pending = this.pendingClients.get(ws);
|
|
1160
|
+
if (!pending) return;
|
|
1161
|
+
|
|
1162
|
+
await this._queueSnapshotMutation(() => undefined);
|
|
1163
|
+
if (ws.readyState !== WS_STATE_OPEN) {
|
|
1164
|
+
this.pendingClients.delete(ws);
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
const snapshot = await this.serializeSnapshot();
|
|
1169
|
+
this._send(ws, { type: 'snapshot', data: snapshot });
|
|
1170
|
+
this._send(ws, {
|
|
1171
|
+
type: 'meta',
|
|
1172
|
+
title: this.title,
|
|
1173
|
+
cwd: this.cwd,
|
|
1174
|
+
env: this.env,
|
|
1175
|
+
cols: this.pty.cols,
|
|
1176
|
+
rows: this.pty.rows
|
|
1177
|
+
});
|
|
1178
|
+
if (this.closed) {
|
|
1179
|
+
this._send(ws, { type: 'status', status: 'terminated' });
|
|
1180
|
+
} else {
|
|
1181
|
+
this._send(ws, { type: 'status', status: 'ready' });
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
for (const payload of pending) {
|
|
1185
|
+
if (ws.readyState !== WS_STATE_OPEN) break;
|
|
1186
|
+
ws.send(payload);
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
this.pendingClients.delete(ws);
|
|
1190
|
+
if (ws.readyState === WS_STATE_OPEN) {
|
|
1191
|
+
this.clients.add(ws);
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
938
1194
|
}
|
|
939
1195
|
|
|
940
1196
|
function clampDimension(value) {
|