@electerm/electerm-react 5.3.26 → 5.5.9

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.
Files changed (146) hide show
  1. package/README.md +246 -12
  2. package/client/common/choose-save-folder.js +21 -2
  3. package/client/common/constants.js +20 -6
  4. package/client/common/create-title.jsx +6 -0
  5. package/client/common/default-setting.js +15 -0
  6. package/client/common/init-setting-item.js +5 -0
  7. package/client/common/platform.js +24 -0
  8. package/client/components/ai/agent-tool-call-card.jsx +10 -2
  9. package/client/components/ai/agent.js +4 -1
  10. package/client/components/ai/ai-attachments.js +87 -0
  11. package/client/components/ai/ai-chat-empty.jsx +43 -0
  12. package/client/components/ai/ai-chat-entry.jsx +13 -4
  13. package/client/components/ai/ai-chat-history-item.jsx +28 -4
  14. package/client/components/ai/ai-chat-history.jsx +2 -1
  15. package/client/components/ai/ai-chat.jsx +104 -13
  16. package/client/components/ai/ai-config-props.js +4 -130
  17. package/client/components/ai/ai-config.jsx +135 -15
  18. package/client/components/ai/ai-presets.js +173 -0
  19. package/client/components/ai/ai.styl +83 -0
  20. package/client/components/bg/css-overwrite.jsx +11 -0
  21. package/client/components/bookmark-form/bookmark-schema.js +4 -0
  22. package/client/components/bookmark-form/common/fields.jsx +3 -0
  23. package/client/components/bookmark-form/config/common-fields.js +12 -0
  24. package/client/components/bookmark-form/config/local.js +3 -2
  25. package/client/components/bookmark-form/config/serial.js +3 -2
  26. package/client/components/bookmark-form/config/ssh.js +2 -1
  27. package/client/components/bookmark-form/config/telnet.js +3 -2
  28. package/client/components/common/item-filter.jsx +76 -0
  29. package/client/components/common/item-filter.styl +54 -0
  30. package/client/components/common/lazy-boundary.jsx +34 -0
  31. package/client/components/common/opacity.jsx +3 -1
  32. package/client/components/footer/cmd-history.jsx +132 -45
  33. package/client/components/footer/cmd-history.styl +42 -4
  34. package/client/components/footer/footer-entry.jsx +35 -5
  35. package/client/components/footer/footer.styl +8 -1
  36. package/client/components/footer/multi-tab-run-modal.jsx +83 -0
  37. package/client/components/footer/tab-select.jsx +34 -17
  38. package/client/components/layout/layout.jsx +23 -31
  39. package/client/components/layout/pixed.js +3 -0
  40. package/client/components/main/main.jsx +55 -28
  41. package/client/components/main/term-fullscreen.styl +9 -3
  42. package/client/components/quick-commands/qm.styl +3 -2
  43. package/client/components/quick-commands/quick-command-create-modal.jsx +60 -0
  44. package/client/components/quick-commands/quick-commands-form-elem.jsx +10 -4
  45. package/client/components/rdp/file-transfer.js +4 -4
  46. package/client/components/remote-monitor/monitor-details.jsx +458 -0
  47. package/client/components/remote-monitor/monitor-details.styl +87 -0
  48. package/client/components/remote-monitor/monitor-model.js +764 -0
  49. package/client/components/remote-monitor/remote-monitor-bar-entry.jsx +18 -0
  50. package/client/components/remote-monitor/remote-monitor-bar.jsx +344 -0
  51. package/client/components/remote-monitor/remote-monitor-bar.styl +106 -0
  52. package/client/components/remote-monitor/session-monitor.js +361 -0
  53. package/client/components/remote-monitor/use-monitor-details.js +10 -0
  54. package/client/components/remote-monitor/visibility.js +18 -0
  55. package/client/components/session/session-control.jsx +29 -2
  56. package/client/components/session/session.jsx +40 -18
  57. package/client/components/session/sessions.jsx +5 -2
  58. package/client/components/setting-panel/setting-common.jsx +25 -56
  59. package/client/components/setting-panel/setting-modal.jsx +57 -42
  60. package/client/components/setting-panel/setting-terminal.jsx +8 -2
  61. package/client/components/setting-panel/setting.styl +1 -1
  62. package/client/components/setting-panel/tab-triggers.jsx +71 -0
  63. package/client/components/sftp/file-compare-modal.jsx +6 -3
  64. package/client/components/sftp/file-item.jsx +5 -3
  65. package/client/components/sftp/owner-list.js +11 -2
  66. package/client/components/sftp/sftp-entry.jsx +4 -0
  67. package/client/components/side-panel-r/side-panel-r.jsx +2 -2
  68. package/client/components/sidebar/side-panel.jsx +1 -1
  69. package/client/components/sys-menu/menu-btn.jsx +30 -1
  70. package/client/components/tabs/index.jsx +2 -2
  71. package/client/components/tabs/tab-title.jsx +3 -2
  72. package/client/components/tabs/tab.jsx +5 -1
  73. package/client/components/terminal/attach-addon-custom.js +295 -49
  74. package/client/components/terminal/automation/index.js +64 -0
  75. package/client/components/terminal/automation/keys.js +49 -0
  76. package/client/components/terminal/automation/stream-matcher.js +82 -0
  77. package/client/components/terminal/automation/strip-ansi.js +19 -0
  78. package/client/components/terminal/automation/trigger-engine.js +233 -0
  79. package/client/components/terminal/automation/trigger-presets.js +110 -0
  80. package/client/components/terminal/command-tracker-addon.js +31 -1
  81. package/client/components/terminal/mixins/index.js +27 -0
  82. package/client/components/terminal/mixins/term-attach.js +123 -0
  83. package/client/components/terminal/mixins/term-config.jsx +84 -0
  84. package/client/components/terminal/mixins/term-content.js +126 -0
  85. package/client/components/terminal/mixins/term-context-menu.jsx +323 -0
  86. package/client/components/terminal/mixins/term-file-drop.js +149 -0
  87. package/client/components/terminal/mixins/term-init.js +122 -0
  88. package/client/components/terminal/mixins/term-log.jsx +96 -0
  89. package/client/components/terminal/mixins/term-resize.js +87 -0
  90. package/client/components/terminal/mixins/term-socket.js +433 -0
  91. package/client/components/terminal/mixins/term-suggestions.js +224 -0
  92. package/client/components/terminal/mixins/term-theme.js +190 -0
  93. package/client/components/terminal/mixins/term-touch.js +523 -0
  94. package/client/components/terminal/shell-detect.js +37 -0
  95. package/client/components/terminal/shell.js +0 -28
  96. package/client/components/terminal/shortcut-bar-defs.js +30 -4
  97. package/client/components/terminal/shortcut-bar-edit.jsx +40 -9
  98. package/client/components/terminal/shortcut-bar-entry.jsx +6 -3
  99. package/client/components/terminal/shortcut-bar.jsx +17 -10
  100. package/client/components/terminal/shortcut-bar.styl +4 -0
  101. package/client/components/terminal/ssh-reload-state.js +96 -0
  102. package/client/components/terminal/startup-queue.js +433 -0
  103. package/client/components/terminal/terminal-interactive.jsx +32 -0
  104. package/client/components/terminal/terminal-select-text.jsx +69 -0
  105. package/client/components/terminal/terminal.jsx +227 -2064
  106. package/client/components/terminal/terminal.styl +61 -0
  107. package/client/components/terminal/transfer-client-base.js +5 -29
  108. package/client/components/terminal/trzsz-client.js +1 -3
  109. package/client/components/terminal/xmodem-client.js +1 -3
  110. package/client/components/terminal/xterm-loader.js +7 -0
  111. package/client/components/terminal/zmodem-client.js +1 -3
  112. package/client/components/terminal-info/base.jsx +18 -70
  113. package/client/components/terminal-info/terminal-info-entry.jsx +13 -5
  114. package/client/components/terminal-info/terminal-info.jsx +50 -59
  115. package/client/components/terminal-info/terminal-info.styl +6 -49
  116. package/client/components/text-editor/open-text-editor.js +30 -0
  117. package/client/components/text-editor/text-editor-entry.jsx +17 -6
  118. package/client/components/text-editor/text-editor.jsx +14 -0
  119. package/client/components/triggers/trigger-editor.jsx +246 -0
  120. package/client/components/triggers/trigger-form.jsx +200 -0
  121. package/client/components/triggers/trigger-session-modal.jsx +199 -0
  122. package/client/components/triggers/trigger-setting-list.jsx +89 -0
  123. package/client/components/triggers/trigger-transport.jsx +60 -0
  124. package/client/components/triggers/triggers-field.jsx +19 -0
  125. package/client/components/widgets/widgets-list.jsx +11 -1
  126. package/client/css/mobile.styl +29 -8
  127. package/client/store/common.js +12 -1
  128. package/client/store/init-state.js +7 -0
  129. package/client/store/mcp-handler.js +158 -30
  130. package/client/store/setting.js +9 -0
  131. package/client/store/store.js +2 -0
  132. package/client/store/sync.js +2 -0
  133. package/client/store/tab.js +42 -3
  134. package/client/store/trigger.js +118 -0
  135. package/client/store/watch.js +13 -5
  136. package/client/views/index.pug +3 -0
  137. package/package.json +1 -1
  138. package/client/components/terminal-info/activity.jsx +0 -57
  139. package/client/components/terminal-info/data-cols-parser.jsx +0 -54
  140. package/client/components/terminal-info/disk.jsx +0 -45
  141. package/client/components/terminal-info/format-disks.js +0 -45
  142. package/client/components/terminal-info/network.jsx +0 -121
  143. package/client/components/terminal-info/resource.jsx +0 -99
  144. package/client/components/terminal-info/run-cmd.jsx +0 -263
  145. package/client/components/terminal-info/sys-info.jsx +0 -56
  146. package/client/components/terminal-info/up.jsx +0 -17
@@ -561,7 +561,11 @@ class Tab extends Component {
561
561
  )
562
562
  }
563
563
  <span className='tab-title'>
564
- <span className='iblock mg1r tab-count' style={styleTag}>{tabCount}</span>
564
+ {!(config && config.disableTabIndex)
565
+ ? (
566
+ <span className='iblock mg1r tab-count' style={styleTag}>{tabCount}</span>
567
+ )
568
+ : null}
565
569
  {tab.isPinned && <PushpinFilled className='tab-pin' />}
566
570
  <span className='mg1r'>{title}</span>
567
571
  </span>
@@ -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._bufferBytes = 0
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 bytes. Under a sustained flood the
45
- // producer outruns the renderer; once pending output exceeds this we drop
46
- // the OLDEST data (preserving the newest, visible tail and the line
47
- // currently being rewritten). Normal interactive output is many orders of
48
- // magnitude smaller and is never dropped.
49
- this._maxBufferBytes = 256 * 1024
50
- this._droppedBytes = 0
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
- if (!discard && this.suppressedData.length > 0) {
88
- for (const data of this.suppressedData) {
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?.write(data)
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
- this.suppressedData.push(data)
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._bufferBytes += str.length
270
- if (this._bufferBytes > this._maxBufferBytes) {
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 byte cap to drop real output. Nothing is painted
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 buf = this._writeBuffer
305
- let kept = 0
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
- let dropped = 0
318
- for (let i = 0; i < cutIdx; i++) {
319
- dropped += buf[i].length
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._bufferBytes = 0
583
+ this._bufferChars = 0
348
584
  return
349
585
  }
350
586
  this._writeBuffer = []
351
- this._bufferBytes = 0
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._bufferBytes = 0
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
+ }