tabminal 2.0.11 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tabminal",
3
- "version": "2.0.11",
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
- // 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':
@@ -0,0 +1,17 @@
1
+ if [[ -n "${TABMINAL_SHELL_TOOLS_PATH:-}" ]]; then
2
+ export PATH="${TABMINAL_SHELL_TOOLS_PATH}:$PATH"
3
+ fi
4
+
5
+ if [[ -f ~/.bashrc ]]; then
6
+ source ~/.bashrc
7
+ fi
8
+
9
+ if [[ -n "${TABMINAL_SHELL_TOOLS_PATH:-}" ]]; then
10
+ export PATH="${TABMINAL_SHELL_TOOLS_PATH}:$PATH"
11
+ fi
12
+
13
+ if [[ -n "${TABMINAL_HOOKS_PATH:-}" ]] && [[ -f "$TABMINAL_HOOKS_PATH" ]]; then
14
+ source "$TABMINAL_HOOKS_PATH"
15
+ fi
16
+
17
+ TABMINAL_SHELL_READY=1
@@ -0,0 +1,78 @@
1
+ if [[ -z "${TABMINAL_SESSION_ID:-}" ]]; then
2
+ return 0
3
+ fi
4
+
5
+ if [[ -n "${TABMINAL_BASH_HOOKS_LOADED:-}" ]]; then
6
+ return 0
7
+ fi
8
+
9
+ TABMINAL_BASH_HOOKS_LOADED=1
10
+
11
+ _tabminal_bash_preexec() {
12
+ if [[ "${BASH_COMMAND:-}" == *"_tabminal_"* ]]; then
13
+ return
14
+ fi
15
+ if [[ "${BASH_COMMAND:-}" == "${PROMPT_COMMAND:-}" ]]; then
16
+ return
17
+ fi
18
+ _tabminal_last_command="$BASH_COMMAND"
19
+ }
20
+
21
+ _tabminal_bash_postexec() {
22
+ local exit_code="$?"
23
+ if [[ -n "${_tabminal_last_command:-}" ]]; then
24
+ local command_b64
25
+ command_b64=$(
26
+ echo -n "$_tabminal_last_command" | base64 | tr -d '\n'
27
+ )
28
+ printf '\x1b]1337;ExitCode=%s;CommandB64=%s\x07' \
29
+ "$exit_code" "$command_b64"
30
+ _tabminal_last_command=''
31
+ fi
32
+ }
33
+
34
+ _tabminal_apply_prompt_marker() {
35
+ local marker=$'\[\e]1337;TabminalPrompt\a\]'
36
+ if [[ "${PS1:-}" != *'TabminalPrompt'* ]]; then
37
+ PS1="${PS1}${marker}"
38
+ fi
39
+ }
40
+
41
+ _tabminal_prompt_contains() {
42
+ local needle="$1"
43
+ local current="${PROMPT_COMMAND:-}"
44
+ [[ "$current" == *"$needle"* ]]
45
+ }
46
+
47
+ _tabminal_install_prompt_command() {
48
+ if ! _tabminal_prompt_contains '_tabminal_bash_postexec'; then
49
+ if [[ -n "${PROMPT_COMMAND:-}" ]]; then
50
+ printf -v PROMPT_COMMAND '_tabminal_bash_postexec; %s' \
51
+ "$PROMPT_COMMAND"
52
+ else
53
+ PROMPT_COMMAND='_tabminal_bash_postexec'
54
+ fi
55
+ fi
56
+
57
+ if ! _tabminal_prompt_contains '_tabminal_apply_prompt_marker'; then
58
+ if [[ -n "${PROMPT_COMMAND:-}" ]]; then
59
+ PROMPT_COMMAND="${PROMPT_COMMAND}; _tabminal_apply_prompt_marker"
60
+ else
61
+ PROMPT_COMMAND='_tabminal_apply_prompt_marker'
62
+ fi
63
+ fi
64
+ }
65
+
66
+ _tabminal_install_tmux_wrapper() {
67
+ if ! command -v tmux >/dev/null 2>&1; then
68
+ return 0
69
+ fi
70
+
71
+ tmux() {
72
+ command tmux -u "$@"
73
+ }
74
+ }
75
+
76
+ trap '_tabminal_bash_preexec' DEBUG
77
+ _tabminal_install_prompt_command
78
+ _tabminal_install_tmux_wrapper
@@ -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
+ };
@@ -37,9 +37,29 @@ const initialRows = Number.parseInt(
37
37
  10
38
38
  ) || 30;
39
39
 
40
+ function buildBashBootstrap({
41
+ env,
42
+ shell,
43
+ shellToolsPath,
44
+ sessionId
45
+ }) {
46
+ const hookPath = path.join(shellToolsPath, 'tabminal-hooks.bash');
47
+ const rcfilePath = path.join(shellToolsPath, 'tabminal-bashrc');
48
+
49
+ env.TABMINAL_SESSION_ID = sessionId;
50
+ env.TABMINAL_SHELL_TOOLS_PATH = shellToolsPath;
51
+ env.TABMINAL_HOOKS_PATH = hookPath;
52
+
53
+ return {
54
+ shell,
55
+ args: ['--rcfile', rcfilePath, '-i']
56
+ };
57
+ }
58
+
40
59
  export class TerminalManager {
41
60
  constructor() {
42
61
  this.sessions = new Map();
62
+ this.snapshotPersistTimers = new Map();
43
63
  this.lastCols = initialCols;
44
64
  this.lastRows = initialRows;
45
65
  this.disposing = false;
@@ -66,55 +86,25 @@ export class TerminalManager {
66
86
  ? `${shellToolsPath}${pathDelimiter}${existingPath}`
67
87
  : shellToolsPath;
68
88
 
89
+ let spawnShell = shell;
69
90
  let args = [];
70
- let initFilePath = null;
71
91
  let initDirPath = null;
72
92
 
73
93
  try {
74
94
  const shellName = path.basename(shell);
75
95
  if (shellName === 'bash') {
76
- initFilePath = path.join(os.tmpdir(), `tabminal-init-${id}.bashrc`);
77
- const bashScript = `
78
- export PATH="${shellToolsPath}:$PATH"
79
- [ -f ~/.bashrc ] && source ~/.bashrc
80
- export PATH="${shellToolsPath}:$PATH"
81
-
82
- _tabminal_bash_preexec() {
83
- # Prevent capturing any of our own internal or setup commands.
84
- if [[ "$BASH_COMMAND" == *"_tabminal_"* || "$BASH_COMMAND" == "$PROMPT_COMMAND" ]]; then
85
- return
86
- fi
87
- _tabminal_last_command="$BASH_COMMAND"
88
- }
89
- trap '_tabminal_bash_preexec' DEBUG
90
-
91
- _tabminal_bash_postexec() {
92
- local EC="$?"
93
- if [[ -n "$_tabminal_last_command" ]]; then
94
- local CMD=$(echo -n "$_tabminal_last_command" | base64 | tr -d '\\n')
95
- printf "\\x1b]1337;ExitCode=%s;CommandB64=%s\\x07" "$EC" "$CMD"
96
- _tabminal_last_command="" # Reset after use
97
- fi
98
- }
99
- _tabminal_apply_prompt_marker() {
100
- local marker=$'\\[\\e]1337;TabminalPrompt\\a\\]'
101
- if [[ "$PS1" != *"TabminalPrompt"* ]]; then
102
- PS1="$PS1$marker"
103
- fi
104
- }
105
- if [[ -n "$PROMPT_COMMAND" ]]; then
106
- printf -v PROMPT_COMMAND "_tabminal_bash_postexec; %s; _tabminal_apply_prompt_marker" "$PROMPT_COMMAND"
107
- else
108
- PROMPT_COMMAND="_tabminal_bash_postexec; _tabminal_apply_prompt_marker"
109
- fi
110
- export PROMPT_COMMAND
111
- `;
112
- fs.writeFileSync(initFilePath, bashScript);
113
- args = ['--rcfile', initFilePath, '-i'];
96
+ const bootstrap = buildBashBootstrap({
97
+ env,
98
+ shell,
99
+ shellToolsPath,
100
+ sessionId: id
101
+ });
102
+ spawnShell = bootstrap.shell;
103
+ args = bootstrap.args;
114
104
  } else if (shellName === 'zsh') {
115
105
  initDirPath = path.join(os.tmpdir(), `tabminal-zsh-${id}`);
116
106
  fs.mkdirSync(initDirPath, { recursive: true });
117
- initFilePath = path.join(initDirPath, '.zshrc');
107
+ const initFilePath = path.join(initDirPath, '.zshrc');
118
108
 
119
109
  const zshScript = `
120
110
  unset ZDOTDIR
@@ -165,10 +155,11 @@ precmd_functions+=(_tabminal_zsh_apply_prompt_marker)
165
155
  if (process.platform !== 'win32') {
166
156
  ptyOptions.encoding = 'utf8';
167
157
  }
168
- ptyProcess = pty.spawn(shell, args, ptyOptions);
158
+ ptyProcess = pty.spawn(spawnShell, args, ptyOptions);
169
159
  } catch (err) {
170
160
  const spawnInfo = {
171
- shell,
161
+ shell: spawnShell,
162
+ requestedShell: shell,
172
163
  args,
173
164
  cwd: initialCwd,
174
165
  cols,
@@ -202,8 +193,10 @@ precmd_functions+=(_tabminal_zsh_apply_prompt_marker)
202
193
  });
203
194
 
204
195
  if (restoredData) {
205
- persistence.loadSessionLog(id).then(log => {
206
- if (log) session.history = log;
196
+ persistence.loadSessionSnapshot(id).then(async (snapshot) => {
197
+ if (!snapshot) return;
198
+ await session.restoreSnapshot(snapshot);
199
+ this.scheduleSnapshotPersist(id);
207
200
  });
208
201
  }
209
202
 
@@ -214,7 +207,6 @@ precmd_functions+=(_tabminal_zsh_apply_prompt_marker)
214
207
  this.removeSession(id);
215
208
  // Cleanup temp files
216
209
  try {
217
- if (initFilePath && fs.existsSync(initFilePath)) fs.unlinkSync(initFilePath);
218
210
  if (initDirPath && fs.existsSync(initDirPath)) fs.rmSync(initDirPath, { recursive: true, force: true });
219
211
  } catch { /* ignore cleanup errors */ }
220
212
  });
@@ -249,8 +241,25 @@ precmd_functions+=(_tabminal_zsh_apply_prompt_marker)
249
241
  }
250
242
  }
251
243
 
252
- appendLog(id, chunk) {
253
- persistence.appendSessionLog(id, chunk);
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);
254
263
  }
255
264
 
256
265
  getSession(id) {
@@ -274,6 +283,11 @@ precmd_functions+=(_tabminal_zsh_apply_prompt_marker)
274
283
  removeSession(id) {
275
284
  const session = this.sessions.get(id);
276
285
  if (session) {
286
+ const timer = this.snapshotPersistTimers.get(id);
287
+ if (timer) {
288
+ clearTimeout(timer);
289
+ this.snapshotPersistTimers.delete(id);
290
+ }
277
291
  session.dispose();
278
292
  this.sessions.delete(id);
279
293
  persistence.deleteSession(id);
@@ -300,6 +314,10 @@ precmd_functions+=(_tabminal_zsh_apply_prompt_marker)
300
314
  dispose() {
301
315
  debugLog('[Manager] Disposing all sessions.');
302
316
  this.disposing = true;
317
+ for (const timer of this.snapshotPersistTimers.values()) {
318
+ clearTimeout(timer);
319
+ }
320
+ this.snapshotPersistTimers.clear();
303
321
  for (const session of this.sessions.values()) {
304
322
  try {
305
323
  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;
@@ -20,11 +25,71 @@ const TITLE_POLL_INTERVAL_MS = 3000;
20
25
 
21
26
  const IGNORED_COMMANDS = [
22
27
  'export PROMPT_COMMAND',
23
- '__bash_prompt'
28
+ '__bash_prompt',
29
+ 'TABMINAL_SHELL_READY=1'
24
30
  ];
25
31
 
26
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";
27
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
+
67
+ function splitTrailingPartialSequence(chunk) {
68
+ if (!chunk) {
69
+ return { complete: '', partial: '' };
70
+ }
71
+
72
+ const oscStart = chunk.lastIndexOf('\u001b]1337;');
73
+ if (oscStart >= 0) {
74
+ const oscTail = chunk.slice(oscStart);
75
+ if (!oscTail.includes('\u0007')) {
76
+ return {
77
+ complete: chunk.slice(0, oscStart),
78
+ partial: oscTail
79
+ };
80
+ }
81
+ }
82
+
83
+ return { complete: chunk, partial: '' };
84
+ }
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
+
28
93
  export class TerminalSession {
29
94
  constructor(pty, options = {}) {
30
95
  this.pty = pty;
@@ -49,12 +114,35 @@ export class TerminalSession {
49
114
  this.historyLimit = Math.max(1, options.historyLimit ?? DEFAULT_HISTORY_LIMIT);
50
115
  this.history = '';
51
116
  this.clients = new Set();
117
+ this.pendingClients = new Map();
52
118
  this.closed = false;
53
119
  this.pollingInterval = null;
54
120
  this.captureBuffer = '';
55
121
  this.captureStartedAt = null;
56
122
  this.lastExecution = null;
57
123
  this.skipNextShellLog = false;
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
+ };
58
146
 
59
147
  this.ansiParser = new AnsiParser({
60
148
  inst_o: (s) => {
@@ -83,10 +171,13 @@ export class TerminalSession {
83
171
  if (this.suppressPtyOutput) return;
84
172
 
85
173
  if (typeof chunk !== 'string') chunk = chunk.toString('utf8');
86
-
87
- if (this.manager) {
88
- this.manager.appendLog(this.id, chunk);
174
+ if (this.partialSequenceBuffer) {
175
+ chunk = this.partialSequenceBuffer + chunk;
176
+ this.partialSequenceBuffer = '';
89
177
  }
178
+ const split = splitTrailingPartialSequence(chunk);
179
+ chunk = split.complete;
180
+ this.partialSequenceBuffer = split.partial;
90
181
 
91
182
  let cleaned = '';
92
183
  let lastIndex = 0;
@@ -120,8 +211,12 @@ export class TerminalSession {
120
211
 
121
212
  if (!cleaned) return;
122
213
 
214
+ this._appendSnapshotData(cleaned);
123
215
  this._appendHistory(cleaned);
124
216
  this.ansiParser.parse(cleaned);
217
+ if (this.manager?.scheduleSnapshotPersist) {
218
+ this.manager.scheduleSnapshotPersist(this.id);
219
+ }
125
220
  this._broadcast({ type: 'output', data: cleaned });
126
221
  };
127
222
 
@@ -244,36 +339,57 @@ export class TerminalSession {
244
339
 
245
340
  attach(ws) {
246
341
  if (!ws) throw new Error('WebSocket instance required');
247
- this.clients.add(ws);
248
- ws.once('close', () => this.clients.delete(ws));
342
+ this.pendingClients.set(ws, []);
343
+ ws.once('close', () => {
344
+ this.clients.delete(ws);
345
+ this.pendingClients.delete(ws);
346
+ });
249
347
  ws.on('message', (raw) => this._routeIncoming(raw, ws));
250
348
  ws.on('error', () => ws.close());
251
349
 
252
- this._send(ws, { type: 'snapshot', data: this.history });
253
- this._send(ws, { type: 'meta', title: this.title, cwd: this.cwd, env: this.env, cols: this.pty.cols, rows: this.pty.rows });
254
- if (this.closed) {
255
- this._send(ws, { type: 'status', status: 'terminated' });
256
- } else {
257
- this._send(ws, { type: 'status', status: 'ready' });
258
- }
350
+ void this._sendInitialState(ws);
259
351
  }
260
352
 
261
353
  dispose() {
262
354
  this.stopTitlePolling();
263
355
  this.clients.clear();
356
+ this.pendingClients.clear();
264
357
  this.dataSubscription?.dispose?.();
265
358
  this.exitSubscription?.dispose?.();
359
+ this.snapshotTerminal?.dispose?.();
266
360
  }
267
361
 
268
362
  resize(cols, rows) {
269
363
  if (this.closed) return;
270
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
+ }
271
371
  this._broadcast({ type: 'meta', title: this.title, cwd: this.cwd, env: this.env, cols, rows });
272
372
  if (this.manager && this.manager.saveSessionState) {
273
373
  this.manager.saveSessionState(this);
274
374
  }
275
375
  }
276
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
+
277
393
  _routeIncoming(raw, ws) {
278
394
  let payload;
279
395
  try {
@@ -892,6 +1008,9 @@ export class TerminalSession {
892
1008
  for (const client of this.clients) {
893
1009
  this._send(client, message, data);
894
1010
  }
1011
+ for (const pending of this.pendingClients.values()) {
1012
+ pending.push(data);
1013
+ }
895
1014
  }
896
1015
 
897
1016
  _send(ws, message, preEncoded) {
@@ -901,12 +1020,82 @@ export class TerminalSession {
901
1020
 
902
1021
  _writeToLogAndBroadcast(text) {
903
1022
  if (!text) return;
904
- if (this.manager) {
905
- this.manager.appendLog(this.id, text);
906
- }
1023
+ this._appendSnapshotData(text);
907
1024
  this._appendHistory(text);
1025
+ if (this.manager?.scheduleSnapshotPersist) {
1026
+ this.manager.scheduleSnapshotPersist(this.id);
1027
+ }
908
1028
  this._broadcast({ type: 'output', data: text });
909
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
+ }
910
1099
  }
911
1100
 
912
1101
  function clampDimension(value) {