@bastra-recall/statusline 1.0.0 → 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.
@@ -6,7 +6,6 @@ import type { BlockInfo } from "./block";
6
6
  import type { CacheTimerInfo } from "./cacheTimer";
7
7
  import type {
8
8
  UsageInfo,
9
- TokenBreakdown,
10
9
  GitInfo,
11
10
  ContextInfo,
12
11
  MetricsInfo,
@@ -17,10 +16,6 @@ import type { BastraInfo } from "./bastra";
17
16
  import {
18
17
  formatModelName,
19
18
  abbreviateFishStyle,
20
- formatCost,
21
- formatTokens,
22
- formatTokenCount,
23
- formatTokenBreakdown,
24
19
  formatTimeSince,
25
20
  formatDuration,
26
21
  formatLongTimeRemaining,
@@ -29,196 +24,67 @@ import {
29
24
  collapseHome,
30
25
  minutesUntilReset,
31
26
  } from "../utils/formatters";
32
- import { resolveBudgetDisplay } from "../utils/budget";
33
- import type { BudgetItemConfig } from "../config/loader";
34
- import { shouldShowIcon } from "../utils/icon-visibility";
35
-
36
- export interface SegmentConfig {
37
- enabled: boolean;
38
- showIcon?: boolean;
39
- }
40
-
41
- export interface DirectorySegmentConfig extends SegmentConfig {
42
- showBasename?: boolean;
43
- style?: "full" | "fish" | "basename";
44
- }
45
27
 
46
- export interface GitSegmentConfig extends SegmentConfig {
47
- showSha?: boolean;
48
- showAheadBehind?: boolean;
49
- showWorkingTree?: boolean;
50
- showOperation?: boolean;
51
- showTag?: boolean;
52
- showTimeSinceCommit?: boolean;
53
- showStashCount?: boolean;
54
- showUpstream?: boolean;
55
- showRepoName?: boolean;
56
- }
57
-
58
- export interface UsageSegmentConfig extends SegmentConfig {
59
- type: "cost" | "tokens" | "both" | "breakdown";
60
- costSource?: "calculated" | "official";
61
- /** Show the trailing "tokens" unit on token counts. Only affects `type: "tokens"` and `type: "both"` (default: true). Inert in the `tui` display style, which never renders the suffix. */
62
- showUnits?: boolean;
63
- }
64
-
65
- export interface TmuxSegmentConfig extends SegmentConfig {}
66
-
67
- export type BarDisplayStyle =
68
- | "text"
69
- | "ball"
70
- | "bar"
71
- | "blocks"
72
- | "blocks-line"
73
- | "capped"
74
- | "dots"
75
- | "filled"
76
- | "geometric"
77
- | "line"
78
- | "squares";
79
-
80
- export interface ContextSegmentConfig extends SegmentConfig {
81
- showPercentageOnly?: boolean;
82
- displayStyle?: BarDisplayStyle;
83
- autocompactBuffer?: number;
84
- percentageMode?: "remaining" | "used";
85
- }
86
-
87
- export interface MetricsSegmentConfig extends SegmentConfig {
88
- showResponseTime?: boolean;
89
- showLastResponseTime?: boolean;
90
- showDuration?: boolean;
91
- showMessageCount?: boolean;
92
- showLinesAdded?: boolean;
93
- showLinesRemoved?: boolean;
94
- }
95
-
96
- export interface BlockSegmentConfig extends SegmentConfig {
97
- type: "cost" | "tokens" | "both" | "time" | "weighted";
98
- burnType?: "cost" | "tokens" | "both" | "none";
99
- displayStyle?: BarDisplayStyle;
100
- }
101
-
102
- export interface TodaySegmentConfig extends SegmentConfig {
103
- type: "cost" | "tokens" | "both" | "breakdown";
104
- /** Show the trailing "tokens" unit on token counts. Only affects `type: "tokens"` and `type: "both"` (default: true). Inert in the `tui` display style, which never renders the suffix. */
105
- showUnits?: boolean;
106
- }
107
-
108
- export interface VersionSegmentConfig extends SegmentConfig {}
109
-
110
- export interface SessionIdSegmentConfig extends SegmentConfig {
111
- showIdLabel?: boolean;
112
- }
113
-
114
- export interface EnvSegmentConfig extends SegmentConfig {
115
- variable: string;
116
- prefix?: string;
117
- }
118
-
119
- export interface WeeklySegmentConfig extends SegmentConfig {
120
- displayStyle?: BarDisplayStyle;
121
- }
122
-
123
- export interface AgentSegmentConfig extends SegmentConfig {
124
- showLabel?: boolean;
125
- }
126
-
127
- export interface ThinkingSegmentConfig extends SegmentConfig {
128
- showEnabled?: boolean;
129
- showEffort?: boolean;
130
- }
131
-
132
- export interface CacheTimerSegmentConfig extends SegmentConfig {
133
- displayMode?: "elapsed" | "remaining";
134
- ttlSeconds?: number;
135
- }
136
-
137
- export interface BastraSegmentConfig extends SegmentConfig {}
138
-
139
- export type AnySegmentConfig =
140
- | SegmentConfig
141
- | DirectorySegmentConfig
142
- | GitSegmentConfig
143
- | UsageSegmentConfig
144
- | TmuxSegmentConfig
145
- | ContextSegmentConfig
146
- | MetricsSegmentConfig
147
- | BlockSegmentConfig
148
- | TodaySegmentConfig
149
- | VersionSegmentConfig
150
- | SessionIdSegmentConfig
151
- | EnvSegmentConfig
152
- | WeeklySegmentConfig
153
- | AgentSegmentConfig
154
- | ThinkingSegmentConfig
155
- | CacheTimerSegmentConfig
156
- | BastraSegmentConfig;
157
-
158
- export interface PowerlineSymbols {
159
- right: string;
160
- left: string;
161
- branch: string;
162
- model: string;
163
- git_clean: string;
164
- git_dirty: string;
165
- git_conflicts: string;
166
- git_ahead: string;
167
- git_behind: string;
168
- git_worktree: string;
169
- git_tag: string;
170
- git_sha: string;
171
- git_upstream: string;
172
- git_stash: string;
173
- git_time: string;
174
- session_cost: string;
175
- block_cost: string;
176
- today_cost: string;
177
- context_time: string;
178
- metrics_response: string;
179
- metrics_last_response: string;
180
- metrics_duration: string;
181
- metrics_messages: string;
182
- metrics_lines_added: string;
183
- metrics_lines_removed: string;
184
- metrics_burn: string;
185
- version: string;
186
- bar_filled: string;
187
- bar_empty: string;
188
- env: string;
189
- session_id: string;
190
- weekly_cost: string;
191
- agent: string;
192
- thinking: string;
193
- cache_timer: string;
194
- bastra: string;
195
- }
196
-
197
- export interface SegmentData {
198
- text: string;
199
- bgColor: string;
200
- fgColor: string;
201
- bold?: boolean;
202
- }
28
+ // #1039: the segment config types, the bar drawing and the shared text
29
+ // formatting live in their own modules; this class keeps one render method per
30
+ // segment and delegates to them. The types are re-exported so every import
31
+ // from "./renderer" keeps working.
32
+ import {
33
+ plural,
34
+ getDisplayDirectoryName,
35
+ formatUsageWithBudget,
36
+ } from "./renderer-format";
37
+ import {
38
+ buildBar,
39
+ resolveBarStyleDef,
40
+ formatPercentageWithBar,
41
+ } from "./renderer-bars";
203
42
 
204
- interface BarStyleDef {
205
- filled: string;
206
- empty: string;
207
- cap?: string;
208
- marker?: string;
209
- }
43
+ import { shouldShowIcon } from "../utils/icon-visibility";
210
44
 
211
- const BAR_STYLES: Record<string, BarStyleDef> = {
212
- ball: { filled: "─", empty: "─", marker: "●" },
213
- blocks: { filled: "█", empty: "░" },
214
- "blocks-line": { filled: "█", empty: "─" },
215
- capped: { filled: "━", empty: "┄", cap: "╸" },
216
- dots: { filled: "●", empty: "○" },
217
- filled: { filled: "■", empty: "□" },
218
- geometric: { filled: "▰", empty: "▱" },
219
- line: { filled: "━", empty: "┄" },
220
- squares: { filled: "◼", empty: "◻" },
221
- };
45
+ import type {
46
+ SegmentConfig,
47
+ DirectorySegmentConfig,
48
+ GitSegmentConfig,
49
+ UsageSegmentConfig,
50
+ ContextSegmentConfig,
51
+ MetricsSegmentConfig,
52
+ BlockSegmentConfig,
53
+ TodaySegmentConfig,
54
+ VersionSegmentConfig,
55
+ SessionIdSegmentConfig,
56
+ EnvSegmentConfig,
57
+ WeeklySegmentConfig,
58
+ AgentSegmentConfig,
59
+ ThinkingSegmentConfig,
60
+ CacheTimerSegmentConfig,
61
+ BastraSegmentConfig,
62
+ PowerlineSymbols,
63
+ SegmentData,
64
+ } from "./renderer-types";
65
+ export type {
66
+ SegmentConfig,
67
+ DirectorySegmentConfig,
68
+ GitSegmentConfig,
69
+ UsageSegmentConfig,
70
+ TmuxSegmentConfig,
71
+ BarDisplayStyle,
72
+ ContextSegmentConfig,
73
+ MetricsSegmentConfig,
74
+ BlockSegmentConfig,
75
+ TodaySegmentConfig,
76
+ VersionSegmentConfig,
77
+ SessionIdSegmentConfig,
78
+ EnvSegmentConfig,
79
+ WeeklySegmentConfig,
80
+ AgentSegmentConfig,
81
+ ThinkingSegmentConfig,
82
+ CacheTimerSegmentConfig,
83
+ BastraSegmentConfig,
84
+ AnySegmentConfig,
85
+ PowerlineSymbols,
86
+ SegmentData,
87
+ } from "./renderer-types";
222
88
 
223
89
  export class SegmentRenderer {
224
90
  constructor(
@@ -261,7 +127,7 @@ export class SegmentRenderer {
261
127
  ? collapseHome(projectDir)
262
128
  : projectDir;
263
129
 
264
- let dirName = this.getDisplayDirectoryName(displayDir, displayProjectDir);
130
+ let dirName = getDisplayDirectoryName(displayDir, displayProjectDir);
265
131
 
266
132
  if (style === "fish") {
267
133
  dirName = abbreviateFishStyle(dirName);
@@ -361,6 +227,8 @@ export class SegmentRenderer {
361
227
  gitStatusIcon = this.symbols.git_conflicts;
362
228
  } else if (gitInfo.status === "dirty") {
363
229
  gitStatusIcon = this.symbols.git_dirty;
230
+ } else if (gitInfo.status === "unknown") {
231
+ gitStatusIcon = "?";
364
232
  }
365
233
  parts.push(gitStatusIcon);
366
234
 
@@ -397,11 +265,12 @@ export class SegmentRenderer {
397
265
 
398
266
  const getCost = () => {
399
267
  if (costSource === "calculated") return usageInfo.session.calculatedCost;
400
- if (costSource === "official") return usageInfo.session.officialCost;
268
+ if (costSource === "official")
269
+ return usageInfo.session.officialCost ?? usageInfo.session.calculatedCost;
401
270
  return usageInfo.session.cost;
402
271
  };
403
272
 
404
- const formattedUsage = this.formatUsageWithBudget(
273
+ const formattedUsage = formatUsageWithBudget(
405
274
  getCost(),
406
275
  usageInfo.session.tokens,
407
276
  usageInfo.session.tokenBreakdown,
@@ -467,12 +336,15 @@ export class SegmentRenderer {
467
336
  const defaultMode = style === "text" ? "remaining" : "used";
468
337
  const mode = config?.percentageMode ?? defaultMode;
469
338
 
470
- const barStyleDef = this.resolveBarStyleDef(style);
339
+ const barStyleDef = resolveBarStyleDef(this.symbols, style);
471
340
 
472
341
  const emptyPct = mode === "remaining" ? "100%" : "0%";
473
342
  if (!contextInfo) {
474
343
  if (barStyleDef) {
475
- const emptyBar = barStyleDef.empty.repeat(barLength);
344
+ const emptyBar =
345
+ mode === "remaining"
346
+ ? buildBar(barStyleDef, barLength, 0, barLength)
347
+ : barStyleDef.empty.repeat(barLength);
476
348
  return {
477
349
  text: `${emptyBar} ${emptyPct}`,
478
350
  bgColor: colors.contextBg,
@@ -505,12 +377,12 @@ export class SegmentRenderer {
505
377
  ? contextInfo.contextLeftPercentage
506
378
  : contextInfo.usablePercentage;
507
379
  const filledCount = Math.round(
508
- (contextInfo.usablePercentage / 100) * barLength,
380
+ (pct / 100) * barLength,
509
381
  );
510
382
  const emptyCount = barLength - filledCount;
511
383
 
512
384
  if (barStyleDef) {
513
- const bar = this.buildBar(
385
+ const bar = buildBar(
514
386
  barStyleDef,
515
387
  filledCount,
516
388
  emptyCount,
@@ -532,61 +404,6 @@ export class SegmentRenderer {
532
404
  return { text, bgColor, fgColor, bold };
533
405
  }
534
406
 
535
- private buildBar(
536
- s: BarStyleDef,
537
- filledCount: number,
538
- emptyCount: number,
539
- barLength: number,
540
- ): string {
541
- if (s.marker) {
542
- const pos = Math.min(filledCount, barLength - 1);
543
- return (
544
- s.filled.repeat(pos) + s.marker + s.empty.repeat(barLength - pos - 1)
545
- );
546
- }
547
- if (s.cap) {
548
- if (filledCount === 0) {
549
- return s.cap + s.empty.repeat(barLength - 1);
550
- }
551
- if (filledCount >= barLength) {
552
- return s.filled.repeat(barLength);
553
- }
554
- return (
555
- s.filled.repeat(filledCount - 1) + s.cap + s.empty.repeat(emptyCount)
556
- );
557
- }
558
- return s.filled.repeat(filledCount) + s.empty.repeat(emptyCount);
559
- }
560
-
561
- private resolveBarStyleDef(style: string): BarStyleDef | null {
562
- return style === "bar"
563
- ? { filled: this.symbols.bar_filled, empty: this.symbols.bar_empty }
564
- : (BAR_STYLES[style] ?? null);
565
- }
566
-
567
- private formatPercentageWithBar(
568
- pct: number,
569
- displayStyle?: BarDisplayStyle,
570
- timeStr?: string | null,
571
- ): string {
572
- const style = displayStyle ?? "text";
573
- const barStyleDef = this.resolveBarStyleDef(style);
574
- const barLength = 10;
575
-
576
- if (barStyleDef) {
577
- const filledCount = Math.round((pct / 100) * barLength);
578
- const emptyCount = barLength - filledCount;
579
- const bar = this.buildBar(
580
- barStyleDef,
581
- filledCount,
582
- emptyCount,
583
- barLength,
584
- );
585
- return timeStr ? `${bar} ${pct}% (${timeStr})` : `${bar} ${pct}%`;
586
- }
587
- return timeStr ? `${pct}% (${timeStr})` : `${pct}%`;
588
- }
589
-
590
407
  renderMetrics(
591
408
  metricsInfo: MetricsInfo | null,
592
409
  colors: PowerlineColors,
@@ -697,7 +514,7 @@ export class SegmentRenderer {
697
514
  }
698
515
 
699
516
  return {
700
- text: `${this.leadingIcon(this.symbols.block_cost, config)}${this.formatPercentageWithBar(pct, config?.displayStyle, timeStr)}`,
517
+ text: `${this.leadingIcon(this.symbols.block_cost, config)}${formatPercentageWithBar(this.symbols, pct, config?.displayStyle, timeStr)}`,
701
518
  bgColor,
702
519
  fgColor,
703
520
  bold,
@@ -731,7 +548,7 @@ export class SegmentRenderer {
731
548
  }
732
549
 
733
550
  return {
734
- text: `${this.leadingIcon(this.symbols.weekly_cost, config)}${this.formatPercentageWithBar(pct, config?.displayStyle, timeStr)}`,
551
+ text: `${this.leadingIcon(this.symbols.weekly_cost, config)}${formatPercentageWithBar(this.symbols, pct, config?.displayStyle, timeStr)}`,
735
552
  bgColor,
736
553
  fgColor,
737
554
  bold,
@@ -749,7 +566,7 @@ export class SegmentRenderer {
749
566
  : configOrType;
750
567
  const type = config?.type ?? "cost";
751
568
  const todayBudget = this.config.budget?.today;
752
- const formattedUsage = this.formatUsageWithBudget(
569
+ const formattedUsage = formatUsageWithBudget(
753
570
  todayInfo.cost,
754
571
  todayInfo.tokens,
755
572
  todayInfo.tokenBreakdown,
@@ -769,72 +586,6 @@ export class SegmentRenderer {
769
586
  };
770
587
  }
771
588
 
772
- private getDisplayDirectoryName(
773
- currentDir: string,
774
- projectDir?: string,
775
- ): string {
776
- if (currentDir.startsWith("~")) {
777
- return currentDir;
778
- }
779
-
780
- if (projectDir && projectDir !== currentDir) {
781
- if (currentDir.startsWith(projectDir)) {
782
- const relativePath = currentDir.slice(projectDir.length + 1);
783
- return relativePath || projectDir.split(/[\\/]/).pop() || "project";
784
- }
785
- }
786
-
787
- return currentDir;
788
- }
789
-
790
- private formatUsageDisplay(
791
- cost: number | null,
792
- tokens: number | null,
793
- tokenBreakdown: TokenBreakdown | null,
794
- type: string,
795
- showUnits: boolean,
796
- ): string {
797
- const tokenStr = showUnits
798
- ? formatTokens(tokens)
799
- : formatTokenCount(tokens);
800
- switch (type) {
801
- case "cost":
802
- return formatCost(cost);
803
- case "tokens":
804
- return tokenStr;
805
- case "both":
806
- return `${formatCost(cost)} (${tokenStr})`;
807
- case "breakdown":
808
- return formatTokenBreakdown(tokenBreakdown);
809
- default:
810
- return formatCost(cost);
811
- }
812
- }
813
-
814
- private formatUsageWithBudget(
815
- cost: number | null,
816
- tokens: number | null,
817
- tokenBreakdown: TokenBreakdown | null,
818
- type: string,
819
- budget: BudgetItemConfig | undefined,
820
- showUnits: boolean,
821
- ): string | null {
822
- const state = resolveBudgetDisplay(cost, tokens, budget);
823
- if (state.suppressAll) return null;
824
- if (!state.showBase) return state.percentText;
825
-
826
- const baseDisplay = this.formatUsageDisplay(
827
- cost,
828
- tokens,
829
- tokenBreakdown,
830
- type,
831
- showUnits,
832
- );
833
- return state.percentText
834
- ? `${baseDisplay} ${state.percentText}`
835
- : baseDisplay;
836
- }
837
-
838
589
  renderVersion(
839
590
  hookData: ClaudeHookData,
840
591
  colors: PowerlineColors,
@@ -939,11 +690,12 @@ export class SegmentRenderer {
939
690
  }
940
691
 
941
692
  const text = `${iconPrefix}${formatCacheTimerElapsed(e)}`;
942
- if (e >= 300) {
693
+ const ttl = config?.ttlSeconds ?? info.detectedTtlSeconds ?? 300;
694
+ if (e >= ttl) {
943
695
  bgColor = colors.contextCriticalBg;
944
696
  fgColor = colors.contextCriticalFg;
945
697
  bold = colors.contextCriticalBold;
946
- } else if (e >= 180) {
698
+ } else if (e >= ttl * 0.6) {
947
699
  bgColor = colors.contextWarningBg;
948
700
  fgColor = colors.contextWarningFg;
949
701
  bold = colors.contextWarningBold;
@@ -973,7 +725,7 @@ export class SegmentRenderer {
973
725
  // Prefer the human-readable banter phrase; fall back to the raw stage
974
726
  // name (banter off, or older feed without current_message).
975
727
  const label = info.currentMessage ?? info.currentStage;
976
- text = `${icon}bastra · ${info.recallCount} calls · ${info.totalHits} hits · ${live}ms · ${label}`;
728
+ text = `${icon}bastra · ${plural(info.recallCount, "call", "calls")} · ${plural(info.totalHits, "hit", "hits")} · ${live}ms · ${label}`;
977
729
  } else {
978
730
  // Between/after recalls in this turn — done snapshot. The running-state
979
731
  // phrase is too brief (~120ms) for the ≥1s statusline refresh to catch,
@@ -987,9 +739,9 @@ export class SegmentRenderer {
987
739
  const tail = showPhrase ? ` · ${info.lastPhrase}` : "";
988
740
  // hits come from recalls only; ms from any tool call. Show each only
989
741
  // when present, so load_memory-only turns read "N calls · Xms" (no hits).
990
- const hits = info.totalHits > 0 ? ` · ${info.totalHits} hits` : "";
742
+ const hits = info.totalHits > 0 ? ` · ${plural(info.totalHits, "hit", "hits")}` : "";
991
743
  const ms = info.totalMs > 0 ? ` · ${info.totalMs}ms` : "";
992
- text = `${icon}✓ bastra · ${info.recallCount} calls${hits}${ms}${tail}`;
744
+ text = `${icon}✓ bastra · ${plural(info.recallCount, "call", "calls")}${hits}${ms}${tail}`;
993
745
  }
994
746
 
995
747
  return {
@@ -4,6 +4,9 @@ import {
4
4
  findTranscriptFile,
5
5
  findAgentTranscripts,
6
6
  parseJsonlFile,
7
+ readJsonlFile,
8
+ canReadProjects,
9
+ createUniqueHash,
7
10
  type ParsedEntry,
8
11
  type ClaudeHookData,
9
12
  } from "../utils/claude";
@@ -68,12 +71,14 @@ export class SessionProvider {
68
71
  const transcriptPath = await findTranscriptFile(sessionId);
69
72
  if (!transcriptPath) {
70
73
  debug(`No transcript found for session: ${sessionId}`);
71
- return null;
74
+ // A session that has not written its transcript yet has spent nothing;
75
+ // without a readable projects directory the usage is unknown.
76
+ return (await canReadProjects()) ? { totalCost: 0, entries: [] } : null;
72
77
  }
73
78
 
74
79
  debug(`Found transcript at: ${transcriptPath}`);
75
80
 
76
- const parsedEntries = await parseJsonlFile(transcriptPath);
81
+ const parsedEntries = await readJsonlFile(transcriptPath);
77
82
  const projectPath = dirname(transcriptPath);
78
83
  const agentTranscripts = await findAgentTranscripts(
79
84
  sessionId,
@@ -91,10 +96,25 @@ export class SessionProvider {
91
96
  return { totalCost: 0, entries: [] };
92
97
  }
93
98
 
99
+ const seenHashes = new Set<string>();
100
+ const dedupedEntries = parsedEntries.filter((entry) => {
101
+ const hash = createUniqueHash(entry);
102
+ if (!hash) return true;
103
+ if (seenHashes.has(hash)) return false;
104
+ seenHashes.add(hash);
105
+ return true;
106
+ });
107
+
108
+ if (dedupedEntries.length !== parsedEntries.length) {
109
+ debug(
110
+ `Session usage: dropped ${parsedEntries.length - dedupedEntries.length} duplicate entries (retries/reconnects logged twice)`,
111
+ );
112
+ }
113
+
94
114
  const entries: SessionUsageEntry[] = [];
95
115
  let totalCost = 0;
96
116
 
97
- for (const entry of parsedEntries) {
117
+ for (const entry of dedupedEntries) {
98
118
  if (entry.message?.usage) {
99
119
  const sessionEntry = convertToSessionEntry(entry);
100
120
 
@@ -142,7 +162,7 @@ export class SessionProvider {
142
162
  ): Promise<SessionInfo> {
143
163
  const sessionUsage = await this.getSessionUsage(sessionId);
144
164
 
145
- if (!sessionUsage || sessionUsage.entries.length === 0) {
165
+ if (!sessionUsage) {
146
166
  return {
147
167
  cost: null,
148
168
  calculatedCost: null,
@@ -161,7 +181,11 @@ export class SessionProvider {
161
181
 
162
182
  const calculatedCost = sessionUsage.totalCost;
163
183
  const hookDataCost = hookData?.cost?.total_cost_usd ?? null;
164
- const cost = calculatedCost ?? hookDataCost;
184
+ // Prefer Claude Code's own authoritative total_cost_usd (billed, not guessed
185
+ // from a client-side price list) whenever the hook provides it; the local
186
+ // per-token recompute is a fallback for older Claude Code versions that
187
+ // don't send `cost` at all.
188
+ const cost = hookDataCost ?? calculatedCost;
165
189
 
166
190
  return {
167
191
  cost,
@@ -4,7 +4,7 @@ import type { TokenBreakdown } from "./session";
4
4
  import { debug } from "../utils/logger";
5
5
  import { PricingService } from "./pricing";
6
6
  import { CacheManager } from "../utils/cache";
7
- import { loadEntriesFromProjects } from "../utils/claude";
7
+ import { loadEntriesFromProjects, canReadProjects } from "../utils/claude";
8
8
 
9
9
  export interface TodayUsageEntry {
10
10
  timestamp: Date;
@@ -69,7 +69,12 @@ export class TodayProvider {
69
69
  "today",
70
70
  latestMtime,
71
71
  )) as TodayUsageEntry[] | null;
72
- if (sharedCached) {
72
+ // The disk cache is keyed on transcript mtime only, so after midnight it
73
+ // still holds yesterday's entries until a transcript is touched.
74
+ if (
75
+ sharedCached &&
76
+ sharedCached.every((e) => formatDate(e.timestamp) === todayDateString)
77
+ ) {
73
78
  debug("Using shared today usage cache");
74
79
  return sharedCached;
75
80
  }
@@ -124,20 +129,13 @@ export class TodayProvider {
124
129
  return todayEntries;
125
130
  }
126
131
 
127
- private async getTodayEntries(): Promise<TodayUsageEntry[]> {
128
- try {
129
- return await this.loadTodayEntries();
130
- } catch (error) {
131
- debug("Error loading today's entries:", error);
132
- return [];
133
- }
134
- }
135
-
136
132
  async getTodayInfo(): Promise<TodayInfo> {
137
133
  try {
138
- const entries = await this.getTodayEntries();
134
+ const entries = await this.loadTodayEntries();
139
135
 
140
- if (entries.length === 0) {
136
+ // No entries is a measured zero only while the projects directory can
137
+ // be read; otherwise today's usage is unknown.
138
+ if (entries.length === 0 && !(await canReadProjects())) {
141
139
  return {
142
140
  cost: null,
143
141
  tokens: null,