@electerm/electerm-react 3.15.186 → 3.15.198

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.
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Whether the AI feature is disabled globally.
3
+ *
4
+ * Set `window.et.disableAIFeature = true` (the same way `disableUpgradeCheck`
5
+ * is injected into the built index.html `_global` data) to hide every
6
+ * AI-related button / icon / setting menu in the UI.
7
+ */
8
+ export const isAIDisabled = () => {
9
+ return !!(window.et && window.et.disableAIFeature)
10
+ }
@@ -78,5 +78,6 @@ export default {
78
78
  startDirectoryLocal: '',
79
79
  allowMultiInstance: false,
80
80
  disableDeveloperTool: false,
81
- dragDropBehavior: 'ask'
81
+ dragDropBehavior: 'ask',
82
+ switchTabOnHover: false
82
83
  }
@@ -5,28 +5,35 @@ import {
5
5
  settingAiId,
6
6
  settingPasswordsId
7
7
  } from '../common/constants'
8
+ import { isAIDisabled } from './ai-feature'
8
9
 
9
10
  const e = window.translate
10
11
 
11
- export default () => ([
12
- {
13
- id: settingTerminalId,
14
- title: e('terminal')
15
- },
16
- {
17
- id: settingShortcutsId,
18
- title: e('settingShortcuts')
19
- },
20
- {
21
- id: settingSyncId,
22
- title: e('settingSync')
23
- },
24
- {
25
- id: settingAiId,
26
- title: 'AI'
27
- },
28
- {
29
- id: settingPasswordsId,
30
- title: e('password')
12
+ export default () => {
13
+ const list = [
14
+ {
15
+ id: settingTerminalId,
16
+ title: e('terminal')
17
+ },
18
+ {
19
+ id: settingShortcutsId,
20
+ title: e('settingShortcuts')
21
+ },
22
+ {
23
+ id: settingSyncId,
24
+ title: e('settingSync')
25
+ },
26
+ {
27
+ id: settingAiId,
28
+ title: 'AI'
29
+ },
30
+ {
31
+ id: settingPasswordsId,
32
+ title: e('password')
33
+ }
34
+ ]
35
+ if (isAIDisabled()) {
36
+ return list.filter(item => item.id !== settingAiId)
31
37
  }
32
- ])
38
+ return list
39
+ }
@@ -33,7 +33,7 @@ const rdpConfig = {
33
33
  commonFields.port,
34
34
  { type: 'profileItem', name: '__profile__', label: '', profileFilter: d => !isEmpty(d.rdp) },
35
35
  { ...commonFields.username, rules: [{ required: true, message: e('username') + ' required' }] },
36
- { ...commonFields.password, rules: [{ required: true, message: e('password') + ' required' }] },
36
+ commonFields.password,
37
37
  commonFields.description,
38
38
  { type: 'input', name: 'domain', label: () => e('domain') },
39
39
  commonFields.proxy,
@@ -21,6 +21,7 @@ import { LoadingOutlined, BookOutlined, RobotOutlined } from '@ant-design/icons'
21
21
  import sessionConfig from './config/session-config'
22
22
  import renderForm from './render-form'
23
23
  import AIBookmarkForm from './ai-bookmark-form'
24
+ import { isAIDisabled } from '../../common/ai-feature'
24
25
  import './bookmark-form.styl'
25
26
 
26
27
  const e = window.translate
@@ -61,6 +62,9 @@ export default class BookmarkIndex2 extends PureComponent {
61
62
  }
62
63
 
63
64
  getInitAiModeState () {
65
+ if (isAIDisabled()) {
66
+ return false
67
+ }
64
68
  const v = window.et.openBookmarkWithAIMode
65
69
  if (v !== true) {
66
70
  return false
@@ -120,7 +124,7 @@ export default class BookmarkIndex2 extends PureComponent {
120
124
  }
121
125
 
122
126
  renderAIButton (isNew) {
123
- if (!isNew || this.state.aiMode) {
127
+ if (!isNew || this.state.aiMode || isAIDisabled()) {
124
128
  return null
125
129
  }
126
130
  return (
@@ -11,6 +11,7 @@ import encodes from '../bookmark-form/common/encodes'
11
11
  import { refs } from '../common/ref'
12
12
  import Qm from '../quick-commands/quick-commands-select'
13
13
  import AIIcon from '../icons/ai-icon'
14
+ import { isAIDisabled } from '../../common/ai-feature'
14
15
  import CmdHistory from './cmd-history'
15
16
 
16
17
  const {
@@ -189,7 +190,7 @@ export default auto(function FooterEntry (props) {
189
190
  return (
190
191
  <div {...sideProps}>
191
192
  <div className='terminal-footer-flex'>
192
- {renderAIIcon()}
193
+ {!isAIDisabled() && renderAIIcon()}
193
194
  {renderCmdHistory()}
194
195
  {renderQuickCommands()}
195
196
  {renderBatchInputs()}
@@ -20,6 +20,7 @@ import TransportsActionStore from '../file-transfer/transports-action-store.jsx'
20
20
  import classnames from 'classnames'
21
21
  import ShortcutControl from '../shortcuts/shortcut-control.jsx'
22
22
  import { isMac, isWin, textTerminalBgValue } from '../../common/constants'
23
+ import { isAIDisabled } from '../../common/ai-feature'
23
24
  import { ConfigProvider } from 'antd'
24
25
  import { NotificationContainer } from '../common/notification'
25
26
  import InfoModal from '../sidebar/info-modal.jsx'
@@ -290,7 +291,7 @@ export default auto(function Index (props) {
290
291
  <Resolutions {...resProps} />
291
292
  <InfoModal {...infoModalProps} />
292
293
  <RightSidePanel {...rightPanelProps}>
293
- <AIChat {...aiChatProps} />
294
+ {!isAIDisabled() && <AIChat {...aiChatProps} />}
294
295
  <TerminalInfo key={store.activeTabId} {...terminalInfoProps} />
295
296
  </RightSidePanel>
296
297
  <SshConfigLoadNotify {...sshConfigProps} />
@@ -306,7 +307,7 @@ export default auto(function Index (props) {
306
307
  <BookmarkFromHistoryModal />
307
308
  <NotificationContainer />
308
309
  <BatchOpRunner />
309
- <AIConfigModal store={store} />
310
+ {!isAIDisabled() && <AIConfigModal store={store} />}
310
311
  <UnixTimestampTooltip />
311
312
  </div>
312
313
  </ConfigProvider>
@@ -603,6 +603,7 @@ export default class SettingCommon extends Component {
603
603
  'hideIP',
604
604
  'allowMultiInstance',
605
605
  'disableDeveloperTool',
606
+ 'switchTabOnHover',
606
607
  'debug'
607
608
  ].map(this.renderToggle)
608
609
  }
@@ -13,6 +13,7 @@ import BookmarksList from '../sidebar/bookmark-select'
13
13
  import History from '../sidebar/history'
14
14
  import DragHandle from '../common/drag-handle'
15
15
  import QuickConnect from './quick-connect'
16
+ import { isAIDisabled } from '../../common/ai-feature'
16
17
 
17
18
  const e = window.translate
18
19
 
@@ -103,12 +104,14 @@ export default function AddBtnMenu ({
103
104
  <CodeFilled /> {e('newBookmark')}
104
105
  </div>
105
106
  {addTabBtn}
106
- <div
107
- className={cls}
108
- onClick={onNewSshAI}
109
- >
110
- <RobotOutlined /> {e('createBookmarkByAI')}
111
- </div>
107
+ {!isAIDisabled() && (
108
+ <div
109
+ className={cls}
110
+ onClick={onNewSshAI}
111
+ >
112
+ <RobotOutlined /> {e('createBookmarkByAI')}
113
+ </div>
114
+ )}
112
115
  <QuickConnect batch={batch} inputOnly />
113
116
  <Tabs
114
117
  activeKey={activeTab}
@@ -3,6 +3,7 @@ import { RobotOutlined } from '@ant-design/icons'
3
3
  import LogoElem from '../common/logo-elem.jsx'
4
4
  import HistoryPanel from '../sidebar/history'
5
5
  import QuickConnect from './quick-connect'
6
+ import { isAIDisabled } from '../../common/ai-feature'
6
7
  import './no-session.styl'
7
8
 
8
9
  const e = window.translate
@@ -40,12 +41,14 @@ export default function NoSessionPanel ({ height, onNewTab, onNewSsh, batch }) {
40
41
  >
41
42
  {e('newBookmark')}
42
43
  </Button>
43
- <Button
44
- onClick={handleCreateAIBookmark}
45
- icon={<RobotOutlined />}
46
- >
47
- {e('createBookmarkByAI')}
48
- </Button>
44
+ {!isAIDisabled() && (
45
+ <Button
46
+ onClick={handleCreateAIBookmark}
47
+ icon={<RobotOutlined />}
48
+ >
49
+ {e('createBookmarkByAI')}
50
+ </Button>
51
+ )}
49
52
  <QuickConnect batch={batch} />
50
53
  </div>
51
54
  <div className='pd3'>
@@ -121,6 +121,21 @@ class Tab extends Component {
121
121
  window.store.clickTab(this.props.tab.id, this.props.batch)
122
122
  }
123
123
 
124
+ onMouseEnter = () => {
125
+ const {
126
+ tab,
127
+ batch,
128
+ currentBatchTabId
129
+ } = this.props
130
+ if (document.querySelector('.' + onDragCls)) {
131
+ return
132
+ }
133
+ if (tab.id === currentBatchTabId) {
134
+ return
135
+ }
136
+ window.store.clickTab(tab.id, batch)
137
+ }
138
+
124
139
  onDrag = () => {
125
140
  addClass(this.tabRef.current, onDragCls)
126
141
  }
@@ -441,7 +456,8 @@ class Tab extends Component {
441
456
  const {
442
457
  isLast,
443
458
  tab,
444
- currentBatchTabId
459
+ currentBatchTabId,
460
+ config
445
461
  } = this.props
446
462
  const {
447
463
  id,
@@ -499,6 +515,7 @@ class Tab extends Component {
499
515
  data-id={id}
500
516
  {...pick(this, [
501
517
  'onDrag',
518
+ ...(config && config.switchTabOnHover ? ['onMouseEnter'] : []),
502
519
  'onDragEnter',
503
520
  'onDragExit',
504
521
  'onDragLeave',
@@ -19,6 +19,15 @@ export function runCmd (pid, cmd) {
19
19
  })
20
20
  }
21
21
 
22
+ export function execCmd (pid, cmd, timeoutMs) {
23
+ return fetch({
24
+ pid,
25
+ cmd,
26
+ timeoutMs,
27
+ action: 'exec-cmd'
28
+ })
29
+ }
30
+
22
31
  export function resizeTerm (pid, cols, rows) {
23
32
  return fetch({
24
33
  pid,
@@ -3,6 +3,7 @@ import { refsStatic, refs } from '../common/ref'
3
3
  import SuggestionItem from './cmd-item'
4
4
  import { aiSuggestionsCache } from '../../common/cache'
5
5
  import { appendMandatoryGuardrails } from '../ai/ai-guardrails'
6
+ import { isAIDisabled } from '../../common/ai-feature'
6
7
  import classnames from 'classnames'
7
8
  import {
8
9
  LoadingOutlined
@@ -354,6 +355,9 @@ export default class TerminalCmdSuggestions extends Component {
354
355
  const {
355
356
  reverse
356
357
  } = this.state
358
+ if (isAIDisabled()) {
359
+ return null
360
+ }
357
361
  if (
358
362
  (pos === 'top' && !reverse) ||
359
363
  (pos === 'bottom' && reverse)
@@ -42,6 +42,7 @@ import { getFolderFromFilePath } from '../sftp/file-read.js'
42
42
  import { CommandTrackerAddon } from './command-tracker-addon.js'
43
43
  import { Osc52Addon } from './osc52-addon.js'
44
44
  import AIIcon from '../icons/ai-icon.jsx'
45
+ import { isAIDisabled } from '../../common/ai-feature.js'
45
46
  import {
46
47
  AimOutlined
47
48
  } from '@ant-design/icons'
@@ -668,6 +669,7 @@ class Term extends Component {
668
669
  // long-press detection here.
669
670
  longPressTimer = null
670
671
  touchStartPos = null
672
+ longPressFired = false
671
673
  longPressThreshold = 500 // ms
672
674
  longPressMoveTolerance = 10 // px
673
675
 
@@ -681,6 +683,7 @@ class Term extends Component {
681
683
  clientY: touch.clientY,
682
684
  target: e.currentTarget
683
685
  }
686
+ this.longPressFired = false
684
687
  clearTimeout(this.longPressTimer)
685
688
  this.longPressTimer = setTimeout(() => {
686
689
  this.handleLongPress()
@@ -702,15 +705,27 @@ class Term extends Component {
702
705
  }
703
706
 
704
707
  onTouchEnd = () => {
708
+ const wasTap = this.touchStartPos && !this.longPressFired
705
709
  clearTimeout(this.longPressTimer)
706
710
  this.longPressTimer = null
707
711
  this.touchStartPos = null
712
+ // xterm's own touch (Gesture) handler calls preventDefault()/stopPropagation()
713
+ // on touchstart/touchend, which suppresses the synthesised mousedown xterm
714
+ // relies on to focus its hidden helper textarea. As a result a tap never
715
+ // focuses the terminal on touch devices, so the soft keyboard never opens
716
+ // and you cannot type. Focus explicitly on a clean tap (not a long-press,
717
+ // not a scroll) so mobile input works. The focus() runs synchronously inside
718
+ // this user-gesture handler, so iOS/Android will show the keyboard.
719
+ if (wasTap && this.term) {
720
+ this.term.focus()
721
+ }
708
722
  }
709
723
 
710
724
  handleLongPress = () => {
711
725
  if (!this.touchStartPos || this.state.loading) {
712
726
  return
713
727
  }
728
+ this.longPressFired = true
714
729
  const { clientX, clientY, target } = this.touchStartPos
715
730
 
716
731
  // Select the word at the touch position (same as desktop right-click word
@@ -1029,12 +1044,16 @@ class Term extends Component {
1029
1044
  label: e('selectall'),
1030
1045
  extra: selectAllShortcut
1031
1046
  },
1032
- {
1033
- key: 'explainWithAi',
1034
- icon: <AIIcon />,
1035
- label: e('explainWithAi'),
1036
- disabled: !hasSelection
1037
- },
1047
+ ...(
1048
+ isAIDisabled()
1049
+ ? []
1050
+ : [{
1051
+ key: 'explainWithAi',
1052
+ icon: <AIIcon />,
1053
+ label: e('explainWithAi'),
1054
+ disabled: !hasSelection
1055
+ }]
1056
+ ),
1038
1057
  {
1039
1058
  key: 'onClear',
1040
1059
  icon: <iconsMap.ReloadOutlined />,
@@ -0,0 +1,45 @@
1
+ /**
2
+ * parse `df` output into disk list
3
+ */
4
+
5
+ export default function formatDisks (str) {
6
+ if (!str) {
7
+ return {
8
+ disks: []
9
+ }
10
+ }
11
+ const r = str.split('\n')
12
+ .slice(1)
13
+ .reduce((acc, s) => {
14
+ if (!s.trim()) {
15
+ return acc
16
+ }
17
+ const arr = s.trim().split(/ +/)
18
+ // df wraps long filesystem names (e.g. nfs "host:/very/long/export/path")
19
+ // onto a second line that starts with spaces and only holds the numeric
20
+ // columns. Merge such continuation lines back into the previous entry, so
21
+ // those disks (commonly nfs) are listed instead of dropped.
22
+ if (/^\s/.test(s) && acc.length) {
23
+ const prev = acc[acc.length - 1]
24
+ prev.size = arr[0]
25
+ prev.used = arr[1]
26
+ prev.avail = arr[2]
27
+ prev.usedPercent = arr[3]
28
+ prev.mount = arr[4]
29
+ return acc
30
+ }
31
+ acc.push({
32
+ filesystem: arr[0],
33
+ size: arr[1],
34
+ used: arr[2],
35
+ avail: arr[3],
36
+ usedPercent: arr[4],
37
+ mount: arr[5]
38
+ })
39
+ return acc
40
+ }, [])
41
+ .filter(d => d.filesystem && d.mount)
42
+ return {
43
+ disks: r
44
+ }
45
+ }
@@ -6,6 +6,7 @@ import { runCmd } from '../terminal/terminal-apis'
6
6
  import { useEffect } from 'react'
7
7
  import wait from '../../common/wait'
8
8
  import parseInt10 from '../../common/parse-int10'
9
+ import formatDisks from './format-disks'
9
10
 
10
11
  function formatActivities (str) {
11
12
  if (!str) {
@@ -31,31 +32,6 @@ function formatActivities (str) {
31
32
  }
32
33
  }
33
34
 
34
- function formatDisks (str) {
35
- if (!str) {
36
- return {
37
- disks: []
38
- }
39
- }
40
- const r = str.split('\n')
41
- .slice(1)
42
- .map(s => {
43
- const arr = s.split(/ +/)
44
- return {
45
- filesystem: arr[0],
46
- size: arr[1],
47
- used: arr[2],
48
- avail: arr[3],
49
- usedPercent: arr[4],
50
- mount: arr[5]
51
- }
52
- })
53
- .filter(d => d.filesystem)
54
- return {
55
- disks: r
56
- }
57
- }
58
-
59
35
  function formatCpu (str) {
60
36
  if (!str) {
61
37
  return {
@@ -0,0 +1,233 @@
1
+ /**
2
+ * AI-powered terminal theme generator.
3
+ * Lets the user describe a theme in natural language, asks the configured AI
4
+ * provider for a full color palette, and writes the result back into the
5
+ * theme form as editable theme text.
6
+ */
7
+ import { useState, useEffect } from 'react'
8
+ import { Button, Input, Space, Alert } from 'antd'
9
+ import {
10
+ RobotOutlined,
11
+ LoadingOutlined
12
+ } from '@ant-design/icons'
13
+ import message from '../common/message'
14
+ import { appendMandatoryGuardrails } from '../ai/ai-guardrails'
15
+ import AiHistory, { addHistoryItem } from '../ai/ai-history.jsx'
16
+ import HelpIcon from '../common/help-icon'
17
+ import { getItem, setItem } from '../../common/safe-local-storage'
18
+ import {
19
+ requiredThemeProps,
20
+ validThemeProps,
21
+ convertTheme,
22
+ convertThemeToText
23
+ } from '../../common/terminal-theme'
24
+
25
+ const STORAGE_KEY_DESC = 'ai_theme_description'
26
+ const STORAGE_KEY_HISTORY = 'ai_theme_history'
27
+ const EVENT_NAME_HISTORY = 'ai-theme-history-update'
28
+ const { TextArea } = Input
29
+ const e = window.translate
30
+
31
+ // Human readable description for every required theme prop, so the model knows
32
+ // exactly what each key controls and which format to use.
33
+ const themePropDescriptions = {
34
+ main: 'main UI background color (hex #rrggbb)',
35
+ 'main-dark': 'darker variant of the main color (hex)',
36
+ 'main-light': 'lighter variant of the main color (hex)',
37
+ text: 'main UI text color (hex)',
38
+ 'text-light': 'lighter variant of the text color (hex)',
39
+ 'text-dark': 'darker variant of the text color (hex)',
40
+ 'text-disabled': 'disabled text color (hex)',
41
+ primary: 'primary accent color for buttons/links (hex)',
42
+ info: 'info status color (hex)',
43
+ success: 'success status color (hex)',
44
+ error: 'error status color (hex)',
45
+ warn: 'warning status color (hex)',
46
+ 'terminal:foreground': 'default terminal text color (hex or rgba)',
47
+ 'terminal:background': 'terminal background color, should relate to main (hex or rgba)',
48
+ 'terminal:cursor': 'terminal cursor color (hex or rgba)',
49
+ 'terminal:cursorAccent': 'terminal cursor accent color (hex or rgba)',
50
+ 'terminal:selectionBackground': 'terminal text selection background (rgba recommended)',
51
+ 'terminal:black': 'ANSI color 0 - black (hex)',
52
+ 'terminal:red': 'ANSI color 1 - red (hex)',
53
+ 'terminal:green': 'ANSI color 2 - green (hex)',
54
+ 'terminal:yellow': 'ANSI color 3 - yellow (hex)',
55
+ 'terminal:blue': 'ANSI color 4 - blue (hex)',
56
+ 'terminal:magenta': 'ANSI color 5 - magenta (hex)',
57
+ 'terminal:cyan': 'ANSI color 6 - cyan (hex)',
58
+ 'terminal:white': 'ANSI color 7 - white (hex)',
59
+ 'terminal:brightBlack': 'ANSI bright color 8 - bright black (hex)',
60
+ 'terminal:brightRed': 'ANSI bright color 9 - bright red (hex)',
61
+ 'terminal:brightGreen': 'ANSI bright color 10 - bright green (hex)',
62
+ 'terminal:brightYellow': 'ANSI bright color 11 - bright yellow (hex)',
63
+ 'terminal:brightBlue': 'ANSI bright color 12 - bright blue (hex)',
64
+ 'terminal:brightMagenta': 'ANSI bright color 13 - bright magenta (hex)',
65
+ 'terminal:brightCyan': 'ANSI bright color 14 - bright cyan (hex)',
66
+ 'terminal:brightWhite': 'ANSI bright color 15 - bright white (hex)'
67
+ }
68
+
69
+ /**
70
+ * build the prompt that asks the AI for a complete theme
71
+ * @param {string} description
72
+ * @return {string}
73
+ */
74
+ function buildThemePrompt (description) {
75
+ const lang = window.store.config.languageAI || window.store.getLangName()
76
+ const fieldList = requiredThemeProps
77
+ .map(key => ` ${key}=${themePropDescriptions[key] || 'color value'}`)
78
+ .join('\n')
79
+ return `You are an electerm terminal theme color generator. Based on the user's natural language description, generate a complete and harmonious color palette by filling in EVERY color listed below.
80
+
81
+ The theme uses a key=value format (one per line). UI colors use hex (#rrggbb). Terminal colors use hex or rgba. The palette should be cohesive, readable and accessible (keep good contrast between foreground and background).
82
+
83
+ Required keys (you MUST fill ALL of them):
84
+ ${fieldList}
85
+
86
+ Important rules:
87
+ 1. Output ONLY the key=value lines, no markdown code fences, no comments, no extra text
88
+ 2. Use exactly the key names listed above
89
+ 3. terminal:background should match or relate to main
90
+ 4. terminal:foreground must contrast well with terminal:background
91
+ 5. Provide distinct, harmonious ANSI 16 colors for the terminal palette
92
+ 6. If you include any description text, reply in ${lang} language (but prefer no extra text at all)
93
+
94
+ User description: ${description}
95
+
96
+ Generate the theme:`
97
+ }
98
+
99
+ /**
100
+ * Normalize whatever the model returned into clean, valid theme text.
101
+ * Strips markdown fences and drops any key that is not a supported theme prop.
102
+ * @param {string|object} raw
103
+ * @return {string}
104
+ */
105
+ function normalizeGeneratedTheme (raw) {
106
+ if (!raw) {
107
+ return ''
108
+ }
109
+ let str = typeof raw === 'string' ? raw : JSON.stringify(raw)
110
+ str = str.trim()
111
+ if (str.startsWith('```theme')) {
112
+ str = str.slice(8)
113
+ } else if (str.startsWith('```')) {
114
+ str = str.slice(3)
115
+ }
116
+ if (str.endsWith('```')) {
117
+ str = str.slice(0, -3)
118
+ }
119
+ str = str.trim()
120
+ const converted = convertTheme(str)
121
+ const uiThemeConfig = {}
122
+ const themeConfig = {}
123
+ for (const [key, value] of Object.entries(converted.uiThemeConfig)) {
124
+ if (validThemeProps.includes(key)) {
125
+ uiThemeConfig[key] = value
126
+ }
127
+ }
128
+ for (const [key, value] of Object.entries(converted.themeConfig)) {
129
+ if (validThemeProps.includes('terminal:' + key)) {
130
+ themeConfig[key] = value
131
+ }
132
+ }
133
+ return convertThemeToText({ themeConfig, uiThemeConfig })
134
+ }
135
+
136
+ export default function ThemeAiEditor (props) {
137
+ const { onChange, disabled } = props
138
+ const [description, setDescription] = useState(() => getItem(STORAGE_KEY_DESC) || '')
139
+ const [loading, setLoading] = useState(false)
140
+
141
+ useEffect(() => {
142
+ setItem(STORAGE_KEY_DESC, description)
143
+ }, [description])
144
+
145
+ async function handleGenerate () {
146
+ if (disabled) {
147
+ return
148
+ }
149
+ if (window.store.aiConfigMissing()) {
150
+ window.store.toggleAIConfig()
151
+ return
152
+ }
153
+ if (!description.trim()) {
154
+ return message.warning(e('description') + ' required')
155
+ }
156
+ setLoading(true)
157
+ try {
158
+ const config = window.store.config
159
+ const prompt = buildThemePrompt(description)
160
+ const aiResponse = await window.pre.runGlobalAsync(
161
+ 'AIchat',
162
+ prompt,
163
+ config.modelAI,
164
+ appendMandatoryGuardrails('You are a helpful assistant that generates electerm terminal theme color palettes.'),
165
+ config.baseURLAI,
166
+ config.apiPathAI,
167
+ config.apiKeyAI,
168
+ config.proxyAI,
169
+ false, // disable streaming for a structured response
170
+ config.authHeaderNameAI
171
+ )
172
+ if (aiResponse && aiResponse.error) {
173
+ throw new Error(aiResponse.error)
174
+ }
175
+ const text = normalizeGeneratedTheme(aiResponse && aiResponse.response)
176
+ if (!text || !text.trim()) {
177
+ throw new Error('empty response')
178
+ }
179
+ onChange(text)
180
+ addHistoryItem(STORAGE_KEY_HISTORY, description, EVENT_NAME_HISTORY)
181
+ message.success(e('Done'))
182
+ } catch (err) {
183
+ console.error('AI theme generation error:', err)
184
+ message.error('Can not generate theme from AI response: ' + err.message)
185
+ } finally {
186
+ setLoading(false)
187
+ }
188
+ }
189
+
190
+ const textAreaProps = {
191
+ value: description,
192
+ onChange: ev => setDescription(ev.target.value),
193
+ placeholder: e('createThemeByAI'),
194
+ autoSize: { minRows: 4, maxRows: 8 },
195
+ disabled: loading || disabled
196
+ }
197
+
198
+ const generateBtnProps = {
199
+ type: 'primary',
200
+ onClick: handleGenerate,
201
+ disabled: !description.trim() || disabled,
202
+ icon: loading ? <LoadingOutlined /> : <RobotOutlined />,
203
+ loading
204
+ }
205
+
206
+ return (
207
+ <div className='theme-ai-editor'>
208
+ <div className='pd1b'>
209
+ <Alert
210
+ type='info'
211
+ showIcon
212
+ title={e('aiSecurityNotice')}
213
+ />
214
+ </div>
215
+ <div className='pd1b'>
216
+ <TextArea {...textAreaProps} />
217
+ </div>
218
+ <AiHistory
219
+ storageKey={STORAGE_KEY_HISTORY}
220
+ eventName={EVENT_NAME_HISTORY}
221
+ onSelect={setDescription}
222
+ />
223
+ <div className='pd1t'>
224
+ <Space>
225
+ <Button {...generateBtnProps}>
226
+ {e('generate')}
227
+ </Button>
228
+ <HelpIcon link='https://github.com/electerm/electerm/wiki/Create-theme-by-AI' />
229
+ </Space>
230
+ </div>
231
+ </div>
232
+ )
233
+ }
@@ -1,5 +1,11 @@
1
1
  import { useRef, useState } from 'react'
2
- import { Button, Input, Form, Space } from 'antd'
2
+ import { Button, Input, Form, Tabs, Flex } from 'antd'
3
+ import {
4
+ BgColorsOutlined,
5
+ FontSizeOutlined,
6
+ RobotOutlined,
7
+ SkinOutlined
8
+ } from '@ant-design/icons'
3
9
  import message from '../common/message'
4
10
  import {
5
11
  convertTheme,
@@ -10,9 +16,11 @@ import {
10
16
  } from '../../common/terminal-theme'
11
17
  import { defaultTheme, defaultThemeLight } from '../../common/theme-defaults'
12
18
  import generate from '../../common/uid'
19
+ import { isAIDisabled } from '../../common/ai-feature'
13
20
  import Link from '../common/external-link'
14
21
  import InputAutoFocus from '../common/input-auto-focus'
15
22
  import ThemePicker from './theme-editor'
23
+ import ThemeAiEditor from './theme-ai-editor'
16
24
  import Upload from '../common/upload'
17
25
  // import './theme-form.styl'
18
26
 
@@ -48,8 +56,8 @@ export default function ThemeForm (props) {
48
56
  // A regex to test the hex color format
49
57
  const hexColorRegex = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/
50
58
 
51
- // A regex to test the rgba color format
52
- const rgbaColorRegex = /^rgba\(\d{1,3}, +\d{1,3}, +\d{1,3}, +(0|0?\.\d+|1)\)$/
59
+ // A regex to test the rgba color format (whitespace after commas is optional)
60
+ const rgbaColorRegex = /^rgba\(\d{1,3},\s*\d{1,3},\s*\d{1,3},\s*(0|0?\.\d+|1)\)$/
53
61
 
54
62
  // A message to store the error message
55
63
  let message = ''
@@ -168,9 +176,13 @@ export default function ThemeForm (props) {
168
176
  setTxt(tt)
169
177
  }
170
178
 
171
- function handleSwitchEditor (e) {
172
- e.preventDefault()
173
- setEditor(editor === 'theme-editor-txt' ? 'theme-editor-color-picker' : 'theme-editor-txt')
179
+ function handleAiGenerated (text) {
180
+ form.setFieldsValue({
181
+ themeText: text
182
+ })
183
+ setTxt(text)
184
+ // jump to the text editor so the user can review/adjust the result
185
+ setEditor('theme-editor-txt')
174
186
  }
175
187
 
176
188
  function renderFuncs (id) {
@@ -238,12 +250,48 @@ export default function ThemeForm (props) {
238
250
  }
239
251
  const isDefaultTheme = id === defaultTheme().id || id === defaultThemeLight().id
240
252
  const disabled = readonly || isDefaultTheme
241
- const switchTxt = editor === 'theme-editor-txt' ? e('editWithColorPicker') : e('editWithTextEditor')
242
253
  const pickerProps = {
243
254
  onChange: onPickerChange,
244
255
  themeText: txt,
245
256
  disabled
246
257
  }
258
+ const tabItems = [
259
+ {
260
+ key: 'theme-editor-color-picker',
261
+ label: (
262
+ <span>
263
+ <BgColorsOutlined className='mg1r' />
264
+ {e('editWithColorPicker')}
265
+ </span>
266
+ )
267
+ },
268
+ {
269
+ key: 'theme-editor-txt',
270
+ label: (
271
+ <span>
272
+ <FontSizeOutlined className='mg1r' />
273
+ {e('editWithTextEditor')}
274
+ </span>
275
+ )
276
+ },
277
+ ...(!isAIDisabled()
278
+ ? [{
279
+ key: 'theme-editor-ai',
280
+ label: (
281
+ <span>
282
+ <RobotOutlined className='mg1r' />
283
+ AI
284
+ </span>
285
+ )
286
+ }]
287
+ : [])
288
+ ]
289
+ const tabsProps = {
290
+ activeKey: editor,
291
+ onChange: setEditor,
292
+ size: 'small',
293
+ items: tabItems
294
+ }
247
295
  return (
248
296
  <Form
249
297
  onFinish={handleSubmit}
@@ -253,6 +301,11 @@ export default function ThemeForm (props) {
253
301
  name='terminal-theme-form'
254
302
  layout='vertical'
255
303
  >
304
+ <div className='mg1b alignright'>
305
+ <Link to='https://theme.electerm.org'>
306
+ <SkinOutlined className='mg1r' />https://theme.electerm.org
307
+ </Link>
308
+ </div>
256
309
  {renderFuncs(id)}
257
310
  <FormItem
258
311
  label={e('themeName')}
@@ -269,43 +322,43 @@ export default function ThemeForm (props) {
269
322
  disabled={disabled}
270
323
  />
271
324
  </FormItem>
272
- <FormItem
273
- label={e('themeConfig')}
274
- >
275
- <div className='mg1b fix'>
276
- <span className='fleft'>
277
- <Space>
278
- <Button
279
- type='dashed'
280
- onClick={handleSwitchEditor}
281
- >
282
- {switchTxt}
283
- </Button>
284
- </Space>
285
- </span>
286
- <span className='fright'>
287
- <Upload
288
- beforeUpload={beforeUpload}
289
- fileList={[]}
290
- className='mg1b'
325
+ <FormItem>
326
+ <Flex
327
+ className='mg1b'
328
+ justify='space-between'
329
+ align='center'
330
+ wrap='wrap'
331
+ gap={8}
332
+ >
333
+ <span>{e('themeConfig')}</span>
334
+ <Upload
335
+ beforeUpload={beforeUpload}
336
+ fileList={[]}
337
+ >
338
+ <Button
339
+ type='dashed'
340
+ disabled={disabled}
291
341
  >
292
- <Button
293
- type='dashed'
294
- disabled={disabled}
295
- >
296
- {e('importFromFile')}
297
- </Button>
298
- </Upload>
299
- </span>
300
- </div>
342
+ {e('importFromFile')}
343
+ </Button>
344
+ </Upload>
345
+ </Flex>
346
+ <Tabs {...tabsProps} />
301
347
  {
302
348
  editor === 'theme-editor-txt'
303
349
  ? renderTxt()
304
- : (
305
- <ThemePicker
306
- {...pickerProps}
307
- />
308
- )
350
+ : editor === 'theme-editor-ai'
351
+ ? (
352
+ <ThemeAiEditor
353
+ onChange={handleAiGenerated}
354
+ disabled={disabled}
355
+ />
356
+ )
357
+ : (
358
+ <ThemePicker
359
+ {...pickerProps}
360
+ />
361
+ )
309
362
  }
310
363
  </FormItem>
311
364
  {
@@ -6,7 +6,7 @@
6
6
  import uid from '../common/uid'
7
7
  import { settingMap } from '../common/constants'
8
8
  import { refs, refsTabs } from '../components/common/ref'
9
- import { runCmd } from '../components/terminal/terminal-apis'
9
+ import { runCmd, execCmd } from '../components/terminal/terminal-apis'
10
10
  import deepCopy from 'json-deep-copy'
11
11
  import {
12
12
  getLocalFileInfo,
@@ -125,6 +125,9 @@ export default Store => {
125
125
  case 'cancel_terminal_command':
126
126
  result = store.mcpCancelTerminalCommand(args)
127
127
  break
128
+ case 'execute_command':
129
+ result = await store.mcpExecuteCommand(args)
130
+ break
128
131
 
129
132
  // Background task operations
130
133
  case 'run_background_command':
@@ -139,6 +142,9 @@ export default Store => {
139
142
  case 'cancel_background_task':
140
143
  result = await store.mcpCancelBackgroundTask(args)
141
144
  break
145
+ case 'cleanup_background_task':
146
+ result = await store.mcpCleanupBackgroundTask(args)
147
+ break
142
148
 
143
149
  // SFTP operations
144
150
  case 'sftp_list':
@@ -738,6 +744,173 @@ export default Store => {
738
744
  }
739
745
  }
740
746
 
747
+ // ==================== Structured Command Execution ====================
748
+
749
+ // Keep at most maxChars of a string, favoring the tail (most recent output).
750
+ function truncateTail (str, maxChars) {
751
+ if (!str || !maxChars || str.length <= maxChars) {
752
+ return { text: str || '', truncated: false }
753
+ }
754
+ return { text: str.slice(-maxChars), truncated: true }
755
+ }
756
+
757
+ // Read up to maxLines lines from the active terminal buffer.
758
+ function collectBufferLines (tabId, maxLines = 800) {
759
+ const term = refs.get('term-' + tabId)
760
+ if (!term || !term.term) return []
761
+ const buffer = term.term.buffer.active
762
+ if (!buffer) return []
763
+ const cursorY = buffer.cursorY || 0
764
+ const baseY = buffer.baseY || 0
765
+ const totalLines = buffer.length || 0
766
+ const end = Math.min(totalLines, baseY + cursorY + 1)
767
+ const start = Math.max(0, end - maxLines)
768
+ const lines = []
769
+ for (let i = start; i < end; i++) {
770
+ const line = buffer.getLine(i)
771
+ lines.push(line ? line.translateToString(true) : '')
772
+ }
773
+ return lines
774
+ }
775
+
776
+ // PTY fallback for non-SSH tabs: wrap the command with unique begin/end
777
+ // sentinels, then poll the terminal buffer for the end sentinel carrying
778
+ // the exit code. stderr cannot be separated in a PTY — it is merged into
779
+ // stdout and flagged via stderrMerged.
780
+ Store.prototype.mcpExecuteCommandPty = async function (args) {
781
+ const { store } = window
782
+ const { command, tabId, timeoutMs } = args
783
+ const marker = `__ET_EXEC_${uid()}__`
784
+ const startLine = `${marker}S`
785
+ const wrapped = `echo "${startLine}"; ${command}; echo "${marker}E$?"`
786
+
787
+ store.runQuickCommand(wrapped, false, tabId)
788
+
789
+ const start = Date.now()
790
+ const pollInterval = 300
791
+ const endRe = new RegExp(`^${marker}E(\\d+)$`)
792
+
793
+ while (Date.now() - start < timeoutMs) {
794
+ const lines = collectBufferLines(tabId)
795
+ let endIdx = -1
796
+ let exitCode = null
797
+ for (let i = lines.length - 1; i >= 0; i--) {
798
+ const m = lines[i].trim().match(endRe)
799
+ if (m) {
800
+ endIdx = i
801
+ exitCode = parseInt(m[1], 10)
802
+ break
803
+ }
804
+ }
805
+ if (endIdx >= 0) {
806
+ let startIdx = -1
807
+ for (let i = endIdx - 1; i >= 0; i--) {
808
+ if (lines[i].trim() === startLine) {
809
+ startIdx = i
810
+ break
811
+ }
812
+ }
813
+ const outLines = lines.slice(startIdx + 1, endIdx)
814
+ return {
815
+ stdout: outLines.join('\n').replace(/\n+$/, ''),
816
+ stderr: '',
817
+ stderrMerged: true,
818
+ exitCode,
819
+ durationMs: Date.now() - start,
820
+ timedOut: false
821
+ }
822
+ }
823
+ await new Promise(resolve => setTimeout(resolve, pollInterval))
824
+ }
825
+
826
+ // Timeout: return whatever followed the start sentinel (if it appeared)
827
+ const lines = collectBufferLines(tabId)
828
+ let startIdx = -1
829
+ for (let i = lines.length - 1; i >= 0; i--) {
830
+ if (lines[i].trim() === startLine) {
831
+ startIdx = i
832
+ break
833
+ }
834
+ }
835
+ const outLines = startIdx >= 0 ? lines.slice(startIdx + 1) : []
836
+ return {
837
+ stdout: outLines.join('\n').replace(/\n+$/, ''),
838
+ stderr: '',
839
+ stderrMerged: true,
840
+ exitCode: null,
841
+ durationMs: Date.now() - start,
842
+ timedOut: true
843
+ }
844
+ }
845
+
846
+ Store.prototype.mcpExecuteCommand = async function (args) {
847
+ const { store } = window
848
+ const { command } = args
849
+ const tabId = args.tabId || store.activeTabId
850
+ const timeoutMs = Math.min(Math.max(args.timeoutMs || 120000, 1000), 600000)
851
+ const maxOutputBytes = args.maxOutputBytes || 204800
852
+ // 'exec' (default): SSH exec channel with sentinel-PTY fallback.
853
+ // 'pty': force sentinel capture in the visible terminal — for commands
854
+ // that need a TTY (color output, sudo prompts, TTY-dependent tools).
855
+ const requestedMode = args.mode === 'pty' ? 'pty' : 'exec'
856
+
857
+ if (!tabId) {
858
+ throw new Error('No active terminal')
859
+ }
860
+ if (!command) {
861
+ throw new Error('No command provided')
862
+ }
863
+
864
+ const tab = store.tabs.find(t => t.id === tabId)
865
+ if (!tab) {
866
+ throw new Error(`Tab not found: ${tabId}`)
867
+ }
868
+
869
+ const isSsh = tab.type === 'ssh' || (!tab.type && !!tab.host)
870
+ const start = Date.now()
871
+ let result = null
872
+ let mode = 'pty'
873
+
874
+ if (requestedMode === 'exec' && isSsh) {
875
+ try {
876
+ const r = await execCmd(tabId, command, timeoutMs)
877
+ result = {
878
+ stdout: r.stdout || '',
879
+ stderr: r.stderr || '',
880
+ stderrMerged: false,
881
+ exitCode: typeof r.exitCode === 'number' ? r.exitCode : null,
882
+ durationMs: Date.now() - start,
883
+ timedOut: !!r.timedOut
884
+ }
885
+ mode = 'exec'
886
+ } catch (e) {
887
+ // Exec channel unavailable (e.g. connection dropped) — fall back to PTY
888
+ if (!/not supported/i.test(e.message || '')) {
889
+ throw e
890
+ }
891
+ }
892
+ }
893
+
894
+ if (!result) {
895
+ result = await store.mcpExecuteCommandPty({ command, tabId, timeoutMs })
896
+ }
897
+
898
+ const out = truncateTail(result.stdout, maxOutputBytes)
899
+ const err = truncateTail(result.stderr, maxOutputBytes)
900
+
901
+ return {
902
+ stdout: out.text,
903
+ stderr: err.text,
904
+ stderrMerged: result.stderrMerged,
905
+ exitCode: result.exitCode,
906
+ durationMs: result.durationMs,
907
+ timedOut: result.timedOut,
908
+ truncated: out.truncated || err.truncated,
909
+ mode,
910
+ tabId
911
+ }
912
+ }
913
+
741
914
  // ==================== Background Task Management ====================
742
915
 
743
916
  const backgroundTasks = new Map()
@@ -814,6 +987,18 @@ export default Store => {
814
987
  const pid = pidOutput.trim()
815
988
 
816
989
  if (!pid) {
990
+ // PID file is gone. The wrapper removes it when the command finishes,
991
+ // so a missing PID file usually means COMPLETED — check the exit file
992
+ // before reporting unknown.
993
+ const exitOutput = await runMonitorCmd(task.tabId,
994
+ `cat ${task.exitFile} 2>/dev/null`)
995
+ const exitCode = exitOutput.trim()
996
+ if (exitCode !== '') {
997
+ task.status = 'completed'
998
+ task.exitCode = parseInt(exitCode, 10)
999
+ task.endTime = Date.now()
1000
+ return { ...task, status: 'completed', exitCode: task.exitCode }
1001
+ }
817
1002
  return { ...task, status: 'unknown', message: 'PID file not found' }
818
1003
  }
819
1004
 
@@ -883,6 +1068,23 @@ export default Store => {
883
1068
  }
884
1069
  }
885
1070
 
1071
+ // Remove remote temp files (log/pid/exit) and untrack the task.
1072
+ // Used by the MCP Tasks TTL sweep; safe to call on unknown tasks.
1073
+ Store.prototype.mcpCleanupBackgroundTask = async function (args) {
1074
+ const task = backgroundTasks.get(args.taskId)
1075
+ if (!task) {
1076
+ return { success: true, message: 'Task not tracked (already cleaned)' }
1077
+ }
1078
+ try {
1079
+ await runMonitorCmd(task.tabId,
1080
+ `rm -f ${task.logFile} ${task.pidFile} ${task.exitFile}`)
1081
+ } catch (_) {
1082
+ // best-effort remote cleanup
1083
+ }
1084
+ backgroundTasks.delete(args.taskId)
1085
+ return { success: true, taskId: args.taskId }
1086
+ }
1087
+
886
1088
  // ==================== Settings APIs ====================
887
1089
 
888
1090
  Store.prototype.mcpGetSettings = function () {
@@ -10,6 +10,7 @@ import {
10
10
  maxZoom,
11
11
  minZoom
12
12
  } from '../common/constants'
13
+ import { isAIDisabled } from '../common/ai-feature'
13
14
 
14
15
  const e = window.translate
15
16
 
@@ -63,6 +64,9 @@ export default Store => {
63
64
 
64
65
  Store.prototype.onNewSshAI = function () {
65
66
  const { store } = window
67
+ if (isAIDisabled()) {
68
+ return
69
+ }
66
70
  if (store.aiConfigMissing()) {
67
71
  store.toggleAIConfig()
68
72
  return
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@electerm/electerm-react",
3
- "version": "3.15.186",
3
+ "version": "3.15.198",
4
4
  "description": "react components src for electerm",
5
5
  "main": "./client/components/main/main.jsx",
6
6
  "license": "MIT",