diff --git a/src/hooks-config.ts b/src/hooks-config.ts index 0a561946..d0a7d026 100644 --- a/src/hooks-config.ts +++ b/src/hooks-config.ts @@ -225,13 +225,17 @@ const STATUSLINE_MARKER = '/api/status-telemetry'; * (present in every managed session via tmux setenv), so the config is static. */ 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 ( `INPUT=$(cat 2>/dev/null || echo '{}'); ` + `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 "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); 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 }); - existing.statusLine = { type: 'command', command: generateStatusLineCommand() }; + existing.statusLine = { type: 'command', command: desired }; // add, or update an out-of-date ours } else { if (!isOurs) return; // nothing of ours to remove (leave a user's own statusLine alone) delete existing.statusLine; diff --git a/src/usage-telemetry.ts b/src/usage-telemetry.ts index cd4549da..88a013dd 100644 --- a/src/usage-telemetry.ts +++ b/src/usage-telemetry.ts @@ -46,7 +46,7 @@ export interface RawStatuslinePayload { five_hour?: { 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 }; model?: { display_name?: string }; } @@ -88,13 +88,61 @@ export function parseStatusTelemetry(data: RawStatuslinePayload | undefined): St return t; } -/** Compact in-terminal footer text (statusLine stdout / print-through). */ -export function formatStatusLineText(t: StatusTelemetry | null): string { - if (!t) return 'codeman'; - const parts: string[] = []; - if (t.fiveHour) parts.push(`5h ${Math.round(t.fiveHour.usedPercentage)}%`); - if (t.sevenDay) parts.push(`7d ${Math.round(t.sevenDay.usedPercentage)}%`); - return parts.length ? `⟳ ${parts.join(' · ')}` : 'codeman'; +/** + * Current-session status for the in-terminal statusline footer. This is the + * "status of the current session" the user sees in Claude's footer — distinct + * from the account-wide plan limits, which live ONLY in the Codeman header chip. + */ +export interface SessionStatus { + 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'; } /** diff --git a/src/web/public/app.js b/src/web/public/app.js index 65c288e4..af93e7ee 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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 + ? '' + : `${label}${p}%`; + chip.innerHTML = [seg('5h', five), seg('7d', seven)].filter(Boolean).join('·'); 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 diff --git a/src/web/public/styles.css b/src/web/public/styles.css index e211d145..87e50767 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -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 { diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 592f16a9..b52ae759 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -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); } diff --git a/src/web/routes/status-telemetry-routes.ts b/src/web/routes/status-telemetry-routes.ts index 743242dd..46673ebd 100644 --- a/src/web/routes/status-telemetry-routes.ts +++ b/src/web/routes/status-telemetry-routes.ts @@ -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)); }); } diff --git a/src/web/schemas.ts b/src/web/schemas.ts index adb78fe0..fb4ef27a 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -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(), }) diff --git a/src/web/server.ts b/src/web/server.ts index d4f9b1a6..a7ce1959 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -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 diff --git a/test/usage-telemetry.test.ts b/test/usage-telemetry.test.ts index 16836cba..185154a4 100644 --- a/test/usage-telemetry.test.ts +++ b/test/usage-telemetry.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect } from 'vitest'; import { parseStatusTelemetry, - formatStatusLineText, + parseSessionStatus, + formatSessionStatusText, telemetrySignature, type RawStatuslinePayload, } from '../src/usage-telemetry.js'; @@ -13,7 +14,7 @@ const REAL: RawStatuslinePayload = { five_hour: { used_percentage: 15, resets_at: 1781409000 }, 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 }, model: { display_name: 'Opus 4.8 (1M context)' }, }; @@ -24,7 +25,7 @@ describe('parseStatusTelemetry', () => { expect(t).not.toBeNull(); expect(t!.fiveHour).toEqual({ usedPercentage: 15, resetAt: 1781409000 * 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!.modelDisplayName).toBe('Opus 4.8 (1M context)'); }); @@ -69,13 +70,39 @@ describe('parseStatusTelemetry', () => { }); }); -describe('formatStatusLineText', () => { - it('formats both windows compactly', () => { - expect(formatStatusLineText(parseStatusTelemetry(REAL))).toBe('⟳ 5h 15% · 7d 34%'); +describe('parseSessionStatus', () => { + it('extracts model, token totals, and context % for the footer', () => { + 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', () => { - expect(formatStatusLineText(null)).toBe('codeman'); + expect(formatSessionStatusText(null)).toBe('codeman'); }); });