@bastra-recall/statusline 0.7.0-beta.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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +26 -0
  3. package/bin/claude-powerline +2 -0
  4. package/dist/browser.d.ts +796 -0
  5. package/dist/browser.js +3 -0
  6. package/dist/index.mjs +48 -0
  7. package/package.json +39 -0
  8. package/src/browser.ts +208 -0
  9. package/src/config/defaults.ts +88 -0
  10. package/src/config/loader.ts +473 -0
  11. package/src/index.ts +90 -0
  12. package/src/powerline.ts +1082 -0
  13. package/src/segments/bastra.ts +123 -0
  14. package/src/segments/block.ts +31 -0
  15. package/src/segments/cacheTimer.ts +108 -0
  16. package/src/segments/context.ts +221 -0
  17. package/src/segments/git.ts +498 -0
  18. package/src/segments/index.ts +33 -0
  19. package/src/segments/metrics.ts +175 -0
  20. package/src/segments/pricing.ts +454 -0
  21. package/src/segments/renderer.ts +1002 -0
  22. package/src/segments/session.ts +207 -0
  23. package/src/segments/tmux.ts +35 -0
  24. package/src/segments/today.ts +191 -0
  25. package/src/themes/dark.ts +61 -0
  26. package/src/themes/gruvbox.ts +61 -0
  27. package/src/themes/index.ts +161 -0
  28. package/src/themes/light.ts +61 -0
  29. package/src/themes/nord.ts +61 -0
  30. package/src/themes/rose-pine.ts +61 -0
  31. package/src/themes/tokyo-night.ts +61 -0
  32. package/src/tui/grid.ts +712 -0
  33. package/src/tui/index.ts +4 -0
  34. package/src/tui/layouts.ts +321 -0
  35. package/src/tui/primitives.ts +187 -0
  36. package/src/tui/renderer.ts +206 -0
  37. package/src/tui/sections.ts +1511 -0
  38. package/src/tui/types.ts +189 -0
  39. package/src/utils/budget.ts +116 -0
  40. package/src/utils/cache.ts +247 -0
  41. package/src/utils/claude.ts +518 -0
  42. package/src/utils/color-support.ts +118 -0
  43. package/src/utils/colors.ts +120 -0
  44. package/src/utils/constants.ts +184 -0
  45. package/src/utils/formatters.ts +175 -0
  46. package/src/utils/icon-visibility.ts +31 -0
  47. package/src/utils/logger.ts +5 -0
  48. package/src/utils/terminal-width.ts +117 -0
  49. package/src/utils/terminal.ts +54 -0
@@ -0,0 +1,1002 @@
1
+ import type { ClaudeHookData } from "../utils/claude";
2
+ import { getEffortLevel, getThinkingEnabled } from "../utils/claude";
3
+ import type { PowerlineColors } from "../themes";
4
+ import type { PowerlineConfig } from "../config/loader";
5
+ import type { BlockInfo } from "./block";
6
+ import type { CacheTimerInfo } from "./cacheTimer";
7
+ import type {
8
+ UsageInfo,
9
+ TokenBreakdown,
10
+ GitInfo,
11
+ ContextInfo,
12
+ MetricsInfo,
13
+ } from ".";
14
+ import type { TodayInfo } from "./today";
15
+ import type { BastraInfo } from "./bastra";
16
+
17
+ import {
18
+ formatModelName,
19
+ abbreviateFishStyle,
20
+ formatCost,
21
+ formatTokens,
22
+ formatTokenCount,
23
+ formatTokenBreakdown,
24
+ formatTimeSince,
25
+ formatDuration,
26
+ formatLongTimeRemaining,
27
+ formatCacheTimerElapsed,
28
+ formatCacheTimerRemaining,
29
+ collapseHome,
30
+ minutesUntilReset,
31
+ } 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
+
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
+ }
203
+
204
+ interface BarStyleDef {
205
+ filled: string;
206
+ empty: string;
207
+ cap?: string;
208
+ marker?: string;
209
+ }
210
+
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
+ };
222
+
223
+ export class SegmentRenderer {
224
+ constructor(
225
+ private readonly config: PowerlineConfig,
226
+ private readonly symbols: PowerlineSymbols,
227
+ ) {}
228
+
229
+ private leadingIcon(symbol: string, segConfig?: SegmentConfig): string {
230
+ const show = shouldShowIcon(
231
+ this.config.display?.showIcons,
232
+ segConfig?.showIcon,
233
+ );
234
+ return show ? `${symbol} ` : "";
235
+ }
236
+
237
+ renderDirectory(
238
+ hookData: ClaudeHookData,
239
+ colors: PowerlineColors,
240
+ config?: DirectorySegmentConfig,
241
+ ): SegmentData {
242
+ const worktreeOriginalCwd = hookData.worktree?.original_cwd || undefined;
243
+ const currentDir =
244
+ worktreeOriginalCwd ??
245
+ (hookData.workspace?.current_dir || hookData.cwd || "/");
246
+ const projectDir = worktreeOriginalCwd ?? hookData.workspace?.project_dir;
247
+
248
+ const style = config?.style ?? (config?.showBasename ? "basename" : "full");
249
+
250
+ if (style === "basename") {
251
+ const basename = currentDir.split(/[\\/]/).pop() || "root";
252
+ return {
253
+ text: basename,
254
+ bgColor: colors.modeBg,
255
+ fgColor: colors.modeFg,
256
+ };
257
+ }
258
+
259
+ const displayDir = collapseHome(currentDir);
260
+ const displayProjectDir = projectDir
261
+ ? collapseHome(projectDir)
262
+ : projectDir;
263
+
264
+ let dirName = this.getDisplayDirectoryName(displayDir, displayProjectDir);
265
+
266
+ if (style === "fish") {
267
+ dirName = abbreviateFishStyle(dirName);
268
+ }
269
+
270
+ return {
271
+ text: dirName,
272
+ bgColor: colors.modeBg,
273
+ fgColor: colors.modeFg,
274
+ };
275
+ }
276
+
277
+ renderGit(
278
+ gitInfo: GitInfo,
279
+ colors: PowerlineColors,
280
+ config?: GitSegmentConfig,
281
+ ): SegmentData | null {
282
+ if (!gitInfo) return null;
283
+
284
+ const parts: string[] = [];
285
+
286
+ if (config?.showRepoName && gitInfo.repoName) {
287
+ parts.push(gitInfo.repoName);
288
+ if (gitInfo.isWorktree) {
289
+ parts.push(this.symbols.git_worktree);
290
+ }
291
+ }
292
+
293
+ if (config?.showOperation && gitInfo.operation) {
294
+ parts.push(`[${gitInfo.operation}]`);
295
+ }
296
+
297
+ const showBranchIcon = shouldShowIcon(
298
+ this.config.display?.showIcons,
299
+ config?.showIcon,
300
+ );
301
+ parts.push(
302
+ showBranchIcon
303
+ ? `${this.symbols.branch} ${gitInfo.branch}`
304
+ : gitInfo.branch,
305
+ );
306
+
307
+ if (config?.showTag && gitInfo.tag) {
308
+ parts.push(`${this.symbols.git_tag} ${gitInfo.tag}`);
309
+ }
310
+
311
+ if (config?.showSha && gitInfo.sha) {
312
+ parts.push(`${this.symbols.git_sha} ${gitInfo.sha}`);
313
+ }
314
+
315
+ if (config?.showAheadBehind !== false) {
316
+ if (gitInfo.ahead > 0 && gitInfo.behind > 0) {
317
+ parts.push(
318
+ `${this.symbols.git_ahead}${gitInfo.ahead}${this.symbols.git_behind}${gitInfo.behind}`,
319
+ );
320
+ } else if (gitInfo.ahead > 0) {
321
+ parts.push(`${this.symbols.git_ahead}${gitInfo.ahead}`);
322
+ } else if (gitInfo.behind > 0) {
323
+ parts.push(`${this.symbols.git_behind}${gitInfo.behind}`);
324
+ }
325
+ }
326
+
327
+ if (config?.showWorkingTree) {
328
+ const counts: string[] = [];
329
+ if (gitInfo.staged && gitInfo.staged > 0)
330
+ counts.push(`+${gitInfo.staged}`);
331
+ if (gitInfo.unstaged && gitInfo.unstaged > 0)
332
+ counts.push(`~${gitInfo.unstaged}`);
333
+ if (gitInfo.untracked && gitInfo.untracked > 0)
334
+ counts.push(`?${gitInfo.untracked}`);
335
+ if (gitInfo.conflicts && gitInfo.conflicts > 0)
336
+ counts.push(`!${gitInfo.conflicts}`);
337
+ if (counts.length > 0) {
338
+ parts.push(`(${counts.join(" ")})`);
339
+ }
340
+ }
341
+
342
+ if (config?.showUpstream && gitInfo.upstream) {
343
+ parts.push(`${this.symbols.git_upstream}${gitInfo.upstream}`);
344
+ }
345
+
346
+ if (
347
+ config?.showStashCount &&
348
+ gitInfo.stashCount &&
349
+ gitInfo.stashCount > 0
350
+ ) {
351
+ parts.push(`${this.symbols.git_stash} ${gitInfo.stashCount}`);
352
+ }
353
+
354
+ if (config?.showTimeSinceCommit && gitInfo.timeSinceCommit !== undefined) {
355
+ const time = formatTimeSince(gitInfo.timeSinceCommit);
356
+ parts.push(`${this.symbols.git_time} ${time}`);
357
+ }
358
+
359
+ let gitStatusIcon = this.symbols.git_clean;
360
+ if (gitInfo.status === "conflicts") {
361
+ gitStatusIcon = this.symbols.git_conflicts;
362
+ } else if (gitInfo.status === "dirty") {
363
+ gitStatusIcon = this.symbols.git_dirty;
364
+ }
365
+ parts.push(gitStatusIcon);
366
+
367
+ return {
368
+ text: parts.join(" "),
369
+ bgColor: colors.gitBg,
370
+ fgColor: colors.gitFg,
371
+ };
372
+ }
373
+
374
+ renderModel(
375
+ hookData: ClaudeHookData,
376
+ colors: PowerlineColors,
377
+ config?: SegmentConfig,
378
+ ): SegmentData {
379
+ const rawName = hookData.model?.display_name || "Claude";
380
+ const modelName = formatModelName(rawName);
381
+
382
+ return {
383
+ text: `${this.leadingIcon(this.symbols.model, config)}${modelName}`,
384
+ bgColor: colors.modelBg,
385
+ fgColor: colors.modelFg,
386
+ };
387
+ }
388
+
389
+ renderSession(
390
+ usageInfo: UsageInfo,
391
+ colors: PowerlineColors,
392
+ config?: UsageSegmentConfig,
393
+ ): SegmentData | null {
394
+ const type = config?.type || "cost";
395
+ const costSource = config?.costSource;
396
+ const sessionBudget = this.config.budget?.session;
397
+
398
+ const getCost = () => {
399
+ if (costSource === "calculated") return usageInfo.session.calculatedCost;
400
+ if (costSource === "official") return usageInfo.session.officialCost;
401
+ return usageInfo.session.cost;
402
+ };
403
+
404
+ const formattedUsage = this.formatUsageWithBudget(
405
+ getCost(),
406
+ usageInfo.session.tokens,
407
+ usageInfo.session.tokenBreakdown,
408
+ type,
409
+ sessionBudget,
410
+ config?.showUnits ?? true,
411
+ );
412
+
413
+ if (formattedUsage === null) return null;
414
+
415
+ const text = `${this.leadingIcon(this.symbols.session_cost, config)}${formattedUsage}`;
416
+
417
+ return {
418
+ text,
419
+ bgColor: colors.sessionBg,
420
+ fgColor: colors.sessionFg,
421
+ };
422
+ }
423
+
424
+ renderSessionId(
425
+ sessionId: string,
426
+ colors: PowerlineColors,
427
+ config?: SessionIdSegmentConfig,
428
+ ): SegmentData {
429
+ const showLabel = config?.showIdLabel !== false;
430
+ const text = showLabel
431
+ ? `${this.leadingIcon(this.symbols.session_id, config)}${sessionId}`
432
+ : sessionId;
433
+
434
+ return {
435
+ text,
436
+ bgColor: colors.sessionBg,
437
+ fgColor: colors.sessionFg,
438
+ };
439
+ }
440
+
441
+ renderTmux(
442
+ sessionId: string | null,
443
+ colors: PowerlineColors,
444
+ ): SegmentData | null {
445
+ if (!sessionId) {
446
+ return {
447
+ text: `tmux:none`,
448
+ bgColor: colors.tmuxBg,
449
+ fgColor: colors.tmuxFg,
450
+ };
451
+ }
452
+
453
+ return {
454
+ text: `tmux:${sessionId}`,
455
+ bgColor: colors.tmuxBg,
456
+ fgColor: colors.tmuxFg,
457
+ };
458
+ }
459
+
460
+ renderContext(
461
+ contextInfo: ContextInfo | null,
462
+ colors: PowerlineColors,
463
+ config?: ContextSegmentConfig,
464
+ ): SegmentData | null {
465
+ const barLength = 10;
466
+ const style = config?.displayStyle ?? "text";
467
+ const defaultMode = style === "text" ? "remaining" : "used";
468
+ const mode = config?.percentageMode ?? defaultMode;
469
+
470
+ const barStyleDef = this.resolveBarStyleDef(style);
471
+
472
+ const emptyPct = mode === "remaining" ? "100%" : "0%";
473
+ if (!contextInfo) {
474
+ if (barStyleDef) {
475
+ const emptyBar = barStyleDef.empty.repeat(barLength);
476
+ return {
477
+ text: `${emptyBar} ${emptyPct}`,
478
+ bgColor: colors.contextBg,
479
+ fgColor: colors.contextFg,
480
+ };
481
+ }
482
+ return {
483
+ text: `${this.leadingIcon(this.symbols.context_time, config)}0 (${emptyPct})`,
484
+ bgColor: colors.contextBg,
485
+ fgColor: colors.contextFg,
486
+ };
487
+ }
488
+
489
+ let bgColor = colors.contextBg;
490
+ let fgColor = colors.contextFg;
491
+ let bold = colors.contextBold;
492
+
493
+ if (contextInfo.contextLeftPercentage <= 20) {
494
+ bgColor = colors.contextCriticalBg;
495
+ fgColor = colors.contextCriticalFg;
496
+ bold = colors.contextCriticalBold;
497
+ } else if (contextInfo.contextLeftPercentage <= 40) {
498
+ bgColor = colors.contextWarningBg;
499
+ fgColor = colors.contextWarningFg;
500
+ bold = colors.contextWarningBold;
501
+ }
502
+
503
+ const pct =
504
+ mode === "remaining"
505
+ ? contextInfo.contextLeftPercentage
506
+ : contextInfo.usablePercentage;
507
+ const filledCount = Math.round(
508
+ (contextInfo.usablePercentage / 100) * barLength,
509
+ );
510
+ const emptyCount = barLength - filledCount;
511
+
512
+ if (barStyleDef) {
513
+ const bar = this.buildBar(
514
+ barStyleDef,
515
+ filledCount,
516
+ emptyCount,
517
+ barLength,
518
+ );
519
+
520
+ const text = config?.showPercentageOnly
521
+ ? `${bar} ${pct}%`
522
+ : `${bar} ${contextInfo.totalTokens.toLocaleString()} (${pct}%)`;
523
+
524
+ return { text, bgColor, fgColor, bold };
525
+ }
526
+
527
+ const iconPrefix = this.leadingIcon(this.symbols.context_time, config);
528
+ const text = config?.showPercentageOnly
529
+ ? `${iconPrefix}${pct}%`
530
+ : `${iconPrefix}${contextInfo.totalTokens.toLocaleString()} (${pct}%)`;
531
+
532
+ return { text, bgColor, fgColor, bold };
533
+ }
534
+
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
+ renderMetrics(
591
+ metricsInfo: MetricsInfo | null,
592
+ colors: PowerlineColors,
593
+ config?: MetricsSegmentConfig,
594
+ ): SegmentData | null {
595
+ if (!metricsInfo) {
596
+ return {
597
+ text: `${this.symbols.metrics_response} new`,
598
+ bgColor: colors.metricsBg,
599
+ fgColor: colors.metricsFg,
600
+ };
601
+ }
602
+
603
+ const parts: string[] = [];
604
+
605
+ if (config?.showLastResponseTime && metricsInfo.lastResponseTime !== null) {
606
+ const lastResponseTime =
607
+ metricsInfo.lastResponseTime < 60
608
+ ? `${metricsInfo.lastResponseTime.toFixed(1)}s`
609
+ : `${(metricsInfo.lastResponseTime / 60).toFixed(1)}m`;
610
+ parts.push(`${this.symbols.metrics_last_response} ${lastResponseTime}`);
611
+ }
612
+
613
+ if (
614
+ config?.showResponseTime !== false &&
615
+ metricsInfo.responseTime !== null
616
+ ) {
617
+ const responseTime =
618
+ metricsInfo.responseTime < 60
619
+ ? `${metricsInfo.responseTime.toFixed(1)}s`
620
+ : `${(metricsInfo.responseTime / 60).toFixed(1)}m`;
621
+ parts.push(`${this.symbols.metrics_response} ${responseTime}`);
622
+ }
623
+
624
+ if (
625
+ config?.showDuration !== false &&
626
+ metricsInfo.sessionDuration !== null
627
+ ) {
628
+ const duration = formatDuration(metricsInfo.sessionDuration);
629
+ parts.push(`${this.symbols.metrics_duration} ${duration}`);
630
+ }
631
+
632
+ if (
633
+ config?.showMessageCount !== false &&
634
+ metricsInfo.messageCount !== null
635
+ ) {
636
+ parts.push(
637
+ `${this.symbols.metrics_messages} ${metricsInfo.messageCount}`,
638
+ );
639
+ }
640
+
641
+ if (
642
+ config?.showLinesAdded !== false &&
643
+ metricsInfo.linesAdded !== null &&
644
+ metricsInfo.linesAdded > 0
645
+ ) {
646
+ parts.push(
647
+ `${this.symbols.metrics_lines_added} ${metricsInfo.linesAdded}`,
648
+ );
649
+ }
650
+
651
+ if (
652
+ config?.showLinesRemoved !== false &&
653
+ metricsInfo.linesRemoved !== null &&
654
+ metricsInfo.linesRemoved > 0
655
+ ) {
656
+ parts.push(
657
+ `${this.symbols.metrics_lines_removed} ${metricsInfo.linesRemoved}`,
658
+ );
659
+ }
660
+
661
+ if (parts.length === 0) {
662
+ return {
663
+ text: `${this.symbols.metrics_response} active`,
664
+ bgColor: colors.metricsBg,
665
+ fgColor: colors.metricsFg,
666
+ };
667
+ }
668
+
669
+ return {
670
+ text: parts.join(" "),
671
+ bgColor: colors.metricsBg,
672
+ fgColor: colors.metricsFg,
673
+ };
674
+ }
675
+
676
+ renderBlock(
677
+ blockInfo: BlockInfo,
678
+ colors: PowerlineColors,
679
+ config?: BlockSegmentConfig,
680
+ ): SegmentData {
681
+ const pct = Math.round(blockInfo.nativeUtilization);
682
+ const timeStr = formatLongTimeRemaining(blockInfo.timeRemaining);
683
+ const blockBudget = this.config.budget?.block;
684
+ const warningThreshold = blockBudget?.warningThreshold ?? 80;
685
+
686
+ let bgColor = colors.blockBg;
687
+ let fgColor = colors.blockFg;
688
+ let bold = colors.blockBold;
689
+ if (pct >= warningThreshold) {
690
+ bgColor = colors.contextCriticalBg;
691
+ fgColor = colors.contextCriticalFg;
692
+ bold = colors.contextCriticalBold;
693
+ } else if (pct >= 50) {
694
+ bgColor = colors.contextWarningBg;
695
+ fgColor = colors.contextWarningFg;
696
+ bold = colors.contextWarningBold;
697
+ }
698
+
699
+ return {
700
+ text: `${this.leadingIcon(this.symbols.block_cost, config)}${this.formatPercentageWithBar(pct, config?.displayStyle, timeStr)}`,
701
+ bgColor,
702
+ fgColor,
703
+ bold,
704
+ };
705
+ }
706
+
707
+ renderWeekly(
708
+ hookData: ClaudeHookData,
709
+ colors: PowerlineColors,
710
+ config?: WeeklySegmentConfig,
711
+ ): SegmentData | null {
712
+ const sevenDay = hookData.rate_limits?.seven_day;
713
+ if (!sevenDay) return null;
714
+
715
+ const pct = Math.round(sevenDay.used_percentage);
716
+ const timeStr = formatLongTimeRemaining(
717
+ minutesUntilReset(sevenDay.resets_at),
718
+ );
719
+
720
+ let bgColor = colors.weeklyBg;
721
+ let fgColor = colors.weeklyFg;
722
+ let bold = colors.weeklyBold;
723
+ if (pct >= 80) {
724
+ bgColor = colors.contextCriticalBg;
725
+ fgColor = colors.contextCriticalFg;
726
+ bold = colors.contextCriticalBold;
727
+ } else if (pct >= 50) {
728
+ bgColor = colors.contextWarningBg;
729
+ fgColor = colors.contextWarningFg;
730
+ bold = colors.contextWarningBold;
731
+ }
732
+
733
+ return {
734
+ text: `${this.leadingIcon(this.symbols.weekly_cost, config)}${this.formatPercentageWithBar(pct, config?.displayStyle, timeStr)}`,
735
+ bgColor,
736
+ fgColor,
737
+ bold,
738
+ };
739
+ }
740
+
741
+ renderToday(
742
+ todayInfo: TodayInfo,
743
+ colors: PowerlineColors,
744
+ configOrType?: TodaySegmentConfig | string,
745
+ ): SegmentData | null {
746
+ const config: TodaySegmentConfig | undefined =
747
+ typeof configOrType === "string"
748
+ ? ({ enabled: true, type: configOrType } as TodaySegmentConfig)
749
+ : configOrType;
750
+ const type = config?.type ?? "cost";
751
+ const todayBudget = this.config.budget?.today;
752
+ const formattedUsage = this.formatUsageWithBudget(
753
+ todayInfo.cost,
754
+ todayInfo.tokens,
755
+ todayInfo.tokenBreakdown,
756
+ type,
757
+ todayBudget,
758
+ config?.showUnits ?? true,
759
+ );
760
+
761
+ if (formattedUsage === null) return null;
762
+
763
+ const text = `${this.leadingIcon(this.symbols.today_cost, config)}${formattedUsage}`;
764
+
765
+ return {
766
+ text,
767
+ bgColor: colors.todayBg,
768
+ fgColor: colors.todayFg,
769
+ };
770
+ }
771
+
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
+ renderVersion(
839
+ hookData: ClaudeHookData,
840
+ colors: PowerlineColors,
841
+ config?: VersionSegmentConfig,
842
+ ): SegmentData | null {
843
+ if (!hookData.version) {
844
+ return null;
845
+ }
846
+
847
+ return {
848
+ text: `${this.leadingIcon(this.symbols.version, config)}v${hookData.version}`,
849
+ bgColor: colors.versionBg,
850
+ fgColor: colors.versionFg,
851
+ };
852
+ }
853
+
854
+ renderEnv(
855
+ colors: PowerlineColors,
856
+ config: EnvSegmentConfig,
857
+ ): SegmentData | null {
858
+ const value = globalThis.process?.env?.[config.variable];
859
+ if (!value) return null;
860
+ const prefix = config.prefix ?? config.variable;
861
+ const iconPrefix = this.leadingIcon(this.symbols.env, config);
862
+ const text = prefix
863
+ ? `${iconPrefix}${prefix}: ${value}`
864
+ : `${iconPrefix}${value}`;
865
+ return { text, bgColor: colors.envBg, fgColor: colors.envFg };
866
+ }
867
+
868
+ renderAgent(
869
+ hookData: ClaudeHookData,
870
+ colors: PowerlineColors,
871
+ config?: AgentSegmentConfig,
872
+ ): SegmentData | null {
873
+ const rawName = hookData.agent?.name;
874
+ if (typeof rawName !== "string") return null;
875
+ const name = rawName.trim();
876
+ if (!name) return null;
877
+
878
+ const iconPrefix = this.leadingIcon(this.symbols.agent, config);
879
+ const body = config?.showLabel ? `agent: ${name}` : name;
880
+
881
+ return {
882
+ text: `${iconPrefix}${body}`,
883
+ bgColor: colors.agentBg,
884
+ fgColor: colors.agentFg,
885
+ };
886
+ }
887
+
888
+ renderThinking(
889
+ hookData: ClaudeHookData,
890
+ colors: PowerlineColors,
891
+ config?: ThinkingSegmentConfig,
892
+ ): SegmentData | null {
893
+ const showEnabled = config?.showEnabled ?? true;
894
+ const showEffort = config?.showEffort ?? true;
895
+ if (!showEnabled && !showEffort) return null;
896
+
897
+ const enabled = showEnabled ? getThinkingEnabled(hookData) : null;
898
+ const level = showEffort ? getEffortLevel(hookData) : null;
899
+
900
+ const parts: string[] = [];
901
+ if (enabled !== null) parts.push(enabled ? "On" : "Off");
902
+ if (level) parts.push(level);
903
+ if (parts.length === 0) return null;
904
+
905
+ const iconPrefix = this.leadingIcon(this.symbols.thinking, config);
906
+ return {
907
+ text: `${iconPrefix}${parts.join(" · ")}`,
908
+ bgColor: colors.thinkingBg,
909
+ fgColor: colors.thinkingFg,
910
+ };
911
+ }
912
+
913
+ renderCacheTimer(
914
+ info: CacheTimerInfo,
915
+ colors: PowerlineColors,
916
+ config?: CacheTimerSegmentConfig,
917
+ ): SegmentData {
918
+ const e = info.elapsedSeconds;
919
+ const iconPrefix = this.leadingIcon(this.symbols.cache_timer, config);
920
+
921
+ let bgColor = colors.cacheTimerBg;
922
+ let fgColor = colors.cacheTimerFg;
923
+ let bold = colors.cacheTimerBold;
924
+
925
+ if (config?.displayMode === "remaining") {
926
+ const ttl = config.ttlSeconds ?? info.detectedTtlSeconds ?? 3600;
927
+ const remaining = Math.max(0, ttl - e);
928
+ const text = `${iconPrefix}${formatCacheTimerRemaining(remaining)}`;
929
+ if (remaining < 60) {
930
+ bgColor = colors.contextCriticalBg;
931
+ fgColor = colors.contextCriticalFg;
932
+ bold = colors.contextCriticalBold;
933
+ } else if (remaining < 300) {
934
+ bgColor = colors.contextWarningBg;
935
+ fgColor = colors.contextWarningFg;
936
+ bold = colors.contextWarningBold;
937
+ }
938
+ return { text, bgColor, fgColor, bold };
939
+ }
940
+
941
+ const text = `${iconPrefix}${formatCacheTimerElapsed(e)}`;
942
+ if (e >= 300) {
943
+ bgColor = colors.contextCriticalBg;
944
+ fgColor = colors.contextCriticalFg;
945
+ bold = colors.contextCriticalBold;
946
+ } else if (e >= 180) {
947
+ bgColor = colors.contextWarningBg;
948
+ fgColor = colors.contextWarningFg;
949
+ bold = colors.contextWarningBold;
950
+ }
951
+
952
+ return { text, bgColor, fgColor, bold };
953
+ }
954
+
955
+ renderBastra(
956
+ info: BastraInfo | null,
957
+ colors: PowerlineColors,
958
+ config?: BastraSegmentConfig,
959
+ ): SegmentData | null {
960
+ if (!info) return null;
961
+
962
+ const icon = this.leadingIcon(this.symbols.bastra, config);
963
+ let text: string;
964
+
965
+ if (info.state === "idle" || info.recallCount === 0) {
966
+ text = `${icon}bastra · ${info.vaultSize} memories`;
967
+ } else if (info.currentStage && info.currentStageStartedAt) {
968
+ // Active recall: live total = accumulated + the running recall's elapsed.
969
+ const live =
970
+ info.currentRecallStartedAt !== null
971
+ ? info.totalMs + Math.max(0, Date.now() - info.currentRecallStartedAt)
972
+ : info.totalMs;
973
+ // Prefer the human-readable banter phrase; fall back to the raw stage
974
+ // name (banter off, or older feed without current_message).
975
+ const label = info.currentMessage ?? info.currentStage;
976
+ text = `${icon}bastra · ${info.recallCount} calls · ${info.totalHits} hits · ${live}ms · ${label}`;
977
+ } else {
978
+ // Between/after recalls in this turn — done snapshot. The running-state
979
+ // phrase is too brief (~120ms) for the ≥1s statusline refresh to catch,
980
+ // so the forwarder persists a done phrase here; show it for a short TTL,
981
+ // then fall back to the bare banner.
982
+ const PHRASE_TTL_MS = 10_000;
983
+ const showPhrase =
984
+ info.lastPhrase !== null &&
985
+ info.lastPhraseAt !== null &&
986
+ Date.now() - info.lastPhraseAt < PHRASE_TTL_MS;
987
+ const tail = showPhrase ? ` · ${info.lastPhrase}` : "";
988
+ // hits come from recalls only; ms from any tool call. Show each only
989
+ // when present, so load_memory-only turns read "N calls · Xms" (no hits).
990
+ const hits = info.totalHits > 0 ? ` · ${info.totalHits} hits` : "";
991
+ const ms = info.totalMs > 0 ? ` · ${info.totalMs}ms` : "";
992
+ text = `${icon}✓ bastra · ${info.recallCount} calls${hits}${ms}${tail}`;
993
+ }
994
+
995
+ return {
996
+ text,
997
+ bgColor: colors.bastraBg,
998
+ fgColor: colors.bastraFg,
999
+ bold: colors.bastraBold,
1000
+ };
1001
+ }
1002
+ }