@bastra-recall/statusline 0.9.2 → 1.0.1

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.
@@ -18,7 +18,7 @@ export function calculateBudgetPercentage(
18
18
  budget: number | undefined,
19
19
  ): number | null {
20
20
  if (!budget || budget <= 0 || cost < 0) return null;
21
- return Math.min(100, (cost / budget) * 100);
21
+ return (cost / budget) * 100;
22
22
  }
23
23
 
24
24
  export function getBudgetStatus(
@@ -71,6 +71,21 @@ export interface ClaudeHookData {
71
71
  };
72
72
  }
73
73
 
74
+ // A rate-limit window whose percentage is not a finite number carries no
75
+ // usage data; drop it so no renderer reads null as 0%.
76
+ export function dropUnreadableRateLimits(
77
+ hookData: ClaudeHookData,
78
+ ): ClaudeHookData {
79
+ const limits = hookData.rate_limits;
80
+ if (!limits) return hookData;
81
+ const readable = (w?: { used_percentage: number }) =>
82
+ typeof w?.used_percentage === "number" && Number.isFinite(w.used_percentage);
83
+ const rate_limits = { ...limits };
84
+ if (!readable(limits.five_hour)) delete rate_limits.five_hour;
85
+ if (!readable(limits.seven_day)) delete rate_limits.seven_day;
86
+ return { ...hookData, rate_limits };
87
+ }
88
+
74
89
  export function getEffortLevel(hookData: ClaudeHookData): string | null {
75
90
  const level = hookData.effort?.level;
76
91
  if (typeof level !== "string") return null;
@@ -140,6 +155,24 @@ export async function findProjectPaths(
140
155
  return projectPaths;
141
156
  }
142
157
 
158
+ // Whether the usage source itself can be read. Only then does "no entries"
159
+ // mean nothing was spent; a missing or unreadable projects directory means
160
+ // the usage is unknown.
161
+ export async function canReadProjects(): Promise<boolean> {
162
+ let readable = false;
163
+
164
+ for (const claudePath of getClaudePaths()) {
165
+ try {
166
+ await readdir(join(claudePath, "projects"));
167
+ readable = true;
168
+ } catch (error) {
169
+ if ((error as { code?: string }).code !== "ENOENT") return false;
170
+ }
171
+ }
172
+
173
+ return readable;
174
+ }
175
+
143
176
  export async function findTranscriptFile(
144
177
  sessionId: string,
145
178
  ): Promise<string | null> {
@@ -296,28 +329,34 @@ const STREAMING_THRESHOLD_BYTES = 1024 * 1024;
296
329
 
297
330
  export async function parseJsonlFile(filePath: string): Promise<ParsedEntry[]> {
298
331
  try {
299
- const stats = await stat(filePath);
300
- const fileSizeBytes = stats.size;
301
- let entries: ParsedEntry[];
302
-
303
- if (fileSizeBytes > STREAMING_THRESHOLD_BYTES) {
304
- debug(
305
- `Using streaming parser for large file ${filePath} (${Math.round(fileSizeBytes / 1024)}KB)`,
306
- );
307
- entries = await parseJsonlFileStreaming(filePath);
308
- } else {
309
- entries = await parseJsonlFileInMemory(filePath);
310
- }
311
-
312
- debug(`Parsed ${entries.length} entries from ${filePath}`);
313
-
314
- return entries;
332
+ return await readJsonlFile(filePath);
315
333
  } catch (error) {
316
334
  debug(`Failed to read file ${filePath}:`, error);
317
335
  return [];
318
336
  }
319
337
  }
320
338
 
339
+ // parseJsonlFile for callers that must tell an unreadable file from an empty
340
+ // one: a read error throws instead of yielding no entries.
341
+ export async function readJsonlFile(filePath: string): Promise<ParsedEntry[]> {
342
+ const stats = await stat(filePath);
343
+ const fileSizeBytes = stats.size;
344
+ let entries: ParsedEntry[];
345
+
346
+ if (fileSizeBytes > STREAMING_THRESHOLD_BYTES) {
347
+ debug(
348
+ `Using streaming parser for large file ${filePath} (${Math.round(fileSizeBytes / 1024)}KB)`,
349
+ );
350
+ entries = await parseJsonlFileStreaming(filePath);
351
+ } else {
352
+ entries = await parseJsonlFileInMemory(filePath);
353
+ }
354
+
355
+ debug(`Parsed ${entries.length} entries from ${filePath}`);
356
+
357
+ return entries;
358
+ }
359
+
321
360
  async function parseJsonlFileInMemory(
322
361
  filePath: string,
323
362
  ): Promise<ParsedEntry[]> {
@@ -6,13 +6,14 @@ interface TokenBreakdown {
6
6
  }
7
7
 
8
8
  export function formatCost(cost: number | null): string {
9
- if (cost === null) return "$0.00";
9
+ if (cost === null) return "--";
10
+ if (cost === 0) return "$0.00";
10
11
  if (cost < 0.01) return "<$0.01";
11
12
  return `$${cost.toFixed(2)}`;
12
13
  }
13
14
 
14
15
  export function formatTokens(tokens: number | null): string {
15
- if (tokens === null) return "0 tokens";
16
+ if (tokens === null) return "--";
16
17
  if (tokens === 0) return "0 tokens";
17
18
  if (tokens >= 1_000_000) {
18
19
  return `${(tokens / 1_000_000).toFixed(1)}M tokens`;
@@ -23,7 +24,7 @@ export function formatTokens(tokens: number | null): string {
23
24
  }
24
25
 
25
26
  export function formatTokenBreakdown(breakdown: TokenBreakdown | null): string {
26
- if (!breakdown) return "0 tokens";
27
+ if (!breakdown) return "--";
27
28
 
28
29
  const parts: string[] = [];
29
30
 
@@ -130,8 +131,14 @@ export function collapseHome(dirPath: string, homeDir?: string): string {
130
131
  homeDir ??
131
132
  globalThis.process?.env?.HOME ??
132
133
  globalThis.process?.env?.USERPROFILE;
133
- if (home && dirPath.startsWith(home)) {
134
- return dirPath.replace(home, "~");
134
+ if (
135
+ home &&
136
+ dirPath.startsWith(home) &&
137
+ (dirPath.length === home.length ||
138
+ dirPath[home.length] === "/" ||
139
+ dirPath[home.length] === "\\")
140
+ ) {
141
+ return `~${dirPath.slice(home.length)}`;
135
142
  }
136
143
  return dirPath;
137
144
  }
@@ -1,6 +1,7 @@
1
1
  export const ESC = String.fromCharCode(27);
2
2
  const ANSI_REGEX = new RegExp(`${ESC}\\[[0-9;]*m`, "g");
3
3
  export const ANSI_SPLIT = new RegExp(`(${ESC}\\[[0-9;]*m)`);
4
+ const COMBINING_MARK = /[\p{Mn}\p{Me}]/u;
4
5
 
5
6
  export function stripAnsi(str: string): string {
6
7
  return str.replace(ANSI_REGEX, "");
@@ -14,12 +15,11 @@ export function stripAnsi(str: string): string {
14
15
  */
15
16
  export function codePointWidth(cp: number): number {
16
17
  if (
17
- (cp >= 0x0300 && cp <= 0x036f) || // combining diacritics
18
- (cp >= 0x1ab0 && cp <= 0x1aff) ||
19
- (cp >= 0x1dc0 && cp <= 0x1dff) ||
20
- (cp >= 0x20d0 && cp <= 0x20ff) || // combining for symbols
21
18
  (cp >= 0xfe00 && cp <= 0xfe0f) || // variation selectors
22
- cp === 0x200b || cp === 0x200c || cp === 0x200d || cp === 0xfeff
19
+ cp === 0x200b || cp === 0x200c || cp === 0x200d || cp === 0xfeff ||
20
+ // Unicode mark categories avoid a fixed list of scripts. Mc spacing
21
+ // marks still occupy a column; Mn/Me marks do not.
22
+ (cp >= 0x0300 && cp <= 0x10ffff && COMBINING_MARK.test(String.fromCodePoint(cp)))
23
23
  ) {
24
24
  return 0;
25
25
  }