@electerm/electerm-react 5.5.15 → 5.5.25

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 (40) hide show
  1. package/README.md +12 -2
  2. package/client/common/constants.js +14 -0
  3. package/client/common/default-setting.js +2 -2
  4. package/client/common/parse-vv.js +313 -0
  5. package/client/common/theme-defaults.js +5 -2
  6. package/client/common/update-check.js +5 -4
  7. package/client/components/ai/ai-presets.js +13 -2
  8. package/client/components/bg/css-overwrite.jsx +16 -1
  9. package/client/components/bookmark-form/common/fields.jsx +3 -0
  10. package/client/components/bookmark-form/common/vv-file-field.jsx +137 -0
  11. package/client/components/bookmark-form/config/spice.js +1 -0
  12. package/client/components/footer/footer-entry.jsx +39 -14
  13. package/client/components/icons/ai-icon.styl +4 -1
  14. package/client/components/layout/layout.jsx +7 -1
  15. package/client/components/main/main.jsx +9 -2
  16. package/client/components/main/term-fullscreen.styl +7 -0
  17. package/client/components/quick-commands/quick-command-ai-editor.jsx +255 -0
  18. package/client/components/quick-commands/quick-command-ai-editor.styl +13 -0
  19. package/client/components/quick-commands/quick-commands-box.jsx +23 -5
  20. package/client/components/quick-commands/quick-commands-form-elem.jsx +129 -65
  21. package/client/components/setting-panel/list.jsx +2 -2
  22. package/client/components/side-panel-r/right-side-panel.styl +11 -1
  23. package/client/components/side-panel-r/side-panel-r.jsx +45 -9
  24. package/client/components/sidebar/bookmark-select.jsx +3 -1
  25. package/client/components/sidebar/index.jsx +13 -4
  26. package/client/components/sidebar/side-panel.jsx +8 -3
  27. package/client/components/sidebar/sidebar-panel.jsx +20 -7
  28. package/client/components/terminal/shortcut-bar.styl +14 -0
  29. package/client/components/terminal/unix-timestamp-tooltip.jsx +27 -1
  30. package/client/components/terminal-info/terminal-info.styl +3 -0
  31. package/client/components/theme/terminal-theme-list.styl +3 -0
  32. package/client/components/theme/theme-ai-editor.jsx +2 -2
  33. package/client/components/theme/theme-form.jsx +2 -2
  34. package/client/components/theme/theme-list-item.jsx +59 -25
  35. package/client/css/mobile.styl +28 -0
  36. package/client/store/common.js +44 -0
  37. package/client/store/init-state.js +2 -1
  38. package/client/store/sidebar.js +5 -1
  39. package/client/store/store.js +9 -2
  40. package/package.json +1 -1
@@ -24,7 +24,7 @@ const {
24
24
 
25
25
  export default auto(function FooterEntry (props) {
26
26
  function handleInfoPanel () {
27
- window.store.openInfoPanel()
27
+ window.store.toggleInfoPanel()
28
28
  }
29
29
 
30
30
  function batchInput (cmd, selectedTabIds) {
@@ -106,11 +106,19 @@ export default auto(function FooterEntry (props) {
106
106
  )
107
107
  }
108
108
 
109
+ function handleAIPanel () {
110
+ window.store.toggleAIPanel()
111
+ }
112
+
109
113
  function renderAIIcon () {
114
+ const { rightPanelVisible, rightPanelTab } = props.store
115
+ // same contract as the info icon: lit while its panel is open
116
+ const active = rightPanelVisible && rightPanelTab === 'ai'
110
117
  return (
111
118
  <div className='terminal-footer-unit terminal-footer-ai'>
112
119
  <AIIcon
113
- onClick={window.store.handleOpenAIPanel}
120
+ onClick={handleAIPanel}
121
+ className={active ? 'active' : ''}
114
122
  />
115
123
  </div>
116
124
  )
@@ -176,11 +184,15 @@ export default auto(function FooterEntry (props) {
176
184
  if (loading) {
177
185
  return null
178
186
  }
187
+ const { rightPanelVisible, rightPanelTab } = props.store
188
+ // keep the icon lit while its panel is open so it reads as "click to close"
189
+ const active = rightPanelVisible && rightPanelTab === 'info'
179
190
  return (
180
191
  <div className='terminal-footer-unit terminal-footer-info'>
181
192
  <BarChartOutlined
182
193
  onClick={handleInfoPanel}
183
- className='pointer font14 terminal-info-icon'
194
+ className={'pointer font14 terminal-info-icon' + (active ? ' active' : '')}
195
+ title={e('info')}
184
196
  />
185
197
  </div>
186
198
  )
@@ -202,7 +214,11 @@ export default auto(function FooterEntry (props) {
202
214
  leftSidePanelWidth,
203
215
  leftSideBarWidth,
204
216
  openedSideBar,
205
- inActiveTerminal
217
+ inActiveTerminal,
218
+ rightPanelVisible,
219
+ rightPanelPinned,
220
+ rightPanelWidth,
221
+ isMobile
206
222
  } = props.store
207
223
  const w = leftSideBarWidth + leftSidePanelWidth
208
224
  // icon bar hidden: show a control on the left of the footer to bring the
@@ -217,16 +233,25 @@ export default auto(function FooterEntry (props) {
217
233
  </div>
218
234
  )
219
235
  : null
220
- const sideProps = openedSideBar
221
- ? {
222
- className: 'main-footer',
223
- style: {
224
- left: `${w}px`
225
- }
226
- }
227
- : {
228
- className: 'main-footer'
229
- }
236
+ // The footer mirrors the space layout.jsx reserves on both sides.
237
+ // Left: reserve while the panel is open OR pinned. A pinned left panel is
238
+ // always open, so this is a superset of the pin state — and it is what keeps
239
+ // the footer from painting over the panel, which spans bottom 0 in both
240
+ // states. Right: reserve only for a pinned panel, because the unpinned right
241
+ // panel is an overlay that stops above the footer and so never needs the
242
+ // space. Mobile reserves nothing on either side, matching layout.jsx, and the
243
+ // right panel has no pin control there at all.
244
+ const footerStyle = {}
245
+ if (openedSideBar && !isMobile) {
246
+ footerStyle.left = `${w}px`
247
+ }
248
+ if (rightPanelVisible && rightPanelPinned && !isMobile) {
249
+ footerStyle.right = `${rightPanelWidth}px`
250
+ }
251
+ const sideProps = {
252
+ className: 'main-footer',
253
+ style: footerStyle
254
+ }
230
255
  if (
231
256
  !inActiveTerminal
232
257
  ) {
@@ -1,3 +1,6 @@
1
1
  .ai-icon
2
2
  display inline-block
3
- cursor pointer
3
+ cursor pointer
4
+ // panel open: same "on" colour the info icon and the panel pin button use
5
+ &.active
6
+ color var(--success)
@@ -151,7 +151,13 @@ export default auto(function Layout (props) {
151
151
  'leftSidePanelWidth',
152
152
  'leftSideBarWidth',
153
153
  'openedSideBar',
154
- 'currentQuickCommands'
154
+ 'currentQuickCommands',
155
+ // the quick-command popup mirrors the footer's horizontal extent, so it
156
+ // needs the same right-panel state the footer uses
157
+ 'rightPanelVisible',
158
+ 'rightPanelPinned',
159
+ 'rightPanelWidth',
160
+ 'isMobile'
155
161
  ])
156
162
  const sessionsProps = {
157
163
  styles: styles.wrapStyles,
@@ -148,7 +148,12 @@ export default auto(function Index (props) {
148
148
  'is-mac': isMac,
149
149
  'not-mac': !isMac,
150
150
  'is-win': isWin,
151
- pinned,
151
+ // The left sidebar dock is desktop-only (see sidebar/index.jsx). This
152
+ // class drives the docked-layout CSS — the footer's left offset and the
153
+ // sidebar panel's top padding — so it has to follow the same gate as the
154
+ // layout maths in layout.jsx, or a pin persisted from a desktop session
155
+ // would restyle the mobile layout.
156
+ pinned: pinned && !store.isMobile,
152
157
  'not-win': !isWin,
153
158
  'qm-pinned': pinnedQuickCommandBar,
154
159
  fullscreen,
@@ -226,6 +231,7 @@ export default auto(function Index (props) {
226
231
  transferHistory: copiedHistory,
227
232
  upgradeInfo,
228
233
  pinned,
234
+ isMobile: store.isMobile,
229
235
  leftSideBarIcons: config.leftSideBarIcons,
230
236
  widgetInstancesLength: widgetInstances.length
231
237
  }
@@ -253,7 +259,8 @@ export default auto(function Index (props) {
253
259
  rightPanelPinned: store.rightPanelPinned,
254
260
  rightPanelWidth: store.rightPanelWidth,
255
261
  title: rightPanelTitle,
256
- rightPanelTab
262
+ rightPanelTab,
263
+ isMobile: store.isMobile
257
264
  }
258
265
  const sshConfigProps = {
259
266
  ...pick(store, [
@@ -37,6 +37,13 @@
37
37
  right 10px !important
38
38
  bottom 10px !important
39
39
 
40
+ // RDP/VNC/Spice fullscreen has no footer (it is in the hide list above), so
41
+ // the right panel has nothing to clear and may use the full height. When the
42
+ // footer IS kept, see .fullscreen.fs-with-footer below.
43
+ .fullscreen:not(.fs-with-footer)
44
+ .right-side-panel
45
+ bottom 0
46
+
40
47
  // Terminal fullscreen keeps the footer (and the quick-command popup above it)
41
48
  // visible; the session shrinks to sit on top of the footer. RDP/VNC/Spice
42
49
  // fullscreen stays footer-less — inActiveTerminal is false for those tabs, so
@@ -0,0 +1,255 @@
1
+ /**
2
+ * AI-powered quick command generator.
3
+ *
4
+ * This is the "create with AI" tab of the quick command form: describe the
5
+ * quick command in natural language, the configured AI provider returns the
6
+ * quick command (name + sub commands in JSON), and the result is written back
7
+ * into the very same form through `onChange`, so the user reviews/edits it in
8
+ * the manual tab before saving.
9
+ */
10
+ import { useState, useEffect } from 'react'
11
+ import { Button, Input, Space, Alert } from 'antd'
12
+ import {
13
+ RobotOutlined,
14
+ LoadingOutlined
15
+ } from '@ant-design/icons'
16
+ import message from '../common/message'
17
+ import { appendMandatoryGuardrails } from '../ai/ai-guardrails'
18
+ import AiHistory, { addHistoryItem } from '../ai/ai-history.jsx'
19
+ import HelpIcon from '../common/help-icon'
20
+ import { getItem, setItem } from '../../common/safe-local-storage'
21
+ import templates from './templates'
22
+ import './quick-command-ai-editor.styl'
23
+
24
+ const STORAGE_KEY_DESC = 'ai_quick_command_description'
25
+ const STORAGE_KEY_HISTORY = 'ai_quick_command_history'
26
+ const EVENT_NAME_HISTORY = 'ai-quick-command-history-update'
27
+ const defaultDelay = 100
28
+ const maxDelay = 65535
29
+ const maxNameLength = 60
30
+ const { TextArea } = Input
31
+ const e = window.translate
32
+
33
+ const wiki = 'https://github.com/electerm/electerm/wiki/Create-quick-command-by-AI'
34
+
35
+ /**
36
+ * build the prompt that asks the AI for one quick command definition
37
+ * @param {string} description
38
+ * @return {string}
39
+ */
40
+ export function buildPrompt (description) {
41
+ const lang = window.store.config.languageAI || window.store.getLangName()
42
+ const templateList = templates.map(t => `{{${t}}}`).join(', ')
43
+ return `You are an electerm quick command generator. Based on the user's natural language description, generate ONE quick command definition in JSON format.
44
+
45
+ The quick command JSON shape:
46
+ {
47
+ "name": "string (required) - short name of the quick command",
48
+ "commands": [
49
+ {
50
+ "name": "string - short name of this step",
51
+ "command": "string (required) - the exact shell command to send to the terminal",
52
+ "delay": "number - milliseconds to wait before sending this step, 1-${maxDelay}, default ${defaultDelay}"
53
+ }
54
+ ],
55
+ "inputOnly": "boolean - true means only type the command into the terminal without pressing enter, default false",
56
+ "labels": "array of strings - optional tags used to group quick commands"
57
+ }
58
+
59
+ Important rules:
60
+ 1. Respond with ONE quick command object, never an array of quick commands
61
+ 2. Split a multi step operation into several "commands" items, one shell command per item, and give a later step a longer "delay" when the step before it takes time
62
+ 3. "command" must be a plain shell command, no markdown, no code fence, no trailing newline
63
+ 4. Keep "name" short (max ${maxNameLength} chars) and write it in ${lang} language, labels included
64
+ 5. You may use the built in templates ${templateList} inside a command when the user asks for clipboard content, current time or current date
65
+ 6. Never invent hosts, paths, ports or credentials the user did not mention
66
+ 7. Respond ONLY with valid JSON, no markdown formatting and no extra explanation
67
+
68
+ User description: ${description}
69
+
70
+ Generate the quick command JSON:`
71
+ }
72
+
73
+ function stripFence (str) {
74
+ let s = str.trim()
75
+ if (s.startsWith('```json')) {
76
+ s = s.slice(7)
77
+ } else if (s.startsWith('```')) {
78
+ s = s.slice(3)
79
+ }
80
+ if (s.endsWith('```')) {
81
+ s = s.slice(0, -3)
82
+ }
83
+ return s.trim()
84
+ }
85
+
86
+ /**
87
+ * Normalize whatever the model returned into a quick command definition the
88
+ * quick command form can consume. Tolerates markdown fences, prose around the
89
+ * JSON, an array payload and string commands.
90
+ * @param {string|object} raw
91
+ * @return {object|null} {name, commands, inputOnly, labels}
92
+ */
93
+ export function normalizeQuickCommand (raw) {
94
+ if (!raw) {
95
+ return null
96
+ }
97
+ let data = raw
98
+ if (typeof data === 'string') {
99
+ const txt = stripFence(data)
100
+ try {
101
+ data = JSON.parse(txt)
102
+ } catch (err) {
103
+ // the model sometimes wraps the JSON in explanation text
104
+ const match = txt.match(/[{[][\s\S]*[}\]]/)
105
+ if (!match) {
106
+ return null
107
+ }
108
+ try {
109
+ data = JSON.parse(match[0])
110
+ } catch (err2) {
111
+ return null
112
+ }
113
+ }
114
+ }
115
+ if (Array.isArray(data)) {
116
+ data = data[0]
117
+ }
118
+ if (!data || typeof data !== 'object') {
119
+ return null
120
+ }
121
+ const rawCommands = Array.isArray(data.commands)
122
+ ? data.commands
123
+ : (data.command ? [data] : [])
124
+ const commands = rawCommands.map(c => {
125
+ if (typeof c === 'string') {
126
+ return {
127
+ name: '',
128
+ command: c,
129
+ delay: defaultDelay
130
+ }
131
+ }
132
+ const delay = Number(c.delay)
133
+ return {
134
+ name: (c.name || '').toString(),
135
+ command: (c.command || c.cmd || '').toString(),
136
+ delay: delay >= 1 ? Math.min(Math.round(delay), maxDelay) : defaultDelay
137
+ }
138
+ }).filter(c => c.command.trim())
139
+ if (!commands.length) {
140
+ return null
141
+ }
142
+ const name = (data.name || data.title || '').toString().trim()
143
+ return {
144
+ name: name || commands[0].command.slice(0, maxNameLength),
145
+ commands,
146
+ inputOnly: !!data.inputOnly,
147
+ labels: Array.isArray(data.labels)
148
+ ? data.labels.map(l => l.toString()).filter(Boolean)
149
+ : []
150
+ }
151
+ }
152
+
153
+ export default function QuickCommandAiEditor (props) {
154
+ const { onChange } = props
155
+ const [description, setDescription] = useState(() => getItem(STORAGE_KEY_DESC) || '')
156
+ const [loading, setLoading] = useState(false)
157
+ const [preview, setPreview] = useState('')
158
+
159
+ useEffect(() => {
160
+ setItem(STORAGE_KEY_DESC, description)
161
+ }, [description])
162
+
163
+ async function handleGenerate () {
164
+ if (window.store.aiConfigMissing()) {
165
+ window.store.toggleAIConfig()
166
+ return
167
+ }
168
+ if (!description.trim()) {
169
+ return message.warning(e('description') + ' ' + e('required'))
170
+ }
171
+ setLoading(true)
172
+ try {
173
+ const config = window.store.config
174
+ const aiResponse = await window.pre.runGlobalAsync(
175
+ 'AIchat',
176
+ buildPrompt(description),
177
+ config.modelAI,
178
+ appendMandatoryGuardrails('You are a helpful assistant that generates electerm quick commands.'),
179
+ config.baseURLAI,
180
+ config.apiPathAI,
181
+ config.apiKeyAI,
182
+ config.proxyAI,
183
+ false, // disable streaming, we need the whole JSON at once
184
+ config.authHeaderNameAI
185
+ )
186
+ if (aiResponse && aiResponse.error) {
187
+ throw new Error(aiResponse.error)
188
+ }
189
+ const data = normalizeQuickCommand(aiResponse && aiResponse.response)
190
+ if (!data) {
191
+ throw new Error('no quick command found in AI response')
192
+ }
193
+ setPreview(JSON.stringify(data, null, 2))
194
+ addHistoryItem(STORAGE_KEY_HISTORY, description, EVENT_NAME_HISTORY)
195
+ onChange(data)
196
+ message.success(e('Done'))
197
+ } catch (err) {
198
+ console.error('AI quick command generation error:', err)
199
+ message.error('Can not generate quick command from AI response: ' + err.message)
200
+ } finally {
201
+ setLoading(false)
202
+ }
203
+ }
204
+
205
+ const textAreaProps = {
206
+ value: description,
207
+ onChange: ev => setDescription(ev.target.value),
208
+ placeholder: e('description'),
209
+ autoSize: { minRows: 4, maxRows: 8 },
210
+ disabled: loading
211
+ }
212
+
213
+ const generateBtnProps = {
214
+ type: 'primary',
215
+ onClick: handleGenerate,
216
+ disabled: !description.trim(),
217
+ icon: loading ? <LoadingOutlined /> : <RobotOutlined />,
218
+ loading
219
+ }
220
+
221
+ return (
222
+ <div className='qm-ai-editor'>
223
+ <div className='pd1b'>
224
+ <Alert
225
+ type='info'
226
+ showIcon
227
+ title={e('aiSecurityNotice')}
228
+ />
229
+ </div>
230
+ <div className='pd1b'>
231
+ <TextArea {...textAreaProps} />
232
+ </div>
233
+ <AiHistory
234
+ storageKey={STORAGE_KEY_HISTORY}
235
+ eventName={EVENT_NAME_HISTORY}
236
+ onSelect={setDescription}
237
+ />
238
+ <div className='pd1t'>
239
+ <Space>
240
+ <Button {...generateBtnProps}>
241
+ {e('submit')}
242
+ </Button>
243
+ <HelpIcon link={wiki} />
244
+ </Space>
245
+ </div>
246
+ {
247
+ !!preview && (
248
+ <div className='pd1t'>
249
+ <pre className='qm-ai-preview'>{preview}</pre>
250
+ </div>
251
+ )
252
+ }
253
+ </div>
254
+ )
255
+ }
@@ -0,0 +1,13 @@
1
+ // "create with AI" tab of the quick command form
2
+ .qm-ai-editor
3
+ // the generated quick command, kept visible for reference after it has been
4
+ // written back into the form
5
+ .qm-ai-preview
6
+ padding 12px
7
+ margin 0
8
+ max-height 300px
9
+ overflow auto
10
+ font-size 12px
11
+ font-family monospace
12
+ white-space pre-wrap
13
+ word-break break-word
@@ -163,7 +163,11 @@ export default function QuickCommandsFooterBox (props) {
163
163
  inActiveTerminal,
164
164
  leftSidePanelWidth,
165
165
  leftSideBarWidth,
166
- openedSideBar
166
+ openedSideBar,
167
+ rightPanelVisible,
168
+ rightPanelPinned,
169
+ rightPanelWidth,
170
+ isMobile
167
171
  } = props
168
172
  if ((!openQuickCommandBar && !pinnedQuickCommandBar) || !inActiveTerminal) {
169
173
  return null
@@ -189,12 +193,26 @@ export default function QuickCommandsFooterBox (props) {
189
193
  : 'text'
190
194
  const cls = classNames('qm-list-wrap')
191
195
  const type = qmSortByFrequency ? 'primary' : 'default'
192
- const w = openedSideBar ? leftSideBarWidth + leftSidePanelWidth : leftSideBarWidth
196
+ // Mirrors the footer's left offset. Mobile reserves nothing for the side
197
+ // panel — it is a full-screen drawer there (store.leftSidePanelWidth returns
198
+ // the viewport width), so adding it would push the popup off-screen.
199
+ const w = openedSideBar && !isMobile
200
+ ? leftSideBarWidth + leftSidePanelWidth
201
+ : leftSideBarWidth
202
+ // Keep the popup's horizontal extent in step with the footer (same `w`
203
+ // expression above). A pinned right panel is a dock that reserves its width
204
+ // in layout.jsx, so give that width up here too — otherwise the popup's right
205
+ // end slides under the dock. Unpinned is an overlay and reserves nothing, and
206
+ // mobile is excluded to match layout.jsx.
207
+ const qmStyle = {
208
+ left: w
209
+ }
210
+ if (rightPanelVisible && rightPanelPinned && !isMobile) {
211
+ qmStyle.right = rightPanelWidth
212
+ }
193
213
  const qmProps = {
194
214
  className: 'qm-wrap-tooltip',
195
- style: {
196
- left: w
197
- }
215
+ style: qmStyle
198
216
  }
199
217
  return (
200
218
  <div