glad-web 1.0.23 → 1.0.24

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.
@@ -122,6 +122,7 @@ class CodexStructuredSession extends EventEmitter {
122
122
  this.threadId = options.resume || null;
123
123
  this.currentTurnId = null;
124
124
  this.currentTurnStartedAt = null;
125
+ this.tokenUsage = null;
125
126
  this.permissionMode = normalizePermissionMode(options.permissionMode);
126
127
  this.sandboxMode = normalizeSandboxMode(options.sandboxMode);
127
128
  this.effectivePermissionMode = null;
@@ -129,6 +130,10 @@ class CodexStructuredSession extends EventEmitter {
129
130
  this.configPermissionMode = null;
130
131
  this.configSandboxMode = null;
131
132
  this.configSandboxWorkspaceWrite = {};
133
+ this.configModel = null;
134
+ this.configEffort = null;
135
+ this.hasModelOverride = Boolean(options.model);
136
+ this.hasEffortOverride = Boolean(options.effort);
132
137
  this.model = options.model || null;
133
138
  this.effort = options.effort || null;
134
139
  this.models = [];
@@ -325,6 +330,10 @@ class CodexStructuredSession extends EventEmitter {
325
330
  }
326
331
 
327
332
  handleNotification(method, params) {
333
+ if (method === 'thread/tokenUsage/updated') {
334
+ this.tokenUsage = params.tokenUsage || params.usage || params;
335
+ return;
336
+ }
328
337
  if (method === 'turn/started') {
329
338
  this.currentTurnId = params.turn?.id || params.turnId || this.currentTurnId;
330
339
  this.currentTurnStartedAt = Date.now();
@@ -338,6 +347,10 @@ class CodexStructuredSession extends EventEmitter {
338
347
  : params.turn?.status === 'interrupted' ? 'cancelled' : 'completed';
339
348
  this.append({ kind: 'turn-end', turnId: completedTurnId, status: turnStatus,
340
349
  durationMs: this.currentTurnStartedAt ? Date.now() - this.currentTurnStartedAt : null });
350
+ for (const item of this.messages.filter(message => message.kind === 'tool'
351
+ && message.turnId === completedTurnId && ['running', 'inProgress'].includes(message.toolStatus))) {
352
+ this.patch(item.id, { toolStatus: turnStatus === 'failed' ? 'failed' : 'completed' });
353
+ }
341
354
  for (const pending of this.pendingPermissions.values()) {
342
355
  this.recordPermission(pending.public, 'denied', 'abort');
343
356
  }
@@ -401,10 +414,13 @@ class CodexStructuredSession extends EventEmitter {
401
414
  else this.append({ kind, providerId: itemId, text: delta, streaming: true });
402
415
  return;
403
416
  }
404
- if (method.startsWith('item/')) this.applyProviderItem(params.item || params);
417
+ if (method.startsWith('item/')) {
418
+ const inferredStatus = method === 'item/completed' ? 'completed' : method === 'item/started' ? 'running' : null;
419
+ this.applyProviderItem(params.item || params, inferredStatus);
420
+ }
405
421
  }
406
422
 
407
- applyProviderItem(raw) {
423
+ applyProviderItem(raw, inferredStatus = null) {
408
424
  if (!raw || typeof raw !== 'object') return;
409
425
  const providerId = String(raw.id || '');
410
426
  const existing = providerId && this.messages.find(item => item.providerId === providerId);
@@ -413,8 +429,10 @@ class CodexStructuredSession extends EventEmitter {
413
429
  if (!kind) return;
414
430
  const text = kind === 'user' ? textFromInputItems(raw.content) : kind === 'assistant' ? String(raw.text || '')
415
431
  : kind === 'reasoning' ? (raw.text || (Array.isArray(raw.summary) ? raw.summary.join('\n') : Array.isArray(raw.content) ? raw.content.join('\n') : '')) : '';
432
+ const inferredToolStatus = inferredStatus === 'completed' && ['failed', 'declined'].includes(raw.status)
433
+ ? raw.status : inferredStatus;
416
434
  const patch = kind === 'tool' ? { ...toolDetails(raw), turnId: raw.turnId || this.currentTurnId,
417
- toolStatus: raw.status || 'running' } : { text, turnId: raw.turnId || this.currentTurnId, streaming: false };
435
+ toolStatus: inferredToolStatus || raw.status || 'running' } : { text, turnId: raw.turnId || this.currentTurnId, streaming: false };
418
436
  if (existing) {
419
437
  this.patch(existing.id, patch);
420
438
  } else if (kind === 'user') {
@@ -432,11 +450,12 @@ class CodexStructuredSession extends EventEmitter {
432
450
  do {
433
451
  const result = await this.request('model/list', { cursor, limit: 100, includeHidden: false });
434
452
  for (const item of result?.data || []) models.push({ id: item.id || item.model, label: item.displayName || item.model || item.id,
435
- efforts: (item.supportedReasoningEfforts || []).map(value => value.reasoningEffort), defaultEffort: item.defaultReasoningEffort || null });
453
+ efforts: (item.supportedReasoningEfforts || []).map(value => value.reasoningEffort), defaultEffort: item.defaultReasoningEffort || null,
454
+ isDefault: Boolean(item.isDefault), contextWindow: Number(item.contextWindow || item.context_window || 0) || null });
436
455
  cursor = result?.nextCursor || null;
437
456
  } while (cursor);
438
457
  this.models = models;
439
- if (!this.model && models[0]) this.model = models[0].id;
458
+ if (!this.model) this.model = (models.find(item => item.isDefault) || models[0])?.id || null;
440
459
  if (!this.effort) this.effort = models.find(item => item.id === this.model)?.defaultEffort || 'medium';
441
460
  this.emitEvent({ type: 'state', state: this.getControlState() });
442
461
  return models;
@@ -448,8 +467,12 @@ class CodexStructuredSession extends EventEmitter {
448
467
  this.configPermissionMode = config.approval_policy || null;
449
468
  this.configSandboxMode = normalizeSandboxMode(config.sandbox_mode);
450
469
  this.configSandboxWorkspaceWrite = config.sandbox_workspace_write || {};
470
+ this.configModel = config.model || null;
471
+ this.configEffort = config.model_reasoning_effort || null;
451
472
  if (!this.permissionMode) this.effectivePermissionMode = this.configPermissionMode;
452
473
  if (!this.sandboxMode) this.effectiveSandboxMode = this.configSandboxMode;
474
+ if (!this.hasModelOverride && this.configModel) this.model = this.configModel;
475
+ if (!this.hasEffortOverride && this.configEffort) this.effort = this.configEffort;
453
476
  this.emitEvent({ type: 'state', state: this.getControlState() });
454
477
  return config;
455
478
  }
@@ -474,11 +497,66 @@ class CodexStructuredSession extends EventEmitter {
474
497
  }));
475
498
  }
476
499
 
500
+ contextStatus() {
501
+ const usage = this.tokenUsage || {};
502
+ const selectedModel = this.models.find(item => item.id === this.model);
503
+ const contextWindow = Number(usage.modelContextWindow || usage.model_context_window
504
+ || usage.contextWindow || usage.context_window || selectedModel?.contextWindow || 0);
505
+ const last = usage.last || usage.lastTokenUsage || usage.last_token_usage || {};
506
+ const usedTokens = Number(last.totalTokens || last.total_tokens || usage.contextTokens
507
+ || usage.context_tokens || 0);
508
+ if (!contextWindow) {
509
+ return !this.threadId && !this.tokenUsage
510
+ ? { usedTokens: 0, contextWindow: null, remainingTokens: null, remainingPercent: 100 }
511
+ : null;
512
+ }
513
+ return {
514
+ usedTokens: Math.max(0, usedTokens),
515
+ contextWindow,
516
+ remainingTokens: Math.max(0, contextWindow - usedTokens),
517
+ remainingPercent: Math.max(0, Math.min(100, Math.round((contextWindow - usedTokens) / contextWindow * 100)))
518
+ };
519
+ }
520
+
521
+ async showStatus() {
522
+ if (this.presentation !== 'structured') return false;
523
+ await this.ensureProcess();
524
+ const accountResult = await this.request('account/read', { refreshToken: false });
525
+ const account = accountResult?.account || null;
526
+ let rateLimits = null;
527
+ if (account?.type === 'chatgpt') {
528
+ try {
529
+ const result = await this.request('account/rateLimits/read', {});
530
+ rateLimits = result?.rateLimits || null;
531
+ } catch (error) {
532
+ this.logger.debugInfo?.(`[codex-app-server] account/rateLimits/read failed: ${error.message}`);
533
+ }
534
+ }
535
+ this.append({ kind: 'status', title: 'Codex status', model: this.model, effort: this.effort,
536
+ account, rateLimits, context: this.contextStatus() });
537
+ return true;
538
+ }
539
+
477
540
  async updateSettings(settings = {}) {
541
+ const configEdits = [];
542
+ if (settings.model) configEdits.push({ keyPath: 'model', value: String(settings.model), mergeStrategy: 'upsert' });
543
+ if (settings.effort) configEdits.push({ keyPath: 'model_reasoning_effort', value: String(settings.effort), mergeStrategy: 'upsert' });
544
+ if (configEdits.length) {
545
+ await this.ensureProcess();
546
+ await this.request('config/batchWrite', { edits: configEdits });
547
+ if (settings.model) this.configModel = String(settings.model);
548
+ if (settings.effort) this.configEffort = String(settings.effort);
549
+ }
478
550
  if (settings.permissionMode !== undefined) this.permissionMode = normalizePermissionMode(settings.permissionMode);
479
551
  if (settings.sandboxMode !== undefined) this.sandboxMode = normalizeSandboxMode(settings.sandboxMode);
480
- if (settings.model !== undefined) this.model = settings.model || null;
481
- if (settings.effort !== undefined) this.effort = settings.effort || null;
552
+ if (settings.model !== undefined) {
553
+ this.hasModelOverride = Boolean(settings.model);
554
+ this.model = settings.model || this.configModel || null;
555
+ }
556
+ if (settings.effort !== undefined) {
557
+ this.hasEffortOverride = Boolean(settings.effort);
558
+ this.effort = settings.effort || this.configEffort || null;
559
+ }
482
560
  const needsConfigDefaults = (settings.permissionMode !== undefined && !this.permissionMode)
483
561
  || (settings.sandboxMode !== undefined && !this.sandboxMode);
484
562
  if (needsConfigDefaults && this.presentation === 'structured') {
@@ -497,8 +575,8 @@ class CodexStructuredSession extends EventEmitter {
497
575
  this.sandboxMode ? {} : this.configSandboxWorkspaceWrite);
498
576
  if (sandboxPolicy) params.sandboxPolicy = sandboxPolicy;
499
577
  }
500
- if (settings.model !== undefined) params.model = this.model;
501
- if (settings.effort !== undefined) params.effort = this.effort;
578
+ if (settings.model !== undefined) params.model = this.hasModelOverride ? this.model : null;
579
+ if (settings.effort !== undefined) params.effort = this.hasEffortOverride ? this.effort : null;
502
580
  if (Object.keys(params).length > 1) await this.request('thread/settings/update', params);
503
581
  }
504
582
  this.emitEvent({ type: 'state', state: this.getControlState() });
@@ -512,7 +590,8 @@ class CodexStructuredSession extends EventEmitter {
512
590
  this.append({ kind: 'user', text: prompt });
513
591
  await this.ensureProcess();
514
592
  if (!this.threadId) {
515
- const params = { model: this.model, cwd: this.workingDir };
593
+ const params = { cwd: this.workingDir };
594
+ if (this.hasModelOverride) params.model = this.model;
516
595
  if (this.permissionMode) params.approvalPolicy = this.permissionMode;
517
596
  if (this.sandboxMode) params.sandbox = this.sandboxMode;
518
597
  const started = await this.request('thread/start', params);
@@ -524,8 +603,9 @@ class CodexStructuredSession extends EventEmitter {
524
603
  this.emitEvent({ type: 'state', state: this.getControlState() });
525
604
  }
526
605
  this.setStatus('running');
527
- const params = { threadId: this.threadId, input: [{ type: 'text', text: prompt }], cwd: this.workingDir,
528
- model: this.model, effort: this.effort, summary: 'auto' };
606
+ const params = { threadId: this.threadId, input: [{ type: 'text', text: prompt }], cwd: this.workingDir, summary: 'auto' };
607
+ if (this.hasModelOverride) params.model = this.model;
608
+ if (this.hasEffortOverride) params.effort = this.effort;
529
609
  if (this.permissionMode) params.approvalPolicy = this.permissionMode;
530
610
  const sandboxPolicy = sandboxPolicyFor(this.sandboxMode, this.workingDir);
531
611
  if (sandboxPolicy) params.sandboxPolicy = sandboxPolicy;
@@ -596,22 +676,36 @@ class CodexStructuredSession extends EventEmitter {
596
676
  const target = String(threadId || this.threadId || '').trim();
597
677
  if (!target || this.presentation !== 'structured' || this.status !== 'idle') return false;
598
678
  await this.ensureProcess();
599
- const params = { threadId: target, model: this.model, cwd: this.workingDir };
679
+ const params = { threadId: target, cwd: this.workingDir };
600
680
  if (this.permissionMode) params.approvalPolicy = this.permissionMode;
601
681
  if (this.sandboxMode) params.sandbox = this.sandboxMode;
602
682
  const result = await this.request('thread/resume', params);
603
683
  this.threadId = result.thread?.id || target;
604
- this.model = result.model || this.model;
684
+ this.hasModelOverride = false;
685
+ this.hasEffortOverride = false;
686
+ this.model = result.model || result.thread?.model || this.model;
687
+ this.effort = result.reasoningEffort || result.thread?.reasoningEffort || this.effort;
605
688
  this.effectivePermissionMode = result.approvalPolicy || this.effectivePermissionMode;
606
689
  this.effectiveSandboxMode = sandboxModeFromPolicy(result.sandbox) || this.effectiveSandboxMode;
607
690
  const history = await this.request('thread/read', { threadId: this.threadId, includeTurns: true });
691
+ this.model = history?.thread?.model || this.model;
692
+ this.effort = history?.thread?.reasoningEffort || history?.thread?.reasoning_effort || this.effort;
693
+ this.tokenUsage = history?.thread?.tokenUsage || history?.thread?.token_usage || this.tokenUsage;
608
694
  this.messages = [];
609
695
  this.completedPermissions = [];
610
696
  for (const turn of history?.thread?.turns || []) {
611
- this.append({ kind: 'turn-start', turnId: turn.id });
612
- for (const item of turn.items || []) this.applyProviderItem({ ...item, turnId: turn.id });
613
697
  const status = turn.status === 'failed' ? 'failed' : turn.status === 'interrupted' ? 'cancelled' : 'completed';
614
- this.append({ kind: 'turn-end', turnId: turn.id, status });
698
+ const startedAt = Number(turn.startedAt || turn.createdAt || 0);
699
+ const completedAt = Number(turn.completedAt || turn.updatedAt || 0);
700
+ const toMilliseconds = value => value > 0 && value < 100000000000 ? value * 1000 : value;
701
+ const startedAtMs = toMilliseconds(startedAt);
702
+ const completedAtMs = toMilliseconds(completedAt);
703
+ this.append({ kind: 'turn-start', turnId: turn.id, ...(startedAtMs ? { createdAt: startedAtMs } : {}) });
704
+ for (const item of turn.items || []) this.applyProviderItem({ ...item, turnId: turn.id }, status === 'failed' ? 'failed' : 'completed');
705
+ const durationMs = Number(turn.durationMs || turn.duration_ms || 0)
706
+ || (startedAtMs && completedAtMs && completedAtMs >= startedAtMs ? completedAtMs - startedAtMs : null);
707
+ this.append({ kind: 'turn-end', turnId: turn.id, status, durationMs,
708
+ ...(completedAtMs ? { createdAt: completedAtMs } : {}) });
615
709
  }
616
710
  this.append({ kind: 'event', level: 'info', text: `Resumed Codex thread ${this.threadId}` });
617
711
  this.emitEvent({ type: 'history-reset', messages: this.messages });
@@ -525,6 +525,9 @@ async function webCommand(options) {
525
525
  if (payload.type === 'codex-settings') {
526
526
  sessionManager.updateCodexSettings(sessionId, payload.settings || {}).catch(error => logger.error(`Codex settings error: ${error.message}`));
527
527
  }
528
+ if (payload.type === 'codex-status') {
529
+ sessionManager.showCodexStatus(sessionId).catch(error => logger.error(`Codex status error: ${error.message}`));
530
+ }
528
531
  if (payload.type === 'codex-abort') {
529
532
  sessionManager.abortCodex(sessionId);
530
533
  }
@@ -5,7 +5,7 @@ function getTerminalCtor() {
5
5
  if (typeof global.window === 'undefined') {
6
6
  global.window = {};
7
7
  }
8
- ({ Terminal: TerminalCtor } = require('xterm-headless'));
8
+ ({ Terminal: TerminalCtor } = require('@xterm/headless'));
9
9
  return TerminalCtor;
10
10
  }
11
11
 
@@ -245,6 +245,12 @@ class SessionManager extends EventEmitter {
245
245
  return session.updateSettings(settings || {});
246
246
  }
247
247
 
248
+ showCodexStatus(id) {
249
+ const session = this.get(id);
250
+ if (!session || session.kind !== 'codex-structured') return Promise.resolve(false);
251
+ return session.showStatus();
252
+ }
253
+
248
254
  abortCodex(id) {
249
255
  const session = this.get(id);
250
256
  return session && session.kind === 'codex-structured' ? session.abort('Aborted by user') : false;
@@ -80,9 +80,17 @@
80
80
  #claude-chat-container { display: none; flex: 1; min-height: 0; overflow-y: auto; background: #050505; padding: 12px 12px 24px 12px; box-sizing: border-box; -webkit-overflow-scrolling: touch; }
81
81
  #codex-chat-container { display: none; flex: 1; min-height: 0; overflow-y: auto; background: #050505; padding: 12px 12px 24px 12px; box-sizing: border-box; -webkit-overflow-scrolling: touch; }
82
82
  .codex-conversation { width: min(100%, var(--chat-content-max)); margin: 0 auto; }
83
+ .codex-working-indicator { position: sticky; top: 0; z-index: 4; width: 28px; height: 28px; margin: 0 0 -28px auto; border: 1px solid rgba(255,255,255,.1); border-radius: 50%; background: rgba(28,28,30,.68); box-shadow: 0 5px 16px rgba(0,0,0,.24); backdrop-filter: blur(8px); pointer-events: none; }
84
+ .codex-working-indicator::after { content: ''; position: absolute; inset: 8px; border: 1.5px solid rgba(255,255,255,.7); border-top-color: transparent; border-radius: 50%; animation: codex-spin .8s linear infinite; }
83
85
  .codex-message { max-width: 100%; margin: 0 0 12px; color: #f5f5f7; font-size: 14px; line-height: 1.45; overflow-wrap: anywhere; }
84
86
  .codex-message.user { max-width: 92%; margin-left: auto; padding: 8px 12px; border: 1px solid rgba(0,122,255,.32); border-radius: 12px; background: rgba(0,122,255,.24); }
85
87
  .codex-message.event { color: var(--text-dim); text-align: center; font-size: 12px; }
88
+ .codex-status-card { margin: 10px 0 14px; border: 1px solid rgba(100,210,255,.2); border-radius: 10px; background: rgba(28,28,30,.72); padding: 11px; color: #f5f5f7; }
89
+ .codex-status-title { margin-bottom: 9px; color: #64d2ff; font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; }
90
+ .codex-status-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; }
91
+ .codex-status-item { min-width: 0; border: 1px solid rgba(255,255,255,.07); border-radius: 8px; background: rgba(255,255,255,.035); padding: 8px; }
92
+ .codex-status-label { color: #8e8e93; font-size: 10px; font-weight: 800; text-transform: uppercase; }
93
+ .codex-status-value { margin-top: 3px; color: #f5f5f7; font-size: 12px; font-weight: 700; overflow-wrap: anywhere; }
86
94
  .codex-tool { border: 1px solid rgba(255,255,255,.1); border-radius: 8px; background: rgba(255,255,255,.045); overflow: hidden; margin: 8px 0; color: #d1d5db; }
87
95
  .codex-tool summary { list-style: none; cursor: pointer; }
88
96
  .codex-tool summary::-webkit-details-marker { display: none; }
@@ -117,8 +125,9 @@
117
125
  .codex-inline-permission .claude-permission-actions { margin-top: 8px; }
118
126
  @keyframes codex-spin { to { transform: rotate(360deg); } }
119
127
  #codex-control-panel { display: none; width: min(100%, var(--control-content-max)); margin: 0 auto; padding: 8px 14px 10px; border-bottom: 1px solid #222; background: #121212; box-sizing: border-box; }
120
- .codex-control-row { display: grid; grid-template-columns: repeat(5, minmax(0, 116px)); justify-content: center; gap: 7px; align-items: center; }
121
- .codex-control-row > * { width: 100%; }
128
+ .codex-control-row { display: grid; grid-template-columns: repeat(6, minmax(0, 116px)); grid-template-rows: auto; justify-content: center; gap: clamp(2px, .8vw, 7px); align-items: center; white-space: nowrap; }
129
+ .codex-control-row > * { width: 100%; min-width: 0; overflow: hidden; }
130
+ .codex-control-row .claude-ctrl-btn, .codex-control-row .codex-select-label { padding-left: clamp(2px, 1vw, 10px); padding-right: clamp(2px, 1vw, 10px); font-size: clamp(8px, 2.2vw, 11px); text-overflow: ellipsis; overflow: hidden; }
122
131
  .codex-select-control { position: relative; display: block; min-width: 0; height: 32px; }
123
132
  .codex-select-label { display: flex; width: 100%; height: 100%; align-items: center; justify-content: center; box-sizing: border-box; border: 1px solid rgba(255,255,255,.1); border-radius: 16px; background: rgba(255,255,255,.08); color: #f5f5f7; font-size: 11px; font-weight: 800; white-space: nowrap; }
124
133
  .codex-select-control:focus-within .codex-select-label { background: rgba(255,255,255,.14); border-color: rgba(0,122,255,.45); }
@@ -293,7 +302,7 @@
293
302
  #nav-bar > div:last-child .icon-btn { min-width: 34px; min-height: 30px; padding: 5px 7px !important; }
294
303
  #input-row { padding-left: 10px; padding-right: 10px; }
295
304
  #codex-control-panel { padding-left: 8px; padding-right: 8px; }
296
- .codex-control-row { grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 5px; }
305
+ .codex-control-row { grid-template-columns: repeat(6, minmax(0, 1fr)); }
297
306
  .codex-control-row .claude-ctrl-btn { height: 36px; padding-left: 4px; padding-right: 4px; }
298
307
  .codex-select-control { height: 36px; }
299
308
  #codex-chat-container { padding: 10px 10px calc(22px + env(safe-area-inset-bottom)); }
@@ -425,6 +434,7 @@
425
434
  </select>
426
435
  </label>
427
436
  <button id="codex-model-btn" class="claude-ctrl-btn" onclick="toggleCodexModelPanel()" title="Model and effort">Model</button>
437
+ <button id="codex-status-btn" class="claude-ctrl-btn" onclick="requestCodexStatus()" title="Show Codex account, limits, and context status">Status</button>
428
438
  <button id="codex-abort-btn" class="claude-ctrl-btn danger" onclick="abortCodexSession()" title="Abort current Codex turn">Abort</button>
429
439
  <button id="codex-resume-btn" class="claude-ctrl-btn primary" onclick="toggleCodexResumePanel()" title="Resume a Codex thread">Resume</button>
430
440
  </div>
@@ -2078,6 +2088,39 @@
2078
2088
  if (inline) return `<div class="codex-inline-permission">${content}</div>`;
2079
2089
  return `<div class="claude-tool claude-permission"><div class="claude-tool-header"><strong>${escapeHtml(request.title || 'Permission required')}</strong></div><div class="claude-tool-body">${content}</div></div>`;
2080
2090
  }
2091
+ function formatCodexReset(timestamp) {
2092
+ const value = Number(timestamp || 0);
2093
+ return value ? new Date(value * 1000).toLocaleString() : '';
2094
+ }
2095
+ function formatCodexTokens(value) {
2096
+ const count = Number(value || 0);
2097
+ return count >= 1000000 ? `${(count / 1000000).toFixed(1)}M`
2098
+ : count >= 1000 ? `${Math.round(count / 1000)}K` : String(count);
2099
+ }
2100
+ function codexStatusItem(label, value) {
2101
+ if (value == null || value === '') return '';
2102
+ return `<div class="codex-status-item"><div class="codex-status-label">${escapeHtml(label)}</div><div class="codex-status-value">${escapeHtml(value)}</div></div>`;
2103
+ }
2104
+ function renderCodexStatus(item) {
2105
+ const account = item.account || {};
2106
+ const limit = item.rateLimits || {};
2107
+ const primary = limit.primary;
2108
+ const secondary = limit.secondary;
2109
+ const context = item.context;
2110
+ const accountLabel = account.type === 'chatgpt'
2111
+ ? [account.email, account.planType].filter(Boolean).join(' · ')
2112
+ : account.type === 'apiKey' ? 'API key' : account.type || 'Not signed in';
2113
+ const fiveHour = primary ? `${Math.max(0, 100 - Number(primary.usedPercent || 0))}% left${primary.resetsAt ? ` · resets ${formatCodexReset(primary.resetsAt)}` : ''}` : '';
2114
+ const weekly = secondary ? `${Math.max(0, 100 - Number(secondary.usedPercent || 0))}% left${secondary.resetsAt ? ` · resets ${formatCodexReset(secondary.resetsAt)}` : ''}` : '';
2115
+ const contextLabel = context ? `${context.remainingPercent}% left${context.contextWindow ? ` · ${formatCodexTokens(context.remainingTokens)} / ${formatCodexTokens(context.contextWindow)}` : ''}` : 'Available after the first usage update';
2116
+ return `<div class="codex-status-card"><div class="codex-status-title">${escapeHtml(item.title || 'Codex status')}</div><div class="codex-status-grid">
2117
+ ${codexStatusItem('Account', accountLabel)}
2118
+ ${codexStatusItem('Model', [item.model, item.effort].filter(Boolean).join(' · '))}
2119
+ ${codexStatusItem('5h limit', fiveHour)}
2120
+ ${codexStatusItem('Weekly limit', weekly)}
2121
+ ${codexStatusItem('Context', contextLabel)}
2122
+ </div></div>`;
2123
+ }
2081
2124
  function renderCodexTool(item, permission = null) {
2082
2125
  const status = codexToolStatus(item);
2083
2126
  const isError = status === 'failed' || Boolean(item.error) || (item.exitCode != null && item.exitCode !== 0);
@@ -2091,17 +2134,22 @@
2091
2134
  const result = item.result || (item.name === 'McpTool' || item.name === 'Agent' ? codexJson(item.input) : '');
2092
2135
  return `<details class="codex-tool${isError ? ' error' : ''}"><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>'}<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>`;
2093
2136
  }
2094
- function renderCodexToolGroup(items, permissionById, usedPermissions) {
2137
+ function renderCodexToolGroup(items, permissionById, usedPermissions, turnEnd = null) {
2095
2138
  const running = items.some(item => codexToolStatus(item) === 'running');
2096
2139
  const failed = items.some(item => codexToolStatus(item) === 'failed' || item.error);
2097
2140
  const startedAt = Math.min(...items.map(item => Number(item.createdAt || Date.now())));
2098
- const seconds = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
2141
+ const storedDuration = Number(turnEnd?.durationMs || 0);
2142
+ const completedAt = Number(turnEnd?.createdAt || 0);
2143
+ const durationMs = storedDuration > 0 ? storedDuration
2144
+ : (!running && completedAt >= startedAt ? completedAt - startedAt : 0);
2145
+ const seconds = durationMs > 0 ? Math.max(1, Math.round(durationMs / 1000)) : null;
2099
2146
  const tools = items.map(item => {
2100
2147
  const permission = permissionById.get(String(item.providerId || ''));
2101
2148
  if (permission) usedPermissions.add(permission.id);
2102
2149
  return renderCodexTool(item, permission);
2103
2150
  }).join('');
2104
- return `<details class="codex-work-group"${running ? ' open' : ''}><summary>${running ? 'Working' : failed ? 'Work finished with errors' : `Worked for ${seconds}s`} · ${items.length} tools</summary><div class="codex-work-group-body">${tools}</div></details>`;
2151
+ const label = running ? 'Working' : failed ? 'Work finished with errors' : seconds ? `Worked for ${seconds}s` : 'Worked';
2152
+ return `<details class="codex-work-group"${running ? ' open' : ''}><summary>${label} · ${items.length} tools</summary><div class="codex-work-group-body">${tools}</div></details>`;
2105
2153
  }
2106
2154
  function renderCodexChat() {
2107
2155
  const container = document.getElementById('codex-chat-container');
@@ -2109,13 +2157,17 @@
2109
2157
  const parts = [];
2110
2158
  const permissionById = new Map(codexPendingPermissions.map(item => [String(item.id), item]));
2111
2159
  const usedPermissions = new Set();
2160
+ const turnEndById = new Map(codexMessages.filter(item => item.kind === 'turn-end' && item.turnId)
2161
+ .map(item => [String(item.turnId), item]));
2112
2162
  const visible = codexMessages.filter(item => !['reasoning', 'turn-start', 'turn-end'].includes(item.kind));
2113
2163
  for (let i = 0; i < visible.length;) {
2114
2164
  const item = visible[i];
2115
2165
  if (item.kind === 'tool') {
2116
2166
  const tools = [];
2117
- while (i < visible.length && visible[i].kind === 'tool') tools.push(visible[i++]);
2118
- if (tools.length > 1) parts.push(renderCodexToolGroup(tools, permissionById, usedPermissions));
2167
+ const turnId = item.turnId;
2168
+ while (i < visible.length && visible[i].kind === 'tool' && visible[i].turnId === turnId) tools.push(visible[i++]);
2169
+ if (tools.length > 1) parts.push(renderCodexToolGroup(tools, permissionById, usedPermissions,
2170
+ turnEndById.get(String(turnId || ''))));
2119
2171
  else {
2120
2172
  const permission = permissionById.get(String(tools[0].providerId || ''));
2121
2173
  if (permission) usedPermissions.add(permission.id);
@@ -2125,14 +2177,14 @@
2125
2177
  }
2126
2178
  if (item.kind === 'assistant') parts.push(`<div class="codex-message assistant claude-md">${renderMarkdown(item.text || '')}</div>`);
2127
2179
  else if (item.kind === 'user') parts.push(`<div class="codex-message user claude-md">${renderMarkdown(item.text || '')}</div>`);
2180
+ else if (item.kind === 'status') parts.push(renderCodexStatus(item));
2128
2181
  else parts.push(`<div class="codex-message ${escapeHtml(item.level === 'error' ? 'event error' : item.kind || 'event')}">${codexText(item.text)}</div>`);
2129
2182
  i += 1;
2130
2183
  }
2131
2184
  for (const request of codexPendingPermissions) if (!usedPermissions.has(request.id)) parts.push(renderCodexPermission(request));
2132
- if (codexState.status === 'running') {
2133
- parts.push('<div class="claude-status">Codex is working...</div>');
2134
- }
2135
- container.innerHTML = `<div class="codex-conversation">${parts.join('') || '<div class="codex-message event">Send a message to start Codex.</div>'}</div>`;
2185
+ const working = codexState.status === 'running'
2186
+ ? '<div class="codex-working-indicator" role="status" aria-label="Codex is working" title="Codex is working"></div>' : '';
2187
+ container.innerHTML = `<div class="codex-conversation">${working}${parts.join('') || '<div class="codex-message event">Send a message to start Codex.</div>'}</div>`;
2136
2188
  requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; });
2137
2189
  }
2138
2190
 
@@ -2175,7 +2227,6 @@
2175
2227
  const pending = Number(codexState.pendingPermissionCount || codexPendingPermissions.filter(item => item.status === 'pending').length) || 0;
2176
2228
  const parts = [];
2177
2229
  if (pending || codexState.status === 'waiting_approval') parts.push(`<span class="claude-state-pill warn">${pending || 1} approval${pending === 1 ? '' : 's'}</span>`);
2178
- else if (codexState.status === 'running') parts.push('<span class="claude-state-pill">Working</span>');
2179
2230
  el.innerHTML = parts.join('');
2180
2231
  el.style.display = parts.length ? 'flex' : 'none';
2181
2232
  }
@@ -2228,6 +2279,7 @@
2228
2279
  sendCodexSettings({ permissionMode, sandboxMode });
2229
2280
  }
2230
2281
  function abortCodexSession() { if (currentSocket?.readyState === 1) currentSocket.send(JSON.stringify({ type: 'codex-abort' })); }
2282
+ function requestCodexStatus() { if (currentSocket?.readyState === 1) currentSocket.send(JSON.stringify({ type: 'codex-status' })); }
2231
2283
  function respondCodexPermission(id, decision) { if (currentSocket?.readyState === 1) currentSocket.send(JSON.stringify({ type: 'codex-permission', id, decision })); }
2232
2284
  async function toggleCodexResumePanel() {
2233
2285
  codexResumePanelOpen = !codexResumePanelOpen;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glad-web",
3
- "version": "1.0.23",
3
+ "version": "1.0.24",
4
4
  "description": "Glad transforms terminal-based AI coding tools into a polished, mobile-friendly local Web interface.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -52,9 +52,9 @@
52
52
  "conf": "^10.2.0",
53
53
  "express": "^5.2.1",
54
54
  "node-pty": "npm:@lydell/node-pty@^1.1.0",
55
- "uuid": "^9.0.0",
55
+ "uuid": "^11.1.1",
56
56
  "ws": "^8.19.0",
57
- "xterm-headless": "^5.3.0"
57
+ "@xterm/headless": "^6.0.0"
58
58
  },
59
59
  "devDependencies": {
60
60
  "caxa": "^3.0.1"