mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
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:
+9
-4
@@ -225,13 +225,17 @@ const STATUSLINE_MARKER = '/api/status-telemetry';
|
|||||||
* (present in every managed session via tmux setenv), so the config is static.
|
* (present in every managed session via tmux setenv), so the config is static.
|
||||||
*/
|
*/
|
||||||
export function generateStatusLineCommand(): string {
|
export function generateStatusLineCommand(): string {
|
||||||
|
// `curl -sk`: CODEMAN_API_URL is loopback HTTPS with a self-signed cert in the
|
||||||
|
// production setup; without -k curl returns 000 and the statusline shows
|
||||||
|
// nothing. -k is safe here (loopback only). Falls back to a brand string so the
|
||||||
|
// footer is never blank if Codeman is unreachable.
|
||||||
return (
|
return (
|
||||||
`INPUT=$(cat 2>/dev/null || echo '{}'); ` +
|
`INPUT=$(cat 2>/dev/null || echo '{}'); ` +
|
||||||
`printf '{"sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$INPUT" | ` +
|
`printf '{"sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$INPUT" | ` +
|
||||||
`curl -s -X POST "$CODEMAN_API_URL${STATUSLINE_MARKER}" ` +
|
`curl -sk -X POST "$CODEMAN_API_URL${STATUSLINE_MARKER}" ` +
|
||||||
`-H 'Content-Type: application/json' ` +
|
`-H 'Content-Type: application/json' ` +
|
||||||
`-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" ` +
|
`-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" ` +
|
||||||
`--data @- 2>/dev/null || true`
|
`--data @- 2>/dev/null || echo codeman`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,9 +263,10 @@ export async function applyStatusLineConfig(casePath: string, enabled: boolean):
|
|||||||
const isOurs = !!current && typeof current.command === 'string' && current.command.includes(STATUSLINE_MARKER);
|
const isOurs = !!current && typeof current.command === 'string' && current.command.includes(STATUSLINE_MARKER);
|
||||||
|
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
if (isOurs) return; // already configured — avoid rewriting on every session create
|
const desired = generateStatusLineCommand();
|
||||||
|
if (isOurs && current?.command === desired) return; // already current — skip rewrite
|
||||||
if (!existsSync(claudeDir)) await mkdir(claudeDir, { recursive: true });
|
if (!existsSync(claudeDir)) await mkdir(claudeDir, { recursive: true });
|
||||||
existing.statusLine = { type: 'command', command: generateStatusLineCommand() };
|
existing.statusLine = { type: 'command', command: desired }; // add, or update an out-of-date ours
|
||||||
} else {
|
} else {
|
||||||
if (!isOurs) return; // nothing of ours to remove (leave a user's own statusLine alone)
|
if (!isOurs) return; // nothing of ours to remove (leave a user's own statusLine alone)
|
||||||
delete existing.statusLine;
|
delete existing.statusLine;
|
||||||
|
|||||||
+56
-8
@@ -46,7 +46,7 @@ export interface RawStatuslinePayload {
|
|||||||
five_hour?: { used_percentage?: number; resets_at?: number };
|
five_hour?: { used_percentage?: number; resets_at?: number };
|
||||||
seven_day?: { used_percentage?: number; resets_at?: number };
|
seven_day?: { used_percentage?: number; resets_at?: number };
|
||||||
};
|
};
|
||||||
context_window?: { used_percentage?: number };
|
context_window?: { used_percentage?: number; total_input_tokens?: number; total_output_tokens?: number };
|
||||||
cost?: { total_cost_usd?: number };
|
cost?: { total_cost_usd?: number };
|
||||||
model?: { display_name?: string };
|
model?: { display_name?: string };
|
||||||
}
|
}
|
||||||
@@ -88,13 +88,61 @@ export function parseStatusTelemetry(data: RawStatuslinePayload | undefined): St
|
|||||||
return t;
|
return t;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compact in-terminal footer text (statusLine stdout / print-through). */
|
/**
|
||||||
export function formatStatusLineText(t: StatusTelemetry | null): string {
|
* Current-session status for the in-terminal statusline footer. This is the
|
||||||
if (!t) return 'codeman';
|
* "status of the current session" the user sees in Claude's footer — distinct
|
||||||
const parts: string[] = [];
|
* from the account-wide plan limits, which live ONLY in the Codeman header chip.
|
||||||
if (t.fiveHour) parts.push(`5h ${Math.round(t.fiveHour.usedPercentage)}%`);
|
*/
|
||||||
if (t.sevenDay) parts.push(`7d ${Math.round(t.sevenDay.usedPercentage)}%`);
|
export interface SessionStatus {
|
||||||
return parts.length ? `⟳ ${parts.join(' · ')}` : 'codeman';
|
modelDisplayName?: string;
|
||||||
|
inputTokens?: number;
|
||||||
|
outputTokens?: number;
|
||||||
|
contextUsedPercentage?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Group a non-negative integer with thousands separators: 562411 → "562,411". */
|
||||||
|
function withCommas(n: number): string {
|
||||||
|
return Math.max(0, Math.round(n))
|
||||||
|
.toString()
|
||||||
|
.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract current-session status (footer) from the raw payload. */
|
||||||
|
export function parseSessionStatus(data: RawStatuslinePayload | undefined): SessionStatus | null {
|
||||||
|
if (!data) return null;
|
||||||
|
const s: SessionStatus = {};
|
||||||
|
if (typeof data.model?.display_name === 'string' && data.model.display_name) {
|
||||||
|
s.modelDisplayName = data.model.display_name.slice(0, 60);
|
||||||
|
}
|
||||||
|
const cw = data.context_window;
|
||||||
|
if (typeof cw?.total_input_tokens === 'number' && Number.isFinite(cw.total_input_tokens)) {
|
||||||
|
s.inputTokens = Math.max(0, cw.total_input_tokens);
|
||||||
|
}
|
||||||
|
if (typeof cw?.total_output_tokens === 'number' && Number.isFinite(cw.total_output_tokens)) {
|
||||||
|
s.outputTokens = Math.max(0, cw.total_output_tokens);
|
||||||
|
}
|
||||||
|
if (typeof cw?.used_percentage === 'number') {
|
||||||
|
s.contextUsedPercentage = clampPct(cw.used_percentage);
|
||||||
|
}
|
||||||
|
return Object.keys(s).length ? s : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format the in-terminal statusline footer: the CURRENT SESSION's status —
|
||||||
|
* `Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%` — NOT the plan limits,
|
||||||
|
* which live in the Codeman header chip. Claude requires a statusLine command to
|
||||||
|
* emit the rate_limits JSON at all, so this is what that command prints back.
|
||||||
|
*/
|
||||||
|
export function formatSessionStatusText(s: SessionStatus | null): string {
|
||||||
|
if (!s) return 'codeman';
|
||||||
|
const groups: string[] = [];
|
||||||
|
if (s.modelDisplayName) groups.push(s.modelDisplayName);
|
||||||
|
const tok: string[] = [];
|
||||||
|
if (s.inputTokens != null) tok.push(`in:${withCommas(s.inputTokens)}`);
|
||||||
|
if (s.outputTokens != null) tok.push(`out:${withCommas(s.outputTokens)}`);
|
||||||
|
if (tok.length) groups.push(tok.join(' '));
|
||||||
|
if (s.contextUsedPercentage != null) groups.push(`ctx:${Math.round(s.contextUsedPercentage)}%`);
|
||||||
|
return groups.length ? groups.join(' ') : 'codeman';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+28
-10
@@ -581,6 +581,7 @@ class CodemanApp {
|
|||||||
const _kbSettings = this.loadAppSettingsFromStorage();
|
const _kbSettings = this.loadAppSettingsFromStorage();
|
||||||
if (_kbSettings.extendedKeyboardBar) KeyboardAccessoryBar.setMode('extended');
|
if (_kbSettings.extendedKeyboardBar) KeyboardAccessoryBar.setMode('extended');
|
||||||
this.applyHeaderVisibilitySettings();
|
this.applyHeaderVisibilitySettings();
|
||||||
|
this.restorePlanUsageChip();
|
||||||
this.applySkin();
|
this.applySkin();
|
||||||
this.applyTabWrapSettings();
|
this.applyTabWrapSettings();
|
||||||
this.applyMonitorVisibility();
|
this.applyMonitorVisibility();
|
||||||
@@ -1818,6 +1819,24 @@ class CodemanApp {
|
|||||||
_onSessionStatusTelemetry(data) {
|
_onSessionStatusTelemetry(data) {
|
||||||
this._latestPlanUsage = data;
|
this._latestPlanUsage = data;
|
||||||
this.updatePlanUsageChip(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) {
|
updatePlanUsageChip(data) {
|
||||||
@@ -1827,19 +1846,18 @@ class CodemanApp {
|
|||||||
const five = pct(data.fiveHour);
|
const five = pct(data.fiveHour);
|
||||||
const seven = pct(data.sevenDay);
|
const seven = pct(data.sevenDay);
|
||||||
if (five === null && seven === null) return;
|
if (five === null && seven === null) return;
|
||||||
const parts = [];
|
// Per-window color by how much is used up: green < 60%, yellow 60–84%, red ≥ 85%.
|
||||||
if (five !== null) parts.push(`5h ${five}%`);
|
const colorClass = (p) => (p >= 85 ? 'pu-red' : p >= 60 ? 'pu-yellow' : 'pu-green');
|
||||||
if (seven !== null) parts.push(`7d ${seven}%`);
|
const seg = (label, p) =>
|
||||||
chip.textContent = parts.join(' · ');
|
p === null
|
||||||
// Color hint as a window nears exhaustion.
|
? ''
|
||||||
const max = Math.max(five ?? 0, seven ?? 0);
|
: `<span class="pu-win"><span class="pu-label">${label}</span><span class="pu-val ${colorClass(p)}">${p}%</span></span>`;
|
||||||
chip.classList.toggle('header-plan-usage--warn', max >= 80 && max < 95);
|
chip.innerHTML = [seg('5h', five), seg('7d', seven)].filter(Boolean).join('<span class="pu-sep">·</span>');
|
||||||
chip.classList.toggle('header-plan-usage--crit', max >= 95);
|
|
||||||
const resetStr = (w) => (w && w.resetAt ? new Date(w.resetAt).toLocaleString() : '—');
|
const resetStr = (w) => (w && w.resetAt ? new Date(w.resetAt).toLocaleString() : '—');
|
||||||
chip.title =
|
chip.title =
|
||||||
`Claude plan usage\n` +
|
`Claude plan usage\n` +
|
||||||
`5-hour: ${five ?? '—'}% (resets ${resetStr(data.fiveHour)})\n` +
|
`5-hour limit: ${five ?? '—'}% used (resets ${resetStr(data.fiveHour)})\n` +
|
||||||
`Weekly: ${seven ?? '—'}% (resets ${resetStr(data.sevenDay)})`;
|
`Weekly limit: ${seven ?? '—'}% used (resets ${resetStr(data.sevenDay)})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scheduled runs
|
// Scheduled runs
|
||||||
|
|||||||
@@ -8341,13 +8341,37 @@ kbd {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
.header-plan-usage--warn {
|
/* Readable two-window layout: dim uppercase label + bold, color-coded value. */
|
||||||
color: #e6b800;
|
.header-plan-usage .pu-win {
|
||||||
border-color: rgba(230, 184, 0, 0.4);
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 4px;
|
||||||
}
|
}
|
||||||
.header-plan-usage--crit {
|
.header-plan-usage .pu-label {
|
||||||
color: var(--danger, #ff5c5c);
|
font-size: 0.66rem;
|
||||||
border-color: rgba(255, 92, 92, 0.5);
|
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. */
|
/* Marker class must out-specify the display:inline-flex !important above. */
|
||||||
.header-plan-usage.header-plan-usage--hidden {
|
.header-plan-usage.header-plan-usage--hidden {
|
||||||
|
|||||||
@@ -298,10 +298,13 @@ export function registerSessionRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Plan-usage statusLine exporter (App Settings → Display → "Plan Usage
|
// Plan-usage statusLine exporter (App Settings → Display → "Plan Usage
|
||||||
// Limits"). Claude-only, and only inside Codeman-managed cases (never
|
// Limits"). Claude-only. Runs for ANY working dir — including linked cases /
|
||||||
// arbitrary user repos, which may carry a hand-authored statusLine). Adds or
|
// real repos, which is where most sessions actually run — mirroring
|
||||||
// removes our exporter so toggling the setting off cleans up on next create.
|
// updateCaseModel above (which likewise writes settings.local.json
|
||||||
if ((body.mode ?? 'claude') === 'claude' && workingDir.startsWith(CASES_DIR + '/')) {
|
// 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);
|
await applyStatusLineConfig(workingDir, body.statusLineTelemetry === true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ import { StatusTelemetrySchema } from '../schemas.js';
|
|||||||
import { parseBody } from '../route-helpers.js';
|
import { parseBody } from '../route-helpers.js';
|
||||||
import {
|
import {
|
||||||
parseStatusTelemetry,
|
parseStatusTelemetry,
|
||||||
formatStatusLineText,
|
parseSessionStatus,
|
||||||
|
formatSessionStatusText,
|
||||||
telemetrySignature,
|
telemetrySignature,
|
||||||
type RawStatuslinePayload,
|
type RawStatuslinePayload,
|
||||||
} from '../../usage-telemetry.js';
|
} from '../../usage-telemetry.js';
|
||||||
@@ -40,24 +41,28 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session
|
|||||||
return 'codeman';
|
return 'codeman';
|
||||||
}
|
}
|
||||||
|
|
||||||
const telemetry = parseStatusTelemetry(data as RawStatuslinePayload | undefined);
|
const payload = data as RawStatuslinePayload | undefined;
|
||||||
if (!telemetry) {
|
|
||||||
// No plan-limit data yet (pre-first-response or non-subscriber auth).
|
|
||||||
return 'codeman';
|
|
||||||
}
|
|
||||||
|
|
||||||
const sig = telemetrySignature(telemetry);
|
// Plan-usage limits (account-wide) → broadcast to the header chip, when
|
||||||
if (lastSig.get(sessionId) !== sig) {
|
// present and changed (the statusline fires on every assistant message).
|
||||||
lastSig.set(sessionId, sig);
|
const telemetry = parseStatusTelemetry(payload);
|
||||||
// Bound the map across long multi-session runs: prune dead sessions.
|
if (telemetry) {
|
||||||
if (lastSig.size > 256) {
|
const sig = telemetrySignature(telemetry);
|
||||||
for (const id of [...lastSig.keys()]) {
|
if (lastSig.get(sessionId) !== sig) {
|
||||||
if (!ctx.sessions.has(id)) lastSig.delete(id);
|
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
@@ -212,7 +212,13 @@ export const StatusTelemetrySchema = z.object({
|
|||||||
seven_day: RateLimitWindowSchema,
|
seven_day: RateLimitWindowSchema,
|
||||||
})
|
})
|
||||||
.optional(),
|
.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(),
|
cost: z.object({ total_cost_usd: z.number().optional() }).optional(),
|
||||||
model: z.object({ display_name: z.string().max(100).optional() }).optional(),
|
model: z.object({ display_name: z.string().max(100).optional() }).optional(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1123,13 +1123,6 @@ export class WebServer extends EventEmitter {
|
|||||||
if (settings.showMultiMonitorButton === true) {
|
if (settings.showMultiMonitorButton === true) {
|
||||||
html = html.replace(' btn-multimonitor--hidden', '');
|
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
|
// Detached single-session ("solo") window: inject the target session id so
|
||||||
// the client can enter solo mode even if a (network-first) service worker
|
// 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
|
// later serves a cached shell. The client primarily detects solo mode from
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import {
|
import {
|
||||||
parseStatusTelemetry,
|
parseStatusTelemetry,
|
||||||
formatStatusLineText,
|
parseSessionStatus,
|
||||||
|
formatSessionStatusText,
|
||||||
telemetrySignature,
|
telemetrySignature,
|
||||||
type RawStatuslinePayload,
|
type RawStatuslinePayload,
|
||||||
} from '../src/usage-telemetry.js';
|
} from '../src/usage-telemetry.js';
|
||||||
@@ -13,7 +14,7 @@ const REAL: RawStatuslinePayload = {
|
|||||||
five_hour: { used_percentage: 15, resets_at: 1781409000 },
|
five_hour: { used_percentage: 15, resets_at: 1781409000 },
|
||||||
seven_day: { used_percentage: 34, resets_at: 1781827200 },
|
seven_day: { used_percentage: 34, resets_at: 1781827200 },
|
||||||
},
|
},
|
||||||
context_window: { used_percentage: 2 },
|
context_window: { used_percentage: 56, total_input_tokens: 562411, total_output_tokens: 1188 },
|
||||||
cost: { total_cost_usd: 0.0415495 },
|
cost: { total_cost_usd: 0.0415495 },
|
||||||
model: { display_name: 'Opus 4.8 (1M context)' },
|
model: { display_name: 'Opus 4.8 (1M context)' },
|
||||||
};
|
};
|
||||||
@@ -24,7 +25,7 @@ describe('parseStatusTelemetry', () => {
|
|||||||
expect(t).not.toBeNull();
|
expect(t).not.toBeNull();
|
||||||
expect(t!.fiveHour).toEqual({ usedPercentage: 15, resetAt: 1781409000 * 1000 });
|
expect(t!.fiveHour).toEqual({ usedPercentage: 15, resetAt: 1781409000 * 1000 });
|
||||||
expect(t!.sevenDay).toEqual({ usedPercentage: 34, resetAt: 1781827200 * 1000 });
|
expect(t!.sevenDay).toEqual({ usedPercentage: 34, resetAt: 1781827200 * 1000 });
|
||||||
expect(t!.contextUsedPercentage).toBe(2);
|
expect(t!.contextUsedPercentage).toBe(56);
|
||||||
expect(t!.costUsd).toBeCloseTo(0.0415495);
|
expect(t!.costUsd).toBeCloseTo(0.0415495);
|
||||||
expect(t!.modelDisplayName).toBe('Opus 4.8 (1M context)');
|
expect(t!.modelDisplayName).toBe('Opus 4.8 (1M context)');
|
||||||
});
|
});
|
||||||
@@ -69,13 +70,39 @@ describe('parseStatusTelemetry', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('formatStatusLineText', () => {
|
describe('parseSessionStatus', () => {
|
||||||
it('formats both windows compactly', () => {
|
it('extracts model, token totals, and context % for the footer', () => {
|
||||||
expect(formatStatusLineText(parseStatusTelemetry(REAL))).toBe('⟳ 5h 15% · 7d 34%');
|
const s = parseSessionStatus(REAL);
|
||||||
|
expect(s).toEqual({
|
||||||
|
modelDisplayName: 'Opus 4.8 (1M context)',
|
||||||
|
inputTokens: 562411,
|
||||||
|
outputTokens: 1188,
|
||||||
|
contextUsedPercentage: 56,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when none of the footer fields are present', () => {
|
||||||
|
expect(parseSessionStatus({})).toBeNull();
|
||||||
|
expect(parseSessionStatus(undefined)).toBeNull();
|
||||||
|
// rate_limits alone is not session status
|
||||||
|
expect(parseSessionStatus({ rate_limits: { five_hour: { used_percentage: 5, resets_at: 1 } } })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatSessionStatusText', () => {
|
||||||
|
it('formats the footer with comma-grouped tokens', () => {
|
||||||
|
expect(formatSessionStatusText(parseSessionStatus(REAL))).toBe(
|
||||||
|
'Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits groups that are missing', () => {
|
||||||
|
expect(formatSessionStatusText({ contextUsedPercentage: 12 })).toBe('ctx:12%');
|
||||||
|
expect(formatSessionStatusText({ modelDisplayName: 'Opus 4.8 (1M context)' })).toBe('Opus 4.8 (1M context)');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to a brand string when there is no data', () => {
|
it('falls back to a brand string when there is no data', () => {
|
||||||
expect(formatStatusLineText(null)).toBe('codeman');
|
expect(formatSessionStatusText(null)).toBe('codeman');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user