@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.
@@ -8,7 +8,7 @@ const execAsync = promisify(exec);
8
8
 
9
9
  export interface GitInfo {
10
10
  branch: string;
11
- status: "clean" | "dirty" | "conflicts";
11
+ status: "clean" | "dirty" | "conflicts" | "unknown";
12
12
  ahead: number;
13
13
  behind: number;
14
14
  sha?: string;
@@ -356,7 +356,7 @@ export class GitService {
356
356
 
357
357
  private async getStatusWithBranchAsync(workingDir: string): Promise<{
358
358
  branch: string | null;
359
- status: "clean" | "dirty" | "conflicts";
359
+ status: "clean" | "dirty" | "conflicts" | "unknown";
360
360
  workingTree?: {
361
361
  staged: number;
362
362
  unstaged: number;
@@ -430,7 +430,7 @@ export class GitService {
430
430
  debug(`Git status with branch command failed in ${workingDir}:`, error);
431
431
  return {
432
432
  branch: await this.getFallbackBranch(workingDir),
433
- status: "clean",
433
+ status: "unknown",
434
434
  };
435
435
  }
436
436
  }
@@ -332,6 +332,60 @@ export class PricingService {
332
332
  return pricing;
333
333
  }
334
334
 
335
+ /**
336
+ * Matches against the live/cached pricing table (not the hardcoded
337
+ * OFFLINE_PRICING_DATA fallback list) by tier + major version, so a model
338
+ * released after this file was last updated (e.g. claude-opus-5-5) prices
339
+ * against its own generation instead of falling through to an older,
340
+ * differently-priced generation via the static pattern list below.
341
+ */
342
+ private static matchLiveTable(
343
+ lowerModelId: string,
344
+ allPricing: Record<string, ModelPricing>,
345
+ ): ModelPricing | null {
346
+ const tierMatch = lowerModelId.match(/(opus|sonnet|haiku)/);
347
+ const tier = tierMatch?.[1];
348
+ if (!tier) return null;
349
+
350
+ // Version parts are 1-2 digits; a date suffix (opus-4-20250514) or a
351
+ // legacy date-only id (claude-3-opus-20240229) must not parse as a version.
352
+ const versionRe = new RegExp(`${tier}-(\\d{1,2})(?:-(\\d{1,2}))?(?!\\d)`);
353
+ const targetVersion = lowerModelId.match(versionRe);
354
+ if (!targetVersion?.[1]) return null;
355
+ const targetMajor = parseInt(targetVersion[1], 10);
356
+ const targetMinor = targetVersion[2] ? parseInt(targetVersion[2], 10) : 0;
357
+
358
+ const candidates = Object.entries(allPricing)
359
+ .map(([key, pricing]) => {
360
+ const m = key.toLowerCase().match(versionRe);
361
+ if (!m?.[1]) return null;
362
+ return {
363
+ key,
364
+ pricing,
365
+ major: parseInt(m[1], 10),
366
+ minor: m[2] ? parseInt(m[2], 10) : 0,
367
+ };
368
+ })
369
+ .filter((c): c is NonNullable<typeof c> => c !== null);
370
+
371
+ if (candidates.length === 0) return null;
372
+
373
+ const sameVersion = candidates.find(
374
+ (c) => c.major === targetMajor && c.minor === targetMinor,
375
+ );
376
+ if (sameVersion) return sameVersion.pricing;
377
+
378
+ const sameMajor = candidates
379
+ .filter((c) => c.major === targetMajor)
380
+ .sort((a, b) => b.minor - a.minor);
381
+ if (sameMajor.length > 0) {
382
+ return sameMajor[0]!.pricing;
383
+ }
384
+
385
+ candidates.sort((a, b) => b.major - a.major || b.minor - a.minor);
386
+ return candidates[0]!.pricing;
387
+ }
388
+
335
389
  private static fuzzyMatchModel(
336
390
  modelId: string,
337
391
  allPricing: Record<string, ModelPricing>,
@@ -343,6 +397,12 @@ export class PricingService {
343
397
  return pricing;
344
398
  }
345
399
  }
400
+
401
+ const liveMatch = this.matchLiveTable(lowerModelId, allPricing);
402
+ if (liveMatch) {
403
+ return liveMatch;
404
+ }
405
+
346
406
  const patterns = [
347
407
  {
348
408
  pattern: ["opus-4-6", "claude-opus-4-6"],
@@ -409,6 +469,11 @@ export class PricingService {
409
469
  if (!usage) {
410
470
  return 0;
411
471
  }
472
+ const breakdown = (
473
+ usage as unknown as {
474
+ cache_creation?: { ephemeral_1h_input_tokens?: number };
475
+ }
476
+ ).cache_creation;
412
477
 
413
478
  const modelId = this.extractModelId(entry);
414
479
  const pricing = await this.getModelPricing(modelId);
@@ -421,8 +486,20 @@ export class PricingService {
421
486
  const inputCost = (inputTokens / 1_000_000) * pricing.input;
422
487
  const outputCost = (outputTokens / 1_000_000) * pricing.output;
423
488
  const cacheReadCost = (cacheReadTokens / 1_000_000) * pricing.cache_read;
489
+ // The 1h share of cache writes bills at the 1h rate; the rest at 5m.
490
+ const cacheWrite1hTokens = Math.min(
491
+ cacheCreationTokens,
492
+ breakdown?.ephemeral_1h_input_tokens || 0,
493
+ );
494
+ // The live pricing table may omit the 1h field for a model. Keep the
495
+ // estimate numeric using the known 5m rate instead of spreading NaN.
496
+ const cacheWrite1hRate = Number.isFinite(pricing.cache_write_1h)
497
+ ? pricing.cache_write_1h
498
+ : pricing.cache_write_5m;
424
499
  const cacheCreationCost =
425
- (cacheCreationTokens / 1_000_000) * pricing.cache_write_5m;
500
+ ((cacheCreationTokens - cacheWrite1hTokens) / 1_000_000) *
501
+ pricing.cache_write_5m +
502
+ (cacheWrite1hTokens / 1_000_000) * cacheWrite1hRate;
426
503
 
427
504
  return inputCost + outputCost + cacheCreationCost + cacheReadCost;
428
505
  }
@@ -0,0 +1,83 @@
1
+ // Progress-bar drawing shared by the gauge segments (context, block, weekly)
2
+ // of SegmentRenderer (#1039). Pure: the only renderer state a bar needs is the
3
+ // symbol set, passed in by the caller.
4
+
5
+ import type { BarDisplayStyle, PowerlineSymbols } from "./renderer-types";
6
+
7
+ interface BarStyleDef {
8
+ filled: string;
9
+ empty: string;
10
+ cap?: string;
11
+ marker?: string;
12
+ }
13
+
14
+ const BAR_STYLES: Record<string, BarStyleDef> = {
15
+ ball: { filled: "─", empty: "─", marker: "●" },
16
+ blocks: { filled: "█", empty: "░" },
17
+ "blocks-line": { filled: "█", empty: "─" },
18
+ capped: { filled: "━", empty: "┄", cap: "╸" },
19
+ dots: { filled: "●", empty: "○" },
20
+ filled: { filled: "■", empty: "□" },
21
+ geometric: { filled: "▰", empty: "▱" },
22
+ line: { filled: "━", empty: "┄" },
23
+ squares: { filled: "◼", empty: "◻" },
24
+ };
25
+
26
+ export function buildBar(
27
+ s: BarStyleDef,
28
+ filledCount: number,
29
+ emptyCount: number,
30
+ barLength: number,
31
+ ): string {
32
+ if (s.marker) {
33
+ const pos = Math.min(filledCount, barLength - 1);
34
+ return (
35
+ s.filled.repeat(pos) + s.marker + s.empty.repeat(barLength - pos - 1)
36
+ );
37
+ }
38
+ if (s.cap) {
39
+ if (filledCount === 0) {
40
+ return s.cap + s.empty.repeat(barLength - 1);
41
+ }
42
+ if (filledCount >= barLength) {
43
+ return s.filled.repeat(barLength);
44
+ }
45
+ return (
46
+ s.filled.repeat(filledCount - 1) + s.cap + s.empty.repeat(emptyCount)
47
+ );
48
+ }
49
+ return s.filled.repeat(filledCount) + s.empty.repeat(emptyCount);
50
+ }
51
+
52
+ export function resolveBarStyleDef(
53
+ symbols: PowerlineSymbols,
54
+ style: string,
55
+ ): BarStyleDef | null {
56
+ return style === "bar"
57
+ ? { filled: symbols.bar_filled, empty: symbols.bar_empty }
58
+ : (BAR_STYLES[style] ?? null);
59
+ }
60
+
61
+ export function formatPercentageWithBar(
62
+ symbols: PowerlineSymbols,
63
+ pct: number,
64
+ displayStyle?: BarDisplayStyle,
65
+ timeStr?: string | null,
66
+ ): string {
67
+ const style = displayStyle ?? "text";
68
+ const barStyleDef = resolveBarStyleDef(symbols, style);
69
+ const barLength = 10;
70
+
71
+ if (barStyleDef) {
72
+ const filledCount = Math.round((pct / 100) * barLength);
73
+ const emptyCount = barLength - filledCount;
74
+ const bar = buildBar(
75
+ barStyleDef,
76
+ filledCount,
77
+ emptyCount,
78
+ barLength,
79
+ );
80
+ return timeStr ? `${bar} ${pct}% (${timeStr})` : `${bar} ${pct}%`;
81
+ }
82
+ return timeStr ? `${pct}% (${timeStr})` : `${pct}%`;
83
+ }
@@ -0,0 +1,89 @@
1
+ // Text formatting shared by the SegmentRenderer segments (#1039): counter
2
+ // plurals, the project-relative directory name and the usage/budget display
3
+ // of the session and today segments. Pure functions, no renderer state.
4
+
5
+ import type { TokenBreakdown } from ".";
6
+ import type { BudgetItemConfig } from "../config/loader";
7
+ import {
8
+ formatCost,
9
+ formatTokens,
10
+ formatTokenCount,
11
+ formatTokenBreakdown,
12
+ } from "../utils/formatters";
13
+ import { resolveBudgetDisplay } from "../utils/budget";
14
+
15
+ /** "1 call" / "2 calls" — the counter is visible in every session, so the
16
+ * singular case must not read as a typo. Same for hits. */
17
+ export function plural(n: number, one: string, many: string): string {
18
+ return `${n} ${n === 1 ? one : many}`;
19
+ }
20
+
21
+ export function getDisplayDirectoryName(
22
+ currentDir: string,
23
+ projectDir?: string,
24
+ ): string {
25
+ if (currentDir.startsWith("~")) {
26
+ return currentDir;
27
+ }
28
+
29
+ if (projectDir && projectDir !== currentDir) {
30
+ const base = projectDir.replace(/[\\/]+$/, "");
31
+ if (
32
+ currentDir.startsWith(base) &&
33
+ /[\\/]/.test(currentDir.charAt(base.length))
34
+ ) {
35
+ const relativePath = currentDir.slice(base.length + 1);
36
+ return relativePath || projectDir.split(/[\\/]/).pop() || "project";
37
+ }
38
+ }
39
+
40
+ return currentDir;
41
+ }
42
+
43
+ export function formatUsageDisplay(
44
+ cost: number | null,
45
+ tokens: number | null,
46
+ tokenBreakdown: TokenBreakdown | null,
47
+ type: string,
48
+ showUnits: boolean,
49
+ ): string {
50
+ const tokenStr = showUnits
51
+ ? formatTokens(tokens)
52
+ : formatTokenCount(tokens);
53
+ switch (type) {
54
+ case "cost":
55
+ return formatCost(cost);
56
+ case "tokens":
57
+ return tokenStr;
58
+ case "both":
59
+ return `${formatCost(cost)} (${tokenStr})`;
60
+ case "breakdown":
61
+ return formatTokenBreakdown(tokenBreakdown);
62
+ default:
63
+ return formatCost(cost);
64
+ }
65
+ }
66
+
67
+ export function formatUsageWithBudget(
68
+ cost: number | null,
69
+ tokens: number | null,
70
+ tokenBreakdown: TokenBreakdown | null,
71
+ type: string,
72
+ budget: BudgetItemConfig | undefined,
73
+ showUnits: boolean,
74
+ ): string | null {
75
+ const state = resolveBudgetDisplay(cost, tokens, budget);
76
+ if (state.suppressAll) return null;
77
+ if (!state.showBase) return state.percentText;
78
+
79
+ const baseDisplay = formatUsageDisplay(
80
+ cost,
81
+ tokens,
82
+ tokenBreakdown,
83
+ type,
84
+ showUnits,
85
+ );
86
+ return state.percentText
87
+ ? `${baseDisplay} ${state.percentText}`
88
+ : baseDisplay;
89
+ }
@@ -0,0 +1,171 @@
1
+ // Segment configuration and output contract of SegmentRenderer (#1039).
2
+ // Re-exported unchanged from ./renderer, which is the import path every caller
3
+ // (config loader, powerline, browser bundle) keeps using.
4
+
5
+ export interface SegmentConfig {
6
+ enabled: boolean;
7
+ showIcon?: boolean;
8
+ }
9
+
10
+ export interface DirectorySegmentConfig extends SegmentConfig {
11
+ showBasename?: boolean;
12
+ style?: "full" | "fish" | "basename";
13
+ }
14
+
15
+ export interface GitSegmentConfig extends SegmentConfig {
16
+ showSha?: boolean;
17
+ showAheadBehind?: boolean;
18
+ showWorkingTree?: boolean;
19
+ showOperation?: boolean;
20
+ showTag?: boolean;
21
+ showTimeSinceCommit?: boolean;
22
+ showStashCount?: boolean;
23
+ showUpstream?: boolean;
24
+ showRepoName?: boolean;
25
+ }
26
+
27
+ export interface UsageSegmentConfig extends SegmentConfig {
28
+ type: "cost" | "tokens" | "both" | "breakdown";
29
+ costSource?: "calculated" | "official";
30
+ /** 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. */
31
+ showUnits?: boolean;
32
+ }
33
+
34
+ export interface TmuxSegmentConfig extends SegmentConfig {}
35
+
36
+ export type BarDisplayStyle =
37
+ | "text"
38
+ | "ball"
39
+ | "bar"
40
+ | "blocks"
41
+ | "blocks-line"
42
+ | "capped"
43
+ | "dots"
44
+ | "filled"
45
+ | "geometric"
46
+ | "line"
47
+ | "squares";
48
+
49
+ export interface ContextSegmentConfig extends SegmentConfig {
50
+ showPercentageOnly?: boolean;
51
+ displayStyle?: BarDisplayStyle;
52
+ autocompactBuffer?: number;
53
+ percentageMode?: "remaining" | "used";
54
+ }
55
+
56
+ export interface MetricsSegmentConfig extends SegmentConfig {
57
+ showResponseTime?: boolean;
58
+ showLastResponseTime?: boolean;
59
+ showDuration?: boolean;
60
+ showMessageCount?: boolean;
61
+ showLinesAdded?: boolean;
62
+ showLinesRemoved?: boolean;
63
+ }
64
+
65
+ export interface BlockSegmentConfig extends SegmentConfig {
66
+ type: "cost" | "tokens" | "both" | "time" | "weighted";
67
+ burnType?: "cost" | "tokens" | "both" | "none";
68
+ displayStyle?: BarDisplayStyle;
69
+ }
70
+
71
+ export interface TodaySegmentConfig extends SegmentConfig {
72
+ type: "cost" | "tokens" | "both" | "breakdown";
73
+ /** 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. */
74
+ showUnits?: boolean;
75
+ }
76
+
77
+ export interface VersionSegmentConfig extends SegmentConfig {}
78
+
79
+ export interface SessionIdSegmentConfig extends SegmentConfig {
80
+ showIdLabel?: boolean;
81
+ }
82
+
83
+ export interface EnvSegmentConfig extends SegmentConfig {
84
+ variable: string;
85
+ prefix?: string;
86
+ }
87
+
88
+ export interface WeeklySegmentConfig extends SegmentConfig {
89
+ displayStyle?: BarDisplayStyle;
90
+ }
91
+
92
+ export interface AgentSegmentConfig extends SegmentConfig {
93
+ showLabel?: boolean;
94
+ }
95
+
96
+ export interface ThinkingSegmentConfig extends SegmentConfig {
97
+ showEnabled?: boolean;
98
+ showEffort?: boolean;
99
+ }
100
+
101
+ export interface CacheTimerSegmentConfig extends SegmentConfig {
102
+ displayMode?: "elapsed" | "remaining";
103
+ ttlSeconds?: number;
104
+ }
105
+
106
+ export interface BastraSegmentConfig extends SegmentConfig {}
107
+
108
+ export type AnySegmentConfig =
109
+ | SegmentConfig
110
+ | DirectorySegmentConfig
111
+ | GitSegmentConfig
112
+ | UsageSegmentConfig
113
+ | TmuxSegmentConfig
114
+ | ContextSegmentConfig
115
+ | MetricsSegmentConfig
116
+ | BlockSegmentConfig
117
+ | TodaySegmentConfig
118
+ | VersionSegmentConfig
119
+ | SessionIdSegmentConfig
120
+ | EnvSegmentConfig
121
+ | WeeklySegmentConfig
122
+ | AgentSegmentConfig
123
+ | ThinkingSegmentConfig
124
+ | CacheTimerSegmentConfig
125
+ | BastraSegmentConfig;
126
+
127
+ export interface PowerlineSymbols {
128
+ right: string;
129
+ left: string;
130
+ branch: string;
131
+ model: string;
132
+ git_clean: string;
133
+ git_dirty: string;
134
+ git_conflicts: string;
135
+ git_ahead: string;
136
+ git_behind: string;
137
+ git_worktree: string;
138
+ git_tag: string;
139
+ git_sha: string;
140
+ git_upstream: string;
141
+ git_stash: string;
142
+ git_time: string;
143
+ session_cost: string;
144
+ block_cost: string;
145
+ today_cost: string;
146
+ context_time: string;
147
+ metrics_response: string;
148
+ metrics_last_response: string;
149
+ metrics_duration: string;
150
+ metrics_messages: string;
151
+ metrics_lines_added: string;
152
+ metrics_lines_removed: string;
153
+ metrics_burn: string;
154
+ version: string;
155
+ bar_filled: string;
156
+ bar_empty: string;
157
+ env: string;
158
+ session_id: string;
159
+ weekly_cost: string;
160
+ agent: string;
161
+ thinking: string;
162
+ cache_timer: string;
163
+ bastra: string;
164
+ }
165
+
166
+ export interface SegmentData {
167
+ text: string;
168
+ bgColor: string;
169
+ fgColor: string;
170
+ bold?: boolean;
171
+ }