local-coding-agent 1.1.0__py3-none-any.whl

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 (101) hide show
  1. local_coding_agent/__init__.py +314 -0
  2. local_coding_agent/__main__.py +5 -0
  3. local_coding_agent/acp_server/__init__.py +38 -0
  4. local_coding_agent/acp_server/_codec.py +95 -0
  5. local_coding_agent/acp_server/_protocol.py +13 -0
  6. local_coding_agent/acp_server/_server.py +663 -0
  7. local_coding_agent/acp_server/_session.py +24 -0
  8. local_coding_agent/acp_server/_tools.py +126 -0
  9. local_coding_agent/ast_compactor.py +130 -0
  10. local_coding_agent/atomizer.py +77 -0
  11. local_coding_agent/benchmark/__init__.py +57 -0
  12. local_coding_agent/benchmark/_cases.py +307 -0
  13. local_coding_agent/benchmark/_judge.py +175 -0
  14. local_coding_agent/benchmark/_oracles.py +157 -0
  15. local_coding_agent/benchmark/_runner.py +259 -0
  16. local_coding_agent/benchmark/_stats.py +22 -0
  17. local_coding_agent/benchmark/_summary.py +143 -0
  18. local_coding_agent/benchmark_oracle_worker.py +169 -0
  19. local_coding_agent/calibration.py +154 -0
  20. local_coding_agent/capability.py +450 -0
  21. local_coding_agent/chat_compaction.py +276 -0
  22. local_coding_agent/cli/__init__.py +12 -0
  23. local_coding_agent/cli/_dispatch.py +377 -0
  24. local_coding_agent/cli/_handlers.py +320 -0
  25. local_coding_agent/cli/_handlers2.py +725 -0
  26. local_coding_agent/cli/_input.py +61 -0
  27. local_coding_agent/cli/_main.py +159 -0
  28. local_coding_agent/cli/_parser.py +327 -0
  29. local_coding_agent/context_manager.py +155 -0
  30. local_coding_agent/controller/__init__.py +18 -0
  31. local_coding_agent/controller/_constants.py +108 -0
  32. local_coding_agent/controller/_controller.py +1008 -0
  33. local_coding_agent/controller/_post_apply.py +216 -0
  34. local_coding_agent/delegator.py +180 -0
  35. local_coding_agent/desktop/__init__.py +8 -0
  36. local_coding_agent/desktop/app.py +100 -0
  37. local_coding_agent/desktop/assets/lucide.min.js +12 -0
  38. local_coding_agent/desktop/assets/tailwind.css +1 -0
  39. local_coding_agent/desktop/client_js.py +1271 -0
  40. local_coding_agent/desktop/components.py +555 -0
  41. local_coding_agent/desktop/server/__init__.py +37 -0
  42. local_coding_agent/desktop/server/_handlers.py +2178 -0
  43. local_coding_agent/desktop/server/_models.py +204 -0
  44. local_coding_agent/desktop/server/_server.py +347 -0
  45. local_coding_agent/desktop/server/_telemetry.py +87 -0
  46. local_coding_agent/desktop/styles.py +76 -0
  47. local_coding_agent/desktop/ui.py +53 -0
  48. local_coding_agent/doctor.py +413 -0
  49. local_coding_agent/hooks/__init__.py +19 -0
  50. local_coding_agent/hooks/_adapters.py +458 -0
  51. local_coding_agent/hooks/_bridge.py +346 -0
  52. local_coding_agent/hooks/_decision.py +26 -0
  53. local_coding_agent/lsp/__init__.py +60 -0
  54. local_coding_agent/lsp/_client.py +322 -0
  55. local_coding_agent/lsp/_fallback.py +297 -0
  56. local_coding_agent/lsp/_manager.py +173 -0
  57. local_coding_agent/lsp/_mock.py +200 -0
  58. local_coding_agent/lsp/_types.py +398 -0
  59. local_coding_agent/mcp_config.py +398 -0
  60. local_coding_agent/mcp_server.py +377 -0
  61. local_coding_agent/memory.py +140 -0
  62. local_coding_agent/mode_router.py +200 -0
  63. local_coding_agent/model_scanner.py +525 -0
  64. local_coding_agent/monitor.py +635 -0
  65. local_coding_agent/observation_policy.py +184 -0
  66. local_coding_agent/ollama_adapter.py +736 -0
  67. local_coding_agent/plan_mode.py +653 -0
  68. local_coding_agent/prescriptions.py +278 -0
  69. local_coding_agent/profiles.py +239 -0
  70. local_coding_agent/repository_tools.py +782 -0
  71. local_coding_agent/ripgrep.py +326 -0
  72. local_coding_agent/semantic_linter.py +122 -0
  73. local_coding_agent/service.py +405 -0
  74. local_coding_agent/session_events.py +684 -0
  75. local_coding_agent/session_query.py +578 -0
  76. local_coding_agent/skill_config.py +426 -0
  77. local_coding_agent/smoke.py +268 -0
  78. local_coding_agent/speculative_racing.py +85 -0
  79. local_coding_agent/spill.py +258 -0
  80. local_coding_agent/stats.py +255 -0
  81. local_coding_agent/stdio.py +132 -0
  82. local_coding_agent/subagent.py +516 -0
  83. local_coding_agent/task.py +90 -0
  84. local_coding_agent/task_store.py +142 -0
  85. local_coding_agent/tasks.py +240 -0
  86. local_coding_agent/terminal/__init__.py +35 -0
  87. local_coding_agent/terminal/_manager.py +156 -0
  88. local_coding_agent/terminal/_process.py +148 -0
  89. local_coding_agent/terminal/_session.py +452 -0
  90. local_coding_agent/terminal/_tools.py +233 -0
  91. local_coding_agent/terminal/_types.py +60 -0
  92. local_coding_agent/tool_call_parser.py +178 -0
  93. local_coding_agent/validators.py +563 -0
  94. local_coding_agent/vram_fit.py +240 -0
  95. local_coding_agent/worker_pool.py +516 -0
  96. local_coding_agent-1.1.0.dist-info/METADATA +202 -0
  97. local_coding_agent-1.1.0.dist-info/RECORD +101 -0
  98. local_coding_agent-1.1.0.dist-info/WHEEL +5 -0
  99. local_coding_agent-1.1.0.dist-info/entry_points.txt +3 -0
  100. local_coding_agent-1.1.0.dist-info/licenses/LICENSE +21 -0
  101. local_coding_agent-1.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1271 @@
1
+ """Client-side JavaScript application logic and state machine for Desktop AI Coding Harness."""
2
+
3
+ from __future__ import annotations
4
+
5
+ DESKTOP_CLIENT_JS = r"""
6
+ const nativeFetch = window.fetch.bind(window);
7
+ window.fetch = (resource, options = {}) => {
8
+ const method = String(options.method || (resource && resource.method) || 'GET').toUpperCase();
9
+ if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
10
+ const headers = new Headers(options.headers || {});
11
+ headers.set('X-Desktop-Token', window.DESKTOP_MUTATION_TOKEN || '');
12
+ options = { ...options, headers };
13
+ }
14
+ return nativeFetch(resource, options);
15
+ };
16
+
17
+ function safeCreateIcons() {
18
+ if (typeof lucide !== 'undefined' && lucide.createIcons) {
19
+ try { lucide.createIcons(); } catch (e) { }
20
+ }
21
+ }
22
+
23
+ function spinnerSvg(size = 14, extraClass = '') {
24
+ return `<svg class="spinner-svg ${extraClass}" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>`;
25
+ }
26
+
27
+ let SESSIONS = [];
28
+ let activeSession = null;
29
+ let activeSessionFilter = 'all';
30
+ let activeProfile = 'qwen2.5-coder';
31
+ let activeCtxOverride = null;
32
+ let SELECTED_MODE = 'hybrid';
33
+ let modelProviders = {}; // model id -> backend ('ollama' | 'llama_server'), from /api/models
34
+ let rollbackAvailable = false;
35
+
36
+ function updateProposalControls(session = null) {
37
+ const hasPatch = Boolean(session && session.patch && session.patch.trim());
38
+ const evidenceVerified = Boolean(session && session.evidence_verified === true);
39
+ ['btnApply', 'btnDelegatedApply'].forEach(id => {
40
+ const button = document.getElementById(id);
41
+ if (button) button.disabled = !hasPatch;
42
+ });
43
+ ['btnRollback', 'btnDelegatedRollback'].forEach(id => {
44
+ const button = document.getElementById(id);
45
+ if (button) button.disabled = !rollbackAvailable;
46
+ });
47
+ const status = document.getElementById('delegatedStatusTag');
48
+ if (status) status.textContent = hasPatch ? 'PROPOSAL READY' : 'NO PROPOSAL';
49
+ const evidence = document.getElementById('delegatedEvidenceTag');
50
+ if (evidence) evidence.textContent = evidenceVerified ? 'Evidence: Verified by Test Runner' : 'Evidence: Not run';
51
+ const oracle = document.getElementById('diffEvidenceStatus');
52
+ if (oracle) {
53
+ const label = oracle.querySelector('span');
54
+ if (label) label.textContent = evidenceVerified ? 'Oracles: External checks passed' : 'Oracles: Not run';
55
+ }
56
+ }
57
+
58
+ function resolveProviderFromRegistry(val) {
59
+ if (Object.hasOwn(modelProviders, val)) return modelProviders[val];
60
+ // Fallback heuristic for profile ids not present in the registry.
61
+ const v = val.toLowerCase();
62
+ if (v.includes('ling') || v.includes('llama-server') || v.endsWith('.gguf')) return 'llama_server';
63
+ return 'ollama';
64
+ }
65
+
66
+ function setMode(mode, btn) {
67
+ SELECTED_MODE = mode;
68
+ document.querySelectorAll('.mode-btn').forEach(b => {
69
+ b.classList.remove('bg-[var(--bg-card)]', 'text-[var(--text-main)]', 'shadow-sm', 'border', 'border-[var(--border-main)]');
70
+ b.classList.add('text-[var(--text-muted)]');
71
+ });
72
+ if (btn) {
73
+ btn.classList.add('bg-[var(--bg-card)]', 'text-[var(--text-main)]', 'shadow-sm', 'border', 'border-[var(--border-main)]');
74
+ btn.classList.remove('text-[var(--text-muted)]');
75
+ }
76
+ const badge = document.getElementById('modeBadge');
77
+ if (badge) badge.textContent = `Mode: ${mode === 'hybrid' ? 'auto' : mode}`;
78
+ const welcomeBadge = document.getElementById('welcomeModeLabel');
79
+ if (welcomeBadge) welcomeBadge.textContent = `Mode: ${mode === 'hybrid' ? 'auto' : mode}`;
80
+ }
81
+
82
+ function openModal(modalId) {
83
+ closeModals();
84
+ const m = document.getElementById(modalId);
85
+ if (m) m.classList.remove('hidden');
86
+ if (modalId === 'modelModal') {
87
+ const statusLabel = document.getElementById('labelModelLoadStatus');
88
+ if (statusLabel) statusLabel.innerHTML = '';
89
+ fetchAndPopulateModels();
90
+ } else if (modalId === 'serverModal') {
91
+ pollStatus();
92
+ }
93
+ safeCreateIcons();
94
+ }
95
+
96
+ function closeModals() {
97
+ document.querySelectorAll('.modal-dialog').forEach(m => m.classList.add('hidden'));
98
+ }
99
+
100
+ async function fetchAndPopulateModels() {
101
+ const refreshBtn = document.getElementById('btnRefreshModels');
102
+ const oldRefreshHtml = refreshBtn ? refreshBtn.innerHTML : '';
103
+ if (refreshBtn) {
104
+ refreshBtn.innerHTML = `${spinnerSvg(11, 'text-cyan-400')} <span class="text-cyan-400">Loading Registry...</span>`;
105
+ refreshBtn.disabled = true;
106
+ }
107
+ try {
108
+ const res = await fetch('/api/models');
109
+ if (!res.ok) return;
110
+ const data = await res.json();
111
+ const select = document.getElementById('modalProfileSelect');
112
+ if (!select) return;
113
+
114
+ const currentVal = select.value || activeProfile;
115
+ select.innerHTML = '';
116
+ modelProviders = {};
117
+
118
+ // 1. Ollama Installed Models (backend reachability is reported honestly)
119
+ const ollamaModels = (data.backends && data.backends.ollama && data.backends.ollama.models) || [];
120
+ const ollamaOnline = Boolean(data.backends && data.backends.ollama && data.backends.ollama.online);
121
+ // 3. llama-server Active Models
122
+ const llamaModels = (data.backends && data.backends.llama_server && data.backends.llama_server.models) || [];
123
+
124
+ // Record provider for every known id so the top-bar backend label and
125
+ // online-dot logic read reality instead of guessing from the name.
126
+ const recordProviders = (ids, provider) => {
127
+ (ids || []).forEach(id => { if (id) modelProviders[id] = provider; });
128
+ };
129
+ recordProviders(ollamaModels, 'ollama');
130
+ recordProviders(llamaModels, 'llama_server');
131
+ (data.profiles || []).forEach(p => { if (p && p.name) modelProviders[p.name] = p.provider === 'openai' ? 'llama_server' : 'ollama'; });
132
+
133
+ if (ollamaModels.length > 0) {
134
+ const optGroup = document.createElement('optgroup');
135
+ optGroup.label = ollamaOnline
136
+ ? '✅ Installed in Ollama (Ready to Use)'
137
+ : '⚠️ Installed in Ollama (backend offline — start Ollama in Local Inference Servers)';
138
+ ollamaModels.forEach(m => {
139
+ const opt = document.createElement('option');
140
+ opt.value = m;
141
+ opt.textContent = `Ollama: ${m}`;
142
+ optGroup.appendChild(opt);
143
+ });
144
+ select.appendChild(optGroup);
145
+ }
146
+
147
+ // 2. Local GGUF Models from Persistent Registry (Discovered across system drives)
148
+ const localGgufs = (data.backends && data.backends.local_gguf && data.backends.local_gguf.models) || [];
149
+ recordProviders(localGgufs.map(g => g.id || g.name), 'llama_server');
150
+ if (localGgufs.length > 0) {
151
+ const optGroup = document.createElement('optgroup');
152
+ optGroup.label = '⚡ Local GGUF → launches llama-server (:8080)';
153
+ localGgufs.forEach(g => {
154
+ const opt = document.createElement('option');
155
+ opt.value = g.id || g.name;
156
+ opt.textContent = `GGUF → llama-server: ${g.display_name} (${g.size_gb} GB)`;
157
+ optGroup.appendChild(opt);
158
+ });
159
+ select.appendChild(optGroup);
160
+ }
161
+
162
+ // 3. llama-server Active Models
163
+ if (llamaModels.length > 0) {
164
+ const optGroup = document.createElement('optgroup');
165
+ optGroup.label = '⚡ Active in llama-server (:8080)';
166
+ llamaModels.forEach(m => {
167
+ const opt = document.createElement('option');
168
+ opt.value = m;
169
+ opt.textContent = `llama-server: ${m}`;
170
+ optGroup.appendChild(opt);
171
+ });
172
+ select.appendChild(optGroup);
173
+ }
174
+
175
+ // 4. Predefined System Profiles (Not installed locally)
176
+ if (data.profiles && data.profiles.length > 0) {
177
+ const optGroup = document.createElement('optgroup');
178
+ optGroup.label = '📦 Predefined Profiles (Requires Pulling)';
179
+ data.profiles.forEach(p => {
180
+ if (!ollamaModels.includes(p.name) && !llamaModels.includes(p.name) && !localGgufs.some(g => g.name === p.name)) {
181
+ const opt = document.createElement('option');
182
+ opt.value = p.name;
183
+ opt.textContent = `${p.provider === 'openai' ? 'llama-server' : 'Ollama'}: ${p.name} (not installed)`;
184
+ opt.disabled = true;
185
+ optGroup.appendChild(opt);
186
+ }
187
+ });
188
+ select.appendChild(optGroup);
189
+ }
190
+
191
+ // Restore active selection or choose best default installed model
192
+ const currentOption = [...select.options].find(o => o.value === currentVal && !o.disabled);
193
+ if (currentOption) {
194
+ select.value = currentVal;
195
+ changeProfile(currentVal);
196
+ } else {
197
+ const firstAvailable = [...select.options].find(o => !o.disabled);
198
+ if (firstAvailable) {
199
+ select.value = firstAvailable.value;
200
+ changeProfile(firstAvailable.value);
201
+ } else {
202
+ const placeholder = document.createElement('option');
203
+ placeholder.value = '';
204
+ placeholder.textContent = 'No local models available';
205
+ placeholder.disabled = true;
206
+ placeholder.selected = true;
207
+ select.prepend(placeholder);
208
+ activeProfile = '';
209
+ document.getElementById('telemetryModel').textContent = 'No model';
210
+ }
211
+ }
212
+ } catch (e) {
213
+ } finally {
214
+ if (refreshBtn) {
215
+ refreshBtn.innerHTML = oldRefreshHtml || '<i data-lucide="refresh-cw" class="w-2.5 h-2.5"></i> <span>Refresh List</span>';
216
+ refreshBtn.disabled = false;
217
+ safeCreateIcons();
218
+ }
219
+ }
220
+ }
221
+
222
+ async function triggerModelScan(deep = true) {
223
+ const btn = document.getElementById('btnDeepScan');
224
+ const oldHtml = btn ? btn.innerHTML : '';
225
+ if (btn) {
226
+ btn.innerHTML = `<span class="inline-flex items-center gap-1">${spinnerSvg(10, 'text-zinc-950')} Scanning system...</span>`;
227
+ btn.disabled = true;
228
+ }
229
+ showToast('Running deep drive model scanner...');
230
+ try {
231
+ const res = await fetch('/api/models/scan', {
232
+ method: 'POST',
233
+ headers: {'Content-Type': 'application/json'},
234
+ body: JSON.stringify({ deep })
235
+ });
236
+ const data = await res.json();
237
+ showToast(`✓ Discovered ${data.total_models || data.models?.length || 0} models across drives`);
238
+ fetchAndPopulateModels();
239
+ } catch (e) {
240
+ showToast('Error during model scan');
241
+ } finally {
242
+ if (btn) {
243
+ btn.innerHTML = oldHtml || 'Deep Drive Scan';
244
+ btn.disabled = false;
245
+ }
246
+ safeCreateIcons();
247
+ }
248
+ }
249
+
250
+ async function addCustomModelFolder() {
251
+ const input = document.getElementById('inputCustomDir');
252
+ const pathVal = input ? input.value.trim() : '';
253
+ if (!pathVal) {
254
+ showToast('Please enter a folder path');
255
+ return;
256
+ }
257
+ try {
258
+ const res = await fetch('/api/models/add_dir', {
259
+ method: 'POST',
260
+ headers: {'Content-Type': 'application/json'},
261
+ body: JSON.stringify({ path: pathVal })
262
+ });
263
+ const data = await res.json();
264
+ if (data.status === 'added' || data.status === 'already_present') {
265
+ showToast(`✓ Added custom folder: ${pathVal}`);
266
+ if (input) input.value = '';
267
+ triggerModelScan(false);
268
+ } else {
269
+ showToast(`Issue adding folder: ${data.error || 'failed'}`);
270
+ }
271
+ } catch (e) {
272
+ showToast('Error saving custom model directory');
273
+ }
274
+ }
275
+
276
+ function renderUnifiedDiff(rawDiff) {
277
+ if (!rawDiff || !rawDiff.trim()) {
278
+ return '<div class="p-8 text-center text-zinc-500 text-xs">No active diff proposal. Run a prompt or select a task session.</div>';
279
+ }
280
+
281
+ const lines = rawDiff.split('\n');
282
+ let html = '<div class="space-y-0.5 font-mono text-[11px] select-text">';
283
+ let oldLine = 0;
284
+ let newLine = 0;
285
+
286
+ for (const line of lines) {
287
+ if (line.startsWith('---') || line.startsWith('+++') || line.startsWith('diff --git')) {
288
+ html += `<div class="px-2 py-0.5 text-zinc-500 font-semibold text-[10px] bg-[var(--bg-card-subtle)]">${escapeHtml(line)}</div>`;
289
+ } else if (line.startsWith('@@')) {
290
+ const match = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
291
+ if (match) {
292
+ oldLine = parseInt(match[1], 10);
293
+ newLine = parseInt(match[2], 10);
294
+ }
295
+ html += `<div class="px-2 py-0.5 text-cyan-500/80 bg-cyan-500/5 text-[10px] font-semibold">${escapeHtml(line)}</div>`;
296
+ } else if (line.startsWith('-')) {
297
+ html += `<div class="flex px-2 py-0.5 diff-del-bg rounded-xs"><span class="w-7 shrink-0 text-right pr-2 select-none num-tabular text-red-400/60">${oldLine ? oldLine++ : ''} -</span><span class="whitespace-pre">${escapeHtml(line.slice(1))}</span></div>`;
298
+ } else if (line.startsWith('+')) {
299
+ html += `<div class="flex px-2 py-0.5 diff-add-bg rounded-xs"><span class="w-7 shrink-0 text-right pr-2 select-none num-tabular text-emerald-400/60">${newLine ? newLine++ : ''} +</span><span class="whitespace-pre">${escapeHtml(line.slice(1))}</span></div>`;
300
+ } else {
301
+ html += `<div class="flex px-2 py-0.5 text-zinc-400"><span class="w-7 shrink-0 text-right pr-2 select-none num-tabular text-zinc-600">${oldLine ? oldLine++ : ''}</span><span class="whitespace-pre">${escapeHtml(line.startsWith(' ') ? line.slice(1) : line)}</span></div>`;
302
+ if (newLine) newLine++;
303
+ }
304
+ }
305
+ html += '</div>';
306
+ return html;
307
+ }
308
+
309
+ async function loadSessions() {
310
+ try {
311
+ const res = await fetch('/api/sessions');
312
+ if (res.ok) {
313
+ const data = await res.json();
314
+ SESSIONS = data.sessions || [];
315
+ }
316
+ } catch (e) {
317
+ SESSIONS = [];
318
+ }
319
+
320
+ renderSessions(activeSessionFilter);
321
+ if (SESSIONS.length > 0) {
322
+ selectSessionById(SESSIONS[0].id);
323
+ }
324
+ }
325
+
326
+ function renderSessions(filter = activeSessionFilter) {
327
+ const list = document.getElementById('sessionList');
328
+ list.innerHTML = '';
329
+ const filtered = SESSIONS.filter(s => filter === 'all' || s.type === filter);
330
+ document.getElementById('sessionCounter').textContent = `${filtered.length} Sessions`;
331
+
332
+ if (filtered.length === 0) {
333
+ list.innerHTML = `
334
+ <div class="p-4 text-center text-zinc-500 font-mono text-[10px] space-y-1">
335
+ <div>No ${filter !== 'all' ? filter : ''} sessions yet</div>
336
+ <div class="text-[9px] text-zinc-600">Type a goal below to start!</div>
337
+ </div>
338
+ `;
339
+ return;
340
+ }
341
+
342
+ filtered.forEach(s => {
343
+ const isSelected = activeSession && activeSession.id === s.id;
344
+ const card = document.createElement('div');
345
+ card.className = `session-card p-2 rounded border cursor-pointer transition ${
346
+ isSelected
347
+ ? 'border-cyan-500/40 bg-[var(--bg-card)] text-[var(--text-main)] shadow-xs'
348
+ : 'border-transparent hover:border-[var(--border-main)] hover:bg-[var(--bg-card)] text-[var(--text-muted)]'
349
+ }`;
350
+ card.onclick = () => selectSessionById(s.id);
351
+
352
+ const badgeClass = s.type === 'user'
353
+ ? 'bg-blue-500/10 border-blue-500/30 text-blue-500'
354
+ : s.agent === 'Codex'
355
+ ? 'bg-purple-500/10 border-purple-500/30 text-purple-500'
356
+ : 'bg-cyan-500/10 border-cyan-500/30 text-cyan-500';
357
+
358
+ const badgeLabel = s.type === 'user' ? 'USER' : `AGENT: ${s.agent || 'MCP'}`;
359
+
360
+ card.innerHTML = `
361
+ <div class="flex items-center justify-between mb-0.5">
362
+ <span class="inline-flex items-center gap-1 px-1 py-0.1 rounded border font-mono text-[9px] font-semibold tracking-wider ${badgeClass}">
363
+ ${badgeLabel}
364
+ </span>
365
+ <span class="text-[9px] font-mono text-zinc-500 num-tabular">${s.time || 'Active'}</span>
366
+ </div>
367
+ <div class="text-[11px] font-medium truncate text-[var(--text-main)]">${escapeHtml(s.title || s.goal || 'Session')}</div>
368
+ <div class="text-[10px] text-zinc-500 font-mono mt-0.5 flex items-center justify-between">
369
+ <span>${escapeHtml(s.file || 'workspace')}</span>
370
+ <span class="${s.status && (s.status.includes('Ready') || s.status === 'Verified') ? 'text-emerald-500 font-semibold' : 'text-zinc-500'}">${escapeHtml(s.status || 'Active')}</span>
371
+ </div>
372
+ `;
373
+ list.appendChild(card);
374
+ });
375
+ safeCreateIcons();
376
+ }
377
+
378
+ function selectSessionById(id) {
379
+ const found = SESSIONS.find(s => s.id === id);
380
+ if (!found) return;
381
+ activeSession = found;
382
+ renderSessions(activeSessionFilter);
383
+ updateProposalControls(found);
384
+
385
+ if (found.type === 'agent') {
386
+ switchTab('delegated');
387
+ document.getElementById('delegatedTaskId').textContent = found.taskId || found.id;
388
+ document.getElementById('delegatedGoal').textContent = found.goal || found.title;
389
+ document.getElementById('delegatedFiles').textContent = found.file || 'src/main.py';
390
+ document.getElementById('delegatedChecks').textContent = (found.checks && found.checks.join(', ')) || 'pytest';
391
+ document.getElementById('delegatedFileName').textContent = found.file || 'src/main.py';
392
+ document.getElementById('delegatedDiffContent').innerHTML = renderUnifiedDiff(found.patch);
393
+ } else {
394
+ switchTab('chat');
395
+ renderSessionTranscript(found);
396
+ document.getElementById('diffFileName').textContent = found.file || 'No active diff';
397
+ document.getElementById('diffStatsTag').textContent = found.patch ? 'Diff Ready' : 'Empty';
398
+ document.getElementById('diffContentArea').innerHTML = renderUnifiedDiff(found.patch);
399
+ }
400
+ }
401
+
402
+ function onCtxOverrideChange(val) {
403
+ const n = parseInt(val, 10);
404
+ if (String(val).trim() && (!Number.isFinite(n) || n < 512)) {
405
+ activeCtxOverride = null;
406
+ showToast('Context override must be at least 512 tokens');
407
+ return;
408
+ }
409
+ activeCtxOverride = Number.isFinite(n) ? n : null;
410
+ const ctxEl = document.getElementById('profCtx');
411
+ if (ctxEl) ctxEl.textContent = activeCtxOverride ? `${activeCtxOverride} tokens (override)` : `${ctxEl.textContent.replace(/ \(override\)$/, '')}`;
412
+ showToast(activeCtxOverride ? `Context override: ${activeCtxOverride} tokens (applies on next prompt)` : 'Context override cleared');
413
+ }
414
+
415
+ function changeProfile(val) {
416
+ const select = document.getElementById('modalProfileSelect');
417
+ const option = select ? [...select.options].find(item => item.value === val) : null;
418
+ if (!val || (option && option.disabled)) {
419
+ showToast('Select an installed local model first');
420
+ return;
421
+ }
422
+ activeProfile = val;
423
+ if (select && select.value !== val) {
424
+ select.value = val;
425
+ }
426
+ document.getElementById('telemetryModel').textContent = val;
427
+ const isLlama = resolveProviderFromRegistry(val) === 'llama_server';
428
+ document.getElementById('backendLabel').textContent = isLlama ? 'LLAMA-SERVER' : 'OLLAMA';
429
+ const welcome = document.getElementById('welcomeModelLabel');
430
+ if (welcome) welcome.textContent = val;
431
+
432
+ const provEl = document.getElementById('profProvider');
433
+ const endEl = document.getElementById('profEndpoint');
434
+ if (provEl) provEl.textContent = isLlama ? 'llama-server' : 'ollama';
435
+ if (endEl) endEl.textContent = isLlama ? 'http://127.0.0.1:8080' : 'http://127.0.0.1:11434';
436
+
437
+ showToast(`Active profile: ${val}`);
438
+ }
439
+
440
+ async function pollStatus() {
441
+ try {
442
+ const res = await fetch('/api/status');
443
+ if (res.ok) {
444
+ const data = await res.json();
445
+ if (data.workspace_name) document.getElementById('workspaceName').textContent = data.workspace_name;
446
+ if (data.git_branch) document.getElementById('workspaceBranch').textContent = `• ${data.git_branch}`;
447
+ const wsPath = document.getElementById('setWorkspacePath');
448
+ if (wsPath && data.workspace) wsPath.textContent = data.workspace;
449
+
450
+ // Real GPU & VRAM from nvidia-smi
451
+ if (data.vram) {
452
+ const v = data.vram;
453
+ const telemetryKnown = Number.isFinite(v.used_gb) && Number.isFinite(v.total_gb);
454
+ document.getElementById('telemetryVram').textContent = telemetryKnown ? `${v.used_gb}/${v.total_gb}G` : 'VRAM ?';
455
+
456
+ const devName = document.getElementById('gpuDeviceName');
457
+ if (devName) devName.textContent = v.gpu_name || 'GPU telemetry unavailable';
458
+
459
+ const vText = document.getElementById('gpuVramText');
460
+ if (vText) vText.textContent = telemetryKnown ? `${v.used_gb} / ${v.total_gb} GB (${v.percent}%)` : 'VRAM unavailable';
461
+
462
+ const vBar = document.getElementById('gpuVramBar');
463
+ if (vBar) vBar.style.width = telemetryKnown && Number.isFinite(v.percent) ? `${v.percent}%` : '0%';
464
+
465
+ const gLoad = document.getElementById('gpuLoadPct');
466
+ if (gLoad) gLoad.textContent = Number.isFinite(v.utilization_pct) ? `${v.utilization_pct}%` : '—';
467
+
468
+ const gTemp = document.getElementById('gpuTemp');
469
+ if (gTemp) gTemp.textContent = Number.isFinite(v.temp_c) ? `${v.temp_c}°C` : '—';
470
+ }
471
+
472
+ // Real Server status
473
+ const ollamaOnline = Boolean(data.servers && data.servers.ollama && data.servers.ollama.online);
474
+ const llamaOnline = Boolean(data.servers && data.servers.llama_server && data.servers.llama_server.online);
475
+ const llamaStatus = (data.servers && data.servers.llama_server && data.servers.llama_server.status) || (llamaOnline ? 'ready' : 'offline');
476
+
477
+ const dotOllama = document.getElementById('dotOllama');
478
+ const dotLlama = document.getElementById('dotLlama');
479
+ const labelOllama = document.getElementById('labelOllamaStatus');
480
+ const labelLlama = document.getElementById('labelLlamaStatus');
481
+
482
+ if (dotOllama) dotOllama.className = `w-2 h-2 rounded-full ${ollamaOnline ? 'bg-emerald-500' : 'bg-red-500'}`;
483
+ if (labelOllama) {
484
+ labelOllama.textContent = ollamaOnline ? 'Online (Port 11434)' : 'Offline';
485
+ }
486
+
487
+ if (dotLlama) {
488
+ dotLlama.className = `w-2 h-2 rounded-full ${llamaStatus === 'loading' ? 'bg-amber-500 animate-pulse' : (llamaOnline ? 'bg-emerald-500' : 'bg-red-500')}`;
489
+ }
490
+ if (labelLlama) {
491
+ if (llamaStatus === 'loading') {
492
+ labelLlama.innerHTML = `<span class="text-amber-400 font-medium">Loading weights into VRAM (:8080)...</span>`;
493
+ } else {
494
+ labelLlama.textContent = llamaOnline ? 'Online (Port 8080)' : 'Offline';
495
+ }
496
+ }
497
+
498
+ const isCurrentLlama = resolveProviderFromRegistry(activeProfile) === 'llama_server';
499
+ const isCurrentOnline = isCurrentLlama ? llamaOnline : ollamaOnline;
500
+
501
+ const topDot = document.getElementById('serverLiveDot');
502
+ const topText = document.getElementById('serverLiveText');
503
+ if (topDot) {
504
+ if (isCurrentLlama && llamaStatus === 'loading') {
505
+ topDot.className = 'w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse';
506
+ } else {
507
+ topDot.className = `w-1.5 h-1.5 rounded-full ${isCurrentOnline ? 'bg-emerald-500' : 'bg-red-500'}`;
508
+ }
509
+ }
510
+ if (topText) {
511
+ if (isCurrentLlama && llamaStatus === 'loading') {
512
+ topText.textContent = 'Loading...';
513
+ } else {
514
+ topText.textContent = isCurrentOnline ? 'Online' : 'Offline';
515
+ }
516
+ }
517
+ }
518
+ } catch (e) {}
519
+ }
520
+
521
+ async function warmupActiveModel() {
522
+ showToast(`Preloading ${activeProfile} into VRAM...`);
523
+ const btn1 = document.getElementById('btnPreloadModel');
524
+ const btn2 = document.getElementById('btnModalPreload');
525
+ const statusLabel = document.getElementById('labelModelLoadStatus');
526
+ const old1 = btn1 ? btn1.innerHTML : '';
527
+ const old2 = btn2 ? btn2.innerHTML : '';
528
+
529
+ if (btn1) {
530
+ btn1.innerHTML = `<span class="inline-flex items-center gap-1.5 text-cyan-400">${spinnerSvg(11, 'text-cyan-400')} Loading into VRAM...</span>`;
531
+ btn1.disabled = true;
532
+ }
533
+ if (btn2) {
534
+ btn2.innerHTML = `<span class="inline-flex items-center gap-1.5 text-cyan-400">${spinnerSvg(11, 'text-cyan-400')} Loading...</span>`;
535
+ btn2.disabled = true;
536
+ }
537
+ if (statusLabel) {
538
+ statusLabel.innerHTML = `<span class="text-cyan-400 inline-flex items-center gap-1">${spinnerSvg(10, 'text-cyan-400')} Loading model into VRAM...</span>`;
539
+ }
540
+
541
+ try {
542
+ const res = await fetch('/api/model/load', {
543
+ method: 'POST',
544
+ headers: {'Content-Type': 'application/json'},
545
+ body: JSON.stringify({ model: activeProfile, ...(activeCtxOverride ? { num_ctx: activeCtxOverride } : {}) })
546
+ });
547
+ const data = await res.json();
548
+ if (data.status === 'loaded') {
549
+ const back = data.backend === 'llama_server' ? 'via llama-server' : (data.backend === 'ollama' ? 'via Ollama' : '');
550
+ showToast(`✓ Model ${data.model || activeProfile} loaded ${back}`.trim());
551
+ if (statusLabel) statusLabel.innerHTML = `<span class="text-emerald-400">✓ Loaded in VRAM${data.backend === 'llama_server' ? ' (llama-server)' : ''}</span>`;
552
+ } else {
553
+ showToast(`⚠️ Load issue: ${data.error || 'failed'}`);
554
+ if (statusLabel) statusLabel.innerHTML = `<span class="text-amber-400">Offline / Error</span>`;
555
+ }
556
+ pollStatus();
557
+ } catch (e) {
558
+ showToast('Error loading model');
559
+ if (statusLabel) statusLabel.innerHTML = `<span class="text-red-400">Connection error</span>`;
560
+ } finally {
561
+ if (btn1) {
562
+ btn1.innerHTML = old1 || '⚡ Preload Model';
563
+ btn1.disabled = false;
564
+ }
565
+ if (btn2) {
566
+ btn2.innerHTML = old2 || '⚡ Load into VRAM';
567
+ btn2.disabled = false;
568
+ }
569
+ safeCreateIcons();
570
+ }
571
+ }
572
+
573
+ async function unloadAllVram() {
574
+ if (!confirm('Unload every managed model from VRAM?')) return;
575
+ const btn = document.getElementById('btnUnloadVram');
576
+ const oldHtml = btn ? btn.innerHTML : '';
577
+ if (btn) {
578
+ btn.innerHTML = `<span class="inline-flex items-center gap-1.5 text-red-400">${spinnerSvg(11, 'text-red-400')} Ejecting...</span>`;
579
+ btn.disabled = true;
580
+ }
581
+ showToast('Ejecting models from VRAM...');
582
+ try {
583
+ const res = await fetch('/api/model/unload_all', { method: 'POST' });
584
+ const data = await res.json();
585
+ if (data.status === 'unloaded_all') {
586
+ showToast('✓ All managed models unloaded from VRAM');
587
+ pollStatus();
588
+ } else {
589
+ showToast(`Unload failed: ${data.error || data.status || 'unknown error'}`);
590
+ }
591
+ } catch (e) {
592
+ showToast('Error unloading models');
593
+ } finally {
594
+ if (btn) {
595
+ btn.innerHTML = oldHtml || 'Eject ALL from VRAM';
596
+ btn.disabled = false;
597
+ }
598
+ safeCreateIcons();
599
+ }
600
+ }
601
+
602
+ function toggleTheme() {
603
+ const html = document.documentElement;
604
+ html.classList.toggle('dark');
605
+ safeCreateIcons();
606
+ showToast(`Switched to ${html.classList.contains('dark') ? 'Dark' : 'Light'} theme`);
607
+ }
608
+
609
+ function switchTab(tabId) {
610
+ document.querySelectorAll('.tab-btn').forEach(btn => {
611
+ btn.classList.remove('bg-[var(--bg-card)]', 'text-[var(--text-main)]', 'shadow-sm', 'border', 'border-[var(--border-main)]');
612
+ btn.classList.add('text-[var(--text-muted)]');
613
+ });
614
+ document.querySelectorAll('.tab-view').forEach(view => view.classList.add('hidden'));
615
+
616
+ if (tabId === 'chat') {
617
+ const btn = document.getElementById('tab-btn-chat');
618
+ btn.classList.add('bg-[var(--bg-card)]', 'text-[var(--text-main)]', 'shadow-sm', 'border', 'border-[var(--border-main)]');
619
+ btn.classList.remove('text-[var(--text-muted)]');
620
+ document.getElementById('view-chat').classList.remove('hidden');
621
+ } else if (tabId === 'delegated') {
622
+ const btn = document.getElementById('tab-btn-delegated');
623
+ btn.classList.add('bg-[var(--bg-card)]', 'text-[var(--text-main)]', 'shadow-sm', 'border', 'border-[var(--border-main)]');
624
+ btn.classList.remove('text-[var(--text-muted)]');
625
+ document.getElementById('view-delegated').classList.remove('hidden');
626
+ }
627
+ }
628
+
629
+ function toggleSidebar() {
630
+ const sb = document.getElementById('sessionSidebar');
631
+ sb.classList.toggle('hidden');
632
+ }
633
+
634
+ function showToast(msg) {
635
+ const toast = document.getElementById('toast');
636
+ const toastText = document.getElementById('toastText');
637
+ toastText.textContent = msg;
638
+ toast.classList.remove('opacity-0', 'translate-y-2');
639
+ toast.classList.add('opacity-100', 'translate-y-0');
640
+ setTimeout(() => {
641
+ toast.classList.remove('opacity-100', 'translate-y-0');
642
+ toast.classList.add('opacity-0', 'translate-y-2');
643
+ }, 2500);
644
+ }
645
+
646
+ async function startNewSession() {
647
+ // Unique across rapid clicks in the same millisecond.
648
+ const newId = `sess-${(window.crypto && crypto.randomUUID) ? crypto.randomUUID() : Date.now() + '-' + Math.random().toString(36).slice(2)}`;
649
+ const newSession = {
650
+ id: newId,
651
+ type: 'user',
652
+ title: 'New coding task',
653
+ file: 'workspace',
654
+ patch: '',
655
+ checks: [],
656
+ status: 'Draft',
657
+ time: 'Just now'
658
+ };
659
+ try {
660
+ await fetch('/api/sessions', {
661
+ method: 'POST',
662
+ headers: { 'Content-Type': 'application/json' },
663
+ body: JSON.stringify(newSession)
664
+ });
665
+ } catch (e) {}
666
+
667
+ SESSIONS.unshift(newSession);
668
+ activeSession = newSession;
669
+ filterSessions('all');
670
+ updateProposalControls(newSession);
671
+ switchTab('chat');
672
+ const messages = document.getElementById('chatMessages');
673
+ if (messages) messages.innerHTML = '<div class="p-8 text-center text-zinc-500 text-xs">New session. Enter a prompt to begin.</div>';
674
+ document.getElementById('chatInput').focus();
675
+ showToast('Started new interactive chat session');
676
+ }
677
+
678
+ function filterSessions(type, evt) {
679
+ activeSessionFilter = type;
680
+ document.querySelectorAll('.filter-chip').forEach(c => {
681
+ c.classList.remove('bg-[var(--bg-card)]', 'border', 'border-[var(--border-main)]', 'text-[var(--text-main)]', 'font-semibold');
682
+ c.classList.add('text-zinc-500');
683
+ });
684
+ const selectedChip = (evt && evt.currentTarget) || document.querySelector(`[data-session-filter="${type}"]`);
685
+ if (selectedChip) {
686
+ selectedChip.classList.add('bg-[var(--bg-card)]', 'border', 'border-[var(--border-main)]', 'text-[var(--text-main)]', 'font-semibold');
687
+ selectedChip.classList.remove('text-zinc-500');
688
+ }
689
+ renderSessions(activeSessionFilter);
690
+ }
691
+
692
+ function copyActiveDiff() {
693
+ if (activeSession && activeSession.patch) {
694
+ navigator.clipboard.writeText(activeSession.patch).then(() => {
695
+ showToast('✓ Raw unified diff copied to clipboard!');
696
+ }).catch(() => {
697
+ showToast('Could not access clipboard');
698
+ });
699
+ } else {
700
+ showToast('No active diff to copy');
701
+ }
702
+ }
703
+
704
+ let lastCheckCommand = '';
705
+
706
+ function resolveCheckCommands(candidate) {
707
+ if (Array.isArray(candidate) && candidate.length) return candidate;
708
+ // No declared check means no external evidence: ask instead of guessing.
709
+ const entered = prompt('Check command to verify this patch (e.g. pytest tests/)', lastCheckCommand);
710
+ if (!entered || !entered.trim()) return null;
711
+ lastCheckCommand = entered.trim();
712
+ return [lastCheckCommand];
713
+ }
714
+
715
+ async function applyProposalAction() {
716
+ if (!activeSession || !activeSession.patch) {
717
+ showToast('No patch proposal available to apply');
718
+ return;
719
+ }
720
+ const checks = resolveCheckCommands(activeSession.checks);
721
+ if (!checks) {
722
+ showToast('Apply cancelled: a targeted check command is required');
723
+ return;
724
+ }
725
+ const btn1 = document.getElementById('btnApply');
726
+ const btn2 = document.getElementById('btnDelegatedApply');
727
+ const old1 = btn1 ? btn1.innerHTML : '';
728
+ const old2 = btn2 ? btn2.innerHTML : '';
729
+ if (btn1) {
730
+ btn1.innerHTML = `<span class="inline-flex items-center gap-1 text-zinc-950 font-semibold">${spinnerSvg(11, 'text-zinc-950')} Applying...</span>`;
731
+ btn1.disabled = true;
732
+ }
733
+ if (btn2) {
734
+ btn2.innerHTML = `<span class="inline-flex items-center gap-1 text-zinc-950 font-semibold">${spinnerSvg(11, 'text-zinc-950')} Applying &amp; Verifying...</span>`;
735
+ btn2.disabled = true;
736
+ }
737
+
738
+ try {
739
+ const res = await fetch('/api/apply', {
740
+ method: 'POST',
741
+ headers: {'Content-Type': 'application/json'},
742
+ body: JSON.stringify({ patch: activeSession.patch, checks: checks, files: (activeSession.files && activeSession.files.length) ? activeSession.files : (activeSession.file ? [activeSession.file] : []) })
743
+ });
744
+ const data = await res.json();
745
+ if (data.status === 'applied') {
746
+ rollbackAvailable = true;
747
+ updateProposalControls(activeSession);
748
+ showToast('✓ Patch applied to workspace and re-verified by test runner!');
749
+ } else {
750
+ showToast(`⚠️ Apply issue: ${data.error || 'Check failed'}`);
751
+ }
752
+ } catch (err) {
753
+ showToast('Error sending apply request to server');
754
+ } finally {
755
+ if (btn1) { btn1.innerHTML = old1 || 'Apply (Ctrl+A)'; btn1.disabled = false; }
756
+ if (btn2) { btn2.innerHTML = old2 || 'Apply Proposal (Ctrl+A)'; btn2.disabled = false; }
757
+ safeCreateIcons();
758
+ }
759
+ }
760
+
761
+ async function rollbackAction() {
762
+ const btn1 = document.getElementById('btnRollback');
763
+ const btn2 = document.getElementById('btnDelegatedRollback');
764
+ const old1 = btn1 ? btn1.innerHTML : '';
765
+ const old2 = btn2 ? btn2.innerHTML : '';
766
+ if (btn1) {
767
+ btn1.innerHTML = `<span class="inline-flex items-center gap-1 text-zinc-400">${spinnerSvg(11, 'text-zinc-400')} Restoring...</span>`;
768
+ btn1.disabled = true;
769
+ }
770
+ if (btn2) {
771
+ btn2.innerHTML = `<span class="inline-flex items-center gap-1 text-zinc-400">${spinnerSvg(11, 'text-zinc-400')} Auto-Rollback...</span>`;
772
+ btn2.disabled = true;
773
+ }
774
+
775
+ try {
776
+ const res = await fetch('/api/rollback', { method: 'POST', headers: {'Content-Type': 'application/json'} });
777
+ const data = await res.json();
778
+ if (data.status === 'rolled_back') {
779
+ rollbackAvailable = false;
780
+ updateProposalControls(activeSession);
781
+ const restored = (data.restored || []).length;
782
+ showToast(restored ? `↺ Restored ${restored} file(s)` : 'Nothing to roll back');
783
+ } else {
784
+ showToast(`Rollback issue: ${data.error || 'failed'}`);
785
+ }
786
+ } catch (err) {
787
+ showToast('Rollback request failed');
788
+ } finally {
789
+ if (btn1) { btn1.innerHTML = old1 || 'Rollback'; btn1.disabled = false; }
790
+ if (btn2) { btn2.innerHTML = old2 || 'Auto-Rollback (git restore)'; btn2.disabled = false; }
791
+ safeCreateIcons();
792
+ }
793
+ }
794
+
795
+ async function runDoctorCheck() {
796
+ const btn = document.getElementById('btnDoctor');
797
+ const oldHtml = btn ? btn.innerHTML : '';
798
+ if (btn) {
799
+ btn.innerHTML = `<span class="inline-flex items-center gap-1.5 text-cyan-400">${spinnerSvg(11, 'text-cyan-400')} Running Doctor...</span>`;
800
+ btn.disabled = true;
801
+ }
802
+ try {
803
+ const res = await fetch('/api/doctor/fix', { method: 'POST' });
804
+ const data = await res.json();
805
+ if (res.ok && data.status === 'ok' && data.report && data.report.success) {
806
+ const count = (data.report.actions || []).length;
807
+ showToast(`✓ Doctor completed (${count} configuration action${count === 1 ? '' : 's'})`);
808
+ } else if (res.ok && data.status === 'partial') {
809
+ showToast(`⚠️ Doctor partially applied: ${(data.report.actions || []).length} ok, errors: ${data.error || 'unknown'}`);
810
+ } else {
811
+ showToast(`Doctor failed: ${data.error || 'unknown error'}`);
812
+ }
813
+ } catch (error) {
814
+ showToast('Doctor request failed');
815
+ } finally {
816
+ if (btn) {
817
+ btn.innerHTML = oldHtml || 'Run Doctor';
818
+ btn.disabled = false;
819
+ }
820
+ safeCreateIcons();
821
+ }
822
+ }
823
+
824
+ function setPromptAndRun(promptText) {
825
+ document.getElementById('chatInput').value = promptText;
826
+ handleUserSubmit();
827
+ }
828
+
829
+ function renderUserPrompt(text) {
830
+ if (!text) return;
831
+ const container = document.getElementById('chatMessages');
832
+ const userDiv = document.createElement('div');
833
+ userDiv.className = 'flex items-start gap-2.5 max-w-2xl';
834
+ userDiv.innerHTML = `
835
+ <div class="w-6 h-6 rounded bg-[var(--bg-card-subtle)] border border-[var(--border-main)] flex items-center justify-center shrink-0 text-[10px] font-mono font-semibold text-zinc-400">DEV</div>
836
+ <div class="flex-1">
837
+ <div class="text-[11px] font-medium text-zinc-500 mb-1 flex items-center gap-2"><span>Developer</span><span class="text-[9px] font-mono text-zinc-500">Interactive Prompt</span></div>
838
+ <div class="p-3 rounded-lg bg-[var(--bg-card)] border border-[var(--border-main)] text-xs text-[var(--text-main)] leading-relaxed shadow-xs">${escapeHtml(text)}</div>
839
+ </div>
840
+ `;
841
+ container.appendChild(userDiv);
842
+ }
843
+
844
+ function renderSessionTranscript(session) {
845
+ const container = document.getElementById('chatMessages');
846
+ if (!container) return;
847
+ container.innerHTML = '';
848
+ renderUserPrompt(session.prompt || session.title || '');
849
+ if (session.message || session.thinking || session.plan) {
850
+ renderAssistantResponse({
851
+ ...session,
852
+ checks: session.test_evidence || [],
853
+ });
854
+ } else {
855
+ container.innerHTML += '<div class="p-8 text-center text-zinc-500 text-xs">No saved transcript for this older session.</div>';
856
+ }
857
+ container.scrollTop = container.scrollHeight;
858
+ }
859
+
860
+ async function handleUserSubmit() {
861
+ const input = document.getElementById('chatInput');
862
+ const val = input.value.trim();
863
+ if (!val) {
864
+ showToast('Please enter a prompt');
865
+ input.focus();
866
+ return;
867
+ }
868
+ if (!activeProfile) {
869
+ showToast('No local models available. Install or discover a model first.');
870
+ openModal('modelModal');
871
+ return;
872
+ }
873
+
874
+ const container = document.getElementById('chatMessages');
875
+ const sendBtn = document.getElementById('btnSendChat');
876
+ const oldSendHtml = sendBtn ? sendBtn.innerHTML : '';
877
+
878
+ renderUserPrompt(val);
879
+ input.value = '';
880
+
881
+ // Live Thinking / Loading Placeholder Card with Spinner
882
+ const thinkingCard = document.createElement('div');
883
+ thinkingCard.id = 'chatThinkingCard';
884
+ thinkingCard.className = 'flex items-start gap-2.5 max-w-2xl';
885
+ thinkingCard.innerHTML = `
886
+ <div class="w-6 h-6 rounded bg-cyan-500/10 border border-cyan-500/30 flex items-center justify-center shrink-0 text-[10px] font-mono font-semibold text-cyan-500">
887
+ ${spinnerSvg(12, 'text-cyan-400')}
888
+ </div>
889
+ <div class="flex-1 space-y-2">
890
+ <div class="text-[11px] font-medium text-zinc-500 flex items-center gap-1.5">
891
+ <span>${escapeHtml(activeProfile)}</span>
892
+ <span class="text-[9px] font-mono text-cyan-500">• Processing Task</span>
893
+ </div>
894
+ <div class="rounded border border-[var(--border-main)] bg-[var(--bg-card)] p-3 text-xs text-zinc-300 flex items-center gap-2.5 font-mono text-[11px] shadow-xs">
895
+ ${spinnerSvg(14, 'text-cyan-400 shrink-0')}
896
+ <span>Model is processing prompt, formulating diff &amp; validating test checks...</span>
897
+ </div>
898
+ </div>
899
+ `;
900
+ container.appendChild(thinkingCard);
901
+ container.scrollTop = container.scrollHeight;
902
+
903
+ if (sendBtn) {
904
+ sendBtn.innerHTML = spinnerSvg(14, 'text-zinc-950');
905
+ sendBtn.disabled = true;
906
+ }
907
+
908
+ try {
909
+ const res = await fetch('/api/chat', {
910
+ method: 'POST',
911
+ headers: { 'Content-Type': 'application/json' },
912
+ body: JSON.stringify({ prompt: val, profile: activeProfile, mode: SELECTED_MODE, ...(activeCtxOverride ? { num_ctx: activeCtxOverride } : {}) })
913
+ });
914
+ const data = await res.json();
915
+
916
+ const existingThinking = document.getElementById('chatThinkingCard');
917
+ if (existingThinking) existingThinking.remove();
918
+
919
+ if (data.status === 'failed' && data.offline_server) {
920
+ renderOfflineHelperCard(data.offline_server, data.error);
921
+ return;
922
+ }
923
+
924
+ renderAssistantResponse(data);
925
+ if (data.patch) {
926
+ document.getElementById('diffFileName').textContent = data.file || 'patch.diff';
927
+ document.getElementById('diffContentArea').innerHTML = renderUnifiedDiff(data.patch);
928
+ if (activeSession) {
929
+ activeSession.patch = data.patch;
930
+ activeSession.file = data.file || 'patch.diff';
931
+ activeSession.files = (data.files && data.files.length) ? data.files : (data.file ? [data.file] : []);
932
+ }
933
+ }
934
+ loadSessions();
935
+ } catch (e) {
936
+ const existingThinking = document.getElementById('chatThinkingCard');
937
+ if (existingThinking) existingThinking.remove();
938
+ renderOfflineHelperCard('ollama', 'Connection error to local server');
939
+ } finally {
940
+ if (sendBtn) {
941
+ sendBtn.innerHTML = oldSendHtml || '<span>Run</span>';
942
+ sendBtn.disabled = false;
943
+ }
944
+ safeCreateIcons();
945
+ }
946
+ }
947
+
948
+ function renderOfflineHelperCard(backend, errorMsg) {
949
+ const container = document.getElementById('chatMessages');
950
+ const errDiv = document.createElement('div');
951
+ errDiv.className = 'flex items-start gap-2.5 max-w-2xl';
952
+ errDiv.innerHTML = `
953
+ <div class="w-6 h-6 rounded bg-amber-500/10 border border-amber-500/30 flex items-center justify-center shrink-0 text-[10px] font-mono font-semibold text-amber-500">⚠️</div>
954
+ <div class="flex-1 space-y-2">
955
+ <div class="text-[11px] font-medium text-zinc-500">Engine Offline Notice</div>
956
+ <div class="p-3.5 rounded-lg bg-amber-950/20 border border-amber-500/30 text-xs text-amber-200 leading-relaxed space-y-2">
957
+ <div>${escapeHtml(errorMsg)}</div>
958
+ <div class="flex items-center gap-2 pt-1">
959
+ <button onclick="warmupActiveModel()" class="px-2.5 py-1 rounded bg-amber-500 hover:bg-amber-400 text-zinc-950 font-semibold text-[11px] transition">
960
+ ⚡ Load Model &amp; Start
961
+ </button>
962
+ <button onclick="openModal('modelModal')" class="px-2 py-1 rounded bg-[var(--bg-card)] hover:border-zinc-500 border border-[var(--border-main)] text-zinc-300 text-[10px] transition">
963
+ Change Model
964
+ </button>
965
+ </div>
966
+ </div>
967
+ </div>
968
+ `;
969
+ container.appendChild(errDiv);
970
+ safeCreateIcons();
971
+ container.scrollTop = container.scrollHeight;
972
+ }
973
+
974
+ function copyCodeSnippet(code) {
975
+ navigator.clipboard.writeText(code).then(() => {
976
+ showToast('✓ Code copied to clipboard');
977
+ }).catch(() => {
978
+ showToast('Failed to copy code');
979
+ });
980
+ }
981
+
982
+ function formatMarkdown(text) {
983
+ if (!text) return '';
984
+ let html = escapeHtml(text);
985
+
986
+ // Fenced code blocks ```lang ... ```
987
+ html = html.replace(/```([a-zA-Z0-9_\-\+]*)\n([\s\S]*?)```/g, (match, lang, code) => {
988
+ const langLabel = lang || 'code';
989
+ const cleanCode = code.replace(/^\n+|\n+$/g, '');
990
+ const rawAttr = encodeURIComponent(cleanCode).replace(/'/g, "%27");
991
+ return `
992
+ <div class="my-2.5 rounded-lg border border-[var(--border-main)] bg-[var(--bg-card-subtle)] overflow-hidden font-mono text-[11px]">
993
+ <div class="flex items-center justify-between px-3 py-1.5 bg-[var(--bg-card)] border-b border-[var(--border-main)] text-[10px] text-zinc-400">
994
+ <span class="font-semibold text-cyan-400">${langLabel}</span>
995
+ <button onclick="copyCodeSnippet(decodeURIComponent('${rawAttr}'))" class="hover:text-zinc-200 transition flex items-center gap-1">
996
+ <i data-lucide="copy" class="w-3 h-3"></i> Copy
997
+ </button>
998
+ </div>
999
+ <pre class="p-3 overflow-x-auto text-zinc-200 leading-relaxed font-mono"><code>${cleanCode}</code></pre>
1000
+ </div>
1001
+ `;
1002
+ });
1003
+
1004
+ // Inline `code`
1005
+ html = html.replace(/`([^`]+)`/g, '<code class="px-1.5 py-0.5 rounded bg-[var(--bg-card-subtle)] border border-[var(--border-main)] text-cyan-300 font-mono text-[11px]">$1</code>');
1006
+
1007
+ // Bold **text**
1008
+ html = html.replace(/\*\*([^*]+)\*\*/g, '<strong class="font-semibold text-zinc-100">$1</strong>');
1009
+
1010
+ // Bullet points
1011
+ html = html.replace(/^[\*\-]\s+(.+)$/gm, '<li class="ml-4 list-disc text-zinc-300">$1</li>');
1012
+
1013
+ // Paragraph breaks
1014
+ html = html.replace(/\n\n/g, '<br><br>');
1015
+ html = html.replace(/\n/g, '<br>');
1016
+
1017
+ return html;
1018
+ }
1019
+
1020
+ function renderAssistantResponse(data) {
1021
+ const container = document.getElementById('chatMessages');
1022
+ const aiDiv = document.createElement('div');
1023
+ aiDiv.className = 'flex items-start gap-2.5 max-w-2xl';
1024
+
1025
+ // Reflect the resolved mode in the badges
1026
+ if (data.mode) {
1027
+ const modeLabel = data.mode === 'hybrid' ? 'auto' : data.mode;
1028
+ const badge = document.getElementById('modeBadge');
1029
+ if (badge) badge.textContent = `Mode: ${modeLabel}`;
1030
+ const welcomeBadge = document.getElementById('welcomeModeLabel');
1031
+ if (welcomeBadge) welcomeBadge.textContent = `Mode: ${modeLabel}`;
1032
+ }
1033
+
1034
+ let planCardHtml = '';
1035
+ if (data.plan) {
1036
+ const p = data.plan;
1037
+ const steps = (p.steps || []).map((s, i) => `<li class="flex items-start gap-1.5"><span class="w-4 h-4 shrink-0 rounded bg-cyan-500/10 border border-cyan-500/30 text-cyan-500 font-mono text-[9px] flex items-center justify-center font-semibold mt-0.5">${i + 1}</span><span class="text-zinc-300">${escapeHtml(s)}</span></li>`).join('');
1038
+ const risks = (p.risks || []).map(r => `<li class="text-zinc-400">• ${escapeHtml(r)}</li>`).join('');
1039
+ const files = (p.files_to_modify || []).map(f => `<span class="px-1.5 py-0.5 rounded bg-[var(--bg-card-subtle)] border border-[var(--border-main)] font-mono text-[10px] text-cyan-300">${escapeHtml(f)}</span>`).join('');
1040
+ planCardHtml = `
1041
+ <div class="rounded-lg border border-cyan-500/30 bg-cyan-950/20 p-3 text-xs space-y-2.5">
1042
+ <div class="flex items-center gap-1.5 text-cyan-400 font-semibold text-[11px]"><i data-lucide="route" class="w-3 h-3"></i> Execution Plan</div>
1043
+ ${p.goal ? `<div class="text-[var(--text-main)]"><span class="text-zinc-500 font-mono text-[10px]">GOAL: </span>${escapeHtml(p.goal)}</div>` : ''}
1044
+ ${steps ? `<div class="space-y-1"><div class="text-zinc-500 font-mono text-[10px]">STEPS</div><ol class="space-y-1">${steps}</ol></div>` : ''}
1045
+ ${risks ? `<div class="space-y-0.5"><div class="text-zinc-500 font-mono text-[10px]">RISKS</div><ul class="space-y-0.5">${risks}</ul></div>` : ''}
1046
+ ${files ? `<div class="space-y-1"><div class="text-zinc-500 font-mono text-[10px]">FILES TO MODIFY</div><div class="flex flex-wrap gap-1">${files}</div></div>` : ''}
1047
+ </div>
1048
+ `;
1049
+ }
1050
+
1051
+ let testCardHtml = '';
1052
+ if (data.checks && data.checks.length > 0 && data.testResult && data.testResult !== 'READY') {
1053
+ const isPass = data.testResult === 'PASSED' || data.testResult === 'ALL CHECKS GREEN';
1054
+ testCardHtml = `
1055
+ <div class="rounded border border-[var(--border-main)] bg-[var(--bg-card)] p-2 flex items-center justify-between text-xs font-mono text-[10px]">
1056
+ <span class="text-zinc-400">${escapeHtml(data.checks.join(', '))}</span>
1057
+ <span class="${isPass ? 'text-emerald-500' : 'text-amber-500'} font-semibold">${escapeHtml(data.testResult)}</span>
1058
+ </div>
1059
+ `;
1060
+ }
1061
+
1062
+ let diffBadgeHtml = '';
1063
+ if (data.patch && data.patch.trim()) {
1064
+ diffBadgeHtml = `
1065
+ <div class="flex items-center justify-between p-2 rounded bg-cyan-950/20 border border-cyan-500/30 text-xs">
1066
+ <span class="text-cyan-300 font-mono text-[11px]">⚡ Proposed changes for <strong>${escapeHtml(data.file || 'patch.diff')}</strong></span>
1067
+ <button onclick="switchTab('delegated')" class="px-2 py-0.5 rounded bg-cyan-500 hover:bg-cyan-400 text-zinc-950 text-[10px] font-semibold transition">
1068
+ Review Diff Studio
1069
+ </button>
1070
+ </div>
1071
+ `;
1072
+ }
1073
+
1074
+ aiDiv.innerHTML = `
1075
+ <div class="w-6 h-6 rounded bg-cyan-500/10 border border-cyan-500/30 flex items-center justify-center shrink-0 text-[10px] font-mono font-semibold text-cyan-500">AI</div>
1076
+ <div class="flex-1 space-y-2">
1077
+ <div class="text-[11px] font-medium text-zinc-500 flex items-center gap-1.5">
1078
+ <span>${escapeHtml(data.profile || activeProfile)}</span>
1079
+ <span class="text-[9px] font-mono text-zinc-500">• AI Response</span>
1080
+ </div>
1081
+ ${data.thinking ? `
1082
+ <div class="rounded border border-[var(--border-main)] bg-[var(--bg-card)] p-2 text-xs text-zinc-400 space-y-1 font-mono text-[10px]">
1083
+ <div class="flex items-center gap-1.5 text-amber-500 font-semibold"><i data-lucide="sparkles" class="w-2.5 h-2.5"></i> Thinking &amp; Analysis</div>
1084
+ <div>• ${escapeHtml(data.thinking)}</div>
1085
+ </div>
1086
+ ` : ''}
1087
+ ${testCardHtml}
1088
+ ${planCardHtml}
1089
+ ${diffBadgeHtml}
1090
+ <div class="p-3.5 rounded-lg bg-[var(--bg-card)] border border-[var(--border-main)] text-xs text-[var(--text-main)] leading-relaxed space-y-2">
1091
+ ${formatMarkdown(data.message || '')}
1092
+ </div>
1093
+ </div>
1094
+ `;
1095
+ container.appendChild(aiDiv);
1096
+ safeCreateIcons();
1097
+ container.scrollTop = container.scrollHeight;
1098
+ }
1099
+
1100
+ function escapeHtml(str) {
1101
+ if (!str) return '';
1102
+ return String(str).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1103
+ }
1104
+
1105
+ // ---- Background Task Queue ----
1106
+ let TASKS = [];
1107
+
1108
+ async function submitQueuedTask(evt) {
1109
+ evt.preventDefault();
1110
+ const goalEl = document.getElementById('taskQueueGoal');
1111
+ const goal = (goalEl.value || '').trim();
1112
+ if (!goal) {
1113
+ showToast('Please enter a task goal');
1114
+ return;
1115
+ }
1116
+ const csv = (id) => (document.getElementById(id).value || '').split(',').map(s => s.trim()).filter(Boolean);
1117
+ const body = { goal: goal, files: csv('taskQueueFiles'), checks: csv('taskQueueChecks') };
1118
+ const prof = (document.getElementById('taskQueueProfile').value || '').trim();
1119
+ if (prof) body.profile = prof;
1120
+ try {
1121
+ const res = await fetch('/api/tasks', {
1122
+ method: 'POST',
1123
+ headers: { 'Content-Type': 'application/json' },
1124
+ body: JSON.stringify(body)
1125
+ });
1126
+ const data = await res.json();
1127
+ if (data.status === 'queued') {
1128
+ goalEl.value = '';
1129
+ showToast(`✓ Queued ${data.task.id}`);
1130
+ pollTasks();
1131
+ } else {
1132
+ showToast(`⚠️ Could not queue task: ${data.error || 'unknown error'}`);
1133
+ }
1134
+ } catch (e) {
1135
+ showToast('Error submitting task');
1136
+ }
1137
+ }
1138
+
1139
+ function taskStatusBadge(status) {
1140
+ const map = {
1141
+ queued: 'bg-zinc-500/10 border-zinc-500/30 text-zinc-400',
1142
+ running: 'bg-amber-500/10 border-amber-500/30 text-amber-500 animate-pulse',
1143
+ accepted: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500',
1144
+ failed: 'bg-red-500/10 border-red-500/30 text-red-400',
1145
+ cancelled: 'bg-zinc-500/10 border-zinc-500/30 text-zinc-500 line-through'
1146
+ };
1147
+ return map[status] || map.queued;
1148
+ }
1149
+
1150
+ function taskDurationText(t) {
1151
+ if (!t.started_at) return '—';
1152
+ const end = t.finished_at || Date.now() / 1000;
1153
+ return `${Math.max(0, end - t.started_at).toFixed(1)}s`;
1154
+ }
1155
+
1156
+ async function pollTasks() {
1157
+ try {
1158
+ const res = await fetch('/api/tasks');
1159
+ if (!res.ok) return;
1160
+ TASKS = (await res.json()).tasks || [];
1161
+ renderTaskQueue();
1162
+ } catch (e) {}
1163
+ }
1164
+
1165
+ function renderTaskQueue() {
1166
+ const list = document.getElementById('taskQueueList');
1167
+ if (!list) return;
1168
+ if (!TASKS.length) {
1169
+ list.innerHTML = '<div class="p-2 text-center text-zinc-500 font-mono text-[10px]">No background tasks yet.</div>';
1170
+ return;
1171
+ }
1172
+ list.innerHTML = TASKS.map(t => `
1173
+ <div class="p-2 rounded bg-[var(--bg-card)] border border-[var(--border-main)] space-y-1">
1174
+ <div class="flex items-center justify-between gap-2">
1175
+ <span class="inline-flex items-center px-1 py-0.1 rounded border font-mono text-[9px] font-semibold tracking-wider ${taskStatusBadge(t.status)}">${escapeHtml(String(t.status).toUpperCase())}</span>
1176
+ <span class="font-mono text-[9px] text-zinc-500 num-tabular truncate">${escapeHtml(t.id || '')} • ${taskDurationText(t)}</span>
1177
+ </div>
1178
+ <div class="text-[11px] text-[var(--text-main)] truncate">${escapeHtml(t.goal || '')}</div>
1179
+ ${t.summary ? `<div class="text-[10px] text-zinc-400 leading-snug">${escapeHtml(t.summary)}</div>` : ''}
1180
+ ${t.error ? `<div class="text-[10px] text-red-400 leading-snug">${escapeHtml(typeof t.error === 'object' ? (t.error.message || '') : String(t.error))}</div>` : ''}
1181
+ <div class="flex items-center gap-1.5">
1182
+ ${(t.status === 'queued' || t.status === 'running') ? `<button onclick="cancelQueuedTask('${t.id}')" class="px-1.5 py-0.5 rounded bg-[var(--bg-card-subtle)] border border-[var(--border-main)] text-[10px] text-zinc-300 hover:text-zinc-100 transition">Cancel</button>` : ''}
1183
+ ${(t.status === 'accepted' && t.patch && t.patch.trim()) ? `<button onclick="applyQueuedTask('${t.id}')" class="px-1.5 py-0.5 rounded bg-emerald-600 hover:bg-emerald-500 text-zinc-950 font-semibold text-[10px] transition">Apply</button>
1184
+ <button onclick="rollbackQueuedTask()" class="px-1.5 py-0.5 rounded bg-[var(--bg-card-subtle)] border border-[var(--border-main)] text-[10px] text-zinc-300 hover:text-zinc-100 transition">Rollback</button>` : ''}
1185
+ </div>
1186
+ </div>
1187
+ `).join('');
1188
+ }
1189
+
1190
+ async function cancelQueuedTask(id) {
1191
+ try {
1192
+ const res = await fetch('/api/tasks/cancel', {
1193
+ method: 'POST',
1194
+ headers: { 'Content-Type': 'application/json' },
1195
+ body: JSON.stringify({ id: id })
1196
+ });
1197
+ const data = await res.json();
1198
+ showToast(data.status === 'cancelling' ? `Cancelling ${id}...` : `Task ${data.status}: ${id}`);
1199
+ pollTasks();
1200
+ } catch (e) {
1201
+ showToast('Error cancelling task');
1202
+ }
1203
+ }
1204
+
1205
+ async function applyQueuedTask(id) {
1206
+ const t = TASKS.find(x => x.id === id);
1207
+ if (!t || !t.patch) return;
1208
+ if (!confirm(`Apply the patch from ${id} to the workspace? Targeted checks will re-run.`)) return;
1209
+ const checks = resolveCheckCommands(t.checks);
1210
+ if (!checks) {
1211
+ showToast('Apply cancelled: a targeted check command is required');
1212
+ return;
1213
+ }
1214
+ try {
1215
+ const res = await fetch('/api/apply', {
1216
+ method: 'POST',
1217
+ headers: { 'Content-Type': 'application/json' },
1218
+ body: JSON.stringify({ patch: t.patch, files: t.files || [], checks: checks })
1219
+ });
1220
+ const data = await res.json();
1221
+ if (data.status === 'applied') {
1222
+ showToast(`✓ Patch applied and verified (${id})`);
1223
+ } else {
1224
+ showToast(`⚠️ Apply issue: ${(data.error || data.status || 'failed').toString().slice(0, 140)}`);
1225
+ }
1226
+ } catch (e) {
1227
+ showToast('Error sending apply request to server');
1228
+ }
1229
+ }
1230
+
1231
+ async function rollbackQueuedTask() {
1232
+ if (!confirm('Roll back the most recently applied patch?')) return;
1233
+ try {
1234
+ const res = await fetch('/api/rollback', { method: 'POST', headers: { 'Content-Type': 'application/json' } });
1235
+ const data = await res.json();
1236
+ if (data.status === 'rolled_back') showToast('↺ Workspace restored cleanly');
1237
+ else showToast(`Rollback issue: ${data.error || data.status}`);
1238
+ } catch (e) {
1239
+ showToast('Error sending rollback request');
1240
+ }
1241
+ }
1242
+
1243
+ document.addEventListener('keydown', event => {
1244
+ if (!(event.ctrlKey || event.metaKey)) return;
1245
+ const key = event.key.toLowerCase();
1246
+ if (key === 'b' && event.key.toLowerCase() === 'b') {
1247
+ event.preventDefault();
1248
+ toggleSidebar();
1249
+ } else if (key === 'n' && event.key.toLowerCase() === 'n') {
1250
+ event.preventDefault();
1251
+ startNewSession();
1252
+ } else if (key === 'a' && event.key.toLowerCase() === 'a') {
1253
+ const tag = (event.target && event.target.tagName) || '';
1254
+ if (!['INPUT', 'TEXTAREA', 'SELECT'].includes(tag)) {
1255
+ event.preventDefault();
1256
+ applyProposalAction();
1257
+ }
1258
+ }
1259
+ });
1260
+
1261
+ // Startup
1262
+ setMode(SELECTED_MODE, document.getElementById('mode-btn-hybrid'));
1263
+ loadSessions();
1264
+ fetchAndPopulateModels();
1265
+ pollStatus();
1266
+ setInterval(pollStatus, 2500);
1267
+ pollTasks();
1268
+ setInterval(pollTasks, 2000);
1269
+ updateProposalControls();
1270
+ safeCreateIcons();
1271
+ """