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 +1 -1
- package/public/app.js +92 -2
- package/public/index.html +1 -1
- package/public/modules/notifications.js +59 -0
- package/public/styles.css +1 -1
- package/src/terminal-session.mjs +102 -2
package/package.json
CHANGED
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
|
|
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 (
|
|
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
|
|
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
package/src/terminal-session.mjs
CHANGED
|
@@ -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()) ||
|