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.
Files changed (102) hide show
  1. package/README.md +326 -326
  2. package/dist/agent/llm.d.ts +4 -0
  3. package/dist/agent/llm.d.ts.map +1 -1
  4. package/dist/agent/prompt-builder.d.ts.map +1 -1
  5. package/dist/agent/prompt-builder.js +13 -9
  6. package/dist/agent/prompt-builder.js.map +1 -1
  7. package/dist/agent/providers/google.d.ts.map +1 -1
  8. package/dist/agent/providers/google.js.map +1 -1
  9. package/dist/agent/providers/openai.d.ts.map +1 -1
  10. package/dist/agent/providers/openai.js +43 -3
  11. package/dist/agent/providers/openai.js.map +1 -1
  12. package/dist/agent/session.d.ts +4 -0
  13. package/dist/agent/session.d.ts.map +1 -1
  14. package/dist/agent/session.js +45 -7
  15. package/dist/agent/session.js.map +1 -1
  16. package/dist/config/index.d.ts.map +1 -1
  17. package/dist/config/index.js +7 -1
  18. package/dist/config/index.js.map +1 -1
  19. package/dist/tools/__tests__/bash.test.js +1 -1
  20. package/dist/tools/__tests__/bash.test.js.map +1 -1
  21. package/dist/tools/__tests__/edit.test.js +875 -371
  22. package/dist/tools/__tests__/edit.test.js.map +1 -1
  23. package/dist/tools/__tests__/grep.test.js +32 -0
  24. package/dist/tools/__tests__/grep.test.js.map +1 -1
  25. package/dist/tools/__tests__/sed.test.d.ts +2 -0
  26. package/dist/tools/__tests__/sed.test.d.ts.map +1 -0
  27. package/dist/tools/__tests__/sed.test.js +228 -0
  28. package/dist/tools/__tests__/sed.test.js.map +1 -0
  29. package/dist/tools/bash.d.ts.map +1 -1
  30. package/dist/tools/bash.js +83 -10
  31. package/dist/tools/bash.js.map +1 -1
  32. package/dist/tools/edit.d.ts +50 -9
  33. package/dist/tools/edit.d.ts.map +1 -1
  34. package/dist/tools/edit.js +328 -158
  35. package/dist/tools/edit.js.map +1 -1
  36. package/dist/tools/grep.d.ts.map +1 -1
  37. package/dist/tools/grep.js +54 -30
  38. package/dist/tools/grep.js.map +1 -1
  39. package/dist/tools/registry.d.ts +0 -6
  40. package/dist/tools/registry.d.ts.map +1 -1
  41. package/dist/tools/registry.js +2 -29
  42. package/dist/tools/registry.js.map +1 -1
  43. package/dist/tools/sed.d.ts +4 -0
  44. package/dist/tools/sed.d.ts.map +1 -0
  45. package/dist/tools/sed.js +121 -0
  46. package/dist/tools/sed.js.map +1 -0
  47. package/dist/web/index.d.ts.map +1 -1
  48. package/dist/web/index.js +26 -3
  49. package/dist/web/index.js.map +1 -1
  50. package/dist/web/public/app.js +5 -855
  51. package/dist/web/public/confirm.js +70 -0
  52. package/dist/web/public/index.html +121 -120
  53. package/dist/web/public/input.js +107 -0
  54. package/dist/web/public/messages.js +395 -0
  55. package/dist/web/public/sidebar.js +82 -0
  56. package/dist/web/public/state.js +72 -0
  57. package/dist/web/public/style.css +996 -949
  58. package/dist/web/public/utils.js +37 -0
  59. package/dist/web/public/websocket.js +267 -0
  60. package/dist/web/public/ws.js +19 -0
  61. package/dist/web/session-bridge.d.ts.map +1 -1
  62. package/dist/web/session-bridge.js +3 -7
  63. package/dist/web/session-bridge.js.map +1 -1
  64. package/package.json +55 -54
  65. package/scripts/copy-native.mjs +24 -0
  66. package/scripts/copy-web.mjs +15 -0
  67. package/src/agent/index.ts +25 -25
  68. package/src/agent/llm.ts +4 -0
  69. package/src/agent/prompt-builder.ts +13 -9
  70. package/src/agent/providers/anthropic.ts +179 -179
  71. package/src/agent/providers/google.ts +210 -211
  72. package/src/agent/providers/ollama.ts +186 -186
  73. package/src/agent/providers/openai.ts +61 -5
  74. package/src/agent/session.ts +59 -3
  75. package/src/config/index.ts +342 -335
  76. package/src/tools/__tests__/bash.test.ts +1 -1
  77. package/src/tools/__tests__/edit.test.ts +981 -385
  78. package/src/tools/__tests__/grep.test.ts +35 -0
  79. package/src/tools/bash.ts +97 -11
  80. package/src/tools/edit.ts +362 -169
  81. package/src/tools/git.ts +76 -76
  82. package/src/tools/grep.ts +57 -30
  83. package/src/tools/registry.ts +2 -31
  84. package/src/tui/index.ts +786 -786
  85. package/src/web/index.ts +25 -3
  86. package/src/web/public/app.js +5 -855
  87. package/src/web/public/confirm.js +70 -0
  88. package/src/web/public/index.html +121 -120
  89. package/src/web/public/input.js +107 -0
  90. package/src/web/public/messages.js +395 -0
  91. package/src/web/public/sidebar.js +82 -0
  92. package/src/web/public/state.js +72 -0
  93. package/src/web/public/style.css +996 -949
  94. package/src/web/public/utils.js +37 -0
  95. package/src/web/public/websocket.js +267 -0
  96. package/src/web/public/ws.js +19 -0
  97. package/src/web/session-bridge.ts +193 -194
  98. package/.claude/settings.local.json +0 -14
  99. package/.kilo/plans/1780064105789-mighty-pixel.md +0 -171
  100. package/.kilo/plans/1780293725888-quick-wizard.md +0 -62
  101. package/.lonny/plan-web-cwd-status.md +0 -38
  102. 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
+ }