tabminal 3.0.6 → 3.0.8

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": "3.0.6",
3
+ "version": "3.0.8",
4
4
  "description": "Tab(ter)minal, a Cloud-Native terminal and ACP agent workspace for desktop, tablet, and phone.",
5
5
  "type": "module",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -108,7 +108,7 @@ const CLOSE_ICON_SVG = '<svg viewBox="0 0 24 24" width="16" height="16" stroke="
108
108
  const AGENT_ICON_SVG = '<svg viewBox="0 0 24 24" width="17" height="17" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="7" width="10" height="10" rx="2"></rect><path d="M9 7V5"></path><path d="M15 7V5"></path><path d="M12 17v2"></path><path d="M5 12H3"></path><path d="M21 12h-2"></path><path d="M9 11h.01"></path><path d="M15 11h.01"></path><path d="M9.5 14c.7.67 1.53 1 2.5 1s1.8-.33 2.5-1"></path></svg>';
109
109
  const TERMINAL_TAB_ICON_SVG = '<svg viewBox="0 0 24 24" width="15" height="15" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"></rect><path d="m8 10 3 2-3 2"></path><path d="M13 15h4"></path></svg>';
110
110
  const MANAGED_TERMINAL_ICON_SVG = '<svg viewBox="0 0 24 24" width="15" height="15" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"></rect><path d="M7 12h.01"></path><path d="M12 9v6"></path><path d="M9 12h6"></path><path d="M18 8v2"></path><path d="M19 9h-2"></path></svg>';
111
- const BELL_ICON_SVG = '<svg viewBox="0 0 24 24" width="15" height="15" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M10 5a2 2 0 1 1 4 0"></path><path d="M5 16a7 7 0 1 0 14 0"></path><path d="M4 16h16"></path><path d="M10 20a2 2 0 0 0 4 0"></path></svg>';
111
+ const BELL_ICON_SVG = '<svg viewBox="0 0 24 24" width="15" height="15" stroke="currentColor" stroke-width="2.1" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4.5a4.5 4.5 0 0 0-4.5 4.5v2.4c0 1.2-.41 2.37-1.17 3.3L5 16.5h14l-1.33-1.8a5.66 5.66 0 0 1-1.17-3.3V9A4.5 4.5 0 0 0 12 4.5"></path><path d="M10.25 19a1.75 1.75 0 0 0 3.5 0"></path></svg>';
112
112
  const SPINNER_ICON_SVG = '<svg viewBox="0 0 24 24" width="15" height="15" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round"><path d="M12 3a9 9 0 1 0 9 9"></path></svg>';
113
113
  const ATTACH_ICON_SVG = '<svg viewBox="0 0 24 24" width="15" height="15" stroke="currentColor" stroke-width="1.9" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05 12.25 20.24a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 1 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.82-2.82l8.49-8.49"></path></svg>';
114
114
  const CHEVRON_DOWN_ICON_SVG = '<svg viewBox="0 0 24 24" width="15" height="15" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"></path></svg>';
@@ -4464,6 +4464,10 @@ class Session {
4464
4464
  editorFlex: '2 1 0%'
4465
4465
  };
4466
4466
  this.previewRelayoutScheduled = false;
4467
+ this.lastTerminalControlClaimAt = 0;
4468
+ this.boundTerminalClaimRoot = null;
4469
+ this.boundTerminalClaimTextarea = null;
4470
+ this.boundTerminalClaimHandler = null;
4467
4471
  this.wrapperElement = null;
4468
4472
  this._createTerminals();
4469
4473
 
@@ -4529,6 +4533,8 @@ class Session {
4529
4533
  const wasActive = state.activeSessionKey === this.key;
4530
4534
  const previewWrapper = this.wrapperElement;
4531
4535
 
4536
+ this.unbindTerminalControlClaim();
4537
+
4532
4538
  try {
4533
4539
  this.previewTerm?.dispose();
4534
4540
  } catch (e) {
@@ -4556,6 +4562,7 @@ class Session {
4556
4562
  if (wasActive && terminalEl) {
4557
4563
  terminalEl.innerHTML = '';
4558
4564
  this.mainTerm.open(terminalEl);
4565
+ this.bindTerminalControlClaim();
4559
4566
  if (this.fitMainTerminalIfVisible()) {
4560
4567
  this.mainTerm.focus();
4561
4568
  }
@@ -4994,7 +5001,10 @@ class Session {
4994
5001
  this.runningExecutionId = '';
4995
5002
  this.runningCommand = '';
4996
5003
 
4997
- if (!isTerminalViewVisible(this)) {
5004
+ if (
5005
+ state.activeSessionKey !== this.key
5006
+ && !isAgentManagedSession(this)
5007
+ ) {
4998
5008
  this.needsAttention = true;
4999
5009
  if (this.lastNotifiedExecutionId !== executionId) {
5000
5010
  this.lastNotifiedExecutionId = executionId;
@@ -5064,6 +5074,79 @@ class Session {
5064
5074
  }
5065
5075
  }
5066
5076
 
5077
+ claimTerminalControl(force = false) {
5078
+ if (state.activeSessionKey !== this.key) {
5079
+ return;
5080
+ }
5081
+ if (this.socket?.readyState !== WebSocket.OPEN) {
5082
+ return;
5083
+ }
5084
+
5085
+ const now = Date.now();
5086
+ if (!force && now - this.lastTerminalControlClaimAt < 250) {
5087
+ return;
5088
+ }
5089
+
5090
+ this.lastTerminalControlClaimAt = now;
5091
+ this.send({ type: 'claim_terminal_control' });
5092
+ }
5093
+
5094
+ bindTerminalControlClaim() {
5095
+ this.unbindTerminalControlClaim();
5096
+
5097
+ const root = this.mainTerm?.element;
5098
+ if (!root) {
5099
+ return;
5100
+ }
5101
+
5102
+ const textarea = this.mainTerm.textarea
5103
+ || root.querySelector('textarea');
5104
+ const handler = () => this.claimTerminalControl();
5105
+
5106
+ root.addEventListener('mousedown', handler, true);
5107
+ root.addEventListener('touchstart', handler, true);
5108
+ if (textarea) {
5109
+ textarea.addEventListener('keydown', handler, true);
5110
+ textarea.addEventListener('paste', handler, true);
5111
+ }
5112
+
5113
+ this.boundTerminalClaimRoot = root;
5114
+ this.boundTerminalClaimTextarea = textarea;
5115
+ this.boundTerminalClaimHandler = handler;
5116
+ }
5117
+
5118
+ unbindTerminalControlClaim() {
5119
+ const handler = this.boundTerminalClaimHandler;
5120
+ if (!handler) {
5121
+ return;
5122
+ }
5123
+
5124
+ this.boundTerminalClaimRoot?.removeEventListener(
5125
+ 'mousedown',
5126
+ handler,
5127
+ true
5128
+ );
5129
+ this.boundTerminalClaimRoot?.removeEventListener(
5130
+ 'touchstart',
5131
+ handler,
5132
+ true
5133
+ );
5134
+ this.boundTerminalClaimTextarea?.removeEventListener(
5135
+ 'keydown',
5136
+ handler,
5137
+ true
5138
+ );
5139
+ this.boundTerminalClaimTextarea?.removeEventListener(
5140
+ 'paste',
5141
+ handler,
5142
+ true
5143
+ );
5144
+
5145
+ this.boundTerminalClaimRoot = null;
5146
+ this.boundTerminalClaimTextarea = null;
5147
+ this.boundTerminalClaimHandler = null;
5148
+ }
5149
+
5067
5150
  reportResize() {
5068
5151
  if (!this.isMainTerminalVisible()) {
5069
5152
  return;
@@ -5081,6 +5164,7 @@ class Session {
5081
5164
  this.shouldReconnect = false;
5082
5165
  clearTimeout(this.retryTimer);
5083
5166
  this.socket?.close();
5167
+ this.unbindTerminalControlClaim();
5084
5168
 
5085
5169
  try {
5086
5170
  if (this.previewTerm) this.previewTerm.dispose();
@@ -10770,6 +10854,11 @@ async function switchToSession(sessionKey, options = {}) {
10770
10854
  return;
10771
10855
  }
10772
10856
 
10857
+ const previousSession = state.activeSessionKey
10858
+ ? state.sessions.get(state.activeSessionKey)
10859
+ : null;
10860
+ previousSession?.unbindTerminalControlClaim();
10861
+
10773
10862
  state.activeSessionKey = sessionKey;
10774
10863
  renderTabs();
10775
10864
  if (scrollTabIntoView) {
@@ -10786,6 +10875,7 @@ async function switchToSession(sessionKey, options = {}) {
10786
10875
 
10787
10876
  // Mount new session
10788
10877
  session.mainTerm.open(terminalEl);
10878
+ session.bindTerminalControlClaim();
10789
10879
  session.fitMainTerminalIfVisible();
10790
10880
  if (session.isMainTerminalVisible()) {
10791
10881
  session.mainTerm.focus();
package/public/index.html CHANGED
@@ -85,7 +85,7 @@
85
85
  previousCompactWorkspaceMode
86
86
  );
87
87
  const compactTerminalTabsMode =
88
- width <= COMPACT_TERMINAL_TAB_MAX_WIDTH;
88
+ width < COMPACT_TERMINAL_TAB_MAX_WIDTH;
89
89
  window.__tabminalCompactWorkspaceMode = compactWorkspaceMode;
90
90
  window.__tabminalCompactTerminalTabsMode =
91
91
  compactTerminalTabsMode;
@@ -6,6 +6,62 @@ export class NotificationManager {
6
6
  }
7
7
  }
8
8
 
9
+ // BEGIN temporary iOS web-app notification dedupe workaround
10
+ static RECENT_NOTIFICATION_KEY = 'tabminal_recent_notifications';
11
+
12
+ static RECENT_NOTIFICATION_LIMIT = 10;
13
+
14
+ static RECENT_NOTIFICATION_TTL_MS = 30_000;
15
+
16
+ _loadRecentNotifications() {
17
+ try {
18
+ const raw = localStorage.getItem(
19
+ NotificationManager.RECENT_NOTIFICATION_KEY
20
+ );
21
+ if (!raw) return [];
22
+ const parsed = JSON.parse(raw);
23
+ return Array.isArray(parsed) ? parsed : [];
24
+ } catch {
25
+ return [];
26
+ }
27
+ }
28
+
29
+ _storeRecentNotifications(entries) {
30
+ try {
31
+ localStorage.setItem(
32
+ NotificationManager.RECENT_NOTIFICATION_KEY,
33
+ JSON.stringify(entries)
34
+ );
35
+ } catch {
36
+ // Ignore localStorage failures.
37
+ }
38
+ }
39
+
40
+ _shouldSuppressRecentNotification(title, body) {
41
+ const now = Date.now();
42
+ const fingerprint = `${title}\n${body}`;
43
+ const recent = this._loadRecentNotifications().filter((entry) => (
44
+ entry
45
+ && typeof entry.fingerprint === 'string'
46
+ && Number.isFinite(entry.at)
47
+ && (now - entry.at) < NotificationManager.RECENT_NOTIFICATION_TTL_MS
48
+ ));
49
+
50
+ if (recent.some((entry) => entry.fingerprint === fingerprint)) {
51
+ this._storeRecentNotifications(
52
+ recent.slice(-NotificationManager.RECENT_NOTIFICATION_LIMIT)
53
+ );
54
+ return true;
55
+ }
56
+
57
+ recent.push({ fingerprint, at: now });
58
+ this._storeRecentNotifications(
59
+ recent.slice(-NotificationManager.RECENT_NOTIFICATION_LIMIT)
60
+ );
61
+ return false;
62
+ }
63
+ // END temporary iOS web-app notification dedupe workaround
64
+
9
65
  requestPermission() {
10
66
  if (!('Notification' in window)) return;
11
67
  if (Notification.permission !== 'granted' && Notification.permission !== 'denied') {
@@ -20,6 +76,9 @@ export class NotificationManager {
20
76
 
21
77
  // Check permission status directly
22
78
  if (Notification.permission === 'granted') {
79
+ if (this._shouldSuppressRecentNotification(title, body)) {
80
+ return true;
81
+ }
23
82
  try {
24
83
  new Notification(title, {
25
84
  body: body,
package/public/styles.css CHANGED
@@ -1561,7 +1561,7 @@ kbd {
1561
1561
  .tab-status-icon.is-attention,
1562
1562
  .agent-editor-tab-icon.is-attention,
1563
1563
  .toggle-agent-btn.is-attention {
1564
- color: var(--warning-color);
1564
+ color: #cb4b16;
1565
1565
  }
1566
1566
 
1567
1567
  .tab-status-icon.is-running,
@@ -22,6 +22,7 @@ const SOS_PM_APC_SEQUENCE_REGEX = /\u001b[\^_][\s\S]*?\u001b\\/g;
22
22
  const TWO_CHAR_ESCAPE_REGEX = /\u001b[@-Z\\-_]/g;
23
23
  const CONTROL_CHAR_REGEX = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g;
24
24
  const TITLE_POLL_INTERVAL_MS = 3000;
25
+ const QUERY_RESPONSE_CSI_REGEX = /^\u001b\[[0-9;?]*[Rn]/;
25
26
 
26
27
  const IGNORED_COMMANDS = [
27
28
  'export PROMPT_COMMAND',
@@ -97,6 +98,75 @@ function estimateSnapshotScrollback(cols, rows, historyLimit) {
97
98
  return Math.max(safeRows, Math.min(50000, estimatedRows));
98
99
  }
99
100
 
101
+ function consumeTerminalQueryResponse(input, start = 0) {
102
+ if (typeof input !== 'string' || start >= input.length) {
103
+ return 0;
104
+ }
105
+
106
+ const slice = input.slice(start);
107
+ const csiMatch = QUERY_RESPONSE_CSI_REGEX.exec(slice);
108
+ if (csiMatch) {
109
+ return csiMatch[0].length;
110
+ }
111
+
112
+ if (!slice.startsWith('\u001b]')) {
113
+ return 0;
114
+ }
115
+
116
+ const oscMatch = /^\u001b](4|10|11);/.exec(slice);
117
+ if (!oscMatch) {
118
+ return 0;
119
+ }
120
+
121
+ const belIndex = slice.indexOf('\u0007');
122
+ const stIndex = slice.indexOf('\u001b\\');
123
+ let endIndex = -1;
124
+
125
+ if (belIndex >= 0) {
126
+ endIndex = belIndex + 1;
127
+ }
128
+
129
+ if (stIndex >= 0) {
130
+ const stEnd = stIndex + 2;
131
+ endIndex = endIndex < 0 ? stEnd : Math.min(endIndex, stEnd);
132
+ }
133
+
134
+ return endIndex > 0 ? endIndex : 0;
135
+ }
136
+
137
+ function isTerminalQueryResponseInput(input) {
138
+ if (typeof input !== 'string' || !input.startsWith('\u001b')) {
139
+ return false;
140
+ }
141
+
142
+ let index = 0;
143
+ while (index < input.length) {
144
+ const consumed = consumeTerminalQueryResponse(input, index);
145
+ if (!consumed) {
146
+ return false;
147
+ }
148
+ index += consumed;
149
+ }
150
+
151
+ return index > 0;
152
+ }
153
+
154
+ function selectFallbackQueryResponder(clients, pendingClients) {
155
+ for (const client of clients) {
156
+ if (client?.readyState === WS_STATE_OPEN) {
157
+ return client;
158
+ }
159
+ }
160
+
161
+ for (const client of pendingClients.keys()) {
162
+ if (client?.readyState === WS_STATE_OPEN) {
163
+ return client;
164
+ }
165
+ }
166
+
167
+ return null;
168
+ }
169
+
100
170
  export class TerminalSession {
101
171
  constructor(pty, options = {}) {
102
172
  this.pty = pty;
@@ -129,6 +199,7 @@ export class TerminalSession {
129
199
  this.history = '';
130
200
  this.clients = new Set();
131
201
  this.pendingClients = new Map();
202
+ this.queryResponseOwner = null;
132
203
  this.closed = false;
133
204
  this.exitStatus = null;
134
205
  this.exitWaiters = [];
@@ -386,9 +457,18 @@ export class TerminalSession {
386
457
  attach(ws) {
387
458
  if (!ws) throw new Error('WebSocket instance required');
388
459
  this.pendingClients.set(ws, []);
460
+ if (!this.queryResponseOwner) {
461
+ this.queryResponseOwner = ws;
462
+ }
389
463
  ws.once('close', () => {
390
464
  this.clients.delete(ws);
391
465
  this.pendingClients.delete(ws);
466
+ if (this.queryResponseOwner === ws) {
467
+ this.queryResponseOwner = selectFallbackQueryResponder(
468
+ this.clients,
469
+ this.pendingClients
470
+ );
471
+ }
392
472
  });
393
473
  ws.on('message', (raw) => this._routeIncoming(raw, ws));
394
474
  ws.on('error', () => ws.close());
@@ -473,17 +553,37 @@ export class TerminalSession {
473
553
  } catch { return; }
474
554
 
475
555
  switch (payload.type) {
476
- case 'input': this._handleInput(payload.data); break;
556
+ case 'input': this._handleInput(payload.data, ws); break;
477
557
  case 'resize': this._handleResize(payload.cols, payload.rows); break;
558
+ case 'claim_terminal_control':
559
+ this._claimTerminalControl(ws);
560
+ break;
478
561
  case 'ping': this._send(ws, { type: 'pong' }); break;
479
562
  }
480
563
  }
481
564
 
482
- _handleInput(data) {
565
+ _handleInput(data, ws) {
483
566
  if (this.closed || typeof data !== 'string') return;
567
+ if (
568
+ isTerminalQueryResponseInput(data)
569
+ && this.queryResponseOwner
570
+ && ws
571
+ && ws !== this.queryResponseOwner
572
+ ) {
573
+ return;
574
+ }
484
575
  this.write(data);
485
576
  }
486
577
 
578
+ _claimTerminalControl(ws) {
579
+ if (!ws) {
580
+ return;
581
+ }
582
+ if (this.clients.has(ws) || this.pendingClients.has(ws)) {
583
+ this.queryResponseOwner = ws;
584
+ }
585
+ }
586
+
487
587
  _isAiEnabled() {
488
588
  return Boolean(
489
589
  (config.openrouterKey && String(config.openrouterKey).trim()) ||