@electerm/electerm-react 5.5.35 → 5.5.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/client/common/bookmark-schemas.js +1 -0
  2. package/client/common/constants.js +1 -0
  3. package/client/common/parse-quick-connect.js +1 -0
  4. package/client/common/parse-widget-log.js +57 -0
  5. package/client/components/ai/agent-tool-call-card.jsx +7 -3
  6. package/client/components/ai/agent.js +176 -33
  7. package/client/components/ai/ai-auto-compress.js +119 -0
  8. package/client/components/ai/ai-chat-history-item.jsx +125 -40
  9. package/client/components/ai/ai-chat.jsx +67 -19
  10. package/client/components/ai/ai-compress.js +95 -0
  11. package/client/components/ai/ai-config.jsx +29 -16
  12. package/client/components/ai/ai-config.styl +20 -0
  13. package/client/components/ai/ai-context.js +1 -1
  14. package/client/components/ai/ai-output.jsx +1 -6
  15. package/client/components/ai/ai-presets.js +0 -15
  16. package/client/components/batch-op/batch-op-runner.jsx +1 -0
  17. package/client/components/bookmark-form/bookmark-schema.js +1 -0
  18. package/client/components/bookmark-form/common/ssh-agent.jsx +31 -21
  19. package/client/components/bookmark-form/config/ssh.js +1 -0
  20. package/client/components/bookmark-form/fix-bookmark-default.js +1 -0
  21. package/client/components/file-transfer/transfer.jsx +3 -119
  22. package/client/components/main/main.jsx +1 -0
  23. package/client/components/session/session-control.jsx +35 -2
  24. package/client/components/session/session-control.styl +5 -0
  25. package/client/components/session/session.jsx +13 -0
  26. package/client/components/setting-panel/tab-widgets.jsx +27 -7
  27. package/client/components/sftp/file-item.jsx +0 -13
  28. package/client/components/terminal/mixins/term-touch.js +18 -0
  29. package/client/components/widgets/widget-control.jsx +4 -2
  30. package/client/components/widgets/widget-instance-detail.jsx +370 -0
  31. package/client/components/widgets/widget-instance.jsx +30 -13
  32. package/client/components/widgets/widget-instances.jsx +3 -1
  33. package/client/components/widgets/widgets-list.jsx +8 -0
  34. package/client/components/widgets/widgets.styl +100 -0
  35. package/client/store/common.js +81 -23
  36. package/client/store/init-state.js +7 -0
  37. package/client/store/load-data.js +1 -0
  38. package/client/store/widgets.js +23 -5
  39. package/package.json +1 -1
  40. package/client/components/file-transfer/zip.js +0 -42
@@ -1,10 +1,17 @@
1
- import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
1
+ import { useState, useEffect, useRef, useCallback, useMemo, memo } 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'
5
- import { runAgentLoop } from './agent'
5
+ import { runAgentLoop, stopAgentRun } from './agent'
6
6
  import { appendMandatoryGuardrails } from './ai-guardrails'
7
- import { buildSessionMessages } from './ai-context'
7
+ import { buildSessionMessages, summarizeContext } from './ai-context'
8
+ import {
9
+ shouldAutoCompress,
10
+ canCompact,
11
+ splitPendingTurn,
12
+ compactAskMessages
13
+ } from './ai-auto-compress'
14
+ import { summarizeMessages, autoCompressSession, autoCompressEnabled } from './ai-compress'
8
15
  import {
9
16
  Alert,
10
17
  Tooltip,
@@ -15,14 +22,23 @@ import {
15
22
  CloseOutlined,
16
23
  CaretDownOutlined,
17
24
  CaretRightOutlined,
25
+ CompressOutlined,
18
26
  PaperClipOutlined
19
27
  } from '@ant-design/icons'
20
28
  import { copy } from '../../common/clipboard'
21
29
  import { formatSize } from './ai-attachments'
22
30
 
23
- export default function AIChatHistoryItem ({ item }) {
31
+ // memo()'d: the panel holds the draft prompt in its own state, so every
32
+ // keystroke re-renders the whole transcript. Without this the keystroke also
33
+ // re-rendered (and re-parsed the markdown of) every message in it -- 50ms+ per
34
+ // character in a long agent session. Entries are updated immutably
35
+ // (store.updateAiHistoryEntry), so the reference comparison is meaningful.
36
+ export default memo(function AIChatHistoryItem ({ item }) {
24
37
  const [showOutput, setShowOutput] = useState(true)
25
38
  const [isStreaming, setIsStreaming] = useState(false)
39
+ // `current` is the stop flag; `requestId` is the HTTP request currently in
40
+ // flight (agent mode), so that stopping can cancel it rather than wait for it
41
+ // to come back.
26
42
  const abortRef = useRef(false)
27
43
  const {
28
44
  prompt,
@@ -38,7 +54,8 @@ export default function AIChatHistoryItem ({ item }) {
38
54
  authHeaderNameAI,
39
55
  languageAI,
40
56
  mode,
41
- toolCalls
57
+ toolCalls,
58
+ autoCompressCount
42
59
  } = item
43
60
 
44
61
  function toggleOutput () {
@@ -62,6 +79,61 @@ export default function AIChatHistoryItem ({ item }) {
62
79
  excludeId: item.id
63
80
  }), [chatSessionId, item.id, item.timestamp])
64
81
 
82
+ // Everything a request needs beyond the message list; also what the
83
+ // compressor uses to reach the same provider (see ai-compress.js).
84
+ const requestConfig = useMemo(() => ({
85
+ modelAI,
86
+ roleAI,
87
+ baseURLAI,
88
+ apiPathAI,
89
+ apiKeyAI,
90
+ proxyAI,
91
+ languageAI,
92
+ authHeaderNameAI
93
+ }), [
94
+ modelAI,
95
+ roleAI,
96
+ baseURLAI,
97
+ apiPathAI,
98
+ apiKeyAI,
99
+ proxyAI,
100
+ languageAI,
101
+ authHeaderNameAI
102
+ ])
103
+
104
+ // The message list this turn actually sends. Normally the memoized session
105
+ // messages above, but when auto compress is on and the request would not fit
106
+ // the window, the earlier turns are replaced by a summary first. The live
107
+ // prompt survives untouched -- it is the question being answered, not
108
+ // history. The stored session is compacted separately once the answer is in
109
+ // (autoCompressSession below), so this is only about the request leaving now.
110
+ const buildOutgoingMessages = useCallback(async () => {
111
+ if (!autoCompressEnabled() || !conversationMessages) {
112
+ return conversationMessages
113
+ }
114
+ const info = summarizeContext(conversationMessages, {
115
+ model: modelAI,
116
+ contextLength: window.store.config.contextLengthAI
117
+ })
118
+ if (!shouldAutoCompress(info)) {
119
+ return conversationMessages
120
+ }
121
+ const { context } = splitPendingTurn(conversationMessages)
122
+ if (!canCompact(context)) {
123
+ return conversationMessages
124
+ }
125
+ // Marked before the request: summarizing a nearly full window takes a
126
+ // while, and the badge is the only sign that this turn is waiting on it.
127
+ // Cleared again if the summary comes back unusable.
128
+ window.store.updateAiHistoryEntry(item.id, { autoCompressCount: 1 })
129
+ const summary = await summarizeMessages(context, requestConfig)
130
+ if (!summary) {
131
+ window.store.updateAiHistoryEntry(item.id, { autoCompressCount: 0 })
132
+ return conversationMessages
133
+ }
134
+ return compactAskMessages(conversationMessages, summary)
135
+ }, [conversationMessages, modelAI, requestConfig, item.id])
136
+
65
137
  const pollStreamContent = useCallback(async (sid) => {
66
138
  try {
67
139
  const streamResponse = await window.pre.runGlobalAsync('getStreamContent', sid)
@@ -74,20 +146,22 @@ export default function AIChatHistoryItem ({ item }) {
74
146
  return window.store.onError(new Error(streamResponse.error))
75
147
  }
76
148
 
77
- const index = window.store.aiChatHistory.findIndex(i => i.id === item.id)
78
- if (index !== -1) {
79
- window.store.aiChatHistory[index].response = streamResponse.content || ''
80
- window.store.aiChatHistory = [...window.store.aiChatHistory]
81
- }
149
+ window.store.updateAiHistoryEntry(item.id, {
150
+ response: streamResponse.content || ''
151
+ })
82
152
  setIsStreaming(streamResponse.hasMore)
83
153
  if (streamResponse.hasMore) {
84
154
  setTimeout(() => pollStreamContent(sid), 200)
155
+ } else {
156
+ // The answer is in the store now, so the session can be summarized for
157
+ // the turns that follow this one.
158
+ autoCompressSession(chatSessionId, requestConfig)
85
159
  }
86
160
  } catch (error) {
87
161
  window.store.removeAiHistory(item.id)
88
162
  window.store.onError(error)
89
163
  }
90
- }, [item.id])
164
+ }, [item.id, chatSessionId, requestConfig])
91
165
 
92
166
  const startRequest = useCallback(async () => {
93
167
  try {
@@ -102,7 +176,7 @@ export default function AIChatHistoryItem ({ item }) {
102
176
  proxyAI,
103
177
  true,
104
178
  authHeaderNameAI,
105
- conversationMessages
179
+ await buildOutgoingMessages()
106
180
  )
107
181
 
108
182
  if (aiResponse && aiResponse.error) {
@@ -112,45 +186,34 @@ export default function AIChatHistoryItem ({ item }) {
112
186
 
113
187
  if (aiResponse && aiResponse.isStream && aiResponse.sessionId) {
114
188
  setIsStreaming(true)
115
- const index = window.store.aiChatHistory.findIndex(i => i.id === item.id)
116
- if (index !== -1) {
117
- window.store.aiChatHistory[index].sessionId = aiResponse.sessionId
118
- window.store.aiChatHistory[index].response = aiResponse.content || ''
119
- }
189
+ window.store.updateAiHistoryEntry(item.id, {
190
+ sessionId: aiResponse.sessionId,
191
+ response: aiResponse.content || ''
192
+ })
120
193
  pollStreamContent(aiResponse.sessionId)
121
194
  } else if (aiResponse && aiResponse.response) {
122
- const index = window.store.aiChatHistory.findIndex(i => i.id === item.id)
123
- if (index !== -1) {
124
- window.store.aiChatHistory[index].response = aiResponse.response
125
- }
195
+ window.store.updateAiHistoryEntry(item.id, {
196
+ response: aiResponse.response
197
+ })
198
+ // No stream to poll: the answer is in the store, so the session can be
199
+ // summarized for the turns that follow this one.
200
+ autoCompressSession(chatSessionId, requestConfig)
126
201
  }
127
202
  } catch (error) {
128
203
  window.store.removeAiHistory(item.id)
129
204
  window.store.onError(error)
130
205
  }
131
- }, [prompt, modelAI, baseURLAI, apiPathAI, apiKeyAI, proxyAI, authHeaderNameAI, item.id, pollStreamContent, conversationMessages])
206
+ }, [prompt, modelAI, baseURLAI, apiPathAI, apiKeyAI, proxyAI, authHeaderNameAI, item.id, chatSessionId, requestConfig, pollStreamContent, buildOutgoingMessages])
132
207
 
133
208
  const startAgentRequest = useCallback(async () => {
134
209
  abortRef.current = false
135
- const config = {
136
- modelAI,
137
- roleAI,
138
- baseURLAI,
139
- apiPathAI,
140
- apiKeyAI,
141
- proxyAI,
142
- languageAI,
143
- authHeaderNameAI
144
- }
145
- await runAgentLoop(item, config, abortRef, setIsStreaming, conversationMessages)
146
- }, [modelAI, roleAI, baseURLAI, apiPathAI, apiKeyAI, proxyAI, languageAI, authHeaderNameAI, item.id, conversationMessages])
210
+ abortRef.requestId = null
211
+ await runAgentLoop(item, requestConfig, abortRef, setIsStreaming, conversationMessages)
212
+ }, [item, requestConfig, conversationMessages])
147
213
 
148
214
  useEffect(() => {
149
215
  if (item.pending) {
150
- const index = window.store.aiChatHistory.findIndex(i => i.id === item.id)
151
- if (index !== -1) {
152
- window.store.aiChatHistory[index].pending = false
153
- }
216
+ window.store.updateAiHistoryEntry(item.id, { pending: false })
154
217
  if (mode === 'agent') {
155
218
  startAgentRequest()
156
219
  } else {
@@ -162,8 +225,8 @@ export default function AIChatHistoryItem ({ item }) {
162
225
  async function handleStop (e) {
163
226
  e.stopPropagation()
164
227
  if (mode === 'agent') {
165
- abortRef.current = true
166
228
  setIsStreaming(false)
229
+ await stopAgentRun(abortRef)
167
230
  return
168
231
  }
169
232
  if (!sessionId) return
@@ -258,6 +321,27 @@ export default function AIChatHistoryItem ({ item }) {
258
321
  )
259
322
  }
260
323
 
324
+ // Auto compress happens on its own, with no visible step of its own, so the
325
+ // turn says so: the conversation was summarized and the request continued
326
+ // from the summary instead of the full history. Ask mode summarizes before
327
+ // sending, agent mode in the middle of the run (possibly more than once).
328
+ function renderAutoCompressBadge () {
329
+ if (!autoCompressCount) {
330
+ return null
331
+ }
332
+ return (
333
+ <div className='ai-auto-compress-badge mg1b'>
334
+ <Tag
335
+ color='blue'
336
+ title='The context filled up and was summarized automatically, this turn continued from the summary'
337
+ >
338
+ <CompressOutlined /> {window.translate('autoCompress')}
339
+ {autoCompressCount > 1 ? ` x${autoCompressCount}` : ''}
340
+ </Tag>
341
+ </div>
342
+ )
343
+ }
344
+
261
345
  function renderToolCalls () {
262
346
  if (mode !== 'agent' || !toolCalls || !toolCalls.length) {
263
347
  return null
@@ -279,9 +363,10 @@ export default function AIChatHistoryItem ({ item }) {
279
363
  </Tooltip>
280
364
  </div>
281
365
  {renderAttachments()}
366
+ {renderAutoCompressBadge()}
282
367
  {renderToolCalls()}
283
368
  {showOutput && <AIOutput item={item} />}
284
369
  {renderStopButton()}
285
370
  </div>
286
371
  )
287
- }
372
+ })
@@ -1,10 +1,11 @@
1
1
  import { useState, useCallback, useEffect, useMemo, useRef } from 'react'
2
- import { Flex, Input, Segmented, Button, Tag, message } from 'antd'
2
+ import { Flex, Input, Segmented, Button, Tag, message, Popover } from 'antd'
3
3
  import TabSelect from '../footer/tab-select'
4
4
  import AiChatHistory from './ai-chat-history'
5
5
  import AiChatSessions from './ai-chat-sessions'
6
6
  import AiContextIndicator from './ai-context-indicator'
7
- import { buildSessionMessages, summarizeContext, getUsageLevel, formatPercent } from './ai-context'
7
+ import SwitchLabel from '../common/switch'
8
+ import { buildSessionMessages, summarizeContext, getUsageLevel } from './ai-context'
8
9
  import { appendMandatoryGuardrails } from './ai-guardrails'
9
10
  import uid from '../../common/uid'
10
11
  import { pick } from 'lodash-es'
@@ -42,6 +43,7 @@ export default function AIChat (props) {
42
43
  const fileInputRef = useRef(null)
43
44
  const [mode, setMode] = useState(() => getItem(aiChatModeLsKey) || 'ask')
44
45
  const isAgent = mode === 'agent'
46
+ const autoCompress = !!props.aiAutoCompress
45
47
  const submitDisabled = isAgent && props.agentRunning
46
48
 
47
49
  const currentChatSessionId = props.currentChatSessionId || ''
@@ -52,8 +54,17 @@ export default function AIChat (props) {
52
54
  }
53
55
  }, [currentChatSessionId, props.rightPanelTab])
54
56
 
55
- const sessionHistory = (props.aiChatHistory || []).filter(
56
- h => h.chatSessionId === currentChatSessionId
57
+ // Memoized on purpose: `prompt` lives in this component's state, so every
58
+ // keystroke re-renders it. `AIChatHistory` is memo()'d, and a fresh array on
59
+ // each keystroke defeated that -- which also meant its scroll-to-bottom
60
+ // layout effect re-ran while typing, yanking the view down mid-scroll.
61
+ // Entries are updated immutably, so a new array identity really does mean
62
+ // the transcript changed.
63
+ const sessionHistory = useMemo(
64
+ () => (props.aiChatHistory || []).filter(
65
+ h => h.chatSessionId === currentChatSessionId
66
+ ),
67
+ [props.aiChatHistory, currentChatSessionId]
57
68
  )
58
69
 
59
70
  const config = props.config || {}
@@ -144,6 +155,12 @@ export default function AIChat (props) {
144
155
  if (window.store.aiConfigMissing()) {
145
156
  window.store.toggleAIConfig()
146
157
  }
158
+ // Read the live flag rather than the rendered one. `submitDisabled` can be
159
+ // stale: the loop sets `agentRunning` from the new turn's mount effect, and
160
+ // manate's `auto` re-subscribes its listener on every render, so a write
161
+ // landing in that window never reaches the panel -- leaving the icon looking
162
+ // enabled while a run is in flight, which let a second agent turn through.
163
+ if (isAgent && window.store.agentRunning) return
147
164
  if (!prompt.trim() && !attachments.length) return
148
165
 
149
166
  const chatId = uid()
@@ -179,12 +196,14 @@ export default function AIChat (props) {
179
196
  id: chatId
180
197
  }
181
198
 
182
- window.store.aiChatHistory.push(chatEntry)
199
+ // Reassign rather than push: the store only notifies on a property write,
200
+ // and the transcript is keyed off the array identity.
201
+ window.store.aiChatHistory = [...window.store.aiChatHistory, chatEntry]
183
202
  setPrompt('')
184
203
  setAttachments([])
185
204
 
186
205
  if (window.store.aiChatHistory.length > MAX_HISTORY) {
187
- window.store.aiChatHistory.splice(MAX_HISTORY)
206
+ window.store.aiChatHistory = window.store.aiChatHistory.slice(0, MAX_HISTORY)
188
207
  }
189
208
  }, [prompt, attachments, mode, currentChatSessionId])
190
209
 
@@ -224,11 +243,39 @@ export default function AIChat (props) {
224
243
  }
225
244
  }
226
245
 
227
- function renderCompressTitle () {
228
- if ((contextLevel === 'warn' || contextLevel === 'danger') && contextInfo) {
229
- return `Context is ${formatPercent(contextInfo.percent)} full — compress to summarize the session`
230
- }
231
- return 'Summarize this session into a single message, dropping the older history'
246
+ function handleAutoCompressChange (val) {
247
+ window.store.setAiAutoCompress(val)
248
+ }
249
+
250
+ // The toolbar button is the auto compress button: the popover behind it holds
251
+ // the persistent toggle plus the manual compress action, so one button covers
252
+ // both "keep this session small for me" and "compact it right now".
253
+ function renderAutoCompressPopover () {
254
+ return (
255
+ <div className='ai-auto-compress-popover'>
256
+ <Flex align='center' justify='space-between' gap={12}>
257
+ <span className='ai-auto-compress-label'>{e('autoCompress')}</span>
258
+ <SwitchLabel
259
+ checked={autoCompress}
260
+ onChange={handleAutoCompressChange}
261
+ size='small'
262
+ />
263
+ </Flex>
264
+ <hr />
265
+ <Flex vertical gap={4} align='flex-start'>
266
+ <Button
267
+ size='small'
268
+ icon={<CompressOutlined />}
269
+ onClick={handleCompressSession}
270
+ loading={compressing}
271
+ disabled={sessionHistory.length < 2}
272
+ type={contextLevel === 'warn' || contextLevel === 'danger' ? 'primary' : 'default'}
273
+ >
274
+ {e('compress')}
275
+ </Button>
276
+ </Flex>
277
+ </div>
278
+ )
232
279
  }
233
280
 
234
281
  function handleShowHistory () {
@@ -321,19 +368,20 @@ export default function AIChat (props) {
321
368
  >
322
369
  {e('new')}
323
370
  </Button>
324
- {sessionHistory.length >= 2 && (
371
+ <Popover
372
+ content={renderAutoCompressPopover()}
373
+ trigger='click'
374
+ placement='topLeft'
375
+ >
325
376
  <Button
326
377
  size='small'
327
378
  icon={<CompressOutlined />}
328
- onClick={handleCompressSession}
329
- loading={compressing}
330
- type={contextLevel === 'warn' ? 'primary' : 'default'}
331
- danger={contextLevel === 'danger'}
332
- title={renderCompressTitle()}
379
+ type={autoCompress ? 'primary' : 'default'}
380
+ danger={!autoCompress && contextLevel === 'danger'}
333
381
  >
334
- {e('compress')}
382
+ {e('autoCompress')}
335
383
  </Button>
336
- )}
384
+ </Popover>
337
385
  <Button
338
386
  size='small'
339
387
  icon={<HistoryOutlined />}
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Runtime side of AI context compression.
3
+ *
4
+ * The pure decisions and message rewrites live in ai-auto-compress.js; this
5
+ * module owns the parts that need the provider (the summary request) and the
6
+ * store (whether the toggle is on, how big a stored session is, compressing it).
7
+ * Both the agent loop and ask mode go through here so "too full" means the same
8
+ * number in both.
9
+ */
10
+
11
+ import { buildSessionMessages, summarizeContext } from './ai-context'
12
+ import { appendMandatoryGuardrails } from './ai-guardrails'
13
+ import {
14
+ COMPRESS_SUMMARY_PROMPT,
15
+ shouldAutoCompress
16
+ } from './ai-auto-compress'
17
+
18
+ // Read at the moment of the decision, not captured: the toggle can be flipped
19
+ // from the popover while a run is in flight.
20
+ export function autoCompressEnabled () {
21
+ return !!(window.store && window.store.aiAutoCompress)
22
+ }
23
+
24
+ // Ask the model for the summary that replaces a conversation. Goes through the
25
+ // plain chat path rather than the tool loop: this request only has to produce
26
+ // text, and giving it tools would let the summarizer act on the terminal while
27
+ // it is supposed to be reading. Returns '' when there is nothing usable, which
28
+ // callers read as "leave the conversation alone".
29
+ export async function summarizeMessages (messages, config = {}) {
30
+ try {
31
+ const result = await window.pre.runGlobalAsync(
32
+ 'AIchat',
33
+ COMPRESS_SUMMARY_PROMPT,
34
+ config.modelAI,
35
+ config.roleAI,
36
+ config.baseURLAI,
37
+ config.apiPathAI,
38
+ config.apiKeyAI,
39
+ config.proxyAI,
40
+ false,
41
+ config.authHeaderNameAI,
42
+ [...messages, { role: 'user', content: COMPRESS_SUMMARY_PROMPT }]
43
+ )
44
+ if (!result || result.error || !result.response) {
45
+ return ''
46
+ }
47
+ return result.response
48
+ } catch (error) {
49
+ console.error('Error summarizing conversation:', error)
50
+ return ''
51
+ }
52
+ }
53
+
54
+ // How big the stored session is, i.e. what the next ask-mode turn would carry.
55
+ // Same figure the panel's own indicator shows (components/ai/ai-chat.jsx), minus
56
+ // agent tools, which only agent mode sends.
57
+ export function sessionContextInfo (sessionId, config = {}) {
58
+ const store = window.store
59
+ if (!sessionId || !store) {
60
+ return null
61
+ }
62
+ const lang = config.languageAI || store.getLangName()
63
+ const role = appendMandatoryGuardrails((config.roleAI || '') + `;用[${lang}]回复`)
64
+ const messages = buildSessionMessages({
65
+ history: store.aiChatHistory,
66
+ chatSessionId: sessionId,
67
+ role
68
+ })
69
+ if (!messages || messages.length < 2) {
70
+ return null
71
+ }
72
+ return summarizeContext(messages, {
73
+ model: config.modelAI,
74
+ contextLength: store.config.contextLengthAI
75
+ })
76
+ }
77
+
78
+ // Compact a stored session when the toggle is on and it is over the line.
79
+ // Called once a turn or a run is finished, so the *next* request starts from the
80
+ // summary instead of the full history. Returns true when it compressed.
81
+ export async function autoCompressSession (sessionId, config = {}) {
82
+ if (!autoCompressEnabled() || !sessionId) {
83
+ return false
84
+ }
85
+ if (!shouldAutoCompress(sessionContextInfo(sessionId, config))) {
86
+ return false
87
+ }
88
+ try {
89
+ await window.store.compressChatSession(sessionId)
90
+ return true
91
+ } catch (error) {
92
+ console.error('Error auto compressing session:', error)
93
+ return false
94
+ }
95
+ }
@@ -5,11 +5,11 @@ import {
5
5
  Button,
6
6
  AutoComplete,
7
7
  Alert,
8
- Space,
9
- Dropdown
8
+ Select,
9
+ Space
10
10
  } from 'antd'
11
11
  import { useEffect, useMemo, useState } from 'react'
12
- import { DownOutlined, ReloadOutlined } from '@ant-design/icons'
12
+ import { ReloadOutlined } from '@ant-design/icons'
13
13
  import Link from '../common/external-link'
14
14
  import AiCache from './ai-cache'
15
15
  import {
@@ -20,6 +20,7 @@ import AiHistory, { addHistoryItem } from './ai-history'
20
20
  import message from '../common/message'
21
21
  import { getAIPresets } from './ai-presets'
22
22
  import { appendMandatoryGuardrails } from './ai-guardrails'
23
+ import './ai-config.styl'
23
24
 
24
25
  const STORAGE_KEY_CONFIG = 'ai_config_history'
25
26
  const EVENT_NAME_CONFIG = 'ai-config-history-update'
@@ -211,18 +212,32 @@ export default function AIConfigForm ({ initialValues, onSubmit, showAIConfig })
211
212
  form.setFieldsValue(values)
212
213
  }
213
214
 
214
- function renderPresetMenu () {
215
- const items = presets.map(p => ({
216
- key: p.id,
217
- label: p.nameAI,
218
- onClick: () => handleSelectPreset(p)
215
+ // A plain antd Select, on purpose: it already scrolls its list inside the
216
+ // viewport (a Dropdown menu grows past the bottom of a phone screen, so the
217
+ // tail of the 18 presets is unreachable there) and `showSearch` filters it.
218
+ // `optionFilterProp` is load-bearing: without it rc-select filters on the
219
+ // option *value* (our id), so "gemini" or "llama" match nothing. The label
220
+ // carries name and model, so filtering on it finds either.
221
+ // The label/select sizing lives in `.ai-preset-row` (ai-config.styl), because
222
+ // Space's `.ant-space-item` wrapper is the flex item, not the child.
223
+ function renderPresetSelect () {
224
+ const options = presets.map(p => ({
225
+ value: p.id,
226
+ label: p.modelAI ? `${p.nameAI} · ${p.modelAI}` : p.nameAI
219
227
  }))
220
228
  return (
221
- <Dropdown menu={{ items }} trigger={['click']}>
222
- <Button>
223
- {e('presets')} <DownOutlined />
224
- </Button>
225
- </Dropdown>
229
+ <div className='alignright'>
230
+ <Space className='mg1b width-100 ai-preset-row'>
231
+ <span>{e('presets')}</span>
232
+ <Select
233
+ showSearch
234
+ optionFilterProp='label'
235
+ options={options}
236
+ value={currentPreset?.id}
237
+ onSelect={id => handleSelectPreset(presets.find(p => p.id === id))}
238
+ />
239
+ </Space>
240
+ </div>
226
241
  )
227
242
  }
228
243
 
@@ -294,9 +309,7 @@ export default function AIConfigForm ({ initialValues, onSubmit, showAIConfig })
294
309
  type='warning'
295
310
  className='mg2b'
296
311
  />
297
- <div className='mg1b alignright'>
298
- {renderPresetMenu()}
299
- </div>
312
+ {renderPresetSelect()}
300
313
  <p>
301
314
  Full Url: {initialValues?.baseURLAI}{initialValues?.apiPathAI}
302
315
  </p>
@@ -0,0 +1,20 @@
1
+ // Preset row: the "Presets" label and the provider select share one line.
2
+ // antd's Space wraps every child in a `.ant-space-item`, and that wrapper — not
3
+ // the child — is the flex item, so the flex behaviour has to be set on the
4
+ // wrappers. Put `flex` on the Select itself and it is silently ignored, which
5
+ // makes the row's overflow land on the label: at 480px wide the label is
6
+ // squeezed into a 17px column and wraps onto four lines.
7
+ .ai-preset-row
8
+ max-width 400px
9
+ .ant-space-item:first-child
10
+ // the label keeps its full text width and never wraps or shrinks
11
+ flex none
12
+ white-space nowrap
13
+
14
+ .ant-space-item:last-child
15
+ // the select takes the rest of the line, shrinking to nothing if it must
16
+ flex 1
17
+ min-width 0
18
+
19
+ .ant-select
20
+ width 100%
@@ -23,7 +23,7 @@ const CJK_RE = /[\u2e80-\u2eff\u3000-\u303f\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9f
23
23
 
24
24
  export const DEFAULT_CONTEXT_WINDOW = 128000
25
25
  export const CONTEXT_WARN_PERCENT = 70
26
- export const CONTEXT_DANGER_PERCENT = 90
26
+ export const CONTEXT_DANGER_PERCENT = 80
27
27
 
28
28
  // Rough token count of a string. Never exact, never wildly wrong. Called on
29
29
  // the whole conversation every time it changes, so it stays a single pass
@@ -98,12 +98,7 @@ export default function AIOutput ({ item }) {
98
98
  }
99
99
 
100
100
  function handleToggleFlag () {
101
- const index = window.store.aiChatHistory.findIndex(i => i.id === item.id)
102
- if (index === -1) {
103
- return
104
- }
105
- window.store.aiChatHistory[index].flagged = !window.store.aiChatHistory[index].flagged
106
- window.store.aiChatHistory = [...window.store.aiChatHistory]
101
+ window.store.updateAiHistoryEntry(item.id, { flagged: !item.flagged })
107
102
  }
108
103
 
109
104
  function renderFlag () {
@@ -67,21 +67,6 @@ export const defaultAIPresets = [
67
67
  authHeaderNameAI: 'Authorization: Bearer',
68
68
  siteUrl: 'https://platform.openai.com/api-keys?utm_source=electerm_app&utm_medium=link&utm_campaign=electerm'
69
69
  },
70
- {
71
- id: 'openai-responses',
72
- nameAI: 'OpenAI (Responses API)',
73
- baseURLAI: 'https://api.openai.com/v1',
74
- apiPathAI: '/responses',
75
- modelAI: 'gpt-4o',
76
- authHeaderNameAI: 'Authorization: Bearer',
77
- modelAIs: [
78
- { value: 'gpt-4o' },
79
- { value: 'gpt-4o-mini' },
80
- { value: 'gpt-4.1' },
81
- { value: 'o4-mini' }
82
- ],
83
- siteUrl: 'https://platform.openai.com/api-keys?utm_source=electerm_app&utm_medium=link&utm_campaign=electerm'
84
- },
85
70
  {
86
71
  id: 'anthropic',
87
72
  nameAI: 'Anthropic Claude',
@@ -172,6 +172,7 @@ export default class BatchOpRunner extends Component {
172
172
  enableSsh: p.enableSsh !== false,
173
173
  useSshAgent: p.useSshAgent !== false,
174
174
  sshAgent: p.sshAgent || '',
175
+ agentForward: p.agentForward || false,
175
176
  term: p.term || 'xterm-256color',
176
177
  encode: p.encode || 'utf8',
177
178
  envLang: p.envLang || 'en_US.UTF-8',
@@ -20,6 +20,7 @@ const bookmarkSchema = {
20
20
  connectionHoppings: 'array - connection hopping definitions',
21
21
  useSshAgent: 'boolean - use SSH agent, default is true',
22
22
  sshAgent: 'string - ssh agent path',
23
+ agentForward: 'boolean - enable SSH agent forwarding (like ssh -A, needed to hop through a jump/bastion host), requires SSH agent, default is false',
23
24
  serverHostKey: 'array - server host key algorithms',
24
25
  cipher: 'array - cipher list',
25
26
  compress: 'array - compression algorithms (zlib@openssh.com, zlib, none)',