glad-web 1.0.28 → 1.0.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/claude/cli-usage.js +95 -0
- package/lib/claude/structured-session.js +223 -51
- package/lib/claude/transcript-repository.js +216 -0
- package/lib/codex/image-store.js +175 -0
- package/lib/codex/structured-session.js +18 -4
- package/lib/commands/web.js +52 -234
- package/lib/server/routes/providers.js +103 -0
- package/lib/server/routes/schedules.js +54 -0
- package/lib/server/routes/workspace.js +77 -0
- package/lib/session/session-manager.js +111 -340
- package/lib/web/claude.js +1074 -0
- package/lib/web/codex.js +475 -0
- package/lib/web/composer.js +230 -0
- package/lib/web/core.js +327 -0
- package/lib/web/git.js +533 -0
- package/lib/web/index.html +45 -3610
- package/lib/web/schedules.js +245 -0
- package/lib/web/session.js +351 -0
- package/lib/web/shell.js +56 -0
- package/lib/web/styles.css +377 -0
- package/lib/web/terminal-scroll.js +81 -0
- package/lib/web/timed-inputs.js +223 -0
- package/lib/workspace/service.js +3 -2
- package/package.json +10 -5
- package/scripts/check-syntax.js +26 -0
package/lib/web/codex.js
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
function codexText(text) { return escapeHtml(text || '').replace(/\n/g, '<br>'); }
|
|
2
|
+
function codexJson(value) {
|
|
3
|
+
if (typeof value === 'string') return value;
|
|
4
|
+
try { return JSON.stringify(value || {}, null, 2); } catch (_) { return String(value || ''); }
|
|
5
|
+
}
|
|
6
|
+
function codexToolStatus(item) {
|
|
7
|
+
const status = item.toolStatus || 'running';
|
|
8
|
+
if (status === 'inProgress') return 'running';
|
|
9
|
+
if (status === 'failed' || status === 'declined') return status;
|
|
10
|
+
if (status === 'completed') return item.exitCode && item.exitCode !== 0 ? 'failed' : 'completed';
|
|
11
|
+
return status;
|
|
12
|
+
}
|
|
13
|
+
function formatCodexDuration(durationMs) {
|
|
14
|
+
const value = Number(durationMs || 0);
|
|
15
|
+
if (!(value > 0)) return '';
|
|
16
|
+
if (value < 1000) return `${Math.round(value)}ms`;
|
|
17
|
+
const seconds = value / 1000;
|
|
18
|
+
if (seconds < 10) return `${seconds.toFixed(1).replace(/\.0$/, '')}s`;
|
|
19
|
+
if (seconds < 60) return `${Math.round(seconds)}s`;
|
|
20
|
+
const minutes = Math.floor(seconds / 60);
|
|
21
|
+
return `${minutes}m ${Math.round(seconds % 60)}s`;
|
|
22
|
+
}
|
|
23
|
+
function renderCodexDiff(diff) {
|
|
24
|
+
return `<div class="codex-diff">${String(diff || '').split('\n').map(line => {
|
|
25
|
+
const type = line.startsWith('+++') || line.startsWith('---') ? 'hunk' : line.startsWith('+') ? 'add' : line.startsWith('-') ? 'del' : line.startsWith('@@') ? 'hunk' : '';
|
|
26
|
+
return `<div class="codex-diff-line ${type}">${escapeHtml(line || ' ')}</div>`;
|
|
27
|
+
}).join('')}</div>`;
|
|
28
|
+
}
|
|
29
|
+
function normalizeCodexChanges(changes) {
|
|
30
|
+
if (Array.isArray(changes)) return changes.filter(Boolean).map(change => [change.path || 'File', change]);
|
|
31
|
+
if (changes && typeof changes === 'object') return Object.entries(changes);
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
function codexChangeDiff(change = {}) {
|
|
35
|
+
if (typeof change.diff === 'string') return change.diff;
|
|
36
|
+
if (typeof change.unified_diff === 'string') return change.unified_diff;
|
|
37
|
+
const kind = change.kind && typeof change.kind === 'object' ? change.kind : {};
|
|
38
|
+
const type = change.type || kind.type || '';
|
|
39
|
+
const oldText = change.modify?.old_content ?? change.old_content ?? change.oldContent ?? (type === 'delete' ? change.content : change.delete?.content) ?? '';
|
|
40
|
+
const newText = change.modify?.new_content ?? change.new_content ?? change.newContent ?? (type === 'add' ? change.content : change.add?.content) ?? '';
|
|
41
|
+
if (!oldText && !newText) return '';
|
|
42
|
+
return `${String(oldText).split('\n').map(line => `-${line}`).join('\n')}\n${String(newText).split('\n').map(line => `+${line}`).join('\n')}`;
|
|
43
|
+
}
|
|
44
|
+
function renderCodexPatch(item) {
|
|
45
|
+
const entries = normalizeCodexChanges(item.changes || item.input?.changes);
|
|
46
|
+
if (!entries.length) return `<div class="codex-tool-body"><pre class="codex-tool-code">${escapeHtml(codexJson(item.input))}</pre></div>`;
|
|
47
|
+
return entries.map(([path, change]) => {
|
|
48
|
+
const kind = change?.type || change?.kind?.type || 'edit';
|
|
49
|
+
const move = change?.move_path || change?.kind?.move_path;
|
|
50
|
+
const diff = codexChangeDiff(change);
|
|
51
|
+
return `<details class="codex-patch-file"><summary><span class="path">${escapeHtml(path)}${move ? ` → ${escapeHtml(move)}` : ''}</span><span class="codex-patch-kind">${escapeHtml(kind)}</span></summary>${diff ? renderCodexDiff(diff) : ''}</details>`;
|
|
52
|
+
}).join('');
|
|
53
|
+
}
|
|
54
|
+
function renderCodexPermission(request, inline = false) {
|
|
55
|
+
if (!request) return '';
|
|
56
|
+
const pending = request.status === 'pending';
|
|
57
|
+
const permissionId = escapeHtml(String(request.id || ''));
|
|
58
|
+
const labels = { approved: 'Allowed once', approved_for_session: 'Allowed for session', denied: 'Denied', abort: 'Stopped' };
|
|
59
|
+
const detail = request.reason || (request.input && Object.keys(request.input).length ? codexJson(request.input) : '');
|
|
60
|
+
const content = `${inline ? `<div class="codex-inline-permission-title">${escapeHtml(request.title || 'Permission required')}</div>` : ''}${detail ? `<div>${codexText(detail)}</div>` : ''}${pending ? `<div class="claude-permission-actions"><button class="small-btn primary" onclick="respondCodexPermission('${escapeHtml(request.id)}', 'approved')">Yes</button><button class="small-btn" onclick="respondCodexPermission('${escapeHtml(request.id)}', 'approved_for_session')">Yes, for session</button><button class="small-btn danger" onclick="respondCodexPermission('${escapeHtml(request.id)}', 'abort')">Stop and explain</button></div>` : `<div class="codex-permission-result">${escapeHtml(labels[request.decision] || request.status)}</div>`}`;
|
|
61
|
+
if (inline) return `<div class="codex-inline-permission" data-codex-permission-id="${permissionId}">${content}</div>`;
|
|
62
|
+
return `<div class="claude-tool claude-permission" data-codex-permission-id="${permissionId}"><div class="claude-tool-header"><strong>${escapeHtml(request.title || 'Permission required')}</strong></div><div class="claude-tool-body">${content}</div></div>`;
|
|
63
|
+
}
|
|
64
|
+
function formatCodexReset(timestamp) {
|
|
65
|
+
const value = Number(timestamp || 0);
|
|
66
|
+
return value ? new Date(value * 1000).toLocaleString() : '';
|
|
67
|
+
}
|
|
68
|
+
function formatCodexTokens(value) {
|
|
69
|
+
const count = Number(value || 0);
|
|
70
|
+
return count >= 1000000 ? `${(count / 1000000).toFixed(1)}M`
|
|
71
|
+
: count >= 1000 ? `${Math.round(count / 1000)}K` : String(count);
|
|
72
|
+
}
|
|
73
|
+
function codexStatusItem(label, value) {
|
|
74
|
+
if (value == null || value === '') return '';
|
|
75
|
+
return `<div class="codex-status-item"><div class="codex-status-label">${escapeHtml(label)}</div><div class="codex-status-value">${escapeHtml(value)}</div></div>`;
|
|
76
|
+
}
|
|
77
|
+
function renderCodexStatus(item) {
|
|
78
|
+
const account = item.account || {};
|
|
79
|
+
const limit = item.rateLimits || {};
|
|
80
|
+
const primary = limit.primary;
|
|
81
|
+
const secondary = limit.secondary;
|
|
82
|
+
const context = item.context;
|
|
83
|
+
const accountLabel = account.type === 'chatgpt'
|
|
84
|
+
? [account.email, account.planType].filter(Boolean).join(' · ')
|
|
85
|
+
: account.type === 'apiKey' ? 'API key' : account.type || 'Not signed in';
|
|
86
|
+
const fiveHour = primary ? `${Math.max(0, 100 - Number(primary.usedPercent || 0))}% left${primary.resetsAt ? ` · resets ${formatCodexReset(primary.resetsAt)}` : ''}` : '';
|
|
87
|
+
const weekly = secondary ? `${Math.max(0, 100 - Number(secondary.usedPercent || 0))}% left${secondary.resetsAt ? ` · resets ${formatCodexReset(secondary.resetsAt)}` : ''}` : '';
|
|
88
|
+
const contextLabel = context ? `${context.remainingPercent}% left${context.contextWindow ? ` · ${formatCodexTokens(context.remainingTokens)} / ${formatCodexTokens(context.contextWindow)}` : ''}` : 'Available after the first usage update';
|
|
89
|
+
return `<div class="codex-status-card"><div class="codex-status-title">${escapeHtml(item.title || 'Codex status')}</div><div class="codex-status-grid">
|
|
90
|
+
${codexStatusItem('Account', accountLabel)}
|
|
91
|
+
${codexStatusItem('Model', [item.model, item.effort].filter(Boolean).join(' · '))}
|
|
92
|
+
${codexStatusItem('5h limit', fiveHour)}
|
|
93
|
+
${codexStatusItem('Weekly limit', weekly)}
|
|
94
|
+
${codexStatusItem('Context', contextLabel)}
|
|
95
|
+
</div></div>`;
|
|
96
|
+
}
|
|
97
|
+
function renderCodexTool(item, permission = null) {
|
|
98
|
+
const status = codexToolStatus(item);
|
|
99
|
+
const isError = status === 'failed' || Boolean(item.error) || (item.exitCode != null && item.exitCode !== 0);
|
|
100
|
+
const runningClass = status === 'running' ? ' running' : '';
|
|
101
|
+
if (item.name === 'CodexPatch') {
|
|
102
|
+
return `<div class="codex-tool${isError ? ' error' : ''}">${renderCodexPatch(item)}${permission ? renderCodexPermission(permission, true) : ''}</div>`;
|
|
103
|
+
}
|
|
104
|
+
const command = item.name === 'CodexBash' ? item.command : item.title || item.tool || '';
|
|
105
|
+
const icon = item.name === 'CodexBash' ? '>_' : item.name === 'McpTool' ? 'MCP' : item.name === 'Agent' ? 'A' : '•';
|
|
106
|
+
const title = item.name === 'CodexBash' ? 'Command' : item.title || item.name || 'Tool';
|
|
107
|
+
const hasInput = item.input && typeof item.input === 'object' && Object.keys(item.input).length > 0;
|
|
108
|
+
const result = item.result || ((item.name === 'McpTool' || item.name === 'Agent') && hasInput ? codexJson(item.input) : '');
|
|
109
|
+
const duration = status === 'running' ? '' : formatCodexDuration(item.durationMs);
|
|
110
|
+
return `<details class="codex-tool${isError ? ' error' : ''}" data-codex-key="tool-${escapeHtml(item.id || item.providerId || '')}"><summary class="codex-tool-header"><span class="codex-tool-icon">${escapeHtml(icon)}</span><span class="codex-tool-title">${escapeHtml(title)}</span>${command && command !== title ? `<span class="codex-tool-command">${escapeHtml(command)}</span>` : '<span class="codex-tool-command"></span>'}${duration ? `<span class="codex-tool-duration">${escapeHtml(duration)}</span>` : ''}<span class="codex-tool-state${runningClass}">${escapeHtml(status === 'completed' ? '' : status)}</span></summary>${result ? `<div class="codex-tool-body"><pre class="codex-tool-code">${escapeHtml(result)}</pre></div>` : ''}${permission ? renderCodexPermission(permission, true) : ''}</details>`;
|
|
111
|
+
}
|
|
112
|
+
function renderCodexToolGroup(items, permissionById, usedPermissions, turnEnd = null) {
|
|
113
|
+
const running = items.some(item => codexToolStatus(item) === 'running');
|
|
114
|
+
const failed = items.some(item => codexToolStatus(item) === 'failed' || item.error);
|
|
115
|
+
const startedAt = Math.min(...items.map(item => Number(item.startedAtMs || item.createdAt || Date.now())));
|
|
116
|
+
const itemCompletedAt = Math.max(...items.map(item => Number(item.completedAtMs || 0)));
|
|
117
|
+
const storedDuration = Number(turnEnd?.durationMs || 0);
|
|
118
|
+
const completedAt = Number(turnEnd?.createdAt || 0);
|
|
119
|
+
const durationMs = itemCompletedAt >= startedAt ? itemCompletedAt - startedAt : storedDuration > 0 ? storedDuration
|
|
120
|
+
: (!running && completedAt >= startedAt ? completedAt - startedAt : 0);
|
|
121
|
+
const duration = formatCodexDuration(durationMs);
|
|
122
|
+
const tools = items.map(item => {
|
|
123
|
+
const permission = permissionById.get(String(item.providerId || ''));
|
|
124
|
+
if (permission) usedPermissions.add(permission.id);
|
|
125
|
+
return renderCodexTool(item, permission);
|
|
126
|
+
}).join('');
|
|
127
|
+
const label = running ? 'Working' : failed ? 'Work finished with errors' : duration ? `Worked for ${duration}` : 'Worked';
|
|
128
|
+
const key = items.map(item => item.id || item.providerId || '').join('-');
|
|
129
|
+
return `<details class="codex-work-group" data-codex-key="group-${escapeHtml(key)}"><summary>${label} · ${items.length} ${items.length === 1 ? 'tool' : 'tools'}</summary><div class="codex-work-group-body">${tools}</div></details>`;
|
|
130
|
+
}
|
|
131
|
+
function isCodexSubagentItem(item) {
|
|
132
|
+
return Boolean(item?.threadId && codexState.threadId && item.threadId !== codexState.threadId);
|
|
133
|
+
}
|
|
134
|
+
function renderCodexSubagentGroup(threadId, items, permissionById, usedPermissions) {
|
|
135
|
+
const turnEnds = items.filter(item => item.kind === 'turn-end');
|
|
136
|
+
const content = items.filter(item => !['reasoning', 'turn-start', 'turn-end'].includes(item.kind));
|
|
137
|
+
const tools = content.filter(item => item.kind === 'tool');
|
|
138
|
+
const messages = content.filter(item => item.kind === 'assistant' || item.kind === 'user');
|
|
139
|
+
const running = tools.some(item => codexToolStatus(item) === 'running')
|
|
140
|
+
|| items.filter(item => item.kind === 'turn-start').length > turnEnds.length;
|
|
141
|
+
const startedAt = Math.min(...items.map(item => Number(item.startedAtMs || item.createdAt || Infinity)));
|
|
142
|
+
const completedAt = Math.max(...turnEnds.map(item => Number(item.createdAt || 0)),
|
|
143
|
+
...content.map(item => Number(item.completedAtMs || item.updatedAt || 0)));
|
|
144
|
+
const duration = formatCodexDuration(Number.isFinite(startedAt) && completedAt >= startedAt
|
|
145
|
+
? completedAt - startedAt : 0);
|
|
146
|
+
const counts = [];
|
|
147
|
+
if (tools.length) counts.push(`${tools.length} ${tools.length === 1 ? 'tool' : 'tools'}`);
|
|
148
|
+
if (messages.length) counts.push(`${messages.length} ${messages.length === 1 ? 'message' : 'messages'}`);
|
|
149
|
+
const label = running ? 'Subagent working' : duration ? `Subagent worked for ${duration}` : 'Subagent worked';
|
|
150
|
+
const body = [];
|
|
151
|
+
for (let i = 0; i < content.length;) {
|
|
152
|
+
const item = content[i];
|
|
153
|
+
if (item.kind === 'tool') {
|
|
154
|
+
const group = [];
|
|
155
|
+
const turnId = item.turnId;
|
|
156
|
+
while (i < content.length && content[i].kind === 'tool' && content[i].turnId === turnId) group.push(content[i++]);
|
|
157
|
+
const turnEnd = turnEnds.find(candidate => candidate.turnId === turnId) || null;
|
|
158
|
+
body.push(renderCodexToolGroup(group, permissionById, usedPermissions, turnEnd));
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (item.kind === 'assistant' || item.kind === 'user') {
|
|
162
|
+
body.push(`<div class="codex-subagent-message${item.kind === 'user' ? ' task' : ''}">${renderMarkdown(item.text || '')}</div>`);
|
|
163
|
+
} else if (item.text) {
|
|
164
|
+
body.push(`<div class="codex-subagent-message">${codexText(item.text)}</div>`);
|
|
165
|
+
}
|
|
166
|
+
i += 1;
|
|
167
|
+
}
|
|
168
|
+
const suffix = counts.length ? ` · ${counts.join(' · ')}` : '';
|
|
169
|
+
return `<details class="codex-work-group codex-subagent-group" data-codex-key="subagent-${escapeHtml(threadId)}"><summary>${escapeHtml(label + suffix)}</summary><div class="codex-work-group-body">${body.join('')}</div></details>`;
|
|
170
|
+
}
|
|
171
|
+
function renderCodexMessageTime(item, finalOnly = false) {
|
|
172
|
+
if (!item || (finalOnly && item.streaming)) return '';
|
|
173
|
+
const timestamp = Number(finalOnly ? (item.completedAtMs || item.updatedAt || item.createdAt) : item.createdAt);
|
|
174
|
+
if (!Number.isFinite(timestamp) || timestamp <= 0) return '';
|
|
175
|
+
const date = new Date(timestamp);
|
|
176
|
+
if (Number.isNaN(date.getTime())) return '';
|
|
177
|
+
const label = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
178
|
+
return `<time class="codex-message-time" datetime="${escapeHtml(date.toISOString())}" title="${escapeHtml(date.toLocaleString())}">${escapeHtml(label)}</time>`;
|
|
179
|
+
}
|
|
180
|
+
function syncCodexDom(current, next) {
|
|
181
|
+
if (!current || !next) return;
|
|
182
|
+
if (current.nodeType !== next.nodeType || current.nodeName !== next.nodeName) {
|
|
183
|
+
current.replaceWith(next.cloneNode(true));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (current.nodeType === Node.TEXT_NODE) {
|
|
187
|
+
if (current.nodeValue !== next.nodeValue) current.nodeValue = next.nodeValue;
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const currentKey = current.getAttribute?.('data-codex-key');
|
|
191
|
+
const nextKey = next.getAttribute?.('data-codex-key');
|
|
192
|
+
if (currentKey && nextKey && currentKey !== nextKey) {
|
|
193
|
+
current.replaceWith(next.cloneNode(true));
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const preserveOpen = current.tagName === 'DETAILS' && currentKey === nextKey;
|
|
197
|
+
const wasOpen = preserveOpen ? current.open : false;
|
|
198
|
+
for (const attribute of Array.from(current.attributes || [])) {
|
|
199
|
+
if (!next.hasAttribute(attribute.name) && !(preserveOpen && attribute.name === 'open')) current.removeAttribute(attribute.name);
|
|
200
|
+
}
|
|
201
|
+
for (const attribute of Array.from(next.attributes || [])) {
|
|
202
|
+
if (!(preserveOpen && attribute.name === 'open') && current.getAttribute(attribute.name) !== attribute.value) {
|
|
203
|
+
current.setAttribute(attribute.name, attribute.value);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (preserveOpen) current.open = wasOpen;
|
|
207
|
+
const currentChildren = Array.from(current.childNodes);
|
|
208
|
+
const nextChildren = Array.from(next.childNodes);
|
|
209
|
+
const shared = Math.min(currentChildren.length, nextChildren.length);
|
|
210
|
+
for (let i = 0; i < shared; i++) syncCodexDom(currentChildren[i], nextChildren[i]);
|
|
211
|
+
for (let i = current.childNodes.length - 1; i >= nextChildren.length; i--) current.childNodes[i].remove();
|
|
212
|
+
for (let i = shared; i < nextChildren.length; i++) current.appendChild(nextChildren[i].cloneNode(true));
|
|
213
|
+
}
|
|
214
|
+
function renderCodexChat() {
|
|
215
|
+
if (codexRenderFrame != null) return;
|
|
216
|
+
codexRenderFrame = requestAnimationFrame(() => {
|
|
217
|
+
codexRenderFrame = null;
|
|
218
|
+
commitCodexChatRender();
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
function commitCodexChatRender() {
|
|
222
|
+
const container = document.getElementById('codex-chat-container');
|
|
223
|
+
if (!container) return;
|
|
224
|
+
const wasEmpty = !container.firstElementChild;
|
|
225
|
+
const previousScrollTop = container.scrollTop;
|
|
226
|
+
const distanceFromBottom = container.scrollHeight - container.clientHeight - previousScrollTop;
|
|
227
|
+
const shouldStickToBottom = wasEmpty || distanceFromBottom <= 64;
|
|
228
|
+
const parts = [];
|
|
229
|
+
const permissionById = new Map(codexPendingPermissions.map(item => [String(item.id), item]));
|
|
230
|
+
const usedPermissions = new Set();
|
|
231
|
+
const turnEndById = new Map(codexMessages.filter(item => item.kind === 'turn-end' && item.turnId)
|
|
232
|
+
.map(item => [String(item.turnId), item]));
|
|
233
|
+
const subagentItems = new Map();
|
|
234
|
+
for (const item of codexMessages) {
|
|
235
|
+
if (!isCodexSubagentItem(item)) continue;
|
|
236
|
+
const threadId = String(item.threadId);
|
|
237
|
+
if (!subagentItems.has(threadId)) subagentItems.set(threadId, []);
|
|
238
|
+
subagentItems.get(threadId).push(item);
|
|
239
|
+
}
|
|
240
|
+
const renderedSubagents = new Set();
|
|
241
|
+
const visible = codexMessages.filter(item => !['reasoning', 'turn-start', 'turn-end'].includes(item.kind));
|
|
242
|
+
for (let i = 0; i < visible.length;) {
|
|
243
|
+
const item = visible[i];
|
|
244
|
+
if (isCodexSubagentItem(item)) {
|
|
245
|
+
const threadId = String(item.threadId);
|
|
246
|
+
if (!renderedSubagents.has(threadId)) {
|
|
247
|
+
renderedSubagents.add(threadId);
|
|
248
|
+
parts.push(renderCodexSubagentGroup(threadId, subagentItems.get(threadId) || [], permissionById, usedPermissions));
|
|
249
|
+
}
|
|
250
|
+
i += 1;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (item.kind === 'tool') {
|
|
254
|
+
const tools = [];
|
|
255
|
+
const turnId = item.turnId;
|
|
256
|
+
while (i < visible.length && visible[i].kind === 'tool' && visible[i].turnId === turnId) tools.push(visible[i++]);
|
|
257
|
+
parts.push(renderCodexToolGroup(tools, permissionById, usedPermissions,
|
|
258
|
+
turnEndById.get(String(turnId || ''))));
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (item.kind === 'assistant') parts.push(`<div class="codex-message-block assistant" data-codex-key="message-${escapeHtml(item.id || '')}"><div class="codex-message assistant claude-md">${renderMarkdown(item.text || '')}</div>${renderCodexMessageTime(item, true)}</div>`);
|
|
262
|
+
else if (item.kind === 'user') parts.push(`<div class="codex-message-block user" data-codex-key="message-${escapeHtml(item.id || '')}"><div class="codex-message user claude-md">${renderMarkdown(item.text || '')}</div>${renderCodexMessageTime(item)}</div>`);
|
|
263
|
+
else if (item.kind === 'status') parts.push(renderCodexStatus(item));
|
|
264
|
+
else parts.push(`<div class="codex-message ${escapeHtml(item.level === 'error' ? 'event error' : item.kind || 'event')}">${codexText(item.text)}</div>`);
|
|
265
|
+
i += 1;
|
|
266
|
+
}
|
|
267
|
+
for (const request of codexPendingPermissions) if (!usedPermissions.has(request.id)) parts.push(renderCodexPermission(request));
|
|
268
|
+
const working = `<div class="codex-working-indicator" role="status" aria-label="Codex is working" title="Codex is working"${codexState.status === 'running' ? '' : ' style="display:none"'}></div>`;
|
|
269
|
+
const template = document.createElement('template');
|
|
270
|
+
template.innerHTML = `<div class="codex-conversation">${working}${parts.join('') || '<div class="codex-message event">Send a message to start Codex.</div>'}</div>`;
|
|
271
|
+
const next = template.content.firstElementChild;
|
|
272
|
+
const current = container.firstElementChild;
|
|
273
|
+
if (!current) container.appendChild(next);
|
|
274
|
+
else syncCodexDom(current, next);
|
|
275
|
+
container.scrollTop = shouldStickToBottom ? container.scrollHeight : previousScrollTop;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function applyCodexState(state = {}) {
|
|
279
|
+
const presentationChanged = state.presentation !== undefined && state.presentation !== codexState.presentation;
|
|
280
|
+
codexState = { ...codexState, ...state };
|
|
281
|
+
const permission = document.getElementById('codex-permission-select');
|
|
282
|
+
if (permission) {
|
|
283
|
+
const defaultOption = permission.querySelector('option[value="default"]');
|
|
284
|
+
const labels = { untrusted: 'Untrusted', 'on-request': 'On request', never: 'Never ask' };
|
|
285
|
+
if (defaultOption) defaultOption.textContent = codexState.effectivePermissionMode
|
|
286
|
+
? `Default (${labels[codexState.effectivePermissionMode] || codexState.effectivePermissionMode})`
|
|
287
|
+
: 'Default';
|
|
288
|
+
permission.value = codexState.permissionMode || 'default';
|
|
289
|
+
}
|
|
290
|
+
const sandbox = document.getElementById('codex-sandbox-select');
|
|
291
|
+
if (sandbox) {
|
|
292
|
+
const defaultOption = sandbox.querySelector('option[value="default"]');
|
|
293
|
+
const labels = { 'read-only': 'Read only', 'workspace-write': 'Workspace write', 'danger-full-access': 'Full access' };
|
|
294
|
+
if (defaultOption) defaultOption.textContent = codexState.effectiveSandboxMode
|
|
295
|
+
? `Default (${labels[codexState.effectiveSandboxMode] || codexState.effectiveSandboxMode})`
|
|
296
|
+
: 'Default';
|
|
297
|
+
sandbox.value = codexState.sandboxMode || 'default';
|
|
298
|
+
}
|
|
299
|
+
const modelButton = document.getElementById('codex-model-btn');
|
|
300
|
+
if (modelButton) modelButton.textContent = 'Model';
|
|
301
|
+
const abort = document.getElementById('codex-abort-btn');
|
|
302
|
+
if (abort) abort.disabled = !codexState.canAbort;
|
|
303
|
+
const fork = document.getElementById('codex-fork-btn');
|
|
304
|
+
if (fork) fork.disabled = !(codexState.presentation === 'structured' && codexState.status === 'idle');
|
|
305
|
+
const terminal = document.getElementById('codex-terminal-switch');
|
|
306
|
+
if (terminal) { terminal.textContent = codexState.presentation === 'terminal' ? 'CHAT' : 'TERM'; terminal.disabled = codexState.presentation === 'structured' && !codexState.canSwitchToTerminal; terminal.title = codexState.presentation === 'terminal' ? 'Return to Codex chat' : 'Switch to Codex terminal'; }
|
|
307
|
+
renderCodexStateBar();
|
|
308
|
+
renderCodexModelPanel();
|
|
309
|
+
if (presentationChanged) setClaudeModeEnabled(isClaudeSession());
|
|
310
|
+
renderCodexChat();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function renderCodexStateBar() {
|
|
314
|
+
const el = document.getElementById('codex-state-bar');
|
|
315
|
+
if (!el) return;
|
|
316
|
+
const pending = Number(codexState.pendingPermissionCount || codexPendingPermissions.filter(item => item.status === 'pending').length) || 0;
|
|
317
|
+
const subagents = Number(codexState.activeSubagentCount || 0) || 0;
|
|
318
|
+
const parts = [];
|
|
319
|
+
if (pending || codexState.status === 'waiting_approval') parts.push(`<button type="button" class="claude-state-pill warn codex-approval-jump" onclick="jumpToCodexApproval()" title="Jump to pending approval" aria-label="Jump to pending approval">${pending || 1} approval${pending === 1 ? '' : 's'}<span aria-hidden="true">↓</span></button>`);
|
|
320
|
+
if (subagents) parts.push(`<span class="claude-state-pill">${subagents} subagent${subagents === 1 ? '' : 's'} running</span>`);
|
|
321
|
+
el.innerHTML = parts.join('');
|
|
322
|
+
el.style.display = parts.length ? 'flex' : 'none';
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function jumpToCodexApproval() {
|
|
326
|
+
const pending = codexPendingPermissions.filter(item => item && item.status === 'pending');
|
|
327
|
+
if (!pending.length) return false;
|
|
328
|
+
const request = pending[codexApprovalJumpIndex % pending.length];
|
|
329
|
+
codexApprovalJumpIndex = (codexApprovalJumpIndex + 1) % pending.length;
|
|
330
|
+
return focusCodexApproval(String(request.id || ''));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function focusCodexApproval(permissionId, retry = true) {
|
|
334
|
+
const target = Array.from(document.querySelectorAll('[data-codex-permission-id]'))
|
|
335
|
+
.find(element => element.dataset.codexPermissionId === permissionId);
|
|
336
|
+
if (!target) {
|
|
337
|
+
if (!retry) return false;
|
|
338
|
+
commitCodexChatRender();
|
|
339
|
+
requestAnimationFrame(() => focusCodexApproval(permissionId, false));
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
for (let parent = target.parentElement; parent; parent = parent.parentElement) {
|
|
344
|
+
if (parent.tagName === 'DETAILS') parent.open = true;
|
|
345
|
+
}
|
|
346
|
+
target.classList.remove('codex-approval-focus');
|
|
347
|
+
void target.offsetWidth;
|
|
348
|
+
target.classList.add('codex-approval-focus');
|
|
349
|
+
target.setAttribute('tabindex', '-1');
|
|
350
|
+
target.focus({ preventScroll: true });
|
|
351
|
+
target.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
|
|
352
|
+
setTimeout(() => target.classList.remove('codex-approval-focus'), 1800);
|
|
353
|
+
return true;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function toggleCodexModelPanel() {
|
|
357
|
+
codexModelPanelOpen = !codexModelPanelOpen;
|
|
358
|
+
codexResumePanelOpen = false;
|
|
359
|
+
codexForkPanelOpen = false;
|
|
360
|
+
codexModelCandidate = codexState.model || codexState.models?.[0]?.id || null;
|
|
361
|
+
document.getElementById('codex-resume-panel').classList.remove('active');
|
|
362
|
+
document.getElementById('codex-fork-panel').classList.remove('active');
|
|
363
|
+
renderCodexModelPanel();
|
|
364
|
+
updateTerminalControlsHeight();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function renderCodexModelPanel() {
|
|
368
|
+
const panel = document.getElementById('codex-model-panel');
|
|
369
|
+
if (!panel) return;
|
|
370
|
+
panel.classList.toggle('active', codexModelPanelOpen);
|
|
371
|
+
if (!codexModelPanelOpen) { panel.innerHTML = ''; return; }
|
|
372
|
+
const models = codexState.models || [];
|
|
373
|
+
const candidate = models.find(item => item.id === codexModelCandidate) || models.find(item => item.id === codexState.model) || models[0];
|
|
374
|
+
if (!candidate) { panel.innerHTML = '<div class="claude-resume-meta" style="padding:12px;">Loading models...</div>'; return; }
|
|
375
|
+
codexModelCandidate = candidate.id;
|
|
376
|
+
const efforts = candidate.efforts?.length ? candidate.efforts : [candidate.defaultEffort || 'medium'];
|
|
377
|
+
panel.innerHTML = `<div class="codex-picker-column">${models.map(item => `<button class="codex-picker-option${item.id === candidate.id ? ' selected' : ''}" onclick="selectCodexModelCandidate(decodePathValue('${encodePathValue(item.id)}'))">${escapeHtml(item.label || item.id)}</button>`).join('')}</div><div class="codex-picker-column">${efforts.map(effort => `<button class="codex-picker-option${candidate.id === codexState.model && effort === codexState.effort ? ' selected' : ''}" onclick="chooseCodexModelEffort(decodePathValue('${encodePathValue(candidate.id)}'), decodePathValue('${encodePathValue(effort)}'))">${escapeHtml(effort)}</button>`).join('')}</div>`;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function selectCodexModelCandidate(model) { codexModelCandidate = model; renderCodexModelPanel(); }
|
|
381
|
+
function chooseCodexModelEffort(model, effort) {
|
|
382
|
+
codexModelPanelOpen = false;
|
|
383
|
+
applyCodexState({ model, effort });
|
|
384
|
+
sendCodexSettings({ model, effort });
|
|
385
|
+
updateTerminalControlsHeight();
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function applyCodexEvent(event) {
|
|
389
|
+
if (!event) return;
|
|
390
|
+
if (event.type === 'message' && event.message) codexMessages.push(event.message);
|
|
391
|
+
else if (event.type === 'message-updated' && event.message) { const i = codexMessages.findIndex(item => item.id === event.message.id); if (i >= 0) codexMessages[i] = event.message; else codexMessages.push(event.message); }
|
|
392
|
+
else if (event.type === 'history-reset') codexMessages = event.messages || [];
|
|
393
|
+
else if (event.type === 'permission-request' && event.request) { codexPendingPermissions = [...codexPendingPermissions.filter(item => item.id !== event.request.id), event.request]; }
|
|
394
|
+
else if (event.type === 'permission-updated' && event.request) codexPendingPermissions = codexPendingPermissions.map(item => item.id === event.request.id ? event.request : item);
|
|
395
|
+
if (event.state) applyCodexState(event.state); else renderCodexChat();
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function sendCodexSettings(settings) { if (currentSocket?.readyState === 1) currentSocket.send(JSON.stringify({ type: 'codex-settings', settings })); }
|
|
399
|
+
function updateCodexSettingsFromControls() {
|
|
400
|
+
const permissionMode = document.getElementById('codex-permission-select').value;
|
|
401
|
+
const sandboxMode = document.getElementById('codex-sandbox-select').value;
|
|
402
|
+
applyCodexState({ permissionMode, sandboxMode });
|
|
403
|
+
sendCodexSettings({ permissionMode, sandboxMode });
|
|
404
|
+
}
|
|
405
|
+
function abortCodexSession() { if (currentSocket?.readyState === 1) currentSocket.send(JSON.stringify({ type: 'codex-abort' })); }
|
|
406
|
+
function requestCodexStatus() { if (currentSocket?.readyState === 1) currentSocket.send(JSON.stringify({ type: 'codex-status' })); }
|
|
407
|
+
function respondCodexPermission(id, decision) { if (currentSocket?.readyState === 1) currentSocket.send(JSON.stringify({ type: 'codex-permission', id, decision })); }
|
|
408
|
+
async function toggleCodexResumePanel() {
|
|
409
|
+
codexResumePanelOpen = !codexResumePanelOpen;
|
|
410
|
+
codexModelPanelOpen = false;
|
|
411
|
+
codexForkPanelOpen = false;
|
|
412
|
+
document.getElementById('codex-model-panel').classList.remove('active');
|
|
413
|
+
document.getElementById('codex-fork-panel').classList.remove('active');
|
|
414
|
+
const panel = document.getElementById('codex-resume-panel');
|
|
415
|
+
panel.classList.toggle('active', codexResumePanelOpen);
|
|
416
|
+
updateTerminalControlsHeight();
|
|
417
|
+
if (!codexResumePanelOpen) return;
|
|
418
|
+
await loadCodexThreadPanel(panel, 'resume');
|
|
419
|
+
}
|
|
420
|
+
async function toggleCodexForkPanel() {
|
|
421
|
+
if (!(codexState.presentation === 'structured' && codexState.status === 'idle')) return;
|
|
422
|
+
codexForkPanelOpen = !codexForkPanelOpen;
|
|
423
|
+
codexModelPanelOpen = false;
|
|
424
|
+
codexResumePanelOpen = false;
|
|
425
|
+
document.getElementById('codex-model-panel').classList.remove('active');
|
|
426
|
+
document.getElementById('codex-resume-panel').classList.remove('active');
|
|
427
|
+
const panel = document.getElementById('codex-fork-panel');
|
|
428
|
+
panel.classList.toggle('active', codexForkPanelOpen);
|
|
429
|
+
updateTerminalControlsHeight();
|
|
430
|
+
if (!codexForkPanelOpen) return;
|
|
431
|
+
await loadCodexThreadPanel(panel, 'fork');
|
|
432
|
+
}
|
|
433
|
+
async function loadCodexThreadPanel(panel, action) {
|
|
434
|
+
panel.innerHTML = '<div class="claude-resume-meta" style="padding:12px;">Loading sessions...</div>';
|
|
435
|
+
try {
|
|
436
|
+
const res = await fetchWithTimeout(`/api/sessions/${activeSessionId}/codex-resume-threads`, {}, 30000);
|
|
437
|
+
const data = await res.json();
|
|
438
|
+
if (!res.ok || !data.success) throw new Error(data.error || 'Failed to load Codex sessions');
|
|
439
|
+
const items = data.items || [];
|
|
440
|
+
panel.innerHTML = items.length ? items.map(item => {
|
|
441
|
+
const questions = Array.isArray(item.questions) ? item.questions : [];
|
|
442
|
+
const handler = action === 'fork' ? 'selectCodexForkThread' : 'selectCodexResumeThread';
|
|
443
|
+
return `<button class="claude-resume-item" onclick="${handler}(decodePathValue('${encodePathValue(item.id)}'))"><div class="claude-resume-title"><span>${escapeHtml((questions[0] || 'Codex session').slice(0, 120))}${item.current ? ' · current' : ''}</span><span>${escapeHtml(item.updatedAt ? new Date(item.updatedAt).toLocaleString() : '')}</span></div><div class="codex-resume-question-secondary">${escapeHtml((questions[1] || '').slice(0, 120))}</div></button>`;
|
|
444
|
+
}).join('') : '<div class="claude-resume-meta" style="padding:12px;">No Codex sessions found for this folder.</div>';
|
|
445
|
+
} catch (e) { panel.innerHTML = `<div class="claude-resume-meta" style="padding:12px;color:#ff6b61;">${escapeHtml(e.message)}</div>`; }
|
|
446
|
+
updateTerminalControlsHeight();
|
|
447
|
+
}
|
|
448
|
+
async function selectCodexResumeThread(threadId) {
|
|
449
|
+
const res = await fetchWithTimeout(`/api/sessions/${activeSessionId}/codex-resume`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ threadId }) }, 30000);
|
|
450
|
+
if (!res.ok) { alert((await res.json()).error || 'Unable to resume Codex thread'); return; }
|
|
451
|
+
codexResumePanelOpen = false;
|
|
452
|
+
document.getElementById('codex-resume-panel').classList.remove('active');
|
|
453
|
+
updateTerminalControlsHeight();
|
|
454
|
+
}
|
|
455
|
+
async function selectCodexForkThread(threadId) {
|
|
456
|
+
const panel = document.getElementById('codex-fork-panel');
|
|
457
|
+
panel.innerHTML = '<div class="claude-resume-meta" style="padding:12px;">Forking and switching this conversation...</div>';
|
|
458
|
+
updateTerminalControlsHeight();
|
|
459
|
+
const res = await fetchWithTimeout(`/api/sessions/${activeSessionId}/codex-fork`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ threadId }) }, 60000);
|
|
460
|
+
const data = await res.json();
|
|
461
|
+
if (!res.ok || !data.success) {
|
|
462
|
+
panel.innerHTML = `<div class="claude-resume-meta" style="padding:12px;color:#ff6b61;">${escapeHtml(data.error || 'Unable to fork Codex thread')}</div>`;
|
|
463
|
+
updateTerminalControlsHeight();
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
codexForkPanelOpen = false;
|
|
467
|
+
panel.classList.remove('active');
|
|
468
|
+
panel.innerHTML = '';
|
|
469
|
+
updateTerminalControlsHeight();
|
|
470
|
+
}
|
|
471
|
+
async function toggleCodexPresentation() {
|
|
472
|
+
const presentation = codexState.presentation === 'terminal' ? 'structured' : 'terminal';
|
|
473
|
+
const res = await fetchWithTimeout(`/api/sessions/${activeSessionId}/codex-presentation`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ presentation }) }, 30000);
|
|
474
|
+
if (!res.ok) alert((await res.json()).error || 'Unable to switch Codex interface');
|
|
475
|
+
}
|