@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
@@ -0,0 +1,433 @@
1
+ import { pick } from 'lodash-es'
2
+ import clone from '../../../common/to-simple-obj.js'
3
+ import deepCopy from 'json-deep-copy'
4
+ import {
5
+ statusMap,
6
+ typeMap,
7
+ isWin,
8
+ isMac,
9
+ terminalSerialType,
10
+ paneMap
11
+ } from '../../../common/constants.js'
12
+ import getProxy from '../../../common/get-proxy.js'
13
+ import { refs } from '../../common/ref.js'
14
+ import { createTerm, resizeTerm } from '../terminal-apis.js'
15
+ import { ZmodemClient } from '../zmodem-client.js'
16
+ import { TrzszClient } from '../trzsz-client.js'
17
+ import { XmodemClient } from '../xmodem-client.js'
18
+ import { loadWebLinksAddon } from '../xterm-loader.js'
19
+ import { KeywordHighlighterAddon } from '../highlight-addon.js'
20
+ import {
21
+ createTerminalReloadState,
22
+ getAlternateBufferSnapshot,
23
+ shouldCaptureTerminalReloadState
24
+ } from '../ssh-reload-state.js'
25
+
26
+ // Expands \n \t \r \\ and \xHH hex byte escapes, used to let users type
27
+ // control bytes (e.g. \x01 = Ctrl+A) in the serial "closeSequence" field.
28
+ function expandCloseSequence (text) {
29
+ let result = ''
30
+ for (let i = 0; i < text.length; i++) {
31
+ if (text[i] === '\\' && i + 1 < text.length) {
32
+ const next = text[i + 1]
33
+ if (next === 'n') {
34
+ result += '\n'
35
+ i++
36
+ } else if (next === 't') {
37
+ result += '\t'
38
+ i++
39
+ } else if (next === 'r') {
40
+ result += '\r'
41
+ i++
42
+ } else if (next === '\\') {
43
+ result += '\\'
44
+ i++
45
+ } else if (next === 'x' && /^[0-9a-fA-F]{2}$/.test(text.slice(i + 2, i + 4))) {
46
+ result += String.fromCharCode(parseInt(text.slice(i + 2, i + 4), 16))
47
+ i += 3
48
+ } else {
49
+ result += text[i]
50
+ }
51
+ } else {
52
+ result += text[i]
53
+ }
54
+ }
55
+ return result
56
+ }
57
+
58
+ /**
59
+ * Session lifecycle: opening the pty/ssh session over its websocket, error
60
+ * and reconnect handling, input broadcast, and the screen snapshot used to
61
+ * restore a shell session (ssh, local, telnet, serial) after a reload.
62
+ */
63
+ export const socketMixin = {
64
+ buildWsUrl (port) {
65
+ const { host, tokenElecterm } = this.props.config
66
+ const { id } = this.props.tab
67
+ if (window.et.buildWsUrl) {
68
+ return window.et.buildWsUrl(
69
+ host,
70
+ port,
71
+ tokenElecterm,
72
+ id
73
+ )
74
+ }
75
+ return `ws://${host}:${port}/terminals/${id}?token=${tokenElecterm}`
76
+ },
77
+
78
+ async remoteInit (term = this.term) {
79
+ this.setState({
80
+ loading: true,
81
+ terminalError: null
82
+ })
83
+ const { cols, rows } = term
84
+ const { config } = this.props
85
+ const {
86
+ keywords = []
87
+ } = config
88
+ const { logName } = this.props
89
+ const tab = window.store.applyProfileToTabs(deepCopy(this.props.tab || {}))
90
+ const {
91
+ srcId, from = 'bookmarks',
92
+ type,
93
+ term: terminalType,
94
+ displayRaw,
95
+ id
96
+ } = tab
97
+ const { savePassword } = this.state
98
+ const termType = type
99
+ const extra = this.props.sessionOptions
100
+ // Determine if this is a local terminal (no host)
101
+ const isLocalType = !tab.host
102
+ // Build exec settings: only for local type, prefer tab settings over config
103
+ let execOpts = {}
104
+ let execPropName = 'execLinux'
105
+ if (isWin) {
106
+ execPropName = 'execWindows'
107
+ } else if (isMac) {
108
+ execPropName = 'execMac'
109
+ }
110
+ if (isLocalType) {
111
+ // Check flat properties on tab first (bookmark data), then fall back to config
112
+ if (tab[execPropName]) {
113
+ // Use bookmark's exec setting directly
114
+ execOpts = {
115
+ [execPropName]: tab[execPropName],
116
+ [`${execPropName}Args`]: tab[`${execPropName}Args`] || []
117
+ }
118
+ } else if (config[execPropName]) {
119
+ // Use global config exec settings
120
+ execOpts = {
121
+ [execPropName]: config[execPropName],
122
+ [`${execPropName}Args`]: config[`${execPropName}Args`] || []
123
+ }
124
+ }
125
+ }
126
+ const keepaliveInterval = tab.keepaliveInterval || config.keepaliveInterval
127
+ const opts = clone({
128
+ cols,
129
+ rows,
130
+ term: terminalType || config.terminalType,
131
+ saveTerminalLogToFile: config.saveTerminalLogToFile,
132
+ ...tab,
133
+ ...extra,
134
+ ...execOpts,
135
+ logName,
136
+ sessionLogPath: this.state.logPath,
137
+ ...pick(config, [
138
+ 'addTimeStampToTermLog',
139
+ 'keepaliveCountMax',
140
+ 'keyword2FA',
141
+ 'debug'
142
+ ]),
143
+ keepaliveInterval,
144
+ tabId: id,
145
+ uid: id,
146
+ srcTabId: tab.id,
147
+ termType,
148
+ readyTimeout: config.sshReadyTimeout,
149
+ proxy: getProxy(tab, config),
150
+ type: tab.host
151
+ ? typeMap.remote
152
+ : typeMap.local
153
+ })
154
+ // Renderer-only state can contain a large serialized screen and must not
155
+ // cross the process boundary as part of the SSH connection options.
156
+ delete opts._reloadState
157
+ const isAutoReconnect = !!(tab.autoReConnect && this.props.config.autoReconnectTerminal)
158
+ const r = await createTerm(opts)
159
+ .catch(err => {
160
+ if (!isAutoReconnect) {
161
+ const text = err.message
162
+ this.handleError({ message: text, from, srcId })
163
+ }
164
+ })
165
+ // Guard: component was unmounted while createTerm was pending.
166
+ // The child process is already running; connect briefly to trigger its cleanup.
167
+ if (this.onClose) {
168
+ if (r && r.port) {
169
+ try {
170
+ const tmpSock = new WebSocket(this.buildWsUrl(r.port))
171
+ tmpSock.onopen = () => tmpSock.close()
172
+ } catch (_e) {}
173
+ }
174
+ return
175
+ }
176
+ if (typeof r === 'string' && r.includes('fail')) {
177
+ return this.promote()
178
+ }
179
+ if (savePassword) {
180
+ window.store.editItem(srcId, extra, from)
181
+ }
182
+ this.setState({
183
+ loading: false
184
+ })
185
+ if (!r) {
186
+ if (isAutoReconnect) {
187
+ this.scheduleAutoReconnect(3000)
188
+ return
189
+ }
190
+ this.setStatus(statusMap.error)
191
+ return
192
+ }
193
+ this.port = r.port
194
+ this.setStatus(statusMap.success)
195
+ refs.get('sftp-' + id)?.initData(id, r.port)
196
+ term.pid = id
197
+ this.pid = id
198
+ const wsUrl = this.buildWsUrl(r.port)
199
+ const socket = new WebSocket(wsUrl)
200
+ socket.onclose = this.oncloseSocket
201
+ socket.onerror = this.onerrorSocket
202
+ this.socket = socket
203
+ this.initSocketEvents()
204
+ this.term = term
205
+ socket.onopen = async () => {
206
+ // attach addon is a dynamic import too — report a failed chunk fetch
207
+ // instead of leaving an unhandled rejection behind
208
+ try {
209
+ await this.initAttachAddon()
210
+ this.startupQueue.runInitScript()
211
+ } catch (e) {
212
+ console.error(e)
213
+ this.handleError({ message: e.message })
214
+ }
215
+ }
216
+ // term.onRrefresh(this.onRefresh)
217
+ term.onResize(this.onResizeTerminal)
218
+ // xterm 6.x exposes buffer change as a public event (IBufferNamespace.onBufferChange).
219
+ // Previously this reached into the private _onBufferChange._listeners array.
220
+ term.buffer.onBufferChange(this.onBufferChange)
221
+ const WebLinksAddon = await loadWebLinksAddon()
222
+ term.loadAddon(new WebLinksAddon(this.webLinkHandler))
223
+ term.focus()
224
+ this.zmodemClient = new ZmodemClient(this)
225
+ this.zmodemClient.init(socket)
226
+ this.trzszClient = new TrzszClient(this)
227
+ this.trzszClient.init(socket)
228
+ this.xmodemClient = new XmodemClient(this)
229
+ this.xmodemClient.init(socket)
230
+ // 仅在可见时 fit,隐藏标签跳过,避免 0 列损坏提示符。
231
+ if (this.isElementVisible()) {
232
+ this.fitAddon.fit()
233
+ }
234
+ // Force-send the current size now that `this.pid` is finally valid.
235
+ // A resize may already have fired earlier (e.g. from the tab-becomes-
236
+ // visible fit in componentDidUpdate, which can run before createTerm()
237
+ // resolves) while this.pid was still undefined - the backend silently
238
+ // drops resize calls for an unknown pid. If that earlier call already
239
+ // updated term.cols/rows locally, xterm's resize() no-ops on the next
240
+ // fit() (dims unchanged) and never fires onResize again, so the
241
+ // backend pty would otherwise be stuck at its initial (possibly wrong)
242
+ // size forever. Sending explicitly here, keyed off the real pid,
243
+ // guarantees the remote pty/ssh channel is told the true current size.
244
+ resizeTerm(this.pid, term.cols, term.rows)
245
+ term.displayRaw = displayRaw
246
+ term.loadAddon(
247
+ new KeywordHighlighterAddon(keywords)
248
+ )
249
+ },
250
+
251
+ handleError ({ message: errorMessage, from, srcId }) {
252
+ this.setState({
253
+ terminalError: {
254
+ message: errorMessage || 'Failed to create terminal session',
255
+ from,
256
+ srcId
257
+ }
258
+ })
259
+ },
260
+
261
+ handleEditBookmarkFromError () {
262
+ const error = this.state.terminalError
263
+ if (!error || error.from !== 'bookmarks' || !error.srcId) {
264
+ return
265
+ }
266
+ const item = window.store.bookmarksMap?.get(error.srcId) ||
267
+ window.store.bookmarks?.find(d => d.id === error.srcId)
268
+ if (!item) {
269
+ return
270
+ }
271
+ window.store.openBookmarkEdit(item)
272
+ },
273
+
274
+ initSocketEvents () {
275
+ const originalSend = this.socket.send
276
+ this.socket.send = (data) => {
277
+ // Call original send first
278
+ originalSend.call(this.socket, data)
279
+
280
+ // Broadcast to other terminals
281
+ this.broadcastSocketData(data)
282
+ }
283
+ },
284
+
285
+ canReceiveBroadcast (termRef) {
286
+ return (
287
+ termRef.socket &&
288
+ termRef.props?.tab.pane === paneMap.terminal
289
+ )
290
+ },
291
+
292
+ broadcastSocketData (data) {
293
+ if (!this.isActiveTerminal() || !this.props.broadcastInput) {
294
+ return
295
+ }
296
+
297
+ window.refs.forEach((termRef, refId) => {
298
+ if (
299
+ refId !== this.id &&
300
+ refId.startsWith('term-') &&
301
+ this.canReceiveBroadcast(termRef)
302
+ ) {
303
+ termRef.socket.send(data)
304
+ }
305
+ })
306
+ },
307
+
308
+ onerrorSocket (err) {
309
+ console.error('onerrorSocket', err)
310
+ },
311
+
312
+ oncloseSocket () {
313
+ if (this.onClose || this.props.tab.enableSsh === false) {
314
+ return
315
+ }
316
+ this.setStatus(
317
+ statusMap.error
318
+ )
319
+ if (this.userTypeExit) {
320
+ return this.props.delTab(this.props.tab.id)
321
+ }
322
+ const { autoReconnectTerminal } = this.props.config
323
+ if (autoReconnectTerminal) {
324
+ this.scheduleAutoReconnect(3000)
325
+ }
326
+ },
327
+
328
+ scheduleAutoReconnect (delay = 3000) {
329
+ clearTimeout(this.timers.reconnectTimer)
330
+ clearInterval(this.timers.reconnectCountdown)
331
+ const seconds = Math.round(delay / 1000)
332
+ this.setState({ reconnectCountdown: seconds })
333
+ let remaining = seconds
334
+ this.timers.reconnectCountdown = setInterval(() => {
335
+ remaining -= 1
336
+ if (remaining <= 0) {
337
+ clearInterval(this.timers.reconnectCountdown)
338
+ this.timers.reconnectCountdown = null
339
+ }
340
+ this.setState({ reconnectCountdown: remaining <= 0 ? null : remaining })
341
+ }, 1000)
342
+ this.timers.reconnectTimer = setTimeout(() => {
343
+ clearInterval(this.timers.reconnectCountdown)
344
+ this.timers.reconnectCountdown = null
345
+ this.setState({ reconnectCountdown: null })
346
+ if (this.onClose || !this.props.config.autoReconnectTerminal) {
347
+ return
348
+ }
349
+ const reconnectCount = (this.props.tab.autoReConnect || 0) + 1
350
+ this.props.reloadTab({ ...this.props.tab, autoReConnect: reconnectCount })
351
+ }, delay)
352
+ },
353
+
354
+ handleCancelAutoReconnect () {
355
+ clearTimeout(this.timers.reconnectTimer)
356
+ clearInterval(this.timers.reconnectCountdown)
357
+ this.timers.reconnectTimer = null
358
+ this.timers.reconnectCountdown = null
359
+ this.setState({ reconnectCountdown: null })
360
+ },
361
+
362
+ handleCancel () {
363
+ const { id } = this.props.tab
364
+ this.props.delTab(id)
365
+ },
366
+
367
+ /**
368
+ * Manually triggered from the "exit gracefully" control in
369
+ * session-control.jsx (serial tabs only). Writes the configured key
370
+ * sequence (default \x01ky = Ctrl+A, k, y to kill a GNU screen window) to
371
+ * the socket, waits a bit so the remote end (e.g. a Bluetooth console
372
+ * adapter) can release cleanly, then closes the tab.
373
+ */
374
+ async exitGracefully () {
375
+ const { tab } = this.props
376
+ if (tab.type !== terminalSerialType) {
377
+ return
378
+ }
379
+ if (!this.onClose && this.attachAddon?._sendData) {
380
+ try {
381
+ const data = expandCloseSequence(tab.closeSequence || '\\x01ky')
382
+ if (data) {
383
+ this.attachAddon._sendData(data)
384
+ }
385
+ } catch (err) {
386
+ console.error('send close sequence failed', err)
387
+ }
388
+ const delay = Number(tab.closeSequenceDelay)
389
+ await new Promise(resolve => setTimeout(resolve, Number.isFinite(delay) && delay >= 0 ? delay : 500))
390
+ }
391
+ this.props.delTab(tab.id)
392
+ },
393
+
394
+ getReloadState () {
395
+ if (!shouldCaptureTerminalReloadState(this.props.tab, this.props.config)) {
396
+ return undefined
397
+ }
398
+ let screen = ''
399
+ try {
400
+ screen = this.serializeAddon?.serialize({
401
+ scrollback: this.props.config.scrollback,
402
+ excludeAltBuffer: true,
403
+ excludeModes: true
404
+ }) || ''
405
+ // Full-screen programs use the alternate buffer. Replaying that buffer
406
+ // as a terminal mode would trap the fresh shell in stale mouse/cursor
407
+ // state, so preserve its visible text as a safe normal-buffer snapshot.
408
+ const alternateScreen = getAlternateBufferSnapshot(this.term?.buffer.active)
409
+ if (alternateScreen) {
410
+ screen += `${screen ? '\r\n' : ''}${alternateScreen}`
411
+ }
412
+ } catch (e) {
413
+ console.warn('Failed to serialize terminal before reload', e)
414
+ }
415
+ const cwd = this.cmdAddon?.getCwd() || this.props.tab._reloadState?.cwd || ''
416
+ return createTerminalReloadState({ cwd, screen })
417
+ },
418
+
419
+ restoreReloadScreen (term) {
420
+ const screen = this.props.tab._reloadState?.screen
421
+ if (!screen) {
422
+ return Promise.resolve()
423
+ }
424
+ return new Promise(resolve => {
425
+ term.write(screen, () => {
426
+ // The framebuffer has been replayed; do not keep a potentially large
427
+ // duplicate string on the live tab. A later reload serializes afresh.
428
+ delete this.props.tab._reloadState.screen
429
+ resolve()
430
+ })
431
+ })
432
+ }
433
+ }
@@ -0,0 +1,224 @@
1
+ import { debounce } from 'lodash-es'
2
+ import { refsStatic } from '../../common/ref.js'
3
+
4
+ /**
5
+ * Command suggestions: reading the current input straight from the terminal
6
+ * buffer, positioning the dropdown at the cursor, and the password prompt
7
+ * variant triggered by the shell integration OSC sequences.
8
+ */
9
+ export const suggestionsMixin = {
10
+ getCursorPosition () {
11
+ if (!this.term) return null
12
+
13
+ // Get the active buffer and cursor position
14
+ const buffer = this.term.buffer.active
15
+ const cursorRow = buffer.cursorY
16
+ const cursorCol = buffer.cursorX
17
+
18
+ // Get dimensions from term element
19
+ const termElement = this.term.element
20
+ if (!termElement) return null
21
+
22
+ // Get the exact position of the terminal element
23
+ const termRect = termElement.getBoundingClientRect()
24
+
25
+ // Calculate cell dimensions
26
+ const cellWidth = termRect.width / this.term.cols
27
+ const cellHeight = termRect.height / this.term.rows
28
+
29
+ // Calculate absolute position relative to terminal element
30
+ const left = Math.floor(termRect.left + (cursorCol * cellWidth))
31
+ const top = Math.floor(termRect.top + ((cursorRow + 1) * cellHeight))
32
+
33
+ return {
34
+ cellWidth,
35
+ cellHeight,
36
+ left,
37
+ top
38
+ }
39
+ },
40
+
41
+ closeSuggestions () {
42
+ refsStatic
43
+ .get('terminal-suggestions')
44
+ ?.closeSuggestions()
45
+ },
46
+
47
+ openSuggestions (cursorPos, data) {
48
+ refsStatic
49
+ .get('terminal-suggestions')
50
+ ?.openSuggestions(cursorPos, data)
51
+ },
52
+
53
+ /**
54
+ * Read current input directly from terminal buffer
55
+ * This is more reliable than tracking character-by-character
56
+ */
57
+ getCurrentInput () {
58
+ if (!this.term) return ''
59
+
60
+ const buffer = this.term.buffer.active
61
+ const cursorY = buffer.cursorY
62
+ const cursorX = buffer.cursorX
63
+
64
+ // Get the current line from buffer (baseY + cursorY gives absolute position)
65
+ const absoluteY = buffer.baseY + cursorY
66
+ const line = buffer.getLine(absoluteY)
67
+ if (!line) return ''
68
+
69
+ // Get text from start of line up to cursor position
70
+ const lineText = line.translateToString(true, 0, cursorX)
71
+
72
+ // Try to extract command after prompt
73
+ // Common prompt endings with trailing space
74
+ const promptEndings = ['$ ', '# ', '> ', '% ', '] ', ') ']
75
+
76
+ let commandStart = 0
77
+ for (const ending of promptEndings) {
78
+ const idx = lineText.lastIndexOf(ending)
79
+ if (idx !== -1 && idx + ending.length > commandStart) {
80
+ commandStart = idx + ending.length
81
+ }
82
+ }
83
+
84
+ return lineText.slice(commandStart)
85
+ },
86
+
87
+ setCurrentInput (value) {
88
+ this.currentInput = value
89
+ },
90
+
91
+ /**
92
+ * Handle special input events for command history tracking
93
+ * The actual input reading is done via getCurrentInput from buffer
94
+ */
95
+ handleInputEvent (d) {
96
+ // Handle Enter - add command to history
97
+ if (d === '\r' || d === '\n') {
98
+ const currentCmd = this.getCurrentInput()
99
+ if (currentCmd && currentCmd.trim() && this.shouldUseManualHistory()) {
100
+ window.store.addCmdHistory(currentCmd.trim())
101
+ }
102
+ if (currentCmd && currentCmd.trim() === 'exit') {
103
+ this.userTypeExit = true
104
+ }
105
+ this.closeSuggestions()
106
+ }
107
+ },
108
+
109
+ onData (d) {
110
+ this.handleInputEvent(d)
111
+ // Skip normal suggestion logic when in password mode
112
+ const suggestions = refsStatic.get('terminal-suggestions')
113
+ if (suggestions?.state?.passwordMode) {
114
+ if (d === '\r' || d === '\n') {
115
+ this.closeSuggestions()
116
+ }
117
+ return
118
+ }
119
+ if (this.props.config.showCmdSuggestions) {
120
+ if (d === '\r' || d === '\n') {
121
+ this.closeSuggestions()
122
+ return
123
+ }
124
+ // Debounce the suggestion opening to avoid expensive work
125
+ // (buffer read + getBoundingClientRect + React re-render) on every keystroke
126
+ this._debouncedOpenSuggestions()
127
+ } else {
128
+ this.closeSuggestions()
129
+ }
130
+ },
131
+
132
+ /**
133
+ * Called by AttachAddonCustom after data is written to the terminal buffer.
134
+ * This fires after server echo arrives, so getCurrentInput() reflects the
135
+ * latest state. We trigger a debounced suggestion refresh so the dropdown
136
+ * updates correctly after backspace, delete, and other edits that rely on
137
+ * server-side echo to update the buffer.
138
+ */
139
+ onTerminalWrite () {
140
+ if (!this.props.config.showCmdSuggestions) {
141
+ return
142
+ }
143
+ const suggestions = refsStatic.get('terminal-suggestions')
144
+ if (suggestions?.state?.showSuggestions && !suggestions?.state?.passwordMode) {
145
+ this._debouncedOpenSuggestions()
146
+ }
147
+ },
148
+
149
+ // One debounced runner per terminal, so a keystroke does not trigger a
150
+ // buffer read + layout + re-render for every character.
151
+ _debouncedOpenSuggestions () {
152
+ if (!this._openSuggestionsDebounced) {
153
+ this._openSuggestionsDebounced = debounce(() => {
154
+ const data = this.getCurrentInput()
155
+ if (!data) {
156
+ this.closeSuggestions()
157
+ return
158
+ }
159
+ const cursorPos = this.getCursorPosition()
160
+ this.openSuggestions(cursorPos, data)
161
+ }, 80)
162
+ }
163
+ this._openSuggestionsDebounced()
164
+ },
165
+
166
+ onPasswordPromptDetected () {
167
+ window.store.notifyTabPasswordPrompt(this.props.tab.id)
168
+ if (!this.props.config.showCmdSuggestions) {
169
+ return
170
+ }
171
+ const cursorPos = this.getCursorPosition()
172
+ if (cursorPos) {
173
+ refsStatic
174
+ .get('terminal-suggestions')
175
+ ?.openPasswordSuggestions(cursorPos)
176
+ }
177
+ },
178
+
179
+ onPasswordPromptCancelled () {
180
+ window.store.clearTabPasswordPrompt(this.props.tab.id)
181
+ const suggestions = refsStatic.get('terminal-suggestions')
182
+ if (suggestions?.state?.passwordMode) {
183
+ suggestions.closeSuggestions()
184
+ }
185
+ },
186
+
187
+ getCmd () {
188
+ return this.cmdAddon.getCurrentCommand()
189
+ },
190
+
191
+ getCwd () {
192
+ // Use shell integration CWD if available
193
+ if (this.cmdAddon && this.cmdAddon.hasShellIntegration()) {
194
+ const cwd = this.cmdAddon.getCwd()
195
+ if (cwd) {
196
+ this.setCwd(cwd)
197
+ return cwd
198
+ }
199
+ }
200
+ // Fallback: no longer needed with shell integration
201
+ return ''
202
+ },
203
+
204
+ setCwd (cwd) {
205
+ this.props.setCwd(cwd, this.state.id)
206
+ },
207
+
208
+ shouldUseManualHistory () {
209
+ return !this.cmdAddon || !this.cmdAddon.hasShellIntegration()
210
+ },
211
+
212
+ parse (rawText) {
213
+ let result = ''
214
+ const len = rawText.length
215
+ for (let i = 0; i < len; i++) {
216
+ if (rawText[i] === '\b') {
217
+ result = result.slice(0, -1)
218
+ } else {
219
+ result += rawText[i]
220
+ }
221
+ }
222
+ return result
223
+ }
224
+ }