@electerm/electerm-react 5.3.15 → 5.5.6
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/README.md +246 -12
- package/client/common/choose-save-folder.js +21 -2
- package/client/common/constants.js +23 -6
- package/client/common/create-title.jsx +6 -0
- package/client/common/default-setting.js +14 -0
- package/client/common/init-setting-item.js +5 -0
- package/client/common/platform.js +24 -0
- package/client/components/ai/agent-tool-call-card.jsx +10 -2
- package/client/components/ai/agent.js +4 -1
- package/client/components/ai/ai-attachments.js +87 -0
- package/client/components/ai/ai-chat-empty.jsx +43 -0
- package/client/components/ai/ai-chat-entry.jsx +13 -4
- package/client/components/ai/ai-chat-history-item.jsx +28 -4
- package/client/components/ai/ai-chat-history.jsx +2 -1
- package/client/components/ai/ai-chat.jsx +104 -13
- package/client/components/ai/ai-config-props.js +4 -130
- package/client/components/ai/ai-config.jsx +135 -15
- package/client/components/ai/ai-presets.js +173 -0
- package/client/components/ai/ai.styl +83 -0
- package/client/components/bg/css-overwrite.jsx +11 -0
- package/client/components/bookmark-form/bookmark-schema.js +4 -0
- package/client/components/bookmark-form/common/fields.jsx +3 -0
- package/client/components/bookmark-form/config/common-fields.js +12 -0
- package/client/components/bookmark-form/config/local.js +3 -2
- package/client/components/bookmark-form/config/serial.js +3 -2
- package/client/components/bookmark-form/config/ssh.js +2 -1
- package/client/components/bookmark-form/config/telnet.js +3 -2
- package/client/components/bookmark-form/index.jsx +10 -8
- package/client/components/common/item-filter.jsx +76 -0
- package/client/components/common/item-filter.styl +54 -0
- package/client/components/common/lazy-boundary.jsx +34 -0
- package/client/components/common/opacity.jsx +3 -1
- package/client/components/footer/footer-entry.jsx +35 -5
- package/client/components/footer/footer.styl +8 -1
- package/client/components/layout/layout.jsx +23 -31
- package/client/components/layout/pixed.js +3 -0
- package/client/components/main/main.jsx +80 -28
- package/client/components/main/term-fullscreen.styl +9 -3
- package/client/components/quick-commands/qm.styl +3 -2
- package/client/components/rdp/file-transfer.js +4 -4
- package/client/components/remote-monitor/monitor-details.jsx +458 -0
- package/client/components/remote-monitor/monitor-details.styl +87 -0
- package/client/components/remote-monitor/monitor-model.js +764 -0
- package/client/components/remote-monitor/remote-monitor-bar-entry.jsx +18 -0
- package/client/components/remote-monitor/remote-monitor-bar.jsx +344 -0
- package/client/components/remote-monitor/remote-monitor-bar.styl +106 -0
- package/client/components/remote-monitor/session-monitor.js +361 -0
- package/client/components/remote-monitor/use-monitor-details.js +10 -0
- package/client/components/remote-monitor/visibility.js +18 -0
- package/client/components/session/session-control.jsx +29 -2
- package/client/components/session/session.jsx +40 -18
- package/client/components/session/sessions.jsx +5 -2
- package/client/components/setting-panel/col.jsx +32 -6
- package/client/components/setting-panel/list.styl +12 -0
- package/client/components/setting-panel/setting-common.jsx +25 -56
- package/client/components/setting-panel/setting-mobile-breadcrumb.jsx +29 -0
- package/client/components/setting-panel/setting-modal.jsx +64 -44
- package/client/components/setting-panel/setting-terminal.jsx +7 -2
- package/client/components/setting-panel/setting-wrap.jsx +0 -3
- package/client/components/setting-panel/setting-wrap.styl +80 -11
- package/client/components/setting-panel/setting.styl +1 -1
- package/client/components/setting-panel/tab-triggers.jsx +71 -0
- package/client/components/sftp/file-compare-modal.jsx +6 -3
- package/client/components/sftp/file-item.jsx +5 -3
- package/client/components/sftp/owner-list.js +11 -2
- package/client/components/side-panel-r/side-panel-r.jsx +2 -2
- package/client/components/sidebar/index.jsx +28 -21
- package/client/components/sidebar/left-sidebar-icons.jsx +1 -1
- package/client/components/sidebar/sidebar.styl +16 -0
- package/client/components/sys-menu/menu-btn.jsx +30 -1
- package/client/components/tabs/index.jsx +2 -2
- package/client/components/tabs/tab-title.jsx +3 -2
- package/client/components/tabs/tab.jsx +5 -1
- package/client/components/tabs/tabs.styl +4 -0
- package/client/components/terminal/attach-addon-custom.js +295 -49
- package/client/components/terminal/automation/index.js +64 -0
- package/client/components/terminal/automation/keys.js +49 -0
- package/client/components/terminal/automation/stream-matcher.js +82 -0
- package/client/components/terminal/automation/strip-ansi.js +19 -0
- package/client/components/terminal/automation/trigger-engine.js +233 -0
- package/client/components/terminal/automation/trigger-presets.js +110 -0
- package/client/components/terminal/command-tracker-addon.js +31 -1
- package/client/components/terminal/mixins/index.js +27 -0
- package/client/components/terminal/mixins/term-attach.js +123 -0
- package/client/components/terminal/mixins/term-config.jsx +84 -0
- package/client/components/terminal/mixins/term-content.js +88 -0
- package/client/components/terminal/mixins/term-context-menu.jsx +291 -0
- package/client/components/terminal/mixins/term-file-drop.js +149 -0
- package/client/components/terminal/mixins/term-init.js +122 -0
- package/client/components/terminal/mixins/term-log.jsx +96 -0
- package/client/components/terminal/mixins/term-resize.js +87 -0
- package/client/components/terminal/mixins/term-socket.js +433 -0
- package/client/components/terminal/mixins/term-suggestions.js +224 -0
- package/client/components/terminal/mixins/term-theme.js +190 -0
- package/client/components/terminal/mixins/term-touch.js +136 -0
- package/client/components/terminal/shell-detect.js +37 -0
- package/client/components/terminal/shell.js +0 -28
- package/client/components/terminal/shortcut-bar-entry.jsx +32 -27
- package/client/components/terminal/shortcut-bar.jsx +4 -6
- package/client/components/terminal/shortcut-bar.styl +4 -0
- package/client/components/terminal/ssh-reload-state.js +56 -0
- package/client/components/terminal/startup-queue.js +379 -0
- package/client/components/terminal/terminal-interactive.jsx +32 -0
- package/client/components/terminal/terminal.jsx +102 -2007
- package/client/components/terminal/transfer-client-base.js +5 -29
- package/client/components/terminal/trzsz-client.js +1 -3
- package/client/components/terminal/xmodem-client.js +1 -3
- package/client/components/terminal/xterm-loader.js +7 -0
- package/client/components/terminal/zmodem-client.js +1 -3
- package/client/components/terminal-info/base.jsx +18 -70
- package/client/components/terminal-info/terminal-info-entry.jsx +13 -5
- package/client/components/terminal-info/terminal-info.jsx +50 -59
- package/client/components/terminal-info/terminal-info.styl +6 -49
- package/client/components/text-editor/open-text-editor.js +30 -0
- package/client/components/text-editor/text-editor-entry.jsx +17 -6
- package/client/components/text-editor/text-editor.jsx +14 -0
- package/client/components/theme/theme-form.jsx +22 -0
- package/client/components/tree-list/bookmark-transport.jsx +7 -31
- package/client/components/tree-list/tree-list.styl +13 -0
- package/client/components/triggers/trigger-editor.jsx +246 -0
- package/client/components/triggers/trigger-form.jsx +200 -0
- package/client/components/triggers/trigger-session-modal.jsx +199 -0
- package/client/components/triggers/trigger-setting-list.jsx +89 -0
- package/client/components/triggers/trigger-transport.jsx +60 -0
- package/client/components/triggers/triggers-field.jsx +19 -0
- package/client/components/widgets/widgets-list.jsx +11 -1
- package/client/css/mobile.styl +9 -1
- package/client/store/common.js +12 -1
- package/client/store/init-state.js +14 -0
- package/client/store/mcp-handler.js +158 -30
- package/client/store/setting.js +20 -0
- package/client/store/sidebar.js +12 -0
- package/client/store/store.js +2 -0
- package/client/store/sync.js +45 -9
- package/client/store/system-menu.js +2 -0
- package/client/store/tab.js +41 -3
- package/client/store/trigger.js +118 -0
- package/client/store/watch.js +13 -5
- package/client/views/index.pug +3 -0
- package/package.json +1 -1
- package/client/components/terminal-info/activity.jsx +0 -57
- package/client/components/terminal-info/data-cols-parser.jsx +0 -54
- package/client/components/terminal-info/disk.jsx +0 -45
- package/client/components/terminal-info/format-disks.js +0 -45
- package/client/components/terminal-info/network.jsx +0 -121
- package/client/components/terminal-info/resource.jsx +0 -99
- package/client/components/terminal-info/run-cmd.jsx +0 -263
- package/client/components/terminal-info/sys-info.jsx +0 -56
- package/client/components/terminal-info/up.jsx +0 -17
|
@@ -1,5 +1,145 @@
|
|
|
1
1
|
import { loadAttachAddon } from './xterm-loader.js'
|
|
2
2
|
|
|
3
|
+
// Cursor-positioning sequences stripped from the head of surviving output
|
|
4
|
+
// after a drop. These are either relative (ESC[nA rewinds n rows) or restore
|
|
5
|
+
// state saved by content we just discarded (ESC[8 / CSI u); left in place
|
|
6
|
+
// they yank the cursor back into already-rendered history and overwrite it.
|
|
7
|
+
// Safe leading sequences (colours, erase, cursor visibility, etc.) are kept.
|
|
8
|
+
const ESC = String.fromCharCode(27)
|
|
9
|
+
const CURSOR_CSI_FINALS = new Set([
|
|
10
|
+
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', '`', 'a', 'd', 'e', 'f', 's', 'u'
|
|
11
|
+
])
|
|
12
|
+
const CURSOR_ESC_FINALS = new Set(['7', '8', 'D', 'E', 'M'])
|
|
13
|
+
|
|
14
|
+
// Upper bound on output buffered while suppression is active (UTF-16 code
|
|
15
|
+
// units). Past this the rest of the hidden output is dropped instead of
|
|
16
|
+
// growing the buffer without limit.
|
|
17
|
+
const MAX_SUPPRESSED_CHARS = 200000
|
|
18
|
+
|
|
19
|
+
function ansiSequenceEnd (str, start) {
|
|
20
|
+
const type = str[start + 1]
|
|
21
|
+
if (!type) {
|
|
22
|
+
return -1
|
|
23
|
+
}
|
|
24
|
+
if (type === '[') {
|
|
25
|
+
for (let i = start + 2; i < str.length; i++) {
|
|
26
|
+
const code = str.charCodeAt(i)
|
|
27
|
+
if (code >= 0x40 && code <= 0x7e) {
|
|
28
|
+
return i + 1
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return -1
|
|
32
|
+
}
|
|
33
|
+
if (type === ']' || type === 'P' || type === '^' || type === '_') {
|
|
34
|
+
for (let i = start + 2; i < str.length; i++) {
|
|
35
|
+
if (str.charCodeAt(i) === 7) {
|
|
36
|
+
return i + 1
|
|
37
|
+
}
|
|
38
|
+
if (str[i] === ESC && str[i + 1] === '\\') {
|
|
39
|
+
return i + 2
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return -1
|
|
43
|
+
}
|
|
44
|
+
for (let i = start + 1; i < str.length; i++) {
|
|
45
|
+
const code = str.charCodeAt(i)
|
|
46
|
+
if (code >= 0x30 && code <= 0x7e) {
|
|
47
|
+
return i + 1
|
|
48
|
+
}
|
|
49
|
+
if (code < 0x20 || code > 0x2f) {
|
|
50
|
+
return start + 2
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return -1
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function stripLeadingCursorOps (str) {
|
|
57
|
+
let index = 0
|
|
58
|
+
let safePrefix = ''
|
|
59
|
+
while (index < str.length) {
|
|
60
|
+
if (str[index] === '\r') {
|
|
61
|
+
index += 1
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
if (str[index] !== ESC) {
|
|
65
|
+
break
|
|
66
|
+
}
|
|
67
|
+
const end = ansiSequenceEnd(str, index)
|
|
68
|
+
if (end < 0) {
|
|
69
|
+
break
|
|
70
|
+
}
|
|
71
|
+
const type = str[index + 1]
|
|
72
|
+
const final = str[end - 1]
|
|
73
|
+
const isCursorOp = type === '['
|
|
74
|
+
? CURSOR_CSI_FINALS.has(final)
|
|
75
|
+
: CURSOR_ESC_FINALS.has(type)
|
|
76
|
+
if (!isCursorOp) {
|
|
77
|
+
safePrefix += str.slice(index, end)
|
|
78
|
+
}
|
|
79
|
+
index = end
|
|
80
|
+
}
|
|
81
|
+
return safePrefix + str.slice(index)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function startAfterPartialAnsi (str, start) {
|
|
85
|
+
if (start <= 0) {
|
|
86
|
+
return start
|
|
87
|
+
}
|
|
88
|
+
const esc = str.lastIndexOf(ESC, start - 1)
|
|
89
|
+
const newline = str.lastIndexOf('\n', start - 1)
|
|
90
|
+
if (esc <= newline) {
|
|
91
|
+
return start
|
|
92
|
+
}
|
|
93
|
+
const end = ansiSequenceEnd(str, esc)
|
|
94
|
+
return end < 0 || end > start ? Math.max(start, end < 0 ? str.length : end) : start
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function truncationMarker (dropped) {
|
|
98
|
+
const kb = Math.round(dropped / 1024)
|
|
99
|
+
return `\r\n...[electerm] output truncated, ${kb}K skipped...\r\n`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function truncateTerminalOutput (str, maxChars) {
|
|
103
|
+
if (str.length <= maxChars) {
|
|
104
|
+
return { output: str, dropped: 0 }
|
|
105
|
+
}
|
|
106
|
+
if (maxChars < truncationMarker(str.length).length) {
|
|
107
|
+
return { output: '', dropped: str.length }
|
|
108
|
+
}
|
|
109
|
+
let marker = truncationMarker(str.length - maxChars)
|
|
110
|
+
let output = ''
|
|
111
|
+
let dropped = str.length
|
|
112
|
+
for (let i = 0; i < 3; i++) {
|
|
113
|
+
const payloadBudget = Math.max(0, maxChars - marker.length)
|
|
114
|
+
let start = Math.max(0, str.length - payloadBudget)
|
|
115
|
+
const newline = str.indexOf('\n', start)
|
|
116
|
+
if (newline >= 0 && newline < str.length - 1) {
|
|
117
|
+
start = newline + 1
|
|
118
|
+
} else {
|
|
119
|
+
start = startAfterPartialAnsi(str, start)
|
|
120
|
+
if (start > 0 && str.charCodeAt(start) >= 0xdc00 && str.charCodeAt(start) <= 0xdfff) {
|
|
121
|
+
start += 1
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
output = stripLeadingCursorOps(str.slice(start))
|
|
125
|
+
dropped = str.length - output.length
|
|
126
|
+
const nextMarker = truncationMarker(dropped)
|
|
127
|
+
if (nextMarker === marker && marker.length + output.length <= maxChars) {
|
|
128
|
+
break
|
|
129
|
+
}
|
|
130
|
+
marker = nextMarker
|
|
131
|
+
}
|
|
132
|
+
if (marker.length + output.length > maxChars) {
|
|
133
|
+
output = output.slice(marker.length + output.length - maxChars)
|
|
134
|
+
dropped = str.length - output.length
|
|
135
|
+
marker = truncationMarker(dropped)
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
output: (marker + output).slice(0, maxChars),
|
|
139
|
+
dropped
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
3
143
|
export default class AttachAddonCustom {
|
|
4
144
|
constructor (term, socket, isWindowsShell) {
|
|
5
145
|
this.term = term
|
|
@@ -7,6 +147,7 @@ export default class AttachAddonCustom {
|
|
|
7
147
|
this.isWindowsShell = isWindowsShell
|
|
8
148
|
this.outputSuppressed = false
|
|
9
149
|
this.suppressedData = []
|
|
150
|
+
this.suppressedChars = 0
|
|
10
151
|
this.suppressTimeout = null
|
|
11
152
|
this.onSuppressionEndCallback = null
|
|
12
153
|
this.hasReceivedInitialData = false
|
|
@@ -17,6 +158,10 @@ export default class AttachAddonCustom {
|
|
|
17
158
|
this.decoder = new TextDecoder('utf-8')
|
|
18
159
|
this._lastDataTime = Date.now()
|
|
19
160
|
this._lastInputTime = Date.now()
|
|
161
|
+
// Set for the lifetime of the connection; every async helper below
|
|
162
|
+
// bails out on it so a disposed addon can never leave a pending promise
|
|
163
|
+
// (and the startup queue waiting on it) hanging.
|
|
164
|
+
this.disposed = false
|
|
20
165
|
this._keepaliveTimer = null
|
|
21
166
|
this._keepaliveInterval = 3000
|
|
22
167
|
this._lastOutputLine = ''
|
|
@@ -29,7 +174,7 @@ export default class AttachAddonCustom {
|
|
|
29
174
|
// second) collapse into a few term.write() calls per frame instead of
|
|
30
175
|
// blocking the main thread on every WebSocket message.
|
|
31
176
|
this._writeBuffer = []
|
|
32
|
-
this.
|
|
177
|
+
this._bufferChars = 0
|
|
33
178
|
this._flushScheduled = false
|
|
34
179
|
this._flushTimer = null
|
|
35
180
|
// Coalescing window. Output is flushed at most once per interval.
|
|
@@ -41,14 +186,34 @@ export default class AttachAddonCustom {
|
|
|
41
186
|
// command result) is flushed immediately instead of paying the
|
|
42
187
|
// coalescing delay. 0 = the first chunk ever flushes immediately.
|
|
43
188
|
this._lastFlushTime = 0
|
|
44
|
-
// Soft cap on buffered-but-unflushed
|
|
45
|
-
//
|
|
46
|
-
// the
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
189
|
+
// Soft cap on buffered-but-unflushed characters (UTF-16 code units, not
|
|
190
|
+
// bytes — see _enqueueWrite). Under a sustained flood the producer outruns
|
|
191
|
+
// the renderer; once pending output exceeds this we drop the OLDEST data
|
|
192
|
+
// (preserving the newest, visible tail and the line currently being
|
|
193
|
+
// rewritten). Normal interactive output is many orders of magnitude
|
|
194
|
+
// smaller and is never dropped.
|
|
195
|
+
//
|
|
196
|
+
// 2M, up from 256K: TUI apps (claude, codex, ...) repaint by rewinding
|
|
197
|
+
// with ESC[nA, and Ink-style rewinds reach 50+ rows. At 256K the cap was
|
|
198
|
+
// tripped routinely by ordinary TUI output, and dropping out of the middle
|
|
199
|
+
// of such a stream corrupted the screen — surviving frames still carried
|
|
200
|
+
// rewinds that then pointed at output we never wrote (see
|
|
201
|
+
// _dropOldestUntil, which now strips them). 2M is ~200 full-screen
|
|
202
|
+
// repaints of a 200x50 terminal, so only genuinely runaway output (yes,
|
|
203
|
+
// `cat` of a huge file) trips it.
|
|
204
|
+
this._maxBufferChars = 2 * 1024 * 1024
|
|
205
|
+
this._droppedChars = 0
|
|
51
206
|
this._droppedWarned = false
|
|
207
|
+
// Automation taps: subscribers receive the decoded string BEFORE write
|
|
208
|
+
// coalescing (see writeToTerminal). Suppressed output never reaches taps.
|
|
209
|
+
this._dataTaps = new Set()
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
addDataTap = (fn) => {
|
|
213
|
+
this._dataTaps.add(fn)
|
|
214
|
+
return () => {
|
|
215
|
+
this._dataTaps.delete(fn)
|
|
216
|
+
}
|
|
52
217
|
}
|
|
53
218
|
|
|
54
219
|
_initBase = async () => {
|
|
@@ -58,6 +223,10 @@ export default class AttachAddonCustom {
|
|
|
58
223
|
}
|
|
59
224
|
|
|
60
225
|
onInitialData = (callback) => {
|
|
226
|
+
if (this.disposed) {
|
|
227
|
+
// Never let a caller wait on a dead connection.
|
|
228
|
+
return
|
|
229
|
+
}
|
|
61
230
|
if (this.hasReceivedInitialData) {
|
|
62
231
|
callback()
|
|
63
232
|
} else {
|
|
@@ -65,9 +234,72 @@ export default class AttachAddonCustom {
|
|
|
65
234
|
}
|
|
66
235
|
}
|
|
67
236
|
|
|
237
|
+
// Timestamp of the last chunk that arrived from the shell, updated even
|
|
238
|
+
// while output is suppressed. This is what "has the terminal gone quiet?"
|
|
239
|
+
// is measured against.
|
|
240
|
+
getLastOutputTime = () => {
|
|
241
|
+
return this._lastDataTime
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Resolve once the shell has produced no output for `idleMs`.
|
|
246
|
+
* Used to pace queued startup work (shell integration injection, run
|
|
247
|
+
* scripts) so the next command is never typed into a shell that is still
|
|
248
|
+
* drawing the previous prompt - that race is what made run scripts get
|
|
249
|
+
* dropped when sftp path following was on.
|
|
250
|
+
* Never rejects and never hangs: resolves false on timeout or when the
|
|
251
|
+
* connection is gone.
|
|
252
|
+
*/
|
|
253
|
+
waitForOutputIdle = ({ idleMs = 400, timeoutMs = 3000 } = {}) => {
|
|
254
|
+
if (this.disposed || !this.term) {
|
|
255
|
+
return Promise.resolve(false)
|
|
256
|
+
}
|
|
257
|
+
return new Promise((resolve) => {
|
|
258
|
+
let done = false
|
|
259
|
+
const finish = (result) => {
|
|
260
|
+
if (done) {
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
done = true
|
|
264
|
+
clearInterval(pollTimer)
|
|
265
|
+
clearTimeout(capTimer)
|
|
266
|
+
resolve(result)
|
|
267
|
+
}
|
|
268
|
+
const step = Math.max(50, Math.min(idleMs, 200))
|
|
269
|
+
const pollTimer = setInterval(() => {
|
|
270
|
+
if (this.disposed || !this.term) {
|
|
271
|
+
return finish(false)
|
|
272
|
+
}
|
|
273
|
+
if (Date.now() - this._lastDataTime >= idleMs) {
|
|
274
|
+
finish(true)
|
|
275
|
+
}
|
|
276
|
+
}, step)
|
|
277
|
+
const capTimer = setTimeout(() => finish(false), Math.max(idleMs, timeoutMs))
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
|
|
68
281
|
startOutputSuppression = (timeout = 3000, onEnd = null, discardOnTimeout = false) => {
|
|
282
|
+
if (this.disposed) {
|
|
283
|
+
onEnd?.()
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
// A suppression may already be running (e.g. the 500ms keepalive one).
|
|
287
|
+
// Starting another must end it first - otherwise its timer stays armed
|
|
288
|
+
// and fires in the middle of the new window, ending it early and
|
|
289
|
+
// flushing half-collected output (the shell integration echo) to screen.
|
|
290
|
+
if (this.outputSuppressed) {
|
|
291
|
+
const previous = this.onSuppressionEndCallback
|
|
292
|
+
this.onSuppressionEndCallback = null
|
|
293
|
+
if (this.suppressTimeout) {
|
|
294
|
+
clearTimeout(this.suppressTimeout)
|
|
295
|
+
this.suppressTimeout = null
|
|
296
|
+
}
|
|
297
|
+
this.outputSuppressed = false
|
|
298
|
+
previous?.()
|
|
299
|
+
}
|
|
69
300
|
this.outputSuppressed = true
|
|
70
301
|
this.suppressedData = []
|
|
302
|
+
this.suppressedChars = 0
|
|
71
303
|
this.onSuppressionEndCallback = onEnd
|
|
72
304
|
this.suppressTimeout = setTimeout(() => {
|
|
73
305
|
if (!discardOnTimeout) {
|
|
@@ -84,12 +316,14 @@ export default class AttachAddonCustom {
|
|
|
84
316
|
}
|
|
85
317
|
this.outputSuppressed = false
|
|
86
318
|
|
|
87
|
-
|
|
88
|
-
|
|
319
|
+
const pending = this.suppressedData
|
|
320
|
+
this.suppressedData = []
|
|
321
|
+
this.suppressedChars = 0
|
|
322
|
+
if (!discard && pending.length > 0 && this.term) {
|
|
323
|
+
for (const data of pending) {
|
|
89
324
|
this.writeToTerminalDirect(data)
|
|
90
325
|
}
|
|
91
326
|
}
|
|
92
|
-
this.suppressedData = []
|
|
93
327
|
|
|
94
328
|
if (this.onSuppressionEndCallback) {
|
|
95
329
|
const callback = this.onSuppressionEndCallback
|
|
@@ -179,13 +413,13 @@ export default class AttachAddonCustom {
|
|
|
179
413
|
|
|
180
414
|
writeToTerminalDirect = (data) => {
|
|
181
415
|
const { term } = this
|
|
182
|
-
if (term.parent?.onZmodem) {
|
|
416
|
+
if (!term || term.parent?.onZmodem) {
|
|
183
417
|
return
|
|
184
418
|
}
|
|
185
419
|
if (typeof data === 'string') {
|
|
186
420
|
return term.write(data)
|
|
187
421
|
}
|
|
188
|
-
term
|
|
422
|
+
term.write(data)
|
|
189
423
|
}
|
|
190
424
|
|
|
191
425
|
writeToTerminal = (data) => {
|
|
@@ -238,7 +472,12 @@ export default class AttachAddonCustom {
|
|
|
238
472
|
this._enqueueWrite('\r\n' + str.slice(oscIdx))
|
|
239
473
|
return
|
|
240
474
|
}
|
|
241
|
-
|
|
475
|
+
// Bounded: a slow login banner or a chatty MOTD during the injection
|
|
476
|
+
// window must not buffer without limit while output is hidden.
|
|
477
|
+
if (this.suppressedChars < MAX_SUPPRESSED_CHARS) {
|
|
478
|
+
this.suppressedChars += str.length
|
|
479
|
+
this.suppressedData.push(data)
|
|
480
|
+
}
|
|
242
481
|
return
|
|
243
482
|
}
|
|
244
483
|
|
|
@@ -255,6 +494,18 @@ export default class AttachAddonCustom {
|
|
|
255
494
|
|
|
256
495
|
// Coalesce the actual write (see _enqueueWrite). notifyOnData /
|
|
257
496
|
// onTerminalWrite fire once per flush instead of once per chunk.
|
|
497
|
+
// Automation taps run here on the decoded string, before coalescing
|
|
498
|
+
// (and after the suppression early-return above, so suppressed
|
|
499
|
+
// keepalive/shell-integration echo never triggers automations).
|
|
500
|
+
if (this._dataTaps.size) {
|
|
501
|
+
for (const fn of this._dataTaps) {
|
|
502
|
+
try {
|
|
503
|
+
fn(str)
|
|
504
|
+
} catch (e) {
|
|
505
|
+
console.error('[dataTap]', e)
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
258
509
|
this._enqueueWrite(str)
|
|
259
510
|
}
|
|
260
511
|
|
|
@@ -266,13 +517,13 @@ export default class AttachAddonCustom {
|
|
|
266
517
|
return
|
|
267
518
|
}
|
|
268
519
|
this._writeBuffer.push(str)
|
|
269
|
-
this.
|
|
270
|
-
if (this.
|
|
520
|
+
this._bufferChars += str.length
|
|
521
|
+
if (this._bufferChars > this._maxBufferChars) {
|
|
271
522
|
this._dropOldestUntil()
|
|
272
523
|
}
|
|
273
524
|
// A hidden window gets its timers throttled by Chromium (Electron's
|
|
274
525
|
// backgroundThrottling defaults to true), which would stall the flush
|
|
275
|
-
// timer and force the
|
|
526
|
+
// timer and force the cap to drop real output. Nothing is painted
|
|
276
527
|
// while hidden, so coalescing buys nothing — flush synchronously.
|
|
277
528
|
if (document.hidden) {
|
|
278
529
|
clearTimeout(this._flushTimer)
|
|
@@ -300,38 +551,23 @@ export default class AttachAddonCustom {
|
|
|
300
551
|
// exceeds the cap we drop the OLDEST chunks (preserving the newest, visible
|
|
301
552
|
// tail and the line currently being rewritten). The first kept chunk is
|
|
302
553
|
// trimmed to the next newline so we never render a half line / broken escape.
|
|
554
|
+
//
|
|
555
|
+
// Dropping is lossy but must never corrupt the stream. The discarded tail
|
|
556
|
+
// usually ended mid-repaint, so a surviving frame can still open with a
|
|
557
|
+
// rewind (ESC[nA) or a cursor restore (ESC[8) aimed at output we never
|
|
558
|
+
// wrote — left in, those yank the cursor back over already-rendered history
|
|
559
|
+
// and overwrite it, which is far worse than the dropped data. Stripping them
|
|
560
|
+
// (see stripLeadingCursorOps) degrades output to appending from wherever the
|
|
561
|
+
// cursor actually is.
|
|
303
562
|
_dropOldestUntil = () => {
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
let cutIdx = buf.length
|
|
307
|
-
for (let i = buf.length - 1; i >= 0; i--) {
|
|
308
|
-
if (kept + buf[i].length > this._maxBufferBytes) {
|
|
309
|
-
cutIdx = i + 1
|
|
310
|
-
break
|
|
311
|
-
}
|
|
312
|
-
kept += buf[i].length
|
|
313
|
-
}
|
|
314
|
-
if (cutIdx <= 0) {
|
|
563
|
+
const buffered = this._writeBuffer.join('')
|
|
564
|
+
if (buffered.length <= this._maxBufferChars) {
|
|
315
565
|
return
|
|
316
566
|
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
if (cutIdx < buf.length) {
|
|
322
|
-
const first = buf[cutIdx]
|
|
323
|
-
const nl = first.indexOf('\n')
|
|
324
|
-
if (nl >= 0 && nl < first.length - 1) {
|
|
325
|
-
dropped += nl + 1
|
|
326
|
-
buf[cutIdx] = first.slice(nl + 1)
|
|
327
|
-
} else {
|
|
328
|
-
dropped += buf[cutIdx].length
|
|
329
|
-
cutIdx += 1
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
this._writeBuffer = buf.slice(cutIdx)
|
|
333
|
-
this._bufferBytes -= dropped
|
|
334
|
-
this._droppedBytes += dropped
|
|
567
|
+
const { output, dropped } = truncateTerminalOutput(buffered, this._maxBufferChars)
|
|
568
|
+
this._writeBuffer = [output]
|
|
569
|
+
this._bufferChars = output.length
|
|
570
|
+
this._droppedChars += dropped
|
|
335
571
|
if (!this._droppedWarned) {
|
|
336
572
|
this._droppedWarned = true
|
|
337
573
|
console.warn('[AttachAddon] Heavy output detected; coalescing writes and dropping intermediate output to keep the UI responsive.')
|
|
@@ -344,11 +580,11 @@ export default class AttachAddonCustom {
|
|
|
344
580
|
const buf = this._writeBuffer
|
|
345
581
|
if (!buf.length || !this.term) {
|
|
346
582
|
this._writeBuffer = []
|
|
347
|
-
this.
|
|
583
|
+
this._bufferChars = 0
|
|
348
584
|
return
|
|
349
585
|
}
|
|
350
586
|
this._writeBuffer = []
|
|
351
|
-
this.
|
|
587
|
+
this._bufferChars = 0
|
|
352
588
|
const data = buf.length === 1 ? buf[0] : buf.join('')
|
|
353
589
|
const { term } = this
|
|
354
590
|
this._lastFlushTime = Date.now()
|
|
@@ -434,16 +670,26 @@ export default class AttachAddonCustom {
|
|
|
434
670
|
}
|
|
435
671
|
|
|
436
672
|
dispose = () => {
|
|
673
|
+
if (this.disposed) {
|
|
674
|
+
return
|
|
675
|
+
}
|
|
676
|
+
this.disposed = true
|
|
437
677
|
this._stopKeepalive()
|
|
438
678
|
clearTimeout(this._echoCheckTimer)
|
|
439
679
|
this._echoCheckTimer = null
|
|
680
|
+
// End any pending suppression *before* dropping `term`, so the callback
|
|
681
|
+
// (and the promise the startup queue is awaiting) is released instead of
|
|
682
|
+
// hanging forever on a connection that is already gone.
|
|
683
|
+
this.stopOutputSuppression(true)
|
|
684
|
+
this.onInitialDataCallback = null
|
|
440
685
|
if (this._flushTimer) {
|
|
441
686
|
clearTimeout(this._flushTimer)
|
|
442
687
|
this._flushTimer = null
|
|
443
688
|
}
|
|
444
689
|
this._flushScheduled = false
|
|
445
690
|
this._writeBuffer = []
|
|
446
|
-
this.
|
|
691
|
+
this._bufferChars = 0
|
|
692
|
+
this._dataTaps.clear()
|
|
447
693
|
// Reset the streaming decoder so any partial multi-byte sequence held
|
|
448
694
|
// over from this connection can not leak into a reused instance.
|
|
449
695
|
this.decoder = new TextDecoder('utf-8')
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Per-terminal-tab automation binding.
|
|
2
|
+
// Subscribes to the attach addon's decoded stream (before coalescing)
|
|
3
|
+
// and feeds it into a TriggerEngine that auto-sends responses.
|
|
4
|
+
import TriggerEngine from './trigger-engine.js'
|
|
5
|
+
|
|
6
|
+
function signature (rules) {
|
|
7
|
+
try {
|
|
8
|
+
return JSON.stringify((rules || []).map(r => [
|
|
9
|
+
r.id, r.enabled, r.match?.type, r.match?.value,
|
|
10
|
+
r.match?.caseSensitive, r.action?.type, r.action?.value,
|
|
11
|
+
r.sendEnter, r.mode, r.cooldownMs
|
|
12
|
+
]))
|
|
13
|
+
} catch (e) {
|
|
14
|
+
return String(Date.now())
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createTriggerManager ({ attachAddon, send, getTriggers, onFire } = {}) {
|
|
19
|
+
const engine = new TriggerEngine({ send, onFire })
|
|
20
|
+
let lastSig = ''
|
|
21
|
+
const sync = () => {
|
|
22
|
+
const rules = getTriggers ? getTriggers() || [] : []
|
|
23
|
+
const sig = signature(rules)
|
|
24
|
+
if (sig !== lastSig) {
|
|
25
|
+
lastSig = sig
|
|
26
|
+
engine.setTriggers(rules)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
sync()
|
|
30
|
+
let disposeTap = null
|
|
31
|
+
if (attachAddon && typeof attachAddon.addDataTap === 'function') {
|
|
32
|
+
disposeTap = attachAddon.addDataTap((str) => {
|
|
33
|
+
try {
|
|
34
|
+
sync()
|
|
35
|
+
engine.push(str)
|
|
36
|
+
} catch (e) {
|
|
37
|
+
console.error('[trigger-manager]', e)
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
engine,
|
|
43
|
+
refresh () {
|
|
44
|
+
try {
|
|
45
|
+
lastSig = ''
|
|
46
|
+
sync()
|
|
47
|
+
} catch (e) {
|
|
48
|
+
console.error('[trigger-manager] refresh failed', e)
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
dispose () {
|
|
52
|
+
if (disposeTap) {
|
|
53
|
+
disposeTap()
|
|
54
|
+
disposeTap = null
|
|
55
|
+
}
|
|
56
|
+
engine.dispose()
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export { TriggerEngine }
|
|
62
|
+
export { default as StreamMatcher } from './stream-matcher.js'
|
|
63
|
+
export { stripAnsi, normalizeCR } from './strip-ansi.js'
|
|
64
|
+
export { expandControlChars } from './keys.js'
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Expands control-char escapes typed by users, e.g. in trigger send text:
|
|
2
|
+
// \n \t \r \\ \xHH hex bytes and ^X caret notation (^M = \r, ^C = \x03)
|
|
3
|
+
export function expandControlChars (text) {
|
|
4
|
+
if (!text) {
|
|
5
|
+
return ''
|
|
6
|
+
}
|
|
7
|
+
let result = ''
|
|
8
|
+
for (let i = 0; i < text.length; i++) {
|
|
9
|
+
const c = text[i]
|
|
10
|
+
if (c === '\\' && i + 1 < text.length) {
|
|
11
|
+
const next = text[i + 1]
|
|
12
|
+
if (next === 'n') {
|
|
13
|
+
result += '\n'
|
|
14
|
+
i++
|
|
15
|
+
} else if (next === 't') {
|
|
16
|
+
result += '\t'
|
|
17
|
+
i++
|
|
18
|
+
} else if (next === 'r') {
|
|
19
|
+
result += '\r'
|
|
20
|
+
i++
|
|
21
|
+
} else if (next === '\\') {
|
|
22
|
+
result += '\\'
|
|
23
|
+
i++
|
|
24
|
+
} else if (next === 'x' && /^[0-9a-fA-F]{2}$/.test(text.slice(i + 2, i + 4))) {
|
|
25
|
+
result += String.fromCharCode(parseInt(text.slice(i + 2, i + 4), 16))
|
|
26
|
+
i += 3
|
|
27
|
+
} else {
|
|
28
|
+
result += c
|
|
29
|
+
}
|
|
30
|
+
} else if (c === '^' && i + 1 < text.length && /[@A-Z[\\\]^_]/.test(text[i + 1])) {
|
|
31
|
+
result += String.fromCharCode(text.charCodeAt(i + 1) - 64)
|
|
32
|
+
i++
|
|
33
|
+
} else {
|
|
34
|
+
result += c
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return result
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function escapeRegExp (s) {
|
|
41
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function toSafeRegExp (pattern, flags = '') {
|
|
45
|
+
if (pattern instanceof RegExp) {
|
|
46
|
+
return new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, '') + flags.replace(/[gy]/g, ''))
|
|
47
|
+
}
|
|
48
|
+
return new RegExp(pattern, flags.replace(/[gy]/g, ''))
|
|
49
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Incremental stream matcher: keeps a sliding-window tail buffer and
|
|
2
|
+
// re-matches on every push so patterns split across chunks still hit.
|
|
3
|
+
import { toSafeRegExp } from './keys.js'
|
|
4
|
+
|
|
5
|
+
export default class StreamMatcher {
|
|
6
|
+
constructor ({ maxBuffer = 65536 } = {}) {
|
|
7
|
+
this.buf = ''
|
|
8
|
+
this.base = 0
|
|
9
|
+
this.maxBuffer = maxBuffer
|
|
10
|
+
this.watchers = new Set()
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
push (str) {
|
|
14
|
+
if (!str) {
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
this.buf += str
|
|
18
|
+
const over = this.buf.length - this.maxBuffer
|
|
19
|
+
if (over > 0) {
|
|
20
|
+
this.buf = this.buf.slice(over)
|
|
21
|
+
this.base += over
|
|
22
|
+
}
|
|
23
|
+
this._scan()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
_scan () {
|
|
27
|
+
for (const w of [...this.watchers]) {
|
|
28
|
+
const m = w.re.exec(this.buf)
|
|
29
|
+
if (!m) {
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
this.watchers.delete(w)
|
|
33
|
+
clearTimeout(w.timer)
|
|
34
|
+
w.resolve({
|
|
35
|
+
index: this.base + m.index,
|
|
36
|
+
text: m[0],
|
|
37
|
+
groups: m.slice(1),
|
|
38
|
+
before: this.buf.slice(Math.max(0, w.from - this.base), m.index)
|
|
39
|
+
})
|
|
40
|
+
if (w.consume) {
|
|
41
|
+
const cut = m.index + m[0].length
|
|
42
|
+
this.base += cut
|
|
43
|
+
this.buf = this.buf.slice(cut)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
wait (re, { timeout = 10000, consume = true, from = null } = {}) {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
const w = {
|
|
51
|
+
re: toSafeRegExp(re),
|
|
52
|
+
resolve,
|
|
53
|
+
reject,
|
|
54
|
+
consume,
|
|
55
|
+
from: from == null ? this.base + this.buf.length : from,
|
|
56
|
+
timer: null
|
|
57
|
+
}
|
|
58
|
+
if (timeout > 0) {
|
|
59
|
+
w.timer = setTimeout(() => {
|
|
60
|
+
this.watchers.delete(w)
|
|
61
|
+
const err = new Error('waitFor timeout: ' + re)
|
|
62
|
+
err.code = 'ETIMEOUT'
|
|
63
|
+
err.tail = this.buf.slice(-4096)
|
|
64
|
+
reject(err)
|
|
65
|
+
}, timeout)
|
|
66
|
+
}
|
|
67
|
+
this.watchers.add(w)
|
|
68
|
+
this._scan()
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
dispose () {
|
|
73
|
+
for (const w of this.watchers) {
|
|
74
|
+
clearTimeout(w.timer)
|
|
75
|
+
const err = new Error('matcher disposed')
|
|
76
|
+
err.code = 'EDISPOSED'
|
|
77
|
+
w.reject?.(err)
|
|
78
|
+
}
|
|
79
|
+
this.watchers.clear()
|
|
80
|
+
this.buf = ''
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/* eslint-disable no-control-regex */
|
|
2
|
+
const OSC = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g
|
|
3
|
+
const CSI = /\x1b\[[0-?]*[ -/]*[@-~]/g
|
|
4
|
+
const OTHER = /\x1b[@-Z\\-_]/g
|
|
5
|
+
|
|
6
|
+
export const stripAnsi = (s) => {
|
|
7
|
+
if (!s) {
|
|
8
|
+
return ''
|
|
9
|
+
}
|
|
10
|
+
return s.replace(OSC, '').replace(CSI, '').replace(OTHER, '')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// \r not followed by \n is treated as line break (progress bar scenes)
|
|
14
|
+
export const normalizeCR = (s) => {
|
|
15
|
+
if (!s) {
|
|
16
|
+
return ''
|
|
17
|
+
}
|
|
18
|
+
return s.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
|
19
|
+
}
|