@electerm/electerm-react 5.5.25 → 5.5.56

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 (66) hide show
  1. package/README.md +5 -5
  2. package/client/common/bookmark-schemas.js +4 -0
  3. package/client/common/constants.js +3 -0
  4. package/client/common/create-title.jsx +5 -1
  5. package/client/common/default-setting.js +2 -0
  6. package/client/common/parse-quick-connect.js +1 -0
  7. package/client/common/parse-vv.js +119 -25
  8. package/client/common/parse-widget-log.js +57 -0
  9. package/client/common/vv-to-tab.js +120 -0
  10. package/client/components/ai/agent-tool-call-card.jsx +26 -7
  11. package/client/components/ai/agent.js +205 -45
  12. package/client/components/ai/ai-auto-compress.js +119 -0
  13. package/client/components/ai/ai-chat-history-item.jsx +133 -84
  14. package/client/components/ai/ai-chat.jsx +135 -30
  15. package/client/components/ai/ai-compress.js +95 -0
  16. package/client/components/ai/ai-config-props.js +12 -1
  17. package/client/components/ai/ai-config.jsx +43 -16
  18. package/client/components/ai/ai-config.styl +20 -0
  19. package/client/components/ai/ai-context-indicator.jsx +83 -0
  20. package/client/components/ai/ai-context.js +272 -0
  21. package/client/components/ai/ai-output.jsx +5 -6
  22. package/client/components/ai/ai-presets.js +1 -16
  23. package/client/components/ai/ai.styl +105 -6
  24. package/client/components/ai/copy-icon.jsx +31 -0
  25. package/client/components/batch-op/batch-op-runner.jsx +1 -0
  26. package/client/components/bookmark-form/bookmark-schema.js +1 -0
  27. package/client/components/bookmark-form/common/ssh-agent.jsx +31 -21
  28. package/client/components/bookmark-form/common/vv-file-field.jsx +3 -2
  29. package/client/components/bookmark-form/config/spice.js +21 -0
  30. package/client/components/bookmark-form/config/ssh.js +1 -0
  31. package/client/components/bookmark-form/fix-bookmark-default.js +1 -0
  32. package/client/components/file-transfer/transfer.jsx +3 -119
  33. package/client/components/footer/cmd-history.jsx +279 -65
  34. package/client/components/footer/cmd-history.styl +68 -1
  35. package/client/components/footer/footer-entry.jsx +7 -1
  36. package/client/components/layout/layout.jsx +12 -19
  37. package/client/components/main/main.jsx +15 -1
  38. package/client/components/quick-commands/qm.styl +58 -0
  39. package/client/components/quick-commands/quick-commands-box.jsx +180 -83
  40. package/client/components/quick-commands/quick-commands-select.jsx +11 -0
  41. package/client/components/session/session-control.jsx +35 -2
  42. package/client/components/session/session-control.styl +5 -0
  43. package/client/components/session/session.jsx +13 -0
  44. package/client/components/setting-panel/setting-common.jsx +2 -0
  45. package/client/components/setting-panel/tab-widgets.jsx +27 -7
  46. package/client/components/sftp/file-item.jsx +0 -13
  47. package/client/components/side-panel-r/side-panel-r.jsx +11 -4
  48. package/client/components/sidebar/bookmark-select.jsx +14 -2
  49. package/client/components/sidebar/history-item.jsx +20 -4
  50. package/client/components/sidebar/history.jsx +7 -0
  51. package/client/components/terminal/mixins/term-touch.js +18 -0
  52. package/client/components/tree-list/tree-list.jsx +31 -3
  53. package/client/components/widgets/widget-control.jsx +4 -2
  54. package/client/components/widgets/widget-instance-detail.jsx +370 -0
  55. package/client/components/widgets/widget-instance.jsx +30 -13
  56. package/client/components/widgets/widget-instances.jsx +3 -1
  57. package/client/components/widgets/widgets-list.jsx +8 -0
  58. package/client/components/widgets/widgets.styl +100 -0
  59. package/client/store/common.js +200 -54
  60. package/client/store/init-state.js +15 -0
  61. package/client/store/load-data.js +73 -0
  62. package/client/store/store.js +6 -0
  63. package/client/store/watch.js +3 -1
  64. package/client/store/widgets.js +23 -5
  65. package/package.json +1 -1
  66. package/client/components/file-transfer/zip.js +0 -42
package/README.md CHANGED
@@ -5,7 +5,6 @@
5
5
  </h1>
6
6
 
7
7
  [![GitHub version](https://badgers.space/github/release/electerm/electerm?corner_radius=m)](https://github.com/electerm/electerm/releases)
8
- [![Build Status](https://github.com/electerm/electerm/actions/workflows/mac-test-1.yml/badge.svg)](https://github.com/electerm/electerm/actions)
9
8
  [![license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/electerm/electerm/blob/master/LICENSE)
10
9
  [![GitHub Sponsors](https://img.shields.io/github/sponsors/electerm?label=Sponsors)](https://github.com/sponsors/electerm)
11
10
  [![star](https://atomgit.com/electerm/electerm/star/badge.svg)](https://atomgit.com/electerm/electerm)
@@ -41,7 +40,7 @@ Besides mainstream Windows/macOS/Linux/Android, electerm also supports HarmonyOS
41
40
 
42
41
  <div align="center">
43
42
  <a href="https://www.atlascloud.ai/?ref=PCAEL2&utm_source=github&utm_medium=link&utm_campaign=electerm">
44
- <img src="https://github.com/electerm/electerm-resource/blob/master/static/images/atlas-cloud.png?raw=true" alt="Atlas Cloud" height="200" />
43
+ <img src="https://github.com/electerm/electerm-resource/blob/master/static/images/atlas-cloud.png?raw=true" alt="Atlas Cloud" width="260" />
45
44
  </a>
46
45
  </div>
47
46
 
@@ -50,12 +49,13 @@ Besides mainstream Windows/macOS/Linux/Android, electerm also supports HarmonyOS
50
49
  ----
51
50
 
52
51
  <div align="center">
53
- <a href="https://fluxionai.world/register?aff=7H7DERHU3GFF&utm_source=github&utm_medium=link&utm_campaign=electerm">
54
- <img src="https://github.com/electerm/electerm-resource/blob/master/static/images/fluxionai.png?raw=true" alt="FluxionAI" height="180" />
52
+ <a href="https://fluxionai.world/register?source=github&campaign=electerm-20260923&promo=ELECTERM&aff=7H7DERHU3GFF">
53
+ <img src="https://github.com/electerm/electerm-resource/blob/master/static/images/fluxionai.png?raw=true" alt="FluxionAI" width="210" />
55
54
  </a>
56
55
  </div>
57
56
 
58
- [Fluxion AI](https://fluxionai.world/register?aff=7H7DERHU3GFF&utm_source=github&utm_medium=link&utm_campaign=electerm) — One gateway to the world's leading AI models. [Sign up here to get started](https://fluxionai.world/register?aff=7H7DERHU3GFF&utm_source=github&utm_medium=link&utm_campaign=electerm).
57
+ [Fluxion AI](https://fluxionai.world/register?source=github&campaign=electerm-20260923&promo=ELECTERM&aff=7H7DERHU3GFF) — One gateway to the world's leading AI models, One API, multi-route delivery, transparent usage and pricing, Chinese Models — 60% OFF, Claude & OpenAI — 70% OFF [Register through the partner link
58
+ Get $3 in API credit](https://fluxionai.world/register?source=github&campaign=electerm-20260923&promo=ELECTERM&aff=7H7DERHU3GFF).
59
59
 
60
60
  ----
61
61
 
@@ -62,6 +62,7 @@ export const sshBookmarkSchema = {
62
62
  enableSftp: z.boolean().optional().describe('Enable sftp, default is true'),
63
63
  useSshAgent: z.boolean().optional().describe('Use SSH agent, default is true'),
64
64
  sshAgent: z.string().optional().describe('SSH agent path'),
65
+ agentForward: z.boolean().optional().describe('Enable SSH agent forwarding (like ssh -A), requires SSH agent, default is false'),
65
66
  serverHostKey: z.array(z.string()).optional().describe('Server host key algorithms'),
66
67
  cipher: z.array(z.string()).optional().describe('Cipher list'),
67
68
  compress: z.array(z.string()).optional().describe('Compression algorithms'),
@@ -152,6 +153,9 @@ export const spiceBookmarkSchema = {
152
153
  ...commonNetworkBookmarkProps,
153
154
  host: z.string().describe('Spice host address'),
154
155
  port: z.number().optional().describe('Spice port (default 5900)'),
156
+ tls: z.boolean().optional().describe('Connect over TLS, default false'),
157
+ ca: z.string().optional().describe('CA certificate in PEM format, used to verify the TLS peer'),
158
+ hostSubject: z.string().optional().describe('Expected TLS certificate subject, like CN=pve1,O=Example'),
155
159
  viewOnly: z.boolean().optional().describe('View only mode'),
156
160
  scaleViewport: z.boolean().optional().describe('Scale viewport to window, default is true')
157
161
  }
@@ -158,6 +158,8 @@ export const pinnedQuickCommandBarKey = 'pinned-quick-command-bar'
158
158
  export const leftSidePanelWidthKey = 'left-sidebar-width'
159
159
  export const rightSidebarWidthKey = 'right-sidebar-width'
160
160
  export const rightPanelPinnedKey = 'right-panel-pinned'
161
+ export const cmdHistoryInRightPanelKey = 'cmd-history-in-right-panel'
162
+ export const quickCommandsInRightPanelKey = 'quick-commands-in-right-panel'
161
163
  export const addPanelWidthLsKey = 'addPanelWidth'
162
164
  export const sftpDefaultSortSettingKey = 'sftp-default-sort'
163
165
  export const qmSortByFrequencyKey = 'qm-sort-by-frequency'
@@ -300,6 +302,7 @@ export const regexHelpLink = 'https://github.com/electerm/electerm/wiki/Terminal
300
302
  export const connectionHoppingWikiLink = 'https://github.com/electerm/electerm/wiki/Connection-Hopping-Behavior-Change-in-electerm-since-v1.50.65'
301
303
  export const aiConfigWikiLink = 'https://github.com/electerm/electerm/wiki/AI-model-config-guide'
302
304
  export const aiChatModeLsKey = 'ai-chat-mode'
305
+ export const aiAutoCompressLsKey = 'ai-auto-compress'
303
306
  export const lastAiChatSessionIdKey = 'last-ai-chat-session-id'
304
307
  export const aiTermOfUseConfirmedLsKey = 'ai-term-of-use-confirmed'
305
308
  export const syncTermOfUseConfirmedLsKey = 'sync-term-of-use-confirmed'
@@ -28,8 +28,12 @@ export default function createTitle (res, hide = true) {
28
28
  const h = hide && window.store.config.hideIP ? maskHost(host) : host
29
29
  const fixTitle = `${username || ''}@${h}:${port}`
30
30
  const extra = host || path ? (path || fixTitle) : (url || '')
31
+ // onlyShowTitleInTab: a session that was given its own title shows just that
32
+ // title. One without a title is left alone and keeps the host/user fallback,
33
+ // otherwise the tab would read as empty.
34
+ const onlyTitle = title && window.store.config.onlyShowTitleInTab
31
35
  let f = title
32
- ? `${title}` + (extra ? ` - ${extra}` : '')
36
+ ? `${title}` + (extra && !onlyTitle ? ` - ${extra}` : '')
33
37
  : extra
34
38
  if (connectionHoppings && connectionHoppings.length) {
35
39
  f = `[⋙]${f}`
@@ -94,8 +94,10 @@ export default {
94
94
  dragDropBehavior: 'ask',
95
95
  switchTabOnHover: false,
96
96
  disableTabIndex: false,
97
+ onlyShowTitleInTab: false,
97
98
  disableShortcutBar: false,
98
99
  disableConfirmForLargeClipboardContent: false,
100
+ doubleClickToOpenBookmark: false,
99
101
  leftSideBarIcons: [
100
102
  'newBookmark',
101
103
  'quickConnect',
@@ -53,6 +53,7 @@ const TYPE_DEFAULT_VALUES = {
53
53
  enableSsh: true,
54
54
  enableSftp: true,
55
55
  useSshAgent: true,
56
+ agentForward: false,
56
57
  authType: 'password',
57
58
  term: 'xterm-256color',
58
59
  encode: 'utf-8',
@@ -8,6 +8,7 @@
8
8
  *
9
9
  * Behaviour is deliberately matched against:
10
10
  * - src/virt-viewer-file.c (gitlab.com/virt-viewer/virt-viewer)
11
+ * - src/virt-viewer-session-spice.c, for which port wins over tls-port
11
12
  * - remote-viewer(1), "CONNECTION FILE" section
12
13
  *
13
14
  * Rejection rules copied from virt_viewer_file_new():
@@ -16,8 +17,9 @@
16
17
  * unknown keys/groups -> kept, never an error (the format is
17
18
  * extensible; the convention is an `x-` prefix)
18
19
  *
19
- * Keep in sync with src/client/common/parse-vv.js -- the two differ only in
20
- * the export statement (see sanitize-filename.js for the same pattern).
20
+ * This is the only copy. .vv loading happens in the renderer only, so there
21
+ * is no src/app/common twin -- unlike sanitize-filename.js, which the main
22
+ * process and the renderer both need.
21
23
  */
22
24
 
23
25
  const MAIN_GROUP = 'virt-viewer'
@@ -26,24 +28,37 @@ const OVIRT_GROUP = 'ovirt'
26
28
  /**
27
29
  * .vv key -> bookmark field understood by
28
30
  * src/client/components/bookmark-form/config/spice.js
31
+ *
32
+ * `port` and `tls-port` are NOT here: which one wins depends on whether the
33
+ * other is present, so they are resolved in parseVv().
29
34
  */
30
35
  const FIELD_MAP = {
31
36
  host: 'host',
32
- port: 'port',
33
37
  password: 'password',
34
38
  title: 'title',
35
- proxy: 'proxy'
39
+ proxy: 'proxy',
40
+ ca: 'ca',
41
+ 'host-subject': 'hostSubject'
36
42
  }
37
43
 
44
+ /**
45
+ * Keys parseVv() consumes itself, so the "not supported" pass must not report
46
+ * them again.
47
+ */
48
+ const HANDLED = new Set([
49
+ 'type',
50
+ 'delete-this-file',
51
+ 'port',
52
+ 'tls-port',
53
+ ...Object.keys(FIELD_MAP)
54
+ ])
55
+
38
56
  /**
39
57
  * Keys the .vv format defines but electerm cannot honour for a spice session.
40
58
  * Each becomes a user-visible note instead of being dropped silently.
41
59
  */
42
60
  const UNSUPPORTED = {
43
- 'tls-port': 'TLS is not supported (electerm makes a plain TCP connection)',
44
- 'tls-ciphers': 'TLS is not supported',
45
- ca: 'TLS certificate verification is not supported',
46
- 'host-subject': 'TLS certificate verification is not supported',
61
+ 'tls-ciphers': 'TLS is used, but the cipher list is not configurable',
47
62
  'unix-path': 'Unix-socket transport is not supported',
48
63
  username: 'spice sessions authenticate with the password only',
49
64
  'disable-channels': 'spice channel selection is not supported',
@@ -70,6 +85,14 @@ const UNSUPPORTED = {
70
85
  'usb-device-reset': 'hotkey rebinding is not supported'
71
86
  }
72
87
 
88
+ /**
89
+ * Proxmox writes the SPICE proxy ticket into `host` (see
90
+ * PVE::AccessControl::remote_viewer_config) and puts the real node name inside
91
+ * it. PVE::Ticket::verify_spice_connect_url() only accepts a ticket younger
92
+ * than 40 seconds, so a .vv from the Proxmox UI is strictly single-use.
93
+ */
94
+ const PROXMOX_TICKET = /^pvespiceproxy:/i
95
+
73
96
  const BOOL_TRUE = new Set(['1', 'true', 'yes', 'on'])
74
97
 
75
98
  function stripBom (text) {
@@ -80,6 +103,49 @@ function isComment (line) {
80
103
  return line.startsWith('#') || line.startsWith(';')
81
104
  }
82
105
 
106
+ /**
107
+ * GKeyFile escapes \\n, \\t, \\r, \\s and \\\\ inside values, and
108
+ * g_key_file_get_string() expands them on read. parseIni keeps values
109
+ * verbatim (a trailing space in a password is significant), so the one value
110
+ * that is handed to a parser rather than used as-is is expanded here.
111
+ *
112
+ * Proxmox relies on this: it writes the whole CA on a single line with literal
113
+ * "\\n" separators (`$cacert =~ s/\n/\\n/g`), which no PEM parser accepts
114
+ * unexpanded.
115
+ *
116
+ * @param {string} value
117
+ * @returns {string}
118
+ */
119
+ function unescapeValue (value) {
120
+ return value.replace(/\\(.)/g, (match, char) => {
121
+ switch (char) {
122
+ case 'n': return '\n'
123
+ case 't': return '\t'
124
+ case 'r': return '\r'
125
+ case 's': return ' '
126
+ case '\\': return '\\'
127
+ default: return match
128
+ }
129
+ })
130
+ }
131
+
132
+ /**
133
+ * Read and range-check one of the two port keys.
134
+ * @returns {number|null}
135
+ */
136
+ function readPort (main, key, result) {
137
+ if (!(key in main)) {
138
+ return null
139
+ }
140
+ const raw = main[key].trim()
141
+ const port = parseInt(raw, 10)
142
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
143
+ result.warnings.push('ignoring invalid ' + key + ' "' + raw + '"')
144
+ return null
145
+ }
146
+ return port
147
+ }
148
+
83
149
  /**
84
150
  * Parse INI text into { groupName: { key: value } }.
85
151
  *
@@ -192,6 +258,30 @@ function parseVv (text) {
192
258
  return result
193
259
  }
194
260
 
261
+ // spice-gtk dials the plain port and only falls back to TLS when the file
262
+ // offers no plain port at all (spice-channel.c: "trying with TLS port"), so
263
+ // a file that sets both keeps its plain port here too.
264
+ //
265
+ // Which port key the file used also decides the transport, so `tls` is set
266
+ // on both branches: the form only overwrites the keys it is handed, and a
267
+ // switch left over from a previous file would silently break the connection.
268
+ const plainPort = readPort(main, 'port', result)
269
+ const tlsPort = readPort(main, 'tls-port', result)
270
+
271
+ if (plainPort) {
272
+ result.fields.port = plainPort
273
+ result.fields.tls = false
274
+ result.applied.push({ key: 'port', value: main.port, field: 'port' })
275
+ } else if (tlsPort) {
276
+ result.fields.port = tlsPort
277
+ result.fields.tls = true
278
+ result.applied.push({
279
+ key: 'tls-port',
280
+ value: main['tls-port'],
281
+ field: 'port'
282
+ })
283
+ }
284
+
195
285
  Object.keys(FIELD_MAP).forEach(key => {
196
286
  if (!(key in main)) {
197
287
  return
@@ -199,14 +289,7 @@ function parseVv (text) {
199
289
  const raw = main[key]
200
290
  const field = FIELD_MAP[key]
201
291
 
202
- if (key === 'port') {
203
- const port = parseInt(raw.trim(), 10)
204
- if (!Number.isInteger(port) || port < 1 || port > 65535) {
205
- result.warnings.push('ignoring invalid port "' + raw.trim() + '"')
206
- return
207
- }
208
- result.fields.port = port
209
- } else if (key === 'password') {
292
+ if (key === 'password') {
210
293
  result.fields.password = raw
211
294
  } else {
212
295
  const value = raw.trim()
@@ -214,7 +297,7 @@ function parseVv (text) {
214
297
  result.warnings.push('ignoring empty "' + key + '"')
215
298
  return
216
299
  }
217
- result.fields[field] = value
300
+ result.fields[field] = key === 'ca' ? unescapeValue(value) : value
218
301
  }
219
302
  result.applied.push({ key, value: raw, field })
220
303
  })
@@ -224,12 +307,12 @@ function parseVv (text) {
224
307
  return result
225
308
  }
226
309
 
227
- // A file that only offers a TLS port has nothing we can dial, because the
228
- // spice transport here is plain TCP (see src/app/server/spice-proxy.js).
229
- if (main['tls-port'] && !('port' in main)) {
310
+ // The Proxmox ticket in `host` is signed and time limited: the proxy rejects
311
+ // anything older than 40 seconds, so a saved bookmark can never work.
312
+ if (PROXMOX_TICKET.test(result.fields.host)) {
230
313
  result.warnings.push(
231
- 'this file defines only tls-port, and TLS is not supported here, ' +
232
- 'so there is no plain port to connect to'
314
+ 'this is a Proxmox SPICE proxy ticket: it is only valid for about 40 ' +
315
+ 'seconds, so connect right away -- saving it as a bookmark will not work'
233
316
  )
234
317
  }
235
318
 
@@ -248,7 +331,7 @@ function parseVv (text) {
248
331
  }
249
332
 
250
333
  Object.keys(main).forEach(key => {
251
- if (key === 'type' || key === 'delete-this-file' || key in FIELD_MAP) {
334
+ if (HANDLED.has(key)) {
252
335
  return
253
336
  }
254
337
  const value = main[key]
@@ -259,6 +342,17 @@ function parseVv (text) {
259
342
  }
260
343
  })
261
344
 
345
+ // Both ports offered: the plain one wins (see above), but say so instead of
346
+ // dropping the TLS port in silence.
347
+ if (plainPort && tlsPort) {
348
+ result.ignored.push({
349
+ key: 'tls-port',
350
+ value: main['tls-port'],
351
+ reason:
352
+ 'this file also offers a plain port, which is the one remote-viewer uses'
353
+ })
354
+ }
355
+
262
356
  if (groups[OVIRT_GROUP]) {
263
357
  result.ignored.push({
264
358
  key: '[' + OVIRT_GROUP + ']',
@@ -288,8 +382,8 @@ function describeVv (parsed) {
288
382
  if (!parsed || !parsed.ok) {
289
383
  return (parsed && parsed.error) || 'invalid .vv file'
290
384
  }
291
- const { host, port, title, proxy } = parsed.fields
292
- const bits = ['spice://' + host + (port ? ':' + port : '')]
385
+ const { host, port, title, proxy, tls } = parsed.fields
386
+ const bits = ['spice' + (tls ? '+tls' : '') + '://' + host + (port ? ':' + port : '')]
293
387
  if (title) {
294
388
  bits.push('"' + title + '"')
295
389
  }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Parse a widget log file into the two streams the widget panel shows.
3
+ *
4
+ * The writer is src/app/widgets/instance-log.js; the format is one entry per
5
+ * line, pipe separated:
6
+ * <iso time>|log|<level>|<message>
7
+ * <iso time>|conn|<ok|fail|info>|<type>|<from>|<message>
8
+ * A message may contain pipes, so everything after the last known separator is
9
+ * taken as the message.
10
+ */
11
+
12
+ const CONN_STATE = {
13
+ ok: true,
14
+ fail: false,
15
+ info: null
16
+ }
17
+
18
+ export function parseWidgetLog (text) {
19
+ const logs = []
20
+ const events = []
21
+ for (const line of String(text || '').split('\n')) {
22
+ if (!line) {
23
+ continue
24
+ }
25
+ const parts = line.split('|')
26
+ if (parts.length < 4) {
27
+ continue
28
+ }
29
+ const ts = Date.parse(parts[0])
30
+ if (!ts) {
31
+ continue
32
+ }
33
+ if (parts[1] === 'conn') {
34
+ if (parts.length < 6) {
35
+ continue
36
+ }
37
+ events.push({
38
+ ts,
39
+ ok: Object.prototype.hasOwnProperty.call(CONN_STATE, parts[2])
40
+ ? CONN_STATE[parts[2]]
41
+ : null,
42
+ type: parts[3],
43
+ from: parts[4] === '-' ? '' : parts[4],
44
+ msg: parts.slice(5).join('|')
45
+ })
46
+ } else {
47
+ logs.push({
48
+ ts,
49
+ level: parts[2],
50
+ msg: parts.slice(3).join('|')
51
+ })
52
+ }
53
+ }
54
+ return { logs, events }
55
+ }
56
+
57
+ export default parseWidgetLog
@@ -0,0 +1,120 @@
1
+ /**
2
+ * .vv connection file -> electerm Spice tab options
3
+ *
4
+ * Kept apart from parse-vv.js on purpose: parse-vv.js knows the file format
5
+ * and nothing about electerm, this module knows electerm's Spice tab shape and
6
+ * nothing about the format. Staying free of window/api access is what makes it
7
+ * unit-testable, and it is shared by both entry points for a .vv file --
8
+ * the bookmark form's "Load .vv file" button and a file handed to electerm
9
+ * from outside (`electerm /path/to/console.vv`).
10
+ *
11
+ * Deliberately imports nothing: constants.js pulls in the logo .png files and
12
+ * `window`, which would make this unimportable under `node --test`.
13
+ * parse-quick-connect.js spells the type out for the same reason.
14
+ */
15
+
16
+ /**
17
+ * The `spice` value of terminalSpiceType (src/client/common/constants.js).
18
+ * A .vv file is a virt-viewer file, and the only type this client implements
19
+ * is spice -- parse-vv.js rejects every other `type=` already.
20
+ */
21
+ const SPICE_TYPE = 'spice'
22
+
23
+ /**
24
+ * Mirrors the Spice defaults in components/bookmark-form/config/spice.js
25
+ * initValues(), so a .vv that omits them behaves exactly like a Spice bookmark
26
+ * added by hand.
27
+ */
28
+ const SPICE_DEFAULTS = {
29
+ viewOnly: false,
30
+ scaleViewport: true
31
+ }
32
+
33
+ /**
34
+ * Port used when the file names a host but no port at all. Matches
35
+ * DEFAULT_PORTS.spice in parse-quick-connect.js.
36
+ */
37
+ const DEFAULT_SPICE_PORT = 5900
38
+
39
+ const VV_EXT = /\.vv$/i
40
+ const URL_LIKE = /:\/\//
41
+
42
+ /**
43
+ * Does this look like a .vv file path?
44
+ *
45
+ * A suffix test rather than an exists()/stat() call on purpose: this is asked
46
+ * synchronously while scanning a command line, and the read that follows
47
+ * reports a missing file far better than a bare boolean could.
48
+ *
49
+ * `://` is excluded so a deep link that happens to end in .vv
50
+ * (spice://host/console.vv) is not mistaken for a path -- deep links have their
51
+ * own parser and would otherwise be read off the disk.
52
+ *
53
+ * @param {string} value
54
+ * @returns {boolean}
55
+ */
56
+ function isVvFile (value) {
57
+ if (typeof value !== 'string') {
58
+ return false
59
+ }
60
+ const text = value.trim()
61
+ return VV_EXT.test(text) && !URL_LIKE.test(text)
62
+ }
63
+
64
+ /**
65
+ * Last path segment, for a tab title when the file carries no `title` key.
66
+ * Done by hand because the renderer has no path module.
67
+ *
68
+ * @param {string} filePath
69
+ * @returns {string}
70
+ */
71
+ function basename (filePath) {
72
+ return String(filePath).split(/[/\\]/).pop() || String(filePath)
73
+ }
74
+
75
+ /**
76
+ * Build tab options from a parse result.
77
+ *
78
+ * Only keys the file actually provided are set. An absent `ca` or `hostSubject`
79
+ * must stay absent rather than become an empty string, because the session code
80
+ * reads "no ca" as "do not verify against a pinned CA" -- an empty string there
81
+ * would mean "pin nothing", which fails every handshake.
82
+ *
83
+ * @param {Object} parsed - result of parseVv()
84
+ * @param {Object} [options] - { filePath } plus any tab overrides
85
+ * @returns {Object} options for store.addTab()
86
+ */
87
+ function vvToTab (parsed, options = {}) {
88
+ const { filePath, ...extra } = options
89
+ const f = parsed.fields
90
+ const tab = {
91
+ type: SPICE_TYPE,
92
+ host: f.host,
93
+ port: f.port || DEFAULT_SPICE_PORT,
94
+ tls: !!f.tls,
95
+ ...SPICE_DEFAULTS
96
+ }
97
+ const optional = {
98
+ ca: f.ca,
99
+ hostSubject: f.hostSubject,
100
+ password: f.password,
101
+ proxy: f.proxy,
102
+ title: f.title || (filePath ? basename(filePath) : '')
103
+ }
104
+ Object.keys(optional).forEach(key => {
105
+ if (optional[key]) {
106
+ tab[key] = optional[key]
107
+ }
108
+ })
109
+ return { ...tab, ...extra }
110
+ }
111
+
112
+ export {
113
+ isVvFile,
114
+ SPICE_TYPE,
115
+ vvToTab,
116
+ basename,
117
+ VV_EXT,
118
+ DEFAULT_SPICE_PORT,
119
+ SPICE_DEFAULTS
120
+ }
@@ -1,4 +1,4 @@
1
- import { useState, useEffect } from 'react'
1
+ import { useState, useEffect, memo } from 'react'
2
2
  import { Tag } from 'antd'
3
3
  import {
4
4
  CaretDownOutlined,
@@ -9,6 +9,7 @@ import {
9
9
  CodeOutlined,
10
10
  DatabaseOutlined
11
11
  } from '@ant-design/icons'
12
+ import CopyIcon from './copy-icon'
12
13
 
13
14
  const toolIcons = {
14
15
  send_terminal_command: CodeOutlined,
@@ -33,7 +34,11 @@ function formatResult (result) {
33
34
  }
34
35
  }
35
36
 
36
- export default function AgentToolCallCard ({ toolCall, autoCollapse }) {
37
+ // memo()'d on the entry object: `formatResult` JSON.parses the whole tool
38
+ // result on every render, and an agent turn can hold dozens of these. The
39
+ // agent loop swaps in a new entry object when a call finishes
40
+ // (runAgentLoop), so an unchanged card keeps its identity and is skipped.
41
+ export default memo(function AgentToolCallCard ({ toolCall, autoCollapse }) {
37
42
  const [expanded, setExpanded] = useState(toolCall.status === 'running')
38
43
  const { name, args, status, result } = toolCall
39
44
  const Icon = toolIcons[name] || CodeOutlined
@@ -65,6 +70,20 @@ export default function AgentToolCallCard ({ toolCall, autoCollapse }) {
65
70
  )
66
71
  }
67
72
 
73
+ function renderPre (text) {
74
+ return (
75
+ <div className='agent-tool-pre-wrap'>
76
+ <CopyIcon text={text} className='agent-tool-copy' />
77
+ <pre className='agent-tool-pre'>{text}</pre>
78
+ </div>
79
+ )
80
+ }
81
+
82
+ const argsText = args && Object.keys(args).length > 0
83
+ ? JSON.stringify(args, null, 2)
84
+ : ''
85
+ const resultText = formatResult(result)
86
+
68
87
  return (
69
88
  <div className={`agent-tool-call-card agent-tool-${status}`}>
70
89
  <div
@@ -79,20 +98,20 @@ export default function AgentToolCallCard ({ toolCall, autoCollapse }) {
79
98
  </div>
80
99
  {expanded && (
81
100
  <div className='agent-tool-detail'>
82
- {args && Object.keys(args).length > 0 && (
101
+ {argsText && (
83
102
  <div className='agent-tool-args'>
84
103
  <div className='agent-tool-label'>Arguments:</div>
85
- <pre className='agent-tool-pre'>{JSON.stringify(args, null, 2)}</pre>
104
+ {renderPre(argsText)}
86
105
  </div>
87
106
  )}
88
- {result && (
107
+ {resultText && (
89
108
  <div className='agent-tool-result'>
90
109
  <div className='agent-tool-label'>Result:</div>
91
- <pre className='agent-tool-pre'>{formatResult(result)}</pre>
110
+ {renderPre(resultText)}
92
111
  </div>
93
112
  )}
94
113
  </div>
95
114
  )}
96
115
  </div>
97
116
  )
98
- }
117
+ })