glad-web 1.0.20 → 1.0.21

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.
@@ -3,6 +3,9 @@ const crypto = require('crypto');
3
3
  const { normalizeEffort, normalizeModel, resolveClaudeModel } = require('./config');
4
4
 
5
5
  const PERMISSION_MODES = new Set(['default', 'acceptEdits', 'bypassPermissions', 'plan']);
6
+ const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit']);
7
+ const EXIT_PLAN_TOOLS = new Set(['exit_plan_mode', 'ExitPlanMode']);
8
+ const DENY_PERMISSION_MESSAGE = "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.";
6
9
 
7
10
  function normalizePermissionMode(value) {
8
11
  const mode = String(value || 'default');
@@ -88,10 +91,14 @@ class ClaudeStructuredSession extends EventEmitter {
88
91
  this.permissionMode = normalizePermissionMode(options.permissionMode);
89
92
  this.model = normalizeModel(options.model);
90
93
  this.effort = normalizeEffort(options.effort);
94
+ this.allowedTools = new Set();
95
+ this.allowedBashLiterals = new Set();
96
+ this.allowedBashPrefixes = new Set();
91
97
  this.resumeSessionId = options.resume || null;
92
98
  this.claudeSessionId = options.resume || null;
93
99
  this.activeOptionSignature = null;
94
100
  this.contextRemaining = null;
101
+ this.latestUsage = null;
95
102
 
96
103
  // Compatibility with existing session-scoped Git/file APIs.
97
104
  this.ptyManager = {
@@ -140,6 +147,7 @@ class ClaudeStructuredSession extends EventEmitter {
140
147
  claudeSessionId: this.claudeSessionId || null,
141
148
  resumeSessionId: this.resumeSessionId || null,
142
149
  contextRemaining: this.contextRemaining,
150
+ latestUsage: this.latestUsage,
143
151
  canAbort: this.status === 'thinking',
144
152
  pendingPermissionCount: this.pendingPermissions.size
145
153
  };
@@ -238,7 +246,7 @@ class ClaudeStructuredSession extends EventEmitter {
238
246
 
239
247
  if (changed) {
240
248
  if (this.query && typeof this.query.setPermissionMode === 'function') {
241
- Promise.resolve(this.query.setPermissionMode(this.permissionMode)).catch(error => {
249
+ Promise.resolve(this.query.setPermissionMode(this.getSdkPermissionMode())).catch(error => {
242
250
  this.logger.debugInfo?.(`[claude-structured] setPermissionMode failed: ${error.message}`);
243
251
  });
244
252
  }
@@ -298,18 +306,161 @@ class ClaudeStructuredSession extends EventEmitter {
298
306
  this.abortController = null;
299
307
  }
300
308
 
301
- respondPermission(id, approved) {
309
+ finishRunner() {
310
+ this.runnerStarted = false;
311
+ this.inputQueue?.close();
312
+ this.inputQueue = null;
313
+ this.query = null;
314
+ this.abortController = null;
315
+ }
316
+
317
+ respondPermission(id, approved, action = null) {
302
318
  const pending = this.pendingPermissions.get(id);
303
319
  if (!pending) return false;
304
320
  this.pendingPermissions.delete(id);
305
- const publicRequest = { ...pending.public, status: approved ? 'approved' : 'denied' };
321
+ const normalizedAction = this.normalizePermissionAction(action, approved);
322
+ const allowedTools = this.getAllowedToolsForAction(normalizedAction, pending);
323
+ const nextMode = this.getPermissionModeForAction(normalizedAction);
324
+ if (allowedTools.length > 0) this.addAllowedTools(allowedTools);
325
+ if (nextMode) {
326
+ this.permissionMode = nextMode;
327
+ if (this.query && typeof this.query.setPermissionMode === 'function') {
328
+ Promise.resolve(this.query.setPermissionMode(this.getSdkPermissionMode())).catch(error => {
329
+ this.logger.debugInfo?.(`[claude-structured] setPermissionMode failed: ${error.message}`);
330
+ });
331
+ }
332
+ }
333
+ const publicRequest = {
334
+ ...pending.public,
335
+ status: approved ? 'approved' : 'denied',
336
+ action: normalizedAction,
337
+ mode: nextMode || undefined,
338
+ allowedTools: allowedTools.length > 0 ? allowedTools : undefined
339
+ };
306
340
  this.emitEvent({ type: 'permission-updated', request: publicRequest });
307
341
  pending.resolve(approved
308
- ? { behavior: 'allow', toolUseID: pending.toolUseID, decisionClassification: 'user_temporary' }
309
- : { behavior: 'deny', message: 'Denied by user', interrupt: true, toolUseID: pending.toolUseID, decisionClassification: 'user_reject' });
342
+ ? {
343
+ behavior: 'allow',
344
+ updatedInput: pending.input || {},
345
+ updatedPermissions: this.getPermissionUpdatesForAction(normalizedAction, pending),
346
+ toolUseID: pending.toolUseID,
347
+ decisionClassification: normalizedAction === 'allow-once' ? 'user_temporary' : 'user_permanent'
348
+ }
349
+ : { behavior: 'deny', message: DENY_PERMISSION_MESSAGE, interrupt: true, toolUseID: pending.toolUseID, decisionClassification: 'user_reject' });
350
+ this.emitEvent({ type: 'state', state: this.getControlState() });
310
351
  return true;
311
352
  }
312
353
 
354
+ normalizePermissionAction(action, approved) {
355
+ if (!approved) return 'deny';
356
+ const value = String(action || '').trim();
357
+ if (['allow-once', 'allow-tool', 'allow-edits', 'bypass'].includes(value)) return value;
358
+ return 'allow-once';
359
+ }
360
+
361
+ getPermissionModeForAction(action) {
362
+ if (action === 'allow-edits') return 'acceptEdits';
363
+ if (action === 'bypass') return 'bypassPermissions';
364
+ return null;
365
+ }
366
+
367
+ getSdkPermissionMode() {
368
+ // Claude CLI refuses --dangerously-skip-permissions under root/sudo. Glad
369
+ // keeps bypass as local state and auto-allows through canUseTool instead.
370
+ if (this.permissionMode === 'bypassPermissions') return 'default';
371
+ return this.permissionMode;
372
+ }
373
+
374
+ getAllowedToolsForAction(action, pending) {
375
+ if (action !== 'allow-tool') return [];
376
+ const toolName = pending.toolName || pending.public.toolName;
377
+ if (!toolName) return [];
378
+ if (toolName === 'Bash') {
379
+ const command = pending.input && typeof pending.input.command === 'string'
380
+ ? pending.input.command
381
+ : '';
382
+ return command ? [`Bash(${command})`] : ['Bash'];
383
+ }
384
+ return [toolName];
385
+ }
386
+
387
+ getPermissionUpdatesForAction(action, pending) {
388
+ const mode = this.getPermissionModeForAction(action);
389
+ const allowedTools = this.getAllowedToolsForAction(action, pending);
390
+ const updates = [];
391
+ if (allowedTools.length > 0) {
392
+ updates.push({
393
+ type: 'addRules',
394
+ rules: allowedTools.map(tool => this.permissionRuleFromTool(tool)),
395
+ behavior: 'allow',
396
+ destination: 'session'
397
+ });
398
+ }
399
+ if (mode === 'acceptEdits') {
400
+ updates.push({
401
+ type: 'addRules',
402
+ rules: Array.from(EDIT_TOOLS).map(toolName => ({ toolName })),
403
+ behavior: 'allow',
404
+ destination: 'session'
405
+ });
406
+ }
407
+ return updates.length > 0 ? updates : undefined;
408
+ }
409
+
410
+ permissionRuleFromTool(tool) {
411
+ const match = String(tool || '').match(/^Bash\(([\s\S]*)\)$/);
412
+ if (match) return { toolName: 'Bash', ruleContent: match[1] };
413
+ return { toolName: String(tool || '') };
414
+ }
415
+
416
+ addAllowedTools(tools) {
417
+ for (const tool of tools) {
418
+ if (tool === 'Bash') {
419
+ this.allowedTools.add(tool);
420
+ } else if (tool.startsWith('Bash(')) {
421
+ this.parseBashPermission(tool);
422
+ } else {
423
+ this.allowedTools.add(tool);
424
+ }
425
+ }
426
+ }
427
+
428
+ parseBashPermission(permission) {
429
+ const match = String(permission || '').match(/^Bash\(([\s\S]*)\)$/);
430
+ if (!match) return;
431
+ const command = match[1];
432
+ if (command.endsWith(':*')) {
433
+ this.allowedBashPrefixes.add(command.slice(0, -2));
434
+ } else {
435
+ this.allowedBashLiterals.add(command);
436
+ }
437
+ }
438
+
439
+ isToolAllowed(toolName, input) {
440
+ if (toolName === 'Bash') {
441
+ if (this.allowedTools.has('Bash')) return true;
442
+ const command = input && typeof input.command === 'string' ? input.command : '';
443
+ if (command && this.allowedBashLiterals.has(command)) return true;
444
+ for (const prefix of this.allowedBashPrefixes) {
445
+ if (command.startsWith(prefix)) return true;
446
+ }
447
+ return false;
448
+ }
449
+ return this.allowedTools.has(toolName);
450
+ }
451
+
452
+ shouldAutoAllowTool(toolName, input) {
453
+ if (this.isToolAllowed(toolName, input)) return true;
454
+ if (this.permissionMode === 'bypassPermissions' && !EXIT_PLAN_TOOLS.has(toolName)) return true;
455
+ if (this.permissionMode === 'acceptEdits' && EDIT_TOOLS.has(toolName)) return true;
456
+ if (this.permissionMode === 'plan' && !this.isDangerousTool(toolName)) return true;
457
+ return false;
458
+ }
459
+
460
+ isDangerousTool(toolName) {
461
+ return toolName === 'Bash' || EDIT_TOOLS.has(toolName) || EXIT_PLAN_TOOLS.has(toolName);
462
+ }
463
+
313
464
  markCompletionRead() {
314
465
  this.hasUnreadCompletion = false;
315
466
  this.completionReadInputSeq = this.inputSeq || 0;
@@ -330,7 +481,8 @@ class ClaudeStructuredSession extends EventEmitter {
330
481
  const options = {
331
482
  cwd: this.workingDir,
332
483
  resume: this.resumeSessionId || undefined,
333
- permissionMode: this.permissionMode,
484
+ permissionMode: this.getSdkPermissionMode(),
485
+ allowDangerouslySkipPermissions: false,
334
486
  effort: this.effort,
335
487
  tools: { type: 'preset', preset: 'claude_code' },
336
488
  env: {
@@ -349,10 +501,12 @@ class ClaudeStructuredSession extends EventEmitter {
349
501
  for await (const message of this.query) {
350
502
  this.handleSdkMessage(message);
351
503
  }
504
+ this.finishRunner();
352
505
  this.setStatus('idle');
353
506
  } catch (error) {
354
507
  if (!this.running) return;
355
508
  if (this.abortRequested) {
509
+ this.finishRunner();
356
510
  this.setStatus('idle');
357
511
  return;
358
512
  }
@@ -375,12 +529,26 @@ class ClaudeStructuredSession extends EventEmitter {
375
529
  }
376
530
 
377
531
  requestPermission(toolName, input, options = {}) {
532
+ if (toolName !== 'AskUserQuestion' && this.shouldAutoAllowTool(toolName, input)) {
533
+ return Promise.resolve({
534
+ behavior: 'allow',
535
+ updatedInput: input || {},
536
+ toolUseID: options.toolUseID,
537
+ decisionClassification: 'user_permanent'
538
+ });
539
+ }
378
540
  const id = crypto.randomUUID();
379
541
  const request = {
380
542
  id,
381
543
  toolName,
382
544
  title: options.title || `${toolName} requires approval`,
383
- reason: options.decisionReason || '',
545
+ displayName: options.displayName || '',
546
+ description: options.description || '',
547
+ reason: options.decisionReason || options.description || '',
548
+ blockedPath: options.blockedPath || null,
549
+ canAllowTool: Boolean(toolName && !EDIT_TOOLS.has(toolName) && !EXIT_PLAN_TOOLS.has(toolName)),
550
+ canAllowEdits: EDIT_TOOLS.has(toolName) || EXIT_PLAN_TOOLS.has(toolName),
551
+ canBypass: EXIT_PLAN_TOOLS.has(toolName),
384
552
  input,
385
553
  createdAt: Date.now(),
386
554
  status: 'pending'
@@ -390,6 +558,8 @@ class ClaudeStructuredSession extends EventEmitter {
390
558
  this.pendingPermissions.set(id, {
391
559
  public: request,
392
560
  resolve,
561
+ input,
562
+ toolName,
393
563
  toolUseID: options.toolUseID
394
564
  });
395
565
  });
@@ -412,6 +582,8 @@ class ClaudeStructuredSession extends EventEmitter {
412
582
  }
413
583
 
414
584
  if (message.type === 'assistant') {
585
+ const usage = message.message && message.message.usage;
586
+ if (usage) this.updateLatestUsageFromClaudeUsage(usage, message.message.model);
415
587
  const content = message.message && message.message.content;
416
588
  const text = textFromContent(content).trim();
417
589
  const toolBlocks = Array.isArray(content)
@@ -467,6 +639,25 @@ class ClaudeStructuredSession extends EventEmitter {
467
639
  const usage = message && (message.usage || message.total_usage || message.result && message.result.usage);
468
640
  const context = usage && (usage.context_remaining || usage.contextRemaining || usage.remaining_context || usage.remainingContext);
469
641
  this.contextRemaining = typeof context === 'number' ? context : null;
642
+ if (usage) this.updateLatestUsageFromClaudeUsage(usage, message.model || message.result && message.result.model);
643
+ }
644
+
645
+ updateLatestUsageFromClaudeUsage(usage, model = null) {
646
+ const inputTokens = Number(usage.input_tokens || usage.inputTokens || 0);
647
+ const outputTokens = Number(usage.output_tokens || usage.outputTokens || 0);
648
+ const cacheCreation = Number(usage.cache_creation_input_tokens || usage.cacheCreationInputTokens || 0);
649
+ const cacheRead = Number(usage.cache_read_input_tokens || usage.cacheReadInputTokens || 0);
650
+ const contextSize = inputTokens + cacheCreation + cacheRead;
651
+ this.latestUsage = {
652
+ inputTokens,
653
+ outputTokens,
654
+ cacheCreation,
655
+ cacheRead,
656
+ totalTokens: inputTokens + outputTokens + cacheCreation + cacheRead,
657
+ contextSize,
658
+ model: model || null,
659
+ updatedAt: Date.now()
660
+ };
470
661
  }
471
662
 
472
663
  summarizeToolInput(input) {
@@ -459,7 +459,7 @@ async function webCommand(options) {
459
459
  sessionManager.sendClaudeInput(sessionId, payload.text || '');
460
460
  }
461
461
  if (payload.type === 'claude-permission') {
462
- sessionManager.respondClaudePermission(sessionId, payload.id, Boolean(payload.approved));
462
+ sessionManager.respondClaudePermission(sessionId, payload.id, Boolean(payload.approved), payload.action || null);
463
463
  }
464
464
  if (payload.type === 'claude-settings') {
465
465
  sessionManager.updateClaudeSettings(sessionId, payload.settings || {});
@@ -177,10 +177,10 @@ class SessionManager extends EventEmitter {
177
177
  return session.sendUserMessage(text);
178
178
  }
179
179
 
180
- respondClaudePermission(id, permissionId, approved) {
180
+ respondClaudePermission(id, permissionId, approved, action = null) {
181
181
  const session = this.get(id);
182
182
  if (!session || session.kind !== 'claude-structured') return false;
183
- return session.respondPermission(permissionId, approved);
183
+ return session.respondPermission(permissionId, approved, action);
184
184
  }
185
185
 
186
186
  updateClaudeSettings(id, settings) {
@@ -78,6 +78,7 @@
78
78
  #terminal-container { flex: 1; min-height: 0; width: 100%; background: #000; position: relative; overflow: hidden; overflow-anchor: none; }
79
79
  #terminal { height: 100%; width: 100%; }
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
+ .claude-context-size-badge { position: sticky; top: 0; z-index: 2; width: max-content; max-width: 100%; margin: 0 0 8px auto; border: 1px solid rgba(255,255,255,0.1); background: rgba(28,28,30,0.94); border-radius: 999px; padding: 4px 9px; color: #d1d5db; font-size: 11px; font-weight: 800; box-shadow: 0 8px 18px rgba(0,0,0,0.24); }
81
82
  .claude-message { max-width: 92%; margin: 0 0 10px 0; padding: 10px 12px; border-radius: 8px; overflow-wrap: anywhere; line-height: 1.45; font-size: 14px; }
82
83
  .claude-message.user { margin-left: auto; background: rgba(0,122,255,0.24); border: 1px solid rgba(0,122,255,0.32); color: #fff; border-radius: 12px; padding-top: 7px; padding-bottom: 7px; }
83
84
  .claude-message.assistant { max-width: 100%; background: transparent; border: 0; color: #f5f5f7; padding: 0 4px; margin: 0 0 12px 0; }
@@ -127,11 +128,12 @@
127
128
  .claude-work-group-title { flex: 1; min-width: 0; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
128
129
  .claude-work-group-body { padding: 2px 8px 8px 8px; }
129
130
  .claude-permission { border-color: rgba(255,204,0,0.4); background: rgba(255,204,0,0.08); }
130
- .claude-permission-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 10px; }
131
+ .claude-permission-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 10px; flex-wrap: wrap; }
132
+ .claude-permission-actions .small-btn { min-height: 30px; max-width: 100%; white-space: normal; line-height: 1.2; }
131
133
  .claude-status { color: var(--text-dim); font-size: 12px; text-align: center; padding: 6px 0 0 0; }
132
134
  #claude-control-panel { display: none; padding: 0 14px 10px 14px; box-sizing: border-box; }
133
- .claude-control-row { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 7px; }
134
- .claude-select, .claude-ctrl-btn { min-width: 0; height: 32px; border: 1px solid rgba(255,255,255,0.1); border-radius: 16px; background: rgba(255,255,255,0.08); color: #f5f5f7; font-size: 11px; font-weight: 800; letter-spacing: 0; padding: 0 8px; outline: none; box-sizing: border-box; }
135
+ .claude-control-row { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 7px; }
136
+ .claude-select, .claude-ctrl-btn { min-width: 0; height: 32px; border: 1px solid rgba(255,255,255,0.1); border-radius: 16px; background: rgba(255,255,255,0.08); color: #f5f5f7; font-size: 11px; font-weight: 800; letter-spacing: 0; padding: 0 10px; outline: none; box-sizing: border-box; }
135
137
  .claude-select { appearance: none; -webkit-appearance: none; text-align: center; text-align-last: center; }
136
138
  .claude-picker-wrap { min-width: 0; position: relative; }
137
139
  .claude-picker-wrap select { display: none; }
@@ -139,11 +141,19 @@
139
141
  .claude-picker-btn.active { background: rgba(0,122,255,0.22); border-color: rgba(0,122,255,0.5); }
140
142
  #claude-picker-panel { display: none; margin-top: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(28,28,30,0.99); border-radius: 12px; max-height: min(280px, 42dvh); overflow-y: auto; box-shadow: 0 16px 36px rgba(0,0,0,0.34); }
141
143
  #claude-picker-panel.active { display: block; }
144
+ #claude-usage-panel { display: none; margin-top: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(28,28,30,0.99); border-radius: 12px; padding: 10px 11px; box-shadow: 0 16px 36px rgba(0,0,0,0.34); color: #f5f5f7; }
145
+ #claude-usage-panel.active { display: block; }
146
+ .claude-usage-title { color: var(--text-dim); font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 9px; }
147
+ .claude-usage-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
148
+ .claude-usage-item { border: 1px solid rgba(255,255,255,0.08); background: rgba(255,255,255,0.045); border-radius: 8px; padding: 8px; min-width: 0; }
149
+ .claude-usage-label { color: var(--text-dim); font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.04em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
150
+ .claude-usage-value { margin-top: 4px; color: #f5f5f7; font-size: 13px; font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
142
151
  .claude-picker-title { padding: 9px 11px 7px 11px; color: var(--text-dim); font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.04em; border-bottom: 1px solid rgba(255,255,255,0.06); }
143
152
  .claude-picker-option { width: 100%; border: 0; border-bottom: 1px solid rgba(255,255,255,0.06); background: transparent; color: #f5f5f7; padding: 10px 11px; text-align: left; cursor: pointer; box-sizing: border-box; }
144
153
  .claude-picker-option:last-child { border-bottom: 0; }
145
154
  .claude-picker-option:active, .claude-picker-option.selected { background: rgba(0,122,255,0.18); }
146
155
  .claude-picker-option-main { display: flex; align-items: center; justify-content: space-between; gap: 10px; font-size: 13px; font-weight: 800; }
156
+ .claude-picker-option-main .selected-label { color: var(--text-dim); font-size: 11px; font-weight: 800; text-transform: uppercase; flex: 0 0 auto; }
147
157
  .claude-picker-option-value { min-width: 0; overflow-wrap: anywhere; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: #d1d5db; font-size: 12px; margin-top: 3px; line-height: 1.35; }
148
158
  .claude-ctrl-btn { cursor: pointer; display: inline-flex; align-items: center; justify-content: center; white-space: nowrap; }
149
159
  .claude-ctrl-btn:active, .claude-select:focus { background: rgba(255,255,255,0.14); border-color: rgba(0,122,255,0.45); }
@@ -154,6 +164,10 @@
154
164
  #claude-state-bar::-webkit-scrollbar { display: none; }
155
165
  .claude-state-pill { display: inline-flex; align-items: center; gap: 4px; border: 1px solid rgba(255,255,255,0.08); background: rgba(255,255,255,0.045); border-radius: 999px; padding: 3px 8px; flex: 0 0 auto; }
156
166
  .claude-state-pill.warn { color: #ffcc00; border-color: rgba(255,204,0,0.24); background: rgba(255,204,0,0.08); }
167
+ .claude-state-pill.perm { margin-left: auto; }
168
+ .claude-state-pill.perm.acceptEdits { color: #34c759; border-color: rgba(52,199,89,0.26); background: rgba(52,199,89,0.08); }
169
+ .claude-state-pill.perm.bypassPermissions { color: #ff9f0a; border-color: rgba(255,159,10,0.26); background: rgba(255,159,10,0.08); }
170
+ .claude-state-pill.perm.plan { color: #64d2ff; border-color: rgba(100,210,255,0.26); background: rgba(100,210,255,0.08); }
157
171
  #claude-resume-panel { display: none; margin-top: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(28,28,30,0.98); border-radius: 12px; max-height: 180px; overflow-y: auto; }
158
172
  #claude-resume-panel.active { display: block; }
159
173
  .claude-resume-item { width: 100%; text-align: left; border: 0; border-bottom: 1px solid rgba(255,255,255,0.06); background: transparent; color: #fff; padding: 10px 11px; cursor: pointer; box-sizing: border-box; }
@@ -275,10 +289,10 @@
275
289
  <div class="claude-control-row">
276
290
  <div class="claude-picker-wrap">
277
291
  <select id="claude-permission-select" class="claude-select" title="Permission mode" onchange="updateClaudeSettingsFromControls()">
278
- <option value="default">P:Def</option>
279
- <option value="acceptEdits">P:Edit</option>
280
- <option value="plan">P:Plan</option>
281
- <option value="bypassPermissions">P:Byp</option>
292
+ <option value="default">Default</option>
293
+ <option value="acceptEdits">Accept edits</option>
294
+ <option value="plan">Plan mode</option>
295
+ <option value="bypassPermissions">Bypass</option>
282
296
  </select>
283
297
  <button id="claude-permission-picker-btn" class="claude-select claude-picker-btn" onclick="toggleClaudePicker('permission')" title="Permission mode">P</button>
284
298
  </div>
@@ -298,11 +312,13 @@
298
312
  </select>
299
313
  <button id="claude-effort-picker-btn" class="claude-select claude-picker-btn" onclick="toggleClaudePicker('effort')" title="Effort">E</button>
300
314
  </div>
315
+ <button id="claude-usage-btn" class="claude-ctrl-btn" onclick="toggleClaudeUsagePanel()" title="Usage">Usage</button>
301
316
  <button id="claude-abort-btn" class="claude-ctrl-btn danger" onclick="abortClaudeSession()" title="Abort current Claude turn">Abort</button>
302
317
  <button id="claude-resume-btn" class="claude-ctrl-btn primary" onclick="toggleClaudeResumePanel()" title="Choose a Claude session to resume">Resume</button>
303
318
  </div>
304
319
  <div id="claude-state-bar"></div>
305
320
  <div id="claude-picker-panel"></div>
321
+ <div id="claude-usage-panel"></div>
306
322
  <div id="claude-resume-panel"></div>
307
323
  </div>
308
324
  <div id="timed-send-panel">
@@ -478,11 +494,14 @@
478
494
  let claudeStatus = 'idle';
479
495
  let claudeRuntimeConfig = null;
480
496
  let claudePickerOpen = null;
497
+ let claudeUsagePanelOpen = false;
498
+ const CLAUDE_CONTEXT_SIZE = 190000;
481
499
  let claudeState = {
482
500
  permissionMode: 'default',
483
501
  model: 'default',
484
502
  effort: 'medium',
485
503
  contextRemaining: null,
504
+ latestUsage: null,
486
505
  claudeSessionId: null,
487
506
  resumeSessionId: null,
488
507
  canAbort: false,
@@ -785,8 +804,7 @@
785
804
  document.getElementById('terminal-container').style.display = enabled ? 'none' : '';
786
805
  document.getElementById('claude-chat-container').style.display = enabled ? 'block' : 'none';
787
806
  document.getElementById('claude-control-panel').style.display = enabled ? 'block' : 'none';
788
- document.getElementById('timer-btn').style.display = enabled ? 'none' : '';
789
- if (enabled) closeTimedSendPanel();
807
+ document.getElementById('timer-btn').style.display = '';
790
808
  document.getElementById('shortcut-rail').style.display = enabled ? 'none' : '';
791
809
  document.getElementById('scroll-controls').style.display = enabled ? 'none' : '';
792
810
  document.getElementById('cmd-input').placeholder = enabled ? 'Message Claude...' : 'Type a message...';
@@ -794,12 +812,13 @@
794
812
  }
795
813
 
796
814
  function updateTerminalControlsHeight() {
815
+ const timedOpen = document.getElementById('timed-send-panel')?.classList.contains('active');
797
816
  if (!isClaudeSession()) {
798
- document.documentElement.style.setProperty('--terminal-controls-rest-height', '108px');
817
+ document.documentElement.style.setProperty('--terminal-controls-rest-height', timedOpen ? '252px' : '108px');
799
818
  return;
800
819
  }
801
- const restHeight = claudeResumePanelOpen ? '308px' : (claudePickerOpen ? '322px' : '154px');
802
- document.documentElement.style.setProperty('--terminal-controls-rest-height', restHeight);
820
+ const baseHeight = claudeResumePanelOpen ? 308 : ((claudePickerOpen || claudeUsagePanelOpen) ? 322 : 154);
821
+ document.documentElement.style.setProperty('--terminal-controls-rest-height', `${baseHeight + (timedOpen ? 144 : 0)}px`);
803
822
  }
804
823
 
805
824
  function shortModelLabel(value, resolved) {
@@ -817,6 +836,35 @@
817
836
  return `M:${compact || source.slice(0, 9)}`;
818
837
  }
819
838
 
839
+ function fullModelLabel(value, resolved, label) {
840
+ const source = String(value || 'default');
841
+ if (source === 'default') return 'Default';
842
+ if (source === 'env') return resolved ? `Environment (${resolved})` : 'Environment';
843
+ if (label && label !== value) return label;
844
+ return String(resolved || value || 'Model');
845
+ }
846
+
847
+ function permissionModeLabel(value) {
848
+ const labels = {
849
+ default: 'Default',
850
+ acceptEdits: 'Accept edits',
851
+ plan: 'Plan mode',
852
+ bypassPermissions: 'Bypass'
853
+ };
854
+ return labels[value] || String(value || 'Default');
855
+ }
856
+
857
+ function effortLabel(value) {
858
+ const labels = {
859
+ low: 'Low',
860
+ medium: 'Medium',
861
+ high: 'High',
862
+ xhigh: 'Extra high',
863
+ max: 'Max'
864
+ };
865
+ return labels[value] || String(value || 'Medium');
866
+ }
867
+
820
868
  function pickerLabelFromOption(option) {
821
869
  return option ? (option.textContent || option.value || '') : '';
822
870
  }
@@ -828,19 +876,19 @@
828
876
 
829
877
  function syncClaudePickerButtons() {
830
878
  const mappings = [
831
- ['permission', 'claude-permission-select', 'claude-permission-picker-btn', 'P'],
832
- ['model', 'claude-model-select', 'claude-model-picker-btn', 'M'],
833
- ['effort', 'claude-effort-select', 'claude-effort-picker-btn', 'E']
879
+ ['permission', 'claude-permission-select', 'claude-permission-picker-btn', 'Perm'],
880
+ ['model', 'claude-model-select', 'claude-model-picker-btn', 'Model'],
881
+ ['effort', 'claude-effort-select', 'claude-effort-picker-btn', 'Effort']
834
882
  ];
835
- mappings.forEach(([type, selectId, buttonId, fixedLabel]) => {
883
+ mappings.forEach(([type, selectId, buttonId, prefix]) => {
836
884
  const select = document.getElementById(selectId);
837
885
  const button = document.getElementById(buttonId);
838
886
  if (!select || !button) return;
839
887
  const option = select.selectedOptions && select.selectedOptions[0];
840
888
  const label = pickerLabelFromOption(option);
841
889
  const fullValue = pickerFullValue(option);
842
- button.textContent = fixedLabel;
843
- button.title = fullValue || label || fixedLabel;
890
+ button.textContent = prefix;
891
+ button.title = fullValue || label || prefix;
844
892
  button.classList.toggle('active', claudePickerOpen === type);
845
893
  });
846
894
  }
@@ -856,6 +904,13 @@
856
904
  updateTerminalControlsHeight();
857
905
  }
858
906
 
907
+ function closeClaudeUsagePanel() {
908
+ claudeUsagePanelOpen = false;
909
+ const panel = document.getElementById('claude-usage-panel');
910
+ if (panel) panel.classList.remove('active');
911
+ updateTerminalControlsHeight();
912
+ }
913
+
859
914
  function pickerTitle(type) {
860
915
  if (type === 'permission') return 'Permission mode';
861
916
  if (type === 'model') return 'Model';
@@ -881,7 +936,7 @@
881
936
  return `<button class="claude-picker-option${selected ? ' selected' : ''}" onclick="chooseClaudePickerOption('${type}', decodePathValue('${encodePathValue(option.value)}'))">
882
937
  <div class="claude-picker-option-main">
883
938
  <span>${escapeHtml(option.textContent || option.value)}</span>
884
- ${selected ? '<span>Selected</span>' : ''}
939
+ ${selected ? '<span class="selected-label">Selected</span>' : ''}
885
940
  </div>
886
941
  ${full ? `<div class="claude-picker-option-value">${escapeHtml(full)}</div>` : ''}
887
942
  </button>`;
@@ -897,6 +952,7 @@
897
952
  return;
898
953
  }
899
954
  if (type === 'model') await refreshClaudeRuntimeConfig();
955
+ closeClaudeUsagePanel();
900
956
  claudeResumePanelOpen = false;
901
957
  const resumePanel = document.getElementById('claude-resume-panel');
902
958
  if (resumePanel) resumePanel.classList.remove('active');
@@ -920,9 +976,10 @@
920
976
  const current = modelEl.value || claudeState.model || config.defaultModel || 'default';
921
977
  modelEl.innerHTML = '';
922
978
  config.models.forEach(item => {
923
- const option = new Option(shortModelLabel(item.value, item.resolved), item.value);
979
+ const option = new Option(fullModelLabel(item.value, item.resolved, item.label), item.value);
924
980
  option.title = item.resolved ? `${item.label}: ${item.resolved}` : item.label;
925
981
  option.dataset.resolved = item.resolved || '';
982
+ option.dataset.shortLabel = shortModelLabel(item.value, item.resolved);
926
983
  modelEl.add(option);
927
984
  });
928
985
  const next = Array.from(modelEl.options).some(option => option.value === current)
@@ -977,6 +1034,8 @@
977
1034
  const abortBtn = document.getElementById('claude-abort-btn');
978
1035
  if (abortBtn) abortBtn.disabled = !(claudeState.canAbort || claudeStatus === 'thinking');
979
1036
  renderClaudeStateBar();
1037
+ renderClaudeUsagePanel();
1038
+ renderClaudeChat();
980
1039
  }
981
1040
 
982
1041
  function shortValue(value, fallback = 'N/A') {
@@ -984,26 +1043,90 @@
984
1043
  return text.length > 16 ? text.slice(0, 13) + '...' : text;
985
1044
  }
986
1045
 
1046
+ function formatTokenCount(value) {
1047
+ const number = Number(value || 0);
1048
+ if (number >= 1000000) return `${(number / 1000000).toFixed(2)}M`;
1049
+ if (number >= 1000) return `${(number / 1000).toFixed(1)}K`;
1050
+ return String(Math.round(number));
1051
+ }
1052
+
1053
+ function formatContextK(value) {
1054
+ return `${(Number(value || 0) / 1000).toFixed(1)}K`;
1055
+ }
1056
+
1057
+ function contextRemainingPercent(usage) {
1058
+ if (!usage || typeof usage.contextSize !== 'number') return null;
1059
+ const used = Math.max(0, usage.contextSize);
1060
+ return Math.max(0, Math.min(100, 100 - (used / CLAUDE_CONTEXT_SIZE) * 100));
1061
+ }
1062
+
1063
+ function renderClaudeContextSizeBadge() {
1064
+ const usage = claudeState.latestUsage;
1065
+ if (!usage || typeof usage.contextSize !== 'number') return '';
1066
+ return `<div class="claude-context-size-badge">Context ${escapeHtml(formatContextK(usage.contextSize))}</div>`;
1067
+ }
1068
+
987
1069
  function renderClaudeStateBar() {
988
1070
  const el = document.getElementById('claude-state-bar');
989
1071
  if (!el) return;
990
- const modelEl = document.getElementById('claude-model-select');
991
- const modelOption = modelEl && modelEl.selectedOptions && modelEl.selectedOptions[0];
992
- const modelLabel = modelOption && modelOption.dataset.resolved
993
- ? modelOption.dataset.resolved
994
- : (claudeState.model || 'default');
995
- const context = typeof claudeState.contextRemaining === 'number'
996
- ? `Ctx ${claudeState.contextRemaining}`
997
- : 'Ctx N/A';
998
1072
  const pending = Number(claudeState.pendingPermissionCount || claudePendingPermissions.filter(item => item.status === 'pending').length) || 0;
999
- const status = claudeStatus === 'thinking' ? 'Working' : shortValue(claudeStatus || 'Idle');
1000
- el.innerHTML = [
1001
- `<span class="claude-state-pill">${escapeHtml(shortValue(claudeState.permissionMode || 'default'))}</span>`,
1002
- `<span class="claude-state-pill">${escapeHtml(shortValue(modelLabel))}</span>`,
1003
- `<span class="claude-state-pill">Eff ${escapeHtml(shortValue(claudeState.effort || 'medium'))}</span>`,
1004
- `<span class="claude-state-pill">${escapeHtml(context)}</span>`,
1005
- `<span class="claude-state-pill${pending ? ' warn' : ''}">${pending ? `${pending} approval` : escapeHtml(status)}</span>`
1006
- ].join('');
1073
+ const mode = claudeState.permissionMode || 'default';
1074
+ const parts = [];
1075
+ if (pending) {
1076
+ parts.push(`<span class="claude-state-pill warn">${pending} approval${pending > 1 ? 's' : ''}</span>`);
1077
+ } else if (claudeStatus === 'thinking') {
1078
+ parts.push('<span class="claude-state-pill">Working</span>');
1079
+ } else if (claudeStatus && !['idle', 'stopped'].includes(claudeStatus)) {
1080
+ parts.push(`<span class="claude-state-pill warn">${escapeHtml(shortValue(claudeStatus))}</span>`);
1081
+ }
1082
+ const remainingPercent = contextRemainingPercent(claudeState.latestUsage);
1083
+ if (remainingPercent !== null && remainingPercent <= 10) {
1084
+ parts.push(`<span class="claude-state-pill warn">${Math.round(remainingPercent)}% left</span>`);
1085
+ }
1086
+ if (mode !== 'default') {
1087
+ parts.push(`<span class="claude-state-pill perm ${escapeHtml(mode)}">${escapeHtml(permissionModeLabel(mode))}</span>`);
1088
+ }
1089
+ el.innerHTML = parts.join('');
1090
+ el.style.display = parts.length ? 'flex' : 'none';
1091
+ }
1092
+
1093
+ function usageItem(label, value) {
1094
+ return `<div class="claude-usage-item">
1095
+ <div class="claude-usage-label">${escapeHtml(label)}</div>
1096
+ <div class="claude-usage-value">${escapeHtml(value)}</div>
1097
+ </div>`;
1098
+ }
1099
+
1100
+ function renderClaudeUsagePanel() {
1101
+ const panel = document.getElementById('claude-usage-panel');
1102
+ if (!panel) return;
1103
+ const usage = claudeState.latestUsage;
1104
+ if (!usage) {
1105
+ panel.innerHTML = '<div class="claude-usage-title">Usage</div><div class="claude-resume-meta">No usage yet.</div>';
1106
+ } else {
1107
+ panel.innerHTML = `<div class="claude-usage-title">Usage</div>
1108
+ <div class="claude-usage-grid">
1109
+ ${usageItem('Total', formatTokenCount(usage.totalTokens))}
1110
+ ${usageItem('Context', formatContextK(usage.contextSize))}
1111
+ ${usageItem('Input', formatTokenCount(usage.inputTokens))}
1112
+ ${usageItem('Output', formatTokenCount(usage.outputTokens))}
1113
+ ${usageItem('Cache write', formatTokenCount(usage.cacheCreation))}
1114
+ ${usageItem('Cache read', formatTokenCount(usage.cacheRead))}
1115
+ </div>`;
1116
+ }
1117
+ panel.classList.toggle('active', claudeUsagePanelOpen);
1118
+ }
1119
+
1120
+ function toggleClaudeUsagePanel() {
1121
+ claudeUsagePanelOpen = !claudeUsagePanelOpen;
1122
+ if (claudeUsagePanelOpen) {
1123
+ closeClaudePicker();
1124
+ claudeResumePanelOpen = false;
1125
+ const resumePanel = document.getElementById('claude-resume-panel');
1126
+ if (resumePanel) resumePanel.classList.remove('active');
1127
+ }
1128
+ renderClaudeUsagePanel();
1129
+ updateTerminalControlsHeight();
1007
1130
  }
1008
1131
 
1009
1132
  async function updateClaudeSettingsFromControls() {
@@ -1027,6 +1150,7 @@
1027
1150
  async function toggleClaudeResumePanel() {
1028
1151
  claudeResumePanelOpen = !claudeResumePanelOpen;
1029
1152
  if (claudeResumePanelOpen) closeClaudePicker();
1153
+ if (claudeResumePanelOpen) closeClaudeUsagePanel();
1030
1154
  const panel = document.getElementById('claude-resume-panel');
1031
1155
  panel.classList.toggle('active', claudeResumePanelOpen);
1032
1156
  updateTerminalControlsHeight();
@@ -1360,9 +1484,41 @@
1360
1484
  return '';
1361
1485
  }
1362
1486
 
1487
+ function isClaudeEditTool(name) {
1488
+ return ['Edit', 'MultiEdit', 'Write', 'NotebookEdit'].includes(name || '');
1489
+ }
1490
+
1491
+ function isClaudeExitPlanTool(name) {
1492
+ return name === 'exit_plan_mode' || name === 'ExitPlanMode';
1493
+ }
1494
+
1495
+ function claudeAllowToolLabel(req) {
1496
+ if (req.toolName === 'Bash' && req.input && typeof req.input.command === 'string') return 'Allow command';
1497
+ return 'Allow tool';
1498
+ }
1499
+
1500
+ function renderClaudePermissionActions(req) {
1501
+ const id = escapeHtml(req.id);
1502
+ const toolName = req.toolName || '';
1503
+ const parts = [
1504
+ `<button class="small-btn primary" onclick="respondClaudePermission('${id}', 'allow-once')">Yes</button>`
1505
+ ];
1506
+ if (isClaudeEditTool(toolName) || isClaudeExitPlanTool(toolName) || req.canAllowEdits) {
1507
+ parts.push(`<button class="small-btn primary" onclick="respondClaudePermission('${id}', 'allow-edits')">Allow edits</button>`);
1508
+ }
1509
+ if (isClaudeExitPlanTool(toolName) || req.canBypass) {
1510
+ parts.push(`<button class="small-btn primary" onclick="respondClaudePermission('${id}', 'bypass')">Allow all</button>`);
1511
+ }
1512
+ if (toolName && !isClaudeEditTool(toolName) && !isClaudeExitPlanTool(toolName) && req.canAllowTool !== false) {
1513
+ parts.push(`<button class="small-btn primary" onclick="respondClaudePermission('${id}', 'allow-tool')">${escapeHtml(claudeAllowToolLabel(req))}</button>`);
1514
+ }
1515
+ parts.push(`<button class="small-btn danger" onclick="respondClaudePermission('${id}', 'deny')">Deny</button>`);
1516
+ return parts.join('');
1517
+ }
1518
+
1363
1519
  function renderClaudeChat() {
1364
1520
  const container = document.getElementById('claude-chat-container');
1365
- const parts = buildClaudeDisplayItems(claudeMessages).map(renderDisplayItem);
1521
+ const parts = [renderClaudeContextSizeBadge(), ...buildClaudeDisplayItems(claudeMessages).map(renderDisplayItem)].filter(Boolean);
1366
1522
  for (const req of claudePendingPermissions.filter(item => item.status === 'pending')) {
1367
1523
  parts.push(`<div class="claude-tool claude-permission">
1368
1524
  <div class="claude-tool-header">
@@ -1371,10 +1527,10 @@
1371
1527
  </div>
1372
1528
  <div class="claude-tool-body">
1373
1529
  ${req.reason ? `<div>${escapeHtml(req.reason)}</div>` : ''}
1530
+ ${req.blockedPath ? `<div class="claude-resume-meta">${escapeHtml(req.blockedPath)}</div>` : ''}
1374
1531
  ${renderToolSection('Input', req.input || {})}
1375
1532
  <div class="claude-permission-actions">
1376
- <button class="small-btn danger" onclick="respondClaudePermission('${req.id}', false)">Deny</button>
1377
- <button class="small-btn primary" onclick="respondClaudePermission('${req.id}', true)">Allow</button>
1533
+ ${renderClaudePermissionActions(req)}
1378
1534
  </div>
1379
1535
  </div>
1380
1536
  </div>`);
@@ -1411,9 +1567,17 @@
1411
1567
  renderClaudeChat();
1412
1568
  }
1413
1569
 
1414
- function respondClaudePermission(id, approved) {
1570
+ function respondClaudePermission(id, actionOrApproved) {
1415
1571
  if (!currentSocket || currentSocket.readyState !== 1) return;
1416
- currentSocket.send(JSON.stringify({ type: 'claude-permission', id, approved }));
1572
+ const action = typeof actionOrApproved === 'string'
1573
+ ? actionOrApproved
1574
+ : (actionOrApproved ? 'allow-once' : 'deny');
1575
+ currentSocket.send(JSON.stringify({
1576
+ type: 'claude-permission',
1577
+ id,
1578
+ action,
1579
+ approved: action !== 'deny'
1580
+ }));
1417
1581
  }
1418
1582
 
1419
1583
  async function createSession(toolKey, sessionName) {
@@ -1809,6 +1973,7 @@
1809
1973
  model: (runtimeConfig && runtimeConfig.defaultModel) || 'default',
1810
1974
  effort: (runtimeConfig && runtimeConfig.defaultEffort) || 'medium',
1811
1975
  contextRemaining: null,
1976
+ latestUsage: null,
1812
1977
  claudeSessionId: null,
1813
1978
  resumeSessionId: null,
1814
1979
  canAbort: false,
@@ -2142,6 +2307,7 @@
2142
2307
  document.getElementById('timer-btn').classList.remove('active');
2143
2308
  editingTimedInputId = null;
2144
2309
  renderTimedTags();
2310
+ updateTerminalControlsHeight();
2145
2311
  }
2146
2312
 
2147
2313
  function stopTimedInputTimers() {
@@ -2202,6 +2368,7 @@
2202
2368
  document.getElementById('timer-btn').classList.add('active');
2203
2369
  updateTimedSendPreview();
2204
2370
  renderTimedTags();
2371
+ updateTerminalControlsHeight();
2205
2372
  }
2206
2373
 
2207
2374
  async function saveTimedSend() {
@@ -2250,12 +2417,19 @@
2250
2417
  const isOpen = panel.classList.toggle('active');
2251
2418
  document.getElementById('timer-btn').classList.toggle('active', isOpen);
2252
2419
  if (isOpen) {
2420
+ if (isClaudeSession()) {
2421
+ closeClaudePicker();
2422
+ closeClaudeUsagePanel();
2423
+ claudeResumePanelOpen = false;
2424
+ document.getElementById('claude-resume-panel').classList.remove('active');
2425
+ }
2253
2426
  initTimedDelaySelectors();
2254
2427
  resetTimedEditor({ keepInput: true });
2255
2428
  updateTimedSendPreview();
2256
2429
  loadTimedInputs();
2257
2430
  }
2258
2431
  else closeTimedSendPanel();
2432
+ updateTerminalControlsHeight();
2259
2433
  });
2260
2434
  inputEl.addEventListener('keydown', (e) => {
2261
2435
  if (e.key === 'Enter' && e.shiftKey) { e.preventDefault(); performSend(); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glad-web",
3
- "version": "1.0.20",
3
+ "version": "1.0.21",
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": {