fix(usage): make plan-usage chip work end-to-end + session-status footer

End-to-end testing on the real install surfaced several issues the unit
tests missed:

- Injection gate excluded real sessions: gated on workingDir under CASES_DIR,
  but sessions run in linked cases / real repos. Drop the gate (match
  updateCaseModel, which writes settings.local.json unconditionally).
- statusLine curl failed on HTTPS: prod is loopback HTTPS with a self-signed
  cert; `curl -s` returns 000. Use `curl -sk` (loopback only). applyStatusLineConfig
  now also updates an out-of-date ours-command so the fix propagates.
- Footer hijacked by limits: the in-terminal statusline now shows CURRENT
  SESSION status — `Opus 4.8 (1M context)  in:562,411 out:1,188  ctx:56%` —
  while the account-wide plan limits live only in the header chip.
- Chip blank after reload: persist last-known to localStorage and restore on
  load (account-global, slow-moving; 12h freshness guard).
- Readability + color: per-window green/yellow/red by usage (<60 / 60–84 / ≥85),
  bolder labels and values.
- Drop the renderIndexHtml strip (client-side reveal only, response-viewer
  pattern) — fixes server-index-title test fragility to local settings.

Footer fields flow through context_window.total_input_tokens/total_output_tokens
(schema + parser). Tests updated; verified live (footer, chip, colors, reload
persistence) on the real install.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
arkon
2026-06-14 05:44:54 +02:00
co-authored by Claude Opus 4.8
parent c82f6c802e
commit 4d9d93dfff
9 changed files with 191 additions and 62 deletions
+28 -10
View File
@@ -581,6 +581,7 @@ class CodemanApp {
const _kbSettings = this.loadAppSettingsFromStorage();
if (_kbSettings.extendedKeyboardBar) KeyboardAccessoryBar.setMode('extended');
this.applyHeaderVisibilitySettings();
this.restorePlanUsageChip();
this.applySkin();
this.applyTabWrapSettings();
this.applyMonitorVisibility();
@@ -1818,6 +1819,24 @@ class CodemanApp {
_onSessionStatusTelemetry(data) {
this._latestPlanUsage = data;
this.updatePlanUsageChip(data);
// Persist last-known so the chip shows immediately on the next page load /
// SSE reconnect, instead of staying blank until a session next renders.
try {
localStorage.setItem('codeman:planUsage', JSON.stringify({ t: Date.now(), data }));
} catch {}
}
// Repopulate the chip from the last-known value on page load (account-global,
// slow-moving; ignored if older than 12h). Live events refresh it.
restorePlanUsageChip() {
try {
const raw = localStorage.getItem('codeman:planUsage');
if (!raw) return;
const saved = JSON.parse(raw);
if (saved?.data && Date.now() - (saved.t || 0) < 12 * 3600 * 1000) {
this.updatePlanUsageChip(saved.data);
}
} catch {}
}
updatePlanUsageChip(data) {
@@ -1827,19 +1846,18 @@ class CodemanApp {
const five = pct(data.fiveHour);
const seven = pct(data.sevenDay);
if (five === null && seven === null) return;
const parts = [];
if (five !== null) parts.push(`5h ${five}%`);
if (seven !== null) parts.push(`7d ${seven}%`);
chip.textContent = parts.join(' · ');
// Color hint as a window nears exhaustion.
const max = Math.max(five ?? 0, seven ?? 0);
chip.classList.toggle('header-plan-usage--warn', max >= 80 && max < 95);
chip.classList.toggle('header-plan-usage--crit', max >= 95);
// Per-window color by how much is used up: green < 60%, yellow 60–84%, red ≥ 85%.
const colorClass = (p) => (p >= 85 ? 'pu-red' : p >= 60 ? 'pu-yellow' : 'pu-green');
const seg = (label, p) =>
p === null
? ''
: `<span class="pu-win"><span class="pu-label">${label}</span><span class="pu-val ${colorClass(p)}">${p}%</span></span>`;
chip.innerHTML = [seg('5h', five), seg('7d', seven)].filter(Boolean).join('<span class="pu-sep">·</span>');
const resetStr = (w) => (w && w.resetAt ? new Date(w.resetAt).toLocaleString() : '—');
chip.title =
`Claude plan usage\n` +
`5-hour: ${five ?? '—'}% (resets ${resetStr(data.fiveHour)})\n` +
`Weekly: ${seven ?? '—'}% (resets ${resetStr(data.sevenDay)})`;
`5-hour limit: ${five ?? '—'}% used (resets ${resetStr(data.fiveHour)})\n` +
`Weekly limit: ${seven ?? '—'}% used (resets ${resetStr(data.sevenDay)})`;
}
// Scheduled runs
+30 -6
View File
@@ -8341,13 +8341,37 @@ kbd {
white-space: nowrap;
cursor: default;
}
.header-plan-usage--warn {
color: #e6b800;
border-color: rgba(230, 184, 0, 0.4);
/* Readable two-window layout: dim uppercase label + bold, color-coded value. */
.header-plan-usage .pu-win {
display: inline-flex;
align-items: baseline;
gap: 4px;
}
.header-plan-usage--crit {
color: var(--danger, #ff5c5c);
border-color: rgba(255, 92, 92, 0.5);
.header-plan-usage .pu-label {
font-size: 0.66rem;
font-weight: 700;
color: var(--text);
opacity: 0.9;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.header-plan-usage .pu-val {
font-weight: 700;
}
.header-plan-usage .pu-sep {
margin: 0 7px;
color: var(--text-dim);
opacity: 0.45;
}
/* Green/yellow/red by how much of the window is used up. */
.header-plan-usage .pu-green {
color: #3fb950;
}
.header-plan-usage .pu-yellow {
color: #d6a01a;
}
.header-plan-usage .pu-red {
color: #f85149;
}
/* Marker class must out-specify the display:inline-flex !important above. */
.header-plan-usage.header-plan-usage--hidden {
+7 -4
View File
@@ -298,10 +298,13 @@ export function registerSessionRoutes(
}
// Plan-usage statusLine exporter (App Settings → Display → "Plan Usage
// Limits"). Claude-only, and only inside Codeman-managed cases (never
// arbitrary user repos, which may carry a hand-authored statusLine). Adds or
// removes our exporter so toggling the setting off cleans up on next create.
if ((body.mode ?? 'claude') === 'claude' && workingDir.startsWith(CASES_DIR + '/')) {
// Limits"). Claude-only. Runs for ANY working dir — including linked cases /
// real repos, which is where most sessions actually run — mirroring
// updateCaseModel above (which likewise writes settings.local.json
// unconditionally). Safe against a user's own statusLine: applyStatusLineConfig
// only ever removes a statusLine that is OURS (marker-guarded), and adds/removes
// ours so toggling the setting off cleans up on the next create.
if ((body.mode ?? 'claude') === 'claude') {
await applyStatusLineConfig(workingDir, body.statusLineTelemetry === true);
}
+20 -15
View File
@@ -17,7 +17,8 @@ import { StatusTelemetrySchema } from '../schemas.js';
import { parseBody } from '../route-helpers.js';
import {
parseStatusTelemetry,
formatStatusLineText,
parseSessionStatus,
formatSessionStatusText,
telemetrySignature,
type RawStatuslinePayload,
} from '../../usage-telemetry.js';
@@ -40,24 +41,28 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session
return 'codeman';
}
const telemetry = parseStatusTelemetry(data as RawStatuslinePayload | undefined);
if (!telemetry) {
// No plan-limit data yet (pre-first-response or non-subscriber auth).
return 'codeman';
}
const payload = data as RawStatuslinePayload | undefined;
const sig = telemetrySignature(telemetry);
if (lastSig.get(sessionId) !== sig) {
lastSig.set(sessionId, sig);
// Bound the map across long multi-session runs: prune dead sessions.
if (lastSig.size > 256) {
for (const id of [...lastSig.keys()]) {
if (!ctx.sessions.has(id)) lastSig.delete(id);
// Plan-usage limits (account-wide) → broadcast to the header chip, when
// present and changed (the statusline fires on every assistant message).
const telemetry = parseStatusTelemetry(payload);
if (telemetry) {
const sig = telemetrySignature(telemetry);
if (lastSig.get(sessionId) !== sig) {
lastSig.set(sessionId, sig);
// Bound the map across long multi-session runs: prune dead sessions.
if (lastSig.size > 256) {
for (const id of [...lastSig.keys()]) {
if (!ctx.sessions.has(id)) lastSig.delete(id);
}
}
ctx.broadcast(SessionStatusTelemetry, { sessionId, ...telemetry });
}
ctx.broadcast(SessionStatusTelemetry, { sessionId, ...telemetry });
}
return formatStatusLineText(telemetry);
// In-terminal statusline footer → CURRENT SESSION status (model / tokens /
// context %), NOT the plan limits. Available from the first render, even
// before rate_limits appears.
return formatSessionStatusText(parseSessionStatus(payload));
});
}
+7 -1
View File
@@ -212,7 +212,13 @@ export const StatusTelemetrySchema = z.object({
seven_day: RateLimitWindowSchema,
})
.optional(),
context_window: z.object({ used_percentage: z.number().optional() }).optional(),
context_window: z
.object({
used_percentage: z.number().optional(),
total_input_tokens: z.number().optional(),
total_output_tokens: z.number().optional(),
})
.optional(),
cost: z.object({ total_cost_usd: z.number().optional() }).optional(),
model: z.object({ display_name: z.string().max(100).optional() }).optional(),
})
-7
View File
@@ -1123,13 +1123,6 @@ export class WebServer extends EventEmitter {
if (settings.showMultiMonitorButton === true) {
html = html.replace(' btn-multimonitor--hidden', '');
}
// Plan-usage chip: carries `header-plan-usage--hidden` by default (App
// Settings → Display → "Plan Usage Limits"); reveal by stripping the marker
// class when the user enabled it, so the chip paints in the right state on
// every normal reload (the client toggles the same class live on save).
if (settings.showPlanUsageLimits === true) {
html = html.replace(' header-plan-usage--hidden', '');
}
// Detached single-session ("solo") window: inject the target session id so
// the client can enter solo mode even if a (network-first) service worker
// later serves a cached shell. The client primarily detects solo mode from