@electerm/electerm-react 3.15.187 → 5.0.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.
@@ -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
+ }
@@ -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>
@@ -10,7 +10,6 @@ import { isEqual, last, isNumber, some, isArray, pick, uniq, debounce } from 'lo
10
10
  import FileSection from './file-item'
11
11
  import resolve from '../../common/resolve'
12
12
  import wait from '../../common/wait'
13
- import isAbsPath from '../../common/is-absolute-path'
14
13
  import classnames from 'classnames'
15
14
  import sorterIndex from '../../common/index-sorter'
16
15
  import { handleErr } from '../../common/fetch'
@@ -107,8 +106,6 @@ export default class Sftp extends Component {
107
106
  this.sftp = null
108
107
  clearTimeout(this.timer4)
109
108
  this.timer4 = null
110
- clearTimeout(this.timer5)
111
- this.timer5 = null
112
109
  // Clear sort cache to prevent memory leaks
113
110
  this._sortCache?.clear()
114
111
  this._lastSortArgs = null
@@ -455,9 +452,7 @@ export default class Sftp extends Component {
455
452
  }
456
453
  const type = files[0]?.type || _type
457
454
  const func = this[type + 'Del']
458
- for (const f of files) {
459
- await func(f)
460
- }
455
+ await Promise.all(files.map(f => func(f)))
461
456
  if (type === typeMap.remote) {
462
457
  await wait(500)
463
458
  }
@@ -560,8 +555,10 @@ export default class Sftp extends Component {
560
555
  }
561
556
 
562
557
  initRemoteAll = async () => {
563
- await this.remoteList()
564
- this.remoteListOwner()
558
+ await Promise.all([
559
+ this.remoteList(),
560
+ this.remoteListOwner().catch(e => console.debug('remoteListOwner error:', e))
561
+ ])
565
562
  }
566
563
 
567
564
  modifier = (...args) => {
@@ -647,12 +644,10 @@ export default class Sftp extends Component {
647
644
  }
648
645
 
649
646
  remoteListOwner = async () => {
650
- const remoteUidTree = await owner.remoteListUsers(
651
- this.props.pid
652
- )
653
- const remoteGidTree = await owner.remoteListGroups(
654
- this.props.pid
655
- )
647
+ const [remoteUidTree, remoteGidTree] = await Promise.all([
648
+ owner.remoteListUsers(this.props.pid),
649
+ owner.remoteListGroups(this.props.pid)
650
+ ])
656
651
  this.setState({
657
652
  remoteGidTree,
658
653
  remoteUidTree
@@ -660,8 +655,10 @@ export default class Sftp extends Component {
660
655
  }
661
656
 
662
657
  localListOwner = async () => {
663
- const localUidTree = await owner.localListUsers()
664
- const localGidTree = await owner.localListGroups()
658
+ const [localUidTree, localGidTree] = await Promise.all([
659
+ owner.localListUsers(),
660
+ owner.localListGroups()
661
+ ])
665
662
  this.setState({
666
663
  localGidTree,
667
664
  localUidTree
@@ -800,14 +797,6 @@ export default class Sftp extends Component {
800
797
  sftpCreated: true
801
798
  })
802
799
  })
803
- this.timer5 = setTimeout(() => {
804
- if (this.type !== 'ftp') {
805
- this.updateRemoteList(remote, remotePath, sftp)
806
- }
807
- this.props.editTab(tab.id, {
808
- sftpCreated: true
809
- })
810
- }, 1000)
811
800
  } catch (e) {
812
801
  const update = {
813
802
  remoteLoading: false,
@@ -828,41 +817,34 @@ export default class Sftp extends Component {
828
817
  remotePath,
829
818
  sftp
830
819
  ) => {
831
- const remote = []
832
- for (const r of remotes) {
833
- const { name } = r
834
- if (r.isSymbol) {
835
- const linkPath = resolve(remotePath, name)
836
- let realpath = await sftp.readlink(linkPath)
837
- .catch(e => {
838
- console.debug(e)
839
- return null
840
- })
841
- if (!realpath) {
842
- continue
843
- }
844
- if (!isAbsPath(realpath)) {
845
- realpath = resolve(remotePath, realpath)
846
- realpath = await sftp.realpath(realpath)
820
+ // Resolve all symlinks in parallel for faster updates.
821
+ // Use sftp.stat (which follows symlinks) directly on the link path
822
+ // instead of readlink + realpath + stat, reducing 2-3 SFTP round
823
+ // trips per symlink to just 1.
824
+ const results = await Promise.all(
825
+ remotes.map(async r => {
826
+ if (!r.isSymbol) {
827
+ r.isSymbolicLink = false
828
+ return r
847
829
  }
830
+ const linkPath = resolve(remotePath, r.name)
848
831
  const realFileInfo = await getRemoteFileInfo(
849
832
  sftp,
850
- realpath
833
+ linkPath
851
834
  ).catch(e => {
852
835
  console.debug('seems a bad symbolic link')
853
836
  console.debug(e)
854
837
  return null
855
838
  })
856
839
  if (!realFileInfo) {
857
- continue
840
+ return null
858
841
  }
859
842
  r.isSymbolicLink = true
860
843
  r.isDirectory = realFileInfo.isDirectory
861
- } else {
862
- r.isSymbolicLink = false
863
- }
864
- remote.push(r)
865
- }
844
+ return r
845
+ })
846
+ )
847
+ const remote = results.filter(Boolean)
866
848
  const update = {
867
849
  remote,
868
850
  remoteFileTree: this.buildTree(remote, typeMap.remote)
@@ -892,14 +874,13 @@ export default class Sftp extends Component {
892
874
  this.getCwdLocal() ||
893
875
  this.getLocalHome()
894
876
  const locals = await window.fs.readdirAsync(localPath)
895
- const local = []
896
- for (const name of locals) {
897
- const p = resolve(localPath, name)
898
- const fileObj = await getLocalFileInfo(p).catch(console.log)
899
- if (fileObj) {
900
- local.push(fileObj)
901
- }
902
- }
877
+ const results = await Promise.all(
878
+ locals.map(name => {
879
+ const p = resolve(localPath, name)
880
+ return getLocalFileInfo(p).catch(console.log)
881
+ })
882
+ )
883
+ const local = results.filter(Boolean)
903
884
  const update = {
904
885
  local,
905
886
  inited: true,
@@ -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'>
@@ -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'
@@ -1043,12 +1044,16 @@ class Term extends Component {
1043
1044
  label: e('selectall'),
1044
1045
  extra: selectAllShortcut
1045
1046
  },
1046
- {
1047
- key: 'explainWithAi',
1048
- icon: <AIIcon />,
1049
- label: e('explainWithAi'),
1050
- disabled: !hasSelection
1051
- },
1047
+ ...(
1048
+ isAIDisabled()
1049
+ ? []
1050
+ : [{
1051
+ key: 'explainWithAi',
1052
+ icon: <AIIcon />,
1053
+ label: e('explainWithAi'),
1054
+ disabled: !hasSelection
1055
+ }]
1056
+ ),
1052
1057
  {
1053
1058
  key: 'onClear',
1054
1059
  icon: <iconsMap.ReloadOutlined />,
@@ -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
  {
@@ -15,7 +15,7 @@ import {
15
15
  } from '@ant-design/icons'
16
16
  import message from '../common/message'
17
17
  import Modal from '../common/modal'
18
- import { copy } from '../../common/clipboard'
18
+ import { copy, readClipboard } from '../../common/clipboard'
19
19
  import VncForm from './vnc-form'
20
20
  import RemoteFloatControl from '../common/remote-float-control'
21
21
  import './vnc.styl'
@@ -49,11 +49,16 @@ export default class VncSession extends PureComponent {
49
49
 
50
50
  domRef = createRef()
51
51
 
52
+ // clipboard sync state (local -> remote direction)
53
+ lastClip = null
54
+ vncViewOnly = false
55
+
52
56
  componentDidMount () {
53
57
  this.remoteInit()
54
58
  }
55
59
 
56
60
  componentWillUnmount () {
61
+ this._detachClipboardSync()
57
62
  this.rfb && this.rfb.disconnect()
58
63
  delete this.rfb
59
64
  clearTimeout(this.timer)
@@ -379,6 +384,7 @@ export default class VncSession extends PureComponent {
379
384
  rfb.compressionLevel = compressionLevel
380
385
  rfb.viewOnly = viewOnly
381
386
  rfb.showDotCursor = showDotCursor
387
+ this.vncViewOnly = viewOnly
382
388
  this.rfb = rfb
383
389
  }
384
390
 
@@ -388,12 +394,14 @@ export default class VncSession extends PureComponent {
388
394
  this.setState({
389
395
  loading: false
390
396
  })
397
+ this._attachClipboardSync()
391
398
  }
392
399
 
393
400
  onDesktopsize = (event) => {
394
401
  }
395
402
 
396
403
  onDisconnect = () => {
404
+ this._detachClipboardSync()
397
405
  this.setStatus(statusMap.error)
398
406
  }
399
407
 
@@ -433,7 +441,61 @@ export default class VncSession extends PureComponent {
433
441
  }
434
442
 
435
443
  onClipboard = (event) => {
436
- copy(event.detail.text)
444
+ const text = event?.detail?.text
445
+ if (!text || text === this.lastClip) {
446
+ return
447
+ }
448
+ // Track so our click-driven sync won't echo this same text back to the
449
+ // server (some servers re-broadcast what they receive).
450
+ this.lastClip = text
451
+ copy(text)
452
+ }
453
+
454
+ // Push the current local clipboard to the remote when it has changed.
455
+ // This implements the local -> remote direction that noVNC cannot do itself.
456
+ // Triggered on view click instead of polling, so it only runs right when the
457
+ // user is about to interact with the remote desktop (e.g. before pasting).
458
+ handleViewMouseDown = () => {
459
+ const rfb = this.rfb
460
+ if (!rfb || this.vncViewOnly) {
461
+ return
462
+ }
463
+ let text
464
+ try {
465
+ text = readClipboard()
466
+ } catch (e) {
467
+ return
468
+ }
469
+ if (!text || text === this.lastClip) {
470
+ return
471
+ }
472
+ this.lastClip = text
473
+ try {
474
+ rfb.clipboardPasteFrom(text)
475
+ } catch (e) {
476
+ // clipboardPasteFrom can throw if the socket is in a bad state; ignore
477
+ }
478
+ }
479
+
480
+ // noVNC calls stopPropagation() on canvas mouse events in the bubble phase,
481
+ // so React's delegated onClick (or any bubble-phase listener) never fires.
482
+ // We attach a CAPTURE-phase mousedown listener on the wrapper so it runs
483
+ // before noVNC can stop propagation. mousedown also fires earlier than click,
484
+ // giving the remote clipboard time to update before the user pastes.
485
+ _attachClipboardSync = () => {
486
+ this._detachClipboardSync()
487
+ const el = this.domRef.current
488
+ if (!el) {
489
+ return
490
+ }
491
+ el.addEventListener('mousedown', this.handleViewMouseDown, true)
492
+ }
493
+
494
+ _detachClipboardSync = () => {
495
+ const el = this.domRef.current
496
+ if (el) {
497
+ el.removeEventListener('mousedown', this.handleViewMouseDown, true)
498
+ }
437
499
  }
438
500
 
439
501
  onBell = (event) => {
@@ -542,6 +604,7 @@ export default class VncSession extends PureComponent {
542
604
  }
543
605
 
544
606
  handleReInit = () => {
607
+ this._detachClipboardSync()
545
608
  this.rfb?.disconnect()
546
609
  delete this.rfb
547
610
  this.remoteInit()
@@ -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.187",
3
+ "version": "5.0.6",
4
4
  "description": "react components src for electerm",
5
5
  "main": "./client/components/main/main.jsx",
6
6
  "license": "MIT",