@electerm/electerm-react 3.15.126 → 3.15.138

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.
@@ -256,6 +256,7 @@ export const regexHelpLink = 'https://github.com/electerm/electerm/wiki/Terminal
256
256
  export const connectionHoppingWikiLink = 'https://github.com/electerm/electerm/wiki/Connection-Hopping-Behavior-Change-in-electerm-since-v1.50.65'
257
257
  export const aiConfigWikiLink = 'https://github.com/electerm/electerm/wiki/AI-model-config-guide'
258
258
  export const aiChatModeLsKey = 'ai-chat-mode'
259
+ export const lastAiChatSessionIdKey = 'last-ai-chat-session-id'
259
260
  export const modals = {
260
261
  hide: 0,
261
262
  setting: 1
@@ -53,6 +53,7 @@ const TYPE_DEFAULT_VALUES = {
53
53
  enableSsh: true,
54
54
  enableSftp: true,
55
55
  useSshAgent: true,
56
+ authType: 'password',
56
57
  term: 'xterm-256color',
57
58
  encode: 'utf-8',
58
59
  envLang: 'en_US.UTF-8'
@@ -137,8 +138,8 @@ function parseQuickConnect (str) {
137
138
  // Shortcut format - default to SSH
138
139
  // Match user@host or user@host:port or just host or host:port
139
140
  // Use last colon to determine port for host:port format
140
- if (/^[\w.-]+@[\w.-]+/.test(input)) {
141
- // user@host or user@host:port
141
+ if (/^[\w.-]+(?::[^@]+)?@[\w.-]+/.test(input)) {
142
+ // user@host, user:password@host, or user@host:port
142
143
  protocol = 'ssh'
143
144
  connectionString = input
144
145
  } else if (/^[\w.-]+:.*:[\d]+$/.test(input)) {
@@ -46,13 +46,22 @@ async function callBackendAIchatWithTools (messages, config) {
46
46
  )
47
47
  }
48
48
 
49
- export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming) {
49
+ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming, conversationMessages = null) {
50
50
  window.store.agentRunning = true
51
51
  try {
52
- const messages = [
53
- { role: 'system', content: buildAgentSystemPrompt(config) },
54
- { role: 'user', content: chatEntry.prompt }
55
- ]
52
+ let messages
53
+ if (conversationMessages && conversationMessages.length > 1) {
54
+ // Replace system message with agent system prompt, keep conversation history
55
+ messages = [
56
+ { role: 'system', content: buildAgentSystemPrompt(config) },
57
+ ...conversationMessages.filter(m => m.role !== 'system')
58
+ ]
59
+ } else {
60
+ messages = [
61
+ { role: 'system', content: buildAgentSystemPrompt(config) },
62
+ { role: 'user', content: chatEntry.prompt }
63
+ ]
64
+ }
56
65
  const toolCallsLog = []
57
66
  let accumulatedContent = ''
58
67
 
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useRef, useCallback } from 'react'
1
+ import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
2
2
  import AIOutput from './ai-output'
3
3
  import AIStopIcon from './ai-stop-icon'
4
4
  import AgentToolCallCard from './agent-tool-call-card'
@@ -22,6 +22,7 @@ export default function AIChatHistoryItem ({ item }) {
22
22
  const {
23
23
  prompt,
24
24
  sessionId,
25
+ chatSessionId,
25
26
  nameAI,
26
27
  modelAI,
27
28
  roleAI,
@@ -29,6 +30,7 @@ export default function AIChatHistoryItem ({ item }) {
29
30
  apiPathAI,
30
31
  apiKeyAI,
31
32
  proxyAI,
33
+ authHeaderNameAI,
32
34
  languageAI,
33
35
  mode,
34
36
  toolCalls
@@ -43,6 +45,50 @@ export default function AIChatHistoryItem ({ item }) {
43
45
  return roleAI + `;用[${lang}]回复`
44
46
  }
45
47
 
48
+ const conversationMessages = useMemo(() => {
49
+ if (!chatSessionId) {
50
+ return null
51
+ }
52
+ const sessionEntries = window.store.aiChatHistory
53
+ .filter(h => h.chatSessionId === chatSessionId && h.timestamp <= item.timestamp)
54
+ .sort((a, b) => a.timestamp - b.timestamp)
55
+
56
+ // Find the last compress entry
57
+ let lastCompressIndex = -1
58
+ for (let i = sessionEntries.length - 1; i >= 0; i--) {
59
+ if (sessionEntries[i].compressed) {
60
+ lastCompressIndex = i
61
+ break
62
+ }
63
+ }
64
+
65
+ const messages = [
66
+ { role: 'system', content: buildRole() }
67
+ ]
68
+
69
+ // Start from the last compress entry (if any), skip older history
70
+ const startIndex = lastCompressIndex >= 0 ? lastCompressIndex : 0
71
+ for (let i = startIndex; i < sessionEntries.length; i++) {
72
+ const entry = sessionEntries[i]
73
+ if (entry.compressed) {
74
+ messages.push({
75
+ role: 'user',
76
+ content: `Here is a summary of our previous conversation for context:\n\n${entry.response}`
77
+ })
78
+ messages.push({
79
+ role: 'assistant',
80
+ content: 'Understood. I will use this context as we continue.'
81
+ })
82
+ } else {
83
+ messages.push({ role: 'user', content: entry.prompt })
84
+ if (entry.response && entry.id !== item.id) {
85
+ messages.push({ role: 'assistant', content: entry.response })
86
+ }
87
+ }
88
+ }
89
+ return messages
90
+ }, [chatSessionId, item.id, item.timestamp])
91
+
46
92
  const pollStreamContent = useCallback(async (sid) => {
47
93
  try {
48
94
  const streamResponse = await window.pre.runGlobalAsync('getStreamContent', sid)
@@ -81,7 +127,9 @@ export default function AIChatHistoryItem ({ item }) {
81
127
  apiPathAI,
82
128
  apiKeyAI,
83
129
  proxyAI,
84
- true
130
+ true,
131
+ authHeaderNameAI,
132
+ conversationMessages
85
133
  )
86
134
 
87
135
  if (aiResponse && aiResponse.error) {
@@ -107,7 +155,7 @@ export default function AIChatHistoryItem ({ item }) {
107
155
  window.store.removeAiHistory(item.id)
108
156
  window.store.onError(error)
109
157
  }
110
- }, [prompt, modelAI, baseURLAI, apiPathAI, apiKeyAI, proxyAI, item.id, pollStreamContent])
158
+ }, [prompt, modelAI, baseURLAI, apiPathAI, apiKeyAI, proxyAI, authHeaderNameAI, item.id, pollStreamContent, conversationMessages])
111
159
 
112
160
  const startAgentRequest = useCallback(async () => {
113
161
  abortRef.current = false
@@ -118,10 +166,11 @@ export default function AIChatHistoryItem ({ item }) {
118
166
  apiPathAI,
119
167
  apiKeyAI,
120
168
  proxyAI,
121
- languageAI
169
+ languageAI,
170
+ authHeaderNameAI
122
171
  }
123
- await runAgentLoop(item, config, abortRef, setIsStreaming)
124
- }, [modelAI, roleAI, baseURLAI, apiPathAI, apiKeyAI, proxyAI, languageAI, item.id])
172
+ await runAgentLoop(item, config, abortRef, setIsStreaming, conversationMessages)
173
+ }, [modelAI, roleAI, baseURLAI, apiPathAI, apiKeyAI, proxyAI, languageAI, authHeaderNameAI, item.id, conversationMessages])
125
174
 
126
175
  useEffect(() => {
127
176
  if (item.pending) {
@@ -0,0 +1,51 @@
1
+ import { Flex, Button, Popconfirm } from 'antd'
2
+ import { DeleteOutlined, MessageOutlined } from '@ant-design/icons'
3
+
4
+ export default function AiChatSession ({
5
+ session,
6
+ isActive,
7
+ onLoadSession,
8
+ onDeleteSession
9
+ }) {
10
+ const time = new Date(session.timestamp).toLocaleString()
11
+ const preview = session.firstPrompt.length > 80
12
+ ? session.firstPrompt.substring(0, 80) + '...'
13
+ : session.firstPrompt
14
+ return (
15
+ <div
16
+ key={session.sessionId}
17
+ className={`ai-chat-session-item${isActive ? ' active' : ''}`}
18
+ onClick={() => onLoadSession(session.sessionId)}
19
+ >
20
+ <Flex vertical className='ai-chat-session-content'>
21
+ <Flex align='center' className='ai-chat-session-header'>
22
+ <MessageOutlined className='mg1r' />
23
+ <span className='ai-chat-session-preview'>{preview}</span>
24
+ </Flex>
25
+ <Flex justify='space-between' align='center' className='ai-chat-session-meta'>
26
+ <span className='ai-chat-session-time'>{time}</span>
27
+ <span className='ai-chat-session-count'>{session.messageCount} messages</span>
28
+ </Flex>
29
+ </Flex>
30
+ <Popconfirm
31
+ title='Delete this chat session?'
32
+ okText='Delete'
33
+ cancelText='Cancel'
34
+ onConfirm={(e) => {
35
+ e?.stopPropagation()
36
+ onDeleteSession(session.sessionId)
37
+ }}
38
+ onCancel={(e) => e?.stopPropagation()}
39
+ >
40
+ <Button
41
+ size='small'
42
+ type='text'
43
+ danger
44
+ icon={<DeleteOutlined />}
45
+ onClick={(e) => e.stopPropagation()}
46
+ className='ai-chat-session-delete'
47
+ />
48
+ </Popconfirm>
49
+ </div>
50
+ )
51
+ }
@@ -0,0 +1,59 @@
1
+ import { Flex, Button, Popconfirm, Empty } from 'antd'
2
+ import { ClearOutlined } from '@ant-design/icons'
3
+ import AiChatSession from './ai-chat-session'
4
+
5
+ const e = window.translate
6
+
7
+ export default function AiChatSessions ({
8
+ sessions,
9
+ currentChatSessionId,
10
+ onLoadSession,
11
+ onDeleteSession,
12
+ onClearAll
13
+ }) {
14
+ if (!sessions || sessions.length === 0) {
15
+ return (
16
+ <Flex
17
+ vertical
18
+ align='center'
19
+ justify='center'
20
+ className='ai-chat-sessions-empty'
21
+ >
22
+ <Empty
23
+ description='No chat history yet'
24
+ image={Empty.PRESENTED_IMAGE_SIMPLE}
25
+ />
26
+ </Flex>
27
+ )
28
+ }
29
+
30
+ return (
31
+ <div className='ai-chat-sessions-wrap'>
32
+ <Flex justify='end' className='ai-chat-sessions-toolbar'>
33
+ <Popconfirm
34
+ title='?'
35
+ okText={e('clear')}
36
+ cancelText={e('cancel')}
37
+ onConfirm={onClearAll}
38
+ >
39
+ <Button
40
+ size='small'
41
+ danger
42
+ icon={<ClearOutlined />}
43
+ >
44
+ {e('clear')}
45
+ </Button>
46
+ </Popconfirm>
47
+ </Flex>
48
+ {sessions.map(session => (
49
+ <AiChatSession
50
+ key={session.sessionId}
51
+ session={session}
52
+ isActive={session.sessionId === currentChatSessionId}
53
+ onLoadSession={onLoadSession}
54
+ onDeleteSession={onDeleteSession}
55
+ />
56
+ ))}
57
+ </div>
58
+ )
59
+ }
@@ -1,13 +1,16 @@
1
1
  import { useState, useCallback, useEffect } from 'react'
2
- import { Flex, Input, Popconfirm, Segmented } from 'antd'
2
+ import { Flex, Input, Segmented, Button } from 'antd'
3
3
  import TabSelect from '../footer/tab-select'
4
4
  import AiChatHistory from './ai-chat-history'
5
+ import AiChatSessions from './ai-chat-sessions'
5
6
  import uid from '../../common/uid'
6
7
  import { pick } from 'lodash-es'
7
8
  import {
8
9
  SettingOutlined,
9
10
  SendOutlined,
10
- UnorderedListOutlined
11
+ PlusOutlined,
12
+ HistoryOutlined,
13
+ CompressOutlined
11
14
  } from '@ant-design/icons'
12
15
  import {
13
16
  aiConfigWikiLink,
@@ -19,14 +22,27 @@ import { refsStatic } from '../common/ref'
19
22
  import './ai.styl'
20
23
 
21
24
  const { TextArea } = Input
22
- const MAX_HISTORY = 100
25
+ const MAX_HISTORY = 500
23
26
 
24
27
  export default function AIChat (props) {
25
28
  const [prompt, setPrompt] = useState('')
29
+ const [compressing, setCompressing] = useState(false)
26
30
  const [mode, setMode] = useState(() => getItem(aiChatModeLsKey) || 'ask')
27
31
  const isAgent = mode === 'agent'
28
32
  const submitDisabled = isAgent && props.agentRunning
29
33
 
34
+ const currentChatSessionId = props.currentChatSessionId || ''
35
+
36
+ useEffect(() => {
37
+ if (!currentChatSessionId && props.rightPanelTab === 'ai') {
38
+ window.store.startNewChat()
39
+ }
40
+ }, [currentChatSessionId, props.rightPanelTab])
41
+
42
+ const sessionHistory = (props.aiChatHistory || []).filter(
43
+ h => h.chatSessionId === currentChatSessionId
44
+ )
45
+
30
46
  function handlePromptChange (e) {
31
47
  setPrompt(e.target.value)
32
48
  }
@@ -50,6 +66,7 @@ export default function AIChat (props) {
50
66
  isStreaming: false,
51
67
  pending: true,
52
68
  sessionId: null,
69
+ chatSessionId: currentChatSessionId,
53
70
  mode,
54
71
  toolCalls: [],
55
72
  ...pick(props.config, [
@@ -60,7 +77,8 @@ export default function AIChat (props) {
60
77
  'apiPathAI',
61
78
  'apiKeyAI',
62
79
  'proxyAI',
63
- 'languageAI'
80
+ 'languageAI',
81
+ 'authHeaderNameAI'
64
82
  ]),
65
83
  timestamp: Date.now(),
66
84
  id: chatId
@@ -72,12 +90,23 @@ export default function AIChat (props) {
72
90
  if (window.store.aiChatHistory.length > MAX_HISTORY) {
73
91
  window.store.aiChatHistory.splice(MAX_HISTORY)
74
92
  }
75
- }, [prompt, mode])
93
+ }, [prompt, mode, currentChatSessionId])
76
94
 
77
95
  function renderHistory () {
96
+ if (props.showChatSessions) {
97
+ return (
98
+ <AiChatSessions
99
+ sessions={window.store.getChatSessions()}
100
+ currentChatSessionId={currentChatSessionId}
101
+ onLoadSession={(sid) => window.store.loadChatSession(sid)}
102
+ onDeleteSession={(sid) => window.store.deleteChatSession(sid)}
103
+ onClearAll={() => window.store.clearAllChatSessions()}
104
+ />
105
+ )
106
+ }
78
107
  return (
79
108
  <AiChatHistory
80
- history={props.aiChatHistory}
109
+ history={sessionHistory}
81
110
  />
82
111
  )
83
112
  }
@@ -86,8 +115,21 @@ export default function AIChat (props) {
86
115
  window.store.toggleAIConfig()
87
116
  }
88
117
 
89
- function clearHistory () {
90
- window.store.aiChatHistory = []
118
+ function handleNewChat () {
119
+ window.store.startNewChat()
120
+ }
121
+
122
+ async function handleCompressSession () {
123
+ setCompressing(true)
124
+ try {
125
+ await window.store.compressChatSession(currentChatSessionId)
126
+ } finally {
127
+ setCompressing(false)
128
+ }
129
+ }
130
+
131
+ function handleShowHistory () {
132
+ window.store.toggleChatSessions()
91
133
  }
92
134
 
93
135
  function renderTabSelect () {
@@ -146,14 +188,43 @@ export default function AIChat (props) {
146
188
  }
147
189
  }
148
190
  }
149
-
191
+ const e = window.translate
150
192
  return (
151
193
  <Flex vertical className='ai-chat-container'>
152
194
  <Flex className='ai-chat-history' flex='auto'>
153
195
  {renderHistory()}
154
196
  </Flex>
155
197
 
156
- <Flex className='ai-chat-input'>
198
+ <Flex vertical className='ai-chat-input'>
199
+ <Flex className='ai-chat-toolbar mg1b' align='left' gap={4}>
200
+ <Button
201
+ size='small'
202
+ icon={<PlusOutlined />}
203
+ onClick={handleNewChat}
204
+ className='mg1r'
205
+ >
206
+ {e('new')}
207
+ </Button>
208
+ {sessionHistory.length >= 2 && (
209
+ <Button
210
+ size='small'
211
+ icon={<CompressOutlined />}
212
+ onClick={handleCompressSession}
213
+ loading={compressing}
214
+ className='mg1r'
215
+ >
216
+ {e('compress')}
217
+ </Button>
218
+ )}
219
+ <Button
220
+ size='small'
221
+ icon={<HistoryOutlined />}
222
+ onClick={handleShowHistory}
223
+ type={props.showChatSessions ? 'primary' : 'default'}
224
+ >
225
+ {e('history')}
226
+ </Button>
227
+ </Flex>
157
228
  <TextArea
158
229
  value={prompt}
159
230
  onChange={handlePromptChange}
@@ -175,17 +246,6 @@ export default function AIChat (props) {
175
246
  onClick={toggleConfig}
176
247
  className='mg1l pointer icon-hover toggle-ai-setting-icon'
177
248
  />
178
- <Popconfirm
179
- title={window.translate('clear') + ' AI ' + window.translate('history') + '?'}
180
- okText={window.translate('ok')}
181
- cancelText={window.translate('cancel')}
182
- onConfirm={clearHistory}
183
- >
184
- <UnorderedListOutlined
185
- className='mg2x pointer clear-ai-icon icon-hover'
186
- title='Clear AI chat history'
187
- />
188
- </Popconfirm>
189
249
  <HelpIcon
190
250
  link={aiConfigWikiLink}
191
251
  />
@@ -143,11 +143,14 @@ export default function AIConfigForm ({ initialValues, onSubmit, showAIConfig })
143
143
  return { label, title }
144
144
  }
145
145
 
146
- function renderApiUrlLabel () {
146
+ function renderApiKeyLabel () {
147
147
  if (baseURLAI === 'https://api.atlascloud.ai/v1') {
148
- return <span>API URL (<Link to='https://atlascloud.ai'>AtlasCloud</Link>)</span>
148
+ return <span className='bold'>API Key (<Link to='https://www.atlascloud.ai/?utm_source=electerm_app&utm_medium=link&utm_campaign=electerm'>get API key from atlascloud</Link>)</span>
149
149
  }
150
- return 'API URL'
150
+ if (baseURLAI === 'https://ai.electerm.org/api/ai') {
151
+ return <span className='bold'>API Key (<Link to='https://ai.electerm.org?utm=electerm'>get API key from ai.electerm.org(free)</Link>)</span>
152
+ }
153
+ return 'API Key'
151
154
  }
152
155
 
153
156
  if (!showAIConfig) {
@@ -184,7 +187,7 @@ export default function AIConfigForm ({ initialValues, onSubmit, showAIConfig })
184
187
  placeholder='e.g. DeepSeek Relay, Local Ollama (optional)'
185
188
  />
186
189
  </Form.Item>
187
- <Form.Item label={renderApiUrlLabel()} required>
190
+ <Form.Item label='API URL' required>
188
191
  <Space.Compact className='width-100'>
189
192
  <Form.Item
190
193
  label='API URL'
@@ -226,7 +229,7 @@ export default function AIConfigForm ({ initialValues, onSubmit, showAIConfig })
226
229
  </Form.Item>
227
230
 
228
231
  <Form.Item
229
- label='API Key'
232
+ label={renderApiKeyLabel()}
230
233
  name='apiKeyAI'
231
234
  >
232
235
  <Password placeholder='Enter your API key' />
@@ -67,15 +67,68 @@
67
67
  opacity 0.4
68
68
  cursor not-allowed
69
69
 
70
- .clear-ai-icon
71
- position relative
72
- &::after
73
- content '×'
74
- position absolute
75
- font-size 12px
76
- top 8px
77
- right -4px
78
- font-weight bold
70
+ .ai-chat-sessions-wrap
71
+ width 100%
72
+ overflow-y auto
73
+ overflow-x hidden
74
+ padding 4px 0
75
+
76
+ .ai-chat-sessions-toolbar
77
+ padding 0 4px 8px
78
+
79
+ .ai-chat-session-item
80
+ display flex
81
+ align-items center
82
+ padding 8px 10px
83
+ margin-bottom 6px
84
+ border 1px solid var(--main-darker)
85
+ border-radius 4px
86
+ cursor pointer
87
+ transition all 0.2s
88
+ &:hover
89
+ border-color #1890ff
90
+ background var(--main-dark)
91
+ .ai-chat-session-delete
92
+ opacity 1
93
+ &.active
94
+ border-color #1890ff
95
+ background rgba(24, 144, 255, 0.06)
96
+
97
+ .ai-chat-session-content
98
+ flex 1
99
+ min-width 0
100
+ overflow hidden
101
+
102
+ .ai-chat-session-header
103
+ margin-bottom 4px
104
+
105
+ .ai-chat-session-preview
106
+ font-size 13px
107
+ font-weight 500
108
+ white-space nowrap
109
+ overflow hidden
110
+ text-overflow ellipsis
111
+
112
+ .ai-chat-session-meta
113
+ font-size 12px
114
+ color var(--text-color-2)
115
+
116
+ .ai-chat-session-time
117
+ flex-shrink 0
118
+
119
+ .ai-chat-session-count
120
+ flex-shrink 0
121
+ margin-left 8px
122
+
123
+ .ai-chat-session-delete
124
+ opacity 0
125
+ flex-shrink 0
126
+ margin-left 8px
127
+ transition opacity 0.2s
128
+
129
+ .ai-chat-sessions-empty
130
+ height 100%
131
+ min-height 200px
79
132
 
80
133
  .ai-stop-icon-square
81
134
  width 20px
@@ -235,7 +235,9 @@ export default auto(function Index (props) {
235
235
  activeTabId: store.activeTabId,
236
236
  showAIConfig: store.showAIConfig,
237
237
  rightPanelTab,
238
- agentRunning: store.agentRunning
238
+ agentRunning: store.agentRunning,
239
+ currentChatSessionId: store.currentChatSessionId,
240
+ showChatSessions: store.showChatSessions
239
241
  }
240
242
  const cmdSuggestionsProps = {
241
243
  suggestions: store.terminalCommandSuggestions
@@ -33,10 +33,43 @@
33
33
  border-top 2px solid var(--primary)
34
34
  .qm-label-select
35
35
  min-width 120px
36
+ .qm-name-input
37
+ max-width 160px
38
+ min-width 80px
36
39
 
37
40
  .qm-flex
38
41
  gap 8px
39
42
 
43
+ .qm-tooltip-content
44
+ max-width 360px
45
+ .qm-tooltip-title
46
+ font-weight 600
47
+ margin-bottom 4px
48
+ word-break break-word
49
+ .qm-tooltip-cmd-list
50
+ margin 0
51
+ padding 0
52
+ list-style none
53
+ .qm-tooltip-cmd-item
54
+ padding 4px 0
55
+ border-top 1px solid rgba(255,255,255,0.15)
56
+ &:first-child
57
+ border-top none
58
+ .qm-tooltip-label
59
+ opacity 0.65
60
+ margin-right 4px
61
+ .qm-tooltip-value
62
+ word-break break-word
63
+ code
64
+ font-family monospace
65
+ .qm-tooltip-meta
66
+ margin-top 4px
67
+ padding-top 4px
68
+ border-top 1px solid rgba(255,255,255,0.15)
69
+ .qm-tooltip-label
70
+ opacity 0.65
71
+ margin-right 4px
72
+
40
73
  @media (max-width: 768px)
41
74
  .qm-flex
42
75
  flex-direction column
@@ -6,19 +6,86 @@ import {
6
6
  PureComponent
7
7
  } from 'react'
8
8
  import {
9
- Button
9
+ Button,
10
+ Tooltip
10
11
  } from 'antd'
11
12
  import classNames from 'classnames'
12
13
 
14
+ const e = window.translate
15
+
13
16
  export default class QuickCommandsItem extends PureComponent {
14
- handleSelect = (id) => {
17
+ handleSelect = () => {
15
18
  this.props.onSelect(
16
19
  this.props.item.id
17
20
  )
18
21
  }
19
22
 
23
+ renderTooltip () {
24
+ const { name, commands, shortcut, inputOnly } = this.props.item
25
+ return (
26
+ <div className='qm-tooltip-content'>
27
+ {
28
+ name && (
29
+ <div className='qm-tooltip-title'>{name}</div>
30
+ )
31
+ }
32
+ {
33
+ commands && commands.length > 0 && (
34
+ <ul className='qm-tooltip-cmd-list'>
35
+ {
36
+ commands.map((c, i) => (
37
+ <li key={i} className='qm-tooltip-cmd-item'>
38
+ {
39
+ c.name && (
40
+ <div className='qm-tooltip-cmd-name'>
41
+ <span className='qm-tooltip-label'>{e('name')}:</span>
42
+ <span className='qm-tooltip-value'>{c.name}</span>
43
+ </div>
44
+ )
45
+ }
46
+ <div className='qm-tooltip-cmd-text'>
47
+ <span className='qm-tooltip-label'>{e('quickCommand')}:</span>
48
+ <code className='qm-tooltip-value'>{c.command}</code>
49
+ </div>
50
+ {
51
+ c.delay && (
52
+ <div className='qm-tooltip-cmd-delay'>
53
+ <span className='qm-tooltip-label'>{e('delay')}:</span>
54
+ <span className='qm-tooltip-value'>{c.delay}ms</span>
55
+ </div>
56
+ )
57
+ }
58
+ </li>
59
+ ))
60
+ }
61
+ </ul>
62
+ )
63
+ }
64
+ {
65
+ (shortcut || inputOnly) && (
66
+ <div className='qm-tooltip-meta'>
67
+ {
68
+ shortcut && (
69
+ <div>
70
+ <span className='qm-tooltip-label'>{e('settingShortcuts')}:</span>
71
+ <span className='qm-tooltip-value'>{shortcut}</span>
72
+ </div>
73
+ )
74
+ }
75
+ {
76
+ inputOnly && (
77
+ <div>[{e('inputOnly')}]</div>
78
+ )
79
+ }
80
+ </div>
81
+ )
82
+ }
83
+ </div>
84
+ )
85
+ }
86
+
20
87
  render () {
21
- const { name, id, shortcut } = this.props.item
88
+ const { name, id } = this.props.item
22
89
  const {
23
90
  draggable,
24
91
  handleDragOver,
@@ -32,7 +99,6 @@ export default class QuickCommandsItem extends PureComponent {
32
99
  className: cls,
33
100
  onClick: this.handleSelect,
34
101
  'data-id': id,
35
- title: shortcut,
36
102
  draggable,
37
103
  onDragOver: handleDragOver,
38
104
  onDragStart: handleDragStart,
@@ -41,12 +107,19 @@ export default class QuickCommandsItem extends PureComponent {
41
107
  onDrop: handleDrop
42
108
  }
43
109
  return (
44
- <Button
45
- key={id}
46
- {...btnProps}
110
+ <Tooltip
111
+ title={this.renderTooltip()}
112
+ placement='top'
113
+ mouseEnterDelay={0.5}
114
+ overlayClassName='qm-tooltip-overlay'
47
115
  >
48
- {name}
49
- </Button>
116
+ <Button
117
+ key={id}
118
+ {...btnProps}
119
+ >
120
+ {name}
121
+ </Button>
122
+ </Tooltip>
50
123
  )
51
124
  }
52
125
  }
@@ -144,7 +144,12 @@ export default function QuickCommandsFooterBox (props) {
144
144
 
145
145
  function filterArray (array, keyword, label) {
146
146
  return array.filter(obj => {
147
- const nameMatches = !keyword || obj.name.toLowerCase().includes(keyword)
147
+ const nameMatches = !keyword ||
148
+ obj.name.toLowerCase().includes(keyword) ||
149
+ (obj.commands || []).some(cmd =>
150
+ (cmd.command || '').toLowerCase().includes(keyword) ||
151
+ (cmd.name || '').toLowerCase().includes(keyword)
152
+ )
148
153
  const labelMatches = !label || (obj.labels || []).includes(label)
149
154
  return nameMatches && labelMatches
150
155
  })
@@ -78,6 +78,18 @@ export default function renderQm (form) {
78
78
  >
79
79
  <HolderOutlined className='mg1r drag' />
80
80
 
81
+ <FormItem
82
+ label=''
83
+ name={[field.name, 'name']}
84
+ noStyle
85
+ rules={[{ max: 100, message: '60 chars max' }]}
86
+ >
87
+ <Input
88
+ placeholder={e('name')}
89
+ className='compact-input qm-name-input'
90
+ maxLength={100}
91
+ />
92
+ </FormItem>
81
93
  <Space.Addon>{e('delay')}</Space.Addon>
82
94
  <FormItem
83
95
  label=''
@@ -181,7 +181,8 @@ export default class QuickCommandsList extends List {
181
181
  if (item.commands && Array.isArray(item.commands)) {
182
182
  // Search in each command in the commands array
183
183
  return n.includes(k) || item.commands.some(cmd =>
184
- (cmd.command || '').toLowerCase().includes(k)
184
+ (cmd.command || '').toLowerCase().includes(k) ||
185
+ (cmd.name || '').toLowerCase().includes(k)
185
186
  )
186
187
  } else {
187
188
  // Fallback to the old behavior for backward compatibility
@@ -92,7 +92,7 @@ export default () => {
92
92
  },
93
93
  {
94
94
  name: 'terminal_paste',
95
- shortcut: 'ctrl+v,ctrl+shift+v',
95
+ shortcut: 'ctrl+shift+v',
96
96
  shortcutMac: 'meta+v',
97
97
  readonly: true
98
98
  },
@@ -224,7 +224,12 @@ export default class AttachAddonCustom {
224
224
 
225
225
  if (typeof data === 'string') {
226
226
  term?.parent?.notifyOnData()
227
- return term.write(data)
227
+ term.write(data)
228
+ // Notify parent that the terminal buffer has been updated (echo received).
229
+ // This allows the suggestion dropdown to refresh after the buffer reflects
230
+ // the latest server-side state (e.g., after backspace echo).
231
+ term?.parent?.onTerminalWrite?.()
232
+ return
228
233
  }
229
234
  data = new Uint8Array(data)
230
235
  const fileReader = new FileReader()
@@ -238,6 +243,7 @@ export default class AttachAddonCustom {
238
243
  term?.parent?.notifyOnData()
239
244
  const str = this.decoder.decode(data)
240
245
  term?.write(str)
246
+ term?.parent?.onTerminalWrite?.()
241
247
  }
242
248
 
243
249
  sendToServer = (data) => {
@@ -2,7 +2,6 @@ import { Component } from 'manate/react/class-components'
2
2
  import { refsStatic, refs } from '../common/ref'
3
3
  import SuggestionItem from './cmd-item'
4
4
  import { aiSuggestionsCache } from '../../common/cache'
5
- import uid from '../../common/uid'
6
5
  import classnames from 'classnames'
7
6
  import {
8
7
  LoadingOutlined
@@ -130,6 +129,7 @@ export default class TerminalCmdSuggestions extends Component {
130
129
  } else {
131
130
  position.top = top + cellHeight
132
131
  }
132
+ this._suggestionsCache = null
133
133
  this.setState({
134
134
  showSuggestions: true,
135
135
  cursorPosition: position,
@@ -188,6 +188,7 @@ export default class TerminalCmdSuggestions extends Component {
188
188
  window.store.addCmdHistory(item.command, 'aiCmdHistory')
189
189
  })
190
190
  }
191
+ this._suggestionsCache = null
191
192
  this.setState({
192
193
  showSuggestions: false,
193
194
  aiSuggestions: [],
@@ -261,7 +262,8 @@ export default class TerminalCmdSuggestions extends Component {
261
262
  if (!uniqueCommands.has(command)) {
262
263
  uniqueCommands.add(command)
263
264
  res.push({
264
- id: uid(),
265
+ // Use stable key to avoid React re-mounting items on every render
266
+ id: type + ':' + command,
265
267
  command,
266
268
  type
267
269
  })
@@ -277,7 +279,7 @@ export default class TerminalCmdSuggestions extends Component {
277
279
  if (b.password && !seen.has(b.password)) {
278
280
  seen.add(b.password)
279
281
  res.push({
280
- id: uid(),
282
+ id: 'PW:' + b.password,
281
283
  command: b.password,
282
284
  type: 'PW',
283
285
  hint: [b.username, [b.host, b.port].filter(Boolean).join(':')].filter(Boolean).join('@')
@@ -287,28 +289,41 @@ export default class TerminalCmdSuggestions extends Component {
287
289
  return this.state.reverse ? res.reverse() : res
288
290
  }
289
291
 
292
+ _suggestionsCache = null
293
+ _suggestionsCacheKey = ''
294
+
290
295
  getSuggestions = () => {
296
+ // Memoize: only recompute when cmd, reverse, aiSuggestions, or props change
297
+ const { cmd, reverse, aiSuggestions } = this.state
298
+ const { suggestions } = this.props
299
+ const cacheKey = cmd + '|' + reverse + '|' + (aiSuggestions?.length || 0) + '|' + (suggestions?.history?.length || 0) + '|' + (suggestions?.batch?.length || 0) + '|' + (suggestions?.quick?.length || 0)
300
+ if (this._suggestionsCache && this._suggestionsCacheKey === cacheKey) {
301
+ return this._suggestionsCache
302
+ }
291
303
  const uniqueCommands = new Set()
292
304
  const {
293
305
  history = [],
294
306
  batch = [],
295
307
  quick = []
296
- } = this.props.suggestions || {}
308
+ } = suggestions || {}
297
309
  const res = []
298
- this.state.aiSuggestions
310
+ aiSuggestions
299
311
  .forEach(item => {
300
312
  if (!uniqueCommands.has(item.command)) {
301
313
  uniqueCommands.add(item.command)
302
314
  }
303
315
  res.push({
304
- id: uid(),
316
+ id: 'AI:' + item.command,
305
317
  ...item
306
318
  })
307
319
  })
308
320
  this.processCommands(history, 'H', uniqueCommands, res)
309
321
  this.processCommands(batch, 'B', uniqueCommands, res)
310
322
  this.processCommands(quick, 'Q', uniqueCommands, res)
311
- return this.state.reverse ? res.reverse() : res
323
+ const finalRes = reverse ? res.reverse() : res
324
+ this._suggestionsCache = finalRes
325
+ this._suggestionsCacheKey = cacheKey
326
+ return finalRes
312
327
  }
313
328
 
314
329
  renderAIIcon () {
@@ -1081,18 +1081,45 @@ class Term extends Component {
1081
1081
  return
1082
1082
  }
1083
1083
  if (this.props.config.showCmdSuggestions) {
1084
- const data = this.getCurrentInput()
1085
- if (data && d !== '\r' && d !== '\n') {
1086
- const cursorPos = this.getCursorPosition()
1087
- this.openSuggestions(cursorPos, data)
1088
- } else {
1084
+ if (d === '\r' || d === '\n') {
1089
1085
  this.closeSuggestions()
1086
+ return
1090
1087
  }
1088
+ // Debounce the suggestion opening to avoid expensive work
1089
+ // (buffer read + getBoundingClientRect + React re-render) on every keystroke
1090
+ this._debouncedOpenSuggestions()
1091
1091
  } else {
1092
1092
  this.closeSuggestions()
1093
1093
  }
1094
1094
  }
1095
1095
 
1096
+ _debouncedOpenSuggestions = debounce(function () {
1097
+ const data = this.getCurrentInput()
1098
+ if (!data) {
1099
+ this.closeSuggestions()
1100
+ return
1101
+ }
1102
+ const cursorPos = this.getCursorPosition()
1103
+ this.openSuggestions(cursorPos, data)
1104
+ }, 80)
1105
+
1106
+ /**
1107
+ * Called by AttachAddonCustom after data is written to the terminal buffer.
1108
+ * This fires after server echo arrives, so getCurrentInput() reflects the
1109
+ * latest state. We trigger a debounced suggestion refresh so the dropdown
1110
+ * updates correctly after backspace, delete, and other edits that rely on
1111
+ * server-side echo to update the buffer.
1112
+ */
1113
+ onTerminalWrite = () => {
1114
+ if (!this.props.config.showCmdSuggestions) {
1115
+ return
1116
+ }
1117
+ const suggestions = refsStatic.get('terminal-suggestions')
1118
+ if (suggestions?.state?.showSuggestions && !suggestions?.state?.passwordMode) {
1119
+ this._debouncedOpenSuggestions()
1120
+ }
1121
+ }
1122
+
1096
1123
  loadRenderer = async (term, config) => {
1097
1124
  // xterm 6.x: only the built-in DOM renderer and the WebGL addon exist
1098
1125
  // (the canvas renderer addon was removed in 6.x). 'dom' = no addon loaded
@@ -10,7 +10,8 @@ import {
10
10
  rightSidebarWidthKey,
11
11
  addPanelWidthLsKey,
12
12
  dismissDelKeyTipLsKey,
13
- connectionMap
13
+ connectionMap,
14
+ lastAiChatSessionIdKey
14
15
  } from '../common/constants'
15
16
  import * as ls from '../common/safe-local-storage'
16
17
  import { refs, refsStatic } from '../components/common/ref'
@@ -256,6 +257,166 @@ export default Store => {
256
257
  window.store.aiChatHistory.splice(index, 1)
257
258
  }
258
259
 
260
+ Store.prototype.startNewChat = action(function () {
261
+ const { store } = window
262
+ store.currentChatSessionId = uid()
263
+ store.showChatSessions = false
264
+ window.localStorage.setItem(lastAiChatSessionIdKey, store.currentChatSessionId)
265
+ })
266
+
267
+ Store.prototype.loadChatSession = action(function (sessionId) {
268
+ const { store } = window
269
+ store.currentChatSessionId = sessionId
270
+ store.showChatSessions = false
271
+ window.localStorage.setItem(lastAiChatSessionIdKey, sessionId)
272
+ })
273
+
274
+ Store.prototype.deleteChatSession = action(function (sessionId) {
275
+ const { store } = window
276
+ const remaining = store.aiChatHistory.filter(d => d.chatSessionId !== sessionId)
277
+ store.aiChatHistory = remaining
278
+ if (store.currentChatSessionId === sessionId) {
279
+ store.startNewChat()
280
+ }
281
+ })
282
+
283
+ Store.prototype.clearAllChatSessions = action(function () {
284
+ const { store } = window
285
+ store.aiChatHistory = []
286
+ store.showChatSessions = false
287
+ store.startNewChat()
288
+ })
289
+
290
+ Store.prototype.compressChatSession = async function (sessionId) {
291
+ const { store } = window
292
+ const sessionEntries = store.aiChatHistory
293
+ .filter(h => h.chatSessionId === sessionId)
294
+ .sort((a, b) => a.timestamp - b.timestamp)
295
+
296
+ // Find the last compress entry
297
+ let lastCompressIndex = -1
298
+ for (let i = sessionEntries.length - 1; i >= 0; i--) {
299
+ if (sessionEntries[i].compressed) {
300
+ lastCompressIndex = i
301
+ break
302
+ }
303
+ }
304
+
305
+ // Need at least 2 non-compress entries since the last compress
306
+ const entriesAfterCompress = lastCompressIndex >= 0
307
+ ? sessionEntries.slice(lastCompressIndex + 1)
308
+ : sessionEntries
309
+ if (entriesAfterCompress.length < 2) {
310
+ return
311
+ }
312
+
313
+ const firstEntry = sessionEntries[0]
314
+ const lang = firstEntry.languageAI || store.getLangName()
315
+ const messages = [
316
+ { role: 'system', content: firstEntry.roleAI + `;用[${lang}]回复` }
317
+ ]
318
+
319
+ // Start from the last compress entry to include its summary as context
320
+ const startIndex = lastCompressIndex >= 0 ? lastCompressIndex : 0
321
+ for (let i = startIndex; i < sessionEntries.length; i++) {
322
+ const entry = sessionEntries[i]
323
+ if (entry.compressed) {
324
+ messages.push({
325
+ role: 'user',
326
+ content: `Here is a summary of our previous conversation for context:\n\n${entry.response}`
327
+ })
328
+ messages.push({
329
+ role: 'assistant',
330
+ content: 'Understood. I will use this context as we continue.'
331
+ })
332
+ } else {
333
+ messages.push({ role: 'user', content: entry.prompt })
334
+ if (entry.response) {
335
+ messages.push({ role: 'assistant', content: entry.response })
336
+ }
337
+ }
338
+ }
339
+
340
+ 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.'
341
+ messages.push({ role: 'user', content: summaryPrompt })
342
+
343
+ const aiResponse = await window.pre.runGlobalAsync(
344
+ 'AIchat',
345
+ summaryPrompt,
346
+ firstEntry.modelAI,
347
+ firstEntry.roleAI,
348
+ firstEntry.baseURLAI,
349
+ firstEntry.apiPathAI,
350
+ firstEntry.apiKeyAI,
351
+ firstEntry.proxyAI,
352
+ false,
353
+ firstEntry.authHeaderNameAI,
354
+ messages
355
+ )
356
+
357
+ if (aiResponse && aiResponse.error) {
358
+ return store.onError(new Error(aiResponse.error))
359
+ }
360
+
361
+ const summary = aiResponse.response || ''
362
+ const compressedEntry = {
363
+ id: uid(),
364
+ prompt: '*Compressed session summary*',
365
+ response: summary,
366
+ isStreaming: false,
367
+ pending: false,
368
+ sessionId: null,
369
+ chatSessionId: sessionId,
370
+ mode: firstEntry.mode,
371
+ toolCalls: [],
372
+ nameAI: firstEntry.nameAI,
373
+ modelAI: firstEntry.modelAI,
374
+ roleAI: firstEntry.roleAI,
375
+ baseURLAI: firstEntry.baseURLAI,
376
+ apiPathAI: firstEntry.apiPathAI,
377
+ apiKeyAI: firstEntry.apiKeyAI,
378
+ proxyAI: firstEntry.proxyAI,
379
+ languageAI: firstEntry.languageAI,
380
+ authHeaderNameAI: firstEntry.authHeaderNameAI,
381
+ timestamp: Date.now(),
382
+ compressed: true
383
+ }
384
+
385
+ // Append compress entry, preserve existing history
386
+ store.aiChatHistory.push(compressedEntry)
387
+ }
388
+
389
+ Store.prototype.toggleChatSessions = action(function () {
390
+ const { store } = window
391
+ store.showChatSessions = !store.showChatSessions
392
+ })
393
+
394
+ Store.prototype.getChatSessions = function () {
395
+ const { aiChatHistory } = window.store
396
+ const sessionMap = new Map()
397
+ for (const entry of aiChatHistory) {
398
+ const sid = entry.chatSessionId
399
+ if (!sid) continue
400
+ if (!sessionMap.has(sid)) {
401
+ sessionMap.set(sid, {
402
+ sessionId: sid,
403
+ firstPrompt: entry.prompt || '',
404
+ timestamp: entry.timestamp,
405
+ messageCount: 1,
406
+ entries: [entry]
407
+ })
408
+ } else {
409
+ const session = sessionMap.get(sid)
410
+ session.messageCount++
411
+ session.entries.push(entry)
412
+ if (entry.timestamp > session.timestamp) {
413
+ session.timestamp = entry.timestamp
414
+ }
415
+ }
416
+ }
417
+ return Array.from(sessionMap.values()).sort((a, b) => b.timestamp - a.timestamp)
418
+ }
419
+
259
420
  Store.prototype.getLangName = function (
260
421
  lang = window.store?.config.language || 'en_us'
261
422
  ) {
@@ -22,7 +22,8 @@ import {
22
22
  qmSortByFrequencyKey,
23
23
  resolutionsLsKey,
24
24
  syncServerDataKey,
25
- splitMap
25
+ splitMap,
26
+ lastAiChatSessionIdKey
26
27
  } from '../common/constants'
27
28
  import * as ls from '../common/safe-local-storage'
28
29
  import { exclude } from 'manate'
@@ -89,6 +90,8 @@ export default () => {
89
90
  _batchInputSelectedTabIds: new Set(),
90
91
  aiChatHistory: [],
91
92
  agentRunning: false,
93
+ currentChatSessionId: window.localStorage.getItem(lastAiChatSessionIdKey) || '',
94
+ showChatSessions: false,
92
95
 
93
96
  // sftp
94
97
  fileOperation: fileOperationsMap.cp, // cp or mv
@@ -114,12 +114,52 @@ export async function addTabFromCommandLine (store, opts) {
114
114
  conf.fromCmdLine
115
115
  ) {
116
116
  store.ipcOpenTab(conf)
117
- } else if (
118
- options.initFolder &&
119
- !(store.config.onStartSessions || []).length &&
120
- store.config.initDefaultTabOnStart
121
- ) {
122
- window.initFolder = options.initFolder
117
+ } else {
118
+ // getHost didn't find a match, try parseQuickConnect for shortcut formats
119
+ // that getHost doesn't support (e.g., user:password@host)
120
+ let parsedFallback = null
121
+ if (argv && argv.length) {
122
+ for (const arg of argv) {
123
+ if (/^-/.test(arg)) continue
124
+ const result = parseQuickConnect(arg)
125
+ if (result && result.host) {
126
+ parsedFallback = result
127
+ break
128
+ }
129
+ }
130
+ }
131
+ if (parsedFallback) {
132
+ // Apply command-line options on top of parsed result
133
+ if (options.password) parsedFallback.password = options.password
134
+ if (options.passphrase) parsedFallback.passphrase = options.passphrase
135
+ if (options.user) parsedFallback.username = options.user
136
+ if (options.port && parseInt10(options.port)) parsedFallback.port = parseInt10(options.port)
137
+ if (options.title) parsedFallback.title = options.title
138
+ if (options.setEnv) parsedFallback.setEnv = options.setEnv
139
+ if (options.sftpOnly) parsedFallback.enableSsh = false
140
+ if (options.initFolder) parsedFallback.startDirectoryLocal = options.initFolder
141
+ if (options.opts) {
142
+ const optsParsed = safeParse(options.opts)
143
+ if (optsParsed !== options.opts) {
144
+ Object.assign(parsedFallback, optsParsed)
145
+ parsedFallback.fromCmdLine = true
146
+ }
147
+ }
148
+ if (options.tp) {
149
+ parsedFallback.type = options.tp
150
+ parsedFallback.fromCmdLine = true
151
+ }
152
+ if (options.privateKeyPath) {
153
+ parsedFallback.privateKey = await window.fs.readFile(options.privateKeyPath)
154
+ }
155
+ store.ipcOpenTab(parsedFallback)
156
+ } else if (
157
+ options.initFolder &&
158
+ !(store.config.onStartSessions || []).length &&
159
+ store.config.initDefaultTabOnStart
160
+ ) {
161
+ window.initFolder = options.initFolder
162
+ }
123
163
  }
124
164
  if (options.batchOp) {
125
165
  refsStatic.get('batch-op-runner')?.runBatchOpFromFile(options.batchOp)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@electerm/electerm-react",
3
- "version": "3.15.126",
3
+ "version": "3.15.138",
4
4
  "description": "react components src for electerm",
5
5
  "main": "./client/components/main/main.jsx",
6
6
  "license": "MIT",