claude-code-pulsify 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,6 +34,14 @@ npx claude-code-pulsify@latest --uninstall
34
34
  - **Context monitor** -- Warns at 65% and 75% context usage via PostToolUse hook
35
35
  - **Update checker** -- Background version check on session start, non-blocking
36
36
 
37
+ ## Context bar
38
+
39
+ The context bar shows **usable** context, not raw token count — it accounts for Claude's autocompact buffer (~16.5% of the window), which is reserved and unavailable to you.
40
+
41
+ - **0%** = fresh session
42
+ - **100%** = autocompact imminent (context will be compressed)
43
+ - Color thresholds: **green** (<50%) → **yellow** (50-65%) → **orange** (65-80%) → **red** (>80%)
44
+
37
45
  ## Checking your installed version
38
46
 
39
47
  ```bash
@@ -49,3 +57,8 @@ You can also check manually:
49
57
  ## Configuration
50
58
 
51
59
  Respects the `CLAUDE_CONFIG_DIR` environment variable. Defaults to `~/.claude`.
60
+
61
+ ## Future Enhancements
62
+
63
+ - Lines changed display (+added / -removed)
64
+ - Token usage counter
package/bin/install.js CHANGED
@@ -4,6 +4,8 @@
4
4
  const fs = require('node:fs')
5
5
  const path = require('node:path')
6
6
 
7
+ const { isOurEntry, upsertHookArray, removeFromHookArray } = require('../lib/install')
8
+
7
9
  const PACKAGE_VERSION = require('../package.json').version
8
10
  const HOOKS_SOURCE = path.join(__dirname, '..', 'hooks')
9
11
  const HOOK_FILES = ['statusline.js', 'context-monitor.js', 'check-update.js', 'check-update-worker.js']
@@ -33,24 +35,6 @@ function writeJSON(filePath, data) {
33
35
  fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8')
34
36
  }
35
37
 
36
- function isOurEntry(entry) {
37
- const cmd = entry?.hooks?.[0]?.command || ''
38
- return cmd.includes('claude-code-pulsify')
39
- }
40
-
41
- function upsertHookArray(arr, newEntry) {
42
- const idx = arr.findIndex(isOurEntry)
43
- if (idx >= 0) {
44
- arr[idx] = newEntry
45
- } else {
46
- arr.push(newEntry)
47
- }
48
- }
49
-
50
- function removeFromHookArray(arr) {
51
- return arr.filter((entry) => !isOurEntry(entry))
52
- }
53
-
54
38
  // --- Install ---
55
39
 
56
40
  function install() {
@@ -8,21 +8,9 @@
8
8
  */
9
9
 
10
10
  const fs = require('node:fs')
11
- const path = require('node:path')
12
11
 
13
- // Thresholds on "used percentage" (normalized, 0-100)
14
- const WARNING_THRESHOLD = 65 // remaining ~35% usable
15
- const CRITICAL_THRESHOLD = 75 // remaining ~25% usable
16
-
17
- // Debounce: minimum tool calls between repeated warnings at the same severity
18
- const DEBOUNCE_COUNT = 5
19
-
20
- // Bridge data older than this is considered stale and ignored
21
- const BRIDGE_STALE_MS = 60_000
22
-
23
- function sanitizeId(id) {
24
- return id.replace(/[^a-zA-Z0-9_-]/g, '')
25
- }
12
+ const { sanitizeId } = require('../lib/statusline')
13
+ const { BRIDGE_STALE_MS, getSeverity, shouldFireWarning } = require('../lib/context')
26
14
 
27
15
  // State file to track debounce across invocations
28
16
  function getStateFile(sessionId) {
@@ -78,20 +66,14 @@ async function main() {
78
66
  if (usedPct == null) return
79
67
 
80
68
  // Determine severity
81
- let severity = null
82
- if (usedPct >= CRITICAL_THRESHOLD) severity = 'CRITICAL'
83
- else if (usedPct >= WARNING_THRESHOLD) severity = 'WARNING'
84
-
69
+ const severity = getSeverity(usedPct)
85
70
  if (!severity) return // Context is fine
86
71
 
87
72
  // Debounce logic
88
73
  const state = readState(sessionId)
89
74
  state.callsSinceWarning = (state.callsSinceWarning || 0) + 1
90
75
 
91
- const isEscalation = severity === 'CRITICAL' && state.lastSeverity === 'WARNING'
92
- const shouldWarn = isEscalation || state.callsSinceWarning >= DEBOUNCE_COUNT
93
-
94
- if (!shouldWarn) {
76
+ if (!shouldFireWarning(state.callsSinceWarning, severity, state.lastSeverity)) {
95
77
  writeState(sessionId, state)
96
78
  return
97
79
  }
@@ -10,72 +10,10 @@
10
10
  const fs = require('node:fs')
11
11
  const path = require('node:path')
12
12
 
13
- // ANSI helpers
14
- const ESC = '\x1b['
15
- const RESET = `${ESC}0m`
16
- const BOLD = `${ESC}1m`
17
- const DIM = `${ESC}2m`
18
- const FG = (r, g, b) => `${ESC}38;2;${r};${g};${b}m`
19
-
20
- // Colors
21
- const GRAY = FG(100, 100, 100)
22
- const WHITE = FG(200, 200, 200)
23
- const GREEN = FG(80, 200, 80)
24
- const YELLOW = FG(220, 200, 60)
25
- const ORANGE = FG(230, 140, 40)
26
- const RED = FG(220, 60, 60)
27
- const CYAN = FG(80, 200, 220)
28
-
29
- const SEPARATOR = `${GRAY}\u2502${RESET}`
30
-
31
- const PROGRESS_BAR_SEGMENTS = 15
32
- const MAX_TASK_LABEL_LENGTH = 40
33
-
34
- // Claude's autocompact kicks in around 16.5% remaining context.
35
- // We normalize the bar so 0% = empty, 100% = autocompact threshold,
36
- // giving users a view of their *usable* context rather than total.
37
- const AUTOCOMPACT_BUFFER = 16.5
38
-
39
- function sanitizeId(id) {
40
- return id.replace(/[^a-zA-Z0-9_-]/g, '')
41
- }
42
-
43
- function normalizeUsage(remainingPct) {
44
- // remaining_percentage goes from 100 (empty) to 0 (full)
45
- // usable range is 100 down to ~16.5 (autocompact buffer)
46
- const usableRange = 100 - AUTOCOMPACT_BUFFER
47
- const usableRemaining = Math.max(0, remainingPct - AUTOCOMPACT_BUFFER)
48
- const usedPct = ((usableRange - usableRemaining) / usableRange) * 100
49
- return Math.min(100, Math.max(0, usedPct))
50
- }
51
-
52
- function getBarColor(usedPct) {
53
- if (usedPct < 50) return GREEN
54
- if (usedPct < 65) return YELLOW
55
- if (usedPct < 80) return ORANGE
56
- return RED
57
- }
58
-
59
- function buildProgressBar(usedPct) {
60
- const filled = Math.round((usedPct / 100) * PROGRESS_BAR_SEGMENTS)
61
- const empty = PROGRESS_BAR_SEGMENTS - filled
62
- const color = getBarColor(usedPct)
63
- const emphasis = usedPct >= 80 ? BOLD : ''
64
-
65
- const filledBar = `${emphasis}${color}${'█'.repeat(filled)}${RESET}`
66
- const emptyBar = `${GRAY}${'░'.repeat(empty)}${RESET}`
67
- const pctLabel = `${color}${BOLD}${Math.round(usedPct)}%${RESET}`
68
-
69
- return `${filledBar}${emptyBar} ${pctLabel}`
70
- }
71
-
72
- function getActiveTask(data) {
73
- const todos = data.todos || []
74
- const active = todos.find((t) => t.status === 'in_progress') || todos.find((t) => t.status === 'pending')
75
- if (!active) return null
76
- const label = active.content || active.description || ''
77
- return label.length > MAX_TASK_LABEL_LENGTH ? label.slice(0, MAX_TASK_LABEL_LENGTH - 3) + '...' : label
78
- }
13
+ const {
14
+ RESET, DIM, WHITE, CYAN, SEPARATOR,
15
+ sanitizeId, normalizeUsage, buildProgressBar, getActiveTask, formatCost,
16
+ } = require('../lib/statusline')
79
17
 
80
18
  function getUpdateIndicator() {
81
19
  const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(require('node:os').homedir(), '.claude')
@@ -114,30 +52,6 @@ function writeBridgeFile(sessionId, metrics) {
114
52
  }
115
53
  }
116
54
 
117
- function formatCost(costUsd) {
118
- if (!costUsd) return ''
119
- if (costUsd < 1) return `${WHITE}$${costUsd.toFixed(2).replace(/0+$/, '').replace(/\.$/, '')}${RESET}`
120
- const str = costUsd.toFixed(1).replace(/\.0$/, '')
121
- return `${WHITE}$${str}${RESET}`
122
- }
123
-
124
- function formatLinesChanged(added, removed) {
125
- const parts = []
126
- if (added) parts.push(`${GREEN}+${added}${RESET}`)
127
- if (removed) parts.push(`${RED}-${removed}${RESET}`)
128
- return parts.join(' ')
129
- }
130
-
131
- function formatTokenCount(input, output) {
132
- const total = (input || 0) + (output || 0)
133
- if (!total) return ''
134
- let compact
135
- if (total >= 1e6) compact = `${(total / 1e6).toFixed(1)}M`
136
- else if (total >= 1e3) compact = `${(total / 1e3).toFixed(1)}k`
137
- else compact = `${total}`
138
- return `${DIM}${compact} tok${RESET}`
139
- }
140
-
141
55
  async function main() {
142
56
  let input = ''
143
57
  await Promise.race([
@@ -179,10 +93,6 @@ async function main() {
179
93
  const remainingPct = data.context_window?.remaining_percentage ?? 100
180
94
  const sessionId = data.session?.id || data.session_id || null
181
95
  const costUsd = data.cost?.total_cost_usd ?? 0
182
- const linesAdded = data.cost?.total_lines_added ?? 0
183
- const linesRemoved = data.cost?.total_lines_removed ?? 0
184
- const totalInputTokens = data.context_window?.total_input_tokens ?? 0
185
- const totalOutputTokens = data.context_window?.total_output_tokens ?? 0
186
96
 
187
97
  // Normalize and build bar
188
98
  const usedPct = normalizeUsage(remainingPct)
@@ -207,10 +117,8 @@ async function main() {
207
117
  model,
208
118
  })
209
119
 
210
- // Format new segments
120
+ // Format cost
211
121
  const cost = formatCost(costUsd)
212
- const lines = formatLinesChanged(linesAdded, linesRemoved)
213
- const tokenCount = formatTokenCount(totalInputTokens, totalOutputTokens)
214
122
 
215
123
  // Build segments array (only include non-empty optional segments)
216
124
  const segments = [
@@ -218,9 +126,7 @@ async function main() {
218
126
  dirLabel,
219
127
  ]
220
128
  if (cost) segments.push(cost)
221
- if (lines) segments.push(lines)
222
- const barWithTokens = tokenCount ? `${bar} ${tokenCount}` : bar
223
- segments.push(barWithTokens)
129
+ segments.push(bar)
224
130
 
225
131
  // Output statusline
226
132
  const line = segments.join(` ${SEPARATOR} `) + taskSegment + updateIndicator
package/lib/context.js ADDED
@@ -0,0 +1,31 @@
1
+ 'use strict'
2
+
3
+ // Thresholds on "used percentage" (normalized, 0-100)
4
+ const WARNING_THRESHOLD = 65 // remaining ~35% usable
5
+ const CRITICAL_THRESHOLD = 75 // remaining ~25% usable
6
+
7
+ // Debounce: minimum tool calls between repeated warnings at the same severity
8
+ const DEBOUNCE_COUNT = 5
9
+
10
+ // Bridge data older than this is considered stale and ignored
11
+ const BRIDGE_STALE_MS = 60_000
12
+
13
+ function getSeverity(usedPct) {
14
+ if (usedPct >= CRITICAL_THRESHOLD) return 'CRITICAL'
15
+ if (usedPct >= WARNING_THRESHOLD) return 'WARNING'
16
+ return null
17
+ }
18
+
19
+ function shouldFireWarning(callsSinceWarning, severity, lastSeverity) {
20
+ const isEscalation = severity === 'CRITICAL' && lastSeverity === 'WARNING'
21
+ return isEscalation || callsSinceWarning >= DEBOUNCE_COUNT
22
+ }
23
+
24
+ module.exports = {
25
+ WARNING_THRESHOLD,
26
+ CRITICAL_THRESHOLD,
27
+ DEBOUNCE_COUNT,
28
+ BRIDGE_STALE_MS,
29
+ getSeverity,
30
+ shouldFireWarning,
31
+ }
package/lib/install.js ADDED
@@ -0,0 +1,21 @@
1
+ 'use strict'
2
+
3
+ function isOurEntry(entry) {
4
+ const cmd = entry?.hooks?.[0]?.command || ''
5
+ return cmd.includes('claude-code-pulsify')
6
+ }
7
+
8
+ function upsertHookArray(arr, newEntry) {
9
+ const idx = arr.findIndex(isOurEntry)
10
+ if (idx >= 0) {
11
+ arr[idx] = newEntry
12
+ } else {
13
+ arr.push(newEntry)
14
+ }
15
+ }
16
+
17
+ function removeFromHookArray(arr) {
18
+ return arr.filter((entry) => !isOurEntry(entry))
19
+ }
20
+
21
+ module.exports = { isOurEntry, upsertHookArray, removeFromHookArray }
@@ -0,0 +1,83 @@
1
+ 'use strict'
2
+
3
+ // ANSI helpers
4
+ const ESC = '\x1b['
5
+ const RESET = `${ESC}0m`
6
+ const BOLD = `${ESC}1m`
7
+ const DIM = `${ESC}2m`
8
+ const FG = (r, g, b) => `${ESC}38;2;${r};${g};${b}m`
9
+
10
+ // Colors
11
+ const GRAY = FG(100, 100, 100)
12
+ const WHITE = FG(200, 200, 200)
13
+ const GREEN = FG(80, 200, 80)
14
+ const YELLOW = FG(220, 200, 60)
15
+ const ORANGE = FG(230, 140, 40)
16
+ const RED = FG(220, 60, 60)
17
+ const CYAN = FG(80, 200, 220)
18
+
19
+ const SEPARATOR = `${GRAY}\u2502${RESET}`
20
+
21
+ const PROGRESS_BAR_SEGMENTS = 15
22
+ const MAX_TASK_LABEL_LENGTH = 40
23
+
24
+ // Claude's autocompact kicks in around 16.5% remaining context.
25
+ // We normalize the bar so 0% = empty, 100% = autocompact threshold,
26
+ // giving users a view of their *usable* context rather than total.
27
+ const AUTOCOMPACT_BUFFER = 16.5
28
+
29
+ function sanitizeId(id) {
30
+ return id.replace(/[^a-zA-Z0-9_-]/g, '')
31
+ }
32
+
33
+ function normalizeUsage(remainingPct) {
34
+ // remaining_percentage goes from 100 (empty) to 0 (full)
35
+ // usable range is 100 down to ~16.5 (autocompact buffer)
36
+ const usableRange = 100 - AUTOCOMPACT_BUFFER
37
+ const usableRemaining = Math.max(0, remainingPct - AUTOCOMPACT_BUFFER)
38
+ const usedPct = ((usableRange - usableRemaining) / usableRange) * 100
39
+ return Math.min(100, Math.max(0, usedPct))
40
+ }
41
+
42
+ function getBarColor(usedPct) {
43
+ if (usedPct < 50) return GREEN
44
+ if (usedPct < 65) return YELLOW
45
+ if (usedPct < 80) return ORANGE
46
+ return RED
47
+ }
48
+
49
+ function buildProgressBar(usedPct) {
50
+ const filled = Math.round((usedPct / 100) * PROGRESS_BAR_SEGMENTS)
51
+ const empty = PROGRESS_BAR_SEGMENTS - filled
52
+ const color = getBarColor(usedPct)
53
+ const emphasis = usedPct >= 80 ? BOLD : ''
54
+
55
+ const filledBar = `${emphasis}${color}${'█'.repeat(filled)}${RESET}`
56
+ const emptyBar = `${GRAY}${'░'.repeat(empty)}${RESET}`
57
+ const pctLabel = `${color}${BOLD}${Math.round(usedPct)}%${RESET}`
58
+
59
+ return `${filledBar}${emptyBar} ${pctLabel}`
60
+ }
61
+
62
+ function getActiveTask(data) {
63
+ const todos = data.todos || []
64
+ const active = todos.find((t) => t.status === 'in_progress') || todos.find((t) => t.status === 'pending')
65
+ if (!active) return null
66
+ const label = active.content || active.description || ''
67
+ return label.length > MAX_TASK_LABEL_LENGTH ? label.slice(0, MAX_TASK_LABEL_LENGTH - 3) + '...' : label
68
+ }
69
+
70
+ function formatCost(costUsd) {
71
+ if (!costUsd) return ''
72
+ if (costUsd < 1) return `${WHITE}$${costUsd.toFixed(2).replace(/0+$/, '').replace(/\.$/, '')}${RESET}`
73
+ const str = costUsd.toFixed(1).replace(/\.0$/, '')
74
+ return `${WHITE}$${str}${RESET}`
75
+ }
76
+
77
+ module.exports = {
78
+ // ANSI helpers
79
+ RESET, DIM, WHITE, CYAN,
80
+ SEPARATOR,
81
+ // Functions
82
+ sanitizeId, normalizeUsage, getBarColor, buildProgressBar, getActiveTask, formatCost,
83
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-pulsify",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Context-aware statusline and context monitor for Claude Code",
5
5
  "bin": {
6
6
  "claude-code-pulsify": "bin/install.js"
@@ -12,12 +12,16 @@
12
12
  "engines": {
13
13
  "node": ">=20"
14
14
  },
15
+ "scripts": {
16
+ "test": "node --test 'tests/*.test.js'"
17
+ },
15
18
  "license": "MIT",
16
19
  "publishConfig": {
17
20
  "access": "public"
18
21
  },
19
22
  "files": [
20
23
  "bin/",
21
- "hooks/"
24
+ "hooks/",
25
+ "lib/"
22
26
  ]
23
27
  }