lumira 1.6.1 → 1.8.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
@@ -26,7 +26,7 @@ Interactive wizard — preset, theme, icons — previewed live before write.
26
26
 
27
27
  > 3,400+ monthly downloads, zero marketing. Try it for one session — `npx lumira install`.
28
28
 
29
- > **What's new in v1.5:** the [`lumira stats` CLI](#stats-cli) prints post-session analytics (cost, tokens, cache hit, tool frequency, burn rate). Combined with the `API N%` latency widget (v1.4.0), 7-day quota projection warning (v1.3.0), and auto-compact proximity glyph ⚠ (v1.4.1), recent releases add several diagnostic signals to the session.
29
+ > **What's new in v1.8.0:** compaction counter (`⊙ N` on line 2) tracks how many times the session has been compacted, pairing with the auto-compact proximity glyph ⚠. Togglable via `display.compactionCount`, on by default in `full`/`balanced`. Combined with the added-dirs badge and worktree breadcrumb (v1.7.0), [`lumira stats` CLI](#stats-cli) (v1.5), `API N%` latency widget (v1.4.0), 7-day quota projection warning (v1.3.0), and auto-compact proximity glyph ⚠ (v1.4.1), recent releases add several diagnostic signals to the session.
30
30
 
31
31
  ## Table of contents
32
32
 
@@ -87,7 +87,7 @@ Inspired by [claude-hud](https://github.com/jarrodwatts/claude-hud); takes a dif
87
87
  - **Config health widget** — surfaces silent fallbacks (theme/powerline degrading in named-ANSI, missing GSD STATE.md). Opt-in.
88
88
  - **Memory usage** — process RSS percentage.
89
89
  - **MCP server detection** — count of attached MCP servers per session.
90
- - **Vim-mode hint, thinking effort, worktree, output style, session name** — all togglable per-field via `display.*`.
90
+ - **Vim-mode hint, thinking effort, worktree, output style, session name, added-dirs badge, worktree origin-branch breadcrumb** — all togglable per-field via `display.*`.
91
91
  - **3-tier color system** — named ANSI / 256-color / truecolor, auto-detected.
92
92
  - **Config-driven** — every feature toggleable via JSON config + CLI flags.
93
93
 
@@ -269,6 +269,7 @@ Create `~/.config/lumira/config.json`:
269
269
  "burnRate": true,
270
270
  "duration": true,
271
271
  "tokenSpeed": true,
272
+ "apiLatency": true,
272
273
  "rateLimits": true,
273
274
  "paceDelta": true,
274
275
  "quotaProjection": true,
@@ -278,6 +279,8 @@ Create `~/.config/lumira/config.json`:
278
279
  "vim": true,
279
280
  "effort": true,
280
281
  "worktree": true,
282
+ "addedDirs": true,
283
+ "worktreeBreadcrumb": true,
281
284
  "agent": true,
282
285
  "sessionName": true,
283
286
  "style": true,
@@ -285,6 +288,7 @@ Create `~/.config/lumira/config.json`:
285
288
  "linesChanged": true,
286
289
  "memory": true,
287
290
  "agents": true,
291
+ "compactionCount": true,
288
292
  "health": false,
289
293
  "contextWarningThreshold": 70,
290
294
  "contextCriticalThreshold": 85
package/dist/config.js CHANGED
@@ -291,6 +291,9 @@ const PRESET_DEFS = {
291
291
  // surface (see burnRate/rateLimits/paceDelta etc. above). Default
292
292
  // remains true; users on full/balanced see the widget out of the box.
293
293
  apiLatency: false,
294
+ addedDirs: false,
295
+ worktreeBreadcrumb: false,
296
+ compactionCount: false,
294
297
  },
295
298
  },
296
299
  };
package/dist/normalize.js CHANGED
@@ -191,6 +191,18 @@ export function normalize(input) {
191
191
  ? sanitizeTermString(claude.effort.level)
192
192
  : undefined,
193
193
  worktreeName: input.worktree?.name ? sanitizeTermString(input.worktree.name) : undefined,
194
+ addedDirsCount: (() => {
195
+ const dirs = input.workspace?.added_dirs;
196
+ if (!Array.isArray(dirs) || dirs.length === 0)
197
+ return undefined;
198
+ return dirs.length;
199
+ })(),
200
+ worktreeOriginalBranch: (() => {
201
+ const orig = input.worktree?.original_branch;
202
+ if (!orig || typeof orig !== 'string')
203
+ return undefined;
204
+ return sanitizeTermString(orig);
205
+ })(),
194
206
  rateLimits,
195
207
  cacheHitRate,
196
208
  raw: input,
@@ -124,7 +124,7 @@ export function extractToolTarget(toolName, input) {
124
124
  // same canonicalisation and allow-list semantics. See `path.ts` for caveats
125
125
  // about the string-level (non-symlink-following) nature of the check.
126
126
  export async function parseTranscript(transcriptPath) {
127
- const result = { ...EMPTY_TRANSCRIPT, tools: [], agents: [], todos: [] };
127
+ const result = { ...EMPTY_TRANSCRIPT, tools: [], agents: [], todos: [], compactionCount: 0 };
128
128
  if (!transcriptPath || !existsSync(transcriptPath)) {
129
129
  if (log.enabled)
130
130
  log('skip — transcript path missing or nonexistent:', transcriptPath || '(empty)');
@@ -185,6 +185,8 @@ export async function parseTranscript(transcriptPath) {
185
185
  const entry = JSON.parse(line);
186
186
  if (!result.sessionStart && entry.timestamp)
187
187
  result.sessionStart = new Date(entry.timestamp);
188
+ if (entry.type === 'system' && entry.subtype === 'compact_boundary')
189
+ result.compactionCount++;
188
190
  const effortMatch = Array.isArray(entry.message?.content)
189
191
  ? entry.message.content
190
192
  .filter((b) => b.type === 'text')
@@ -0,0 +1,10 @@
1
+ export function computeBurnExtrapolation(usedPct, elapsedSec, remainingSec) {
2
+ const windowSec = elapsedSec + remainingSec;
3
+ const elapsedPct = (elapsedSec / windowSec) * 100;
4
+ const delta = usedPct - elapsedPct;
5
+ const burnRateSec = usedPct / elapsedSec;
6
+ const timeToExhaustSec = (100 - usedPct) / burnRateSec;
7
+ const willExhaustBefore = timeToExhaustSec < remainingSec;
8
+ return { burnRateSec, elapsedSec, remainingSec, delta, timeToExhaustSec, willExhaustBefore };
9
+ }
10
+ //# sourceMappingURL=burn-math.js.map
@@ -40,6 +40,18 @@ export function renderLine1(ctx, c) {
40
40
  left.push(hyperlink(pathToFileURL(cwd).href, label));
41
41
  }
42
42
  }
43
+ // Added dirs badge — only when count > 0; warning color at >= 5
44
+ if (display.addedDirs && input.addedDirsCount != null && input.addedDirsCount > 0) {
45
+ const badge = `+${input.addedDirsCount} dirs`;
46
+ left.push(input.addedDirsCount >= 5 ? c.orange(badge) : c.dim(badge));
47
+ }
48
+ // Worktree origin-branch breadcrumb — only when original_branch is present,
49
+ // there IS a current branch to contrast against (anchor), and they differ.
50
+ const branchForBreadcrumb = input.gitBranch || git.branch;
51
+ if (display.worktreeBreadcrumb && input.worktreeOriginalBranch && branchForBreadcrumb && input.worktreeOriginalBranch !== branchForBreadcrumb) {
52
+ const truncated = truncField(input.worktreeOriginalBranch, 15);
53
+ left.push(c.gray(`↳ ${truncated}`));
54
+ }
43
55
  // Duration (Claude only)
44
56
  if (display.duration && input.durationMs != null) {
45
57
  right.push(c.dim(`${icons.clock} ${formatDuration(input.durationMs)}`));
@@ -25,7 +25,7 @@ export function formatCountdown(resetsAt) {
25
25
  return `${s}s`;
26
26
  }
27
27
  export function renderLine2(ctx, c) {
28
- const { input, tokenSpeed, transcript: { thinkingEffort }, config: { display }, cols, memory, mcp, icons } = ctx;
28
+ const { input, tokenSpeed, transcript: { thinkingEffort, compactionCount }, config: { display }, cols, memory, mcp, icons } = ctx;
29
29
  const leftParts = [];
30
30
  const rightParts = [];
31
31
  // Track context slots pushed so critical rate-limit segments anchor after
@@ -56,6 +56,11 @@ export function renderLine2(ctx, c) {
56
56
  contextSlotCount++;
57
57
  }
58
58
  }
59
+ // Compaction counter — how many times this session has been compacted.
60
+ // Self-gating: renders nothing at count 0. Paired with the context bar.
61
+ if (display.compactionCount && compactionCount > 0) {
62
+ leftParts.push(c.dim(`⊙ ${compactionCount}`));
63
+ }
59
64
  // Tokens
60
65
  if (display.tokens) {
61
66
  const inTokens = input.tokens.input;
@@ -1,4 +1,5 @@
1
1
  import { debug } from '../utils/debug.js';
2
+ import { computeBurnExtrapolation } from './burn-math.js';
2
3
  const log = debug('pace');
3
4
  export function computePaceDelta(usedPercentage, resetsAt, nowSec) {
4
5
  const now = nowSec ?? Date.now() / 1000;
@@ -15,15 +16,12 @@ export function computePaceDelta(usedPercentage, resetsAt, nowSec) {
15
16
  log({ reason: 'insufficient data (<5min)', elapsedSec, remainingSec });
16
17
  return null;
17
18
  }
18
- const elapsedPct = (elapsedSec / totalWindowSec) * 100;
19
- const delta = usedPercentage - elapsedPct;
20
- let timeToExhaustion = null;
21
- if (delta > 0 && usedPercentage > 0) {
22
- // burn rate = usedPercentage / elapsedSec (pct per second)
23
- // time to exhaust remaining (100 - usedPercentage) pct at that rate
24
- timeToExhaustion = (100 - usedPercentage) / (usedPercentage / elapsedSec) / 60;
25
- }
19
+ const burn = computeBurnExtrapolation(usedPercentage, elapsedSec, remainingSec);
20
+ const timeToExhaustion = burn.delta > 0 && usedPercentage > 0
21
+ ? burn.timeToExhaustSec / 60
22
+ : null;
26
23
  if (log.enabled) {
24
+ const elapsedPct = (elapsedSec / totalWindowSec) * 100;
27
25
  log({
28
26
  usedPercentage,
29
27
  resetsAt,
@@ -33,11 +31,11 @@ export function computePaceDelta(usedPercentage, resetsAt, nowSec) {
33
31
  remainingSec: Math.round(remainingSec),
34
32
  remainingMin: Math.round(remainingSec / 60),
35
33
  elapsedPct: Math.round(elapsedPct * 10) / 10,
36
- delta: Math.round(delta * 10) / 10,
34
+ delta: Math.round(burn.delta * 10) / 10,
37
35
  timeToExhaustionMin: timeToExhaustion != null ? Math.round(timeToExhaustion) : null,
38
36
  });
39
37
  }
40
- return { delta, timeToExhaustion };
38
+ return { delta: burn.delta, timeToExhaustion };
41
39
  }
42
40
  export function formatPaceDelta(pace) {
43
41
  const rounded = Math.round(pace.delta);
@@ -11,19 +11,21 @@ import { derivePowerlinePalette, DEFAULT_POWERLINE_PALETTE, } from '../themes.js
11
11
  /**
12
12
  * Build the line1 segment list for the powerline renderer. Segment priorities
13
13
  * control which get dropped first when the terminal is narrow (lower = drops first):
14
- * model: 100 (always kept)
15
- * branch: 80
16
- * dir: 60
17
- * task: 40
18
- * duration: 35
19
- * memory: 30
20
- * tokenSpeed: 25
21
- * linesChanged: 24
22
- * worktree: 23
23
- * agent: 22
24
- * sessionName: 21
25
- * version: 20
26
- * style: 18 (dropped first)
14
+ * model: 100 (always kept)
15
+ * branch: 80
16
+ * dir: 60
17
+ * addedDirs: 59 (renders after dir; data-gated; evicts before dir under pressure)
18
+ * task: 40
19
+ * duration: 35
20
+ * memory: 30
21
+ * tokenSpeed: 25
22
+ * linesChanged: 24
23
+ * worktree: 23
24
+ * worktreeBreadcrumb: 22.5 (between worktree@23 and agent@22; data-gated)
25
+ * agent: 22
26
+ * sessionName: 21
27
+ * version: 20
28
+ * style: 18 (dropped first)
27
29
  */
28
30
  function buildSegments(ctx, palette, c) {
29
31
  const { input, git, transcript, config: { display }, icons, memory, tokenSpeed } = ctx;
@@ -77,6 +79,15 @@ function buildSegments(ctx, palette, c) {
77
79
  priority: 60,
78
80
  });
79
81
  }
82
+ if (display.addedDirs && input.addedDirsCount != null && input.addedDirsCount > 0) {
83
+ const badge = `+${input.addedDirsCount} dirs`;
84
+ const bg = input.addedDirsCount >= 5 ? palette.taskBg : palette.versionBg;
85
+ // Priority 59 — one BELOW directory@60. The badge annotates the directory,
86
+ // so it must evict before the directory under narrow-terminal pressure
87
+ // (applyPriorityEviction drops lowest-priority first). Insertion order is
88
+ // unchanged, so it still renders right after the directory.
89
+ segments.push({ text: badge, bg, fg: palette.fg, priority: 59 });
90
+ }
80
91
  const activeTask = getActiveTodo(transcript);
81
92
  if (activeTask) {
82
93
  segments.push({
@@ -136,6 +147,19 @@ function buildSegments(ctx, palette, c) {
136
147
  priority: 23,
137
148
  });
138
149
  }
150
+ if (display.worktreeBreadcrumb && input.worktreeOriginalBranch) {
151
+ const currentBranch = input.gitBranch || git.branch;
152
+ // Only render when there is a current branch to contrast against — the
153
+ // breadcrumb is meaningless without an anchor (no branch shown / branch off).
154
+ if (currentBranch && input.worktreeOriginalBranch !== currentBranch) {
155
+ segments.push({
156
+ text: `↳ ${truncField(input.worktreeOriginalBranch, 15)}`,
157
+ bg: palette.versionBg,
158
+ fg: palette.fg,
159
+ priority: 22.5,
160
+ });
161
+ }
162
+ }
139
163
  // Mirrors classic line1: prefer the explicit input.agentName (subagent
140
164
  // session render), else show the cubes badge when exactly one *named*
141
165
  // subagent is running on the parent. Anonymous agents stay collapsed
@@ -44,7 +44,7 @@ function getApiLatencyBg(pct, palette) {
44
44
  // urgency channels the user actually needs at a glance. Decision rationale
45
45
  // recorded against PR #47.
46
46
  function buildSegments(ctx, palette, c) {
47
- const { input, config: { display }, icons, mcp, transcript: { thinkingEffort } } = ctx;
47
+ const { input, config: { display }, icons, mcp, transcript: { thinkingEffort, compactionCount } } = ctx;
48
48
  const segments = [];
49
49
  // Context bar — always highest priority. plain=true so the bar cells inherit
50
50
  // the powerline segment bg; only %/icon/hint emit color escapes.
@@ -72,6 +72,11 @@ function buildSegments(ctx, palette, c) {
72
72
  segments.push({ text: `${formatTokens(used)}/${formatTokens(capacity)}`, bg: palette.dirBg, fg: palette.fg, priority: 90 });
73
73
  }
74
74
  }
75
+ // Compaction counter — self-gating at 0; priority 88 keeps it adjacent to
76
+ // the context family (contextBar=100, contextTokens=90).
77
+ if (display.compactionCount && compactionCount > 0) {
78
+ segments.push({ text: `⊙ ${compactionCount}`, bg: palette.dirBg, fg: palette.fg, priority: 88 });
79
+ }
75
80
  // 7d projection — computed once, surfaced inside the 7d segment when the
76
81
  // badge is visible (≥50%), or as a standalone segment when it isn't. Mirrors
77
82
  // classic line2: badge filter hides noise, projection surfaces signal.
@@ -1,4 +1,5 @@
1
1
  import { debug } from '../utils/debug.js';
2
+ import { computeBurnExtrapolation } from './burn-math.js';
2
3
  const log = debug('quota-projection');
3
4
  /**
4
5
  * Extrapolates current burn rate to when the quota would hit 100%.
@@ -27,21 +28,18 @@ export function computeQuotaProjection(usedPct, resetsAt, windowSec, nowSec, min
27
28
  log({ reason: 'insufficient elapsed', elapsedSec, minElapsedSec });
28
29
  return null;
29
30
  }
30
- // burnRate is in pct-per-second
31
- const burnRate = usedPct / elapsedSec;
32
- const timeToExhaustSec = (100 - usedPct) / burnRate;
33
- const willExhaustBefore = timeToExhaustSec < remainingSec;
31
+ const burn = computeBurnExtrapolation(usedPct, elapsedSec, remainingSec);
34
32
  if (log.enabled) {
35
33
  log({
36
34
  usedPct,
37
35
  elapsedSec: Math.round(elapsedSec),
38
36
  remainingSec: Math.round(remainingSec),
39
- burnRate,
40
- timeToExhaustSec: Math.round(timeToExhaustSec),
41
- willExhaustBefore,
37
+ burnRate: burn.burnRateSec,
38
+ timeToExhaustSec: Math.round(burn.timeToExhaustSec),
39
+ willExhaustBefore: burn.willExhaustBefore,
42
40
  });
43
41
  }
44
- return { timeToExhaustSec, willExhaustBefore };
42
+ return { timeToExhaustSec: burn.timeToExhaustSec, willExhaustBefore: burn.willExhaustBefore };
45
43
  }
46
44
  /**
47
45
  * Renders a projection as a short warning string (e.g. "⚠ Mon", "🔥 ~12h").
package/dist/types.js CHANGED
@@ -11,6 +11,7 @@ export const EMPTY_TRANSCRIPT = Object.freeze({
11
11
  todos: Object.freeze([]),
12
12
  thinkingEffort: '',
13
13
  sessionStart: null,
14
+ compactionCount: 0,
14
15
  });
15
16
  /** Hard cap on per-command wall time (ms). */
16
17
  export const CUSTOM_COMMAND_MAX_TIMEOUT_MS = 2000;
@@ -104,6 +105,9 @@ export const DEFAULT_DISPLAY = {
104
105
  agents: true,
105
106
  health: false,
106
107
  apiLatency: true,
108
+ addedDirs: true,
109
+ worktreeBreadcrumb: true,
110
+ compactionCount: true,
107
111
  contextWarningThreshold: DEFAULT_CONTEXT_WARNING_THRESHOLD,
108
112
  contextCriticalThreshold: DEFAULT_CONTEXT_CRITICAL_THRESHOLD,
109
113
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lumira",
3
- "version": "1.6.1",
3
+ "version": "1.8.0",
4
4
  "description": "Real-time statusline HUD for Claude Code and Qwen Code. Includes session analytics CLI, API latency overhead widget, 7d quota projection, auto-compact proximity warnings, themes, and powerline. Zero deps.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -44,7 +44,7 @@ Your job: read the user's current config, translate their natural-language reque
44
44
 
45
45
  Valid keys (anything else must be rejected):
46
46
 
47
- `model`, `branch`, `gitChanges`, `directory`, `contextBar`, `contextTokens`, `tokens`, `cost`, `burnRate`, `duration`, `tokenSpeed`, `rateLimits`, `paceDelta`, `quotaProjection`, `tools`, `todos`, `vim`, `effort`, `worktree`, `agent`, `agents`, `sessionName`, `style`, `version`, `linesChanged`, `memory`, `cacheMetrics`, `mcp`, `health`
47
+ `model`, `branch`, `gitChanges`, `directory`, `contextBar`, `contextTokens`, `tokens`, `cost`, `burnRate`, `duration`, `tokenSpeed`, `rateLimits`, `paceDelta`, `quotaProjection`, `tools`, `todos`, `vim`, `effort`, `worktree`, `agent`, `agents`, `sessionName`, `style`, `version`, `linesChanged`, `memory`, `cacheMetrics`, `mcp`, `health`, `apiLatency`, `addedDirs`, `worktreeBreadcrumb`
48
48
 
49
49
  ### Display thresholds (`display.*`, numeric)
50
50