@owloops/claude-powerline 1.24.4 → 1.25.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 (46) hide show
  1. package/dist/browser.d.ts +676 -0
  2. package/dist/browser.js +3 -0
  3. package/dist/index.mjs +10 -10
  4. package/package.json +9 -1
  5. package/plugin/templates/config-tui-compact.json +4 -4
  6. package/plugin/templates/config-tui-full.json +5 -5
  7. package/plugin/templates/config-tui-standard.json +5 -5
  8. package/src/browser.ts +203 -0
  9. package/src/config/defaults.ts +79 -0
  10. package/src/config/loader.ts +462 -0
  11. package/src/index.ts +90 -0
  12. package/src/powerline.ts +904 -0
  13. package/src/segments/block.ts +31 -0
  14. package/src/segments/context.ts +221 -0
  15. package/src/segments/git.ts +492 -0
  16. package/src/segments/index.ts +25 -0
  17. package/src/segments/metrics.ts +175 -0
  18. package/src/segments/pricing.ts +454 -0
  19. package/src/segments/renderer.ts +796 -0
  20. package/src/segments/session.ts +207 -0
  21. package/src/segments/tmux.ts +35 -0
  22. package/src/segments/today.ts +191 -0
  23. package/src/themes/dark.ts +52 -0
  24. package/src/themes/gruvbox.ts +52 -0
  25. package/src/themes/index.ts +131 -0
  26. package/src/themes/light.ts +52 -0
  27. package/src/themes/nord.ts +52 -0
  28. package/src/themes/rose-pine.ts +52 -0
  29. package/src/themes/tokyo-night.ts +52 -0
  30. package/src/tui/grid.ts +712 -0
  31. package/src/tui/index.ts +4 -0
  32. package/src/tui/layouts.ts +285 -0
  33. package/src/tui/primitives.ts +175 -0
  34. package/src/tui/renderer.ts +206 -0
  35. package/src/tui/sections.ts +1080 -0
  36. package/src/tui/types.ts +181 -0
  37. package/src/utils/budget.ts +47 -0
  38. package/src/utils/cache.ts +247 -0
  39. package/src/utils/claude.ts +489 -0
  40. package/src/utils/color-support.ts +118 -0
  41. package/src/utils/colors.ts +120 -0
  42. package/src/utils/constants.ts +176 -0
  43. package/src/utils/formatters.ts +160 -0
  44. package/src/utils/logger.ts +5 -0
  45. package/src/utils/terminal-width.ts +117 -0
  46. package/src/utils/terminal.ts +11 -0
@@ -0,0 +1,1080 @@
1
+ import type { PowerlineConfig } from "../config/loader";
2
+ import type { PowerlineColors } from "../themes";
3
+ import type {
4
+ TuiData,
5
+ SymbolSet,
6
+ BoxChars,
7
+ RenderCtx,
8
+ SegmentTemplate,
9
+ JustifyValue,
10
+ TuiTitleConfig,
11
+ } from "./types";
12
+ import { visibleLength } from "../utils/terminal";
13
+
14
+ import {
15
+ formatCost,
16
+ formatTokenCount,
17
+ collapseHome,
18
+ formatDuration,
19
+ formatModelName,
20
+ formatResponseTime,
21
+ formatTimeRemaining,
22
+ formatLongTimeRemaining,
23
+ minutesUntilReset,
24
+ abbreviateFishStyle,
25
+ } from "../utils/formatters";
26
+ import { getBudgetStatus } from "../utils/budget";
27
+ import { colorize, truncateAnsi } from "./primitives";
28
+
29
+ export function resolveTitleToken(
30
+ template: string,
31
+ data: TuiData,
32
+ resolvedData?: Record<string, string>,
33
+ ): string {
34
+ const rawName = data.hookData.model?.display_name || "Claude";
35
+ const modelName = formatModelName(rawName).toLowerCase();
36
+
37
+ return template.replace(/\{([^}]+)\}/g, (_match, token: string) => {
38
+ if (resolvedData) {
39
+ const value = resolvedData[token];
40
+ if (value !== undefined) return value;
41
+ }
42
+ if (token === "model") return modelName;
43
+ return "";
44
+ });
45
+ }
46
+
47
+ export function buildTitleBar(
48
+ data: TuiData,
49
+ box: BoxChars,
50
+ innerWidth: number,
51
+ titleConfig?: TuiTitleConfig,
52
+ resolvedData?: Record<string, string>,
53
+ ): string {
54
+ const leftTemplate = titleConfig?.left ?? "{model}";
55
+ const rightTemplate = titleConfig?.right;
56
+ const leftResolved = resolveTitleToken(leftTemplate, data, resolvedData);
57
+ const leftText = leftResolved ? ` ${leftResolved} ` : "";
58
+ const leftLen = visibleLength(leftText);
59
+
60
+ if (!rightTemplate) {
61
+ const simpleFill = innerWidth - leftLen;
62
+ return (
63
+ box.topLeft +
64
+ leftText +
65
+ box.horizontal.repeat(Math.max(0, simpleFill)) +
66
+ box.topRight
67
+ );
68
+ }
69
+
70
+ const rightResolved = resolveTitleToken(rightTemplate, data, resolvedData);
71
+ const rightText = rightResolved ? ` ${rightResolved} ` : "";
72
+ const rightLen = visibleLength(rightText);
73
+
74
+ // Truncate if combined text exceeds innerWidth
75
+ let finalLeft = leftText;
76
+ let finalLeftLen = leftLen;
77
+ let finalRight = rightText;
78
+ let finalRightLen = rightLen;
79
+
80
+ if (finalLeftLen + finalRightLen > innerWidth) {
81
+ const maxLeft = Math.max(0, innerWidth - finalRightLen);
82
+ if (finalLeftLen > maxLeft) {
83
+ finalLeft = truncateAnsi(finalLeft, maxLeft);
84
+ finalLeftLen = visibleLength(finalLeft);
85
+ }
86
+ if (finalLeftLen + finalRightLen > innerWidth) {
87
+ const maxRight = Math.max(0, innerWidth - finalLeftLen);
88
+ finalRight = truncateAnsi(finalRight, maxRight);
89
+ finalRightLen = visibleLength(finalRight);
90
+ }
91
+ }
92
+
93
+ const fillCount = innerWidth - finalLeftLen - finalRightLen;
94
+
95
+ if (fillCount < 2) {
96
+ const simpleFill = innerWidth - finalLeftLen;
97
+ return (
98
+ box.topLeft +
99
+ finalLeft +
100
+ box.horizontal.repeat(Math.max(0, simpleFill)) +
101
+ box.topRight
102
+ );
103
+ }
104
+
105
+ return (
106
+ box.topLeft +
107
+ finalLeft +
108
+ box.horizontal.repeat(fillCount) +
109
+ finalRight +
110
+ box.topRight
111
+ );
112
+ }
113
+
114
+ function resolveThresholdColor(
115
+ pct: number,
116
+ defaultColor: string,
117
+ colors: PowerlineColors,
118
+ warningAt = 60,
119
+ criticalAt = 80,
120
+ ): string {
121
+ if (pct >= criticalAt) return colors.contextCriticalFg;
122
+ if (pct >= warningAt) return colors.contextWarningFg;
123
+ return defaultColor;
124
+ }
125
+
126
+ function buildBarString(
127
+ pct: number,
128
+ barWidth: number,
129
+ sym: SymbolSet,
130
+ reset: string,
131
+ fgColor: string,
132
+ ): string {
133
+ barWidth = Math.max(5, barWidth);
134
+ const filledCount = Math.max(
135
+ 0,
136
+ Math.min(barWidth, Math.round((pct / 100) * barWidth)),
137
+ );
138
+ const emptyCount = barWidth - filledCount;
139
+ const bar =
140
+ sym.bar_filled.repeat(filledCount) + sym.bar_empty.repeat(emptyCount);
141
+ return colorize(bar, fgColor, reset);
142
+ }
143
+
144
+ export function formatContextParts(
145
+ data: TuiData,
146
+ sym: SymbolSet,
147
+ ): Record<string, string> {
148
+ if (!data.contextInfo)
149
+ return { icon: "", label: "context", bar: "", pct: "", tokens: "" };
150
+
151
+ const usedPct = data.contextInfo.usablePercentage;
152
+ const tokenStr = formatTokenCount(data.contextInfo.totalTokens);
153
+ const maxStr = formatTokenCount(data.contextInfo.maxTokens);
154
+
155
+ return {
156
+ icon: sym.context_time,
157
+ label: "context",
158
+ bar: " ",
159
+ pct: `${usedPct}%`,
160
+ tokens: `${tokenStr}/${maxStr}`,
161
+ };
162
+ }
163
+
164
+ export function buildContextBar(
165
+ data: TuiData,
166
+ barWidth: number,
167
+ sym: SymbolSet,
168
+ reset: string,
169
+ colors: PowerlineColors,
170
+ partFg?: Record<string, string>,
171
+ ): string {
172
+ if (!data.contextInfo) return "";
173
+ const usedPct = data.contextInfo.usablePercentage;
174
+ const defaultFg =
175
+ partFg?.["context.bar"] ?? partFg?.["context"] ?? colors.contextFg;
176
+ const fgColor = resolveThresholdColor(usedPct, defaultFg, colors);
177
+ return buildBarString(usedPct, barWidth, sym, reset, fgColor);
178
+ }
179
+
180
+ export function buildBlockBar(
181
+ data: TuiData,
182
+ barWidth: number,
183
+ sym: SymbolSet,
184
+ reset: string,
185
+ colors: PowerlineColors,
186
+ config: PowerlineConfig,
187
+ partFg?: Record<string, string>,
188
+ ): string {
189
+ if (!data.blockInfo) return "";
190
+
191
+ const pct = data.blockInfo.nativeUtilization;
192
+ const warningThreshold = config.budget?.block?.warningThreshold ?? 80;
193
+ const defaultFg =
194
+ partFg?.["block.bar"] ?? partFg?.["block"] ?? colors.blockFg;
195
+ const fgColor = resolveThresholdColor(
196
+ pct,
197
+ defaultFg,
198
+ colors,
199
+ 50,
200
+ warningThreshold,
201
+ );
202
+ return buildBarString(pct, barWidth, sym, reset, fgColor);
203
+ }
204
+
205
+ export function buildWeeklyBar(
206
+ data: TuiData,
207
+ barWidth: number,
208
+ sym: SymbolSet,
209
+ reset: string,
210
+ colors: PowerlineColors,
211
+ partFg?: Record<string, string>,
212
+ ): string {
213
+ const sevenDay = data.hookData.rate_limits?.seven_day;
214
+ if (!sevenDay) return "";
215
+
216
+ const pct = sevenDay.used_percentage;
217
+ const defaultFg =
218
+ partFg?.["weekly.bar"] ?? partFg?.["weekly"] ?? colors.weeklyFg;
219
+ const fgColor = resolveThresholdColor(pct, defaultFg, colors);
220
+ return buildBarString(pct, barWidth, sym, reset, fgColor);
221
+ }
222
+
223
+ export function buildContextLine(
224
+ data: TuiData,
225
+ contentWidth: number,
226
+ sym: SymbolSet,
227
+ reset: string,
228
+ colors: PowerlineColors,
229
+ ): string | null {
230
+ if (!data.contextInfo) {
231
+ return null;
232
+ }
233
+
234
+ const usedPct = data.contextInfo.usablePercentage;
235
+ const tokenStr = formatTokenCount(data.contextInfo.totalTokens);
236
+ const maxStr = formatTokenCount(data.contextInfo.maxTokens);
237
+ const suffix = ` ${usedPct}% ${tokenStr}/${maxStr}`;
238
+ const barLen = Math.max(5, contentWidth - suffix.length);
239
+ const filledCount = Math.max(
240
+ 0,
241
+ Math.min(barLen, Math.round((usedPct / 100) * barLen)),
242
+ );
243
+ const emptyCount = barLen - filledCount;
244
+ const bar =
245
+ sym.bar_filled.repeat(filledCount) + sym.bar_empty.repeat(emptyCount);
246
+
247
+ const fgColor = resolveThresholdColor(usedPct, colors.contextFg, colors);
248
+
249
+ return colorize(`${bar}${suffix}`, fgColor, reset);
250
+ }
251
+
252
+ function getDirectoryDisplay(hookData: TuiData["hookData"]): string {
253
+ const currentDir = hookData.workspace?.current_dir || hookData.cwd || "/";
254
+ return collapseHome(currentDir);
255
+ }
256
+
257
+ export function collectMetricSegments(
258
+ data: TuiData,
259
+ sym: SymbolSet,
260
+ config: PowerlineConfig,
261
+ reset: string,
262
+ colors: PowerlineColors,
263
+ ): string[] {
264
+ const segments: string[] = [];
265
+
266
+ if (data.blockInfo) {
267
+ segments.push(
268
+ colorize(
269
+ formatBlockSegment(data.blockInfo, sym, config),
270
+ colors.blockFg,
271
+ reset,
272
+ ),
273
+ );
274
+ }
275
+ const sevenDay = data.hookData.rate_limits?.seven_day;
276
+ if (sevenDay) {
277
+ segments.push(
278
+ colorize(formatWeeklySegment(sevenDay, sym), colors.weeklyFg, reset),
279
+ );
280
+ }
281
+ if (data.usageInfo) {
282
+ segments.push(
283
+ colorize(
284
+ formatSessionSegment(data.usageInfo, sym, config),
285
+ colors.sessionFg,
286
+ reset,
287
+ ),
288
+ );
289
+ }
290
+ if (data.todayInfo) {
291
+ segments.push(
292
+ colorize(
293
+ formatTodaySegment(data.todayInfo, sym, config),
294
+ colors.todayFg,
295
+ reset,
296
+ ),
297
+ );
298
+ }
299
+
300
+ const activityParts = collectActivityParts(data, sym);
301
+ if (activityParts.length > 0) {
302
+ segments.push(colorize(activityParts.join(" · "), colors.metricsFg, reset));
303
+ }
304
+
305
+ return segments;
306
+ }
307
+
308
+ export function collectActivityParts(data: TuiData, sym: SymbolSet): string[] {
309
+ const parts: string[] = [];
310
+ if (data.metricsInfo) {
311
+ if (
312
+ data.metricsInfo.sessionDuration !== null &&
313
+ data.metricsInfo.sessionDuration > 0
314
+ ) {
315
+ parts.push(
316
+ `${sym.metrics_duration} ${formatDuration(data.metricsInfo.sessionDuration)}`,
317
+ );
318
+ }
319
+ if (
320
+ data.metricsInfo.messageCount !== null &&
321
+ data.metricsInfo.messageCount > 0
322
+ ) {
323
+ parts.push(`${sym.metrics_messages} ${data.metricsInfo.messageCount}`);
324
+ }
325
+ }
326
+ return parts;
327
+ }
328
+
329
+ export function collectWorkspaceParts(
330
+ data: TuiData,
331
+ sym: SymbolSet,
332
+ reset: string,
333
+ colors: PowerlineColors,
334
+ ): string[] {
335
+ const parts: string[] = [];
336
+
337
+ const gitStr = formatGitSegment(data, sym);
338
+ if (gitStr) parts.push(colorize(gitStr, colors.gitFg, reset));
339
+
340
+ const dir = abbreviateFishStyle(getDirectoryDisplay(data.hookData));
341
+ parts.push(colorize(dir, colors.modeFg, reset));
342
+
343
+ return parts;
344
+ }
345
+
346
+ export function collectFooterParts(
347
+ data: TuiData,
348
+ sym: SymbolSet,
349
+ config: PowerlineConfig,
350
+ reset: string,
351
+ colors: PowerlineColors,
352
+ ): string[] {
353
+ const parts: string[] = [];
354
+
355
+ if (data.hookData.version) {
356
+ parts.push(
357
+ colorize(
358
+ `${sym.version} v${data.hookData.version}`,
359
+ colors.versionFg,
360
+ reset,
361
+ ),
362
+ );
363
+ }
364
+ if (data.tmuxSessionId) {
365
+ parts.push(colorize(`tmux:${data.tmuxSessionId}`, colors.tmuxFg, reset));
366
+ }
367
+
368
+ if (data.metricsInfo) {
369
+ const metricParts: string[] = [];
370
+ if (
371
+ data.metricsInfo.responseTime !== null &&
372
+ !isNaN(data.metricsInfo.responseTime) &&
373
+ data.metricsInfo.responseTime > 0
374
+ ) {
375
+ metricParts.push(
376
+ `${sym.metrics_response} ${formatResponseTime(data.metricsInfo.responseTime)}`,
377
+ );
378
+ }
379
+ if (
380
+ data.metricsInfo.linesAdded !== null &&
381
+ data.metricsInfo.linesAdded > 0
382
+ ) {
383
+ metricParts.push(
384
+ `${sym.metrics_lines_added}${data.metricsInfo.linesAdded}`,
385
+ );
386
+ }
387
+ if (
388
+ data.metricsInfo.linesRemoved !== null &&
389
+ data.metricsInfo.linesRemoved > 0
390
+ ) {
391
+ metricParts.push(
392
+ `${sym.metrics_lines_removed}${data.metricsInfo.linesRemoved}`,
393
+ );
394
+ }
395
+ if (metricParts.length > 0) {
396
+ parts.push(colorize(metricParts.join(" · "), colors.metricsFg, reset));
397
+ }
398
+ }
399
+
400
+ const envConfig = config.display.lines
401
+ .map((line) => line.segments.env)
402
+ .find((env) => env?.enabled);
403
+
404
+ if (envConfig && envConfig.variable) {
405
+ const envVal = globalThis.process?.env?.[envConfig.variable];
406
+ if (envVal) {
407
+ const prefix = envConfig.prefix ?? envConfig.variable;
408
+ parts.push(
409
+ colorize(prefix ? `${prefix}:${envVal}` : envVal, colors.envFg, reset),
410
+ );
411
+ }
412
+ }
413
+
414
+ return parts;
415
+ }
416
+
417
+ export function formatBlockParts(
418
+ blockInfo: TuiData["blockInfo"] & {},
419
+ sym: SymbolSet,
420
+ _config: PowerlineConfig,
421
+ ): Record<string, string> {
422
+ const value = `${Math.round(blockInfo.nativeUtilization)}%`;
423
+ const time = formatTimeRemaining(blockInfo.timeRemaining);
424
+
425
+ return {
426
+ icon: sym.block_cost,
427
+ label: "block",
428
+ value,
429
+ time,
430
+ budget: "",
431
+ bar: " ",
432
+ };
433
+ }
434
+
435
+ export function formatBlockSegment(
436
+ blockInfo: TuiData["blockInfo"] & {},
437
+ sym: SymbolSet,
438
+ config: PowerlineConfig,
439
+ ): string {
440
+ const parts = formatBlockParts(blockInfo, sym, config);
441
+ let text = `${parts.icon} ${parts.value}`;
442
+ if (parts.time) text += ` · ${parts.time}`;
443
+ if (parts.budget) text += parts.budget;
444
+ return text;
445
+ }
446
+
447
+ export function formatWeeklyParts(
448
+ sevenDay: { used_percentage: number; resets_at: number },
449
+ sym: SymbolSet,
450
+ ): Record<string, string> {
451
+ const pct = `${Math.round(sevenDay.used_percentage)}%`;
452
+ const time = formatLongTimeRemaining(minutesUntilReset(sevenDay.resets_at));
453
+ return { icon: sym.weekly_cost, label: "weekly", pct, time, bar: " " };
454
+ }
455
+
456
+ export function formatWeeklySegment(
457
+ sevenDay: { used_percentage: number; resets_at: number },
458
+ sym: SymbolSet,
459
+ ): string {
460
+ const parts = formatWeeklyParts(sevenDay, sym);
461
+ let text = `${parts.icon} ${parts.pct}`;
462
+ if (parts.time) text += ` · ${parts.time}`;
463
+ return text;
464
+ }
465
+
466
+ export function formatSessionParts(
467
+ usageInfo: TuiData["usageInfo"] & {},
468
+ sym: SymbolSet,
469
+ config: PowerlineConfig,
470
+ ): Record<string, string> {
471
+ const sessionTokens = usageInfo.session.tokens;
472
+ const tokenStr =
473
+ sessionTokens !== null && sessionTokens > 0
474
+ ? formatTokenCount(sessionTokens)
475
+ : "";
476
+
477
+ let budget = "";
478
+ const sessionBudget = config.budget?.session;
479
+ if (sessionBudget?.amount && usageInfo.session.cost !== null) {
480
+ budget = getBudgetStatus(
481
+ usageInfo.session.cost,
482
+ sessionBudget.amount,
483
+ sessionBudget.warningThreshold || 80,
484
+ ).displayText;
485
+ }
486
+
487
+ return {
488
+ icon: sym.session_cost,
489
+ label: "session",
490
+ cost: formatCost(usageInfo.session.cost),
491
+ tokens: tokenStr,
492
+ budget,
493
+ };
494
+ }
495
+
496
+ export function formatSessionSegment(
497
+ usageInfo: TuiData["usageInfo"] & {},
498
+ sym: SymbolSet,
499
+ config: PowerlineConfig,
500
+ ): string {
501
+ const parts = formatSessionParts(usageInfo, sym, config);
502
+ let text = `${parts.icon} ${parts.cost}`;
503
+ if (parts.tokens) text += ` · ${parts.tokens}`;
504
+ if (parts.budget) text += parts.budget;
505
+ return text;
506
+ }
507
+
508
+ export function formatTodayParts(
509
+ todayInfo: TuiData["todayInfo"] & {},
510
+ sym: SymbolSet,
511
+ config: PowerlineConfig,
512
+ ): Record<string, string> {
513
+ let budget = "";
514
+ const todayBudget = config.budget?.today;
515
+ if (todayBudget?.amount && todayInfo.cost !== null) {
516
+ budget = getBudgetStatus(
517
+ todayInfo.cost,
518
+ todayBudget.amount,
519
+ todayBudget.warningThreshold || 80,
520
+ ).displayText;
521
+ }
522
+
523
+ return {
524
+ icon: sym.today_cost,
525
+ cost: formatCost(todayInfo.cost),
526
+ label: "today",
527
+ budget,
528
+ };
529
+ }
530
+
531
+ export function formatTodaySegment(
532
+ todayInfo: TuiData["todayInfo"] & {},
533
+ sym: SymbolSet,
534
+ config: PowerlineConfig,
535
+ ): string {
536
+ const parts = formatTodayParts(todayInfo, sym, config);
537
+ let text = `${parts.icon} ${parts.cost} ${parts.label}`;
538
+ if (parts.budget) text += parts.budget;
539
+ return text;
540
+ }
541
+
542
+ function formatMetricsParts(
543
+ data: TuiData,
544
+ sym: SymbolSet,
545
+ ): Record<string, string> {
546
+ const empty = {
547
+ response: "",
548
+ responseIcon: "",
549
+ responseVal: "",
550
+ lastResponse: "",
551
+ lastResponseIcon: "",
552
+ lastResponseVal: "",
553
+ added: "",
554
+ addedIcon: "",
555
+ addedVal: "",
556
+ removed: "",
557
+ removedIcon: "",
558
+ removedVal: "",
559
+ };
560
+ if (!data.metricsInfo) return empty;
561
+
562
+ const hasResponse =
563
+ data.metricsInfo.responseTime !== null &&
564
+ !isNaN(data.metricsInfo.responseTime) &&
565
+ data.metricsInfo.responseTime > 0;
566
+ const responseValStr = hasResponse
567
+ ? formatResponseTime(data.metricsInfo.responseTime!)
568
+ : "";
569
+
570
+ const hasLast =
571
+ data.metricsInfo.lastResponseTime !== null &&
572
+ !isNaN(data.metricsInfo.lastResponseTime) &&
573
+ data.metricsInfo.lastResponseTime > 0;
574
+ const lastValStr = hasLast
575
+ ? formatResponseTime(data.metricsInfo.lastResponseTime!)
576
+ : "";
577
+
578
+ const hasAdded =
579
+ data.metricsInfo.linesAdded !== null && data.metricsInfo.linesAdded > 0;
580
+ const addedValStr = hasAdded ? `${data.metricsInfo.linesAdded}` : "";
581
+
582
+ const hasRemoved =
583
+ data.metricsInfo.linesRemoved !== null && data.metricsInfo.linesRemoved > 0;
584
+ const removedValStr = hasRemoved ? `${data.metricsInfo.linesRemoved}` : "";
585
+
586
+ return {
587
+ response: hasResponse ? `${sym.metrics_response} ${responseValStr}` : "",
588
+ responseIcon: hasResponse ? sym.metrics_response : "",
589
+ responseVal: responseValStr,
590
+ lastResponse: hasLast
591
+ ? `${sym.metrics_last_response} ${lastValStr}`
592
+ : `${sym.metrics_last_response} --`,
593
+ lastResponseIcon: sym.metrics_last_response,
594
+ lastResponseVal: hasLast ? lastValStr : "--",
595
+ added: hasAdded ? `${sym.metrics_lines_added}${addedValStr}` : "",
596
+ addedIcon: hasAdded ? sym.metrics_lines_added : "",
597
+ addedVal: addedValStr,
598
+ removed: hasRemoved ? `${sym.metrics_lines_removed}${removedValStr}` : "",
599
+ removedIcon: hasRemoved ? sym.metrics_lines_removed : "",
600
+ removedVal: removedValStr,
601
+ };
602
+ }
603
+
604
+ function formatMetricsSegment(data: TuiData, sym: SymbolSet): string {
605
+ const parts = formatMetricsParts(data, sym);
606
+ const filled = [
607
+ parts.response,
608
+ parts.lastResponse,
609
+ parts.added,
610
+ parts.removed,
611
+ ].filter(Boolean);
612
+ return filled.length > 0 ? filled.join(" · ") : "";
613
+ }
614
+
615
+ function formatActivityParts(
616
+ data: TuiData,
617
+ sym: SymbolSet,
618
+ ): Record<string, string> {
619
+ const empty = {
620
+ icon: "",
621
+ duration: "",
622
+ durationIcon: "",
623
+ durationVal: "",
624
+ messages: "",
625
+ messagesIcon: "",
626
+ messagesVal: "",
627
+ };
628
+ if (!data.metricsInfo) return empty;
629
+
630
+ const hasDuration =
631
+ data.metricsInfo.sessionDuration !== null &&
632
+ data.metricsInfo.sessionDuration > 0;
633
+ const durationValStr = hasDuration
634
+ ? formatDuration(data.metricsInfo.sessionDuration!)
635
+ : "";
636
+
637
+ const hasMessages =
638
+ data.metricsInfo.messageCount !== null && data.metricsInfo.messageCount > 0;
639
+ const messagesValStr = hasMessages ? `${data.metricsInfo.messageCount}` : "";
640
+
641
+ return {
642
+ icon: sym.activity,
643
+ duration: hasDuration ? `${sym.metrics_duration} ${durationValStr}` : "",
644
+ durationIcon: hasDuration ? sym.metrics_duration : "",
645
+ durationVal: durationValStr,
646
+ messages: hasMessages ? `${sym.metrics_messages} ${messagesValStr}` : "",
647
+ messagesIcon: hasMessages ? sym.metrics_messages : "",
648
+ messagesVal: messagesValStr,
649
+ };
650
+ }
651
+
652
+ function formatActivitySegment(data: TuiData, sym: SymbolSet): string {
653
+ const parts = formatActivityParts(data, sym);
654
+ const filled = [parts.duration, parts.messages].filter(Boolean);
655
+ return filled.length > 0 ? filled.join(" · ") : "";
656
+ }
657
+
658
+ function formatGitParts(data: TuiData, sym: SymbolSet): Record<string, string> {
659
+ if (!data.gitInfo)
660
+ return {
661
+ icon: "",
662
+ headVal: "",
663
+ branch: "",
664
+ status: "",
665
+ ahead: "",
666
+ behind: "",
667
+ working: "",
668
+ head: "",
669
+ };
670
+
671
+ let statusIcon: string;
672
+ if (data.gitInfo.status === "conflicts") {
673
+ statusIcon = sym.git_conflicts;
674
+ } else if (data.gitInfo.status === "dirty") {
675
+ statusIcon = sym.git_dirty;
676
+ } else {
677
+ statusIcon = sym.git_clean;
678
+ }
679
+
680
+ const ahead =
681
+ data.gitInfo.ahead > 0 ? `${sym.git_ahead}${data.gitInfo.ahead}` : "";
682
+ const behind =
683
+ data.gitInfo.behind > 0 ? `${sym.git_behind}${data.gitInfo.behind}` : "";
684
+
685
+ const counts: string[] = [];
686
+ if (data.gitInfo.staged && data.gitInfo.staged > 0)
687
+ counts.push(`+${data.gitInfo.staged}`);
688
+ if (data.gitInfo.unstaged && data.gitInfo.unstaged > 0)
689
+ counts.push(`~${data.gitInfo.unstaged}`);
690
+ if (data.gitInfo.untracked && data.gitInfo.untracked > 0)
691
+ counts.push(`?${data.gitInfo.untracked}`);
692
+ const working = counts.length > 0 ? `(${counts.join(" ")})` : "";
693
+
694
+ const headParts = [sym.branch, data.gitInfo.branch, statusIcon];
695
+ if (ahead) headParts.push(ahead);
696
+ if (behind) headParts.push(behind);
697
+
698
+ const infoParts = [data.gitInfo.branch, statusIcon];
699
+ if (ahead) infoParts.push(ahead);
700
+ if (behind) infoParts.push(behind);
701
+
702
+ return {
703
+ icon: sym.branch,
704
+ headVal: infoParts.join(" "),
705
+ branch: data.gitInfo.branch,
706
+ status: statusIcon,
707
+ ahead,
708
+ behind,
709
+ working,
710
+ head: headParts.join(" "),
711
+ };
712
+ }
713
+
714
+ function formatGitSegment(data: TuiData, sym: SymbolSet): string {
715
+ const parts = formatGitParts(data, sym);
716
+ if (!parts.icon) return "";
717
+ let text = `${parts.icon} ${parts.branch} ${parts.status}`;
718
+ if (parts.ahead) text += ` ${parts.ahead}`;
719
+ if (parts.behind) text += `${parts.behind}`;
720
+ if (parts.working) text += ` ${parts.working}`;
721
+ return text;
722
+ }
723
+
724
+ function formatDirParts(
725
+ data: TuiData,
726
+ config: PowerlineConfig,
727
+ sym: SymbolSet,
728
+ ): Record<string, string> {
729
+ return { icon: sym.dir, value: formatDirValue(data, config) };
730
+ }
731
+
732
+ function formatDirValue(data: TuiData, config: PowerlineConfig): string {
733
+ const raw = getDirectoryDisplay(data.hookData);
734
+ const dirConfig = config.display.lines
735
+ .map((line) => line.segments.directory)
736
+ .find((d) => d?.enabled);
737
+ const style =
738
+ dirConfig?.style ?? (dirConfig?.showBasename ? "basename" : "fish");
739
+ if (style === "basename") {
740
+ const sep = raw.includes("/") ? "/" : "\\";
741
+ return raw.split(sep).pop() || raw;
742
+ }
743
+ if (style === "full") return raw;
744
+ return abbreviateFishStyle(raw);
745
+ }
746
+
747
+ function formatVersionParts(
748
+ data: TuiData,
749
+ sym: SymbolSet,
750
+ ): Record<string, string> {
751
+ if (!data.hookData.version) return { icon: "", value: "" };
752
+ return { icon: sym.version, value: `v${data.hookData.version}` };
753
+ }
754
+
755
+ function formatVersionSegment(data: TuiData, sym: SymbolSet): string {
756
+ const parts = formatVersionParts(data, sym);
757
+ if (!parts.icon) return "";
758
+ return `${parts.icon} ${parts.value}`;
759
+ }
760
+
761
+ function formatTmuxParts(data: TuiData): Record<string, string> {
762
+ if (!data.tmuxSessionId) return { label: "", value: "" };
763
+ return { label: "tmux", value: data.tmuxSessionId };
764
+ }
765
+
766
+ function formatTmuxSegment(data: TuiData): string {
767
+ const parts = formatTmuxParts(data);
768
+ if (!parts.label) return "";
769
+ return `${parts.label}:${parts.value}`;
770
+ }
771
+
772
+ function formatEnvParts(config: PowerlineConfig): Record<string, string> {
773
+ const envConfig = config.display.lines
774
+ .map((line) => line.segments.env)
775
+ .find((env) => env?.enabled);
776
+
777
+ if (!envConfig || !envConfig.variable) return { prefix: "", value: "" };
778
+ const envVal = globalThis.process?.env?.[envConfig.variable];
779
+ if (!envVal) return { prefix: "", value: "" };
780
+ const prefix = envConfig.prefix ?? envConfig.variable;
781
+ return { prefix: prefix || "", value: envVal };
782
+ }
783
+
784
+ function formatEnvSegment(config: PowerlineConfig): string {
785
+ const parts = formatEnvParts(config);
786
+ if (!parts.value) return "";
787
+ return parts.prefix ? `${parts.prefix}:${parts.value}` : parts.value;
788
+ }
789
+
790
+ function addParts(
791
+ result: Record<string, string>,
792
+ segment: string,
793
+ parts: Record<string, string>,
794
+ color: string,
795
+ reset: string,
796
+ partFg?: Record<string, string>,
797
+ ): void {
798
+ for (const [key, value] of Object.entries(parts)) {
799
+ const partKey = `${segment}.${key}`;
800
+ const partColor = partFg?.[partKey] ?? partFg?.[segment] ?? color;
801
+ result[partKey] = value ? colorize(value, partColor, reset) : "";
802
+ }
803
+ }
804
+
805
+ // --- Template Composition ---
806
+
807
+ export interface ResolvedTemplate {
808
+ items: string[];
809
+ gap: number;
810
+ justify: JustifyValue;
811
+ }
812
+
813
+ function resolveTemplateItems(
814
+ template: SegmentTemplate,
815
+ segmentRef: string,
816
+ resolvedData: Record<string, string>,
817
+ ): string[] {
818
+ const dotIdx = segmentRef.indexOf(".");
819
+ const baseSegment = dotIdx !== -1 ? segmentRef.slice(0, dotIdx) : segmentRef;
820
+
821
+ return template.items
822
+ .map((item) => {
823
+ const match = item.match(/^\{(.+)\}$/);
824
+ if (!match) return item ? colorize(item, "", "") : "";
825
+ const partName = match[1]!;
826
+ const key = `${baseSegment}.${partName}`;
827
+ return resolvedData[key] ?? "";
828
+ })
829
+ .filter(Boolean);
830
+ }
831
+
832
+ export function composeTemplate(
833
+ items: string[],
834
+ gap: number,
835
+ justify: JustifyValue,
836
+ cellWidth?: number,
837
+ ): string {
838
+ if (items.length === 0) return "";
839
+
840
+ if (justify === "between" && cellWidth !== undefined && items.length > 1) {
841
+ const totalContent = items.reduce(
842
+ (sum, item) => sum + visibleLength(item),
843
+ 0,
844
+ );
845
+ const totalGap = Math.max(
846
+ gap * (items.length - 1),
847
+ cellWidth - totalContent,
848
+ );
849
+ const baseGap = Math.floor(totalGap / (items.length - 1));
850
+ const extraSpaces = totalGap % (items.length - 1);
851
+
852
+ let result = items[0]!;
853
+ for (let i = 1; i < items.length; i++) {
854
+ result += " ".repeat(baseGap + (i <= extraSpaces ? 1 : 0)) + items[i];
855
+ }
856
+ return result;
857
+ }
858
+
859
+ return items.join(" ".repeat(gap));
860
+ }
861
+
862
+ export interface ResolvedSegments {
863
+ data: Record<string, string>;
864
+ templates: Record<string, ResolvedTemplate>;
865
+ }
866
+
867
+ export function resolveSegments(
868
+ data: TuiData,
869
+ ctx: RenderCtx,
870
+ ): ResolvedSegments {
871
+ const { sym, config, reset, colors } = ctx;
872
+ const pf = colors.partFg;
873
+
874
+ const colorizeOrEmpty = (text: string, color: string): string =>
875
+ text ? colorize(text, color, reset) : "";
876
+
877
+ const result: Record<string, string> = {};
878
+
879
+ // Model
880
+ const rawModelName = data.hookData.model?.display_name || "Claude";
881
+ const modelName = formatModelName(rawModelName).toLowerCase();
882
+ const modelColor = pf?.["model"] ?? colors.modelFg;
883
+ result.model = colorizeOrEmpty(`${sym.model} ${modelName}`, modelColor);
884
+ addParts(
885
+ result,
886
+ "model",
887
+ { icon: sym.model, value: modelName },
888
+ colors.modelFg,
889
+ reset,
890
+ pf,
891
+ );
892
+
893
+ // Context (bar is width-dependent, resolved later via lateResolve)
894
+ const contextLine = buildContextLine(
895
+ data,
896
+ ctx.contentWidth,
897
+ sym,
898
+ reset,
899
+ colors,
900
+ );
901
+ result.context = contextLine ?? "";
902
+ const ctxParts = formatContextParts(data, sym);
903
+ const ctxColor = data.contextInfo
904
+ ? resolveThresholdColor(
905
+ data.contextInfo.usablePercentage,
906
+ colors.contextFg,
907
+ colors,
908
+ )
909
+ : colors.contextFg;
910
+ addParts(result, "context", ctxParts, ctxColor, reset, pf);
911
+
912
+ // Block
913
+ if (data.blockInfo) {
914
+ const blockColor = pf?.["block"] ?? colors.blockFg;
915
+ result.block = colorizeOrEmpty(
916
+ formatBlockSegment(data.blockInfo, sym, config),
917
+ blockColor,
918
+ );
919
+ addParts(
920
+ result,
921
+ "block",
922
+ formatBlockParts(data.blockInfo, sym, config),
923
+ colors.blockFg,
924
+ reset,
925
+ pf,
926
+ );
927
+ } else {
928
+ result.block = "";
929
+ }
930
+
931
+ // Session
932
+ if (data.usageInfo) {
933
+ const sessionColor = pf?.["session"] ?? colors.sessionFg;
934
+ result.session = colorizeOrEmpty(
935
+ formatSessionSegment(data.usageInfo, sym, config),
936
+ sessionColor,
937
+ );
938
+ addParts(
939
+ result,
940
+ "session",
941
+ formatSessionParts(data.usageInfo, sym, config),
942
+ colors.sessionFg,
943
+ reset,
944
+ pf,
945
+ );
946
+ } else {
947
+ result.session = "";
948
+ }
949
+
950
+ // Today
951
+ if (data.todayInfo) {
952
+ const todayColor = pf?.["today"] ?? colors.todayFg;
953
+ result.today = colorizeOrEmpty(
954
+ formatTodaySegment(data.todayInfo, sym, config),
955
+ todayColor,
956
+ );
957
+ addParts(
958
+ result,
959
+ "today",
960
+ formatTodayParts(data.todayInfo, sym, config),
961
+ colors.todayFg,
962
+ reset,
963
+ pf,
964
+ );
965
+ } else {
966
+ result.today = "";
967
+ }
968
+
969
+ // Weekly
970
+ const sevenDay = data.hookData.rate_limits?.seven_day;
971
+ if (sevenDay) {
972
+ const weeklyColor = pf?.["weekly"] ?? colors.weeklyFg;
973
+ result.weekly = colorizeOrEmpty(
974
+ formatWeeklySegment(sevenDay, sym),
975
+ weeklyColor,
976
+ );
977
+ addParts(
978
+ result,
979
+ "weekly",
980
+ formatWeeklyParts(sevenDay, sym),
981
+ colors.weeklyFg,
982
+ reset,
983
+ pf,
984
+ );
985
+ } else {
986
+ result.weekly = "";
987
+ }
988
+
989
+ // Git
990
+ const gitColor = pf?.["git"] ?? colors.gitFg;
991
+ result.git = colorizeOrEmpty(formatGitSegment(data, sym), gitColor);
992
+ addParts(result, "git", formatGitParts(data, sym), colors.gitFg, reset, pf);
993
+
994
+ // Dir
995
+ const dirColor = pf?.["dir"] ?? colors.modeFg;
996
+ result.dir = colorizeOrEmpty(formatDirValue(data, config), dirColor);
997
+ addParts(
998
+ result,
999
+ "dir",
1000
+ formatDirParts(data, config, sym),
1001
+ colors.modeFg,
1002
+ reset,
1003
+ pf,
1004
+ );
1005
+
1006
+ // Version
1007
+ const versionColor = pf?.["version"] ?? colors.versionFg;
1008
+ result.version = colorizeOrEmpty(
1009
+ formatVersionSegment(data, sym),
1010
+ versionColor,
1011
+ );
1012
+ addParts(
1013
+ result,
1014
+ "version",
1015
+ formatVersionParts(data, sym),
1016
+ colors.versionFg,
1017
+ reset,
1018
+ pf,
1019
+ );
1020
+
1021
+ // Tmux
1022
+ const tmuxColor = pf?.["tmux"] ?? colors.tmuxFg;
1023
+ result.tmux = colorizeOrEmpty(formatTmuxSegment(data), tmuxColor);
1024
+ addParts(result, "tmux", formatTmuxParts(data), colors.tmuxFg, reset, pf);
1025
+
1026
+ // Metrics
1027
+ const metricsColor = pf?.["metrics"] ?? colors.metricsFg;
1028
+ result.metrics = colorizeOrEmpty(
1029
+ formatMetricsSegment(data, sym),
1030
+ metricsColor,
1031
+ );
1032
+ addParts(
1033
+ result,
1034
+ "metrics",
1035
+ formatMetricsParts(data, sym),
1036
+ colors.metricsFg,
1037
+ reset,
1038
+ pf,
1039
+ );
1040
+
1041
+ // Activity
1042
+ const activityColor = pf?.["activity"] ?? colors.metricsFg;
1043
+ result.activity = colorizeOrEmpty(
1044
+ formatActivitySegment(data, sym),
1045
+ activityColor,
1046
+ );
1047
+ addParts(
1048
+ result,
1049
+ "activity",
1050
+ formatActivityParts(data, sym),
1051
+ colors.metricsFg,
1052
+ reset,
1053
+ pf,
1054
+ );
1055
+
1056
+ // Env
1057
+ const envColor = pf?.["env"] ?? colors.envFg;
1058
+ result.env = colorizeOrEmpty(formatEnvSegment(config), envColor);
1059
+ addParts(result, "env", formatEnvParts(config), colors.envFg, reset, pf);
1060
+
1061
+ // Apply segment templates: resolve items and compose default value
1062
+ const templates: Record<string, ResolvedTemplate> = {};
1063
+ const segmentConfigs = config.display.tui?.segments;
1064
+ if (segmentConfigs) {
1065
+ for (const [segRef, tmpl] of Object.entries(segmentConfigs)) {
1066
+ const items = resolveTemplateItems(tmpl, segRef, result);
1067
+ const gap = tmpl.gap ?? 1;
1068
+ const justify = tmpl.justify ?? "start";
1069
+ templates[segRef] = { items, gap, justify };
1070
+ // Compose default (without cell width for "between")
1071
+ result[segRef] = composeTemplate(
1072
+ items,
1073
+ gap,
1074
+ justify === "between" ? "start" : justify,
1075
+ );
1076
+ }
1077
+ }
1078
+
1079
+ return { data: result, templates };
1080
+ }