glad-web 1.0.46 → 2.0.2

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 (74) hide show
  1. package/README.md +4 -192
  2. package/THIRD_PARTY_NOTICES.md +27 -0
  3. package/bin/glad.cjs +56 -0
  4. package/package.json +19 -61
  5. package/README.zh-CN.md +0 -198
  6. package/assets/logo.svg +0 -43
  7. package/bin/cli.js +0 -65
  8. package/lib/ai-tools/demo/enhanced-demo.js +0 -625
  9. package/lib/ai-tools/demo/index.js +0 -24
  10. package/lib/ai-tools/demo/responses.js +0 -88
  11. package/lib/ai-tools/detector.js +0 -76
  12. package/lib/ai-tools/registry.js +0 -300
  13. package/lib/claude/cli-usage.js +0 -95
  14. package/lib/claude/config.js +0 -82
  15. package/lib/claude/structured-session.js +0 -884
  16. package/lib/claude/transcript-repository.js +0 -216
  17. package/lib/codex/image-store.js +0 -174
  18. package/lib/codex/structured-session.js +0 -1590
  19. package/lib/commands/config.js +0 -78
  20. package/lib/commands/tools.js +0 -128
  21. package/lib/commands/web.js +0 -605
  22. package/lib/config/constants.js +0 -17
  23. package/lib/config/manager.js +0 -108
  24. package/lib/git/service.js +0 -83
  25. package/lib/notifications/message-formatter.js +0 -94
  26. package/lib/notifications/notification-service.js +0 -143
  27. package/lib/notifications/serverchan-client.js +0 -58
  28. package/lib/notifications/serverchan-settings-store.js +0 -115
  29. package/lib/schedule/job-runner.js +0 -162
  30. package/lib/schedule/job-store.js +0 -167
  31. package/lib/schedule/key-sequences.js +0 -49
  32. package/lib/schedule/scheduler-service.js +0 -39
  33. package/lib/server/routes/notifications.js +0 -52
  34. package/lib/server/routes/providers.js +0 -114
  35. package/lib/server/routes/schedules.js +0 -54
  36. package/lib/server/routes/skillhub.js +0 -104
  37. package/lib/server/routes/usage.js +0 -23
  38. package/lib/server/routes/workspace.js +0 -77
  39. package/lib/session/buffer.js +0 -102
  40. package/lib/session/file-attachment-store.js +0 -168
  41. package/lib/session/pty-manager.js +0 -255
  42. package/lib/session/rendered-history.js +0 -225
  43. package/lib/session/session-manager.js +0 -1032
  44. package/lib/session/text-history.js +0 -274
  45. package/lib/skillhub/client.js +0 -121
  46. package/lib/skillhub/settings-store.js +0 -168
  47. package/lib/skillhub/skill-installer.js +0 -320
  48. package/lib/usage/ccusage-runner.js +0 -128
  49. package/lib/usage/source-catalog.js +0 -26
  50. package/lib/usage/usage-service.js +0 -226
  51. package/lib/utils/logger.js +0 -74
  52. package/lib/utils/pid.js +0 -67
  53. package/lib/utils/validation.js +0 -53
  54. package/lib/web/bootstrap.js +0 -34
  55. package/lib/web/claude.js +0 -1150
  56. package/lib/web/codex.js +0 -1045
  57. package/lib/web/composer.js +0 -493
  58. package/lib/web/core.js +0 -385
  59. package/lib/web/git.js +0 -535
  60. package/lib/web/gitgraph.js +0 -293
  61. package/lib/web/index.html +0 -547
  62. package/lib/web/layout.js +0 -69
  63. package/lib/web/notifications.js +0 -164
  64. package/lib/web/schedules.js +0 -245
  65. package/lib/web/session.js +0 -361
  66. package/lib/web/shell.js +0 -74
  67. package/lib/web/skillhub.js +0 -197
  68. package/lib/web/styles.css +0 -932
  69. package/lib/web/terminal-scroll.js +0 -81
  70. package/lib/web/theme.js +0 -60
  71. package/lib/web/timed-inputs.js +0 -216
  72. package/lib/web/usage.js +0 -323
  73. package/lib/workspace/service.js +0 -77
  74. package/scripts/check-syntax.js +0 -26
package/lib/web/claude.js DELETED
@@ -1,1150 +0,0 @@
1
- function isClaudeSession() {
2
- return activeToolKey === 'claude-code';
3
- }
4
-
5
- function isCodexSession() {
6
- return activeToolKey === 'codex';
7
- }
8
-
9
- function setClaudeModeEnabled(enabled) {
10
- const codexChat = isCodexSession();
11
- const structured = enabled || codexChat;
12
- const actionRail = enabled
13
- ? document.querySelector('.claude-control-rail')
14
- : codexChat
15
- ? document.getElementById('codex-control-rail')
16
- : document.getElementById('shortcut-rail');
17
- const attachmentButton = document.getElementById('attachment-btn');
18
- const scheduleButton = document.getElementById('schedule-send-btn');
19
- if (actionRail && attachmentButton && scheduleButton) {
20
- actionRail.prepend(scheduleButton);
21
- actionRail.prepend(attachmentButton);
22
- }
23
- document.getElementById('terminal-container').style.display = structured ? 'none' : '';
24
- document.getElementById('claude-chat-container').style.display = enabled ? 'block' : 'none';
25
- document.getElementById('codex-chat-container').style.display = codexChat ? 'block' : 'none';
26
- document.getElementById('claude-control-panel').style.display = enabled ? 'flex' : 'none';
27
- document.getElementById('codex-control-panel').style.display = codexChat ? 'flex' : 'none';
28
- document.getElementById('attachment-btn').style.display = '';
29
- document.getElementById('shortcut-rail').style.display = structured ? 'none' : '';
30
- document.getElementById('scroll-controls').style.display = structured ? 'none' : '';
31
- document.getElementById('cmd-input').placeholder = enabled ? 'Message Claude...' : codexChat ? 'Message Codex...' : 'Type a message...';
32
- if (!codexChat) {
33
- const skillPrefix = document.getElementById('composer-skill-prefix');
34
- skillPrefix.classList.remove('active');
35
- skillPrefix.innerHTML = '';
36
- }
37
- if (!structured) renderSessionAttention();
38
- updateTerminalControlsHeight();
39
- }
40
-
41
- function updateTerminalControlsHeight() {
42
- const controls = document.getElementById('terminal-controls');
43
- const measuredHeight = controls ? Math.ceil(controls.getBoundingClientRect().height) : 0;
44
- document.documentElement.style.setProperty('--terminal-controls-rest-height', `${Math.max(0, measuredHeight)}px`);
45
- }
46
-
47
- function shortModelLabel(value, resolved) {
48
- const source = String(value || 'default');
49
- const target = String(resolved || source);
50
- const compact = target
51
- .replace(/^claude-/, '')
52
- .replace(/-20\d{6,}.*$/, '')
53
- .slice(0, 9);
54
- if (source === 'default') return 'M:Def';
55
- if (source === 'env') return `M:${compact || 'Env'}`;
56
- if (source === 'sonnet') return `M:${compact || 'Son'}`;
57
- if (source === 'opus') return `M:${compact || 'Opus'}`;
58
- if (source === 'haiku') return `M:${compact || 'Hai'}`;
59
- return `M:${compact || source.slice(0, 9)}`;
60
- }
61
-
62
- function fullModelLabel(value, resolved, label) {
63
- const source = String(value || 'default');
64
- if (source === 'default') return 'Default';
65
- if (source === 'env') return resolved ? `Environment (${resolved})` : 'Environment';
66
- if (label && label !== value) return label;
67
- return String(resolved || value || 'Model');
68
- }
69
-
70
- function permissionModeLabel(value) {
71
- const labels = {
72
- default: 'Default',
73
- acceptEdits: 'Accept edits',
74
- plan: 'Plan mode',
75
- bypassPermissions: 'Bypass'
76
- };
77
- return labels[value] || String(value || 'Default');
78
- }
79
-
80
- function effortLabel(value) {
81
- const labels = {
82
- low: 'Low',
83
- medium: 'Medium',
84
- high: 'High',
85
- xhigh: 'Extra high',
86
- max: 'Max'
87
- };
88
- return labels[value] || String(value || 'Medium');
89
- }
90
-
91
- function pickerLabelFromOption(option) {
92
- return option ? (option.textContent || option.value || '') : '';
93
- }
94
-
95
- function pickerFullValue(option) {
96
- if (!option) return '';
97
- return option.dataset.resolved || '';
98
- }
99
-
100
- function syncClaudePickerButtons() {
101
- const mappings = [
102
- ['permission', 'claude-permission-select', 'claude-permission-picker-btn', 'Permission'],
103
- ['model', 'claude-model-select', 'claude-model-picker-btn', 'Model']
104
- ];
105
- mappings.forEach(([type, selectId, buttonId, prefix]) => {
106
- const select = document.getElementById(selectId);
107
- const button = document.getElementById(buttonId);
108
- if (!select || !button) return;
109
- const option = select.selectedOptions && select.selectedOptions[0];
110
- const label = pickerLabelFromOption(option);
111
- const fullValue = pickerFullValue(option);
112
- setActionButtonLabel(button, prefix);
113
- button.title = type === 'model'
114
- ? `${fullValue || label || prefix} · ${effortLabel(claudeState.effort)}`
115
- : (fullValue || label || prefix);
116
- button.classList.toggle('active', claudePickerOpen === type);
117
- });
118
- }
119
-
120
- function closeClaudePicker() {
121
- claudePickerOpen = null;
122
- const panel = document.getElementById('claude-picker-panel');
123
- if (panel) {
124
- panel.classList.remove('active');
125
- panel.innerHTML = '';
126
- }
127
- syncClaudePickerButtons();
128
- updateTerminalControlsHeight();
129
- }
130
-
131
- function pickerTitle(type) {
132
- if (type === 'permission') return 'Permission mode';
133
- if (type === 'model') return 'Model';
134
- if (type === 'effort') return 'Effort';
135
- return 'Options';
136
- }
137
-
138
- function selectIdForPicker(type) {
139
- if (type === 'permission') return 'claude-permission-select';
140
- if (type === 'model') return 'claude-model-select';
141
- if (type === 'effort') return 'claude-effort-select';
142
- return '';
143
- }
144
-
145
- function renderClaudePicker(type) {
146
- const panel = document.getElementById('claude-picker-panel');
147
- const select = document.getElementById(selectIdForPicker(type));
148
- if (!panel || !select) return;
149
- const renderOptions = optionType => {
150
- const optionSelect = document.getElementById(selectIdForPicker(optionType));
151
- if (!optionSelect) return '';
152
- return Array.from(optionSelect.options).map(option => {
153
- const full = pickerFullValue(option);
154
- const isSelected = option.value === optionSelect.value;
155
- return `<button class="claude-picker-option${isSelected ? ' selected' : ''}" onclick="chooseClaudePickerOption('${optionType}', decodePathValue('${encodePathValue(option.value)}'))">
156
- <div class="claude-picker-option-main">
157
- <span>${escapeHtml(option.textContent || option.value)}</span>
158
- ${isSelected ? '<span class="selected-label">Selected</span>' : ''}
159
- </div>
160
- ${full ? `<div class="claude-picker-option-value">${escapeHtml(full)}</div>` : ''}
161
- </button>`;
162
- }).join('');
163
- };
164
- panel.classList.toggle('combined', type === 'model');
165
- panel.innerHTML = type === 'model'
166
- ? `<div class="claude-picker-column"><div class="claude-picker-title">Model</div>${renderOptions('model')}</div>
167
- <div class="claude-picker-column"><div class="claude-picker-title">Effort</div>${renderOptions('effort')}</div>`
168
- : `<div class="claude-picker-title">${escapeHtml(pickerTitle(type))}</div>${renderOptions(type)}`;
169
- panel.classList.add('active');
170
- syncClaudePickerButtons();
171
- updateTerminalControlsHeight();
172
- }
173
-
174
- async function toggleClaudePicker(type) {
175
- if (claudePickerOpen === type) {
176
- closeClaudePicker();
177
- return;
178
- }
179
- if (type === 'model') await refreshClaudeRuntimeConfig();
180
- claudeResumePanelOpen = false;
181
- claudeForkPanelOpen = false;
182
- const resumePanel = document.getElementById('claude-resume-panel');
183
- if (resumePanel) resumePanel.classList.remove('active');
184
- const forkPanel = document.getElementById('claude-fork-panel');
185
- if (forkPanel) forkPanel.classList.remove('active');
186
- claudePickerOpen = type;
187
- renderClaudePicker(type);
188
- }
189
-
190
- async function chooseClaudePickerOption(type, value) {
191
- const select = document.getElementById(selectIdForPicker(type));
192
- if (!select) return;
193
- select.value = value;
194
- const keepCombinedOpen = claudePickerOpen === 'model' && (type === 'model' || type === 'effort');
195
- if (!keepCombinedOpen) closeClaudePicker();
196
- await updateClaudeSettingsFromControls();
197
- if (keepCombinedOpen && claudePickerOpen === 'model') renderClaudePicker('model');
198
- }
199
-
200
- function applyClaudeRuntimeConfig(config) {
201
- if (!config || !Array.isArray(config.models)) return;
202
- claudeRuntimeConfig = config;
203
- const modelEl = document.getElementById('claude-model-select');
204
- if (modelEl) {
205
- const current = modelEl.value || claudeState.model || config.defaultModel || 'default';
206
- modelEl.innerHTML = '';
207
- config.models.forEach(item => {
208
- const option = new Option(fullModelLabel(item.value, item.resolved, item.label), item.value);
209
- option.title = item.resolved ? `${item.label}: ${item.resolved}` : item.label;
210
- option.dataset.resolved = item.resolved || '';
211
- option.dataset.shortLabel = shortModelLabel(item.value, item.resolved);
212
- modelEl.add(option);
213
- });
214
- const next = Array.from(modelEl.options).some(option => option.value === current)
215
- ? current
216
- : (config.defaultModel || 'default');
217
- modelEl.value = next;
218
- claudeState.model = next;
219
- }
220
- if (config.defaultEffort && claudeState.effort === 'medium') {
221
- claudeState.effort = config.defaultEffort;
222
- }
223
- syncClaudePickerButtons();
224
- renderClaudeStateBar();
225
- }
226
-
227
- async function refreshClaudeRuntimeConfig() {
228
- try {
229
- const res = await fetchWithTimeout('/api/claude-config', {}, 10000);
230
- const data = await res.json();
231
- if (!res.ok || !data.success) throw new Error(data.error || 'Failed to load Claude config');
232
- applyClaudeRuntimeConfig(data.config);
233
- return data.config;
234
- } catch (e) {
235
- log('Failed to load Claude config: ' + e.message);
236
- return null;
237
- }
238
- }
239
-
240
- function ensureSelectOption(selectEl, value, labelPrefix) {
241
- const normalized = String(value || '').trim();
242
- if (!normalized) return;
243
- const exists = Array.from(selectEl.options).some(option => option.value === normalized);
244
- if (!exists) {
245
- const label = `${labelPrefix}:${normalized.replace(/^claude-/, '').replace(/-20\d{6,}.*$/, '').slice(0, 10)}`;
246
- selectEl.add(new Option(label, normalized));
247
- }
248
- }
249
-
250
- function applyClaudeState(state = {}, options = {}) {
251
- claudeState = { ...claudeState, ...state };
252
- claudeStatus = claudeState.status || claudeStatus;
253
- if (typeof syncComposerSendState === 'function') {
254
- syncComposerSendState({ acknowledgeProviderState: Boolean(options.providerStateReceived) });
255
- }
256
- const permissionEl = document.getElementById('claude-permission-select');
257
- const modelEl = document.getElementById('claude-model-select');
258
- const effortEl = document.getElementById('claude-effort-select');
259
- if (permissionEl) permissionEl.value = claudeState.permissionMode || 'default';
260
- if (modelEl) {
261
- ensureSelectOption(modelEl, claudeState.model || 'default', 'M');
262
- modelEl.value = claudeState.model || 'default';
263
- }
264
- if (effortEl) effortEl.value = claudeState.effort || 'medium';
265
- syncClaudePickerButtons();
266
- const abortBtn = document.getElementById('claude-abort-btn');
267
- if (abortBtn) abortBtn.disabled = !(claudeState.canAbort || claudeStatus === 'thinking');
268
- const forkBtn = document.getElementById('claude-fork-btn');
269
- if (forkBtn) forkBtn.disabled = claudeStatus === 'thinking';
270
- const usageBtn = document.getElementById('claude-usage-btn');
271
- if (usageBtn) {
272
- usageBtn.disabled = claudeUsagePending || claudeStatus === 'thinking';
273
- setActionButtonLabel(usageBtn, claudeUsagePending ? 'Loading' : 'Usage');
274
- }
275
- const contextBtn = document.getElementById('claude-context-btn');
276
- if (contextBtn) {
277
- contextBtn.disabled = claudeContextPending || claudeStatus === 'thinking';
278
- setActionButtonLabel(contextBtn, claudeContextPending ? 'Loading' : 'Context');
279
- }
280
- renderClaudeStateBar();
281
- renderClaudeChat();
282
- }
283
-
284
- function shortValue(value, fallback = 'N/A') {
285
- const text = String(value || fallback);
286
- return text.length > 16 ? text.slice(0, 13) + '...' : text;
287
- }
288
-
289
- function formatTokenCount(value) {
290
- const number = Number(value || 0);
291
- if (number >= 1000000) return `${(number / 1000000).toFixed(2)}M`;
292
- if (number >= 1000) return `${(number / 1000).toFixed(1)}K`;
293
- return String(Math.round(number));
294
- }
295
-
296
- function formatClaudeDuration(durationMs) {
297
- const value = Number(durationMs || 0);
298
- if (!(value > 0)) return '';
299
- if (value < 1000) return `${Math.round(value)}ms`;
300
- const seconds = value / 1000;
301
- if (seconds < 10) return `${seconds.toFixed(1).replace(/\.0$/, '')}s`;
302
- if (seconds < 60) return `${Math.round(seconds)}s`;
303
- const minutes = Math.floor(seconds / 60);
304
- return `${minutes}m ${Math.round(seconds % 60)}s`;
305
- }
306
-
307
- function renderClaudeMessageTime(message, finalOnly = false) {
308
- if (!message) return '';
309
- const timestamp = Number(finalOnly
310
- ? (message.completedAtMs || message.updatedAt || message.createdAt)
311
- : message.createdAt);
312
- if (!Number.isFinite(timestamp) || timestamp <= 0) return '';
313
- const date = new Date(timestamp);
314
- if (Number.isNaN(date.getTime())) return '';
315
- const label = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
316
- return `<time class="claude-message-time" datetime="${escapeHtml(date.toISOString())}" title="${escapeHtml(date.toLocaleString())}">${escapeHtml(label)}</time>`;
317
- }
318
-
319
- function renderClaudeStateBar() {
320
- const el = document.getElementById('claude-state-bar');
321
- if (!el) return;
322
- const pending = Number(claudeState.pendingPermissionCount || claudePendingPermissions.filter(item => item.status === 'pending').length) || 0;
323
- const mode = claudeState.permissionMode || 'default';
324
- const parts = [];
325
- const attention = [];
326
- if (pending) {
327
- attention.push(`<button type="button" class="session-attention-pill approval claude-approval-jump" onclick="jumpToClaudeApproval()" title="Jump to pending approval" aria-label="Jump to pending Claude approval"><span aria-hidden="true">!</span>${pending} approval${pending > 1 ? 's' : ''}<span aria-hidden="true">↓</span></button>`);
328
- } else if (claudeStatus && !['idle', 'stopped', 'thinking'].includes(claudeStatus)) {
329
- parts.push(`<span class="claude-state-pill warn">${escapeHtml(shortValue(claudeStatus))}</span>`);
330
- }
331
- if (mode !== 'default') {
332
- parts.push(`<span class="claude-state-pill perm ${escapeHtml(mode)}">${escapeHtml(permissionModeLabel(mode))}</span>`);
333
- }
334
- el.innerHTML = parts.join('');
335
- el.style.display = parts.length ? 'flex' : 'none';
336
- renderSessionAttention(attention);
337
- }
338
-
339
- function jumpToClaudeApproval() {
340
- const pending = claudePendingPermissions.filter(item => item && item.status === 'pending');
341
- if (!pending.length) return false;
342
- const request = pending[claudeApprovalJumpIndex % pending.length];
343
- claudeApprovalJumpIndex = (claudeApprovalJumpIndex + 1) % pending.length;
344
- return focusClaudeApproval(String(request.id || ''));
345
- }
346
-
347
- function focusClaudeApproval(permissionId, retry = true) {
348
- const target = Array.from(document.querySelectorAll('[data-claude-permission-id]'))
349
- .find(element => element.dataset.claudePermissionId === permissionId);
350
- if (!target) {
351
- if (!retry) return false;
352
- commitClaudeChatRender();
353
- requestAnimationFrame(() => focusClaudeApproval(permissionId, false));
354
- return false;
355
- }
356
- for (let parent = target.parentElement; parent; parent = parent.parentElement) {
357
- if (parent.tagName === 'DETAILS') parent.open = true;
358
- }
359
- target.classList.remove('claude-approval-focus');
360
- void target.offsetWidth;
361
- target.classList.add('claude-approval-focus');
362
- target.setAttribute('tabindex', '-1');
363
- target.focus({ preventScroll: true });
364
- target.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
365
- setTimeout(() => target.classList.remove('claude-approval-focus'), 1800);
366
- return true;
367
- }
368
-
369
- function prepareClaudeInfoRequest() {
370
- closeClaudePicker();
371
- claudeResumePanelOpen = false;
372
- claudeForkPanelOpen = false;
373
- document.getElementById('claude-resume-panel').classList.remove('active');
374
- document.getElementById('claude-fork-panel').classList.remove('active');
375
- }
376
-
377
- function requestClaudeUsage() {
378
- if (!currentSocket || currentSocket.readyState !== 1 || claudeUsagePending) return;
379
- prepareClaudeInfoRequest();
380
- claudeUsagePending = true;
381
- applyClaudeState({});
382
- currentSocket.send(JSON.stringify({ type: 'claude-usage' }));
383
- }
384
-
385
- function requestClaudeContext() {
386
- if (!currentSocket || currentSocket.readyState !== 1 || claudeContextPending) return;
387
- prepareClaudeInfoRequest();
388
- claudeContextPending = true;
389
- applyClaudeState({});
390
- currentSocket.send(JSON.stringify({ type: 'claude-context' }));
391
- }
392
-
393
- async function updateClaudeSettingsFromControls() {
394
- await refreshClaudeRuntimeConfig();
395
- const settings = {
396
- permissionMode: document.getElementById('claude-permission-select').value,
397
- model: document.getElementById('claude-model-select').value,
398
- effort: document.getElementById('claude-effort-select').value
399
- };
400
- applyClaudeState(settings);
401
- if (currentSocket && currentSocket.readyState === 1) {
402
- currentSocket.send(JSON.stringify({ type: 'claude-settings', settings }));
403
- }
404
- }
405
-
406
- function abortClaudeSession() {
407
- if (!currentSocket || currentSocket.readyState !== 1) return;
408
- currentSocket.send(JSON.stringify({ type: 'claude-abort' }));
409
- }
410
-
411
- async function toggleClaudeResumePanel() {
412
- claudeResumePanelOpen = !claudeResumePanelOpen;
413
- if (claudeResumePanelOpen) closeClaudePicker();
414
- claudeForkPanelOpen = false;
415
- document.getElementById('claude-fork-panel').classList.remove('active');
416
- const panel = document.getElementById('claude-resume-panel');
417
- panel.classList.toggle('active', claudeResumePanelOpen);
418
- updateTerminalControlsHeight();
419
- if (claudeResumePanelOpen && !claudeResumeItemsLoaded) {
420
- await loadClaudeResumeSessions(panel, 'resume');
421
- }
422
- }
423
-
424
- async function toggleClaudeForkPanel() {
425
- if (claudeStatus === 'thinking') return;
426
- claudeForkPanelOpen = !claudeForkPanelOpen;
427
- closeClaudePicker();
428
- claudeResumePanelOpen = false;
429
- document.getElementById('claude-resume-panel').classList.remove('active');
430
- const panel = document.getElementById('claude-fork-panel');
431
- panel.classList.toggle('active', claudeForkPanelOpen);
432
- updateTerminalControlsHeight();
433
- if (claudeForkPanelOpen) await loadClaudeResumeSessions(panel, 'fork');
434
- }
435
-
436
- async function loadClaudeResumeSessions(panel = document.getElementById('claude-resume-panel'), action = 'resume') {
437
- if (!activeSessionId) return;
438
- panel.innerHTML = '<div class="claude-resume-meta" style="padding:12px;">Loading sessions...</div>';
439
- try {
440
- const res = await fetchWithTimeout(`/api/sessions/${activeSessionId}/claude-resume-sessions`, {}, 15000);
441
- const data = await res.json();
442
- if (!res.ok || !data.success) throw new Error(data.error || 'Failed to load resume sessions');
443
- const items = data.items || [];
444
- claudeResumeItemsLoaded = true;
445
- if (!items.length) {
446
- panel.innerHTML = '<div class="claude-resume-meta" style="padding:12px;">No local Claude sessions found for this folder.</div>';
447
- return;
448
- }
449
- panel.innerHTML = items.map(item => {
450
- const encodedId = encodePathValue(item.id);
451
- const updated = item.updatedAt ? new Date(item.updatedAt).toLocaleString() : 'Unknown';
452
- const active = item.id === claudeState.resumeSessionId || item.id === claudeState.claudeSessionId;
453
- const questions = Array.isArray(item.questions) ? item.questions : [];
454
- const handler = action === 'fork' ? 'selectClaudeForkSession' : 'selectClaudeResumeSession';
455
- return `<button class="claude-resume-item" onclick="${handler}(decodePathValue('${encodedId}'))">
456
- <div class="claude-resume-title"><span>${escapeHtml(questions[0] || item.firstText || 'Claude session')}${active ? ' · current' : ''}</span><span>${escapeHtml(updated)}</span></div>
457
- <div class="codex-resume-question-secondary">${escapeHtml(questions[1] || '')}</div>
458
- </button>`;
459
- }).join('');
460
- } catch (e) {
461
- panel.innerHTML = `<div class="claude-resume-meta" style="padding:12px; color:#ff6b61;">${escapeHtml(e.message)}</div>`;
462
- }
463
- }
464
-
465
- function selectClaudeResumeSession(id) {
466
- if (!id || !currentSocket || currentSocket.readyState !== 1) return;
467
- currentSocket.send(JSON.stringify({ type: 'claude-resume', resumeSessionId: id }));
468
- applyClaudeState({ resumeSessionId: id, claudeSessionId: id });
469
- claudeResumePanelOpen = false;
470
- document.getElementById('claude-resume-panel').classList.remove('active');
471
- updateTerminalControlsHeight();
472
- }
473
-
474
- async function selectClaudeForkSession(id) {
475
- if (!id || !activeSessionId || claudeStatus === 'thinking') return;
476
- const panel = document.getElementById('claude-fork-panel');
477
- panel.innerHTML = '<div class="claude-resume-meta" style="padding:12px;">Forking and switching this conversation...</div>';
478
- updateTerminalControlsHeight();
479
- try {
480
- const res = await fetchWithTimeout(`/api/sessions/${activeSessionId}/claude-fork`, {
481
- method: 'POST',
482
- headers: { 'Content-Type': 'application/json' },
483
- body: JSON.stringify({ claudeSessionId: id })
484
- }, 60000);
485
- const data = await res.json();
486
- if (!res.ok || !data.success) throw new Error(data.error || 'Unable to fork Claude session');
487
- applyClaudeState({ resumeSessionId: data.claudeSessionId, claudeSessionId: data.claudeSessionId });
488
- claudeForkPanelOpen = false;
489
- panel.classList.remove('active');
490
- panel.innerHTML = '';
491
- } catch (error) {
492
- panel.innerHTML = `<div class="claude-resume-meta" style="padding:12px;color:#ff6b61;">${escapeHtml(error.message)}</div>`;
493
- }
494
- updateTerminalControlsHeight();
495
- }
496
-
497
- function textFromClaudeMessage(message) {
498
- if (!message) return '';
499
- if (message.text) return String(message.text);
500
- if (message.summary) return String(message.summary);
501
- return '';
502
- }
503
-
504
- function escapeRegExp(text) {
505
- return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
506
- }
507
-
508
- function inlineMarkdown(text) {
509
- const protectedSegments = [];
510
- const protect = value => `\uE000${protectedSegments.push(value) - 1}\uE001`;
511
- const restore = value => value.replace(/\uE000(\d+)\uE001/g, (_match, index) => protectedSegments[Number(index)] || '');
512
- const formatText = value => {
513
- const codeSegments = [];
514
- const protectCode = code => `\uE002${codeSegments.push(code) - 1}\uE003`;
515
- const restoreCode = formatted => formatted.replace(/\uE002(\d+)\uE003/g, (_match, index) => codeSegments[Number(index)] || '');
516
- let formatted = value.replace(/`([^`]+)`/g, (_match, code) => protectCode(`<code>${code}</code>`));
517
- formatted = formatted.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
518
- formatted = formatted.replace(/(^|[^\p{L}\p{N}_])__([^_\n]+)__(?![\p{L}\p{N}_])/gu, '$1<strong>$2</strong>');
519
- formatted = formatted.replace(/\*([^*\n]+)\*/g, '<em>$1</em>');
520
- formatted = formatted.replace(/(^|[^\p{L}\p{N}_])_([^_\n]+)_(?![\p{L}\p{N}_])/gu, '$1<em>$2</em>');
521
- return restoreCode(formatted);
522
- };
523
-
524
- let html = escapeHtml(text || '');
525
- html = html.replace(/`([^`]+)`|!\[([^\]]*)\]\((https?:\/\/[^)\s]+)\)|\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g,
526
- (_match, code, alt, imageUrl, label, linkUrl) => {
527
- if (code !== undefined) return protect(`<code>${code}</code>`);
528
- if (imageUrl !== undefined) return protect(`<img src="${imageUrl}" alt="${alt}">`);
529
- return protect(`<a href="${linkUrl}" target="_blank" rel="noopener noreferrer">${formatText(label)}</a>`);
530
- });
531
- return restore(formatText(html));
532
- }
533
-
534
- function parseMarkdownFenceOpener(line) {
535
- const match = String(line || '').match(/^\s{0,3}(`{3,}|~{3,})(.*)$/);
536
- if (!match) return null;
537
- const marker = match[1];
538
- const info = match[2].trim();
539
- if (marker[0] === '`' && info.includes('`')) return null;
540
- return {
541
- character: marker[0],
542
- length: marker.length,
543
- language: info.split(/\s+/, 1)[0] || ''
544
- };
545
- }
546
-
547
- function isMarkdownFenceCloser(line, opener) {
548
- const match = String(line || '').match(/^\s{0,3}(`+|~+)\s*$/);
549
- return Boolean(match && match[1][0] === opener.character && match[1].length >= opener.length);
550
- }
551
-
552
- function splitMarkdownBlocks(markdown) {
553
- const lines = String(markdown || '').replace(/\r\n/g, '\n').split('\n');
554
- const blocks = [];
555
- let i = 0;
556
- while (i < lines.length) {
557
- if (!lines[i].trim()) {
558
- i++;
559
- continue;
560
- }
561
- const fence = parseMarkdownFenceOpener(lines[i]);
562
- if (fence) {
563
- const content = [];
564
- i++;
565
- while (i < lines.length && !isMarkdownFenceCloser(lines[i], fence)) {
566
- content.push(lines[i]);
567
- i++;
568
- }
569
- if (i < lines.length) i++;
570
- blocks.push({ type: 'code', language: fence.language, content: content.join('\n') });
571
- continue;
572
- }
573
- if (/^\s*[-*_]{3,}\s*$/.test(lines[i])) {
574
- blocks.push({ type: 'hr' });
575
- i++;
576
- continue;
577
- }
578
- if (/^\s{0,3}#{1,4}\s+/.test(lines[i])) {
579
- const match = lines[i].match(/^(\s{0,3})(#{1,4})\s+(.+)$/);
580
- blocks.push({ type: 'header', level: match[2].length, text: match[3] });
581
- i++;
582
- continue;
583
- }
584
- if (lines[i].includes('|') && i + 1 < lines.length && /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(lines[i + 1])) {
585
- const rows = [lines[i]];
586
- i += 2;
587
- while (i < lines.length && lines[i].includes('|') && lines[i].trim()) {
588
- rows.push(lines[i]);
589
- i++;
590
- }
591
- blocks.push({ type: 'table', rows });
592
- continue;
593
- }
594
- if (/^\s*([-*+])\s+/.test(lines[i]) || /^\s*\d+\.\s+/.test(lines[i])) {
595
- const orderedMatch = lines[i].match(/^\s*(\d+)\.\s+/);
596
- const ordered = Boolean(orderedMatch);
597
- const start = ordered ? Number(orderedMatch[1]) : null;
598
- const items = [];
599
- while (i < lines.length && (ordered ? /^\s*\d+\.\s+/.test(lines[i]) : /^\s*[-*+]\s+/.test(lines[i]))) {
600
- items.push(lines[i].replace(ordered ? /^\s*\d+\.\s+/ : /^\s*[-*+]\s+/, ''));
601
- i++;
602
- }
603
- blocks.push({ type: ordered ? 'ol' : 'ul', items, ...(ordered ? { start } : {}) });
604
- continue;
605
- }
606
- if (/^\s*>\s?/.test(lines[i])) {
607
- const quote = [];
608
- while (i < lines.length && /^\s*>\s?/.test(lines[i])) {
609
- quote.push(lines[i].replace(/^\s*>\s?/, ''));
610
- i++;
611
- }
612
- blocks.push({ type: 'quote', text: quote.join('\n') });
613
- continue;
614
- }
615
- const paragraph = [];
616
- while (i < lines.length && lines[i].trim()) {
617
- if (parseMarkdownFenceOpener(lines[i]) || /^\s{0,3}#{1,4}\s+/.test(lines[i]) || /^\s*([-*+])\s+/.test(lines[i]) || /^\s*\d+\.\s+/.test(lines[i])) break;
618
- paragraph.push(lines[i]);
619
- i++;
620
- }
621
- // Always make progress even if a future block detector and parser disagree.
622
- if (!paragraph.length) {
623
- paragraph.push(lines[i]);
624
- i++;
625
- }
626
- blocks.push({ type: 'paragraph', text: paragraph.join('\n') });
627
- }
628
- return blocks;
629
- }
630
-
631
- function renderMarkdown(markdown) {
632
- const blocks = splitMarkdownBlocks(markdown);
633
- return `<div class="claude-md">${blocks.map(block => {
634
- if (block.type === 'code') {
635
- const language = block.language ? `<div class="claude-tool-section-title">${escapeHtml(block.language)}</div>` : '';
636
- return `<pre>${language}<code>${escapeHtml(block.content)}</code></pre>`;
637
- }
638
- if (block.type === 'hr') return '<hr>';
639
- if (block.type === 'header') return `<h${block.level}>${inlineMarkdown(block.text)}</h${block.level}>`;
640
- if (block.type === 'ul') return `<ul>${block.items.map(item => `<li>${inlineMarkdown(item)}</li>`).join('')}</ul>`;
641
- if (block.type === 'ol') return `<ol${block.start !== 1 ? ` start="${block.start}"` : ''}>${block.items.map(item => `<li>${inlineMarkdown(item)}</li>`).join('')}</ol>`;
642
- if (block.type === 'quote') return `<blockquote>${renderMarkdown(block.text)}</blockquote>`;
643
- if (block.type === 'table') return renderMarkdownTable(block.rows);
644
- return `<p>${inlineMarkdown(block.text).replace(/\n/g, '<br>')}</p>`;
645
- }).join('')}</div>`;
646
- }
647
-
648
- function renderMarkdownTable(rows) {
649
- const parsed = rows.map(row => row.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(cell => cell.trim()));
650
- if (!parsed.length) return '';
651
- const [head, ...body] = parsed;
652
- return `<table><thead><tr>${head.map(cell => `<th>${inlineMarkdown(cell)}</th>`).join('')}</tr></thead><tbody>${body.map(row => `<tr>${row.map(cell => `<td>${inlineMarkdown(cell)}</td>`).join('')}</tr>`).join('')}</tbody></table>`;
653
- }
654
-
655
- function parseLocalCommandMessage(text) {
656
- const value = String(text || '');
657
- if (/^\s*<local-command-caveat>[\s\S]*?<\/local-command-caveat>\s*$/.test(value)) return { kind: 'hidden' };
658
- const stdoutMatch = value.match(/^\s*<local-command-stdout>\s*([\s\S]*?)\s*<\/local-command-stdout>\s*$/);
659
- if (stdoutMatch) {
660
- const stdout = stdoutMatch[1].trim();
661
- if (/^Goal set:/i.test(stdout)) return { kind: 'hidden' };
662
- return { kind: 'text', text: stdout };
663
- }
664
- const raw = value.trim().match(/^\/([a-zA-Z][\w:-]*)(?:\s+([\s\S]*?))?$/);
665
- if (raw) {
666
- return { kind: 'command', commandName: raw[1], args: raw[2] && raw[2].trim() };
667
- }
668
- const nameMatch = value.match(/<command-name>\s*\/?([^<]+?)\s*<\/command-name>/);
669
- if (nameMatch) {
670
- const argsMatch = value.match(/<command-args>\s*([\s\S]*?)\s*<\/command-args>/);
671
- const stripped = value
672
- .replace(/<command-message>[\s\S]*?<\/command-message>/g, '')
673
- .replace(/<command-name>[\s\S]*?<\/command-name>/g, '')
674
- .replace(/<command-args>[\s\S]*?<\/command-args>/g, '')
675
- .trim();
676
- if (!stripped) return { kind: 'command', commandName: nameMatch[1].trim(), args: argsMatch && argsMatch[1].trim() };
677
- return { kind: 'text', text: stripped };
678
- }
679
- return { kind: 'text', text: value };
680
- }
681
-
682
- function toolCategory(name) {
683
- if (['Bash', 'CodexBash', 'execute'].includes(name)) return 'terminal';
684
- if (['Edit', 'MultiEdit', 'Write', 'NotebookEdit'].includes(name)) return 'edit';
685
- if (['Read', 'LS'].includes(name)) return 'read';
686
- if (['Grep', 'Glob'].includes(name)) return 'search';
687
- if (['WebFetch', 'WebSearch'].includes(name)) return 'web';
688
- if (['Task', 'Agent'].includes(name)) return 'task';
689
- return 'other';
690
- }
691
-
692
- function toolIcon(category) {
693
- const labels = { terminal: '$', edit: '+/-', read: 'R', search: '?', web: 'W', task: 'A', other: '*' };
694
- return labels[category] || '*';
695
- }
696
-
697
- function toolCommand(tool) {
698
- const input = tool && tool.input;
699
- if (!input || typeof input !== 'object') return '';
700
- if (typeof input.command === 'string') return input.command;
701
- if (Array.isArray(input.command)) return input.command.join(' ');
702
- if (typeof input.file_path === 'string') return input.file_path;
703
- if (typeof input.path === 'string') return input.path;
704
- if (typeof input.pattern === 'string') return input.pattern;
705
- if (typeof input.prompt === 'string') return input.prompt;
706
- return tool.summary || '';
707
- }
708
-
709
- function toolTitle(tool) {
710
- const name = tool.name || 'Tool';
711
- const input = tool.input || {};
712
- if (name === 'Bash') return (tool.description || 'Terminal');
713
- if (name === 'Read' && input.file_path) return input.file_path;
714
- if (name === 'Edit' || name === 'MultiEdit' || name === 'Write') return input.file_path || name;
715
- if (name === 'Grep') return input.pattern ? `grep(pattern: ${input.pattern})` : 'Search Content';
716
- if (name === 'Glob') return input.pattern || 'Search Files';
717
- if ((name === 'Task' || name === 'Agent') && input.description) return input.description;
718
- if (name.startsWith('mcp__')) return name.replace(/^mcp__/, '').replace(/__/g, ': ');
719
- return name;
720
- }
721
-
722
- function renderToolSection(title, value) {
723
- if (value === undefined || value === null || value === '') return '';
724
- const code = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
725
- return `<div class="claude-tool-section"><div class="claude-tool-section-title">${escapeHtml(title)}</div><pre class="claude-tool-code">${escapeHtml(code)}</pre></div>`;
726
- }
727
-
728
- function renderClaudeDiff(oldText, newText) {
729
- const deleted = oldText ? String(oldText).split('\n').map(line => `<div class="codex-diff-line del">${escapeHtml(`-${line}`)}</div>`).join('') : '';
730
- const added = newText ? String(newText).split('\n').map(line => `<div class="codex-diff-line add">${escapeHtml(`+${line}`)}</div>`).join('') : '';
731
- return `<div class="codex-diff">${deleted}${added}</div>`;
732
- }
733
-
734
- function renderClaudeEditBody(tool) {
735
- const input = tool.input || {};
736
- const path = input.file_path || input.path || input.notebook_path || 'File';
737
- const edits = tool.name === 'MultiEdit' && Array.isArray(input.edits) ? input.edits
738
- : tool.name === 'Write' ? [{ old_string: '', new_string: input.content || '' }]
739
- : tool.name === 'NotebookEdit' ? [{ old_string: '', new_string: input.new_source || '' }]
740
- : [{ old_string: input.old_string || '', new_string: input.new_string || '' }];
741
- const changes = edits.map((edit, index) => `<details class="claude-edit-file"${edits.length === 1 ? ' open' : ''}>
742
- <summary><span class="path">${escapeHtml(path)}</span>${edits.length > 1 ? `<span class="codex-patch-kind">edit ${index + 1}</span>` : ''}</summary>
743
- ${renderClaudeDiff(edit.old_string, edit.new_string)}
744
- </details>`).join('');
745
- return `<div class="claude-edit-body">${changes}${tool.resultText ? renderToolSection(tool.isError ? 'Error' : 'Output', tool.resultText) : ''}</div>`;
746
- }
747
-
748
- function renderClaudeTool(tool, permission = null) {
749
- const category = toolCategory(tool.name || '');
750
- const command = toolCommand(tool);
751
- const status = tool.toolStatus || (tool.isError ? 'failed' : (tool.completedAtMs ? 'completed' : 'running'));
752
- const running = status === 'running';
753
- const hasResult = tool.resultText !== undefined && tool.resultText !== '';
754
- const duration = running ? '' : formatClaudeDuration(tool.durationMs);
755
- const open = tool.isError || (!hasResult && !running) ? ' open' : '';
756
- const compact = category === 'terminal' || category === 'search' || category === 'read';
757
- const body = isClaudeEditTool(tool.name) ? renderClaudeEditBody(tool)
758
- : compact && !tool.isError ? '' : `<div class="claude-tool-body">
759
- ${renderToolSection('Input', tool.input)}
760
- ${renderToolSection(tool.isError ? 'Error' : 'Output', tool.resultText)}
761
- </div>`;
762
- return `<details class="claude-tool claude-tool-card${tool.isError ? ' error' : ''}" data-claude-key="tool-${escapeHtml(tool.id || tool.toolUseId || '')}"${open}>
763
- <summary class="claude-tool-header">
764
- <span class="claude-tool-icon" data-icon="${escapeHtml(toolIcon(category))}"></span>
765
- <span class="claude-tool-title">${escapeHtml(toolTitle(tool))}</span>
766
- <span class="claude-tool-command">${escapeHtml(command)}</span>
767
- ${duration ? `<span class="claude-tool-duration">${escapeHtml(duration)}</span>` : ''}
768
- <span class="claude-tool-status${running ? ' running' : ''}">${escapeHtml(status === 'completed' ? '' : status)}</span>
769
- </summary>
770
- ${body}${permission ? renderClaudePermission(permission, true) : ''}
771
- </details>`;
772
- }
773
-
774
- function renderWorkGroup(items, context) {
775
- const running = items.some(item => item.kind === 'tool' && item.toolStatus === 'running');
776
- const failed = items.some(item => item.kind === 'tool' && ['failed', 'cancelled'].includes(item.toolStatus));
777
- const startedAt = Math.min(...items.map(item => Number(item.startedAtMs || item.createdAt || Date.now())));
778
- const completedAt = Math.max(...items.map(item => Number(item.completedAtMs || 0)));
779
- const duration = formatClaudeDuration(!running && completedAt >= startedAt ? completedAt - startedAt : 0);
780
- const label = running ? 'Working' : failed ? 'Work finished with errors' : duration ? `Worked for ${duration}` : 'Worked';
781
- const key = items.map(item => item.id || item.toolUseId || '').join('-');
782
- return `<details class="claude-work-group" data-claude-key="group-${escapeHtml(key)}"${running ? ' open' : ''}>
783
- <summary><span class="claude-tool-icon" data-icon="*"></span><span class="claude-work-group-title">${escapeHtml(label)} · ${items.length} ${items.length > 1 ? 'tools' : 'tool'}</span>${running ? '<span class="claude-tool-status running">running</span>' : ''}</summary>
784
- <div class="claude-work-group-body">${items.map(item => renderDisplayItem(item, context)).join('')}</div>
785
- </details>`;
786
- }
787
-
788
- function buildClaudeDisplayItems(messages) {
789
- const items = [];
790
- const byToolUseId = new Map();
791
- const turnEndById = new Map(messages
792
- .filter(message => message?.kind === 'turn-end' && message.turnId)
793
- .map(message => [String(message.turnId), message]));
794
- for (const message of messages) {
795
- if (!message) continue;
796
- if (message.kind === 'turn-start' || message.kind === 'turn-end') continue;
797
- if (message.kind === 'tool') {
798
- const item = { ...message, kind: 'tool', resultText: '', isError: false, toolStatus: message.toolStatus || 'running' };
799
- items.push(item);
800
- if (item.toolUseId) byToolUseId.set(item.toolUseId, item);
801
- continue;
802
- }
803
- if (message.kind === 'tool-result') {
804
- const parent = message.toolUseId && byToolUseId.get(message.toolUseId);
805
- if (parent) {
806
- parent.resultText = message.text || '';
807
- parent.isError = Boolean(message.isError);
808
- parent.completedAtMs = Number(message.completedAtMs || message.createdAt || Date.now());
809
- parent.durationMs = Math.max(0, parent.completedAtMs - Number(parent.startedAtMs || parent.createdAt || parent.completedAtMs));
810
- parent.toolStatus = parent.isError ? 'failed' : 'completed';
811
- } else {
812
- items.push(message);
813
- }
814
- continue;
815
- }
816
- items.push(message);
817
- }
818
-
819
- for (const item of items) {
820
- if (item.kind !== 'tool' || item.toolStatus !== 'running' || !item.turnId) continue;
821
- const turnEnd = turnEndById.get(String(item.turnId));
822
- if (!turnEnd) continue;
823
- item.completedAtMs = Number(turnEnd.createdAt || item.createdAt || Date.now());
824
- item.durationMs = Math.max(0, item.completedAtMs - Number(item.startedAtMs || item.createdAt || item.completedAtMs));
825
- item.toolStatus = turnEnd.turnStatus === 'failed' ? 'failed'
826
- : turnEnd.turnStatus === 'cancelled' ? 'cancelled' : 'completed';
827
- item.isError = item.toolStatus === 'failed';
828
- }
829
-
830
- const grouped = [];
831
- let run = [];
832
- const flush = () => {
833
- if (run.length > 1) grouped.push({ kind: 'work-group', items: run });
834
- else if (run.length === 1) grouped.push(run[0]);
835
- run = [];
836
- };
837
- for (const item of items) {
838
- if (item.kind === 'tool') {
839
- if (run.length && run[0].turnId !== item.turnId) flush();
840
- run.push(item);
841
- continue;
842
- }
843
- flush();
844
- grouped.push(item);
845
- }
846
- flush();
847
- return grouped;
848
- }
849
-
850
- function renderUserMessage(message) {
851
- const parsed = parseLocalCommandMessage(textFromClaudeMessage(message));
852
- if (parsed.kind === 'hidden') return '';
853
- const attachments = Array.isArray(message.attachments) && message.attachments.length
854
- ? `<div class="claude-message-attachments">${message.attachments.map(item => `<span class="claude-message-attachment" title="${escapeHtml(item.name || 'Attachment')}"><svg class="message-attachment-icon action-icon" aria-hidden="true"><use href="#icon-${item.kind === 'file' ? 'file' : 'image'}"></use></svg>${escapeHtml(item.name || 'Attachment')}</span>`).join('')}</div>` : '';
855
- if (parsed.kind === 'command') {
856
- const args = parsed.args ? `<div class="claude-message user">${renderMarkdown(parsed.args)}</div>` : '';
857
- return `${args}<div class="claude-message user"><span class="claude-command-chip">/${escapeHtml(parsed.commandName)}</span>${attachments}</div>`;
858
- }
859
- const content = parsed.text ? renderMarkdown(parsed.text) : '';
860
- return `<div class="claude-message user">${content}${attachments}</div>`;
861
- }
862
-
863
- function formatClaudeMoney(value, currency = 'USD') {
864
- const amount = Number(value || 0);
865
- try {
866
- return new Intl.NumberFormat([], { style: 'currency', currency: currency || 'USD', maximumFractionDigits: 2 }).format(amount);
867
- } catch {
868
- return `$${amount.toFixed(2)}`;
869
- }
870
- }
871
-
872
- function claudeUsageCardItem(label, value) {
873
- if (value == null || value === '') return '';
874
- return `<div class="codex-status-item"><div class="codex-status-label">${escapeHtml(label)}</div><div class="codex-status-value">${escapeHtml(value)}</div></div>`;
875
- }
876
-
877
- function renderClaudeUsageCard(message) {
878
- if (message.error) {
879
- return `<div class="codex-status-card claude-usage-card error" data-claude-key="message-${escapeHtml(message.id || '')}">
880
- <div class="codex-status-title">Claude usage</div>
881
- <div class="codex-status-value">${escapeHtml(message.error)}</div>
882
- </div>`;
883
- }
884
- const usage = message.usage || {};
885
- const session = usage.session || {};
886
- const duration = [session.wallDuration ? `${session.wallDuration} wall` : '', session.apiDuration ? `${session.apiDuration} API` : '']
887
- .filter(Boolean).join(' · ');
888
- const tokenUsage = session.inputTokens != null || session.outputTokens != null
889
- ? `${formatTokenCount(session.inputTokens)} in · ${formatTokenCount(session.outputTokens)} out`
890
- : '';
891
- const cacheUsage = session.cacheReadTokens != null || session.cacheWriteTokens != null
892
- ? `${formatTokenCount(session.cacheReadTokens)} read · ${formatTokenCount(session.cacheWriteTokens)} write`
893
- : '';
894
- const models = Array.isArray(session.models) ? session.models : [];
895
- const modelItems = models.map(model => claudeUsageCardItem(
896
- model.model || 'Model',
897
- `${formatTokenCount(model.inputTokens)} in · ${formatTokenCount(model.outputTokens)} out${model.costUsd == null ? '' : ` · ${formatClaudeMoney(model.costUsd)}`}`
898
- )).join('');
899
- return `<div class="codex-status-card claude-usage-card" data-claude-key="message-${escapeHtml(message.id || '')}">
900
- <div class="codex-status-title">${escapeHtml(message.title || 'Claude usage')}</div>
901
- <div class="codex-status-grid">
902
- ${claudeUsageCardItem('Session cost', session.totalCostUsd == null ? '' : formatClaudeMoney(session.totalCostUsd))}
903
- ${claudeUsageCardItem('Duration', duration)}
904
- ${claudeUsageCardItem('Tokens', tokenUsage)}
905
- ${claudeUsageCardItem('Cache', cacheUsage)}
906
- ${claudeUsageCardItem('Code changes', session.linesAdded == null && session.linesRemoved == null ? '' : `+${Number(session.linesAdded || 0)} / -${Number(session.linesRemoved || 0)}`)}
907
- ${modelItems}
908
- </div>
909
- </div>`;
910
- }
911
-
912
- function renderClaudeContextCard(message) {
913
- if (message.error) {
914
- return `<div class="codex-status-card claude-context-card error" data-claude-key="message-${escapeHtml(message.id || '')}">
915
- <div class="codex-status-title">Claude context</div>
916
- <div class="codex-status-value">${escapeHtml(message.error)}</div>
917
- </div>`;
918
- }
919
- const context = message.context || {};
920
- const hasTotals = context.usedTokens != null && context.maxTokens != null;
921
- const total = hasTotals
922
- ? `${formatTokenCount(context.usedTokens)} / ${formatTokenCount(context.maxTokens)} · ${Number(context.usedPercent || 0)}% used`
923
- : '';
924
- const categories = Array.isArray(context.categories) ? context.categories : [];
925
- const categoryItems = categories.map(category => claudeUsageCardItem(
926
- category.label || 'Context item',
927
- `${formatTokenCount(category.tokens)}${category.percent ? ` · ${category.percent}` : ''}`
928
- )).join('');
929
- return `<div class="codex-status-card claude-context-card" data-claude-key="message-${escapeHtml(message.id || '')}">
930
- <div class="codex-status-title">${escapeHtml(message.title || 'Claude context')}</div>
931
- <div class="codex-status-grid">
932
- ${claudeUsageCardItem('Model', context.model)}
933
- ${claudeUsageCardItem('Context', total)}
934
- ${claudeUsageCardItem('Available', context.remainingTokens == null ? '' : `${formatTokenCount(context.remainingTokens)} tokens`)}
935
- ${categoryItems}
936
- </div>
937
- </div>`;
938
- }
939
-
940
- function renderDisplayItem(message, context = null) {
941
- if (!message) return '';
942
- if (message.kind === 'user') return `<div class="claude-message-block user" data-claude-key="message-${escapeHtml(message.id || '')}">${renderUserMessage(message)}${renderClaudeMessageTime(message)}</div>`;
943
- if (message.kind === 'assistant') return `<div class="claude-message-block assistant" data-claude-key="message-${escapeHtml(message.id || '')}"><div class="claude-message assistant">${renderMarkdown(textFromClaudeMessage(message))}</div>${renderClaudeMessageTime(message, true)}</div>`;
944
- if (message.kind === 'tool') {
945
- const permission = context?.permissionByToolUseId.get(String(message.toolUseId || '')) || null;
946
- if (permission) context.usedPermissions.add(permission.id);
947
- return renderClaudeTool(message, permission);
948
- }
949
- if (message.kind === 'tool-result') return `<div class="claude-tool${message.isError ? ' error' : ''}">${renderToolSection(message.isError ? 'Error' : 'Output', message.text || '')}</div>`;
950
- if (message.kind === 'work-group') return renderWorkGroup(message.items, context);
951
- if (message.kind === 'usage') return renderClaudeUsageCard(message);
952
- if (message.kind === 'context') return renderClaudeContextCard(message);
953
- if (message.kind === 'event') return `<div class="claude-message event${message.level === 'error' ? ' error' : ''}" data-claude-key="message-${escapeHtml(message.id || '')}">${escapeHtml(message.text || '')}</div>`;
954
- return '';
955
- }
956
-
957
- function isClaudeEditTool(name) {
958
- return ['Edit', 'MultiEdit', 'Write', 'NotebookEdit'].includes(name || '');
959
- }
960
-
961
- function isClaudeExitPlanTool(name) {
962
- return name === 'exit_plan_mode' || name === 'ExitPlanMode';
963
- }
964
-
965
- function claudeAllowToolLabel(req) {
966
- if (req.toolName === 'Bash' && req.input && typeof req.input.command === 'string') return 'Allow command';
967
- return 'Allow tool';
968
- }
969
-
970
- function renderClaudePermissionActions(req) {
971
- const id = escapeHtml(req.id);
972
- const toolName = req.toolName || '';
973
- const parts = [
974
- `<button class="small-btn primary" onclick="respondClaudePermission('${id}', 'allow-once')">Yes</button>`
975
- ];
976
- if (isClaudeEditTool(toolName) || isClaudeExitPlanTool(toolName) || req.canAllowEdits) {
977
- parts.push(`<button class="small-btn primary" onclick="respondClaudePermission('${id}', 'allow-edits')">Allow edits</button>`);
978
- }
979
- if (isClaudeExitPlanTool(toolName) || req.canBypass) {
980
- parts.push(`<button class="small-btn primary" onclick="respondClaudePermission('${id}', 'bypass')">Allow all</button>`);
981
- }
982
- if (toolName && !isClaudeEditTool(toolName) && !isClaudeExitPlanTool(toolName) && req.canAllowTool !== false) {
983
- parts.push(`<button class="small-btn primary" onclick="respondClaudePermission('${id}', 'allow-tool')">${escapeHtml(claudeAllowToolLabel(req))}</button>`);
984
- }
985
- parts.push(`<button class="small-btn danger" onclick="respondClaudePermission('${id}', 'deny')">Deny</button>`);
986
- return parts.join('');
987
- }
988
-
989
- function renderClaudePermission(req, inline = false) {
990
- const id = escapeHtml(String(req.id || ''));
991
- const body = `${req.reason ? `<div>${escapeHtml(req.reason)}</div>` : ''}
992
- ${req.blockedPath ? `<div class="claude-resume-meta">${escapeHtml(req.blockedPath)}</div>` : ''}
993
- ${renderToolSection('Input', req.input || {})}
994
- <div class="claude-permission-actions">${renderClaudePermissionActions(req)}</div>`;
995
- if (inline) {
996
- return `<div class="claude-inline-permission" data-claude-permission-id="${id}">
997
- <div class="claude-inline-permission-title">${escapeHtml(req.title || req.toolName || 'Permission required')}</div>${body}
998
- </div>`;
999
- }
1000
- return `<div class="claude-tool claude-permission" data-claude-key="permission-${id}" data-claude-permission-id="${id}">
1001
- <div class="claude-tool-header"><span class="claude-tool-icon" data-icon="!"></span><strong>${escapeHtml(req.title || req.toolName || 'Permission required')}</strong></div>
1002
- <div class="claude-tool-body">${body}</div>
1003
- </div>`;
1004
- }
1005
-
1006
- function syncClaudeDom(current, next) {
1007
- if (!current || !next) return;
1008
- if (current.nodeType !== next.nodeType || current.nodeName !== next.nodeName) {
1009
- current.replaceWith(next.cloneNode(true));
1010
- return;
1011
- }
1012
- if (current.nodeType === Node.TEXT_NODE) {
1013
- if (current.nodeValue !== next.nodeValue) current.nodeValue = next.nodeValue;
1014
- return;
1015
- }
1016
- const currentKey = current.getAttribute?.('data-claude-key');
1017
- const nextKey = next.getAttribute?.('data-claude-key');
1018
- if (currentKey && nextKey && currentKey !== nextKey) {
1019
- current.replaceWith(next.cloneNode(true));
1020
- return;
1021
- }
1022
- const preserveOpen = current.tagName === 'DETAILS' && currentKey === nextKey;
1023
- const wasOpen = preserveOpen ? current.open : false;
1024
- for (const attribute of Array.from(current.attributes || [])) {
1025
- if (!next.hasAttribute(attribute.name) && !(preserveOpen && attribute.name === 'open')) current.removeAttribute(attribute.name);
1026
- }
1027
- for (const attribute of Array.from(next.attributes || [])) {
1028
- if (!(preserveOpen && attribute.name === 'open') && current.getAttribute(attribute.name) !== attribute.value) {
1029
- current.setAttribute(attribute.name, attribute.value);
1030
- }
1031
- }
1032
- if (preserveOpen) current.open = wasOpen;
1033
- const currentChildren = Array.from(current.childNodes);
1034
- const nextChildren = Array.from(next.childNodes);
1035
- const shared = Math.min(currentChildren.length, nextChildren.length);
1036
- for (let i = 0; i < shared; i++) syncClaudeDom(currentChildren[i], nextChildren[i]);
1037
- for (let i = current.childNodes.length - 1; i >= nextChildren.length; i--) current.childNodes[i].remove();
1038
- for (let i = shared; i < nextChildren.length; i++) current.appendChild(nextChildren[i].cloneNode(true));
1039
- }
1040
-
1041
- function renderClaudeChat() {
1042
- if (claudeRenderFrame != null) return;
1043
- claudeRenderFrame = requestAnimationFrame(() => {
1044
- claudeRenderFrame = null;
1045
- commitClaudeChatRender();
1046
- });
1047
- }
1048
-
1049
- function commitClaudeChatRender() {
1050
- const container = document.getElementById('claude-chat-container');
1051
- if (!container) return;
1052
- const wasEmpty = !container.firstElementChild;
1053
- const previousScrollTop = container.scrollTop;
1054
- const distanceFromBottom = container.scrollHeight - container.clientHeight - previousScrollTop;
1055
- const shouldStickToBottom = wasEmpty || distanceFromBottom <= 64;
1056
- const permissionByToolUseId = new Map(claudePendingPermissions
1057
- .filter(item => item.status === 'pending' && item.toolUseId)
1058
- .map(item => [String(item.toolUseId), item]));
1059
- const context = { permissionByToolUseId, usedPermissions: new Set() };
1060
- const parts = buildClaudeDisplayItems(claudeMessages).map(item => renderDisplayItem(item, context)).filter(Boolean);
1061
- for (const req of claudePendingPermissions.filter(item => item.status === 'pending')) {
1062
- if (!context.usedPermissions.has(req.id)) parts.push(renderClaudePermission(req));
1063
- }
1064
- const working = `<div class="claude-working-indicator" role="status" aria-label="Claude is working" title="Claude is working"${claudeStatus === 'thinking' ? '' : ' style="display:none"'}></div>`;
1065
- const template = document.createElement('template');
1066
- template.innerHTML = `<div class="claude-conversation">${working}${parts.join('') || '<div class="claude-message event">Send a message to start Claude.</div>'}</div>`;
1067
- const next = template.content.firstElementChild;
1068
- const current = container.firstElementChild;
1069
- if (!current) container.appendChild(next);
1070
- else syncClaudeDom(current, next);
1071
- renderClaudeStateBar();
1072
- container.scrollTop = shouldStickToBottom ? container.scrollHeight : previousScrollTop;
1073
- }
1074
-
1075
- function applyClaudeEvent(event) {
1076
- if (!event) return;
1077
- if (event.type === 'message' && event.message) {
1078
- claudeMessages.push(event.message);
1079
- if (event.message.kind === 'usage') claudeUsagePending = false;
1080
- if (event.message.kind === 'context') claudeContextPending = false;
1081
- } else if (event.type === 'status') {
1082
- claudeStatus = event.status || 'idle';
1083
- } else if (event.type === 'permission-request' && event.request) {
1084
- claudePendingPermissions = claudePendingPermissions.filter(item => item.id !== event.request.id);
1085
- claudePendingPermissions.push(event.request);
1086
- } else if (event.type === 'permission-updated' && event.request) {
1087
- claudePendingPermissions = claudePendingPermissions.map(item => item.id === event.request.id ? event.request : item);
1088
- } else if (event.type === 'history-reset' && Array.isArray(event.messages)) {
1089
- claudeMessages = event.messages;
1090
- } else if (event.type === 'state' && event.state) {
1091
- applyClaudeState(event.state, { providerStateReceived: true });
1092
- }
1093
- if (event.type !== 'state') applyClaudeState({
1094
- status: claudeStatus,
1095
- pendingPermissionCount: claudePendingPermissions.filter(item => item.status === 'pending').length,
1096
- canAbort: claudeStatus === 'thinking'
1097
- }, { providerStateReceived: event.type === 'status' });
1098
- renderClaudeChat();
1099
- }
1100
-
1101
- function respondClaudePermission(id, actionOrApproved) {
1102
- if (!currentSocket || currentSocket.readyState !== 1) return;
1103
- const action = typeof actionOrApproved === 'string'
1104
- ? actionOrApproved
1105
- : (actionOrApproved ? 'allow-once' : 'deny');
1106
- currentSocket.send(JSON.stringify({
1107
- type: 'claude-permission',
1108
- id,
1109
- action,
1110
- approved: action !== 'deny'
1111
- }));
1112
- }
1113
-
1114
- async function createSession(toolKey, sessionName) {
1115
- const selectedSkill = window.pendingSkillHubSkill || null;
1116
- try {
1117
- const workingDirectory = document.getElementById('cwd-field').value;
1118
- const runtimeConfig = toolKey === 'claude-code' ? await refreshClaudeRuntimeConfig() : null;
1119
- const claudeOptions = runtimeConfig ? {
1120
- model: runtimeConfig.defaultModel || 'default',
1121
- effort: runtimeConfig.defaultEffort || 'medium'
1122
- } : undefined;
1123
- const endpoint = selectedSkill ? '/api/skillhub/sessions' : '/api/sessions';
1124
- const body = selectedSkill ? {
1125
- toolKey,
1126
- workingDirectory,
1127
- skill: {
1128
- id: selectedSkill.id,
1129
- version: selectedSkill.version,
1130
- digest: selectedSkill.digest
1131
- }
1132
- } : { toolKey, workingDirectory, claudeOptions };
1133
- const list = document.getElementById('tools-list');
1134
- if (selectedSkill) list.innerHTML = '<p class="skill-hall-status">Downloading and verifying Skill…</p>';
1135
- const res = await fetchWithTimeout(endpoint, {
1136
- method: 'POST',
1137
- headers: { 'Content-Type': 'application/json' },
1138
- body: JSON.stringify(body)
1139
- }, selectedSkill ? 70000 : 30000);
1140
- const data = await res.json();
1141
- if (!res.ok || !data.id) throw new Error(data.error || 'Failed to create session');
1142
- document.getElementById('modal-overlay').style.display = 'none';
1143
- window.pendingSkillHubSkill = null;
1144
- if (selectedSkill) joinSession(data.id, data.name || selectedSkill.name, 'codex');
1145
- else refreshSessionsNow();
1146
- } catch (e) {
1147
- alert('Failed to create session: ' + e.message);
1148
- if (selectedSkill) await showToolModal(selectedSkill);
1149
- }
1150
- }