From ad7111e50be025eda5b5e4930c2629159a3ddc51 Mon Sep 17 00:00:00 2001 From: Cody Anderson <70287898+arakasi1@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:25:47 -0600 Subject: [PATCH] feat(#2161): opt-in absolute token count on the statusline context meter (#2174) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(#2161): opt-in absolute token count on the statusline context meter New statusline.show_context_tokens config (default false). When enabled, the context meter shows the absolute token total after the percentage, e.g. "████░░░░░░ 46% (156k)" — summing input, cache-creation, cache-read, and output tokens from context_window.current_usage (matching /context). Default output is byte-for-byte unchanged when the flag is absent or false. The .planning config is now read once per render and shared with the last-command/position block instead of being re-read. Claude-Session: https://claude.ai/code/session_01Hme55Pvq6BhpgwBcyC5HAg * docs(#2161): changeset fragment for PR #2174 * fix(#2161): review fixes — k-to-M threshold, boundary tests, changeset format - formatTokens promotes to the M branch when k-rounding reaches 1000 (999,500-999,999 rendered "1000k" instead of "1.0M") - boundary tests at 999499/999500/999999/1000000/1000001 - Number() guards on the four usage fields (silent string-concat gap) - changeset body ends with the (#2161) citation per house convention Claude-Session: https://claude.ai/code/session_01Hme55Pvq6BhpgwBcyC5HAg * fix(#2161): round-2 review fixes — config-set coverage, precision claim, exports style - config-set accept/reject tests for statusline.show_context_tokens (mirrors the post-planning-gaps precedent the issue scope names) - changeset + docs no longer claim parity with /context: the suffix sums four fields while the meter %% derives from used_percentage (three), so the figures can diverge slightly - module.exports one entry per line Claude-Session: https://claude.ai/code/session_01Hme55Pvq6BhpgwBcyC5HAg * test: regenerate golden-install-parity fixtures for the statusline hook change Claude-Session: https://claude.ai/code/session_01Hme55Pvq6BhpgwBcyC5HAg --------- Co-authored-by: Tom Boucher --- .changeset/jolly-jays-march.md | 5 + docs/CONFIGURATION.md | 1 + .../bin/shared/config-schema.manifest.json | 1 + hooks/gsd-statusline.js | 52 +++++- src/config.cts | 7 + .../golden-install-parity/antigravity.json | 4 +- .../golden-install-parity/augment.json | 4 +- .../golden-install-parity/claude-local.json | 4 +- .../golden-install-parity/claude.json | 4 +- .../fixtures/golden-install-parity/cline.json | 2 +- .../golden-install-parity/codebuddy.json | 4 +- .../fixtures/golden-install-parity/codex.json | 2 +- .../golden-install-parity/copilot.json | 2 +- .../golden-install-parity/cursor.json | 2 +- .../golden-install-parity/hermes.json | 4 +- .../fixtures/golden-install-parity/kilo.json | 2 +- .../fixtures/golden-install-parity/kimi.json | 4 +- .../golden-install-parity/opencode.json | 4 +- tests/fixtures/golden-install-parity/pi.json | 4 +- .../fixtures/golden-install-parity/qwen.json | 4 +- .../fixtures/golden-install-parity/trae.json | 2 +- .../golden-install-parity/windsurf.json | 2 +- .../fixtures/golden-install-parity/zcode.json | 2 +- tests/gsd-statusline.test.cjs | 171 ++++++++++++++++++ 24 files changed, 260 insertions(+), 33 deletions(-) create mode 100644 .changeset/jolly-jays-march.md diff --git a/.changeset/jolly-jays-march.md b/.changeset/jolly-jays-march.md new file mode 100644 index 000000000..430f1cd44 --- /dev/null +++ b/.changeset/jolly-jays-march.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 2174 +--- +**Opt-in absolute token count on the statusline context meter** — new `statusline.show_context_tokens` config (default `false`). When enabled, the meter shows the absolute context total after the percentage, e.g. "████░░░░░░ 46% (156k)", summing input, cache-creation, cache-read, and output tokens from the hook payload (a broader basis than the meter's percentage, which is derived from `used_percentage` and excludes output tokens — the two figures can diverge slightly). Default meter output is unchanged. (#2161) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 1812c9470..4b15aab2b 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -452,6 +452,7 @@ If `.planning/` is in `.gitignore`, `commit_docs` is automatically `false` regar | `hooks.workflow_guard` | boolean | `false` | Warn when file edits happen outside GSD workflow context (advises using `/gsd-quick` or `/gsd-fast`) | | `statusline.show_last_command` | boolean | `false` | Append `last: /` suffix to the statusline showing the most recently invoked slash command. Opt-in; reads the active session transcript to extract the latest `` tag (closes #2538) | | `statusline.context_position` | string | `"end"` | Position of the context-window meter. `"end"` (default) renders at line tail; `"front"` renders immediately after the model name so the meter stays visible in narrow terminals. Closes #2937 | +| `statusline.show_context_tokens` | boolean | `false` | Append the absolute token count (e.g. `(156k)`) after the context meter's percentage. Sums input, cache-creation, cache-read, and output tokens from the hook payload — a broader basis than the meter's percentage (which excludes output tokens), so the two figures can diverge slightly. Opt-in; the meter is unchanged when the flag is absent | The prompt injection guard hook (`gsd-prompt-guard.js`) is always active and cannot be disabled — it's a security feature, not a workflow toggle. diff --git a/gsd-core/bin/shared/config-schema.manifest.json b/gsd-core/bin/shared/config-schema.manifest.json index eeadb2d2a..721e23b1d 100644 --- a/gsd-core/bin/shared/config-schema.manifest.json +++ b/gsd-core/bin/shared/config-schema.manifest.json @@ -71,6 +71,7 @@ "workflow.context_coverage_gate", "statusline.show_last_command", "statusline.context_position", + "statusline.show_context_tokens", "workflow.max_discuss_passes", "features.thinking_partner", "context", diff --git a/hooks/gsd-statusline.js b/hooks/gsd-statusline.js index 847530a2e..a0b611b03 100755 --- a/hooks/gsd-statusline.js +++ b/hooks/gsd-statusline.js @@ -286,6 +286,36 @@ function formatGsdState(s) { return parts.join(' · '); } +// --- Context token count (opt-in) --------------------------------------------- + +/** + * Format a token count compactly: 156342 → '156k', 1234567 → '1.2M'. + */ +function formatTokens(tokens) { + // Promote to the M branch when k-rounding would reach 1000 (999,500-999,999 + // must render "1.0M", never "1000k"). + if (tokens >= 1000000 || Math.round(tokens / 1000) >= 1000) { + return (tokens / 1000000).toFixed(1) + 'M'; + } + if (tokens >= 1000) return Math.round(tokens / 1000) + 'k'; + return String(tokens); +} + +/** + * Pure function: build the token-count suffix for the context meter from the + * hook input's context_window.current_usage block. Sums input, cache-creation, + * cache-read, and output tokens (the same total Claude Code's /context shows). + * Returns ' (156k)' or '' when usage is absent/empty. + */ +function contextTokenSuffix(currentUsage) { + if (!currentUsage || typeof currentUsage !== 'object') return ''; + const total = (Number(currentUsage.input_tokens) || 0) + + (Number(currentUsage.cache_creation_input_tokens) || 0) + + (Number(currentUsage.cache_read_input_tokens) || 0) + + (Number(currentUsage.output_tokens) || 0); + return total > 0 ? ` (${formatTokens(total)})` : ''; +} + // --- stdin ------------------------------------------------------------------ function runStatusline() { @@ -304,6 +334,11 @@ function runStatusline() { const session = data.session_id || ''; const remaining = data.context_window?.remaining_percentage; + // Read .planning config once — used by the context meter (token suffix) + // and the last-command/position block below. Fail-soft to {}. + let cfg = {}; + try { cfg = readGsdConfig(dir); } catch (e) {} + // Context window display (shows USED percentage scaled to usable context) // Claude Code reserves a buffer for autocompact. By default this is ~16.5% // of the total window, but users can override it via CLAUDE_CODE_AUTO_COMPACT_WINDOW @@ -349,15 +384,21 @@ function runStatusline() { const filled = Math.floor(used / 10); const bar = '█'.repeat(filled) + '░'.repeat(10 - filled); + // Opt-in absolute token count after the percentage (statusline.show_context_tokens) + let tokenSuffix = ''; + if (getConfigValue(cfg, 'statusline.show_context_tokens') === true) { + tokenSuffix = contextTokenSuffix(data.context_window?.current_usage); + } + // Color based on usable context thresholds if (used < 50) { - ctx = ` \x1b[32m${bar} ${used}%\x1b[0m`; + ctx = ` \x1b[32m${bar} ${used}%${tokenSuffix}\x1b[0m`; } else if (used < 65) { - ctx = ` \x1b[33m${bar} ${used}%\x1b[0m`; + ctx = ` \x1b[33m${bar} ${used}%${tokenSuffix}\x1b[0m`; } else if (used < 80) { - ctx = ` \x1b[38;5;208m${bar} ${used}%\x1b[0m`; + ctx = ` \x1b[38;5;208m${bar} ${used}%${tokenSuffix}\x1b[0m`; } else { - ctx = ` \x1b[5;31m💀 ${bar} ${used}%\x1b[0m`; + ctx = ` \x1b[5;31m💀 ${bar} ${used}%${tokenSuffix}\x1b[0m`; } } @@ -422,7 +463,6 @@ function runStatusline() { let lastCmdSuffix = ''; let position = 'end'; try { - const cfg = readGsdConfig(dir); if (getConfigValue(cfg, 'statusline.show_last_command') === true) { const transcriptPath = data.transcript_path; const lastCmd = readLastSlashCommand(transcriptPath); @@ -531,6 +571,8 @@ module.exports = { composeStatusline, isInstalledAheadOfLatest, evaluateUpdateCache, + formatTokens, + contextTokenSuffix, }; /** diff --git a/src/config.cts b/src/config.cts index a0989f78c..45afc6892 100644 --- a/src/config.cts +++ b/src/config.cts @@ -763,6 +763,13 @@ function cmdConfigSet(cwd: string, keyPath: string | undefined, value: string | const VALID_CONTEXT_POSITIONS = ['front', 'end']; if (kp === 'statusline.context_position') assertEnumValue(parsedValue, val, VALID_CONTEXT_POSITIONS, 'statusline.context_position'); + // statusline.show_context_tokens — boolean only + if (kp === 'statusline.show_context_tokens') { + if (typeof parsedValue !== 'boolean') { + error(`Invalid statusline.show_context_tokens '${val}'. Must be a boolean (true or false).`); + } + } + // Fallow scope + profile enum validation (#3424) const VALID_FALLOW_SCOPES = ['phase', 'repo']; if (kp === 'code_quality.fallow.scope') assertEnumValue(parsedValue, val, VALID_FALLOW_SCOPES, 'code_quality.fallow.scope'); diff --git a/tests/fixtures/golden-install-parity/antigravity.json b/tests/fixtures/golden-install-parity/antigravity.json index ee69a5669..fc15df65d 100644 --- a/tests/fixtures/golden-install-parity/antigravity.json +++ b/tests/fixtures/golden-install-parity/antigravity.json @@ -41,7 +41,7 @@ "gsd-core/bin/gsd-tools.cjs": "600e9ed0cb6ed7bb", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "6bba2b9c9fa47cb8", + "gsd-core/bin/shared/config-schema.manifest.json": "b67705431d738ae7", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "8bc541aabc2e143c", @@ -328,7 +328,7 @@ "hooks/gsd-read-guard.js": "9e423cd03e2d1b16", "hooks/gsd-read-injection-scanner.js": "eefea61f9b0e464c", "hooks/gsd-session-state.sh": "e54379ba86bf1b6d", - "hooks/gsd-statusline.js": "8ae31be7a006204b", + "hooks/gsd-statusline.js": "9e64af477e774de6", "hooks/gsd-update-banner.js": "55143a25f978f301", "hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38", "hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd", diff --git a/tests/fixtures/golden-install-parity/augment.json b/tests/fixtures/golden-install-parity/augment.json index 61d2c8ad0..120386618 100644 --- a/tests/fixtures/golden-install-parity/augment.json +++ b/tests/fixtures/golden-install-parity/augment.json @@ -112,7 +112,7 @@ "gsd-core/bin/gsd-tools.cjs": "37fb6081fcb18038", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "6bba2b9c9fa47cb8", + "gsd-core/bin/shared/config-schema.manifest.json": "b67705431d738ae7", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", @@ -399,7 +399,7 @@ "hooks/gsd-read-guard.js": "9e423cd03e2d1b16", "hooks/gsd-read-injection-scanner.js": "c8800819f7443a15", "hooks/gsd-session-state.sh": "e54379ba86bf1b6d", - "hooks/gsd-statusline.js": "3be32d2012c77fc1", + "hooks/gsd-statusline.js": "3e633cd082663b99", "hooks/gsd-update-banner.js": "55143a25f978f301", "hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38", "hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd", diff --git a/tests/fixtures/golden-install-parity/claude-local.json b/tests/fixtures/golden-install-parity/claude-local.json index f660e3b97..de6f1260e 100644 --- a/tests/fixtures/golden-install-parity/claude-local.json +++ b/tests/fixtures/golden-install-parity/claude-local.json @@ -111,7 +111,7 @@ "gsd-core/bin/gsd-tools.cjs": "37fb6081fcb18038", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "6bba2b9c9fa47cb8", + "gsd-core/bin/shared/config-schema.manifest.json": "b67705431d738ae7", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", @@ -398,7 +398,7 @@ "hooks/gsd-read-guard.js": "9e423cd03e2d1b16", "hooks/gsd-read-injection-scanner.js": "00d2449afefd2e5f", "hooks/gsd-session-state.sh": "e54379ba86bf1b6d", - "hooks/gsd-statusline.js": "7c315416ffc99a9a", + "hooks/gsd-statusline.js": "920558a55eb86587", "hooks/gsd-update-banner.js": "b457746cb76c1957", "hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38", "hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd", diff --git a/tests/fixtures/golden-install-parity/claude.json b/tests/fixtures/golden-install-parity/claude.json index 7e0d512eb..278315fb6 100644 --- a/tests/fixtures/golden-install-parity/claude.json +++ b/tests/fixtures/golden-install-parity/claude.json @@ -40,7 +40,7 @@ "gsd-core/bin/gsd-tools.cjs": "37fb6081fcb18038", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "6bba2b9c9fa47cb8", + "gsd-core/bin/shared/config-schema.manifest.json": "b67705431d738ae7", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", @@ -327,7 +327,7 @@ "hooks/gsd-read-guard.js": "9e423cd03e2d1b16", "hooks/gsd-read-injection-scanner.js": "00d2449afefd2e5f", "hooks/gsd-session-state.sh": "e54379ba86bf1b6d", - "hooks/gsd-statusline.js": "7c315416ffc99a9a", + "hooks/gsd-statusline.js": "920558a55eb86587", "hooks/gsd-update-banner.js": "b457746cb76c1957", "hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38", "hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd", diff --git a/tests/fixtures/golden-install-parity/cline.json b/tests/fixtures/golden-install-parity/cline.json index 598d068aa..d1151047b 100644 --- a/tests/fixtures/golden-install-parity/cline.json +++ b/tests/fixtures/golden-install-parity/cline.json @@ -44,7 +44,7 @@ "gsd-core/bin/gsd-tools.cjs": "a84914f7cab74332", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "6bba2b9c9fa47cb8", + "gsd-core/bin/shared/config-schema.manifest.json": "b67705431d738ae7", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", diff --git a/tests/fixtures/golden-install-parity/codebuddy.json b/tests/fixtures/golden-install-parity/codebuddy.json index aa369e9c0..7468c5bbf 100644 --- a/tests/fixtures/golden-install-parity/codebuddy.json +++ b/tests/fixtures/golden-install-parity/codebuddy.json @@ -112,7 +112,7 @@ "gsd-core/bin/gsd-tools.cjs": "37fb6081fcb18038", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "6bba2b9c9fa47cb8", + "gsd-core/bin/shared/config-schema.manifest.json": "b67705431d738ae7", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", @@ -399,7 +399,7 @@ "hooks/gsd-read-guard.js": "9e423cd03e2d1b16", "hooks/gsd-read-injection-scanner.js": "7f7a7615b303369a", "hooks/gsd-session-state.sh": "e54379ba86bf1b6d", - "hooks/gsd-statusline.js": "ef8dcb6d64fd4493", + "hooks/gsd-statusline.js": "6ab2905537f885ba", "hooks/gsd-update-banner.js": "55143a25f978f301", "hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38", "hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd", diff --git a/tests/fixtures/golden-install-parity/codex.json b/tests/fixtures/golden-install-parity/codex.json index 36c21e16f..1ddaf96c1 100644 --- a/tests/fixtures/golden-install-parity/codex.json +++ b/tests/fixtures/golden-install-parity/codex.json @@ -147,7 +147,7 @@ "gsd-core/bin/gsd-tools.cjs": "37fb6081fcb18038", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "6bba2b9c9fa47cb8", + "gsd-core/bin/shared/config-schema.manifest.json": "b67705431d738ae7", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", diff --git a/tests/fixtures/golden-install-parity/copilot.json b/tests/fixtures/golden-install-parity/copilot.json index 27c773c07..2aab090d5 100644 --- a/tests/fixtures/golden-install-parity/copilot.json +++ b/tests/fixtures/golden-install-parity/copilot.json @@ -42,7 +42,7 @@ "gsd-core/bin/gsd-tools.cjs": "600e9ed0cb6ed7bb", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "6bba2b9c9fa47cb8", + "gsd-core/bin/shared/config-schema.manifest.json": "b67705431d738ae7", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "10226e9512dd44bf", diff --git a/tests/fixtures/golden-install-parity/cursor.json b/tests/fixtures/golden-install-parity/cursor.json index 870503ea7..41ddd34a1 100644 --- a/tests/fixtures/golden-install-parity/cursor.json +++ b/tests/fixtures/golden-install-parity/cursor.json @@ -112,7 +112,7 @@ "gsd-core/bin/gsd-tools.cjs": "db16ded31dd6eecf", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "6bba2b9c9fa47cb8", + "gsd-core/bin/shared/config-schema.manifest.json": "b67705431d738ae7", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", diff --git a/tests/fixtures/golden-install-parity/hermes.json b/tests/fixtures/golden-install-parity/hermes.json index 5eb2530ec..fe439f057 100644 --- a/tests/fixtures/golden-install-parity/hermes.json +++ b/tests/fixtures/golden-install-parity/hermes.json @@ -41,7 +41,7 @@ "gsd-core/bin/gsd-tools.cjs": "b3f927ee4b4c4711", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "6bba2b9c9fa47cb8", + "gsd-core/bin/shared/config-schema.manifest.json": "b67705431d738ae7", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", @@ -328,7 +328,7 @@ "hooks/gsd-read-guard.js": "1f58b020a91f032b", "hooks/gsd-read-injection-scanner.js": "f358eca3fa1eab24", "hooks/gsd-session-state.sh": "e54379ba86bf1b6d", - "hooks/gsd-statusline.js": "861808560e60b233", + "hooks/gsd-statusline.js": "34d2ae303558b917", "hooks/gsd-update-banner.js": "b457746cb76c1957", "hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38", "hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd", diff --git a/tests/fixtures/golden-install-parity/kilo.json b/tests/fixtures/golden-install-parity/kilo.json index c274dadf3..8f387eb90 100644 --- a/tests/fixtures/golden-install-parity/kilo.json +++ b/tests/fixtures/golden-install-parity/kilo.json @@ -112,7 +112,7 @@ "gsd-core/bin/gsd-tools.cjs": "37fb6081fcb18038", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "6bba2b9c9fa47cb8", + "gsd-core/bin/shared/config-schema.manifest.json": "b67705431d738ae7", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", diff --git a/tests/fixtures/golden-install-parity/kimi.json b/tests/fixtures/golden-install-parity/kimi.json index cd7ac3baa..5da5f0f58 100644 --- a/tests/fixtures/golden-install-parity/kimi.json +++ b/tests/fixtures/golden-install-parity/kimi.json @@ -18,7 +18,7 @@ ".kimi/hooks/gsd-read-guard.js": "9e423cd03e2d1b16", ".kimi/hooks/gsd-read-injection-scanner.js": "c519598b9257aafa", ".kimi/hooks/gsd-session-state.sh": "e54379ba86bf1b6d", - ".kimi/hooks/gsd-statusline.js": "2736b0885aa97bbf", + ".kimi/hooks/gsd-statusline.js": "ae0c0e7a934c5deb", ".kimi/hooks/gsd-update-banner.js": "55143a25f978f301", ".kimi/hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38", ".kimi/hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd", @@ -105,7 +105,7 @@ "gsd-core/bin/gsd-tools.cjs": "37fb6081fcb18038", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "6bba2b9c9fa47cb8", + "gsd-core/bin/shared/config-schema.manifest.json": "b67705431d738ae7", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", diff --git a/tests/fixtures/golden-install-parity/opencode.json b/tests/fixtures/golden-install-parity/opencode.json index 0e3348de8..aa6842369 100644 --- a/tests/fixtures/golden-install-parity/opencode.json +++ b/tests/fixtures/golden-install-parity/opencode.json @@ -112,7 +112,7 @@ "gsd-core/bin/gsd-tools.cjs": "37fb6081fcb18038", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "6bba2b9c9fa47cb8", + "gsd-core/bin/shared/config-schema.manifest.json": "b67705431d738ae7", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", @@ -399,7 +399,7 @@ "hooks/gsd-read-guard.js": "9e423cd03e2d1b16", "hooks/gsd-read-injection-scanner.js": "f72060dfe035f706", "hooks/gsd-session-state.sh": "e54379ba86bf1b6d", - "hooks/gsd-statusline.js": "9c132b5985800462", + "hooks/gsd-statusline.js": "3dd40389fa786dd6", "hooks/gsd-update-banner.js": "55143a25f978f301", "hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38", "hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd", diff --git a/tests/fixtures/golden-install-parity/pi.json b/tests/fixtures/golden-install-parity/pi.json index 15e4a4e56..2cd30cb92 100644 --- a/tests/fixtures/golden-install-parity/pi.json +++ b/tests/fixtures/golden-install-parity/pi.json @@ -8,7 +8,7 @@ "gsd-core/bin/gsd-tools.cjs": "37fb6081fcb18038", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "6bba2b9c9fa47cb8", + "gsd-core/bin/shared/config-schema.manifest.json": "b67705431d738ae7", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", @@ -295,7 +295,7 @@ "hooks/gsd-read-guard.js": "9e423cd03e2d1b16", "hooks/gsd-read-injection-scanner.js": "f454242c010804cf", "hooks/gsd-session-state.sh": "e54379ba86bf1b6d", - "hooks/gsd-statusline.js": "5539e1ae859b987e", + "hooks/gsd-statusline.js": "41b0080cf0f3e7e9", "hooks/gsd-update-banner.js": "55143a25f978f301", "hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38", "hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd", diff --git a/tests/fixtures/golden-install-parity/qwen.json b/tests/fixtures/golden-install-parity/qwen.json index 256b73ba0..ccd12a98c 100644 --- a/tests/fixtures/golden-install-parity/qwen.json +++ b/tests/fixtures/golden-install-parity/qwen.json @@ -41,7 +41,7 @@ "gsd-core/bin/gsd-tools.cjs": "6454021dec4d9563", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "6bba2b9c9fa47cb8", + "gsd-core/bin/shared/config-schema.manifest.json": "b67705431d738ae7", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", @@ -328,7 +328,7 @@ "hooks/gsd-read-guard.js": "2c8d417d12b51040", "hooks/gsd-read-injection-scanner.js": "396574bd25e99ff9", "hooks/gsd-session-state.sh": "e54379ba86bf1b6d", - "hooks/gsd-statusline.js": "739140996a3c0d49", + "hooks/gsd-statusline.js": "5686cf31c721b2b1", "hooks/gsd-update-banner.js": "b457746cb76c1957", "hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38", "hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd", diff --git a/tests/fixtures/golden-install-parity/trae.json b/tests/fixtures/golden-install-parity/trae.json index 4a5148a3f..25b84997c 100644 --- a/tests/fixtures/golden-install-parity/trae.json +++ b/tests/fixtures/golden-install-parity/trae.json @@ -41,7 +41,7 @@ "gsd-core/bin/gsd-tools.cjs": "c28c9076058c5e73", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "6bba2b9c9fa47cb8", + "gsd-core/bin/shared/config-schema.manifest.json": "b67705431d738ae7", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", diff --git a/tests/fixtures/golden-install-parity/windsurf.json b/tests/fixtures/golden-install-parity/windsurf.json index 625774ee5..48e41a345 100644 --- a/tests/fixtures/golden-install-parity/windsurf.json +++ b/tests/fixtures/golden-install-parity/windsurf.json @@ -41,7 +41,7 @@ "gsd-core/bin/gsd-tools.cjs": "568e7c6bdd415c1a", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "6bba2b9c9fa47cb8", + "gsd-core/bin/shared/config-schema.manifest.json": "b67705431d738ae7", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", diff --git a/tests/fixtures/golden-install-parity/zcode.json b/tests/fixtures/golden-install-parity/zcode.json index fe60606c9..790d628ad 100644 --- a/tests/fixtures/golden-install-parity/zcode.json +++ b/tests/fixtures/golden-install-parity/zcode.json @@ -112,7 +112,7 @@ "gsd-core/bin/gsd-tools.cjs": "37fb6081fcb18038", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "6bba2b9c9fa47cb8", + "gsd-core/bin/shared/config-schema.manifest.json": "b67705431d738ae7", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", diff --git a/tests/gsd-statusline.test.cjs b/tests/gsd-statusline.test.cjs index 0ac03eb01..f2fb70b07 100644 --- a/tests/gsd-statusline.test.cjs +++ b/tests/gsd-statusline.test.cjs @@ -1190,5 +1190,176 @@ test('config-set rejects invalid statusline.context_position', () => { cleanup(tmpDir); } }); + +// Same write-path enforcement for the boolean statusline.show_context_tokens +// key (#2161) — mirrors the workflow.post_planning_gaps precedent the issue's +// scope names (tests/post-planning-gaps-2493.test.cjs). +test('config-set statusline.show_context_tokens true → persisted as boolean', () => { + const tmpDir = createTempProject(); + try { + const r = runGsdTools(['config-set', 'statusline.show_context_tokens', 'true'], tmpDir); + assert.ok(r.success, r.error); + const config = JSON.parse( + fs.readFileSync(path.join(tmpDir, '.planning', 'config.json'), 'utf-8')); + assert.strictEqual(config.statusline.show_context_tokens, true); + } finally { + cleanup(tmpDir); + } +}); + +test('config-set statusline.show_context_tokens yes → rejected', () => { + const tmpDir = createTempProject(); + try { + const r = runGsdTools(['config-set', 'statusline.show_context_tokens', 'yes'], tmpDir); + assert.equal(r.success, false, 'non-boolean value must be rejected'); + assert.match(r.error || r.output, /boolean|true|false/i); + } finally { + cleanup(tmpDir); + } +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Context meter token count (statusline.show_context_tokens) +// ──────────────────────────────────────────────────────────────────────── +{ + const { test, describe } = require('node:test'); + const assert = require('node:assert/strict'); + const fs = require('node:fs'); + const os = require('node:os'); + const path = require('node:path'); + const { execFileSync } = require('node:child_process'); + const { cleanup } = require('./helpers.cjs'); + const { formatTokens, contextTokenSuffix } = require('../hooks/gsd-statusline.js'); + const { VALID_CONFIG_KEYS } = require('../gsd-core/bin/lib/config-schema.cjs'); + + const hookPath = path.join(__dirname, '..', 'hooks', 'gsd-statusline.js'); + + describe('config schema: statusline.show_context_tokens', () => { + test('registers statusline.show_context_tokens', () => { + assert.ok( + VALID_CONFIG_KEYS.has('statusline.show_context_tokens'), + 'statusline.show_context_tokens must be in VALID_CONFIG_KEYS', + ); + }); + }); + + describe('formatTokens', () => { + test('passes small counts through', () => { + assert.equal(formatTokens(0), '0'); + assert.equal(formatTokens(999), '999'); + }); + test('rounds thousands to k', () => { + assert.equal(formatTokens(1000), '1k'); + assert.equal(formatTokens(156342), '156k'); + assert.equal(formatTokens(156700), '157k'); + }); + test('formats millions with one decimal', () => { + assert.equal(formatTokens(1000000), '1.0M'); + assert.equal(formatTokens(1234567), '1.2M'); + }); + test('k-to-M threshold boundary: limit-1 / limit / limit+1', () => { + // 999,999 k-rounds to 1000 — must promote to the M branch, never "1000k" + assert.equal(formatTokens(999999), '1.0M'); + assert.equal(formatTokens(1000000), '1.0M'); + assert.equal(formatTokens(1000001), '1.0M'); + // 999,499 is the last value that still k-rounds below 1000 + assert.equal(formatTokens(999499), '999k'); + assert.equal(formatTokens(999500), '1.0M'); + }); + }); + + describe('contextTokenSuffix', () => { + test('returns empty string for absent/malformed usage', () => { + assert.equal(contextTokenSuffix(null), ''); + assert.equal(contextTokenSuffix(undefined), ''); + assert.equal(contextTokenSuffix('nope'), ''); + assert.equal(contextTokenSuffix({}), ''); + }); + test('sums all four token dimensions', () => { + const suffix = contextTokenSuffix({ + input_tokens: 1000, + cache_creation_input_tokens: 2000, + cache_read_input_tokens: 150000, + output_tokens: 3000, + }); + assert.equal(suffix, ' (156k)'); + }); + test('tolerates missing dimensions', () => { + assert.equal(contextTokenSuffix({ input_tokens: 500 }), ' (500)'); + }); + }); + + describe('statusline output token suffix (e2e)', () => { + function makeProject(flag) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ctx-tokens-')); + fs.mkdirSync(path.join(dir, '.planning'), { recursive: true }); + if (flag !== undefined) { + fs.writeFileSync( + path.join(dir, '.planning', 'config.json'), + JSON.stringify({ statusline: { show_context_tokens: flag } }), + ); + } + return dir; + } + + function runHook(dir) { + const payload = JSON.stringify({ + model: { display_name: 'Claude' }, + workspace: { current_dir: dir }, + session_id: `test-tokens-${Date.now()}-${Math.random().toString(36).slice(2)}`, + context_window: { + remaining_percentage: 70, + total_tokens: 200000, + current_usage: { + input_tokens: 1000, + cache_read_input_tokens: 150000, + output_tokens: 5000, + }, + }, + }); + let stdout = ''; + try { + stdout = execFileSync(process.execPath, [hookPath], { + input: payload, encoding: 'utf8', timeout: 4000, + }); + } catch (e) { + stdout = e.stdout || ''; + } + // eslint-disable-next-line no-control-regex -- stripping ANSI SGR sequences from captured CLI output + return stdout.replace(/\x1b\[[0-9;]*m/g, ''); + } + + test('flag=true appends the token count after the percentage', () => { + const dir = makeProject(true); + try { + const out = runHook(dir); + assert.match(out, /% \(156k\)/, `expected "(156k)" after the meter %; got: ${out}`); + } finally { + cleanup(dir); + } + }); + + test('default (flag absent) meter is unchanged — no token count', () => { + const dir = makeProject(undefined); + try { + const out = runHook(dir); + assert.doesNotMatch(out, /\(\d+(?:\.\d+)?[kM]?\)/, `expected no token suffix; got: ${out}`); + } finally { + cleanup(dir); + } + }); + + test('flag=false meter is unchanged — no token count', () => { + const dir = makeProject(false); + try { + const out = runHook(dir); + assert.doesNotMatch(out, /\(\d+(?:\.\d+)?[kM]?\)/, `expected no token suffix; got: ${out}`); + } finally { + cleanup(dir); + } + }); }); }