@electerm/electerm-react 5.5.25 → 5.5.35

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 (41) hide show
  1. package/README.md +5 -5
  2. package/client/common/bookmark-schemas.js +3 -0
  3. package/client/common/constants.js +2 -0
  4. package/client/common/create-title.jsx +5 -1
  5. package/client/common/default-setting.js +2 -0
  6. package/client/common/parse-vv.js +119 -25
  7. package/client/common/vv-to-tab.js +120 -0
  8. package/client/components/ai/agent-tool-call-card.jsx +19 -4
  9. package/client/components/ai/agent.js +33 -16
  10. package/client/components/ai/ai-chat-history-item.jsx +12 -48
  11. package/client/components/ai/ai-chat.jsx +82 -25
  12. package/client/components/ai/ai-config-props.js +12 -1
  13. package/client/components/ai/ai-config.jsx +14 -0
  14. package/client/components/ai/ai-context-indicator.jsx +83 -0
  15. package/client/components/ai/ai-context.js +272 -0
  16. package/client/components/ai/ai-output.jsx +4 -0
  17. package/client/components/ai/ai-presets.js +1 -1
  18. package/client/components/ai/ai.styl +105 -6
  19. package/client/components/ai/copy-icon.jsx +31 -0
  20. package/client/components/bookmark-form/common/vv-file-field.jsx +3 -2
  21. package/client/components/bookmark-form/config/spice.js +21 -0
  22. package/client/components/footer/cmd-history.jsx +279 -65
  23. package/client/components/footer/cmd-history.styl +68 -1
  24. package/client/components/footer/footer-entry.jsx +7 -1
  25. package/client/components/layout/layout.jsx +12 -19
  26. package/client/components/main/main.jsx +14 -1
  27. package/client/components/quick-commands/qm.styl +58 -0
  28. package/client/components/quick-commands/quick-commands-box.jsx +180 -83
  29. package/client/components/quick-commands/quick-commands-select.jsx +11 -0
  30. package/client/components/setting-panel/setting-common.jsx +2 -0
  31. package/client/components/side-panel-r/side-panel-r.jsx +11 -4
  32. package/client/components/sidebar/bookmark-select.jsx +14 -2
  33. package/client/components/sidebar/history-item.jsx +20 -4
  34. package/client/components/sidebar/history.jsx +7 -0
  35. package/client/components/tree-list/tree-list.jsx +31 -3
  36. package/client/store/common.js +120 -32
  37. package/client/store/init-state.js +8 -0
  38. package/client/store/load-data.js +72 -0
  39. package/client/store/store.js +6 -0
  40. package/client/store/watch.js +3 -1
  41. package/package.json +1 -1
@@ -4,7 +4,9 @@ import './right-side-panel.styl'
4
4
  import {
5
5
  CloseCircleOutlined,
6
6
  PushpinOutlined,
7
- BarChartOutlined
7
+ BarChartOutlined,
8
+ HistoryOutlined,
9
+ ThunderboltOutlined
8
10
  } from '@ant-design/icons'
9
11
  import {
10
12
  Typography,
@@ -57,9 +59,14 @@ export default memo(function RightSidePanel (
57
59
  // from a desktop session must not turn the panel into a dock here — that
58
60
  // would make it full height over the footer for no reachable reason.
59
61
  const pinned = rightPanelPinned && !isMobile
60
- const tag = rightPanelTab === 'ai'
61
- ? <Tag className='mg1r'>AI</Tag>
62
- : <BarChartOutlined className='mg1r' />
62
+ // one glyph per tab, so the panel says what it is showing; the info tab is
63
+ // the fallback, since a stale or unknown tab falls back to it everywhere else
64
+ const tagMap = {
65
+ ai: <Tag className='mg1r'>AI</Tag>,
66
+ cmdHistory: <HistoryOutlined className='mg1r' />,
67
+ quickCommands: <ThunderboltOutlined className='mg1r' />
68
+ }
69
+ const tag = tagMap[rightPanelTab] || <BarChartOutlined className='mg1r' />
63
70
 
64
71
  function onDragEnd (nw) {
65
72
  window.store.setRightSidePanelWidth(nw)
@@ -2,11 +2,13 @@
2
2
  * bookmark select
3
3
  */
4
4
 
5
+ import { useState } from 'react'
5
6
  import { auto } from 'manate/react'
6
7
  import TreeList from '../tree-list/tree-list'
7
8
 
8
9
  export default auto(function BookmarkSelect (props) {
9
10
  const { store, from, autoFocus } = props
11
+ const [activeItemId, setActiveItemId] = useState('')
10
12
  const {
11
13
  listStyle,
12
14
  openedSideBar,
@@ -19,7 +21,7 @@ export default auto(function BookmarkSelect (props) {
19
21
  if (from === 'sidebar' && openedSideBar !== 'bookmarks') {
20
22
  return null
21
23
  }
22
- const onClickItem = (item) => {
24
+ const openBookmark = (item) => {
23
25
  // A pinned (docked) panel stays open; anything else — including mobile,
24
26
  // where pinning is not a reachable mode — dismisses on selection.
25
27
  if (!(store.pinned && !store.isMobile)) {
@@ -27,10 +29,20 @@ export default auto(function BookmarkSelect (props) {
27
29
  }
28
30
  store.onSelectBookmark(item.id)
29
31
  }
32
+ // doubleClickToOpenBookmark: a single click only marks the row, the session
33
+ // opens on a double click. Touch devices have no double click, so the setting
34
+ // is ignored there. This is the sidebar list only — the settings bookmarks tab
35
+ // keeps selecting for editing on a single click.
36
+ const openOnDoubleClick = !!store.config.doubleClickToOpenBookmark &&
37
+ !store.isTouchDevice
30
38
  const base = {
31
39
  bookmarks: bookmarks || [],
32
40
  type: 'bookmarks',
33
- onClickItem,
41
+ activeItemId: openOnDoubleClick ? activeItemId : undefined,
42
+ onClickItem: openOnDoubleClick
43
+ ? item => setActiveItemId(item.id)
44
+ : openBookmark,
45
+ onDoubleClickItem: openOnDoubleClick ? openBookmark : undefined,
34
46
  listStyle,
35
47
  staticList: true
36
48
  }
@@ -1,4 +1,5 @@
1
- import React, { useCallback, useEffect, useRef } from 'react'
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react'
2
+ import classnames from 'classnames'
2
3
  import createTitle, { createTitleWithTag } from '../../common/create-title'
3
4
  import { DeleteOutlined, BookFilled } from '@ant-design/icons'
4
5
  import { refsStatic } from '../common/ref'
@@ -6,18 +7,32 @@ import { refsStatic } from '../common/ref'
6
7
  export default function HistoryItem (props) {
7
8
  const { store } = window
8
9
  const {
9
- item
10
+ item,
11
+ openOnDoubleClick
10
12
  } = props
13
+ const [active, setActive] = useState(false)
11
14
  const timeoutRef = useRef(null)
12
15
 
13
16
  const handleClick = useCallback(() => {
17
+ // with doubleClickToOpenBookmark on, a click only marks the row — the
18
+ // session opens on the double click below
19
+ if (openOnDoubleClick) {
20
+ setActive(true)
21
+ return
22
+ }
14
23
  if (timeoutRef.current) {
15
24
  clearTimeout(timeoutRef.current)
16
25
  }
17
26
  timeoutRef.current = setTimeout(() => {
18
27
  store.onSelectHistory(item.tab)
19
28
  }, 10)
20
- }, [item.tab])
29
+ }, [item.tab, openOnDoubleClick])
30
+
31
+ const handleDoubleClick = useCallback(() => {
32
+ if (openOnDoubleClick) {
33
+ store.onSelectHistory(item.tab)
34
+ }
35
+ }, [item.tab, openOnDoubleClick])
21
36
 
22
37
  useEffect(() => {
23
38
  return () => {
@@ -47,9 +62,10 @@ export default function HistoryItem (props) {
47
62
  const tt = createTitle(item.tab)
48
63
  return (
49
64
  <div
50
- className='item-list-unit'
65
+ className={classnames('item-list-unit', { active })}
51
66
  title={tt}
52
67
  onClick={handleClick}
68
+ onDoubleClick={handleDoubleClick}
53
69
  >
54
70
  <div className='elli pd1y pd2x'>
55
71
  {title}
@@ -25,6 +25,12 @@ export default auto(function HistoryPanel (props) {
25
25
  const {
26
26
  history
27
27
  } = store
28
+ // doubleClickToOpenBookmark: a single click only marks the row, the session
29
+ // opens on a double click. Touch devices have no double click, so the setting
30
+ // is ignored there. Read here rather than in HistoryItem because this
31
+ // component is `auto` — it re-renders when the input modality flips.
32
+ const openOnDoubleClick = !!store.config.doubleClickToOpenBookmark &&
33
+ !store.isTouchDevice
28
34
  let arr = store.config.disableConnectionHistory ? [] : history
29
35
  if (sortByFrequency) {
30
36
  arr = [...arr].sort((a, b) => { return b.count - a.count })
@@ -75,6 +81,7 @@ export default auto(function HistoryPanel (props) {
75
81
  <HistoryItem
76
82
  key={item.id}
77
83
  item={item}
84
+ openOnDoubleClick={openOnDoubleClick}
78
85
  />
79
86
  )
80
87
  })
@@ -158,7 +158,11 @@ export default class ItemListTree extends Component {
158
158
  : matchedRowKeys[0]
159
159
  const row = rows.find(item => item.key === rowKey)
160
160
  if (row?.item?.id) {
161
- this.selectBookmarkById(row.item.id)
161
+ // Enter is a deliberate activation, so it opens the bookmark even when
162
+ // doubleClickToOpenBookmark makes a single click only mark the row. The
163
+ // settings bookmarks tab passes no onDoubleClickItem, so there Enter
164
+ // still selects for editing, exactly like a click.
165
+ this.selectBookmarkById(row.item.id, this.props.onDoubleClickItem)
162
166
  }
163
167
  }
164
168
  }
@@ -404,7 +408,9 @@ export default class ItemListTree extends Component {
404
408
  }
405
409
  }
406
410
 
407
- selectBookmarkById = (id) => {
411
+ // `act` overrides what a selection does; only the Enter path passes it, so a
412
+ // click keeps whatever meaning the consumer gave onClickItem.
413
+ selectBookmarkById = (id, act) => {
408
414
  const { store } = window
409
415
  store.storeAssign({
410
416
  currentBookmarkGroupId: findBookmarkGroupId(store.bookmarkGroups, id)
@@ -414,7 +420,28 @@ export default class ItemListTree extends Component {
414
420
  d => d.id === id
415
421
  )
416
422
  if (bookmark) {
417
- this.props.onClickItem(bookmark)
423
+ (act || this.props.onClickItem)(bookmark)
424
+ }
425
+ }
426
+
427
+ handleDoubleClick = (e) => {
428
+ // only the static list (the sidebar) passes this, so the bookmark edit page
429
+ // keeps its single click to select behaviour
430
+ const { onDoubleClickItem, bookmarks } = this.props
431
+ if (!onDoubleClickItem) {
432
+ return
433
+ }
434
+ const item = findParentBySel(e.target, '.tree-item')
435
+ // a group toggles on a single click, so a double click would just toggle it
436
+ // twice and land back where it started
437
+ if (!item || item.getAttribute('data-is-group') === 'true') {
438
+ return
439
+ }
440
+ const bookmark = bookmarks.find(
441
+ d => d.id === item.getAttribute('data-item-id')
442
+ )
443
+ if (bookmark) {
444
+ onDoubleClickItem(bookmark)
418
445
  }
419
446
  }
420
447
 
@@ -917,6 +944,7 @@ export default class ItemListTree extends Component {
917
944
  className='item-list-wrap'
918
945
  style={listStyle}
919
946
  ref={this.listRef}
947
+ onDoubleClick={this.handleDoubleClick}
920
948
  >
921
949
  {this.renderVirtualTreeContent(rows, editor)}
922
950
  {this.renderEditorOverlay(editor)}
@@ -5,12 +5,15 @@
5
5
  import handleError from '../common/error-handler'
6
6
  import Modal from '../components/common/modal'
7
7
  import { appendMandatoryGuardrails } from '../components/ai/ai-guardrails'
8
+ import { buildSessionMessages } from '../components/ai/ai-context'
8
9
  import { debounce, some, get, pickBy } from 'lodash-es'
9
10
  import {
10
11
  leftSidePanelWidthKey,
11
12
  leftSideBarOpenKey,
12
13
  rightSidebarWidthKey,
13
14
  rightPanelPinnedKey,
15
+ cmdHistoryInRightPanelKey,
16
+ quickCommandsInRightPanelKey,
14
17
  addPanelWidthLsKey,
15
18
  connectionMap,
16
19
  lastAiChatSessionIdKey,
@@ -25,7 +28,7 @@ import { requireTermOfUse } from '../common/term-of-use'
25
28
  import { action } from 'manate'
26
29
  import uid from '../common/uid'
27
30
  import deepCopy from 'json-deep-copy'
28
- import { aiConfigsArr } from '../components/ai/ai-config-props'
31
+ import { aiConfigsArr, optionalAIConfigsArr } from '../components/ai/ai-config-props'
29
32
 
30
33
  const e = window.translate
31
34
  const { assign } = Object
@@ -179,6 +182,100 @@ export default Store => {
179
182
  ls.setItem(rightPanelPinnedKey, v + '')
180
183
  window.store.rightPanelPinned = v
181
184
  }
185
+
186
+ // The cmd history panel has two homes: the footer popover (the default) and
187
+ // the right side panel. Which one it is in is a durable preference, persisted
188
+ // like the right panel pin — the footer trigger has to keep landing where the
189
+ // user last put the panel, or the choice would only survive until the next
190
+ // reload.
191
+ Store.prototype.setCmdHistoryInRightPanel = function (v) {
192
+ ls.setItem(cmdHistoryInRightPanelKey, v + '')
193
+ window.store.cmdHistoryInRightPanel = v
194
+ }
195
+
196
+ Store.prototype.openCmdHistoryPanel = function () {
197
+ const { store } = window
198
+ store.rightPanelVisible = true
199
+ store.rightPanelTab = 'cmdHistory'
200
+ }
201
+
202
+ // Same toggle contract as toggleInfoPanel/toggleAIPanel: while the history
203
+ // lives in the right panel, the footer trigger opens and closes it.
204
+ Store.prototype.toggleCmdHistoryPanel = function () {
205
+ const { store } = window
206
+ if (store.rightPanelVisible && store.rightPanelTab === 'cmdHistory') {
207
+ store.rightPanelVisible = false
208
+ return
209
+ }
210
+ store.openCmdHistoryPanel()
211
+ }
212
+
213
+ Store.prototype.moveCmdHistoryToRightPanel = function () {
214
+ const { store } = window
215
+ store.setCmdHistoryInRightPanel(true)
216
+ store.openCmdHistoryPanel()
217
+ }
218
+
219
+ // Hand the panel back to the footer popover and open it there: the move has
220
+ // to be visible, otherwise the panel just looks like it vanished.
221
+ Store.prototype.moveCmdHistoryToFooter = function () {
222
+ const { store } = window
223
+ store.setCmdHistoryInRightPanel(false)
224
+ if (store.rightPanelTab === 'cmdHistory') {
225
+ store.rightPanelVisible = false
226
+ }
227
+ refsStatic.get('CmdHistory')?.openPopover()
228
+ }
229
+
230
+ // The quick command panel has the same two homes as the cmd history one, and
231
+ // the same durable preference: the footer popup (the default) and the right
232
+ // side panel. The footer popup is not a popover but a floating box driven by
233
+ // store.openQuickCommandBar, so "docked" is expressed by the preference alone
234
+ // and the box itself decides which of its two forms to render.
235
+ Store.prototype.setQuickCommandsInRightPanel = function (v) {
236
+ ls.setItem(quickCommandsInRightPanelKey, v + '')
237
+ window.store.quickCommandsInRightPanel = v
238
+ }
239
+
240
+ Store.prototype.openQuickCommandsPanel = function () {
241
+ const { store } = window
242
+ store.rightPanelVisible = true
243
+ store.rightPanelTab = 'quickCommands'
244
+ }
245
+
246
+ // Same toggle contract as toggleInfoPanel/toggleAIPanel/toggleCmdHistoryPanel:
247
+ // while the panel lives in the right panel, the footer Q opens and closes it.
248
+ Store.prototype.toggleQuickCommandsPanel = function () {
249
+ const { store } = window
250
+ if (store.rightPanelVisible && store.rightPanelTab === 'quickCommands') {
251
+ store.rightPanelVisible = false
252
+ return
253
+ }
254
+ store.openQuickCommandsPanel()
255
+ }
256
+
257
+ Store.prototype.moveQuickCommandsToRightPanel = function () {
258
+ const { store } = window
259
+ store.setQuickCommandsInRightPanel(true)
260
+ // the footer box would otherwise be left floating over a terminal it no
261
+ // longer owns; the pin is deliberately kept, so handing the panel back
262
+ // restores the exact shape it had before
263
+ store.openQuickCommandBar = false
264
+ store.openQuickCommandsPanel()
265
+ }
266
+
267
+ // Hand the panel back to the footer and open it there: the move has to be
268
+ // visible, otherwise the panel just looks like it vanished. A pin that was
269
+ // left on while docked brings the box back pinned, which is what the user
270
+ // last asked for.
271
+ Store.prototype.moveQuickCommandsToFooter = function () {
272
+ const { store } = window
273
+ store.setQuickCommandsInRightPanel(false)
274
+ if (store.rightPanelTab === 'quickCommands') {
275
+ store.rightPanelVisible = false
276
+ }
277
+ store.openQuickCommandBar = true
278
+ }
182
279
  Store.prototype.beforeExit = function (evt) {
183
280
  const { confirmBeforeExit } = window.store.config
184
281
  if (
@@ -353,7 +450,10 @@ export default Store => {
353
450
  if (index === -1) {
354
451
  return
355
452
  }
356
- window.store.aiChatHistory.splice(index, 1)
453
+ // Reassign instead of splice: the store only notifies subscribers on a
454
+ // property write, so an in-place mutation leaves the panel rendering the
455
+ // list it already had.
456
+ window.store.aiChatHistory = store.aiChatHistory.filter(d => d.id !== id)
357
457
  }
358
458
 
359
459
  Store.prototype.startNewChat = action(function () {
@@ -388,6 +488,9 @@ export default Store => {
388
488
 
389
489
  Store.prototype.compressChatSession = async function (sessionId) {
390
490
  const { store } = window
491
+ if (!sessionId) {
492
+ return
493
+ }
391
494
  const sessionEntries = store.aiChatHistory
392
495
  .filter(h => h.chatSessionId === sessionId)
393
496
  .sort((a, b) => a.timestamp - b.timestamp)
@@ -411,33 +514,14 @@ export default Store => {
411
514
 
412
515
  const firstEntry = sessionEntries[0]
413
516
  const lang = firstEntry.languageAI || store.getLangName()
414
- const messages = [
415
- { role: 'system', content: appendMandatoryGuardrails(firstEntry.roleAI + `;用[${lang}]回复`) }
416
- ]
417
-
418
- // Start from the last compress entry to include its summary as context
419
- const startIndex = lastCompressIndex >= 0 ? lastCompressIndex : 0
420
- for (let i = startIndex; i < sessionEntries.length; i++) {
421
- const entry = sessionEntries[i]
422
- if (entry.compressed) {
423
- messages.push({
424
- role: 'user',
425
- content: `Here is a summary of our previous conversation for context:\n\n${entry.response}`
426
- })
427
- messages.push({
428
- role: 'assistant',
429
- content: 'Understood. I will use this context as we continue.'
430
- })
431
- } else {
432
- messages.push({
433
- role: 'user',
434
- content: entry.promptWithAttachments || entry.prompt
435
- })
436
- if (entry.response) {
437
- messages.push({ role: 'assistant', content: entry.response })
438
- }
439
- }
440
- }
517
+ // Same message list the chat turn would send (including the response of
518
+ // every entry), so the summary describes the conversation the model
519
+ // actually saw.
520
+ const messages = buildSessionMessages({
521
+ history: store.aiChatHistory,
522
+ chatSessionId: sessionId,
523
+ role: appendMandatoryGuardrails(firstEntry.roleAI + `;用[${lang}]回复`)
524
+ })
441
525
 
442
526
  const summaryPrompt = 'Please summarize the above conversation concisely. Include key information, decisions, context, and any important details that would be needed to continue this conversation effectively.'
443
527
  messages.push({ role: 'user', content: summaryPrompt })
@@ -484,8 +568,10 @@ export default Store => {
484
568
  compressed: true
485
569
  }
486
570
 
487
- // Append compress entry, preserve existing history
488
- store.aiChatHistory.push(compressedEntry)
571
+ // Append compress entry, preserve existing history. Reassign rather than
572
+ // push for the same reason as removeAiHistory -- the panel has to see the
573
+ // new context size, which is the whole point of compressing.
574
+ store.aiChatHistory = [...store.aiChatHistory, compressedEntry]
489
575
  }
490
576
 
491
577
  Store.prototype.toggleChatSessions = action(function () {
@@ -566,7 +652,9 @@ export default Store => {
566
652
  }
567
653
 
568
654
  Store.prototype.aiConfigMissing = function () {
569
- return aiConfigsArr.filter(k => k !== 'apiKeyAI' && k !== 'proxyAI' && k !== 'nameAI').some(k => !window.store.config[k])
655
+ return aiConfigsArr
656
+ .filter(k => !optionalAIConfigsArr.includes(k))
657
+ .some(k => !window.store.config[k])
570
658
  }
571
659
 
572
660
  Store.prototype.clearHistory = function () {
@@ -19,6 +19,8 @@ import {
19
19
  leftSidePanelWidthKey,
20
20
  rightSidebarWidthKey,
21
21
  rightPanelPinnedKey,
22
+ cmdHistoryInRightPanelKey,
23
+ quickCommandsInRightPanelKey,
22
24
  addPanelWidthLsKey,
23
25
  qmSortByFrequencyKey,
24
26
  resolutionsLsKey,
@@ -95,6 +97,10 @@ export default () => {
95
97
  _batchInputSelectedTabIds: new Set(),
96
98
  aiChatHistory: [],
97
99
  agentRunning: false,
100
+ // live context accounting while an agent loop is running: the agent
101
+ // carries tool schemas and tool results that the session history does
102
+ // not, so it publishes its own figure here (see components/ai/agent.js)
103
+ aiContextInfo: null,
98
104
  currentChatSessionId: window.localStorage.getItem(lastAiChatSessionIdKey) || '',
99
105
  showChatSessions: false,
100
106
 
@@ -127,6 +133,8 @@ export default () => {
127
133
  rightPanelVisible: false,
128
134
  rightPanelTab: 'info',
129
135
  rightPanelPinned: ls.getItem(rightPanelPinnedKey) === 'true',
136
+ cmdHistoryInRightPanel: ls.getItem(cmdHistoryInRightPanelKey) === 'true',
137
+ quickCommandsInRightPanel: ls.getItem(quickCommandsInRightPanelKey) === 'true',
130
138
  _rightPanelWidth: parseInt(ls.getItem(rightSidebarWidthKey), 10) || 500,
131
139
  showAIConfigModal: false,
132
140
 
@@ -14,6 +14,10 @@ import { initWsCommon } from '../common/fetch-from-server'
14
14
  import safeParse from '../common/parse-json-safe'
15
15
  import initWatch from './watch'
16
16
  import { parseQuickConnect } from '../common/parse-quick-connect'
17
+ import message from '../components/common/message'
18
+ import fs from '../common/fs'
19
+ import { parseVv, describeVv } from '../common/parse-vv'
20
+ import { vvToTab } from '../common/vv-to-tab'
17
21
 
18
22
  function getHost (argv, opts) {
19
23
  const arr = argv
@@ -35,6 +39,56 @@ function getHost (argv, opts) {
35
39
  return {}
36
40
  }
37
41
 
42
+ /**
43
+ * Open a .vv (virt-viewer) connection file as a new Spice session.
44
+ *
45
+ * Used when electerm is handed a file from outside:
46
+ * electerm /path/to/console.vv
47
+ *
48
+ * This opens a session, it does not import a bookmark. For a Proxmox file that
49
+ * is the only shape that can work at all: the signed proxy ticket it carries is
50
+ * refused by the node's :3128 daemon once it is more than about 40 seconds old,
51
+ * so a bookmark saved from one could never connect.
52
+ *
53
+ * The parse itself is shared with the bookmark form's "Load .vv file" button --
54
+ * src/client/common/parse-vv.js is the single implementation.
55
+ *
56
+ * @param {Object} store
57
+ * @param {string} filePath - absolute path, resolved in the main process
58
+ * @returns {Promise<Object|null>} the parse result, or null on failure
59
+ */
60
+ export async function openVvFile (store, filePath) {
61
+ console.debug('opening .vv file', filePath)
62
+ let text
63
+ try {
64
+ // fs.readFile resolves the file as a utf8 string (src/app/lib/fs.js)
65
+ text = await fs.readFile(filePath)
66
+ } catch (err) {
67
+ message.error('cannot read ' + filePath + ': ' + err.message)
68
+ return null
69
+ }
70
+ const parsed = parseVv(text)
71
+ if (!parsed.ok) {
72
+ message.error(describeVv(parsed) + ' (' + filePath + ')')
73
+ return null
74
+ }
75
+ const tab = vvToTab(parsed, { filePath })
76
+ console.debug('.vv file -> tab', tab)
77
+ // A file that did not come from a person clicking through a form gets no
78
+ // chance to read a hint, so anything dropped from it has to be said out loud.
79
+ if (parsed.warnings.length) {
80
+ console.debug('.vv warnings', parsed.warnings)
81
+ }
82
+ if (parsed.ignored.length) {
83
+ message.warning(
84
+ parsed.ignored.length + ' option(s) in this .vv were skipped: ' +
85
+ parsed.ignored.map(d => d.key).join(', ')
86
+ )
87
+ }
88
+ store.ipcOpenTab(tab)
89
+ return parsed
90
+ }
91
+
38
92
  export async function addTabFromCommandLine (store, opts) {
39
93
  console.debug('command line params', opts)
40
94
  if (!opts) {
@@ -52,6 +106,12 @@ export async function addTabFromCommandLine (store, opts) {
52
106
  if (isHelp) {
53
107
  return store.openAbout(infoTabs.cmd)
54
108
  }
109
+ // A .vv connection file, e.g. `electerm /path/to/console.vv`.
110
+ // Checked before the protocol-URL scan below because the two cannot overlap:
111
+ // a path ending in .vv never matches a `scheme://` prefix.
112
+ if (opts.vvFile) {
113
+ return openVvFile(store, opts.vvFile)
114
+ }
55
115
  // Check if argv contains a protocol URL (e.g., ssh://user@host)
56
116
  // and use parseQuickConnect for proper parsing
57
117
  if (argv && argv.length) {
@@ -297,6 +357,18 @@ export default (Store) => {
297
357
  window.store.ipcOpenTab(pending)
298
358
  }
299
359
  }
360
+ /**
361
+ * A .vv file that arrived before the window existed -- macOS delivers
362
+ * 'open-file' during launch, so a cold double-click always lands here. The
363
+ * payload is the same shape initCommandLine() returns, so it goes through the
364
+ * same entry point as `electerm /path/to/console.vv`.
365
+ */
366
+ Store.prototype.checkPendingVvFile = async function () {
367
+ const pending = await window.pre.runGlobalAsync('getPendingVvFile')
368
+ if (pending) {
369
+ addTabFromCommandLine(window.store, pending)
370
+ }
371
+ }
300
372
  Store.prototype.parseQuickConnect = function (url) {
301
373
  return parseQuickConnect(url)
302
374
  }
@@ -145,6 +145,12 @@ class Store {
145
145
  if (rightPanelTab === 'ai') {
146
146
  return getBrand(baseURLAI).brand || 'Custom AI Model'
147
147
  }
148
+ if (rightPanelTab === 'cmdHistory') {
149
+ return window.translate('cmdHistory')
150
+ }
151
+ if (rightPanelTab === 'quickCommands') {
152
+ return window.translate('quickCommands')
153
+ }
148
154
  return createTitle(window.store.currentTab)
149
155
  }
150
156
 
@@ -151,7 +151,9 @@ export default store => {
151
151
  window.pre.runGlobalAsync('setTitle', title)
152
152
  window.store.currentLayoutBatch = tab.batch
153
153
  }
154
- if (tab && store.rightPanelVisible) {
154
+ // refreshing the info panel on tab switch only concerns the info tab: the
155
+ // right panel can also be showing the AI chat or the cmd history
156
+ if (tab && store.rightPanelVisible && store.rightPanelTab === 'info') {
155
157
  window.store.openInfoPanelAction()
156
158
  }
157
159
  return store.activeTabId
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@electerm/electerm-react",
3
- "version": "5.5.25",
3
+ "version": "5.5.35",
4
4
  "description": "react components src for electerm",
5
5
  "main": "./client/components/main/main.jsx",
6
6
  "license": "MIT",