tabminal 2.0.14 → 2.0.16

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.
@@ -56,6 +56,18 @@ function buildBashBootstrap({
56
56
  };
57
57
  }
58
58
 
59
+ function clearBashPromptEnv(env) {
60
+ for (const key of [
61
+ 'PROMPT_COMMAND',
62
+ 'PS0',
63
+ 'PS1',
64
+ 'PS2',
65
+ 'PS4'
66
+ ]) {
67
+ delete env[key];
68
+ }
69
+ }
70
+
59
71
  export class TerminalManager {
60
72
  constructor() {
61
73
  this.sessions = new Map();
@@ -83,48 +95,49 @@ export class TerminalManager {
83
95
  return next;
84
96
  }
85
97
 
86
- createSession(restoredData = null) {
87
- // Use ID from options if present, otherwise generate new
88
- const id = (restoredData && restoredData.id) ? restoredData.id : crypto.randomUUID();
89
- const shell = resolveShell();
90
-
91
- // Use CWD from options if present, otherwise default
92
- const initialCwd = (restoredData && restoredData.cwd)
93
- ? restoredData.cwd
94
- : (process.env.TABMINAL_CWD || os.homedir());
95
-
96
- const env = { ...process.env };
97
-
98
- // Inject shell tools
99
- const shellToolsPath = path.join(process.cwd(), 'shell');
100
- const pathDelimiter = path.delimiter;
101
- const pathKey = Object.keys(env).find((key) => key.toLowerCase() === 'path') || 'PATH';
102
- const existingPath = env[pathKey];
103
- env[pathKey] = existingPath
104
- ? `${shellToolsPath}${pathDelimiter}${existingPath}`
105
- : shellToolsPath;
106
-
107
- let spawnShell = shell;
108
- let args = [];
98
+ _createPtySession(options = {}) {
99
+ const id = options.id || crypto.randomUUID();
100
+ const shell = options.shell || resolveShell();
101
+ const initialCwd = options.cwd
102
+ || process.env.TABMINAL_CWD
103
+ || os.homedir();
104
+ const env = {
105
+ ...process.env,
106
+ ...(options.env || {})
107
+ };
108
+ let spawnShell = options.spawnCommand || shell;
109
+ let args = Array.isArray(options.spawnArgs) ? options.spawnArgs : [];
109
110
  let initDirPath = null;
110
111
 
111
- try {
112
- const shellName = path.basename(shell);
113
- if (shellName === 'bash') {
114
- const bootstrap = buildBashBootstrap({
115
- env,
116
- shell,
117
- shellToolsPath,
118
- sessionId: id
119
- });
120
- spawnShell = bootstrap.shell;
121
- args = bootstrap.args;
122
- } else if (shellName === 'zsh') {
123
- initDirPath = path.join(os.tmpdir(), `tabminal-zsh-${id}`);
124
- fs.mkdirSync(initDirPath, { recursive: true });
125
- const initFilePath = path.join(initDirPath, '.zshrc');
126
-
127
- const zshScript = `
112
+ if (!options.directSpawn) {
113
+ const shellToolsPath = path.join(process.cwd(), 'shell');
114
+ const pathDelimiter = path.delimiter;
115
+ const pathKey = Object.keys(env).find(
116
+ (key) => key.toLowerCase() === 'path'
117
+ ) || 'PATH';
118
+ const existingPath = env[pathKey];
119
+ env[pathKey] = existingPath
120
+ ? `${shellToolsPath}${pathDelimiter}${existingPath}`
121
+ : shellToolsPath;
122
+
123
+ try {
124
+ const shellName = path.basename(shell);
125
+ if (shellName === 'bash') {
126
+ clearBashPromptEnv(env);
127
+ const bootstrap = buildBashBootstrap({
128
+ env,
129
+ shell,
130
+ shellToolsPath,
131
+ sessionId: id
132
+ });
133
+ spawnShell = bootstrap.shell;
134
+ args = bootstrap.args;
135
+ } else if (shellName === 'zsh') {
136
+ initDirPath = path.join(os.tmpdir(), `tabminal-zsh-${id}`);
137
+ fs.mkdirSync(initDirPath, { recursive: true });
138
+ const initFilePath = path.join(initDirPath, '.zshrc');
139
+
140
+ const zshScript = `
128
141
  unset ZDOTDIR
129
142
  [ -f ~/.zshrc ] && source ~/.zshrc
130
143
  export PATH="${shellToolsPath}:$PATH"
@@ -150,25 +163,26 @@ preexec_functions+=(_tabminal_zsh_preexec)
150
163
  precmd_functions+=(_tabminal_zsh_postexec)
151
164
  precmd_functions+=(_tabminal_zsh_apply_prompt_marker)
152
165
  `;
153
- fs.writeFileSync(initFilePath, zshScript);
154
- env.ZDOTDIR = initDirPath;
155
- args = ['-i'];
166
+ fs.writeFileSync(initFilePath, zshScript);
167
+ env.ZDOTDIR = initDirPath;
168
+ args = ['-i'];
169
+ }
170
+ } catch (err) {
171
+ console.error('[Manager] Failed to create init script:', err);
156
172
  }
157
- } catch (err) {
158
- console.error('[Manager] Failed to create init script:', err);
159
173
  }
160
174
 
161
- const cols = restoredData ? restoredData.cols : this.lastCols;
162
- const rows = restoredData ? restoredData.rows : this.lastRows;
175
+ const cols = Number.isFinite(options.cols) ? options.cols : this.lastCols;
176
+ const rows = Number.isFinite(options.rows) ? options.rows : this.lastRows;
163
177
 
164
178
  let ptyProcess;
165
179
  try {
166
180
  const ptyOptions = {
167
181
  name: 'xterm-256color',
168
- cols: cols,
169
- rows: rows,
182
+ cols,
183
+ rows,
170
184
  cwd: initialCwd,
171
- env: env
185
+ env
172
186
  };
173
187
  if (process.platform !== 'win32') {
174
188
  ptyOptions.encoding = 'utf8';
@@ -200,17 +214,25 @@ precmd_functions+=(_tabminal_zsh_apply_prompt_marker)
200
214
 
201
215
  const session = new TerminalSession(ptyProcess, {
202
216
  id,
203
- historyLimit,
204
- createdAt: restoredData ? new Date(restoredData.createdAt) : new Date(),
217
+ historyLimit: options.historyLimit ?? historyLimit,
218
+ createdAt: options.createdAt
219
+ ? new Date(options.createdAt)
220
+ : new Date(),
205
221
  manager: this,
206
222
  shell,
207
223
  initialCwd,
208
- env: env,
209
- editorState: restoredData ? restoredData.editorState : undefined,
210
- executions: restoredData ? restoredData.executions : undefined
224
+ env,
225
+ title: options.title || '',
226
+ managed: options.managed || null,
227
+ persistent: options.persistent !== false,
228
+ removeOnExit: options.removeOnExit !== false,
229
+ enableAiHijack: options.enableAiHijack !== false,
230
+ enableTitlePolling: options.enableTitlePolling !== false,
231
+ editorState: options.editorState,
232
+ executions: options.executions
211
233
  });
212
234
 
213
- if (restoredData) {
235
+ if (options.restoreSnapshot) {
214
236
  persistence.loadSessionSnapshot(id).then(async (snapshot) => {
215
237
  if (!snapshot) return;
216
238
  await session.restoreSnapshot(snapshot);
@@ -220,21 +242,70 @@ precmd_functions+=(_tabminal_zsh_apply_prompt_marker)
220
242
 
221
243
  this.sessions.set(id, session);
222
244
 
223
- // Initial save
224
- void this.saveSessionState(session);
245
+ if (session.persistent) {
246
+ void this.saveSessionState(session);
247
+ }
225
248
 
226
249
  ptyProcess.onExit(() => {
227
- void this.removeSession(id);
228
- // Cleanup temp files
250
+ if (session.removeOnExit) {
251
+ void this.removeSession(id);
252
+ }
229
253
  try {
230
- if (initDirPath && fs.existsSync(initDirPath)) fs.rmSync(initDirPath, { recursive: true, force: true });
231
- } catch { /* ignore cleanup errors */ }
254
+ if (initDirPath && fs.existsSync(initDirPath)) {
255
+ fs.rmSync(initDirPath, { recursive: true, force: true });
256
+ }
257
+ } catch {
258
+ // ignore cleanup errors
259
+ }
232
260
  });
233
261
  debugLog(`[Manager] Created session ${id}`);
234
262
  return session;
235
263
  }
236
264
 
265
+ createSession(restoredData = null) {
266
+ return this._createPtySession({
267
+ id: restoredData?.id,
268
+ shell: resolveShell(),
269
+ cwd: restoredData?.cwd,
270
+ cols: restoredData?.cols,
271
+ rows: restoredData?.rows,
272
+ createdAt: restoredData?.createdAt,
273
+ title: restoredData?.title,
274
+ editorState: restoredData?.editorState,
275
+ executions: restoredData?.executions,
276
+ restoreSnapshot: Boolean(restoredData),
277
+ persistent: true,
278
+ removeOnExit: true,
279
+ enableAiHijack: true,
280
+ enableTitlePolling: true
281
+ });
282
+ }
283
+
284
+ createManagedSession(options = {}) {
285
+ const spawnRequest = options.spawnRequest || {};
286
+ const shell = spawnRequest.command || resolveShell();
287
+ return this._createPtySession({
288
+ shell,
289
+ cwd: options.cwd,
290
+ env: options.env,
291
+ cols: options.cols,
292
+ rows: options.rows,
293
+ title: options.title || path.basename(shell) || 'Terminal',
294
+ directSpawn: true,
295
+ spawnCommand: spawnRequest.command,
296
+ spawnArgs: spawnRequest.args,
297
+ persistent: false,
298
+ removeOnExit: false,
299
+ enableAiHijack: false,
300
+ enableTitlePolling: false,
301
+ managed: options.managed || null
302
+ });
303
+ }
304
+
237
305
  saveSessionState(session) {
306
+ if (!session?.persistent) {
307
+ return Promise.resolve();
308
+ }
238
309
  if (this.sessions.get(session.id) !== session) {
239
310
  return Promise.resolve();
240
311
  }
@@ -259,13 +330,15 @@ precmd_functions+=(_tabminal_zsh_apply_prompt_marker)
259
330
  if (data.editorState) {
260
331
  session.editorState = { ...session.editorState, ...data.editorState };
261
332
  }
262
- this.saveSessionState(session);
333
+ if (session.persistent) {
334
+ this.saveSessionState(session);
335
+ }
263
336
  }
264
337
  }
265
338
 
266
339
  scheduleSnapshotPersist(id) {
267
340
  const session = this.sessions.get(id);
268
- if (!session) return;
341
+ if (!session || !session.persistent) return;
269
342
 
270
343
  const existing = this.snapshotPersistTimers.get(id);
271
344
  if (existing) {
@@ -341,6 +414,9 @@ precmd_functions+=(_tabminal_zsh_apply_prompt_marker)
341
414
  env: s.env,
342
415
  cols: s.pty.cols,
343
416
  rows: s.pty.rows,
417
+ closed: !!s.closed,
418
+ exitStatus: s.exitStatus || null,
419
+ managed: s.managed || null,
344
420
  editorState: s.editorState,
345
421
  executions: s.executions
346
422
  }));
@@ -13,7 +13,7 @@ const {
13
13
  const WS_STATE_OPEN = 1;
14
14
  const DEFAULT_HISTORY_LIMIT = 512 * 1024; // chars
15
15
  const OSC_SEQUENCE_REGEX =
16
- /\u001b\]1337;(ExitCode=(\d+);CommandB64=([a-zA-Z0-9+/=]+)|TabminalPrompt)\u0007/g;
16
+ /\u001b\]1337;(ExitCode=(\d+);CommandB64=([a-zA-Z0-9+/=]+)|CommandStartB64=([a-zA-Z0-9+/=]+)|TabminalPrompt)\u0007/g;
17
17
  const EXTRA_PRIVATE_MODE_REGEX = /\u001b\[\?(1005|1006|1015)([hl])/g;
18
18
  const CSI_SEQUENCE_REGEX = /\u001b\[[0-9;?]*[ -\/]*[@-~]/g;
19
19
  const OSC_STRIP_REGEX = /\u001b\][\s\S]*?(?:\u0007|\u001b\\)/g;
@@ -29,6 +29,13 @@ const IGNORED_COMMANDS = [
29
29
  'TABMINAL_SHELL_READY=1'
30
30
  ];
31
31
 
32
+ function isIgnoredExecutionCommand(command) {
33
+ return !!(
34
+ command
35
+ && IGNORED_COMMANDS.some((ignored) => command.includes(ignored))
36
+ );
37
+ }
38
+
32
39
  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";
33
40
 
34
41
  async function loadHeadlessXtermPackages() {
@@ -96,10 +103,17 @@ export class TerminalSession {
96
103
  this.id = options.id;
97
104
  this.manager = options.manager;
98
105
  this.createdAt = options.createdAt ?? new Date();
106
+ this.updatedAt = this.createdAt;
99
107
  this.shell = options.shell;
100
108
  this.initialCwd = options.initialCwd;
101
-
102
- this.title = this.shell ? this.shell.split('/').pop() : 'Terminal';
109
+ this.managed = options.managed || null;
110
+ this.persistent = options.persistent !== false;
111
+ this.removeOnExit = options.removeOnExit !== false;
112
+ this.enableAiHijack = options.enableAiHijack !== false;
113
+ this.enableTitlePolling = options.enableTitlePolling !== false;
114
+
115
+ this.title = options.title
116
+ || (this.shell ? this.shell.split('/').pop() : 'Terminal');
103
117
  this.cwd = this.initialCwd;
104
118
  this.inputBuffer = '';
105
119
 
@@ -116,10 +130,16 @@ export class TerminalSession {
116
130
  this.clients = new Set();
117
131
  this.pendingClients = new Map();
118
132
  this.closed = false;
133
+ this.exitStatus = null;
134
+ this.exitWaiters = [];
135
+ this.stateListeners = new Set();
119
136
  this.pollingInterval = null;
120
137
  this.captureBuffer = '';
121
138
  this.captureStartedAt = null;
122
139
  this.lastExecution = null;
140
+ this.executionCounter = 0;
141
+ this.currentExecutionId = '';
142
+ this.ignoreCurrentExecution = false;
123
143
  this.skipNextShellLog = false;
124
144
  this.skipNextShellLogResetTimer = null;
125
145
  this.partialSequenceBuffer = '';
@@ -152,7 +172,9 @@ export class TerminalSession {
152
172
  const newTitle = s.substring(2);
153
173
  if (newTitle && newTitle !== this.title) {
154
174
  this.title = newTitle;
175
+ this.updatedAt = new Date();
155
176
  this._broadcast({ type: 'meta', title: this.title, cwd: this.cwd, env: this.env, cols: this.pty.cols, rows: this.pty.rows });
177
+ this._emitStateChange();
156
178
  }
157
179
  } else if (s.startsWith('7;')) {
158
180
  try {
@@ -161,7 +183,9 @@ export class TerminalSession {
161
183
  const newCwd = decodeURIComponent(url.pathname);
162
184
  if (newCwd !== this.cwd) {
163
185
  this.cwd = newCwd;
186
+ this.updatedAt = new Date();
164
187
  this._broadcast({ type: 'meta', title: this.title, cwd: this.cwd, env: this.env, cols: this.pty.cols, rows: this.pty.rows });
188
+ this._emitStateChange();
165
189
  }
166
190
  }
167
191
  } catch { /* ignore */ }
@@ -198,6 +222,8 @@ export class TerminalSession {
198
222
  const exitCodeStr = match[2];
199
223
  const cmdB64 = match[3];
200
224
  this._handleExitCodeSequence(exitCodeStr, cmdB64);
225
+ } else if (sequence.startsWith('CommandStartB64=')) {
226
+ this._handleCommandStartSequence(match[4]);
201
227
  } else {
202
228
  this._handlePromptMarker();
203
229
  }
@@ -215,27 +241,43 @@ export class TerminalSession {
215
241
 
216
242
  this._appendSnapshotData(cleaned);
217
243
  this._appendHistory(cleaned);
244
+ this.updatedAt = new Date();
218
245
  this.ansiParser.parse(cleaned);
219
246
  if (this.manager?.scheduleSnapshotPersist) {
220
247
  this.manager.scheduleSnapshotPersist(this.id);
221
248
  }
222
249
  this._broadcast({ type: 'output', data: cleaned });
250
+ this._emitStateChange();
223
251
  };
224
252
 
225
253
  this._handleExit = (details) => {
226
254
  this.closed = true;
255
+ this.exitStatus = {
256
+ exitCode: Number.isFinite(details?.exitCode)
257
+ ? details.exitCode
258
+ : null,
259
+ signal: details?.signal ?? null
260
+ };
261
+ this.updatedAt = new Date();
227
262
  this.stopTitlePolling();
228
263
  this._broadcast({
229
264
  type: 'status',
230
265
  status: 'terminated',
231
- code: details?.exitCode ?? 0,
232
- signal: details?.signal ?? null
266
+ code: this.exitStatus.exitCode ?? 0,
267
+ signal: this.exitStatus.signal
233
268
  });
269
+ this._emitStateChange();
270
+ for (const waiter of this.exitWaiters) {
271
+ waiter(this.exitStatus);
272
+ }
273
+ this.exitWaiters.length = 0;
234
274
  };
235
275
 
236
276
  this.dataSubscription = this.pty.onData(this._handleData);
237
277
  this.exitSubscription = this.pty.onExit(this._handleExit);
238
- this.startTitlePolling();
278
+ if (this.enableTitlePolling) {
279
+ this.startTitlePolling();
280
+ }
239
281
  }
240
282
 
241
283
  startTitlePolling() {
@@ -323,7 +365,9 @@ export class TerminalSession {
323
365
  if (titleChanged) this.title = newTitle;
324
366
  if (envChanged) this.env = newEnv;
325
367
  if (cwdChanged) this.cwd = newCwd;
368
+ this.updatedAt = new Date();
326
369
  this._broadcast({ type: 'meta', title: this.title, cwd: this.cwd, env: this.env, cols: this.pty.cols, rows: this.pty.rows });
370
+ this._emitStateChange();
327
371
  }
328
372
  } catch { /* ignore */ }
329
373
  };
@@ -372,11 +416,40 @@ export class TerminalSession {
372
416
  this.manager.scheduleSnapshotPersist(this.id);
373
417
  }
374
418
  this._broadcast({ type: 'meta', title: this.title, cwd: this.cwd, env: this.env, cols, rows });
419
+ this.updatedAt = new Date();
420
+ this._emitStateChange();
375
421
  if (this.manager && this.manager.saveSessionState) {
376
422
  this.manager.saveSessionState(this);
377
423
  }
378
424
  }
379
425
 
426
+ waitForExit() {
427
+ if (this.exitStatus) {
428
+ return Promise.resolve(this.exitStatus);
429
+ }
430
+ return new Promise((resolve) => {
431
+ this.exitWaiters.push(resolve);
432
+ });
433
+ }
434
+
435
+ onStateChange(listener) {
436
+ if (typeof listener !== 'function') return () => {};
437
+ this.stateListeners.add(listener);
438
+ return () => {
439
+ this.stateListeners.delete(listener);
440
+ };
441
+ }
442
+
443
+ _emitStateChange() {
444
+ for (const listener of this.stateListeners) {
445
+ try {
446
+ listener(this);
447
+ } catch {
448
+ // Ignore state listener failures.
449
+ }
450
+ }
451
+ }
452
+
380
453
  async restoreSnapshot(snapshot) {
381
454
  if (typeof snapshot !== 'string' || !snapshot) return;
382
455
  this.history = snapshot;
@@ -436,7 +509,7 @@ export class TerminalSession {
436
509
  }
437
510
 
438
511
  let startIndex = 0;
439
- const aiEnabled = this._isAiEnabled();
512
+ const aiEnabled = this.enableAiHijack && this._isAiEnabled();
440
513
  for (let i = 0; i < data.length; i++) {
441
514
  const char = data[i];
442
515
 
@@ -726,10 +799,39 @@ export class TerminalSession {
726
799
  }
727
800
 
728
801
  _handlePromptMarker() {
802
+ if (this.currentExecutionId && !this.ignoreCurrentExecution) {
803
+ this._broadcast({
804
+ type: 'execution',
805
+ phase: 'idle',
806
+ executionId: this.currentExecutionId
807
+ });
808
+ }
809
+ this.currentExecutionId = '';
810
+ this.ignoreCurrentExecution = false;
729
811
  this.captureBuffer = '';
730
812
  this.captureStartedAt = null;
731
813
  }
732
814
 
815
+ _handleCommandStartSequence(cmdB64) {
816
+ const command = this._decodeCommandSafe(cmdB64);
817
+ const startedAt = new Date();
818
+ this.captureStartedAt = startedAt;
819
+ this.ignoreCurrentExecution = isIgnoredExecutionCommand(command);
820
+ if (this.ignoreCurrentExecution) {
821
+ this.currentExecutionId = '';
822
+ return;
823
+ }
824
+ this.executionCounter += 1;
825
+ this.currentExecutionId = `exec-${this.executionCounter}`;
826
+ this._broadcast({
827
+ type: 'execution',
828
+ phase: 'started',
829
+ executionId: this.currentExecutionId,
830
+ command,
831
+ startedAt
832
+ });
833
+ }
834
+
733
835
  _handleExitCodeSequence(exitCodeStr, cmdB64) {
734
836
  if (this.skipNextShellLog) {
735
837
  this.skipNextShellLog = false;
@@ -741,6 +843,10 @@ export class TerminalSession {
741
843
 
742
844
  const exitCode = Number.parseInt(exitCodeStr, 10);
743
845
  const command = this._decodeCommandSafe(cmdB64);
846
+ const executionId = this.currentExecutionId
847
+ || `exec-${++this.executionCounter}`;
848
+ const isIgnored = this.ignoreCurrentExecution
849
+ || isIgnoredExecutionCommand(command);
744
850
 
745
851
  const completedAt = new Date();
746
852
  const entry = this._postProcessExecutionEntry({
@@ -754,9 +860,22 @@ export class TerminalSession {
754
860
  });
755
861
 
756
862
  this.lastExecution = entry;
863
+ this.currentExecutionId = '';
864
+ this.ignoreCurrentExecution = false;
865
+ if (isIgnored) {
866
+ this.captureBuffer = '';
867
+ this.captureStartedAt = null;
868
+ return;
869
+ }
757
870
  this._logCommandExecution(entry);
758
871
  this.captureBuffer = '';
759
872
  this.captureStartedAt = null;
873
+ this._broadcast({
874
+ type: 'execution',
875
+ phase: 'completed',
876
+ executionId,
877
+ entry
878
+ });
760
879
 
761
880
  // Auto-Fix: If command failed, ask AI for help
762
881
  if (exitCode !== 0 && entry.command && this._isAiEnabled()) {
@@ -1065,7 +1184,7 @@ export class TerminalSession {
1065
1184
 
1066
1185
  _logCommandExecution(entry) {
1067
1186
  // Filter out internal shell integration commands
1068
- if (entry.command && IGNORED_COMMANDS.some(ignored => entry.command.includes(ignored))) {
1187
+ if (isIgnoredExecutionCommand(entry.command)) {
1069
1188
  return;
1070
1189
  }
1071
1190
 
@@ -1096,6 +1215,8 @@ export class TerminalSession {
1096
1215
  if (this.manager) {
1097
1216
  this.manager.saveSessionState(this);
1098
1217
  }
1218
+ this.updatedAt = new Date();
1219
+ this._emitStateChange();
1099
1220
  }
1100
1221
 
1101
1222
  _broadcast(message) {
@@ -1117,10 +1238,12 @@ export class TerminalSession {
1117
1238
  if (!text) return;
1118
1239
  this._appendSnapshotData(text);
1119
1240
  this._appendHistory(text);
1241
+ this.updatedAt = new Date();
1120
1242
  if (this.manager?.scheduleSnapshotPersist) {
1121
1243
  this.manager.scheduleSnapshotPersist(this.id);
1122
1244
  }
1123
1245
  this._broadcast({ type: 'output', data: text });
1246
+ this._emitStateChange();
1124
1247
  }
1125
1248
 
1126
1249
  _queueSnapshotMutation(mutate) {