lonny-agent 0.2.4 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +326 -326
- package/dist/agent/llm.d.ts +4 -0
- package/dist/agent/llm.d.ts.map +1 -1
- package/dist/agent/prompt-builder.d.ts.map +1 -1
- package/dist/agent/prompt-builder.js +13 -9
- package/dist/agent/prompt-builder.js.map +1 -1
- package/dist/agent/providers/google.d.ts.map +1 -1
- package/dist/agent/providers/google.js.map +1 -1
- package/dist/agent/providers/openai.d.ts.map +1 -1
- package/dist/agent/providers/openai.js +43 -3
- package/dist/agent/providers/openai.js.map +1 -1
- package/dist/agent/session.d.ts +4 -0
- package/dist/agent/session.d.ts.map +1 -1
- package/dist/agent/session.js +45 -7
- package/dist/agent/session.js.map +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +7 -1
- package/dist/config/index.js.map +1 -1
- package/dist/tools/__tests__/bash.test.js +1 -1
- package/dist/tools/__tests__/bash.test.js.map +1 -1
- package/dist/tools/__tests__/edit.test.js +875 -371
- package/dist/tools/__tests__/edit.test.js.map +1 -1
- package/dist/tools/__tests__/grep.test.js +32 -0
- package/dist/tools/__tests__/grep.test.js.map +1 -1
- package/dist/tools/__tests__/sed.test.d.ts +2 -0
- package/dist/tools/__tests__/sed.test.d.ts.map +1 -0
- package/dist/tools/__tests__/sed.test.js +228 -0
- package/dist/tools/__tests__/sed.test.js.map +1 -0
- package/dist/tools/bash.d.ts.map +1 -1
- package/dist/tools/bash.js +83 -10
- package/dist/tools/bash.js.map +1 -1
- package/dist/tools/edit.d.ts +50 -9
- package/dist/tools/edit.d.ts.map +1 -1
- package/dist/tools/edit.js +328 -158
- package/dist/tools/edit.js.map +1 -1
- package/dist/tools/grep.d.ts.map +1 -1
- package/dist/tools/grep.js +54 -30
- package/dist/tools/grep.js.map +1 -1
- package/dist/tools/registry.d.ts +0 -6
- package/dist/tools/registry.d.ts.map +1 -1
- package/dist/tools/registry.js +2 -29
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/sed.d.ts +4 -0
- package/dist/tools/sed.d.ts.map +1 -0
- package/dist/tools/sed.js +121 -0
- package/dist/tools/sed.js.map +1 -0
- package/dist/web/index.d.ts.map +1 -1
- package/dist/web/index.js +26 -3
- package/dist/web/index.js.map +1 -1
- package/dist/web/public/app.js +5 -855
- package/dist/web/public/confirm.js +70 -0
- package/dist/web/public/index.html +121 -120
- package/dist/web/public/input.js +107 -0
- package/dist/web/public/messages.js +395 -0
- package/dist/web/public/sidebar.js +82 -0
- package/dist/web/public/state.js +72 -0
- package/dist/web/public/style.css +996 -949
- package/dist/web/public/utils.js +37 -0
- package/dist/web/public/websocket.js +267 -0
- package/dist/web/public/ws.js +19 -0
- package/dist/web/session-bridge.d.ts.map +1 -1
- package/dist/web/session-bridge.js +3 -7
- package/dist/web/session-bridge.js.map +1 -1
- package/package.json +55 -54
- package/scripts/copy-native.mjs +24 -0
- package/scripts/copy-web.mjs +15 -0
- package/src/agent/index.ts +25 -25
- package/src/agent/llm.ts +4 -0
- package/src/agent/prompt-builder.ts +13 -9
- package/src/agent/providers/anthropic.ts +179 -179
- package/src/agent/providers/google.ts +210 -211
- package/src/agent/providers/ollama.ts +186 -186
- package/src/agent/providers/openai.ts +61 -5
- package/src/agent/session.ts +59 -3
- package/src/config/index.ts +342 -335
- package/src/tools/__tests__/bash.test.ts +1 -1
- package/src/tools/__tests__/edit.test.ts +981 -385
- package/src/tools/__tests__/grep.test.ts +35 -0
- package/src/tools/bash.ts +97 -11
- package/src/tools/edit.ts +362 -169
- package/src/tools/git.ts +76 -76
- package/src/tools/grep.ts +57 -30
- package/src/tools/registry.ts +2 -31
- package/src/tui/index.ts +786 -786
- package/src/web/index.ts +25 -3
- package/src/web/public/app.js +5 -855
- package/src/web/public/confirm.js +70 -0
- package/src/web/public/index.html +121 -120
- package/src/web/public/input.js +107 -0
- package/src/web/public/messages.js +395 -0
- package/src/web/public/sidebar.js +82 -0
- package/src/web/public/state.js +72 -0
- package/src/web/public/style.css +996 -949
- package/src/web/public/utils.js +37 -0
- package/src/web/public/websocket.js +267 -0
- package/src/web/public/ws.js +19 -0
- package/src/web/session-bridge.ts +193 -194
- package/.claude/settings.local.json +0 -14
- package/.kilo/plans/1780064105789-mighty-pixel.md +0 -171
- package/.kilo/plans/1780293725888-quick-wizard.md +0 -62
- package/.lonny/plan-web-cwd-status.md +0 -38
- package/src/tools/exec.ts +0 -348
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/* ── Message Rendering ── */
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
messagesEl,
|
|
5
|
+
pendingToolCalls,
|
|
6
|
+
state,
|
|
7
|
+
tokenCache,
|
|
8
|
+
tokenCalls,
|
|
9
|
+
tokenIn,
|
|
10
|
+
tokenOut,
|
|
11
|
+
} from './state.js'
|
|
12
|
+
import {
|
|
13
|
+
escapeHtml,
|
|
14
|
+
formatTimestamp,
|
|
15
|
+
formatTokenCount,
|
|
16
|
+
renderMarkdown,
|
|
17
|
+
scrollToBottom,
|
|
18
|
+
} from './utils.js'
|
|
19
|
+
|
|
20
|
+
export function addSystemMessage(text) {
|
|
21
|
+
const div = document.createElement('div')
|
|
22
|
+
div.className = 'system-message'
|
|
23
|
+
div.textContent = text
|
|
24
|
+
messagesEl.appendChild(div)
|
|
25
|
+
scrollToBottom()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function addUserMessage(text) {
|
|
29
|
+
const msgDiv = document.createElement('div')
|
|
30
|
+
msgDiv.className = 'message'
|
|
31
|
+
|
|
32
|
+
const header = document.createElement('div')
|
|
33
|
+
header.className = 'message-header'
|
|
34
|
+
header.innerHTML = `<span class="label user">You</span> <span class="timestamp">${formatTimestamp()}</span>`
|
|
35
|
+
msgDiv.appendChild(header)
|
|
36
|
+
|
|
37
|
+
const body = document.createElement('div')
|
|
38
|
+
body.className = 'message-body user'
|
|
39
|
+
body.innerHTML = renderMarkdown(text)
|
|
40
|
+
msgDiv.appendChild(body)
|
|
41
|
+
|
|
42
|
+
messagesEl.appendChild(msgDiv)
|
|
43
|
+
scrollToBottom()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function startAssistantMessage() {
|
|
47
|
+
state.streamingText = ''
|
|
48
|
+
state.streamingMsgEl = document.createElement('div')
|
|
49
|
+
state.streamingMsgEl.className = 'message'
|
|
50
|
+
|
|
51
|
+
const header = document.createElement('div')
|
|
52
|
+
header.className = 'message-header'
|
|
53
|
+
header.innerHTML = `<span class="label assistant">Lonny</span> <span class="timestamp">${formatTimestamp()}</span>`
|
|
54
|
+
state.streamingMsgEl.appendChild(header)
|
|
55
|
+
|
|
56
|
+
const body = document.createElement('div')
|
|
57
|
+
body.className = 'message-body assistant streaming'
|
|
58
|
+
body.textContent = ''
|
|
59
|
+
state.streamingMsgEl.appendChild(body)
|
|
60
|
+
|
|
61
|
+
messagesEl.appendChild(state.streamingMsgEl)
|
|
62
|
+
scrollToBottom()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function appendChunk(text) {
|
|
66
|
+
if (!state.streamingMsgEl) {
|
|
67
|
+
startAssistantMessage()
|
|
68
|
+
}
|
|
69
|
+
state.streamingText += text
|
|
70
|
+
const body = state.streamingMsgEl.querySelector('.message-body')
|
|
71
|
+
body.innerHTML = renderMarkdown(state.streamingText)
|
|
72
|
+
body.classList.add('streaming')
|
|
73
|
+
scrollToBottom()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function finalizeAssistantMessage() {
|
|
77
|
+
if (!state.streamingMsgEl) return
|
|
78
|
+
const body = state.streamingMsgEl.querySelector('.message-body')
|
|
79
|
+
body.innerHTML = renderMarkdown(state.streamingText)
|
|
80
|
+
body.classList.remove('streaming')
|
|
81
|
+
state.streamingMsgEl = null
|
|
82
|
+
state.streamingText = ''
|
|
83
|
+
scrollToBottom()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function addToolCall(name, input, id) {
|
|
87
|
+
const container = state.streamingMsgEl || messagesEl.querySelector('.message:last-child')
|
|
88
|
+
const div = document.createElement('div')
|
|
89
|
+
div.className = id ? 'tool-call executing' : 'tool-call'
|
|
90
|
+
div.dataset.toolId = id || ''
|
|
91
|
+
|
|
92
|
+
if (name === 'edit') {
|
|
93
|
+
let paths = []
|
|
94
|
+
if (Array.isArray(input?.edits)) {
|
|
95
|
+
paths = input.edits.map(e => e.file_path).filter(Boolean)
|
|
96
|
+
} else if (input?.content) {
|
|
97
|
+
const content = input.content
|
|
98
|
+
// Parse Markdown edit blocks to extract file paths
|
|
99
|
+
// Use RegExp constructor to avoid backtick issues
|
|
100
|
+
const bt = '\x60'
|
|
101
|
+
const blockRegex = new RegExp(bt + bt + bt + 'edit\\s*([\\s\\S]*?)' + bt + bt + bt, 'gi')
|
|
102
|
+
for (const blockMatch of content.matchAll(blockRegex)) {
|
|
103
|
+
const blockContent = blockMatch[1] || ''
|
|
104
|
+
const fileMatch = blockContent.match(/^file:\s*(.+)$/m)
|
|
105
|
+
if (fileMatch) paths.push(fileMatch[1].trim())
|
|
106
|
+
// Detect create-file blocks (empty/missing old:)
|
|
107
|
+
const oldMatch = blockContent.match(/^old:([\s\S]*?)^new:/m)
|
|
108
|
+
if (!oldMatch || !oldMatch[1].trim()) {
|
|
109
|
+
hasEmptyOld = true
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Fallback: no edit markers -- try raw file: lines
|
|
113
|
+
if (paths.length === 0) {
|
|
114
|
+
const fileMatch = content.match(/^file:\s*(.+)$/gm)
|
|
115
|
+
if (fileMatch) paths = fileMatch.map(m => m.replace(/^file:\s*/, '').trim())
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const uniquePaths = [...new Set(paths)]
|
|
119
|
+
const pathsStr =
|
|
120
|
+
uniquePaths.length > 0 ? uniquePaths.map(p => escapeHtml(p)).join(', ') : '(no files)'
|
|
121
|
+
|
|
122
|
+
// Also check JSON-format edits for create-file detection
|
|
123
|
+
if (!hasEmptyOld && Array.isArray(input?.edits)) {
|
|
124
|
+
hasEmptyOld = input.edits.some(e => e.old_string === '')
|
|
125
|
+
}
|
|
126
|
+
const badge = hasEmptyOld ? 'create' : 'edit'
|
|
127
|
+
div.innerHTML = `<span class="tool-name tool-name-edit">✏ edit</span><span class="tool-change-badge tool-change-${badge}">${badge}</span> <span class="tool-input tool-input-edit">${pathsStr}</span>`
|
|
128
|
+
} else {
|
|
129
|
+
const inputStr = typeof input === 'object' ? JSON.stringify(input).slice(0, 120) : String(input)
|
|
130
|
+
div.innerHTML = `<span class="tool-name">${escapeHtml(name)}</span> <span class="tool-input">${escapeHtml(inputStr)}</span>`
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (container && container.matches('.message')) {
|
|
134
|
+
container.appendChild(div)
|
|
135
|
+
} else {
|
|
136
|
+
messagesEl.appendChild(div)
|
|
137
|
+
}
|
|
138
|
+
if (id) pendingToolCalls.set(id, div)
|
|
139
|
+
scrollToBottom()
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const DIFF_RED = '#ff5050'
|
|
143
|
+
const DIFF_GREEN = '#00c864'
|
|
144
|
+
const DIFF_DIM = '#888888'
|
|
145
|
+
|
|
146
|
+
const ANSI_RED = '\x1b[38;2;255;80;80m'
|
|
147
|
+
const ANSI_GREEN = '\x1b[38;2;0;200;100m'
|
|
148
|
+
const ANSI_DIM = '\x1b[38;2;100;100;100m'
|
|
149
|
+
const ANSI_PATTERN = /\x1b\[[\d;]+m/g
|
|
150
|
+
|
|
151
|
+
function renderDiffContent(text) {
|
|
152
|
+
const hasAnsi = text.includes(ANSI_RED) || text.includes(ANSI_GREEN) || text.includes(ANSI_DIM)
|
|
153
|
+
|
|
154
|
+
if (hasAnsi) {
|
|
155
|
+
return text
|
|
156
|
+
.split('\n')
|
|
157
|
+
.map(line => {
|
|
158
|
+
if (!line.trim()) return `${escapeHtml(line)}\n`
|
|
159
|
+
const color = line.includes(ANSI_RED)
|
|
160
|
+
? DIFF_RED
|
|
161
|
+
: line.includes(ANSI_GREEN)
|
|
162
|
+
? DIFF_GREEN
|
|
163
|
+
: line.includes(ANSI_DIM)
|
|
164
|
+
? DIFF_DIM
|
|
165
|
+
: null
|
|
166
|
+
const clean = line.replace(ANSI_PATTERN, '')
|
|
167
|
+
if (!color) return `${escapeHtml(clean)}\n`
|
|
168
|
+
return `<span style="color:${color}">${escapeHtml(clean)}\n</span>`
|
|
169
|
+
})
|
|
170
|
+
.join('')
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Fallback: detect unified-diff markers (- / +) without ANSI codes
|
|
174
|
+
const hasDiffMarkers = text.split('\n').some(l => l.trim().match(/^[-+]\s/))
|
|
175
|
+
if (hasDiffMarkers) {
|
|
176
|
+
return text
|
|
177
|
+
.split('\n')
|
|
178
|
+
.map(line => {
|
|
179
|
+
const trimmed = line.trim()
|
|
180
|
+
if (trimmed.startsWith('- ')) {
|
|
181
|
+
return `<span style="color:${DIFF_RED}">${escapeHtml(line)}\n</span>`
|
|
182
|
+
}
|
|
183
|
+
if (trimmed.startsWith('+ ')) {
|
|
184
|
+
return `<span style="color:${DIFF_GREEN}">${escapeHtml(line)}\n</span>`
|
|
185
|
+
}
|
|
186
|
+
if (trimmed.match(/^[-+]\s/)) {
|
|
187
|
+
return `<span style="color:${DIFF_DIM}">${escapeHtml(line)}\n</span>`
|
|
188
|
+
}
|
|
189
|
+
return `${escapeHtml(line)}\n`
|
|
190
|
+
})
|
|
191
|
+
.join('')
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return escapeHtml(text)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function addToolResult(name, success, outputOrError, id) {
|
|
198
|
+
if (id) {
|
|
199
|
+
const pendingEl = pendingToolCalls.get(id)
|
|
200
|
+
if (pendingEl) {
|
|
201
|
+
pendingEl.classList.remove('executing')
|
|
202
|
+
pendingToolCalls.delete(id)
|
|
203
|
+
if (name === 'edit') {
|
|
204
|
+
if (success) {
|
|
205
|
+
pendingEl.classList.add('completed')
|
|
206
|
+
const fileSummary = (outputOrError || '')
|
|
207
|
+
.split('\n')
|
|
208
|
+
.map(l => l.trim())
|
|
209
|
+
.filter(l => /^(Edited|Created|Deleted) /.test(l))
|
|
210
|
+
.join(', ')
|
|
211
|
+
if (fileSummary) {
|
|
212
|
+
const inputSpan = pendingEl.querySelector('.tool-input-edit')
|
|
213
|
+
if (inputSpan) {
|
|
214
|
+
inputSpan.textContent = fileSummary.replace(/:\s*$/, '')
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
pendingEl.classList.add('failed')
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const container = state.streamingMsgEl || messagesEl.querySelector('.message:last-child')
|
|
225
|
+
const div = document.createElement('div')
|
|
226
|
+
div.className = 'tool-result'
|
|
227
|
+
if (success) {
|
|
228
|
+
let display = outputOrError
|
|
229
|
+
if (display === '(no output)') display = ''
|
|
230
|
+
if (name === 'edit' && display) {
|
|
231
|
+
const lines = display.split('\n')
|
|
232
|
+
const edits = [] // { summary, diffLines[] }
|
|
233
|
+
let currentEdit = null
|
|
234
|
+
for (const line of lines) {
|
|
235
|
+
const trimmed = line.trim()
|
|
236
|
+
const stripped = trimmed.replace(ANSI_PATTERN, '')
|
|
237
|
+
if (
|
|
238
|
+
trimmed.startsWith('Edited ') ||
|
|
239
|
+
trimmed.startsWith('Created ') ||
|
|
240
|
+
trimmed.startsWith('Deleted ')
|
|
241
|
+
) {
|
|
242
|
+
currentEdit = { summary: trimmed, diffLines: [] }
|
|
243
|
+
edits.push(currentEdit)
|
|
244
|
+
} else if (
|
|
245
|
+
currentEdit &&
|
|
246
|
+
(stripped.startsWith('- ') || stripped.startsWith('+ ') || stripped.match(/^\s*\d+ /))
|
|
247
|
+
) {
|
|
248
|
+
currentEdit.diffLines.push(line)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
let html = `<span class="tool-result-success">✔ ${escapeHtml(name)}</span>`
|
|
252
|
+
if (edits.length === 0 && display.includes(' FAIL ')) {
|
|
253
|
+
html += `<span class="tool-result-diff"><pre>${escapeHtml(display)}</pre></span>`
|
|
254
|
+
} else {
|
|
255
|
+
for (const edit of edits) {
|
|
256
|
+
html += `<span class="tool-result-summary">${escapeHtml(edit.summary)}</span>`
|
|
257
|
+
if (edit.diffLines.length > 0) {
|
|
258
|
+
html += `<span class="tool-result-diff"><pre><code>${renderDiffContent(edit.diffLines.join('\n'))}</code></pre></span>`
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
div.innerHTML = html
|
|
263
|
+
} else {
|
|
264
|
+
const summary = typeof display === 'string' ? display.slice(0, 80) : ''
|
|
265
|
+
div.innerHTML = `<span class="tool-result-success">✔ ${escapeHtml(name)}</span>${summary ? ` ${escapeHtml(summary)}` : ''}`
|
|
266
|
+
}
|
|
267
|
+
} else {
|
|
268
|
+
if (name === 'edit') {
|
|
269
|
+
const filePathPattern = /([\w/.-]+\.[a-z]+)/gi
|
|
270
|
+
const filePaths = [...new Set((outputOrError.match(filePathPattern) || []).slice(0, 5))]
|
|
271
|
+
const summary =
|
|
272
|
+
filePaths.length > 0
|
|
273
|
+
? `<span class="tool-error-files">${filePaths.map(p => escapeHtml(p)).join(', ')}</span>`
|
|
274
|
+
: ''
|
|
275
|
+
div.innerHTML =
|
|
276
|
+
`<span class="tool-result-error">✖ ${escapeHtml(name)}</span>` +
|
|
277
|
+
summary +
|
|
278
|
+
`<span class="tool-error-detail">${escapeHtml(outputOrError)}</span>`
|
|
279
|
+
} else {
|
|
280
|
+
div.innerHTML = `<span class="tool-result-error">✖ ${escapeHtml(name)}</span> <span class="tool-error-detail">${escapeHtml(outputOrError)}</span>`
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (container && container.matches('.message')) {
|
|
284
|
+
container.appendChild(div)
|
|
285
|
+
} else {
|
|
286
|
+
messagesEl.appendChild(div)
|
|
287
|
+
}
|
|
288
|
+
scrollToBottom()
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function showThinking(text) {
|
|
292
|
+
if (!state.thinkingEl) {
|
|
293
|
+
state.thinkingEl = document.createElement('div')
|
|
294
|
+
state.thinkingEl.className = 'thinking-block'
|
|
295
|
+
state.thinkingEl.innerHTML =
|
|
296
|
+
'<div class="thinking-label">🤔 Think</div><div class="thinking-content"></div>'
|
|
297
|
+
if (state.streamingMsgEl) {
|
|
298
|
+
const body = state.streamingMsgEl.querySelector('.message-body')
|
|
299
|
+
state.streamingMsgEl.insertBefore(state.thinkingEl, body)
|
|
300
|
+
} else {
|
|
301
|
+
messagesEl.appendChild(state.thinkingEl)
|
|
302
|
+
}
|
|
303
|
+
state.thinkingText = ''
|
|
304
|
+
}
|
|
305
|
+
state.thinkingText += text
|
|
306
|
+
const content = state.thinkingEl.querySelector('.thinking-content')
|
|
307
|
+
content.innerHTML = renderMarkdown(state.thinkingText)
|
|
308
|
+
scrollToBottom()
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function hideThinking() {
|
|
312
|
+
if (state.thinkingEl) {
|
|
313
|
+
state.thinkingEl.classList.add('thinking-done')
|
|
314
|
+
state.thinkingEl = null
|
|
315
|
+
state.thinkingText = ''
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function renderSessionHistory(messages) {
|
|
320
|
+
if (!Array.isArray(messages)) return
|
|
321
|
+
for (const msg of messages) {
|
|
322
|
+
if (msg.role === 'user') {
|
|
323
|
+
addUserMessage(msg.content || '')
|
|
324
|
+
} else if (msg.role === 'assistant') {
|
|
325
|
+
startAssistantMessage()
|
|
326
|
+
if (msg.reasoning_content) {
|
|
327
|
+
showThinking(msg.reasoning_content)
|
|
328
|
+
hideThinking()
|
|
329
|
+
}
|
|
330
|
+
if (msg.content) {
|
|
331
|
+
state.streamingText = msg.content
|
|
332
|
+
const body = state.streamingMsgEl.querySelector('.message-body')
|
|
333
|
+
body.innerHTML = renderMarkdown(msg.content)
|
|
334
|
+
}
|
|
335
|
+
if (msg.tool_calls && msg.tool_calls.length > 0) {
|
|
336
|
+
for (const tc of msg.tool_calls) {
|
|
337
|
+
addToolCall(tc.name, tc.input)
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
finalizeAssistantMessage()
|
|
341
|
+
} else if (msg.role === 'tool') {
|
|
342
|
+
const content = msg.content || ''
|
|
343
|
+
const isError = content.startsWith('ERROR: ')
|
|
344
|
+
if (isError) {
|
|
345
|
+
addToolResult(msg.name || '', false, content.replace(/^ERROR:\s*/, ''))
|
|
346
|
+
} else {
|
|
347
|
+
addToolResult(msg.name || '', true, content)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function addTokenStats(
|
|
354
|
+
turnIn,
|
|
355
|
+
turnOut,
|
|
356
|
+
totalIn,
|
|
357
|
+
totalOut,
|
|
358
|
+
turnApi,
|
|
359
|
+
totalApi,
|
|
360
|
+
totalCacheHit,
|
|
361
|
+
totalCacheMiss,
|
|
362
|
+
) {
|
|
363
|
+
const div = document.createElement('div')
|
|
364
|
+
const cacheHit = totalCacheHit ?? 0
|
|
365
|
+
const cacheMiss = totalCacheMiss ?? 0
|
|
366
|
+
const cacheTotal = cacheHit + cacheMiss
|
|
367
|
+
let statsText = `▴${turnIn} ▾${turnOut} total ${totalIn + totalOut} calls ${turnApi}(${totalApi})`
|
|
368
|
+
if (cacheTotal > 0) {
|
|
369
|
+
const pct = Math.round((cacheHit / cacheTotal) * 100)
|
|
370
|
+
statsText += ` cached ${pct}%`
|
|
371
|
+
}
|
|
372
|
+
div.className = 'token-stats-bar'
|
|
373
|
+
div.textContent = statsText
|
|
374
|
+
messagesEl.appendChild(div)
|
|
375
|
+
scrollToBottom()
|
|
376
|
+
|
|
377
|
+
tokenIn.textContent = formatTokenCount(totalIn)
|
|
378
|
+
tokenOut.textContent = formatTokenCount(totalOut)
|
|
379
|
+
tokenCalls.textContent = `(${totalApi})`
|
|
380
|
+
if (cacheTotal > 0) {
|
|
381
|
+
const pct = Math.round((cacheHit / cacheTotal) * 100)
|
|
382
|
+
tokenCache.textContent = `| 缓存 ${pct}%`
|
|
383
|
+
tokenCache.classList.remove('hidden')
|
|
384
|
+
} else {
|
|
385
|
+
tokenCache.classList.add('hidden')
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export function addErrorMessage(text) {
|
|
390
|
+
const div = document.createElement('div')
|
|
391
|
+
div.className = 'message'
|
|
392
|
+
div.innerHTML = `<div class="message-body error">${escapeHtml(text)}</div>`
|
|
393
|
+
messagesEl.appendChild(div)
|
|
394
|
+
scrollToBottom()
|
|
395
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/* ── Sidebar (Plans / Todos) ── */
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
plansList,
|
|
5
|
+
plansPane,
|
|
6
|
+
plansPlaceholder,
|
|
7
|
+
sidebarTabs,
|
|
8
|
+
state,
|
|
9
|
+
todosList,
|
|
10
|
+
todosPane,
|
|
11
|
+
todosPlaceholder,
|
|
12
|
+
} from './state.js'
|
|
13
|
+
import { escapeHtml } from './utils.js'
|
|
14
|
+
import { sendWsMsg } from './ws.js'
|
|
15
|
+
|
|
16
|
+
export function switchTab(tabId) {
|
|
17
|
+
sidebarTabs.forEach(tab => {
|
|
18
|
+
tab.classList.toggle('active', tab.dataset.tab === tabId)
|
|
19
|
+
})
|
|
20
|
+
plansPane.classList.toggle('active', tabId === 'plans')
|
|
21
|
+
todosPane.classList.toggle('active', tabId === 'todos')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function updatePlans() {
|
|
25
|
+
plansList.innerHTML = ''
|
|
26
|
+
if (state.plans.length === 0) {
|
|
27
|
+
plansPlaceholder.style.display = ''
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
plansPlaceholder.style.display = 'none'
|
|
31
|
+
for (const plan of state.plans) {
|
|
32
|
+
const div = document.createElement('div')
|
|
33
|
+
div.className = `plan-item${plan.name === state.currentPlanName ? ' active' : ''}`
|
|
34
|
+
const timeStr = plan.mtime
|
|
35
|
+
? new Date(plan.mtime).toLocaleDateString() +
|
|
36
|
+
' ' +
|
|
37
|
+
new Date(plan.mtime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
38
|
+
: ''
|
|
39
|
+
div.dataset.planName = plan.name
|
|
40
|
+
div.innerHTML = `<div class="plan-name">${escapeHtml(plan.name)}</div><div class="plan-time">${escapeHtml(timeStr)}</div>`
|
|
41
|
+
div.addEventListener('click', () => onPlanClick(plan.name))
|
|
42
|
+
plansList.appendChild(div)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function updateTodos() {
|
|
47
|
+
todosList.innerHTML = ''
|
|
48
|
+
if (!state.currentPlanName) {
|
|
49
|
+
todosPlaceholder.textContent = '(no plan selected)'
|
|
50
|
+
todosPlaceholder.style.display = ''
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
if (state.todos.length === 0) {
|
|
54
|
+
todosPlaceholder.textContent = '(no todos)'
|
|
55
|
+
todosPlaceholder.style.display = ''
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
todosPlaceholder.style.display = 'none'
|
|
59
|
+
const header = document.createElement('div')
|
|
60
|
+
header.className = 'todo-header-text'
|
|
61
|
+
header.textContent = state.currentPlanName
|
|
62
|
+
todosList.appendChild(header)
|
|
63
|
+
for (const todo of state.todos) {
|
|
64
|
+
const div = document.createElement('div')
|
|
65
|
+
div.className = 'todo-item'
|
|
66
|
+
const done = todo.done
|
|
67
|
+
div.innerHTML =
|
|
68
|
+
`<span class="todo-check ${done ? 'done' : 'pending'}">${done ? '✅' : '⬜'}</span>` +
|
|
69
|
+
`<span class="todo-text ${done ? 'done' : ''}">${escapeHtml(todo.text)}</span>`
|
|
70
|
+
todosList.appendChild(div)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function onPlanClick(planName) {
|
|
75
|
+
sendWsMsg({ type: 'load_plan', planName })
|
|
76
|
+
switchTab('todos')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function updatePlansAndTodos() {
|
|
80
|
+
updatePlans()
|
|
81
|
+
updateTodos()
|
|
82
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/* ── DOM References & Shared State ── */
|
|
2
|
+
|
|
3
|
+
// ── DOM References ──
|
|
4
|
+
export const messagesEl = document.getElementById('messages')
|
|
5
|
+
export const chatInput = document.getElementById('chat-input')
|
|
6
|
+
export const sendBtn = document.getElementById('send-btn')
|
|
7
|
+
export const stopBtn = document.getElementById('stop-btn')
|
|
8
|
+
export const statusIndicator = document.getElementById('status-indicator')
|
|
9
|
+
export const modeDisplay = document.getElementById('mode-display')
|
|
10
|
+
export const modelDisplay = document.getElementById('model-display')
|
|
11
|
+
export const tokenIn = document.getElementById('token-in')
|
|
12
|
+
export const tokenOut = document.getElementById('token-out')
|
|
13
|
+
export const tokenCalls = document.getElementById('token-calls')
|
|
14
|
+
export const tokenCache = document.getElementById('token-cache')
|
|
15
|
+
export const balanceDisplay = document.getElementById('balance-display')
|
|
16
|
+
export const balanceSep = document.getElementById('balance-sep')
|
|
17
|
+
export const cwdDisplay = document.getElementById('cwd-display')
|
|
18
|
+
export const connectionOverlay = document.getElementById('connection-overlay')
|
|
19
|
+
export const chatContainer = document.getElementById('chat-container')
|
|
20
|
+
export const sidebar = document.getElementById('sidebar')
|
|
21
|
+
export const sidebarTabs = document.querySelectorAll('.sidebar-tab')
|
|
22
|
+
export const plansPane = document.getElementById('plans-pane')
|
|
23
|
+
export const todosPane = document.getElementById('todos-pane')
|
|
24
|
+
export const plansList = document.getElementById('plans-list')
|
|
25
|
+
export const todosList = document.getElementById('todos-list')
|
|
26
|
+
export const plansPlaceholder = document.getElementById('plans-placeholder')
|
|
27
|
+
export const todosPlaceholder = document.getElementById('todos-placeholder')
|
|
28
|
+
export const slashHint = document.getElementById('slash-hint')
|
|
29
|
+
|
|
30
|
+
// ── Mutable State ──
|
|
31
|
+
export const state = {
|
|
32
|
+
isRunning: false,
|
|
33
|
+
currentMode: 'code',
|
|
34
|
+
currentModel: '',
|
|
35
|
+
currentProvider: '',
|
|
36
|
+
streamingMsgEl: null,
|
|
37
|
+
streamingText: '',
|
|
38
|
+
thinkingEl: null,
|
|
39
|
+
thinkingText: '',
|
|
40
|
+
reconnectAttempts: 0,
|
|
41
|
+
plans: [],
|
|
42
|
+
currentPlanName: '',
|
|
43
|
+
todos: [],
|
|
44
|
+
pendingConfirmResolve: null,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const pendingToolCalls = new Map()
|
|
48
|
+
|
|
49
|
+
export const MAX_RECONNECT_ATTEMPTS = 10
|
|
50
|
+
export const RECONNECT_DELAY = 2000
|
|
51
|
+
|
|
52
|
+
// ── Status Helpers ──
|
|
53
|
+
|
|
54
|
+
export function setInputEnabled(enabled) {
|
|
55
|
+
chatInput.disabled = !enabled
|
|
56
|
+
sendBtn.disabled = !enabled
|
|
57
|
+
sendBtn.classList.toggle('hidden', !enabled)
|
|
58
|
+
stopBtn.classList.toggle('hidden', enabled)
|
|
59
|
+
if (!enabled) {
|
|
60
|
+
stopBtn.disabled = false
|
|
61
|
+
}
|
|
62
|
+
if (enabled) {
|
|
63
|
+
chatInput.focus()
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function setStatus(running) {
|
|
68
|
+
state.isRunning = running
|
|
69
|
+
statusIndicator.textContent = running ? '● running' : '○ idle'
|
|
70
|
+
statusIndicator.className = running ? 'status-running' : 'status-idle'
|
|
71
|
+
setInputEnabled(!running)
|
|
72
|
+
}
|