@electerm/electerm-react 5.5.15 → 5.5.35

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 (64) hide show
  1. package/README.md +14 -4
  2. package/client/common/bookmark-schemas.js +3 -0
  3. package/client/common/constants.js +16 -0
  4. package/client/common/create-title.jsx +5 -1
  5. package/client/common/default-setting.js +4 -2
  6. package/client/common/parse-vv.js +407 -0
  7. package/client/common/theme-defaults.js +5 -2
  8. package/client/common/update-check.js +5 -4
  9. package/client/common/vv-to-tab.js +120 -0
  10. package/client/components/ai/agent-tool-call-card.jsx +19 -4
  11. package/client/components/ai/agent.js +33 -16
  12. package/client/components/ai/ai-chat-history-item.jsx +12 -48
  13. package/client/components/ai/ai-chat.jsx +82 -25
  14. package/client/components/ai/ai-config-props.js +12 -1
  15. package/client/components/ai/ai-config.jsx +14 -0
  16. package/client/components/ai/ai-context-indicator.jsx +83 -0
  17. package/client/components/ai/ai-context.js +272 -0
  18. package/client/components/ai/ai-output.jsx +4 -0
  19. package/client/components/ai/ai-presets.js +13 -2
  20. package/client/components/ai/ai.styl +105 -6
  21. package/client/components/ai/copy-icon.jsx +31 -0
  22. package/client/components/bg/css-overwrite.jsx +16 -1
  23. package/client/components/bookmark-form/common/fields.jsx +3 -0
  24. package/client/components/bookmark-form/common/vv-file-field.jsx +138 -0
  25. package/client/components/bookmark-form/config/spice.js +22 -0
  26. package/client/components/footer/cmd-history.jsx +279 -65
  27. package/client/components/footer/cmd-history.styl +68 -1
  28. package/client/components/footer/footer-entry.jsx +46 -15
  29. package/client/components/icons/ai-icon.styl +4 -1
  30. package/client/components/layout/layout.jsx +12 -13
  31. package/client/components/main/main.jsx +23 -3
  32. package/client/components/main/term-fullscreen.styl +7 -0
  33. package/client/components/quick-commands/qm.styl +58 -0
  34. package/client/components/quick-commands/quick-command-ai-editor.jsx +255 -0
  35. package/client/components/quick-commands/quick-command-ai-editor.styl +13 -0
  36. package/client/components/quick-commands/quick-commands-box.jsx +197 -82
  37. package/client/components/quick-commands/quick-commands-form-elem.jsx +129 -65
  38. package/client/components/quick-commands/quick-commands-select.jsx +11 -0
  39. package/client/components/setting-panel/list.jsx +2 -2
  40. package/client/components/setting-panel/setting-common.jsx +2 -0
  41. package/client/components/side-panel-r/right-side-panel.styl +11 -1
  42. package/client/components/side-panel-r/side-panel-r.jsx +56 -13
  43. package/client/components/sidebar/bookmark-select.jsx +17 -3
  44. package/client/components/sidebar/history-item.jsx +20 -4
  45. package/client/components/sidebar/history.jsx +7 -0
  46. package/client/components/sidebar/index.jsx +13 -4
  47. package/client/components/sidebar/side-panel.jsx +8 -3
  48. package/client/components/sidebar/sidebar-panel.jsx +20 -7
  49. package/client/components/terminal/shortcut-bar.styl +14 -0
  50. package/client/components/terminal/unix-timestamp-tooltip.jsx +27 -1
  51. package/client/components/terminal-info/terminal-info.styl +3 -0
  52. package/client/components/theme/terminal-theme-list.styl +3 -0
  53. package/client/components/theme/theme-ai-editor.jsx +2 -2
  54. package/client/components/theme/theme-form.jsx +2 -2
  55. package/client/components/theme/theme-list-item.jsx +59 -25
  56. package/client/components/tree-list/tree-list.jsx +31 -3
  57. package/client/css/mobile.styl +28 -0
  58. package/client/store/common.js +164 -32
  59. package/client/store/init-state.js +10 -1
  60. package/client/store/load-data.js +72 -0
  61. package/client/store/sidebar.js +5 -1
  62. package/client/store/store.js +15 -2
  63. package/client/store/watch.js +3 -1
  64. package/package.json +1 -1
@@ -0,0 +1,120 @@
1
+ /**
2
+ * .vv connection file -> electerm Spice tab options
3
+ *
4
+ * Kept apart from parse-vv.js on purpose: parse-vv.js knows the file format
5
+ * and nothing about electerm, this module knows electerm's Spice tab shape and
6
+ * nothing about the format. Staying free of window/api access is what makes it
7
+ * unit-testable, and it is shared by both entry points for a .vv file --
8
+ * the bookmark form's "Load .vv file" button and a file handed to electerm
9
+ * from outside (`electerm /path/to/console.vv`).
10
+ *
11
+ * Deliberately imports nothing: constants.js pulls in the logo .png files and
12
+ * `window`, which would make this unimportable under `node --test`.
13
+ * parse-quick-connect.js spells the type out for the same reason.
14
+ */
15
+
16
+ /**
17
+ * The `spice` value of terminalSpiceType (src/client/common/constants.js).
18
+ * A .vv file is a virt-viewer file, and the only type this client implements
19
+ * is spice -- parse-vv.js rejects every other `type=` already.
20
+ */
21
+ const SPICE_TYPE = 'spice'
22
+
23
+ /**
24
+ * Mirrors the Spice defaults in components/bookmark-form/config/spice.js
25
+ * initValues(), so a .vv that omits them behaves exactly like a Spice bookmark
26
+ * added by hand.
27
+ */
28
+ const SPICE_DEFAULTS = {
29
+ viewOnly: false,
30
+ scaleViewport: true
31
+ }
32
+
33
+ /**
34
+ * Port used when the file names a host but no port at all. Matches
35
+ * DEFAULT_PORTS.spice in parse-quick-connect.js.
36
+ */
37
+ const DEFAULT_SPICE_PORT = 5900
38
+
39
+ const VV_EXT = /\.vv$/i
40
+ const URL_LIKE = /:\/\//
41
+
42
+ /**
43
+ * Does this look like a .vv file path?
44
+ *
45
+ * A suffix test rather than an exists()/stat() call on purpose: this is asked
46
+ * synchronously while scanning a command line, and the read that follows
47
+ * reports a missing file far better than a bare boolean could.
48
+ *
49
+ * `://` is excluded so a deep link that happens to end in .vv
50
+ * (spice://host/console.vv) is not mistaken for a path -- deep links have their
51
+ * own parser and would otherwise be read off the disk.
52
+ *
53
+ * @param {string} value
54
+ * @returns {boolean}
55
+ */
56
+ function isVvFile (value) {
57
+ if (typeof value !== 'string') {
58
+ return false
59
+ }
60
+ const text = value.trim()
61
+ return VV_EXT.test(text) && !URL_LIKE.test(text)
62
+ }
63
+
64
+ /**
65
+ * Last path segment, for a tab title when the file carries no `title` key.
66
+ * Done by hand because the renderer has no path module.
67
+ *
68
+ * @param {string} filePath
69
+ * @returns {string}
70
+ */
71
+ function basename (filePath) {
72
+ return String(filePath).split(/[/\\]/).pop() || String(filePath)
73
+ }
74
+
75
+ /**
76
+ * Build tab options from a parse result.
77
+ *
78
+ * Only keys the file actually provided are set. An absent `ca` or `hostSubject`
79
+ * must stay absent rather than become an empty string, because the session code
80
+ * reads "no ca" as "do not verify against a pinned CA" -- an empty string there
81
+ * would mean "pin nothing", which fails every handshake.
82
+ *
83
+ * @param {Object} parsed - result of parseVv()
84
+ * @param {Object} [options] - { filePath } plus any tab overrides
85
+ * @returns {Object} options for store.addTab()
86
+ */
87
+ function vvToTab (parsed, options = {}) {
88
+ const { filePath, ...extra } = options
89
+ const f = parsed.fields
90
+ const tab = {
91
+ type: SPICE_TYPE,
92
+ host: f.host,
93
+ port: f.port || DEFAULT_SPICE_PORT,
94
+ tls: !!f.tls,
95
+ ...SPICE_DEFAULTS
96
+ }
97
+ const optional = {
98
+ ca: f.ca,
99
+ hostSubject: f.hostSubject,
100
+ password: f.password,
101
+ proxy: f.proxy,
102
+ title: f.title || (filePath ? basename(filePath) : '')
103
+ }
104
+ Object.keys(optional).forEach(key => {
105
+ if (optional[key]) {
106
+ tab[key] = optional[key]
107
+ }
108
+ })
109
+ return { ...tab, ...extra }
110
+ }
111
+
112
+ export {
113
+ isVvFile,
114
+ SPICE_TYPE,
115
+ vvToTab,
116
+ basename,
117
+ VV_EXT,
118
+ DEFAULT_SPICE_PORT,
119
+ SPICE_DEFAULTS
120
+ }
@@ -9,6 +9,7 @@ import {
9
9
  CodeOutlined,
10
10
  DatabaseOutlined
11
11
  } from '@ant-design/icons'
12
+ import CopyIcon from './copy-icon'
12
13
 
13
14
  const toolIcons = {
14
15
  send_terminal_command: CodeOutlined,
@@ -65,6 +66,20 @@ export default function AgentToolCallCard ({ toolCall, autoCollapse }) {
65
66
  )
66
67
  }
67
68
 
69
+ function renderPre (text) {
70
+ return (
71
+ <div className='agent-tool-pre-wrap'>
72
+ <CopyIcon text={text} className='agent-tool-copy' />
73
+ <pre className='agent-tool-pre'>{text}</pre>
74
+ </div>
75
+ )
76
+ }
77
+
78
+ const argsText = args && Object.keys(args).length > 0
79
+ ? JSON.stringify(args, null, 2)
80
+ : ''
81
+ const resultText = formatResult(result)
82
+
68
83
  return (
69
84
  <div className={`agent-tool-call-card agent-tool-${status}`}>
70
85
  <div
@@ -79,16 +94,16 @@ export default function AgentToolCallCard ({ toolCall, autoCollapse }) {
79
94
  </div>
80
95
  {expanded && (
81
96
  <div className='agent-tool-detail'>
82
- {args && Object.keys(args).length > 0 && (
97
+ {argsText && (
83
98
  <div className='agent-tool-args'>
84
99
  <div className='agent-tool-label'>Arguments:</div>
85
- <pre className='agent-tool-pre'>{JSON.stringify(args, null, 2)}</pre>
100
+ {renderPre(argsText)}
86
101
  </div>
87
102
  )}
88
- {result && (
103
+ {resultText && (
89
104
  <div className='agent-tool-result'>
90
105
  <div className='agent-tool-label'>Result:</div>
91
- <pre className='agent-tool-pre'>{formatResult(result)}</pre>
106
+ {renderPre(resultText)}
92
107
  </div>
93
108
  )}
94
109
  </div>
@@ -1,5 +1,6 @@
1
1
  import { agentTools, executeToolCall } from './agent-tools'
2
2
  import { appendMandatoryGuardrails } from './ai-guardrails'
3
+ import { buildAgentMessages, summarizeContext } from './ai-context'
3
4
 
4
5
  const MAX_ITERATIONS = 150
5
6
 
@@ -47,27 +48,35 @@ async function callBackendAIchatWithTools (messages, config) {
47
48
  )
48
49
  }
49
50
 
51
+ // Publish the size of the context the agent is carrying so the panel can show
52
+ // it. An agent request is not just the conversation: it also carries every
53
+ // tool schema, and each iteration appends the tool results, which is what
54
+ // actually fills the window. `usage` is the provider's own count for the
55
+ // previous request and is kept for comparison -- the estimate is what the
56
+ // *next* request will cost, so that is the number on display.
57
+ function publishContext (messages, config, usage) {
58
+ const info = summarizeContext(messages, {
59
+ model: config.modelAI,
60
+ contextLength: window.store.config.contextLengthAI,
61
+ tools: agentTools
62
+ })
63
+ if (usage && usage.promptTokens) {
64
+ info.measuredTokens = usage.promptTokens
65
+ }
66
+ window.store.aiContextInfo = info
67
+ }
68
+
50
69
  export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming, conversationMessages = null) {
51
70
  window.store.agentRunning = true
52
71
  try {
53
- let messages
54
- if (conversationMessages && conversationMessages.length > 1) {
55
- // Replace system message with agent system prompt, keep conversation history
56
- messages = [
57
- { role: 'system', content: buildAgentSystemPrompt(config) },
58
- ...conversationMessages.filter(m => m.role !== 'system')
59
- ]
60
- } else {
61
- messages = [
62
- { role: 'system', content: buildAgentSystemPrompt(config) },
63
- {
64
- role: 'user',
65
- content: chatEntry.promptWithAttachments || chatEntry.prompt
66
- }
67
- ]
68
- }
72
+ const messages = buildAgentMessages({
73
+ systemPrompt: buildAgentSystemPrompt(config),
74
+ conversationMessages,
75
+ prompt: chatEntry.promptWithAttachments || chatEntry.prompt
76
+ })
69
77
  const toolCallsLog = []
70
78
  let accumulatedContent = ''
79
+ let lastUsage = null
71
80
 
72
81
  setIsStreaming(true)
73
82
  updateChatEntry(chatEntry, {
@@ -84,7 +93,11 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
84
93
  return
85
94
  }
86
95
 
96
+ publishContext(messages, config, lastUsage)
87
97
  const result = await callBackendAIchatWithTools(messages, config)
98
+ if (result.usage) {
99
+ lastUsage = result.usage
100
+ }
88
101
 
89
102
  if (result.error) {
90
103
  setIsStreaming(false)
@@ -104,6 +117,7 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
104
117
  }
105
118
 
106
119
  messages.push(assistantMessage)
120
+ publishContext(messages, config, lastUsage)
107
121
 
108
122
  if (assistantMessage.content) {
109
123
  accumulatedContent += (accumulatedContent ? '\n\n' : '') + assistantMessage.content
@@ -176,5 +190,8 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
176
190
  })
177
191
  } finally {
178
192
  window.store.agentRunning = false
193
+ // hand the panel back to the session based estimate: tool results are not
194
+ // carried into the next turn, so the agent figure stops being meaningful
195
+ window.store.aiContextInfo = null
179
196
  }
180
197
  }
@@ -4,6 +4,7 @@ import AIStopIcon from './ai-stop-icon'
4
4
  import AgentToolCallCard from './agent-tool-call-card'
5
5
  import { runAgentLoop } from './agent'
6
6
  import { appendMandatoryGuardrails } from './ai-guardrails'
7
+ import { buildSessionMessages } from './ai-context'
7
8
  import {
8
9
  Alert,
9
10
  Tooltip,
@@ -49,54 +50,17 @@ export default function AIChatHistoryItem ({ item }) {
49
50
  return appendMandatoryGuardrails(roleAI + `;用[${lang}]回复`)
50
51
  }
51
52
 
52
- const conversationMessages = useMemo(() => {
53
- if (!chatSessionId) {
54
- return null
55
- }
56
- const sessionEntries = window.store.aiChatHistory
57
- .filter(h => h.chatSessionId === chatSessionId && h.timestamp <= item.timestamp)
58
- .sort((a, b) => a.timestamp - b.timestamp)
59
-
60
- // Find the last compress entry
61
- let lastCompressIndex = -1
62
- for (let i = sessionEntries.length - 1; i >= 0; i--) {
63
- if (sessionEntries[i].compressed) {
64
- lastCompressIndex = i
65
- break
66
- }
67
- }
68
-
69
- const messages = [
70
- { role: 'system', content: buildRole() }
71
- ]
72
-
73
- // Start from the last compress entry (if any), skip older history
74
- const startIndex = lastCompressIndex >= 0 ? lastCompressIndex : 0
75
- for (let i = startIndex; i < sessionEntries.length; i++) {
76
- const entry = sessionEntries[i]
77
- if (entry.compressed) {
78
- messages.push({
79
- role: 'user',
80
- content: `Here is a summary of our previous conversation for context:\n\n${entry.response}`
81
- })
82
- messages.push({
83
- role: 'assistant',
84
- content: 'Understood. I will use this context as we continue.'
85
- })
86
- } else {
87
- messages.push({
88
- role: 'user',
89
- // promptWithAttachments carries the inlined file blocks;
90
- // falls back to the plain prompt for legacy entries
91
- content: entry.promptWithAttachments || entry.prompt
92
- })
93
- if (entry.response && entry.id !== item.id) {
94
- messages.push({ role: 'assistant', content: entry.response })
95
- }
96
- }
97
- }
98
- return messages
99
- }, [chatSessionId, item.id, item.timestamp])
53
+ // The context this turn sends: the session history up to and including the
54
+ // previous turns, but not this turn's own (still streaming) response.
55
+ // Shared with the context size indicator in ai-chat.jsx so the number shown
56
+ // is built from the same message list that goes out.
57
+ const conversationMessages = useMemo(() => buildSessionMessages({
58
+ history: window.store.aiChatHistory,
59
+ chatSessionId,
60
+ upto: item.timestamp,
61
+ role: buildRole(),
62
+ excludeId: item.id
63
+ }), [chatSessionId, item.id, item.timestamp])
100
64
 
101
65
  const pollStreamContent = useCallback(async (sid) => {
102
66
  try {
@@ -1,8 +1,11 @@
1
- import { useState, useCallback, useEffect, useRef } from 'react'
1
+ import { useState, useCallback, useEffect, useMemo, useRef } from 'react'
2
2
  import { Flex, Input, Segmented, Button, Tag, message } 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
+ import AiContextIndicator from './ai-context-indicator'
7
+ import { buildSessionMessages, summarizeContext, getUsageLevel, formatPercent } from './ai-context'
8
+ import { appendMandatoryGuardrails } from './ai-guardrails'
6
9
  import uid from '../../common/uid'
7
10
  import { pick } from 'lodash-es'
8
11
  import {
@@ -53,6 +56,48 @@ export default function AIChat (props) {
53
56
  h => h.chatSessionId === currentChatSessionId
54
57
  )
55
58
 
59
+ const config = props.config || {}
60
+
61
+ // Size of the context the *next* message will carry: every entry of this
62
+ // session, from the last compression onwards, built by the same helper the
63
+ // sender uses. While an agent loop is running its own, much larger, figure
64
+ // is published to the store (tool schemas and tool results included) and
65
+ // takes precedence.
66
+ const sessionContextInfo = useMemo(() => {
67
+ if (!currentChatSessionId) {
68
+ return null
69
+ }
70
+ const lang = config.languageAI || window.store.getLangName()
71
+ const role = appendMandatoryGuardrails(
72
+ (config.roleAI || '') + `;用[${lang}]回复`
73
+ )
74
+ const messages = buildSessionMessages({
75
+ history: props.aiChatHistory,
76
+ chatSessionId: currentChatSessionId,
77
+ role
78
+ })
79
+ if (!messages || messages.length < 2) {
80
+ return null
81
+ }
82
+ return summarizeContext(messages, {
83
+ model: config.modelAI,
84
+ contextLength: config.contextLengthAI
85
+ })
86
+ }, [
87
+ props.aiChatHistory,
88
+ // length as well as identity: sending a message pushes onto the existing
89
+ // array, so the reference alone would not change
90
+ props.aiChatHistory.length,
91
+ currentChatSessionId,
92
+ config.roleAI,
93
+ config.languageAI,
94
+ config.modelAI,
95
+ config.contextLengthAI
96
+ ])
97
+
98
+ const contextInfo = props.aiContextInfo || sessionContextInfo
99
+ const contextLevel = getUsageLevel(contextInfo && contextInfo.percent)
100
+
56
101
  function handlePromptChange (e) {
57
102
  setPrompt(e.target.value)
58
103
  }
@@ -179,6 +224,13 @@ export default function AIChat (props) {
179
224
  }
180
225
  }
181
226
 
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'
232
+ }
233
+
182
234
  function handleShowHistory () {
183
235
  window.store.toggleChatSessions()
184
236
  }
@@ -259,34 +311,39 @@ export default function AIChat (props) {
259
311
  </Flex>
260
312
 
261
313
  <Flex vertical className='ai-chat-input'>
262
- <Flex className='ai-chat-toolbar mg1b' align='left' gap={4}>
263
- <Button
264
- size='small'
265
- icon={<PlusOutlined />}
266
- onClick={handleNewChat}
267
- className='mg1r new-chat-btn'
268
- >
269
- {e('new')}
270
- </Button>
271
- {sessionHistory.length >= 2 && (
314
+ <Flex className='ai-chat-toolbar mg1b' align='center' justify='space-between' gap={4}>
315
+ <Flex align='center' gap={4}>
316
+ <Button
317
+ size='small'
318
+ icon={<PlusOutlined />}
319
+ onClick={handleNewChat}
320
+ className='new-chat-btn'
321
+ >
322
+ {e('new')}
323
+ </Button>
324
+ {sessionHistory.length >= 2 && (
325
+ <Button
326
+ size='small'
327
+ icon={<CompressOutlined />}
328
+ onClick={handleCompressSession}
329
+ loading={compressing}
330
+ type={contextLevel === 'warn' ? 'primary' : 'default'}
331
+ danger={contextLevel === 'danger'}
332
+ title={renderCompressTitle()}
333
+ >
334
+ {e('compress')}
335
+ </Button>
336
+ )}
272
337
  <Button
273
338
  size='small'
274
- icon={<CompressOutlined />}
275
- onClick={handleCompressSession}
276
- loading={compressing}
277
- className='mg1r'
339
+ icon={<HistoryOutlined />}
340
+ onClick={handleShowHistory}
341
+ type={props.showChatSessions ? 'primary' : 'default'}
278
342
  >
279
- {e('compress')}
343
+ {e('history')}
280
344
  </Button>
281
- )}
282
- <Button
283
- size='small'
284
- icon={<HistoryOutlined />}
285
- onClick={handleShowHistory}
286
- type={props.showChatSessions ? 'primary' : 'default'}
287
- >
288
- {e('history')}
289
- </Button>
345
+ </Flex>
346
+ <AiContextIndicator info={contextInfo} />
290
347
  </Flex>
291
348
  {attachments.length > 0 && (
292
349
  <Flex wrap='wrap' gap={4} className='ai-chat-attachments mg1b'>
@@ -7,7 +7,18 @@ export const aiConfigsArr = [
7
7
  'authHeaderNameAI',
8
8
  'apiPathAI',
9
9
  'languageAI',
10
- 'proxyAI'
10
+ 'proxyAI',
11
+ 'contextLengthAI'
12
+ ]
13
+
14
+ // Keys that may legitimately be empty. `aiConfigMissing` only forces the
15
+ // config modal open when one of the *other* keys is unset, so anything
16
+ // optional has to be listed here or the modal would pop up forever.
17
+ export const optionalAIConfigsArr = [
18
+ 'apiKeyAI',
19
+ 'proxyAI',
20
+ 'nameAI',
21
+ 'contextLengthAI'
11
22
  ]
12
23
 
13
24
  export {
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  Form,
3
3
  Input,
4
+ InputNumber,
4
5
  Button,
5
6
  AutoComplete,
6
7
  Alert,
@@ -356,6 +357,19 @@ export default function AIConfigForm ({ initialValues, onSubmit, showAIConfig })
356
357
  {renderModelInput()}
357
358
  </Form.Item>
358
359
 
360
+ <Form.Item
361
+ label='Context length'
362
+ name='contextLengthAI'
363
+ tooltip='How many tokens this model can hold in one request. Leave empty to guess from the model name; the chat panel uses it to show how full the context is.'
364
+ >
365
+ <InputNumber
366
+ placeholder='auto (detect from model name)'
367
+ min={1000}
368
+ step={1000}
369
+ style={{ width: '100%' }}
370
+ />
371
+ </Form.Item>
372
+
359
373
  <Form.Item
360
374
  label={renderApiKeyLabel()}
361
375
  name='apiKeyAI'
@@ -0,0 +1,83 @@
1
+ import { Tooltip } from 'antd'
2
+ import {
3
+ formatTokens,
4
+ formatPercent,
5
+ getUsageLevel,
6
+ CONTEXT_WARN_PERCENT
7
+ } from './ai-context'
8
+
9
+ // How full the context window is, as a small bar + count, with the breakdown
10
+ // in the tooltip. The number is what the next request will carry, so it is
11
+ // the one to watch before pressing Compress.
12
+ export default function AiContextIndicator ({ info }) {
13
+ if (!info || !info.tokens) {
14
+ return null
15
+ }
16
+ const {
17
+ tokens,
18
+ messagesTokens,
19
+ toolsTokens,
20
+ windowSize,
21
+ windowSource,
22
+ percent,
23
+ measuredTokens
24
+ } = info
25
+ const level = getUsageLevel(percent)
26
+ const width = Math.min(100, Math.max(2, percent || 0))
27
+
28
+ function renderWindowSource () {
29
+ if (windowSource === 'config') {
30
+ return `window ${formatTokens(windowSize)}, set in AI config`
31
+ }
32
+ if (windowSource === 'model') {
33
+ return `window ${formatTokens(windowSize)}, from the model name`
34
+ }
35
+ return `window assumed to be ${formatTokens(windowSize)} — set "Context length" in AI config if your model differs`
36
+ }
37
+
38
+ const tip = (
39
+ <div className='ai-context-tip'>
40
+ <div className='ai-context-tip-title'>Context of the next message</div>
41
+ <div className='ai-context-tip-row'>
42
+ <span>system + history</span>
43
+ <span>~{formatTokens(messagesTokens)}</span>
44
+ </div>
45
+ {toolsTokens > 0 && (
46
+ <div className='ai-context-tip-row'>
47
+ <span>agent tools</span>
48
+ <span>~{formatTokens(toolsTokens)}</span>
49
+ </div>
50
+ )}
51
+ <div className='ai-context-tip-row ai-context-tip-total'>
52
+ <span>total</span>
53
+ <span>~{formatTokens(tokens)} ({formatPercent(percent)})</span>
54
+ </div>
55
+ {measuredTokens > 0 && (
56
+ <div className='ai-context-tip-row'>
57
+ <span>last request, measured</span>
58
+ <span>{measuredTokens.toLocaleString()}</span>
59
+ </div>
60
+ )}
61
+ <div className='ai-context-tip-note'>{renderWindowSource()}</div>
62
+ {(level === 'warn' || level === 'danger') && (
63
+ <div className='ai-context-tip-warn'>
64
+ Over {CONTEXT_WARN_PERCENT}% — use Compress to summarize the session.
65
+ </div>
66
+ )}
67
+ </div>
68
+ )
69
+
70
+ return (
71
+ <Tooltip title={tip}>
72
+ <span className={`ai-context-indicator ai-context-${level}`}>
73
+ <span className='ai-context-bar'>
74
+ <span className='ai-context-bar-fill' style={{ width: width + '%' }} />
75
+ </span>
76
+ <span className='ai-context-count'>
77
+ {formatTokens(tokens)}/{formatTokens(windowSize)}
78
+ </span>
79
+ <span className='ai-context-percent'>{formatPercent(percent)}</span>
80
+ </span>
81
+ </Tooltip>
82
+ )
83
+ }