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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tabminal",
3
- "version": "2.0.12",
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.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.19.0"
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.3"
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
- // Preview Terminal (Always create instance to maintain logic consistency)
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: { background: '#002b36', foreground: '#839496', cursor: 'transparent', selectionBackground: 'transparent' }
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: { background: '#002b36', foreground: '#839496', cursor: '#93a1a1', cursorAccent: '#002b36', selectionBackground: '#073642' }
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
- // Event Listeners
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
- this.connect();
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
- if (this.previewTerm) this.previewTerm.write(this.history);
1195
- this.mainTerm.write(this.history, () => { this.isRestoring = false; });
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':
@@ -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
 
@@ -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.loadSessionLog(id).then(log => {
196
- if (log) session.history = log;
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
- persistence.saveSession(session.id, {
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
- appendLog(id, chunk) {
242
- persistence.appendSessionLog(id, chunk);
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') {
@@ -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.clients.add(ws);
276
- ws.once('close', () => this.clients.delete(ws));
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._send(ws, { type: 'snapshot', data: this.history });
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 alan.prompt(finalPrompt, {
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
- if (this.manager) {
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) {