@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.
- package/README.md +5 -5
- package/client/common/bookmark-schemas.js +4 -0
- package/client/common/constants.js +3 -0
- package/client/common/create-title.jsx +5 -1
- package/client/common/default-setting.js +2 -0
- package/client/common/parse-quick-connect.js +1 -0
- package/client/common/parse-vv.js +119 -25
- package/client/common/parse-widget-log.js +57 -0
- package/client/common/vv-to-tab.js +120 -0
- package/client/components/ai/agent-tool-call-card.jsx +26 -7
- package/client/components/ai/agent.js +205 -45
- package/client/components/ai/ai-auto-compress.js +119 -0
- package/client/components/ai/ai-chat-history-item.jsx +133 -84
- package/client/components/ai/ai-chat.jsx +135 -30
- package/client/components/ai/ai-compress.js +95 -0
- package/client/components/ai/ai-config-props.js +12 -1
- package/client/components/ai/ai-config.jsx +43 -16
- package/client/components/ai/ai-config.styl +20 -0
- package/client/components/ai/ai-context-indicator.jsx +83 -0
- package/client/components/ai/ai-context.js +272 -0
- package/client/components/ai/ai-output.jsx +5 -6
- package/client/components/ai/ai-presets.js +1 -16
- package/client/components/ai/ai.styl +105 -6
- package/client/components/ai/copy-icon.jsx +31 -0
- package/client/components/batch-op/batch-op-runner.jsx +1 -0
- package/client/components/bookmark-form/bookmark-schema.js +1 -0
- package/client/components/bookmark-form/common/ssh-agent.jsx +31 -21
- package/client/components/bookmark-form/common/vv-file-field.jsx +3 -2
- package/client/components/bookmark-form/config/spice.js +21 -0
- package/client/components/bookmark-form/config/ssh.js +1 -0
- package/client/components/bookmark-form/fix-bookmark-default.js +1 -0
- package/client/components/file-transfer/transfer.jsx +3 -119
- package/client/components/footer/cmd-history.jsx +279 -65
- package/client/components/footer/cmd-history.styl +68 -1
- package/client/components/footer/footer-entry.jsx +7 -1
- package/client/components/layout/layout.jsx +12 -19
- package/client/components/main/main.jsx +15 -1
- package/client/components/quick-commands/qm.styl +58 -0
- package/client/components/quick-commands/quick-commands-box.jsx +180 -83
- package/client/components/quick-commands/quick-commands-select.jsx +11 -0
- package/client/components/session/session-control.jsx +35 -2
- package/client/components/session/session-control.styl +5 -0
- package/client/components/session/session.jsx +13 -0
- package/client/components/setting-panel/setting-common.jsx +2 -0
- package/client/components/setting-panel/tab-widgets.jsx +27 -7
- package/client/components/sftp/file-item.jsx +0 -13
- package/client/components/side-panel-r/side-panel-r.jsx +11 -4
- package/client/components/sidebar/bookmark-select.jsx +14 -2
- package/client/components/sidebar/history-item.jsx +20 -4
- package/client/components/sidebar/history.jsx +7 -0
- package/client/components/terminal/mixins/term-touch.js +18 -0
- package/client/components/tree-list/tree-list.jsx +31 -3
- package/client/components/widgets/widget-control.jsx +4 -2
- package/client/components/widgets/widget-instance-detail.jsx +370 -0
- package/client/components/widgets/widget-instance.jsx +30 -13
- package/client/components/widgets/widget-instances.jsx +3 -1
- package/client/components/widgets/widgets-list.jsx +8 -0
- package/client/components/widgets/widgets.styl +100 -0
- package/client/store/common.js +200 -54
- package/client/store/init-state.js +15 -0
- package/client/store/load-data.js +73 -0
- package/client/store/store.js +6 -0
- package/client/store/watch.js +3 -1
- package/client/store/widgets.js +23 -5
- package/package.json +1 -1
- package/client/components/file-transfer/zip.js +0 -42
package/README.md
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
</h1>
|
|
6
6
|
|
|
7
7
|
[](https://github.com/electerm/electerm/releases)
|
|
8
|
-
[](https://github.com/electerm/electerm/actions)
|
|
9
8
|
[](https://github.com/electerm/electerm/blob/master/LICENSE)
|
|
10
9
|
[](https://github.com/sponsors/electerm)
|
|
11
10
|
[](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"
|
|
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?
|
|
54
|
-
<img src="https://github.com/electerm/electerm-resource/blob/master/static/images/fluxionai.png?raw=true" alt="FluxionAI"
|
|
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?
|
|
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',
|
|
@@ -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
|
-
*
|
|
20
|
-
*
|
|
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-
|
|
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 === '
|
|
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
|
-
//
|
|
228
|
-
//
|
|
229
|
-
if (
|
|
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
|
|
232
|
-
'so
|
|
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
|
|
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
|
-
|
|
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
|
-
{
|
|
101
|
+
{argsText && (
|
|
83
102
|
<div className='agent-tool-args'>
|
|
84
103
|
<div className='agent-tool-label'>Arguments:</div>
|
|
85
|
-
|
|
104
|
+
{renderPre(argsText)}
|
|
86
105
|
</div>
|
|
87
106
|
)}
|
|
88
|
-
{
|
|
107
|
+
{resultText && (
|
|
89
108
|
<div className='agent-tool-result'>
|
|
90
109
|
<div className='agent-tool-label'>Result:</div>
|
|
91
|
-
|
|
110
|
+
{renderPre(resultText)}
|
|
92
111
|
</div>
|
|
93
112
|
)}
|
|
94
113
|
</div>
|
|
95
114
|
)}
|
|
96
115
|
</div>
|
|
97
116
|
)
|
|
98
|
-
}
|
|
117
|
+
})
|