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