@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.
Files changed (149) hide show
  1. package/README.md +246 -12
  2. package/client/common/choose-save-folder.js +21 -2
  3. package/client/common/constants.js +23 -6
  4. package/client/common/create-title.jsx +6 -0
  5. package/client/common/default-setting.js +14 -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/bookmark-form/index.jsx +10 -8
  29. package/client/components/common/item-filter.jsx +76 -0
  30. package/client/components/common/item-filter.styl +54 -0
  31. package/client/components/common/lazy-boundary.jsx +34 -0
  32. package/client/components/common/opacity.jsx +3 -1
  33. package/client/components/footer/footer-entry.jsx +35 -5
  34. package/client/components/footer/footer.styl +8 -1
  35. package/client/components/layout/layout.jsx +23 -31
  36. package/client/components/layout/pixed.js +3 -0
  37. package/client/components/main/main.jsx +80 -28
  38. package/client/components/main/term-fullscreen.styl +9 -3
  39. package/client/components/quick-commands/qm.styl +3 -2
  40. package/client/components/rdp/file-transfer.js +4 -4
  41. package/client/components/remote-monitor/monitor-details.jsx +458 -0
  42. package/client/components/remote-monitor/monitor-details.styl +87 -0
  43. package/client/components/remote-monitor/monitor-model.js +764 -0
  44. package/client/components/remote-monitor/remote-monitor-bar-entry.jsx +18 -0
  45. package/client/components/remote-monitor/remote-monitor-bar.jsx +344 -0
  46. package/client/components/remote-monitor/remote-monitor-bar.styl +106 -0
  47. package/client/components/remote-monitor/session-monitor.js +361 -0
  48. package/client/components/remote-monitor/use-monitor-details.js +10 -0
  49. package/client/components/remote-monitor/visibility.js +18 -0
  50. package/client/components/session/session-control.jsx +29 -2
  51. package/client/components/session/session.jsx +40 -18
  52. package/client/components/session/sessions.jsx +5 -2
  53. package/client/components/setting-panel/col.jsx +32 -6
  54. package/client/components/setting-panel/list.styl +12 -0
  55. package/client/components/setting-panel/setting-common.jsx +25 -56
  56. package/client/components/setting-panel/setting-mobile-breadcrumb.jsx +29 -0
  57. package/client/components/setting-panel/setting-modal.jsx +64 -44
  58. package/client/components/setting-panel/setting-terminal.jsx +7 -2
  59. package/client/components/setting-panel/setting-wrap.jsx +0 -3
  60. package/client/components/setting-panel/setting-wrap.styl +80 -11
  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/side-panel-r/side-panel-r.jsx +2 -2
  67. package/client/components/sidebar/index.jsx +28 -21
  68. package/client/components/sidebar/left-sidebar-icons.jsx +1 -1
  69. package/client/components/sidebar/sidebar.styl +16 -0
  70. package/client/components/sys-menu/menu-btn.jsx +30 -1
  71. package/client/components/tabs/index.jsx +2 -2
  72. package/client/components/tabs/tab-title.jsx +3 -2
  73. package/client/components/tabs/tab.jsx +5 -1
  74. package/client/components/tabs/tabs.styl +4 -0
  75. package/client/components/terminal/attach-addon-custom.js +295 -49
  76. package/client/components/terminal/automation/index.js +64 -0
  77. package/client/components/terminal/automation/keys.js +49 -0
  78. package/client/components/terminal/automation/stream-matcher.js +82 -0
  79. package/client/components/terminal/automation/strip-ansi.js +19 -0
  80. package/client/components/terminal/automation/trigger-engine.js +233 -0
  81. package/client/components/terminal/automation/trigger-presets.js +110 -0
  82. package/client/components/terminal/command-tracker-addon.js +31 -1
  83. package/client/components/terminal/mixins/index.js +27 -0
  84. package/client/components/terminal/mixins/term-attach.js +123 -0
  85. package/client/components/terminal/mixins/term-config.jsx +84 -0
  86. package/client/components/terminal/mixins/term-content.js +88 -0
  87. package/client/components/terminal/mixins/term-context-menu.jsx +291 -0
  88. package/client/components/terminal/mixins/term-file-drop.js +149 -0
  89. package/client/components/terminal/mixins/term-init.js +122 -0
  90. package/client/components/terminal/mixins/term-log.jsx +96 -0
  91. package/client/components/terminal/mixins/term-resize.js +87 -0
  92. package/client/components/terminal/mixins/term-socket.js +433 -0
  93. package/client/components/terminal/mixins/term-suggestions.js +224 -0
  94. package/client/components/terminal/mixins/term-theme.js +190 -0
  95. package/client/components/terminal/mixins/term-touch.js +136 -0
  96. package/client/components/terminal/shell-detect.js +37 -0
  97. package/client/components/terminal/shell.js +0 -28
  98. package/client/components/terminal/shortcut-bar-entry.jsx +32 -27
  99. package/client/components/terminal/shortcut-bar.jsx +4 -6
  100. package/client/components/terminal/shortcut-bar.styl +4 -0
  101. package/client/components/terminal/ssh-reload-state.js +56 -0
  102. package/client/components/terminal/startup-queue.js +379 -0
  103. package/client/components/terminal/terminal-interactive.jsx +32 -0
  104. package/client/components/terminal/terminal.jsx +102 -2007
  105. package/client/components/terminal/transfer-client-base.js +5 -29
  106. package/client/components/terminal/trzsz-client.js +1 -3
  107. package/client/components/terminal/xmodem-client.js +1 -3
  108. package/client/components/terminal/xterm-loader.js +7 -0
  109. package/client/components/terminal/zmodem-client.js +1 -3
  110. package/client/components/terminal-info/base.jsx +18 -70
  111. package/client/components/terminal-info/terminal-info-entry.jsx +13 -5
  112. package/client/components/terminal-info/terminal-info.jsx +50 -59
  113. package/client/components/terminal-info/terminal-info.styl +6 -49
  114. package/client/components/text-editor/open-text-editor.js +30 -0
  115. package/client/components/text-editor/text-editor-entry.jsx +17 -6
  116. package/client/components/text-editor/text-editor.jsx +14 -0
  117. package/client/components/theme/theme-form.jsx +22 -0
  118. package/client/components/tree-list/bookmark-transport.jsx +7 -31
  119. package/client/components/tree-list/tree-list.styl +13 -0
  120. package/client/components/triggers/trigger-editor.jsx +246 -0
  121. package/client/components/triggers/trigger-form.jsx +200 -0
  122. package/client/components/triggers/trigger-session-modal.jsx +199 -0
  123. package/client/components/triggers/trigger-setting-list.jsx +89 -0
  124. package/client/components/triggers/trigger-transport.jsx +60 -0
  125. package/client/components/triggers/triggers-field.jsx +19 -0
  126. package/client/components/widgets/widgets-list.jsx +11 -1
  127. package/client/css/mobile.styl +9 -1
  128. package/client/store/common.js +12 -1
  129. package/client/store/init-state.js +14 -0
  130. package/client/store/mcp-handler.js +158 -30
  131. package/client/store/setting.js +20 -0
  132. package/client/store/sidebar.js +12 -0
  133. package/client/store/store.js +2 -0
  134. package/client/store/sync.js +45 -9
  135. package/client/store/system-menu.js +2 -0
  136. package/client/store/tab.js +41 -3
  137. package/client/store/trigger.js +118 -0
  138. package/client/store/watch.js +13 -5
  139. package/client/views/index.pug +3 -0
  140. package/package.json +1 -1
  141. package/client/components/terminal-info/activity.jsx +0 -57
  142. package/client/components/terminal-info/data-cols-parser.jsx +0 -54
  143. package/client/components/terminal-info/disk.jsx +0 -45
  144. package/client/components/terminal-info/format-disks.js +0 -45
  145. package/client/components/terminal-info/network.jsx +0 -121
  146. package/client/components/terminal-info/resource.jsx +0 -99
  147. package/client/components/terminal-info/run-cmd.jsx +0 -263
  148. package/client/components/terminal-info/sys-info.jsx +0 -56
  149. package/client/components/terminal-info/up.jsx +0 -17
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Trigger list for the settings panel (left col), follows quick commands list.
3
+ */
4
+ import List from '../setting-panel/list'
5
+ import { PlusOutlined, CopyOutlined } from '@ant-design/icons'
6
+ import classnames from 'classnames'
7
+ import highlight from '../common/highlight'
8
+ import deepCopy from 'json-deep-copy'
9
+ import uid from '../../common/uid'
10
+ import TriggerTransport from './trigger-transport.jsx'
11
+
12
+ const e = window.translate
13
+
14
+ export default class TriggerSettingList extends List {
15
+ del = (item, e) => {
16
+ e.stopPropagation()
17
+ this.props.store.delTrigger(item)
18
+ }
19
+
20
+ onClickItem = (item) => {
21
+ this.props.onClickItem(item)
22
+ }
23
+
24
+ duplicateItem = (e, item) => {
25
+ e.stopPropagation()
26
+ const copy = deepCopy(item)
27
+ copy.id = uid()
28
+ copy.name = (item.name || 'Unnamed') + ' (copy)'
29
+ window.store.addTrigger(copy)
30
+ }
31
+
32
+ renderDuplicateBtn = (item) => {
33
+ if (!item.id) {
34
+ return null
35
+ }
36
+ return (
37
+ <CopyOutlined
38
+ title={e('duplicate')}
39
+ className='pointer list-item-duplicate'
40
+ onClick={(e) => this.duplicateItem(e, item)}
41
+ />
42
+ )
43
+ }
44
+
45
+ renderItem = (item) => {
46
+ if (!item) {
47
+ return null
48
+ }
49
+ const { activeItemId } = this.props
50
+ const { name, id } = item
51
+ const cls = classnames(
52
+ 'item-list-unit',
53
+ {
54
+ active: activeItemId === id
55
+ }
56
+ )
57
+ let title = name || 'Unnamed'
58
+ title = highlight(
59
+ title,
60
+ this.state.keyword
61
+ )
62
+ return (
63
+ <div
64
+ key={id}
65
+ className={cls}
66
+ onClick={() => this.onClickItem(item)}
67
+ >
68
+ <div className='elli pd1y pd2x' title={name}>
69
+ {
70
+ !id
71
+ ? <PlusOutlined className='mg1r' />
72
+ : null
73
+ }
74
+ {title}
75
+ </div>
76
+ {this.renderDuplicateBtn(item)}
77
+ {this.renderDelBtn(item)}
78
+ </div>
79
+ )
80
+ }
81
+
82
+ renderTransport = () => {
83
+ return (
84
+ <TriggerTransport
85
+ store={this.props.store}
86
+ />
87
+ )
88
+ }
89
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Trigger import/export, follows quick command transport.
3
+ */
4
+ import BookmarkTransport from '../tree-list/bookmark-transport'
5
+ import download from '../../common/download'
6
+ import time from '../../common/time'
7
+ import { runImportTask } from '../../common/import-task'
8
+ import { normalizeTrigger } from '../../store/trigger'
9
+ import { validateTriggers } from '../terminal/automation/trigger-engine'
10
+
11
+ const e = window.translate
12
+
13
+ export default class TriggerTransport extends BookmarkTransport {
14
+ name = 'triggers'
15
+
16
+ beforeUpload = async (file) => {
17
+ const { store } = this.props
18
+ const txt = file.fileContent !== undefined
19
+ ? file.fileContent
20
+ : await window.fs.readFile(file.filePath)
21
+ try {
22
+ const arr = JSON.parse(txt).map(normalizeTrigger)
23
+ const errors = validateTriggers(arr)
24
+ if (errors.length) {
25
+ throw new Error(errors[0])
26
+ }
27
+ const state = store[this.name]
28
+ const existing = new Set(state.map(v => v.id))
29
+ const fresh = arr.filter(t => !existing.has(t.id))
30
+ if (!fresh.length) {
31
+ return false
32
+ }
33
+ await runImportTask({
34
+ title: e('import'),
35
+ batch: 200,
36
+ stopWatchers: [this.name],
37
+ steps: [
38
+ {
39
+ label: e('triggers'),
40
+ items: fresh,
41
+ process: (chunk) => {
42
+ state.push(...chunk)
43
+ }
44
+ }
45
+ ]
46
+ })
47
+ store.refreshAllTriggers()
48
+ } catch (err) {
49
+ store.onError(err)
50
+ }
51
+ return false
52
+ }
53
+
54
+ handleDownload = () => {
55
+ const { store } = this.props
56
+ const txt = JSON.stringify(store[this.name] || [], null, 2)
57
+ const stamp = time(undefined, 'YYYY-MM-DD-HH-mm-ss')
58
+ download('electerm-' + this.name + '-' + stamp + '.json', txt)
59
+ }
60
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Bookmark form wrapper for triggers.
3
+ * Works as an antd Form field (value/onChange injected by Form.Item).
4
+ */
5
+ import { Form } from 'antd'
6
+ import TriggerEditor from './trigger-editor.jsx'
7
+
8
+ const e = window.translate
9
+
10
+ export default function TriggersField () {
11
+ return (
12
+ <Form.Item
13
+ name='triggers'
14
+ label={e('triggers')}
15
+ >
16
+ <TriggerEditor />
17
+ </Form.Item>
18
+ )
19
+ }
@@ -9,13 +9,16 @@ import {
9
9
  import WidgetInstances from './widget-instances'
10
10
  import classnames from 'classnames'
11
11
  import highlight from '../common/highlight'
12
+ import {
13
+ settingPanelMobileBreakpoint
14
+ } from '../../common/constants'
12
15
  import {
13
16
  auto
14
17
  } from 'manate/react'
15
18
 
16
19
  const e = window.translate
17
20
 
18
- export default auto(function WidgetsList ({ activeItemId, store }) {
21
+ export default auto(function WidgetsList ({ activeItemId, store, onClickItem }) {
19
22
  const { widgetInstances } = store
20
23
  const [tab, setTab] = useState('widgets') // or instances
21
24
  const [widgets, setWidgets] = useState([])
@@ -50,7 +53,14 @@ export default auto(function WidgetsList ({ activeItemId, store }) {
50
53
  }
51
54
 
52
55
  const onClickWidget = (widget) => {
56
+ if (onClickItem) {
57
+ return onClickItem(widget)
58
+ }
53
59
  window.store.setSettingItem(widget)
60
+ // narrow panels drill into the right col content after picking a widget
61
+ if (window.store.innerWidth <= settingPanelMobileBreakpoint) {
62
+ window.store.settingMobileView = 'content'
63
+ }
54
64
  }
55
65
 
56
66
  const renderWidgetItem = (widget, i) => {
@@ -3,6 +3,14 @@
3
3
  // Breakpoint: 600px (see mobileBreakpoint in constants.js)
4
4
  // ---------------------------------------------------------------------------
5
5
  .is-mobile
6
+ // Keep every terminal footer row on the same positioning reference as the
7
+ // touch shortcut bar. Mobile browsers change the visual viewport when their
8
+ // chrome or soft keyboard opens; mixing absolute footer rows with the fixed
9
+ // shortcut row makes the monitor/shortcut stack detach from the footer.
10
+ .main-footer,
11
+ .remote-monitor-bar
12
+ position fixed
13
+
6
14
  // ---- 1. Tabs: show only current tab + dropdown, close icon always visible
7
15
  .tabs.mobile-tabs
8
16
  display flex
@@ -81,7 +89,7 @@
81
89
  position fixed
82
90
  left 0
83
91
  right 0
84
- bottom 36px
92
+ bottom calc(var(--footer-stack-height, 36px) + var(--shortcut-bar-h, 0px) + var(--shortcut-bar-kb-offset, 0px))
85
93
  z-index 200
86
94
  background var(--main)
87
95
  padding 8px 10px
@@ -276,6 +276,14 @@ export default Store => {
276
276
  const { store } = window
277
277
  store.rightPanelVisible = true
278
278
  store.rightPanelTab = 'ai'
279
+ // Ask for AI config right away when AI is not configured yet.
280
+ // This has to happen here (a plain user action) instead of from
281
+ // AIChat's mount effect: a write issued there lands in the very commit
282
+ // that opened the panel, and that update is dropped, so the config
283
+ // modal never showed up on first open.
284
+ if (store.aiConfigMissing()) {
285
+ store.toggleAIConfig()
286
+ }
279
287
  }
280
288
 
281
289
  Store.prototype.explainWithAi = function (txt) {
@@ -377,7 +385,10 @@ export default Store => {
377
385
  content: 'Understood. I will use this context as we continue.'
378
386
  })
379
387
  } else {
380
- messages.push({ role: 'user', content: entry.prompt })
388
+ messages.push({
389
+ role: 'user',
390
+ content: entry.promptWithAttachments || entry.prompt
391
+ })
381
392
  if (entry.response) {
382
393
  messages.push({ role: 'assistant', content: entry.response })
383
394
  }
@@ -134,6 +134,8 @@ export default () => {
134
134
  settingTab: settingMap.bookmarks, // setting tab
135
135
  bookmarkId: undefined,
136
136
  showModal: 0,
137
+ // mobile settings view: 'menu' (left col list, default) or 'content' (right col)
138
+ settingMobileView: 'menu',
137
139
 
138
140
  // setting sync related
139
141
  autoSyncReady: false,
@@ -165,6 +167,10 @@ export default () => {
165
167
  pinnedQuickCommandBar: ls.getItem(pinnedQuickCommandBarKey) === 'y',
166
168
  qmSortByFrequency: ls.getItem(qmSortByFrequencyKey) === 'yes',
167
169
 
170
+ // declarative auto triggers (global; bookmark/session ones live on the tab)
171
+ triggers: [],
172
+ triggerSessionOpen: false,
173
+
168
174
  // touch-only shortcut bar (see components/terminal/shortcut-bar-entry.jsx)
169
175
  shortcutBarVisible: false,
170
176
  // px the bar is lifted above a page-overlaying system input panel
@@ -187,6 +193,9 @@ export default () => {
187
193
 
188
194
  // editor
189
195
  showEditor: false,
196
+ // set to true to mount the text editor component,
197
+ // so its code is only loaded when user really needs it
198
+ textEditorRequested: false,
190
199
 
191
200
  // file/info modal
192
201
  showFileModal: false,
@@ -213,6 +222,11 @@ export default () => {
213
222
  isMaximized: window.pre.runSync('isMaximized'),
214
223
  hasNodePty: window.pre.runSync('nodePtyCheck'),
215
224
  isMobile: window.innerWidth <= mobileBreakpoint,
225
+ // reflects the input device actually in use, not screen capability — a
226
+ // capability probe only proves the screen can be touched, not whether the
227
+ // user operates it by touch. Seeded false and left to main.jsx, which sets
228
+ // mouse → false / touch → true on the first real pointer event.
229
+ isTouchDevice: false,
216
230
  fullscreen: false,
217
231
  tabsHeight: 36,
218
232
 
@@ -35,6 +35,36 @@ const dangerousTabProps = [
35
35
  'interactiveValues'
36
36
  ]
37
37
 
38
+ // Tab/session UI state keys that must never be set by MCP/AI callers.
39
+ // A crafted `batch` (missing, string, or out-of-range) used to crash the
40
+ // Sessions render (`sizes[batch]` undefined -> cannot destructure height).
41
+ // `addTab` now clamps batch too, but strip here as first line of defense.
42
+ const tabInternalProps = [
43
+ 'batch',
44
+ 'id',
45
+ 'status',
46
+ 'pane',
47
+ 'tabCount',
48
+ 'from',
49
+ 'srcId',
50
+ 'sftpCreated',
51
+ 'isTransporting',
52
+ 'mcpStatus',
53
+ 'activeTabId',
54
+ 'sshSftpSplitView',
55
+ 'sshTunnelResults',
56
+ 'displayRaw',
57
+ 'autoReConnect',
58
+ '_reloadState',
59
+ 'isPinned'
60
+ ]
61
+
62
+ function stripTabInternalProps (obj) {
63
+ return Object.fromEntries(
64
+ Object.entries(obj).filter(([key]) => !tabInternalProps.includes(key))
65
+ )
66
+ }
67
+
38
68
  // Strip dangerous props from an object, returning a safe copy.
39
69
  function stripDangerousTabProps (obj) {
40
70
  return Object.fromEntries(
@@ -42,15 +72,64 @@ function stripDangerousTabProps (obj) {
42
72
  )
43
73
  }
44
74
 
75
+ // ==================== Tab MCP status tag helpers ====================
76
+ // Reflect MCP activity on the tab title via tab.mcpStatus
77
+ // (rendered as [running]/[waiting]/[done]/[error] by create-title.jsx).
78
+
79
+ const mcpStatusTimers = new Map()
80
+
81
+ function findMcpTab (tabId) {
82
+ return window.store.tabs.find(t => t.id === tabId)
83
+ }
84
+
85
+ function setTabMcpStatus (tabId, status) {
86
+ const tab = findMcpTab(tabId)
87
+ if (!tab) {
88
+ return
89
+ }
90
+ const timer = mcpStatusTimers.get(tabId)
91
+ if (timer) {
92
+ clearTimeout(timer)
93
+ mcpStatusTimers.delete(tabId)
94
+ }
95
+ tab.mcpStatus = status
96
+ }
97
+
98
+ // Set a status that auto-clears after clearDelay ms (clearDelay 0 = keep
99
+ // until the next status change). The clear only applies if the status
100
+ // has not been replaced in the meantime.
101
+ function setTabMcpStatusAuto (tabId, status, clearDelay = 6000) {
102
+ setTabMcpStatus(tabId, status)
103
+ if (!clearDelay) {
104
+ return
105
+ }
106
+ mcpStatusTimers.set(tabId, setTimeout(() => {
107
+ mcpStatusTimers.delete(tabId)
108
+ const tab = findMcpTab(tabId)
109
+ if (tab && tab.mcpStatus === status) {
110
+ tab.mcpStatus = ''
111
+ }
112
+ }, clearDelay))
113
+ }
114
+
45
115
  export default Store => {
46
116
  // Initialize MCP handler - called when MCP widget is started
47
117
  Store.prototype.initMcpHandler = function () {
118
+ if (window._mcpHandlerInitialized) {
119
+ return
120
+ }
121
+ window._mcpHandlerInitialized = true
48
122
  const { ipcOnEvent } = window.pre
49
123
  // Listen for MCP requests from main process
50
124
  ipcOnEvent('mcp-request', (event, request) => {
51
- const { requestId, action, data } = request
52
- if (action === 'tool-call') {
53
- window.store.handleMcpToolCall(requestId, data.toolName, data.args)
125
+ try {
126
+ const { requestId, action, data } = request || {}
127
+ if (action === 'tool-call') {
128
+ window.store.handleMcpToolCall(requestId, data && data.toolName, data && data.args)
129
+ }
130
+ } catch (err) {
131
+ // Never let a malformed IPC payload break the UI thread
132
+ console.error('mcp-request dispatch error', err)
54
133
  }
55
134
  })
56
135
  }
@@ -58,6 +137,7 @@ export default Store => {
58
137
  // Handle individual tool calls
59
138
  Store.prototype.handleMcpToolCall = async function (requestId, toolName, args) {
60
139
  const { store } = window
140
+ args = args || {}
61
141
 
62
142
  try {
63
143
  let result
@@ -266,9 +346,10 @@ export default Store => {
266
346
 
267
347
  Store.prototype.mcpAddBookmark = async function (args) {
268
348
  const { store } = window
349
+ const safeArgs = stripTabInternalProps(stripDangerousTabProps({ ...(args || {}) }))
269
350
  const bookmark = fixBookmarkData({
270
- id: uid(),
271
- ...args
351
+ ...safeArgs,
352
+ id: uid()
272
353
  })
273
354
 
274
355
  const { valid, errors } = validateBookmarkData(bookmark)
@@ -287,14 +368,16 @@ export default Store => {
287
368
 
288
369
  Store.prototype.mcpEditBookmark = function (args) {
289
370
  const { store } = window
290
- const { id, updates } = args
371
+ const { id, updates } = args || {}
291
372
 
292
373
  const bookmark = store.bookmarks.find(b => b.id === id)
293
374
  if (!bookmark) {
294
375
  throw new Error(`Bookmark not found: ${id}`)
295
376
  }
296
377
 
297
- store.editItem(id, updates, settingMap.bookmarks)
378
+ const safeUpdates = stripTabInternalProps(stripDangerousTabProps({ ...(updates || {}) }))
379
+ delete safeUpdates.id
380
+ store.editItem(id, safeUpdates, settingMap.bookmarks)
298
381
 
299
382
  return {
300
383
  success: true,
@@ -335,12 +418,23 @@ export default Store => {
335
418
 
336
419
  Store.prototype.mcpAddBookmarkGroup = async function (args) {
337
420
  const { store } = window
421
+ const title = (args || {}).title
422
+ if (!title || typeof title !== 'string') {
423
+ throw new Error('title is required')
424
+ }
425
+ const parentId = (args || {}).parentId
426
+ if (parentId) {
427
+ const parent = (store.bookmarkGroups || []).find(g => g.id === parentId)
428
+ if (!parent) {
429
+ throw new Error(`Parent bookmark group not found: ${parentId}`)
430
+ }
431
+ }
338
432
  const group = {
339
433
  id: uid(),
340
- title: args.title,
434
+ title,
341
435
  bookmarkIds: [],
342
436
  bookmarkGroupIds: [],
343
- level: args.parentId ? 2 : 1
437
+ level: parentId ? 2 : 1
344
438
  }
345
439
 
346
440
  await store.addBookmarkGroup(group)
@@ -418,6 +512,8 @@ export default Store => {
418
512
  host: t.host,
419
513
  type: t.type || 'local',
420
514
  status: t.status,
515
+ from: t.from,
516
+ mcpStatus: t.mcpStatus || '',
421
517
  isTransporting: t.isTransporting,
422
518
  onData: refsTabs.get('tab-' + t.id)?.state.terminalOnData,
423
519
  batch: t.batch
@@ -511,6 +607,10 @@ export default Store => {
511
607
  const { store } = window
512
608
  store.addTab()
513
609
  const newTabId = store.activeTabId
610
+ const tab = store.tabs.find(t => t.id === newTabId)
611
+ if (tab) {
612
+ tab.from = 'mcp'
613
+ }
514
614
 
515
615
  return {
516
616
  success: true,
@@ -524,7 +624,10 @@ export default Store => {
524
624
  // Strip dangerous execution-related props before any processing.
525
625
  // This prevents MCP/AI callers from injecting execLinux/execLinuxArgs,
526
626
  // setEnv, runScripts, etc. to spawn arbitrary local processes.
527
- const safeArgs = stripDangerousTabProps({ ...args })
627
+ // Also strip tab UI-state props (notably `batch`): a crafted batch
628
+ // used to crash Sessions via `sizes[batch]` undefined destructuring.
629
+ // Batch is always assigned by addTab from the current layout.
630
+ const safeArgs = stripTabInternalProps(stripDangerousTabProps({ ...(args || {}) }))
528
631
  const data = fixBookmarkData(safeArgs)
529
632
 
530
633
  const { valid, errors } = validateBookmarkData(data)
@@ -537,6 +640,7 @@ export default Store => {
537
640
  from: 'mcp',
538
641
  ...newTerm(true, true)
539
642
  }
643
+ delete tab.batch
540
644
 
541
645
  store.addTab(tab)
542
646
  const newTabId = store.activeTabId
@@ -565,6 +669,8 @@ export default Store => {
565
669
  }
566
670
 
567
671
  store.runQuickCommand(command, args.inputOnly || false, tabId)
672
+ // Show [running] on the tab; auto-clears if the caller never waits.
673
+ setTabMcpStatusAuto(tabId, 'running', 15000)
568
674
 
569
675
  return {
570
676
  success: true,
@@ -652,6 +758,7 @@ export default Store => {
652
758
  throw new Error('No active terminal')
653
759
  }
654
760
 
761
+ setTabMcpStatus(tabId, 'waiting')
655
762
  const start = Date.now()
656
763
 
657
764
  // Brief initial wait so the command has time to start producing output
@@ -684,6 +791,7 @@ export default Store => {
684
791
  const onData = tabRef?.state.terminalOnData
685
792
  if (!onData) {
686
793
  const { output, lineCount } = collectOutput()
794
+ setTabMcpStatusAuto(tabId, 'done')
687
795
  return {
688
796
  tabId,
689
797
  elapsed: Date.now() - start,
@@ -695,7 +803,9 @@ export default Store => {
695
803
  await new Promise(resolve => setTimeout(resolve, pollInterval))
696
804
  }
697
805
 
698
- // Timeout reached — return whatever is currently in the buffer
806
+ // Timeout reached — return whatever is currently in the buffer.
807
+ // The command is still producing output, so keep showing [running].
808
+ setTabMcpStatus(tabId, 'running')
699
809
  const { output, lineCount } = collectOutput()
700
810
  return {
701
811
  tabId,
@@ -901,29 +1011,41 @@ export default Store => {
901
1011
  let result = null
902
1012
  let mode = 'pty'
903
1013
 
904
- if (requestedMode === 'exec' && isSsh) {
905
- try {
906
- const r = await execCmd(tabId, command, timeoutMs)
907
- result = {
908
- stdout: r.stdout || '',
909
- stderr: r.stderr || '',
910
- stderrMerged: false,
911
- exitCode: typeof r.exitCode === 'number' ? r.exitCode : null,
912
- durationMs: Date.now() - start,
913
- timedOut: !!r.timedOut
914
- }
915
- mode = 'exec'
916
- } catch (e) {
917
- // Exec channel unavailable (e.g. connection dropped) — fall back to PTY
918
- if (!/not supported/i.test(e.message || '')) {
919
- throw e
1014
+ setTabMcpStatus(tabId, 'running')
1015
+ try {
1016
+ if (requestedMode === 'exec' && isSsh) {
1017
+ try {
1018
+ const r = await execCmd(tabId, command, timeoutMs)
1019
+ result = {
1020
+ stdout: r.stdout || '',
1021
+ stderr: r.stderr || '',
1022
+ stderrMerged: false,
1023
+ exitCode: typeof r.exitCode === 'number' ? r.exitCode : null,
1024
+ durationMs: Date.now() - start,
1025
+ timedOut: !!r.timedOut
1026
+ }
1027
+ mode = 'exec'
1028
+ } catch (e) {
1029
+ // Only session types that genuinely lack an exec channel
1030
+ // (local/telnet/serial/...) fall back to PTY sentinel capture.
1031
+ // An SSH tab always has exec support, so any exec failure there
1032
+ // means the connection is dead — surface it immediately as
1033
+ // [error] instead of polling a dead terminal for the full
1034
+ // timeout and misreporting timedOut.
1035
+ if (isSsh || !/not supported/i.test(e.message || '')) {
1036
+ throw e
1037
+ }
920
1038
  }
921
1039
  }
922
- }
923
1040
 
924
- if (!result) {
925
- result = await store.mcpExecuteCommandPty({ command, tabId, timeoutMs })
1041
+ if (!result) {
1042
+ result = await store.mcpExecuteCommandPty({ command, tabId, timeoutMs })
1043
+ }
1044
+ } catch (err) {
1045
+ setTabMcpStatusAuto(tabId, 'error')
1046
+ throw err
926
1047
  }
1048
+ setTabMcpStatusAuto(tabId, result.timedOut ? 'running' : 'done')
927
1049
 
928
1050
  const out = truncateTail(result.stdout, maxOutputBytes)
929
1051
  const err = truncateTail(result.stderr, maxOutputBytes)
@@ -995,6 +1117,7 @@ export default Store => {
995
1117
  status: 'started'
996
1118
  }
997
1119
  backgroundTasks.set(taskId, task)
1120
+ setTabMcpStatus(task.tabId, 'running')
998
1121
 
999
1122
  return {
1000
1123
  taskId,
@@ -1027,6 +1150,7 @@ export default Store => {
1027
1150
  task.status = 'completed'
1028
1151
  task.exitCode = parseInt(exitCode, 10)
1029
1152
  task.endTime = Date.now()
1153
+ setTabMcpStatusAuto(task.tabId, 'done')
1030
1154
  return { ...task, status: 'completed', exitCode: task.exitCode }
1031
1155
  }
1032
1156
  return { ...task, status: 'unknown', message: 'PID file not found' }
@@ -1037,6 +1161,7 @@ export default Store => {
1037
1161
 
1038
1162
  if (aliveCheck.trim() === 'alive') {
1039
1163
  task.status = 'running'
1164
+ setTabMcpStatus(task.tabId, 'running')
1040
1165
  return { ...task, pid, status: 'running' }
1041
1166
  }
1042
1167
 
@@ -1048,6 +1173,7 @@ export default Store => {
1048
1173
  task.status = 'completed'
1049
1174
  task.exitCode = exitCode !== '' ? parseInt(exitCode, 10) : null
1050
1175
  task.endTime = Date.now()
1176
+ setTabMcpStatusAuto(task.tabId, 'done')
1051
1177
  return { ...task, pid, status: 'completed', exitCode: task.exitCode }
1052
1178
  }
1053
1179
 
@@ -1083,6 +1209,7 @@ export default Store => {
1083
1209
  `kill ${pid} 2>/dev/null; echo $? > ${task.exitFile}`)
1084
1210
  task.status = 'cancelled'
1085
1211
  task.endTime = Date.now()
1212
+ setTabMcpStatus(task.tabId, '')
1086
1213
  return {
1087
1214
  taskId: task.id,
1088
1215
  pid,
@@ -1112,6 +1239,7 @@ export default Store => {
1112
1239
  // best-effort remote cleanup
1113
1240
  }
1114
1241
  backgroundTasks.delete(args.taskId)
1242
+ setTabMcpStatus(task.tabId, '')
1115
1243
  return { success: true, taskId: args.taskId }
1116
1244
  }
1117
1245