diff --git a/src/__tests__/fixtures/render-parity/s1-basic.stdout.txt b/src/__tests__/fixtures/render-parity/s1-basic.stdout.txt new file mode 100644 index 000000000..06994a44d --- /dev/null +++ b/src/__tests__/fixtures/render-parity/s1-basic.stdout.txt @@ -0,0 +1,3 @@ +Model: Sonnet 4.5 | Total: 14.0k +Ctx(u) Used: 0.4% | cwd: /tmp/ccs-parity-fixture/project +v2.1.0 | Term: 80 diff --git a/src/__tests__/fixtures/render-parity/s2-theme-truecolor.stdout.txt b/src/__tests__/fixtures/render-parity/s2-theme-truecolor.stdout.txt new file mode 100644 index 000000000..bf5ca4f59 --- /dev/null +++ b/src/__tests__/fixtures/render-parity/s2-theme-truecolor.stdout.txt @@ -0,0 +1,2 @@ +Model: Sonnet 4.5 | Ctx: 659 +THEME | Total: 14.0k diff --git a/src/__tests__/fixtures/render-parity/s2b-gradient-truecolor.stdout.txt b/src/__tests__/fixtures/render-parity/s2b-gradient-truecolor.stdout.txt new file mode 100644 index 000000000..be1a2f7f6 --- /dev/null +++ b/src/__tests__/fixtures/render-parity/s2b-gradient-truecolor.stdout.txt @@ -0,0 +1,2 @@ +Model: Sonnet 4.5 | Ctx: 659 +GRADIENT-LINE | Total: 14.0k diff --git a/src/__tests__/fixtures/render-parity/s3-ansi16-narrow.stdout.txt b/src/__tests__/fixtures/render-parity/s3-ansi16-narrow.stdout.txt new file mode 100644 index 000000000..416d2c172 --- /dev/null +++ b/src/__tests__/fixtures/render-parity/s3-ansi16-narrow.stdout.txt @@ -0,0 +1,2 @@ +Model: Sonnet 4.5dim-partCtx Used: 0.3% +BOX diff --git a/src/__tests__/fixtures/render-parity/s4-powerline.stdout.txt b/src/__tests__/fixtures/render-parity/s4-powerline.stdout.txt new file mode 100644 index 000000000..0118b6ae1 --- /dev/null +++ b/src/__tests__/fixtures/render-parity/s4-powerline.stdout.txt @@ -0,0 +1,2 @@ +Model: Sonnet 4.5                                       Ctx: 659                                      Total: 14.0k +PL|⎇ no git diff --git a/src/__tests__/fixtures/render-parity/s5-custom-command.stdout.txt b/src/__tests__/fixtures/render-parity/s5-custom-command.stdout.txt new file mode 100644 index 000000000..b88f6a243 --- /dev/null +++ b/src/__tests__/fixtures/render-parity/s5-custom-command.stdout.txt @@ -0,0 +1 @@ +parity-fixed-42 | Model: Sonnet 4.5 diff --git a/src/__tests__/fixtures/render-parity/s6-malformed-json.stdout.txt b/src/__tests__/fixtures/render-parity/s6-malformed-json.stdout.txt new file mode 100644 index 000000000..cdde1864b --- /dev/null +++ b/src/__tests__/fixtures/render-parity/s6-malformed-json.stdout.txt @@ -0,0 +1 @@ +⚠ invalid config | Model: Sonnet 4.5 | Ctx: 659 | ⎇ no git | (no git) diff --git a/src/__tests__/fixtures/render-parity/s7-updatemessage.stdout.txt b/src/__tests__/fixtures/render-parity/s7-updatemessage.stdout.txt new file mode 100644 index 000000000..d7b721e71 --- /dev/null +++ b/src/__tests__/fixtures/render-parity/s7-updatemessage.stdout.txt @@ -0,0 +1,2 @@ +Model: Sonnet 4.5 +UPDATE-MESSAGE-PARITY diff --git a/src/__tests__/fixtures/render-parity/transcript.jsonl b/src/__tests__/fixtures/render-parity/transcript.jsonl new file mode 100644 index 000000000..8d8397ea1 --- /dev/null +++ b/src/__tests__/fixtures/render-parity/transcript.jsonl @@ -0,0 +1,40 @@ +{"type":"user","timestamp":"2026-09-20T10:00:00.000Z","message":{"role":"user","content":[{"type":"text","text":"question 0"}]}} +{"type":"assistant","timestamp":"2026-09-20T10:01:30.000Z","message":{"role":"assistant","id":"msg_1","stop_reason":"end_turn","content":[{"type":"text","text":"answer 1"}],"usage":{"input_tokens":101,"output_tokens":41,"cache_read_input_tokens":500,"cache_creation_input_tokens":20}}} +{"type":"user","timestamp":"2026-09-20T10:02:00.000Z","message":{"role":"user","content":[{"type":"text","text":"question 2"}]}} +{"type":"assistant","timestamp":"2026-09-20T10:03:30.000Z","message":{"role":"assistant","id":"msg_3","stop_reason":"end_turn","content":[{"type":"text","text":"answer 3"}],"usage":{"input_tokens":103,"output_tokens":43,"cache_read_input_tokens":500,"cache_creation_input_tokens":20}}} +{"type":"user","timestamp":"2026-09-20T10:04:00.000Z","message":{"role":"user","content":[{"type":"text","text":"question 4"}]}} +{"type":"assistant","timestamp":"2026-09-20T10:05:30.000Z","message":{"role":"assistant","id":"msg_5","stop_reason":"end_turn","content":[{"type":"text","text":"answer 5"}],"usage":{"input_tokens":105,"output_tokens":45,"cache_read_input_tokens":500,"cache_creation_input_tokens":20}}} +{"type":"user","timestamp":"2026-09-20T10:06:00.000Z","message":{"role":"user","content":[{"type":"text","text":"question 6"}]}} +{"type":"assistant","timestamp":"2026-09-20T10:07:30.000Z","message":{"role":"assistant","id":"msg_7","stop_reason":"end_turn","content":[{"type":"text","text":"answer 7"}],"usage":{"input_tokens":107,"output_tokens":47,"cache_read_input_tokens":500,"cache_creation_input_tokens":20}}} +{"type":"user","timestamp":"2026-09-20T10:08:00.000Z","message":{"role":"user","content":[{"type":"text","text":"question 8"}]}} +{"type":"assistant","timestamp":"2026-09-20T10:09:30.000Z","message":{"role":"assistant","id":"msg_9","stop_reason":"end_turn","content":[{"type":"text","text":"answer 9"}],"usage":{"input_tokens":109,"output_tokens":49,"cache_read_input_tokens":500,"cache_creation_input_tokens":20}}} +{"type":"user","timestamp":"2026-09-20T10:10:00.000Z","message":{"role":"user","content":[{"type":"text","text":"question 10"}]}} +{"type":"assistant","timestamp":"2026-09-20T10:11:30.000Z","message":{"role":"assistant","id":"msg_11","stop_reason":"end_turn","content":[{"type":"text","text":"answer 11"}],"usage":{"input_tokens":111,"output_tokens":51,"cache_read_input_tokens":500,"cache_creation_input_tokens":20}}} +{"type":"user","timestamp":"2026-09-20T10:12:00.000Z","message":{"role":"user","content":[{"type":"text","text":"question 12"}]}} +{"type":"assistant","timestamp":"2026-09-20T10:13:30.000Z","message":{"role":"assistant","id":"msg_13","stop_reason":"end_turn","content":[{"type":"text","text":"answer 13"}],"usage":{"input_tokens":113,"output_tokens":53,"cache_read_input_tokens":500,"cache_creation_input_tokens":20}}} +{"type":"user","timestamp":"2026-09-20T10:14:00.000Z","message":{"role":"user","content":[{"type":"text","text":"question 14"}]}} +{"type":"assistant","timestamp":"2026-09-20T10:15:30.000Z","message":{"role":"assistant","id":"msg_15","stop_reason":"end_turn","content":[{"type":"text","text":"answer 15"}],"usage":{"input_tokens":115,"output_tokens":55,"cache_read_input_tokens":500,"cache_creation_input_tokens":20}}} +{"type":"user","timestamp":"2026-09-20T10:16:00.000Z","message":{"role":"user","content":[{"type":"text","text":"question 16"}]}} +{"type":"assistant","timestamp":"2026-09-20T10:17:30.000Z","message":{"role":"assistant","id":"msg_17","stop_reason":"end_turn","content":[{"type":"text","text":"answer 17"}],"usage":{"input_tokens":117,"output_tokens":57,"cache_read_input_tokens":500,"cache_creation_input_tokens":20}}} +{"type":"user","timestamp":"2026-09-20T10:18:00.000Z","message":{"role":"user","content":[{"type":"text","text":"question 18"}]}} +{"type":"assistant","timestamp":"2026-09-20T10:19:30.000Z","message":{"role":"assistant","id":"msg_19","stop_reason":"end_turn","content":[{"type":"text","text":"answer 19"}],"usage":{"input_tokens":119,"output_tokens":59,"cache_read_input_tokens":500,"cache_creation_input_tokens":20}}} +{"type":"user","timestamp":"2026-09-20T10:20:00.000Z","message":{"role":"user","content":[{"type":"text","text":"question 20"}]}} +{"type":"assistant","timestamp":"2026-09-20T10:21:30.000Z","message":{"role":"assistant","id":"msg_21","stop_reason":"end_turn","content":[{"type":"text","text":"answer 21"}],"usage":{"input_tokens":121,"output_tokens":61,"cache_read_input_tokens":500,"cache_creation_input_tokens":20}}} +{"type":"user","timestamp":"2026-09-20T10:22:00.000Z","message":{"role":"user","content":[{"type":"text","text":"question 22"}]}} +{"type":"assistant","timestamp":"2026-09-20T10:23:30.000Z","message":{"role":"assistant","id":"msg_23","stop_reason":"end_turn","content":[{"type":"text","text":"answer 23"}],"usage":{"input_tokens":123,"output_tokens":63,"cache_read_input_tokens":500,"cache_creation_input_tokens":20}}} +{"type":"user","timestamp":"2026-09-20T10:24:00.000Z","message":{"role":"user","content":[{"type":"text","text":"question 24"}]}} +{"type":"assistant","timestamp":"2026-09-20T10:25:30.000Z","message":{"role":"assistant","id":"msg_25","stop_reason":"end_turn","content":[{"type":"text","text":"answer 25"}],"usage":{"input_tokens":125,"output_tokens":65,"cache_read_input_tokens":500,"cache_creation_input_tokens":20}}} +{"type":"user","timestamp":"2026-09-20T10:26:00.000Z","message":{"role":"user","content":[{"type":"text","text":"question 26"}]}} +{"type":"assistant","timestamp":"2026-09-20T10:27:30.000Z","message":{"role":"assistant","id":"msg_27","stop_reason":"end_turn","content":[{"type":"text","text":"answer 27"}],"usage":{"input_tokens":127,"output_tokens":67,"cache_read_input_tokens":500,"cache_creation_input_tokens":20}}} +{"type":"user","timestamp":"2026-09-20T10:28:00.000Z","message":{"role":"user","content":[{"type":"text","text":"question 28"}]}} +{"type":"assistant","timestamp":"2026-09-20T10:29:30.000Z","message":{"role":"assistant","id":"msg_29","stop_reason":"end_turn","content":[{"type":"text","text":"answer 29"}],"usage":{"input_tokens":129,"output_tokens":69,"cache_read_input_tokens":500,"cache_creation_input_tokens":20}}} +{"type":"user","timestamp":"2026-09-20T10:30:00.000Z","message":{"role":"user","content":[{"type":"text","text":"question 30"}]}} +{"type":"assistant","timestamp":"2026-09-20T10:31:30.000Z","message":{"role":"assistant","id":"msg_31","stop_reason":"end_turn","content":[{"type":"text","text":"answer 31"}],"usage":{"input_tokens":131,"output_tokens":71,"cache_read_input_tokens":500,"cache_creation_input_tokens":20}}} +{"type":"user","timestamp":"2026-09-20T10:32:00.000Z","message":{"role":"user","content":[{"type":"text","text":"question 32"}]}} +{"type":"assistant","timestamp":"2026-09-20T10:33:30.000Z","message":{"role":"assistant","id":"msg_33","stop_reason":"end_turn","content":[{"type":"text","text":"answer 33"}],"usage":{"input_tokens":133,"output_tokens":73,"cache_read_input_tokens":500,"cache_creation_input_tokens":20}}} +{"type":"user","timestamp":"2026-09-20T10:34:00.000Z","message":{"role":"user","content":[{"type":"text","text":"question 34"}]}} +{"type":"assistant","timestamp":"2026-09-20T10:35:30.000Z","message":{"role":"assistant","id":"msg_35","stop_reason":"end_turn","content":[{"type":"text","text":"answer 35"}],"usage":{"input_tokens":135,"output_tokens":75,"cache_read_input_tokens":500,"cache_creation_input_tokens":20}}} +{"type":"user","timestamp":"2026-09-20T10:36:00.000Z","message":{"role":"user","content":[{"type":"text","text":"question 36"}]}} +{"type":"assistant","timestamp":"2026-09-20T10:37:30.000Z","message":{"role":"assistant","id":"msg_37","stop_reason":"end_turn","content":[{"type":"text","text":"answer 37"}],"usage":{"input_tokens":137,"output_tokens":77,"cache_read_input_tokens":500,"cache_creation_input_tokens":20}}} +{"type":"user","timestamp":"2026-09-20T10:38:00.000Z","message":{"role":"user","content":[{"type":"text","text":"question 38"}]}} +{"type":"assistant","timestamp":"2026-09-20T10:39:30.000Z","message":{"role":"assistant","id":"msg_39","stop_reason":"end_turn","content":[{"type":"text","text":"answer 39"}],"usage":{"input_tokens":139,"output_tokens":79,"cache_read_input_tokens":500,"cache_creation_input_tokens":20}}} diff --git a/src/__tests__/render-parity.test.ts b/src/__tests__/render-parity.test.ts new file mode 100644 index 000000000..af7889c66 --- /dev/null +++ b/src/__tests__/render-parity.test.ts @@ -0,0 +1,237 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, + type MockInstance +} from 'vitest'; + +import { renderStatusLines } from '../render'; +import type { RenderInvocation } from '../types/RenderContext'; +import type { StatusJSON } from '../types/StatusJSON'; +import type { LoadedSettings } from '../utils/config'; +import { + getConfigLoadError, + loadSettingsFrom +} from '../utils/config'; + +// Byte parity for the #15 request-scoped render extraction: the golden bytes +// below were captured from the pre-refactor one-shot CLI (bun +// src/ccstatusline.ts < payload) with fixed fixtures, fixed env and a fixed +// terminal width. renderStatusLines must reproduce them byte-for-byte, since +// the entry point prints `text` with a single trailing newline. + +const FIXTURES_DIR = path.join(__dirname, 'fixtures', 'render-parity'); +const TRANSCRIPT_PATH = path.join(FIXTURES_DIR, 'transcript.jsonl'); + +// Exists and is not a git repository: git widgets fall back to "no git" and +// custom commands still inherit a valid working directory. +const NON_GIT_CWD = '/private/tmp'; +// The cwd string carried in the golden payload (widgets print it verbatim). +const PAYLOAD_CWD = '/tmp/ccs-parity-fixture/project'; + +function payloadFor(): StatusJSON { + return { + model: { id: 'claude-sonnet-4-5', display_name: 'Sonnet 4.5' }, + session_id: 'parity-session-15', + transcript_path: TRANSCRIPT_PATH, + cwd: PAYLOAD_CWD, + workspace: { current_dir: PAYLOAD_CWD }, + cost: { + total_cost_usd: 1.25, + total_duration_ms: 60000, + total_api_duration_ms: 30000, + total_lines_added: 10, + total_lines_removed: 2 + }, + version: '2.1.0', + output_style: { name: 'default' } + }; +} + +const SCENARIOS: { + name: string; + colorLevel: number; + width: number; + extraSettings?: Record; + lines: { id: string; type: string; color?: string; customText?: string; bold?: boolean; dim?: boolean | 'parens'; merge?: boolean | 'no-padding'; backgroundColor?: string; commandPath?: string }[][]; +}[] = [ + { + name: 's1-basic', + colorLevel: 2, + width: 80, + lines: [ + [{ id: '1', type: 'model', color: 'cyan' }, { id: '2', type: 'separator' }, { id: '3', type: 'tokens-total', color: 'brightBlack' }], + [{ id: '4', type: 'context-percentage-usable', color: 'green' }, { id: '5', type: 'separator' }, { id: '6', type: 'current-working-dir', color: 'blue' }], + [{ id: '7', type: 'version' }, { id: '8', type: 'separator' }, { id: '9', type: 'terminal-width' }] + ] + }, + { + name: 's2-theme-truecolor', + colorLevel: 3, + width: 100, + extraSettings: { powerline: { enabled: false, theme: 'dracula', continueThemeAcrossLines: true } }, + lines: [ + [{ id: '1', type: 'model' }, { id: '2', type: 'separator' }, { id: '3', type: 'context-length' }], + [{ id: '4', type: 'custom-text', customText: 'THEME', color: 'red', bold: true }, { id: '5', type: 'separator' }, { id: '6', type: 'tokens-total' }] + ] + }, + { + name: 's2b-gradient-truecolor', + colorLevel: 3, + width: 100, + extraSettings: { overrideForegroundColor: 'gradient:hex:FF0000,hex:00FF00' }, + lines: [ + [{ id: '1', type: 'model' }, { id: '2', type: 'separator' }, { id: '3', type: 'context-length' }], + [{ id: '4', type: 'custom-text', customText: 'GRADIENT-LINE' }, { id: '5', type: 'separator' }, { id: '6', type: 'tokens-total' }] + ] + }, + { + name: 's3-ansi16-narrow', + colorLevel: 1, + width: 60, + lines: [ + [{ id: '1', type: 'model', bold: true, color: 'red' }, { id: '2', type: 'custom-text', customText: 'dim-part', dim: true, merge: 'no-padding' }, { id: '3', type: 'context-percentage', dim: 'parens' }], + [{ id: '4', type: 'custom-text', customText: 'BOX', color: 'white', backgroundColor: 'bgBlue' }] + ] + }, + { + name: 's4-powerline', + colorLevel: 2, + width: 120, + extraSettings: { powerline: { enabled: true, theme: 'nord', continueThemeAcrossLines: true } }, + lines: [ + [{ id: '1', type: 'model' }, { id: '2', type: 'flex-separator' }, { id: '3', type: 'context-length' }, { id: '4', type: 'flex-separator' }, { id: '5', type: 'tokens-total' }], + [{ id: '6', type: 'custom-text', customText: 'PL' }, { id: '7', type: 'separator' }, { id: '8', type: 'git-branch' }] + ] + }, + { + name: 's5-custom-command', + colorLevel: 2, + width: 90, + lines: [ + [{ id: '1', type: 'custom-command', commandPath: 'echo parity-fixed-42', color: 'green' }, { id: '2', type: 'separator' }, { id: '3', type: 'model' }] + ] + } +]; + +async function renderWithSettings( + configPath: string, + settingsJson: Record, + width: number +): Promise<{ text: string; loaded: LoadedSettings }> { + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, JSON.stringify(settingsJson), 'utf-8'); + const loaded = await loadSettingsFrom(configPath); + const invocation: RenderInvocation = { + configPath, + cwd: NON_GIT_CWD, + env: {}, + terminalWidth: width + }; + const { text } = await renderStatusLines(payloadFor(), loaded, invocation); + return { text: text + '\n', loaded }; +} + +describe('render parity vs pre-refactor CLI golden bytes', () => { + let tmpDir: string; + let cwdSpy: MockInstance<() => string>; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-parity-')); + cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(NON_GIT_CWD); + }); + + afterEach(() => { + cwdSpy.mockRestore(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it.each(SCENARIOS.map(s => [s.name, s]))( + '%s renders byte-identical to the one-shot CLI', + async (_name, scenario) => { + const configPath = path.join(tmpDir, `${scenario.name}.json`); + const { text } = await renderWithSettings(configPath, { + version: 3, + colorLevel: scenario.colorLevel, + lines: scenario.lines, + ...scenario.extraSettings + }, scenario.width); + const golden = fs.readFileSync(path.join(FIXTURES_DIR, `${scenario.name}.stdout.txt`), 'utf-8'); + expect(text).toBe(golden); + } + ); + + it('malformed config keeps the warning badge and default layout byte-identical', async () => { + const configPath = path.join(tmpDir, 's6-malformed-json.json'); + fs.mkdirSync(tmpDir, { recursive: true }); + fs.writeFileSync(configPath, '{"version": 3, "lines": [BROKEN', 'utf-8'); + const loaded = await loadSettingsFrom(configPath); + const { text } = await renderStatusLines(payloadFor(), loaded, { + configPath, + cwd: NON_GIT_CWD, + env: {}, + terminalWidth: 80 + }); + const golden = fs.readFileSync(path.join(FIXTURES_DIR, 's6-malformed-json.stdout.txt'), 'utf-8'); + expect(text + '\n').toBe(golden); + }); + + it('updatemessage prints the message and persists the decremented count', async () => { + const configPath = path.join(tmpDir, 's7-updatemessage.json'); + const { text } = await renderWithSettings(configPath, { + version: 3, + colorLevel: 2, + updatemessage: { message: 'UPDATE-MESSAGE-PARITY', remaining: 5 }, + lines: [ + [{ id: '1', type: 'model', color: 'cyan' }], + [{ id: '2', type: 'separator' }], + [] + ] + }, 80); + const golden = fs.readFileSync(path.join(FIXTURES_DIR, 's7-updatemessage.stdout.txt'), 'utf-8'); + expect(text).toBe(golden); + + // The config write stayed inside this request's configPath and the + // remaining count was decremented atomically. + const saved = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as { updatemessage?: { remaining?: number } }; + expect(saved.updatemessage?.remaining).toBe(4); + }); + + it('emits no stdout and returns the text instead', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const configPath = path.join(tmpDir, 'no-stdout.json'); + const { text } = await renderWithSettings(configPath, { + version: 3, + colorLevel: 2, + lines: [[{ id: '1', type: 'model', color: 'cyan' }]] + }, 80); + expect(logSpy).not.toHaveBeenCalled(); + expect(text.length).toBeGreaterThan(0); + logSpy.mockRestore(); + }); + + it('keeps per-request config loads isolated from module state', async () => { + const good = path.join(tmpDir, 'good.json'); + const bad = path.join(tmpDir, 'bad.json'); + fs.writeFileSync(bad, 'not json at all', 'utf-8'); + fs.writeFileSync(good, JSON.stringify({ + version: 3, + colorLevel: 2, + lines: [[{ id: '1', type: 'model', color: 'cyan' }]] + }), 'utf-8'); + + const goodLoaded = await loadSettingsFrom(good); + const badLoaded = await loadSettingsFrom(bad); + + // Each load reports its own outcome without touching module state. + expect(goodLoaded.loadError).toBeNull(); + expect(badLoaded.loadError).toBe('settings.json is not valid JSON'); + expect(getConfigLoadError()).toBeNull(); + }); +}); diff --git a/src/ccstatusline.ts b/src/ccstatusline.ts index f6570b766..da7ff85cb 100644 --- a/src/ccstatusline.ts +++ b/src/ccstatusline.ts @@ -1,22 +1,16 @@ #!/usr/bin/env node -import chalk from 'chalk'; - -import type { SkillsMetrics } from './types'; -import type { RenderContext } from './types/RenderContext'; +import { renderStatusLines } from './render'; import type { StatusJSON } from './types/StatusJSON'; import { StatusJSONSchema } from './types/StatusJSON'; -import { getVisibleText } from './utils/ansi'; -import { prefetchClaudeStatusIfNeeded } from './utils/claude-service-status'; import { isCliMode, runCli } from './utils/cli'; -import { updateColorMap } from './utils/colors'; -import { ZERO_COMPACTION_STATS } from './utils/compaction'; import { - getConfigLoadError, + getConfigPath, initConfigPath, loadSettings, + loadSettingsFrom, saveSettings } from './utils/config'; import { @@ -24,31 +18,10 @@ import { refreshGitReviewCacheFromCli } from './utils/git-review-cache'; import { handleHookInput } from './utils/hook-handler'; -import { getTranscriptAnalysis } from './utils/jsonl'; -import { advanceGlobalPowerlineThemeIndex } from './utils/powerline-theme-index'; -import { - buildConfigWarningBadge, - calculateMaxWidthsFromPreRendered, - countPowerlineStartCapSlots, - preRenderAllWidgets, - renderStatusLine -} from './utils/renderer'; -import { advanceGlobalSeparatorIndex } from './utils/separator-index'; -import { getSkillsMetrics } from './utils/skills'; -import { - getWidgetSpeedWindowSeconds, - isWidgetSpeedWindowEnabled -} from './utils/speed-window'; import { getPackageVersion, getTerminalWidth } from './utils/terminal'; -import { prefetchUsageDataIfNeeded } from './utils/usage-prefetch'; - -function hasSessionDurationInStatusJson(data: StatusJSON): boolean { - const durationMs = data.cost?.total_duration_ms; - return typeof durationMs === 'number' && Number.isFinite(durationMs) && durationMs >= 0; -} async function readStdin(): Promise { // Check if stdin is a TTY (terminal) - if it is, there's no piped data @@ -92,180 +65,30 @@ async function ensureWindowsUtf8CodePage() { } } -async function renderMultipleLines(data: StatusJSON) { - const settings = await loadSettings(); - const configError = getConfigLoadError(); - - // Set global chalk level based on settings - chalk.level = settings.colorLevel; - - // Update color map after setting chalk level - updateColorMap(); - - // Get all lines to render - const lines = settings.lines; - - // Check if session clock is needed - const hasSessionClock = lines.some(line => line.some(item => item.type === 'session-clock')); - - const speedWidgetTypes = new Set(['output-speed', 'input-speed', 'total-speed']); - const hasSpeedItems = lines.some(line => line.some(item => speedWidgetTypes.has(item.type))); - const hasCompactionWidget = lines.some(line => line.some(item => item.type === 'compaction-counter')); - const hasThinkingEffortWidget = lines.some(line => line.some(item => item.type === 'thinking-effort')); - const hasSessionNameWidget = lines.some(line => line.some(item => item.type === 'session-name')); - const hasLastTurnTokensWidget = lines.some(line => line.some(item => item.type === 'tokens-last-turn')); - const needsTranscriptThinkingEffort = hasThinkingEffortWidget - && (!data.effort || !('level' in data.effort)); - const requestedSpeedWindows = new Set(); - for (const line of lines) { - for (const item of line) { - if (speedWidgetTypes.has(item.type) && isWidgetSpeedWindowEnabled(item)) { - requestedSpeedWindows.add(getWidgetSpeedWindowSeconds(item)); - } - } - } - - const transcriptAnalysisPromise = data.transcript_path - ? getTranscriptAnalysis(data.transcript_path, { - includeSessionDuration: hasSessionClock && !hasSessionDurationInStatusJson(data), - includeSpeedMetrics: hasSpeedItems, - includeSubagents: true, - speedWindowSeconds: Array.from(requestedSpeedWindows), - includeCompactionStats: hasCompactionWidget, - includeThinkingEffort: needsTranscriptThinkingEffort, - includeSessionName: hasSessionNameWidget, - includeLastTurnTokens: hasLastTurnTokensWidget - }) - : Promise.resolve(null); - const [transcriptAnalysis, usageData, claudeStatusData] = await Promise.all([ - transcriptAnalysisPromise, - prefetchUsageDataIfNeeded(lines, data), - prefetchClaudeStatusIfNeeded(lines) - ]); - - const tokenMetrics = transcriptAnalysis?.tokenMetrics ?? null; - const sessionDuration = transcriptAnalysis?.sessionDuration ?? null; - const speedMetrics = transcriptAnalysis?.speedMetricsCollection?.sessionAverage ?? null; - const windowedSpeedMetrics = transcriptAnalysis?.speedMetricsCollection?.windowed ?? null; - - let skillsMetrics: SkillsMetrics | null = null; - if (data.session_id) { - skillsMetrics = getSkillsMetrics(data.session_id); - } - - const compactionData = hasCompactionWidget - ? (transcriptAnalysis?.compactionData ?? ZERO_COMPACTION_STATS) - : null; - - // Create render context - const context: RenderContext = { - data, - tokenMetrics, - speedMetrics, - windowedSpeedMetrics, - usageData, - claudeStatusData, - sessionDuration, - transcriptSessionName: hasSessionNameWidget - ? (transcriptAnalysis?.sessionName ?? null) - : undefined, - transcriptThinkingEffort: needsTranscriptThinkingEffort - ? (transcriptAnalysis?.thinkingEffort ?? null) - : undefined, - skillsMetrics, - compactionData, - terminalWidth: getTerminalWidth({ - sessionId: data.session_id, - ttlSeconds: settings.terminalWidthCacheTtlSeconds - }), - isPreview: false, - minimalist: settings.minimalistMode, - gitCacheTtlSeconds: settings.gitCacheTtlSeconds, - customCommandCacheTtlSeconds: settings.customCommandCacheTtlSeconds, - gitReviewNeedsChecks: lines.some(line => line.some(item => item.type === 'git-ci-status')) +// Snapshot everything the render needs from this process (#15): one invocation +// context per request, so the shared-process runner can vary these per request. +function createInvocationFromProcess(terminalWidth: number | null) { + return { + configPath: getConfigPath(), + cwd: process.cwd(), + env: { ...process.env }, + terminalWidth }; +} - // Always pre-render all widgets once (for efficiency) - const preRenderedLines = preRenderAllWidgets(lines, settings, context); - const preCalculatedMaxWidths = calculateMaxWidthsFromPreRendered(preRenderedLines, settings); - - // Render each line using pre-rendered content - let globalSeparatorIndex = 0; - let globalPowerlineThemeIndex = 0; - let globalPowerlineStartCapIndex = 0; - let configBadgePrepended = false; - for (let i = 0; i < lines.length; i++) { - const lineItems = lines[i]; - if (lineItems && lineItems.length > 0) { - const preRenderedWidgets = preRenderedLines[i] ?? []; - const lineContext = { - ...context, - lineIndex: i, - globalSeparatorIndex, - globalPowerlineThemeIndex, - globalPowerlineStartCapIndex - }; - let line = renderStatusLine(lineItems, settings, lineContext, preRenderedWidgets, preCalculatedMaxWidths); - - // Only output the line if it has content (not just ANSI codes) - // Strip ANSI codes to check if there's actual text - const strippedLine = getVisibleText(line).trim(); - if (strippedLine.length > 0) { - if (configError && !configBadgePrepended) { - // On the error path settings are always inMemoryDefaults(), whose separators render as ' | '. - line = `${buildConfigWarningBadge(settings.colorLevel)} | ${line}`; - configBadgePrepended = true; - } - - // Replace all spaces with non-breaking spaces to prevent VSCode trimming - let outputLine = line.replace(/ /g, '\u00A0'); - - // Add reset code at the beginning to override Claude Code's dim setting - outputLine = '\x1b[0m' + outputLine; - console.log(outputLine); - - globalSeparatorIndex = advanceGlobalSeparatorIndex(globalSeparatorIndex, lineItems, preRenderedWidgets); - if (settings.powerline.enabled) { - globalPowerlineStartCapIndex += countPowerlineStartCapSlots(lineItems, preRenderedWidgets); - } - if (settings.powerline.enabled && settings.powerline.continueThemeAcrossLines) { - globalPowerlineThemeIndex = advanceGlobalPowerlineThemeIndex(globalPowerlineThemeIndex, preRenderedWidgets); - } - } - } - } - - // Defensive fallback: if no content line was emitted, ensure the warning is not lost - if (configError && !configBadgePrepended) { - console.log('\x1b[0m' + buildConfigWarningBadge(settings.colorLevel).replace(/ /g, '\u00A0')); - } - - // Check if there's an update message to display - if (settings.updatemessage?.message - && settings.updatemessage.message.trim() !== '' - && settings.updatemessage.remaining - && settings.updatemessage.remaining > 0) { - // Display the message - console.log(settings.updatemessage.message); - - // Decrement the remaining count - const newRemaining = settings.updatemessage.remaining - 1; - - // Update or remove the updatemessage - if (newRemaining <= 0) { - // Remove the entire updatemessage block - const { updatemessage, ...newSettings } = settings; - await saveSettings(newSettings); - } else { - // Update the remaining count - await saveSettings({ - ...settings, - updatemessage: { - ...settings.updatemessage, - remaining: newRemaining - } - }); - } +async function renderMultipleLines(data: StatusJSON) { + const loaded = await loadSettingsFrom(getConfigPath()); + + // Terminal width is resolved once per request from process state (memoized + // probe + session L2 cache) and passed into the render explicitly. + const terminalWidth = getTerminalWidth({ + sessionId: data.session_id, + ttlSeconds: loaded.settings.terminalWidthCacheTtlSeconds + }); + + const { text } = await renderStatusLines(data, loaded, createInvocationFromProcess(terminalWidth)); + if (text !== '') { + console.log(text); } } diff --git a/src/render.ts b/src/render.ts new file mode 100644 index 000000000..a4fa673b1 --- /dev/null +++ b/src/render.ts @@ -0,0 +1,239 @@ +import chalk from 'chalk'; + +import type { SkillsMetrics } from './types'; +import type { + RenderContext, + RenderInvocation +} from './types/RenderContext'; +import type { Settings } from './types/Settings'; +import type { StatusJSON } from './types/StatusJSON'; +import { getVisibleText } from './utils/ansi'; +import { prefetchClaudeStatusIfNeeded } from './utils/claude-service-status'; +import { updateColorMap } from './utils/colors'; +import { ZERO_COMPACTION_STATS } from './utils/compaction'; +import type { LoadedSettings } from './utils/config'; +import { saveSettingsTo } from './utils/config'; +import { getTranscriptAnalysis } from './utils/jsonl'; +import { advanceGlobalPowerlineThemeIndex } from './utils/powerline-theme-index'; +import { + buildConfigWarningBadge, + calculateMaxWidthsFromPreRendered, + countPowerlineStartCapSlots, + preRenderAllWidgets, + renderStatusLine +} from './utils/renderer'; +import { advanceGlobalSeparatorIndex } from './utils/separator-index'; +import { getSkillsMetrics } from './utils/skills'; +import { + getWidgetSpeedWindowSeconds, + isWidgetSpeedWindowEnabled +} from './utils/speed-window'; +import { prefetchUsageDataIfNeeded } from './utils/usage-prefetch'; + +export interface RenderedStatusLines { + /** All output lines joined with '\n', without a trailing newline; '' if nothing to print. */ + text: string; + settings: Settings; + loadError: string | null; +} + +function hasSessionDurationInStatusJson(data: StatusJSON): boolean { + const durationMs = data.cost?.total_duration_ms; + return typeof durationMs === 'number' && Number.isFinite(durationMs) && durationMs >= 0; +} + +/** + * Request-scoped render orchestration (#15): given validated input, the + * config loaded for this request, and an explicit invocation snapshot, returns + * the status line text. No stdout, no process exit, no process-global reads — + * the entry point owns those. + * + * The section after the last await (chalk setup through pre-render and line + * formatting) is fully synchronous, so per-request styling state — the one + * remaining process-global (chalk.level/COLOR_MAP) — is serialized there and + * cannot interleave with a concurrent request. + */ +export async function renderStatusLines( + data: StatusJSON, + loaded: LoadedSettings, + invocation: RenderInvocation +): Promise { + const { settings, loadError: configError } = loaded; + + // Get all lines to render + const lines = settings.lines; + + // Check if session clock is needed + const hasSessionClock = lines.some(line => line.some(item => item.type === 'session-clock')); + + const speedWidgetTypes = new Set(['output-speed', 'input-speed', 'total-speed']); + const hasSpeedItems = lines.some(line => line.some(item => speedWidgetTypes.has(item.type))); + const hasCompactionWidget = lines.some(line => line.some(item => item.type === 'compaction-counter')); + const hasThinkingEffortWidget = lines.some(line => line.some(item => item.type === 'thinking-effort')); + const hasSessionNameWidget = lines.some(line => line.some(item => item.type === 'session-name')); + const hasLastTurnTokensWidget = lines.some(line => line.some(item => item.type === 'tokens-last-turn')); + const needsTranscriptThinkingEffort = hasThinkingEffortWidget + && (!data.effort || !('level' in data.effort)); + const requestedSpeedWindows = new Set(); + for (const line of lines) { + for (const item of line) { + if (speedWidgetTypes.has(item.type) && isWidgetSpeedWindowEnabled(item)) { + requestedSpeedWindows.add(getWidgetSpeedWindowSeconds(item)); + } + } + } + + const transcriptAnalysisPromise = data.transcript_path + ? getTranscriptAnalysis(data.transcript_path, { + includeSessionDuration: hasSessionClock && !hasSessionDurationInStatusJson(data), + includeSpeedMetrics: hasSpeedItems, + includeSubagents: true, + speedWindowSeconds: Array.from(requestedSpeedWindows), + includeCompactionStats: hasCompactionWidget, + includeThinkingEffort: needsTranscriptThinkingEffort, + includeSessionName: hasSessionNameWidget, + includeLastTurnTokens: hasLastTurnTokensWidget + }) + : Promise.resolve(null); + const [transcriptAnalysis, usageData, claudeStatusData] = await Promise.all([ + transcriptAnalysisPromise, + prefetchUsageDataIfNeeded(lines, data), + prefetchClaudeStatusIfNeeded(lines) + ]); + + // --- Start of the synchronous formatting section (chalk setup through + // pre-render and line formatting; the only awaits below it are the + // updatemessage config writes after formatting is done). --- + // Set global chalk level based on settings, then rebuild the color map. + // Kept at the top of the sync section (rather than before the awaits) so + // concurrent requests (#15) serialize on styling state for the whole + // formatting pass. + chalk.level = settings.colorLevel; + updateColorMap(); + + const tokenMetrics = transcriptAnalysis?.tokenMetrics ?? null; + const sessionDuration = transcriptAnalysis?.sessionDuration ?? null; + const speedMetrics = transcriptAnalysis?.speedMetricsCollection?.sessionAverage ?? null; + const windowedSpeedMetrics = transcriptAnalysis?.speedMetricsCollection?.windowed ?? null; + + let skillsMetrics: SkillsMetrics | null = null; + if (data.session_id) { + skillsMetrics = getSkillsMetrics(data.session_id); + } + + const compactionData = hasCompactionWidget + ? (transcriptAnalysis?.compactionData ?? ZERO_COMPACTION_STATS) + : null; + + // Create render context + const context: RenderContext = { + data, + tokenMetrics, + speedMetrics, + windowedSpeedMetrics, + usageData, + claudeStatusData, + sessionDuration, + transcriptSessionName: hasSessionNameWidget + ? (transcriptAnalysis?.sessionName ?? null) + : undefined, + transcriptThinkingEffort: needsTranscriptThinkingEffort + ? (transcriptAnalysis?.thinkingEffort ?? null) + : undefined, + skillsMetrics, + compactionData, + terminalWidth: invocation.terminalWidth, + isPreview: false, + minimalist: settings.minimalistMode, + gitCacheTtlSeconds: settings.gitCacheTtlSeconds, + customCommandCacheTtlSeconds: settings.customCommandCacheTtlSeconds, + gitReviewNeedsChecks: lines.some(line => line.some(item => item.type === 'git-ci-status')) + }; + + const outputLines: string[] = []; + + // Always pre-render all widgets once (for efficiency) + const preRenderedLines = preRenderAllWidgets(lines, settings, context); + const preCalculatedMaxWidths = calculateMaxWidthsFromPreRendered(preRenderedLines, settings); + + // Render each line using pre-rendered content + let globalSeparatorIndex = 0; + let globalPowerlineThemeIndex = 0; + let globalPowerlineStartCapIndex = 0; + let configBadgePrepended = false; + for (let i = 0; i < lines.length; i++) { + const lineItems = lines[i]; + if (lineItems && lineItems.length > 0) { + const preRenderedWidgets = preRenderedLines[i] ?? []; + const lineContext = { + ...context, + lineIndex: i, + globalSeparatorIndex, + globalPowerlineThemeIndex, + globalPowerlineStartCapIndex + }; + let line = renderStatusLine(lineItems, settings, lineContext, preRenderedWidgets, preCalculatedMaxWidths); + + // Only output the line if it has content (not just ANSI codes) + // Strip ANSI codes to check if there's actual text + const strippedLine = getVisibleText(line).trim(); + if (strippedLine.length > 0) { + if (configError && !configBadgePrepended) { + // On the error path settings are always inMemoryDefaults(), whose separators render as ' | '. + line = `${buildConfigWarningBadge(settings.colorLevel)} | ${line}`; + configBadgePrepended = true; + } + + // Replace all spaces with non-breaking spaces to prevent VSCode trimming + let outputLine = line.replace(/ /g, '\u00A0'); + + // Add reset code at the beginning to override Claude Code's dim setting + outputLine = '\x1b[0m' + outputLine; + outputLines.push(outputLine); + + globalSeparatorIndex = advanceGlobalSeparatorIndex(globalSeparatorIndex, lineItems, preRenderedWidgets); + if (settings.powerline.enabled) { + globalPowerlineStartCapIndex += countPowerlineStartCapSlots(lineItems, preRenderedWidgets); + } + if (settings.powerline.enabled && settings.powerline.continueThemeAcrossLines) { + globalPowerlineThemeIndex = advanceGlobalPowerlineThemeIndex(globalPowerlineThemeIndex, preRenderedWidgets); + } + } + } + } + + // Defensive fallback: if no content line was emitted, ensure the warning is not lost + if (configError && !configBadgePrepended) { + outputLines.push('\x1b[0m' + buildConfigWarningBadge(settings.colorLevel).replace(/ /g, '\u00A0')); + } + + // Check if there's an update message to display + if (settings.updatemessage?.message + && settings.updatemessage.message.trim() !== '' + && settings.updatemessage.remaining + && settings.updatemessage.remaining > 0) { + // Display the message + outputLines.push(settings.updatemessage.message); + + // Decrement the remaining count + const newRemaining = settings.updatemessage.remaining - 1; + + // Update or remove the updatemessage + if (newRemaining <= 0) { + // Remove the entire updatemessage block + const { updatemessage, ...newSettings } = settings; + await saveSettingsTo(invocation.configPath, newSettings); + } else { + // Update the remaining count + await saveSettingsTo(invocation.configPath, { + ...settings, + updatemessage: { + ...settings.updatemessage, + remaining: newRemaining + } + }); + } + } + + return { text: outputLines.join('\n'), settings, loadError: configError }; +} diff --git a/src/types/RenderContext.ts b/src/types/RenderContext.ts index 9e0a0606f..94ff909d7 100644 --- a/src/types/RenderContext.ts +++ b/src/types/RenderContext.ts @@ -42,6 +42,23 @@ export interface CompactionData { tokensReclaimed: number; } +/** + * Everything a render needs from the surrounding process, resolved once per + * request by the entry point. Render-path code reads process state through + * this snapshot instead of touching process/env directly, so a future + * shared-process runner (#14) can serve concurrent requests (#15). + */ +export interface RenderInvocation { + /** Resolved settings.json path for this request. */ + configPath: string; + /** Working directory snapshot for this request. */ + cwd: string; + /** Environment snapshot for this request. */ + env: NodeJS.ProcessEnv; + /** Terminal width probed once per request (null = no TTY found). */ + terminalWidth: number | null; +} + export interface RenderContext { data?: StatusJSON; tokenMetrics?: TokenMetrics | null; diff --git a/src/utils/__tests__/usage-fetch.test.ts b/src/utils/__tests__/usage-fetch.test.ts index 22b1794ee..2df2b0767 100644 --- a/src/utils/__tests__/usage-fetch.test.ts +++ b/src/utils/__tests__/usage-fetch.test.ts @@ -140,7 +140,7 @@ https.request = (...args) => { return request; }; -const { fetchUsageData } = await import(${JSON.stringify(usageModulePath)}); +const { fetchUsageData, createUsageMemoryCache } = await import(${JSON.stringify(usageModulePath)}); const lockFile = path.join(os.homedir(), '.cache', 'ccstatusline', 'usage.lock'); const cacheFile = path.join(os.homedir(), '.cache', 'ccstatusline', 'usage.json'); @@ -148,8 +148,11 @@ const nowMs = Number(process.env.TEST_NOW_MS || Date.now()); const requiredFields = JSON.parse(process.env.TEST_REQUIRED_FIELDS_JSON || '[]'); Date.now = () => nowMs; -const first = await fetchUsageData({ requiredFields }); -const second = await fetchUsageData({ requiredFields }); +// One memory cache per probe run, matching the request-scoped contract (#15): +// repeated calls within a single request reuse it; a new request starts fresh. +const probeCache = createUsageMemoryCache(); +const first = await fetchUsageData({ requiredFields, cache: probeCache }); +const second = await fetchUsageData({ requiredFields, cache: probeCache }); process.stdout.write(JSON.stringify({ first, second, diff --git a/src/utils/__tests__/usage-prefetch.test.ts b/src/utils/__tests__/usage-prefetch.test.ts index 50c81a56c..9091ada6f 100644 --- a/src/utils/__tests__/usage-prefetch.test.ts +++ b/src/utils/__tests__/usage-prefetch.test.ts @@ -24,6 +24,12 @@ function epochToIso(epochSeconds: number): string { return new Date(epochSeconds * 1000).toISOString(); } +// expect.any() is typed `any`, which trips no-unsafe-assignment inside object +// literals; widening to unknown keeps the same matcher lint-clean. +function anyCacheMatcher(): unknown { + return expect.any(Object); +} + describe('usage prefetch', () => { let mockFetchUsageData: { mock: { calls: unknown[][] }; @@ -240,7 +246,7 @@ describe('usage prefetch', () => { expect(usageData?.fableUsage).toBe(5); expect(mockFetchUsageData.mock.calls.length).toBe(1); - expect(mockFetchUsageData.mock.calls[0]).toEqual([{ requiredFields: ['fableUsage'] }]); + expect(mockFetchUsageData.mock.calls[0]).toEqual([{ requiredFields: ['fableUsage'], cache: anyCacheMatcher() }]); }); it.each([ @@ -323,7 +329,7 @@ describe('usage prefetch', () => { weeklyResetAt: epochToIso(1774540000) }); expect(mockFetchUsageData.mock.calls).toEqual([ - [{ requiredFields: ['sessionResetAt'] }] + [{ requiredFields: ['sessionResetAt'], cache: anyCacheMatcher() }] ]); }); @@ -338,7 +344,7 @@ describe('usage prefetch', () => { expect(usageData).toBeNull(); expect(mockFetchUsageData.mock.calls).toEqual([ - [{ requiredFields: ['weeklyResetAt'] }] + [{ requiredFields: ['weeklyResetAt'], cache: anyCacheMatcher() }] ]); }); @@ -372,7 +378,7 @@ describe('usage prefetch', () => { error: 'no-credentials' }); expect(mockFetchUsageData.mock.calls.length).toBe(1); - expect(mockFetchUsageData.mock.calls[0]).toEqual([{ requiredFields: ['weeklySonnetUsage'] }]); + expect(mockFetchUsageData.mock.calls[0]).toEqual([{ requiredFields: ['weeklySonnetUsage'], cache: anyCacheMatcher() }]); }); it('preserves API errors when statusline data has no usable usage fields', async () => { @@ -402,7 +408,7 @@ describe('usage prefetch', () => { weeklySonnetUsage: 8 }); expect(mockFetchUsageData.mock.calls.length).toBe(1); - expect(mockFetchUsageData.mock.calls[0]).toEqual([{ requiredFields: ['weeklySonnetUsage'] }]); + expect(mockFetchUsageData.mock.calls[0]).toEqual([{ requiredFields: ['weeklySonnetUsage'], cache: anyCacheMatcher() }]); }); it('uses aggregate weekly reset as the cursor fallback without fetching for fable reset data', async () => { @@ -419,7 +425,7 @@ describe('usage prefetch', () => { fableUsage: 8 }); expect(mockFetchUsageData.mock.calls.length).toBe(1); - expect(mockFetchUsageData.mock.calls[0]).toEqual([{ requiredFields: ['fableUsage'] }]); + expect(mockFetchUsageData.mock.calls[0]).toEqual([{ requiredFields: ['fableUsage'], cache: anyCacheMatcher() }]); }); it('fetches extra usage fields while preserving statusline usage data', async () => { @@ -450,7 +456,8 @@ describe('usage prefetch', () => { 'extraUsageUtilization', 'extraUsageLimit', 'extraUsageUsed' - ] + ], + cache: anyCacheMatcher() }] ]); }); @@ -474,7 +481,8 @@ describe('usage prefetch', () => { 'extraUsageEnabled', 'extraUsageLimit', 'extraUsageUsed' - ] + ], + cache: anyCacheMatcher() }] ]); }); @@ -519,7 +527,7 @@ describe('usage prefetch', () => { expect(usageData?.weeklySonnetUsage).toBe(8); expect(usageData?.weeklyOpusUsage).toBe(2); expect(mockFetchUsageData.mock.calls).toEqual([[ - { requiredFields: ['weeklySonnetUsage', 'weeklyOpusUsage'] } + { requiredFields: ['weeklySonnetUsage', 'weeklyOpusUsage'], cache: anyCacheMatcher() } ]]); }); diff --git a/src/utils/config.ts b/src/utils/config.ts index 829ed52ce..454a5c5e4 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -53,16 +53,20 @@ interface SettingsPaths { settingsPath: string; } +function getSettingsPathsFor(configPath: string): SettingsPaths { + return { + configDir: path.dirname(configPath), + settingsPath: configPath + }; +} + interface AtomicWriteTarget { targetPath: string; tempDir: string; } function getSettingsPaths(): SettingsPaths { - return { - configDir: path.dirname(settingsPath), - settingsPath - }; + return getSettingsPathsFor(settingsPath); } function getErrorCode(error: unknown): string | undefined { @@ -155,21 +159,24 @@ async function writeDefaultSettings(paths: SettingsPaths): Promise { /** * Load ccstatusline settings from disk. * - * Recovery contract: if the file cannot be read or fails validation, loadSettings - * NEVER overwrites it — it returns built-in defaults in memory, records the reason - * (see getConfigLoadError), and leaves the file untouched for the user to fix. The - * file is written only when it is missing (first run), or when a readable config is - * migrated to the current version AND the migrated result validates first. All writes - * go through writeSettingsJson, which is atomic (temp file + rename). + * Recovery contract: if the file cannot be read or fails validation, the load + * NEVER overwrites it — it returns built-in defaults in memory, records the + * reason (see loadError on the result), and leaves the file untouched for the + * user to fix. The file is written only when it is missing (first run), or when + * a readable config is migrated to the current version AND the migrated result + * validates first. All writes go through writeSettingsJson, which is atomic + * (temp file + rename). + * + * Takes the config path explicitly and mutates no module state, so concurrent + * requests can each load their own config (#15). */ -export async function loadSettings(): Promise { - lastLoadError = null; - const paths = getSettingsPaths(); +export async function loadSettingsFrom(configPath: string): Promise { + const paths = getSettingsPathsFor(configPath); try { // Check if settings file exists if (!fs.existsSync(paths.settingsPath)) - return await writeDefaultSettings(paths); + return { settings: await writeDefaultSettings(paths), loadError: null }; const content = await readFile(paths.settingsPath, 'utf-8'); let rawData: unknown; @@ -178,8 +185,7 @@ export async function loadSettings(): Promise { rawData = JSON.parse(content); } catch { console.error('Failed to parse settings.json, using defaults (file left unchanged)'); - lastLoadError = 'settings.json is not valid JSON'; - return inMemoryDefaults(); + return { settings: inMemoryDefaults(), loadError: 'settings.json is not valid JSON' }; } // Check if this is a v1 config (no version field) @@ -190,8 +196,7 @@ export async function loadSettings(): Promise { const v1Result = SettingsSchema_v1.safeParse(rawData); if (!v1Result.success) { console.error('Invalid v1 settings format, using defaults (file left unchanged):', v1Result.error); - lastLoadError = 'settings.json is not in a valid format'; - return inMemoryDefaults(); + return { settings: inMemoryDefaults(), loadError: 'settings.json is not in a valid format' }; } // Migrate v1 to the current version (persisted below, only once it validates) @@ -208,8 +213,7 @@ export async function loadSettings(): Promise { const result = SettingsSchema.safeParse(rawData); if (!result.success) { console.error('Failed to parse settings, using defaults (file left unchanged):', result.error); - lastLoadError = 'settings.json is not in a valid format'; - return inMemoryDefaults(); + return { settings: inMemoryDefaults(), loadError: 'settings.json is not in a valid format' }; } // Persist a migration only after the migrated result validates, so a faulty @@ -219,18 +223,32 @@ export async function loadSettings(): Promise { } return { - ...result.data, - lines: upgradeLegacyWidgetTypes(result.data.lines) + settings: { + ...result.data, + lines: upgradeLegacyWidgetTypes(result.data.lines) + }, + loadError: null }; } catch (error) { console.error('Error loading settings, using defaults:', error); - lastLoadError = 'settings.json could not be read'; - return inMemoryDefaults(); + return { settings: inMemoryDefaults(), loadError: 'settings.json could not be read' }; } } -export async function saveSettings(settings: Settings): Promise { - const paths = getSettingsPaths(); +export async function loadSettings(): Promise { + const { settings, loadError } = await loadSettingsFrom(settingsPath); + lastLoadError = loadError; + return settings; +} + +export interface LoadedSettings { + settings: Settings; + /** Why the on-disk config was rejected and defaults are shown; null on a clean load. */ + loadError: string | null; +} + +export async function saveSettingsTo(configPath: string, settings: Settings): Promise { + const paths = getSettingsPathsFor(configPath); // Always include version when saving const settingsWithVersion = { @@ -247,6 +265,10 @@ export async function saveSettings(settings: Settings): Promise { } catch { /* ignore hook sync failures */ } } +export async function saveSettings(settings: Settings): Promise { + return saveSettingsTo(settingsPath, settings); +} + export type ImportValidationResult = | { status: 'valid'; data: Settings; presentKeys: (keyof Settings)[] } | { status: 'invalid'; reason: string }; diff --git a/src/utils/usage-fetch.ts b/src/utils/usage-fetch.ts index 315212dde..1b1049d1f 100644 --- a/src/utils/usage-fetch.ts +++ b/src/utils/usage-fetch.ts @@ -36,7 +36,11 @@ const MAX_LOCK_HORIZON = 24 * 60 * 60; // seconds const MACOS_USAGE_CREDENTIALS_SERVICE = 'Claude Code-credentials'; const MACOS_SECURITY_DUMP_MAX_BUFFER = 8 * 1024 * 1024; -export interface FetchUsageDataOptions { requiredFields?: readonly UsageDataField[] } +export interface FetchUsageDataOptions { + requiredFields?: readonly UsageDataField[]; + /** Request-scoped memory cache; a fresh one per render (#15). */ + cache: UsageMemoryCache; +} // The access token is what the API is called with; the refresh token is kept // alongside it only to fingerprint the account (see getUsageCacheIdentity). @@ -345,10 +349,18 @@ export function parseUsageApiResponse(rawJson: string): UsageData | null { return result; } -// Memory caches -let cachedUsageData: UsageData | null = null; -let usageCacheTime = 0; -let usageErrorCacheMaxAge = LOCK_MAX_AGE; +// Memory cache. Scoped to a single request: callers create a fresh +// UsageMemoryCache per render (#15) instead of sharing one process-global +// result, so concurrent requests cannot serve each other stale data. +export interface UsageMemoryCache { + data: UsageData | null; + time: number; + errorMaxAge: number; +} + +export function createUsageMemoryCache(): UsageMemoryCache { + return { data: null, time: 0, errorMaxAge: LOCK_MAX_AGE }; +} type UsageLockError = z.infer; @@ -365,18 +377,18 @@ function ensureCacheDirExists(): void { } } -function setCachedUsageError(error: UsageError, now: number, maxAge = LOCK_MAX_AGE): UsageData { +function setCachedUsageError(cache: UsageMemoryCache, error: UsageError, now: number, maxAge = LOCK_MAX_AGE): UsageData { const errorData: UsageData = { error }; - cachedUsageData = errorData; - usageCacheTime = now; - usageErrorCacheMaxAge = maxAge; + cache.data = errorData; + cache.time = now; + cache.errorMaxAge = maxAge; return errorData; } -function cacheUsageData(data: UsageData, now: number): UsageData { - cachedUsageData = data; - usageCacheTime = now; - usageErrorCacheMaxAge = LOCK_MAX_AGE; +function cacheUsageData(cache: UsageMemoryCache, data: UsageData, now: number): UsageData { + cache.data = data; + cache.time = now; + cache.errorMaxAge = LOCK_MAX_AGE; return data; } @@ -408,6 +420,7 @@ function hasRequiredUsageFields(data: UsageData, requiredFields: readonly UsageD } function getStaleUsageOrError( + cache: UsageMemoryCache, error: UsageError, now: number, cacheIdentity: UsageCacheIdentity | null, @@ -416,10 +429,10 @@ function getStaleUsageOrError( ): UsageData { const stale = readStaleUsageCache(cacheIdentity); if (stale && !stale.error && hasRequiredUsageFields(stale, requiredFields)) { - return cacheUsageData(stale, now); + return cacheUsageData(cache, stale, now); } - return setCachedUsageError(error, now, errorCacheMaxAge); + return setCachedUsageError(cache, error, now, errorCacheMaxAge); } function normalizeSecurityTimedateValue(rawValue: string): string | null { @@ -800,18 +813,19 @@ async function fetchFromUsageApi(token: string): Promise { }); } -export async function fetchUsageData(options: FetchUsageDataOptions = {}): Promise { +export async function fetchUsageData(options: FetchUsageDataOptions): Promise { const now = Math.floor(Date.now() / 1000); const requiredFields = options.requiredFields ?? []; + const cache = options.cache; // Check memory cache (fast path) - if (cachedUsageData) { - const cacheAge = now - usageCacheTime; - if (!cachedUsageData.error && cacheAge < CACHE_MAX_AGE && hasRequiredUsageFields(cachedUsageData, requiredFields)) { - return cachedUsageData; + if (cache.data) { + const cacheAge = now - cache.time; + if (!cache.data.error && cacheAge < CACHE_MAX_AGE && hasRequiredUsageFields(cache.data, requiredFields)) { + return cache.data; } - if (cachedUsageData.error && cacheAge < usageErrorCacheMaxAge) { - return cachedUsageData; + if (cache.data.error && cacheAge < cache.errorMaxAge) { + return cache.data; } } @@ -833,7 +847,7 @@ export async function fetchUsageData(options: FetchUsageDataOptions = {}): Promi if (fileData && !fileData.error && tokenHashMatches(readCachedTokenHash(rawCache), cacheIdentity) && hasRequiredUsageFields(fileData, requiredFields)) { - return cacheUsageData(fileData, now); + return cacheUsageData(cache, fileData, now); } } } catch { @@ -841,12 +855,13 @@ export async function fetchUsageData(options: FetchUsageDataOptions = {}): Promi } if (!token) { - return getStaleUsageOrError('no-credentials', now, cacheIdentity, LOCK_MAX_AGE, requiredFields); + return getStaleUsageOrError(cache, 'no-credentials', now, cacheIdentity, LOCK_MAX_AGE, requiredFields); } const activeLock = readActiveUsageLock(now); if (activeLock) { return getStaleUsageOrError( + cache, activeLock.error, now, cacheIdentity, @@ -863,23 +878,23 @@ export async function fetchUsageData(options: FetchUsageDataOptions = {}): Promi if (response.kind === 'rate-limited') { writeUsageLock(now + response.retryAfterSeconds, 'rate-limited'); - return getStaleUsageOrError('rate-limited', now, cacheIdentity, response.retryAfterSeconds, requiredFields); + return getStaleUsageOrError(cache, 'rate-limited', now, cacheIdentity, response.retryAfterSeconds, requiredFields); } if (response.kind === 'error') { - return getStaleUsageOrError('api-error', now, cacheIdentity, LOCK_MAX_AGE, requiredFields); + return getStaleUsageOrError(cache, 'api-error', now, cacheIdentity, LOCK_MAX_AGE, requiredFields); } const usageData = parseUsageApiResponse(response.body); if (!usageData) { writeUsageLock(now + LOCK_MAX_AGE, 'parse-error'); - return getStaleUsageOrError('parse-error', now, cacheIdentity, LOCK_MAX_AGE, requiredFields); + return getStaleUsageOrError(cache, 'parse-error', now, cacheIdentity, LOCK_MAX_AGE, requiredFields); } // Validate we got actual data if (usageData.sessionUsage === undefined && usageData.weeklyUsage === undefined) { writeUsageLock(now + LOCK_MAX_AGE, 'parse-error'); - return getStaleUsageOrError('parse-error', now, cacheIdentity, LOCK_MAX_AGE, requiredFields); + return getStaleUsageOrError(cache, 'parse-error', now, cacheIdentity, LOCK_MAX_AGE, requiredFields); } // Save to cache @@ -897,9 +912,9 @@ export async function fetchUsageData(options: FetchUsageDataOptions = {}): Promi clearUsageLock(); } - return cacheUsageData(usageData, now); + return cacheUsageData(cache, usageData, now); } catch { writeUsageLock(now + LOCK_MAX_AGE, 'parse-error'); - return getStaleUsageOrError('parse-error', now, cacheIdentity, LOCK_MAX_AGE, requiredFields); + return getStaleUsageOrError(cache, 'parse-error', now, cacheIdentity, LOCK_MAX_AGE, requiredFields); } } diff --git a/src/utils/usage-prefetch.ts b/src/utils/usage-prefetch.ts index 8fddba027..3c74b5a2a 100644 --- a/src/utils/usage-prefetch.ts +++ b/src/utils/usage-prefetch.ts @@ -6,6 +6,7 @@ import type { WidgetItem } from '../types/Widget'; import type { UsageData } from './usage'; import { fetchUsageData } from './usage'; +import { createUsageMemoryCache } from './usage-fetch'; import type { UsageDataField } from './usage-types'; import { WEEKLY_MODEL_USAGE_BUCKETS, @@ -226,7 +227,10 @@ export async function prefetchUsageDataIfNeeded(lines: WidgetItem[][], data?: St return rateLimitsData; } - const apiData = await fetchUsageData({ requiredFields: missingFields }); + // Request-scoped: each prefetch gets its own memory cache, so one render's + // result can never leak into another's (#15). + const usageCache = createUsageMemoryCache(); + const apiData = await fetchUsageData({ requiredFields: missingFields, cache: usageCache }); if (apiData.error && missingRequirements.suppressFetchError) { return rateLimitsData; } diff --git a/src/utils/usage.ts b/src/utils/usage.ts index 0532b7819..96a94361b 100644 --- a/src/utils/usage.ts +++ b/src/utils/usage.ts @@ -1,4 +1,4 @@ -export { fetchUsageData } from './usage-fetch'; +export { createUsageMemoryCache, fetchUsageData } from './usage-fetch'; export { formatUsageDuration, formatUsageResetAt,