@electerm/electerm-react 3.15.0 → 3.15.32
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/client/common/default-setting.js +1 -0
- package/client/components/ai/agent-tools.js +121 -1
- package/client/components/ai/ai-chat-history-item.jsx +6 -0
- package/client/components/ai/ai-chat.jsx +1 -0
- package/client/components/ai/ai-config-props.js +1 -0
- package/client/components/ai/ai-config.jsx +13 -2
- package/client/components/ai/ai-output.jsx +6 -2
- package/client/components/sftp/file-item.jsx +12 -1
- package/client/components/terminal/osc52-addon.js +147 -0
- package/client/components/terminal/terminal.jsx +8 -1
- package/client/components/terminal/xmodem-client.js +62 -0
- package/client/store/common.js +1 -1
- package/client/store/load-data.js +14 -0
- package/client/store/mcp-handler.js +231 -0
- package/package.json +1 -1
|
@@ -31,7 +31,7 @@ export const agentTools = [
|
|
|
31
31
|
type: 'function',
|
|
32
32
|
function: {
|
|
33
33
|
name: 'send_terminal_command',
|
|
34
|
-
description: 'Send a command to a terminal tab and wait for it to finish. Returns the command output.',
|
|
34
|
+
description: 'Send a command to a terminal tab and wait for it to finish. Returns the command output. For long-running commands (builds, deployments, installations), use run_background_command instead to avoid timeouts.',
|
|
35
35
|
parameters: {
|
|
36
36
|
type: 'object',
|
|
37
37
|
properties: {
|
|
@@ -334,6 +334,114 @@ export const agentTools = [
|
|
|
334
334
|
properties: {}
|
|
335
335
|
}
|
|
336
336
|
}
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
type: 'function',
|
|
340
|
+
function: {
|
|
341
|
+
name: 'get_terminal_status',
|
|
342
|
+
description: 'Check terminal status: running (actively receiving data), idle, or password prompt. Returns last 20 lines of output. Lightweight, non-blocking.',
|
|
343
|
+
parameters: {
|
|
344
|
+
type: 'object',
|
|
345
|
+
properties: {
|
|
346
|
+
tabId: {
|
|
347
|
+
type: 'string',
|
|
348
|
+
description: 'Tab ID. Omit for active terminal.'
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
type: 'function',
|
|
356
|
+
function: {
|
|
357
|
+
name: 'cancel_terminal_command',
|
|
358
|
+
description: 'Cancel the running command in a terminal by sending Ctrl+C.',
|
|
359
|
+
parameters: {
|
|
360
|
+
type: 'object',
|
|
361
|
+
properties: {
|
|
362
|
+
tabId: {
|
|
363
|
+
type: 'string',
|
|
364
|
+
description: 'Tab ID. Omit for active terminal.'
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
type: 'function',
|
|
372
|
+
function: {
|
|
373
|
+
name: 'run_background_command',
|
|
374
|
+
description: 'Run a command in the background using nohup. The terminal is freed immediately. Returns a taskId for monitoring. Use get_background_task_status and get_background_task_log to check progress.',
|
|
375
|
+
parameters: {
|
|
376
|
+
type: 'object',
|
|
377
|
+
properties: {
|
|
378
|
+
command: {
|
|
379
|
+
type: 'string',
|
|
380
|
+
description: 'The shell command to run in the background.'
|
|
381
|
+
},
|
|
382
|
+
tabId: {
|
|
383
|
+
type: 'string',
|
|
384
|
+
description: 'Tab ID. Omit for active terminal.'
|
|
385
|
+
}
|
|
386
|
+
},
|
|
387
|
+
required: ['command']
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
type: 'function',
|
|
393
|
+
function: {
|
|
394
|
+
name: 'get_background_task_status',
|
|
395
|
+
description: 'Check if a background task is running, completed (with exit code), or unknown.',
|
|
396
|
+
parameters: {
|
|
397
|
+
type: 'object',
|
|
398
|
+
properties: {
|
|
399
|
+
taskId: {
|
|
400
|
+
type: 'string',
|
|
401
|
+
description: 'Task ID from run_background_command.'
|
|
402
|
+
}
|
|
403
|
+
},
|
|
404
|
+
required: ['taskId']
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
},
|
|
408
|
+
{
|
|
409
|
+
type: 'function',
|
|
410
|
+
function: {
|
|
411
|
+
name: 'get_background_task_log',
|
|
412
|
+
description: 'Read the output log of a background task. Returns the last N lines.',
|
|
413
|
+
parameters: {
|
|
414
|
+
type: 'object',
|
|
415
|
+
properties: {
|
|
416
|
+
taskId: {
|
|
417
|
+
type: 'string',
|
|
418
|
+
description: 'Task ID from run_background_command.'
|
|
419
|
+
},
|
|
420
|
+
lines: {
|
|
421
|
+
type: 'number',
|
|
422
|
+
description: 'Number of recent lines to read (default 100).'
|
|
423
|
+
}
|
|
424
|
+
},
|
|
425
|
+
required: ['taskId']
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
},
|
|
429
|
+
{
|
|
430
|
+
type: 'function',
|
|
431
|
+
function: {
|
|
432
|
+
name: 'cancel_background_task',
|
|
433
|
+
description: 'Cancel a running background task by killing its process.',
|
|
434
|
+
parameters: {
|
|
435
|
+
type: 'object',
|
|
436
|
+
properties: {
|
|
437
|
+
taskId: {
|
|
438
|
+
type: 'string',
|
|
439
|
+
description: 'Task ID from run_background_command.'
|
|
440
|
+
}
|
|
441
|
+
},
|
|
442
|
+
required: ['taskId']
|
|
443
|
+
}
|
|
444
|
+
}
|
|
337
445
|
}
|
|
338
446
|
]
|
|
339
447
|
|
|
@@ -391,6 +499,18 @@ export async function executeToolCall (toolName, args) {
|
|
|
391
499
|
return JSON.stringify(store.mcpSftpTransferList())
|
|
392
500
|
case 'sftp_transfer_history':
|
|
393
501
|
return JSON.stringify(store.mcpSftpTransferHistory())
|
|
502
|
+
case 'get_terminal_status':
|
|
503
|
+
return JSON.stringify(store.mcpGetTerminalStatus(args))
|
|
504
|
+
case 'cancel_terminal_command':
|
|
505
|
+
return JSON.stringify(store.mcpCancelTerminalCommand(args))
|
|
506
|
+
case 'run_background_command':
|
|
507
|
+
return JSON.stringify(store.mcpRunBackgroundCommand(args))
|
|
508
|
+
case 'get_background_task_status':
|
|
509
|
+
return JSON.stringify(await store.mcpGetBackgroundTaskStatus(args))
|
|
510
|
+
case 'get_background_task_log':
|
|
511
|
+
return JSON.stringify(await store.mcpGetBackgroundTaskLog(args))
|
|
512
|
+
case 'cancel_background_task':
|
|
513
|
+
return JSON.stringify(await store.mcpCancelBackgroundTask(args))
|
|
394
514
|
default:
|
|
395
515
|
throw new Error(`Unknown agent tool: ${toolName}`)
|
|
396
516
|
}
|
|
@@ -22,6 +22,7 @@ export default function AIChatHistoryItem ({ item }) {
|
|
|
22
22
|
const {
|
|
23
23
|
prompt,
|
|
24
24
|
sessionId,
|
|
25
|
+
nameAI,
|
|
25
26
|
modelAI,
|
|
26
27
|
roleAI,
|
|
27
28
|
baseURLAI,
|
|
@@ -190,6 +191,11 @@ export default function AIChatHistoryItem ({ item }) {
|
|
|
190
191
|
function renderTitle () {
|
|
191
192
|
return (
|
|
192
193
|
<div>
|
|
194
|
+
{nameAI && (
|
|
195
|
+
<p>
|
|
196
|
+
<b>Name:</b> {nameAI}
|
|
197
|
+
</p>
|
|
198
|
+
)}
|
|
193
199
|
<p>
|
|
194
200
|
<b>Model:</b> {modelAI}
|
|
195
201
|
</p>
|
|
@@ -94,10 +94,13 @@ export default function AIConfigForm ({ initialValues, onSubmit, showAIConfig })
|
|
|
94
94
|
|
|
95
95
|
function renderHistoryItem (item) {
|
|
96
96
|
if (!item || typeof item !== 'object') return { label: 'Unknown', title: 'Unknown' }
|
|
97
|
+
const name = item.nameAI || ''
|
|
97
98
|
const model = item.modelAI || 'Default Model'
|
|
98
99
|
const rolePrefix = item.roleAI ? item.roleAI.substring(0, 15) + '...' : ''
|
|
99
|
-
const label = `[${model}] ${rolePrefix}`
|
|
100
|
-
const title =
|
|
100
|
+
const label = name || `[${model}] ${rolePrefix}`
|
|
101
|
+
const title = name
|
|
102
|
+
? `${name}\nModel: ${item.modelAI}\nURL: ${item.baseURLAI}`
|
|
103
|
+
: `Model: ${item.modelAI}\nRole: ${item.roleAI}\nURL: ${item.baseURLAI}`
|
|
101
104
|
return { label, title }
|
|
102
105
|
}
|
|
103
106
|
|
|
@@ -131,6 +134,14 @@ export default function AIConfigForm ({ initialValues, onSubmit, showAIConfig })
|
|
|
131
134
|
layout='vertical'
|
|
132
135
|
className='ai-config-form'
|
|
133
136
|
>
|
|
137
|
+
<Form.Item
|
|
138
|
+
label='Name'
|
|
139
|
+
name='nameAI'
|
|
140
|
+
>
|
|
141
|
+
<Input
|
|
142
|
+
placeholder='e.g. DeepSeek Relay, Local Ollama (optional)'
|
|
143
|
+
/>
|
|
144
|
+
</Form.Item>
|
|
134
145
|
<Form.Item label={renderApiUrlLabel()} required>
|
|
135
146
|
<Space.Compact className='width-100'>
|
|
136
147
|
<Form.Item
|
|
@@ -10,7 +10,9 @@ const e = window.translate
|
|
|
10
10
|
export default function AIOutput ({ item }) {
|
|
11
11
|
const {
|
|
12
12
|
response,
|
|
13
|
-
baseURLAI
|
|
13
|
+
baseURLAI,
|
|
14
|
+
nameAI,
|
|
15
|
+
modelAI
|
|
14
16
|
} = item
|
|
15
17
|
if (!response) {
|
|
16
18
|
return null
|
|
@@ -82,10 +84,12 @@ export default function AIOutput ({ item }) {
|
|
|
82
84
|
if (!brand) {
|
|
83
85
|
return null
|
|
84
86
|
}
|
|
87
|
+
const nameLabel = nameAI || modelAI
|
|
88
|
+
const label = nameLabel ? `${brand}:${nameLabel}` : brand
|
|
85
89
|
return (
|
|
86
90
|
<div className='pd1y'>
|
|
87
91
|
<Link to={brandUrl}>
|
|
88
|
-
<Tag>{
|
|
92
|
+
<Tag>{label}</Tag>
|
|
89
93
|
</Link>
|
|
90
94
|
</div>
|
|
91
95
|
)
|
|
@@ -697,11 +697,22 @@ export default class FileSection extends React.Component {
|
|
|
697
697
|
const {
|
|
698
698
|
path, name
|
|
699
699
|
} = this.state.file
|
|
700
|
-
|
|
700
|
+
let rp = path ? resolve(path, name) : this.props[`${this.props.type}Path`]
|
|
701
|
+
if (this.props.type === typeMap.remote) {
|
|
702
|
+
rp = this.convertSftpPathToTerminalPath(rp)
|
|
703
|
+
}
|
|
701
704
|
this.props.tab.pane = paneMap.terminal
|
|
702
705
|
refs.get('term-' + this.props.tab.id)?.cd(rp)
|
|
703
706
|
}
|
|
704
707
|
|
|
708
|
+
convertSftpPathToTerminalPath = (p) => {
|
|
709
|
+
const m = p.match(/^\/([a-zA-Z]:)(.*)$/)
|
|
710
|
+
if (m) {
|
|
711
|
+
return m[1] + m[2].replace(/\//g, '\\')
|
|
712
|
+
}
|
|
713
|
+
return p
|
|
714
|
+
}
|
|
715
|
+
|
|
705
716
|
fetchEditorText = async (path, type) => {
|
|
706
717
|
// const sftp = sftpFunc()
|
|
707
718
|
const text = typeMap.remote === type
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Osc52Addon - Handles OSC 52 clipboard escape sequences
|
|
3
|
+
*
|
|
4
|
+
* OSC 52 allows terminal programs (TUI apps like vim, tmux, opencode, etc.)
|
|
5
|
+
* to copy and paste text via the system clipboard.
|
|
6
|
+
*
|
|
7
|
+
* Format: ESC ] 52 ; Pc ; Pd BEL (or ST)
|
|
8
|
+
* Pc = clipboard selection: c (clipboard), p (primary), s (secondary)
|
|
9
|
+
* Pd = base64-encoded content, or "?" to read
|
|
10
|
+
*
|
|
11
|
+
* Write request: ESC ] 52 ; c ; <base64data> BEL
|
|
12
|
+
* -> decodes base64 and writes to system clipboard
|
|
13
|
+
*
|
|
14
|
+
* Read request: ESC ] 52 ; c ; ? BEL
|
|
15
|
+
* -> reads system clipboard, encodes base64, sends response
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export class Osc52Addon {
|
|
19
|
+
constructor () {
|
|
20
|
+
this.terminal = undefined
|
|
21
|
+
this._disposables = []
|
|
22
|
+
this._sendData = null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Set the function used to send data back to the PTY
|
|
27
|
+
* @param {function} sendDataFn - function(string) to send data to server
|
|
28
|
+
*/
|
|
29
|
+
setSendData (sendDataFn) {
|
|
30
|
+
this._sendData = sendDataFn
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
activate (terminal) {
|
|
34
|
+
this.terminal = terminal
|
|
35
|
+
|
|
36
|
+
if (terminal.parser && terminal.parser.registerOscHandler) {
|
|
37
|
+
const oscHandler = terminal.parser.registerOscHandler(52, (data) => {
|
|
38
|
+
return this._handleOsc52(data)
|
|
39
|
+
})
|
|
40
|
+
this._disposables.push(oscHandler)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
dispose () {
|
|
45
|
+
this.terminal = null
|
|
46
|
+
if (this._disposables) {
|
|
47
|
+
this._disposables.forEach(d => d.dispose())
|
|
48
|
+
this._disposables.length = 0
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Handle OSC 52 clipboard sequence
|
|
54
|
+
* @param {string} data - The OSC data after "52;"
|
|
55
|
+
* @returns {boolean} Whether the sequence was handled
|
|
56
|
+
*/
|
|
57
|
+
_handleOsc52 (data) {
|
|
58
|
+
if (!data) return false
|
|
59
|
+
|
|
60
|
+
// Parse: Pc;Pd where Pc is selection target and Pd is payload
|
|
61
|
+
const semicolonIdx = data.indexOf(';')
|
|
62
|
+
if (semicolonIdx === -1) return false
|
|
63
|
+
|
|
64
|
+
const target = data.substring(0, semicolonIdx)
|
|
65
|
+
const payload = data.substring(semicolonIdx + 1)
|
|
66
|
+
|
|
67
|
+
// Only handle clipboard target ('c'), also accept 'c' among multiple targets
|
|
68
|
+
if (!target.includes('c')) return false
|
|
69
|
+
|
|
70
|
+
if (payload === '?') {
|
|
71
|
+
// Read request - send clipboard content back
|
|
72
|
+
return this._handleReadRequest()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Write request - decode and write to clipboard
|
|
76
|
+
return this._handleWriteRequest(payload)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Handle clipboard read request (ESC]52;c;? BEL)
|
|
81
|
+
* Reads system clipboard and sends response back to the terminal
|
|
82
|
+
*/
|
|
83
|
+
_handleReadRequest () {
|
|
84
|
+
try {
|
|
85
|
+
const text = window.pre.readClipboard()
|
|
86
|
+
const base64 = text ? this._encodeBase64(text) : ''
|
|
87
|
+
this._sendResponse(base64)
|
|
88
|
+
} catch (e) {
|
|
89
|
+
console.error('OSC 52 clipboard read failed:', e)
|
|
90
|
+
this._sendResponse('')
|
|
91
|
+
}
|
|
92
|
+
return true
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Handle clipboard write request (ESC]52;c;<base64> BEL)
|
|
97
|
+
* Decodes base64 content and writes to system clipboard
|
|
98
|
+
*/
|
|
99
|
+
_handleWriteRequest (base64Data) {
|
|
100
|
+
try {
|
|
101
|
+
const text = this._decodeBase64(base64Data)
|
|
102
|
+
if (text) {
|
|
103
|
+
window.pre.writeClipboard(text)
|
|
104
|
+
}
|
|
105
|
+
} catch (e) {
|
|
106
|
+
console.error('OSC 52 clipboard write failed:', e)
|
|
107
|
+
}
|
|
108
|
+
return true
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Send OSC 52 response back to the terminal (for read requests)
|
|
113
|
+
* @param {string} base64Data - base64-encoded clipboard content
|
|
114
|
+
*/
|
|
115
|
+
_sendResponse (base64Data) {
|
|
116
|
+
if (!this._sendData) return
|
|
117
|
+
// Response format: ESC ] 52 ; c ; <base64> BEL
|
|
118
|
+
const response = `\x1b]52;c;${base64Data}\x07`
|
|
119
|
+
this._sendData(response)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Encode a string to base64 (handles UTF-8 properly)
|
|
124
|
+
*/
|
|
125
|
+
_encodeBase64 (str) {
|
|
126
|
+
const encoder = new TextEncoder()
|
|
127
|
+
const bytes = encoder.encode(str)
|
|
128
|
+
let binary = ''
|
|
129
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
130
|
+
binary += String.fromCharCode(bytes[i])
|
|
131
|
+
}
|
|
132
|
+
return btoa(binary)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Decode a base64 string (handles UTF-8 properly)
|
|
137
|
+
*/
|
|
138
|
+
_decodeBase64 (base64) {
|
|
139
|
+
const binary = atob(base64)
|
|
140
|
+
const bytes = new Uint8Array(binary.length)
|
|
141
|
+
for (let i = 0; i < binary.length; i++) {
|
|
142
|
+
bytes[i] = binary.charCodeAt(i)
|
|
143
|
+
}
|
|
144
|
+
const decoder = new TextDecoder()
|
|
145
|
+
return decoder.decode(bytes)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -38,6 +38,7 @@ import { KeywordHighlighterAddon } from './highlight-addon.js'
|
|
|
38
38
|
import { getFilePath, isUnsafeFilename } from '../../common/file-drop-utils.js'
|
|
39
39
|
import { getFolderFromFilePath } from '../sftp/file-read.js'
|
|
40
40
|
import { CommandTrackerAddon } from './command-tracker-addon.js'
|
|
41
|
+
import { Osc52Addon } from './osc52-addon.js'
|
|
41
42
|
import AIIcon from '../icons/ai-icon.jsx'
|
|
42
43
|
import {
|
|
43
44
|
getShellIntegrationCommand,
|
|
@@ -204,6 +205,9 @@ class Term extends Component {
|
|
|
204
205
|
this.encode || this.props.tab.encode || 'utf-8'
|
|
205
206
|
)
|
|
206
207
|
await this.attachAddon.activate(this.term)
|
|
208
|
+
if (this.osc52Addon) {
|
|
209
|
+
this.osc52Addon.setSendData(this.attachAddon._sendData.bind(this.attachAddon))
|
|
210
|
+
}
|
|
207
211
|
}
|
|
208
212
|
|
|
209
213
|
getValue = (props, type, name) => {
|
|
@@ -391,7 +395,8 @@ class Term extends Component {
|
|
|
391
395
|
if (isUnsafeFilename(p)) {
|
|
392
396
|
return message.error('File name contains unsafe characters')
|
|
393
397
|
}
|
|
394
|
-
|
|
398
|
+
const isWinPath = /^[a-zA-Z]:\\/.test(p)
|
|
399
|
+
this.runQuickCommand(isWinPath ? `cd /d "${p}"` : `cd "${p}"`)
|
|
395
400
|
}
|
|
396
401
|
|
|
397
402
|
onDrop = e => {
|
|
@@ -1125,6 +1130,8 @@ class Term extends Component {
|
|
|
1125
1130
|
term.loadAddon(this.fitAddon)
|
|
1126
1131
|
term.loadAddon(this.searchAddon)
|
|
1127
1132
|
term.loadAddon(this.cmdAddon)
|
|
1133
|
+
this.osc52Addon = new Osc52Addon()
|
|
1134
|
+
term.loadAddon(this.osc52Addon)
|
|
1128
1135
|
if (tab.enableTerminalImage) {
|
|
1129
1136
|
const ImageAddon = await loadImageAddon()
|
|
1130
1137
|
this.imageAddon = new ImageAddon({
|
|
@@ -66,6 +66,68 @@ export class XmodemClient extends TransferClientBase {
|
|
|
66
66
|
case 'session-error':
|
|
67
67
|
this.onError(msg.error)
|
|
68
68
|
break
|
|
69
|
+
case 'auto-trigger-receive':
|
|
70
|
+
this.handleAutoReceive(msg.name)
|
|
71
|
+
break
|
|
72
|
+
case 'auto-trigger-send':
|
|
73
|
+
this.handleAutoSend()
|
|
74
|
+
break
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Auto-triggered: device is sending a file, electerm should receive it.
|
|
80
|
+
* Opens save folder dialog then starts xmodem receive.
|
|
81
|
+
* @param {string} fileName - Original filename from the device
|
|
82
|
+
*/
|
|
83
|
+
async handleAutoReceive (fileName) {
|
|
84
|
+
if (this.isActive) return
|
|
85
|
+
|
|
86
|
+
this.isActive = true
|
|
87
|
+
this.writeBanner('RECEIVE', null)
|
|
88
|
+
|
|
89
|
+
const savePath = await this.openSaveFolderSelect()
|
|
90
|
+
if (savePath) {
|
|
91
|
+
this.savePath = savePath
|
|
92
|
+
this.sendToServer({
|
|
93
|
+
event: 'start-receive'
|
|
94
|
+
})
|
|
95
|
+
this.sendToServer({
|
|
96
|
+
event: 'set-save-path',
|
|
97
|
+
path: savePath,
|
|
98
|
+
name: fileName
|
|
99
|
+
})
|
|
100
|
+
} else {
|
|
101
|
+
this.isActive = false
|
|
102
|
+
this.writeToTerminal('\r\n\x1b[33mXMODEM Receive cancelled\x1b[0m\r\n')
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Auto-triggered: device wants to receive a file, electerm should send it.
|
|
108
|
+
* Opens file select dialog then starts xmodem send.
|
|
109
|
+
*/
|
|
110
|
+
async handleAutoSend () {
|
|
111
|
+
if (this.isActive) return
|
|
112
|
+
|
|
113
|
+
this.isActive = true
|
|
114
|
+
this.writeBanner('SEND', null)
|
|
115
|
+
|
|
116
|
+
const files = await this.openFileSelect({
|
|
117
|
+
title: 'Choose file to send via XMODEM',
|
|
118
|
+
message: 'Choose file to send via XMODEM'
|
|
119
|
+
})
|
|
120
|
+
if (files && files.length > 0) {
|
|
121
|
+
this.sendToServer({
|
|
122
|
+
event: 'start-send'
|
|
123
|
+
})
|
|
124
|
+
this.sendToServer({
|
|
125
|
+
event: 'send-files',
|
|
126
|
+
files
|
|
127
|
+
})
|
|
128
|
+
} else {
|
|
129
|
+
this.isActive = false
|
|
130
|
+
this.writeToTerminal('\r\n\x1b[33mXMODEM Send cancelled\x1b[0m\r\n')
|
|
69
131
|
}
|
|
70
132
|
}
|
|
71
133
|
|
package/client/store/common.js
CHANGED
|
@@ -298,7 +298,7 @@ export default Store => {
|
|
|
298
298
|
}
|
|
299
299
|
|
|
300
300
|
Store.prototype.aiConfigMissing = function () {
|
|
301
|
-
return aiConfigsArr.filter(k => k !== 'apiKeyAI' && k !== 'proxyAI').some(k => !window.store.config[k])
|
|
301
|
+
return aiConfigsArr.filter(k => k !== 'apiKeyAI' && k !== 'proxyAI' && k !== 'nameAI').some(k => !window.store.config[k])
|
|
302
302
|
}
|
|
303
303
|
|
|
304
304
|
Store.prototype.clearHistory = function () {
|
|
@@ -52,6 +52,20 @@ export async function addTabFromCommandLine (store, opts) {
|
|
|
52
52
|
if (isHelp) {
|
|
53
53
|
return store.openAbout(infoTabs.cmd)
|
|
54
54
|
}
|
|
55
|
+
// Check if argv contains a protocol URL (e.g., ssh://user@host)
|
|
56
|
+
// and use parseQuickConnect for proper parsing
|
|
57
|
+
if (argv && argv.length) {
|
|
58
|
+
const protocolUrl = argv.find(arg =>
|
|
59
|
+
/^(ssh|telnet|rdp|vnc|serial|spice|ftp|http|https|electerm):\/\//i.test(arg)
|
|
60
|
+
)
|
|
61
|
+
if (protocolUrl) {
|
|
62
|
+
const parsed = parseQuickConnect(protocolUrl)
|
|
63
|
+
if (parsed) {
|
|
64
|
+
return store.ipcOpenTab(parsed)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
55
69
|
const conf = getHost(argv, options)
|
|
56
70
|
const update = {
|
|
57
71
|
passphrase: options.passphrase,
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import uid from '../common/uid'
|
|
7
7
|
import { settingMap } from '../common/constants'
|
|
8
8
|
import { refs, refsTabs } from '../components/common/ref'
|
|
9
|
+
import { runCmd } from '../components/terminal/terminal-apis'
|
|
9
10
|
import deepCopy from 'json-deep-copy'
|
|
10
11
|
import {
|
|
11
12
|
getLocalFileInfo,
|
|
@@ -118,6 +119,26 @@ export default Store => {
|
|
|
118
119
|
case 'wait_for_terminal_idle':
|
|
119
120
|
result = await store.mcpWaitForTerminalIdle(args)
|
|
120
121
|
break
|
|
122
|
+
case 'get_terminal_status':
|
|
123
|
+
result = store.mcpGetTerminalStatus(args)
|
|
124
|
+
break
|
|
125
|
+
case 'cancel_terminal_command':
|
|
126
|
+
result = store.mcpCancelTerminalCommand(args)
|
|
127
|
+
break
|
|
128
|
+
|
|
129
|
+
// Background task operations
|
|
130
|
+
case 'run_background_command':
|
|
131
|
+
result = store.mcpRunBackgroundCommand(args)
|
|
132
|
+
break
|
|
133
|
+
case 'get_background_task_status':
|
|
134
|
+
result = await store.mcpGetBackgroundTaskStatus(args)
|
|
135
|
+
break
|
|
136
|
+
case 'get_background_task_log':
|
|
137
|
+
result = await store.mcpGetBackgroundTaskLog(args)
|
|
138
|
+
break
|
|
139
|
+
case 'cancel_background_task':
|
|
140
|
+
result = await store.mcpCancelBackgroundTask(args)
|
|
141
|
+
break
|
|
121
142
|
|
|
122
143
|
// SFTP operations
|
|
123
144
|
case 'sftp_list':
|
|
@@ -652,6 +673,216 @@ export default Store => {
|
|
|
652
673
|
}
|
|
653
674
|
}
|
|
654
675
|
|
|
676
|
+
// ==================== Terminal Status & Cancel ====================
|
|
677
|
+
|
|
678
|
+
Store.prototype.mcpGetTerminalStatus = function (args) {
|
|
679
|
+
const { store } = window
|
|
680
|
+
const tabId = args.tabId || store.activeTabId
|
|
681
|
+
if (!tabId) {
|
|
682
|
+
throw new Error('No active terminal')
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const tabRef = refsTabs.get('tab-' + tabId)
|
|
686
|
+
const onData = tabRef?.state.terminalOnData || ''
|
|
687
|
+
const term = refs.get('term-' + tabId)
|
|
688
|
+
|
|
689
|
+
let output = ''
|
|
690
|
+
let lineCount = 0
|
|
691
|
+
if (term && term.term) {
|
|
692
|
+
const buffer = term.term.buffer.active
|
|
693
|
+
if (buffer) {
|
|
694
|
+
const lines = []
|
|
695
|
+
const cursorY = buffer.cursorY || 0
|
|
696
|
+
const baseY = buffer.baseY || 0
|
|
697
|
+
const totalLines = buffer.length || 0
|
|
698
|
+
const end = baseY + cursorY + 1
|
|
699
|
+
const start = Math.max(0, end - 20)
|
|
700
|
+
for (let i = start; i < Math.min(totalLines, end); i++) {
|
|
701
|
+
const line = buffer.getLine(i)
|
|
702
|
+
if (line) {
|
|
703
|
+
lines.push(line.translateToString(true))
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
output = lines.join('\n')
|
|
707
|
+
lineCount = lines.length
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
return {
|
|
712
|
+
tabId,
|
|
713
|
+
isRunning: onData === 'feed',
|
|
714
|
+
hasPasswordPrompt: onData === 'password',
|
|
715
|
+
isIdle: !onData,
|
|
716
|
+
output,
|
|
717
|
+
lineCount
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
Store.prototype.mcpCancelTerminalCommand = function (args) {
|
|
722
|
+
const { store } = window
|
|
723
|
+
const tabId = args.tabId || store.activeTabId
|
|
724
|
+
if (!tabId) {
|
|
725
|
+
throw new Error('No active terminal')
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const term = refs.get('term-' + tabId)
|
|
729
|
+
if (!term || !term.attachAddon) {
|
|
730
|
+
throw new Error('Terminal not found')
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
term.attachAddon._sendData('\x03')
|
|
734
|
+
return {
|
|
735
|
+
success: true,
|
|
736
|
+
message: 'Sent Ctrl+C to terminal',
|
|
737
|
+
tabId
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// ==================== Background Task Management ====================
|
|
742
|
+
|
|
743
|
+
const backgroundTasks = new Map()
|
|
744
|
+
let bgTaskCounter = 0
|
|
745
|
+
|
|
746
|
+
async function runMonitorCmd (tabId, cmd) {
|
|
747
|
+
try {
|
|
748
|
+
const result = await runCmd(tabId, cmd)
|
|
749
|
+
return result
|
|
750
|
+
} catch (e) {
|
|
751
|
+
// Fallback: send via terminal and wait for idle
|
|
752
|
+
const { store } = window
|
|
753
|
+
store.mcpSendTerminalCommand({ command: cmd, tabId })
|
|
754
|
+
const idle = await store.mcpWaitForTerminalIdle({
|
|
755
|
+
tabId, timeout: 10000, lines: 10, minWait: 500
|
|
756
|
+
})
|
|
757
|
+
return idle.output || ''
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
Store.prototype.mcpRunBackgroundCommand = function (args) {
|
|
762
|
+
const { store } = window
|
|
763
|
+
const tabId = args.tabId || store.activeTabId
|
|
764
|
+
if (!tabId) {
|
|
765
|
+
throw new Error('No active terminal')
|
|
766
|
+
}
|
|
767
|
+
if (!args.command) {
|
|
768
|
+
throw new Error('No command provided')
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const taskId = `bg-${Date.now()}-${++bgTaskCounter}`
|
|
772
|
+
const logFile = `/tmp/electerm-${taskId}.log`
|
|
773
|
+
const pidFile = `/tmp/electerm-${taskId}.pid`
|
|
774
|
+
const exitFile = `/tmp/electerm-${taskId}.exit`
|
|
775
|
+
|
|
776
|
+
// Encode command as base64 to avoid all quote-escaping issues.
|
|
777
|
+
// The subshell runs the user's command, captures its exit code, then cleans up the PID file.
|
|
778
|
+
const b64 = btoa(args.command)
|
|
779
|
+
const inner = `eval "$(echo ${b64} | base64 --decode)" > ${logFile} 2>&1; e=$?; echo $e > ${exitFile}; rm -f ${pidFile}`
|
|
780
|
+
const wrapped = `nohup bash -c '${inner}' & echo $! > ${pidFile}; disown`
|
|
781
|
+
|
|
782
|
+
store.mcpSendTerminalCommand({ command: wrapped, tabId, inputOnly: false })
|
|
783
|
+
|
|
784
|
+
const task = {
|
|
785
|
+
id: taskId,
|
|
786
|
+
command: args.command,
|
|
787
|
+
tabId,
|
|
788
|
+
startTime: Date.now(),
|
|
789
|
+
logFile,
|
|
790
|
+
pidFile,
|
|
791
|
+
exitFile,
|
|
792
|
+
status: 'started'
|
|
793
|
+
}
|
|
794
|
+
backgroundTasks.set(taskId, task)
|
|
795
|
+
|
|
796
|
+
return {
|
|
797
|
+
taskId,
|
|
798
|
+
tabId,
|
|
799
|
+
logFile,
|
|
800
|
+
pidFile,
|
|
801
|
+
exitFile,
|
|
802
|
+
message: 'Command started in background. Use get_background_task_status to check.'
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
Store.prototype.mcpGetBackgroundTaskStatus = async function (args) {
|
|
807
|
+
const task = backgroundTasks.get(args.taskId)
|
|
808
|
+
if (!task) {
|
|
809
|
+
throw new Error(`Task ${args.taskId} not found`)
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
const pidOutput = await runMonitorCmd(task.tabId,
|
|
813
|
+
`cat ${task.pidFile} 2>/dev/null`)
|
|
814
|
+
const pid = pidOutput.trim()
|
|
815
|
+
|
|
816
|
+
if (!pid) {
|
|
817
|
+
return { ...task, status: 'unknown', message: 'PID file not found' }
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const aliveCheck = await runMonitorCmd(task.tabId,
|
|
821
|
+
`kill -0 ${pid} 2>/dev/null && echo alive || echo dead`)
|
|
822
|
+
|
|
823
|
+
if (aliveCheck.trim() === 'alive') {
|
|
824
|
+
task.status = 'running'
|
|
825
|
+
return { ...task, pid, status: 'running' }
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// Process exited — read exit code
|
|
829
|
+
const exitOutput = await runMonitorCmd(task.tabId,
|
|
830
|
+
`cat ${task.exitFile} 2>/dev/null`)
|
|
831
|
+
const exitCode = exitOutput.trim()
|
|
832
|
+
|
|
833
|
+
task.status = 'completed'
|
|
834
|
+
task.exitCode = exitCode !== '' ? parseInt(exitCode, 10) : null
|
|
835
|
+
task.endTime = Date.now()
|
|
836
|
+
return { ...task, pid, status: 'completed', exitCode: task.exitCode }
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
Store.prototype.mcpGetBackgroundTaskLog = async function (args) {
|
|
840
|
+
const task = backgroundTasks.get(args.taskId)
|
|
841
|
+
if (!task) {
|
|
842
|
+
throw new Error(`Task ${args.taskId} not found`)
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const lines = args.lines || 100
|
|
846
|
+
const output = await runMonitorCmd(task.tabId,
|
|
847
|
+
`tail -n ${lines} ${task.logFile} 2>/dev/null || echo '(no output yet)'`)
|
|
848
|
+
|
|
849
|
+
return {
|
|
850
|
+
taskId: task.id,
|
|
851
|
+
output: output.trim(),
|
|
852
|
+
lines
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
Store.prototype.mcpCancelBackgroundTask = async function (args) {
|
|
857
|
+
const task = backgroundTasks.get(args.taskId)
|
|
858
|
+
if (!task) {
|
|
859
|
+
throw new Error(`Task ${args.taskId} not found`)
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const pidOutput = await runMonitorCmd(task.tabId,
|
|
863
|
+
`cat ${task.pidFile} 2>/dev/null`)
|
|
864
|
+
const pid = pidOutput.trim()
|
|
865
|
+
|
|
866
|
+
if (pid) {
|
|
867
|
+
await runMonitorCmd(task.tabId,
|
|
868
|
+
`kill ${pid} 2>/dev/null; echo $? > ${task.exitFile}`)
|
|
869
|
+
task.status = 'cancelled'
|
|
870
|
+
task.endTime = Date.now()
|
|
871
|
+
return {
|
|
872
|
+
taskId: task.id,
|
|
873
|
+
pid,
|
|
874
|
+
status: 'cancelled',
|
|
875
|
+
message: 'Process killed'
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
return {
|
|
880
|
+
taskId: task.id,
|
|
881
|
+
status: 'unknown',
|
|
882
|
+
message: 'PID not found, task may have already finished'
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
655
886
|
// ==================== Settings APIs ====================
|
|
656
887
|
|
|
657
888
|
Store.prototype.mcpGetSettings = function () {
|