tabminal 2.0.13 → 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.13",
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,7 @@
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
47
  "xterm-addon-serialize": "^0.11.0",
48
48
  "xterm-headless": "^5.3.0"
49
49
  },
@@ -56,6 +56,6 @@
56
56
  },
57
57
  "homepage": "https://github.com/leask/tabminal#readme",
58
58
  "devDependencies": {
59
- "eslint": "^10.0.3"
59
+ "eslint": "^10.1.0"
60
60
  }
61
61
  }
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
 
@@ -60,11 +60,29 @@ export class TerminalManager {
60
60
  constructor() {
61
61
  this.sessions = new Map();
62
62
  this.snapshotPersistTimers = new Map();
63
+ this.sessionPersistenceChains = new Map();
63
64
  this.lastCols = initialCols;
64
65
  this.lastRows = initialRows;
65
66
  this.disposing = false;
66
67
  }
67
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
+
68
86
  createSession(restoredData = null) {
69
87
  // Use ID from options if present, otherwise generate new
70
88
  const id = (restoredData && restoredData.id) ? restoredData.id : crypto.randomUUID();
@@ -200,24 +218,28 @@ precmd_functions+=(_tabminal_zsh_apply_prompt_marker)
200
218
  });
201
219
  }
202
220
 
221
+ this.sessions.set(id, session);
222
+
203
223
  // Initial save
204
- this.saveSessionState(session);
224
+ void this.saveSessionState(session);
205
225
 
206
226
  ptyProcess.onExit(() => {
207
- this.removeSession(id);
227
+ void this.removeSession(id);
208
228
  // Cleanup temp files
209
229
  try {
210
230
  if (initDirPath && fs.existsSync(initDirPath)) fs.rmSync(initDirPath, { recursive: true, force: true });
211
231
  } catch { /* ignore cleanup errors */ }
212
232
  });
213
-
214
- this.sessions.set(id, session);
215
233
  debugLog(`[Manager] Created session ${id}`);
216
234
  return session;
217
235
  }
218
236
 
219
237
  saveSessionState(session) {
220
- 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, {
221
243
  id: session.id,
222
244
  title: session.title,
223
245
  cwd: session.cwd,
@@ -227,7 +249,7 @@ precmd_functions+=(_tabminal_zsh_apply_prompt_marker)
227
249
  createdAt: session.createdAt,
228
250
  editorState: session.editorState,
229
251
  executions: session.executions
230
- });
252
+ }));
231
253
  }
232
254
 
233
255
  updateSessionState(id, data) {
@@ -250,13 +272,17 @@ precmd_functions+=(_tabminal_zsh_apply_prompt_marker)
250
272
  clearTimeout(existing);
251
273
  }
252
274
 
253
- const timer = setTimeout(async () => {
275
+ const timer = setTimeout(() => {
254
276
  this.snapshotPersistTimers.delete(id);
255
277
  const currentSession = this.sessions.get(id);
256
278
  if (!currentSession) return;
257
279
 
258
- const snapshot = await currentSession.serializeSnapshot();
259
- await persistence.saveSessionSnapshot(id, snapshot);
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
+ });
260
286
  }, 250);
261
287
 
262
288
  this.snapshotPersistTimers.set(id, timer);
@@ -280,7 +306,7 @@ precmd_functions+=(_tabminal_zsh_apply_prompt_marker)
280
306
  this.lastRows = rows;
281
307
  }
282
308
 
283
- removeSession(id) {
309
+ async removeSession(id) {
284
310
  const session = this.sessions.get(id);
285
311
  if (session) {
286
312
  const timer = this.snapshotPersistTimers.get(id);
@@ -288,9 +314,18 @@ precmd_functions+=(_tabminal_zsh_apply_prompt_marker)
288
314
  clearTimeout(timer);
289
315
  this.snapshotPersistTimers.delete(id);
290
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
+ }
291
326
  session.dispose();
292
327
  this.sessions.delete(id);
293
- persistence.deleteSession(id);
328
+ await this.queueSessionPersistence(id, () => persistence.deleteSession(id));
294
329
  debugLog(`[Manager] Removed session ${id}`);
295
330
  }
296
331
  }
@@ -121,7 +121,9 @@ export class TerminalSession {
121
121
  this.captureStartedAt = null;
122
122
  this.lastExecution = null;
123
123
  this.skipNextShellLog = false;
124
+ this.skipNextShellLogResetTimer = null;
124
125
  this.partialSequenceBuffer = '';
126
+ this.activeAiRun = null;
125
127
  this.snapshotScrollback = estimateSnapshotScrollback(
126
128
  this.pty.cols,
127
129
  this.pty.rows,
@@ -352,6 +354,7 @@ export class TerminalSession {
352
354
 
353
355
  dispose() {
354
356
  this.stopTitlePolling();
357
+ this._clearSkipNextShellLogResetTimer();
355
358
  this.clients.clear();
356
359
  this.pendingClients.clear();
357
360
  this.dataSubscription?.dispose?.();
@@ -416,6 +419,11 @@ export class TerminalSession {
416
419
  }
417
420
 
418
421
  write(data) {
422
+ if (this.activeAiRun && typeof data === 'string') {
423
+ this._handleInputDuringAi(data);
424
+ return;
425
+ }
426
+
419
427
  if (typeof data === 'string' && data.startsWith('\x1b')) {
420
428
  this.pty.write(data);
421
429
  this.inputBuffer = '';
@@ -503,6 +511,19 @@ export class TerminalSession {
503
511
  }
504
512
  }
505
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
+
506
527
  _buildAiContext(history) {
507
528
  let pendingShellHistory = '';
508
529
  const conversationHistory = [];
@@ -526,9 +547,67 @@ export class TerminalSession {
526
547
  return { conversationHistory, pendingShellHistory };
527
548
  }
528
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
+
529
607
  async _handleAiCommand(prompt) {
530
608
  // Prevent duplicate logging from shell integration
531
609
  this.skipNextShellLog = true;
610
+ this._clearSkipNextShellLogResetTimer();
532
611
  // Ensure clean line start and set Cyan color (No prefix yet)
533
612
  this._writeToLogAndBroadcast('\r\x1b[K\x1b[36m');
534
613
  // Gather Context (Current Session Only)
@@ -546,8 +625,17 @@ export class TerminalSession {
546
625
  const startTime = new Date();
547
626
  let fullResponse = '';
548
627
  let isFirstChunk = true;
628
+ const run = {
629
+ prompt,
630
+ startedAt: startTime,
631
+ cancelled: false
632
+ };
633
+ this.activeAiRun = run;
549
634
  try {
550
635
  const streamCallback = (chunk) => {
636
+ if (this.activeAiRun !== run || run.cancelled) {
637
+ return;
638
+ }
551
639
  // console.log('Chunk Received:');
552
640
  // console.log(chunk);
553
641
  if (chunk && chunk.text) {
@@ -563,13 +651,17 @@ export class TerminalSession {
563
651
  }
564
652
  };
565
653
  // console.log('Start AI Prompt...');
566
- const result = await alan.prompt(finalPrompt, {
654
+ const result = await this._promptAi(finalPrompt, {
567
655
  stream: streamCallback,
568
656
  delta: true,
569
657
  messages: conversationHistory,
570
658
  trimBeginning: true
571
659
  });
572
660
 
661
+ if (this.activeAiRun !== run || run.cancelled) {
662
+ return;
663
+ }
664
+
573
665
  if (result && result.text) {
574
666
  fullResponse = result.text;
575
667
  }
@@ -588,6 +680,9 @@ export class TerminalSession {
588
680
  });
589
681
 
590
682
  } catch (e) {
683
+ if (this.activeAiRun !== run || run.cancelled) {
684
+ return;
685
+ }
591
686
  this._writeToLogAndBroadcast(`\x1b[31mAI Error: ${e.message}\x1b[0m\r\n`);
592
687
 
593
688
  this._logCommandExecution({
@@ -598,13 +693,12 @@ export class TerminalSession {
598
693
  startedAt: startTime,
599
694
  completedAt: new Date()
600
695
  });
696
+ } finally {
697
+ if (this.activeAiRun === run) {
698
+ this.activeAiRun = null;
699
+ this._finishAiInteraction('normal');
700
+ }
601
701
  }
602
-
603
- // Resume PTY output
604
- this.suppressPtyOutput = false;
605
-
606
- // Restore prompt by sending \r to pty (empty command)
607
- this.pty.write('\r');
608
702
  }
609
703
 
610
704
  _handleResize(cols, rows) {
@@ -639,6 +733,7 @@ export class TerminalSession {
639
733
  _handleExitCodeSequence(exitCodeStr, cmdB64) {
640
734
  if (this.skipNextShellLog) {
641
735
  this.skipNextShellLog = false;
736
+ this._clearSkipNextShellLogResetTimer();
642
737
  this.captureBuffer = '';
643
738
  this.captureStartedAt = null;
644
739
  return;